TransTweetX

TransTweetX offers precise, emoji‑friendly translations for Twitter/X feed and now automatically retranslates text revealed after hitting “Show more/Read more”.

  1. // ==UserScript==
  2. // @name TransTweetX
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.4
  5. // @description TransTweetX offers precise, emoji‑friendly translations for Twitter/X feed and now automatically retranslates text revealed after hitting “Show more/Read more”.
  6. // @author Ian
  7. // @license MIT
  8. // @match https://twitter.com/*
  9. // @match https://x.com/*
  10. // @grant GM_xmlhttpRequest
  11. // @connect translate.googleapis.com
  12. // @require https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js
  13. // ==/UserScript==
  14.  
  15. (function () {
  16. 'use strict';
  17.  
  18. /*───────────────────────────
  19. * CONFIGURATION
  20. *──────────────────────────*/
  21. const config = {
  22. tweetSelector: '[data-testid="tweetText"]',
  23. targetLang: 'zh-CN',
  24. skipLanguages: new Set(['zh-CN', 'zh-TW']),
  25. languages: {
  26. 'zh-CN': '简体中文',
  27. 'zh-TW': '繁體中文',
  28. 'en': 'English',
  29. 'ja': '日本語',
  30. 'ru': 'Русский',
  31. 'fr': 'Français',
  32. 'de': 'Deutsch'
  33. },
  34. translationInterval: 100,
  35. maxRetry: 2,
  36. concurrentRequests: 3,
  37. baseDelay: 30,
  38. translationStyle: {
  39. color: 'inherit',
  40. fontSize: '0.9em',
  41. borderLeft: '2px solid #1da1f2',
  42. padding: '0 10px',
  43. margin: '4px 0',
  44. whiteSpace: 'pre-wrap',
  45. opacity: '0.8'
  46. },
  47. viewportPriority: {
  48. centerRadius: 200,
  49. updateInterval: 500,
  50. maxPriorityItems: 5
  51. }
  52. };
  53.  
  54. /*───────────────────────────
  55. * STATE
  56. *──────────────────────────*/
  57. let processingQueue = new Set();
  58. let requestQueue = [];
  59. let isTranslating = false;
  60. const visibleTweets = new Map();
  61.  
  62. /*───────────────────────────
  63. * UTILS
  64. *──────────────────────────*/
  65. const delay = ms => new Promise(res => setTimeout(res, ms));
  66.  
  67. async function translateAndDetectLanguage(text) {
  68. return new Promise(resolve => {
  69. GM_xmlhttpRequest({
  70. method: 'GET',
  71. url: `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${config.targetLang}&dt=t&q=${encodeURIComponent(text)}`,
  72. onload: res => {
  73. try {
  74. const data = JSON.parse(res.responseText);
  75. const translated = data[0].map(i => i[0]).join('').trim();
  76. const detectedSourceLang = (data[2] || '').toLowerCase();
  77. resolve({ translated, detectedSourceLang });
  78. } catch {
  79. resolve({ translated: text, detectedSourceLang: '' });
  80. }
  81. },
  82. onerror: () => resolve({ translated: text, detectedSourceLang: '' })
  83. });
  84. });
  85. }
  86.  
  87. async function translateTweet(tweet, text) {
  88. const { translated, detectedSourceLang } = await translateAndDetectLanguage(text);
  89. const lang = detectedSourceLang.toLowerCase();
  90.  
  91. if (lang === config.targetLang.toLowerCase() || config.skipLanguages.has(lang)) {
  92. const container = tweet.nextElementSibling;
  93. if (container?.classList.contains('translation-container')) container.remove();
  94. return null;
  95. }
  96.  
  97. return translated;
  98. }
  99.  
  100. function extractPerfectText(tweet) {
  101. const clone = tweet.cloneNode(true);
  102. clone.querySelectorAll('a, button, [data-testid="card.wrapper"]').forEach(el => {
  103. // Preserve emoji links, drop the rest
  104. if (!el.innerHTML.match(/[\p{Extended_Pictographic}\p{Emoji_Component}]/gu)) el.remove();
  105. });
  106. clone.innerHTML = clone.innerHTML.replace(/<br\s*\/?>(?=\n?)/gi, '\n');
  107. return clone.textContent.replace(/[\u00A0\u200B]+/g, ' ').trim();
  108. }
  109.  
  110. /*───────────────────────────
  111. * TRANSLATION PIPELINE
  112. *──────────────────────────*/
  113. function createTranslationContainer() {
  114. const container = document.createElement('div');
  115. container.className = 'translation-container';
  116. Object.assign(container.style, config.translationStyle);
  117. container.innerHTML = '<div class="loading-spinner"></div>';
  118. return container;
  119. }
  120.  
  121. function watchTweetChanges(tweet) {
  122. if (tweet.dataset.transWatcher) return; // already watching
  123.  
  124. const observer = new MutationObserver(() => {
  125. const updatedText = extractPerfectText(tweet);
  126. if (!updatedText || tweet.dataset.lastOriginalText === updatedText) return;
  127.  
  128. tweet.dataset.lastOriginalText = updatedText;
  129. const container = tweet.nextElementSibling;
  130. if (container?.classList.contains('translation-container')) {
  131. container.innerHTML = '<div class="loading-spinner"></div>';
  132. }
  133.  
  134. // push to front so the user sees update quickly
  135. requestQueue.unshift({ tweet, text: updatedText, retryCount: 0 });
  136. processQueue();
  137. });
  138.  
  139. observer.observe(tweet, { childList: true, characterData: true, subtree: true });
  140. tweet.dataset.transWatcher = 'true';
  141. }
  142.  
  143. function processTweet(tweet) {
  144. if (processingQueue.has(tweet) || tweet.dataset.transProcessed) return;
  145. processingQueue.add(tweet);
  146. tweet.dataset.transProcessed = 'true';
  147.  
  148. const originalText = extractPerfectText(tweet);
  149. if (!originalText) {
  150. processingQueue.delete(tweet);
  151. return;
  152. }
  153.  
  154. // store text for change detection
  155. tweet.dataset.lastOriginalText = originalText;
  156.  
  157. const container = createTranslationContainer();
  158. tweet.after(container);
  159.  
  160. const distance = distanceToViewportCenter(tweet);
  161. const request = { tweet, text: originalText, retryCount: 0 };
  162. if (distance < config.viewportPriority.centerRadius) {
  163. requestQueue.unshift(request);
  164. } else {
  165. requestQueue.push(request);
  166. }
  167.  
  168. watchTweetChanges(tweet);
  169. processQueue();
  170. }
  171.  
  172. async function processQueue() {
  173. if (isTranslating || requestQueue.length === 0) return;
  174. isTranslating = true;
  175.  
  176. // closest to viewport centre first
  177. requestQueue.sort((a, b) => distanceToViewportCenter(a.tweet) - distanceToViewportCenter(b.tweet));
  178. const batch = requestQueue.splice(0, config.concurrentRequests);
  179.  
  180. await Promise.all(batch.map(async ({ tweet, text }) => {
  181. try {
  182. const translated = await translateTweet(tweet, text);
  183. if (translated) updateTranslation(tweet, translated);
  184. } catch {
  185. markTranslationFailed(tweet);
  186. } finally {
  187. processingQueue.delete(tweet);
  188. }
  189. }));
  190.  
  191. isTranslating = false;
  192. if (requestQueue.length > 0) processQueue();
  193. }
  194.  
  195. function updateTranslation(tweet, translated) {
  196. const container = tweet.nextElementSibling;
  197. if (container?.classList.contains('translation-container')) {
  198. container.innerHTML = translated.replace(/\n/g, '<br>');
  199. }
  200. }
  201.  
  202. function markTranslationFailed(tweet) {
  203. const container = tweet.nextElementSibling;
  204. if (container?.classList.contains('translation-container')) {
  205. container.innerHTML = '<span style="color:red">翻译失败</span>';
  206. }
  207. }
  208.  
  209. /*───────────────────────────
  210. * VIEWPORT TRACKING
  211. *──────────────────────────*/
  212. function getElementCenter(el) {
  213. const rect = el.getBoundingClientRect();
  214. return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
  215. }
  216.  
  217. function distanceToViewportCenter(el) {
  218. const center = { x: window.innerWidth / 2, y: window.innerHeight / 2 };
  219. const elCenter = visibleTweets.get(el) || getElementCenter(el);
  220. return Math.hypot(center.x - elCenter.x, center.y - elCenter.y);
  221. }
  222.  
  223. function setupViewportTracker() {
  224. const update = () => {
  225. document.querySelectorAll(config.tweetSelector).forEach(tweet => {
  226. const rect = tweet.getBoundingClientRect();
  227. if (rect.top < window.innerHeight && rect.bottom > 0) {
  228. visibleTweets.set(tweet, getElementCenter(tweet));
  229. } else {
  230. visibleTweets.delete(tweet);
  231. }
  232. });
  233. };
  234. window.addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });
  235. setInterval(update, config.viewportPriority.updateInterval);
  236. }
  237.  
  238. /*───────────────────────────
  239. * MUTATION OBSERVER (new tweets)
  240. *──────────────────────────*/
  241. function setupMutationObserver() {
  242. const observer = new MutationObserver(mutations => {
  243. mutations.forEach(m => {
  244. m.addedNodes.forEach(node => {
  245. if (node.nodeType === 1) node.querySelectorAll(config.tweetSelector).forEach(processTweet);
  246. });
  247. });
  248. });
  249. observer.observe(document, { childList: true, subtree: true });
  250. }
  251.  
  252. /*───────────────────────────
  253. * CONTROL PANEL
  254. *──────────────────────────*/
  255. function initControlPanel() {
  256. const panelHTML = `
  257. <div id="trans-panel">
  258. <div id="trans-icon"><i class="fa-solid fa-language"></i></div>
  259. <div id="trans-menu">
  260. <div style="padding: 6px 12px; font-weight: bold">Target language</div>
  261. ${Object.entries(config.languages).map(([code, name]) => `
  262. <div class="lang-item target" data-lang="${code}">${name}</div>
  263. `).join('')}
  264. <hr style="margin: 8px 0; border: none; border-top: 1px solid #ccc;">
  265. <div style="padding: 6px 12px; font-weight: bold">No translation of language</div>
  266. ${Object.entries(config.languages).map(([code, name]) => `
  267. <div class="lang-item skip ${config.skipLanguages.has(code) ? 'active' : ''}" data-skip="${code}">${name}</div>
  268. `).join('')}
  269. </div>
  270. </div>
  271. `;
  272.  
  273. const style = document.createElement('style');
  274. style.textContent = `
  275. #trans-panel { position: fixed; bottom: 20px; right: 20px; z-index: 9999; font-family: sans-serif; }
  276. #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); }
  277. #trans-icon:hover { transform: scale(1.1); }
  278. #trans-icon i { color: white; font-size: 20px; }
  279. #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); }
  280. #trans-menu.show { opacity: 1; visibility: visible; transform: translateY(0); }
  281. .lang-item { padding: 10px 16px; font-size: 14px; cursor: pointer; transition: background 0.2s; }
  282. .lang-item:hover { background: rgba(29,161,242,0.1); }
  283. .lang-item.target[data-lang="${config.targetLang}"] { color: #1da1f2; font-weight: bold; }
  284. .lang-item.skip.active { background: rgba(29,161,242,0.1); }
  285. .loading-spinner { width: 16px; height: 16px; border: 2px solid #ddd; border-top-color: #1da1f2; border-radius: 50%; animation: spin 1s linear infinite; margin: 5px; }
  286. @keyframes spin { to { transform: rotate(360deg); } }
  287. `;
  288. document.head.appendChild(style);
  289. document.body.insertAdjacentHTML('beforeend', panelHTML);
  290.  
  291. const icon = document.getElementById('trans-icon');
  292. const menu = document.getElementById('trans-menu');
  293.  
  294. icon.addEventListener('click', e => {
  295. e.stopPropagation();
  296. menu.classList.toggle('show');
  297. });
  298.  
  299. document.querySelectorAll('.lang-item.target').forEach(item => {
  300. item.addEventListener('click', function () {
  301. config.targetLang = this.dataset.lang;
  302. refreshAllTranslations();
  303. menu.classList.remove('show');
  304. // update highlight
  305. document.querySelectorAll('.lang-item.target').forEach(li => li.style.color = '');
  306. this.style.color = '#1da1f2';
  307. });
  308. });
  309.  
  310. document.querySelectorAll('.lang-item.skip').forEach(item => {
  311. item.addEventListener('click', function () {
  312. const lang = this.dataset.skip;
  313. if (config.skipLanguages.has(lang)) {
  314. config.skipLanguages.delete(lang);
  315. this.classList.remove('active');
  316. } else {
  317. config.skipLanguages.add(lang);
  318. this.classList.add('active');
  319. }
  320. });
  321. });
  322.  
  323. document.addEventListener('click', e => {
  324. if (!e.target.closest('#trans-panel')) menu.classList.remove('show');
  325. });
  326. }
  327.  
  328. /*───────────────────────────
  329. * REFRESH UTIL (when targetLang changed)
  330. *──────────────────────────*/
  331. function refreshAllTranslations() {
  332. document.querySelectorAll('.translation-container').forEach(el => el.remove());
  333. processingQueue.clear();
  334. requestQueue = [];
  335. document.querySelectorAll(config.tweetSelector).forEach(tweet => {
  336. delete tweet.dataset.transProcessed;
  337. processTweet(tweet);
  338. });
  339. }
  340.  
  341. /*───────────────────────────
  342. * INIT
  343. *──────────────────────────*/
  344. function init() {
  345. initControlPanel();
  346. setupViewportTracker();
  347. setupMutationObserver();
  348. document.querySelectorAll(config.tweetSelector).forEach(tweet => {
  349. visibleTweets.set(tweet, getElementCenter(tweet));
  350. processTweet(tweet);
  351. });
  352. }
  353.  
  354. window.addEventListener('load', init);
  355. if (document.readyState === 'complete') init();
  356. })();