From ee9c89018e2cd6d804096f13a7570fa485f6d79e Mon Sep 17 00:00:00 2001 From: Tyler Seymour Date: Wed, 31 Dec 2025 15:20:36 -0500 Subject: [PATCH] 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;