跳到主要内容

🦾 [浏览器自动化] 了解 CDP:browser-use 背后的隐藏功臣

· 阅读需 18 分钟
卤代烃
微信公众号@卤代烃实验室

cdp-hero.jpg

Chrome DevTools Protocol (CDP) 是 Chromium 浏览器调试工具的核心通信协议:它基于 JSON 格式,可以通过 WebSocket 实现客户端与浏览器内核之间的双向实时交互。

基于 CDP 的开源产品有许多,其中最有名的应该是 Chrome Devtools FrontendPuppeteerPlaywright 了。

Chrome Devtools Frontend 就是前端开发者天天按 F12 唤起的调试面板,而 Puppeteer 和 Playwright 是非常有名的浏览器自动化操作工具,如今的 agent browser tool(例如 playwright-mcpbrowser-usechrome-devtools-mcp)也是基于它们构建的。可以说每个开发者都在使用 CDP,但因它的定位比较底层,大家常常又意识不到他的存在。

Chrome Devtools FrontendPuppeteer

CDP 有自己的官方文档站和相关的 Github 地址,秉承了 Google 开源项目的一贯风格,简洁,克制,就是没什么可读性。文档和项目都是根据源码变动自动生成的,所以只能用来做 API 的查询,这也导致如果没有相关的领域知识,直接阅读相关文档或 deepwiki 是拿不到什么有建设性内容的。

上面的那些吐槽,也是我写本文的原因,互联网上介绍 CDP 的博文太少了,也没什么系统性的架构分析,所以不如我自己来写丰富一下 AI 的语料库(bushi)。


协议格式

首先 CDP 协议是一个典型的 CS 架构,这里我们拿 Chrome Devtools 为例:

  • Chrome Devtools:就是 Client,用来做调试数据的 UI 展示,方便用户阅读
  • CDP:就是连接 Client-Server 的 Protocol,定义 API 的各种格式和细节
  • Chromium/Chrome:就是 Server,用来产生各种数据

CDP 协议的格式基于 JSON-RPC 2.0 做了一些轻量的定制。首先是去掉了 JSON 结构体中的 "jsonrpc": "2.0" 这种每次都要发送的冗余信息。可以看下面几个 CDP 的实际例子:


首先是常规的 JSON RFC Request/Response,细节不用关注,就看整体的格式:

Target.setDiscoverTargets

// Client -> Chromium
{
"id":2
"method": "Target.setDiscoverTargets",
"params": {"discover":true,"filter":[{}]},
}

// Chromium -> Client
{
"id": 2,
"result": {}
}

可以看到这就是一个经典的 JSON RFC 调用,用 id 串起 request 和 response 的关系,然后 request 中通过 methodparams 把请求方法和请求参数带上;response 通过 result 带上响应结果。


关于 JSON RFC Notification(Event)的例子如下,定义也很清晰,就不展开了:

Target.targetCreated

{
"method": "Target.targetCreated",
"params": {
"targetInfo": {
"targetId": "12345",
"type": "browser",
"title": "",
"url": "",
"attached": true,
"canAccessOpener": false
}
}
}

众所周知,JSON RFC 只是一套协议标准,它其实可以跑在任意的支持双向通讯的通信协议上。目前 CDP 的主流方案还是跑在 WebSocket 上(也可以用本地 pipe 的方式连接,但用的人少),所以用户可以借助任意的 Websocket 开源库搭建出合适的产品。


Domain 整体分类

如果直接看 CDP 的文档,会发现它的目录侧边栏只有一列,那就是 Domains,然后下面有一堆看起来很熟悉的名词:DOM,CSS,Console,Debugger 等等...

CDP DomainsChrome Devtools Frontend

其实这些 Domain 都可以和 Chrome Devtools 联系起来的。所以我们可以从 Chrome Devtools 的各种功能反推 CDP 中的各种 Domain 作用:

  • Elements:会用到 DOM,CSS 等 domain 的 API
  • Console:会用到 Log,Runtime 等 domain 的 API
  • Network:会用到 Network 等 domain 的 API
  • Performance:会用到 Performance,Emulation 等 domain 的 API
  • ......

那么到这里就有一个比较直观的认识了。我们再返回看 CDP 本身,CDP 其实可以分为两大类,然后下面有不同的 Domain 分类:

  • Browser Protocol:浏览器相关的协议,之下的 Domain 都是平台相关的,比如说 Page,DOM,CSS,Network,都是和浏览器功能相关
  • JavaScript Protocol:JS 引擎相关的协议,主要围绕 JS 引擎功能本身,比如说 Runtime,Debugger,HeapProfiler 等,都是比较纯粹的 JS 语言调试功能

deepwiki 给出的 CDP Domains 分类


了解了 Domain 的整体分类,下一步我们探索一下 Domain 内部的运行流程。


