1.购买服务器阿里云:服务器购买地址https://t.aliyun.com/U/Bg6shY若失效,可用地址
阿里云:
服务器购买地址
https://t.aliyun.com/U/Bg6shY若失效,可用地址
https://www.aliyun.com/daily-act/ecs/activity_selection?source=5176.29345612&userCode=49hts92d腾讯云:
https://curl.qcloud.com/wJpWmSfU若失效,可用地址
https://cloud.tencent.com/act/cps/redirect?redirect=2446&cps_key=ad201ee2ef3b771157f72ee5464b1fea&from=console华为云
https://activity.huaweicloud.com/cps.html?fromacct=64b5cf7cc11b4840bb4ed2ea0b2f4468&utm_source=V1g3MDY4NTY=&utm_medium=cps&utm_campaign=2019052.部署教程
3.代码如下
(function() {'use strict';// 默认配置const DEFAULT_CONFIG = {region: '',token: '',voice: 'zh-CN-XiaoxiaoNeural',rate: '1.0',pitch: '0Hz',ttsEngine: 'azure', // 'azure' 或 'system'systemVoice: '', // 系统语音名称playPauseKey: 'Space',rereadKey: 'KeyR',autoStart: true,readTitle: true,readContent: true,playMode: 'normal' // 'normal': 正常结束, 'loop': 循环播放};// 全局变量let config = {};let contentQueue = [];let currentIndex = 0;let isPlaying = false;let isPaused = false;let settingsWindow = null;let availableVoices = [];let currentAudio = null;let currentUrl = '';let userNavigatedWhilePlaying = false; // 用户在朗读时导航的标记let justFinishedReading = false; // 刚完成朗读的标记let reloadTimeoutId = null;let floatingControl = null;// CSS样式const styles = `.folo-reader-panel {position: fixed;top: 20px;right: 20px;width: 350px;background: white;border: 1px solid #ccc;border-radius: 8px;box-shadow: 0 4px 12px rgba(0,0,0,0.15);z-index: 10000;font-family: Arial, sans-serif;font-size: 14px;}.folo-reader-header {background: #007acc;color: white;padding: 10px 15px;border-radius: 8px 8px 0 0;display: flex;justify-content: space-between;align-items: center;cursor: move;}.folo-reader-title {font-weight: bold;font-size: 16px;}.folo-reader-controls {display: flex;gap: 5px;}.folo-reader-btn {background: rgba(255,255,255,0.2);border: none;color: white;width: 24px;height: 24px;border-radius: 4px;cursor: pointer;display: flex;align-items: center;justify-content: center;font-size: 12px;}.folo-reader-btn:hover {background: rgba(255,255,255,0.3);}.folo-reader-content {padding: 15px;max-height: 400px;overflow-y: auto;}.folo-reader-content.minimized {display: none;}.setting-group {margin-bottom: 15px;}.setting-label {display: block;margin-bottom: 5px;font-weight: bold;color: #333;}.setting-input {width: 100%;padding: 8px;border: 1px solid #ddd;border-radius: 4px;box-sizing: border-box;}.setting-select {width: 100%;padding: 8px;border: 1px solid #ddd;border-radius: 4px;box-sizing: border-box;}.setting-button {background: #007acc;color: white;border: none;padding: 8px 15px;border-radius: 4px;cursor: pointer;margin-right: 5px;margin-bottom: 5px;}.setting-button:hover {background: #005999;}.setting-button.secondary {background: #6c757d;}.setting-button.secondary:hover {background: #545b62;}.status-indicator {display: inline-block;width: 8px;height: 8px;border-radius: 50%;margin-right: 5px;}.status-success {background: #28a745;}.status-error {background: #dc3545;}.status-warning {background: #ffc107;}.progress-info {margin-top: 10px;padding: 10px;background: #f8f9fa;border-radius: 4px;font-size: 12px;}.key-binding {display: flex;align-items: center;margin-bottom: 8px;}.key-label {width: 80px;font-size: 12px;}.key-input {flex: 1;padding: 4px 8px;border: 1px solid #ddd;border-radius: 4px;font-size: 12px;}/* 悬浮播放控制面板样式 */.folo-floating-control {position: fixed;bottom: 30px;right: 30px;background: linear-gradient(135deg, #007acc, #0056b3);border-radius: 50px;padding: 12px 20px;box-shadow: 0 6px 20px rgba(0,122,204,0.3);z-index: 9999;font-family: Arial, sans-serif;display: flex;align-items: center;gap: 12px;cursor: move;transition: all 0.3s ease;backdrop-filter: blur(10px);border: 1px solid rgba(255,255,255,0.2);}.folo-floating-control:hover {box-shadow: 0 8px 25px rgba(0,122,204,0.4);transform: translateY(-2px);}.folo-control-btn {background: rgba(255,255,255,0.15);border: none;color: white;width: 36px;height: 36px;border-radius: 50%;cursor: pointer;display: flex;align-items: center;justify-content: center;font-size: 14px;font-weight: bold;transition: all 0.2s ease;backdrop-filter: blur(5px);}.folo-control-btn:hover {background: rgba(255,255,255,0.25);transform: scale(1.1);}.folo-control-btn:active {transform: scale(0.95);}.folo-control-btn.play-pause {width: 42px;height: 42px;font-size: 16px;background: rgba(255,255,255,0.2);}.folo-control-btn.play-pause:hover {background: rgba(255,255,255,0.3);}.folo-control-status {color: white;font-size: 12px;font-weight: 500;max-width: 150px;overflow: hidden;white-space: nowrap;text-overflow: ellipsis;text-shadow: 0 1px 2px rgba(0,0,0,0.3);}.folo-progress-indicator {color: rgba(255,255,255,0.8);font-size: 10px;background: rgba(255,255,255,0.1);padding: 2px 6px;border-radius: 10px;backdrop-filter: blur(5px);}`;// 初始化function init() {console.log('Folo自动阅读脚本已启动');// 记录初始URLcurrentUrl = window.location.href;// 加载配置loadConfig();// 添加样式addStyles();// 创建设置面板createSettingsPanel();// 创建悬浮控制面板createFloatingControl();// 绑定键盘事件bindKeyEvents();// 添加导航监听setupNavigationListener();// 注册菜单命令GM_registerMenuCommand('打开设置面板', showSettingsPanel);// 等待页面加载完成后开始自动阅读if (document.readyState === 'loading') {document.addEventListener('DOMContentLoaded', () => {// DOMContentLoaded后再等待一点时间确保动态内容加载setTimeout(startAutoReading, 1000);});} else {// 页面已经加载完成,稍等一下再开始setTimeout(startAutoReading, 1000);}}// 加载配置function loadConfig() {config = Object.assign({}, DEFAULT_CONFIG);for (let key in DEFAULT_CONFIG) {const savedValue = GM_getValue(key);if (savedValue !== undefined) {config[key] = savedValue;}}// 如果GM存储中没有region和token,尝试从Cookie加载if ((!config.region || !config.token)) {loadFromCookie();}console.log('配置加载完成:', {hasRegion: !!config.region,hasToken: !!config.token,region: config.region});}// 保存配置function saveConfig() {for (let key in config) {GM_setValue(key, config[key]);}// 同时保存到cookie作为备份saveToCookie();}// 保存到Cookiefunction saveToCookie() {if (config.region && config.token) {const configData = {region: config.region,token: config.token,timestamp: Date.now()};// 设置cookie过期时间为30天const expires = new Date();expires.setTime(expires.getTime() + (30 * 24 * 60 * 60 * 1000));const cookieValue = encodeURIComponent(JSON.stringify(configData));const cookieString = `folo_reader_config=${cookieValue}; expires=${expires.toUTCString()}; path=/`;document.cookie = cookieString;console.log('配置已保存到Cookie:', {region: configData.region,tokenLength: configData.token.length,expires: expires.toUTCString()});} else {console.warn('配置不完整,跳过Cookie保存:', {hasRegion: !!config.region,hasToken: !!config.token});}}// 从Cookie加载配置function loadFromCookie() {const cookies = document.cookie.split(';');for (let cookie of cookies) {const [name, value] = cookie.trim().split('=');if (name === 'folo_reader_config') {try {const configData = JSON.parse(decodeURIComponent(value));if (configData.region && configData.token) {// 如果GM存储中没有配置,则使用Cookie中的配置if (!config.region || !config.token) {config.region = configData.region;config.token = configData.token;// 保存到GM存储GM_setValue('region', config.region);GM_setValue('token', config.token);console.log('从Cookie加载配置成功并同步到GM存储');return true;}}} catch (error) {console.error('解析Cookie配置失败:', error);}break;}}return false;}// 检查并提示输入必要配置function checkAndPromptConfig() {return new Promise((resolve, reject) => {// 如果已有配置,直接返回if (config.region && config.token) {resolve(true);return;}// 尝试从Cookie加载if (loadFromCookie() && config.region && config.token) {// 同步到设置面板UIupdateConfigUI();resolve(true);return;}// 显示配置提示弹窗showConfigPrompt().then((success) => {if (success) {console.log('配置弹窗完成,已保存配置');resolve(true);} else {reject(new Error('用户取消配置'));}}).catch(reject);});}// 显示配置提示弹窗function showConfigPrompt() {return new Promise((resolve) => {// 创建提示弹窗const promptDialog = document.createElement('div');promptDialog.style.cssText = `position: fixed;top: 0;left: 0;width: 100%;height: 100%;background: rgba(0, 0, 0, 0.5);z-index: 99999;display: flex;align-items: center;justify-content: center;font-family: Arial, sans-serif;`;promptDialog.innerHTML = `<div style="background: white;padding: 30px;border-radius: 12px;box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);max-width: 500px;width: 90%;"><h3 style="margin: 0 0 20px 0; color: #333; text-align: center;">🎵 Folo自动阅读配置</h3><p style="margin: 0 0 20px 0; color: #666; line-height: 1.5;">需要配置Azure语音服务才能使用朗读功能:</p><div style="margin-bottom: 15px;"><label style="display: block; margin-bottom: 5px; font-weight: bold; color: #333;">Azure区域 (如: eastasia, westus2):</label><input type="text" id="promptRegion" placeholder="例如: eastasia" style="width: 100%;padding: 10px;border: 1px solid #ddd;border-radius: 6px;box-sizing: border-box;font-size: 14px;"></div><div style="margin-bottom: 20px;"><label style="display: block; margin-bottom: 5px; font-weight: bold; color: #333;">Azure语音服务密钥:</label><input type="password" id="promptToken" placeholder="输入您的Azure语音服务密钥" style="width: 100%;padding: 10px;border: 1px solid #ddd;border-radius: 6px;box-sizing: border-box;font-size: 14px;"><small style="color: #999; font-size: 12px;">配置将安全保存在本地,用于语音合成服务</small></div><div style="text-align: center;"><button id="promptConfirm" style="background: #007acc;color: white;border: none;padding: 12px 24px;border-radius: 6px;cursor: pointer;font-size: 14px;margin-right: 10px;">确认配置</button><button id="promptCancel" style="background: #6c757d;color: white;border: none;padding: 12px 24px;border-radius: 6px;cursor: pointer;font-size: 14px;">取消</button></div><div style="margin-top: 15px; text-align: center;"><small style="color: #999; font-size: 11px;">💡 提示: 也可以通过右下角悬浮面板的设置按钮进行配置</small></div></div>`;document.body.appendChild(promptDialog);// 绑定事件const regionInput = promptDialog.querySelector('#promptRegion');const tokenInput = promptDialog.querySelector('#promptToken');const confirmBtn = promptDialog.querySelector('#promptConfirm');const cancelBtn = promptDialog.querySelector('#promptCancel');// 如果有部分配置,预填充if (settingsWindow) {const panel = settingsWindow;const currentRegion = panel.querySelector('#regionInput').value;const currentToken = panel.querySelector('#tokenInput').value;regionInput.value = currentRegion || config.region || '';tokenInput.value = currentToken || config.token || '';} else {regionInput.value = config.region || '';tokenInput.value = config.token || '';}confirmBtn.addEventListener('click', () => {const region = regionInput.value.trim();const token = tokenInput.value.trim();if (!region || !token) {alert('请填写完整的区域和密钥信息');return;}config.region = region;config.token = token;// 保存配置到本地存储和CookiesaveConfig();// 同步到设置面板UIupdateConfigUI();console.log('配置已保存并同步到设置面板');document.body.removeChild(promptDialog);resolve(true);});cancelBtn.addEventListener('click', () => {document.body.removeChild(promptDialog);resolve(false);});// ESC键取消const handleEsc = (e) => {if (e.key === 'Escape') {document.body.removeChild(promptDialog);document.removeEventListener('keydown', handleEsc);resolve(false);}};document.addEventListener('keydown', handleEsc);// 聚焦到第一个输入框setTimeout(() => regionInput.focus(), 100);});}// 添加样式function addStyles() {const styleSheet = document.createElement('style');styleSheet.textContent = styles;document.head.appendChild(styleSheet);}// 创建设置面板function createSettingsPanel() {const panel = document.createElement('div');panel.className = 'folo-reader-panel';panel.innerHTML = `<div class="folo-reader-header"><div class="folo-reader-title">Folo自动阅读设置</div><div class="folo-reader-controls"><button class="folo-reader-btn" id="minimizeBtn" title="最小化">−</button><button class="folo-reader-btn" id="closeBtn" title="关闭">×</button></div></div><div class="folo-reader-content" id="panelContent"><div class="setting-group"><label class="setting-label">TTS引擎:</label><select class="setting-select" id="ttsEngineSelect"><option value="azure">Azure TTS</option><option value="system">系统TTS</option></select></div><div class="setting-group" id="azureSettings"><label class="setting-label">Azure语音区域:</label><input type="text" class="setting-input" id="regionInput" placeholder="例如: eastasia"></div><div class="setting-group" id="azureTokenGroup"><label class="setting-label">Azure语音Token:</label><input type="password" class="setting-input" id="tokenInput" placeholder="输入您的Azure语音服务Token"></div><div class="setting-group" id="azureTestGroup"><button class="setting-button" id="testConnectionBtn">测试连接</button><span id="connectionStatus"></span></div><div class="setting-group" id="voiceGroup"><label class="setting-label">语音选择:</label><select class="setting-select" id="voiceSelect"><option value="">正在加载语音列表...</option></select><select class="setting-select" id="systemVoiceSelect" style="display: none;"><option value="">正在加载系统语音...</option></select></div><div class="setting-group" id="voiceTestGroup"><button class="setting-button" id="testVoiceBtn">测试发音</button></div><div class="setting-group"><label class="setting-label">语速 (0.5-2.0):</label><input type="range" min="0.5" max="2.0" step="0.1" id="rateSlider" class="setting-input"><span id="rateValue">1.0</span></div><div class="setting-group"><label class="setting-label">音调 (-200Hz到+200Hz):</label><input type="range" min="-200" max="200" step="10" id="pitchSlider" class="setting-input"><span id="pitchValue">0Hz</span></div><div class="setting-group"><div class="setting-group"><label class="setting-label">快捷键设置:</label><div class="key-binding"><span class="key-label">播放/暂停:</span><input type="text" class="key-input" id="playPauseKeyInput" readonly></div><div class="key-binding"><span class="key-label">重新朗读:</span><input type="text" class="key-input" id="rereadKeyInput" readonly></div></div><div class="setting-group"><label class="setting-label">朗读内容选择:</label><div style="display: flex; gap: 15px; align-items: center;"><label style="display: flex; align-items: center; gap: 5px; font-weight: normal;"><input type="checkbox" id="readTitleCheckbox" style="margin: 0;"><span>朗读标题</span></label><label style="display: flex; align-items: center; gap: 5px; font-weight: normal;"><input type="checkbox" id="readContentCheckbox" style="margin: 0;"><span>朗读内容</span></label></div><small style="color: #666; font-size: 11px; margin-top: 5px; display: block;">💡 可以根据频道特点选择只读标题或只读内容,或者两者都读</small></div><div class="setting-group"><label class="setting-label">播放模式:</label><select class="setting-select" id="playModeSelect"><option value="normal">正常模式 - 播放完成后停止</option><option value="loop">循环模式 - 自动重复当前条目</option></select><small style="color: #666; font-size: 11px; margin-top: 5px; display: block;">🎵 循环模式会重复播放当前文章</small></div><div class="setting-group"><button class="setting-button" id="saveBtn">保存设置</button><button class="setting-button secondary" id="resetBtn">重置默认</button></div><div class="setting-group"><button class="setting-button secondary" id="refreshContentBtn">重新提取内容</button><button class="setting-button secondary" id="debugContentBtn">调试内容提取</button></div><div class="progress-info" id="progressInfo"><div>状态: <span id="readerStatus">未开始</span></div><div>进度: <span id="readerProgress">0/0</span></div><div>当前内容: <span id="currentContent">无</span></div></div></div>`;document.body.appendChild(panel);settingsWindow = panel;// 绑定面板事件bindPanelEvents();// 加载当前配置到界面loadConfigToUI();// 默认隐藏面板panel.style.display = 'none';}// 创建悬浮控制面板function createFloatingControl() {const control = document.createElement('div');control.className = 'folo-floating-control';control.innerHTML = `<button class="folo-control-btn play-pause" id="floatingPlayBtn" title="播放/暂停">▶</button><div class="folo-control-status" id="floatingStatus">准备就绪</div><div class="folo-progress-indicator" id="floatingProgress">0/0</div><button class="folo-control-btn" id="floatingSettingsBtn" title="打开设置">⚙</button>`;document.body.appendChild(control);floatingControl = control;// 绑定悬浮控制面板事件bindFloatingControlEvents();// 使悬浮面板可拖拽makeElementDraggable(control, control);// 初始化状态updateFloatingControlStatus();}// 绑定悬浮控制面板事件function bindFloatingControlEvents() {const control = floatingControl;// 播放/暂停按钮control.querySelector('#floatingPlayBtn').addEventListener('click', (e) => {e.stopPropagation();e.preventDefault();console.log('悬浮播放按钮被点击,当前状态:', { isPlaying, isPaused });try {togglePlayPause();} catch (error) {console.error('悬浮播放按钮处理出错:', error);updateStatus('播放控制出错: ' + error.message);}});// 设置按钮control.querySelector('#floatingSettingsBtn').addEventListener('click', (e) => {e.stopPropagation();showSettingsPanel();});// 双击悬浮面板重新加载内容control.addEventListener('dblclick', (e) => {e.stopPropagation();reloadContent();});}// 更新悬浮控制面板状态function updateFloatingControlStatus() {if (!floatingControl) {console.log('悬浮控制面板不存在,跳过状态更新');return;}try {const playBtn = floatingControl.querySelector('#floatingPlayBtn');const statusText = floatingControl.querySelector('#floatingStatus');const progressText = floatingControl.querySelector('#floatingProgress');if (!playBtn || !statusText || !progressText) {console.error('悬浮控制面板元素不完整');return;}console.log('更新悬浮控制面板状态:', { isPlaying, isPaused, currentIndex, queueLength: contentQueue.length });// 更新播放按钮if (isPlaying && !isPaused) {playBtn.textContent = '⏸';playBtn.title = '暂停';} else {playBtn.textContent = '▶';playBtn.title = '播放';}// 更新状态文本let status = '准备就绪';if (isPlaying && !isPaused) {const currentContent = contentQueue[currentIndex];if (currentContent) {switch(currentContent.type) {case 'title':status = '标题';break;case 'article-paragraph':status = '段落';break;case 'article-heading':status = '文章标题';break;case 'quoted-text':status = '引用';break;case 'article-sentence':status = '句子';break;default:status = '朗读中';}} else {status = '朗读中';}} else if (isPaused) {status = '已暂停';} else if (contentQueue.length === 0) {status = '无内容';} else {// 显示当前配置状态const modeText = {'normal': '普通','loop': '循环','sequential': '顺序'}[config.playMode] || '普通';if (config.readTitle && config.readContent) {status = `标题+内容·${modeText}`;} else if (config.readTitle) {status = `仅标题·${modeText}`;} else if (config.readContent) {status = `仅内容·${modeText}`;} else {status = '未配置';}}statusText.textContent = status;// 更新进度if (contentQueue.length > 0) {progressText.textContent = `${currentIndex + 1}/${contentQueue.length}`;} else {progressText.textContent = '0/0';}} catch (error) {console.error('更新悬浮控制面板状态时出错:', error);}}// 绑定面板事件function bindPanelEvents() {const panel = settingsWindow;// 最小化按钮panel.querySelector('#minimizeBtn').addEventListener('click', () => {const content = panel.querySelector('#panelContent');content.classList.toggle('minimized');const btn = panel.querySelector('#minimizeBtn');btn.textContent = content.classList.contains('minimized') ? '+' : '−';});// 关闭按钮panel.querySelector('#closeBtn').addEventListener('click', hideSettingsPanel);// 测试连接按钮panel.querySelector('#testConnectionBtn').addEventListener('click', testConnection);// 测试发音按钮panel.querySelector('#testVoiceBtn').addEventListener('click', testVoice);// 保存设置按钮panel.querySelector('#saveBtn').addEventListener('click', saveSettings);// 重置默认按钮panel.querySelector('#resetBtn').addEventListener('click', resetToDefault);// 重新提取内容按钮panel.querySelector('#refreshContentBtn').addEventListener('click', () => {contentQueue = extractContent();currentIndex = 0;updateProgress();updateStatus(`重新提取完成,共${contentQueue.length}段内容`);});// 调试内容提取按钮panel.querySelector('#debugContentBtn').addEventListener('click', debugContentExtraction);// 语速滑块const rateSlider = panel.querySelector('#rateSlider');const rateValue = panel.querySelector('#rateValue');rateSlider.addEventListener('input', (e) => {rateValue.textContent = e.target.value;});// 音调滑块const pitchSlider = panel.querySelector('#pitchSlider');const pitchValue = panel.querySelector('#pitchValue');pitchSlider.addEventListener('input', (e) => {pitchValue.textContent = e.target.value + 'Hz';});// TTS引擎切换const ttsEngineSelect = panel.querySelector('#ttsEngineSelect');ttsEngineSelect.addEventListener('change', (e) => {const isAzure = e.target.value === 'azure';// 显示/隐藏相关设置组panel.querySelector('#azureSettings').style.display = isAzure ? 'block' : 'none';panel.querySelector('#azureTokenGroup').style.display = isAzure ? 'block' : 'none';panel.querySelector('#azureTestGroup').style.display = isAzure ? 'block' : 'none';// 切换语音选择器panel.querySelector('#voiceSelect').style.display = isAzure ? 'block' : 'none';panel.querySelector('#systemVoiceSelect').style.display = isAzure ? 'none' : 'block';// 如果切换到系统TTS,加载系统语音if (!isAzure) {loadSystemVoices();}});// 保留的快捷键输入(播放暂停和重新阅读)const keyInputs = ['playPauseKeyInput', 'rereadKeyInput'];keyInputs.forEach(inputId => {const input = panel.querySelector('#' + inputId);input.addEventListener('keydown', (e) => {e.preventDefault();input.value = e.code;});});// 拖拽功能makeElementDraggable(panel, panel.querySelector('.folo-reader-header'));// 朗读选项变化时的提示const readTitleCheckbox = panel.querySelector('#readTitleCheckbox');const readContentCheckbox = panel.querySelector('#readContentCheckbox');readTitleCheckbox.addEventListener('change', () => {if (!readTitleCheckbox.checked && !readContentCheckbox.checked) {alert('至少需要选择一种内容类型进行朗读!');readTitleCheckbox.checked = true;}});readContentCheckbox.addEventListener('change', () => {if (!readTitleCheckbox.checked && !readContentCheckbox.checked) {alert('至少需要选择一种内容类型进行朗读!');readContentCheckbox.checked = true;}});}// 加载配置到UIfunction loadConfigToUI() {const panel = settingsWindow;if (!panel) return;panel.querySelector('#ttsEngineSelect').value = config.ttsEngine || 'azure';panel.querySelector('#regionInput').value = config.region;panel.querySelector('#tokenInput').value = config.token;panel.querySelector('#rateSlider').value = config.rate;panel.querySelector('#rateValue').textContent = config.rate;panel.querySelector('#pitchSlider').value = config.pitch.replace('Hz', '');panel.querySelector('#pitchValue').textContent = config.pitch;panel.querySelector('#playPauseKeyInput').value = config.playPauseKey;panel.querySelector('#rereadKeyInput').value = config.rereadKey;panel.querySelector('#readTitleCheckbox').checked = config.readTitle;panel.querySelector('#readContentCheckbox').checked = config.readContent;panel.querySelector('#playModeSelect').value = config.playMode;// 触发TTS引擎切换事件以显示/隐藏相关设置const ttsEngineSelect = panel.querySelector('#ttsEngineSelect');ttsEngineSelect.dispatchEvent(new Event('change'));// 如果是系统TTS,设置系统语音选择if (config.ttsEngine === 'system' && config.systemVoice) {setTimeout(() => {const systemVoiceSelect = panel.querySelector('#systemVoiceSelect');if (systemVoiceSelect) {systemVoiceSelect.value = config.systemVoice;}}, 100);}}// 更新配置UI(用于同步弹窗输入的配置)function updateConfigUI() {if (!settingsWindow) return;const panel = settingsWindow;panel.querySelector('#regionInput').value = config.region;panel.querySelector('#tokenInput').value = config.token;console.log('设置面板UI已更新:', { region: config.region, tokenLength: config.token.length });}// 显示设置面板function showSettingsPanel() {if (settingsWindow) {settingsWindow.style.display = 'block';loadAvailableVoices();}}// 隐藏设置面板function hideSettingsPanel() {if (settingsWindow) {settingsWindow.style.display = 'none';}}// 获取Azure TTS端点function getTTSEndpoint(region) {return `https://${region}.tts.speech.microsoft.com/cognitiveservices/v1`;}// 获取语音列表端点function getVoicesEndpoint(region) {return `https://${region}.tts.speech.microsoft.com/cognitiveservices/voices/list`;}// 测试连接function testConnection() {const panel = settingsWindow;const regionInput = panel.querySelector('#regionInput');const tokenInput = panel.querySelector('#tokenInput');const statusSpan = panel.querySelector('#connectionStatus');const testRegion = regionInput.value.trim() || config.region;const testToken = tokenInput.value.trim() || config.token;if (!testRegion || !testToken) {statusSpan.innerHTML = '<span class="status-indicator status-error"></span>请填写完整的区域和Token';return;}statusSpan.innerHTML = '<span class="status-indicator status-warning"></span>测试中...';// 测试语音合成const testText = '连接测试成功';synthesizeText(testText, testRegion, testToken, config.voice).then(audioData => {statusSpan.innerHTML = '<span class="status-indicator status-success"></span>连接成功';// 播放测试音频playAudioData(audioData);// 加载语音列表loadAvailableVoices(testRegion, testToken);}).catch(error => {console.error('连接测试失败:', error);statusSpan.innerHTML = '<span class="status-indicator status-error"></span>连接失败: ' + error.message;});}// 加载系统语音function loadSystemVoices() {const panel = settingsWindow;if (!panel) return;const systemVoiceSelect = panel.querySelector('#systemVoiceSelect');if (!systemVoiceSelect) return;if ('speechSynthesis' in window) {const voices = speechSynthesis.getVoices();systemVoiceSelect.innerHTML = '<option value="">请选择系统语音</option>';// 筛选中文语音或默认语音const chineseVoices = voices.filter(voice =>voice.lang.includes('zh') || voice.lang.includes('cn') || voice.name.includes('Chinese'));const voicesToShow = chineseVoices.length > 0 ? chineseVoices : voices;voicesToShow.forEach(voice => {const option = document.createElement('option');option.value = voice.name;option.textContent = `${voice.name} (${voice.lang})`;systemVoiceSelect.appendChild(option);});// 如果没有找到语音,等待语音加载完成if (voices.length === 0) {speechSynthesis.addEventListener('voiceschanged', loadSystemVoices, { once: true });}} else {systemVoiceSelect.innerHTML = '<option value="">浏览器不支持系统TTS</option>';}}// 加载可用语音function loadAvailableVoices(region = null, token = null) {const panel = settingsWindow;const voiceSelect = panel.querySelector('#voiceSelect');const targetRegion = region || config.region;const targetToken = token || config.token;if (!targetRegion || !targetToken) {console.error('缺少区域或Token信息');return;}voiceSelect.innerHTML = '<option value="">正在加载语音列表...</option>';GM_xmlhttpRequest({method: 'GET',url: getVoicesEndpoint(targetRegion),headers: {'Ocp-Apim-Subscription-Key': targetToken},onload: function(response) {try {if (response.status === 200) {const voices = JSON.parse(response.responseText);availableVoices = voices.filter(voice => voice.Locale.startsWith('zh-CN'));voiceSelect.innerHTML = '';availableVoices.forEach(voice => {const option = document.createElement('option');option.value = voice.ShortName;option.textContent = `${voice.LocalName} (${voice.Gender})`;if (voice.ShortName === config.voice) {option.selected = true;}voiceSelect.appendChild(option);});console.log('成功加载语音列表:', availableVoices.length, '个中文语音');} else {throw new Error(`HTTP ${response.status}: ${response.statusText}`);}} catch (error) {console.error('解析语音列表失败:', error);voiceSelect.innerHTML = '<option value="">加载语音失败</option>';}},onerror: function(error) {console.error('获取语音列表失败:', error);voiceSelect.innerHTML = '<option value="">加载语音失败</option>';}});}// 测试发音function testVoice() {const panel = settingsWindow;const ttsEngineSelect = panel.querySelector('#ttsEngineSelect');const voiceSelect = panel.querySelector('#voiceSelect');const systemVoiceSelect = panel.querySelector('#systemVoiceSelect');const rateSlider = panel.querySelector('#rateSlider');const pitchSlider = panel.querySelector('#pitchSlider');const testText = '您好,这是语音测试。Hello, this is a voice test.';if (ttsEngineSelect.value === 'system') {// 系统TTS测试if (!systemVoiceSelect.value) {alert('请先选择一个系统语音');return;}// 临时设置配置进行测试const originalConfig = { ...config };config.ttsEngine = 'system';config.systemVoice = systemVoiceSelect.value;config.rate = rateSlider.value;synthesizeSystemTTS(testText).then(() => {console.log('系统TTS测试完成');}).catch(error => {console.error('测试发音失败:', error);alert('测试发音失败: ' + error.message);}).finally(() => {// 恢复原配置Object.assign(config, originalConfig);});} else {// Azure TTS测试if (!voiceSelect.value) {alert('请先选择一个语音');return;}if (!config.region || !config.token) {alert('请先配置Azure区域和密钥');return;}synthesizeText(testText, config.region, config.token, voiceSelect.value, rateSlider.value, pitchSlider.value + 'Hz').then(audioData => {playAudioData(audioData);}).catch(error => {console.error('测试发音失败:', error);alert('测试发音失败: ' + error.message);});}}// 保存设置function saveSettings() {const panel = settingsWindow;config.ttsEngine = panel.querySelector('#ttsEngineSelect').value;config.region = panel.querySelector('#regionInput').value;config.token = panel.querySelector('#tokenInput').value;config.voice = panel.querySelector('#voiceSelect').value;config.systemVoice = panel.querySelector('#systemVoiceSelect').value;config.rate = panel.querySelector('#rateSlider').value;config.pitch = panel.querySelector('#pitchSlider').value + 'Hz';config.playPauseKey = panel.querySelector('#playPauseKeyInput').value;config.rereadKey = panel.querySelector('#rereadKeyInput').value;config.readTitle = panel.querySelector('#readTitleCheckbox').checked;config.readContent = panel.querySelector('#readContentCheckbox').checked;config.playMode = panel.querySelector('#playModeSelect').value;// 验证至少选择一种内容类型if (!config.readTitle && !config.readContent) {alert('请至少选择朗读标题或朗读内容中的一项!');// 恢复默认设置panel.querySelector('#readTitleCheckbox').checked = true;panel.querySelector('#readContentCheckbox').checked = true;config.readTitle = true;config.readContent = true;}saveConfig();console.log('设置已保存到GM存储和Cookie:', {region: config.region,tokenLength: config.token.length,readTitle: config.readTitle,readContent: config.readContent});alert('设置已保存');}// 重置默认设置function resetToDefault() {if (confirm('确定要重置为默认设置吗?')) {config = Object.assign({}, DEFAULT_CONFIG);saveConfig();loadConfigToUI();alert('已重置为默认设置');}}// 新的内容提取逻辑(支持Shadow DOM)function extractContent() {const contentParts = [];// 1. 提取标题内容(根据配置决定是否包含)if (config.readTitle) {const titleElements = document.querySelectorAll('#follow-app-grid-container > div > div.\\@container.relative.flex.size-full.flex-col.overflow-hidden.print\\:size-auto.print\\:overflow-visible > div > div > div > div > article > div.group.relative.block.min-w-0.rounded-lg > div > a > div > div');titleElements.forEach((titleContainer, index) => {const titleParagraphs = titleContainer.querySelectorAll('p');titleParagraphs.forEach(p => {const text = p.textContent.trim();if (text && text.length > 2) {contentParts.push({type: 'title',content: text,element: p,index: index});}});});}// 2. 提取文章内容(根据配置决定是否包含)if (config.readContent) {const shadowHostSelector = '#follow-app-grid-container > div > div.\\@container.relative.flex.size-full.flex-col.overflow-hidden.print\\:size-auto.print\\:overflow-visible > div > div > div > div > article > div:nth-child(2) > div.mx-auto.mb-32.mt-8.max-w-full.cursor-auto.text-\\[0\\.94rem\\] > div';const shadowHosts = document.querySelectorAll(shadowHostSelector);console.log(`找到 ${shadowHosts.length} 个可能包含Shadow DOM的元素`);shadowHosts.forEach((shadowHost, hostIndex) => {// 检查是否有shadowRootif (shadowHost.shadowRoot) {console.log(`Shadow DOM主机 ${hostIndex} 有shadowRoot`);// 在shadowRoot中查找 #follow-entry-renderconst articleElement = shadowHost.shadowRoot.querySelector('#follow-entry-render');if (articleElement) {console.log(`在Shadow DOM中找到 #follow-entry-render`);// 按原文顺序提取所有相关元素(p, h1-h6)const allElements = articleElement.querySelectorAll('p, h1, h2, h3, h4, h5, h6');allElements.forEach((element, elementIndex) => {const text = element.textContent.trim();if (text && text.length > 2) {let elementType, additionalInfo = {};if (element.tagName.toLowerCase() === 'p') {elementType = 'article-paragraph';additionalInfo.paragraphIndex = elementIndex;} else if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(element.tagName.toLowerCase())) {elementType = 'article-heading';additionalInfo.headingLevel = element.tagName.toLowerCase();additionalInfo.headingIndex = elementIndex;}contentParts.push({type: elementType,content: text,element: element,articleIndex: hostIndex,elementIndex: elementIndex,...additionalInfo});}});// 提取双引号包裹的文字内容const textContent = articleElement.textContent || articleElement.innerText || '';const quotedTextRegex = /"([^"]+)"/g;let match;let quotedCount = 0;while ((match = quotedTextRegex.exec(textContent)) !== null) {const quotedText = match[1].trim();if (quotedText && quotedText.length > 2) {contentParts.push({type: 'quoted-text',content: quotedText,element: articleElement,articleIndex: hostIndex,quotedIndex: quotedCount});quotedCount++;}}// 如果没有找到p和标题元素,按句子分割if (allElements.length === 0) {const fullText = articleElement.textContent || '';if (fullText.trim()) {// 按句子分割(中英文句号、问号、感叹号)const sentences = fullText.split(/[。!?.!?]+/).filter(s => s.trim().length > 5);sentences.forEach((sentence, sentIndex) => {const text = sentence.trim();if (text) {contentParts.push({type: 'article-sentence',content: text,element: articleElement,articleIndex: hostIndex,sentenceIndex: sentIndex});}});}}console.log(`Shadow DOM文章 ${hostIndex} 提取完成: ${allElements.length}个元素, ${quotedCount}个引号文本`);} else {console.log(`Shadow DOM主机 ${hostIndex} 中未找到 #follow-entry-render`);}} else {console.log(`元素 ${hostIndex} 没有shadowRoot属性`);}});// 3. 备用方法:直接查找普通DOM中的 #follow-entry-render(如果存在)const directArticleElements = document.querySelectorAll('#follow-entry-render');if (directArticleElements.length > 0) {console.log(`备用方法:在普通DOM中找到 ${directArticleElements.length} 个 #follow-entry-render 元素`);directArticleElements.forEach((article, index) => {// 按原文顺序提取所有相关元素(p, h1-h6)const allElements = article.querySelectorAll('p, h1, h2, h3, h4, h5, h6');allElements.forEach((element, elementIndex) => {const text = element.textContent.trim();if (text && text.length > 2) {let elementType, additionalInfo = {};if (element.tagName.toLowerCase() === 'p') {elementType = 'article-paragraph';} else if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(element.tagName.toLowerCase())) {elementType = 'article-heading';additionalInfo.headingLevel = element.tagName.toLowerCase();}contentParts.push({type: elementType,content: text,element: element,articleIndex: index,elementIndex: elementIndex,...additionalInfo});}});// 提取双引号包裹的文字内容const textContent = article.textContent || article.innerText || '';const quotedTextRegex = /"([^"]+)"/g;let match;while ((match = quotedTextRegex.exec(textContent)) !== null) {const quotedText = match[1].trim();if (quotedText && quotedText.length > 2) {contentParts.push({type: 'quoted-text',content: quotedText,element: article,articleIndex: index});}}});}}// 去重处理 - 移除重复的内容const uniqueContent = [];const seenTexts = new Set();contentParts.forEach(item => {const normalizedText = item.content.replace(/\s+/g, ' ').trim();if (!seenTexts.has(normalizedText) && normalizedText.length > 2) {seenTexts.add(normalizedText);uniqueContent.push(item);}});console.log('提取到的内容数量:', uniqueContent.length);console.log('标题数量:', uniqueContent.filter(item => item.type === 'title').length);console.log('文章段落数量:', uniqueContent.filter(item => item.type === 'article-paragraph').length);console.log('文章标题数量:', uniqueContent.filter(item => item.type === 'article-heading').length);console.log('引号文本数量:', uniqueContent.filter(item => item.type === 'quoted-text').length);console.log('句子数量:', uniqueContent.filter(item => item.type === 'article-sentence').length);console.log('配置状态:', { readTitle: config.readTitle, readContent: config.readContent });return uniqueContent;}// 设置导航监听器function setupNavigationListener() {let lastContentHash = '';// 计算内容哈希值function getContentHash() {const contentContainer = document.querySelector('#follow-app-grid-container');if (contentContainer) {return contentContainer.innerHTML.length + contentContainer.textContent.slice(0, 100);}return window.location.href;}// 初始化内容哈希lastContentHash = getContentHash();// 监听URL变化和内容变化const observer = new MutationObserver((mutations) => {const newUrl = window.location.href;const newContentHash = getContentHash();// URL变化if (newUrl !== currentUrl) {console.log('检测到URL变化:', currentUrl, '->', newUrl);currentUrl = newUrl;lastContentHash = newContentHash;handleNavigationChange();return;}// 内容显著变化(可能是SPA路由)if (newContentHash !== lastContentHash) {console.log('检测到内容变化,可能是页面切换');lastContentHash = newContentHash;// 延迟一点时间确保是真正的页面切换而不是小的DOM更新clearTimeout(this.contentChangeTimeout);this.contentChangeTimeout = setTimeout(() => {handleNavigationChange();}, 500);}});// 开始观察document的变化observer.observe(document, {childList: true,subtree: true,attributes: true,attributeFilter: ['class', 'style']});// 也监听popstate事件(浏览器前进后退)window.addEventListener('popstate', () => {console.log('检测到popstate事件');handleNavigationChange();});// 监听hashchange事件window.addEventListener('hashchange', () => {console.log('检测到hashchange事件');handleNavigationChange();});}// 处理导航变化function handleNavigationChange() {console.log('检测到页面变化,重新加载内容');const shouldRestart = userNavigatedWhilePlaying || justFinishedReading; // 保存重启标记stopCurrentPlayback();// 清除之前的重载计时器if (reloadTimeoutId) {clearTimeout(reloadTimeoutId);}// 延迟重新加载内容,等待新页面加载完成reloadTimeoutId = setTimeout(() => {console.log('新页面加载完成,重新提取内容');reloadContent();// 如果用户在朗读时导航或刚完成朗读,自动重新开始朗读if (shouldRestart) {const reason = userNavigatedWhilePlaying ? '朗读时导航' : '完成朗读后切换';console.log(`${reason},准备重新开始朗读`);updateStatus('页面切换完成,正在重新开始朗读...');userNavigatedWhilePlaying = false; // 重置标记justFinishedReading = false; // 重置标记// 稍微延迟一下确保内容完全加载setTimeout(() => {checkAndPromptConfig().then(() => {contentQueue = extractContent();currentIndex = 0;if (contentQueue.length > 0) {updateStatus('重新开始朗读');playNext();} else {updateStatus('新页面无内容可播放');}}).catch((error) => {console.error('重新开始朗读失败:', error);updateStatus('重新开始朗读失败: ' + error.message);});}, 500);}}, 2000); // 等待2秒让新内容加载}// 停止当前播放function stopCurrentPlayback() {console.log('停止当前播放, TTS引擎:', config.ttsEngine);if (currentAudio) {if (config.ttsEngine === 'system') {// 系统TTS完全停止if (speechSynthesis.speaking || speechSynthesis.paused) {speechSynthesis.cancel();console.log('系统TTS已取消');}} else {// Azure TTS停止try {currentAudio.pause();currentAudio.currentTime = 0; // 重置播放位置console.log('Azure TTS已停止');} catch (error) {console.log('Azure TTS停止时出错:', error);}}currentAudio = null;}isPlaying = false;isPaused = false;// 移除高亮removeHighlight();console.log('播放状态已重置');updateStatus('已停止 - 检测到页面切换');console.log('已停止当前播放');// 更新悬浮控制面板状态updateFloatingControlStatus();}// 重新加载内容function reloadContent() {console.log('重新加载内容...');contentQueue = extractContent();currentIndex = 0;if (contentQueue.length > 0) {updateStatus('内容已重新加载');updateProgress();console.log('重新加载完成,共', contentQueue.length, '段内容');// 如果之前在播放且有配置,检查配置后自动开始新内容的播放// 但如果是用户导航或刚完成朗读触发的,则不在这里自动播放(会在handleNavigationChange中处理)if (config.autoStart && isPlaying && !userNavigatedWhilePlaying && !justFinishedReading) {const playModeText = {'normal': '普通模式','loop': '循环模式','sequential': '顺序模式'}[config.playMode] || '普通模式';updateStatus(`${playModeText} - 加载新内容`);setTimeout(() => {checkAndPromptConfig().then(() => {console.log('重新加载后自动播放 (', config.playMode, '模式)');playNext();}).catch((error) => {console.log('重新加载后配置检查失败:', error.message);updateStatus('需要配置后点击播放');isPlaying = false;updateFloatingControlStatus();});}, 500);}} else {updateStatus('重新加载后未找到内容');console.log('重新加载后未找到可阅读内容');}}// 开始自动阅读function startAutoReading() {if (!config.autoStart) {console.log('自动开始已关闭,等待手动启动');return;}console.log('开始自动阅读流程...');// 使用智能等待,检测页面内容是否已加载function waitForContent(attempts = 0) {const maxAttempts = 10; // 最多尝试10次const interval = 1000; // 每次间隔1秒console.log(`尝试提取内容 (第${attempts + 1}次)`);contentQueue = extractContent();currentIndex = 0;if (contentQueue.length > 0) {updateStatus('内容已加载,检查配置...');updateProgress();console.log('内容加载完成,共', contentQueue.length, '段内容');// 检查配置,如果有效则自动开始播放checkAndPromptConfig().then(() => {console.log('配置检查通过,开始自动播放');updateStatus('配置检查通过,开始播放');// 延迟一点时间确保状态更新完成setTimeout(() => {playNext();}, 500);}).catch((error) => {console.log('配置检查失败或用户取消:', error.message);updateStatus('点击播放按钮开始阅读');isPlaying = false;updateFloatingControlStatus();});} else if (attempts < maxAttempts) {console.log(`第${attempts + 1}次未找到内容,${interval/1000}秒后重试...`);updateStatus(`等待内容加载... (${attempts + 1}/${maxAttempts})`);setTimeout(() => waitForContent(attempts + 1), interval);} else {updateStatus('未找到可阅读内容');console.log('达到最大尝试次数,页面内容提取失败');}}// 开始等待内容setTimeout(() => waitForContent(), 2000); // 首次延迟2秒开始}// 简化的调试内容提取功能function debugContentExtraction() {console.clear();console.log('=== 内容提取调试开始 ===');// 检查标题元素const titleElements = document.querySelectorAll('#follow-app-grid-container > div > div.\\@container.relative.flex.size-full.flex-col.overflow-hidden.print\\:size-auto.print\\:overflow-visible > div > div > div > div > article > div.group.relative.block.min-w-0.rounded-lg > div > a > div > div');console.log('找到标题容器数量:', titleElements.length);titleElements.forEach((titleContainer, index) => {const titleParagraphs = titleContainer.querySelectorAll('p');console.log(`标题容器 ${index} 包含 ${titleParagraphs.length} 个p元素`);titleParagraphs.forEach((p, pIndex) => {console.log(` 标题 ${index}-${pIndex}:`, p.textContent.substring(0, 100));});});// 检查文章元素const articleElements = document.querySelectorAll('#follow-entry-render');console.log('找到文章元素数量:', articleElements.length);articleElements.forEach((article, index) => {console.log(`文章 ${index}:`, article);const paragraphs = article.querySelectorAll('p');console.log(` 包含 ${paragraphs.length} 个p元素`);// 检查双引号文本const textContent = article.textContent || '';const quotedMatches = textContent.match(/"[^"]+"/g);console.log(` 找到 ${quotedMatches ? quotedMatches.length : 0} 个双引号文本`);if (quotedMatches) {quotedMatches.slice(0, 3).forEach((match, i) => {console.log(` 引号文本 ${i}:`, match);});}});// 测试当前提取方法const extractedContent = extractContent();console.log('当前提取方法结果:', extractedContent);console.log('=== 内容提取调试结束 ===');// 显示结果给用户const summary = `调试完成!检查浏览器控制台查看详细信息。- 找到 ${titleElements.length} 个标题容器- 找到 ${articleElements.length} 个文章元素- 总共提取 ${extractedContent.length} 段内容💡 如果遇到播放错误,请:1. 打开浏览器开发者工具 (F12)2. 查看控制台 (Console) 标签页3. 查找红色的错误信息4. 检查网络 (Network) 标签页是否有失败的请求常见问题:- 403错误:检查Azure密钥是否正确- 网络错误:检查网络连接- 音频播放错误:可能是浏览器策略问题,尝试手动点击播放`;alert(summary);}// 播放下一段内容function playNext() {if (currentIndex >= contentQueue.length) {// 播放完成,根据播放模式决定下一步操作handlePlaybackEnd();return;}const currentContent = contentQueue[currentIndex];if (!currentContent || !currentContent.content) {currentIndex++;playNext();return;}// 检查配置checkAndPromptConfig().then(() => {continuePlayback(currentContent);}).catch((error) => {console.error('配置检查失败:', error);updateStatus('需要配置Azure语音服务');isPlaying = false;updateFloatingControlStatus();});}// 处理播放结束function handlePlaybackEnd() {console.log('播放结束,当前播放模式:', config.playMode);switch (config.playMode) {case 'loop':// 循环模式 - 重新播放当前条目updateStatus('循环播放 - 重新开始');console.log('循环模式:重新开始播放');setTimeout(() => {currentIndex = 0; // 重置到开头playNext();}, 1000); // 稍微停顿1秒break;case 'normal':default:// 正常模式 - 播放完成后停止updateStatus('阅读完成');console.log('正常模式:播放完成,停止播放');isPlaying = false;isPaused = false;justFinishedReading = true; // 设置刚完成朗读标记updateFloatingControlStatus();break;}}function continuePlayback(currentContent) {isPlaying = true;isPaused = false;// 根据内容类型更新状态let statusText = '正在阅读';switch(currentContent.type) {case 'title':statusText = '正在阅读标题';break;case 'article-paragraph':statusText = '正在阅读文章段落';break;case 'article-heading':statusText = `正在阅读文章${currentContent.headingLevel}标题`;break;case 'quoted-text':statusText = '正在阅读引用文本';break;default:statusText = '正在阅读';}updateStatus(statusText);updateProgress();updateCurrentContent(currentContent.content, currentContent.type);// 更新悬浮控制面板状态updateFloatingControlStatus();// 高亮当前阅读的元素highlightElement(currentContent.element);// 使用Azure TTS合成语音console.log('开始语音合成:', {text: currentContent.content.substring(0, 50) + '...',region: config.region,voice: config.voice,rate: config.rate,pitch: config.pitch});synthesizeVoice(currentContent.content).then(audioData => {console.log('语音合成成功,开始播放音频');if (config.ttsEngine === 'system') {// 系统TTS直接播放,不需要playAudioDataconsole.log('音频播放完成');removeHighlight();currentIndex++;setTimeout(() => {if (!isPaused) {playNext();}}, 500); // 段落间短暂停顿} else {// Azure TTS需要播放音频数据playAudioData(audioData).then(() => {console.log('音频播放完成');removeHighlight();currentIndex++;setTimeout(() => {if (!isPaused) {playNext();}}, 500); // 段落间短暂停顿}).catch(error => {console.error('音频播放错误:', error);console.error('音频播放错误详细信息:', {error: error,message: error.message,stack: error.stack});updateStatus('播放错误: ' + error.message);isPlaying = false;removeHighlight();updateFloatingControlStatus();});}}).catch(error => {console.error('语音合成错误:', error);// 构建更清楚的错误信息let errorMsg = '';let originalError = '';if (error.message) {originalError = error.message;errorMsg = error.message;// 特殊错误的友好提示(但保留原始信息)if (errorMsg.includes('Session rule count exceeded')) {errorMsg = `Azure TTS错误: ${originalError} (可能是会话限制而非配额问题)`;} else if (errorMsg.includes('Unauthorized')) {errorMsg = `认证失败: ${originalError}`;} else if (errorMsg.includes('network')) {errorMsg = `网络错误: ${originalError}`;}} else if (typeof error === 'string') {errorMsg = error;} else if (error.status) {errorMsg = `HTTP ${error.status}: ${error.statusText || '请求失败'}`;} else {errorMsg = '未知错误';}console.error('语音合成错误详细信息:', {error: error,message: errorMsg,stack: error.stack,config: {region: config.region,hasToken: !!config.token,voice: config.voice}});updateStatus('合成失败: ' + errorMsg);isPlaying = false;removeHighlight();updateFloatingControlStatus();});}// 暂停/继续播放function togglePlayPause() {console.log('执行togglePlayPause,当前状态:', { isPlaying, isPaused });if (isPlaying && !isPaused) {// 暂停当前播放if (currentAudio) {if (config.ttsEngine === 'system') {// 系统TTS暂停if (speechSynthesis.speaking) {speechSynthesis.pause();console.log('系统TTS已暂停');}} else {// Azure TTS暂停currentAudio.pause();console.log('Azure TTS已暂停');}}isPaused = true;updateStatus('已暂停');} else if (isPaused) {// 继续播放if (currentAudio) {if (config.ttsEngine === 'system') {// 系统TTS恢复if (speechSynthesis.paused) {speechSynthesis.resume();console.log('系统TTS已恢复');} else {// 如果暂停状态异常,重新开始当前内容console.log('系统TTS状态异常,重新播放当前内容');if (currentIndex < contentQueue.length) {const currentContent = contentQueue[currentIndex];synthesizeText(currentContent.content);}}} else {// Azure TTS恢复const playResult = currentAudio.play();if (playResult instanceof Promise) {playResult.catch(error => {console.log('Azure TTS恢复失败,重新播放:', error);// 如果恢复失败,重新播放当前内容if (currentIndex < contentQueue.length) {const currentContent = contentQueue[currentIndex];synthesizeText(currentContent.content);}});}console.log('Azure TTS已恢复');}}isPaused = false;updateStatus('正在阅读');} else {// 重新开始 - 需要检查配置checkAndPromptConfig().then(() => {contentQueue = extractContent();currentIndex = 0;if (contentQueue.length > 0) {playNext();} else {updateStatus('无内容可播放');}}).catch((error) => {console.error('配置检查失败:', error);updateStatus('需要配置Azure语音服务');// 自动打开设置面板showSettingsPanel();});}// 更新悬浮控制面板状态updateFloatingControlStatus();}// 重新朗读当前段落function rereadCurrent() {if (currentIndex > 0) {currentIndex--;}if (currentAudio) {currentAudio.pause();currentAudio = null;}playNext();}// 绑定键盘事件(只保留播放暂停和重新阅读功能,监听导航键以便重新朗读)function bindKeyEvents() {document.addEventListener('keydown', (e) => {// 防止在输入框中触发if (e.target.tagName.toLowerCase() === 'input' || e.target.tagName.toLowerCase() === 'textarea') {return;}// 检测←→方向键(如果正在朗读,标记需要重新开始)if (e.code === 'ArrowLeft' || e.code === 'ArrowRight') {if (isPlaying && !isPaused) {console.log('检测到用户导航操作,正在朗读中,标记需要重新开始');updateStatus('检测到导航操作,将在新页面加载后重新朗读');// 设置标记,表示用户手动导航且需要重新开始朗读userNavigatedWhilePlaying = true;stopCurrentPlayback();}// 不阻止默认行为,让页面正常切换return;}// 只处理播放暂停和重新阅读快捷键if (e.code === config.playPauseKey) {e.preventDefault();togglePlayPause();} else if (e.code === config.rereadKey) {e.preventDefault();rereadCurrent();}});}// 更新状态function updateStatus(status) {console.log('状态:', status);if (settingsWindow) {const statusElement = settingsWindow.querySelector('#readerStatus');if (statusElement) {statusElement.textContent = status;}}// 更新悬浮控制面板updateFloatingControlStatus();}// 更新进度function updateProgress() {if (settingsWindow) {const progressElement = settingsWindow.querySelector('#readerProgress');if (progressElement) {progressElement.textContent = `${currentIndex + 1}/${contentQueue.length}`;}}// 更新悬浮控制面板updateFloatingControlStatus();}// 更新当前内容显示function updateCurrentContent(content, type = '') {if (settingsWindow) {const contentElement = settingsWindow.querySelector('#currentContent');if (contentElement) {const typePrefix = type ? `[${type}] ` : '';const displayContent = content.length > 50 ? content.substring(0, 50) + '...' : content;contentElement.textContent = typePrefix + displayContent;}}}// 高亮当前元素function highlightElement(element) {if (element) {element.style.backgroundColor = '#ffeb3b';element.style.outline = '2px solid #ff9800';element.scrollIntoView({ behavior: 'smooth', block: 'center' });}}// 移除高亮function removeHighlight() {const highlighted = document.querySelectorAll('[style*="background-color: rgb(255, 235, 59)"]');highlighted.forEach(el => {el.style.backgroundColor = '';el.style.outline = '';});}// XML转义function escapeXml(text) {return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');}// 使元素可拖拽function makeElementDraggable(element, handle) {let isDragging = false;let currentX;let currentY;let initialX;let initialY;let xOffset = 0;let yOffset = 0;handle.addEventListener('mousedown', dragStart);document.addEventListener('mousemove', drag);document.addEventListener('mouseup', dragEnd);function dragStart(e) {initialX = e.clientX - xOffset;initialY = e.clientY - yOffset;if (e.target === handle) {isDragging = true;}}function drag(e) {if (isDragging) {e.preventDefault();currentX = e.clientX - initialX;currentY = e.clientY - initialY;xOffset = currentX;yOffset = currentY;element.style.transform = `translate3d(${currentX}px, ${currentY}px, 0)`;}}function dragEnd() {initialX = currentX;initialY = currentY;isDragging = false;}}// 统一的语音合成函数(根据配置选择Azure TTS或系统TTS)function synthesizeVoice(text) {if (config.ttsEngine === 'system') {return synthesizeSystemTTS(text);} else {return synthesizeText(text, config.region, config.token, config.voice, config.rate, config.pitch);}}// 系统TTS合成函数function synthesizeSystemTTS(text) {return new Promise((resolve, reject) => {if (!('speechSynthesis' in window)) {reject(new Error('浏览器不支持系统TTS'));return;}// 确保之前的播放已经停止if (speechSynthesis.speaking || speechSynthesis.paused) {speechSynthesis.cancel();console.log('取消之前的系统TTS播放');}const utterance = new SpeechSynthesisUtterance(text);// 设置语音if (config.systemVoice) {const voices = speechSynthesis.getVoices();const selectedVoice = voices.find(voice => voice.name === config.systemVoice);if (selectedVoice) {utterance.voice = selectedVoice;}}// 设置语速和音调utterance.rate = parseFloat(config.rate) || 1.0;utterance.pitch = 1.0; // 系统TTS的音调调整有限let isResolved = false; // 防止重复resolveutterance.onstart = () => {console.log('系统TTS开始播放');currentAudio = {pause: () => {if (speechSynthesis.speaking && !speechSynthesis.paused) {speechSynthesis.pause();console.log('系统TTS暂停成功');}},resume: () => {if (speechSynthesis.paused) {speechSynthesis.resume();console.log('系统TTS恢复成功');}},stop: () => {speechSynthesis.cancel();console.log('系统TTS停止成功');}};};utterance.onend = () => {console.log('系统TTS播放完成');currentAudio = null;if (!isResolved) {isResolved = true;resolve();}};utterance.onerror = (error) => {console.error('系统TTS错误:', error);currentAudio = null;if (!isResolved) {isResolved = true;reject(new Error('系统TTS播放失败: ' + error.error));}};// 添加超时机制,防止卡死const timeout = setTimeout(() => {if (!isResolved) {console.log('系统TTS播放超时,强制结束');speechSynthesis.cancel();currentAudio = null;isResolved = true;reject(new Error('系统TTS播放超时'));}}, 30000); // 30秒超时utterance.onend = () => {console.log('系统TTS播放完成');clearTimeout(timeout);currentAudio = null;if (!isResolved) {isResolved = true;resolve();}};try {speechSynthesis.speak(utterance);console.log('系统TTS已开始speak调用');} catch (error) {clearTimeout(timeout);console.error('系统TTS speak调用失败:', error);if (!isResolved) {isResolved = true;reject(error);}}});}// Azure TTS语音合成函数function synthesizeText(text, region, token, voice, rate = '1.0', pitch = '0Hz') {return new Promise((resolve, reject) => {console.log('synthesizeText调用参数:', {textLength: text.length,region: region,hasToken: !!token,tokenLength: token ? token.length : 0,voice: voice,rate: rate,pitch: pitch});if (!region || !token) {const error = `TTS配置不完整: region=${region}, hasToken=${!!token}`;console.error(error);reject(new Error(error));return;}const ssml = `<speak version="1.0" xmlns="http://www.w3.org/2025/10/synthesis" xml:lang="zh-CN"><voice name="${voice}"><prosody rate="${rate}" pitch="${pitch}">${escapeXml(text)}</prosody></voice></speak>`;console.log('生成的SSML:', ssml.substring(0, 200) + '...');console.log('TTS端点:', getTTSEndpoint(region));GM_xmlhttpRequest({method: 'POST',url: getTTSEndpoint(region),headers: {'Ocp-Apim-Subscription-Key': token,'Content-Type': 'application/ssml+xml','X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3'},data: ssml,responseType: 'arraybuffer',onload: function(response) {console.log('TTS响应:', {status: response.status,statusText: response.statusText,responseSize: response.response ? response.response.byteLength : 0});if (response.status === 200) {console.log('TTS合成成功,音频数据大小:', response.response.byteLength);resolve(response.response);} else {const error = `TTS请求失败: HTTP ${response.status} ${response.statusText}`;console.error(error);console.error('TTS错误响应头:', response.responseHeaders);reject(new Error(error));}},onerror: function(error) {let errorMsg = 'TTS请求网络错误';let errorDetails = '';if (error && error.error) {errorDetails = error.error;} else if (error && error.message) {errorDetails = error.message;} else if (error && typeof error === 'string') {errorDetails = error;} else if (error) {try {errorDetails = JSON.stringify(error);} catch (e) {errorDetails = String(error);}}if (errorDetails) {errorMsg += ': ' + errorDetails;}console.error(errorMsg);console.error('网络错误详细信息:', error);// 输出可选中的错误信息到控制台console.log('=== 可复制的错误信息 ===');console.log(errorMsg);console.log('======================');reject(new Error(errorMsg));}});});}// 播放音频数据function playAudioData(audioData) {return new Promise((resolve, reject) => {try {console.log('开始播放音频,数据大小:', audioData.byteLength, '字节');const audioBlob = new Blob([audioData], { type: 'audio/mpeg' });const audioUrl = URL.createObjectURL(audioBlob);console.log('音频Blob创建成功,URL:', audioUrl);if (currentAudio) {currentAudio.pause();URL.revokeObjectURL(currentAudio.src);}currentAudio = new Audio(audioUrl);currentAudio.onended = () => {console.log('音频播放结束');URL.revokeObjectURL(audioUrl);currentAudio = null;resolve();};currentAudio.onerror = (error) => {console.error('音频播放出错:', error);console.error('音频错误详细信息:', {error: error,audioUrl: audioUrl,readyState: currentAudio?.readyState,networkState: currentAudio?.networkState});URL.revokeObjectURL(audioUrl);currentAudio = null;reject(new Error('音频播放失败: ' + (error.message || 'Unknown error')));};currentAudio.oncanplaythrough = () => {console.log('音频可以开始播放');};currentAudio.onloadstart = () => {console.log('开始加载音频');};currentAudio.onloadeddata = () => {console.log('音频数据加载完成');};console.log('开始播放音频...');currentAudio.play().catch(playError => {console.error('Audio.play()失败:', playError);URL.revokeObjectURL(audioUrl);currentAudio = null;reject(new Error('音频播放启动失败: ' + playError.message));});} catch (error) {console.error('播放音频时发生异常:', error);reject(new Error('播放音频异常: ' + error.message));}});}init();})();
该脚本为网站 Folo (https://app.folo.is) 开发,用于实现自动语音阅读文章的增强体验。
主要方法
| init() | |
| loadConfig() / saveConfig() | |
| createSettingsPanel() | |
| createFloatingControl() | |
| extractContent() | |
| synthesizeVoice() | |
| synthesizeText() | |
| synthesizeSystemTTS() | |
| playAudioData() | |
| togglePlayPause() | |
| rereadCurrent() | |
| setupNavigationListener() | |
| checkAndPromptConfig() / showConfigPrompt() | |
| updateFloatingControlStatus() | |
| highlightElement() / removeHighlight() | |
| bindKeyEvents() | |
| handlePlaybackEnd() |
执行逻辑
页面加载 → init()↓加载配置(Azure区域、Token、语音、语速、音调等)↓创建设置面板 + 悬浮面板↓提取文章标题和内容(支持 Shadow DOM)↓检查配置(Azure 凭证有效性)↓调用 synthesizeVoice() → Azure 或 系统 TTS↓playAudioData() 播放语音↓播放完成 → 下一段内容↓若全部播放完毕 → 根据播放模式执行(停止 / 循环)
用户交互界面说明
1. 设置面板(完整控制)
包括:
语音引擎(Azure / 系统)
Azure 地区与 Token
语音选择、语速、音调
播放快捷键绑定
是否朗读标题/内容
播放模式选择(正常/循环)
内容调试与刷新功能
2. 🎵 悬浮面板(快捷控制)
位于页面右下角,包含:
▶ / ⏸ 播放暂停按钮
状态显示(标题/段落/引用/暂停)
当前进度(如
3/12)⚙ 设置按钮
支持拖拽、双击刷新内容。
技术亮点
| Shadow DOM 解析 | shadowRoot.querySelector() 深度提取封装内容。 |
| Azure TTS API 调用 | GM_xmlhttpRequest 直接向微软语音服务发起 HTTPS 请求。 |
| 跨域安全管理 | |
| 语音合成标记语言 (SSML) | <prosody rate pitch> 控制发音语速与音高。 |
| 播放状态同步 | |
| 自动错误恢复 |
注意:
本文部分变量已做脱敏处理,仅用于测试和学习研究,禁止用于商业用途,不能保证其合法性,准确性,完整性和有效性,请根据情况自行判断。技术层面需要提供帮助,可以通过打赏的方式进行探讨。
没有评论:
发表评论