// ==UserScript==
// @name TransTweetX
// @namespace http://tampermonkey.net/
// @version 1.3
// @description TransTweetX offers precise, emoji-friendly translations for Twitter/X feed.
// @author Ian
// @license MIT
// @match https://twitter.com/*
// @match https://x.com/*
// @grant GM_xmlhttpRequest
// @connect translate.googleapis.com
// @require https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js
// ==/UserScript==
(function () {
'use strict';
const config = {
tweetSelector: '[data-testid="tweetText"]',
targetLang: 'zh-CN',
skipLanguages: new Set(['zh-CN', 'zh-TW']),
languages: {
'zh-CN': '简体中文',
'zh-TW': '繁體中文',
'en': 'English',
'ja': '日本語',
'ru': 'Русский',
'fr': 'Français',
'de': 'Deutsch'
},
translationInterval: 100,
maxRetry: 2,
concurrentRequests: 3,
baseDelay: 30,
translationStyle: {
color: 'inherit',
fontSize: '0.9em',
borderLeft: '2px solid #1da1f2',
padding: '0 10px',
margin: '4px 0',
whiteSpace: 'pre-wrap',
opacity: '0.8'
},
viewportPriority: {
centerRadius: 200,
updateInterval: 500,
maxPriorityItems: 5
}
};
let processingQueue = new Set();
let requestQueue = [];
let isTranslating = false;
let visibleTweets = new Map();
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function translateAndDetectLanguage(text) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${config.targetLang}&dt=t&q=${encodeURIComponent(text)}`,
onload: res => {
try {
const data = JSON.parse(res.responseText);
const translated = data[0].map(i => i[0]).join('').trim();
const detectedSourceLang = (data[2] || '').toLowerCase();
resolve({ translated, detectedSourceLang });
} catch {
resolve({ translated: text, detectedSourceLang: '' });
}
},
onerror: () => resolve({ translated: text, detectedSourceLang: '' })
});
});
}
async function translateTweet(tweet, text) {
const { translated, detectedSourceLang } = await translateAndDetectLanguage(text);
const lang = detectedSourceLang.toLowerCase();
if (lang === config.targetLang.toLowerCase() || config.skipLanguages.has(lang)) {
const container = tweet.nextElementSibling;
if (container?.classList.contains('translation-container')) container.remove();
return null;
}
return translated;
}
function extractPerfectText(tweet) {
const clone = tweet.cloneNode(true);
clone.querySelectorAll('a, button, [data-testid="card.wrapper"]').forEach(el => {
if (!el.innerHTML.match(/[\p{Extended_Pictographic}\p{Emoji_Component}]/gu)) el.remove();
});
clone.innerHTML = clone.innerHTML.replace(/<br\s*\/?>/gi, '\n');
return clone.textContent.replace(/[\u00A0\u200B]+/g, ' ').trim();
}
function createTranslationContainer() {
const container = document.createElement('div');
container.className = 'translation-container';
Object.assign(container.style, config.translationStyle);
container.innerHTML = '<div class="loading-spinner"></div>';
return container;
}
function processTweet(tweet) {
if (processingQueue.has(tweet) || tweet.dataset.transProcessed) return;
processingQueue.add(tweet);
tweet.dataset.transProcessed = true;
const originalText = extractPerfectText(tweet);
if (!originalText) return;
const container = createTranslationContainer();
tweet.after(container);
const distance = distanceToViewportCenter(tweet);
const request = { tweet, text: originalText, retryCount: 0 };
if (distance < config.viewportPriority.centerRadius) {
requestQueue.unshift(request);
} else {
requestQueue.push(request);
}
processQueue();
}
async function processQueue() {
if (isTranslating || requestQueue.length === 0) return;
isTranslating = true;
requestQueue.sort((a, b) => distanceToViewportCenter(a.tweet) - distanceToViewportCenter(b.tweet));
const batch = requestQueue.splice(0, config.concurrentRequests);
await Promise.all(batch.map(async ({ tweet, text }) => {
try {
const translated = await translateTweet(tweet, text);
if (translated) updateTranslation(tweet, translated);
} catch {
markTranslationFailed(tweet);
} finally {
processingQueue.delete(tweet);
}
}));
isTranslating = false;
if (requestQueue.length > 0) processQueue();
}
function updateTranslation(tweet, translated) {
const container = tweet.nextElementSibling;
if (container?.classList.contains('translation-container')) {
container.innerHTML = translated.replace(/\n/g, '<br>');
}
}
function markTranslationFailed(tweet) {
const container = tweet.nextElementSibling;
if (container?.classList.contains('translation-container')) {
container.innerHTML = '<span style="color:red">翻译失败</span>';
}
}
function setupMutationObserver() {
const observer = new MutationObserver(mutations => {
mutations.forEach(m => {
m.addedNodes.forEach(node => {
if (node.nodeType === 1) node.querySelectorAll(config.tweetSelector).forEach(processTweet);
});
});
});
observer.observe(document, { childList: true, subtree: true });
}
function setupViewportTracker() {
const update = () => {
document.querySelectorAll(config.tweetSelector).forEach(tweet => {
const rect = tweet.getBoundingClientRect();
if (rect.top < window.innerHeight && rect.bottom > 0) {
visibleTweets.set(tweet, getElementCenter(tweet));
} else {
visibleTweets.delete(tweet);
}
});
};
window.addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });
setInterval(update, config.viewportPriority.updateInterval);
}
function getElementCenter(el) {
const rect = el.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}
function distanceToViewportCenter(el) {
const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
const elCenter = visibleTweets.get(el) || getElementCenter(el);
return Math.hypot(center.x - elCenter.x, center.y - elCenter.y);
}
function initControlPanel() {
const panelHTML = `
<div id="trans-panel">
<div id="trans-icon"><i class="fa-solid fa-language"></i></div>
<div id="trans-menu">
<div style="padding: 6px 12px; font-weight: bold">Target language</div>
${Object.entries(config.languages).map(([code, name]) => `
<div class="lang-item target" data-lang="${code}">${name}</div>
`).join('')}
<hr style="margin: 8px 0; border: none; border-top: 1px solid #ccc;">
<div style="padding: 6px 12px; font-weight: bold">No translation of language</div>
${Object.entries(config.languages).map(([code, name]) => `
<div class="lang-item skip ${config.skipLanguages.has(code) ? 'active' : ''}" data-skip="${code}">${name}</div>
`).join('')}
</div>
</div>
`;
const style = document.createElement('style');
style.textContent = `
#trans-panel { position: fixed; bottom: 20px; right: 20px; z-index: 9999; font-family: sans-serif; }
#trans-icon { width: 40px; height: 40px; border-radius: 50%; background: rgba(29, 161, 242, 0.9); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.3s; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
#trans-icon:hover { transform: scale(1.1); }
#trans-icon i { color: white; font-size: 20px; }
#trans-menu { width: 180px; background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); border-radius: 12px; padding: 8px 0; margin-top: 10px; opacity: 0; visibility: hidden; transform: translateY(10px); transition: all 0.3s; box-shadow: 0 8px 24px rgba(0,0,0,0.15); }
#trans-menu.show { opacity: 1; visibility: visible; transform: translateY(0); }
.lang-item { padding: 10px 16px; font-size: 14px; cursor: pointer; transition: background 0.2s; }
.lang-item:hover { background: rgba(29,161,242,0.1); }
.lang-item.target[data-lang="${config.targetLang}"] { color: #1da1f2; font-weight: bold; }
.lang-item.skip.active { background: rgba(29,161,242,0.1); }
.loading-spinner { width: 16px; height: 16px; border: 2px solid #ddd; border-top-color: #1da1f2; border-radius: 50%; animation: spin 1s linear infinite; margin: 5px; }
@keyframes spin { to { transform: rotate(360deg); } }
`;
document.head.appendChild(style);
document.body.insertAdjacentHTML('beforeend', panelHTML);
const icon = document.getElementById('trans-icon');
const menu = document.getElementById('trans-menu');
icon.addEventListener('click', e => {
e.stopPropagation();
menu.classList.toggle('show');
});
document.querySelectorAll('.lang-item.target').forEach(item => {
item.addEventListener('click', function () {
config.targetLang = this.dataset.lang;
refreshAllTranslations();
menu.classList.remove('show');
});
});
document.querySelectorAll('.lang-item.skip').forEach(item => {
item.addEventListener('click', function () {
const lang = this.dataset.skip;
if (config.skipLanguages.has(lang)) {
config.skipLanguages.delete(lang);
this.classList.remove('active');
} else {
config.skipLanguages.add(lang);
this.classList.add('active');
}
});
});
document.addEventListener('click', e => {
if (!e.target.closest('#trans-panel')) menu.classList.remove('show');
});
}
function refreshAllTranslations() {
document.querySelectorAll('.translation-container').forEach(el => el.remove());
processingQueue.clear();
requestQueue = [];
document.querySelectorAll(config.tweetSelector).forEach(tweet => {
delete tweet.dataset.transProcessed;
processTweet(tweet);
});
}
function init() {
initControlPanel();
setupViewportTracker();
setupMutationObserver();
document.querySelectorAll(config.tweetSelector).forEach(tweet => {
visibleTweets.set(tweet, getElementCenter(tweet));
processTweet(tweet);
});
}
window.addEventListener('load', init);
if (document.readyState === 'complete') init();
})();