diff --git a/src/content/market/author-base-client.ts b/src/content/market/author-base-client.ts new file mode 100644 index 0000000..e723ddf --- /dev/null +++ b/src/content/market/author-base-client.ts @@ -0,0 +1,122 @@ +import type { MarketRecord } from "./types"; + +interface FetchResponseLike { + json(): Promise; + ok: boolean; +} + +type FetchLike = ( + input: string, + init?: RequestInit +) => Promise; + +interface AuthorBaseClientOptions { + baseUrl?: string; + fetchImpl?: FetchLike; + timeoutMs?: number; +} + +export function createAuthorBaseClient(options: AuthorBaseClientOptions = {}) { + const baseUrl = options.baseUrl ?? resolveBaseUrl(); + const fetchImpl = options.fetchImpl ?? defaultFetch; + const timeoutMs = options.timeoutMs ?? 8000; + + return { + async loadAuthorBaseInfo(authorId: string): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetchImpl( + buildAuthorBaseInfoUrl(authorId, baseUrl), + { + credentials: "include", + method: "GET", + signal: controller.signal + } + ); + + if (!response.ok) { + return buildFailedRecord(authorId, "request-failed"); + } + + return mapAuthorBaseInfoResponse(authorId, await response.json()); + } catch (error) { + return buildFailedRecord( + authorId, + error instanceof Error && error.name === "AbortError" + ? "timeout" + : "request-failed" + ); + } finally { + clearTimeout(timeoutId); + } + } + }; +} + +export function buildAuthorBaseInfoUrl( + authorId: string, + baseUrl: string +): string { + const url = new URL("/gw/api/author/get_author_base_info", baseUrl); + url.searchParams.set("o_author_id", authorId); + url.searchParams.set("platform_source", "1"); + url.searchParams.set("platform_channel", "1"); + url.searchParams.set("recommend", "true"); + url.searchParams.set("need_sec_uid", "true"); + url.searchParams.set("need_linkage_info", "true"); + return url.toString(); +} + +export function mapAuthorBaseInfoResponse( + authorId: string, + payload: unknown +): MarketRecord { + if (!isRecord(payload)) { + return buildFailedRecord(authorId, "bad-response"); + } + + const authorName = readString(payload.nick_name); + if (!authorName) { + return buildFailedRecord(authorId, "missing-rate"); + } + + return { + authorId, + authorName, + status: "success" + }; +} + +function buildFailedRecord( + authorId: string, + failureReason: MarketRecord["failureReason"] +): MarketRecord { + return { + authorId, + authorName: "", + failureReason, + status: "failed" + }; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : 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; +} diff --git a/src/content/market/author-id-dialog.ts b/src/content/market/author-id-dialog.ts new file mode 100644 index 0000000..9a79e77 --- /dev/null +++ b/src/content/market/author-id-dialog.ts @@ -0,0 +1,130 @@ +export function promptForAuthorIds(document: Document): Promise { + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.dataset.authorIdDialog = "overlay"; + applyOverlayStyles(overlay); + + const dialog = document.createElement("section"); + applyDialogStyles(dialog); + + const title = document.createElement("h2"); + title.textContent = "按星图ID导出画像CSV"; + applyTitleStyles(title); + + const textarea = document.createElement("textarea"); + textarea.dataset.authorIdDialogInput = "textarea"; + textarea.placeholder = "每行一个星图ID,也支持逗号、空格分隔"; + applyTextareaStyles(textarea); + + const hint = document.createElement("p"); + hint.textContent = "粘贴客户提供的达人星图ID,确认后将批量导出画像和商业能力数据。"; + applyHintStyles(hint); + + const actions = document.createElement("div"); + applyActionsStyles(actions); + + const cancelButton = document.createElement("button"); + cancelButton.type = "button"; + cancelButton.textContent = "取消"; + applySecondaryButtonStyles(cancelButton); + + const confirmButton = document.createElement("button"); + confirmButton.type = "button"; + confirmButton.textContent = "开始导出"; + applyPrimaryButtonStyles(confirmButton); + + actions.append(cancelButton, confirmButton); + dialog.append(title, hint, textarea, actions); + overlay.append(dialog); + document.body.appendChild(overlay); + + const close = (value: string | null) => { + overlay.remove(); + resolve(value); + }; + + cancelButton.addEventListener("click", () => close(null)); + confirmButton.addEventListener("click", () => close(textarea.value)); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) { + close(null); + } + }); + textarea.focus(); + }); +} + +function applyOverlayStyles(overlay: HTMLElement): void { + overlay.style.position = "fixed"; + overlay.style.inset = "0"; + overlay.style.zIndex = "2147483647"; + overlay.style.display = "flex"; + overlay.style.alignItems = "center"; + overlay.style.justifyContent = "center"; + overlay.style.background = "rgba(15, 23, 42, 0.38)"; +} + +function applyDialogStyles(dialog: HTMLElement): void { + dialog.style.width = "520px"; + dialog.style.maxWidth = "calc(100vw - 32px)"; + dialog.style.background = "#ffffff"; + dialog.style.borderRadius = "8px"; + dialog.style.boxShadow = "0 18px 45px rgba(15, 23, 42, 0.22)"; + dialog.style.padding = "20px"; + dialog.style.boxSizing = "border-box"; +} + +function applyTitleStyles(title: HTMLElement): void { + title.style.margin = "0 0 8px"; + title.style.fontSize = "18px"; + title.style.fontWeight = "700"; + title.style.color = "#1f2329"; +} + +function applyHintStyles(hint: HTMLElement): void { + hint.style.margin = "0 0 12px"; + hint.style.fontSize = "13px"; + hint.style.lineHeight = "20px"; + hint.style.color = "#64748b"; +} + +function applyTextareaStyles(textarea: HTMLTextAreaElement): void { + textarea.style.width = "100%"; + textarea.style.height = "220px"; + textarea.style.resize = "vertical"; + textarea.style.border = "1px solid #d0d7de"; + textarea.style.borderRadius = "6px"; + textarea.style.padding = "10px"; + textarea.style.boxSizing = "border-box"; + textarea.style.fontSize = "13px"; + textarea.style.lineHeight = "20px"; + textarea.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace"; + textarea.style.color = "#1f2329"; +} + +function applyActionsStyles(actions: HTMLElement): void { + actions.style.display = "flex"; + actions.style.justifyContent = "flex-end"; + actions.style.columnGap = "8px"; + actions.style.marginTop = "14px"; +} + +function applyPrimaryButtonStyles(button: HTMLButtonElement): void { + button.style.height = "32px"; + button.style.padding = "0 15px"; + button.style.border = "1px solid #7f1d2d"; + button.style.borderRadius = "8px"; + button.style.background = "#7f1d2d"; + button.style.color = "#ffffff"; + button.style.fontWeight = "600"; +} + +function applySecondaryButtonStyles(button: HTMLButtonElement): void { + button.style.height = "32px"; + button.style.padding = "0 15px"; + button.style.border = "1px solid #d0d7de"; + button.style.borderRadius = "8px"; + button.style.background = "#ffffff"; + button.style.color = "#1f2329"; + button.style.fontWeight = "600"; +} diff --git a/src/content/market/author-id-input.ts b/src/content/market/author-id-input.ts new file mode 100644 index 0000000..fb0e665 --- /dev/null +++ b/src/content/market/author-id-input.ts @@ -0,0 +1,39 @@ +export interface ParsedAuthorIds { + duplicates: string[]; + invalidTokens: string[]; + ids: string[]; +} + +const AUTHOR_ID_PATTERN = /^\d{16,20}$/; + +export function parseAuthorIds(input: string): ParsedAuthorIds { + const ids: string[] = []; + const duplicates: string[] = []; + const invalidTokens: string[] = []; + const seen = new Set(); + + input + .split(/[\s,,;;]+/) + .map((token) => token.trim()) + .filter(Boolean) + .forEach((token) => { + if (!/^\d+$/.test(token) || !AUTHOR_ID_PATTERN.test(token)) { + invalidTokens.push(token); + return; + } + + if (seen.has(token)) { + duplicates.push(token); + return; + } + + seen.add(token); + ids.push(token); + }); + + return { + duplicates, + ids, + invalidTokens + }; +} diff --git a/src/content/market/index.ts b/src/content/market/index.ts index a97720c..6f54341 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -5,7 +5,10 @@ import { createAudienceProfileClient, type AudienceProfileRequestTarget } from "./audience-profile-client"; +import { createAuthorBaseClient } from "./author-base-client"; +import { parseAuthorIds } from "./author-id-input"; import { createBusinessAbilityClient } from "./business-ability-client"; +import { promptForAuthorIds } from "./author-id-dialog"; import { promptForBatchName } from "./batch-name-dialog"; import { createBatchPayload, type BatchPayload } from "./batch-payload"; import { @@ -59,6 +62,7 @@ export interface CreateMarketControllerOptions { buildCsv?: (records: MarketRecord[]) => string; document: Document; getAuthState?: () => Promise; + loadAuthorBaseInfo?: (authorId: string) => Promise; loadBusinessAbility?: ( record: MarketRecord ) => Promise; @@ -74,6 +78,7 @@ export interface CreateMarketControllerOptions { callback: MutationCallback ) => MutationObserverLike; onCsvReady?: (csv: string, filename?: string) => void; + promptAuthorIds?: () => Promise | string | null; promptBatchName?: () => Promise | string | null; resultStore?: ReturnType; submitBatch?: (payload: BatchPayload) => Promise; @@ -83,6 +88,7 @@ export interface CreateMarketControllerOptions { export function createMarketController(options: CreateMarketControllerOptions) { const marketApiClient = createMarketApiClient(); const audienceProfileClient = createAudienceProfileClient(); + const authorBaseClient = createAuthorBaseClient(); const businessAbilityClient = createBusinessAbilityClient(); const sendRuntimeMessage = createRuntimeMessageSender(); const resultStore = options.resultStore ?? createMarketResultStore(); @@ -95,6 +101,8 @@ export function createMarketController(options: CreateMarketControllerOptions) { const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv; const loadAudienceProfile = options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile; + const loadAuthorBaseInfo = + options.loadAuthorBaseInfo ?? authorBaseClient.loadAuthorBaseInfo; const loadBusinessAbility = options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility; const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage)); @@ -104,6 +112,9 @@ export function createMarketController(options: CreateMarketControllerOptions) { const promptBatchName = options.promptBatchName ?? (() => promptForBatchName(options.document)); + const promptAuthorIds = + options.promptAuthorIds ?? + (() => promptForAuthorIds(options.document)); const submitBatch = options.submitBatch ?? ((payload: BatchPayload) => @@ -266,6 +277,49 @@ export function createMarketController(options: CreateMarketControllerOptions) { setToolbarBusyState(toolbar, false); } }, + onExportAudienceProfileByIds: async () => { + const input = await promptAuthorIds(); + if (input === null) { + return; + } + + const parsed = parseAuthorIds(input); + if (parsed.ids.length === 0) { + setToolbarExportStatus(toolbar, "请输入有效的达人星图ID"); + return; + } + + setToolbarBusyState(toolbar, true); + try { + setToolbarExportStatus( + toolbar, + `识别 ${parsed.ids.length + parsed.duplicates.length + parsed.invalidTokens.length} 个,去重后 ${parsed.ids.length} 个,非法 ${parsed.invalidTokens.length} 个` + ); + + const rows: AudienceProfileExportRow[] = []; + for (let index = 0; index < parsed.ids.length; index += 1) { + const authorId = parsed.ids[index]; + setToolbarExportStatus( + toolbar, + `按ID画像导出中 ${index + 1}/${parsed.ids.length}...` + ); + rows.push(await loadAudienceProfileRowById(authorId)); + } + + options.onCsvReady?.( + buildAudienceCsv(rows), + buildAudienceProfileFilename(new Date(), "按ID导出") + ); + setToolbarExportStatus(toolbar, ""); + } catch (error) { + setToolbarExportStatus( + toolbar, + error instanceof Error ? error.message : "按ID导出失败,请稍后重试" + ); + } finally { + setToolbarBusyState(toolbar, false); + } + }, onSubmitBatch: async () => { syncSelectionStateFromDom(); const exportTarget = readToolbarExportTarget(toolbar); @@ -706,6 +760,107 @@ export function createMarketController(options: CreateMarketControllerOptions) { } } + async function loadAudienceProfileRowById( + authorId: string + ): Promise { + const baseRecord = await loadAuthorBaseInfoSafe(authorId); + const recordForRequests = { + ...baseRecord, + authorName: baseRecord.authorName || authorId + }; + const [profiles, businessAbility] = await Promise.all([ + loadAudienceProfileSet(recordForRequests), + loadBusinessAbilitySafe(recordForRequests) + ]); + const failureReasons = collectAudienceProfileRowFailures( + baseRecord, + profiles, + businessAbility + ); + const rowStatus = + failureReasons.length === 0 + ? "成功" + : hasAudienceProfileRowSuccess(baseRecord, profiles, businessAbility) + ? "部分成功" + : "失败"; + const authorName = baseRecord.authorName || ""; + + return { + businessAbility, + profiles, + record: { + ...recordForRequests, + exportFields: { + 达人ID: authorId, + 达人名称: authorName, + 导出状态: rowStatus, + 失败原因: failureReasons.join("; ") + } + } + }; + } + + async function loadAuthorBaseInfoSafe(authorId: string): Promise { + try { + return await loadAuthorBaseInfo(authorId); + } catch (error) { + return { + authorId, + authorName: "", + failureReason: + error instanceof Error ? "request-failed" : "request-failed", + status: "failed" + }; + } + } + + function collectAudienceProfileRowFailures( + baseRecord: MarketRecord, + profiles: AudienceProfileExportRow["profiles"], + businessAbility: BusinessAbilityResult + ): string[] { + const failures: string[] = []; + if (baseRecord.status === "failed") { + failures.push(`基础信息:${baseRecord.failureReason ?? "request-failed"}`); + } + + Object.entries(profiles).forEach(([kind, profile]) => { + if (profile.status === "failed") { + failures.push(`${readAudienceProfileKindLabel(kind as AudienceProfileKind)}:${profile.failureReason ?? "request-failed"}`); + } + }); + + if (businessAbility.status === "failed") { + failures.push(`商业能力:${businessAbility.failureReason ?? "request-failed"}`); + } + + return failures; + } + + function hasAudienceProfileRowSuccess( + baseRecord: MarketRecord, + profiles: AudienceProfileExportRow["profiles"], + businessAbility: BusinessAbilityResult + ): boolean { + return ( + baseRecord.status === "success" || + businessAbility.status === "success" || + Object.values(profiles).some((profile) => profile.status === "success") + ); + } + + function readAudienceProfileKindLabel(kind: AudienceProfileKind): string { + if (kind === "audience") { + return "观众画像"; + } + + if (kind === "fans") { + return "粉丝画像"; + } + + return "铁粉画像"; + } + async function prepareCurrentPageForExport(): Promise { await runSyncCycle(); await harvestCurrentPageForExport(); @@ -1415,11 +1570,15 @@ function hasRuntimeMessageSender(): boolean { ); } -function buildAudienceProfileFilename(date = new Date()): string { +function buildAudienceProfileFilename( + date = new Date(), + label?: string +): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); const hour = String(date.getHours()).padStart(2, "0"); const minute = String(date.getMinutes()).padStart(2, "0"); - return `达人连接用户画像_${year}${month}${day}_${hour}${minute}.csv`; + const labelPart = label ? `_${label}` : ""; + return `达人连接用户画像${labelPart}_${year}${month}${day}_${hour}${minute}.csv`; } diff --git a/src/content/market/plugin-toolbar.ts b/src/content/market/plugin-toolbar.ts index 60a69fe..3de6348 100644 --- a/src/content/market/plugin-toolbar.ts +++ b/src/content/market/plugin-toolbar.ts @@ -6,10 +6,12 @@ import type { export interface PluginToolbarHandlers { onExport(): Promise | void; onExportAudienceProfile(): Promise | void; + onExportAudienceProfileByIds(): Promise | void; onSubmitBatch(): Promise | void; } export interface PluginToolbarDom { + audienceProfileByIdExportButton: HTMLButtonElement; audienceProfileExportButton: HTMLButtonElement; batchSubmitButton: HTMLButtonElement; exportButton: HTMLButtonElement; @@ -39,8 +41,16 @@ export function ensurePluginToolbar( "[data-plugin-toolbar='root']" ) as HTMLElement | null; if (existingRoot) { - ensureToolbarMounted(existingRoot, document); - return readToolbarDom(existingRoot); + if ( + existingRoot.querySelector( + '[data-plugin-export-audience-profile-by-id="button"]' + ) + ) { + ensureToolbarMounted(existingRoot, document); + return readToolbarDom(existingRoot); + } + + existingRoot.remove(); } const root = document.createElement("section"); @@ -74,6 +84,11 @@ export function ensurePluginToolbar( audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button"; audienceProfileExportButton.textContent = "导出画像CSV"; + const audienceProfileByIdExportButton = document.createElement("button"); + audienceProfileByIdExportButton.type = "button"; + audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button"; + audienceProfileByIdExportButton.textContent = "按ID导出画像CSV"; + const batchSubmitButton = document.createElement("button"); batchSubmitButton.type = "button"; batchSubmitButton.dataset.pluginBatchSubmit = "button"; @@ -88,6 +103,7 @@ export function ensurePluginToolbar( exportCustomPagesInput, exportButton, audienceProfileExportButton, + audienceProfileByIdExportButton, batchSubmitButton, exportStatusText ); @@ -95,6 +111,7 @@ export function ensurePluginToolbar( document.body.appendChild(root); applyNativeControlStyles(document, { audienceProfileExportButton, + audienceProfileByIdExportButton, batchSubmitButton, exportButton, exportCustomPagesInput, @@ -108,12 +125,16 @@ export function ensurePluginToolbar( audienceProfileExportButton.addEventListener("click", () => { void handlers.onExportAudienceProfile(); }); + audienceProfileByIdExportButton.addEventListener("click", () => { + void handlers.onExportAudienceProfileByIds(); + }); batchSubmitButton.addEventListener("click", () => { void handlers.onSubmitBatch(); }); exportRangeSelect.addEventListener("change", () => { syncCustomPagesInputVisibility({ batchSubmitButton, + audienceProfileByIdExportButton, audienceProfileExportButton, exportButton, exportCustomPagesInput, @@ -125,6 +146,7 @@ export function ensurePluginToolbar( const toolbarDom = { audienceProfileExportButton, + audienceProfileByIdExportButton, batchSubmitButton, exportButton, exportCustomPagesInput, @@ -150,6 +172,9 @@ function appendOption( function readToolbarDom(root: HTMLElement): PluginToolbarDom { const toolbarDom = { + audienceProfileByIdExportButton: root.querySelector( + '[data-plugin-export-audience-profile-by-id="button"]' + ) as HTMLButtonElement, audienceProfileExportButton: root.querySelector( '[data-plugin-export-audience-profile="button"]' ) as HTMLButtonElement, @@ -235,6 +260,7 @@ export function setToolbarBusyState( ): void { [ toolbar.batchSubmitButton, + toolbar.audienceProfileByIdExportButton, toolbar.audienceProfileExportButton, toolbar.exportButton, toolbar.exportRangeSelect, @@ -433,6 +459,7 @@ function applyNativeControlStyles( document: Document, controls: { audienceProfileExportButton: HTMLButtonElement; + audienceProfileByIdExportButton: HTMLButtonElement; batchSubmitButton: HTMLButtonElement; exportButton: HTMLButtonElement; exportCustomPagesInput: HTMLInputElement; @@ -450,12 +477,14 @@ function applyNativeControlStyles( if (nativeButton) { controls.exportButton.className = nativeButton.className; controls.audienceProfileExportButton.className = nativeButton.className; + controls.audienceProfileByIdExportButton.className = nativeButton.className; controls.batchSubmitButton.className = nativeButton.className; } [ controls.exportButton, controls.audienceProfileExportButton, + controls.audienceProfileByIdExportButton, controls.batchSubmitButton ].forEach((button) => { applyPrimaryButtonStyles(button); @@ -509,6 +538,7 @@ function ensurePluginActionButtonTheme(document: Document): void { style.textContent = ` [data-plugin-export="button"]:hover:not(:disabled), [data-plugin-export-audience-profile="button"]:hover:not(:disabled), + [data-plugin-export-audience-profile-by-id="button"]:hover:not(:disabled), [data-plugin-batch-submit="button"]:hover:not(:disabled) { background-color: #6d1627 !important; border-color: #6d1627 !important; @@ -516,6 +546,7 @@ function ensurePluginActionButtonTheme(document: Document): void { [data-plugin-export="button"]:active:not(:disabled), [data-plugin-export-audience-profile="button"]:active:not(:disabled), + [data-plugin-export-audience-profile-by-id="button"]:active:not(:disabled), [data-plugin-batch-submit="button"]:active:not(:disabled) { background-color: #58111f !important; border-color: #58111f !important; @@ -524,6 +555,7 @@ function ensurePluginActionButtonTheme(document: Document): void { [data-plugin-export="button"]:focus-visible, [data-plugin-export-audience-profile="button"]:focus-visible, + [data-plugin-export-audience-profile-by-id="button"]:focus-visible, [data-plugin-batch-submit="button"]:focus-visible { outline: none !important; box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !important; @@ -531,6 +563,7 @@ function ensurePluginActionButtonTheme(document: Document): void { [data-plugin-export="button"]:disabled, [data-plugin-export-audience-profile="button"]:disabled, + [data-plugin-export-audience-profile-by-id="button"]:disabled, [data-plugin-batch-submit="button"]:disabled { background-color: #c89ca4 !important; border-color: #c89ca4 !important; diff --git a/tests/author-base-client.test.ts b/tests/author-base-client.test.ts new file mode 100644 index 0000000..e54f054 --- /dev/null +++ b/tests/author-base-client.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + buildAuthorBaseInfoUrl, + createAuthorBaseClient, + mapAuthorBaseInfoResponse +} from "../src/content/market/author-base-client"; + +describe("author-base-client", () => { + test("builds Xingtu author base info url", () => { + expect( + buildAuthorBaseInfoUrl("6866044569306267651", "https://www.xingtu.cn") + ).toBe( + "https://www.xingtu.cn/gw/api/author/get_author_base_info?o_author_id=6866044569306267651&platform_source=1&platform_channel=1&recommend=true&need_sec_uid=true&need_linkage_info=true" + ); + }); + + test("maps author nickname into a market record", () => { + expect(mapAuthorBaseInfoResponse("6866044569306267651", { + base_resp: { status_code: 0, status_message: "Success" }, + nick_name: "小九儿" + })).toEqual({ + authorId: "6866044569306267651", + authorName: "小九儿", + status: "success" + }); + }); + + test("loads author base info from Xingtu", async () => { + const fetchImpl = vi.fn(async () => ({ + json: async () => ({ + base_resp: { status_code: 0, status_message: "Success" }, + nick_name: "小九儿" + }), + ok: true + })); + const client = createAuthorBaseClient({ + baseUrl: "https://www.xingtu.cn", + fetchImpl, + timeoutMs: 1000 + }); + + await expect(client.loadAuthorBaseInfo("6866044569306267651")).resolves.toEqual({ + authorId: "6866044569306267651", + authorName: "小九儿", + status: "success" + }); + expect(fetchImpl).toHaveBeenCalledWith( + "https://www.xingtu.cn/gw/api/author/get_author_base_info?o_author_id=6866044569306267651&platform_source=1&platform_channel=1&recommend=true&need_sec_uid=true&need_linkage_info=true", + expect.objectContaining({ + credentials: "include", + method: "GET" + }) + ); + }); +}); diff --git a/tests/author-id-input.test.ts b/tests/author-id-input.test.ts new file mode 100644 index 0000000..75af90d --- /dev/null +++ b/tests/author-id-input.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; + +import { parseAuthorIds } from "../src/content/market/author-id-input"; + +describe("author-id-input", () => { + test("parses newline comma and space separated Xingtu author ids", () => { + expect(parseAuthorIds(` + 6866044569306267651 + 7040323176106033165,7088592143119286285 + 7222310247979810854 + `)).toEqual({ + duplicates: [], + ids: [ + "6866044569306267651", + "7040323176106033165", + "7088592143119286285", + "7222310247979810854" + ], + invalidTokens: [] + }); + }); + + test("deduplicates ids and reports invalid tokens", () => { + expect(parseAuthorIds(` + 6866044569306267651 + bad-id + 123 + 6866044569306267651 + `)).toEqual({ + duplicates: ["6866044569306267651"], + ids: ["6866044569306267651"], + invalidTokens: ["bad-id", "123"] + }); + }); +}); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 56bde20..13b5131 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -328,6 +328,9 @@ describe("market-content-entry", () => { expect( document.querySelector('[data-plugin-export-audience-profile="button"]') ).not.toBeNull(); + expect( + document.querySelector('[data-plugin-export-audience-profile-by-id="button"]') + ).not.toBeNull(); expect(document.querySelector('[data-plugin-batch-submit="button"]')).not.toBeNull(); expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull(); @@ -340,12 +343,17 @@ describe("market-content-entry", () => { const audienceProfileExportButton = document.querySelector( '[data-plugin-export-audience-profile="button"]' ) as HTMLButtonElement | null; + const audienceProfileByIdExportButton = document.querySelector( + '[data-plugin-export-audience-profile-by-id="button"]' + ) as HTMLButtonElement | null; expect(exportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); expect(batchSubmitButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); expect(audienceProfileExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); + expect(audienceProfileByIdExportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)"); expect(exportButton?.style.color).toBe("rgb(255, 255, 255)"); expect(batchSubmitButton?.style.color).toBe("rgb(255, 255, 255)"); expect(audienceProfileExportButton?.style.color).toBe("rgb(255, 255, 255)"); + expect(audienceProfileByIdExportButton?.style.color).toBe("rgb(255, 255, 255)"); }); test("remounts the plugin action bar when the native market action row appears later", async () => { @@ -1698,6 +1706,87 @@ describe("market-content-entry", () => { ); }); + test("audience profile export by id loads pasted creators without page selection", async () => { + document.body.innerHTML = buildRealMarketFixture([ + { authorId: "111", authorName: "达人 A", price21To60s: "¥11,000" } + ]); + const buildAudienceProfileCsv = vi.fn(() => "profile-csv"); + const loadAuthorBaseInfo = vi.fn(async (authorId: string) => ({ + authorId, + authorName: authorId === "6866044569306267651" ? "小九儿" : "达人 B", + status: "success" as const + })); + const loadBusinessAbility = vi.fn(async () => ({ + estimates: {}, + status: "success" as const, + videos: {} + })); + const loadAudienceProfile = vi.fn(async () => ({ + 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"); + const controller = trackController(createMarketController({ + buildAudienceProfileCsv, + document, + loadAuthorBaseInfo, + loadBusinessAbility, + loadAudienceProfile, + loadAuthorMetrics: async () => ({ + success: false, + reason: "request-failed" + }), + onCsvReady, + promptAuthorIds: () => ` + 6866044569306267651 + 7040323176106033165 + 6866044569306267651 + bad-id + `, + window + })); + + await controller.ready; + click('[data-plugin-export-audience-profile-by-id="button"]'); + await waitForMockCall(buildAudienceProfileCsv, 40, 50); + + expect(loadAuthorBaseInfo.mock.calls.map(([authorId]) => authorId)).toEqual([ + "6866044569306267651", + "7040323176106033165" + ]); + expect(loadAudienceProfile).toHaveBeenCalledTimes(6); + expect(loadBusinessAbility).toHaveBeenCalledTimes(2); + expect(buildAudienceProfileCsv).toHaveBeenCalledWith([ + expect.objectContaining({ + record: expect.objectContaining({ + authorId: "6866044569306267651", + authorName: "小九儿", + exportFields: { + 达人ID: "6866044569306267651", + 达人名称: "小九儿", + 导出状态: "成功", + 失败原因: "" + } + }) + }), + expect.objectContaining({ + record: expect.objectContaining({ + authorId: "7040323176106033165", + authorName: "达人 B" + }) + }) + ]); + expect(onCsvReady).toHaveBeenCalledWith( + "profile-csv", + expect.stringMatching(/^达人连接用户画像_按ID导出_\d{8}_\d{4}\.csv$/) + ); + }); + test( "selected export keeps a generic loading status while exporting the default paged range", async () => {