// ==UserScript== // @name X/Twitter & Discord 实时翻译插件 // @namespace http://tampermonkey.net/ // @version 1.8 // @description 支持推特实时翻译,Discord 翻译,支持翻译字体大小颜色可调整。精简版,仅保留翻译功能。 // @author Antigravity // @match *://twitter.com/* // @match *://x.com/* // @match *://pro.x.com/* // @match *://discord.com/* // @match *://www.patreon.com/* // @match *://ko-fi.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @connect translate.googleapis.com // @downloadURL https://update.greasyfork.icu/scripts/564823/XTwitter%20%20Discord%20%E5%AE%9E%E6%97%B6%E7%BF%BB%E8%AF%91%E6%8F%92%E4%BB%B6.user.js // @updateURL https://update.greasyfork.icu/scripts/564823/XTwitter%20%20Discord%20%E5%AE%9E%E6%97%B6%E7%BF%BB%E8%AF%91%E6%8F%92%E4%BB%B6.meta.js // ==/UserScript== (function () { 'use strict'; console.log("🚀 翻译插件已启动..."); const __SystemConfig = { decode: (str) => { try { return decodeURIComponent(escape(window.atob(str))); } catch (e) { return ""; } }, params: { svc_trans: "aHR0cHM6Ly90cmFuc2xhdGUuZ29vZ2xlYXBpcy5jb20vdHJhbnNsYXRlX2Evc2luZ2xl" } }; const Storage = { getConfig: () => ({ transColor: '#00E676', transFontSize: '14px', floatTop: '60%', transMode: 'below', // 'below', 'hover', 'bilingual' ...JSON.parse(GM_getValue('ling_config_simple', '{}')) }), setConfig: (cfg) => { GM_setValue('ling_config_simple', JSON.stringify(cfg)); updateStyles(); } }; function updateStyles() { const cfg = Storage.getConfig(); const oldStyle = document.getElementById('ling-style'); if (oldStyle) oldStyle.remove(); const isMobile = window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const css = ` .ling-trans-box { margin-top: 6px; padding: 8px 10px; background: #ffffff; border-left: 3px solid ${cfg.transColor}; border-radius: 4px; color: #000000; font-size: ${cfg.transFontSize}; line-height: 1.5; font-family: "Consolas", monospace; white-space: normal; } .ling-trans-label { display: block; margin-bottom: 4px; opacity: 0.6; font-size: 10px; line-height: 1.2; } .ling-trans-text { white-space: pre-wrap; overflow-wrap: anywhere; } .ling-trans-line { display: block; margin: 0 0 3px; } .ling-trans-line:last-child { margin-bottom: 0; } .ling-discord-box { margin-top: 4px; padding: 4px 8px; opacity: 0.9; background: rgba(255,255,255,0.9); border-left: 2px solid ${cfg.transColor}; color: #000000; } .ling-hover-tooltip { position: absolute; background: rgba(255,255,255,0.9); color: #000000, padding: 8px 12px; border-radius: 6px; font-size: ${cfg.transFontSize}; line-height: 1.4; z-index: 2147483647; max-width: 300px; word-wrap: break-word; box-shadow: 0 4px 12px rgba(0,0,0,0.5); pointer-events: none; opacity: 0; transition: opacity 0.2s; } .ling-hover-tooltip.show { opacity: 1; } .ling-bilingual { position: relative; } .ling-bilingual .original { opacity: 0.6; text-decoration: line-through; } .ling-bilingual .translation { color: #000000; font-size: ${cfg.transFontSize}; } .ling-dashboard { position: fixed; top: 15%; right: ${isMobile ? '10px' : '20px'}; background: #111; border: 1px solid #333; border-radius: 12px; padding: ${isMobile ? '10px' : '15px'}; z-index: 2147483646; box-shadow: 0 10px 30px rgba(0,0,0,0.8); min-width: ${isMobile ? '180px' : '200px'}; opacity: 0; visibility: hidden; transform: translateY(-10px); transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s; } .ling-dashboard.active { opacity: 1; visibility: visible; transform: translateY(0); } .ling-float-toggle { position: fixed; right: ${isMobile ? '5px' : '10px'}; top: ${cfg.floatTop}; width: ${isMobile ? '35px' : '45px'}; height: ${isMobile ? '35px' : '45px'}; border-radius: 50%; background: #000; border: 2px solid #00E676; color: #fff; display: flex; justify-content: center; align-items: center; cursor: ${isMobile ? 'pointer' : 'grab'}; z-index: 2147483645; box-shadow: 0 4px 10px rgba(0,0,0,0.5); transition: transform 0.1s, opacity 0.2s; opacity: 0.8; user-select: none; font-size: ${isMobile ? '10px' : '14px'}; } .ling-float-toggle:hover { opacity: 1; transform: scale(1.05); } .ling-logo-text { font-family: 'Arial Black', sans-serif; font-weight: 900; font-size: ${isMobile ? '12px' : '14px'}; } #ling-settings-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 2147483647; display: flex; justify-content: center; align-items: center; } #ling-settings-box { background: #16181c; border: 1px solid #333; border-radius: 12px; padding: ${isMobile ? '15px' : '20px'}; width: ${isMobile ? '90%' : '300px'}; max-width: 400px; color: #fff; } .ling-row { margin-bottom: 15px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; } .ling-row label { flex: 1; margin-bottom: ${isMobile ? '5px' : '0'}; } .ling-row input { flex: 1; max-width: 100px; } .ling-btn { background: #00E676; color: #000; border: none; padding: ${isMobile ? '10px' : '8px'}; border-radius: 5px; width: 100%; font-weight: bold; cursor: pointer; margin-top: 10px; font-size: ${isMobile ? '14px' : '16px'}; } @media (max-width: 768px) { .ling-dashboard { right: 10px; min-width: 180px; padding: 10px; } .ling-float-toggle { right: 5px; width: 40px; height: 40px; } .ling-hover-tooltip { max-width: 250px; font-size: 12px; } } `; const node = document.createElement('style'); node.id = 'ling-style'; node.innerHTML = css; document.head.appendChild(node); } function createProxyRequest(options) { return GM_xmlhttpRequest(options); } function getTranslateApiBase() { let apiBase = "https://translate.googleapis.com/translate_a/single"; try { apiBase = __SystemConfig.decode(__SystemConfig.params.svc_trans) || apiBase; } catch (e) { } return apiBase; } function readTranslateResponse(responseText) { const data = JSON.parse(responseText); const parts = []; if (data && data[0]) { data[0].forEach(i => { if (i[0]) parts.push(i[0]); }); } return parts.join(''); } function translateText(text, callback) { const url = `${getTranslateApiBase()}?client=gtx&sl=auto&tl=zh-CN&dt=t&q=${encodeURIComponent(text)}`; createProxyRequest({ method: "GET", url: url, timeout: 5000, onload: (res) => { try { callback(readTranslateResponse(res.responseText)); } catch (e) { callback(''); } }, onerror: () => callback(''), ontimeout: () => callback('') }); } function splitTranslatableLines(text) { return (text || '') .split(/\n+/) .map(line => line.trim()) .filter(Boolean); } function processContent(element, text, platform) { if (!text || element.dataset.lingProcessed) return; element.dataset.lingProcessed = "true"; const isForeign = !/[\u4e00-\u9fa5]/.test(text) || (text.match(/[\u4e00-\u9fa5]/g) || []).length / text.length < 0.3; if (isForeign && text.length > 3) { const textShort = text.length > 2000 ? text.substring(0, 2000) : text; const sourceLines = splitTranslatableLines(textShort); if (platform === 'twitter' && sourceLines.length > 1) { const translatedLines = new Array(sourceLines.length); let doneCount = 0; sourceLines.forEach((line, index) => { translateText(line, (translated) => { translatedLines[index] = translated; doneCount += 1; if (doneCount === sourceLines.length) { const visibleLines = translatedLines.filter(Boolean); if (visibleLines.length) renderBox(element, visibleLines, platform); } }); }); } else { translateText(textShort, (transResult) => { if (transResult) renderBox(element, transResult, platform); }); } } } function appendTranslationContent(container, transText) { const label = document.createElement('span'); label.className = 'ling-trans-label'; label.textContent = '[翻译]'; container.appendChild(label); const content = document.createElement('div'); content.className = 'ling-trans-text'; const lines = Array.isArray(transText) ? transText : String(transText || '').split(/\n+/); lines.forEach((line) => { const lineNode = document.createElement('span'); lineNode.className = 'ling-trans-line'; lineNode.textContent = line; content.appendChild(lineNode); }); container.appendChild(content); } function renderBox(element, transText, platform) { const cfg = Storage.getConfig(); const isMobile = window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); // 获取元素的背景色和字体 const computedStyle = window.getComputedStyle(element); const bgColor = computedStyle.backgroundColor || 'rgba(0,0,0,0.8)'; const fontFamily = computedStyle.fontFamily || 'inherit'; const fontSize = computedStyle.fontSize || cfg.transFontSize; const lineHeight = computedStyle.lineHeight || '1.5'; if (cfg.transMode === 'hover' && !isMobile) { // 悬浮模式:鼠标悬停显示翻译 const tooltip = document.createElement('div'); tooltip.className = 'ling-hover-tooltip'; tooltip.textContent = Array.isArray(transText) ? transText.join('\n') : transText; tooltip.style.backgroundColor = 'rgba(255,255,255,0.9)'; tooltip.style.fontFamily = fontFamily; tooltip.style.fontSize = fontSize; tooltip.style.lineHeight = lineHeight; element.style.position = 'relative'; element.appendChild(tooltip); element.addEventListener('mouseenter', () => { tooltip.classList.add('show'); }); element.addEventListener('mouseleave', () => { tooltip.classList.remove('show'); }); } else if (cfg.transMode === 'bilingual') { // 双语模式:原文和译文交替显示 element.classList.add('ling-bilingual'); const original = document.createElement('span'); original.className = 'original'; original.textContent = element.textContent; const translation = document.createElement('div'); translation.className = 'translation'; translation.textContent = Array.isArray(transText) ? transText.join('\n') : transText; translation.style.whiteSpace = 'pre-wrap'; translation.style.fontFamily = fontFamily; translation.style.fontSize = fontSize; translation.style.lineHeight = lineHeight; element.innerHTML = ''; element.appendChild(original); element.appendChild(translation); } else { // 默认下方显示模式 const container = document.createElement('div'); container.className = (platform === 'discord' || platform === 'patreon' || platform === 'kofi') ? 'ling-trans-box ling-discord-box' : 'ling-trans-box'; appendTranslationContent(container, transText); container.style.backgroundColor = 'rgba(255,255,255,0.9)'; container.style.fontFamily = fontFamily; container.style.fontSize = fontSize; container.style.lineHeight = lineHeight; const isXMessage = element.getAttribute('data-testid')?.startsWith('message-text-'); const isKofiComment = platform === 'kofi' && element.classList.contains('kfds-top-mrgn-8'); if (((platform === 'twitter' && !isXMessage) || platform === 'patreon' || platform === 'kofi') && !isKofiComment) { element.insertAdjacentElement('afterend', container); } else { element.appendChild(container); } } } function toggleDashboard() { let dashboard = document.querySelector('.ling-dashboard'); if (!dashboard) { initDashboard(); dashboard = document.querySelector('.ling-dashboard'); } if (dashboard.classList.contains('active')) { dashboard.classList.remove('active'); setTimeout(() => { dashboard.style.display = 'none'; }, 200); } else { dashboard.style.display = 'block'; void dashboard.offsetWidth; setTimeout(() => { dashboard.classList.add('active'); }, 10); } } function initDashboard() { const cfg = Storage.getConfig(); const modeText = cfg.transMode === 'below' ? '下方显示' : cfg.transMode === 'hover' ? '悬浮显示' : '双语模式'; const div = document.createElement('div'); div.className = 'ling-dashboard'; div.innerHTML = `
翻译助手精简版
当前模式: ${modeText}
仅保留 X & Discord 翻译功能
`; document.body.appendChild(div); document.getElementById('ling-close-dash').onclick = toggleDashboard; document.getElementById('ling-btn-set').onclick = openSettings; } function openSettings() { const cfg = Storage.getConfig(); const div = document.createElement('div'); div.id = 'ling-settings-overlay'; div.innerHTML = `

