TransPostBSKY

Automatic insertion of translations for the Bluesky timeline (emoji support, visual prioritization, automatic detection of "Show more" re-translations, etc.)

当前为 2025-05-03 提交的版本,查看 最新版本

// ==UserScript==
// @name         TransPostBSKY
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Automatic insertion of translations for the Bluesky timeline (emoji support, visual prioritization, automatic detection of "Show more" re-translations, etc.)
// @author       Ian
// @license      MIT
// @match        https://bsky.app/*
// @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';

  /*───────────────────────────
   *  CONFIGURATION
   *──────────────────────────*/
  const config = {
    postSelector: '[data-testid="postText"]',

    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 #4c9aff',
      padding: '0 10px',
      margin: '4px 0',
      whiteSpace: 'pre-wrap',
      opacity: '0.8',
      /**让翻译块独占整行 **/
      display: 'block',
      width: '100%',
      flex: '0 0 100%'
    },

    viewportPriority: {
      centerRadius: 200,
      updateInterval: 500,
      maxPriorityItems: 5
    }
  };

  /*───────────────────────────
   *  STATE
   *──────────────────────────*/
  let processingQueue = new Set();
  let requestQueue = [];
  let isTranslating = false;
  const visiblePosts = new Map();

  /*───────────────────────────
   *  UTILS
   *──────────────────────────*/
  const delay = ms => new Promise(res => setTimeout(res, 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 translatePost(node, text) {
    const { translated, detectedSourceLang } = await translateAndDetectLanguage(text);
    const lang = detectedSourceLang.toLowerCase();

    if (lang === config.targetLang.toLowerCase() || config.skipLanguages.has(lang)) {
      const container = node.nextElementSibling;
      if (container?.classList.contains('translation-container')) container.remove();
      return null;
    }
    return translated;
  }

  function extractPerfectText(node) {
    const clone = node.cloneNode(true);
    clone.querySelectorAll('a, button').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();
  }

  /*───────────────────────────
   *  TRANSLATION PIPELINE
   *──────────────────────────*/
  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 watchPostChanges(node) {
    if (node.dataset.transWatcher) return;

    const observer = new MutationObserver(() => {
      const updatedText = extractPerfectText(node);
      if (!updatedText || node.dataset.lastOriginalText === updatedText) return;

      node.dataset.lastOriginalText = updatedText;
      const container = node.nextElementSibling;
      if (container?.classList.contains('translation-container')) {
        container.innerHTML = '<div class="loading-spinner"></div>';
      }

      requestQueue.unshift({ node, text: updatedText, retryCount: 0 });
      processQueue();
    });

    observer.observe(node, { childList: true, characterData: true, subtree: true });
    node.dataset.transWatcher = 'true';
  }

  function processPost(node) {
    if (processingQueue.has(node) || node.dataset.transProcessed) return;
    processingQueue.add(node);
    node.dataset.transProcessed = 'true';

    const originalText = extractPerfectText(node);
    if (!originalText) {
      processingQueue.delete(node);
      return;
    }
    node.dataset.lastOriginalText = originalText;

    const container = createTranslationContainer();
    node.after(container);

    const distance = distanceToViewportCenter(node);
    const req = { node, text: originalText, retryCount: 0 };
    distance < config.viewportPriority.centerRadius ? requestQueue.unshift(req) : requestQueue.push(req);

    watchPostChanges(node);
    processQueue();
  }

  async function processQueue() {
    if (isTranslating || requestQueue.length === 0) return;
    isTranslating = true;

    requestQueue.sort((a, b) => distanceToViewportCenter(a.node) - distanceToViewportCenter(b.node));
    const batch = requestQueue.splice(0, config.concurrentRequests);

    await Promise.all(batch.map(async ({ node, text }) => {
      try {
        const translated = await translatePost(node, text);
        if (translated) updateTranslation(node, translated);
      } catch {
        markTranslationFailed(node);
      } finally {
        processingQueue.delete(node);
      }
    }));

    isTranslating = false;
    if (requestQueue.length > 0) processQueue();
  }

  function updateTranslation(node, translated) {
    const container = node.nextElementSibling;
    if (container?.classList.contains('translation-container')) {
      container.innerHTML = translated.replace(/\n/g, '<br>');
    }
  }

  function markTranslationFailed(node) {
    const container = node.nextElementSibling;
    if (container?.classList.contains('translation-container')) {
      container.innerHTML = '<span style="color:red">翻译失败</span>';
    }
  }

  /*───────────────────────────
   *  VIEWPORT TRACKING
   *──────────────────────────*/
  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 = visiblePosts.get(el) || getElementCenter(el);
    return Math.hypot(center.x - elCenter.x, center.y - elCenter.y);
  }

  function setupViewportTracker() {
    const update = () => {
      document.querySelectorAll(config.postSelector).forEach(node => {
        const rect = node.getBoundingClientRect();
        rect.top < window.innerHeight && rect.bottom > 0
          ? visiblePosts.set(node, getElementCenter(node))
          : visiblePosts.delete(node);
      });
    };
    window.addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });
    setInterval(update, config.viewportPriority.updateInterval);
  }

  /*───────────────────────────
   *  MUTATION OBSERVER (new posts)
   *──────────────────────────*/
  function setupMutationObserver() {
    const observer = new MutationObserver(mutations => {
      mutations.forEach(m => {
        m.addedNodes.forEach(node => {
          if (node.nodeType === 1) node.querySelectorAll(config.postSelector).forEach(processPost);
        });
      });
    });
    observer.observe(document, { childList: true, subtree: true });
  }

  /*───────────────────────────
   *  CONTROL PANEL
   *──────────────────────────*/
  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(76,154,255,.9);display:flex;align-items:center;justify-content:center;cursor:pointer;transition:.3s;box-shadow:0 4px 6px rgba(0,0,0,.1)}
      #trans-icon:hover{transform:scale(1.1)}
      #trans-icon i{color:#fff;font-size:20px}
      #trans-menu{width:180px;background:rgba(255,255,255,.95);backdrop-filter:blur(10px);border-radius:12px;padding:8px 0;margin-top:10px;opacity:0;visibility:hidden;transform:translateY(10px);transition:.3s;box-shadow:0 8px 24px rgba(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 .2s}
      .lang-item:hover{background:rgba(76,154,255,.1)}
      .lang-item.target[data-lang="${config.targetLang}"]{color:#4c9aff;font-weight:bold}
      .lang-item.skip.active{background:rgba(76,154,255,.1)}
      .loading-spinner{width:16px;height:16px;border:2px solid #ddd;border-top-color:#4c9aff;border-radius:50%;animation:spin 1s linear infinite;margin:5px}
      @keyframes spin{to{transform:rotate(360deg)}}
      /* 保证翻译块整行显示 */
      .translation-container{display:block;width:100%;flex:0 0 100%}
    `;
    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.target').forEach(li => li.style.color = '');
        this.style.color = '#4c9aff';
      });
    });

    document.querySelectorAll('.lang-item.skip').forEach(item => {
      item.addEventListener('click', function () {
        const lang = this.dataset.skip;
        config.skipLanguages.has(lang) ? config.skipLanguages.delete(lang) : config.skipLanguages.add(lang);
        this.classList.toggle('active');
      });
    });

    document.addEventListener('click', e => {
      if (!e.target.closest('#trans-panel')) menu.classList.remove('show');
    });
  }

  /*───────────────────────────
   *  REFRESH UTIL
   *──────────────────────────*/
  function refreshAllTranslations() {
    document.querySelectorAll('.translation-container').forEach(el => el.remove());
    processingQueue.clear();
    requestQueue = [];
    document.querySelectorAll(config.postSelector).forEach(node => {
      delete node.dataset.transProcessed;
      processPost(node);
    });
  }

  /*───────────────────────────
   *  INIT
   *──────────────────────────*/
  function init() {
    initControlPanel();
    setupViewportTracker();
    setupMutationObserver();
    document.querySelectorAll(config.postSelector).forEach(node => {
      visiblePosts.set(node, getElementCenter(node));
      processPost(node);
    });
  }

  window.addEventListener('load', init);
  if (document.readyState === 'complete') init();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址