Skip to content
Gary Wu
Go back

How to Export Twitter/X Bookmarks

Edit page

Org Status: 🟡 Dormant Cloudflare: N/A Last Audited: 2026-04-28


Twitter/X has no native bookmark export. You can save thousands of tweets, but there is no button to download them. This article covers every viable extraction method — from GraphQL interception to the official API — with full working code for each. By the end you will know exactly which approach fits your use case and have copy-paste code to make it work.

What you will learn:


  1. The Problem
  2. Core Concepts
  3. Method 1: GraphQL/XHR Interception
  4. Method 2: DOM Scraping with Auto-Scroll
  5. Method 3: Official Twitter API
  6. Method 4: Hybrid Local Database
  7. Method 5: Console Scripts and Userscripts
  8. The Best Hybrid Approach
  9. Handling Anti-Automation
  10. Data Formats and Export Targets
  11. Comparison Matrix
  12. Decision Tree
  13. Anti-Patterns
  14. References

Twitter launched bookmarks in February 2018. Six years later, there is still no export button. This is not an oversight. It is a deliberate product decision to keep your data locked inside the platform.

Why Twitter does not let you export bookmarks

  1. Engagement retention — Bookmarks give you a reason to come back. An exported list does not.
  2. No regulatory pressure — GDPR data exports include tweets you wrote, not tweets you bookmarked. Bookmarks reference other people’s content, so Twitter argues they are not “your data” in the regulatory sense.
  3. API tier gating — The Bookmarks API endpoint exists but requires the Basic tier ($200/month). This prices out casual users and individual developers.
  4. 800-tweet hard cap — Even with the paid API, you only get your 800 most recent bookmarks. If you have 3,000 bookmarks, the oldest 2,200 are inaccessible through any official channel.

What this means in practice

What changes if you solve this

A working bookmark export pipeline turns Twitter from a black hole of saved links into a structured knowledge base. You can feed bookmarks into an LLM for summarization, build a personal search engine over saved tweets, or simply have a backup before Twitter changes its mind about something.

Key insight: The gap between “Twitter will not let you export bookmarks” and “it is technically impossible” is enormous. Twitter’s web app fetches your bookmarks through a GraphQL API that returns rich JSON. Your browser already has this data. The question is how to capture it.


Before diving into methods, you need to understand the architecture Twitter’s web app uses to render bookmarks. Every method exploits a different layer of this stack.

Twitter’s Internal GraphQL API

Twitter’s web app is a React single-page application. When you visit x.com/i/bookmarks, the app makes a GraphQL request to an internal endpoint:

GET /i/api/graphql/{queryId}/Bookmarks?variables={...}&features={...}

The queryId is a hash that changes with deployments but the operation name Bookmarks is stable. The response contains the full tweet object — text, author, metrics, media URLs, timestamps — everything.

// The shape of Twitter's internal GraphQL bookmark response
interface BookmarkTimelineResponse {
  data: {
    bookmark_timeline_v2: {
      timeline: {
        instructions: TimelineInstruction[];
      };
    };
  };
}

interface TimelineInstruction {
  type: 'TimelineAddEntries' | 'TimelineTerminateTimeline' | 'TimelineClearCache';
  entries?: TimelineEntry[];
}

interface TimelineEntry {
  entryId: string;        // "tweet-1234567890"
  sortIndex: string;      // pagination cursor
  content: {
    entryType: 'TimelineTimelineItem' | 'TimelineTimelineCursor';
    itemContent?: {
      __typename: 'TimelineTweet';
      tweet_results: {
        result: TweetResult;
      };
    };
    // Cursor entries have value + cursorType instead
    value?: string;
    cursorType?: 'Top' | 'Bottom';
  };
}

interface TweetResult {
  __typename: 'Tweet';
  rest_id: string;
  core: {
    user_results: {
      result: {
        core: {
          screen_name: string;
          name: string;
        };
        legacy: {
          screen_name: string;
          followers_count: number;
          verified: boolean;
        };
        is_blue_verified: boolean;
      };
    };
  };
  legacy: {
    full_text: string;
    created_at: string;        // "Mon Jan 01 00:00:00 +0000 2024"
    favorite_count: number;
    retweet_count: number;
    reply_count: number;
    quote_count: number;
    bookmark_count: number;
    entities: {
      urls: Array<{ expanded_url: string; display_url: string }>;
      media?: Array<{ media_url_https: string; type: string }>;
    };
    in_reply_to_status_id_str?: string;
  };
  views?: {
    count: string;
  };
}

Key insight: Twitter’s internal API returns far richer data than the official v2 API. You get view counts, bookmark counts, full entity metadata, and quoted tweet chains. The official API gives you a flat subset.

The Two Execution Contexts

Chrome extensions run code in two isolated worlds:

// ISOLATED world (default) — has extension APIs, cannot see page JS
// content.js runs here — can access chrome.runtime, chrome.storage
// CANNOT access window.fetch, XMLHttpRequest as the page sees them

// MAIN world — shares JS context with the page
// interceptor.js runs here — can monkey-patch fetch/XHR
// CANNOT access chrome.runtime or any extension APIs

This distinction is critical. To intercept Twitter’s network requests, your code MUST run in the MAIN world. The ISOLATED world sees a clean XMLHttpRequest and fetch — not the ones Twitter’s app is using.

In Manifest V3, you declare the world in your manifest:

