入り浸り雑談slack

昼間に雑談をするためだけの雑談slackのことや、私の思うことなどを書きます。

ChatGPTやGeminiの強調表示が*そのまま*になる問題を防ぐTampermonkeyスクリプトを作った

トップ画像 どうもみなさん、サメジ部長です。

ChatGPTやGeminiを日常的に使っていると、AIがMarkdown形式で出力したテキストの強調表示が、うまくHTMLに変換されずに **こんな感じ***こんな感じ*アスタリスクがそのまま表示されてしまうこと、ありませんか? そして、そのままスタイルミスをコピペしているクソブログも見たことがありませんか?

例えばこういうような。

Geminiで起きた強調表示ミス
Geminiで起きた強調表示ミス

今回は、この地味ながらも気になる問題を解決するために私とAIで作ったTampermonkeyスクリプトを紹介します。導入するとこのように修正できますよ。

対応スクリプトによる適用後
対応スクリプトによる対応後

なぜこの問題が起きるのか?

この記事はほぼGeminiに書かせています。Geminiが言うには、以下のような理由で現象が起こるようです。

ChatGPTやGeminiのようなモダンなWebアプリケーションは、ページ全体を再読み込みすることなく、動的にコンテンツを生成・更新します。AIからの返信も、リアルタイムでページに挿入されていきます。

この時、Markdownの記法(***)をリッチなHTML(<strong><em>)に変換する処理が追いつかなかったり、特定の条件下でうまく機能しなかったりすることが原因で、記号がそのまま表示されてしまう現象が起きます。

と、いうことでTampermonkeyというブラウザにJavaScriptベースのカスタムスクリプト実行プラットフォームを使って、AIのスタイルミスを修正するスクリプトを書いてみましょう。

導入方法

  1. お使いのブラウザに Tampermonkey 拡張機能をインストールします。
  2. Tampermonkeyの管理画面を開き、「新規スクリプトを追加」をクリックします。
    Tempermonkey設定画面
    Tempermonkey設定画面
  3. 表示されたエディタに、コードをすべて貼り付けます。
  4. ファイルメニューから保存をクリックします。
    ファイルメニューから保存を選択してください。
    ファイルメニュー

これで設定は完了しました。ChatGPTやGeminiのページを開けば、スクリプトが自動で有効になり、強調表示が正しくレンダリングされるようになります。

ブラウザのプラグインアイコンがこんな風になれば動作しています。

Tempermonkeyアイコン
Tempermonkeyアイコン

完成したTampermonkeyスクリプト

以下、ChatGPTとGeminiに対応したスタイル修正スクリプトです。

