从 B 站链接到任意播放器:B2VRC 的完整实现与使用指南

从扫码登录、WBI 签名、DASH 轨道,到 Range 代理、HLS 重写与 ffmpeg 合并,一次讲清 B2VRC 的实现与使用方法。

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

B2VRC 工程笔记封面

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 自己的下载,只统计分发给客户端的流量。

安全上有五道防线:

  1. 管理面隔离:/api/* 只允许本机 / 局域网,出现任何转发头就拒绝;
  2. 媒体域名白名单:只代理 B 站媒体 CDN,重定向后再次校验;
  3. 不可猜测的链接:9 字节随机 ID(72 位熵),HLS 子链接 HMAC-SHA256 签名;
  4. 输入与进程安全:校验协议与 userinfo,ffmpeg 直接 spawn 不经过 shell,分片名走正则;
  5. 最小暴露:公网地址只保留 origin,默认关闭公网管理,Cookie 只落本机。
威胁 防线 结果
公网访客调用 /api 管理面隔离 403
用中转代理任意网站 域名白名单 400
通过 302 跳到恶意地址 重定向二次校验 502
枚举短链接 72 位随机 ID 不可行
篡改 HLS 子链接 HMAC + 常量时间比较 403
一次开太多 ffmpeg 合并 6 / HLS 10 上限 429

仍然需要注意:Cookie 是明文;ALLOW_PUBLIC_ADMIN=1 很危险;分享链接没有鉴权;FRP 暴露的是本机服务,分享结束建议关闭隧道。

6. 使用指南

B2VRC 主界面
主界面:顶部是视频解析与直播入口,右上角是收藏、设置、统计和登录。

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 分享

  1. npm start 启动本机服务;
  2. 在 Sakura FRP 创建 HTTP 隧道,本地地址 127.0.0.1,本地端口 16100;
  3. 复制公网地址,例如 https://demo.sakurafrp.com;
  4. 在本机打开 http://127.0.0.1:16100,进入「中转设置」→「公网中转地址」填写并保存;
  5. 切换到「本地中转」并重新复制链接。

分享时保持本机服务与隧道在线。公网访客只能使用已生成的媒体链接,不能调用管理接口。

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

赞 (0)
zy的头像zy
McLite:轻量 Minecraft 启动器完整实现与使用指南
上一篇 6天前
PixivDesk:从零构建一个可断点续传的 Pixiv 桌面客户端
下一篇 6天前

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注