573 lines
16 KiB
TypeScript
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;
|
|
}
|