fix: stabilize market metrics hydration and sorting

This commit is contained in:
admin123 2026-04-23 14:35:14 +08:00
parent a51c6f7bf2
commit 233de28713
7 changed files with 362 additions and 45 deletions

View File

@ -61,6 +61,7 @@ const SORTABLE_MARKET_FIELDS = [
type RowOrderTarget = { type RowOrderTarget = {
container: HTMLElement; container: HTMLElement;
mode: "css" | "dom";
node: HTMLElement; node: HTMLElement;
}; };
@ -106,12 +107,7 @@ export function readMarketPageSignature(root: ParentNode): string {
document document
?.querySelector(".el-pagination .number.active, .xt-pagination .number.active") ?.querySelector(".el-pagination .number.active, .xt-pagination .number.active")
?.textContent?.trim() ?? ""; ?.textContent?.trim() ?? "";
const table = syncMarketTable(root); const authorIds = readRawAuthorIds(root).join("|");
const authorIds =
table?.rows
.map((row) => row.authorId)
.filter((authorId) => Boolean(authorId))
.join("|") ?? "";
return `${explicitPageIndex || activePageIndex}::${authorIds}`; return `${explicitPageIndex || activePageIndex}::${authorIds}`;
} }
@ -209,6 +205,9 @@ export function applyRowOrder(
orderedAuthorIds: string[] orderedAuthorIds: string[]
): void { ): void {
const rowById = new Map(table.rows.map((rowDom) => [rowDom.authorId, rowDom])); const rowById = new Map(table.rows.map((rowDom) => [rowDom.authorId, rowDom]));
const orderByAuthorId = new Map(
orderedAuthorIds.map((authorId, index) => [authorId, index])
);
orderedAuthorIds.forEach((authorId) => { orderedAuthorIds.forEach((authorId) => {
const rowDom = rowById.get(authorId); const rowDom = rowById.get(authorId);
@ -216,7 +215,17 @@ export function applyRowOrder(
return; 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); container.appendChild(node);
}); });
}); });
@ -283,6 +292,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
orderTargets: [ orderTargets: [
{ {
container: body, container: body,
mode: "dom",
node: row node: row
} }
], ],
@ -324,6 +334,75 @@ function syncDivGridMarketTable(root: ParentNode): MarketTableDom | null {
return 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 { function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
const headerSection = root.querySelector( const headerSection = root.querySelector(
".section-wrapper.sticky-header" ".section-wrapper.sticky-header"
@ -520,6 +599,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
return { return {
container, container,
mode: "css",
node: cell node: cell
}; };
}) })

View File