{
  "content_scripts": [
    {
      "matches": ["*://x.com/*", "*://twitter.com/*"],
      "js": ["interceptor.js"],
      "world": "MAIN",
      "run_at": "document_start"
    },
    {
      "matches": ["*://x.com/*", "*://twitter.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ]
}

The MAIN world script communicates with the ISOLATED content script through CustomEvent on window:

// In MAIN world (interceptor.js) — dispatches captured data
window.dispatchEvent(
  new CustomEvent('bookmark:data', {
    detail: { tweets: parsedTweets }
  })
);

// In ISOLATED world (content.js) — receives and forwards to background
window.addEventListener('bookmark:data', (event: CustomEvent) => {
  chrome.runtime.sendMessage({
    type: 'BOOKMARKS_CAPTURED',
    tweets: event.detail.tweets
  });
});

Pagination via Cursor

Twitter uses cursor-based pagination. Each response includes cursor entries at the top and bottom of the entries array:

// Extract the bottom cursor for next page
function extractBottomCursor(entries: TimelineEntry[]): string | null {
  for (const entry of entries) {
    if (
      entry.content.entryType === 'TimelineTimelineCursor' &&
      entry.content.cursorType === 'Bottom'
    ) {
      return entry.content.value ?? null;
    }
  }
  return null;
}

The web app triggers pagination by scrolling. When you reach the bottom, it fires another GraphQL request with the cursor value. This is why auto-scroll is part of every serious export approach.


The best approach. Intercept Twitter’s own network requests, capture the full GraphQL response, and parse it. Zero additional API calls. Full tweet data. Works as long as the web app works.

How it works

  1. A script running in the page context monkey-patches window.fetch and/or XMLHttpRequest.prototype.open
  2. Every time Twitter makes a GraphQL request, the patched function checks if the operation name matches Bookmarks
  3. When it matches, the response body is cloned and parsed
  4. The parsed tweet data is emitted via CustomEvent to the extension’s content script (or stored directly if running as a userscript)

Full implementation: fetch interceptor

Twitter’s web app primarily uses fetch, not XMLHttpRequest. Modern interceptors should patch both, but fetch is the primary target.

// interceptor.ts — MAIN world, document_start
// Monkey-patches window.fetch to passively capture GraphQL bookmark responses.
// Does NOT make any additional network requests.

(function () {
  'use strict';

  // Operations we want to capture
  const TARGET_OPS = new Set([
    'Bookmarks',
    'BookmarksAllDelete_mutation',  // Track deletions too
  ]);

  // Parse the operation name from a GraphQL URL
  function parseGraphQLUrl(url: string): { queryId: string; operationName: string } | null {
    const match = url.match(/\/graphql\/([^/]+)\/([^?]+)/);
    if (!match) return null;
    return { queryId: match[1], operationName: match[2] };
  }

  function isGraphQL(url: string): boolean {
    return url.includes('/i/api/graphql');
  }

  // Emit captured data to the content script via CustomEvent
  function emit(operationName: string, queryId: string, url: string, body: string): void {
    window.dispatchEvent(
      new CustomEvent('bm:graphql', {
        detail: { operationName, queryId, url, body },
      })
    );
  }

  // --- Patch fetch ---
  const originalFetch = window.fetch;

  window.fetch = function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
    const url =
      typeof input === 'string'
        ? input
        : input instanceof URL
          ? input.href
          : (input as Request).url || '';

    const promise = originalFetch.apply(this, arguments as any);

    if (!isGraphQL(url)) return promise;

    const parsed = parseGraphQLUrl(url);
    if (!parsed || !TARGET_OPS.has(parsed.operationName)) return promise;

    const { operationName, queryId } = parsed;

    // Clone the response and read it asynchronously — never block the page
    promise
      .then((response: Response) => {
        const clone = response.clone();
        clone
          .text()
          .then((text: string) => {
            if (text.length > 0 && text[0] === '{') {
              emit(operationName, queryId, url, text);
            }
          })
          .catch(() => {});
      })
      .catch(() => {});

    return promise;
  };

  // --- Patch XMLHttpRequest (fallback) ---
  const origOpen = XMLHttpRequest.prototype.open;
  const origSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.open = function (method: string, url: string | URL) {
    (this as any)._bmUrl = typeof url === 'string' ? url : url.toString();
    return origOpen.apply(this, arguments as any);
  };

  XMLHttpRequest.prototype.send = function () {
    const url: string = (this as any)._bmUrl || '';
    if (isGraphQL(url)) {
      const parsed = parseGraphQLUrl(url);
      if (parsed && TARGET_OPS.has(parsed.operationName)) {
        const { operationName, queryId } = parsed;
        this.addEventListener('load', function () {
          try {
            const text = this.responseText;
            if (text.length > 0 && text[0] === '{') {
              emit(operationName, queryId, url, text);
            }
          } catch (_) {}
        });
      }
    }
    return origSend.apply(this, arguments as any);
  };
})();

Full implementation: response parser

// parser.ts — Parse bookmark GraphQL responses into clean tweet objects

interface ParsedBookmark {
  id: string;
  text: string;
  authorHandle: string;
  authorName: string;
  authorFollowers: number;
  authorVerified: boolean;
  createdAt: string;
  url: string;
  metrics: {
    likes: number;
    retweets: number;
    replies: number;
    quotes: number;
    bookmarks: number;
    views: number;
  };
  media: Array<{
    url: string;
    type: 'photo' | 'video' | 'animated_gif';
  }>;
  urls: Array<{
    expanded: string;
    display: string;
  }>;
  quotedTweet?: ParsedBookmark;
  isReply: boolean;
  inReplyToId?: string;
}

function parseBookmarkResponse(responseText: string): {
  tweets: ParsedBookmark[];
  bottomCursor: string | null;
} {
  const json = JSON.parse(responseText);
  const instructions =
    json.data?.bookmark_timeline_v2?.timeline?.instructions ?? [];

  const addEntries = instructions.find(
    (i: any) => i.type === 'TimelineAddEntries'
  );
  if (!addEntries?.entries) {
    return { tweets: [], bottomCursor: null };
  }

  const tweets: ParsedBookmark[] = [];
  let bottomCursor: string | null = null;

  for (const entry of addEntries.entries) {
    // Handle cursor entries
    if (entry.content?.cursorType === 'Bottom') {
      bottomCursor = entry.content.value;
      continue;
    }

    // Handle tweet entries
    const tweetResult = entry.content?.itemContent?.tweet_results?.result;
    if (!tweetResult || tweetResult.__typename !== 'Tweet') continue;

    const parsed = parseTweetResult(tweetResult);
    if (parsed) tweets.push(parsed);
  }

  return { tweets, bottomCursor };
}

function parseTweetResult(result: any): ParsedBookmark | null {
  try {
    const legacy = result.legacy;
    if (!legacy) return null;

    // User data — Twitter moved fields between core and legacy
    const userResult = result.core?.user_results?.result;
    const screenName =
      userResult?.core?.screen_name ??
      userResult?.legacy?.screen_name ??
      'unknown';
    const name =
      userResult?.core?.name ??
      userResult?.legacy?.name ??
      screenName;
    const followers = userResult?.legacy?.followers_count ?? 0;
    const verified =
      userResult?.is_blue_verified ??
      userResult?.legacy?.verified ??
      false;

    // Media
    const media: ParsedBookmark['media'] = [];
    if (legacy.entities?.media) {
      for (const m of legacy.entities.media) {
        media.push({
          url: m.media_url_https,
          type: m.type as 'photo' | 'video' | 'animated_gif',
        });
      }
    }
    // Extended media for videos
    if (legacy.extended_entities?.media) {
      for (const m of legacy.extended_entities.media) {
        if (m.type === 'video' || m.type === 'animated_gif') {
          const variants = m.video_info?.variants ?? [];
          const best = variants
            .filter((v: any) => v.content_type === 'video/mp4')
            .sort((a: any, b: any) => (b.bitrate ?? 0) - (a.bitrate ?? 0))[0];
          if (best) {
            media.push({ url: best.url, type: m.type });
          }
        }
      }
    }

    // URLs
    const urls: ParsedBookmark['urls'] = [];
    if (legacy.entities?.urls) {
      for (const u of legacy.entities.urls) {
        urls.push({ expanded: u.expanded_url, display: u.display_url });
      }
    }

    // Quoted tweet (recursive)
    let quotedTweet: ParsedBookmark | undefined;
    if (result.quoted_status_result?.result) {
      quotedTweet =
        parseTweetResult(result.quoted_status_result.result) ?? undefined;
    }

    return {
      id: result.rest_id,
      text: legacy.full_text,
      authorHandle: screenName,
      authorName: name,
      authorFollowers: followers,
      authorVerified: verified,
      createdAt: legacy.created_at,
      url: `https://x.com/${screenName}/status/${result.rest_id}`,
      metrics: {
        likes: legacy.favorite_count ?? 0,
        retweets: legacy.retweet_count ?? 0,
        replies: legacy.reply_count ?? 0,
        quotes: legacy.quote_count ?? 0,
        bookmarks: legacy.bookmark_count ?? 0,
        views: parseInt(result.views?.count ?? '0', 10),
      },
      media,
      urls,
      quotedTweet,
      isReply: !!legacy.in_reply_to_status_id_str,
      inReplyToId: legacy.in_reply_to_status_id_str,
    };
  } catch {
    return null;
  }
}

Extension manifest (Manifest V3)

{
  "manifest_version": 3,
  "name": "Twitter Bookmark Exporter",
  "version": "1.0.0",
  "description": "Export your Twitter/X bookmarks as JSON, CSV, or Markdown",
  "permissions": ["activeTab", "storage"],
  "content_scripts": [
    {
      "matches": ["https://x.com/*", "https://twitter.com/*"],
      "js": ["interceptor.js"],
      "world": "MAIN",
      "run_at": "document_start"
    },
    {
      "matches": ["https://x.com/*", "https://twitter.com/*"],
      "js": ["content.js"],
      "run_at": "document_idle"
    }
  ],
  "background": {
    "service_worker": "background.js"
  },
  "action": {
    "default_popup": "popup.html"
  },
  "icons": {
    "16": "icons/icon16.png",
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  }
}

Critical: The "world": "MAIN" declaration is what makes this work. Without it, your content script runs in the ISOLATED world and cannot see Twitter’s fetch calls. This was added in Chrome 102 for Manifest V3 extensions. In Manifest V2, you had to inject a <script> tag manually.

Content script bridge

// content.ts — ISOLATED world, bridges MAIN world events to background

const allBookmarks: any[] = [];

// Listen for intercepted GraphQL data from MAIN world
window.addEventListener('bm:graphql', ((event: CustomEvent) => {
  const { operationName, body } = event.detail;

  if (operationName === 'Bookmarks') {
    try {
      const { tweets, bottomCursor } = parseBookmarkResponse(body);
      allBookmarks.push(...tweets);

      // Forward to background script for storage
      chrome.runtime.sendMessage({
        type: 'BOOKMARKS_BATCH',
        tweets,
        totalCount: allBookmarks.length,
        hasMore: !!bottomCursor,
      });

      // Update badge with count
      chrome.runtime.sendMessage({
        type: 'UPDATE_BADGE',
        count: allBookmarks.length,
      });
    } catch (err) {
      console.error('[bookmark-exporter] Parse error:', err);
    }
  }
}) as EventListener);

// Listen for export commands from popup
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message.type === 'GET_BOOKMARKS') {
    sendResponse({ bookmarks: allBookmarks });
  }
  if (message.type === 'EXPORT_JSON') {
    downloadJSON(allBookmarks);
    sendResponse({ success: true });
  }
  if (message.type === 'EXPORT_CSV') {
    downloadCSV(allBookmarks);
    sendResponse({ success: true });
  }
  if (message.type === 'EXPORT_MARKDOWN') {
    downloadMarkdown(allBookmarks);
    sendResponse({ success: true });
  }
  return true; // Keep channel open for async response
});

function downloadJSON(bookmarks: any[]): void {
  const blob = new Blob([JSON.stringify(bookmarks, null, 2)], {
    type: 'application/json',
  });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `twitter-bookmarks-${new Date().toISOString().split('T')[0]}.json`;
  a.click();
  URL.revokeObjectURL(url);
}

