From bfcc6910d71b088e7280fbd942a24e2d207315f1 Mon Sep 17 00:00:00 2001 From: the-vampiire <25523682+the-vampiire@users.noreply.github.com> Date: Wed, 21 Jan 2026 19:51:15 -0500 Subject: [PATCH] feat: add pagination control to home timeline methods Adds `cursor` and `maxPages` options to `getHomeTimeline()` and `getHomeLatestTimeline()`, with `nextCursor` in the response. This enables: - Resumable pagination (start from a saved position) - Controlled page fetching (fetch N pages, get cursor for more) - Consistent API with other paginated methods Co-Authored-By: Claude Opus 4.5 --- src/lib/twitter-client-home.ts | 19 +- tests/cli-shared.test.ts | 8 +- tests/twitter-client.home-timeline.test.ts | 198 +++++++++++++++++++++ 3 files changed, 216 insertions(+), 9 deletions(-) diff --git a/src/lib/twitter-client-home.ts b/src/lib/twitter-client-home.ts index 61f77f7..5725de8 100644 --- a/src/lib/twitter-client-home.ts +++ b/src/lib/twitter-client-home.ts @@ -14,6 +14,10 @@ function isQueryIdMismatch(errors: Array<{ message?: string }>): boolean { export interface HomeTimelineFetchOptions { /** Include raw GraphQL response in `_raw` field */ includeRaw?: boolean; + /** Cursor for pagination - continue from this position */ + cursor?: string; + /** Maximum number of pages to fetch (returns nextCursor if more available) */ + maxPages?: number; } export interface TwitterClientHomeMethods { @@ -59,12 +63,14 @@ export function withHome>( count: number, options: HomeTimelineFetchOptions, ): Promise { - const { includeRaw = false } = options; + const { includeRaw = false, cursor: externalCursor, maxPages } = options; const features = buildHomeTimelineFeatures(); const pageSize = 20; const seen = new Set(); const tweets: TweetData[] = []; - let cursor: string | undefined; + let cursor: string | undefined = externalCursor; + let nextCursor: string | undefined; + let pagesFetched = 0; const fetchPage = async (pageCount: number, pageCursor?: string) => { let lastError: string | undefined; @@ -170,6 +176,7 @@ export function withHome>( while (tweets.length < count) { const pageCount = Math.min(pageSize, count - tweets.length); const page = await fetchWithRefresh(pageCount, cursor); + pagesFetched += 1; if (!page.success) { return { success: false, error: page.error }; } @@ -188,12 +195,18 @@ export function withHome>( } if (!page.cursor || page.cursor === cursor || page.tweets.length === 0 || added === 0) { + nextCursor = undefined; + break; + } + if (maxPages && pagesFetched >= maxPages) { + nextCursor = page.cursor; break; } cursor = page.cursor; + nextCursor = page.cursor; } - return { success: true, tweets }; + return { success: true, tweets, nextCursor }; } } diff --git a/tests/cli-shared.test.ts b/tests/cli-shared.test.ts index c7554a6..cdaa8ea 100644 --- a/tests/cli-shared.test.ts +++ b/tests/cli-shared.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -40,11 +40,7 @@ describe('cli shared', () => { const tempHome = mkdtempSync(join(tmpdir(), 'bird-home-')); const configDir = join(tempHome, '.config', 'bird'); mkdirSync(configDir, { recursive: true }); - writeFileSync( - join(configDir, 'config.json5'), - '{ chromeProfileDir: "/tmp/Brave/Profile 1" }', - 'utf8', - ); + writeFileSync(join(configDir, 'config.json5'), '{ chromeProfileDir: "/tmp/Brave/Profile 1" }', 'utf8'); process.env.HOME = tempHome; try { diff --git a/tests/twitter-client.home-timeline.test.ts b/tests/twitter-client.home-timeline.test.ts index 607e82a..3b14018 100644 --- a/tests/twitter-client.home-timeline.test.ts +++ b/tests/twitter-client.home-timeline.test.ts @@ -340,5 +340,203 @@ describe('TwitterClient home timeline', () => { expect(result.tweets).toHaveLength(1); expect(mockFetch).toHaveBeenCalledTimes(2); }); + + it('respects maxPages option', async () => { + // First page with cursor + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + home: { + home_timeline_urt: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'tweet1', + legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' }, + core: { + user_results: { + result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'cursor-page-2', + }, + }, + ], + }, + ], + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getHomeTimeline(10, { maxPages: 1 }); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(1); + expect(result.nextCursor).toBe('cursor-page-2'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('resumes from provided cursor', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + home: { + home_timeline_urt: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'tweet2', + legacy: { + full_text: 'Second page tweet', + created_at: 'Mon Jan 06 00:00:00 +0000 2025', + }, + core: { + user_results: { + result: { rest_id: 'u2', legacy: { screen_name: 'user2', name: 'User 2' } }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + ], + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getHomeTimeline(1, { cursor: 'resume-cursor' }); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(1); + expect(result.tweets?.[0].id).toBe('tweet2'); + + // Verify cursor was passed in the request + const url = mockFetch.mock.calls[0][0] as string; + expect(url).toContain('resume-cursor'); + }); + + it('returns undefined nextCursor when no more pages', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + home: { + home_timeline_urt: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'tweet1', + legacy: { full_text: 'Only tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' }, + core: { + user_results: { + result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } }, + }, + }, + }, + }, + }, + }, + }, + // No cursor entry - end of timeline + ], + }, + ], + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getHomeTimeline(1); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(1); + expect(result.nextCursor).toBeUndefined(); + }); + + it('works with getHomeLatestTimeline too', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + data: { + home: { + home_timeline_urt: { + instructions: [ + { + entries: [ + { + content: { + itemContent: { + tweet_results: { + result: { + rest_id: 'latest1', + legacy: { full_text: 'Latest tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' }, + core: { + user_results: { + result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } }, + }, + }, + }, + }, + }, + }, + }, + { + content: { + cursorType: 'Bottom', + value: 'latest-cursor', + }, + }, + ], + }, + ], + }, + }, + }, + }), + }); + + const client = new TwitterClient({ cookies: validCookies }); + const result = await client.getHomeLatestTimeline(10, { maxPages: 1 }); + + expect(result.success).toBe(true); + expect(result.tweets).toHaveLength(1); + expect(result.nextCursor).toBe('latest-cursor'); + }); }); });