⚙️ 翻译设置

`; document.body.appendChild(div); document.getElementById('ling-close').onclick = () => div.remove(); document.getElementById('ling-save').onclick = () => { Storage.setConfig({ transColor: document.getElementById('c-tc').value, transFontSize: document.getElementById('c-ts').value, transMode: document.getElementById('c-tm').value, floatTop: cfg.floatTop }); div.remove(); }; } function createFloatingToggle() { if (document.querySelector('.ling-float-toggle')) return; const div = document.createElement('div'); div.className = 'ling-float-toggle'; div.innerHTML = `Tran`; div.onclick = toggleDashboard; const isMobile = window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); let isDragging = false; let startY, startTop; if (isMobile) { // 移动设备使用触摸事件 div.addEventListener('touchstart', (e) => { isDragging = false; const touch = e.touches[0]; startY = touch.clientY; startTop = div.offsetTop; }); div.addEventListener('touchmove', (e) => { const touch = e.touches[0]; if (Math.abs(touch.clientY - startY) > 10) isDragging = true; if (isDragging) { e.preventDefault(); let newTop = startTop + (touch.clientY - startY); div.style.top = Math.max(10, Math.min(window.innerHeight - 50, newTop)) + 'px'; } }); div.addEventListener('touchend', () => { if (isDragging) { const cfg = Storage.getConfig(); cfg.floatTop = div.style.top; Storage.setConfig(cfg); } }); } else { // 桌面设备使用鼠标事件 div.onmousedown = (e) => { isDragging = false; startY = e.clientY; startTop = div.offsetTop; document.onmousemove = (ev) => { if (Math.abs(ev.clientY - startY) > 3) isDragging = true; if (isDragging) { let newTop = startTop + (ev.clientY - startY); div.style.top = Math.max(10, Math.min(window.innerHeight - 50, newTop)) + 'px'; } }; document.onmouseup = () => { document.onmousemove = null; document.onmouseup = null; if (isDragging) { const cfg = Storage.getConfig(); cfg.floatTop = div.style.top; Storage.setConfig(cfg); } }; }; } document.body.appendChild(div); } function getTextCompact(el) { return (el?.innerText || el?.textContent || '').replace(/\s+/g, '').trim(); } function isSortMenu(menu) { const txt = getTextCompact(menu); return txt.includes('排序方式') || txt.includes('Sortby') || txt.includes('Sortorder'); } let twitterLatestSortLocked = false; function isMenuItemSelected(menuItem) { return menuItem.getAttribute('aria-checked') === 'true' || !!menuItem.querySelector('svg'); } function enforceTwitterLatestSort(root = document) { let applied = false; const menus = root.querySelectorAll ? root.querySelectorAll('div[role="menu"]') : []; menus.forEach(menu => { if (!isSortMenu(menu) || menu.dataset.lingSortHandled === '1') return; const menuItems = menu.querySelectorAll('div[role="menuitem"]'); const latestItem = Array.from(menuItems).find(item => { const txt = getTextCompact(item); return txt.includes('最近') || txt.includes('Latest') || txt.includes('Recent'); }); if (!latestItem) return; if (!isMenuItemSelected(latestItem)) latestItem.click(); menu.dataset.lingSortHandled = '1'; applied = true; }); if (applied) twitterLatestSortLocked = true; return applied; } function forceSearchLiveTimeline() { if (!/x\.com$|twitter\.com$/.test(window.location.host)) return false; if (!window.location.pathname.startsWith('/search')) return false; const params = new URLSearchParams(window.location.search); if (params.get('f') === 'live') return false; params.set('f', 'live'); const next = `${window.location.pathname}?${params.toString()}${window.location.hash}`; window.location.replace(next); return true; } function findTwitterSortTrigger(root = document) { const selector = 'button[aria-haspopup="menu"], div[role="button"][aria-haspopup="menu"]'; const candidates = root.querySelectorAll ? Array.from(root.querySelectorAll(selector)) : []; for (const item of candidates) { const attrs = `${item.getAttribute('aria-label') || ''} ${item.getAttribute('title') || ''} ${getTextCompact(item)}`.toLowerCase(); if ( attrs.includes('sort') || attrs.includes('order') || attrs.includes('timeline') || attrs.includes('排序') || attrs.includes('选项') || attrs.includes('选项') ) return item; } return null; } function scheduleTwitterLatestSort(maxRetry = 16, delay = 500) { let retry = 0; const runner = () => { if (twitterLatestSortLocked) return; if (enforceTwitterLatestSort(document)) return; const trigger = findTwitterSortTrigger(document); if (trigger) { trigger.click(); setTimeout(() => enforceTwitterLatestSort(document), 120); } retry += 1; if (!twitterLatestSortLocked && retry < maxRetry) setTimeout(runner, delay); }; runner(); } function scan(node, siteType) { if (!node) return; if (node.nodeType !== 1 && node.nodeType !== 11) return; // Element or DocumentFragment (ShadowRoot) if (siteType === 'twitter') { const elements = node.querySelectorAll ? node.querySelectorAll('div[data-testid="tweetText"], div[data-testid^="message-text-"]') : []; elements.forEach(t => processContent(t, t.innerText, 'twitter')); enforceTwitterLatestSort(node); } else if (siteType === 'discord') { // 更新选择器以包括嵌入描述 const elements = node.querySelectorAll ? node.querySelectorAll('div[id^="message-content"], div.embedDescription__623de') : []; elements.forEach(msg => processContent(msg, msg.innerText, 'discord')); } else if (siteType === 'patreon') { const elements = node.querySelectorAll ? node.querySelectorAll('[data-tag="post-title"], [data-tag="post-content"] p, .cm-gBCCZY p') : []; elements.forEach(i => processContent(i, i.innerText, 'patreon')); } else if (siteType === 'kofi') { // 原有内容:文章标题、正文 // 新增内容:.kfds-top-mrgn-8 (评论区内容) const elements = node.querySelectorAll ? node.querySelectorAll('.caption-pdg, .post-story-text, .kfds-top-mrgn-8') : []; elements.forEach(i => { // 排除掉不是评论内容的通用边距容器(可选:检查其父级是否有评论特征) if (i.classList.contains('kfds-top-mrgn-8') && !i.closest('.kfds-lyt-column')) return; processContent(i, i.innerText, 'kofi'); }); // Shadow DOM 内容保持不变 const hosts = node.querySelectorAll ? node.querySelectorAll('.article-host') : []; hosts.forEach(h => { if (h.shadowRoot && !h.dataset.lingObserved) { h.dataset.lingObserved = "true"; scan(h.shadowRoot, 'kofi-shadow'); startObserver(h.shadowRoot, 'kofi-shadow'); } }); } else if (siteType === 'kofi-shadow') { const elements = node.querySelectorAll ? node.querySelectorAll('.fr-view p') : []; elements.forEach(p => processContent(p, p.innerText, 'kofi')); } } function startObserver(target, siteType) { const observer = new MutationObserver((mutations) => { mutations.forEach(m => { m.addedNodes.forEach(n => { if (n.nodeType === 1) { scan(n, siteType); if (siteType === 'twitter') enforceTwitterLatestSort(n); if (siteType === 'twitter' && (n.matches?.('div[data-testid="tweetText"]') || n.getAttribute?.('data-testid')?.startsWith('message-text-'))) { processContent(n, n.innerText, 'twitter'); } } }); }); }); observer.observe(target, { childList: true, subtree: true }); // X/Twitter 修复:监听“显示更多”展开长推文 if (siteType === 'twitter') { document.addEventListener('click', (e) => { // 1. 扩大匹配范围:检查点击目标或其父级是否包含关键文本 const btn = e.target.closest('button, a, [role="button"]'); if (!btn) return; const targetText = (btn.innerText || btn.textContent || "").trim(); const isShowMore = targetText.includes('显示更多') || targetText.includes('Show more') || targetText.includes('Show additional'); if (isShowMore) { // 2. 找到对应的推文容器 const article = btn.closest('article') || btn.closest('[data-testid="tweet"]'); if (article) { const tweetText = article.querySelector('div[data-testid="tweetText"]'); if (tweetText) { // 3. 强制重置状态 delete tweetText.dataset.lingProcessed; // 移除现有的翻译框防止重复显示 const oldBoxes = article.querySelectorAll('.ling-trans-box'); oldBoxes.forEach(box => box.remove()); // 4. 适当延长延迟,确保 X 完成 DOM 展开渲染(800ms -> 1200ms) setTimeout(() => { // 重新抓取最新的全文内容 const newText = tweetText.innerText || tweetText.textContent; processContent(tweetText, newText, 'twitter'); }, 1200); } } } }, true); } } function init() { updateStyles(); createFloatingToggle(); const host = window.location.host; let siteType = null; if (host.includes('twitter.com') || host.includes('x.com')) siteType = 'twitter'; else if (host.includes('discord.com')) siteType = 'discord'; else if (host.includes('patreon.com')) siteType = 'patreon'; else if (host.includes('ko-fi.com')) siteType = 'kofi'; if (siteType) { startObserver(document.body, siteType); scan(document.body, siteType); if (siteType === 'twitter') { if (forceSearchLiveTimeline()) return; scheduleTwitterLatestSort(); } } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); GM_registerMenuCommand("打开/关闭翻译设置", toggleDashboard); })();