import { getStarIdFromUrl } from "../shared/get-star-id"; import { CANDIDATE_ANALYSIS_MESSAGE_TYPE, CANDIDATE_REQUEST_MESSAGE_TYPE, EXTENSION_MESSAGE_SOURCE, HOOK_READY_MESSAGE_TYPE, RESULT_MESSAGE_TYPE } from "../shared/message-types"; import { createRouteKey } from "../shared/route-key"; import type { AfterSearchRateResult, ExtractAfterSearchRatesResult } from "../shared/result-types"; import { extractAfterSearchRates as defaultExtractAfterSearchRates } from "../shared/extract-after-search-rates"; import { looksLikeCandidateRequest, shouldInspectResponse } from "./network-interceptor"; const PAGE_HOOK_MARKER = "__starChartSearchEnhancerPageHookInstalled"; const XHR_REQUEST_URL = "__starChartSearchEnhancerRequestUrl"; const XHR_REQUEST_METHOD = "__starChartSearchEnhancerRequestMethod"; interface HookWindow extends Window { [PAGE_HOOK_MARKER]?: boolean; [key: string]: unknown; } interface InstallPageHookOptions { extractAfterSearchRates?: ( payload: unknown ) => ExtractAfterSearchRatesResult; now?: () => number; postMessage?: (message: unknown, targetOrigin: string) => void; timeoutMs?: number; window?: HookWindow; } interface RouteContext { navigationSeq: number; pageStarId: string | null; routeKey: string; } export function installPageHook(options: InstallPageHookOptions = {}) { const hookWindow = options.window ?? ((globalThis as typeof globalThis & { window?: HookWindow }).window as | HookWindow | undefined); if (!hookWindow) { return { alreadyInstalled: false }; } if (hookWindow[PAGE_HOOK_MARKER]) { return { alreadyInstalled: true }; } hookWindow[PAGE_HOOK_MARKER] = true; const extractAfterSearchRates = options.extractAfterSearchRates ?? defaultExtractAfterSearchRates; const now = options.now ?? Date.now; const postMessage = options.postMessage ?? hookWindow.postMessage.bind(hookWindow); const timeoutMs = options.timeoutMs ?? 10_000; let routeContext = createRouteContext(hookWindow.location.href, 1); let candidateRequestCount = 0; let lastCandidateRequestUrl: string | undefined; let timeoutHandle = hookWindow.setTimeout(emitTimeoutResult, timeoutMs); const originalFetch = hookWindow.fetch.bind(hookWindow); const originalPushState = hookWindow.history.pushState.bind(hookWindow.history); const originalReplaceState = hookWindow.history.replaceState.bind( hookWindow.history ); const originalXhrOpen = hookWindow.XMLHttpRequest.prototype.open; const originalXhrSend = hookWindow.XMLHttpRequest.prototype.send; postMessage( { payload: { pageStarId: routeContext.pageStarId, routeKey: routeContext.routeKey }, source: EXTENSION_MESSAGE_SOURCE, type: HOOK_READY_MESSAGE_TYPE }, "*" ); hookWindow.fetch = (async (...args: Parameters) => { const response = await originalFetch(...args); const requestUrl = resolveRequestUrl(args[0], hookWindow.location.href); const requestMethod = resolveRequestMethod(args[0], args[1]); void inspectFetchResponse({ extractAfterSearchRates, now, postMessage, requestMethod, requestUrl, response, routeContext, window: hookWindow }); return response; }) as typeof fetch; hookWindow.history.pushState = wrapHistoryMethod(originalPushState); hookWindow.history.replaceState = wrapHistoryMethod(originalReplaceState); hookWindow.addEventListener("popstate", handleNavigation); hookWindow.XMLHttpRequest.prototype.open = function open( this: XMLHttpRequest & Record, ...args: Parameters ) { this[XHR_REQUEST_METHOD] = args[0]; this[XHR_REQUEST_URL] = resolveAbsoluteUrl(String(args[1]), hookWindow.location.href); return originalXhrOpen.apply(this, args); }; hookWindow.XMLHttpRequest.prototype.send = function send( this: XMLHttpRequest & Record, ...args: Parameters ) { this.addEventListener("loadend", () => { const requestUrl = typeof this[XHR_REQUEST_URL] === "string" ? this[XHR_REQUEST_URL] : ""; if (!requestUrl || !looksLikeCandidateRequest(requestUrl)) { return; } const responseText = typeof this.responseText === "string" ? this.responseText : ""; if (!responseText) { return; } if ( !shouldInspectResponse({ contentType: this.getResponseHeader?.("content-type") ?? null, text: responseText, url: requestUrl }) ) { return; } void inspectPayloadText({ extractAfterSearchRates, now, postMessage, requestMethod: typeof this[XHR_REQUEST_METHOD] === "string" ? this[XHR_REQUEST_METHOD] : "GET", requestUrl, status: this.status, text: responseText }); }); return originalXhrSend.apply(this, args); }; return { alreadyInstalled: false }; function wrapHistoryMethod( originalMethod: T ) { return ((...args: Parameters) => { const result = originalMethod(...args); handleNavigation(); return result; }) as T; } function handleNavigation() { routeContext = createRouteContext( hookWindow.location.href, routeContext.navigationSeq + 1 ); candidateRequestCount = 0; lastCandidateRequestUrl = undefined; hookWindow.clearTimeout(timeoutHandle); timeoutHandle = hookWindow.setTimeout(emitTimeoutResult, timeoutMs); } function emitTimeoutResult() { const payload: AfterSearchRateResult = { capturedAt: now(), pageStarId: routeContext.pageStarId, pageUrl: hookWindow.location.href, rawPathHints: [], reason: "Timed out waiting for after-search-rate capture", routeKey: routeContext.routeKey, stage: "timeout", success: false, matchedRequestUrl: lastCandidateRequestUrl }; postMessageResult(postMessage, payload); } async function inspectFetchResponse(input: { extractAfterSearchRates: ( payload: unknown ) => ExtractAfterSearchRatesResult; now: () => number; postMessage: (message: unknown, targetOrigin: string) => void; requestMethod: string; requestUrl: string; response: Response; routeContext: RouteContext; window: HookWindow; }) { try { const clone = input.response.clone(); const text = await clone.text(); if ( !shouldInspectResponse({ contentType: clone.headers.get("content-type"), text, url: input.requestUrl }) ) { return; } await inspectPayloadText({ extractAfterSearchRates: input.extractAfterSearchRates, now: input.now, postMessage: input.postMessage, requestMethod: input.requestMethod, requestUrl: input.requestUrl, status: input.response.status, text }); } catch { return; } } async function inspectPayloadText(input: { extractAfterSearchRates: ( payload: unknown ) => ExtractAfterSearchRatesResult; now: () => number; postMessage: (message: unknown, targetOrigin: string) => void; requestMethod: string; requestUrl: string; status: number; text: string; }) { try { const parsedPayload = JSON.parse(input.text); const extractionResult = input.extractAfterSearchRates(parsedPayload); if (!extractionResult.matched && !looksLikeCandidateRequest(input.requestUrl)) { return; } candidateRequestCount += 1; lastCandidateRequestUrl = input.requestUrl; postMessage( { payload: { extractorLevel: extractionResult.extractorLevel, matched: extractionResult.matched, reason: extractionResult.reason, requestUrl: input.requestUrl, routeKey: routeContext.routeKey, signalEntries: collectSignalEntries(parsedPayload), success: extractionResult.success, topLevelKeys: getTopLevelKeys(parsedPayload) }, source: EXTENSION_MESSAGE_SOURCE, type: CANDIDATE_ANALYSIS_MESSAGE_TYPE }, "*" ); postMessage( { payload: { requestMethod: input.requestMethod, requestUrl: input.requestUrl, routeKey: routeContext.routeKey, status: input.status }, source: EXTENSION_MESSAGE_SOURCE, type: CANDIDATE_REQUEST_MESSAGE_TYPE }, "*" ); if (!extractionResult.success) { return; } hookWindow.clearTimeout(timeoutHandle); postMessageResult(input.postMessage, { capturedAt: input.now(), pageStarId: routeContext.pageStarId, pageUrl: hookWindow.location.href, rawPathHints: extractionResult.rawPathHints, routeKey: routeContext.routeKey, stage: "captured", success: true, matchedRequestUrl: input.requestUrl, rates: extractionResult.rates }); } catch { return; } } } function createRouteContext(url: string, navigationSeq: number): RouteContext { const parsedUrl = new URL(url); const pageStarId = getStarIdFromUrl(parsedUrl.href); return { navigationSeq, pageStarId, routeKey: createRouteKey({ navigationSeq, pageStarId, pathname: parsedUrl.pathname }) }; } function postMessageResult( postMessage: (message: unknown, targetOrigin: string) => void, payload: AfterSearchRateResult ) { postMessage( { payload, source: EXTENSION_MESSAGE_SOURCE, type: RESULT_MESSAGE_TYPE }, "*" ); } function resolveRequestMethod( input: RequestInfo | URL, init?: RequestInit ): string { if (input instanceof Request) { return input.method; } return init?.method ?? "GET"; } function resolveRequestUrl(input: RequestInfo | URL, baseUrl: string): string { if (input instanceof Request) { return input.url; } if (input instanceof URL) { return input.href; } return resolveAbsoluteUrl(String(input), baseUrl); } function resolveAbsoluteUrl(url: string, baseUrl: string): string { return new URL(url, baseUrl).href; } function getTopLevelKeys(payload: unknown): string[] { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return []; } return Object.keys(payload).slice(0, 8); } function collectSignalEntries(payload: unknown): string[] { const entries: string[] = []; const signalPattern = /(search|view|rate|ase|seed|看后搜)/i; visitPayload(payload, "$", (value, path, key) => { if (!key || !signalPattern.test(key) || entries.length >= 12) { return; } const summary = summarizeValue(value); entries.push(`${path}=${summary}`); }); return entries; } function summarizeValue(value: unknown): string { if (typeof value === "string") { return JSON.stringify(value); } if (typeof value === "number" || typeof value === "boolean") { return String(value); } if (Array.isArray(value)) { return `[array:${value.length}]`; } if (value && typeof value === "object") { return `[object:${Object.keys(value).slice(0, 5).join(",")}]`; } return String(value); } function visitPayload( value: unknown, path: string, visitor: (value: unknown, path: string, key?: string) => void, key?: string ): void { visitor(value, path, key); if (Array.isArray(value)) { value.forEach((entry, index) => { visitPayload(entry, `${path}[${index}]`, visitor); }); return; } if (!value || typeof value !== "object") { return; } Object.entries(value).forEach(([entryKey, entryValue]) => { visitPayload(entryValue, `${path}.${entryKey}`, visitor, entryKey); }); } function bootstrapPageHook() { const hookWindow = ( globalThis as typeof globalThis & { window?: HookWindow } ).window; if (!hookWindow) { return; } if (!hookWindow.location.hostname.endsWith("xingtu.cn")) { return; } installPageHook({ window: hookWindow }); } bootstrapPageHook();