// ==UserScript==
// @name SaveBili - B站全场景下载助手
// @namespace https://github.com/savebili
// @version 2.2.0
// @description 支持B站投稿视频、番剧、专栏、动态下载,下载按钮位于弹幕发送键右侧,下载文件自带声音
// @author SaveBili
// @license MIT
// @icon https://www.bilibili.com/favicon.ico
// @tag 下载工具
// @tag B站
// @tag 视频下载
// @tag 实用工具
// @category 下载工具
// @match *://www.bilibili.com/video/*
// @match *://www.bilibili.com/bangumi/play/*
// @match *://www.bilibili.com/read/*
// @match *://t.bilibili.com/*
// @match *://www.bilibili.com/opus/*
// @match *://space.bilibili.com/*
// @grant GM_xmlhttpRequest
// @grant GM_download
// @grant GM_setValue
// @grant GM_getValue
// @connect *
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const DEBUG = true;
const log = (...args) => { if (DEBUG) console.log('[SaveBili]', ...args); };
// ==================== 场景检测 ====================
function getPageInfo() {
const url = location.href;
let m;
m = url.match(/\/video\/(BV[0-9A-Za-z]{10}|av\d+)/i);
if (m) return { type: 'video', id: m[1] };
m = url.match(/\/bangumi\/play\/(ep|ss)(\d+)/i);
if (m) return { type: 'bangumi', id: m[1] + m[2] };
m = url.match(/\/read\/(cv\d+)/i);
if (m) return { type: 'article', id: m[1] };
m = url.match(/\/opus\/(\d+)/);
if (m) return { type: 'opus', id: m[1] };
m = url.match(/t\.bilibili\.com\/(\d+)/);
if (m) return { type: 'opus', id: m[1] };
m = url.match(/space\.bilibili\.com\/(\d+)/);
if (m) return { type: 'space', id: m[1] };
return null;
}
// ==================== 请求封装 ====================
function gmRequest(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || 'GET',
url: options.url,
headers: Object.assign({
'User-Agent': DEFAULT_UA,
'Referer': 'https://www.bilibili.com/',
}, options.headers || {}),
responseType: options.responseType || 'json',
timeout: 20000,
onload: (res) => {
log('API 响应:', options.url.substring(0, 100), '状态:', res.status);
if (res.status < 200 || res.status >= 300) {
return reject(new Error(`HTTP ${res.status}`));
}
if (res.response === null || res.response === undefined) {
if (!res.responseText) {
return reject(new Error('服务器返回空数据(可能是登录态失效或内容被限制)'));
}
}
try {
const data = res.response || (res.responseText ? JSON.parse(res.responseText) : null);
if (data === null || data === undefined) {
return reject(new Error('服务器返回数据为空'));
}
resolve(data);
} catch (e) {
reject(new Error('响应解析失败: ' + e.message));
}
},
onerror: () => reject(new Error('网络请求失败(请检查 @connect 授权)')),
ontimeout: () => reject(new Error('请求超时')),
});
});
}
const fetchJson = (url, headers) => gmRequest({ url, headers });
// 判断是否为"清晰度不可用"错误(B站返回 no data / -404 / -400)
function isQualityError(err) {
const msg = (err && err.message) || '';
return /no\s*data/i.test(msg) || /-?404/.test(msg) || /-?400/.test(msg);
}
// ==================== 视频信息解析 ====================
async function getVideoInfo(bvid) {
const api = bvid.toLowerCase().startsWith('av')
? `https://api.bilibili.com/x/web-interface/view?aid=${bvid.slice(2)}`
: `https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`;
const data = await fetchJson(api);
if (data.code !== 0) throw new Error(data.message || `视频信息获取失败 code=${data.code}`);
const d = data.data;
if (!d) throw new Error('视频信息为空(视频可能已下架或需要登录)');
if (!d.pages || !d.pages.length) throw new Error('视频无可用分P');
return {
bvid: d.bvid, aid: d.aid, cid: d.cid,
title: d.title || 'untitled',
desc: d.desc || '', pic: d.pic || '',
owner: (d.owner && d.owner.name) || '',
pages: d.pages.map(p => ({
cid: p.cid, page: p.page, part: p.part || ('P' + p.page), duration: p.duration,
})),
};
}
async function getBangumiInfo(epOrSs) {
const url = epOrSs.startsWith('ep')
? `https://api.bilibili.com/pgc/view/web/season?ep_id=${epOrSs.slice(2)}`
: `https://api.bilibili.com/pgc/view/web/season?season_id=${epOrSs.slice(2)}`;
const data = await fetchJson(url);
if (data.code !== 0) throw new Error(data.message || `番剧信息获取失败 code=${data.code}`);
const d = data.data || data.result;
if (!d) throw new Error('番剧数据为空(可能需要登录或为付费内容)');
const eps = [...(d.episodes || [])];
(d.section || []).forEach(s => eps.push(...(s.episodes || [])));
if (!eps.length) throw new Error('番剧无可用剧集');
return {
title: d.title || '番剧',
cover: d.cover || '',
episodes: eps.map(ep => ({
ep_id: ep.id, cid: ep.cid, aid: ep.aid || 0,
title: ep.share_copy || ep.long_title || ep.title || '',
index: ep.title || '',
})),
};
}
// ==================== 播放地址解析(核心修复:自动降级清晰度) ====================
function qualityLabel(qn) {
const map = { 120: '4K', 116: '1080P60', 112: '1080P+', 80: '1080P', 74: '720P60', 64: '720P', 32: '480P', 16: '360P' };
return map[qn] || `qn${qn}`;
}
// 单次请求播放地址
async function fetchPlayurlOnce(aid, cid, bvid, quality) {
const url = `https://api.bilibili.com/x/player/playurl?avid=${aid}&cid=${cid}` +
`&qn=${quality}&otype=json&platform=pc&fnver=0&fnval=1&fourk=1`;
const headers = bvid ? { Referer: `https://www.bilibili.com/video/${bvid}` } : {};
const data = await fetchJson(url, headers);
if (data.code !== 0) {
const err = new Error(data.message || `播放地址获取失败 code=${data.code}`);
err.code = data.code;
throw err;
}
const d = data.data || data.result;
if (!d) throw new Error('播放数据为空');
return d;
}
// 带自动降级:目标清晰度失败时,自动尝试更低清晰度
async function getPlayurl(aid, cid, bvid, quality) {
const fallbacks = [quality, 80, 64, 32, 16]
.filter((v, i, a) => a.indexOf(v) === i && v <= quality);
let lastErr = null;
for (const qn of fallbacks) {
try {
log(`尝试清晰度 qn=${qn} (${qualityLabel(qn)})`);
const d = await fetchPlayurlOnce(aid, cid, bvid, qn);
// durl
if (d.durl && d.durl.length > 0) {
const urls = [];
d.durl.forEach(seg => {
if (seg.url) urls.push(seg.url);
(seg.backup_url || []).forEach(u => { if (u && !urls.includes(u)) urls.push(u); });
});
if (!urls.length) throw new Error('播放地址为空');
return {
mode: 'single', urls,
qualityId: d.quality || qn,
qualityName: qualityLabel(d.quality || qn),
downgraded: qn !== quality,
};
}
// dash
if (d.dash) {
const r = pickDashStreams(d.dash, qn);
r.downgraded = qn !== quality;
return r;
}
throw new Error('不支持的流格式');
} catch (e) {
lastErr = e;
log(`qn=${qn} 失败:`, e.message);
if (!isQualityError(e) && !/播放数据为空|不支持的流格式/.test(e.message)) {
// 非清晰度问题(如网络错误)直接抛出
throw e;
}
}
}
// 全部失败
const baseMsg = lastErr ? lastErr.message : '所有清晰度均不可用';
if (/no\s*data/i.test(baseMsg)) {
throw new Error(
'该视频在当前清晰度下不可用(可能是大会员专享或视频已限制)。\n' +
'请尝试:\n' +
'1. 登录 B 站账号(脚本会自动使用当前页面的登录态)\n' +
'2. 切换更低的清晰度后重试\n' +
'3. 刷新页面后重试'
);
}
throw new Error(baseMsg);
}
async function fetchBangumiPlayurlOnce(epId, cid, quality) {
const url = `https://api.bilibili.com/pgc/player/web/playurl?ep_id=${epId}&cid=${cid}` +
`&qn=${quality}&fnval=1&fourk=1&otype=json`;
const data = await fetchJson(url);
if (data.code !== 0) {
const err = new Error(data.message || `番剧播放地址获取失败 code=${data.code}`);
err.code = data.code;
throw err;
}
const d = data.data || data.result;
if (!d) throw new Error('番剧播放数据为空');
return d;
}
async function getBangumiPlayurl(epId, cid, quality) {
const fallbacks = [quality, 80, 64, 32, 16]
.filter((v, i, a) => a.indexOf(v) === i && v <= quality);
let lastErr = null;
for (const qn of fallbacks) {
try {
log(`番剧尝试清晰度 qn=${qn}`);
const d = await fetchBangumiPlayurlOnce(epId, cid, qn);
if (d.durl && d.durl.length > 0) {
const urls = [];
d.durl.forEach(seg => {
if (seg.url) urls.push(seg.url);
(seg.backup_url || []).forEach(u => { if (u && !urls.includes(u)) urls.push(u); });
});
if (!urls.length) throw new Error('播放地址为空');
return {
mode: 'single', urls,
qualityId: d.quality || qn,
qualityName: qualityLabel(d.quality || qn),
downgraded: qn !== quality,
};
}
if (d.dash) {
const r = pickDashStreams(d.dash, qn);
r.downgraded = qn !== quality;
return r;
}
throw new Error('不支持的流格式');
} catch (e) {
lastErr = e;
log(`番剧 qn=${qn} 失败:`, e.message);
if (!isQualityError(e) && !/播放数据为空|不支持的流格式/.test(e.message)) throw e;
}
}
const baseMsg = lastErr ? lastErr.message : '所有清晰度均不可用';
if (/no\s*data/i.test(baseMsg)) {
throw new Error(
'番剧在当前清晰度下不可用(需登录或大会员)。\n' +
'请先扫码/手动登录 B 站,或切换到更低清晰度后重试。'
);
}
throw new Error(baseMsg);
}
function pickDashStreams(dash, quality) {
const videos = dash.video || [];
const audios = dash.audio || [];
if (!videos.length) throw new Error('无可用的视频流');
let candidates = videos.filter(v => v.id === quality);
if (!candidates.length) {
const below = videos.filter(v => v.id <= quality);
candidates = below.length ? below.filter(v => v.id === Math.max(...below.map(v => v.id))) : videos;
}
candidates.sort((a, b) => {
const r = c => (c.codecs || '').startsWith('avc') ? 0 : (c.codecs || '').startsWith('hev') ? 1 : 2;
return r(a) - r(b);
});
const video = candidates[0];
const audio = audios.length ? audios.reduce((a, b) => (a.bandwidth || 0) > (b.bandwidth || 0) ? a : b) : null;
const videoUrls = [];
const vMain = video.baseUrl || video.base_url;
if (vMain) videoUrls.push(vMain);
(video.backupUrl || video.backup_url || []).forEach(u => { if (u) videoUrls.push(u); });
if (!videoUrls.length) throw new Error('视频流地址为空');
const audioUrls = [];
if (audio) {
const aMain = audio.baseUrl || audio.base_url;
if (aMain) audioUrls.push(aMain);
(audio.backupUrl || audio.backup_url || []).forEach(u => { if (u) audioUrls.push(u); });
}
return {
mode: 'dash',
videoUrls, audioUrls,
videoCodec: video.codecs || '',
qualityId: video.id,
qualityName: qualityLabel(video.id),
};
}
// ==================== 下载核心 ====================
function downloadOne(urls, filename, referer, onProgress) {
let lastErr = null;
const tryNext = (i) => {
if (i >= urls.length) return Promise.reject(lastErr || new Error('所有下载节点均失败'));
const url = urls[i];
log(`尝试下载 [${i + 1}/${urls.length}]:`, url.substring(0, 80) + '...');
return downloadSingle(url, filename, referer, onProgress).catch(e => {
lastErr = e;
log(`URL ${i + 1} 失败:`, e.message);
if (onProgress) onProgress(`节点 ${i + 1} 失败,尝试下一节点...`);
return tryNext(i + 1);
});
};
return tryNext(0);
}
function downloadSingle(url, filename, referer, onProgress) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: {
'User-Agent': DEFAULT_UA,
'Referer': referer || 'https://www.bilibili.com/',
'Origin': 'https://www.bilibili.com',
'Accept': '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
responseType: 'arraybuffer',
timeout: 0,
onprogress: (e) => {
if (onProgress && e.lengthComputable && e.total > 0) {
onProgress(e.loaded, e.total);
}
},
onload: (res) => {
log('下载响应状态:', res.status, '数据长度:', res.response ? res.response.byteLength : 0);
if (res.status < 200 || res.status >= 300) {
if (res.status === 403) return reject(new Error(`403 链接已过期,请刷新页面重试`));
if (res.status === 404) return reject(new Error(`404 文件不存在`));
if (res.status === 0) return reject(new Error(`请求被拦截(请检查 @connect 授权)`));
return reject(new Error(`HTTP ${res.status}`));
}
if (!res.response || res.response.byteLength === 0) {
return reject(new Error('下载内容为空(no data)'));
}
try {
const blob = new Blob([res.response], { type: 'video/mp4' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 3000);
resolve();
} catch (e) {
reject(new Error('保存文件失败: ' + e.message));
}
},
onerror: () => reject(new Error(`网络错误(CDN 域名可能未授权)`)),
ontimeout: () => reject(new Error('下载超时')),
});
});
}
async function downloadVideo(streams, title, referer, onProgress) {
const safeTitle = sanitizeFilename(title);
const ref = referer || 'https://www.bilibili.com/';
if (streams.mode === 'single') {
const qnTag = streams.qualityName || '';
const warn = streams.downgraded ? `(已自动降级至 ${qnTag})` : `(${qnTag})`;
onProgress(`正在下载视频${warn}...`, 0, 0);
await downloadOne(streams.urls, safeTitle + '.mp4', ref,
(l, t) => onProgress(`下载中 ${(l / 1048576).toFixed(1)}MB / ${(t / 1048576).toFixed(1)}MB`, l, t));
return { mode: 'single', title: safeTitle, downgraded: streams.downgraded };
}
onProgress('正在下载视频流(该视频仅支持DASH,将分离音视频)...', 0, 40);
await downloadOne(streams.videoUrls, safeTitle + '_video.m4s', ref,
(l, t) => onProgress(`视频流 ${(l / 1048576).toFixed(1)}MB`, l, t * 0.4));
if (streams.audioUrls && streams.audioUrls.length) {
onProgress('正在下载音频流...', 40, 100);
await downloadOne(streams.audioUrls, safeTitle + '_audio.m4s', ref,
(l, t) => onProgress(`音频流 ${(l / 1048576).toFixed(1)}MB`, 40 + l / t * 60, 100));
}
return { mode: 'dash', title: safeTitle };
}
function sanitizeFilename(name) {
return (name || 'untitled').replace(/[\\/*?:"<>|\n\r\t]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 120) || 'untitled';
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
// ==================== 专栏 / 动态下载 ====================
async function downloadArticle(cvId) {
const articleId = cvId.toLowerCase().startsWith('cv') ? cvId.slice(2) : cvId;
const data = await fetchJson(`https://api.bilibili.com/x/article/view?id=${articleId}`);
if (data.code !== 0) throw new Error(data.message || '专栏解析失败');
const d = data.data || data.result;
if (!d) throw new Error('专栏数据为空(文章可能已删除或需要登录)');
const title = d.title || `专栏_${articleId}`;
const safeTitle = sanitizeFilename(title);
let contentHtml = d.content || '';
let imgIndex = 0; const images = [];
contentHtml = contentHtml.replace(/]*>/gi, (tag) => {
const m = tag.match(/data-src=["']([^"']+)["']/) || tag.match(/src=["']([^"']+)["']/);
if (!m) return tag;
let src = m[1];
if (src.startsWith('//')) src = 'https:' + src;
else if (src.startsWith('/')) src = 'https://www.bilibili.com' + src;
else if (!src.startsWith('http')) return tag;
const name = `img_${String(imgIndex).padStart(3, '0')}.jpg`;
images.push({ url: src, name }); imgIndex++;
return tag.replace(/src=["'][^"']*["']/, `src="images/${name}"`);
});
for (const img of images) {
try { await downloadOne([img.url], `${safeTitle}_${img.name}`, `https://www.bilibili.com/read/${cvId}`, () => {}); } catch (e) {}
}
const html = `