浏览器自动化的第三种形态:常驻 Chrome + CDP,比每次起新进程稳得多
做站点自动化,常见的写法是每次跑一个 launch(),登录、干活、关浏览器。短任务没问题,但一旦要跨多次调用、跨多个站点,就会撞上两个坑:会话 Cookie 一关进程就没了,而且每次都重新登录很容易触发风控。
我现在的做法是让一个 Chrome 常驻,所有脚本通过 CDP 连上去。这篇把具体做法和踩过的坑写清楚。
一、先分清你要的是「启动」还是「接管」
browser.launch() 是启动一个新的浏览器进程,独立的 profile、独立的 Cookie 存储、独立的 session。
connect_over_cdp() 是接管一个已经存在的浏览器,拿到的是你手动登录过的那个会话。
差别在这:
# 每次都要重新登录,而且关掉之后 session cookie 就没了
with sync_playwright() as p:
b = p.chromium.launch(headless=True)
page = b.new_page()
...
b.close() # session cookie 一起没了
# 拿到的是当前正在运行的 Chrome,登录态是真人登出来的
with sync_playwright() as p:
b = p.chromium.connect_over_cdp("http://127.0.0.1:9222")
ctx = b.contexts[0]
page = ctx.pages[0]
第二种形态下,人可以在窗口里扫码登录,脚本立刻就能用;脚本跑完不关浏览器,人还能接着操作。两边交替干活,不用反复登录。
二、启动命令
"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 ^
"https://developer.huaweicloud.com/grow"
注意 Chrome 156 之后有个变化:默认 profile 加 --remote-debugging-port 会被直接拒绝,必须显式给一个 --user-data-dir。
--user-data-dir 指向一个你独占的目录,不要指向你日常在用的那个 profile——不然你正常上网时打不开浏览器。
三、连上去之后怎么选页面
一个常驻浏览器可能开了几十个标签页,别直接用 ctx.pages[0]:
# 按 URL 找
pages = [x for x in ctx.pages if "devstation" in x.url]
page = pages[-1] if pages else ctx.new_page()
我用的时候开过 124 个标签页,因为每次调试都新开一个。清理可以直接打 CDP 的 HTTP 接口:
import json, urllib.request
targets = json.load(urllib.request.urlopen("http://127.0.0.1:9222/json/list"))
for t in targets:
if t["type"] == "page" and should_close(t["url"]):
urllib.request.urlopen("http://127.0.0.1:9222/json/close/" + t["id"])
不需要真的关掉浏览器,关标签页就够了。
四、页面里的 fetch 被劫持怎么办
有些站点会注入 SDK 劫持 window.fetch 做埋点或代理。这时候在 page.evaluate 里 await fetch(...) 会直接抛异常,堆栈指向一堆压缩过的 sourcemap,根本没法查。
换 XMLHttpRequest 最省事:
() => new Promise(res => {
const x = new XMLHttpRequest();
x.open('GET', url, true);
x.withCredentials = true;
x.onload = () => res([x.status, x.responseText]);
x.onerror = () => res([-1, 'network error']);
x.send();
})
XHR 不走 window.fetch 那条链,劫持不到。
五、登录检测不要只看 URL
跳回首页不等于登录成功。直接打一个需要鉴权的接口:
() => new Promise(res => {
const x = new XMLHttpRequest();
x.open('GET', 'https://devdata.huaweicloud.com/rest/developer/fwdu/rest/developer/user/hdcommunitysoservice/v1/growth/bonus-beans', true);
x.withCredentials = true;
x.onload = () => res([x.status, x.responseText]);
x.send();
})
返回 200 且 body 不是 not login,才是真的登录了。
把这个探测挂成一个轮询,每 2 秒一次,一旦通过就立刻干活:
while time.time() < deadline:
status, body = page.evaluate(PROBE)
if status == 200 and body != "not login":
break
time.sleep(2)
这就是「用户扫码,脚本等着接管」的实现方式。
六、CSRF 要先取再带
很多网关需要先 GET 一个接口,从响应头里拿 token,再塞进后续每个请求的同名请求头。
const x = new XMLHttpRequest();
x.open('GET', 'https://devdata.huaweicloud.com/api/get-ainfo', true);
x.withCredentials = true;
x.onload = () => {
const csrf = x.getResponseHeader('csrf') || '';
// 后续每个请求都带 csrf 头
};
x.send();
注意这个响应头默认对前端不可见,服务端必须在 Access-Control-Expose-Headers 里把它列出来,否则 getResponseHeader 拿到的是 null。
七、SPA 不要用 networkidle
很多站点有长连接或者轮询,networkidle 永远等不到。用 domcontentloaded 加显式等待:
page.goto(url, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(3000) # 或者 wait_for_selector
八、自定义元素的文字抓不到怎么办
现在不少站点用 Web Components,按钮可能是 <xhs-publish-btn> 这种自定义元素,文字在闭合的 shadow DOM 里,get_by_text 抓不到。
两个办法:
# 1. 直接按标签名点
page.locator("xhs-publish-btn").first.click(force=True)
# 2. 按坐标点,先滚进视口
btn.scroll_into_view_if_needed()
box = btn.bounding_box()
page.mouse.click(box["x"] + box["width"] / 2, box["y"] + box["height"] / 2)
弹窗里的按钮容易在视口外,必须先 scroll_into_view_if_needed 再点坐标,不然点的是别的东西。
九、最后
自动化真正的难点不在元素定位,在状态管理。
会话什么时候会丢、登录态谁负责维护、Cookie 要不要落盘、脚本和真人怎么交替用同一个窗口——这些想清楚之后,剩下的都是体力活。
把 Chrome 当成长驻资源,用 CDP 接管而不是每次启动,是我现在最省心的形态。登录一次,后续所有任务复用同一份状态,风控触发频率也低得多。
- 点赞
- 收藏
- 关注作者
评论(0)