TransTweetX

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

目前為 2025-02-18 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         TransTweetX
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  TransTweetX offers precise, emoji-friendly translations for Twitter/X feed.
// @author       Ian & https://github.com/iaaaannn0/TransTweetX
// @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',
        languages: {
            'zh-CN': '中文',
            'en': 'English',
            'ja': '日本語',
            'ru': 'Русский',
            'fr': 'Français',
            'de': 'Deutsch'
        },
        translationInterval: 200,
        maxRetry: 3,
        translationStyle: {
            color: 'inherit',
            fontSize: '0.9em',
            borderLeft: '2px solid #1da1f2',
            padding: '0 10px',
            margin: '4px 0',
            whiteSpace: 'pre-wrap',
            opacity: '0.8'
        }
    };

    let processingQueue = new Set();
    let requestQueue = [];
    let isTranslating = false;

    // 初始化控制面板
    function initControlPanel() {
        const panelHTML = `
            <div id="trans-panel">
                <div id="trans-icon"><i class="fa-solid fa-language"></i></div>
                <div id="trans-menu">
                    ${Object.entries(config.languages).map(([code, name]) => `
                        <div class="lang-item" data-lang="${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: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, 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 cubic-bezier(0.4, 0, 0.2, 1);
                backdrop-filter: blur(10px);
                box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
            }

            #trans-icon:hover {
                transform: scale(1.1);
                background: rgba(29, 161, 242, 0.95);
            }

            #trans-icon i {
                color: white;
                font-size: 20px;
            }

            #trans-menu {
                width: 150px;
                background: rgba(255, 255, 255, 0.9);
                backdrop-filter: blur(10px);
                border-radius: 12px;
                padding: 8px 0;
                margin-top: 10px;
                opacity: 0;
                visibility: hidden;
                transform: translateY(10px);
                transition: all 0.3s ease;
                box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
            }

            #trans-menu.show {
                opacity: 1;
                visibility: visible;
                transform: translateY(0);
            }

            .lang-item {
                padding: 12px 16px;
                font-size: 14px;
                color: #333;
                cursor: pointer;
                transition: all 0.2s;
            }

            .lang-item:hover {
                background: rgba(29, 161, 242, 0.1);
            }

            .lang-item[data-lang="${config.targetLang}"] {
                color: #1da1f2;
                font-weight: 500;
            }

            .loading-spinner {
                width: 16px;
                height: 16px;
                border: 2px solid #ddd;
                border-top-color: #1da1f2;
                border-radius: 50%;
                animation: spin 1s linear infinite;
            }

            @keyframes spin {
                to { transform: rotate(360deg); }
            }

            @media (prefers-color-scheme: dark) {
                #trans-menu {
                    background: rgba(21, 32, 43, 0.9);
                }
                .lang-item {
                    color: #fff;
                }
            }
        `;
        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');
        });

        menu.querySelectorAll('.lang-item').forEach(item => {
            item.addEventListener('click', function() {
                config.targetLang = this.dataset.lang;
                refreshAllTranslations();
                menu.classList.remove('show');
            });
        });

        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);
        });
    }

    // 队列处理系统
    async function processQueue() {
        if (isTranslating || requestQueue.length === 0) return;
        isTranslating = true;

        while (requestQueue.length > 0) {
            const { tweet, text, retryCount } = requestQueue.shift();
            try {
                const translated = await translateWithEmoji(text);
                updateTranslation(tweet, translated);
                await delay(config.translationInterval);
            } catch (error) {
                if (retryCount < config.maxRetry) {
                    requestQueue.push({ tweet, text, retryCount: retryCount + 1 });
                } else {
                    markTranslationFailed(tweet);
                }
            }
        }

        isTranslating = false;
    }

    // 精准文本提取
    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')
            .replace(/<\/div><div/g, '\n</div><div');

        clone.querySelectorAll('span, div').forEach(el => {
            const style = window.getComputedStyle(el);
            if (['block', 'flex'].includes(style.display)) {
                el.after(document.createTextNode('\n'));
            }
        });

        return clone.textContent
            .replace(/\u00A0/g, ' ')
            .replace(/^[\s\u200B]+|[\s\u200B]+$/g, '')
            .replace(/(\S)[ \t]+\n/g, '$1\n')
            .replace(/[ \t]{2,}/g, ' ')
            .replace(/(\n){3,}/g, '\n\n')
            .trim();
    }

    // Emoji感知翻译
    async function translateWithEmoji(text) {
        const segments = [];
        let lastIndex = 0;
        const emojiRegex = /(\p{Extended_Pictographic}|\p{Emoji_Component}+)/gu;

        for (const match of text.matchAll(emojiRegex)) {
            const [emoji] = match;
            const index = match.index;
            if (index > lastIndex) {
                segments.push({ type: 'text', content: text.slice(lastIndex, index) });
            }
            segments.push({ type: 'emoji', content: emoji });
            lastIndex = index + emoji.length;
        }

        if (lastIndex < text.length) {
            segments.push({ type: 'text', content: text.slice(lastIndex) });
        }

        const translated = [];
        for (const seg of segments) {
            if (seg.type === 'emoji') {
                translated.push(seg.content);
            } else {
                const text = seg.content.trim();
                if (text) {
                    translated.push(await translateText(text));
                    await delay(config.translationInterval);
                }
            }
        }
        return translated.join(' ');
    }

    // 核心翻译功能
    function translateText(text, retry = 0) {
        return new Promise((resolve, reject) => {
            if (retry > config.maxRetry) return resolve(text);

            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);
                        resolve(data[0].map(i => i[0]).join('').trim());
                    } catch {
                        translateText(text, retry + 1).then(resolve);
                    }
                },
                onerror: () => {
                    translateText(text, retry + 1).then(resolve);
                }
            });
        });
    }

    // 推文处理流程
    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);

        requestQueue.push({
            tweet,
            text: originalText,
            retryCount: 0
        });

        processQueue();
    }

    // 创建翻译容器
    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 updateTranslation(tweet, translated) {
        const container = tweet.nextElementSibling;
        if (container?.classList.contains('translation-container')) {
            container.innerHTML = translated.split('\n').join('<br>');
            processingQueue.delete(tweet);
        }
    }

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

    // 动态内容监听
    function setupMutationObserver() {
        const observer = new MutationObserver(mutations => {
            mutations.forEach(mutation => {
                if (mutation.type === 'childList') {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType === 1) {
                            node.querySelectorAll(config.tweetSelector).forEach(processTweet);
                        }
                    });
                }
                else if (mutation.type === 'characterData') {
                    const tweet = mutation.target.closest(config.tweetSelector);
                    if (tweet) processTweet(tweet);
                }
            });
        });

        observer.observe(document, {
            childList: true,
            subtree: true,
            characterData: true
        });
    }

    // 工具函数
    const delay = ms => new Promise(r => setTimeout(r, ms));

    // 初始化入口
    function init() {
        initControlPanel();
        setupMutationObserver();
        document.querySelectorAll(config.tweetSelector).forEach(processTweet);
    }

    // 启动脚本
    window.addEventListener('load', init);
    if (document.readyState === 'complete') init();
})();