feat: wire market plugin controls
This commit is contained in:
parent
f6b2bdc862
commit
9b3c6756bb
@ -1 +1,34 @@
|
||||
export {};
|
||||
import {
|
||||
createMarketController,
|
||||
type CreateMarketControllerOptions
|
||||
} from "./market/index";
|
||||
|
||||
interface BootContentScriptOptions {
|
||||
createMarketController?: (
|
||||
options: CreateMarketControllerOptions
|
||||
) => { ready: Promise<void> };
|
||||
document?: Document;
|
||||
window?: Window;
|
||||
}
|
||||
|
||||
export async function bootContentScript(
|
||||
options: BootContentScriptOptions = {}
|
||||
): Promise<{ ready: Promise<void> } | null> {
|
||||
const currentWindow = options.window ?? window;
|
||||
const currentDocument = options.document ?? document;
|
||||
const controllerFactory =
|
||||
options.createMarketController ?? createMarketController;
|
||||
|
||||
if (!isMarketPage(currentWindow.location.href)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return controllerFactory({
|
||||
document: currentDocument,
|
||||
window: currentWindow
|
||||
});
|
||||
}
|
||||
|
||||
function isMarketPage(url: string): boolean {
|
||||
return url.startsWith("https://xingtu.cn/ad/creator/market");
|
||||
}
|
||||
|
||||
176
src/content/market/index.ts
Normal file
176
src/content/market/index.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { buildMarketCsv } from "./csv-exporter";
|
||||
import {
|
||||
applyRowOrder,
|
||||
applyRowVisibility,
|
||||
renderMarketRowState,
|
||||
syncMarketTable
|
||||
} from "./dom-sync";
|
||||
import { applyFilterAndSort } from "./filter-sort-controller";
|
||||
import { createFullScanController } from "./full-scan-controller";
|
||||
import { createMarketApiClient } from "./api-client";
|
||||
import { ensurePluginToolbar } from "./plugin-toolbar";
|
||||
import { createMarketResultStore } from "./result-store";
|
||||
import type {
|
||||
MarketApiResult,
|
||||
MarketFilterState,
|
||||
MarketRecord,
|
||||
MarketRowSnapshot,
|
||||
MarketSortState
|
||||
} from "./types";
|
||||
|
||||
interface FullScanControllerLike {
|
||||
ensureScanForExport(): Promise<void>;
|
||||
ensureScanForFilter(): Promise<void>;
|
||||
ensureScanForSort(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface CreateMarketControllerOptions {
|
||||
buildCsv?: (records: MarketRecord[]) => string;
|
||||
document: Document;
|
||||
fullScanController?: FullScanControllerLike;
|
||||
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
|
||||
onCsvReady?: (csv: string) => void;
|
||||
resultStore?: ReturnType<typeof createMarketResultStore>;
|
||||
window: Window;
|
||||
}
|
||||
|
||||
export function createMarketController(options: CreateMarketControllerOptions) {
|
||||
const table = syncMarketTable(options.document);
|
||||
const resultStore = options.resultStore ?? createMarketResultStore();
|
||||
const loadAuthorMetrics =
|
||||
options.loadAuthorMetrics ?? createMarketApiClient().loadAuthorAseInfo;
|
||||
const buildCsv = options.buildCsv ?? buildMarketCsv;
|
||||
let activeFilters: MarketFilterState = {};
|
||||
let activeSort: MarketSortState | undefined;
|
||||
|
||||
const fullScanController =
|
||||
options.fullScanController ??
|
||||
createFullScanController({
|
||||
goToNextPage: async () => false,
|
||||
hasNextPage: () => false,
|
||||
loadAuthorMetrics,
|
||||
readCurrentPageRows: () =>
|
||||
table ? table.rows.map((rowDom) => readRowSnapshot(rowDom.row)) : [],
|
||||
resultStore
|
||||
});
|
||||
|
||||
const toolbar = ensurePluginToolbar(options.document, {
|
||||
onApplyFilter: async () => {
|
||||
activeFilters = {
|
||||
personalVideoAfterSearchRateMin: parseNumberValue(
|
||||
toolbar.personalFilterInput.value
|
||||
),
|
||||
singleVideoAfterSearchRateMin: parseNumberValue(
|
||||
toolbar.singleFilterInput.value
|
||||
)
|
||||
};
|
||||
await fullScanController.ensureScanForFilter();
|
||||
applyCurrentView();
|
||||
},
|
||||
onApplySort: async () => {
|
||||
activeSort = readSortState(toolbar.sortFieldSelect, toolbar.sortDirectionSelect);
|
||||
await fullScanController.ensureScanForSort();
|
||||
applyCurrentView();
|
||||
},
|
||||
onExport: async () => {
|
||||
await fullScanController.ensureScanForExport();
|
||||
const records = getVisibleOrderedRecords();
|
||||
options.onCsvReady?.(buildCsv(records));
|
||||
}
|
||||
});
|
||||
|
||||
const ready = hydrateCurrentPage().then(() => {
|
||||
applyCurrentView();
|
||||
});
|
||||
|
||||
return {
|
||||
ready
|
||||
};
|
||||
|
||||
async function hydrateCurrentPage(): Promise<void> {
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const rowDom of table.rows) {
|
||||
const rowSnapshot = readRowSnapshot(rowDom.row);
|
||||
resultStore.upsertMarketRow(rowSnapshot);
|
||||
resultStore.setAuthorLoading(rowSnapshot.authorId);
|
||||
renderMarketRowState(rowDom, {
|
||||
...rowSnapshot,
|
||||
status: "loading"
|
||||
});
|
||||
|
||||
const metricsResult = await loadAuthorMetrics(rowSnapshot.authorId);
|
||||
if (metricsResult.success) {
|
||||
resultStore.setAuthorSuccess(rowSnapshot.authorId, metricsResult.rates);
|
||||
renderMarketRowState(rowDom, {
|
||||
...rowSnapshot,
|
||||
status: "success",
|
||||
rates: metricsResult.rates
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
resultStore.setAuthorFailed(rowSnapshot.authorId, metricsResult.reason);
|
||||
renderMarketRowState(rowDom, {
|
||||
...rowSnapshot,
|
||||
failureReason: metricsResult.reason,
|
||||
status: "failed"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyCurrentView(): void {
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = getVisibleOrderedRecords();
|
||||
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
|
||||
applyRowOrder(table, records.map((record) => record.authorId));
|
||||
}
|
||||
|
||||
function getVisibleOrderedRecords(): MarketRecord[] {
|
||||
return applyFilterAndSort(resultStore.listRecords(), {
|
||||
filters: activeFilters,
|
||||
sort: activeSort
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function readRowSnapshot(row: HTMLElement): MarketRowSnapshot {
|
||||
return {
|
||||
authorId: row.dataset.authorId ?? "",
|
||||
authorName:
|
||||
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
|
||||
"",
|
||||
price21To60s:
|
||||
row
|
||||
.querySelector('[data-market-field="price21To60s"]')
|
||||
?.textContent?.trim() ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
function parseNumberValue(value: string): number | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsedValue = Number(value);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : undefined;
|
||||
}
|
||||
|
||||
function readSortState(
|
||||
fieldSelect: HTMLSelectElement,
|
||||
directionSelect: HTMLSelectElement
|
||||
): MarketSortState | undefined {
|
||||
if (!fieldSelect.value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
direction: directionSelect.value === "asc" ? "asc" : "desc",
|
||||
field: fieldSelect.value as MarketSortState["field"]
|
||||
};
|
||||
}
|
||||
137
src/content/market/plugin-toolbar.ts
Normal file
137
src/content/market/plugin-toolbar.ts
Normal file
@ -0,0 +1,137 @@
|
||||
export interface PluginToolbarHandlers {
|
||||
onApplyFilter(): Promise<void> | void;
|
||||
onApplySort(): Promise<void> | void;
|
||||
onExport(): Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface PluginToolbarDom {
|
||||
exportButton: HTMLButtonElement;
|
||||
filterApplyButton: HTMLButtonElement;
|
||||
personalFilterInput: HTMLInputElement;
|
||||
root: HTMLElement;
|
||||
singleFilterInput: HTMLInputElement;
|
||||
sortApplyButton: HTMLButtonElement;
|
||||
sortDirectionSelect: HTMLSelectElement;
|
||||
sortFieldSelect: HTMLSelectElement;
|
||||
}
|
||||
|
||||
export function ensurePluginToolbar(
|
||||
document: Document,
|
||||
handlers: PluginToolbarHandlers
|
||||
): PluginToolbarDom {
|
||||
const existingRoot = document.querySelector(
|
||||
"[data-plugin-toolbar='root']"
|
||||
) as HTMLElement | null;
|
||||
if (existingRoot) {
|
||||
return readToolbarDom(existingRoot);
|
||||
}
|
||||
|
||||
const root = document.createElement("section");
|
||||
root.dataset.pluginToolbar = "root";
|
||||
|
||||
const singleFilterInput = document.createElement("input");
|
||||
singleFilterInput.type = "number";
|
||||
singleFilterInput.step = "0.01";
|
||||
singleFilterInput.dataset.pluginFilterSingle = "input";
|
||||
|
||||
const personalFilterInput = document.createElement("input");
|
||||
personalFilterInput.type = "number";
|
||||
personalFilterInput.step = "0.01";
|
||||
personalFilterInput.dataset.pluginFilterPersonal = "input";
|
||||
|
||||
const filterApplyButton = document.createElement("button");
|
||||
filterApplyButton.type = "button";
|
||||
filterApplyButton.dataset.pluginFilterApply = "button";
|
||||
filterApplyButton.textContent = "应用筛选";
|
||||
|
||||
const sortFieldSelect = document.createElement("select");
|
||||
sortFieldSelect.dataset.pluginSortField = "select";
|
||||
appendOption(sortFieldSelect, "", "不排序");
|
||||
appendOption(sortFieldSelect, "singleVideoAfterSearchRate", "单视频看后搜率");
|
||||
appendOption(sortFieldSelect, "personalVideoAfterSearchRate", "个人视频看后搜率");
|
||||
|
||||
const sortDirectionSelect = document.createElement("select");
|
||||
sortDirectionSelect.dataset.pluginSortDirection = "select";
|
||||
appendOption(sortDirectionSelect, "desc", "降序");
|
||||
appendOption(sortDirectionSelect, "asc", "升序");
|
||||
|
||||
const sortApplyButton = document.createElement("button");
|
||||
sortApplyButton.type = "button";
|
||||
sortApplyButton.dataset.pluginSortApply = "button";
|
||||
sortApplyButton.textContent = "应用排序";
|
||||
|
||||
const exportButton = document.createElement("button");
|
||||
exportButton.type = "button";
|
||||
exportButton.dataset.pluginExport = "button";
|
||||
exportButton.textContent = "导出CSV";
|
||||
|
||||
root.append(
|
||||
singleFilterInput,
|
||||
personalFilterInput,
|
||||
filterApplyButton,
|
||||
sortFieldSelect,
|
||||
sortDirectionSelect,
|
||||
sortApplyButton,
|
||||
exportButton
|
||||
);
|
||||
document.body.prepend(root);
|
||||
|
||||
filterApplyButton.addEventListener("click", () => {
|
||||
void handlers.onApplyFilter();
|
||||
});
|
||||
sortApplyButton.addEventListener("click", () => {
|
||||
void handlers.onApplySort();
|
||||
});
|
||||
exportButton.addEventListener("click", () => {
|
||||
void handlers.onExport();
|
||||
});
|
||||
|
||||
return {
|
||||
exportButton,
|
||||
filterApplyButton,
|
||||
personalFilterInput,
|
||||
root,
|
||||
singleFilterInput,
|
||||
sortApplyButton,
|
||||
sortDirectionSelect,
|
||||
sortFieldSelect
|
||||
};
|
||||
}
|
||||
|
||||
function appendOption(
|
||||
select: HTMLSelectElement,
|
||||
value: string,
|
||||
label: string
|
||||
): void {
|
||||
const option = select.ownerDocument.createElement("option");
|
||||
option.value = value;
|
||||
option.textContent = label;
|
||||
select.appendChild(option);
|
||||
}
|
||||
|
||||
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
|
||||
return {
|
||||
exportButton: root.querySelector(
|
||||
'[data-plugin-export="button"]'
|
||||
) as HTMLButtonElement,
|
||||
filterApplyButton: root.querySelector(
|
||||
'[data-plugin-filter-apply="button"]'
|
||||
) as HTMLButtonElement,
|
||||
personalFilterInput: root.querySelector(
|
||||
'[data-plugin-filter-personal="input"]'
|
||||
) as HTMLInputElement,
|
||||
root,
|
||||
singleFilterInput: root.querySelector(
|
||||
'[data-plugin-filter-single="input"]'
|
||||
) as HTMLInputElement,
|
||||
sortApplyButton: root.querySelector(
|
||||
'[data-plugin-sort-apply="button"]'
|
||||
) as HTMLButtonElement,
|
||||
sortDirectionSelect: root.querySelector(
|
||||
'[data-plugin-sort-direction="select"]'
|
||||
) as HTMLSelectElement,
|
||||
sortFieldSelect: root.querySelector(
|
||||
'[data-plugin-sort-field="select"]'
|
||||
) as HTMLSelectElement
|
||||
};
|
||||
}
|
||||
261
tests/market-content-entry.test.ts
Normal file
261
tests/market-content-entry.test.ts
Normal file
@ -0,0 +1,261 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment-options {"url":"https://xingtu.cn/"}
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createMarketResultStore } from "../src/content/market/result-store";
|
||||
|
||||
describe("market-content-entry", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
test("boots the market controller on the Xingtu market URL", async () => {
|
||||
const createMarketController = vi.fn(() => ({
|
||||
ready: Promise.resolve()
|
||||
}));
|
||||
|
||||
window.history.replaceState({}, "", "/ad/creator/market");
|
||||
|
||||
const { bootContentScript } = await import("../src/content/index");
|
||||
await bootContentScript({
|
||||
createMarketController
|
||||
});
|
||||
|
||||
expect(createMarketController).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("hydrates current page rows on start", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = createMarketController({
|
||||
document,
|
||||
loadAuthorMetrics: async (authorId) => ({
|
||||
success: true,
|
||||
rates:
|
||||
authorId === "a"
|
||||
? {
|
||||
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||||
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||||
}
|
||||
: {
|
||||
singleVideoAfterSearchRate: "0.5%-1%",
|
||||
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||||
}
|
||||
}),
|
||||
window
|
||||
});
|
||||
|
||||
await controller.ready;
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-market-row-cell="singleVideoAfterSearchRate"]')
|
||||
?.textContent
|
||||
).toBe("0.02% - 0.1%");
|
||||
expect(
|
||||
document.querySelector('[data-market-row-cell="personalVideoAfterSearchRate"]')
|
||||
?.textContent
|
||||
).toBe("0.03% - 0.2%");
|
||||
});
|
||||
|
||||
test("applying plugin filters triggers full scan and hides non-matching rows", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const resultStore = createMarketResultStore();
|
||||
const ensureScanForFilter = vi.fn(async () => {
|
||||
resultStore.setAuthorSuccess("a", {
|
||||
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||||
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||||
});
|
||||
resultStore.setAuthorSuccess("b", {
|
||||
singleVideoAfterSearchRate: "0.5%-1%",
|
||||
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||||
});
|
||||
});
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = createMarketController({
|
||||
document,
|
||||
fullScanController: {
|
||||
ensureScanForExport: vi.fn(async () => {}),
|
||||
ensureScanForFilter,
|
||||
ensureScanForSort: vi.fn(async () => {})
|
||||
},
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
resultStore,
|
||||
window
|
||||
});
|
||||
|
||||
await controller.ready;
|
||||
setInputValue('[data-plugin-filter-single="input"]', "0.1");
|
||||
click('[data-plugin-filter-apply="button"]');
|
||||
await flush();
|
||||
|
||||
expect(ensureScanForFilter).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
document.querySelector('[data-market-row="a"]')?.hasAttribute("hidden")
|
||||
).toBe(true);
|
||||
expect(
|
||||
document.querySelector('[data-market-row="b"]')?.hasAttribute("hidden")
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("applying plugin sorting triggers full scan and reorders rows", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const resultStore = createMarketResultStore();
|
||||
const ensureScanForSort = vi.fn(async () => {
|
||||
resultStore.setAuthorSuccess("a", {
|
||||
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||||
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||||
});
|
||||
resultStore.setAuthorSuccess("b", {
|
||||
singleVideoAfterSearchRate: "0.5%-1%",
|
||||
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||||
});
|
||||
});
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = createMarketController({
|
||||
document,
|
||||
fullScanController: {
|
||||
ensureScanForExport: vi.fn(async () => {}),
|
||||
ensureScanForFilter: vi.fn(async () => {}),
|
||||
ensureScanForSort
|
||||
},
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
resultStore,
|
||||
window
|
||||
});
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
|
||||
setSelectValue('[data-plugin-sort-direction="select"]', "desc");
|
||||
click('[data-plugin-sort-apply="button"]');
|
||||
await flush();
|
||||
|
||||
expect(ensureScanForSort).toHaveBeenCalledTimes(1);
|
||||
expect(readRowOrder()).toEqual(["b", "a"]);
|
||||
});
|
||||
|
||||
test("export triggers full scan and hands ordered visible records to the csv exporter", async () => {
|
||||
document.body.innerHTML = buildMarketFixture();
|
||||
const resultStore = createMarketResultStore();
|
||||
const buildCsv = vi.fn(() => "csv-output");
|
||||
const onCsvReady = vi.fn();
|
||||
const ensureScanForExport = vi.fn(async () => {
|
||||
resultStore.setAuthorSuccess("a", {
|
||||
singleVideoAfterSearchRate: "0.02% - 0.1%",
|
||||
personalVideoAfterSearchRate: "0.03% - 0.2%"
|
||||
});
|
||||
resultStore.setAuthorSuccess("b", {
|
||||
singleVideoAfterSearchRate: "0.5%-1%",
|
||||
personalVideoAfterSearchRate: "0.01% - 0.1%"
|
||||
});
|
||||
});
|
||||
|
||||
const { createMarketController } = await import("../src/content/market/index");
|
||||
const controller = createMarketController({
|
||||
buildCsv,
|
||||
document,
|
||||
fullScanController: {
|
||||
ensureScanForExport,
|
||||
ensureScanForFilter: vi.fn(async () => {}),
|
||||
ensureScanForSort: vi.fn(async () => {})
|
||||
},
|
||||
loadAuthorMetrics: async () => ({
|
||||
success: false,
|
||||
reason: "request-failed"
|
||||
}),
|
||||
onCsvReady,
|
||||
resultStore,
|
||||
window
|
||||
});
|
||||
|
||||
await controller.ready;
|
||||
setSelectValue('[data-plugin-sort-field="select"]', "singleVideoAfterSearchRate");
|
||||
setSelectValue('[data-plugin-sort-direction="select"]', "desc");
|
||||
click('[data-plugin-sort-apply="button"]');
|
||||
await flush();
|
||||
click('[data-plugin-export="button"]');
|
||||
await flush();
|
||||
|
||||
expect(ensureScanForExport).toHaveBeenCalledTimes(1);
|
||||
expect(buildCsv).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ authorId: "a" }),
|
||||
expect.objectContaining({ authorId: "b" })
|
||||
])
|
||||
);
|
||||
expect(buildCsv.mock.calls[0][0].map((record) => record.authorId)).toEqual([
|
||||
"b",
|
||||
"a"
|
||||
]);
|
||||
expect(onCsvReady).toHaveBeenCalledWith("csv-output");
|
||||
});
|
||||
});
|
||||
|
||||
function buildMarketFixture() {
|
||||
return `
|
||||
<div data-market-table>
|
||||
<div data-market-header>
|
||||
<div data-market-header-cell="authorName">达人</div>
|
||||
<div data-market-header-cell="price21To60s">21-60s报价</div>
|
||||
</div>
|
||||
<div data-market-body>
|
||||
<div data-market-row="a" data-author-id="a">
|
||||
<span data-market-field="authorName">Alpha</span>
|
||||
<span data-market-field="price21To60s">450000</span>
|
||||
</div>
|
||||
<div data-market-row="b" data-author-id="b">
|
||||
<span data-market-field="authorName">Beta</span>
|
||||
<span data-market-field="price21To60s">70000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function click(selector: string) {
|
||||
const element = document.querySelector(selector) as HTMLButtonElement | null;
|
||||
if (!element) {
|
||||
throw new Error(`Missing element: ${selector}`);
|
||||
}
|
||||
|
||||
element.click();
|
||||
}
|
||||
|
||||
function setInputValue(selector: string, value: string) {
|
||||
const element = document.querySelector(selector) as HTMLInputElement | null;
|
||||
if (!element) {
|
||||
throw new Error(`Missing input: ${selector}`);
|
||||
}
|
||||
|
||||
element.value = value;
|
||||
}
|
||||
|
||||
function setSelectValue(selector: string, value: string) {
|
||||
const element = document.querySelector(selector) as HTMLSelectElement | null;
|
||||
if (!element) {
|
||||
throw new Error(`Missing select: ${selector}`);
|
||||
}
|
||||
|
||||
element.value = value;
|
||||
}
|
||||
|
||||
function readRowOrder() {
|
||||
return Array.from(document.querySelectorAll("[data-market-row]")).map(
|
||||
(row) => row.getAttribute("data-author-id")
|
||||
);
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user