// ==UserScript==
// @name B站视频下载助手(视频/音频/封面/字幕)
// @namespace bilibili-downloader
// @version 1.1.1
// @description 在B站视频页注入下载面板:下载各清晰度视频与音频(DASH 分离保存)、封面、CC字幕;最高支持 4K(需登录账号)。
// @author you
// @match https://www.bilibili.com/video/*
// @grant GM_xmlhttpRequest
// @connect api.bilibili.com
// @connect www.bilibili.com
// @connect bilivideo.com
// @connect bilivideo.cn
// @connect akamaized.net
// @connect hdslb.com
// @require https://cdn.jsdelivr.net/npm/js-md5@0.8.3/src/md5.min.js
// @run-at document-idle
// @noframes
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const PROMO = {
sites: ['https://www.xygy.top/', 'https://www.vnoteai.cn/'],
popupChance: 0.3,
};
let promoIdx = 0;
function promoOnDownload() {
if (PROMO.sites.length && Math.random() < PROMO.popupChance) {
window.open(PROMO.sites[promoIdx++ % PROMO.sites.length], '_blank', 'noopener');
}
}
/* ---------------- WBI 签名(B 站新版 API 需要) ---------------- */
const MIXIN_TAB = [46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35,
27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16,
24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63,
57, 62, 11, 36, 20, 34, 44, 52];
let wbiCache = { key: null, ts: 0 };
async function getWbiKey() {
if (wbiCache.key && Date.now() - wbiCache.ts < 3600 * 1000) return wbiCache.key;
const r = await (await fetch('https://api.bilibili.com/x/web-interface/nav', { credentials: 'include' })).json();
const img = (r.data?.wbi_img?.img_url || '').split('/').pop().split('.')[0];
const sub = (r.data?.wbi_img?.sub_url || '').split('/').pop().split('.')[0];
let raw = '';
MIXIN_TAB.forEach(i => { raw += (img + sub)[i]; });
wbiCache = { key: raw.slice(0, 32), ts: Date.now() };
return wbiCache.key;
}
function signWbi(params, key) {
const wts = Math.floor(Date.now() / 1000);
const p = { ...params, wts };
const query = Object.keys(p).sort()
.filter(k => p[k] !== undefined && p[k] !== null)
.map(k => `${encodeURIComponent(k)}=${encodeURIComponent(p[k])}`)
.join('&');
return { ...p, w_rid: md5(query + key) };
}
async function wbiGet(path, params) {
try {
const key = await getWbiKey();
const qs = new URLSearchParams(signWbi(params, key)).toString();
return await (await fetch(`https://api.bilibili.com${path}?${qs}`, { credentials: 'include' })).json();
} catch (e) {
// WBI 签名失败时退回旧版免签接口
const qs = new URLSearchParams(params).toString();
const legacy = path.replace('/wbi/', '/');
return await (await fetch(`https://api.bilibili.com${legacy}?${qs}`, { credentials: 'include' })).json();
}
}
/* ---------------- 通用工具 ---------------- */
const QN_FALLBACK = {
127: '8K 超高清', 126: '杜比视界', 125: 'HDR 真彩', 120: '4K 超清',
116: '1080P 60帧', 112: '1080P 高码率', 80: '1080P 高清', 74: '720P 60帧',
64: '720P 高清', 32: '480P 清晰', 16: '360P 流畅'
};
const AUDIO_LABEL = { 30216: '64K', 30232: '132K', 30280: '320K', 30250: '杜比全景声', 30251: 'Hi-Res 无损' };
function codecShort(codecs) {
if (/^avc/i.test(codecs)) return 'AVC';
if (/^hev/i.test(codecs)) return 'HEVC';
if (/^av01/i.test(codecs)) return 'AV1';
return codecs;
}
function sanitize(name) {
return name.replace(/[\\/:*?"<>|\r\n]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120);
}
function fmtSrtTime(sec) {
const ms = Math.round((sec - Math.floor(sec)) * 1000);
const s = Math.floor(sec);
const pad = (n, w) => String(n).padStart(w, '0');
return `${pad(Math.floor(s / 3600), 2)}:${pad(Math.floor(s % 3600 / 60), 2)}:${pad(s % 60, 2)},${pad(ms, 3)}`;
}
function jsonToSrt(body) {
return body.map((line, i) =>
`${i + 1}\n${fmtSrtTime(line.from)} --> ${fmtSrtTime(line.to)}\n${line.content.replace(/\n/g, ' ')}\n`
).join('\n');
}
async function fetchBlob(urls, onProgress) {
let lastErr = null;
for (const url of urls) {
try {
const res = await fetch(url, { credentials: 'omit' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const total = +(res.headers.get('Content-Length') || 0);
if (!res.body) {
const b = await res.blob();
onProgress && onProgress(b.size, b.size);
return b;
}
const reader = res.body.getReader();
const chunks = [];
let got = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
got += value.length;
onProgress && onProgress(got, total);
}
return new Blob(chunks);
} catch (e) {
lastErr = e;
// fetch 失败(多为跨域),退回 GM_xmlhttpRequest
if (typeof GM_xmlhttpRequest !== 'function') continue;
try {
return await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'blob',
headers: { Referer: 'https://www.bilibili.com/' },
onprogress: r => onProgress && onProgress(r.loaded, r.total || 0),
onload: r => (r.status >= 200 && r.status < 300)
? resolve(r.response)
: reject(new Error('HTTP ' + r.status)),
onerror: () => reject(new Error('网络错误'))
});
});
} catch (e2) { lastErr = e2; }
}
}
throw lastErr || new Error('下载失败');
}
function saveBlob(blob, name) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 5000);
}
/* ---------------- 面板 UI ---------------- */
const state = {
bvid: null, p: 1,
view: null, // view API 结果(标题/封面/分P)
dash: null, // playurl 的 dash 部分
durl: null, // 老视频无 dash 时的兜底
acceptDesc: {}, // qn -> 清晰度名称
audioEntries: [], // 音频流列表
subtitleList: [], // CC 字幕列表
busy: false,
};
let panel, els = {};
function buildPanel() {
panel = document.createElement('div');
panel.id = 'bili-dl-panel';
panel.innerHTML = `
B站下载助手✕
`;
document.body.appendChild(panel);
els = {
p: panel.querySelector('#bd-p'), q: panel.querySelector('#bd-q'),
c: panel.querySelector('#bd-c'), a: panel.querySelector('#bd-a'),
s: panel.querySelector('#bd-s'),
v: panel.querySelector('#bd-v'), au: panel.querySelector('#bd-au'),
cv: panel.querySelector('#bd-cv'), su: panel.querySelector('#bd-su'),
prog: panel.querySelector('.prog'), progIn: panel.querySelector('.prog .in'),
st: panel.querySelector('.st'),
};
// 拖动
const hd = panel.querySelector('.hd');
let sx, sy, ox, oy, drag = false;
hd.addEventListener('mousedown', e => {
if (e.target.classList.contains('x')) return;
drag = true; sx = e.clientX; sy = e.clientY;
const r = panel.getBoundingClientRect(); ox = r.left; oy = r.top;
e.preventDefault();
});
window.addEventListener('mousemove', e => {
if (!drag) return;
panel.style.left = (ox + e.clientX - sx) + 'px';
panel.style.top = (oy + e.clientY - sy) + 'px';
panel.style.right = 'auto'; panel.style.bottom = 'auto';
});
window.addEventListener('mouseup', () => drag = false);
hd.querySelector('.x').addEventListener('click', () => {
panel.remove(); panel = null; els = {};
});
els.p.addEventListener('change', () => loadMedia());
els.q.addEventListener('change', fillCodec);
els.v.addEventListener('click', () => downloadVideo());
els.au.addEventListener('click', () => downloadAudio());
els.cv.addEventListener('click', () => downloadCover());
els.su.addEventListener('click', () => downloadSubtitle());
renderAds();
}
function renderAds() {
const box = panel.querySelector('#bd-ads');
box.innerHTML = '推广
';
PROMO.sites.forEach(u => {
const a = document.createElement('a');
a.href = u;
a.target = '_blank';
a.rel = 'noopener noreferrer';
a.textContent = u.replace(/^https?:\/\//, '').replace(/\/+$/, '');
box.appendChild(a);
});
}
function setBusy(busy) {
state.busy = busy;
[els.v, els.au, els.cv, els.su].forEach(b => b.disabled = busy);
els.prog.style.display = busy ? 'block' : 'none';
if (!busy) { els.progIn.style.width = '0'; }
}
function setStatus(text) { els.st.textContent = text; }
function showProgress(got, total) {
if (total) {
els.progIn.style.width = Math.min(100, got / total * 100) + '%';
setStatus(`下载中 ${(got / 1048576).toFixed(1)}MB / ${(total / 1048576).toFixed(1)}MB`);
} else {
els.progIn.style.width = '100%';
setStatus(`下载中 ${(got / 1048576).toFixed(1)}MB`);
}
}
/* ---------------- 数据加载 ---------------- */
function currentBvid() {
const m = location.pathname.match(/\/video\/(BV[0-9A-Za-z]+)/i);
return m ? m[1] : null;
}
async function loadVideoData() {
const bvid = currentBvid();
if (!bvid) return false;
const p = parseInt(new URLSearchParams(location.search).get('p') || '1', 10);
if (bvid === state.bvid && p === state.p) return true;
state.bvid = bvid; state.p = p;
setStatus('加载视频信息…');
const r = await (await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`, { credentials: 'include' })).json();
if (r.code !== 0) { setStatus('获取视频信息失败:' + r.message); return false; }
state.view = r.data;
// 分P下拉
els.p.innerHTML = '';
(state.view.pages || [{ page: 1, part: state.view.title, cid: state.view.cid }]).forEach(pg => {
const o = document.createElement('option');
o.value = pg.page;
o.textContent = `P${pg.page} ${pg.part}`.slice(0, 40);
els.p.appendChild(o);
});
els.p.value = String(p);
els.p.dataset.p = '1'; // 与 URL 不同时标记为手动切换
await loadMedia();
return true;
}
async function loadMedia() {
const page = state.view.pages.find(pg => pg.page === +els.p.value) || state.view.pages[0];
const cid = page.cid;
setStatus('加载播放数据…');
const res = await wbiGet('/x/player/wbi/playurl', {
bvid: state.bvid, cid, qn: 120, fnval: 4048, fourk: 1, fnver: 0
});
if (res.code !== 0 || !res.data) { setStatus('获取播放地址失败:' + (res.message || res.code)); return; }
(state.view.pages || []).forEach(pg => delete pg._media);
const media = {
dash: res.data.dash || null,
durl: res.data.durl || null,
subs: [],
};
page._media = media;
state.acceptDesc = {};
(res.data.accept_quality || []).forEach((qn, i) => {
state.acceptDesc[qn] = (res.data.accept_description || [])[i] || QN_FALLBACK[qn] || ('清晰度 ' + qn);
});
// 清晰度下拉(去重、从高到低)
els.q.innerHTML = '';
if (media.dash) {
const qids = [...new Set(media.dash.video.map(v => v.id))].sort((a, b) => b - a);
qids.forEach(qn => {
const o = document.createElement('option');
o.value = qn;
o.textContent = `${state.acceptDesc[qn] || qn}(${media.dash.video.find(v => v.id === qn).width}x${media.dash.video.find(v => v.id === qn).height})`;
els.q.appendChild(o);
});
fillCodec();
} else if (media.durl) {
const o = document.createElement('option');
o.value = 'durl'; o.textContent = '默认清晰度(该视频无 DASH 流)';
els.q.appendChild(o);
els.c.innerHTML = '';
}
// 音质下拉(普通 + Hi-Res + 杜比)
state.audioEntries = [];
if (media.dash) {
(media.dash.audio || []).forEach(a => state.audioEntries.push(a));
if (media.dash.dolby?.audio?.length) media.dash.dolby.audio.forEach(a => state.audioEntries.push(a));
if (media.dash.flac?.audio) state.audioEntries.push(media.dash.flac.audio);
}
els.a.innerHTML = '';
state.audioEntries
.slice()
.sort((x, y) => y.bandwidth - x.bandwidth)
.forEach(a => {
const o = document.createElement('option');
o.value = state.audioEntries.indexOf(a);
const label = AUDIO_LABEL[a.id] || `${Math.round(a.bandwidth * 8 / 1000)}kbps`;
o.textContent = `${label}(${Math.round(a.bandwidth * 8 / 1e6 * 10) / 10}Mbps)`;
els.a.appendChild(o);
});
if (!state.audioEntries.length) els.a.innerHTML = '';
// 字幕列表(需要登录才能拿到 CC 字幕)
const v2 = await wbiGet('/x/player/wbi/v2', { bvid: state.bvid, cid });
media.subs = v2.code === 0 ? (v2.data?.subtitle?.subtitles || []) : [];
els.s.innerHTML = '';
if (media.subs.length) {
media.subs.forEach((s, i) => {
const o = document.createElement('option');
o.value = i;
o.textContent = s.lan_doc || s.lan;
els.s.appendChild(o);
});
} else {
els.s.innerHTML = '';
}
setStatus('就绪 ✓');
}
function fillCodec() {
const media = currentMedia();
els.c.innerHTML = '';
if (!media?.dash) return;
const qid = +els.q.value;
const list = media.dash.video.filter(v => v.id === qid).sort((a, b) => b.bandwidth - a.bandwidth);
list.forEach((v, i) => {
const o = document.createElement('option');
o.value = i;
o.textContent = `${codecShort(v.codecs)} ${v.frameRate || ''} ${Math.round(v.bandwidth * 8 / 1e6 * 10) / 10}Mbps`;
els.c.appendChild(o);
});
}
function currentMedia() {
const page = state.view?.pages.find(pg => pg.page === +els.p.value);
return page?._media || null;
}
function currentTitle() {
const page = state.view.pages.find(pg => pg.page === +els.p.value);
const multi = state.view.pages.length > 1;
return sanitize(state.view.title) + (multi ? ` P${page.page} ${sanitize(page.part)}` : '');
}
/* ---------------- 下载动作 ---------------- */
async function downloadVideo() {
if (state.busy) return;
promoOnDownload();
const media = currentMedia();
if (!media) return;
setBusy(true);
try {
if (media.dash) {
const qid = +els.q.value;
const list = media.dash.video.filter(v => v.id === qid).sort((a, b) => b.bandwidth - a.bandwidth);
const entry = list[+els.c.value] || list[0];
const qName = state.acceptDesc[qid] || qid;
const urls = [entry.baseUrl, ...(entry.backupUrl || [])];
const blob = await fetchBlob(urls, showProgress);
saveBlob(blob, `${currentTitle()} [${qName}][${codecShort(entry.codecs)}].mp4`);
} else if (media.durl) {
const d = media.durl[0];
const blob = await fetchBlob([d.url, ...(d.backup_url || [])], showProgress);
saveBlob(blob, `${currentTitle()}.mp4`);
}
setStatus('视频已保存 ✓(与音频分开的两个文件)');
} catch (e) {
setStatus('视频下载失败:' + e.message);
}
setBusy(false);
}
async function downloadAudio() {
if (state.busy) return;
promoOnDownload();
const idx = +els.a.value;
if (idx < 0 || !state.audioEntries[idx]) { setStatus('没有可下载的音频流'); return; }
setBusy(true);
try {
const a = state.audioEntries[idx];
const label = AUDIO_LABEL[a.id] || 'audio';
const ext = a.id === 30251 ? 'flac' : 'm4a';
const blob = await fetchBlob([a.baseUrl, ...(a.backupUrl || [])], showProgress);
saveBlob(blob, `${currentTitle()} [${label}].${ext}`);
setStatus('音频已保存 ✓');
} catch (e) {
setStatus('音频下载失败:' + e.message);
}
setBusy(false);
}
async function downloadCover() {
if (state.busy || !state.view) return;
promoOnDownload();
setBusy(true);
try {
const pic = state.view.pic.replace(/^http:/, 'https:');
const blob = await fetchBlob([pic], showProgress);
const ext = (pic.match(/\.(jpe?g|png|webp|avif)(\?|$)/i) || [])[1] || 'jpg';
saveBlob(blob, `${sanitize(state.view.title)} [封面].${ext}`);
setStatus('封面已保存 ✓');
} catch (e) {
setStatus('封面下载失败:' + e.message);
}
setBusy(false);
}
async function downloadSubtitle() {
if (state.busy) return;
promoOnDownload();
const media = currentMedia();
const idx = +els.s.value;
if (!media || idx < 0 || !media.subs[idx]) { setStatus('没有可下载的字幕'); return; }
setBusy(true);
try {
const sub = media.subs[idx];
const url = sub.subtitle_url.startsWith('//') ? 'https:' + sub.subtitle_url : sub.subtitle_url;
const json = JSON.parse(await (await fetchBlob([url])).text());
const srt = jsonToSrt(json.body || []);
saveBlob(new Blob([srt], { type: 'text/plain;charset=utf-8' }),
`${currentTitle()} [${sub.lan_doc || sub.lan}].srt`);
setStatus('字幕已保存 ✓');
} catch (e) {
setStatus('字幕下载失败:' + e.message);
}
setBusy(false);
}
/* ---------------- SPA 页面变化监听 ---------------- */
setInterval(async () => {
if (!location.pathname.startsWith('/video/')) {
if (panel) { panel.remove(); panel = null; els = {}; state.bvid = null; }
return;
}
if (!panel) { buildPanel(); state.bvid = null; }
if (state.busy) return;
const bvid = currentBvid();
const p = new URLSearchParams(location.search).get('p') || '1';
if (bvid && (bvid !== state.bvid || String(p) !== String(state.p))) {
try { await loadVideoData(); } catch (e) { setStatus('加载失败:' + e.message); }
}
}, 800);
})();