恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
B站直播源抓取与PHP代理搭建:彻底解决m3u8地址频繁失效问题
首页
资讯中心
/
B站直播源抓取与PHP代理搭建:彻底解决m3u8地址频繁失效问题
B站直播源抓取与PHP代理搭建:彻底解决m3u8地址频繁失效问题
发布时间:2026/9/19 19:54:15
前几天群里又有人问起B站直播源抓取的事说拿API拿到的地址放进播放器没几分钟就断了。这个问题我太熟了刚入坑那会儿我也是把从接口里抠出来的m3u8地址直接硬编码到配置里结果要么403要么过几分钟就失效来来回回折腾了一个多礼拜才摸清楚背后的机制。这篇文章就把我从API分析到PHP代理服务器搭建的完整过程写下来重点讲清楚B站直播源为什么会失效、接口参数怎么传、代理层到底要解决哪些问题以及我在实测中踩过的各种坑。不管你是想给电视、PotPlayer做直播列表还是想批量监控多个直播间这套流程都能直接拿来用。1. 先理清直播源的整体链路再动手抓取1.1 为什么网上流传的地址“活不过”几分钟很多人第一次接触B站直播源都是从网上别人分享的m3u8地址开始的。这种地址通常是别人在某一个时间点通过API拿到的拿到手的时候看起来很正常丢进播放器也能播。但是过几分钟到几小时不等它一定会挂而且挂了之后没有任何提示播放器只会一直转圈。原因在于B站CDN返回的流地址不是永久资源而是一个带签名和过期时间的临时链接。这个链接的host、路径和extra参数拼在一起之后extra里面通常带了过期时间戳和签名参数。CDN会根据这个签名校验请求是否在有效期内以及请求的Referer、User-Agent是否符合要求。一旦过了时间戳或者请求头不对CDN直接拒掉。所以如果你试图维护一个“固定的B站直播源地址”从根上就是错的。正确的做法是让程序在每次需要播放时动态去API获取最新地址或者做一个代理层每次请求都实时拉取、实时转发。这也是后面PHP代理服务器的存在意义。1.2 一条直播流从页面到播放器要经过什么理解这条链路比背十个接口参数都有用。打开B站任意一个直播间时实际发生的事情是这样的浏览器根据直播间URL拿到房间号先请求一个初始化接口拿到真实房间ID和直播状态。再调用播放信息接口传房间ID、协议类型、清晰度、编码等参数。接口返回一组播放地址里面包含多个协议HTTP-FLV、HLS、多个清晰度原画、蓝光、超清等、多个编码AVC、HEVC、AV1的组合。播放器从返回的地址里挑一个最合适的开始拉流。我们做直播源抓取本质上就是把这个流程里的第2到第3步从浏览器里“拆”出来用代码自己控制。关键点在于我们请求接口时必须模拟一个正常的浏览器播放器带上正确的User-Agent、Referer、Origin等请求头否则接口很可能返回异常数据或者返回了地址但后续拉流时被CDN拒绝。这里明确一下边界B站直播的播放信息接口本身是公开的直播间页面自己也会调用它我们只是用程序去请求这个公开接口再对返回数据做整理和转发。这不涉及任何逆向破解或者绕过机制属于正常的开发实践。1.3 环境准备与选型思路实操之前先把环境准备好。这套方案不挑操作系统Windows、Linux、macOS都可以跑关键是下面这几样PHP 7.4以上版本必须启用curl扩展和json扩展。一台能访问外网的机器本地开发环境也行但如果要给电视或公网访问建议部署到一台小服务器上或者通过内网穿透暴露到局域网。测试工具命令行curl、VLC播放器、Postman或Apifox。我为什么选PHP而不是Node.js或Python其实Python写起来也很顺但PHP在Web服务器部署上有一个天然优势随便一个Nginx或Apache就能跑起来文件传上去就能当接口用不需要常驻进程也不怕进程挂掉。对于这种轻量级的API转发需求PHP这种“请求来了处理一下就走”的模式反而最不容易出问题。当然如果你更熟悉别的语言思路完全一样代码照着逻辑翻译就行。2. API剖析从直播间URL到真实流地址的完整链路2.1 用room_init拿到真实房间号和开播状态打开一个直播间URL长这样https://live.bilibili.com/123456。大多数人会以为尾巴上的数字就是API要用的房间ID但实际上这里有坑。B站内部区分“真实房间号”和“短房间号”有些特殊房间比如赛事直播间、某些活动房间URL上的ID和真实房间ID并不一致。如果你拿着URL上的数字直接去请求播放信息接口有概率拿不到流。所以第一步永远是先调初始化接口把真实房间号换回来curl https://api.live.bilibili.com/room/v1/Room/room_init?id123456 \ -H User-Agent: Mozilla/5.0 \ -H Referer: https://live.bilibili.com/返回的JSON里data字段包含几个关键信息room_id就是真实房间号live_status表示直播状态uid是主播用户IDtitle是当前直播标题。这里live_status的取值要注意1是正在直播2是轮播说明这个直播间配置了轮播内容可能不是真人实时直播0是未开播。我建议在代理层里把“未开播”和“轮播”这两种情况分开处理。如果抓取目标是实时开播监控需要忽略live_status2的情况如果只是想稳定看到一个画面轮播其实也能拉流。2.2 getRoomPlayInfo的请求参数与返回结构拿到真实房间号之后核心接口是播放信息接口curl https://api.live.bilibili.com/xlive/web-room/v1/index/getRoomPlayInfo?room_id123456protocol0,1format0,1,2codec0,1qn10000platformwebptype8 \ -H User-Agent: Mozilla/5.0 \ -H Referer: https://live.bilibili.com/这个接口的参数层级比较多很多人第一次看会懵。拆开来看其实是两组概念一组是“你能接受什么格式”一组是“你想要什么清晰度”。protocol表示流传输协议0是HTTP-FLV1是HLS。要拿m3u8地址protocol必须包含1否则返回里只有flv流。format是封装格式0对应flv1对应ts2对应fmp4。codec是视频编码0是AVC也就是H.2641是HEVC也就是H.2652是AV1。一般不建议填太多AVC的兼容性最好国内外播放器基本都支持。qn是清晰度档位对应关系大致如下qn参数清晰度说明10000原画部分直播间需要大会员或主播开启400蓝光非所有直播间都提供250超清常规可用档位150高清兼容性较好80流畅基本一定可用platform建议固定为webptype传8即可这两个参数在实际使用中影响不大但官网页面就是这么传的保持一致最稳妥。接口返回的JSON结构很长最关键的部分在data.playurl_info.playurl.stream。这是一个数组里面每一个元素对应一种协议组合然后每种协议下面又有format数组和codec数组。我们要做的就是从这堆嵌套结构里找到protocol_name为http_hls、format_name为ts或fmp4的那一组再从组内第一个可用的codec里取出base_url和url_info数组。2.3 完整地址的拼接方式与过期时间判断从返回结构里拿到的东西不是直接能播放的完整URL需要拼接。规则是完整播放地址 url_info[0].host base_url url_info[0].extra其中host是CDN节点域名base_url是路径extra是鉴权签名参数通常是?expiresxxxtokenxxx格式。三者拼起来之后才是播放器真正能请求的地址。拼好之后一定要看一下expires字段。这个字段是过期时间戳单位是秒代表了这个地址能活到什么时候。B站的直播流地址有效期短则几分钟长则几小时取决于CDN策略。拿到地址后判断一下当前时间距离expires还有多久如果少于60秒说明这个地址已经快要失效了最好重新请求一次。拼地址这个环节是最容易出错的。很多人只取了base_url就往播放器里扔结果播放器报404还有人把host和base_url中间漏了斜杠导致拼接出来路径不对。我的建议是写一个小函数专门负责拼接和日志输出每拼一个地址就把host、base_url、extra分别打出来看一眼。3. 搭建PHP代理服务器同时解决跨域、防盗链和过期3.1 为什么前端直接请求API行不通理论上说浏览器里打开直播间页面时前端JS自己就调用了上面的接口。那是不是我们直接在前端JS里请求就行实际操作时会遇到两个问题。第一个是CORS跨域。B站API默认只允许来自live.bilibili.com的跨域请求你从自己的域名或者localhost去fetch浏览器会在控制台报Access-Control-Allow-Origin错误接口数据根本拿不到。有些人会用浏览器插件关掉跨域校验但这只能在自己电脑上调试没法交付给别人用。第二个是播放器的请求头不可控。就算你把播放地址拿到了丢给VLC或者电视机顶盒去播很多播放器不会主动带上Referer: https://live.bilibili.com/这个头甚至有些播放器的User-Agent跟浏览器相差很远。那么即使地址是有效的CDN校验请求头时也会把你当成盗链请求处理返回403。所以代理层是必须的。PHP代理服务器做的事情本质上就是三件替你请求API、补全所有必要的HTTP头、把结果转发给下游。播放器和前端只需要跟你的代理服务器通信不需要知道B站API的细节。3.2 核心代理代码接口转发先从最基础的一版写起。这个脚本接收room_id和qn参数返回JSON格式的播放地址?php // live_proxy.php // 用法: live_proxy.php?room_id123456qn10000 header(Content-Type: application/json; charsetutf-8); header(Access-Control-Allow-Origin: *); $roomId isset($_GET[room_id]) ? intval($_GET[room_id]) : 0; $qn isset($_GET[qn]) ? intval($_GET[qn]) : 250; if ($roomId 0) { die(json_encode([code -1, message room_id is required])); } function requestApi($url, $params) { $fullUrl $url . ? . http_build_query($params); $ch curl_init($fullUrl); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER true, CURLOPT_TIMEOUT 10, CURLOPT_SSL_VERIFYPEER false, CURLOPT_HTTPHEADER [ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://live.bilibili.com/, Origin: https://live.bilibili.com, ], ]); $body curl_exec($ch); $errno curl_errno($ch); curl_close($ch); if ($errno) { return null; } return json_decode($body, true); } // 第一步真实房间号与开播状态 $roomInfo requestApi(https://api.live.bilibili.com/room/v1/Room/room_init, [ id $roomId, ]); if (!$roomInfo || ($roomInfo[code] ?? -1) ! 0) { die(json_encode([code -2, message room_init failed])); } $realRoomId $roomInfo[data][room_id] ?? 0; $liveStatus $roomInfo[data][live_status] ?? 0; if ($liveStatus 0) { die(json_encode([code -3, message room not live])); } // 第二步获取播放信息重点是protocol要包含1hls $playInfo requestApi(https://api.live.bilibili.com/xlive/web-room/v1/index/getRoomPlayInfo, [ room_id $realRoomId, protocol 0,1, format 0,1,2, codec 0,1, qn $qn, platform web, ptype 8, ]); if (!$playInfo || ($playInfo[code] ?? -1) ! 0) { die(json_encode([code -4, message getRoomPlayInfo failed])); } $streams $playInfo[data][playurl_info][playurl][stream] ?? []; $targetPlayUrl null; $expires 0; foreach ($streams as $stream) { if (($stream[protocol_name] ?? ) ! http_hls) { continue; } foreach ($stream[format] as $fmt) { if (!in_array($fmt[format_name], [ts, fmp4])) { continue; } if (empty($fmt[codec])) { continue; } $codec $fmt[codec][0]; $urlInfo $codec[url_info][0] ?? null; if ($urlInfo) { $targetPlayUrl $urlInfo[host] . $codec[base_url] . $urlInfo[extra]; $expires $urlInfo[expire] ?? 0; break 2; } } } if (!$targetPlayUrl) { die(json_encode([code -5, message no available hls stream])); } echo json_encode([ code 0, data [ room_id $realRoomId, play_url $targetPlayUrl, expires $expires, ], ]);这个版本已经能完成核心任务了。有几个细节值得展开说。CURLOPT_SSL_VERIFYPEER我直接设成了false。生产环境这么做不太推荐但很多PHP环境的CA证书链不完整不关掉反而会因为证书校验失败导致请求报错。如果是自己用的工具关掉影响不大。http_build_query会自动处理参数编码比手拼字符串更安全。所有参数都传字符串形式实测中B站API对参数类型比较敏感整数和字符串混用偶尔会出问题。超时时间设了10秒。B站接口正常响应在200毫秒到1秒之间10秒已经非常宽裕但如果下游网络异常这个超时能保证代理层不会长时间卡死。3.3 更进一步m3u8透传与分片重写上面这版代理返回的是JSON适合给自己的代码调用。但如果你想把地址直接丢给VLC、PotPlayer、电视盒子这类播放器问题又来了这些播放器不会先调你的JSON接口再播。解决思路是把代理做成“播放器友好型”也就是直接输出m3u8内容。播放器请求你的代理地址代理去B站拉取m3u8内容然后把内容里的分片地址全部替换成你自己的代理地址这样播放器后续的每一个分片请求都会经过代理转发请求头完全由代理控制。?php // m3u8_proxy.php // 用法: m3u8_proxy.php?urlurlencoded m3u8地址 header(Content-Type: application/vnd.apple.mpegurl); header(Access-Control-Allow-Origin: *); $url isset($_GET[url]) ? urldecode($_GET[url]) : ; if (empty($url) || !filter_var($url, FILTER_VALIDATE_URL)) { http_response_code(400); exit(bad url); } $ch curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER true, CURLOPT_TIMEOUT 15, CURLOPT_SSL_VERIFYPEER false, CURLOPT_HTTPHEADER [ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Referer: https://live.bilibili.com/, ], ]); $m3u8 curl_exec($ch); $errno curl_errno($ch); curl_close($ch); if ($errno || empty($m3u8)) { http_response_code(502); exit(fetch m3u8 failed); } $lines explode(\n, $m3u8); foreach ($lines as $line) { $line trim($line); if ($line || isset($line[0]) $line[0] #) { continue; } // 只重写http开头的分片地址 if (strpos($line, http) 0) { $line m3u8_proxy.php?url . urlencode($line); } } unset($line); echo implode(\n, $lines);这段代码有几个地方要注意。HLS的m3u8文件分片有两种写法绝对地址和相对地址。B站实测返回的多是绝对地址也就是http(s)://开头所以重写时只处理这类行就能覆盖大多数情况。如果遇到相对地址就得拼上m3u8的目录前缀逻辑会复杂一截但在B站场景下基本用不到。重写分片的本质是让播放器把对CDN的请求转移到你的代理上。代理再带齐请求头去访问CDN。这样一来播放器本身的请求头是什么样就无所谓了。这个方法对VLC和大部分Android播放器都非常有效是我最终稳定用的方案。这里还藏着一个容易踩的点m3u8内容里以#开头的行是标签行比如#EXTINF这些行绝对不能重写否则播放器会解析失败。上面代码里用$line[0] #做了判断这步很重要。4. 高频翻车现场我替你踩过的那些坑4.1 直播接口返回码异常先看room_id和开播状态我最初开发的时候犯的最大错误是拿着直播间URL的数字直接去请求getRoomPlayInfo然后这个接口返回了一个code不为0的JSON。看报错完全摸不着头脑后来挨个接口试才发现是room_init这一步被跳过了。这里给新手一个排查经验任何一次接口异常先把返回的原始JSON完整打印出来看。B站API的code字段有特殊含义0是成功-404通常是房间不存在或者没有流-352是风控校验失败400一般是请求参数不对。不要只看message因为B站很多接口的message写得很随意。另外就是开播状态的判断。直播间没开播时getRoomPlayInfo经常返回空数据或者一个打不开的备用地址。一定要先用room_init拿到live_status等于1才继续往下走。这个顺序不能乱。4.2 播放器能打开m3u8却卡住或403问题在请求头有段时间我直接用API返回的地址丢给VLC标题栏能显示正在播放但画面一直出不来日志里能看到一条条403。这就是典型的请求头问题。VLC默认的User-Agent是VLC/3.0.18 LibVLC/3.0.18Referer默认留空。B站CDN看到这种请求头直接当成盗链。解决办法有两个方向一是在VLC里设置自定义头高级偏好设置里的http-referrer和http-user-agent二是走我前面写的m3u8代理。实际用下来代理方案更省心因为不需要给每台播放设备单独配。排查这类问题的思路也很固定先用命令行curl带Referer拉一遍地址能通说明地址没问题再用curl不带Referer拉一遍403了说明就是请求头问题。两步一对比问题就锁定了。4.3 原画拿不到只能拿到流畅或高清qn10000并不保证一定能拿到原画。实测中有几种情况会导致清晰度回退直播间本身没开原画、账号没有大会员权限、直播推流参数没达到原画标准。接口返回的可用清晰度列表是动态的它会根据当前登录状态和直播配置返回实际可用的档位。我在代理脚本里做了一件事先在返回数据里读取所有可用的qn列表如果请求的档位不在列表里就自动选一个最高的可用档位。这样虽然不能保证原画但至少返回的一定是能播的最高清晰度。不要硬编码清晰度回退逻辑必须写在代码里。另外还有一个细节某些直播间开了杜比或者全景声音频编码会和普通直播间不一样。如果你对音频兼容性有要求优先选codecavc的流它的音频轨道通常兼容性最好。4.4 访问频繁被风控代理层要做的事任何一个公开接口被高频调用都会触发风控。我第一次做批量监控的时候写了50个直播间同时去请求接口跑了几分钟之后所有请求开始返回-352然后IP被临时限制了一段时间。风控的触发条件通常不是单次请求而是单位时间内的总请求量。应对方案按优先级排序在代理层加缓存。同一个房间号在30秒内的播放地址请求直接返回缓存不要重复打B站API。播放地址本身有效期有几分钟30秒缓存完全不影响使用。批量请求时做限速。每个直播间之间至少间隔200到500毫秒可以用usleep实现。不要高频轮询。检查开播状态的话30秒到1分钟一轮足够了10秒一轮纯属自己找封。缓存逻辑很简单用一个PHP文件或者Redis都行。只缓存播放地址和过期时间不需要缓存完整JSON能省不少内存。5. 扩展玩法批量开播监控与m3u播放列表生成5.1 批量检测直播间状态代理跑通之后自然就想从“抓单个直播间”扩展成“监控一批直播间”。这个需求在直播数据统计、开播提醒、直播录制等场景都很常见。批量检测的核心是循环调用room_init接口收集每个房间号的开播状态和直播标题。我建议把房间号列表维护在一个独立的PHP文件里类似一个频道配置?php // channels.php return [ [name 直播间A, room_id 123456], [name 直播间B, room_id 234567], [name 直播间C, room_id 345678], ];检测脚本用一个循环去读取这个配置逐个请求room_init然后把结果汇总。这里必须加限速前面已经说过了不加迟早被风控。汇总结果可以用表格形式输出也可以写入一个JSON文件供其他系统调用。批量检测里还有一个实用技巧把所有房间号的流地址获取结果合并成一个索引文件。每次检查时先看缓存里有没有有效地址有就直接用没有再挨个重新请求。这样即使监控数量增加对B站API的实际请求频率也能保持在一个安全范围内。5.2 生成自更新的m3u播放列表很多人做B站直播源最终的落地场景是让电视盒子或者智能电视的播放器能直接像看IPTV一样选台。这种场景需要的就是一个m3u文件但B站直播地址会过期所以这个m3u文件必须是动态生成的定期刷新。思路是写一个生成脚本遍历频道配置逐个房间获取最新的m3u8代理地址然后拼出一个标准的m3u文件#EXTM3U #EXTINF:-1 tvg-logohttps://xxx group-titleB站,主播名字 http://your-server/m3u8_proxy.php?urlurlencoded地址生成频率建议5到10分钟一次不需要更频繁。为什么因为B站的播放地址有效期通常远大于这个时间5分钟一次的刷新足够保证每个频道拿到的地址都在有效期内。而且这种低频请求基本不会触发风控。这里有一个哲学问题值得说透很多人喜欢手动去网上找“最新B站直播源”然后复制粘贴到播放器里用两天坏了再去找新的。这其实是把能自动化的活硬生生做成了体力活。只要代理服务器和m3u生成脚本能跑起来地址过期的问题就被自动消化了你完全不需要关心CDN签名什么时候失效。m3u文件本身可以放在PHP的web目录下访问http://your-server/playlist.m3u就能拿到最新列表。只要生成脚本定时覆盖这个文件播放器每次要播的时候重新拉一下就能保持地址新鲜。5.3 播放器对接与实际使用体验对接完不同播放器之后我的使用体验是VLC对重写后的m3u8代理地址兼容性最好几乎不用额外配置PotPlayer偶尔会按自己的逻辑缓存m3u8内容建议每过一段时间手动刷新一下列表电视盒子上如果自带播放器不支持HLS可以考虑装一个支持HLS的第三方播放器比如MX Player或者Just Player。有一个坑必须提醒m3u文件里如果同时配置了大量频道部分播放器在解析时会一次性并发请求所有频道的流地址。如果这些地址全部指向你的代理而代理又需要逐个去B站拉取m3u8内容瞬间并发可能把服务器和B站风控都打爆。解决办法是在m3u生成脚本里只放实际在直播的频道没开播的房间不写入列表这样既减小了m3u文件体积也避免了无效请求。还有一个体验优化的小细节在m3u的频道名里加上直播标题。很多播放器在频道列表页会显示#EXTINF里的名称如果把当前直播标题写进去切台的时候一眼就能看到主播在播什么内容比自己记房间号方便得多。写在最后的个人体会这套方案我前前后后改了三版从最初硬编码地址到后来写了一个简单的Python拉流脚本最后才沉淀成PHP代理加m3u生成器这个架构。整体跑下来最深的感受是B站直播源抓取的核心难点从来不是“拿地址”这一步而是地址过期和请求头校验这两个隐性门槛。只要把代理层做好让所有请求都从代理统一出去后面所有的播放器兼容问题都会变得非常简单。如果你也想做类似的事情我的建议是从一个直播间开始调通再逐步扩展。第一版不要追求原画也不要追求同时监控几十个房间先拿一个房间把room_init、getRoomPlayInfo、地址拼接、m3u8透传这几个环节跑通后面的扩展都是水到渠成的事。最后再分享一个调试小技巧把代理脚本的错误信息写成JSON返回比如{code:-3,message:room not live}调试的时候配合Postman非常直观不要用裸文本报错否则后面对接播放器时你会被各种莫名其妙的缓存问题搞疯。