DatafetchPro
    export-x-bookmarks-csv
    Apr 17, 20265 min read81 views

    Export X Bookmarks to CSV with a Userscript

    I built a Tampermonkey userscript that lets you export X bookmarks to CSV, with likes, reposts and views. Here is how it works and where it breaks.

    A few hundred saved posts on X is a research folder you cannot open. Saving takes one click. Getting the list back out does not, because there is no menu item that will export X bookmarks to CSV, and the account archive X offers arrives as a bulk download of your account history rather than a tidy bookmarks spreadsheet. So I wrote a Tampermonkey userscript that does the job from inside the tab you already have open.

    Below is what the script collects, what the file looks like when it lands in your downloads folder, where this approach breaks, and the point at which a userscript stops being the right tool for the job.


    Why there is no button to export X bookmarks to CSV

    Your bookmarks are private to your account, and the page that displays them is assembled in the browser as you scroll rather than handed over as one finished document. That is why right-click and "save page as" gives you almost nothing useful.

    I did not use X's API for this. The bookmark list is already drawn on the screen in front of you, so the script reads the rendered page instead of asking a server for the same data a second time. There is no login workaround involved either. You are signed in already, and the script sees exactly what your own eyes see.

    Three terms, since they run through the rest of this post. A userscript is a small piece of JavaScript that runs on one specific website after the page loads and reads or changes what is on it. Tampermonkey is the browser extension that stores userscripts and decides which one runs where. CSV stands for comma-separated values, a plain text table that Excel, Google Sheets, Numbers and every programming language can open without a plugin.


    Installing the Tampermonkey userscript

    Setup, step by step

    1. Install the Tampermonkey browser extension for Chrome, Edge, Firefox or Safari.
    2. Open the Tampermonkey dashboard and create a new script.
    3. Paste the script below over the template it gives you, then save.
    4. Open your bookmarks page on X while signed in.
    5. Click the "Export to CSV" button that now appears on the page.

    The script scrolls your bookmarks for you, collects each post as it comes into view, skips anything it has already recorded so you do not end up with duplicate rows, and writes the result to a CSV file. A counter updates while it runs, so you can watch the total climb instead of guessing whether the tab has frozen.

    Let it finish the scroll. A large collection can take a minute or more, and stopping it early gives you a partial file.

    The script


    JavaScript
    // ==UserScript==
    // @name X Bookmark Exporter (Pro CSV)
    // @namespace http://tampermonkey.net/
    // @version 1.5
    // @description Export X bookmarks with Metrics, URLs, and dynamic button feedback
    // @author Gemini
    // @match https://x.com/i/bookmarks
    // @match https://twitter.com/i/bookmarks
    // @grant none
    // ==/UserScript==

    (function() {
    'use strict';

    let extractedData = new Map();
    let isScraping = false;

    const createBtn = () => {
    const btn = document.createElement('button');
    btn.id = "gemini-export-btn";
    btn.innerText = "Export to CSV";

    Object.assign(btn.style, {
    position: 'fixed',
    top: '12px',
    right: '80px',
    backgroundColor: '#eff3f4',
    color: '#0f1419',
    padding: '0 20px',
    height: '36px',
    borderRadius: '9999px',
    fontWeight: '700',
    fontSize: '14px',
    cursor: 'pointer',
    zIndex: '9999',
    border: '1px solid rgba(0,0,0,0)',
    boxShadow: '0 0 10px rgba(0,0,0,0.3)',
    transition: 'all 0.3s ease'
    });

    btn.onclick = toggleScrape;
    document.body.appendChild(btn);
    };

    const parseMetric = (el, testId) => {
    let target = (testId === "views")
    ? el.querySelector(`a[href$="/analytics"]`)
    : el.querySelector(`button[data-testid="${testId}"]`);

    if (!target) return "0";
    const label = target.getAttribute('aria-label') || "";
    // Extract only the numbers and shorthand (K, M) from the label
    return label.replace(/[^0-9.KMB]/g, '').trim() || "0";
    };

    const formatCSVCell = (text) => {
    if (!text) return '""';
    return `"${text.replace(/"/g, '""').replace(/\n/g, ' ')}"`;
    };

    const scrapeVisibleTweets = () => {
    const tweets = document.querySelectorAll('div[data-testid="cellInnerDiv"]');
    tweets.forEach(tweet => {
    const tweetText = tweet.querySelector('div[data-testid="tweetText"]')?.innerText || "";
    const userContent = tweet.querySelector('div[data-testid="User-Name"]')?.innerText || "";
    const timeLink = tweet.querySelector('time')?.parentElement;
    const tweetURL = timeLink ? `https://x.com${timeLink.getAttribute('href')}` : "";

    // Keying by URL to avoid duplicates precisely
    const uniqueKey = tweetURL || (userContent + tweetText);

    if (tweetText && !extractedData.has(uniqueKey)) {
    const parts = userContent.split('\n');
    extractedData.set(uniqueKey, {
    name: parts[0] || "Unknown",
    handle: parts[1] || "",
    content: tweetText,
    url: tweetURL,
    replies: parseMetric(tweet, "reply"),
    reposts: parseMetric(tweet, "retweet"),
    likes: parseMetric(tweet, "like"),
    views: parseMetric(tweet, "views")
    });
    }
    });
    };

    const toggleScrape = async () => {
    if (isScraping) return;
    isScraping = true;

    const btn = document.getElementById('gemini-export-btn');
    btn.style.backgroundColor = "#1d9bf0"; // Change to X Blue during extraction
    btn.style.color = "#ffffff";

    let lastHeight = 0;
    let noChangeCount = 0;

    while (noChangeCount < 3) {
    scrapeVisibleTweets();
    btn.innerText = `Extracting: ${extractedData.size}...`;

    window.scrollBy(0, window.innerHeight);
    await new Promise(r => setTimeout(r, 2000));

    let newHeight = document.documentElement.scrollHeight;
    if (newHeight === lastHeight) {
    noChangeCount++;
    } else {
    noChangeCount = 0;
    }
    lastHeight = newHeight;
    }

    downloadCSV();

    // Success State
    btn.innerText = "Done ✓";
    btn.style.backgroundColor = "#00ba7c"; // Success Green

    setTimeout(() => {
    btn.innerText = "Export to CSV";
    btn.style.backgroundColor = "#eff3f4";
    btn.style.color = "#0f1419";
    isScraping = false;
    }, 5000); // 5 second reset as requested
    };

    const downloadCSV = () => {
    const header = "Name,Handle,Tweet Content,Tweet URL,Replies,Reposts,Likes,Views\n";
    let rows = "";

    extractedData.forEach(item => {
    rows += `${formatCSVCell(item.name)},` +
    `${formatCSVCell(item.handle)},` +
    `${formatCSVCell(item.content)},` +
    `${formatCSVCell(item.url)},` +
    `${formatCSVCell(item.replies)},` +
    `${formatCSVCell(item.reposts)},` +
    `${formatCSVCell(item.likes)},` +
    `${formatCSVCell(item.views)}\n`;
    });

    const blob = new Blob(["\uFEFF" + header + rows], { type: 'text/csv;charset=utf-8;' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `x-bookmarks-${new Date().toISOString().split('T')[0]}.csv`;
    a.click();
    };

    window.addEventListener('load', () => setTimeout(createBtn, 2000));
    })();


    What lands in the CSV file

    A finished run to export X bookmarks to CSV gives you one file, with a header row and everything below it in the same shape.

    The columns

    Eight columns, one row per bookmarked post.

    • Name — the display name on the account, which is the part people change on a whim.
    • Username — the @handle, which is the stable identifier and the one worth keying on.
    • Tweet content — the text of the post itself.
    • Tweet URL — a direct link back to the original, so any row can be checked against its source.
    • Replies — reply count.
    • Reposts — repost count. Older writing and some interfaces still call this a retweet; it is the same number.
    • Likes — like count.
    • Views — view count.

    Every number is a snapshot of the moment the export ran, not a live figure. Run it again in a month and the counts will have moved, which is useful if you keep the dated files.

    Commas, quotes and the Excel problem

    Posts contain commas, quotation marks, line breaks and emoji, which are the exact characters a CSV uses for structure. The convention for handling that clash is set out in RFC 4180, the CSV format specification: any field holding a comma or a line break gets wrapped in double quotes, and a literal double quote inside that field is written twice.

    Excel is the usual source of trouble afterwards. On Windows, double-clicking a UTF-8 file often turns emoji and accented characters into strings like ’. Import it instead through Data, then From Text/CSV, and set the file origin to UTF-8. Google Sheets assumes UTF-8 already, so uploading there sidesteps the whole thing.

    A quick check before you trust the file

    Compare the row count in your spreadsheet against the number the on-screen counter finished on. If they match, the quoting held. If the spreadsheet has more rows than the counter reported, a line break inside a post has been read as the start of a new record, and that tells you the escaping needs attention rather than the scraping.


    Where a Twitter bookmarks export breaks

    The timeline unloads what you scrolled past

    X renders a long list a screen at a time and discards rows that have scrolled well out of sight, which is what keeps the browser from crawling. Anything the script has already recorded is held safely in memory, but this is the reason the scroll has to run to the end. Data that was never drawn on screen was never available to read.

    Selectors change without warning

    The script locates each piece of data by matching a pattern against the page structure, a CSS selector, as documented by MDN. Front-end teams rewrite their markup regularly, and when they do, a selector that matched yesterday matches nothing today.

    The symptom is recognisable. The export runs, the counter climbs, and you open a file full of rows with empty columns. Or the browser console shows something like Cannot read properties of null (reading 'textContent'), which means the script asked for text from an element it never found. The fix is a selector update, not a rewrite, but it does mean a script like this needs occasional maintenance rather than none.

    What the script does not capture

    The rule is simple: it gets what the interface shows. If the bookmarks list does not display something, it will not be in your file. Check the file against a handful of bookmarks you remember before you build anything on top of it.


    Turning the export into something useful

    The original point of this script was that saved posts are dead weight until they are structured. A few things the file supports directly:

    • Content research. Sort by likes descending and read the top thirty rows in one sitting. Patterns in opening lines and post length show up far faster in a spreadsheet than in an infinite scroll.
    • Trend analysis. Export monthly, keep each file dated, and compare. The URL column is a stable join key, so matching this month against last month is straightforward.
    • Feeding Python. pandas.read_csv("bookmarks.csv") is one line, and you have a dataframe. From there it goes into whatever analysis or model-building work you already do.
    • Lead and niche research. The handle column gives you a list of accounts to work through by hand.

    CSV is fine up to a few thousand rows. Past that, or once you want to query rather than scroll, the data belongs in SQLite, PostgreSQL, a Google Sheet or Airtable. I cover how I set those up under DatafetchPro automation services.


    When browser automation beats a userscript

    A userscript is the right tool when you are signed in, the data is on screen, and you will run it by hand every so often. It is the wrong tool once any of these are true:

    • It needs to run on a schedule while you are asleep.
    • It needs to cover several accounts, or several sites, in one pass.
    • The output belongs in a database, Google Sheet or Airtable rather than your downloads folder.
    • Someone other than you has to be able to run it without touching code.

    At that point I move to Python with Playwright, which drives a real browser from code and can run without a visible window, or to a no-code macro in UI.Vision RPA, or an Automa workflow, or a packaged Chrome extension when the person using it is not the person who built it. The output then goes into proper storage and runs on a schedule through a REST or GraphQL call or a webhook.

    That is the work I do to order, sold through Fiverr. If you have a site with data behind it and no way to get the data out, the scraper and bot services I build start from the same place this script did: open the page, look at what is actually on it, and read it properly. More writeups like this one sit in the DatafetchPro article archive, and there is background on who runs DatafetchPro.

    Terms of service and robots.txt

    Technically, this script reads a page you are already signed into and already looking at. Whether that is permitted in your particular case is a separate question, and not one I can answer on your behalf.

    X publishes its terms of service for the platform, and a robots.txt file sits at the root of the domain. Google's documentation on how robots.txt directives work explains how to read one. Going through both and deciding what applies to your situation is your call, and where the data involves other people, the lead research case above especially, the privacy rules where you operate matter as much as the platform's own terms.

    I build the tool. The decision about where to point it stays with you.

    0

    Rather not build it yourself?

    I build this kind of thing for a living.

    Send me the site and what you need out of it — you'll get an approach, a timeline, and a fixed quote back. Scoping is free.