// ==UserScript== // @name 视频下载助手 - 哔哩哔哩 // @namespace https://github.com/MakotoArai-CN/video-download-helper // @version 0.1.0 // @description 纯本地的视频下载器,使用原生JavaScript对视频音频进行合并并输出,支持登录账号可以观看的最高分辨率视频下载(非破解,下载的清晰度等取决于账号权限),脚本仅供学习研究使用。 // @author Makoto // @match *://www.bilibili.com/video/* // @match *://www.bilibili.com/bangumi/play/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @connect api.bilibili.com // @connect bilivideo.com // @connect bilivideo.cn // @connect bilivideo.net // @connect akamaized.net // @connect * // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; const LEARNING_DISCLAIMER = '本视频通过学习工具下载,仅供个人学习研究使用,请勿用于商业用途,请支持正版内容创作者。'; const CONFIG = { QUALITY_MAP: { 127: '8K 超高清', 126: '杜比视界', 125: 'HDR 真彩色', 120: '4K 超清', 116: '1080P 60帧', 112: '1080P 高码率', 80: '1080P 高清', 74: '720P 60帧', 64: '720P 高清', 32: '480P 清晰', 16: '360P 流畅' }, MERGE_METHODS: { JSMERGE: 'js-merge', SEPARATE: 'separate' } }; const STYLES = ` #bdl-panel { position: fixed; right: 20px; bottom: 80px; z-index: 100000; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; } #bdl-main-btn { width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #00a1d6 0%, #0081b3 100%); border: none; cursor: pointer; box-shadow: 0 4px 15px rgba(0, 161, 214, 0.5); display: flex; align-items: center; justify-content: center; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; } #bdl-main-btn:hover { transform: scale(1.08) translateY(-2px); box-shadow: 0 6px 25px rgba(0, 161, 214, 0.6); } #bdl-main-btn:active { transform: scale(1.02); } #bdl-main-btn:disabled { cursor: not-allowed; } #bdl-main-btn svg { width: 30px; height: 30px; fill: white; position: relative; z-index: 2; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); } #bdl-main-btn:hover svg { transform: translateY(-1px); } #bdl-progress-circle { position: absolute; bottom: 0; left: 0; width: 100%; background: linear-gradient(180deg, #fb7299 0%, #f25d8e 100%); transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1); height: 0%; border-radius: 0 0 30px 30px; overflow: hidden; box-shadow: 0 -2px 10px rgba(251, 114, 153, 0.3) inset; } #bdl-progress-circle::before { content: ''; position: absolute; top: -15px; left: -50%; width: 200%; height: 30px; background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.5) 0%, transparent 50%); border-radius: 45%; animation: bdlCircleWave 2.5s ease-in-out infinite; } #bdl-progress-circle::after { content: ''; position: absolute; top: -12px; left: -50%; width: 200%; height: 25px; background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.3) 0%, transparent 50%); border-radius: 40%; animation: bdlCircleWave 3s ease-in-out infinite reverse; } @keyframes bdlCircleWave { 0%, 100% { transform: translateX(0) translateY(0) rotate(0deg); } 25% { transform: translateX(-15%) translateY(-2px) rotate(-2deg); } 50% { transform: translateX(-25%) translateY(-4px) rotate(0deg); } 75% { transform: translateX(-35%) translateY(-2px) rotate(2deg); } } .bdl-progress-bubble { position: absolute; bottom: 0; width: 4px; height: 4px; background: rgba(255, 255, 255, 0.6); border-radius: 50%; animation: bdlBubbleRise 3s ease-in infinite; opacity: 0; } .bdl-progress-bubble:nth-child(1) { left: 20%; animation-delay: 0s; animation-duration: 2.5s; } .bdl-progress-bubble:nth-child(2) { left: 50%; animation-delay: 0.8s; animation-duration: 3s; } .bdl-progress-bubble:nth-child(3) { left: 70%; animation-delay: 1.5s; animation-duration: 2.8s; } @keyframes bdlBubbleRise { 0% { bottom: 0; opacity: 0; transform: translateX(0) scale(0.5); } 10% { opacity: 1; } 50% { opacity: 0.8; transform: translateX(10px) scale(1); } 100% { bottom: 100%; opacity: 0; transform: translateX(-10px) scale(0.5); } } .bdl-popup { position: absolute; bottom: 75px; right: 0; width: 420px; background: #fff; border-radius: 16px; box-shadow: 0 10px 50px rgba(0, 0, 0, 0.2); display: none; overflow: hidden; } .bdl-popup.show { display: block; animation: bdlFadeIn 0.25s cubic-bezier(0.4, 0, 0.2, 1); } @keyframes bdlFadeIn { from { opacity: 0; transform: translateY(10px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes bdlFadeOut { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.95); } } .bdl-header { background: linear-gradient(135deg, #00a1d6 0%, #0081b3 100%); color: white; padding: 18px 20px; display: flex; justify-content: space-between; align-items: center; } .bdl-header-title { font-size: 17px; font-weight: 600; display: flex; align-items: center; gap: 8px; } .bdl-close { width: 30px; height: 30px; border: none; background: rgba(255,255,255,0.2); border-radius: 50%; cursor: pointer; font-size: 18px; color: white; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .bdl-close:hover { background: rgba(255,255,255,0.3); transform: rotate(90deg); } .bdl-body { padding: 20px; max-height: 65vh; overflow-y: auto; } .bdl-info-card { background: #f8f9fa; border-radius: 12px; padding: 15px; margin-bottom: 18px; } .bdl-info-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 10px; line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .bdl-info-meta { display: flex; gap: 15px; font-size: 13px; color: #666; } .bdl-info-meta-item { display: flex; align-items: center; gap: 5px; } .bdl-section { margin-bottom: 18px; } .bdl-section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .bdl-section-title { font-size: 14px; font-weight: 600; color: #444; } .bdl-pages-container { max-height: 200px; overflow-y: auto; border: 1px solid #e8e8e8; border-radius: 10px; padding: 10px; background: #fafafa; } .bdl-page-item { display: flex; align-items: center; padding: 10px; margin-bottom: 8px; border: 2px solid #e8e8e8; border-radius: 8px; cursor: pointer; transition: all 0.2s; background: white; } .bdl-page-item:last-child { margin-bottom: 0; } .bdl-page-item:hover { border-color: #00a1d6; } .bdl-page-item.active { border-color: #00a1d6; background: linear-gradient(135deg, rgba(0,161,214,0.08) 0%, rgba(0,129,179,0.08) 100%); } .bdl-page-checkbox { width: 18px; height: 18px; margin-right: 12px; cursor: pointer; accent-color: #00a1d6; } .bdl-page-info { flex: 1; min-width: 0; } .bdl-page-num { font-size: 12px; color: #999; margin-bottom: 3px; } .bdl-page-title { font-size: 13px; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .bdl-page-duration { font-size: 12px; color: #999; margin-left: 10px; } .bdl-pages-actions { display: flex; gap: 10px; margin-top: 10px; } .bdl-pages-actions button { flex: 1; padding: 8px; border: 1px solid #00a1d6; border-radius: 6px; background: white; color: #00a1d6; font-size: 12px; cursor: pointer; transition: all 0.2s; } .bdl-pages-actions button:hover { background: #00a1d6; color: white; } .bdl-quality-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .bdl-quality-btn { padding: 10px 8px; border: 2px solid #e8e8e8; border-radius: 10px; background: white; font-size: 12px; cursor: pointer; transition: all 0.2s; text-align: center; color: #555; } .bdl-quality-btn:hover { border-color: #00a1d6; color: #00a1d6; } .bdl-quality-btn.active { border-color: #00a1d6; background: #00a1d6; color: white; } .bdl-quality-btn.disabled { opacity: 0.4; cursor: not-allowed; } .bdl-method-list { display: flex; flex-direction: column; gap: 10px; } .bdl-method-item { display: flex; align-items: center; padding: 12px 15px; border: 2px solid #e8e8e8; border-radius: 12px; cursor: pointer; transition: all 0.2s; background: white; } .bdl-method-item:hover { border-color: #00a1d6; } .bdl-method-item.active { border-color: #00a1d6; background: linear-gradient(135deg, rgba(0,161,214,0.08) 0%, rgba(0,129,179,0.08) 100%); } .bdl-method-radio { width: 20px; height: 20px; border: 2px solid #ccc; border-radius: 50%; margin-right: 12px; display: flex; align-items: center; justify-content: center; transition: all 0.2s; } .bdl-method-item.active .bdl-method-radio { border-color: #00a1d6; } .bdl-method-item.active .bdl-method-radio::after { content: ''; width: 10px; height: 10px; background: #00a1d6; border-radius: 50%; } .bdl-method-content { flex: 1; } .bdl-method-name { font-size: 14px; font-weight: 600; color: #333; margin-bottom: 3px; display: flex; align-items: center; gap: 8px; } .bdl-method-desc { font-size: 12px; color: #888; } .bdl-method-status { font-size: 11px; padding: 3px 8px; border-radius: 10px; font-weight: 500; } .bdl-method-status.ready { background: #d4edda; color: #155724; } .bdl-progress-section { background: #f8f9fa; border-radius: 12px; padding: 15px; margin-bottom: 18px; display: none; } .bdl-progress-section.show { display: block; } .bdl-progress-row { margin-bottom: 12px; } .bdl-progress-row:last-child { margin-bottom: 0; } .bdl-progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; font-size: 13px; } .bdl-progress-label { color: #555; font-weight: 500; } .bdl-progress-value { color: #888; } .bdl-progress-track { height: 10px; background: #e0e0e0; border-radius: 5px; overflow: hidden; position: relative; } .bdl-progress-bar { height: 100%; border-radius: 5px; transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); width: 0%; position: relative; overflow: hidden; background: linear-gradient(90deg, #fb7299, #ff9eb5); } .bdl-progress-bar::before { content: ''; position: absolute; top: -50%; left: 0; width: 100%; height: 200%; background: repeating-linear-gradient( 90deg, transparent, transparent 10px, rgba(255,255,255,0.15) 10px, rgba(255,255,255,0.15) 20px ); animation: bdlStripe 1s linear infinite; } .bdl-progress-bar::after { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 50%; background: linear-gradient(to bottom, rgba(255,255,255,0.3), transparent); border-radius: 5px 5px 0 0; } @keyframes bdlStripe { 0% { transform: translateX(-20px); } 100% { transform: translateX(0); } } .bdl-progress-bar.video { background: linear-gradient(90deg, #fb7299, #ff9eb5); } .bdl-progress-bar.audio { background: linear-gradient(90deg, #00a1d6, #66d4ff); } .bdl-progress-bar.merge { background: linear-gradient(90deg, #fb7299, #00a1d6); } .bdl-alert { padding: 12px 15px; border-radius: 10px; font-size: 13px; margin-bottom: 18px; display: none; line-height: 1.5; } .bdl-alert.show { display: block; } .bdl-alert.info { background: #e6f7ff; border: 1px solid #91d5ff; color: #0050b3; } .bdl-alert.success { background: #fff0f6; border: 1px solid #ffadd2; color: #c41d7f; } .bdl-alert.warning { background: #fffbe6; border: 1px solid #ffe58f; color: #ad6800; } .bdl-alert.error { background: #fff1f0; border: 1px solid #ffa39e; color: #cf1322; } .bdl-download-btn { width: 100%; padding: 15px; border: none; border-radius: 12px; font-size: 16px; font-weight: 600; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); background: linear-gradient(135deg, #00a1d6 0%, #0081b3 100%); color: white; display: flex; align-items: center; justify-content: center; gap: 8px; } .bdl-download-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 5px 20px rgba(0, 161, 214, 0.4); } .bdl-download-btn:disabled { background: linear-gradient(135deg, #ccc 0%, #aaa 100%); cursor: not-allowed; transform: none; } .bdl-footer { text-align: center; padding: 15px 20px; background: #f8f9fa; font-size: 12px; color: #999; line-height: 1.6; cursor: pointer; user-select: none; transition: all 0.2s; } .bdl-footer:hover { background: #f0f1f2; color: #666; } .bdl-tips { background: #fffbe6; border: 1px solid #ffe58f; border-radius: 10px; padding: 12px 15px; margin-bottom: 18px; font-size: 12px; color: #ad6800; line-height: 1.6; } .bdl-tips-title { font-weight: 600; margin-bottom: 5px; display: flex; align-items: center; gap: 5px; } .bdl-spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #fff; border-radius: 50%; border-top-color: transparent; animation: bdlSpin 0.8s linear infinite; } @keyframes bdlSpin { to { transform: rotate(360deg); } } .bdl-badge { display: inline-block; padding: 2px 6px; font-size: 10px; border-radius: 4px; font-weight: 600; } .bdl-badge.recommended { background: #52c41a; color: white; } .bdl-complete-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: radial-gradient(circle at center, rgba(251, 114, 153, 0.15) 0%, rgba(0, 161, 214, 0.15) 100%); backdrop-filter: blur(8px); z-index: 100001; display: flex; align-items: center; justify-content: center; animation: bdlOverlayIn 0.4s cubic-bezier(0.4, 0, 0.2, 1); } @keyframes bdlOverlayIn { from { opacity: 0; backdrop-filter: blur(0px); } to { opacity: 1; backdrop-filter: blur(8px); } } .bdl-complete-container { position: relative; display: flex; align-items: center; justify-content: center; } .bdl-complete-icon { width: 140px; height: 140px; background: linear-gradient(135deg, #fb7299 0%, #f25d8e 50%, #00a1d6 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; animation: bdlIconPop 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); box-shadow: 0 20px 60px rgba(251, 114, 153, 0.4), 0 0 0 0 rgba(251, 114, 153, 0.4); position: relative; z-index: 2; } .bdl-complete-icon::before { content: ''; position: absolute; inset: 0; border-radius: 50%; background: linear-gradient(135deg, #fb7299 0%, #00a1d6 100%); animation: bdlIconPulse 2s ease-in-out infinite; z-index: -1; } @keyframes bdlIconPop { 0% { transform: scale(0) rotate(-180deg); opacity: 0; } 50% { transform: scale(1.15) rotate(10deg); } 100% { transform: scale(1) rotate(0deg); opacity: 1; } } @keyframes bdlIconPulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(251, 114, 153, 0.7); } 50% { box-shadow: 0 0 0 30px rgba(251, 114, 153, 0); } } .bdl-complete-icon svg { width: 80px; height: 80px; fill: none; stroke: white; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.2)); } .bdl-complete-icon svg path { stroke-dasharray: 80; stroke-dashoffset: 80; animation: bdlCheckDraw 0.6s cubic-bezier(0.4, 0, 0.2, 1) 0.3s forwards; } @keyframes bdlCheckDraw { to { stroke-dashoffset: 0; } } .bdl-complete-ripple { position: absolute; top: 50%; left: 50%; width: 140px; height: 140px; margin: -70px 0 0 -70px; border-radius: 50%; border: 3px solid #fb7299; animation: bdlRippleOut 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; z-index: 1; } .bdl-complete-ripple:nth-child(2) { border-color: #00a1d6; animation-delay: 0.15s; } .bdl-complete-ripple:nth-child(3) { border-color: #fb7299; animation-delay: 0.3s; } @keyframes bdlRippleOut { 0% { transform: scale(1); opacity: 1; } 100% { transform: scale(2.8); opacity: 0; } } .bdl-complete-particles { position: absolute; top: 50%; left: 50%; width: 0; height: 0; z-index: 3; } .bdl-particle { position: absolute; border-radius: 50%; animation: bdlParticleFly 1.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; } @keyframes bdlParticleFly { 0% { transform: translate(0, 0) scale(1) rotate(0deg); opacity: 1; } 100% { transform: translate(var(--tx), var(--ty)) scale(0) rotate(360deg); opacity: 0; } } .bdl-complete-text { position: absolute; top: calc(50% + 110px); left: 50%; transform: translateX(-50%); font-size: 24px; font-weight: 700; background: linear-gradient(135deg, #fb7299 0%, #00a1d6 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 2px 20px rgba(251, 114, 153, 0.3); animation: bdlTextFade 0.5s cubic-bezier(0.4, 0, 0.2, 1) 0.5s both; white-space: nowrap; letter-spacing: 2px; } @keyframes bdlTextFade { from { opacity: 0; transform: translateX(-50%) translateY(20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } .bdl-complete-sparkles { position: absolute; top: 50%; left: 50%; width: 200px; height: 200px; margin: -100px 0 0 -100px; z-index: 4; } .bdl-sparkle { position: absolute; width: 6px; height: 6px; background: linear-gradient(135deg, #fb7299, #00a1d6); clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); animation: bdlSparkle 1.5s ease-in-out infinite; opacity: 0; } @keyframes bdlSparkle { 0%, 100% { opacity: 0; transform: scale(0) rotate(0deg); } 50% { opacity: 1; transform: scale(1) rotate(180deg); } } #bdl-pages-section { max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease, margin-bottom 0.3s ease; margin-bottom: 0; } #bdl-pages-section.show { max-height: 500px; opacity: 1; margin-bottom: 18px; } `; const Utils = { getVideoId: function () { var pathname = window.location.pathname; var bvidMatch = pathname.match(/\/video\/(BV[\w]+)/i); if (bvidMatch) { return { type: 'video', id: bvidMatch[1] }; } var epMatch = pathname.match(/\/bangumi\/play\/ep(\d+)/i); if (epMatch) { return { type: 'bangumi', id: 'ep' + epMatch[1] }; } var ssMatch = pathname.match(/\/bangumi\/play\/ss(\d+)/i); if (ssMatch) { return { type: 'bangumi', id: 'ss' + ssMatch[1] }; } return null; }, getCurrentPage: function () { var urlParams = new URLSearchParams(window.location.search); return parseInt(urlParams.get('p')) || 1; }, formatDuration: function (seconds) { if (!seconds) return '00:00'; var h = Math.floor(seconds / 3600); var m = Math.floor((seconds % 3600) / 60); var s = Math.floor(seconds % 60); if (h > 0) { return h + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); } return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); }, formatBytes: function (bytes) { if (!bytes) return '0 B'; var k = 1024; var sizes = ['B', 'KB', 'MB', 'GB']; var i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }, sanitizeFilename: function (filename) { return filename .replace(/[<>:"/\\|?*\x00-\x1f]/g, '_') .replace(/\s+/g, ' ') .trim() .substring(0, 180); }, delay: function (ms) { return new Promise(function (resolve) { setTimeout(resolve, ms); }); } }; const Network = { fetchJSON: function (url) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'Referer': 'https://www.bilibili.com', 'User-Agent': navigator.userAgent }, responseType: 'json', onload: function (res) { if (res.status >= 200 && res.status < 300) { var data = res.response; if (typeof data === 'string') { data = JSON.parse(data); } resolve(data); } else { reject(new Error('HTTP ' + res.status)); } }, onerror: function () { reject(new Error('网络错误')); }, ontimeout: function () { reject(new Error('请求超时')); } }); }); }, downloadBuffer: function (url, onProgress) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'Referer': 'https://www.bilibili.com', 'Origin': 'https://www.bilibili.com', 'User-Agent': navigator.userAgent }, responseType: 'arraybuffer', onprogress: function (e) { if (e.lengthComputable && onProgress) { onProgress(e.loaded, e.total); } }, onload: function (res) { if (res.status >= 200 && res.status < 300) { resolve(res.response); } else { reject(new Error('下载失败: ' + res.status)); } }, onerror: function () { reject(new Error('下载网络错误')); }, ontimeout: function () { reject(new Error('下载超时')); } }); }); } }; const BiliAPI = { getVideoInfo: function (bvid) { return Network.fetchJSON('https://api.bilibili.com/x/web-interface/view?bvid=' + bvid).then(function (res) { if (res.code !== 0) { throw new Error(res.message || '获取视频信息失败'); } return res.data; }); }, getBangumiInfo: function (videoId) { var self = this; var isEp = videoId.indexOf('ep') === 0; var id = videoId.replace(/^(ep|ss)/, ''); var url = 'https://api.bilibili.com/pgc/view/web/season?'; if (isEp) { url += 'ep_id=' + id; } else { url += 'season_id=' + id; } return Network.fetchJSON(url).then(function (res) { if (res.code !== 0) { throw new Error(res.message || '获取番剧信息失败'); } var result = res.result; var episodes = result.episodes || []; var pages = []; var currentEpId = null; if (isEp) { currentEpId = parseInt(id); } else { var urlMatch = window.location.pathname.match(/ep(\d+)/); if (urlMatch) { currentEpId = parseInt(urlMatch[1]); } } for (var i = 0; i < episodes.length; i++) { var ep = episodes[i]; pages.push({ cid: ep.cid, page: i + 1, part: ep.long_title || ep.title || ('第' + (i + 1) + '集'), duration: ep.duration / 1000, ep_id: ep.id, bvid: ep.bvid }); } var currentIndex = 0; if (currentEpId) { for (var j = 0; j < pages.length; j++) { if (pages[j].ep_id === currentEpId) { currentIndex = j; break; } } } var totalDuration = 0; for (var k = 0; k < episodes.length; k++) { totalDuration += episodes[k].duration / 1000; } return { title: result.season_title || result.title, pages: pages, owner: { name: result.up_info ? result.up_info.uname : '番剧' }, duration: totalDuration, desc: result.evaluate || '', currentPage: currentIndex + 1, type: 'bangumi' }; }); }, getPlayUrl: function (params) { var url; if (params.type === 'bangumi') { url = 'https://api.bilibili.com/pgc/player/web/playurl?ep_id=' + params.ep_id + '&cid=' + params.cid + '&qn=' + params.qn + '&fnval=4048&fnver=0&fourk=1'; } else { url = 'https://api.bilibili.com/x/player/playurl?bvid=' + params.bvid + '&cid=' + params.cid + '&qn=' + params.qn + '&fnval=4048&fnver=0&fourk=1'; } return Network.fetchJSON(url).then(function (res) { if (res.code !== 0) { throw new Error(res.message || '获取播放地址失败'); } return res.result || res.data; }); }, getAvailableQualities: function (playData) { var list = []; if (playData.accept_quality && playData.accept_description) { for (var i = 0; i < playData.accept_quality.length; i++) { list.push({ qn: playData.accept_quality[i], desc: playData.accept_description[i] || CONFIG.QUALITY_MAP[playData.accept_quality[i]] || playData.accept_quality[i] + 'P' }); } } return list; }, getStreams: function (playData, targetQn) { var dash = playData.dash; if (!dash) { throw new Error('该视频不支持DASH格式'); } var video = null; var audio = null; if (dash.video && dash.video.length > 0) { var sortedVideo = dash.video.slice().sort(function (a, b) { if (b.id !== a.id) return b.id - a.id; return b.bandwidth - a.bandwidth; }); for (var i = 0; i < sortedVideo.length; i++) { if (sortedVideo[i].id === targetQn) { video = sortedVideo[i]; break; } } if (!video) { for (var j = 0; j < sortedVideo.length; j++) { if (sortedVideo[j].id <= targetQn) { video = sortedVideo[j]; break; } } } if (!video) { video = sortedVideo[sortedVideo.length - 1]; } } if (dash.audio && dash.audio.length > 0) { var sortedAudio = dash.audio.slice().sort(function (a, b) { return b.bandwidth - a.bandwidth; }); audio = sortedAudio[0]; } if (dash.dolby && dash.dolby.audio && dash.dolby.audio[0]) { var dolby = dash.dolby.audio[0]; if (!audio || dolby.bandwidth > audio.bandwidth) { audio = dolby; } } if (dash.flac && dash.flac.audio) { if (!audio || dash.flac.audio.bandwidth > audio.bandwidth) { audio = dash.flac.audio; } } return { video: video, audio: audio }; } }; const JSMerger = { name: 'JS原生合并', status: 'ready', readBox: function (buffer, offset) { var view = new DataView(buffer); if (offset + 8 > buffer.byteLength) return null; var size = view.getUint32(offset); var type = String.fromCharCode( view.getUint8(offset + 4), view.getUint8(offset + 5), view.getUint8(offset + 6), view.getUint8(offset + 7) ); var headerSize = 8; if (size === 1 && offset + 16 <= buffer.byteLength) { size = Number(view.getBigUint64(offset + 8)); headerSize = 16; } else if (size === 0) { size = buffer.byteLength - offset; } return { size: size, type: type, headerSize: headerSize, offset: offset }; }, parseBoxes: function (buffer) { var boxes = []; var offset = 0; while (offset < buffer.byteLength) { var box = this.readBox(buffer, offset); if (!box || box.size < 8) break; boxes.push({ size: box.size, type: box.type, headerSize: box.headerSize, offset: box.offset, data: new Uint8Array(buffer, offset, box.size) }); offset += box.size; } return boxes; }, findBox: function (boxes, type) { for (var i = 0; i < boxes.length; i++) { if (boxes[i].type === type) { return boxes[i]; } } return null; }, findAllBoxes: function (boxes, type) { var result = []; for (var i = 0; i < boxes.length; i++) { if (boxes[i].type === type) { result.push(boxes[i]); } } return result; }, parseContainerBox: function (boxData, headerOffset) { if (headerOffset === undefined) { headerOffset = 8; } var childBoxes = []; var offset = headerOffset; while (offset < boxData.length) { if (offset + 8 > boxData.length) break; var view = new DataView(boxData.buffer, boxData.byteOffset + offset); var size = view.getUint32(0); var type = String.fromCharCode( boxData[offset + 4], boxData[offset + 5], boxData[offset + 6], boxData[offset + 7] ); if (size === 0) size = boxData.length - offset; if (size < 8 || offset + size > boxData.length) break; childBoxes.push({ size: size, type: type, offset: offset, data: boxData.slice(offset, offset + size) }); offset += size; } return childBoxes; }, createBox: function (type, content) { var size = 8 + content.length; var box = new Uint8Array(size); var view = new DataView(box.buffer); view.setUint32(0, size); box[4] = type.charCodeAt(0); box[5] = type.charCodeAt(1); box[6] = type.charCodeAt(2); box[7] = type.charCodeAt(3); box.set(content, 8); return box; }, concat: function () { var arrays = Array.prototype.slice.call(arguments); var totalLen = 0; for (var i = 0; i < arrays.length; i++) { totalLen += arrays[i].length; } var result = new Uint8Array(totalLen); var offset = 0; for (var j = 0; j < arrays.length; j++) { result.set(arrays[j], offset); offset += arrays[j].length; } return result; }, modifyTrackId: function (trakData, newId) { var result = new Uint8Array(trakData); var trakBoxes = this.parseContainerBox(result); for (var i = 0; i < trakBoxes.length; i++) { var box = trakBoxes[i]; if (box.type === 'tkhd') { var version = result[box.offset + 8]; var trackIdOffset = box.offset + 8 + (version === 0 ? 12 : 20); var view = new DataView(result.buffer, result.byteOffset + trackIdOffset); view.setUint32(0, newId); } } return result; }, modifyTrexTrackId: function (trexData, newId) { var result = new Uint8Array(trexData); var view = new DataView(result.buffer, result.byteOffset + 12); view.setUint32(0, newId); return result; }, getTrackType: function (trakData) { var boxes = this.parseContainerBox(trakData); for (var i = 0; i < boxes.length; i++) { var box = boxes[i]; if (box.type === 'mdia') { var mdiaBoxes = this.parseContainerBox(box.data); for (var j = 0; j < mdiaBoxes.length; j++) { var mdiaBox = mdiaBoxes[j]; if (mdiaBox.type === 'hdlr') { var handlerType = String.fromCharCode( mdiaBox.data[16], mdiaBox.data[17], mdiaBox.data[18], mdiaBox.data[19] ); return handlerType; } } } } return null; }, buildMoov: function (videoMoov, audioMoov, metadata) { var videoMoovBoxes = this.parseContainerBox(videoMoov.data); var audioMoovBoxes = this.parseContainerBox(audioMoov.data); var mvhd = this.findBox(videoMoovBoxes, 'mvhd'); if (!mvhd) throw new Error('找不到mvhd box'); var videoTrak = null; for (var i = 0; i < videoMoovBoxes.length; i++) { var vbox = videoMoovBoxes[i]; if (vbox.type === 'trak') { var trackType = this.getTrackType(vbox.data); if (trackType === 'vide') { videoTrak = vbox; break; } } } var audioTrak = null; for (var j = 0; j < audioMoovBoxes.length; j++) { var abox = audioMoovBoxes[j]; if (abox.type === 'trak') { var aTrackType = this.getTrackType(abox.data); if (aTrackType === 'soun') { audioTrak = abox; break; } } } if (!videoTrak) throw new Error('找不到视频轨道'); var videoTrakData = this.modifyTrackId(videoTrak.data, 1); var audioTrakData = null; if (audioTrak) { audioTrakData = this.modifyTrackId(audioTrak.data, 2); } var mvhdData = new Uint8Array(mvhd.data); var mvhdVersion = mvhdData[8]; var nextTrackIdOffset = mvhdVersion === 0 ? 8 + 96 : 8 + 108; var mvhdView = new DataView(mvhdData.buffer, mvhdData.byteOffset + nextTrackIdOffset - 4); mvhdView.setUint32(0, audioTrakData ? 3 : 2); var videoMvex = this.findBox(videoMoovBoxes, 'mvex'); var audioMvex = this.findBox(audioMoovBoxes, 'mvex'); var mvexData = null; if (videoMvex || audioMvex) { mvexData = this.buildMvex(videoMvex, audioMvex); } var udtaContent = this.buildUdta(metadata); var moovParts = [mvhdData, videoTrakData]; if (audioTrakData) { moovParts.push(audioTrakData); } if (mvexData) { moovParts.push(mvexData); } moovParts.push(udtaContent); var moovContent = this.concat.apply(this, moovParts); return this.createBox('moov', moovContent); }, buildMvex: function (videoMvex, audioMvex) { var mvexParts = []; if (videoMvex) { var videoMvexBoxes = this.parseContainerBox(videoMvex.data); for (var i = 0; i < videoMvexBoxes.length; i++) { var box = videoMvexBoxes[i]; if (box.type === 'trex') { var modifiedTrex = this.modifyTrexTrackId(box.data, 1); mvexParts.push(modifiedTrex); } else if (box.type === 'mehd') { mvexParts.push(box.data); } } } if (audioMvex) { var audioMvexBoxes = this.parseContainerBox(audioMvex.data); for (var j = 0; j < audioMvexBoxes.length; j++) { var abox = audioMvexBoxes[j]; if (abox.type === 'trex') { var modifiedAudioTrex = this.modifyTrexTrackId(abox.data, 2); mvexParts.push(modifiedAudioTrex); } } } if (mvexParts.length > 0) { var mvexContent = this.concat.apply(this, mvexParts); return this.createBox('mvex', mvexContent); } return null; }, buildUdta: function (metadata) { var encoder = new TextEncoder(); var self = this; var buildDataBox = function (value) { if (!value) return null; var valueBytes = encoder.encode(value); var payload = new Uint8Array(8 + valueBytes.length); var view = new DataView(payload.buffer); view.setUint32(0, 1); view.setUint32(4, 0); payload.set(valueBytes, 8); return self.createBox('data', payload); }; var buildMetaTag = function (tag, value) { if (!value) return new Uint8Array(0); var dataBox = buildDataBox(value); if (!dataBox) return new Uint8Array(0); return self.createBox(tag, dataBox); }; var titleTag = buildMetaTag('\xa9nam', metadata.title); var artistTag = buildMetaTag('\xa9ART', metadata.author); var albumTag = buildMetaTag('\xa9alb', 'Bilibili'); var yearTag = buildMetaTag('\xa9day', new Date().getFullYear().toString()); var commentText = metadata.description ? (metadata.description + '\n\n' + LEARNING_DISCLAIMER) : LEARNING_DISCLAIMER; var commentTag = buildMetaTag('\xa9cmt', commentText); var encoderTag = buildMetaTag('\xa9too', 'Bilibili Video Downloader'); var ilstContent = self.concat( titleTag, artistTag, albumTag, yearTag, commentTag, encoderTag ); var ilstBox = self.createBox('ilst', ilstContent); var hdlrContent = new Uint8Array(24); var hdlrView = new DataView(hdlrContent.buffer); hdlrView.setUint32(0, 0); hdlrView.setUint32(4, 0); hdlrContent.set([0x6d, 0x64, 0x69, 0x72], 8); hdlrContent.set([0x61, 0x70, 0x70, 0x6c], 12); hdlrView.setUint32(16, 0); hdlrView.setUint32(20, 0); var hdlrBox = self.createBox('hdlr', hdlrContent); var metaContent = self.concat( new Uint8Array([0, 0, 0, 0]), hdlrBox, ilstBox ); var metaBox = self.createBox('meta', metaContent); return self.createBox('udta', metaBox); }, merge: function (videoBuffer, audioBuffer, metadata) { var self = this; return new Promise(function (resolve, reject) { try { var videoBoxes = self.parseBoxes(videoBuffer); var audioBoxes = self.parseBoxes(audioBuffer); var videoFtyp = self.findBox(videoBoxes, 'ftyp'); var videoMoov = self.findBox(videoBoxes, 'moov'); var videoMdat = self.findAllBoxes(videoBoxes, 'mdat'); var videoMoof = self.findAllBoxes(videoBoxes, 'moof'); var audioMoov = self.findBox(audioBoxes, 'moov'); var audioMdat = self.findAllBoxes(audioBoxes, 'mdat'); var audioMoof = self.findAllBoxes(audioBoxes, 'moof'); if (!videoFtyp || !videoMoov) { throw new Error('视频文件结构不完整'); } var isFragmented = videoMoof.length > 0 || audioMoof.length > 0; if (isFragmented) { console.log('检测到fMP4格式'); var parts = [videoFtyp.data]; if (audioMoov) { var newMoov = self.buildMoov(videoMoov, audioMoov, metadata); parts.push(newMoov); } else { parts.push(videoMoov.data); } for (var i = 0; i < videoMoof.length; i++) { parts.push(videoMoof[i].data); if (videoMdat[i]) { parts.push(videoMdat[i].data); } } for (var j = 0; j < audioMoof.length; j++) { var modifiedMoof = self.modifyMoofTrackId(audioMoof[j].data, 2); parts.push(modifiedMoof); if (audioMdat[j]) { parts.push(audioMdat[j].data); } } resolve(self.concat.apply(self, parts).buffer); } else { var mergedMoov; if (audioMoov) { mergedMoov = self.buildMoov(videoMoov, audioMoov, metadata); } else { mergedMoov = videoMoov.data; } var allMdat = []; for (var k = 0; k < videoMdat.length; k++) { allMdat.push(videoMdat[k].data); } if (audioMdat.length > 0) { for (var l = 0; l < audioMdat.length; l++) { allMdat.push(audioMdat[l].data); } } var mdatParts = []; for (var m = 0; m < allMdat.length; m++) { mdatParts.push(allMdat[m].slice(8)); } var mdatContent = self.concat.apply(self, mdatParts); var mergedMdat = self.createBox('mdat', mdatContent); resolve(self.concat(videoFtyp.data, mergedMoov, mergedMdat).buffer); } } catch (error) { console.error('JS合并失败:', error); reject(error); } }); }, modifyMoofTrackId: function (moofData, newId) { var result = new Uint8Array(moofData); var boxes = this.parseContainerBox(result); for (var i = 0; i < boxes.length; i++) { var box = boxes[i]; if (box.type === 'traf') { var trafBoxes = this.parseContainerBox(box.data); for (var j = 0; j < trafBoxes.length; j++) { var trafBox = trafBoxes[j]; if (trafBox.type === 'tfhd') { var offset = box.offset + trafBox.offset + 12; var view = new DataView(result.buffer, result.byteOffset + offset); view.setUint32(0, newId); } } } } return result; } }; const MergeManager = { currentMethod: CONFIG.MERGE_METHODS.JSMERGE, methods: { 'js-merge': { name: 'JS原生合并', desc: '浏览器内直接合并,兼容性好', handler: JSMerger, recommended: true }, 'separate': { name: '分离下载', desc: '分别保存视频和音频文件', handler: null } }, setMethod: function (method) { this.currentMethod = method; }, merge: function (videoBuffer, audioBuffer, metadata) { var self = this; var method = this.methods[this.currentMethod]; if (this.currentMethod === CONFIG.MERGE_METHODS.SEPARATE) { return Promise.resolve({ separate: true, video: videoBuffer, audio: audioBuffer }); } if (!method.handler) { return Promise.reject(new Error('未找到合并处理器')); } return method.handler.merge(videoBuffer, audioBuffer, metadata).then(function (result) { return { separate: false, data: result }; }).catch(function (error) { console.error(method.name + ' 合并失败:', error); throw error; }); } }; const CompleteEffect = { show: function () { var overlay = document.createElement('div'); overlay.className = 'bdl-complete-overlay'; var html = '