ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

B站视频下载:DASH音视频分离与ffmpeg合并实战

B站视频下载:DASH音视频分离与ffmpeg合并实战

1. 先把“下载”这件事的本质看透

1.1 右键另存为拿不到文件,问题出在哪

很多人第一次尝试 B站视频下载,动作都高度一致:在播放画面上点右键,指望蹦出一个“视频另存为”。结果要么菜单里根本没有这一项,要么存下来的是一个几百字节的网页文件,双击打开还是跳回浏览器。这不是操作姿势不对,而是从根上搞错了对象。现代视频网站在浏览器里播放的内容,并不是一个躺在 HTML 里的<video src="xxx.mp4">标签,播放器只是一层外壳,真正的画面数据是播放器在运行时向服务器动态请求、边下边播的。换句话说,页面里压根没有“一个完整的视频文件”这个概念,你右键另存,存的只能是外壳。

所以,理解 B站视频下载的第一步,是先把心理预期从“找一个文件”切换到“还原一次播放过程”。浏览器能播出来,说明数据一定经过了你的网络;我们要做的,就是把这条数据通路上的原料截下来,再按原来的方式拼回去。这个思路一旦建立,后面所有的工具选择、参数配置、报错排查,都会变得有迹可循,而不是靠试。我见过太多人卡在这一步,反复换工具却始终不明白为什么失败,本质就是没分清“页面”和“流”是两码事。

1.2 音视频分离传输,是现代视频站的基本盘

早期的视频网站确实是一个 FLV 或 MP4 文件从头拉到尾,那种结构下载起来非常简单,抓到一个地址就能整段拖下来。现在主流站点普遍采用 DASH(Dynamic Adaptive Streaming over HTTP,基于 HTTP 的动态自适应流)方案,它的核心特征是:画面和声音是两条完全独立的流,各自切片、各自请求、各自缓存。播放器根据当前网速动态挑选合适清晰度的分片,网速好就切到高码率,网速差就降到低码率,这样卡顿少、起播快。

这个设计对观看体验是好事,对下载来说就多了一道工序。你抓到的往往是两个地址:一个video轨,一个audio轨,两者都是.m4s后缀的分片容器。如果只下了视频轨,播放器打开后就是一段默片;只下音频轨,那就是一个纯音轨文件。很多人第一次操作时看到“下载成功但没声音”,急得去换播放器、换解码器,其实文件本身就是不完整的,问题出在只拿了一半原料。

提示:判断一个站点的视频结构,最快的办法是看抓到的媒体请求数量。如果视频和音频是两个不同地址、体积各自独立,那基本可以确定是分离传输,必须走合并流程。

1.3 三种编码格式,直接决定你的文件能不能播

同样是 1080P,B站视频下载下来可能是三种完全不同的编码:AVC(也就是 H.264)、HEVC(H.265)、AV1。站方提供多套编码是为了兼顾不同设备的解码能力和带宽成本,但对下载者来说,这三者的兼容性差异非常大,选错了会出现“我这儿能播、发到别人手机上黑屏”的尴尬。

AVC 是兼容性最好的选择,十几年前的设备都能硬解,剪辑软件、播放器、手机、电视通吃,代价是同画质下体积偏大。HEVC 压缩效率比 AVC 高约 30% 到 50%,体积小很多,但对老设备不友好,部分安卓机和 Windows 需要额外装解码器才能播。AV1 是更新的开放格式,压缩效率最高,可惜生态支持还在爬坡,很多剪辑软件导入直接报错。

我的建议很明确:如果你下载是为了存档、剪辑、二次加工或者发给别人看,优先选 AVC;如果纯粹是自己在一个确认支持 HEVC 的设备上看,追求省空间,那再考虑 HEVC。别为了省那 30% 的空间,换来一堆兼容性麻烦,这笔账不划算。

编码类型站内常见标识兼容性同画质体积适用场景
AVC / H.264codecid 7极好,几乎通吃基准(偏大)剪辑、分发、长期存档
HEVC / H.265codecid 12中等,老设备吃力约小 30%~50%自用观看、移动端
AV1codecid 13较差,生态在建设中最小尝鲜、专业播放环境

1.4 先把边界划清楚,再谈技术

这里必须说一句可能不太讨喜但很重要的话:技术上能做到,不等于任何场景都该做。我自己的原则是三条线——自己上传的内容随便下,那是你自己的劳动成果,备份、留档、换平台迁移都天经地义;明确获得作者授权的内容可以下,很多创作者会主动说明允许搬运或转载;平台明确标注可下载的内容正常下。除此之外的内容,技术上能解析出来,但把它用于二次上传、剪辑搬运、商业用途,那就是另一回事了,跟工具本身无关。

