Patchright 浏览器自动化坑速查
> 由真实持久化浏览器自动化任务(采集 → 下载 → 网盘转存)踩坑沉淀整理,按【坑/现象 → 根因 → 解法 → 通用教训】组织。
坑 1:自带打包内核在新版 macOS 上直接崩
【症状】浏览器进程能拉起(有 pid),但渲染会话秒挂,日志出现 Mach rendezvous failed, terminating process (parent died?) 或 Page.enable: Internal server error, session closed。
【根因】Patchright 自带的 Chrome for Testing 打包内核版本太旧,与新版 macOS 不兼容。与 profile 锁无关——别在这条岔路上浪费时间。
【解法】切系统 Chrome,两种写法任选:
# 写法一:channel 指定系统 Chrome(推荐)
ctx = await p.chromium.launch_persistent_context(
user_data_dir, channel="chrome")
# 写法二:显式 executable_path
ctx = await p.chromium.launch_persistent_context(
user_data_dir,
executable_path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
【通用教训】环境升级后浏览器自动化「进程活着但会话秒死」,先怀疑打包内核版本,不是 profile。别再把 profile 迁回打包内核。
坑 2:异常退出后 SingletonLock 残锁,下次启动必失败
【症状】某次进程异常退出后,每次启动持久化上下文都失败。检查发现 SingletonLock -> hostname-<pid>,而该 pid 早已是死进程。
【根因】Chromium 用 SingletonLock / SingletonCookie / SingletonSocket 三个锁文件保证单实例。异常退出来不及清理,残留锁指向死 pid,下次启动被判「已有实例在跑」。
【解法】每次启动前先清锁,固化进启动函数:
import os
from pathlib import Path
def clear_profile_locks(profile_dir: str):
for name in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
p = Path(profile_dir) / name
if p.is_symlink() or p.exists():
p.unlink()
【通用教训】所有 long-running 持久化浏览器任务,锁清理必须是启动路径的第一步,不是故障后手动补救。
坑 3:登录判据只看 cookie 名,把「未登录」误判成「已登录」
【症状】页面明确显示「未登录 · 登录后即可观看」,脚本却报「✅ 已是登录态」;甚至扫码后 9 秒内就误报登录成功。
【根因】以某短视频平台为例:sessionid / sessionid_ss 过期后 cookie 并不消失,未登录状态下平台照样下发这两个名字。cookie 名存在 ≠ 会话有效。
【解法】唯一可信判据是页面真实渲染结果:
async def is_logged_in(page) -> bool:
# 1) 真实内容判据:真收藏卡片的 img[alt] 非空(alt 形如 "作者:标题")
real_card = await page.query_selector(
'a[href*="/video/"] img[alt]:not([alt=""])')
if real_card:
return True
# 2) 未登录文案判据
body = await page.inner_text("body")
for kw in ("未登录", "登录后", "扫码登录"):
if kw in body:
return False
# 3) cookie 只能兜底,且必须「值非空且够长」
cookies = await page.context.cookies()
ss = next((c for c in cookies if c["name"] == "sessionid_ss"), None)
return bool(ss and ss.get("value") and len(ss["value"]) > 16)
要点:
- evaluate 取不到状态时按未登录处理,不能落回 cookie 兜底。
- cookie 兜底必须校验值非空且够长——空的 sessionid_ss 不能算已登录。
【通用教训】验证侧判据必须比被测对象更严格。「cookie 名存在」是状态的最弱证据;页面渲染结果才是最强证据。
坑 4:虚拟列表页面「滚完再一次性抓」必得 0 条
【症状】同一脚本有时抓 18 条、有时 0 条,极不稳定。
【根因】收藏/信息流类页面用虚拟列表:滚出视口的内容即被卸载。先滚到底再统一抓取时,首屏内容早已被回收。
【解法】边滚边收,滚动与抓取同步进行:
async def scroll_and_collect(page, seen: set, out: list):
last_height = 0
while True:
cards = await page.query_selector_all('a[href*="/video/"]')
for c in cards:
href = await c.get_attribute("href")
if href and href not in seen:
seen.add(href)
out.append(c)
await page.mouse.wheel(0, 1200)
await page.wait_for_timeout(600)
h = await page.evaluate("document.scrollingElement.scrollHeight")
if h == last_height:
break
last_height = h
等待策略(首屏不稳定时的三层兜底):等「真卡片出现」最多 20s → 没有就点主 Tab 触发渲染 → 再等 16s → 还没有就 reload → 再等 24s。
【通用教训】虚拟列表没有「滚完」这回事,只有「边滚边收」。
坑 5:SEO 蜘蛛假链接污染 anchor 计数
【症状】a[href*="/video/"] 数到 7 条,但全是假货——没有真卡片却报「抓到了」。
【根因】页面混着 source=Baiduspider 之类的 SEO 链接:纯文本 <a>、无 <img>、innerText 有标题,选择器一视同仁全部命中。
【解法】判据必须是 img[alt] 非空,而不是「anchor 存在」:
# 错:只数 anchor
cards = await page.query_selector_all('a[href*="/video/"]')
# 对:真内容卡片 = anchor 里有非空 alt 的 img
cards = await page.query_selector_all('a[href*="/video/"] img[alt]:not([alt=""])')
【通用教训】SPA 页面上 anchor 存在只证明 DOM 里有节点,不证明渲染出了内容。抓取判据要绑定到「只有真内容才会有的属性」(如 img 的 alt 文本)。
坑 6:网盘网页端上传落子目录,核验脚本误判失败
【症状】上传流程跑完、接口返回成功,但核验脚本按根目录(pdir_fid=0)列文件找不到刚传的东西 → 误以为上传失败。
【根因】以夸克网盘网页端为例:网页端上传默认落到「夸克上传文件」子目录,不在根目录。
【解法】核验两选一:
# 1) 进该子目录查
files = quark_ls(dir_name="夸克上传文件")
# 2) 或按更新时间倒序取 head(不是 tail!)
files = quark_ls(sort="_sort=updated_at:desc")[:10]
【通用教训】「确认成功」这一步的判据必须与被测对象的实际行为对齐——上传落点、排序方向(head vs tail)都是判据的一部分。三次「假成功」里两次都出在核验判据上。
通用教训汇总
- 环境升级后自动化崩,先怀疑打包内核版本,不是 profile / 锁 / 网络。
- 锁清理必须是启动路径第一步,不是故障后手动补救。
- 登录/状态判据用页面渲染结果,不用 cookie 名;cookie 只能兜底且须校验值非空。
- 虚拟列表只有边滚边收,没有「滚完再抓」。
- 验证判据必须比被测对象更严格——凡「确认成功」这一步,绑定真内容特有属性 + 对齐实际落点。
相关坑速查
- yt-dlp 调用同一平台的 web 接口遇签名 403、cookie 导出 expires=-1 的坑,见同作者《yt-dlp 网络排障速查》。