feat: export audience profiles by author ids

This commit is contained in:
2026-05-18 19:24:15 +08:00
parent 39c4191a95
commit 38da39589f
8 changed files with 667 additions and 4 deletions
+122
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;
}
+130
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";
}
+39
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
};
}
+161 -2
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`;
}
+35 -2
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;