// ==UserScript== // @name TransLite(Via 专版) // @namespace https://via.browser/ // @version 26.8.27 // @description TransLite Via 专版: LLM批量翻译(流式输出),MyMemory/百度/自定义引擎,翻译角色预设,快捷键,GitHub模式,429限流保护,源码配置区,HARDCODED全局配置 // @author rewwoxv.(原作者);修改版 by GeorgeChou17 // @match *://*/* // @grant none // @run-at document_idle // @license AGPL-3.0-or-later // ==/UserScript== (function() { 'use strict'; // ==================================================================================== // ★★★ 源码硬编码兜底区(Via 跨域高频字段) ★★★ // // 所有完整配置项(引擎/超时/流式/快捷键/按钮/GitHub 等)请在上方「源码配置区」 // DEFAULT_CONFIG 中修改。此处仅保留【跨域兜底高频字段】: // // Via 浏览器普通脚本无法使用 GM_setValue,localStorage 受同源策略限制—— // 切换域名后面板保存的配置会重置。因此把 apiBase / apiKey / model 等写死在 // 下方对象里,即可在所有网页上作为"全局默认值"生效。 // // 网页内的"设置面板"保存的内容仅作为 当前域名的独立覆写(Override); // 切换到其他域名时,会回退到此处的硬编码值。 // ==================================================================================== var HARDCODED = { apiBase: '', // ← 在此填写 API Base URL(OpenAI 兼容,含 /v1)。脚本不内置默认服务商 apiKey: '', // ← 在此填写你的 API Key(推荐硬编码,避免重复输入) model: '' // ← 在此填写模型名称 // 其余参数(引擎/目标语言/翻译模式/超时/流式/429/快捷键/按钮/GitHub 等) // 请在上方 DEFAULT_CONFIG 源码配置区直接修改 }; // 配置版本:结构/默认值变更时 +1;存储配置版本落后时触发一次性迁移(清除旧内置服务商数据) var CONFIG_VERSION = 3; var LEGACY_API_BASES = ['https://api.qnaigc.com/v1', 'https://api.openai.com/v1', 'https://note3-prev-api.askdiandian.com/v1']; var LEGACY_MODELS = ['z-ai/glm-4.5-air-free', 'dots3-note-prev']; // ==================================================================================== // ★★★ 源码配置区(Via 专版:所有设置均可直接在此修改,改 true/false、改数字即可) ★★★ // // 下方对象包含全部可调设置。直接修改这里的值并保存脚本即生效。 // // 【重要】配置优先级:设置面板保存的内容 > 源码配置区 > 默认值。 // 若之前在设置面板保存过配置,面板值会覆盖源码配置区。 // 需要让「源码配置区」的改动立即生效时:打开设置面板 → 点「🔄 恢复默认配置」 //(清除面板保存,之后的运行将完全以源码配置区为准)。 // // 建议:把 apiBase / apiKey / model 直接写死在下方源码里(见 HARDCODED 区), // 这样即使切换域名(Via 的 localStorage 跨域会重置)也能全局生效。 // ==================================================================================== var DEFAULT_CONFIG = Object.assign({ engine: 'llm', // 翻译引擎: 'llm' | 'mymemory' | 'baidu' | 'custom' targetLang: 'Simplified Chinese', // 目标语言 translateMode: 'replace', // 'replace'=直接替换 / 'bilingual'=双语对照 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: 4096, llmTimeout: 120, enableStreaming: true, // 流式输出(默认开启;可在设置中关闭) rateLimitRetries: 3, // 429/限流重试次数(默认 3,可自定义;耗尽后弹窗提示并停止翻译) btnColor: '#2563eb', btnSize: 46, btnOpacity: 0.9, toastDuration: 2500, enableThreeFingerLongPress: true, enableFloatBtn: true, // 悬浮翻译按钮(🌐)默认开启 enableSettingsBtn: true, // 悬浮设置按钮(⚙️)默认显示(Via 无菜单命令,隐藏则无设置入口) threeFingerLongPressMs: 500, maxBatchChars: 5000, maxBatchParagraphs: 15, batchDelayMs: 3000, maxRetries: 3, retryTimes: 2, retryDelayBase: 2000, settingsScope: 'global', whitelistPages: [], blacklistPages: [], autoTranslate: false, settingsBtnY: 70, settingsBtnSide: 'right', githubAuto: true, // 访问 github.com / *.github.io 等域名使用 AI 翻译时自动切换 GitHub 模式 enableShortcut: true, // 启用键盘/外接键盘快捷键 shortcutKey: 'ctrl+alt+t', // 默认翻译快捷键(可在设置中录制) enableMouseShortcut: false, // 双击页面空白区域翻译(默认关闭) cfgVersion: CONFIG_VERSION }, 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) {} } // 一次性配置迁移(版本门槛):仅当存储配置版本落后时,清除旧内置服务商数据并打版本标记; // 用户实际使用的服务值即使与旧默认相同,迁移后也不再被清空(永不重复执行) if (cfg && typeof cfg === 'object' && cfg.cfgVersion !== CONFIG_VERSION) { if (LEGACY_API_BASES.indexOf(cfg.apiBase) !== -1) cfg.apiBase = ''; if (LEGACY_MODELS.indexOf(cfg.model) !== -1) cfg.model = ''; if (cfg.llmMaxTokens === 8192) cfg.llmMaxTokens = 4096; cfg.cfgVersion = CONFIG_VERSION; try { localStorage.setItem(key, JSON.stringify(cfg)); } catch(e) {} console.log('[LLM翻译] 配置已迁移到 v' + CONFIG_VERSION); } // 【修复合并顺序】:默认配置 < 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; // ==================== 翻译取消 / 429 停止(模块级状态) ==================== var cancelRequested = false; // 翻译进行中再次触发(快捷键/悬浮按钮等)→ 置位取消 var translateStopped = false; // 429 限流多次失败后置位,停止当前翻译任务 function notifyRateLimitAndStop() { if (translateStopped) return; translateStopped = true; hideProgress(); // 清除计时器与进度显示(任务计数随之停止) resetBtnState(); try { window.alert('429 多并发限流,请稍候重试。\n\n已停止当前翻译任务,避免无效重试。'); } catch (e) {} showToast('429 多并发限流,已停止翻译', 'warn'); } // ==================== GitHub 模式(参考沉浸式翻译:github 域名下 AI 翻译自动切换) ==================== var githubActive = false; var GITHUB_SKIP = { PRE:1, CODE:1 }; var DEFAULT_GITHUB_PROMPT = 'You are translating a GitHub page (README, issues, pull requests, discussions, comments). Translate the prose into {{targetLang}}. STRICT RULES: 1) Do NOT translate code, shell/terminal commands, file paths, URLs, package names, API names, or programming identifiers. 2) Keep Markdown, HTML tags and all formatting intact. 3) For the numbered [N] segments I provide, translate each independently and output ONLY the [N] translated lines, keeping the [N] markers, with no extra text, headings, or explanations.'; function isGitHubDomain() { var h = location.hostname || ''; return /(^|\.)github\.com$/i.test(h) || /(^|\.)github\.io$/i.test(h) || /(^|\.)github\.dev$/i.test(h); } function getSkipTags() { return githubActive ? Object.assign({}, SKIP_TAGS, GITHUB_SKIP) : SKIP_TAGS; } // GitHub 模式:仅跳过文件/目录名单元格(文件名不翻译),commit message、日期等正文照常翻译 var GITHUB_FILENAME_CELL_SEL = '.react-directory-filename-cell, table.files td.content'; function isGitHubFileNameCell(el) { return githubActive && el.closest && el.closest(GITHUB_FILENAME_CELL_SEL); } // ==================== 翻译角色(系统提示词预设) ==================== // 内置 9 个预设,均遵循「默认通用」的编号格式:[N] 编号 + {{targetLang}} 占位 + 输出 ONLY [N] 行。 // 自定义角色由用户按相同格式修改,持久化于 localStorage(当前域名)。 var ROLE_PRESETS = [ { name: '默认通用', prompt: '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] 早上好' }, { name: '科技编程', prompt: 'You are a precise translator specialized in tech and programming content. I will give you multiple texts, each prefixed with [N]. Translate ALL of them into {{targetLang}}. Output each translation on its own line, prefixed with the SAME [N] marker, and nothing else. Rules: keep code, shell commands, file paths, URLs, package names, API names and identifiers UNTRANSLATED; use common industry-standard terms for technical jargon.' }, { name: '医学论文', prompt: 'You are a precise translator specialized in medical research papers. I will give you multiple texts, each prefixed with [N]. Translate ALL of them into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use standard Chinese medical terminology; keep drug generic names, Latin binomials, gene/protein symbols and reference numbers as-is; keep statistics and measurement units accurate.' }, { name: '机械论文', prompt: 'You are a precise translator specialized in mechanical engineering papers. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use standard engineering terminology; keep units, model numbers, parameters, standard codes (e.g. ISO, GB) and equations as-is.' }, { name: '新闻媒体', prompt: 'You are a precise translator for news media. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use a concise, objective journalistic style; use common Chinese renderings for person and place names; keep official institution names and direct quotes accurate.' }, { name: '法律译者', prompt: 'You are a precise legal translator. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use accurate legal terminology; keep article/section numbers and citations as-is; use official names for laws and treaties; keep the tone formal and precise.' }, { name: '小说译者', prompt: 'You are a literary translator for novels. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: preserve the literary tone, style and rhythm; use common renderings for names and places; make dialogue natural; keep proper nouns or coined terms when appropriate.' }, { name: '游戏译者', prompt: 'You are a game localization translator. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use short, punchy phrasing suited to UI and quest text; keep skill/item/quest names consistent; use official or established translations for proper nouns.' }, { name: '论文通用', prompt: 'You are a precise academic translator for research papers. Translate the [N]-numbered texts into {{targetLang}}, outputting ONLY the same [N] lines. Rules: use a formal, rigorous academic style; keep terminology consistent; keep formulas, figure/table/reference numbers and citations as-is.' } ]; var CUSTOM_ROLES_KEY = 'translite_custom_roles'; function getCustomRoles() { try { var raw = localStorage.getItem(CUSTOM_ROLES_KEY); var r = raw ? JSON.parse(raw) : []; return Array.isArray(r) ? r : []; } catch (e) { return []; } } function saveCustomRole(role) { var list = getCustomRoles(); list.push(role); try { localStorage.setItem(CUSTOM_ROLES_KEY, JSON.stringify(list)); } catch (e) {} } function deleteCustomRole(index) { var list = getCustomRoles(); list.splice(index, 1); try { localStorage.setItem(CUSTOM_ROLES_KEY, JSON.stringify(list)); } catch (e) {} } // 生成角色下拉框选项,并按当前提示词自动选中匹配的预设/自定义角色 function buildRoleOptionsHTML(currentPrompt) { var html = '', found = false; ROLE_PRESETS.forEach(function(p, i) { var sel = (currentPrompt === p.prompt) ? ' selected' : ''; if (sel) found = true; html += ''; }); getCustomRoles().forEach(function(r, i) { var sel = (currentPrompt === r.prompt) ? ' selected' : ''; if (sel) found = true; html += ''; }); if (!found) html += ''; return html; } function getRolePromptByValue(v) { if (v === 'none' || !v) return null; if (v.charAt(0) === 'b') return ROLE_PRESETS[parseInt(v.slice(1), 10)] ? ROLE_PRESETS[parseInt(v.slice(1), 10)].prompt : null; if (v.charAt(0) === 'c') { var roles = getCustomRoles(); return roles[parseInt(v.slice(1), 10)] ? roles[parseInt(v.slice(1), 10)].prompt : null; } return null; } function isCustomRoleValue(v) { return !!v && v.charAt(0) === 'c'; } function getSelectedRoleValue(currentPrompt) { for (var i = 0; i < ROLE_PRESETS.length; i++) if (currentPrompt === ROLE_PRESETS[i].prompt) return 'b' + i; var roles = getCustomRoles(); for (var j = 0; j < roles.length; j++) if (currentPrompt === roles[j].prompt) return 'c' + j; return null; } async function doTranslate() { if (translating) { // 翻译进行中:再次触发 = 取消当前翻译任务 cancelRequested = true; showToast('正在取消翻译…(等待当前批次返回)', 'info'); return; } // GitHub 模式自动切换(仅对 AI 类引擎生效) githubActive = !!(config.githubAuto && isGitHubDomain() && (config.engine === 'llm' || !config.engine || config.engine === 'custom')); if (githubActive) { showToast('已自动启用 GitHub 模式(保留代码/命令/文件名不翻译)', 'info'); } if (config.engine === 'llm' || !config.engine || config.engine === 'google') { if (!config.apiBase) { showToast('请先在设置面板「LLM 配置」中填写 API 地址(脚本不内置默认服务商)', 'warn'); return; } if (!config.apiKey) { showToast('请先在设置面板「LLM 配置」中填写 API Key', 'warn'); return; } if (!config.model) { showToast('请先在设置面板「LLM 配置」中填写模型名称', 'warn'); return; } } 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; cancelRequested = false; translateStopped = false; updateBtnState('\u23F3', '#d97706'); try { if (config.translateMode === 'bilingual') { await translateBilingual(); } else { await translateReplace(); } if (cancelRequested) { // 用户取消 hideProgress(); resetBtnState(); showToast('已取消翻译', 'warn'); } else if (!translateStopped) { updateBtnState('\u2705', '#16a34a'); hasTranslated = true; showToast('翻译完成', 'success'); } } catch(err) { if (!translateStopped && !cancelRequested) { // 429 停止/用户取消场景已提示,不再重复报错 console.error('[LLM翻译]', err); updateBtnState('\u274C', '#dc2626'); showToast('翻译出错:' + err.message, 'error'); } } finally { setTimeout(resetBtnState, 2000); translating = false; cancelRequested = false; translateStopped = 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 removeSettingsBtnAll() { if (settingsBtn && settingsBtn.parentNode) settingsBtn.parentNode.removeChild(settingsBtn); if (settingsBtnTab && settingsBtnTab.parentNode) settingsBtnTab.parentNode.removeChild(settingsBtnTab); settingsBtn = null; settingsBtnTab = null; } function applySettingsBtn() { if (config.enableSettingsBtn) { if (!settingsBtn) setupSettingsButton(); if (settingsBtn && !settingsBtn.parentNode && document.body) document.body.appendChild(settingsBtn); if (settingsBtnTab && !settingsBtnTab.parentNode && document.body) document.body.appendChild(settingsBtnTab); updateSettingsBtnAppearance(); } else { removeSettingsBtnAll(); } } function injectUI() { if (!document.body) { setTimeout(injectUI, 100); return; } applyFloatBtn(); applySettingsBtn(); } injectUI(); // ==================== 快捷键(电脑键盘 / 移动端外接键盘 + 鼠标双击) ==================== var recordingShortcut = false; // 正在录制快捷键时,全局监听不触发翻译 function normalizeKey(e) { var parts = []; if (e.ctrlKey) parts.push('ctrl'); if (e.altKey) parts.push('alt'); if (e.shiftKey) parts.push('shift'); if (e.metaKey) parts.push('meta'); var k = e.key; if (!k || k === 'Control' || k === 'Alt' || k === 'Shift' || k === 'Meta' || k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null; parts.push(k === ' ' ? 'space' : String(k).toLowerCase()); return parts.join('+'); } function registerShortcut() { // 键盘快捷键(输入框/文本框内不触发) document.addEventListener('keydown', function(e) { if (recordingShortcut) return; if (!config.enableShortcut || !config.shortcutKey) return; var t = e.target; if (t && t.closest && t.closest('input,textarea,select,[contenteditable]')) return; if (normalizeKey(e) === String(config.shortcutKey).trim().toLowerCase()) { e.preventDefault(); doTranslate(); } }); // 鼠标双击页面空白区域翻译(交互元素及其内部不触发;双击选中文字也不触发) document.addEventListener('dblclick', function(e) { if (!config.enableMouseShortcut || translating) return; var t = e.target; if (!t || !t.closest) return; if (t.closest('a,button,input,textarea,select,label,option,[contenteditable]')) return; var sel = window.getSelection ? window.getSelection().toString() : ''; if (sel && sel.length > 0) return; doTranslate(); }); } registerShortcut(); // ==================== 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 兼容,请填写你自己的服务)
' + '

