Files
writing-agent/tools/st-request-diff/cdp.mjs
T

200 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 极简 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;
}