// ==UserScript== // @name Auto Translate // @namespace auto-translate // @version 1.1.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'; try { if (document.contentType === 'application/xml') return } catch (_) {} const deviceLang = (navigator.language || navigator.userLanguage || 'zh-CN').split('-')[0]; const fullDeviceLang = navigator.language || 'zh-CN'; // ── UA伪装:每个引擎模拟对应浏览器的翻译请求 ── const UA = { google: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', microsoft: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0', tencent: 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.95 Safari/537.36 QBCore/4.0.1.400 QQBrowser/11.5.5250.400' }; // ── 网络层 ── function gmFetch(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ timeout: 15000, ...opts, onload: resolve, onerror: reject, ontimeout: reject, }); }); } // ── 引擎定义 ── 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)}`, headers: { 'User-Agent': UA.google, 'Referer': 'https://translate.google.com/', } }); if (r.status !== 200) throw new Error('Google API error'); const data = JSON.parse(r.responseText); if (!data || !data[0]) return text; return data[0].filter(s => s && s[0]).map(s => s[0]).join(''); } }, microsoft: { name: 'Microsoft', _token: null, _tokenTime: 0, async getToken() { if (this._token && Date.now() - this._tokenTime < 480000) return this._token; const r = await gmFetch({ method: 'GET', url: 'https://edge.microsoft.com/translate/auth', headers: { 'User-Agent': UA.microsoft } }); 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', 'User-Agent': UA.microsoft, 'Origin': 'https://www.bing.com', 'Referer': 'https://www.bing.com/translator', }, 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-qq-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; 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', 'User-Agent': UA.tencent, 'Origin': 'https://transmart.qq.com', 'Referer': 'https://transmart.qq.com/', }, 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); } // ── 翻译核心 ── async function translate(text) { if (!text || !text.trim()) return null; const trimmed = text.trim(); if (!isTranslatable(trimmed)) return null; const cached = cacheGet(trimmed); if (cached) return cached; // 带回退的翻译:主引擎失败时尝试备用引擎 const engines = [currentEngine, ...['microsoft', 'google', 'tencent'].filter(e => e !== currentEngine)]; for (const engineName of engines) { try { const engine = Engine[engineName]; const result = await engine.translate(trimmed, targetLang); if (result && result !== trimmed) { cacheSet(trimmed, result); return result; } return null; // 翻译结果和原文一样,说明不需要翻译 } catch (e) { console.warn(`[翻译] ${engineName} 失败:`, e.message); continue; // 尝试下一个引擎 } } return null; } // ── 判断文本是否值得翻译 ── function isTranslatable(text) { if (!text) return false; const t = text.trim(); if (t.length < 2) return false; if (/^\d+$/.test(t)) return false; // 纯数字 if (/^[\s\d\p{P}\p{S}]+$/u.test(t)) return false; // 纯符号/数字 if (/^https?:\/\//i.test(t)) return false; // URL if (/^[a-zA-Z_$][\w$]*$/.test(t) && t.length < 25) return false; // 代码变量名 if (/^#[0-9a-f]{3,8}$/i.test(t)) return false; // 颜色值 if (/^v?\d+\.\d+/i.test(t)) return false; // 版本号 return true; } // ── 批量翻译 ── 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 || !isTranslatable(t)) continue; const c = cacheGet(t); if (c) { results[i] = c; continue; } uncached.push(t); uncachedIdx.push(i); } if (uncached.length === 0) return results; // 微软批量(最多50条,比原来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 += 50) { const chunk = uncached.slice(batch, batch + 50); const chunkIdx = uncachedIdx.slice(batch, batch + 50); 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', 'User-Agent': UA.microsoft, 'Origin': 'https://www.bing.com', 'Referer': 'https://www.bing.com/translator', }, 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 = currentEngine === 'google' ? 8 : 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遍历与替换 ── const SKIP_TAGS = /^(script|style|code|pre|svg|math|noscript|iframe|canvas|video|audio|img|br|hr|input|select|option|textarea|kbd|samp|var)$/i; const SKIP_CLASS = /translate-ui|notranslate|katex|mathjax|hljs|highlight|CodeMirror|monaco/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; if (node.getAttribute && node.getAttribute('translate') === 'no') return true; } return false; } // ── 改进的目标语言检测 ── // 支持更多语言的检测,减少误判 function isTargetLang(text) { if (!text || !text.trim()) return true; const t = text.trim(); // 太短的文本不好判断,交给翻译引擎 if (t.length <= 3) return false; if (targetLang === 'zh') { // 中文占比超过60%认为是中文 const zhChars = (t.match(/[\u4e00-\u9fff]/g) || []).length; return zhChars / t.replace(/[\s\d\p{P}]/gu, '').length > 0.6; } if (targetLang === 'en') { const enChars = (t.match(/[a-zA-Z]/g) || []).length; // 纯拉丁字母也可能是法语/德语等,但如果页面语言就是英语就跳过 const pageLang = (document.documentElement.lang || '').split('-')[0].toLowerCase(); if (pageLang === 'en') return enChars / t.replace(/[\s\d\p{P}]/gu, '').length > 0.6; return false; // 非英语页面上的拉丁文本不跳过 } if (targetLang === 'ja') { return (t.match(/[\u3040-\u309f\u30a0-\u30ff]/g) || []).length > 0; } if (targetLang === 'ko') { return (t.match(/[\uac00-\ud7af]/g) || []).length > t.length * 0.3; } if (targetLang === 'ru') { return (t.match(/[\u0400-\u04ff]/g) || []).length > t.length * 0.3; } if (targetLang === 'ar') { return (t.match(/[\u0600-\u06ff]/g) || []).length > t.length * 0.3; } if (targetLang === 'th') { return (t.match(/[\u0e00-\u0e7f]/g) || []).length > t.length * 0.3; } 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 (!isTranslatable(text)) 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; } function collectPlaceholders(root) { return [...root.querySelectorAll('input[placeholder], textarea[placeholder]')] .filter(el => !el.dataset.translated && el.placeholder.trim() && !isTargetLang(el.placeholder)); } // ── 翻译状态控制 ── let isTranslating = false; async function translatePage(root) { if (isTranslating) return; isTranslating = true; try { root = root || document.body; setLoadingState(true); const textNodes = collectTextNodes(root); const placeholders = collectPlaceholders(root); if (textNodes.length === 0 && placeholders.length === 0) return; // 文本节点翻译 if (textNodes.length > 0) { 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'; } } // 翻译页面标题 await translateTitle(); } catch (e) { console.error('[翻译] 错误:', e); } finally { isTranslating = false; setLoadingState(false); } } // ── 页面标题翻译 ── let originalTitle = ''; async function translateTitle() { if (originalTitle) return; // 已经翻译过 const title = document.title; if (!title || !title.trim() || isTargetLang(title)) return; if (!isTranslatable(title.trim())) return; originalTitle = title; try { const result = await translate(title.trim()); if (result) document.title = result; } catch (_) {} } // ── 还原原文 ── 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; }); // 还原标题 if (originalTitle) { document.title = originalTitle; originalTitle = ''; } } // ── 加载状态 ── function setLoadingState(loading) { const btn = document.getElementById('tuBtn'); if (!btn) return; if (loading) { btn.classList.add('loading'); } else { btn.classList.remove('loading'); } } // ── 滚动监听 ── 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)) { // 不处理我们自己的UI if (node.classList && node.classList.contains('translate-ui')) continue; roots.add(node); } } } if (roots.size > 0) { // 临时解锁让动态内容也能翻译 const was = isTranslating; isTranslating = false; roots.forEach(root => { if (document.body.contains(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; position:relative} .tu-btn:active{transform:scale(0.9)} .tu-btn.active{background:rgba(34,128,255,0.8)} .tu-btn.loading::after{content:'';position:absolute;inset:-3px;border-radius:50%; border:2px solid transparent;border-top-color:rgba(255,255,255,0.8); animation:tu-spin .8s linear infinite} @keyframes tu-spin{to{transform:rotate(360deg)}} .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 = `