ChatGPT UI update on October 8th appears to have broken rich-text copying to markdown, without preserving bold, italic, and bold-italic formatting, and making links to external websites replaced by links to google favicons.
The resulting markdown is as bad as it can be.
After several communications with the support and no resolution provided, I wrote the JS that you can insert into the browser console (press F12, and in the opened panel enter this JS code) that will return the ability to copy classic markdown to the clipboard. It adds a “Copy normal Markdown” button to all individual ChatGPT responses, and the resulting markdown preserves headings, bold and italic text, links, lists, tables, and code blocks.
Enjoy the script and support Scientific-Tools{dot}Org via Scientific-Tools{dot}Org{slash}support-us if you find this script useful.
/**
* ChatGPT Classic Markdown Extractor
*
* @name ChatGPT Classic Markdown Extractor
* @description Adds a "Copy normal Markdown" button to ChatGPT responses.
* @author Scientific-Tools.Org
* @version 1.0.0
* @created 2026-10-12
* @license MIT
*
* Converts rendered ChatGPT responses into standard Markdown (thus avoiding bad markdown recently introduced by the OpenAI UI update) and copies the result directly to the clipboard.
* This JavaScript tool that adds a “Copy normal Markdown” button to all individual ChatGPT responses. It converts rendered answers into standard Markdown, preserving headings, bold and italic text, links, lists, tables, and code blocks. Clicking the button copies Markdown to the clipboard.
* Note: This experimental script may not fully support interactive elements or Mermaid diagrams and can interfere with ChatGPT's interface.
* If you need to remove the existing buttons and observer
* window.__normalMarkdown.stop();
* or
* window.__normalMarkdown?.stop?.()
*/
(() => {
"use strict";
// Stop an earlier installation.
window.__normalMarkdown?.stop?.();
const ROOT_SELECTOR = '[class*="DilResponseRoot-"]';
const BUTTON_CLASS = "normal-markdown-copy-button";
let observer;
let scanTimer;
const buttons = new Set();
// ---------- Helpers ----------
const getText = el => el.textContent || "";
const escapeMD = text =>
text.replace(/\\/g, "\\\\")
.replace(/([*_`[\]])/g, "\\$1");
const isFavicon = el => {
const src = el.getAttribute("src") || "";
return /favicon|google\.com\/s2\/favicons/i.test(src);
};
const ignored = el =>
el.matches(
"script,style,noscript,button,svg," +
"input,textarea,select,[aria-hidden='true']"
) ||
el.closest("." + BUTTON_CLASS);
const weight = value => {
if (value === "bold") return 700;
if (value === "normal") return 400;
return parseInt(value, 10) || 400;
};
function wrap(text, marker) {
if (!text.trim()) return text;
const before = text.match(/^\s*/)[0];
const after = text.match(/\s*$/)[0];
const middle = text.slice(
before.length,
text.length - after.length
);
return before + marker + middle + marker + after;
}
// ---------- Inline formatting ----------
function inline(node, parent = null) {
if (node.nodeType === Node.TEXT_NODE) {
return escapeMD(
node.nodeValue.replace(/\s+/g, " ")
);
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node;
const tag = el.tagName.toLowerCase();
if (ignored(el)) return "";
if (tag === "br") return "\n";
if (tag === "img") {
if (isFavicon(el)) return "";
const src = el.getAttribute("src") || "";
const alt = el.getAttribute("alt") || "";
return src && alt
? ``
: "";
}
if (tag === "code" && !el.closest("pre")) {
const text = getText(el);
const runs = [...text.matchAll(/`+/g)]
.map(m => m[0].length);
const fence = "`".repeat(
Math.max(1, ...runs.map(n => n + 1))
);
return fence + text + fence;
}
let result = [...el.childNodes]
.map(child => inline(child, el))
.join("");
if (tag === "a") {
const href = el.getAttribute("href");
if (!href) return result;
let url;
try {
url = new URL(href, location.href);
} catch {
return result;
}
if (!["http:", "https:", "mailto:"].includes(
url.protocol
)) return result;
if (/google\.com\/s2\/favicons/.test(url.href)) {
return "";
}
return `[${result.trim() || url.href}](${
url.href.replace(/\)/g, "%29")
})`;
}
if (!result.trim()) return result;
if (tag === "strong" || tag === "b") {
return wrap(result, "**");
}
if (tag === "em" || tag === "i") {
return wrap(result, "*");
}
if (["del", "s", "strike"].includes(tag)) {
return wrap(result, "~~");
}
// Recover CSS-based bold and italic.
if (parent && ![
"p", "li", "a", "pre", "code",
"h1", "h2", "h3", "h4", "h5", "h6"
].includes(tag)) {
const cs = getComputedStyle(el);
const ps = getComputedStyle(parent);
const bold =
weight(cs.fontWeight) >= 600 &&
weight(ps.fontWeight) < 600;
const italic =
cs.fontStyle === "italic" &&
ps.fontStyle !== "italic";
if (bold && italic) return wrap(result, "***");
if (bold) return wrap(result, "**");
if (italic) return wrap(result, "*");
}
return result;
}
function inlineChildren(el) {
return [...el.childNodes]
.map(child => inline(child, el))
.join("")
.trim();
}
// ---------- Markdown blocks ----------
function convert(root) {
const output = [];
const add = text => {
if (text?.trim()) output.push(text.trim());
};
function convertList(el, depth = 0) {
const ordered = el.tagName === "OL";
let number = Number(el.getAttribute("start") || 1);
return [...el.children]
.filter(li => li.tagName === "LI")
.map(li => {
const nested = [...li.children]
.filter(c => /^(UL|OL)$/.test(c.tagName));
const text = [...li.childNodes]
.filter(n =>
n.nodeType !== Node.ELEMENT_NODE ||
!/^(UL|OL)$/.test(n.tagName)
)
.map(n => inline(n, li))
.join("")
.trim();
const checkbox = li.querySelector(
'input[type="checkbox"],[role="checkbox"]'
);
let prefix;
if (checkbox) {
const checked =
checkbox.checked ||
checkbox.getAttribute("aria-checked") === "true";
prefix = `- [${checked ? "x" : " "}] `;
} else {
prefix = ordered ? `${number++}. ` : "- ";
}
const line =
" ".repeat(depth) +
prefix +
text;
const children = nested.map(n =>
convertList(n, depth + 1)
).join("\n");
return line + (children ? "\n" + children : "");
})
.join("\n");
}
function convertTable(el) {
const rows = [...el.querySelectorAll("tr")]
.map(tr =>
[...tr.children]
.filter(c => /^(TD|TH)$/.test(c.tagName))
.map(c =>
inlineChildren(c)
.replace(/\|/g, "\\|")
.replace(/\n/g, " ")
)
).filter(r => r.length);
if (!rows.length) return "";
const cols = Math.max(...rows.map(r => r.length));
const row = r =>
"| " +
Array.from(
{ length: cols },
(_, i) => r[i] || ""
).join(" | ") +
" |";
return [
row(rows[0]),
row(Array(cols).fill("---")),
...rows.slice(1).map(row)
].join("\n");
}
function visit(el) {
if (ignored(el)) return;
const tag = el.tagName.toLowerCase();
// Avoid exporting rendered Mermaid SVG and styling.
if (
el.matches(
'.mermaid, [class*="mermaid"], ' +
'[id^="chatgpt-mermaid-"]'
)
) {
const source = el.getAttribute("data-mermaid-source");
if (source) {
add("```mermaid\n" + source + "\n```");
} else {
add("<!-- Mermaid diagram: source unavailable -->");
}
return;
}
if (tag === "pre") {
const code = el.querySelector("code") || el;
const source = getText(code).replace(/\n$/, "");
const classes =
String(code.className || "") + " " +
String(el.className || "");
const lang =
classes.match(/(?:language-|lang-)([\w+#-]+)/)?.[1]
|| "";
const runs = [...source.matchAll(/`+/g)]
.map(m => m[0].length);
const fence = "`".repeat(
Math.max(3, ...runs.map(n => n + 1))
);
add(`${fence}${lang}\n${source}\n${fence}`);
return;
}
if (/^h[1-6]$/.test(tag)) {
add("#".repeat(Number(tag[1])) +
" " + inlineChildren(el));
return;
}
if (tag === "ul" || tag === "ol") {
add(convertList(el));
return;
}
if (tag === "table") {
add(convertTable(el));
return;
}
if (tag === "blockquote") {
const container = document.createElement("div");
for (const child of el.childNodes) {
container.appendChild(child.cloneNode(true));
}
const text = convert(container);
add(
text.split("\n")
.map(line => line ? "> " + line : ">")
.join("\n")
);
return;
}
if (tag === "hr") {
add("---");
return;
}
if (tag === "p") {
add(inlineChildren(el));
return;
}
if (tag === "img") {
add(inline(el, el.parentElement));
return;
}
const blockTags =
/^(P|DIV|SECTION|ARTICLE|H[1-6]|UL|OL|PRE|TABLE|BLOCKQUOTE|HR)$/;
const hasBlocks = [...el.children]
.some(c => blockTags.test(c.tagName));
if (hasBlocks) {
for (const child of el.childNodes) {
if (child.nodeType === Node.ELEMENT_NODE) {
visit(child);
} else if (
child.nodeType === Node.TEXT_NODE &&
child.textContent.trim()
) {
add(escapeMD(child.textContent.trim()));
}
}
} else {
add(inlineChildren(el));
}
}
// Process root itself if appropriate.
if (/^(P|PRE|UL|OL|TABLE|BLOCKQUOTE|H[1-6])$/
.test(root.tagName)) {
visit(root);
} else {
[...root.children].forEach(visit);
}
return output.join("\n\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
// ---------- Popup ----------
function openPopup(markdown) {
const popup = window.open(
"",
"classic_markdown_popup",
"width=1000,height=780,resizable=yes,scrollbars=yes"
);
if (!popup) {
alert("Allow popups for chatgpt.com.");
return;
}
const d = popup.document;
d.title = "ChatGPT - Classic Markdown";
d.body.replaceChildren();
d.body.style.cssText =
"margin:0;padding:20px;background:#f6f6f6;" +
"font-family:system-ui;color:#222";
const heading = d.createElement("h2");
heading.textContent = "Classic Markdown";
const info = d.createElement("p");
info.textContent =
"Markdown reconstructed from one ChatGPT answer.";
const area = d.createElement("textarea");
area.value = markdown;
area.spellcheck = false;
area.wrap = "soft";
area.style.cssText =
"box-sizing:border-box;width:100%;height:72vh;" +
"padding:15px;font:13px/1.5 monospace;" +
"white-space:pre-wrap;" +
"overflow-wrap:anywhere;" +
"overflow-x:hidden;";
const copy = d.createElement("button");
copy.textContent = "Copy Markdown";
copy.style.cssText =
"padding:12px 20px;margin:12px 0;cursor:pointer";
const status = d.createElement("span");
status.style.marginLeft = "12px";
copy.onclick = async () => {
try {
await popup.navigator.clipboard.writeText(area.value);
status.textContent = "Copied!";
} catch {
area.focus();
area.select();
const ok = d.execCommand("copy");
status.textContent = ok
? "Copied!"
: "Press Ctrl+C to copy";
}
};
d.body.append(
heading,
info,
copy,
status,
area
);
popup.focus();
}
// ---------- Button injection ----------
function inject(root) {
if (root.dataset.normalMarkdownInstalled) return;
const button = document.createElement("button");
button.className = BUTTON_CLASS;
button.type = "button";
button.textContent = "Copy normal Markdown";
button.title =
"ChatGPT Classic Markdown Extractor v1.0.0\n" +
"Created by Scientific-Tools.Org";
button.style.cssText = [
"display:inline-block",
"margin:10px 0",
"padding:8px 14px",
"border:1px solid #999",
"border-radius:8px",
"background:#e9e9e9",
"color:#111",
"font:13px system-ui",
"cursor:pointer",
"position:relative",
"z-index:10"
].join(";");
/*
// Version for popup window with normal markdown code in it
button.onclick = event => {
event.preventDefault();
event.stopPropagation();
const markdown = convert(root);
if (!markdown) {
alert("No Markdown content extracted.");
return;
}
openPopup(markdown);
};
*/
// Version that just copies normal markdown code without a popup window
button.onclick = async event => {
event.preventDefault();
event.stopPropagation();
try {
const markdown = convert(root);
if (!markdown) {
alert("No Markdown content extracted.");
return;
}
await navigator.clipboard.writeText(markdown);
// Brief visual confirmation
const originalText = button.textContent;
button.textContent = "✓ Markdown copied!";
setTimeout(() => {
if (button.isConnected) {
button.textContent = originalText;
}
}, 1500);
} catch (error) {
console.error("Markdown copy failed:", error);
alert("Could not copy Markdown: " + error.message);
}
};
// Place button immediately after response.
root.insertAdjacentElement("afterend", button);
root.dataset.normalMarkdownInstalled = "true";
buttons.add(button);
}
function scan() {
const roots = document.querySelectorAll(ROOT_SELECTOR);
roots.forEach(inject);
return roots.length;
}
const count = scan();
// Watch for new responses.
observer = new MutationObserver(() => {
clearTimeout(scanTimer);
scanTimer = setTimeout(scan, 400);
});
observer.observe(document.body, {
childList: true,
subtree: true
});
window.__normalMarkdown = {
scan,
convert,
stop() {
observer.disconnect();
clearTimeout(scanTimer);
buttons.forEach(button => button.remove());
buttons.clear();
document.querySelectorAll(ROOT_SELECTOR)
.forEach(root =>
delete root.dataset.normalMarkdownInstalled
);
delete window.__normalMarkdown;
console.log("[Normal Markdown] Stopped.");
}
};
console.log(
`[Normal Markdown] Installed on ${count} rendered responses.`
);
})();