从 Playwright 到 CDP:手撕 Chrome DevTools Protocol 做爬虫
从 Playwright 到 CDP:手撕 Chrome DevTools Protocol 做爬虫
用过 Playwright 的人都知道它有多舒服:API 设计干净,自动等待元素,跨浏览器,文档齐全。但当你的爬虫项目规模上去、或者遇到一些刁钻的反爬场景时,Playwright 这层封装反而开始碍事了。最近我在一个项目里把 Playwright 换成了直接操作 CDP(Chrome DevTools Protocol),踩了不少坑也尝到了甜头。这篇文章把整个过程和原理梳理一遍。
一、先搞清楚 Playwright 和 CDP 的关系
很多人没意识到的一点是:Playwright(以及 Puppeteer)本身就是构建在 CDP 之上的。
当你写 page.goto(url) 的时候,Playwright 在底层做的事情,大致就是通过 WebSocket 给浏览器发了一条这样的消息:
{
"id": 1,
"method": "Page.navigate",
"params": { "url": "https://example.com" }
}
所以「从 Playwright 换到 CDP」,本质上不是换了个工具,而是把中间那层封装拿掉,自己直接和浏览器对话。Playwright 帮你做的自动等待、重试、元素定位、跨浏览器兼容,现在都要你自己处理或舍弃。
这就引出一个关键问题:既然 Playwright 更省事,为什么还要往下沉?
二、为什么放弃 Playwright 转向裸 CDP
我的项目里主要是这几个原因,你可以对照看看自己是否需要。
1. 体积和依赖
Playwright 会下载它自己打过补丁的浏览器(patched Chromium),整个依赖装下来几百兆。如果你的部署环境对镜像体积敏感,或者你想复用系统已有的 Chrome,裸 CDP 只需要一个 WebSocket 客户端就够了,不需要 Playwright 这个重型依赖。
2. 反爬识别
这是最现实的原因。Playwright 和 Puppeteer 启动的浏览器会留下一些可被检测的痕迹。虽然有 playwright-stealth 这类方案,但它们是在和检测方打补丁的军备竞赛。
直接用系统装的 Chrome、以 --remote-debugging-port 方式启动,再用 CDP 接管,启动出来的浏览器特征更接近真实用户环境。你能完全控制每一个启动参数,不会有框架偷偷帮你注入东西。
3. 精细控制
CDP 提供的能力是全集,Playwright 暴露的是子集。有些底层操作(比如细粒度的 Fetch 域请求拦截、Network 的某些事件、Target 管理多页面)通过 Playwright 用起来别扭,直接发 CDP 命令反而清爽。
4. 性能
少一层封装,少一些 Playwright 内部的等待和同步逻辑,在高并发抓取时延迟更可控。这点提升不算巨大,但量大了能感觉到。
需要泼一盆冷水:如果你不需要上面这些,Playwright 完全够用,自己撸 CDP 是在给自己找活干。换之前先确认收益。
三、CDP 的核心概念
在写代码之前,有几个概念必须理清楚,否则会一头雾水。
协议本身
CDP 是基于 WebSocket 的双向 JSON 协议。你给浏览器发命令(command),浏览器回结果(result);同时浏览器会主动推送事件(event)给你。
命令长这样,每条命令带一个递增的 id,浏览器用相同的 id 回你:
// 发送
{ "id": 42, "method": "Runtime.evaluate", "params": { "expression": "document.title" } }
// 收到
{ "id": 42, "result": { "result": { "type": "string", "value": "Example Domain" } } }
事件没有 id,是浏览器主动推的:
{ "method": "Page.loadEventFired", "params": { "timestamp": 123456.789 } }
域(Domain)
CDP 把功能按域划分,每个域管一摊事。爬虫里最常打交道的:
Page— 页面导航、生命周期、截图、PDFRuntime— 在页面里跑 JS、拿返回值、监听 consoleNetwork— 网络请求/响应监听,cookie 管理,header 设置Fetch— 请求拦截与改写,能力比 Network 拦截更强DOM/DOMSnapshot— DOM 查询与快照Input— 模拟鼠标键盘Target— 管理标签页、iframe、worker 这些「目标」Emulation— 模拟设备、UA、地理位置、时区
大部分域需要先发 Xxx.enable 才会开始推送事件,比如要监听网络就得先 Network.enable。这是新手最容易忘的一步。
Target 和 Session
这块是裸 CDP 比 Playwright 麻烦的地方,得讲清楚。
一个 Chrome 实例里有多个「Target」:每个标签页是一个 target,每个 iframe、Service Worker 也可能是。当你连上浏览器级别的 WebSocket 端点时,你只能发浏览器级别的命令(比如 Target.getTargets)。要操作具体某个页面,你需要 attach 到那个 target,拿到一个 sessionId,之后发给这个页面的命令都要带上这个 sessionId。
{ "id": 1, "method": "Target.attachToTarget", "params": { "targetId": "ABC123", "flatten": true } }
flatten: true 很关键,它让你能在同一个 WebSocket 连接上通过 sessionId 路由命令到不同 target,而不用为每个页面单独开连接。Playwright 帮你屏蔽了这整套逻辑,现在得自己管。
四、动手:启动并连接
启动 Chrome
用调试端口模式启动系统的 Chrome:
google-chrome \
--remote-debugging-port=9222 \
--user-data-dir=/tmp/chrome-cdp-profile \
--no-first-run \
--no-default-browser-check \
--headless=new
几个参数说明:
--remote-debugging-port=9222开启 CDP 端点--user-data-dir指定独立的用户目录,避免和你日常用的 Chrome 冲突--headless=new用新版无头模式;如果要调试,先去掉它看着浏览器跑
启动后访问 http://localhost:9222/json/version 能拿到 WebSocket 端点地址:
{
"Browser": "Chrome/120.0.0.0",
"webSocketDebuggerUrl": "ws://localhost:9222/devtools/browser/xxxx-xxxx"
}
安全提示:调试端口默认只监听 localhost,但绝对不要把 --remote-debugging-port 暴露到公网或绑定到 0.0.0.0。任何能访问这个端口的人都能完全控制你的浏览器、读取所有 cookie 和会话。生产环境放在隔离网络里,需要远程访问就走 SSH 隧道。
用 Python 连接
下面用 websockets 库写一个最小的 CDP 客户端,让你看清底层在干什么。生产中你可能会用现成的库,但理解原理很重要。
import asyncio
import json
import itertools
import httpx
import websockets
class CDPClient:
def __init__(self, ws_url: str):
self.ws_url = ws_url
self.ws = None
self._id_gen = itertools.count(1)
self._pending = {} # id -> Future,等命令返回
self._event_handlers = {} # method -> [callback]
async def connect(self):
# max_size=None 很重要:响应体可能很大,默认 1MB 上限会断开
self.ws = await websockets.connect(self.ws_url, max_size=None)
asyncio.create_task(self._receive_loop())
async def _receive_loop(self):
async for raw in self.ws:
msg = json.loads(raw)
if "id" in msg:
# 命令返回
fut = self._pending.pop(msg["id"], None)
if fut and not fut.done():
if "error" in msg:
fut.set_exception(Exception(msg["error"]))
else:
fut.set_result(msg.get("result", {}))
else:
# 事件推送
method = msg.get("method")
for cb in self._event_handlers.get(method, []):
cb(msg.get("params", {}))
async def send(self, method: str, params: dict = None, session_id: str = None):
msg_id = next(self._id_gen)
payload = {"id": msg_id, "method": method, "params": params or {}}
if session_id:
payload["sessionId"] = session_id
fut = asyncio.get_event_loop().create_future()
self._pending[msg_id] = fut
await self.ws.send(json.dumps(payload))
return await fut
def on(self, method: str, callback):
self._event_handlers.setdefault(method, []).append(callback)
async def get_browser_ws(port=9222):
async with httpx.AsyncClient() as client:
r = await client.get(f"http://localhost:{port}/json/version")
return r.json()["webSocketDebuggerUrl"]
注意 max_size=None 那行。爬虫经常要读大的 HTML 或 JSON 响应,websockets 默认限制单帧 1MB,超了会直接断连,这个坑我踩过。
打开页面并抓取内容
async def main():
ws_url = await get_browser_ws()
client = CDPClient(ws_url)
await client.connect()
# 1. 创建一个新标签页
target = await client.send("Target.createTarget", {"url": "about:blank"})
target_id = target["targetId"]
# 2. attach 拿 session
attached = await client.send(
"Target.attachToTarget",
{"targetId": target_id, "flatten": True},
)
session_id = attached["sessionId"]
# 3. 开启需要的域(注意带上 session_id)
await client.send("Page.enable", session_id=session_id)
await client.send("Runtime.enable", session_id=session_id)
# 4. 监听页面加载完成事件
load_event = asyncio.Event()
client.on("Page.loadEventFired", lambda params: load_event.set())
# 5. 导航
await client.send("Page.navigate",
{"url": "https://example.com"},
session_id=session_id)
await load_event.wait()
# 6. 跑 JS 拿渲染后的 HTML
result = await client.send(
"Runtime.evaluate",
{"expression": "document.documentElement.outerHTML", "returnByValue": True},
session_id=session_id,
)
html = result["result"]["value"]
print(html[:500])
await client.send("Target.closeTarget", {"targetId": target_id})
asyncio.run(main())
跑通这段,你就完成了 Playwright 一行 page.goto() 背后的全部工作。可以明显感觉到:自由度高了,但保姆没了。load_event.wait() 只等到 load 事件,对于 SPA 这远远不够,你得自己判断「内容真的渲染好了」——这正是 Playwright 的自动等待帮你省掉的活。
五、几个爬虫常用的高级操作
等待真正的内容(替代 Playwright 的 auto-wait)
裸 CDP 没有自动等待,最实用的做法是轮询执行 JS 检查条件:
async def wait_for_selector(client, session_id, selector, timeout=10):
expr = f"!!document.querySelector({json.dumps(selector)})"
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
r = await client.send("Runtime.evaluate",
{"expression": expr, "returnByValue": True},
session_id=session_id)
if r["result"]["value"]:
return True
await asyncio.sleep(0.1)
raise TimeoutError(f"等待 {selector} 超时")
注意用 json.dumps(selector) 把选择器安全地嵌进 JS 表达式,避免选择器里有引号导致语法错误或注入问题。所有拼进 expression 的外部字符串都该这么处理。
拦截和读取网络请求
这是 CDP 的强项。很多时候网页的数据来自 XHR/fetch 接口,与其解析渲染后的 DOM,不如直接抓接口返回的 JSON。
await client.send("Network.enable", session_id=session_id)
responses = {}
def on_response(params):
resp = params["response"]
if "/api/data" in resp["url"]:
responses[params["requestId"]] = resp["url"]
client.on("Network.responseReceived", on_response)
# 拿到 requestId 后读响应体
async def get_body(request_id):
r = await client.send("Network.getResponseBody",
{"requestId": request_id},
session_id=session_id)
return r["body"] # 若 base64Encoded 为 True 需解码
用 Fetch 域改写请求
如果你想拦截请求、改 header、甚至直接伪造响应,用 Fetch 域。它会暂停每个匹配的请求,等你决定放行、修改还是返回假数据:
await client.send("Fetch.enable",
{"patterns": [{"urlPattern": "*"}]},
session_id=session_id)
async def on_request_paused(params):
req_id = params["requestId"]
url = params["request"]["url"]
# 屏蔽图片和字体,省流量提速
if any(url.endswith(ext) for ext in (".png", ".jpg", ".woff2", ".gif")):
await client.send("Fetch.failRequest",
{"requestId": req_id, "errorReason": "BlockedByClient"},
session_id=session_id)
else:
await client.send("Fetch.continueRequest",
{"requestId": req_id},
session_id=session_id)
# 注意:on 的回调需要是同步的,这里要包一层创建 task
client.on("Fetch.requestPaused",
lambda p: asyncio.create_task(on_request_paused(p)))
屏蔽图片字体这招对纯数据抓取很有用,能显著降低带宽和加载时间。但记住,开了 Fetch.enable 之后每个请求都会暂停等你处理,漏掉某个请求不调用 continueRequest,页面就卡住了。这是另一个常见坑。
文档加载前注入脚本
要在页面任何脚本运行之前注入代码(比如覆盖 navigator.webdriver),用这个:
await client.send(
"Page.addScriptToEvaluateOnNewDocument",
{"source": "Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"},
session_id=session_id,
)
六、反爬:CDP 的优势和它自己的破绽
转向裸 CDP 的一大动机是隐蔽性,但要清楚它不是银弹。
CDP 接管系统真实 Chrome,确实少了 Playwright/Puppeteer 那些框架特征。但 CDP 本身也会留下痕迹,最典型的是:开了 Runtime.enable 之后,某些检测脚本能通过特定手段感知到调试器的存在(早期 Runtime.enable 会影响 console.debug 的行为,被一些反爬库利用)。所以在敏感场景下,有人会刻意避免开启 Runtime 域,改用其他方式执行 JS。
常见的伪装措施:
- 用真实的
--user-data-dir,带上正常的浏览历史和 cookie - 设置合理的 UA、语言、时区、屏幕分辨率(通过
Emulation域) - 避免
--headless的指纹,或用--headless=new(比老版无头特征少) - 修正
navigator.webdriver、WebGL vendor、navigator.plugins等 - 控制请求节奏,加随机延迟,模拟人类操作轨迹
说句实在话:反爬和爬虫是持续对抗,没有一劳永逸的方案。换到 CDP 给了你更多调整空间,但不代表自动隐身。请只对你有权抓取的目标做这些,遵守目标站点的 robots 协议和服务条款。
七、Playwright vs 裸 CDP,到底怎么选
把权衡摊开说清楚:
| 维度 | Playwright | 裸 CDP |
|---|---|---|
| 上手难度 | 低,API 友好 | 高,要懂协议细节 |
| 自动等待 | 内置 | 自己实现 |
| 元素定位 | 强大的 locator | 自己写 JS/DOM 查询 |
| 依赖体积 | 大(自带浏览器) | 小(一个 WS 客户端) |
| 底层控制 | 子集 | 全集 |
| 多页面/iframe 管理 | 自动 | 手动管 session |
| 反爬灵活性 | 中(需 stealth 补丁) | 高(完全可控) |
| 维护成本 | 低 | 高 |
我的建议很直接:
- 大多数项目用 Playwright。 它省下的开发和维护时间通常远超那点性能和体积收益。
- 只有当你确实被 Playwright 卡住时才下沉到 CDP,比如极致的反爬对抗、严苛的部署体积限制、或者需要某些 Playwright 不暴露的底层能力。
- 还有个折中方案常被忽略:Playwright 本身允许你拿到 CDP session(
browser_context.new_cdp_session(page)),可以在享受 Playwright 便利的同时,对个别操作直接发 CDP 命令。很多时候这才是最优解,不必非得二选一。
小结
从 Playwright 换到裸 CDP,本质是用「开发便利性」换「控制力和隐蔽性」。它能让你看清浏览器自动化的底层是怎么运作的——WebSocket 上来回的 JSON、域的启用、target 和 session 的路由、事件驱动的等待。这份理解本身就很值钱,哪怕你之后又回到 Playwright,也会写出更好的爬虫代码。
但别为了换而换。先确认你遇到的问题是 Playwright 真的解决不了的,再决定要不要承担这份额外的复杂度。如果只是想要其中某个能力,先试试 Playwright 提供的 CDP session 接口。
最后照例提醒一句:技术中立,但用途有边界。抓取请遵守目标站点的服务条款、robots 规则和相关法律法规,尊重数据所有权和用户隐私。
后记
2026年6月17日于上海,在claude opus 4.8辅助下完成。
更多推荐
所有评论(0)