不装 Puppeteer:用 Node 内置 WebSocket 直接驱动 Chrome DevTools 协议

不装 Puppeteer:用 Node 内置 WebSocket 直接驱动 Chrome DevTools 协议

234
234
2026-10-04 / 0 评论 / 26 阅读 / 5194 字 / 正在检测是否收录...
这篇是开发工具笔记,和 DNS 无关。站内的加密 DNS 服务在dns.23-4.cn。

要做浏览器自动化,第一反应通常是装 Puppeteer 或 Playwright。但如果你的需求只是「打开页面、等它渲染完、取几个值、点几下」,那 Node 24 已经内置了需要的一切——全局 WebSocket 和全局 fetch,加起来不到 120 行就能驱动 Chrome DevTools 协议(CDP)。

这篇是我自己写回归脚本时踩出来的完整做法,含几个会让人白白调试半小时的坑。

一、先启动一个带调试端口的 Chrome

chrome --headless=new --remote-debugging-port=9357 \
       --user-data-dir=/tmp/cdp-profile \
       --no-first-run --disable-gpu \
       --window-size=1440,900 about:blank

关键点:--remote-debugging-port 是唯一入口,之后所有操作都通过 CDP 走 WebSocket。--user-data-dir 单独给一个目录,避免污染你日常的浏览器配置。

二、开一个新标签页,拿到它的 WebSocket 地址

const CDP_PORT = 9357;

async function newTab() {
  // 注意:必须是 PUT,GET 在较新的 Chrome 上已不再创建标签页
  const r = await fetch(`http://127.0.0.1:${CDP_PORT}/json/new?about:blank`, {
    method: 'PUT',
  });
  return r.json();               // { id, webSocketDebuggerUrl, ... }
}

三、一个最小可用的 CDP 会话封装

CDP 是「发一条带 id 的消息,等一条同 id 的回复」,另外还会不停推事件。把它包成 Promise 就够了:

async function attach(wsUrl) {
  const ws = new WebSocket(wsUrl);
  await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });

  let id = 0;
  const pending = new Map();
  const events = [];

  ws.onmessage = (ev) => {
    const m = JSON.parse(ev.data);
    if (m.id && pending.has(m.id)) {          // 这是某条命令的回复
      const { res, rej } = pending.get(m.id);
      pending.delete(m.id);
      m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result);
    } else if (m.method) {                    // 这是事件推送
      events.push(m);
    }
  };

  const send = (method, params = {}) => new Promise((res, rej) => {
    const i = ++id;
    pending.set(i, { res, rej });
    ws.send(JSON.stringify({ id: i, method, params }));
    setTimeout(() => {                        // 一定要有超时,否则一条丢失的回复会永久挂住
      if (pending.has(i)) { pending.delete(i); rej(new Error('timeout ' + method)); }
    }, 40000);
  });

  const evaluate = async (expression) => {
    const r = await send('Runtime.evaluate', {
      expression, returnByValue: true, awaitPromise: true,
    });
    if (r.exceptionDetails) {
      throw new Error('eval: ' + (r.exceptionDetails.exception?.description
                               || r.exceptionDetails.text));
    }
    return r.result.value;                    // returnByValue 时结果在这里
  };

  return { send, evaluate, events, close: () => ws.close() };
}

四、打开页面并「确认它真的渲染了」

const s = await attach((await newTab()).webSocketDebuggerUrl);

await s.send('Page.enable');
await s.send('Runtime.enable');
await s.send('Log.enable');                   // 收集 console 错误
await s.send('Emulation.setDeviceMetricsOverride', {
  width: 390, height: 844, deviceScaleFactor: 1, mobile: true,
});

await s.send('Page.navigate', { url: 'https://example.com' });

// 等 load
for (let i = 0; i < 120; i++) {
  await new Promise(r => setTimeout(r, 250));
  if (await s.evaluate('document.readyState').catch(() => null) === 'complete') break;
}

到这里就有一个必须做的动作:

// 先断言页面确实渲染了,再断言别的东西
const rendered = await s.evaluate(`({
  title: document.title,
  bodyLen: document.body ? document.body.innerHTML.length : 0,
  items: document.querySelectorAll('.some-selector').length,
})`);
if (!rendered.bodyLen) throw new Error('页面根本没渲染,后面的断言全是假的');

为什么必须先做这一步:如果导航其实没成功(比如 URL 写错了),document 还是 about:blank。此时 document.querySelectorAll(...).length 会老老实实返回 0,而你如果断言的是「不应该有某个元素」,它会通过——测试全绿,功能全坏。这种「空断言」是最难发现的假阳性。

五、五个会浪费你半小时的坑

1. requestAnimationFrame 在无头模式下不会按你预期触发。
没有合成器时,rAF 可能永远不触发,或者只触发一两次。用 setTimeout 代替所有等待:

const sleep = (ms) => new Promise(r => setTimeout(r, ms));
await sleep(2500);   // 让 JS 跑完

2. 不要把你的变量命名成 URL。
URL 是 Node 的全局 WHATWG URL 类。写 const URL = 'https://...' 之后,某些库内部 new URL(...) 会抛错或被静默吞掉,导航「看起来成功了」但实际去了 about:blank——于是所有断言真空通过。这是个非常隐蔽的坑,换个名字就好。

3. 每个视口开一个新标签页。
Emulation.setDeviceMetricsOverride 改尺寸之后,页面里的媒体查询、懒加载、以及已经算好的布局不会自动全部重算。做「桌面 / 移动」两套验证时,开新标签页重新加载,别在同一页上反复改尺寸。

4. 用 process.exitCode,不要 process.exit()。
process.exit() 会立刻切断进程,WebSocket 里的响应还没读完,输出可能被截断。设置 process.exitCode = 1 然后自然退出。

5. 收集 console 错误。
Runtime.exceptionThrown 给的是未捕获异常,Log.entryAdded 给的是 console 输出。两个都开,才能看到「页面加载时的报错」:

const errors = s.events
  .filter(e => e.method === 'Runtime.exceptionThrown'
            || (e.method === 'Log.entryAdded' && e.params.entry.level === 'error'))
  .map(e => e.method === 'Runtime.exceptionThrown'
    ? 'EXC: ' + (e.params.exceptionDetails?.exception?.description || '')
    : 'LOG: ' + e.params.entry.text);

六、和 Puppeteer 比,值得吗

原生 CDPPuppeteer / Playwright
依赖0,Node 内置需要下载浏览器 + 一堆依赖
体积一个脚本文件几百 MB
上手成本需要理解协议API 友好
能力上限协议本身,最全封装了常用部分,偶尔要 evaluate 绕回 CDP
适合场景固定环境的回归脚本、CI 里的定点验证复杂交互、爬虫、端到端测试

如果你只是要跑几个固定页面的定点验证(比如「这个链接是不是 200」「这个元素在移动端有没有被遮挡」),原生 CDP 更省事,而且不会因为浏览器版本升级而整体失效——你依赖的是协议,不是封装层。


相关阅读

0个赞
取消