import type { SpreadInfoConfig, SpreadInfoMetrics, SpreadMetricFilterRule } from "./types"; interface FetchResponseLike { json(): Promise; ok: boolean; } type FetchLike = ( input: string, init?: RequestInit ) => Promise; interface SpreadInfoClientOptions { baseUrl?: string; configs?: SpreadInfoConfig[]; fetchImpl?: FetchLike; timeoutMs?: number; } interface SpreadInfoMetricDefinition { key: keyof MappedSpreadInfoResponse; label: string; } export interface MappedSpreadInfoResponse { averageCommentCount?: string; averageDuration?: string; averageLikeCount?: string; averageShareCount?: string; finishRate?: string; interactionRate?: string; playMedian?: string; } const SPREAD_INFO_METRICS: SpreadInfoMetricDefinition[] = [ { key: "finishRate", label: "完播率" }, { key: "playMedian", label: "播放量中位数" }, { key: "interactionRate", label: "互动率" }, { key: "averageDuration", label: "作品平均时长" }, { key: "averageCommentCount", label: "作品平均评论数" }, { key: "averageLikeCount", label: "作品平均点赞数" }, { key: "averageShareCount", label: "作品平均转发数" } ]; export const DEFAULT_SPREAD_INFO_CONFIGS: SpreadInfoConfig[] = [ { flowType: 0, onlyAssign: false, range: 2, type: 1 }, { flowType: 0, onlyAssign: false, range: 3, type: 1 }, ...buildXingtuVideoConfigs() ]; export function createSpreadInfoClient(options: SpreadInfoClientOptions = {}) { const baseUrl = options.baseUrl ?? resolveBaseUrl(); const configs = options.configs ?? DEFAULT_SPREAD_INFO_CONFIGS; const fetchImpl = options.fetchImpl ?? defaultFetch; const timeoutMs = options.timeoutMs ?? 8000; return { async loadAuthorSpreadMetrics(authorId: string): Promise { const metrics: SpreadInfoMetrics = {}; for (const config of configs) { const mappedResponse = await loadSpreadInfoFromUrl( buildSpreadInfoUrl(authorId, config, baseUrl) ); Object.entries(buildSpreadInfoMetricMap(config, mappedResponse)).forEach( ([header, value]) => { metrics[header] = value; } ); } return metrics; }, async loadAuthorSpreadMetricSnapshot( authorId: string, config: SpreadInfoConfig ): Promise { return loadSpreadInfoFromUrl(buildSpreadInfoUrl(authorId, config, baseUrl)); } }; async function loadSpreadInfoFromUrl( url: string ): Promise { 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 {}; } return mapSpreadInfoResponse(await response.json()); } catch { return {}; } finally { clearTimeout(timeoutId); } } } export function buildSpreadInfoUrl( authorId: string, config: SpreadInfoConfig, baseUrl: string ): 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(config.type)); url.searchParams.set("flow_type", String(config.flowType)); url.searchParams.set("only_assign", String(config.onlyAssign)); url.searchParams.set("range", String(config.range)); return url.toString(); } export function buildSpreadInfoColumns( configs: SpreadInfoConfig[] = DEFAULT_SPREAD_INFO_CONFIGS ): string[] { return configs.flatMap((config) => SPREAD_INFO_METRICS.map((metric) => buildSpreadInfoColumnHeader(config, metric)) ); } export function buildSpreadInfoMetricMap( config: SpreadInfoConfig, metrics: MappedSpreadInfoResponse ): SpreadInfoMetrics { const values: SpreadInfoMetrics = {}; SPREAD_INFO_METRICS.forEach((metric) => { const value = metrics[metric.key]; if (hasTextValue(value)) { values[buildSpreadInfoColumnHeader(config, metric)] = value; } }); return values; } export function mapSpreadInfoResponse( payload: unknown ): MappedSpreadInfoResponse { const data = getPayloadData(payload); if (!data) { return {}; } return { averageCommentCount: readStringLike(data.comment_avg), averageDuration: formatMillisecondsAsSeconds(readNumberLike(data.avg_duration)), averageLikeCount: readStringLike(data.like_avg), averageShareCount: readStringLike(data.share_avg), finishRate: formatBasisPointPercent( readNumberLike(readNestedValue(data.play_over_rate, "value")) ), interactionRate: formatBasisPointPercent( readNumberLike(readNestedValue(data.interact_rate, "value")) ), playMedian: readStringLike(data.play_mid) ?? readStringLike(readNestedValue(readNestedValue(data.item_rate, "play_mid"), "value")) }; } export function normalizeSpreadInfoConfig( config: SpreadInfoConfig ): SpreadInfoConfig { return config.type === 1 ? { ...config, flowType: 0, onlyAssign: false } : { ...config }; } export function buildSpreadInfoConfigKey(config: SpreadInfoConfig): string { const normalized = normalizeSpreadInfoConfig(config); return [ normalized.type, normalized.onlyAssign ? 1 : 0, normalized.flowType, normalized.range ].join(":"); } export function matchesSpreadMetricRule( metrics: MappedSpreadInfoResponse, rule: SpreadMetricFilterRule ): boolean { const numericValue = readDisplayNumber(metrics[rule.metric]); return numericValue !== null && numericValue >= rule.threshold; } function buildSpreadInfoColumnHeader( config: SpreadInfoConfig, metric: SpreadInfoMetricDefinition ): string { return ["内容数据", ...buildConfigPrefixParts(config), metric.label].join("-"); } function buildConfigPrefixParts(config: SpreadInfoConfig): string[] { const typeLabel = config.type === 1 ? "个人视频" : "星图视频"; const rangeLabel = config.range === 2 ? "近30天" : "近90天"; if (config.type === 1) { return [typeLabel, rangeLabel]; } return [ config.onlyAssign ? "只看指派" : "不限指派", config.flowType === 1 ? "排除营销流量" : "不排除营销流量", typeLabel, rangeLabel ]; } function buildXingtuVideoConfigs(): SpreadInfoConfig[] { const configs: SpreadInfoConfig[] = []; [false, true].forEach((onlyAssign) => { ([0, 1] as const).forEach((flowType) => { ([2, 3] as const).forEach((range) => { configs.push({ flowType, onlyAssign, range, type: 2 }); }); }); }); return configs; } function getPayloadData(payload: unknown): Record | null { if (!isRecord(payload)) { return null; } return isRecord(payload.data) ? payload.data : payload; } function readNestedValue(value: unknown, key: string): unknown { return isRecord(value) ? value[key] : undefined; } function readStringLike(value: unknown): string | undefined { if (typeof value === "string") { return value; } if (typeof value === "number") { return String(value); } return undefined; } function readNumberLike(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { return value; } if (typeof value === "string" && value.trim().length > 0) { const parsedValue = Number(value); return Number.isFinite(parsedValue) ? parsedValue : null; } return null; } function readDisplayNumber(value: string | undefined): number | null { if (!hasTextValue(value)) { return null; } const parsedValue = Number(value.replace(/[% ,]/g, "")); return Number.isFinite(parsedValue) ? parsedValue : null; } function formatBasisPointPercent(value: number | null): string | undefined { if (value === null) { return undefined; } return `${formatDecimal(value / 100)}%`; } function formatMillisecondsAsSeconds(value: number | null): string | undefined { if (value === null) { return undefined; } return formatDecimal(value / 100); } function formatDecimal(value: number): string { return value.toFixed(2).replace(/\.?0+$/, ""); } function resolveBaseUrl(): string { if (typeof location !== "undefined" && location.origin) { return location.origin; } return "https://www.xingtu.cn"; } async function defaultFetch(input: string, init?: RequestInit) { return fetch(input, init); } function hasTextValue(value: string | undefined): value is string { return typeof value === "string" && value.trim().length > 0; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; }