import { buildMarketCsv } from "./csv-exporter"; import { applyRowOrder, applyRowVisibility, renderMarketRowState, 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 } from "./plugin-toolbar"; import { readToolbarExportTarget, setToolbarBusyState, setToolbarExportStatus } from "./plugin-toolbar"; import { createMarketResultStore } from "./result-store"; import type { MarketApiResult, MarketFilterState, MarketExportTarget, MarketRecord, MarketRowSnapshot, MarketSortState } from "./types"; interface MutationObserverLike { disconnect(): void; observe(target: Node, options?: MutationObserverInit): void; } export interface CreateMarketControllerOptions { buildCsv?: (records: MarketRecord[]) => string; document: Document; loadAuthorMetrics?: (authorId: string) => Promise; mutationObserverFactory?: ( callback: MutationCallback ) => MutationObserverLike; onCsvReady?: (csv: string) => void; resultStore?: ReturnType; window: Window; } export function createMarketController(options: CreateMarketControllerOptions) { const marketApiClient = createMarketApiClient(); const resultStore = options.resultStore ?? createMarketResultStore(); const loadAuthorMetrics = options.loadAuthorMetrics ?? marketApiClient.loadAuthorAseInfo; const buildCsv = options.buildCsv ?? buildMarketCsv; const mutationObserverFactory = options.mutationObserverFactory ?? ((callback: MutationCallback) => new MutationObserver(callback)); const exportRangeController = createExportRangeController({ document: options.document, onProgress: ({ currentPage, totalPages }) => { setToolbarExportStatus( toolbar, totalPages ? `导出中 ${currentPage}/${totalPages} 页...` : `导出中 第${currentPage}页...` ); }, prepareCurrentPageForExport: prepareCurrentPageForExport, readCurrentPageRecords: () => getVisibleOrderedRecords(), window: options.window }); let activeFilters: MarketFilterState = {}; let activeSort: MarketSortState | undefined; let isSyncRunning = false; let isSyncScheduled = false; let needsResync = false; const observer = mutationObserverFactory(() => { scheduleSync(); }); const observationRoot = options.document.body ?? options.document.documentElement; if (observationRoot) { observer.observe(observationRoot, { childList: true, subtree: true }); } const toolbar = ensurePluginToolbar(options.document, { onApplyFilter: async () => { activeFilters = { personalVideoAfterSearchRateMin: parseNumberValue( toolbar.personalFilterInput.value ), singleVideoAfterSearchRateMin: parseNumberValue( toolbar.singleFilterInput.value ) }; applyCurrentView(); }, onApplySort: async () => { activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect); applyCurrentView(); }, onExport: async () => { const exportTarget = readToolbarExportTarget(toolbar); if (!exportTarget.target) { setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效"); return; } setToolbarBusyState(toolbar, true); try { const records = await exportRecords(exportTarget.target); options.onCsvReady?.(buildCsv(records)); setToolbarExportStatus(toolbar, ""); } catch (error) { setToolbarExportStatus( toolbar, error instanceof Error ? error.message : "导出失败,请稍后重试" ); } finally { setToolbarBusyState(toolbar, false); } }, onSubmitBatch: async () => { setToolbarExportStatus(toolbar, "批次提交功能开发中"); } }); const ready = runSyncCycle(); return { dispose() { observer.disconnect(); }, ready }; async function hydrateCurrentPage(): Promise { const table = syncMarketTable(options.document); if (!table) { return; } for (const rowDom of table.rows) { const rowSnapshot = readRowSnapshot(rowDom); if (!rowSnapshot.authorId) { continue; } resultStore.upsertMarketRow(rowSnapshot); const existingRecord = resultStore.getRecord(rowSnapshot.authorId); if (existingRecord?.status === "success" && existingRecord.rates) { renderMarketRowState(rowDom, existingRecord); continue; } if (existingRecord?.status === "failed") { renderMarketRowState(rowDom, existingRecord); continue; } if (existingRecord?.status === "loading") { renderMarketRowState(rowDom, { ...rowSnapshot, status: "loading" }); continue; } if (rowSnapshot.hasDirectRatesSource) { const directRates = rowSnapshot.rates ?? {}; const hasAllRates = Boolean(directRates.singleVideoAfterSearchRate) && Boolean(directRates.personalVideoAfterSearchRate); resultStore.setAuthorSuccess(rowSnapshot.authorId, directRates); renderMarketRowState(rowDom, { ...rowSnapshot, rates: directRates, status: "success" }); if (hasAllRates) { continue; } } resultStore.setAuthorLoading(rowSnapshot.authorId); renderMarketRowState(rowDom, { ...rowSnapshot, rates: resultStore.getRecord(rowSnapshot.authorId)?.rates, status: "loading" }); const metricsResult = await loadAuthorMetrics(rowSnapshot.authorId); if (metricsResult.success) { resultStore.setAuthorSuccess(rowSnapshot.authorId, metricsResult.rates); renderMarketRowState(rowDom, { ...rowSnapshot, status: "success", rates: metricsResult.rates }); continue; } resultStore.setAuthorFailed(rowSnapshot.authorId, metricsResult.reason); renderMarketRowState(rowDom, { ...rowSnapshot, failureReason: metricsResult.reason, status: "failed" }); } } function applyCurrentView(): void { const table = syncMarketTable(options.document); if (!table) { return; } const records = getVisibleOrderedRecords(table); applyRowVisibility(table, new Set(records.map((record) => record.authorId))); applyRowOrder(table, records.map((record) => record.authorId)); } function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] { const currentPageRecords = readCurrentPageRecords(table); return applyFilterAndSort(currentPageRecords, { filters: activeFilters, sort: activeSort }); } async function exportRecords(target: MarketExportTarget): Promise { if (target.mode === "count" && target.pageCount <= 1) { setToolbarExportStatus(toolbar, "导出中..."); await prepareCurrentPageForExport(); return getVisibleOrderedRecords(); } return exportRangeController.exportRecords(target); } async function prepareCurrentPageForExport(): Promise { await runSyncCycle(); await harvestCurrentPageForExport(); } async function harvestCurrentPageForExport(): Promise { await collectCurrentPageSnapshotsUntilSettled(); 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); await collectCurrentPageSnapshotsUntilSettled(); if (nextScrollTop === maxScrollTop) { break; } } if (scrollContainer.scrollTop !== originalScrollTop) { setScrollTop(scrollContainer, originalScrollTop); await collectCurrentPageSnapshotsUntilSettled(); } } function readCurrentPageRecords(table: ReturnType): MarketRecord[] { if (!table) { return []; } return table.rows .map((rowDom) => { const rowSnapshot = readRowSnapshot(rowDom); if (!rowSnapshot.authorId) { return null; } const existingRecord = resultStore.getRecord(rowSnapshot.authorId); return { ...existingRecord, ...rowSnapshot, authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "", exportFields: mergeFieldMap( existingRecord?.exportFields, rowSnapshot.exportFields ), location: mergeStringValue(existingRecord?.location, rowSnapshot.location), price21To60s: mergeStringValue( existingRecord?.price21To60s, rowSnapshot.price21To60s ), rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates), status: existingRecord?.status ?? "idle" } satisfies MarketRecord; }) .filter((record): record is MarketRecord => record !== null); } function collectCurrentPageSnapshots(): void { readCurrentPageRows(options.document).forEach((rowSnapshot) => { resultStore.upsertMarketRow(rowSnapshot); }); } function findCurrentPageScrollContainer( table: ReturnType ): HTMLElement | null { if (!table) { return null; } const seenElements = new Set(); 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; while (currentElement) { if ( !seenElements.has(currentElement) && isScrollableContainer(currentElement) ) { return currentElement; } seenElements.add(currentElement); currentElement = currentElement.parentElement; } } return 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 { let previousFingerprint = ""; let stablePassCount = 0; for (let attempt = 0; attempt < 9; 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(); if (!hydrationSnapshot.fingerprint) { stablePassCount = 0; previousFingerprint = ""; continue; } if (hydrationSnapshot.fingerprint === previousFingerprint) { stablePassCount += 1; } else { previousFingerprint = hydrationSnapshot.fingerprint; stablePassCount = 1; } if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) { return; } } } function readVisibleRowHydrationSnapshot(): { fingerprint: string; missingDefaultFieldCount: number; } { const table = syncMarketTable(options.document); if (!table || table.rows.length === 0) { return { 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 hasRepresentativeVideo = hasTextValue( rowSnapshot.exportFields?.["代表视频"] ); const hasPriceField = hasTextValue(rowSnapshot.price21To60s) || hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]); const missingDefaultFieldCount = Number(!hasRepresentativeVideo) + Number(!hasPriceField); return [ rowSnapshot.authorId, populatedFieldCount, hasRepresentativeVideo ? "video" : "no-video", hasPriceField ? "price" : "no-price", `missing:${missingDefaultFieldCount}` ].join(":"); }); return { 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 (isSyncRunning) { needsResync = true; return; } if (isSyncScheduled) { return; } isSyncScheduled = true; options.window.setTimeout(() => { isSyncScheduled = false; void runSyncCycle(); }, 0); } async function runSyncCycle(): Promise { if (isSyncRunning) { needsResync = true; return; } isSyncRunning = true; try { await hydrateCurrentPage(); applyCurrentView(); } finally { isSyncRunning = false; 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)); } function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot { return { authorId: rowDom.authorId, authorName: rowDom.authorName, exportFields: rowDom.exportFields, hasDirectRatesSource: rowDom.hasDirectRatesSource, price21To60s: rowDom.price21To60s, rates: rowDom.rates }; } function parseNumberValue(value: string): number | undefined { if (!value) { return undefined; } const parsedValue = Number(value); return Number.isFinite(parsedValue) ? parsedValue : undefined; } function readSortState( fieldSelect: HTMLSelectElement, directionSelect: HTMLSelectElement ): MarketSortState | undefined { if (!fieldSelect.value) { return undefined; } return { direction: directionSelect.value === "asc" ? "asc" : "desc", field: fieldSelect.value as MarketSortState["field"] }; } 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 mergeStringValue( current: string | undefined, incoming: string | undefined ): string | undefined { if (hasTextValue(incoming) || !hasTextValue(current)) { return incoming ?? current; } return current; } function hasTextValue(value: string | undefined): boolean { return typeof value === "string" && value.trim().length > 0; }