From ee9c89018e2cd6d804096f13a7570fa485f6d79e Mon Sep 17 00:00:00 2001 From: Tyler Seymour Date: Wed, 31 Dec 2025 15:20:36 -0500 Subject: [PATCH 01/15] feat: add folder support for bookmarks command --- README.md | 5 +- src/index.ts | 16 ++- src/lib/extract-bookmark-folder-id.ts | 20 ++++ src/lib/twitter-client.ts | 113 ++++++++++++++++++++++ tests/cli.test.ts | 22 +++++ tests/twitter-client-coverage.api.test.ts | 64 ++++++++++++ tests/twitter-client.test.ts | 73 ++++++++++++++ 7 files changed, 308 insertions(+), 5 deletions(-) create mode 100644 src/lib/extract-bookmark-folder-id.ts diff --git a/README.md b/README.md index 8e76627..6b92482 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,9 @@ bird search "from:steipete" -n 5 bird mentions -n 5 bird mentions --user @steipete -n 5 -# Bookmarks +# Bookmarks bird bookmarks -n 5 +bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/ # Refresh GraphQL query IDs cache (no rebuild) bird query-ids --fresh @@ -67,7 +68,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 bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder). - `bird whoami` — print which Twitter account your cookies belong to. - `bird check` — show which credentials are available and where they were sourced from. diff --git a/src/index.ts b/src/index.ts index 7c4a9e6..a7d198c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import JSON5 from 'json5'; import kleur from 'kleur'; import { resolveCliInvocation } from './lib/cli-args.js'; import { type CookieSource, resolveCredentials } from './lib/cookies.js'; +import { extractBookmarkFolderId } from './lib/extract-bookmark-folder-id.js'; import { extractTweetId } from './lib/extract-tweet-id.js'; import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js'; import { @@ -713,8 +714,9 @@ program .command('bookmarks') .description('Get your bookmarked tweets') .option('-n, --count ', 'Number of bookmarks to fetch', '20') + .option('--folder-id ', 'Bookmark folder (collection) id') .option('--json', 'Output as JSON') - .action(async (cmdOpts: { count?: string; json?: boolean }) => { + .action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '20', 10); @@ -731,10 +733,18 @@ program } const client = new TwitterClient({ cookies, timeoutMs }); - const result = await client.getBookmarks(count); + const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null; + if (cmdOpts.folderId && !folderId) { + console.error(`${p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/.`); + process.exit(1); + } + const result = folderId + ? await client.getBookmarkFolderTimeline(folderId, count) + : await client.getBookmarks(count); if (result.success && result.tweets) { - printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' }); + const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.'; + printTweets(result.tweets, { json: cmdOpts.json, emptyMessage }); } else { console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`); process.exit(1); diff --git a/src/lib/extract-bookmark-folder-id.ts b/src/lib/extract-bookmark-folder-id.ts new file mode 100644 index 0000000..0e47c88 --- /dev/null +++ b/src/lib/extract-bookmark-folder-id.ts @@ -0,0 +1,20 @@ +/** + * Extract bookmark folder (collection) ID from an X/Twitter bookmarks URL. + */ +const BOOKMARK_FOLDER_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/bookmarks\/(\d+)/i; +const BOOKMARK_FOLDER_ID_REGEX = /^\d{5,}$/; + +export function extractBookmarkFolderId(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + const urlMatch = BOOKMARK_FOLDER_URL_REGEX.exec(trimmed); + if (urlMatch) { + return urlMatch[1]; + } + if (BOOKMARK_FOLDER_ID_REGEX.test(trimmed)) { + return trimmed; + } + return null; +} diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 2ef1946..e808127 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', + BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ', } as const; type OperationName = keyof typeof FALLBACK_QUERY_IDS; @@ -1887,6 +1888,11 @@ export class TwitterClient { return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw'])); } + private async getBookmarkFolderQueryIds(): Promise { + const primary = await this.getQueryId('BookmarkFolderTimeline'); + return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ'])); + } + /** * Get the authenticated user's bookmarks */ @@ -1984,4 +1990,111 @@ export class TwitterClient { return { success: false, error: firstAttempt.error }; } + + /** + * Get the authenticated user's bookmark folder timeline + */ + async getBookmarkFolderTimeline(folderId: string, count = 20): Promise { + const variablesWithCount = { + bookmark_collection_id: folderId, + includePromotedContent: true, + count, + }; + + const variablesWithoutCount = { + bookmark_collection_id: folderId, + includePromotedContent: true, + }; + + const features = this.buildBookmarksFeatures(); + + const tryOnce = async (variables: Record) => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getBookmarkFolderQueryIds(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${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_collection_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?.bookmark_collection_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 bookmark folder', had404 }; + }; + + let firstAttempt = await tryOnce(variablesWithCount); + if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) { + firstAttempt = await tryOnce(variablesWithoutCount); + } + if (firstAttempt.success) { + return { success: true, tweets: firstAttempt.tweets }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + let secondAttempt = await tryOnce(variablesWithCount); + if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) { + secondAttempt = await tryOnce(variablesWithoutCount); + } + if (secondAttempt.success) { + return { success: true, tweets: secondAttempt.tweets }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } } diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 9c62bc4..d483106 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js'; import { extractTweetId } from '../src/lib/extract-tweet-id.js'; describe('CLI utilities', () => { @@ -29,4 +30,25 @@ describe('CLI utilities', () => { expect(extractTweetId(url)).toBe('1234567890123456789'); }); }); + + describe('extractBookmarkFolderId', () => { + it('should extract ID from x.com bookmarks URL', () => { + const url = 'https://x.com/i/bookmarks/1976792203235119344'; + expect(extractBookmarkFolderId(url)).toBe('1976792203235119344'); + }); + + it('should extract ID from twitter.com bookmarks URL', () => { + const url = 'https://twitter.com/i/bookmarks/1976792203235119344'; + expect(extractBookmarkFolderId(url)).toBe('1976792203235119344'); + }); + + it('should accept a numeric ID as-is', () => { + const id = '1976792203235119344'; + expect(extractBookmarkFolderId(id)).toBe('1976792203235119344'); + }); + + it('should return null for invalid values', () => { + expect(extractBookmarkFolderId('not-an-id')).toBeNull(); + }); + }); }); diff --git a/tests/twitter-client-coverage.api.test.ts b/tests/twitter-client-coverage.api.test.ts index 7083dc6..c6d39e6 100644 --- a/tests/twitter-client-coverage.api.test.ts +++ b/tests/twitter-client-coverage.api.test.ts @@ -17,6 +17,7 @@ type ResponseLike = { type TwitterClientApiPrivate = TwitterClient & { getBookmarksQueryIds: () => Promise; + getBookmarkFolderQueryIds: () => Promise; }; const makeResponse = (overrides: Partial = {}): ResponseLike => ({ @@ -204,4 +205,67 @@ describe('TwitterClient API coverage', () => { expect(result.error).toBe('HTTP 404'); }); }); + + describe('bookmark folder error paths', () => { + 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 result = await client.getBookmarkFolderTimeline('123', 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 result = await client.getBookmarkFolderTimeline('123', 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 result = await client.getBookmarkFolderTimeline('123', 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.getBookmarkFolderQueryIds = async () => []; + + const result = await client.getBookmarkFolderTimeline('123', 1); + + expect(result.success).toBe(false); + expect(result.error).toBe('Unknown error fetching bookmark folder'); + }); + + 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 result = await client.getBookmarkFolderTimeline('123', 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..9c722ab 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -1144,6 +1144,79 @@ describe('TwitterClient', () => { }); }); + describe('bookmark folders', () => { + let mockFetch: ReturnType; + + beforeEach(() => { + mockFetch = vi.fn(); + global.fetch = mockFetch as unknown as typeof fetch; + }); + + it('fetches bookmark folder timeline and parses tweet results', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + bookmark_collection_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '9', + legacy: { + full_text: 'saved in folder', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '9', + }, + core: { + user_results: { + result: { + rest_id: 'u9', + legacy: { screen_name: 'folder', name: 'Folder' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getBookmarkFolderTimeline('123', 2); + + expect(result.success).toBe(true); + expect(result.tweets?.[0].id).toBe('9'); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(options.method).toBe('GET'); + expect(String(url)).toContain('/BookmarkFolderTimeline?'); + const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string); + expect(parsedVars.bookmark_collection_id).toBe('123'); + 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; From c90d3f9ab4be4093478b2dd8e4ad9b3236c232ea Mon Sep 17 00:00:00 2001 From: Alex Hillman Date: Wed, 31 Dec 2025 21:55:02 +0000 Subject: [PATCH 02/15] Add native quote tweet support to TweetData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add quoted_status_result to GraphqlTweetResult type - Add quotedTweet field to TweetData interface - Extract and map quoted tweets in mapTweetResult() - Works for getTweet, getBookmarks, search, etc. - Document JSON output schema in README 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 18 ++++++++++++++++++ src/lib/twitter-client.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/README.md b/README.md index 231976b..0158651 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,24 @@ Environment shortcuts: - `read` returns full text for Notes and Articles when present. - Use `--plain` for stable, script-friendly output (no emoji, no color). +### JSON Schema + +When using `--json`, tweet objects include: + +| Field | Type | Description | +|-------|------|-------------| +| `id` | string | Tweet ID | +| `text` | string | Full tweet text (includes Note/Article content when present) | +| `author` | object | `{ username, name }` | +| `authorId` | string | Author's user ID | +| `createdAt` | string | Timestamp | +| `replyCount` | number | Number of replies | +| `retweetCount` | number | Number of retweets | +| `likeCount` | number | Number of likes | +| `conversationId` | string | Thread conversation ID | +| `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) | +| `quotedTweet` | object? | Embedded quote tweet with `{ id, text, author, authorId }` (present if this is a quote tweet) | + ## Query IDs (GraphQL) X rotates GraphQL “query IDs” frequently. Each GraphQL operation is addressed as: diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index e5f5379..7a957b7 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -180,6 +180,9 @@ type GraphqlTweetResult = { }>; }>; }; + quoted_status_result?: { + result?: GraphqlTweetResult; + }; }; export type TweetResult = @@ -212,6 +215,15 @@ export interface TweetData { likeCount?: number; conversationId?: string; inReplyToStatusId?: string; + quotedTweet?: { + id: string; + text: string; + author: { + username: string; + name: string; + }; + authorId?: string; + }; } export interface GetTweetResult { @@ -686,6 +698,29 @@ export class TwitterClient { const text = this.extractTweetText(result); if (!text) return undefined; + // Extract quoted tweet if present + let quotedTweet: TweetData['quotedTweet'] = undefined; + const quotedResult = result.quoted_status_result?.result; + if (quotedResult?.rest_id) { + const quotedUserResult = quotedResult?.core?.user_results?.result; + const quotedUserLegacy = quotedUserResult?.legacy; + const quotedUserCore = quotedUserResult?.core; + const quotedUsername = quotedUserLegacy?.screen_name ?? quotedUserCore?.screen_name; + const quotedName = quotedUserLegacy?.name ?? quotedUserCore?.name ?? quotedUsername; + const quotedText = this.extractTweetText(quotedResult); + if (quotedUsername && quotedText) { + quotedTweet = { + id: quotedResult.rest_id, + text: quotedText, + author: { + username: quotedUsername, + name: quotedName || quotedUsername, + }, + authorId: quotedUserResult?.rest_id, + }; + } + } + return { id: result.rest_id, text, @@ -700,6 +735,7 @@ export class TwitterClient { name: name || username, }, authorId: userId, + quotedTweet, }; } From 676bff71f305f86f6bac6630c44de8defdc62d80 Mon Sep 17 00:00:00 2001 From: swairshah Date: Wed, 31 Dec 2025 14:02:08 -0800 Subject: [PATCH 03/15] 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 e541d904f2a85cabc0826c7353c9a05d2da5390c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 09:13:41 +0100 Subject: [PATCH 04/15] test: cover bookmark folder parsing and count fallback --- tests/cli.test.ts | 5 +++ tests/twitter-client.test.ts | 75 ++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/tests/cli.test.ts b/tests/cli.test.ts index d483106..a3f5a9d 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -50,5 +50,10 @@ describe('CLI utilities', () => { it('should return null for invalid values', () => { expect(extractBookmarkFolderId('not-an-id')).toBeNull(); }); + + it('should return null for folder_id query URLs', () => { + const url = 'https://x.com/i/bookmarks?folder_id=1976792203235119344'; + expect(extractBookmarkFolderId(url)).toBeNull(); + }); }); }); diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 9c722ab..941b101 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -1215,6 +1215,81 @@ describe('TwitterClient', () => { const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string); expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true); }); + + it('retries without count when API rejects the count variable', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + errors: [{ message: 'Variable "$count" is not defined by operation' }], + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + bookmark_collection_timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '9', + legacy: { + full_text: 'saved in folder', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '9', + }, + core: { + user_results: { + result: { + rest_id: 'u9', + legacy: { screen_name: 'folder', name: 'Folder' }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const clientPrivate = client as TwitterClient & { getBookmarkFolderQueryIds: () => Promise }; + clientPrivate.getBookmarkFolderQueryIds = async () => ['test']; + + const result = await client.getBookmarkFolderTimeline('123', 2); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const firstVars = JSON.parse( + new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string, + ); + const secondVars = JSON.parse( + new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string, + ); + + expect(firstVars.count).toBe(2); + expect(secondVars.count).toBeUndefined(); + }); }); describe('conversation helpers', () => { From de209d96b22134eb8ceacfee92e8be0dd8115b04 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 09:13:45 +0100 Subject: [PATCH 05/15] docs: update changelog for bookmark folders --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6154d8e..750744e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - `bookmarks` command to list your bookmarked tweets. +- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour). ### Changed - Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`). From 9660f9092dcce63d2316c0931116006b36afbe13 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 09:54:49 +0100 Subject: [PATCH 06/15] 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 07/15] 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 08/15] 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 09/15] 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 10/15] 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, { From 071953e9f43fd13073e48c7e183bcd058ac4c17f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 10:45:15 +0100 Subject: [PATCH 11/15] feat: add quote depth option for quoted tweets --- src/index.ts | 42 ++++++++++++++++++++++------- src/lib/twitter-client.ts | 56 ++++++++++++++++++--------------------- 2 files changed, 59 insertions(+), 39 deletions(-) diff --git a/src/index.ts b/src/index.ts index 931064a..86fc042 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,6 +99,7 @@ type BirdConfig = { firefoxProfile?: string; cookieSource?: CookieSource | CookieSource[]; timeoutMs?: number; + quoteDepth?: number; }; function readConfigFile(path: string): Partial { @@ -180,6 +181,7 @@ program .option('--media ', 'Attach media file (repeatable, up to 4 images or 1 video)', collect, []) .option('--alt ', 'Alt text for the corresponding --media (repeatable)', collect, []) .option('--timeout ', 'Request timeout in milliseconds') + .option('--quote-depth ', 'Max quoted tweet depth (default: 1; 0 disables)') .option('--plain', 'Plain output (stable, no emoji, no color)') .option('--no-emoji', 'Disable emoji output') .option('--no-color', 'Disable ANSI colors (or set NO_COLOR)'); @@ -224,6 +226,19 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb return resolveTimeoutMs(options.timeout, config.timeoutMs, process.env.BIRD_TIMEOUT_MS); } +function resolveQuoteDepth(...values: Array): number | undefined { + for (const value of values) { + if (value === undefined || value === null || value === '') continue; + const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed); + } + return undefined; +} + +function resolveQuoteDepthFromOptions(options: { quoteDepth?: string | number }): number | undefined { + return resolveQuoteDepth(options.quoteDepth, config.quoteDepth, process.env.BIRD_QUOTE_DEPTH); +} + function detectMime(path: string): string | null { const ext = path.toLowerCase(); if (ext.endsWith('.jpg') || ext.endsWith('.jpeg')) return 'image/jpeg'; @@ -364,6 +379,7 @@ program .action(async (text: string) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); let media: MediaSpec[] = []; try { media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); @@ -387,7 +403,7 @@ program console.error(`${l('source')}${cookies.source}`); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); let mediaIds: string[] | undefined; if (media.length > 0) { const uploaded: string[] = []; @@ -422,6 +438,7 @@ program .action(async (tweetIdOrUrl: string, text: string) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); let media: MediaSpec[] = []; try { media = loadMedia({ media: opts.media ?? [], alts: opts.alt ?? [] }); @@ -448,7 +465,7 @@ program console.error(`${p('info')}Replying to tweet: ${tweetId}`); - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); let mediaIds: string[] | undefined; if (media.length > 0) { const uploaded: string[] = []; @@ -483,6 +500,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); @@ -497,7 +515,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getTweet(tweetId); if (result.success && result.tweet) { @@ -526,6 +544,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -539,7 +558,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getReplies(tweetId); if (result.success && result.tweets) { @@ -559,6 +578,7 @@ program .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const tweetId = extractTweetId(tweetIdOrUrl); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -572,7 +592,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getThread(tweetId); if (result.success && result.tweets) { @@ -593,6 +613,7 @@ program .action(async (query: string, cmdOpts: { count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -606,7 +627,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.search(query, count); if (result.success && result.tweets) { @@ -627,6 +648,7 @@ program .action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '10', 10); const fromUserOpt = mentionsQueryFromUserOption(cmdOpts.user); @@ -648,7 +670,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); if (!query) { const who = await client.getCurrentUser(); @@ -682,6 +704,7 @@ program .action(async (cmdOpts: { count?: string; json?: boolean }) => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const count = Number.parseInt(cmdOpts.count || '20', 10); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -695,7 +718,7 @@ program process.exit(1); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getBookmarks(count); if (result.success && result.tweets) { @@ -713,6 +736,7 @@ program .action(async () => { const opts = program.opts(); const timeoutMs = resolveTimeoutFromOptions(opts); + const quoteDepth = resolveQuoteDepthFromOptions(opts); const { cookies, warnings } = await resolveCredentialsFromOptions(opts); @@ -729,7 +753,7 @@ program console.error(`${l('source')}${cookies.source}`); } - const client = new TwitterClient({ cookies, timeoutMs }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const result = await client.getCurrentUser(); const credentialSource = cookies.source ?? 'env/auto-detected cookies'; diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 7a957b7..96c20e9 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -35,7 +35,14 @@ const QUERY_IDS: Record = { const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array; +function normalizeQuoteDepth(value?: number): number { + if (value === undefined || value === null) return 1; + if (!Number.isFinite(value)) return 1; + return Math.max(0, Math.floor(value)); +} + type GraphqlTweetResult = { + __typename?: string; rest_id?: string; legacy?: { full_text?: string; @@ -180,6 +187,7 @@ type GraphqlTweetResult = { }>; }>; }; + tweet?: GraphqlTweetResult; quoted_status_result?: { result?: GraphqlTweetResult; }; @@ -215,15 +223,8 @@ export interface TweetData { likeCount?: number; conversationId?: string; inReplyToStatusId?: string; - quotedTweet?: { - id: string; - text: string; - author: { - username: string; - name: string; - }; - authorId?: string; - }; + // Optional quoted tweet; depth controlled by quoteDepth (default: 1). + quotedTweet?: TweetData; } export interface GetTweetResult { @@ -252,6 +253,8 @@ export interface TwitterClientOptions { cookies: TwitterCookies; userAgent?: string; timeoutMs?: number; + // Max depth for quoted tweets (0 disables). Defaults to 1. + quoteDepth?: number; } interface CreateTweetResponse { @@ -276,6 +279,7 @@ export class TwitterClient { private cookieHeader: string; private userAgent: string; private timeoutMs?: number; + private quoteDepth: number; private clientUuid: string; private clientDeviceId: string; private clientUserId?: string; @@ -291,6 +295,7 @@ export class TwitterClient { options.userAgent || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; this.timeoutMs = options.timeoutMs; + this.quoteDepth = normalizeQuoteDepth(options.quoteDepth); this.clientUuid = randomUUID(); this.clientDeviceId = randomUUID(); } @@ -686,7 +691,13 @@ export class TwitterClient { ); } - private mapTweetResult(result: GraphqlTweetResult | undefined): TweetData | undefined { + private unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined { + if (!result) return undefined; + if (result.tweet) return result.tweet; + return result; + } + + private mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth = this.quoteDepth): TweetData | undefined { const userResult = result?.core?.user_results?.result; const userLegacy = userResult?.legacy; const userCore = userResult?.core; @@ -698,26 +709,11 @@ export class TwitterClient { const text = this.extractTweetText(result); if (!text) return undefined; - // Extract quoted tweet if present - let quotedTweet: TweetData['quotedTweet'] = undefined; - const quotedResult = result.quoted_status_result?.result; - if (quotedResult?.rest_id) { - const quotedUserResult = quotedResult?.core?.user_results?.result; - const quotedUserLegacy = quotedUserResult?.legacy; - const quotedUserCore = quotedUserResult?.core; - const quotedUsername = quotedUserLegacy?.screen_name ?? quotedUserCore?.screen_name; - const quotedName = quotedUserLegacy?.name ?? quotedUserCore?.name ?? quotedUsername; - const quotedText = this.extractTweetText(quotedResult); - if (quotedUsername && quotedText) { - quotedTweet = { - id: quotedResult.rest_id, - text: quotedText, - author: { - username: quotedUsername, - name: quotedName || quotedUsername, - }, - authorId: quotedUserResult?.rest_id, - }; + let quotedTweet: TweetData | undefined; + if (quoteDepth > 0) { + const quotedResult = this.unwrapTweetResult(result.quoted_status_result?.result); + if (quotedResult) { + quotedTweet = this.mapTweetResult(quotedResult, quoteDepth - 1); } } From df6b92e39c97ee7584ff17da351b58aaf98ffcf8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 10:45:20 +0100 Subject: [PATCH 12/15] test: cover quoted tweet depth --- tests/twitter-client.test.ts | 91 +++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 5a17e9f..645f665 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { TwitterClient } from '../src/lib/twitter-client.js'; +import { type TweetData, TwitterClient } from '../src/lib/twitter-client.js'; describe('TwitterClient', () => { const originalFetch = global.fetch; @@ -952,6 +952,95 @@ describe('TwitterClient', () => { }); }); + describe('quoted tweets', () => { + const makeTweetResult = ( + id: string, + text: string, + username = `user${id}`, + name = `User ${id}`, + ): Record => ({ + rest_id: id, + legacy: { + full_text: text, + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: id, + }, + core: { + user_results: { + result: { + rest_id: `u${id}`, + legacy: { screen_name: username, name }, + }, + }, + }, + }); + + it('includes one level of quoted tweet by default', () => { + const quoted = makeTweetResult('2', 'quoted'); + const root = makeTweetResult('1', 'root'); + root.quoted_status_result = { result: quoted }; + + const client = new TwitterClient({ cookies: validCookies }); + const mapped = (client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }).mapTweetResult( + root, + ); + + expect(mapped?.quotedTweet?.id).toBe('2'); + expect(mapped?.quotedTweet?.quotedTweet).toBeUndefined(); + }); + + it('honors quoteDepth = 0', () => { + const quoted = makeTweetResult('2', 'quoted'); + const root = makeTweetResult('1', 'root'); + root.quoted_status_result = { result: quoted }; + + const client = new TwitterClient({ cookies: validCookies, quoteDepth: 0 }); + const mapped = (client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }).mapTweetResult( + root, + ); + + expect(mapped?.quotedTweet).toBeUndefined(); + }); + + it('recurses when quoteDepth > 1', () => { + const quoted2 = makeTweetResult('3', 'quoted2'); + const quoted1 = makeTweetResult('2', 'quoted1'); + quoted1.quoted_status_result = { result: quoted2 }; + const root = makeTweetResult('1', 'root'); + root.quoted_status_result = { result: quoted1 }; + + const client = new TwitterClient({ cookies: validCookies, quoteDepth: 2 }); + const mapped = (client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }).mapTweetResult( + root, + ); + + expect(mapped?.quotedTweet?.id).toBe('2'); + expect(mapped?.quotedTweet?.quotedTweet?.id).toBe('3'); + expect(mapped?.quotedTweet?.quotedTweet?.quotedTweet).toBeUndefined(); + }); + + it('unwraps quoted tweet visibility wrappers', () => { + const quoted = makeTweetResult('2', 'quoted'); + const root = makeTweetResult('1', 'root'); + root.quoted_status_result = { + result: { + __typename: 'TweetWithVisibilityResults', + tweet: quoted, + }, + }; + + const client = new TwitterClient({ cookies: validCookies }); + const mapped = (client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }).mapTweetResult( + root, + ); + + expect(mapped?.quotedTweet?.id).toBe('2'); + }); + }); + describe('search', () => { let mockFetch: ReturnType; From 529403e38669a58ce86f63fcd718bf6da59086fe Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 10:45:25 +0100 Subject: [PATCH 13/15] docs: document quoted tweet depth --- CHANGELOG.md | 3 +++ README.md | 9 ++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 661a665..05c54ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.4.1 — Unreleased +### Added +- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml). + ### Changed - Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`). diff --git a/README.md b/README.md index 0158651..12879fd 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ bird query-ids --fresh Global options: - `--timeout `: abort requests after the given timeout (milliseconds). +- `--quote-depth `: max quoted tweet depth in JSON output (default: 1; 0 disables). - `--plain`: stable output (no emoji, no color). - `--no-emoji`: disable emoji output. - `--no-color`: disable ANSI colors (or set `NO_COLOR=1`). @@ -108,12 +109,14 @@ Example `~/.config/bird/config.json5`: // Cookie source order for browser extraction (string or array) cookieSource: ["firefox", "safari"], firefoxProfile: "default-release", - timeoutMs: 20000 + timeoutMs: 20000, + quoteDepth: 1 } ``` Environment shortcuts: - `BIRD_TIMEOUT_MS` +- `BIRD_QUOTE_DEPTH` ## Output @@ -130,14 +133,14 @@ When using `--json`, tweet objects include: | `id` | string | Tweet ID | | `text` | string | Full tweet text (includes Note/Article content when present) | | `author` | object | `{ username, name }` | -| `authorId` | string | Author's user ID | +| `authorId` | string? | Author's user ID | | `createdAt` | string | Timestamp | | `replyCount` | number | Number of replies | | `retweetCount` | number | Number of retweets | | `likeCount` | number | Number of likes | | `conversationId` | string | Thread conversation ID | | `inReplyToStatusId` | string? | Parent tweet ID (present if this is a reply) | -| `quotedTweet` | object? | Embedded quote tweet with `{ id, text, author, authorId }` (present if this is a quote tweet) | +| `quotedTweet` | object? | Embedded quote tweet (same schema; depth controlled by `--quote-depth`) | ## Query IDs (GraphQL) From 68ec1d67bf492cb6709d7ea60bb4154bc1b6a5b2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 10:47:26 +0100 Subject: [PATCH 14/15] docs: update changelog for quoted tweets --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 296c4c0..07adda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - `likes` command to list your liked tweets (thanks @swairshah). +- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml). ### Changed - Query ID updater now tracks the Likes GraphQL operation. @@ -14,9 +15,6 @@ - `bookmarks` command to list your bookmarked tweets. - `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour). -### Added -- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml). - ### 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. From 9b7dab76439d7db2ad25978beb49cece8c2f5793 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 1 Jan 2026 10:48:34 +0100 Subject: [PATCH 15/15] style: satisfy lint for quote depth --- src/index.ts | 8 ++++++-- src/lib/twitter-client.ts | 16 ++++++++++++---- tests/twitter-client.test.ts | 24 ++++++++++++------------ 3 files changed, 30 insertions(+), 18 deletions(-) diff --git a/src/index.ts b/src/index.ts index c969b0f..099f7bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -245,9 +245,13 @@ function resolveTimeoutFromOptions(options: { timeout?: string | number }): numb function resolveQuoteDepth(...values: Array): number | undefined { for (const value of values) { - if (value === undefined || value === null || value === '') continue; + if (value === undefined || value === null || value === '') { + continue; + } const parsed = typeof value === 'number' ? value : Number.parseInt(value, 10); - if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed); + if (Number.isFinite(parsed) && parsed >= 0) { + return Math.floor(parsed); + } } return undefined; } diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 6ef1878..c84eeaa 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -42,8 +42,12 @@ const QUERY_IDS: Record = { const TARGET_QUERY_ID_OPERATIONS = Object.keys(FALLBACK_QUERY_IDS) as Array; function normalizeQuoteDepth(value?: number): number { - if (value === undefined || value === null) return 1; - if (!Number.isFinite(value)) return 1; + if (value === undefined || value === null) { + return 1; + } + if (!Number.isFinite(value)) { + return 1; + } return Math.max(0, Math.floor(value)); } @@ -729,8 +733,12 @@ export class TwitterClient { } private unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined { - if (!result) return undefined; - if (result.tweet) return result.tweet; + if (!result) { + return undefined; + } + if (result.tweet) { + return result.tweet; + } return result; } diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 5b86f1f..15e6128 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -992,9 +992,9 @@ describe('TwitterClient', () => { root.quoted_status_result = { result: quoted }; const client = new TwitterClient({ cookies: validCookies }); - const mapped = (client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }).mapTweetResult( - root, - ); + const mapped = ( + client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } + ).mapTweetResult(root); expect(mapped?.quotedTweet?.id).toBe('2'); expect(mapped?.quotedTweet?.quotedTweet).toBeUndefined(); @@ -1006,9 +1006,9 @@ describe('TwitterClient', () => { root.quoted_status_result = { result: quoted }; const client = new TwitterClient({ cookies: validCookies, quoteDepth: 0 }); - const mapped = (client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }).mapTweetResult( - root, - ); + const mapped = ( + client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } + ).mapTweetResult(root); expect(mapped?.quotedTweet).toBeUndefined(); }); @@ -1021,9 +1021,9 @@ describe('TwitterClient', () => { root.quoted_status_result = { result: quoted1 }; const client = new TwitterClient({ cookies: validCookies, quoteDepth: 2 }); - const mapped = (client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }).mapTweetResult( - root, - ); + const mapped = ( + client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } + ).mapTweetResult(root); expect(mapped?.quotedTweet?.id).toBe('2'); expect(mapped?.quotedTweet?.quotedTweet?.id).toBe('3'); @@ -1041,9 +1041,9 @@ describe('TwitterClient', () => { }; const client = new TwitterClient({ cookies: validCookies }); - const mapped = (client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined }).mapTweetResult( - root, - ); + const mapped = ( + client as unknown as { mapTweetResult: (result: unknown) => TweetData | undefined } + ).mapTweetResult(root); expect(mapped?.quotedTweet?.id).toBe('2'); });