TransPostBSKY

Auto-translate Bluesky timeline, post detail and replies – emoji-safe, viewport-aware, re-translate on “Show more”, with a floating language panel.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         TransPostBSKY
// @namespace    http://tampermonkey.net/
// @version      1.2
// @description  Auto-translate Bluesky timeline, post detail and replies – emoji-safe, viewport-aware, re-translate on “Show more”, with a floating language panel.
// @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';

  /*───────────────────────────
   *  CONFIG
   *──────────────────────────*/
  const config = {
    /** 节点选择器:覆盖首页流、详情页、回帖正文 */
    postSelectors: [
      'main [data-testid*="postText"]',            // 早期/后备 DOM
      'main div[dir="auto"][data-word-wrap]'       // 现行 DOM
    ],

    targetLang: 'zh-CN',
    skipLanguages: new Set(['zh-CN', 'zh-TW']),
    languages: {
      'zh-CN': '简体中文',
      'zh-TW': '繁體中文',
      en: 'English',
      ja: '日本語',
      ru: 'Русский',
      fr: 'Français',
      de: 'Deutsch'
    },

    concurrentRequests: 3,
    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 auto',
      alignSelf: 'flex-start'
    },

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

  /*───────────────────────────
   *  STATE
   *──────────────────────────*/
  const processing = new Set();
  let queue = [];
  let busy = false;
  const visible = new Map();

  /*───────────────────────────
   *  HELPERS
   *──────────────────────────*/
  const selectorAll = config.postSelectors.join(',');
  function collectNodes(root = document) {
    const out = new Set();
    if (root.matches?.(selectorAll)) out.add(root);
    root.querySelectorAll?.(selectorAll).forEach(n => out.add(n));
    return [...out].filter(n => !n.classList.contains('translation-container'));
  }

  async function gTranslate(text) {
    return new Promise(res => {
      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: r => {
          try {
            const j = JSON.parse(r.responseText);
            res({ tr: j[0].map(i => i[0]).join('').trim(), src: (j[2] || '').toLowerCase() });
          } catch {
            res({ tr: text, src: '' });
          }
        },
        onerror: () => res({ tr: text, src: '' })
      });
    });
  }

  function extractText(node) {
    const clone = node.cloneNode(true);
    clone.querySelectorAll('a, button').forEach(el => {
      if (!/[\p{Extended_Pictographic}\p{Emoji_Component}]/u.test(el.innerHTML)) el.remove();
    });
    clone.innerHTML = clone.innerHTML.replace(/<br\s*\/?>/gi, '\n');
    return clone.textContent.replace(/[\u00A0\u200B]+/g, ' ').trim();
  }

  function makeBox() {
    const div = document.createElement('div');
    div.className = 'translation-container';
    Object.assign(div.style, config.translationStyle);
    div.innerHTML = '<div class="loading-spinner"></div>';
    return div;
  }

  /*───────────────────────────
   *  CORE PIPELINE
   *──────────────────────────*/
  function handle(node) {
    if (processing.has(node) || node.dataset.trDone) return;
    processing.add(node);
    node.dataset.trDone = 1;

    const raw = extractText(node);
    if (!raw) return processing.delete(node);
    node.dataset.raw = raw;
    node.after(makeBox());

    const req = { node, text: raw };
    (distance(node) < config.viewportPriority.centerRadius ? queue.unshift(req) : queue.push(req));
    watchNode(node);
    runQueue();
  }

  function watchNode(node) {
    if (node.dataset.trObs) return;
    node.dataset.trObs = 1;
    new MutationObserver(() => {
      const cur = extractText(node);
      if (!cur || cur === node.dataset.raw) return;
      node.dataset.raw = cur;
      node.nextElementSibling.innerHTML = '<div class="loading-spinner"></div>';
      queue.unshift({ node, text: cur });
      runQueue();
    }).observe(node, { childList: true, characterData: true, subtree: true });
  }

  async function runQueue() {
    if (busy || !queue.length) return;
    busy = true;

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

    await Promise.all(
      batch.map(async ({ node, text }) => {
        try {
          const { tr, src } = await gTranslate(text);
          node.nextElementSibling.innerHTML =
            src === config.targetLang.toLowerCase() || config.skipLanguages.has(src)
              ? ''
              : tr.replace(/\n/g, '<br>');
        } catch {
          node.nextElementSibling.innerHTML = '<span style="color:red">翻译失败</span>';
        } finally {
          processing.delete(node);
        }
      })
    );

    busy = false;
    queue.length && runQueue();
  }

  /*───────────────────────────
   *  VIEWPORT LOGIC
   *──────────────────────────*/
  function center(el) {
    const r = el.getBoundingClientRect();
    return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
  }
  function distance(el) {
    const c = visible.get(el) || center(el);
    return Math.hypot(innerWidth / 2 - c.x, innerHeight / 2 - c.y);
  }
  function trackViewport() {
    const update = () =>
      collectNodes().forEach(n => {
        const r = n.getBoundingClientRect();
        r.top < innerHeight && r.bottom > 0 ? visible.set(n, center(n)) : visible.delete(n);
      });
    addEventListener('scroll', () => requestAnimationFrame(update), { passive: true });
    setInterval(update, config.viewportPriority.updateInterval);
  }

  /*───────────────────────────
   *  DOM OBSERVERS & SCANS
   *──────────────────────────*/
  function scan(root = document) {
    collectNodes(root).forEach(handle);
  }
  function observeDOM() {
    new MutationObserver(ms => ms.forEach(m => m.addedNodes.forEach(n => scan(n))))
      .observe(document, { childList: true, subtree: true });
  }

  /*───────────────────────────
   *  CONTROL PANEL
   *──────────────────────────*/
  function initPanel() {
    const panelHTML = `
      <div id="trans-panel">
        <div id="trans-icon"><i class="fa-solid fa-language"></i></div>
        <div id="trans-menu">
          <div class="menu-title">Target language</div>
          ${Object.entries(config.languages)
            .map(
              ([code, name]) =>
                `<div class="lang-item target" data-lang="${code}">${name}</div>`
            )
            .join('')}
          <hr>
          <div class="menu-title">Do not translate</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:200px;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)}
      .menu-title{padding:6px 12px;font-weight:bold;font-size:13px}
      .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%}
      hr{margin:8px 0;border:none;border-top:1px solid #ccc}
    `;
    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.addEventListener('click', e => {
      if (!e.target.closest('#trans-panel')) menu.classList.remove('show');
    });

    /** 切换目标语言 **/
    document.querySelectorAll('.lang-item.target').forEach(item =>
      item.addEventListener('click', function () {
        config.targetLang = this.dataset.lang;
        document.querySelectorAll('.lang-item.target').forEach(li => (li.style.color = ''));
        this.style.color = '#4c9aff';
        refreshAll();
        menu.classList.remove('show');
      })
    );

    /** 切换跳过语言 **/
    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');
      })
    );
  }

  function refreshAll() {
    document.querySelectorAll('.translation-container').forEach(el => el.remove());
    processing.clear();
    queue = [];
    scan();
  }

  /*───────────────────────────
   *  INIT
   *──────────────────────────*/
  function init() {
    initPanel();
    trackViewport();
    observeDOM();
    scan();
    setInterval(scan, 1000); // 再保险补漏
  }

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