X Translate

Replaces Grok AI translations on X/Twitter with Google Translate. Supports 70+ languages. Adds translate button directly in the feed.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         X Translate
// @namespace    https://x.com
// @version      1.4
// @description  Replaces Grok AI translations on X/Twitter with Google Translate. Supports 70+ languages. Adds translate button directly in the feed.
// @match        https://x.com/*
// @match        https://twitter.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @connect      translate.googleapis.com
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const browserLang = navigator.language.split("-")[0];

  function getTargetLang() {
    return GM_getValue("targetLang", browserLang);
  }

  const LANGUAGES = [
    ["af","Afrikaans"],["sq","Albanian"],["am","Amharic"],["ar","Arabic"],
    ["hy","Armenian"],["az","Azerbaijani"],["eu","Basque"],["be","Belarusian"],
    ["bn","Bengali"],["bs","Bosnian"],["bg","Bulgarian"],["my","Burmese"],
    ["ca","Catalan"],["zh","Chinese"],["hr","Croatian"],["cs","Czech"],
    ["da","Danish"],["nl","Dutch"],["en","English"],["et","Estonian"],
    ["fil","Filipino"],["fi","Finnish"],["fr","French"],["fy","Frisian"],
    ["gl","Galician"],["ka","Georgian"],["de","German"],["el","Greek"],
    ["gn","Guarani"],["gu","Gujarati"],["he","Hebrew"],["hi","Hindi"],
    ["hu","Hungarian"],["is","Icelandic"],["id","Indonesian"],["ga","Irish"],
    ["it","Italian"],["ja","Japanese"],["kn","Kannada"],["kk","Kazakh"],
    ["km","Khmer"],["ko","Korean"],["ky","Kyrgyz"],["lo","Lao"],
    ["lv","Latvian"],["ln","Lingala"],["lt","Lithuanian"],["mk","Macedonian"],
    ["ms","Malay"],["ml","Malayalam"],["mt","Maltese"],["mr","Marathi"],
    ["mn","Mongolian"],["ne","Nepali"],["no","Norwegian"],["nb","Norwegian Bokmål"],
    ["fa","Persian"],["pl","Polish"],["pt","Portuguese"],["pa","Punjabi"],
    ["ro","Romanian"],["ru","Russian"],["sr","Serbian"],["sk","Slovak"],
    ["sl","Slovenian"],["es","Spanish"],["sw","Swahili"],["sv","Swedish"],
    ["ta","Tamil"],["te","Telugu"],["th","Thai"],["tr","Turkish"],
    ["uk","Ukrainian"],["ur","Urdu"],["uz","Uzbek"],["vi","Vietnamese"],
    ["cy","Welsh"],["zu","Zulu"],
  ];

  function showLanguagePicker() {
    document.getElementById("gt-lang-picker")?.remove();

    const current = getTargetLang();

    const overlay = document.createElement("div");
    overlay.id = "gt-lang-picker";
    overlay.style.cssText =
      "position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:999999;" +
      "display:flex;align-items:center;justify-content:center;";

    const dialog = document.createElement("div");
    dialog.style.cssText =
      "background:#fff;border-radius:16px;padding:24px;width:340px;max-height:80vh;" +
      "display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,sans-serif;" +
      "color:#0f1419;box-shadow:0 8px 28px rgba(0,0,0,0.25);";

    const title = document.createElement("div");
    title.textContent = "Translation Language";
    title.style.cssText = "font-size:18px;font-weight:700;margin-bottom:16px;";

    const search = document.createElement("input");
    search.type = "text";
    search.placeholder = "Search languages...";
    search.style.cssText =
      "width:100%;padding:10px 12px;border:1px solid #cfd9de;border-radius:8px;" +
      "font-size:14px;outline:none;box-sizing:border-box;margin-bottom:12px;";

    const list = document.createElement("div");
    list.style.cssText = "overflow-y:auto;flex:1;max-height:50vh;";

    function renderList(filter = "") {
      list.innerHTML = "";
      const lf = filter.toLowerCase();
      for (const [code, name] of LANGUAGES) {
        if (lf && !name.toLowerCase().includes(lf) && !code.includes(lf)) continue;

        const item = document.createElement("div");
        item.textContent = `${name} (${code})`;
        item.style.cssText =
          "padding:10px 12px;border-radius:8px;cursor:pointer;font-size:14px;" +
          (code === current ? "background:#e8f5fd;font-weight:600;color:#1d9bf0;" : "");
        item.addEventListener("mouseenter", () => {
          if (code !== current) item.style.background = "#f7f9f9";
        });
        item.addEventListener("mouseleave", () => {
          if (code !== current) item.style.background = "";
        });
        item.addEventListener("click", () => {
          GM_setValue("targetLang", code);
          overlay.remove();
          location.reload();
        });
        list.appendChild(item);
      }
    }

    search.addEventListener("input", () => renderList(search.value));
    overlay.addEventListener("click", (e) => { if (e.target === overlay) overlay.remove(); });

    dialog.append(title, search, list);

    const resetBtn = document.createElement("button");
    resetBtn.textContent = `Reset to browser default (${browserLang})`;
    resetBtn.style.cssText =
      "margin-top:12px;padding:8px;border:1px solid #cfd9de;border-radius:8px;" +
      "background:none;cursor:pointer;font-size:13px;color:#536471;width:100%;";
    resetBtn.addEventListener("click", () => {
      GM_setValue("targetLang", browserLang);
      overlay.remove();
      location.reload();
    });
    dialog.appendChild(resetBtn);

    overlay.appendChild(dialog);
    document.body.appendChild(overlay);
    search.focus();
    renderList();
  }

  GM_registerMenuCommand("Set translation language", showLanguagePicker);

  function googleTranslate(text) {
    return new Promise((resolve, reject) => {
      const url =
        "https://translate.googleapis.com/translate_a/single?" +
        new URLSearchParams({
          client: "gtx",
          sl: "auto",
          tl: getTargetLang(),
          dt: "t",
          q: text,
        });

      GM_xmlhttpRequest({
        method: "GET",
        url,
        onload(res) {
          try {
            const data = JSON.parse(res.responseText);
            const translated = data[0].map((s) => s[0]).join("");
            const detectedLang = data[2];
            resolve({ text: translated, sourceLang: detectedLang });
          } catch (e) {
            reject(e);
          }
        },
        onerror: reject,
      });
    });
  }

  function getTweetText(button) {
    let node = button.parentElement;
    while (node) {
      const textEl = node.querySelector('[data-testid="tweetText"], [data-testid="UserDescription"]');
      if (textEl) return { element: textEl, text: textEl.innerText };
      node = node.parentElement;
    }
    return null;
  }

  const GT_PATH = "M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0 0 14.07 6H17V4h-7V2H8v2H1v2h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z";

  function replaceIcon(btn) {
    const parent = btn.parentElement;
    const svg = parent?.querySelector("svg");
    if (!svg) return;

    svg.setAttribute("viewBox", "0 0 24 24");
    svg.innerHTML = `<path d="${GT_PATH}" fill="currentColor"/>`;
    svg.dataset.gtReplaced = "true";
  }

  function escapeHtml(s) {
    return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
  }

  function buildTranslationSource(root) {
    const placeholders = [];
    let text = "";
    const walk = (node) => {
      for (const child of node.childNodes) {
        if (child.nodeType === Node.TEXT_NODE) {
          text += child.textContent;
        } else if (child.nodeType === Node.ELEMENT_NODE) {
          const tag = child.tagName;
          if (tag === "A" || tag === "IMG") {
            const idx = placeholders.length;
            placeholders.push(child.outerHTML);
            text += `\uE000${idx}\uE001`;
          } else if (tag === "BR") {
            text += "\n";
          } else {
            walk(child);
          }
        }
      }
    };
    walk(root);
    return { text, placeholders };
  }

  async function doTranslate(tweetTextEl, btnSpan) {
    if (tweetTextEl.dataset.gtTranslated === "true") {
      tweetTextEl.innerHTML = tweetTextEl.dataset.gtOriginalHtml;
      tweetTextEl.dataset.gtTranslated = "";
      if (btnSpan) btnSpan.textContent = "Show translation";
      return;
    }

    const origBtnText = btnSpan?.textContent;
    if (btnSpan) btnSpan.textContent = "Translating...";

    try {
      const { text: source, placeholders } = buildTranslationSource(tweetTextEl);
      const result = await googleTranslate(source);
      const html = escapeHtml(result.text)
        .replace(/\n/g, "<br>")
        .replace(/\uE000\s*(\d+)\s*\uE001/g, (m, i) => placeholders[+i] ?? m);
      tweetTextEl.dataset.gtOriginalHtml = tweetTextEl.innerHTML;
      tweetTextEl.dataset.gtTranslated = "true";
      tweetTextEl.innerHTML = html;
      if (btnSpan) btnSpan.textContent = "Hide translation";
    } catch (err) {
      console.error("Google Translate error:", err);
      if (btnSpan) btnSpan.textContent = origBtnText || "Show translation";
    }
  }

  function handleTranslateButton(btn) {
    if (btn.dataset.googleTranslate === "patched") return;
    btn.dataset.googleTranslate = "patched";

    replaceIcon(btn);

    btn.addEventListener(
      "click",
      async (e) => {
        e.stopImmediatePropagation();
        e.preventDefault();
        e.stopPropagation();

        const tweet = getTweetText(btn);
        if (!tweet) return;
        doTranslate(tweet.element, btn.querySelector("span"));
      },
      true
    );
  }

  function injectFeedButtons() {
    const targetLang = getTargetLang();
    const tweetTexts = document.querySelectorAll('[data-testid="tweetText"]');

    for (const el of tweetTexts) {
      if (el.dataset.gtFeedPatched) continue;
      el.dataset.gtFeedPatched = "true";

      const lang = el.getAttribute("lang");
      if (!lang || lang === targetLang || lang === "und") continue;

      const article = el.closest("article");
      if (!article) continue;
      const existingBtn = article.querySelector('[data-google-translate="patched"]');
      if (existingBtn) continue;

      const btn = document.createElement("div");
      btn.style.cssText =
        "display:flex;align-items:center;gap:4px;cursor:pointer;padding:4px 0;" +
        "color:rgb(29,155,240);font-size:13px;margin-top:4px;width:fit-content;align-self:flex-start;";
      btn.innerHTML =
        `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" ` +
        `width="1.1em" height="1.1em"><path d="${GT_PATH}"/></svg>` +
        `<span>Show translation</span>`;

      btn.addEventListener("click", (e) => {
        e.stopPropagation();
        e.preventDefault();
        doTranslate(el, btn.querySelector("span"));
      });

      el.parentElement.insertBefore(btn, el);
    }
  }

  function findAndPatchButtons() {
    const allSpans = document.querySelectorAll("span");
    for (const span of allSpans) {
      if (span.textContent.trim() === "Show translation" || span.textContent.trim() === "Hide translation") {
        const btn = span.closest('[role="button"]') || span.closest("a") || span.parentElement;
        if (btn) handleTranslateButton(btn);
      }
    }
  }

  function patchAll() {
    findAndPatchButtons();
    injectFeedButtons();
  }

  const observer = new MutationObserver(() => patchAll());
  observer.observe(document.body, { childList: true, subtree: true });

  patchAll();
})();