Save the Gemini chat you have open, every prompt and answer, as a PDF, a Word document or Markdown. Math formulas are kept as TeX.
Open the conversation you want on gemini.google.com, then run the script there.
// Responsive Muse · Gemini Conversation Exporter · v1.0 · 2026-10-22
// Saves the Gemini conversation you have open as Markdown, Word (.doc) or PDF, with code
// blocks, lists and tables kept. Runs only on gemini.google.com. Gemini loads older messages
// as you scroll up, 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 Gemini chat';
const NAMES = { user: 'You', ai: 'Gemini' }; // headings used in the file
const DELAY = 1000; // pause between scrolls, in milliseconds
if (location.hostname !== 'gemini.google.com' || !/^\/(app|gem)\//.test(location.pathname)) {
alert('Open the Gemini conversation you want to export first (gemini.google.com/app/…).');
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('.conversation-container')];
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) {
panel.finish('No messages found. Gemini may have changed its layout: please report it on responsive-muse.com.');
return 0;
}
box = box || document.scrollingElement;
// 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 prompt, then Gemini's answer) once
const seen = new Map();
const collect = () => {
turns().forEach((t, i) => {
const user = t.querySelector('user-query .query-text, user-query');
const ai = t.querySelector('model-response message-content .markdown, model-response .markdown');
const key = t.id || (user?.textContent || '').slice(0, 200) + '|' + i;
if (seen.has(key)) return;
const parts = [];
if (user) parts.push({ role: 'user', markdown: user.textContent.replace(/^(You said|Has dicho)\s*/i, '').trim() });
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. Gemini may have changed its layout: please report it on responsive-muse.com.');
return 0;
}
deliverChat(document.title.replace(/\s*-\s*Google Gemini$/, '') || 'Gemini conversation', messages, NAMES);
return messages.length;
})();
Bookmarklet: drag the middle button to your bookmarks bar, open gemini.google.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 gemini.google.com itself loading your list.
- Never touches your login. It doesn't read cookies, passwords, tokens or browser storage.
- Only runs on gemini.google.com. Anywhere else it stops before doing anything.
- Short and readable. 201 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:
54e109b18adbecd9…
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 gemini.google.com.
How to use it
- Open the conversation on gemini.google.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.
Formulas and code done right
Gemini draws each formula three times (picture, accessibility version and source), so copying a chat gives you garbled math. This script keeps only the TeX source, like $v = d/t$, which Notion, Obsidian and most Markdown editors display as a proper formula. Code blocks keep their language label.
FAQ
Can't I export to Google Docs from Gemini?
Gemini can send a single answer to Docs, one at a time. This script saves the whole conversation, your prompts and every answer, in one file.
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.