function downloadCSV(bookmarks: any[]): void {
  const headers = [
    'id', 'url', 'author_handle', 'author_name', 'text',
    'created_at', 'likes', 'retweets', 'replies', 'views',
  ];
  const rows = bookmarks.map((b) =>
    headers.map((h) => {
      const val =
        h === 'url' ? b.url :
        h === 'author_handle' ? b.authorHandle :
        h === 'author_name' ? b.authorName :
        h === 'text' ? b.text :
        h === 'created_at' ? b.createdAt :
        h === 'likes' ? b.metrics?.likes :
        h === 'retweets' ? b.metrics?.retweets :
        h === 'replies' ? b.metrics?.replies :
        h === 'views' ? b.metrics?.views :
        b[h] ?? '';
      return `"${String(val).replace(/"/g, '""')}"`;
    }).join(',')
  );
  const csv = [headers.join(','), ...rows].join('\n');
  const blob = new Blob([csv], { type: 'text/csv' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `twitter-bookmarks-${new Date().toISOString().split('T')[0]}.csv`;
  a.click();
  URL.revokeObjectURL(url);
}

function downloadMarkdown(bookmarks: any[]): void {
  const lines = bookmarks.map((b) => {
    const date = new Date(b.createdAt).toISOString().split('T')[0];
    const metrics = `${b.metrics.likes}L ${b.metrics.retweets}RT ${b.metrics.views}V`;
    return [
      `## [@${b.authorHandle}](https://x.com/${b.authorHandle}) — ${date}`,
      '',
      b.text,
      '',
      `[Link](${b.url}) | ${metrics}`,
      b.media.length > 0
        ? b.media.map((m: any) => `![](${m.url})`).join('\n')
        : '',
      '',
      '---',
      '',
    ].filter(Boolean).join('\n');
  });
  const md = `# Twitter Bookmarks Export\n\nExported: ${new Date().toISOString()}\nTotal: ${bookmarks.length} bookmarks\n\n---\n\n${lines.join('\n')}`;
  const blob = new Blob([md], { type: 'text/markdown' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = `twitter-bookmarks-${new Date().toISOString().split('T')[0]}.md`;
  a.click();
  URL.revokeObjectURL(url);
}

Gotchas

  1. Twitter uses fetch, not XHR — Patching only XMLHttpRequest will miss most requests on modern Twitter. Always patch both.
  2. Response cloning — You must response.clone() before reading. If you consume the original response body, Twitter’s app will break.
  3. document_start timing — The interceptor must load before Twitter’s JavaScript. If it loads late, early requests slip through.
  4. Rate limiting — Twitter rate-limits the Bookmarks GraphQL endpoint at roughly 500 requests per 15 minutes. Normal scrolling will not hit this, but programmatic rapid-fire requests will.
  5. Schema drift — Twitter moves fields between core and legacy objects without warning. In March 2026, screen_name moved from user_results.result.legacy to user_results.result.core. Your parser needs fallback paths.

Reference implementation

The gold standard for this approach is prinsss/twitter-web-exporter (3,400+ stars). It runs as a Tampermonkey userscript and supports bookmarks, likes, tweets, lists, and media download. It uses the same GraphQL interception pattern described here but packages it as a userscript instead of a Chrome extension.


The quick-and-dirty approach. Read tweet text directly from the rendered DOM. No network interception, no API calls. Works in the DevTools console with zero setup.

How it works

  1. Navigate to x.com/i/bookmarks
  2. A MutationObserver watches for new DOM nodes
  3. As tweets render, querySelectorAll('[data-testid="tweetText"]') extracts tweet text
  4. window.scrollBy(0, 5000) triggers Twitter’s infinite scroll to load more tweets
  5. When the tweet count is unchanged for 2+ scroll cycles, export is complete

Full implementation: console script

This is based on the gd3kr gist, the most widely shared bookmark export script:

// Paste this into DevTools console on x.com/i/bookmarks
// Scrolls through all bookmarks, captures tweet data, downloads as JSON

(function exportBookmarks() {
  const tweets = [];
  const seenIds = new Set();
  let previousCount = 0;
  let unchangedCount = 0;
  let scrollCount = 0;

  // Extract data from a tweet article element
  function parseTweetElement(article) {
    const tweetText = article.querySelector('[data-testid="tweetText"]');
    const userLink = article.querySelector(
      'a[role="link"][href*="/"]'
    );
    const time = article.querySelector('time');

    // Extract handle from link href
    let handle = '';
    if (userLink) {
      const href = userLink.getAttribute('href');
      if (href && href.startsWith('/')) {
        handle = href.split('/')[1];
      }
    }

    // Extract metrics from aria-labels
    const likeButton = article.querySelector(
      '[data-testid="like"]'
    );
    const retweetButton = article.querySelector(
      '[data-testid="retweet"]'
    );
    const replyButton = article.querySelector(
      '[data-testid="reply"]'
    );

    function getMetric(button) {
      if (!button) return 0;
      const label = button.getAttribute('aria-label') || '';
      const match = label.match(/(\d[\d,]*)/);
      return match ? parseInt(match[1].replace(/,/g, ''), 10) : 0;
    }

    // Build tweet URL from handle + status link
    let tweetUrl = '';
    const statusLink = article.querySelector(
      'a[href*="/status/"]'
    );
    if (statusLink) {
      tweetUrl = 'https://x.com' + statusLink.getAttribute('href');
    }

    // Extract tweet ID from URL
    let tweetId = '';
    if (tweetUrl) {
      const idMatch = tweetUrl.match(/\/status\/(\d+)/);
      if (idMatch) tweetId = idMatch[1];
    }

    return {
      id: tweetId,
      text: tweetText ? tweetText.innerText : '',
      handle: handle,
      url: tweetUrl,
      timestamp: time ? time.getAttribute('datetime') : '',
      likes: getMetric(likeButton),
      retweets: getMetric(retweetButton),
      replies: getMetric(replyButton),
    };
  }

  // MutationObserver to catch new tweets as they render
  const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      if (!mutation.addedNodes.length) continue;

      const articles = document.querySelectorAll(
        'article[data-testid="tweet"]'
      );
      for (const article of articles) {
        const parsed = parseTweetElement(article);
        if (parsed.id && !seenIds.has(parsed.id)) {
          seenIds.add(parsed.id);
          tweets.push(parsed);
        }
      }
    }
  });

  observer.observe(document.body, { childList: true, subtree: true });

  // Also capture any tweets already on the page
  const existing = document.querySelectorAll(
    'article[data-testid="tweet"]'
  );
  for (const article of existing) {
    const parsed = parseTweetElement(article);
    if (parsed.id && !seenIds.has(parsed.id)) {
      seenIds.add(parsed.id);
      tweets.push(parsed);
    }
  }

  console.log(`[bookmark-export] Starting scroll. Initial tweets: ${tweets.length}`);

  // Auto-scroll interval
  const scrollInterval = setInterval(() => {
    window.scrollBy(0, 5000);
    scrollCount++;

    console.log(
      `[bookmark-export] Scroll #${scrollCount}${tweets.length} tweets captured`
    );

    if (tweets.length === previousCount) {
      unchangedCount++;
      if (unchangedCount >= 3) {
        clearInterval(scrollInterval);
        observer.disconnect();

        console.log(
          `[bookmark-export] Done! ${tweets.length} tweets captured in ${scrollCount} scrolls.`
        );

        // Download as JSON
        const blob = new Blob(
          [JSON.stringify(tweets, null, 2)],
          { type: 'application/json' }
        );
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `bookmarks-${Date.now()}.json`;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
      }
    } else {
      unchangedCount = 0;
    }

    previousCount = tweets.length;
  }, 2000);

  // Safety: expose stop function
  window.__stopBookmarkExport = function () {
    clearInterval(scrollInterval);
    observer.disconnect();
    console.log(
      `[bookmark-export] Manually stopped. ${tweets.length} tweets captured.`
    );
    return tweets;
  };

  console.log(
    '[bookmark-export] Running. Call window.__stopBookmarkExport() to stop early.'
  );
})();

Enhanced version with richer DOM extraction

The basic tweetText approach misses media, quoted tweets, and thread context. Here is an enhanced parser:

function parseEnhancedTweetElement(article) {
  // Basic fields
  const base = parseTweetElement(article);

  // Images
  const images = [];
  const imgElements = article.querySelectorAll(
    '[data-testid="tweetPhoto"] img'
  );
  for (const img of imgElements) {
    const src = img.getAttribute('src');
    if (src && src.includes('pbs.twimg.com')) {
      // Get original quality
      images.push(src.replace(/&name=\w+/, '&name=orig'));
    }
  }

  // Video presence (cannot get URL from DOM alone)
  const hasVideo = article.querySelector(
    '[data-testid="videoPlayer"]'
  ) !== null;

  // Quoted tweet
  let quotedTweet = null;
  const quotedArticle = article.querySelector(
    '[data-testid="quoteTweet"]'
  );
  if (quotedArticle) {
    const quotedText = quotedArticle.querySelector(
      '[data-testid="tweetText"]'
    );
    const quotedUser = quotedArticle.querySelector(
      'a[role="link"][href*="/"]'
    );
    quotedTweet = {
      text: quotedText ? quotedText.innerText : '',
      handle: quotedUser
        ? quotedUser.getAttribute('href')?.split('/')[1] ?? ''
        : '',
    };
  }

  // Thread indicator
  const isThread = article.querySelector(
    '[data-testid="tweet-text-show-more-link"]'
  ) !== null;

  // Display name (not just handle)
  let displayName = '';
  const nameSpan = article.querySelector(
    'a[role="link"] span'
  );
  if (nameSpan) {
    displayName = nameSpan.textContent || '';
  }

  return {
    ...base,
    displayName,
    images,
    hasVideo,
    quotedTweet,
    isThread,
  };
}

Limitations

LimitationImpactWorkaround
No engagement metrics beyond likes/retweets/repliesMissing views, bookmarks, quotesUse GraphQL interception instead
Images require additional parsingOnly get URLs, not media metadataParse data-testid="tweetPhoto" containers
Videos cannot be extractedDOM only shows the player, not the video URLNeed network interception for video URLs
Twitter virtualizes the DOMOld tweets are removed as you scroll downMutationObserver captures before removal
data-testid attributes can changeTwitter could rename them at any timeFragile — check after Twitter deploys
Slow for large collections2 seconds per scroll, ~20 tweets per scroll1,000 bookmarks takes ~2 minutes

Key insight: DOM scraping is the fastest way to get started but produces the poorest data. You get tweet text and basic metrics. GraphQL interception gives you the full tweet object including media URLs, view counts, entity metadata, and exact timestamps.

Reference implementations


The legitimate approach. Uses Twitter’s v2 API endpoint GET /2/users/:id/bookmarks. Requires OAuth 2.0 PKCE. Costs $200/month minimum. Returns at most 800 bookmarks.

Setup requirements

  1. Developer account at developer.x.com
  2. Basic tier ($200/month) — The Free tier does not include bookmark access
  3. OAuth 2.0 app with scopes: tweet.read, users.read, bookmark.read
  4. PKCE flow — Bookmarks are user-context-only; app-only tokens do not work

OAuth 2.0 PKCE flow

// oauth.ts — Twitter OAuth 2.0 Authorization Code with PKCE

import crypto from 'crypto';

const CLIENT_ID = process.env.TWITTER_CLIENT_ID!;
const REDIRECT_URI = 'http://localhost:3000/callback';

// Step 1: Generate PKCE challenge
function generatePKCE(): { codeVerifier: string; codeChallenge: string } {
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');
  return { codeVerifier, codeChallenge };
}

// Step 2: Build authorization URL
function getAuthorizationUrl(codeChallenge: string, state: string): string {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'tweet.read users.read bookmark.read offline.access',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });
  return `https://twitter.com/i/oauth2/authorize?${params}`;
}

// Step 3: Exchange code for token
async function exchangeCodeForToken(
  code: string,
  codeVerifier: string
): Promise<{ access_token: string; refresh_token: string }> {
  const response = await fetch('https://api.twitter.com/2/oauth2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code,
      grant_type: 'authorization_code',
      client_id: CLIENT_ID,
      redirect_uri: REDIRECT_URI,
      code_verifier: codeVerifier,
    }),
  });

  if (!response.ok) {
    throw new Error(`Token exchange failed: ${response.status} ${await response.text()}`);
  }

  return response.json();
}

// Step 4: Refresh token (tokens expire in 2 hours)
async function refreshAccessToken(
  refreshToken: string
): Promise<{ access_token: string; refresh_token: string }> {
  const response = await fetch('https://api.twitter.com/2/oauth2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      refresh_token: refreshToken,
      grant_type: 'refresh_token',
      client_id: CLIENT_ID,
    }),
  });

  if (!response.ok) {
    throw new Error(`Token refresh failed: ${response.status}`);
  }

  return response.json();
}

Fetching bookmarks with pagination

// bookmarks-api.ts — Fetch bookmarks via official Twitter API v2

interface TwitterApiBookmark {
  id: string;
  text: string;
  created_at: string;
  author_id: string;
  public_metrics: {
    retweet_count: number;
    reply_count: number;
    like_count: number;
    quote_count: number;
    bookmark_count: number;
    impression_count: number;
  };
  entities?: {
    urls?: Array<{ expanded_url: string; display_url: string }>;
    mentions?: Array<{ username: string }>;
  };
}

interface BookmarksResponse {
  data: TwitterApiBookmark[];
  includes?: {
    users: Array<{ id: string; username: string; name: string }>;
  };
  meta: {
    result_count: number;
    next_token?: string;
  };
}

