どうもみなさん、サメジ部長です。
ChatGPTやGeminiを日常的に使っていると、AIがMarkdown形式で出力したテキストの強調表示が、うまくHTMLに変換されずに **こんな感じ** や *こんな感じ* でアスタリスクがそのまま表示されてしまうこと、ありませんか? そして、そのままスタイルミスをコピペしているクソブログも見たことがありませんか?
例えばこういうような。
今回は、この地味ながらも気になる問題を解決するために私とAIで作ったTampermonkeyスクリプトを紹介します。導入するとこのように修正できますよ。

なぜこの問題が起きるのか?
この記事はほぼGeminiに書かせています。Geminiが言うには、以下のような理由で現象が起こるようです。
ChatGPTやGeminiのようなモダンなWebアプリケーションは、ページ全体を再読み込みすることなく、動的にコンテンツを生成・更新します。AIからの返信も、リアルタイムでページに挿入されていきます。
この時、Markdownの記法(**や*)をリッチなHTML(<strong>や<em>)に変換する処理が追いつかなかったり、特定の条件下でうまく機能しなかったりすることが原因で、記号がそのまま表示されてしまう現象が起きます。
と、いうことでTampermonkeyというブラウザにJavaScriptベースのカスタムスクリプト実行プラットフォームを使って、AIのスタイルミスを修正するスクリプトを書いてみましょう。
導入方法
- お使いのブラウザに Tampermonkey 拡張機能をインストールします。
- Tampermonkeyの管理画面を開き、「新規スクリプトを追加」をクリックします。

Tempermonkey設定画面 - 表示されたエディタに、コードをすべて貼り付けます。
ファイルメニューから保存をクリックします。
ファイルメニュー
これで設定は完了しました。ChatGPTやGeminiのページを開けば、スクリプトが自動で有効になり、強調表示が正しくレンダリングされるようになります。
ブラウザのプラグインアイコンがこんな風になれば動作しています。

完成した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操作:
TreeWalkerとDocumentFragmentを活用し、ページの構造を壊すことなく安全に置換を実行します。 - 動的コンテンツ対応:
MutationObserverにより、後から生成されたAIの応答にもリアルタイムで対応します。 - Shadow DOM対応: GeminiのUIにも対応済みです。
- デバッグモード:
DEBUGフラグをtrueにすると、変換した箇所がハイライトされ、動作確認が容易になります。
まとめ
かゆいところに手が届くと思い、この記事を作成しました。
このスクリプトが、皆さんの普段のAIライフとクソブログのコピペワークフローの簡易化に少しでも快適にする一助となれば幸いです。