@ -10,6 +10,7 @@ interface ExportRangeControllerOptions {
onProgress?: (state: { currentPage: number; totalPages?: number }) => void; onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
prepareCurrentPageForExport(): Promise<void>; prepareCurrentPageForExport(): Promise<void>;
readCurrentPageRecords(): MarketRecord[]; readCurrentPageRecords(): MarketRecord[];
readCurrentPageRowCount(): number;
window: Window; window: Window;
} }
@ -26,13 +27,10 @@ export function createExportRangeController(options: ExportRangeControllerOption
currentPage, currentPage,
totalPages: target.mode === "count" ? target.pageCount : undefined totalPages: target.mode === "count" ? target.pageCount : undefined
}); });
const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount); const currentPageRecords = await preparePageRecords(expectedMinimumRowCount);
if (!currentPageReady) { if (!currentPageRecords) {
throw new Error(`${currentPage} 页加载超时,请稍后重试`); throw new Error(`${currentPage} 页加载超时,请稍后重试`);
} }
await options.prepareCurrentPageForExport();
const currentPageRecords = options.readCurrentPageRecords();
currentPageRecords.forEach((record) => { currentPageRecords.forEach((record) => {
const existingRecord = mergedRecords.get(record.authorId); const existingRecord = mergedRecords.get(record.authorId);
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record)); mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
@ -63,6 +61,33 @@ export function createExportRangeController(options: ExportRangeControllerOption
} }
}; };
async function preparePageRecords(
expectedMinimumRowCount: number | undefined
): Promise<MarketRecord[] | null> {
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<boolean> { async function waitForPageChange(previousSignature: string): Promise<boolean> {
const previousPageState = parsePageSignature(previousSignature); const previousPageState = parsePageSignature(previousSignature);
@ -82,9 +107,7 @@ export function createExportRangeController(options: ExportRangeControllerOption
return false; return false;
} }
async function waitForCurrentPageReady( async function waitForCurrentPageReady(): Promise<boolean> {
expectedMinimumRowCount: number | undefined
): Promise<boolean> {
let stableAttemptCount = 0; let stableAttemptCount = 0;
let lastReadyFingerprint = ""; let lastReadyFingerprint = "";
@ -101,17 +124,6 @@ export function createExportRangeController(options: ExportRangeControllerOption
continue; continue;
} }
if (
typeof expectedMinimumRowCount === "number" &&
expectedMinimumRowCount > 0 &&
!pageState.isTerminalPage &&
pageState.rowCount < expectedMinimumRowCount
) {
stableAttemptCount = 0;
lastReadyFingerprint = "";
continue;
}
const readyFingerprint = [ const readyFingerprint = [
pageState.pageToken, pageState.pageToken,
pageState.authorIds, pageState.authorIds,
@ -146,9 +158,13 @@ export function createExportRangeController(options: ExportRangeControllerOption
authorIds: pageSignature.authorIds, authorIds: pageSignature.authorIds,
isTerminalPage: isPageControlDisabled(nextPageControl), isTerminalPage: isPageControlDisabled(nextPageControl),
pageToken: pageSignature.pageToken, pageToken: pageSignature.pageToken,
rowCount: options.readCurrentPageRecords().length rowCount: options.readCurrentPageRowCount()
}; };
} }
function isCurrentPageTerminal(): boolean {
return isPageControlDisabled(findNextPageControl(options.document));
}
} }
function parsePageSignature(signature: string): { function parsePageSignature(signature: string): {

View File

@ -89,7 +89,7 @@ function compareRateSortRecords(
const rightLowerBound = parseRateLowerBound(rightValue ?? null); const rightLowerBound = parseRateLowerBound(rightValue ?? null);
if (leftLowerBound == null && rightLowerBound == null) { if (leftLowerBound == null && rightLowerBound == null) {
return 0; return compareRecordIdentity(leftRecord, rightRecord);
} }
if (leftLowerBound == null) { if (leftLowerBound == null) {
@ -107,7 +107,11 @@ function compareRateSortRecords(
} }
const tieBreak = compareRateValues(leftValue, rightValue); 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( function compareBackendMetricRecords(
@ -120,7 +124,7 @@ function compareBackendMetricRecords(
const rightValue = parseBackendMetricValue(rightRecord.backendMetrics?.[field]); const rightValue = parseBackendMetricValue(rightRecord.backendMetrics?.[field]);
if (leftValue == null && rightValue == null) { if (leftValue == null && rightValue == null) {
return 0; return compareRecordIdentity(leftRecord, rightRecord);
} }
if (leftValue == null) { if (leftValue == null) {
@ -131,7 +135,11 @@ function compareBackendMetricRecords(
return -1; 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 { function parseBackendMetricValue(value: string | null | undefined): number | null {
@ -156,3 +164,15 @@ function isRateSortField(
field === "personalVideoAfterSearchRate" 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);
}

View File

@ -3,6 +3,7 @@ import { createBatchPayload, type BatchPayload } from "./batch-payload";
import { import {
applyRowOrder, applyRowOrder,
applyRowVisibility, applyRowVisibility,
readMarketPageSignature,
renderMarketRowState, renderMarketRowState,
syncPluginSortHeaders, syncPluginSortHeaders,
syncMarketTable, syncMarketTable,
@ -97,8 +98,14 @@ export function createMarketController(options: CreateMarketControllerOptions) {
let activeSort: MarketSortState | undefined; let activeSort: MarketSortState | undefined;
let isSyncRunning = false; let isSyncRunning = false;
let isSyncScheduled = false; let isSyncScheduled = false;
let lastKnownPageSignature = "";
let needsResync = false; let needsResync = false;
const observer = mutationObserverFactory(() => { const observer = mutationObserverFactory(() => {
const nextPageSignature = readMarketPageSignature(options.document);
if (nextPageSignature === lastKnownPageSignature) {
return;
}
scheduleSync(); scheduleSync();
}); });
const observationRoot = options.document.body ?? options.document.documentElement; const observationRoot = options.document.body ?? options.document.documentElement;
@ -374,6 +381,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const records = getVisibleOrderedRecords(table); const records = getVisibleOrderedRecords(table);
applyRowVisibility(table, new Set(records.map((record) => record.authorId))); applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
applyRowOrder(table, 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 { try {
await hydrateCurrentPage(); await hydrateCurrentPage();
applyCurrentView(); applyCurrentView();
lastKnownPageSignature = readMarketPageSignature(options.document);
} finally { } finally {
isSyncRunning = false; isSyncRunning = false;
if (needsResync) { if (needsResync) {

View File

@ -121,4 +121,67 @@ describe("filter-sort-controller", () => {
expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]); 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"
]);
});
}); });

View File

@ -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 () => { test("applying plugin filters hides non-matching current-page rows without a full scan", async () => {
document.body.innerHTML = buildMarketFixture(); document.body.innerHTML = buildMarketFixture();
const resultStore = createMarketResultStore(); const resultStore = createMarketResultStore();
@ -3113,28 +3193,47 @@ function readRowOrder() {
} }
function readDivAuthorOrder() { function readDivAuthorOrder() {
return Array.from( const authorColumn = document.querySelector(
document.querySelectorAll('[data-testid^="author-cell-"] a'), '[data-testid="author-section"] .content-column'
(link) => link.textContent?.trim() ?? "" );
return readVisualCells(authorColumn).map(
(cell) => cell.querySelector("a")?.textContent?.trim() ?? ""
); );
} }
function readDivRightRowTexts(rowIndex: number) { function readDivRightRowTexts(rowIndex: number) {
return Array.from( return Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column'), document.querySelectorAll('[data-testid="right-section"] > .content-column'),
(column) => (column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
); );
} }
function readDivPluginRowTexts(rowIndex: number) { function readDivPluginRowTexts(rowIndex: number) {
return Array.from( return Array.from(
document.querySelectorAll('[data-testid="plugin-section"] > .content-column'), document.querySelectorAll('[data-testid="plugin-section"] > .content-column'),
(column) => (column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
column.querySelectorAll(".content-cell")[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<T extends { dispose?: () => void }>(controller: T): T { function trackController<T extends { dispose?: () => void }>(controller: T): T {
if (controller.dispose) { if (controller.dispose) {
disposers.push(() => controller.dispose?.()); disposers.push(() => controller.dispose?.());

View File

@ -553,6 +553,7 @@ describe("market-dom-sync", () => {
expect(table.rows[0].rates).toEqual({ expect(table.rows[0].rates).toEqual({
singleVideoAfterSearchRate: "0.02%" singleVideoAfterSearchRate: "0.02%"
}); });
expect(readMarketPageSignature(document)).toContain("::111|222");
}); });
test("finds the real next-page button in Xingtu pagination", () => { test("finds the real next-page button in Xingtu pagination", () => {
@ -581,6 +582,16 @@ describe("market-dom-sync", () => {
expect(isPageControlDisabled(nextControl)).toBe(false); expect(isPageControlDisabled(nextControl)).toBe(false);
expect(readMarketPageSignature(document)).toContain("1::111|222"); 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() { function buildRealMarketGridFixture() {
@ -898,16 +909,14 @@ function readPluginHeaderTexts() {
function readRightRowTexts(rowIndex: number) { function readRightRowTexts(rowIndex: number) {
return Array.from( return Array.from(
document.querySelectorAll('[data-testid="right-section"] > .content-column'), document.querySelectorAll('[data-testid="right-section"] > .content-column'),
(column) => (column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
); );
} }
function readPluginRowTexts(rowIndex: number) { function readPluginRowTexts(rowIndex: number) {
return Array.from( return Array.from(
document.querySelectorAll('[data-testid="plugin-section"] > .content-column'), document.querySelectorAll('[data-testid="plugin-section"] > .content-column'),
(column) => (column) => readVisualCells(column as Element)[rowIndex]?.textContent?.trim() ?? ""
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
); );
} }
@ -918,9 +927,11 @@ function readScrollHintText() {
} }
function readAuthorNames() { function readAuthorNames() {
return Array.from( const authorColumn = document.querySelector(
document.querySelectorAll('[data-testid="author-section"] .content-cell a'), '[data-testid="author-section"] .content-column'
(link) => link.textContent?.trim() ?? "" );
return readVisualCells(authorColumn).map(
(cell) => cell.querySelector("a")?.textContent?.trim() ?? ""
); );
} }
@ -937,3 +948,22 @@ function readRightActionHiddenStates() {
(cell) => (cell as HTMLElement).hidden (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);
});
}