用 Python 直连 Chrome DevTools Protocol:比 Selenium 更省心的网页自动化

举报
yd_257742204 发表于 2026/11/03 07:59:06 2026/11/03
【摘要】 从登录态复用、Runtime.evaluate 探针、真实鼠标输入,到点击后必须回读验证——一份跑了几个月换来的 CDP 自动化实战笔记。

为什么放弃 Selenium

过去两年我用 Selenium 做过不少重复性网页操作:批量下载报表、给内部系统补录数据、每天在几个开发者平台打卡。真正跑起来才发现,最麻烦的不是写定位表达式,而是三件事:

  1. 每次启动都要新开一个干净的浏览器,登录态得靠 cookie 注入或者手动扫码;
  2. 无头模式下容易触发风控,很多站点能识别 navigator.webdriver;
  3. 调试周期长,脚本一报错就得从头重跑。

直到有一次我需要在一台已经登录好的 Chrome 上做自动化,才发现 Chrome 自带了一个更底层的通道:DevTools Protocol(简称 CDP)。Chrome 只要带 --remote-debugging-port 启动,就会开一个本地 HTTP + WebSocket 服务,你连上去就能像开发者工具一样读取和操作页面。

第一步:把 Chrome 变成可编程的

启动参数很简单:

chrome --remote-debugging-port=9222 --user-data-dir=D:/chrome-profile --no-first-run

--user-data-dir 指定独立的用户数据目录,这样登录态会持久化在磁盘上,脚本重启浏览器也不用重新登录。这一条对长期运行的自动化脚本价值最大——相当于把「人已经登录好的浏览器」变成脚本的运行时。

连上去之后先访问 http://127.0.0.1:9222/json/version,能拿到 browser 级别的 WebSocket 地址;再访问 /json/list 拿到所有标签页,每个标签页都有自己的 webSocketDebuggerUrl。

import requests
tabs = requests.get("http://127.0.0.1:9222/json/list").json()
for t in tabs:
    if t["type"] == "page":
        print(t["id"], t["url"])

这里有个很实用的细节:一个页面 target 的 WebSocket 地址是稳定的,只要不关标签页就能一直复用,不需要每次重新 attach。

第二步:Runtime.evaluate 是最强的探针

连上标签页后,绝大多数需求一句话就能解决——执行任意 JS 并拿到返回值:

from websocket import create_connection
ws = create_connection(tab["webSocketDebuggerUrl"])
ws.send(json.dumps({
    "id": 1, "method": "Runtime.evaluate",
    "params": {"expression": "document.title", "returnByValue": True}
}))

这个能力比 Selenium 的 find_element 强太多,因为你可以直接写业务判断。例如「找出所有可见的按钮及其中心坐标」:

[].slice.call(document.querySelectorAll('button'))
  .filter(e => e.getBoundingClientRect().width > 0)
  .map(e => {
    const r = e.getBoundingClientRect();
    return {text: e.innerText.trim(), x: r.x + r.width/2, y: r.y + r.height/2};
  })

注意那个 width > 0 过滤。很多前端框架(Element、Ant Design、TDesign)会把同一份 DOM 渲染两份:一份可见、一份为了做动画或占位而 display:none。不过滤就会拿到一堆 width=0, height=0 的幽灵元素,坐标永远是 (0,0),点下去自然毫无反应。

第三步:真实点击和「假点击」的区别

这是踩坑最多的地方。用 element.click() 在大多数页面能触发事件,但有两类场景会失效:

  • 依赖 isTrusted 的页面:有些站点在事件处理器里判断 event.isTrusted,合成事件一律忽略;
  • Canvas / 自绘组件:根本没有可点的 DOM,只有坐标。

这两种情况都得用 CDP 的 Input.dispatchMouseEvent 发真实输入事件,参数里必须把 mousePressed 和 mouseReleased 配成一对,buttons 与 button 要对得上:

