定位与适用边界
Chrome DevTools Protocol(CDP)是一套用于检查、调试和控制 Chromium 的协议。Chrome DevTools、Puppeteer、部分浏览器自动化和诊断工具都建立在这类能力之上。CDP 客户端通过 WebSocket 发送 JSON 消息,可以让浏览器执行 JavaScript、读取 DOM、监听网络请求、模拟输入、采集性能数据或控制页面生命周期。
CDP 不是 Chrome DevTools 的界面,也不是专门为“注入”设计的框架。DevTools 是一种 CDP 客户端;运行时注入只是 CDP 的一种用法。
这几个边界需要先固定:
- CDP 操作的是正在运行的 Browser、Page 或其他 Target,默认不会修改应用安装包和源码。
- 能否连接取决于目标程序是否开放调试入口,基于 Chromium 不等于默认允许外部连接。
- CDP 通常首先控制网页或 Electron Renderer(网页渲染进程),不能自动获得 Electron Main Process(原生主进程)的全部权限。
Runtime.evaluate对当前 Document 的修改会随刷新消失;已注册的新 Document 脚本会在导航后重新执行,只有 Target 重建后才需要重新发现并注册。
协议模型:Target、Session、Domain 与消息
CDP 是客户端和 Chromium 之间的控制协议。理解下面几个对象后,大部分 API 都可以按同一方式阅读。
| 概念 | 含义 | 常见例子 |
|---|---|---|
| Browser | 整个 Chromium 浏览器进程 | 创建页面、查询版本、管理 Target |
| Target | 一个可调试对象 | 页面、iframe、Worker、Service Worker |
| Session | 客户端与某个 Target 的逻辑连接 | 对指定页面发送命令并接收事件 |
| Domain | 按职责划分的协议模块 | Runtime、Page、DOM、Network、Input |
| Method | 客户端主动发送的命令 | Runtime.evaluate、Page.navigate |
| Event | Chromium 主动推送的通知 | Page.loadEventFired、Network.requestWillBeSent |
| Execution Context | JavaScript 执行环境 | 页面主世界、iframe、隔离世界 |
主世界是页面自身脚本所在的 JavaScript 环境;隔离世界拥有独立的全局对象,可以减少注入代码与页面变量互相污染。iframe 至少拥有自己的 Execution Context;采用跨进程 iframe 时,它还可能作为独立 Target 出现。
一条命令通常包含自增 id、方法名和参数:
{
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": "document.title",
"returnByValue": true
}
}成功响应通过相同的 id 与请求对应:
{
"id": 1,
"result": {
"result": {
"type": "string",
"value": "Example Domain"
}
}
}事件没有请求 id,因为它不是某条命令的直接响应:
{
"method": "Page.loadEventFired",
"params": {
"timestamp": 12345.67
}
}因此,一个完整客户端至少要处理两件事:根据 id 匹配请求和响应,以及把没有 id 的消息分发给事件监听器。
从启动到控制页面的连接链路
外部 CDP 客户端通常按以下顺序工作。
- 以远程调试参数启动 Chromium 或目标应用。
- 通过 HTTP 发现 Browser 和 Target。
- 选择要控制的页面 Target。
- 连接该 Target 的 WebSocket 地址。
- 启用所需 Domain,例如
Runtime.enable、Page.enable。 - 发送命令并持续处理响应与事件。
开放调试端口
macOS 上可以用独立 Profile 启动 Chrome:
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
--remote-debugging-address=127.0.0.1 \
--remote-debugging-port=9222 \
--user-data-dir=/private/tmp/cdp-demo-profile \
https://example.com/--remote-debugging-port=9222 开放 TCP 调试端口;--remote-debugging-address=127.0.0.1 将监听范围限制在本机;独立的 --user-data-dir 避免复用日常 Profile(保存 Cookie、缓存和设置的浏览器数据目录),也能绕开 Chromium 的 Profile 锁和已有进程复用问题。
另一种方式是 --remote-debugging-pipe。它不开放 TCP 端口,而是让父进程通过管道通信,暴露面更小,但要求启动器直接持有目标进程的输入输出管道,实现也更复杂。
⚠️ 启动参数只对新进程生效。应用已经运行时,再把参数发送给旧进程不会让现有 Renderer 自动开放 CDP。单实例应用还可能把第二次启动请求转交给旧进程,因此调试启动器通常需要独立 Profile 或专门的实例管理策略。
发现 Browser 与 Target
调试端口开放后,可以查询:
curl http://127.0.0.1:9222/json/version
curl http://127.0.0.1:9222/json/list/json/version 用于确认浏览器和协议版本,并提供 Browser 级 WebSocket 地址。/json/list 返回可调试 Target,页面条目通常包含:
{
"id": "TARGET_ID",
"type": "page",
"title": "Example Domain",
"url": "https://example.com/",
"webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/TARGET_ID"
}客户端不应默认选择第一项。一个应用可能同时存在主窗口、设置页、iframe、Worker 和后台页面,应根据 type、URL、标题或其他稳定特征筛选目标。
Target 直连与 Browser Session
CDP 有两种常见连接方式:
- Target WebSocket 直连:直接连接
/json/list中页面自己的webSocketDebuggerUrl。当前连接天然对应这个 Target,适合单页面工具和下面的最小实验。 - Browser WebSocket 加 Session:先连接
/json/version返回的 Browser WebSocket,再通过Target.attachToTarget附着到一个或多个 Target。后续消息携带sessionId,适合统一管理多个页面、iframe 和 Worker。
两种方式使用相同的 Domain、Method 和 Event。区别在于 Target 直连把“连接与目标”绑定在一起,Browser 连接则需要客户端维护 targetId、sessionId 与业务对象之间的映射。
下面的图把“发现”和“控制”分开:HTTP 端点只负责告诉客户端有哪些 Target 以及 WebSocket 地址;真正持续的命令、响应和事件通过 WebSocket 传输。
图 1:蓝色路径表示单个 Target 直连,紫色路径表示先连接 Browser、再用 sessionId 管理多个 Target。无论采用哪条路径,客户端最终控制的都是明确选中的 Target。
最小运行时注入实验
下面的实验只依赖 Node.js 18+ 和 ws。它连接前一步打开的 https://example.com/,在页面右上角插入一个固定提示条。
先安装依赖:
npm install ws创建 cdp-demo.mjs:
import WebSocket from "ws";
const targets = await fetch("http://127.0.0.1:9222/json/list").then((response) => response.json());
const target = targets.find((candidate) =>
candidate.type === "page" && candidate.url.startsWith("https://example.com/")
);
if (!target?.webSocketDebuggerUrl) {
throw new Error("没有找到可调试的页面 Target");
}
const debuggerUrl = new URL(target.webSocketDebuggerUrl);
if (
debuggerUrl.protocol !== "ws:" ||
debuggerUrl.hostname !== "127.0.0.1" ||
debuggerUrl.port !== "9222"
) {
throw new Error(`拒绝连接非预期的调试地址:${debuggerUrl.href}`);
}
const socket = new WebSocket(debuggerUrl);
await new Promise((resolve, reject) => {
socket.once("open", resolve);
socket.once("error", reject);
});
let sequence = 0;
const pending = new Map();
socket.on("message", (rawMessage) => {
const message = JSON.parse(rawMessage.toString());
if (!message.id) return;
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
if (message.error) request.reject(new Error(message.error.message));
else request.resolve(message.result);
});
function call(method, params = {}) {
const id = ++sequence;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }));
});
}
await call("Runtime.enable");
const evaluation = await call("Runtime.evaluate", {
expression: `(() => {
const id = "cdp-demo-banner";
let banner = document.getElementById(id);
if (!banner) {
const mount = document.body || document.documentElement;
banner = document.createElement("div");
banner.id = id;
Object.assign(banner.style, {
position: "fixed",
top: "12px",
right: "12px",
zIndex: "2147483647",
padding: "8px 12px",
borderRadius: "8px",
background: "#111827",
color: "#ffffff",
font: "14px sans-serif"
});
mount.appendChild(banner);
}
banner.textContent = "Injected through CDP";
return { url: location.href, inserted: true };
})()`,
awaitPromise: true,
returnByValue: true
});
if (evaluation.exceptionDetails) {
const description = evaluation.exceptionDetails.exception?.description;
throw new Error(description || evaluation.exceptionDetails.text || "页面脚本执行失败");
}
console.log(evaluation.result.value);
socket.close();运行:
node cdp-demo.mjs这个例子包含一条最小闭环:HTTP 发现 Target、WebSocket 建连、id 关联响应、Runtime.evaluate 执行代码。提示条在当前 Document 中创建,刷新页面后就会消失,也不会写进目标网站或浏览器安装目录。为了突出协议主链路,示例省略了请求超时、事件分发、断线重连和进程退出清理;长期运行的客户端需要补齐这些机制。
当前 Document 与后续 Document 的注入差异
Runtime.evaluate 只在当前 Execution Context 中执行。页面刷新、跳转或 Renderer 重建后,原来的 DOM 和 JavaScript 对象都会销毁。
需要覆盖后续加载的 Document 时,可以注册:
{
"id": 2,
"method": "Page.addScriptToEvaluateOnNewDocument",
"params": {
"source": "console.log('document created')"
}
}该命令返回脚本标识符。需要停止注入时,通过 Page.removeScriptToEvaluateOnNewDocument 删除注册。
两种方式承担不同职责:
| 方式 | 生效时机 | 页面刷新后 | 常见用途 |
|---|---|---|---|
Runtime.evaluate | 当前 Document 已经存在时 | 失效 | 诊断、一次性修改、立即挂载 UI |
Page.addScriptToEvaluateOnNewDocument | 新 Document 的脚本执行前 | 重新执行 | 初始化 Hook、持续注入、导航覆盖 |
只看“刷新后是否存在”容易把 Document 和 Target 两层生命周期混在一起。下图把两种注入方式放在同一个生命周期里比较。
图 2:页面导航只会替换 Document,注册在当前 Target 上的新 Document 脚本仍可再次执行;只有标签页、窗口或 Electron webContents 被销毁并重建,原 Target 才会消失,此时 Session 和注册脚本都需要重新建立。
实际工具通常会同时使用两者:先用 Runtime.evaluate 处理当前页面,再注册新 Document 脚本覆盖后续导航。如果对应的标签页、窗口或 Electron webContents 被销毁,原 Target 会随之消失;仅注册脚本不再足够,外部程序还要发现新 Target、建立 Session 并再次注册。普通刷新或底层 Renderer 进程切换不一定改变 Page Target,不能只根据进程变化判断是否需要重连。
CDP 在 Electron 中的能力边界
Electron 使用 Chromium 渲染页面,因此很多 Electron Renderer 可以通过 CDP 控制。不过,Electron 是应用框架,不是“默认开放的远程浏览器”。外部连接仍然取决于应用是否接受远程调试参数、是否允许新实例以及是否存在可连接的 Renderer Target。
Electron 的 Main Process 负责窗口、进程和系统能力;Renderer 承载 HTML、CSS、DOM 和页面 JavaScript;Preload 在页面加载前运行,并可通过 contextBridge 向 Renderer 暴露一组受控函数。这里的 Bridge 指这组跨进程调用入口,而不是 CDP 自带接口。
Electron 的 Main Process、Preload 和 Renderer 权限不同:
| 能力 | CDP 是否自动获得 | 原因 |
|---|---|---|
| 读取和修改 Renderer DOM | 通常可以 | DOM 和页面 JavaScript 位于 Renderer |
| 执行页面 JavaScript | 通常可以 | Runtime.evaluate 在 Execution Context 中执行 |
| 使用页面已暴露的 API | 视应用而定 | 取决于全局对象和 Preload Bridge |
| 直接执行 Electron Main Process 代码 | 通常不可以 | Main Process 是不同进程和权限域 |
| 访问文件系统或系统命令 | 视配置而定 | 取决于 Node Integration、Context Isolation 和 Bridge |
CDP 能否修改界面与能否调用原生能力是两个问题。下图用蓝色表示 CDP 的直接控制路径,用橙色表示由目标应用决定的条件入口。
图 3:CDP 默认到达 Renderer;继续调用窗口、文件和进程能力,需要目标应用通过 Node Integration 或 Preload Bridge 明确提供路径。
如果 Preload 通过 contextBridge 暴露了受控接口,注入代码可能调用这些接口;这属于目标应用自己的 API 边界,不是 CDP 自动赋予的权限。Node Integration 决定 Renderer 能否直接使用 Node.js,Context Isolation 决定 Preload 与页面是否共享同一个 JavaScript 全局环境。即使可以修改界面,也可能完全无法调用应用的原生能力。
并非所有跨平台桌面框架都使用 Chromium。Electron 自带 Chromium;Tauri 通常使用操作系统 WebView,例如 macOS 上的 WKWebView。后者不应直接套用 Chromium 的 CDP 启动参数。
运行时注入的工程化分层
一次演示只需要执行 JavaScript,长期维护的工具通常需要分成四层。
| 层次 | 职责 | 典型失败 |
|---|---|---|
| 启动层 | 创建可调试实例、隔离 Profile、管理端口或管道 | 参数未生效、单实例复用、Profile 锁冲突 |
| 连接层 | 发现 Target、建立 WebSocket、关联请求和事件 | 选错 Target、连接断开、协议版本变化 |
| 注入层 | 注册脚本、操作 DOM、处理导航和 Renderer 重建 | Selector 失效、重复挂载、页面刷新后丢失 |
| 产品适配层 | 调用目标应用公开或私有的 Bridge 与路由 | 接口改名、权限变化、应用升级不兼容 |
核心业务不宜直接写进注入脚本。更稳妥的结构是让业务服务和数据模型独立存在,CDP 只作为可替换的界面适配器。目标应用升级导致注入失效时,核心能力仍可通过独立页面、CLI 或正式 API 使用。
安全边界
远程调试端点应当被视为高权限控制接口。连接者通常可以读取页面内容、执行 JavaScript、观察网络请求,并在协议允许的范围内访问浏览器状态。开启调试端口的 Profile 不应继续被当作可信的日常浏览环境。
最低安全基线包括:
- 监听地址限制为
127.0.0.1,不要绑定0.0.0.0或公网网卡。 - 使用独立、可丢弃的
user-data-dir,不要复用包含 Cookie、缓存和敏感登录态的日常 Profile。 - 优先使用调试管道;使用端口时只在工具运行期间开放,并在进程退出时关闭。
还需要处理以下约束:
- 对发现到的 WebSocket URL 做协议、主机和端口校验,拒绝非预期地址。
- 不把 CDP 端口当作有身份认证的服务;同一台机器上的其他进程可能尝试连接。
- 谨慎使用
Page.setBypassCSP等绕过 Content Security Policy(CSP,内容安全策略)的命令,并缩小目标范围和存活时间。 - 为注入脚本设计幂等标识、卸载逻辑和进程退出清理,避免重复挂载和残留状态。
兼容性与排错入口
| 现象 | 优先检查 |
|---|---|
/json/version 无法访问 | 进程是否真正携带调试参数启动,端口是否冲突 |
| 第二次启动没有新窗口 | 应用是否执行单实例复用,是否需要独立 Profile |
/json/list 没有目标页面 | 页面是否尚未创建,筛选条件是否错误 |
| WebSocket 建连后命令无响应 | 请求 id 是否正确关联,连接是否提前关闭 |
Runtime.evaluate 成功但界面没变化 | 是否连接了错误页面或错误 iframe Execution Context |
| 刷新后注入消失 | 是否只使用了 Runtime.evaluate |
| 窗口重建后持续注入也消失 | 原 Target 是否已被销毁,是否需要发现新 Target 并重新注册 |
| 能改 DOM 但不能访问文件或原生功能 | 能力是否位于 Main Process,页面是否暴露 Preload Bridge |
| 应用升级后入口找不到 | DOM Selector、窗口标题或私有接口是否发生变化 |
CDP 协议同时存在稳定版本和 tip-of-tree 版本:稳定版本是带版本约束的协议快照,tip-of-tree 跟随 Chromium 开发分支变化,兼容性更弱。客户端可以通过 /json/version 查询产品和协议版本,通过 /json/protocol 或 Schema.getDomains 读取目标暴露的协议结构;调用仍需处理 Method not found 等错误,不能只靠产品版本号推断能力。
选型判断
CDP 适合:
- Chromium 和 Chromium WebView 诊断;
- 自动化测试与性能分析;
- 内部工具、原型验证和短生命周期集成;
- 没有正式扩展点时的可回退运行时适配。
需要长期分发和稳定兼容时,应优先寻找官方 Plugin、Extension、SDK、CLI 或 HTTP API。CDP 注入依赖运行时结构,尤其是用于定位元素的 DOM/CSS Selector 和私有 Bridge;目标应用升级后,维护方需要自行承担探测、兼容和降级责任。
资料来源
- Chrome DevTools Protocol:协议首页
- Chrome DevTools Protocol:Runtime Domain
- Chrome DevTools Protocol:Page Domain
- Chrome DevTools Protocol:Target Domain
- Chrome DevTools Protocol:Schema Domain
- Electron:Process Model
- Electron:Supported Command Line Switches