// ==UserScript== // @name 划词翻译 | Select & Translate // @namespace gdy_max@163.com // @version 2.0 // @description 百度通用/大模型双模式+Google三客户端回退。词典有道+FreeDict双源。窗口自动定位。 // @author gdy // @match *://*/* // @exclude https://www.google.com/* // @require https://cdn.jsdelivr.net/npm/js-md5@0.8.3/build/md5.min.js // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_setClipboard // @connect fanyi-api.baidu.com // @connect translate.googleapis.com // @connect api.dictionaryapi.dev // @connect dict.youdao.com // @license MIT // @downloadURL https://update.greasyfork.icu/scripts/575400/%E5%88%92%E8%AF%8D%E7%BF%BB%E8%AF%91%20%7C%20Select%20%20Translate.user.js // @updateURL https://update.greasyfork.icu/scripts/575400/%E5%88%92%E8%AF%8D%E7%BF%BB%E8%AF%91%20%7C%20Select%20%20Translate.meta.js // ==/UserScript== (function() { 'use strict'; // ========== 常量 ========== const WIN_WIDTH_DEFAULT = 340; const WIN_HEIGHT_MIN = 120; const WIN_HEIGHT_DEFAULT = 180; const WIN_MARGIN = 10; const WIN_GAP = 10; const MIN_SEL_LENGTH = 2; // ========== 持久化配置 ========== const STORAGE = { get engine() { return GM_getValue('st_engine', 'google'); }, set engine(v) { GM_setValue('st_engine', v); }, get baiduId() { return GM_getValue('st_baidu_id', ''); }, set baiduId(v){ GM_setValue('st_baidu_id', v); }, get baiduKey(){ return GM_getValue('st_baidu_key', ''); }, set baiduKey(v){GM_setValue('st_baidu_key', v); }, get baiduMode() { return GM_getValue('st_baidu_mode', 'general'); }, set baiduMode(v){ GM_setValue('st_baidu_mode', v); }, get theme() { return GM_getValue('st_theme', 'light'); }, set theme(v) { GM_setValue('st_theme', v); }, }; function baiduReady() { return STORAGE.baiduId && STORAGE.baiduKey; } function currentEngine() { return baiduReady() ? STORAGE.engine : 'google'; } // ========== 主题配色 ========== const THEMES = { light: { bg: 'rgba(255,255,255,0.95)', text: '#333', header: '#f0f2f5', border: '#e1e4e8', btnHover: '#e8eaed', shadow: 'rgba(0,0,0,0.12)' }, dark: { bg: 'rgba(40,44,52,0.95)', text: '#abb2bf', header: '#21252b', border: '#3e4451', btnHover: '#353b45', shadow: 'rgba(0,0,0,0.3)' } }; function applyTheme(mode) { const t = THEMES[mode] || THEMES.light; const r = document.documentElement.style; for (const k in t) r.setProperty(`--st-${k}`, t[k]); STORAGE.theme = mode; } // ========== 样式 ========== GM_addStyle(` :root { --st-primary: #4285f4; --st-blur: blur(12px); } #st-trigger { position: fixed; z-index: 2147483640; display: none; cursor: pointer; width: 26px; height: 26px; line-height: 26px; text-align: center; background: rgba(255,255,255,0.45); backdrop-filter: var(--st-blur); -webkit-backdrop-filter: var(--st-blur); color: #1a73e8; border-radius: 50%; font-size: 13px; font-weight: bold; border: 1px solid rgba(255,255,255,0.4); box-shadow: 0 2px 10px rgba(0,0,0,0.1); user-select: none; transition: transform 0.2s; } #st-window { position: fixed; z-index: 2147483641; display: none; width: ${WIN_WIDTH_DEFAULT}px; min-height: ${WIN_HEIGHT_MIN}px; background: var(--st-bg); color: var(--st-text); border: 1px solid var(--st-border); border-radius: 12px; backdrop-filter: var(--st-blur); -webkit-backdrop-filter: var(--st-blur); box-shadow: 0 12px 36px var(--st-shadow); flex-direction: column; overflow: hidden; transition: opacity 0.2s; } #st-window.visible { display: flex; } .st-body { padding: 14px; overflow-y: auto; flex: 1; font-size: 14px; line-height: 1.65; } .st-btn-group { display: flex; gap: 4px; align-items: center; } .st-btn { cursor: pointer; width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; transition: 0.2s; opacity: 0.6; } .st-btn:hover { opacity: 1; background: var(--st-btn-hover); color: var(--st-primary); } .st-pin { transition: transform 0.2s; font-size: 14px; filter: grayscale(1); } .st-pin.active { filter: grayscale(0); opacity: 1; transform: scale(1.1); } .st-resize { position: absolute; bottom: 0; right: 0; width: 14px; height: 14px; cursor: nwse-resize; background: linear-gradient(135deg, transparent 70%, #999 70%); } /* ---------- 设置面板 ---------- */ #st-overlay { position: fixed; inset: 0; z-index: 2147483641; background: rgba(0,0,0,0.2); display: none; } #st-overlay.visible { display: block; } #st-settings { display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2147483642; background: var(--st-bg); color: var(--st-text); padding: 22px; border-radius: 18px; box-shadow: 0 24px 60px rgba(0,0,0,0.3); width: 280px; border: 1px solid var(--st-border); backdrop-filter: var(--st-blur); -webkit-backdrop-filter: var(--st-blur); } #st-settings.visible { display: block; } .st-set-title { margin: 0 0 18px 0; font-size: 15px; font-weight: 700; text-align: center; } .st-set-section { margin-bottom: 16px; } .st-set-label { font-size: 10px; opacity: 0.5; margin-bottom: 6px; letter-spacing: 0.5px; } .st-option-group { display: flex; background: var(--st-header); padding: 4px; border-radius: 10px; border: 1px solid var(--st-border); } .st-option { flex: 1; padding: 8px 0; text-align: center; font-size: 13px; cursor: pointer; border-radius: 7px; transition: 0.2s; opacity: 0.6; } .st-option.selected { background: var(--st-bg); opacity: 1; font-weight: 600; color: var(--st-primary); box-shadow: 0 2px 8px rgba(0,0,0,0.1); } .st-input-group { display: none; flex-direction: column; gap: 6px; margin-top: 8px; } .st-input-group.visible { display: flex; } .st-input { width: 100%; box-sizing: border-box; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--st-border); background: var(--st-header); color: var(--st-text); font-size: 12px; outline: none; transition: border 0.2s; } .st-input:focus { border-color: var(--st-primary); } .st-set-hint { font-size: 10px; opacity: 0.4; line-height: 1.5; } `); // ========== 翻译 ========== function translateGoogle(text) { const clients = ['dict-chrome-ex', 'gtx', 'te']; function tryClient(i) { if (i >= clients.length) return Promise.resolve('Google 翻译不可用,请切换百度引擎。'); return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `https://translate.googleapis.com/translate_a/single?client=${clients[i]}&sl=auto&tl=zh-CN&dt=t&q=${encodeURIComponent(text)}`, timeout: 6000, onload: (res) => { try { const data = JSON.parse(res.responseText); let result = ''; if (Array.isArray(data) && data[0]) { data[0].forEach(p => { if (p && p[0]) result += p[0]; }); } if (result) { resolve(result); return; } // 空结果 → 试下一个客户端 resolve(tryClient(i + 1)); } catch (_) { resolve(tryClient(i + 1)); } }, onerror: () => resolve(tryClient(i + 1)), ontimeout: () => resolve(tryClient(i + 1)), }); }); } return tryClient(0); } function translateBaidu(text) { return STORAGE.baiduMode === 'llm' ? translateBaiduLLM(text) : translateBaiduGeneral(text); } function translateBaiduGeneral(text) { return new Promise((resolve) => { const salt = Date.now(); const sign = md5(STORAGE.baiduId + text + salt + STORAGE.baiduKey); GM_xmlhttpRequest({ method: 'POST', url: 'https://fanyi-api.baidu.com/api/trans/vip/translate', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `q=${encodeURIComponent(text)}&from=auto&to=zh&appid=${STORAGE.baiduId}&salt=${salt}&sign=${sign}`, timeout: 8000, onload: (res) => { try { const data = JSON.parse(res.responseText); if (data.error_code) { resolve(`翻译出错 (${data.error_code}): ${data.error_msg || '未知错误'}`); return; } const results = data.trans_result || []; resolve(results.map(r => r.dst).join('') || '翻译失败:无结果。'); } catch (_) { resolve('翻译失败,请检查网络。'); } }, onerror: () => resolve('翻译失败,请检查网络。'), ontimeout: () => resolve('翻译超时,请检查网络。'), }); }); } function translateBaiduLLM(text) { return new Promise((resolve) => { const salt = Date.now(); const sign = md5(STORAGE.baiduId + text + salt + STORAGE.baiduKey); GM_xmlhttpRequest({ method: 'POST', url: 'https://fanyi-api.baidu.com/api/trans/vip/translate', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `q=${encodeURIComponent(text)}&from=auto&to=zh&appid=${STORAGE.baiduId}&salt=${salt}&sign=${sign}&action=1`, timeout: 12000, onload: (res) => { try { const data = JSON.parse(res.responseText); if (data.error_code) { // 大模型额度用完或不可用 → 降级通用翻译 resolve(translateBaiduGeneral(text)); return; } const results = data.trans_result || []; resolve(results.map(r => r.dst).join('') || '翻译失败:无结果。'); } catch (_) { resolve(translateBaiduGeneral(text)); } }, onerror: () => resolve(translateBaiduGeneral(text)), ontimeout: () => resolve(translateBaiduGeneral(text)), }); }); } function translate(text) { return currentEngine() === 'baidu' ? translateBaidu(text) : translateGoogle(text); } // ========== 词典 (单个英文单词) ========== function isSingleWord(text) { return /^[a-zA-Z]+$/.test(text); } // 源1: Free Dictionary API (海外快,国内可能慢/墙) function lookupFreeDict(word) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`, timeout: 5000, onload: (res) => { try { const data = JSON.parse(res.responseText); if (!Array.isArray(data) || data.length === 0) { resolve(null); return; } const entry = data[0]; let phonetic = ''; if (entry.phonetic) { phonetic = ` ${entry.phonetic}`; } else if (entry.phonetics && entry.phonetics.length) { const p = entry.phonetics.find(ph => ph.text); if (p) phonetic = ` ${p.text}`; } let md = `**${entry.word}**${phonetic}\n\n`; const meanings = entry.meanings || []; meanings.slice(0, 3).forEach(m => { md += `*${m.partOfSpeech}*\n`; (m.definitions || []).slice(0, 2).forEach((d, i) => { md += `${i + 1}. ${d.definition}\n`; if (d.example) md += `> "${d.example}"\n`; }); md += '\n'; }); resolve(md.trim()); } catch (_) { resolve(null); } }, onerror: () => resolve(null), ontimeout: () => resolve(null), }); }); } // 源2: 有道词典 (国内可用) function lookupYoudao(word) { return new Promise((resolve) => { GM_xmlhttpRequest({ method: 'GET', url: `https://dict.youdao.com/suggest?num=1&ver=3.0&doctype=json&cache=false&le=en&q=${encodeURIComponent(word)}`, headers: { 'Referer': 'https://dict.youdao.com/' }, timeout: 5000, onload: (res) => { try { const data = JSON.parse(res.responseText); const entries = data?.data?.entries; if (!entries || entries.length === 0) { resolve(null); return; } const explain = entries[0].explain || ''; if (!explain) { resolve(null); return; } const parts = explain.split(';').filter(Boolean); let md = `**${word}**\n\n`; parts.forEach(p => md += `${p.trim()}\n\n`); resolve(md.trim()); } catch (_) { resolve(null); } }, onerror: () => resolve(null), ontimeout: () => resolve(null), }); }); } async function lookupDictionary(word) { // 先试有道 (国内直连),失败试 Free Dict (海外/CDN) let result = await lookupYoudao(word); if (!result) result = await lookupFreeDict(word); return result; } // ========== 获取选区坐标 ========== function getSelectionRect() { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return null; const r = sel.getRangeAt(0).getBoundingClientRect(); if (r.width === 0 && r.height === 0) return null; return { left: r.left, top: r.top, width: r.width, height: r.height }; } // ========== 构建 DOM ========== const trigger = Object.assign(document.createElement('div'), { id: 'st-trigger', textContent: 'T' }); const win = document.createElement('div'); win.id = 'st-window'; win.innerHTML = `
📌
📋 ⚙️
`; const overlay = Object.assign(document.createElement('div'), { id: 'st-overlay' }); const settings = document.createElement('div'); settings.id = 'st-settings'; settings.innerHTML = `
设置
TRANSLATION ENGINE
百度
Google
fanyi-api.baidu.com 注册获取,免费 200 万字符/月
TRANSLATION MODE
通用翻译
大模型
THEME MODE
明亮
暗黑
`; document.body.append(trigger, win, overlay, settings); applyTheme(STORAGE.theme); // ========== DOM 引用缓存 ========== const $ = (sel) => win.querySelector(sel); const $$ = (sel) => settings.querySelector(sel); const bodyEl = $('.st-content'); const copyBtn = $('.st-copy'); const closeBtn = $('.st-close'); const pinBtn = $('.st-pin'); const settingsBtn = $('.st-settings-btn'); const headerEl = $('.st-header'); const resizeEl = $('.st-resize'); const engineOpts = [...settings.querySelectorAll('#st-engine-group .st-option')]; const themeOpts = [...settings.querySelectorAll('#st-theme-group .st-option')]; const baiduModeOpts = [...settings.querySelectorAll('#st-baidu-mode-group .st-option')]; const baiduConfig = $$('#st-baidu-config'); const baiduIdInput = $$('#st-baidu-id'); const baiduKeyInput = $$('#st-baidu-key'); // ========== 状态 ========== let isPinned = false; let isDragging = false; let isResizing = false; let dragStartX = 0; let dragStartY = 0; let dragWinLeft = 0; let dragWinTop = 0; let currentTranslation = ''; // ========== 窗口显示/隐藏 ========== function showWin() { win.classList.add('visible'); } function hideWin() { if (!isPinned) win.classList.remove('visible'); } // ========== 定位窗口到选文上方 ========== function positionWindow(rect) { const w = win.offsetWidth || WIN_WIDTH_DEFAULT; const h = win.offsetHeight || WIN_HEIGHT_DEFAULT; let left = rect.left + rect.width / 2 - w / 2; let top = rect.top - h - WIN_GAP; if (rect.top < h + WIN_GAP + WIN_MARGIN) { top = rect.top + rect.height + WIN_GAP; } const maxLeft = window.innerWidth - w - WIN_MARGIN; left = Math.max(WIN_MARGIN, Math.min(left, maxLeft)); win.style.left = `${left}px`; win.style.top = `${top}px`; } // ========== 执行翻译/查词 ========== async function doTranslate(text) { bodyEl.innerHTML = '查询中...'; showWin(); let result; if (isSingleWord(text)) { result = await lookupDictionary(text); if (!result) result = await translate(text); // 词库没有 → 降级翻译 } else { result = await translate(text); } currentTranslation = result; bodyEl.textContent = result; } // ========== 事件: 选文出现触发器 ========== document.addEventListener('mouseup', (e) => { if (isDragging || isResizing) return; requestAnimationFrame(() => { const text = window.getSelection().toString().trim(); if (!text || text.length < MIN_SEL_LENGTH) return; if (win.contains(e.target) || trigger.contains(e.target) || overlay.contains(e.target)) return; trigger.style.left = `${e.clientX + 10}px`; trigger.style.top = `${e.clientY + 10}px`; trigger.style.display = 'block'; trigger.dataset.text = text; }); }); // ========== 事件: 点击其他地方关闭 ========== document.addEventListener('mousedown', (e) => { if (win.contains(e.target) || trigger.contains(e.target) || overlay.contains(e.target)) return; if (settings.contains(e.target)) return; hideWin(); trigger.style.display = 'none'; closeSettings(); }); // ========== 触发器点击 → 翻译 ========== trigger.addEventListener('click', () => { const text = trigger.dataset.text; if (!text) return; trigger.style.display = 'none'; if (!isPinned) { const rect = getSelectionRect(); if (rect) positionWindow(rect); } doTranslate(text); }); // ========== 关闭按钮 (无视 pin 状态) ========== closeBtn.addEventListener('click', () => { isPinned = false; pinBtn.classList.remove('active'); pinBtn.textContent = '📌'; hideWin(); }); // ========== 复制按钮 ========== copyBtn.addEventListener('click', () => { if (!currentTranslation) return; GM_setClipboard(currentTranslation); copyBtn.textContent = '✅'; setTimeout(() => { copyBtn.textContent = '📋'; }, 1000); }); // ========== 固定按钮 ========== pinBtn.addEventListener('click', () => { isPinned = !isPinned; pinBtn.classList.toggle('active', isPinned); pinBtn.textContent = isPinned ? '📍' : '📌'; }); // ========== 拖拽 ========== headerEl.addEventListener('mousedown', (e) => { if (e.target.closest('.st-btn')) return; isDragging = true; dragStartX = e.clientX; dragStartY = e.clientY; dragWinLeft = win.offsetLeft; dragWinTop = win.offsetTop; }); // ========== 缩放 ========== resizeEl.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); isResizing = true; dragStartX = e.clientX; dragStartY = e.clientY; }); function onMouseMove(e) { if (isDragging) { win.style.left = `${dragWinLeft + e.clientX - dragStartX}px`; win.style.top = `${dragWinTop + e.clientY - dragStartY}px`; } if (isResizing) { const w = e.clientX - win.offsetLeft; const h = e.clientY - win.offsetTop; if (w > 180) win.style.width = `${w}px`; if (h > WIN_HEIGHT_MIN) win.style.height = `${h}px`; } } function onMouseUp() { if (isDragging || isResizing) { isDragging = false; isResizing = false; } } document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); // ========== 设置面板 ========== function refreshSettingsPanel() { // 引擎 const eng = STORAGE.engine; engineOpts.forEach(o => o.classList.toggle('selected', o.dataset.engine === eng)); // 百度配置显示/隐藏 baiduConfig.classList.toggle('visible', eng === 'baidu'); // 百度输入框值 baiduIdInput.value = STORAGE.baiduId; baiduKeyInput.value = STORAGE.baiduKey; // 百度翻译模式 const mode = STORAGE.baiduMode; baiduModeOpts.forEach(o => o.classList.toggle('selected', o.dataset.mode === mode)); // 主题 const theme = STORAGE.theme; themeOpts.forEach(o => o.classList.toggle('selected', o.dataset.mode === theme)); } function openSettings() { refreshSettingsPanel(); overlay.classList.add('visible'); settings.classList.add('visible'); } function closeSettings() { overlay.classList.remove('visible'); settings.classList.remove('visible'); } settingsBtn.addEventListener('click', openSettings); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSettings(); }); // ========== 引擎切换 ========== engineOpts.forEach(opt => { opt.addEventListener('click', function() { const eng = this.dataset.engine; STORAGE.engine = eng; engineOpts.forEach(o => o.classList.toggle('selected', o === this)); baiduConfig.classList.toggle('visible', eng === 'baidu'); if (eng === 'baidu') { baiduIdInput.focus(); } }); }); // ========== 百度配置输入 ========== baiduIdInput.addEventListener('input', function() { STORAGE.baiduId = this.value.trim(); }); baiduKeyInput.addEventListener('input', function() { STORAGE.baiduKey = this.value.trim(); }); // ========== 百度模式切换 ========== baiduModeOpts.forEach(opt => { opt.addEventListener('click', function() { STORAGE.baiduMode = this.dataset.mode; baiduModeOpts.forEach(o => o.classList.toggle('selected', o === this)); }); }); // ========== 主题切换 ========== themeOpts.forEach(opt => { opt.addEventListener('click', function() { themeOpts.forEach(o => o.classList.remove('selected')); this.classList.add('selected'); applyTheme(this.dataset.mode); closeSettings(); }); }); })();