Playwright + CDP:接管已登录的浏览器,别再跟 Cookie 死磕
做浏览器自动化,最烦的不是定位元素,是登录态。脚本跑一次好好的,第二次就 403 not login,翻半天日志才发现 Cookie 过期了,或者压根没落盘。
这篇把我踩过的坑和最后跑通的方案写清楚,代码可以直接抄。
一、先搞清楚登录态为什么丢了
浏览器里的 Cookie 分两种:
- 持久 Cookie:带
expires/max-age,写进磁盘,重启还在。 - 会话 Cookie:只有
session,进程一退就没了。
华为云、大多数 SSO 系统用的恰恰是后者。你手动登录完,关掉窗口,那个 IAMIdentity 之类的东西就一起蒸发了。
Playwright 的 browser.close() 和 context.close() 都会走正常的浏览器退出流程,会话 Cookie 同样被清掉。所以「第一次能跑,第二次不行」几乎是必然。
二、方案 A:持久化 user data dir
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
r"D:\auto\profile",
channel="chrome",
headless=False,
locale="zh-CN",
)
page = ctx.pages[0]
page.goto("https://example.com/login")
input("登录完成后回车…")
# 后续操作都在这个 ctx 里做
launch_persistent_context 会把整个 profile 写到磁盘,重启进程后 Cookie、localStorage、IndexedDB 都在。
注意两点:
1. channel="chrome" 用系统装的 Chrome,比 Playwright 自带的 Chromium 兼容性好,很多站点的风控认这个 UA。
2. 同一个 user_data_dir 不能同时被两个 Chrome 实例占用,跑之前先把窗口关干净。
三、方案 B:CDP 接管,进程不关就不会丢
方案 A 有个前提:你得在一个进程里把活干完。如果要做多步、跨很多次调用,更稳的是让 Chrome 常驻,用 CDP 连上去。
先启动带调试端口的 Chrome:
"C:\Program Files\Google\Chrome\Application\chrome.exe" ^
--remote-debugging-port=9222 ^
--user-data-dir="D:\auto\profile" ^
--no-first-run ^
--no-default-browser-check
然后任何脚本、任何时候都能连:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.connect_over_cdp("http://127.0.0.1:9222")
ctx = browser.contexts[0]
page = ctx.pages[0] if ctx.pages else ctx.new_page()
print(page.url)
# 这里拿到的就是你手动登录过的那个会话
connect_over_cdp 不会新建浏览器,只是接管已有的。你手动点了登录,脚本立刻就能用;脚本跑完 Browser 也不关,人还能接着操作。两边交替干活,这是最舒服的形态。
Chrome 156 之后有个变化:默认 profile 加 --remote-debugging-port 会被拒绝,必须显式给一个 --user-data-dir。上面那条命令已经带上了。
四、页面里的 fetch 被劫持了怎么办
有些站点会注入 SDK 劫持 window.fetch,做一些埋点或者代理转发。这时候在 page.evaluate 里直接 await fetch(...),可能直接抛异常,堆栈还指向一堆压缩过的 sourcemap,很难查。
换成 XMLHttpRequest 最省事:
new Promise(res => {
const x = new XMLHttpRequest();
x.open('POST', url, true);
x.withCredentials = true;
x.setRequestHeader('Content-Type', 'application/json;charset=UTF-8');
x.onload = () => res([x.status, x.responseText]);
x.onerror = () => res([-1, 'network error']);
x.send(JSON.stringify(payload));
})
XHR 不走 window.fetch 那条链,劫持不到。
五、跨域请求的两个必要条件
在页面上下文里请求另一个域名,服务端必须同时给出:
Access-Control-Allow-Origin: https://当前站点
Access-Control-Allow-Credentials: true
并且脚本里要带 withCredentials: true / credentials: 'include',否则 Cookie 不会带过去。
另外,服务端返回的自定义响应头(比如 CSRF token)默认对前端不可见,必须在 Access-Control-Expose-Headers 里列出来,否则 x.getResponseHeader('csrf') 拿到的是 null。
六、几个实战细节
CSRF 要先取再带。一般是先 GET 一个接口,从响应头里拿 token,再把它塞进后续每个请求的同名请求头。token 会过期,401/403 之后要重新取一次。
别用 wait_until="networkidle" 等 SPA。很多站点有长连接或者轮询,永远等不到 idle。用 domcontentloaded 加显式 wait_for_selector,稳得多。
登录检测别只看 URL。页面跳到首页不等于登录成功,直接打一个需要鉴权的接口,看返回是不是 not login,才是准的。
WAF 会拦裸请求。curl 能过的接口,浏览器里未必能过,反之亦然。带齐 Origin、Referer、User-Agent 再试。
七、最后
自动化这件事,九成的坑都在状态管理上。元素定位、等待、重试这些都是小问题,真正让人卡一天的是「我明明登录了,为什么它说没登录」。
把会话这东西当成资源来管:要么持久化到磁盘,要么让进程常驻通过 CDP 接管。想明白这一层,剩下的都是体力活。
- 点赞
- 收藏
- 关注作者
评论(0)