MediaWiki:Gadget-speechSynthewiki.js

Материал из Абсурдопедии
Перейти к навигации Перейти к поиску

Замечание: Возможно, после публикации вам придётся очистить кэш своего браузера, чтобы увидеть изменения.

  • Firefox / Safari: Удерживая клавишу Shift, нажмите на панели инструментов Обновить либо нажмите Ctrl+F5 или Ctrl+R (⌘+R на Mac)
  • Google Chrome: Нажмите Ctrl+Shift+R (⌘+Shift+R на Mac)
  • Edge: Удерживая Ctrl, нажмите Обновить либо нажмите Ctrl+F5
  • Opera: Нажмите Ctrl+F5.
/** 
 * Speech Synthewiki — гаджет озвучки статей браузером для скина Vector-10
 * Версия 0.3.1 (absurdoversion, beta) — ещё надо поработать над озвучкой формул
 * Кнопки по языку интерфейса пользователя, пока поддержка 4 языков
 * Лицензия: CC BY-SA 4.0 (источник: absurdopedia.wiki)
*/

/*****************************************************************/
   // ИСПОЛЬЗОВАНИЕ
   // Возле каждого раздела статьи появляется ссылка «озвучить»
   // Нажимаешь → читает весь текст под этим заголовком
   // Если хочешь остановить — жмёшь «стоп» (кнопка меняется)
   // Когда раздел заканчивается — воспроизведение останавливается
   // Кнопка «озвучить всё» озвучивает всю статью
   // Исключается шаблон «Месяц» (таблица с 10+ месяцами подряд)
/*****************************************************************/

