// ==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);
}
})();