async function fetchAllBookmarks(
  accessToken: string,
  userId: string
): Promise<TwitterApiBookmark[]> {
  const allBookmarks: TwitterApiBookmark[] = [];
  let paginationToken: string | undefined;
  let pageCount = 0;

  const tweetFields = [
    'created_at',
    'public_metrics',
    'entities',
    'author_id',
    'context_annotations',
  ].join(',');

  const expansions = 'author_id';
  const userFields = 'username,name,profile_image_url,verified';

  do {
    const params = new URLSearchParams({
      'tweet.fields': tweetFields,
      expansions,
      'user.fields': userFields,
      max_results: '100', // Maximum per page
    });

    if (paginationToken) {
      params.set('pagination_token', paginationToken);
    }

    const url = `https://api.twitter.com/2/users/${userId}/bookmarks?${params}`;

    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${accessToken}` },
    });

    if (response.status === 429) {
      // Rate limited — wait and retry
      const resetTime = response.headers.get('x-rate-limit-reset');
      const waitMs = resetTime
        ? (parseInt(resetTime, 10) * 1000 - Date.now()) + 1000
        : 60_000;
      console.log(`Rate limited. Waiting ${Math.ceil(waitMs / 1000)}s...`);
      await new Promise((resolve) => setTimeout(resolve, waitMs));
      continue; // Retry same page
    }

    if (!response.ok) {
      throw new Error(`API error: ${response.status} ${await response.text()}`);
    }

    const body: BookmarksResponse = await response.json();

    if (body.data) {
      allBookmarks.push(...body.data);
    }

    paginationToken = body.meta?.next_token;
    pageCount++;

    console.log(
      `Page ${pageCount}: ${body.data?.length ?? 0} bookmarks ` +
      `(total: ${allBookmarks.length})`
    );

    // Small delay to be respectful
    await new Promise((resolve) => setTimeout(resolve, 500));
  } while (paginationToken);

  return allBookmarks;
}

Limitations

LimitationDetail
$200/month minimumBasic tier required. Free tier has no bookmark access.
800 bookmark capHard limit — the API will not return bookmarks beyond the 800 most recent.
Token expiryAccess tokens expire in 2 hours. Must implement refresh flow.
Fewer fieldsNo view counts, no bookmark counts, no extended media metadata compared to GraphQL.
Rate limit180 requests per 15 minutes per user. With 100 bookmarks per page, that is 18,000 bookmarks per 15 minutes — more than enough given the 800 cap.
No search/filterCannot filter by date, author, or keyword. Get everything or nothing.

Reference implementations

Key insight: The official API is the “correct” way to export bookmarks, but it is also the worst option for most people. $200/month for a maximum of 800 tweets, with less data per tweet than the GraphQL interception method gets for free. The API exists primarily for businesses building Twitter integrations, not for personal bookmark export.


The power user approach. Combine GraphQL interception with a local database (IndexedDB or SQLite) to build a persistent, searchable bookmark archive that survives across browser sessions.

How Twillot works

Twillot is the most sophisticated open-source bookmark manager. It uses GraphQL interception to capture bookmarks, stores them in IndexedDB, and provides folder management, search, and AI-powered categorization.

Architecture:

Browser (x.com)
  └─ MAIN world interceptor ─── captures GraphQL responses
       └─ Content script bridge
            └─ Background service worker
                 └─ IndexedDB (local storage)
                      ├─ tweets table
                      ├─ folders table
                      ├─ tags table (AI-generated)
                      └─ sync_state table

Building your own local archive

// db.ts — IndexedDB storage for captured bookmarks

const DB_NAME = 'bookmark-archive';
const DB_VERSION = 2;

interface StoredBookmark {
  id: string;               // Tweet ID (primary key)
  text: string;
  authorHandle: string;
  authorName: string;
  createdAt: string;        // Original tweet timestamp
  capturedAt: string;       // When we captured it
  url: string;
  metrics: {
    likes: number;
    retweets: number;
    replies: number;
    views: number;
    bookmarks: number;
  };
  media: Array<{ url: string; type: string }>;
  folder?: string;          // User-assigned folder
  tags?: string[];          // AI-generated tags
  notes?: string;           // User annotations
  raw?: string;             // Full GraphQL response for this tweet
}

function openDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onupgradeneeded = (event) => {
      const db = (event.target as IDBOpenDBRequest).result;

      if (!db.objectStoreNames.contains('bookmarks')) {
        const store = db.createObjectStore('bookmarks', { keyPath: 'id' });
        store.createIndex('authorHandle', 'authorHandle', { unique: false });
        store.createIndex('capturedAt', 'capturedAt', { unique: false });
        store.createIndex('folder', 'folder', { unique: false });
      }

      if (!db.objectStoreNames.contains('sync_state')) {
        db.createObjectStore('sync_state', { keyPath: 'key' });
      }
    };

    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
}

async function upsertBookmarks(bookmarks: StoredBookmark[]): Promise<number> {
  const db = await openDB();
  const tx = db.transaction('bookmarks', 'readwrite');
  const store = tx.objectStore('bookmarks');
  let upserted = 0;

  for (const bookmark of bookmarks) {
    // Merge with existing data (preserve user annotations)
    const existing = await new Promise<StoredBookmark | undefined>(
      (resolve) => {
        const req = store.get(bookmark.id);
        req.onsuccess = () => resolve(req.result);
        req.onerror = () => resolve(undefined);
      }
    );

    const merged: StoredBookmark = existing
      ? {
          ...bookmark,
          folder: existing.folder ?? bookmark.folder,
          tags: existing.tags ?? bookmark.tags,
          notes: existing.notes,
          capturedAt: existing.capturedAt, // Keep original capture time
        }
      : { ...bookmark, capturedAt: new Date().toISOString() };

    store.put(merged);
    upserted++;
  }

  await new Promise<void>((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });

  db.close();
  return upserted;
}

async function searchBookmarks(
  query: string,
  options?: {
    folder?: string;
    author?: string;
    limit?: number;
    offset?: number;
  }
): Promise<StoredBookmark[]> {
  const db = await openDB();
  const tx = db.transaction('bookmarks', 'readonly');
  const store = tx.objectStore('bookmarks');
  const results: StoredBookmark[] = [];

  return new Promise((resolve) => {
    const cursor = store.openCursor();
    let skipped = 0;

    cursor.onsuccess = (event) => {
      const result = (event.target as IDBRequest<IDBCursorWithValue>).result;
      if (!result) {
        db.close();
        resolve(results);
        return;
      }

      const bookmark: StoredBookmark = result.value;
      let matches = true;

      // Text search (simple substring)
      if (query && !bookmark.text.toLowerCase().includes(query.toLowerCase())) {
        matches = false;
      }

      // Folder filter
      if (options?.folder && bookmark.folder !== options.folder) {
        matches = false;
      }

      // Author filter
      if (
        options?.author &&
        bookmark.authorHandle.toLowerCase() !== options.author.toLowerCase()
      ) {
        matches = false;
      }

      if (matches) {
        if (options?.offset && skipped < options.offset) {
          skipped++;
        } else {
          results.push(bookmark);
        }
      }

      if (options?.limit && results.length >= options.limit) {
        db.close();
        resolve(results);
        return;
      }

      result.continue();
    };
  });
}

async function getBookmarkCount(): Promise<number> {
  const db = await openDB();
  const tx = db.transaction('bookmarks', 'readonly');
  const store = tx.objectStore('bookmarks');

  return new Promise((resolve) => {
    const countReq = store.count();
    countReq.onsuccess = () => {
      db.close();
      resolve(countReq.result);
    };
  });
}

async function exportAll(
  format: 'json' | 'csv' | 'markdown'
): Promise<string> {
  const db = await openDB();
  const tx = db.transaction('bookmarks', 'readonly');
  const store = tx.objectStore('bookmarks');

  const all: StoredBookmark[] = await new Promise((resolve) => {
    const req = store.getAll();
    req.onsuccess = () => resolve(req.result);
  });

  db.close();

  switch (format) {
    case 'json':
      return JSON.stringify(all, null, 2);
    case 'csv':
      return bookmarksToCsv(all);
    case 'markdown':
      return bookmarksToMarkdown(all);
  }
}

Folder management

// folders.ts — Organize bookmarks into user-defined folders

async function assignFolder(
  bookmarkIds: string[],
  folder: string
): Promise<void> {
  const db = await openDB();
  const tx = db.transaction('bookmarks', 'readwrite');
  const store = tx.objectStore('bookmarks');

  for (const id of bookmarkIds) {
    const req = store.get(id);
    req.onsuccess = () => {
      const bookmark = req.result;
      if (bookmark) {
        bookmark.folder = folder;
        store.put(bookmark);
      }
    };
  }

  await new Promise<void>((resolve, reject) => {
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });

  db.close();
}

async function getFolders(): Promise<Array<{ name: string; count: number }>> {
  const db = await openDB();
  const tx = db.transaction('bookmarks', 'readonly');
  const store = tx.objectStore('bookmarks');
  const folderCounts = new Map<string, number>();

  return new Promise((resolve) => {
    const cursor = store.openCursor();
    cursor.onsuccess = (event) => {
      const result = (event.target as IDBRequest<IDBCursorWithValue>).result;
      if (!result) {
        db.close();
        resolve(
          Array.from(folderCounts.entries())
            .map(([name, count]) => ({ name, count }))
            .sort((a, b) => b.count - a.count)
        );
        return;
      }
      const folder = result.value.folder ?? 'Uncategorized';
      folderCounts.set(folder, (folderCounts.get(folder) ?? 0) + 1);
      result.continue();
    };
  });
}

AI categorization (optional)

// categorize.ts — Use an LLM to auto-tag bookmarks

async function categorizeBookmarks(
  bookmarks: StoredBookmark[],
  apiKey: string
): Promise<Map<string, string[]>> {
  const results = new Map<string, string[]>();

  // Batch bookmarks to reduce API calls (20 per batch)
  const batches: StoredBookmark[][] = [];
  for (let i = 0; i < bookmarks.length; i += 20) {
    batches.push(bookmarks.slice(i, i + 20));
  }

  for (const batch of batches) {
    const tweetSummaries = batch.map(
      (b, i) => `[${i}] @${b.authorHandle}: ${b.text.slice(0, 200)}`
    ).join('\n');

    const response = await fetch('https://api.openai.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${apiKey}`,
      },
      body: JSON.stringify({
        model: 'gpt-4o-mini',
        messages: [
          {
            role: 'system',
            content: `Categorize tweets into 1-3 tags each. Return JSON: {"results": [{"index": 0, "tags": ["tag1", "tag2"]}]}. Use lowercase tags like: programming, ai, business, design, career, writing, productivity, humor, news, science, crypto, startup.`,
          },
          { role: 'user', content: tweetSummaries },
        ],
        response_format: { type: 'json_object' },
        max_tokens: 1000,
      }),
    });

    const json = await response.json();
    const content = JSON.parse(json.choices[0].message.content);

    for (const result of content.results) {
      const bookmark = batch[result.index];
      if (bookmark) {
        results.set(bookmark.id, result.tags);
      }
    }

    // Rate limit courtesy
    await new Promise((r) => setTimeout(r, 500));
  }

  return results;
}