我之所以在开头就讲这个,是因为它直接影响你的技术路线选择。如果只是备份自己的投稿,你会更关心原画质、原始音频采样率、字幕轨道完整性;如果是学习用途看一遍就删,那其实根本不需要下载,收藏夹和稍后再看够用了。目的不同,参数的取舍完全不同,别一上来就埋头搞技术,先把“我要这个文件干什么”想清楚,能省掉一大半无用功。

2. 定位真实播放地址,抓包这一关怎么过

2.1 开发者工具的正确打开方式

整个流程里最容易劝退新手的就是这一步,但其实逻辑极简:让浏览器自己把地址找出来,你只负责看。打开视频页面,按下 F12(或者右键“检查”),切到 Network(网络)面板,然后做两件容易被忽略的小事——先点一下清空按钮把已有记录清掉,再按 F5 刷新页面。不清空的话,几百条请求混在一起,你要找的那两条会被淹没。

刷新之后,在过滤器里依次尝试media、xhr、fetch。media分类下会直接列出浏览器正在请求的媒体资源,通常能看到两条体积较大的记录,类型标注为fetch或media,域名往往和主站不同。这时候不要急着复制,先把请求的完整信息看一遍:Request URL、Request Headers、Response 三个标签页,后面全要靠它们。特别是 Request Headers,很多人失败就是因为漏了这里的某一项,后面会专门讲。

如果media过滤器什么都没抓到,那就换思路,去看xhr里的接口请求。播放器在起播前一定会先发一个“我要这个视频的播放地址”的请求,接口名里通常带playurl这类字样,响应是一大段 JSON。找到它,后面的活儿就变成读 JSON 了,比瞎猜可靠得多。

2.2 从 playurl 响应里挑出你要的那几条流

打开那个接口响应的 Preview 或 Response 标签,你会看到一层层嵌套的 JSON。别被吓到,真正要用的字段就那么几个:

  • data.dash.video是一个数组,每个元素代表一条视频轨,对应一种清晰度和一种编码;
  • data.dash.audio同理,代表音频轨;
  • 每条轨里的baseUrl是主地址,backupUrl是一个数组形式的备用地址,主地址抽风时可以顶上;
  • id字段对应清晰度档位,常见的有 16(360P)、32(480P)、64(720P)、80(1080P)、112(1080P+)、116(1080P60)、120(4K);
  • codecid对应编码,7 是 AVC,12 是 HEVC,13 是 AV1;
  • bandwidth是这条流的码率,单位是 bit/s,这个数字后面估算体积要用到。

挑选的优先级我一般这么排:先按id锁定清晰度,再按codecid筛编码,最后看bandwidth是不是合理。有时候同一清晰度会返回多条 AVC 记录,码率略有差异,选高的那条。音频轨通常只有一两条,选bandwidth最大的即可,音频码率差距不大,但高码率的听感确实更扎实。

举个实际的估算例子。假设你选中的视频轨bandwidth是 2,000,000,也就是 2 Mbps。换算成字节:2,000,000 ÷ 8 = 250,000 B/s,约等于 244 KB/s。一个 10 分钟的视频,600 秒 × 244 KB ≈ 146 MB。音频轨假设 128 Kbps,即 16 KB/s,600 秒约 9.4 MB。合计大概 155 MB。提前算一遍,你就能判断硬盘空间够不够、下载要等多久,不至于下到一半才发现只剩 3G 空间。

2.3 请求头里绕不开的那几项

地址拿到了,直接粘到浏览器新标签页里,十有八九返回 403。这不是地址失效,而是服务端在校验“你是不是那个正在播放的客户端”。浏览器自动带的请求头,你用别的方式发请求时是不会自动带上的,必须手动补齐。关键的就三个:

Referer,告诉服务端你是从哪个页面来的,缺了它通常直接被拒。User-Agent,声明客户端类型,建议直接用你浏览器里那一串原样复制,别自己编。Cookie,某些情况下需要带上登录态才拿得到高清晰度,具体看你的账号权限,注意别把 Cookie 到处粘贴,那玩意等同于你的登录凭证。

还有一个经常被忽略的点:Range 头。分片请求通常是按字节区间拉的,服务端返回 206 Partial Content。如果你手动请求时没带 Range,服务端可能返回整段,也可能直接拒绝,具体看实现。稳妥做法是模拟分片请求,或者干脆让下载工具自己处理。

2.4 一个手写的请求示例

把上面这些拼起来,用命令行验证一下地址是否可用,这是最省事的排错方式:

