// ==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 += `';
}
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);
}
})();