解析引擎通常长成这样:一个 HTTP 客户端,带上一堆 header,去请求目标页面的接口。这篇文章讲的是另一条路——我们在生产环境里跑了一个无头 Chromium(Playwright + headless Chromium),让它作为解析链路的最后一环。为什么值得为一个页面付出启动浏览器的开销?装机踩了多少坑?这篇完整记录。
背景:三条免费通道的死亡清单
需求很具体:抖音图集帖的「实况图」每张图带一段 3 秒短视频,直链只在完整详情 schema 的 image_post_info.images[].video.playAddr 里。我们把免费通道枚举了一遍,逐个实测死法:
| 通道 | 实测结果 |
|---|---|
| web detail API | Argus 安全插件拦截,403 |
| share 分享页 SSR | 缩水 schema:images[0] 只有 5 个键,无 video 字段,image_post_info 整个不存在 |
| 裸 HTTP 打 note 页 | 71KB 的 JS 挑战壳,带全套浏览器请求头也一样 |
三张死亡清单摆在一起,结论自明:这份数据只通过「真浏览器渲染后的页面」下发。这就是上无头浏览器的全部理由——不是技术偏好,是排除法排出来的。
核心方案:不抓 DOM,解 payload
最常见的无头浏览器方案是:等页面渲染完,querySelectorAll 抓 DOM,模拟交互翻页。我们最早参考的实现就是这么写的——固定等 12 秒、鼠标按坐标点掉登录弹窗、循环最多 14 轮、每轮等 2.5 秒抓 <video> 的 currentSrc。实测多次运行分别得到 10/6/3 对结果,最坏 35 秒——每张图的视频元素是否在 2.5 秒内挂载,全看当时机器的心情。
换掉的方案一行 DOM 都没碰:页面 goto 后监听文档响应,抖音的 note 页是 SSR(946KB HTML,gzip 后约 200KB),数据以 React flight payload 形式内嵌在 self.__pace_f.push([1,"7:[...]"]) 的序列化 chunk 里。解开它,直接拿 aweme.detail.images[i]——不翻页、不点弹窗、不靠坐标,顺序由 payload 决定,配对用图片 token(同一张图各尺寸变体 token 相同)。
这个改动把单次提取从「10/6/3 对不稳定」变成生产实测 3.6~4.1 秒稳定出全量,端到端解析 5.2 秒。方法论上它和「解接口」是同一件事:页面渲染只是拿到数据的手段,能从传输层拿就不要等 DOM。
两个容易翻车的细节:一是 payload 里可能夹带推荐位的图集作品,取数据必须比对 awemeId,否则会把别人作品的视频贴到本作品的图上;二是资源纪律——拦截 image/media/font 请求、拿到 payload 立即 abort 后续(JS 包约 3.5MB 是大头),实测把单次提取从 7.5 秒压到 2.5 秒以内。
装机:Alibaba Cloud Linux 3 的四个坑
服务器是阿里云 ECS(Alibaba Cloud Linux 3,RHEL8 系)。playwright install-deps 直接失败——它按发行版 ID 查依赖表,不认识 alinux,回退 ubuntu24.04 去调 apt-get,而这台机器没有 apt,exit 127。更麻烦的是 Playwright 1.63 已把 RHEL/CentOS 从依赖表删掉,没有现成清单可抄。
解法是手工映射:拿 playwright 的 Ubuntu chromium 依赖表逐项翻译成 RHEL8 包名,先对阿里云源做只读校验(避免一个包名不存在整批失败),再 dnf 安装。结果:14 个必需库缺 10 个(libgbm/libdrm/libatk/libcups/libasound/libpango 等),共 24 个包、约 2 分钟装完。headless Chromium 没有 libgbm/libdrm 是起不来的。
其余三个坑快速过:
- glibc 虚惊:下载的内核包名带
-ubuntu24.04-,担心 glibc 不够。实测服务器 glibc 是 2.32(比 RHEL8 标称的 2.28 新),chrome --version正常。教训:版本问题先验本体,别按包名推演。 - pip 装了什么:
pip install playwright只装 Python 驱动,不含浏览器——相当于装了 selenium 没装 Chrome。内核要单独playwright install chromium(398MB,1Mbps 服务器下了 13 分钟)。 - HOME 一致性:playwright 按
~/.cache/ms-playwright找内核。systemd 服务的HOME必须与安装时的用户环境一致,否则服务进程找不到内核、静默降级——这是我们最不能接受的那种失败。装完第一件事是核对HOME=/opt/ffmpegtool与内核缓存路径一致。
playwright 用延迟导入,装完无需重启服务——失败的 import 不进 sys.modules,下次调用自动重试。
生产化:三个必须回答的问题
问题一:探活误杀。 直链有三种 CDN host 后缀,其中一种需要 Referer 而前端 fetch 伪造不了 Referer,所以服务端要逐个候选做 Range 探活、挑免 Referer 可用的。上线后 imageVideos 偶发为空——真实链路重放发现,直链刚生成就被探活时,CDN 偶发拒绝首个请求(同一 URL 几秒后同样请求头返回 206)。更糟的是空结果被 4 小时缓存住。修法:非 2xx 先等 400ms 重试一次再判死;探活只剔除明确返回非 2xx 的候选,传输层异常不算死链。教训:沙箱里单发探针永远碰不到这种时序问题,必须起真实服务重放。
问题二:并发打爆内存。 单 worker 2G 内存的服务器,并发起两个 Chromium 实例就是灾难。整条提取链用 Semaphore(1) 串行——吞吐换稳定,对这个量级的站点是正确取舍。
问题三:超时预算怎么算。 最坏路径必须算得清:提取 40s + 清理尾巴 ≤9s(三处有界清理各 3s)+ 前置抓取 2~5s,总和顶在 nginx proxy_read_timeout 60s 之下。内部等待窗口(28s)必须显著小于总预算,否则窗口没用完就被掐断、兜底清理来不及跑。改任何一处超时都要重新核算整条链——这种账平时没人看,出事时就是 504。
收益与边界
最终架构:免费 HTTP 通道优先(覆盖绝大多数请求,零浏览器开销),仅图集实况图落到 Chromium 路径;任何浏览器侧失败一律返回空对象静默降级——实况视频是增值能力,绝不能拖累图片和视频主链路。上线后曾评估「详情里可能已自带该字段,可短路省掉浏览器」的优化,生产日志证明该短路零命中——浏览器开销省不掉,但 3~4 秒的单次成本在可接受范围内。
无头浏览器当解析引擎不是优雅的方案,但在「目标平台不给数据、免费通道全灭」的现实约束下,它是排除法之后剩下的那条路。要走通它,工程量在浏览器之外:装机清单、探活重试、串行化、超时核算、静默降级——这些才是生产和无头 demo 的真正差距。
(本站视频解析支持抖音、B站、小红书、快手,服务端解析、直链回传不代理流量——上面这套引擎每天在线上跑。)