feat: add cursor-based pagination for following/followers commands
Add pagination support to `bird following` and `bird followers` commands, similar to existing pagination in search/bookmarks/likes. Changes: - Add `cursor` parameter to `getFollowing()` and `getFollowers()` client methods - Return `nextCursor` in `FollowingResult` for pagination continuation - Add `--cursor <cursor>` CLI option for manual pagination - Add `--all` CLI flag to automatically fetch all pages - Add `--max-pages <number>` option to limit pages when using --all - Add input validation for --max-pages (requires --all or --cursor) - Add deduplication using Set to prevent duplicate users - Add 1-second delay between pages to avoid overwhelming the API - Update `-n/--count` description to clarify it's per-page - Add unit tests for cursor parameter and nextCursor response Usage examples: # Fetch first page (default 20 users) bird following # Fetch with specific page size bird following -n 50 # Use cursor for next page bird following --cursor "CURSOR_FROM_PREVIOUS" # Fetch ALL following users automatically (with rate limiting) bird following --all --json # Limit to first 5 pages bird following --all --max-pages 5 # Same options work for followers bird followers --all Note: REST API fallback does not support cursor pagination. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
committed by
Peter Steinberger
co-authored by
Claude Opus 4.5
parent
ad125348d6
commit
39a477265c
@@ -280,6 +280,160 @@ describe('TwitterClient following/followers', () => {
|
||||
expect(urls[1]).toContain('/Followers?');
|
||||
expect(urls[2]).toContain('/followers/list.json?');
|
||||
});
|
||||
|
||||
it('passes cursor parameter to following API and returns nextCursor', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
user: {
|
||||
result: {
|
||||
timeline: {
|
||||
timeline: {
|
||||
instructions: [
|
||||
{
|
||||
type: 'TimelineAddEntries',
|
||||
entries: [
|
||||
{
|
||||
content: {
|
||||
itemContent: {
|
||||
user_results: {
|
||||
result: makeUserResult('1', 'alpha', 'Alpha'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
content: {
|
||||
cursorType: 'Bottom',
|
||||
value: 'next-cursor-abc123',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
|
||||
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
||||
|
||||
const result = await client.getFollowing('123', 20, 'prev-cursor-xyz');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.users?.length).toBe(1);
|
||||
expect(result.nextCursor).toBe('next-cursor-abc123');
|
||||
|
||||
// Verify cursor was passed in the request
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
|
||||
expect(parsedVars.cursor).toBe('prev-cursor-xyz');
|
||||
});
|
||||
|
||||
it('passes cursor parameter to followers API and returns nextCursor', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
user: {
|
||||
result: {
|
||||
timeline: {
|
||||
timeline: {
|
||||
instructions: [
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
content: {
|
||||
itemContent: {
|
||||
user_results: {
|
||||
result: makeUserResult('9', 'beta', 'Beta'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
content: {
|
||||
cursorType: 'Bottom',
|
||||
value: 'followers-next-cursor',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const clientPrivate = client as unknown as TwitterClient & { getFollowersQueryIds: () => Promise<string[]> };
|
||||
clientPrivate.getFollowersQueryIds = async () => ['test'];
|
||||
|
||||
const result = await client.getFollowers('456', 50, 'my-cursor');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.users?.[0].username).toBe('beta');
|
||||
expect(result.nextCursor).toBe('followers-next-cursor');
|
||||
|
||||
// Verify cursor was passed in the request
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
|
||||
expect(parsedVars.cursor).toBe('my-cursor');
|
||||
});
|
||||
|
||||
it('returns undefined nextCursor when no cursor in response', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
user: {
|
||||
result: {
|
||||
timeline: {
|
||||
timeline: {
|
||||
instructions: [
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
content: {
|
||||
itemContent: {
|
||||
user_results: {
|
||||
result: makeUserResult('1', 'only', 'Only'),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const clientPrivate = client as unknown as TwitterClient & { getFollowingQueryIds: () => Promise<string[]> };
|
||||
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
||||
|
||||
const result = await client.getFollowing('123', 20);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TwitterClient likes', () => {
|
||||
|
||||
Reference in New Issue
Block a user