用 CDP 驱动常驻 Chrome 做网页自动化:五个必踩的坑与解法
为什么要自己连 CDP
做网页自动化时,Playwright / Selenium 的默认姿势是自己拉起一个浏览器实例。但在"复用已有登录态"的场景里这条路很难走通:cookie、localStorage、设备信任记录、企业 SSO 的会话 cookie 都存在真实用户的 profile 里,复制一份 profile 往往还会因为设备指纹或会话绑定而失效。
更省事的做法是:让一个正常登录过的 Chrome 常驻,带 --remote-debugging-port=9222 启动,自动化程序只作为客户端连上去。代价是框架替你做的事都得自己写。下面五个坑是我在实际项目里逐个踩出来的,每一个都足以让脚本"看起来在跑,其实什么也没发生"。
坑一:页面在后台跑,React 根本不 hydrate
无头或后台运行的 Chrome 经常是 document.visibilityState === "hidden"、document.hasFocus() === false。这不是小问题:requestAnimationFrame 被降频甚至暂停,IntersectionObserver 不触发,React 18 的并发调度拿不到时间片。表现就是:HTML 骨架已经返回,但按钮一直是 disabled,输入框绑不上事件,页面"白屏但没报错"。
解法是两个 CDP 命令,模拟"页面在前台且有焦点":
cdp("Emulation.setFocusEmulationEnabled", enabled=True)
cdp("Page.setWebLifecycleState", state="active")
注意必须在每次导航之后重新设置一次。这两个状态是挂在当前 document 上的,Page.navigate 之后新 document 会回到默认值,这也是很多人"第一页好用、第二页就废"的原因。
坑二:element.click() 不触发 React 的事件处理
用 JS 直接调 document.querySelector(...).click(),对原生 onclick 有效,但对大量现代组件库无效。原因有两层:一是这种合成事件的 isTrusted 为 false,有些库会直接忽略;二是它只派发一个孤立的 click,没有 pointerdown → mousedown → pointerup → mouseup → click 这条完整序列,而很多组件把逻辑挂在 pointerdown 上,或者用 document 级监听配合 pointerdown/pointerup 判断是否算一次点击。
正确做法是走浏览器的输入管线,用 Input.dispatchMouseEvent 发真实鼠标事件,坐标从 getBoundingClientRect() 取:
def click(cdp, x, y):
cdp("Input.dispatchMouseEvent", type="mousePressed",
x=x, y=y, button="left", buttons=1, clickCount=1)
cdp("Input.dispatchMouseEvent", type="mouseReleased",
x=x, y=y, button="left", buttons=0, clickCount=1)
还有一个前置条件:坐标必须是视口坐标。元素如果在视口外,点击会打到别的地方。所以先 scrollIntoView({block: "center"}) 再取 rect。有些页面把 body 设成不可滚动(body.clientHeight === body.scrollHeight),这时 window.scrollTo() 完全没反应,只能靠 scrollIntoView 让容器自己滚。
坑三:点完按钮,页面跑到了新标签页里
带 target="_blank" 的按钮会创建新的 target,原来那个 session 的 Page.navigate 根本到不了。更麻烦的是新 target 是异步创建的,点完立刻查可能还查不到。
可靠的做法是对比点击前后的 target 集合,并轮询等待新 target 出现:
before = {t["targetId"] for t in cdp("Target.getTargets")["targetInfos"]
if t["type"] == "page"}
click(cdp, x, y)
new_id = wait_for(lambda: next(
(t["targetId"] for t in cdp("Target.getTargets")["targetInfos"]
if t["type"] == "page" and t["targetId"] not in before), None))
拿到新 targetId 之后用 Target.attachToTarget(targetId=..., flatten=True) 拿到 sessionId,之后每条消息带上 sessionId,就能在一个 WebSocket 里同时操作多个标签页。flatten=True 是关键,不加的话 CDP 会另开一条 WebSocket,消息路由要自己维护。
坑四:等错了事件,SPA 永远"加载不完"
Page.loadEventFired 对单页应用几乎没有意义——它只代表初始 HTML 和静态资源加载完,此时接口还没回,组件还没渲染。等 networkidle 也不稳:有轮询或长连接的应用永远不会 idle。
比较稳的是"轮询目标状态",等一个具体的、可观测的条件,而不是等一个全局事件:
def wait_for(fn, timeout=20, interval=0.3):
deadline = time.time() + timeout
while time.time() < deadline:
try:
v = fn()
if v:
return v
except Exception:
pass # 元素还没挂载,evaluate 抛错是正常的
time.sleep(interval)
raise TimeoutError("condition not met")
条件可以是"某个选择器出现"、"某段文本变化"、"某个按钮不再是 disabled"。判断条件尽量选业务状态,别选 DOM 数量这类易变的东西。
坑五:WebSocket 没有超时,一个慢请求挂死整个循环
自己写 CDP 客户端最容易忽略的一点:ws.recv() 默认是阻塞的。当页面在跑一个重接口、或者 target 崩了,recv() 会一直挂着,整个脚本静默卡死,日志里什么都没有。
两个习惯:建连时设 timeout,并且把"发消息-收消息"封装成按 id 匹配的循环,把无关的事件消息(method 字段)丢掉,别让它们打乱应答匹配:
import itertools, json, websocket
class CDP:
def __init__(self, ws_url, timeout=30):
self.ws = websocket.create_connection(ws_url, timeout=timeout)
self.ids = itertools.count(1)
self.session = None
def call(self, method, **params):
mid = next(self.ids)
msg = {"id": mid, "method": method, "params": params}
if self.session:
msg["sessionId"] = self.session
self.ws.send(json.dumps(msg))
while True:
data = json.loads(self.ws.recv())
if data.get("id") != mid:
continue # 事件或别的请求的应答,跳过
if "error" in data:
raise RuntimeError(data["error"])
return data.get("result", {})
入口地址从 http://127.0.0.1:9222/json/version 的 webSocketDebuggerUrl 拿,不要硬编码,Chrome 每次启动这个路径都可能变。
小结
这五个坑的共同点是一样的:浏览器自动化的失败大多是静默的。页面没报错、脚本没异常,只是没效果。所以判断成功不能靠"命令返回了 result",要靠一个业务侧可观测的结果——按钮文案变了、列表里多了一条、接口返回了新的 id。把"等待 + 断言"当成流程的一等公民,比任何技巧都重要。
最后提醒一句:这类自动化只应作用于自己的账号和已获授权的系统。遇到验证码、人脸识别、二次验证,正确的做法是停下来交给人,而不是想办法绕过——绕过验证码既不可靠,也越过了边界。
- 点赞
- 收藏
- 关注作者
评论(0)