Простой Ридер Текста

Легковесный скрипт для чтения вслух текста со страниц. Чтение с места клика, регулировка скорости, базовый скроллинг.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Простой Ридер Текста
// @namespace    http://tampermonkey.net/
// @version      4.0
// @description  Легковесный скрипт для чтения вслух текста со страниц. Чтение с места клика, регулировка скорости, базовый скроллинг.
// @author       You
// @match        *://*/*
// @grant        none
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // Функция для создания панели поддержки
    function createSupportPanel() {
        if (document.getElementById('ttsSupportPanel')) {
            return;
        }

        const supportPanel = document.createElement('div');
        supportPanel.id = 'ttsSupportPanel';
        supportPanel.innerHTML = `
            <div style="text-align: center; line-height: 1.3;">
                Если вам нравится скрипт, вы можете сделать добровольный взнос:<br>
                <a href="https://finance.ozon.ru/apps/sbp/ozonbankpay/01997254-f6d5-7d4c-8a72-15f9a62fea9d" 
                   target="_blank" 
                   style="color: #0066cc; text-decoration: none; font-weight: bold;">
                    Поддержать проект
                </a>
            </div>
        `;

        Object.assign(supportPanel.style, {
            position: 'fixed',
            bottom: '10px',
            right: '10px',
            background: 'rgba(255, 255, 255, 0.95)',
            border: '1px solid #ddd',
            borderRadius: '8px',
            padding: '8px 12px',
            fontSize: '11px',
            fontFamily: 'Arial, sans-serif',
            color: '#666',
            zIndex: '9999',
            maxWidth: '200px',
            boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
            backdropFilter: 'blur(5px)',
            opacity: '0.8',
            transition: 'opacity 0.3s ease'
        });

        supportPanel.addEventListener('mouseenter', () => {
            supportPanel.style.opacity = '1';
        });

        supportPanel.addEventListener('mouseleave', () => {
            supportPanel.style.opacity = '0.8';
        });

        try {
            document.body.appendChild(supportPanel);
        } catch (e) {
            document.documentElement.appendChild(supportPanel);
        }

        return supportPanel;
    }

    // Функция для безопасного добавления панели
    function createControlPanel() {
        if (document.getElementById('ttsControlPanel')) {
            return;
        }

        const controlPanel = document.createElement('div');
        controlPanel.id = 'ttsControlPanel';
        controlPanel.innerHTML = `
            <div style="padding: 5px; cursor: move; border-bottom: 1px solid #ccc; background: #f0f0f0; border-radius: 5px 5px 0 0; font-size: 12px;">📢 Ридер Текста</div>
            <div style="padding: 5px;">
                <button id="ttsPlay" style="margin: 2px; padding: 5px;">▶️</button>
                <button id="ttsPause" style="margin: 2px; padding: 5px;">⏸️</button>
                <button id="ttsStop" style="margin: 2px; padding: 5px;">⏹️</button>
                <button id="ttsFaster" style="margin: 2px; padding: 5px; font-size: 10px;">⏩ +</button>
                <button id="ttsSlower" style="margin: 2px; padding: 5px; font-size: 10px;">⏪ -</button>
                <div style="margin-top: 5px; font-size: 11px; text-align: center;">
                    Скорость: <span id="speedValue">1.0</span>x
                </div>
                <button id="ttsResetSpeed" style="margin: 2px; padding: 3px; font-size: 9px; width: 100%;">Сбросить скорость</button>
            </div>
            <div style="padding: 5px; background: #f9f9f9; border-top: 1px solid #eee; font-size: 9px; text-align: center; color: #666;">
                Двойной клик по тексту для чтения с места
            </div>
        `;

        Object.assign(controlPanel.style, {
            position: 'fixed',
            top: '20px',
            right: '20px',
            background: 'white',
            border: '1px solid #ccc',
            borderRadius: '5px',
            zIndex: '10000',
            boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
            userSelect: 'none',
            minWidth: '140px',
            fontFamily: 'Arial, sans-serif'
        });

        document.body.appendChild(controlPanel);

        return controlPanel;
    }

    // Функции для работы с localStorage
    function saveSettings() {
        try {
            localStorage.setItem('ttsReadingSpeed', currentRate.toString());
        } catch (e) {
            console.log('Не удалось сохранить настройки:', e);
        }
    }

    function loadSettings() {
        try {
            const savedSpeed = localStorage.getItem('ttsReadingSpeed');
            if (savedSpeed) {
                const speed = parseFloat(savedSpeed);
                if (speed >= 0.5 && speed <= 3.0) {
                    currentRate = speed;
                }
            }
        } catch (e) {
            console.log('Не удалось загрузить настройки:', e);
        }
    }

    // Ждем полной загрузки DOM
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    // Переменные для управления речью
    let speechInstance = null;
    let isPaused = false;
    let currentRate = 1.0;
    let currentText = '';
    let isManualStop = false;
    let wordBoundaries = [];
    let currentWordIndex = 0;
    let speechStartTime = 0;
    let estimatedPosition = 0;

    function init() {
        setTimeout(() => {
            const controlPanel = createControlPanel();
            const supportPanel = createSupportPanel();
            if (controlPanel) {
                loadSettings();
                setupEventListeners();
                setupDoubleClickHandler();
                makePanelDraggable(controlPanel);
                updateSpeedDisplay();
            }
        }, 1000);
    }

    // Функция для настройки обработчика двойного клика
    function setupDoubleClickHandler() {
        document.addEventListener('dblclick', handleDoubleClick);
    }

    // Обработчик двойного клика
    function handleDoubleClick(event) {
        if (event.target.closest('#ttsControlPanel') || event.target.closest('#ttsSupportPanel')) {
            return;
        }

        const selection = window.getSelection();
        let targetElement = event.target;
        
        if (selection.toString().trim().length > 0) {
            const range = selection.getRangeAt(0);
            targetElement = range.commonAncestorContainer;
            
            if (targetElement.nodeType === Node.TEXT_NODE) {
                targetElement = targetElement.parentElement;
            }
        }

        const startPosition = findTextPosition(targetElement);
        if (startPosition !== -1) {
            playFromPosition(startPosition);
        }
    }

    // Находим позицию в тексте для начала чтения
    function findTextPosition(element) {
        if (!currentText) {
            currentText = extractMainText();
            if (!currentText) {
                alert('Сначала нажмите кнопку воспроизведения для извлечения текста');
                return -1;
            }
            wordBoundaries = calculateWordBoundaries(currentText);
        }

        const elementText = element.textContent || '';
        if (!elementText.trim()) return -1;

        const elementTextClean = cleanTextSimple(elementText);
        const position = currentText.indexOf(elementTextClean);
        
        if (position === -1) {
            const words = elementTextClean.split(/\s+/).filter(w => w.length > 2);
            if (words.length > 0) {
                for (let word of words) {
                    const wordPosition = currentText.indexOf(word);
                    if (wordPosition !== -1) {
                        return wordPosition;
                    }
                }
            }
            return -1;
        }

        return position;
    }

    // Функция для обновления отображения скорости
    function updateSpeedDisplay() {
        const speedElement = document.getElementById('speedValue');
        if (speedElement) {
            speedElement.textContent = currentRate.toFixed(1);
            if (currentRate > 1.0) {
                speedElement.style.color = '#e74c3c';
            } else if (currentRate < 1.0) {
                speedElement.style.color = '#3498db';
            } else {
                speedElement.style.color = '#2c3e50';
            }
        }
    }

    // ПРОСТАЯ функция для извлечения текста
    function extractMainText() {
        // Стратегия 1: Ищем по специфичным селекторам
        const selectors = [
            'article', 'main', '[role="main"]', '.article', '.story', '.news',
            '.content', '.post-content', '.article-content', '.news-content',
            '.entry-content', '.story-content', '.text-content', '.text'
        ];

        for (let selector of selectors) {
            const elements = document.querySelectorAll(selector);
            for (let element of elements) {
                if (isValidContentElement(element)) {
                    const text = extractCleanText(element);
                    if (text && text.length > 200) {
                        return text;
                    }
                }
            }
        }

        // Стратегия 2: Ищем самый большой текстовый блок
        const elements = document.querySelectorAll('p, div, section, article, main');
        let bestElement = null;
        let maxLength = 0;

        for (let element of elements) {
            if (isValidContentElement(element)) {
                const text = extractCleanText(element);
                if (text && text.length > maxLength) {
                    maxLength = text.length;
                    bestElement = element;
                }
            }
        }

        if (bestElement && maxLength > 300) {
            return extractCleanText(bestElement);
        }

        return null;
    }

    // Извлекаем чистый текст из элемента
    function extractCleanText(element) {
        const clone = element.cloneNode(true);
        removeUnwantedElements(clone);
        
        const paragraphs = clone.querySelectorAll('p, h1, h2, h3, h4, h5, h6');
        const textParts = [];
        
        for (let p of paragraphs) {
            const text = cleanTextSimple(p.textContent);
            if (text && text.length > 20) {
                textParts.push(text);
            }
        }
        
        return textParts.length > 0 ? textParts.join('\n\n') : cleanTextSimple(clone.textContent);
    }

    // УПРОЩЕННАЯ очистка текста
    function cleanTextSimple(text) {
        if (!text) return '';
        
        return text
            .replace(/https?:\/\/[^\s]+/g, '')
            .replace(/www\.[^\s]+/g, '')
            .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '')
            .replace(/\b[a-zA-Z]{1,2}\b/g, '')
            .replace(/[^\w\u0400-\u04FF\s\.\,\!\?\-\:\(\)«»]/g, '')
            .replace(/\s+/g, ' ')
            .replace(/\n+/g, ' ')
            .trim();
    }

    // Удаляем только очевидный мусор
    function removeUnwantedElements(element) {
        const unwantedSelectors = [
            'nav', 'header', 'footer', 'aside', 'menu',
            '.nav', '.navigation', '.header', '.footer', '.sidebar',
            '.menu', '.breadcrumb', '.pagination',
            '.ad', '.advertisement', '.banner', '.ads',
            '.social', '.share', '.comments', '.comment',
            'script', 'style', 'img', 'picture', 'video', 'audio', 'iframe'
        ];
        
        unwantedSelectors.forEach(selector => {
            const elements = element.querySelectorAll(selector);
            elements.forEach(el => el.remove());
        });
    }

    // Проверяем валидность контентного элемента
    function isValidContentElement(element) {
        if (!element || element.offsetParent === null) return false;
        
        const style = window.getComputedStyle(element);
        if (style.display === 'none' || style.visibility === 'hidden') return false;
        
        if (element.closest('nav, header, footer, aside, .nav, .header, .footer, .sidebar')) {
            return false;
        }
        
        return true;
    }

    // Функция для разбивки текста на слова
    function calculateWordBoundaries(text) {
        const words = text.split(/\s+/);
        const boundaries = [];
        let position = 0;
        
        for (let word of words) {
            if (word.trim()) {
                boundaries.push({
                    start: position,
                    end: position + word.length,
                    word: word
                });
                position += word.length + 1;
            }
        }
        
        return boundaries;
    }

    // Функция для оценки текущей позиции
    function estimateCurrentPosition() {
        if (!speechStartTime) return 0;
        
        const elapsedTime = (Date.now() - speechStartTime) / 1000;
        const estimatedChars = Math.floor(elapsedTime * currentRate * 12);
        
        return Math.min(estimatedChars, currentText.length);
    }

    // Функция для поиска текущего слова по позиции
    function findCurrentWordIndex(position) {
        for (let i = 0; i < wordBoundaries.length; i++) {
            if (position >= wordBoundaries[i].start && position < wordBoundaries[i].end) {
                return i;
            }
        }
        return Math.min(position, wordBoundaries.length - 1);
    }

    // Функция для начала чтения с позиции
    function playFromPosition(startPosition = 0) {
        if (!currentText) {
            currentText = extractMainText();
            if (!currentText || currentText.length < 50) {
                alert('Не удалось найти текст для чтения.');
                return;
            }
            wordBoundaries = calculateWordBoundaries(currentText);
        }

        const textToRead = currentText.substring(startPosition);
        if (textToRead.length < 10) return;

        speechSynthesis.cancel();

        speechInstance = new SpeechSynthesisUtterance(textToRead);
        speechInstance.lang = 'ru-RU';
        speechInstance.rate = currentRate;

        speechStartTime = Date.now();
        currentWordIndex = findCurrentWordIndex(startPosition);

        speechInstance.onend = () => {
            if (!isManualStop) {
                speechInstance = null;
                isPaused = false;
                currentWordIndex = 0;
            }
            isManualStop = false;
        };

        speechInstance.onerror = (event) => {
            if (!isManualStop && event.error !== 'interrupted') {
                console.error('Ошибка TTS:', event);
            }
            isManualStop = false;
        };

        isManualStop = false;
        speechSynthesis.speak(speechInstance);
    }

    // Основная функция чтения
    function playText() {
        if (isPaused && speechInstance) {
            speechSynthesis.resume();
            speechStartTime = Date.now() - (estimatedPosition / (currentRate * 12)) * 1000;
            isPaused = false;
            return;
        }

        if (speechSynthesis.speaking && !isPaused) {
            estimatedPosition = estimateCurrentPosition();
            playFromPosition(estimatedPosition);
            return;
        }

        currentText = extractMainText();
        if (!currentText || currentText.length < 50) {
            alert('Не удалось найти текст для чтения.');
            return;
        }

        wordBoundaries = calculateWordBoundaries(currentText);
        currentWordIndex = 0;
        playFromPosition(0);
    }

    function pauseSpeech() {
        if (speechSynthesis.speaking && !isPaused) {
            estimatedPosition = estimateCurrentPosition();
            speechSynthesis.pause();
            isPaused = true;
        }
    }

    function stopSpeech() {
        isManualStop = true;
        speechSynthesis.cancel();
        isPaused = false;
        speechInstance = null;
        currentWordIndex = 0;
    }

    function increaseSpeed() {
        if (currentRate < 3.0) {
            currentRate += 0.1;
            updateSpeedDisplay();
            saveSettings();
            
            if (speechSynthesis.speaking && !isPaused) {
                estimatedPosition = estimateCurrentPosition();
                playFromPosition(estimatedPosition);
            }
        }
    }

    function decreaseSpeed() {
        if (currentRate > 0.5) {
            currentRate -= 0.1;
            updateSpeedDisplay();
            saveSettings();
            
            if (speechSynthesis.speaking && !isPaused) {
                estimatedPosition = estimateCurrentPosition();
                playFromPosition(estimatedPosition);
            }
        }
    }

    function resetSpeedToDefault() {
        currentRate = 1.0;
        updateSpeedDisplay();
        saveSettings();
    }

    function setupEventListeners() {
        document.getElementById('ttsPlay').addEventListener('click', playText);
        document.getElementById('ttsPause').addEventListener('click', pauseSpeech);
        document.getElementById('ttsStop').addEventListener('click', stopSpeech);
        document.getElementById('ttsFaster').addEventListener('click', increaseSpeed);
        document.getElementById('ttsSlower').addEventListener('click', decreaseSpeed);
        document.getElementById('ttsResetSpeed').addEventListener('click', resetSpeedToDefault);
    }

    function makePanelDraggable(panel) {
        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
        const header = panel.querySelector('div');

        header.onmousedown = dragMouseDown;

        function dragMouseDown(e) {
            e.preventDefault();
            pos3 = e.clientX;
            pos4 = e.clientY;
            document.onmouseup = closeDragElement;
            document.onmousemove = elementDrag;
        }

        function elementDrag(e) {
            e.preventDefault();
            pos1 = pos3 - e.clientX;
            pos2 = pos4 - e.clientY;
            pos3 = e.clientX;
            pos4 = e.clientY;
            panel.style.top = (panel.offsetTop - pos2) + "px";
            panel.style.left = (panel.offsetLeft - pos1) + "px";
        }

        function closeDragElement() {
            document.onmouseup = null;
            document.onmousemove = null;
        }
    }
})();