573 lines
16 KiB
TypeScript

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<MarketApiResult>;
mutationObserverFactory?: (
callback: MutationCallback
) => MutationObserverLike;
onCsvReady?: (csv: string) => void;
resultStore?: ReturnType<typeof createMarketResultStore>;
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<void> {
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<MarketRecord[]> {
if (target.mode === "count" && target.pageCount <= 1) {
setToolbarExportStatus(toolbar, "导出中...");
await prepareCurrentPageForExport();
return getVisibleOrderedRecords();
}
return exportRangeController.exportRecords(target);
}
async function prepareCurrentPageForExport(): Promise<void> {
await runSyncCycle();
await harvestCurrentPageForExport();
}
async function harvestCurrentPageForExport(): Promise<void> {
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<typeof syncMarketTable>): 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<typeof syncMarketTable>
): HTMLElement | null {
if (!table) {
return null;
}
const seenElements = new Set<HTMLElement>();
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<void> {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 0);
});
await Promise.resolve();
}
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
let previousFingerprint = "";
let stablePassCount = 0;
for (let attempt = 0; attempt < 9; attempt += 1) {
await waitForDomSettled();
if (attempt > 0) {
await new Promise<void>((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<void> {
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<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 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;
}