Export your X (Twitter) bookmarks

Save every post you bookmarked on X to a spreadsheet, a Markdown note for Notion or Obsidian, or a plain list of links. Free, in your browser, no app to authorize.

X moved bookmarks to x.com/i/history ("Saved"). The old /i/bookmarks link redirects there; this script works on the new page and also on your Likes tab.

export-bookmarks.js · 127 linespaste in the DevTools console on x.com
// Responsive Muse · X (Twitter) Bookmarks Exporter · v1.1 · 2026-10-22
// Saves all your X bookmarks to CSV, Markdown or TXT: date, author, @handle, text and link.
// Also works on your Likes tab. Runs only on x.com/i/history (X's new home for bookmarks and
// likes; the old x.com/i/bookmarks redirects there). X only keeps on screen the posts you can
// see, so the script collects them while it scrolls. It makes no network requests of its own
// and never reads cookies, passwords or tokens.
(async () => {
  const TOOL = 'Export X bookmarks';
  const FORMAT = 'csv';        // 'csv' (Excel, Sheets), 'md' (Notion, Obsidian) or 'txt' (links only)
  const DELAY = 1200;          // pause between scrolls, in milliseconds

  if (!/^(x|twitter)\.com$/.test(location.hostname) || !/^\/i\/(history|bookmarks)/.test(location.pathname)) {
    alert('Open your bookmarks first (signed in):\nhttps://x.com/i/bookmarks');
    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(); },
    };
  })();
  // Offer the result as a downloaded file or as clipboard text (a real click, so it always works)
  const deliver = (summary, body, file) => {
    const save = () => {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(new Blob([/\.csv$/.test(file) ? '\ufeff' + body : body], { type: 'text/plain;charset=utf-8' }));
      a.download = file;
      a.click();
      setTimeout(() => URL.revokeObjectURL(a.href), 5000);
      panel.text(`${summary}\nSaved as ${file} (check your Downloads).`);
    };
    const copy = () => navigator.clipboard.writeText(body)
      .then(() => panel.text(`${summary}\nCopied. Paste it into a spreadsheet or a note.`))
      .catch(() => panel.text(`${summary}\nThe browser blocked the clipboard: use Download instead.`));
    panel.actions(summary, [['Close', false, () => panel.close()], ['Copy', false, copy], ['Download', true, save]]);
  };
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const posts = new Map();

  // Read the posts currently on screen
  const collect = () => {
    for (const art of document.querySelectorAll('article[data-testid="tweet"]')) {
      const time = art.querySelector('a[href*="/status/"] time');
      const link = time?.closest('a')?.getAttribute('href') || '';
      const id = (link.match(/\/status\/(\d+)/) || [])[1];
      if (!id || posts.has(id)) continue;
      const names = [...art.querySelectorAll('[data-testid="User-Name"] span')].map(s => s.textContent.trim()).filter(Boolean);
      posts.set(id, {
        date: time.getAttribute('datetime') || '',
        author: names[0] || '',
        handle: names.find(n => n.startsWith('@')) || '@' + link.split('/')[1],
        text: (art.querySelector('[data-testid="tweetText"]')?.innerText || '').replace(/\s+\n/g, '\n').trim(),
        media: art.querySelectorAll('[data-testid="tweetPhoto"], [data-testid="videoPlayer"]').length,
        url: 'https://x.com' + link.replace(/\/(analytics|photo|video).*$/, ''),
      });
    }
  };

  // 1. Scroll to the end, collecting as we go, until nothing new appears four times in a row
  let stop = false;
  panel.stoppable(() => { stop = true; });
  window.scrollTo(0, 0);
  await sleep(DELAY);
  let idle = 0;
  while (idle < 4 && !stop) {
    const before = posts.size;
    collect();
    window.scrollBy(0, window.innerHeight * 0.8);
    await sleep(DELAY);
    idle = posts.size === before ? idle + 1 : 0;
    panel.text(`Collecting posts… ${posts.size}`);
  }
  const list = [...posts.values()];
  if (!list.length) {
    panel.finish('No posts found. X may have changed its layout: please report it on responsive-muse.com.');
    return 0;
  }

  // 2. Build the file and offer it
  const cell = v => '"' + String(v).replace(/"/g, '""') + '"';
  const body = FORMAT === 'txt' ? list.map(p => p.url).join('\n')
    : FORMAT === 'md' ? list.map(p => `- **${p.author}** (${p.handle}) · ${p.date.slice(0, 10)} · [link](${p.url})\n  ${p.text.replace(/\n/g, '\n  ')}`).join('\n')
    : ['Date,Author,Handle,Text,Media,URL',
       ...list.map(p => [p.date, p.author, p.handle, p.text, p.media, p.url].map(cell).join(','))].join('\n');
  const kind = /likes/.test(location.pathname) ? 'likes' : 'bookmarks';
  deliver(`${stop ? 'Stopped early. ' : ''}Found ${many(list.length, 'post')}.`, body, `x-${kind}.${FORMAT}`);
  return list.length;
})();

Bookmarklet: drag the middle button to your bookmarks bar, open x.com and click the bookmark. It runs the same script with the options above.

Why it's safe to runAutomated check passed Oct 22, 2026 · tested on a real account Oct 22, 2026
  • Sends nothing anywhere. No fetch, XHR or external scripts. The only network activity is x.com itself loading your list.
  • Never touches your login. It doesn't read cookies, passwords, tokens or browser storage.
  • Only runs on x.com. Anywhere else it stops before doing anything.
  • Short and readable. 127 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: f8d07a363b9016b7…
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 x.com.
Did it work for you?

How to use it

  1. Open x.com/i/bookmarks while signed in (it opens the new History > Saved page).
  2. Open the console: F12 or Ctrl+Shift+J (Cmd+Option+J on Mac). If Chrome asks, type allow pasting and press Enter.
  3. Pick a format, copy the script, paste it in the console and press Enter. It scrolls down your bookmarks collecting every post, then shows a box: click Download (or Copy).

Why other bookmark exporters fail

Most exporters ask you to log in with your X account and pay for API access, and old scripts look for the /i/bookmarks page that no longer exists. X also removes posts from the page as you scroll past them, so a script that reads the page once only gets about ten. This one collects them while scrolling: in our test it saved every bookmark even though X never showed more than ten at a time.

FAQ

Does X have a bookmark export?

No. X's data archive includes bookmarks only in some regions and takes a day or more. This script reads the bookmarks page you can already see.

Why does it scroll by itself?

X only keeps about ten posts on screen at a time and removes the rest as you scroll, so the script collects them while it scrolls. Keep the tab open until it says "Saved".

Can I export my likes too?

Yes. Open the Likes tab (x.com/i/history/likes) and run the same script.

More free tools

Clean up