X.com Gemini Translator

Supercharge your X.com experience by replacing the default translator with the advanced Google Gemini AI. Get more accurate translations and a button that appears on *every* tweet.

As of 02.11.2025. See апошняя версія.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         X.com Gemini Translator
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Supercharge your X.com experience by replacing the default translator with the advanced Google Gemini AI. Get more accurate translations and a button that appears on *every* tweet.
// @author       ospx
// @license MIT
// @match        https://x.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @connect      localhost
// @connect      generativelanguage.googleapis.com
// ==/UserScript==

(function() {
    'use strict';

    const C = {
        SETTINGS_ICON_CLASS: 'gemini-settings-icon',
        BUTTON_ID: 'gemini-translate-button',
        TEXT_PROCESSED_ATTR: 'data-gemini-text-hooked',
        MODAL_ID: 'gemini-settings-modal',
        BACKDROP_ID: 'gemini-settings-backdrop',
        TWEET_TEXT_SELECTOR: '[data-testid="tweetText"]',
        TOAST_ID: 'gemini-toast'
    };

    const L10N = {
        ru: {
            settings_title: 'Настройки Gemini Переводчика',
            api_key_label: 'API Key',
            api_host_label: 'API Host',
            model_label: 'Модель',
            target_lang_label: 'Язык перевода',
            interface_lang_label: 'Язык интерфейса',
            system_prompt_label: 'Системный промт',
            save_button: 'Сохранить',
            close_button: 'Закрыть',
            settings_saved: 'Настройки сохранены!',
            translate_button: 'Перевести пост',
            show_original_button: 'Показать оригинал',
            loading_text: 'Перевод...',
            error_network: 'Ошибка сети при обращении к API',
            error_api_response: 'Ошибка ответа Gemini API',
            settings_tooltip: 'Настройки переводчика'
        },
        en: {
            settings_title: 'Gemini Translator Settings',
            api_key_label: 'API Key',
            api_host_label: 'API Host',
            model_label: 'Model',
            target_lang_label: 'Target Language',
            interface_lang_label: 'Interface Language',
            system_prompt_label: 'System Prompt',
            save_button: 'Save',
            close_button: 'Close',
            settings_saved: 'Settings saved!',
            translate_button: 'Translate post',
            show_original_button: 'Show original',
            loading_text: 'Translating...',
            error_network: 'Network error while accessing API',
            error_api_response: 'Gemini API response error',
            settings_tooltip: 'Translator settings'
        },
        uk: {
            settings_title: 'Налаштування Gemini Перекладача',
            api_key_label: 'API Key',
            api_host_label: 'API Host',
            model_label: 'Модель',
            target_lang_label: 'Мова перекладу',
            interface_lang_label: 'Мова інтерфейсу',
            system_prompt_label: 'Системний промт',
            save_button: 'Зберегти',
            close_button: 'Закрити',
            settings_saved: 'Налаштування збережено!',
            translate_button: 'Перекласти пост',
            show_original_button: 'Показати оригінал',
            loading_text: 'Переклад...',
            error_network: 'Помилка мережі при зверненні до API',
            error_api_response: 'Помилка відповіді Gemini API',
            settings_tooltip: 'Налаштування перекладача'
        }
    };

    function t(key) {
        const lang = ConfigManager.get().interfaceLanguage || 'ru';
        return L10N[lang]?.[key] || L10N['en']?.[key] || `[${key}]`;
    }


    const ConfigManager = {
        defaults: {
            apiHost: 'https://generativelanguage.googleapis.com',
            apiKey: '',
            targetLanguage: 'English',
            interfaceLanguage: 'en',
            model: 'gemini-2.5-flash',
            systemPrompt: `You are an expert translator. Your sole task is to accurately translate the provided text while strictly preserving all formatting details.

Rules:
1. **Mirror formatting preservation:** Always maintain special characters (>, *, -, #, quotes), line breaks, and paragraphs. The translation structure must completely mirror the original.
2. **Translation only:** Never add any comments, explanations, or introductory phrases.
3. **Clean output:** Return only the translated text.`
        },

        config: {},

        load() {
            const stored = GM_getValue('geminiTranslatorSettings', null);
            this.config = stored ? { ...this.defaults, ...JSON.parse(stored) } : { ...this.defaults };

            // Автоопределение языка браузера при первом запуске
            if (!stored) {
                const browserLang = navigator.language.split('-')[0];
                if (L10N[browserLang]) {
                    this.config.interfaceLanguage = browserLang;
                }
            }
        },

        save(newConfig) {
            this.config = { ...this.config, ...newConfig };
            GM_setValue('geminiTranslatorSettings', JSON.stringify(this.config));
        },

        get() {
            return this.config;
        }
    };


    const UIManager = {
        createModal() {
            const oldModal = document.getElementById(C.MODAL_ID);
            if (oldModal) oldModal.remove();

            const modalHTML = `
                <div id="${C.BACKDROP_ID}"></div>
                <div id="gemini-settings-container" style='font-family: "TwitterChirp", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;'>
                    <div id="gemini-modal-header">
                        <button id="gemini-modal-close-btn" aria-label="${t('close_button')}">✕</button>
                        <h2>${t('settings_title')}</h2>
                    </div>
                    <div id="gemini-modal-body">
                        <div class="gt-setting">
                            <label for="gt-api-key">${t('api_key_label')}</label>
                            <input type="text" id="gt-api-key">
                        </div>
                        <div class="gt-setting">
                            <label for="gt-api-host">${t('api_host_label')}</label>
                            <input type="text" id="gt-api-host">
                        </div>
                        <div class="gt-setting">
                            <label for="gt-model">${t('model_label')}</label>
                            <input type="text" id="gt-model">
                        </div>
                        <div class="gt-setting">
                            <label for="gt-target-lang">${t('target_lang_label')}</label>
                            <input type="text" id="gt-target-lang">
                        </div>
                        <div class="gt-setting">
                            <label for="gt-interface-lang">${t('interface_lang_label')}</label>
                            <select id="gt-interface-lang">
                                <option value="ru">Русский</option>
                                <option value="en">English</option>
                                <option value="uk">Українська</option>
                            </select>
                        </div>
                        <div class="gt-setting">
                            <label for="gt-system-prompt">${t('system_prompt_label')}</label>
                            <textarea id="gt-system-prompt" rows="10"></textarea>
                        </div>
                    </div>
                    <div id="gemini-modal-footer">
                        <button id="gt-save-btn">${t('save_button')}</button>
                    </div>
                </div>
            `;

            const modalWrapper = document.createElement('div');
            modalWrapper.id = C.MODAL_ID;
            modalWrapper.innerHTML = modalHTML;
            document.body.appendChild(modalWrapper);

            this.injectStyles();
            this.attachModalHandlers();
        },

        injectStyles() {
            const style = document.createElement('style');
            style.textContent = `
                #${C.BACKDROP_ID} {
                    position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
                    background-color: rgba(0, 0, 0, 0.65); z-index: 9998;
                }
                #gemini-settings-container {
                    display: flex; flex-direction: column; position: fixed;
                    top: 50%; left: 50%; transform: translate(-50%, -50%);
                    background-color: #000000; border-radius: 16px;
                    width: 90vw; max-width: 600px; height: 90vh; max-height: 650px;
                    z-index: 9999;
                    box-shadow: rgb(22 24 28 / 50%) 0px 0px 15px, rgb(22 24 28 / 50%) 0px 0px 3px 1px;
                }
                #gemini-modal-header {
                    display: flex; align-items: center; padding: 12px 16px;
                    border-bottom: 1px solid rgb(56, 68, 77); flex-shrink: 0;
                }
                #gemini-modal-header h2 {
                    font-size: 20px; font-weight: bold; margin: 0; margin-left: 20px;
                    color: rgb(231, 233, 234);
                }
                #gemini-modal-close-btn {
                    background: none; border: none; cursor: pointer; font-size: 20px;
                    color: rgb(231, 233, 234); padding: 8px; border-radius: 9999px;
                    display: flex; align-items: center; justify-content: center;
                }
                #gemini-modal-close-btn:hover { background-color: rgba(239, 243, 244, 0.1); }
                #gemini-modal-body { padding: 16px; overflow-y: auto; }
                .gt-setting { position: relative; margin-bottom: 24px; }
                .gt-setting label {
                    position: absolute; top: 12px; left: 12px;
                    font-size: 13px; color: rgb(113, 118, 123);
                }
                .gt-setting input, .gt-setting textarea, .gt-setting select {
                    width: calc(100% - 24px); padding: 12px; padding-top: 32px;
                    background-color: transparent; border: 1px solid rgb(56, 68, 77);
                    color: rgb(231, 233, 234); border-radius: 4px; font-size: 17px;
                    transition: all 0.2s ease-in-out;
                }
                .gt-setting textarea { padding-top: 32px; }
                .gt-setting input:focus, .gt-setting textarea:focus, .gt-setting select:focus {
                    border-color: rgb(29, 155, 240);
                    box-shadow: 0 0 0 1px rgb(29, 155, 240);
                    background-color: black; outline: none;
                }
                #gemini-modal-footer {
                    padding: 16px; text-align: right;
                    border-top: 1px solid rgb(56, 68, 77); flex-shrink: 0;
                }
                #gt-save-btn {
                    background-color: rgb(239, 243, 244); color: rgb(15, 20, 25);
                    padding: 10px 24px; border: none; border-radius: 9999px;
                    cursor: pointer; font-size: 15px; font-weight: bold;
                    transition: background-color 0.2s;
                }
                #gt-save-btn:hover { background-color: rgb(215, 219, 220); }

                #${C.TOAST_ID} {
                    position: fixed; bottom: 20px; right: 20px; z-index: 10000;
                    background-color: rgb(29, 155, 240); color: white;
                    padding: 12px 24px; border-radius: 8px;
                    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
                    font-family: "TwitterChirp", sans-serif;
                    font-size: 15px; opacity: 0; transition: opacity 0.3s;
                }
                #${C.TOAST_ID}.show { opacity: 1; }
                #${C.TOAST_ID}.error { background-color: rgb(244, 33, 46); }
            `;
            document.getElementById(C.MODAL_ID).appendChild(style);
        },

        attachModalHandlers() {
            const closeModal = () => {
                document.getElementById(C.MODAL_ID).style.display = 'none';
            };

            document.getElementById('gemini-modal-close-btn').onclick = closeModal;
            document.getElementById(C.BACKDROP_ID).onclick = closeModal;
            document.getElementById('gt-save-btn').onclick = () => {
                const newConfig = {
                    apiHost: document.getElementById('gt-api-host').value,
                    apiKey: document.getElementById('gt-api-key').value,
                    model: document.getElementById('gt-model').value,
                    targetLanguage: document.getElementById('gt-target-lang').value,
                    interfaceLanguage: document.getElementById('gt-interface-lang').value,
                    systemPrompt: document.getElementById('gt-system-prompt').value
                };
                ConfigManager.save(newConfig);
                this.showToast(t('settings_saved'));
                closeModal();
            };
        },

        openModal() {
            const modal = document.getElementById(C.MODAL_ID);
            if (!modal) {
                this.createModal();
            }

            const config = ConfigManager.get();
            document.getElementById('gt-api-host').value = config.apiHost;
            document.getElementById('gt-api-key').value = config.apiKey;
            document.getElementById('gt-model').value = config.model;
            document.getElementById('gt-target-lang').value = config.targetLanguage;
            document.getElementById('gt-interface-lang').value = config.interfaceLanguage;
            document.getElementById('gt-system-prompt').value = config.systemPrompt;

            document.getElementById(C.MODAL_ID).style.display = 'block';
        },

        showToast(message, isError = false) {
            let toast = document.getElementById(C.TOAST_ID);
            if (!toast) {
                toast = document.createElement('div');
                toast.id = C.TOAST_ID;
                document.body.appendChild(toast);
            }

            toast.textContent = message;
            toast.className = isError ? 'error' : '';

            setTimeout(() => toast.classList.add('show'), 10);
            setTimeout(() => {
                toast.classList.remove('show');
            }, 3000);
        },

        createTranslateButton() {
            const container = document.createElement('div');
            container.style.cssText = 'display: flex; align-items: center; cursor: pointer; margin-top: 4px;';

            const icon = document.createElement('span');
            icon.textContent = '⚙️';
            icon.className = C.SETTINGS_ICON_CLASS;
            icon.title = t('settings_tooltip');
            icon.style.marginRight = '8px';

            const button = document.createElement('button');
            button.id = C.BUTTON_ID;
            button.setAttribute('style',
                'color: rgb(29, 155, 240); background-color: transparent; border: none; ' +
                'padding: 0; cursor: pointer; font-family: "TwitterChirp", -apple-system, ' +
                'BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; ' +
                'font-size: 13px; font-weight: 400; line-height: 16px;'
            );

            const buttonSpan = document.createElement('span');
            buttonSpan.textContent = t('translate_button');
            button.appendChild(buttonSpan);

            container.appendChild(icon);
            container.appendChild(button);

            return container;
        }
    };


    const GeminiAPI = {
        translate(text) {
            return new Promise((resolve, reject) => {
                const config = ConfigManager.get();
                const url = `${config.apiHost}/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`;

                const systemInstruction = {
                    parts: [{ text: config.systemPrompt }]
                };

                const generationConfig = {
                    responseMimeType: "application/json",
                    responseSchema: {
                        type: "OBJECT",
                        properties: {
                            translatedText: { type: "STRING" }
                        }
                    }
                };

                GM_xmlhttpRequest({
                    method: 'POST',
                    url: url,
                    headers: { 'Content-Type': 'application/json' },
                    data: JSON.stringify({
                        system_instruction: systemInstruction,
                        contents: [{ parts: [{ text: text }] }],
                        generationConfig: generationConfig
                    }),
                    onload: (response) => {
                        try {
                            const result = JSON.parse(response.responseText);
                            const structuredJsonString = result.candidates[0].content.parts[0].text;
                            const structuredData = JSON.parse(structuredJsonString);
                            resolve(structuredData.translatedText.trim());
                        } catch (e) {
                            console.error('Gemini API Error:', e, response.responseText);
                            reject(new Error(t('error_api_response')));
                        }
                    },
                    onerror: (error) => {
                        console.error('Network Error:', error);
                        reject(new Error(t('error_network')));
                    }
                });
            });
        }
    };

    const DOMObserver = {
        observer: null,

        init(callback) {
            this.observer = new MutationObserver(mutations => {
                mutations.forEach(mutation => {
                    mutation.addedNodes.forEach(node => {
                        if (node.nodeType !== 1) return;

                        const textElements = node.querySelectorAll(
                            `${C.TWEET_TEXT_SELECTOR}:not([${C.TEXT_PROCESSED_ATTR}])`
                        );

                        textElements.forEach(callback);
                    });
                });
            });

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

    function processNewTweet(textElement) {
        textElement.setAttribute(C.TEXT_PROCESSED_ATTR, 'true');

        const oldButton = textElement.nextElementSibling;
        if (oldButton?.tagName === 'BUTTON') {
            oldButton.remove();
        }

        const buttonContainer = UIManager.createTranslateButton();
        textElement.after(buttonContainer);
    }

    function handleClick(event) {
        const target = event.target;

        if (target.classList.contains(C.SETTINGS_ICON_CLASS)) {
            event.preventDefault();
            event.stopPropagation();
            UIManager.openModal();
            return;
        }

        const button = target.closest(`#${C.BUTTON_ID}`);
        if (!button) return;

        event.preventDefault();
        event.stopPropagation();

        const postElement = button.closest('article');
        const textElement = postElement?.querySelector(C.TWEET_TEXT_SELECTOR);
        const buttonSpan = button.querySelector('span');

        if (!postElement || !textElement || !buttonSpan) return;

        if (!button.dataset.originalText) {
            button.dataset.originalText = buttonSpan.textContent;
        }

        const originalButtonText = button.dataset.originalText;
        const currentButtonText = buttonSpan.textContent;

        if (currentButtonText === t('translate_button') || currentButtonText === originalButtonText) {
            if (!textElement.dataset.originalText) {
                textElement.dataset.originalText = textElement.innerText;
            }

            if (textElement.dataset.translatedText) {
                textElement.innerText = textElement.dataset.translatedText;
                buttonSpan.textContent = t('show_original_button');
                return;
            }

            buttonSpan.textContent = t('loading_text');

            GeminiAPI.translate(textElement.dataset.originalText)
                .then(translatedText => {
                    textElement.dataset.translatedText = translatedText;
                    textElement.innerText = translatedText;
                    buttonSpan.textContent = t('show_original_button');
                })
                .catch(error => {
                    UIManager.showToast(error.message, true);
                    buttonSpan.textContent = originalButtonText;
                });
        }
        else if (currentButtonText === t('show_original_button')) {
            if (textElement.dataset.originalText) {
                textElement.innerText = textElement.dataset.originalText;
                buttonSpan.textContent = t('translate_button');
            }
        }
    }

    function main() {
        ConfigManager.load();
        UIManager.createModal();
        document.getElementById(C.MODAL_ID).style.display = 'none';

        DOMObserver.init(processNewTweet);
        document.body.addEventListener('click', handleClick, true);
    }

    main();

})();