Skip to content

用原生 JS 写一个 B 站一键下载扩展:不读 Cookie、不用 ffmpeg,音视频还能自己合并 ​

需求很朴素:在 B 站视频页点一下,把正在看的这个视频存到本地。

市面上的下载器大多要走「粘贴链接 → 解析 → 选清晰度 → 等合并」的流程,有的还要装桌面客户端、装 ffmpeg、甚至要登录扫码。而我的使用场景其实很窄——一个页面、一个视频、一次点击,下完就走。

所以这篇不聊“怎么破解”,只记录一个只服务自己的小工具是怎么写出来的:它读什么、不读什么、哪些地方必须做取舍,以及四个真正卡住过我的坑。

项目说明
形态Chrome / Edge 扩展(Manifest V3)
体积10 个源文件,约 2300 行原生 JS,零第三方依赖
需要Chrome 100+,不需要登录、Cookie、ffmpeg、Node
画质未登录状态下由 B 站决定,通常 480P~720P

直接下载:bili-oneclick-downloader.zip(61.7 KB,含源码与 README,解压后按文末三步加载即可)

下面按「为什么做 → 怎么做的 → 踩了哪些坑」的顺序展开;赶时间的话可以直接跳到第八节,装起来用。

一、开发目的:把“存一份”这件事做到一次点击 ​

先说我不要什么,因为约束决定了架构:

约束原因
不读 Cookie、不登录Cookie 是账号凭据,一旦进扩展就有泄露风险;而且“要登录”这件事本身就把工具变重了
不依赖 ffmpeg装外部二进制就要处理平台差异、路径、杀软误报,一个“点一下”的工具不该带这种负担
不用框架、不用打包器扩展一共十来个文件,引入 webpack/vite 只会让“改一行要等编译”变成日常
权限最小化只申请 downloads / activeTab / scripting / clipboardWrite,域名只放 bilibili 与它的 CDN

代价也很明确:拿不到会员画质,番剧和课程下不了(这些必须登录鉴权)。这个边界我在设计阶段就接受了——工具的目标是“自己有权保存的内容存一份离线”,不是“把 B 站搬空”。

二、它长什么样 ​

