Save the ChatGPT chat you have open as a PDF, a Word document or Markdown, with headings, lists, code blocks and tables intact. Free and private: it runs in your own browser.
Open the conversation you want on chatgpt.com, then run the script there.
// Responsive Muse · ChatGPT Conversation Exporter · v1.0 · 2026-10-22
// Saves the ChatGPT conversation you have open as Markdown, Word (.doc) or PDF, with code
// blocks, lists and tables kept. Runs only on chatgpt.com. ChatGPT only keeps part of a long
// chat on screen, so the script scrolls from the top and collects every message on the way.
// It makes no network requests of its own and never reads cookies, passwords or tokens.
(async () => {
const TOOL = 'Export ChatGPT chat';
const NAMES = { user: 'You', ai: 'ChatGPT' }; // headings used in the file
const DELAY = 900; // pause between scrolls, in milliseconds
if (!/^(chatgpt\.com|chat\.openai\.com)$/.test(location.hostname) || !/\/c\//.test(location.pathname)) {
alert('Open the ChatGPT conversation you want to export first (chatgpt.com/c/…).');
return;
}
// A small box on the page shows progress and asks before changing anything,
// so you never need to read or edit the console.
const many = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`; // 1 video, 2 videos
const panel = (() => {
const box = document.createElement('div');
box.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483647;width:340px;max-width:calc(100vw - 32px);' +
'background:#111318;color:#e6e8ee;font:14px/1.5 system-ui,sans-serif;border-radius:12px;padding:16px;' +
'box-shadow:0 8px 30px rgba(0,0,0,.4);text-align:left';
const head = document.createElement('strong');
head.textContent = TOOL;
const msg = document.createElement('p');
msg.style.cssText = 'margin:8px 0 12px;white-space:pre-line';
const row = document.createElement('div');
row.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end';
box.append(head, msg, row);
document.body.append(box);
const button = (label, primary, onClick) => {
const b = document.createElement('button');
b.textContent = label;
b.style.cssText = 'font:600 14px system-ui,sans-serif;padding:8px 14px;border-radius:8px;cursor:pointer;' +
(primary ? 'background:#0f766e;color:#fff;border:1px solid #0f766e' : 'background:none;color:#e6e8ee;border:1px solid #3a3f4b');
b.onclick = onClick;
row.append(b);
};
return {
text(t) { msg.textContent = t; },
ask(t, yes) {
msg.textContent = t;
row.replaceChildren();
return new Promise(r => {
button('Cancel', false, () => { row.replaceChildren(); r(false); });
button(yes, true, () => { row.replaceChildren(); r(true); });
});
},
stoppable(onStop) { row.replaceChildren(); button('Stop', false, onStop); },
actions(t, list) {
msg.textContent = t;
row.replaceChildren();
list.forEach(([label, primary, run]) => button(label, primary, run));
},
finish(t) { this.actions(t, [['Close', true, () => box.remove()]]); },
close() { box.remove(); },
};
})();
// Turn a rendered message (HTML) back into Markdown: headings, lists, code, tables, links
const toMarkdown = root => {
const skip = el => el.matches('button, svg, script, style, [aria-hidden="true"], .sr-only, [data-markdown-copy="exclude"], ' +
'.code-block-decoration, [class*="code-block-header"]');
const walk = (node, depth) => {
if (node.nodeType === 3) return node.textContent;
if (node.nodeType !== 1 || skip(node)) return '';
const kids = () => [...node.childNodes].map(n => walk(n, depth)).join('');
const tag = node.tagName.toLowerCase();
// Math (KaTeX) is on the page three times; keep only its TeX source
if (node.classList.contains('katex')) {
const tex = node.querySelector('annotation[encoding="application/x-tex"]')?.textContent.trim();
if (tex) return node.closest('.katex-display') ? `\n\n$$${tex}$$\n\n` : `$${tex}$`;
}
if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(+tag[1])} ${kids().trim()}\n\n`;
if (tag === 'p') return `\n\n${kids().trim()}\n\n`;
if (tag === 'br') return '\n';
if (tag === 'hr') return '\n\n---\n\n';
if (tag === 'strong' || tag === 'b') return `**${kids()}**`;
if (tag === 'em' || tag === 'i') return `*${kids()}*`;
if (tag === 'a') return `[${kids()}](${node.href})`;
if (tag === 'img') return node.alt ? `` : '';
if (tag === 'pre') {
const code = node.querySelector('code') || node;
const lang = ((code.className || '').match(/language-([\w+-]+)/) || [])[1] || '';
return `\n\n\`\`\`${lang}\n${code.textContent.replace(/\n$/, '')}\n\`\`\`\n\n`;
}
if (tag === 'code') return '`' + node.textContent + '`';
if (tag === 'blockquote') return '\n\n' + kids().trim().split('\n').map(l => '> ' + l).join('\n') + '\n\n';
if (tag === 'ul' || tag === 'ol') {
const items = [...node.children].filter(li => li.tagName === 'LI').map((li, i) => {
const body = [...li.childNodes].map(n => walk(n, depth + 1)).join('').trim().replace(/\n{2,}/g, '\n');
return ' '.repeat(depth) + (tag === 'ol' ? `${i + 1}. ` : '- ') + body;
});
return '\n' + items.join('\n') + '\n';
}
if (tag === 'table') {
const rows = [...node.querySelectorAll('tr')].map(tr =>
'| ' + [...tr.children].map(c => walk(c, depth).trim().replace(/\|/g, '\\|').replace(/\n/g, ' ')).join(' | ') + ' |');
if (rows.length) rows.splice(1, 0, '|' + ' --- |'.repeat(rows[0].split(' | ').length));
return '\n\n' + rows.join('\n') + '\n\n';
}
return kids();
};
return walk(root, 0).replace(/\n{3,}/g, '\n\n').trim();
};
// Offer the chat as Markdown, Word or PDF (all built here, in your browser)
const deliverChat = (title, messages, names, note = '') => {
const date = new Date().toISOString().slice(0, 10);
const md = `# ${title}\n\n_Exported ${date} from ${location.href}_\n\n` + messages.map(m =>
`## ${m.role === 'user' ? names.user : names.ai}\n\n${m.markdown}`).join('\n\n---\n\n') + '\n';
const esc = s => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + esc(title) + '</title><style>' +
'body{font:15px/1.6 Georgia,serif;max-width:760px;margin:40px auto;padding:0 20px;color:#111}' +
'h1{font:700 26px system-ui,sans-serif}h2{font:600 15px system-ui,sans-serif;text-transform:uppercase;letter-spacing:.05em;color:#555;margin-top:32px}' +
'.msg{white-space:pre-wrap}.user{background:#f3f4f6;padding:12px 16px;border-radius:8px}' +
'pre,code{font:13px Consolas,monospace}hr{border:0;border-top:1px solid #ddd}</style></head><body>' +
`<h1>${esc(title)}</h1><p><em>Exported ${date}</em></p>` + messages.map(m =>
`<h2>${esc(m.role === 'user' ? names.user : names.ai)}</h2><div class="msg ${m.role}">${esc(m.markdown)}</div>`).join('<hr>') +
'</body></html>';
const file = (title || 'chat').replace(/[\\/:*?"<>|]+/g, '_').slice(0, 80);
const save = (body, ext, type) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([body], { type }));
a.download = `${file}.${ext}`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
panel.text(`Saved as ${file}.${ext} (check your Downloads).`);
};
const pdf = () => {
const w = window.open('', '_blank');
if (!w) { panel.text('Your browser blocked the new window. Allow pop-ups for this site and try again.'); return; }
w.document.write(html);
w.document.close();
w.focus();
w.print();
panel.text('Choose "Save as PDF" as the printer in the window that opened.');
};
const copy = () => navigator.clipboard.writeText(md)
.then(() => panel.text('Copied as Markdown. Paste it into Notion, Obsidian or any editor.'))
.catch(() => panel.text('The browser blocked the clipboard: use Markdown instead.'));
panel.actions(`Found ${many(messages.length, 'message')}.${note ? '\n' + note : ''}`, [
['Copy', false, copy],
['Markdown', false, () => save(md, 'md', 'text/markdown;charset=utf-8')],
['Word', false, () => save('\ufeff' + html, 'doc', 'application/msword')],
['PDF', true, pdf],
]);
};
const sleep = ms => new Promise(r => setTimeout(r, ms));
const turns = () => [...document.querySelectorAll('[data-turn-key]')];
const first = turns()[0];
let box = first;
while (box && !(box.scrollHeight > box.clientHeight + 50 && /(auto|scroll)/.test(getComputedStyle(box).overflowY))) box = box.parentElement;
if (!first || !box) {
panel.finish('No messages found. ChatGPT may have changed its layout: please report it on responsive-muse.com.');
return 0;
}
// 1. Scroll to the very top until no older messages load
let last = -1, idle = 0;
while (idle < 3) {
box.scrollTop = 0;
await sleep(DELAY);
const n = turns().length;
idle = n === last ? idle + 1 : 0;
last = n;
panel.text('Loading the start of the conversation…');
}
// 2. Scroll down, collecting each turn (your message, then ChatGPT's answer) once
const seen = new Map();
const collect = () => {
for (const t of turns()) {
const key = t.getAttribute('data-turn-key');
if (seen.has(key)) continue;
const parts = [];
const user = t.querySelector('[data-user-message-bubble]');
const ai = t.querySelector('[data-markdown-text-style="assistant-message"]');
// Images you attached (they're private and their links expire, so only note them)
const images = [...t.querySelectorAll('img')].filter(img => !ai || !ai.contains(img)).length;
const note = images ? `[${many(images, 'image')} attached]` : '';
const said = [user ? user.textContent.trim() : '', note].filter(Boolean).join('\n\n');
if (said) parts.push({ role: 'user', markdown: said });
if (ai) parts.push({ role: 'ai', markdown: toMarkdown(ai) });
if (parts.length) seen.set(key, parts);
}
};
idle = 0;
while (idle < 3) {
const before = seen.size;
collect();
const atEnd = box.scrollTop + box.clientHeight >= box.scrollHeight - 5;
box.scrollTop += box.clientHeight * 0.8;
await sleep(DELAY);
idle = seen.size === before && atEnd ? idle + 1 : 0;
panel.text(`Reading messages… ${many([...seen.values()].flat().length, 'message')}`);
}
const messages = [...seen.values()].flat();
if (!messages.length) {
panel.finish('No messages found. ChatGPT may have changed its layout: please report it on responsive-muse.com.');
return 0;
}
deliverChat(document.title.replace(/\s*[-|]\s*ChatGPT$/, '') || 'ChatGPT conversation', messages, NAMES);
return messages.length;
})();
Bookmarklet: drag the middle button to your bookmarks bar, open chatgpt.com and click the bookmark. It runs the same script with the options above.
- Sends nothing anywhere. No fetch, XHR or external scripts. The only network activity is chatgpt.com itself loading your list.
- Never touches your login. It doesn't read cookies, passwords, tokens or browser storage.
- Only runs on chatgpt.com. Anywhere else it stops before doing anything.
- Short and readable. 204 commented lines, not minified or obfuscated.
- Shows its progress on the page. A small box tells you what it's doing and offers Download or Copy at the end.
- Same code for everyone. SHA-256 of the default version:
88584803ae4ea91f…
Verify it yourself in 30 seconds
- Read it: every line is above, with comments.
- Search the code for
fetch,XMLHttpRequest,cookie,localStorageoreval. None of them appear. - Open the Network tab before running it: you'll only see requests to chatgpt.com.
How to use it
- Open the conversation on chatgpt.com while signed in.
- Open the console: F12 or Ctrl+Shift+J (Cmd+Option+J on Mac). If the browser asks, type
allow pastingand press Enter. - Copy the script, paste it in the console and press Enter. It scrolls through the chat, then a small box offers Copy, Markdown, Word and PDF.
- For a PDF, click PDF and choose “Save as PDF” as the printer.
Why not use ChatGPT’s data export?
ChatGPT’s “Export data” emails a zip of your entire history in raw JSON and HTML, which takes a while and is hard to read. Copying by hand loses the formatting. This script rebuilds the conversation you’re reading as a clean document in a few seconds, and notes the images you attached (their links are private, so they aren’t copied).
FAQ
Isn't ChatGPT's own export enough?
ChatGPT's "Export data" sends your whole account as a zip by email, in raw JSON and HTML. This saves just the conversation you're reading, as a clean document, in seconds.
Are code blocks, tables and math kept?
Yes. Code keeps its language, tables become Markdown tables and math is exported as TeX ($…$), which Notion, Obsidian and most Markdown editors display as formulas.
How do I get a PDF?
Click PDF in the box: a clean print view opens. Choose "Save as PDF" as the printer. Nothing is uploaded anywhere.
Does it work with very long chats?
Yes. The site only keeps part of a long chat on screen, so the script scrolls from the top and collects every message on the way. Keep the tab open until the box shows the buttons.
What about images I sent?
They're noted as "[N images attached]". Their links are private and expire, so they aren't copied into the file.