curl -I \ -H "Referer: https://<站点域名>/video/<视频ID>" \ -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..." \ -H "Cookie: <从浏览器复制的完整值>" \ "https://<流地址前缀>/....../video.m4s"

-I只发 HEAD 请求,看响应头就行。返回 200 或 206,说明地址和请求头都对上了;返回 403 是请求头缺东西;返回 404 是地址本身有问题,可能过期了。这里要提醒一句,这类流地址通常带时效签名,参数里有一串deadline或者类似的过期时间,一般是几小时。抓到了就尽快下,别放到第二天再动手,否则只能重抓一遍,白做工。

3. 下载与合并,把两段流还原成一个能播的文件

3.1 分片下载与断点续传

一个长视频的视频轨,往往被切成几十甚至上百个分片,每个分片几 MB。你从baseUrl拿到的可能只是其中一个分片的地址,也可能是整段的入口,具体看返回结构。如果发现地址里带index、segment这类字样,或者下载下来只有几 MB、时长明显不对,那就说明你拿到的是分片,需要按顺序把后面的都拉下来。

这时候手工一条条下就不现实了。稳妥的办法是用支持批量队列的下载方式,把分片地址按序号排好,依次拉取到同一个目录,最后按顺序拼接。拼接的顺序极其重要,一旦顺序错乱,合并出来的文件会出现画面跳帧、时间轴错位,而且这种错误是隐性的,不从头上看根本发现不了。我的习惯是下载时就给文件命名带上三位数的序号前缀,比如seg_001、seg_002,拼的时候按文件名排序,能避开这个坑。

断点续传这块,命令行工具基本都支持,加个-C -参数即可。但要注意,续传的前提是文件本身没被写坏。如果上一次中断是因为磁盘满或者网络异常导致文件写了一半,续传会得到一个混合了新旧数据的坏文件。遇到续传后文件大小异常、播放卡在某个位置,别犹豫,删掉重下,比排查半天省时间。

3.2 ffmpeg 合并,参数到底该怎么写

合并这一步,ffmpeg是绕不过去的工具,免费、跨平台、命令清晰。最基本的合并命令长这样:

ffmpeg -i video.m4s -i audio.m4s -c copy -movflags +faststart output.mp4

这里有几个点值得说清楚,因为每一个都对应一类常见失败。

-i video.m4s -i audio.m4s的顺序决定了输出文件里第一个流是视频、第二个是音频。顺序反了不会报错,但某些播放器会认错主轨道,表现就是打开后默认播音频、画面不显示。养成先视频后音频的习惯,能省掉一堆莫名其妙的兼容问题。

-c copy是整条命令的灵魂,意思是“直接复制流,不重新编码”。为什么必须这样?因为重新编码意味着解码再编码,十分钟的视频可能要跑几分钟甚至更久,画质还会有肉眼可见的损失(除非你给足码率),纯属吃力不讨好。-c copy几秒钟就完事,画质和原始流一模一样,这是唯一正确的选择。

-movflags +faststart的作用是把索引信息挪到文件头部。不加这个参数,生成的 MP4 也能播,但在网页上边下边播时会先黑屏一段时间,因为播放器得等整个文件下完才能读到索引。加了之后,几十 KB 的索引被前置,起播几乎是瞬间的。这个参数对小体积文件感知不强,但对几百 MB 的长视频,差别非常明显。

如果合并时报错Could not find codec parameters,八成是你下的分片不完整或者混了不同清晰度的片段。这时候不要硬试,回去检查每个分片的体积是否接近、是不是来自同一条baseUrl前缀。

3.3 “改后缀就能播”这个误区

网上流传一种说法:把.m4s直接改成.mp4或者.m4a,就能当普通文件用。这个说法只对了一半,而且很容易把人带沟里。

如果那个分片里已经包含了完整的视频流,改后缀确实能让部分播放器认出来,因为.m4s本质上就是 ISOBMFF 容器,跟 MP4 是同一家族。但问题在于,大多数分片是切片而非完整文件,头部的 moov box 可能是缺失的,改后缀打开要么黑屏,要么只播几秒就卡住。更麻烦的是,你没法从文件大小判断它到底完不完整——它可能只是少了一个索引,体积看起来正常,播放就是不行。

所以我的态度很明确:不要用改后缀的方式偷懒。视频轨和音频轨老老实实用ffmpeg合并,这一段多花三十秒,后面能省掉几小时的困惑。真要图省事,至少用ffprobe先查一下文件里到底有几个流:

ffprobe -v error -show_entries stream=index,codec_type,codec_name -of csv output.mp4

