用 CDP 驱动常驻 Chrome:轻量网页自动化的实战与四个坑

举报
yd_297054392 发表于 2026/10/28 03:16:29 2026/10/28
【摘要】 用 Chrome DevTools Protocol 驱动常驻 Chrome 的最小实现(60 行),以及 WebSocket 403、/json/new 405、页面 hidden 不 hydrate、click() 不触发 React 事件四个真实坑的报错与解法。

日常要处理一些必须真实点击才能完成的网页流程(后台系统、内部平台、带登录态的管理页)。这类场景用 Selenium / Playwright 也能跑,但它们每次都要新起一个浏览器实例,登录态、信任设备、localStorage 全丢,每次重登的代价往往比流程本身还大。更麻烦的是内存:在一台 2 核 1.7 GB 的小机器上,常驻一个 Chrome 已经吃掉 700 MB,再叠加 driver 进程就很吃紧。

换一个思路:把 Chrome 当成一个常驻服务,用 Chrome DevTools Protocol(CDP)直接驱动它。下面是一份能跑通的最小实现,以及过程中踩到的四个真实坑——每一个都有报错原文和解决办法。

一、把 Chrome 变成一个常驻服务

用 systemd 用户服务拉起 Chrome,关键是 --remote-debugging-port 和固定的 --user-data-dir,后者保证登录态落在磁盘上、重启不丢:

ExecStart=/usr/bin/google-chrome \
  --headless=new \
  --remote-debugging-port=9222 \
  --user-data-dir=%h/.chrome-profile \
  --no-first-run --disable-dev-shm-usage

起之后先用 HTTP 端点确认它还活着:

$ curl -s http://127.0.0.1:9222/json/version
{
   "Browser": "Chrome/145.0.7632.6",
   "Protocol-Version": "1.3",
   "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/37daf113-71f1-431f-..."
}

这个 webSocketDebuggerUrl 就是浏览器级的控制入口:建标签页、关标签页都走它;单个页面里的执行 JS、点鼠标则走「页面级会话」。

二、最小可用客户端

只需要 websocket-client 一个依赖,其余全用标准库。核心是三个方法:send 发指令、js 执行表达式、click 做真实鼠标点击。

import json, time, urllib.request
import websocket  # pip install websocket-client

HOST = "http://127.0.0.1:9222"

def http_json(path):
    with urllib.request.urlopen(HOST + path, timeout=10) as r:
        return json.load(r)

class Browser:
    """连到浏览器级 WebSocket:负责开关标签页。"""
    def __init__(self):
        # suppress_origin=True 很关键:Chrome 会拒绝带 Origin 头的 WS 握手(403)
        self.ws = websocket.create_connection(
            http_json("/json/version")["webSocketDebuggerUrl"],
            timeout=30, suppress_origin=True)
        self.i = 0

    def _send(self, method, **params):
        self.i += 1
        self.ws.send(json.dumps({"id": self.i, "method": method, "params": params}))
        while True:
            msg = json.loads(self.ws.recv())
            if msg.get("id") == self.i:
                if "error" in msg:
                    raise RuntimeError(msg["error"])
                return msg["result"]

    def new_tab(self, url):
        tid = self._send("Target.createTarget", url="about:blank")["targetId"]
        sid = self._send("Target.attachToTarget", targetId=tid, flatten=True)["sessionId"]
        return Tab(self.ws, tid, sid, url)

页面级会话复用同一条连接,靠 sessionId 区分标签页:

