// ==UserScript== // @name TransLite v26.1.6 // @namespace https://via.browser/ // @version 26.1.6.2 // @description TransLite: LLM批量翻译,MyMemory兜底,三指长按,可拖动按钮,HARDCODED全局配置 // @author rewwoxv. // @match *://*/* // @grant none // @run-at document_idle // ==/UserScript== (function() { 'use strict'; // ==================================================================================== // ★★★ 全局配置硬编码区 ★★★ // // 由于 Via 浏览器普通脚本无法使用 GM_setValue 跨域存储, // localStorage 受同源策略限制——切换域名后配置会重置。 // // 解决方案:将常用参数直接写在下方 HARDCODED 对象里, // 这些值在所有网页上作为"全局默认值"生效。 // // 网页内的"设置面板"保存的内容仅作为 当前域名的独立覆写(Override) —— // 切换到其他域名时,会回退到此处的硬编码值。 // ==================================================================================== var HARDCODED = { engine: 'llm', // 翻译引擎: 'llm' | 'mymemory' | 'baidu' | 'custom' apiBase: 'https://api.qnaigc.com/v1', // ← 在此填写 API Base URL(七牛云等) apiKey: '', // ← 在此填写你的 API Key(推荐硬编码,避免重复输入) model: 'z-ai/glm-4.5-air-free', // 模型名称 targetLang: 'Simplified Chinese', // 目标语言 translateMode: 'replace' // 'replace'=直接替换 / 'bilingual'=双语模式 // 其他参数请通过网页内的设置面板调整,保存后对当前域名生效 }; // ==================== 内部默认配置(不建议直接修改) ==================== var DEFAULT_CONFIG = Object.assign({ baiduAppId: '', baiduSecret: '', baiduFrom: 'auto', baiduTo: 'zh', customName: '', customApiUrl: '', customMethod: 'POST', customBodyTemplate: '', customHeaders: '', customResponsePath: '', customFrom: 'auto', customTo: 'zh', mmFrom: 'en', mmTo: 'zh', systemPrompt: 'You are a precise translator. I will give you multiple texts, each prefixed with [N] where N is a number. Translate ALL of them into {{targetLang}}. Output each translation on its own line, prefixed with the SAME [N] marker. Do NOT add any extra text, explanations, or notes. Output ONLY [N] translation lines. Example:\nInput:\n[0] Hello world\n[1] Good morning\nOutput:\n[0] 你好世界\n[1] 早上好', proxyPrefix: '', enableThinking: false, llmTemperature: 0.3, llmMaxTokens: 8192, llmTimeout: 60, btnColor: '#2563eb', btnSize: 46, btnOpacity: 0.9, toastDuration: 2500, enableThreeFingerLongPress: true, enableFloatBtn: false, threeFingerLongPressMs: 500, maxBatchChars: 5000, maxBatchParagraphs: 15, batchDelayMs: 3000, maxRetries: 3, retryTimes: 2, retryDelayBase: 2000, settingsScope: 'global', whitelistPages: [], blacklistPages: [], autoTranslate: false, settingsBtnY: 70, settingsBtnSide: 'right' }, HARDCODED); // HARDCODED 的值覆盖上面的同名字段 // ==================== 设置作用域管理 ==================== var GLOBAL_KEY = 'llm_translator_config'; var SCOPE_KEY = 'llm_translator_scope'; function getHostname() { try { return window.location.hostname; } catch(e) { return 'unknown'; } } function getScopeInfo() { try { var raw = localStorage.getItem(SCOPE_KEY); if (raw) { var s = JSON.parse(raw); return { settingsScope: s.settingsScope || 'global', whitelistPages: s.whitelistPages || [], blacklistPages: s.blacklistPages || [] }; } } catch(e) {} return { settingsScope: 'global', whitelistPages: [], blacklistPages: [] }; } function saveScopeInfo(scopeInfo) { try { localStorage.setItem(SCOPE_KEY, JSON.stringify(scopeInfo)); } catch(e) {} } function getConfigKey() { var si = getScopeInfo(); var hn = getHostname(); switch (si.settingsScope) { case 'perPage': return GLOBAL_KEY + '_' + hn; case 'whitelist': if (si.whitelistPages.indexOf(hn) !== -1) return GLOBAL_KEY + '_' + hn; return GLOBAL_KEY; case 'blacklist': if (si.blacklistPages.indexOf(hn) !== -1) return GLOBAL_KEY + '_' + hn; return GLOBAL_KEY; default: // 'global' return GLOBAL_KEY; } } /** * saveConfig: * ① 始终写入 GLOBAL_KEY(全局主副本,跨域读回时有兜底) * ② 若 scope 是 perPage/whitelist/blacklist 且当前域名命中,额外写入 per-page key * * 注:localStorage 受同源策略限制,切换到完全不同的 origin 后 * 仍需在 HARDCODED 里预写 apiKey 等高频字段才能真正全局生效。 */ function saveConfig(cfg) { var configJson = JSON.stringify(cfg); var hn = getHostname(); var scope = cfg.settingsScope || 'global'; var ppKey = GLOBAL_KEY + '_' + hn; // 计算是否需要写 per-page key var writePerPage = false; if (scope === 'perPage') { writePerPage = true; } else if (scope === 'whitelist' && (cfg.whitelistPages || []).indexOf(hn) !== -1) { writePerPage = true; } else if (scope === 'blacklist' && (cfg.blacklistPages || []).indexOf(hn) !== -1) { writePerPage = true; } try { localStorage.setItem(GLOBAL_KEY, configJson); // ★ 始终写全局主副本 if (writePerPage) { localStorage.setItem(ppKey, configJson); } else { // 清理当前域名可能残留的 per-page 覆写,避免混淆 try { localStorage.removeItem(ppKey); } catch(e2) {} } } catch(e) { console.warn('[LLM翻译] 无法保存配置:', e); } saveScopeInfo({ settingsScope: scope, whitelistPages: cfg.whitelistPages || [], blacklistPages: cfg.blacklistPages || [] }); } function loadConfig() { var key = getConfigKey(); var cfg = null; try { var saved = localStorage.getItem(key); if (saved) cfg = JSON.parse(saved); } catch(e) { console.warn('[LLM翻译] 读取配置失败:', e); } if (!cfg && key !== GLOBAL_KEY) { try { var gSaved = localStorage.getItem(GLOBAL_KEY); if (gSaved) cfg = JSON.parse(gSaved); } catch(e) {} } // 【修复合并顺序】:默认配置 < HARDCODED全局兜底 < 当前页面UI保存的配置 var finalConfig = Object.assign({}, DEFAULT_CONFIG, HARDCODED, cfg || {}); // 【保护机制】:特殊处理,如果 localStorage 中存在空值(例如之前未填写的 API Key),强制使用 HARDCODED 中的值 if (HARDCODED.apiKey && !finalConfig.apiKey) finalConfig.apiKey = HARDCODED.apiKey; if (HARDCODED.apiBase && !finalConfig.apiBase) finalConfig.apiBase = HARDCODED.apiBase; if (HARDCODED.model && !finalConfig.model) finalConfig.model = HARDCODED.model; return finalConfig; } var config = loadConfig(); // ==================== 状态管理 ==================== var translating = false; var progressEl = null; var backupMap = null; var bilingualElements = []; // ==================== MD5 工具 ==================== function md5(string) { function md5cycle(x, k) { var a = x[0], b = x[1], c = x[2], d = x[3]; a = ff(a, b, c, d, k[0], 7, -680876936); d = ff(d, a, b, c, k[1], 12, -389564586); c = ff(c, d, a, b, k[2], 17, 606105819); b = ff(b, c, d, a, k[3], 22, -1044525330); a = ff(a, b, c, d, k[4], 7, -176418897); d = ff(d, a, b, c, k[5], 12, 1200080426); c = ff(c, d, a, b, k[6], 17, -1473231341); b = ff(b, c, d, a, k[7], 22, -45705983); a = ff(a, b, c, d, k[8], 7, 1770035416); d = ff(d, a, b, c, k[9], 12, -1958414417); c = ff(c, d, a, b, k[10], 17, -42063); b = ff(b, c, d, a, k[11], 22, -1990404162); a = ff(a, b, c, d, k[12], 7, 1804603682); d = ff(d, a, b, c, k[13], 12, -403257723); c = ff(c, d, a, b, k[14], 17, 1236535329); b = ff(b, c, d, a, k[15], 22, -374384042); a = gg(a, b, c, d, k[1], 5, -168207062); d = gg(d, a, b, c, k[6], 9, -1069501632); c = gg(c, d, a, b, k[11], 14, 643717713); b = gg(b, c, d, a, k[0], 20, -373897302); a = gg(a, b, c, d, k[5], 5, -701558691); d = gg(d, a, b, c, k[10], 9, 38016083); c = gg(c, d, a, b, k[15], 14, -660478335); b = gg(b, c, d, a, k[4], 20, -405537848); a = gg(a, b, c, d, k[9], 5, 568446438); d = gg(d, a, b, c, k[14], 9, -1019803690); c = gg(c, d, a, b, k[3], 14, -187363961); b = gg(b, c, d, a, k[8], 20, 1163531501); a = gg(a, b, c, d, k[13], 5, -1444681467); d = gg(d, a, b, c, k[2], 9, -51403784); c = gg(c, d, a, b, k[7], 14, 1735328473); b = gg(b, c, d, a, k[12], 20, -1926607734); a = hh(a, b, c, d, k[5], 4, -378558); d = hh(d, a, b, c, k[8], 11, -2022574463); c = hh(c, d, a, b, k[11], 16, 1839030562); b = hh(b, c, d, a, k[14], 23, -35309556); a = hh(a, b, c, d, k[1], 4, -1530992060); d = hh(d, a, b, c, k[4], 11, 1272893353); c = hh(c, d, a, b, k[7], 16, -155497632); b = hh(b, c, d, a, k[10], 23, -1094730640); a = hh(a, b, c, d, k[13], 4, 681279174); d = hh(d, a, b, c, k[0], 11, -358537222); c = hh(c, d, a, b, k[3], 16, -722521979); b = hh(b, c, d, a, k[6], 23, 76029189); a = hh(a, b, c, d, k[9], 4, -640364487); d = hh(d, a, b, c, k[12], 11, -421815835); c = hh(c, d, a, b, k[15], 16, 530742520); b = hh(b, c, d, a, k[2], 23, -995338651); a = ii(a, b, c, d, k[0], 6, -198630844); d = ii(d, a, b, c, k[7], 10, 1126891415); c = ii(c, d, a, b, k[14], 15, -1416354905); b = ii(b, c, d, a, k[5], 21, -57434055); a = ii(a, b, c, d, k[12], 6, 1700485571); d = ii(d, a, b, c, k[3], 10, -1894986606); c = ii(c, d, a, b, k[10], 15, -1051523); b = ii(b, c, d, a, k[1], 21, -2054922799); a = ii(a, b, c, d, k[8], 6, 1873313359); d = ii(d, a, b, c, k[15], 10, -30611744); c = ii(c, d, a, b, k[6], 15, -1560198380); b = ii(b, c, d, a, k[13], 21, 1309151649); a = ii(a, b, c, d, k[4], 6, -145523070); d = ii(d, a, b, c, k[11], 10, -1120210379); c = ii(c, d, a, b, k[2], 15, 718787259); b = ii(b, c, d, a, k[9], 21, -343485551); x[0] = add32(a, x[0]); x[1] = add32(b, x[1]); x[2] = add32(c, x[2]); x[3] = add32(d, x[3]); } function cmn(q, a, b, x, s, t) { a = add32(add32(a, q), add32(x, t)); return add32((a << s) | (a >>> (32 - s)), b); } function ff(a, b, c, d, x, s, t) { return cmn((b & c) | ((~b) & d), a, b, x, s, t); } function gg(a, b, c, d, x, s, t) { return cmn((b & d) | (c & (~d)), a, b, x, s, t); } function hh(a, b, c, d, x, s, t) { return cmn(b ^ c ^ d, a, b, x, s, t); } function ii(a, b, c, d, x, s, t) { return cmn(c ^ (b | (~d)), a, b, x, s, t); } function md5blk(s) { var md5blks = [], i; for (i = 0; i < 64; i += 4) { md5blks[i >> 2] = s.charCodeAt(i) + (s.charCodeAt(i+1) << 8) + (s.charCodeAt(i+2) << 16) + (s.charCodeAt(i+3) << 24); } return md5blks; } var hex_chr = '0123456789abcdef'.split(''); function rhex(n) { var s = '', j = 0; for (; j < 4; j++) s += hex_chr[(n >> (j * 8 + 4)) & 0x0F] + hex_chr[(n >> (j * 8)) & 0x0F]; return s; } function hex(x) { for (var i = 0; i < x.length; i++) x[i] = rhex(x[i]); return x.join(''); } function add32(a, b) { return (a + b) & 0xFFFFFFFF; } function md5str(s) { var n = s.length, state = [1732584193, -271733879, -1732584194, 271733878], i; for (i = 64; i <= n; i += 64) { md5cycle(state, md5blk(s.substring(i - 64, i))); } s = s.substring(i - 64); var tail = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]; for (i = 0; i < s.length; i++) tail[i >> 2] |= s.charCodeAt(i) << ((i % 4) << 3); tail[i >> 2] |= 0x80 << ((i % 4) << 3); if (i > 55) { md5cycle(state, tail); tail = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]; } tail[14] = n * 8; md5cycle(state, tail); return state; } return hex(md5str(string)); } // ==================== HTML/属性 转义 ==================== function escAttr(s) { if (!s) return ''; return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); } function escHtml(s) { if (!s) return ''; return String(s).replace(/&/g, '&').replace(//g, '>'); } // ==================== 统一的翻译入口 ==================== var hasTranslated = false; async function doTranslate() { if (translating) return; if (config.engine === 'llm' || !config.engine || config.engine === 'google') { if (!config.apiKey) { showToast('需要填写 API Key。请去七牛云官网 https://www.qiniu.com/ai/agent 或微信小程序免费申请', 'warn'); return; } if (!config.model) config.model = 'z-ai/glm-4.5-air-free'; } if (config.engine === 'baidu') { if (!config.baiduAppId || !config.baiduSecret) { showToast('百度翻译需要填写 APP ID 和密钥,请进入设置', 'warn'); return; } } if (config.engine === 'custom') { if (!config.customApiUrl) { showToast('自定义翻译平台需要填写 API 地址,请进入设置', 'warn'); return; } if (!config.customResponsePath) { showToast('自定义翻译平台需要填写响应结果提取路径', 'warn'); return; } } translating = true; updateBtnState('\u23F3', '#d97706'); try { if (config.translateMode === 'bilingual') { await translateBilingual(); } else { await translateReplace(); } updateBtnState('\u2705', '#16a34a'); hasTranslated = true; showToast('翻译完成', 'success'); } catch(err) { console.error('[LLM翻译]', err); updateBtnState('\u274C', '#dc2626'); showToast('翻译出错:' + err.message, 'error'); } finally { setTimeout(resetBtnState, 2000); translating = false; } } // ==================== UI:悬浮翻译按钮 ==================== var btn = null; function createFloatBtn() { if (btn) return btn; btn = document.createElement('div'); btn.id = 'llm-translator-btn'; btn.title = '点击翻译'; btn.style.cssText = 'position:fixed;bottom:80px;right:20px;z-index:99998;' + 'width:' + (config.btnSize || 46) + 'px;height:' + (config.btnSize || 46) + 'px;line-height:' + (config.btnSize || 46) + 'px;text-align:center;' + 'background:' + (config.btnColor || '#2563eb') + ';color:#fff;border-radius:50%;' + 'font-size:20px;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.3);' + 'user-select:none;-webkit-user-select:none;' + 'transition:transform .2s,background .2s;opacity:' + (config.btnOpacity || 0.9) + ';'; btn.textContent = '\uD83C\uDF10'; btn.addEventListener('click', function() { doTranslate(); }); return btn; } function removeFloatBtn() { if (btn && btn.parentNode) { btn.parentNode.removeChild(btn); btn = null; } } function updateBtnState(icon, color) { if (btn) { btn.textContent = icon; btn.style.background = color; } } function resetBtnState() { if (btn) { btn.textContent = '\uD83C\uDF10'; btn.style.background = config.btnColor || '#2563eb'; } } function applyFloatBtn() { if (config.enableFloatBtn) { var b = createFloatBtn(); if (document.body && !b.parentNode) document.body.appendChild(b); } else { removeFloatBtn(); } } // ==================== UI:可拖动吸附设置按钮 ★ 新设计 ★ ==================== var settingsBtn = null; // 圆形齿轮按钮 var settingsBtnTab = null; // 吸附后的窄条 var settingsState = 'expanded'; // 'expanded' | 'tab' | 'dragging' var settingsSnapSide = 'right'; // 'left' | 'right' var settingsY = 0; // 绝对 px 位置(top) var settingsDragOffsetY = 0; // 拖动偏移 var settingsDragStartY = 0; // 拖动起始 Y var settingsSnapTimeout = null; var settingsWasDragged = false; var settingsTapJustOpened = false; // 防止 endSettingsDrag 和 click 重复打开 var DRAG_THRESHOLD = 5; // 移动超过 5px 才算拖拽 function loadSettingsBtnPosition() { settingsSnapSide = config.settingsBtnSide || 'right'; settingsY = (config.settingsBtnY || 70) / 100 * window.innerHeight; } function saveSettingsBtnPosition() { config.settingsBtnSide = settingsSnapSide; config.settingsBtnY = Math.round(settingsY / window.innerHeight * 100); saveConfig(config); } function createSettingsBtnElements() { // 圆形齿轮按钮 settingsBtn = document.createElement('div'); settingsBtn.id = 'llm-translator-settings-btn'; settingsBtn.title = '翻译设置'; settingsBtn.style.cssText = 'position:fixed;z-index:100000;' + 'width:36px;height:36px;line-height:36px;text-align:center;' + 'background:rgba(80,80,80,0.8);color:#fff;border-radius:50%;' + 'font-size:18px;cursor:pointer;box-shadow:0 2px 10px rgba(0,0,0,.35);' + 'user-select:none;-webkit-user-select:none;' + 'transition:opacity .25s;opacity:1;'; settingsBtn.textContent = '\u2699\uFE0F'; // 吸附窄条(触发展开) settingsBtnTab = document.createElement('div'); settingsBtnTab.id = 'llm-translator-settings-tab'; settingsBtnTab.style.cssText = 'position:fixed;z-index:100000;' + 'width:8px;height:44px;' + 'background:rgba(80,80,80,0.55);' + 'border-radius:0 6px 6px 0;' + 'cursor:pointer;' + 'box-shadow:0 2px 8px rgba(0,0,0,.2);' + 'user-select:none;-webkit-user-select:none;' + 'transition:background .2s,width .2s;'; settingsBtnTab.textContent = ''; } function updateSettingsBtnAppearance() { if (!settingsBtn || !settingsBtnTab) return; if (settingsState === 'tab') { settingsBtn.style.display = 'none'; settingsBtnTab.style.display = 'block'; // 定位窄条到边缘 if (settingsSnapSide === 'left') { settingsBtnTab.style.left = '0px'; settingsBtnTab.style.right = 'auto'; settingsBtnTab.style.borderRadius = '0 6px 6px 0'; } else { settingsBtnTab.style.right = '0px'; settingsBtnTab.style.left = 'auto'; settingsBtnTab.style.borderRadius = '6px 0 0 6px'; } settingsBtnTab.style.top = Math.max(0, Math.min(settingsY - 22, window.innerHeight - 44)) + 'px'; } else { settingsBtnTab.style.display = 'none'; settingsBtn.style.display = 'block'; settingsBtn.style.opacity = '1'; if (settingsSnapSide === 'left') { settingsBtn.style.left = '4px'; settingsBtn.style.right = 'auto'; } else { settingsBtn.style.right = '4px'; settingsBtn.style.left = 'auto'; } settingsBtn.style.top = Math.max(4, Math.min(settingsY - 18, window.innerHeight - 40)) + 'px'; } } function expandSettingsFromTab(e) { if (e) e.preventDefault(); if (settingsState === 'expanded') return; settingsState = 'expanded'; updateSettingsBtnAppearance(); scheduleSettingsSnapBack(); } function scheduleSettingsSnapBack() { if (settingsSnapTimeout) clearTimeout(settingsSnapTimeout); settingsSnapTimeout = setTimeout(function() { if (settingsState === 'expanded') { settingsState = 'tab'; updateSettingsBtnAppearance(); } }, 3500); } // 鼠标/触摸拖动 function startSettingsDrag(e) { if (e.type === 'touchstart') { if (e.touches.length > 1) return; // 多指不处理 } e.preventDefault(); settingsState = 'dragging'; settingsWasDragged = false; settingsTapJustOpened = false; if (settingsSnapTimeout) { clearTimeout(settingsSnapTimeout); settingsSnapTimeout = null; } var clientY = e.touches ? e.touches[0].clientY : e.clientY; settingsDragStartY = clientY; settingsDragOffsetY = clientY - settingsY; settingsBtn.style.transition = 'none'; settingsBtn.style.opacity = '0.85'; settingsBtn.style.display = 'block'; settingsBtnTab.style.display = 'none'; document.addEventListener('mousemove', moveSettingsDrag, {passive: false}); document.addEventListener('mouseup', endSettingsDrag, {once: true}); document.addEventListener('touchmove', moveSettingsDrag, {passive: false}); document.addEventListener('touchend', endSettingsDrag, {once: true}); document.addEventListener('touchcancel', endSettingsDrag, {once: true}); } function moveSettingsDrag(e) { if (settingsState !== 'dragging') return; e.preventDefault(); var clientY = e.touches ? e.touches[0].clientY : e.clientY; // 超过阈值才算真正拖拽 if (Math.abs(clientY - settingsDragStartY) > DRAG_THRESHOLD) { settingsWasDragged = true; } if (!settingsWasDragged) return; // 还没到阈值,不移动 settingsY = Math.max(4, Math.min(clientY - settingsDragOffsetY, window.innerHeight - 40)); settingsBtn.style.top = settingsY - 18 + 'px'; // 在拖动到边缘附近时临时显示吸附方向 var clientX = e.touches ? e.touches[0].clientX : e.clientX; var nearLeft = clientX < window.innerWidth * 0.3; settingsBtn.style.left = nearLeft ? '4px' : 'auto'; settingsBtn.style.right = nearLeft ? 'auto' : '4px'; } function endSettingsDrag(e) { document.removeEventListener('mousemove', moveSettingsDrag); document.removeEventListener('touchmove', moveSettingsDrag); if (settingsState !== 'dragging') return; // 如果没真正拖拽(仅单击),直接打开设置面板 if (!settingsWasDragged) { settingsState = 'expanded'; settingsBtn.style.transition = 'opacity .25s'; updateSettingsBtnAppearance(); scheduleSettingsSnapBack(); // 标记已处理,防止后续 click 事件二次触发 settingsTapJustOpened = true; setTimeout(function() { settingsTapJustOpened = false; }, 300); // 延迟打开设置,避免 touch 序列干扰 setTimeout(function() { openSettingsModal(); }, 50); return; } // 决定吸附边 var btnRect = settingsBtn.getBoundingClientRect(); var centerX = btnRect.left + btnRect.width / 2; settingsSnapSide = (centerX < window.innerWidth / 2) ? 'left' : 'right'; // 吸附到边缘 + 窄条模式 settingsState = 'tab'; settingsBtn.style.transition = 'opacity .25s'; updateSettingsBtnAppearance(); saveSettingsBtnPosition(); scheduleSettingsSnapBack(); } // 设置按钮的点击处理 function onSettingsBtnTap(e) { if (settingsWasDragged) return; e.stopPropagation(); e.preventDefault(); openSettingsModal(); scheduleSettingsSnapBack(); } function setupSettingsButton() { createSettingsBtnElements(); loadSettingsBtnPosition(); // 默认窄条模式 settingsState = 'tab'; // 绑定事件 settingsBtn.addEventListener('touchstart', startSettingsDrag, {passive: false}); settingsBtn.addEventListener('mousedown', startSettingsDrag); settingsBtn.addEventListener('click', function(e) { // 如果 endSettingsDrag 已处理过(触摸单击),跳过 if (settingsTapJustOpened) return; if (settingsWasDragged) { settingsWasDragged = false; return; } onSettingsBtnTap(e); }); settingsBtnTab.addEventListener('click', expandSettingsFromTab); settingsBtnTab.addEventListener('touchstart', function(e) { e.preventDefault(); // 不启动拖动,直接展开 }, {passive: false}); settingsBtnTab.addEventListener('touchend', function(e) { e.preventDefault(); expandSettingsFromTab(e); }); } function injectUI() { if (!document.body) { setTimeout(injectUI, 100); return; } applyFloatBtn(); if (!settingsBtn) setupSettingsButton(); if (settingsBtn && !settingsBtn.parentNode) document.body.appendChild(settingsBtn); if (settingsBtnTab && !settingsBtnTab.parentNode) document.body.appendChild(settingsBtnTab); updateSettingsBtnAppearance(); } injectUI(); // ==================== Toast 提示 ==================== function showToast(msg, type) { var colors = { success: '#16a34a', warn: '#d97706', error: '#dc2626', info: '#2563eb' }; var toast = document.createElement('div'); toast.textContent = msg; toast.style.cssText = 'position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:100002;' + 'background:' + (colors[type] || colors.info) + ';color:#fff;' + 'padding:8px 20px;border-radius:20px;font-size:14px;' + 'font-family:system-ui,sans-serif;white-space:nowrap;' + 'box-shadow:0 4px 12px rgba(0,0,0,.25);' + 'opacity:0;transition:opacity .3s;pointer-events:none;'; if (!document.body) return; document.body.appendChild(toast); if (window.requestAnimationFrame) { window.requestAnimationFrame(function() { toast.style.opacity = '1'; }); } else { toast.style.opacity = '1'; } setTimeout(function() { toast.style.opacity = '0'; setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300); }, config.toastDuration || 2500); } // ==================== 三指长按翻译(稳健版·防抖触控状态机) ==================== // 参考沉浸式翻译脚本的事件队列钩子机制: // 1. 使用 activePoints (Object) 按 identifier 累积触控点,解决手指分批到达问题 // 2. 仅在恰好三指时启动定时器,四指以上直接打断 // 3. touchmove 中使用 50px 宽松阈值(分轴判定),容忍光滑屏幕微小滑动 // 4. touchcancel 妥善处理,只清理触控点而不强行中断脚本 var touchStatus = { timer: null, activePoints: {}, // { [identifier]: {x, y} } triggered: false }; function registerThreeFingerLongPress(target) { if (!target || !target.addEventListener) return; // 核心逻辑:使用事件委托,降低对单一目标的依赖 target.addEventListener('touchstart', function(e) { if (!config.enableThreeFingerLongPress) return; // 记录当前所有触控点 for (var i = 0; i < e.changedTouches.length; i++) { var t = e.changedTouches[i]; touchStatus.activePoints[t.identifier] = { x: t.clientX, y: t.clientY }; } var count = Object.keys(touchStatus.activePoints).length; // 仅在刚好三指时开启计时 if (count === 3 && !touchStatus.triggered) { touchStatus.timer = setTimeout(function() { touchStatus.triggered = true; doTranslate(); }, config.threeFingerLongPressMs || 500); } else if (count > 3) { // 四指以上直接失效 clearTimeout(touchStatus.timer); } }, { passive: true }); var clearTouch = function(e) { for (var i = 0; i < e.changedTouches.length; i++) { delete touchStatus.activePoints[e.changedTouches[i].identifier]; } if (Object.keys(touchStatus.activePoints).length < 3) { clearTimeout(touchStatus.timer); touchStatus.triggered = false; } }; target.addEventListener('touchend', clearTouch, { passive: true }); target.addEventListener('touchcancel', clearTouch, { passive: true }); // 移动检测,增加容错,避免抖动导致长按取消 target.addEventListener('touchmove', function(e) { if (!touchStatus.timer) return; for (var i = 0; i < e.changedTouches.length; i++) { var t = e.changedTouches[i]; var start = touchStatus.activePoints[t.identifier]; if (start && (Math.abs(t.clientX - start.x) > 50 || Math.abs(t.clientY - start.y) > 50)) { clearTimeout(touchStatus.timer); touchStatus.timer = null; } } }, { passive: true }); } registerThreeFingerLongPress(document); // ==================== 自动翻译:检测非目标语言页面 ★ 新功能 ★ ==================== function getPageLang() { // 方法1: var htmlEl = document.documentElement; var lang = (htmlEl.getAttribute('lang') || htmlEl.getAttribute('xml:lang') || '').toLowerCase(); if (lang) { var dash = lang.indexOf('-'); return dash > 0 ? lang.slice(0, dash) : lang; } // 方法2:meta 标签 var meta = document.querySelector('meta[name="language"], meta[http-equiv="content-language"]'); if (meta) { var ml = (meta.getAttribute('content') || '').toLowerCase(); if (ml) { var d = ml.indexOf('-'); return d > 0 ? ml.slice(0, d) : ml; } } // 方法3:文字特征分析 var sample = ''; try { sample = (document.body ? document.body.innerText : '').slice(0, 600); } catch(e) {} sample = sample.replace(/\s/g, ''); if (!sample || sample.length < 20) return 'en'; var cjk = (sample.match(/[\u4e00-\u9fff\u3400-\u4dbf\uF900-\uFAFF]/g) || []).length; var jp = (sample.match(/[\u3040-\u309f\u30a0-\u30ff]/g) || []).length; var ko = (sample.match(/[\uac00-\ud7af\u1100-\u11ff]/g) || []).length; var total = sample.length; if (cjk / total > 0.2) return 'zh'; if (jp / total > 0.15) return 'ja'; if (ko / total > 0.15) return 'ko'; return 'en'; } function isTargetLang(pageLang) { var target = (config.targetLang || 'Simplified Chinese').toLowerCase(); if (target.indexOf('chinese') !== -1 || target.indexOf('中文') !== -1 || target.indexOf('简体') !== -1) { return pageLang === 'zh'; } if (target.indexOf('japanese') !== -1 || target.indexOf('日文') !== -1 || target.indexOf('日语') !== -1) { return pageLang === 'ja'; } if (target.indexOf('korean') !== -1 || target.indexOf('韩文') !== -1 || target.indexOf('韩语') !== -1) { return pageLang === 'ko'; } if (target.indexOf('english') !== -1 || target.indexOf('英文') !== -1 || target.indexOf('英语') !== -1) { return pageLang === 'en'; } // 默认:目标是中文,非中文才翻译 return pageLang === 'zh'; } function checkAutoTranslate() { if (!config.autoTranslate) return; setTimeout(function() { if (translating || hasTranslated) return; var pageLang = getPageLang(); if (!isTargetLang(pageLang)) { showToast('检测到非目标语言 (' + pageLang + '),自动翻译中...', 'info'); doTranslate(); } }, 1200); } // ==================== 动态内容监听 ==================== var mutationObserver = null; var mutationDebounceTimer = null; function startMutationObserver() { if (mutationObserver || !window.MutationObserver) return; mutationObserver = new MutationObserver(function(mutations) { var addedNodes = 0; for (var m = 0; m < mutations.length; m++) { addedNodes += mutations[m].addedNodes.length; } if (addedNodes > 3 && hasTranslated) { if (mutationDebounceTimer) clearTimeout(mutationDebounceTimer); mutationDebounceTimer = setTimeout(function() { showToast('检测到新内容加载,可再次点击翻译按钮', 'info'); }, 1500); } }); mutationObserver.observe(document.documentElement, { childList: true, subtree: true }); } if (document.body) { startMutationObserver(); } else { document.addEventListener('DOMContentLoaded', startMutationObserver); } // ==================== 设置弹窗(更新:作用域 + 七牛云介绍) ==================== var modalOverlay = null; function openSettingsModal() { if (modalOverlay) { closeSettingsModal(); return; } var cfg = loadConfig(); // 展开设置按钮以便操作 if (settingsState === 'tab') { settingsState = 'expanded'; updateSettingsBtnAppearance(); } if (settingsSnapTimeout) { clearTimeout(settingsSnapTimeout); settingsSnapTimeout = null; } modalOverlay = document.createElement('div'); modalOverlay.id = 'llm-translator-modal'; modalOverlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;z-index:999999;' + 'background:rgba(0,0,0,0.5);display:flex;align-items:flex-start;justify-content:center;' + 'overflow-y:auto;padding:12px 8px 40px;box-sizing:border-box;'; var panel = document.createElement('div'); panel.style.cssText = 'background:#f3f4f6;border-radius:16px;padding:16px;width:100%;max-width:480px;' + 'font-family:system-ui,-apple-system,sans-serif;color:#1f2937;box-sizing:border-box;' + 'box-shadow:0 8px 32px rgba(0,0,0,0.3);'; panel.innerHTML = buildSettingsHTML(cfg); modalOverlay.appendChild(panel); modalOverlay.addEventListener('click', function(e) { if (e.target === modalOverlay) closeSettingsModal(); }); document.body.appendChild(modalOverlay); bindSettingsEvents(cfg); } function closeSettingsModal() { if (modalOverlay && modalOverlay.parentNode) { modalOverlay.parentNode.removeChild(modalOverlay); } modalOverlay = null; // 关闭后恢复窄条 settingsState = 'tab'; updateSettingsBtnAppearance(); scheduleSettingsSnapBack(); } function buildSettingsHTML(cfg) { var engineLLM = cfg.engine === 'llm' ? ' selected' : ''; var engineMyMemory = cfg.engine === 'mymemory' ? ' selected' : ''; var engineBaidu = cfg.engine === 'baidu' ? ' selected' : ''; var engineCustom = cfg.engine === 'custom' ? ' selected' : ''; var modeReplace = cfg.translateMode === 'replace' ? ' selected' : ''; var modeBilingual = cfg.translateMode === 'bilingual' ? ' selected' : ''; var scopeGlobal = cfg.settingsScope === 'global' ? ' selected' : ''; var scopePerPage = cfg.settingsScope === 'perPage' ? ' selected' : ''; var scopeWhitelist = cfg.settingsScope === 'whitelist' ? ' selected' : ''; var scopeBlacklist = cfg.settingsScope === 'blacklist' ? ' selected' : ''; var threeFingerChecked = cfg.enableThreeFingerLongPress ? 'checked' : ''; var floatBtnChecked = cfg.enableFloatBtn ? 'checked' : ''; var autoTransChecked = cfg.autoTranslate ? 'checked' : ''; var llmSectionDisplay = (cfg.engine === 'llm' || !cfg.engine) ? 'block' : 'none'; var mmSectionDisplay = cfg.engine === 'mymemory' ? 'block' : 'none'; var baiduSectionDisplay = cfg.engine === 'baidu' ? 'block' : 'none'; var customSectionDisplay = cfg.engine === 'custom' ? 'block' : 'none'; var whitelistDisplay = cfg.settingsScope === 'whitelist' ? 'block' : 'none'; var blacklistDisplay = cfg.settingsScope === 'blacklist' ? 'block' : 'none'; var whitelistVal = (cfg.whitelistPages || []).join(', '); var blacklistVal = (cfg.blacklistPages || []).join(', '); return '' + '
' + '

