From 9c553e46bf18abc7a729978e37da87fce91d769c Mon Sep 17 00:00:00 2001 From: Alex Hillman Date: Tue, 30 Dec 2025 22:44:52 +0000 Subject: [PATCH 1/2] feat: add bookmarks command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `bird bookmarks` command to fetch the authenticated user's bookmarked tweets. - New `getBookmarks()` method in TwitterClient - Uses graphql_timeline_v2_bookmark_timeline feature flag - Supports `--count` and `--json` options - Follows existing patterns for query ID rotation and retry logic Usage: bird bookmarks # Get your recent bookmarks bird bookmarks -n 50 # Get 50 bookmarks bird bookmarks --json # Output as JSON 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/index.ts | 35 ++++++++++++ src/lib/twitter-client.ts | 115 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/src/index.ts b/src/index.ts index 53b393a..931064a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,6 +138,7 @@ const KNOWN_COMMANDS = new Set([ 'thread', 'search', 'mentions', + 'bookmarks', 'help', 'whoami', 'check', @@ -310,6 +311,7 @@ program 'TweetDetail', 'SearchTimeline', 'UserArticlesTweets', + 'Bookmarks', ]; if (cmdOpts.fresh) { @@ -671,6 +673,39 @@ program } }); +// Bookmarks command - get user's bookmarks +program + .command('bookmarks') + .description('Get your bookmarked tweets') + .option('-n, --count ', 'Number of bookmarks to fetch', '20') + .option('--json', 'Output as JSON') + .action(async (cmdOpts: { count?: string; json?: boolean }) => { + const opts = program.opts(); + const timeoutMs = resolveTimeoutFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + + const { cookies, warnings } = await resolveCredentialsFromOptions(opts); + + for (const warning of warnings) { + console.error(`${p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + const result = await client.getBookmarks(count); + + if (result.success && result.tweets) { + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' }); + } else { + console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`); + process.exit(1); + } + }); + // Whoami command - show the logged-in account program .command('whoami') diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index d19bdad..e5f5379 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -23,6 +23,7 @@ const FALLBACK_QUERY_IDS = { TweetDetail: '97JF30KziU00483E_8elBA', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', + Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', } as const; type OperationName = keyof typeof FALLBACK_QUERY_IDS; @@ -1795,4 +1796,118 @@ export class TwitterClient { return { success: true, tweets: thread }; } + + private buildBookmarksFeatures(): Record { + return { + ...this.buildSearchFeatures(), + graphql_timeline_v2_bookmark_timeline: true, + blue_business_profile_image_shape_enabled: true, + responsive_web_text_conversations_enabled: false, + tweetypie_unmention_optimization_enabled: true, + vibe_api_enabled: true, + responsive_web_twitter_blue_verified_badge_is_enabled: true, + interactive_text_enabled: true, + longform_notetweets_richtext_consumption_enabled: true, + responsive_web_media_download_video_enabled: false, + }; + } + + private async getBookmarksQueryIds(): Promise { + const primary = await this.getQueryId('Bookmarks'); + return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw'])); + } + + /** + * Get the authenticated user's bookmarks + */ + async getBookmarks(count = 20): Promise { + const variables = { + count, + includePromotedContent: false, + withDownvotePerspective: false, + withReactionsMetadata: false, + withReactionsPerspective: false, + }; + + const features = this.buildBookmarksFeatures(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getBookmarksQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + bookmark_timeline_v2?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }>; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions; + const tweets = this.parseTweetsFromInstructions(instructions); + + return { success: true as const, tweets, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) return { success: true, tweets: firstAttempt.tweets }; + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) return { success: true, tweets: secondAttempt.tweets }; + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } } From 2ba0eff1e6697320da689795ddd78607a14e680b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 31 Dec 2025 12:04:24 +0100 Subject: [PATCH 2/2] test: cover bookmarks --- CHANGELOG.md | 4 ++ README.md | 6 ++- scripts/update-query-ids.ts | 1 + tests/twitter-client.test.ts | 72 ++++++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 661a665..bd50d14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,12 @@ ## 0.4.1 — Unreleased +### Added +- `bookmarks` command to list your bookmarked tweets. + ### Changed - Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`). +- Query ID updater now tracks the Bookmarks GraphQL operation. ## 0.4.0 — 2025-12-26 diff --git a/README.md b/README.md index 231976b..8e76627 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,9 @@ bird search "from:steipete" -n 5 bird mentions -n 5 bird mentions --user @steipete -n 5 +# Bookmarks +bird bookmarks -n 5 + # Refresh GraphQL query IDs cache (no rebuild) bird query-ids --fresh ``` @@ -64,6 +67,7 @@ bird query-ids --fresh - `bird thread [--json]` — show the full conversation thread. - `bird search "" [-n count] [--json]` — search for tweets matching a query. - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). +- `bird bookmarks [-n count] [--json]` — list your bookmarked tweets. - `bird whoami` — print which Twitter account your cookies belong to. - `bird check` — show which credentials are available and where they were sourced from. @@ -117,7 +121,7 @@ Environment shortcuts: ## Output -- `--json` prints raw tweet objects for read/replies/thread/search/mentions. +- `--json` prints raw tweet objects for read/replies/thread/search/mentions/bookmarks. - `read` returns full text for Notes and Articles when present. - Use `--plain` for stable, script-friendly output (no emoji, no color). diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index 91b062f..60b8648 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -13,6 +13,7 @@ const TARGET_OPERATIONS = [ 'FavoriteTweet', 'TweetDetail', 'SearchTimeline', + 'Bookmarks', ] as const; type OperationName = (typeof TARGET_OPERATIONS)[number]; diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 5a17e9f..f4f3fe9 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -1072,6 +1072,78 @@ describe('TwitterClient', () => { }); }); + describe('bookmarks', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + it('fetches bookmarks and parses tweet results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + bookmark_timeline_v2: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '1', + legacy: { + full_text: 'saved', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '1', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'root', name: 'Root' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getBookmarks(2); + + expect(result.success).toBe(true); + expect(result.tweets?.[0].id).toBe('1'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('GET'); + expect(String(url)).toContain('/Bookmarks?'); + const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string); + expect(parsedVars.count).toBe(2); + const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string); + expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true); + }); + }); + describe('conversation helpers', () => { let mockFetch: ReturnType;