release: 0.2.0421.2
This commit is contained in:
@@ -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();
|
||||
@@ -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(
|
||||
|
||||
@@ -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] ?? ""
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user