feat: add sortable market metric columns

This commit is contained in:
admin123 2026-04-23 13:29:20 +08:00
parent 2f77199920
commit a51c6f7bf2
8 changed files with 2852 additions and 213 deletions

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,9 @@ import {
parseRateLowerBound parseRateLowerBound
} from "../../shared/rate-normalizer"; } from "../../shared/rate-normalizer";
import type { import type {
AfterSearchRates,
BackendMetrics,
MarketSortField,
MarketFilterState, MarketFilterState,
MarketRecord, MarketRecord,
MarketSortState MarketSortState
@ -67,8 +70,21 @@ function compareRecords(
rightRecord: MarketRecord, rightRecord: MarketRecord,
sort: MarketSortState sort: MarketSortState
): number { ): number {
const leftValue = leftRecord.rates?.[sort.field]; if (isRateSortField(sort.field)) {
const rightValue = rightRecord.rates?.[sort.field]; return compareRateSortRecords(leftRecord, rightRecord, sort);
}
return compareBackendMetricRecords(leftRecord, rightRecord, sort);
}
function compareRateSortRecords(
leftRecord: MarketRecord,
rightRecord: MarketRecord,
sort: MarketSortState
): number {
const field = sort.field as keyof Required<AfterSearchRates>;
const leftValue = leftRecord.rates?.[field];
const rightValue = rightRecord.rates?.[field];
const leftLowerBound = parseRateLowerBound(leftValue ?? null); const leftLowerBound = parseRateLowerBound(leftValue ?? null);
const rightLowerBound = parseRateLowerBound(rightValue ?? null); const rightLowerBound = parseRateLowerBound(rightValue ?? null);
@ -93,3 +109,50 @@ function compareRecords(
const tieBreak = compareRateValues(leftValue, rightValue); const tieBreak = compareRateValues(leftValue, rightValue);
return sort.direction === "asc" ? tieBreak : -tieBreak; return sort.direction === "asc" ? tieBreak : -tieBreak;
} }
function compareBackendMetricRecords(
leftRecord: MarketRecord,
rightRecord: MarketRecord,
sort: MarketSortState
): number {
const field = sort.field as keyof Required<BackendMetrics>;
const leftValue = parseBackendMetricValue(leftRecord.backendMetrics?.[field]);
const rightValue = parseBackendMetricValue(rightRecord.backendMetrics?.[field]);
if (leftValue == null && rightValue == null) {
return 0;
}
if (leftValue == null) {
return 1;
}
if (rightValue == null) {
return -1;
}
return sort.direction === "asc" ? leftValue - rightValue : rightValue - leftValue;
}
function parseBackendMetricValue(value: string | null | undefined): number | null {
if (!value) {
return null;
}
const normalizedValue = value.replace(/,/g, "").replace(/%/g, "").trim();
if (!normalizedValue) {
return null;
}
const numericValue = Number(normalizedValue);
return Number.isFinite(numericValue) ? numericValue : null;
}
function isRateSortField(
field: MarketSortField
): field is keyof Required<AfterSearchRates> {
return (
field === "singleVideoAfterSearchRate" ||
field === "personalVideoAfterSearchRate"
);
}

View File

@ -4,6 +4,7 @@ import {
applyRowOrder, applyRowOrder,
applyRowVisibility, applyRowVisibility,
renderMarketRowState, renderMarketRowState,
syncPluginSortHeaders,
syncMarketTable, syncMarketTable,
type MarketRowDom type MarketRowDom
} from "./dom-sync"; } from "./dom-sync";
@ -14,7 +15,8 @@ import { ensurePluginToolbar } from "./plugin-toolbar";
import { import {
readToolbarExportTarget, readToolbarExportTarget,
setToolbarBusyState, setToolbarBusyState,
setToolbarExportStatus setToolbarExportStatus,
setToolbarSortState
} from "./plugin-toolbar"; } from "./plugin-toolbar";
import { createMarketResultStore } from "./result-store"; import { createMarketResultStore } from "./result-store";
import { import {
@ -88,6 +90,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}, },
prepareCurrentPageForExport: prepareCurrentPageForExport, prepareCurrentPageForExport: prepareCurrentPageForExport,
readCurrentPageRecords: () => getVisibleOrderedRecords(), readCurrentPageRecords: () => getVisibleOrderedRecords(),
readCurrentPageRowCount: () => countCurrentPageRows(options.document),
window: options.window window: options.window
}); });
let activeFilters: MarketFilterState = {}; let activeFilters: MarketFilterState = {};
@ -99,12 +102,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
scheduleSync(); scheduleSync();
}); });
const observationRoot = options.document.body ?? options.document.documentElement; const observationRoot = options.document.body ?? options.document.documentElement;
if (observationRoot) { startObserving();
observer.observe(observationRoot, {
childList: true,
subtree: true
});
}
const toolbar = ensurePluginToolbar(options.document, { const toolbar = ensurePluginToolbar(options.document, {
onApplyFilter: async () => { onApplyFilter: async () => {
@ -209,7 +207,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
for (const rowDom of table.rows) { for (const rowDom of table.rows) {
const rowSnapshot = readRowSnapshot(rowDom); const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId) { if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) {
continue; continue;
} }
@ -362,14 +360,27 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} }
function applyCurrentView(): void { function applyCurrentView(): void {
const table = syncMarketTable(options.document); runWithoutMutationSync(() => {
if (!table) { const table = syncMarketTable(options.document);
return; if (!table) {
} return;
}
const records = getVisibleOrderedRecords(table); syncPluginSortHeaders(options.document, {
applyRowVisibility(table, new Set(records.map((record) => record.authorId))); activeSort,
applyRowOrder(table, records.map((record) => record.authorId)); onToggleSort: toggleSortFromHeader
});
const records = getVisibleOrderedRecords(table);
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
applyRowOrder(table, records.map((record) => record.authorId));
});
}
function toggleSortFromHeader(field: MarketSortState["field"]): void {
activeSort = getNextSortState(activeSort, field);
setToolbarSortState(toolbar, activeSort);
applyCurrentView();
} }
function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] { function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] {
@ -397,6 +408,7 @@ export function createMarketController(options: CreateMarketControllerOptions) {
async function prepareCurrentPageForExport(): Promise<void> { async function prepareCurrentPageForExport(): Promise<void> {
await runSyncCycle(); await runSyncCycle();
await harvestCurrentPageForExport(); await harvestCurrentPageForExport();
await runSyncCycle();
} }
async function harvestCurrentPageForExport(): Promise<void> { async function harvestCurrentPageForExport(): Promise<void> {
@ -445,29 +457,37 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return table.rows return table.rows
.map((rowDom) => { .map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom); const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId) { if (!rowSnapshot.authorId || !hasTextValue(rowSnapshot.authorName)) {
return null; return null;
} }
const existingRecord = resultStore.getRecord(rowSnapshot.authorId); const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
const authorName =
mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "";
const location = mergeStringValue(existingRecord?.location, rowSnapshot.location);
const price21To60s = mergeStringValue(
existingRecord?.price21To60s,
rowSnapshot.price21To60s
);
return { return {
...existingRecord, ...existingRecord,
...rowSnapshot, ...rowSnapshot,
authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "", authorName,
backendMetrics: mergeFieldMap( backendMetrics: mergeFieldMap(
existingRecord?.backendMetrics, existingRecord?.backendMetrics,
rowSnapshot.backendMetrics rowSnapshot.backendMetrics
), ),
backendMetricsStatus: existingRecord?.backendMetricsStatus ?? "idle", backendMetricsStatus: existingRecord?.backendMetricsStatus ?? "idle",
exportFields: mergeFieldMap( exportFields: withExportFieldFallbacks(
existingRecord?.exportFields, mergeFieldMap(existingRecord?.exportFields, rowSnapshot.exportFields),
rowSnapshot.exportFields {
), authorName,
location: mergeStringValue(existingRecord?.location, rowSnapshot.location), location,
price21To60s: mergeStringValue( price21To60s
existingRecord?.price21To60s, }
rowSnapshot.price21To60s
), ),
location,
price21To60s,
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates), rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
status: existingRecord?.status ?? "idle" status: existingRecord?.status ?? "idle"
} satisfies MarketRecord; } satisfies MarketRecord;
@ -488,27 +508,42 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return null; return null;
} }
const seenElements = new Set<HTMLElement>(); const candidateScores = new Map<HTMLElement, { depth: number; scrollRange: number }>();
const candidateRoots = table.rows const candidateRoots = table.rows
.map((rowDom) => rowDom.row) .map((rowDom) => rowDom.row)
.filter((row): row is HTMLElement => row instanceof options.window.HTMLElement); .filter((row): row is HTMLElement => row instanceof options.window.HTMLElement);
for (const rootElement of candidateRoots) { for (const rootElement of candidateRoots) {
let currentElement = rootElement.parentElement; let currentElement = rootElement.parentElement;
let depth = 0;
while (currentElement) { while (currentElement) {
if ( if (isScrollableContainer(currentElement)) {
!seenElements.has(currentElement) && const scrollRange = currentElement.scrollHeight - currentElement.clientHeight;
isScrollableContainer(currentElement) const existingScore = candidateScores.get(currentElement);
) { if (!existingScore || depth < existingScore.depth) {
return currentElement; candidateScores.set(currentElement, {
depth,
scrollRange
});
}
} }
seenElements.add(currentElement); depth += 1;
currentElement = currentElement.parentElement; currentElement = currentElement.parentElement;
} }
} }
return null; const rankedCandidates = Array.from(candidateScores.entries()).sort((left, right) => {
const [, leftScore] = left;
const [, rightScore] = right;
if (rightScore.scrollRange !== leftScore.scrollRange) {
return rightScore.scrollRange - leftScore.scrollRange;
}
return leftScore.depth - rightScore.depth;
});
return rankedCandidates[0]?.[0] ?? null;
} }
function isScrollableContainer(element: HTMLElement): boolean { function isScrollableContainer(element: HTMLElement): boolean {
@ -529,8 +564,9 @@ export function createMarketController(options: CreateMarketControllerOptions) {
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> { async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
let previousFingerprint = ""; let previousFingerprint = "";
let stablePassCount = 0; let stablePassCount = 0;
let fingerprintStableSince = 0;
for (let attempt = 0; attempt < 9; attempt += 1) { for (let attempt = 0; attempt < 16; attempt += 1) {
await waitForDomSettled(); await waitForDomSettled();
if (attempt > 0) { if (attempt > 0) {
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
@ -555,21 +591,38 @@ export function createMarketController(options: CreateMarketControllerOptions) {
} else { } else {
previousFingerprint = hydrationSnapshot.fingerprint; previousFingerprint = hydrationSnapshot.fingerprint;
stablePassCount = 1; stablePassCount = 1;
fingerprintStableSince = options.window.Date.now();
} }
if (hydrationSnapshot.missingDefaultFieldCount === 0 && stablePassCount >= 2) { const stableForMs = options.window.Date.now() - fingerprintStableSince;
if (
hydrationSnapshot.missingDefaultFieldCount === 0 &&
hydrationSnapshot.blankExportFieldCount === 0 &&
stablePassCount >= 2
) {
return;
}
if (
hydrationSnapshot.missingDefaultFieldCount === 0 &&
hydrationSnapshot.blankExportFieldCount > 0 &&
stablePassCount >= 2 &&
stableForMs >= 500
) {
return; return;
} }
} }
} }
function readVisibleRowHydrationSnapshot(): { function readVisibleRowHydrationSnapshot(): {
blankExportFieldCount: number;
fingerprint: string; fingerprint: string;
missingDefaultFieldCount: number; missingDefaultFieldCount: number;
} { } {
const table = syncMarketTable(options.document); const table = syncMarketTable(options.document);
if (!table || table.rows.length === 0) { if (!table || table.rows.length === 0) {
return { return {
blankExportFieldCount: 0,
fingerprint: "", fingerprint: "",
missingDefaultFieldCount: 0 missingDefaultFieldCount: 0
}; };
@ -580,6 +633,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter( const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value === "string" && value.trim().length > 0 (value) => typeof value === "string" && value.trim().length > 0
).length; ).length;
const blankExportFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value !== "string" || value.trim().length === 0
).length;
const hasAuthorField = hasTextValue(rowSnapshot.exportFields?.["达人信息"]);
const hasRepresentativeVideo = hasTextValue( const hasRepresentativeVideo = hasTextValue(
rowSnapshot.exportFields?.["代表视频"] rowSnapshot.exportFields?.["代表视频"]
); );
@ -587,11 +644,15 @@ export function createMarketController(options: CreateMarketControllerOptions) {
hasTextValue(rowSnapshot.price21To60s) || hasTextValue(rowSnapshot.price21To60s) ||
hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]); hasTextValue(rowSnapshot.exportFields?.["21-60s报价"]);
const missingDefaultFieldCount = const missingDefaultFieldCount =
Number(!hasRepresentativeVideo) + Number(!hasPriceField); Number(!hasAuthorField) +
Number(!hasRepresentativeVideo) +
Number(!hasPriceField);
return [ return [
rowSnapshot.authorId, rowSnapshot.authorId,
populatedFieldCount, populatedFieldCount,
`blank:${blankExportFieldCount}`,
hasAuthorField ? "author" : "no-author",
hasRepresentativeVideo ? "video" : "no-video", hasRepresentativeVideo ? "video" : "no-video",
hasPriceField ? "price" : "no-price", hasPriceField ? "price" : "no-price",
`missing:${missingDefaultFieldCount}` `missing:${missingDefaultFieldCount}`
@ -599,6 +660,10 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}); });
return { return {
blankExportFieldCount: parts.reduce((count, part) => {
const match = part.match(/:blank:(\d+):/);
return count + Number(match?.[1] ?? 0);
}, 0),
fingerprint: parts.join("|"), fingerprint: parts.join("|"),
missingDefaultFieldCount: parts.reduce((count, part) => { missingDefaultFieldCount: parts.reduce((count, part) => {
const match = part.match(/missing:(\d+)$/); const match = part.match(/missing:(\d+)$/);
@ -624,6 +689,26 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}, 0); }, 0);
} }
function runWithoutMutationSync(callback: () => void): void {
observer.disconnect();
try {
callback();
} finally {
startObserving();
}
}
function startObserving(): void {
if (!observationRoot) {
return;
}
observer.observe(observationRoot, {
childList: true,
subtree: true
});
}
async function runSyncCycle(): Promise<void> { async function runSyncCycle(): Promise<void> {
if (isSyncRunning) { if (isSyncRunning) {
needsResync = true; needsResync = true;
@ -658,7 +743,19 @@ function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
return table.rows return table.rows
.map((rowDom) => readRowSnapshot(rowDom)) .map((rowDom) => readRowSnapshot(rowDom))
.filter((row): row is MarketRowSnapshot => Boolean(row.authorId)); .filter(
(row): row is MarketRowSnapshot =>
Boolean(row.authorId) && hasTextValue(row.authorName)
);
}
function countCurrentPageRows(document: Document): number {
const table = syncMarketTable(document);
if (!table) {
return 0;
}
return table.rows.filter((rowDom) => Boolean(rowDom.authorId)).length;
} }
function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot { function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
@ -667,6 +764,7 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
authorName: rowDom.authorName, authorName: rowDom.authorName,
exportFields: rowDom.exportFields, exportFields: rowDom.exportFields,
hasDirectRatesSource: rowDom.hasDirectRatesSource, hasDirectRatesSource: rowDom.hasDirectRatesSource,
location: rowDom.location,
price21To60s: rowDom.price21To60s, price21To60s: rowDom.price21To60s,
rates: rowDom.rates rates: rowDom.rates
}; };
@ -695,6 +793,27 @@ function readSortState(
}; };
} }
function getNextSortState(
currentSort: MarketSortState | undefined,
field: MarketSortState["field"]
): MarketSortState | undefined {
if (!currentSort || currentSort.field !== field) {
return {
direction: "desc",
field
};
}
if (currentSort.direction === "desc") {
return {
direction: "asc",
field
};
}
return undefined;
}
function mergeFieldMap<T extends Record<string, string | undefined>>( function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined, current: T | undefined,
incoming: T | undefined incoming: T | undefined
@ -805,6 +924,46 @@ function mergeStringValue(
return current; return current;
} }
function withExportFieldFallbacks(
exportFields: Record<string, string | undefined> | undefined,
fallbackValues: {
authorName: string;
location: string | undefined;
price21To60s: string | undefined;
}
): Record<string, string | undefined> | undefined {
if (!exportFields) {
return undefined;
}
const nextExportFields = {
...exportFields
};
if (
"达人信息" in nextExportFields &&
!hasTextValue(nextExportFields["达人信息"]) &&
hasTextValue(fallbackValues.authorName)
) {
nextExportFields["达人信息"] = fallbackValues.authorName;
}
if (
"地区" in nextExportFields &&
!hasTextValue(nextExportFields["地区"]) &&
hasTextValue(fallbackValues.location)
) {
nextExportFields["地区"] = fallbackValues.location;
}
if (
"21-60s报价" in nextExportFields &&
!hasTextValue(nextExportFields["21-60s报价"]) &&
hasTextValue(fallbackValues.price21To60s)
) {
nextExportFields["21-60s报价"] = fallbackValues.price21To60s;
}
return nextExportFields;
}
function hasTextValue(value: string | undefined): boolean { function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0; return typeof value === "string" && value.trim().length > 0;
} }

View File

@ -1,4 +1,46 @@
import type { MarketExportScope, MarketExportTarget } from "./types"; import type {
MarketExportScope,
MarketExportTarget,
MarketSortState
} from "./types";
const SORT_FIELD_OPTIONS = [
{
label: "单视频看后搜率",
value: "singleVideoAfterSearchRate"
},
{
label: "个人视频看后搜率",
value: "personalVideoAfterSearchRate"
},
{
label: "看后搜率",
value: "afterViewSearchRate"
},
{
label: "看后搜数",
value: "afterViewSearchCount"
},
{
label: "新增A3数",
value: "a3IncreaseCount"
},
{
label: "新增A3率",
value: "newA3Rate"
},
{
label: "CPA3",
value: "cpa3"
},
{
label: "cp_search",
value: "cpSearch"
}
] as const satisfies Array<{
label: string;
value: NonNullable<MarketSortState["field"]>;
}>;
export interface PluginToolbarHandlers { export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void; onApplyFilter(): Promise<void> | void;
@ -54,8 +96,9 @@ export function ensurePluginToolbar(
const sortFieldSelect = document.createElement("select"); const sortFieldSelect = document.createElement("select");
sortFieldSelect.dataset.pluginSortField = "select"; sortFieldSelect.dataset.pluginSortField = "select";
appendOption(sortFieldSelect, "", "不排序"); appendOption(sortFieldSelect, "", "不排序");
appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率"); SORT_FIELD_OPTIONS.forEach(({ label, value }) => {
appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率"); appendOption(sortFieldSelect, value, label);
});
const sortDirectionSelect = document.createElement("select"); const sortDirectionSelect = document.createElement("select");
sortDirectionSelect.dataset.pluginSortDirection = "select"; sortDirectionSelect.dataset.pluginSortDirection = "select";
@ -293,6 +336,14 @@ export function setToolbarExportStatus(
toolbar.exportStatusText.textContent = text; toolbar.exportStatusText.textContent = text;
} }
export function setToolbarSortState(
toolbar: PluginToolbarDom,
sort: MarketSortState | undefined
): void {
toolbar.sortFieldSelect.value = sort?.field ?? "";
toolbar.sortDirectionSelect.value = sort?.direction ?? "desc";
}
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void { function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportCustomPagesInput.hidden = toolbar.exportCustomPagesInput.hidden =
toolbar.exportRangeSelect.value !== "custom"; toolbar.exportRangeSelect.value !== "custom";

View File

@ -12,6 +12,10 @@ export interface BackendMetrics {
newA3Rate?: string; newA3Rate?: string;
} }
export type MarketSortField =
| keyof Required<AfterSearchRates>
| keyof Required<BackendMetrics>;
export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing"; export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "missing";
export interface MarketRowSnapshot { export interface MarketRowSnapshot {
@ -49,7 +53,7 @@ export type MarketExportTarget =
export interface MarketSortState { export interface MarketSortState {
direction: "asc" | "desc"; direction: "asc" | "desc";
field: keyof Required<AfterSearchRates>; field: MarketSortField;
} }
export type MarketApiFailureReason = export type MarketApiFailureReason =

View File

@ -93,4 +93,32 @@ describe("filter-sort-controller", () => {
expect(result.slice(-2).map((record) => record.authorId)).toEqual(["c", "d"]); expect(result.slice(-2).map((record) => record.authorId)).toEqual(["c", "d"]);
}); });
test("sorts by backend metric descending and keeps empty values at the end", () => {
const result = applyFilterAndSort(
[
{
...baseRecords[0],
backendMetrics: {
afterViewSearchRate: "0.36%"
}
},
{
...baseRecords[1],
backendMetrics: {
afterViewSearchRate: "1.4%"
}
},
baseRecords[2]
],
{
sort: {
direction: "desc",
field: "afterViewSearchRate"
}
}
);
expect(result.map((record) => record.authorId)).toEqual(["b", "a", "c"]);
});
}); });

