// ==UserScript== // @name 本地翻译 // @namespace http://tampermonkey.net/ // @version 1.0 // @description 基于 Chrome 内置 Translator API:圆形悬浮、模态配置、原文对照、后台并发预翻译与渐进替换(需要 Chrome 131+), 见https://www.v2ex.com/t/1167770 // @author mirakyux // @match *://*/* // @grant none // @run-at document_idle // ==/UserScript== (async function () { 'use strict'; /* ========== 配置 & 缓存 ========== */ const STORAGE_KEY = 'tm_translator_cfg_v1'; const defaultCfg = { sourceLang: 'en', targetLang: 'zh', mode: 'page', // 'page' 或 'selection' keepOriginal: true, batchSize: 12, concurrency: 8, autoPretranslate: false // 是否页面加载后自动后台预翻译 }; let cfg = Object.assign({}, defaultCfg, loadCfg()); function loadCfg() { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : {}; } catch (e) { return {}; } } function saveCfg() { localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg)); } /* ========== 小工具 ========== */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function style(el, css) { Object.assign(el.style, css); } /* ========== 检查 Translator 可用性 ========== */ async function checkAvailability(source, target) { if (!('Translator' in self)) { console.error('[Translator] 不存在:请使用 Chrome 131+(Canary/Dev),并启用 chrome://flags/#translation-api 与 chrome://flags/#optimization-guide-on-device-model 。'); return { ok: false, state: 'no_api' }; } try { const state = await self.Translator.availability({ sourceLanguage: source, targetLanguage: target }); // state: 'unavailable' | 'downloadable' | 'downloading' | 'available' return { ok: state !== 'unavailable', state }; } catch (e) { console.error('检查 Translator.availability 出错:', e); return { ok: false, state: 'error', error: e }; } } /* 缓存 translator 实例 per language-pair */ const translators = new Map(); async function getTranslator(src, tgt) { const key = `${src}->${tgt}`; if (translators.has(key)) return translators.get(key); const inst = await self.Translator.create({ sourceLanguage: src, targetLanguage: tgt }); translators.set(key, inst); return inst; } /* ========== UI:圆形半透明悬浮按钮(可拖拽) ========== */ const circle = document.createElement('div'); circle.id = 'tm-translate-circle'; circle.title = '单击:翻译(长按打开设置)'; circle.innerText = '🌐'; style(circle, { position: 'fixed', right: '24px', bottom: '24px', width: '60px', height: '60px', borderRadius: '50%', background: 'rgba(0,123,255,0.78)', color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '24px', zIndex: 2147483647, cursor: 'pointer', boxShadow: '0 6px 18px rgba(0,0,0,0.28)', backdropFilter: 'blur(6px)', userSelect: 'none', transition: 'transform .18s, opacity .18s' }); document.body.appendChild(circle); circle.addEventListener('mouseenter', () => { circle.style.transform = 'scale(1.06)'; circle.style.opacity = '1'; }); circle.addEventListener('mouseleave', () => { circle.style.transform = 'scale(1)'; circle.style.opacity = '0.92'; }); // 拖拽 (function makeDraggable(el) { let dragging = false, startX = 0, startY = 0, origRight = 0, origBottom = 0; el.addEventListener('mousedown', (e) => { if (e.button !== 0) return; dragging = true; startX = e.clientX; startY = e.clientY; origRight = parseFloat(getComputedStyle(el).right); origBottom = parseFloat(getComputedStyle(el).bottom); document.body.style.userSelect = 'none'; }); window.addEventListener('mousemove', (e) => { if (!dragging) return; const dx = e.clientX - startX, dy = e.clientY - startY; el.style.right = (origRight - dx) + 'px'; el.style.bottom = (origBottom - dy) + 'px'; }); window.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; document.body.style.userSelect = ''; }); })(circle); // 长按弹出设置(700ms) let pressT; circle.addEventListener('mousedown', () => { pressT = setTimeout(() => { showConfigModal(); }, 700); }); circle.addEventListener('mouseup', () => clearTimeout(pressT)); circle.addEventListener('mouseleave', () => clearTimeout(pressT)); /* 单击触发翻译(根据模式) */ circle.addEventListener('click', async (e) => { e.preventDefault(); if (cfg.mode === 'page') { await startBackgroundPretranslate(); } else { // selection 模式:提示用户选中 const sel = window.getSelection().toString().trim(); if (!sel) { showToast('请先选中文本再点击翻译(或切换为整页模式)'); return; } await translateSelectionAndShow(sel); } }); /* ========== 小 toast 用于提示 ========== */ const toast = document.createElement('div'); toast.id = 'tm-toast'; style(toast, { position: 'fixed', right: '24px', bottom: '96px', zIndex: 2147483646, background: 'rgba(0,0,0,0.7)', color: '#fff', padding: '8px 12px', borderRadius: '8px', fontSize: '13px', display: 'none' }); document.body.appendChild(toast); let toastTimer = null; function showToast(text, dur = 2500) { toast.innerText = text; toast.style.display = 'block'; if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toast.style.display = 'none'; }, dur); } /* ========== 模态:配置面板 ========== */ function showConfigModal() { if (document.getElementById('tm-config-modal')) return; const wrapper = document.createElement('div'); wrapper.id = 'tm-config-modal'; style(wrapper, { position: 'fixed', inset: '0', zIndex: 2147483647, display: 'flex', alignItems: 'center', justifyContent: 'center' }); wrapper.innerHTML = `

