460 lines
12 KiB
TypeScript
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();
|