// ==UserScript==
// @name АвтоTTS Irina с подсветкой слов v2.4
// @namespace https://gf.qytechs.cn/ru/users/You
// @version 2.4
// @description Чтение статей, книг и больших текстов с подсветкой слов, плавной прокруткой и голосом Irina (улучшенная совместимость с auto.ru и похожими сайтами)
// @author You
// @license MIT
// @match *://*/*
// @grant none
// ==/UserScript==
(function() {
'use strict';
let rate = 2.3;
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 });
})();