// ==UserScript== // @name Bilibili CC字幕实时显示插件(含AI翻译)- 修复版 // @name:en Bilibili CC Subtitle Extractor with AI Translation - Fixed // @namespace http://tampermonkey.net/ // @version 1.2 // @description 在B站播放器中集成CC字幕列表,支持DeepSeek AI实时翻译,提供"双语双行"字幕渲染。已修复AI字幕URL不包含CID导致的识别失败问题。 // @description:en Integrate CC subtitle list in Bilibili video player with DeepSeek AI translation. Fixed "initial subtitle mismatch" caused by auto-resume. Fixed hash-URL subtitle detection. // @author Corde // @match *://*.bilibili.com/video/* // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_xmlhttpRequest // @grant unsafeWindow // @license MIT // @run-at document-end // @downloadURL https://update.greasyfork.icu/scripts/561121/Bilibili%20CC%E5%AD%97%E5%B9%95%E5%AE%9E%E6%97%B6%E6%98%BE%E7%A4%BA%E6%8F%92%E4%BB%B6%EF%BC%88%E5%90%ABAI%E7%BF%BB%E8%AF%91%EF%BC%89-%20%E4%BF%AE%E5%A4%8D%E7%89%88.user.js // @updateURL https://update.greasyfork.icu/scripts/561121/Bilibili%20CC%E5%AD%97%E5%B9%95%E5%AE%9E%E6%97%B6%E6%98%BE%E7%A4%BA%E6%8F%92%E4%BB%B6%EF%BC%88%E5%90%ABAI%E7%BF%BB%E8%AF%91%EF%BC%89-%20%E4%BF%AE%E5%A4%8D%E7%89%88.meta.js // ==/UserScript== (function() { 'use strict'; // 调试日志工具 const Logger = { log: (...args) => console.log('%c[CC字幕插件]', 'color: #00a1d6; font-weight: bold;', ...args), error: (...args) => console.error('%c[CC字幕插件]', 'color: red; font-weight: bold;', ...args), warn: (...args) => console.warn('%c[CC字幕插件]', 'color: orange; font-weight: bold;', ...args) }; // 获取真实的 window 对象 const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; // ==================== 配置管理模块 ==================== const ConfigManager = { defaults: { apiKey: '', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', targetLanguage: 'Indonesian', enabled: false, dualMode: true, preload: true, floatingWindow: { visible: true, position: { x: 100, y: 100 }, size: { width: 450, height: 120 } }, promptTemplate: `将以下中文文本翻译成印尼语: {text} ` }, get(key) { const value = GM_getValue(key); return value !== undefined ? value : this.defaults[key]; }, set(key, value) { GM_setValue(key, value); }, getAll() { return { apiKey: this.get('apiKey'), baseURL: this.get('baseURL'), model: this.get('model'), targetLanguage: this.get('targetLanguage'), enabled: this.get('enabled'), dualMode: this.get('dualMode'), preload: this.get('preload'), floatingWindow: this.get('floatingWindow'), promptTemplate: this.get('promptTemplate') }; }, setAll(config) { Object.keys(config).forEach(key => { this.set(key, config[key]); }); } }; // ==================== 翻译服务模块 ==================== const TranslationService = { cache: new Map(), pendingRequests: new Map(), requestQueue: [], isProcessingQueue: false, currentContextId: '', requestingIndices: new Set(), setContextId(id) { if (this.currentContextId !== id) { this.currentContextId = id; this.cache.clear(); this.pendingRequests.clear(); this.requestQueue = []; this.requestingIndices.clear(); this.isProcessingQueue = false; Logger.log('>>> 上下文切换,翻译缓存已重置'); } }, generateCacheKey(text, language) { return `${this.currentContextId}_${language}:${text.substring(0, 50)}_${text.length}`; }, async translate(text, config) { if (!text || !config.apiKey) return text; const cacheKey = this.generateCacheKey(text, config.targetLanguage); if (this.cache.has(cacheKey)) return this.cache.get(cacheKey); if (this.pendingRequests.has(cacheKey)) return this.pendingRequests.get(cacheKey); const prompt = config.promptTemplate.replace('{text}', text); const requestBody = { model: config.model, messages: [ { role: 'system', content: 'You are a professional translator. Keep translations concise.' }, { role: 'user', content: prompt } ], stream: false, temperature: 0.3, max_tokens: 1000 }; const translationPromise = (async () => { try { const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "POST", url: `${config.baseURL}/v1/chat/completions`, headers: { "Content-Type": "application/json", "Authorization": `Bearer ${config.apiKey}` }, data: JSON.stringify(requestBody), onload: (res) => { if (res.status >= 200 && res.status < 300) resolve(JSON.parse(res.responseText)); else reject(new Error(`API Error: ${res.status} ${res.statusText}`)); }, onerror: (err) => reject(err) }); }); const translatedText = response.choices?.[0]?.message?.content?.trim(); if (!translatedText) throw new Error('Empty translation'); this.cache.set(cacheKey, translatedText); return translatedText; } catch (error) { Logger.warn(`翻译失败:`, error.message); return null; } finally { this.pendingRequests.delete(cacheKey); } })(); this.pendingRequests.set(cacheKey, translationPromise); return translationPromise; }, prefetch(subtitles, currentTime, config) { if (!config.enabled || !config.apiKey || !config.preload) return; const PRELOAD_WINDOW = 180; const endTime = currentTime + PRELOAD_WINDOW; const candidates = subtitles.body.filter((item, index) => { const inRange = item.from > currentTime && item.from <= endTime; if (!inRange) return false; const cacheKey = this.generateCacheKey(item.content, config.targetLanguage); return !this.cache.has(cacheKey) && !this.requestingIndices.has(index); }); if (candidates.length === 0) return; candidates.forEach(item => { if (this.requestQueue.length < 30) { const index = subtitles.body.indexOf(item); if (!this.requestingIndices.has(index)) { this.requestQueue.push({ item, index, config }); this.requestingIndices.add(index); } } }); if (this.requestQueue.length > 0) this.processQueue(); }, async processQueue() { if (this.isProcessingQueue) return; this.isProcessingQueue = true; while (this.requestQueue.length > 0) { const task = this.requestQueue.shift(); const cacheKeyCheck = this.generateCacheKey(task.item.content, task.config.targetLanguage); if (!cacheKeyCheck.startsWith(this.currentContextId)) { this.isProcessingQueue = false; return; } try { await this.translate(task.item.content, task.config); } catch (e) { /* ignore */ } finally { this.requestingIndices.delete(task.index); await new Promise(r => setTimeout(r, 200)); } } this.isProcessingQueue = false; }, async translateBatch(subtitles, config, onProgress) { if (!config.enabled || !config.apiKey) return null; const results = []; const batchSize = 5; const delay = 100; for (let i = 0; i < subtitles.body.length; i += batchSize) { const batch = subtitles.body.slice(i, i + batchSize); const batchPromises = batch.map(async (item) => { const translated = await this.translate(item.content, config); return { index: subtitles.body.indexOf(item), translated: translated || item.content }; }); const batchResults = await Promise.all(batchPromises); results.push(...batchResults); if (onProgress) onProgress(results.length, subtitles.body.length); if (i + batchSize < subtitles.body.length) await new Promise(r => setTimeout(r, delay)); } return results.sort((a, b) => a.index - b.index); } }; // ==================== 视频信息获取模块 (增强版) ==================== const VideoInfoFetcher = { getUrlParams() { const url = window.location.href; const bvidMatch = url.match(/\/video\/(BV[a-zA-Z0-9]+)/); const bvid = bvidMatch ? bvidMatch[1] : null; const params = new URLSearchParams(window.location.search); const pParam = params.get('p'); const p = parseInt(pParam || '1'); return { bvid, p, isExplicitP: !!pParam }; }, // 增强的fetchWithRetry,支持自定义headers和更好的错误处理 async fetchWithRetry(url, retries = 3, resultParser = null, customHeaders = null) { for (let i = 0; i < retries; i++) { try { return await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: url, headers: customHeaders || { "Referer": window.location.href }, onload: (res) => { if (res.status === 200) { try { const json = JSON.parse(res.responseText); if (resultParser) { resolve(resultParser(json)); } else { // 返回整个response对象,让调用方检查code resolve(json); } } catch (e) { reject(new Error(`JSON解析失败: ${e.message}`)); } } else { reject(new Error(`HTTP ${res.status}`)); } }, onerror: (e) => reject(new Error(`网络错误: ${e.message}`)), ontimeout: () => reject(new Error('请求超时')) }); }); } catch (e) { if (i === retries - 1) { Logger.error(`fetchWithRetry 最终失败: ${url}, 错误: ${e.message}`); throw e; } Logger.warn(`fetchWithRetry 重试 ${i + 1}/${retries}: ${url}, 错误: ${e.message}`); await new Promise(r => setTimeout(r, 1000 * (i + 1))); // 指数退避 } } }, async sniffPlayerCid(targetBvid, maxWaitMs = 5000) { const start = Date.now(); while (Date.now() - start < maxWaitMs) { const player = win.player || win.bpxPlayer; if (player && typeof player.getVideoInfo === 'function') { const info = player.getVideoInfo(); if (info && info.bvid === targetBvid && info.cid) { return info.cid; } } await new Promise(r => setTimeout(r, 200)); } return null; }, async getVideoDetails(bvid, p, isExplicitP) { Logger.log(`>>> 解析视频信息: BVID=${bvid}, P=${p} (显式指定: ${isExplicitP})`); try { const apiUrl = `https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`; const response = await this.fetchWithRetry(apiUrl); // 修复:检查响应完整性 if (!response || response.code !== 0 || !response.data) { throw new Error(`API响应异常: code=${response?.code}, message=${response?.message || '无详情'}`); } const videoData = response.data; let targetP = p; let targetCid = null; if (!isExplicitP) { Logger.log('URL未指定分P,进入首屏智能嗅探模式...'); const playerCid = await this.sniffPlayerCid(bvid); if (playerCid) { // 修复:访问 videoData.pages if (!videoData.pages || !Array.isArray(videoData.pages)) { throw new Error('视频数据格式异常: pages字段缺失或不是数组'); } const realPage = videoData.pages.find(pg => pg.cid === playerCid); if (realPage) { Logger.log(`嗅探成功! 播放器实际在播 P${realPage.page} (CID=${playerCid})`); targetP = realPage.page; targetCid = playerCid; } else { Logger.warn('播放器CID未在API列表中找到,回退到默认 P1'); } } else { Logger.warn('嗅探超时,假设为 P1'); } } // 修复:访问 videoData.pages if (!targetCid) { if (!videoData.pages || !Array.isArray(videoData.pages)) { throw new Error('视频数据格式异常: pages字段缺失'); } const pageData = videoData.pages.find(page => page.page === targetP); if (!pageData) { throw new Error(`未找到分P ${targetP},可用分P: ${videoData.pages.map(p => p.page).join(', ')}`); } targetCid = pageData.cid; } Logger.log(`最终锁定目标: P${targetP}, CID=${targetCid}`); return { cid: targetCid, aid: videoData.aid, bvid: bvid, title: videoData.title, p: targetP, pages: videoData.pages }; } catch (e) { Logger.error('视频信息解析失败:', e); throw e; } }, async getSubtitleConfig(cid, bvid, aid) { // 获取当前页面的Cookie用于认证 const getCookies = () => { return document.cookie.split('; ').map(c => { const [name, ...valueParts] = c.split('='); return { name, value: valueParts.join('=') }; }); }; const cookies = getCookies(); const sessData = cookies.find(c => c.name === 'SESSDATA')?.value || ''; // 构建完整的请求头 const headers = { "Referer": window.location.href, "User-Agent": navigator.userAgent, "Origin": "https://www.bilibili.com", "Cookie": sessData ? `SESSDATA=${sessData}` : '' }; // 优先调用更稳定的公开API,修复URL格式 const urls = [ `https://api.bilibili.com/x/v2/dm/view?aid=${aid}&oid=${cid}&type=1`, `https://api.bilibili.com/x/player/v2?cid=${cid}&bvid=${bvid}` ]; for (const url of urls) { try { Logger.log(`尝试获取字幕: ${url}`); const response = await this.fetchWithRetry(url, 3, null, headers); if (response.code === 0) { let subtitles = null; // 统一处理两种API的返回格式 if (response.data?.subtitle?.subtitles?.length > 0) { subtitles = response.data.subtitle.subtitles; } else if (response.data?.subtitles?.length > 0) { subtitles = response.data.subtitles; } // 验证字幕有效性 if (subtitles && subtitles.length > 0) { const firstSub = subtitles[0]; // FIX: 移除严格的CID包含检查。B站AI字幕(aisubtitle.hdslb.com)使用Hash文件名,不包含明文CID。 // 只要API调用是针对正确CID发起的,返回的数据通常就是正确的。 if (firstSub.subtitle_url) { Logger.log(`✅ 获取到字幕: ${firstSub.lan_doc || '未知语言'}`); Logger.log(`字幕URL: ${firstSub.subtitle_url}`); // 简单的二次校验,记录一下但不拦截 if (!firstSub.subtitle_url.includes(`${cid}`) && !firstSub.subtitle_url.includes('aisubtitle')) { Logger.log('提示: 字幕URL未使用CID命名,可能是Hash命名或AI字幕'); } return subtitles; } else { Logger.warn(`⚠️ API返回了空字幕URL`); } } else { Logger.warn(`API返回无字幕数据: ${url}`); } } else { Logger.warn(`API返回错误 code: ${response.code}, message: ${response.message || ''}`); } } catch (e) { Logger.warn(`字幕API请求失败: ${url}, 错误: ${e.message}`); } } Logger.error(`❌ 所有字幕API均无法获取有效字幕 for CID: ${cid}`); return null; }, async getSubtitleContent(url) { if (url.startsWith('//')) url = 'https:' + url; if (url.startsWith('http://')) url = url.replace('http://', 'https://'); // 增加字幕内容验证 return await this.fetchWithRetry(url, 3, (json) => { if (json.body && Array.isArray(json.body)) { // 验证字幕时间线是否有效 const validItems = json.body.filter(item => item.hasOwnProperty('from') && item.hasOwnProperty('to') && item.hasOwnProperty('content') && typeof item.from === 'number' && typeof item.to === 'number' && typeof item.content === 'string' ); if (validItems.length > 0) { Logger.log(`✅ 字幕内容加载成功,共${validItems.length}条`); return json; } throw new Error(`Invalid Subtitle: 无效的subtitle数据格式,仅${validItems.length}条有效`); } throw new Error('Invalid Subtitle JSON: missing body'); }); } }; // ==================== 视频画面渲染器 ==================== const VideoSubtitleRenderer = { container: null, subtitleElement: null, init() { this.createOverlay(); this.injectStyles(); }, createOverlay() { const videoArea = document.querySelector('.bpx-player-video-area') || document.querySelector('.player-video') || document.querySelector('video')?.parentElement; if (!videoArea || videoArea.querySelector('.video-subtitle-renderer')) return; const div = document.createElement('div'); div.className = 'video-subtitle-renderer'; div.style.cssText = ` position: absolute; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 100; display: flex; flex-direction: column; justify-content: flex-end; align-items: center; padding-bottom: 50px; `; const textDiv = document.createElement('div'); textDiv.className = 'video-subtitle-content'; textDiv.style.cssText = ` text-align: center; background: rgba(0,0,0,0.6); padding: 6px 12px; border-radius: 6px; transition: opacity 0.2s; opacity: 0; `; div.appendChild(textDiv); videoArea.appendChild(div); this.container = div; this.subtitleElement = textDiv; }, injectStyles() { if (document.getElementById('cc-video-style')) return; const style = document.createElement('style'); style.id = 'cc-video-style'; style.textContent = ` .cc-primary-text { font-size: 24px; color: #fff; font-weight: bold; text-shadow: 1px 1px 2px #000; line-height: 1.4; margin-bottom: 2px; } .cc-secondary-text { font-size: 16px; color: #ddd; text-shadow: 1px 1px 2px #000; font-weight: normal; } .bpx-player-video-wrap:fullscreen .cc-primary-text { font-size: 32px; } .bpx-player-video-wrap:fullscreen .cc-secondary-text { font-size: 20px; } `; document.head.appendChild(style); }, update(htmlContent) { if (!this.subtitleElement) this.createOverlay(); if (!this.subtitleElement) return; if (htmlContent) { this.subtitleElement.innerHTML = htmlContent; this.subtitleElement.style.opacity = 1; } else { this.subtitleElement.style.opacity = 0; } }, clear() { if (this.subtitleElement) { this.subtitleElement.innerHTML = ''; this.subtitleElement.style.opacity = 0; } } }; // ==================== SettingsUI ==================== const SettingsUI = { element: null, show() { if (!this.element) this.create(); this.updateFields(); this.element.style.display = 'flex'; }, hide() { if (this.element) this.element.style.display = 'none'; }, create() { const div = document.createElement('div'); div.className = 'cc-settings-overlay'; div.style.cssText = `position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 100001; display: none; align-items: center; justify-content: center;`; div.innerHTML = `