// ==UserScript== // @name B站视频字幕 AI 总结助手 // @namespace https://scriptcat.org/ // @version 1.0.0 // @description B站视频字幕 AI 总结,支持字幕刷新、模块高亮、当前播放器字幕识别、浮球拖拽、面板调宽、多 API 配置、自定义 Prompt、关闭思考、超时设置、字幕分段、时间跳转、总结缓存。 // @author ScriptCat Agent // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/list/* // @match https://www.bilibili.com/bangumi/play/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @connect * // @license GPL-3.0 // ==/UserScript== (function () { 'use strict'; const STORE_KEYS = { apiBase: 'bili_ai_summary_api_base', apiKey: 'bili_ai_summary_api_key', apiModel: 'bili_ai_summary_api_model', apiProfiles: 'bili_ai_summary_api_profiles', activeApiProfileId: 'bili_ai_summary_active_api_profile_id', summaryPrompt: 'bili_ai_summary_custom_prompt', disableThinking: 'bili_ai_summary_disable_thinking', requestTimeout: 'bili_ai_summary_request_timeout', floatPos: 'bili_ai_summary_float_pos', panelWidth: 'bili_ai_summary_panel_width' }; const DEFAULT_API_BASE = 'https://api.deepseek.com/v1'; const DEFAULT_MODEL = 'deepseek-v4-flash'; const DEFAULT_FLOAT_POS = { right: 24, top: 180 }; const DEFAULT_PANEL_WIDTH = 420; const MIN_PANEL_WIDTH = 320; const MAX_PANEL_WIDTH = 720; const MAX_PANEL_HEIGHT = 560; const ABS_MIN_PANEL_HEIGHT = 120; const DEFAULT_REQUEST_TIMEOUT = 120000; const DEFAULT_SUMMARY_PROMPT = `请你根据 B 站视频字幕生成中文 Markdown 总结。 要求: 1. 必须按字幕中的时间顺序分段总结。 2. 每个主要小节标题必须以时间开头,例如:## [03:20] 主题内容。 3. 每个时间段下面写 2-4 条要点。 4. 保留关键观点、重要细节、结论和转折。 5. 不要编造字幕中没有的信息。 6. 不要输出无关寒暄。 7. 所有时间必须保留为 [mm:ss] 或 [hh:mm:ss] 格式。`; let floatBtn = null; let panel = null; let resizeHandle = null; let isPanelOpen = false; let latestSubtitleText = ''; let latestSummaryText = ''; let latestDisplayText = ''; let currentModule = 'subtitle'; // subtitle / summary let isGenerating = false; let livePanelWidth = null; function $(selector, root = document) { return root.querySelector(selector); } function clamp(num, min, max) { return Math.max(min, Math.min(max, num)); } function escapeHTML(str) { return String(str || '').replace(/[&<>"']/g, s => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[s])); } function toast(message, duration = 2200) { const el = document.createElement('div'); el.textContent = message; el.style.cssText = ` position: fixed; z-index: 2147483647; left: 50%; top: 24px; transform: translateX(-50%); padding: 9px 14px; border-radius: 999px; background: rgba(0,0,0,.78); color: #fff; font-size: 13px; pointer-events: none; box-shadow: 0 6px 20px rgba(0,0,0,.2); `; document.body.appendChild(el); setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .25s'; setTimeout(() => el.remove(), 300); }, duration); } /*********************** * API / 配置 ***********************/ function createProfileId() { return `profile_${Date.now()}_${Math.random().toString(16).slice(2)}`; } function getApiProfiles() { let profiles = GM_getValue(STORE_KEYS.apiProfiles, null); if (!Array.isArray(profiles) || !profiles.length) { const oldBase = GM_getValue(STORE_KEYS.apiBase, DEFAULT_API_BASE); const oldKey = GM_getValue(STORE_KEYS.apiKey, ''); const oldModel = GM_getValue(STORE_KEYS.apiModel, DEFAULT_MODEL); profiles = [{ id: createProfileId(), name: '默认配置', apiBase: oldBase || DEFAULT_API_BASE, apiKey: oldKey || '', apiModel: oldModel || DEFAULT_MODEL }]; GM_setValue(STORE_KEYS.apiProfiles, profiles); GM_setValue(STORE_KEYS.activeApiProfileId, profiles[0].id); } return profiles; } function saveApiProfiles(profiles) { GM_setValue(STORE_KEYS.apiProfiles, profiles); } function getActiveApiProfileId() { const profiles = getApiProfiles(); const savedId = GM_getValue(STORE_KEYS.activeApiProfileId, ''); if (profiles.some(p => p.id === savedId)) return savedId; const firstId = profiles[0]?.id || ''; if (firstId) GM_setValue(STORE_KEYS.activeApiProfileId, firstId); return firstId; } function setActiveApiProfileId(id) { GM_setValue(STORE_KEYS.activeApiProfileId, id); } function getActiveApiProfile() { const profiles = getApiProfiles(); const activeId = getActiveApiProfileId(); return profiles.find(p => p.id === activeId) || profiles[0]; } function getApiBase() { const profile = getActiveApiProfile(); return String(profile?.apiBase || DEFAULT_API_BASE).replace(/\/+$/, ''); } function getApiKey() { const profile = getActiveApiProfile(); return profile?.apiKey || ''; } function getApiModel() { const profile = getActiveApiProfile(); return profile?.apiModel || DEFAULT_MODEL; } function getSummaryPrompt() { return GM_getValue(STORE_KEYS.summaryPrompt, DEFAULT_SUMMARY_PROMPT) || DEFAULT_SUMMARY_PROMPT; } function setSummaryPrompt(prompt) { GM_setValue(STORE_KEYS.summaryPrompt, prompt || DEFAULT_SUMMARY_PROMPT); } function getDisableThinking() { return GM_getValue(STORE_KEYS.disableThinking, true); } function setDisableThinking(value) { GM_setValue(STORE_KEYS.disableThinking, !!value); } function getRequestTimeout() { const saved = Number(GM_getValue(STORE_KEYS.requestTimeout, DEFAULT_REQUEST_TIMEOUT)); return clamp(saved || DEFAULT_REQUEST_TIMEOUT, 10000, 600000); } function setRequestTimeout(ms) { const value = clamp(Number(ms) || DEFAULT_REQUEST_TIMEOUT, 10000, 600000); GM_setValue(STORE_KEYS.requestTimeout, value); return value; } function getPanelWidth() { const saved = Number(GM_getValue(STORE_KEYS.panelWidth, DEFAULT_PANEL_WIDTH)); return clamp(saved || DEFAULT_PANEL_WIDTH, MIN_PANEL_WIDTH, MAX_PANEL_WIDTH); } function setPanelWidth(width) { const w = clamp(width, MIN_PANEL_WIDTH, MAX_PANEL_WIDTH); GM_setValue(STORE_KEYS.panelWidth, w); return w; } function getFloatPos() { const saved = GM_getValue(STORE_KEYS.floatPos, null); if (saved && typeof saved === 'object') { return { right: typeof saved.right === 'number' ? saved.right : DEFAULT_FLOAT_POS.right, top: typeof saved.top === 'number' ? saved.top : DEFAULT_FLOAT_POS.top }; } return { ...DEFAULT_FLOAT_POS }; } function saveFloatPos(pos) { GM_setValue(STORE_KEYS.floatPos, pos); } function isVideoPage() { return /bilibili\.com\/video\/|bilibili\.com\/bangumi\/play\/|bilibili\.com\/list\//.test(location.href); } function formatTime(seconds) { seconds = Number(seconds) || 0; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); if (h > 0) { return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } function timeToSeconds(time) { const parts = String(time || '').split(':').map(Number); if (parts.length === 2) return parts[0] * 60 + parts[1]; if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; return 0; } function markdownToHTML(text) { let html = escapeHTML(text || ''); html = html.replace(/^### (.*)$/gm, '

$1

'); html = html.replace(/^## (.*)$/gm, '

$1

'); html = html.replace(/^# (.*)$/gm, '

$1

'); html = html.replace(/\*\*(.*?)\*\*/g, '$1'); html = html.replace(/`([^`]+)`/g, '$1'); html = html.replace(/\[([0-9]{1,2}:[0-9]{2}(?::[0-9]{2})?)\]/g, (_, time) => { return `[${time}]`; }); html = html.replace(/^\s*[-*]\s+(.*)$/gm, '
  • $1
  • '); html = html.replace(/(
  • [\s\S]*?<\/li>)/g, ''); html = html.replace(/<\/ul>\s*