// ==UserScript== // @name 在线听书助手 🎧 // @namespace https://github.com/ebook-tts // @version 5.0.0 // @description 在线阅读时朗读网页内容,支持本地系统音色 + 多路网络音色(有道/百度/Google TTS)、语速/音调/音量调节、悬浮球控制面板。快捷键:Alt+S 播放/暂停,Alt+Q 停止,Alt+←/→ 上/下一段,Alt+H 唤回面板。 // @author WorkBuddy // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect dict.youdao.com // @connect tts.baidu.com // @connect translate.googleapis.com // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ===================================================== // 音色中文名称映射表(本地系统音色) // ===================================================== const VOICE_ZH_NAME = { 'Microsoft Huihui': '微软 慧慧(普通话·女)', 'Microsoft Kangkang': '微软 康康(普通话·男)', 'Microsoft Yaoyao': '微软 瑶瑶(普通话·女童)', 'Microsoft Tracy': '微软 晓晓(粤语·女)', 'Microsoft Danny': '微软 丹尼(粤语·男)', 'Microsoft HiuGaai': '微软 晓佳(粤语·女)', 'Microsoft HiuMaan': '微软 晓曼(粤语·女)', 'Microsoft WanLung': '微软 云龙(粤语·男)', 'Microsoft Xiaoxiao': '微软 晓晓(普通话·女)', 'Microsoft Yunxi': '微软 云希(普通话·男)', 'Microsoft Yunjian': '微软 云健(普通话·男)', 'Microsoft Xiaochen': '微软 晓辰(普通话·女)', 'Microsoft Xiaohan': '微软 晓涵(普通话·女)', 'Microsoft Xiaomeng': '微软 晓梦(普通话·女)', 'Microsoft Xiaomo': '微软 晓墨(普通话·女)', 'Microsoft Xiaoqiu': '微软 晓秋(普通话·女)', 'Microsoft Xiaorui': '微软 晓睿(普通话·女)', 'Microsoft Xiaoshuang': '微软 晓双(普通话·女童)', 'Microsoft Xiaoyan': '微软 晓颜(普通话·女)', 'Microsoft Xiaoyou': '微软 晓悠(普通话·女童)', 'Microsoft Xiaozhen': '微软 晓甄(普通话·女)', 'Microsoft Yunfeng': '微软 云枫(普通话·男)', 'Microsoft Yunhao': '微软 云皓(普通话·男)', 'Microsoft Yunxia': '微软 云夏(普通话·男)', 'Microsoft Yunyang': '微软 云扬(普通话·男)', 'Microsoft Yunye': '微软 云野(普通话·男)', 'Microsoft Yunze': '微软 云泽(普通话·男)', 'Ting-Ting': '婷婷(普通话·女)', 'Sin-ji': '善慈(粤语·女)', 'Mei-Jia': '美佳(闽南话·女)', 'Yu-shu': '玉书(台湾·女)', 'Google 普通话(中国大陆)': '谷歌 普通话(大陆·女)', 'Google 粤語(香港)': '谷歌 粤语(香港·女)', 'Google 國語(臺灣)': '谷歌 国语(台湾·女)', 'Google 中文(中国大陆)': '谷歌 普通话(大陆)', }; // ===================================================== // 网络音色列表 // ===================================================== const ONLINE_VOICES = [ { id: 'youdao:0', name: '有道·女声(普通话·自然)', engine: 'youdao', type: '0' }, { id: 'youdao:1', name: '有道·男声(普通话·沉稳)', engine: 'youdao', type: '1' }, { id: 'youdao:3', name: '有道·萝莉(普通话·女童)', engine: 'youdao', type: '3' }, { id: 'youdao:4', name: '有道·大叔(普通话·低沉)', engine: 'youdao', type: '4' }, { id: 'baidu:0', name: '百度·女声(普通话·标准)', engine: 'baidu', type: '0' }, { id: 'baidu:1', name: '百度·男声(普通话·磁性)', engine: 'baidu', type: '1' }, { id: 'google:zh-CN', name: 'Google·普通话(大陆)', engine: 'google', type: 'zh-CN' }, { id: 'google:zh-TW', name: 'Google·国语(台湾)', engine: 'google', type: 'zh-TW' }, ]; // ===================================================== // 状态管理 // ===================================================== const state = { isPlaying: false, isPaused: false, utterance: null, audioEl: null, paragraphs: [], currentIndex: 0, voices: [], selectedVoiceId: GM_getValue('selectedVoiceId', ''), rate: parseFloat(GM_getValue('rate', '1.0')), pitch: parseFloat(GM_getValue('pitch', '1.0')), volume: parseFloat(GM_getValue('volume', '1.0')), highlightEl: null, panelOpen: false, voiceMode: GM_getValue('voiceMode', 'local'), onlineAbort: null, }; // ===================================================== // 样式注入 // ===================================================== GM_addStyle(` #erp-fab { position: fixed !important; bottom: 28px !important; right: 28px !important; z-index: 2147483646 !important; width: 52px !important; height: 52px !important; border-radius: 50% !important; background: linear-gradient(135deg, #7c4dff, #e040fb) !important; box-shadow: 0 4px 20px rgba(124,77,255,0.55) !important; cursor: pointer !important; border: none !important; outline: none !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 24px !important; user-select: none !important; transition: transform 0.2s, box-shadow 0.2s !important; visibility: visible !important; opacity: 1 !important; pointer-events: auto !important; } #erp-fab:hover { transform: scale(1.1) !important; box-shadow: 0 6px 28px rgba(124,77,255,0.7) !important; } #erp-fab:active { transform: scale(0.95) !important; } #erp-fab .erp-fab-dot { position: absolute !important; top: 3px !important; right: 3px !important; width: 10px !important; height: 10px !important; border-radius: 50% !important; border: 2px solid #fff !important; background: #4caf50 !important; display: none !important; } #erp-fab .erp-fab-dot.show { display: block !important; } #erp-fab .erp-fab-dot.playing { background: #7c4dff !important; animation: erp-pulse 1.2s infinite !important; } #erp-fab .erp-fab-dot.paused { background: #ff9800 !important; } @keyframes erp-pulse { 0%,100%{opacity:1;transform:scale(1);} 50%{opacity:0.5;transform:scale(1.3);} } #erp-panel { position: fixed !important; bottom: 90px !important; right: 20px !important; z-index: 2147483647 !important; width: 330px !important; background: linear-gradient(160deg, #1a1a2e 0%, #16213e 55%, #0f3460 100%) !important; border-radius: 20px !important; box-shadow: 0 10px 48px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,255,255,0.09) !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif !important; font-size: 13px !important; color: #e8eaf6 !important; user-select: none !important; overflow: hidden !important; transform-origin: bottom right !important; transition: opacity 0.2s, transform 0.2s !important; opacity: 0 !important; transform: scale(0.85) translateY(10px) !important; pointer-events: none !important; display: block !important; } #erp-panel.open { opacity: 1 !important; transform: scale(1) translateY(0) !important; pointer-events: auto !important; } #erp-panel .erp-hd { display: flex !important; align-items: center !important; justify-content: space-between !important; padding: 13px 16px 11px !important; background: rgba(255,255,255,0.05) !important; border-bottom: 1px solid rgba(255,255,255,0.07) !important; cursor: move !important; } #erp-panel .erp-hd-title { font-size: 14px !important; font-weight: 700 !important; color: #fff !important; display: flex !important; align-items: center !important; gap: 8px !important; } #erp-panel .erp-sdot { width: 8px !important; height: 8px !important; border-radius: 50% !important; background: rgba(255,255,255,0.2) !important; flex-shrink: 0 !important; transition: background 0.3s !important; } #erp-panel .erp-sdot.playing { background: #7c4dff !important; box-shadow: 0 0 6px #7c4dff !important; animation: erp-pulse 1.2s infinite !important; } #erp-panel .erp-sdot.paused { background: #ff9800 !important; } #erp-panel .erp-hd-close { width: 26px !important; height: 26px !important; border-radius: 7px !important; background: rgba(255,255,255,0.08) !important; border: none !important; color: rgba(255,255,255,0.6) !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 14px !important; transition: all 0.15s !important; } #erp-panel .erp-hd-close:hover { background: rgba(255,100,100,0.25) !important; color: #ff6b6b !important; } #erp-panel .erp-player { padding: 14px 16px 10px !important; } #erp-panel .erp-prog-bar { height: 3px !important; background: rgba(255,255,255,0.1) !important; border-radius: 3px !important; overflow: hidden !important; margin-bottom: 5px !important; } #erp-panel .erp-prog-fill { height: 100% !important; width: 0% !important; background: linear-gradient(90deg,#7c4dff,#e040fb) !important; border-radius: 3px !important; transition: width 0.4s !important; } #erp-panel .erp-prog-info { display: flex !important; justify-content: space-between !important; font-size: 11px !important; color: rgba(255,255,255,0.38) !important; margin-bottom: 12px !important; } #erp-panel .erp-controls { display: flex !important; align-items: center !important; justify-content: center !important; gap: 10px !important; } #erp-panel .erp-btn { border: none !important; cursor: pointer !important; outline: none !important; background: transparent !important; color: #e8eaf6 !important; transition: all 0.15s !important; padding: 0 !important; margin: 0 !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; font-size: 13px !important; line-height: 1 !important; } #erp-panel .erp-btn:hover { transform: scale(1.12) !important; } #erp-panel .erp-btn:active { transform: scale(0.93) !important; } #erp-panel .erp-ctrl { width: 40px !important; height: 40px !important; border-radius: 50% !important; background: rgba(255,255,255,0.09) !important; font-size: 16px !important; } #erp-panel .erp-ctrl:hover { background: rgba(255,255,255,0.16) !important; } #erp-panel .erp-play-btn { width: 52px !important; height: 52px !important; font-size: 22px !important; background: linear-gradient(135deg,#7c4dff,#e040fb) !important; box-shadow: 0 4px 18px rgba(124,77,255,0.45) !important; border-radius: 50% !important; } #erp-panel .erp-play-btn:hover { box-shadow: 0 6px 24px rgba(124,77,255,0.65) !important; } #erp-panel .erp-stop-btn { color: #ff6b6b !important; } #erp-panel .erp-stop-btn:hover { background: rgba(255,107,107,0.15) !important; } #erp-panel .erp-sep { height: 1px !important; background: rgba(255,255,255,0.07) !important; margin: 10px 0 !important; } #erp-panel .erp-settings { padding: 0 16px 14px !important; } #erp-panel .erp-row { margin-bottom: 11px !important; } #erp-panel .erp-row:last-child { margin-bottom: 0 !important; } #erp-panel .erp-lbl { display: flex !important; justify-content: space-between !important; font-size: 11px !important; color: rgba(255,255,255,0.5) !important; margin-bottom: 5px !important; font-weight: 500 !important; } #erp-panel .erp-lbl b { color: rgba(255,255,255,0.85) !important; font-weight: 600 !important; } #erp-panel .erp-tabs { display: flex !important; gap: 4px !important; margin-bottom: 8px !important; background: rgba(255,255,255,0.05) !important; border-radius: 8px !important; padding: 3px !important; } #erp-panel .erp-tab { flex: 1 !important; padding: 5px 0 !important; text-align: center !important; border-radius: 6px !important; font-size: 12px !important; font-weight: 500 !important; cursor: pointer !important; color: rgba(255,255,255,0.45) !important; border: none !important; background: transparent !important; transition: all 0.15s !important; } #erp-panel .erp-tab.active { background: linear-gradient(135deg,#7c4dff,#e040fb) !important; color: #fff !important; box-shadow: 0 2px 8px rgba(124,77,255,0.4) !important; } #erp-panel .erp-select { width: 100% !important; padding: 7px 10px !important; border-radius: 8px !important; background: rgba(255,255,255,0.08) !important; border: 1px solid rgba(255,255,255,0.11) !important; color: #e8eaf6 !important; font-size: 12px !important; cursor: pointer !important; outline: none !important; } #erp-panel .erp-select:hover, #erp-panel .erp-select:focus { background: rgba(255,255,255,0.12) !important; border-color: rgba(124,77,255,0.55) !important; } #erp-panel .erp-select option, #erp-panel .erp-select optgroup { background: #1a1a2e !important; color: #e8eaf6 !important; } #erp-panel .erp-range { width: 100% !important; height: 4px !important; -webkit-appearance: none !important; appearance: none !important; background: rgba(255,255,255,0.14) !important; border-radius: 2px !important; cursor: pointer !important; outline: none !important; } #erp-panel .erp-range::-webkit-slider-thumb { -webkit-appearance: none !important; width: 14px !important; height: 14px !important; border-radius: 50% !important; background: #7c4dff !important; cursor: pointer !important; box-shadow: 0 0 6px rgba(124,77,255,0.5) !important; } #erp-panel .erp-range::-moz-range-thumb { width: 14px !important; height: 14px !important; border-radius: 50% !important; background: #7c4dff !important; cursor: pointer !important; border: none !important; } #erp-panel .erp-tip { font-size: 11px !important; color: rgba(255,255,255,0.4) !important; text-align: center !important; padding: 4px 0 2px !important; display: none !important; } #erp-panel .erp-tip.show { display: block !important; } #erp-panel .erp-tip.err { color: #ff6b6b !important; } #erp-panel .erp-tip.ok { color: #4caf50 !important; } #erp-panel .erp-spin { display: inline-block !important; animation: erp-spin 0.8s linear infinite !important; } @keyframes erp-spin { to { transform: rotate(360deg); } } .erp-highlight { background-color: rgba(124,77,255,0.13) !important; border-left: 3px solid #7c4dff !important; padding-left: 8px !important; border-radius: 3px !important; outline: 2px solid rgba(124,77,255,0.22) !important; outline-offset: 3px !important; transition: background-color 0.3s !important; } `); // ===================================================== // 工具函数 // ===================================================== function getVoiceDisplayName(voice) { for (const key of Object.keys(VOICE_ZH_NAME)) { if (voice.name.includes(key)) return VOICE_ZH_NAME[key]; } const name = voice.name .replace(/^Microsoft\s+/i, '微软 ') .replace(/^Google\s+/i, '谷歌 '); const langMap = { 'zh-CN':'普通话', 'zh-HK':'粤语', 'zh-TW':'台湾', 'cmn-CN':'普通话', 'yue-HK':'粤语' }; return `${name}(${langMap[voice.lang] || voice.lang})`; } // ===================================================== // 提取正文段落 // ===================================================== function extractParagraphs() { const BLOCK = 'p,article,section,blockquote,h1,h2,h3,h4,h5,h6,li,dd,td'; const IGNORE = new Set(['SCRIPT','STYLE','NOSCRIPT','IFRAME','BUTTON','INPUT','TEXTAREA','SELECT','NAV','FOOTER','HEADER']); const isIgnored = el => { let n = el; while (n && n !== document.body) { if (IGNORE.has(n.tagName)) return true; n = n.parentElement; } return false; }; const isVisible = el => { const s = getComputedStyle(el); return s.display !== 'none' && s.visibility !== 'hidden' && s.opacity !== '0'; }; const SELS = ['article','main','.content','.chapter','.book-content','#content','#chapter', '.reader-content','.novel-content','.text-content','.article-content', '.post-content','[role="main"]','.entry-content','.read-content', '.story-body','.article-body','.page-content']; let container = null; for (const s of SELS) { const el = document.querySelector(s); if (el && (el.innerText || '').trim().length > 150) { container = el; break; } } if (!container) container = document.body; const all = [...container.querySelectorAll(BLOCK)]; const filtered = all.filter(el => { if (isIgnored(el) || !isVisible(el)) return false; return (el.innerText || '').trim().replace(/\s+/g, ' ').length > 15; }); // 去掉被父元素包含的重复节点 return filtered .filter((el, i) => !filtered.some((o, j) => j !== i && o.contains(el) && o !== el)) .map(el => ({ text: (el.innerText || '').trim().replace(/\s+/g, ' '), el })); } // ===================================================== // 加载本地声音列表 // ===================================================== function loadVoices() { return new Promise(resolve => { const v = speechSynthesis.getVoices(); if (v.length) { resolve(v); return; } speechSynthesis.onvoiceschanged = () => resolve(speechSynthesis.getVoices()); setTimeout(() => resolve(speechSynthesis.getVoices()), 2500); }); } // ===================================================== // 网络 TTS(GM_xmlhttpRequest,无跨域限制) // ===================================================== function playAudioBuffer(arrayBuffer, mimeType, onEnd, onError) { try { const blob = new Blob([arrayBuffer], { type: mimeType }); const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.volume = state.volume; state.audioEl = audio; audio.play().catch(onError); audio.onended = () => { URL.revokeObjectURL(url); onEnd(); }; audio.onerror = () => { URL.revokeObjectURL(url); onError(); }; } catch (e) { onError(e); } } function fetchYoudaoTTS(text, type, onReady, onError) { const chunk = text.slice(0, 200); GM_xmlhttpRequest({ method: 'GET', url: `https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(chunk)}&type=${type}`, responseType: 'arraybuffer', headers: { 'Referer': 'https://dict.youdao.com/', 'User-Agent': navigator.userAgent }, timeout: 10000, onload: r => (r.status === 200 && r.response && r.response.byteLength > 100) ? onReady(r.response, 'audio/mpeg') : onError('有道TTS状态异常:' + r.status), onerror: () => onError('有道TTS网络错误'), ontimeout:() => onError('有道TTS超时'), }); } function fetchBaiduTTS(text, per, rate, onReady, onError) { const spd = Math.round(Math.min(Math.max(rate, 0.5), 2.5) * 5); const chunk = text.slice(0, 200); GM_xmlhttpRequest({ method: 'GET', url: `https://tts.baidu.com/text2audio?lan=zh&ie=UTF-8&spd=${spd}&per=${per}&text=${encodeURIComponent(chunk)}`, responseType: 'arraybuffer', headers: { 'Referer': 'https://www.baidu.com/', 'User-Agent': navigator.userAgent }, timeout: 10000, onload: r => (r.status === 200 && r.response && r.response.byteLength > 100) ? onReady(r.response, 'audio/mpeg') : onError('百度TTS状态异常:' + r.status), onerror: () => onError('百度TTS网络错误'), ontimeout:() => onError('百度TTS超时'), }); } function fetchGoogleTTS(text, lang, onReady, onError) { const chunk = text.slice(0, 200); GM_xmlhttpRequest({ method: 'GET', url: `https://translate.googleapis.com/translate_tts?ie=UTF-8&q=${encodeURIComponent(chunk)}&tl=${lang}&client=gtx&ttsspeed=1`, responseType: 'arraybuffer', headers: { 'Referer': 'https://translate.google.com/', 'User-Agent': navigator.userAgent }, timeout: 10000, onload: r => (r.status === 200 && r.response && r.response.byteLength > 100) ? onReady(r.response, 'audio/mpeg') : onError('Google TTS状态异常:' + r.status), onerror: () => onError('Google TTS网络错误'), ontimeout:() => onError('Google TTS超时'), }); } // 统一入口,失败自动降级 function fetchOnlineTTS(text, voiceId, onReady, onError) { const vo = ONLINE_VOICES.find(v => v.id === voiceId) || ONLINE_VOICES[0]; const fallback = (err) => { // 降级到有道0 fetchYoudaoTTS(text, '0', onReady, err2 => onError(err + ' / ' + err2)); }; if (vo.engine === 'youdao') { fetchYoudaoTTS(text, vo.type, onReady, err => fetchBaiduTTS(text, '0', state.rate, onReady, err2 => onError(err + ' / ' + err2))); } else if (vo.engine === 'baidu') { fetchBaiduTTS(text, vo.type, state.rate, onReady, err => fallback(err)); } else if (vo.engine === 'google') { fetchGoogleTTS(text, vo.type, onReady, err => fallback(err)); } else { onError('未知引擎: ' + vo.engine); } } // ===================================================== // 播放控制 // ===================================================== function speak(index) { if (index < 0 || index >= state.paragraphs.length) { stopAll(); return; } abortCurrent(); state.currentIndex = index; state.isPlaying = true; state.isPaused = false; doHighlight(); scrollToEl(state.paragraphs[index].el); updateUI(); const text = state.paragraphs[index].text; const onEnd = () => { if (state.isPlaying) speak(state.currentIndex + 1); }; const onErr = () => { if (state.isPlaying) speak(state.currentIndex + 1); }; if (state.voiceMode === 'online') { const voiceId = state.selectedVoiceId || ONLINE_VOICES[0].id; setTip('⟳ 正在合成音频…'); let cancelled = false; state.onlineAbort = () => { cancelled = true; }; fetchOnlineTTS(text, voiceId, (buf, mime) => { if (cancelled || !state.isPlaying || state.currentIndex !== index) return; setTip('✅ 网络音色合成成功', 'ok'); playAudioBuffer(buf, mime, onEnd, () => { if (state.isPlaying) onErr(); }); }, (errMsg) => { if (cancelled || !state.isPlaying) return; setTip('⚠ 网络音色失败,已切换到本地音色', 'err'); speakLocal(text, onEnd, onErr); } ); } else { speakLocal(text, onEnd, onErr); } } function speakLocal(text, onEnd, onErr) { speechSynthesis.cancel(); const utter = new SpeechSynthesisUtterance(text); const voices = speechSynthesis.getVoices(); const v = voices.find(v => v.voiceURI === state.selectedVoiceId); if (v) utter.voice = v; utter.rate = state.rate; utter.pitch = state.pitch; utter.volume = state.volume; utter.onend = onEnd; utter.onerror = e => { if (e.error !== 'interrupted' && e.error !== 'canceled') onErr(); }; state.utterance = utter; speechSynthesis.speak(utter); } function abortCurrent() { speechSynthesis.cancel(); if (state.onlineAbort) { state.onlineAbort(); state.onlineAbort = null; } if (state.audioEl) { state.audioEl.pause(); state.audioEl.src = ''; state.audioEl = null; } } function pauseResume() { if (state.isPaused) { if (state.audioEl) state.audioEl.play().catch(() => {}); else speechSynthesis.resume(); state.isPaused = false; state.isPlaying = true; } else if (state.isPlaying) { if (state.audioEl) state.audioEl.pause(); else speechSynthesis.pause(); state.isPaused = true; state.isPlaying = false; } else { state.paragraphs = extractParagraphs(); if (!state.paragraphs.length) { showToast('未找到可朗读的正文,请在文章页面使用'); return; } speak(state.currentIndex || 0); return; } updateUI(); } function stopAll() { abortCurrent(); state.isPlaying = false; state.isPaused = false; clearHighlight(); setTip(''); updateUI(); } function prevPara() { if (!state.paragraphs.length) state.paragraphs = extractParagraphs(); const idx = Math.max(0, state.currentIndex - 1); const wasPlaying = state.isPlaying || state.isPaused; stopAll(); if (wasPlaying) { state.isPlaying = true; speak(idx); } else state.currentIndex = idx; } function nextPara() { if (!state.paragraphs.length) state.paragraphs = extractParagraphs(); const idx = Math.min(state.paragraphs.length - 1, state.currentIndex + 1); const wasPlaying = state.isPlaying || state.isPaused; stopAll(); if (wasPlaying) { state.isPlaying = true; speak(idx); } else state.currentIndex = idx; } // ===================================================== // 高亮与滚动 // ===================================================== function doHighlight() { clearHighlight(); const p = state.paragraphs[state.currentIndex]; if (p && p.el) { p.el.classList.add('erp-highlight'); state.highlightEl = p.el; } } function clearHighlight() { document.querySelectorAll('.erp-highlight').forEach(el => el.classList.remove('erp-highlight')); state.highlightEl = null; } function scrollToEl(el) { if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // ===================================================== // 创建 UI // ===================================================== function createUI() { if (document.getElementById('erp-fab')) return; // 悬浮球 const fab = document.createElement('button'); fab.id = 'erp-fab'; fab.title = '听书助手(Alt+S 播放)'; fab.innerHTML = `🎧`; document.body.appendChild(fab); // 悬浮面板 const panel = document.createElement('div'); panel.id = 'erp-panel'; panel.innerHTML = `