feat: add sortable market metric columns
This commit is contained in:
parent
2f77199920
commit
a51c6f7bf2
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
runWithoutMutationSync(() => {
|
||||||
const table = syncMarketTable(options.document);
|
const table = syncMarketTable(options.document);
|
||||||
if (!table) {
|
if (!table) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncPluginSortHeaders(options.document, {
|
||||||
|
activeSort,
|
||||||
|
onToggleSort: toggleSortFromHeader
|
||||||
|
});
|
||||||
|
|
||||||
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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 =
|
||||||
|
|||||||
@ -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
@ -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'),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user