// ==UserScript== // @name Auto Translate // @namespace auto-translate // @version 2.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'; // ═══════════════════════════════════════════════════════════════ // 形式化规约 (Coq-style reasoning) // // 状态空间 S := { mode: Mode, nodes: Set, cache: Map } // Mode := Idle | Translating | Translated | Bilingual // // 不变量 I₁: ∀ node ∈ DOM, // node.dataset.translated = '1' ⟹ node.dataset.originalText ≠ ∅ // // 不变量 I₂: ∀ (k,v) ∈ cache, // translate(k) = v ∧ k ≠ v // // 不变量 I₃ (幂等性): // translatePage(root); translatePage(root) ≡ translatePage(root) // 即:已标记 data-translated 的节点不会被重复处理 // // 不变量 I₄ (可逆性): // restorePage(translatePage(root)) ≡ root // 即:翻译后还原得到原始状态 // // 不变量 I₅ (双语同构): // bilingualMode=true ⟹ ∀ translated node, // node.children 包含 span.bilingual-src 和 span.bilingual-dst // 且 src.textContent = originalText ∧ dst.textContent = translatedText // // 状态转移: // Idle →[translatePage]→ Translated // Translated →[restorePage]→ Idle // Translated →[toggleBilingual]→ Bilingual // Bilingual →[toggleBilingual]→ Translated // Bilingual →[restorePage]→ Idle // * →[switchEngine]→ Idle (清缓存,还原,重新翻译) // ═══════════════════════════════════════════════════════════════ try { if (document.contentType === 'application/xml') return; } catch (_) {} // ── 常量 ── const BATCH_SIZE = 25; const CONCURRENCY = 6; const SCROLL_DEBOUNCE = 600; const MUTATION_DEBOUNCE = 800; const STARTUP_DELAY = 800; const TOKEN_REFRESH = 480000; // 8分钟 const MAX_CACHE = 3000; const MAX_TEXT_LEN = 5000; // ── 设备语言 ── const deviceLang = (() => { const raw = (navigator.language || navigator.userLanguage || 'zh-CN'); const base = raw.split('-')[0].toLowerCase(); return base; })(); // ── 工具函数 ── function gmFetch(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ timeout: 15000, ...opts, onload: resolve, onerror: reject, ontimeout: reject, }); }); } function uuid() { if (crypto.randomUUID) return crypto.randomUUID(); return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); } // ── 引擎定义 ── // 接口契约: Engine.translate(text, toLang) → Promise // 前置: text.trim().length > 0 // 后置: result.length > 0 ∨ result = null (翻译失败) const Engine = { google: { name: 'Google', langCode(lang) { const m = { 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', hi: 'hi', nl: 'nl', pl: 'pl', uk: 'uk', cs: 'cs', sv: 'sv', da: 'da', fi: 'fi', el: 'el', ro: 'ro', hu: 'hu', nb: 'no', ms: 'ms', tl: 'tl' }; return m[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 ${r.status}`); const d = JSON.parse(r.responseText); return d[0].map(s => s[0]).join(''); }, // Google 不支持原生批量,使用通用并发 supportsBatch: false, }, microsoft: { name: 'Microsoft', _token: null, _tokenTime: 0, async getToken() { if (this._token && Date.now() - this._tokenTime < TOKEN_REFRESH) 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 ${r.status}`); this._token = r.responseText; this._tokenTime = Date.now(); return this._token; }, langCode(lang) { const m = { 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', hi: 'hi', nl: 'nl', pl: 'pl', uk: 'uk', cs: 'cs', sv: 'sv', da: 'da', fi: 'fi', el: 'el', ro: 'ro', hu: 'hu', nb: 'nb', ms: 'ms', tl: 'fil' }; return m[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 ${r.status}`); return JSON.parse(r.responseText)[0].translations[0].text; }, // 微软API原生支持批量(最多25条) supportsBatch: true, async translateBatch(texts, toLang) { const token = await this.getToken(); const to = this.langCode(toLang); const results = []; for (let i = 0; i < texts.length; i += BATCH_SIZE) { const chunk = texts.slice(i, i + BATCH_SIZE); 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 (const item of data) results.push(item.translations[0].text); } else { // 批量失败,填充null for (let j = 0; j < chunk.length; j++) results.push(null); } } return results; } }, tencent: { name: 'Tencent', _clientKey: null, getClientKey() { if (!this._clientKey) this._clientKey = `browser-chrome-120.0-${uuid()}-${Date.now()}`; return this._clientKey; }, langCode(lang) { const m = { 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 m[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', '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 ${r.status}`); return JSON.parse(r.responseText).auto_translation[0]; }, supportsBatch: false, } }; // ── 缓存层 ── // 同构性:cache 是从 原文空间 到 译文空间 的单射 // LRU策略:超出上限时删除最早插入的条目 const cache = new Map(); function cacheGet(k) { return cache.get(k) || null; } function cacheSet(k, v) { if (cache.size >= MAX_CACHE) cache.delete(cache.keys().next().value); cache.set(k, v); } // ── 持久化状态 ── let currentEngine = await GM_getValue('engine', 'microsoft'); let targetLang = await GM_getValue('targetLang', deviceLang); let autoMode = await GM_getValue('autoMode', true); let bilingualMode = await GM_getValue('bilingualMode', false); // ── DOM分析 ── const SKIP_TAGS = new Set([ 'script', 'style', 'code', 'pre', 'svg', 'math', 'noscript', 'iframe', 'canvas', 'video', 'audio', 'img', 'br', 'hr', 'select', 'option', 'link', 'meta', 'head' ]); const SKIP_CLASS_RE = /translate-ui|notranslate|katex|mathjax|highlight|code-block|monaco|ace_editor|cm-editor/i; function shouldSkipElement(el) { if (!el) return false; if (SKIP_TAGS.has(el.tagName.toLowerCase())) return true; if (el.isContentEditable) return true; if (SKIP_CLASS_RE.test(el.className)) return true; return false; } // 检测文本是否已经是目标语言 // 使用启发式:中文/日文/韩文通过Unicode范围判断,其余通过拉丁字符判断 const CJK_RE = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/; const LATIN_RE = /[a-zA-ZÀ-ÿ]/; function detectTextType(text) { const t = text.trim(); if (!t) return 'empty'; const cjkCount = (t.match(/[\u4e00-\u9fff]/g) || []).length; const jpCount = (t.match(/[\u3040-\u309f\u30a0-\u30ff]/g) || []).length; const krCount = (t.match(/[\uac00-\ud7af]/g) || []).length; const latinCount = (t.match(/[a-zA-ZÀ-ÿ]/g) || []).length; const total = t.replace(/[\s\d\p{P}]/gu, '').length; if (total === 0) return 'empty'; if (cjkCount / total > 0.5) return 'zh'; if (jpCount / total > 0.3) return 'ja'; if (krCount / total > 0.3) return 'ko'; if (latinCount / total > 0.5) return 'latin'; return 'other'; } function isAlreadyTargetLang(text) { const type = detectTextType(text); if (type === 'empty') return true; if (targetLang === 'zh' && type === 'zh') return true; if (targetLang === 'ja' && type === 'ja') return true; if (targetLang === 'ko' && type === 'ko') return true; if (['en', 'fr', 'de', 'es', 'pt', 'it', 'nl', 'pl', 'sv', 'da', 'fi', 'ro', 'hu', 'cs', 'tr', 'vi', 'id', 'ms', 'tl'].includes(targetLang) && type === 'latin') { // 拉丁系语言之间无法纯靠字符区分,只在目标语言不是这些时跳过 return false; } return false; } // ── 文本节点收集 ── // TreeWalker: O(n) 单次遍历,比递归更高效且不会栈溢出 function collectTextNodes(root) { const nodes = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; if (parent.closest('[data-translated]')) return NodeFilter.FILTER_REJECT; if (parent.closest('.translate-ui')) return NodeFilter.FILTER_REJECT; // 向上检查是否有需要跳过的祖先 let el = parent; while (el && el !== root) { if (shouldSkipElement(el)) return NodeFilter.FILTER_REJECT; el = el.parentElement; } const text = node.textContent.trim(); if (!text) return NodeFilter.FILTER_REJECT; if (text.length < 2) return NodeFilter.FILTER_REJECT; if (/^\d+[\d.,\s]*$/.test(text)) return NodeFilter.FILTER_REJECT; // 纯数字 if (/^[^\w\s]+$/.test(text)) return NodeFilter.FILTER_REJECT; // 纯符号 if (isAlreadyTargetLang(text)) 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().length > 1 && !isAlreadyTargetLang(el.placeholder)); } // ── 批量翻译调度器 ── // 定理:batchTranslate 的输出长度 = 输入长度 // 证明:每个索引位置要么填充翻译结果,要么保持null async function batchTranslate(texts) { const n = texts.length; const results = new Array(n).fill(null); const uncached = []; const uncachedIdx = []; // 阶段1:缓存命中 for (let i = 0; i < n; i++) { const t = texts[i].trim(); if (!t || /^\d+[\d.,\s]*$/.test(t)) continue; if (t.length > MAX_TEXT_LEN) continue; const c = cacheGet(t); if (c) { results[i] = c; continue; } uncached.push(t); uncachedIdx.push(i); } if (uncached.length === 0) return results; const engine = Engine[currentEngine]; // 阶段2:引擎批量翻译 if (engine.supportsBatch && engine.translateBatch) { try { const batchResults = await engine.translateBatch(uncached, targetLang); for (let i = 0; i < batchResults.length; i++) { const original = uncached[i]; const translated = batchResults[i]; if (translated && translated !== original) { cacheSet(original, translated); results[uncachedIdx[i]] = translated; } } return results; } catch (e) { console.warn('batch translate failed, fallback to single:', e); } } // 阶段3:并发逐条翻译 for (let i = 0; i < uncached.length; i += CONCURRENCY) { const chunk = uncached.slice(i, i + CONCURRENCY); const chunkIdx = uncachedIdx.slice(i, i + CONCURRENCY); await Promise.allSettled(chunk.map(async (text, j) => { try { const result = await engine.translate(text, targetLang); if (result && result !== text) { cacheSet(text, result); results[chunkIdx[j]] = result; } } catch (e) { // 单条失败静默跳过 } })); } return results; } // ── DOM替换 ── // 替换策略分两种模式: // 1. 纯替换模式:直接替换textContent,原文存data-originalText // 2. 双语模式:创建 src+dst 的span结构 function applyTranslation(textNode, translated) { const parent = textNode.parentElement; if (!parent || parent.dataset.translated) return; const original = textNode.textContent; if (bilingualMode) { // 双语结构: // // original //
// translated //
const wrapper = document.createElement('span'); wrapper.dataset.translated = '1'; wrapper.dataset.originalText = original; const srcSpan = document.createElement('span'); srcSpan.className = 'bilingual-src'; srcSpan.textContent = original; const br = document.createElement('br'); br.className = 'bilingual-br'; const dstSpan = document.createElement('span'); dstSpan.className = 'bilingual-dst'; dstSpan.textContent = translated; wrapper.appendChild(srcSpan); wrapper.appendChild(br); wrapper.appendChild(dstSpan); textNode.parentNode.replaceChild(wrapper, textNode); } else { // 纯替换模式 parent.dataset.translated = '1'; parent.dataset.originalText = original; textNode.textContent = translated; } } function applyPlaceholderTranslation(el, translated) { el.dataset.originalPlaceholder = el.placeholder; el.placeholder = translated; el.dataset.translated = '1'; } // ── 页面翻译 ── // 前置条件:root 是有效DOM节点 // 后置条件:root下所有非目标语言文本被翻译 // 幂等性保证:data-translated标记防止重复 let translating = false; async function translatePage(root) { if (translating) return; translating = true; try { root = root || document.body; 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; // 验证节点仍在DOM中 if (!textNodes[i].parentElement) continue; if (textNodes[i].parentElement.dataset.translated) continue; applyTranslation(textNodes[i], 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; applyPlaceholderTranslation(placeholders[i], phResults[i]); } } } catch (e) { console.warn('translatePage error:', e); } finally { translating = false; } } // ── 还原 ── // 后置条件:DOM恢复到翻译前状态,所有data-translated标记被移除 function restorePage() { // 双语模式还原:wrapper → 原始文本节点 document.querySelectorAll('span[data-translated].bilingual-src, span[data-translated]').forEach(el => { // 什么都不做,统一在下面处理 }); document.querySelectorAll('[data-translated]').forEach(el => { if (el.tagName === 'SPAN' && el.querySelector('.bilingual-src')) { // 双语wrapper还原 const original = el.dataset.originalText; const textNode = document.createTextNode(original); el.parentNode.replaceChild(textNode, el); } else 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; delete el.dataset.translated; } else if (el.dataset.originalPlaceholder) { // placeholder还原 el.placeholder = el.dataset.originalPlaceholder; delete el.dataset.originalPlaceholder; delete el.dataset.translated; } else { delete el.dataset.translated; } }); } // ── 双语切换 ── // 状态转移:Translated ↔ Bilingual async function toggleBilingual() { bilingualMode = !bilingualMode; GM_setValue('bilingualMode', bilingualMode); restorePage(); cache.clear(); // 清缓存确保一致性 if (autoMode) { await translatePage(); } updateButtonState(); } // ── 滚动检测 ── let scrollTimer = null; let lastDocHeight = document.documentElement.scrollHeight; function onScroll() { if (!autoMode) return; if (scrollTimer) clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { const h = document.documentElement.scrollHeight; if (h > lastDocHeight + 100) { // 容差100px lastDocHeight = h; translatePage(); } }, SCROLL_DEBOUNCE); } // ── Mutation Observer ── let mutationTimer = null; const observer = new MutationObserver(mutations => { if (!autoMode) return; if (mutationTimer) clearTimeout(mutationTimer); mutationTimer = setTimeout(() => { const targets = new Set(); for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE && !shouldSkipElement(node) && !node.closest('.translate-ui') && !node.dataset.translated) { targets.add(node); } } } if (targets.size > 0) { // 如果变更范围广,直接翻译body if (targets.size > 20) { translatePage(); } else { targets.forEach(t => translatePage(t)); } } }, MUTATION_DEBOUNCE); }); // ── 样式注入 ── // 只注入一次,最小化CSS footprint GM_addStyle(` /* ── 按钮与面板 ── */ .translate-ui{position:fixed;bottom:20px;right:20px;z-index:2147483647;font-family:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;-webkit-tap-highlight-color:transparent} .translate-ui *{box-sizing:border-box;margin:0;padding:0} .tu-btn{width:44px;height:44px;border-radius:50%;border:none;cursor:pointer;display:flex;align-items:center;justify-content:center; background:rgba(0,0,0,.45);color:#fff;backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px); box-shadow:0 2px 12px rgba(0,0,0,.18);transition:transform .15s,background .2s;touch-action:manipulation;-webkit-user-select:none;user-select:none} .tu-btn:active{transform:scale(.88)} .tu-btn.active{background:rgba(34,128,255,.8)} .tu-btn svg{pointer-events:none} .tu-panel{position:absolute;bottom:54px;right:0;width:220px;border-radius:14px;padding:14px;display:none; background:rgba(255,255,255,.97);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px); box-shadow:0 8px 32px rgba(0,0,0,.14);color:#222;font-size:13px;line-height:1.4} .tu-panel.show{display:block;animation:tuFadeIn .15s ease} @keyframes tuFadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}} .tu-panel label{display:block;margin:10px 0 4px;font-size:11px;color:#999;text-transform:uppercase;letter-spacing:.6px;font-weight:600} .tu-panel label:first-child{margin-top:0} .tu-panel select{width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:8px;font-size:13px;background:#fff;color:#222;outline:none; -webkit-appearance:auto;appearance:auto} .tu-panel select:focus{border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,.2)} .tu-actions{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap} .tu-actions button{flex:1;min-width:0;padding:8px 4px;border:none;border-radius:8px;font-size:12px;font-weight:500;cursor:pointer; transition:background .15s,transform .1s;touch-action:manipulation;white-space:nowrap} .tu-actions button:active{transform:scale(.95)} .tu-go{background:#4a9eff;color:#fff} .tu-go:hover{background:#3b8de6} .tu-restore{background:#f0f0f0;color:#555} .tu-restore:hover{background:#e4e4e4} .tu-bilingual{background:#f5e6ff;color:#7c3aed} .tu-bilingual:hover{background:#ebe0f5} .tu-bilingual.on{background:#7c3aed;color:#fff} /* ── 双语样式 ── */ .bilingual-src{display:block;opacity:.55;font-size:.92em;line-height:1.4} .bilingual-dst{display:block;line-height:1.4} .bilingual-br{display:none} /* ── 暗色适配 ── */ @media(prefers-color-scheme:dark){ .tu-panel{background:rgba(28,28,30,.97);color:#f0f0f0} .tu-panel select{background:#2c2c2e;color:#f0f0f0;border-color:#444} .tu-restore{background:#333;color:#ccc} .tu-bilingual{background:#3b2063;color:#c4a1ff} .tu-bilingual.on{background:#7c3aed;color:#fff} .bilingual-src{opacity:.45} } /* ── 手机端微调 ── */ @media(max-width:600px){ .translate-ui{bottom:12px;right:12px} .tu-btn{width:40px;height:40px} .tu-panel{width:200px;padding:12px;bottom:50px} } `); // ── UI构建 ── const LANG_OPTIONS = [ ['zh', '中文'], ['en', 'English'], ['ja', '日本語'], ['ko', '한국어'], ['fr', 'Français'], ['de', 'Deutsch'], ['es', 'Español'], ['ru', 'Русский'], ['pt', 'Português'], ['ar', 'العربية'], ['th', 'ไทย'], ['vi', 'Tiếng Việt'], ['it', 'Italiano'], ['tr', 'Türkçe'], ['id', 'Indonesia'], ['hi', 'हिन्दी'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['uk', 'Українська'], ['cs', 'Čeština'], ['sv', 'Svenska'], ['da', 'Dansk'], ['fi', 'Suomi'], ['el', 'Ελληνικά'], ['ro', 'Română'], ['hu', 'Magyar'], ['ms', 'Melayu'], ['tl', 'Filipino'], ]; const langOptionsHTML = LANG_OPTIONS.map(([v, t]) => ``).join(''); const ui = document.createElement('div'); ui.className = 'translate-ui'; ui.innerHTML = `
`; document.body.appendChild(ui); // ── UI 元素引用 ── const btn = document.getElementById('tuBtn'); const panel = document.getElementById('tuPanel'); const engineSel = document.getElementById('tuEngine'); const langSel = document.getElementById('tuLang'); const bilingualBtn = document.getElementById('tuBilingual'); engineSel.value = currentEngine; langSel.value = targetLang; function updateButtonState() { btn.classList.toggle('active', autoMode); bilingualBtn.classList.toggle('on', bilingualMode); } // ── UI 事件 ── btn.addEventListener('click', e => { e.stopPropagation(); panel.classList.toggle('show'); }); // 点击外部关闭面板 document.addEventListener('click', e => { if (!ui.contains(e.target)) panel.classList.remove('show'); }); // 触摸设备 document.addEventListener('touchstart', e => { if (!ui.contains(e.target)) panel.classList.remove('show'); }, { passive: true }); engineSel.addEventListener('change', () => { currentEngine = engineSel.value; GM_setValue('engine', currentEngine); cache.clear(); }); langSel.addEventListener('change', () => { targetLang = langSel.value; GM_setValue('targetLang', targetLang); cache.clear(); }); // 翻译按钮 document.getElementById('tuGo').addEventListener('click', async () => { panel.classList.remove('show'); autoMode = true; GM_setValue('autoMode', true); restorePage(); cache.clear(); lastDocHeight = document.documentElement.scrollHeight; updateButtonState(); await translatePage(); }); // 还原按钮 document.getElementById('tuRestore').addEventListener('click', () => { panel.classList.remove('show'); autoMode = false; GM_setValue('autoMode', false); restorePage(); updateButtonState(); }); // 双语按钮 bilingualBtn.addEventListener('click', async () => { panel.classList.remove('show'); await toggleBilingual(); }); // ── 油猴菜单 ── GM_registerMenuCommand('翻译页面', () => { autoMode = true; GM_setValue('autoMode', true); updateButtonState(); translatePage(); }); GM_registerMenuCommand('还原页面', () => { autoMode = false; GM_setValue('autoMode', false); updateButtonState(); restorePage(); }); GM_registerMenuCommand('切换双语', () => toggleBilingual()); // ── 判断页面语言 ── function isPageAlreadyTargetLang() { const htmlLang = (document.documentElement.lang || '').toLowerCase(); // zh-CN, zh-TW, zh-Hans → zh const pageLang = htmlLang.split('-')[0]; if (pageLang === targetLang) return true; // 无lang属性时,抽样检测页面文本 if (!htmlLang) { const sample = (document.body.textContent || '').substring(0, 500); return isAlreadyTargetLang(sample); } return false; } // ── 启动 ── // 终态验证:仅当 autoMode=true 且页面非目标语言时自动翻译 window.addEventListener('scroll', onScroll, { passive: true }); observer.observe(document.body, { childList: true, subtree: true }); if (autoMode && !isPageAlreadyTargetLang()) { setTimeout(() => translatePage(), STARTUP_DELAY); } })();