(function () {
    'use strict';

    var currentUtterance = null;
    var currentButton = null;
    var voicesReady = false;

    // ---------- разрешённые пространства имён ----------
    var ALLOWED_NS = [0, 4, 100, 114, 116, 118, 120];


    // ---------- локализация ----------
    var I18N = {
        ru: {
            speak: 'озвучить',
            stop: 'стоп',
            speakAll: '🔊 озвучить всё',
            noText: 'Под этим заголовком нет текста для озвучки',
            noSynth: 'Браузер не поддерживает синтез речи'
        },
        en: {
            speak: 'listen',
            stop: 'stop',
            speakAll: '🔊 listen to all'
        },
        eo: {
            speak: 'laŭtlegi',
            stop: 'halti',
            speakAll: '🔊 laŭtlegi ĉion'
        },
        udm: {
            speak: 'куараё карыны',
            stop: 'дугдытыны',
            speakAll: '🔊 ваньзэ куараё карыны'
        }
    };

    function detectLang() {
        try {
            var explicit = mw.user.options.get('gadget-SpeechSynthewiki-lang');
            if (explicit && I18N[explicit]) return explicit;
        } catch (e) { /* ignore */ }

        var ui = (mw.config.get('wgUserLanguage') || '').toLowerCase();
        if (ui.indexOf('udm') === 0) return 'udm';
        if (ui.indexOf('en') === 0) return 'en';
        if (ui.indexOf('eo') === 0) return 'eo';
        if (ui.indexOf('ru') === 0) return 'ru';

        var content = (mw.config.get('wgContentLanguage') || '').toLowerCase();
        if (I18N[content]) return content;

        return 'ru';
    }

    var LANG = detectLang();
    var T = I18N[LANG] || I18N.ru;

    function msg(key) {
        return (T && T[key]) || I18N.ru[key] || '';
    }

    // ---------- служебные элементы, которые не озвучиваем ----------
    var SKIP_SELECTORS = [
        '#siteSub', '#contentSub', '#jump-to-nav', '.mw-jump-link',
        '.mw-indicators', '.catlinks', '.printfooter', '.mw-hidden-catlinks',
        '.noprint', '.metadata',
        '.navbox', '.vertical-navbox', '.sistersitebox', '.ambox',
        '.mbox-text-span', '.mw-references-wrap', 'ol.references',
        '.reference', 'sup.reference', '.reflist',
        'style', 'script',
        '.mw-empty-elt', '.mw-kartographer-maplink',
        '.cyclo-tts-start', '.cyclo-tts-lead',
        '.mw-editsection', '.editsection',
        '#toc', '.toc', '.mw-toc', '#mw-toc', '#mw-toc-heading',
        '#toctitle', '.toctitle', '.vector-toc', '.vector-toc-heading',
        '#mw-panel-toc', '.tocnumber', '.toctext',
        '.sidebar-toc', '.minerva-toc'
    ].join(',');

    var SKIP_TOP = [
        'h1.firstHeading', '#siteSub', '#contentSub', '.mw-indicators',
        '.mw-jump-link', '.printfooter', '.catlinks',
        '.cyclo-tts-lead', '.cyclo-tts-start',
        '#toc', '.toc', '#toctitle', '.toctitle',
        '.vector-toc', '.mw-toc', '#mw-panel-toc'
    ].join(',');

    // ---------- признаки шаблона «Месяц» ----------
    var MONTH_NAMES = [
        'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
        'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
    ];

    function isMonthTable($table) {
        var t = $table.text();
        var count = 0;
        for (var i = 0; i < MONTH_NAMES.length; i++) {
            if (t.indexOf(MONTH_NAMES[i]) !== -1) count++;
        }
        return count >= 10;
    }

    // ---------- голоса ----------
    function getRussianVoice() {
        var voices = window.speechSynthesis.getVoices() || [];
        return voices.find(function (v) {
            return /^ru/i.test(v.lang) || /рус/i.test(v.name);
        }) || null;
    }

    function ensureVoices(cb) {
        if (voicesReady) { cb(); return; }
        var v = window.speechSynthesis.getVoices();
        if (v && v.length) { voicesReady = true; cb(); return; }
        var handler = function () {
            voicesReady = true;
            window.speechSynthesis.removeEventListener('voiceschanged', handler);
            cb();
        };
        window.speechSynthesis.addEventListener('voiceschanged', handler);
    }

    // ---------- сброс всех кнопок в исходное состояние ----------
    function resetAllButtons() {
        $('.cyclo-tts-start a, .cyclo-tts-lead a').each(function () {
            var $btn = $(this);
            if ($btn.data('lead') === 'full') {
                $btn.text(T.speakAll);
            } else {
                $btn.text(T.speak);
            }
        });
        currentButton = null;
        currentUtterance = null;
    }

    // ---------- очистка клона ----------
    function cleanClone(cloned) {
        cloned.find([
            '.thumb', '.thumbinner', '.thumbcaption', '.magnify',
            '.floatnone', '.floatleft', '.floatright',
            'ul.gallery', 'div.gallery', 'li.gallerybox', 'div.thumb',
            '.gallerycaption', 'sup.reference', '.metadata', '.noprint',
            // подписи к картинкам
            'figcaption', '.mw-caption', '.image-caption', '.caption',
            'img', 'a.image', 'style', 'script'
        ].join(',')).remove();

        // Вырезаем таблицу шаблона «Месяц» по содержимому
        cloned.find('table').filter(function () {
            return isMonthTable($(this));
        }).remove();

        cloned.contents().each(function () {
            if (this.nodeType === 3) {
                if (/(Файл|File|Image|Изображение|Медиа):/i.test(this.nodeValue)) {
                    this.nodeValue = '';
                }
            } else if (this.nodeType === 1) {
                var $el = $(this);
                var href = $el.attr('href') || '';
                if ($el.is('a') && /\.(jpg|jpeg|png|gif|bmp|svg|webp|ogg|mp3|wav|pdf|djvu)(\?|$)/i.test(href)) {
                    $el.remove();
                } else if ($el.hasClass('image') || $el.hasClass('internal') || href.indexOf('Файл:') !== -1) {
                    $el.remove();
                }
            }
        });
        return cloned;
    }

    // ---------- MathML → речь ----------
    function mathToSpeech($math) {
        var $clone = $math.clone();
        $clone.find('annotation').remove();
        var parts = [];

        $clone.find('msqrt').each(function () {
            parts.push(' корень из ' + $(this).text().trim() + ' ');
            $(this).remove();
        });
        $clone.find('mfrac').each(function () {
            var $ch = $(this).children();
            parts.push(' дробь ' + $ch.eq(0).text().trim() + ' на ' + $ch.eq(1).text().trim() + ' ');
            $(this).remove();
        });
        $clone.find('msup').each(function () {
            var $ch = $(this).children();
            var base = $ch.eq(0).text().trim();
            var exp = $ch.eq(1).text().trim();
            if (exp === '2') parts.push(base + ' в квадрате ');
            else if (exp === '3') parts.push(base + ' в кубе ');
            else parts.push(base + ' в степени ' + exp + ' ');
            $(this).remove();
        });
        $clone.find('msub').each(function () {
            var $ch = $(this).children();
            parts.push($ch.eq(0).text().trim() + ' с индексом ' + $ch.eq(1).text().trim() + ' ');
            $(this).remove();
        });

        var rest = $clone.text().trim()
            .replace(/×/g, ' умножить ')
            .replace(/÷/g, ' разделить ')
            .replace(/≠/g, ' не равно ')
            .replace(/≈/g, ' приблизительно равно ')
            .replace(/≤/g, ' меньше или равно ')
            .replace(/≥/g, ' больше или равно ')
            .replace(/±/g, ' плюс минус ')
            .replace(/∞/g, ' бесконечность ')
            .replace(/√/g, ' корень из ')
            .replace(/π/g, ' пи ')
            .replace(/∑/g, ' сумма ')
            .replace(/∏/g, ' произведение ')
            .replace(/∫/g, ' интеграл ')
            .replace(/∂/g, ' частная производная ')
            .replace(/∇/g, ' набла ')
            .replace(/∈/g, ' принадлежит ')
            .replace(/∉/g, ' не принадлежит ')
            .replace(/∩/g, ' пересечение ')
            .replace(/∪/g, ' объединение ')
            .replace(/⊂/g, ' подмножество ')
            .replace(/⊃/g, ' надмножество ');
        if (rest) parts.push(rest);

        return parts.join(' ').trim();
    }

    // ---------- сбор текста секции ----------
    function buildSpeechText($startNode, $stopNode) {
        var chunks = [];
        var $node = $startNode;

        while ($node.length) {
            if ($stopNode && $node[0] === $stopNode[0]) break;
            if (!$stopNode && $node.is('h1, h2, h3, h4, h5, h6') && $node[0] !== $startNode[0]) break;
            if (!$stopNode && $node.is('.mw-heading')) break;

            var $cloned = cleanClone($node.clone());
            $cloned.find(SKIP_SELECTORS).remove();
            if ($cloned.is(SKIP_SELECTORS)) {
                $node = $node.next();
                continue;
            }
            $cloned.find('math').each(function () {
                var spoken = mathToSpeech($(this));
                if (spoken) $(this).replaceWith(document.createTextNode(' формула: ' + spoken + ' '));
                else $(this).remove();
            });

            var t = $cloned.text().replace(/\s+/g, ' ').trim();
            if (t) chunks.push(t);
            $node = $node.next();
        }
        return chunks.join('\n');
    }

    // ---------- сбор текста преамбулы ----------
    function buildLeadText() {
        var $root = $('#mw-content-text .mw-parser-output');
        if (!$root.length) $root = $('#mw-content-text');
        if (!$root.length) return '';

        // первый заголовок — либо сам h1..h6, либо его обёртка .mw-heading
        var $firstHeader = $root.find('h1, h2, h3, h4, h5, h6, .mw-heading').first();
        var stopEl = $firstHeader.length ? $firstHeader[0] : null;

        var chunks = [];
        $root.children().each(function () {
            // останавливаемся на первом заголовке или на его обёртке
            if (stopEl && (this === stopEl || $(this).is(stopEl) || $(this).find(stopEl).length)) {
                return false;
            }
            var $el = $(this);

            // Пропускаем таблицу шаблона «Месяц» по содержимому
            if ($el.is('table') && isMonthTable($el)) {
                return true;
            }

            if ($el.is(SKIP_TOP)) return true;
            if ($el.find('#toctitle, .toctitle').length) return true;

            var $cloned = cleanClone($el.clone());
            $cloned.find(SKIP_SELECTORS).remove();
            if ($cloned.is(SKIP_SELECTORS)) return true;

            $cloned.find('math').each(function () {
                var spoken = mathToSpeech($(this));
                if (spoken) $(this).replaceWith(document.createTextNode(' формула: ' + spoken + ' '));
                else $(this).remove();
            });

            var t = $cloned.text().replace(/\s+/g, ' ').trim();
            if (t) chunks.push(t);
            return true;
        });

        return chunks.join('\n');
    }

    // ---------- сбор текста всей статьи ----------
    function buildFullText() {
        var $root = $('#mw-content-text .mw-parser-output');
        if (!$root.length) $root = $('#mw-content-text');
        if (!$root.length) return '';

        var chunks = [];

        var lead = buildLeadText();
        if (lead) chunks.push(lead);

        $root.find('.mw-heading, h1, h2, h3, h4, h5, h6').each(function () {
            var $h = $(this);
            if ($h.is('#mw-toc-heading, #toctitle, .toctitle')) return;
            if ($h.closest('#toc, .toc, .vector-toc, .mw-toc, #mw-panel-toc').length) return;
            if ($h.find('#toctitle, .toctitle').length) return;

            var $realHeader = $h.is('.mw-heading')
                ? $h.children('h1,h2,h3,h4,h5,h6')
                : $h;
            var $start = $realHeader.next();
            if (!$start.length) $start = $realHeader.parent().next();
            var $stop = $start.nextAll('.mw-heading, h1, h2, h3, h4, h5, h6').first();
            var t = $start.length ? buildSpeechText($start, $stop.length ? $stop : null) : '';
            if (t) chunks.push(t);
        });

        return chunks.join('\n');
    }

    // ---------- клик ----------
    function onClick(event) {
        event.preventDefault();
        var a = event.currentTarget;

        if (currentButton === a && window.speechSynthesis.speaking) {
            window.speechSynthesis.cancel();
            $(a).text($(a).data('lead') === 'full' ? T.speakAll : T.speak);
            currentButton = null;
            currentUtterance = null;
            return;
        }

        var text;
        var mode = $(a).data('lead');
        if (mode === 'lead-only') {
            text = buildLeadText();
        } else if (mode === 'full') {
            text = buildFullText();
        } else {
            var $heading = $(a).closest('.mw-heading, h1, h2, h3, h4, h5, h6');
            var $realHeader = $heading.is('.mw-heading')
                ? $heading.children('h1,h2,h3,h4,h5,h6')
                : $heading;

            var $start = $realHeader.next();
            if (!$start.length) $start = $realHeader.parent().next();
            var $stop = $start.nextAll('.mw-heading, h1, h2, h3, h4, h5, h6').first();
            text = $start.length ? buildSpeechText($start, $stop.length ? $stop : null) : '';
        }

        if (!text) {
            resetAllButtons();
            mw.notify(msg('noText'), { type: 'warn' });
            return;
        }

        if (window.speechSynthesis.speaking) {
            window.speechSynthesis.cancel();
            if (currentButton) $(currentButton).text(
                $(currentButton).data('lead') === 'full' ? T.speakAll : T.speak
            );
        }

        currentUtterance = new SpeechSynthesisUtterance(text);
        currentUtterance.lang = 'ru-RU';
        currentUtterance.rate = 1.0;
        currentUtterance.pitch = 1.0;
        currentUtterance.volume = 1.0;

        ensureVoices(function () {
            var v = getRussianVoice();
            if (v) currentUtterance.voice = v;

            currentUtterance.onstart = function () {
                $(a).text(T.stop);
                currentButton = a;
            };
            currentUtterance.onend = function () {
                $(a).text(
                    $(a).data('lead') === 'full' ? T.speakAll : T.speak
                );
                if (currentButton === a) { currentButton = null; currentUtterance = null; }
            };
            currentUtterance.onerror = function (ev) {
                if (ev.error !== 'interrupted' && ev.error !== 'canceled') {
                    var restore = $(a).data('lead') === 'full' ? T.speakAll : T.speak;
                    if ($(a).text() !== restore) $(a).text(restore);
                    if (currentButton === a) { currentButton = null; currentUtterance = null; }
                }
            };

            window.speechSynthesis.speak(currentUtterance);
        });
    }

    // ---------- вставка кнопок ----------
    function addButtons() {
        // Только в разрешённых пространствах имён
        var ns = mw.config.get('wgNamespaceNumber');
        if (ALLOWED_NS.indexOf(ns) === -1) return;

        var $root = $('#mw-content-text .mw-parser-output');
        if (!$root.length) $root = $('#mw-content-text');
        if (!$root.length) return;

        // --- кнопки по краям: «озвучить всё» слева, «озвучить» справа ---
        if (!$root.find('.cyclo-tts-lead').length) {
            var $leadBar = $('<div class="cyclo-tts-lead" style="margin:.5em 0;display:flex;flex-wrap:wrap;gap:.5em;justify-content:space-between;align-items:center;"></div>');

            var $fullBtn = $('<a href="#" style="cursor:pointer;"></a>')
                .text(T.speakAll)
                .data('lead', 'full');
            $fullBtn.on('click', onClick);

            var $leadOnlyBtn = $('<a href="#" style="cursor:pointer;"></a>')
                .text(T.speak)
                .data('lead', 'lead-only');
            $leadOnlyBtn.on('click', onClick);

            $leadBar.append($fullBtn).append($leadOnlyBtn);
            $root.prepend($leadBar);
        }

        // --- кнопки у заголовков ---
        $root.find('.mw-heading, h1, h2, h3, h4, h5, h6').each(function () {
            var $h = $(this);
            if ($h.hasClass('cyclo-tts-start')) return;

            if ($h.is('#mw-toc-heading, #toctitle, .toctitle')) return;
            if ($h.closest('#toc, .toc, .vector-toc, .mw-toc, #mw-panel-toc').length) return;
            if ($h.find('#toctitle, .toctitle').length) return;

            var $edit = $h.find('.mw-editsection, .editsection').first();
            if (!$edit.length) return;
            if ($edit.prev().hasClass('cyclo-tts-start')) return;

            var $span = $('<span class="mw-editsection cyclo-tts-start">[<a href="#" style="cursor:pointer;"></a>]</span> ');
            $span.find('a').text(T.speak);
            $span.on('click', 'a', onClick);
            $edit.before($span);
        });
    }

    // ---------- запуск ----------
    mw.loader.using(['jquery'], function () {
        $(function () {
            if (!window.speechSynthesis) {
                console.warn('[Speech Synthewiki] ' + msg('noSynth'));
                return;
            }
            addButtons();

            mw.hook('userjs.languageChanged').add(function () {
                LANG = detectLang();
                T = I18N[LANG] || I18N.ru;
                $('.cyclo-tts-start a').text(T.speak);
                $('.cyclo-tts-lead a').each(function () {
                    var $btn = $(this);
                    if ($btn.data('lead') === 'full') $btn.text(T.speakAll);
                    else $btn.text(T.speak);
                });
            });
        });
    });
})();