用 Playwright 驱动真实 Chrome 批量采集网页版大模型回答:6 个踩坑记录 - 九宸智能

发布时间: · 更新时间:

用 Playwright 通过 CDP 驱动真实 Chrome 批量采集网页版大模型回答,从能跑到稳定跑一整晚之间踩过的 6 个坑。

我们需要定期统计一件事:同一批问题问豆包、DeepSeek、通义千问的网页版,回答里提到了谁、引用了哪些网站。API 版不联网,结果和用户在网页上看到的不一样,所以只能自动化操作网页版。

这篇记录从"能跑"到"能稳定跑一整晚"之间踩过的 6 个坑。环境是 Python 3.13 + Playwright,macOS 和 Windows 都要支持。

坑 1:无头浏览器几乎每次都触发验证码

最开始用 Playwright 自带的无头 Chromium,基本上打开页面就是滑块验证。无头浏览器的指纹特征太明显了。

换成的做法是:启动一个真实的、有界面的 Chrome,开远程调试端口,再用 Playwright 通过 CDP 连上去。

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \  --remote-debugging-port=9333 \  --remote-debugging-address=127.0.0.1 \  --user-data-dir="$HOME/.geo-executor/chrome-profile"
browser = await playwright.chromium.connect_over_cdp("http://127.0.0.1:9333")context = browser.contexts[0]page = context.pages[0] if context.pages else await context.new_page()

这里有两个细节必须注意:

换成这种方式后,验证码基本不再出现。登录由人在这个 Chrome 里扫码完成,脚本不经手任何账号密码。

坑 2:一个浏览器连两个 Playwright 客户端,会互相卡死

调试时很容易出现:源码跑着一个实例,打包好的桌面版又启动了一个,两边都 connect_over_cdp 到同一个 Chrome。结果两个客户端相互阻塞,每一步操作都卡到超时,报错信息完全看不出真正原因——我们为此排查了很久。

解决办法是加一个进程级互斥锁,命令行版和桌面版共用同一把锁:

import fcntlclass SingleInstance:    def __init__(self, path):        self._path = path        self._fh = None    def acquire(self) -> bool:        self._fh = open(self._path, "w")        try:            fcntl.flock(self._fh, fcntl.LOCK_EX | fcntl.LOCK_NB)            return True        except OSError:            self._fh.close()            self._fh = None            return False

Windows 下换成 msvcrt.locking。拿不到锁就直接提示"本机已有执行器在运行",而不是硬连上去。

坑 3:connect_over_cdp 默认不超时

Chrome 没起来、端口被占用时,connect_over_cdp 会一直挂着。外面看到的现象是"程序启动了,但什么也不做",日志里也什么都没有。

所有远程调用都要包一层超时:

ATTACH_TIMEOUT_SECONDS = 60browser = await asyncio.wait_for(    playwright.chromium.connect_over_cdp(endpoint),    timeout=ATTACH_TIMEOUT_SECONDS,)

失败后按指数退避重试。页面级的操作(等回答生成、读取引用)也各自设超时,不依赖默认值。

坑 4:笔记本休眠会让单调时钟停走

一次长任务跑到一半,笔记本合盖休眠了 34 分钟。醒来后任务既没超时也没继续,就停在那里。

原因是 macOS 休眠期间 time.monotonic() 不前进,基于它算的"已经等了多久"永远到不了超时阈值。而服务端给任务的租约是按真实时间算的,早就过期了,整轮任务都被判失败。

处理方式是任务运行期间阻止系统休眠:

import ctypes, os, subprocess, sysif sys.platform == "darwin":    # 进程在就一直阻止休眠,进程退出后自动失效    subprocess.Popen(["caffeinate", "-dimsu", "-w", str(os.getpid())])elif sys.platform == "win32":    ES_CONTINUOUS, ES_SYSTEM_REQUIRED, ES_AWAYMODE_REQUIRED = 0x80000000, 0x00000001, 0x00000040    ctypes.windll.kernel32.SetThreadExecutionState(        ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_AWAYMODE_REQUIRED    )

服务端同时把这类超时归为"可重试",重新排队,而不是直接判失败。

坑 5:请求节奏本身就是风控信号

连续快速提问 45 次后,通义千问弹出了滑块验证。这说明问得太快、太规律,本身就会被识别。

后来加了三层控制:

import randomDEFAULT_INTERVALS = {"doubao": 20, "deepseek": 45, "tongyi": 45}INTERVAL_JITTER = 0.4def next_interval(platform: str) -> float:    base = DEFAULT_INTERVALS.get(platform, 45)    return base * (1 + random.uniform(-INTERVAL_JITTER, INTERVAL_JITTER))

需要强调:遇到验证码,正确的做法是停下来交给人处理,而不是想办法绕过去。 绕过验证码违反平台规则,也会让账号面临更严格的风控。

坑 6:多账号不能开多个客户端,只能一个循环轮询

豆包、DeepSeek、千问三个账号要同时跑,但坑 2 已经说明不能开多个客户端连同一个浏览器。

最后的做法是:一个事件循环里轮询多个会话,每个会话各自维护页面、间隔和当日配额:

async def run(self):    while not self._stop.is_set():        for session in self._sessions:            if not session.due():            # 还没到这个会话的下次执行时间                continue            if session.quota_exhausted():    # 今天的配额用完了                continue            await self._tick(session)        await asyncio.sleep(1)

三个平台的任务在时间上自然错开,既避开了客户端冲突,也顺带分散了请求节奏。

小结

这类"驱动真实浏览器做自动化"的任务,难点往往不在业务逻辑,而在这些地方: