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

Простой скрипт для чтения вслух основного текста страницы с перемещаемой панелью управления.

当前为 2025-10-05 提交的版本,查看 最新版本

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

/*
 * MIT License
 * 
 * Copyright (c) 2024 Простой Ридер Текста
 * 
 * Данная лицензия разрешает лицам, получившим копию данного программного обеспечения
 * и сопутствующей документации (в дальнейшем «Программное обеспечение»), безвозмездно
 * использовать Программное обеспечение без ограничений, включая неограниченное право
 * на использование, копирование, изменение, объединение, публикацию, распространение,
 * сублицензирование и/или продажу копий Программного обеспечения, а также лицам, которым
 * предоставляется данное Программное обеспечение, при соблюдении следующих условий:
 * 
 * Указанное выше уведомление об авторском праве и данные условия должны быть включены во
 * все копии или значимые части Программного обеспечения.
 * 
 * ДАННОЕ ПРОГРАММНОЕ ОБЕСПЕЧЕНИЕ ПРЕДОСТАВЛЯЕТСЯ «КАК ЕСТЬ», БЕЗ КАКИХ-ЛИБО ГАРАНТИЙ,
 * ЯВНО ВЫРАЖЕННЫХ ИЛИ ПОДРАЗУМЕВАЕМЫХ, ВКЛЮЧАЯ, НО НЕ ОГРАНИЧИВАЯСЬ ГАРАНТИЯМИ ТОВАРНОЙ
 * ПРИГОДНОСТИ, СООТВЕТСТВИЯ ПО ЕГО КОНКРЕТНОМУ НАЗНАЧЕНИЮ И НЕНАРУШЕНИЯ ПРАВ. НИ В КАКОМ
 * СЛУЧАЕ АВТОРЫ ИЛИ ПРАВООБЛАДАТЕЛИ НЕ НЕСУТ ОТВЕТСТВЕННОСТИ ПО ИСКАМ О ВОЗМЕЩЕНИИ УЩЕРБА,
 * УБЫТКОВ ИЛИ ДРУГИХ ТРЕБОВАНИЙ ПО ДЕЙСТВУЮЩЕМУ ПРАВУ, ИЛИ ПО ИНЫМ ПРИЧИНАМ, ВОЗНИКШИМ
 * ИЗ-ЗА ИСПОЛЬЗОВАНИЯ ПРОГРАММНОГО ОБЕСПЕЧЕНИЯ ИЛИ ИНЫХ ДЕЙСТВИЙ С ПРОГРАММНЫМ ОБЕСПЕЧЕНИЕМ.
 */

