Playwright 接管已登录的 Chrome:connect_over_cdp 的实测细节与五个坑
背景:为什么我要「接管」一个已经在跑的浏览器
做自动化时最烦的不是写选择器,而是登录。手机验证码、扫码、二次确认,任何一步都需要人;而这些会话又偏偏存在浏览器里,脚本 launch() 出来的干净实例一个都没有。
我维护了一台无显示器的 Linux 机器,上面长跑着一个有头 Chrome(Xvfb + noVNC),人通过网页 VNC 手动登录,脚本通过 CDP(Chrome DevTools Protocol)接管同一个实例。跑了一个多月,踩到的坑都在「连接」这一层,而不是业务逻辑里。下面这几条都是在本机实测的数字,环境是 Chrome 156.0.8078.4 / CDP Protocol 1.3 / Playwright 1.64 / Python 3.12。
一、前提:浏览器必须以调试端口启动
CDP 只能连上「启动时开了门」的进程。启动参数里要有:
chrome --remote-debugging-port=9222 \
--remote-debugging-address=127.0.0.1 \
--user-data-dir=/root/.remote-chrome
--remote-debugging-address 默认只监听回环,这正好:不要把 9222 暴露到公网,它等同于把浏览器完全交出去——任何能连上的人都能读你的 cookie、执行任意 JS。外面要看画面就用 noVNC,看页面用 VNC 或截图。
连上之后先确认身份,别急着操作:
sess = browser.new_browser_cdp_session()
print(sess.send("Browser.getVersion")["product"]) # Chrome/156.0.8078.4
二、连接本身很便宜,别为它做缓存
实测在同一个局域网/回环下:
connect_over_cdp()到连上:305 mscontext.pages里再new_page():102 ms
也就是说连接开销基本等于一次 HTTP 请求,每次脚本跑起来重新连一遍完全没问题,不需要搞「长连接守护进程」。真正贵的是页面加载:同一个页面 goto(wait_until="domcontentloaded") 花了 2067 ms,而这还只是开始(见坑二)。
三、坑一:browser.close() 不会关掉用户的窗口
这一点反直觉但很重要。通过 CDP 连上去的 browser 对象只是「一条连接」,browser.close() 断的是连接,不是进程。实测调用之后 systemctl is-active 仍是 active,curl 127.0.0.1:9222/json/version 照常返回。
好消息是脚本崩溃也不会把用户的浏览器带走;坏消息是——你关不掉它。要真正重启浏览器得走服务管理(systemctl restart),而重启会丢会话 cookie(见坑四),所以别在任务跑到一半时重启。
四、坑二:domcontentloaded 不等于页面可用
这是最容易导致「脚本时好时坏」的一条。现在的前端基本都是 SPA,domcontentloaded 触发时 HTML 骨架刚到,数据还在路上。同一个成长中心页面,我在 domcontentloaded 之后每秒采样一次 DOM 节点数和目标卡片数:
| 时刻 | innerText 长度 | DOM 节点 | 目标卡片 |
|---|---|---|---|
| +0 s | 0 | 36 | 0 |
| +1 s | 106 | 123 | 0 |
| +2 s | 939 | 557 | 8 |
| +3 s | 1725 | 1679 | 15 |
| +4 s | 1725 | 1679 | 15 |
注意第 0 秒 innerText 是 0——不是「少」,是一个字符都没有,此时任何文本匹配都会得出「页面没有这个按钮」的错误结论。而到第 3 秒节点数翻了 46 倍。
所以判断页面「好了没有」不能靠固定睡眠,要靠可观测条件:
# 差:固定睡眠,机器慢一点就翻车
page.wait_for_timeout(3000)
# 好:等到真正需要的元素出现
page.wait_for_function("() => document.querySelectorAll('.cardItem').length >= 8", timeout=30000)
反过来,如果某个列表是「点展开才加载更多」,就得先点再等数量变化,而不是等固定时间——同一页面上第二次加载往往比第一次快,固定睡眠纯属浪费。
五、坑三:别假设只有一个 context
connect_over_cdp 拿到的 browser.contexts 里是用户真实在用的上下文,带着他登录出来的全部 cookie。实测这个实例 contexts 长度恒为 1。
如果你顺手写了 browser.new_context(),得到的是一个干净的、没有任何登录态的上下文——表现就是「明明连上了却处处要我登录」。正确姿势:
ctx = browser.contexts[0] # 复用真实上下文
page = ctx.new_page() # 自己开一个标签干活
...
page.close() # 干完自己关
这条「自己开的自己关」不是洁癖。长期运行下,脚本留下的重复标签会累积:我这边实测过一次跑到 20 个标签页、27 个进程、RSS 5.6 GB,清理到 1 个标签后 RSS 回落到 1.5 GB 左右。定时任务里最好再配一条「保留白名单标签、其余全关」的回收任务,每天跑一次。
六、坑四:会话 cookie 的 expires=-1 救不了重启
context.cookies(url) 读到的会话 cookie,expires 是 -1(我这边某域名 35 个 cookie 里带会话态的就静态标记为 -1 的那几个)。这类 cookie 只存在于内存,Chrome 一退出就没了——profile 目录里不会留下它们。
如果你的浏览器会因为 OOM、升级、手动重启而重启,登录态就跟着丢。想让「重启不掉登录」,得把会话 cookie 改成有明确过期时间再写回运行中的浏览器,让它落盘:
ck = ctx.cookies()
for c in ck:
if c.get("expires", -1) == -1:
c["expires"] = time.time() + 30*86400
ctx.add_cookies(ck)
但请注意这只是「延长寿命」,不是「永久登录」:服务端的会话该过期还是会过期(我这边实测约 20 小时),而且一旦服务端会话死了,一个还被浏览器长期保存的旧 cookie 反而可能让站点在「有会话 → 校验失败 → 弹回登录」之间循环。所以续命逻辑要配一个「先验证再回写」的开关:验证不通过就把这批 cookie 删掉,别硬塞。
七、坑五:等待失败要能「看得见」
无人值守的脚本最怕静默失败。我的做法是给每个关键步骤留一个明确的判定信号,而不是「没抛异常就算成功」。例如点击提交按钮之后:
- 有请求事件:
page.on("request")收到目标 POST; - 无跳转站点:页面出现成功文案;
- 两者都没有:就是失败,绝不重试提交(重试容易产生重复数据)。
还有一种反直觉的情况:page.evaluate() 抛 Execution context was destroyed, most likely because of a navigation,这通常意味着提交成功、页面正在跳转,不是错误。把这个异常当成「疑似成功」再去核对结果,比当成失败重发一次安全得多。
小结
- 连接很便宜(~300 ms),别过度设计;
browser.close()只断连接,杀不掉进程,也关不掉用户的窗口;- SPA 下
domcontentloaded时innerText可能为 0,等待要等条件不要等时间; - 务必用
contexts[0],new_context()会得到一个没有登录态的干净上下文; - 会话 cookie 只活到进程退出,要持久化得改写
expires,并且「先验证再回写」; - 自己开的标签自己关,配合定时回收。
这套组合让「人手动登录一次、脚本自动干活」变得可行,剩下的最大不确定性就不在代码里,而在服务端会话的有效期上了。
- 点赞
- 收藏
- 关注作者
评论(0)