Playwright 复用登录态的三个坑:从反复扫码到 CDP 常驻浏览器
用 Playwright 做需要登录的自动化,最耗时间的往往不是写选择器,而是登录态。脚本每次跑都要重新扫码、headless 模式跑起来一律跳登录页、两个脚本同时跑必有一个崩——这些问题的根因都不在 Playwright,而在浏览器本身。
这篇把踩过的三个坑和解法写清楚:为什么会这样、报错长什么样、怎么绕过去。
一、先搞清楚:登录态到底存在哪里
浏览器里的"登录态"至少分三层,三者失效时机完全不同:
1. Cookie:分会话级(session cookie,不带 Expires/Max-Age)和持久化(带过期时间)两种。会话级 cookie 只存在于内存,浏览器进程退出即销毁。
2. localStorage / IndexedDB:随 profile 目录落盘,一般比 cookie 活得久。
3. 服务端会话:即使客户端 cookie 还在,服务端也可能因为超时、单点登录换票、异地登录而作废。
很多人遇到的"明明磁盘上有 cookie,跑起来还是要登录",多数是第 1 层——站点发的是会话级 cookie。用 context.cookies() 看一眼就能确认:
for c in context.cookies():
if c["expires"] == -1:
print("session cookie:", c["name"], c["domain"])
expires == -1 就是会话级。这类 cookie 不会因为 user_data_dir 持久化就一定留得下来——Chrome 在异常退出时根本来不及写盘。
二、常规做法,以及它们的天花板
方案 A:launch_persistent_context(user_data_dir=...)
context = p.chromium.launch_persistent_context(
user_data_dir="/path/to/profile",
headless=False,
)
这是官方推荐做法,能保留 cookie、localStorage、扩展、缓存。天花板在于:它和手动打开的 Chrome 抢同一个目录。
方案 B:storage_state 导出导入
context.storage_state(path="state.json") # 导出
context = browser.new_context(storage_state="state.json") # 导入
好处是轻量、可进 CI。但它把这些东西存成一份 JSON,指纹信息(UA、屏幕、语言、WebGL 渲染器)并不随之迁移。凡是做过设备指纹校验的站点(尤其带 SSO 的),新 context 大概率直接判未登录。它适合"自己写的后台系统",不适合对指纹敏感的公网站点。
三、坑一:同一 profile 不能多进程打开
user_data_dir 一个目录同时只能被一个 Chrome 实例持有。Chrome 会在目录里放 SingletonLock、SingletonCookie、SingletonSocket 三个文件做互斥。
现象通常很迷惑——不是报"目录被占用",而是:
Target page, context or browser has been closed
或者进程直接退出、一个日志都没有。
原因是:第二个实例发现锁存在,把自己"移交"给已有实例后退出,Playwright 等不到可用的页面就抛错。
判断方法:看目录里有没有 SingletonLock:
ls -la "$PROFILE_DIR" | grep Singleton
解法:要么串行化(一把文件锁),要么干脆别让 Playwright 自己启动浏览器——见第五节的 CDP 方案。
四、坑二:headless 与 headed 不共享"登录态"
同一条 profile,headless=False 登录成功,改成 headless=True 又要登录。原因有两层:
1. profile 被占用后静默降级。上一个 headed 实例还没退干净,headless 启动时发现目录被锁,可能以临时目录启动——你的 cookie 一条都没带上,看起来像"headless 不保存登录态"。
2. 指纹差异触发风控。headless 的默认 UA 带 HeadlessChrome,navigator.webdriver 为 true,屏幕尺寸、WebGL 渲染器也和真实浏览器不同。做过风控的站点会直接把你踢回登录页,而不是报错。
所以"headless 登录不上"多数不是 Playwright 的 bug,是站点识别出你不是正常浏览器。
解法:
- 先确保 profile 没被占用(坑一的解);
- 需要 headless 时用新版 headless(
channel="chromium"或--headless=new),UA 与真实浏览器更接近; - 别指望靠改 UA 骗过所有站点——有些指纹项改不动。
五、坑三:沙箱与权限把 Chrome 直接杀掉
在受限环境(容器、macOS 沙箱、CI 的 seccomp)里启动 Chrome,常见两种死法:
- 进程起不来:
zygote_host_impl_linux.cc报Failed to move to new namespace,加--no-sandbox能过; - 进程被外部杀掉:macOS 上 Chrome 启动时会复制并重新签名自己的可执行文件,如果沙箱禁止 unlink 临时目录,Chrome 会直接退出。
第二种尤其坑——Playwright 只会告诉你"浏览器已关闭",真正的错误在系统日志里。排查思路是看进程退出码和系统日志,而不是盯着 Python 堆栈。
六、方案:让浏览器常驻,Playwright 只做"接管"
绕开上面三个坑最省事的办法,是把浏览器生命周期和脚本解耦:Chrome 起着别关,脚本用 CDP 连上去。
第一步,手动(或由脚本)启动带调试端口的 Chrome:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--remote-debugging-port=9333 \
--user-data-dir="$HOME/.cache/my-profile" \
--no-first-run --no-default-browser-check
两点必须注意:
--user-data-dir要独立,不要用默认 profile。Chrome 从 136 起出于安全考虑,禁止对默认 profile 开远程调试,端口根本不会监听。- 端口只监听
127.0.0.1,别暴露到0.0.0.0。
第二步,脚本连上去:
from playwright.sync_api import sync_playwright
CDP = "http://127.0.0.1:9333"
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp(CDP)
context = browser.contexts[0] # 复用已有的 context
page = context.pages[0] if context.pages else context.new_page()
page.goto("https://example.com/dashboard")
print(page.title())
connect_over_cdp 有几个和 launch 不一样的地方,值得提前知道:
| 行为 | launch / launch_persistent_context |
connect_over_cdp |
|---|---|---|
| 生命周期 | Playwright 管,脚本结束就关 | 不归你管,脚本退出浏览器还在 |
browser.close() |
关浏览器 | 只断开连接,浏览器继续跑 |
| 登录态 | 靠 profile | 靠那个一直在跑的实例 |
| 调试 | 难 | 直接看窗口,随时手动接管 |
注意:browser.close() 在 CDP 模式下不会关闭浏览器(Playwright 的行为是断开连接)。想真正关掉,得走 HTTP 接口:
import requests
requests.get("http://127.0.0.1:9333/json/close/" + page._impl_obj._guid)
# 或者干脆 kill 掉 Chrome 进程
七、这套方案的收益和代价
收益:
- 登录一次,后续所有脚本复用,扫码成本从"每跑一次"降到"每天一次";
- 出问题时能直接看窗口、开 DevTools 手动复现,比 headless 黑盒调试快得多;
- 多个脚本可以并发连同一个浏览器(各自 new_page),不用抢 profile 锁。
代价:
- 浏览器是个长驻进程,机器重启就没了,得有个"启动检查";
- 会话过期是必然的(服务端有自己的超时),脚本要能识别"掉登录"并主动提示,而不是瞎跑;
- profile 目录里有 cookie 和 localStorage,绝不能提交到 git。
八、一个实用的开工检查
脚本开头花 3 行做健康检查,比跑到一半失败强:
import requests
def ensure_browser(cdp="http://127.0.0.1:9333"):
try:
v = requests.get(f"{cdp}/json/version", timeout=3).json()
print("Chrome", v["Browser"], "ready")
return True
except Exception:
print("浏览器未运行,请先启动带 --remote-debugging-port 的 Chrome")
return False
登录态是否还有效,则要看页面本身,不能只看 cookie 是否存在:
html = page.evaluate("() => document.documentElement.innerHTML")
logged_in = "退出登录" in html # 比读 innerText 可靠:菜单项常常是隐藏的
这一点很关键。很多"以为自己还登着"的误判,是因为 innerText 读不到悬浮菜单里的"退出登录",而 innerHTML 能读到。
小结
三个坑的根因分别是:cookie 的存储层级、Chrome 的目录互斥锁、外部环境的进程约束。它们都不在 Playwright 的 API 里,所以光改代码改不好。
把浏览器当成一个独立服务来管理,脚本只负责"连上去干活",是这几个问题里投入产出比最高的解法。至于登录态过期——那是业务现实,不是技术问题,老老实实做检测、做提示就行了。
- 点赞
- 收藏
- 关注作者
评论(0)