Domain 内部通信

理解某个 Domain 的运行流程,还是老办法,对照着 Chrome Devtools Frontend 的某个调试面板反推,这样理解起来是最快的。


这里我们拿 Console 面板为例,这个基本上是 Web 开发者日常使用频率最高的功能了。

从 UI 面板上看有很多功能,有筛选,分类,分组等各种高级功能,但绝大部分的功能都是前端上的实现,联系到背后和 Console 相关的 CDP 协议,其实主要就 5 条:


举一个真实的例子,我们在 Console 面板先发起一个不合规的网络请求,然后再 console.log 一句文字:

  • 首先每个页面打开 Devtools 的时候,会默认调用 Log.enable 启动 log 监听
  • 手动 fetch 一个不合规的地址时,浏览器会先做安全检查,通过 Log.entryAdded 提示不合规
  • 发起一个真实的网络请求,失败后会通过 Runtime.exceptionThrown 提示 Failed to fetch
  • 最后手动调用 console API,CDP 会发一个 Runtime.consoleAPICalled 的调用 log event

chrome-devtools-log

把上面的的例子抽象一下,其实所有的 Domain 的调用流程基本都是一样的:

  • 通过 Domain.enable 开启某个 Domain 的调试功能
  • 开启功能后,就可以在这个阶段发送相关的 methods 调用,也可以监听 Chrome 发来的各种 event
  • 通过 Domain.disable 关闭这个 Domain 的调试功能

domain

附加提示

部分 Domain 并没有 enable/disable 这两个 methods,具体情况具体分析


Target: 特殊的 Domain

上面介绍了 Domain 的分类和 Domain 内部运转的整体流程,但是有一个 Domain 非常的特殊,那就是 Target

type 分类

Target 是一个较为抽象的概述,它指的是浏览器中的可交互实体

  • 我创建了一个浏览器,那么它本身就是一个 type 为「browser」的 Target
  • 浏览器里有一个标签页,那么这个页面本身就是一个 type 为「page」的 Target
  • 这个页面里要做一些耗时计算创建了一个 Worker,那么它就是一个 type 为「worker」的 Target

目前从 chromium 源码上可以看出,Target 的 type 有以下几种:

  • browser,browser_ui,webview
  • tab,page,iframe
  • worker,shared_worker,service_worker
  • worklet,shared_storage_worklet,auction_worklet
  • assistive_technology,other

从上面的 target type 可以看出,Target 整体是属于一个 scope 比较大的实体,基本上是以进程/线程作为隔离单位分割的,每个 type 下可能包含多个 CDP domain,比如说 page 下就有 Runtime,Network,Storage,Log 等 domain,其他类型同理。


交互流程

Target 的内部分类清晰了,那么还剩重要的一环:如何和 Target 做交互


CDP 这里的逻辑是,先发个请求,向 Target 发起交互申请,然后 Target 就会给你一个 sessionId,之后的交互就在这个 session 信道上进行。CDP 在这里也对 JSON-RPC 2.0 做了一个轻量定制,它们把 sessionId 放在了 JSON 的最外层,和 id 同一个层级:

{
method: "SystemInfo.getInfo",
id: 9,
sessionId: "62584FD718EC0B52B47067AE1F922DF1"
}

我举个实际的例子看 session 的交互流程。

假设我们想从 browser Target 上获取一些系统消息,先假设我们事先已经知道了 browser 的 targetId,那么一个完整的 session 通信如下:

注意

这里为了聚焦 session 的核心交互逻辑,下面的 CDP message 删除了不必要的信息

  1. Client 通过 Target.attachToTarget API 向 browser 发起会话请求,拿到 sessionId
// Client —> Chromium
{
"method": "Target.attachToTarget",
"params": {
"targetId": "31a082d2-ba00-4d8f-b807-9d63522a6112", // browser targetId
"flatten": true // 使用 flatten 模式,后续将会把 sessionId 和 id 放在同一层级
},
"id": 8
}

// Chromium —> Client
{
"id":8,
"result": {
"sessionId": "62584FD718EC0B52B47067AE1F922DF1" // 拿到这次对话的 sessionId
}
}
  1. Client 带上上一步给的 sessionId,发送一条获取系统信息的 CDP 调用并获取到相关消息
// Client —> Chromium
{
"method": "SystemInfo.getInfo", // 获取系统信息的方法
"id": 9,
"sessionId": "62584FD718EC0B52B47067AE1F922DF1" // sessionId 和 id 同级,在最外层
}

// Chromium —> Client
{
"id": 9,
"sessionId": "62584FD718EC0B52B47067AE1F922DF1"
"result": { /* ... */ },
}
  1. 不想在这个 session 上聊了,调用 Target.detachFromTarget 直接断开连接,自此这个会话就算销毁了
