feat: export audience profiles by author ids
This commit is contained in:
parent
39c4191a95
commit
38da39589f
122
src/content/market/author-base-client.ts
Normal file
122
src/content/market/author-base-client.ts
Normal 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;
|
||||||
|
}
|
||||||
130
src/content/market/author-id-dialog.ts
Normal file
130
src/content/market/author-id-dialog.ts
Normal 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";
|
||||||
|
}
|
||||||
39
src/content/market/author-id-input.ts
Normal file
39
src/content/market/author-id-input.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -5,7 +5,10 @@ import {
|
|||||||
createAudienceProfileClient,
|
createAudienceProfileClient,
|
||||||
type AudienceProfileRequestTarget
|
type AudienceProfileRequestTarget
|
||||||
} from "./audience-profile-client";
|
} from "./audience-profile-client";
|
||||||
|
import { createAuthorBaseClient } from "./author-base-client";
|
||||||
|
import { parseAuthorIds } from "./author-id-input";
|
||||||
import { createBusinessAbilityClient } from "./business-ability-client";
|
import { createBusinessAbilityClient } from "./business-ability-client";
|
||||||
|
import { promptForAuthorIds } from "./author-id-dialog";
|
||||||
import { promptForBatchName } from "./batch-name-dialog";
|
import { promptForBatchName } from "./batch-name-dialog";
|
||||||
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
import { createBatchPayload, type BatchPayload } from "./batch-payload";
|
||||||
import {
|
import {
|
||||||
@ -59,6 +62,7 @@ export interface CreateMarketControllerOptions {
|
|||||||
buildCsv?: (records: MarketRecord[]) => string;
|
buildCsv?: (records: MarketRecord[]) => string;
|
||||||
document: Document;
|
document: Document;
|
||||||
getAuthState?: () => Promise<AuthStateValue>;
|
getAuthState?: () => Promise<AuthStateValue>;
|
||||||
|
loadAuthorBaseInfo?: (authorId: string) => Promise<MarketRecord>;
|
||||||
loadBusinessAbility?: (
|
loadBusinessAbility?: (
|
||||||
record: MarketRecord
|
record: MarketRecord
|
||||||
) => Promise<BusinessAbilityResult>;
|
) => Promise<BusinessAbilityResult>;
|
||||||
@ -74,6 +78,7 @@ export interface CreateMarketControllerOptions {
|
|||||||
callback: MutationCallback
|
callback: MutationCallback
|
||||||
) => MutationObserverLike;
|
) => MutationObserverLike;
|
||||||
onCsvReady?: (csv: string, filename?: string) => void;
|
onCsvReady?: (csv: string, filename?: string) => void;
|
||||||
|
promptAuthorIds?: () => Promise<string | null> | string | null;
|
||||||
promptBatchName?: () => Promise<string | null> | string | null;
|
promptBatchName?: () => Promise<string | null> | string | null;
|
||||||
resultStore?: ReturnType<typeof createMarketResultStore>;
|
resultStore?: ReturnType<typeof createMarketResultStore>;
|
||||||
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
submitBatch?: (payload: BatchPayload) => Promise<unknown>;
|
||||||
@ -83,6 +88,7 @@ export interface CreateMarketControllerOptions {
|
|||||||
export function createMarketController(options: CreateMarketControllerOptions) {
|
export function createMarketController(options: CreateMarketControllerOptions) {
|
||||||
const marketApiClient = createMarketApiClient();
|
const marketApiClient = createMarketApiClient();
|
||||||
const audienceProfileClient = createAudienceProfileClient();
|
const audienceProfileClient = createAudienceProfileClient();
|
||||||
|
const authorBaseClient = createAuthorBaseClient();
|
||||||
const businessAbilityClient = createBusinessAbilityClient();
|
const businessAbilityClient = createBusinessAbilityClient();
|
||||||
const sendRuntimeMessage = createRuntimeMessageSender();
|
const sendRuntimeMessage = createRuntimeMessageSender();
|
||||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||||
@ -95,6 +101,8 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv;
|
const buildAudienceCsv = options.buildAudienceProfileCsv ?? buildAudienceProfileCsv;
|
||||||
const loadAudienceProfile =
|
const loadAudienceProfile =
|
||||||
options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile;
|
options.loadAudienceProfile ?? audienceProfileClient.loadAudienceProfile;
|
||||||
|
const loadAuthorBaseInfo =
|
||||||
|
options.loadAuthorBaseInfo ?? authorBaseClient.loadAuthorBaseInfo;
|
||||||
const loadBusinessAbility =
|
const loadBusinessAbility =
|
||||||
options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility;
|
options.loadBusinessAbility ?? businessAbilityClient.loadBusinessAbility;
|
||||||
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
|
const getAuthState = options.getAuthState ?? (() => readAuthState(sendRuntimeMessage));
|
||||||
@ -104,6 +112,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
const promptBatchName =
|
const promptBatchName =
|
||||||
options.promptBatchName ??
|
options.promptBatchName ??
|
||||||
(() => promptForBatchName(options.document));
|
(() => promptForBatchName(options.document));
|
||||||
|
const promptAuthorIds =
|
||||||
|
options.promptAuthorIds ??
|
||||||
|
(() => promptForAuthorIds(options.document));
|
||||||
const submitBatch =
|
const submitBatch =
|
||||||
options.submitBatch ??
|
options.submitBatch ??
|
||||||
((payload: BatchPayload) =>
|
((payload: BatchPayload) =>
|
||||||
@ -266,6 +277,49 @@ export function createMarketController(options: CreateMarketControllerOptions) {
|
|||||||
setToolbarBusyState(toolbar, false);
|
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 () => {
|
onSubmitBatch: async () => {
|
||||||
syncSelectionStateFromDom();
|
syncSelectionStateFromDom();
|
||||||
const exportTarget = readToolbarExportTarget(toolbar);
|
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> {
|
async function prepareCurrentPageForExport(): Promise<void> {
|
||||||
await runSyncCycle();
|
await runSyncCycle();
|
||||||
await harvestCurrentPageForExport();
|
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 year = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(date.getDate()).padStart(2, "0");
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
const hour = String(date.getHours()).padStart(2, "0");
|
const hour = String(date.getHours()).padStart(2, "0");
|
||||||
const minute = String(date.getMinutes()).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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,10 +6,12 @@ import type {
|
|||||||
export interface PluginToolbarHandlers {
|
export interface PluginToolbarHandlers {
|
||||||
onExport(): Promise<void> | void;
|
onExport(): Promise<void> | void;
|
||||||
onExportAudienceProfile(): Promise<void> | void;
|
onExportAudienceProfile(): Promise<void> | void;
|
||||||
|
onExportAudienceProfileByIds(): Promise<void> | void;
|
||||||
onSubmitBatch(): Promise<void> | void;
|
onSubmitBatch(): Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginToolbarDom {
|
export interface PluginToolbarDom {
|
||||||
|
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||||
audienceProfileExportButton: HTMLButtonElement;
|
audienceProfileExportButton: HTMLButtonElement;
|
||||||
batchSubmitButton: HTMLButtonElement;
|
batchSubmitButton: HTMLButtonElement;
|
||||||
exportButton: HTMLButtonElement;
|
exportButton: HTMLButtonElement;
|
||||||
@ -39,10 +41,18 @@ export function ensurePluginToolbar(
|
|||||||
"[data-plugin-toolbar='root']"
|
"[data-plugin-toolbar='root']"
|
||||||
) as HTMLElement | null;
|
) as HTMLElement | null;
|
||||||
if (existingRoot) {
|
if (existingRoot) {
|
||||||
|
if (
|
||||||
|
existingRoot.querySelector(
|
||||||
|
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||||
|
)
|
||||||
|
) {
|
||||||
ensureToolbarMounted(existingRoot, document);
|
ensureToolbarMounted(existingRoot, document);
|
||||||
return readToolbarDom(existingRoot);
|
return readToolbarDom(existingRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existingRoot.remove();
|
||||||
|
}
|
||||||
|
|
||||||
const root = document.createElement("section");
|
const root = document.createElement("section");
|
||||||
root.dataset.pluginToolbar = "root";
|
root.dataset.pluginToolbar = "root";
|
||||||
applyToolbarRootStyles(root);
|
applyToolbarRootStyles(root);
|
||||||
@ -74,6 +84,11 @@ export function ensurePluginToolbar(
|
|||||||
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
|
audienceProfileExportButton.dataset.pluginExportAudienceProfile = "button";
|
||||||
audienceProfileExportButton.textContent = "导出画像CSV";
|
audienceProfileExportButton.textContent = "导出画像CSV";
|
||||||
|
|
||||||
|
const audienceProfileByIdExportButton = document.createElement("button");
|
||||||
|
audienceProfileByIdExportButton.type = "button";
|
||||||
|
audienceProfileByIdExportButton.dataset.pluginExportAudienceProfileById = "button";
|
||||||
|
audienceProfileByIdExportButton.textContent = "按ID导出画像CSV";
|
||||||
|
|
||||||
const batchSubmitButton = document.createElement("button");
|
const batchSubmitButton = document.createElement("button");
|
||||||
batchSubmitButton.type = "button";
|
batchSubmitButton.type = "button";
|
||||||
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
batchSubmitButton.dataset.pluginBatchSubmit = "button";
|
||||||
@ -88,6 +103,7 @@ export function ensurePluginToolbar(
|
|||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
exportButton,
|
exportButton,
|
||||||
audienceProfileExportButton,
|
audienceProfileExportButton,
|
||||||
|
audienceProfileByIdExportButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportStatusText
|
exportStatusText
|
||||||
);
|
);
|
||||||
@ -95,6 +111,7 @@ export function ensurePluginToolbar(
|
|||||||
document.body.appendChild(root);
|
document.body.appendChild(root);
|
||||||
applyNativeControlStyles(document, {
|
applyNativeControlStyles(document, {
|
||||||
audienceProfileExportButton,
|
audienceProfileExportButton,
|
||||||
|
audienceProfileByIdExportButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
@ -108,12 +125,16 @@ export function ensurePluginToolbar(
|
|||||||
audienceProfileExportButton.addEventListener("click", () => {
|
audienceProfileExportButton.addEventListener("click", () => {
|
||||||
void handlers.onExportAudienceProfile();
|
void handlers.onExportAudienceProfile();
|
||||||
});
|
});
|
||||||
|
audienceProfileByIdExportButton.addEventListener("click", () => {
|
||||||
|
void handlers.onExportAudienceProfileByIds();
|
||||||
|
});
|
||||||
batchSubmitButton.addEventListener("click", () => {
|
batchSubmitButton.addEventListener("click", () => {
|
||||||
void handlers.onSubmitBatch();
|
void handlers.onSubmitBatch();
|
||||||
});
|
});
|
||||||
exportRangeSelect.addEventListener("change", () => {
|
exportRangeSelect.addEventListener("change", () => {
|
||||||
syncCustomPagesInputVisibility({
|
syncCustomPagesInputVisibility({
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
|
audienceProfileByIdExportButton,
|
||||||
audienceProfileExportButton,
|
audienceProfileExportButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
@ -125,6 +146,7 @@ export function ensurePluginToolbar(
|
|||||||
|
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
audienceProfileExportButton,
|
audienceProfileExportButton,
|
||||||
|
audienceProfileByIdExportButton,
|
||||||
batchSubmitButton,
|
batchSubmitButton,
|
||||||
exportButton,
|
exportButton,
|
||||||
exportCustomPagesInput,
|
exportCustomPagesInput,
|
||||||
@ -150,6 +172,9 @@ function appendOption(
|
|||||||
|
|
||||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||||
const toolbarDom = {
|
const toolbarDom = {
|
||||||
|
audienceProfileByIdExportButton: root.querySelector(
|
||||||
|
'[data-plugin-export-audience-profile-by-id="button"]'
|
||||||
|
) as HTMLButtonElement,
|
||||||
audienceProfileExportButton: root.querySelector(
|
audienceProfileExportButton: root.querySelector(
|
||||||
'[data-plugin-export-audience-profile="button"]'
|
'[data-plugin-export-audience-profile="button"]'
|
||||||
) as HTMLButtonElement,
|
) as HTMLButtonElement,
|
||||||
@ -235,6 +260,7 @@ export function setToolbarBusyState(
|
|||||||
): void {
|
): void {
|
||||||
[
|
[
|
||||||
toolbar.batchSubmitButton,
|
toolbar.batchSubmitButton,
|
||||||
|
toolbar.audienceProfileByIdExportButton,
|
||||||
toolbar.audienceProfileExportButton,
|
toolbar.audienceProfileExportButton,
|
||||||
toolbar.exportButton,
|
toolbar.exportButton,
|
||||||
toolbar.exportRangeSelect,
|
toolbar.exportRangeSelect,
|
||||||
@ -433,6 +459,7 @@ function applyNativeControlStyles(
|
|||||||
document: Document,
|
document: Document,
|
||||||
controls: {
|
controls: {
|
||||||
audienceProfileExportButton: HTMLButtonElement;
|
audienceProfileExportButton: HTMLButtonElement;
|
||||||
|
audienceProfileByIdExportButton: HTMLButtonElement;
|
||||||
batchSubmitButton: HTMLButtonElement;
|
batchSubmitButton: HTMLButtonElement;
|
||||||
exportButton: HTMLButtonElement;
|
exportButton: HTMLButtonElement;
|
||||||
exportCustomPagesInput: HTMLInputElement;
|
exportCustomPagesInput: HTMLInputElement;
|
||||||
@ -450,12 +477,14 @@ function applyNativeControlStyles(
|
|||||||
if (nativeButton) {
|
if (nativeButton) {
|
||||||
controls.exportButton.className = nativeButton.className;
|
controls.exportButton.className = nativeButton.className;
|
||||||
controls.audienceProfileExportButton.className = nativeButton.className;
|
controls.audienceProfileExportButton.className = nativeButton.className;
|
||||||
|
controls.audienceProfileByIdExportButton.className = nativeButton.className;
|
||||||
controls.batchSubmitButton.className = nativeButton.className;
|
controls.batchSubmitButton.className = nativeButton.className;
|
||||||
}
|
}
|
||||||
|
|
||||||
[
|
[
|
||||||
controls.exportButton,
|
controls.exportButton,
|
||||||
controls.audienceProfileExportButton,
|
controls.audienceProfileExportButton,
|
||||||
|
controls.audienceProfileByIdExportButton,
|
||||||
controls.batchSubmitButton
|
controls.batchSubmitButton
|
||||||
].forEach((button) => {
|
].forEach((button) => {
|
||||||
applyPrimaryButtonStyles(button);
|
applyPrimaryButtonStyles(button);
|
||||||
@ -509,6 +538,7 @@ function ensurePluginActionButtonTheme(document: Document): void {
|
|||||||
style.textContent = `
|
style.textContent = `
|
||||||
[data-plugin-export="button"]:hover:not(:disabled),
|
[data-plugin-export="button"]:hover:not(:disabled),
|
||||||
[data-plugin-export-audience-profile="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) {
|
[data-plugin-batch-submit="button"]:hover:not(:disabled) {
|
||||||
background-color: #6d1627 !important;
|
background-color: #6d1627 !important;
|
||||||
border-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="button"]:active:not(:disabled),
|
||||||
[data-plugin-export-audience-profile="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) {
|
[data-plugin-batch-submit="button"]:active:not(:disabled) {
|
||||||
background-color: #58111f !important;
|
background-color: #58111f !important;
|
||||||
border-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="button"]:focus-visible,
|
||||||
[data-plugin-export-audience-profile="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 {
|
[data-plugin-batch-submit="button"]:focus-visible {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
box-shadow: 0 0 0 3px rgba(127, 29, 45, 0.2) !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="button"]:disabled,
|
||||||
[data-plugin-export-audience-profile="button"]:disabled,
|
[data-plugin-export-audience-profile="button"]:disabled,
|
||||||
|
[data-plugin-export-audience-profile-by-id="button"]:disabled,
|
||||||
[data-plugin-batch-submit="button"]:disabled {
|
[data-plugin-batch-submit="button"]:disabled {
|
||||||
background-color: #c89ca4 !important;
|
background-color: #c89ca4 !important;
|
||||||
border-color: #c89ca4 !important;
|
border-color: #c89ca4 !important;
|
||||||
|
|||||||
56
tests/author-base-client.test.ts
Normal file
56
tests/author-base-client.test.ts
Normal 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"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
tests/author-id-input.test.ts
Normal file
35
tests/author-id-input.test.ts
Normal 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"]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -328,6 +328,9 @@ describe("market-content-entry", () => {
|
|||||||
expect(
|
expect(
|
||||||
document.querySelector('[data-plugin-export-audience-profile="button"]')
|
document.querySelector('[data-plugin-export-audience-profile="button"]')
|
||||||
).not.toBeNull();
|
).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-batch-submit="button"]')).not.toBeNull();
|
||||||
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
|
expect(document.querySelector('[data-plugin-export-status="text"]')).not.toBeNull();
|
||||||
|
|
||||||
@ -340,12 +343,17 @@ describe("market-content-entry", () => {
|
|||||||
const audienceProfileExportButton = document.querySelector(
|
const audienceProfileExportButton = document.querySelector(
|
||||||
'[data-plugin-export-audience-profile="button"]'
|
'[data-plugin-export-audience-profile="button"]'
|
||||||
) as HTMLButtonElement | null;
|
) 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(exportButton?.style.backgroundColor).toBe("rgb(127, 29, 45)");
|
||||||
expect(batchSubmitButton?.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(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(exportButton?.style.color).toBe("rgb(255, 255, 255)");
|
||||||
expect(batchSubmitButton?.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(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 () => {
|
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(
|
test(
|
||||||
"selected export keeps a generic loading status while exporting the default paged range",
|
"selected export keeps a generic loading status while exporting the default paged range",
|
||||||
async () => {
|
async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user