// ==UserScript== // @name Auto Translate - test dev NO DOWNLOAD // @namespace auto-translate // @version 1.0.0 // @description 自动检测设备语言,翻译网页内容并直接替换 // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @connect translate.googleapis.com // @connect api-edge.cognitive.microsofttranslator.com // @connect edge.microsoft.com // @connect transmart.qq.com // @run-at document-idle // @downloadURL none // ==/UserScript== (async () => { 'use strict'; // ═══════════════════════════════════════════ // 形式化状态机:State → Action → State' // 不变量(Invariant):页面上任意文本节点要么是原文要么是译文,不存在中间态 // 前置条件(Pre):文本节点存在且非空 // 后置条件(Post):文本节点内容被替换为目标语言,原文存储在data属性中 // ═══════════════════════════════════════════ try { if (document.contentType === 'application/xml') return } catch (_) {} // ── 设备语言检测 ── const deviceLang = (navigator.language || navigator.userLanguage || 'zh-CN').split('-')[0]; const fullDeviceLang = navigator.language || 'zh-CN'; // ── 引擎定义 ── // 每个引擎满足接口:{ name, langMap, translate(text, from, to) → Promise } const Engine = { google: { name: 'Google', langCode(lang) { const map = { zh: 'zh-CN', en: 'en', ja: 'ja', ko: 'ko', fr: 'fr', de: 'de', es: 'es', ru: 'ru', pt: 'pt', ar: 'ar', th: 'th', vi: 'vi', it: 'it', tr: 'tr', id: 'id' }; return map[lang] || lang; }, async translate(text, toLang) { const to = this.langCode(toLang); const r = await gmFetch({ method: 'GET', url: `https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&sl=auto&tl=${to}&q=${encodeURIComponent(text)}`, }); if (r.status !== 200) throw new Error('Google API error'); const data = JSON.parse(r.responseText); return data[0].map(s => s[0]).join(''); } }, microsoft: { name: 'Microsoft', _token: null, _tokenTime: 0, async getToken() { // token有效期约10分钟,这里8分钟刷新 if (this._token && Date.now() - this._tokenTime < 480000) return this._token; const r = await gmFetch({ method: 'GET', url: 'https://edge.microsoft.com/translate/auth' }); if (r.status !== 200) throw new Error('MS auth error'); this._token = r.responseText; this._tokenTime = Date.now(); return this._token; }, langCode(lang) { const map = { zh: 'zh-Hans', en: 'en', ja: 'ja', ko: 'ko', fr: 'fr', de: 'de', es: 'es', ru: 'ru', pt: 'pt', ar: 'ar', th: 'th', vi: 'vi', it: 'it', tr: 'tr', id: 'id' }; return map[lang] || lang; }, async translate(text, toLang) { const token = await this.getToken(); const to = this.langCode(toLang); const r = await gmFetch({ method: 'POST', url: `https://api-edge.cognitive.microsofttranslator.com/translate?from=&to=${to}&api-version=3.0`, headers: { 'authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, data: JSON.stringify([{ Text: text }]), }); if (r.status !== 200) throw new Error('MS translate error'); return JSON.parse(r.responseText)[0].translations[0].text; } }, tencent: { name: 'Tencent', _clientKey: null, getClientKey() { if (this._clientKey) return this._clientKey; this._clientKey = `browser-chrome-120.0-Windows_10-${crypto.randomUUID()}-${Date.now()}`; return this._clientKey; }, langCode(lang) { const map = { zh: 'zh', en: 'en', ja: 'ja', ko: 'ko', fr: 'fr', de: 'de', es: 'es', ru: 'ru', pt: 'pt', ar: 'ar', th: 'th', vi: 'vi', it: 'it', tr: 'tr', id: 'id' }; return map[lang] || lang; }, async translate(text, toLang) { const to = this.langCode(toLang); const r = await gmFetch({ method: 'POST', url: 'https://transmart.qq.com/api/imt', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ header: { fn: 'auto_translation', session: '', client_key: this.getClientKey(), user: '' }, type: 'plain', model_category: 'normal', text_domain: 'general', source: { lang: 'auto', text_list: [text] }, target: { lang: to } }), }); if (r.status !== 200) throw new Error('Tencent translate error'); return JSON.parse(r.responseText).auto_translation[0]; } } }; // ── 持久化状态 ── let currentEngine = await GM_getValue('engine', 'microsoft'); let targetLang = await GM_getValue('targetLang', deviceLang); let autoMode = await GM_getValue('autoMode', true); let excludedHosts = JSON.parse(await GM_getValue('excludedHosts', '[]')); if (excludedHosts.includes(location.host)) return; // ── 缓存层 ── // 同构映射:原文 → 译文,双射关系在单次翻译会话中保持 const cache = new Map(); const MAX_CACHE = 2000; function cacheGet(text) { return cache.get(text); } function cacheSet(text, translated) { if (cache.size >= MAX_CACHE) { const firstKey = cache.keys().next().value; cache.delete(firstKey); } cache.set(text, translated); } // ── 网络层 ── function gmFetch(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ ...opts, onload: resolve, onerror: reject, ontimeout: reject, }); }); } // ── 翻译核心 ── // 不变量:translate(text) 幂等,对同一text多次调用结果一致 async function translate(text) { if (!text || !text.trim()) return null; const trimmed = text.trim(); if (/^\d+$/.test(trimmed)) return null; // 纯数字不翻译 const cached = cacheGet(trimmed); if (cached) return cached; try { const engine = Engine[currentEngine]; const result = await engine.translate(trimmed, targetLang); if (result && result !== trimmed) { cacheSet(trimmed, result); return result; } } catch (e) { console.warn('translate error:', e); } return null; } // ── 批量翻译(减少请求数)── // 微软支持批量,Google和腾讯逐条 async function batchTranslate(texts) { const results = new Array(texts.length).fill(null); const uncached = []; const uncachedIdx = []; for (let i = 0; i < texts.length; i++) { const t = texts[i].trim(); if (!t || /^\d+$/.test(t)) continue; const c = cacheGet(t); if (c) { results[i] = c; continue; } uncached.push(t); uncachedIdx.push(i); } if (uncached.length === 0) return results; // 微软支持批量最多25条 if (currentEngine === 'microsoft' && uncached.length > 1) { try { const engine = Engine.microsoft; const token = await engine.getToken(); const to = engine.langCode(targetLang); for (let batch = 0; batch < uncached.length; batch += 25) { const chunk = uncached.slice(batch, batch + 25); const chunkIdx = uncachedIdx.slice(batch, batch + 25); const r = await gmFetch({ method: 'POST', url: `https://api-edge.cognitive.microsofttranslator.com/translate?from=&to=${to}&api-version=3.0`, headers: { 'authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, data: JSON.stringify(chunk.map(t => ({ Text: t }))), }); if (r.status === 200) { const data = JSON.parse(r.responseText); for (let j = 0; j < data.length; j++) { const translated = data[j].translations[0].text; const original = chunk[j]; if (translated && translated !== original) { cacheSet(original, translated); results[chunkIdx[j]] = translated; } } } } return results; } catch (_) { /* fallback to single */ } } // 逐条翻译(Google / Tencent / 微软fallback) const concurrency = 5; for (let i = 0; i < uncached.length; i += concurrency) { const batch = uncached.slice(i, i + concurrency); const batchIdx = uncachedIdx.slice(i, i + concurrency); const promises = batch.map(async (text, j) => { const result = await translate(text); if (result) results[batchIdx[j]] = result; }); await Promise.allSettled(promises); } return results; } // ── DOM遍历与替换 ── // 前置条件:node是文本节点或包含文本的元素 // 后置条件:node.textContent被替换,node.dataset.originalText保存原文 // 不变量:已翻译节点(有data-translated标记)不会被重复翻译 const SKIP_TAGS = /^(script|style|code|pre|svg|math|noscript|iframe|canvas|video|audio|img|br|hr|input|select|option)$/i; const SKIP_CLASS = /translate-ui|notranslate|katex|mathjax/i; function shouldSkip(node) { if (!node) return true; if (node.nodeType === Node.ELEMENT_NODE) { if (SKIP_TAGS.test(node.tagName)) return true; if (SKIP_CLASS.test(node.className)) return true; if (node.isContentEditable) return true; if (node.dataset && node.dataset.translated) return true; } return false; } function isTargetLang(text) { if (!text || !text.trim()) return true; // 如果目标语言是中文,检查文本是否已经是中文 if (targetLang === 'zh') return /^[\u4e00-\u9fff\s\d\p{P}]+$/u.test(text.trim()); // 如果目标语言是英文,检查是否已经是英文 if (targetLang === 'en') return /^[a-zA-Z\s\d\p{P}]+$/u.test(text.trim()); // 其他语言默认不跳过 return false; } function collectTextNodes(root) { const nodes = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (shouldSkip(node.parentElement)) return NodeFilter.FILTER_REJECT; const text = node.textContent.trim(); if (!text) return NodeFilter.FILTER_REJECT; if (/^\d+$/.test(text)) return NodeFilter.FILTER_REJECT; if (text.length < 2) return NodeFilter.FILTER_REJECT; if (isTargetLang(text)) return NodeFilter.FILTER_REJECT; if (node.parentElement && node.parentElement.dataset.translated) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); while (walker.nextNode()) nodes.push(walker.currentNode); return nodes; } // placeholder翻译 function collectPlaceholders(root) { return [...root.querySelectorAll('input[placeholder], textarea[placeholder]')] .filter(el => !el.dataset.translated && el.placeholder.trim() && !isTargetLang(el.placeholder)); } // ── 替换执行 ── // 纯替换,不创建额外DOM元素 async function translatePage(root) { root = root || document.body; const textNodes = collectTextNodes(root); const placeholders = collectPlaceholders(root); if (textNodes.length === 0 && placeholders.length === 0) return; // 文本节点翻译 const texts = textNodes.map(n => n.textContent.trim()); const results = await batchTranslate(texts); for (let i = 0; i < textNodes.length; i++) { if (!results[i]) continue; const node = textNodes[i]; const parent = node.parentElement; if (!parent) continue; // 保存原文到父元素 if (!parent.dataset.originalText) { parent.dataset.originalText = node.textContent; } parent.dataset.translated = '1'; node.textContent = results[i]; } // placeholder翻译 if (placeholders.length > 0) { const phTexts = placeholders.map(el => el.placeholder.trim()); const phResults = await batchTranslate(phTexts); for (let i = 0; i < placeholders.length; i++) { if (!phResults[i]) continue; placeholders[i].dataset.originalPlaceholder = placeholders[i].placeholder; placeholders[i].placeholder = phResults[i]; placeholders[i].dataset.translated = '1'; } } } // ── 还原原文 ── function restorePage() { document.querySelectorAll('[data-translated]').forEach(el => { if (el.dataset.originalText) { // 找到文本节点还原 for (const child of el.childNodes) { if (child.nodeType === Node.TEXT_NODE) { child.textContent = el.dataset.originalText; break; } } delete el.dataset.originalText; } if (el.dataset.originalPlaceholder) { el.placeholder = el.dataset.originalPlaceholder; delete el.dataset.originalPlaceholder; } delete el.dataset.translated; }); } // ── 滚动监听(新内容自动翻译)── let scrollTimer = null; let lastHeight = document.documentElement.scrollHeight; function onScroll() { if (scrollTimer) clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { const h = document.documentElement.scrollHeight; if (h > lastHeight) { lastHeight = h; if (autoMode) translatePage(); } }, 800); } // ── MutationObserver(动态内容自动翻译)── let mutationTimer = null; const observer = new MutationObserver((mutations) => { if (!autoMode) return; if (mutationTimer) clearTimeout(mutationTimer); mutationTimer = setTimeout(() => { // 找到变化的根节点翻译 const roots = new Set(); for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE && !shouldSkip(node)) { roots.add(node); } } } roots.forEach(root => translatePage(root)); }, 1000); }); // ── UI ── GM_addStyle(` .translate-ui{position:fixed;bottom:20px;right:20px;z-index:999999;font-family:system-ui,-apple-system,sans-serif} .translate-ui *{box-sizing:border-box;margin:0;padding:0} .tu-btn{width:42px;height:42px;border-radius:50%;border:none;background:rgba(0,0,0,0.5);color:#fff;cursor:pointer; display:flex;align-items:center;justify-content:center;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px); box-shadow:0 2px 8px rgba(0,0,0,0.15);transition:transform .2s,background .2s;touch-action:manipulation} .tu-btn:active{transform:scale(0.9)} .tu-btn.active{background:rgba(34,128,255,0.8)} .tu-panel{position:absolute;bottom:52px;right:0;width:200px;background:rgba(255,255,255,0.95); backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-radius:12px; box-shadow:0 4px 24px rgba(0,0,0,0.12);padding:12px;display:none;color:#333;font-size:13px} .tu-panel.show{display:block} .tu-panel label{display:block;margin:8px 0 4px;font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.5px} .tu-panel select{width:100%;padding:6px 8px;border:1px solid #ddd;border-radius:6px;font-size:13px; background:#fff;color:#333;outline:none;appearance:auto} .tu-panel select:focus{border-color:#4a9eff} .tu-row{display:flex;gap:6px;margin-top:10px} .tu-row button{flex:1;padding:7px 0;border:none;border-radius:6px;font-size:12px;cursor:pointer; transition:background .2s;touch-action:manipulation} .tu-row .tu-restore{background:#f0f0f0;color:#555} .tu-row .tu-restore:active{background:#ddd} .tu-row .tu-go{background:#4a9eff;color:#fff} .tu-row .tu-go:active{background:#3080dd} .tu-row .tu-exclude{background:#ff6b6b;color:#fff;font-size:11px} .tu-row .tu-exclude:active{background:#e55} @media(prefers-color-scheme:dark){ .tu-panel{background:rgba(30,30,30,0.95);color:#eee} .tu-panel select{background:#2a2a2a;color:#eee;border-color:#444} .tu-row .tu-restore{background:#333;color:#ccc} } `); const ui = document.createElement('div'); ui.className = 'translate-ui'; ui.innerHTML = `
`; document.body.appendChild(ui); const btn = document.getElementById('tuBtn'); const panel = document.getElementById('tuPanel'); const engineSelect = document.getElementById('tuEngine'); const langSelect = document.getElementById('tuLang'); engineSelect.value = currentEngine; langSelect.value = targetLang; btn.addEventListener('click', (e) => { e.stopPropagation(); panel.classList.toggle('show'); }); document.addEventListener('click', (e) => { if (!ui.contains(e.target)) panel.classList.remove('show'); }); engineSelect.addEventListener('change', () => { currentEngine = engineSelect.value; GM_setValue('engine', currentEngine); cache.clear(); // 切换引擎清缓存 }); langSelect.addEventListener('change', () => { targetLang = langSelect.value; GM_setValue('targetLang', targetLang); cache.clear(); }); document.getElementById('tuGo').addEventListener('click', async () => { panel.classList.remove('show'); btn.classList.add('active'); autoMode = true; GM_setValue('autoMode', true); restorePage(); cache.clear(); lastHeight = document.documentElement.scrollHeight; await translatePage(); }); document.getElementById('tuRestore').addEventListener('click', () => { panel.classList.remove('show'); btn.classList.remove('active'); autoMode = false; GM_setValue('autoMode', false); restorePage(); }); document.getElementById('tuExclude').addEventListener('click', () => { if (!excludedHosts.includes(location.host)) { excludedHosts.push(location.host); GM_setValue('excludedHosts', JSON.stringify(excludedHosts)); } restorePage(); ui.remove(); observer.disconnect(); window.removeEventListener('scroll', onScroll); }); // ── 菜单 ── GM_registerMenuCommand('翻译当前页面', () => translatePage()); GM_registerMenuCommand('还原当前页面', () => restorePage()); // ── 启动 ── // 终态验证:autoMode=true且页面非目标语言 → 自动翻译 function isPageInTargetLang() { const lang = (document.documentElement.lang || '').split('-')[0].toLowerCase(); return lang === targetLang; } window.addEventListener('scroll', onScroll, { passive: true }); observer.observe(document.body, { childList: true, subtree: true }); if (autoMode && !isPageInTargetLang()) { // 延迟等待页面渲染完成 setTimeout(() => translatePage(), 1500); } })();