尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python下载B站视频:从m4s音视频分离到ffmpeg合并MP4

Python下载B站视频:从m4s音视频分离到ffmpeg合并MP4 简介针对B站视频下载需求这份Python爬虫脚本为需要批量保存B站视频的开发者提供了一种轻量实现方案。脚本核心逻辑简洁使用时只需从目标视频链接中提取bvid并修改对应变量即可运行下载适合具有一定Python基础、希望了解网页数据解析与视频流获取流程的爬虫学习者参考。包体内共1个文件为单个.py源码文件整体压缩包仅2KB便于直接查看与二次修改无需复杂环境配置。目前已有3288人学习下载说明该脚本在入门级B站爬虫场景中具备一定参考价值。借助这份脚本读者可以快速理解基于bvid构造请求、解析视频地址及调用下载接口的常见思路并在此基础上扩展为支持多P、批量下载等功能提升爬虫实战能力。 在浏览器里打开一个B站视频按F12盯着Network看几秒你会发现我当初一模一样的问题明明播放的是个视频怎么抓到的全都是m4s后缀的请求这也是很多人第一次用Python写B站视频爬虫下载脚本时栽跟头的地方——你以为要下载的是一个mp4文件真正要处理的是两条完全独立的音视频流。这篇文章就用Python从零拆一遍B站视频下载的完整链路BV号怎么换成真实地址、requests怎么绕过防盗链、两段m4s怎么用ffmpeg合并成mp4以及我把脚本跑稳定之前踩过的那些坑。适合刚学完requests、想拿真实项目练手的人也适合想搞清楚流媒体下载原理、不再依赖现成小工具的人。1. 先搞清楚核心事实B站视频不是一个mp4文件1.1 网页播放器里真正加载的是两段“碎片流”在我第一次写B站下载脚本的时候第一个让我懵掉的地方就是浏览器开发者工具里根本看不到一个完整的.mp4请求。你打开Network面板筛选media能看到的请求后缀几乎都是.m4s而且一出来就是两个一个标记是video另一个标记是audio。这两个m4s就是B站DASH方案下的视频轨和音频轨。视频轨负责画面音频轨负责声音两条流独立传输、独立缓存播放器在本地把它们同步合成。所以你如果只把video.m4s下载下来得到的是一段没有声音的“默片”只下audio.m4s得到的是能听到但没有任何画面的纯音频。这也是为什么网上一堆“为什么我下载的视频没有声音”的求助帖九成都是只下了一条流。1.2 B站为什么要用这种“音视频分离”的方案很多人不理解直接在服务器上存一个mp4不是更省事吗为什么非要拆开存这里面的核心原因是码率和清晰度切换的灵活性。B站视频的码率在1080P下通常有2Mbps以上4K或者高码率更是翻倍如果整段存成单个mp4用户在播放器里从720P切到1080P就要重新请求一个整文件CDN缓存压力大、用户等待时间长。拆成DASH流之后视频轨可以按清晰度分别存多份音频轨只需要存一份切换清晰度时只换视频流的请求地址即可音频流可以继续复用。另外音频和视频分开存储也方便B站在客户端做码率自适应网络差的时候视频降清晰度但音频还能保持稳定。理解了这一层你在写下载脚本时就不会再纠结“为什么拿到两个地址”这种问题了。你能做的合理选择是从接口返回的数据里把视频流和音频流的URL分别取出来各自下载最后用工具合成。1.3 动手验证在浏览器里看一次真实的请求打开你电脑上的Chrome按F12进入开发者工具切到Network面板随便打开一个B站视频并点击播放然后在筛选框输入m4s。你会看到类似这样的两个请求一个URL里包含video相关参数响应类型是application/octet-stream另一个URL里包含audio相关参数响应类型相同查看这两个请求的Headers能发现它们都要求带上Referer和User-Agent而不带Referer直接访问时B站会返回403。这就是后面用Python下载时最需要注意的防盗链机制。2. 从BV号到真实流地址完整接口调用链路2.1 第一步把BV号解析成cidB站视频页面上的那个BV号比如BV1xx411c7mD并不是下载用的直接标识。要拿到视频的实际地址得先把BV号换成视频接口里真正认的ID。第一步调用的接口是这个import requests def get_video_info(bvid): url https://api.bilibili.com/x/web-interface/view params {bvid: bvid} headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 } resp requests.get(url, paramsparams, headersheaders, timeout10) data resp.json() if data[code] ! 0: raise RuntimeError(f接口返回错误: {data[message]}) return data[data]这个接口返回的data里有视频标题title、封面、作者信息还有一个非常关键的字段cid。cid才是视频分P的本地ID。如果你遇到的视频是“多P合集”这里会多一个pages数组每一P都有自己的cid和part标题。很多人下载合集只下到第一P就是因为在get_video_info时只拿了顶层data[cid]没有从data[pages]里遍历每一P的cid。2.2 第二步用cid换取真正的音视频流地址拿到bvid和cid之后就可以请求播放地址接口了def get_play_url(bvid, cid): url https://api.bilibili.com/x/player/playurl params { bvid: bvid, cid: cid, fnval: 4048, qn: 80 } headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36, Referer: fhttps://www.bilibili.com/video/{bvid} } resp requests.get(url, paramsparams, headersheaders, timeout10) data resp.json() if data[code] ! 0: raise RuntimeError(f播放接口返回错误: {data[message]}) return data[data]这里的两个参数值得单独解释。fnval是流格式类型填16代表请求DASH格式填64代表支持高码率我通常直接写4048它实际上是把DASH、HDR等多种格式的标志位叠加在一起返回结果里会包含dash字段。qn是请求的清晰度16代表360P32代表480P64代表720P80代表1080P。普通登录账号到1080P就是公开内容里比较高的档位了更高码率涉及账号权限问题不在普通爬虫的讨论范围内。返回的data[dash]里有两个数组video和audio。每个元素代表一个备选流包含base_url、backup_url、bandwidth、codecs等字段。通常video数组里会有多个清晰度的流选bandwidth最大码率最高的那个audio数组里同样按bandwidth排序取最大。这里还有一个备用地址backup_url主地址下载失败时可以用它补救。2.3 Headers才是能不能拿到数据的隐形门槛接口参数写对了但如果请求头不对照样拿不到内容。我实际调试过的最常见的三种情况如下Headers字段不设置时的现象建议值User-Agent部分接口直接403一个正常的PC浏览器UAReferer播放地址接口403https://www.bilibili.com/video/{bvid}Cookie返回的流地址可能是低清晰度或者不全浏览器登录后复制下来的SESSDATA等这里要特别说明一下Cookie。B站部分接口在未登录状态下也能返回DASH流但码率档位可能受限。另外有些分P较多的视频未登录时请求playurl容易被风控。我的做法是先把浏览器里登录态下的Cookie复制出来放进脚本的headers里。这不是破解会员内容只是让脚本以“你自己本人账号”的身份去访问公开可以看的视频权限范围和你正常在网页上看视频是一样的。3. 下载音视频流requests直连与防盗链的坑3.1 大文件别用response.content必须流式下载拿到base_url之后最直观的下载写法是requests.get(url).content然后直接写文件。这种做法对十几MB的小文件没问题但B站一条1080P的视频流动辄几百MB使用.content会把整个文件一次性加载到内存里程序内存直接往上飙甚至可能下载到一半卡死。正确的做法是开启stream模式分块写入磁盘def download_stream(url, filepath, headers): with requests.get(url, headersheaders, streamTrue, timeout(10, 30)) as r: r.raise_for_status() total int(r.headers.get(Content-Length, 0)) downloaded 0 with open(filepath, wb) as f: for chunk in r.iter_content(chunk_size1024 * 1024): if chunk: f.write(chunk) downloaded len(chunk) if total: percent downloaded / total * 100 print(f\r已下载 {percent:.1f}%, end, flushTrue) print()chunk_size我习惯用1MB也就是1024*1024字节。太小比如1024会导致频繁写磁盘下载速度被IO拖慢太大比如10MB又会让进度反馈变得迟钝。如果你愿意装第三方库配合tqdm写进度条会更直观。3.2 防盗链Referer错一个字符都是403这部分是踩坑高发区。B站CDN对音视频流地址有严格的防盗链检查下载的时候请求头里必须带上Referer而且它不能随便写要精确到对应的视频页也就是https://www.bilibili.com/video/{bvid}这个地址。只写https://www.bilibili.com都不一定行空着更是直接吃403。这里还有个小细节如果你直接复制浏览器控制台里看到的base_url里面可能带有一串签名参数比如?txxxsignxxx这些签名是有时效的。从playurl接口返回后要尽快下载拖太久签名过期了会得到一堆403或者422的响应。所以我的下载函数会把base_url原样传进去只补headers不去改URL里的任何东西。3.3 请求频率想要脚本稳定就别挑战风控下载阶段最危险的不是403而是被B站风控系统盯上。当你连续快速发起大量请求时B站会返回HTTP状态码412并让你过验证码。一旦出现412单靠改headers已经解决不了只能等一段时间冷却或者换个登录态。我的经验是下载多个视频时串行比并发更安全。每一步请求之间至少sleep 0.3到1秒如果是多P合集可以在每个分P之间sleep 2秒。虽然看起来慢了但至少脚本不会用两分钟就死在半路上。如果你一定要追求速度并发数保持在2到3并且要准备好重试机制遇到412就退避等待。4. ffmpeg合并把音视频流变成一个正常mp44.1 为什么不能把m4s直接改名成mp4下载下来的文件后缀是.m4s有些教程会教你把video.m4s改成video.mp4然后“就能播了”。这种说法只说对了一半m4s本质上是fragmented MP4分片MP4视频轨和音频轨的数据格式就是H.264/H.265和AAC很多播放器确实能识别并播放单独的视频流但这意味着你永远要把文件当“半个视频”对待拖进剪辑软件大概率不认甚至有些播放器出现画面和声音不同步。正确做法是用ffmpeg把这些分片流重新封装成标准的mp4容器。封装不等于重新编码过程非常快而且不损失画质。4.2 用一条命令完成合并ffmpeg -i video.m4s -i audio.m4s -c copy output.mp4这条命令的作用是把两个输入文件-i指定重新封装成output.mp4-c copy表示直接复制编码后的数据流不做二次编码。所以它跑完的速度通常取决于磁盘IO而不是CPU性能一个几分钟的视频几秒钟就能合并完。如果文件是B站下载下来的m4s而且ffmpeg版本较新比如7.x单视频轨或单音频轨也能直接封装遇到个别文件播放异常可以加-movflags faststart参数把moov元数据挪到文件头部方便在线播放和拖动进度条。4.3 在Python脚本里调用ffmpeg合并这一步没必要往回读文件到内存直接调用外部命令就行import subprocess def merge_audio_video(video_path, audio_path, output_path): cmd [ ffmpeg, -y, -i, video_path, -i, audio_path, -c, copy, output_path ] result subprocess.run(cmd, capture_outputTrue, textTrue) if result.returncode ! 0: raise RuntimeError(fffmpeg 合并失败: {result.stderr[-500:]})这里有几个工程化的细节-y表示输出文件存在时直接覆盖避免脚本交互卡住capture_outputTrue是为了把报错信息捕获下来方便排错检查returncode是必须的不要只调用不检查否则ffmpeg崩了脚本还会继续往下跑。还遇到过一种情况同一个视频的视频流和音频流时长有微小偏差通常几十毫秒合并后播放到最后可能出现极短的静音或黑屏。对于个人下载收藏来说基本没影响介意的话可以后期在剪辑软件里统一处理。5. 稳定运行实测踩坑记录与边界提醒5.1 四个高频问题的排查对照表把这段经历做成一个表方便你出问题时直接对照症状根因解决办法playurl接口返回403Referer缺失或写错headers里补Referer精确到视频页返回412请求频率过高触发风控放慢请求或等待一段时间后重试只有视频没有音频只下载了video流补下载audio流并ffmpeg合并多P视频只下载了第一P用了顶层cid而非pages里的cid遍历data[pages]获取每一P cid下载到一半报连接错误网络波动或签名过期加断点续传或重试尽快开始下载5.2 让脚本更耐用的三个小手段一是文件完整性校验下载结束后对比本地文件大小和接口返回的Content-Length是否一致不一致就删掉重下避免把损坏文件交给ffmpeg浪费时间。二是重试机制给下载函数包一层装饰器或者写个循环遇到ConnectionError、Timeout、HTTP 403/412时退避重试。退避时间用指数增长第一次3秒、第二次9秒、第三次27秒最多重试3次。三是把中间结果落盘把每个视频的标题、cid、已下载状态保存到一个文本或SQLite数据库里这样脚本中途崩了重新运行可以直接跳过已完成的文件。这个设计在下载几百个视频时特别省心不然重跑一遍能把人急死。5.3 边界说明爬虫能用但要有边界感B站视频下载这个主题网上工具很多但自己用Python写一遍的价值在于理解整个流程。我要提醒的是这类脚本只适合下载自己账号下可正常观看的公开内容用于离线学习、个人收藏和个人研究。不要拿它去薅高码率会员资源不要批量扒别人的付费内容也不要对单个UP主的全量视频做高频抓取。B站接口一直在调整今天能用的接口和字段过几个月可能就变了。所以把代码写得模块化、把请求日志打清楚比临时用一次性的脚本更重要。哪天接口返回的数据结构和预期对不上第一反应应该是去看接口实际返回了什么而不是怀疑自己的参数写错了。最后再分享一点我的体会下载器这种工具网上现成的一大把自己写一遍之后你对HTTP接口、防盗链、流媒体封装格式的理解会上一个台阶。以后再遇到其他视频平台或者音频平台的下载需求底层逻辑基本都是同一套——找接口、带headers、下分片、合成文件唯一的区别只是请求参数和返回格式不同。把这篇文章里的思路吃透比收藏一百个现成脚本都管用。本文还有配套的精品资源点击获取
返回列表