脚本不内置任何默认服务商。请自行填写你的 OpenAI 兼容 API:地址(含 /v1)、API Key、模型名称,三项均必填。

' + '' + '' + '' + '' + '' + '' + '
' + '' + '' + '
' + '

选择预设会自动填充提示词,可再手动修改。自定义角色请按「默认通用」格式:保持 [N] 编号与 {{targetLang}} 占位符、输出 ONLY [N] 行。

' + '
' + '
' + '
' + '
' + '' + '' + '' + '
模型思考模式 (reasoning)' + '
' + '
流式输出(stream,更快看到译文)' + '
' + '' + '
填写地址/Key/模型后点此测试,可快速定位网络或鉴权问题。
' + '
' + '
' + '
MyMemory 翻译配置
' + '
' + '
' + '
' + '
' + '

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

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

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

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

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

' + '
' + '
' + '
界面与快捷操作
' + '
' + '
' + '
' + '
' + '' + '
三指长按手势翻译(0.5秒)' + '
' + '
悬浮翻译按钮(右下角 🌐)' + '
' + '
悬浮设置按钮(右下角 ⚙️)' + '
' + '
GitHub 自动模式(保留代码/命令/文件名不翻译)' + '
' + '

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

' + '
' + '
' + '
快捷键(迅速触发翻译)
' + '
启用键盘快捷键' + '
' + '' + '
双击页面空白区域翻译' + '
' + '

