#!/usr/bin/env node /** * cdp_eval.js —— 极简 CDP 工具: 连上**已经在跑的** Chrome(需带 --remote-debugging-port), * 在指定标签页里求值一段 JS 并把结果按 JSON 打印回来。 * * 零依赖: 只用 Node >= 22 内置的全局 WebSocket / fetch。 * * 用法: * node tools/cdp_eval.js --port 9222 --match 1ms.run --file /tmp/expr.js * echo 'document.title' | node tools/cdp_eval.js --port 9222 --match 1ms.run * node tools/cdp_eval.js --list # 列出所有标签页 * * 选项: * --port N 调试端口 (默认 9222) * --match SUBSTR 选 url 含该子串的标签页 (默认第一个 page) * --file PATH 从文件读表达式 (等价 awaitPromise) * --reload 求值前先 reload 一次并等待 load * --timeout MS 默认 30000 */ const fs = require('fs'); function arg(name, def) { const i = process.argv.indexOf('--' + name); if (i < 0) return def; const v = process.argv[i + 1]; return (v === undefined || v.startsWith('--')) ? true : v; } async function listTargets(port) { const r = await fetch(`http://127.0.0.1:${port}/json/list`); return r.json(); } class Cdp { constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.events = []; } static async connect(url) { const ws = new WebSocket(url); await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); const c = new Cdp(ws); ws.onmessage = ev => { const m = JSON.parse(ev.data); if (m.id && c.waits.has(m.id)) { const { res, rej } = c.waits.get(m.id); c.waits.delete(m.id); m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); } else if (m.method) { c.events.push(m); } }; return c; } send(method, params = {}) { const id = ++this.id; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((res, rej) => this.waits.set(id, { res, rej })); } close() { try { this.ws.close(); } catch (e) {} } } const sleep = ms => new Promise(r => setTimeout(r, ms)); (async () => { const port = parseInt(arg('port', '9222'), 10); const timeout = parseInt(arg('timeout', '30000'), 10); if (arg('list', false)) { const t = await listTargets(port); for (const x of t) console.log(`${x.type}\t${(x.url || '').slice(0, 120)}\t${x.id}`); return; } const match = arg('match', null); const targets = (await listTargets(port)).filter(t => t.type === 'page' || t.type === 'iframe'); let target = null; if (match) target = targets.find(t => (t.url || '').includes(match)); if (!target) target = targets[0]; if (!target) throw new Error('没有可用标签页'); const cdp = await Cdp.connect(target.webSocketDebuggerUrl); await cdp.send('Runtime.enable'); // --init-script FILE: 在**该文档的任何脚本之前**注入 (配合 --reload 使用)。 // 用途: 挂钩子必须在 tdc.js 之类的目标脚本执行前就位, 否则模块系统已经初始化完/被缓存。 const initFile = arg('init-script', null); if (initFile && initFile !== true) { await cdp.send('Page.enable'); const src = fs.readFileSync(initFile, 'utf8'); const added = await cdp.send('Page.addScriptToEvaluateOnNewDocument', { source: src }); console.error(`[init-script] installed ${initFile} (${added.identifier})`); } if (arg('reload', false)) { await cdp.send('Page.enable'); await cdp.send('Page.reload', { ignoreCache: true }); await sleep(parseInt(arg('settle', '2500'), 10)); } let expr; const file = arg('file', null); if (file && file !== true) expr = fs.readFileSync(file, 'utf8'); else { expr = fs.readFileSync(0, 'utf8'); // stdin } if (!expr.trim()) throw new Error('表达式为空'); const res = await cdp.send('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true, timeout, }); const out = { target: { url: target.url, title: target.title, id: target.id }, ok: !res.exceptionDetails, result: res.result ? res.result.value : null, exception: res.exceptionDetails ? (res.exceptionDetails.exception || {}).description : null, }; console.log(JSON.stringify(out, null, 2)); cdp.close(); })().catch(e => { console.error('CDP_ERROR: ' + e.message); process.exit(1); });