feat: export audience profiles by author ids

This commit is contained in:
admin123 2026-05-18 19:24:15 +08:00
parent 39c4191a95
commit 38da39589f
8 changed files with 667 additions and 4 deletions

View File

@ -0,0 +1,122 @@
import type { MarketRecord } from "./types";
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
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<MarketRecord> {
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<string, unknown> {
return typeof value === "object" && value !== null;
}

View File

@ -0,0 +1,130 @@
export function promptForAuthorIds(document: Document): Promise<string | null> {
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";
}

View File

@ -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<string>();
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
};
}

View File

@ -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<AuthStateValue>;
loadAuthorBaseInfo?: (authorId: string) => Promise<MarketRecord>;
loadBusinessAbility?: (
record: MarketRecord
) => Promise<BusinessAbilityResult>;
@ -74,6 +78,7 @@ export interface CreateMarketControllerOptions {
callback: MutationCallback
) => MutationObserverLike;
onCsvReady?: (csv: string, filename?: string) => void;
promptAuthorIds?: () => Promise<string | null> | string | null;
promptBatchName?: () => Promise<string | null> | string | null;
resultStore?: ReturnType<typeof createMarketResultStore>;
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
@ -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<AudienceProfileExportRow> {
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<MarketRecord> {
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<void> {
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`;
}

View File

@ -6,10 +6,12 @@ import type {
export interface PluginToolbarHandlers {
onExport(): Promise<void> | void;
onExportAudienceProfile(): Promise<void> | void;
onExportAudienceProfileByIds(): Promise<void> | void;
onSubmitBatch(): Promise<void> | 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;

View File

@ -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"
})
);
});
});

View File

@ -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"]
});
});
});

View File

@ -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 () => {