From bc744a3bf9c1891c116b3408040b3563dbf6e674 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 07:06:18 +0000 Subject: [PATCH] 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 }; } /**