从 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 — 页面导航、生命周期、截图、PDF
  • Runtime — 在页面里跑 JS、拿返回值、监听 console
  • Network — 网络请求/响应监听,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 sessionbrowser_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辅助下完成。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