双语网页朗读器

在页面右下角插入一个固定朗读按钮,滚动时自动隐藏/显示。更智能地朗读网页标题与正文,点击按钮可暂停/继续播放,支持移动端。使用 Summary TTS (GET /api/aiyue)。改进了正文提取逻辑,尽量只朗读主要段落,并尝试排除图像来源等短文本。添加了简单的页面语言检测功能和手动语言选择。现在在强制语言模式下,会尝试只朗读该语言的段落或行,对翻译页面更友好。默认语言设置为中文。

// ==UserScript==
// @name         双语网页朗读器
// @namespace    http://tampermonkey.net/
// @version      2.3
// @description  在页面右下角插入一个固定朗读按钮,滚动时自动隐藏/显示。更智能地朗读网页标题与正文,点击按钮可暂停/继续播放,支持移动端。使用 Summary TTS (GET /api/aiyue)。改进了正文提取逻辑,尽量只朗读主要段落,并尝试排除图像来源等短文本。添加了简单的页面语言检测功能和手动语言选择。现在在强制语言模式下,会尝试只朗读该语言的段落或行,对翻译页面更友好。默认语言设置为中文。
// @author       You
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // --- Configuration ---
    const ttsDomains = [
        'https://ms-ra-forwarder-for-ifreetime-beta-two.vercel.app/',
    ];
    const chineseVoice = 'zh-CN-XiaoxiaoNeural';
    const englishVoice = 'en-US-EricNeural';
    const barHeight = '40px';
    const scrollThreshold = 10;
    const minMainContentLength = 200;
    const minParagraphLengthForReading = 20; // Minimum length for a *paragraph* to be considered main content
    const minLineLengthForReading = 10; // Minimum length for a *line* within a paragraph when filtering
    let forcedLanguage = 'zh'; // 默认强制语言为中文

    // --- UI Setup ---
    const fixedBar = document.createElement('div');
    Object.assign(fixedBar.style, {
        position: 'fixed',
        bottom: '0',
        right: '10px', // 修改为右下角, 并添加10px的右边距
        width: 'auto', // 宽度自适应内容
        height: barHeight,
        backgroundColor: 'transparent', // 移除背景色
        color: '#333',
        zIndex: 9998,
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'flex-end', // 内容靠右对齐
        transition: 'transform 0.3s ease-in-out',
        transform: 'translateY(0)',
        padding: '0' // 移除内边距
    });
    document.body.appendChild(fixedBar);

    const buttonContainer = document.createElement('div');
    Object.assign(buttonContainer.style, {
        display: 'flex',
        gap: '10px',
        alignItems: 'center'
    });
    fixedBar.appendChild(buttonContainer);

    const readButton = document.createElement('button');
    readButton.textContent = '朗读';
    Object.assign(readButton.style, {
        backgroundColor: '#409EFF',
        color: 'white',
        border: 'none',
        padding: '8px 16px',
        borderRadius: '4px',
        fontSize: '14px',
        cursor: 'pointer',
        boxShadow: 'none'
    });
    buttonContainer.appendChild(readButton);

    const langSelect = document.createElement('select');
    const autoOption = document.createElement('option');
    autoOption.value = 'auto';
    autoOption.textContent = '自动检测';
    langSelect.appendChild(autoOption);

    const zhOption = document.createElement('option');
    zhOption.value = 'zh';
    zhOption.textContent = '中文';
    langSelect.appendChild(zhOption);
    zhOption.selected = true; // 默认选中中文

    const enOption = document.createElement('option');
    enOption.value = 'en';
    enOption.textContent = '英文';
    langSelect.appendChild(enOption);

    Object.assign(langSelect.style, {
        padding: '8px',
        borderRadius: '4px',
        border: '1px solid #ccc',
        fontSize: '14px',
        cursor: 'pointer'
    });
    buttonContainer.appendChild(langSelect);

    // Ensure padding bottom is applied if body height is less than viewport height
    // This handles cases where content doesn't fill the screen initially
     if (document.body.scrollHeight <= window.innerHeight) {
        document.body.style.paddingBottom = barHeight;
     } else {
        // If content is tall enough to scroll, let the scroll listener handle padding
        // Add a small initial padding just in case, the listener will adjust
         document.body.style.paddingBottom = '1px';
     }


    // --- Scroll Hide/Show Logic ---
    let lastScrollTop = 0;
    let isBarVisible = true;

    window.addEventListener('scroll', () => {
        const currentScrollTop = window.scrollY || document.documentElement.scrollTop;

        //Only hide if scrolling down and not near the top
        if (currentScrollTop > lastScrollTop && currentScrollTop > parseInt(barHeight, 10)) {
            if (isBarVisible) {
                fixedBar.style.transform = `translateY(${barHeight})`;
                isBarVisible = false;
            }
        } else if (currentScrollTop < lastScrollTop || currentScrollTop <= parseInt(barHeight, 10)) { // Always show when scrolling up or at the top
            if (!isBarVisible) {
                fixedBar.style.transform = 'translateY(0)';
                isBarVisible = true;
            }
        }

        lastScrollTop = currentScrollTop;
    }, { passive: true }); // Use passive listener for better performance


    // --- Audio Playback State ---
    let audio = null;
    let isPlaying = false;
    // --- 语言检测函数 (改进细节) ---
    function detectLanguageFromText(text) {
        // Use a more robust check, excluding common punctuation, numbers, and symbols
        const cleanedText = text.replace(/[\s\d\p{P}\p{S}]/gu, ''); // Remove whitespace, digits, punctuation, symbols

        const chineseChars = cleanedText.match(/[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]/g); // CJK Unified Ideographs etc.
        const englishChars = cleanedText.match(/[a-zA-Z]/g);
        const chineseCount = chineseChars ? chineseChars.length : 0;
        const englishCount = englishChars ? englishChars.length : 0;
        // If very little "clean" text, detection is unreliable
         if (chineseCount + englishCount < 5) { // Adjusted threshold for very short segments
             return 'unknown'; // Cannot reliably detect
         }

         // Consider a passage Chinese if Chinese characters significantly outnumber English characters
         const ratioThreshold = 2; // Chinese chars should be at least this many times more than English

         if (chineseCount > englishCount * ratioThreshold) {
             return 'zh';
         }
         // Consider a passage English if English characters significantly outnumber Chinese characters
         if (englishCount > chineseCount * ratioThreshold) {
             return 'en';
         }

         // If the ratio is not met, default based on which has more chars
         if (chineseCount > englishCount) {
             return 'zh'; // Dominantly Chinese but not strongly
         } else {
             return 'en'; // Dominantly English or ambiguous
         }
    }


    // --- 确定朗读声音 (不变) ---
    function determineVoice(text) {
        if (forcedLanguage === 'zh') {
            console.log('朗读器: 用户强制选择语言: 中文');
            return chineseVoice;
        }
        if (forcedLanguage === 'en') {
            console.log('朗读器: 用户强制选择语言: 英文');
            return englishVoice;
        }

        // Fallback to page lang attribute if no forced language
        const langAttribute = document.documentElement.lang ||
        document.body.lang || '';
        const lowerLangAttribute = langAttribute.toLowerCase();

        if (lowerLangAttribute.startsWith('zh')) {
            console.log('朗读器: 从lang属性检测到语言: 中文');
            return chineseVoice;
        }
        if (lowerLangAttribute.startsWith('en')) {
            console.log('朗读器: 从lang属性检测到语言: 英文');
            return englishVoice;
        }

        // Fallback to text content detection if no forced language or lang attribute
        const detectedLangFromText = detectLanguageFromText(text);
        console.log('朗读器: 从文本内容检测到语言:', detectedLangFromText === 'zh' ? '中文' : '英文');
        return detectedLangFromText === 'zh' ? chineseVoice : englishVoice;
    }

    // --- TTS 播放函数 (不变) ---
    async function playSummaryTTS(text, voiceName) {
        if (!text || text.trim().length < 10) {
             console.warn('朗读器: 没有足够的文本可朗读.');
             alert('没有可朗读的文本内容。'); // 更友好的提示
             readButton.textContent = '朗读';
             isPlaying = false;
             if (audio) {
                 audio.pause();
                 audio.src = '';
                 audio = null;
             }
             return;
         }

        const query = new URLSearchParams({
            text: text,
            voiceName: voiceName,
            speed: 0
        }).toString();
        for (const domain of ttsDomains) {
            const ttsUrl = `${domain}api/aiyue?${query}`;
            console.log(`朗读器: 尝试TTS请求: ${ttsUrl}`);

            try {
                if (audio) {
                    audio.pause();
                    audio.src = '';
                    audio = null;
                }

                audio = new Audio(ttsUrl);
                audio.play();
                isPlaying = true;
                readButton.textContent = '暂停朗读';

                audio.onended = () => {
                    console.log('朗读器: 音频播放结束');
                    readButton.textContent = '朗读';
                    isPlaying = false;
                    audio = null;
                };
                audio.onerror = (e) => {
                    console.error('朗读器: 音频播放出错:', e);
                    alert('音频播放出错');
                    readButton.textContent = '朗读';
                    isPlaying = false;
                    audio = null;
                };
                // If successful, break the loop
                return;
            } catch (e) {
                console.error(`朗读器: TTS请求失败 (${domain}):`, e);
            }
        }

        // If the loop finishes without returning (all domains failed)
        console.error('朗读器: 所有TTS源请求失败或音频播放出错。');
        alert('所有TTS源请求失败或音频播放出错。');
        readButton.textContent = '朗读';
        isPlaying = false;
        audio = null;
    }

    // --- 更智能的提取标题 + 正文 (改进细节) ---
    // This function extracts potential text, filtering happens later if forced language is set
    function extractTitleAndBody() {
        const title = document.querySelector('h1')?.innerText ||
        document.title || '';
        let mainContentElement = null;
        let maxTextLength = 0;
        const potentialCandidates = document.querySelectorAll(
            'main, article, .article-body, .entry-content, .post-content, #main-content, #article, #content'
        );
        potentialCandidates.forEach(element => {
            // Use textContent for potentially more accurate text without hidden elements
            const text = element.textContent?.trim() || ''; // Add nullish coalescing for safety
            if (text.length > maxTextLength) {
                maxTextLength = text.length;
                mainContentElement = element;
            }
        });
        if (!mainContentElement || maxTextLength < minMainContentLength) {
            // Fallback if no strong candidate is found
            mainContentElement = document.body;
            console.log('朗读器: 使用 document.body 作为主要内容区域 (Fallback)');
        } else {
             console.log('朗读器: 确定的主要内容区域:', mainContentElement.tagName || mainContentElement.id || mainContentElement.className);
        }


        let bodyText = '';
        if (mainContentElement) {
             // Prefer paragraphs first
            const paragraphs = Array.from(mainContentElement.querySelectorAll('p'));
            const paragraphText = paragraphs
                .filter(p => {
                    const text = p.innerText?.trim() || ''; // Use innerText for visible text
                    // Filter out short paragraphs or image captions that might not be main content
                    // Also exclude common non-content text patterns
                    return text.length >= minParagraphLengthForReading &&
                           !/^\s*图(?:片)?说(?:明)?:?\s*(\d+\.)?/i.test(text) && // Exclude image captions
                           !/^\s*(相关|标签|上一篇|下一篇|分享|评论|作者|来源|日期)\s*[::]?\s*[\s\S]*$/i.test(text); // Exclude metadata lines
                })
                .map(p => p.innerText?.trim() || '')
                .join('\n\n');
            // If paragraph extraction is insufficient, use the full text content and try to clean
            if (paragraphText.length < minMainContentLength / 2) {
                 console.log('朗读器: 段落提取不足,尝试清理整个区域文本');
                 bodyText = mainContentElement.textContent?.trim() || ''; // Use textContent for full text
                 // Remove text from known noise elements
                 const noiseSelectors = 'nav, aside, .sidebar, .comment-section, #comments, .related-posts, .footer, header, footer, style, script';
                 const noiseElements = mainContentElement.querySelectorAll(noiseSelectors);
                 noiseElements.forEach(el => {
                     const noiseText = el.textContent?.trim() || '';
                     if (noiseText.length > 0) {
                          // Replace noise block text with newlines to help separate content
                        bodyText = bodyText.split(noiseText).join('\n\n');
                     }
                 });
            } else {
                 console.log('朗读器: 使用段落提取结果');
                 bodyText = paragraphText;
            }

            // Clean up multiple newlines and spaces after combining/cleaning
             bodyText = bodyText.replace(/\n\s*\n/g, '\n\n').replace(/[ \t]+/g, ' ').trim(); // Replace tabs and multiple spaces with single space
        }

        // Combine title and body, clean up extra spaces/newlines at the start/end
        let fullText = `${title.trim()}.
${bodyText.trim()}`.trim();

        // Remove leading ". " if title was empty
        if (fullText.startsWith('. ')) {
            fullText = fullText.substring(2).trim();
        }

        console.log(`朗读器: 提取文本 (前200字): "${fullText.slice(0, 200)}..."`);
        console.log(`朗读器: 提取文本总长度: ${fullText.length}`);
        // Limit the total text length sent to TTS to avoid potential issues
        return fullText.slice(0, 4000); // Increased limit slightly, adjust if needed
    }

    // --- 语言选择下拉框事件 ---
    langSelect.addEventListener('change', () => {
        forcedLanguage = langSelect.value === 'auto' ? null : langSelect.value;
        console.log('朗读器: 用户选择语言:', forcedLanguage || '自动检测');
    // If audio is currently playing, stop it as the language/content filter might change
    if (audio && !audio.ended && !audio.error) {
         console.log('朗读器: 语言选择改变,停止当前朗读');
         audio.pause();
         audio.src = '';
         audio = null;
         isPlaying = false;
         readButton.textContent = '朗读';
    }
});
    // --- 按钮点击事件 ---
    readButton.addEventListener('click', () => {
        if (audio && !audio.ended && !audio
.error) {
if (isPlaying) {
console.log('朗读器: 暂停播放');
audio.pause();
isPlaying = false;
readButton.textContent = '继续朗读';
} else {
if (audio.paused) {
console.log('朗读器: 继续播放');
audio.play();
isPlaying = true;
readButton.textContent = '暂停朗读';
} else {
console.warn('朗读器: 处于未知音频状态,尝试重新朗读。');
// Fallback to starting a new read if state is weird
startNewRead();
}
}
} else {
console.log('朗读器: 开始新的朗读');
startNewRead();
}
});
// Function to handle starting a new read
function startNewRead() {
const fullExtractedText = extractTitleAndBody();
if (fullExtractedText.length < 10) {
alert('找不到可朗读的主要正文内容。');
readButton.textContent = '朗读';
return;
}
     // Determine the voice based on forced language or auto-detection of the *full* text
     const selectedVoice = determineVoice(fullExtractedText);
     let textToRead = fullExtractedText;

     // --- NEW: Filter text based on forced language (using line-based check) ---
     if (forcedLanguage === 'zh' || forcedLanguage === 'en') {
         console.log(`朗读器: 根据强制语言 "${forcedLanguage}" 过滤文本 (行级别检查)`);
         // Split into paragraphs first, then process lines within paragraphs
         const paragraphs = fullExtractedText.split(/\n\n+/);
         const filteredParagraphs = [];

         paragraphs.forEach(paragraph => {
             const lines = paragraph.split('\n'); // Split paragraph into lines by single newlines
             const filteredLines = lines.filter(line => {
                 const text = line.trim();
                if (text.length < minLineLengthForReading) { // Use a lower threshold for lines
                      return false; // Exclude short lines
                 }
                 // Check language of the individual line
                 const detectedLangForLine = detectLanguageFromText(text);
                 // Keep the line only if its detected language matches the forced language
                 return detectedLangForLine === forcedLanguage;
             });

             if (filteredLines.length > 0) {
                 // Join lines within the paragraph back with single newlines
                 filteredParagraphs.push(filteredLines.join('\n'));
             }
         });
         // Join filtered paragraphs back with double newlines
         textToRead = filteredParagraphs.join('\n\n');

         if (textToRead.trim().length === 0) {
             console.warn('朗读器: 过滤后没有找到匹配强制语言的文本.');
             alert(`根据您的语言选择(${forcedLanguage === 'zh' ? '中文' : '英文'}),没有找到匹配的文本段落或行。\n\n提示:如果使用了翻译工具,请确保页面已完全翻译,并等待几秒钟再尝试朗读。`);
             readButton.textContent = '朗读';
             return;
         }
          console.log(`朗读器: 过滤后文本长度: ${textToRead.length}`);
          console.log(`朗读器: 过滤后文本 (前200字): "${textToRead.slice(0, 200)}..."`);
     }
     // --- END NEW Filtering ---


     playSummaryTTS(textToRead, selectedVoice);
}

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址