用 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() 这里有两个细节必须注意: 调试端口一定绑 127.0.0.1 。 绑成 0.0.0.0 ,等于把这个浏览器的完整控制权开放给局域网里的所有人,包括浏览器里所有已登录的账号。 用独立的 user-data-dir 。 和日常使用的 Chrome 共用配置,一是互相干扰,二是脚本异常退出时可能损坏你平时的浏览器配置。 换成这种方式后,验证码基本不再出现。登录由人在这个 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 次后,通义千问弹出了滑块验证。这说明问得太快、太规律,本身就会被识别。 后来加了三层控制: 每个平台设不同的基础间隔(豆包 20 秒,DeepSeek、千问各 45 秒) 间隔上叠加 ±40% 的随机抖动,避免固定周期 每个账号设每日上限;一旦出现验证码,该账号冷却 15 分钟 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) 三个平台的任务在时间上自然错开,既避开了客户端冲突,也顺带分散了请求节奏。 小结 这类"驱动真实浏览器做自动化"的任务,难点往往不在业务逻辑,而在这些地方: 连真实浏览器,不用无头浏览器 任何远程调用都要有超时 注意宿主机休眠这类环境因素 主动把请求节奏压下来,遇到验证码就交给人