From bc744a3bf9c1891c116b3408040b3563dbf6e674 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 07:06:18 +0000 Subject: [PATCH 1/2] feat: add full pagination support to likes (parity with bookmarks) Library changes: - Add getAllLikes(options) method for fetching all likes with cursor support - Refactor getLikes to use private getLikesPaged helper - Return nextCursor for resumable pagination - Support maxPages option to limit pages fetched CLI changes: - Add --all flag to fetch all likes - Add --max-pages to limit pagination - Add --cursor to resume from previous fetch - Output nextCursor in JSON mode for resumable pagination This brings likes to feature parity with bookmarks. Co-Authored-By: Claude Opus 4.5 # Conflicts: # src/commands/users.ts --- src/commands/users.ts | 85 ++++++++++++++++++++--------- src/lib/twitter-client-timelines.ts | 43 +++++++++++---- 2 files changed, 90 insertions(+), 38 deletions(-) diff --git a/src/commands/users.ts b/src/commands/users.ts index 77cc26e..00d7bba 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -328,39 +328,72 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { .command('likes') .description('Get your liked tweets') .option('-n, --count ', 'Number of likes to fetch', '20') + .option('--all', 'Fetch all likes (paged)') + .option('--max-pages ', 'Stop after N pages when using --all') + .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') - .action(async (cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean }) => { - const opts = program.opts(); - const timeoutMs = ctx.resolveTimeoutFromOptions(opts); - const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); - const count = Number.parseInt(cmdOpts.count || '20', 10); + .action( + async (cmdOpts: { + count?: string; + json?: boolean; + jsonFull?: boolean; + all?: boolean; + maxPages?: string; + cursor?: string; + }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; - const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); - for (const warning of warnings) { - console.error(`${ctx.p('warn')}${warning}`); - } + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } - if (!cookies.authToken || !cookies.ct0) { - console.error(`${ctx.p('err')}Missing required credentials`); - process.exit(1); - } + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } - const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); - const includeRaw = cmdOpts.jsonFull ?? false; - const result = await client.getLikes(count, { includeRaw }); + const usePagination = cmdOpts.all || cmdOpts.cursor; + if (maxPages !== undefined && !usePagination) { + console.error(`${ctx.p('err')}--max-pages requires --all or --cursor.`); + process.exit(1); + } + if (!usePagination && (!Number.isFinite(count) || count <= 0)) { + console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`); + process.exit(1); + } + if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) { + console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`); + process.exit(1); + } - if (result.success) { - ctx.printTweets(result.tweets, { - json: cmdOpts.json || cmdOpts.jsonFull, - emptyMessage: 'No liked tweets found.', - }); - } else { - console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`); - process.exit(1); - } - }); + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); + const includeRaw = cmdOpts.jsonFull ?? false; + const timelineOptions = { includeRaw }; + const paginationOptions = { includeRaw, maxPages, cursor: cmdOpts.cursor }; + const result = usePagination + ? await client.getAllLikes(paginationOptions) + : await client.getLikes(count, timelineOptions); + + if (result.success) { + const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull); + ctx.printTweetsResult(result, { + json: isJson, + usePagination: Boolean(usePagination), + emptyMessage: 'No liked tweets found.', + }); + } else { + console.error(`${ctx.p('err')}Failed to fetch likes: ${result.error}`); + process.exit(1); + } + }, + ); program .command('whoami') diff --git a/src/lib/twitter-client-timelines.ts b/src/lib/twitter-client-timelines.ts index b7e7ba8..557f6cd 100644 --- a/src/lib/twitter-client-timelines.ts +++ b/src/lib/twitter-client-timelines.ts @@ -21,6 +21,7 @@ export interface TwitterClientTimelineMethods { getBookmarks(count?: number, options?: TimelineFetchOptions): Promise; getAllBookmarks(options?: TimelinePaginationOptions): Promise; getLikes(count?: number, options?: TimelineFetchOptions): Promise; + getAllLikes(options?: TimelinePaginationOptions): Promise; getBookmarkFolderTimeline(folderId: string, count?: number, options?: TimelineFetchOptions): Promise; getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise; } @@ -75,18 +76,27 @@ export function withTimelines { - const { includeRaw = false } = options; + return this.getLikesPaged(count, options); + } + + async getAllLikes(options?: TimelinePaginationOptions): Promise { + return this.getLikesPaged(Number.POSITIVE_INFINITY, options); + } + + private async getLikesPaged(limit: number, options: TimelinePaginationOptions = {}): Promise { const userResult = await this.getCurrentUser(); if (!userResult.success || !userResult.user) { return { success: false, error: userResult.error ?? 'Could not determine current user' }; } - const userId = userResult.user.id; + const features = buildLikesFeatures(); const pageSize = 20; const seen = new Set(); const tweets: TweetData[] = []; - let cursor: string | undefined; - const features = buildLikesFeatures(); + let cursor: string | undefined = options.cursor; + let nextCursor: string | undefined; + let pagesFetched = 0; + const { includeRaw = false, maxPages } = options; const fetchPage = async (pageCount: number, pageCursor?: string) => { let lastError: string | undefined; @@ -164,9 +174,9 @@ export function withTimelines= count) { + if (!unlimited && tweets.length >= limit) { break; } } - if (!page.cursor || page.cursor === cursor || page.tweets.length === 0 || added === 0) { + const pageCursor = page.cursor; + if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) { + nextCursor = undefined; break; } - cursor = page.cursor; + if (maxPages && pagesFetched >= maxPages) { + nextCursor = pageCursor; + break; + } + cursor = pageCursor; + nextCursor = pageCursor; } - return { success: true, tweets }; + return { success: true, tweets, nextCursor }; } /** From afa7063a72213a55bd2b051dc3d306b32ae28ea5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 07:07:47 +0000 Subject: [PATCH 2/2] fix: guard pagination limits (#44) (thanks @jsholmes) --- CHANGELOG.md | 1 + src/lib/twitter-client-lists.ts | 2 +- src/lib/twitter-client-search.ts | 2 +- src/lib/twitter-client-timelines.ts | 6 +-- tests/twitter-client.likes.test.ts | 73 +++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f12d8e7..1826e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ - Long-form article tweets now render rich Draft.js content blocks/entities (#36) — thanks @crcatala. - `news`/`trending` command for Explore tabs with AI-curated headlines (#39) — thanks @aavetis. - `search` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#42) — thanks @pjtf93. +- `likes` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#44) — thanks @jsholmes. ### Changed - Library typing: `SearchResult` is now a discriminated union (so `error` only exists when `success: false`). diff --git a/src/lib/twitter-client-lists.ts b/src/lib/twitter-client-lists.ts index d1f1e33..877d5a3 100644 --- a/src/lib/twitter-client-lists.ts +++ b/src/lib/twitter-client-lists.ts @@ -449,7 +449,7 @@ export function withLists>( return { success: false as const, error: firstAttempt.error }; }; - const unlimited = !Number.isFinite(limit); + const unlimited = limit === Number.POSITIVE_INFINITY; while (unlimited || tweets.length < limit) { const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length); const page = await fetchWithRefresh(pageCount, cursor); diff --git a/src/lib/twitter-client-search.ts b/src/lib/twitter-client-search.ts index 5c87d17..7848d69 100644 --- a/src/lib/twitter-client-search.ts +++ b/src/lib/twitter-client-search.ts @@ -209,7 +209,7 @@ export function withSearch> return { success: false as const, error: firstAttempt.error }; }; - const unlimited = !Number.isFinite(limit); + const unlimited = limit === Number.POSITIVE_INFINITY; while (unlimited || tweets.length < limit) { const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length); const page = await fetchWithRefresh(pageCount, cursor); diff --git a/src/lib/twitter-client-timelines.ts b/src/lib/twitter-client-timelines.ts index 557f6cd..9494312 100644 --- a/src/lib/twitter-client-timelines.ts +++ b/src/lib/twitter-client-timelines.ts @@ -204,7 +204,7 @@ export function withTimelines { expect(secondVars.cursor).toBe('cursor-1'); }); + it('respects maxPages when fetching all likes', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + timeline: { + timeline: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: '1', + legacy: { + full_text: 'liked page 1', + created_at: '2024-01-01T00:00:00Z', + reply_count: 0, + retweet_count: 0, + favorite_count: 0, + conversation_id_str: '1', + }, + core: { + user_results: { + result: { + rest_id: 'u1', + legacy: { screen_name: 'root', name: 'Root' }, + }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'cursor-1', + }, + }, + ], + }, + ], + }, + }, + }, + }, + }, + }), + }); + + 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.getAllLikes({ maxPages: 1 }); + + expect(result.success).toBe(true); + expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']); + expect(result.nextCursor).toBe('cursor-1'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('returns an error when current user is unavailable', async () => { const client = new TwitterClient({ cookies: validCookies }); const clientPrivate = client as unknown as TwitterClientPrivate;