// ==UserScript== // @name Auto Translate - TWP Enhanced // @namespace auto-translate-twp // @version 2.0.0 // @description 基于TWP开源项目的Google翻译API,支持249种语言,自动翻译网页内容并直接替换 // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_addStyle // @grant GM_registerMenuCommand // @connect translate-pa.googleapis.com // @connect translate.googleapis.com // @connect api-edge.cognitive.microsofttranslator.com // @connect edge.microsoft.com // @connect transmart.qq.com // @run-at document-idle // @downloadURL none // ==/UserScript== (async () => { 'use strict'; // ═══════════════════════════════════════════════════════════════ // 基于 TWP (Traduzir-paginas-web) 开源项目的翻译API完整移植 // 源码: https://github.com/FilipePS/Traduzir-paginas-web // API端点: translate-pa.googleapis.com/v1/translateHtml // 认证方式: X-goog-api-key (动态获取 + 备用密钥) // ═══════════════════════════════════════════════════════════════ try { if (document.contentType === 'application/xml') return; } catch (_) {} // ── 设备语言检测 ── const deviceLang = (navigator.language || navigator.userLanguage || 'zh-CN').split('-')[0]; const fullDeviceLang = navigator.language || 'zh-CN'; // ══════════════════════════════════════ // 工具函数 // ══════════════════════════════════════ function escapeHTML(text) { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function unescapeHTML(text) { const el = document.createElement('textarea'); el.innerHTML = text; return el.value; } function gmFetch(opts) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ ...opts, onload: resolve, onerror: reject, ontimeout: reject }); }); } // ══════════════════════════════════════ // TWP GoogleHelper_v2 - API密钥管理 // 完整移植自 TWP translationService.js // ══════════════════════════════════════ const GoogleAuth = { _apiKey: null, _lastRequestTime: null, _authNotFound: false, _authPromise: null, get apiKey() { return this._apiKey; }, // 备用API密钥 (从TWP源码提取,Base64编码的字节数组) get _fallbackKey() { 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 ])); // = "AIzaSyATBXajvzQLTDHEQbcpq0Ihe0vWDHmO520" }, async ensureAuth() { if (this._authPromise) return await this._authPromise; this._authPromise = new Promise(async (resolve) => { let needUpdate = false; if (this._lastRequestTime) { const now = Date.now(); const elapsed = now - this._lastRequestTime; if (this._apiKey) { needUpdate = elapsed > 20 * 60 * 1000; // 20分钟刷新 } else if (this._authNotFound) { needUpdate = elapsed > 5 * 60 * 1000; // 5分钟重试 } else { needUpdate = elapsed > 60 * 1000; // 1分钟重试 } } else { needUpdate = true; } if (needUpdate) { this._lastRequestTime = Date.now(); try { // 从Google的JS bundle中动态提取最新API密钥 const jsUrl = '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'; const r = await gmFetch({ method: 'GET', url: jsUrl, timeout: 10000 }); if (r.responseText && r.responseText.length > 1) { const match = r.responseText.match(/['"]x-goog-api-key['"]\s*:\s*['"](\w{39})['"]/i); if (match && match[1]) { this._apiKey = match[1]; this._authNotFound = false; console.log('[TWP Auth] 动态获取API Key成功'); } else { this._authNotFound = true; this._apiKey = this._fallbackKey; console.log('[TWP Auth] 未匹配到Key,使用备用Key'); } } else { this._authNotFound = true; this._apiKey = this._fallbackKey; } } catch (e) { console.warn('[TWP Auth] 获取失败,使用备用Key', e); this._apiKey = this._fallbackKey; } } resolve(); }); this._authPromise.finally(() => { this._authPromise = null; }); return await this._authPromise; } }; // ══════════════════════════════════════ // TWP 响应解析器 // 完整移植自 TWP cbTransformResponse // ══════════════════════════════════════ function parseGoogleNewResponse(result, dontSortResults) { // 移除
 标签
    if (result.indexOf('
') !== -1) {
      result = result.replace('
', '');
      const index = result.indexOf('>');
      if (index !== -1) result = result.slice(index + 1);
    }

    // 提取  标签中的翻译内容
    const sentences = [];
    let idx = 0;
    while (true) {
      const sentenceStart = result.indexOf('', idx);
      if (sentenceStart === -1) break;
      const sentenceEnd = result.indexOf('', sentenceStart);
      if (sentenceEnd === -1) {
        sentences.push(result.slice(sentenceStart + 3));
        break;
      } else {
        sentences.push(result.slice(sentenceStart + 3, sentenceEnd));
      }
      idx = sentenceEnd;
    }
    result = sentences.length > 0 ? sentences.join(' ') : result;
    result = result.replace(/<\/b>/g, '');

    // 提取带索引的  标签
    let resultArray = [];
    let lastEndPos = 0;
    for (const r of result.matchAll(/()([^<>]*(?=<\/a>))*/g)) {
      const fullText = r[0];
      const fullLength = r[0].length;
      const pos = r.index;
      if (pos > lastEndPos) {
        const aTag = r[1];
        const insideText = r[2] || '';
        const outsideText = result.slice(lastEndPos, pos).replace(/<\/a>/g, '');
        resultArray.push(aTag + outsideText + insideText);
      } else {
        resultArray.push(fullText);
      }
      lastEndPos = pos + fullLength;
    }

    let indexes;
    if (resultArray.length > 0) {
      indexes = resultArray
        .map(v => parseInt((v.match(/[0-9]+(?=>)/g) || ['0'])[0]))
        .filter(v => !isNaN(v));
      resultArray = resultArray.map(v => {
        const start = v.indexOf('>');
        return v.slice(start + 1);
      });
    } else {
      resultArray = [result];
      indexes = [0];
    }

    // HTML实体解码
    resultArray = resultArray.map(v => unescapeHTML(v));

    if (dontSortResults) return resultArray;

    // 按索引排序
    const finalArray = [];
    for (let j = 0; j < indexes.length; j++) {
      if (finalArray[indexes[j]]) {
        finalArray[indexes[j]] += ' ' + resultArray[j];
      } else {
        finalArray[indexes[j]] = resultArray[j];
      }
    }
    return finalArray;
  }

  // ══════════════════════════════════════
  //  翻译引擎定义
  // ══════════════════════════════════════

  const Engine = {

    // ─────────────────────────────────
    //  Google 新版 API (TWP核心)
    //  端点: translate-pa.googleapis.com/v1/translateHtml
    //  认证: X-goog-api-key
    // ─────────────────────────────────
    google: {
      name: 'Google (TWP)',

      // TWP语言代码替换映射
      _langReplace(lang) {
        const map = { 'prs': 'fa-AF' };
        return map[lang] || lang;
      },

      async translateBatch(texts, toLang) {
        await GoogleAuth.ensureAuth();
        const apiKey = GoogleAuth.apiKey;
        if (!apiKey) throw new Error('No Google API Key');

        const tl = this._langReplace(toLang);

        // TWP cbTransformRequest: 转换请求文本
        const escaped = texts.map(t => escapeHTML(t));
        let requestText;
        if (escaped.length === 1) {
          requestText = `
${escaped[0]}
`; } else { requestText = '
' + escaped.map((t, i) => `${t}`).join('') + '
'; } // TWP cbGetRequestBody: 构建请求体 const body = JSON.stringify([ [[requestText], 'auto', tl], 'te' ]); const r = await gmFetch({ method: 'POST', url: 'https://translate-pa.googleapis.com/v1/translateHtml', headers: { 'Content-Type': 'application/json+protobuf', 'X-goog-api-key': apiKey }, data: body, timeout: 30000 }); if (r.status !== 200) throw new Error(`Google New API error: ${r.status}`); // TWP cbParseResponse: 解析响应 const response = JSON.parse(r.responseText); const translatedHtml = response[0][0]; // TWP cbTransformResponse: 提取翻译结果 const results = parseGoogleNewResponse(translatedHtml, false); return results; }, async translate(text, toLang) { const results = await this.translateBatch([text], toLang); return results[0] || null; } }, // ───────────────────────────────── // Google 经典 API (回退) // 端点: translate.googleapis.com/translate_a/single // 无需API Key (client=gtx) // ───────────────────────────────── google_classic: { name: 'Google (Classic)', langCode(lang) { const map = { zh: 'zh-CN', he: 'iw' }; return map[lang] || lang; }, async translateBatch(texts, toLang) { const results = []; const to = this.langCode(toLang); const concurrency = 5; for (let i = 0; i < texts.length; i += concurrency) { const batch = texts.slice(i, i + concurrency); const promises = batch.map(async (text) => { try { 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)}`, timeout: 15000 }); if (r.status !== 200) return null; const data = JSON.parse(r.responseText); return data[0].map(s => s[0]).join(''); } catch (_) { return null; } }); const batchResults = await Promise.allSettled(promises); results.push(...batchResults.map(r => r.status === 'fulfilled' ? r.value : null)); } return results; }, async translate(text, toLang) { const r = await this.translateBatch([text], toLang); return r[0]; } }, // ───────────────────────────────── // Microsoft Edge 翻译 // ───────────────────────────────── 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', timeout: 10000 }); if (r.status !== 200) throw new Error('MS auth error'); this._token = r.responseText; this._tokenTime = Date.now(); return this._token; }, langCode(lang) { const map = { zh: 'zh-Hans', 'zh-CN': 'zh-Hans', 'zh-TW': 'zh-Hant', auto: '', no: 'nb', fil: 'fil', mn: 'mn-Cyrl', sr: 'sr-Cyrl', hmn: 'mww', jw: 'id' }; return map[lang] !== undefined ? map[lang] : lang; }, async translateBatch(texts, toLang) { const token = await this.getToken(); const to = this.langCode(toLang); const results = new Array(texts.length).fill(null); for (let batch = 0; batch < texts.length; batch += 25) { const chunk = texts.slice(batch, batch + 25); 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 ${token}`, 'Content-Type': 'application/json' }, data: JSON.stringify(chunk.map(t => ({ Text: t }))), timeout: 30000 }); if (r.status === 200) { const data = JSON.parse(r.responseText); for (let j = 0; j < data.length; j++) { results[batch + j] = data[j].translations[0].text; } } } catch (_) {} } return results; }, async translate(text, toLang) { const r = await this.translateBatch([text], toLang); return r[0]; } }, // ───────────────────────────────── // 腾讯交互翻译 (Tencent Transmart) // ───────────────────────────────── tencent: { name: 'Tencent', _clientKey: null, getClientKey() { if (this._clientKey) return this._clientKey; this._clientKey = `browser-chrome-130.0-Windows_10-${crypto.randomUUID()}-${Date.now()}`; return this._clientKey; }, langCode(lang) { const map = { 'zh-CN': 'zh', 'zh-TW': 'zh-TW', zh: 'zh', fil: 'fil', no: 'no', sr: 'sr' }; return map[lang] || lang; }, async translateBatch(texts, toLang) { const to = this.langCode(toLang); const results = new Array(texts.length).fill(null); const concurrency = 3; for (let i = 0; i < texts.length; i += concurrency) { const batch = texts.slice(i, i + concurrency); const promises = batch.map(async (text, j) => { try { 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 } }), timeout: 15000 }); if (r.status === 200) { results[i + j] = JSON.parse(r.responseText).auto_translation[0]; } } catch (_) {} }); await Promise.allSettled(promises); } return results; }, async translate(text, toLang) { const r = await this.translateBatch([text], toLang); return r[0]; } } }; // ══════════════════════════════════════ // 完整语言列表 (249种, 来自TWP) // 分组用于UI展示 // ══════════════════════════════════════ const LANG_GROUPS = { '★ 常用': [ ['zh-CN','中文(简体)'],['zh-TW','中文(繁體)'],['en','English'], ['ja','日本語'],['ko','한국어'],['fr','Français'], ['de','Deutsch'],['es','Español'],['ru','Русский'], ['pt','Português'],['ar','العربية'],['it','Italiano'], ['th','ไทย'],['vi','Tiếng Việt'],['id','Indonesia'], ['tr','Türkçe'],['nl','Nederlands'],['pl','Polski'], ['uk','Українська'],['hi','हिन्दी'] ], '东亚': [ ['yue','粵語 Cantonese'],['lzh','文言文 Literary Chinese'], ['mn','Монгол Mongolian'],['mvf','ᠮᠣᠩᠭᠣᠯ Mongolian Traditional'] ], '南亚': [ ['bn','বাংলা Bengali'],['gu','ગુજરાતી Gujarati'],['kn','ಕನ್ನಡ Kannada'], ['ml','മലയാളം Malayalam'],['mr','मराठी Marathi'],['ne','नेपाली Nepali'], ['or','ଓଡ଼ିଆ Odia'],['pa','ਪੰਜਾਬੀ Punjabi'],['pa-Arab','پنجابی Punjabi Shahmukhi'], ['si','සිංහල Sinhala'],['ta','தமிழ் Tamil'],['te','తెలుగు Telugu'], ['ur','اردو Urdu'],['as','অসমীয়া Assamese'],['bho','भोजपुरी Bhojpuri'], ['doi','डोगरी Dogri'],['gom','कोंकणी Konkani'],['ks','कॉशुर Kashmiri'], ['mai','मैथिली Maithili'],['mni-Mtei','ꯃꯤꯇꯩꯂꯣꯟ Meiteilon'], ['sa','संस्कृतम् Sanskrit'],['sd','سنڌي Sindhi'],['brx','बड़ो Bodo'], ['hne','छत्तीसगढ़ी Chhattisgarhi'],['awa','अवधी Awadhi'], ['dv','ދިވެހި Dhivehi'],['mwr','मारवाड़ी Marwadi'], ['new','नेपालभाषा Newari'],['kokborok','Kokborok'],['trp','Kokborok'], ['dz','རྫོང་ཁ Dzongkha'],['bo','བོད་སྐད Tibetan'],['lus','Mizo'] ], '东南亚': [ ['ms','Melayu Malay'],['ms-Arab','ملايو Malay Jawi'],['fil','Filipino'], ['tl','Tagalog'],['ceb','Cebuano'],['my','မြန်မာ Myanmar'], ['km','ខ្មែរ Khmer'],['lo','ລາວ Lao'],['jw','Jawa Javanese'], ['su','Sunda Sundanese'],['hmn','Hmong'],['ilo','Ilokano Ilocano'], ['ban','Bali Balinese'],['bik','Bikol'],['hil','Hiligaynon'], ['iba','Iban'],['kac','Jingpo 景颇语'],['mad','Madhurâ Madurese'], ['mak','Mangkasara Makassar'],['min','Minangkabau'],['pam','Kapampangan'], ['ace','Acèh Acehnese'],['btx','Batak Karo'],['bts','Batak Simalungun'], ['bbc','Batak Toba'],['bew','Betawi'],['cnh','Hakha Chin'], ['pag','Pangasinan'] ], '中东 & 中亚': [ ['fa','فارسی Persian'],['prs','دری Dari'],['ps','پښتو Pashto'], ['he','עברית Hebrew'],['ku','Kurdî Kurdish'],['ckb','کوردی Kurdish Sorani'], ['az','Azərbaycan Azerbaijani'],['ka','ქართული Georgian'], ['hy','Հայերեն Armenian'],['kk','Қазақ Kazakh'],['ky','Кыргызча Kyrgyz'], ['uz','Oʻzbek Uzbek'],['tg','Тоҷикӣ Tajik'],['tk','Türkmen Turkmen'], ['tt','Татар Tatar'],['ug','ئۇيغۇرچە Uyghur'],['ba','Башҡорт Bashkir'], ['ce','Нохчийн Chechen'],['cv','Чӑваш Chuvash'],['kv','Коми Komi'], ['os','Ирон Ossetian'],['crh','Qırımtatar Crimean Tatar'], ['ab','Аԥсуа Abkhaz'],['av','Авар Avar'],['bal','بلوچی Baluchi'], ['bua','Буряад Buryat'] ], '西欧 & 北欧': [ ['sv','Svenska Swedish'],['da','Dansk Danish'],['no','Norsk Norwegian'], ['fi','Suomi Finnish'],['is','Íslenska Icelandic'],['ga','Gaeilge Irish'], ['cy','Cymraeg Welsh'],['gd','Gàidhlig Scots Gaelic'],['gv','Gaelg Manx'], ['br','Brezhoneg Breton'],['co','Corsu Corsican'],['eu','Euskara Basque'], ['gl','Galego Galician'],['ca','Català Catalan'],['oc','Occitan'], ['lb','Lëtzebuergesch Luxembourgish'],['fy','Frysk Frisian'], ['mt','Malti Maltese'],['fo','Føroyskt Faroese'],['se','Davvisámegiella Sami'], ['kl','Kalaallisut Greenlandic'],['lij','Lìgure Ligurian'],['li','Limburgs Limburgish'], ['lmo','Lombard'],['fur','Furlan Friulian'],['ltg','Latgaļu Latgalian'], ['dsb','Lower Sorbian'],['hsb','Upper Sorbian'],['pt-PT','Português Portugal'], ['fr-CA','Français Canada'] ], '东欧 & 巴尔干': [ ['cs','Čeština Czech'],['sk','Slovenčina Slovak'],['hu','Magyar Hungarian'], ['ro','Română Romanian'],['bg','Български Bulgarian'],['hr','Hrvatski Croatian'], ['sr','Српски Serbian'],['sl','Slovenščina Slovenian'],['bs','Bosanski Bosnian'], ['mk','Македонски Macedonian'],['sq','Shqip Albanian'],['et','Eesti Estonian'], ['lv','Latviešu Latvian'],['lt','Lietuvių Lithuanian'],['be','Беларуская Belarusian'], ['mrj','Кырык мары Hill Mari'],['chm','Олык марий Meadow Mari'] ], '非洲': [ ['sw','Kiswahili Swahili'],['ha','Hausa'],['yo','Yorùbá Yoruba'], ['ig','Igbo'],['zu','isiZulu Zulu'],['xh','isiXhosa Xhosa'], ['af','Afrikaans'],['am','አማርኛ Amharic'],['so','Soomaali Somali'], ['sn','chiShona Shona'],['st','Sesotho'],['ny','Chichewa Nyanja'], ['mg','Malagasy'],['rw','Kinyarwanda'],['lg','Luganda'], ['ti','ትግርኛ Tigrinya'],['om','Oromo'],['nso','Sepedi'], ['ts','Xitsonga Tsonga'],['ln','Lingála Lingala'],['bm','Bamanankan Bambara'], ['ee','Eʋegbe Ewe'],['kri','Krio'],['ak','Twi'], ['ach','Acholi'],['alz','Alur'],['aa','Qafar Afar'], ['bem','Bemba'],['bci','Baoulé'],['din','Dinka'],['dov','Dombe'], ['dyu','Dyula'],['ff','Fulfulde Fulani'],['fon','Fɔngbe Fon'], ['gaa','Gã Ga'],['cgg','Kiga'],['kg','Kikongo'],['mkw','Kituba'], ['kr','Kanuri'],['luo','Dholuo Luo'],['ndc-ZW','Ndau'], ['nr','isiNdebele South'],['nus','Nuer'],['rn','Ikirundi Rundi'], ['sg','Sängö Sango'],['nqo','ߒߞߏ NKo'] ], '美洲 & 太平洋': [ ['ht','Kreyòl Haitian Creole'],['haw','Hawaiian'],['sm','Samoan'], ['mi','Māori'],['gn','Guarani'],['qu','Runasimi Quechua'], ['ay','Aymar Aymara'],['fj','Vosa Vakaviti Fijian'], ['ch','Chamoru Chamorro'],['chk','Chuukese'],['mh','Marshallese'], ['jam','Jamaican Patois'],['pap','Papiamentu Papiamento'], ['mfe','Morisien Mauritian Creole'],['nhe','Nahuatl'], ['mam','Mam'],['kek',"Q'eqchi'"],['ikt','Inuinnaqtun'], ['iu','ᐃᓄᒃᑎᑐᑦ Inuktitut'],['iu-Latn','Inuktut Latin'], ['hrx','Hunsrik'] ], '其他': [ ['eo','Esperanto'],['la','Latina Latin'],['yi','ייִדיש Yiddish'], ['rom','Romani'],['kha','Khasi'],['hne','Chhattisgarhi'], ['ks','Kashmiri'] ] }; // 构建 code → name 平面映射 const ALL_LANGS = {}; for (const group of Object.values(LANG_GROUPS)) { for (const [code, name] of group) { ALL_LANGS[code] = name; } } // ══════════════════════════════════════ // 持久化状态 // ══════════════════════════════════════ let currentEngine = await GM_getValue('engine', 'google'); let targetLang = await GM_getValue('targetLang', (() => { // 智能匹配设备语言 if (fullDeviceLang in ALL_LANGS) return fullDeviceLang; if (deviceLang in ALL_LANGS) return deviceLang; if (deviceLang === 'zh') return 'zh-CN'; return deviceLang; })()); let autoMode = await GM_getValue('autoMode', true); let excludedHosts = JSON.parse(await GM_getValue('excludedHosts', '[]')); let useGoogleFallback = await GM_getValue('useGoogleFallback', true); if (excludedHosts.includes(location.host)) return; // ── 缓存层 ── const cache = new Map(); const MAX_CACHE = 3000; function cacheGet(text) { return cache.get(text); } function cacheSet(text, translated) { if (cache.size >= MAX_CACHE) cache.delete(cache.keys().next().value); cache.set(text, translated); } // ══════════════════════════════════════ // 翻译核心 // ══════════════════════════════════════ async function translate(text) { if (!text || !text.trim()) return null; const trimmed = text.trim(); if (/^\d+$/.test(trimmed) || trimmed.length < 2) return null; const cached = cacheGet(trimmed); if (cached) return cached; try { const engine = Engine[currentEngine]; const result = await engine.translate(trimmed, targetLang); if (result && result !== trimmed) { cacheSet(trimmed, result); return result; } } catch (e) { console.warn('[Translate] 主引擎失败:', e); // Google新API失败时自动回退到经典API if (currentEngine === 'google' && useGoogleFallback) { try { const result = await Engine.google_classic.translate(trimmed, targetLang); if (result && result !== trimmed) { cacheSet(trimmed, result); return result; } } catch (e2) { console.warn('[Translate] 回退引擎也失败:', e2); } } } return null; } async function batchTranslate(texts) { const results = new Array(texts.length).fill(null); const uncached = []; const uncachedIdx = []; for (let i = 0; i < texts.length; i++) { const t = texts[i].trim(); if (!t || /^\d+$/.test(t) || t.length < 2) 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]; // ── Google 新API批量翻译 (TWP方式) ── if (currentEngine === 'google') { const BATCH_SIZE = 50; const MAX_CHARS = 5000; for (let start = 0; start < uncached.length;) { let end = start; let totalChars = 0; while (end < uncached.length && end - start < BATCH_SIZE) { totalChars += uncached[end].length; if (totalChars > MAX_CHARS && end > start) break; end++; } const chunk = uncached.slice(start, end); const chunkIdx = uncachedIdx.slice(start, end); try { const translated = await Engine.google.translateBatch(chunk, targetLang); for (let j = 0; j < translated.length; j++) { if (translated[j] && translated[j] !== chunk[j]) { cacheSet(chunk[j], translated[j]); results[chunkIdx[j]] = translated[j]; } } } catch (e) { console.warn('[Google Batch] 失败,回退逐条翻译:', e); // 回退到经典API逐条翻译 if (useGoogleFallback) { try { const fallbackResults = await Engine.google_classic.translateBatch(chunk, targetLang); for (let j = 0; j < fallbackResults.length; j++) { if (fallbackResults[j] && fallbackResults[j] !== chunk[j]) { cacheSet(chunk[j], fallbackResults[j]); results[chunkIdx[j]] = fallbackResults[j]; } } } catch (_) {} } } start = end; } return results; } // ── Microsoft批量翻译 ── if (currentEngine === 'microsoft') { try { const translated = await engine.translateBatch(uncached, targetLang); for (let j = 0; j < translated.length; j++) { if (translated[j] && translated[j] !== uncached[j]) { cacheSet(uncached[j], translated[j]); results[uncachedIdx[j]] = translated[j]; } } return results; } catch (_) {} } // ── 其他引擎 / 逐条回退 ── try { const translated = await engine.translateBatch(uncached, targetLang); for (let j = 0; j < translated.length; j++) { if (translated[j] && translated[j] !== uncached[j]) { cacheSet(uncached[j], translated[j]); results[uncachedIdx[j]] = translated[j]; } } } catch (e) { console.warn('[Batch] 批量翻译失败:', e); } 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; } return false; } function isTargetLang(text) { if (!text || !text.trim()) return true; const t = text.trim(); const lang = targetLang.split('-')[0]; if (lang === 'zh') return /^[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef\s\d\p{P}]+$/u.test(t); if (lang === 'ja') return /[\u3040-\u309f\u30a0-\u30ff]/.test(t); if (lang === 'ko') return /[\uac00-\ud7af\u1100-\u11ff]/.test(t); if (lang === 'en') return /^[a-zA-Z\s\d\p{P}]+$/u.test(t); if (lang === 'ar' || lang === 'he' || lang === 'ur' || lang === 'fa') return /[\u0600-\u06ff\u0590-\u05ff]/.test(t); if (lang === 'ru' || lang === 'uk' || lang === 'be' || lang === 'bg') return /[\u0400-\u04ff]/.test(t); if (lang === 'th') return /[\u0e00-\u0e7f]/.test(t); return false; } function collectTextNodes(root) { const nodes = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (shouldSkip(node.parentElement)) return NodeFilter.FILTER_REJECT; const text = node.textContent.trim(); if (!text || 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 translating = false; let translatedCount = 0; function updateStatusBadge() { const badge = document.getElementById('tuBadge'); if (badge) { if (translating) { badge.textContent = '⟳'; badge.style.display = 'block'; badge.style.background = '#ff9800'; } else if (translatedCount > 0) { badge.textContent = translatedCount > 99 ? '99+' : translatedCount; badge.style.display = 'block'; badge.style.background = '#4caf50'; } else { badge.style.display = 'none'; } } } async function translatePage(root) { root = root || document.body; const textNodes = collectTextNodes(root); const placeholders = collectPlaceholders(root); if (textNodes.length === 0 && placeholders.length === 0) return; translating = true; updateStatusBadge(); // 文本节点翻译 if (textNodes.length > 0) { const texts = textNodes.map(n => n.textContent.trim()); const results = await batchTranslate(texts); for (let i = 0; i < textNodes.length; i++) { if (!results[i]) continue; const node = textNodes[i]; const parent = node.parentElement; if (!parent) continue; if (!parent.dataset.originalText) parent.dataset.originalText = node.textContent; parent.dataset.translated = '1'; node.textContent = results[i]; translatedCount++; } } // placeholder翻译 if (placeholders.length > 0) { const phTexts = placeholders.map(el => el.placeholder.trim()); const phResults = await batchTranslate(phTexts); for (let i = 0; i < placeholders.length; i++) { if (!phResults[i]) continue; placeholders[i].dataset.originalPlaceholder = placeholders[i].placeholder; placeholders[i].placeholder = phResults[i]; placeholders[i].dataset.translated = '1'; } } translating = false; updateStatusBadge(); } function restorePage() { document.querySelectorAll('[data-translated]').forEach(el => { if (el.dataset.originalText) { for (const child of el.childNodes) { if (child.nodeType === Node.TEXT_NODE) { child.textContent = el.dataset.originalText; break; } } delete el.dataset.originalText; } if (el.dataset.originalPlaceholder) { el.placeholder = el.dataset.originalPlaceholder; delete el.dataset.originalPlaceholder; } delete el.dataset.translated; }); translatedCount = 0; updateStatusBadge(); } // ── 滚动监听 ── let scrollTimer = null; let lastHeight = document.documentElement.scrollHeight; function onScroll() { if (scrollTimer) clearTimeout(scrollTimer); scrollTimer = setTimeout(() => { const h = document.documentElement.scrollHeight; if (h > lastHeight) { lastHeight = h; if (autoMode) translatePage(); } }, 800); } // ── MutationObserver ── let mutationTimer = null; const observer = new MutationObserver((mutations) => { if (!autoMode) return; if (mutationTimer) clearTimeout(mutationTimer); mutationTimer = setTimeout(() => { const roots = new Set(); for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE && !shouldSkip(node)) roots.add(node); } } roots.forEach(root => translatePage(root)); }, 1000); }); // ══════════════════════════════════════ // UI 界面 // ══════════════════════════════════════ GM_addStyle(` .translate-ui{position:fixed;bottom:20px;right:20px;z-index:999999;font-family:system-ui,-apple-system,sans-serif} .translate-ui *{box-sizing:border-box;margin:0;padding:0} .tu-btn{width:44px;height:44px;border-radius:50%;border:none;background:rgba(0,0,0,0.55);color:#fff;cursor:pointer; display:flex;align-items:center;justify-content:center;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px); box-shadow:0 2px 12px rgba(0,0,0,0.2);transition:transform .2s,background .2s;touch-action:manipulation;position:relative} .tu-btn:hover{transform:scale(1.05)} .tu-btn:active{transform:scale(0.92)} .tu-btn.active{background:rgba(34,128,255,0.85)} .tu-badge{position:absolute;top:-4px;right:-4px;min-width:18px;height:18px;border-radius:9px; background:#4caf50;color:#fff;font-size:10px;line-height:18px;text-align:center;padding:0 4px; font-weight:600;display:none;pointer-events:none} .tu-panel{position:absolute;bottom:54px;right:0;width:260px;max-height:70vh; background:rgba(255,255,255,0.97);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px); border-radius:14px;box-shadow:0 8px 32px rgba(0,0,0,0.15);padding:14px;display:none; color:#333;font-size:13px;overflow-y:auto} .tu-panel.show{display:block} .tu-panel label{display:block;margin:10px 0 4px;font-size:11px;color:#888; text-transform:uppercase;letter-spacing:.5px;font-weight:600} .tu-panel select{width:100%;padding:7px 10px;border:1px solid #e0e0e0;border-radius:8px; font-size:13px;background:#fff;color:#333;outline:none;appearance:auto;cursor:pointer} .tu-panel select:focus{border-color:#4a9eff;box-shadow:0 0 0 2px rgba(74,158,255,0.15)} .tu-panel select optgroup{font-weight:700;color:#333} .tu-panel select option{font-weight:400;color:#555;padding:2px 0} .tu-info{margin-top:8px;padding:8px 10px;background:rgba(74,158,255,0.08);border-radius:8px; font-size:11px;color:#666;line-height:1.5} .tu-info b{color:#4a9eff} .tu-row{display:flex;gap:6px;margin-top:10px} .tu-row button{flex:1;padding:8px 0;border:none;border-radius:8px;font-size:12px; cursor:pointer;transition:all .2s;touch-action:manipulation;font-weight:500} .tu-row .tu-restore{background:#f0f0f0;color:#555} .tu-row .tu-restore:hover{background:#e4e4e4} .tu-row .tu-restore:active{background:#d5d5d5} .tu-row .tu-go{background:#4a9eff;color:#fff} .tu-row .tu-go:hover{background:#3d8ce6} .tu-row .tu-go:active{background:#3080dd} .tu-row .tu-exclude{background:#ff6b6b;color:#fff;font-size:11px} .tu-row .tu-exclude:hover{background:#e55} .tu-chk{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:12px;color:#666;cursor:pointer} .tu-chk input{cursor:pointer;accent-color:#4a9eff} @media(prefers-color-scheme:dark){ .tu-panel{background:rgba(28,28,30,0.97);color:#eee} .tu-panel select{background:#2a2a2e;color:#eee;border-color:#444} .tu-panel select:focus{border-color:#4a9eff} .tu-panel select optgroup{color:#ddd} .tu-panel select option{color:#bbb} .tu-info{background:rgba(74,158,255,0.12);color:#aaa} .tu-row .tu-restore{background:#333;color:#ccc} .tu-chk{color:#aaa} } `); // 构建语言选择器HTML function buildLangOptions() { let html = ''; for (const [groupName, langs] of Object.entries(LANG_GROUPS)) { html += ``; for (const [code, name] of langs) { html += ``; } html += ''; } return html; } const ui = document.createElement('div'); ui.className = 'translate-ui'; ui.innerHTML = `
当前引擎: -
已翻译: 0 个节点
`; document.body.appendChild(ui); // ── UI事件绑定 ── const btn = document.getElementById('tuBtn'); const panel = document.getElementById('tuPanel'); const engineSelect = document.getElementById('tuEngine'); const langSelect = document.getElementById('tuLang'); const fallbackChk = document.getElementById('tuFallback'); const engineNameEl = document.getElementById('tuEngineName'); const countEl = document.getElementById('tuCount'); engineSelect.value = currentEngine; langSelect.value = targetLang; fallbackChk.checked = useGoogleFallback; function updateInfo() { const eng = Engine[currentEngine]; engineNameEl.textContent = eng ? eng.name : currentEngine; countEl.textContent = translatedCount; } updateInfo(); // 定期更新计数 setInterval(() => { countEl.textContent = translatedCount; }, 2000); btn.addEventListener('click', (e) => { e.stopPropagation(); panel.classList.toggle('show'); updateInfo(); }); document.addEventListener('click', (e) => { if (!ui.contains(e.target)) panel.classList.remove('show'); }); engineSelect.addEventListener('change', () => { currentEngine = engineSelect.value; GM_setValue('engine', currentEngine); cache.clear(); updateInfo(); }); langSelect.addEventListener('change', () => { targetLang = langSelect.value; GM_setValue('targetLang', targetLang); cache.clear(); }); fallbackChk.addEventListener('change', () => { useGoogleFallback = fallbackChk.checked; GM_setValue('useGoogleFallback', useGoogleFallback); }); document.getElementById('tuGo').addEventListener('click', async () => { panel.classList.remove('show'); btn.classList.add('active'); autoMode = true; GM_setValue('autoMode', true); restorePage(); cache.clear(); lastHeight = document.documentElement.scrollHeight; await translatePage(); }); document.getElementById('tuRestore').addEventListener('click', () => { panel.classList.remove('show'); btn.classList.remove('active'); autoMode = false; GM_setValue('autoMode', false); restorePage(); }); document.getElementById('tuExclude').addEventListener('click', () => { 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('🌐 翻译当前页面', () => translatePage()); GM_registerMenuCommand('↩️ 还原当前页面', () => restorePage()); GM_registerMenuCommand('⚙️ 切换引擎: Google TWP', () => { currentEngine = 'google'; GM_setValue('engine', 'google'); engineSelect.value = 'google'; cache.clear(); updateInfo(); }); GM_registerMenuCommand('⚙️ 切换引擎: Google Classic', () => { currentEngine = 'google_classic'; GM_setValue('engine', 'google_classic'); engineSelect.value = 'google_classic'; cache.clear(); updateInfo(); }); GM_registerMenuCommand('⚙️ 切换引擎: Microsoft', () => { currentEngine = 'microsoft'; GM_setValue('engine', 'microsoft'); engineSelect.value = 'microsoft'; cache.clear(); updateInfo(); }); GM_registerMenuCommand('⚙️ 切换引擎: Tencent', () => { currentEngine = 'tencent'; GM_setValue('engine', 'tencent'); engineSelect.value = 'tencent'; cache.clear(); updateInfo(); }); // ── 启动 ── function isPageInTargetLang() { const pageLang = (document.documentElement.lang || '').split('-')[0].toLowerCase(); const target = targetLang.split('-')[0].toLowerCase(); return pageLang === target; } window.addEventListener('scroll', onScroll, { passive: true }); observer.observe(document.body, { childList: true, subtree: true }); if (autoMode && !isPageInTargetLang()) { // 预先获取Google API Key if (currentEngine === 'google') { GoogleAuth.ensureAuth().then(() => { console.log('[TWP Auth] API Key ready:', GoogleAuth.apiKey ? '✓' : '✗'); }); } setTimeout(() => translatePage(), 1500); } })();