输出的结果里如果只有video没有audio,那说明你只下了一半,回头补音频轨。这个检查动作我建议格式化到流程里,每合并一个文件就跑一次,比事后发现问题强得多。

3.4 字幕、封面、弹幕,要不要一起拿

很多人只盯着视频本体,等下载完了才发现字幕没跟着来,尤其是那种只有自动生成字幕、没有外挂字幕轨的视频,事后补起来非常费劲。我的做法是字幕优先单独抓,在接口响应里找subtitle相关的字段,通常会给出srt或json格式的地址,下载下来后用ffmpeg嵌进去或者单独存成.srt放旁边,播放器会自动加载同名字幕。

封面图相对简单,页面源码里能直接找到图片地址,顺手存一份,日后整理素材库的时候好用。弹幕则是另一回事,原始数据是 XML 格式,得靠专门的转换工具转成 ASS 字幕才能正常渲染,而且不同播放器对 ASS 的支持好坏不一,我一般只在确实需要归档的时候才折腾这一步,日常下载跳过。

提示:字幕文件和视频文件保持同名、放在同一目录,绝大多数播放器都能自动挂载。这个小习惯的回报率极高,比事后手动加载字幕省事得多。

4. 常见问题排查速查

4.1 403 与 412,两个最劝退的报错

403 表示“服务器理解了你的请求,但拒绝执行”。在视频下载场景里,九成是请求头没带全,尤其是Referer和User-Agent。排查顺序建议是:先把Referer补上重试,不行再加User-Agent,最后才怀疑Cookie。不要一上来就把所有头都塞进去,那样即使成功了你也说不清是哪一项在起作用,下次换场景还得重新试。

412 表示“前置条件不满足”,在这个语境下通常是签名参数过期或者校验失败。遇到 412,先检查地址里的时间戳参数,如果已经超过有效期,唯一的解法就是回页面重新抓一遍。别想着改时间戳蒙混过关,签名是配套计算的,改了反而彻底失效。我自己的习惯是抓完地址立刻下载,中间不切去做别的事,就是为了绕开这个坑。

报错码常见原因排查动作成功率
403Referer / UA 缺失补齐请求头后重试高
412签名过期、参数被改回页面重新抓取地址高
404地址拼错、分片序号越界核对地址前缀与序号中
206 但文件 0 字节Range 头异常改用工具自动处理中
连接重置并发过高、被限速降低并发数、加间隔高

4.2 有画面没声音,或者音画不同步

有画面没声音,八成是漏了音频轨,或者合并时-i只给了一个文件。回到第 3.2 节检查命令,确认两个-i都在。还有一种隐蔽情况:两条流的时长不一致,合并后视频轨正常,音频轨末尾被截断。这不是合并的锅,而是你下的音频分片少了最后一段,重新核对分片数量就能确认。

音画不同步的成因更复杂一些。如果偏移量是从头到尾恒定的,通常是因为两路流的起始时间戳不同,合并时没有对齐。可以在ffmpeg里用-itsoffset做微调,但更省事的办法是换一个下载时能保证两路流同时起点的工具,从源头避免。如果偏移量随着播放时间逐渐变大,那基本可以判定是某一路的帧率或采样率信息被写错了,这种文件建议直接重下,修的性价比太低。

4.3 合并报错与文件损坏

Invalid data found when processing input是最常见的一条。成因有三个:分片没下全、多个清晰度的分片混在了一起、文件在传输中被截断。排查方法是逐个用ffprobe检查分片,能读出流信息的才算完整。如果只有第一个分片正常、后面的都报错,那说明你拿到的可能是索引文件而非真实数据,回去看响应结构。

还有一种情况是合并成功、文件也能播,但拖到某个时间点就卡死。这通常是拼接顺序错了,中间某一段被重复或跳过。解决办法是用-f concat配合一个列表文件按序合,而不是简单地用通配符*.m4s——通配符展开是按字典序的,seg_10会排在seg_2前面,这个坑我踩过不止一次。

4.4 我的几条实操心得

第一,每完成一个环节就验证一次。地址拿到先用curl -I探一下,分片下完先ffprobe查一下,合并完再播一遍开头和结尾。这样出问题时你知道是哪一步坏的,不用从头复盘。

第二,目录结构按“视频ID / 清晰度 / 流类型”分好。听起来麻烦,但当你同时处理三五个视频时,混在一起的video.m4s会让你彻底失去判断力,根本分不清哪个对应哪个。

第三,保留原始分片,直到确认成品没问题再删。合并失败想重来时,重下分片的时间成本远高于多占的那点硬盘空间。我一般确认成品能完整播放、时长对得上、音画同步之后,才清理中间文件。