键盘快捷键在电脑键盘与移动端外接键盘上均有效;在输入框/文本框内不会触发。翻译进行中再次按快捷键或点击 🌐 可取消翻译。

' + '
' + '
' + '' + '' + '
' + '' + '' + '
'; } 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 roleSel = document.getElementById('ls-role-select'); var roleDelBtn = document.getElementById('ls-role-del'); var roleTa = document.getElementById('ls-system-prompt'); if (roleSel) { roleSel.addEventListener('change', function() { var p = getRolePromptByValue(this.value); if (p && roleTa) roleTa.value = p; if (roleDelBtn) roleDelBtn.style.display = isCustomRoleValue(this.value) ? '' : 'none'; }); } var roleSaveBtn = document.getElementById('ls-role-save'); if (roleSaveBtn) { roleSaveBtn.addEventListener('click', function() { if (!roleTa) return; var prompt = roleTa.value.trim(); if (!prompt) { showToast('提示词不能为空', 'warn'); return; } var name = window.prompt('请输入该角色的名称(例如:我的翻译助手)\n提示:自定义角色请按「默认通用」格式修改([N] 编号 + {{targetLang}} 占位符,输出 ONLY [N] 行):'); if (name === null) return; name = name.trim(); if (!name) { showToast('角色名称不能为空', 'warn'); return; } saveCustomRole({ name: name, prompt: prompt }); if (roleSel) roleSel.innerHTML = buildRoleOptionsHTML(prompt); if (roleDelBtn) roleDelBtn.style.display = ''; showToast('已保存角色「' + name + '」(当前域名持久化)', 'success'); }); } if (roleDelBtn) { roleDelBtn.addEventListener('click', function() { if (!roleSel) return; var v = roleSel.value; if (!isCustomRoleValue(v)) return; var idx = parseInt(v.slice(1), 10); var roles = getCustomRoles(); if (!roles[idx]) return; if (!window.confirm('确定删除自定义角色「' + roles[idx].name + '」吗?')) return; deleteCustomRole(idx); if (roleSel) roleSel.innerHTML = buildRoleOptionsHTML(ROLE_PRESETS[0].prompt); if (roleTa) roleTa.value = ROLE_PRESETS[0].prompt; if (roleDelBtn) roleDelBtn.style.display = 'none'; showToast('已删除角色,提示词已恢复为「默认通用」', 'success'); }); } // 「测试连接」:读取当前输入框的值发一条最小请求(不保存配置) var testBtn = document.getElementById('ls-test-btn'); if (testBtn) { testBtn.addEventListener('click', async function() { function gv(id) { var el = document.getElementById(id); return el ? el.value : ''; } var apiBase = gv('ls-api-base').trim(); var apiKey = gv('ls-api-key').trim(); var model = gv('ls-model').trim(); var proxy = gv('ls-proxy-prefix').trim(); var resEl = document.getElementById('ls-test-result'); if (!resEl) return; if (!apiBase || !model) { resEl.textContent = '❌ 请先填写 API 地址和模型名称'; resEl.style.color = '#dc2626'; return; } var url = proxy + normalizeLLMUrl(apiBase); resEl.textContent = '⏳ 正在请求 ' + safeHost(url) + '(最长 15 秒)…'; resEl.style.color = '#2563eb'; var t0 = Date.now(); try { var xhr = new XMLHttpRequest(); xhr.open('POST', url, true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Authorization', 'Bearer ' + apiKey); xhr.timeout = 15000; var body = JSON.stringify({ model: model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 5 }); var done = false; xhr.onload = function() { var cost = ((Date.now() - t0) / 1000).toFixed(1); if (xhr.status >= 200 && xhr.status < 300) { resEl.textContent = '✅ 连接成功(HTTP ' + xhr.status + ',' + safeHost(url) + ',用时 ' + cost + 's)'; resEl.style.color = '#16a34a'; } else { resEl.textContent = '❌ HTTP ' + xhr.status + '(' + safeHost(url) + ',用时 ' + cost + 's)' + (xhr.responseText ? ':' + xhr.responseText.slice(0, 150) : ''); resEl.style.color = '#dc2626'; } }; xhr.onerror = function() { resEl.textContent = '❌ 网络错误:无法连接 ' + safeHost(url) + '(用时 ' + ((Date.now() - t0) / 1000).toFixed(1) + 's)'; resEl.style.color = '#dc2626'; }; xhr.ontimeout = function() { resEl.textContent = '❌ 请求超时(15 秒)→ ' + safeHost(url); resEl.style.color = '#dc2626'; }; xhr.send(body); } catch (e) { resEl.textContent = '❌ ' + e.message; resEl.style.color = '#dc2626'; } }); } // 快捷键录制:点击「录制」后,捕获用户按下的组合键写入输入框 var recordBtn = document.getElementById('ls-shortcut-record'); var shortcutInput = document.getElementById('ls-shortcut-key'); if (recordBtn && shortcutInput) { recordBtn.addEventListener('click', function() { if (recordingShortcut) return; recordingShortcut = true; recordBtn.textContent = '⏳ 请按组合键…(Esc 取消)'; function onRecordKey(e) { e.preventDefault(); // 录制期间按键不落到页面;全局监听由 recordingShortcut 标志拦截 if (e.key === 'Escape') { finish(null); return; } var combo = normalizeKey(e); if (!combo) return; // 纯修饰键(Ctrl/Alt/Shift…),等待下一个键 finish(combo); } function finish(combo) { recordingShortcut = false; document.removeEventListener('keydown', onRecordKey, true); recordBtn.textContent = '🎬 录制'; if (combo) { shortcutInput.value = combo; shortcutInput.style.color = '#16a34a'; showToast('快捷键已设为 ' + combo + '(保存后生效)', 'success'); } } document.addEventListener('keydown', onRecordKey, true); }); } 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.llmTimeout = parseInt(gv('ls-llm-timeout')) || 120; var rlr = parseInt(gv('ls-ratelimit-retries'), 10); newCfg.rateLimitRetries = (isNaN(rlr) || rlr < 0) ? 3 : rlr; newCfg.enableStreaming = gc('ls-enable-streaming'); 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.enableSettingsBtn = gc('ls-settings-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.githubAuto = gc('ls-github-auto'); newCfg.enableShortcut = gc('ls-enable-shortcut'); newCfg.shortcutKey = gv('ls-shortcut-key').trim().toLowerCase(); newCfg.enableMouseShortcut = gc('ls-mouse-shortcut'); // 保留按钮位置 newCfg.settingsBtnSide = settingsSnapSide; newCfg.settingsBtnY = Math.round(settingsY / window.innerHeight * 100); saveConfig(newCfg); config = newCfg; applyFloatBtn(); applySettingsBtn(); 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(); }); // 恢复默认配置:清除面板保存(localStorage),让「源码配置区 DEFAULT_CONFIG + HARDCODED」接管 var resetBtn = document.getElementById('ls-reset-btn'); if (resetBtn) resetBtn.addEventListener('click', function() { if (!window.confirm('确定清除面板保存的配置,改用脚本源码配置区(DEFAULT_CONFIG / HARDCODED)吗?此操作不可撤销。')) return; try { localStorage.removeItem(GLOBAL_KEY); localStorage.removeItem(GLOBAL_KEY + '_' + getHostname()); localStorage.removeItem(SCOPE_KEY); } catch (e) {} config = loadConfig(); applyFloatBtn(); applySettingsBtn(); closeSettingsModal(); showToast('已恢复默认配置(源码配置区生效),请刷新页面', 'success'); }); } 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 流式版 + 429 可配重试) ==================== // 流式:stream:true + XHR onprogress 增量解析 SSE(事件块 \n\n 分隔,兼容 CRLF), // 首 token 到达即开始累积;onload 时若增量为空 → 对完整响应文本全量解析兜底 → 完整 JSON 兜底。 // 服务端不支持流式(400/415/501)→ 自动回退非流式重发一次。 // 429 重试次数由 rateLimitRetries 控制(默认 3),耗尽后弹窗提示并停止当前翻译任务。 function safeHost(url) { try { return new URL(url).host; } catch (e) { var m = String(url).match(/^https?:\/\/([^/]+)/i); return m ? m[1] : String(url).slice(0, 60); } } function normalizeLLMUrl(base) { var u = String(base || '').trim().replace(/\/+$/, ''); if (/\/chat\/completions$/i.test(u)) return u; return u + '/chat/completions'; } function parseSSEBody(text) { var norm = String(text || '').replace(/\r\n/g, '\n'); var content = '', done = false; var blocks = norm.split('\n\n'); for (var i = 0; i < blocks.length; i++) { var lines = blocks[i].split('\n'); var payloads = []; for (var j = 0; j < lines.length; j++) { var line = lines[j]; if (line.indexOf('data:') === 0) payloads.push(line.slice(5).trim()); } if (payloads.length === 0) continue; var joined = payloads.join('\n'); if (joined === '[DONE]') { done = true; continue; } try { var o = JSON.parse(joined); var ch = o.choices && o.choices[0]; var c = ch && (ch.delta ? ch.delta.content : (ch.message ? ch.message.content : null)); if (c) content += c; } catch (e) {} } return { content: content, done: done }; } function callLLM(systemPrompt, userText, cfg, onStream) { var MAX_RETRIES = cfg.rateLimitRetries || 3; return doLLMRequest(0, !!(cfg.enableStreaming)); function doLLMRequest(attempt, useStream) { return new Promise(function(resolve, reject) { if (!cfg.apiBase) { reject(new Error('请先在设置面板「LLM 配置」中填写 API 地址(脚本不内置默认服务商)')); return; } if (!cfg.model) { reject(new Error('请先在设置面板「LLM 配置」中填写模型名称')); return; } var url = (cfg.proxyPrefix || '') + normalizeLLMUrl(cfg.apiBase); console.log('[LLM翻译] 请求地址: ' + url + (useStream ? ' (流式)' : '')); // 流式增量:按事件块(\n\n)累积,processedLen 记录已消费的字符位置 var streamFull = '', processedLen = 0; function onStreamProgress() { var norm = (xhr.responseText || '').replace(/\r\n/g, '\n'); var pos = processedLen; while (true) { var end = norm.indexOf('\n\n', pos); if (end === -1) break; var r = parseSSEBody(norm.substring(pos, end)); streamFull += r.content; pos = end + 2; } processedLen = pos; if (typeof onStream === 'function') onStream(streamFull.length); } 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 || 120) * 1000; if (useStream) xhr.onprogress = onStreamProgress; xhr.onload = function() { if (xhr.status >= 200 && xhr.status < 300) { var content; if (useStream) { content = streamFull.trim(); if (!content) { // 兜底1:onprogress 可能未触发/增量丢失 → 对完整响应文本全量解析 SSE content = parseSSEBody(xhr.responseText).content.trim(); if (!content) { // 兜底2:服务端可能忽略了 stream 参数直接返回完整 JSON try { var d2 = JSON.parse(xhr.responseText); content = d2.choices && d2.choices[0] && d2.choices[0].message && d2.choices[0].message.content; } catch (e2) { content = null; } } } } else { try { var data = JSON.parse(xhr.responseText); content = data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; } catch (e) { reject(new Error('API 返回格式错误:' + e.message)); return; } } if (content === null || content === undefined || content === '') { console.warn('[LLM翻译] 流式返回内容为空,响应前 300 字符: ' + String(xhr.responseText || '').slice(0, 300)); reject(new Error('API 返回内容为空')); } else { resolve(content.trim()); } } else { // 服务端不支持流式 → 回退非流式重发一次(不占用重试次数) if (useStream && (xhr.status === 400 || xhr.status === 415 || xhr.status === 501)) { console.log('[LLM翻译] 服务端不支持流式(HTTP ' + xhr.status + '),已回退非流式重发'); doLLMRequest(attempt, false).then(resolve, reject); return; } 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, useStream).then(resolve, reject); }, delay); return; } notifyRateLimitAndStop(); // 429 重试耗尽:弹窗提示并停止当前翻译任务 reject(new Error('429 多并发限流,已重试' + MAX_RETRIES + '次,请稍候重试')); return; } reject(new Error('API 请求失败 (' + xhr.status + '): ' + errText)); } }; xhr.onerror = function() { reject(new Error('网络错误:无法连接 ' + safeHost(url))); }; xhr.ontimeout = function() { reject(new Error('请求超时(' + (cfg.llmTimeout || 120) + '秒)→ ' + safeHost(url))); }; var body = { model: cfg.model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userText } ], temperature: cfg.llmTemperature || 0.1, max_tokens: cfg.llmMaxTokens || 4096, stream: useStream ? true : false }; 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 rlRetries = config.rateLimitRetries || 3; // 429/5xx 重试次数(可自定义) 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 < rlRetries) { var delay = (config.retryDelayBase || 2000) * Math.pow(2, attempt); console.warn('[LLM翻译] ' + resp.status + ' 限流/错误,' + (delay/1000) + 's 后重试'); await sleep(delay); continue; } if (resp.status === 429) notifyRateLimitAndStop(); // 429 重试耗尽:弹窗提示并停止 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, onStream) { 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 = (githubActive ? DEFAULT_GITHUB_PROMPT : config.systemPrompt).replace(/\{\{targetLang\}\}/g, config.targetLang); try { return await callLLM(prompt, text, config, onStream); } catch(e) { if (translateStopped || cancelRequested) throw e; // 429 停止/取消场景不再降级重试 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 (isGitHubFileNameCell(el)) return; // GitHub 文件/目录名单元格跳过,不翻译 var skip = getSkipTags(); if (skip[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 = 4000; // 单批上限调小:减小单次请求生成量,慢模型更不易超时 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++) { if (translateStopped || cancelRequested) break; 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++) { if (translateStopped || cancelRequested) break; var translated = await translateText(batches[b].text, function(n) { streamChars = n; }); 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++) { if (translateStopped || cancelRequested) break; 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++) { if (translateStopped || cancelRequested) break; 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++) { if (translateStopped || cancelRequested) break; var translated = await translateText(batches[b].text, function(n) { streamChars = n; }); 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++) { if (translateStopped || cancelRequested) break; 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(); } // ==================== 进度指示 ==================== var progressTimer = null; var progressStart = 0; var streamChars = 0; // 流式输出已接收字符数(展示用) 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; progressStart = Date.now(); streamChars = 0; if (progressTimer) clearInterval(progressTimer); function renderProgress() { var elapsed = Math.floor((Date.now() - progressStart) / 1000); var extra = streamChars > 0 ? ' · 已接收 ' + streamChars + ' 字符' : '(慢模型可能需 1-2 分钟,请耐心等待)'; progressEl.textContent = '翻译中 ' + current + '/' + total + ' (' + pct + '%) · 已等待 ' + elapsed + 's' + extra; } renderProgress(); progressTimer = setInterval(renderProgress, 1000); progressEl.style.display = ''; } function hideProgress() { if (progressTimer) { clearInterval(progressTimer); progressTimer = null; } if (progressEl) progressEl.style.display = 'none'; } // ==================== 自动翻译检查(页面加载后触发) ==================== checkAutoTranslate(); })(); /* * TransLite(Via 专版) — Open Source License (AGPL-3.0) * This program is free software: you can redistribute it and/or modify it under the * terms of the GNU Affero General Public License as published by the Free Software * Foundation, either version 3 of the License, or (at your option) any later version. * * TransLite(Via 专版) is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A * PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along * with this program. If not, see . * * --------------------------------------------------------------------------- * Full AGPL-3.0 license text:/n GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community to the greatest extent possible. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public Licenses for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. Developers that receive your program in Executable form may convert it into source code, even if they do not have the source code of your program. The GNU Affero General Public License specifically affirms the right of users who interact with the program over a network to receive a copy of the source code. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that receive your program in Executable form may convert it into source code, even if they do not have the source code of your program. The GNU Affero General Public License specifically affirms the right of users who interact with the program over a network to receive a copy of the source code. By "modified version" of the work, we mean either the work, or any derivative work under copyright law: that is to say, a work containing the program or a portion of it, either verbatim or with modifications and/or translated into another language. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of the work. "Executable code" means the source code, or a portion of it, after it has been processed by a special program (compiler, interpreter, etc.) so that it can be directly executed by a computer. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement the Standard Interface of an operating system (if it is a library that does that). A "Major Component", in this context, means the kernel, compiler, or other core component of a specific operating system (if any), or a compiler or interpreter for a specific programming language. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. "AGPL" also means the GNU Affero General Public License. "AGPL Cover" means the GNU Affero General Public License, either version 3 of the License, or (at your option) any later version. A work is "covered" by this License if it is subject to AGPL Cover. A work is "uncovered" if it is not covered by AGPL Cover. If you develop a new work, and you want it to be free software, the most effective way to do so is to state that it is subject to this License. The following guidelines will help you do so. a) You should put a notice like this near the beginning of each file: This file is part of [name of your program]. [name of your program] is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. [name of your program] is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with [name of your program]. If not, see . b) You should also include a copy of the GNU Affero General Public License in a file named "LICENSE" in the same directory as this file. c) If the program is interactive, you should make it output a short notice like this when it starts in an interactive mode: [name of your program] Copyright (C) [year] [your name] This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be different; for a GUI interface, you would use an "about box". d) You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, see . e) You should also include a copy of the GNU Affero General Public License in a file named "COPYING" in the same directory as this file. If you develop a new program, and you want it to be free software, the most effective way to do so is to state that it is subject to the GNU Affero General Public License. The following guidelines will help you do so. a) Put a notice like this near the beginning of each file: This file is part of [name of your program]. [name of your program] is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. [name of your program] is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with [name of your program]. If not, see . b) Also include a copy of the GNU Affero General Public License in a file named "LICENSE" in the same directory as this file. c) If the program is interactive, make it output a short notice like this when it starts in an interactive mode: [name of your program] Copyright (C) [year] [your name] This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be different; for a GUI interface, you would use an "about box". d) You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, see . e) Also include a copy of the GNU Affero General Public License in a file named "COPYING" in the same directory as this file. ============================================================= END OF TERMS AND CONDITIONS ============================================================= How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be free software, the most effective way to do so is to state that it is subject to this License. The following guidelines will help you do so. Attach these notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. TransLite Copyright (C) 2026 rewwoxv. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: TransLite Copyright (C) 2026 rewwoxv. This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the relevant parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, see . */