ChatGPT 网页端 LaTeX 公式复制修复
OpenAI 的网页版一直存在一个 bug,当点击复制按钮时,复制出的公式内容会进行错误的转义。
| 正确的 Markdown 格式 | ChatGPT 复制出来的结果 |
|---|---|
\[ f(x) = \frac{1}{x} \] | [ f(x) = \frac{1}{x} ] |
\( a = \frac{3}{5} \) | ( a = \frac{3}{5} ) |
然后,我就借助 Antigravity 自带的浏览器控制功能,对这个 bug 进行了逆向分析
分析结果是:
- ChatGPT 页面中,Message 对象存储的原始 Markdown 是完整的,形如
"\\( a=\\frac{3}{5} \\)" - 在点击复制时,触发一个名为
copyToClipboard的函数 - 该函数会调用一个
stripEscapes()函数,手动进行 转义符清洗,例如把\#变成# - 错误就出在这里,它也会错误地将
\[变成[,导致复制出的 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] 复制按钮拦截已启用");
})();

