Greasy Fork is available in English.
支持推特实时翻译,Discord 翻译,支持翻译字体大小颜色可调整。精简版,仅保留翻译功能。
// ==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
// ==/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 = `
<div style="color:#00E676;font-weight:bold;margin-bottom:10px;display:flex;justify-content:space-between;border-bottom:1px solid #333;padding-bottom:5px;">
<span>翻译助手精简版</span><span style="cursor:pointer;" id="ling-close-dash">✖</span>
</div>
<div style="margin-bottom:10px;font-size:12px;color:#ccc;">当前模式: ${modeText}</div>
<button class="ling-btn" id="ling-btn-set">⚙️ 翻译设置</button>
<div style="margin-top:10px;font-size:10px;color:#666;text-align:center;">仅保留 X & Discord 翻译功能</div>
`;
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 = `
<div id="ling-settings-box">
<h3 style="margin-top:0;color:#00E676;">⚙️ 翻译设置</h3>
<div class="ling-row"><label>翻译颜色</label><input type="color" id="c-tc" value="${cfg.transColor}"></div>
<div class="ling-row"><label>翻译字号</label><input type="text" id="c-ts" value="${cfg.transFontSize}" style="width:60px;background:#222;border:1px solid #444;color:#fff;"></div>
<div class="ling-row"><label>翻译模式</label>
<select id="c-tm" style="background:#222;border:1px solid #444;color:#fff;">
<option value="below" ${cfg.transMode === 'below' ? 'selected' : ''}>下方显示</option>
<option value="hover" ${cfg.transMode === 'hover' ? 'selected' : ''}>悬浮显示</option>
<option value="bilingual" ${cfg.transMode === 'bilingual' ? 'selected' : ''}>双语模式</option>
</select>
</div>
<button class="ling-btn" id="ling-save">保存</button>
<button class="ling-btn" id="ling-close" style="background:#333;color:#fff;margin-top:10px">关闭</button>
</div>
`;
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 = `<span class="ling-logo-text">Tran</span>`;
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);
})();