// ==UserScript==
// @name         Enhanced Markdown Bold/Italic Fixer
// @namespace    http://tampermonkey.net/
// @version      7.0
// @description  Convert **bold** and *italic* markdown to HTML tags on ChatGPT and Gemini with infinite loop prevention
// @author       Merged Enhanced Version
// @match        https://chatgpt.com/*
// @match        https://gemini.google.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(() => {
  "use strict";

  // Configuration
  const SKIP_ELEMENTS = "pre, code, kbd, samp, textarea, script, style";
  const PROCESSED_MARK = 'data-markdown-fixer-processed';
  const DEBUG = false;

  // Enhanced regex patterns from chatgpt.js
  const RX_BOLD = /\*\*([\s\S]+?)\*\*/g;
  const RX_ITALIC = /(^|[^*])\*([^*\s][\s\S]*?)\*(?=[^*]|$)/g;

  // Safe HTML conversion with loop prevention
  const htmlConvert = (html) => {
    return html
      .replace(RX_BOLD, '<strong class="em-fix-bold">$1</strong>')
      .replace(RX_ITALIC, (_, p1, p2) => p1 + '<em class="em-fix-italic">' + p2 + "</em>");
  };

  // Fragment-based safe text conversion (from gemini.js approach)
  const convertTextNodeSafely = (node) => {
    if (!node.parentElement ||
        node.parentElement.closest(SKIP_ELEMENTS) ||
        node.parentElement.hasAttribute(PROCESSED_MARK)) {
      return;
    }

    const text = node.textContent;
    if (!text.includes('*')) return;

    const parent = node.parentElement;
    if (!parent) return;

    const fragment = document.createDocumentFragment();
    let lastIndex = 0;
    let hasChanges = false;

    // Process bold patterns
    let workingText = text;
    const boldMatches = [...workingText.matchAll(RX_BOLD)];

    if (boldMatches.length > 0) {
      hasChanges = true;
      lastIndex = 0;

      boldMatches.forEach(match => {
        // Add text before match
        if (match.index > lastIndex) {
          fragment.appendChild(document.createTextNode(workingText.slice(lastIndex, match.index)));
        }

        // Add bold element
        const strong = document.createElement('strong');
        strong.className = 'em-fix-bold';
        strong.textContent = match[1];
        fragment.appendChild(strong);

        lastIndex = match.index + match[0].length;
      });

      // Add remaining text
      if (lastIndex < workingText.length) {
        fragment.appendChild(document.createTextNode(workingText.slice(lastIndex)));
      }

      // Update working text for italic processing
      workingText = fragment.textContent;
    }

    // Process italic patterns on remaining text
    if (workingText.includes('*')) {
      const italicMatches = [...workingText.matchAll(RX_ITALIC)];

      if (italicMatches.length > 0) {
        hasChanges = true;
        const newFragment = document.createDocumentFragment();
        lastIndex = 0;

        italicMatches.forEach(match => {
          // Add text before match
          if (match.index > lastIndex) {
            newFragment.appendChild(document.createTextNode(workingText.slice(lastIndex, match.index)));
          }

          // Add prefix if exists
          if (match[1]) {
            newFragment.appendChild(document.createTextNode(match[1]));
          }

          // Add italic element
          const em = document.createElement('em');
          em.className = 'em-fix-italic';
          em.textContent = match[2];
          newFragment.appendChild(em);

          lastIndex = match.index + match[0].length;
        });

        // Add remaining text
        if (lastIndex < workingText.length) {
          newFragment.appendChild(document.createTextNode(workingText.slice(lastIndex)));
        }

        // Replace fragment if we processed italics
        if (boldMatches.length > 0) {
          fragment.replaceChildren(...newFragment.childNodes);
        } else {
          fragment.appendChild(newFragment);
        }
      }
    }

    // Apply changes if any were made
    if (hasChanges) {
      parent.replaceChild(fragment, node);
      parent.setAttribute(PROCESSED_MARK, 'true');
    }
  };

  // Element-level conversion for cross-boundary cases
  const patchElement = (el) => {
    if (el.closest(SKIP_ELEMENTS) ||
        !el.textContent.includes("*") ||
        el.querySelector(SKIP_ELEMENTS) ||
        el.hasAttribute(PROCESSED_MARK)) {
      return;
    }

    const originalHTML = el.innerHTML;
    const convertedHTML = htmlConvert(originalHTML);

    if (convertedHTML !== originalHTML) {
      el.innerHTML = convertedHTML;
      el.setAttribute(PROCESSED_MARK, 'true');
    }
  };

  // Enhanced tree walker with both approaches
  const processNode = (root) => {
    // First pass: individual text nodes (safer)
    const textWalker = document.createTreeWalker(
      root,
      NodeFilter.SHOW_TEXT,
      {
        acceptNode: node =>
          node.parentElement && !node.parentElement.closest(SKIP_ELEMENTS)
            ? NodeFilter.FILTER_ACCEPT
            : NodeFilter.FILTER_REJECT
      }
    );

    const textNodes = [];
    for (let node = textWalker.nextNode(); node; node = textWalker.nextNode()) {
      textNodes.push(node);
    }

    textNodes.forEach(convertTextNodeSafely);

    // Second pass: element-level for cross-boundary cases
    const elements = root.querySelectorAll("*");
    elements.forEach(patchElement);
  };

  // Initial processing
  if (document.body) {
    processNode(document.body);
  }

  // Enhanced mutation observer with Shadow DOM support
  const createObserver = (targetNode) => {
    return new MutationObserver(mutations => {
      mutations.forEach(mutation => {
        mutation.addedNodes.forEach(addedNode => {
          if (addedNode.nodeType === Node.TEXT_NODE) {
            convertTextNodeSafely(addedNode);
          } else if (addedNode.nodeType === Node.ELEMENT_NODE) {
            processNode(addedNode);
          }
        });
      });
    });
  };

  // Observe main document
  const mainObserver = createObserver(document);
  mainObserver.observe(document.body || document.documentElement, {
    childList: true,
    subtree: true
  });

  // Shadow DOM support for Gemini
  if (document.documentElement.shadowRoot) {
    const shadowObserver = createObserver(document.documentElement.shadowRoot);
    shadowObserver.observe(document.documentElement.shadowRoot, {
      childList: true,
      subtree: true
    });
  }

  // Debug styling
  if (DEBUG) {
    const style = document.createElement("style");
    style.textContent = `
      strong.em-fix-bold {
        background: rgba(255,255,0,0.25);
        font-weight: 700;
        border-radius: 2px;
        padding: 1px 2px;
      }
      em.em-fix-italic {
        background: rgba(0,255,255,0.25);
        font-style: italic;
        border-radius: 2px;
        padding: 1px 2px;
      }
    `;
    document.head.appendChild(style);
  }

  // Cleanup function for potential memory leaks
  window.addEventListener('beforeunload', () => {
    if (mainObserver) mainObserver.disconnect();
    if (shadowObserver) shadowObserver.disconnect();
  });

})();

スクリプトの主な機能

  • 高精度な正規表現: **bold***italic* を的確に捉えつつ、意図しない変換を防ぎます。
  • 処理除外: コードブロック (pre, codeなど) は変換対象から除外します。
  • 無限ループ防止: 一度処理した要素には data-markdown-fixer-processed 属性を付与し、再処理を防ぎます。
  • 安全なDOM操作: TreeWalkerDocumentFragment を活用し、ページの構造を壊すことなく安全に置換を実行します。
  • 動的コンテンツ対応: MutationObserver により、後から生成されたAIの応答にもリアルタイムで対応します。
  • Shadow DOM対応: GeminiのUIにも対応済みです。
  • デバッグモード: DEBUGフラグを true にすると、変換した箇所がハイライトされ、動作確認が容易になります。

まとめ

かゆいところに手が届くと思い、この記事を作成しました。

このスクリプトが、皆さんの普段のAIライフとクソブログのコピペワークフローの簡易化に少しでも快適にする一助となれば幸いです。