⚙ 翻译设置 v12.0

' + // ★ 新增:设置作用域 '
' + '
设置作用域
' + '' + '
' + '' + '

仅白名单中的域名拥有独立设置,其余共用全局设置。

' + '
' + '' + '

黑名单中的域名不继承全局设置,须单独配置。

' + '
' + '
' + '
翻译引擎
' + '
' + '
' + '
LLM 配置(OpenAI 兼容,默认七牛云免费)
' + // ★ 更新:七牛云介绍文案 + 超链接 '

推荐:七牛云免费 API,新用户免费发放 200万 tokens 额度,含多款免费模型(glm-4.5-air-free 等)。去 七牛云官网 或微信小程序"七牛云"免费申请 Key。

' + '' + '' + '' + '' + '' + '
' + '
' + '
' + '
' + '' + '
模型思考模式 (reasoning)' + '
' + '
' + '
' + '
MyMemory 翻译配置
' + '
' + '
' + '
' + '
' + '

完全免费,无需注册,支持 ISO 639-1 语言代码。每日约1000字符限额。

' + '
' + '
' + '
百度翻译配置
' + '

申请地址:https://api.fanyi.baidu.com

' + '' + '' + '
' + '
' + '
' + '
' + '
' + '
自定义翻译平台
' + '' + '' + '' + '' + '' + '' + '
' + '
' + '
' + '
' + '
' + '
翻译模式
' + '
' + // ★ 新增:自动翻译开关 '
' + '
自动翻译
' + '
进入非目标语言网页时自动翻译' + '
' + '

