您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Чтение статей, книг и больших текстов с подсветкой слов, плавной прокруткой и голосом Irina (улучшенная совместимость с auto.ru и похожими сайтами)
当前为
// ==UserScript== // @name АвтоTTS Irina с подсветкой слов v2.3 // @namespace https://gf.qytechs.cn/ru/users/You // @version 2.3 // @description Чтение статей, книг и больших текстов с подсветкой слов, плавной прокруткой и голосом Irina (улучшенная совместимость с auto.ru и похожими сайтами) // @author You // @license MIT // @match *://*/* // @grant none // ==/UserScript== (function() { 'use strict'; let rate = 2.5; let paragraphs = []; let isReading = false; let currentUtter = null; let selectedVoice = null; let highlightedSpan = null; let currentParagraphIndex = 0; // ====== UI ====== const activateBtn = document.createElement("button"); activateBtn.textContent = "Загрузка голоса..."; Object.assign(activateBtn.style, { position: "fixed", top: "10px", left: "10px", zIndex: "2147483647", padding: "5px 10px", backgroundColor: "#007bff", color: "#fff", border: "none", borderRadius: "5px", cursor: "not-allowed", fontFamily: "Arial, sans-serif" }); document.body.appendChild(activateBtn); const indicator = document.createElement("div"); Object.assign(indicator.style, { position: "fixed", bottom: "10px", right: "10px", padding: "5px 10px", backgroundColor: "rgba(0,0,0,0.7)", color: "#fff", fontSize: "14px", borderRadius: "5px", zIndex: "2147483647", fontFamily: "Arial, sans-serif", display: "flex", alignItems: "center", gap: "5px", cursor: "move" }); const rateText = document.createElement("span"); rateText.textContent = `Скорость: ${rate.toFixed(1)}`; indicator.appendChild(rateText); const btnUp = document.createElement("button"); btnUp.textContent = "▲"; indicator.appendChild(btnUp); const btnDown = document.createElement("button"); btnDown.textContent = "▼"; indicator.appendChild(btnDown); const btnPause = document.createElement("button"); btnPause.textContent = "⏸"; indicator.appendChild(btnPause); const btnStop = document.createElement("button"); btnStop.textContent = "⏹"; indicator.appendChild(btnStop); document.body.appendChild(indicator); // ====== Drag ====== function makeDraggable(el) { let drag = false, ox = 0, oy = 0; el.addEventListener("mousedown", e => { drag = true; ox = e.clientX - el.offsetLeft; oy = e.clientY - el.offsetTop; }); document.addEventListener("mousemove", e => { if (drag) { el.style.left = (e.clientX - ox) + "px"; el.style.top = (e.clientY - oy) + "px"; } }); document.addEventListener("mouseup", () => drag = false); } makeDraggable(activateBtn); makeDraggable(indicator); // ====== Voice selection ====== function waitForVoice(callback) { const check = setInterval(() => { const voices = speechSynthesis.getVoices(); selectedVoice = voices.find(v => /irina/i.test(v.name) && /ru/i.test(v.lang)) || voices.find(v => v.lang && v.lang.toLowerCase().startsWith("ru")); if (selectedVoice) { clearInterval(check); console.info("[TTS] selected voice:", selectedVoice.name || selectedVoice.lang); callback(); } }, 150); // also try to trigger voiceschanged after a short timeout (some browsers) setTimeout(()=> { speechSynthesis.getVoices(); }, 200); } // ====== Helpers / Filters ====== function isAdBlock(text) { if (!text) return true; const lower = text.toLowerCase(); // common phrases to ignore if (/реклама|подписк|контак|меню|ссылка|поделиться|рекомендуем|©|инн|телефон|cookie|политика|объявления загружают/i.test(lower)) return true; if (text.length < 10) return true; return false; } function findWidestBlock() { const all = Array.from(document.body.querySelectorAll("div, section, article, main")); let maxW = 0, widest = null; all.forEach(el => { try { const rect = el.getBoundingClientRect(); if (rect.width > maxW && el.querySelectorAll("p, h2, h3, li, blockquote").length > 0) { maxW = rect.width; widest = el; } } catch (e) { /* ignore hidden nodes */ } }); return widest; } // ====== Init paragraphs (robust) ====== function initParagraphs() { // candidate selectors (including auto.ru patterns) const selectors = [ "article", "main", ".article__content", ".article-content", ".journal-article__content", ".mag-article__content", ".article", ".content", ".page__content", ".intro", ".lead" ]; let container = null; for (const s of selectors) { try { const el = document.querySelector(s); if (el && el.querySelector("p, h2, h3, li, blockquote")) { container = el; break; } } catch(e) {} } if (!container) container = findWidestBlock() || document.body; console.info("[TTS] chosen container:", container && container.tagName ? container.tagName + (container.className?("."+container.className):"") : container); // collect nodes that look like content const nodes = Array.from(container.querySelectorAll("p, h2, h3, li, blockquote")); let filtered = []; for (let i = 0; i < nodes.length; i++) { let el = nodes[i]; let text = el.innerText || el.textContent || ""; text = text.replace(/\u00A0/g,' ').trim(); // remove common placeholders and short noise if (/подождите|объявления загружаются|реклама/i.test(text)) continue; if (i === 0 && text.length > 0) { filtered.push(el); continue; } if (text.length > 30 && !isAdBlock(text)) filtered.push(el); } // prepare paragraphs array: for each element, replace innerHTML with spans built from tokens paragraphs = []; filtered.forEach((el, idx) => { // get clean normalized text let raw = (el.innerText || el.textContent || "").replace(/\u00A0/g,' ').trim(); raw = raw.replace(/\s+/g, " "); // normalize whitespace if (!raw) return; // split to tokens: each token includes trailing spaces (word+spaces) const tokens = raw.match(/(\S+\s*)/g) || [raw]; // build safe spans preserving tokens exactly const newHtml = tokens.map(t => { // escape HTML inside tokens (to avoid injection) const esc = t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); return `<span data-toklen="${esc.length}">${esc}</span>`; }).join(""); try { el.innerHTML = newHtml; } catch (e) { // fallback: create nodes manually el.innerHTML = ""; tokens.forEach(t => { const span = document.createElement("span"); span.textContent = t; el.appendChild(span); }); } paragraphs.push(el); }); console.info(`[TTS] paragraphs prepared: ${paragraphs.length}`); // debug: log first few paragraphs texts paragraphs.slice(0,5).forEach((p,i)=>console.debug(`[TTS] para ${i}:`, (p.innerText||p.textContent).slice(0,120))); } // ====== Highlighting + smooth conditional scroll ====== function highlightSpan(span) { if (highlightedSpan && highlightedSpan !== span) highlightedSpan.style.backgroundColor = ""; highlightedSpan = span; if (!span) return; span.style.backgroundColor = "orange"; // conditional scrolling: only scroll if span is far from center +/- 28% height const rect = span.getBoundingClientRect(); const centerY = window.innerHeight / 2; const delta = rect.top + rect.height/2 - centerY; const threshold = window.innerHeight * 0.28; if (Math.abs(delta) > threshold) { span.scrollIntoView({behavior: "smooth", block: "center"}); } } // ====== Read paragraph with precise token mapping ====== function readParagraph(index) { if (!isReading) return; if (index >= paragraphs.length) { // reached end: stop stopReading(); return; } currentParagraphIndex = index; const p = paragraphs[index]; // build normalized text exactly equal to concatenation of span texts const spans = Array.from(p.querySelectorAll("span")); const tokens = spans.map(s => s.textContent); // tokens contain trailing spaces as created const normalized = tokens.join(""); if (!normalized.trim()) { // skip empty readParagraph(index + 1); return; } // create utterance that exactly matches spans const utter = new SpeechSynthesisUtterance(normalized); if (selectedVoice) utter.voice = selectedVoice; utter.rate = rate; utter.lang = "ru-RU"; // prepare cumulative lengths for fast lookup const cumLengths = []; let acc = 0; for (let t of tokens) { cumLengths.push(acc); acc += t.length; } utter.onboundary = function(event) { // some browsers don't set event.name; rely on charIndex if (typeof event.charIndex !== "number") return; const charIndex = event.charIndex; // binary search in cumLengths to find token index let lo = 0, hi = cumLengths.length - 1, found = 0; while (lo <= hi) { const mid = Math.floor((lo + hi) / 2); if (cumLengths[mid] <= charIndex) { found = mid; lo = mid + 1; } else { hi = mid - 1; } } // highlight that token const span = spans[found]; if (span) highlightSpan(span); }; utter.onend = () => { // clear highlight for paragraph then proceed highlightSpan(null); // small delay to allow scroll and UX setTimeout(()=> readParagraph(index + 1), 50); }; currentUtter = utter; speechSynthesis.speak(utter); } function startReading() { if (paragraphs.length === 0) initParagraphs(); if (paragraphs.length === 0) { console.warn("[TTS] no paragraphs found to read on this page."); return; } isReading = true; activateBtn.textContent = "Стоп TTS"; readParagraph(0); } function stopReading() { isReading = false; speechSynthesis.cancel(); activateBtn.textContent = "Активировать TTS"; if (highlightedSpan) highlightedSpan.style.backgroundColor = ""; } // ====== Buttons binding ====== waitForVoice(() => { activateBtn.textContent = "Активировать TTS"; activateBtn.style.cursor = "pointer"; activateBtn.addEventListener("click", () => { if (isReading) stopReading(); else startReading(); }); }); btnUp.addEventListener("click", () => { rate += 0.1; rateText.textContent = `Скорость: ${rate.toFixed(1)}`; }); btnDown.addEventListener("click", () => { rate = Math.max(0.5, rate - 0.1); rateText.textContent = `Скорость: ${rate.toFixed(1)}`; }); btnPause.addEventListener("click", () => { if (!currentUtter) return; speechSynthesis.paused ? speechSynthesis.resume() : speechSynthesis.pause(); }); btnStop.addEventListener("click", stopReading); // ====== Auto-init once page ready (optional) ====== // we don't auto-start reading; we just prepare paragraphs for faster start setTimeout(()=> { try { initParagraphs(); } catch(e) { console.warn("[TTS] initParagraphs error", e); } }, 600); // observe DOM changes — if significant content loads later, refresh paragraphs const observer = new MutationObserver((mutations) => { // if large chunk added, re-init paragraphs (throttle) if (observer._t) return; observer._t = setTimeout(()=> { observer._t = null; try { initParagraphs(); } catch(e) {} }, 700); }); observer.observe(document.body, { childList: true, subtree: true }); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址