// ==UserScript== // @name B站视频下载助手(脚本猫版) // @namespace https://scriptcat.org/zh-CN/users/211428 // @version 1.0.1 // @description 下载B站视频,支持投稿和番剧,可选择清晰度,下载当前或全部P // @author ganjueqi // @license GPL-3.0-or-later // @copyright 2026, ganjueqi // ---------- 发布平台标签(用于GreasyFork等站点的分类检索) ---------- // @tag B站 // @tag 视频下载 // @tag bilibili // @tag downloader // @tag 番剧下载 // @tag 投稿视频 // ---------- 项目与支持 ---------- // @homepageURL https://github.com/ganjueqi/bili-downloader // @supportURL https://github.com/ganjueqi/bili-downloader/issues // ---------- 匹配规则 ---------- // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/bangumi/* // @match https://bilibili.com/video/* // @match https://bilibili.com/bangumi/* // ---------- 权限申请 ---------- // @grant GM_xmlhttpRequest // @grant GM_download // @grant unsafeWindow // @run-at document-end // ==/UserScript== (function() { 'use strict'; // ======================== 基本配置 ======================== // 这里可以按需调整下载并发数和清晰度对应的数字代号 const CONFIG = { maxThread: 2, // 同时下载的视频个数(别开太多,防止被限制) qualityMap: { // 清晰度选项(数字是B站接口用的参数) '高清1080P': 80, '最佳': 120, // 通常指最高可用画质 '超清4K': 120, '高清1080P+': 112, '高清720P60': 74, '高清720P': 64, '流畅360P': 16 } }; // ======================== 工具函数 ======================== // 在控制台打印带前缀的日志,方便调试 function log(msg, data) { console.log('[B站下载]', msg, data || ''); } // ----- 获取当前页面视频信息(同步,从全局状态里读) ----- // 仅适用于投稿视频(非番剧) function getPageInfo() { const state = unsafeWindow.__INITIAL_STATE__; if (!state) return null; if (state.videoData && state.videoData.pages) { // 检测到投稿视频 return { type: 'tougao', state: state, bvid: state.bvid, pages: state.videoData.pages, title: state.videoData.title }; } return null; } // ----- 异步获取番剧信息(通过API请求) ----- async function getFanjuInfo() { // 从当前网址中提取 season_id 或 ep_id const path = location.pathname; let season_id = null, ep_id = null; const ssMatch = path.match(/\/bangumi\/play\/ss(\d+)/); if (ssMatch) season_id = ssMatch[1]; const epMatch = path.match(/\/bangumi\/play\/ep(\d+)/); if (epMatch) ep_id = epMatch[1]; // 构造请求地址 let url; if (season_id) { url = `https://api.bilibili.com/pgc/view/web/ep/list?season_id=${season_id}&t=${Date.now()}`; } else if (ep_id) { url = `https://api.bilibili.com/pgc/view/web/ep/list?ep_id=${ep_id}&t=${Date.now()}`; } else { return null; } // 发出请求获取剧集列表 const resp = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'Accept': 'application/json' }, onload: (res) => resolve(JSON.parse(res.responseText)), onerror: reject }); }); if (resp && resp.result && resp.result.episodes) { const episodes = resp.result.episodes; // 用第一集的分享标题作为整部番的标题 const title = episodes[0]?.share_copy || '番剧'; return { type: 'fanju', title: title, episodes: episodes }; } return null; } // ----- 获取投稿视频的播放地址(单个分P) ----- async function getTougaoUrl(cid, bvid, quality) { const url = `https://api.bilibili.com/x/player/playurl?cid=${cid}&bvid=${bvid}&otype=json&qn=${quality}`; const resp = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'Accept': 'application/json' }, onload: (res) => resolve(JSON.parse(res.responseText)), onerror: reject }); }); // 通常 durl 数组第一个就是主视频地址(分段合并情况暂只取第一个) if (resp.data && resp.data.durl && resp.data.durl.length > 0) { return resp.data.durl[0].url; } return null; } // ----- 获取番剧视频的播放地址(单个剧集) ----- async function getFanjuUrl(ep_id, quality) { const url = `https://api.bilibili.com/pgc/player/web/playurl/?ep_id=${ep_id}&qn=${quality}`; const resp = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'Accept': 'application/json' }, onload: (res) => resolve(JSON.parse(res.responseText)), onerror: reject }); }); if (resp.result && resp.result.durl && resp.result.durl.length > 0) { return resp.result.durl[0].url; } return null; } // ----- 使用GM_download直接下载(推荐) ----- function downloadVideo(url, filename) { return new Promise((resolve, reject) => { GM_download({ url: url, name: filename, headers: { 'Referer': 'https://www.bilibili.com/' }, onload: resolve, onerror: reject }); }); } // (备选方案)用GM_xmlhttpRequest拉取blob再通过a标签下载,但一般用上面的即可 async function downloadBlob(url, filename) { const blob = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', onload: (res) => resolve(res.response), onerror: reject }); }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); } // ======================== 用户界面构建 ======================== // 在页面右上角生成一个面板,包含标题、清晰度选择、下载按钮和分P列表 function createUI() { // 先移除之前可能残留的面板 const old = document.getElementById('bili-downloader-panel'); if (old) old.remove(); const panel = document.createElement('div'); panel.id = 'bili-downloader-panel'; panel.style.cssText = ` position: fixed; top: 80px; right: 20px; width: 400px; max-height: 80vh; background: #fff; border: 1px solid #ccc; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 99999; display: flex; flex-direction: column; overflow: hidden; font-family: "Microsoft YaHei", sans-serif; font-size: 14px; color: #333; `; // ----- 标题栏(含关闭按钮) ----- const header = document.createElement('div'); header.style.cssText = ` background: #00a1d6; color: #fff; padding: 10px 15px; font-weight: bold; display: flex; justify-content: space-between; align-items: center; `; const titleSpan = document.createElement('span'); titleSpan.textContent = 'B站视频下载'; header.appendChild(titleSpan); const closeBtn = document.createElement('span'); closeBtn.textContent = '✕'; closeBtn.style.cssText = 'cursor:pointer; font-size:18px;'; closeBtn.onclick = () => { panel.style.display = 'none'; }; header.appendChild(closeBtn); panel.appendChild(header); // ----- 信息区:视频标题、清晰度选择、操作按钮 ----- const info = document.createElement('div'); info.style.cssText = 'padding: 10px 15px; border-bottom: 1px solid #eee;'; const videoTitle = document.createElement('div'); videoTitle.id = 'downloader-title'; videoTitle.style.cssText = 'font-weight:bold; font-size:16px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;'; videoTitle.textContent = '加载中...'; info.appendChild(videoTitle); const controls = document.createElement('div'); controls.style.cssText = 'margin-top:8px; display:flex; align-items:center; flex-wrap:wrap; gap:6px;'; // 清晰度下拉框 const qLabel = document.createElement('span'); qLabel.textContent = '清晰度:'; controls.appendChild(qLabel); const qSelect = document.createElement('select'); qSelect.id = 'downloader-quality'; const qMap = CONFIG.qualityMap; Object.keys(qMap).forEach(k => { const opt = document.createElement('option'); opt.value = qMap[k]; opt.textContent = k; qSelect.appendChild(opt); }); // 从本地存储恢复上次选择的清晰度 qSelect.value = localStorage.getItem('biliDownloadQuality') || 120; controls.appendChild(qSelect); // “下载当前”按钮(只下载当前正在播放的分P) const btnCurrent = document.createElement('button'); btnCurrent.textContent = '下载当前'; btnCurrent.style.cssText = 'padding:4px 12px; background:#00a1d6; color:#fff; border:none; border-radius:4px; cursor:pointer;'; controls.appendChild(btnCurrent); // “下载全部”按钮(下载列表里的所有分P) const btnAll = document.createElement('button'); btnAll.textContent = '下载全部'; btnAll.style.cssText = 'padding:4px 12px; background:#ff8c00; color:#fff; border:none; border-radius:4px; cursor:pointer;'; controls.appendChild(btnAll); info.appendChild(controls); panel.appendChild(info); // ----- 分P列表(滚动) ----- const listContainer = document.createElement('div'); listContainer.style.cssText = 'flex:1; overflow-y:auto; padding:10px 15px;'; const list = document.createElement('ul'); list.id = 'downloader-list'; list.style.cssText = 'list-style:none; margin:0; padding:0;'; listContainer.appendChild(list); panel.appendChild(listContainer); document.body.appendChild(panel); // 返回UI元素引用,方便后续绑定事件 return { panel, title: videoTitle, qSelect, list, btnCurrent, btnAll }; } // ======================== 下载核心逻辑 ======================== let currentInfo = null; // 存储当前视频信息:{ type, title, items: [{id, title, cid?, bvid?, ep_id?}] } let downloadQueue = []; // 等待下载的任务队列 let activeDownloads = 0; // 当前正在下载的个数 // ---- 获取视频信息(自动识别投稿或番剧) ---- async function fetchVideoInfo() { // 先尝试投稿(从 __INITIAL_STATE__ 读取) let info = getPageInfo(); if (info) { const items = info.pages.map((p, idx) => ({ id: p.cid, title: `${idx+1}_${p.part || p.page?.part || '无标题'}_${info.title}`, cid: p.cid, bvid: info.bvid })); currentInfo = { type: 'tougao', title: info.title, items: items }; return currentInfo; } // 若不是投稿,尝试番剧 const fanju = await getFanjuInfo(); if (fanju) { const items = fanju.episodes.map((ep, idx) => ({ id: ep.id, title: `${idx+1}_${ep.share_copy || ep.title || '无标题'}_${fanju.title}`, ep_id: ep.id })); currentInfo = { type: 'fanju', title: fanju.title, items: items }; return currentInfo; } return null; } // ---- 向队列中添加一个下载任务 ---- function addDownloadTask(item, quality) { downloadQueue.push({ item, quality }); processQueue(); // 尝试开始处理队列 } // ---- 处理队列(并发控制) ---- async function processQueue() { // 如果队列空或并发已满,则暂不处理 if (downloadQueue.length === 0 || activeDownloads >= CONFIG.maxThread) return; const task = downloadQueue.shift(); activeDownloads++; try { const { item, quality } = task; let url; if (currentInfo.type === 'tougao') { url = await getTougaoUrl(item.cid, item.bvid, quality); } else if (currentInfo.type === 'fanju') { url = await getFanjuUrl(item.ep_id, quality); } if (url) { // 下载文件,命名格式为“标题.mp4”(实际可能是flv,但影响不大) const filename = item.title + '.mp4'; await downloadVideo(url, filename); log('下载完成', filename); // 这里可以添加界面反馈(如标记已完成),暂略 } else { log('获取视频地址失败', item); } } catch (e) { log('下载出错', e); } finally { activeDownloads--; // 继续处理队列中剩余的任务 processQueue(); } } // ======================== 主入口 ======================== async function init() { // 创建UI面板 const ui = createUI(); // 获取当前视频信息(投稿或番剧) const info = await fetchVideoInfo(); if (!info) { ui.title.textContent = '未识别视频信息'; return; } ui.title.textContent = info.title; // 将每个分P显示在列表中,并绑定“下载”按钮 ui.list.innerHTML = ''; info.items.forEach((item, idx) => { const li = document.createElement('li'); li.style.cssText = ` padding: 6px 0; border-bottom: 1px solid #f0f0f0; display: flex; align-items: center; justify-content: space-between; `; const span = document.createElement('span'); span.textContent = item.title; span.style.cssText = 'white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:220px;'; const btn = document.createElement('button'); btn.textContent = '下载'; btn.style.cssText = 'padding:2px 10px; background:#00a1d6; color:#fff; border:none; border-radius:4px; cursor:pointer;'; btn.dataset.idx = idx; btn.onclick = function(e) { e.stopPropagation(); const quality = parseInt(ui.qSelect.value, 10); addDownloadTask(item, quality); btn.textContent = '队列中'; btn.disabled = true; // 3秒后恢复按钮文字(只是演示效果) setTimeout(() => { btn.textContent = '下载'; btn.disabled = false; }, 3000); }; li.appendChild(span); li.appendChild(btn); ui.list.appendChild(li); }); // ---- “下载当前”按钮:根据当前播放的cid找到对应的分P ---- ui.btnCurrent.onclick = function() { const state = unsafeWindow.__INITIAL_STATE__; let currentCid = state?.cid || state?.videoData?.cid; if (!currentCid) { alert('无法获取当前播放视频ID'); return; } const item = info.items.find(it => it.cid === currentCid || it.id === currentCid); if (!item) { alert('当前视频不在列表中'); return; } const quality = parseInt(ui.qSelect.value, 10); addDownloadTask(item, quality); }; // ---- “下载全部”按钮:将所有分P加入队列 ---- ui.btnAll.onclick = function() { const quality = parseInt(ui.qSelect.value, 10); info.items.forEach(item => { addDownloadTask(item, quality); }); }; } // 等待页面完全加载后再启动 if (document.readyState === 'complete') { init(); } else { window.addEventListener('load', init); } })();