TransTweetX

TransTweetX offers precise, emoji-friendly translations for Twitter/X feed.

// ==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();
})();