diff --git a/src/content/market/audience-profile-client.ts b/src/content/market/audience-profile-client.ts index e175641..ec842b7 100644 --- a/src/content/market/audience-profile-client.ts +++ b/src/content/market/audience-profile-client.ts @@ -16,6 +16,16 @@ type FetchLike = ( init?: RequestInit ) => Promise; +export type AudienceProfileRequestTarget = + | { + linkType: number; + source: "audienceDistribution"; + } + | { + authorType: number; + source: "fansDistribution"; + }; + interface AudienceProfileClientOptions { baseUrl?: string; fetchImpl?: FetchLike; @@ -49,10 +59,13 @@ 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 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( @@ -65,14 +78,14 @@ export function createAudienceProfileClient( return { async loadAudienceProfile( record: MarketRecord, - linkType: number + target: AudienceProfileRequestTarget ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetchImpl( - buildAudienceProfileUrl(record.authorId, baseUrl, linkType), + buildAudienceProfileUrl(record.authorId, baseUrl, target), { credentials: "include", method: "GET", @@ -106,13 +119,22 @@ export function createAudienceProfileClient( export function buildAudienceProfileUrl( authorId: string, baseUrl: string, - linkType = 3 + target: AudienceProfileRequestTarget ): string { - const url = new URL("/gw/api/data_sp/author_audience_distribution", baseUrl); + 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"); - url.searchParams.set("platform_channel", "1"); - url.searchParams.set("link_type", String(linkType)); + 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(); } diff --git a/src/content/market/index.ts b/src/content/market/index.ts index 3d3b83a..9317690 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -1,8 +1,9 @@ import { buildMarketCsv } from "./csv-exporter"; import { buildAudienceProfileCsv } from "./audience-profile-csv"; import { - AUDIENCE_PROFILE_LINK_TYPES, - createAudienceProfileClient + AUDIENCE_PROFILE_TARGETS, + createAudienceProfileClient, + type AudienceProfileRequestTarget } from "./audience-profile-client"; import { promptForBatchName } from "./batch-name-dialog"; import { createBatchPayload, type BatchPayload } from "./batch-payload"; @@ -58,7 +59,7 @@ export interface CreateMarketControllerOptions { getAuthState?: () => Promise; loadAudienceProfile?: ( record: MarketRecord, - linkType: number + target: AudienceProfileRequestTarget ) => Promise; loadAuthorMetrics?: (authorId: string) => Promise; searchBackendMetrics?: (starIds: string[]) => Promise< @@ -101,13 +102,13 @@ export function createMarketController(options: CreateMarketControllerOptions) { readBatchSubmitAck(sendRuntimeMessage, payload)); const audienceProfileTargets: Array<{ kind: AudienceProfileKind; - linkType: number; + target: AudienceProfileRequestTarget; }> = [ - { kind: "audience", linkType: AUDIENCE_PROFILE_LINK_TYPES.audience }, - { kind: "fans", linkType: AUDIENCE_PROFILE_LINK_TYPES.fans }, + { kind: "audience", target: AUDIENCE_PROFILE_TARGETS.audience }, + { kind: "fans", target: AUDIENCE_PROFILE_TARGETS.fans }, { kind: "longtimeFans", - linkType: AUDIENCE_PROFILE_LINK_TYPES.longtimeFans + target: AUDIENCE_PROFILE_TARGETS.longtimeFans } ]; let activeProgressLabel = "导出中"; @@ -664,9 +665,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { ): Promise { const profiles = {} as AudienceProfileExportRow["profiles"]; - for (const { kind, linkType } of audienceProfileTargets) { + for (const { kind, target } of audienceProfileTargets) { try { - profiles[kind] = await loadAudienceProfile(record, linkType); + profiles[kind] = await loadAudienceProfile(record, target); } catch (error) { profiles[kind] = { failureReason: diff --git a/tests/audience-profile-client.test.ts b/tests/audience-profile-client.test.ts index a80f08f..9eeb8c2 100644 --- a/tests/audience-profile-client.test.ts +++ b/tests/audience-profile-client.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import { + AUDIENCE_PROFILE_TARGETS, createAudienceProfileClient, mapAudienceProfileResponse } from "../src/content/market/audience-profile-client"; @@ -21,10 +22,10 @@ describe("audience-profile-client", () => { authorId: "7294473194298146854", authorName: "奇奇de海洋", status: "success" - }, 3); + }, AUDIENCE_PROFILE_TARGETS.audience); 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=3", + "https://www.xingtu.cn/gw/api/data_sp/author_audience_distribution?o_author_id=7294473194298146854&platform_source=1&platform_channel=1&link_type=5", expect.objectContaining({ credentials: "include", method: "GET" @@ -42,6 +43,32 @@ describe("audience-profile-client", () => { ); }); + test("loads fans and iron fan profiles from Xingtu fan distribution endpoint", async () => { + const fetchImpl = vi.fn(async () => ({ + json: async () => buildAudiencePayload(), + ok: true + })); + const client = createAudienceProfileClient({ + baseUrl: "https://www.xingtu.cn", + fetchImpl, + timeoutMs: 1000 + }); + + await client.loadAudienceProfile({ + authorId: "7294473194298146854", + authorName: "奇奇de海洋", + status: "success" + }, AUDIENCE_PROFILE_TARGETS.longtimeFans); + + expect(fetchImpl).toHaveBeenCalledWith( + "https://www.xingtu.cn/gw/api/data_sp/get_author_fans_distribution?o_author_id=7294473194298146854&platform_source=1&author_type=5", + expect.objectContaining({ + credentials: "include", + method: "GET" + }) + ); + }); + test("maps Xingtu audience distribution payload into named profile sections", () => { const result = mapAudienceProfileResponse(buildAudiencePayload()); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index a53318f..9d1a986 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -1624,8 +1624,8 @@ describe("market-content-entry", () => { { authorId: "222", authorName: "达人 B", price21To60s: "¥22,000" } ]); const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); - const loadAudienceProfile = vi.fn(async (_record, linkType: number) => { - if (linkType === 4) { + const loadAudienceProfile = vi.fn(async (_record, target) => { + if (target.source === "fansDistribution" && target.authorType === 5) { return { age: [{ label: "31-40", value: "30%" }], crowd: [{ label: "都市蓝领", value: "50%" }], @@ -1666,10 +1666,10 @@ describe("market-content-entry", () => { await waitForMockCall(buildAudienceProfileCsv, 40, 50); expect(loadAudienceProfile).toHaveBeenCalledTimes(3); - expect(loadAudienceProfile.mock.calls.map(([, linkType]) => linkType)).toEqual([ - 3, - 1, - 4 + expect(loadAudienceProfile.mock.calls.map(([, target]) => target)).toEqual([ + { linkType: 5, source: "audienceDistribution" }, + { authorType: 1, source: "fansDistribution" }, + { authorType: 5, source: "fansDistribution" } ]); expect(buildAudienceProfileCsv).toHaveBeenCalledWith([ {