小說朗讀助手

自動朗讀小說,支援跨域同步、實體化反白、點擊跳轉。針對筆趣閣等網站進行極致過濾,解決標題重複及廣告干擾問題。

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         小說朗讀助手
// @namespace    http://tampermonkey.net/
// @version      5.8
// @description  自動朗讀小說,支援跨域同步、實體化反白、點擊跳轉。針對筆趣閣等網站進行極致過濾,解決標題重複及廣告干擾問題。
// @author       Antigravity
// @license      MIT
// @match        *://*/*
// @match        https://*.linovelib.com/novel/*/*.html
// @match        https://*.biquge.com/*
// @match        https://*.biquuge.com/*
// @match        https://*.bqg.org/*
// @match        https://*.69shuba.cx/*
// @match        https://*.wa01.com/novel/pagea/*.html
// @match        https://*.ttkan.co/novel/pagea/*.html
// @match        https://ttk.tw/novel/chapters/*/*.html
// @match        https://czbooks.net/n/*/*
// @match        https://www.wenku8.net/novel/*/*/*.htm
// @match        https://mp.weixin.qq.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addValueChangeListener
// ==/UserScript==
(function() {
    'use strict';
    // --- 1. 網站專屬設定 ---
    const siteConfig = {
        "linovelib.com": { selector: "#acontent, .read-content", clean: ['.run-text', '.img_box', '.div-img'] },
        "wa01.com": { selector: ".content" },
        "ttkan.co": { selector: ".content" },
        "ttk.tw": { selector: ".content" },
        "czbooks.net": { selector: ".content" },
        "wenku8.net": { selector: "#content" },
        "qq.com": { selector: "#js_content" },
        "69shuba": { selector: ".txtnav" },
        "biqu": { 
            selector: "#content, #htmlContent, .content", 
            clean: ['.bottem1', '.bottem2', '.bookname', '.read_app', '#app_read', '.footer', '.header', '.navbar', 'script', 'style', '.ads', '.divimage', 'h1'] 
        },
        "bqg": { 
            selector: "#content", 
            clean: ['.bottem1', '.bottem2', '.bookname'] 
        }
    };
    // --- 2. 智慧判斷 ---
    const isNovelPage = () => {
        const url = window.location.href;
        if (url.includes('fanqienovel.com')) return false;
        for (let domain in siteConfig) { if (url.includes(domain)) return true; }
        if (url.match(/(chapter|read|html|\d+\/\d+)/)) {
            if (document.body.innerText.includes('下一章') || document.body.innerText.includes('下一页') || document.body.innerText.includes('下一頁')) {
                return true;
            }
        }
        return false;
    };
    if (!isNovelPage()) return;
    if (document.getElementById('tts-overlay')) return;
    // --- 3. 注入 CSS ---
    const style = document.createElement('style');
    style.textContent = `
        .tts-sentence { cursor: pointer; transition: background-color 0.2s; border-radius: 3px; }
        .tts-sentence:hover { background-color: #e3f2fd; }
        .tts-active { background-color: #fff9c4 !important; color: #d50000 !important; font-weight: bold; box-shadow: 0 0 5px rgba(255, 193, 7, 0.5); }
    `;
    document.head.appendChild(style);
    const savedRate = localStorage.getItem('tts-rate') || '1.2';
    // --- 4. 介面構建 ---
    const overlay = document.createElement('div');
    overlay.id = 'tts-overlay';
    overlay.style.cssText = `
        position: fixed; bottom: 20px; right: 20px; width: 50px; height: 50px;
        background: #2196f3; border-radius: 25px; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
        z-index: 99999; display: flex; align-items: center; justify-content: center;
        color: white; font-size: 24px; cursor: pointer; transition: all 0.3s;
    `;
    overlay.innerHTML = '🎧';
    document.body.appendChild(overlay);
    const panel = document.createElement('div');
    panel.id = 'tts-panel';
    panel.style.cssText = `
        position: fixed; bottom: 80px; right: 20px; width: 300px; background: white;
        padding: 15px; border-radius: 10px; box-shadow: 0 4px 20px rgba(0,0,0,0.2);
        z-index: 99999; display: none; font-family: -apple-system, system-ui;
    `;
    panel.innerHTML = `
        <div style="display:flex;justify-content:space-between;margin-bottom:15px;">
            <h3 style="margin:0;font-size:16px;">朗讀控制</h3>
            <span id="tts-status" style="font-size:12px;color:#666;">準備就緒</span>
        </div>
        <div style="margin-bottom:15px;">
            <div style="display:flex;justify-content:space-between;margin-bottom:5px;">
                <span style="font-size:14px;">進度</span>
                <span id="tts-progress-val" style="font-size:14px;">0%</span>
            </div>
            <input type="range" id="tts-progress" min="0" max="100" value="0" style="width:100%; cursor: pointer;">
        </div>
        <div style="display:flex;gap:10px;margin-bottom:15px;">
            <button id="tts-play" style="flex:1;padding:10px;background:#2196f3;color:white;border:none;border-radius:8px;font-size:16px;">▶ 播放</button>
            <button id="tts-pause" style="flex:1;padding:10px;background:#f5f5f5;color:#333;border:none;border-radius:8px;font-size:16px;">⏸ 暫停</button>
        </div>
        <div style="margin-bottom:10px;">
            <div style="display:flex;justify-content:space-between;margin-bottom:5px;">
                <span style="font-size:14px;">語速</span>
                <span id="tts-rate-val" style="font-size:14px;">${savedRate}x</span>
            </div>
            <input type="range" id="tts-rate" min="0.5" max="2" step="0.1" value="${savedRate}" style="width:100%">
        </div>
        <label style="display:flex;align-items:center;gap:8px;font-size:14px;">
            <input type="checkbox" id="tts-auto-next" checked> 自動翻頁播放
        </label>
        <label style="display:flex;align-items:center;gap:8px;font-size:14px;margin-top:5px;">
            <input type="checkbox" id="tts-auto-scroll" checked> 自動捲動畫面
        </label>
    `;
    document.body.appendChild(panel);
    let synth = window.speechSynthesis;
    let utterance = null;
    let idx = 0;
    let isPlaying = false;
    let isDragging = false;
    let playlistElements = []; 
    const tabId = Math.random().toString(36).substr(2, 9);
    GM_addValueChangeListener('tts_active_tab', function(name, oldVal, newVal, remote) {
        if (newVal !== tabId && isPlaying) {
            stopPlayback(true);
        }
    });
    overlay.onclick = () => {
        panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
    };
    // --- 5. 核心:內容處理 ---
    function prepareContent() {
        playlistElements = [];
        let contentElement = null;
        let config = null;
        const url = window.location.href;
        for (let domain in siteConfig) {
            if (url.includes(domain)) {
                config = siteConfig[domain];
                const selectors = config.selector.split(', ');
                for (let sel of selectors) {
                    let el = document.querySelector(sel);
                    if (el) { contentElement = el; break; }
                }
                break;
            }
        }
        if (!contentElement) {
            const commonSelectors = ['#content', '#chaptercontent', '.read-content', '.novel-content', '#BookText', '.txtnav', '#text', '.entry-content', '#acontent'];
            for (let sel of commonSelectors) {
                let el = document.querySelector(sel);
                if (el && el.innerText.length > 200) { contentElement = el; break; }
            }
        }
        
        if (!contentElement) contentElement = document.body;
        if (config && config.clean) {
            config.clean.forEach(sel => contentElement.querySelectorAll(sel).forEach(el => el.remove()));
        }
        contentElement.querySelectorAll('script, style, noscript, iframe, .ads, .advertisement').forEach(el => el.remove());
        const walker = document.createTreeWalker(contentElement, NodeFilter.SHOW_TEXT, {
            acceptNode: function(node) {
                if (!node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
                const parent = node.parentElement;
                if (['script', 'style', 'noscript', 'iframe'].includes(parent.tagName.toLowerCase())) return NodeFilter.FILTER_REJECT;
                if (parent.classList.contains('tts-sentence')) return NodeFilter.FILTER_REJECT;
                if (parent.closest('#tts-overlay') || parent.closest('#tts-panel')) return NodeFilter.FILTER_REJECT;
                return NodeFilter.FILTER_ACCEPT;
            }
        }, false);
        const nodesToProcess = [];
        let currentNode;
        while (currentNode = walker.nextNode()) nodesToProcess.push(currentNode);
        const filterKeywords = [
            '上一章', '下一章', '返回目录', '加入书签', '推荐本书', '章节错误', 'Copyright', 
            'All rights reserved', '下载APP', '广告', '手机阅读', '追看新章节', '下载本站客户端',
            '本站所有收录', '网站地图', '存书签', '换手', '关灯', '字体', '登录', '注册',
            '首页', '玄幻', '武侠', '都市', '历史', '网游', '科幻', '言情', '排行', '完本',
            '笔趣阁', '搜索', '其他', '目录'
        ];
        let lastText = "";
        const pageTitle = document.title.replace(/[\s\-_].*$/, '').trim();
        nodesToProcess.forEach(node => {
            const text = node.nodeValue;
            let skip = false;
            
            for (let k of filterKeywords) { 
                if (text.includes(k)) { 
                    skip = true; 
                    break; 
                } 
            }
            
            if (skip || /第\s*[\(\(]\s*\d+\s*[\/\\]\s*\d+\s*[\)\)]\s*页/.test(text)) return;
            if (/^[\s\d\.\-\_\|]+$/.test(text)) return;
            const sentences = text.split(/([。!?;\n]+)/);
            const fragment = document.createDocumentFragment();
            let hasContent = false;
            
            for (let i = 0; i < sentences.length; i++) {
                const part = sentences[i].trim();
                if (!part) continue;
                if (part === lastText) continue;
                
                if (playlistElements.length < 3 && (part.includes(pageTitle) || pageTitle.includes(part))) {
                    continue;
                }
                
                const span = document.createElement('span');
                span.className = 'tts-sentence';
                span.textContent = sentences[i]; 
                span.onclick = (e) => {
                    e.stopPropagation();
                    const index = playlistElements.indexOf(span);
                    if (index !== -1) {
                        idx = index;
                        if (isPlaying) { synth.cancel(); speakNext(); } else { play(); }
                    }
                };
                fragment.appendChild(span);
                
                if (part.length > 1 && !/^[。!?;\n\s]+$/.test(part)) {
                    playlistElements.push(span);
                    lastText = part;
                    hasContent = true;
                }
            }
            if (hasContent) node.parentNode.replaceChild(fragment, node);
        });
    }
    function clearHighlight() {
        document.querySelectorAll('.tts-active').forEach(el => el.classList.remove('tts-active'));
    }
    function highlightAndScroll(element) {
        clearHighlight();
        if (element) {
            element.classList.add('tts-active');
            if (document.getElementById('tts-auto-scroll').checked) {
                element.scrollIntoView({behavior: "smooth", block: "center"});
            }
        }
    }
    function stopPlayback(isExternal = false) {
        isPlaying = false;
        synth.cancel();
        updateUI(false);
        clearHighlight();
        if (isExternal) {
            document.getElementById('tts-status').innerText = '已由其他分頁接手';
        }
    }
    function play() {
        if(isPlaying) return;
        GM_setValue('tts_active_tab', tabId);
        synth.cancel();
        
        if (playlistElements.length === 0 || !document.body.contains(playlistElements[0])) {
            prepareContent();
        }
        
        if (playlistElements.length === 0) { 
            document.getElementById('tts-status').innerText = '內容載入中...';
            setTimeout(play, 1000);
            return; 
        }
        const progress = document.getElementById('tts-progress');
        progress.max = playlistElements.length - 1;
        progress.value = idx;
        
        isPlaying = true;
        updateUI(true);
        setTimeout(speakNext, 100);
    }
    function speakNext() {
        if(!isPlaying) return;
        if (GM_getValue('tts_active_tab') !== tabId) {
            stopPlayback(true);
            return;
        }
        
        if(idx >= playlistElements.length) {
            document.getElementById('tts-status').innerText = '本章結束';
            clearHighlight();
            if(document.getElementById('tts-auto-next').checked) {
                goToNextChapter();
            } else {
                isPlaying = false;
                updateUI(false);
            }
            return;
        }
        if (!isDragging) {
            const progress = document.getElementById('tts-progress');
            progress.value = idx;
            const percent = Math.round((idx / playlistElements.length) * 100);
            document.getElementById('tts-progress-val').innerText = percent + '%';
            document.getElementById('tts-status').innerText = `朗讀中 ${percent}%`;
        }
        const element = playlistElements[idx];
        const text = element.textContent;
        highlightAndScroll(element);
        utterance = new SpeechSynthesisUtterance(text);
        utterance.rate = document.getElementById('tts-rate').value;
        utterance.lang = 'zh-TW';
        
        utterance.onend = () => { if (isPlaying) { idx++; speakNext(); } };
        utterance.onerror = (e) => { if(isPlaying && e.error !== 'interrupted') { idx++; speakNext(); } };
        synth.speak(utterance);
    }
    function goToNextChapter() {
        document.getElementById('tts-status').innerText = '正在跳轉...';
        const links = document.querySelectorAll('a');
        let nextLink = null;
        for(let link of links) {
            const t = link.innerText;
            if(t.includes('下一章') || t.includes('下一页') || t.includes('下一頁') || (t.toLowerCase().includes('next') && t.length < 10)) {
                nextLink = link;
                break;
            }
        }
        if (!nextLink) {
            const nextBtn = document.querySelector('.next-chapter') || document.querySelector('#next_url');
            if (nextBtn) nextLink = nextBtn;
        }
        if (nextLink) {
            localStorage.setItem('tts-autoplay', 'true');
            nextLink.click();
        } else {
            alert('找不到下一章連結');
            isPlaying = false;
            updateUI(false);
        }
    }
    function updateUI(playing) {
        const playBtn = document.getElementById('tts-play');
        const pauseBtn = document.getElementById('tts-pause');
        if(playing) {
            playBtn.style.background = '#4caf50';
            playBtn.innerText = '朗讀中';
            pauseBtn.innerText = '⏸ 暫停';
        } else {
            playBtn.style.background = '#2196f3';
            playBtn.innerText = '▶ 播放';
            pauseBtn.innerText = '▶ 繼續';
        }
    }
    const progressBar = document.getElementById('tts-progress');
    progressBar.oninput = function() {
        isDragging = true;
        idx = parseInt(this.value);
        const percent = Math.round((idx / playlistElements.length) * 100);
        document.getElementById('tts-progress-val').innerText = percent + '%';
    };
    progressBar.onchange = function() {
        isDragging = false;
        idx = parseInt(this.value);
        if (isPlaying) {
            synth.cancel();
            setTimeout(() => { if (!synth.speaking) speakNext(); }, 100);
        }
    };
    document.getElementById('tts-play').onclick = play;
    document.getElementById('tts-pause').onclick = () => {
        if (isPlaying) {
            isPlaying = false;
            synth.cancel();
            updateUI(false);
        } else {
            play();
        }
    };
    document.getElementById('tts-rate').oninput = function() {
        document.getElementById('tts-rate-val').innerText = this.value + 'x';
        localStorage.setItem('tts-rate', this.value);
    };
    document.getElementById('tts-rate').onchange = function() {
        if (isPlaying) {
            synth.cancel();
            setTimeout(() => { if (!synth.speaking) speakNext(); }, 100);
        }
    };
    if(localStorage.getItem('tts-autoplay') === 'true') {
        localStorage.removeItem('tts-autoplay');
        setTimeout(() => { panel.style.display = 'block'; play(); }, 1500);
    }
})();