for typ, buttons in (("mouseMoved", 0), ("mousePressed", 1), ("mouseReleased", 0)):
    ws.send(json.dumps({"id": n, "method": "Input.dispatchMouseEvent",
                        "params": {"type": typ, "x": x, "y": y,
                                   "button": "left", "buttons": buttons,
                                   "clickCount": 1}}))
    n += 1

键盘同理。很多人以为 Input.insertText 就等价于打字,其实它只往输入框塞字符,不产生 keydown/keyup。对于 React 这类受控组件,有些逻辑只在 keyup 里跑(比如搜索联想、表单校验),只 insertText 会导致「文字进去了但按钮还是灰的」。

我的经验是:点击聚焦 → insertText 填值 → 再补一次 Enter 的 keyDown/char/keyUp,三步走最稳。Enter 的 windowsVirtualKeyCode 是 13,text 字段给 \r。

第四步:点击之后必须回读验证

这是我损失时间最多的一课。dispatchMouseEvent 永远返回成功——它只保证事件发出去了,不保证被谁接收、也不保证业务逻辑成立。

所以每次点击后都应该重新读一次页面状态:

def click_and_verify(ws, x, y, expr, expect):
    click(ws, x, y)
    time.sleep(1.5)
    val = evaluate(ws, expr)
    return val == expect

举个真实例子:给代码托管平台的仓库点 Star,按钮外层是容器、内层还有个显示数量的徽标(badge)。按徽标坐标点,事件冒泡到容器时会被容器的点击处理器拦掉,状态不变;必须点最外层容器的矩形中心,点完再读 .status-btn__text 是不是从 Star 变成 Starred,数字有没有 +1。没有这个断言,你会以为自己成功了,直到第二天发现积分没到账。

第五步:iframe 是另一个世界

跨源的 iframe(比如嵌在门户里的在线 IDE、聊天窗口)在主页面的 Runtime.evaluate 里完全不可见。正确做法是先拿 frame tree:

ws.send(json.dumps({"id": 1, "method": "Page.getFrameTree"}))
# 从 result 里取出 childFrames[i].frame.id
ws.send(json.dumps({"id": 2, "method": "Page.createIsolatedWorld",
                    "params": {"frameId": fid, "worldName": "probe"}}))
# 拿回 executionContextId,之后 evaluate 时带上 contextId

有个细节值得记住:iframe 内部元素的坐标是相对 iframe 视口的,如果 iframe 不在 (0,0),真实点击的坐标要加上偏移。反过来,如果 iframe 正好铺满整个视口,内部坐标就等于页面坐标,直接用即可。

关于稳定性

跑了几个月,我把稳定性问题归成三类:

  1. 超时。CDP 的 WebSocket 在页面导航、渲染进程崩溃时会断开,脚本必须能重连,而不是抛异常退出。
  2. WS 地址必须用完整 ID。很多人习惯用前 8 位短 ID 做匹配,但 WebSocket URL 里必须是完整的那一长串,否则连不上,报的还是个看不出原因的握手异常。
  3. 字符串转义。往 Runtime.evaluate 里塞 JS 时,如果你的宿主语言(Python、PowerShell)先做了一层转义,正则里的 \s、\n 很可能变成非法字符,报 SyntaxError: Invalid regular expression。我的做法是在 JS 里避免正则,改用 String.fromCharCode(10) 分割行。

什么时候不该用 CDP

最后一句实话:如果站点提供了 API,就用 API。

CDP 自动化的本质是「模拟人」,它慢、依赖页面结构、随前端改版而失效。我现在处理同一个平台时,会先抓一次页面自身的 XHR,看有没有稳定的接口可以直接调——有接口就掉头走接口,页面自动化只留给那些「确实只有点击这一条路」的场景。

CDP 的价值不在于替代 API,而在于填补 API 的空白:那些没开放接口、但人点几下就能完成的事,现在可以交给脚本了。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。