class Tab:
    def __init__(self, ws, tid, sid, url):
        self.ws, self.tid, self.sid, self.i = ws, tid, sid, 0
        self.send("Page.enable")
        self.send("Page.navigate", url=url)
        time.sleep(3)
        # 常驻 headless Chrome 的标签页常处于 hidden,React/Next 页面不会 hydrate
        self.send("Emulation.setFocusEmulationEnabled", enabled=True)
        self.send("Page.setWebLifecycleState", state="active")
        time.sleep(1)

    def send(self, method, **params):
        self.i += 1
        self.ws.send(json.dumps({"id": self.i, "method": method,
                                 "sessionId": self.sid, "params": params}))
        while True:
            msg = json.loads(self.ws.recv())
            if msg.get("id") == self.i:
                if "error" in msg:
                    raise RuntimeError(msg["error"])
                return msg["result"]

    def js(self, expr):
        r = self.send("Runtime.evaluate", expression=expr,
                      returnByValue=True, awaitPromise=True)
        return r["result"].get("value")

    def click(self, selector):
        """真实鼠标点击:先 scrollIntoView 取坐标,再按下 + 抬起"""
        x, y = self.js("""(() => {const e = document.querySelector('%s');
            e.scrollIntoView({block:'center'});
            const b = e.getBoundingClientRect();
            return [b.x + b.width/2, b.y + b.height/2];})()""" % selector)
        for t in ("mousePressed", "mouseReleased"):
            self.send("Input.dispatchMouseEvent", type=t, x=x, y=y,
                      button="left", clickCount=1)
        return round(x), round(y)

三、四个真实坑

坑 1:WebSocket 握手被 403 拒绝

第一次连就失败,报错写得很直白:

websocket._exceptions.WebSocketBadStatusException: Handshake status 403 Forbidden
Rejected an incoming WebSocket connection from the http://127.0.0.1:9222 origin.
Use the command line flag --remote-allow-origins=... to allow connections from this origin

原因是 websocket-client 默认会带上 Origin 请求头,而新版 Chrome 会校验它。两个解法:建连时传 suppress_origin=True,或者给 Chrome 加 --remote-allow-origins=*。前者更干净,不用改服务启动参数。

坑 2:/json/new 返回 405

很多教程让你用 GET /json/new?url=... 开标签页。在 Chrome 145 上实测,GET 和 PUT 都返回 HTTP Error 405: Method Not Allowed。别在这条路上耗时间,直接走浏览器级 WebSocket 的 Target.createTarget + Target.attachToTarget(flatten=True),一次拿到 sessionId,比 HTTP 端点可靠得多。

坑 3:页面永远是 hidden,React 页面不 hydrate

这个坑最隐蔽:页面能拿到 HTML,但按钮全是 disabled、点下去没反应、整个正文区是空的。原因是常驻 headless 实例里 document.visibilityState 是 hidden,React / Next 的流式渲染会一直挂起。

解法是导航后立刻补两条指令:

tab.send("Emulation.setFocusEmulationEnabled", enabled=True)
tab.send("Page.setWebLifecycleState", state="active")

每次导航后都要重设,否则新页面又会掉回 hidden。判断有没有踩到这个坑,直接读 tab.js("document.visibilityState")。

坑 4:element.click() 不触发 React 处理器

用 Runtime.evaluate 执行 document.querySelector(btn).click(),在某些页面上完全没反应,也不报错。原因是这类站点的事件挂在 React 的合成事件系统上,需要真实的 pointer / mouse 事件序列才能触发。

解法就是上面 click() 的写法:先用 scrollIntoView 把元素挪进视口(很多后台页面 document 本身不可滚动,只能靠它),再用 getBoundingClientRect 取中心坐标,发 Input.dispatchMouseEvent 的 pressed + released。顺带一提,取到坐标后最好再补一句 document.elementFromPoint(x, y) 复核落点——页面上经常有透明的模态遮罩盖在上面,点击会静默失效。

四、跑一遍

$ python cdp_demo.py
browser: Chrome/145.0.7632.6
url      : https://example.com/
title    : Example Domain
visible  : visible
clicked  : (632, 712)
after    : https://www.iana.org/help/example-domains

最后一行说明真实点击生效了:点的是 example.com 上的那个链接,跳转到了 IANA。visible: visible 说明坑 3 的两条指令起了作用。

小结

CDP 的思路并不新,但它把「自动化」从「每次新开一个浏览器」变成「连上一个已经在跑的浏览器」,这对需要登录态、需要信任设备的流程是质变。代价是你要自己处理上面这些细节——不过踩过一次之后,60 行代码就够用了。

最后提醒一句:这套东西适合自动化自己的重复操作。用在别人站点上的批量行为之前,先看看对方的 robots 和服务条款。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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