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; loadAuthorBaseInfo?: (authorId: string) => Promise; loadBusinessAbility?: ( record: MarketRecord ) => Promise; loadAudienceProfile?: ( record: MarketRecord, target: AudienceProfileRequestTarget ) => Promise; loadAuthorMetrics?: (authorId: string) => Promise; searchBackendMetrics?: (starIds: string[]) => Promise< Array >; mutationObserverFactory?: ( callback: MutationCallback ) => MutationObserverLike; onCsvReady?: (csv: string, filename?: string) => void; promptAuthorIds?: () => Promise | string | null; promptBatchName?: () => Promise | string | null; resultStore?: ReturnType; submitBatch?: (payload: BatchPayload) => Promise; 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(); let toolbar: ReturnType | 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 { 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 { 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 { 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): 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 { 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 { 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 { 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 { 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 { 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 { try { return await loadAuthorMetrics(authorId); } catch { return { reason: "request-failed", success: false }; } } async function loadBackendMetricsMap( authorIds: string[] ): Promise> { const metricsMap = new Map(); 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 { await runSyncCycle(); await harvestCurrentPageForExport(); await runSyncCycle(); } async function hydrateExportRecords(records: MarketRecord[]): Promise { 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 { 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): 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 ): HTMLElement | null { if (!table) { return null; } const candidateScores = new Map(); 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 { await new Promise((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((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 { 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>( current: T | undefined, incoming: T | undefined ): T | undefined { if (!current && !incoming) { return undefined; } const merged = { ...(current ?? {}) } as Record; 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 { return (message: unknown) => Promise.resolve( ( globalThis as typeof globalThis & { chrome?: { runtime?: { sendMessage?: (payload: unknown) => Promise; }; }; } ).chrome?.runtime?.sendMessage?.(message) ); } async function readAuthState( sendMessage: (message: unknown) => Promise ): Promise { 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, payload: BatchPayload ): Promise { 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, starIds: string[] ): Promise> { 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 | undefined, fallbackValues: { authorName: string; location: string | undefined; price21To60s: string | undefined; } ): Record | 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`; }