首页 / 资讯中心 / 文章详情

从XHR到DASH:B站视频数据采集与下载全链路解析

从XHR到DASH:B站视频数据采集与下载全链路解析 ★ FEATURED ARTICLE
2020年9月4号我把手头那个视频素材整理小工具推进到了第四天。那会儿的需求很具体把B站某个分区最近一周发布的视频元数据包括标题、UP主、播放量、封面图和时长拉到一个本地表里再筛选出公开可下载的视频做素材归档。翻了一圈官方开放平台的文档个人开发者能申请到的权限非常有限很多能力卡在审核流程里短时间根本用不上。于是我把目光转向了B站网页端自己在用的那一套外部接口也就是浏览器Network面板里能看到的那些XHR请求结果发现这条路又直接又有效。这篇文章就把day04跑通的完整链路记录下来适合正在做视频数据采集、想学习网页端接口调用或者单纯想搞明白B站视频页面数据从哪来的朋友。1. 外部接口和官方API之间我为什么选了前者1.1 一次视频播放背后藏着的HTTP请求很多人第一次接触B站接口会以为视频数据是什么加密协议传的其实完全不是。你在浏览器里打开任意一个视频页点播放按钮的瞬间页面会同时发出好几个网络请求。打开开发者工具切到Network面板再切换到XHR过滤条件就能看到一串接口返回。其中最关键的一个请求名字一般是playurl。点开它的Response里面是一大段JSON包含了当前视频在不同清晰度下的流媒体地址。换句话说整个网页播放过程就是先请求视频元数据接口拿到视频ID再请求播放地址接口拿到视频流地址最后播放器拿着地址去拉流。浏览器能做到的事理论上用代码也能完整模拟一遍。这也解释了为什么我最终放弃了官方开放平台的思路转而直接对着网页端接口做开发。网页端接口本身就是给浏览器用的数据格式完全公开字段命名也相对稳定只要模拟出浏览器请求的样子就能拿到同样的数据。1.2 两边能力对比对比项官方开放平台网页端外部接口申请门槛需要企业资质或个人开发者申请审核周期长直接调用无申请流程数据范围受权限控制部分数据要额外申请网页端能看到的数据基本都能拿到稳定性相对稳定有专门技术支持接口可能随版本调整需要自己维护风控力度有规范配额频率控制不当容易被临时限制适合场景商业应用、长期稳定的数据服务个人项目、快速验证、学习研究我当时的需求是典型的个人研究场景数据量不大、周期短、对实时性要求也不高用网页端外部接口明显更划算。等真正跑通之后再评估是否需要转向官方通道也不迟。1.3 边界得划清楚外部接口虽然好用但不代表可以乱来。我的原则是只请求那些公开可访问的数据不做任何越权的尝试。比如某个视频页面游客就能看那游客接口返回的数据就是合法可用的如果某个清晰度需要会员权限那就老老实实不碰伪造参数去绕过权限校验是非常危险的行为轻则账号进风控重则涉及平台条款问题。另一个边界是不能把这些数据拿去做大规模商业分发。自己本地做素材整理、个人学习、接口机制研究这些都是合理用途但把抓下来的视频资源打包二次传播就不合适了。做技术的底线是知道什么能碰、什么不能碰。2. 最小可用请求框架从bvid到cid和aid2.1 先解决请求头伪装B站的接口服务器会校验请求来源如果请求看起来不像浏览器发出的就会直接拒绝。所以第一步是把请求头伪装到位核心是两个字段User-Agent和Referer。User-Agent用来表明你是一个浏览器Referer用来表明你是从B站页面过来的。这两个字段缺一个接口可能就会返回异常。我踩过的坑是只设置了UA忘了Referer结果接口返回403排查半天才发现问题。import requests headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36, Referer: https://www.bilibili.com/, } session requests.Session() session.headers.update(headers)这段代码建立了一个带有指定请求头的会话后续所有请求都通过session发出请求头就能自动携带。注意这里用Session而不是直接requests.get理由下面细说。2.2 用view接口拿核心元数据B站视频页面用的是BV号也就是链接里那串以BV开头的字符串。但很多内部接口认的是cid和aid所以第一步要先把BV号换算成这两样东西。请求的接口是view接口完整路径是https://api.bilibili.com/x/web-interface/view?bvidBVxxxxxx这个接口返回的JSON非常丰富data字段里包含了视频标题、简介、UP主信息、封面图、播放量、点赞数、发布时间以及最重要的cid和aid。下面是我当时写的一个简化解析函数def get_video_info(bvid): url https://api.bilibili.com/x/web-interface/view params {bvid: bvid} resp session.get(url, paramsparams, timeout5) data resp.json() if data[code] ! 0: raise RuntimeError(f接口返回错误: code{data[code]}, message{data[message]}) info data[data] return { aid: info[aid], cid: info[cid], title: info[title], owner: info[owner][name], pic: info[pic], duration: info[duration], pubdate: info[pubdate], }实际运行时返回的数据长这样只是我这里简化成了几个关键字段{ code: 0, message: 0, data: { aid: 123456, cid: 789012, title: 示例视频标题, owner: {name: 某UP主}, duration: 3600, pubdate: 1599123456 } }拿到cid之后就完成了一大半工作因为后续的播放地址接口和评论、弹幕等接口基本都需要cid。2.3 第一次请求前先串一次视频页这里有一个很容易忽略的细节访问api接口和直接访问视频页面生成的Cookie不一样。api接口会给你下发buvid3但如果你先访问一次视频页会让会话状态更接近真实用户。实际操作上我习惯在批量请求开始前先用session去请求一次视频页的HTML让服务端把初始Cookie种下来然后再去调api接口。这样能明显减少接口返回风控码的概率。代码很简单# 先访问一次视频页获取初始cookie seed_url https://www.bilibili.com/video/{bvid} session.get(seed_url, timeout5)记住这个动作只需要做一次。如果你准备跑一批视频先拿第一个视频的页面做种子请求就够了后面直接循环调接口不要每调一个视频就重新请求一次页面。3. playurl接口视频流地址是怎么被剖出来的3.1 qn和fnval参数的真实作用拿到cid之后就可以请求真正的视频流地址了。这一步用的接口是playurl路径如下https://api.bilibili.com/x/player/playurl?bvidBVxxxxxxcid789012qn64fnval16两个核心参数需要理解不能无脑复制。qn表示清晰度fnval表示返回格式。qn常用值对照如下qn值清晰度备注16360P最低清晰度32480P一般够用641080P高清普通登录用户可访问801080P通常需要特定条件1121080P6060帧版本这里的规则很简单你的账号有什么权限接口才会返回什么清晰度的地址。我自己测的时候用普通账号请求qn64能正常返回1080P地址但请求qn112时接口直接不返回对应清晰度的流这不是什么破解问题而是权限校验的预期行为。fnval参数更关键。它决定接口返回的是旧版mp4地址还是新版DASH格式地址。我强烈建议用fnval16也就是DASH格式因为DASH返回的音画分离地址对后续下载合并更可控。如果fnval设成0或1接口可能会返回一个durl数组里面是mp4分片清晰度选择少不说分片处理也更麻烦。3.2 从JSON中定位真实地址fnval16时返回的数据结构是这样的{ code: 0, data: { dash: { video: [ { id: 64, baseUrl: https://.../video.m4s, backupUrl: [https://.../backup_video.m4s] } ], audio: [ { id: 30280, baseUrl: https://.../audio.m4s, backupUrl: [https://.../backup_audio.m4s] } ] } } }注意这里有两个数组video数组里放的是画面流audio数组里放的是声音流。B站采用DASH自适应码流方案会把视频和音频拆成独立的轨道播放器在本地把两路流合在一起再播放。这也解释了一个常见现象直接从dash地址下载下来的文件后缀是.m4s拿普通播放器打开要么只有画面没有声音要么干脆放不了。选择哪一路流也有讲究。video数组里可能有多个清晰度版本应该优先选id值等于你请求的qn值的那个audio数组里一般有多个音质版本id值最大的通常音质最好但体积也更大按需选择即可。3.3 下载地址的防盗链限制你以为拿到baseUrl就能直接下载了还差一步。这个视频流地址带有防盗链校验直接裸请求大概率返回403。下载时必须带上和前面一样的请求头尤其是Referer必须指向视频所在的详情页不能只填B站首页。还有一点baseUrl如果失效了可以利用backupUrl里的备用地址。我实测的体验是baseUrl偶尔会超时备用的反而能连上。所以在下载逻辑里最好做一个地址遍历第一优先用baseUrl失败就切换backupUrl里的第一个地址。def download_stream(stream_item, save_path): urls [stream_item[baseUrl], *stream_item.get(backupUrl, [])] for idx, url in enumerate(urls): try: stream_headers { User-Agent: headers[User-Agent], Referer: https://www.bilibili.com/video/{bvid}, } resp session.get(url, headersstream_headers, streamTrue, timeout10) if resp.status_code 200: with open(save_path, wb) as fp: for chunk in resp.iter_content(chunk_size1024 * 512): if chunk: fp.write(chunk) return except requests.RequestException: continue raise RuntimeError(所有视频流地址都下载失败)3.4 常见错误码排查表接口请求多了之后难免遇到各种错误码提前记下来能省不少排查时间错误码含义常见原因与应对-404资源不存在视频被删除、BV号错误、cid不匹配检查参数-412请求被拦截频率过高触发风控停止请求等待几小时再试-352请求被限制当前网络环境有问题尝试手机流量换IP-449需要登录有些接口要求登录态带上有效Cookie403地址被拒绝防盗链校验失败检查Referer和UA我碰到最多的是-412大部分情况是某一段循环里请求发太快了。当时不死心连续重试结果越重试限制时间越长后来学乖了一旦看到-412立刻停下等一段时间再继续。4. 批量抓取时最容易翻车的三个点4.1 请求频率与随机间隔单次接口调用很简单难的是批量跑的时候怎么不把自己干掉。B站对同IP的接口频率有隐性限制连续高频请求必被风控。我当时的经验是两个相邻请求之间至少间隔2到3秒而且间隔要随机化不能固定2秒或3秒否则机器特征太明显照样容易触发风控。随机间隔的写法也很简单import random import time # 2到4秒之间随机停顿 time.sleep(random.uniform(2, 4))如果你只跑几十个视频这个频率足够稳。你要是跑上千个建议再加一个整体调度比如每抓100个视频主动休息两分钟。网上不少现成的调度算法但核心思想就一条慢就是快。4.2 超时与重试机制网络请求永远不可靠所以必须给requests设置超时。我当时见过很多新手不设timeout参数结果一个卡住的请求把整个任务挂死后面全部堵住。正确的做法很重要每个请求都设置合理的超时再加重试机制。超时时间给8到10秒比较合理太小容易误判太大则会让重试机制失灵。重试次数我一般设3次每次重试间隔递增相当于指数退避from tenacity import retry, stop_after_attempt, wait_exponential retry(stopstop_after_attempt(3), waitwait_exponential(multiplier1, min2, max8)) def safe_get(url, paramsNone): resp requests.get(url, paramsparams, timeout8, headersheaders) if resp.status_code ! 200: raise ConnectionError(fHTTP {resp.status_code}) return resp重试逻辑只应对网络抖动和瞬时错误一旦遇到-412这种风控码重试没有意义必须停下来人工处理。我的判断标准是5xx和超时可以直接重试但业务错误码要单独处理。4.3 不要把登录态搞到批量脚本里这一点非常容易被人忽略。如果你的脚本只是为了获取公开数据完全没有必要登录账号保持游客身份就够了。我见过有人为了抓更高清晰度的视频把个人账号的Cookie塞进批量脚本结果某次循环逻辑出错疯狂请求账号没过多久就进风控了。批量任务要尽量降低风险而登录态恰恰会放大风险。哪怕你真有必要用登录态也应该做一个独立的登录模块严格控制它的调用频率别和批量下载混在同一个循环里。5. 把m4s变成能看的视频文件5.1 先下载再合并前面说过DASH格式会把画面和声音分开放所以下载阶段的工作是先拿到两个文件一个视频轨m4s一个音频轨m4s。下载顺序没有讲究但保存路径一定要区分清楚比如统一用视频ID做文件名前缀{aid}_video.m4s {aid}_audio.m4s我遇到的坑是有些视频的低清晰度版本可能只有视频轨没有独立的音频轨因为低清晰度走的是合并好的MP4格式不拆DASH。所以写代码判断一下如果dash字段为空就回头找durl字段那里有完整的mp4地址。5.2 ffmpeg合并实操拿到两个m4s文件之后合并是图形界面播放器干不了的事得交给ffmpeg。命令非常简单ffmpeg -i {aid}_video.m4s -i {aid}_audio.m4s -c copy {aid}.mp4-c copy参数的意思是直接复制编码流不做重新编码速度极快画质也不损失。因为视频轨和音频轨本身就是H.264和AAC编码封装进MP4容器不需要转码。如果手头没装ffmpeg直接用包管理器装一下就行Windows下可以把ffmpeg的bin目录加到环境变量。5.3 断点续传和进度显示下载视频文件经常面临断网、断电、磁盘满这类意外。如果你下载的是几百MB的高清视频中断之后重新下载整个文件太浪费了。HTTP协议天然支持断点续传靠的是Range请求头。def resume_download(url, save_path): downloaded 0 if os.path.exists(save_path): downloaded os.path.getsize(save_path) head_headers {**stream_headers, Range: fbytes{downloaded}-} resp session.get(url, headershead_headers, streamTrue, timeout15) if resp.status_code not in (200, 206): raise RuntimeError(f无法断点续传: HTTP {resp.status_code}) with open(save_path, ab) as fp: for chunk in resp.iter_content(chunk_size1024 * 512): if chunk: fp.write(chunk)这里有个细节服务端返回206表示支持部分内容请求返回200表示它忽略了Range头给你发了完整文件。此时如果直接以追加模式写入文件就重复了。要判断状态码如果是200就改成覆盖模式。6. day04收尾一些只有跑过才知道的细节6.1 接口字段说变就变B站的网页端接口更新频率不算低字段名、参数名、甚至整个接口的路径都可能调整。我写这段内容的时候用的参数过一阵子再看未必完全一致。所以写代码时一定不要把接口返回结构硬编码散落在各处最好集中封装成解析函数将来接口变了只改一个地方。我后来的习惯是在解析函数里加上一层容错。比如返回的data里没有cid那就当成异常视频跳过而不是让整个脚本崩溃。毕竟做批量采集单条数据的损失是可以接受的整体流程的稳定才是关键。6.2 时间窗口对成功率的影响实测下来晚上8点到11点是B站访问高峰接口响应慢风控也变严格。同样的请求频率白天可能跑得稳稳的夜里黄金时段就容易出现-412。所以我后来养成了习惯大批量任务放在凌晨或上午跑成功率明显更高。这和平台的服务端负载策略有关系高峰期对非浏览器请求的审查也更严格。6.3 封面图和视频地址都需要Referer不仅视频流地址需要Referer封面图、弹幕接口也都需要。我当时写了一个通用请求头任何资源请求都带上Referer从源头上杜绝了403问题。后来查看日志发现凡是漏掉Referer的请求清一色全是403加上之后瞬间正常。6.4 day04之后我改进了什么第四天的版本还很粗糙核心逻辑就是单线程循环一个视频接一个视频地处理。后来陆续加了多线程下载、失败队列、抓取进度断点保存才慢慢变成一个能稳定跑一晚上的小工具。但回头看最关键的节点就是day04这部分链路打通能拿到cid能拿到流地址能下载能合并。链路通了后面做的一切都是优化。如果你现在也在做类似的事情我的建议是先跑通一条完整链路哪怕只处理一个视频也远比写一堆半吊子工具更有效率。链路不闭环一切优化都是空中楼阁。
阅读完成 · 觉得有帮助?
咨询建站