我们需要定期统计一件事:同一批问题问豆包、DeepSeek、通义千问的网页版,回答里提到了谁、引用了哪些网站。API 版不联网,结果和用户在网页上看到的不一样,所以只能自动化操作网页版。
这篇记录从"能跑"到"能稳定跑一整晚"之间踩过的 6 个坑。环境是 Python 3.13 + Playwright,macOS 和 Windows 都要支持。
最开始用 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 里扫码完成,脚本不经手任何账号密码。
调试时很容易出现:源码跑着一个实例,打包好的桌面版又启动了一个,两边都 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 FalseWindows 下换成 msvcrt.locking。拿不到锁就直接提示"本机已有执行器在运行",而不是硬连上去。
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,)失败后按指数退避重试。页面级的操作(等回答生成、读取引用)也各自设超时,不依赖默认值。
一次长任务跑到一半,笔记本合盖休眠了 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 )服务端同时把这类超时归为"可重试",重新排队,而不是直接判失败。
连续快速提问 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))需要强调:遇到验证码,正确的做法是停下来交给人处理,而不是想办法绕过去。 绕过验证码违反平台规则,也会让账号面临更严格的风控。
豆包、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)三个平台的任务在时间上自然错开,既避开了客户端冲突,也顺带分散了请求节奏。
这类"驱动真实浏览器做自动化"的任务,难点往往不在业务逻辑,而在这些地方:
本文由 AI 辅助生成,内容经作者审核并对其负责。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。