开启后,打开非本语言网页时将自动触发翻译。默认关闭。

' + '
' + '
' + '
界面与快捷操作
' + '
' + '
' + '
' + '
' + '' + '
三指长按手势翻译(0.5秒)' + '
' + '
悬浮翻译按钮(右下角 🌐)' + '
' + '

设置按钮(⚙️)可拖动到屏幕边缘,松开后自动吸附为窄条。

' + '
' + '
' + '' + '' + '
' + '' + '
'; } function bindSettingsEvents(cfg) { var engineSel = document.getElementById('ls-engine'); if (engineSel) { engineSel.addEventListener('change', function() { var v = this.value; var secMap = { llm:'ls-llm-sec', mymemory:'ls-mm-sec', baidu:'ls-baidu-sec', custom:'ls-custom-sec' }; Object.keys(secMap).forEach(function(k) { var el = document.getElementById(secMap[k]); if (el) el.style.display = (k === v) ? 'block' : 'none'; }); }); } var scopeSel = document.getElementById('ls-scope'); if (scopeSel) { scopeSel.addEventListener('change', function() { var v = this.value; var wlEl = document.getElementById('ls-whitelist-sec'); var blEl = document.getElementById('ls-blacklist-sec'); if (wlEl) wlEl.style.display = (v === 'whitelist') ? 'block' : 'none'; if (blEl) blEl.style.display = (v === 'blacklist') ? 'block' : 'none'; }); } var saveBtn = document.getElementById('ls-save-btn'); if (saveBtn) { saveBtn.addEventListener('click', function() { var newCfg = Object.assign({}, loadConfig()); function gv(id) { var el = document.getElementById(id); return el ? el.value : ''; } function gc(id) { var el = document.getElementById(id); return el ? el.checked : false; } newCfg.engine = gv('ls-engine'); newCfg.mmFrom = gv('ls-mm-from').trim() || 'en'; newCfg.mmTo = gv('ls-mm-to').trim() || 'zh'; newCfg.baiduAppId = gv('ls-baidu-appid').trim(); newCfg.baiduSecret = gv('ls-baidu-secret').trim(); newCfg.baiduFrom = gv('ls-baidu-from').trim(); newCfg.baiduTo = gv('ls-baidu-to').trim(); newCfg.customName = gv('ls-custom-name').trim(); newCfg.customApiUrl = gv('ls-custom-url').trim(); newCfg.customMethod = gv('ls-custom-method'); newCfg.customBodyTemplate = gv('ls-custom-body').trim(); newCfg.customHeaders = gv('ls-custom-headers').trim(); newCfg.customResponsePath = gv('ls-custom-respath').trim(); newCfg.customFrom = gv('ls-custom-from').trim(); newCfg.customTo = gv('ls-custom-to').trim(); newCfg.apiBase = gv('ls-api-base').trim(); newCfg.apiKey = gv('ls-api-key').trim(); newCfg.model = gv('ls-model').trim(); newCfg.targetLang = gv('ls-target-lang').trim(); newCfg.systemPrompt = gv('ls-system-prompt').trim(); newCfg.llmTemperature = parseFloat(gv('ls-llm-temp')) || 0.3; newCfg.llmMaxTokens = parseInt(gv('ls-llm-max')) || 4096; newCfg.proxyPrefix = gv('ls-proxy-prefix').trim(); newCfg.translateMode = gv('ls-translate-mode'); newCfg.enableThreeFingerLongPress = gc('ls-three-finger'); newCfg.enableFloatBtn = gc('ls-float-btn'); newCfg.enableThinking = gc('ls-enable-thinking'); newCfg.btnColor = gv('ls-btn-color'); newCfg.btnSize = parseInt(gv('ls-btn-size')) || 46; newCfg.toastDuration = parseInt(gv('ls-toast-duration')) || 2500; // ★ 新增字段 newCfg.settingsScope = gv('ls-scope'); newCfg.autoTranslate = gc('ls-auto-translate'); newCfg.whitelistPages = gv('ls-whitelist').split(',').map(function(s){return s.trim();}).filter(Boolean); newCfg.blacklistPages = gv('ls-blacklist').split(',').map(function(s){return s.trim();}).filter(Boolean); // 保留按钮位置 newCfg.settingsBtnSide = settingsSnapSide; newCfg.settingsBtnY = Math.round(settingsY / window.innerHeight * 100); saveConfig(newCfg); config = newCfg; applyFloatBtn(); closeSettingsModal(); showToast('设置已保存,立即生效', 'success'); }); } var closeBtn = document.getElementById('ls-close-btn'); if (closeBtn) closeBtn.addEventListener('click', closeSettingsModal); var restoreBtn = document.getElementById('ls-restore-btn'); if (restoreBtn) restoreBtn.addEventListener('click', function() { closeSettingsModal(); restoreOriginal(); }); } function openSettings() { openSettingsModal(); } // ==================== 恢复原文 ==================== function restoreOriginal() { var restored = 0; if (backupMap && backupMap.size > 0) { backupMap.forEach(function(originalText, el) { if (el && el.parentNode) { el.textContent = originalText; restored++; } }); backupMap = null; } if (bilingualElements.length > 0) { for (var i = 0; i < bilingualElements.length; i++) { if (bilingualElements[i].parentNode) { bilingualElements[i].parentNode.removeChild(bilingualElements[i]); restored++; } } bilingualElements = []; } if (restored > 0) { hasTranslated = false; showToast('已恢复 ' + restored + ' 处原文', 'success'); } else { showToast('没有可恢复的原文', 'info'); } } // ==================== 翻译引擎:MyMemory ==================== async function callMyMemoryAPI(text, from, to) { from = from || config.mmFrom || 'en'; to = to || config.mmTo || 'zh'; var langpair = encodeURIComponent(from + '|' + to); if (text.length > 500) { var segs = []; for (var i = 0; i < text.length; i += 450) { segs.push(text.slice(i, i + 450)); } var results = []; for (var s = 0; s < segs.length; s++) { results.push(await callMyMemorySingle(segs[s], langpair)); if (s < segs.length - 1) await sleep(600); } return results.join(''); } return await callMyMemorySingle(text, langpair); } async function callMyMemorySingle(text, langpair) { var url = 'https://api.mymemory.translated.net/get?q=' + encodeURIComponent(text) + '&langpair=' + langpair; var resp = await fetchWithRetry(url); if (!resp.ok) throw new Error('MyMemory 请求失败 (' + resp.status + ')'); var data = await resp.json(); if (data.responseStatus !== 200) { throw new Error('MyMemory 翻译错误: ' + (data.responseDetails || data.responseStatus)); } return data.responseData.translatedText; } // ==================== 翻译引擎:百度 ==================== async function callBaiduAPI(text, from, to) { var appid = config.baiduAppId; var secret = config.baiduSecret; var salt = Date.now().toString(); var sign = md5(appid + text + salt + secret); var params = new URLSearchParams(); params.append('q', text); params.append('from', from || 'auto'); params.append('to', to || 'zh'); params.append('appid', appid); params.append('salt', salt); params.append('sign', sign); var resp = await fetch('https://fanyi-api.baidu.com/api/trans/vip/translate?' + params.toString()); if (!resp.ok) throw new Error('百度翻译请求失败 (' + resp.status + ')'); var data = await resp.json(); if (data.error_code) throw new Error('百度翻译错误 ' + data.error_code + ': ' + data.error_msg); if (data.trans_result && data.trans_result[0]) { return data.trans_result.map(function(item) { return item.dst; }).join('\n'); } throw new Error('百度翻译返回格式异常'); } // ==================== 工具:按 JSON 路径提取值 ==================== function getByPath(obj, pathStr) { if (!pathStr) return undefined; var parts = pathStr.split('.'); var current = obj; for (var i = 0; i < parts.length; i++) { if (current == null) return undefined; var part = parts[i]; if (Array.isArray(current) && /^\d+$/.test(part)) { current = current[parseInt(part)]; } else { current = current[part]; } } return current; } // ==================== 翻译引擎:自定义平台 ==================== async function callCustomAPI(text, from, to) { var url = config.customApiUrl; var method = config.customMethod || 'POST'; var headers = {}; var body = null; if (config.customHeaders) { try { headers = JSON.parse(config.customHeaders); } catch(e) { console.warn('[LLM翻译] 自定义请求头解析失败:', e.message); } } if (method === 'POST' && config.customBodyTemplate) { var bodyStr = config.customBodyTemplate .replace(/\{\{text\}\}/g, text) .replace(/\{\{from\}\}/g, from || '') .replace(/\{\{to\}\}/g, to || ''); try { JSON.parse(bodyStr); body = bodyStr; if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'application/json'; } catch(e) { throw new Error('自定义平台请求体 JSON 格式错误:' + e.message); } } if (method === 'GET' && config.customBodyTemplate) { var getParamsStr = config.customBodyTemplate .replace(/\{\{text\}\}/g, encodeURIComponent(text)) .replace(/\{\{from\}\}/g, encodeURIComponent(from || '')) .replace(/\{\{to\}\}/g, encodeURIComponent(to || '')); try { var paramObj = JSON.parse(getParamsStr); var sep = url.indexOf('?') >= 0 ? '&' : '?'; var kparts = []; var keys = Object.keys(paramObj); for (var k = 0; k < keys.length; k++) { kparts.push(encodeURIComponent(keys[k]) + '=' + encodeURIComponent(paramObj[keys[k]])); } url += sep + kparts.join('&'); } catch(e) { url += (url.indexOf('?') >= 0 ? '&' : '?') + getParamsStr; } } var fetchOpts = { method: method, headers: headers }; if (body) fetchOpts.body = body; var resp = await fetch(url, fetchOpts); if (!resp.ok) { var errText = await resp.text().catch(function() { return ''; }); throw new Error('自定义平台请求失败 (' + resp.status + '): ' + errText.slice(0, 200)); } var data = await resp.json(); var result = getByPath(data, config.customResponsePath); if (result == null || result === '') { throw new Error('响应提取失败,路径 "' + config.customResponsePath + '"。响应:' + JSON.stringify(data).slice(0, 200)); } return String(result); } // ==================== 翻译引擎:LLM (XHR 兼容版 + 限流退避重试) ==================== function callLLM(systemPrompt, userText, cfg) { var MAX_RETRIES = 3; return doLLMRequest(0); function doLLMRequest(attempt) { return new Promise(function(resolve, reject) { var url = (cfg.proxyPrefix || '') + (cfg.apiBase || 'https://api.openai.com/v1').replace(/\/+$/, '') + '/chat/completions'; var xhr = new XMLHttpRequest(); xhr.open('POST', url, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Authorization', 'Bearer ' + (cfg.apiKey || '')); xhr.timeout = (cfg.llmTimeout || 60) * 1000; xhr.onload = function() { if (xhr.status >= 200 && xhr.status < 300) { try { var data = JSON.parse(xhr.responseText); var content = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; if (content === null || content === undefined) { reject(new Error('API 返回内容为空')); } else { resolve(content.trim()); } } catch(e) { reject(new Error('API 返回格式错误:' + e.message)); } } else { var errText = xhr.responseText.slice(0, 200).toLowerCase(); var isRetryable = xhr.status === 429 || xhr.status === 503 || errText.indexOf('rate limit') !== -1 || errText.indexOf('rpm') !== -1 || errText.indexOf('busy') !== -1 || errText.indexOf('overloaded') !== -1; if (isRetryable) { if (attempt < MAX_RETRIES) { var delay = Math.pow(2, attempt + 1) * 3000; console.log('[LLM翻译] 服务器繁忙/限流,' + (delay/1000) + '秒后重试(第' + (attempt+1) + '次)...'); setTimeout(function() { doLLMRequest(attempt + 1).then(resolve, reject); }, delay); return; } reject(new Error('服务器忙碌,已重试' + MAX_RETRIES + '次,请稍后再试')); return; } reject(new Error('API 请求失败 (' + xhr.status + '): ' + errText)); } }; xhr.onerror = function() { reject(new Error('网络错误:无法连接 ' + cfg.apiBase)); }; xhr.ontimeout = function() { reject(new Error('请求超时(' + (cfg.llmTimeout || 60) + '秒)')); }; var body = { model: cfg.model || 'gpt-3.5-turbo', messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userText } ], temperature: cfg.llmTemperature || 0.1, max_tokens: cfg.llmMaxTokens || 4096 }; xhr.send(JSON.stringify(body)); }); } } function sleep(ms) { return new Promise(function(resolve) { setTimeout(resolve, ms); }); } // ==================== 带重试的 fetch ==================== async function fetchWithRetry(url, options, retries) { retries = retries || config.maxRetries || 3; var lastErr = null; for (var attempt = 0; attempt <= retries; attempt++) { try { var resp = await fetch(url, options); if (resp.ok) return resp; if ((resp.status === 429 || resp.status >= 500) && attempt < retries) { var delay = (config.retryDelayBase || 2000) * Math.pow(2, attempt); console.warn('[LLM翻译] ' + resp.status + ' 限流/错误,' + (delay/1000) + 's 后重试'); await sleep(delay); continue; } throw new Error('请求失败 (' + resp.status + ')'); } catch(err) { if (err.message && err.message.indexOf('请求失败') === 0) throw err; lastErr = err; if (attempt < retries) { var d = (config.retryDelayBase || 2000) * Math.pow(2, attempt); console.warn('[LLM翻译] 网络错误,' + (d/1000) + 's 后重试', err.message); await sleep(d); } } } throw lastErr || new Error('重试耗尽'); } // ==================== 通用翻译调用 ==================== async function translateText(text) { if (config.engine === 'mymemory') { return await callMyMemoryAPI(text, config.mmFrom, config.mmTo); } else if (config.engine === 'baidu') { return await callBaiduAPI(text, config.baiduFrom || 'auto', config.baiduTo || 'zh'); } else if (config.engine === 'custom') { return await callCustomAPI(text, config.customFrom || 'auto', config.customTo || 'zh'); } else { var prompt = config.systemPrompt.replace(/\{\{targetLang\}\}/g, config.targetLang); try { return await callLLM(prompt, text, config); } catch(e) { console.warn('[LLM翻译] LLM失败: ' + e.message + ',自动降级到 MyMemory'); return await callMyMemoryAPI(text, config.mmFrom, config.mmTo); } } } // ==================== 文本容器收集 ==================== var INLINE_TAGS = { A:1, SPAN:1, B:1, STRONG:1, I:1, EM:1, U:1, S:1, SUP:1, SUB:1, CODE:1, KBD:1, SMALL:1, MARK:1, ABBR:1, CITE:1, Q:1, LABEL:1, TIME:1, DFN:1, VAR:1, SAMP:1, FONT:1, DEL:1, INS:1 }; var SKIP_TAGS = { SCRIPT:1, STYLE:1, NOSCRIPT:1, SVG:1, IFRAME:1, TEXTAREA:1, INPUT:1, SELECT:1, OPTION:1, BUTTON:1, CANVAS:1, VIDEO:1, AUDIO:1, OBJECT:1, EMBED:1, MAP:1, AREA:1, LINK:1, META:1, BR:1, HR:1, IMG:1, FORM:1, FIELDSET:1 }; var STRIP_TAGS = { NAV:1, HEADER:1, FOOTER:1, ASIDE:1, NOSCRIPT:1 }; function isLeafBlock(el) { if (!el || !el.tagName) return false; if (SKIP_TAGS[el.tagName]) return false; if (STRIP_TAGS[el.tagName]) return false; var cs = window.getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return false; var d = cs.display; if (d.indexOf('inline') === 0 && d !== 'inline-block') return false; for (var i = 0; i < el.children.length; i++) { var c = el.children[i]; if (SKIP_TAGS[c.tagName]) continue; var cs2 = window.getComputedStyle(c); var cd = cs2.display; if (cd.indexOf('block') !== -1 || cd.indexOf('flex') !== -1 || cd.indexOf('grid') !== -1 || cd === 'table' || cd === 'table-row' || cd === 'table-cell' || cd === 'list-item') { return false; } } var txt = el.textContent.trim(); return txt.length > 1; } function collectTextContainers() { var containers = []; function walk(el) { if (!el || !el.tagName) return; if (SKIP_TAGS[el.tagName]) return; if (STRIP_TAGS[el.tagName]) return; var cs = window.getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return; if (isLeafBlock(el)) { containers.push({ el: el, text: el.textContent.trim() }); return; } for (var i = 0; i < el.children.length; i++) { walk(el.children[i]); } } if (document.body) { var mainAreas = document.querySelectorAll('main, article, [role="main"], .content, .article, .post, #content, #article, #main'); if (mainAreas.length > 0) { for (var a = 0; a < mainAreas.length; a++) walk(mainAreas[a]); } if (containers.length === 0) walk(document.body); } return containers; } // ==================== 分批:替换模式 ==================== function createBatches(containers) { var MAX_CHARS = config.maxBatchChars || 5000; var batches = []; var batch = { containers: [], texts: [] }; var charCount = 0; for (var i = 0; i < containers.length; i++) { var c = containers[i]; var len = c.text.length; if (batch.containers.length > 0 && charCount + len > MAX_CHARS) { batches.push({ containers: batch.containers, text: batch.texts.join('\n|||SEP|||\n') }); batch = { containers: [c], texts: [c.text] }; charCount = len; } else { batch.containers.push(c); batch.texts.push(c.text); charCount += len; } } if (batch.containers.length > 0) { batches.push({ containers: batch.containers, text: batch.texts.join('\n|||SEP|||\n') }); } return batches; } // ==================== 分批:双语模式 ==================== function createParagraphBatches(containers) { var MAX_PARAS = config.maxBatchParagraphs || 10; var batches = []; for (var i = 0; i < containers.length; i += MAX_PARAS) { var batch = containers.slice(i, i + MAX_PARAS); batches.push({ containers: batch, text: batch.map(function(c) { return c.text; }).join('\n|||SEP|||\n') }); } return batches; } // ==================== LLM 编号格式:合并为单条消息 ==================== var LLM_BATCH_MAX_CHARS = 8000; function buildLLMBatches(containers) { var batches = []; var batch = []; var charCount = 0; for (var i = 0; i < containers.length; i++) { var marker = '[' + batch.length + '] '; var line = marker + containers[i].text; if (batch.length > 0 && charCount + line.length > LLM_BATCH_MAX_CHARS) { batches.push({ containers: batch, text: batch.map(function(c, idx) { return '[' + idx + '] ' + c.text; }).join('\n') }); batch = [containers[i]]; charCount = line.length; } else { batch.push(containers[i]); charCount += line.length; } } if (batch.length > 0) { batches.push({ containers: batch, text: batch.map(function(c, idx) { return '[' + idx + '] ' + c.text; }).join('\n') }); } return batches; } function parseLLMResponse(text) { var results = []; var lines = text.split('\n'); var re = /^\s*\[(\d+)\]\s*/; for (var i = 0; i < lines.length; i++) { var m = lines[i].match(re); if (m) { var idx = parseInt(m[1], 10); results[idx] = lines[i].substring(m[0].length).trim(); } } var out = []; for (var j = 0; j < results.length; j++) { if (results[j] !== undefined) out[j] = results[j]; } return out; } // ==================== 翻译模式:直接替换 ==================== async function translateReplace() { var containers = collectTextContainers(); if (containers.length === 0) throw new Error('页面没有可翻译的文本'); backupMap = new Map(); for (var i = 0; i < containers.length; i++) { backupMap.set(containers[i].el, containers[i].el.textContent); } if (config.engine === 'mymemory') { showProgress(0, containers.length); for (var mi = 0; mi < containers.length; mi++) { var c = containers[mi]; try { var t = await translateText(c.text); c.el.textContent = t.trim(); } catch(e) { console.warn('[LLM翻译] 容器' + mi + '翻译失败:', e.message); } showProgress(mi + 1, containers.length); if (mi < containers.length - 1) await sleep(2000); } } else if (config.engine === 'llm' || !config.engine) { var batches = buildLLMBatches(containers); showProgress(0, batches.length); for (var b = 0; b < batches.length; b++) { var translated = await translateText(batches[b].text); var parts = parseLLMResponse(translated); for (var j = 0; j < batches[b].containers.length; j++) { if (parts[j]) { batches[b].containers[j].el.textContent = parts[j]; } } showProgress(b + 1, batches.length); if (b < batches.length - 1) await sleep(2000); } } else { var oldBatches = createBatches(containers); showProgress(0, oldBatches.length); for (var b2 = 0; b2 < oldBatches.length; b2++) { var batch = oldBatches[b2]; var translated2 = await translateText(batch.text); var parts2 = translated2.split(/\n?\|\|\|SEP\|\|\|\n?/); for (var j2 = 0; j2 < batch.containers.length; j2++) { if (j2 < parts2.length) { var trimmed = parts2[j2].trim(); if (trimmed) { batch.containers[j2].el.textContent = trimmed; } } } showProgress(b2 + 1, oldBatches.length); if (b2 < oldBatches.length - 1) await sleep(config.batchDelayMs || 800); } } hideProgress(); } // ==================== 双语译文插入 ==================== function appendBilingualElement(origEl, translatedText) { if (!translatedText) return; var origCs = window.getComputedStyle(origEl); var transEl = document.createElement('div'); transEl.textContent = translatedText; transEl.className = 'llm-translated-bilingual'; var s = transEl.style; s.display = 'block'; s.width = '100%'; s.boxSizing = 'border-box'; s.clear = 'both'; s.fontSize = origCs.fontSize; s.fontFamily = origCs.fontFamily; s.fontWeight = origCs.fontWeight; s.color = origCs.color; s.fontStyle = origCs.fontStyle; s.lineHeight = origCs.lineHeight; s.textAlign = origCs.textAlign; s.wordBreak = 'break-word'; s.overflowWrap = 'break-word'; s.backgroundColor = 'rgba(248,250,252,0.95)'; s.padding = '4px 8px'; s.marginBottom = '2px'; s.borderRadius = '4px'; s.borderLeft = '3px solid #94a3b8'; if (origEl.insertAdjacentElement) { origEl.insertAdjacentElement('afterend', transEl); } else if (origEl.parentNode) { origEl.parentNode.insertBefore(transEl, origEl.nextSibling); } bilingualElements.push(transEl); } // ==================== 翻译模式:双语对照 ==================== async function translateBilingual() { var containers = collectTextContainers(); if (containers.length === 0) throw new Error('页面没有可翻译的段落'); for (var c = 0; c < bilingualElements.length; c++) { if (bilingualElements[c].parentNode) bilingualElements[c].parentNode.removeChild(bilingualElements[c]); } bilingualElements = []; if (config.engine === 'mymemory') { showProgress(0, containers.length); for (var mi = 0; mi < containers.length; mi++) { var container = containers[mi]; try { var transText = await translateText(container.text); appendBilingualElement(container.el, transText.trim()); } catch(e) { console.warn('[LLM翻译] 容器' + mi + '翻译失败:', e.message); } showProgress(mi + 1, containers.length); if (mi < containers.length - 1) await sleep(2000); } } else if (config.engine === 'llm' || !config.engine) { var batches = buildLLMBatches(containers); showProgress(0, batches.length); for (var b = 0; b < batches.length; b++) { var translated = await translateText(batches[b].text); var parts = parseLLMResponse(translated); for (var j = 0; j < batches[b].containers.length; j++) { if (parts[j]) { appendBilingualElement(batches[b].containers[j].el, parts[j]); } } showProgress(b + 1, batches.length); if (b < batches.length - 1) await sleep(2000); } } else { var pBatches = createParagraphBatches(containers); showProgress(0, pBatches.length); for (var b2 = 0; b2 < pBatches.length; b2++) { var batch = pBatches[b2]; var translated2 = await translateText(batch.text); var parts2 = translated2.split(/\n?\|\|\|SEP\|\|\|\n?/); for (var j2 = 0; j2 < batch.containers.length; j2++) { if (j2 >= parts2.length) continue; var container2 = batch.containers[j2]; var trimmed = parts2[j2].trim(); if (!trimmed) continue; appendBilingualElement(container2.el, trimmed); } showProgress(b2 + 1, pBatches.length); if (b2 < pBatches.length - 1) await sleep(config.batchDelayMs || 800); } } hideProgress(); } // ==================== 进度指示 ==================== function showProgress(current, total) { if (!progressEl) { progressEl = document.createElement('div'); progressEl.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:100001;' + 'background:rgba(0,0,0,.78);color:#fff;padding:8px 20px;' + 'border-radius:20px;font-size:13px;font-family:system-ui,sans-serif;' + 'box-shadow:0 4px 12px rgba(0,0,0,.3);pointer-events:none;'; if (document.body) document.body.appendChild(progressEl); } var pct = total > 0 ? Math.round(current / total * 100) : 0; progressEl.textContent = '翻译中 ' + current + '/' + total + ' (' + pct + '%)'; progressEl.style.display = ''; } function hideProgress() { if (progressEl) progressEl.style.display = 'none'; } // ==================== 自动翻译检查(页面加载后触发) ==================== checkAutoTranslate(); })();