// ==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 = `
`;
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('已就绪,长按圆形图标进入设置');
})();