翻译设置

检测中...
`; document.body.appendChild(wrapper); // set current mode selection document.getElementById('tm-mode').value = cfg.mode; async function refreshApiStatus() { const src = document.getElementById('tm-src').value.trim() || cfg.sourceLang; const tgt = document.getElementById('tm-tgt').value.trim() || cfg.targetLang; const statusEl = document.getElementById('tm-api-status'); statusEl.innerText = '检测中...'; const res = await checkAvailability(src, tgt); if (res.state === 'available') { statusEl.innerHTML = '✅ Translator API 可用(可立即创建会话)'; } else if (res.state === 'downloadable') { statusEl.innerHTML = '📦 模型可下载(首次使用将触发下载)'; } else if (res.state === 'downloading') { statusEl.innerHTML = '⬇️ 模型正在下载,请稍候'; } else if (res.state === 'unavailable') { statusEl.innerHTML = '❌ 不支持该语言对或设备受限'; } else if (res.state === 'no_api') { statusEl.innerHTML = '❌ 浏览器不支持 Translator API(请启用实验特性)'; } else { statusEl.innerHTML = '❌ 检测失败(控制台查看错误)'; } } document.getElementById('tm-test').addEventListener('click', refreshApiStatus); document.getElementById('tm-cancel').addEventListener('click', () => wrapper.remove()); document.getElementById('tm-save').addEventListener('click', async () => { cfg.sourceLang = document.getElementById('tm-src').value.trim() || cfg.sourceLang; cfg.targetLang = document.getElementById('tm-tgt').value.trim() || cfg.targetLang; cfg.keepOriginal = document.getElementById('tm-keep').checked; cfg.autoPretranslate = document.getElementById('tm-auto').checked; cfg.mode = document.getElementById('tm-mode').value; cfg.batchSize = Math.max(2, parseInt(document.getElementById('tm-batch').value) || cfg.batchSize); cfg.concurrency = Math.max(1, parseInt(document.getElementById('tm-conc').value) || cfg.concurrency); saveCfg(); showToast('设置已保存'); wrapper.remove(); }); // initial status refreshApiStatus(); } /* ========== 翻译选中并在浮层显示 ========== */ async function translateSelectionAndShow(text, opts = {}) { const avail = await checkAvailability(cfg.sourceLang, cfg.targetLang); if (!avail.ok) { showToast('Translator API 不可用:请查看控制台与模态检测提示'); console.warn('Translator API 状态:', avail); return; } const translator = await getTranslator(cfg.sourceLang, cfg.targetLang); try { const res = await translator.translate(text); showResultModal({ title: '选中翻译', original: text, translated: res, anchorRect: opts.anchorRect }); } catch (err) { console.error('划词翻译失败:', err); showToast('翻译失败(控制台查看)'); } } /* ========== 结果模态 ========== */ function showResultModal({ title = '翻译', original = '', translated = '', anchorRect = null } = {}) { // create container const id = 'tm-result-modal'; const exist = document.getElementById(id); if (exist) exist.remove(); const wrap = document.createElement('div'); wrap.id = id; style(wrap, { position: 'fixed', inset: 0, zIndex: 2147483647, display: 'flex', alignItems: 'center', justifyContent: 'center' }); wrap.innerHTML = `

${title}

