From 249e6a59718f3a5fcd8e88ddfb78cc8c935c047e Mon Sep 17 00:00:00 2001 From: admin123 Date: Mon, 18 May 2026 18:27:47 +0800 Subject: [PATCH] feat: export business ability metrics --- src/content/market/audience-profile-csv.ts | 93 +++++- src/content/market/audience-profile-types.ts | 45 +++ src/content/market/business-ability-client.ts | 281 ++++++++++++++++++ src/content/market/index.ts | 30 +- tests/audience-profile-csv.test.ts | 48 +++ tests/business-ability-client.test.ts | 138 +++++++++ tests/market-content-entry.test.ts | 11 + 7 files changed, 643 insertions(+), 3 deletions(-) create mode 100644 src/content/market/business-ability-client.ts create mode 100644 tests/business-ability-client.test.ts diff --git a/src/content/market/audience-profile-csv.ts b/src/content/market/audience-profile-csv.ts index b2bbb4c..6a710c3 100644 --- a/src/content/market/audience-profile-csv.ts +++ b/src/content/market/audience-profile-csv.ts @@ -4,7 +4,11 @@ import type { AudienceProfileDistributionItem, AudienceProfileExportRow, AudienceProfileKind, - AudienceProfileResult + AudienceProfileResult, + BusinessAbilityDurationKind, + BusinessAbilityEstimateMetrics, + BusinessAbilityVideoKind, + BusinessAbilityVideoMetrics } from "./audience-profile-types"; type AudienceProfileCsvColumn = { @@ -43,12 +47,54 @@ const CROWD_LABELS = [ "小镇青年" ]; +const BUSINESS_VIDEO_LAYOUTS: Array<{ + key: BusinessAbilityVideoKind; + label: string; +}> = [ + { key: "personalVideo", label: "个人视频" }, + { key: "xingtuVideo", label: "星图视频" } +]; + +const BUSINESS_VIDEO_METRIC_LAYOUTS: Array<{ + key: keyof BusinessAbilityVideoMetrics; + label: string; +}> = [ + { key: "medianPlay", label: "播放量中位数" }, + { key: "finishRate", label: "完播率" }, + { key: "interactionRate", label: "互动率" }, + { key: "publishedItems", label: "发布作品" }, + { key: "averageDuration", label: "平均时长" }, + { key: "averageLike", label: "平均点赞" }, + { key: "averageComment", label: "平均评论" }, + { key: "averageShare", label: "平均转发" } +]; + +const BUSINESS_ESTIMATE_LAYOUTS: Array<{ + key: BusinessAbilityDurationKind; + label: string; +}> = [ + { key: "oneToTwenty", label: "1-20s视频" }, + { key: "twentyToSixty", label: "20-60s视频" }, + { key: "overSixty", label: "60s以上视频" } +]; + +const BUSINESS_ESTIMATE_METRIC_LAYOUTS: Array<{ + key: keyof BusinessAbilityEstimateMetrics; + label: string; +}> = [ + { key: "expectedCpm", label: "预期CPM" }, + { key: "expectedCpe", label: "预期CPE" }, + { key: "expectedPlay", label: "预期播放量" }, + { key: "hotRate", label: "爆文率" } +]; + export function buildAudienceProfileCsv( rows: AudienceProfileExportRow[] ): string { const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); const csvColumns = [ ...marketColumns.map(toMarketColumn), + ...buildBusinessAbilityColumns(), ...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)) ]; const headerLine = csvColumns.map((column) => column.header).join(","); @@ -59,6 +105,51 @@ export function buildAudienceProfileCsv( return [headerLine, ...rowLines].join("\n"); } +function buildBusinessAbilityColumns(): AudienceProfileCsvColumn[] { + return [ + ...BUSINESS_VIDEO_LAYOUTS.flatMap((videoLayout) => + BUSINESS_VIDEO_METRIC_LAYOUTS.map((metricLayout) => ({ + header: `商业能力-${videoLayout.label}-${metricLayout.label}`, + readValue: (row: AudienceProfileExportRow) => + readBusinessVideoValue(row, videoLayout.key, metricLayout.key) + })) + ), + ...BUSINESS_ESTIMATE_LAYOUTS.flatMap((durationLayout) => + BUSINESS_ESTIMATE_METRIC_LAYOUTS.map((metricLayout) => ({ + header: `商业能力-${durationLayout.label}-${metricLayout.label}`, + readValue: (row: AudienceProfileExportRow) => + readBusinessEstimateValue(row, durationLayout.key, metricLayout.key) + })) + ) + ]; +} + +function readBusinessVideoValue( + row: AudienceProfileExportRow, + videoKey: BusinessAbilityVideoKind, + metricKey: keyof BusinessAbilityVideoMetrics +): string { + const businessAbility = row.businessAbility; + if (!businessAbility || businessAbility.status !== "success") { + return ""; + } + + return businessAbility.videos[videoKey]?.[metricKey] ?? ""; +} + +function readBusinessEstimateValue( + row: AudienceProfileExportRow, + durationKey: BusinessAbilityDurationKind, + metricKey: keyof BusinessAbilityEstimateMetrics +): string { + const businessAbility = row.businessAbility; + if (!businessAbility || businessAbility.status !== "success") { + return ""; + } + + return businessAbility.estimates[durationKey]?.[metricKey] ?? ""; +} + function toMarketColumn(column: CsvColumn): AudienceProfileCsvColumn { return { header: column.header, diff --git a/src/content/market/audience-profile-types.ts b/src/content/market/audience-profile-types.ts index 1ded9fa..a05640a 100644 --- a/src/content/market/audience-profile-types.ts +++ b/src/content/market/audience-profile-types.ts @@ -37,6 +37,51 @@ export type AudienceProfileResult = | AudienceProfileFailure; export interface AudienceProfileExportRow { + businessAbility?: BusinessAbilityResult; profiles: AudienceProfileSet; record: MarketRecord; } + +export type BusinessAbilityVideoKind = + | "personalVideo" + | "xingtuVideo"; + +export interface BusinessAbilityVideoMetrics { + averageComment: string; + averageDuration: string; + averageLike: string; + averageShare: string; + finishRate: string; + interactionRate: string; + medianPlay: string; + publishedItems: string; +} + +export type BusinessAbilityDurationKind = + | "oneToTwenty" + | "twentyToSixty" + | "overSixty"; + +export interface BusinessAbilityEstimateMetrics { + expectedCpe: string; + expectedCpm: string; + expectedPlay: string; + hotRate: string; +} + +export interface BusinessAbilitySuccess { + estimates: Partial< + Record + >; + status: "success"; + videos: Partial>; +} + +export interface BusinessAbilityFailure { + failureReason?: string; + status: "failed"; +} + +export type BusinessAbilityResult = + | BusinessAbilitySuccess + | BusinessAbilityFailure; diff --git a/src/content/market/business-ability-client.ts b/src/content/market/business-ability-client.ts new file mode 100644 index 0000000..6ae5e17 --- /dev/null +++ b/src/content/market/business-ability-client.ts @@ -0,0 +1,281 @@ +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: formatDecimal(readNumber(data?.cpm_1_20), 0), + expectedPlay, + hotRate + }, + overSixty: { + expectedCpe: formatDecimal(readNumber(data?.cpe_60), 1), + expectedCpm: formatDecimal(readNumber(data?.cpm_60), 0), + expectedPlay, + hotRate + }, + twentyToSixty: { + expectedCpe: formatDecimal(readNumber(data?.cpe_20_60), 1), + expectedCpm: formatDecimal(readNumber(data?.cpm_20_60), 0), + 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 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; +} diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 9317690..a97720c 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -5,6 +5,7 @@ import { createAudienceProfileClient, type AudienceProfileRequestTarget } from "./audience-profile-client"; +import { createBusinessAbilityClient } from "./business-ability-client"; import { promptForBatchName } from "./batch-name-dialog"; import { createBatchPayload, type BatchPayload } from "./batch-payload"; import { @@ -36,7 +37,8 @@ import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-me import type { AudienceProfileExportRow, AudienceProfileKind, - AudienceProfileResult + AudienceProfileResult, + BusinessAbilityResult } from "./audience-profile-types"; import type { BackendMetrics, @@ -57,6 +59,9 @@ export interface CreateMarketControllerOptions { buildCsv?: (records: MarketRecord[]) => string; document: Document; getAuthState?: () => Promise; + loadBusinessAbility?: ( + record: MarketRecord + ) => Promise; loadAudienceProfile?: ( record: MarketRecord, target: AudienceProfileRequestTarget @@ -78,6 +83,7 @@ export interface CreateMarketControllerOptions { export function createMarketController(options: CreateMarketControllerOptions) { const marketApiClient = createMarketApiClient(); const audienceProfileClient = createAudienceProfileClient(); + const businessAbilityClient = createBusinessAbilityClient(); const sendRuntimeMessage = createRuntimeMessageSender(); const resultStore = options.resultStore ?? createMarketResultStore(); const loadAuthorMetrics = @@ -89,6 +95,8 @@ export function createMarketController(options: CreateMarketControllerOptions) { const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv; const loadAudienceProfile = options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile; + const loadBusinessAbility = + options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility; const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage)); const mutationObserverFactory = options.mutationObserverFactory ?? @@ -227,8 +235,12 @@ export function createMarketController(options: CreateMarketControllerOptions) { toolbar, `画像导出中 ${index + 1}/${selectedRecords.length}...` ); - const profiles = await loadAudienceProfileSet(record); + const [profiles, businessAbility] = await Promise.all([ + loadAudienceProfileSet(record), + loadBusinessAbilitySafe(record) + ]); rows.push({ + businessAbility, profiles, record }); @@ -680,6 +692,20 @@ export function createMarketController(options: CreateMarketControllerOptions) { return profiles; } + async function loadBusinessAbilitySafe( + record: MarketRecord + ): Promise { + try { + return await loadBusinessAbility(record); + } catch (error) { + return { + failureReason: + error instanceof Error ? error.message : "request-failed", + status: "failed" + }; + } + } + async function prepareCurrentPageForExport(): Promise { await runSyncCycle(); await harvestCurrentPageForExport(); diff --git a/tests/audience-profile-csv.test.ts b/tests/audience-profile-csv.test.ts index 8bda508..3e294a1 100644 --- a/tests/audience-profile-csv.test.ts +++ b/tests/audience-profile-csv.test.ts @@ -35,6 +35,45 @@ describe("audience-profile-csv", () => { status: "success" } }, + businessAbility: { + estimates: { + oneToTwenty: { + expectedCpe: "2.1", + expectedCpm: "120", + expectedPlay: "250w", + hotRate: "100%" + }, + twentyToSixty: { + expectedCpe: "3.7", + expectedCpm: "212", + expectedPlay: "250w", + hotRate: "100%" + } + }, + status: "success", + videos: { + personalVideo: { + averageComment: "4.5w", + averageDuration: "150s", + averageLike: "113.2w", + averageShare: "26.5w", + finishRate: "15.8%", + interactionRate: "3.9%", + medianPlay: "3738.4w", + publishedItems: "<5" + }, + xingtuVideo: { + averageComment: "5.1w", + averageDuration: "170s", + averageLike: "150.3w", + averageShare: "68.4w", + finishRate: "19.9%", + interactionRate: "5.5%", + medianPlay: "4059.7w", + publishedItems: "<5" + } + } + }, record: { authorId: "123", authorName: "达人 A", @@ -52,6 +91,11 @@ describe("audience-profile-csv", () => { expect(headerLine).toContain("达人信息,连接用户数"); expect(headerLine).not.toContain("抓取状态"); expect(headerLine).not.toContain("失败原因"); + expect(headerLine).toContain("商业能力-个人视频-播放量中位数"); + expect(headerLine).toContain("商业能力-星图视频-平均转发"); + expect(headerLine).toContain("商业能力-1-20s视频-预期CPM"); + expect(headerLine).toContain("商业能力-20-60s视频-爆文率"); + expect(headerLine).toContain("商业能力-60s以上视频-预期播放量"); expect(headerLine).toContain("观众画像-男性占比"); expect(headerLine).toContain("粉丝画像-女性占比"); expect(headerLine).not.toContain("铁粉画像-男性占比"); @@ -63,6 +107,10 @@ describe("audience-profile-csv", () => { expect(headerLine).not.toContain("兴趣TOP"); expect(rowLine).toContain("71.7%"); expect(rowLine).toContain("60%"); + expect(readCsvValue(csv, "商业能力-个人视频-播放量中位数")).toBe("3738.4w"); + expect(readCsvValue(csv, "商业能力-星图视频-平均转发")).toBe("68.4w"); + expect(readCsvValue(csv, "商业能力-1-20s视频-预期CPM")).toBe("120"); + expect(readCsvValue(csv, "商业能力-20-60s视频-爆文率")).toBe("100%"); }); test("leaves distribution cells empty when profile loading fails", () => { diff --git a/tests/business-ability-client.test.ts b/tests/business-ability-client.test.ts new file mode 100644 index 0000000..a81f6a8 --- /dev/null +++ b/tests/business-ability-client.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + buildBusinessAbilityEstimateUrl, + buildBusinessAbilityVideoUrl, + createBusinessAbilityClient, + mapBusinessAbilityEstimateResponse, + mapBusinessAbilityVideoResponse +} from "../src/content/market/business-ability-client"; + +describe("business-ability-client", () => { + test("builds commercial ability urls used by the Xingtu detail page", () => { + expect( + buildBusinessAbilityVideoUrl( + "6724241209444794382", + "https://www.xingtu.cn", + 2 + ) + ).toBe( + "https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2" + ); + + expect( + buildBusinessAbilityEstimateUrl( + "6724241209444794382", + "https://www.xingtu.cn" + ) + ).toBe( + "https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382" + ); + }); + + test("maps video content metrics into page-style display values", () => { + expect(mapBusinessAbilityVideoResponse(buildVideoPayload())).toEqual({ + averageComment: "5.1w", + averageDuration: "170s", + averageLike: "150.3w", + averageShare: "68.4w", + finishRate: "19.9%", + interactionRate: "5.5%", + medianPlay: "4059.7w", + publishedItems: "<5" + }); + }); + + test("maps duration estimates into page-style display values", () => { + expect(mapBusinessAbilityEstimateResponse(buildEstimatePayload())).toEqual({ + oneToTwenty: { + expectedCpe: "2.1", + expectedCpm: "120", + expectedPlay: "250w", + hotRate: "100%" + }, + overSixty: { + expectedCpe: "4.2", + expectedCpm: "240", + expectedPlay: "250w", + hotRate: "100%" + }, + twentyToSixty: { + expectedCpe: "3.7", + expectedCpm: "212", + expectedPlay: "250w", + hotRate: "100%" + } + }); + }); + + test("loads personal video, Xingtu video, and duration estimate metrics", async () => { + const requestedUrls: string[] = []; + const fetchImpl = vi.fn(async (input: string) => { + requestedUrls.push(input); + return { + json: async () => + input.includes("get_author_commerce_spread_info") + ? buildEstimatePayload() + : buildVideoPayload(), + ok: true + }; + }); + const client = createBusinessAbilityClient({ + baseUrl: "https://www.xingtu.cn", + fetchImpl, + timeoutMs: 1000 + }); + + await expect( + client.loadBusinessAbility({ + authorId: "6724241209444794382", + authorName: "李蠕蠕", + status: "success" + }) + ).resolves.toMatchObject({ + estimates: expect.objectContaining({ + twentyToSixty: expect.objectContaining({ expectedCpm: "212" }) + }), + status: "success", + videos: { + personalVideo: expect.objectContaining({ medianPlay: "4059.7w" }), + xingtuVideo: expect.objectContaining({ medianPlay: "4059.7w" }) + } + }); + + expect(requestedUrls).toEqual([ + "https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=1&flow_type=0&only_assign=true&range=2", + "https://www.xingtu.cn/gw/api/data_sp/get_author_spread_info?o_author_id=6724241209444794382&platform_source=1&platform_channel=1&type=2&flow_type=0&only_assign=true&range=2", + "https://www.xingtu.cn/gw/api/aggregator/get_author_commerce_spread_info?o_author_id=6724241209444794382" + ]); + }); +}); + +function buildVideoPayload() { + return { + avg_duration: 17002, + base_resp: { status_code: 0, status_message: "" }, + comment_avg: 51404, + interact_rate: { value: 551 }, + item_num: 2, + like_avg: 1503028, + play_mid: 40596960, + play_over_rate: { value: 1991 }, + share_avg: 684318 + }; +} + +function buildEstimatePayload() { + return { + base_resp: { status_code: 0, status_message: "" }, + cpe_1_20: "2.1035", + cpe_20_60: "3.7161", + cpe_60: "4.2069", + cpm_1_20: "119.9976", + cpm_20_60: "211.9958", + cpm_60: "239.9953", + platform_hot_rate: "1", + vv: "2500049" + }; +} diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 9d1a986..56bde20 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -1624,6 +1624,11 @@ describe("market-content-entry", () => { { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ]); const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); + const loadBusinessAbility = vi.fn(async () => ({ + estimates: {}, + status: "success" as const, + videos: {} + })); const loadAudienceProfile = vi.fn(async (_record, target) => { if (target.source === "fansDistribution" && target.authorType === 5) { return { @@ -1648,6 +1653,7 @@ describe("market-content-entry", () => { const controller = trackController(createMarketController({ buildAudienceProfileCsv, document, + loadBusinessAbility, loadAudienceProfile, loadAuthorMetrics: async () => ({ success: false, @@ -1666,6 +1672,10 @@ describe("market-content-entry", () => { await waitForMockCall(buildAudienceProfileCsv, 40, 50); expect(loadAudienceProfile).toHaveBeenCalledTimes(3); + expect(loadBusinessAbility).toHaveBeenCalledTimes(1); + expect(loadBusinessAbility).toHaveBeenCalledWith( + expect.objectContaining({ authorId: "222" }) + ); expect(loadAudienceProfile.mock.calls.map(([, target]) => target)).toEqual([ { linkType: 5, source: "audienceDistribution" }, { authorType: 1, source: "fansDistribution" }, @@ -1678,6 +1688,7 @@ describe("market-content-entry", () => { fans: expect.objectContaining({ status: "success" }), longtimeFans: expect.objectContaining({ status: "success" }) }, + businessAbility: expect.objectContaining({ status: "success" }), record: expect.objectContaining({ authorId: "222" }) } ]);