Export a Perplexity thread to Markdown, Word or PDF

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.

export-perplexity.js · 192 linespaste in the DevTools console on www.perplexity.ai
// 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 ? `![${node.alt}](${node.src})` : '';
      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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    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.

Why it's safe to runAutomated check passed Oct 22, 2026
  • 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
  1. Read it: every line is above, with comments.
  2. Search the code for fetch, XMLHttpRequest, cookie, localStorage or eval. None of them appear.
  3. Open the Network tab before running it: you'll only see requests to www.perplexity.ai.
Did it work for you?

How to use it

  1. Open the thread on perplexity.ai.
  2. Open the console: F12 or Ctrl+Shift+J (Cmd+Option+J on Mac). If Chrome asks, type allow pasting and press Enter.
  3. 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.

More free tools

Export & back up