diff --git a/src/content/market/audience-profile-client.ts b/src/content/market/audience-profile-client.ts index 2285048..e175641 100644 --- a/src/content/market/audience-profile-client.ts +++ b/src/content/market/audience-profile-client.ts @@ -1,6 +1,7 @@ import type { MarketRecord } from "./types"; import type { AudienceProfileDistributionItem, + AudienceProfileKind, AudienceProfileResult, AudienceProfileSuccess } from "./audience-profile-types"; @@ -18,7 +19,6 @@ type FetchLike = ( interface AudienceProfileClientOptions { baseUrl?: string; fetchImpl?: FetchLike; - linkType?: number; timeoutMs?: number; } @@ -49,16 +49,24 @@ const GENDER_LABELS: Record = { const AGE_ORDER = ["18-23", "24-30", "31-40", "41-50", "50+"]; const CITY_TIER_ORDER = ["一线", "新一线", "二线", "三线", "四线", "五线"]; +export const AUDIENCE_PROFILE_LINK_TYPES: Record = { + audience: 3, + fans: 1, + longtimeFans: 4 +}; + export function createAudienceProfileClient( options: AudienceProfileClientOptions = {} ) { const baseUrl = options.baseUrl ?? resolveBaseUrl(); const fetchImpl = options.fetchImpl ?? defaultFetch; const timeoutMs = options.timeoutMs ?? 8000; - const linkType = options.linkType ?? 1; return { - async loadAudienceProfile(record: MarketRecord): Promise { + async loadAudienceProfile( + record: MarketRecord, + linkType: number + ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); @@ -98,7 +106,7 @@ export function createAudienceProfileClient( export function buildAudienceProfileUrl( authorId: string, baseUrl: string, - linkType = 1 + linkType = 3 ): string { const url = new URL("/gw/api/data_sp/author_audience_distribution", baseUrl); url.searchParams.set("o_author_id", authorId); diff --git a/src/content/market/audience-profile-csv.ts b/src/content/market/audience-profile-csv.ts index 51a727f..7170782 100644 --- a/src/content/market/audience-profile-csv.ts +++ b/src/content/market/audience-profile-csv.ts @@ -1,11 +1,10 @@ import { escapeCsvCell } from "../../shared/csv"; -import { - buildMarketCsvColumns, - type CsvColumn -} from "./csv-exporter"; +import { buildMarketCsvColumns, type CsvColumn } from "./csv-exporter"; import type { AudienceProfileDistributionItem, - AudienceProfileExportRow + AudienceProfileExportRow, + AudienceProfileKind, + AudienceProfileResult } from "./audience-profile-types"; type AudienceProfileCsvColumn = { @@ -13,44 +12,18 @@ type AudienceProfileCsvColumn = { readValue: (row: AudienceProfileExportRow) => string; }; +const PROFILE_LAYOUTS: Array<{ + includeGender: boolean; + kind: AudienceProfileKind; + label: string; +}> = [ + { includeGender: true, kind: "audience", label: "观众画像" }, + { includeGender: true, kind: "fans", label: "粉丝画像" }, + { includeGender: false, kind: "longtimeFans", label: "铁粉画像" } +]; + const GENDER_LABELS = ["男性", "女性"]; const AGE_LABELS = ["18-23", "24-30", "31-40", "41-50", "50+"]; -const PROVINCE_LABELS = [ - "北京", - "天津", - "河北", - "山西", - "内蒙古", - "辽宁", - "吉林", - "黑龙江", - "上海", - "江苏", - "浙江", - "安徽", - "福建", - "江西", - "山东", - "河南", - "湖北", - "湖南", - "广东", - "广西", - "海南", - "重庆", - "四川", - "贵州", - "云南", - "西藏", - "陕西", - "甘肃", - "青海", - "宁夏", - "新疆", - "香港", - "澳门", - "台湾" -]; const CITY_TIER_LABELS = [ "一线城市", "新一线城市", @@ -75,8 +48,8 @@ export function buildAudienceProfileCsv( ): string { const marketColumns = buildMarketCsvColumns(rows.map((row) => row.record)); const csvColumns = [ - ...marketColumns.map(toAudienceProfileColumn), - ...buildAudienceProfileColumns() + ...marketColumns.map(toMarketColumn), + ...PROFILE_LAYOUTS.flatMap((layout) => buildProfileColumns(layout)) ]; const headerLine = csvColumns.map((column) => column.header).join(","); const rowLines = rows.map((row) => @@ -86,95 +59,76 @@ export function buildAudienceProfileCsv( return [headerLine, ...rowLines].join("\n"); } -function toAudienceProfileColumn( - column: CsvColumn -): AudienceProfileCsvColumn { +function toMarketColumn(column: CsvColumn): AudienceProfileCsvColumn { return { header: column.header, readValue: (row) => column.readValue(row.record) }; } -function buildAudienceProfileColumns(): AudienceProfileCsvColumn[] { - return [ - { - header: "画像抓取状态", - readValue: (row) => (row.profile.status === "success" ? "成功" : "失败") - }, - { - header: "画像失败原因", - readValue: (row) => - row.profile.status === "failed" ? row.profile.failureReason ?? "" : "" - }, - ...buildFixedDistributionColumns("连接用户", "gender", GENDER_LABELS), - ...buildFixedDistributionColumns("连接用户", "age", AGE_LABELS), - ...buildFixedDistributionColumns("省份", "province", PROVINCE_LABELS), - ...buildRankedDistributionColumns("地域", "cityTop", 10), - ...buildFixedDistributionColumns("城市等级", "cityTier", CITY_TIER_LABELS), - ...buildRankedDistributionColumns("兴趣", "interest", 10), - ...buildFixedDistributionColumns("八大人群", "crowd", CROWD_LABELS) - ]; -} - -function buildFixedDistributionColumns( - prefix: string, - key: "age" | "cityTier" | "crowd" | "gender" | "province", - labels: string[] -): AudienceProfileCsvColumn[] { - return labels.map((label) => ({ - header: `${prefix}-${label}占比`, - readValue: (row) => readDistributionValue(row, key, label) - })); -} - -function buildRankedDistributionColumns( - prefix: string, - key: "cityTop" | "interest", - count: number -): AudienceProfileCsvColumn[] { +function buildProfileColumns(layout: { + includeGender: boolean; + kind: AudienceProfileKind; + label: string; +}): AudienceProfileCsvColumn[] { const columns: AudienceProfileCsvColumn[] = []; - for (let index = 0; index < count; index += 1) { + + if (layout.includeGender) { columns.push( - { - header: `${prefix}TOP${index + 1}名称`, - readValue: (row) => readDistributionItem(row, key, index)?.label ?? "" - }, - { - header: `${prefix}TOP${index + 1}占比`, - readValue: (row) => readDistributionItem(row, key, index)?.value ?? "" - } + ...buildFixedDistributionColumns( + layout.label, + layout.kind, + "gender", + GENDER_LABELS + ) ); } + columns.push( + ...buildFixedDistributionColumns(layout.label, layout.kind, "age", AGE_LABELS), + ...buildFixedDistributionColumns( + layout.label, + layout.kind, + "cityTier", + CITY_TIER_LABELS + ), + ...buildFixedDistributionColumns(layout.label, layout.kind, "crowd", CROWD_LABELS) + ); + return columns; } +function buildFixedDistributionColumns( + prefix: string, + kind: AudienceProfileKind, + key: "age" | "cityTier" | "crowd" | "gender", + labels: string[] +): AudienceProfileCsvColumn[] { + return labels.map((label) => ({ + header: `${prefix}-${label}占比`, + readValue: (row) => readDistributionValue(row.profiles[kind], key, label) + })); +} + function readDistributionValue( - row: AudienceProfileExportRow, - key: "age" | "cityTier" | "crowd" | "gender" | "province", + profile: AudienceProfileResult, + key: "age" | "cityTier" | "crowd" | "gender", label: string ): string { - return readDistributionItems(row, key).find((item) => item.label === label)?.value ?? ""; + if (profile.status !== "success") { + return ""; + } + + return ( + readProfileDistributionItems(profile, key).find( + (candidate) => candidate.label === label + )?.value ?? "" + ); } -function readDistributionItem( - row: AudienceProfileExportRow, - key: "cityTop" | "interest", - index: number -): AudienceProfileDistributionItem | undefined { - return readDistributionItems(row, key)[index]; -} - -function readDistributionItems( - row: AudienceProfileExportRow, - key: - | "age" - | "cityTier" - | "cityTop" - | "crowd" - | "gender" - | "interest" - | "province" +function readProfileDistributionItems( + profile: AudienceProfileResult, + key: "age" | "cityTier" | "crowd" | "gender" ): AudienceProfileDistributionItem[] { - return row.profile.status === "success" ? row.profile[key] ?? [] : []; + return profile.status === "success" ? profile[key] ?? [] : []; } diff --git a/src/content/market/audience-profile-types.ts b/src/content/market/audience-profile-types.ts index c044f76..1ded9fa 100644 --- a/src/content/market/audience-profile-types.ts +++ b/src/content/market/audience-profile-types.ts @@ -1,5 +1,10 @@ import type { MarketRecord } from "./types"; +export type AudienceProfileKind = + | "audience" + | "fans" + | "longtimeFans"; + export interface AudienceProfileDistributionItem { label: string; value: string; @@ -21,11 +26,17 @@ export interface AudienceProfileFailure { status: "failed"; } +export interface AudienceProfileSet { + audience: AudienceProfileResult; + fans: AudienceProfileResult; + longtimeFans: AudienceProfileResult; +} + export type AudienceProfileResult = | AudienceProfileSuccess | AudienceProfileFailure; export interface AudienceProfileExportRow { - profile: AudienceProfileResult; + profiles: AudienceProfileSet; record: MarketRecord; } diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 9d13089..3d3b83a 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -1,6 +1,9 @@ import { buildMarketCsv } from "./csv-exporter"; import { buildAudienceProfileCsv } from "./audience-profile-csv"; -import { createAudienceProfileClient } from "./audience-profile-client"; +import { + AUDIENCE_PROFILE_LINK_TYPES, + createAudienceProfileClient +} from "./audience-profile-client"; import { promptForBatchName } from "./batch-name-dialog"; import { createBatchPayload, type BatchPayload } from "./batch-payload"; import { @@ -31,6 +34,7 @@ import { import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages"; import type { AudienceProfileExportRow, + AudienceProfileKind, AudienceProfileResult } from "./audience-profile-types"; import type { @@ -52,7 +56,10 @@ export interface CreateMarketControllerOptions { buildCsv?: (records: MarketRecord[]) => string; document: Document; getAuthState?: () => Promise; - loadAudienceProfile?: (record: MarketRecord) => Promise; + loadAudienceProfile?: ( + record: MarketRecord, + linkType: number + ) => Promise; loadAuthorMetrics?: (authorId: string) => Promise; searchBackendMetrics?: (starIds: string[]) => Promise< Array @@ -92,6 +99,17 @@ export function createMarketController(options: CreateMarketControllerOptions) { options.submitBatch ?? ((payload: BatchPayload) => readBatchSubmitAck(sendRuntimeMessage, payload)); + const audienceProfileTargets: Array<{ + kind: AudienceProfileKind; + linkType: number; + }> = [ + { kind: "audience", linkType: AUDIENCE_PROFILE_LINK_TYPES.audience }, + { kind: "fans", linkType: AUDIENCE_PROFILE_LINK_TYPES.fans }, + { + kind: "longtimeFans", + linkType: AUDIENCE_PROFILE_LINK_TYPES.longtimeFans + } + ]; let activeProgressLabel = "导出中"; let shouldShowDetailedProgress = true; const exportRangeController = createExportRangeController({ @@ -208,14 +226,18 @@ export function createMarketController(options: CreateMarketControllerOptions) { toolbar, `画像导出中 ${index + 1}/${selectedRecords.length}...` ); - const profile = await loadAudienceProfile(record); + const profiles = await loadAudienceProfileSet(record); rows.push({ - profile, + profiles, record }); } - if (rows.every((row) => row.profile.status === "failed")) { + if ( + rows.every((row) => + Object.values(row.profiles).every((profile) => profile.status === "failed") + ) + ) { setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试"); return; } @@ -637,6 +659,26 @@ export function createMarketController(options: CreateMarketControllerOptions) { return records.filter((record) => selectedAuthorIds.has(record.authorId)); } + async function loadAudienceProfileSet( + record: MarketRecord + ): Promise { + const profiles = {} as AudienceProfileExportRow["profiles"]; + + for (const { kind, linkType } of audienceProfileTargets) { + try { + profiles[kind] = await loadAudienceProfile(record, linkType); + } catch (error) { + profiles[kind] = { + failureReason: + error instanceof Error ? error.message : "request-failed", + status: "failed" + }; + } + } + + return profiles; + } + async function prepareCurrentPageForExport(): Promise { await runSyncCycle(); await harvestCurrentPageForExport(); diff --git a/tests/audience-profile-client.test.ts b/tests/audience-profile-client.test.ts index 8e39169..a80f08f 100644 --- a/tests/audience-profile-client.test.ts +++ b/tests/audience-profile-client.test.ts @@ -21,10 +21,10 @@ describe("audience-profile-client", () => { authorId: "7294473194298146854", authorName: "奇奇de海洋", status: "success" - }); + }, 3); expect(fetchImpl).toHaveBeenCalledWith( - "https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=1", + "https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=3", expect.objectContaining({ credentials: "include", method: "GET" diff --git a/tests/audience-profile-csv.test.ts b/tests/audience-profile-csv.test.ts index 43a637d..3d286c1 100644 --- a/tests/audience-profile-csv.test.ts +++ b/tests/audience-profile-csv.test.ts @@ -4,21 +4,36 @@ import { buildAudienceProfileCsv } from "../src/content/market/audience-profile- import type { AudienceProfileExportRow } from "../src/content/market/audience-profile-types"; describe("audience-profile-csv", () => { - test("appends structured audience profile columns after the market export columns", () => { + test("exports only requested profile distribution columns", () => { const csv = buildAudienceProfileCsv([ { - profile: { - age: [{ label: "31-40", value: "50%" }], - cityTier: [{ label: "一线城市", value: "100%" }], - cityTop: [{ label: "广州", value: "30.4%" }], - crowd: [{ label: "都市蓝领", value: "100%" }], - gender: [ - { label: "男性", value: "71.7%" }, - { label: "女性", value: "28.3%" } - ], - interest: [{ label: "随拍", value: "100%" }], - province: [{ label: "广东", value: "60%" }], - status: "success" + profiles: { + audience: { + age: [{ label: "31-40", value: "50%" }], + cityTier: [{ label: "一线城市", value: "100%" }], + crowd: [{ label: "都市蓝领", value: "100%" }], + gender: [ + { label: "男性", value: "71.7%" }, + { label: "女性", value: "28.3%" } + ], + status: "success" + }, + fans: { + age: [{ label: "31-40", value: "40%" }], + cityTier: [{ label: "一线城市", value: "80%" }], + crowd: [{ label: "都市蓝领", value: "60%" }], + gender: [ + { label: "男性", value: "60%" }, + { label: "女性", value: "40%" } + ], + status: "success" + }, + longtimeFans: { + age: [{ label: "31-40", value: "30%" }], + cityTier: [{ label: "一线城市", value: "70%" }], + crowd: [{ label: "都市蓝领", value: "50%" }], + status: "success" + } }, record: { authorId: "123", @@ -29,44 +44,55 @@ describe("audience-profile-csv", () => { }, status: "success" } - } - ] satisfies AudienceProfileExportRow[]); + } satisfies AudienceProfileExportRow + ]); const [headerLine, rowLine] = csv.split("\n"); expect(headerLine).toContain("达人信息,连接用户数"); - expect(headerLine).toContain("画像抓取状态"); - expect(headerLine).toContain("连接用户-男性占比"); - expect(headerLine).toContain("连接用户-31-40占比"); - expect(headerLine).toContain("省份-广东占比"); - expect(headerLine).toContain("地域TOP1名称,地域TOP1占比"); - expect(headerLine).toContain("城市等级-一线城市占比"); - expect(headerLine).toContain("兴趣TOP1名称,兴趣TOP1占比"); - expect(headerLine).toContain("八大人群-都市蓝领占比"); - expect(rowLine).toContain("成功"); + expect(headerLine).not.toContain("抓取状态"); + expect(headerLine).not.toContain("失败原因"); + expect(headerLine).toContain("观众画像-男性占比"); + expect(headerLine).toContain("粉丝画像-女性占比"); + expect(headerLine).not.toContain("铁粉画像-男性占比"); + expect(headerLine).toContain("观众画像-31-40占比"); + expect(headerLine).toContain("粉丝画像-一线城市占比"); + expect(headerLine).toContain("铁粉画像-都市蓝领占比"); + expect(headerLine).not.toContain("省份"); + expect(headerLine).not.toContain("地域TOP"); + expect(headerLine).not.toContain("兴趣TOP"); expect(rowLine).toContain("71.7%"); - expect(rowLine).toContain("广州,30.4%"); - expect(rowLine).toContain("随拍,100%"); + expect(rowLine).toContain("60%"); }); - test("keeps failed profile rows and marks their failure reason", () => { + test("leaves distribution cells empty when profile loading fails", () => { const csv = buildAudienceProfileCsv([ { - profile: { - failureReason: "request-failed", - status: "failed" + profiles: { + audience: { + failureReason: "request-failed", + status: "failed" + }, + fans: { + failureReason: "timeout", + status: "failed" + }, + longtimeFans: { + status: "failed" + } }, record: { authorId: "123", authorName: "达人 A", status: "success" } - } - ] satisfies AudienceProfileExportRow[]); + } satisfies AudienceProfileExportRow + ]); const [, rowLine] = csv.split("\n"); - expect(rowLine).toContain("失败"); - expect(rowLine).toContain("request-failed"); + expect(rowLine).not.toContain("失败"); + expect(rowLine).not.toContain("request-failed"); + expect(rowLine).not.toContain("timeout"); }); }); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 5dc6a5e..a53318f 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -1624,10 +1624,24 @@ describe("market-content-entry", () => { { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ]); const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); - const loadAudienceProfile = vi.fn(async () => ({ - gender: [{ label: "男性", value: "60%" }], - status: "success" as const - })); + const loadAudienceProfile = vi.fn(async (_record, linkType: number) => { + if (linkType === 4) { + return { + age: [{ label: "31-40", value: "30%" }], + crowd: [{ label: "都市蓝领", value: "50%" }], + cityTier: [{ label: "一线城市", value: "70%" }], + status: "success" as const + }; + } + + return { + age: [{ label: "31-40", value: "60%" }], + crowd: [{ label: "都市蓝领", value: "80%" }], + cityTier: [{ label: "一线城市", value: "90%" }], + gender: [{ label: "男性", value: "60%" }], + status: "success" as const + }; + }); const onCsvReady = vi.fn(); const { createMarketController } = await import("../src/content/market/index"); @@ -1651,15 +1665,18 @@ describe("market-content-entry", () => { click('[data-plugin-export-audience-profile="button"]'); await waitForMockCall(buildAudienceProfileCsv, 40, 50); - expect(loadAudienceProfile).toHaveBeenCalledTimes(1); - expect(loadAudienceProfile).toHaveBeenCalledWith( - expect.objectContaining({ authorId: "222" }) - ); + expect(loadAudienceProfile).toHaveBeenCalledTimes(3); + expect(loadAudienceProfile.mock.calls.map(([, linkType]) => linkType)).toEqual([ + 3, + 1, + 4 + ]); expect(buildAudienceProfileCsv).toHaveBeenCalledWith([ { - profile: { - gender: [{ label: "男性", value: "60%" }], - status: "success" + profiles: { + audience: expect.objectContaining({ status: "success" }), + fans: expect.objectContaining({ status: "success" }), + longtimeFans: expect.objectContaining({ status: "success" }) }, record: expect.objectContaining({ authorId: "222" }) }