import type { MarketRecord } from "./types"; import type { BusinessAbilityDurationKind, BusinessAbilityEstimateMetrics, BusinessAbilityResult, BusinessAbilitySuccess, BusinessAbilityVideoMetrics } from "./audience-profile-types"; interface FetchResponseLike { json(): Promise; ok: boolean; } type FetchLike = ( input: string, init?: RequestInit ) => Promise; interface BusinessAbilityClientOptions { baseUrl?: string; fetchImpl?: FetchLike; timeoutMs?: number; } const VIDEO_TYPES = { personalVideo: 1, xingtuVideo: 2 } as const; export function createBusinessAbilityClient( options: BusinessAbilityClientOptions = {} ) { const baseUrl = options.baseUrl ?? resolveBaseUrl(); const fetchImpl = options.fetchImpl ?? defaultFetch; const timeoutMs = options.timeoutMs ?? 8000; return { async loadBusinessAbility(record: MarketRecord): Promise { const personalVideo = await loadJson( buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.personalVideo) ); const xingtuVideo = await loadJson( buildBusinessAbilityVideoUrl(record.authorId, baseUrl, VIDEO_TYPES.xingtuVideo) ); const estimates = await loadJson( buildBusinessAbilityEstimateUrl(record.authorId, baseUrl) ); if (!personalVideo.ok || !xingtuVideo.ok || !estimates.ok) { return { failureReason: personalVideo.failureReason ?? xingtuVideo.failureReason ?? estimates.failureReason, status: "failed" }; } return { estimates: mapBusinessAbilityEstimateResponse(estimates.payload), status: "success", videos: { personalVideo: mapBusinessAbilityVideoResponse(personalVideo.payload), xingtuVideo: mapBusinessAbilityVideoResponse(xingtuVideo.payload) } } satisfies BusinessAbilitySuccess; } }; async function loadJson(url: string): Promise< | { ok: true; payload: unknown } | { failureReason: string; ok: false } > { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetchImpl(url, { credentials: "include", method: "GET", signal: controller.signal }); if (!response.ok) { return { failureReason: "request-failed", ok: false }; } return { ok: true, payload: await response.json() }; } catch (error) { return { failureReason: error instanceof Error && error.name === "AbortError" ? "timeout" : "request-failed", ok: false }; } finally { clearTimeout(timeoutId); } } } export function buildBusinessAbilityVideoUrl( authorId: string, baseUrl: string, videoType: number ): string { const url = new URL("/gw/api/data_sp/get_author_spread_info", baseUrl); url.searchParams.set("o_author_id", authorId); url.searchParams.set("platform_source", "1"); url.searchParams.set("platform_channel", "1"); url.searchParams.set("type", String(videoType)); url.searchParams.set("flow_type", "0"); url.searchParams.set("only_assign", "true"); url.searchParams.set("range", "2"); return url.toString(); } export function buildBusinessAbilityEstimateUrl( authorId: string, baseUrl: string ): string { const url = new URL( "/gw/api/aggregator/get_author_commerce_spread_info", baseUrl ); url.searchParams.set("o_author_id", authorId); return url.toString(); } export function mapBusinessAbilityVideoResponse( payload: unknown ): BusinessAbilityVideoMetrics { const data = getPayloadData(payload); return { averageComment: formatWan(readNumber(data?.comment_avg)), averageDuration: formatDuration(readNumber(data?.avg_duration)), averageLike: formatWan(readNumber(data?.like_avg)), averageShare: formatWan(readNumber(data?.share_avg)), finishRate: formatBasisPointRate(readNestedNumber(data, "play_over_rate", "value")), interactionRate: formatBasisPointRate( readNestedNumber(data, "interact_rate", "value") ), medianPlay: formatWan(readNumber(data?.play_mid)), publishedItems: formatPublishedItems(readNumber(data?.item_num)) }; } export function mapBusinessAbilityEstimateResponse( payload: unknown ): Partial> { const data = getPayloadData(payload); const expectedPlay = formatWan(readNumber(data?.vv)); const hotRate = formatDecimalRate(readNumber(data?.platform_hot_rate)); return { oneToTwenty: { expectedCpe: formatDecimal(readNumber(data?.cpe_1_20), 1), expectedCpm: formatFixedDecimal(readNumber(data?.cpm_1_20), 1), expectedPlay, hotRate }, overSixty: { expectedCpe: formatDecimal(readNumber(data?.cpe_60), 1), expectedCpm: formatFixedDecimal(readNumber(data?.cpm_60), 1), expectedPlay, hotRate }, twentyToSixty: { expectedCpe: formatDecimal(readNumber(data?.cpe_20_60), 1), expectedCpm: formatFixedDecimal(readNumber(data?.cpm_20_60), 1), expectedPlay, hotRate } }; } function formatPublishedItems(value: number | null): string { if (value === null) { return ""; } return value > 0 && value < 5 ? "<5" : formatDecimal(value, 0); } function formatDuration(value: number | null): string { if (value === null) { return ""; } return `${formatDecimal(value / 100, 0)}s`; } function formatBasisPointRate(value: number | null): string { if (value === null) { return ""; } return `${formatDecimal(value / 100, 1)}%`; } function formatDecimalRate(value: number | null): string { if (value === null) { return "缺失"; } return `${formatDecimal(value * 100, 0)}%`; } function formatWan(value: number | null): string { if (value === null) { return ""; } if (Math.abs(value) >= 10000) { return `${formatDecimal(value / 10000, 1)}w`; } return formatDecimal(value, 0); } function formatDecimal(value: number | null, digits: number): string { if (value === null || !Number.isFinite(value)) { return ""; } const fixed = value.toFixed(digits); return fixed.replace(/\.0+$/, "").replace(/(\.\d*?)0+$/, "$1"); } function formatFixedDecimal(value: number | null, digits: number): string { if (value === null || !Number.isFinite(value)) { return ""; } return value.toFixed(digits); } function readNestedNumber( data: Record | null, objectKey: string, valueKey: string ): number | null { const objectValue = data?.[objectKey]; if (!isRecord(objectValue)) { return null; } return readNumber(objectValue[valueKey]); } function readNumber(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string" && value.trim()) { const numericValue = Number(value); return Number.isFinite(numericValue) ? numericValue : null; } return null; } function getPayloadData(payload: unknown): Record | null { if (!isRecord(payload)) { return null; } return isRecord(payload.data) ? payload.data : payload; } function resolveBaseUrl(): string { if (typeof location !== "undefined" && location.origin) { return location.origin; } return "https://xingtu.cn"; } async function defaultFetch(input: string, init?: RequestInit) { return fetch(input, init); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; }