// ==UserScript==
// @name ToastTranslatorVoice
// @namespace https://github.com/StringManolo/userscripts
// @version 1.0
// @description Traduce al español el texto seleccionado. Toca el toast para escuchar la pronunciación (nativa si está en Wiktionary, o sintética de Google).
// @author StringManolo
// @license GPL-3.0-or-later
// @homepageURL https://github.com/StringManolo/userscripts
// @supportURL https://github.com/StringManolo/userscripts/issues
// @match *://*/*
// @noframes
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect translate.googleapis.com
// @connect wiktionary.org
// @connect *.wiktionary.org
// @connect upload.wikimedia.org
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ------------------------------------------------------------------
// Configuración
// ------------------------------------------------------------------
const TARGET_LANG = 'es';
const SELECTION_DEBOUNCE = 400;
const TOAST_DURATION = 6000;
const MAX_TEXT_LENGTH = 300;
let selectionTimer = null;
let lastSelectedText = '';
let currentRequestId = 0;
let lastOriginalText = '';
let lastTranslatedText = '';
let lastDetectedLang = 'en';
let hideTimer = null;
let audioCache = new Map();
// ------------------------------------------------------------------
// Estilos (toast)
// ------------------------------------------------------------------
GM_addStyle(`
#qt-toast {
position: fixed;
left: 50%;
bottom: 24px;
transform: translateX(-50%) translateY(16px);
max-width: 88vw;
background: rgba(20, 20, 22, 0.95);
color: #fff;
padding: 12px 16px;
border-radius: 14px;
font-size: 16px;
line-height: 1.4;
box-shadow: 0 6px 20px rgba(0,0,0,0.35);
z-index: 2147483647;
opacity: 0;
pointer-events: none;
transition: opacity .18s ease, transform .18s ease;
font-family: -apple-system, Roboto, Arial, sans-serif;
cursor: pointer;
}
#qt-toast.qt-visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
pointer-events: auto;
}
#qt-toast b { font-weight: 600; }
#qt-toast .qt-hint {
display: block;
margin-top: 4px;
font-size: 11px;
opacity: .65;
}
#qt-toast.qt-loading .qt-hint {
opacity: .9;
}
`);
// ------------------------------------------------------------------
// Toast UI
// ------------------------------------------------------------------
const toast = document.createElement('div');
toast.id = 'qt-toast';
document.documentElement.appendChild(toast);
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
}
function renderToast(main, hint, { loading = false } = {}) {
toast.innerHTML = `${escapeHtml(main)}${hint}`;
toast.classList.toggle('qt-loading', loading);
toast.classList.add('qt-visible');
clearTimeout(hideTimer);
if (!loading) {
hideTimer = setTimeout(() => toast.classList.remove('qt-visible'), TOAST_DURATION);
}
}
// ------------------------------------------------------------------
// Traducción (Google Translate)
// ------------------------------------------------------------------
function translateText(text) {
const url = 'https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=' +
TARGET_LANG + '&dt=t&q=' + encodeURIComponent(text);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
const translated = data[0].map(seg => seg[0]).join('');
const detectedLang = data[2] || 'en';
resolve({ translated, detectedLang });
} catch (e) {
reject(e);
}
},
onerror: reject,
ontimeout: reject,
timeout: 8000,
});
});
}
function normalizeLang(code) {
return (code || 'en').split('-')[0].toLowerCase();
}
// ------------------------------------------------------------------
// WIKTIONARY (fuente nativa)
// ------------------------------------------------------------------
const LANG_SUBDOMAIN = {
en: 'en', es: 'es', fr: 'fr', de: 'de', it: 'it', pt: 'pt',
ru: 'ru', zh: 'zh', ja: 'ja', ar: 'ar', nl: 'nl', pl: 'pl',
sv: 'sv', ko: 'ko', tr: 'tr', vi: 'vi', id: 'id', ms: 'ms',
};
function fetchWiktionaryParse(word, lang) {
const sub = LANG_SUBDOMAIN[lang] || lang;
const url = `https://${sub}.wiktionary.org/w/api.php?action=parse&page=${encodeURIComponent(word)}&prop=text&format=json&origin=*`;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
const html = data.parse?.text?.['*'] || '';
const audioRegex = /]+src="([^"]+\.(?:ogg|mp3|wav))"/i;
const match = html.match(audioRegex);
if (match) {
let audioUrl = match[1];
if (audioUrl.startsWith('//')) audioUrl = 'https:' + audioUrl;
else if (audioUrl.startsWith('/')) audioUrl = 'https://' + sub + '.wiktionary.org' + audioUrl;
resolve(audioUrl);
} else {
const hrefRegex = /href="([^"]+\.(?:ogg|mp3|wav))"/i;
const hrefMatch = html.match(hrefRegex);
if (hrefMatch) {
let audioUrl = hrefMatch[1];
if (audioUrl.startsWith('//')) audioUrl = 'https:' + audioUrl;
else if (audioUrl.startsWith('/')) audioUrl = 'https://' + sub + '.wiktionary.org' + audioUrl;
resolve(audioUrl);
} else {
resolve(null);
}
}
} catch (e) {
resolve(null);
}
},
onerror: () => resolve(null),
ontimeout: () => resolve(null),
timeout: 5000,
});
});
}
function fetchWiktionaryRaw(word, lang) {
const sub = LANG_SUBDOMAIN[lang] || lang;
const url = `https://${sub}.wiktionary.org/w/index.php?title=${encodeURIComponent(word)}&action=raw`;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: (res) => {
try {
const wikitext = res.responseText;
const audioTemplate = /{{(?:audio|pronunciaci[óo]n|pronunciation)\s*\|([^}|]+)(?:\|[^}]*)?}}/i;
const match = wikitext.match(audioTemplate);
if (match) {
let fileName = match[1].trim();
resolve(fetchCommonsFileUrl(fileName));
return;
}
const mediaLink = /\[\[Media:([^\]]+)\]\]/i;
const mediaMatch = wikitext.match(mediaLink);
if (mediaMatch) {
let fileName = mediaMatch[1].trim();
resolve(fetchCommonsFileUrl(fileName));
return;
}
resolve(null);
} catch (e) {
resolve(null);
}
},
onerror: () => resolve(null),
ontimeout: () => resolve(null),
timeout: 5000,
});
});
}
function fetchCommonsFileUrl(fileName) {
const url = `https://commons.wikimedia.org/w/api.php?action=query&titles=File:${encodeURIComponent(fileName)}&prop=imageinfo&iiprop=url&format=json&origin=*`;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url,
onload: (res) => {
try {
const data = JSON.parse(res.responseText);
const pages = data.query?.pages;
for (const pageId in pages) {
const info = pages[pageId]?.imageinfo?.[0];
if (info?.url) {
resolve(info.url);
return;
}
}
resolve(null);
} catch (e) {
resolve(null);
}
},
onerror: () => resolve(null),
ontimeout: () => resolve(null),
timeout: 5000,
});
});
}
async function getWiktionaryAudio(word, lang) {
if (!LANG_SUBDOMAIN[lang]) return null;
const cacheKey = `${lang}:${word}`;
if (audioCache.has(cacheKey)) return audioCache.get(cacheKey);
let url = await fetchWiktionaryParse(word, lang);
if (!url) url = await fetchWiktionaryRaw(word, lang);
audioCache.set(cacheKey, url);
return url ? { url, source: 'Wiktionary' } : null;
}
// ------------------------------------------------------------------
// Pronunciación
// ------------------------------------------------------------------
function playGoogleTTS(word, lang) {
const url = 'https://translate.google.com/translate_tts?ie=UTF-8&q=' +
encodeURIComponent(word) + '&tl=' + lang + '&client=tw-ob';
const audio = new Audio(url);
audio.play().catch(() => {});
}
async function pronounce(word, lang) {
renderToast(lastTranslatedText, 'Buscando pronunciación…', { loading: true });
let result = null;
try {
result = await getWiktionaryAudio(word, lang);
} catch (e) {
// Silently fallback
}
if (result) {
try {
const audio = new Audio(result.url);
await audio.play();
renderToast(
lastTranslatedText,
`${lang} → ${TARGET_LANG} · ${result.source} (nativo) · toca para repetir`
);
return;
} catch (e) {
// Fallback to TTS
}
}
playGoogleTTS(word, lang);
renderToast(
lastTranslatedText,
`${lang} → ${TARGET_LANG} · Google TTS (sintético) · toca para repetir`
);
}
// ------------------------------------------------------------------
// Flujo principal
// ------------------------------------------------------------------
async function handleText(text) {
text = (text || '').trim();
if (!text || text.length > MAX_TEXT_LENGTH) return;
const reqId = ++currentRequestId;
renderToast(text, 'Traduciendo…', { loading: true });
try {
const { translated, detectedLang } = await translateText(text);
if (reqId !== currentRequestId) return;
lastOriginalText = text;
lastTranslatedText = translated;
lastDetectedLang = normalizeLang(detectedLang);
renderToast(translated, `${detectedLang} → ${TARGET_LANG} · toca para escuchar`);
} catch (e) {
if (reqId !== currentRequestId) return;
renderToast(text, 'Error de traducción.');
}
}
// ------------------------------------------------------------------
// Eventos
// ------------------------------------------------------------------
document.addEventListener('selectionchange', () => {
clearTimeout(selectionTimer);
selectionTimer = setTimeout(() => {
const sel = document.getSelection();
if (!sel || sel.rangeCount === 0) return;
if (toast.contains(sel.anchorNode)) return;
const text = sel.toString().trim();
if (!text || text === lastSelectedText) return;
lastSelectedText = text;
handleText(text);
}, SELECTION_DEBOUNCE);
});
toast.addEventListener('click', () => {
if (!lastOriginalText) return;
pronounce(lastOriginalText, lastDetectedLang);
});
})();