Skip to content

Instantly share code, notes, and snippets.

@miketromba
Last active August 30, 2026 15:03
Show Gist options
  • Select an option

  • Save miketromba/3cecba180dab31b36955b4723de642cd to your computer and use it in GitHub Desktop.

Select an option

Save miketromba/3cecba180dab31b36955b4723de642cd to your computer and use it in GitHub Desktop.
// ── ChatGPT Conversation β†’ Markdown Exporter (saved + temp chats) ────────────
// Paste into the console on any ChatGPT conversation, saved OR temporary.
(() => {
function htmlToMd(node) {
if (!node) return '';
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
if (node.nodeType !== Node.ELEMENT_NODE) return '';
const tag = node.tagName.toLowerCase();
const ch = () => Array.from(node.childNodes).map(htmlToMd).join('');
switch (tag) {
case 'h1': return `# ${ch()}\n\n`; case 'h2': return `## ${ch()}\n\n`;
case 'h3': return `### ${ch()}\n\n`; case 'h4': return `#### ${ch()}\n\n`;
case 'h5': return `##### ${ch()}\n\n`; case 'h6': return `###### ${ch()}\n\n`;
case 'p': return `${ch()}\n\n`;
case 'br': return '\n';
case 'strong': case 'b': return `**${ch()}**`;
case 'em': case 'i': return `*${ch()}*`;
case 'code': return node.closest('pre') ? node.textContent : `\`${node.textContent}\``;
case 'pre': {
const c = node.querySelector('code'); const raw = c ? c.textContent : node.textContent;
let lang = ''; if (c) { const m = (c.className||'').match(/language-([\w-]+)/); if (m) lang = m[1]; }
return `\`\`\`${lang}\n${raw.replace(/\n$/,'')}\n\`\`\`\n\n`;
}
case 'ul': return Array.from(node.children).map(li=>`- ${htmlToMd(li).trim()}`).join('\n')+'\n\n';
case 'ol': { const s = parseInt(node.getAttribute('start')||'1',10);
return Array.from(node.children).map((li,i)=>`${s+i}. ${htmlToMd(li).trim()}`).join('\n')+'\n\n'; }
case 'li': return ch().trim();
case 'a': return `[${ch()}](${node.getAttribute('href')||''})`;
case 'blockquote': return `> ${ch().trim().replace(/\n/g,'\n> ')}\n\n`;
case 'hr': return `---\n\n`;
case 'table': {
const rows = Array.from(node.querySelectorAll('tr')); if (!rows.length) return '';
const hd = Array.from(rows[0].querySelectorAll('th,td')).map(c=>c.textContent.trim());
let md = '| '+hd.join(' | ')+' |\n| '+hd.map(()=>'---').join(' | ')+' |\n';
for (let i=1;i<rows.length;i++) md += '| '+Array.from(rows[i].querySelectorAll('td,th')).map(c=>c.textContent.trim()).join(' | ')+' |\n';
return md+'\n';
}
default: return ch();
}
}
// ---- method 1: backend API (saved chats) ----------------------------------
async function viaApi() {
const m = location.pathname.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
if (!m) return null;
const id = m[0];
let token;
try { token = (await fetch('/api/auth/session',{credentials:'include'}).then(r=>r.json())).accessToken; }
catch { return null; }
if (!token) return null;
const res = await fetch(`/backend-api/conversation/${id}`, {
credentials:'include', headers:{ Authorization:'Bearer '+token }
});
if (!res.ok) return null;
const conv = await res.json();
const map = conv.mapping; if (!map) return null;
let root = null; for (const k in map) if (!map[k].parent) { root = k; break; }
const out = [];
let nid = root;
while (nid) {
const node = map[nid], msg = node.message;
if (msg && msg.author && ['user','assistant'].includes(msg.author.role) && msg.content) {
if (msg.content.content_type === 'text' || msg.content.content_type === 'multimodal_text') {
const text = (msg.content.parts||[]).filter(p=>typeof p==='string').join('\n\n').trim();
if (text) out.push({ role: msg.author.role, text });
}
}
const kids = node.children || [];
nid = kids.length ? kids[kids.length-1] : null;
}
return out.length ? { title: conv.title, messages: out } : null;
}
// ---- method 2: scroll & scrape (temp chats / API unavailable) -------------
async function viaScrape() {
const sleep = ms => new Promise(r=>setTimeout(r,ms));
const findScroller = () => {
const msg = document.querySelector('[data-message-author-role]');
if (msg) { let n = msg.parentElement;
while (n && n !== document.documentElement) {
const s = getComputedStyle(n);
if ((s.overflowY==='auto'||s.overflowY==='scroll') && n.scrollHeight>n.clientHeight+5) return n;
n = n.parentElement;
} }
let best=null;
document.querySelectorAll('div,main').forEach(n=>{ const s=getComputedStyle(n);
if((s.overflowY==='auto'||s.overflowY==='scroll')&&n.scrollHeight>n.clientHeight+50){if(!best||n.scrollHeight>best.scrollHeight)best=n;} });
return best || document.scrollingElement;
};
const scroller = findScroller();
// id -> { role, text, turnNum, abs } β€” order keys captured AT HARVEST TIME
const seen = new Map();
const harvest = () => {
const base = scroller.scrollTop;
for (const el of document.querySelectorAll('[data-message-author-role]')) {
const id = el.getAttribute('data-message-id') || el.textContent.slice(0,80);
if (seen.has(id)) continue;
const role = el.getAttribute('data-message-author-role');
if (role !== 'user' && role !== 'assistant') continue;
let text;
if (role === 'user') {
const p = Array.from(el.querySelectorAll('.whitespace-pre-wrap'));
text = p.length ? p.map(x=>x.textContent.trim()).filter(Boolean).join('\n\n') : el.textContent.trim();
} else {
const md = el.querySelector('.markdown');
text = md ? htmlToMd(md).trim() : el.textContent.trim();
}
if (!text) continue;
const turnEl = el.closest('[data-testid^="conversation-turn"]');
const turnNum = turnEl ? parseInt((turnEl.getAttribute('data-testid').match(/(\d+)$/)||[])[1]||'0',10) : 0;
const abs = base + el.getBoundingClientRect().top; // monotonic position in scroll content
seen.set(id, { role, text, turnNum, abs });
}
};
// Scroll from the very top in SMALL steps so virtualized turns mount.
scroller.scrollTo(0,0); await sleep(600); harvest();
let prevTop=-1, same=0, guard=0;
while (guard++ < 3000) {
harvest();
const top = scroller.scrollTop;
if (top === prevTop) { if (++same >= 5) break; } else same = 0;
prevTop = top;
scroller.scrollTo(0, top + 250);
await sleep(230);
}
await sleep(500); harvest();
// FIX: order by the position captured during harvest, not the post-scroll DOM.
const ordered = [...seen.values()].sort((a,b) =>
(a.turnNum - b.turnNum) || (a.abs - b.abs)
).map(({ role, text }) => ({ role, text }));
return ordered.length ? { title: null, messages: ordered } : null;
}
async function run() {
let result = null, source = 'API';
try { result = await viaApi(); } catch {}
if (!result) { source = 'DOM scrape'; result = await viaScrape(); }
if (!result || !result.messages.length) {
alert('Could not export: no messages found. Make sure a conversation is visible on screen.');
return;
}
const title = (result.title || document.title || 'ChatGPT Conversation')
.replace(/\s*[-–]\s*ChatGPT$/i,'').trim() || 'ChatGPT Conversation';
const date = new Date().toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'});
let md = `# ${title}\n\n*Exported on ${date}* \n*URL: ${location.href}*\n\n---\n\n`;
for (const { role, text } of result.messages) {
md += role === 'user' ? `## πŸ§‘ Human\n\n${text}\n\n---\n\n` : `## πŸ€– ChatGPT\n\n${text}\n\n---\n\n`;
}
const blob = new Blob([md], { type:'text/markdown;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = title.replace(/[^a-z0-9]/gi,'_').toLowerCase() + '.md';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(a.href);
console.log(`βœ… Exported "${title}" via ${source} β€” ${result.messages.length} messages, ${(md.length/1024).toFixed(1)} KB`);
}
run();
})();
@miketromba

Copy link
Copy Markdown
Author

How to export/download a ChatGPT conversation to markdown

Usage: Open any ChatGPT conversation, press Cmd+Option+J (Mac) or Ctrl+Shift+J (Windows/Linux) to open DevTools, paste the script into the console, and hit Enter. The page will auto-scroll through the thread to load every message, then a .md file will download automatically named after the conversation title.

What it exports:

  • Full conversation with Human and ChatGPT turns clearly labeled
  • Formatting preserved: bold, italics, inline code, code blocks (with language), headers, lists, tables, blockquotes, links
  • Title, export date, and original URL as a header

Why it scrolls: Unlike Claude, ChatGPT virtualizes its message list β€” only a handful of turns are kept in the DOM at a time, and off-screen ones are unmounted. The script scrolls from top to bottom and harvests each message as it renders (deduping by message ID), which is why it runs asynchronously. Let it finish scrolling before the download triggers.

Compatibility note: Tested on chatgpt.com as of May 2026. If the script breaks after a ChatGPT UI update, the selectors to check/update are:

  • [data-message-author-role] β€” user/assistant message turns ("user" vs "assistant")
  • .markdown β€” assistant message content
  • .whitespace-pre-wrap β€” user message text
  • main [class*="overflow-y-auto"] β€” the scrollable conversation container

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment