Agent 究竟如何使用你的浏览器:CDP(Chrome DevTools Protocol)指南

一句话说:CDP 是一个控制面,让外部程序可以访问 Chromium 的页面、网络栈、JavaScript 运行时、输入系统、调试器和性能分析工具。这是一篇面向初学者的 CDP 指南。

如果你打开 Chrome,按下 F12 或 opt + cmd + i,就会出现一个面板。它可以检查页面、查看每一个网络请求、执行 JavaScript、模拟手机,还能记录性能 trace。

这个面板其实是一个独立进程,正在和浏览器通信。它们之间使用的语言,就是 Chrome DevTools Protocol,也就是 CDP。

在 @browserbase,我们花了相当于数年工程时间来研究这个协议,试图驯服它的不完美之处,也因此学到了很多。这就是我们当年希望手边就有的一篇 CDP 讲解。

* 这篇文章包含交互式组件,可以在我们的博客上查看!

DevTools 背后的协议

CDP 内置在基于 Chromium 的浏览器里,包括 Chrome、Edge、Brave、Arc 和 Opera。DevTools、Lighthouse、Puppeteer、Playwright,以及很多浏览器 agent,都用它和浏览器通信。

只要你的代码在控制 Chromium,底层某处大概率就有 CDP。

一点历史

CDP 一开始是 Chrome DevTools 背后的管道,并不是一个通用自动化 API。

Chrome 在 2008 年发布时,它的检查器来自 WebKit,也就是当时 Chrome 和 Safari 共同使用的渲染引擎。DevTools 前端需要和被检查的页面分开运行,所以团队定义了一套连接前端和浏览器的线协议(wire protocol)。

┌─────────────────────┐       CDP messages       ┌─────────────────────┐
│ DevTools front end  │  ← commands / events →   │ Chromium browser    │
│ HTML, CSS, JS       │                          │ renderer, network,  │
│                     │                          │ runtime, input      │
└─────────────────────┘                          └─────────────────────┘

这套 WebKit 远程调试协议,后来成了 CDP 的前身。

随后,Google 在 2013 年把 Blink 从 WebKit 中拆分出来。Chrome 的协议也随之继续演进,并成为有文档、有版本的 Chrome DevTools Protocol。Safari 则继续使用 WebKit Inspector Protocol。

Headless Chrome 和 Puppeteer 在 2017 年出现。再后来,Playwright 把浏览器自动化推广到了 Chromium、Firefox 和 WebKit。

自己和 Chrome 通信

用启用远程调试的方式启动 Chrome。这里使用临时 profile,因为现代 Chrome 会限制对默认 profile 做远程调试。

/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
  --remote-debugging-port=9222 \
  --user-data-dir=/tmp/cdp-demo \
  about:blank

Chrome 现在会暴露本地发现端点(discovery endpoint):

# Run these in a different terminal
curl -s <http://localhost:9222/json/version> | jq .
curl -s <http://localhost:9222/json/list> | jq .

第一个端点描述浏览器本身,第二个列出可以 attach 的 target。一个 page target 会包含 webSocketDebuggerUrl,也就是建立 CDP 连接的端点。

使用 WebSocket,你可以写一个最小版 DevTools 客户端:

import WebSocket from "ws";

const targets = await fetch("<http://localhost:9222/json/list>")
  .then(response => response.json());

const page = targets.find(target => target.type === "page");
const socket = new WebSocket(page.webSocketDebuggerUrl);

let id = 0;
const send = (method, params = {}) => socket.send(JSON.stringify({
  id: ++id,
  method,
  params,
}));

socket.on("open", () => {
  send("Page.enable");
  send("Page.navigate", { url: "<https://example.com>" });
});

socket.on("message", data => {
  const message = JSON.parse(data);

  if (message.method === "Page.loadEventFired") {
    console.log("Page loaded");
    socket.close();
  }
});

它会连接到一个页面,启用生命周期事件,发起导航,然后等待 Chrome 通知页面已经加载完成。

CDP 的其他部分也类似,只是规模要大得多。

CDP 的语义

CDP 使用 JSON 消息,通常通过 WebSocket 或 pipe 传输。协议按域(domain)划分,比如 Page、Network、Runtime、Input、Target、Accessibility 和 Tracing。

每个域负责浏览器的一部分。

  • Page 处理导航。
  • Network 观察请求和响应。
  • Runtime 执行 JavaScript。
  • Target 发现页面、frame 和 worker。
  • Tracing 记录性能数据。

一条命令(command)会要求 Chrome 做某件事:

{"id": 1, "method": "Page.navigate", "params": {"url": "<https://example.com>"}}

Chrome 会返回带有同一个 ID 的响应(response)和结果:

{"id": 1, "result": {"frameId": "...", "loaderId": "..."}}

ID 让客户端可以同时发出多条命令,并把每个响应匹配回对应的请求(request)。

事件(event)则反向流动。只要浏览器状态发生变化,Chrome 就会发出事件:

{"method": "Network.requestWillBeSent", "params": {}}
{"method": "Page.frameNavigated", "params": {}}
{"method": "Runtime.executionContextCreated", "params": {}}

事件没有 request ID。要读取它们,需要先启用某个域,然后消费它的事件流(event stream)。

CDP 命令是问题或指令,事件是浏览器给出的回应。

Session(会话)和 target(目标)

可以把 CDP 连接想成一棵树。

这棵树的根是浏览器。从根开始,你会发现各个 target,并 attach 到它们。

target 是 Chrome 可以检查或控制的东西:一个页面、一个 out-of-process iframe、一个 service worker、一个 shared worker,或者一个扩展页面。

attach 到 target 会创建一个 session。通过这个 session 发出的命令会作用在对应 target 上,它的事件也会通过同一个 session 返回。

