一个运行在本机的 Bilibili 解析与中转客户端。默认输出 CDN 原始直链;需要单条音视频、更高画质或公网分享时,再让本机介入。

1. 问题不在链接,而在上下文
B 站视频在网页里播放正常,复制到 VLC、PotPlayer 或 VRChat 里却经常遇到三种情况:直接 403、只有画面没有声音、直播播几秒就停。
原因通常不是“链接过期”,而是三个约束叠加:
- CDN 会校验
Referer和播放器User-Agent; - 高清画质使用 DASH,视频轨和音频轨是分开的;
- HLS 播放列表里是相对地址,子分片同样需要正确的请求头。
播放器拿到的是一个 URL,B 站要的却是一整套上下文。B2VRC 做的事情,就是把这套上下文补在本机,并封装成播放器可以直接打开的链接。
它的设计顺序很明确:直链优先,按需中转,边界清晰。 默认不经过本机,只有兼容性或画质需要时才启用中转。
2. 全局:一个进程,三层职责

整个系统只有一个 Node 进程,承担解析和中转两件事;ffmpeg 不常驻,只在需要合并或生成 HLS 时以子进程启动。
| 层 | 文件 | 职责 |
|---|---|---|
| 呈现层 | public/* |
界面、轨道选择、链接复制、播放控制 |
| 领域层 | server/bili.js、relay.js |
B 站数据 → 播放地址;媒体 → 可播放链接 |
| 基础设施 | index.js、store.js、stats.js |
路由、持久化、统计、安全 |
请求被分成三个面:
/api/*管理面:解析、登录、配置、统计;/r/p/h/v/s媒体面:转发、分片、合并;/静态面:单页界面。
管理面默认只允许本机 / 局域网,媒体面才对拿到链接的人开放。这个划分是整个安全模型的基础。
桌面版启动时,Electron 从 16100 开始找端口,设置 HOST=127.0.0.1、PORT、B2VRC_DATA_DIR,再动态导入 server/index.js,等 /api/health 返回后打开窗口。源码运行时则直接 npm start,默认监听 0.0.0.0:16100。
数据放在一个目录里:settings.json、cookies.json、links.json、stats.json、secret.key 和 hls/。写 JSON 时先写临时文件再原子改名,避免中断留下半个文件。
3. 解析层:把 B 站接口收拢成一种结构

3.1 输入归一化
用户可能粘贴完整链接、分享文案、BV 号、av 号,甚至 ep329017。解析层先把它们收敛成少量字段:bvid、aid、epId、seasonId、page。
如果整段文字里夹着 URL,只取第一个地址;如果是 b23.tv 短链,先跟随重定向。分 P 参数 p 会保留,后续用来选择 pages 里的某一集。
3.2 登录与 WBI
扫码登录分三步:申请二维码、显示 Data URL、轮询状态。真正的登录态在响应的 Set-Cookie 里。项目让所有 B 站请求都吸收 Cookie,因此 SESSDATA、bili_jct、buvid3 在播放、搜索等请求中刷新时也能被接住。
部分接口还要求 WBI 签名。密钥来自 nav 返回的两张图片地址,取文件名拼成 64 个字符,按固定置换表重排,截取 32 位作为 mixinKey:
function mixinKey(imgKey, subKey) {
const source = `${imgKey}${subKey}`;
return MIXIN_KEY_ENC_TAB
.map((index) => source[index] || '')
.join('')
.slice(0, 32);
}
function signWbiParams(params, keys) {
const all = { ...params, wts: Math.floor(Date.now() / 1000) };
const query = Object.keys(all)
.sort()
.map((key) => {
const value = String(all[key] ?? '').replace(/[!'()*]/g, '');
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join('&');
return `${query}&w_rid=${md5(query + mixinKey(keys.imgKey, keys.subKey))}`;
}
密钥缓存 30 分钟。签名接口失败时,代码会退回不带 WBI 的旧接口;这不是长久方案,但能多一次机会。
3.3 两路取流
真正决定画质的是 playurl。项目同时发两个请求:
- DASH:
fnval=4048,请求分离的视频轨和音频轨; - 合并流:
fnval=1&platform=html5&high_quality=1,请求已包含音视频的 MP4。
4048 是能力位的和:DASH 16 + HDR 64 + 4K 128 + 杜比音频 256 + 杜比视界 512 + 8K 1024 + AV1 2048。并行请求的分工是:DASH 是主路径,负责高画质轨道;合并流是补充,提供可直接播放的 MP4。普通视频的 DASH 失败会直接报错;番剧则在两个请求都失败时才报错,可以降级到合并流。
合并流通常是 720P 或 360P,这是 B 站普通 MP4 的限制。想要 1080P+,必须走 DASH,再由 ffmpeg 合并或生成 HLS。
3.4 归一化
B 站原始字段很多。项目把它们压成两类结果:kind: 'dash' 或 kind: 'progressive',并提供 videoTracks、audioTracks、combined.parts、defaultVideo、defaultAudio 和 referer。
默认轨道按固定顺序选:画质不超过请求的 qn、同画质优先 H.264、音频优先 30280(192K)。前端只需要判断 kind,不再理解 dash.video、durl、playurl_info 这些差异。
番剧走另一套 PGC 接口:pgc/view/web/season 取季和剧集,pgc/player/web/playurl 取流。剧集可能分散在多个字段里,项目会合并去重。未登录或权限不足时,接口可能返回 is_preview,页面会提示“当前为番剧试看片段”。
直播则把 protocol → format → codec → url_info 三层嵌套压平成轨道,按 HLS fMP4 > HLS TS > FLV 排序。浏览器不能直接播放 FLV,所以页面内预览只会选 HLS。
4. 中转层:四种链接

| 出口 | 路径 | 做什么 | 代价 |
|---|---|---|---|
| 直链 | CDN 原始地址 | 不经过本机 | 受 UA / Referer / 有效期限制 |
| 完整文件 | /p/:id.mp4 |
代理合并 MP4,保留 Range 与时长 | 画质通常 720P |
| HLS | /h/:id/index.m3u8 |
ffmpeg 生成 event playlist + TS 分片 | 首片等待、占 CPU |
| MP4 | /v/:id.mp4 |
ffmpeg 输出单条 fMP4 | 无 Content-Length,拖动受限 |
4.1 流式转发
所有媒体代理最终走到 relayMedia():fetch 拿到 ReadableStream,接两个计量用的 Transform,再 pipe 给响应:
const stream = Readable.fromWeb(upstream.body);
const inboundMeter = new Transform({
transform(chunk, encoding, callback) {
recordConnectionBytes(conn, { inBytes: chunk.length });
callback(null, chunk);
},
});
const outboundMeter = new Transform({
transform(chunk, encoding, callback) {
recordConnectionBytes(conn, { outBytes: chunk.length });
callback(null, chunk);
},
});
res.on('close', () => {
if (!res.writableEnded) abortController.abort();
});
stream.pipe(inboundMeter).pipe(outboundMeter).pipe(res);
不缓冲整个文件;原样透传 Range、Content-Range、Content-Length、ETag;客户端断开就中止上游。跟随重定向后还会再检查一次最终域名是否在白名单内。
「完整文件」模式代理的是 B 站已经合并好的 MP4,字节没有重新封装,所以播放器能显示时长、拖动、续传。这是它比 HLS / MP4 更适合做默认中转的原因。
4.2 HLS 重写
远程 HLS 播放列表里的子分片通常是相对路径。直接交给播放器,播放器会按当前域名请求,缺少 Referer / UA,于是 403。
项目把每一行 URI 重写成 /s/<token>:
function signPlaylistUrl(rawUrl, referer) {
const absolute = new URL(rawUrl).href;
if (!isAllowedMediaUrl(absolute)) return rawUrl;
const token = signToken({
k: 'media',
u: absolute,
r: referer,
});
return `/s/${token}`;
}
rewritePlaylist() 同时处理普通行和 URI="..." 标签参数;data: 与 skd: 会跳过。签名使用 HMAC-SHA256,payload 里包含真实地址和 Referer,/s/:token 验证通过后复用 relayMedia() 转发。
这样不用为每个分片存一条记录,修改 URL 也无法换成别的域名,签名比较使用 timingSafeEqual。
4.3 ffmpeg 合并
用户选择「MP4」时,ffmpeg 把视频轨和音频轨 copy 成一条 fMP4,从 stdout 直接写给响应:
addInput(args, videoUrl, referer);
if (audioUrl) addInput(args, audioUrl, referer);
args.push('-map', '0:v:0');
args.push('-map', audioUrl ? '1:a:0?' : '0:a:0?');
args.push(
'-map_metadata', '-1',
'-c', 'copy',
'-movflags', 'frag_keyframe+empty_moov+default_base_moof',
'-f', 'mp4',
'pipe:1',
);
-c copy 不重新编码;frag_keyframe+empty_moov 让 MP4 可以边生成边播放。30 秒没有输出就返回 504;同时最多 6 个合并进程;res.write() 返回 false 时暂停 ffmpeg 的 stdout,等 drain 再恢复。
代价是输出没有 Content-Length 和 Range,部分播放器会把它当直播流,进度条不可拖或只能有限拖动。
4.4 本地 HLS
「HLS」模式让 ffmpeg 在本地生成 event playlist:4 秒一个分片,temp_file 先写临时文件再改名,服务端等 index.m3u8 和第一个分片出现后再返回播放列表。
结果兼容性最好,已经生成的部分可以拖动;代价是首片通常要等 2–10 秒,长视频会占 CPU 和磁盘。服务端限制同时最多 10 个 HLS 会话,旧目录 7 天后清理。
5. 统计与安全

统计分成四层:session(本次运行)、lifetime(跨启动累计)、daily(自然日)、connection(单条连接)。字节在数据流过时累加;带宽每秒采样一次,保留最近 60 个点。
不同出口的计量方式不同:文件直通、直播代理和 HLS 分片代理会同时统计回源与分发;ffmpeg 合并和本地 HLS 生成看不到 ffmpeg 自己的下载,只统计分发给客户端的流量。
安全上有五道防线:
- 管理面隔离:
/api/*只允许本机 / 局域网,出现任何转发头就拒绝; - 媒体域名白名单:只代理 B 站媒体 CDN,重定向后再次校验;
- 不可猜测的链接:9 字节随机 ID(72 位熵),HLS 子链接 HMAC-SHA256 签名;
- 输入与进程安全:校验协议与 userinfo,ffmpeg 直接
spawn不经过 shell,分片名走正则; - 最小暴露:公网地址只保留 origin,默认关闭公网管理,Cookie 只落本机。
| 威胁 | 防线 | 结果 |
|---|---|---|
公网访客调用 /api |
管理面隔离 | 403 |
| 用中转代理任意网站 | 域名白名单 | 400 |
| 通过 302 跳到恶意地址 | 重定向二次校验 | 502 |
| 枚举短链接 | 72 位随机 ID | 不可行 |
| 篡改 HLS 子链接 | HMAC + 常量时间比较 | 403 |
| 一次开太多 ffmpeg | 合并 6 / HLS 10 上限 | 429 |
仍然需要注意:Cookie 是明文;ALLOW_PUBLIC_ADMIN=1 很危险;分享链接没有鉴权;FRP 暴露的是本机服务,分享结束建议关闭隧道。
6. 使用指南

6.1 安装
推荐安装版:
dist\B2VRC-1.0.0-setup.exe
也提供免安装便携版 dist\B2VRC-1.0.0-portable.exe。两者都不需要安装 Node.js。
从源码运行:
npm install
npm start
浏览器打开 http://127.0.0.1:16100。源码数据在项目 data/,安装版在 %APPDATA%\B2VRC\data。
exe 未签名,Windows SmartScreen 可能提示“未知发布者”,选择“更多信息 → 仍要运行”。
6.2 登录
点右上角「登录 B 站」,用哔哩哔哩手机 App 扫码并确认。登录成功后,下一次解析会刷新清晰度列表。
也可以手动导入包含 SESSDATA 的 Cookie。Cookie 过期后重新扫码即可。
6.3 视频、番剧、直播
支持 BV / av / b23.tv 短链、带分 P 的链接、番剧 ep / ss、直播间号。
右侧「播放器直链」默认是直链模式,输出 B 站原生合并 MP4。需要更高画质或播放器 403 时,切换到「本地中转」:
| 场景 | 选择 |
|---|---|
| 浏览器、PotPlayer 正常播放 | 直链 |
| VLC / VRChat 提示 403 | 本地中转 → 完整文件 |
| 需要进度条、时长、拖动 | 本地中转 → 完整文件 |
| 需要 1080P+ / 4K | 本地中转 → HLS 或 MP4 |
番剧首次解析会自动切到中转,因为 PGC CDN 对 Referer 更严格。直播支持 HLS fMP4 / HLS TS / FLV;浏览器预览会自动选 HLS。
页面内「本地预览」始终按需创建临时中转,不会改变你复制的链接。
6.4 用 Sakura FRP 分享
npm start启动本机服务;- 在 Sakura FRP 创建 HTTP 隧道,本地地址
127.0.0.1,本地端口16100; - 复制公网地址,例如
https://demo.sakurafrp.com; - 在本机打开
http://127.0.0.1:16100,进入「中转设置」→「公网中转地址」填写并保存; - 切换到「本地中转」并重新复制链接。
分享时保持本机服务与隧道在线。公网访客只能使用已生成的媒体链接,不能调用管理接口。
7. 构建与边界
打包:
npm install
npm run build:exe
输出 B2VRC-1.0.0-setup.exe 和 B2VRC-1.0.0-portable.exe。electron-builder 配置里关闭了 asar(便于动态导入和 ffmpeg-static 路径),使用 asInvoker(不需要管理员权限),NSIS 按用户安装。
Electron 下载慢时,可以设置:
$env:ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
$env:ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/"
最后,把边界再说一遍:
- 仅用于个人学习和自用;
- 不批量下载、不二次分发、不绕过会员或付费限制;
- 中转只面向 B 站媒体 CDN,不是通用代理;
- 分享受版权保护的内容前,确认你有相应权利;
- Cookie 和
data/目录不要外传。
8. 结语
B2VRC 解决的不是“怎么下载 B 站视频”,而是一个更小、更具体的问题:
怎么让一个 B 站链接,在任意播放器里正常播放。
答案分三层:解析层把接口差异收拢成统一轨道;中转层把传输差异收拢成四种出口;前端只负责选择和复制。默认直链,按需中转,边界清晰——这既是使用顺序,也是这套代码的结构。
本文是 B2VRC 工程笔记的完整版。更细的接口、代理和安全实现,见系列分篇。
主题测试文章,只做测试使用。发布者:zy,转转请注明出处:https://zynetc.top/?p=9100