// ==UserScript== // @name 跳跳眼 // @namespace https://greasyfork.org/zh-CN/users/1535852-severedline // @version 2.1.1 // @description 快速阅读脚本。支持 RSVP 与高亮追踪。适用于有阅读障碍的用户。 // @author qwerty // @license Unlicense // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_getResourceText // @grant GM_getResourceURL // @resource jiebaWasmJs https://cdn.jsdelivr.net/npm/jieba-wasm@2.4.0/pkg/web/jieba_rs_wasm.js // @resource jiebaWasmBg https://cdn.jsdelivr.net/npm/jieba-wasm@2.4.0/pkg/web/jieba_rs_wasm_bg.wasm // @require https://cdn.jsdelivr.net/npm/dompurify@3.4.1/dist/purify.min.js // @require https://cdn.jsdelivr.net/npm/hotkeys-js@4.0.3/dist/hotkeys-js.min.js // @require https://cdn.jsdelivr.net/npm/@mozilla/readability@0.6.0/Readability.min.js // @downloadURL https://update.greasyfork.icu/scripts/575171/%E8%B7%B3%E8%B7%B3%E7%9C%BC.user.js // @updateURL https://update.greasyfork.icu/scripts/575171/%E8%B7%B3%E8%B7%B3%E7%9C%BC.meta.js // ==/UserScript== /* global Readability, DOMPurify, hotkeys */ (function () { 'use strict'; let overlayHost = null; let wasmEngine = null; const icons = { play: ``, pause: ``, prev: ``, next: ``, close: ``, theme: ``, mode: ``, book: ``, tts: ``, ttsOff: ``, }; async function initWasmEngine() { if (wasmEngine) return; showToast('🚀 正在加载分词引擎...'); try { let jsCode = GM_getResourceText('jiebaWasmJs'); if (!jsCode) throw new Error('无法获取 WASM JS'); jsCode = jsCode .replace(/export function ([a-zA-Z0-9_]+)/g, 'wasmSandbox.$1 = function $1') .replace(/export\s*\{[^}]+\}\s*;/g, '') .replace(/export default ([a-zA-Z0-9_]+);?/g, 'wasmSandbox.__wbg_init = $1;') .replace(/import\.meta\.url/g, 'location.href') .replace(/import\.meta/g, '{}'); const wasmSandbox = {}; const runSandbox = new Function('wasmSandbox', jsCode); runSandbox(wasmSandbox); const wasmUrl = GM_getResourceURL('jiebaWasmBg'); if (!wasmUrl) throw new Error('无法获取 WASM 二进制文件'); const wasmRes = await fetch(wasmUrl); const wasmBuffer = await wasmRes.arrayBuffer(); await wasmSandbox.__wbg_init(wasmBuffer); wasmEngine = wasmSandbox; removeToast(); } catch (error) { removeToast(); console.warn('WASM 分词加载失败,回退到浏览器内置分词器', error); wasmEngine = 'fallback'; } } document.addEventListener('keydown', (e) => { if (e.altKey && e.code === 'KeyR') { e.preventDefault(); launch(); } }); if (typeof GM_registerMenuCommand !== 'undefined') { GM_registerMenuCommand('📖 启动速读 (选中文本/全文)[Alt+R]', launch); } function createMobileFab() { if (document.getElementById('sr-mobile-fab')) return; const fab = document.createElement('div'); fab.id = 'sr-mobile-fab'; fab.innerHTML = icons.book; Object.assign(fab.style, { position: 'fixed', bottom: '30px', right: '30px', zIndex: '2147483646', width: '50px', height: '50px', borderRadius: '25px', backgroundColor: 'rgba(28, 28, 30, 0.65)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(10px)', boxShadow: '0 8px 24px rgba(0,0,0,0.15)', cursor: 'pointer', transition: 'all 0.3s ease', opacity: '0.4', }); let scrollTimeout; window.addEventListener( 'scroll', () => { fab.style.opacity = '1'; fab.style.pointerEvents = 'auto'; clearTimeout(scrollTimeout); scrollTimeout = setTimeout(() => { fab.style.opacity = '0.3'; fab.style.pointerEvents = 'none'; }, 2000); }, { passive: true }, ); fab.style.pointerEvents = 'none'; setTimeout(() => (fab.style.pointerEvents = 'auto'), 500); fab.onclick = () => launch(); document.body.appendChild(fab); } if (/Mobi|Android|iPhone/i.test(navigator.userAgent)) { createMobileFab(); } async function launch() { if (overlayHost) return; try { await initWasmEngine(); const selection = window.getSelection(); const selectionText = selection.toString().trim(); let articleTitle = document.title; let articleHTML = ''; if (selectionText) { articleTitle = '选中文本阅读'; const range = selection.getRangeAt(0); const container = document.createElement('div'); container.appendChild(range.cloneContents()); articleHTML = DOMPurify.sanitize( container.innerHTML || selectionText .split('\n') .filter((p) => p.trim()) .map((p) => `

