// ==UserScript== // @name ChatGPT LaTeX Smart Render Fix // @namespace chatgpt-latex-smart-fix // @version 2.0.0 // @description 智能修复 ChatGPT 未渲染的 LaTeX,包括 $...$、$$...$$、\(...\)、\[...\]。会先判断 $...$ 是否像公式,避免把金额误渲染。 // @author yixing233 // @homepageURL https://github.com/yixing233/GPTCraber // @supportURL https://github.com/yixing233/GPTCraber/issues // @downloadURL https://raw.githubusercontent.com/yixing233/GPTCraber/main/chatgpt-latex-smart-fix.user.js // @updateURL https://raw.githubusercontent.com/yixing233/GPTCraber/main/chatgpt-latex-smart-fix.user.js // @license GPL-3.0-only // @match https://chatgpt.com/* // @match https://www.chatgpt.com/* // @match https://chat.openai.com/* // @grant none // @run-at document-idle // @require https://cdn.jsdelivr.net/npm/katex@0.18.7/dist/katex.min.js // ==/UserScript== (function () { 'use strict'; const KATEX_CSS = 'https://cdn.jsdelivr.net/npm/katex@0.18.7/dist/katex.min.css'; // @require 理论上会在脚本执行前注入 katex,但 CDN 被拦截、油猴缓存异常等 // 情况下仍可能缺失。缺了就静默退出,不要在每个文本节点上抛 ReferenceError。 if (typeof katex === 'undefined') { console.warn('[ChatGPT LaTeX Smart Fix] KaTeX 未加载,脚本已退出'); return; } /* ----------------------------- 1. 加载 KaTeX CSS 与少量自有样式 ----------------------------- */ if (!document.querySelector('#chatgpt-katex-smart-css')) { const link = document.createElement('link'); link.id = 'chatgpt-katex-smart-css'; link.rel = 'stylesheet'; link.href = KATEX_CSS; document.head.appendChild(link); } if (!document.querySelector('#chatgpt-katex-smart-style')) { const style = document.createElement('style'); style.id = 'chatgpt-katex-smart-style'; // 行内公式跟随文字基线,行间公式独占一行居中。 // 这里用 span + display:block 而不是 div:父节点往往是

, // 往

里插

是非法嵌套,浏览器会把段落拆开导致排版错乱。 style.textContent = [ '.chatgpt-smart-katex-inline{display:inline-block;vertical-align:baseline}', '.chatgpt-smart-katex-display{display:block;margin:.8em 0;text-align:center;overflow-x:auto;overflow-y:hidden}', ].join('\n'); document.head.appendChild(style); } /* ----------------------------- 2. 判断 $...$ 是否真的像数学公式 ----------------------------- */ function looksLikeMath(content) { const s = content.trim(); if (!s) return false; /* * 排除典型金额: * * $100 * $29.9 * $1,299 * $12.99 USD */ if ( /^\d[\d,.]*(?:\s?(?:USD|CNY|RMB|EUR|JPY|GBP|美元|元|人民币))?$/i.test(s) ) { return false; } /* * 明确包含 LaTeX 命令 * * \mathcal * \frac * \alpha * \mathrm * ... */ if (/\\[a-zA-Z]+/.test(s)) { return true; } /* * 上下标: * * G_i * x^2 * a_{ij} */ if (/[_^]/.test(s)) { return true; } /* * 数学结构符号 */ if (/[={}[\]()+\-*/<>≤≥≈≠∑∫√∞]/.test(s)) { return true; } /* * 单个数学变量: * * $x$ * $G$ * $T$ * * 科研场景很常见。 */ if (/^[A-Za-zα-ωΑ-Ω]$/.test(s)) { return true; } /* * 带数字的变量: * * x1 * A2 */ if (/^[A-Za-z]+\d+$/.test(s)) { return true; } /* * 简单变量组合: * * Re * Nu * CO_2 已经会被 _ 捕获 * * 限制长度避免普通英文单词被误判。 */ if (/^[A-Za-z]{1,3}$/.test(s)) { return true; } return false; } /* ----------------------------- 3. KaTeX 渲染 ----------------------------- */ function createMath(content, displayMode) { const span = document.createElement('span'); span.className = displayMode ? 'chatgpt-smart-katex-display' : 'chatgpt-smart-katex-inline'; span.dataset.smartKatex = '1'; try { katex.render(content, span, { displayMode, // throwOnError=true 才能走到下面的 catch:渲染失败时本函数返回 // null,调用方保留原始 $...$ 文本。若设成 false,KaTeX 会把错误 // 原文用红字渲染出来顶掉原本还算可读的公式源码。 throwOnError: true, strict: false, trust: false, output: 'htmlAndMathml' }); return span; } catch (e) { console.debug( '[ChatGPT LaTeX Smart Fix] render error:', content, e ); return null; } } /* ----------------------------- 4. 处理单个文本节点 ----------------------------- */ // 不进入代码块、textarea、已有 KaTeX 等区域 const SKIP_SELECTOR = [ 'pre', 'code', 'textarea', 'script', 'style', '.katex', '.katex-display', '[data-smart-katex="1"]' ].join(','); function processTextNode(node) { if (!node || node.nodeType !== Node.TEXT_NODE) { return; } const text = node.nodeValue; if ( !text || (!text.includes('$') && !text.includes('\\(') && !text.includes('\\[')) ) { return; } const parent = node.parentElement; if (!parent) return; if (parent.closest(SKIP_SELECTOR)) { return; } /* * 匹配顺序非常重要: * * $$...$$ * \[...\] * \(...\) * $...$ */ const regex = /(\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|(? lastIndex) { frag.appendChild( document.createTextNode( text.slice(lastIndex, match.index) ) ); } frag.appendChild(rendered); lastIndex = regex.lastIndex; changed = true; } if (!changed) { return; } /* * 公式后的剩余普通文本 */ if (lastIndex < text.length) { frag.appendChild( document.createTextNode( text.slice(lastIndex) ) ); } node.replaceWith(frag); } /* ----------------------------- 5. 遍历 ChatGPT 回答 ----------------------------- */ function processElement(root) { if (!root) return; if ( root.nodeType === Node.ELEMENT_NODE && root.closest?.(SKIP_SELECTOR) ) { return; } const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (!parent) { return NodeFilter.FILTER_REJECT; } if (parent.closest(SKIP_SELECTOR)) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } } ); const nodes = []; let current; // 先收集再改写:遍历过程中替换节点会让 TreeWalker 的游标失效。 while ((current = walker.nextNode())) { nodes.push(current); } nodes.forEach(processTextNode); } /* ----------------------------- 6. 找出所有 ChatGPT 消息 ----------------------------- */ function renderAll() { const selectors = [ '[data-message-author-role="assistant"] .markdown', '[data-message-author-role="assistant"]' ]; document .querySelectorAll(selectors.join(',')) .forEach(processElement); } /* ----------------------------- 7. ChatGPT 流式输出监听 ----------------------------- */ let timer = null; function scheduleRender() { clearTimeout(timer); /* * 不要每输出一个 token 就重绘, * 等流式文本稍微稳定后再处理。 */ timer = setTimeout(() => { renderAll(); }, 450); } const observer = new MutationObserver((mutations) => { let relevant = false; for (const mutation of mutations) { if ( mutation.type === 'childList' || mutation.type === 'characterData' ) { relevant = true; break; } } if (relevant) { scheduleRender(); } }); function start() { renderAll(); observer.observe(document.body, { subtree: true, childList: true, characterData: true }); } if (document.readyState === 'loading') { document.addEventListener( 'DOMContentLoaded', start, { once: true } ); } else { start(); } })();