5. 批量处理与工具选型的取舍

5.1 现成工具和自写脚本,边界在哪

市面上的下载工具大致分两类。一类是图形界面的封装版,粘贴地址就能下,优点是零门槛,缺点是遇到结构调整就容易失效,而且你没法干预它的合并参数,清晰度、编码基本不可控。另一类是开源的命令行工具,功能强、参数全、更新快,代价是要花点时间读文档。

我的建议是分场景:偶尔下几个,用图形工具够了,别为了省事去学命令行;需要批量、需要指定编码、需要自动化归档,那就上命令行。判断标准很简单——如果你开始重复同样的操作三次以上,就该考虑脚本化了;如果一个月才用一次,学脚本的投入根本收不回来。

5.2 一个批量脚本的骨架

真要批量处理,思路比代码重要。核心流程就四步:拿到视频 ID 列表、逐个请求播放地址、按清晰度和编码筛选出目标流、下载并合并。下面是个骨架,具体接口和字段名要按实际情况调整:

import subprocess, os, json, pathlib def fetch_stream_info(video_id): # 请求播放地址接口,返回解析后的 JSON ... def pick_streams(info, prefer_codec=7, prefer_qn=80): videos = info["data"]["dash"]["video"] audios = info["data"]["dash"]["audio"] v = next((x for x in videos if x["id"] == prefer_qn and x["codecid"] == prefer_codec), None) a = max(audios, key=lambda x: x["bandwidth"]) return v, a def download(url, out_path, headers): cmd = ["curl", "-L", "-C", "-", "-o", out_path] for k, val in headers.items(): cmd += ["-H", f"{k}: {val}"] cmd.append(url) subprocess.run(cmd, check=True) def merge(v_path, a_path, out_path): subprocess.run([ "ffmpeg", "-y", "-i", v_path, "-i", a_path, "-c", "copy", "-movflags", "+faststart", out_path ], check=True)

这段代码里最关键的不是语法,而是pick_streams里的筛选逻辑:先保证能拿到目标编码,再退而求其次。写成next(...)带默认值时一定要处理None,因为高清晰度不一定对所有人开放,账号权限不够时那个档位压根不在返回数组里,直接取会抛异常,整个批次全挂。稳妥写法是先按id过滤,结果为空就降一档,逐级回退。

5.3 并发、限速与礼貌

批量下载最容易犯的错是把并发开到很大,觉得这样快。实际结果是触发风控,轻则请求被拖慢,重则整批失败。我的经验值是并发控制在 3 到 5 之间,每个请求之间加个几百毫秒的间隔,总体速度并不会慢多少,因为瓶颈往往在你的带宽而不是服务端响应。

限速这件事也值得提一句。家用宽带上行下行不对称,下行跑满时其他设备会明显卡顿。给下载工具加个限速参数(比如--limit-rate 5M),把速度压在带宽的六七成,家里其他人刷视频、开会都不受影响,自己也少挨抱怨。这不是技术问题,是使用习惯问题,但确实影响体验。

5.4 存储与归档的一点经验

下得多了,文件管理会变成新问题。我的做法是文件名统一成标题_清晰度_编码.mp4的格式,标题里的特殊字符先替换掉,避免不同系统下的路径问题。目录按年月分,一个月一个文件夹,找起来直观。另外,建一个清单文件记录每个视频的原始地址、下载时间、文件大小和校验值,将来文件损坏或者需要重下,不用再回页面翻一遍。

校验值这块,用sha256sum算一下存进清单里,几十 MB 的文件几秒钟就算完。听起来有点重,但当你半年后发现某个文件播不了、又不确定是下载时就坏了还是硬盘出了问题时,这个值能立刻给你答案。这一点在长期归档的场景里价值很高,日常随便下下就不用折腾了。

最后分享一个小技巧:如果你经常需要同一批视频的不同清晰度,别重复抓地址,同一次响应里的多条流地址往往同时有效,把需要的几条都存下来,一次性下完,比反复回页面抓取高效得多。地址过期时间一般够你下完一个中等长度的视频,但别拖太久,拖久了就得重来。

踩过几次坑之后我越来越觉得,B站视频下载这件事,技术上没有多高深,难的是把每一步的因果关系想明白:为什么拿不到文件、为什么要合并、为什么报 403、为什么没声音。这些问题一旦从原理上通了,换什么工具、换什么站,套路都是同一套。工具会过时,接口会变,但“播放是流、流要合并、合并要不重编码”这三条,短期内不会变。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进