
X Bookmarks Fetcher
- 13 installs
- 44 repo stars
- Updated July 8, 2026
- aviz85/claude-skills-library
x-bookmarks-fetcher is a Claude skill that downloads X/Twitter bookmarks via the official X API v2 with OAuth 2.0, saving text and media locally.
About
x-bookmarks-fetcher downloads X/Twitter bookmarks through the official X API v2 with OAuth 2.0 user-context auth, requiring a one-time browser consent and then a refresh token. A developer uses it to export or save bookmarked tweets, with the text, images, and videos written to a local folder. It filters by a time window on tweet created_at and works on the X API Free tier.
- Fetches X/Twitter bookmarks via the official X API v2, no scraping
- OAuth 2.0 user-context auth: one-time browser consent, then refresh-token forever
- Saves tweet text, metadata, images, and videos to a local folder
X Bookmarks Fetcher by the numbers
- 13 all-time installs (skills.sh)
- Ranked #1,438 of 2,719 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
x-bookmarks-fetcher capabilities & compatibility
Requires an X developer app with OAuth 2.0 credentials; available on the X API Free tier (rate limited).
- Capabilities
- youtube downloader · whatsapp
- Use cases
- web scraping · research
- Pricing
- Bring your own API key
What x-bookmarks-fetcher says it does
Download X/Twitter bookmarks via the official X API v2 (no scraping, no Chrome session).
Available on Free tier (rate limited — currently ~10 requests per 15 min)
Auth: OAuth 2.0 User Context with `bookmark.read` scope
npx skills add https://github.com/aviz85/claude-skills-library --skill x-bookmarks-fetcherAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 13 |
|---|---|
| repo stars | ★ 44 |
| Last updated | July 8, 2026 |
| Repository | aviz85/claude-skills-library ↗ |
What it does
Export or download recent X/Twitter bookmarks with media to a local folder via the official X API v2.
Who is it for?
Exporting or saving recent X bookmarks with their images and videos to a local folder.
Skip if: Scraping X or using a Chrome session; it uses the official API, not scraping.
When should I use this skill?
User asks to grab, download, or export their X bookmarks or pull recent saved posts from X/Twitter.
What you get
A local folder of bookmarked tweets with text, metadata, images, and videos.
- bookmarks-raw.json
- index.json / index.md
- Per-bookmark text files
By the numbers
- Default 48-hour window
- 20 max API pages, 100 bookmarks each
- Free tier ~10 requests per 15 min
Files
X Bookmarks Fetcher
Download X/Twitter bookmarks via the official X API v2 (no scraping, no Chrome session). Saves tweet text, metadata, images, and videos to a local folder.
First-time setup
If scripts/.env does not yet exist, follow SETUP.md in this skill folder. It walks through: 1. Creating an X developer app with OAuth 2.0 enabled 2. Setting the callback URL to http://127.0.0.1:8765/callback 3. Copying client ID/secret into .env 4. Running npm run auth (one-time browser consent → saves refresh token)
Once scripts/.env and scripts/.tokens.json exist, fetching is pure API.
Quick Start
cd ~/.claude/skills/x-bookmarks-fetcher/scripts
# One-time auth (opens browser for consent, saves tokens)
node auth.mjs
# Fetch bookmarks from the last 48 hours (default)
node fetch.mjs
# Custom hours window
node fetch.mjs --hours 24
# Custom output folder
node fetch.mjs --hours 48 --out /tmp/my-bookmarks
# Specific time range (ISO timestamps)
node fetch.mjs --since 2026-05-19T00:00:00ZOptions for fetch.mjs
| Option | Default | Description |
|---|---|---|
--hours <N> | 48 | Filter to tweets created within last N hours |
--since <iso> | derived from --hours | Lower bound (ISO 8601 timestamp). Overrides --hours |
--out <dir> | /tmp/x-bookmarks-YYYYMMDD | Output directory |
--max-pages <N> | 20 | Stop after N API pages (100 bookmarks each) |
--no-media | off | Skip downloading images/videos |
What gets saved
<out>/
├── bookmarks-raw.json # Full API response (incl. includes for users, media, referenced tweets)
├── index.json # Parsed/flattened items
├── index.md # Human-readable summary
├── <handle>_<id>.txt # One file per bookmark with text + URL + media list
└── media/
├── <media_key>.jpg # Photos
├── <media_key>.mp4 # Highest-bitrate MP4 for videos/GIFs
└── <media_key>_poster.jpg # Video thumbnailsFiltering note
The X API returns bookmarks in bookmarking-order (most recently saved first), but doesn't expose the bookmark timestamp. The --hours / --since filter is applied to tweet created_at (when the post was published). For most uses this approximates "what I saved recently" — but if you bookmarked an old tweet today, it will be excluded.
API tier requirements
- Endpoint:
GET /2/users/:id/bookmarks - Auth: OAuth 2.0 User Context with
bookmark.readscope - Available on Free tier (rate limited — currently ~10 requests per 15 min)
Files in this skill
SKILL.md— this fileREADME.md— same content as SKILL.md, for non-Claude readersSETUP.md— first-time setup walkthroughscripts/auth.mjs— OAuth 2.0 PKCE flow (opens browser once)scripts/fetch.mjs— bookmark fetcher + media downloaderscripts/.env.example— credential templatescripts/.gitignore— ignores.envand.tokens.json
scripts/.env
scripts/.tokens.json
scripts/node_modules/
*.log
.DS_Store
x-bookmarks-fetcher
Standalone Claude Code skill that downloads X (Twitter) bookmarks via the official X API v2 — no Chrome session, no scraping, no third-party services. Pure HTTPS + OAuth 2.0.
Saves each bookmark's text, metadata, and media (images + videos) into a local folder you can grep, archive, feed to an LLM, or pipe into anything else.
Why this exists
X's bookmark UI is hostile to power users:
- Bookmarks are buried, slow, and don't expose timestamps.
- The official "Download your data" archive omits bookmarks entirely.
- Most third-party tools either scrape (fragile + ToS-risky) or want your Twitter login (sketchy).
This skill uses the same API endpoint an X-employed engineer would use: GET /2/users/:id/bookmarks. It needs a developer app (free) and a one-time browser consent. After that, every fetch is a pure API call.
Install
cd ~/.claude/skills
git clone <this-repo> x-bookmarks-fetcher # or copy the folder
cd x-bookmarks-fetcherNo npm install needed — the scripts use only Node.js stdlib (https, crypto, fs, http, url). Tested on Node ≥ 18.
First-time setup
See [SETUP.md](./SETUP.md) for the full walkthrough. TL;DR:
1. Create an X developer app at https://developer.x.com — enable OAuth 2.0 and set callback URL to http://127.0.0.1:8765/callback. 2. cp scripts/.env.example scripts/.env and fill in X_CLIENT_ID + X_CLIENT_SECRET. 3. node scripts/auth.mjs — browser opens, click Authorize, tokens save automatically.
Usage
cd scripts
# Last 48 hours of bookmarks (default)
node fetch.mjs
# Last 24h
node fetch.mjs --hours 24
# Everything since a specific timestamp
node fetch.mjs --since 2026-05-19T00:00:00Z
# Custom output folder
node fetch.mjs --out ~/Downloads/x-bookmarksOutput layout
/tmp/x-bookmarks-20260520/
├── bookmarks-raw.json # untouched API response
├── index.json # parsed list (id, url, text, author, media refs, downloaded files)
├── index.md # readable markdown summary
├── @handle_tweetid.txt # one per bookmark — author, URL, full text
└── media/
├── <media_key>.jpg
├── <media_key>.mp4 # highest-bitrate MP4 variant
└── <media_key>_poster.jpgHow it filters
The X API returns bookmarks in bookmarking-order (newest save first), but doesn't expose the bookmark timestamp itself. The --hours / --since filter applies to tweet `created_at` (publication time of the post you saved). That's a close-enough proxy for "what did I save recently" — the only false-negatives are old tweets you bookmarked today.
Security
scripts/.envandscripts/.tokens.jsonare in.gitignore.- Tokens are stored with
chmod 600. - All HTTPS calls go directly to
api.twitter.comandapi.x.com— no third-party servers in the loop. - Refresh token auto-rotates every 2 hours. Lost the token file? Just run
node auth.mjsagain.
Limitations
- Free tier rate limit: ~10 requests per 15 minutes on this endpoint. The script handles 429s by waiting for the reset timestamp.
- Page size: 100 bookmarks per request.
- No bookmark-timestamp: the API never returns when you bookmarked something.
- No cursoring back in time past a few hundred bookmarks on Free tier in practice.
License
MIT. Share freely.
# X (Twitter) Developer App credentials — get them from
# https://developer.x.com/en/portal/dashboard → your app → User authentication settings
# Required scopes / settings:
# - App permissions: Read (or Read and write)
# - Type of App: Web App, Automated App or Bot (enables OAuth 2.0 Confidential Client)
# - Callback URI: http://127.0.0.1:8765/callback
X_CLIENT_ID=your-oauth2-client-id-here
X_CLIENT_SECRET=your-oauth2-client-secret-here
# Optional override (defaults to 8765). If you change this, also update
# the Callback URI in the X developer portal and CALLBACK_URL below.
# CALLBACK_PORT=8765
.env
.tokens.json
*.log
node_modules/
#!/usr/bin/env node
// X OAuth 2.0 Authorization Code Flow with PKCE
// Opens browser once, exchanges code for tokens, saves to .tokens.json next to this script.
// Re-run anytime to refresh the tokens file from scratch.
import crypto from "node:crypto";
import http from "node:http";
import https from "node:https";
import fs from "node:fs";
import path from "node:path";
import { execFile } from "node:child_process";
import { URL, URLSearchParams } from "node:url";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ENV_PATH = path.join(SCRIPT_DIR, ".env");
const TOKENS_PATH = path.join(SCRIPT_DIR, ".tokens.json");
if (!fs.existsSync(ENV_PATH)) {
console.error(`\nMissing ${ENV_PATH}\n`);
console.error(`Copy .env.example to .env and fill in X_CLIENT_ID + X_CLIENT_SECRET.`);
console.error(`See SETUP.md for instructions.\n`);
process.exit(1);
}
const env = Object.fromEntries(
fs.readFileSync(ENV_PATH, "utf8")
.split("\n")
.filter(l => l.includes("=") && !l.trim().startsWith("#"))
.map(l => { const i = l.indexOf("="); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; })
);
const CLIENT_ID = env.X_CLIENT_ID;
const CLIENT_SECRET = env.X_CLIENT_SECRET;
const PORT = parseInt(env.CALLBACK_PORT || "8765", 10);
const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
const SCOPES = ["tweet.read", "users.read", "bookmark.read", "offline.access"].join(" ");
if (!CLIENT_ID || CLIENT_ID.includes("your-")) {
console.error("X_CLIENT_ID is not set. Edit scripts/.env. See SETUP.md.");
process.exit(1);
}
if (!CLIENT_SECRET || CLIENT_SECRET.includes("your-")) {
console.error("X_CLIENT_SECRET is not set. Edit scripts/.env. See SETUP.md.");
process.exit(1);
}
function b64url(buf) {
return buf.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
const codeVerifier = b64url(crypto.randomBytes(64));
const codeChallenge = b64url(crypto.createHash("sha256").update(codeVerifier).digest());
const state = b64url(crypto.randomBytes(16));
const authUrl = new URL("https://twitter.com/i/oauth2/authorize");
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", CLIENT_ID);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authUrl.searchParams.set("scope", SCOPES);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", codeChallenge);
authUrl.searchParams.set("code_challenge_method", "S256");
console.log("\n=== X OAuth 2.0 setup ===\n");
console.log("Callback URI (must match the one registered in the X dev portal):");
console.log(" " + REDIRECT_URI + "\n");
console.log("Opening this URL to authorize:");
console.log(" " + authUrl.toString() + "\n");
function exchangeCode(code) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams({
code,
grant_type: "authorization_code",
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier,
}).toString();
const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
const r = https.request("https://api.twitter.com/2/oauth2/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
Authorization: `Basic ${basic}`,
},
}, res => {
let d = ""; res.on("data", c => d += c); res.on("end", () => resolve({ status: res.statusCode, body: d }));
});
r.on("error", reject); r.write(body); r.end();
});
}
const server = http.createServer(async (req, res) => {
const u = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (u.pathname !== "/callback") {
res.writeHead(404); res.end("not found"); return;
}
const code = u.searchParams.get("code");
const gotState = u.searchParams.get("state");
const err = u.searchParams.get("error");
if (err) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end(`<h2>Authorization error</h2><pre>${err}\n${u.searchParams.get("error_description") || ""}</pre>`);
console.error("\nAuth error:", err, u.searchParams.get("error_description") || "");
server.close(); process.exit(1);
}
if (gotState !== state) {
res.writeHead(400); res.end("state mismatch");
console.error("\nState mismatch — possible CSRF. Re-run.");
server.close(); process.exit(1);
}
try {
const tk = await exchangeCode(code);
if (tk.status !== 200) {
res.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
res.end(`<h2>Token exchange failed</h2><pre>${tk.body}</pre>`);
console.error("\nToken exchange failed:", tk.status, tk.body);
server.close(); process.exit(1);
}
const tokens = JSON.parse(tk.body);
tokens.obtained_at = Date.now();
fs.writeFileSync(TOKENS_PATH, JSON.stringify(tokens, null, 2));
fs.chmodSync(TOKENS_PATH, 0o600);
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(`<h2>Authorized</h2><p>Tokens saved to ${TOKENS_PATH}. You can close this tab.</p>`);
console.log("\nTokens saved to:", TOKENS_PATH);
console.log(" scopes:", tokens.scope);
console.log(" access_token expires in:", tokens.expires_in, "seconds");
console.log("\nYou can now run: node fetch.mjs\n");
setTimeout(() => { server.close(); process.exit(0); }, 500);
} catch (e) {
res.writeHead(500); res.end(String(e));
console.error(e); server.close(); process.exit(1);
}
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`Listening on ${REDIRECT_URI} ...\n`);
const opener = process.platform === "darwin" ? "open"
: process.platform === "win32" ? "start"
: "xdg-open";
execFile(opener, [authUrl.toString()], () => {});
});
setTimeout(() => {
console.error("\nTimed out waiting for authorization (5 min).");
server.close(); process.exit(1);
}, 5 * 60 * 1000);
#!/usr/bin/env node
// Fetch X bookmarks via OAuth 2.0 user-context, filter by tweet created_at,
// save text + download images/videos.
import https from "node:https";
import fs from "node:fs";
import path from "node:path";
import { URL, URLSearchParams } from "node:url";
import { fileURLToPath } from "node:url";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ENV_PATH = path.join(SCRIPT_DIR, ".env");
const TOKENS_PATH = path.join(SCRIPT_DIR, ".tokens.json");
if (!fs.existsSync(ENV_PATH) || !fs.existsSync(TOKENS_PATH)) {
console.error("\nMissing .env or .tokens.json. Run `node auth.mjs` first.");
console.error("See SETUP.md for the full first-time setup walkthrough.\n");
process.exit(1);
}
const env = Object.fromEntries(
fs.readFileSync(ENV_PATH, "utf8")
.split("\n")
.filter(l => l.includes("=") && !l.trim().startsWith("#"))
.map(l => { const i = l.indexOf("="); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; })
);
let tokens = JSON.parse(fs.readFileSync(TOKENS_PATH, "utf8"));
// ---- args ----
function getArg(name, def = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return def;
return process.argv[i + 1];
}
function hasFlag(name) {
return process.argv.includes(`--${name}`);
}
const hours = parseInt(getArg("hours", "48"), 10);
const sinceArg = getArg("since");
const outArg = getArg("out");
const maxPages = parseInt(getArg("max-pages", "20"), 10);
const skipMedia = hasFlag("no-media");
const since = sinceArg ? new Date(sinceArg) : new Date(Date.now() - hours * 3600 * 1000);
if (isNaN(since.getTime())) {
console.error(`Invalid --since: ${sinceArg}`); process.exit(1);
}
const dateStamp = new Date().toISOString().slice(0, 10).replace(/-/g, "");
const outRoot = outArg || `/tmp/x-bookmarks-${dateStamp}`;
const mediaDir = path.join(outRoot, "media");
fs.mkdirSync(outRoot, { recursive: true });
fs.mkdirSync(mediaDir, { recursive: true });
// ---- token helpers ----
function saveTokens(t) {
t.obtained_at = Date.now();
fs.writeFileSync(TOKENS_PATH, JSON.stringify(t, null, 2));
fs.chmodSync(TOKENS_PATH, 0o600);
tokens = t;
}
function tokenExpired() {
return Date.now() > tokens.obtained_at + (tokens.expires_in - 60) * 1000;
}
function httpsRequest(opts, body) {
return new Promise((resolve, reject) => {
const r = https.request(opts, res => {
let d = ""; res.on("data", c => d += c);
res.on("end", () => resolve({ status: res.statusCode, body: d, headers: res.headers }));
});
r.on("error", reject);
if (body) r.write(body);
r.end();
});
}
async function refreshIfNeeded() {
if (!tokenExpired()) return;
console.log("[*] Refreshing access token...");
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: tokens.refresh_token,
client_id: env.X_CLIENT_ID,
}).toString();
const basic = Buffer.from(`${env.X_CLIENT_ID}:${env.X_CLIENT_SECRET}`).toString("base64");
const r = await httpsRequest({
method: "POST",
hostname: "api.twitter.com",
path: "/2/oauth2/token",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
Authorization: `Basic ${basic}`,
},
}, body);
if (r.status !== 200) throw new Error(`Refresh failed ${r.status}: ${r.body}\nRe-run \`node auth.mjs\`.`);
saveTokens(JSON.parse(r.body));
console.log("[*] Token refreshed.");
}
async function apiGet(url) {
await refreshIfNeeded();
const u = new URL(url);
return httpsRequest({
method: "GET",
hostname: u.hostname,
path: u.pathname + u.search,
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
}
function download(url, dest) {
return new Promise((resolve, reject) => {
const u = new URL(url);
const f = fs.createWriteStream(dest);
https.get({ hostname: u.hostname, path: u.pathname + u.search, headers: { "User-Agent": "x-bookmarks-fetcher/1.0" } }, res => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
f.close();
try { fs.unlinkSync(dest); } catch {}
return download(res.headers.location, dest).then(resolve, reject);
}
if (res.statusCode !== 200) {
f.close();
try { fs.unlinkSync(dest); } catch {}
return reject(new Error(`HTTP ${res.statusCode} for ${url}`));
}
res.pipe(f);
f.on("finish", () => f.close(() => resolve(dest)));
f.on("error", reject);
}).on("error", reject);
});
}
async function getMe() {
const r = await apiGet("https://api.twitter.com/2/users/me");
if (r.status !== 200) throw new Error("users/me failed: " + r.body);
return JSON.parse(r.body).data;
}
async function getAllBookmarks(userId, sinceDate) {
const baseParams = new URLSearchParams({
"max_results": "100",
"tweet.fields": "created_at,author_id,attachments,entities,public_metrics,referenced_tweets,conversation_id,lang,note_tweet",
"expansions": "author_id,attachments.media_keys,referenced_tweets.id,referenced_tweets.id.author_id",
"user.fields": "username,name,verified",
"media.fields": "type,url,preview_image_url,variants,alt_text,duration_ms,height,width",
});
const all = { data: [], includes: { users: [], media: [], tweets: [] } };
let token = null;
let pages = 0;
const sinceMs = sinceDate.getTime();
while (pages < maxPages) {
const params = new URLSearchParams(baseParams);
if (token) params.set("pagination_token", token);
const url = `https://api.twitter.com/2/users/${userId}/bookmarks?` + params.toString();
const r = await apiGet(url);
if (r.status === 429) {
const reset = parseInt(r.headers["x-rate-limit-reset"] || "0", 10) * 1000;
const waitMs = Math.max(reset - Date.now(), 5000);
console.log(`[!] Rate limited, waiting ${Math.round(waitMs / 1000)}s...`);
await new Promise(res => setTimeout(res, waitMs));
continue;
}
if (r.status !== 200) throw new Error(`bookmarks failed ${r.status}: ${r.body}`);
const j = JSON.parse(r.body);
pages++;
const items = j.data || [];
all.data.push(...items);
if (j.includes?.users) all.includes.users.push(...j.includes.users);
if (j.includes?.media) all.includes.media.push(...j.includes.media);
if (j.includes?.tweets) all.includes.tweets.push(...j.includes.tweets);
console.log(`[*] Page ${pages}: +${items.length} bookmarks (total ${all.data.length})`);
// Heuristic: bookmarks come in bookmark-order (not created_at order). Always pull at least
// 2 pages to be safe, then stop when no item on the page is within the time window.
if (pages >= 2) {
const anyRecent = items.some(t => new Date(t.created_at).getTime() >= sinceMs);
if (!anyRecent) { console.log(`[*] No tweets on this page within window — stopping.`); break; }
}
token = j.meta?.next_token;
if (!token) break;
await new Promise(res => setTimeout(res, 800));
}
return all;
}
function pickVideoVariant(variants) {
const mp4 = (variants || []).filter(v => v.content_type === "video/mp4" && typeof v.bit_rate === "number");
if (!mp4.length) return null;
mp4.sort((a, b) => b.bit_rate - a.bit_rate);
return mp4[0];
}
async function downloadMedia(media, outDir) {
if (!media) return [];
const key = media.media_key;
const tasks = [];
if (media.type === "photo" && media.url) {
const ext = path.extname(new URL(media.url).pathname) || ".jpg";
tasks.push({ url: media.url, dest: path.join(outDir, `${key}${ext}`) });
} else if (media.type === "video" || media.type === "animated_gif") {
const v = pickVideoVariant(media.variants);
if (v) tasks.push({ url: v.url, dest: path.join(outDir, `${key}.mp4`) });
if (media.preview_image_url) tasks.push({ url: media.preview_image_url, dest: path.join(outDir, `${key}_poster.jpg`) });
}
const saved = [];
for (const t of tasks) {
try { await download(t.url, t.dest); saved.push(path.basename(t.dest)); }
catch (e) { console.error(` download failed: ${t.url}: ${e.message}`); }
}
return saved;
}
function sanitize(s) { return s.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 80); }
async function main() {
console.log(`[*] Filter: tweets created since ${since.toISOString()}`);
console.log(`[*] Output: ${outRoot}`);
const me = await getMe();
console.log(`[*] Authenticated as @${me.username} (id ${me.id})`);
const result = await getAllBookmarks(me.id, since);
const usersById = Object.fromEntries(result.includes.users.map(u => [u.id, u]));
const mediaByKey = Object.fromEntries(result.includes.media.map(m => [m.media_key, m]));
const sinceMs = since.getTime();
const filtered = result.data.filter(t => new Date(t.created_at).getTime() >= sinceMs);
console.log(`[*] ${filtered.length} of ${result.data.length} bookmarks are within the time window`);
fs.writeFileSync(path.join(outRoot, "bookmarks-raw.json"), JSON.stringify(result, null, 2));
const items = [];
for (const t of filtered) {
const user = usersById[t.author_id];
const handle = user?.username || t.author_id;
const url = `https://x.com/${handle}/status/${t.id}`;
const mediaKeys = t.attachments?.media_keys || [];
const mediaItems = mediaKeys.map(k => mediaByKey[k]).filter(Boolean);
const downloadedFiles = [];
if (!skipMedia) {
for (const m of mediaItems) {
const files = await downloadMedia(m, mediaDir);
downloadedFiles.push(...files);
}
}
const text = t.note_tweet?.text || t.text || "";
items.push({
id: t.id,
url,
created_at: t.created_at,
author: { id: t.author_id, username: handle, name: user?.name },
text,
lang: t.lang,
public_metrics: t.public_metrics,
media: mediaItems.map(m => ({
media_key: m.media_key,
type: m.type,
alt_text: m.alt_text,
duration_ms: m.duration_ms,
width: m.width,
height: m.height,
})),
downloaded_files: downloadedFiles,
});
const slug = sanitize(`${handle}_${t.id}`);
fs.writeFileSync(
path.join(outRoot, `${slug}.txt`),
`@${handle} — ${t.created_at}\n${url}\n\n${text}\n` +
(downloadedFiles.length ? `\n[media: ${downloadedFiles.join(", ")}]\n` : "")
);
}
fs.writeFileSync(path.join(outRoot, "index.json"), JSON.stringify(items, null, 2));
const md = [
`# X Bookmarks`,
``,
`Fetched: ${new Date().toISOString()}`,
`Filter: tweets created since ${since.toISOString()}`,
`Authenticated as: @${me.username}`,
`Count: ${items.length}`,
``,
...items.map(it => {
const lines = [
`## @${it.author.username} — ${it.created_at}`,
`${it.url}`,
``,
it.text,
];
if (it.downloaded_files.length) lines.push(``, `media: ${it.downloaded_files.join(", ")}`);
return lines.join("\n");
}),
].join("\n\n");
fs.writeFileSync(path.join(outRoot, "index.md"), md);
console.log(`\n[done] Saved ${items.length} bookmarks + media to ${outRoot}`);
console.log(` bookmarks-raw.json — full API response`);
console.log(` index.json — parsed items`);
console.log(` index.md — readable summary`);
console.log(` <handle>_<id>.txt — one per bookmark`);
if (!skipMedia) console.log(` media/ — images + videos`);
}
main().catch(e => { console.error("\nERROR:", e.message || e); process.exit(1); });
{
"name": "x-bookmarks-fetcher",
"version": "1.0.0",
"description": "Download X (Twitter) bookmarks via the official X API v2. Saves text + images + videos to a local folder.",
"type": "module",
"private": true,
"engines": { "node": ">=18" },
"scripts": {
"auth": "node auth.mjs",
"fetch": "node fetch.mjs"
}
}
Setup — x-bookmarks-fetcher
One-time setup, ~5 minutes. After this every fetch is a pure API call.
1. Create an X developer app
1. Go to https://developer.x.com/en/portal/dashboard and sign in with your X account. 2. If you don't already have a project, create one (the Free tier is fine). 3. Inside the project, create an App (any name). 4. On the app page, find User authentication settings and click Set up (or Edit if it already exists).
2. Configure OAuth 2.0
Fill the User authentication settings form like this:
| Field | Value |
|---|---|
| App permissions | Read (or Read and write if you also want to post) |
| Type of App | Web App, Automated App or Bot ← critical, this enables OAuth 2.0 |
| Callback URI / Redirect URL | http://127.0.0.1:8765/callback |
| Website URL | any URL you own (e.g. your portfolio or https://x.com/your_handle) |
Save. X will display your Client ID and Client Secret (the secret may appear only once — copy it now).
⚠️ If you previously had OAuth 2.0 disabled and only had OAuth 1.0a keys, X will regenerate the OAuth 2.0 credentials when you save. The OAuth 1.0a keys are unrelated and stay the same.
3. Drop credentials into .env
cd ~/.claude/skills/x-bookmarks-fetcher/scripts
cp .env.example .envEdit .env:
X_CLIENT_ID=your-client-id-from-x-dev-portal
X_CLIENT_SECRET=your-client-secret-from-x-dev-portal.env is gitignored. Don't commit it.
4. Authorize once
node auth.mjsThis will: 1. Print an authorization URL and open it in your default browser. 2. You log in to X (if not already) and click Authorize app. 3. X redirects to http://127.0.0.1:8765/callback — a tiny local server (started by the script) catches the code. 4. The server exchanges the code for an access_token + refresh_token and saves them to scripts/.tokens.json.
You should see:
Tokens saved to: /Users/.../x-bookmarks-fetcher/scripts/.tokens.json
scopes: users.read tweet.read offline.access bookmark.read
expires in: 7200 secondsThe refresh_token is what makes this last forever — fetch.mjs auto-rotates the access token when it's about to expire.
5. Fetch
node fetch.mjs # last 48h
node fetch.mjs --hours 24 # last 24h
node fetch.mjs --hours 168 # last weekOutput lands in /tmp/x-bookmarks-<YYYYMMDD>/ by default. See README.md for full options.
---
Troubleshooting
"Something went wrong — you weren't able to give access to the App"
Your callback URL isn't registered or isn't an exact match. Recheck step 2 — it must be exactly http://127.0.0.1:8765/callback (no trailing slash, http not https).
"Unsupported Authentication" on fetch.mjs
The bookmarks endpoint requires OAuth 2.0 User Context. Make sure node auth.mjs finished successfully and .tokens.json exists in scripts/. Inspect with cat scripts/.tokens.json — it should contain access_token, refresh_token, and scope: "... bookmark.read ...".
Rate limit (429)
Free tier ≈ 10 requests / 15 min on this endpoint. fetch.mjs reads x-rate-limit-reset from the response headers and sleeps until the window opens. If you hit this often, paginate over multiple sessions instead of in one shot.
Port 8765 already in use
Some other process is listening on it. Either free the port (lsof -i :8765) or change PORT at the top of auth.mjs — but you'll also need to update the Callback URI in the X dev portal to match.
Tokens lost / corrupt
Just re-run node auth.mjs. Click Authorize again. New tokens overwrite the old file.
Related skills
FAQ
Does x-bookmarks-fetcher scrape X?
No. It uses the official X API v2 with OAuth 2.0 user-context auth, not scraping or a Chrome session.
What time filter does it apply?
The --hours / --since filter applies to the tweet's created_at, since the API does not expose the bookmark timestamp.