<!-- Source: https://deepseek-browser-use.io.nexus/27 -->

# 源码教程：Chrome 走 CDP 与 CDP 客户端

本机 Google Chrome 一律通过 **CDP（Chrome DevTools Protocol）** 驱动：服务自己拉进程、开远程调试端口、用协议接上。为此工程里多了一个独立的协议客户端，包名 `nexus.io.chromium.cdp`，它不依赖工程内其它包，可以整包拿走单独用。

前文 [05](<https://deepseek-browser-use.io.nexus/05>) 讲的是「用哪个浏览器、用哪份 profile」的表现，本文讲这条启动路径本身怎么实现、为什么这么实现、以及踩过哪些坑。

[[toc]]

## 一、为什么本机 Chrome 走 CDP

| 理由 | 说明 |
| --- | --- |
| 不再要求「先关掉你正在用的 Chrome」 | CDP 这条路用的是**托管 profile**，不是用户日常那份 `User Data`，两者互不抢目录。而 Playwright 的持久化上下文用管道调试，Chrome 从 136 起**拒绝在默认用户数据目录上开启远程调试**，那条路要用真实 profile 就必须先关掉浏览器并放开机器策略 |
| 视口语义天然正确 | 这条路不套视口模拟，页面尺寸一直跟着真实窗口走 —— 正好是 `browser.viewport=window` 想要的「所见即所得」，不必再靠「不给默认视口」绕（见 [06](<https://deepseek-browser-use.io.nexus/06>)） |
| 创建期参数可以自己补 | 持久化上下文有一批**创建时才能给**的选项（下载目录、权限、UA、HTTP 认证）。走 CDP 之后这些没有现成入口，改为用协议命令补，补不上的会在回执里写明原因而不是静默退化 —— 清单见本文第五节 |

内置 Chromium 仍走 Playwright 的持久化上下文：它不需要「本机安装」这个概念，开发态还没有内嵌可执行文件（交给 Playwright 自己解析），换成自己拉进程只会多一份要维护的启动逻辑。Edge 走 CDP 更早，原因在沙箱（见 [05](<https://deepseek-browser-use.io.nexus/05>) 第六节）。

## 二、启动链路

```text
PlaywrightService.launchChromiumFamily
  → 解析出固定的 cdp 托管 profile 目录（ChromeBrowser.cdpManagedProfileDir）
  → PlaywrightService.cdpArgs：拼启动参数，含 --remote-debugging-port=<配置端口或 0>
  → ChromeLauncher.launch：ProcessBuilder 拉进程，边读 stderr 边等调试地址
       DevTools listening on ws://127.0.0.1:<port>/...
  → 把 ws 地址换成 HTTP 端点（http://127.0.0.1:<端口>）
  → Playwright 的 connectOverCDP 接上（全量命令仍走这条已有的执行链）
  → CdpLaunchSupport.apply：用独立 CDP 客户端再连一次，补创建期参数并回报浏览器身份
```

两条连接并存不是重复：**前者负责全部 122 个命令**，后者负责「持久化上下文原本在创建时给好、而 CDP 没有对应入口」的那几项，并且把浏览器亲口报的版本写进回执，让 `mode:cdp` 这句自述有第三方证据。

`ChromeLauncher` 有几处刻意的实现选择：

- **从 stderr 读调试地址，而不是读 `DevToolsActivePort` 文件**：那个文件只在**非默认**用户数据目录下才会被写出来。托管 profile 恰好是非默认目录，但用户 profile 不是，两条路统一走 stderr，少一个分支。
- **区分「拒绝」与「提前退出」**：Chrome 明确拒绝远程调试时会打印 `DevTools remote debugging requires a non-default data directory`，此时它**不会自己退出**（会变成一个普通浏览器继续跑），所以服务必须自己把它收掉；而 profile 被占用时新进程会立刻退出（见第八节）。
- **不读输出会把浏览器堵死**：读线程必须一直把 stdout/stderr 读干净。

## 三、独立客户端 `nexus.io.chromium.cdp`

| 类型 | 职责 |
| --- | --- |
| `CdpConnection` | 一条 WebSocket 连接：命令—回执配对、事件路由、失败分型、关闭 |
| `CdpSession` | 连到某个 target 的一条逻辑会话；按会话发命令、订阅事件（跨域 iframe 的基础） |
| `CdpBrowser` | 浏览器级门面：版本、target 与页签、会话挂载、下载目录、权限、自动挂载 |
| `CdpPage` | 页面级门面：导航与等待、求值、截图、UA 覆盖、原生弹窗处理 |
| `CdpTarget` | 一个 target 的概要（类型、地址、标题、所属浏览器上下文） |
| `CdpException` | 失败分型：协议级拒绝 / 传输断 / **发出去没等到回执** |
| `CdpEventListener` | 事件回调接口 |

**为什么自己写而不是引一个协议库。** 现成的选择分两类，都不合适：一类是多年没有发版的社区客户端；另一类是按浏览器里程碑逐个发版的协议绑定 —— 用它等于把「升级浏览器」和「升级依赖」绑在一起，而这个工程本来就自己内嵌浏览器、自己控制版本。这个客户端只需要「连上任意版本的 Chromium 系浏览器、收发协议消息」，自己实现反而更小：**只用 JDK 自带的 WebSocket 客户端加上工程已有的 JSON 工具**，没有新增依赖。

**这个包不 import 工程内任何其它包**，所以它可以被整包复制到别的项目里。它的自测也刻意不借用工程的启动工具：`src/test/java/nexus/io/chromium/cdp/CdpClientTest.java` 自己探测浏览器可执行文件、自己挑空闲端口、自己拉进程、自己等端口就绪，覆盖连接、导航、求值、异步求值、截图、会话隔离、target 列表、失败分型与连接状态自述。

## 四、三个关键机制

### 4.1 命令与回执靠自增 id 配对

每条命令分配一个自增 `id` 放进消息，回执按 `id` 唤醒等待方。**调用方可以从任意线程发命令**；事件回调则在读 WebSocket 的那条线程上执行，所以回调里不要做阻塞的协议同步调用（那会把后续所有事件堵在后面）。

### 4.2 事件按 `sessionId` 路由

浏览器级命令不带 `sessionId`，页签 / 子 frame 级的命令与事件带。扁平模式（挂载 target 时带 `flatten`）下不再需要把消息层层套进 `sendMessageToTarget`，所有命令与事件都走同一条 WebSocket，靠 `sessionId` 区分。

**为什么必须按 target 分会话**：跨域 iframe 在 Chromium 里是**独立渲染进程**，它是自己的 target、有自己的执行上下文。在顶层会话里求值，`document` 永远是顶层文档的；要进 iframe 内部求值，只能用属于那个 frame 的会话，或在该会话里指定执行上下文。

### 4.3 失败要分得清

| 形态 | 判据 | 调用方该怎么办 |
| --- | --- | --- |
| 协议级拒绝 | 回执里带 `error`（方法不存在、参数不合法、权限名不认识） | 改参数或改方法，重试无用 |
| 传输断了 | 连接已关闭 / WebSocket 出错 | 重建连接 |
| **发出去没等到回执** | 超时 | **不能当成没生效** —— 尤其点击、提交类命令 |

第三类是最容易被写错的一类：超时只说明「没等到回执」，动作可能已经落到页面上。把它统一当失败然后自动重发，就是重复提交的来源。

## 五、创建期参数怎么补

`CdpLaunchSupport` 在接上浏览器之后补两件事，并把结果写进 `start` 回执的 `data.browser.cdp.notes`：

| 补什么 | 协议命令 | 不补的后果 |
| --- | --- | --- |
| 下载目录 | `Browser.setDownloadBehavior` | 页面触发的下载落到浏览器自己的默认下载目录，而调用方还在约定目录里数文件 —— 表现是「点了下载却什么都没发生」 |
| 权限 | `Browser.grantPermissions` | 剪贴板类操作直接抛权限错误，站点侧只表现为「按钮点了没反应」 |

**权限名不是 Playwright 那一套。** Playwright 的 `setPermissions` 收 `clipboard-read` / `clipboard-write`，而协议的 `PermissionType` 里叫 `clipboardReadWrite` / `clipboardSanitizedWrite`。照抄 Playwright 的名字会得到 `Unknown permission type` —— 而这类错误一旦被兜住，表现就是剪贴板按钮静默失效，在站点侧完全看不出是权限问题。自测里专门留了一条反向用例把这个坑钉住。

两条都是**尽力而为**：失败只记警告并写进 `notes`，**绝不让 `start` 失败**。用「少一个下载目录设置」换「整个任务起不来」是明显的亏本买卖；但也不能不吭声，所以失败原因必须出现在回执里。

另外一个协议特性值得单独提：**打开自动挂载并让新 target 停在调试器前时，必须有人放行**（`Runtime.runIfWaitingForDebugger`），否则新页签永远白屏。这个客户端在打开开关的同时就注册了「收到挂载事件立刻放行」的监听器，不把「忘了放行」留给调用方。

## 六、回执里多了什么

```json
{"data":{"browser":{"type":"chrome","engine":"chromium","mode":"cdp","userProfile":false,
  "profileDir":"<托管 profile 绝对路径>","headless":false,
  "cdp":{"product":"Chrome/<版本>","protocolVersion":"<协议版本>",
    "notes":["下载目录:<路径>(Browser.setDownloadBehavior)","已授权限:clipboardReadWrite / clipboardSanitizedWrite / notifications"]}}}}
```

`mode` 是服务自己的说法，`cdp.product` 是**浏览器亲口报的**（`Browser.getVersion` 的 `product`）。排查版本相关问题时先看它。`notes` 记录补设置的实际结果，成功与失败都在里面。

## 七、profile：固定一份，不按端口派生

本机 Chrome 走 CDP 时用的托管 profile 默认是 `~/.config/browseruse/profiles/shared-default`，由 `browser.chrome.cdpProfileDir` 控制。

**为什么不按端口派生。** 按端口派生（`shared-<端口>`）是为了让多个服务实例各自一份 profile、不抢锁。但这条路用的是托管 profile，本来就不会和用户正在开的 Chrome 抢目录；再按端口分只会带来一个纯粹损失：**换端口等于换一套登录态**。固定成 `shared-default` 之后，换端口不换登录态。

解析优先级：`browser.chrome.cdpProfileDir` > `browser.profileDir`（显式配置永远优先，想跟旧的托管目录共用一份登录态就指过去）> `shared-default`。想用**用户自己那份** Chrome profile（现成的 Google 登录态）请打开 `browser.chrome.useUserProfile`，那用的是系统默认的 `User Data`，与这一份无关 —— 详见 [05](<https://deepseek-browser-use.io.nexus/05>) 第四节。

> 从「按端口派生的托管 profile」切到 `shared-default` 时，**登录态不会跟着走**：那是两个不同的目录。要沿用旧目录，用 `browser.chrome.cdpProfileDir` 或 `browser.profileDir` 显式指过去。

## 八、排障

### 8.1 启动后立即退出、而且「没有输出」

现象：`start` 失败，报「浏览器启动后立即退出（退出码 21）：(Chrome 没有输出)」。

原因几乎只有一个：**这份托管 profile 已经被另一个 Chrome 占着**。新进程把命令行交给已有实例后自己退出，而**输出也被那个实例吃掉了**，所以只看启动器的报错会得到一句「没有输出」，完全指不到方向。

正常只可能有两个来源：上一次服务被强杀留下的孤儿浏览器，或者人手工起的调试实例。查法：看哪个 `chrome` 进程的命令行里带着这个 profile 目录。

```powershell
Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" |
  Where-Object { $_.CommandLine -like '*shared-default*' } |
  Select-Object ProcessId, CommandLine
```

### 8.2 调试端口：为什么不写 9222

**9222 不是浏览器的内置默认值**：Chromium 系浏览器要求显式传 `--remote-debugging-port`。9222 只是 Puppeteer / Selenium / 各类编辑器插件**约定俗成**用的端口。

跟着用它的代价是双向的：任何同样按默认值连 9222 的工具都会连到我们的浏览器上；反过来，别人手工起的调试实例会占着端口与 profile，让我们的启动失败 —— 8.1 那个「退出码 21、没有输出」就是这么来的。

所以默认值是 **0**：让浏览器自己挑一个空闲端口，端口号从它的 stderr 里读出来。这样不会有端口被占用的启动失败，同一台机器跑多个实例也不会互相踩，挑出来的端口一定在 10000 以上。需要稳定端点（想用别的工具接上来看、或要固定防火墙规则）时，用 `browser.chrome.debugPort` 钉一个 **10000 以上**的端口。

### 8.3 `set_credentials` 在这条路上用不了

HTTP 基本认证的凭据只能在创建上下文时设置，而这条路的上下文不是由 Playwright 创建的，也没法重建。需要它时改用 `browser=chromium`（内置 Chromium，走持久化上下文）或 `browser=firefox`。失败信息里会直接给出这两个取值。

## 九、自测怎么证明它真的能用

协议库这种「薄」组件最容易出现「编译过了、其实没连上」的情况，所以自测是一条**不依赖工程其它代码**的端到端用例：

- 自己探测浏览器可执行文件；找不到就整类跳过（这台机器上没装浏览器不算失败）。
- 自己挑空闲端口、用临时 profile 目录拉一个无头浏览器，不碰开发机上那份登录态。
- 断言覆盖：浏览器版本前缀、协议版本、导航与标题、`document` 求值、**异步表达式（Promise）求值**、截图字节是合法 PNG、两个页签的会话不串台、target 列表能解析、页面内异常与协议级拒绝分型正确、连接对象能自述存活状态。
- 反向用例：**Playwright 的权限名确实会被协议拒绝**，把这个容易犯、后果又静默的错误钉住。

配置与运维视角的清单见 [12](<https://deepseek-browser-use.io.nexus/12>)，浏览器与 profile 的表现见 [05](<https://deepseek-browser-use.io.nexus/05>)。
