// ==UserScript== // @name 微软翻译 // @namespace microsoft-translator // @description 基于 Microsoft Translator 的网页翻译。鼠标移到左边缘弹出按钮,翻译后保持显示,再次点击恢复原文。已优化侧边栏/目录翻译。 // @version 1.1.1 // @license MIT // @author Qiu Zongman // @homepageURL https://gitee.com/qiuzongman/text/blob/main/TamperMonkey/microsoft-translator/ // @icon https://gitee.com/qiuzongman/text/raw/main/TamperMonkey/microsoft-translator/microsoft.png // @match *://*/* // @connect edge.microsoft.com // @connect api-edge.cognitive.microsofttranslator.com // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @run-at document-end // ==/UserScript== // // 测试网站: // https://www.nytimes.com/ // https://www.bbc.com/ // https://techcrunch.com/ // https://www.awwwards.com/ // https://ourworldindata.org/ // https://arxiv.org/html/2606.06593v1 // https://en.wikipedia.org/wiki/Computer // https://developer.mozilla.org/ // https://stackoverflow.com/ // https://github.com/python/cpython // // v1.1.1 改进: // - 设置面板入口去掉齿轮图标 // - 快捷键输入改为点击后按键自动捕获, 不再手动输入 // (function () { 'use strict'; if (window.top !== window.self) return; const AUTH = 'https://edge.microsoft.com/translate/auth'; const API = 'https://api-edge.cognitive.microsofttranslator.com/translate?api-version=3.0&to=zh-Hans&textType=plain'; const API_HTML = 'https://api-edge.cognitive.microsofttranslator.com/translate?api-version=3.0&to=zh-Hans&textType=html'; const MAX_UNITS = 5000; const MAX_CHARS = 500000; const CHUNK_NODES = 25; const CHUNK_CHARS = 8000; const CONCURRENCY = 6; const CACHE_LIMIT = 5000; let token = ''; let tokenTime = 0; let running = false; let cache = {}; let cacheKeys = []; let markNode = new WeakMap(), markEl = new WeakMap(), originNode = new WeakMap(), originEl = new WeakMap(); let originAttr = new WeakMap(), markAttr = new WeakMap(); let originNodes = [], originEls = [], originAttrEls = []; let isTranslated = false; let translateInProgress = false; // ---------- 设置 ---------- let settings = {}; const DEFAULT_SETTINGS = { selTranslate: false, autoTranslate: false, btnMode: 'hide', keyMod: 'alt', pageKey: 'A', selKey: 'S' }; let everTranslated = false; function loadSettings() { try { var raw = typeof GM_getValue === 'function' ? GM_getValue('ms_settings', null) : null; } catch(e) {} settings = raw ? { ...DEFAULT_SETTINGS, ...JSON.parse(raw) } : { ...DEFAULT_SETTINGS }; } function saveSettings() { try { if (typeof GM_setValue === 'function') GM_setValue('ms_settings', JSON.stringify(settings)); } catch(e) {} } const ZH_RE = /[\u4e00-\u9fff]/; const EXT_RE = /\.(apk|apks|xapk|zip|7z|rar|tar|gz|tgz|xz|img|iso|json|xml|yaml|yml|txt|md|dex|jar|so|ko|bin|exe|dll|rs|c|cpp|h|java|kt|py|js|ts|css|html)$/i; const URL_RE = /^https?:\/\//i; const TECH_STR_RE = /^[A-Za-z0-9._+@#:/\\()[\]-]+$/; const HEX_RE = /^[a-f0-9]{7,40}$/i; const VERSION_RE = /^v?\d+(\.\d+){1,4}([-+][A-Za-z0-9._-]+)?$/i; const CONST_RE = /^[A-Z0-9_]{2,12}$/; const CAMEL_RE = /^[A-Za-z]+[A-Z][A-Za-z0-9_.$-]*$/; const PUNCT_CODE_RE = /[,:;()]/; const METHOD_RE = /\b[A-Za-z_$][A-Za-z0-9_$]*\s*\(\s*\)/g; const ID_RE = /\b[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*\b/g; const COMMON_EN_WORDS = new Set([ 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'shall', 'should', 'may', 'might', 'can', 'could', 'of', 'in', 'on', 'at', 'to', 'for', 'with', 'and', 'or', 'but', 'not', 'this', 'that', 'these', 'those', 'it', 'they', 'we', 'you', 'he', 'she', 'i', 'from', 'by', 'about', 'as', 'into', 'like', 'through', 'after', 'over', 'between', 'out', 'against', 'during', 'without', 'before', 'under', 'around', 'among' ]); function hasNaturalLanguage(s) { var count = 0, words = s.toLowerCase().split(/\s+/); for (var i = 0; i < words.length; i++) { if (COMMON_EN_WORDS.has(words[i]) && ++count >= 2) return true; } return false; } function req(o, ok, bad) { var opt = { method: o.method || 'GET', url: o.url, headers: o.headers || {}, data: o.data, timeout: o.timeout || 16000 }; if (typeof GM !== 'undefined' && GM.xmlHttpRequest) GM.xmlHttpRequest(opt).then(ok).catch(function(e) { bad(e && e.message || String(e)); }); else if (typeof GM_xmlhttpRequest === 'function') GM_xmlhttpRequest({ method: opt.method, url: opt.url, headers: opt.headers, data: opt.data, timeout: opt.timeout, onload: ok, onerror: function() { bad('请求失败'); }, ontimeout: function() { bad('请求超时'); } }); else fetch(opt.url, { method: opt.method, headers: opt.headers, body: opt.data }).then(function(r) { return r.text().then(function(t) { ok({ status: r.status, responseText: t }); }); }).catch(function(e) { bad(e && e.message || String(e)); }); } function cleanup(s) { s = String(s || '').trim(); s = s.replace(/([\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])/g, '$1'); s = s.replace(/\s+([,。!?:;、)】》])/g, '$1'); s = s.replace(/([(【《])\s+/g, '$1'); s = s.replace(/([\u4e00-\u9fff])\s+([,。!?:;、])/g, '$1$2'); return s; } const HTML_PREFIX = '__MS_AUTO_ZH_HTML__'; function htmlKey(s) { return HTML_PREFIX + s; } function isHtmlKey(s) { return s.startsWith(HTML_PREFIX); } function stripHtmlKey(s) { return isHtmlKey(s) ? s.slice(HTML_PREFIX.length) : s; } function modeOfText(s) { return isHtmlKey(s) ? 'html' : 'plain'; } function setCache(src, dst) { if (!src || !dst || cache[src]) return; cache[src] = cleanup(dst); cacheKeys.push(src); if (cacheKeys.length > CACHE_LIMIT) { delete cache[cacheKeys.shift()]; } } function rememberNode(node, raw) { if (originNode.has(node)) return; originNode.set(node, raw); originNodes.push(node); } function rememberEl(el) { if (originEl.has(el)) return; originEl.set(el, el.innerHTML); originEls.push(el); } function rememberAttr(el, key, raw) { if (!el) return; var rec = originAttr.get(el); if (!rec) { rec = {}; originAttr.set(el, rec); originAttrEls.push(el); } if (!(key in rec)) rec[key] = raw; } function writeUnit(unit, dst) { if (!unit) return; if (unit.type === 'html') { if (!unit.el || !unit.el.isConnected) return; rememberEl(unit.el); unit.el.innerHTML = dst; markEl.set(unit.el, unit.el.textContent.trim()); } else if (unit.type === 'el') { if (!unit.el || !unit.el.isConnected) return; rememberEl(unit.el); unit.el.textContent = dst; markEl.set(unit.el, dst); } else if (unit.type === 'attr') { if (!unit.el || !unit.el.isConnected) return; rememberAttr(unit.el, unit.attr, unit.text); unit.el.setAttribute(unit.attr, dst); var m = markAttr.get(unit.el); if (!m) { m = {}; markAttr.set(unit.el, m); } m[unit.attr] = dst; } else { if (!unit.node || !unit.node.parentNode) return; rememberNode(unit.node, unit.raw); unit.node.nodeValue = unit.head + dst + unit.tail; markNode.set(unit.node, dst); } } function restoreOriginal() { for (var i = 0; i < originEls.length; i++) { var e = originEls[i]; if (e && e.parentNode && originEl.has(e)) e.innerHTML = originEl.get(e); } for (i = 0; i < originNodes.length; i++) { var n = originNodes[i]; if (n && n.parentNode && originNode.has(n)) n.nodeValue = originNode.get(n); } for (i = 0; i < originAttrEls.length; i++) { var a = originAttrEls[i]; var rec = a && originAttr.get(a); if (!a || !rec) continue; for (var key in rec) a.setAttribute(key, rec[key]); } originNode = new WeakMap(); originEl = new WeakMap(); originAttr = new WeakMap(); originNodes.length = 0; originEls.length = 0; originAttrEls.length = 0; markNode = new WeakMap(); markEl = new WeakMap(); markAttr = new WeakMap(); isTranslated = false; } function protect(t) { const s = String(t || '').trim(); if (!s || s.length < 2) return true; if (EXT_RE.test(s)) return true; if (URL_RE.test(s)) return true; if (TECH_STR_RE.test(s) && (s.match(/[._@#:/\\()[\]-]/g) || []).length >= 2) return true; if (HEX_RE.test(s)) return true; if (VERSION_RE.test(s)) return true; // 全大写常见英文词(ALL/TOP/NEW/YES/NO/OK)不拦截,技术缩写(URL/API/CSS等)仍拦截 if (CONST_RE.test(s) && !/^(ALL|TOP|NEW|YES|NO|OK)$/.test(s)) return true; // CAP_WORD_RE 已移除,之前误拦了 Home/News/Yes/No 等常见导航词 // 以 . 开头(如 .github、.gitignore)→ 文件/目录名 if (/^\.[a-zA-Z]/.test(s)) return true; // 单点扩展名模式(如 README.rst、configure.ac、aclocal.m4)→ 文件名 if (/^[a-zA-Z0-9][a-zA-Z0-9._-]*\.[a-zA-Z0-9]{2,6}$/.test(s)) return true; if (CAMEL_RE.test(s)) return true; const methodCalls = s.match(METHOD_RE) || []; if (methodCalls.length >= 2) return true; const ids = s.match(ID_RE) || []; let codeIds = 0; for (let i = 0; i < ids.length; i++) { const id = ids[i]; if (/[a-z][A-Z]|[_$]/.test(id) || /^[A-Z][A-Za-z0-9_$]*[A-Z][A-Za-z0-9_$]*$/.test(id)) codeIds++; } if (methodCalls.length >= 1 && codeIds >= 1) return true; if (codeIds >= 3 && PUNCT_CODE_RE.test(s)) { return !hasNaturalLanguage(s); } // 额外保护:纯符号或单个大写字母(如 "A", "B")不翻译 if (/^[^a-zA-Z\u4e00-\u9fff]*$/.test(s) || /^[A-Z]$/.test(s)) return true; return false; } function isCJK(cp) { return cp >= 0x4E00 && cp <= 0x9FFF; } function isLatin(cp) { return (cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A); } function isLetter(cp) { return (cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A) || (cp >= 0xC0 && cp <= 0x24F) || (cp >= 0x370 && cp <= 0x3FF) || (cp >= 0x400 && cp <= 0x52F) || (cp >= 0x590 && cp <= 0x5FF) || (cp >= 0x600 && cp <= 0x6FF) || (cp >= 0x900 && cp <= 0xDFF) || (cp >= 0xE00 && cp <= 0xE7F) || (cp >= 0x1000 && cp <= 0x109F) || (cp >= 0x10A0 && cp <= 0x10FF) || (cp >= 0x1200 && cp <= 0x137F) || (cp >= 0x1780 && cp <= 0x17FF) || (cp >= 0x3040 && cp <= 0x309F) || (cp >= 0x30A0 && cp <= 0x30FF) || (cp >= 0xAC00 && cp <= 0xD7AF); } function countLetters(t, nonLatinOnly) { return Array.from(String(t || '')).filter(function(c) { var cp = c.charCodeAt(0); if (isCJK(cp)) return false; if (nonLatinOnly && isLatin(cp)) return false; return isLetter(cp); }).length; } function should(t) { if (!t || t.length < 2) return false; if (protect(t)) return false; let zhCount = 0; for (let i = 0; i < t.length; i++) { if (isCJK(t.charCodeAt(i))) zhCount++; } // 放宽字母数要求:只需至少1个字母即可(原来为2),但需排除单字母或纯符号 if (zhCount > 0) return countLetters(t, true) >= 1; return countLetters(t, false) >= 1; } function rectEl(el) { if (!el || el.nodeType !== 1) return null; const r = el.getBoundingClientRect(); if (!r || (r.width === 0 && r.height === 0)) { return { top: 1e9, bottom: 0, left: 0, right: 0, width: 0, height: 0 }; } return r; } function rectOfNode(node) { return rectEl(node.parentElement); } function rectOfEl(el) { return rectEl(el); } function simpleRichBlock(el) { if (!el || !el.querySelector) return false; if (el.querySelector('pre,code,kbd,samp,var,script,style,textarea,input,select,button,svg,canvas,math,table')) return false; // 扩展允许的标签,包含列表和导航结构 const forbidden = el.querySelectorAll('*:not(a,span,b,strong,i,em,u,mark,small,sub,sup,br,ul,ol,li,nav,aside,header,footer)'); return forbidden.length === 0; } function collectShadowRoots(root) { const roots = []; const all = root.querySelectorAll('*'); for (let i = 0; i < all.length; i++) { if (all[i].shadowRoot) { roots.push(all[i].shadowRoot); const nested = collectShadowRoots(all[i].shadowRoot); for (let j = 0; j < nested.length; j++) roots.push(nested[j]); } } return roots; } function walkTextNodes(root, processedNodes, list, cacheHits) { const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false); let n; while ((n = w.nextNode())) { if (n.parentElement && n.parentElement.closest('pre,code,kbd,samp,var')) continue; if (processedNodes.has(n)) continue; const raw = n.nodeValue || ''; const text = raw.trim(); if (markNode.get(n) === text) continue; if (!should(text)) continue; const r = rectOfNode(n); if (!r) continue; const trimmedStart = raw.trimStart(); const head = raw.slice(0, raw.length - trimmedStart.length); const trimmedEnd = raw.trimEnd(); const tail = raw.slice(trimmedEnd.length); if (cache[text]) { writeUnit({ type: 'node', node: n, raw, text, head, tail, top: r.top }, cache[text]); cacheHits++; continue; } list.push({ type: 'node', node: n, raw, text, head, tail, top: r.top }); if (list.length >= MAX_UNITS) break; } return cacheHits; } function walkAttrs(root, list, cacheHits) { const ATTR_NAMES = ['title', 'placeholder', 'aria-label']; const all = root.querySelectorAll('*'); for (let i = 0; i < all.length; i++) { const el = all[i]; for (let j = 0; j < ATTR_NAMES.length; j++) { const attr = ATTR_NAMES[j]; const val = (el.getAttribute(attr) || '').trim(); if (!val) continue; var m = markAttr.get(el); if (m && m[attr] === val) continue; if (!should(val)) continue; if (cache[val]) { rememberAttr(el, attr, val); el.setAttribute(attr, cache[val]); cacheHits++; m = markAttr.get(el); if (!m) { m = {}; markAttr.set(el, m); } m[attr] = cache[val]; continue; } list.push({ type: 'attr', el, attr, text: val, top: 0 }); if (list.length >= MAX_UNITS) return cacheHits; } if (list.length >= MAX_UNITS) return cacheHits; } return cacheHits; } // ---------- 收集翻译单元 ---------- function collectUnits() { const list = []; let cacheHits = 0; const processedEls = new Set(); const processedNodes = new WeakSet(); // 1. 处理块级元素(段落、标题等) const blockSelector = 'p,blockquote,dd,figcaption,summary,h1,h2,h3,h4,h5,h6'; const blocks = document.querySelectorAll(blockSelector); for (let i = 0; i < blocks.length; i++) { const el = blocks[i]; if (processedEls.has(el)) continue; if (!simpleRichBlock(el)) continue; const trimmed = el.textContent.trim(); if (!trimmed || trimmed.length > 1600) continue; if (!should(trimmed) && countLetters(trimmed, true) < 2) continue; const html = el.innerHTML.trim(); const key = htmlKey(html); if (cache[key]) { const r = rectOfEl(el); if (r) { writeUnit({ type: 'html', el, text: key, top: r.top }, cache[key]); cacheHits++; processedEls.add(el); } continue; } const r = rectOfEl(el); if (!r) continue; processedEls.add(el); list.push({ type: 'html', el, text: key, top: r.top }); const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); let node; while ((node = walker.nextNode())) { processedNodes.add(node); } if (list.length >= MAX_UNITS) return { list, cacheHits }; } // 2. 处理叶子元素(包括新增的 标签) const elementSelector = 'p,li,h1,h2,h3,h4,h5,h6,dt,dd,figcaption,summary,blockquote,a'; const elements = document.querySelectorAll(elementSelector); for (let i = 0; i < elements.length; i++) { const el = elements[i]; if (processedEls.has(el)) continue; // 对于 标签,即使有子元素也尝试处理内部文本(但排除包含图片等复杂情况的链接) if (el.tagName === 'A' && el.children.length > 0) { // 如果子元素只有纯文本节点,也可以处理 if (el.querySelector('img,svg,canvas') || el.children.length > 3) continue; } else if (el.children.length > 0) { continue; } const trimmed = el.textContent.trim(); if (!should(trimmed) || trimmed.length > 1200) continue; if (cache[trimmed]) { const r = rectOfEl(el); if (r) { writeUnit({ type: 'el', el, text: trimmed, top: r.top }, cache[trimmed]); cacheHits++; processedEls.add(el); } continue; } const r = rectOfEl(el); if (!r) continue; processedEls.add(el); list.push({ type: 'el', el, text: trimmed, top: r.top }); if (list.length >= MAX_UNITS) return { list, cacheHits }; } // 3. 全局文本节点遍历(补充侧边栏等) cacheHits = walkTextNodes(document.body, processedNodes, list, cacheHits); // 4. 翻译属性 (title/placeholder/aria-label) cacheHits = walkAttrs(document.body, list, cacheHits); // 5. Shadow DOM 内容收集 const shadowRoots = collectShadowRoots(document.body); for (let r = 0; r < shadowRoots.length; r++) { const shadow = shadowRoots[r]; cacheHits = walkTextNodes(shadow, processedNodes, list, cacheHits); if (list.length >= MAX_UNITS) return { list, cacheHits }; cacheHits = walkAttrs(shadow, list, cacheHits); if (list.length >= MAX_UNITS) return { list, cacheHits }; } return { list, cacheHits }; } function collect() { return new Promise(resolve => { const doCollect = () => { const result = collectUnits(); result.list.sort((a, b) => { const av = a.top >= 0 && a.top <= innerHeight ? 0 : 1; const bv = b.top >= 0 && b.top <= innerHeight ? 0 : 1; if (av !== bv) return av - bv; return Math.abs(a.top - innerHeight / 2) - Math.abs(b.top - innerHeight / 2); }); const out = []; let chars = 0; for (let i = 0; i < result.list.length; i++) { out.push(result.list[i]); chars += result.list[i].text.length; if (out.length >= MAX_UNITS || chars >= MAX_CHARS) break; } resolve({ list: out, cacheHits: result.cacheHits }); }; if (typeof requestIdleCallback === 'function') { requestIdleCallback(doCollect, { timeout: 200 }); } else { setTimeout(doCollect, 0); } }); } function group(list) { const map = {}; const texts = []; for (let i = 0; i < list.length; i++) { const t = list[i].text; if (!map[t]) { map[t] = []; texts.push(t); } map[t].push(list[i]); } return { map, texts }; } function chunks(texts) { const out = []; let cur = []; let len = 0; let mode = ''; for (let i = 0; i < texts.length; i++) { const t = texts[i]; const nextMode = modeOfText(t); if (cur.length && (nextMode !== mode || cur.length >= CHUNK_NODES || len + stripHtmlKey(t).length > CHUNK_CHARS)) { out.push(cur); cur = []; len = 0; mode = ''; } if (!cur.length) mode = nextMode; cur.push(t); len += stripHtmlKey(t).length; } if (cur.length) out.push(cur); return out; } function traceId() { return Date.now() + '-' + Math.random().toString(16).slice(2); } function trans(arr, retry, cb) { const htmlMode = arr.length && isHtmlKey(arr[0]); const url = htmlMode ? API_HTML : API; const body = arr.map(t => ({ Text: stripHtmlKey(t) })); req({ method: 'POST', url, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token, 'X-ClientTraceId': traceId() }, data: JSON.stringify(body) }, r => { if ((r.status === 401 || r.status === 403) && retry) { token = ''; tokenTime = 0; getToken(err => { if (err) { cb(err); return; } trans(arr, false, cb); }); return; } if (r.status < 200 || r.status >= 300) { cb('翻译请求失败'); return; } let data; try { data = JSON.parse(r.responseText); } catch(e) { cb('返回格式错误'); return; } if (!Array.isArray(data)) { cb('返回格式错误'); return; } for (let i = 0; i < arr.length; i++) { const dst = data[i]?.translations?.[0]?.text; if (dst) setCache(arr[i], dst); } cb(''); }, e => { cb(e); }); } let pendingWrites = []; let rafId = null; function scheduleWrite(unit, dst) { pendingWrites.push({ unit, dst }); if (!rafId) { rafId = requestAnimationFrame(() => { const writes = pendingWrites; pendingWrites = []; rafId = null; for (let i = 0; i < writes.length; i++) { writeUnit(writes[i].unit, writes[i].dst); } }); } } function apply(g) { let n = 0; for (const src in g.map) { const dst = cache[src]; if (!dst) continue; const units = g.map[src]; for (let i = 0; i < units.length; i++) { scheduleWrite(units[i], dst); n++; } } return n; } function getToken(cb) { if (token && Date.now() - tokenTime < 8 * 60 * 1000) { cb(''); return; } req({ method: 'GET', url: AUTH }, r => { const t = String(r.responseText || '').trim(); if (r.status < 200 || r.status >= 300 || !t) { cb('获取令牌失败'); return; } token = t; tokenTime = Date.now(); cb(''); }, cb); } // ---------- 手动翻译主函数 ---------- async function startManual(callback) { if (running) { if (callback) callback(false, '翻译进行中'); return; } running = true; translateInProgress = true; const { list, cacheHits } = await collect(); if (!list.length) { running = false; translateInProgress = false; if (cacheHits > 0) { isTranslated = true; everTranslated = true; if (callback) callback(true, cacheHits); } else { if (callback) callback(false, '没有需要翻译的内容'); } return; } const g = group(list); const cs = chunks(g.texts); let index = 0; let active = 0; let changed = cacheHits; const total = cs.length; let done = 0; updateBtnProgress(0, total); function doneTranslate() { const finalize = function() { running = false; translateInProgress = false; if (changed > cacheHits) { isTranslated = true; everTranslated = true; if (callback) callback(true, changed); } else { if (callback) callback(false, '翻译失败'); } }; if (rafId) { requestAnimationFrame(finalize); } else { finalize(); } } getToken(err => { if (err) { running = false; translateInProgress = false; if (callback) callback(false, err); return; } function next() { if (!document.body) { running = false; translateInProgress = false; if (callback) callback(false, '已停止'); return; } while (active < CONCURRENCY && index < cs.length) { active++; trans(cs[index++], true, e => { active--; done++; if (e) { // 单个分片失败不终止,等全部完成后看是否有成功内容 if (index >= cs.length && active === 0) { doneTranslate(); } return; } changed += apply(g); updateBtnProgress(done, total); if (index >= cs.length && active === 0) { doneTranslate(); return; } next(); }); } } next(); }); } function restoreManual() { if (running || translateInProgress) return; restoreOriginal(); isTranslated = false; } // ---------- 划词翻译 ---------- let selBtn = null; let selResult = null; function createSelectionUI() { if (selBtn) return; selBtn = document.createElement('button'); selBtn.id = 'ms-sel-trans-btn'; selBtn.textContent = '译'; Object.assign(selBtn.style, { position: 'fixed', display: 'none', zIndex: '999998', padding: '4px 10px', backgroundColor: '#4285f4', color: 'white', border: 'none', borderRadius: '4px' }); document.body.appendChild(selBtn); selResult = document.createElement('div'); selResult.id = 'ms-sel-trans-result'; Object.assign(selResult.style, { position: 'fixed', display: 'none', zIndex: '999998', padding: '10px 14px', backgroundColor: 'white', color: '#333', border: '1px solid #999', maxWidth: '420px' }); document.body.appendChild(selResult); } function getSelText() { const sel = window.getSelection(); return sel ? sel.toString().trim() : ''; } function showSelBtn(x, y) { if (!selBtn || !settings.selTranslate) return; const t = getSelText(); if (!t || t.length < 2 || ZH_RE.test(t)) { hideSelBtn(); return; } selBtn.style.left = x + 'px'; selBtn.style.top = y + 'px'; selBtn.style.display = 'block'; } function hideSelBtn() { if (selBtn) selBtn.style.display = 'none'; } function showSelResult(text, x, y) { if (!selResult) return; selResult.textContent = text || ''; selResult.style.left = x + 'px'; selResult.style.top = y + 'px'; selResult.style.display = 'block'; } function hideSelResult() { if (selResult) selResult.style.display = 'none'; } function translateSelection() { const text = getSelText(); if (!text) return; showSelResult('翻译中…', parseInt(selResult.style.left) || 100, parseInt(selResult.style.top) || 100); if (cache[text]) { selResult.textContent = cache[text]; return; } trans([text], true, function(err) { selResult.textContent = err ? '翻译失败' : cleanup(cache[text] || text); }); } // ---------- 按钮进度 ---------- function updateBtnProgress(num, total) { const btn = document.getElementById('ms-manual-trans-btn'); if (!btn) return; let pn = btn.querySelector('.ms-bp'); if (!pn) { pn = document.createElement('span'); pn.className = 'ms-bp'; btn.appendChild(pn); } pn.textContent = num + '/' + total; } function clearBtnProgress() { var p = document.getElementById('ms-manual-trans-btn'); if (p) { p = p.querySelector('.ms-bp'); if (p) p.remove(); } } // ---------- 设置面板 ---------- let settingsPanel = null; function openSettings() { if (settingsPanel) { closeSettings(); return; } // 克隆当前设置,UI 修改只影响此副本 const ui = { ...settings }; settingsPanel = document.createElement('div'); settingsPanel.id = 'ms-settings-panel'; Object.assign(settingsPanel.style, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', zIndex: '1000000', backgroundColor: 'white', color: '#333', padding: '16px 20px', border: '1px solid #999' }); var s = 'display:block;margin:3px 0;cursor:pointer;font-size:13px', h = 'margin:8px 0 4px;font-weight:bold;font-size:13px'; settingsPanel.innerHTML = '
功能
' + '' + '' + '
快捷键
' + '
修饰键
' + '
页面翻译 ' + '
划词翻译 ' + '
翻译按钮
' + '' + '' + '
' + '' + '' + '
'; document.body.appendChild(settingsPanel); // 所有交互只修改 ui 副本 document.querySelectorAll('input[name="ms-btn-mode"]').forEach(function(el) { el.onchange = function() { ui.btnMode = this.value; }; }); document.querySelectorAll('input[name="ms-key-mod"]').forEach(function(el) { el.onchange = function() { ui.keyMod = this.value; // 修饰键标签已内联在 HTML 中,无需额外更新 }; }); // 快捷键捕获:点击输入框后按下键盘自动记录 settingsPanel.addEventListener('keydown', function(e) { var active = document.activeElement; if (!active || !active.classList.contains('ms-keycapture') || !active._cap) return; e.preventDefault(); e.stopPropagation(); var key = e.key === ' ' ? 'Space' : e.key.toUpperCase(); if (key.length !== 1 || !/^[A-Z]$/.test(key)) return; active.value = key; ui[active.id === 'ms-set-pagekey' ? 'pageKey' : 'selKey'] = key; active._cap = false; active.blur(); }, true); document.querySelectorAll('.ms-keycapture').forEach(function(inp) { inp.addEventListener('focus', function() { this._cap = true; this.value = ''; this.placeholder = '点击后按键'; }); inp.addEventListener('blur', function() { this._cap = false; this.placeholder = '点击后按键'; }); }); document.getElementById('ms-set-sel').onchange = function() { ui.selTranslate = this.checked; }; document.getElementById('ms-set-auto').onchange = function() { ui.autoTranslate = this.checked; }; // 保存:写入真实 settings document.getElementById('ms-set-save').onclick = function() { settings = { ...ui }; saveSettings(); // 刷新按钮位置:触发现有的 mouseleave 事件处理器来应用新 btnMode const btn = document.getElementById('ms-manual-trans-btn'); if (btn) btn.dispatchEvent(new Event('mouseleave')); if (settings.selTranslate && !selBtn) { createSelectionUI(); initSelectionEvents(); } else if (!settings.selTranslate && selBtn) { if (selBtn.parentNode) selBtn.remove(); if (selResult.parentNode) selResult.remove(); selBtn = null; selResult = null; } closeSettings(); }; // 取消:丢弃 ui,直接关闭 document.getElementById('ms-set-cancel').onclick = closeSettings; } function closeSettings() { if (settingsPanel) { settingsPanel.remove(); settingsPanel = null; } } // ---------- 按钮 ---------- function createButton() { if (document.getElementById('ms-manual-trans-btn')) return; const btn = document.createElement('button'); btn.id = 'ms-manual-trans-btn'; btn.textContent = '翻译'; Object.assign(btn.style, { fontSize: 'medium', position: 'fixed', top: '50%', transform: 'translateY(-50%)', zIndex: '999999', padding: '10px 18px', backgroundColor: '#4285f4', color: 'white', border: 'none', borderRadius: '0 8px 8px 0', transition: 'left 0.3s ease' }); let hideLeft = '0px'; btn._isHovered = false; function syncPosition() { if (isTranslated || translateInProgress || (settings.btnMode === 'show' && everTranslated)) { btn.style.left = '0px'; return; } if (btn._isHovered) { btn.style.left = '0px'; } else { btn.style.left = hideLeft; } } function updateHideLeft() { const showEdge = 10; hideLeft = -(btn.offsetWidth - showEdge) + 'px'; syncPosition(); } requestAnimationFrame(() => updateHideLeft()); if (typeof ResizeObserver !== 'undefined') { new ResizeObserver(() => updateHideLeft()).observe(btn); } btn.addEventListener('mouseenter', () => { btn._isHovered = true; syncPosition(); }); btn.addEventListener('mouseleave', () => { btn._isHovered = false; syncPosition(); }); btn.addEventListener('click', () => { if (translateInProgress) return; if (isTranslated) { restoreManual(); btn.textContent = '翻译'; btn.style.backgroundColor = '#4285f4'; syncPosition(); } else { btn.textContent = '翻译中'; btn.style.backgroundColor = '#f0ad4e'; syncPosition(); updateBtnProgress(0, 0); startManual((success, result) => { clearBtnProgress(); if (success) { btn.textContent = '恢复'; btn.style.backgroundColor = '#db4437'; } else { btn.textContent = '翻译'; btn.style.backgroundColor = '#4285f4'; console.error('翻译失败:', result); var tip = document.createElement('div'); tip.textContent = '翻译失败: ' + (result || '未知错误'); Object.assign(tip.style, { position: 'fixed', bottom: '90px', left: '20px', backgroundColor: 'rgba(0,0,0,0.7)', color: 'white', padding: '6px 12px', borderRadius: '6px', fontSize: '12px', zIndex: '999999' }); document.body.appendChild(tip); setTimeout(function() { tip.remove(); }, 2000); } syncPosition(); }); } }); document.body.appendChild(btn); requestAnimationFrame(() => updateHideLeft()); } function initSelectionEvents() { document.addEventListener('mouseup', function(e) { if (e.target && (e.target.id === 'ms-manual-trans-btn' || e.target.id === 'ms-sel-trans-btn' || (selResult && selResult.contains(e.target)))) return; setTimeout(function() { if (!settings.selTranslate) { hideSelBtn(); return; } const text = getSelText(); if (!text || text.length < 2 || ZH_RE.test(text)) { hideSelBtn(); return; } const range = window.getSelection().getRangeAt(0); const rect = range.getBoundingClientRect(); if (rect && (rect.width > 0 || rect.height > 0)) { showSelBtn(rect.right + 4, rect.top - 30); } }, 10); }); document.addEventListener('mousedown', function(e) { if (e.target && (e.target.id === 'ms-sel-trans-btn' || (selResult && selResult.contains(e.target)))) return; hideSelBtn(); hideSelResult(); }); if (selBtn) { selBtn.addEventListener('click', function(e) { e.stopPropagation(); const rect = selBtn.getBoundingClientRect(); showSelResult('翻译中…', rect.left, rect.bottom + 4); translateSelection(); }); } } function initSettings() { loadSettings(); if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('设置', openSettings); } } function initShortcuts() { document.addEventListener('keydown', function(e) { if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable)) return; var modOk = settings.keyMod === 'ctrl+alt' ? (e.ctrlKey && e.altKey && !e.shiftKey) : settings.keyMod === 'alt' ? (e.altKey && !e.ctrlKey && !e.shiftKey) : (e.ctrlKey && !e.altKey && !e.shiftKey); if (modOk && e.code === 'Key' + settings.pageKey) { e.preventDefault(); const btn = document.getElementById('ms-manual-trans-btn'); if (btn) btn.click(); return; } if (modOk && e.code === 'Key' + settings.selKey) { if (!settings.selTranslate) return; e.preventDefault(); const text = getSelText(); if (text && text.length >= 2 && !ZH_RE.test(text)) { if (!selBtn) { createSelectionUI(); initSelectionEvents(); } if (selResult) { selResult.textContent = '翻译中…'; selResult.style.display = 'block'; const rect = window.getSelection().getRangeAt(0).getBoundingClientRect(); selResult.style.left = rect.left + 'px'; selResult.style.top = (rect.bottom + 4) + 'px'; translateSelection(); } } return; } }); } function tryAutoTranslate() { if (!settings.autoTranslate) return; const doIt = function() { setTimeout(function() { const btn = document.getElementById('ms-manual-trans-btn'); if (btn && !isTranslated) btn.click(); }, 1500); }; if (document.readyState === 'complete') { doIt(); } else { window.addEventListener('load', doIt); } } function initUI() { createButton(); if (settings.selTranslate) { createSelectionUI(); initSelectionEvents(); } initShortcuts(); tryAutoTranslate(); } initSettings(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initUI); } else { initUI(); } getToken(() => {}); })();