Reference implementation


The zero-install approach. Paste code into DevTools or install a Tampermonkey userscript. No extension, no API keys, no developer account.

Console paste-and-run

The simplest possible export — paste this into DevTools while on x.com/i/bookmarks:

// Minimal bookmark export — paste into DevTools console
// Captures tweet text only (no metrics, no media)

const tweets = [];
const seen = new Set();

const observer = new MutationObserver(() => {
  document.querySelectorAll('[data-testid="tweetText"]').forEach((el) => {
    const text = el.innerText;
    if (!seen.has(text)) {
      seen.add(text);
      tweets.push(text);
    }
  });
});

observer.observe(document.body, { childList: true, subtree: true });

const scroll = setInterval(() => {
  window.scrollBy(0, 5000);
  console.log(`Captured: ${tweets.length}`);
}, 2000);

// Run this when done scrolling:
// clearInterval(scroll); observer.disconnect();
// copy(JSON.stringify(tweets, null, 2));

After all bookmarks load, run clearInterval(scroll); observer.disconnect(); copy(JSON.stringify(tweets, null, 2)); to copy the JSON to clipboard.

Tampermonkey/Greasemonkey userscript

Userscripts run automatically when you visit the bookmarks page. No extension publish process needed.

// ==UserScript==
// @name         Twitter Bookmark Exporter
// @namespace    https://github.com/garywu/twitter-bookmark-export-methods
// @version      1.0.0
// @description  Export Twitter/X bookmarks via GraphQL interception
// @match        https://x.com/*
// @match        https://twitter.com/*
// @grant        GM_download
// @grant        GM_notification
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';

  const capturedBookmarks = [];
  const seenIds = new Set();

  function parseGraphQLUrl(url) {
    const m = url.match(/\/graphql\/([^/]+)\/([^?]+)/);
    return m ? { queryId: m[1], operationName: m[2] } : null;
  }

  function extractTweets(responseText) {
    try {
      const json = JSON.parse(responseText);
      const instructions =
        json.data?.bookmark_timeline_v2?.timeline?.instructions ?? [];
      const addEntries = instructions.find(
        (i) => i.type === 'TimelineAddEntries'
      );
      if (!addEntries?.entries) return [];

      const tweets = [];
      for (const entry of addEntries.entries) {
        const result = entry.content?.itemContent?.tweet_results?.result;
        if (!result || result.__typename !== 'Tweet') continue;

        const legacy = result.legacy;
        if (!legacy) continue;

        const user = result.core?.user_results?.result;
        const handle =
          user?.core?.screen_name ?? user?.legacy?.screen_name ?? 'unknown';
        const name = user?.core?.name ?? user?.legacy?.name ?? handle;

        if (seenIds.has(result.rest_id)) continue;
        seenIds.add(result.rest_id);

        tweets.push({
          id: result.rest_id,
          text: legacy.full_text,
          authorHandle: handle,
          authorName: name,
          createdAt: legacy.created_at,
          url: `https://x.com/${handle}/status/${result.rest_id}`,
          likes: legacy.favorite_count ?? 0,
          retweets: legacy.retweet_count ?? 0,
          replies: legacy.reply_count ?? 0,
          views: parseInt(result.views?.count ?? '0', 10),
          bookmarks: legacy.bookmark_count ?? 0,
          media: (legacy.entities?.media ?? []).map((m) => ({
            url: m.media_url_https,
            type: m.type,
          })),
        });
      }
      return tweets;
    } catch {
      return [];
    }
  }

  // Patch fetch
  const originalFetch = window.fetch;
  window.fetch = function (input, init) {
    const url =
      typeof input === 'string'
        ? input
        : input instanceof URL
          ? input.href
          : input?.url || '';

    const promise = originalFetch.apply(this, arguments);

    if (url.includes('/i/api/graphql')) {
      const parsed = parseGraphQLUrl(url);
      if (parsed?.operationName === 'Bookmarks') {
        promise
          .then((response) => {
            const clone = response.clone();
            clone
              .text()
              .then((text) => {
                const tweets = extractTweets(text);
                if (tweets.length > 0) {
                  capturedBookmarks.push(...tweets);
                  updateUI();
                }
              })
              .catch(() => {});
          })
          .catch(() => {});
      }
    }

    return promise;
  };

  // Floating UI
  function createUI() {
    // Only show on bookmarks page
    if (!location.pathname.includes('/bookmarks')) return;

    const container = document.createElement('div');
    container.id = 'bm-exporter-ui';
    container.innerHTML = `
      <div style="
        position: fixed;
        bottom: 20px;
        right: 20px;
        z-index: 10000;
        background: #1d9bf0;
        color: white;
        padding: 12px 16px;
        border-radius: 12px;
        font-family: -apple-system, sans-serif;
        font-size: 14px;
        box-shadow: 0 4px 12px rgba(0,0,0,0.3);
        cursor: pointer;
        user-select: none;
      ">
        <div id="bm-count">0 bookmarks captured</div>
        <div style="margin-top: 8px; display: flex; gap: 8px;">
          <button id="bm-export-json" style="
            background: white; color: #1d9bf0; border: none;
            padding: 4px 12px; border-radius: 6px; cursor: pointer;
            font-size: 12px; font-weight: 600;
          ">JSON</button>
          <button id="bm-export-csv" style="
            background: white; color: #1d9bf0; border: none;
            padding: 4px 12px; border-radius: 6px; cursor: pointer;
            font-size: 12px; font-weight: 600;
          ">CSV</button>
          <button id="bm-export-md" style="
            background: white; color: #1d9bf0; border: none;
            padding: 4px 12px; border-radius: 6px; cursor: pointer;
            font-size: 12px; font-weight: 600;
          ">MD</button>
          <button id="bm-auto-scroll" style="
            background: white; color: #1d9bf0; border: none;
            padding: 4px 12px; border-radius: 6px; cursor: pointer;
            font-size: 12px; font-weight: 600;
          ">Auto-Scroll</button>
        </div>
      </div>
    `;
    document.body.appendChild(container);

    document.getElementById('bm-export-json').addEventListener('click', () => {
      downloadFile(
        JSON.stringify(capturedBookmarks, null, 2),
        'bookmarks.json',
        'application/json'
      );
    });

    document.getElementById('bm-export-csv').addEventListener('click', () => {
      const csv = bookmarksToCsv(capturedBookmarks);
      downloadFile(csv, 'bookmarks.csv', 'text/csv');
    });

    document.getElementById('bm-export-md').addEventListener('click', () => {
      const md = bookmarksToMd(capturedBookmarks);
      downloadFile(md, 'bookmarks.md', 'text/markdown');
    });

    document.getElementById('bm-auto-scroll').addEventListener('click', startAutoScroll);
  }

  function updateUI() {
    const counter = document.getElementById('bm-count');
    if (counter) {
      counter.textContent = `${capturedBookmarks.length} bookmarks captured`;
    }
  }

  function downloadFile(content, filename, mimeType) {
    const blob = new Blob([content], { type: mimeType });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.click();
    URL.revokeObjectURL(url);
  }

  function bookmarksToCsv(bookmarks) {
    const headers = 'id,url,author,text,created_at,likes,retweets,views\n';
    const rows = bookmarks
      .map(
        (b) =>
          `"${b.id}","${b.url}","${b.authorHandle}","${b.text.replace(/"/g, '""')}","${b.createdAt}",${b.likes},${b.retweets},${b.views}`
      )
      .join('\n');
    return headers + rows;
  }

  function bookmarksToMd(bookmarks) {
    return bookmarks
      .map(
        (b) =>
          `## [@${b.authorHandle}](https://x.com/${b.authorHandle})\n\n${b.text}\n\n[Link](${b.url}) | ${b.likes}L ${b.retweets}RT ${b.views}V\n\n---\n`
      )
      .join('\n');
  }

  // Auto-scroll with completion detection
  let scrolling = false;

  function startAutoScroll() {
    if (scrolling) return;
    scrolling = true;

    let prevCount = capturedBookmarks.length;
    let unchanged = 0;

    const btn = document.getElementById('bm-auto-scroll');
    if (btn) btn.textContent = 'Scrolling...';

    const interval = setInterval(() => {
      window.scrollBy(0, 4000);

      if (capturedBookmarks.length === prevCount) {
        unchanged++;
        if (unchanged >= 4) {
          clearInterval(interval);
          scrolling = false;
          if (btn) btn.textContent = 'Done!';
          GM_notification({
            title: 'Bookmark Export Complete',
            text: `${capturedBookmarks.length} bookmarks captured. Click export.`,
          });
        }
      } else {
        unchanged = 0;
      }
      prevCount = capturedBookmarks.length;
    }, 2000);
  }

  // Initialize UI when on bookmarks page
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', createUI);
  } else {
    createUI();
  }

  // Re-check on navigation (SPA)
  const pushState = history.pushState;
  history.pushState = function () {
    pushState.apply(this, arguments);
    setTimeout(createUI, 1000);
  };
})();

Greasyfork userscript

The Export Twitter Bookmarks script on Greasyfork works by monitoring the HTML DOM (not GraphQL interception). It captures tweet URLs, text, creation dates, and embedded image URLs. Press F2 to download, F4 to print to console.

When to use console scripts vs. userscripts

FactorConsole ScriptUserscript (Tampermonkey)
Setup timeZero — paste and run2 minutes (install Tampermonkey + script)
PersistenceGone when tab closesRuns automatically on every visit
CapabilitiesBasic — no file download APIGM_download, GM_notification, GM_xmlhttpRequest
SecurityYou can read the code before runningScript auto-runs — trust the author
Execution contextRuns in page context (MAIN world)@grant none = page context; with grants = isolated
Best forOne-time exportOngoing passive capture

Recommendation: GraphQL interception + auto-scroll in a Chrome extension. This combines the data richness of Method 1 with the automation of Method 2. Here is how to build it.

Architecture

┌──────────────────────────────────────────┐
│  Chrome Extension                         │
│                                          │
│  interceptor.js (MAIN world)             │
│  ├─ Patches fetch + XHR                  │
│  ├─ Captures Bookmarks GraphQL responses │
│  └─ Emits via CustomEvent                │
│                                          │
│  content.js (ISOLATED world)             │
│  ├─ Receives CustomEvent data            │
│  ├─ Manages auto-scroll state            │
│  ├─ Detects scroll completion            │
│  └─ Forwards to background               │
│                                          │
│  background.js (Service Worker)          │
│  ├─ Stores in IndexedDB                  │
│  ├─ Deduplicates                         │
│  ├─ Manages export formats               │
│  └─ Updates badge count                  │
│                                          │
│  popup.html                              │
│  ├─ Shows capture status                 │
│  ├─ Start/stop auto-scroll              │
│  └─ Export buttons (JSON/CSV/MD)        │
└──────────────────────────────────────────┘

