// ==UserScript== // @name B站字幕下载器 // @namespace https://space.bilibili.com/398910090 // @version 1.4 // @author Ace // @description 下载B站视频字幕,支持用户上传字幕和AI字幕,支持JSON、SRT、VTT格式及自定义扩展名,支持批量下载所有语言字幕并打包为ZIP // @match *://*.bilibili.com/video/* // @match *://*.bilibili.com/bangumi/play/* // @icon https://www.bilibili.com/favicon.ico // @grant GM_xmlhttpRequest // @grant GM_download // @grant unsafeWindow // @license MIT // ==/UserScript== (function() { 'use strict'; // 记录脚本启动时间 const startTime = performance.now(); // 获取脚本版本号 const version = '1.4'; // 脚本名称 const scriptName = 'B站字幕下载器'; // 字幕格式枚举 const SUBTITLE_FORMATS = { JSON: 'json', SRT: 'srt', VTT: 'vtt', CUSTOM: 'custom' }; // 自定义扩展名相关常量 const CUSTOM_EXTENSIONS_KEY = 'bilibili_subtitle_custom_extensions'; const INCLUDE_BV_KEY = 'bilibili_subtitle_include_bv'; let customExtensions = []; // 预设扩展名列表 const PRESET_EXTENSIONS = [ { name: 'TXT', value: 'txt', mimeType: 'text/plain' }, { name: 'MD', value: 'md', mimeType: 'text/markdown' }, { name: 'CSV', value: 'csv', mimeType: 'text/csv' }, { name: 'XML', value: 'xml', mimeType: 'application/xml' }, { name: 'HTML', value: 'html', mimeType: 'text/html' } ]; // 存储拦截到的字幕URL({url, language},language留空由内容检测兜底) let interceptedSubtitleUrls = []; // 添加全局样式 function addGlobalStyles() { const style = document.createElement('style'); style.textContent = ` /* 全局样式,确保下拉选项可见 */ .bilibili-subtitle-download-confirm select { background-color: rgba(25, 26, 27, 0.98) !important; color: white !important; } .bilibili-subtitle-download-confirm select option { background-color: rgba(25, 26, 27, 0.98) !important; color: white !important; } /* InfoBar 样式 */ .bilibili-subtitle-infobar { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: rgba(25, 26, 27, 0.98); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; padding: 12px 16px; color: white; font-size: 14px; z-index: 2147483647; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8); backdrop-filter: blur(10px); min-width: 200px; max-width: 400px; text-align: center; transition: all 0.3s ease; } /* InfoBar 不同类型的样式 */ .bilibili-subtitle-infobar.info { border-left: 4px solid #00a1d6; } .bilibili-subtitle-infobar.success { border-left: 4px solid #52c41a; } .bilibili-subtitle-infobar.warning { border-left: 4px solid #faad14; } .bilibili-subtitle-infobar.error { border-left: 4px solid #f5222d; } `; document.head.appendChild(style); } // 显示信息提示条 (InfoBar) function showInfoBar(message, type = 'info', duration = 3000) { // 移除已存在的InfoBar const existingInfoBar = document.querySelector('.bilibili-subtitle-infobar'); if (existingInfoBar) { existingInfoBar.remove(); } // 创建新的InfoBar const infoBar = document.createElement('div'); infoBar.className = `bilibili-subtitle-infobar ${type}`; infoBar.textContent = message; // 添加到页面 document.body.appendChild(infoBar); // 设置自动移除 if (duration > 0) { setTimeout(() => { if (infoBar.parentNode) { // 添加淡出动画 infoBar.style.opacity = '0'; infoBar.style.transform = 'translate(-50%, -50%) scale(0.9)'; // 动画完成后移除元素 setTimeout(() => { if (infoBar.parentNode) { infoBar.remove(); } }, 300); } }, duration); } // 点击关闭 infoBar.addEventListener('click', () => { if (infoBar.parentNode) { infoBar.remove(); } }); return infoBar; } // 初始化函数 function init() { // 添加全局样式 addGlobalStyles(); // 加载自定义扩展名设置 loadCustomExtensions(); // 启动网络请求拦截 setupNetworkInterception(); // 等待字幕选择器加载 waitForSubtitleSelector(); } // 等待字幕选择器加载 function waitForSubtitleSelector() { let timeoutId; const checkInterval = setInterval(() => { // 查找字幕选择器相关元素 const subtitlePanel = document.querySelector('.bpx-player-ctrl-subtitle-menu-left') || document.querySelector('.bpx-player-ctrl-subtitle-menu-origin') || document.querySelector('.bpx-player-ctrl-subtitle-language-item'); if (subtitlePanel) { clearInterval(checkInterval); clearTimeout(timeoutId); // 确保找到正确的父面板 const actualPanel = subtitlePanel.closest('.bpx-player-ctrl-subtitle-menu-left') || subtitlePanel.closest('.bpx-player-ctrl-subtitle-menu-origin') || subtitlePanel.parentElement; createDownloadInterface(actualPanel); // 输出成功日志 const endTime = performance.now(); const costTime = Math.round(endTime - startTime); console.log(`%c 🎬 ${scriptName} v${version} %c Cost ${costTime}ms`, "background:#4A90E2;color:white;padding:2px 6px;border-radius:3px 0 0 3px;font-weight:bold;", "background:#50E3C2;color:#003333;padding:2px 6px;border-radius:0 3px 3px 0;font-weight:bold;"); } }, 500); // 30秒后超时 timeoutId = setTimeout(() => { clearInterval(checkInterval); console.log('[B站字幕下载器] 注入失败, 当前视频可能没有字幕⚠️'); }, 30000); } // 设置网络请求拦截(只拦截字幕文件请求) function setupNetworkInterception() { console.log('[BSD] 网络拦截已启动'); // 拦截XMLHttpRequest const originalXHROpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url) { if (url && isSubtitleFileUrl(url)) { recordSubtitleUrl(url); } return originalXHROpen.apply(this, arguments); }; // 拦截fetch const originalFetch = window.fetch; window.fetch = function(input) { const url = (typeof input === 'string') ? input : (input && input.url); if (url && isSubtitleFileUrl(url)) { recordSubtitleUrl(url); } return originalFetch.apply(this, arguments); }; } // 记录字幕文件URL(语言留空,下载后由内容检测兜底) function recordSubtitleUrl(url) { const normalizedUrl = url.replace(/^https?:/, ''); interceptedSubtitleUrls.push({ url: normalizedUrl, language: '__pending__' }); console.log('[BSD] 拦截到字幕文件URL:', normalizedUrl); } // 判断是否是字幕文件URL(精确匹配,排除API和日志URL) function isSubtitleFileUrl(url) { if (!url) return false; if (url.includes('api.bilibili.com')) return false; if (url.includes('data.bilibili.com')) return false; // 字幕文件URL特征:含 subtitle 或 ai_subtitle 路径,且有 auth_key 参数 return (url.includes('subtitle') || url.includes('ai_subtitle')) && url.includes('auth_key'); } // 兼容旧代码的判断函数 function isSubtitleUrl(url) { return isSubtitleFileUrl(url); } // 创建下载界面 function createDownloadInterface(subtitlePanel) { console.log('[BSD] 创建下载界面, 面板:', subtitlePanel ? subtitlePanel.className : 'null'); // 添加下载按钮 try { addDownloadButtons(); } catch (e) { console.error('[BSD] addDownloadButtons 初始异常:', e); } // 添加批量下载按钮 try { addBatchDownloadButton(); } catch (e) { console.error('[BSD] addBatchDownloadButton 初始异常:', e); } // 监听字幕列表变化,动态添加下载按钮 const observer = new MutationObserver(() => { try { addDownloadButtons(); } catch (e) { console.error('[BSD] observer addDownloadButtons 异常:', e); } try { addBatchDownloadButton(); } catch (e) { console.error('[BSD] observer addBatchDownloadButton 异常:', e); } }); observer.observe(subtitlePanel, { childList: true, subtree: true }); // 15秒后停止监听 setTimeout(() => observer.disconnect(), 15000); console.log('[BSD] MutationObserver 已设置, 15秒后断开'); } // 添加下载按钮 function addDownloadButtons() { const subtitleItems = document.querySelectorAll('.bpx-player-ctrl-subtitle-language-item'); if (subtitleItems.length === 0) { console.warn('[BSD] addDownloadButtons: 未找到字幕选项 (.bpx-player-ctrl-subtitle-language-item)'); return; } let added = 0; subtitleItems.forEach(item => { // 避免重复添加 if (item.querySelector('.bilibili-subtitle-download-btn')) { return; } // 创建下载按钮 const downloadBtn = document.createElement('button'); downloadBtn.className = 'bilibili-subtitle-download-btn'; downloadBtn.textContent = '下载'; downloadBtn.style.cssText = ` background: transparent; border: none; color: white; cursor: pointer; font-size: 0.85em; padding: 0.1em 0.5em; margin: 0 0 0 0.6em; border-radius: 3px; transition: all 0.2s ease; text-decoration: none; outline: none; min-width: 0; text-align: center; display: inline-flex; align-items: center; justify-content: center; line-height: 1.6; height: auto; min-height: auto; vertical-align: middle; box-sizing: border-box; position: relative; top: 0; `; // 绑定点击事件 downloadBtn.addEventListener('click', (e) => { e.stopPropagation(); // 阻止事件冒泡,避免触发字幕选择 showFormatMenu(e.target); }); // 添加悬停效果 downloadBtn.addEventListener('mouseenter', () => { // 鼠标悬停时背景色变为半透明蓝色(B站主题色) // downloadBtn.style.backgroundColor = 'rgba(0, 161, 214, 0.3)'; // 鼠标悬停时文字颜色变为蓝色(B站主题色) downloadBtn.style.color = '#00a1d6'; }); downloadBtn.addEventListener('mouseleave', () => { // 鼠标离开时恢复原始背景色(半透明深灰色) // downloadBtn.style.backgroundColor = 'rgba(35, 37, 39, 0.8)'; // 鼠标离开时恢复原始文字颜色(白色) downloadBtn.style.color = 'white'; }); // 添加到字幕选项 item.appendChild(downloadBtn); added++; }); if (added > 0) { console.log(`[BSD] addDownloadButtons: 新增下载按钮 ${added} 个 (共 ${subtitleItems.length} 个字幕项)`); } } // 显示格式选择菜单(简化版:直接下载) function showFormatMenu() { // 直接调用下载函数,使用默认格式 downloadSubtitle(SUBTITLE_FORMATS.JSON); } // 获取字幕URL(字符串数组,兼容单个下载) function getSubtitleUrls() { return getSubtitleUrlsWithLang().map(item => item.url); } // 获取字幕URL及其语言({url, language}对象数组) // 语言统一留空,下载后由 detectLanguageFromContent 兜底检测 function getSubtitleUrlsWithLang() { // 拦截记录(语言留空,由内容检测兜底) const intercepted = interceptedSubtitleUrls.map(item => ({ url: item.url, language: item.language })); // 从脚本标签中提取字幕URL(语言留空,统一由内容检测兜底) const scripts = document.querySelectorAll('script'); let scriptFound = 0; const scriptUrls = []; scripts.forEach(script => { const content = script.textContent; if (!content) return; const userSubtitleMatch = content.match(/https?:\/\/[^\s"]*subtitle\/[^\s"]*\.json\?auth_key=[^\s"]*/g); if (userSubtitleMatch) { scriptUrls.push(...userSubtitleMatch); scriptFound += userSubtitleMatch.length; } const aiSubtitleMatch = content.match(/https?:\/\/[^\s"]*ai_subtitle\/[^\s"]*\?auth_key=[^\s"]*/g); if (aiSubtitleMatch) { scriptUrls.push(...aiSubtitleMatch); scriptFound += aiSubtitleMatch.length; } }); console.log('[BSD] Script解析到字幕URL:', scriptFound, '个'); console.log('[BSD] 拦截器累计到字幕URL:', intercepted.length, '个'); // 合并:拦截记录优先,script来源同样留空(统一由内容检测兜底) const scriptItems = scriptUrls.map(url => ({ url: url.replace(/^https?:/, ''), language: '__pending__' })); const all = [...intercepted, ...scriptItems]; // 按URL去重(保留第一条的语言) const seen = new Set(); const unique = []; for (const item of all) { if (item.url && isSubtitleUrl(item.url) && item.url.includes('auth_key') && !seen.has(item.url)) { seen.add(item.url); unique.push(item); } } console.log('[BSD] 合并去重后有效字幕URL:', unique.length, '个'); unique.forEach((it, i) => console.log(`[BSD] URL[${i}]:`, it.url, '-> 语言:', it.language)); return unique; } // 获取BV号 function getBVNumber() { // 从URL中提取BV号 const url = window.location.href; const match = url.match(/BV[0-9A-Za-z]{10}/); return match ? match[0] : 'unknown_bv'; } // 获取当前选中的字幕语言 function getCurrentSubtitleLanguage() { const subtitleItems = document.querySelectorAll('.bpx-player-ctrl-subtitle-language-item'); // 提取单项语言名的辅助函数(排除"关闭字幕"项) function getLangFromItem(item) { const languageElement = item.querySelector('.bpx-player-ctrl-subtitle-language-item-text'); let language = languageElement ? languageElement.textContent.trim() : item.textContent.trim(); // 移除"下载"按钮文字和附加信息 language = language.replace(/下载\s*$/, '').replace(/\s*\((自动生成|AI|机翻|人工)\)/g, '').trim(); // 排除"关闭"项(无语言意义) if (/^(关闭|关闭字幕|无|Off)$/i.test(language)) return null; return language; } // 查找当前选中的字幕选项 for (const item of subtitleItems) { if (item.classList.contains('bpx-state-active') || item.classList.contains('active') || item.classList.contains('selected') || item.getAttribute('aria-selected') === 'true') { const lang = getLangFromItem(item); if (lang) return lang; } } // 没找到active项时不回退到第一项(第一项常是"关闭字幕") return 'unknown_lang'; } // 从字幕JSON内容检测语言(通过字符集判断) function detectLanguageFromContent(subtitleData) { if (!subtitleData || !subtitleData.body || subtitleData.body.length === 0) { return null; } // 取前10条的content拼接作为样本 const sampleText = subtitleData.body.slice(0, 10).map(item => item.content || '').join(' '); if (!sampleText) return null; const hasChinese = /[\u4e00-\u9fff]/.test(sampleText); // CJK统一表意文字 const hasHiragana = /[\u3040-\u309f]/.test(sampleText); // 平假名 const hasKatakana = /[\u30a0-\u30ff]/.test(sampleText); // 片假名 const hasHangul = /[\uac00-\ud7af]/.test(sampleText); // 韩文字母 if (hasHiragana || hasKatakana) return '日语'; if (hasHangul) return '韩语'; if (hasChinese) return '中文'; // 纯拉丁字母 if (/^[a-zA-Z0-9\s.,!?;:'"()\-]+$/.test(sampleText)) return 'English'; return null; } // 下载字幕 function downloadSubtitle(format) { const urls = getSubtitleUrls(); console.log('检测到的字幕URL:', urls); if (urls.length === 0) { console.error('未找到字幕URL'); showInfoBar('未找到字幕URL,请先选择字幕,然后再重试下载', 'error'); return; } // 显示正在下载提示 showInfoBar('正在下载字幕数据...', 'info', 0); // 使用第一个找到的字幕URL // 补全协议(规范化后可能是 // 开头) const subtitleUrl = urls[0].startsWith('//') ? 'https:' + urls[0] : urls[0]; // 获取字幕数据 GM_xmlhttpRequest({ method: 'GET', url: subtitleUrl, onload: function(response) { // 移除正在下载的提示 const loadingInfoBar = document.querySelector('.bilibili-subtitle-infobar.info'); if (loadingInfoBar && loadingInfoBar.textContent.includes('正在下载字幕数据')) { loadingInfoBar.remove(); } try { const subtitleData = JSON.parse(response.responseText); let content, filename, mimeType; // 获取语言和BV号用于文件名 const language = getCurrentSubtitleLanguage(); const bvNumber = getBVNumber(); // 根据选择的格式转换字幕 switch (format) { case SUBTITLE_FORMATS.JSON: { content = JSON.stringify(subtitleData, null, 2); filename = `${language}_${bvNumber}.json`; mimeType = 'application/json'; break; } case SUBTITLE_FORMATS.SRT: { content = jsonToSrt(subtitleData); filename = `${language}_${bvNumber}.srt`; mimeType = 'text/srt'; break; } case SUBTITLE_FORMATS.VTT: { content = jsonToVtt(subtitleData); filename = `${language}_${bvNumber}.vtt`; mimeType = 'text/vtt'; break; } default: { // 检查是否是自定义扩展名 const customExt = customExtensions.find(ext => ext.value === format); if (customExt) { // 对于自定义扩展名,默认使用SRT格式的内容 content = jsonToSrt(subtitleData); filename = `${language}_${bvNumber}.${format}`; mimeType = customExt.mimeType; } else { // 如果不是已知的自定义扩展名,默认使用SRT格式 content = jsonToSrt(subtitleData); filename = `${language}_${bvNumber}.${format}`; mimeType = 'text/plain'; } break; } } // 下载字幕文件 downloadFile(content, filename, mimeType, subtitleData, format); } catch (error) { console.error('处理字幕数据时出错:', error); showInfoBar('处理字幕数据时出错:' + error.message, 'error'); } }, onerror: function() { // 移除正在下载的提示 const loadingInfoBar = document.querySelector('.bilibili-subtitle-infobar.info'); if (loadingInfoBar && loadingInfoBar.textContent.includes('正在下载字幕数据')) { loadingInfoBar.remove(); } console.error('下载字幕数据失败'); showInfoBar('下载字幕数据失败', 'error'); } }); } // 添加批量下载按钮 function addBatchDownloadButton() { // 避免重复添加 if (document.querySelector('.bilibili-subtitle-batch-download-btn')) { return; } const subtitleItems = document.querySelectorAll('.bpx-player-ctrl-subtitle-language-item'); // 少于2个字幕时不显示批量下载按钮 if (subtitleItems.length < 2) { console.log(`[BSD] addBatchDownloadButton: 字幕项 ${subtitleItems.length} 个, 少于2个不显示批量按钮`); return; } console.log(`[BSD] addBatchDownloadButton: 字幕项 ${subtitleItems.length} 个, 准备插入批量按钮`); // 找到语言列表容器 const listContainer = subtitleItems[0].parentElement; if (!listContainer) { console.warn('[BSD] addBatchDownloadButton: listContainer 为 null'); return; } console.log('[BSD] addBatchDownloadButton: listContainer =', listContainer.className); // 创建批量下载按钮 const btn = document.createElement('div'); btn.className = 'bilibili-subtitle-batch-download-btn'; btn.textContent = '批量下载全部字幕'; btn.style.cssText = ` padding: 6px 12px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: #00a1d6; font-size: 13px; border: 1px dashed rgba(0, 161, 214, 0.5); border-radius: 4px; margin: 4px 0; background: rgba(0, 161, 214, 0.05); transition: all 0.2s ease; `; btn.addEventListener('click', (e) => { e.stopPropagation(); showBatchFormatDialog(); }); btn.addEventListener('mouseenter', () => { btn.style.backgroundColor = 'rgba(0, 161, 214, 0.15)'; btn.style.borderColor = '#00a1d6'; }); btn.addEventListener('mouseleave', () => { btn.style.backgroundColor = 'rgba(0, 161, 214, 0.05)'; btn.style.borderColor = 'rgba(0, 161, 214, 0.5)'; }); // 插入到语言列表容器的父级中(列表容器之前),避免修改列表容器内部导致B站重渲染 const targetContainer = listContainer.parentElement || listContainer; try { targetContainer.insertBefore(btn, listContainer); console.log('[BSD] addBatchDownloadButton: 批量按钮已插入到 listContainer 之前'); } catch (e) { // 如果 insertBefore 失败,回退到 appendChild console.warn('[BSD] addBatchDownloadButton: insertBefore 失败, 回退 appendChild:', e.message); targetContainer.appendChild(btn); } } // 动态注入JSZip到主页面环境(绕过油猴沙箱,使 generateAsync 正常工作) // 返回 Promise 成功 / Promise 失败 const JSZIP_CDN = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'; function ensureJSZipLoaded() { // 兼容获取主页面 window(沙箱下用 unsafeWindow,否则回退 window) const pageWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; // 已加载则直接返回 if (pageWin.JSZip) { console.log('[BSD] JSZip已存在于主页面'); return Promise.resolve(true); } // 避免重复注入script标签 if (document.querySelector('#bsd-jszip-script')) { console.log('[BSD] JSZip script已存在, 等待加载...'); } else { const script = document.createElement('script'); script.id = 'bsd-jszip-script'; script.src = JSZIP_CDN; document.head.appendChild(script); console.log('[BSD] 已注入JSZip script标签:', JSZIP_CDN); } // 轮询等待加载完成,最多等15秒 return new Promise((resolve) => { const start = Date.now(); const timer = setInterval(() => { if (pageWin.JSZip) { clearInterval(timer); console.log('[BSD] JSZip加载完成, 耗时:', Date.now() - start, 'ms'); resolve(true); } else if (Date.now() - start > 15000) { clearInterval(timer); console.error('[BSD] JSZip加载超时(15s)'); resolve(false); } }, 100); }); } // 显示批量下载格式选择窗口 async function showBatchFormatDialog() { // 注入并等待JSZip加载到主页面环境 showInfoBar('正在加载JSZip库...', 'info', 0); const loaded = await ensureJSZipLoaded(); const loadingBar = document.querySelector('.bilibili-subtitle-infobar.info'); if (loadingBar) loadingBar.remove(); if (!loaded) { showInfoBar('JSZip库加载失败,请检查网络后重试', 'error'); return; } // 移除已存在的窗口 const existing = document.querySelector('.bilibili-subtitle-batch-dialog'); if (existing) existing.remove(); // 创建窗口(复用 download-confirm 类名以应用下拉框全局样式) const dialog = document.createElement('div'); dialog.className = 'bilibili-subtitle-batch-dialog bilibili-subtitle-download-confirm'; dialog.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: rgba(25, 26, 27, 0.98); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; padding: 20px; z-index: 2147483647; font-size: 13px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8); backdrop-filter: blur(10px); min-width: 320px; color: white; `; // 标题 const title = document.createElement('div'); title.textContent = '批量下载字幕'; title.style.cssText = ` font-size: 16px; font-weight: bold; margin-bottom: 12px; text-align: center; `; dialog.appendChild(title); // 提示信息 const info = document.createElement('div'); info.textContent = '将下载当前视频所有语言的字幕,打包为ZIP文件'; info.style.cssText = ` margin-bottom: 16px; text-align: center; font-size: 12px; color: rgba(255, 255, 255, 0.6); line-height: 1.5; `; dialog.appendChild(info); // 格式选择 const formatLabel = document.createElement('div'); formatLabel.textContent = '字幕格式:'; formatLabel.style.cssText = ` margin-bottom: 8px; font-size: 12px; color: rgba(255, 255, 255, 0.8); `; dialog.appendChild(formatLabel); const formatSelect = document.createElement('select'); formatSelect.style.cssText = ` width: 100%; padding: 8px; margin-bottom: 20px; background: rgba(25, 26, 27, 0.98); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; font-size: 13px; box-sizing: border-box; cursor: pointer; `; const formatOptions = [ { value: SUBTITLE_FORMATS.JSON, name: 'JSON' }, { value: SUBTITLE_FORMATS.SRT, name: 'SRT' }, { value: SUBTITLE_FORMATS.VTT, name: 'VTT' } ]; formatOptions.forEach(opt => { const option = document.createElement('option'); option.value = opt.value; option.textContent = opt.name; formatSelect.appendChild(option); }); // 添加已保存的自定义扩展名 if (customExtensions.length > 0) { const separator = document.createElement('option'); separator.textContent = '---'; separator.disabled = true; formatSelect.appendChild(separator); customExtensions.forEach(ext => { const option = document.createElement('option'); option.value = ext.value; option.textContent = ext.name; formatSelect.appendChild(option); }); } dialog.appendChild(formatSelect); // 按钮容器 const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = ` display: flex; justify-content: space-between; gap: 10px; `; dialog.appendChild(buttonContainer); // 取消按钮 const cancelBtn = document.createElement('button'); cancelBtn.textContent = '取消'; cancelBtn.style.cssText = ` flex: 1; padding: 8px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; cursor: pointer; transition: all 0.2s ease; `; cancelBtn.addEventListener('click', () => dialog.remove()); buttonContainer.appendChild(cancelBtn); // 下载按钮 const downloadBtn = document.createElement('button'); downloadBtn.textContent = '批量下载'; downloadBtn.style.cssText = ` flex: 1; padding: 8px; background: rgba(0, 161, 214, 0.8); border: 1px solid rgba(0, 161, 214, 0.8); border-radius: 4px; color: white; cursor: pointer; transition: all 0.2s ease; `; downloadBtn.addEventListener('click', () => { const format = formatSelect.value; dialog.remove(); batchDownloadSubtitles(format); }); buttonContainer.appendChild(downloadBtn); // 悬停效果 cancelBtn.addEventListener('mouseenter', () => cancelBtn.style.background = 'rgba(255, 255, 255, 0.2)'); cancelBtn.addEventListener('mouseleave', () => cancelBtn.style.background = 'rgba(255, 255, 255, 0.1)'); downloadBtn.addEventListener('mouseenter', () => downloadBtn.style.background = 'rgba(0, 161, 214, 1)'); downloadBtn.addEventListener('mouseleave', () => downloadBtn.style.background = 'rgba(0, 161, 214, 0.8)'); document.body.appendChild(dialog); // ESC关闭 const handleEsc = (e) => { if (e.key === 'Escape') { dialog.remove(); document.removeEventListener('keydown', handleEsc); } }; document.addEventListener('keydown', handleEsc); // 点击外部关闭 const handleOutsideClick = (e) => { if (!dialog.contains(e.target)) { dialog.remove(); document.removeEventListener('click', handleOutsideClick); } }; setTimeout(() => document.addEventListener('click', handleOutsideClick), 100); } // 批量下载所有字幕并打包为ZIP async function batchDownloadSubtitles(format) { console.log('[BSD] ===== 批量下载开始 ====='); console.log('[BSD] 目标格式:', format); // 确保JSZip已注入主页面(showBatchFormatDialog已预加载,这里做兜底检查) const pageWin = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window; console.log('[BSD] JSZip状态:', pageWin.JSZip ? '已加载' : '未加载'); if (!pageWin.JSZip) { showInfoBar('正在加载JSZip库...', 'info', 0); const loaded = await ensureJSZipLoaded(); const loadingBar = document.querySelector('.bilibili-subtitle-infobar.info'); if (loadingBar) loadingBar.remove(); if (!loaded) { showInfoBar('JSZip库加载失败,请检查网络后重试', 'error'); return; } } // 获取字幕URL及其语言(拦截时记录的语言名) const subtitleItems = getSubtitleUrlsWithLang(); console.log('[BSD] 批量下载获取到URL数量:', subtitleItems.length); if (subtitleItems.length === 0) { console.warn('[BSD] 未找到字幕URL,批量下载终止'); showInfoBar('未找到字幕URL,请先在字幕面板中切换各语言字幕触发加载后再重试', 'error', 5000); return; } // 更新提示 showInfoBar(`正在下载 ${subtitleItems.length} 个字幕...`, 'info', 0); const bvNumber = getBVNumber(); console.log('[BSD] BV号:', bvNumber); // 使用主页面环境的JSZip(绕过沙箱,使 generateAsync 正常工作) const zip = new pageWin.JSZip(); const downloadedFiles = []; // 收集成功下载的文件,用于ZIP失败时回退逐个下载 let successCount = 0; const errors = []; const usedFilenames = new Set(); // 用于处理重名冲突 // 并行下载所有字幕 const downloadPromises = subtitleItems.map((item, index) => { // 补全协议(规范化后是 // 开头) const fullUrl = item.url.startsWith('//') ? 'https:' + item.url : item.url; console.log(`[BSD] 开始下载字幕[${index}]:`, fullUrl, '-> 语言:', item.language); return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: fullUrl, onload: function(response) { console.log(`[BSD] 字幕[${index}]响应状态:`, response.status, '内容长度:', response.responseText ? response.responseText.length : 0); try { const subtitleData = JSON.parse(response.responseText); let content, ext; switch (format) { case SUBTITLE_FORMATS.JSON: content = JSON.stringify(subtitleData, null, 2); ext = 'json'; break; case SUBTITLE_FORMATS.SRT: content = jsonToSrt(subtitleData); ext = 'srt'; break; case SUBTITLE_FORMATS.VTT: content = jsonToVtt(subtitleData); ext = 'vtt'; break; default: // 自定义扩展名,使用SRT内容 content = jsonToSrt(subtitleData); ext = format; break; } // 文件名:优先用拦截时记录的语言名,未知时从内容检测,仍未知用序号 let namePrefix = item.language; if (!namePrefix || namePrefix === 'unknown_lang' || namePrefix === '__pending__') { const detected = detectLanguageFromContent(subtitleData); console.log(`[BSD] 字幕[${index}]内容检测语言:`, detected); namePrefix = detected || `字幕${index + 1}`; } let safeName = namePrefix.replace(/[\\/:*?"<>|]/g, '_').trim(); // 处理重名:追加序号 let filename = `${safeName}_${bvNumber}.${ext}`; if (usedFilenames.has(filename)) { filename = `${safeName}_${index + 1}_${bvNumber}.${ext}`; } usedFilenames.add(filename); zip.file(filename, content); downloadedFiles.push({ filename, content }); successCount++; console.log(`[BSD] 字幕[${index}]下载成功 -> 文件名:`, filename); resolve(); } catch (e) { console.error(`[BSD] 字幕[${index}]解析失败:`, e.message); errors.push(`字幕${index + 1}: ${e.message}`); resolve(); } }, onerror: function() { console.error(`[BSD] 字幕[${index}]网络请求失败:`, fullUrl); errors.push(`字幕${index + 1}: 网络请求失败`); resolve(); } }); }); }); await Promise.all(downloadPromises); console.log('[BSD] 全部下载完成 - 成功:', successCount, '失败:', errors.length); if (errors.length > 0) { console.log('[BSD] 失败详情:', errors); } if (successCount === 0) { const loadingBar = document.querySelector('.bilibili-subtitle-infobar.info'); if (loadingBar) loadingBar.remove(); showInfoBar('所有字幕下载失败', 'error'); return; } // 更新提示 showInfoBar('正在打包ZIP文件...', 'info', 0); console.log('[BSD] 开始打包ZIP...'); // 生成ZIP(带10秒超时保护,防止 generateAsync 在沙箱环境卡住) const zipPromise = zip.generateAsync({ type: 'blob' }); const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('ZIP打包超时(10s)')), 10000); }); try { const blob = await Promise.race([zipPromise, timeoutPromise]); console.log('[BSD] ZIP打包完成, 大小:', blob.size, 'bytes'); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${bvNumber}_字幕合集.zip`; link.style.display = 'none'; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() => URL.revokeObjectURL(url), 100); const loadingBar = document.querySelector('.bilibili-subtitle-infobar.info'); if (loadingBar) loadingBar.remove(); if (errors.length > 0) { showInfoBar(`成功下载 ${successCount} 个字幕,${errors.length} 个失败`, 'warning', 5000); } else { showInfoBar(`成功下载 ${successCount} 个字幕并打包为ZIP!`, 'success'); } console.log('[BSD] ===== 批量下载结束(ZIP) ====='); } catch (e) { console.error('[BSD] 打包ZIP失败:', e.message); // 回退方案:逐个触发下载 console.log('[BSD] 回退到逐个下载方案, 文件数:', downloadedFiles.length); const loadingBar = document.querySelector('.bilibili-subtitle-infobar.info'); if (loadingBar) loadingBar.remove(); // 逐个下载,间隔300ms避免浏览器拦截 for (let i = 0; i < downloadedFiles.length; i++) { const f = downloadedFiles[i]; customDownload(f.content, f.filename, 'text/plain'); await new Promise(r => setTimeout(r, 300)); } if (errors.length > 0) { showInfoBar(`已逐个下载 ${successCount} 个字幕(ZIP打包失败),${errors.length} 个失败`, 'warning', 5000); } else { showInfoBar(`已逐个下载 ${successCount} 个字幕(ZIP打包失败已回退)`, 'success'); } console.log('[BSD] ===== 批量下载结束(回退逐个) ====='); } } // 获取字幕主体数据(兼容用户上传字幕和AI字幕) function getSubtitleBody(subtitleData) { if (subtitleData && subtitleData.body) { return subtitleData.body; } else if (subtitleData && subtitleData.data && subtitleData.data.body) { return subtitleData.data.body; } else { throw new Error('无法找到字幕主体数据'); } } // JSON转SRT格式 function jsonToSrt(subtitleData) { let srt = ''; const body = getSubtitleBody(subtitleData); body.forEach((item, index) => { srt += `${index + 1}\n`; srt += `${formatTime(item.from)} --> ${formatTime(item.to)}\n`; srt += `${item.content}\n\n`; }); return srt; } // JSON转VTT格式 function jsonToVtt(subtitleData) { let vtt = 'WEBVTT\n\n'; const body = getSubtitleBody(subtitleData); body.forEach((item, index) => { vtt += `${index + 1}\n`; vtt += `${formatTime(item.from).replace(',', '.')} --> ${formatTime(item.to).replace(',', '.')}\n`; vtt += `${item.content}\n\n`; }); return vtt; } // 格式化时间 function formatTime(seconds) { const date = new Date(seconds * 1000); const hours = date.getUTCHours().toString().padStart(2, '0'); const minutes = date.getUTCMinutes().toString().padStart(2, '0'); const secs = date.getUTCSeconds().toString().padStart(2, '0'); const milliseconds = Math.floor(date.getUTCMilliseconds()).toString().padStart(3, '0'); return `${hours}:${minutes}:${secs},${milliseconds}`; } // 显示下载确认窗口 function showDownloadConfirm(content, filename, mimeType, originalData, currentFormat) { // 移除已存在的确认窗口 const existingConfirm = document.querySelector('.bilibili-subtitle-download-confirm'); if (existingConfirm) { existingConfirm.remove(); } // 从文件名中提取语言、BV号和扩展名信息 let extension = filename.match(/\.([^.]+)$/)[1]; const filenameParts = filename.replace(/\.[^/.]+$/, '').split('_'); const language = filenameParts[0] || 'unknown_lang'; const bvNumber = filenameParts[1] || ''; // 当前格式信息 let currentContent = content; let currentMimeType = mimeType; let currentFilename = filename; let currentFileSize = new Blob([content], { type: mimeType }).size; let currentFileSizeStr = currentFileSize < 1024 ? `${currentFileSize} B` : `${(currentFileSize / 1024).toFixed(2)} KB`; // 根据复选框状态更新文件名的函数 function updateFilenameBasedOnBVCheckbox() { const includeBV = includeBVCheckbox.checked; const base = includeBV ? `${language}_${bvNumber}` : language; currentFilename = `${base}.${extension}`; filenameInput.value = currentFilename; } // 创建确认窗口 const confirmWindow = document.createElement('div'); confirmWindow.className = 'bilibili-subtitle-download-confirm'; confirmWindow.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: rgba(25, 26, 27, 0.98); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; padding: 20px; z-index: 2147483647; font-size: 13px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8); backdrop-filter: blur(10px); min-width: 300px; color: white; `; // 创建标题 const title = document.createElement('div'); title.textContent = '下载字幕'; title.style.cssText = ` font-size: 16px; font-weight: bold; margin-bottom: 16px; text-align: center; `; confirmWindow.appendChild(title); // 创建文件名输入 const filenameLabel = document.createElement('div'); filenameLabel.textContent = '文件名:'; filenameLabel.style.cssText = ` margin-bottom: 8px; font-size: 12px; color: rgba(255, 255, 255, 0.8); `; confirmWindow.appendChild(filenameLabel); const filenameInput = document.createElement('input'); filenameInput.type = 'text'; filenameInput.value = filename; filenameInput.style.cssText = ` width: 100%; padding: 8px; margin-bottom: 16px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; font-size: 13px; box-sizing: border-box; `; confirmWindow.appendChild(filenameInput); // 加载是否包含BV号的偏好设置 let savedIncludeBV = localStorage.getItem(INCLUDE_BV_KEY); let defaultIncludeBV = true; if (savedIncludeBV !== null) { try { defaultIncludeBV = JSON.parse(savedIncludeBV); } catch { defaultIncludeBV = true; localStorage.setItem(INCLUDE_BV_KEY, JSON.stringify(defaultIncludeBV)); } } else { localStorage.setItem(INCLUDE_BV_KEY, JSON.stringify(defaultIncludeBV)); } // 创建是否包含BV号的复选框 const includeBVContainer = document.createElement('div'); includeBVContainer.style.cssText = ` margin-bottom: 16px; display: flex; align-items: center; gap: 8px; `; const includeBVCheckbox = document.createElement('input'); includeBVCheckbox.type = 'checkbox'; includeBVCheckbox.id = 'include-bv-checkbox'; includeBVCheckbox.checked = defaultIncludeBV; includeBVCheckbox.style.cssText = ` accent-color: #00a1d6; cursor: pointer; `; const includeBVLabel = document.createElement('label'); includeBVLabel.textContent = '文件名包含BV号'; includeBVLabel.setAttribute('for', 'include-bv-checkbox'); includeBVLabel.style.cssText = ` cursor: pointer; font-size: 13px; color: rgba(255, 255, 255, 0.8); `; includeBVContainer.appendChild(includeBVCheckbox); includeBVContainer.appendChild(includeBVLabel); confirmWindow.appendChild(includeBVContainer); // 添加复选框的事件监听器 includeBVCheckbox.addEventListener('change', () => { // 保存用户的选择到localStorage localStorage.setItem(INCLUDE_BV_KEY, JSON.stringify(includeBVCheckbox.checked)); updateFilenameBasedOnBVCheckbox(); }); // 创建格式选择下拉框 const formatLabel = document.createElement('div'); formatLabel.textContent = '格式:'; formatLabel.style.cssText = ` margin-bottom: 8px; font-size: 12px; color: rgba(255, 255, 255, 0.8); `; confirmWindow.appendChild(formatLabel); const formatSelect = document.createElement('select'); formatSelect.style.cssText = ` width: 100%; padding: 8px; margin-bottom: 16px; background: rgba(25, 26, 27, 0.98); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; font-size: 13px; box-sizing: border-box; appearance: none; -webkit-appearance: none; -moz-appearance: none; cursor: pointer; `; // 创建文件大小信息 const sizeInfo = document.createElement('div'); sizeInfo.innerHTML = `文件大小: ${currentFileSizeStr}`; sizeInfo.style.cssText = ` margin-bottom: 16px; text-align: center; font-size: 12px; `; confirmWindow.appendChild(sizeInfo); // 为下拉框创建包装器以实现自定义样式 const selectWrapper = document.createElement('div'); selectWrapper.style.cssText = ` position: relative; width: 100%; `; // 定义格式选项 const formatOptions = [ { value: SUBTITLE_FORMATS.JSON, name: 'JSON', mimeType: 'application/json' }, { value: SUBTITLE_FORMATS.SRT, name: 'SRT', mimeType: 'text/srt' }, { value: SUBTITLE_FORMATS.VTT, name: 'VTT', mimeType: 'text/vtt' } ]; // 添加格式选项 formatOptions.forEach(option => { const opt = document.createElement('option'); opt.value = option.value; opt.textContent = option.name; opt.setAttribute('data-mime', option.mimeType); if (option.value === currentFormat) { opt.selected = true; } formatSelect.appendChild(opt); }); // 添加自定义扩展名分隔线 const separator = document.createElement('option'); separator.value = 'separator'; separator.textContent = '---'; separator.disabled = true; formatSelect.appendChild(separator); // 添加自定义扩展名选项 const customOption = document.createElement('option'); customOption.value = SUBTITLE_FORMATS.CUSTOM; customOption.textContent = '自定义...'; formatSelect.appendChild(customOption); // 添加已保存的自定义扩展名 customExtensions.forEach(ext => { const opt = document.createElement('option'); opt.value = ext.value; opt.textContent = ext.name; opt.setAttribute('data-mime', ext.mimeType); opt.setAttribute('data-custom', 'true'); formatSelect.appendChild(opt); }); // 将下拉框移动到包装器中,插入到sizeInfo之前 confirmWindow.insertBefore(selectWrapper, sizeInfo); selectWrapper.appendChild(formatSelect); // 添加下载方式选项 const downloadMethodLabel = document.createElement('div'); downloadMethodLabel.textContent = '下载方式:'; downloadMethodLabel.style.cssText = ` margin-bottom: 8px; font-size: 12px; color: rgba(255, 255, 255, 0.8); `; confirmWindow.appendChild(downloadMethodLabel); const downloadMethods = document.createElement('div'); downloadMethods.style.cssText = ` margin-bottom: 20px; display: flex; flex-direction: column; gap: 6px; `; confirmWindow.appendChild(downloadMethods); // 定义下载方式选项 const downloadOptions = [ { value: 'direct', name: '直接下载' }, { value: 'newtab', name: '新标签页打开' }, { value: 'clipboard', name: '复制到剪贴板' } ]; // 创建单选按钮组 const downloadMethodGroup = document.createElement('div'); downloadMethodGroup.style.cssText = ` display: flex; flex-direction: column; gap: 8px; `; downloadMethods.appendChild(downloadMethodGroup); // 默认选择直接下载 let selectedDownloadMethod = 'direct'; // 创建下载按钮引用 let downloadBtn; // 更新按钮文本函数 function updateDownloadButtonText() { if (!downloadBtn) return; switch (selectedDownloadMethod) { case 'direct': downloadBtn.textContent = '下载'; break; case 'newtab': downloadBtn.textContent = '打开'; break; case 'clipboard': downloadBtn.textContent = '复制'; break; } } downloadOptions.forEach(option => { const optionContainer = document.createElement('div'); optionContainer.style.cssText = ` display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 4px; border-radius: 3px; transition: background-color 0.2s ease; `; // 添加悬停效果 optionContainer.addEventListener('mouseenter', () => { optionContainer.style.backgroundColor = 'rgba(0, 161, 214, 0.1)'; }); optionContainer.addEventListener('mouseleave', () => { optionContainer.style.backgroundColor = 'transparent'; }); const radio = document.createElement('input'); radio.type = 'radio'; radio.name = 'subtitle-download-method'; radio.value = option.value; radio.checked = option.value === selectedDownloadMethod; radio.style.cssText = ` accent-color: #00a1d6; `; const label = document.createElement('label'); label.textContent = option.name; label.style.cssText = ` cursor: pointer; font-size: 13px; `; // 添加点击事件 optionContainer.addEventListener('click', () => { radio.checked = true; selectedDownloadMethod = option.value; // 更新按钮文本 updateDownloadButtonText(); }); optionContainer.appendChild(radio); optionContainer.appendChild(label); downloadMethodGroup.appendChild(optionContainer); }); // 当格式改变时更新内容和文件名 formatSelect.addEventListener('change', () => { const newFormat = formatSelect.value; // 如果选择了自定义...选项 if (newFormat === SUBTITLE_FORMATS.CUSTOM) { // 保存当前选择的格式 const currentSelectedIndex = formatSelect.selectedIndex; // 打开自定义扩展名设置界面 createCustomExtensionDialog((newExtension) => { // 重新创建下载确认窗口,显示新添加的自定义扩展名 confirmWindow.remove(); showDownloadConfirm(content, filename, mimeType, originalData, newExtension.value); }); // 恢复原来的选择 setTimeout(() => { formatSelect.selectedIndex = currentSelectedIndex; }, 0); return; } // 检查是否是自定义扩展名 const selectedOption = formatSelect.options[formatSelect.selectedIndex]; const isCustom = selectedOption.hasAttribute('data-custom'); let selectedFormatInfo; if (isCustom) { // 获取自定义扩展名信息 selectedFormatInfo = { value: newFormat, mimeType: selectedOption.getAttribute('data-mime') }; } else { // 查找内置格式信息 selectedFormatInfo = formatOptions.find(opt => opt.value === newFormat); } if (selectedFormatInfo) { // 根据新格式重新转换字幕 let newContent; switch (newFormat) { case SUBTITLE_FORMATS.JSON: newContent = JSON.stringify(originalData, null, 2); break; case SUBTITLE_FORMATS.SRT: newContent = jsonToSrt(originalData); break; case SUBTITLE_FORMATS.VTT: newContent = jsonToVtt(originalData); break; default: // 对于自定义扩展名,默认使用SRT格式的内容 newContent = jsonToSrt(originalData); break; } // 更新当前内容、MIME类型和文件名 currentContent = newContent; currentMimeType = selectedFormatInfo.mimeType; // 根据复选框状态更新扩展名和文件名 extension = newFormat; updateFilenameBasedOnBVCheckbox(); // 更新文件大小 currentFileSize = new Blob([newContent], { type: currentMimeType }).size; currentFileSizeStr = currentFileSize < 1024 ? `${currentFileSize} B` : `${(currentFileSize / 1024).toFixed(2)} KB`; sizeInfo.innerHTML = `文件大小: ${currentFileSizeStr}`; } }); // 创建按钮容器 const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = ` display: flex; justify-content: space-between; gap: 10px; `; confirmWindow.appendChild(buttonContainer); // 创建取消按钮 const cancelBtn = document.createElement('button'); cancelBtn.textContent = '取消'; cancelBtn.style.cssText = ` flex: 1; padding: 8px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; cursor: pointer; transition: all 0.2s ease; `; cancelBtn.addEventListener('click', () => { confirmWindow.remove(); }); buttonContainer.appendChild(cancelBtn); // 创建下载按钮 downloadBtn = document.createElement('button'); downloadBtn.textContent = '下载'; downloadBtn.style.cssText = ` flex: 1; padding: 8px; background: rgba(0, 161, 214, 0.8); border: 1px solid rgba(0, 161, 214, 0.8); border-radius: 4px; color: white; cursor: pointer; transition: all 0.2s ease; `; downloadBtn.addEventListener('click', () => { const newFilename = filenameInput.value.trim() || currentFilename; confirmWindow.remove(); // 根据选择的下载方式执行不同操作 switch (selectedDownloadMethod) { case 'direct': // 使用自定义下载逻辑 customDownload(currentContent, newFilename, currentMimeType); break; case 'newtab': { // 在新标签页打开 const blob = new Blob([currentContent], { type: currentMimeType }); const url = URL.createObjectURL(blob); window.open(url, '_blank'); // 释放URL对象 setTimeout(() => { URL.revokeObjectURL(url); }, 100); break; } case 'clipboard': // 复制到剪贴板 navigator.clipboard.writeText(currentContent) .then(() => { showInfoBar('字幕内容已复制到剪贴板!', 'success'); }) .catch(err => { console.error('复制失败:', err); showInfoBar('复制失败,请手动复制!', 'error'); }); break; } }); buttonContainer.appendChild(downloadBtn); // 添加悬停效果 cancelBtn.addEventListener('mouseenter', () => { cancelBtn.style.background = 'rgba(255, 255, 255, 0.2)'; }); cancelBtn.addEventListener('mouseleave', () => { cancelBtn.style.background = 'rgba(255, 255, 255, 0.1)'; }); downloadBtn.addEventListener('mouseenter', () => { downloadBtn.style.background = 'rgba(0, 161, 214, 1)'; }); downloadBtn.addEventListener('mouseleave', () => { downloadBtn.style.background = 'rgba(0, 161, 214, 0.8)'; }); // 添加到页面 document.body.appendChild(confirmWindow); // 自动聚焦到文件名输入框 filenameInput.focus(); filenameInput.select(); // 添加右键删除自定义扩展名功能 formatSelect.addEventListener('contextmenu', (e) => { e.preventDefault(); // 获取当前选中的选项 const selectedIndex = formatSelect.selectedIndex; const selectedOption = formatSelect.options[selectedIndex]; // 检查是否是自定义扩展名 if (selectedOption && selectedOption.hasAttribute('data-custom')) { const extValue = selectedOption.value; const extName = selectedOption.textContent; // 显示自定义删除确认窗口 showDeleteConfirm(extName, () => { // 从数组中移除 customExtensions = customExtensions.filter(ext => ext.value !== extValue); // 保存到localStorage saveCustomExtensions(); // 重新创建下载确认窗口 confirmWindow.remove(); showDownloadConfirm(content, filename, mimeType, originalData, currentFormat); }); } }); // 按ESC键关闭 const handleEsc = (e) => { if (e.key === 'Escape') { confirmWindow.remove(); document.removeEventListener('keydown', handleEsc); } }; document.addEventListener('keydown', handleEsc); // 点击外部关闭 const handleOutsideClick = (e) => { if (!confirmWindow.contains(e.target)) { confirmWindow.remove(); document.removeEventListener('click', handleOutsideClick); } }; setTimeout(() => { document.addEventListener('click', handleOutsideClick); }, 100); } // 自定义下载方法 function customDownload(content, filename, mimeType) { const blob = new Blob([content], { type: mimeType }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.style.display = 'none'; document.body.appendChild(link); link.click(); document.body.removeChild(link); // 显示下载完成提示 showInfoBar(`字幕文件 "${filename}" 下载完成!`, 'success'); // 释放URL对象 setTimeout(() => { URL.revokeObjectURL(link.href); }, 100); } // 下载文件 - 现在直接调用确认窗口 function downloadFile(content, filename, mimeType, originalData, currentFormat) { showDownloadConfirm(content, filename, mimeType, originalData, currentFormat); } // 加载自定义扩展名设置 function loadCustomExtensions() { try { const saved = localStorage.getItem(CUSTOM_EXTENSIONS_KEY); if (saved) { customExtensions = JSON.parse(saved); } } catch (error) { console.error('加载自定义扩展名失败:', error); customExtensions = []; } } // 保存自定义扩展名设置 function saveCustomExtensions() { try { localStorage.setItem(CUSTOM_EXTENSIONS_KEY, JSON.stringify(customExtensions)); } catch (error) { console.error('保存自定义扩展名失败:', error); } } // 创建自定义扩展名设置界面 function createCustomExtensionDialog(onSuccess) { // 移除已存在的对话框 const existingDialog = document.querySelector('.bilibili-subtitle-custom-extension-dialog'); if (existingDialog) { existingDialog.remove(); } // 创建对话框容器 const dialog = document.createElement('div'); dialog.className = 'bilibili-subtitle-custom-extension-dialog'; dialog.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: rgba(25, 26, 27, 0.98); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; padding: 20px; z-index: 2147483648; font-size: 13px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8); backdrop-filter: blur(10px); min-width: 350px; color: white; `; // 创建标题 const title = document.createElement('div'); title.textContent = '自定义扩展名设置'; title.style.cssText = ` font-size: 16px; font-weight: bold; margin-bottom: 16px; text-align: center; `; dialog.appendChild(title); // 创建内容容器 const content = document.createElement('div'); content.style.cssText = ` display: flex; flex-direction: column; gap: 16px; `; dialog.appendChild(content); // 预设扩展名选择 const presetSection = document.createElement('div'); presetSection.style.cssText = ` display: flex; flex-direction: column; gap: 8px; `; content.appendChild(presetSection); const presetLabel = document.createElement('div'); presetLabel.textContent = '预设扩展名:'; presetLabel.style.cssText = ` font-size: 12px; color: rgba(255, 255, 255, 0.8); font-weight: bold; `; presetSection.appendChild(presetLabel); const presetGrid = document.createElement('div'); presetGrid.style.cssText = ` display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 8px; `; presetSection.appendChild(presetGrid); // 添加预设选项 PRESET_EXTENSIONS.forEach(preset => { const presetOption = document.createElement('button'); presetOption.textContent = preset.name; presetOption.style.cssText = ` padding: 6px 10px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; cursor: pointer; transition: all 0.2s ease; font-size: 12px; `; presetOption.addEventListener('click', () => { // 移除所有预设的选中状态 presetGrid.querySelectorAll('button').forEach(btn => { btn.style.background = 'rgba(255, 255, 255, 0.1)'; btn.style.borderColor = 'rgba(255, 255, 255, 0.3)'; }); // 添加当前预设的选中状态 presetOption.style.background = 'rgba(0, 161, 214, 0.8)'; presetOption.style.borderColor = 'rgba(0, 161, 214, 0.8)'; // 自动填充到自定义输入框 if (customNameInput) customNameInput.value = preset.name; if (customValueInput) customValueInput.value = preset.value; if (customMimeTypeInput) customMimeTypeInput.value = preset.mimeType; }); presetGrid.appendChild(presetOption); }); // 自定义扩展名输入 const customSection = document.createElement('div'); customSection.style.cssText = ` display: flex; flex-direction: column; gap: 12px; `; content.appendChild(customSection); const customLabel = document.createElement('div'); customLabel.textContent = '自定义设置:'; customLabel.style.cssText = ` font-size: 12px; color: rgba(255, 255, 255, 0.8); font-weight: bold; `; customSection.appendChild(customLabel); // 扩展名名称 const nameContainer = document.createElement('div'); nameContainer.style.cssText = ` display: flex; flex-direction: column; gap: 4px; `; customSection.appendChild(nameContainer); const nameLabel = document.createElement('label'); nameLabel.textContent = '显示名称:'; nameLabel.style.cssText = ` font-size: 12px; color: rgba(255, 255, 255, 0.8); `; nameContainer.appendChild(nameLabel); const customNameInput = document.createElement('input'); customNameInput.type = 'text'; customNameInput.placeholder = '例如: TXT'; customNameInput.style.cssText = ` width: 100%; padding: 8px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; font-size: 13px; box-sizing: border-box; `; nameContainer.appendChild(customNameInput); // 扩展名值 const valueContainer = document.createElement('div'); valueContainer.style.cssText = ` display: flex; flex-direction: column; gap: 4px; `; customSection.appendChild(valueContainer); const valueLabel = document.createElement('label'); valueLabel.textContent = '扩展名:'; valueLabel.style.cssText = ` font-size: 12px; color: rgba(255, 255, 255, 0.8); `; valueContainer.appendChild(valueLabel); const valueWrapper = document.createElement('div'); valueWrapper.style.cssText = ` display: flex; align-items: center; `; valueContainer.appendChild(valueWrapper); const dotLabel = document.createElement('span'); dotLabel.textContent = '.'; dotLabel.style.cssText = ` color: white; font-size: 16px; margin-right: 4px; `; valueWrapper.appendChild(dotLabel); const customValueInput = document.createElement('input'); customValueInput.type = 'text'; customValueInput.placeholder = '例如: txt'; customValueInput.style.cssText = ` flex: 1; padding: 8px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; font-size: 13px; box-sizing: border-box; `; valueWrapper.appendChild(customValueInput); // MIME类型(高级设置) const advancedSection = document.createElement('div'); advancedSection.style.cssText = ` display: flex; flex-direction: column; gap: 4px; `; customSection.appendChild(advancedSection); const advancedLabel = document.createElement('label'); advancedLabel.textContent = 'MIME类型 (可选):'; advancedLabel.style.cssText = ` font-size: 12px; color: rgba(255, 255, 255, 0.8); `; advancedSection.appendChild(advancedLabel); const customMimeTypeInput = document.createElement('input'); customMimeTypeInput.type = 'text'; customMimeTypeInput.placeholder = '例如: text/plain'; customMimeTypeInput.style.cssText = ` width: 100%; padding: 8px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; font-size: 13px; box-sizing: border-box; `; advancedSection.appendChild(customMimeTypeInput); // 按钮容器 const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = ` display: flex; justify-content: space-between; gap: 10px; `; content.appendChild(buttonContainer); // 创建取消按钮 const cancelBtn = document.createElement('button'); cancelBtn.textContent = '取消'; cancelBtn.style.cssText = ` flex: 1; padding: 8px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; cursor: pointer; transition: all 0.2s ease; `; cancelBtn.addEventListener('click', () => { dialog.remove(); }); buttonContainer.appendChild(cancelBtn); // 创建保存按钮 const saveBtn = document.createElement('button'); saveBtn.textContent = '保存'; saveBtn.style.cssText = ` flex: 1; padding: 8px; background: rgba(0, 161, 214, 0.8); border: 1px solid rgba(0, 161, 214, 0.8); border-radius: 4px; color: white; cursor: pointer; transition: all 0.2s ease; `; saveBtn.addEventListener('click', () => { // 获取输入值 const name = customNameInput.value.trim(); const value = customValueInput.value.trim(); const mimeType = customMimeTypeInput.value.trim() || 'text/plain'; // 验证输入 if (!name || !value) { showInfoBar('请填写完整的扩展名信息', 'error'); return; } // 验证扩展名格式 if (!/^[a-zA-Z0-9]+$/.test(value)) { showInfoBar('扩展名只能包含字母和数字', 'error'); return; } // 检查是否已存在 const exists = customExtensions.some(ext => ext.value === value || ext.name.toLowerCase() === name.toLowerCase()); if (exists) { showInfoBar('该扩展名已存在', 'error'); return; } // 添加自定义扩展名 const newExtension = { name: name, value: value, mimeType: mimeType }; customExtensions.push(newExtension); saveCustomExtensions(); // 调用成功回调 if (onSuccess) { onSuccess(newExtension); } // 关闭对话框 dialog.remove(); }); buttonContainer.appendChild(saveBtn); // 添加悬停效果 cancelBtn.addEventListener('mouseenter', () => { cancelBtn.style.background = 'rgba(255, 255, 255, 0.2)'; }); cancelBtn.addEventListener('mouseleave', () => { cancelBtn.style.background = 'rgba(255, 255, 255, 0.1)'; }); saveBtn.addEventListener('mouseenter', () => { saveBtn.style.background = 'rgba(0, 161, 214, 1)'; }); saveBtn.addEventListener('mouseleave', () => { saveBtn.style.background = 'rgba(0, 161, 214, 0.8)'; }); // 添加到页面 document.body.appendChild(dialog); // 自动聚焦到名称输入框 customNameInput.focus(); // 按ESC键关闭 const handleEsc = (e) => { if (e.key === 'Escape') { dialog.remove(); document.removeEventListener('keydown', handleEsc); } }; document.addEventListener('keydown', handleEsc); } // 显示删除确认窗口 function showDeleteConfirm(extName, onConfirm) { // 移除已存在的确认窗口 const existingDialog = document.querySelector('.bilibili-subtitle-delete-confirm'); if (existingDialog) { existingDialog.remove(); } // 创建确认窗口 const dialog = document.createElement('div'); dialog.className = 'bilibili-subtitle-delete-confirm'; dialog.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: rgba(25, 26, 27, 0.98); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 8px; padding: 20px; z-index: 2147483647; font-size: 13px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.8); backdrop-filter: blur(10px); min-width: 300px; color: white; transition: all 0.3s ease; `; // 创建标题 const title = document.createElement('div'); title.textContent = '确认删除'; title.style.cssText = ` font-size: 16px; font-weight: bold; margin-bottom: 16px; text-align: center; `; dialog.appendChild(title); // 创建提示信息 const message = document.createElement('div'); message.textContent = `确定要删除自定义扩展名 "${extName}" 吗?`; message.style.cssText = ` margin-bottom: 20px; text-align: center; line-height: 1.5; `; dialog.appendChild(message); // 创建按钮容器 const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = ` display: flex; justify-content: space-between; gap: 10px; `; dialog.appendChild(buttonContainer); // 创建取消按钮 const cancelBtn = document.createElement('button'); cancelBtn.textContent = '取消'; cancelBtn.style.cssText = ` flex: 1; padding: 8px; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: white; cursor: pointer; transition: all 0.2s ease; `; cancelBtn.addEventListener('click', () => { dialog.remove(); }); buttonContainer.appendChild(cancelBtn); // 创建删除按钮 const deleteBtn = document.createElement('button'); deleteBtn.textContent = '删除'; deleteBtn.style.cssText = ` flex: 1; padding: 8px; background: rgba(245, 34, 45, 0.8); border: 1px solid rgba(245, 34, 45, 0.8); border-radius: 4px; color: white; cursor: pointer; transition: all 0.2s ease; `; deleteBtn.addEventListener('click', () => { dialog.remove(); if (onConfirm) { onConfirm(); } }); buttonContainer.appendChild(deleteBtn); // 添加悬停效果 cancelBtn.addEventListener('mouseenter', () => { cancelBtn.style.background = 'rgba(255, 255, 255, 0.2)'; }); cancelBtn.addEventListener('mouseleave', () => { cancelBtn.style.background = 'rgba(255, 255, 255, 0.1)'; }); deleteBtn.addEventListener('mouseenter', () => { deleteBtn.style.background = 'rgba(245, 34, 45, 1)'; }); deleteBtn.addEventListener('mouseleave', () => { deleteBtn.style.background = 'rgba(245, 34, 45, 0.8)'; }); // 添加到页面 document.body.appendChild(dialog); // 按ESC键关闭 const handleEsc = (e) => { if (e.key === 'Escape') { dialog.remove(); document.removeEventListener('keydown', handleEsc); } }; document.addEventListener('keydown', handleEsc); // 点击外部关闭 const handleOutsideClick = (e) => { if (!dialog.contains(e.target)) { dialog.remove(); document.removeEventListener('click', handleOutsideClick); } }; setTimeout(() => { document.addEventListener('click', handleOutsideClick); }, 100); } // 页面加载完成后初始化 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();