277 lines
7.7 KiB
TypeScript
277 lines
7.7 KiB
TypeScript
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<void>;
|
|
readCurrentPageRecords(): MarketRecord[];
|
|
window: Window;
|
|
}
|
|
|
|
export function createExportRangeController(options: ExportRangeControllerOptions) {
|
|
return {
|
|
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
|
|
const mergedRecords = new Map<string, MarketRecord>();
|
|
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<boolean> {
|
|
const previousPageState = parsePageSignature(previousSignature);
|
|
|
|
for (let attempt = 0; attempt < 60; attempt += 1) {
|
|
await new Promise<void>((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<boolean> {
|
|
let stableAttemptCount = 0;
|
|
let lastReadyFingerprint = "";
|
|
|
|
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
await new Promise<void>((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<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 mergeStatus(
|
|
current: MarketRecordStatus,
|
|
incoming: MarketRecordStatus
|
|
): MarketRecordStatus {
|
|
const priority: Record<MarketRecordStatus, number> = {
|
|
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;
|
|
}
|