用 CDP 驱动常驻 Chrome:轻量网页自动化的实战与四个坑
日常要处理一些必须真实点击才能完成的网页流程(后台系统、内部平台、带登录态的管理页)。这类场景用 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 和服务条款。
- 点赞
- 收藏
- 关注作者
评论(0)