How DevPause turns a rendered ChatGPT, Claude or Gemini answer into clean text — no Markdown library
"Copy clean" sounds like it should be navigator.clipboard.writeText(answer.textContent). It is not, and the reason is that none of the three sites keep the Markdown they generated anywhere in the DOM. ChatGPT, Claude and Gemini each run the model's raw Markdown through their own renderer once, client-side, and only the resulting HTML ever reaches the page — headings become <h1>–<h3>, bold becomes <strong>, a fenced code block becomes a <pre> with its own copy button and syntax-highlighting markup inside. By the time DevPause's button sees the answer, the ** and ``` are gone — what is left is a real, if messy, piece of rendered UI, and the job is to read that UI back out as text a human would want to paste into Word or Telegram, not to reverse Markdown that was never there.
Why textContent is the wrong tool
el.textContent concatenates every text node under an element, including ones no sighted user ever sees. All three sites ship accessibility text for screen readers — a hidden "Claude responded:" label before the first paragraph, sr-only spans repeating a heading's content, duplicate MathML next to a LaTeX formula so screen readers get a pronounceable version. textContent cannot tell any of that apart from the visible answer; it just appends it. innerText is closer (it respects CSS visibility) but still has no concept of "this is a list item, prefix it with a bullet" or "this is a table cell, separate it from the next one" — it collapses a <table> into one run-on line. DevPause's plain.js instead walks the DOM tree itself, node by node, and decides what each tag means for plain-text output.
The walker, element by element
The core is one recursive function, walk(node, ctx), called on the answer's root element. For a text node it collapses whitespace unless a pre flag in the context says otherwise; for everything else it looks at the tag name and a handful of attributes:
const SKIP = new Set(["BUTTON","SVG","STYLE","SCRIPT","NOSCRIPT","TEMPLATE","SELECT","INPUT","TEXTAREA"]);
if (SKIP.has(tag) || attr(node, "aria-hidden") === "true" || attr(node, "data-dp-bar") !== null) return "";
const cls = String(attr(node, "class") || "");
if (/\b(sr-only|visually-hidden|screen-reader)\b/.test(cls) || attr(node, "hidden") !== null) return "";
if (typeof getComputedStyle === "function" && node.isConnected) {
const st = getComputedStyle(node);
if (st.display === "none" || st.visibility === "hidden" ||
(st.position === "absolute" && st.clip === "rect(0px, 0px, 0px, 0px)")) return "";
}
The first check removes DevPause's own button bar (data-dp-bar) and anything the site marked aria-hidden — the standard signal for "decorative, screen readers skip this." The class-name check catches the common accessibility convention of a class named sr-only rather than a CSS property, since some sites hide text by moving it off-screen with position/clip instead. The third check is the general case of that: an element can be visually hidden purely through computed style, with no attribute or class hinting at it at all, which is exactly how one of the three sites hides its duplicate "X responded:" label.
Formulas: taking the source, not the rendering
KaTeX renders a LaTeX formula into a tree of <span> elements positioned to look like math, plus a hidden <annotation> holding the original LaTeX and an MathML tree for accessibility. Walking the visible spans node by node would reproduce fragments of glyphs in the wrong order; DevPause instead special-cases the KaTeX wrapper and reads the source directly:
if (/\bkatex\b/.test(cls) && !/\bkatex-(html|mathml)\b/.test(cls)) {
const ann = node.querySelector?.("annotation");
return ann ? ` ${String(ann.textContent || "").trim()} ` : ` ${node.textContent.replace(/\s+/g, " ").trim()} `;
}
The negative part of the class check (!/\bkatex-(html|mathml)\b/) matters: KaTeX nests an inner katex-html block and an inner katex-mathml block inside the outer .katex wrapper this code already matched, so without excluding those two the walker would recurse into them and append the formula a second and third time.
Lists and tables without a layout engine
Ordered lists need to track the current number across <li value="n"> overrides and a reversed attribute, and nested lists need deeper indentation than their parent:
if (tag === "UL" || tag === "OL") {
const items = kids.filter((k) => k.nodeType === 1 && k.tagName === "LI");
const reversed = attr(node, "reversed") !== null;
let n = Number(attr(node, "start")) || (reversed ? items.length : 1);
const pad = " ".repeat(ctx.depth);
const lines = items.map((li) => {
if (Number(attr(li, "value"))) n = Number(attr(li, "value"));
const mark = tag === "OL" ? `${n}. ` : "• ";
n += reversed ? -1 : 1;
const body = walk(li, { ...ctx, depth: ctx.depth + 1, li: true }).replace(/^\n+|\n+$/g, "");
return pad + mark + body.replace(/\n(?!\s{2}|\s*$)/g, "\n" + pad + " ");
});
return `\n${lines.join("\n")}\n`;
}
Tables get no column-width alignment — that would need to measure every cell first — just a readable cell | cell | cell per row, built by a small recursive visit() that walks past any wrapping <tbody>/<thead> down to the actual <tr> rows:
if (tag === "TABLE") {
const rows = [];
const visit = (n) => {
for (const k of Array.from(n.childNodes || [])) {
if (k.nodeType !== 1) continue;
if (k.tagName === "TR") {
const cells = Array.from(k.childNodes).filter((c) => c.nodeType === 1 && /^T[DH]$/.test(c.tagName));
rows.push(cells.map((c) => walk(c, ctx).replace(/\s+/g, " ").trim()).join(" | "));
} else visit(k);
}
};
visit(node);
return `\n\n${rows.join("\n")}\n\n`;
}
Pasted into a chat app this stays readable text; pasted into a spreadsheet, the | separators are close enough to tab-separated that most people reformat it in seconds — a deliberate trade-off against pulling in a table-layout dependency for a feature that runs on every click.
Cleanup after the walk
The exported function, dpPlain, runs the walk and then does three passes a single recursive function cannot do on its own, because they depend on the whole output at once rather than one node at a time:
globalThis.dpPlain = function dpPlain(root) {
let text = walk(root, { pre: false, depth: 0 })
.split("\n").map((l) => l.replace(/[ \t]+$/, "").replace(/^ (?=\S)/, ""))
.join("\n").replace(/\n{3,}/g, "\n\n").trim();
text = text.replace(/^(?:(?:Claude|ChatGPT|Gemini)\s+(?:responded|said|replied)|You said)\s*:?\s*/i, "");
const paras = text.split("\n\n");
if (paras.length > 1 && paras[0].trim() === paras[1].trim()) paras.splice(0, 1);
return paras.join("\n\n").trim();
};
The line-trimming collapses trailing whitespace and the stray leading space that block-level recursion tends to leave behind; the blank-line collapse turns the "paragraph, blank, blank, paragraph" that nested block tags can produce into a normal single blank line. The last two lines exist because one of the three sites, independently of its screen-reader label, also duplicates the first paragraph of every answer in a visually hidden node for a different reason (an SEO/preview snippet) — by the time the walker reaches it, it already passed the aria-hidden/sr-only/computed-style filters above, because it isn't marked hidden in any of those ways, it is simply positioned off-screen in a way only this specific duplicate check catches: comparing the first two paragraphs after the fact and dropping the repeat.
Frequently asked questions
Does this touch the AI's actual response in any way?
No — it only reads the already-rendered DOM, and only at the moment of the click. It builds a text string in memory and puts it on the clipboard; nothing is sent anywhere.
Why not use a library like Turndown that converts HTML to Markdown?
Turndown and similar libraries produce Markdown, which is the opposite of what "clean copy" is for — the whole point is removing **, ### and ```, not re-adding them in a different dialect. A plain-text walker with the few rules above is also a fraction of the code size for a feature that only needs to run inside a browser extension, not handle arbitrary HTML from anywhere on the web.
What happens if a site changes its markup?
The tag-name and block-element handling (headings, lists, tables, <pre>) is standard HTML and rarely changes. The fragile parts are the site-specific hidden-label patterns and the KaTeX class names, which is why they are isolated as small, separately testable checks rather than folded into the general recursion.
DevPause is a free Chrome extension that adds utility buttons under ChatGPT, Claude and Gemini answers — clean copy, shorter, simpler, as a table, continue, and a wide-screen toggle. It is ad-supported (one small text line while an answer streams) rather than subscription-based, and the walker discussed above is the real, current code it ships with.