release: 0.2.0421.2
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user