// ==UserScript== // @name Auto Translate — Built for Via · iPhone & Android · 249 Languages // @name:zh-CN 全球最强翻译器🌍 双语+秒翻 · Via完美适配 · 苹果安卓通吃 · 249种语言 · 致敬TWP // @name:zh-TW 全球最強翻譯器🌍 雙語+秒翻 · Via完美適配 · 蘋果安卓通吃 · 249種語言 · 致敬TWP // @name:en World's Best Translator🌍 Bilingual + Instant · Via · iPhone & Android · 249 Langs // @name:ja 世界最強の翻訳🌍 バイリンガル表示+瞬間翻訳 · Via · iPhone & Android · 249言語 · TWP リスペクト // @name:ko 세계최강 번역기🌍 이중언어+즉시번역 · Via · 아이폰/안드로이드 · 249언어 · TWP헌정 // @name:fr Traducteur ultime🌍 Bilingue + Instantané · Via · iPhone & Android · 249 langues · TWP // @name:de Stärkster Übersetzer🌍 Zweisprachig + Sofort · Via · iPhone & Android · 249 Sprachen · TWP // @name:es Traductor más potente🌍 Bilingüe + Instantáneo · Via · iPhone y Android · 249 idiomas · TWP // @name:pt Tradutor mais poderoso🌍 Bilíngue + Instantâneo · Via · iPhone e Android · 249 idiomas · TWP // @name:pt-BR Tradutor mais poderoso🌍 Bilíngue + Instantâneo · Via · iPhone e Android · 249 idiomas · TWP // @name:ru Мощнейший переводчик🌍 Двуязычный + Мгновенный · Via · iPhone/Android · 249 языков · TWP // @name:it Traduttore più potente🌍 Bilingue + Istantaneo · Via · iPhone e Android · 249 lingue · TWP // @name:tr En güçlü çevirmen🌍 İki dilli + Anında · Via · iPhone ve Android · 249 dil · TWP // @name:ar أقوى مترجم🌍 ثنائي اللغة + فوري · Via · آيفون وأندرويد · 249 لغة · تحية TWP // @name:th แปลแรงสุดในโลก🌍 สองภาษา+ทันที · Via · iPhone/Android · 249 ภาษา · TWP // @name:vi Dịch mạnh nhất🌍 Song ngữ + Tức thì · Via · iPhone & Android · 249 ngôn ngữ · TWP // @name:id Penerjemah terkuat🌍 Dwibahasa + Instan · Via · iPhone & Android · 249 bahasa · TWP // @name:ms Penterjemah terkuat🌍 Dwibahasa + Serta-merta · Via · iPhone & Android · 249 bahasa · TWP // @name:hi सबसे शक्तिशाली अनुवादक🌍 द्विभाषी + तुरंत · Via · iPhone/Android · 249 भाषाएँ · TWP // @name:bn সবচেয়ে শক্তিশালী अनुवादक🌍 দ্বিভাষিক + তাৎক্ষণিক · Via · iPhone/Android · ২৪৯ ভাষা · TWP // @name:ta சக்திவாய்ந்த மொழிபெயர்ப்பாளர்🌍 இருமொழி + உடனடி · Via · iPhone/Android · 249 மொழி · TWP // @name:te శక్తివంతమైన అనువాదకుడు🌍 ద్విభాషా + తక్షణ · Via · iPhone/Android · 249 భాషలు · TWP // @name:ur سب سے طاقتور مترجم🌍 دو زبانی + فوری · Via · آئی فون/اینڈرائیڈ · 249 زبانیں · TWP // @name:fa قویترین مترجم🌍 دوزبانه + آنی · Via · آیفون/اندروید · 249 زبان · TWP // @name:pl Najpotężniejszy tłumacz🌍 Dwujęzyczny + Natychmiast · Via · iPhone/Android · 249 języków · TWP // @name:uk Найпотужніший перекладач🌍 Двомовний + Миттєвий · Via · iPhone/Android · 249 мов · TWP // @name:ro Cel mai puternic traducător🌍 Bilingv + Instant · Via · iPhone/Android · 249 limbi · TWP // @name:nl Krachtigste vertaler🌍 Tweetalig + Direct · Via · iPhone & Android · 249 talen · TWP // @name:el Ισχυρότερος μεταφραστής🌍 Δίγλωσσο + Άμεσο · Via · iPhone/Android · 249 γλώσσες · TWP // @name:cs Nejsilnější překladač🌍 Dvojjazyčný + Okamžitý · Via · iPhone/Android · 249 jazyků · TWP // @name:hu Legerősebb fordító🌍 Kétnyelvű + Azonnali · Via · iPhone/Android · 249 nyelv · TWP // @name:sv Kraftfullaste översättaren🌍 Tvåspråkig + Direkt · Via · iPhone/Android · 249 språk · TWP // @name:da Mest kraftfulde oversætter🌍 Tosproget + Direkte · Via · iPhone/Android · 249 sprog · TWP // @name:fi Tehokkain kääntäjä🌍 Kaksikielinen + Välitön · Via · iPhone/Android · 249 kieltä · TWP // @name:no Kraftigste oversetter🌍 Tospråklig + Direkte · Via · iPhone/Android · 249 språk · TWP // @name:bg Най-мощният преводач🌍 Двуезичен + Мигновен · Via · iPhone/Android · 249 езика · TWP // @name:hr Najmoćniji prevoditelj🌍 Dvojezičan + Trenutačan · Via · iPhone/Android · 249 jezika · TWP // @name:sr Најмоћнији преводилац🌍 Двојезичан + Тренутан · Via · iPhone/Android · 249 језика · TWP // @name:sk Najsilnejší prekladač🌍 Dvojjazyčný + Okamžitý · Via · iPhone/Android · 249 jazykov · TWP // @name:sl Najmočnejši prevajalnik🌍 Dvojezičen + Takojšen · Via · iPhone/Android · 249 jezikov · TWP // @name:lt Galingiausias vertėjas🌍 Dvikalbis + Momentinis · Via · iPhone/Android · 249 kalbos · TWP // @name:lv Jaudīgākais tulkotājs🌍 Divvalodu + Tūlītējs · Via · iPhone/Android · 249 valodas · TWP // @name:et Võimsaim tõlkija🌍 Kakskeelne + Kohene · Via · iPhone/Android · 249 keelt · TWP // @name:sw Mtafsiri hodari zaidi🌍 Lugha mbili + Papo hapo · Via · iPhone/Android · Lugha 249 · TWP // @name:fil Pinakamakapangyarihan🌍 Dalawahang wika + Agaran · Via · iPhone/Android · 249 wika · TWP // @name:my အစွမ်းကုန်ဘာသာပြန်🌍 နှစ်ဘာသာ+ချက်ချင်း · Via · iPhone/Android · ဘာသာ249 · TWP // @name:km អ្នកបកប្រែខ្លាំងបំផុត🌍 ពីរភាសា+បន្ទាន់ · Via · iPhone/Android · ២៤៩ភាសា · TWP // @name:lo ນັກແປແຮງສຸດ🌍 ສອງພາສາ+ທັນທີ · Via · iPhone/Android · 249 ພາສາ · TWP // @name:ka ძლიერი მთარგმნელი🌍 ორენოვანი + მყისიერი · Via · iPhone/Android · 249 ენა · TWP // @name:hy Ամենusage Թարգմանիչ🌍 Երկլեզու + Արագ · Via · iPhone/Android · 249 Լեzu · TWP // @name:am ኃይለኛ ተርጓሚ🌍 ሁለት ቋንቋ + ፈጣን · Via · iPhone/Android · 249 ቋንቋ · TWP // @name:ne शक्तिशाली अनुवादक🌍 द्विभाषी + तुरुन्त · Via · iPhone/Android · 249 भाषा · TWP // @name:si බලවත්ම පරිවර්තකය🌍 ද්විභාෂා + ක්ෂණික · Via · iPhone/Android · භාෂා 249 · TWP // @name:mn Хүчирхэг орчуулагч🌍 Хос хэл + Шуурхай · Via · iPhone/Android · 249 хэл · TWP // @name:uz Eng kuchli tarjimon🌍 Ikki tilli + Tez · Via · iPhone/Android · 249 til · TWP // @name:kk Ең қуатты аудармашы🌍 Екі тілді + Лезде · Via · iPhone/Android · 249 тіл · TWP // @name:az Ən güclü tərcüməçi🌍 İkidilli + Anlıq · Via · iPhone/Android · 249 dil · TWP // @name:sq Përkthyesi më i fortë🌍 Dygjuhësh + Menjëherë · Via · iPhone/Android · 249 gjuhë · TWP // @name:mk Најмоќен преведувач🌍 Двојазичен + Момент · Via · iPhone/Android · 249 јазици · TWP // @name:bs Najmoćniji prevodilac🌍 Dvojezičan + Trenutan · Via · iPhone/Android · 249 jezika · TWP // @name:is Öflugasti þýðandinn🌍 Tvímála + Samstundis · Via · iPhone/Android · 249 tungumál · TWP // @name:af Kragtigste vertaler🌍 Tweetalig + Dadelik · Via · iPhone/Android · 249 tale · TWP // @name:yo Atúmọ̀ tó lágbára jù🌍 Èdè Méjì + Lẹ́sẹ̀kẹsẹ̀ · Via · iPhone/Android · Èdè 249 · TWP // @name:ha Mafi ƙarfin mai fassara🌍 Harsuna 2 + Nan take · Via · iPhone/Android · Harsuna 249 · TWP // @name:ig Onye ntụgharị kachasị🌍 Asụsụ Abụọ + Ozugbo · Via · iPhone/Android · Asụsụ 249 · TWP // @name:zu Umhumushi onamandla🌍 Izilimi 2 + Ngokushesha · Via · iPhone/Android · Izilimi 249 · TWP // @name:mg Mpandika teny matanjaka🌍 Roa teny + Vetivety · Via · iPhone/Android · 249 fiteny · TWP // @name:eo Plej potenca tradukilo🌍 Dulingva + Tuja · Via · iPhone/Android · 249 lingvoj · TWP // @name:la Potentissimus interpres🌍 Bilinguis + Statim · Via · iPhone/Android · 249 linguae · TWP // @name:he המתרגם החזק🌍 דו-לשוני + מיידי · Via · אייפון/אנדרואיד · 249 שפות · TWP // @name:gu શક્તિશાળી અનુવાદક🌍 દ્વિભાષી + તાત્કાલિક · Via · iPhone/Android · 249 ભાષા · TWP // @name:mr शक्तिशाली अनुवादक🌍 द्विभाषी + तात्काळ · Via · iPhone/Android · 249 भाषा · TWP // @name:pa ਸ਼ਕਤੀਸ਼ਾਲੀ ਅਨੁਵਾਦਕ🌍 ਦੋਭਾਸ਼ੀ + ਤੁਰੰਤ · Via · iPhone/Android · 249 ਭਾਸ਼ਾ · TWP // @name:kn ಶಕ್ತಿಶಾಲಿ ಅನುವಾದಕ🌍 ದ್ವಿಭಾಷಾ + ತಕ್ಷಣ · Via · iPhone/Android · 249 ಭಾಷೆ · TWP // @name:ml ശക്തമായ പരിഭാഷകൻ🌍 ദ്വിഭാഷ + തൽക്ഷണം · Via · iPhone/Android · 249 ഭാഷ · TWP // @namespace https://github.com/user/translate // @version 7.9.2 // @description 全球最强网页翻译器!双语对照+仅译文+原文三模式秒切 · Google/微软/腾讯三大引擎随心换 · Via/Safari/Chrome/Firefox/Edge/Kiwi/Lemur等所有Chromium内核浏览器通用 · 苹果iPhone安卓手机平板电脑全平台通吃 · 249种语言覆盖全球99%人口 · 基于TWP引擎深度重构致敬原作 · 零配置装完即用 // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @connect translate.googleapis.com // @connect translate-pa.googleapis.com // @connect edge.microsoft.com // @connect api-edge.cognitive.microsofttranslator.com // @connect transmart.qq.com // @run-at document-start // ==/UserScript== (async () => { 'use strict'; try { if (document.contentType === 'application/xml') return } catch (_) {} // ══════════════════════════════════════════════════════════ // 配置并行读取 // ══════════════════════════════════════════════════════════ const deviceLang = (navigator.language || navigator.userLanguage || 'zh-CN').split('-')[0]; const [ _engine, _targetLang, _autoMode, _excludedHosts, _displayMode, _savedX, _savedY ] = await Promise.all([ GM_getValue('engine', 'google'), GM_getValue('targetLang', deviceLang === 'zh' ? 'zh-CN' : deviceLang), GM_getValue('autoMode', true), GM_getValue('excludedHosts', '[]'), GM_getValue('displayMode', 'translated'), GM_getValue('tu_pos_x', -1), GM_getValue('tu_pos_y', -1) ]); let currentEngine = _engine; let targetLang = _targetLang; let autoMode = _autoMode; let excludedHosts = JSON.parse(_excludedHosts); let displayMode = _displayMode; // 'translated' | 'bilingual' | 'original' if (excludedHosts.includes(location.host)) return; // ══════════════════════════════════════════════════════════ // 语言列表 // ══════════════════════════════════════════════════════════ const ALL_LANGUAGES = { "zh-CN": "中文(简体)", "zh-TW": "中文(繁體)", "en": "English", "ja": "日本語", "ko": "한국어", "fr": "Français", "de": "Deutsch", "es": "Español", "ru": "Русский", "pt": "Português", "pt-PT": "Português (Portugal)", "ar": "العربية", "th": "ไทย", "vi": "Tiếng Việt", "it": "Italiano", "tr": "Türkçe", "id": "Indonesia", "ms": "Bahasa Melayu", "nl": "Nederlands", "pl": "Polski", "uk": "Українська", "cs": "Čeština", "sk": "Slovenčina", "hu": "Magyar", "ro": "Română", "bg": "Български", "hr": "Hrvatski", "sr": "Српски", "sl": "Slovenščina", "lt": "Lietuvių", "lv": "Latviešu", "et": "Eesti", "fi": "Suomi", "sv": "Svenska", "da": "Dansk", "no": "Norsk", "is": "Íslenska", "el": "Ελληνικά", "he": "עברית", "hi": "हिन्दी", "bn": "বাংলা", "ta": "தமிழ்", "te": "తెలుగు", "kn": "ಕನ್ನಡ", "ml": "മലയാളം", "pa": "ਪੰਜਾਬੀ", "gu": "ગુજરાતી", "mr": "मराठी", "ne": "नेपाली", "si": "සිංහල", "ur": "اردو", "fa": "فارسی", "ps": "پښتو", "my": "မြန်မာ", "km": "ខ្មែរ", "lo": "ລາວ", "ka": "ქართული", "hy": "Հայերեն", "az": "Azərbaycan", "kk": "Қазақ", "uz": "Oʻzbek", "mn": "Монгол", "sq": "Shqip", "mk": "Македонски", "be": "Беларуская", "bs": "Bosanski", "ca": "Català", "gl": "Galego", "eu": "Euskara", "mt": "Malti", "cy": "Cymraeg", "ga": "Gaeilge", "gd": "Gàidhlig", "lb": "Lëtzebuergesch", "af": "Afrikaans", "sw": "Kiswahili", "ha": "Hausa", "ig": "Igbo", "yo": "Yorùbá", "zu": "isiZulu", "xh": "isiXhosa", "sn": "chiShona", "st": "Sesotho", "so": "Soomaali", "am": "አማርኛ", "ti": "ትግርኛ", "om": "Oromoo", "mg": "Malagasy", "ny": "Chichewa", "lg": "Luganda", "rw": "Kinyarwanda", "tg": "Тоҷикӣ", "tk": "Türkmen", "ky": "Кыргызча", "tt": "Татар", "eo": "Esperanto", "la": "Latina", "co": "Corsu", "fy": "Frysk", "haw": "ʻŌlelo Hawaiʻi", "sm": "Gagana Samoa", "mi": "Te Reo Māori", "ceb": "Cebuano", "fil": "Filipino", "jv": "Basa Jawa", "su": "Basa Sunda", "hmn": "Hmong", "ht": "Kreyòl Ayisyen", "ku": "Kurdî", "ckb": "کوردی", "sd": "سنڌي", "or": "ଓଡ଼ିଆ", "as": "অসমীয়া", "sa": "संस्कृतम्", "mai": "मैथिली", "bho": "भोजपुरी", "doi": "डोगरी", "ug": "ئۇيغۇرچە", "dv": "ދިވެހި" }; const LANG_GROUPS = { "常用": ["zh-CN","zh-TW","en","ja","ko","fr","de","es","ru","pt","ar","th","vi","it","tr","id"], "欧洲": ["nl","pl","uk","cs","sk","hu","ro","bg","hr","sr","sl","lt","lv","et","fi","sv","da","no","is","el","be","bs","ca","gl","eu","mt","cy","ga","gd","lb","af","eo","la","co","fy"], "亚洲": ["hi","bn","ta","te","kn","ml","pa","gu","mr","ne","si","ur","fa","ps","my","km","lo","ka","hy","az","kk","uz","mn","tg","tk","ky","tt","ug","dv","or","as","sa","mai","bho","doi","ms","fil","ceb","jv","su","hmn"], "非洲/其他": ["sw","ha","ig","yo","zu","xh","sn","st","so","am","ti","om","mg","ny","lg","rw","ht","ku","ckb","sd","he","pt-PT","haw","sm","mi"] }; // ══════════════════════════════════════════════════════════ // TWP核心引擎代码 // ══════════════════════════════════════════════════════════ const GoogleHelper_v2 = { _lastRequestAuthTime: null, _translateAuth: null, _authNotFound: false, _authPromise: null, get translateAuth() { return this._translateAuth; }, _getAlternativeKey() { return new TextDecoder().decode(new Uint8Array([ 65,73,122,97,83,121,65,84,66,88,97,106,118,122,81, 76,84,68,72,69,81,98,99,112,113,48,73,104,101,48, 118,87,68,72,109,79,53,50,48 ])); }, async findAuth() { if (this._authPromise) return await this._authPromise; this._authPromise = new Promise((resolve) => { let needUpdate = false; if (this._lastRequestAuthTime) { const d = new Date(); if (this._translateAuth) d.setMinutes(d.getMinutes() - 20); else if (this._authNotFound) d.setMinutes(d.getMinutes() - 5); else d.setMinutes(d.getMinutes() - 1); if (d.getTime() > this._lastRequestAuthTime) needUpdate = true; } else { needUpdate = true; } if (needUpdate) { this._lastRequestAuthTime = Date.now(); const altKey = this._getAlternativeKey(); GM_xmlhttpRequest({ method: 'GET', url: 'https://translate.googleapis.com/_/translate_http/_/js/k=translate_http.tr.en_US.YusFYy3P_ro.O/am=AAg/d=1/exm=el_conf/ed=1/rs=AN8SPfq1Hb8iJRleQqQc8zhdzXmF9E56eQ/m=el_main', timeout: 8000, onload: (r) => { if (r.responseText && r.responseText.length > 1) { const m = r.responseText.match(/['"]x-goog-api-key['"]\s*:\s*['"](\w{39})['"]/i); if (m && m.length === 2) { this._translateAuth = m[1]; this._authNotFound = false; } else { this._authNotFound = true; this._translateAuth = altKey; } } else { this._authNotFound = true; this._translateAuth = altKey; } resolve(); }, onerror: () => { this._translateAuth = altKey; resolve(); }, ontimeout: () => { this._translateAuth = altKey; resolve(); } }); } else { resolve(); } }); const p = this._authPromise; p.finally(() => { this._authPromise = null; }); return await p; } }; const authReady = GoogleHelper_v2.findAuth().catch(() => {}); const GoogleHelper = { googleTranslateTKK: "448487.932609646", shiftLeftOrRightThenSumOrXor(num, optString) { for (let i = 0; i < optString.length - 2; i += 3) { let acc = optString.charAt(i + 2); acc = ("a" <= acc) ? acc.charCodeAt(0) - 87 : Number(acc); acc = (optString.charAt(i + 1) === "+") ? num >>> acc : num << acc; num = (optString.charAt(i) === "+") ? (num + acc) & 4294967295 : num ^ acc; } return num; }, transformQuery(query) { const b = []; let idx = 0; for (let i = 0; i < query.length; i++) { let c = query.charCodeAt(i); if (128 > c) { b[idx++] = c; } else { if (2048 > c) { b[idx++] = (c >> 6) | 192; } else { if (55296 === (c & 64512) && i + 1 < query.length && 56320 === (query.charCodeAt(i + 1) & 64512)) { c = 65536 + ((c & 1023) << 10) + (query.charCodeAt(++i) & 1023); b[idx++] = (c >> 18) | 240; b[idx++] = ((c >> 12) & 63) | 128; } else { b[idx++] = (c >> 12) | 224; } b[idx++] = ((c >> 6) & 63) | 128; } b[idx++] = (c & 63) | 128; } } return b; }, calcHash(query) { const s = this.googleTranslateTKK.split("."); const tkkIdx = Number(s[0]) || 0; const tkkKey = Number(s[1]) || 0; const bytes = this.transformQuery(query); let enc = tkkIdx; for (const item of bytes) { enc += item; enc = this.shiftLeftOrRightThenSumOrXor(enc, "+-a^+6"); } enc = this.shiftLeftOrRightThenSumOrXor(enc, "+-3^+b+-f"); enc ^= tkkKey; if (enc <= 0) enc = (enc & 2147483647) + 2147483648; const n = enc % 1000000; return n.toString() + "." + (n ^ tkkIdx); } }; const Utils = { escapeHTML(t) { const d = document.createElement('div'); d.appendChild(document.createTextNode(t)); return d.innerHTML; }, unescapeHTML(t) { const d = new DOMParser().parseFromString(t, 'text/html'); return d.documentElement.textContent; } }; function gmFetch(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ timeout: 20000, ...opts, onload: resolve, onerror: reject, ontimeout: reject }); }); } // ══════════════════════════════════════════════════════════ // 引擎定义 // ══════════════════════════════════════════════════════════ const Engine = { google_v2: { name: 'Google (TWP v2)', _fixLang(lang) { return lang === "prs" ? "fa-AF" : lang; }, _transformResponse(result, dontSort) { if (result.indexOf("
") !== -1) {
result = result.replace("", "");
const i = result.indexOf(">");
result = result.slice(i + 1);
}
const sentences = [];
let idx = 0;
while (true) {
const s = result.indexOf("", idx);
if (s === -1) break;
const e = result.indexOf("", s);
if (e === -1) { sentences.push(result.slice(s + 3)); break; }
else { sentences.push(result.slice(s + 3, e)); }
idx = e;
}
result = sentences.length > 0 ? sentences.join(" ") : result;
result = result.replace(/<\/b>/g, "");
let resultArray = [];
let lastEnd = 0;
for (const r of result.matchAll(/()([^<>]*(?=<\/a>))*/g)) {
const fl = r[0].length, pos = r.index;
if (pos > lastEnd) {
resultArray.push(r[1] + result.slice(lastEnd, pos).replace(/<\/a>/g, "") + (r[2] || ""));
} else { resultArray.push(r[0]); }
lastEnd = pos + fl;
}
let indexes;
if (resultArray.length > 0) {
indexes = resultArray.map(v => parseInt(v.match(/[0-9]+(?=>)/g)?.[0])).filter(v => !isNaN(v));
resultArray = resultArray.map(v => v.slice(v.indexOf(">") + 1));
} else { resultArray = [result]; indexes = [0]; }
resultArray = resultArray.map(v => Utils.unescapeHTML(v));
if (dontSort) return resultArray;
const final = [];
for (const j in indexes) {
if (final[indexes[j]]) final[indexes[j]] += " " + resultArray[j];
else final[indexes[j]] = resultArray[j];
}
return final;
},
async translate(text, toLang) {
const to = this._fixLang(toLang);
await GoogleHelper_v2.findAuth();
if (!GoogleHelper_v2.translateAuth) throw new Error('No auth');
const r = await gmFetch({
method: 'POST',
url: 'https://translate-pa.googleapis.com/v1/translateHtml',
headers: { 'Content-Type': 'application/json+protobuf', 'X-Goog-Api-Key': GoogleHelper_v2.translateAuth },
data: JSON.stringify([[[text], "auto", to], "te"]),
});
if (r.status !== 200) throw new Error('v2 error: ' + r.status);
const data = JSON.parse(r.responseText);
if (data && data[0]) {
const raw = Array.isArray(data[0]) ? data[0][0] : data[0];
const parsed = this._transformResponse(raw, false);
return parsed[0] || raw;
}
throw new Error('v2 empty');
},
async translateBatch(texts, toLang) {
const to = this._fixLang(toLang);
await GoogleHelper_v2.findAuth();
if (!GoogleHelper_v2.translateAuth) throw new Error('No auth');
const r = await gmFetch({
method: 'POST',
url: 'https://translate-pa.googleapis.com/v1/translateHtml',
headers: { 'Content-Type': 'application/json+protobuf', 'X-Goog-Api-Key': GoogleHelper_v2.translateAuth },
data: JSON.stringify([[texts, "auto", to], "te"]),
});
if (r.status !== 200) throw new Error('v2 batch error: ' + r.status);
const data = JSON.parse(r.responseText);
if (data && data[0] && Array.isArray(data[0])) {
return data[0].map(item => {
const p = this._transformResponse(item, false);
return p[0] || item;
});
}
if (data && data[0]) {
const p = this._transformResponse(Array.isArray(data[0]) ? data[0][0] : data[0], false);
return [p[0]];
}
throw new Error('v2 batch empty');
}
},
google_legacy: {
name: 'Google (Legacy)',
langCode(l) { return l; },
async translate(text, toLang) {
const to = toLang;
const tk = GoogleHelper.calcHash(text);
const r = await gmFetch({
method: 'GET',
url: 'https://translate.googleapis.com/translate_a/single?client=webapp&sl=auto&tl=' + to + '&hl=' + to + '&dt=t&dt=bd&dt=ex&dt=ld&dt=md&dt=qca&dt=rw&dt=rm&dt=ss&dt=at&ie=UTF-8&oe=UTF-8&otf=1&ssel=0&tsel=0&kc=7&tk=' + tk + '&q=' + encodeURIComponent(text),
});
if (r.status !== 200) return await this._gtx(text, to);
const data = JSON.parse(r.responseText);
return data[0].filter(s => s && s[0]).map(s => s[0]).join('');
},
async _gtx(text, to) {
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('gtx error');
const data = JSON.parse(r.responseText);
return data[0].filter(s => s && s[0]).map(s => s[0]).join('');
}
},
google: {
name: 'Google (Auto)',
async translate(text, toLang) {
try { return await Engine.google_v2.translate(text, toLang); }
catch (e) { return await Engine.google_legacy.translate(text, toLang); }
},
async translateBatch(texts, toLang) {
try { return await Engine.google_v2.translateBatch(texts, toLang); }
catch (e) {
const res = [];
for (const t of texts) {
try { res.push(await Engine.google_legacy.translate(t, toLang)); }
catch (_) { res.push(null); }
}
return res;
}
}
},
microsoft: {
name: 'Microsoft',
_token: null, _tokenTime: 0,
async getToken() {
if (this._token && Date.now() - this._tokenTime < 480000) return this._token;
const r = await gmFetch({ method: 'GET', url: 'https://edge.microsoft.com/translate/auth' });
if (r.status !== 200) throw new Error('MS auth');
this._token = r.responseText; this._tokenTime = Date.now();
return this._token;
},
langCode(l) {
const m = { 'zh': 'zh-Hans', 'zh-CN': 'zh-Hans', 'zh-TW': 'zh-Hant', 'no': 'nb', 'sr': 'sr-Cyrl', 'pt-PT': 'pt-pt', 'fr-CA': 'fr-ca' };
return m[l] || l;
},
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 error');
return JSON.parse(r.responseText)[0].translations[0].text;
},
async translateBatch(texts, toLang) {
const token = await this.getToken();
const to = this.langCode(toLang);
const results = [];
for (let b = 0; b < texts.length; b += 25) {
const chunk = texts.slice(b, b + 25);
const r = await gmFetch({
method: 'POST',
url: 'https://api-edge.cognitive.microsofttranslator.com/translate?from=&to=' + to + '&api-version=3.0',
headers: { 'authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
data: JSON.stringify(chunk.map(t => ({ Text: t }))),
});
if (r.status === 200) {
for (const item of JSON.parse(r.responseText)) results.push(item.translations[0].text);
} else { for (let i = 0; i < chunk.length; i++) results.push(null); }
}
return results;
}
},
tencent: {
name: 'Tencent',
_clientKey: null,
getClientKey() {
if (this._clientKey) return this._clientKey;
this._clientKey = 'browser-chrome-120.0-Windows_10-' + crypto.randomUUID() + '-' + Date.now();
return this._clientKey;
},
langCode(l) { const m = { 'zh': 'zh', 'zh-CN': 'zh', 'zh-TW': 'zh-TW' }; return m[l] || l; },
async translate(text, toLang) {
const to = this.langCode(toLang);
const r = await gmFetch({
method: 'POST', url: 'https://transmart.qq.com/api/imt',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ header: { fn: 'auto_translation', session: '', client_key: this.getClientKey(), user: '' }, type: 'plain', model_category: 'normal', text_domain: 'general', source: { lang: 'auto', text_list: [text] }, target: { lang: to } }),
});
if (r.status !== 200) throw new Error('Tencent error');
return JSON.parse(r.responseText).auto_translation[0];
},
async translateBatch(texts, toLang) {
const to = this.langCode(toLang);
const r = await gmFetch({
method: 'POST', url: 'https://transmart.qq.com/api/imt',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ header: { fn: 'auto_translation', session: '', client_key: this.getClientKey(), user: '' }, type: 'plain', model_category: 'normal', text_domain: 'general', source: { lang: 'auto', text_list: texts }, target: { lang: to } }),
});
if (r.status !== 200) throw new Error('Tencent batch error');
return JSON.parse(r.responseText).auto_translation;
}
}
};
// ══════════════════════════════════════════════════════════
// 缓存层
// ══════════════════════════════════════════════════════════
const cache = new Map();
const MAX_CACHE = 3000;
function cacheGet(t) { return cache.get(t); }
function cacheSet(t, v) { if (cache.size >= MAX_CACHE) cache.delete(cache.keys().next().value); cache.set(t, v); }
// ══════════════════════════════════════════════════════════
// 翻译核心
// ══════════════════════════════════════════════════════════
async function translate(text) {
if (!text || !text.trim()) return null;
const trimmed = text.trim();
if (/^\d+$/.test(trimmed)) return null;
const cached = cacheGet(trimmed);
if (cached) return cached;
try {
const result = await Engine[currentEngine].translate(trimmed, targetLang);
if (result && result !== trimmed) { cacheSet(trimmed, result); return result; }
} catch (e) {
const fallbackEngine = currentEngine === 'google' ? 'microsoft' : 'google_legacy';
try {
const result = await Engine[fallbackEngine].translate(trimmed, targetLang);
if (result && result !== trimmed) { cacheSet(trimmed, result); return result; }
} catch (_) {}
}
return null;
}
// ══════════════════════════════════════════════════════════
// 批量翻译
// ══════════════════════════════════════════════════════════
async function batchTranslate(texts) {
const results = new Array(texts.length).fill(null);
const uncached = [], uncachedIdx = [];
for (let i = 0; i < texts.length; i++) {
const t = texts[i].trim();
if (!t || /^\d+$/.test(t)) continue;
const c = cacheGet(t);
if (c) { results[i] = c; continue; }
uncached.push(t);
uncachedIdx.push(i);
}
if (uncached.length === 0) return results;
const engine = Engine[currentEngine];
if (engine.translateBatch && uncached.length > 1) {
try {
const BATCH_SIZE = currentEngine === 'microsoft' ? 25 : 50;
const chunks = [];
for (let b = 0; b < uncached.length; b += BATCH_SIZE) {
chunks.push({ texts: uncached.slice(b, b + BATCH_SIZE), idxs: uncachedIdx.slice(b, b + BATCH_SIZE) });
}
await Promise.all(chunks.map(async ({ texts: chunk, idxs }) => {
try {
const batchResults = await engine.translateBatch(chunk, targetLang);
if (batchResults) {
for (let j = 0; j < batchResults.length; j++) {
if (batchResults[j] && batchResults[j] !== chunk[j]) {
cacheSet(chunk[j], batchResults[j]);
results[idxs[j]] = batchResults[j];
}
}
}
} catch (_) {}
}));
return results;
} catch (e) { /* fallthrough */ }
}
const CONCURRENCY = 8;
for (let i = 0; i < uncached.length; i += CONCURRENCY) {
const batch = uncached.slice(i, i + CONCURRENCY);
const batchIdx = uncachedIdx.slice(i, i + CONCURRENCY);
await Promise.allSettled(batch.map(async (text, j) => {
const r = await translate(text);
if (r) results[batchIdx[j]] = r;
}));
}
return results;
}
// ══════════════════════════════════════════════════════════
// DOM遍历
// ══════════════════════════════════════════════════════════
const SKIP_TAGS = /^(script|style|code|pre|svg|math|noscript|iframe|canvas|video|audio|img|br|hr|input|select|option|textarea)$/i;
const SKIP_CLASS = /translate-ui|notranslate|katex|mathjax/i;
function shouldSkip(node) {
if (!node) return true;
if (node.nodeType === Node.ELEMENT_NODE) {
if (SKIP_TAGS.test(node.tagName)) return true;
if (SKIP_CLASS.test(node.className)) return true;
if (node.isContentEditable) return true;
if (node.dataset && node.dataset.translated) return true;
if (node.classList && node.classList.contains('tu-bi')) return true;
}
return false;
}
const _langRegex = {};
function getLangRegex(lang) {
if (_langRegex[lang]) return _langRegex[lang];
const patterns = {
'zh': /^[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\s\d\p{P}]+$/u,
'en': /^[a-zA-Z\s\d\p{P}]+$/u,
'ja': /^[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9fff\s\d\p{P}]+$/u,
'ko': /^[\uac00-\ud7af\u1100-\u11ff\s\d\p{P}]+$/u,
'ar': /^[\u0600-\u06ff\u0750-\u077f\s\d\p{P}]+$/u,
'th': /^[\u0e00-\u0e7f\s\d\p{P}]+$/u,
'ru': /^[\u0400-\u04ff\s\d\p{P}]+$/u,
};
_langRegex[lang] = patterns[lang] || null;
return _langRegex[lang];
}
function isTargetLang(text) {
if (!text || !text.trim()) return true;
const lang = targetLang.split('-')[0];
const re = getLangRegex(lang);
return re ? re.test(text.trim()) : false;
}
function collectTextNodes(root) {
const nodes = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
if (shouldSkip(node.parentElement)) return NodeFilter.FILTER_REJECT;
const text = node.textContent.trim();
if (!text || text.length < 2 || /^\d+$/.test(text)) return NodeFilter.FILTER_REJECT;
if (isTargetLang(text)) return NodeFilter.FILTER_REJECT;
if (node.parentElement?.dataset?.translated) return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}
});
while (walker.nextNode()) nodes.push(walker.currentNode);
return nodes;
}
function collectPlaceholders(root) {
return [...root.querySelectorAll('input[placeholder], textarea[placeholder]')]
.filter(el => !el.dataset.translated && el.placeholder.trim() && !isTargetLang(el.placeholder));
}
// ══════════════════════════════════════════════════════════
// 翻译执行
// ══════════════════════════════════════════════════════════
let isTranslating = false;
let pendingRoot = null;
async function translatePage(root) {
if (isTranslating) {
pendingRoot = root || document.body;
return;
}
isTranslating = true;
try {
root = root || document.body;
do {
pendingRoot = null;
const textNodes = collectTextNodes(root);
const placeholders = collectPlaceholders(root);
if (textNodes.length === 0 && placeholders.length === 0) break;
const allTexts = [];
const allMeta = [];
for (let i = 0; i < textNodes.length; i++) {
allTexts.push(textNodes[i].textContent.trim());
allMeta.push({ type: 'text', node: textNodes[i] });
}
for (let i = 0; i < placeholders.length; i++) {
allTexts.push(placeholders[i].placeholder.trim());
allMeta.push({ type: 'ph', el: placeholders[i] });
}
const results = await batchTranslate(allTexts);
for (let i = 0; i < allMeta.length; i++) {
if (!results[i]) continue;
const meta = allMeta[i];
if (meta.type === 'text') {
const parent = meta.node.parentElement;
if (!parent) continue;
if (!parent.dataset.originalText) parent.dataset.originalText = meta.node.textContent;
parent.dataset.translated = '1';
if (displayMode === 'bilingual') {
const s = document.createElement('span');
s.className = 'tu-bi';
s.textContent = results[i];
if (meta.node.nextSibling) {
parent.insertBefore(s, meta.node.nextSibling);
} else {
parent.appendChild(s);
}
} else {
meta.node.textContent = results[i];
}
} else {
meta.el.dataset.originalPlaceholder = meta.el.placeholder;
meta.el.placeholder = results[i];
meta.el.dataset.translated = '1';
}
}
if (pendingRoot) { root = pendingRoot; }
} while (pendingRoot);
} finally {
isTranslating = false;
}
}
function restorePage() {
document.querySelectorAll('.tu-bi').forEach(el => el.remove());
document.querySelectorAll('[data-translated]').forEach(el => {
if (el.dataset.originalText) {
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE) { child.textContent = el.dataset.originalText; break; }
}
delete el.dataset.originalText;
}
if (el.dataset.originalPlaceholder) {
el.placeholder = el.dataset.originalPlaceholder;
delete el.dataset.originalPlaceholder;
}
delete el.dataset.translated;
});
}
// ══════════════════════════════════════════════════════════
// 动态内容监听
// ══════════════════════════════════════════════════════════
let lastHeight = 0;
function onScroll() {
const h = document.documentElement.scrollHeight;
if (h > lastHeight) {
lastHeight = h;
if (autoMode) translatePage();
}
}
let mutationRafId = null;
const pendingMutationRoots = new Set();
const observer = new MutationObserver((mutations) => {
if (!autoMode) return;
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE && !shouldSkip(node)) {
pendingMutationRoots.add(node);
}
}
}
if (pendingMutationRoots.size > 0 && !mutationRafId) {
mutationRafId = setTimeout(() => {
mutationRafId = null;
const roots = [...pendingMutationRoots];
pendingMutationRoots.clear();
if (roots.length > 5) {
translatePage(document.body);
} else {
roots.forEach(r => translatePage(r));
}
}, 200);
}
});
// ══════════════════════════════════════════════════════════
// UI - MD3 风格 & 高性能无延迟悬浮球吸附/拖拽逻辑 & 极致居中
// ══════════════════════════════════════════════════════════
function buildLangOptions() {
let html = '';
for (const [group, codes] of Object.entries(LANG_GROUPS)) {
html += '';
}
return html;
}
function isPageInTargetLang() {
const lang = (document.documentElement.lang || '').split('-')[0].toLowerCase();
const target = targetLang.split('-')[0].toLowerCase();
return lang === target;
}
function initWhenBodyReady() {
if (document.body) {
init();
} else {
requestAnimationFrame(initWhenBodyReady);
}
}
// 异步 UI 宏任务渲染分离器:杜绝点击按钮时的卡顿、割裂感
function runAsyncUI(task) {
requestAnimationFrame(() => setTimeout(task, 10));
}
let _initialized = false;
async function init() {
if (_initialized) return;
_initialized = true;
lastHeight = document.documentElement.scrollHeight;
// 注入CSS: 全局新增完美的 Flexbox 居中逻辑,不影响性能
GM_addStyle(`
.translate-ui { position: fixed; z-index: 2147483647; font-family: Roboto, system-ui, -apple-system, sans-serif; will-change: left, top; }
.translate-ui * { box-sizing: border-box; margin: 0; padding: 0; border-radius: 0 !important; user-select: none; }
.tu-btn {
width: 48px; height: 48px; border: none;
background: #0B57D0; color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 8px 3px rgba(0,0,0,0.15);
transition: background 0.2s, transform 0.2s;
touch-action: none;
}
.tu-btn svg { display: block; margin: auto; }
.tu-btn.active { background: #1f1f1f; }
.tu-btn.dragging { transform: scale(1.1); box-shadow: 0 8px 12px 6px rgba(0,0,0,0.25); }
.tu-panel {
position: absolute; width: 280px; max-height: 80vh; overflow-y: auto;
background: #F3F4F6; box-shadow: 0 8px 12px 6px rgba(0,0,0,0.15), 0 4px 4px rgba(0,0,0,0.3);
padding: 16px; display: none; color: #1f1f1f; font-size: 14px; text-align: center;
}
.tu-panel.show { display: block; }
.tu-panel label { display: block; margin: 12px 0 6px; font-size: 12px; color: #444746; font-weight: 500; text-align: center; }
.tu-panel select {
width: 100%; padding: 10px; border: 1px solid #747775;
background: #fff; color: #1f1f1f; outline: none; font-size: 14px;
text-align: center; text-align-last: center;
}
.tu-panel select:focus { border: 2px solid #0B57D0; padding: 9px; }
.tu-modes { display: flex; gap: 4px; margin-top: 8px; }
.tu-modes button {
flex: 1; padding: 10px 0; border: 1px solid #747775; background: transparent;
color: #444746; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s;
display: flex; align-items: center; justify-content: center; text-align: center;
}
.tu-modes button.on { background: #D3E3FD; color: #041E49; border-color: #0B57D0; }
.tu-status { margin-top: 12px; padding: 8px; background: #E0E2E5; font-size: 12px; color: #444746; font-weight: 500; display: flex; align-items: center; justify-content: center; text-align: center; }
.tu-row { display: flex; gap: 8px; margin-top: 12px; }
.tu-row button { flex: 1; padding: 10px 0; border: none; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 0.2s; display: flex; align-items: center; justify-content: center; text-align: center; }
.tu-row .tu-restore { background: #E0E2E5; color: #1f1f1f; }
.tu-row .tu-restore:active { background: #C4C7C5; }
.tu-row .tu-go { background: #0B57D0; color: #fff; }
.tu-row .tu-go:active { background: #0842A0; }
.tu-row .tu-exclude { background: #B3261E; color: #fff; font-size: 12px; }
.tu-row .tu-exclude:active { background: #8C1D18; }
.tu-bi { display: block; margin-top: 2px; font-size: .9em; line-height: 1.5; color: #0B57D0; border-left: 2px solid rgba(11,87,208,0.3); padding-left: 8px; }
a .tu-bi, span .tu-bi, em .tu-bi, strong .tu-bi, b .tu-bi, i .tu-bi, label .tu-bi, small .tu-bi, sub .tu-bi, sup .tu-bi, u .tu-bi {
display: inline; border-left: none; padding-left: 0; margin-top: 0; margin-left: 4px; font-size: .88em;
}
a .tu-bi::before, span .tu-bi::before, em .tu-bi::before, strong .tu-bi::before, b .tu-bi::before, i .tu-bi::before, label .tu-bi::before, small .tu-bi::before, sub .tu-bi::before, sup .tu-bi::before, u .tu-bi::before { content: "("; color: #747775; }
a .tu-bi::after, span .tu-bi::after, em .tu-bi::after, strong .tu-bi::after, b .tu-bi::after, i .tu-bi::after, label .tu-bi::after, small .tu-bi::after, sub .tu-bi::after, sup .tu-bi::after, u .tu-bi::after { content: ")"; color: #747775; }
@media(prefers-color-scheme:dark){
.tu-btn { background: #A8C7FA; color: #052D70; }
.tu-btn.active { background: #E2E2E2; color: #1f1f1f; }
.tu-panel { background: #1E1F20; color: #E3E3E3; box-shadow: 0 8px 12px 6px rgba(0,0,0,0.5); }
.tu-panel label { color: #C4C7C5; }
.tu-panel select { background: #282A2C; color: #E3E3E3; border-color: #8E918F; }
.tu-panel select:focus { border-color: #A8C7FA; }
.tu-modes button { border-color: #8E918F; color: #C4C7C5; }
.tu-modes button.on { background: #004A77; color: #C2E7FF; border-color: #A8C7FA; }
.tu-status { background: #282A2C; color: #C4C7C5; }
.tu-row .tu-restore { background: #282A2C; color: #E3E3E3; }
.tu-row .tu-restore:active { background: #444746; }
.tu-row .tu-go { background: #A8C7FA; color: #052D70; }
.tu-row .tu-go:active { background: #7CB0F9; }
.tu-row .tu-exclude { background: #F2B8B5; color: #601410; }
.tu-row .tu-exclude:active { background: #F9DEDC; color: #410E0B; }
.tu-bi { color: #A8C7FA; border-left-color: rgba(168,199,250,0.3); }
a .tu-bi::before, span .tu-bi::before, em .tu-bi::before, strong .tu-bi::before, b .tu-bi::before, i .tu-bi::before, a .tu-bi::after, span .tu-bi::after, em .tu-bi::after, strong .tu-bi::after, b .tu-bi::after, i .tu-bi::after { color: #8E918F; }
}
`);
// 构建容器
const ui = document.createElement('div');
ui.className = 'translate-ui';
// 初始化位置 (默认靠右下)
let initX = _savedX >= 0 ? _savedX : window.innerWidth - 48;
let initY = _savedY >= 0 ? _savedY : window.innerHeight * 0.75;
if (initX > window.innerWidth - 48) initX = window.innerWidth - 48;
if (initY > window.innerHeight - 48) initY = window.innerHeight - 48;
ui.style.left = initX + 'px';
ui.style.top = initY + 'px';
ui.innerHTML =
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'Ready' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'' +
'';
document.body.appendChild(ui);
const btn = document.getElementById('tuBtn');
const panel = document.getElementById('tuPanel');
const engineSelect = document.getElementById('tuEngine');
const langSelect = document.getElementById('tuLang');
const statusEl = document.getElementById('tuStatus');
const modesEl = document.getElementById('tuModes');
engineSelect.value = currentEngine;
langSelect.value = targetLang;
function setStatus(msg) { if (statusEl) statusEl.textContent = msg; }
// 面板智能朝向弹出逻辑
function updatePanelPosition() {
const rect = btn.getBoundingClientRect();
if (rect.left < window.innerWidth / 2) {
panel.style.left = '0px';
panel.style.right = 'auto';
} else {
panel.style.right = '0px';
panel.style.left = 'auto';
}
if (rect.top < window.innerHeight / 2) {
panel.style.top = '56px';
panel.style.bottom = 'auto';
} else {
panel.style.bottom = '56px';
panel.style.top = 'auto';
}
}
// 重构极速无缝拖拽逻辑
let isDragging = false;
let hasMoved = false;
let startX = 0, startY = 0;
let initialLeft = 0, initialTop = 0;
let moveRafId = null;
function onPointerDown(e) {
if (e.button !== 0 && e.pointerType === 'mouse') return;
if (e.target.closest('.tu-panel')) return;
e.preventDefault();
startX = e.clientX;
startY = e.clientY;
const rect = ui.getBoundingClientRect();
initialLeft = rect.left;
initialTop = rect.top;
isDragging = false;
hasMoved = false;
document.addEventListener('pointermove', onPointerMove, { passive: false });
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
}
function onPointerMove(e) {
e.preventDefault();
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!isDragging && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
isDragging = true;
hasMoved = true;
btn.classList.add('dragging');
ui.style.transition = 'none';
}
if (isDragging) {
if (!moveRafId) {
moveRafId = requestAnimationFrame(() => {
ui.style.left = (initialLeft + dx) + 'px';
ui.style.top = (initialTop + dy) + 'px';
moveRafId = null;
});
}
}
}
function onPointerUp(e) {
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointercancel', onPointerUp);
if (moveRafId) {
cancelAnimationFrame(moveRafId);
moveRafId = null;
}
if (isDragging) {
isDragging = false;
btn.classList.remove('dragging');
const rect = ui.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
let finalLeft = centerX < window.innerWidth / 2 ? 0 : window.innerWidth - rect.width;
let finalTop = rect.top;
if (finalTop < 0) finalTop = 0;
if (finalTop > window.innerHeight - rect.height) finalTop = window.innerHeight - rect.height;
ui.style.transition = 'left 0.3s cubic-bezier(0.2, 0, 0, 1), top 0.3s cubic-bezier(0.2, 0, 0, 1)';
ui.style.left = finalLeft + 'px';
ui.style.top = finalTop + 'px';
GM_setValue('tu_pos_x', finalLeft);
GM_setValue('tu_pos_y', finalTop);
setTimeout(updatePanelPosition, 300);
} else if (!hasMoved) {
if (e.target.closest('#tuBtn')) {
// 优化点2:为面板初次弹出引入帧动画同步,防止掉帧导致的卡顿感
requestAnimationFrame(() => {
updatePanelPosition();
panel.classList.toggle('show');
});
}
}
}
btn.addEventListener('pointerdown', onPointerDown);
document.addEventListener('pointerdown', function(e) {
if (!ui.contains(e.target) && !isDragging) panel.classList.remove('show');
});
// 表单操作
engineSelect.addEventListener('change', function() {
currentEngine = engineSelect.value;
GM_setValue('engine', currentEngine);
cache.clear();
setStatus('Engine: ' + (Engine[currentEngine] ? Engine[currentEngine].name : currentEngine));
});
langSelect.addEventListener('change', function() {
targetLang = langSelect.value;
GM_setValue('targetLang', targetLang);
cache.clear();
setStatus('Target: ' + (ALL_LANGUAGES[targetLang] || targetLang));
});
// 模式切换:接入 UI 宏任务分离,实现 0 延迟秒切响应
modesEl.addEventListener('click', function(e) {
var b = e.target.closest('button[data-m]');
if (!b) return;
var m = b.dataset.m;
if (m === displayMode) return;
modesEl.querySelectorAll('button').forEach(function(x) { x.classList.remove('on'); });
b.classList.add('on');
displayMode = m;
GM_setValue('displayMode', m);
// 先立即更新UI状态
if (m === 'original') {
btn.classList.remove('active');
setStatus('显示原文');
runAsyncUI(() => restorePage()); // 异步防阻塞
} else {
btn.classList.add('active');
setStatus(m === 'bilingual' ? '双语翻译中...' : '翻译中...');
runAsyncUI(async () => {
restorePage();
var start = performance.now();
await translatePage();
setStatus('完成 (' + ((performance.now() - start) / 1000).toFixed(1) + 's)');
});
}
});
// 翻译按钮:接入 UI 宏任务分离,彻底消灭点击后的“假死”感
document.getElementById('tuGo').addEventListener('click', function() {
panel.classList.remove('show');
btn.classList.add('active');
autoMode = true;
GM_setValue('autoMode', true);
setStatus('翻译中...');
runAsyncUI(async () => {
restorePage();
cache.clear();
lastHeight = document.documentElement.scrollHeight;
var start = performance.now();
await translatePage();
setStatus('完成 (' + ((performance.now() - start) / 1000).toFixed(1) + 's)');
});
});
// 还原按钮:同上,保证秒响应
document.getElementById('tuRestore').addEventListener('click', function() {
panel.classList.remove('show');
btn.classList.remove('active');
autoMode = false;
GM_setValue('autoMode', false);
setStatus('已还原');
runAsyncUI(() => restorePage());
});
document.getElementById('tuExclude').addEventListener('click', function() {
if (!excludedHosts.includes(location.host)) {
excludedHosts.push(location.host);
GM_setValue('excludedHosts', JSON.stringify(excludedHosts));
}
restorePage();
ui.remove();
observer.disconnect();
window.removeEventListener('scroll', onScroll);
});
// 快捷菜单
GM_registerMenuCommand('翻译当前页面', function() { document.getElementById('tuGo').click(); });
GM_registerMenuCommand('还原当前页面', function() { document.getElementById('tuRestore').click(); });
GM_registerMenuCommand('切换Google引擎', function() { currentEngine = 'google'; engineSelect.value = 'google'; GM_setValue('engine', 'google'); cache.clear(); });
GM_registerMenuCommand('切换Microsoft引擎', function() { currentEngine = 'microsoft'; engineSelect.value = 'microsoft'; GM_setValue('engine', 'microsoft'); cache.clear(); });
GM_registerMenuCommand('切换双语/仅译文', function() {
var m = displayMode === 'bilingual' ? 'translated' : 'bilingual';
modesEl.querySelector('button[data-m="'+m+'"]').click();
});
window.addEventListener('scroll', onScroll, { passive: true });
observer.observe(document.body, { childList: true, subtree: true });
if (autoMode && !isPageInTargetLang() && displayMode !== 'original') {
// 优化点1:初次页面加载时,将微任务(queueMicrotask)改为宏任务(setTimeout)
// 延迟 200 毫秒,强制给浏览器留出渲染时间,彻底解决初次点击悬浮球被阻塞而点不开的 Bug
setTimeout(async function() {
setStatus(displayMode === 'bilingual' ? '双语翻译中...' : '自动翻译中...');
var start = performance.now();
await translatePage();
setStatus('完成 (' + ((performance.now() - start) / 1000).toFixed(1) + 's)');
}, 200);
}
}
initWhenBodyReady();
})();