MarkZ

定位与适用边界

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按职责划分的协议模块RuntimePageDOMNetworkInput
Method客户端主动发送的命令Runtime.evaluatePage.navigate
EventChromium 主动推送的通知Page.loadEventFiredNetwork.requestWillBeSent
Execution ContextJavaScript 执行环境页面主世界、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 客户端通常按以下顺序工作。

  1. 以远程调试参数启动 Chromium 或目标应用。
  2. 通过 HTTP 发现 Browser 和 Target。
  3. 选择要控制的页面 Target。
  4. 连接该 Target 的 WebSocket 地址。
  5. 启用所需 Domain,例如 Runtime.enablePage.enable
  6. 发送命令并持续处理响应与事件。

开放调试端口

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 连接则需要客户端维护 targetIdsessionId 与业务对象之间的映射。

下面的图把“发现”和“控制”分开:HTTP 端点只负责告诉客户端有哪些 Target 以及 WebSocket 地址;真正持续的命令、响应和事件通过 WebSocket 传输。

CDP 客户端如何发现并控制 Chromium Target

图 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 的直接控制路径,用橙色表示由目标应用决定的条件入口。

CDP 在 Electron 中的权限边界

图 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 不应继续被当作可信的日常浏览环境。

最低安全基线包括:

  1. 监听地址限制为 127.0.0.1,不要绑定 0.0.0.0 或公网网卡。
  2. 使用独立、可丢弃的 user-data-dir,不要复用包含 Cookie、缓存和敏感登录态的日常 Profile。
  3. 优先使用调试管道;使用端口时只在工具运行期间开放,并在进程退出时关闭。

还需要处理以下约束:

  • 对发现到的 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/protocolSchema.getDomains 读取目标暴露的协议结构;调用仍需处理 Method not found 等错误,不能只靠产品版本号推断能力。

选型判断

CDP 适合:

  • Chromium 和 Chromium WebView 诊断;
  • 自动化测试与性能分析;
  • 内部工具、原型验证和短生命周期集成;
  • 没有正式扩展点时的可回退运行时适配。

需要长期分发和稳定兼容时,应优先寻找官方 Plugin、Extension、SDK、CLI 或 HTTP API。CDP 注入依赖运行时结构,尤其是用于定位元素的 DOM/CSS Selector 和私有 Bridge;目标应用升级后,维护方需要自行承担探测、兼容和降级责任。

资料来源


相关笔记