`; document.body.appendChild(wrap); document.getElementById('tm-close-res').addEventListener('click', () => wrap.remove()); document.getElementById('tm-copy-trans').addEventListener('click', async () => { try { await navigator.clipboard.writeText(translated); showToast('译文已复制'); } catch (e) { showToast('复制失败'); } }); // if anchorRect provided, optionally position small tooltip instead; for simplicity we use modal center } /* ========== 批量后台预翻译(多线程并发、渐进替换) ========== */ // Helper: 判断一个文本节点是否可翻译(含英文/非目标语言字符判断) function isNodeTranslatable(node) { if (!node || !node.nodeValue) return false; const txt = node.nodeValue.trim(); if (!txt) return false; // 排除脚本、样式、meta 等 const p = node.parentElement; if (!p) return false; const tag = p.tagName; if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'CODE', 'PRE', 'TEXTAREA'].includes(tag)) return false; if (p.closest('[contenteditable="true"]')) return false; // 太短或纯标点的忽略 if (txt.length < 2) return false; // 过滤掉数据绑定/URL if (/^https?:\/\//.test(txt)) return false; return true; } // collect text nodes in document order function collectTextNodes() { const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode: (node) => isNodeTranslatable(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT }); const arr = []; let n; while ((n = walker.nextNode())) arr.push(n); return arr; } // Replace a node with translated text, optionally wrap to show original on hover function applyTranslationToNode(node, translated, keepOriginal) { if (!node || node.parentNode == null) return; if (keepOriginal) { const span = document.createElement('span'); span.setAttribute('data-tm-original', node.nodeValue); span.setAttribute('title', node.nodeValue); // hover shows original span.style.borderBottom = '1px dashed rgba(0,0,0,0.12)'; span.style.cursor = 'help'; span.textContent = translated; node.parentNode.replaceChild(span, node); } else { node.nodeValue = translated; } } // Main pretranslate routine let pretranslateRunning = false; async function startBackgroundPretranslate() { if (pretranslateRunning) { showToast('正在后台翻译中'); return; } const avail = await checkAvailability(cfg.sourceLang, cfg.targetLang); if (!avail.ok) { showToast('Translator API 不可用:请在设置中检测或查看控制台'); console.warn('Translator API 状态:', avail); return; } pretranslateRunning = true; showToast('开始后台翻译...(控制台显示进度)', 2500); console.log('[tm-translator] 收集文本节点...'); const nodes = collectTextNodes(); console.log(`[tm-translator] 共找到 ${nodes.length} 个可翻译节点(可能包含重复/结构化文本)`); const translator = await getTranslator(cfg.sourceLang, cfg.targetLang); const total = nodes.length; const batchSize = Math.max(2, parseInt(cfg.batchSize) || defaultCfg.batchSize); const concurrency = Math.max(1, parseInt(cfg.concurrency) || defaultCfg.concurrency); let done = 0; for (let i = 0; i < nodes.length; i += batchSize) { const batch = nodes.slice(i, i + batchSize); // within a batch, process in groups sized by concurrency const groupPromises = []; for (let j = 0; j < batch.length; j += concurrency) { const group = batch.slice(j, j + concurrency); const p = Promise.allSettled(group.map(async (node) => { try { const srcText = node.nodeValue.trim(); if (!srcText) return { ok: false }; // Avoid giant块 if (srcText.length > 2000) return { ok: false }; const translated = await translator.translate(srcText); // Apply translation to DOM (gradual) applyTranslationToNode(node, translated, cfg.keepOriginal); done++; return { ok: true }; } catch (e) { console.warn('单节点翻译失败', e); return { ok: false, error: e }; } })); groupPromises.push(p); } // await all groups in this batch await Promise.all(groupPromises); console.log(`[tm-translator] 进度:${Math.min(done, total)}/${total}`); // small sleep between batches so页面不卡顿 await sleep(220); } pretranslateRunning = false; showToast('后台翻译完成', 3000); console.log('[tm-translator] 后台翻译完成'); } /* ========== 页面加载后自动预翻译(可选) ========== */ if (cfg.autoPretranslate) { // 延迟启动,给页面一个渲染缓冲 setTimeout(() => { startBackgroundPretranslate(); }, 1600); } /* ========== 在 DOM 增加监听:如果新节点(动态内容)出现,按模式决定是否自动翻译 ========== */ const mo = new MutationObserver(async (mutList) => { if (!cfg.autoPretranslate) return; // collect newly added text nodes quickly let added = 0; for (const m of mutList) { for (const node of m.addedNodes) { if (node.nodeType === Node.TEXT_NODE && isNodeTranslatable(node)) { added++; } else if (node.nodeType === 1) { // an element added — we may collect its text children const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { acceptNode: (n) => isNodeTranslatable(n) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT }); while (walker.nextNode()) added++; } } } if (added > 0 && !pretranslateRunning) { // do a small incremental translate (not whole page) console.log(`[tm-translator] 检测到 ${added} 个新增文本节点,触发增量翻译`); startBackgroundPretranslate(); } }); mo.observe(document.body, { childList: true, subtree: true }); /* ========== 页面卸载清理 ========== */ window.addEventListener('beforeunload', () => { translators.forEach((t) => { try { t?.destroy?.(); } catch (e) { } }); }); /* ========== 最后:控制台友好启用提示 ========== */ console.log('%c[tm-translator] 脚本已加载 — 基于 Chrome 内置 Translator API', 'font-weight:700;color:#0a66c2'); console.log('配置:', cfg); console.log('如果看不到翻译功能,请:'); console.log('- 使用 Chrome 131+ (Canary/Dev);'); console.log('- 打开 chrome://flags/#translation-api 和 chrome://flags/#optimization-guide-on-device-model 并启用;'); console.log('- 首次使用可能需要下载模型(几 MB ~ 上百 MB,视语言)。在模态中点击「检测 API」可以查看 availability 状态。'); // 提示快速操作 showToast('已就绪,长按圆形图标进入设置'); })();