// ==UserScript== // @name Auto Translate Pro // @namespace auto-translate-pro // @version 2.0.0 // @description 智能网页翻译 · 自动检测 · 直接替换 · 双语对照 · 多引擎 · 移动端适配 // @match *://*/* // @exclude *://translate.google.*/* // @exclude *://fanyi.baidu.com/* // @exclude *://transmart.qq.com/* // @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 // @connect www2.deepl.com // @connect translate.alibaba.com // @connect m.youdao.com // @connect fanyi.baidu.com // @connect fanyi.sogou.com // @connect papago.naver.com // @connect api.interpreter.caiyunai.com // @connect fanyi.caiyunapp.com // @run-at document-end // @noframes // @downloadURL none // ==/UserScript== (async () => { 'use strict'; /* ═══════════════════════════════════════════════════════════════════ * 形式化规范 — Formal Specification (Coq-flavoured) * ───────────────────────────────────────────────────────────────── * * Inductive State := Idle | Translating | Done. * Inductive Mode := Replace | Bilingual. * Record Engine := { translate : string → lang → IO string }. * * (* ── 同构性 (Isomorphism) ── *) * Definition engine_iso : * ∀ (e₁ e₂ : Engine), type(e₁.translate) ≡ type(e₂.translate). * * (* ── 不变量 (Invariants) ── *) * I₁ : ∀ n, translated(n) → stored_original(n). * I₂ : restore ∘ translate ≡ id. (* 可逆性 *) * I₃ : translate ∘ translate ≡ translate. (* 幂等性 *) * * (* ── 前置 / 后置条件 ── *) * Pre (translate) : dom_ready ∧ engine_authed. * Post(translate) : ∀ visible_text, lang(text) = target_lang. * Pre (restore) : ∃ n, translated(n). * Post(restore) : ∀ n, text(n) = original(n). * ═══════════════════════════════════════════════════════════════════ */ // ╔═══════════════════════════════════════╗ // ║ §0 GUARDS — 类型守卫 ║ // ╚═══════════════════════════════════════╝ try { if (document.contentType === 'application/xml') return } catch (_) {} // ╔═══════════════════════════════════════╗ // ║ §1 FOUNDATIONS — 基础设施 ║ // ╚═══════════════════════════════════════╝ const sleep = ms => new Promise(r => setTimeout(r, ms)); const uuid = () => crypto.randomUUID?.() ?? 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = Math.random() * 16 | 0; return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); }); const randStr = n => { const c = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let s = ''; for (let i = 0; i < n; i++) s += c[Math.random() * c.length | 0]; return s; }; /** GM_xmlhttpRequest → Promise */ function gm(opts) { return new Promise((ok, fail) => { GM_xmlhttpRequest({ ...opts, onload: ok, onerror: fail, ontimeout: fail }); }); } /** 设备语言 → 短码 */ const deviceLang = (() => { const raw = navigator.language || navigator.userLanguage || 'zh-CN'; const short = raw.split('-')[0].toLowerCase(); return short; })(); const isMobile = /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent); // ╔═══════════════════════════════════════╗ // ║ §2 CONFIGURATION — 持久化状态 ║ // ╚═══════════════════════════════════════╝ let cfg = { engine: await GM_getValue('t_engine', 'microsoft'), lang: await GM_getValue('t_lang', deviceLang), auto: await GM_getValue('t_auto', true), bilingual: await GM_getValue('t_bilingual', false), hlColor: await GM_getValue('t_hlColor', '#d8551f'), }; const saveCfg = k => GM_setValue('t_' + k, cfg[k]); // ╔═══════════════════════════════════════╗ // ║ §3 CACHE — 翻译记忆 (LRU Map) ║ // ╚═══════════════════════════════════════╝ // 性质:cache.get ∘ cache.set ≡ id (读写一致) const cache = new Map(); const CACHE_MAX = 3000; function cacheGet(t) { return cache.get(t) } function cacheSet(t, v) { if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value); cache.set(t, v); } // ╔═══════════════════════════════════════╗ // ║ §4 ENGINES — 翻译引擎同构族 ║ // ║ ║ // ║ 每个引擎 e 满足: ║ // ║ e.translate : (text, lang) → str ║ // ║ e.langCode : shortLang → apiLang ║ // ║ e.auth? : () → void ║ // ╚═══════════════════════════════════════╝ const LANG_COMMON = { 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' }; const engines = { // ── 4.1 Microsoft ★ ────────────────────────────── microsoft: { label: 'Microsoft', _tk: null, _tkTime: 0, langMap: { ...LANG_COMMON, zh:'zh-Hans' }, lc(l) { return this.langMap[l] || l }, async auth() { if (this._tk && Date.now() - this._tkTime < 480_000) return; const r = await gm({ method:'GET', url:'https://edge.microsoft.com/translate/auth' }); if (r.status !== 200) throw Error('MS auth ' + r.status); this._tk = r.responseText; this._tkTime = Date.now(); }, /** 单条翻译 */ async translate(text, lang) { await this.auth(); const r = await gm({ method:'POST', url:`https://api-edge.cognitive.microsofttranslator.com/translate?from=&to=${this.lc(lang)}&api-version=3.0`, headers:{ 'authorization':`Bearer ${this._tk}`, 'Content-Type':'application/json' }, data: JSON.stringify([{Text:text}]), }); if (r.status !== 200) throw Error('MS tr ' + r.status); return JSON.parse(r.responseText)[0].translations[0].text; }, /** 批量翻译 — 最多 25 条/请求 (reduce network round-trips) */ async batch(texts, lang) { await this.auth(); const to = this.lc(lang); const out = new Array(texts.length).fill(null); for (let i = 0; i < texts.length; i += 25) { const chunk = texts.slice(i, i+25); const r = await gm({ method:'POST', url:`https://api-edge.cognitive.microsofttranslator.com/translate?from=&to=${to}&api-version=3.0`, headers:{ 'authorization':`Bearer ${this._tk}`, 'Content-Type':'application/json' }, data: JSON.stringify(chunk.map(t => ({Text:t}))), }); if (r.status === 200) { JSON.parse(r.responseText).forEach((d,j) => { out[i+j] = d.translations[0].text }); } } return out; } }, // ── 4.2 Google ★ ───────────────────────────────── google: { label: 'Google', langMap: { ...LANG_COMMON, zh:'zh-CN' }, lc(l) { return this.langMap[l] || l }, async translate(text, lang) { const r = await gm({ method:'GET', url:`https://translate.googleapis.com/translate_a/single?client=gtx&dt=t&sl=auto&tl=${this.lc(lang)}&q=${encodeURIComponent(text)}`, }); if (r.status !== 200) throw Error('G tr ' + r.status); return JSON.parse(r.responseText)[0].map(s => s[0]).join(''); } }, // ── 4.3 Tencent (Transmart) ★ ──────────────────── tencent: { label: 'Tencent', _ck: null, langMap: { ...LANG_COMMON }, lc(l) { return this.langMap[l] || l }, async translate(text, lang) { this._ck = this._ck || `browser-chrome-120.0-os-${uuid()}-${Date.now()}`; const r = await gm({ 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._ck, user:'' }, type:'plain', model_category:'normal', text_domain:'general', source:{ lang:'auto', text_list:[text] }, target:{ lang: this.lc(lang) } }), }); if (r.status !== 200) throw Error('QQ tr ' + r.status); return JSON.parse(r.responseText).auto_translation[0]; } }, // ── 4.4 DeepL ──────────────────────────────────── deepl: { label: 'DeepL', langMap: { 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' }, lc(l) { return this.langMap[l] || l.toUpperCase() }, async translate(text, lang) { const to = this.lc(lang); const from = (lang === 'en') ? 'ZH' : 'EN'; const id = 1e4 * Math.round(1e4 * Math.random()); const ts = Date.now(); const iCount = (text.match(/[i]/g) || []).length + 1; const body = JSON.stringify({ jsonrpc:'2.0', method:'LMT_handle_jobs', id, params:{ jobs:[{ kind:'default', sentences:[{text, id:0, prefix:''}], raw_en_context_before:[], raw_en_context_after:[], preferred_num_beams:4, quality:'fast' }], lang:{ preference:{ weight:{}, default:'default' }, source_lang_user_selected:'auto', target_lang: to }, priority:-1, commonJobParams:{ mode:'translate', browserType:1 }, timestamp: ts + (iCount - ts % iCount) } }).replace( 'hod":"', ((id+3)%13===0||(id+5)%29===0) ? 'hod" : "' : 'hod": "' ); const r = await gm({ method:'POST', url:'https://www2.deepl.com/jsonrpc?method=LMT_handle_jobs', headers:{ 'Content-Type':'application/json','Origin':'https://www.deepl.com' }, anonymous:true, data: body, }); if (r.status !== 200) throw Error('DL tr ' + r.status); return JSON.parse(r.responseText).result.translations[0].beams[0].sentences[0].text; } }, // ── 4.5 Alibaba ────────────────────────────────── alibaba: { label: 'Alibaba', _csrf: null, langMap: { ...LANG_COMMON }, lc(l) { return this.langMap[l] || l }, async auth() { if (this._csrf) return; const r = await gm({ method:'GET', url:'https://translate.alibaba.com/api/translate/csrftoken' }); if (r.status === 200) this._csrf = JSON.parse(r.responseText).token; }, async translate(text, lang) { await this.auth(); const b = randStr(16); const r = await gm({ method:'POST', url:'https://translate.alibaba.com/api/translate/text', headers:{ 'content-type':`multipart/form-data; boundary=----WebKitFormBoundary${b}`, 'origin':'https://translate.alibaba.com', }, data: `------WebKitFormBoundary${b}\r\nContent-Disposition: form-data; name="srcLang"\r\n\r\nauto\r\n` + `------WebKitFormBoundary${b}\r\nContent-Disposition: form-data; name="tgtLang"\r\n\r\n${this.lc(lang)}\r\n` + `------WebKitFormBoundary${b}\r\nContent-Disposition: form-data; name="domain"\r\n\r\ngeneral\r\n` + `------WebKitFormBoundary${b}\r\nContent-Disposition: form-data; name="query"\r\n\r\n${text}\r\n` + `------WebKitFormBoundary${b}\r\nContent-Disposition: form-data; name="_csrf"\r\n\r\n${this._csrf}\r\n` + `------WebKitFormBoundary${b}--\r\n`, }); if (r.status !== 200) throw Error('Ali tr ' + r.status); return JSON.parse(r.responseText).data.translateText; } }, // ── 4.6 Youdao Mobile ──────────────────────────── youdao: { label: 'Youdao', langMap: { 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' }, lc(l) { return this.langMap[l] || l.toUpperCase() }, async translate(text, lang) { const from = (lang === 'zh' || lang === 'ZH_CN') ? this.langMap['en'] : this.langMap['zh']; const to = this.lc(lang); const r = await gm({ method:'POST', url:'https://m.youdao.com/translate', headers:{ 'Content-Type':'application/x-www-form-urlencoded', 'Referer':'https://m.youdao.com/translate/' }, data:`inputtext=${encodeURIComponent(text)}&type=${from}2${to}`, }); if (r.status !== 200) throw Error('YD tr ' + r.status); const doc = document.implementation.createHTMLDocument(); doc.body.innerHTML = r.responseText; const li = doc.querySelector('#translateResult li'); return li ? li.innerText.trim() : null; } }, // ── 4.7 Baidu Mobile ───────────────────────────── baidu: { label: 'Baidu', _token: null, _gtk: null, langMap: { ...LANG_COMMON }, lc(l) { return this.langMap[l] || l }, async auth() { if (this._token && this._gtk) return; const r = await gm({ method:'GET', url:'https://fanyi.baidu.com', headers:{ 'user-agent':'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Mobile Safari/537.36' }, }); if (r.status !== 200) throw Error('BD auth ' + r.status); this._token = /token:\s*['"]([^'"]+)/.exec(r.responseText)?.[1]; this._gtk = /['"](\d{6}\.\d{9})['"]/.exec(r.responseText)?.[1]; }, _sign(text, gtk) { const S = (a, b) => { for (let c = 0; c < b.length - 2; c += 3) { let d = b.charAt(c+2); d = d >= 'a' ? d.charCodeAt(0)-87 : +d; d = b.charAt(c+1) === '+' ? a >>> d : a << d; a = b.charAt(c) === '+' ? a+d & 4294967295 : a ^ d; } return a; }; const parts = gtk.split('.'), f = +parts[0]||0, m = +parts[1]||0; const bytes = []; for (let i = 0; i < text.length; i++) { let c = text.charCodeAt(i); if (c < 128) bytes.push(c); else if (c < 2048) { bytes.push(c>>6|192); bytes.push(c&63|128); } else { bytes.push(c>>12|224); bytes.push(c>>6&63|128); bytes.push(c&63|128); } } let v = f; for (const b of bytes) v = S(v+b, '+-a^+6'); v = S(v, '+-3^+b+-f'); v ^= m; if (v < 0) v = 2147483648 + (2147483647 & v); v %= 1e6; return `${v}.${v^f}`; }, async translate(text, lang) { await this.auth(); const from = (lang==='zh') ? 'en' : 'zh'; const r = await gm({ method:'POST', url:'https://fanyi.baidu.com/basetrans', headers:{ 'Content-Type':'application/x-www-form-urlencoded', 'X-Requested-With':'XMLHttpRequest', 'Referer':'https://fanyi.baidu.com/' }, data:`query=${encodeURIComponent(text)}&from=${from}&to=${this.lc(lang)}&token=${this._token}&sign=${this._sign(text,this._gtk)}`, }); if (r.status !== 200) throw Error('BD tr ' + r.status); return JSON.parse(r.responseText).trans[0].dst; } }, }; // ╔═══════════════════════════════════════╗ // ║ §5 DOM — 文本节点收集与过滤 ║ // ║ ║ // ║ Lemma collect_sound : ║ // ║ ∀ n ∈ collect(root), ║ // ║ ¬ skip(n) ∧ needs_translation(n) ║ // ╚═══════════════════════════════════════╝ const SKIP_TAG = /^(SCRIPT|STYLE|CODE|PRE|SVG|MATH|NOSCRIPT|IFRAME|CANVAS|VIDEO|AUDIO|IMG|BR|HR|INPUT|SELECT|OPTION|TEXTAREA|KBD|SAMP|VAR)$/; const SKIP_CLASS = /tr-ui|notranslate|katex|mathjax|hljs|prism|shiki/i; const TR_ATTR = 'data-tr'; function shouldSkip(el) { if (!el) return true; if (SKIP_TAG.test(el.tagName)) return true; if (SKIP_CLASS.test(el.className)) return true; if (el.isContentEditable) return true; if (el.hasAttribute?.(TR_ATTR)) return true; return false; } /** 目标语言快速检测 — 跳过已经是目标语言的文本 */ function looksLikeTarget(text) { if (!text || text.length < 2) return true; const t = text.trim(); if (/^\d+$/.test(t)) return true; if (cfg.lang === 'zh') return /^[\u4e00-\u9fff\s\d\p{P}]+$/u.test(t); if (cfg.lang === 'en') return /^[\x20-\x7E\s]+$/u.test(t); if (cfg.lang === 'ja') return /[\u3040-\u309F\u30A0-\u30FF]/.test(t); if (cfg.lang === 'ko') return /[\uAC00-\uD7AF]/.test(t); return false; } /** TreeWalker 收集需要翻译的文本节点 */ function collectText(root) { const nodes = []; const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(nd) { if (shouldSkip(nd.parentElement)) return NodeFilter.FILTER_REJECT; const t = nd.textContent.trim(); if (!t || t.length < 2 || /^\d+$/.test(t)) return NodeFilter.FILTER_REJECT; if (nd.parentElement?.hasAttribute(TR_ATTR)) return NodeFilter.FILTER_REJECT; if (looksLikeTarget(t)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); while (walk.nextNode()) nodes.push(walk.currentNode); // Shadow DOM root.querySelectorAll?.('*')?.forEach(el => { if (el.shadowRoot) nodes.push(...collectText(el.shadowRoot)); }); return nodes; } /** 收集需要翻译的 placeholder */ function collectPH(root) { return [...(root.querySelectorAll?.('input[placeholder],textarea[placeholder]') || [])] .filter(el => !el.hasAttribute(TR_ATTR) && el.placeholder.trim() && !looksLikeTarget(el.placeholder)); } // ╔═══════════════════════════════════════╗ // ║ §6 TRANSLATOR — 编排层 ║ // ║ ║ // ║ Theorem translate_idempotent : ║ // ║ translate(translate(page)) ≡ ║ // ║ translate(page). ║ // ║ Proof: 已标记 data-tr 的节点被 ║ // ║ shouldSkip 过滤 ∎ ║ // ╚═══════════════════════════════════════╝ let translating = false; let translated_count = 0; /** 对单条文本执行翻译(含缓存) */ async function tr1(text, lang) { const t = text.trim(); if (!t) return null; const c = cacheGet(t); if (c) return c; try { const eng = engines[cfg.engine]; const result = await eng.translate(t, lang); if (result && result !== t) { cacheSet(t, result); return result; } } catch (e) { console.warn('[TR]', e.message) } return null; } /** * 应用翻译到 DOM — 两种模式 * * Replace 模式 (保持 I₁: 原文存于 data-tr-src): * node.textContent ← translated * * Bilingual 模式 (保持 I₁ + 双射): * node → [original] [translated] */ function applyOne(textNode, original, translated) { const parent = textNode.parentElement; if (!parent) return; if (cfg.bilingual) { // ── 双语模式 ── const wrapper = document.createElement('span'); wrapper.setAttribute(TR_ATTR, '1'); wrapper.dataset.trSrc = original; const srcSpan = document.createElement('span'); srcSpan.className = 'tr-s'; srcSpan.textContent = original + ' '; const dstSpan = document.createElement('span'); dstSpan.className = 'tr-d'; dstSpan.textContent = translated; wrapper.append(srcSpan, dstSpan); textNode.replaceWith(wrapper); } else { // ── 替换模式 ── parent.setAttribute(TR_ATTR, '1'); parent.dataset.trSrc = original; textNode.textContent = translated; } translated_count++; } /** 翻译一棵 DOM 子树 */ async function translateRoot(root) { root = root || document.body; const nodes = collectText(root); const phs = collectPH(root); if (!nodes.length && !phs.length) return; const texts = nodes.map(n => n.textContent.trim()); const eng = engines[cfg.engine]; let results; // 微软支持批量 → 减少请求 if (cfg.engine === 'microsoft' && eng.batch) { // 分离缓存命中 vs 未命中 const uncachedIdx = []; const uncachedTxt = []; results = texts.map((t, i) => { const c = cacheGet(t); if (c) return c; uncachedIdx.push(i); uncachedTxt.push(t); return null; }); if (uncachedTxt.length) { try { const batchRes = await eng.batch(uncachedTxt, cfg.lang); batchRes.forEach((r, j) => { if (r && r !== uncachedTxt[j]) { cacheSet(uncachedTxt[j], r); results[uncachedIdx[j]] = r; } }); } catch (e) { console.warn('[TR batch]', e.message); // 回退逐条 for (let j = 0; j < uncachedTxt.length; j++) { const r = await tr1(uncachedTxt[j], cfg.lang); if (r) results[uncachedIdx[j]] = r; } } } } else { // 并发逐条翻译 (concurrency = 6) results = new Array(texts.length).fill(null); const CONC = 6; for (let i = 0; i < texts.length; i += CONC) { const batch = texts.slice(i, i + CONC); const ps = batch.map(async (t, j) => { results[i + j] = await tr1(t, cfg.lang); }); await Promise.allSettled(ps); } } // 写入 DOM for (let i = 0; i < nodes.length; i++) { if (!results[i] || results[i] === texts[i]) continue; if (!nodes[i].parentElement) continue; applyOne(nodes[i], texts[i], results[i]); } // Placeholder if (phs.length) { const phTexts = phs.map(el => el.placeholder.trim()); for (let i = 0; i < phs.length; i += 6) { const batch = phTexts.slice(i, i+6); const ps = batch.map(async (t, j) => { const r = await tr1(t, cfg.lang); if (r) { phs[i+j].dataset.trSrc = phs[i+j].placeholder; phs[i+j].placeholder = r; phs[i+j].setAttribute(TR_ATTR, '1'); } }); await Promise.allSettled(ps); } } } /** 主翻译入口 */ async function translatePage() { if (translating) return; translating = true; translated_count = 0; setStatus('translating'); try { // 针对特定站点缩小翻译范围 let root = document.body; if (/twitter\.com|x\.com/.test(location.host)) { root = document.querySelector('[data-testid="primaryColumn"]') || root; } await translateRoot(root); } catch (e) { console.error('[TR page]', e); } translating = false; setStatus('done'); } // ╔═══════════════════════════════════════╗ // ║ §7 RESTORE — 还原 (保证 I₂) ║ // ║ ║ // ║ Theorem restore_inverse : ║ // ║ ∀ page, restore(translate(page)) ║ // ║ ≡ page. ║ // ║ Proof: 原文从 data-tr-src 恢复, ║ // ║ 标记清除, DOM 结构还原 ∎ ║ // ╚═══════════════════════════════════════╝ function restorePage() { // 双语模式的 wrapper span document.querySelectorAll('span.tr-s, span.tr-d, span[data-tr].tr-bi, span[data-tr]').forEach(el => { // noop — handled below }); document.querySelectorAll(`[${TR_ATTR}]`).forEach(el => { const src = el.dataset.trSrc; if (!src) { el.removeAttribute(TR_ATTR); return; } if (el.tagName === 'SPAN' && el.querySelector('.tr-s')) { // 双语 wrapper → 替换回文本节点 el.replaceWith(document.createTextNode(src)); } else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { el.placeholder = src; delete el.dataset.trSrc; el.removeAttribute(TR_ATTR); } else { // 替换模式 → 找到文本子节点还原 for (const ch of el.childNodes) { if (ch.nodeType === Node.TEXT_NODE) { ch.textContent = src; break; } } delete el.dataset.trSrc; el.removeAttribute(TR_ATTR); } }); translated_count = 0; setStatus('idle'); } // ╔═══════════════════════════════════════╗ // ║ §8 OBSERVERS — 动态内容感知 ║ // ║ ║ // ║ 不变量: 新增 DOM 节点被自动纳入翻译 ║ // ╚═══════════════════════════════════════╝ let mutTimer = null; const observer = new MutationObserver(muts => { if (!cfg.auto || translating) return; if (mutTimer) clearTimeout(mutTimer); mutTimer = setTimeout(() => { const roots = new Set(); for (const m of muts) { for (const nd of m.addedNodes) { if (nd.nodeType === Node.ELEMENT_NODE && !shouldSkip(nd)) roots.add(nd); } } if (roots.size) roots.forEach(r => translateRoot(r)); }, 800); }); let scrollTimer = null, lastH = 0; function onScroll() { if (!cfg.auto) return; if (scrollTimer) clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { const h = document.documentElement.scrollHeight; if (h > lastH + 200) { lastH = h; translatePage(); } }, 600); } // ╔═══════════════════════════════════════╗ // ║ §9 UI — 界面层 ║ // ║ ║ // ║ 移动端 + 桌面端自适应 ║ // ║ 暗色模式自动跟随系统 ║ // ╚═══════════════════════════════════════╝ GM_addStyle(` /* ── 翻译高亮 ── */ .tr-d{color:${cfg.hlColor}!important;font-style:normal} .tr-s{opacity:.55} /* ── 浮动按钮 ── */ .tr-ui{position:fixed;bottom:${isMobile?16:24}px;right:${isMobile?12:24}px; z-index:2147483647;font-family:system-ui,-apple-system,sans-serif;font-size:13px; -webkit-tap-highlight-color:transparent} .tr-ui *{box-sizing:border-box;margin:0;padding:0} .tr-btn{width:${isMobile?46:44}px;height:${isMobile?46:44}px;border-radius:50%;border:none; background:rgba(0,0,0,.45);color:#fff;cursor:pointer; display:flex;align-items:center;justify-content:center; backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px); box-shadow:0 2px 12px rgba(0,0,0,.18); transition:transform .15s,background .2s,box-shadow .2s; touch-action:manipulation;outline:none;position:relative} .tr-btn:active{transform:scale(.88)} .tr-btn.on{background:rgba(34,128,255,.75);box-shadow:0 2px 16px rgba(34,128,255,.35)} .tr-btn svg{pointer-events:none} /* 翻译中脉冲 */ @keyframes tr-pulse{0%,100%{box-shadow:0 0 0 0 rgba(34,128,255,.5)} 50%{box-shadow:0 0 0 10px rgba(34,128,255,0)}} .tr-btn.busy{animation:tr-pulse 1.2s infinite} /* ── 面板 ── */ .tr-panel{position:absolute;bottom:56px;right:0;width:${isMobile?220:210}px; background:rgba(255,255,255,.96);color:#333; backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px); border-radius:14px;box-shadow:0 8px 32px rgba(0,0,0,.14); padding:14px 16px;opacity:0;pointer-events:none; transform:translateY(8px) scale(.96); transition:opacity .2s,transform .2s} .tr-panel.open{opacity:1;pointer-events:auto;transform:translateY(0) scale(1)} .tr-panel label{display:block;margin:10px 0 4px;font-size:11px;color:#999; text-transform:uppercase;letter-spacing:.6px;font-weight:600} .tr-panel label:first-child{margin-top:0} .tr-panel select{width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:8px; font-size:13px;background:#fff;color:#333;outline:none; -webkit-appearance:auto;appearance:auto} .tr-panel select:focus{border-color:#5aa3ff} .tr-toggle{display:flex;align-items:center;justify-content:space-between; margin:10px 0 6px;cursor:pointer;-webkit-user-select:none;user-select:none} .tr-toggle-track{width:38px;height:22px;border-radius:11px;background:#ccc; position:relative;transition:background .2s} .tr-toggle-track.on{background:#5aa3ff} .tr-toggle-thumb{width:18px;height:18px;border-radius:50%;background:#fff; position:absolute;top:2px;left:2px;transition:left .2s;box-shadow:0 1px 3px rgba(0,0,0,.2)} .tr-toggle-track.on .tr-toggle-thumb{left:18px} .tr-actions{display:flex;gap:8px;margin-top:12px} .tr-actions button{flex:1;padding:8px 0;border:none;border-radius:8px; font-size:12px;font-weight:600;cursor:pointer;transition:background .15s; touch-action:manipulation;outline:none} .tr-act-go{background:#5aa3ff;color:#fff} .tr-act-go:active{background:#3d8ae5} .tr-act-rst{background:#f0f0f0;color:#555} .tr-act-rst:active{background:#ddd} .tr-status{text-align:center;margin-top:8px;font-size:11px;color:#aaa;min-height:14px} @media(prefers-color-scheme:dark){ .tr-panel{background:rgba(28,28,30,.94);color:#eee;box-shadow:0 8px 32px rgba(0,0,0,.4)} .tr-panel select{background:#2c2c2e;color:#eee;border-color:#444} .tr-act-rst{background:#3a3a3c;color:#ccc} .tr-panel label{color:#777} .tr-status{color:#666} } `); // ── HTML ── const uiEl = document.createElement('div'); uiEl.className = 'tr-ui'; uiEl.innerHTML = `
双语 Bilingual
`; document.body.appendChild(uiEl); const $ = id => document.getElementById(id); const btnEl = $('trBtn'); const panelEl = $('trPanel'); const engSel = $('trEng'); const langSel = $('trLang'); const statEl = $('trStat'); engSel.value = cfg.engine; langSel.value = cfg.lang; function setStatus(s) { if (s === 'translating') { btnEl.classList.add('busy'); statEl.textContent = '翻译中…'; } else if (s === 'done') { btnEl.classList.remove('busy'); statEl.textContent = translated_count ? `已翻译 ${translated_count} 处` : '完成'; setTimeout(() => { if (statEl.textContent.startsWith('已')) statEl.textContent = '' }, 3000); } else { btnEl.classList.remove('busy'); statEl.textContent = ''; } } // ╔═══════════════════════════════════════╗ // ║ §10 EVENTS — 交互绑定 ║ // ╚═══════════════════════════════════════╝ // 面板开关 btnEl.addEventListener('click', e => { e.stopPropagation(); panelEl.classList.toggle('open'); }); document.addEventListener('click', e => { if (!uiEl.contains(e.target)) panelEl.classList.remove('open'); }); // 阻止面板点击冒泡 panelEl.addEventListener('click', e => e.stopPropagation()); // 引擎切换 engSel.addEventListener('change', () => { cfg.engine = engSel.value; saveCfg('engine'); cache.clear(); }); // 语言切换 langSel.addEventListener('change', () => { cfg.lang = langSel.value; saveCfg('lang'); cache.clear(); }); // 双语开关 $('trBiToggle').addEventListener('click', () => { cfg.bilingual = !cfg.bilingual; saveCfg('bilingual'); $('trBiToggle').querySelector('.tr-toggle-track').classList.toggle('on', cfg.bilingual); // 切换模式 → 还原再翻译 if (translated_count > 0) { restorePage(); translatePage(); } }); // 翻译按钮 $('trGo').addEventListener('click', async () => { panelEl.classList.remove('open'); cfg.auto = true; saveCfg('auto'); btnEl.classList.add('on'); restorePage(); cache.clear(); lastH = document.documentElement.scrollHeight; await translatePage(); }); // 还原按钮 $('trRst').addEventListener('click', () => { panelEl.classList.remove('open'); cfg.auto = false; saveCfg('auto'); btnEl.classList.remove('on'); restorePage(); }); // 输入框三击空格翻译 let spaceCount = 0, lastSpaceTime = 0; document.body.addEventListener('keydown', e => { if (e.key !== ' ') return; const now = Date.now(); spaceCount = (now - lastSpaceTime < 300) ? spaceCount + 1 : 1; lastSpaceTime = now; if (spaceCount >= 3 && /INPUT|TEXTAREA/i.test(e.target.tagName)) { spaceCount = 0; const el = e.target; const val = (el.value || '').trim(); if (val) { tr1(val, cfg.lang).then(r => { if (r) el.value = r }); } } }); // GM 菜单 GM_registerMenuCommand('翻译页面', () => translatePage()); GM_registerMenuCommand('还原页面', () => restorePage()); // ╔═══════════════════════════════════════╗ // ║ §11 BOOTSTRAP — 启动 ║ // ║ ║ // ║ 终态验证: ║ // ║ auto=true ∧ ¬isTargetLang(page) ║ // ║ → translatePage() 被调用 ║ // ╚═══════════════════════════════════════╝ function isPageTargetLang() { const l = (document.documentElement.lang || '').split('-')[0].toLowerCase(); return l && l === cfg.lang; } // 启动观察器 observer.observe(document.body, { childList: true, subtree: true }); lastH = document.documentElement.scrollHeight; window.addEventListener('scroll', onScroll, { passive: true }); // 自动翻译 if (cfg.auto && !isPageTargetLang()) { setTimeout(() => translatePage(), 1200); } })();