From 233de287133ab6af84792195a53cbd0b854ec5ff Mon Sep 17 00:00:00 2001 From: admin123 Date: Thu, 23 Apr 2026 14:35:14 +0800 Subject: [PATCH] fix: stabilize market metrics hydration and sorting --- src/content/market/dom-sync.ts | 94 +++++++++++++-- src/content/market/export-range-controller.ts | 56 +++++---- src/content/market/filter-sort-controller.ts | 28 ++++- src/content/market/index.ts | 9 ++ tests/filter-sort-controller.test.ts | 63 ++++++++++ tests/market-content-entry.test.ts | 113 ++++++++++++++++-- tests/market-dom-sync.test.ts | 44 +++++-- 7 files changed, 362 insertions(+), 45 deletions(-) diff --git a/src/content/market/dom-sync.ts b/src/content/market/dom-sync.ts index fb24cf7..7330430 100644 --- a/src/content/market/dom-sync.ts +++ b/src/content/market/dom-sync.ts @@ -61,6 +61,7 @@ const SORTABLE_MARKET_FIELDS = [ type RowOrderTarget = { container: HTMLElement; + mode: "css" | "dom"; node: HTMLElement; }; @@ -106,12 +107,7 @@ export function readMarketPageSignature(root: ParentNode): string { document ?.querySelector(".el-pagination .number.active, .xt-pagination .number.active") ?.textContent?.trim() ?? ""; - const table = syncMarketTable(root); - const authorIds = - table?.rows - .map((row) => row.authorId) - .filter((authorId) => Boolean(authorId)) - .join("|") ?? ""; + const authorIds = readRawAuthorIds(root).join("|"); return `${explicitPageIndex || activePageIndex}::${authorIds}`; } @@ -209,6 +205,9 @@ export function applyRowOrder( orderedAuthorIds: string[] ): void { const rowById = new Map(table.rows.map((rowDom) => [rowDom.authorId, rowDom])); + const orderByAuthorId = new Map( + orderedAuthorIds.map((authorId, index) => [authorId, index]) + ); orderedAuthorIds.forEach((authorId) => { const rowDom = rowById.get(authorId); @@ -216,7 +215,17 @@ export function applyRowOrder( return; } - rowDom.orderTargets.forEach(({ container, node }) => { + rowDom.orderTargets.forEach(({ container, mode, node }) => { + const visualOrder = orderByAuthorId.get(authorId) ?? orderedAuthorIds.length; + if (mode === "css") { + container.dataset.marketOrderMode = "css"; + container.style.display = "flex"; + container.style.flexDirection = "column"; + node.style.order = String(visualOrder); + return; + } + + container.dataset.marketOrderMode = "dom"; container.appendChild(node); }); }); @@ -283,6 +292,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null { orderTargets: [ { container: body, + mode: "dom", node: row } ], @@ -324,6 +334,75 @@ function syncDivGridMarketTable(root: ParentNode): MarketTableDom | null { return null; } +function readRawAuthorIds(root: ParentNode): string[] { + const document = getOwnerDocument(root); + const syntheticAuthorIds = readSyntheticAuthorIds(root); + if (syntheticAuthorIds && syntheticAuthorIds.length > 0) { + return syntheticAuthorIds; + } + + const divGridAuthorIds = readDivGridAuthorIds(root); + if (divGridAuthorIds && divGridAuthorIds.length > 0) { + return divGridAuthorIds; + } + + if (!document) { + return []; + } + + return readSerializedMarketRows(document) + .map((row) => row.authorId) + .filter((authorId) => Boolean(authorId)); +} + +function readSyntheticAuthorIds(root: ParentNode): string[] | null { + const body = root.querySelector("[data-market-body]") as HTMLElement | null; + if (!body) { + return null; + } + + return Array.from(body.querySelectorAll("[data-market-row]")) + .map((row) => + row instanceof HTMLElement ? row.dataset.authorId ?? "" : "" + ) + .filter((authorId) => Boolean(authorId)); +} + +function readDivGridAuthorIds(root: ParentNode): string[] | null { + const document = getOwnerDocument(root); + if (!document) { + return null; + } + + const marketRoot = document.querySelector(".base-author-list"); + if (!(marketRoot instanceof document.defaultView!.HTMLElement)) { + return null; + } + + const bodySection = Array.from(marketRoot.querySelectorAll(".section-wrapper")).find( + (section): section is HTMLElement => + section instanceof document.defaultView!.HTMLElement && + !section.classList.contains("sticky-header") + ); + const authorSection = bodySection + ? Array.from(bodySection.children).find( + (child): child is HTMLElement => + child instanceof document.defaultView!.HTMLElement && + child.querySelector(".content-column .content-cell") + ) ?? null + : null; + const authorColumn = authorSection + ? getDirectContentColumns(authorSection)[0] ?? null + : null; + if (!authorColumn) { + return null; + } + + return getDirectContentCells(authorColumn) + .map((cell) => extractAuthorId(cell)) + .filter((authorId) => Boolean(authorId)); +} + function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { const headerSection = root.querySelector( ".section-wrapper.sticky-header" @@ -520,6 +599,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null { return { container, + mode: "css", node: cell }; }) diff --git a/src/content/market/export-range-controller.ts b/src/content/market/export-range-controller.ts index 7b0b3b2..9221399 100644 --- a/src/content/market/export-range-controller.ts +++ b/src/content/market/export-range-controller.ts @@ -10,6 +10,7 @@ interface ExportRangeControllerOptions { onProgress?: (state: { currentPage: number; totalPages?: number }) => void; prepareCurrentPageForExport(): Promise; readCurrentPageRecords(): MarketRecord[]; + readCurrentPageRowCount(): number; window: Window; } @@ -26,13 +27,10 @@ export function createExportRangeController(options: ExportRangeControllerOption currentPage, totalPages: target.mode === "count" ? target.pageCount : undefined }); - const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount); - if (!currentPageReady) { + const currentPageRecords = await preparePageRecords(expectedMinimumRowCount); + if (!currentPageRecords) { 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)); @@ -63,6 +61,33 @@ export function createExportRangeController(options: ExportRangeControllerOption } }; + async function preparePageRecords( + expectedMinimumRowCount: number | undefined + ): Promise { + for (let attempt = 0; attempt < 4; attempt += 1) { + const currentPageReady = await waitForCurrentPageReady(); + if (!currentPageReady) { + return null; + } + + await options.prepareCurrentPageForExport(); + const currentPageRecords = options.readCurrentPageRecords(); + if ( + currentPageRecords.length > 0 && + ( + typeof expectedMinimumRowCount !== "number" || + expectedMinimumRowCount <= 0 || + isCurrentPageTerminal() || + currentPageRecords.length >= expectedMinimumRowCount + ) + ) { + return currentPageRecords; + } + } + + return null; + } + async function waitForPageChange(previousSignature: string): Promise { const previousPageState = parsePageSignature(previousSignature); @@ -82,9 +107,7 @@ export function createExportRangeController(options: ExportRangeControllerOption return false; } - async function waitForCurrentPageReady( - expectedMinimumRowCount: number | undefined - ): Promise { + async function waitForCurrentPageReady(): Promise { let stableAttemptCount = 0; let lastReadyFingerprint = ""; @@ -101,17 +124,6 @@ export function createExportRangeController(options: ExportRangeControllerOption continue; } - if ( - typeof expectedMinimumRowCount === "number" && - expectedMinimumRowCount > 0 && - !pageState.isTerminalPage && - pageState.rowCount < expectedMinimumRowCount - ) { - stableAttemptCount = 0; - lastReadyFingerprint = ""; - continue; - } - const readyFingerprint = [ pageState.pageToken, pageState.authorIds, @@ -146,9 +158,13 @@ export function createExportRangeController(options: ExportRangeControllerOption authorIds: pageSignature.authorIds, isTerminalPage: isPageControlDisabled(nextPageControl), pageToken: pageSignature.pageToken, - rowCount: options.readCurrentPageRecords().length + rowCount: options.readCurrentPageRowCount() }; } + + function isCurrentPageTerminal(): boolean { + return isPageControlDisabled(findNextPageControl(options.document)); + } } function parsePageSignature(signature: string): { diff --git a/src/content/market/filter-sort-controller.ts b/src/content/market/filter-sort-controller.ts index 5bc615d..19c4f9f 100644 --- a/src/content/market/filter-sort-controller.ts +++ b/src/content/market/filter-sort-controller.ts @@ -89,7 +89,7 @@ function compareRateSortRecords( const rightLowerBound = parseRateLowerBound(rightValue ?? null); if (leftLowerBound == null && rightLowerBound == null) { - return 0; + return compareRecordIdentity(leftRecord, rightRecord); } if (leftLowerBound == null) { @@ -107,7 +107,11 @@ function compareRateSortRecords( } const tieBreak = compareRateValues(leftValue, rightValue); - return sort.direction === "asc" ? tieBreak : -tieBreak; + if (tieBreak !== 0) { + return sort.direction === "asc" ? tieBreak : -tieBreak; + } + + return compareRecordIdentity(leftRecord, rightRecord); } function compareBackendMetricRecords( @@ -120,7 +124,7 @@ function compareBackendMetricRecords( const rightValue = parseBackendMetricValue(rightRecord.backendMetrics?.[field]); if (leftValue == null && rightValue == null) { - return 0; + return compareRecordIdentity(leftRecord, rightRecord); } if (leftValue == null) { @@ -131,7 +135,11 @@ function compareBackendMetricRecords( return -1; } - return sort.direction === "asc" ? leftValue - rightValue : rightValue - leftValue; + if (leftValue !== rightValue) { + return sort.direction === "asc" ? leftValue - rightValue : rightValue - leftValue; + } + + return compareRecordIdentity(leftRecord, rightRecord); } function parseBackendMetricValue(value: string | null | undefined): number | null { @@ -156,3 +164,15 @@ function isRateSortField( field === "personalVideoAfterSearchRate" ); } + +function compareRecordIdentity( + leftRecord: MarketRecord, + rightRecord: MarketRecord +): number { + const authorIdCompare = leftRecord.authorId.localeCompare(rightRecord.authorId); + if (authorIdCompare !== 0) { + return authorIdCompare; + } + + return leftRecord.authorName.localeCompare(rightRecord.authorName); +} diff --git a/src/content/market/index.ts b/src/content/market/index.ts index d025aa5..d406faf 100644 --- a/src/content/market/index.ts +++ b/src/content/market/index.ts @@ -3,6 +3,7 @@ import { createBatchPayload, type BatchPayload } from "./batch-payload"; import { applyRowOrder, applyRowVisibility, + readMarketPageSignature, renderMarketRowState, syncPluginSortHeaders, syncMarketTable, @@ -97,8 +98,14 @@ export function createMarketController(options: CreateMarketControllerOptions) { let activeSort: MarketSortState | undefined; let isSyncRunning = false; let isSyncScheduled = false; + let lastKnownPageSignature = ""; let needsResync = false; const observer = mutationObserverFactory(() => { + const nextPageSignature = readMarketPageSignature(options.document); + if (nextPageSignature === lastKnownPageSignature) { + return; + } + scheduleSync(); }); const observationRoot = options.document.body ?? options.document.documentElement; @@ -374,6 +381,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { const records = getVisibleOrderedRecords(table); applyRowVisibility(table, new Set(records.map((record) => record.authorId))); applyRowOrder(table, records.map((record) => record.authorId)); + lastKnownPageSignature = readMarketPageSignature(options.document); }); } @@ -719,6 +727,7 @@ export function createMarketController(options: CreateMarketControllerOptions) { try { await hydrateCurrentPage(); applyCurrentView(); + lastKnownPageSignature = readMarketPageSignature(options.document); } finally { isSyncRunning = false; if (needsResync) { diff --git a/tests/filter-sort-controller.test.ts b/tests/filter-sort-controller.test.ts index 40db0fc..2063a59 100644 --- a/tests/filter-sort-controller.test.ts +++ b/tests/filter-sort-controller.test.ts @@ -121,4 +121,67 @@ describe("filter-sort-controller", () => { expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]); }); + + test("keeps equal rate buckets in a deterministic order across repeated sorts", () => { + const records: MarketRecord[] = [ + { + authorId: "b", + authorName: "Beta", + status: "success", + rates: { + singleVideoAfterSearchRate: "0.5% - 1%" + } + }, + { + authorId: "a", + authorName: "Alpha", + status: "success", + rates: { + singleVideoAfterSearchRate: "0.5% - 1%" + } + }, + { + authorId: "d", + authorName: "Delta", + status: "success", + rates: { + singleVideoAfterSearchRate: "0.25% - 0.5%" + } + }, + { + authorId: "c", + authorName: "Gamma", + status: "success", + rates: { + singleVideoAfterSearchRate: "0.25% - 0.5%" + } + } + ]; + + const firstResult = applyFilterAndSort(records, { + sort: { + direction: "desc", + field: "singleVideoAfterSearchRate" + } + }); + const secondResult = applyFilterAndSort([...records].reverse(), { + sort: { + direction: "desc", + field: "singleVideoAfterSearchRate" + } + }); + + expect(firstResult.map((record) => record.authorId)).toEqual([ + "a", + "b", + "c", + "d" + ]); + expect(secondResult.map((record) => record.authorId)).toEqual([ + "a", + "b", + "c", + "d" + ]); + }); }); diff --git a/tests/market-content-entry.test.ts b/tests/market-content-entry.test.ts index 2514558..c801e0f 100644 --- a/tests/market-content-entry.test.ts +++ b/tests/market-content-entry.test.ts @@ -511,6 +511,86 @@ describe("market-content-entry", () => { ]); }); + test("rehydrates real rows after serialized market rows arrive later", async () => { + document.body.innerHTML = buildRealMarketFixtureWithoutAuthorIds([ + { + authorName: "达人 A", + price21To60s: "¥450,000" + }, + { + authorName: "达人 B", + price21To60s: "¥20,000" + } + ]); + + const loadAuthorMetrics = vi.fn(async (authorId: string) => ({ + success: true as const, + rates: + authorId === "111" + ? { + singleVideoAfterSearchRate: "0.02%", + personalVideoAfterSearchRate: "0.03% - 0.2%" + } + : { + singleVideoAfterSearchRate: "0.5%-1%", + personalVideoAfterSearchRate: "0.01% - 0.1%" + } + })); + + const { createMarketController } = await import("../src/content/market/index"); + const controller = trackController(createMarketController({ + document, + loadAuthorMetrics, + window + })); + + await controller.ready; + + expect(loadAuthorMetrics).not.toHaveBeenCalled(); + expect(readDivPluginRowTexts(0)).toEqual(["", "", "", "", "", "", "", ""]); + expect(readDivPluginRowTexts(1)).toEqual(["", "", "", "", "", "", "", ""]); + + document.documentElement.setAttribute( + "data-sces-market-rows", + JSON.stringify([ + { + authorId: "111", + authorName: "达人 A", + singleVideoAfterSearchRate: "0.02%" + }, + { + authorId: "222", + authorName: "达人 B" + } + ]) + ); + + await flushWithTimers(); + await flushWithTimers(); + + expect(loadAuthorMetrics).toHaveBeenCalledTimes(2); + expect(readDivPluginRowTexts(0)).toEqual([ + "0.02%", + "0.03% - 0.2%", + "", + "", + "", + "", + "", + "" + ]); + expect(readDivPluginRowTexts(1)).toEqual([ + "0.5% - 1%", + "0.01% - 0.1%", + "", + "", + "", + "", + "", + "" + ]); + }); + test("applying plugin filters hides non-matching current-page rows without a full scan", async () => { document.body.innerHTML = buildMarketFixture(); const resultStore = createMarketResultStore(); @@ -3113,28 +3193,47 @@ function readRowOrder() { } function readDivAuthorOrder() { - return Array.from( - document.querySelectorAll('[data-testid^="author-cell-"] a'), - (link) => link.textContent?.trim() ?? "" + const authorColumn = document.querySelector( + '[data-testid="author-section"] .content-column' + ); + return readVisualCells(authorColumn).map( + (cell) => cell.querySelector("a")?.textContent?.trim() ?? "" ); } function readDivRightRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="right-section"] > .content-column'), - (column) => - column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" + (column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? "" ); } function readDivPluginRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="plugin-section"] > .content-column'), - (column) => - column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" + (column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? "" ); } +function readVisualCells(root: Element | null): HTMLElement[] { + if (!root) { + return []; + } + + return Array.from(root.querySelectorAll(":scope > .content-cell")) + .filter((cell): cell is HTMLElement => cell instanceof HTMLElement) + .sort((left, right) => { + const leftOrder = Number(left.style.order || "0"); + const rightOrder = Number(right.style.order || "0"); + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + const cells = Array.from(root.querySelectorAll(":scope > .content-cell")); + return cells.indexOf(left) - cells.indexOf(right); + }); +} + function trackController void }>(controller: T): T { if (controller.dispose) { disposers.push(() => controller.dispose?.()); diff --git a/tests/market-dom-sync.test.ts b/tests/market-dom-sync.test.ts index 594f894..7970497 100644 --- a/tests/market-dom-sync.test.ts +++ b/tests/market-dom-sync.test.ts @@ -553,6 +553,7 @@ describe("market-dom-sync", () => { expect(table.rows[0].rates).toEqual({ singleVideoAfterSearchRate: "0.02%" }); + expect(readMarketPageSignature(document)).toContain("::111|222"); }); test("finds the real next-page button in Xingtu pagination", () => { @@ -581,6 +582,16 @@ describe("market-dom-sync", () => { expect(isPageControlDisabled(nextControl)).toBe(false); expect(readMarketPageSignature(document)).toContain("1::111|222"); }); + + test("reads market page signature without mutating the page", () => { + document.body.innerHTML = buildRealMarketGridFixture(); + + const signature = readMarketPageSignature(document); + + expect(signature).toContain("::111|222"); + expect(document.querySelector('[data-testid="plugin-header"]')).toBeNull(); + expect(document.querySelector('[data-testid="plugin-section"]')).toBeNull(); + }); }); function buildRealMarketGridFixture() { @@ -898,16 +909,14 @@ function readPluginHeaderTexts() { function readRightRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="right-section"] > .content-column'), - (column) => - column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" + (column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? "" ); } function readPluginRowTexts(rowIndex: number) { return Array.from( document.querySelectorAll('[data-testid="plugin-section"] > .content-column'), - (column) => - column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? "" + (column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? "" ); } @@ -918,9 +927,11 @@ function readScrollHintText() { } function readAuthorNames() { - return Array.from( - document.querySelectorAll('[data-testid="author-section"] .content-cell a'), - (link) => link.textContent?.trim() ?? "" + const authorColumn = document.querySelector( + '[data-testid="author-section"] .content-column' + ); + return readVisualCells(authorColumn).map( + (cell) => cell.querySelector("a")?.textContent?.trim() ?? "" ); } @@ -937,3 +948,22 @@ function readRightActionHiddenStates() { (cell) => (cell as HTMLElement).hidden ); } + +function readVisualCells(root: Element | null): HTMLElement[] { + if (!root) { + return []; + } + + return Array.from(root.querySelectorAll(":scope > .content-cell")) + .filter((cell): cell is HTMLElement => cell instanceof HTMLElement) + .sort((left, right) => { + const leftOrder = Number(left.style.order || "0"); + const rightOrder = Number(right.style.order || "0"); + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + + const cells = Array.from(root.querySelectorAll(":scope > .content-cell")); + return cells.indexOf(left) - cells.indexOf(right); + }); +}