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