加一套抓真实请求做预设对比的工具,并把三份预设的机制差异记进 README。

This commit is contained in:
2026-10-22 20:07:33 +08:00
parent 18f034bc2d
commit ed09e5c831
21 changed files with 1735 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
/**
* 极简 CDP 客户端(零依赖,靠 Node 22+ 自带的全局 WebSocket)。
*
* 只做三件事:拉起一个 Chromium/Edge、连上它的 browser target、在页面里求值。
* 够用来驱动酒馆前端把「真实发送」那一下点出去。
*/
import { spawn } from "node:child_process";
import { existsSync, mkdtempSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const BROWSER_CANDIDATES = [
"C:/Program Files/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
];
export function findBrowser(explicit) {
if (explicit) return explicit;
for (const p of BROWSER_CANDIDATES) {
if (existsSync(p)) return p;
}
throw new Error("找不到 chrome.exe / msedge.exe,可用 --browser 指定");
}
export async function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
export async function waitFor(fn, { timeoutMs = 60000, intervalMs = 300, label = "条件" } = {}) {
const start = Date.now();
let lastErr;
while (Date.now() - start < timeoutMs) {
try {
const value = await fn();
if (value) return value;
} catch (err) {
lastErr = err;
}
await sleep(intervalMs);
}
throw new Error(`等待「${label}」超时(${timeoutMs}ms)${lastErr ? `:${lastErr.message}` : ""}`);
}
export class CdpSession {
#ws;
#nextId = 1;
#pending = new Map();
#listeners = new Map();
constructor(ws, label) {
this.label = label;
this.#ws = ws;
ws.addEventListener("message", (event) => {
let msg;
try {
msg = JSON.parse(typeof event.data === "string" ? event.data : String(event.data));
} catch {
return;
}
if (msg.id !== undefined && this.#pending.has(msg.id)) {
const { resolve, reject } = this.#pending.get(msg.id);
this.#pending.delete(msg.id);
if (msg.error) reject(new Error(`${msg.error.message} (${msg.error.code})`));
else resolve(msg.result);
return;
}
if (msg.method) {
for (const cb of this.#listeners.get(msg.method) ?? []) cb(msg.params);
}
});
}
static async connect(wsUrl, label = "cdp") {
const ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => {
ws.addEventListener("open", () => resolve(), { once: true });
ws.addEventListener("error", (e) => reject(new Error(`ws 连接失败:${e.message ?? ""}`)), { once: true });
});
return new CdpSession(ws, label);
}
send(method, params = {}, sessionId) {
const id = this.#nextId++;
const payload = { id, method, params };
if (sessionId) payload.sessionId = sessionId;
return new Promise((resolve, reject) => {
this.#pending.set(id, { resolve, reject });
try {
this.#ws.send(JSON.stringify(payload));
} catch (err) {
this.#pending.delete(id);
reject(err);
}
});
}
on(method, cb) {
const list = this.#listeners.get(method) ?? [];
list.push(cb);
this.#listeners.set(method, list);
}
close() {
try {
this.#ws.close();
} catch {
/* ignore */
}
}
}
/** 起浏览器(默认 headless),返回进程 + 调试端口。 */
export async function launchBrowser({ exe, port = 9222, headless = true, userDataDir } = {}) {
const executable = findBrowser(exe);
const profileDir = userDataDir ?? mkdtempSync(path.join(os.tmpdir(), "wa-cdp-"));
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-extensions",
"--disable-background-networking",
"--disable-sync",
"--disable-gpu",
"--mute-audio",
"about:blank",
];
if (headless) args.unshift("--headless=new");
const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"] });
child.stdout.on("data", () => {});
child.stderr.on("data", () => {});
const version = await waitFor(
async () => (await fetch(`http://127.0.0.1:${port}/json/version`)).ok && (await fetch(`http://127.0.0.1:${port}/json/version`)).json(),
{ timeoutMs: 30000, label: `浏览器调试端口 ${port}` },
);
return { child, port, wsUrl: version.webSocketDebuggerUrl, exe: executable, profileDir };
}
/** 在浏览器里开一个页面并返回可求值的会话。 */
export async function openPage(browser, url) {
const cdp = await CdpSession.connect(browser.wsUrl, "browser");
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
await cdp.send("Page.enable", {}, sessionId);
await cdp.send("Runtime.enable", {}, sessionId);
if (url) await cdp.send("Page.navigate", { url }, sessionId);
return { cdp, targetId, sessionId };
}
/** 页面内求值(支持 await)。非 byValue 友好的值请自行 JSON 化。 */
export async function evaluate(page, expression, { awaitPromise = true } = {}) {
const result = await page.cdp.send(
"Runtime.evaluate",
{
expression,
awaitPromise,
returnByValue: true,
userGesture: true,
allowUnsafeEvalBlockedByCSP: true,
},
page.sessionId,
);
if (result.exceptionDetails) {
const detail = result.exceptionDetails;
const text =
detail.exception?.description ??
detail.exception?.value ??
detail.text ??
"页面内求值失败";
throw new Error(String(text));
}
return result.result?.value;
}
/** 把页面的 console / 异常收进 page.logs,排查前端卡住用。 */
export function captureConsole(page) {
page.logs = [];
const push = (type, text) => {
page.logs.push({ type, text: String(text).slice(0, 800) });
if (page.logs.length > 300) page.logs.shift();
};
page.cdp.on("Runtime.consoleAPICalled", (params) => {
const text = (params.args || []).map((a) => a.value ?? a.description ?? a.type).join(" ");
push(params.type, text);
});
page.cdp.on("Runtime.exceptionThrown", (params) => {
const d = params.exceptionDetails || {};
push("exception", (d.exception && d.exception.description) || d.text || "");
});
page.cdp.on("Log.entryAdded", (params) => {
const e = params.entry || {};
push("log:" + e.level, e.text || "");
});
page.cdp.send("Log.enable", {}, page.sessionId).catch(() => {});
return page.logs;
}