做视频解析站(把分享链接换成可下载的 CDN 直链),本质上是在和各家 App 的风控体系周旋。本文是 ffmpegtool.com 解析链路(抖音/小红书/快手/B站)的排障记录。这些坑有个共同点:最后的根因和最初的假设都不一样。所有结论都有 curl 或 ffprobe 级别的实测。
一、灰色播放按钮:video 的 referrerpolicy 不管媒体请求
抖音解析成功、封面正常,播放按钮是灰的。这轮排查连续走了四个错方向:以为没播放器(有);以为 URL 时效(现取的新链);以为 HEVC 编码(ffprobe 实探是 h264 High);最后怀疑 Referer 防盗链,给 <video> 加了 referrerPolicy="no-referrer"——没用。
决定性实验是四组 curl:
| UA | Referer | 结果 |
|---|---|---|
| 普通 | 无 | 206 video/mp4 |
| 普通 | localhost | 403 text/html |
| headless | 有 | 403 |
| headless | 无 | 206 |
CDN 只认不带 Referer 的请求。再看抓包:明明设了 referrerpolicy 的 video 元素,发出的媒体请求还是带着 Referer。这是 Chromium 的老问题——<video>/<audio> 元素的 referrerpolicy 属性不作用于媒体资源请求(对 img/script 生效,对 media 不生效)。地址栏直接打开链接能播,是因为导航请求天生没有 Referer。
正解只剩一条路:fetch(url, { referrerPolicy: 'no-referrer', mode: 'cors', credentials: 'omit' }) 拿 blob,createObjectURL 之后喂给 <source>。为什么不加页面级 <meta name="referrer" content="no-referrer"> 一了百了?因为后端靠请求头 Referer 统计来源,页面级 no-referrer 会把全站埋点来源打废。修一个 bug 不能顺手砍掉一条数据线。
二、两次"IP 被限流了",两次都错了
抖音分享页 SSR 解析间歇性失败,第一反应是 IP 被风控。验证手段是 A/B 对比:同一出口、同一时刻,手机短链一直稳,网页长链(douyin.com/jingxuan?modal_id=...)一直空。不是限流,是结构差异——SSR 是否注入数据取决于 URL 有没有带"分享来源"签名参数(did/iid/u_code/share_sign,设备级凭据,不是登录态)。裸 id 请求只渲染空壳。修复是内置一组真实分享落地的参数,加手机 UA 连试 4 次吃"预热"效应:同一 client 前 2~3 次空壳,收下 set-cookie 之后连续成功,8 连发实测结果是 ---OOOOO。
第二次更典型。我们想优化 ttwid(web 凭据 cookie),思路是"每请求现注册,换新鲜身份,避免共享身份被贴标签"。池化方案写完后实测:5 种 UA 注册,拿到的 ttwid 中段恒等。ttwid 是 IP 绑定凭据,identity 就是出口 IP 的函数,注册多少次都一样。方案当天完整回退。
顺带一个认知修正:/aweme/v1/web/aweme/detail/ 长期被认为需要 a_bogus/X-Bogus 签名,实际唯一门槛是 ttwid cookie。没有 ttwid 时接口返回 200 空 body,被误读成了"要签名"。风控接口的失败形态经常不是 403 而是静默空响应。
三、实况图:通道问题,不是凭证问题
用户反馈:抖音原链接的图片是实况图(Live Photo),解析出来全是静态的。实况视频直链只存在于完整详情 schema 的 image_post_info.images[].video.playAddr。我们把免费 HTTP 通道逐个实测了一遍:
- web detail API → 403 Blocked by ArgusSecurityPlugin
- share SSR → 缩水 schema,
images[0]只有 uri/url_list/width/height,没有 video 字段 - 裸 HTTP 打 note 页 → 71KB 的 JS 挑战壳
表象像风控,于是本能地调 UA、换 Referer、试 cookie。这是把通道问题当成凭证问题——通道选错了,参数调得再对也是零。确认数据只在真浏览器渲染后的页面下发之后,上了无头 Chromium:打开 note 页,从 946KB 的 SSR HTML(gzip 后约 200KB)里解 self.__pace_f.push(...) 的 React flight payload,直接取 aweme.detail.images[i]。
第一版实现照搬过参考方案的 DOM 抓取:等 12 秒、点坐标关弹窗、ArrowRight 翻页最多 14 轮。实测多次运行只拿到 10/6/3 对,结果取决于每张图的 <video> 能不能在 2.5 秒内挂载。改成解 SSR payload 之后不翻页、不点弹窗、顺序由数据决定,生产实测单次提取 3.6~4.1 秒。这里的教训是:拿到参考实现,第一步应该提取它编码的经验事实(数据在哪、什么条件下有),而不是扫一眼结构就按自己的思路重写。
过程中还补了两个坑。一是 CDN 直链刚生成时探活会偶发拒掉首请求,同一 URL 几秒后同样请求头返回 206——探活加了 400 毫秒重试再判死,不然会把活链误杀。二是解析失败的负缓存:有用户 7 分钟连点 6 次重试,每次失败都把降级链 detail→share×4→桌面全打一遍。用户越急,出口 IP 信誉掉得越快,恶性循环。同链接 90 秒内重复提交直接短路返回同一失败(消息原文回填,缺陷指纹不变),实测第二次提交 0.00 秒返回。
四、其他平台:数据形状的坑
小红书白屏。预览没画面,下载下来的文件却能播。前端怎么修都没用。最后抓真实接口下载字节、手工解析 MP4 的 codec box,发现只剩 HEVC。根因在后端:_WATERMARKED_STREAM_TYPES = {259, 309} 是个错误假定,见到 streamType 259 就丢弃,而 259 恰恰是 Chrome 能解的 H.264 档。对照开源的 XHS-Downloader:社区主流实现根本不按 streamType 过滤水印。前端修不动的 bug,根因往往在数据侧。
快手图集全 404。返回的图片链接是相对路径 /ufile/atlas/...,没有域名;而且我们防御性实现里找的键名是 cdns,真实 JSON 里它叫 cdn(host 字符串数组,无 scheme)。抓一次真实落地页 dump 原始 JSON,两处偏差全暴露。没有真实样本背书的"防御式解析",等于没有解析。
B站没声音。yt-dlp 抓的 B站 DASH 流全部 acodec=none(音视频分离)。换 html5 playurl 通道,拿单文件 mp4,音视频同框。一个陷阱:请求 qn=64 实测回落 quality=16,请求值不能信,要信返回值。
mime 污染白屏。小红书 CDN 返回 Content-Type: application/octet-stream,构造流转 File 时 type: blob.type || mime 直接采用了它——非空,|| mime 兜底不触发——下游按 file.type.startsWith('video') 判类失败,预览区压根不渲染 video。octet-stream 和空 type 必须回落调用方声明的 mime。
最后
把这几层窗户纸捅完,剩下三句话:失败形态不等于失败原因(403 可能是通道选错,空响应可能是缺 cookie,"时好时坏"可能是不同链接结构不同);先枚举下发通道逐个实测,再谈凭证优化,在同一条错误通道里调 UA/Referer/签名,收益是零;所有"成熟开源项目都这么写"的假定,都要拿真实样本验证一次——streamType 259、键名 cdns、qn 请求值,三个错误假定全死在这。