1718 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { buildMarketCsv } from "./csv-exporter";
import {
buildAudienceProfileCsv,
listAudienceProfileSelectableFieldGroups,
type AudienceProfileCsvOptions
} from "./audience-profile-csv";
import {
AUDIENCE_PROFILE_TARGETS,
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 { promptForAudienceProfileFields } from "./audience-profile-field-dialog";
import { promptForAuthorIds } from "./author-id-dialog";
import { promptForBatchName } from "./batch-name-dialog";
import { createBatchPayload, type BatchPayload } from "./batch-payload";
import {
applyRowOrder,
applyRowVisibility,
readMarketPageSignature,
renderMarketRowState,
syncPluginSortHeaders,
syncMarketSelectionState,
syncMarketTable,
type MarketRowDom
} from "./dom-sync";
import { applyFilterAndSort } from "./filter-sort-controller";
import { createMarketApiClient } from "./api-client";
import { createExportRangeController } from "./export-range-controller";
import { ensurePluginToolbar, isPluginToolbarMounted } from "./plugin-toolbar";
import { createSilentExportController } from "./silent-export-controller";
import {
readToolbarExportTarget,
setToolbarBusyState,
setToolbarExportStatus
} from "./plugin-toolbar";
import { createMarketResultStore } from "./result-store";
import {
isAuthResponseMessage,
type AuthStateValue
} from "../../shared/auth-messages";
import { isBackendMetricsResponseMessage } from "../../shared/backend-metrics-messages";
import type {
AudienceProfileExportRow,
AudienceProfileKind,
AudienceProfileResult,
BusinessAbilityResult
} from "./audience-profile-types";
import type {
BackendMetrics,
MarketApiResult,
MarketExportTarget,
MarketRecord,
MarketRowSnapshot,
MarketSortState
} from "./types";
interface MutationObserverLike {
disconnect(): void;
observe(target: Node, options?: MutationObserverInit): void;
}
export interface CreateMarketControllerOptions {
buildAudienceProfileCsv?: (
rows: AudienceProfileExportRow[],
options?: AudienceProfileCsvOptions
) => string;
buildCsv?: (records: MarketRecord[]) => string;
document: Document;
getAuthState?: () => Promise<AuthStateValue>;
loadAuthorBaseInfo?: (authorId: string) => Promise<MarketRecord>;
loadBusinessAbility?: (
record: MarketRecord
) => Promise<BusinessAbilityResult>;
loadAudienceProfile?: (
record: MarketRecord,
target: AudienceProfileRequestTarget
) => Promise<AudienceProfileResult>;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
searchBackendMetrics?: (starIds: string[]) => Promise<
Array<BackendMetrics & { starId: string }>
>;
mutationObserverFactory?: (
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>;
window: Window;
}
const AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY =
"sces:audience-profile:selectedHeaders";
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();
const loadAuthorMetrics =
options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo;
const searchBackendMetrics =
options.searchBackendMetrics ??
(hasRuntimeMessageSender() ? (starIds: string[]) => readBackendMetrics(sendRuntimeMessage, starIds) : null);
const buildCsv = options.buildCsv ?? buildMarketCsv;
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));
const mutationObserverFactory =
options.mutationObserverFactory ??
((callback: MutationCallback) => new MutationObserver(callback));
const promptBatchName =
options.promptBatchName ??
(() => promptForBatchName(options.document));
const promptAuthorIds =
options.promptAuthorIds ??
(() => promptForAuthorIds(options.document));
const submitBatch =
options.submitBatch ??
((payload: BatchPayload) =>
readBatchSubmitAck(sendRuntimeMessage, payload));
const audienceProfileTargets: Array<{
kind: AudienceProfileKind;
target: AudienceProfileRequestTarget;
}> = [
{ kind: "audience", target: AUDIENCE_PROFILE_TARGETS.audience },
{ kind: "fans", target: AUDIENCE_PROFILE_TARGETS.fans },
{
kind: "longtimeFans",
target: AUDIENCE_PROFILE_TARGETS.longtimeFans
}
];
let activeProgressLabel = "导出中";
let shouldShowDetailedProgress = true;
const exportRangeController = createExportRangeController({
document: options.document,
onProgress: ({ currentPage, totalPages }) => {
updateToolbarProgress(currentPage, totalPages);
},
prepareCurrentPageForExport: prepareCurrentPageForExport,
readCurrentPageRecords: () => getVisibleOrderedRecords(),
readCurrentPageRowCount: () => countCurrentPageRows(options.document),
window: options.window
});
const silentExportController = createSilentExportController({
document: options.document,
onProgress: ({ currentPage, totalPages }) => {
updateToolbarProgress(currentPage, totalPages);
}
});
let activeSort: MarketSortState | undefined;
let isDisposed = false;
let isSyncRunning = false;
let isSyncScheduled = false;
let lastKnownPageSignature = "";
let needsResync = false;
let scheduledSyncTimeoutId: number | null = null;
const selectedAuthorIds = new Set<string>();
let toolbar: ReturnType<typeof ensurePluginToolbar> | undefined;
const observer = mutationObserverFactory(() => {
if (isDisposed) {
return;
}
let nextPageSignature = lastKnownPageSignature;
try {
nextPageSignature = readMarketPageSignature(options.document);
} catch {
return;
}
const toolbarNeedsRemount =
!toolbar || !isPluginToolbarMounted(toolbar.root, options.document);
const selectionControlsMissing =
!options.document.querySelector('[data-market-selection-checkbox="row"]') ||
!options.document.querySelector('[data-market-selection-checkbox="header"]');
if (
nextPageSignature === lastKnownPageSignature &&
!toolbarNeedsRemount &&
!selectionControlsMissing
) {
return;
}
scheduleSync();
});
const observationRoot = options.document.body ?? options.document.documentElement;
startObserving();
const toolbarHandlers = {
onExport: async () => {
syncSelectionStateFromDom();
const exportTarget = readToolbarExportTarget(toolbar);
if (!exportTarget.target) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return;
}
setToolbarBusyState(toolbar, true);
try {
const records = filterRecordsBySelection(
await exportRecords(exportTarget.target, "导出中", {
showDetailedProgress: selectedAuthorIds.size === 0
})
);
options.onCsvReady?.(buildCsv(records));
setToolbarExportStatus(toolbar, "");
} catch (error) {
setToolbarExportStatus(
toolbar,
error instanceof Error ? error.message : "导出失败,请稍后重试"
);
} finally {
setToolbarBusyState(toolbar, false);
}
},
onExportAudienceProfile: async () => {
syncSelectionStateFromDom();
if (selectedAuthorIds.size === 0) {
setToolbarExportStatus(toolbar, "请先勾选需要导出数据的达人");
return;
}
const exportTarget = readToolbarExportTarget(toolbar);
if (!exportTarget.target) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return;
}
setToolbarBusyState(toolbar, true);
try {
const selectedRecords = filterRecordsBySelectionStrict(
await exportRecords(exportTarget.target, "画像导出中", {
showDetailedProgress: false
})
);
if (selectedRecords.length === 0) {
setToolbarExportStatus(toolbar, "当前导出范围内没有选中的达人");
return;
}
const rows: AudienceProfileExportRow[] = [];
for (let index = 0; index < selectedRecords.length; index += 1) {
const record = selectedRecords[index];
setToolbarExportStatus(
toolbar,
`画像导出中 ${index + 1}/${selectedRecords.length}...`
);
const [profiles, businessAbility] = await Promise.all([
loadAudienceProfileSet(record),
loadBusinessAbilitySafe(record)
]);
rows.push({
businessAbility,
profiles,
record
});
}
if (
rows.every((row) =>
Object.values(row.profiles).every((profile) => profile.status === "failed")
)
) {
setToolbarExportStatus(toolbar, "画像导出失败,请稍后重试");
return;
}
options.onCsvReady?.(
buildAudienceCsv(rows, {
selectedHeaders: readAudienceProfileSelectedHeaders()
}),
buildAudienceProfileFilename()
);
setToolbarExportStatus(toolbar, "");
} catch (error) {
setToolbarExportStatus(
toolbar,
error instanceof Error ? error.message : "画像导出失败,请稍后重试"
);
} finally {
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 backendMetricsByAuthorId = await loadBackendMetricsMap(parsed.ids);
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,
backendMetricsByAuthorId.get(authorId)
)
);
}
options.onCsvReady?.(
buildAudienceCsv(rows, {
selectedHeaders: readAudienceProfileSelectedHeaders()
}),
buildAudienceProfileFilename(new Date(), "按ID导出")
);
setToolbarExportStatus(toolbar, "");
} catch (error) {
setToolbarExportStatus(
toolbar,
error instanceof Error ? error.message : "按ID导出失败请稍后重试"
);
} finally {
setToolbarBusyState(toolbar, false);
}
},
onConfigureAudienceProfileFields: async () => {
const groups = listAudienceProfileSelectableFieldGroups();
const selectedHeaders = readAudienceProfileSelectedHeaders();
const nextHeaders = await promptForAudienceProfileFields(
options.document,
groups,
selectedHeaders
);
if (nextHeaders === null) {
return;
}
saveAudienceProfileSelectedHeaders(nextHeaders);
setToolbarExportStatus(
toolbar,
`字段已保存(已选 ${nextHeaders.length}/${readAudienceProfileSelectableHeaders().length} 个字段)`
);
},
onSubmitBatch: async () => {
syncSelectionStateFromDom();
const exportTarget = readToolbarExportTarget(toolbar);
if (!exportTarget.target) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return;
}
const batchName = await promptBatchName();
if (batchName === null) {
return;
}
if (!batchName.trim()) {
setToolbarExportStatus(toolbar, "请输入批次名称");
return;
}
setToolbarBusyState(toolbar, true);
try {
const hasSelectedAuthors = selectedAuthorIds.size > 0;
const records = filterRecordsBySelection(
await exportRecords(
exportTarget.target,
hasSelectedAuthors ? "提交已选达人中" : "提交中",
{
showDetailedProgress: !hasSelectedAuthors
}
)
);
const authState = await getAuthState();
if (!authState.isAuthenticated) {
throw new Error("请先登录插件");
}
const payload = createBatchPayload({
authState,
batchName,
createdAt: new Date().toISOString(),
records
});
await submitBatch(payload);
setToolbarExportStatus(toolbar, "批次提交成功");
} catch (error) {
setToolbarExportStatus(
toolbar,
error instanceof Error ? error.message : "批次提交失败,请稍后重试"
);
} finally {
setToolbarBusyState(toolbar, false);
}
}
};
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
const ready = runSyncCycle();
return {
dispose() {
isDisposed = true;
observer.disconnect();
if (scheduledSyncTimeoutId !== null) {
options.window.clearTimeout(scheduledSyncTimeoutId);
scheduledSyncTimeoutId = null;
}
},
ready
};
async function hydrateCurrentPage(): Promise<void> {
const table = syncMarketTable(options.document);
if (!table) {
return;
}
const pageRows: Array<{
rowDom: MarketRowDom;
rowSnapshot: MarketRowSnapshot;
}> = [];
for (const rowDom of table.rows) {
const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) {
continue;
}
pageRows.push({
rowDom,
rowSnapshot
});
resultStore.upsertMarketRow(rowSnapshot);
}
const pendingRateRows: typeof pageRows = [];
const rowsNeedingBackendMetrics: typeof pageRows = [];
pageRows.forEach(({ rowDom, rowSnapshot }) => {
if (rowSnapshot.hasDirectRatesSource) {
resultStore.setAuthorSuccess(rowSnapshot.authorId, rowSnapshot.rates ?? {});
}
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
const needsRateFetch =
!hasSettledRateState(existingRecord) &&
!hasCompleteRates(existingRecord?.rates);
const needsBackendMetrics =
Boolean(searchBackendMetrics) &&
!hasSettledBackendMetricsState(existingRecord);
if (needsRateFetch) {
resultStore.setAuthorLoading(rowSnapshot.authorId);
pendingRateRows.push({
rowDom,
rowSnapshot
});
}
if (needsBackendMetrics) {
resultStore.setBackendMetricsLoading(rowSnapshot.authorId);
rowsNeedingBackendMetrics.push({
rowDom,
rowSnapshot
});
}
if (needsRateFetch || needsBackendMetrics) {
renderMarketRowState(rowDom, {
...(existingRecord ?? {
authorId: rowSnapshot.authorId,
authorName: rowSnapshot.authorName,
status: "idle" as const
}),
...rowSnapshot,
backendMetricsStatus: needsBackendMetrics ? "loading" : existingRecord?.backendMetricsStatus,
rates: existingRecord?.rates,
status: needsRateFetch || needsBackendMetrics ? "loading" : existingRecord?.status ?? "idle"
});
return;
}
if (existingRecord) {
renderMarketRowState(rowDom, existingRecord);
}
});
await Promise.all([
hydrateRatesForRows(pendingRateRows),
hydrateBackendMetricsForPage(rowsNeedingBackendMetrics)
]);
pageRows.forEach(({ rowDom, rowSnapshot }) => {
const record = resultStore.getRecord(rowSnapshot.authorId);
if (!record) {
return;
}
renderMarketRowState(rowDom, record);
});
}
async function hydrateRatesForRows(
pageRows: Array<{
rowDom: MarketRowDom;
rowSnapshot: MarketRowSnapshot;
}>
): Promise<void> {
if (pageRows.length === 0) {
return;
}
await Promise.all(
pageRows.map(async ({ rowSnapshot }) => {
const metricsResult = await loadAuthorMetrics(rowSnapshot.authorId);
if (metricsResult.success) {
resultStore.setAuthorSuccess(rowSnapshot.authorId, metricsResult.rates);
return;
}
resultStore.setAuthorFailed(rowSnapshot.authorId, metricsResult.reason);
})
);
}
async function hydrateBackendMetricsForPage(
pageRows: Array<{
rowDom: MarketRowDom;
rowSnapshot: MarketRowSnapshot;
}>
): Promise<void> {
if (!searchBackendMetrics || pageRows.length === 0) {
return;
}
try {
const rows = await searchBackendMetrics(
pageRows.map(({ rowSnapshot }) => rowSnapshot.authorId)
);
const rowMap = new Map(rows.map((row) => [row.starId, row]));
pageRows.forEach(({ rowSnapshot }) => {
const backendMetrics = rowMap.get(rowSnapshot.authorId);
if (backendMetrics) {
resultStore.setBackendMetricsSuccess(rowSnapshot.authorId, backendMetrics);
} else {
resultStore.setBackendMetricsMissing(rowSnapshot.authorId);
}
});
} catch {
pageRows.forEach(({ rowSnapshot }) => {
resultStore.setBackendMetricsFailed(rowSnapshot.authorId);
});
}
}
function applyCurrentView(): void {
runWithoutMutationSync(() => {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
const table = syncMarketTable(options.document);
if (!table) {
return;
}
syncPluginSortHeaders(options.document, {
activeSort,
onToggleSort: toggleSortFromHeader
});
const records = getVisibleOrderedRecords(table);
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
applyRowOrder(table, records.map((record) => record.authorId));
bindSelectionControls(table);
syncMarketSelectionState(table, selectedAuthorIds);
lastKnownPageSignature = readMarketPageSignature(options.document);
});
}
function bindSelectionControls(table: ReturnType<typeof syncMarketTable>): void {
if (!table) {
return;
}
table.rows.forEach((rowDom) => {
rowDom.selectionCheckbox.dataset.marketSelectionAuthorId = rowDom.authorId;
if (rowDom.selectionCheckbox.dataset.marketSelectionBound === "true") {
return;
}
rowDom.selectionCheckbox.dataset.marketSelectionBound = "true";
rowDom.selectionCheckbox.addEventListener("change", () => {
if (rowDom.selectionCheckbox.checked) {
selectedAuthorIds.add(rowDom.authorId);
} else {
selectedAuthorIds.delete(rowDom.authorId);
}
refreshSelectionControls();
});
});
if (!table.headerSelectionCheckbox) {
return;
}
if (table.headerSelectionCheckbox.dataset.marketSelectionBound === "true") {
return;
}
table.headerSelectionCheckbox.dataset.marketSelectionBound = "true";
table.headerSelectionCheckbox.addEventListener("change", () => {
const currentTable = syncMarketTable(options.document);
if (!currentTable) {
return;
}
const visibleRows = currentTable.rows.filter((rowDom) =>
rowDom.visibilityTargets.some((target) => !target.hidden)
);
const scopedRows = visibleRows.length > 0 ? visibleRows : currentTable.rows;
if (table.headerSelectionCheckbox?.checked) {
scopedRows.forEach((rowDom) => {
selectedAuthorIds.add(rowDom.authorId);
});
} else {
scopedRows.forEach((rowDom) => {
selectedAuthorIds.delete(rowDom.authorId);
});
}
refreshSelectionControls();
});
}
function refreshSelectionControls(): void {
const table = syncMarketTable(options.document);
if (!table) {
return;
}
bindSelectionControls(table);
syncMarketSelectionState(table, selectedAuthorIds);
}
function syncSelectionStateFromDom(): void {
const rowSelectionCheckboxes = Array.from(
options.document.querySelectorAll('[data-market-selection-checkbox="row"]')
).filter(
(element): element is HTMLInputElement => element instanceof HTMLInputElement
);
if (rowSelectionCheckboxes.length === 0) {
return;
}
rowSelectionCheckboxes.forEach((checkbox) => {
const authorId = checkbox.dataset.marketSelectionAuthorId?.trim();
if (!authorId) {
return;
}
if (checkbox.checked) {
selectedAuthorIds.add(authorId);
} else {
selectedAuthorIds.delete(authorId);
}
});
refreshSelectionControls();
}
function toggleSortFromHeader(field: MarketSortState["field"]): void {
activeSort = getNextSortState(activeSort, field);
applyCurrentView();
}
function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] {
const currentPageRecords = readCurrentPageRecords(table);
return applyFilterAndSort(currentPageRecords, {
sort: activeSort
});
}
async function exportRecords(
target: MarketExportTarget,
inProgressLabel = "导出中",
progressOptions: {
showDetailedProgress?: boolean;
} = {}
): Promise<MarketRecord[]> {
activeProgressLabel = inProgressLabel;
shouldShowDetailedProgress = progressOptions.showDetailedProgress ?? true;
setToolbarExportStatus(toolbar, `${inProgressLabel}...`);
if (target.mode === "count" && target.pageCount <= 1) {
await prepareCurrentPageForExport();
return getVisibleOrderedRecords();
}
const silentExportRecords = await silentExportController.exportRecords(target);
if (silentExportRecords) {
return hydrateExportRecords(
silentExportRecords.map((record) => ({
...record,
status: record.status ?? "idle"
}))
);
}
return exportRangeController.exportRecords(target);
}
function updateToolbarProgress(
currentPage: number,
totalPages: number | undefined
): void {
if (!shouldShowDetailedProgress) {
setToolbarExportStatus(toolbar, `${activeProgressLabel}...`);
return;
}
setToolbarExportStatus(
toolbar,
totalPages
? `${activeProgressLabel} ${currentPage}/${totalPages} 页...`
: `${activeProgressLabel}${currentPage}页...`
);
}
function filterRecordsBySelection(records: MarketRecord[]): MarketRecord[] {
if (selectedAuthorIds.size === 0) {
return records;
}
const selectedRecords = records.filter((record) =>
selectedAuthorIds.has(record.authorId)
);
return selectedRecords.length > 0 ? selectedRecords : records;
}
function filterRecordsBySelectionStrict(records: MarketRecord[]): MarketRecord[] {
if (selectedAuthorIds.size === 0) {
return [];
}
return records.filter((record) => selectedAuthorIds.has(record.authorId));
}
async function loadAudienceProfileSet(
record: MarketRecord
): Promise<AudienceProfileExportRow["profiles"]> {
const profiles = {} as AudienceProfileExportRow["profiles"];
for (const { kind, target } of audienceProfileTargets) {
try {
profiles[kind] = await loadAudienceProfile(record, target);
} catch (error) {
profiles[kind] = {
failureReason:
error instanceof Error ? error.message : "request-failed",
status: "failed"
};
}
}
return profiles;
}
async function loadBusinessAbilitySafe(
record: MarketRecord
): Promise<BusinessAbilityResult> {
try {
return await loadBusinessAbility(record);
} catch (error) {
return {
failureReason:
error instanceof Error ? error.message : "request-failed",
status: "failed"
};
}
}
async function loadAudienceProfileRowById(
authorId: string,
backendMetrics?: BackendMetrics
): Promise<AudienceProfileExportRow> {
const [baseRecord, metricsResult] = await Promise.all([
loadAuthorBaseInfoSafe(authorId),
loadAuthorMetricsSafe(authorId)
]);
const recordForRequests = {
...baseRecord,
authorName: baseRecord.authorName || authorId,
...(metricsResult.success ? { rates: metricsResult.rates } : {}),
...(backendMetrics
? { backendMetrics, backendMetricsStatus: "success" as const }
: {})
};
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"
};
}
}
async function loadAuthorMetricsSafe(
authorId: string
): Promise<MarketApiResult> {
try {
return await loadAuthorMetrics(authorId);
} catch {
return {
reason: "request-failed",
success: false
};
}
}
async function loadBackendMetricsMap(
authorIds: string[]
): Promise<Map<string, BackendMetrics>> {
const metricsMap = new Map<string, BackendMetrics>();
if (!searchBackendMetrics || authorIds.length === 0) {
return metricsMap;
}
try {
const rows = await searchBackendMetrics(authorIds);
rows.forEach((row) => {
const { starId, ...backendMetrics } = row;
metricsMap.set(starId, backendMetrics);
});
} catch {
return metricsMap;
}
return metricsMap;
}
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 "铁粉画像";
}
function readAudienceProfileSelectableHeaders(): string[] {
return listAudienceProfileSelectableFieldGroups().flatMap(
(group) => group.headers
);
}
function readAudienceProfileSelectedHeaders(): string[] {
const selectableHeaders = readAudienceProfileSelectableHeaders();
const selectableHeaderSet = new Set(selectableHeaders);
try {
const rawValue = options.window.localStorage?.getItem(
AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY
);
if (!rawValue) {
return selectableHeaders;
}
const parsedValue = JSON.parse(rawValue) as unknown;
if (!Array.isArray(parsedValue)) {
return selectableHeaders;
}
const selectedHeaders = parsedValue.filter(
(header): header is string =>
typeof header === "string" && selectableHeaderSet.has(header)
);
return selectedHeaders.length > 0 ? selectedHeaders : selectableHeaders;
} catch {
return selectableHeaders;
}
}
function saveAudienceProfileSelectedHeaders(headers: string[]): void {
const selectableHeaderSet = new Set(readAudienceProfileSelectableHeaders());
const selectedHeaders = headers.filter((header) =>
selectableHeaderSet.has(header)
);
try {
options.window.localStorage?.setItem(
AUDIENCE_PROFILE_FIELD_SELECTION_STORAGE_KEY,
JSON.stringify(selectedHeaders)
);
} catch {
// localStorage may be unavailable in hardened browser contexts.
}
}
async function prepareCurrentPageForExport(): Promise<void> {
await runSyncCycle();
await harvestCurrentPageForExport();
await runSyncCycle();
}
async function hydrateExportRecords(records: MarketRecord[]): Promise<MarketRecord[]> {
for (const record of records) {
resultStore.upsertMarketRow(record);
const existingRecord = resultStore.getRecord(record.authorId);
if (existingRecord?.status === "success" && existingRecord.rates) {
continue;
}
if (record.hasDirectRatesSource) {
const directRates = record.rates ?? {};
const hasAllRates =
Boolean(directRates.singleVideoAfterSearchRate) &&
Boolean(directRates.personalVideoAfterSearchRate);
resultStore.setAuthorSuccess(record.authorId, directRates);
if (hasAllRates) {
continue;
}
} else {
resultStore.setAuthorLoading(record.authorId);
}
const metricsResult = await loadAuthorMetrics(record.authorId);
if (metricsResult.success) {
resultStore.setAuthorSuccess(record.authorId, metricsResult.rates);
} else {
resultStore.setAuthorFailed(record.authorId, metricsResult.reason);
}
}
if (searchBackendMetrics) {
const backendTargetRecords = records.filter((record) => {
const existingRecord = resultStore.getRecord(record.authorId);
return !(
existingRecord?.backendMetricsStatus === "success" ||
existingRecord?.backendMetricsStatus === "missing"
);
});
if (backendTargetRecords.length > 0) {
backendTargetRecords.forEach((record) => {
resultStore.setBackendMetricsLoading(record.authorId);
});
try {
const backendRows = await searchBackendMetrics(
backendTargetRecords.map((record) => record.authorId)
);
const backendRowMap = new Map(backendRows.map((row) => [row.starId, row]));
backendTargetRecords.forEach((record) => {
const backendMetrics = backendRowMap.get(record.authorId);
if (backendMetrics) {
resultStore.setBackendMetricsSuccess(record.authorId, backendMetrics);
} else {
resultStore.setBackendMetricsMissing(record.authorId);
}
});
} catch {
backendTargetRecords.forEach((record) => {
resultStore.setBackendMetricsFailed(record.authorId);
});
}
}
}
return records.map((record) => toMarketRecord(record));
}
async function harvestCurrentPageForExport(): Promise<void> {
let hydrationSnapshot = await collectCurrentPageSnapshotsUntilSettled();
if (
hydrationSnapshot.missingDefaultFieldCount === 0 &&
hydrationSnapshot.blankExportFieldCount === 0
) {
return;
}
const table = syncMarketTable(options.document);
const scrollContainer = findCurrentPageScrollContainer(table);
if (!scrollContainer) {
return;
}
const originalScrollTop = scrollContainer.scrollTop;
const maxScrollTop = Math.max(
0,
scrollContainer.scrollHeight - scrollContainer.clientHeight
);
if (maxScrollTop <= 0) {
return;
}
const step = Math.max(scrollContainer.clientHeight, 240);
for (
let nextScrollTop = Math.min(originalScrollTop + step, maxScrollTop);
nextScrollTop > originalScrollTop && nextScrollTop <= maxScrollTop;
nextScrollTop = Math.min(nextScrollTop + step, maxScrollTop)
) {
setScrollTop(scrollContainer, nextScrollTop);
hydrationSnapshot = await collectCurrentPageSnapshotsUntilSettled();
if (
hydrationSnapshot.missingDefaultFieldCount === 0 &&
hydrationSnapshot.blankExportFieldCount === 0
) {
break;
}
if (nextScrollTop === maxScrollTop) {
break;
}
}
if (scrollContainer.scrollTop !== originalScrollTop) {
setScrollTop(scrollContainer, originalScrollTop);
}
}
function readCurrentPageRecords(table: ReturnType<typeof syncMarketTable>): MarketRecord[] {
if (!table) {
return [];
}
return table.rows
.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) {
return null;
}
return toMarketRecord(rowSnapshot);
})
.filter((record): record is MarketRecord => record !== null);
}
function toMarketRecord(rowSnapshot: MarketRowSnapshot): MarketRecord {
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
const authorName =
mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "";
const coreUserId = mergeStringValue(
existingRecord?.coreUserId,
rowSnapshot.coreUserId
);
const location = mergeStringValue(existingRecord?.location, rowSnapshot.location);
const price21To60s = mergeStringValue(
existingRecord?.price21To60s,
rowSnapshot.price21To60s
);
return {
...existingRecord,
...rowSnapshot,
authorName,
backendMetrics: mergeFieldMap(
existingRecord?.backendMetrics,
rowSnapshot.backendMetrics
),
backendMetricsStatus: existingRecord?.backendMetricsStatus ?? "idle",
coreUserId,
exportFields: withExportFieldFallbacks(
mergeFieldMap(existingRecord?.exportFields, rowSnapshot.exportFields),
{
authorName,
location,
price21To60s
}
),
location,
price21To60s,
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
status: existingRecord?.status ?? "idle"
} satisfies MarketRecord;
}
function collectCurrentPageSnapshots(): void {
readCurrentPageRows(options.document).forEach((rowSnapshot) => {
resultStore.upsertMarketRow(rowSnapshot);
});
}
function findCurrentPageScrollContainer(
table: ReturnType<typeof syncMarketTable>
): HTMLElement | null {
if (!table) {
return null;
}
const candidateScores = new Map<HTMLElement, { depth: number; scrollRange: number }>();
const candidateRoots = table.rows
.map((rowDom) => rowDom.row)
.filter((row): row is HTMLElement => row instanceof options.window.HTMLElement);
for (const rootElement of candidateRoots) {
let currentElement = rootElement.parentElement;
let depth = 0;
while (currentElement) {
if (isScrollableContainer(currentElement)) {
const scrollRange = currentElement.scrollHeight - currentElement.clientHeight;
const existingScore = candidateScores.get(currentElement);
if (!existingScore || depth < existingScore.depth) {
candidateScores.set(currentElement, {
depth,
scrollRange
});
}
}
depth += 1;
currentElement = currentElement.parentElement;
}
}
const rankedCandidates = Array.from(candidateScores.entries()).sort((left, right) => {
const [, leftScore] = left;
const [, rightScore] = right;
if (rightScore.scrollRange !== leftScore.scrollRange) {
return rightScore.scrollRange - leftScore.scrollRange;
}
return leftScore.depth - rightScore.depth;
});
return rankedCandidates[0]?.[0] ?? null;
}
function isScrollableContainer(element: HTMLElement): boolean {
const computedStyle = options.window.getComputedStyle(element);
return (
/auto|scroll|overlay/.test(computedStyle.overflowY) &&
element.scrollHeight > element.clientHeight
);
}
async function waitForDomSettled(): Promise<void> {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 0);
});
await Promise.resolve();
}
async function collectCurrentPageSnapshotsUntilSettled(): Promise<{
blankExportFieldCount: number;
fingerprint: string;
missingDefaultFieldCount: number;
}> {
let previousFingerprint = "";
let stablePassCount = 0;
let fingerprintStableSince = 0;
let lastSnapshot = {
blankExportFieldCount: 0,
fingerprint: "",
missingDefaultFieldCount: 0
};
for (let attempt = 0; attempt < 16; attempt += 1) {
await waitForDomSettled();
if (attempt > 0) {
await new Promise<void>((resolve) => {
options.window.setTimeout(
resolve,
previousFingerprint.includes("|missing:0") ? 25 : 50
);
});
await Promise.resolve();
}
collectCurrentPageSnapshots();
const hydrationSnapshot = readVisibleRowHydrationSnapshot();
lastSnapshot = hydrationSnapshot;
if (!hydrationSnapshot.fingerprint) {
stablePassCount = 0;
previousFingerprint = "";
continue;
}
if (hydrationSnapshot.fingerprint === previousFingerprint) {
stablePassCount += 1;
} else {
previousFingerprint = hydrationSnapshot.fingerprint;
stablePassCount = 1;
fingerprintStableSince = options.window.Date.now();
}
const stableForMs = options.window.Date.now() - fingerprintStableSince;
if (
hydrationSnapshot.missingDefaultFieldCount === 0 &&
hydrationSnapshot.blankExportFieldCount === 0 &&
stablePassCount >= 2
) {
return hydrationSnapshot;
}
if (
hydrationSnapshot.missingDefaultFieldCount === 0 &&
hydrationSnapshot.blankExportFieldCount > 0 &&
stablePassCount >= 2 &&
stableForMs >= 500
) {
return hydrationSnapshot;
}
}
return lastSnapshot;
}
function readVisibleRowHydrationSnapshot(): {
blankExportFieldCount: number;
fingerprint: string;
missingDefaultFieldCount: number;
} {
const table = syncMarketTable(options.document);
if (!table || table.rows.length === 0) {
return {
blankExportFieldCount: 0,
fingerprint: "",
missingDefaultFieldCount: 0
};
}
const parts = table.rows.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom);
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value === "string" && value.trim().length > 0
).length;
const blankExportFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value !== "string" || value.trim().length === 0
).length;
const hasAuthorField = hasTextValue(rowSnapshot.exportFields?.["达人信息"]);
const hasRepresentativeVideo = hasTextValue(
rowSnapshot.exportFields?.["代表视频"]
);
const hasPriceField =
hasTextValue(rowSnapshot.price21To60s) ||
hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]);
const missingDefaultFieldCount =
Number(!hasAuthorField) +
Number(!hasRepresentativeVideo) +
Number(!hasPriceField);
return [
rowSnapshot.authorId,
populatedFieldCount,
`blank:${blankExportFieldCount}`,
hasAuthorField ? "author" : "no-author",
hasRepresentativeVideo ? "video" : "no-video",
hasPriceField ? "price" : "no-price",
`missing:${missingDefaultFieldCount}`
].join(":");
});
return {
blankExportFieldCount: parts.reduce((count, part) => {
const match = part.match(/:blank:(\d+):/);
return count + Number(match?.[1] ?? 0);
}, 0),
fingerprint: parts.join("|"),
missingDefaultFieldCount: parts.reduce((count, part) => {
const match = part.match(/missing:(\d+)$/);
return count + Number(match?.[1] ?? 0);
}, 0)
};
}
function scheduleSync(): void {
if (isDisposed) {
return;
}
if (isSyncRunning) {
needsResync = true;
return;
}
if (isSyncScheduled) {
return;
}
isSyncScheduled = true;
scheduledSyncTimeoutId = options.window.setTimeout(() => {
scheduledSyncTimeoutId = null;
isSyncScheduled = false;
if (isDisposed) {
return;
}
void runSyncCycle();
}, 0);
}
function runWithoutMutationSync(callback: () => void): void {
if (isDisposed) {
return;
}
observer.disconnect();
try {
callback();
} finally {
startObserving();
}
}
function startObserving(): void {
if (isDisposed || !observationRoot) {
return;
}
observer.observe(observationRoot, {
childList: true,
subtree: true
});
}
async function runSyncCycle(): Promise<void> {
if (isDisposed) {
return;
}
if (isSyncRunning) {
needsResync = true;
return;
}
isSyncRunning = true;
try {
toolbar = ensurePluginToolbar(options.document, toolbarHandlers);
await hydrateCurrentPage();
applyCurrentView();
lastKnownPageSignature = readMarketPageSignature(options.document);
} finally {
isSyncRunning = false;
if (isDisposed) {
return;
}
if (needsResync) {
needsResync = false;
scheduleSync();
}
}
}
}
function setScrollTop(element: HTMLElement, top: number): void {
element.scrollTop = top;
element.dispatchEvent(new Event("scroll"));
}
function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
const table = syncMarketTable(document);
if (!table) {
return [];
}
return table.rows
.map((rowDom) => readRowSnapshot(rowDom))
.filter(
(row): row is MarketRowSnapshot =>
Boolean(row.authorId) && hasTextValue(row.authorName)
);
}
function countCurrentPageRows(document: Document): number {
const table = syncMarketTable(document);
if (!table) {
return 0;
}
return table.rows.filter((rowDom) => Boolean(rowDom.authorId)).length;
}
function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
return {
authorId: rowDom.authorId,
authorName: rowDom.authorName,
exportFields: rowDom.exportFields,
hasDirectRatesSource: rowDom.hasDirectRatesSource,
location: rowDom.location,
price21To60s: rowDom.price21To60s,
rates: rowDom.rates
};
}
function getNextSortState(
currentSort: MarketSortState | undefined,
field: MarketSortState["field"]
): MarketSortState | undefined {
if (!currentSort || currentSort.field !== field) {
return {
direction: "desc",
field
};
}
if (currentSort.direction === "desc") {
return {
direction: "asc",
field
};
}
return undefined;
}
function hasCompleteRates(
rates:
| {
personalVideoAfterSearchRate?: string;
singleVideoAfterSearchRate?: string;
}
| undefined
): boolean {
return Boolean(
rates?.singleVideoAfterSearchRate && rates?.personalVideoAfterSearchRate
);
}
function hasSettledRateState(record: MarketRecord | null): boolean {
if (!record) {
return false;
}
return record.status === "failed" || hasCompleteRates(record.rates);
}
function hasSettledBackendMetricsState(record: MarketRecord | null): boolean {
if (!record) {
return false;
}
return (
record.backendMetricsStatus === "success" ||
record.backendMetricsStatus === "missing" ||
record.backendMetricsStatus === "failed"
);
}
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (hasTextValue(value) || !hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
return (message: unknown) =>
Promise.resolve(
(
globalThis as typeof globalThis & {
chrome?: {
runtime?: {
sendMessage?: (payload: unknown) => Promise<unknown>;
};
};
}
).chrome?.runtime?.sendMessage?.(message)
);
}
async function readAuthState(
sendMessage: (message: unknown) => Promise<unknown>
): Promise<AuthStateValue> {
const response = await sendMessage({ type: "auth:get-state" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
throw new Error("请先登录插件");
}
return response.value;
}
async function readBatchSubmitAck(
sendMessage: (message: unknown) => Promise<unknown>,
payload: BatchPayload
): Promise<unknown> {
const response = await sendMessage({
payload,
type: "batch:submit"
});
if (
response &&
typeof response === "object" &&
(response as { ok?: unknown }).ok === true
) {
return (response as { value?: unknown }).value;
}
if (
response &&
typeof response === "object" &&
(response as { ok?: unknown }).ok === false &&
typeof (response as { error?: unknown }).error === "string"
) {
throw new Error((response as { error: string }).error);
}
throw new Error("批次提交失败,请稍后重试");
}
async function readBackendMetrics(
sendMessage: (message: unknown) => Promise<unknown>,
starIds: string[]
): Promise<Array<BackendMetrics & { starId: string }>> {
const response = await sendMessage({
type: "backend-metrics:search",
value: {
starIds
}
});
if (
isBackendMetricsResponseMessage(response) &&
response.ok &&
response.type === "backend-metrics:result"
) {
return response.value.rows;
}
throw new Error("后端指标加载失败");
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (hasTextValue(incoming) || !hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function withExportFieldFallbacks(
exportFields: Record<string, string | undefined> | undefined,
fallbackValues: {
authorName: string;
location: string | undefined;
price21To60s: string | undefined;
}
): Record<string, string | undefined> | undefined {
if (!exportFields) {
return undefined;
}
const nextExportFields = {
...exportFields
};
if (
"达人信息" in nextExportFields &&
!hasTextValue(nextExportFields["达人信息"]) &&
hasTextValue(fallbackValues.authorName)
) {
nextExportFields["达人信息"] = fallbackValues.authorName;
}
if (
"地区" in nextExportFields &&
!hasTextValue(nextExportFields["地区"]) &&
hasTextValue(fallbackValues.location)
) {
nextExportFields["地区"] = fallbackValues.location;
}
if (
"21-60s报价" in nextExportFields &&
!hasTextValue(nextExportFields["21-60s报价"]) &&
hasTextValue(fallbackValues.price21To60s)
) {
nextExportFields["21-60s报价"] = fallbackValues.price21To60s;
}
return nextExportFields;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function hasRuntimeMessageSender(): boolean {
return Boolean(
(
globalThis as typeof globalThis & {
chrome?: {
runtime?: {
sendMessage?: unknown;
};
};
}
).chrome?.runtime?.sendMessage
);
}
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");
const labelPart = label ? `_${label}` : "";
return `达人连接用户画像${labelPart}_${year}${month}${day}_${hour}${minute}.csv`;
}