Auto-scroll controller (content script)

// auto-scroll.ts — Smart auto-scroll with completion detection
// Runs in ISOLATED world content script

interface ScrollState {
  isScrolling: boolean;
  capturedCount: number;
  previousCount: number;
  unchangedCycles: number;
  scrollCycles: number;
  startTime: number;
}

const state: ScrollState = {
  isScrolling: false,
  capturedCount: 0,
  previousCount: 0,
  unchangedCycles: 0,
  scrollCycles: 0,
  startTime: 0,
};

let scrollInterval: ReturnType<typeof setInterval> | null = null;

function startAutoScroll(): void {
  if (state.isScrolling) return;

  // Navigate to bookmarks if not already there
  if (!window.location.pathname.includes('/bookmarks')) {
    window.location.href = 'https://x.com/i/bookmarks';
    return;
  }

  state.isScrolling = true;
  state.unchangedCycles = 0;
  state.scrollCycles = 0;
  state.startTime = Date.now();

  chrome.runtime.sendMessage({ type: 'SCROLL_STARTED' });

  scrollInterval = setInterval(() => {
    // Smooth scroll to avoid triggering anti-automation
    window.scrollBy({
      top: 3000,
      behavior: 'smooth',
    });

    state.scrollCycles++;

    // Check completion
    chrome.runtime.sendMessage(
      { type: 'GET_CAPTURE_COUNT' },
      (response) => {
        if (!response) return;
        state.capturedCount = response.count;

        if (state.capturedCount === state.previousCount) {
          state.unchangedCycles++;

          // Wait longer before declaring done (Twitter can be slow to load)
          if (state.unchangedCycles >= 3) {
            stopAutoScroll('complete');
          }
        } else {
          state.unchangedCycles = 0;
        }

        state.previousCount = state.capturedCount;

        // Update popup
        chrome.runtime.sendMessage({
          type: 'SCROLL_PROGRESS',
          captured: state.capturedCount,
          scrolls: state.scrollCycles,
          elapsed: Math.round((Date.now() - state.startTime) / 1000),
        });
      }
    );
  }, 2500); // 2.5s between scrolls — slower is more reliable
}

function stopAutoScroll(reason: 'complete' | 'manual' | 'error'): void {
  if (scrollInterval) {
    clearInterval(scrollInterval);
    scrollInterval = null;
  }

  state.isScrolling = false;

  const elapsed = Math.round((Date.now() - state.startTime) / 1000);

  chrome.runtime.sendMessage({
    type: 'SCROLL_FINISHED',
    reason,
    captured: state.capturedCount,
    scrolls: state.scrollCycles,
    elapsed,
  });
}

// Listen for commands from popup
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  if (message.type === 'START_SCROLL') {
    startAutoScroll();
    sendResponse({ success: true });
  }
  if (message.type === 'STOP_SCROLL') {
    stopAutoScroll('manual');
    sendResponse({ success: true });
  }
  if (message.type === 'GET_SCROLL_STATE') {
    sendResponse({
      isScrolling: state.isScrolling,
      captured: state.capturedCount,
      scrolls: state.scrollCycles,
    });
  }
  return true;
});

Background service worker

// background.ts — Service worker for storage and badge management

const bookmarkStore: Map<string, any> = new Map();

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
  switch (message.type) {
    case 'BOOKMARKS_BATCH': {
      let newCount = 0;
      for (const tweet of message.tweets) {
        if (!bookmarkStore.has(tweet.id)) {
          bookmarkStore.set(tweet.id, tweet);
          newCount++;
        }
      }

      // Update badge
      chrome.action.setBadgeText({
        text: String(bookmarkStore.size),
      });
      chrome.action.setBadgeBackgroundColor({ color: '#1d9bf0' });

      sendResponse({
        stored: newCount,
        total: bookmarkStore.size,
      });
      break;
    }

    case 'GET_CAPTURE_COUNT': {
      sendResponse({ count: bookmarkStore.size });
      break;
    }

    case 'GET_ALL_BOOKMARKS': {
      sendResponse({
        bookmarks: Array.from(bookmarkStore.values()),
      });
      break;
    }

    case 'EXPORT': {
      const bookmarks = Array.from(bookmarkStore.values());
      sendResponse({ bookmarks, format: message.format });
      break;
    }

    case 'SCROLL_FINISHED': {
      // Show notification
      chrome.notifications.create({
        type: 'basic',
        iconUrl: 'icons/icon128.png',
        title: 'Bookmark Export Complete',
        message: `Captured ${message.captured} bookmarks in ${message.elapsed}s`,
      });
      break;
    }
  }

  return true;
});

Why this is the best approach

  1. Full data — GraphQL responses contain everything: text, metrics, media URLs, entities, quoted tweets, view counts
  2. No API cost — Zero dollars. Works with a free Twitter account.
  3. No bookmark limit — Captures every bookmark you scroll past. No 800-tweet cap.
  4. Passive + active — Captures bookmarks as you browse naturally, plus auto-scroll for bulk export
  5. Reliable — Browser extensions are invisible to Twitter’s anti-automation. You are a real user with a real browser.
  6. Persistent — IndexedDB survives browser restarts. Build up your archive over time.

Twitter invests heavily in detecting and blocking automated access. Understanding these defenses helps you choose the right extraction method.

What Twitter detects

SignalDetection MethodRisk Level
Headless browsernavigator.webdriver === true, missing Chrome pluginsHigh
Puppeteer/PlaywrightCDP protocol detection, Chrome flagsHigh
Missing webpackChunkTwitter checks window.webpackChunk_twitter_responsive_web existsMedium
Unusual scroll patternsPerfectly uniform scroll intervalsLow
Request volume>500 GraphQL requests per 15 minutesMedium
Missing cookiesNo ct0, auth_token, or twid cookiesHigh
User-Agent anomaliesHeadless Chrome UA string, mismatched versionsMedium

Why Playwright gets blocked

When you launch Playwright or Puppeteer against Twitter:

// This WILL get blocked
import { chromium } from 'playwright';

const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://x.com/i/bookmarks');
// -> Redirected to login or shown "Something went wrong"

Even in headed mode, Twitter detects automation through:

  1. Chrome DevTools Protocol (CDP) — Playwright communicates with Chrome through CDP. Twitter’s JavaScript can detect CDP commands.
  2. navigator.webdriver — Set to true in automated browsers. Stealth plugins can remove it, but Twitter has secondary checks.
  3. Missing browser state — Automated browsers lack the accumulated cookies, localStorage, and IndexedDB state of a real browsing session.
  4. webpackChunk_twitter_responsive_web — Twitter checks for the existence of its own webpack chunks. A fresh Playwright page that navigates directly to /i/bookmarks may not have these globals initialized.

Why browser extensions work

Extensions bypass all of these checks because:

  1. Real browser — The extension runs inside your actual Chrome with your actual session cookies and browsing history.
  2. No CDP — Extensions use the Chrome Extensions API, not CDP. Twitter cannot detect them through protocol analysis.
  3. MAIN world injection — When a content script runs in "world": "MAIN", it executes in the exact same JavaScript context as Twitter’s code. There is no detectable boundary.
  4. Passive capture — The interceptor does not make requests. It observes responses to requests Twitter’s own code initiated.

Key insight: The fundamental difference is that extensions ride on top of legitimate user activity. Playwright creates synthetic activity from scratch. Twitter can distinguish the two.

CSP (Content Security Policy) considerations

Twitter’s CSP headers restrict script sources. However:

Rate limiting from excessive scrolling

Twitter rate-limits the Bookmarks GraphQL endpoint. If you auto-scroll too aggressively:

// BAD — scrolling too fast triggers rate limits
setInterval(() => window.scrollBy(0, 10000), 500);

// GOOD — give Twitter time to respond between pages
setInterval(() => {
  window.scrollBy({ top: 3000, behavior: 'smooth' });
}, 2500);

With 2.5-second intervals, you load roughly 20 tweets per cycle. A collection of 2,000 bookmarks takes about 4 minutes. This pace is well within Twitter’s rate limits and mimics natural scrolling behavior.


Different use cases need different formats. Here is what each format is good for and how to generate it.

JSON — Full fidelity

JSON preserves the complete tweet object. Use it for programmatic access, database import, or as an intermediate format.

{
  "id": "1876543210987654321",
  "text": "The best way to learn distributed systems is to break one in production.\n\nHere's a thread on what happened when we lost a Kafka partition 🧵",
  "authorHandle": "systemsengineer",
  "authorName": "Systems Engineer",
  "authorFollowers": 45200,
  "authorVerified": true,
  "createdAt": "Sat Feb 15 14:30:00 +0000 2025",
  "url": "https://x.com/systemsengineer/status/1876543210987654321",
  "metrics": {
    "likes": 3842,
    "retweets": 1205,
    "replies": 147,
    "quotes": 89,
    "bookmarks": 2341,
    "views": 892000
  },
  "media": [
    {
      "url": "https://pbs.twimg.com/media/example.jpg",
      "type": "photo"
    }
  ],
  "urls": [
    {
      "expanded": "https://example.com/kafka-incident-report",
      "display": "example.com/kafka-incident…"
    }
  ],
  "quotedTweet": null,
  "isReply": false
}

CSV — Spreadsheet analysis

Flat structure for Excel, Google Sheets, or pandas. Loses nested data (media, URLs) but gains sortability and filterability.

function bookmarksToCsv(bookmarks: ParsedBookmark[]): string {
  const headers = [
    'id',
    'url',
    'author_handle',
    'author_name',
    'author_followers',
    'text',
    'created_at',
    'likes',
    'retweets',
    'replies',
    'quotes',
    'bookmarks',
    'views',
    'has_media',
    'is_reply',
    'link_count',
  ];

  const escape = (val: string | number | boolean): string => {
    const str = String(val);
    if (str.includes(',') || str.includes('"') || str.includes('\n')) {
      return `"${str.replace(/"/g, '""')}"`;
    }
    return str;
  };

  const rows = bookmarks.map((b) =>
    [
      b.id,
      b.url,
      b.authorHandle,
      b.authorName,
      b.authorFollowers,
      b.text.replace(/\n/g, ' '), // Flatten newlines for CSV
      b.createdAt,
      b.metrics.likes,
      b.metrics.retweets,
      b.metrics.replies,
      b.metrics.quotes,
      b.metrics.bookmarks,
      b.metrics.views,
      b.media.length > 0,
      b.isReply,
      b.urls.length,
    ]
      .map(escape)
      .join(',')
  );

  return [headers.join(','), ...rows].join('\n');
}

