1718 lines
50 KiB
TypeScript
1718 lines
50 KiB
TypeScript
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`;
|
||
}
|