From 676bff71f305f86f6bac6630c44de8defdc62d80 Mon Sep 17 00:00:00 2001 From: swairshah Date: Wed, 31 Dec 2025 14:02:08 -0800 Subject: [PATCH 1/6] feat: add likes command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `bird likes` command to fetch the authenticated user's liked tweets, similar to the existing bookmarks command. 🤖 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 | 114 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/src/index.ts b/src/index.ts index 3e1a8fc..ba49d61 100644 --- a/src/index.ts +++ b/src/index.ts @@ -150,6 +150,7 @@ const KNOWN_COMMANDS = new Set([ 'search', 'mentions', 'bookmarks', + 'likes', 'help', 'whoami', 'check', @@ -347,6 +348,7 @@ program 'SearchTimeline', 'UserArticlesTweets', 'Bookmarks', + 'Likes', ]; if (cmdOpts.fresh) { @@ -741,6 +743,39 @@ program } }); +// Likes command - get user's liked tweets +program + .command('likes') + .description('Get your liked tweets') + .option('-n, --count ', 'Number of likes 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.getLikes(count); + + if (result.success && result.tweets) { + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' }); + } else { + console.error(`${p('err')}Failed to fetch likes: ${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 db740a5..6cc65ac 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -28,6 +28,7 @@ const FALLBACK_QUERY_IDS = { SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', + Likes: 'JR2gceKucIKcVNB_9JkhsA', } as const; type OperationName = keyof typeof FALLBACK_QUERY_IDS; @@ -1987,4 +1988,117 @@ export class TwitterClient { return { success: false, error: firstAttempt.error }; } + + private async getLikesQueryIds(): Promise { + const primary = await this.getQueryId('Likes'); + return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA'])); + } + + /** + * Get the authenticated user's liked tweets + */ + async getLikes(count = 20): Promise { + const userResult = await this.getCurrentUser(); + if (!userResult.success || !userResult.user) { + return { success: false, error: userResult.error ?? 'Could not determine current user' }; + } + + const variables = { + userId: userResult.user.id, + count, + includePromotedContent: false, + withClientEventToken: false, + withBirdwatchNotes: false, + withVoice: true, + }; + + 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.getLikesQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/Likes?${params.toString()}`; + + 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?: { + user?: { + result?: { + timeline?: { + 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?.user?.result?.timeline?.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 likes', 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 9660f9092dcce63d2316c0931116006b36afbe13 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 09:54:49 +0100 Subject: [PATCH 2/6] refactor: share timeline features for likes --- src/lib/twitter-client.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 6cc65ac..6934f9c 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -1871,10 +1871,10 @@ export class TwitterClient { return { success: true, tweets: thread }; } - private buildBookmarksFeatures(): Record { + // Shared timeline features for likes/bookmarks-style timelines. + private buildTimelineFeatures(): 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, @@ -1886,6 +1886,17 @@ export class TwitterClient { }; } + private buildBookmarksFeatures(): Record { + return { + ...this.buildTimelineFeatures(), + graphql_timeline_v2_bookmark_timeline: true, + }; + } + + private buildLikesFeatures(): Record { + return this.buildTimelineFeatures(); + } + private async getBookmarksQueryIds(): Promise { const primary = await this.getQueryId('Bookmarks'); return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw'])); @@ -2012,7 +2023,7 @@ export class TwitterClient { withVoice: true, }; - const features = this.buildBookmarksFeatures(); + const features = this.buildLikesFeatures(); const params = new URLSearchParams({ variables: JSON.stringify(variables), From 1e6dda961dd2b5490b39396c75d319a217ef0a21 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 09:54:53 +0100 Subject: [PATCH 3/6] test: cover likes timeline handling --- tests/twitter-client-coverage.api.test.ts | 91 +++++++++++++++++++ tests/twitter-client.test.ts | 104 ++++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/tests/twitter-client-coverage.api.test.ts b/tests/twitter-client-coverage.api.test.ts index 7083dc6..38027c0 100644 --- a/tests/twitter-client-coverage.api.test.ts +++ b/tests/twitter-client-coverage.api.test.ts @@ -17,6 +17,12 @@ type ResponseLike = { type TwitterClientApiPrivate = TwitterClient & { getBookmarksQueryIds: () => Promise; + getLikesQueryIds: () => Promise; + getCurrentUser: () => Promise<{ + success: boolean; + user?: { id: string; username: string; name: string }; + error?: string; + }>; }; const makeResponse = (overrides: Partial = {}): ResponseLike => ({ @@ -204,4 +210,89 @@ describe('TwitterClient API coverage', () => { expect(result.error).toBe('HTTP 404'); }); }); + + describe('likes error paths', () => { + const stubCurrentUser = async () => ({ + success: true, + user: { id: '123', username: 'tester', name: 'Tester' }, + }); + + it('returns an error for non-ok responses', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getCurrentUser = stubCurrentUser; + clientPrivate.getLikesQueryIds = async () => ['test']; + + const result = await client.getLikes(1); + + expect(result.success).toBe(false); + expect(result.error).toContain('HTTP 500'); + }); + + it('returns API errors from payloads', async () => { + const mockFetch = vi.fn().mockResolvedValueOnce( + makeResponse({ + json: async () => ({ errors: [{ message: 'bad' }] }), + }), + ); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getCurrentUser = stubCurrentUser; + clientPrivate.getLikesQueryIds = async () => ['test']; + + const result = await client.getLikes(1); + + expect(result.success).toBe(false); + expect(result.error).toBe('bad'); + }); + + it('returns an error when fetching throws', async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error('boom')); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getCurrentUser = stubCurrentUser; + clientPrivate.getLikesQueryIds = async () => ['test']; + + const result = await client.getLikes(1); + + expect(result.success).toBe(false); + expect(result.error).toContain('boom'); + }); + + it('returns unknown error when no query ids are available', async () => { + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getCurrentUser = stubCurrentUser; + clientPrivate.getLikesQueryIds = async () => []; + + const result = await client.getLikes(1); + + expect(result.success).toBe(false); + expect(result.error).toBe('Unknown error fetching likes'); + }); + + it('returns the second attempt error after 404s', async () => { + const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' })); + global.fetch = mockFetch as unknown as typeof fetch; + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientApiPrivate; + clientPrivate.getCurrentUser = stubCurrentUser; + clientPrivate.getLikesQueryIds = async () => ['test']; + + const result = await client.getLikes(1); + + expect(result.success).toBe(false); + expect(result.error).toBe('HTTP 404'); + }); + }); }); diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index f4f3fe9..ef410c1 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -9,6 +9,14 @@ describe('TwitterClient', () => { cookieHeader: 'auth_token=test_auth_token; ct0=test_ct0_token', source: 'test', }; + type TwitterClientPrivate = TwitterClient & { + getCurrentUser: () => Promise<{ + success: boolean; + user?: { id: string; username: string; name: string }; + error?: string; + }>; + getLikesQueryIds: () => Promise; + }; afterEach(() => { global.fetch = originalFetch; @@ -1144,6 +1152,102 @@ describe('TwitterClient', () => { }); }); + describe('likes', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + it('fetches likes and parses tweet results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '2', + legacy: { + full_text: 'liked', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '2', + }, + core: { + user_results: { + result: { + rest_id: 'u2', + legacy: { screen_name: 'root', name: 'Root' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ + success: true, + user: { id: '42', username: 'tester', name: 'Tester' }, + }); + clientPrivate.getLikesQueryIds = async () => ['test']; + + const result = await client.getLikes(2); + + expect(result.success).toBe(true); + expect(result.tweets?.[0].id).toBe('2'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('GET'); + expect(String(url)).toContain('/Likes?'); + const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string); + expect(parsedVars.userId).toBe('42'); + 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).toBeUndefined(); + }); + + it('returns an error when current user is unavailable', async () => { + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as unknown as TwitterClientPrivate; + clientPrivate.getCurrentUser = async () => ({ success: false, error: 'no user' }); + + const result = await client.getLikes(1); + + expect(result.success).toBe(false); + expect(result.error).toBe('no user'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + describe('conversation helpers', () => { let mockFetch: ReturnType; From cbe81a1230d9bf5256d2801f870650411a0ca055 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 09:54:56 +0100 Subject: [PATCH 4/6] docs: add likes command docs --- CHANGELOG.md | 6 ++++++ README.md | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0d3d0..a2fd5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## 0.4.2 — Unreleased +### Added +- `likes` command to list your liked tweets (thanks @swairshah). + +### Changed +- Query ID updater now tracks the Likes GraphQL operation. + ## 0.4.1 — 2025-12-31 ### Added diff --git a/README.md b/README.md index 8e76627..7579aa4 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ bird mentions --user @steipete -n 5 # Bookmarks bird bookmarks -n 5 +# Likes +bird likes -n 5 + # Refresh GraphQL query IDs cache (no rebuild) bird query-ids --fresh ``` @@ -68,6 +71,7 @@ bird query-ids --fresh - `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 likes [-n count] [--json]` — list your liked tweets. - `bird whoami` — print which Twitter account your cookies belong to. - `bird check` — show which credentials are available and where they were sourced from. From e5599c7731eaa06ec52dd0adebe2b8d54b9065c7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 09:55:01 +0100 Subject: [PATCH 5/6] build: update query id updater for likes --- scripts/update-query-ids.ts | 1 + src/lib/query-ids.json | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index 60b8648..f809d26 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -14,6 +14,7 @@ const TARGET_OPERATIONS = [ 'TweetDetail', 'SearchTimeline', 'Bookmarks', + 'Likes', ] as const; type OperationName = (typeof TARGET_OPERATIONS)[number]; diff --git a/src/lib/query-ids.json b/src/lib/query-ids.json index d981057..119b852 100644 --- a/src/lib/query-ids.json +++ b/src/lib/query-ids.json @@ -3,5 +3,6 @@ "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "TweetDetail": "97JF30KziU00483E_8elBA", - "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ" + "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ", + "Likes": "JR2gceKucIKcVNB_9JkhsA" } From ce4906a5022372a5b119f49958b703dcdfd81278 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 10:00:35 +0100 Subject: [PATCH 6/6] fix: use query params string for bookmark folder --- src/lib/twitter-client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 55fcd91..08a068c 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -2146,7 +2146,7 @@ export class TwitterClient { }); for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params}`; + const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`; try { const response = await this.fetchWithTimeout(url, {