装好之后,打开任意 bilibili.com/video/BV... 页面,标题下方那行信息(播放量 / 弹幕 / 日期)的末尾会多出一个 24×24 的蓝色图标(B 站按钮蓝 #00AEEC),点一下就开始下载。

这个位置是改过一次的。第一版放在播放器右上角,直接盖住画面;第二版按“信息行最后一个元素的右边”定位,结果按钮被顶到了侧边栏和详情列之间的空隙里——因为那行只有一个满宽的外壳元素。这个坑下面会详细讲。

点扩展图标则是完整面板:

区域内容
信息卡标题、实际拿到的清晰度(分轨 会标出来)、需要下载几个文件、有无字幕
分P 下拉单 P 直接下;多 P 可选 第2P,也能 全部 3P 串行提交
三个按钮下载视频 / 只要字幕 .srt / 复制地址(粘到 IDM、aria2、Motrix 都行)
进度与日志实时进度条 + 带时间戳的日志,最多 300 行,可一键复制

日志这块是我刻意做厚的——下载类工具出问题时,用户唯一能提供的就是日志。每条都带时间戳,解析失败时会把 qn 尝试顺序、命中的接口、降级原因一条条打出来。

三、技术路线:地址是怎么拿到的 ​

所有跨域请求都在 background 的 service worker 里发(credentials: 'omit',不带任何凭据),页面侧只负责读信息和画按钮。取地址有两条路,优先走第一条,失败才降级:

1. 先用页面内嵌的播放信息 ​

B 站会把当前视频的播放信息直接塞进页面的 window.__playinfo__ 和 __INITIAL_STATE__。但扩展的 content script 跑在隔离世界(isolated world),读不到页面的 window 变量。

解法是注入一段跑在页面主世界(MAIN world)的脚本,让它把信息通过 CustomEvent 抛回来:

javascript
// inject.js(MAIN world):读完抛事件
window.dispatchEvent(new CustomEvent('bili-dl-pageinfo', {
  detail: { bvid, cid, p, title, pages, playinfo: window.__playinfo__ }
}));

之所以不用 world: "MAIN" 这个更简洁的声明方式,是因为它需要 Chrome 111+,而 minimum_chrome_version 我定在 100。

2. 接口兜底:WBI 签名 ​

页面信息过期或拿不到时,走接口:view 取标题与 cid → wbi/playurl 取地址。后者需要 WBI 签名,B 站 2023 年之后加的,规则是:

  1. 先请求 nav 接口,从返回的 img_url / sub_url 里各截一段拼出 mixinKey(64 项打乱表,取 32 位);
  2. 参数按 key 排序、encodeURIComponent 后拼成 query,去掉 !'()* 这几个字符;
  3. 加时间戳 wts,再算 w_rid = md5(query + mixinKey)。
javascript
// utils.js:签名核心
var query = Object.keys(params).sort().map(function (k) {
  return encodeURIComponent(k) + '=' + encodeURIComponent(String(params[k]).replace(/[!'()*]/g, ''));
}).join('&');
return query + '&w_rid=' + md5(query + mixinKey);

MD5 是自己手写的(TextEncoder + DataView 处理 UTF-8),因为 service worker 里没有 Node 的 crypto。写完拿 Node 的 crypto 对了 5 组输入(含中文),结果一致才敢用。

3. 画质策略:先要“能直接播的单文件” ​

这是整个工具最关键的取舍。B 站返回地址有两种形态:

形态特点处理
durl(单文件)一条流,画面和声音在一起,下载即可播首选,按 80 → 64 → 32(1080P→720P→480P)依次试
dash(分轨)视频流和音频流分开,必须合并次选;只给 dash 时自动合并(下一节)

默认策略是“先抢 durl 单文件”——因为对普通用户来说,“下载完能直接双击播放”比“多 200P 清晰度”重要得多。面板里可以勾「画质优先」,这时才会主动去探 dash。

四、最难的一块:没有 ffmpeg 怎么合并音视频 ​

B 站的 dash 给的是两条独立的 fMP4 分片流(ISO BMFF,ftyp + moov + 一串 moof + mdat)。传统做法是 ffmpeg -i v.m4s -i a.m4s -c copy out.mp4,而我要在浏览器里用纯 JS 干同样的事。

原理其实不复杂:MP4 就是一个盒子(box)套盒子的结构。合并 = 把两条轨的盒子装进同一个文件,再把数据块按时间对齐。

具体做了四件事:

  1. 重建 moov:取视频轨的 ftyp / mvhd,把两条轨的 trak 都塞进去,track_ID 重编为 1(视频)、2(音频),mvhd 末尾的 next_track_ID 改成 3;
  2. 重建 mvex:分片流必须有 mvex/trex 声明每轨的默认参数,这里按新 track_ID 各生成一份;
  3. moof + mdat 按 tfdt 交错排列:tfdt 是每个分片的解码时间,按它排序才能保证画面和声音对得上,同时间点让视频优先;
  4. 逐个修偏移量:位置一变,mfhd.sequence_number、tfhd.track_ID 和 trun.data_offset 全都要跟着改。
javascript
// muxer.js:data_offset 是相对 base 的,搬动 moof 后要整体平移
var baseEff = f.baseOff >= 0 ? u64(b, f.baseOff) : f.oldPos;
for (var i = 0; i < f.trunOffsets.length; i++) {
  var off = f.trunOffsets[i];
  w32(b, off, u32(b, off) + baseEff - f.oldPos);
}
if (f.baseOff >= 0) w64(b, f.baseOff, newPos);   // 显式 base_data_offset 也要改

最容易踩的一点:trun 的 flag 有两种——default-base-is-moof(偏移相对当前 moof)和显式 base_data_offset。两种都要处理,否则合并出来的文件能播但音画不同步,或者干脆一片绿屏。

合并出来的仍是分片 MP4,PotPlayer / VLC / mpv / Chrome 都能直接播;极少数老旧的系统自带播放器可能不认。

硬约束:合并要把两条轨道读进内存,上限设了 700MB(约 25~30 分钟的 720P),超了就退回“分别保存两条轨道”并明确告知,绝不静默产出没声音的文件。

五、多段视频:从源头避开,而不是硬拼 ​

还有一种情况是 durl 被切成好几段。非分片的 MP4 无法用纯 JS 无损拼接(要重算 sample table、chunk offset,工程量和风险都远超收益)。

所以策略不是“想办法拼”,而是改去要 dash——一条视频流 + 一条音频流,合并一次就得到一个完整文件,从源头绕开分段。只有当 dash 也拿不到、确实只剩多段 durl 时,才按段保存,并额外生成一个同名 .m3u 播放列表:把列表和视频放同一目录,双击就能用 PotPlayer 连续播放。

这条经验挺通用:遇到“合不了”的格式,先想想能不能换一种“不用合”的输入。

六、四个真正卡住过我的坑 ​

坑一:目录名以 _ 开头,扩展直接加载失败 ​

自测代码当时放在 _dev/ 目录,chrome://extensions/ 报:

text
Cannot load extension with file or directory name _dev.
Filenames starting with "_" are reserved for use by the system.

根因:MV3 扩展包内,除 _locales 外任何以 _ 开头的文件或目录都会被拒绝(_ 前缀在 Chrome 里是系统保留的)。改名 tests/ 即可,但那半天我一直在怀疑 manifest 写错了。

坑二:按钮被“挤”到整行最右边 ​

按“信息行最后一个子元素的右侧”定位,按钮却跑到了侧边栏和详情列之间。

把那行 DOM 贴出来才看明白:.video-info-meta 里只有一个满宽的外壳 .video-info-detail-list,真正的内容项(播放量、弹幕、日期、含AI生成内容 标签)都在它里面。所以“最后一个子元素的右边缘”= 整行右边缘。

修复:让定位逐层下钻——只要「最靠右的可见子元素」宽度 ≥ 父宽的 90%,就认定它是布局容器,继续往里走一层,直到拿到真正的内容项:

javascript
function contentBox(row) {
  var node = row, box = row.getBoundingClientRect();
  for (var d = 0; d < 8; d++) {
    var last = rightMostChild(node);
    if (!last) break;
    if (last.rect.width < box.width * 0.9) return last.rect;  // 到内容层了
    node = last.el; box = last.rect;                          // 还是外壳,继续钻
  }
  return box;
}

顺带说一句:按钮全程是 position: fixed 浮层,用 getBoundingClientRect() 算坐标,不往页面 DOM 里插任何节点——所以无论怎么改尺寸位置,都不会让页面错位。

坑三:下载下来的文件名不是页面标题 ​

第一版直接用接口 view.title 命名。但接口标题和页面 h1 显示的文字不保证一致:合集、多 P、接口把 & 返回成 &amp; 时都会对不上。

修复:以页面 h1.video-title 为准(先读 title 属性,长标题不截断;再退 textContent),接口标题降为兜底。同时解决一个隐蔽问题:页面标题只属于“当前这一 P”,所以标题要和它的 P 号一起传过去,切到别的 P 时自动回落到接口标题,否则会把 P1 的名字安到 P2 上。

清理规则:非法字符(\ / : * ? " < > |)换空格、HTML 实体还原、截断 80 字、全角标点保留。

坑四:fnval=1 也会回 dash,代码直接崩 ​

请求单文件(fnval=1)时,少数稿件服务端依然返回 dash 结构。我原来写的是“拿到结果就按 durl 处理”,于是 bestDurl.durl.length 抛了 TypeError,整个解析流程挂掉。

修复:先判 result.mode,把两者分别存进 bestDurl / bestDash,最后再按“单文件优先、画质优先、能不能合并”三条规则挑。永远不要假设服务端会守约——这大概是这次开发最实在的一条经验。

七、没有浏览器环境,怎么保证它是对的 ​

扩展没法像普通网页那样开个 dev server 点点看,所以我用 Node 搭了一套「假环境」,把能测的部分全测了:

测试手段断言数
合并器 muxer.test.js现场构造两条 fMP4 分片流,合并后逐项验结构18
合并流程 merge.test.jsmock chrome.downloads + fetch,验上限回退与 m3u10
落盘流程 saver.test.jsmock 三级降级链路10
文件命名 naming.test.js加载真实的 background.js,只 mock 接口层11
按钮定位 position.test.js假 DOM + vm 跑真实的 content.js8

最值得一提的是最后两个——它们跑的不是“复制一份逻辑来测”,而是直接执行生产代码:

  • 命名测试把 background.js 真实加载起来,只替换 getView / resolveStreams 两个方法,然后发一条 resolve 消息,断言最终文件名是 全网都在吹的AI神技,我们一测就露馅了.mp4;
  • 定位测试用假 DOM 复刻那行信息(满宽外壳 + 四个内容项),跑真实的 content.js,断言按钮的 left 是 482px(内容项右边缘 + 10)而不是 1210px(整行右边缘)。

合并器的校验最严:合并后重新解析,检查两条 trak、track_ID 是否为 1/2、sequence_number 是否 1~8 连续、每个 data_offset 是否真的指向 mdat 的载荷起点,最后逐字节比对合并前后的媒体数据是否完全一致。

57 项断言,全绿。

八、下载与安装 ​

下载地址:bili-oneclick-downloader.zip(61.7 KB,22 个文件,含源码、README 与自测脚本)

三步装好:

  1. 解压 zip,得到一个 bili-oneclick-downloader 文件夹(这层就是扩展根目录,里面直接是 manifest.json);
  2. 打开 Chrome 地址栏输入 chrome://extensions/(Edge 用 edge://extensions/),打开右上角 开发者模式;
  3. 点 加载已解压的扩展程序,选中刚才那个文件夹。

然后打开任意视频页,标题下方信息行末尾就会出现下载图标。文件默认落在 下载目录/bili/ 下,文件名就是视频标题(多 P 为 标题_P2.mp4,同名自动加 (1))。

想改代码的话,文件结构是这样:

text
bili-oneclick-downloader/
├── manifest.json     MV3 清单
├── popup.html/css/js 面板:解析 / 展示 / 进度 / 日志
├── saver.js          落盘流程:合并 → 三级降级 → 多段 m3u
├── background.js     service worker:消息路由、下载调度
├── api.js            view / playurl / nav / 字幕,含 WBI 签名
├── muxer.js          纯 JS fMP4 合并器
├── merge.js          合并流程:拉流、进度、保存
├── downloader.js     三级下载降级
├── utils.js          文件名清洗、SRT、MD5、WBI
├── content.js        页面侧:悬浮按钮与定位
├── inject.js         MAIN world:读页面播放信息
└── tests/            node 跑的自测,不参与扩展加载

下载走的是三级降级,出问题时看面板日志就知道停在哪一级:

  1. chrome.downloads.download —— 主路径,绕过 CORS,有原生进度;
  2. 失败则 fetch → Blob → <a download> —— 走内存,上限 500MB,需保持面板打开;
  3. 再失败则把直链复制到剪贴板,提示粘到 IDM / aria2。

九、边界与合规 ​

这个工具只做一件事,也只做到这里:

  • 不支持番剧、课程、付费内容——这些必须登录鉴权,而它不读 Cookie;
  • 不提供会员画质——未登录状态由 B 站决定(通常 480P~720P);
  • 不读取、不存储、不上传任何 Cookie 或账号信息,所有请求 credentials: 'omit';
  • 不调用任何第三方接口、图床或统计服务,无远程代码、无 eval。

仅用于下载你本人有权保存的内容(自己的稿件、已获授权的素材、个人离线观看)。不得用于批量抓取、二次分发或任何侵犯著作权与平台权益的行为,使用时须遵守哔哩哔哩用户协议及相关法律法规。因使用产生的版权与账号风险由使用者自行承担。

另外提醒:B 站前端结构随时会变。信息行类名、__playinfo__ 字段、WBI 的混淆表都可能调整,改版后按钮可能不出现或解析失败——这是所有“贴着别人页面写”的工具的宿命,不在代码质量能解决的范围内。

小结 ​

决策取舍
不读 Cookie拿不到会员画质与番剧,换来零账号风险与零登录成本
单文件 durl 优先放弃一点清晰度,换来“下载完双击就能播”
自己写 fMP4 合并多写 300 行代码,换来不依赖 ffmpeg、跨平台零安装
多段 durl 改要 dash不硬拼非分片 MP4,从源头绕开分段
按钮用 fixed 浮层不用改页面 DOM,永远不会把布局挤歪
把中间态做成日志日志厚一点,排障时少问十句

写完回头看,这个工具真正的难点不是“怎么拿到地址”(那是查文档和试错),而是三件事:

  • 接受边界——不登录就一定拿不到某些内容,与其做一堆半残的绕过,不如把边界写清楚;
  • 不假设服务端守约——fnval=1 也可能回 dash,能处理“两个分支”比写对“一个分支”重要;
  • 把不可见的东西变可见——按钮坐标、文件名、合并后的偏移量,全都做成可断言的数字,比“看起来对”可靠得多。

基于 VitePress & @sugarat/theme 构建