// ==UserScript==
// @name 网页文本语音播放助手
// @namespace http://tampermonkey.net/
// @version 0.21
// @description 在网页上添加浮动框,选择文本并用语音播放(支持桌面和移动设备)
// @author You
// @match http://*/*
// @match https://*/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// 动态加载 Readability.js(带完整性校验和跨域属性)
const readabilityScript = document.createElement('script');
readabilityScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/readability/0.6.0/Readability.js';
readabilityScript.integrity = 'sha512-cY9LjZzucgo2OKzTs/0J5LrG2IqeDv2CB+0JQ6O9B+J6Mu+fKZ4qI5/NxnGQq6AGx2mtsJhWLuAfBsV7gPnoZA==';
readabilityScript.crossOrigin = 'anonymous';
readabilityScript.referrerPolicy = 'no-referrer';
document.head.appendChild(readabilityScript);
// 从本地存储中获取设置或使用默认值
let apiKey = GM_getValue('tts_api_key', '');
let voiceOption = GM_getValue('tts_voice', 'FunAudioLLM/CosyVoice2-0.5B:anna');
let speedValue = GM_getValue('tts_speed', 1);
let gainValue = GM_getValue('tts_gain', 0);
let isMinimized = GM_getValue('tts_minimized', true);
let enableHighlight = GM_getValue('tts_enable_highlight', false);
// 检测是否为移动设备
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// 调整移动设备样式
const boxWidth = isMobile ? '85vw' : '300px';
const boxPosition = isMobile ? '10px' : '20px';
const textareaHeight = isMobile ? '80px' : '100px';
const fontSize = isMobile ? '14px' : '16px';
const buttonPadding = isMobile ? '8px 12px' : '5px 10px';
// 添加样式
GM_addStyle(`
#tts-floating-box {
position: fixed;
bottom: ${boxPosition};
right: ${boxPosition};
width: ${boxWidth};
max-width: 90vw;
background-color: #fff;
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
z-index: 9999;
padding: 10px;
font-family: Arial, sans-serif;
font-size: ${fontSize};
}
#tts-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
}
#tts-header h3 {
margin: 0;
font-size: ${fontSize};
}
#tts-header-buttons {
display: flex;
}
#tts-settings, #tts-minimize {
cursor: pointer;
background: none;
border: none;
font-size: ${fontSize};
padding: 0 5px;
}
#tts-text {
width: 100%;
height: ${textareaHeight};
margin-bottom: 10px;
resize: vertical;
border: 1px solid #ddd;
padding: 5px;
box-sizing: border-box;
font-size: ${fontSize};
}
#tts-controls {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
#tts-controls button {
padding: ${buttonPadding};
background-color: #4CAF50;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
margin-bottom: 5px;
font-size: ${fontSize};
}
#tts-controls button:hover {
background-color: #45a049;
}
#tts-stop {
background-color: #f44336 !important;
}
#tts-stop:hover {
background-color: #d32f2f !important;
}
#tts-translate {
background-color: #2196F3 !important;
}
#tts-translate:hover {
background-color: #0b7dda !important;
}
#tts-progress {
margin-top: 10px;
font-size: ${isMobile ? '12px' : '12px'};
}
#tts-minimized {
position: fixed;
bottom: ${boxPosition};
right: ${boxPosition};
width: ${isMobile ? '50px' : '40px'};
height: ${isMobile ? '50px' : '40px'};
background-color: #4CAF50;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
z-index: 9999;
display: flex;
justify-content: center;
align-items: center;
color: white;
font-weight: bold;
font-size: ${isMobile ? '20px' : '18px'};
}
#tts-settings-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
z-index: 10000;
display: none;
justify-content: center;
align-items: center;
}
#tts-settings-content {
width: ${isMobile ? '90vw' : '400px'};
background-color: #fff;
border-radius: 5px;
padding: 20px;
max-height: ${isMobile ? '80vh' : 'auto'};
overflow-y: ${isMobile ? 'auto' : 'visible'};
}
#tts-settings-content h3 {
margin-top: 0;
border-bottom: 1px solid #eee;
padding-bottom: 10px;
}
.tts-settings-group {
margin-bottom: 15px;
}
.tts-settings-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.tts-settings-group input, .tts-settings-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 3px;
box-sizing: border-box;
font-size: ${fontSize};
}
.tts-checkbox-container {
display: flex;
align-items: center;
}
.tts-checkbox-container input[type="checkbox"] {
width: auto;
margin-right: 10px;
}
.tts-slider-container {
display: flex;
align-items: center;
}
.tts-slider-container input[type="range"] {
flex: 1;
}
.tts-slider-value {
width: 40px;
text-align: center;
margin-left: 10px;
}
.tts-settings-buttons {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.tts-settings-buttons button {
padding: 8px 15px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
margin-left: 10px;
font-size: ${fontSize};
}
.tts-settings-buttons button.cancel {
background-color: #f44336;
}
#tts-navigation {
display: flex;
justify-content: space-between;
margin-top: 10px;
display: none;
}
#tts-navigation button {
padding: ${buttonPadding};
background-color: #FF9800;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: ${fontSize};
width: 48%;
}
#tts-navigation button:hover {
background-color: #F57C00;
}
.tts-highlight {
background-color: #FFEB3B;
color: #000;
border-radius: 2px;
box-shadow: 0 0 2px rgba(0,0,0,0.3);
}
/* 移动端特别优化 */
@media (max-width: 768px) {
#tts-controls {
flex-direction: ${isMobile ? 'column' : 'row'};
}
#tts-controls button {
width: ${isMobile ? '100%' : 'auto'};
margin-bottom: 8px;
}
}
`);
// 创建浮动框
const floatingBox = document.createElement('div');
floatingBox.id = 'tts-floating-box';
floatingBox.innerHTML = `
<div id="tts-header">
<h3>文本语音播放</h3>
<div id="tts-header-buttons">
<button id="tts-settings" title="设置">⚙️</button>
<button id="tts-minimize" title="最小化">-</button>
</div>
</div>
<textarea id="tts-text" placeholder="在此输入文本或从网页选择文本"></textarea>
<div id="tts-controls">
<button id="tts-play">播放</button>
<button id="tts-stop">停止</button>
<button id="tts-translate">翻译</button>
<button id="tts-get-selection">获取选中文本</button>
</div>
<div id="tts-navigation">
<button id="tts-prev">上一句</button>
<button id="tts-next">下一句</button>
</div>
<div id="tts-progress"></div>
`;
document.body.appendChild(floatingBox);
// 创建最小化后的图标
const minimizedIcon = document.createElement('div');
minimizedIcon.id = 'tts-minimized';
minimizedIcon.textContent = 'TTS';
document.body.appendChild(minimizedIcon);
// 创建设置窗口
const settingsModal = document.createElement('div');
settingsModal.id = 'tts-settings-modal';
settingsModal.innerHTML = `
<div id="tts-settings-content">
<h3>设置</h3>
<div class="tts-settings-group">
<label for="tts-api-key">API Key</label>
<input type="text" id="tts-api-key" placeholder="请输入API Key" value="${apiKey}">
</div>
<div class="tts-settings-group">
<label for="tts-voice-select">语音选择</label>
<select id="tts-voice-select">
<option value="FunAudioLLM/CosyVoice2-0.5B:alex" ${voiceOption === 'FunAudioLLM/CosyVoice2-0.5B:alex' ? 'selected' : ''}>Alex(男声)</option>
<option value="FunAudioLLM/CosyVoice2-0.5B:anna" ${voiceOption === 'FunAudioLLM/CosyVoice2-0.5B:anna' ? 'selected' : ''}>Anna(女声)</option>
<option value="FunAudioLLM/CosyVoice2-0.5B:bella" ${voiceOption === 'FunAudioLLM/CosyVoice2-0.5B:bella' ? 'selected' : ''}>Bella(女声)</option>
<option value="FunAudioLLM/CosyVoice2-0.5B:benjamin" ${voiceOption === 'FunAudioLLM/CosyVoice2-0.5B:benjamin' ? 'selected' : ''}>Benjamin(男声)</option>
<option value="FunAudioLLM/CosyVoice2-0.5B:charles" ${voiceOption === 'FunAudioLLM/CosyVoice2-0.5B:charles' ? 'selected' : ''}>Charles(男声)</option>
<option value="FunAudioLLM/CosyVoice2-0.5B:claire" ${voiceOption === 'FunAudioLLM/CosyVoice2-0.5B:claire' ? 'selected' : ''}>Claire(女声)</option>
<option value="FunAudioLLM/CosyVoice2-0.5B:david" ${voiceOption === 'FunAudioLLM/CosyVoice2-0.5B:david' ? 'selected' : ''}>David(男声)</option>
<option value="FunAudioLLM/CosyVoice2-0.5B:diana" ${voiceOption === 'FunAudioLLM/CosyVoice2-0.5B:diana' ? 'selected' : ''}>Diana(女声)</option>
</select>
</div>
<div class="tts-settings-group">
<label for="tts-speed-slider">语音速度(0.25-4.0)</label>
<div class="tts-slider-container">
<input type="range" id="tts-speed-slider" min="0.25" max="4" step="0.05" value="${speedValue}">
<span id="tts-speed-value" class="tts-slider-value">${speedValue}</span>
</div>
</div>
<div class="tts-settings-group">
<label for="tts-gain-slider">音量(-10至10)</label>
<div class="tts-slider-container">
<input type="range" id="tts-gain-slider" min="-10" max="10" step="0.5" value="${gainValue}">
<span id="tts-gain-value" class="tts-slider-value">${gainValue}</span>
</div>
</div>
<div class="tts-settings-group">
<div class="tts-checkbox-container">
<input type="checkbox" id="tts-enable-highlight" ${enableHighlight ? 'checked' : ''}>
<label for="tts-enable-highlight">启用文本高亮和导航</label>
</div>
</div>
<div class="tts-settings-buttons">
<button id="tts-settings-cancel" class="cancel">取消</button>
<button id="tts-settings-save">保存</button>
</div>
</div>
`;
document.body.appendChild(settingsModal);
// 播放状态管理
let isPlaying = false;
let audioQueue = [];
let currentAudio = null;
let nextAudio = null;
let textSegments = [];
let currentIndex = 0;
let translatedText = "";
let highlightElements = [];
let lastHighlightElement = null;
// 根据保存的设置决定是否最小化
if (isMinimized) {
document.getElementById('tts-floating-box').style.display = 'none';
document.getElementById('tts-minimized').style.display = 'flex';
} else {
document.getElementById('tts-floating-box').style.display = 'block';
document.getElementById('tts-minimized').style.display = 'none';
}
// 根据高亮设置显示或隐藏导航按钮
document.getElementById('tts-navigation').style.display = enableHighlight ? 'flex' : 'none';
// 最小化和恢复功能
document.getElementById('tts-minimize').addEventListener('click', function() {
document.getElementById('tts-floating-box').style.display = 'none';
document.getElementById('tts-minimized').style.display = 'flex';
isMinimized = true;
GM_setValue('tts_minimized', true);
});
document.getElementById('tts-minimized').addEventListener('click', function() {
document.getElementById('tts-floating-box').style.display = 'block';
document.getElementById('tts-minimized').style.display = 'none';
isMinimized = false;
GM_setValue('tts_minimized', false);
});
// 设置按钮事件
document.getElementById('tts-settings').addEventListener('click', function() {
document.getElementById('tts-settings-modal').style.display = 'flex';
});
// 设置窗口中的滑动条事件
document.getElementById('tts-speed-slider').addEventListener('input', function() {
document.getElementById('tts-speed-value').textContent = this.value;
});
document.getElementById('tts-gain-slider').addEventListener('input', function() {
document.getElementById('tts-gain-value').textContent = this.value;
});
// 高亮复选框事件
document.getElementById('tts-enable-highlight').addEventListener('change', function() {
document.getElementById('tts-navigation').style.display = this.checked ? 'flex' : 'none';
});
// 取消和保存设置事件
document.getElementById('tts-settings-cancel').addEventListener('click', function() {
document.getElementById('tts-settings-modal').style.display = 'none';
});
document.getElementById('tts-settings-save').addEventListener('click', function() {
// 保存设置到本地存储
apiKey = document.getElementById('tts-api-key').value;
voiceOption = document.getElementById('tts-voice-select').value;
speedValue = parseFloat(document.getElementById('tts-speed-slider').value);
gainValue = parseFloat(document.getElementById('tts-gain-slider').value);
enableHighlight = document.getElementById('tts-enable-highlight').checked;
GM_setValue('tts_api_key', apiKey);
GM_setValue('tts_voice', voiceOption);
GM_setValue('tts_speed', speedValue);
GM_setValue('tts_gain', gainValue);
GM_setValue('tts_enable_highlight', enableHighlight);
// 根据高亮设置显示或隐藏导航按钮
document.getElementById('tts-navigation').style.display = enableHighlight ? 'flex' : 'none';
document.getElementById('tts-settings-modal').style.display = 'none';
});
// 导航按钮事件
document.getElementById('tts-prev').addEventListener('click', function() {
if (currentIndex > 0) {
currentIndex--;
if (isPlaying) {
stopPlayback();
playNext();
} else {
highlightCurrentText();
}
}
});
document.getElementById('tts-next').addEventListener('click', function() {
if (currentIndex < textSegments.length - 1) {
currentIndex++;
if (isPlaying) {
stopPlayback();
playNext();
} else {
highlightCurrentText();
}
}
});
// 点击设置窗口外部关闭窗口
document.getElementById('tts-settings-modal').addEventListener('click', function(e) {
if (e.target === document.getElementById('tts-settings-modal')) {
document.getElementById('tts-settings-modal').style.display = 'none';
}
});
// 修改获取选中文本的逻辑
document.getElementById('tts-get-selection').addEventListener('click', function() {
const selectedText = window.getSelection().toString().trim();
if (selectedText) {
document.getElementById('tts-text').value = selectedText;
} else {
// 使用 Readability.js 提取主要内容
const mainArticle = extractMainArticle();
if (mainArticle) {
document.getElementById('tts-text').value = mainArticle.textContent.trim();
} else {
alert('未找到主要内容或 Readability.js 加载失败');
}
}
});
// 翻译按钮事件
document.getElementById('tts-translate').addEventListener('click', function() {
const text = document.getElementById('tts-text').value.trim();
if (!text) {
alert('请输入或选择文本');
return;
}
if (!apiKey) {
alert('请先在设置中配置API Key');
document.getElementById('tts-settings-modal').style.display = 'flex';
return;
}
document.getElementById('tts-progress').textContent = '正在翻译...';
// 调用API进行翻译
translateText(text, function(result) {
if (result) {
translatedText = result;
document.getElementById('tts-text').value = translatedText;
document.getElementById('tts-progress').textContent = '翻译完成';
} else {
document.getElementById('tts-progress').textContent = '翻译失败';
}
});
});
// 播放按钮事件
document.getElementById('tts-play').addEventListener('click', function() {
const text = document.getElementById('tts-text').value.trim();
if (!text) {
alert('请输入或选择文本');
return;
}
if (!apiKey) {
alert('请先在设置中配置API Key');
document.getElementById('tts-settings-modal').style.display = 'flex';
return;
}
if (isPlaying) {
return;
}
// 清除所有高亮
clearAllHighlights();
isPlaying = true;
document.getElementById('tts-progress').textContent = '准备播放...';
// 按标点符号拆分文本,每段最多50个字
textSegments = splitText(text);
// 如果之前已经导航过,从当前位置开始播放
// 否则从第一句开始
if (currentIndex === undefined || currentIndex < 0) {
currentIndex = 0;
}
// 开始播放
playNext();
});
// 停止按钮事件
document.getElementById('tts-stop').addEventListener('click', function() {
stopPlayback();
});
// 翻译文本
function translateText(text, callback) {
const options = {
method: 'POST',
url: 'https://api.siliconflow.cn/v1/chat/completions',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
data: JSON.stringify({
"model": "THUDM/GLM-4-9B-0414",
"messages": [
{
"role": "user",
"content":
`请将\"${text}\"按以下规则转换文本:
-请将整体内容按中文句号分隔,并翻译为中文。
-请将所有数字部分转换为中文可读的数字形式,比如1.23,你应该转换为一点二三,这样数字的小数点也可以用于读音。\n\n`
}
],
"stream": false,
"max_tokens": 1024,
"temperature": 0.7,
"top_p": 0.7,
"top_k": 50,
"frequency_penalty": 0.5,
"n": 1,
"response_format": {
"type": "text"
}
}),
onload: function(response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
if (data.choices && data.choices[0] && data.choices[0].message) {
callback(data.choices[0].message.content);
} else {
console.error('翻译API返回了异常的数据结构:', data);
callback(null);
}
} catch (e) {
console.error('无法解析翻译结果:', e);
callback(null);
}
} else {
console.error('翻译请求失败:', response.statusText);
callback(null);
}
},
onerror: function(error) {
console.error('翻译API请求错误:', error);
callback(null);
}
};
GM_xmlhttpRequest(options);
}
// 根据标点符号拆分文本,优先按完整句子拆分
function splitText(text) {
// 主要句子结束标点
const sentenceEnders = ['。', '!', '?', '.', '!', '?', '\n\n'];
// 次要分隔标点(句内停顿)
const clauseSeparators = [',', ',', ';', ';', ':', ':', '、', '\n'];
let segments = [];
let currentText = text;
// 第一步:尝试按句子结束标点拆分
while (currentText.length > 0) {
let sentenceEndIndex = -1;
// 寻找最近的句子结束标点
for (let punct of sentenceEnders) {
let index = currentText.indexOf(punct);
if (index !== -1 && (sentenceEndIndex === -1 || index < sentenceEndIndex)) {
sentenceEndIndex = index;
}
}
// 如果找到句子结束标点
if (sentenceEndIndex !== -1) {
// 提取一个完整句子(包含结束标点)
const sentence = currentText.substring(0, sentenceEndIndex + 1).trim();
// 检查句子长度,如果超过150个字符,进一步拆分
if (sentence.length > 150) {
// 按次级标点拆分长句
const subSegments = splitLongSentence(sentence, clauseSeparators);
segments = segments.concat(subSegments);
} else if (sentence.trim()) {
segments.push(sentence);
}
// 移除已处理的句子
currentText = currentText.substring(sentenceEndIndex + 1);
} else {
// 没有找到句子结束标点,尝试按次级标点拆分
if (currentText.length > 100) {
const subSegments = splitLongSentence(currentText, clauseSeparators);
segments = segments.concat(subSegments);
} else if (currentText.trim()) {
segments.push(currentText.trim());
}
currentText = '';
}
}
return segments;
}
// 按次级标点拆分长句
function splitLongSentence(sentence, punctuations) {
let segments = [];
let currentSegment = '';
let maxLength = 100; // 长句子的最大长度
for (let i = 0; i < sentence.length; i++) {
currentSegment += sentence[i];
// 如果遇到次级标点且当前段落已有一定长度,或当前段落过长
if ((punctuations.includes(sentence[i]) && currentSegment.length > 20) ||
currentSegment.length >= maxLength ||
i === sentence.length - 1) {
if (currentSegment.trim()) {
segments.push(currentSegment.trim());
}
currentSegment = '';
}
}
// 处理剩余内容
if (currentSegment.trim()) {
segments.push(currentSegment.trim());
}
return segments;
}
// 播放下一段文本
function playNext() {
if (!isPlaying || currentIndex >= textSegments.length) {
if (currentIndex >= textSegments.length) {
document.getElementById('tts-progress').textContent = '播放完成';
isPlaying = false;
}
return;
}
document.getElementById('tts-progress').textContent = `播放中 ${currentIndex + 1}/${textSegments.length}`;
// 当前要处理的文本段
const segment = textSegments[currentIndex];
// 如果启用了高亮,先高亮当前文本
if (enableHighlight) {
highlightCurrentText();
}
// 转换当前文本为语音
convertTextToSpeech(segment, function(audioData) {
// 创建音频并播放
const audio = new Audio(URL.createObjectURL(audioData));
audio.onended = function() {
currentIndex++;
playNext();
};
// 存储当前音频
currentAudio = audio;
// 播放音频
audio.play();
// 如果还有下一段,预加载下一段
if (currentIndex + 1 < textSegments.length) {
preloadNextSegment();
}
});
}
// 高亮当前文本
function highlightCurrentText() {
if (!enableHighlight || currentIndex >= textSegments.length || currentIndex < 0) {
return;
}
// 清除之前的高亮
clearAllHighlights();
const textToHighlight = textSegments[currentIndex];
// 查找页面上所有文本节点
const textNodes = findTextNodes(document.body);
// 在文本节点中查找匹配的文本并高亮
let found = false;
for (let i = 0; i < textNodes.length; i++) {
const node = textNodes[i];
const nodeText = node.nodeValue;
if (nodeText.includes(textToHighlight)) {
const startIndex = nodeText.indexOf(textToHighlight);
const endIndex = startIndex + textToHighlight.length;
// 将文本节点分割,并用高亮元素包裹匹配文本
const range = document.createRange();
const textNode = node;
// 分割文本节点
if (startIndex > 0) {
const beforeText = nodeText.substring(0, startIndex);
const beforeNode = document.createTextNode(beforeText);
node.parentNode.insertBefore(beforeNode, node);
}
// 创建高亮元素
const highlightSpan = document.createElement('span');
highlightSpan.className = 'tts-highlight';
highlightSpan.textContent = textToHighlight;
// 将高亮元素插入到原始节点之前
node.parentNode.insertBefore(highlightSpan, node);
// 修改原始节点的文本
if (endIndex < nodeText.length) {
node.nodeValue = nodeText.substring(endIndex);
} else {
node.nodeValue = '';
}
// 存储高亮元素以便后续清除
highlightElements.push(highlightSpan);
// 记录最后创建的高亮元素
lastHighlightElement = highlightSpan;
// 滚动到高亮元素位置
scrollToElement(highlightSpan);
found = true;
break;
}
}
// 如果未找到匹配文本,可以尝试部分匹配
if (!found && textToHighlight.length > 10) {
const partialText = textToHighlight.substring(0, 10);
for (let i = 0; i < textNodes.length; i++) {
const node = textNodes[i];
const nodeText = node.nodeValue;
if (nodeText.includes(partialText)) {
const startIndex = nodeText.indexOf(partialText);
// 获取可能的匹配文本
const possibleMatchLength = Math.min(textToHighlight.length, nodeText.length - startIndex);
const possibleMatch = nodeText.substring(startIndex, startIndex + possibleMatchLength);
// 创建高亮元素
const highlightSpan = document.createElement('span');
highlightSpan.className = 'tts-highlight';
highlightSpan.textContent = possibleMatch;
// 将原始节点分割
if (startIndex > 0) {
node.parentNode.insertBefore(document.createTextNode(nodeText.substring(0, startIndex)), node);
}
node.parentNode.insertBefore(highlightSpan, node);
if (startIndex + possibleMatchLength < nodeText.length) {
node.nodeValue = nodeText.substring(startIndex + possibleMatchLength);
} else {
node.nodeValue = '';
}
// 存储高亮元素
highlightElements.push(highlightSpan);
// 记录最后创建的高亮元素
lastHighlightElement = highlightSpan;
// 滚动到高亮元素位置
scrollToElement(highlightSpan);
found = true;
break;
}
}
}
}
// 查找所有文本节点
function findTextNodes(node) {
let textNodes = [];
// 忽略脚本标签、样式标签和隐藏元素中的文本
if (node.nodeName === 'SCRIPT' ||
node.nodeName === 'STYLE' ||
node.nodeName === 'NOSCRIPT' ||
(node.style && (node.style.display === 'none' || node.style.visibility === 'hidden')) ||
(node.id && typeof node.id === 'string' && node.id.startsWith('tts-'))) {
return textNodes;
}
// 如果是文本节点且有内容,添加到结果中
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
textNodes.push(node);
} else if (node.nodeType === Node.ELEMENT_NODE) {
// 递归处理子节点
for (let i = 0; i < node.childNodes.length; i++) {
const childTextNodes = findTextNodes(node.childNodes[i]);
textNodes = textNodes.concat(childTextNodes);
}
}
return textNodes;
}
// 清除所有高亮
function clearAllHighlights() {
highlightElements.forEach(element => {
if (element && element.parentNode) {
// 将高亮元素的内容合并到前一个或后一个文本节点,或创建新的文本节点
const textNode = document.createTextNode(element.textContent);
element.parentNode.replaceChild(textNode, element);
}
});
// 安全地尝试合并相邻文本节点
try {
document.body.normalize();
} catch (error) {
console.warn('合并文本节点时出错:', error);
}
// 清空高亮元素数组
highlightElements = [];
lastHighlightElement = null;
}
// 滚动到元素位置
function scrollToElement(element) {
if (!element) return;
// 计算元素的位置
const rect = element.getBoundingClientRect();
// 如果元素不在可视区域内,滚动到元素位置
if (rect.top < 0 || rect.bottom > window.innerHeight) {
// 滚动到元素位置,使其在视图中间
window.scrollTo({
top: window.pageYOffset + rect.top - (window.innerHeight / 2),
behavior: 'smooth'
});
}
}
// 预加载下一段音频
function preloadNextSegment() {
if (currentIndex + 1 < textSegments.length) {
const nextSegment = textSegments[currentIndex + 1];
convertTextToSpeech(nextSegment, function(audioData) {
// 存储下一段的音频数据,以备后用
nextAudio = {
blob: audioData,
index: currentIndex + 1
};
});
}
}
// 停止播放
function stopPlayback() {
isPlaying = false;
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
}
document.getElementById('tts-progress').textContent = '已停止';
// 如果启用了高亮功能,保留当前高亮但不清除
// 这样用户可以在暂停后使用导航按钮
}
// 调用API将文本转换为语音
function convertTextToSpeech(text, callback) {
const options = {
method: 'POST',
url: 'https://api.siliconflow.cn/v1/audio/speech',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
data: JSON.stringify({
model: "FunAudioLLM/CosyVoice2-0.5B",
input: text,
voice: voiceOption,
response_format: "mp3",
sample_rate: 32000,
stream: false,
speed: speedValue,
gain: gainValue
}),
responseType: 'blob',
onload: function(response) {
if (response.status === 200) {
callback(response.response);
} else {
console.error('语音合成失败:', response.statusText);
document.getElementById('tts-progress').textContent = '语音合成失败';
isPlaying = false;
}
},
onerror: function(error) {
console.error('API请求错误:', error);
document.getElementById('tts-progress').textContent = 'API请求错误';
isPlaying = false;
}
};
GM_xmlhttpRequest(options);
}
// 让浮动框可拖动 - 同时支持鼠标和触摸事件
makeFloatingBoxDraggable();
// 拖动功能实现 - 同时支持鼠标和触摸事件
function makeFloatingBoxDraggable() {
const box = document.getElementById('tts-floating-box');
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
const header = document.querySelector('#tts-header');
// 鼠标事件
header.onmousedown = dragMouseDown;
// 触摸事件
header.addEventListener('touchstart', dragTouchStart, { passive: false });
function dragMouseDown(e) {
// 如果点击的是最小化按钮或设置按钮,不进行拖动
if (e.target.id === 'tts-minimize' || e.target.id === 'tts-settings') {
return;
}
e = e || window.event;
e.preventDefault();
// 获取鼠标在点击时的位置
pos3 = e.clientX;
pos4 = e.clientY;
document.onmouseup = closeDragElement;
// 当鼠标移动时调用elementDrag
document.onmousemove = elementDrag;
}
function dragTouchStart(e) {
// 如果触摸的是最小化按钮或设置按钮,不进行拖动
if (e.target.id === 'tts-minimize' || e.target.id === 'tts-settings') {
return;
}
e.preventDefault();
const touch = e.touches[0];
// 获取触摸开始的位置
pos3 = touch.clientX;
pos4 = touch.clientY;
document.addEventListener('touchend', closeTouchDrag, { passive: false });
document.addEventListener('touchcancel', closeTouchDrag, { passive: false });
document.addEventListener('touchmove', elementTouchDrag, { passive: false });
}
function elementDrag(e) {
e = e || window.event;
e.preventDefault();
// 计算新位置
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
// 设置元素的新位置
setBoxPosition();
}
function elementTouchDrag(e) {
e.preventDefault();
const touch = e.touches[0];
// 计算新位置
pos1 = pos3 - touch.clientX;
pos2 = pos4 - touch.clientY;
pos3 = touch.clientX;
pos4 = touch.clientY;
// 设置元素的新位置
setBoxPosition();
}
function setBoxPosition() {
box.style.top = (box.offsetTop - pos2) + "px";
box.style.left = (box.offsetLeft - pos1) + "px";
box.style.right = 'auto';
box.style.bottom = 'auto';
}
function closeDragElement() {
// 停止鼠标移动
document.onmouseup = null;
document.onmousemove = null;
}
function closeTouchDrag() {
// 停止触摸移动
document.removeEventListener('touchend', closeTouchDrag);
document.removeEventListener('touchcancel', closeTouchDrag);
document.removeEventListener('touchmove', elementTouchDrag);
}
}
// 替换原有的 extractMainArticle 函数
function extractMainArticle() {
// 等待 Readability.js 加载完成
if (typeof Readability === 'undefined') {
console.error('Readability.js 未加载完成');
return null;
}
try {
// 创建 Readability 实例
const documentClone = document.cloneNode(true);
const readability = new Readability(documentClone);
const article = readability.parse();
if (article && article.textContent) {
return {
textContent: article.textContent,
title: article.title
};
} else {
console.error('未找到主要内容');
return null;
}
} catch (error) {
console.error('提取内容时出错:', error);
return null;
}
}
// // 调用函数并获取主要文章
// const mainArticle = extractMainArticle();
// if (mainArticle) {
// console.log('主要文章内容:', mainArticle.textContent);
// } else {
// console.log('未找到主要文章部分');
// }
})();