235 lines
6.1 KiB
TypeScript

import {
createMarketController,
type CreateMarketControllerOptions
} from "./market/index";
import { renderMarketAuthGate } from "./market/auth-gate";
import {
isAuthResponseMessage,
type AuthStateValue
} from "../shared/auth-messages";
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
) => { dispose?: () => void; ready: Promise<void> };
document?: Document;
sendAuthMessage?: (message: unknown) => Promise<unknown>;
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;
const sendAuthMessage =
options.sendAuthMessage ?? createRuntimeMessageSender();
if (!isMarketPage(currentWindow.location.href)) {
return null;
}
installMarketPageBridge(currentDocument);
const authState = await readAuthState(sendAuthMessage);
if (!authState?.isAuthenticated) {
await waitForBodyReady(currentDocument, currentWindow);
renderMarketAuthGate(
currentDocument,
currentWindow,
isExpiredAuthState(authState) ? "登录已过期,请重新登录" : undefined
);
return {
ready: Promise.resolve()
};
}
await waitForBodyReady(currentDocument, currentWindow);
return controllerFactory({
document: currentDocument,
onCsvReady: (csv: string, filename?: string) => {
if (filename) {
downloadCsv(currentDocument, currentWindow, csv, filename);
return;
}
if (requestCsvDownload(csv)) {
return;
}
downloadCsv(currentDocument, currentWindow, csv, filename);
},
window: currentWindow
});
}
async function readAuthState(
sendMessage: (message: unknown) => Promise<unknown>
): Promise<AuthStateValue | null> {
const response = await sendMessage({ type: "auth:get-state" });
if (!isAuthResponseMessage(response) || !response.ok || response.type !== "auth:state") {
return null;
}
return response.value;
}
function isMarketPage(url: string): boolean {
const parsedUrl = new URL(url);
const isXingtuHost =
parsedUrl.hostname === "xingtu.cn" || parsedUrl.hostname.endsWith(".xingtu.cn");
return isXingtuHost && parsedUrl.pathname.startsWith("/ad/creator/market");
}
function bootstrapContentScript() {
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
if (!runtime || typeof window === "undefined" || typeof document === "undefined") {
return;
}
const marker = "__starChartSearchEnhancerContentController";
const scopedWindow = window as Window & {
[marker]?: boolean | { dispose?: () => void; ready: Promise<void> } | null;
};
if (scopedWindow[marker]) {
return;
}
scopedWindow[marker] = true;
void bootContentScript().then((controller) => {
scopedWindow[marker] = controller;
});
}
bootstrapContentScript();
function requestCsvDownload(csv: string, filename?: 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: filename ?? `star-chart-search-enhancer-${formatTimestampForFilename()}.csv`,
type: DOWNLOAD_MARKET_CSV_MESSAGE
});
return true;
}
function createRuntimeMessageSender(): (message: unknown) => Promise<unknown> {
return async (message: unknown) => {
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
if (typeof runtime?.sendMessage !== "function") {
return null;
}
return runtime.sendMessage(message);
};
}
async function waitForBodyReady(document: Document, currentWindow: Window): Promise<void> {
if (document.body) {
return;
}
await new Promise<void>((resolve) => {
const handleReady = () => {
if (document.body) {
document.removeEventListener("DOMContentLoaded", handleReady);
resolve();
}
};
document.addEventListener("DOMContentLoaded", handleReady);
currentWindow.setTimeout(handleReady, 0);
});
}
function downloadCsv(
document: Document,
window: Window,
csv: string,
filename?: 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 = filename ?? `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 isExpiredAuthState(authState: AuthStateValue | null): boolean {
const lastError = authState?.lastError;
return (
typeof lastError === "string" &&
(/token/i.test(lastError) || lastError.includes("过期"))
);
}
function installMarketPageBridge(document: Document) {
if (
document.documentElement.querySelector(
'[data-sces-market-bridge="script"]'
)
) {
return;
}
const script = document.createElement("script");
script.dataset.scesMarketBridge = "script";
const runtime = (
globalThis as typeof globalThis & {
chrome?: { runtime?: ChromeRuntimeLike };
}
).chrome?.runtime;
const bridgeUrl = runtime?.getURL?.("content/market-page-bridge.js");
if (bridgeUrl) {
script.src = bridgeUrl;
} else {
script.textContent = "";
}
(document.head ?? document.documentElement).appendChild(script);
}