Greasy Fork is available in English.

TransPostBSKY

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

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