自己折腾了一个 Tampermonkey 脚本,功能比较实用:
- 划词后出现红点,点击直接翻译选中文字
- 旁边有蓝点,点击可全文翻译
- 全文翻译是直接替换页面文本节点
- 支持恢复原文
- 支持多个 API 自动故障回退
- 点页面其它地方,红蓝点会自动消失
适合拿来自定义接第三方翻译/LLM API,用来看英文网页挺方便。
希望对大家有用,不用折腾沉浸式翻译了,这个就是沉浸式,简单方便好用。好用点赞啊。


// ==UserScript==
// @name 划词翻译 + 全文翻译(红点/蓝点/恢复原文/自动回退)
// @name:en Selection Translate + Full Page Translate
// @namespace http://tampermonkey.net/
// @version 3.2
// @description 红点翻译划词,蓝点全文翻译。直接替换页面文本节点,支持恢复原文,支持多API自动故障回退。
// @author AI Assistant
// @match *://*/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @connect *
// @license MIT
// ==/UserScript==
(function () {
'use strict';
if (window.__AI_TRANSLATOR_FULLPAGE_RUNNING__) {
console.log('翻译脚本已在运行,本次加载被阻止。');
return;
}
window.__AI_TRANSLATOR_FULLPAGE_RUNNING__ = true;
const API_PROVIDERS = [
{
endpoint: 'https://YOUR_API_ENDPOINT_1/chat/completions',
key: 'YOUR_API_KEY_1',
model: 'gemini-translate-pro',
prompt: '{text}',
batchPrompt: `Translate the following multiple text segments into simplified Chinese.
Rules:
1. Keep the numbering markers EXACTLY as they are.
2. Do not add explanations.
3. Do not omit any item.
4. Preserve formatting as much as possible.
5. Output ONLY the translated result in the same marker format.
{text}`
},
{
endpoint: 'https://YOUR_API_ENDPOINT_2/chat/completions',
key: 'YOUR_API_KEY_2',
model: 'gemini-2.5-flash',
prompt: `Translate the following text into simplified Chinese, keeping the original formatting as much as possible. Provide only the translated content, without any extra explanations or introductory phrases.\n\nText to translate:\n"""\n{text}\n"""`,
batchPrompt: `Translate the following multiple text segments into simplified Chinese.
Rules:
1. Keep the numbering markers EXACTLY as they are.
2. Do not add explanations.
3. Do not omit any item.
4. Preserve formatting as much as possible.
5. Output ONLY the translated result in the same marker format.
{text}`
}
];
const REQUEST_TIMEOUT = 45000;
const BATCH_MAX_ITEMS = 12;
const BATCH_MAX_CHARS = 3200;
const MIN_TEXT_LENGTH = 2;
const MAIN_CONTENT_SELECTORS = [
'article',
'main',
'[role="main"]',
'.article',
'.post',
'.entry-content',
'.content',
'.markdown-body',
'.doc-content',
'.main-content'
];
const EXCLUDED_TAGS = new Set([
'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'OPTION',
'CODE', 'PRE', 'KBD', 'SAMP', 'SVG', 'CANVAS'
]);
const INLINE_UI_EXCLUDE_SELECTORS = [
'#ai-translator-btn',
'#ai-fullpage-btn',
'#ai-translator-popup',
'#ai-translate-toolbar'
];
const isConfigIncomplete = API_PROVIDERS.some(p =>
p.endpoint.includes('YOUR_API_ENDPOINT') || p.key.includes('YOUR_API_KEY')
);
if (isConfigIncomplete) {
alert('【划词/全文翻译脚本】\n\n请先在脚本顶部“用户配置区”填入你的 API endpoint 和 API key。');
return;
}
GM_addStyle(`
#ai-translator-btn, #ai-fullpage-btn {
position: absolute;
width: 10px;
height: 10px;
border-radius: 50%;
cursor: pointer;
z-index: 999999;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
border: 2px solid white;
transform: translate(-50%, -50%);
opacity: 0.72;
transition: transform 0.2s ease, opacity 0.2s ease;
}
#ai-translator-btn:hover, #ai-fullpage-btn:hover {
transform: translate(-50%, -50%) scale(1.2);
opacity: 1;
}
#ai-translator-btn {
background-color: #ff4757;
}
#ai-fullpage-btn {
background-color: #1e90ff;
}
#ai-translator-popup {
position: absolute;
z-index: 999998;
background-color: #FFFBEF;
border: 1px solid #EAEAEA;
border-radius: 8px;
box-shadow: 0 5px 16px rgba(0, 0, 0, 0.14);
padding: 14px 16px;
max-width: 460px;
min-width: 220px;
font-size: 14px;
line-height: 1.65;
color: #333;
text-align: left;
white-space: pre-wrap;
word-wrap: break-word;
transform: translate(-50%, -50%);
box-sizing: border-box;
}
#ai-translate-toolbar {
position: fixed;
right: 16px;
bottom: 18px;
z-index: 999999;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: rgba(28, 28, 32, 0.92);
color: #fff;
border-radius: 10px;
box-shadow: 0 6px 24px rgba(0,0,0,0.28);
font-size: 12px;
user-select: none;
backdrop-filter: blur(6px);
}
#ai-translate-toolbar button {
border: none;
border-radius: 8px;
padding: 6px 10px;
cursor: pointer;
color: #fff;
font-size: 12px;
line-height: 1;
}
#ai-translate-toolbar button:hover {
opacity: 0.9;
}
#ai-restore-btn {
background: #ff6b6b;
}
#ai-retranslate-btn {
background: #4dabf7;
}
#ai-toolbar-close-btn {
background: #666;
}
#ai-translate-status {
max-width: 220px;
color: #f1f3f5;
opacity: 0.95;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
`);
let translateBtn = null;
let fullPageBtn = null;
let translatePopup = null;
let toolbar = null;
let statusTextEl = null;
let isFullPageTranslating = false;
let pageTranslated = false;
const originalTextMap = new WeakMap();
function cleanupDotsAndPopup() {
if (translateBtn) {
translateBtn.remove();
translateBtn = null;
}
if (fullPageBtn) {
fullPageBtn.remove();
fullPageBtn = null;
}
if (translatePopup) {
translatePopup.remove();
translatePopup = null;
}
}
function clearSelection() {
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
}
}
function ensureToolbar() {
if (toolbar) return;
toolbar = document.createElement('div');
toolbar.id = 'ai-translate-toolbar';
toolbar.innerHTML = `
<button id="ai-restore-btn" title="恢复原文">恢复原文</button>
<button id="ai-retranslate-btn" title="翻译新增内容">翻译新增</button>
<span id="ai-translate-status">未翻译</span>
<button id="ai-toolbar-close-btn" title="关闭">×</button>
`;
document.body.appendChild(toolbar);
statusTextEl = toolbar.querySelector('#ai-translate-status');
toolbar.querySelector('#ai-restore-btn').addEventListener('click', () => {
restoreOriginalPage();
});
toolbar.querySelector('#ai-retranslate-btn').addEventListener('click', async () => {
if (isFullPageTranslating) return;
try {
await translateEntirePage({ onlyUntranslated: true });
} catch (e) {
setStatus('翻译新增失败:' + e.message);
}
});
toolbar.querySelector('#ai-toolbar-close-btn').addEventListener('click', () => {
restoreOriginalPage();
toolbar.remove();
toolbar = null;
statusTextEl = null;
});
}
function setStatus(text) {
ensureToolbar();
if (statusTextEl) statusTextEl.textContent = text;
console.log('[AI翻译]', text);
}
function showPopup(text, top, left) {
if (translatePopup) translatePopup.remove();
translatePopup = document.createElement('div');
translatePopup.id = 'ai-translator-popup';
translatePopup.textContent = text;
document.body.appendChild(translatePopup);
translatePopup.style.top = `${top}px`;
translatePopup.style.left = `${left}px`;
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function chunkByRules(items, maxItems, maxChars) {
const batches = [];
let current = [];
let currentChars = 0;
for (const item of items) {
const len = item.text.length;
if (
current.length >= maxItems ||
(current.length > 0 && currentChars + len > maxChars)
) {
batches.push(current);
current = [];
currentChars = 0;
}
current.push(item);
currentChars += len;
}
if (current.length) batches.push(current);
return batches;
}
function isElementVisible(el) {
if (!el || !el.isConnected) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
return true;
}
function isInsideExcludedUI(node) {
let el = node.parentElement;
while (el) {
for (const selector of INLINE_UI_EXCLUDE_SELECTORS) {
if (el.matches && el.matches(selector)) return true;
}
el = el.parentElement;
}
return false;
}
function hasMeaningfulLatinText(text) {
return /[A-Za-z\u00C0-\u024F]/.test(text);
}
function getMainContentRoot() {
for (const selector of MAIN_CONTENT_SELECTORS) {
const el = document.querySelector(selector);
if (el && el.innerText && el.innerText.trim().length > 100) {
return el;
}
}
return document.body;
}
function extractCoreText(raw) {
const leading = raw.match(/^\s*/)?.[0] || '';
const trailing = raw.match(/\s*$/)?.[0] || '';
const core = raw.trim();
return { leading, core, trailing };
}
function getTranslatableTextNodes(root, onlyUntranslated = true) {
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT;
if (onlyUntranslated && originalTextMap.has(node)) return NodeFilter.FILTER_REJECT;
const raw = node.nodeValue;
const trimmed = raw.trim();
if (!trimmed || trimmed.length < MIN_TEXT_LENGTH) {
return NodeFilter.FILTER_REJECT;
}
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
if (EXCLUDED_TAGS.has(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
if (isInsideExcludedUI(node)) {
return NodeFilter.FILTER_REJECT;
}
if (!isElementVisible(parent)) {
return NodeFilter.FILTER_REJECT;
}
if (!/[^\d\s\p{P}]/u.test(trimmed) && !hasMeaningfulLatinText(trimmed)) {
return NodeFilter.FILTER_REJECT;
}
if (trimmed.length < MIN_TEXT_LENGTH) {
return NodeFilter.FILTER_REJECT;
}
if (!hasMeaningfulLatinText(trimmed)) {
return NodeFilter.FILTER_REJECT;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
const nodes = [];
let current;
while ((current = walker.nextNode())) {
nodes.push(current);
}
return nodes;
}
function requestChatCompletion(provider, promptText) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: provider.endpoint,
timeout: REQUEST_TIMEOUT,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${provider.key}`
},
data: JSON.stringify({
model: provider.model,
messages: [{ role: 'user', content: promptText }],
temperature: 0.1,
stream: false
}),
onload: function (response) {
try {
const data = JSON.parse(response.responseText);
const content = data?.choices?.[0]?.message?.content?.trim();
if (content) {
resolve(content);
} else {
reject(new Error(`无有效返回内容,HTTP ${response.status}`));
}
} catch (e) {
reject(new Error('响应解析失败'));
}
},
onerror: function (response) {
reject(new Error(`网络错误 ${response?.status || ''}`));
},
ontimeout: function () {
reject(new Error('请求超时'));
}
});
});
}
function fillTemplate(template, text) {
return (template || '{text}').replace('{text}', text);
}
async function translateSingleText(text) {
let lastErr = null;
for (let i = 0; i < API_PROVIDERS.length; i++) {
const provider = API_PROVIDERS[i];
const promptText = fillTemplate(provider.prompt, text);
try {
return await requestChatCompletion(provider, promptText);
} catch (e) {
console.warn(`单条翻译 API[${i}] 失败:`, e.message);
lastErr = e;
}
}
throw lastErr || new Error('单条翻译失败:所有API均不可用');
}
function buildBatchPayload(items) {
return items.map((item, idx) => {
return `<<<ITEM_${idx + 1}>>>\n${item.text}`;
}).join('\n\n');
}
function parseBatchResponse(responseText, expectedCount) {
const results = new Array(expectedCount).fill('');
const regex = /<<<ITEM_(\d+)>>>\s*([\s\S]*?)(?=\n\s*<<<ITEM_\d+>>>|$)/g;
let match;
while ((match = regex.exec(responseText)) !== null) {
const index = parseInt(match[1], 10) - 1;
if (index >= 0 && index < expectedCount) {
results[index] = (match[2] || '').trim();
}
}
return results;
}
async function translateBatch(batchItems) {
const batchText = buildBatchPayload(batchItems);
let lastErr = null;
for (let i = 0; i < API_PROVIDERS.length; i++) {
const provider = API_PROVIDERS[i];
const prompt = fillTemplate(provider.batchPrompt, batchText);
try {
const response = await requestChatCompletion(provider, prompt);
const parsed = parseBatchResponse(response, batchItems.length);
const validCount = parsed.filter(Boolean).length;
if (validCount < Math.ceil(batchItems.length * 0.7)) {
throw new Error('批量解析质量过低,转下一个API或单条翻译');
}
return parsed.map((t, idx) => t || batchItems[idx].text);
} catch (e) {
console.warn(`批量翻译 API[${i}] 失败:`, e.message);
lastErr = e;
}
}
console.warn('批量翻译全部失败,降级为单条翻译:', lastErr?.message || '');
const results = [];
for (const item of batchItems) {
try {
const one = await translateSingleText(item.text);
results.push(one || item.text);
await sleep(120);
} catch {
results.push(item.text);
}
}
return results;
}
async function handleSelectionTranslate(text, top, left) {
showPopup('翻译中...', top, left);
try {
const translated = await translateSingleText(text);
if (translatePopup) translatePopup.textContent = translated || '翻译失败';
} catch (e) {
if (translatePopup) translatePopup.textContent = '翻译失败:' + e.message;
}
}
async function translateEntirePage({ onlyUntranslated = true } = {}) {
if (isFullPageTranslating) {
setStatus('正在翻译中,请稍候...');
return;
}
isFullPageTranslating = true;
ensureToolbar();
try {
const root = getMainContentRoot();
const nodes = getTranslatableTextNodes(root, onlyUntranslated);
if (!nodes.length) {
setStatus(onlyUntranslated ? '没有发现新的可翻译内容' : '未找到可翻译文本');
return;
}
const items = nodes.map(node => {
const raw = node.nodeValue;
const { leading, core, trailing } = extractCoreText(raw);
return {
node,
raw,
leading,
core,
trailing,
text: core
};
}).filter(item => item.text && item.text.length >= MIN_TEXT_LENGTH);
if (!items.length) {
setStatus('未找到有效文本');
return;
}
const batches = chunkByRules(items, BATCH_MAX_ITEMS, BATCH_MAX_CHARS);
setStatus(`准备翻译:${items.length} 段文本,${batches.length} 批`);
let done = 0;
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
setStatus(`翻译中:第 ${i + 1}/${batches.length} 批`);
const translatedTexts = await translateBatch(batch);
batch.forEach((item, idx) => {
const translated = translatedTexts[idx];
if (!translated) return;
if (!originalTextMap.has(item.node)) {
originalTextMap.set(item.node, item.raw);
}
item.node.nodeValue = item.leading + translated + item.trailing;
done++;
});
await sleep(150);
}
pageTranslated = true;
setStatus(`全文翻译完成:已替换 ${done} 段文本`);
} catch (e) {
setStatus('全文翻译失败:' + e.message);
throw e;
} finally {
isFullPageTranslating = false;
}
}
function restoreOriginalPage() {
const root = getMainContentRoot();
const walker = document.createTreeWalker(
root,
NodeFilter.SHOW_TEXT,
null
);
let count = 0;
let node;
while ((node = walker.nextNode())) {
if (originalTextMap.has(node)) {
node.nodeValue = originalTextMap.get(node);
count++;
}
}
pageTranslated = false;
setStatus(`已恢复原文:${count} 处`);
}
document.addEventListener('mouseup', function (e) {
if (
e.target?.id === 'ai-translator-btn' ||
e.target?.id === 'ai-fullpage-btn' ||
(translatePopup && translatePopup.contains(e.target)) ||
(toolbar && toolbar.contains(e.target))
) {
return;
}
cleanupDotsAndPopup();
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.toString().trim()) {
return;
}
const selectedText = selection.toString().trim();
let range;
try {
range = selection.getRangeAt(0);
} catch {
return;
}
const rect = range.getBoundingClientRect();
if (!rect) return;
const centerX = window.scrollX + rect.left + rect.width / 2;
const centerY = window.scrollY + rect.top + rect.height / 2;
translateBtn = document.createElement('div');
translateBtn.id = 'ai-translator-btn';
document.body.appendChild(translateBtn);
translateBtn.style.top = `${centerY}px`;
translateBtn.style.left = `${centerX}px`;
fullPageBtn = document.createElement('div');
fullPageBtn.id = 'ai-fullpage-btn';
document.body.appendChild(fullPageBtn);
fullPageBtn.style.top = `${centerY}px`;
fullPageBtn.style.left = `${centerX + 18}px`;
translateBtn.addEventListener('click', function (event) {
event.stopPropagation();
handleSelectionTranslate(selectedText, centerY + 24, centerX);
if (translateBtn) translateBtn.style.display = 'none';
if (fullPageBtn) fullPageBtn.style.display = 'none';
});
fullPageBtn.addEventListener('click', async function (event) {
event.stopPropagation();
showPopup('全文翻译中,请稍候...', centerY + 24, centerX + 60);
if (translateBtn) translateBtn.style.display = 'none';
if (fullPageBtn) fullPageBtn.style.display = 'none';
try {
await translateEntirePage({ onlyUntranslated: true });
if (translatePopup) {
translatePopup.textContent = '全文翻译完成';
setTimeout(() => {
if (translatePopup) {
translatePopup.remove();
translatePopup = null;
}
}, 1200);
}
} catch (e) {
if (translatePopup) translatePopup.textContent = '全文翻译失败:' + e.message;
}
});
});
document.addEventListener('mousedown', function (e) {
if (
(translatePopup && translatePopup.contains(e.target)) ||
(toolbar && toolbar.contains(e.target)) ||
e.target?.id === 'ai-translator-btn' ||
e.target?.id === 'ai-fullpage-btn'
) {
return;
}
cleanupDotsAndPopup();
clearSelection();
});
})();
2 个帖子 - 2 位参与者