200 lines
6.4 KiB
JavaScript
200 lines
6.4 KiB
JavaScript
/**
|
||
* 极简 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;
|
||
}
|