import type { MarketRecord } from "./types"; import type { AudienceProfileDistributionItem, AudienceProfileKind, AudienceProfileResult, AudienceProfileSuccess } from "./audience-profile-types"; interface FetchResponseLike { json(): Promise; ok: boolean; } type FetchLike = ( input: string, init?: RequestInit ) => Promise; export type AudienceProfileRequestTarget = | { linkType: number; source: "audienceDistribution"; } | { authorType: number; source: "fansDistribution"; }; interface AudienceProfileClientOptions { baseUrl?: string; fetchImpl?: FetchLike; timeoutMs?: number; } type DistributionSection = | "age" | "cityTier" | "cityTop" | "crowd" | "gender" | "interest" | "province"; const SECTION_BY_DISPLAY: Array<[RegExp, DistributionSection]> = [ [/性别/, "gender"], [/年龄/, "age"], [/省份|全国省份/, "province"], [/城市分布|地域/, "cityTop"], [/城市等级/, "cityTier"], [/兴趣/, "interest"], [/八大人群/, "crowd"] ]; const GENDER_LABELS: Record = { female: "女性", male: "男性" }; const AGE_ORDER = ["18-23", "24-30", "31-40", "41-50", "50+"]; const CITY_TIER_ORDER = ["一线", "新一线", "二线", "三线", "四线", "五线"]; export const AUDIENCE_PROFILE_TARGETS: Record< AudienceProfileKind, AudienceProfileRequestTarget > = { audience: { linkType: 5, source: "audienceDistribution" }, fans: { authorType: 1, source: "fansDistribution" }, longtimeFans: { authorType: 5, source: "fansDistribution" } }; export function createAudienceProfileClient( options: AudienceProfileClientOptions = {} ) { const baseUrl = options.baseUrl ?? resolveBaseUrl(); const fetchImpl = options.fetchImpl ?? defaultFetch; const timeoutMs = options.timeoutMs ?? 8000; return { async loadAudienceProfile( record: MarketRecord, target: AudienceProfileRequestTarget ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetchImpl( buildAudienceProfileUrl(record.authorId, baseUrl, target), { credentials: "include", method: "GET", signal: controller.signal } ); if (!response.ok) { return { failureReason: "request-failed", status: "failed" }; } return mapAudienceProfileResponse(await response.json()); } catch (error) { return { failureReason: error instanceof Error && error.name === "AbortError" ? "timeout" : "request-failed", status: "failed" }; } finally { clearTimeout(timeoutId); } } }; } export function buildAudienceProfileUrl( authorId: string, baseUrl: string, target: AudienceProfileRequestTarget ): string { const url = new URL( target.source === "audienceDistribution" ? "/gw/api/data_sp/author_audience_distribution" : "/gw/api/data_sp/get_author_fans_distribution", baseUrl ); url.searchParams.set("o_author_id", authorId); url.searchParams.set("platform_source", "1"); if (target.source === "audienceDistribution") { url.searchParams.set("platform_channel", "1"); url.searchParams.set("link_type", String(target.linkType)); } else { url.searchParams.set("author_type", String(target.authorType)); } return url.toString(); } export function mapAudienceProfileResponse( payload: unknown ): AudienceProfileResult { if (!isRecord(payload) || !Array.isArray(payload.distributions)) { return { failureReason: "bad-response", status: "failed" }; } const profile: AudienceProfileSuccess = { status: "success" }; payload.distributions.forEach((section) => { if (!isRecord(section)) { return; } const display = readString(section.type_display); const sectionName = resolveSection(display); if (!sectionName || !Array.isArray(section.distribution_list)) { return; } profile[sectionName] = normalizeDistributionItems( section.distribution_list, sectionName ); }); if (Object.keys(profile).length === 1) { return { failureReason: "missing-profile", status: "failed" }; } return profile; } function normalizeDistributionItems( rawItems: unknown[], sectionName: DistributionSection ): AudienceProfileDistributionItem[] { const parsedItems = rawItems .map((item) => { if (!isRecord(item)) { return null; } const key = readString(item.distribution_key); const value = readNumber(item.distribution_value); if (!key || value === null) { return null; } return { label: normalizeLabel(key, sectionName), rawLabel: key, value }; }) .filter((item): item is { label: string; rawLabel: string; value: number } => Boolean(item) ); const total = parsedItems.reduce((sum, item) => sum + item.value, 0); if (total <= 0) { return []; } return parsedItems .sort((left, right) => compareDistributionItems(left, right, sectionName)) .map((item) => ({ label: item.label, value: formatPercent(item.value / total) })); } function compareDistributionItems( left: { rawLabel: string; value: number }, right: { rawLabel: string; value: number }, sectionName: DistributionSection ): number { if (sectionName === "age") { return orderIndex(AGE_ORDER, left.rawLabel) - orderIndex(AGE_ORDER, right.rawLabel); } if (sectionName === "cityTier") { return ( orderIndex(CITY_TIER_ORDER, left.rawLabel) - orderIndex(CITY_TIER_ORDER, right.rawLabel) ); } return right.value - left.value; } function orderIndex(order: string[], value: string): number { const index = order.indexOf(value); return index === -1 ? order.length : index; } function normalizeLabel(label: string, sectionName: DistributionSection): string { if (sectionName === "gender") { return GENDER_LABELS[label] ?? label; } if (sectionName === "cityTier" && !label.endsWith("城市")) { return `${label}城市`; } return label; } function resolveSection(display: string | null): DistributionSection | null { if (!display) { return null; } return ( SECTION_BY_DISPLAY.find(([pattern]) => pattern.test(display))?.[1] ?? null ); } function formatPercent(value: number): string { const percent = Math.round(value * 1000) / 10; return `${Number.isInteger(percent) ? percent.toFixed(0) : percent.toFixed(1)}%`; } function readString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } 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 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; }