// ==UserScript==
// @name B站字幕提取器
// @namespace https://blog.qitongtingyu.online/
// @version 1.0.0
// @description 从B站视频页面提取字幕文本,支持单个视频/分P视频下载,多种字幕导出格式,提供字幕搜索快速定位功能
// @author 栖桐听雨
// @match https://www.bilibili.com/video/*
// @icon https://www.bilibili.com/favicon.ico
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @grant GM_setValue
// @grant GM_getValue
// @connect api.bilibili.com
// @connect aisubtitle.hdslb.com
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
CACHE_MAX_SIZE: 50,
CACHE_MAX_AGE: 3600000,
REQUEST_TIMEOUT: 10000,
REQUEST_RETRIES: 2,
REQUEST_RETRY_DELAY: 1000,
STORAGE_KEYS: {
CUSTOM_EXTENSIONS: 'bili_transcript_custom_extensions',
DOWNLOAD_SETTINGS: 'bili_transcript_download_settings'
},
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' },
{ name: 'SRT', value: 'srt', mimeType: 'text/x-subrip' },
{ name: 'VTT', value: 'vtt', mimeType: 'text/vtt' },
{ name: 'ASS', value: 'ass', mimeType: 'text/x-ass' },
{ name: 'LRC', value: 'lrc', mimeType: 'text/lrc' },
{ name: 'JSON', value: 'json', mimeType: 'application/json' }
],
DEFAULT_DOWNLOAD_SETTINGS: {
format: 'txt',
downloadMethod: 'direct',
includeBV: true,
includeTimestamp: false,
includeDuration: false,
includeSubtitleTime: true
}
};
const ErrorTypes = {
NETWORK_ERROR: 'NETWORK_ERROR',
API_ERROR: 'API_ERROR',
AUTH_ERROR: 'AUTH_ERROR',
PARSE_ERROR: 'PARSE_ERROR',
VALIDATION_ERROR: 'VALIDATION_ERROR',
UNKNOWN_ERROR: 'UNKNOWN_ERROR'
};
class SubtitleError extends Error {
constructor(type, message, originalError = null) {
super(message);
this.type = type;
this.originalError = originalError;
}
}
// 存储管理模块
const StorageManager = {
getCustomExtensions() {
try {
const saved = localStorage.getItem(CONFIG.STORAGE_KEYS.CUSTOM_EXTENSIONS);
return saved ? JSON.parse(saved) : [];
} catch (error) {
console.error('加载自定义扩展名失败:', error);
return [];
}
},
saveCustomExtensions(extensions) {
try {
localStorage.setItem(CONFIG.STORAGE_KEYS.CUSTOM_EXTENSIONS, JSON.stringify(extensions));
} catch (error) {
console.error('保存自定义扩展名失败:', error);
}
},
getDownloadSettings() {
try {
const saved = localStorage.getItem(CONFIG.STORAGE_KEYS.DOWNLOAD_SETTINGS);
return saved ? JSON.parse(saved) : CONFIG.DEFAULT_DOWNLOAD_SETTINGS;
} catch (error) {
console.error('加载下载设置失败:', error);
return CONFIG.DEFAULT_DOWNLOAD_SETTINGS;
}
},
saveDownloadSettings(settings) {
try {
localStorage.setItem(CONFIG.STORAGE_KEYS.DOWNLOAD_SETTINGS, JSON.stringify(settings));
} catch (error) {
console.error('保存下载设置失败:', error);
}
}
};
let state = {
currentVideo: { bvid: '', cid: '', title: '', duration: 0 },
videoList: [],
videoListType: 'single',
subtitleList: [],
subtitleDetails: [],
modalOpenCount: 0,
searchResults: [],
currentSearchIndex: -1
};
function disableScroll() {
state.modalOpenCount++;
if (state.modalOpenCount === 1) {
document.documentElement.style.setProperty('overflow', 'hidden', 'important');
document.body.style.setProperty('overflow', 'hidden', 'important');
}
}
function enableScroll() {
state.modalOpenCount--;
if (state.modalOpenCount <= 0) {
state.modalOpenCount = 0;
document.documentElement.style.removeProperty('overflow');
document.body.style.removeProperty('overflow');
}
}
class SubtitleCache {
constructor(maxSize = CONFIG.CACHE_MAX_SIZE, maxAge = CONFIG.CACHE_MAX_AGE) {
this.cache = new Map();
this.maxSize = maxSize;
this.maxAge = maxAge;
}
generateKey(bvid, cid, subtitleId) {
return `${bvid}_${cid}_${subtitleId}`;
}
get(bvid, cid, subtitleId) {
const key = this.generateKey(bvid, cid, subtitleId);
const item = this.cache.get(key);
if (!item) return null;
if (Date.now() - item.timestamp > this.maxAge) {
this.cache.delete(key);
return null;
}
return item.data;
}
set(bvid, cid, subtitleId, data) {
const key = this.generateKey(bvid, cid, subtitleId);
if (this.cache.size >= this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, { data: data, timestamp: Date.now() });
}
clear(bvid, cid) {
if (bvid && cid) {
const prefix = `${bvid}_${cid}_`;
for (const key of this.cache.keys()) {
if (key.startsWith(prefix)) {
this.cache.delete(key);
}
}
} else {
this.cache.clear();
}
}
}
const subtitleCache = new SubtitleCache();
class RequestDeduplicator {
constructor() {
this.pendingRequests = new Map();
}
async request(key, requestFn) {
if (this.pendingRequests.has(key)) {
return this.pendingRequests.get(key);
}
const promise = requestFn().finally(() => {
this.pendingRequests.delete(key);
});
this.pendingRequests.set(key, promise);
return promise;
}
}
const deduplicator = new RequestDeduplicator();
function showToast(message, type = 'info', duration = 3000) {
const toast = document.createElement('div');
toast.className = `bili-transcript-toast bili-transcript-toast-${type}`;
const icons = {
success: '',
error: '',
warning: '',
info: ''
};
toast.innerHTML = ``;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1)';
setTimeout(() => document.body.contains(toast) && toast.remove(), 300);
}, duration);
}
function sanitizeInput(input) {
if (typeof input !== 'string') return '';
return input.replace(/&/g, '&').replace(//g, '>')
.replace(/"/g, '"').replace(/'/g, ''').replace(/\//g, '/');
}
function escapeCSV(value) {
if (typeof value !== 'string') return '';
return value.includes(',') || value.includes('"') || value.includes('\n')
? '"' + value.replace(/"/g, '""') + '"'
: value;
}
function getCookies() {
return Object.fromEntries(
document.cookie.split(';').map(c => c.trim().split('=')).filter(([k, v]) => k && v)
);
}
function getHeaders() {
const cookies = getCookies();
const cookieString = Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; ');
return {
'Referer': 'https://www.bilibili.com',
'User-Agent': navigator.userAgent,
'Cookie': cookieString.trim()
};
}
async function request(url, options = {}) {
const { timeout = CONFIG.REQUEST_TIMEOUT, retries = CONFIG.REQUEST_RETRIES, retryDelay = CONFIG.REQUEST_RETRY_DELAY, raw = false } = options;
return new Promise((resolve, reject) => {
let attempts = 0;
function attempt() {
attempts++;
const timer = setTimeout(() => {
attempts <= retries ? setTimeout(attempt, retryDelay) : reject(new SubtitleError(ErrorTypes.NETWORK_ERROR, '请求超时'));
}, timeout);
GM_xmlhttpRequest({
method: options.method || 'GET',
url: url,
headers: options.headers || getHeaders(),
timeout: timeout,
onload: (response) => {
clearTimeout(timer);
if (raw) return resolve(response.responseText);
try {
const data = JSON.parse(response.responseText);
if (data.code === 0) {
resolve(data);
} else if (data.code === -101) {
reject(new SubtitleError(ErrorTypes.AUTH_ERROR, '请重新登录B站'));
} else if (data.code === -404) {
reject(new SubtitleError(ErrorTypes.API_ERROR, '请求的资源不存在'));
} else {
reject(new SubtitleError(ErrorTypes.API_ERROR, data.message || 'API请求失败'));
}
} catch (e) {
reject(new SubtitleError(ErrorTypes.PARSE_ERROR, '响应数据格式错误', e));
}
},
onerror: (error) => {
clearTimeout(timer);
attempts <= retries ? setTimeout(attempt, retryDelay) : reject(new SubtitleError(ErrorTypes.NETWORK_ERROR, '网络请求失败', error));
},
ontimeout: () => {
clearTimeout(timer);
attempts <= retries ? setTimeout(attempt, retryDelay) : reject(new SubtitleError(ErrorTypes.NETWORK_ERROR, '请求超时'));
}
});
}
attempt();
});
}
async function getVideoInfo(bvid) {
try {
return await request(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`);
} catch (error) {
const title = document.querySelector('.video-title')?.textContent;
const cid = window.__INITIAL_STATE__?.videoData?.cid;
if (title && cid) return { data: { title, cid } };
throw error;
}
}
async function getVideoPages(bvid) {
try {
const response = await request(`https://api.bilibili.com/x/player/pagelist?bvid=${bvid}`);
return response.data || [];
} catch (error) {
return [];
}
}
async function fetchSubtitles(bvid, cid, forceRefresh = false) {
const timestamp = Date.now();
const url = forceRefresh
? `https://api.bilibili.com/x/player/wbi/v2?bvid=${bvid}&cid=${cid}&_=${timestamp}`
: `https://api.bilibili.com/x/player/wbi/v2?bvid=${bvid}&cid=${cid}`;
const key = `subtitles_${bvid}_${cid}_${forceRefresh ? timestamp : ''}`;
return deduplicator.request(key, async () => {
const response = await request(url, {
headers: {
'Referer': `https://www.bilibili.com/video/${bvid}/`,
'Origin': 'https://www.bilibili.com',
'Accept': 'application/json, text/plain, */*'
}
});
return response.data?.subtitle?.subtitles?.map(s => parseSubtitleItem(s)) || [];
});
}
async function fetchSubtitlesFromWebInterface(bvid, cid) {
try {
const response = await request(`https://api.bilibili.com/x/web-interface/view?bvid=${bvid}`);
return response.data?.subtitle?.list?.map(s => parseSubtitleItem(s)) || [];
} catch (error) {
console.error('备用接口获取字幕失败:', error);
return [];
}
}
function parseSubtitleItem(subtitle) {
return {
id: subtitle.id,
lan: subtitle.lan_doc || subtitle.lan,
url: subtitle.subtitle_url || subtitle.url || subtitle.content_url || subtitle.caption_url || ''
};
}
async function getSubtitleContent(url, bvid, cid, subtitleId) {
const cached = subtitleCache.get(bvid, cid, subtitleId);
if (cached) return cached;
if (!url) return [];
const fullUrl = url.startsWith('//') ? `https:${url}` : url;
try {
const response = await request(fullUrl, { raw: true });
const data = JSON.parse(response);
const content = data.body || [];
if (content.length > 0) subtitleCache.set(bvid, cid, subtitleId, content);
return content;
} catch (error) {
console.error('获取字幕内容失败:', error);
return [];
}
}
function parseTime(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 1000);
return { h, m, s, ms };
}
function formatTime(seconds, format = 'srt') {
const { h, m, s, ms } = parseTime(seconds);
if (format === 'srt') {
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`;
} else if (format === 'ass') {
return `${h.toString().padStart(1, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}.${Math.floor(ms / 10).toString().padStart(2, '0')}`;
} else if (format === 'lrc') {
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}.${Math.floor(ms / 10).toString().padStart(2, '0')}`;
}
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
// 格式转换函数
function convertToSRT(content) {
return content.map((item, index) => {
const from = formatTime(item.from, 'srt');
const to = formatTime(item.to, 'srt');
return `${index + 1}\n${from} --> ${to}\n${item.content}\n`;
}).join('\n');
}
function convertToTXT(content, includeTime = false) {
return content.map(item => includeTime ? `${formatTime(item.from, 'srt')} ${item.content}` : item.content).join('\n');
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
return h > 0 ? `${h}-${String(m).padStart(2, '0')}-${String(s).padStart(2, '0')}` : `${m}-${String(s).padStart(2, '0')}`;
}
function convertToJSON(content) {
return JSON.stringify(content, null, 2);
}
function convertToVTT(content) {
let vtt = 'WEBVTT\n\n';
content.forEach((item, index) => {
const from = formatTime(item.from, 'srt').replace(',', '.');
const to = formatTime(item.to, 'srt').replace(',', '.');
vtt += `${index + 1}\n${from} --> ${to}\n${item.content}\n\n`;
});
return vtt;
}
function convertToCSV(content) {
let csv = '序号,开始时间,结束时间,字幕内容\n';
content.forEach((item, index) => {
const from = formatTime(item.from, 'srt');
const to = formatTime(item.to, 'srt');
csv += `${index + 1},${escapeCSV(from)},${escapeCSV(to)},${escapeCSV(item.content)}\n`;
});
return csv;
}
function convertToXML(content) {
let xml = '\n
当前视频没有可用的字幕
暂无可选视频
`; } else { videoList.forEach((video, index) => { const checkbox = document.createElement('label'); checkbox.className = 'checkbox-item'; const title = video.title || '未知标题'; checkbox.innerHTML = ` ${index + 1}. ${sanitizeInput(title).substring(0, 40)}${title.length > 40 ? '...' : ''}`; videoCheckboxes.appendChild(checkbox); }); } document.getElementById('select-all').addEventListener('click', () => { document.querySelectorAll('#video-checkboxes input').forEach(cb => cb.checked = true); }); document.getElementById('deselect-all').addEventListener('click', () => { document.querySelectorAll('#video-checkboxes input').forEach(cb => cb.checked = false); }); document.getElementById('start-batch-download').addEventListener('click', batchDownloadSubtitles); async function batchDownloadSubtitles() { const selectedVideos = Array.from(document.querySelectorAll('#video-checkboxes input:checked')) .map(cb => ({ bvid: cb.value, cid: cb.getAttribute('data-cid') || '' })); const settings = StorageManager.getDownloadSettings(); const format = settings.format; if (selectedVideos.length === 0) { showToast('请选择至少一个视频', 'warning'); return; } showToast(`开始下载 ${selectedVideos.length} 个视频的字幕...`, 'info'); for (const video of selectedVideos) { try { await downloadVideoSubtitle(video, format, settings); await new Promise(resolve => setTimeout(resolve, 500)); } catch (error) { console.error(`下载 ${video.bvid} 字幕失败:`, error); } } showToast('批量下载完成', 'success'); removeModal('batch-download-modal'); } } async function downloadVideoSubtitle(video, format, settings) { let cid = video.cid; if (!cid) cid = await getVideoCID(video.bvid); if (!cid) return; let subtitles = await fetchSubtitles(video.bvid, cid); if (subtitles.length === 0) return; const selectedSubtitle = subtitles.find(s => !s.lan.includes('摘要') && !s.lan.includes('AI') && s.lan.includes('中文')) || subtitles[0]; if (!selectedSubtitle.url) return; const content = await getSubtitleContent(selectedSubtitle.url, video.bvid, cid, selectedSubtitle.id); if (content.length === 0) return; const videoInfo = await getVideoInfo(video.bvid); const title = videoInfo.data?.title || video.bvid; const duration = videoInfo.data?.duration || 0; let filename = title.replace(/[\\/:*?"<>|]/g, '_'); if (settings.includeBV && video.bvid) filename = `${filename}_${video.bvid}`; if (settings.includeTimestamp) { const now = new Date(); const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; filename = `${filename}_${timestamp}`; } if (settings.includeDuration && duration > 0) { const durationStr = formatDuration(duration); filename = `${filename}_${durationStr}`; } let convertedContent; let mimeType = 'text/plain'; switch (format) { case 'srt': convertedContent = convertToSRT(content); mimeType = 'text/x-subrip'; break; case 'vtt': convertedContent = convertToVTT(content); mimeType = 'text/vtt'; break; case 'json': convertedContent = convertToJSON(content); mimeType = 'application/json'; break; case 'csv': convertedContent = convertToCSV(content); mimeType = 'text/csv'; break; case 'xml': convertedContent = convertToXML(content); mimeType = 'application/xml'; break; case 'ass': convertedContent = convertToASS(content, title); mimeType = 'text/x-ass'; break; case 'lrc': convertedContent = convertToLRC(content); mimeType = 'text/lrc'; break; default: convertedContent = convertToTXT(content, settings.includeSubtitleTime); } downloadFile(convertedContent, `${filename}.${format}`, mimeType); } function createFloatButton() { removeModal('bili-transcript-btn'); const btn = document.createElement('button'); btn.id = 'bili-transcript-btn'; btn.className = 'bili-transcript-btn'; btn.innerHTML = ''; btn.title = '提取字幕'; btn.addEventListener('click', createModal); document.body.appendChild(btn); } function initVideoList() { const videos = getVideoList(); if (videos.length > 1) { state.videoList = videos; state.videoListType = 'collection'; } else if (videos.length === 1) { state.videoList = videos; state.videoListType = 'single'; } else { state.videoList = []; state.videoListType = 'single'; } } function init() { state.currentVideo = getCurrentVideoInfo(); initVideoList(); injectStyles(); createFloatButton(); } function injectStyles() { const style = document.createElement('style'); style.textContent = ` :root { --primary: #a18276; --primary-dark: #8a6f64; --primary-light: #b89a8f; --accent: #f4b886; --bg-primary: #fefdfb; --bg-secondary: #fcdfa6; --bg-surface: #f4b886; --bg-white: #ffffff; --bg-transparent: rgba(255, 255, 255, 0.02); --bg-hover: rgba(161, 130, 118, 0.1); --bg-selected: rgba(161, 130, 118, 0.15); --bg-selected-hover: rgba(161, 130, 118, 0.2); --bg-highlight: rgba(244, 184, 134, 0.3); --text-primary: #5c4a42; --text-secondary: #8a7268; --text-muted: #a89a90; --text-white: #ffffff; --border: rgba(92, 74, 66, 0.12); --border-hover: rgba(92, 74, 66, 0.2); --border-light: rgba(255, 255, 255, 0.15); --success: #7a9e7e; --error: #c97b7b; --warning: #e6a75c; --info: #a18276; --scrollbar-track-surface: rgba(161, 130, 118, 0.08); --scrollbar-thumb-surface: rgba(161, 130, 118, 0.4); --scrollbar-thumb-surface-hover: rgba(161, 130, 118, 0.6); --shadow-primary: rgba(161, 130, 118, 0.35); --shadow-primary-hover: rgba(161, 130, 118, 0.45); --shadow-primary-active: rgba(161, 130, 118, 0.3); --shadow-modal: rgba(0, 0, 0, 0.15); --shadow-toast: rgba(0, 0, 0, 0.3); --shadow-dropdown: rgba(161, 130, 118, 0.15); --icon-bg: rgba(59, 130, 246, 0.1); --focus-ring: rgba(161, 130, 118, 0.2); --btn-close-bg: rgba(255, 255, 255, 0.15); --btn-close-hover: rgba(255, 255, 255, 0.25); --btn-small-hover: rgba(255, 255, 255, 0.05); --btn-small-active: rgba(255, 255, 255, 0.08); } /* 通用基础样式 */ * { box-sizing: border-box; } .base-transition { transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .base-card { border: 1px solid var(--border); border-radius: 10px; background: var(--bg-surface); } .base-input { width: 100%; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; font-size: 14px; background: var(--bg-surface); color: var(--text-primary); font-family: inherit; outline: none; } .base-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--focus-ring); background: var(--bg-white); } .base-btn { padding: 14px 32px; border: none; border-radius: 16px; font-size: 15px; font-weight: 600; cursor: pointer; font-family: inherit; display: inline-flex; align-items: center; justify-content: center; gap: 10px; min-width: 120px; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .base-btn:hover { transform: translateY(-2px); } .base-btn:active { transform: translateY(0); } .base-btn-small { padding: 12px 20px; border: none; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .base-btn-small:hover { background: var(--btn-small-hover); border-color: var(--border-hover); transform: translateY(-2px); } .base-btn-small:active { background: var(--btn-small-active); transform: translateY(0); } /* 统一滚动条样式 */ .subtitle-content, .checkbox-list, .format-list, .custom-select-dropdown, .settings-modal .modal-body { scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb-surface) var(--scrollbar-track-surface); } .subtitle-content::-webkit-scrollbar, .checkbox-list::-webkit-scrollbar, .format-list::-webkit-scrollbar, .custom-select-dropdown::-webkit-scrollbar, .settings-modal .modal-body::-webkit-scrollbar { width: 6px; display: block; } .subtitle-content::-webkit-scrollbar-track, .checkbox-list::-webkit-scrollbar-track, .format-list::-webkit-scrollbar-track, .custom-select-dropdown::-webkit-scrollbar-track, .settings-modal .modal-body::-webkit-scrollbar-track { background: var(--scrollbar-track-surface); border-radius: 3px; } .subtitle-content::-webkit-scrollbar-thumb, .checkbox-list::-webkit-scrollbar-thumb, .format-list::-webkit-scrollbar-thumb, .custom-select-dropdown::-webkit-scrollbar-thumb, .settings-modal .modal-body::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb-surface); border-radius: 3px; } .subtitle-content::-webkit-scrollbar-thumb:hover, .checkbox-list::-webkit-scrollbar-thumb:hover, .format-list::-webkit-scrollbar-thumb:hover, .custom-select-dropdown::-webkit-scrollbar-thumb:hover, .settings-modal .modal-body::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-surface-hover); } /* 动画 */ @keyframes slideDown { from { opacity: 0; transform: translateX(-50%) translateY(-20px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } @keyframes slideUp { from { opacity: 1; transform: translateX(-50%) translateY(0); } to { opacity: 0; transform: translateX(-50%) translateY(-20px); } } @keyframes spin { to { transform: rotate(360deg); } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } /* 全局字体 */ .bili-transcript-modal, .bili-transcript-toast, .bili-transcript-btn { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } /* 悬浮按钮 */ .bili-transcript-btn { position: fixed; right: 24px; bottom: 24px; width: 52px; height: 52px; border-radius: 50%; background: var(--primary); color: var(--text-white); border: none; box-shadow: 0 6px 20px var(--shadow-primary), 0 0 0 1px var(--border-light) inset; font-size: 20px; cursor: pointer; z-index: 10000; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); display: flex; align-items: center; justify-content: center; } .bili-transcript-btn:hover { transform: scale(1.1) translateY(-2px); box-shadow: 0 10px 30px var(--shadow-primary-hover), 0 0 0 1px rgba(255,255,255,0.2) inset; } /* Toast提示 */ .bili-transcript-toast { position: fixed; top: 20px; left: 50%; transform: translateX(-50%); padding: 12px 20px; border-radius: 8px; box-shadow: 0 8px 24px var(--shadow-toast); z-index: 10006; display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--text-white); animation: slideDown 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .bili-transcript-toast-success { background: var(--success); } .bili-transcript-toast-error { background: var(--error); } .bili-transcript-toast-warning { background: var(--warning); } .bili-transcript-toast-info { background: var(--info); } .toast-icon { flex-shrink: 0; } .toast-message { font-size: 14px; font-weight: 500; } /* 模态框通用 */ .bili-transcript-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 10002; display: flex; align-items: center; justify-content: center; opacity: 1; transition: opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1); backdrop-filter: blur(4px); } .modal-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: transparent; } .modal-content { position: relative; width: 92%; max-width: 640px; max-height: 88vh; background: var(--bg-primary); border-radius: 14px; overflow: hidden; box-shadow: 0 12px 40px var(--shadow-modal), 0 0 0 1px var(--border); display: flex; flex-direction: column; } .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; background: var(--primary); color: var(--text-white); flex-shrink: 0; border-bottom: 1px solid var(--border-light); } .modal-header h2 { margin: 0; font-size: 16px; font-weight: 600; line-height: 1.3; display: flex; align-items: center; gap: 10px; } .modal-icon { flex-shrink: 0; } .close-btn { width: 34px; height: 34px; border: none; background: var(--btn-close-bg); color: var(--text-white); border-radius: 12px; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 0; font-size: 16px; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .close-btn:hover { background: var(--btn-close-hover); transform: scale(1.15); } .modal-body { padding: 24px; flex: 1; display: flex; flex-direction: column; gap: 12px; overflow: hidden; } .modal-footer { display: flex; gap: 10px; justify-content: flex-end; padding: 16px 22px; border-top: 1px solid var(--border); background: var(--bg-transparent); flex-shrink: 0; } .btn-primary { padding: 10px 20px; border: none; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 80px; background: var(--primary); color: var(--text-white); box-shadow: 0 4px 12px var(--shadow-primary); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .btn-primary:hover { transform: translateY(-2px); box-shadow: 0 6px 20px var(--shadow-primary-hover); } .btn-primary:active { transform: translateY(0); box-shadow: 0 3px 10px var(--shadow-primary-active); } .btn-secondary { padding: 10px 20px; border: 1px solid var(--border); border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-width: 80px; background: var(--bg-surface); color: var(--text-primary); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .btn-secondary:hover { background: var(--bg-secondary); border-color: var(--border-hover); transform: translateY(-2px); } .btn-secondary:active { transform: translateY(0); } .btn-small { padding: 8px 16px; border: 1px solid var(--border); border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: var(--bg-surface); color: var(--text-primary); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .btn-small:hover { background: var(--bg-secondary); border-color: var(--border-hover); transform: translateY(-1px); } .btn-small:active { transform: translateY(0); } /* 视频信息 */ .video-info { padding: 18px 20px; background: var(--bg-surface); border-radius: 12px; border: 1px solid var(--border); flex-shrink: 0; } .info-row { display: flex; margin-bottom: 14px; align-items: flex-start; } .info-row:last-child { margin-bottom: 0; } .info-label { font-weight: 600; color: var(--text-secondary); width: 60px; flex-shrink: 0; font-size: 13px; letter-spacing: 0.5px; } .info-value { color: var(--text-primary); word-break: break-all; font-size: 14px; line-height: 1.65; font-weight: 400; } /* 自定义下拉框 */ .selector-container { flex-shrink: 0; } .custom-select { position: relative; width: 100%; z-index: 10; } .custom-select-trigger { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 13px 16px; border: 1px solid var(--border); border-radius: 10px; font-size: 14px; background: var(--bg-primary); color: var(--text-primary); cursor: pointer; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .custom-select-trigger:hover { border-color: var(--border-hover); background: var(--bg-surface); } .custom-select-trigger:active { transform: scale(0.98); } .custom-select-value { flex: 1; text-align: left; margin: 0; } .custom-select-icon-wrapper { flex-shrink: 0; display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; margin-left: 12px; color: var(--text-secondary); transition: color 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .custom-select-trigger:hover .custom-select-icon-wrapper, .custom-select.active .custom-select-icon-wrapper { color: var(--text-primary); } .custom-select-icon-wrapper svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } .custom-select-dropdown { position: absolute; top: calc(100% + 8px); left: 0; right: 0; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 24px var(--shadow-dropdown); opacity: 0; visibility: hidden; transform: translateY(-8px); transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); z-index: 100; max-height: 200px; overflow-y: auto; } .custom-select-dropdown.active { opacity: 1; visibility: visible; transform: translateY(0); } .custom-select-option { padding: 11px 16px; font-size: 14px; color: var(--text-primary); cursor: pointer; transition: background 0.15s cubic-bezier(0.16, 1, 0.3, 1); } .custom-select-option:hover { background: var(--bg-hover); } .custom-select-option.disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.6; } .custom-select-option.disabled:hover { background: none; } /* 原生下拉框 */ .video-select { width: 100%; padding: 13px 16px; border: 1px solid var(--border); border-radius: 10px; font-size: 14px; background: var(--bg-surface); color: var(--text-primary); font-family: inherit; cursor: pointer; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); appearance: none; position: relative; } .video-select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--focus-ring); background: var(--bg-white); } .video-select:hover { border-color: var(--border-hover); background: var(--bg-white); } /* 搜索框 */ .search-container { flex-shrink: 0; } .search-input-wrapper { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-surface); transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .search-input-wrapper:focus-within { border-color: var(--primary); box-shadow: 0 0 0 3px var(--focus-ring); background: var(--bg-white); } .search-icon { color: var(--text-secondary); flex-shrink: 0; } .search-input { flex: 1; border: none; background: transparent; font-size: 14px; color: var(--text-primary); font-family: inherit; outline: none; } .search-input::placeholder { color: var(--text-muted); } .search-nav { display: flex; align-items: center; gap: 4px; } .search-nav-btn { width: 32px; height: 32px; border: none; border-radius: 10px; background: transparent; color: var(--text-secondary); font-size: 14px; cursor: pointer; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); display: flex; align-items: center; justify-content: center; } .search-nav-btn:hover { background: var(--btn-small-hover); color: var(--text-primary); transform: scale(1.1); } .search-status { font-size: 12px; color: var(--text-muted); min-width: 40px; text-align: center; } .highlight { background: var(--bg-highlight); padding: 0 2px; border-radius: 2px; font-weight: 500; } .search-match { background: var(--bg-selected); } .subtitle-item.search-selected { background: var(--bg-selected-hover); border-left: 3px solid var(--primary); } /* 字幕内容 */ .subtitle-content-wrapper { flex: 0 1 420px; min-height: 180px; max-height: 420px; } .subtitle-content { height: 100%; overflow-y: auto; overflow-x: hidden; border: 1px solid var(--border); border-radius: 12px; padding: 16px; background: var(--bg-primary); } .loading { text-align: center; padding: 40px; color: var(--text-muted); font-size: 14px; display: flex; align-items: center; justify-content: center; gap: 10px; } .loading-spinner { animation: spin 1.5s linear infinite; } .loading-path { animation: pulse 1.5s ease-in-out infinite; } .no-subtitles { text-align: center; padding: 40px; display: flex; flex-direction: column; align-items: center; } .no-subtitles-icon { width: 64px; height: 64px; margin-bottom: 20px; background: var(--icon-bg); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 28px; } .no-subtitles h3 { margin: 0 0 10px 0; color: var(--text-primary); font-size: 17px; font-weight: 600; } .no-subtitles p { margin: 0 0 20px 0; color: var(--text-muted); font-size: 14px; line-height: 1.5; } .no-subtitles-tips { list-style: none; padding: 0; margin: 0; text-align: left; } .no-subtitles-tips li { margin-bottom: 8px; font-size: 13px; color: var(--text-muted); padding-left: 20px; position: relative; } .no-subtitles-tips li::before { content: ''; position: absolute; left: 0; top: 6px; width: 4px; height: 4px; background: var(--primary); border-radius: 50%; } .subtitle-item { display: flex; gap: 20px; padding: 12px 14px; border-bottom: 1px solid var(--border); cursor: pointer; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); border-radius: 8px; margin: 2px 0; } .subtitle-item:last-child { border-bottom: none; } .subtitle-item:hover { background: var(--bg-hover); transform: translateX(4px); } .subtitle-time { color: var(--primary); font-family: 'SF Mono', 'Monaco', 'Inconsolata', monospace; font-size: 12px; font-weight: 500; flex-shrink: 0; width: 90px; text-align: right; letter-spacing: 0.3px; } .subtitle-text { color: var(--text-primary); font-size: 14px; line-height: 1.6; flex: 1; padding-left: 12px; border-left: 1px solid var(--border); } /* 批量下载 */ .batch-modal { max-width: 680px; } .batch-section { margin-bottom: 24px; } .batch-section:last-child { margin-bottom: 0; } .batch-section h3 { margin: 0 0 14px 0; font-size: 14px; color: var(--text-primary); font-weight: 600; } .batch-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .batch-header h3 { margin: 0; font-size: 15px; } .batch-actions { display: flex; gap: 8px; } .checkbox-list { max-height: 260px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; padding: 4px; background: var(--bg-secondary); } .checkbox-item { display: flex; align-items: center; padding: 11px 14px; cursor: pointer; border-radius: 8px; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); gap: 11px; } .checkbox-item:hover { background: var(--bg-hover); } .checkbox-item:has(input:checked) { background: var(--bg-selected); } .checkbox-item:has(input:checked):hover { background: var(--bg-selected-hover); } .checkbox-item input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; flex-shrink: 0; } .checkbox-item span { color: var(--text-primary); font-size: 14px; line-height: 1.5; font-weight: 400; } /* 下载确认弹窗 */ .download-modal { max-width: 500px; } .download-section { margin-bottom: 16px; display: flex; flex-direction: column; gap: 8px; } .download-label { font-size: 13px; color: var(--text-secondary); font-weight: 500; } .download-input { width: 100%; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; font-size: 14px; background: var(--bg-surface); color: var(--text-primary); font-family: inherit; outline: none; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .download-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--focus-ring); background: var(--bg-white); } .download-format-display { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; font-size: 14px; background: var(--bg-surface); color: var(--text-primary); font-weight: 500; } .download-format-display .format-hint { font-size: 12px; color: var(--text-muted); font-weight: normal; } .download-methods { display: flex; flex-direction: column; gap: 8px; } .download-method-label { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-radius: 8px; cursor: pointer; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); background: var(--bg-surface); border: 1px solid var(--border); } .download-method-label:hover { background: var(--btn-small-hover); border-color: var(--border-hover); } .download-method-label input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; } .download-method-label span { color: var(--text-primary); font-size: 13px; } .download-method-label:has(input:checked) { background: var(--primary); border-color: var(--primary); } .download-method-label:has(input:checked) span { color: var(--text-white); font-weight: 600; } /* 设置弹窗 */ .settings-modal { max-width: 520px; max-height: 80vh; overflow-y: auto !important; overflow-x: hidden; } .settings-modal .modal-body { max-height: calc(80vh - 140px); overflow-y: auto; } .settings-section { margin-bottom: 20px; } .settings-section:last-child { margin-bottom: 0; } .settings-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .settings-section h3 { margin: 0 0 12px 0; font-size: 14px; color: var(--text-primary); font-weight: 600; } .settings-header h3 { margin: 0; } .settings-checkbox { display: flex; align-items: center; gap: 10px; cursor: pointer; padding: 10px 12px; border-radius: 8px; transition: background 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .settings-checkbox:hover { background: var(--bg-hover); } .settings-checkbox input { width: 17px; height: 17px; accent-color: var(--primary); cursor: pointer; } .settings-checkbox span { color: var(--text-primary); font-size: 14px; } .format-list { border: 1px solid var(--border); border-radius: 10px; padding: 4px; background: var(--bg-secondary); max-height: 200px; overflow-y: auto; } .format-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 6px; cursor: pointer; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .format-item:hover { background: var(--bg-hover); } .format-item.selected { background: var(--bg-selected); border: 1px solid var(--primary); margin: -1px; } .format-name { font-size: 14px; color: var(--text-primary); } .remove-ext-btn { padding: 8px 16px; border: none; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); background: var(--error); color: white; opacity: 0.9; } .remove-ext-btn:hover { opacity: 1; transform: scale(1.1); box-shadow: 0 4px 12px rgba(201, 123, 123, 0.4); } /* 自定义扩展名弹窗 */ .custom-ext-modal { max-width: 400px; } .custom-ext-section { margin-bottom: 16px; display: flex; flex-direction: column; gap: 8px; } .custom-ext-label { font-size: 13px; color: var(--text-secondary); font-weight: 500; } .mime-hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; padding: 8px 12px; background: var(--bg-secondary); border-radius: 6px; } .custom-ext-input { width: 100%; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; font-size: 14px; background: var(--bg-surface); color: var(--text-primary); font-family: inherit; outline: none; transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); } .custom-ext-input:focus { border-color: var(--primary); box-shadow: 0 0 0 3px var(--focus-ring); background: var(--bg-white); } .custom-ext-input::placeholder { color: var(--text-muted); } /* 响应式 */ @media (max-width: 480px) { .modal-footer { flex-direction: column; } .btn-primary, .btn-secondary { width: 100%; justify-content: center; } .modal-body { padding: 16px; } .modal-header { padding: 14px 16px; } .search-nav { display: none; } } `; document.head.appendChild(style); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();