// ==UserScript== // @name 星特翻译插件 // @namespace https://xiaote.design.blog/yhjb/xtfycj // @version 2.9 // @description 悬浮球拖动翻译,批量请求,毛玻璃面板,完整动画,最高层级 // @author You // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect edge.microsoft.com // @connect api.cognitive.microsofttranslator.com // @license Apache-2.0 // ==/UserScript== (function() { 'use strict'; const UA = 'Mozilla/5.0 (Linux; Android 8.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'; const LANG_MAP = { 'zh-TW': 'zh-Hant', 'zh-CN': 'zh-Hans', 'tl': 'fil', 'sr': 'sr-Cyrl', 'no': 'nb', 'mn': 'mn-Cyrl', 'hmn': 'mww', 'iw': 'he' }; const REVERSE_LANG_MAP = {}; for (const [k, v] of Object.entries(LANG_MAP)) { REVERSE_LANG_MAP[v] = k; } function normalizeLang(lang) { if (!lang) return ''; const low = lang.toLowerCase(); if (LANG_MAP[lang]) return lang; if (REVERSE_LANG_MAP[lang]) return REVERSE_LANG_MAP[lang]; const simpleMap = { 'zh': 'zh-CN', 'zh-hans': 'zh-CN', 'zh-hant': 'zh-TW', 'jw': 'jv' }; if (simpleMap[low]) return simpleMap[low]; return lang; } const DEFAULTS = { targetLang: navigator.language.includes('zh') ? 'zh-CN' : 'en', transMode: 'replace', autoTranslate: true, sourceLang: 'auto', batchSize: 800, showBall: true, ballPos: { left: 30, top: 200 }, smartAutoTranslate: true, autoTranslateThreshold: 0.8, perTextLanguageCheck: true, forceTranslate: false }; const Z_INDEX = '2147483647'; function getSetting(key) { const val = GM_getValue(key); return val !== undefined ? val : DEFAULTS[key]; } function saveSetting(key, value) { GM_setValue(key, value); } for (const k in DEFAULTS) { if (GM_getValue(k) === undefined) GM_setValue(k, DEFAULTS[k]); } // ---------- Token 管理 ---------- function parseTokenExp(token) { try { const payload = JSON.parse(atob(token.split('.')[1])); return payload.exp * 1000; } catch(e) { return -1; } } function httpGet(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers: { 'User-Agent': UA }, onload: resolve, onerror: reject }); }); } function httpPost(url, body, token) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, data: body, headers: { 'User-Agent': UA, 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, onload: resolve, onerror: reject }); }); } async function fetchNewToken() { const res = await httpGet('https://edge.microsoft.com/translate/auth'); if (res.status !== 200) throw new Error('Token 获取失败'); const token = res.responseText; let exp = parseTokenExp(token); if (exp < Date.now()) exp = Date.now() + 600000; exp -= 30000; GM_setValue('token', token); GM_setValue('tokenExp', exp); return token; } async function getToken() { const token = GM_getValue('token'); const exp = GM_getValue('tokenExp', 0); if (token && exp > Date.now()) return token; return await fetchNewToken(); } async function batchTranslate(texts, from, to) { let fromLang = LANG_MAP[from] || from; let toLang = LANG_MAP[to] || to; let query = '?api-version=3.0&to=' + encodeURIComponent(toLang); if (fromLang !== 'auto') query += '&from=' + encodeURIComponent(fromLang); const body = JSON.stringify(texts.map(t => ({ Text: t }))); let token = await getToken(); let res = await httpPost('https://api.cognitive.microsofttranslator.com/translate' + query, body, token); if (res.status === 401) { GM_setValue('token', ''); GM_setValue('tokenExp', 0); token = await fetchNewToken(); res = await httpPost('https://api.cognitive.microsofttranslator.com/translate' + query, body, token); } if (res.status !== 200) throw new Error('翻译失败: ' + res.status); const data = JSON.parse(res.responseText); const translations = data.map(item => item.translations[0].text); const detectedLanguages = data.map(item => item.detectedLanguage?.language || ''); return { translations, detectedLanguages }; } // ---------- 页面翻译(文本节点遍历) ---------- let isTranslating = false; let stopRequested = false; // 记录已翻译的文本节点,避免重复(WeakSet 不影响垃圾回收) const translatedNodes = new WeakSet(); function shouldSkipNode(node) { const parent = node.parentElement; if (!parent) return true; const tag = parent.tagName.toLowerCase(); if (['script','style','noscript','textarea','input','code','pre','kbd','samp','var','tt'].includes(tag)) return true; if (parent.closest('[data-mt-skip]')) return true; if (parent.classList.contains('notranslate') || parent.classList.contains('mt-processed')) return true; if (parent.isContentEditable) return true; // 如果是追加翻译的 span 本身,跳过 if (parent.classList.contains('mt-translation')) return true; return false; } function collectTextNodes(root) { const nodes = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { if (shouldSkipNode(node)) return NodeFilter.FILTER_REJECT; const text = node.textContent.trim(); if (!text) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); while (walker.nextNode()) { nodes.push(walker.currentNode); } return nodes; } async function translatePageBatch() { if (isTranslating) return; const mode = getSetting('transMode'); const targetLang = getSetting('targetLang'); const sourceLang = getSetting('sourceLang'); const batchSize = getSetting('batchSize'); const smartAuto = getSetting('smartAutoTranslate'); const threshold = getSetting('autoTranslateThreshold'); const perTextCheck = getSetting('perTextLanguageCheck'); const forceTranslate = getSetting('forceTranslate'); // 强制翻译:清除所有已翻译标记和追加内容,重置节点记录 if (forceTranslate) { document.querySelectorAll('[data-mt-translated]').forEach(el => el.removeAttribute('data-mt-translated')); document.querySelectorAll('.mt-translation').forEach(el => el.remove()); // WeakSet 无法清空,只能整体重建 // 后续翻译会基于新的文本节点重新收集,所以不必清空 } const textNodes = collectTextNodes(document.body); if (textNodes.length === 0) { isTranslating = false; updateUI(); return; } isTranslating = true; stopRequested = false; updateUI(); // 去重:文本内容 → 对应的文本节点数组 const textMap = new Map(); for (const node of textNodes) { // 跳过已翻译的节点(只在非强制模式下生效) if (!forceTranslate && translatedNodes.has(node)) continue; const text = node.textContent.trim(); if (!textMap.has(text)) { textMap.set(text, []); } textMap.get(text).push(node); } const uniqueTexts = [...textMap.keys()]; if (uniqueTexts.length === 0) { isTranslating = false; updateUI(); return; } // 智能跳过 if (smartAuto && uniqueTexts.length > 0) { const sampleSize = Math.min(uniqueTexts.length, batchSize); const sampleTexts = uniqueTexts.slice(0, sampleSize); try { const { detectedLanguages } = await batchTranslate(sampleTexts, 'auto', targetLang); let matchCount = 0; const normalizedTarget = normalizeLang(targetLang); for (const dl of detectedLanguages) { if (normalizeLang(dl) === normalizedTarget) matchCount++; } const ratio = matchCount / detectedLanguages.length; if (ratio >= threshold) { console.log(`智能跳过:${(ratio*100).toFixed(0)}% 的文本已是目标语言,停止翻译`); isTranslating = false; updateUI(); return; } } catch(e) { console.warn('智能检测失败,继续翻译', e); } } const effectiveSourceLang = perTextCheck ? 'auto' : sourceLang; try { for (let i = 0; i < uniqueTexts.length; i += batchSize) { if (stopRequested) break; const batch = uniqueTexts.slice(i, i + batchSize); const { translations, detectedLanguages } = await batchTranslate(batch, effectiveSourceLang, targetLang); for (let j = 0; j < batch.length; j++) { const original = batch[j]; const translation = translations[j]; const detectedLang = normalizeLang(detectedLanguages[j] || ''); const normalizedTarget = normalizeLang(targetLang); if (perTextCheck && detectedLang === normalizedTarget) continue; const nodes = textMap.get(original); nodes.forEach(node => { // 再次检查是否已翻译(处理并发问题) if (!forceTranslate && translatedNodes.has(node)) return; translatedNodes.add(node); if (mode === 'replace') { node.textContent = translation; } else { if (translation === original) return; const span = document.createElement('span'); span.className = 'mt-translation'; span.textContent = translation; span.style.cssText = 'color:#666;font-style:italic;margin-left:4px;'; // 在文本节点后插入 span if (node.parentNode) { node.parentNode.insertBefore(span, node.nextSibling); } } // 在父元素上设置标记,以便跳过后续收集(非必须,但保留兼容) if (node.parentElement && !node.parentElement.hasAttribute('data-mt-translated')) { node.parentElement.setAttribute('data-mt-translated', '1'); } }); } if (i + batchSize < uniqueTexts.length) await new Promise(r => setTimeout(r, 100)); } } catch(e) { console.error('翻译出错:', e); alert('翻译出错: ' + e.message); } finally { isTranslating = false; stopRequested = false; updateUI(); } } function stopTranslation() { stopRequested = true; } // ---------- 悬浮球 UI ---------- let container, ball, panel, panelVisible = false; const BALL_SIZE = 32; const SVG_ICON = ``; function createBall() { container = document.createElement('div'); container.id = 'mt-float-container'; container.style.cssText = ` position:fixed; z-index:${Z_INDEX}; transition: left 0.2s ease, top 0.2s ease; user-select:none; -webkit-tap-highlight-color:transparent; `; const pos = getSetting('ballPos'); container.style.left = pos.left + 'px'; container.style.top = pos.top + 'px'; ball = document.createElement('div'); ball.id = 'mt-float-ball'; ball.innerHTML = SVG_ICON; ball.style.cssText = ` width:${BALL_SIZE}px;height:${BALL_SIZE}px;border-radius:50%;background:#ff6a00; display:flex;align-items:center;justify-content:center;cursor:pointer; border:1px solid #e05d00;user-select:none;touch-action:none; -webkit-tap-highlight-color:transparent; transition: background 0.2s, transform 0.2s; `; let startX, startY, initLeft, initTop, dragging = false; ball.addEventListener('pointerdown', (e) => { if (e.target.tagName === 'BUTTON' || e.target.closest('button')) return; e.preventDefault(); ball.setPointerCapture(e.pointerId); startX = e.clientX; startY = e.clientY; const rect = container.getBoundingClientRect(); initLeft = rect.left; initTop = rect.top; dragging = false; container.style.transition = 'none'; ball.addEventListener('pointermove', onMove); ball.addEventListener('pointerup', onUp); }); function onMove(e) { const dx = e.clientX - startX; const dy = e.clientY - startY; if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragging = true; let left = initLeft + dx; let top = initTop + dy; left = Math.max(0, Math.min(window.innerWidth - BALL_SIZE, left)); top = Math.max(0, Math.min(window.innerHeight - BALL_SIZE, top)); container.style.left = left + 'px'; container.style.top = top + 'px'; } function onUp(e) { ball.removeEventListener('pointermove', onMove); ball.removeEventListener('pointerup', onUp); container.style.transition = 'left 0.3s ease, top 0.3s ease'; const rect = container.getBoundingClientRect(); const centerX = rect.left + BALL_SIZE/2; let finalLeft = rect.left; if (centerX < 60) finalLeft = -BALL_SIZE/2; else if (centerX > window.innerWidth - 60) finalLeft = window.innerWidth - BALL_SIZE/2; finalLeft = Math.max(-BALL_SIZE/2, Math.min(window.innerWidth - BALL_SIZE/2, finalLeft)); container.style.left = finalLeft + 'px'; container.style.top = rect.top + 'px'; saveSetting('ballPos', { left: finalLeft, top: rect.top }); } ball.addEventListener('click', () => { if (dragging) return; const curLeft = parseFloat(container.style.left); if (curLeft < 0 || curLeft > window.innerWidth - BALL_SIZE) { const newLeft = curLeft < 0 ? 10 : window.innerWidth - BALL_SIZE - 10; container.style.left = newLeft + 'px'; saveSetting('ballPos', { left: newLeft, top: parseFloat(container.style.top) }); } panelVisible = !panelVisible; if (panelVisible) { panel.style.display = 'block'; requestAnimationFrame(() => { panel.style.opacity = '1'; panel.style.transform = 'translateY(0)'; }); } else { panel.style.opacity = '0'; panel.style.transform = 'translateY(10px)'; setTimeout(() => { if (!panelVisible) panel.style.display = 'none'; }, 300); } }); panel = document.createElement('div'); panel.id = 'mt-float-panel'; panel.style.cssText = ` display:none; opacity:0; transform:translateY(10px); position:absolute; bottom:${BALL_SIZE + 10}px; right:0; min-width:200px; background:#ffffff; border:1px solid #d0d0d0; border-radius:16px; overflow:hidden; user-select:none; -webkit-tap-highlight-color:transparent; transition: opacity 0.3s ease, transform 0.3s ease; z-index:${Z_INDEX}; `; container.appendChild(ball); container.appendChild(panel); document.body.appendChild(container); updateUI(); } function removeBall() { if (container) { container.remove(); container = ball = panel = null; panelVisible = false; } } function updateUI() { if (!panel) return; const oldContent = panel.firstChild; if (oldContent) panel.removeChild(oldContent); const content = document.createElement('div'); content.style.cssText = 'padding:12px;display:flex;flex-direction:column;gap:8px;'; panel.appendChild(content); const modeText = getSetting('transMode') === 'replace' ? '替换原文' : '补充翻译'; const autoText = getSetting('autoTranslate') ? '自动翻译: 开' : '自动翻译: 关'; const buttons = [ { id: 'mt-btn-translate', text: '翻译此页' }, { id: 'mt-btn-stop', text: '停止翻译', style: isTranslating ? 'color:#ff6a00;font-weight:bold;' : '' }, { id: 'mt-btn-toggle-auto', text: autoText }, { id: 'mt-btn-toggle-mode', text: `切换模式: ${modeText}` }, { id: 'mt-btn-settings', text: '设置' } ]; buttons.forEach(({id, text, style}) => { const btn = document.createElement('button'); btn.id = id; btn.textContent = text; btn.style.cssText = ` width:100%; padding:10px 12px; border:1px solid #d0d0d0; border-radius:16px; background:#ffffff; cursor:pointer; font-size:14px; text-align:left; color:#333; user-select:none; -webkit-tap-highlight-color:transparent; transition: background 0.2s; ${style || ''} `; btn.addEventListener('mouseenter', ()=> btn.style.background='#f0f0f0'); btn.addEventListener('mouseleave', ()=> btn.style.background='#ffffff'); btn.addEventListener('pointerdown', (e) => e.stopPropagation()); content.appendChild(btn); }); content.querySelector('#mt-btn-translate').addEventListener('click', () => { hidePanel(); translatePageBatch(); }); content.querySelector('#mt-btn-stop').addEventListener('click', () => { stopTranslation(); hidePanel(); }); content.querySelector('#mt-btn-toggle-auto').addEventListener('click', () => { saveSetting('autoTranslate', !getSetting('autoTranslate')); hidePanel(); updateUI(); }); content.querySelector('#mt-btn-toggle-mode').addEventListener('click', () => { const cur = getSetting('transMode'); saveSetting('transMode', cur === 'replace' ? 'append' : 'replace'); hidePanel(); updateUI(); }); content.querySelector('#mt-btn-settings').addEventListener('click', () => { hidePanel(); openSettings(); }); if (isTranslating) { ball.style.background = '#cc5500'; } else { ball.style.background = '#ff6a00'; } } function hidePanel() { panelVisible = false; if (panel) { panel.style.opacity = '0'; panel.style.transform = 'translateY(10px)'; setTimeout(() => { if (!panelVisible) panel.style.display = 'none'; }, 300); } } // ---------- 设置面板 ---------- function buildSettingsHTML() { const s = { sourceLang: getSetting('sourceLang'), targetLang: getSetting('targetLang'), transMode: getSetting('transMode'), autoTranslate: getSetting('autoTranslate'), showBall: getSetting('showBall'), batchSize: getSetting('batchSize'), smartAutoTranslate: getSetting('smartAutoTranslate'), autoTranslateThreshold: getSetting('autoTranslateThreshold'), perTextLanguageCheck: getSetting('perTextLanguageCheck'), forceTranslate: getSetting('forceTranslate') }; const langList = [ ['auto','自动检测'],['zh-CN','中文简体'],['zh-TW','中文繁体'],['en','英语'],['ru','俄语'],['uk','乌克兰语'], ['af','南非荷兰语'],['am','阿姆哈拉语'],['ar','阿拉伯语'],['az','阿塞拜疆语'],['bg','保加利亚语'], ['bn','孟加拉语'],['bs','波斯尼亚语'],['ca','加泰罗尼亚语'],['cs','捷克语'],['cy','威尔士语'], ['da','丹麦语'],['de','德语'],['el','希腊语'],['es','西班牙语'],['et','爱沙尼亚语'], ['eu','巴斯克语'],['fa','波斯语'],['fi','芬兰语'],['fr','法语'],['ga','爱尔兰语'], ['gl','加利西亚语'],['gu','古吉拉特语'],['ha','豪萨语'],['hi','印地语'],['hmn','苗语'], ['hr','克罗地亚语'],['ht','海地克里奥尔语'],['hu','匈牙利语'],['hy','亚美尼亚语'],['id','印尼语'], ['ig','伊博语'],['is','冰岛语'],['it','意大利语'],['iw','希伯来语'],['ja','日语'], ['ka','格鲁吉亚语'],['kk','哈萨克语'],['km','高棉语'],['kn','卡纳达语'],['ko','韩语'], ['ky','吉尔吉斯语'],['lo','老挝语'],['lt','立陶宛语'],['lv','拉脱维亚语'],['mi','毛利语'], ['mk','马其顿语'],['ml','马拉雅拉姆语'],['mn','蒙古语'],['mr','马拉地语'],['ms','马来语'], ['my','缅甸语'],['ne','尼泊尔语'],['nl','荷兰语'],['no','挪威语'],['pa','旁遮普语'], ['pl','波兰语'],['ps','普什图语'],['pt','葡萄牙语'],['ro','罗马尼亚语'],['sd','信德语'], ['si','僧伽罗语'],['sk','斯洛伐克语'],['sl','斯洛文尼亚语'],['sm','萨摩亚语'],['sn','绍纳语'], ['so','索马里语'],['sq','阿尔巴尼亚语'],['sr','塞尔维亚语'],['st','塞索托语'],['sv','瑞典语'], ['sw','斯瓦希里语'],['ta','泰米尔语'],['te','泰卢固语'],['th','泰语'],['tl','菲律宾语'], ['tr','土耳其语'],['tt','鞑靼语'],['ug','维吾尔语'],['ur','乌尔都语'],['uz','乌兹别克语'], ['vi','越南语'],['xh','科萨语'],['yo','约鲁巴语'] ]; const langOptions = (selected, isSource) => { return langList.filter(([code]) => isSource || code !== 'auto').map(([code, name]) => { const sel = code === selected ? ' selected' : ''; return ``; }).join(''); }; return `

