Playwright 复用登录态的三个坑:从反复扫码到 CDP 常驻浏览器

举报
yd_235984788 发表于 2026/10/28 09:10:24 2026/10/28
【摘要】 会话级 cookie 不落盘、同一 profile 的单实例锁、headless 与 headed 的指纹差异——这三个问题让登录态复用反复失败。本文讲清各自的报错表现,并给出用 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 里,所以光改代码改不好。

把浏览器当成一个独立服务来管理,脚本只负责"连上去干活",是这几个问题里投入产出比最高的解法。至于登录态过期——那是业务现实,不是技术问题,老老实实做检测、做提示就行了。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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