// ==UserScript== // @name Auto Translate Pro // @namespace auto-translate-pro // @version 3.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 // @connect fanyi.sogou.com // @connect ifanyi.iciba.com // @connect m.youdao.com // @connect api.interpreter.caiyunai.com // @connect fanyi.caiyunapp.com // @connect translate.alibaba.com // @connect papago.naver.com // @connect www2.deepl.com // @connect fanyi.baidu.com // @connect translate.yandex.net // @connect translate.yandex.com // @connect fanyi.pdf365.cn // @connect * // @run-at document-idle // @downloadURL none // ==/UserScript== (async () => { 'use strict'; // ═══════════════════════════════════════════════════════════════════ // // 形式化规约 (Coq-style Specification) // // ── 类型定义 ── // TextNode := DOM Text Node with textContent.trim().length > 0 // TransUnit := { node: TextNode, original: string, translated: string | null } // EngineSpec := { name: string, translate: string → string → Promise } // Mode := Idle | Translating | Replaced | Bilingual // State := { mode: Mode, units: Map, cache: Map } // // ── 不变量 ── // I₁ (标记一致性): // ∀ el ∈ DOM, el.dataset.translated = '1' // ⟹ el.dataset.origText ∈ string ∧ el.dataset.origText = 翻译前文本 // // I₂ (缓存纯函数): // ∀ (k, v) ∈ cache, engine.translate(k, lang) = v // 即缓存是翻译函数的记忆化 // // I₃ (幂等性): // translatePage ∘ translatePage ≡ translatePage // 通过 data-translated 守卫实现 // // I₄ (可逆性): // restorePage ∘ translatePage ≡ id // 翻译前状态完整保存,还原精确恢复 // // I₅ (双语同构): // bilingualMode = true ⟹ // ∀ translated node, ∃ (span.bt-s, span.bt-d) ∈ node.children // s.t. bt-s.text = original ∧ bt-d.text = translated // // I₆ (完备性): // ∀ visible text node n ∈ DOM, // ¬isTargetLang(n.text) ∧ ¬isSkipped(n.parent) // ⟹ n ∈ translatePage 的处理范围 // // ── 状态转移 ── // Idle →[translate]→ Translating // Translating →[complete]→ Replaced | Bilingual // Replaced →[restore]→ Idle // Replaced →[toggleBi]→ Bilingual // Bilingual →[toggleBi]→ Replaced // Bilingual →[restore]→ Idle // * →[switchEngine]→ Idle → Translating (自动重翻) // * →[switchLang]→ Idle → Translating (自动重翻) // // ═══════════════════════════════════════════════════════════════════ try { if (document.contentType === 'application/xml') return; } catch (_) {} // ════════════════════════════ // 常量 // ════════════════════════════ const BATCH_SIZE = 25; const CONCURRENCY = 5; const SCROLL_DEBOUNCE = 500; const MUTATION_DEBOUNCE = 700; const STARTUP_DELAY = 1000; const TOKEN_TTL = 480000; const MAX_CACHE = 4000; const MAX_TEXT_LEN = 5000; const MIN_TEXT_LEN = 1; // ════════════════════════════ // 工具函数 // ════════════════════════════ const deviceLang = (() => { const raw = navigator.language || navigator.userLanguage || 'zh-CN'; return raw.split('-')[0].toLowerCase(); })(); function gmFetch(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ timeout: 20000, ...opts, onload: resolve, onerror: reject, ontimeout: reject, }); }); } function uuid() { if (typeof crypto !== 'undefined' && 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); }); } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } // MD5 极简实现 (用于搜狗/词霸等签名) // 生产环境应使用完整实现,这里提供核心功能 function md5(string) { function md5cycle(x, k) { let a = x[0], b = x[1], c = x[2], d = x[3]; a = ff(a, b, c, d, k[0], 7, -680876936);d = ff(d, a, b, c, k[1], 12, -389564586);c = ff(c, d, a, b, k[2], 17, 606105819);b = ff(b, c, d, a, k[3], 22, -1044525330); a = ff(a, b, c, d, k[4], 7, -176418897);d = ff(d, a, b, c, k[5], 12, 1200080426);c = ff(c, d, a, b, k[6], 17, -1473231341);b = ff(b, c, d, a, k[7], 22, -45705983); a = ff(a, b, c, d, k[8], 7, 1770035416);d = ff(d, a, b, c, k[9], 12, -1958414417);c = ff(c, d, a, b, k[10], 17, -42063);b = ff(b, c, d, a, k[11], 22, -1990404162); a = ff(a, b, c, d, k[12], 7, 1804603682);d = ff(d, a, b, c, k[13], 12, -40341101);c = ff(c, d, a, b, k[14], 17, -1502002290);b = ff(b, c, d, a, k[15], 22, 1236535329); a = gg(a, b, c, d, k[1], 5, -165796510);d = gg(d, a, b, c, k[6], 9, -1069501632);c = gg(c, d, a, b, k[11], 14, 643717713);b = gg(b, c, d, a, k[0], 20, -373897302); a = gg(a, b, c, d, k[5], 5, -701558691);d = gg(d, a, b, c, k[10], 9, 38016083);c = gg(c, d, a, b, k[15], 14, -660478335);b = gg(b, c, d, a, k[4], 20, -405537848); a = gg(a, b, c, d, k[9], 5, 568446438);d = gg(d, a, b, c, k[14], 9, -1019803690);c = gg(c, d, a, b, k[3], 14, -187363961);b = gg(b, c, d, a, k[8], 20, 1163531501); a = gg(a, b, c, d, k[13], 5, -1444681467);d = gg(d, a, b, c, k[2], 9, -51403784);c = gg(c, d, a, b, k[7], 14, 1735328473);b = gg(b, c, d, a, k[12], 20, -1926607734); a = hh(a, b, c, d, k[5], 4, -378558);d = hh(d, a, b, c, k[8], 11, -2022574463);c = hh(c, d, a, b, k[11], 16, 1839030562);b = hh(b, c, d, a, k[14], 23, -35309556); a = hh(a, b, c, d, k[1], 4, -1530992060);d = hh(d, a, b, c, k[4], 11, 1272893353);c = hh(c, d, a, b, k[7], 16, -155497632);b = hh(b, c, d, a, k[10], 23, -1094730640); a = hh(a, b, c, d, k[13], 4, 681279174);d = hh(d, a, b, c, k[0], 11, -358537222);c = hh(c, d, a, b, k[3], 16, -722521979);b = hh(b, c, d, a, k[6], 23, 76029189); a = hh(a, b, c, d, k[9], 4, -640364487);d = hh(d, a, b, c, k[12], 11, -421815835);c = hh(c, d, a, b, k[15], 16, 530742520);b = hh(b, c, d, a, k[2], 23, -995338651); a = ii(a, b, c, d, k[0], 6, -198630844);d = ii(d, a, b, c, k[7], 10, 1126891415);c = ii(c, d, a, b, k[14], 15, -1416354905);b = ii(b, c, d, a, k[5], 21, -57434055); a = ii(a, b, c, d, k[12], 6, 1700485571);d = ii(d, a, b, c, k[3], 10, -1894986606);c = ii(c, d, a, b, k[10], 15, -1051523);b = ii(b, c, d, a, k[1], 21, -2054922799); a = ii(a, b, c, d, k[8], 6, 1873313359);d = ii(d, a, b, c, k[15], 10, -30611744);c = ii(c, d, a, b, k[6], 15, -1560198380);b = ii(b, c, d, a, k[13], 21, 1309151649); a = ii(a, b, c, d, k[4], 6, -145523070);d = ii(d, a, b, c, k[11], 10, -1120210379);c = ii(c, d, a, b, k[2], 15, 718787259);b = ii(b, c, d, a, k[9], 21, -343485551); x[0] = add32(a, x[0]);x[1] = add32(b, x[1]);x[2] = add32(c, x[2]);x[3] = add32(d, x[3]); } function cmn(q, a, b, x, s, t) { a = add32(add32(a, q), add32(x, t)); return add32((a << s) | (a >>> (32 - s)), b); } function ff(a, b, c, d, x, s, t) { return cmn((b & c) | ((~b) & d), a, b, x, s, t); } function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & (~d)), a, b, x, s, t); } function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t); } function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | (~d)), a, b, x, s, t); } function md51(s) { let n = s.length, state = [1732584193, -271733879, -1732584194, 271733878], i; for (i = 64; i <= n; i += 64) md5cycle(state, md5blk(s.substring(i - 64, i))); s = s.substring(i - 64); let tail = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]; for (i = 0; i < s.length; i++) tail[i >> 2] |= s.charCodeAt(i) << ((i % 4) << 3); tail[i >> 2] |= 0x80 << ((i % 4) << 3); if (i > 55) { md5cycle(state, tail); tail = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]; } tail[14] = n * 8; md5cycle(state, tail); return state; } function md5blk(s) { let md5blks = [], i; for (i = 0; i < 64; i += 4) md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i+1) << 8) + (s.charCodeAt(i+2) << 16) + (s.charCodeAt(i+3) << 24); return md5blks; } function rhex(n) { let s = '', j; for (j = 0; j < 4; j++) s += ('0' + ((n >> (j * 8 + 4)) & 0x0F).toString(16) + (n >> (j * 8) & 0x0F).toString(16)); return s; } function hex(x) { for (let i = 0; i < x.length; i++) x[i] = rhex(x[i]); return x.join(''); } function add32(a, b) { return (a + b) & 0xFFFFFFFF; } if (typeof string !== 'string') string = String(string); return hex(md51(unescape(encodeURIComponent(string)))); } // ════════════════════════════ // 翻译引擎注册表 // ════════════════════════════ // // 每个引擎满足接口契约: // name: string — 显示名 // needsAuth: boolean — 是否需要预鉴权 // auth(): Promise — 鉴权 (needsAuth=true时调用) // langCode(baseLang: string): string — 将基础语言码映射为引擎语言码 // translate(text, toLang): Promise — 翻译单条 // supportsBatch: boolean — 是否支持原生批量 // translateBatch?(texts, toLang): Promise — 批量翻译 // const Engine = {}; // ── Google ── Engine.google = { name: 'Google', needsAuth: false, supportsBatch: false, 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(''); } }; // ── Microsoft ── Engine.microsoft = { name: 'Microsoft', needsAuth: true, supportsBatch: true, _token: null, _tokenTime: 0, async auth() { if (this._token && Date.now() - this._tokenTime < TOKEN_TTL) return; 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(); }, 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) { await this.auth(); 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 ${this._token}`, 'Content-Type': 'application/json' }, data: JSON.stringify([{ Text: text }]), }); if (r.status !== 200) throw new Error(`MS ${r.status}`); return JSON.parse(r.responseText)[0].translations[0].text; }, async translateBatch(texts, toLang) { await this.auth(); const to = this.langCode(toLang); const all = []; for (let i = 0; i < texts.length; i += BATCH_SIZE) { const chunk = texts.slice(i, i + BATCH_SIZE); try { const r = await gmFetch({ method: 'POST', url: `https://api-edge.cognitive.microsofttranslator.com/translate?from=&to=${to}&api-version=3.0`, headers: { 'authorization': `Bearer ${this._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) all.push(item.translations[0].text); } else { for (let j = 0; j < chunk.length; j++) all.push(null); } } catch (_) { for (let j = 0; j < chunk.length; j++) all.push(null); } } return all; } }; // ── Tencent (腾讯交互) ── Engine.tencent = { name: 'Tencent', needsAuth: false, supportsBatch: false, _clientKey: null, 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) { if (!this._clientKey) this._clientKey = `browser-chrome-120.0-${uuid()}-${Date.now()}`; 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._clientKey,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]; } }; // ── DeepL (Web) ── Engine.deepl = { name: 'DeepL', needsAuth: false, supportsBatch: false, langCode(lang) { const m = { zh:'ZH',en:'EN',ja:'JA',ko:'KO',fr:'FR',de:'DE',es:'ES',ru:'RU',pt:'PT', it:'IT',tr:'TR',nl:'NL',pl:'PL',cs:'CS',da:'DA',fi:'FI',el:'EL',ro:'RO',hu:'HU',sv:'SV',id:'ID' }; return m[lang] || lang.toUpperCase(); }, async translate(text, toLang) { const to = this.langCode(toLang); const from = 'auto'; const id = Math.floor(Math.random() * 100000) + 1; const ts = Date.now(); const iCount = (text.match(/i/g) || []).length + 1; const body = { 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:from, target_lang:to }, priority:-1, commonJobParams:{ mode:'translate', browserType:1 }, timestamp: ts + (iCount - ts % iCount) } }; let payload = JSON.stringify(body); if ((id + 3) % 13 === 0 || (id + 5) % 29 === 0) { payload = payload.replace('"method":"', '"method" : "'); } const r = await gmFetch({ method:'POST', url:'https://www2.deepl.com/jsonrpc?method=LMT_handle_jobs', headers:{ 'Content-Type':'application/json','Origin':'https://www.deepl.com','Referer':'https://www.deepl.com/' }, anonymous: true, data: payload, }); if (r.status !== 200) throw new Error(`DeepL ${r.status}`); return JSON.parse(r.responseText).result.translations[0].beams[0].sentences[0].text; } }; // ── Alibaba ── Engine.alibaba = { name: 'Alibaba', needsAuth: true, supportsBatch: false, _csrf: null, _boundary: null, async auth() { if (this._csrf) return; const r = await gmFetch({ method:'GET', url:'https://translate.alibaba.com/api/translate/csrftoken' }); if (r.status === 200) { this._csrf = JSON.parse(r.responseText).token; this._boundary = Array.from({length:16}, () => 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'[Math.random()*62|0]).join(''); } else throw new Error(`Alibaba auth ${r.status}`); }, 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) { await this.auth(); const to = this.langCode(toLang); const b = this._boundary; const body = `------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${to}\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`; const r = await gmFetch({ method:'POST', url:'https://translate.alibaba.com/api/translate/text', headers:{ 'content-type':`multipart/form-data; boundary=----WebKitFormBoundary${b}`, 'accept':'application/json, text/plain, */*', 'Origin':'https://translate.alibaba.com','Referer':'https://translate.alibaba.com/', 'x-xsrf-token_property_item': this._csrf, }, data: body, }); if (r.status !== 200) throw new Error(`Alibaba ${r.status}`); return JSON.parse(r.responseText).data.translateText; } }; // ── Papago ── Engine.papago = { name: 'Papago', needsAuth: false, supportsBatch: false, _id: null, langCode(lang) { const m = { zh:'zh-CN',en:'en',ja:'ja',ko:'ko',fr:'fr',de:'de',es:'es',ru:'ru',pt:'pt', th:'th',vi:'vi',it:'it',tr:'tr',id:'id' }; return m[lang] || lang; }, _hmacMd5(key, message) { // 简易 HMAC-MD5 const blockSize = 64; let keyBytes = key; if (keyBytes.length > blockSize) keyBytes = md5(keyBytes); while (keyBytes.length < blockSize) keyBytes += '\0'; let opad = '', ipad = ''; for (let i = 0; i < blockSize; i++) { const b = keyBytes.charCodeAt(i); opad += String.fromCharCode(b ^ 0x5c); ipad += String.fromCharCode(b ^ 0x36); } const inner = md5(ipad + message); // md5 returns hex, need to convert back to binary for outer let innerBin = ''; for (let i = 0; i < inner.length; i += 2) innerBin += String.fromCharCode(parseInt(inner.substr(i, 2), 16)); return md5(opad + innerBin); }, async translate(text, toLang) { if (!this._id) this._id = uuid(); const to = this.langCode(toLang); const ts = Date.now() - 1073; const msg = `${this._id}\nhttps://papago.naver.com/apis/nsmt/translate\n${ts}`; const auth = 'PPG ' + this._id + ':' + btoa(this._hmacMd5('v1.8.12_7cf22c1499', msg)); const r = await gmFetch({ method:'POST', url:'https://papago.naver.com/apis/nsmt/translate', headers:{ 'Origin':'https://papago.naver.com','Referer':'https://papago.naver.com/', 'accept':'application/json','content-type':'application/x-www-form-urlencoded; charset=UTF-8', 'Device-Type':'pc','Timestamp':`${ts}`,'Authorization': auth, }, data:`deviceId=${this._id}&locale=zh-CN&dict=true&dictDisplay=30&honorific=false&instant=false&paging=false&source=auto&target=${to}&text=${encodeURIComponent(text)}`, }); if (r.status !== 200) throw new Error(`Papago ${r.status}`); return JSON.parse(r.responseText).translatedText; } }; // ── 有道手机版 ── Engine.youdao = { name: 'Youdao', needsAuth: false, supportsBatch: false, langCode(lang) { const m = { zh:'ZH_CN',en:'EN',ja:'JA',ko:'KO',fr:'FR',de:'DE',es:'ES',ru:'RU',pt:'PT' }; return m[lang] || lang.toUpperCase(); }, async translate(text, toLang) { const to = this.langCode(toLang); const r = await gmFetch({ method:'POST', url:'https://m.youdao.com/translate', headers:{ 'Origin':'https://m.youdao.com','Referer':'https://m.youdao.com/translate/','Content-Type':'application/x-www-form-urlencoded' }, data:`inputtext=${encodeURIComponent(text)}&type=AUTO2${to}`, }); if (r.status !== 200) throw new Error(`Youdao ${r.status}`); const doc = document.implementation.createHTMLDocument(); doc.body.innerHTML = r.responseText; const li = doc.querySelector('#translateResult li'); return li ? li.innerText.trim() : null; } }; // ── 彩云小译 ── Engine.caiyun = { name: 'Caiyun', needsAuth: true, supportsBatch: false, _token: null, _jwt: null, _deviceId: null, langCode(lang) { const m = { zh:'zh',en:'en',ja:'ja',ko:'ko',fr:'fr',de:'de',es:'es',ru:'ru' }; return m[lang] || lang; }, _rot13(str) { const a = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; const b = 'NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm'; return str.split('').map(c => { const i = a.indexOf(c); return i > -1 ? b[i] : c; }).join(''); }, _decodeTarget(target) { try { const rotated = this._rot13(target); return decodeURIComponent(escape(atob(rotated))); } catch (_) { return target; } }, async auth() { if (this._jwt && this._token) return; this._deviceId = this._deviceId || Array.from({length:32}, () => '0123456789abcdef'[Math.random()*16|0]).join(''); // 获取页面提取token const pageRes = await gmFetch({ method:'GET', url:'https://fanyi.caiyunapp.com/' }); if (pageRes.status !== 200) throw new Error('Caiyun page error'); const jsMatch = /\/assets\/index\.(.*?)\.js/i.exec(pageRes.responseText); if (!jsMatch) throw new Error('Caiyun js not found'); const jsRes = await gmFetch({ method:'GET', url:`https://fanyi.caiyunapp.com/dist${jsMatch[0]}` }); if (jsRes.status !== 200) throw new Error('Caiyun js error'); const tokenMatch = /token:"(.*?)"/i.exec(jsRes.responseText); if (!tokenMatch) throw new Error('Caiyun token not found'); this._token = tokenMatch[1]; // 获取JWT const jwtRes = await gmFetch({ method:'POST', url:'https://api.interpreter.caiyunai.com/v1/user/jwt/generate', headers:{ 'accept':'application/json, text/plain, */*','content-type':'application/json;charset=UTF-8', 'app-name':'xy','device-id':this._deviceId,'os-type':'web', 'x-authorization':this._token,'origin':'https://fanyi.caiyunapp.com', }, data: JSON.stringify({ browser_id: this._deviceId }), }); if (jwtRes.status === 200) this._jwt = JSON.parse(jwtRes.responseText).jwt; }, async translate(text, toLang) { await this.auth(); if (!this._jwt) throw new Error('Caiyun no JWT'); const to = this.langCode(toLang); const r = await gmFetch({ method:'POST', url:'https://api.interpreter.caiyunai.com/v1/translator', headers:{ 'accept':'application/json, text/plain, */*','content-type':'application/json;charset=UTF-8', 'app-name':'xiaoyi','device-id':this._deviceId,'os-type':'web', 't-authorization':this._jwt,'x-authorization':this._token, 'origin':'https://fanyi.caiyunapp.com', }, data: JSON.stringify({ source:text, trans_type:`auto2${to}`, request_id:'web_fanyi', media:'text', os_type:'web', dict:true, cached:true, replaced:true, style:'formal', browser_id:this._deviceId, }), }); if (r.status !== 200) throw new Error(`Caiyun ${r.status}`); return this._decodeTarget(JSON.parse(r.responseText).target); } }; // ── 百度手机版 ── Engine.baidu = { name: 'Baidu', needsAuth: true, supportsBatch: false, _token: null, _gtk: null, langCode(lang) { const m = { zh:'zh',en:'en',ja:'jp',ko:'kor',fr:'fra',de:'de',es:'spa',ru:'ru',pt:'pt', ar:'ara',th:'th',vi:'vie',it:'it',tr:'tr',id:'id' }; return m[lang] || lang; }, async auth() { if (this._token && this._gtk) return; const r = await gmFetch({ method:'GET', url:'https://fanyi.baidu.com', headers:{ 'user-agent':'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36' }, }); if (r.status !== 200) throw new Error(`Baidu auth ${r.status}`); const tokenMatch = /token:\s*['"]([^'"]+)['"]/i.exec(r.responseText); const gtkMatch = /['"](\d{6}\.\d{9})['"]/i.exec(r.responseText); if (tokenMatch) this._token = tokenMatch[1]; if (gtkMatch) this._gtk = gtkMatch[1]; if (!this._token || !this._gtk) throw new Error('Baidu auth data not found'); }, _sign(text) { const gtk = this._gtk; const h = gtk.split('.'); let f = Number(h[0]) || 0, m = Number(h[1]) || 0; let g = [], y = 0; for (let v = 0; v < text.length; v++) { let _ = text.charCodeAt(v); if (_ < 128) g[y++] = _; else if (_ < 2048) { g[y++] = _ >> 6 | 192; g[y++] = 63 & _ | 128; } else if (55296 === (64512 & _) && v + 1 < text.length && 56320 === (64512 & text.charCodeAt(v + 1))) { _ = 65536 + ((1023 & _) << 10) + (1023 & text.charCodeAt(++v)); g[y++] = _ >> 18 | 240; g[y++] = _ >> 12 & 63 | 128; g[y++] = _ >> 6 & 63 | 128; g[y++] = 63 & _ | 128; } else { g[y++] = _ >> 12 | 224; g[y++] = _ >> 6 & 63 | 128; g[y++] = 63 & _ | 128; } } let b = f; const w = '+-a^+6', k = '+-3^+b+-f'; function n(t, e) { for (let n = 0; n < e.length - 2; n += 3) { let r = e.charAt(n + 2); r = r >= 'a' ? r.charCodeAt(0) - 87 : Number(r); r = '+' === e.charAt(n + 1) ? t >>> r : t << r; t = '+' === e.charAt(n) ? t + r & 0xFFFFFFFF : t ^ r; } return t; } for (let x = 0; x < g.length; x++) b = n(b += g[x], w); b = n(b, k); b ^= m; if (b < 0) b = (2147483647 & b) + 2147483648; b %= 1e6; return `${b}.${b ^ f}`; }, async translate(text, toLang) { await this.auth(); const to = this.langCode(toLang); const r = await gmFetch({ method:'POST', url:'https://fanyi.baidu.com/basetrans', headers:{ 'content-type':'application/x-www-form-urlencoded', 'origin':'https://fanyi.baidu.com','referer':'https://fanyi.baidu.com/', }, data:`query=${encodeURIComponent(text)}&from=auto&to=${to}&token=${this._token}&sign=${this._sign(text)}`, }); if (r.status !== 200) throw new Error(`Baidu ${r.status}`); return JSON.parse(r.responseText).trans[0].dst; } }; // ── Yandex ── Engine.yandex = { name: 'Yandex', needsAuth: true, supportsBatch: false, _reqId: null, _idx: 0, 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 auth() { if (this._reqId) return; const r = await gmFetch({ method:'GET', url:'https://translate.yandex.com' }); if (r.status !== 200) throw new Error(`Yandex auth ${r.status}`); const m = /reqid\s*=\s*'([^']+)'/i.exec(r.responseText); if (m) this._reqId = m[1]; else throw new Error('Yandex reqid not found'); }, async translate(text, toLang) { await this.auth(); const to = this.langCode(toLang); const r = await gmFetch({ method:'POST', anonymous:true, url:`https://translate.yandex.net/api/v1/tr.json/translate?id=${this._reqId}-${this._idx++}-0&srv=tr-text&source_lang=auto&target_lang=${to}&reason=type-end&format=text&ajax=1`, headers:{ 'content-type':'application/x-www-form-urlencoded','origin':'https://translate.yandex.com','referer':'https://translate.yandex.com/' }, data:`text=${encodeURIComponent(text)}&options=4`, }); if (r.status !== 200) throw new Error(`Yandex ${r.status}`); return JSON.parse(r.responseText).text[0]; } }; // ── 福昕翻译 ── Engine.fuxi = { name: 'Fuxi', needsAuth: false, supportsBatch: false, langCode(lang) { const m = { zh:'zh-CN',en:'en',ja:'ja',ko:'ko',fr:'fr',de:'de',es:'es',ru:'ru' }; return m[lang] || lang; }, async translate(text, toLang) { const to = this.langCode(toLang); const ts = Date.now(); const sign = md5(ts + 'FOXIT_YEE_TRANSLATE'); const r = await gmFetch({ method:'POST', url:'https://fanyi.pdf365.cn/api/wordTranslateResult', headers:{ 'content-type':'application/x-www-form-urlencoded; charset=UTF-8','origin':'https://fanyi.pdf365.cn','referer':'https://fanyi.pdf365.cn/free' }, data:`plateform=web&orginL=auto&targetL=${to}&text=${encodeURIComponent(text)}×tamp=${ts}&sign=${sign}&userId=`, }); if (r.status !== 200) throw new Error(`Fuxi ${r.status}`); return JSON.parse(r.responseText).result; } }; // ════════════════════════════ // 引擎名称列表(UI用) // ════════════════════════════ const ENGINE_LIST = [ ['microsoft', 'Microsoft'], ['google', 'Google'], ['tencent', 'Tencent'], ['deepl', 'DeepL'], ['alibaba', 'Alibaba'], ['papago', 'Papago'], ['youdao', 'Youdao'], ['caiyun', 'Caiyun'], ['baidu', 'Baidu'], ['yandex', 'Yandex'], ['fuxi', 'Fuxi'], ]; // ════════════════════════════ // 持久化状态 // ════════════════════════════ let currentEngine = await GM_getValue('engine', 'microsoft'); let targetLang = await GM_getValue('targetLang', deviceLang); let autoMode = await GM_getValue('autoMode', false); let bilingualMode = await GM_getValue('bilingualMode', false); // 验证引擎存在 if (!Engine[currentEngine]) currentEngine = 'microsoft'; // ════════════════════════════ // LRU 缓存 // ════════════════════════════ // // 同构性:cache 是原文空间到译文空间的部分函数 // 性质:|cache| ≤ MAX_CACHE (bounded) // const cache = new Map(); function cacheGet(k) { return cache.get(k) || null; } function cacheSet(k, v) { if (!k || !v || k === v) return; if (cache.size >= MAX_CACHE) { const first = cache.keys().next().value; cache.delete(first); } cache.set(k, v); } function cacheClear() { cache.clear(); } // ════════════════════════════ // 语言检测 // ════════════════════════════ function detectScript(text) { if (!text) return 'empty'; const t = text.trim().replace(/[\s\d\p{P}\p{S}]/gu, ''); if (!t) return 'empty'; const len = t.length; const cjk = (t.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length; const jp = (t.match(/[\u3040-\u309f\u30a0-\u30ff]/g) || []).length; const kr = (t.match(/[\uac00-\ud7af]/g) || []).length; const ar = (t.match(/[\u0600-\u06ff]/g) || []).length; const thai = (t.match(/[\u0e00-\u0e7f]/g) || []).length; const cyrl = (t.match(/[\u0400-\u04ff]/g) || []).length; const latin= (t.match(/[a-zA-ZÀ-ÿ]/g) || []).length; const ratio = n => n / len; if (ratio(cjk) > 0.4) return 'zh'; if (ratio(jp) > 0.2) return 'ja'; if (ratio(kr) > 0.3) return 'ko'; if (ratio(ar) > 0.3) return 'ar'; if (ratio(thai) > 0.3) return 'th'; if (ratio(cyrl) > 0.3) return 'ru'; if (ratio(latin) > 0.4) return 'latin'; return 'other'; } function isAlreadyTarget(text) { const script = detectScript(text); if (script === 'empty') return true; // 目标语言和检测脚本匹配时视为已翻译 if (targetLang === 'zh' && script === 'zh') return true; if (targetLang === 'ja' && (script === 'ja' || script === 'zh')) return false; // 日语含汉字,不跳过 if (targetLang === 'ko' && script === 'ko') return true; if (targetLang === 'ar' && script === 'ar') return true; if (targetLang === 'th' && script === 'th') return true; if (targetLang === 'ru' && script === 'ru') return true; // 拉丁系语言之间无法用字符区分 // 只在目标语言是非拉丁系时,跳过拉丁文本 // 这个策略是保守的:可能误判,但不会漏翻 return 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','template','object','embed', ]); const SKIP_CLASS_RE = /translate-ui|notranslate|katex|mathjax|highlight|monaco|ace_editor|cm-editor|hljs|prism|code-block|syntaxhighlighter/i; function shouldSkipEl(el) { if (!el || el.nodeType !== Node.ELEMENT_NODE) 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; if (el.closest && el.closest('.translate-ui')) return true; // 跳过已翻译 if (el.dataset && el.dataset.translated) return true; return false; } // 检查祖先链是否有需要跳过的元素 (缓存友好的向上遍历) function hasSkippedAncestor(el, root) { let node = el; while (node && node !== root && node !== document.body) { if (shouldSkipEl(node)) return true; node = node.parentElement; } return false; } 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 (shouldSkipEl(parent)) return NodeFilter.FILTER_REJECT; // 已翻译检查 if (parent.dataset.translated) return NodeFilter.FILTER_REJECT; if (parent.closest('[data-translated]')) return NodeFilter.FILTER_REJECT; const text = node.textContent; if (!text) return NodeFilter.FILTER_REJECT; const trimmed = text.trim(); if (trimmed.length <= MIN_TEXT_LEN) return NodeFilter.FILTER_REJECT; // 纯数字、纯符号 if (/^[\d\s.,;:!?@#$%^&*()_+\-=\[\]{}'"|\\/<>~`]+$/.test(trimmed)) return NodeFilter.FILTER_REJECT; // 单个字母/字符 if (/^[a-zA-Z]$/.test(trimmed)) return NodeFilter.FILTER_REJECT; // 已经是目标语言 if (isAlreadyTarget(trimmed)) return NodeFilter.FILTER_REJECT; // 祖先链检查 (更完整但略慢,放在最后) if (hasSkippedAncestor(parent, root)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); while (walker.nextNode()) nodes.push(walker.currentNode); return nodes; } function collectPlaceholders(root) { const result = []; try { const els = root.querySelectorAll('input[placeholder], textarea[placeholder]'); for (const el of els) { if (el.dataset.translated) continue; const ph = (el.placeholder || '').trim(); if (ph.length > MIN_TEXT_LEN && !isAlreadyTarget(ph)) { result.push(el); } } } catch (_) {} return result; } // title 属性收集 function collectTitles(root) { const result = []; try { const els = root.querySelectorAll('[title]'); for (const el of els) { if (el.dataset.translatedTitle) continue; const t = (el.title || '').trim(); if (t.length > MIN_TEXT_LEN && !isAlreadyTarget(t)) { result.push(el); } } } catch (_) {} return result; } // alt 属性收集 function collectAlts(root) { const result = []; try { const els = root.querySelectorAll('img[alt]'); for (const el of els) { if (el.dataset.translatedAlt) continue; const a = (el.alt || '').trim(); if (a.length > MIN_TEXT_LEN && !isAlreadyTarget(a)) { result.push(el); } } } catch (_) {} return result; } // ════════════════════════════ // 批量翻译调度器 // ════════════════════════════ // // 定理 (输出长度守恒): // |batchTranslate(texts)| = |texts| // ∀ i, results[i] ∈ string ∪ {null} // async function batchTranslate(texts) { const n = texts.length; if (n === 0) return []; 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 || t.length > MAX_TEXT_LEN) continue; if (/^[\d\s.,]+$/.test(t)) continue; const c = cacheGet(t); if (c !== null) { results[i] = c; continue; } uncached.push(t); uncachedIdx.push(i); } if (uncached.length === 0) return results; const engine = Engine[currentEngine]; if (!engine) return results; // 鉴权 if (engine.needsAuth) { try { await engine.auth(); } catch (e) { console.warn('auth failed:', e); return results; } } // 阶段2: 引擎原生批量 if (engine.supportsBatch && engine.translateBatch) { try { const batchRes = await engine.translateBatch(uncached, targetLang); for (let i = 0; i < batchRes.length; i++) { if (batchRes[i] && batchRes[i] !== uncached[i]) { cacheSet(uncached[i], batchRes[i]); results[uncachedIdx[i]] = batchRes[i]; } } return results; } catch (e) { console.warn('batch failed, fallback:', 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 res = await engine.translate(text, targetLang); if (res && res !== text) { cacheSet(text, res); results[chunkIdx[j]] = res; } } catch (e) { // 静默 } })); // 每批次间隔,避免速率限制 if (i + CONCURRENCY < uncached.length) await sleep(50); } return results; } // ════════════════════════════ // DOM 替换策略 // ════════════════════════════ // ── 纯替换 ── // 前置: node 是文本节点,translated 非空 // 后置: node.textContent = translated, parent.dataset.origText = original function applyReplace(textNode, translated) { const parent = textNode.parentElement; if (!parent || parent.dataset.translated) return; const original = textNode.textContent; if (!parent.dataset.origText) { parent.dataset.origText = original; } parent.dataset.translated = '1'; textNode.textContent = translated; } // ── 双语结构 ── // 前置: node 是文本节点,translated 非空 // 后置: node 被替换为 // original // translated // // // 不变量 I₅: // wrapper.querySelector('.bt-s').textContent = original // wrapper.querySelector('.bt-d').textContent = translated // wrapper.dataset.origText = original (用于还原) function applyBilingual(textNode, translated) { const parent = textNode.parentElement; if (!parent) return; // 检查是否已在双语wrapper内 if (parent.dataset.translated || parent.closest('[data-translated]')) return; const original = textNode.textContent; const wrapper = document.createElement('span'); wrapper.dataset.translated = '1'; wrapper.dataset.origText = original; wrapper.dataset.biMode = '1'; wrapper.className = 'bt-wrap'; const srcSpan = document.createElement('span'); srcSpan.className = 'bt-s'; srcSpan.textContent = original; const dstSpan = document.createElement('span'); dstSpan.className = 'bt-d'; dstSpan.textContent = translated; wrapper.appendChild(srcSpan); wrapper.appendChild(dstSpan); try { textNode.parentNode.replaceChild(wrapper, textNode); } catch (_) {} } // 统一替换入口 function applyTranslation(textNode, translated) { if (bilingualMode) { applyBilingual(textNode, translated); } else { applyReplace(textNode, translated); } } // ════════════════════════════ // 页面翻译 // ════════════════════════════ let translating = false; let pendingRoots = new Set(); async function translatePage(root) { root = root || document.body; if (translating) { // 排队 pendingRoots.add(root); return; } translating = true; try { await _doTranslate(root); // 处理排队的 while (pendingRoots.size > 0) { const roots = [...pendingRoots]; pendingRoots.clear(); for (const r of roots) { await _doTranslate(r); } } } finally { translating = false; } } async function _doTranslate(root) { // ── 文本节点 ── const textNodes = collectTextNodes(root); 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]; if (!node.parentElement) continue; if (node.parentElement.dataset.translated) continue; applyTranslation(node, results[i]); } } // ── placeholder ── const phs = collectPlaceholders(root); if (phs.length > 0) { const phTexts = phs.map(el => el.placeholder.trim()); const phResults = await batchTranslate(phTexts); for (let i = 0; i < phs.length; i++) { if (!phResults[i]) continue; phs[i].dataset.origPlaceholder = phs[i].placeholder; phs[i].placeholder = phResults[i]; phs[i].dataset.translated = '1'; } } // ── title 属性 ── const titles = collectTitles(root); if (titles.length > 0) { const titleTexts = titles.map(el => el.title.trim()); const titleResults = await batchTranslate(titleTexts); for (let i = 0; i < titles.length; i++) { if (!titleResults[i]) continue; titles[i].dataset.origTitle = titles[i].title; titles[i].title = titleResults[i]; titles[i].dataset.translatedTitle = '1'; } } // ── alt 属性 ── const alts = collectAlts(root); if (alts.length > 0) { const altTexts = alts.map(el => el.alt.trim()); const altResults = await batchTranslate(altTexts); for (let i = 0; i < alts.length; i++) { if (!altResults[i]) continue; alts[i].dataset.origAlt = alts[i].alt; alts[i].alt = altResults[i]; alts[i].dataset.translatedAlt = '1'; } } // ── document.title ── if (root === document.body && document.title && !document._titleTranslated) { const titleText = document.title.trim(); if (titleText.length > MIN_TEXT_LEN && !isAlreadyTarget(titleText)) { const [result] = await batchTranslate([titleText]); if (result) { document._origTitle = document.title; document.title = result; document._titleTranslated = true; } } } } // ════════════════════════════ // 还原 // ════════════════════════════ // // 后置条件: DOM回到翻译前状态 // 证明: 每种翻译操作都保存了原始值,还原时精确恢复 // function restorePage() { // 双语结构还原: wrapper → 文本节点 document.querySelectorAll('[data-bi-mode]').forEach(el => { const orig = el.dataset.origText; if (orig !== undefined) { const textNode = document.createTextNode(orig); try { el.parentNode.replaceChild(textNode, el); } catch (_) {} } }); // 纯替换还原 document.querySelectorAll('[data-translated]:not([data-bi-mode])').forEach(el => { // 文本内容还原 if (el.dataset.origText !== undefined) { // 找第一个文本节点替换 let restored = false; for (const child of el.childNodes) { if (child.nodeType === Node.TEXT_NODE) { child.textContent = el.dataset.origText; restored = true; break; } } if (!restored) { // 没有文本节点就设 textContent (保守策略) // 但要注意不破坏子元素 } delete el.dataset.origText; delete el.dataset.translated; } // placeholder 还原 if (el.dataset.origPlaceholder !== undefined) { el.placeholder = el.dataset.origPlaceholder; delete el.dataset.origPlaceholder; delete el.dataset.translated; } }); // title 还原 document.querySelectorAll('[data-translated-title]').forEach(el => { if (el.dataset.origTitle !== undefined) { el.title = el.dataset.origTitle; delete el.dataset.origTitle; delete el.dataset.translatedTitle; } }); // alt 还原 document.querySelectorAll('[data-translated-alt]').forEach(el => { if (el.dataset.origAlt !== undefined) { el.alt = el.dataset.origAlt; delete el.dataset.origAlt; delete el.dataset.translatedAlt; } }); // document.title 还原 if (document._titleTranslated && document._origTitle) { document.title = document._origTitle; delete document._origTitle; delete document._titleTranslated; } } // ════════════════════════════ // 双语切换 // ════════════════════════════ async function toggleBilingual() { bilingualMode = !bilingualMode; GM_setValue('bilingualMode', bilingualMode); // 还原后用新模式重翻 restorePage(); cacheClear(); // 清缓存保证重新走翻译流程 updateUI(); if (autoMode) { await sleep(100); await translatePage(); } } // ════════════════════════════ // 滚动 & Mutation 监听 // ════════════════════════════ let scrollTimer = null; let lastDocH = document.documentElement.scrollHeight; function onScroll() { if (!autoMode) return; if (scrollTimer) clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { const h = document.documentElement.scrollHeight; if (h > lastDocH + 80) { lastDocH = h; translatePage(); } }, SCROLL_DEBOUNCE); } 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 && !shouldSkipEl(node)) { targets.add(node); } } } if (targets.size === 0) return; if (targets.size > 30) { translatePage(); } else { targets.forEach(t => translatePage(t)); } }, MUTATION_DEBOUNCE); }); // ════════════════════════════ // 样式 // ════════════════════════════ 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;line-height:1.4} .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:230px;border-radius:14px;padding:14px 16px; 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} .tu-panel.show{display:block;animation:tuIn .15s ease} @keyframes tuIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}} .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,.15)} .tu-acts{display:flex;gap:6px;margin-top:12px;flex-wrap:wrap} .tu-acts 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-acts button:active{transform:scale(.95)} .tu-go{background:#4a9eff;color:#fff} .tu-go:hover{background:#3b8de6} .tu-rest{background:#f0f0f0;color:#555} .tu-rest:hover{background:#e4e4e4} .tu-bi{background:#f5e6ff;color:#7c3aed} .tu-bi:hover{background:#ebe0f5} .tu-bi.on{background:#7c3aed;color:#fff} /* ── 双语结构 ── */ .bt-wrap{display:inline} .bt-s{display:block;opacity:.5;font-size:.88em;line-height:1.35;color:inherit} .bt-d{display:block;line-height:1.4;color:inherit} /* 行内元素的双语:不换行 */ a .bt-wrap, span .bt-wrap, em .bt-wrap, strong .bt-wrap, b .bt-wrap, i .bt-wrap, label .bt-wrap, td .bt-wrap, th .bt-wrap, li .bt-wrap, button .bt-wrap { display:inline } a .bt-s, span .bt-s, em .bt-s, strong .bt-s, b .bt-s, i .bt-s, label .bt-s, td .bt-s, th .bt-s, li .bt-s, button .bt-s { display:inline;margin-right:4px } a .bt-d, span .bt-d, em .bt-d, strong .bt-d, b .bt-d, i .bt-d, label .bt-d, td .bt-d, th .bt-d, li .bt-d, button .bt-d { display:inline } /* 段落的双语 */ p .bt-s, h1 .bt-s, h2 .bt-s, h3 .bt-s, h4 .bt-s, h5 .bt-s, h6 .bt-s, div .bt-s, blockquote .bt-s, figcaption .bt-s, dd .bt-s, dt .bt-s { display:block;margin-bottom:2px } /* ── 暗色 ── */ @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-rest{background:#333;color:#ccc} .tu-bi{background:#3b2063;color:#c4a1ff} .tu-bi.on{background:#7c3aed;color:#fff} .bt-s{opacity:.4} } /* ── 手机 ── */ @media(max-width:600px){ .translate-ui{bottom:12px;right:12px} .tu-btn{width:40px;height:40px} .tu-panel{width:210px;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 engineOptionsHTML = ENGINE_LIST.map(([v,t]) => ``).join(''); const langOptionsHTML = LANG_OPTIONS.map(([v,t]) => ``).join(''); const ui = document.createElement('div'); ui.className = 'translate-ui'; ui.innerHTML = `
`; document.body.appendChild(ui); const elBtn = document.getElementById('tuBtn'); const elPanel = document.getElementById('tuPanel'); const elEngine = document.getElementById('tuEngine'); const elLang = document.getElementById('tuLang'); const elBi = document.getElementById('tuBi'); elEngine.value = currentEngine; elLang.value = targetLang; function updateUI() { elBtn.classList.toggle('active', autoMode); elBi.classList.toggle('on', bilingualMode); elBi.textContent = bilingualMode ? '双语 ✓' : '双语'; elEngine.value = currentEngine; elLang.value = targetLang; } updateUI(); // ── 事件 ── elBtn.addEventListener('click', e => { e.stopPropagation(); elPanel.classList.toggle('show'); }); const closePanel = e => { if (!ui.contains(e.target)) elPanel.classList.remove('show'); }; document.addEventListener('click', closePanel); document.addEventListener('touchstart', closePanel, { passive: true }); elEngine.addEventListener('change', async () => { currentEngine = elEngine.value; GM_setValue('engine', currentEngine); cacheClear(); // 如果当前有翻译,还原后重翻 if (autoMode) { restorePage(); await sleep(100); await translatePage(); } }); elLang.addEventListener('change', async () => { targetLang = elLang.value; GM_setValue('targetLang', targetLang); cacheClear(); if (autoMode) { restorePage(); await sleep(100); await translatePage(); } }); document.getElementById('tuGo').addEventListener('click', async () => { elPanel.classList.remove('show'); autoMode = true; GM_setValue('autoMode', true); restorePage(); cacheClear(); lastDocH = document.documentElement.scrollHeight; updateUI(); await translatePage(); }); document.getElementById('tuRest').addEventListener('click', () => { elPanel.classList.remove('show'); autoMode = false; GM_setValue('autoMode', false); restorePage(); updateUI(); }); elBi.addEventListener('click', async () => { elPanel.classList.remove('show'); await toggleBilingual(); }); // ── 油猴菜单 ── GM_registerMenuCommand('翻译页面', () => { autoMode = true; GM_setValue('autoMode', true); updateUI(); translatePage(); }); GM_registerMenuCommand('还原页面', () => { autoMode = false; GM_setValue('autoMode', false); updateUI(); restorePage(); }); GM_registerMenuCommand('切换双语', () => toggleBilingual()); GM_registerMenuCommand('切换引擎', () => { const keys = ENGINE_LIST.map(e => e[0]); const idx = keys.indexOf(currentEngine); currentEngine = keys[(idx + 1) % keys.length]; GM_setValue('engine', currentEngine); cacheClear(); updateUI(); if (autoMode) { restorePage(); translatePage(); } }); // ════════════════════════════ // 页面语言检测 & 启动 // ════════════════════════════ function isPageTargetLang() { const htmlLang = (document.documentElement.lang || '').toLowerCase().split('-')[0]; if (htmlLang && htmlLang === targetLang) return true; // 无lang属性时抽样 if (!htmlLang) { const body = document.body; if (!body) return false; const sample = (body.innerText || '').substring(0, 600); if (sample.length < 20) return false; return isAlreadyTarget(sample); } return false; } // 启动观察者 window.addEventListener('scroll', onScroll, { passive: true }); observer.observe(document.body, { childList: true, subtree: true }); // 自动翻译 if (autoMode && !isPageTargetLang()) { setTimeout(() => translatePage(), STARTUP_DELAY); } })();