OpenAI 的网页版一直存在一个 bug,当点击复制按钮时,复制出的公式内容会进行错误的转义。

正确的 Markdown 格式ChatGPT 复制出来的结果
\[ f(x) = \frac{1}{x} \][ f(x) = \frac{1}{x} ]
\( a = \frac{3}{5} \)( a = \frac{3}{5} )

然后,我就借助 Antigravity 自带的浏览器控制功能,对这个 bug 进行了逆向分析

分析结果是:

  1. ChatGPT 页面中,Message 对象存储的原始 Markdown 是完整的,形如 "\\( a=\\frac{3}{5} \\)"
  2. 在点击复制时,触发一个名为 copyToClipboard 的函数
  3. 该函数会调用一个 stripEscapes() 函数,手动进行 转义符清洗,例如把 \# 变成 #
  4. 错误就出在这里,它也会错误地将 \[ 变成 [,导致复制出的 LaTeX 出问题

找到了错误,修正就很简单,这里直接搜索 React 组件的原始数据,找到 markdown 后直接复制即可。

我也是让 Antigravity Opus 写了个油猴脚本,先将原始的复制事件拦截,然后替换成正常的,效果很好。如果有更多需求,佬友们可以自行二次开发。

// ==UserScript== // @name         ChatGPT LaTeX 复制修复 // @namespace    https://github.com/theigrams // @version      1.0.0 // @description  直接从 React 状态读取原始 Markdown,绕过 ChatGPT 的错误转义逻辑 // @author       Antigravity // @match        https://chatgpt.com/* // @match        https://chat.openai.com/* // @icon         https://chat.openai.com/favicon.ico // @grant        none // @run-at       document-end // ==/UserScript==

(function () { "use strict";

  /**
* 从 React Fiber 中提取原始 Markdown
* @param {HTMLElement} turnElement - conversation-turn 元素
* @returns {string} 原始 Markdown 文本
* @throws {Error} 如果无法读取 React 状态
*/
function getOriginalMarkdownFromReact(turnElement) { // 获取 React Fiber 节点 const fiberKey = Object.keys(turnElement).find((k) => k.startsWith("__reactFiber$") ); if (!fiberKey) { throw new Error("无法找到 React Fiber 节点"); } let fiber = turnElement[fiberKey]; let depth = 0; const maxDepth = 50; // 向上遍历 Fiber 树,查找消息数据 while (fiber && depth < maxDepth) { const props = fiber.memoizedProps; if (props) { // 尝试多种可能的数据路径 // 路径 1: message.content.parts if (props.message?.content?.parts) { const parts = props.message.content.parts; if (Array.isArray(parts) && parts.length > 0) { console.log("[Markdown Copy] 找到数据路径: message.content.parts"); return parts.join("\n"); } } // 路径 2: displayParts if (props.displayParts) { const parts = props.displayParts; if (Array.isArray(parts) && parts.length > 0) { // displayParts 可能是对象数组 const text = parts .map((p) => (typeof p === "string" ? p : p.text || "")) .join(""); if (text) { console.log("[Markdown Copy] 找到数据路径: displayParts"); return text; } } } // 路径 3: text 属性 if (typeof props.text === "string" && props.text.includes("\\")) { console.log("[Markdown Copy] 找到数据路径: text"); return props.text; } // 路径 4: content 字符串 if (typeof props.content === "string" && props.content.includes("\\")) { console.log("[Markdown Copy] 找到数据路径: content"); return props.content; } // 路径 5: children 中的文本 if (props.children?.props?.message?.content?.parts) { const parts = props.children.props.message.content.parts; if (Array.isArray(parts) && parts.length > 0) { console.log( "[Markdown Copy] 找到数据路径: children.props.message.content.parts" ); return parts.join("\n"); } } } fiber = fiber.return; depth++; } throw new Error(`遍历了 ${depth} 层 Fiber 节点,未找到原始 Markdown 数据`); } /**
* 深度搜索 React 状态中的 Markdown 内容
* @param {HTMLElement} turnElement
* @returns {string}
*/
function deepSearchMarkdown(turnElement) { const fiberKey = Object.keys(turnElement).find((k) => k.startsWith("__reactFiber$") ); if (!fiberKey) { throw new Error("无法找到 React Fiber 节点"); } const visited = new Set(); let result = null; function search(obj, path = "", depth = 0) { if (depth > 20 || !obj || visited.has(obj)) return; if (typeof obj !== "object") return; visited.add(obj); // 检查是否是我们要找的数据 if (Array.isArray(obj) && obj.length > 0 && typeof obj[0] === "string") { const text = obj.join("\n"); // 检查是否包含 LaTeX 定界符(正确的格式) if ( text.includes("\\[") || text.includes("\\(") || text.includes("\\begin") ) { console.log(`[Markdown Copy] 深度搜索找到数据: ${path}`); result = text; return; } } if (typeof obj === "string" && obj.length > 50) { if ( obj.includes("\\[") || obj.includes("\\(") || obj.includes("\\begin") ) { console.log(`[Markdown Copy] 深度搜索找到字符串: ${path}`); result = obj; return; } } for (const key in obj) { if (result) return; // 已找到,停止搜索 try { search(obj[key], `${path}.${key}`, depth + 1); } catch (e) { // 忽略循环引用等错误 } } } let fiber = turnElement[fiberKey]; let fiberDepth = 0; while (fiber && fiberDepth < 30 && !result) { if (fiber.memoizedProps) { search(fiber.memoizedProps, `fiber[${fiberDepth}].memoizedProps`); } if (fiber.memoizedState && !result) { search(fiber.memoizedState, `fiber[${fiberDepth}].memoizedState`); } fiber = fiber.return; fiberDepth++; } if (!result) { throw new Error("深度搜索未找到包含 LaTeX 定界符的原始 Markdown"); } return result; } /**
* 显示提示消息
*/
function showToast(message, isError = false) { const toast = document.createElement("div"); toast.textContent = message; toast.style.cssText = ` position: fixed; bottom: 20px; right: 20px; background: ${ isError ? "linear-gradient(135deg, #ef4444 0%, #dc2626 100%)" : "linear-gradient(135deg, #10a37f 0%, #1a7f64 100%)" }; color: white; padding: 12px 20px; border-radius: 8px; font-size: 14px; font-weight: 500; z-index: 10000; box-shadow: 0 4px 12px ${ isError ? "rgba(239, 68, 68, 0.3)" : "rgba(16, 163, 127, 0.3)" }; animation: slideIn 0.3s ease; `; const style = document.createElement("style"); style.textContent = ` @keyframes slideIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } `; document.head.appendChild(style); document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = "0"; toast.style.transform = "translateY(10px)"; toast.style.transition = "all 0.3s ease"; setTimeout(() => toast.remove(), 300); }, 3000); } /**
* 拦截复制按钮点击
*/
function interceptCopyButtons() { document.addEventListener( "click", async (e) => { // 检查是否点击了复制按钮 const btn = e.target.closest( 'button[data-testid="copy-turn-action-button"]' ); if (!btn) return; // 阻止原始的复制行为 e.stopPropagation(); e.preventDefault(); console.log("[Markdown Copy] 拦截到复制按钮点击"); try { // 找到对应的消息容器 const turn = btn.closest('[data-testid^="conversation-turn-"]'); if (!turn) { throw new Error("无法找到消息容器元素 (conversation-turn)"); } console.log( "[Markdown Copy] 找到消息容器:", turn.getAttribute("data-testid") ); // 尝试从 React 状态读取原始 Markdown let markdown; try { markdown = getOriginalMarkdownFromReact(turn); } catch (e1) { console.log("[Markdown Copy] 常规路径失败,尝试深度搜索..."); markdown = deepSearchMarkdown(turn); } if (!markdown) { throw new Error("获取到的 Markdown 为空"); } // 写入剪贴板 await navigator.clipboard.writeText(markdown); console.log("[Markdown Copy] 成功复制原始 Markdown"); console.log( "[Markdown Copy] 预览:", markdown.substring(0, 200) + "..." ); showToast("✓ 已复制原始 Markdown"); } catch (error) { console.error("[Markdown Copy] 错误:", error); showToast(`✗ 复制失败: ${error.message}`, true); // 不 fallback,直接报错 throw error; } }, true ); // 使用捕获阶段,确保先于其他处理器执行 } // 初始化 console.log("[Markdown Copy] 脚本已加载 v1.0.0"); interceptCopyButtons(); console.log("[Markdown Copy] 复制按钮拦截已启用"); })();

📌 转载信息
原作者:
Theigrams
转载时间:
2026/1/14 17:40:35

标签: 油猴脚本, ChatGPT, markdown, react, LaTeX

添加新评论