/** * 零依赖 Chrome DevTools Protocol 驱动 —— `puppeteer-core` 缺失时的走查替代。 * * ── 为什么需要它 ── * 本仓所有真机走查都 import `puppeteer-core`(在 `~/.dsh/profiles/desktop/node_modules` 下, * 属于工具层而非仓库依赖)。该模块在本机已消失,导致 `t28-fill-container.mjs` 等直接 * `ERR_MODULE_NOT_FOUND`。Chrome 本身在(`C:\Program Files\Google\Chrome\Application\chrome.exe`), * 而 Node 24 自带全局 `WebSocket` 与 `fetch`,所以"驱动浏览器"这件事**不需要任何依赖**。 * * ── 它是什么、不是什么 ── * 只实现走查真正用到的那一小撮 puppeteer API(`launch`/`newPage`/`goto`/`evaluate`/ * `evaluateOnNewDocument`/`waitForSelector`/`waitForFunction`/`$`/`click`/`on`/`close`), * 用 CDP 原生命令实现。**不是** puppeteer 的通用替代:没有选择器语法、没有元素句柄、 * 没有网络拦截。要那些能力请恢复 `puppeteer-core`。 * * ── 用法 ── * const browser = await cdpLaunch({ executablePath: CHROME, width: 1680, height: 950 }) * const page = await browser.newPage() * await page.goto(url, { timeout: 60000 }) * * 注意:Chrome 启动需要放宽沙箱(受限模式下 crashpad 会 `OpenProcess: 拒绝访问 (0x5)` 自杀)。 */ import { spawn } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) /** 与 CDP 的一条 WebSocket 连接:发命令、按 id 收响应、把事件分发给订阅者。 */ class Connection { constructor(ws) { this.ws = ws this.seq = 0 this.pending = new Map() this.handlers = new Set() ws.addEventListener('message', (event) => { let message = null try { message = JSON.parse(event.data) } catch { return } if (message.id !== undefined) { const slot = this.pending.get(message.id) if (!slot) return this.pending.delete(message.id) if (message.error) slot.reject(new Error(`${slot.method}: ${message.error.message}`)) else slot.resolve(message.result) return } for (const handler of this.handlers) handler(message) }) } send(method, params = {}, sessionId) { const id = ++this.seq const payload = { id, method, params } if (sessionId) payload.sessionId = sessionId this.ws.send(JSON.stringify(payload)) return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject, method }) }) } onMessage(handler) { this.handlers.add(handler) return () => this.handlers.delete(handler) } } class Page { constructor(conn, sessionId) { this.conn = conn this.sessionId = sessionId this.listeners = { pageerror: [], console: [] } this.mouse = { move: async (x, y) => this._dispatchMouse('mouseMoved', x, y), click: async (x, y, options = {}) => { const button = options.button || 'left' await this._dispatchMouse('mouseMoved', x, y) await this._dispatchMouse('mousePressed', x, y, { button, clickCount: options.clickCount || 1 }) await this._dispatchMouse('mouseReleased', x, y, { button, clickCount: options.clickCount || 1 }) }, } this._onMessage = (message) => { if (message.sessionId !== this.sessionId) return if (message.method === 'Runtime.exceptionThrown') { const details = message.params.exceptionDetails || {} const text = (details.exception && (details.exception.description || details.exception.value)) || details.text || 'unknown' for (const cb of this.listeners.pageerror) cb(text) } if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') { const text = (message.params.args || []) .map((a) => (a.value !== undefined ? a.value : a.description || '')) .join(' ') // 形状对齐 puppeteer(`m.type()` / `m.text()`):走查脚本按那套写,不该为驱动改姿势。 for (const cb of this.listeners.console) cb({ type: () => 'error', text: () => text }) } } } async _init() { this.conn.onMessage(this._onMessage) await this.conn.send('Page.enable', {}, this.sessionId) await this.conn.send('Runtime.enable', {}, this.sessionId) } /** * 截图(返回 base64;`format` 默认 png)。 * * 票 47 的"真实帧差"证据用它:同一视角隔一小段时间截两帧, * **字节不同 ⇒ 画面确实在变**(PNG 对同一输入是确定性编码)。 * 关掉特效时两帧应当**字节相同**(画面静止)—— 这一对断言才排除了"其实没在动"。 */ async screenshot(options = {}) { const result = await this.conn.send('Page.captureScreenshot', { format: options.format || 'png', ...(options.clip ? { clip: options.clip } : {}), }, this.sessionId) return result && result.data ? result.data : '' } async _dispatchMouse(type, x, y, extra = {}) { return this.conn.send('Input.dispatchMouseEvent', { type, x: Number(x), y: Number(y), button: extra.button || 'none', buttons: extra.buttons === undefined ? (type === 'mousePressed' ? 1 : 0) : extra.buttons, clickCount: extra.clickCount || 0, pointerType: 'mouse', }, this.sessionId) } on(event, cb) { if (this.listeners[event]) this.listeners[event].push(cb) return this } /** 在**页面上下文**里跑一段函数;参数会被 JSON 序列化后代入。 */ async evaluate(fn, ...args) { const expression = `(${fn.toString()})(${args.map((a) => JSON.stringify(a)).join(',')})` const { result, exceptionDetails } = await this.conn.send( 'Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, this.sessionId, ) if (exceptionDetails) { const text = (exceptionDetails.exception && exceptionDetails.exception.description) || exceptionDetails.text throw new Error(`evaluate 失败:${text}`) } return result.value } /** 注册"每次新文档都先跑一遍"的脚本(注入 mock 场景等)。 */ async evaluateOnNewDocument(fn, ...args) { const source = `(${fn.toString()})(${args.map((a) => JSON.stringify(a)).join(',')})` await this.conn.send('Page.addScriptToEvaluateOnNewDocument', { source }, this.sessionId) } /** 导航并等到 `readyState === 'complete'`(puppeteer 的 networkidle2 由调用方补 sleep 近似)。 */ async goto(url, options = {}) { const timeout = options.timeout || 60000 await this.conn.send('Page.navigate', { url }, this.sessionId) const deadline = Date.now() + timeout for (;;) { try { const ready = await this.evaluate(() => document.readyState) if (ready === 'complete') return } catch { /* 导航中途上下文会被销毁,重试即可 */ } if (Date.now() > deadline) throw new Error(`goto 超时:${url}`) await sleep(100) } } async waitForSelector(selector, options = {}) { const timeout = options.timeout || 20000 const deadline = Date.now() + timeout const hidden = options.hidden === true for (;;) { const state = await this.evaluate((sel) => { const el = document.querySelector(sel) if (!el) return 'missing' const rect = el.getBoundingClientRect() const styled = window.getComputedStyle(el) const invisible = styled.display === 'none' || styled.visibility === 'hidden' || rect.width + rect.height === 0 return invisible ? 'hidden' : 'visible' }, selector).catch(() => 'missing') if (hidden ? state !== 'visible' : state === 'visible') return if (Date.now() > deadline) throw new Error(`waitForSelector 超时:${selector}`) await sleep(80) } } async waitForFunction(fn, options = {}, ...args) { const timeout = options.timeout || 20000 const deadline = Date.now() + timeout for (;;) { const ok = await this.evaluate(fn, ...args).catch(() => false) if (ok) return if (Date.now() > deadline) throw new Error('waitForFunction 超时') await sleep(80) } } /** 与 puppeteer 一致:命中回一个真值对象,未命中回 null。 */ async $(selector) { const found = await this.evaluate((sel) => !!document.querySelector(sel), selector).catch(() => false) return found ? { selector } : null } async click(selector) { const clicked = await this.evaluate((sel) => { const el = document.querySelector(sel) if (!el) return false el.click() return true }, selector) if (!clicked) throw new Error(`click 找不到元素:${selector}`) } async close() { this.conn.onMessage(() => {}) this.conn.handlers.delete(this._onMessage) await this.conn.send('Target.closeTarget', { targetId: this.targetId }).catch(() => {}) } } class Browser { constructor(conn, info) { this.conn = conn this.child = info.child this.userDataDir = info.userDataDir this.versionInfo = info.version } async version() { return this.versionInfo.Browser || 'chrome' } async newPage() { const { targetId } = await this.conn.send('Target.createTarget', { url: 'about:blank' }) const { sessionId } = await this.conn.send('Target.attachToTarget', { targetId, flatten: true }) const page = new Page(this.conn, sessionId) page.targetId = targetId await page._init() return page } async close() { try { this.conn.ws.close() } catch { /* 已经断了就算了 */ } try { this.child.kill() } catch { /* 同上 */ } await sleep(200) try { rmSync(this.userDataDir, { recursive: true, force: true }) } catch { /* 临时目录清不掉不影响结论 */ } } } /** * 启动 Chrome 并连上它的浏览器级 WebSocket。 * `--remote-debugging-port=0` 让系统分配端口,Chrome 会把真实端口写进 * `/DevToolsActivePort`,从而不会和其它 Chrome 抢 9222。 */ export async function cdpLaunch(options = {}) { const width = options.width || 1680 const height = options.height || 950 const userDataDir = mkdtempSync(join(tmpdir(), 'cdp-cockpit-')) const args = [ '--headless=new', '--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, `--window-size=${width},${height}`, '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage', ...(options.args || []), 'about:blank', ] // stdio 用 ignore:受限模式下"捕获子进程输出"会 EPERM,而 Chrome 的日志对结论无用。 const child = spawn(options.executablePath, args, { stdio: 'ignore' }) const portFile = join(userDataDir, 'DevToolsActivePort') let raw = '' for (let i = 0; i < 200; i += 1) { try { raw = readFileSync(portFile, 'utf8') if (raw.trim()) break } catch { /* 还没写出来 */ } await sleep(100) } const port = Number(String(raw).split('\n')[0].trim()) if (!port) { child.kill() throw new Error('Chrome 没写出 DevToolsActivePort —— 大概率是沙箱把它的 IPC 拦掉了') } const version = await fetch(`http://127.0.0.1:${port}/json/version`).then((r) => r.json()) const ws = new WebSocket(version.webSocketDebuggerUrl) await new Promise((resolve, reject) => { ws.addEventListener('open', resolve, { once: true }) ws.addEventListener('error', () => reject(new Error('连不上 Chrome 的调试 WebSocket')), { once: true }) }) return new Browser(new Connection(ws), { child, userDataDir, version }) }