import { findNextPageControl, isPageControlDisabled, readMarketPageSignature } from "./dom-sync"; import type { MarketExportTarget, MarketRecord, MarketRecordStatus } from "./types"; interface ExportRangeControllerOptions { document: Document; onProgress?: (state: { currentPage: number; totalPages?: number }) => void; prepareCurrentPageForExport(): Promise; readCurrentPageRecords(): MarketRecord[]; window: Window; } export function createExportRangeController(options: ExportRangeControllerOptions) { return { async exportRecords(target: MarketExportTarget): Promise { const mergedRecords = new Map(); let currentPage = 0; let expectedMinimumRowCount: number | undefined; while (true) { currentPage += 1; options.onProgress?.({ currentPage, totalPages: target.mode === "count" ? target.pageCount : undefined }); const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount); if (!currentPageReady) { throw new Error(`第 ${currentPage} 页加载超时,请稍后重试`); } await options.prepareCurrentPageForExport(); const currentPageRecords = options.readCurrentPageRecords(); currentPageRecords.forEach((record) => { const existingRecord = mergedRecords.get(record.authorId); mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record)); }); expectedMinimumRowCount = Math.max( expectedMinimumRowCount ?? 0, currentPageRecords.length ); if (target.mode === "count" && currentPage >= target.pageCount) { break; } const previousSignature = readMarketPageSignature(options.document); const nextPageControl = findNextPageControl(options.document); if (!nextPageControl || isPageControlDisabled(nextPageControl)) { break; } nextPageControl.click(); const pageChanged = await waitForPageChange(previousSignature); if (!pageChanged) { throw new Error(`第 ${currentPage + 1} 页导出失败,请稍后重试`); } } return Array.from(mergedRecords.values()); } }; async function waitForPageChange(previousSignature: string): Promise { const previousPageState = parsePageSignature(previousSignature); for (let attempt = 0; attempt < 60; attempt += 1) { await new Promise((resolve) => { options.window.setTimeout(resolve, 50); }); await Promise.resolve(); const nextSignature = readMarketPageSignature(options.document); const nextPageState = parsePageSignature(nextSignature); if (hasLoadedNextPage(previousPageState, nextPageState)) { return true; } } return false; } async function waitForCurrentPageReady( expectedMinimumRowCount: number | undefined ): Promise { let stableAttemptCount = 0; let lastReadyFingerprint = ""; for (let attempt = 0; attempt < 80; attempt += 1) { await new Promise((resolve) => { options.window.setTimeout(resolve, 150); }); await Promise.resolve(); const pageState = readCurrentPageState(); if (!pageState.authorIds || pageState.rowCount <= 0) { stableAttemptCount = 0; lastReadyFingerprint = ""; continue; } if ( typeof expectedMinimumRowCount === "number" && expectedMinimumRowCount > 0 && !pageState.isTerminalPage && pageState.rowCount < expectedMinimumRowCount ) { stableAttemptCount = 0; lastReadyFingerprint = ""; continue; } const readyFingerprint = [ pageState.pageToken, pageState.authorIds, String(pageState.rowCount), pageState.isTerminalPage ? "terminal" : "paged" ].join("::"); if (readyFingerprint === lastReadyFingerprint) { stableAttemptCount += 1; } else { lastReadyFingerprint = readyFingerprint; stableAttemptCount = 1; } if (stableAttemptCount >= 6) { return true; } } return false; } function readCurrentPageState(): { authorIds: string; isTerminalPage: boolean; pageToken: string; rowCount: number; } { const pageSignature = parsePageSignature(readMarketPageSignature(options.document)); const nextPageControl = findNextPageControl(options.document); return { authorIds: pageSignature.authorIds, isTerminalPage: isPageControlDisabled(nextPageControl), pageToken: pageSignature.pageToken, rowCount: options.readCurrentPageRecords().length }; } } function parsePageSignature(signature: string): { authorIds: string; pageToken: string; } { const separatorIndex = signature.indexOf("::"); if (separatorIndex < 0) { return { authorIds: "", pageToken: signature.trim() }; } return { authorIds: signature.slice(separatorIndex + 2).trim(), pageToken: signature.slice(0, separatorIndex).trim() }; } function hasLoadedNextPage( previousPageState: { authorIds: string; pageToken: string; }, nextPageState: { authorIds: string; pageToken: string; } ): boolean { if (!nextPageState.authorIds) { return false; } if (nextPageState.pageToken || previousPageState.pageToken) { return nextPageState.pageToken !== previousPageState.pageToken; } return nextPageState.authorIds !== previousPageState.authorIds; } function mergeMarketRecord( existingRecord: MarketRecord | undefined, incomingRecord: MarketRecord ): MarketRecord { if (!existingRecord) { return { ...incomingRecord, exportFields: mergeFieldMap(undefined, incomingRecord.exportFields), rates: mergeFieldMap(undefined, incomingRecord.rates) }; } return { ...existingRecord, ...incomingRecord, authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "", exportFields: mergeFieldMap( existingRecord.exportFields, incomingRecord.exportFields ), failureReason: incomingRecord.failureReason ?? existingRecord.failureReason, hasDirectRatesSource: existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource, location: mergeStringValue(existingRecord.location, incomingRecord.location), price21To60s: mergeStringValue( existingRecord.price21To60s, incomingRecord.price21To60s ), rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates), status: mergeStatus(existingRecord.status, incomingRecord.status) }; } 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 mergeStatus( current: MarketRecordStatus, incoming: MarketRecordStatus ): MarketRecordStatus { const priority: Record = { failed: 1, idle: 0, loading: 2, missing: -1, success: 3 }; return priority[incoming] >= priority[current] ? incoming : current; } 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; }