ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Chrome CDP 从入门到实战:浏览器自动化调试协议深度解析

Chrome CDP 从入门到实战:浏览器自动化调试协议深度解析 很多入行做爬虫、做自动化测试的朋友第一次听说 chrome-cdp 的时候都是一脸懵。这玩意儿全称叫 Chrome DevTools Protocol说白了就是 Chrome 浏览器留出的一扇后门允许你用代码去控制浏览器几乎所有的行为打开页面、点击按钮、拦截请求、读取响应、改 DOM、模拟弱网甚至直接调用 JS 函数。比 Selenium 更底层、比 Puppeteer 更灵活是真正意义上的“浏览器远程调试协议”。这篇指南就从一个初学者的视角完整梳理 chrome-cdp 从环境准备、原理理解到上手实操的全过程。我会把我在实际项目里踩过的坑、验证过的方案、以及一些文档里不会告诉你的细节一起写进来。不管你是想用 CDP 做数据采集、前端自动化测试还是搞性能分析、故障复现这篇文章都能帮你把地基打牢。1. 为什么是 CDP它和 Selenium、Puppeteer 到底有什么区别很多人一开始接触浏览器自动化用的是 Selenium 或者 Puppeteer。这两个工具确实好用但它们其实都是在 CDP 之上又包了一层。先理解这一点你就能明白为什么我说 CDP 是更底层的协议。1.1 CDP 在自动化工具链里的位置CDP 的本质是一个基于 WebSocket 的 JSON-RPC 协议。Chrome 启动时如果加了--remote-debugging-port参数就会开一个调试端口这个端口对外提供两类能力一类是 HTTP 接口用来查询当前有哪些可调试的页面Target另一类是 WebSocket 接口用来和某个页面建立长连接然后双向收发命令和事件。Selenium 的架构是“客户端 - WebDriver - ChromeDriver - Chrome”而 ChromeDriver 内部其实就是把 WebDriver 的协议翻译成了 CDP 命令发给浏览器。Puppeteer 更直接它本身就是 Chrome DevTools 团队维护的 Node 库里面封装的page.click()、page.type()这些方法底层全是 CDP 的Input.dispatchMouseEvent、Input.insertText。所以说你平时用 Selenium 写自动化遇到某些刁钻场景搞不定比如监听网络请求、绕过检测、读取浏览器性能指标本质上就是因为上层封装把 CDP 的能力藏起来了而直接用 CDP 才能触达底层。1.2 CDP 的典型使用场景CDP 强在哪我挑几个我实际用过的场景说。第一是网络层拦截。Selenium 想获取页面某个 XHR 接口的请求头和响应体非常麻烦你得用execute_script去 HookXMLHttpRequest。但是 CDP 有Network.enable和Network.responseReceived事件浏览器每个网络请求都会主动推给你请求 URL、请求头、状态码、响应体通过Network.getResponseBody全都能拿干干净净。第二是性能分析。CDP 能开启Performance、Profiler、Log等域可以拿到完整的性能时间线、JS 堆栈和调用统计。我在排查页面卡顿问题时直接用 CDP 录一段Performance.enable的数据基本能把耗时的函数定位出来。第三是内存调试。比如HeapProfiler.collectGarbage强制触发垃圾回收HeapProfiler.takeHeapSnapshot导出堆快照。这在做 WebView 内存泄漏分析时是救命级别的功能。1.3 什么情况下你该直接上手 CDP如果你只是做个简单的表单自动填写Puppeteer 完全够用没必要自己搞 WebSocket。但是一旦你遇到以下这些需求建议直接切到 CDP需要监听浏览器的所有网络请求包括图片、字体、WebSocket 帧需要在页面加载的某个精确时间点注入 JS 或修改请求需要控制浏览器的下载行为比如自动保存文件而不弹窗需要和已有的 DevTools 协作比如远程连接到一个已经打开的 Chrome需要绕过一些基于 WebDriver 特征检测的反爬机制。这些需求用框架的 API 要么勉强能做但费劲要么完全做不到必须自己调 CDP。2. 环境准备从零启动一个可调试的 Chrome 实例说再多理论不如先跑起来。这一节的内容是基础中的基础但我见过很多新手在启动带调试端口的 Chrome 时栽跟头后面所有命令都白搭。2.1 启动参数--remote-debugging-port 和 --user-data-dir要让 Chrome 开放调试端口最核心的参数就两个chrome --remote-debugging-port9222 --user-data-dir/tmp/chrome-debug--remote-debugging-port指定调试端口默认值是 0表示随机端口所以必须显式指定。--user-data-dir也很关键它让 Chrome 使用一个全新的用户数据目录而不是你日常浏览器的数据目录。这是为了隔离会话避免打开调试端口时把你现有的浏览器窗口“接管”走。如果你不指定--user-data-dirChrome 会复用当前用户已启动的浏览器实例这样调试端口的参数不会生效。如果你在 Linux 服务器上跑建议再加一个--headlessnew即无头模式。新版 Chrome 的无头模式已经和完整版基本一致了可以渲染、执行 JS、发送请求适合跑在没有任何显示设备的机器上。而在本地开发调试时我更建议先开着有头模式也就是不加 headless这样你能看到浏览器每一步实际在干什么排查问题时特别有用。2.2 验证端口请求 /json/version 和 /json/list启动之后先在浏览器或者命令行里访问下面的地址curl http://127.0.0.1:9222/json/version返回内容里会有Browser版本号、webSocketDebuggerUrl、User-Agent等信息看到这个就说明调试端口已经通了。再访问curl http://127.0.0.1:9222/json/list这个接口返回的是当前所有可调试的页面列表每个条目里有id、title、url、webSocketDebuggerUrl字段。webSocketDebuggerUrl就是我们后续要建立的 WebSocket 连接地址。你打开任意一个页面控制台里都能看到对应的目标。2.3 通过命令行自动创建新页面如果你想用代码控制浏览器的整个生命周期用http://127.0.0.1:9222/json/new?url这个接口可以新建一个标签页curl -X PUT http://127.0.0.1:9222/json/new?https://example.com这里注意有人用 GET 请求这个接口会返回 405应该用 PUT 方法。新建成功后会返回这个页面的信息其中webSocketDebuggerUrl就是你要连接的目标地址。拿到这个地址后就可以开始正式连接了。3. 连接 CDP手写 WebSocket 客户端还是用现成库连接 CDP 有两种路径一是直接用 WebSocket 库自己拼 JSON 消息二是用社区封装好的 CDP 客户端库。两种我都用过建议是学习阶段一定要手写一遍理解协议流转工程落地阶段用封装库效率高得多。3.1 WebSocket 协议基础命令、响应、事件CDP 的消息是一个 JSON 对象通过 WebSocket 发送给浏览器。三种类型命令Command客户端发给浏览器字段是idmethodparams响应Response浏览器回给客户端字段是idresult或者error这里的id和命令的id一一对应事件Event浏览器主动推送给客户端字段是methodparams没有id。写成代码看非常直观。下面用 Python 的websocket-client库演示最基础的连接和命令交互import json import websocket ws websocket.create_connection(ws://127.0.0.1:9222/devtools/page/xxxx) # 给浏览器发送一个命令获取页面标题 command_id 1 ws.send(json.dumps({ id: command_id, method: Runtime.evaluate, params: { expression: document.title, returnByValue: True } })) # 接收响应 response json.loads(ws.recv()) print(response.get(result, {}).get(result, {}).get(value))这里returnByValue设为True意思是让浏览器把执行结果的值直接返回而不是返回一个远程对象的引用。Runtime.evaluate是后面你使用频率最高的一个命令相当于在页面里执行一段 JS然后把结果取回来。3.2 用 chrome-remote-interface 快速上手光手写 WebSocket 还行但真实项目里要处理消息 id 映射、事件分发、连接重连手写太累了。我一般用 Node 的chrome-remote-interface库npm install chrome-remote-interface连接并且获取页面标题的例子const CDP require(chrome-remote-interface); (async () { const client await CDP({ host: 127.0.0.1, port: 9222 }); const { Runtime, Page } client; await Page.enable(); await Runtime.enable(); const result await Runtime.evaluate({ expression: document.title, returnByValue: true }); console.log(页面标题是, result.result.value); client.close(); })();chrome-remote-interface比较轻量它只是帮你做了消息的 promise 化封装基本保留了 CDP 的原生命令风格。如果你想用 Python类似的库有pychrome不过在事件处理和连接管理上我觉得没有 Node 版顺手。3.3 事件监听的要点先 enable 才能收到事件这是新手最容易忽略的一个点。CDP 的绝大多数域DOM、Network、Page、Runtime 等默认是关闭事件推送的你必须先发送对应的enable命令才能开始接收该域的事件。比如我要监听网络请求先发Network.enable然后在消息循环里处理Network.requestWillBeSent、Network.responseReceived这些事件。用伪代码描述就是1. 连接 WebSocket 2. 发送 Network.enable 3. 循环 ws.recv() 4. 如果收到 method 为 Network.responseReceived 的消息处理它事件是不会停止的所以消息循环通常是一个while True除非你主动退出连接。这一点对资源管理要求很高连接处理完一定要记得关闭。4. 核心实操用 CDP 完成一次完整的自动化任务接下来我带大家走一遍真实的任务流程打开页面、等待资源加载、监听网络请求、获取接口数据、点击按钮、拿到最终结果。把这一套流程走通了CDP 就相当于掌握了一半。4.1 导航控制Page 域的核心命令打开页面最核心的命令是Page.navigateawait Page.enable(); await Page.navigate({ url: https://example.com }); await Page.loadEventFired();Page.loadEventFired是一个事件监听它的意思是等待页面触发load事件。但要注意现在很多页面是 SPA 架构load事件触发时页面内容可能还在异步加载所以更稳妥的做法是等某个你关心的选择器出现或者等某个 XHR 请求结束。用 CDP 怎么轮询等待还是用Runtime.evaluate执行 JS 去判断条件async function waitForSelector(selector, timeout 10000) { const start Date.now(); while (Date.now() - start timeout) { const result await Runtime.evaluate({ expression: !!document.querySelector(${selector}), returnByValue: true }); if (result.result.value) return true; await sleep(200); } throw new Error(等待选择器超时); }4.2 监听网络请求Network 域的实战姿势监听网络请求我直接说代码。拿到连接后await Network.enable(); const pendingRequests new Map(); client.on(Network.requestWillBeSent, (params) { const { requestId, request } params; pendingRequests.set(requestId, { url: request.url, method: request.method, postData: request.postData, }); }); client.on(Network.responseReceived, async (params) { const { requestId, response } params; const req pendingRequests.get(requestId); if (!req) return; if (req.url.includes(/api/)) { const body await Network.getResponseBody({ requestId }); console.log(接口响应, body.body); } });注意Network.getResponseBody返回的 body 默认是 base64 编码的如果响应是文本内容需要先判断body.base64Encoded字段再决定要不要做 base64 解码。还有一个巨坑如果请求已经被浏览器判定为失败比如 404、CORS 错误或者请求是data:协议getResponseBody会直接抛错所以调用时最好包一层 try-catch。4.3 模拟鼠标点击Input 域的细节点击按钮很多教程写的都是Runtime.evaluate直接执行document.querySelector(...).click()。这种方式能触发 React/Vue 的合成事件吗实测有坑但大多数场景可以。问题是有些页面会检测“是否是用户真实行为”比如某些反爬方案会检查event.isTrusted用 JS 触发的 click 这个字段是false。要模拟真实点击得用Input.dispatchMouseEventasync function clickAtSelector(selector) { // 先通过 JS 获取元素坐标 const rect await Runtime.evaluate({ expression: (() { const el document.querySelector(${selector}); if (!el) return null; const r el.getBoundingClientRect(); return { x: r.x r.width / 2, y: r.y r.height / 2 }; })(), returnByValue: true }); const { x, y } rect.result.value; // 模拟真实的鼠标移动按下抬起 await Input.dispatchMouseEvent({ type: mouseMoved, x, y }); await Input.dispatchMouseEvent({ type: mousePressed, x, y, button: left, clickCount: 1 }); await Input.dispatchMouseEvent({ type: mouseReleased, x, y, button: left, clickCount: 1 }); }这里注意坐标是基于视口的不是页面绝对坐标。如果页面有滚动需要先滚到元素可见位置再取坐标。大多数情况下scrollIntoViewIfNeeded能解决。4.4 表单填写进阶用 Input.insertText 而不是直接赋值填表单有个隐藏的坑用 JS 直接给input.value赋值React 这种受控组件可能不会同步状态提交时数据就丢了。要触发框架的 input 事件用 CDP 的Input.insertText最稳async function fillInput(selector, text) { // 先点击这个输入框保证它获得焦点 await clickAtSelector(selector); // 清空原来的内容模拟 CtrlA 删除 await Input.dispatchKeyEvent({ type: keyDown, modifiers: 2, key: a, code: KeyA }); await Input.dispatchKeyEvent({ type: keyUp, modifiers: 2, key: a, code: KeyA }); // 输入新内容 await Input.insertText({ text }); }modifiers: 2表示 Ctrl 键被按下。这个操作组合下来和真人操作几乎一致React/Vue 的状态也都能正常更新。5. 高级玩法把 CDP 变成你的自动化“瑞士军刀”掌握了基础交互之后CDP 真正让我觉得“这东西值”的是下面这些高级能力。它们能把之前不可能完成的自动化任务变得简单。5.1 页面 JS 注入在任何时机执行你的代码Page.addScriptToEvaluateOnNewDocument这个命令可以在新文档创建时执行一段 JS执行时机比页面自身的任何脚本都早。这通常被用来改写环境变量、删除爬虫检测特征、添加全局 Hook。比如一个常见的场景页面上有个检测函数你想在它执行前先改掉它await Page.addScriptToEvaluateOnNewDocument({ source: const originalDefineProperty Object.defineProperty; Object.defineProperty function(obj, prop, descriptor) { // 篡改某检测参数 if (prop webdriver) { descriptor.value false; } return originalDefineProperty.call(this, obj, prop, descriptor); }; });注意这段脚本是“注入时机”以后每打开一个新页面、刷新或者跳转只要页面文档被创建脚本都会执行一次。对于需要跨页面保持统一注入的场景非常方便。5.2 拦截和修改请求Fetch 域带来“中间人”能力Fetch.enable可以拦截浏览器发出的所有请求并且让你决定是继续、中止还是伪造一个响应。这在写自动化脚本时太有用了比如你要测试一个前端页面但后端的某个接口一直不稳定你就可以直接拦截这个接口并返回 mock 数据await Fetch.enable({ patterns: [{ urlPattern: *://*/api/order/list*, requestStage: Request }] }); client.on(Fetch.requestPaused, async ({ requestId, request }) { if (request.url.includes(/api/order/list)) { // 直接伪造一个响应不实际请求网络 await Fetch.fulfillRequest({ requestId, responseCode: 200, responseHeaders: [{ name: Content-Type, value: application/json }], body: Buffer.from(JSON.stringify({ code: 0, data: [] })).toString(base64), }); } else { await Fetch.continueRequest({ requestId }); } });body必须是 base64 编码这也是个容易踩的细节。另外Fetch.enable之后如果你不处理某个请求也没关系但要记得对每个 paused 的请求都调用一次continueRequest或者fulfillRequest否则请求会一直挂起浏览器页面会卡住等待。5.3 性能分析Performance 和 Profiler 实战CDP 可以获取各种性能指标。先启用Performanceawait Performance.enable(); await Performance.getMetrics().then(({ metrics }) { metrics.forEach(m console.log(m.name, m.value)); });返回的指标里有Requests、ScriptDuration、LayoutDuration、TaskDuration、JSHeapUsedSize、JSHeapTotalSize等基本覆盖了页面加载和运行的核心指标。如果要做更细粒度的网络耗时分析可以监听Network.requestWillBeSent和Network.loadingFinished记录timestamp计算出每个资源的TTFB和传输耗时。这个方法做页面性能诊断非常直观。5.4 模拟弱网与地理位置Emulation 域模拟弱网条件一是用Network.emulateNetworkConditionsawait Network.enable(); await Network.emulateNetworkConditions({ offline: false, latency: 1000, // 延迟 1000ms downloadThroughput: 500 * 1024 / 8, // 500KB/s uploadThroughput: 100 * 1024 / 8 // 100KB/s });注意downloadThroughput和uploadThroughput的单位是字节/秒不是比特1KB 1024 字节所以 500KB/s 要写成500 * 1024 / 8。我一开始就写错过导致模拟的是完全没有网速的环境。地理位置模拟用Emulation.setGeolocationOverrideawait Emulation.setGeolocationOverride({ latitude: 31.2304, longitude: 121.4737, accuracy: 100 });配合Page.setDeviceMetricsOverride可以同时模拟一个指定尺寸的移动设备视口这对测试响应式布局和移动端抓包很有帮助。6. 工程化落地如何把 CDP 封装进你的项目如果你只是临时调试手写 WebSocket 就够了。但一旦要把它做成一个规范的自动化测试框架或者数据采集服务就需要稍微注意一下工程架构。6.1 用 TypeScript 事件驱动封装一个最小 CDP 客户端我通常会封装一个CDPClient类把消息 id 自增、pending 请求的 Promise 映射、事件监听器注册这几件事统一管起来。核心代码长这样import WebSocket from ws; class CDPClient { private ws!: WebSocket; private id 0; private pending new Mapnumber, { resolve: any; reject: any }(); private listeners new Mapstring, Set(params: any) void(); constructor(private wsUrl: string) {} connect() { this.ws new WebSocket(this.wsUrl); this.ws.on(message, (data) this.handleMessage(JSON.parse(data.toString()))); } send(method: string, params: object {}) { const id this.id; return new Promise((resolve, reject) { this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params })); }); } on(method: string, cb: (params: any) void) { if (!this.listeners.has(method)) this.listeners.set(method, new Set()); this.listeners.get(method)!.add(cb); } private handleMessage(msg: any) { if (msg.id this.pending.has(msg.id)) { const { resolve, reject } this.pending.get(msg.id)!; this.pending.delete(msg.id); if (msg.error) reject(new Error(JSON.stringify(msg.error))); else resolve(msg.result); return; } if (msg.method this.listeners.has(msg.method)) { this.listeners.get(msg.method)!.forEach(cb cb(msg.params)); } } }这个封装的意义在于你写业务代码时不再需要关心消息 id 是怎么对应的发一个命令就await它的返回事件就注册on方法去监听。工程代码的可读性和健壮性明显上一个台阶。6.2 如何优雅地管理多个标签页和多个浏览器实例一个chrome --remote-debugging-port9222可以打开多个标签页每个标签页对应一个webSocketDebuggerUrl。要并发操作多个页面给每个页面都建一个CDPClient即可。如果并发量更大、需要隔离会话可以考虑给每个任务单独启动一个带独立端口的 Chrome 进程用完直接杀掉这样连 cookie、缓存、本地存储都是隔离的互不影响。我实际做数据采集时通常会采用一个调度策略设置一个端口池比如 9222 到 9232每个端口跑一个独立的 Chrome 实例任务进来就挑一个空闲端口实例去执行。这种方式的优点是单个实例的崩溃不会影响其他任务而且重启单个实例的成本很低。缺点是内存占用高一个 Chrome 实例大概占 300MB 到 1GB 内存需要根据机器配置平衡并发数。6.3 与 Puppeteer 结合使用鱼与熊掌兼得如果你是在 Node 环境开发Puppeteer 已经内置了 CDP 的全部能力它甚至把send方法直接暴露出来了const puppeteer require(puppeteer); (async () { const browser await puppeteer.launch({ headless: new }); const page await browser.newPage(); const cdpSession await page.createCDPSession(); // 用 CDP 监听网络请求 await cdpSession.send(Network.enable); cdpSession.on(Network.responseReceived, (params) { console.log(收到响应, params.response.url); }); await page.goto(https://example.com); await browser.close(); })();这种方式的好处是页面导航、元素定位这些用 Puppeteer 的 API安全省心网络层、性能层、注入层这种底层能力用createCDPSession去调 CDP。两套逻辑互补写起来很顺。7. 常见问题与排查技巧实录CDP 用久了会遇到各种稀奇古怪的问题我把踩过频率最高的几个列出来附上排查思路。7.1 连不上 9222 端口第一步先确认 Chrome 进程是否真的带参数启动了。用ps -ef | grep chrome看一眼启动命令里有没有--remote-debugging-port9222。如果参数在但curl http://127.0.0.1:9222/json/version还是失败检查一下防火墙和网络策略看看是不是本机 curl 都访问不了。如果是云服务器还要确认安全组是否放行了该端口。还有一个坑如果你在 macOS 上第一次运行带--remote-debugging-port的命令系统会弹一次“允许接入网络”的提示如果点了拒绝之后 Chrome 会静默失败端口起不来。去系统设置里的防火墙项把 Chrome 改为允许即可。7.2 WebSocket 连上之后收不到事件90% 的情况是忘了先发送对应域的enable命令。比如你监听Network.responseReceived却没发Network.enable浏览器根本不会向你推送任何网络事件。这是 CDP 的显式订阅机制任何域默认都是关闭状态。另一个可能是你连错了webSocketDebuggerUrl。一个浏览器有多个目标比如页面、Service Worker、扩展只有页面的调试地址才会产生页面相关事件。用json/list的时候注意看type字段确保连的是type: page的那个。7.3 Runtime.evaluate 执行结果拿到的是 undefined最常见的原因是表达式本身没有返回值。比如执行document.querySelector(.title)这个表达式的值是一个 DOM 对象如果没有设置returnByValue: true返回的就是一个对象的引用你无法直接拿到字符串。另外document.querySelector找不到元素会返回null这在序列化时也会被当作正常值返回。我写表达式时习惯直接写成一个 IIFE立即执行函数最后强制return一个 JSON 可序列化的值Runtime.evaluate({ expression: (() { const el document.querySelector(.title); return el ? el.textContent : null; })(), returnByValue: true });7.4 getResponseBody 报错 “No resource with given identifier found”这个错误通常是因为请求还在进行中或者已经超过了Network.getResponseBody的有效期。CDP 的响应体缓存是有限的你必须在收到Network.loadingFinished事件之后立刻去取晚了几秒可能就取不到了。解决办法是把取响应体的时机放在loadingFinished事件里而不是responseReceived里。responseReceived只是响应头到达body 可能还没传输完。代码里加上client.on(Network.loadingFinished, async (params) { try { const { body } await Network.getResponseBody({ requestId: params.requestId }); console.log(完整响应体, body); } catch (e) { // 有些资源类型没有响应体比如 204这里直接忽略 } });7.5 页面总是触发自动检测/滑块验证这个问题比较复杂我直接说结论。如果你用 puppeteer 或者无头模式访问一些严格的反爬平台很容易被识别。CDP 本身并不“隐身”但配合Page.addScriptToEvaluateOnNewDocument这种早期注入可以抹掉大部分基于 JS 的检测特征。常见的检测点包括navigator.webdriver是否为truewindow.chrome对象是否存在无头模式可能缺失navigator.plugins和navigator.languages是否符合真实浏览器webgl 渲染器信息是否可以正常获取这些都可以用注入脚本去补。但要注意反爬技术也在升级很多平台已经结合了浏览器指纹、行为轨迹、IP 质量等多维度的检测仅靠改这些特征是做不过的。合规的数据采集永远是前提技术只是工具。8. 一些建议和心得最后聊点实际的。CDP 这套协议文档很全但官方文档更像是“字典”按域名排列引脚和事件没有业务视角的串联。建议新手学习时不要死磕文档而是带着真实任务去试比如“把某个页面的所有请求记录下来”“自动填写某个表单并提交”遇到不会的命令再翻文档效率会高很多。另外调试阶段强烈建议开着有头模式的 Chrome肉眼盯着每一步操作代码哪里写错了能快速发现。等整个过程稳定了再切到 headless 模式跑全流程。我第一次做无头模式的时候代码里坐标全是基于有头模式算的切到 headless 后元素位置偏移点击全点偏了。原因就是 headless 模式下视口大小、滚动条占用空间和有头模式有细微差别后来统一在启动参数里设置--window-size和--force-device-scale-factor才稳定下来。CDP 本身不会让你的浏览器自动化“无敌”它只是给了你一台发动机怎么开、开去哪取决于你的业务思路。这篇指南覆盖了从启动参数、协议原理到核心命令、工程封装的完整链路后续你就可以照着这个思路去实现自己的自动化任务了。
返回列表