${p}

`) .join(''), ); } else { const clone = document.cloneNode(true); const elementsToRemove = clone.querySelectorAll( 'script, style, noscript, iframe, svg, canvas, video, audio', ); elementsToRemove.forEach((el) => el.remove()); const reader = new Readability(clone); const article = reader.parse(); if (article && article.content && article.content.trim() !== '') { articleTitle = article.title; articleHTML = DOMPurify.sanitize(article.content); } else { articleHTML = document.body.innerText .split('\n') .filter((p) => p.trim()) .map((p) => `

${DOMPurify.sanitize(p)}

`) .join(''); } } buildUI(articleTitle, articleHTML); } catch (error) { console.error('启动失败流程终止', error); } } function buildUI(title, htmlContent) { const originalOverflow = document.body.style.overflow; overlayHost = document.createElement('div'); const shadow = overlayHost.attachShadow({ mode: 'open' }); shadow.innerHTML = `

${title}

备...
0 / 0 (0%)
350 WPM
`; document.documentElement.appendChild(overlayHost); document.body.style.overflow = 'hidden'; const hlContainer = shadow.getElementById('hl-container'); hlContainer.innerHTML = htmlContent; const playableWords = []; function traverseAndWrap(node) { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent; if (!text.trim()) return; let words; if (wasmEngine && wasmEngine !== 'fallback') { words = wasmEngine.cut(text, false); } else { if (typeof Intl.Segmenter === 'function') { if (!window.__sr_segmenter) { window.__sr_segmenter = new Intl.Segmenter('zh-CN', { granularity: 'word' }); } words = [...window.__sr_segmenter.segment(text)] .map((s) => s.segment) .filter((w) => w.trim().length > 0 || /[\n\r]/.test(w)); } else { words = text.split('').filter((w) => w.trim().length > 0 || /[\n\r]/.test(w)); } } const fragment = document.createDocumentFragment(); words.forEach((w) => { if (w.trim().length === 0) { fragment.appendChild(document.createTextNode(w)); } else { const span = document.createElement('span'); span.textContent = w; span.className = 'sr-word'; span.dataset.index = playableWords.length; fragment.appendChild(span); playableWords.push({ word: w.trim(), el: span }); } }); node.parentNode.replaceChild(fragment, node); } else if (node.nodeType === Node.ELEMENT_NODE) { if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'IFRAME'].includes(node.tagName.toUpperCase())) return; Array.from(node.childNodes).forEach(traverseAndWrap); } } Array.from(hlContainer.childNodes).forEach(traverseAndWrap); let isPlaying = false, currentIndex = 0, timerId = null, lastActiveEl = null; let isTtsEnabled = false; let wpm = parseInt(localStorage.getItem('sr_wpm')) || 350; let isRsvpMode = localStorage.getItem('sr_mode') !== 'false'; let isDarkMode = localStorage.getItem('sr_theme') !== null ? localStorage.getItem('sr_theme') === 'true' : window.matchMedia('(prefers-color-scheme: dark)').matches; const ui = { topBar: shadow.getElementById('top-bar'), bottomIsland: shadow.getElementById('bottom-island'), btnPlay: shadow.getElementById('btn-play'), btnPrev: shadow.getElementById('btn-prev'), btnNext: shadow.getElementById('btn-next'), btnClose: shadow.getElementById('btn-close'), btnTts: shadow.getElementById('btn-tts'), btnTheme: shadow.getElementById('btn-theme'), btnMode: shadow.getElementById('btn-mode'), rsvpBox: shadow.getElementById('rsvp-box'), rsvpView: shadow.getElementById('rsvp-view'), hlView: shadow.getElementById('highlight-view'), progSlider: shadow.getElementById('progress-slider'), progText: shadow.getElementById('progress-text'), wpmSlider: shadow.getElementById('wpm-slider'), wpmText: shadow.getElementById('wpm-text'), overlay: shadow.getElementById('overlay'), }; ui.progSlider.max = Math.max(0, playableWords.length - 1); function getORPIndex(word) { const len = word.length; if (len <= 1) return 0; if (len >= 6) return Math.ceil(len / 3); return Math.floor(len / 2); } async function playTTS() { if (!isTtsEnabled) return; window.speechSynthesis.cancel(); let textToSpeak = ''; let charIndexMap = []; for (let i = currentIndex; i < playableWords.length; i++) { let w = playableWords[i].word; charIndexMap.push({ charStart: textToSpeak.length, wordIndex: i }); textToSpeak += w; if (textToSpeak.length > 100 && /[。!?.!?\n]/.test(w)) break; if (textToSpeak.length >= 150) break; } if (!textToSpeak) return; const utterance = new SpeechSynthesisUtterance(textToSpeak); let voices = window.speechSynthesis.getVoices(); if (voices.length === 0) { await Promise.race([ new Promise((resolve) => window.speechSynthesis.addEventListener('voiceschanged', resolve, { once: true }), ), new Promise((resolve) => setTimeout(resolve, 1000)), ]); voices = window.speechSynthesis.getVoices(); } const zhVoice = voices.find((v) => v.lang === 'zh-CN' && v.localService) || voices.find((v) => v.lang === 'zh-CN') || voices.find((v) => v.lang === 'zh-TW' && v.localService) || voices.find((v) => v.lang === 'zh-TW') || voices.find((v) => v.lang === 'zh-HK') || voices.find((v) => v.lang === 'zh') || voices.find((v) => v.lang.startsWith('cmn')) || voices.find((v) => v.lang.includes('zh')) || voices[0]; if (zhVoice) { utterance.voice = zhVoice; utterance.lang = zhVoice.lang; } else { utterance.lang = 'zh-CN'; } utterance.rate = Math.max(0.5, Math.min(3.5, 0.5 + wpm / 150)); utterance.volume = 1.0; let lastSyncedIndex = -1; utterance.onboundary = (e) => { if (!isPlaying || !isTtsEnabled) return; let matchedIndex = currentIndex; for (let i = 0; i < charIndexMap.length; i++) { if (charIndexMap[i].charStart <= e.charIndex) { matchedIndex = charIndexMap[i].wordIndex; } else { break; } } if (matchedIndex !== lastSyncedIndex) { lastSyncedIndex = matchedIndex; currentIndex = matchedIndex; requestAnimationFrame(() => updateDisplay()); } }; utterance.onend = () => { if (!isPlaying || !isTtsEnabled) return; if (charIndexMap.length === 0) { currentIndex++; } else { currentIndex = charIndexMap[charIndexMap.length - 1].wordIndex + 1; } updateDisplay(); if (currentIndex < playableWords.length && isPlaying && isTtsEnabled) { requestAnimationFrame(() => playTTS()); } else { if (currentIndex >= playableWords.length) { currentIndex = playableWords.length - 1; updateDisplay(); } pause(); } }; utterance.onerror = (e) => { if (e.error === 'interrupted' || e.error === 'canceled') return; console.warn('TTS 错误:', e.error, e); if (e.error === 'language-unavailable' || e.error === 'voice-unavailable') { console.warn('中文语音不可用,尝试使用默认语音'); const fallback = new SpeechSynthesisUtterance(textToSpeak); fallback.lang = 'zh-CN'; fallback.rate = utterance.rate; fallback.onboundary = utterance.onboundary; fallback.onend = utterance.onend; fallback.onerror = () => { console.warn('回退语音也失败了'); pause(); }; window.speechSynthesis.speak(fallback); return; } pause(); }; window.speechSynthesis.speak(utterance); } function updateDisplay() { if (playableWords.length === 0) return; const current = playableWords[currentIndex]; const wordText = current.word; const orp = getORPIndex(wordText); const prefix = wordText.substring(0, orp); const focal = wordText.substring(orp, orp + 1); const suffix = wordText.substring(orp + 1); ui.rsvpBox.innerHTML = `${prefix}
${focal}
${suffix}`; if (lastActiveEl) { lastActiveEl.classList.remove('active'); } if (current.el) { current.el.classList.add('active'); lastActiveEl = current.el; if (!isRsvpMode) { const c = ui.hlView; const elTop = current.el.offsetTop; const cTop = c.scrollTop; const cHeight = c.clientHeight; if (elTop < cTop + cHeight * 0.2 || elTop > cTop + cHeight * 0.8) { current.el.scrollIntoView({ behavior: wpm > 300 ? 'auto' : 'smooth', block: 'center' }); } } } ui.progSlider.value = currentIndex; const percent = Math.floor(((currentIndex + 1) / playableWords.length) * 100) || 0; ui.progText.textContent = `${currentIndex + 1} / ${playableWords.length} (${percent}%)`; } function play() { if (isPlaying || currentIndex >= playableWords.length) return; isPlaying = true; ui.btnPlay.innerHTML = icons.pause; ui.bottomIsland.classList.add('playing'); ui.topBar.classList.add('playing'); if (isTtsEnabled) { playTTS(); } else { queueNextWord(); } } function pause() { isPlaying = false; ui.btnPlay.innerHTML = icons.play; ui.bottomIsland.classList.remove('playing'); ui.topBar.classList.remove('playing'); clearTimeout(timerId); window.speechSynthesis.cancel(); } function queueNextWord() { if (!isPlaying) return; updateDisplay(); const word = playableWords[currentIndex].word; currentIndex++; if (currentIndex >= playableWords.length) { pause(); currentIndex = playableWords.length - 1; updateDisplay(); return; } let delay = 60000 / wpm; if (/[.!?。!?…]/.test(word)) { delay *= Math.max(1.5, 2.5 - wpm / 500); } else if (/[,;:,;:、——]/.test(word)) { delay *= Math.max(1.2, 1.8 - wpm / 800); } else if (/[\n\r]/.test(word)) { delay *= Math.max(1.5, 2.2 - wpm / 600); } else if (word.length >= 4) { delay *= Math.min(1.4, 1 + (word.length - 3) * 0.05); } timerId = setTimeout(queueNextWord, delay); } function seek(offset) { currentIndex = Math.max(0, Math.min(playableWords.length - 1, currentIndex + offset)); updateDisplay(); if (isPlaying && isTtsEnabled) playTTS(); } function setWpm(val) { wpm = Math.max(100, Math.min(1000, val)); ui.wpmSlider.value = wpm; ui.wpmText.textContent = `${wpm} WPM`; if (isPlaying && isTtsEnabled) playTTS(); } hotkeys.setScope('swiftread'); hotkeys('space', 'swiftread', (e) => { e.preventDefault(); isPlaying ? pause() : play(); }); hotkeys('left', 'swiftread', (e) => { e.preventDefault(); seek(-1); }); hotkeys('right', 'swiftread', (e) => { e.preventDefault(); seek(1); }); hotkeys('up', 'swiftread', (e) => { e.preventDefault(); setWpm(wpm + 25); }); hotkeys('down', 'swiftread', (e) => { e.preventDefault(); setWpm(wpm - 25); }); hotkeys('esc', 'swiftread', (e) => { e.preventDefault(); ui.btnClose.click(); }); ui.btnPlay.onclick = () => (isPlaying ? pause() : play()); ui.btnPrev.onclick = () => seek(-10); ui.btnNext.onclick = () => seek(10); ui.btnClose.onclick = () => { pause(); clearTimeout(timerId); hotkeys.unbind('space,left,right,up,down,esc', 'swiftread'); hotkeys.deleteScope('swiftread'); playableWords.length = 0; lastActiveEl = null; if (hlContainer) { hlContainer.innerHTML = ''; } Object.keys(ui).forEach((key) => (ui[key] = null)); overlayHost.remove(); overlayHost = null; document.body.style.overflow = originalOverflow; }; if (isDarkMode) ui.overlay.classList.add('dark-mode'); ui.rsvpView.style.display = isRsvpMode ? 'flex' : 'none'; ui.hlView.style.display = isRsvpMode ? 'none' : 'block'; setWpm(wpm); ui.btnTts.innerHTML = isTtsEnabled ? icons.tts : icons.ttsOff; ui.btnTts.onclick = () => { isTtsEnabled = !isTtsEnabled; ui.btnTts.innerHTML = isTtsEnabled ? icons.tts : icons.ttsOff; if (!isTtsEnabled) window.speechSynthesis.cancel(); if (isPlaying) { pause(); play(); } }; ui.btnTheme.onclick = () => { const isDark = ui.overlay.classList.toggle('dark-mode'); localStorage.setItem('sr_theme', isDark); }; ui.btnMode.onclick = () => { isRsvpMode = !isRsvpMode; localStorage.setItem('sr_mode', isRsvpMode); ui.rsvpView.style.display = isRsvpMode ? 'flex' : 'none'; ui.hlView.style.display = isRsvpMode ? 'none' : 'block'; updateDisplay(); }; ui.wpmSlider.oninput = (e) => { setWpm(parseInt(e.target.value)); localStorage.setItem('sr_wpm', e.target.value); }; ui.progSlider.oninput = (e) => { if (isPlaying) pause(); currentIndex = parseInt(e.target.value); updateDisplay(); }; setTimeout(() => ui.overlay.focus(), 100); ui.rsvpView.onclick = () => (isPlaying ? pause() : play()); ui.hlView.onclick = (e) => { if (window.getSelection().toString().trim().length > 0) return; if (e.target.classList.contains('sr-word')) { if (isPlaying) pause(); currentIndex = parseInt(e.target.dataset.index, 10); updateDisplay(); return; } isPlaying ? pause() : play(); }; updateDisplay(); } let toastEl = null; function showToast(msg) { if (toastEl) return; toastEl = document.createElement('div'); toastEl.textContent = msg; Object.assign(toastEl.style, { position: 'fixed', top: '40px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(28,28,30,0.85)', color: '#fff', padding: '14px 28px', borderRadius: '30px', zIndex: '2147483647', fontFamily: 'system-ui, sans-serif', fontSize: '15px', fontWeight: '500', backdropFilter: 'blur(10px)', boxShadow: '0 12px 32px rgba(0,0,0,0.2)', }); document.documentElement.appendChild(toastEl); } function removeToast() { if (toastEl) { toastEl.remove(); toastEl = null; } } })();