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.
// 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.
- 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
- 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 x.com.
How to use it
- Open x.com/i/bookmarks while signed in (it opens the new History > Saved page).
- Open the console: F12 or Ctrl+Shift+J (Cmd+Option+J on Mac). If Chrome asks, type
allow pastingand press Enter. - 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.