星特翻译插件设置

`; } function openSettings() { const existing = document.querySelector('.mt-settings-overlay'); if (existing) existing.remove(); const overlay = document.createElement('div'); overlay.className = 'mt-settings-overlay'; overlay.innerHTML = buildSettingsHTML(); document.body.appendChild(overlay); overlay.querySelector('#mt-close-btn').addEventListener('click', () => overlay.remove()); overlay.querySelector('#mt-save-btn').addEventListener('click', () => { const auto = overlay.querySelector('#mt-set-auto').checked; const force = overlay.querySelector('#mt-set-force').checked; const smartAuto = overlay.querySelector('#mt-set-smart-auto').checked; const threshold = parseFloat(overlay.querySelector('#mt-set-threshold').value) || 0.8; const perCheck = overlay.querySelector('#mt-set-per-check').checked; const ballShow = overlay.querySelector('#mt-set-ball').checked; const mode = overlay.querySelector('#mt-set-mode').value; const from = overlay.querySelector('#mt-set-from').value; const to = overlay.querySelector('#mt-set-to').value; const batch = parseInt(overlay.querySelector('#mt-set-batch').value, 10) || 800; saveSetting('autoTranslate', auto); saveSetting('forceTranslate', force); saveSetting('smartAutoTranslate', smartAuto); saveSetting('autoTranslateThreshold', threshold); saveSetting('perTextLanguageCheck', perCheck); saveSetting('showBall', ballShow); saveSetting('transMode', mode); saveSetting('sourceLang', from); saveSetting('targetLang', to); saveSetting('batchSize', batch); if (ballShow) { if (!container) createBall(); } else { removeBall(); } overlay.remove(); updateUI(); if (auto && !document.querySelector('[data-mt-translated]')) { setTimeout(translatePageBatch, 500); } }); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); } // ---------- 油猴菜单 ---------- GM_registerMenuCommand('切换翻译模式', () => { const cur = getSetting('transMode'); saveSetting('transMode', cur === 'replace' ? 'append' : 'replace'); if (getSetting('showBall')) updateUI(); }); GM_registerMenuCommand('设置目标语言', () => { const lang = prompt('目标语言代码 (例如 en, zh-CN)', getSetting('targetLang')); if (lang) { saveSetting('targetLang', lang); if (getSetting('showBall')) updateUI(); } }); GM_registerMenuCommand('切换自动翻译', () => { saveSetting('autoTranslate', !getSetting('autoTranslate')); if (getSetting('showBall')) updateUI(); }); GM_registerMenuCommand('显示/隐藏悬浮球', () => { const show = !getSetting('showBall'); saveSetting('showBall', show); if (show) { if (!container) createBall(); } else { removeBall(); } }); GM_registerMenuCommand('打开设置', openSettings); // ---------- 初始化 ---------- function init() { if (!document.body) { document.addEventListener('DOMContentLoaded', init); return; } if (getSetting('showBall')) createBall(); if (getSetting('autoTranslate')) { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(translatePageBatch, 800)); } else { setTimeout(translatePageBatch, 800); } } } init(); })();