АвтоTTS Irina с подсветкой слов v2.4

Чтение статей, книг и больших текстов с подсветкой слов, плавной прокруткой и голосом Irina (улучшенная совместимость с auto.ru и похожими сайтами)

// ==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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
                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或关注我们的公众号极客氢云获取最新地址