// Client —> Chromium
{
"method": "Target.detachFromTarget",
"id": 11,
"sessionId":"62584FD718EC0B52B47067AE1F922DF1"
}

// Chromium —> Client
{
"id": 11,
"result": {}
}

上面的流程可以用下面的图表示:

session

当然涉及 Target 生命周期的相关 Methods 和 Event 还有很多,一一讲解也不现实,感兴趣的同学可以自己探索。


一对多

除了上述的特性,Target 还有一个特点,那就是一个 Target 允许多个 session 连接。这意味着可以有多个 Client 去控制同一个 Target。这在现实中也是很常见的。比如说对于一个网页实体,它既可以被 Chrome Devtools(Client1)调试,也可以同时被 puppeteer(Client2)连接做自动化控制。当然这也会带来了一些资源访问的并发问题,在实际应用场景上需要万分的小心。


综合案例

综上所述,我们可以看一个实际的例子,把上面的内容都囊括起来。

下面的案例我是用 puppeteer 创建了一个 url 为 about:blank 的新网页时,底层的 CDP 调用流程。调用的源文件可以访问右边的超链接下载:create_about_blank_page.har,har 文件可用 Chrome Devtools Network 导入查看:

cdp-chrome-devtools-network


首先是最开始的 Target 创建流程。注意下图红框和红线里的内容:

cdp-target

  • 首先调用 Target.createTarget 创建一个 page(在调用 createTarget 时,会同步生成一个 tab Target,我们可以忽略这个行为,不影响后续理解)
  • page Target 创建好后,在响应 Target.createTarget methods 的同时,还会发送一个 Target.targetCreated 的 event,里面有这个 page Target 的详细 meta info,例如 targetId,url,title 等
  • page Target 的 meta info 变动时,会下发 Target.targetInfoChanged event 同步信息变化
  • page Target 下发一个 Target.attachedToTarget 的 event,告知 client 这次连接的 sessionId,这样后续的一些 domain 操作就可以带上 sessionId 保证信道了

Target 创建好后,就要开启这个 page 下的各个 Domain 了:

cdp-enable

  • Network.enable:开启 Network Domain 的监听,比如说各种网络请求的 request/response 的细节
  • Page.enable:开启 Page Domain 的监听,比如说 navigation 行为的操纵
  • Runtime.enable:开启 Runtime Domain 的监听,比如说要在 page 里 evaluate 一段注入函数
  • Performance.enable:开启 Performance Domain 的监听,比如说一些 metrics 信息
  • Log.enable:开启 log 相关信息的监听,比如说各种 console.log 信息

开启相关 Domain 后,就可以监听这个 page Target 的相关 event 或者主动触发一些方法,如下图所示:

cdp-method-event

  • 我们主动执行 Page.getNavigationHistory methods,获取当前页面的 history 导航记录
  • 我们监听到 Runtime.consoleAPICalled event 的触发,拿到了一些 console 信息

相关的细节还有很多就不一一列举了,感兴趣的同学可以看上面的 har 源文件,我相信全部看完后就会对 CDP 有个清晰的认知了。


编码建议

就目前(2025.12)而言,Code Agent 和 DeepResearch 等常见的 AI 辅助编程工具在 CDP 领域上表现并不是很好,主要的原因有 3 点:

  • 预训练语料少:从前文可知,CDP 因为协议过于底层,所以相关的使用案例和代码非常少,模型预训练时语料很少,导致幻觉还是比较严重的
  • 文档质量一般:CDP 文档写的太简洁了,基本就是根据出入参自动生成的类型文档,只能用来查询核实一下,想从中获得完整的概念,对 AI 和人来说还是太难了
  • API 动态迭代:CDP 虽然开源出来了,但其本质还是一个为 Chromium 服务的私有协议,其 latest 版本一直在动态迭代中,所以这种动态变化也影响了 AI 的发挥

综合以上原因,我的一个策略是「小步快跑,随时验证」。方案就是对于自己想实现的功能,先让 AI 出一个大致的方案,但是不要直接在自己的迭代的项目里直接写,而是先生成一个可以快速验证相关功能的最小 DEMO,然后亲自去验证这个方案是否符合预期。

「亲自验证 DEMO 可行性」 这一步非常重要,因为 AI 直出的 CDP 解决方案可靠性并不高,不像 AI -> UI 有较高的容错率和置信度,只有在 DEMO 上验证成功的方案才有迁移到正式项目的价值。


另一个解决方案,就是 puppeteer 等优秀项目上吸取经验。puppeteer 底层也是调用 CDP,而且它迭代了十余年,对一些常见案例已经沉淀了一套成熟的解决方案。通过学习它内部的 CDP 调用流程,可以学习到很多文档未曾描述的运用场景。下一篇 Blog,我们就分析一下 puppeteer 的源码架构,让我们在调用过程中更加得心应手。