// ==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;
}
}
})();