From 16f21800b57a90c8b72edbd0b04624d5990d41a9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 02:41:08 +0000 Subject: [PATCH] refactor: align replies/thread pagination (#35) (thanks @crcatala) --- CHANGELOG.md | 1 + README.md | 8 ++-- src/commands/read.ts | 57 +++++++++++-------------- src/lib/twitter-client-tweet-detail.ts | 8 ++-- tests/commands.read.test.ts | 43 ++++++++++--------- tests/twitter-client.pagination.test.ts | 29 ++++++++++++- 6 files changed, 85 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11aae14..e69762b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0. - Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0. - `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala. +- `replies` and `thread` now support pagination (`--all`, `--max-pages`, `--cursor`, `--delay`) (#35) — thanks @crcatala. ### Fixed - macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst. diff --git a/README.md b/README.md index e946953..b1b4e3b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ bird 1234567890123456789 --json # Thread + replies bird thread https://x.com/user/status/1234567890123456789 bird replies 1234567890123456789 +bird replies 1234567890123456789 --max-pages 3 --json +bird thread 1234567890123456789 --max-pages 3 --json # Search + mentions bird search "from:steipete" -n 5 @@ -100,8 +102,8 @@ const result = await client.search('from:steipete', 50); - `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs. - `bird read [--json]` — fetch tweet content as text or JSON. - `bird [--json]` — shorthand for `read` when only a URL or ID is provided. -- `bird replies [--json]` — list replies to a tweet. -- `bird thread [--json]` — show the full conversation thread. +- `bird replies [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet. +- `bird thread [--all] [--max-pages n] [--cursor string] [--delay ms] [--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 user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline. @@ -177,7 +179,7 @@ Environment shortcuts: ## Output - `--json` prints raw tweet objects for read/replies/thread/search/mentions/user-tweets/bookmarks/likes. -- When using `--json` with pagination (`--cursor`, `--max-pages`, or `-n > 20`), `user-tweets` prints `{ tweets, nextCursor }`. +- When using `--json` with pagination (`--all`, `--cursor`, `--max-pages`, or for `user-tweets` when `-n > 20`), output is `{ tweets, nextCursor }`. - `read` returns full text for Notes and Articles when present. - Use `--plain` for stable, script-friendly output (no emoji, no color). diff --git a/src/commands/read.ts b/src/commands/read.ts index ccd61f1..7e371ae 100644 --- a/src/commands/read.ts +++ b/src/commands/read.ts @@ -50,8 +50,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { .description('List replies to a tweet (by ID or URL)') .argument('', 'Tweet ID or URL') .option('--all', 'Fetch all replies (paged)') - .option('--max-pages ', 'Stop after N pages when using --all') - .option('--delay ', 'Delay in ms between page fetches (default: 1000)') + .option('--max-pages ', 'Fetch N pages (implies pagination)') + .option('--delay ', 'Delay in ms between page fetches', '1000') .option('--cursor ', 'Resume pagination from a cursor') .option('--json', 'Output as JSON') .option('--json-full', 'Output as JSON with full raw API response in _raw field') @@ -72,13 +72,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); const tweetId = ctx.extractTweetId(tweetIdOrUrl); const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; - const pageDelayMs = cmdOpts.delay ? Number.parseInt(cmdOpts.delay, 10) : 1000; + const pageDelayMs = Number.parseInt(cmdOpts.delay || '1000', 10); - const usePagination = cmdOpts.all || cmdOpts.cursor; - if (maxPages !== undefined && !usePagination) { - console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`); - process.exit(1); - } + const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor || maxPages !== undefined); if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) { console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`); process.exit(1); @@ -111,18 +107,19 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { }) : await client.getReplies(tweetId, { includeRaw }); - if (result.success && result.tweets) { - const isJson = cmdOpts.json || cmdOpts.jsonFull; - if (isJson && usePagination) { - console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2)); - } else { - ctx.printTweets(result.tweets, { json: isJson, emptyMessage: 'No replies found.' }); - } + if (result.tweets) { + const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull); + ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage: 'No replies found.' }); // Show pagination hint if there's more if (result.nextCursor && !isJson) { console.error(`${ctx.p('info')}More replies available. Use --cursor "${result.nextCursor}" to continue.`); } + + if (!result.success) { + console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`); + process.exit(1); + } } else { console.error(`${ctx.p('err')}Failed to fetch replies: ${result.error}`); process.exit(1); @@ -135,8 +132,8 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { .description('Show the full conversation thread containing the tweet') .argument('', 'Tweet ID or URL') .option('--all', 'Fetch all thread tweets (paged)') - .option('--max-pages ', 'Stop after N pages when using --all') - .option('--delay ', 'Delay in ms between page fetches (default: 1000)') + .option('--max-pages ', 'Fetch N pages (implies pagination)') + .option('--delay ', 'Delay in ms between page fetches', '1000') .option('--cursor ', 'Resume pagination from a cursor') .option('--json', 'Output as JSON') .option('--json-full', 'Output as JSON with full raw API response in _raw field') @@ -157,13 +154,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); const tweetId = ctx.extractTweetId(tweetIdOrUrl); const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; - const pageDelayMs = cmdOpts.delay ? Number.parseInt(cmdOpts.delay, 10) : 1000; + const pageDelayMs = Number.parseInt(cmdOpts.delay || '1000', 10); - const usePagination = cmdOpts.all || cmdOpts.cursor; - if (maxPages !== undefined && !usePagination) { - console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`); - process.exit(1); - } + const usePagination = Boolean(cmdOpts.all || cmdOpts.cursor || maxPages !== undefined); if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) { console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`); process.exit(1); @@ -196,16 +189,9 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { }) : await client.getThread(tweetId, { includeRaw }); - if (result.success && result.tweets) { - const isJson = cmdOpts.json || cmdOpts.jsonFull; - if (isJson && usePagination) { - console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2)); - } else { - ctx.printTweets(result.tweets, { - json: isJson, - emptyMessage: 'No thread tweets found.', - }); - } + if (result.tweets) { + const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull); + ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage: 'No thread tweets found.' }); // Show pagination hint if there's more if (result.nextCursor && !isJson) { @@ -213,6 +199,11 @@ export function registerReadCommands(program: Command, ctx: CliContext): void { `${ctx.p('info')}More thread tweets available. Use --cursor "${result.nextCursor}" to continue.`, ); } + + if (!result.success) { + console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`); + process.exit(1); + } } else { console.error(`${ctx.p('err')}Failed to fetch thread: ${result.error}`); process.exit(1); diff --git a/src/lib/twitter-client-tweet-detail.ts b/src/lib/twitter-client-tweet-detail.ts index 62768ab..27f8553 100644 --- a/src/lib/twitter-client-tweet-detail.ts +++ b/src/lib/twitter-client-tweet-detail.ts @@ -383,7 +383,7 @@ export function withTweetDetails 0) { - return { success: true, tweets: allReplies, nextCursor: cursor, error: response.error }; + return { success: false, tweets: allReplies, nextCursor: cursor, error: response.error }; } return response; } @@ -402,7 +402,7 @@ export function withTweetDetails 0) { - return { success: true, tweets: allTweets, nextCursor: cursor, error: response.error }; + return { success: false, tweets: allTweets, nextCursor: cursor, error: response.error }; } return response; } @@ -467,7 +467,7 @@ export function withTweetDetails { const createMockContext = () => @@ -15,25 +16,25 @@ describe('replies command', () => { }), p: () => '', printTweets: () => undefined, + printTweetsResult: () => undefined, }) as unknown as CliContext; - it('requires --all or --cursor when --max-pages is provided', async () => { + it('uses pagination when --max-pages is provided', async () => { const program = new Command(); registerReadCommands(program, createMockContext()); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new Error(`exit ${code}`); - }) as never); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const pagedSpy = vi + .spyOn(TwitterClient.prototype, 'getRepliesPaged') + .mockResolvedValue({ success: true, tweets: [], nextCursor: undefined }); + const unpagedSpy = vi.spyOn(TwitterClient.prototype, 'getReplies').mockResolvedValue({ success: true, tweets: [] }); try { - await expect(program.parseAsync(['node', 'bird', 'replies', '123', '--max-pages', '2'])).rejects.toThrow( - 'exit 1', - ); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all or --cursor')); + await program.parseAsync(['node', 'bird', 'replies', '123', '--max-pages', '2', '--json']); + expect(pagedSpy).toHaveBeenCalledTimes(1); + expect(unpagedSpy).toHaveBeenCalledTimes(0); } finally { - exitSpy.mockRestore(); - errorSpy.mockRestore(); + pagedSpy.mockRestore(); + unpagedSpy.mockRestore(); } }); @@ -90,23 +91,25 @@ describe('thread command', () => { }), p: () => '', printTweets: () => undefined, + printTweetsResult: () => undefined, }) as unknown as CliContext; - it('requires --all or --cursor when --max-pages is provided', async () => { + it('uses pagination when --max-pages is provided', async () => { const program = new Command(); registerReadCommands(program, createMockContext()); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { - throw new Error(`exit ${code}`); - }) as never); - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const pagedSpy = vi + .spyOn(TwitterClient.prototype, 'getThreadPaged') + .mockResolvedValue({ success: true, tweets: [], nextCursor: undefined }); + const unpagedSpy = vi.spyOn(TwitterClient.prototype, 'getThread').mockResolvedValue({ success: true, tweets: [] }); try { - await expect(program.parseAsync(['node', 'bird', 'thread', '123', '--max-pages', '2'])).rejects.toThrow('exit 1'); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all or --cursor')); + await program.parseAsync(['node', 'bird', 'thread', '123', '--max-pages', '2', '--json']); + expect(pagedSpy).toHaveBeenCalledTimes(1); + expect(unpagedSpy).toHaveBeenCalledTimes(0); } finally { - exitSpy.mockRestore(); - errorSpy.mockRestore(); + pagedSpy.mockRestore(); + unpagedSpy.mockRestore(); } }); diff --git a/tests/twitter-client.pagination.test.ts b/tests/twitter-client.pagination.test.ts index c34f7d2..9595dd5 100644 --- a/tests/twitter-client.pagination.test.ts +++ b/tests/twitter-client.pagination.test.ts @@ -185,11 +185,38 @@ describe('TwitterClient pagination for thread and replies', () => { const client = new TwitterClient({ cookies: validCookies }); const result = await client.getRepliesPaged('1', { pageDelayMs: 0 }); - expect(result.success).toBe(true); + expect(result.success).toBe(false); expect(result.tweets?.length).toBe(2); expect(result.error).toContain('500'); expect(result.nextCursor).toBe('cursor-page-2'); }); + + it('continues pagination even when a page contains no replies', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => makeConversationPayload(['2'], 'cursor-page-2', '1'), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => makeConversationPayload(['10'], 'cursor-page-3', undefined), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => makeConversationPayload(['3'], undefined, '1'), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getRepliesPaged('1', { pageDelayMs: 0 }); + + expect(result.success).toBe(true); + expect(result.tweets?.map((t) => t.id)).toEqual(['2', '3']); + expect(result.nextCursor).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); }); describe('getThreadPaged', () => {