Markdown — AI/LLM ingestion

Markdown is the ideal format for feeding bookmarks into ChatGPT, Claude, or any LLM-based tool. Structure it for readability and semantic chunking.

function bookmarksToMarkdown(bookmarks: ParsedBookmark[]): string {
  const sections: string[] = [
    `# Twitter Bookmarks Export`,
    ``,
    `Exported: ${new Date().toISOString()}`,
    `Total: ${bookmarks.length} bookmarks`,
    ``,
    `---`,
    ``,
  ];

  // Group by author for better LLM context
  const byAuthor = new Map<string, ParsedBookmark[]>();
  for (const b of bookmarks) {
    const group = byAuthor.get(b.authorHandle) ?? [];
    group.push(b);
    byAuthor.set(b.authorHandle, group);
  }

  // Sort authors by number of bookmarked tweets (descending)
  const sortedAuthors = Array.from(byAuthor.entries()).sort(
    (a, b) => b[1].length - a[1].length
  );

  for (const [handle, tweets] of sortedAuthors) {
    const firstTweet = tweets[0];
    sections.push(
      `## @${handle} (${firstTweet.authorName}) — ${tweets.length} bookmarks`
    );
    sections.push('');

    for (const tweet of tweets) {
      const date = new Date(tweet.createdAt).toISOString().split('T')[0];
      const metrics = [
        `${tweet.metrics.likes.toLocaleString()} likes`,
        `${tweet.metrics.retweets.toLocaleString()} RT`,
        tweet.metrics.views > 0
          ? `${tweet.metrics.views.toLocaleString()} views`
          : '',
      ]
        .filter(Boolean)
        .join(' | ');

      sections.push(`### ${date}`);
      sections.push('');
      sections.push(tweet.text);
      sections.push('');

      if (tweet.urls.length > 0) {
        sections.push(
          'Links: ' +
            tweet.urls.map((u) => `[${u.display}](${u.expanded})`).join(', ')
        );
        sections.push('');
      }

      if (tweet.media.length > 0) {
        for (const m of tweet.media) {
          sections.push(`![${m.type}](${m.url})`);
        }
        sections.push('');
      }

      sections.push(`[Original](${tweet.url}) | ${metrics}`);
      sections.push('');
      sections.push('---');
      sections.push('');
    }
  }

  return sections.join('\n');
}

Obsidian vault format

For personal knowledge management, export each bookmark as a separate Markdown note with YAML frontmatter:

function bookmarksToObsidian(bookmarks: ParsedBookmark[]): Map<string, string> {
  const files = new Map<string, string>();

  for (const b of bookmarks) {
    const date = new Date(b.createdAt).toISOString().split('T')[0];
    const filename = `${date}-${b.authorHandle}-${b.id}.md`;

    const content = [
      '---',
      `id: "${b.id}"`,
      `author: "${b.authorHandle}"`,
      `author_name: "${b.authorName}"`,
      `date: ${b.createdAt}`,
      `url: ${b.url}`,
      `likes: ${b.metrics.likes}`,
      `retweets: ${b.metrics.retweets}`,
      `views: ${b.metrics.views}`,
      `tags:`,
      `  - twitter-bookmark`,
      `  - "@${b.authorHandle}"`,
      '---',
      '',
      b.text,
      '',
      b.urls.length > 0
        ? `## Links\n${b.urls.map((u) => `- [${u.display}](${u.expanded})`).join('\n')}`
        : '',
      '',
      `## Source`,
      `[Original Tweet](${b.url})`,
    ]
      .filter(Boolean)
      .join('\n');

    files.set(filename, content);
  }

  return files;
}

Format comparison

FormatBest forData lossFile size (1000 tweets)LLM-friendly
JSONProgrammatic use, database importNone~2-5 MBPoor (too verbose)
CSVSpreadsheet analysis, sorting, filteringMedia, nested data~500 KBPoor
Markdown (single file)LLM ingestion, readingSome formatting~1-2 MBExcellent
Markdown (per-tweet)Obsidian, PKM toolsNone~2-4 MB totalGood
HTMLBrowser viewingSome metadata~3-5 MBPoor

All five methods compared

FactorGraphQL InterceptionDOM ScrapingOfficial APILocal DB (Twillot)Console/Userscript
Data richnessFull tweet object, media URLs, metrics, entities, quoted tweets, view countsTweet text, basic metrics from aria-labels, images from DOMTweet text, public metrics, entities, author info — no views, no bookmark countsFull tweet object (same as GraphQL)Varies — DOM-based gets text only; GraphQL-based gets full data
Bookmark limitUnlimited (scroll-based)Unlimited (scroll-based)800 max (hard API cap)Unlimited (scroll-based)Unlimited (scroll-based)
CostFreeFree$200/month (Basic tier)FreeFree
Setup complexityMedium (build extension or install userscript)Low (paste console script)High (developer account, OAuth app, code)Low (install from Chrome Web Store)Very low (paste and run)
AutomationYes (auto-scroll in extension)Yes (built-in auto-scroll)Yes (pagination loop)Yes (sync on visit)Manual (must initiate)
Auth methodYour browser session (cookies)Your browser session (DOM)OAuth 2.0 PKCE tokenYour browser session (cookies)Your browser session (cookies/DOM)
Anti-bot riskVery low (passive, real browser)Very low (DOM reads only)None (official endpoint)Very low (passive, real browser)Very low (runs in console)
FragilityMedium (GraphQL schema can change)High (DOM data-testid can change)Low (versioned API)Medium (same as GraphQL)High (DOM or GraphQL changes break it)
Media downloadYes (URLs in response)Partial (images only, no video URLs)Partial (media keys, need separate lookup)Yes (URLs in response)Depends on implementation
Offline searchNo (unless you add storage)NoNoYes (IndexedDB)No
Incremental syncPossible with cursor trackingNo (full re-scroll)Possible with pagination tokensYes (built-in)No

Detailed pros and cons

MethodProsCons
GraphQL InterceptionRichest data; free; no API limits; works with any account; passive captureRequires extension or userscript; schema can drift; needs auto-scroll for bulk export
DOM ScrapingSimplest setup; zero dependencies; works in DevTools; easy to understandPoorest data quality; no video URLs; fragile selectors; slow for large collections
Official APIStable; documented; legal certainty; no browser needed$200/month; 800 bookmark cap; less data than GraphQL; requires OAuth setup
Local DB (Twillot)Full data; search; folders; AI tags; persistent; incrementalBlack box (closed-source Chrome extension); depends on third-party maintenance
Console/UserscriptZero install; instant; runs anywhere; easy to auditNot persistent; manual trigger; no automation; varies wildly in quality

Do you need to export Twitter bookmarks?

├─ One-time export, don't care about media?
│  └─ Use METHOD 2: DOM Scraping (paste console script, done in 5 minutes)

├─ One-time export, want full data?
│  └─ Use METHOD 5: Tampermonkey userscript with GraphQL interception
│     Install Tampermonkey → install twitter-web-exporter → scroll → export

├─ Ongoing capture, building a personal archive?
│  ├─ Want something pre-built?
│  │  └─ Use METHOD 4: Install Twillot from Chrome Web Store
│  │
│  └─ Want full control?
│     └─ Use METHOD 1+2 HYBRID: Build the Chrome extension from this article
│        GraphQL interception + auto-scroll + IndexedDB storage

├─ Building a product that integrates with Twitter bookmarks?
│  └─ Use METHOD 3: Official API (legal certainty, stable, documented)
│     Budget $200/month for Basic tier

└─ Just want tweet text for an LLM?
   └─ Use METHOD 2: Console script, export as Markdown
      Paste, scroll, copy, done

My recommendation for most developers: Install prinsss/twitter-web-exporter as a Tampermonkey userscript. It uses GraphQL interception, supports bookmarks and many other data types, and requires zero code. If you want to build your own system, use the hybrid approach (Method 1 + 2) from this article as your starting point.


Example 1: Extract bookmarks to clipboard (one-liner)

// Run on x.com/i/bookmarks after scrolling through all bookmarks
copy(JSON.stringify(
  [...document.querySelectorAll('[data-testid="tweetText"]')]
    .map(el => el.innerText),
  null, 2
));

Example 2: Count bookmarks by author

function countByAuthor(bookmarks: ParsedBookmark[]): Map<string, number> {
  const counts = new Map<string, number>();
  for (const b of bookmarks) {
    counts.set(b.authorHandle, (counts.get(b.authorHandle) ?? 0) + 1);
  }
  return new Map(
    [...counts.entries()].sort((a, b) => b[1] - a[1])
  );
}

// Usage:
const authors = countByAuthor(bookmarks);
for (const [handle, count] of authors) {
  console.log(`@${handle}: ${count} bookmarks`);
}

Example 3: Find bookmarks with the most engagement

function topBookmarks(
  bookmarks: ParsedBookmark[],
  metric: keyof ParsedBookmark['metrics'] = 'likes',
  limit = 20
): ParsedBookmark[] {
  return [...bookmarks]
    .sort((a, b) => b.metrics[metric] - a.metrics[metric])
    .slice(0, limit);
}

// Top 20 most-liked bookmarks
const top = topBookmarks(bookmarks, 'likes', 20);
top.forEach((b, i) => {
  console.log(`${i + 1}. ${b.metrics.likes}L — @${b.authorHandle}: ${b.text.slice(0, 80)}...`);
});

Example 4: Extract all image URLs from bookmarks

function extractMediaUrls(
  bookmarks: ParsedBookmark[]
): Array<{ tweetId: string; url: string; type: string }> {
  const media: Array<{ tweetId: string; url: string; type: string }> = [];
  for (const b of bookmarks) {
    for (const m of b.media) {
      media.push({
        tweetId: b.id,
        url: m.type === 'photo'
          ? m.url.replace(/&name=\w+/, '&name=orig') // Get original quality
          : m.url,
        type: m.type,
      });
    }
  }
  return media;
}

// Download all images
const allMedia = extractMediaUrls(bookmarks);
console.log(`Found ${allMedia.length} media items across ${bookmarks.length} bookmarks`);

Example 5: Detect when bookmark page is fully loaded (no scrolling needed)

// Wait for Twitter's loading spinner to disappear
function waitForBookmarksLoaded() {
  return new Promise((resolve) => {
    const check = setInterval(() => {
      const spinner = document.querySelector('[role="progressbar"]');
      const tweets = document.querySelectorAll('article[data-testid="tweet"]');
      if (!spinner && tweets.length > 0) {
        clearInterval(check);
        resolve(tweets.length);
      }
    }, 500);
  });
}

