460 lines
12 KiB
TypeScript

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<typeof fetch>) => {
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<string, unknown>,
...args: Parameters<XMLHttpRequest["open"]>
) {
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<string, unknown>,
...args: Parameters<XMLHttpRequest["send"]>
) {
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<T extends History["pushState"] | History["replaceState"]>(
originalMethod: T
) {
return ((...args: Parameters<T>) => {
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();