release: 0.2.0421.2

This commit is contained in:
2026-04-21 17:10:06 +08:00
parent 55324a5bb7
commit 968cc88c62
19 changed files with 2832 additions and 178 deletions
+95
View File
@@ -0,0 +1,95 @@
interface ChromeDownloadsLike {
download(
options: {
filename: string;
saveAs?: boolean;
url: string;
},
callback?: () => void
): Promise<unknown> | void;
}
interface ChromeRuntimeLike {
onMessage?: {
addListener(
listener: (
message: unknown,
sender: unknown,
sendResponse: (response: unknown) => void
) => boolean | void
): void;
};
}
interface ChromeLike {
downloads?: ChromeDownloadsLike;
runtime?: ChromeRuntimeLike;
}
type DownloadMarketCsvMessage = {
csv: string;
filename: string;
type: "download-market-csv";
};
export function registerBackgroundMessageHandler(
chromeLike: ChromeLike = (
globalThis as typeof globalThis & {
chrome?: ChromeLike;
}
).chrome ?? {}
): void {
chromeLike.runtime?.onMessage?.addListener((message, _sender, sendResponse) => {
if (!isDownloadMarketCsvMessage(message)) {
return;
}
void triggerCsvDownload(chromeLike, message)
.then(() => {
sendResponse({ ok: true });
})
.catch((error) => {
sendResponse({
error: error instanceof Error ? error.message : String(error),
ok: false
});
});
return true;
});
}
async function triggerCsvDownload(
chromeLike: ChromeLike,
message: DownloadMarketCsvMessage
): Promise<void> {
if (!chromeLike.downloads?.download) {
throw new Error("chrome.downloads.download is unavailable");
}
const csvUrl = `data:text/csv;charset=utf-8,${encodeURIComponent(`\uFEFF${message.csv}`)}`;
await Promise.resolve(
chromeLike.downloads.download({
filename: message.filename,
saveAs: false,
url: csvUrl
})
);
}
function isDownloadMarketCsvMessage(
message: unknown
): message is DownloadMarketCsvMessage {
if (!message || typeof message !== "object") {
return false;
}
const candidate = message as Partial<DownloadMarketCsvMessage>;
return (
candidate.type === "download-market-csv" &&
typeof candidate.csv === "string" &&
typeof candidate.filename === "string"
);
}
registerBackgroundMessageHandler();
+47
View File
@@ -6,8 +6,11 @@ import {
interface ChromeRuntimeLike {
getURL?: (path: string) => string;
id?: string;
sendMessage?: (message: unknown) => void | Promise<unknown>;
}
const DOWNLOAD_MARKET_CSV_MESSAGE = "download-market-csv";
interface BootContentScriptOptions {
createMarketController?: (
options: CreateMarketControllerOptions
@@ -32,6 +35,13 @@ export async function bootContentScript(
return controllerFactory({
document: currentDocument,
onCsvReady: (csv: string) => {
if (requestCsvDownload(csv)) {
return;
}
downloadCsv(currentDocument, currentWindow, csv);
},
window: currentWindow
});
}
@@ -72,6 +82,43 @@ function bootstrapContentScript() {
bootstrapContentScript();
function requestCsvDownload(csv: string): boolean {
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
if (!runtime?.id || typeof runtime.sendMessage !== "function") {
return false;
}
runtime.sendMessage({
csv,
filename: `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
type: DOWNLOAD_MARKET_CSV_MESSAGE
});
return true;
}
function downloadCsv(document: Document, window: Window, csv: string): void {
const blob = new Blob(["\uFEFF", csv], {
type: "text/csv;charset=utf-8"
});
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = objectUrl;
link.download = `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(objectUrl);
}
function formatTimestampForFilename(): string {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function installMarketPageBridge(document: Document) {
if (
document.documentElement.querySelector(
+40 -9
View File
@@ -2,7 +2,12 @@ import { normalizeRateDisplay } from "../../shared/rate-normalizer";
import { escapeCsvCell } from "../../shared/csv";
import type { MarketRecord } from "./types";
const CSV_COLUMNS = [
type CsvColumn = {
header: string;
readValue: (record: MarketRecord) => string;
};
const FALLBACK_BASE_COLUMNS: CsvColumn[] = [
{
header: "达人ID",
readValue: (record: MarketRecord) => record.authorId
@@ -18,7 +23,10 @@ const CSV_COLUMNS = [
{
header: "21-60s报价",
readValue: (record: MarketRecord) => record.price21To60s ?? ""
},
}
];
const RATE_COLUMNS: CsvColumn[] = [
{
header: "单视频看后搜率",
readValue: (record: MarketRecord) =>
@@ -32,18 +40,41 @@ const CSV_COLUMNS = [
record.rates?.personalVideoAfterSearchRate
? normalizeRateDisplay(record.rates.personalVideoAfterSearchRate)
: ""
},
{
header: "插件数据状态",
readValue: (record: MarketRecord) => record.status
}
] as const;
];
export function buildMarketCsv(records: MarketRecord[]): string {
const headerLine = CSV_COLUMNS.map((column) => column.header).join(",");
const baseColumns = buildBaseColumns(records);
const csvColumns = [...baseColumns, ...RATE_COLUMNS];
const headerLine = csvColumns.map((column) => column.header).join(",");
const rowLines = records.map((record) =>
CSV_COLUMNS.map((column) => escapeCsvCell(column.readValue(record))).join(",")
csvColumns.map((column) => escapeCsvCell(column.readValue(record))).join(",")
);
return [headerLine, ...rowLines].join("\n");
}
function buildBaseColumns(records: MarketRecord[]): CsvColumn[] {
const orderedHeaders: string[] = [];
const seenHeaders = new Set<string>();
records.forEach((record) => {
Object.keys(record.exportFields ?? {}).forEach((header) => {
if (seenHeaders.has(header)) {
return;
}
seenHeaders.add(header);
orderedHeaders.push(header);
});
});
if (orderedHeaders.length === 0) {
return FALLBACK_BASE_COLUMNS;
}
return orderedHeaders.map((header) => ({
header,
readValue: (record: MarketRecord) => record.exportFields?.[header] ?? ""
}));
}
+142
View File
@@ -20,6 +20,7 @@ type RowOrderTarget = {
export interface MarketRowDom {
authorId: string;
authorName: string;
exportFields?: Record<string, string>;
hasDirectRatesSource?: boolean;
personalCell: HTMLElement;
price21To60s?: string;
@@ -38,6 +39,68 @@ export function syncMarketTable(root: ParentNode): MarketTableDom | null {
return syncSyntheticMarketTable(root) ?? syncDivGridMarketTable(root);
}
export function readMarketPageSignature(root: ParentNode): string {
const document = getOwnerDocument(root);
const explicitPageIndex =
document?.documentElement.getAttribute("data-test-page-index") ?? "";
const activePageIndex =
document
?.querySelector(".el-pagination .number.active, .xt-pagination .number.active")
?.textContent?.trim() ?? "";
const table = syncMarketTable(root);
const authorIds =
table?.rows
.map((row) => row.authorId)
.filter((authorId) => Boolean(authorId))
.join("|") ?? "";
return `${explicitPageIndex || activePageIndex}::${authorIds}`;
}
export function findNextPageControl(root: ParentNode): HTMLElement | null {
const document = getOwnerDocument(root);
if (!document) {
return null;
}
const explicitControl = document.querySelector('[data-testid="next-page"]');
if (explicitControl instanceof document.defaultView!.HTMLElement) {
return explicitControl;
}
const paginationNextControl = document.querySelector(
".el-pagination .btn-next, .xt-pagination .btn-next"
);
if (paginationNextControl instanceof document.defaultView!.HTMLElement) {
return paginationNextControl;
}
const candidates = Array.from(
document.querySelectorAll("button, a, [role='button']")
).filter(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement
);
return (
candidates.find((element) =>
/下一页|next/i.test(normalizeExportCellText(element.textContent))
) ?? null
);
}
export function isPageControlDisabled(control: HTMLElement | null): boolean {
if (!control) {
return true;
}
if (control instanceof HTMLButtonElement) {
return control.disabled;
}
return control.getAttribute("aria-disabled") === "true";
}
export function renderMarketRowState(
rowDom: MarketRowDom,
record: MarketRecord
@@ -109,6 +172,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
ensureSyntheticHeaderCell(header, SINGLE_COLUMN_KEY, "单视频看后搜率");
ensureSyntheticHeaderCell(header, PERSONAL_COLUMN_KEY, "个人视频看后搜率");
const headerLabelsByField = readSyntheticHeaderLabels(header);
const rows = Array.from(body.querySelectorAll("[data-market-row]")).map(
(rowElement) => {
const row = rowElement as HTMLElement;
@@ -120,6 +184,7 @@ function syncSyntheticMarketTable(root: ParentNode): MarketTableDom | null {
authorName:
row.querySelector('[data-market-field="authorName"]')?.textContent?.trim() ??
"",
exportFields: readSyntheticExportFields(row, headerLabelsByField),
hasDirectRatesSource: false,
orderTargets: [
{
@@ -228,6 +293,11 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
? getDirectContentColumns(section)
: []
);
const allHeaderCells = Array.from(headerSection.children).flatMap((section) =>
section instanceof root.ownerDocument.defaultView!.HTMLElement
? getDirectHeaderCells(section)
: []
);
const authorCells = getDirectContentCells(authorColumn);
const singleCells = getDirectContentCells(singleColumn);
const personalCells = getDirectContentCells(personalColumn);
@@ -263,6 +333,7 @@ function syncDivGridRoot(root: HTMLElement): MarketTableDom | null {
{
authorId,
authorName,
exportFields: readExportFieldsForDivGridRow(allHeaderCells, rowCells),
hasDirectRatesSource:
vueMarketRow?.hasDirectRatesSource ??
serializedMarketRow?.hasDirectRatesSource ??
@@ -420,6 +491,64 @@ function getOwnerDocument(root: ParentNode): Document | null {
return root instanceof Document ? root : null;
}
function readSyntheticHeaderLabels(header: HTMLElement): Record<string, string> {
return Array.from(header.querySelectorAll("[data-market-header-cell]")).reduce<
Record<string, string>
>((labels, cell) => {
if (!(cell instanceof header.ownerDocument.defaultView!.HTMLElement)) {
return labels;
}
const field = cell.dataset.marketHeaderCell;
if (!field) {
return labels;
}
labels[field] = normalizeExportCellText(cell.textContent);
return labels;
}, {});
}
function readSyntheticExportFields(
row: HTMLElement,
headerLabelsByField: Record<string, string>
): Record<string, string> {
const exportFields: Record<string, string> = {};
for (const cell of row.querySelectorAll("[data-market-field]")) {
if (!(cell instanceof row.ownerDocument.defaultView!.HTMLElement)) {
continue;
}
const field = cell.dataset.marketField;
const headerLabel = field ? headerLabelsByField[field] : "";
if (!shouldExportColumn(headerLabel)) {
continue;
}
exportFields[headerLabel] = normalizeExportCellText(cell.textContent);
}
return exportFields;
}
function readExportFieldsForDivGridRow(
headerCells: HTMLElement[],
rowCells: HTMLElement[]
): Record<string, string> {
const exportFields: Record<string, string> = {};
rowCells.forEach((cell, index) => {
const headerLabel = normalizeExportCellText(headerCells[index]?.textContent);
if (!shouldExportColumn(headerLabel)) {
return;
}
exportFields[headerLabel] = normalizeExportCellText(cell.textContent);
});
return exportFields;
}
function findPreviousHeaderCell(cell: HTMLElement): HTMLElement | null {
let current = cell.previousElementSibling;
while (current) {
@@ -674,6 +803,19 @@ function normalizeMarketListRate(value: unknown): string | null {
return typeof value === "string" ? normalizeFractionRateDisplay(value) : null;
}
function normalizeExportCellText(value: string | null | undefined): string {
return value?.replace(/\s+/g, " ").trim() ?? "";
}
function shouldExportColumn(label: string): boolean {
return Boolean(
label &&
label !== ACTION_HEADER_TEXT &&
label !== "单视频看后搜率" &&
label !== "个人视频看后搜率"
);
}
function readRateCellText(value: string | undefined): string {
return value ? normalizeRateDisplay(value) : UNAVAILABLE_RATE_TEXT;
}
@@ -0,0 +1,276 @@
import {
findNextPageControl,
isPageControlDisabled,
readMarketPageSignature
} from "./dom-sync";
import type { MarketExportTarget, MarketRecord, MarketRecordStatus } from "./types";
interface ExportRangeControllerOptions {
document: Document;
onProgress?: (state: { currentPage: number; totalPages?: number }) => void;
prepareCurrentPageForExport(): Promise<void>;
readCurrentPageRecords(): MarketRecord[];
window: Window;
}
export function createExportRangeController(options: ExportRangeControllerOptions) {
return {
async exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
const mergedRecords = new Map<string, MarketRecord>();
let currentPage = 0;
let expectedMinimumRowCount: number | undefined;
while (true) {
currentPage += 1;
options.onProgress?.({
currentPage,
totalPages: target.mode === "count" ? target.pageCount : undefined
});
const currentPageReady = await waitForCurrentPageReady(expectedMinimumRowCount);
if (!currentPageReady) {
throw new Error(`${currentPage} 页加载超时,请稍后重试`);
}
await options.prepareCurrentPageForExport();
const currentPageRecords = options.readCurrentPageRecords();
currentPageRecords.forEach((record) => {
const existingRecord = mergedRecords.get(record.authorId);
mergedRecords.set(record.authorId, mergeMarketRecord(existingRecord, record));
});
expectedMinimumRowCount = Math.max(
expectedMinimumRowCount ?? 0,
currentPageRecords.length
);
if (target.mode === "count" && currentPage >= target.pageCount) {
break;
}
const previousSignature = readMarketPageSignature(options.document);
const nextPageControl = findNextPageControl(options.document);
if (!nextPageControl || isPageControlDisabled(nextPageControl)) {
break;
}
nextPageControl.click();
const pageChanged = await waitForPageChange(previousSignature);
if (!pageChanged) {
throw new Error(`${currentPage + 1} 页导出失败,请稍后重试`);
}
}
return Array.from(mergedRecords.values());
}
};
async function waitForPageChange(previousSignature: string): Promise<boolean> {
const previousPageState = parsePageSignature(previousSignature);
for (let attempt = 0; attempt < 60; attempt += 1) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 50);
});
await Promise.resolve();
const nextSignature = readMarketPageSignature(options.document);
const nextPageState = parsePageSignature(nextSignature);
if (hasLoadedNextPage(previousPageState, nextPageState)) {
return true;
}
}
return false;
}
async function waitForCurrentPageReady(
expectedMinimumRowCount: number | undefined
): Promise<boolean> {
let stableAttemptCount = 0;
let lastReadyFingerprint = "";
for (let attempt = 0; attempt < 80; attempt += 1) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 150);
});
await Promise.resolve();
const pageState = readCurrentPageState();
if (!pageState.authorIds || pageState.rowCount <= 0) {
stableAttemptCount = 0;
lastReadyFingerprint = "";
continue;
}
if (
typeof expectedMinimumRowCount === "number" &&
expectedMinimumRowCount > 0 &&
!pageState.isTerminalPage &&
pageState.rowCount < expectedMinimumRowCount
) {
stableAttemptCount = 0;
lastReadyFingerprint = "";
continue;
}
const readyFingerprint = [
pageState.pageToken,
pageState.authorIds,
String(pageState.rowCount),
pageState.isTerminalPage ? "terminal" : "paged"
].join("::");
if (readyFingerprint === lastReadyFingerprint) {
stableAttemptCount += 1;
} else {
lastReadyFingerprint = readyFingerprint;
stableAttemptCount = 1;
}
if (stableAttemptCount >= 6) {
return true;
}
}
return false;
}
function readCurrentPageState(): {
authorIds: string;
isTerminalPage: boolean;
pageToken: string;
rowCount: number;
} {
const pageSignature = parsePageSignature(readMarketPageSignature(options.document));
const nextPageControl = findNextPageControl(options.document);
return {
authorIds: pageSignature.authorIds,
isTerminalPage: isPageControlDisabled(nextPageControl),
pageToken: pageSignature.pageToken,
rowCount: options.readCurrentPageRecords().length
};
}
}
function parsePageSignature(signature: string): {
authorIds: string;
pageToken: string;
} {
const separatorIndex = signature.indexOf("::");
if (separatorIndex < 0) {
return {
authorIds: "",
pageToken: signature.trim()
};
}
return {
authorIds: signature.slice(separatorIndex + 2).trim(),
pageToken: signature.slice(0, separatorIndex).trim()
};
}
function hasLoadedNextPage(
previousPageState: {
authorIds: string;
pageToken: string;
},
nextPageState: {
authorIds: string;
pageToken: string;
}
): boolean {
if (!nextPageState.authorIds) {
return false;
}
if (nextPageState.pageToken || previousPageState.pageToken) {
return nextPageState.pageToken !== previousPageState.pageToken;
}
return nextPageState.authorIds !== previousPageState.authorIds;
}
function mergeMarketRecord(
existingRecord: MarketRecord | undefined,
incomingRecord: MarketRecord
): MarketRecord {
if (!existingRecord) {
return {
...incomingRecord,
exportFields: mergeFieldMap(undefined, incomingRecord.exportFields),
rates: mergeFieldMap(undefined, incomingRecord.rates)
};
}
return {
...existingRecord,
...incomingRecord,
authorName: mergeStringValue(existingRecord.authorName, incomingRecord.authorName) ?? "",
exportFields: mergeFieldMap(
existingRecord.exportFields,
incomingRecord.exportFields
),
failureReason: incomingRecord.failureReason ?? existingRecord.failureReason,
hasDirectRatesSource:
existingRecord.hasDirectRatesSource || incomingRecord.hasDirectRatesSource,
location: mergeStringValue(existingRecord.location, incomingRecord.location),
price21To60s: mergeStringValue(
existingRecord.price21To60s,
incomingRecord.price21To60s
),
rates: mergeFieldMap(existingRecord.rates, incomingRecord.rates),
status: mergeStatus(existingRecord.status, incomingRecord.status)
};
}
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (hasTextValue(value) || !hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStatus(
current: MarketRecordStatus,
incoming: MarketRecordStatus
): MarketRecordStatus {
const priority: Record<MarketRecordStatus, number> = {
failed: 1,
idle: 0,
loading: 2,
missing: -1,
success: 3
};
return priority[incoming] >= priority[current] ? incoming : current;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (hasTextValue(incoming) || !hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
+275 -98
View File
@@ -7,24 +7,24 @@ import {
type MarketRowDom
} from "./dom-sync";
import { applyFilterAndSort } from "./filter-sort-controller";
import { createFullScanController } from "./full-scan-controller";
import { createMarketApiClient } from "./api-client";
import { createExportRangeController } from "./export-range-controller";
import { ensurePluginToolbar } from "./plugin-toolbar";
import {
readToolbarExportTarget,
setToolbarBusyState,
setToolbarExportStatus
} from "./plugin-toolbar";
import { createMarketResultStore } from "./result-store";
import type {
MarketApiResult,
MarketFilterState,
MarketExportTarget,
MarketRecord,
MarketRowSnapshot,
MarketSortState
} from "./types";
interface FullScanControllerLike {
ensureScanForExport(): Promise<void>;
ensureScanForFilter(): Promise<void>;
ensureScanForSort(): Promise<void>;
}
interface MutationObserverLike {
disconnect(): void;
observe(target: Node, options?: MutationObserverInit): void;
@@ -33,7 +33,6 @@ interface MutationObserverLike {
export interface CreateMarketControllerOptions {
buildCsv?: (records: MarketRecord[]) => string;
document: Document;
fullScanController?: FullScanControllerLike;
loadAuthorMetrics?: (authorId: string) => Promise<MarketApiResult>;
mutationObserverFactory?: (
callback: MutationCallback
@@ -52,22 +51,25 @@ export function createMarketController(options: CreateMarketControllerOptions) {
const mutationObserverFactory =
options.mutationObserverFactory ??
((callback: MutationCallback) => new MutationObserver(callback));
const exportRangeController = createExportRangeController({
document: options.document,
onProgress: ({ currentPage, totalPages }) => {
setToolbarExportStatus(
toolbar,
totalPages
? `导出中 ${currentPage}/${totalPages} 页...`
: `导出中 第${currentPage}页...`
);
},
prepareCurrentPageForExport: prepareCurrentPageForExport,
readCurrentPageRecords: () => getVisibleOrderedRecords(),
window: options.window
});
let activeFilters: MarketFilterState = {};
let activeSort: MarketSortState | undefined;
let isSyncRunning = false;
let isSyncScheduled = false;
let needsResync = false;
const fullScanController =
options.fullScanController ??
createFullScanController({
goToNextPage: () => goToNextMarketPage(options.document, options.window),
hasNextPage: () => hasNextMarketPage(options.document),
loadAuthorMetrics,
readCurrentPageRows: () =>
readCurrentPageRows(options.document),
resultStore
});
const observer = mutationObserverFactory(() => {
scheduleSync();
});
@@ -89,18 +91,32 @@ export function createMarketController(options: CreateMarketControllerOptions) {
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 exportTarget = readToolbarExportTarget(toolbar);
if (!exportTarget.target) {
setToolbarExportStatus(toolbar, exportTarget.error ?? "导出配置无效");
return;
}
setToolbarBusyState(toolbar, true);
try {
const records = await exportRecords(exportTarget.target);
options.onCsvReady?.(buildCsv(records));
setToolbarExportStatus(toolbar, "");
} catch (error) {
setToolbarExportStatus(
toolbar,
error instanceof Error ? error.message : "导出失败,请稍后重试"
);
} finally {
setToolbarBusyState(toolbar, false);
}
}
});
@@ -195,18 +211,213 @@ export function createMarketController(options: CreateMarketControllerOptions) {
return;
}
const records = getVisibleOrderedRecords();
const records = getVisibleOrderedRecords(table);
applyRowVisibility(table, new Set(records.map((record) => record.authorId)));
applyRowOrder(table, records.map((record) => record.authorId));
}
function getVisibleOrderedRecords(): MarketRecord[] {
return applyFilterAndSort(resultStore.listRecords(), {
function getVisibleOrderedRecords(table = syncMarketTable(options.document)): MarketRecord[] {
const currentPageRecords = readCurrentPageRecords(table);
return applyFilterAndSort(currentPageRecords, {
filters: activeFilters,
sort: activeSort
});
}
async function exportRecords(target: MarketExportTarget): Promise<MarketRecord[]> {
if (target.mode === "count" && target.pageCount <= 1) {
setToolbarExportStatus(toolbar, "导出中...");
await prepareCurrentPageForExport();
return getVisibleOrderedRecords();
}
return exportRangeController.exportRecords(target);
}
async function prepareCurrentPageForExport(): Promise<void> {
await runSyncCycle();
await harvestCurrentPageForExport();
}
async function harvestCurrentPageForExport(): Promise<void> {
await collectCurrentPageSnapshotsUntilSettled();
const table = syncMarketTable(options.document);
const scrollContainer = findCurrentPageScrollContainer(table);
if (!scrollContainer) {
return;
}
const originalScrollTop = scrollContainer.scrollTop;
const maxScrollTop = Math.max(
0,
scrollContainer.scrollHeight - scrollContainer.clientHeight
);
if (maxScrollTop <= 0) {
return;
}
const step = Math.max(scrollContainer.clientHeight, 240);
for (
let nextScrollTop = Math.min(originalScrollTop + step, maxScrollTop);
nextScrollTop > originalScrollTop && nextScrollTop <= maxScrollTop;
nextScrollTop = Math.min(nextScrollTop + step, maxScrollTop)
) {
setScrollTop(scrollContainer, nextScrollTop);
await collectCurrentPageSnapshotsUntilSettled();
if (nextScrollTop === maxScrollTop) {
break;
}
}
if (scrollContainer.scrollTop !== originalScrollTop) {
setScrollTop(scrollContainer, originalScrollTop);
await collectCurrentPageSnapshotsUntilSettled();
}
}
function readCurrentPageRecords(table: ReturnType<typeof syncMarketTable>): MarketRecord[] {
if (!table) {
return [];
}
return table.rows
.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom);
if (!rowSnapshot.authorId) {
return null;
}
const existingRecord = resultStore.getRecord(rowSnapshot.authorId);
return {
...existingRecord,
...rowSnapshot,
authorName: mergeStringValue(existingRecord?.authorName, rowSnapshot.authorName) ?? "",
exportFields: mergeFieldMap(
existingRecord?.exportFields,
rowSnapshot.exportFields
),
location: mergeStringValue(existingRecord?.location, rowSnapshot.location),
price21To60s: mergeStringValue(
existingRecord?.price21To60s,
rowSnapshot.price21To60s
),
rates: mergeFieldMap(existingRecord?.rates, rowSnapshot.rates),
status: existingRecord?.status ?? "idle"
} satisfies MarketRecord;
})
.filter((record): record is MarketRecord => record !== null);
}
function collectCurrentPageSnapshots(): void {
readCurrentPageRows(options.document).forEach((rowSnapshot) => {
resultStore.upsertMarketRow(rowSnapshot);
});
}
function findCurrentPageScrollContainer(
table: ReturnType<typeof syncMarketTable>
): HTMLElement | null {
if (!table) {
return null;
}
const seenElements = new Set<HTMLElement>();
const candidateRoots = table.rows
.map((rowDom) => rowDom.row)
.filter((row): row is HTMLElement => row instanceof options.window.HTMLElement);
for (const rootElement of candidateRoots) {
let currentElement = rootElement.parentElement;
while (currentElement) {
if (
!seenElements.has(currentElement) &&
isScrollableContainer(currentElement)
) {
return currentElement;
}
seenElements.add(currentElement);
currentElement = currentElement.parentElement;
}
}
return null;
}
function isScrollableContainer(element: HTMLElement): boolean {
const computedStyle = options.window.getComputedStyle(element);
return (
/auto|scroll|overlay/.test(computedStyle.overflowY) &&
element.scrollHeight > element.clientHeight
);
}
async function waitForDomSettled(): Promise<void> {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 0);
});
await Promise.resolve();
}
async function collectCurrentPageSnapshotsUntilSettled(): Promise<void> {
let previousFingerprint = "";
let stablePassCount = 0;
for (let attempt = 0; attempt < 12; attempt += 1) {
await waitForDomSettled();
if (attempt > 0) {
await new Promise<void>((resolve) => {
options.window.setTimeout(resolve, 100);
});
await Promise.resolve();
}
collectCurrentPageSnapshots();
const nextFingerprint = readVisibleRowHydrationFingerprint();
if (!nextFingerprint) {
stablePassCount = 0;
previousFingerprint = "";
continue;
}
if (nextFingerprint === previousFingerprint) {
stablePassCount += 1;
} else {
previousFingerprint = nextFingerprint;
stablePassCount = 1;
}
if (stablePassCount >= 3) {
return;
}
}
}
function readVisibleRowHydrationFingerprint(): string {
const table = syncMarketTable(options.document);
if (!table || table.rows.length === 0) {
return "";
}
return table.rows
.map((rowDom) => {
const rowSnapshot = readRowSnapshot(rowDom);
const populatedFieldCount = Object.values(rowSnapshot.exportFields ?? {}).filter(
(value) => typeof value === "string" && value.trim().length > 0
).length;
return [
rowSnapshot.authorId,
populatedFieldCount,
rowSnapshot.price21To60s?.trim() ? "price" : "no-price"
].join(":");
})
.join("|");
}
function scheduleSync(): void {
if (isSyncRunning) {
needsResync = true;
@@ -245,6 +456,11 @@ export function createMarketController(options: CreateMarketControllerOptions) {
}
function setScrollTop(element: HTMLElement, top: number): void {
element.scrollTop = top;
element.dispatchEvent(new Event("scroll"));
}
function readCurrentPageRows(document: Document): MarketRowSnapshot[] {
const table = syncMarketTable(document);
if (!table) {
@@ -260,6 +476,7 @@ function readRowSnapshot(rowDom: MarketRowDom): MarketRowSnapshot {
return {
authorId: rowDom.authorId,
authorName: rowDom.authorName,
exportFields: rowDom.exportFields,
hasDirectRatesSource: rowDom.hasDirectRatesSource,
price21To60s: rowDom.price21To60s,
rates: rowDom.rates
@@ -289,79 +506,39 @@ function readSortState(
};
}
function hasNextMarketPage(document: Document): boolean {
const nextButton = findNextPageButton(document);
return Boolean(nextButton && !isDisabled(nextButton));
}
async function goToNextMarketPage(
document: Document,
window: Window
): Promise<boolean> {
const nextButton = findNextPageButton(document);
if (!nextButton || isDisabled(nextButton)) {
return false;
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const previousSignature = getCurrentPageSignature(document);
nextButton.click();
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
return waitForPageSignatureChange(document, window, previousSignature);
}
function findNextPageButton(document: Document): HTMLElement | null {
const selectorMatch = document.querySelector(
'[data-testid="next-page"], .ant-pagination-next, .aux-pagination-next, .auxo-pagination-next, [aria-label="next page"]'
);
if (selectorMatch instanceof document.defaultView!.HTMLElement) {
return selectorMatch;
}
return Array.from(document.querySelectorAll("button, a, div, span")).find(
(element): element is HTMLElement =>
element instanceof document.defaultView!.HTMLElement &&
element.textContent?.trim() === "下一页"
) ?? null;
}
function isDisabled(element: HTMLElement): boolean {
return (
"disabled" in element &&
Boolean((element as HTMLButtonElement).disabled) ||
element.getAttribute("aria-disabled") === "true" ||
/disabled|is-disabled/.test(element.className)
);
}
function getCurrentPageSignature(document: Document): string {
return readCurrentPageRows(document)
.map((row) => row.authorId)
.join("|");
}
function waitForPageSignatureChange(
document: Document,
window: Window,
previousSignature: string
): Promise<boolean> {
return new Promise((resolve) => {
const startedAt = Date.now();
const check = () => {
const currentSignature = getCurrentPageSignature(document);
if (currentSignature && currentSignature !== previousSignature) {
resolve(true);
return;
}
if (Date.now() - startedAt >= 5000) {
resolve(false);
return;
}
window.setTimeout(check, 50);
};
check();
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (hasTextValue(value) || !hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (hasTextValue(incoming) || !hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
+148 -3
View File
@@ -1,3 +1,5 @@
import type { MarketExportScope, MarketExportTarget } from "./types";
export interface PluginToolbarHandlers {
onApplyFilter(): Promise<void> | void;
onApplySort(): Promise<void> | void;
@@ -6,6 +8,9 @@ export interface PluginToolbarHandlers {
export interface PluginToolbarDom {
exportButton: HTMLButtonElement;
exportCustomPagesInput: HTMLInputElement;
exportRangeSelect: HTMLSelectElement;
exportStatusText: HTMLElement;
filterApplyButton: HTMLButtonElement;
personalFilterInput: HTMLInputElement;
root: HTMLElement;
@@ -65,6 +70,25 @@ export function ensurePluginToolbar(
exportButton.dataset.pluginExport = "button";
exportButton.textContent = "导出CSV";
const exportRangeSelect = document.createElement("select");
exportRangeSelect.dataset.pluginExportRange = "select";
appendOption(exportRangeSelect, "current", "当前页");
appendOption(exportRangeSelect, "first-5", "前5页");
appendOption(exportRangeSelect, "first-10", "前10页");
appendOption(exportRangeSelect, "all", "全部");
appendOption(exportRangeSelect, "custom", "自定义");
exportRangeSelect.value = "first-5";
const exportCustomPagesInput = document.createElement("input");
exportCustomPagesInput.type = "number";
exportCustomPagesInput.min = "1";
exportCustomPagesInput.step = "1";
exportCustomPagesInput.hidden = true;
exportCustomPagesInput.dataset.pluginExportCustomPages = "input";
const exportStatusText = document.createElement("span");
exportStatusText.dataset.pluginExportStatus = "text";
root.append(
singleFilterInput,
personalFilterInput,
@@ -72,8 +96,11 @@ export function ensurePluginToolbar(
sortFieldSelect,
sortDirectionSelect,
sortApplyButton,
exportRangeSelect,
exportCustomPagesInput,
exportButton
);
root.append(exportStatusText);
document.body.prepend(root);
filterApplyButton.addEventListener("click", () => {
@@ -85,9 +112,27 @@ export function ensurePluginToolbar(
exportButton.addEventListener("click", () => {
void handlers.onExport();
});
exportRangeSelect.addEventListener("change", () => {
syncCustomPagesInputVisibility({
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
filterApplyButton,
personalFilterInput,
root,
singleFilterInput,
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
});
});
return {
const toolbarDom = {
exportButton,
exportCustomPagesInput,
exportRangeSelect,
exportStatusText,
filterApplyButton,
personalFilterInput,
root,
@@ -95,7 +140,10 @@ export function ensurePluginToolbar(
sortApplyButton,
sortDirectionSelect,
sortFieldSelect
};
} satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom);
return toolbarDom;
}
function appendOption(
@@ -110,10 +158,19 @@ function appendOption(
}
function readToolbarDom(root: HTMLElement): PluginToolbarDom {
return {
const toolbarDom = {
exportButton: root.querySelector(
'[data-plugin-export="button"]'
) as HTMLButtonElement,
exportCustomPagesInput: root.querySelector(
'[data-plugin-export-custom-pages="input"]'
) as HTMLInputElement,
exportRangeSelect: root.querySelector(
'[data-plugin-export-range="select"]'
) as HTMLSelectElement,
exportStatusText: root.querySelector(
'[data-plugin-export-status="text"]'
) as HTMLElement,
filterApplyButton: root.querySelector(
'[data-plugin-filter-apply="button"]'
) as HTMLButtonElement,
@@ -133,5 +190,93 @@ function readToolbarDom(root: HTMLElement): PluginToolbarDom {
sortFieldSelect: root.querySelector(
'[data-plugin-sort-field="select"]'
) as HTMLSelectElement
} satisfies PluginToolbarDom;
syncCustomPagesInputVisibility(toolbarDom);
return toolbarDom;
}
export function readToolbarExportTarget(
toolbar: PluginToolbarDom
): { error?: string; target?: MarketExportTarget } {
const scope = toolbar.exportRangeSelect.value as MarketExportScope;
if (scope === "all") {
return {
target: {
mode: "all"
}
};
}
if (scope === "current") {
return {
target: {
mode: "count",
pageCount: 1
}
};
}
if (scope === "first-5") {
return {
target: {
mode: "count",
pageCount: 5
}
};
}
if (scope === "first-10") {
return {
target: {
mode: "count",
pageCount: 10
}
};
}
const pageCount = Number(toolbar.exportCustomPagesInput.value);
if (!Number.isInteger(pageCount) || pageCount < 1) {
return {
error: "请输入有效页数"
};
}
return {
target: {
mode: "count",
pageCount
}
};
}
export function setToolbarBusyState(
toolbar: PluginToolbarDom,
isBusy: boolean
): void {
[
toolbar.exportButton,
toolbar.filterApplyButton,
toolbar.sortApplyButton,
toolbar.singleFilterInput,
toolbar.personalFilterInput,
toolbar.sortFieldSelect,
toolbar.sortDirectionSelect,
toolbar.exportRangeSelect,
toolbar.exportCustomPagesInput
].forEach((element) => {
element.disabled = isBusy;
});
}
export function setToolbarExportStatus(
toolbar: PluginToolbarDom,
text: string
): void {
toolbar.exportStatusText.textContent = text;
}
function syncCustomPagesInputVisibility(toolbar: PluginToolbarDom): void {
toolbar.exportCustomPagesInput.hidden =
toolbar.exportRangeSelect.value !== "custom";
}
+53 -6
View File
@@ -37,14 +37,24 @@ export function createMarketResultStore() {
upsertMarketRow(row: MarketRowSnapshot) {
const existingRecord = records.get(row.authorId);
if (existingRecord) {
existingRecord.authorName =
mergeStringValue(existingRecord.authorName, row.authorName) ??
existingRecord.authorName;
existingRecord.location = mergeStringValue(
existingRecord.location,
row.location
);
existingRecord.price21To60s = mergeStringValue(
existingRecord.price21To60s,
row.price21To60s
);
existingRecord.exportFields = mergeFieldMap(
existingRecord.exportFields,
row.exportFields
);
existingRecord.hasDirectRatesSource =
existingRecord.hasDirectRatesSource || row.hasDirectRatesSource;
if (row.rates) {
existingRecord.rates = {
...existingRecord.rates,
...row.rates
};
}
existingRecord.rates = mergeFieldMap(existingRecord.rates, row.rates);
return existingRecord;
}
@@ -72,3 +82,40 @@ export function createMarketResultStore() {
return nextRecord;
}
}
function mergeFieldMap<T extends Record<string, string | undefined>>(
current: T | undefined,
incoming: T | undefined
): T | undefined {
if (!current && !incoming) {
return undefined;
}
const merged = {
...(current ?? {})
} as Record<string, string | undefined>;
Object.entries(incoming ?? {}).forEach(([key, value]) => {
const currentValue = merged[key];
if (!hasTextValue(currentValue)) {
merged[key] = value;
}
});
return merged as T;
}
function mergeStringValue(
current: string | undefined,
incoming: string | undefined
): string | undefined {
if (!hasTextValue(current)) {
return incoming ?? current;
}
return current;
}
function hasTextValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
+12
View File
@@ -8,6 +8,7 @@ export type MarketRecordStatus = "idle" | "loading" | "success" | "failed" | "mi
export interface MarketRowSnapshot {
authorId: string;
authorName: string;
exportFields?: Record<string, string>;
hasDirectRatesSource?: boolean;
location?: string;
price21To60s?: string;
@@ -24,6 +25,17 @@ export interface MarketFilterState {
singleVideoAfterSearchRateMin?: number;
}
export type MarketExportScope = "current" | "first-5" | "first-10" | "all" | "custom";
export type MarketExportTarget =
| {
mode: "all";
}
| {
mode: "count";
pageCount: number;
};
export interface MarketSortState {
direction: "asc" | "desc";
field: keyof Required<AfterSearchRates>;
+5 -1
View File
@@ -1,8 +1,12 @@
{
"manifest_version": 3,
"name": "Star Chart Search Enhancer",
"version": "0.0.0",
"version": "0.2.0421.2",
"description": "Bootstraps the Xingtu creator market content script.",
"permissions": ["downloads"],
"background": {
"service_worker": "background/index.js"
},
"content_scripts": [
{
"matches": [