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:
- Why Twitter deliberately prevents bookmark export and what technical constraints exist
- How to intercept Twitter’s internal GraphQL responses to capture full tweet data passively
- How to scrape bookmarks from the DOM with auto-scroll for a quick-and-dirty export
- How to use the official Bookmarks API (and why it costs $200/month for 800 tweets)
- How to build a local-first bookmark database with IndexedDB and folder management
- How to combine GraphQL interception with auto-scroll for the best hybrid approach
- How Twitter detects automation and why browser extensions bypass those defenses
- How to structure exported data for AI/LLM ingestion, CSV analysis, or archival
- The Problem
- Core Concepts
- Method 1: GraphQL/XHR Interception
- Method 2: DOM Scraping with Auto-Scroll
- Method 3: Official Twitter API
- Method 4: Hybrid Local Database
- Method 5: Console Scripts and Userscripts
- The Best Hybrid Approach
- Handling Anti-Automation
- Data Formats and Export Targets
- Comparison Matrix
- Decision Tree
- Anti-Patterns
- 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
- Engagement retention — Bookmarks give you a reason to come back. An exported list does not.
- 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.
- API tier gating — The Bookmarks API endpoint exists but requires the Basic tier ($200/month). This prices out casual users and individual developers.
- 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
- You cannot search your bookmarks (Twitter added search in 2023, but it is unreliable for large collections)
- You cannot back up bookmarks before they vanish (deleted tweets disappear from bookmarks silently)
- You cannot use bookmarks in external tools — note-taking apps, AI assistants, research databases
- You cannot migrate bookmarks if you switch accounts
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
- A script running in the page context monkey-patches
window.fetchand/orXMLHttpRequest.prototype.open - Every time Twitter makes a GraphQL request, the patched function checks if the operation name matches
Bookmarks - When it matches, the response body is cloned and parsed
- The parsed tweet data is emitted via
CustomEventto 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’sfetchcalls. 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) => ``).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
- Twitter uses
fetch, not XHR — Patching onlyXMLHttpRequestwill miss most requests on modern Twitter. Always patch both. - Response cloning — You must
response.clone()before reading. If you consume the original response body, Twitter’s app will break. document_starttiming — The interceptor must load before Twitter’s JavaScript. If it loads late, early requests slip through.- 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.
- Schema drift — Twitter moves fields between
coreandlegacyobjects without warning. In March 2026,screen_namemoved fromuser_results.result.legacytouser_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
- Navigate to
x.com/i/bookmarks - A
MutationObserverwatches for new DOM nodes - As tweets render,
querySelectorAll('[data-testid="tweetText"]')extracts tweet text window.scrollBy(0, 5000)triggers Twitter’s infinite scroll to load more tweets- 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
| Limitation | Impact | Workaround |
|---|---|---|
| No engagement metrics beyond likes/retweets/replies | Missing views, bookmarks, quotes | Use GraphQL interception instead |
| Images require additional parsing | Only get URLs, not media metadata | Parse data-testid="tweetPhoto" containers |
| Videos cannot be extracted | DOM only shows the player, not the video URL | Need network interception for video URLs |
| Twitter virtualizes the DOM | Old tweets are removed as you scroll down | MutationObserver captures before removal |
data-testid attributes can change | Twitter could rename them at any time | Fragile — check after Twitter deploys |
| Slow for large collections | 2 seconds per scroll, ~20 tweets per scroll | 1,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
- sahil-lalani/bookmark-export (450+ stars) — Chrome extension using DOM scraping
- gd3kr/948296cf675469f5028911f8eb276dbc — The original console gist
- ShreyaPrasad1209/Export-Twitter-bookmarks-in-markdown — DevTools + Python for Markdown output
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
- Developer account at developer.x.com
- Basic tier ($200/month) — The Free tier does not include bookmark access
- OAuth 2.0 app with scopes:
tweet.read,users.read,bookmark.read - 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
| Limitation | Detail |
|---|---|
| $200/month minimum | Basic tier required. Free tier has no bookmark access. |
| 800 bookmark cap | Hard limit — the API will not return bookmarks beyond the 800 most recent. |
| Token expiry | Access tokens expire in 2 hours. Must implement refresh flow. |
| Fewer fields | No view counts, no bookmark counts, no extended media metadata compared to GraphQL. |
| Rate limit | 180 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/filter | Cannot filter by date, author, or keyword. Get everything or nothing. |
Reference implementations
- xdevplatform/export-bookmarks — Official Twitter sample, Flask + OAuth 2.0
- nornagon/twitter-bookmark-archiver — Node.js, downloads media, generates HTML viewer
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
- twillot-app/twillot — Full-featured bookmark manager with folders, search, and AI tags
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
| Factor | Console Script | Userscript (Tampermonkey) |
|---|---|---|
| Setup time | Zero — paste and run | 2 minutes (install Tampermonkey + script) |
| Persistence | Gone when tab closes | Runs automatically on every visit |
| Capabilities | Basic — no file download API | GM_download, GM_notification, GM_xmlhttpRequest |
| Security | You can read the code before running | Script auto-runs — trust the author |
| Execution context | Runs in page context (MAIN world) | @grant none = page context; with grants = isolated |
| Best for | One-time export | Ongoing 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
- Full data — GraphQL responses contain everything: text, metrics, media URLs, entities, quoted tweets, view counts
- No API cost — Zero dollars. Works with a free Twitter account.
- No bookmark limit — Captures every bookmark you scroll past. No 800-tweet cap.
- Passive + active — Captures bookmarks as you browse naturally, plus auto-scroll for bulk export
- Reliable — Browser extensions are invisible to Twitter’s anti-automation. You are a real user with a real browser.
- 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
| Signal | Detection Method | Risk Level |
|---|---|---|
| Headless browser | navigator.webdriver === true, missing Chrome plugins | High |
| Puppeteer/Playwright | CDP protocol detection, Chrome flags | High |
Missing webpackChunk | Twitter checks window.webpackChunk_twitter_responsive_web exists | Medium |
| Unusual scroll patterns | Perfectly uniform scroll intervals | Low |
| Request volume | >500 GraphQL requests per 15 minutes | Medium |
| Missing cookies | No ct0, auth_token, or twid cookies | High |
| User-Agent anomalies | Headless Chrome UA string, mismatched versions | Medium |
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:
- Chrome DevTools Protocol (CDP) — Playwright communicates with Chrome through CDP. Twitter’s JavaScript can detect CDP commands.
navigator.webdriver— Set totruein automated browsers. Stealth plugins can remove it, but Twitter has secondary checks.- Missing browser state — Automated browsers lack the accumulated cookies, localStorage, and IndexedDB state of a real browsing session.
webpackChunk_twitter_responsive_web— Twitter checks for the existence of its own webpack chunks. A fresh Playwright page that navigates directly to/i/bookmarksmay not have these globals initialized.
Why browser extensions work
Extensions bypass all of these checks because:
- Real browser — The extension runs inside your actual Chrome with your actual session cookies and browsing history.
- No CDP — Extensions use the Chrome Extensions API, not CDP. Twitter cannot detect them through protocol analysis.
- 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. - 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:
- Extensions are exempt from CSP — Chrome’s content script injection bypasses the page’s CSP entirely. This is by design in the extensions API.
- Userscripts (Tampermonkey) are also exempt — Tampermonkey injects scripts outside the CSP scope.
- Inline
<script>injection is blocked — If you try to inject a script tag from a content script running in the ISOLATED world, CSP will block it. Use"world": "MAIN"in the manifest instead.
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(``);
}
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
| Format | Best for | Data loss | File size (1000 tweets) | LLM-friendly |
|---|---|---|---|---|
| JSON | Programmatic use, database import | None | ~2-5 MB | Poor (too verbose) |
| CSV | Spreadsheet analysis, sorting, filtering | Media, nested data | ~500 KB | Poor |
| Markdown (single file) | LLM ingestion, reading | Some formatting | ~1-2 MB | Excellent |
| Markdown (per-tweet) | Obsidian, PKM tools | None | ~2-4 MB total | Good |
| HTML | Browser viewing | Some metadata | ~3-5 MB | Poor |
All five methods compared
| Factor | GraphQL Interception | DOM Scraping | Official API | Local DB (Twillot) | Console/Userscript |
|---|---|---|---|---|---|
| Data richness | Full tweet object, media URLs, metrics, entities, quoted tweets, view counts | Tweet text, basic metrics from aria-labels, images from DOM | Tweet text, public metrics, entities, author info — no views, no bookmark counts | Full tweet object (same as GraphQL) | Varies — DOM-based gets text only; GraphQL-based gets full data |
| Bookmark limit | Unlimited (scroll-based) | Unlimited (scroll-based) | 800 max (hard API cap) | Unlimited (scroll-based) | Unlimited (scroll-based) |
| Cost | Free | Free | $200/month (Basic tier) | Free | Free |
| Setup complexity | Medium (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) |
| Automation | Yes (auto-scroll in extension) | Yes (built-in auto-scroll) | Yes (pagination loop) | Yes (sync on visit) | Manual (must initiate) |
| Auth method | Your browser session (cookies) | Your browser session (DOM) | OAuth 2.0 PKCE token | Your browser session (cookies) | Your browser session (cookies/DOM) |
| Anti-bot risk | Very low (passive, real browser) | Very low (DOM reads only) | None (official endpoint) | Very low (passive, real browser) | Very low (runs in console) |
| Fragility | Medium (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 download | Yes (URLs in response) | Partial (images only, no video URLs) | Partial (media keys, need separate lookup) | Yes (URLs in response) | Depends on implementation |
| Offline search | No (unless you add storage) | No | No | Yes (IndexedDB) | No |
| Incremental sync | Possible with cursor tracking | No (full re-scroll) | Possible with pagination tokens | Yes (built-in) | No |
Detailed pros and cons
| Method | Pros | Cons |
|---|---|---|
| GraphQL Interception | Richest data; free; no API limits; works with any account; passive capture | Requires extension or userscript; schema can drift; needs auto-scroll for bulk export |
| DOM Scraping | Simplest setup; zero dependencies; works in DevTools; easy to understand | Poorest data quality; no video URLs; fragile selectors; slow for large collections |
| Official API | Stable; 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; incremental | Black box (closed-source Chrome extension); depends on third-party maintenance |
| Console/Userscript | Zero install; instant; runs anywhere; easy to audit | Not 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`);
Example 7: Generate a reading list from bookmarks with links
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’t | Do Instead | Why |
|---|---|---|
| Use Playwright/Puppeteer to scrape bookmarks | Use 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 tokens | Intercept responses passively from normal browsing | Direct requests risk account suspension. Passive interception makes zero additional API calls. |
Parse only [data-testid="tweetText"] and call it done | Parse the full article element for handle, metrics, media, and tweet URL | Text-only export loses 80% of the useful data. |
Scroll at setInterval(fn, 200) for speed | Use 2-2.5 second intervals with behavior: 'smooth' | Fast scrolling triggers rate limits and can get the bookmarks page temporarily throttled. |
Store bookmarks in localStorage | Use IndexedDB for structured storage | localStorage 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 only | Check core.screen_name first, fall back to legacy.screen_name | Twitter 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 bookmarks | Use 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 export | Paste a console script or install a userscript | Over-engineering a one-time task. Console scripts take 30 seconds. |
Assume data-testid attributes are stable | Check for attribute changes after Twitter deploys | Twitter 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 tracking | Track the bottom cursor and resume from where you left off | Without 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 world | Use "world": "MAIN" in manifest.json | ISOLATED 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 responses | Use response.clone().text() then JSON.parse | Reading .json() on the original response consumes the body. Twitter’s app then gets an empty response and breaks. Always clone first. |
Official Documentation
- Twitter API v2 Bookmarks Introduction — Official endpoint docs, rate limits, field reference
- Twitter API v2 Bookmarks Integration Guide — OAuth 2.0 PKCE setup, request/response examples
- Twitter API Rate Limits — Per-endpoint rate limit tables
- Twitter API v2 Authentication Mapping — Which auth method for which endpoint
- Chrome Manifest V3 Content Scripts —
world: "MAIN"documentation, execution contexts - MDN Content Scripts — Cross-browser content script reference
Open Source Implementations
- prinsss/twitter-web-exporter — Gold standard GraphQL interception userscript (3,400+ stars). Exports bookmarks, tweets, likes, lists, media. Runs via Tampermonkey.
- sahil-lalani/bookmark-export — Chrome extension using DOM scraping (450+ stars). Simple, effective, limited data.
- twillot-app/twillot — Full bookmark manager extension. IndexedDB storage, folders, search, AI categorization.
- xdevplatform/export-bookmarks — Official Twitter sample app. Flask + OAuth 2.0 PKCE, exports to CSV.
- nornagon/twitter-bookmark-archiver — Node.js archiver using official API. Downloads media, generates HTML viewer (129 stars).
- gd3kr/948296cf675469f5028911f8eb276dbc — The original console gist for DOM-based bookmark scraping.
- ShreyaPrasad1209/Export-Twitter-bookmarks-in-markdown — DevTools extraction + Python Markdown conversion.
- xdevplatform/bookmarks-to-notion — Official sample: export bookmarks to Notion via API.
Userscripts
- Export Twitter Bookmarks (Greasyfork) — DOM-based userscript. F2 to download, F4 to console. Captures text, URLs, images.
- Twitter Web Exporter (Greasyfork) — prinsss’s userscript distribution on Greasyfork.
Blog Posts and Guides
- API Design of X (Twitter) Home Timeline — Deep dive into Twitter’s GraphQL timeline response structure,
TimelineAddEntries, cursor pagination. - Export your Bookmarks with Flask, OAuth 2.0, and Render — Step-by-step walkthrough of the official API approach.
- How to Inject a Global with Web Extensions in Manifest V3 — Explains
"world": "MAIN"for MV3 content scripts. - How to Avoid Bot Detection with Playwright — Why headless browsers get blocked and stealth techniques.
- Scraping Twitter with Headless Playwright — Practical guide with anti-detection notes.
- X (Twitter) API Pricing 2026: All Tiers Compared — Breakdown of Free ($0), Basic ($200/mo), Pro ($5K/mo), Enterprise ($42K+/mo) tiers.
- The Best Twitter Bookmarks Extension in 2025 — Twillot’s own comparison of bookmark management tools.
- How to Get More Than 800 Bookmarks (X Developer Community) — Community discussion confirming the 800 cap is a hard limit.
Related Tools
- Tampermonkey — The most popular userscript manager for Chrome, Firefox, Edge.
- Violentmonkey — Open-source alternative to Tampermonkey.
- yt-dlp — Download videos from tweet URLs when DOM scraping cannot extract video URLs directly.
- twitter-api-client — Unofficial Python client for Twitter’s internal GraphQL API.
- twitter-openapi — Reverse-engineered OpenAPI spec for Twitter’s internal endpoints.
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.