Chrome 使用 Site Isolation 把不同站点的页面隔离到不同的 renderer process 里。当一个页面嵌入跨站 iframe 时,Chrome 可能会把这个 frame 提升成 out-of-process iframe。它看起来仍然嵌在页面里,但 CDP 可能会把它暴露成另一个 target,并拥有自己的 session。

Worker 会给这棵树增加更多分支。dedicated worker 在页面主线程之外运行 JavaScript,而 shared worker 和 service worker 可以比单个页面活得更久。CDP 会把这些环境表示成 target 或 execution context,这样客户端在执行代码或监听事件时,就能定位到正确的地方。

Navigation 会再次改变这棵树。加载新 document 会销毁 frame 旧的 JavaScript execution context,并创建一个新的。即使 tab 和 frame ID 看起来没变,之前 context 里的任何 object ID 或引用都会失效。

一个 CDP 客户端必须同时追踪结构和生命周期:有哪些 target,attach 了哪些 session,哪些 execution context 属于哪个 frame,以及它们什么时候消失。

一个现代页面看起来可能只是一个 tab,但底下其实是一组不断变化的进程和 JavaScript 环境。这就是为什么在它之上构建系统这么难。

你可以为每个 target 打开一条独立的 CDP 连接,但这很快就会变得痛苦。flat mode 会把它们复用到同一条连接上,并用 sessionId 标识每个 session。要让它正常工作,客户端必须注意到 target,及时 attach,把消息路由到正确的 session,并在 target 消失时恢复。

CDP 暴露了浏览器的哪些部分?

CDP 是从页面外部和浏览器通信的。这个位置让它能看到更多东西:renderer、网络栈、输入系统、worker、storage 和 debugger。

网络流量

Network domain 可以观察 document request、script、image、API call、redirect、cache hit 和 service worker 活动。

一个 request 通常会经过 Network.requestWillBeSentNetwork.responseReceivedNetwork.loadingFinished。这些事件共享 ID,所以客户端可以重建完整生命周期,然后调用 Network.getResponseBody

一次可见的页面加载,背后可能包含多个 frame、redirect、preflight request 和数百个资源。

JavaScript 执行

Runtime domain 会执行代码,并报告 console call、exception 和 execution context。execution context 很重要,因为代码并不是运行在一个永久不变的环境里。frame 和 worker 都有自己的 context,但 navigation 会销毁它们。

浏览器输入

Input domain 会通过浏览器的输入系统发送鼠标、键盘、触摸和拖拽事件。

这比在某个元素上调用 JavaScript 方法更底层。但它仍然不会决定该点哪个元素、等待布局稳定,或者检查有没有 overlay 挡住目标。

浏览器级输入也不会让自动化和真人变得不可区分。它只是让客户端控制 Chrome 的输入路径,仅此而已。

Trace 和诊断

Performance.getMetricsTracing.start、console event、exception event、截图和 screencast,暴露的都是 DevTools 里也能看到的东西。

一次失败的浏览器运行,会自带解释:可能是某个 request 卡住了,某个 frame 导航了,某个 exception 触发了,也可能是 trace 显示时间花在了哪里。

CDP 把浏览器变成了一个完全可观测的系统。

为什么直接执行原始 CDP 很痛苦

线协议格式很简单,难的是管理状态。

一个原始 CDP 客户端必须先启用 domain,才能收到有用的事件;必须在 navigation 创建和销毁 target、execution context 时持续追踪它们;还要协调多个 domain 中互相竞速的响应和事件。

参考文档解释了 message 的形状,但跳过了真实客户端需要的生命周期细节。一个 session 能活多久?什么会杀掉它?navigation 会保留它吗?另一个 WebSocket 能复用它的 sessionId 吗?

一个好的客户端必须在 target、session 和 context 出现、消失时持续追踪它们,而且通常此时命令仍然在飞。我们只能自己发现这些规则:强制触发 navigation 和 crash,记录每一个事件,然后看哪些东西还能活下来。

Chrome 本身也一直在变,而且变得很多。stable、experimental 和 deprecated method 会同时存在于协议里。

还有策略问题:CDP 可以把一个鼠标事件发送到某个坐标,但它不能判断哪个元素值得点击,也不能判断结果算不算成功。

这就是 Playwright 和 Puppeteer 这类库存在的原因。它们负责等待、目标选择、生命周期状态和交互策略;当你需要更底层能力时,它们也会暴露 CDP session。

协议的边界

CDP 属于 Chromium,而且只属于 Chromium。Firefox 和 WebKit 暴露的是不同的调试接口(Firefox 已在 2024 年弃用 CDP)。WebDriver 提供的是跨浏览器自动化标准。WebDriver BiDi 则在这个模型上增加了双向 event。CDP 能更深入 Chromium,是因为 Chromium 掌握协议的两端。

调试连接也强大到足以变得危险。它可以检查页面、执行任意 JS、读取网络流量,并控制已登录的 session。

tip-of-tree protocol 会随着 Chromium 一起变化。生产客户端需要选择自己支持哪些版本,并处理 method 缺失的情况。

浏览器的控制面

人用英语和人交流,程序用协议和其他程序交流,agent 用 CDP 和浏览器交流。

你可以通过同一个协议导航页面、观察请求、执行 JS、检查 worker、发送输入、收集 trace,并跟踪每一个 target。我建议使用更高层的工具,让这些能力更容易使用。

你大概不应该把每一次交互都建立在原始 CDP 之上。但理解它如何工作、哪些抽象才合适,能帮助你决定该如何让 agent 访问 Web。

→ Kyle
--------------------------------

如果你想阅读带交互式组件的版本,可以看看这篇博客