前几天有个朋友问我,能不能把一个B站学习视频下载到本地,他想在地铁上反复看。我第一反应是丢给他一个在线解析网站,但转念一想,那些网站要么弹广告、要么第二天就失效,与其来回折腾,不如用PHP写个解析脚本,几分钟就能拿到高清播放地址。今天就把这个思路和代码完整放出来,希望能帮到和那位朋友一样困在“B站视频怎么下载”这个问题上的人。如果你懂一点PHP、会用命令行,甚至只是想了解视频网站接口是怎么工作的,下面这5分钟的内容应该能让你少走很多弯路。
这篇文章的主角是一个免费的PHP小工具。它做的事情很简单:输入一个B站BV号,输出一串或者多串可以直接播放的视频地址。核心代码不到60行,不依赖任何框架,也不需要数据库,只要你的机器能跑PHP就行。与其说它是一个“破解工具”,不如说它是一份“接口调用练习”,因为B站网页端本身就是这样拿视频地址的,我们只是把播放器背后发起的请求用PHP模拟了一遍。
1. 为什么我不推荐“在线解析网站”,而是自己写脚本
1.1 在线解析网站的三大痛点
先说说我为什么不建议你用那些所谓“免费B站解析下载网站”。
第一个痛点是广告和诱导。这类站点为了赚流量费,页面上通常堆满了假播放按钮、下载器推广和弹窗。你只是想下载一个视频,结果可能装了一堆流氓插件,或者下载下来是个exe而不是mp4。关于这一点,我在帮朋友排查电脑问题时见过太多次。
第二个痛点是清晰度被锁。免费的在线解析往往只能拿到480P或者720P,想要1080P就引导你开会员或者关注公众号、付费买卡密。问题是,B站很多视频本身就是公开的、你登录账号就能看1080P,这些网站只是把你的清晰度降级再卖给你,属于两头赚。
第三个痛点是失效快。B站接口一调整、风控一收紧,这些站点很短时间内就会集体扑街。你今天收藏的解析网站,下周可能就变成“本站已关停,请加群获取新地址”。这种依赖关系让你永远在追逐一个不稳定的服务。
1.2 自己写脚本的本质:拿到播放地址
别看“解析”这个词听起来很玄,它背后的逻辑其实很简单:视频网页能正常播放,就说明播放地址一定存在,而且能被浏览器获取到。所谓解析,就是去找到那个地址,把它从接口返回的数据里取出来,给它加上必要的请求头,让你能在任意播放器里直接访问。整个过程跟“破解”完全不沾边,更像是在模仿浏览器去和B站服务器“正常对话”。
我见过不少人对“接口”有心理壁垒,觉得那是后端工程师研究的东西。其实你用一个PHP文件加几行cURL代码,就能把“接口”这件事体验一遍。这也是我推荐大家自己写而不是用别人工具的原因:一旦你理解了接口调用、JSON解析、HTTP头设置这几个概念,以后不管是下载其他网站的视频,还是对接各种开放平台API,都会变得很自然。
1.3 这篇文章适合谁
老实说,如果你完全不会PHP,只是想要一个现成工具,那这个脚本也能直接用,运行一下、复制地址出来就行。但如果你希望从这个例子里学到东西,那就需要具备一点点基础:
- 会打开命令行,知道
php xxx.php这种运行方式 - 能看懂基本的PHP语法就行,不需要会面向对象
- 对“JSON”这个东西有印象,知道它是接口返回的数据格式
满足这几点,你跟着下面内容走一遍,应该就能把这套逻辑吃透。
2. B站视频地址的获取逻辑:BV号、cid、playurl是怎么串起来的
2.1 从BV号到aid:view接口
B站早期的视频ID是纯数字的,也就是av号,比如av170001。后来为了防爬和去重,B站换成了现在这种BV开头的字符串,比如BV1GJ411x7h7。但是B站内部很多老接口依然只看数字ID,也就是aid。
所以我们做的第一件事,就是把用户输入的BV号,转换成接口能识别的aid和cid。转换所用的接口是:
https://api.bilibili.com/x/web-interface/view?bvid=BVxxxxxxxx这个接口返回的是视频的详细信息,包括标题、封面、简介、分区,以及一个非常重要的字段cid。cid是分P的唯一标识,B站一个视频可以有多个分P(也就是你看到的视频列表),每一P对应一个不同的cid。所以严格来说,获取播放地址时,BV号并不是最终参数,cid才是。
举个例子,你请求view接口后,返回的JSON结构大概长这样(简化过):
{ "code": 0, "data": { "aid": 123456789, "bvid": "BV1GJ411x7h7", "cid": 234567890, "title": "示例视频标题", "pages": [ { "page": 1, "cid": 234567890, "part": "P1标题" }, { "page": 2, "cid": 234567891, "part": "P2标题" } ] } }大部分视频只有一P,所以data.cid就是在这个页面下默认播放的那一P的ID。如果你需要处理多P,data.pages数组里有全部信息。这部分后面进阶会讲到。
2.2 从avid + cid到播放地址:playurl接口
拿到aid和cid之后,第二步就是请求真正的播放地址接口:
https://api.bilibili.com/x/player/playurl?avid=123456789&cid=234567890&qn=80&fnval=0这个接口会返回一串播放信息。其中qn是清晰度参数,fnval是格式参数。如果fnval=0,返回的是比较传统的DURL格式,视频通常是一个或多个分段地址,可以直接下载;如果fnval=16,返回的是DASH格式,视频轨和音频轨分离,后面需要额外合并。
在DURL格式下,返回的数据核心是data.durl数组:
{ "code": 0, "data": { "timelength": 1830000, "durl": [ { "url": "https://upos-sz-mirror.../xxx.flv?xxx", "order": 1 } ] } }我们需要的,就是durl[0].url这个字段。
2.3 防盗链和Referer:B站为什么拦住外站请求
拿到播放地址之后,如果你直接复制到浏览器地址栏打开,大概率会看到403错误,或者下载下来的是一堆乱码。这不是地址错了,而是B站的CDN会校验HTTP请求头里的Referer字段。
它的逻辑是:只有来自https://www.bilibili.com/页面的播放请求才会被允许,其他来源一概拦截。这就是我们常说的“防盗链”。浏览器在播放B站视频时,会自动带上Referer,所以一切正常;而你直接复制地址到新标签页打开时,Referer就变了,于是被拒绝。
很多新手写爬虫、写解析脚本失败,就失败在这一步:明明接口返回了地址,却下载不下来。解决方式也很简单,在cURL请求里面手动设置一下头信息即可。这也是我下面代码里重点强调的部分。
3. 5分钟跑通:完整的PHP解析脚本与逐段拆解
3.1 环境准备
第一步确认你的环境满足要求:
- PHP 7.4 或更高版本,命令行输入
php -v能看到版本号 - PHP已启用cURL扩展,命令行输入
php -m | grep curl能看到curl
如果你用的是Windows,最简单的方式是装一个PHPStudy或者Laragon,里面自带了PHP和curl扩展。如果你用的是Mac或者Linux,系统自带的PHP通常也够用。
整个过程不需要安装Composer、不需要引入任何第三方库,因为底层HTTP请求我们用cURL完成,JSON解析用PHP自带的json_decode,完全够用。
3.2 第一段代码:请求view接口,拿到cid
我习惯先封装一个通用的request()函数,把公共的请求头统一设置好。这样可以避免在多个地方重复写User-Agent和Referer。
<?php function request(string $url): array { $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Referer: https://www.bilibili.com/' ]); $result = curl_exec($ch); if (curl_errno($ch)) { throw new RuntimeException('cURL错误: ' . curl_error($ch)); } curl_close($ch); return json_decode($result, true); }CURLOPT_SSL_VERIFYPEER设为false,是为了避免本地缺少CA证书导致的SSL验证失败。这个设置在生产环境不建议长期开启,但本地跑脚本问题不大。
然后写一个getPlayUrl()方法,第一步先请求view接口取cid和aid:
function getPlayUrl(string $bvid): array { $viewUrl = "https://api.bilibili.com/x/web-interface/view?bvid={$bvid}"; $viewData = request($viewUrl); if (!isset($viewData['code']) || $viewData['code'] !== 0) { throw new RuntimeException('获取视频信息失败: ' . ($viewData['message'] ?? '未知错误')); } $aid = $viewData['data']['aid']; $cid = $viewData['data']['cid']; echo "标题: " . $viewData['data']['title'] . PHP_EOL; // 下一步:请求播放地址 }这里先校验返回状态code,只要不为0,就说明BV号有问题或者视频不存在,直接抛异常结束。接下来才进入真正的playurl请求。
3.3 第二段代码:请求playurl,拿到高清播放地址
接着上面的函数继续写:
$playApi = "https://api.bilibili.com/x/player/playurl?avid={$aid}&cid={$cid}&qn=80&fnval=0"; $playData = request($playApi); if (!isset($playData['code']) || $playData['code'] !== 0) { throw new RuntimeException('获取播放地址失败: ' . ($playData['message'] ?? '未知错误')); } return $playData['data']; }这里的qn=80表示请求1080P,如果当前账号或视频没有这个清晰度,接口会自动降级,不会直接报错。至于fnval=0,表示使用DURL格式,得到的是可以直接播放的FLV或MP4地址,对新手最友好。
3.4 完整源码
把上面几段拼起来,再加一个命令行入口,就是完整的工具,代码不到60行:
<?php function request(string $url): array { $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Referer: https://www.bilibili.com/' ]); $result = curl_exec($ch); if (curl_errno($ch)) { throw new RuntimeException('cURL错误: ' . curl_error($ch)); } curl_close($ch); return json_decode($result, true); } function getPlayUrl(string $bvid): array { $viewData = request("https://api.bilibili.com/x/web-interface/view?bvid={$bvid}"); if (!isset($viewData['code']) || $viewData['code'] !== 0) { throw new RuntimeException('获取视频信息失败: ' . ($viewData['message'] ?? '未知错误')); } $aid = $viewData['data']['aid']; $cid = $viewData['data']['cid']; echo "标题: " . $viewData['data']['title'] . PHP_EOL; $playData = request("https://api.bilibili.com/x/player/playurl?avid={$aid}&cid={$cid}&qn=80&fnval=0"); if (!isset($playData['code']) || $playData['code'] !== 0) { throw new RuntimeException('获取播放地址失败: ' . ($playData['message'] ?? '未知错误')); } return $playData['data']; } $bvid = $argv[1] ?? ''; if ($bvid === '') { die("用法: php bili_parser.php BVxxxxxxxx\n"); } try { $data = getPlayUrl($bvid); if (isset($data['durl']) && $data['durl']) { foreach ($data['durl'] as $index => $part) { echo "分片" . ($index + 1) . "地址: " . $part['url'] . PHP_EOL; } } else { echo "未解析到播放地址。" . PHP_EOL; } } catch (RuntimeException $e) { echo "解析失败: " . $e->getMessage() . PHP_EOL; }把这段代码保存为bili_parser.php。
3.5 实际运行效果
在命令行输入:
php bili_parser.php BV1GJ411x7h7会先打印视频标题,然后输出一个或多个分片地址。拿B站官方账号的一个视频测试,输出类似这样:
标题: 【官方】B站 2019拜年祭 分片1地址: https://upos-sz-mirroralicn.bilivideo.com/upgcxcode/.../xxx.flv?e=ig...把这段地址复制到支持FLV的播放器里,比如PotPlayer、VLC,或者直接用ffplay打开,就能正常播放。如果地址被断成好几行,说明是从终端复制的换行符问题,可以重定向到一个文本文件:
php bili_parser.php BV1GJ411x7h7 > output.txt如果你只是想在浏览器里直接看,也可以把这个PHP脚本放到本地Web服务器目录下,用?bvid=传参。不过命令行方式最简单,也不会被PHP内置的浏览器输出格式干扰,推荐优先用命令行。
4. 我在实测中踩过的坑:防盗链、多分片、清晰度被锁
4.1 坑1:Referer没写对,返回403
我第一次测试的时候,Referer写的是:
'Referer: https://www.bilibili.com'注意,少了一个结尾斜杠。结果部分视频能解析,部分视频的播放地址拿到之后怎么都下载不了。后来我对比浏览器的请求头才发现,B站CDN校验时对Referer的匹配比较严格,把末尾斜杠加上去:
'Referer: https://www.bilibili.com/'问题立刻消失。别看这个区别不起眼,在实际使用中可能就是一次请求成功、一次请求失败的差别。
还有一点,如果你改动了请求头大小写,比如写成referer,部分CDN也能识别,但为了稳妥,HTTP头里的固定名称建议按标准来,大小写保持一致。
4.2 坑2:有些视频返回多个durl分片
长视频和高码率视频会被B站切成多个分片,在接口返回里就是durl数组里有多个元素。每个分片都有自己独立的URL、时长和大小。如果只取durl[0]['url'],你可能只下载了视频的前几分钟,后面的全丢了。
所以上面完整代码里我特意用了foreach循环,把所有分片地址都打印出来。如果你要下载完整视频,就得把每个分片都下载下来,再按顺序合并。对于FLV格式,合并可以直接用ffmpeg:
ffmpeg -i "concat:part1.flv|part2.flv|part3.flv" -c copy output.flv或者更清晰的做法,先把每个分片下载到本地,然后用:
ffmpeg -f concat -safe 0 -i filelist.txt -c copy output.flv其中filelist.txt里每行写一个file 'part1.flv'。
顺便说一句,如果你看到一个视频接口返回了十几段分片,不用担心是异常,这是很正常的切片策略。B站的CDN会根据网络状况动态决定分片数量,同一个视频在不同时间请求,分片数都可能不一样。
4.3 坑3:没登录只能拿到480P
B站对未登录用户的清晰度限制非常明显。未登录状态下,即使你请求qn=80,接口也可能只给你返回一个480P的地址。这是因为B站要求视频清晰度必须结合账号权限来校验:普通游客最多看480P,登录用户通常能看720P或1080P,更高清晰度(比如1080P+、4K、原画)则要求大会员。
如果你需要下载1080P,一个办法是在请求头里带上你自己的登录Cookie。怎么拿Cookie?浏览器登录B站后,按F12打开开发者工具,在Network里随便点一个请求,复制请求头里的Cookie字段。然后在上面的request()函数里加一行:
'Cookie: SESSDATA=你的登录凭证; bili_jct=你的csrf_token'这里只使用你自己账号的Cookie,相当于模拟你自己在网页端看视频,属于正常账号行为,不涉及破解任何东西。但要注意,Cookie属于敏感信息,千万不要把带着Cookie的脚本随便发给别人,更不要把Cookie上传到公开代码仓库。否则别人能用你的账号发弹幕、评论,甚至做一些更麻烦的操作。
我实测发现,携带了Cookie之后,接口返回的清晰度才会真正按你账号的权限走。普通登录账号一般能拿到1080P,大会员账号可以拿到1080P+甚至更高。
4.4 坑4:请求过快返回-412
B站接口有风控策略,如果你高频刷接口,返回的数据里code可能会变成-412,提示“请求被拦截”或者“操作太频繁”。我一开始调试时,用脚本循环测试了好几个BV号,每个之间间隔不到1秒,结果第4个就触发了风控,需要等好几分钟才能继续用。
解决办法很简单:控制请求频率,每次解析之间至少间隔2到3秒,不要批量、高并发地跑这个脚本。同时建议把CURLOPT_TIMEOUT设小一点,比如5到10秒,避免一个接口卡住让脚本一直挂着。
另外,尽量不要在生产环境或者公司网络出口去频繁抓B站接口,因为整个出口IP都可能被限。作为个人工具,控制频率、合理使用,一般不会出问题。
5. 进阶玩法:dash格式、音视频合并、把它变成通用小工具
5.1 什么是dash格式,为什么B站把一个视频拆成两半
如果你把fnval从0改成16,接口返回的数据结构会变得不一样:
{ "code": 0, "data": { "dash": { "video": [ { "id": 80, "baseUrl": "https://upos-sz-mirror.../video.m4s?xxx" } ], "audio": [ { "id": 30216, "baseUrl": "https://upos-sz-mirror.../audio.m4s?xxx" } ] } } }dash对象下面有video和audio两个数组,视频轨和音频轨是完全分开的两个文件。这就是DASH自适应流媒体协议,它的优势是能根据用户网速动态切换清晰度,让视频加载更流畅。但代价就是,如果你直接下载视频轨文件,打开后会没有声音。
很多用视频下载工具下载B站视频的新手会遇到一个困扰:明明下载下来的是.m4s文件,用播放器打开却只有画面没有声音。这往往是因为他下载的是B站网页缓存里的视频轨,而不是合并后的成品。用接口请求DASH格式时也存在同样的问题。
所以如果你的目标是直接拿一个完整的视频文件,用fnval=0的DURL格式反而是最省事的。只有当你对清晰度有极致要求,连DURL格式都不满足时,才去处理DASH格式。
5.2 用ffmpeg把两个m4s合并成mp4
既然DASH格式把视频和音频分开了,那拿到地址后的最后一步就是合并。合并工具我推荐ffmpeg,几乎全平台通用,而且处理这种纯复制流的合并非常快。
下载好视频轨video.m4s和音频轨audio.m4s之后,在同一目录执行:
ffmpeg -i video.m4s -i audio.m4s -c copy output.mp4这里的-c copy意思是流复制,也就是不重新编码,只是把视频轨和音频轨封装到同一个MP4容器里。整个合并过程通常几秒就能完成,而且画质没有任何损失。
如果你是在命令行里只拿到了baseUrl,也可以直接用PHP的file_put_contents或者cURL去下载这两个文件。不过要注意,DASH的baseUrl同样需要带Referer请求头,否则下载下来的依然是403。
5.3 支持多P视频
前面说过,view接口返回的data.pages数组里包含了所有分P的信息。把它利用起来,就能把工具从“单P解析”升级成“全P解析”。
思路不复杂:把解析播放地址的逻辑抽成一个resolveByCid($aid, $cid)函数,然后循环遍历pages数组:
$pages = $viewData['data']['pages']; foreach ($pages as $page) { echo "P" . $page['page'] . ": " . $page['part'] . PHP_EOL; $playData = resolveByCid($aid, $page['cid']); // 输出对应地址 }这样遇到“番剧、课程、合集”这类视频,你一次性就能拿到所有分P的播放地址,而不是只拿第一P。部分官方合集视频可能有几十上百P,用这个工具配合下载工具的批量任务功能,就能实现完整离线备份。
不过还是那句话,合集视频可能涉及UP主和平台版权,个人离线观看、学习剪辑、做笔记都可以,不要二次分发。
5.4 写在最后:关于版权与合规的提醒
每次聊到视频解析、下载这类话题,版权问题都绕不开。我的态度很明确:技术是中性的,但这个能力应该用在合理的地方。
你完全可以用这个工具下载自己上传到B站的视频,做本地备份,这比从后台导出方便得多;你也可以下载一些公有版权、允许二次创作的视频做剪辑素材;你还能用它来学习B站接口的调用逻辑,锻炼自己的PHP和网络编程能力。但我不建议用它去批量抓取他人付费内容,更不建议把下载的视频搬运到其他平台。这不仅涉及版权风险,也违背了创作者分享内容的初衷。
B站的接口和风控策略一直在更新,今天能用的一些参数、字段,过几个月可能就会失效。如果你照着这篇文章操作时发现接口返回异常,最可能的原因就是B站调整了策略。这时候不妨回头看看接口文档、浏览器Network面板里的真实请求,用同样的思路重新适配就好,这也是写代码的乐趣所在。
最后再分享一个小技巧:把上面代码里的request()函数单独保存成一个common.php,以后你在其他项目里需要请求第三方接口时,直接require进来就能复用。我后来写抖音、小红书、YouTube的解析脚本,用的也都是同一个基础函数,只是换了接口地址和参数。站在接口的角度看,这些平台的结构其实大同小异。有了这次的PHP经验,下次你再遇到任何“视频下载”需求,应该都不会慌。