File diff suppressed because it is too large Load Diff

View File

@ -48,9 +48,22 @@ describe("market-dom-sync", () => {
) )
).not.toBeNull(); ).not.toBeNull();
expect( expect(
document.querySelector('[data-market-header-cell="backendMetrics"]') document.querySelector('[data-market-header-cell="afterViewSearchRate"]')
).not.toBeNull(); ).not.toBeNull();
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(6); expect(
document.querySelector('[data-market-header-cell="afterViewSearchCount"]')
).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="a3IncreaseCount"]')
).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="newA3Rate"]')
).not.toBeNull();
expect(document.querySelector('[data-market-header-cell="cpa3"]')).not.toBeNull();
expect(
document.querySelector('[data-market-header-cell="cpSearch"]')
).not.toBeNull();
expect(document.querySelectorAll("[data-market-row-cell]").length).toBe(16);
}); });
test("renders loading, success, missing, and failed states", () => { test("renders loading, success, missing, and failed states", () => {
@ -87,12 +100,15 @@ describe("market-dom-sync", () => {
}); });
expect(alphaRow.singleCell.textContent).toBe("加载中..."); expect(alphaRow.singleCell.textContent).toBe("加载中...");
expect(alphaRow.backendMetricsCell.textContent).toBe("加载中..."); expect(alphaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("加载中...");
expect(betaRow.singleCell.textContent).toBe("0.5% - 1%"); expect(betaRow.singleCell.textContent).toBe("0.5% - 1%");
expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%"); expect(betaRow.personalCell.textContent).toBe("0.02% - 0.1%");
expect(betaRow.backendMetricsCell.textContent).toContain("看后搜率"); expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("0.36%");
expect(betaRow.backendMetricsCell.textContent).toContain("0.36%"); expect(betaRow.backendMetricsCells.afterViewSearchCount.textContent).toBe("9,689.96");
expect(betaRow.backendMetricsCell.textContent).toContain("CPA3"); expect(betaRow.backendMetricsCells.a3IncreaseCount.textContent).toBe("78,366.22");
expect(betaRow.backendMetricsCells.newA3Rate.textContent).toBe("3.44%");
expect(betaRow.backendMetricsCells.cpa3.textContent).toBe("1.79");
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("14.46");
renderMarketRowState(betaRow, { renderMarketRowState(betaRow, {
authorId: "b", authorId: "b",
@ -104,7 +120,8 @@ describe("market-dom-sync", () => {
personalVideoAfterSearchRate: "0.02 - 0.1%" personalVideoAfterSearchRate: "0.02 - 0.1%"
} }
}); });
expect(betaRow.backendMetricsCell.textContent).toBe("暂无数据"); expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("暂无数据");
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("暂无数据");
renderMarketRowState(betaRow, { renderMarketRowState(betaRow, {
authorId: "b", authorId: "b",
@ -114,7 +131,8 @@ describe("market-dom-sync", () => {
}); });
expect(betaRow.singleCell.textContent).toBe("加载失败"); expect(betaRow.singleCell.textContent).toBe("加载失败");
expect(betaRow.personalCell.textContent).toBe("加载失败"); expect(betaRow.personalCell.textContent).toBe("加载失败");
expect(betaRow.backendMetricsCell.textContent).toBe("加载失败"); expect(betaRow.backendMetricsCells.afterViewSearchRate.textContent).toBe("加载失败");
expect(betaRow.backendMetricsCells.cpSearch.textContent).toBe("加载失败");
}); });
test("hides rows outside the visible author ids", () => { test("hides rows outside the visible author ids", () => {
@ -152,27 +170,55 @@ describe("market-dom-sync", () => {
throw new Error("Expected market table"); throw new Error("Expected market table");
} }
expect(readRightHeaderTexts()).toEqual([ expect(readRightHeaderTexts()).toEqual(["21-60s报价", "操作"]);
"21-60s报价", expect(readPluginHeaderTexts()).toEqual([
"单视频看后搜率", "单视频看后搜率",
"个人视频看后搜率", "个人视频看后搜率",
"秒探指标", "看后搜率",
"操作" "看后搜数",
"新增A3数",
"新增A3率",
"CPA3",
"cp_search"
]); ]);
expect(
document
.querySelector(".section-wrapper.sticky-header")
?.classList.contains("hide-scrollbar")
).toBe(false);
expect(
document.querySelector(".section-wrapper:not(.sticky-header)")?.classList.contains(
"hide-scrollbar"
)
).toBe(false);
expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标");
expect(document.querySelectorAll('[data-testid="market-scroll-hint"]')).toHaveLength(1);
expect(
(
document.querySelector('[data-testid="plugin-header"]') as HTMLElement
).style.position
).not.toBe("sticky");
const pluginHeaderCells = Array.from(
document.querySelectorAll('[data-testid="plugin-header"] > .header-cell')
) as HTMLElement[];
expect(pluginHeaderCells[0]?.style.width).toBe("160px");
expect(pluginHeaderCells[1]?.style.width).toBe("160px");
expect(pluginHeaderCells[0]?.style.whiteSpace).toBe("nowrap");
expect(pluginHeaderCells[1]?.style.whiteSpace).toBe("nowrap");
expect( expect(
Number.parseFloat( Number.parseFloat(
( (
document.querySelector('[data-testid="right-header"]') as HTMLElement document.querySelector('[data-testid="right-header"]') as HTMLElement
).style.width ).style.width
) )
).toBeGreaterThan(350); ).toBe(350);
expect( expect(
Number.parseFloat( Number.parseFloat(
( (
document.querySelector('[data-testid="right-section"]') as HTMLElement document.querySelector('[data-testid="right-section"]') as HTMLElement
).style.width ).style.width
) )
).toBeGreaterThan(350); ).toBe(350);
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
renderMarketRowState(table.rows[0], { renderMarketRowState(table.rows[0], {
@ -194,13 +240,21 @@ describe("market-dom-sync", () => {
} }
}); });
expect(readRightRowTexts(0)).toEqual([ expect(readRightRowTexts(0)).toEqual(["¥450,000", "下单"]);
"¥450,000", expect(readPluginRowTexts(0)).toEqual([
"0.5% - 1%", "0.5% - 1%",
"0.02% - 0.1%", "0.02% - 0.1%",
"看后搜率0.36%看后搜数9,689.96新增A3数78,366.22新增A3率3.44%CPA31.79cp_search14.46", "0.36%",
"下单" "9,689.96",
"78,366.22",
"3.44%",
"1.79",
"14.46"
]); ]);
expect(table.rows[0].singleCell.style.width).toBe("160px");
expect(table.rows[0].personalCell.style.width).toBe("160px");
expect(table.rows[0].singleCell.style.whiteSpace).toBe("nowrap");
expect(table.rows[0].personalCell.style.whiteSpace).toBe("nowrap");
applyRowVisibility(table, new Set(["222"])); applyRowVisibility(table, new Set(["222"]));
@ -211,7 +265,8 @@ describe("market-dom-sync", () => {
applyRowOrder(table, ["222", "111"]); applyRowOrder(table, ["222", "111"]);
expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]); expect(readAuthorNames()).toEqual(["达人 B", "达人 A"]);
expect(readRightRowTexts(0)).toEqual(["¥20,000", "", "", "", "下单"]); expect(readRightRowTexts(0)).toEqual(["¥20,000", "下单"]);
expect(readPluginRowTexts(0)).toEqual(["", "", "", "", "", "", "", ""]);
expect(table.rows[0].exportFields).toMatchObject({ expect(table.rows[0].exportFields).toMatchObject({
"21-60s报价": "¥450,000", "21-60s报价": "¥450,000",
"代表视频": "代表视频A", "代表视频": "代表视频A",
@ -219,6 +274,94 @@ describe("market-dom-sync", () => {
}); });
}); });
test("keeps a single scroll hint across repeated syncs", () => {
document.body.innerHTML = buildRealMarketGridFixture();
expect(syncMarketTable(document)).not.toBeNull();
expect(syncMarketTable(document)).not.toBeNull();
expect(document.querySelectorAll('[data-testid="market-scroll-hint"]')).toHaveLength(1);
expect(readScrollHintText()).toBe("横向滚动可查看看后搜率、秒探指标");
});
test("uses native-like alignment styles for plugin cells", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
const pluginHeaderCell = document.querySelector(
'[data-testid="plugin-header"] [data-market-header-cell="singleVideoAfterSearchRate"]'
) as HTMLElement | null;
const pluginBodyCell = table.rows[0].singleCell;
expect(pluginHeaderCell?.style.display).toBe("flex");
expect(pluginHeaderCell?.style.alignItems).toBe("center");
expect(pluginBodyCell.style.display).toBe("flex");
expect(pluginBodyCell.style.alignItems).toBe("center");
expect(pluginBodyCell.style.paddingTop).toBe("12px");
expect(pluginBodyCell.style.paddingBottom).toBe("12px");
});
test("keeps native-like alignment styles after repeated syncs", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithScopedAttributes();
expect(syncMarketTable(document)).not.toBeNull();
const secondTable = syncMarketTable(document);
if (!secondTable) {
throw new Error("Expected market table");
}
const pluginBodyCell = secondTable.rows[0].singleCell;
expect(pluginBodyCell.style.display).toBe("flex");
expect(pluginBodyCell.style.alignItems).toBe("center");
expect(pluginBodyCell.style.paddingTop).toBe("12px");
expect(pluginBodyCell.style.paddingBottom).toBe("12px");
expect(pluginBodyCell.hasAttribute("data-v-cell-scope")).toBe(true);
});
test("keeps export field alignment when a row is missing the price cell", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithMissingPriceCell();
const initialTable = syncMarketTable(document);
if (!initialTable) {
throw new Error("Expected market table");
}
renderMarketRowState(initialTable.rows[1], {
authorId: "222",
authorName: "达人 B",
backendMetrics: {
a3IncreaseCount: "78,366.22",
afterViewSearchCount: "9,689.96",
afterViewSearchRate: "0.36%",
cpSearch: "14.46",
cpa3: "1.79",
newA3Rate: "3.44%"
},
backendMetricsStatus: "success",
status: "success",
rates: {
singleVideoAfterSearchRate: "0.5%-1%",
personalVideoAfterSearchRate: "0.02 - 0.1%"
}
});
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table after rerender");
}
expect(table.rows[1].exportFields).toMatchObject({
"21-60s报价": "",
"代表视频": "代表视频B",
"达人信息": "达人 B"
});
expect(table.rows[1].exportFields?.["21-60s报价"]).not.toContain("看后搜率");
});
test("falls back to the market vue state when the DOM has no author id", () => { test("falls back to the market vue state when the DOM has no author id", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds(); document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
attachMarketVueState([ attachMarketVueState([
@ -244,6 +387,146 @@ describe("market-dom-sync", () => {
expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]); expect(table.rows.map((row) => row.authorId)).toEqual(["111", "222"]);
}); });
test("fills blank export cells from the market vue state", () => {
document.body.innerHTML = buildRichMarketGridFixtureWithBlankSecondRow();
attachMarketVueState([
{
attribute_datas: {
avg_search_after_view_rate_30d: "0.003",
burst_text_rate: "1",
city: "温州",
content_theme_labels_180d: ["有趣剧情创作", "亲情剧集", "情感短剧"],
follower: "4550556",
gender: "2",
interact_rate_within_30d: "0.0572",
link_link_cnt_by_industry: "27029613",
nickname: "达人 A",
play_over_rate_within_30d: "0.263",
price_20_60: "155000",
prospective_20_60_cpm: "21.2362",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "7298854",
star_id: "111"
},
{
attribute_datas: {
avg_search_after_view_rate_30d: "0.003",
burst_text_rate: "0",
city: "杭州",
content_theme_labels_180d: ["搞笑剧情", "大学宿舍趣事", "校园生活"],
follower: "901234",
gender: "2",
interact_rate_within_30d: "0.072",
link_link_cnt_by_industry: "20773000",
nickname: "达人 B",
play_over_rate_within_30d: "0.35",
price_20_60: "38000",
prospective_20_60_cpm: "182.5",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "208000",
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows[1].authorId).toBe("222");
expect(table.rows[1].price21To60s).toBe("¥38,000");
expect(table.rows[1].exportFields).toMatchObject({
"21-60s报价": "¥38,000",
: "7.2%",
: "搞笑剧情 大学宿舍趣事 1+",
: "35%",
: "-",
: "90.1w",
: "达人 B 女 杭州",
: "剧情搞笑",
: "2,077.3w",
CPM: "182.5",
: "20.8w"
});
expect(table.rows[1].rates).toEqual({
singleVideoAfterSearchRate: "0.3%"
});
});
test("finds market rows in nested vue children", () => {
document.body.innerHTML = buildRichMarketGridFixtureWithBlankSecondRow();
attachNestedMarketVueState([
{
attribute_datas: {
city: "杭州",
follower: "901234",
gender: "2",
interact_rate_within_30d: "0.072",
link_link_cnt_by_industry: "20773000",
nickname: "达人 B",
play_over_rate_within_30d: "0.35",
price_20_60: "38000",
prospective_20_60_cpm: "182.5",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "208000",
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows[1].price21To60s).toBe("¥38,000");
expect(table.rows[1].exportFields).toMatchObject({
: "90.1w",
: "20.8w",
: "7.2%"
});
});
test("prefers vue fallback when the price cell is polluted", () => {
document.body.innerHTML = buildRichMarketGridFixtureWithPollutedSecondPrice();
attachMarketVueState([
{
attribute_datas: {
city: "杭州",
follower: "901234",
gender: "2",
interact_rate_within_30d: "0.072",
link_link_cnt_by_industry: "20773000",
nickname: "达人 B",
play_over_rate_within_30d: "0.35",
price_20_60: "38000",
prospective_20_60_cpm: "182.5",
tags_relation: {
: ["剧情"]
}
},
expected_play_num: "208000",
star_id: "222"
}
]);
const table = syncMarketTable(document);
if (!table) {
throw new Error("Expected market table");
}
expect(table.rows[1].price21To60s).toBe("¥38,000");
expect(table.rows[1].exportFields?.["21-60s报价"]).toBe("¥38,000");
});
test("falls back to serialized market rows when vue state is unavailable", () => { test("falls back to serialized market rows when vue state is unavailable", () => {
document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds(); document.body.innerHTML = buildRealMarketGridFixtureWithoutAuthorIds();
document.documentElement.setAttribute( document.documentElement.setAttribute(
@ -385,8 +668,167 @@ function buildRealMarketGridFixtureWithoutAuthorIds() {
`; `;
} }
function buildRealMarketGridFixtureWithMissingPriceCell() {
return `
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;"></div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="header-cell" style="min-width: 190px;"></div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;"></div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" data-testid="author-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" data-testid="author-cell-111" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111"> A</a>
</div>
<div class="content-cell" data-testid="author-cell-222" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/222"> B</a>
</div>
</div>
</div>
<div class="middle-columns" style="width: 190px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">A</div>
<div class="content-cell" style="height: 120px;">B</div>
</div>
</div>
<div data-testid="right-section" class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
<div class="content-cell" style="height: 120px;">¥450,000</div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" data-testid="action-cell-111" style="height: 120px;"></div>
<div class="content-cell" data-testid="action-cell-222" style="height: 120px;"></div>
</div>
</div>
</div>
</div>
`;
}
function buildRealMarketGridFixtureWithScopedAttributes() {
return buildRealMarketGridFixture()
.replace(
'<div class="header-cell" style="min-width: 190px;">代表视频</div>',
'<div data-v-header-scope class="header-cell" style="min-width: 190px;">代表视频</div>'
)
.replace(
'<div class="content-cell" style="height: 120px;">代表视频A</div>',
'<div data-v-cell-scope class="content-cell" style="height: 120px;">代表视频A</div>'
)
.replace(
'<div class="content-cell" style="height: 120px;">代表视频B</div>',
'<div data-v-cell-scope class="content-cell" style="height: 120px;">代表视频B</div>'
);
}
function buildRichMarketGridFixtureWithBlankSecondRow() {
return `
<div class="base-author-list">
<div class="section-wrapper sticky-header hide-scrollbar">
<div style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="header-cell" style="min-width: 310px;"></div>
</div>
<div class="middle-columns" style="width: 1210px; display: flex;">
<div class="header-cell" style="min-width: 190px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 180px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;">CPM</div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
<div class="header-cell" style="min-width: 120px;"></div>
</div>
<div data-testid="right-header" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="header-cell" style="min-width: 150px;">21-60s报价</div>
<div class="header-cell" style="min-width: 200px;"></div>
</div>
</div>
<div class="section-wrapper hide-scrollbar">
<div class="content-section" style="width: 310px; display: flex; position: sticky; left: 0; z-index: 11;">
<div class="content-column" style="min-width: 310px;">
<div class="content-cell" style="height: 120px;">
<a href="https://xingtu.cn/ad/creator/author-homepage/douyin-video/111"> A</a>
</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
<div class="middle-columns" style="width: 1210px; display: flex;">
<div class="content-column" style="min-width: 190px;">
<div class="content-cell" style="height: 120px;">A</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;"></div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 180px;">
<div class="content-cell" style="height: 120px;"> 1+</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">2,703w</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">455.1w</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">21.2</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">729.9w</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">5.7%</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">26.3%</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 120px;">
<div class="content-cell" style="height: 120px;">100%</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
<div class="content-section" style="width: 350px; display: flex; position: sticky; right: 0; z-index: 11;">
<div class="content-column" style="min-width: 150px;">
<div class="content-cell" style="height: 120px;">¥155,000</div>
<div class="content-cell" style="height: 120px;"></div>
</div>
<div class="content-column" style="min-width: 200px;">
<div class="content-cell" style="height: 120px;"></div>
<div class="content-cell" style="height: 120px;"></div>
</div>
</div>
</div>
</div>
`;
}
function buildRichMarketGridFixtureWithPollutedSecondPrice() {
return buildRichMarketGridFixtureWithBlankSecondRow().replace(
'<div class="content-cell" style="height: 120px;"></div>\n </div>\n <div class="content-column" style="min-width: 200px;">',
'<div class="content-cell" style="height: 120px;">看后搜率0.39%看后搜数2,248.33新增A3数0.00新增A3率0%CPA30.00cp_search20.01</div>\n </div>\n <div class="content-column" style="min-width: 200px;">'
);
}
function attachMarketVueState( function attachMarketVueState(
marketList: Array<{ attribute_datas?: { nickname?: string }; star_id?: string }> marketList: Array<Record<string, unknown>>
) { ) {
const marketRoot = document.querySelector(".base-author-list"); const marketRoot = document.querySelector(".base-author-list");
if (!(marketRoot instanceof HTMLElement)) { if (!(marketRoot instanceof HTMLElement)) {
@ -405,6 +847,40 @@ function attachMarketVueState(
}); });
} }
function attachNestedMarketVueState(marketList: Array<Record<string, unknown>>) {
const marketRoot = document.querySelector(".base-author-list");
if (!(marketRoot instanceof HTMLElement)) {
throw new Error("Expected market root");
}
Object.defineProperty(marketRoot, "__vue__", {
configurable: true,
value: {
$children: [
{
$children: [
{
_setupState: {},
$children: [
{
_setupState: {
__$temp_1: {
marketList
}
},
$children: []
}
]
}
],
_setupState: {}
}
],
_setupState: {}
}
});
}
function readRightHeaderTexts() { function readRightHeaderTexts() {
return Array.from( return Array.from(
document.querySelectorAll('[data-testid="right-header"] > *'), document.querySelectorAll('[data-testid="right-header"] > *'),
@ -412,6 +888,13 @@ function readRightHeaderTexts() {
); );
} }
function readPluginHeaderTexts() {
return Array.from(
document.querySelectorAll('[data-testid="plugin-header"] > *'),
(cell) => cell.textContent?.trim() ?? ""
);
}
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'),
@ -420,6 +903,20 @@ function readRightRowTexts(rowIndex: number) {
); );
} }
function readPluginRowTexts(rowIndex: number) {
return Array.from(
document.querySelectorAll('[data-testid="plugin-section"] > .content-column'),
(column) =>
column.querySelectorAll(".content-cell")[rowIndex]?.textContent?.trim() ?? ""
);
}
function readScrollHintText() {
return (
document.querySelector('[data-testid="market-scroll-hint"]')?.textContent?.trim() ?? ""
);
}
function readAuthorNames() { function readAuthorNames() {
return Array.from( return Array.from(
document.querySelectorAll('[data-testid="author-section"] .content-cell a'), document.querySelectorAll('[data-testid="author-section"] .content-cell a'),