// Usage:
waitForBookmarksLoaded().then((count) => {
  console.log(`Page loaded with ${count} visible tweets`);
});

Example 6: Filter bookmarks by date range

function filterByDateRange(
  bookmarks: ParsedBookmark[],
  start: Date,
  end: Date
): ParsedBookmark[] {
  return bookmarks.filter((b) => {
    const date = new Date(b.createdAt);
    return date >= start && date <= end;
  });
}

// Get bookmarks from 2025 only
const bookmarks2025 = filterByDateRange(
  bookmarks,
  new Date('2025-01-01'),
  new Date('2025-12-31')
);
console.log(`${bookmarks2025.length} bookmarks from 2025`);
function generateReadingList(bookmarks: ParsedBookmark[]): string {
  // Filter bookmarks that contain URLs (likely articles/resources)
  const withLinks = bookmarks.filter((b) => b.urls.length > 0);

  const lines = withLinks.map((b) => {
    const links = b.urls
      .map((u) => `[${u.display}](${u.expanded})`)
      .join(', ');
    return `- **@${b.authorHandle}**: ${b.text.slice(0, 100).replace(/\n/g, ' ')}... — ${links}`;
  });

  return `# Reading List from Bookmarks\n\n${lines.join('\n')}`;
}

Example 8: Detect duplicate bookmarks (same text, different tweets)

function findDuplicates(
  bookmarks: ParsedBookmark[]
): Map<string, ParsedBookmark[]> {
  const textMap = new Map<string, ParsedBookmark[]>();

  for (const b of bookmarks) {
    // Normalize text for comparison
    const normalized = b.text.toLowerCase().trim().replace(/\s+/g, ' ');
    const group = textMap.get(normalized) ?? [];
    group.push(b);
    textMap.set(normalized, group);
  }

  // Return only groups with duplicates
  return new Map(
    [...textMap.entries()].filter(([, tweets]) => tweets.length > 1)
  );
}

Example 9: Monitor for new GraphQL operation names (discovery)

// Paste in DevTools to see ALL GraphQL operations Twitter makes
(function() {
  const seen = new Set();
  const origFetch = window.fetch;
  window.fetch = function(input) {
    const url = typeof input === 'string' ? input : input?.url || '';
    if (url.includes('/i/api/graphql')) {
      const match = url.match(/\/graphql\/([^/]+)\/([^?]+)/);
      if (match && !seen.has(match[2])) {
        seen.add(match[2]);
        console.log(`[GraphQL] ${match[2]} (queryId: ${match[1]})`);
      }
    }
    return origFetch.apply(this, arguments);
  };
  console.log('GraphQL operation monitor active. Browse Twitter normally.');
})();

Example 10: Export bookmarks as OPML (for RSS readers)

function bookmarksToOPML(bookmarks: ParsedBookmark[]): string {
  // Extract unique authors and create an OPML subscription list
  const authors = new Map<string, { name: string; handle: string }>();
  for (const b of bookmarks) {
    if (!authors.has(b.authorHandle)) {
      authors.set(b.authorHandle, {
        name: b.authorName,
        handle: b.authorHandle,
      });
    }
  }

  const outlines = [...authors.values()]
    .map(
      (a) =>
        `    <outline text="@${a.handle} (${a.name})" ` +
        `type="rss" ` +
        `xmlUrl="https://nitter.net/${a.handle}/rss" ` +
        `htmlUrl="https://x.com/${a.handle}" />`
    )
    .join('\n');

  return `<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
  <head><title>Twitter Bookmarked Authors</title></head>
  <body>
    <outline text="Bookmarked Authors">
${outlines}
    </outline>
  </body>
</opml>`;
}

Don’tDo InsteadWhy
Use Playwright/Puppeteer to scrape bookmarksUse a Chrome extension with "world": "MAIN"Twitter detects headless browsers via CDP, navigator.webdriver, and missing webpackChunk globals. Extensions are invisible.
Make direct GraphQL requests with stolen auth tokensIntercept responses passively from normal browsingDirect requests risk account suspension. Passive interception makes zero additional API calls.
Parse only [data-testid="tweetText"] and call it doneParse the full article element for handle, metrics, media, and tweet URLText-only export loses 80% of the useful data.
Scroll at setInterval(fn, 200) for speedUse 2-2.5 second intervals with behavior: 'smooth'Fast scrolling triggers rate limits and can get the bookmarks page temporarily throttled.
Store bookmarks in localStorageUse IndexedDB for structured storagelocalStorage has a 5-10 MB limit and no indexing. IndexedDB handles millions of records with indexes for search.
Rely on user_results.result.legacy.screen_name onlyCheck core.screen_name first, fall back to legacy.screen_nameTwitter moved user fields from legacy to core in March 2026. Hard-coded paths break silently.
Pay $200/month for the official API to export personal bookmarksUse GraphQL interception (free, richer data, no 800 cap)The official API is designed for businesses building integrations, not personal export.
Build a full extension when you need a one-time exportPaste a console script or install a userscriptOver-engineering a one-time task. Console scripts take 30 seconds.
Assume data-testid attributes are stableCheck for attribute changes after Twitter deploysTwitter can rename data-testid="tweetText" to anything at any time. These are internal test identifiers, not a public API.
Fetch the same bookmarks page repeatedly without cursor trackingTrack the bottom cursor and resume from where you left offWithout cursor tracking, you re-process the same tweets every time and waste time scrolling through already-captured content.
Run GraphQL interceptor in ISOLATED content script worldUse "world": "MAIN" in manifest.jsonISOLATED world scripts cannot see the page’s fetch or XMLHttpRequest. They get a clean prototype chain that Twitter’s code does not use.
Use response.json() to read intercepted fetch responsesUse response.clone().text() then JSON.parseReading .json() on the original response consumes the body. Twitter’s app then gets an empty response and breaks. Always clone first.

Official Documentation

Open Source Implementations

Userscripts

Blog Posts and Guides


For reference, here is the complete response structure from Twitter’s internal Bookmarks GraphQL endpoint:

{
  "data": {
    "bookmark_timeline_v2": {
      "timeline": {
        "instructions": [
          {
            "type": "TimelineAddEntries",
            "entries": [
              {
                "entryId": "tweet-1876543210987654321",
                "sortIndex": "1876543210987654321",
                "content": {
                  "entryType": "TimelineTimelineItem",
                  "__typename": "TimelineTimelineItem",
                  "itemContent": {
                    "__typename": "TimelineTweet",
                    "tweet_results": {
                      "result": {
                        "__typename": "Tweet",
                        "rest_id": "1876543210987654321",
                        "core": {
                          "user_results": {
                            "result": {
                              "__typename": "User",
                              "id": "VXNlcjoxMjM0NTY3ODk=",
                              "rest_id": "123456789",
                              "core": {
                                "screen_name": "exampleuser",
                                "name": "Example User"
                              },
                              "legacy": {
                                "screen_name": "exampleuser",
                                "name": "Example User",
                                "followers_count": 15420,
                                "friends_count": 892,
                                "verified": false,
                                "profile_image_url_https": "https://pbs.twimg.com/profile_images/.../photo.jpg"
                              },
                              "is_blue_verified": true
                            }
                          }
                        },
                        "legacy": {
                          "full_text": "This is the full tweet text including any t.co links https://t.co/abc123",
                          "created_at": "Sat Feb 15 14:30:00 +0000 2025",
                          "favorite_count": 3842,
                          "retweet_count": 1205,
                          "reply_count": 147,
                          "quote_count": 89,
                          "bookmark_count": 2341,
                          "lang": "en",
                          "in_reply_to_status_id_str": null,
                          "in_reply_to_user_id_str": null,
                          "entities": {
                            "urls": [
                              {
                                "url": "https://t.co/abc123",
                                "expanded_url": "https://example.com/article",
                                "display_url": "example.com/article"
                              }
                            ],
                            "user_mentions": [],
                            "hashtags": [],
                            "media": [
                              {
                                "media_url_https": "https://pbs.twimg.com/media/example.jpg",
                                "type": "photo",
                                "sizes": {
                                  "large": { "w": 1200, "h": 800 },
                                  "medium": { "w": 900, "h": 600 },
                                  "small": { "w": 450, "h": 300 },
                                  "thumb": { "w": 150, "h": 150 }
                                }
                              }
                            ]
                          }
                        },
                        "views": {
                          "count": "892341",
                          "state": "EnabledWithCount"
                        },
                        "quoted_status_result": null
                      }
                    }
                  }
                }
              },
              {
                "entryId": "cursor-bottom-1876543210987654320",
                "sortIndex": "1876543210987654320",
                "content": {
                  "entryType": "TimelineTimelineCursor",
                  "__typename": "TimelineTimelineCursor",
                  "value": "DAACDAABCgABGMKg...",
                  "cursorType": "Bottom"
                }
              },
              {
                "entryId": "cursor-top-1876543210987654322",
                "sortIndex": "1876543210987654322",
                "content": {
                  "entryType": "TimelineTimelineCursor",
                  "__typename": "TimelineTimelineCursor",
                  "value": "DAABCgABGMKg...",
                  "cursorType": "Top"
                }
              }
            ]
          }
        ]
      }
    }
  }
}

A complete Chrome extension for bookmark export follows this structure:

twitter-bookmark-exporter/
├── manifest.json           # MV3 manifest with MAIN world content script
├── interceptor.js          # MAIN world — patches fetch/XHR
├── content.js              # ISOLATED world — bridge + auto-scroll
├── background.js           # Service worker — storage + badge
├── popup.html              # Extension popup UI
├── popup.js                # Popup logic — start/stop, export buttons
├── parser.js               # GraphQL response parser
├── db.js                   # IndexedDB wrapper
├── export.js               # JSON/CSV/Markdown formatters
└── icons/
    ├── icon16.png
    ├── icon48.png
    └── icon128.png

The key architectural decision is splitting the interceptor (MAIN world) from the controller (ISOLATED world). The interceptor is intentionally minimal — it patches network functions and emits events. All logic (parsing, storage, export) lives in the ISOLATED world where it has access to Chrome extension APIs.


Published: 2026-03-15. All code examples are TypeScript/JavaScript and run in Chrome 102+ or Firefox 128+. The GraphQL response shapes reflect Twitter’s internal API as of March 2026 and may change without notice.


Edit page
Share this post on:

Previous Post
Troubleshooting tldraw with TanStack Start
Next Post
Video Creation Resources