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,
|
||||
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`;
|
||||
}
|
||||
|
||||
@ -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,10 +41,18 @@ export function ensurePluginToolbar(
|
||||
"[data-plugin-toolbar='root']"
|
||||
) as HTMLElement | null;
|
||||
if (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");
|
||||
root.dataset.pluginToolbar = "root";
|
||||
applyToolbarRootStyles(root);
|
||||
@ -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;
|
||||
|
||||
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(
|
||||
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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user