用原生 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 抛回来:
// 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 年之后加的,规则是:
- 先请求
nav接口,从返回的img_url/sub_url里各截一段拼出mixinKey(64 项打乱表,取 32 位); - 参数按 key 排序、
encodeURIComponent后拼成 query,去掉!'()*这几个字符; - 加时间戳
wts,再算w_rid = md5(query + mixinKey)。
// 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)套盒子的结构。合并 = 把两条轨的盒子装进同一个文件,再把数据块按时间对齐。
具体做了四件事:
- 重建
moov:取视频轨的ftyp/mvhd,把两条轨的trak都塞进去,track_ID重编为 1(视频)、2(音频),mvhd末尾的next_track_ID改成 3; - 重建
mvex:分片流必须有mvex/trex声明每轨的默认参数,这里按新 track_ID 各生成一份; moof + mdat按tfdt交错排列:tfdt是每个分片的解码时间,按它排序才能保证画面和声音对得上,同时间点让视频优先;- 逐个修偏移量:位置一变,
mfhd.sequence_number、tfhd.track_ID和trun.data_offset全都要跟着改。
// 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/ 报:
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%,就认定它是布局容器,继续往里走一层,直到拿到真正的内容项:
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、接口把 & 返回成 & 时都会对不上。
修复:以页面 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.js | mock chrome.downloads + fetch,验上限回退与 m3u | 10 |
落盘流程 saver.test.js | mock 三级降级链路 | 10 |
文件命名 naming.test.js | 加载真实的 background.js,只 mock 接口层 | 11 |
按钮定位 position.test.js | 假 DOM + vm 跑真实的 content.js | 8 |
最值得一提的是最后两个——它们跑的不是“复制一份逻辑来测”,而是直接执行生产代码:
- 命名测试把
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 与自测脚本)
三步装好:
- 解压 zip,得到一个
bili-oneclick-downloader文件夹(这层就是扩展根目录,里面直接是manifest.json); - 打开 Chrome 地址栏输入
chrome://extensions/(Edge 用edge://extensions/),打开右上角 开发者模式; - 点 加载已解压的扩展程序,选中刚才那个文件夹。
然后打开任意视频页,标题下方信息行末尾就会出现下载图标。文件默认落在 下载目录/bili/ 下,文件名就是视频标题(多 P 为 标题_P2.mp4,同名自动加 (1))。
想改代码的话,文件结构是这样:
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 跑的自测,不参与扩展加载下载走的是三级降级,出问题时看面板日志就知道停在哪一级:
chrome.downloads.download—— 主路径,绕过 CORS,有原生进度;- 失败则
fetch → Blob → <a download>—— 走内存,上限 500MB,需保持面板打开; - 再失败则把直链复制到剪贴板,提示粘到 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,能处理“两个分支”比写对“一个分支”重要; - 把不可见的东西变可见——按钮坐标、文件名、合并后的偏移量,全都做成可断言的数字,比“看起来对”可靠得多。