Save a Perplexity thread as Markdown, Word or PDF with every question, answer, table and code block, and the sources of each answer as numbered links. Free, in your browser, no extension.
// Responsive Muse · Perplexity Thread Exporter · v1.0 · 2026-10-22
// Saves the Perplexity thread you have open as Markdown, Word (.doc) or PDF: every question and
// answer, with tables, code blocks and the source links of each citation. Runs only on
// perplexity.ai. It makes no network requests of its own and never reads cookies, passwords
// or tokens.
(async () => {
const TOOL = 'Export Perplexity thread';
const NAMES = { user: 'You', ai: 'Perplexity' }; // headings used in the file
if (!/(^|\.)perplexity\.ai$/.test(location.hostname) || !/^\/search\//.test(location.pathname)) {
alert('Open the Perplexity thread you want to export first (perplexity.ai/search/…).');
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));
// Questions are the plain-text bubbles of the thread; answers are the formatted ".prose" blocks
const main = document.querySelector('main') || document.body;
const QUESTION = 'div.whitespace-pre-wrap.break-words';
// 1. Scroll through the thread once so every answer is rendered
window.scrollTo(0, 0);
for (let y = 0; y < document.documentElement.scrollHeight; y += window.innerHeight * 0.8) {
window.scrollTo(0, y);
panel.text('Reading the thread…');
await sleep(300);
}
// Citations become numbered links [1], [2]… plus a list of sources under each answer
// (worked on a copy, so the page itself is not changed)
const answer = el => {
const copy = el.cloneNode(true);
const urls = [];
for (const c of [...copy.querySelectorAll('span.citation')].filter(s => !s.parentElement.closest('span.citation'))) {
const url = c.getAttribute('data-pplx-citation-url') || c.querySelector('a')?.href || '';
if (!url) { c.remove(); continue; }
const n = urls.includes(url) ? urls.indexOf(url) + 1 : urls.push(url);
const a = document.createElement('a');
a.href = url;
a.textContent = `[${n}]`;
c.replaceWith(' ', a);
}
return toMarkdown(copy) + (urls.length ? '\n\n**Sources**\n\n' + urls.map((u, i) => `${i + 1}. ${u}`).join('\n') : '');
};
// 2. Collect questions and answers in page order
const parts = [...main.querySelectorAll(`${QUESTION}, .prose`)]
.filter(el => !el.closest('nav, aside, [data-testid="login-modal"]'))
.filter(el => !el.parentElement.closest(`${QUESTION}, .prose`)); // skip nested matches
const messages = parts.map(el => el.matches('.prose')
? { role: 'ai', markdown: answer(el) }
: { role: 'user', markdown: el.innerText.trim() })
.filter(m => m.markdown);
if (!messages.some(m => m.role === 'ai')) {
panel.finish('No answers found. Perplexity may have changed its layout: please report it on responsive-muse.com.');
return 0;
}
const title = messages.find(m => m.role === 'user')?.markdown.split('\n')[0].slice(0, 80) || 'Perplexity thread';
deliverChat(title, messages, NAMES);
return messages.length;
})();
Bookmarklet: drag the middle button to your bookmarks bar, open www.perplexity.ai 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 www.perplexity.ai itself loading your list.
- Never touches your login. It doesn't read cookies, passwords, tokens or browser storage.
- Only runs on www.perplexity.ai. Anywhere else it stops before doing anything.
- Short and readable. 192 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:
6047e116081b40bc…
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 www.perplexity.ai.
How to use it
- Open the thread on perplexity.ai.
- Open the console: F12 or Ctrl+Shift+J (Cmd+Option+J on Mac). If Chrome asks, type
allow pastingand press Enter. - Copy the script, paste it in the console and press Enter. A box appears: click Markdown, Word or PDF (or Copy to paste it into Notion or Obsidian).
Sources stay with the answer
Perplexity’s value is in its citations, and copy-pasting a thread loses them or turns them into “+1” chips. This exporter numbers every cited page, links it inline and lists all the sources under each answer, so the document still works as a research note.
FAQ
Are the sources included?
Yes. Each citation becomes a numbered link like [1], and every answer ends with its list of sources, so the document keeps where each fact came from.
Does it work without a Perplexity account?
Yes. It exports whatever thread you have open, signed in or not, including tables and code blocks.
Isn't there an export button in Perplexity?
Some plans offer a PDF or Markdown export per thread, but it varies by plan and app. This works the same everywhere and also gives you a Word file.