(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>
        `;

        // Стили для панели
        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: '120px',
            fontFamily: 'Arial, sans-serif'
        });

        document.body.appendChild(controlPanel);

        return controlPanel;
    }

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

    function loadSpeedFromStorage() {
        try {
            const savedSpeed = localStorage.getItem('ttsReadingSpeed');
            if (savedSpeed) {
                const speed = parseFloat(savedSpeed);
                // Проверяем, что значение в допустимом диапазоне
                if (speed >= 0.5 && speed <= 3.0) {
                    return speed;
                }
            }
        } catch (e) {
            console.log('Не удалось загрузить скорость из localStorage:', e);
        }
        return 1.0; // Значение по умолчанию
    }

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

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

    function init() {
        // Даем небольшую задержку для полной инициализации страницы
        setTimeout(() => {
            const controlPanel = createControlPanel();
            const supportPanel = createSupportPanel(); // Создаем панель поддержки
            if (controlPanel) {
                // Загружаем сохраненную скорость ДО настройки обработчиков
                currentRate = loadSpeedFromStorage();
                setupEventListeners();
                makePanelDraggable(controlPanel);
                updateSpeedDisplay();
            }
        }, 1000);
    }

    // Переменные для управления речью
    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 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 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; // +1 для пробела
            }
        }
        
        return boundaries;
    }

    // Функция для оценки текущей позиции в тексте
    function estimateCurrentPosition() {
        if (!speechStartTime) return 0;
        
        const elapsedTime = (Date.now() - speechStartTime) / 1000; // в секундах
        const estimatedChars = Math.floor(elapsedTime * currentRate * 15); // ~15 символов в секунду при скорости 1.0
        
        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 extractMainText() {
        // Специфичные селекторы для новостных сайтов
        const selectors = [
            'article', 
            'main', 
            '[role="main"]', 
            '.content', 
            '.post-content', 
            '.article', 
            '.news-text',
            '.text',
            '.story',
            '[class*="content"]',
            '[class*="article"]',
            '[class*="text"]',
            // Специфичные для interfax
            '.at',
            '.textMT',
            '.article-content'
        ];

        for (let selector of selectors) {
            const elements = document.querySelectorAll(selector);
            for (let element of elements) {
                const text = element.textContent.trim();
                if (text.length > 200 && !isNavigation(element)) {
                    return cleanText(text);
                }
            }
        }

        // Альтернативный подход: ищем самый большой текстовый блок
        const allElements = document.querySelectorAll('p, div, section, article');
        let bestElement = null;
        let maxLength = 0;

        for (let element of allElements) {
            const text = element.textContent.trim();
            if (text.length > maxLength && !isNavigation(element)) {
                maxLength = text.length;
                bestElement = element;
            }
        }

        if (bestElement && maxLength > 200) {
            return cleanText(bestElement.textContent);
        }

        // Если ничего не нашли, берем body
        return cleanText(document.body.textContent);
    }

    // Функция для проверки, является ли элемент навигацией
    function isNavigation(element) {
        const navSelectors = ['nav', 'header', 'footer', 'menu', '.nav', '.header', '.footer', '.menu'];
        const tagName = element.tagName.toLowerCase();
        const className = element.className.toLowerCase();
        
        return navSelectors.some(selector => 
            tagName === selector.replace('.', '') || 
            className.includes(selector.replace('.', ''))
        );
    }

    // Функция для очистки текста
    function cleanText(text) {
        return text
            .replace(/\s+/g, ' ')
            .replace(/\n+/g, ' ')
            .trim();
    }

    // Функция для начала чтения с определенной позиции
    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) {
            // Достигнут конец текста
            speechInstance = null;
            isPaused = false;
            currentWordIndex = 0;
            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) => {
            console.error('Ошибка синтеза речи:', event);
            if (!isManualStop && event.error !== 'interrupted') {
                alert('Произошла ошибка при синтезе речи. Проверьте поддержку TTS в вашем браузере.');
            }
            isManualStop = false;
        };

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

    // Функция для начала или возобновления чтения
    function playText() {
        if (isPaused && speechInstance) {
            speechSynthesis.resume();
            speechStartTime = Date.now() - (estimatedPosition / (currentRate * 15)) * 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 setupEventListeners() {
        const playBtn = document.getElementById('ttsPlay');
        const pauseBtn = document.getElementById('ttsPause');
        const stopBtn = document.getElementById('ttsStop');
        const fasterBtn = document.getElementById('ttsFaster');
        const slowerBtn = document.getElementById('ttsSlower');
        const resetBtn = document.getElementById('ttsResetSpeed');

        if (playBtn) playBtn.addEventListener('click', playText);
        if (pauseBtn) pauseBtn.addEventListener('click', pauseSpeech);
        if (stopBtn) stopBtn.addEventListener('click', stopSpeech);
        if (fasterBtn) fasterBtn.addEventListener('click', increaseSpeed);
        if (slowerBtn) slowerBtn.addEventListener('click', decreaseSpeed);
        if (resetBtn) resetBtn.addEventListener('click', resetSpeedToDefault);
    }

    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();
            saveSpeedToStorage(currentRate);
            
            // Перезапускаем с текущей позиции
            if (speechSynthesis.speaking && !isPaused) {
                estimatedPosition = estimateCurrentPosition();
                playFromPosition(estimatedPosition);
            }
        }
    }

    function decreaseSpeed() {
        if (currentRate > 0.5) {
            currentRate -= 0.1;
            updateSpeedDisplay();
            saveSpeedToStorage(currentRate);
            
            // Перезапускаем с текущей позиции
            if (speechSynthesis.speaking && !isPaused) {
                estimatedPosition = estimateCurrentPosition();
                playFromPosition(estimatedPosition);
            }
        }
    }

    // Функция для реализации перетаскивания панели
    function makePanelDraggable(panel) {
        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
        const header = panel.querySelector('div');

        if (!header) return;

        header.onmousedown = dragMouseDown;

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

        function elementDrag(e) {
            e = e || window.event;
            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;
        }
    }
})();