// ==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); })();