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:
Micah Alpern
2026-01-11 10:59:35 +00:00
committed by Peter Steinberger
co-authored by Claude Opus 4.5
parent ad125348d6
commit 39a477265c
4 changed files with 424 additions and 69 deletions
+2
View File
@@ -280,6 +280,8 @@ export interface FollowingResult {
success: boolean;
users?: TwitterUser[];
error?: string;
/** Cursor for fetching the next page of results */
nextCursor?: string;
}
export interface TwitterClientOptions {
+29 -13
View File
@@ -7,12 +7,12 @@ import {
} from './twitter-client-constants.js';
import { buildFollowingFeatures } from './twitter-client-features.js';
import type { CurrentUserResult, FollowingResult } from './twitter-client-types.js';
import { parseUsersFromInstructions } from './twitter-client-utils.js';
import { extractCursorFromInstructions, parseUsersFromInstructions } from './twitter-client-utils.js';
export interface TwitterClientUserMethods {
getCurrentUser(): Promise<CurrentUserResult>;
getFollowing(userId: string, count?: number): Promise<FollowingResult>;
getFollowers(userId: string, count?: number): Promise<FollowingResult>;
getFollowing(userId: string, count?: number, cursor?: string): Promise<FollowingResult>;
getFollowers(userId: string, count?: number, cursor?: string): Promise<FollowingResult>;
}
export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
@@ -309,13 +309,17 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
/**
* Get users that a user is following
*/
async getFollowing(userId: string, count = 20): Promise<FollowingResult> {
const variables = {
async getFollowing(userId: string, count = 20, cursor?: string): Promise<FollowingResult> {
const variables: Record<string, unknown> = {
userId,
count,
includePromotedContent: false,
};
if (cursor) {
variables.cursor = cursor;
}
const features = buildFollowingFeatures();
const params = new URLSearchParams({
@@ -369,8 +373,11 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = parseUsersFromInstructions(instructions);
const nextCursor = extractCursorFromInstructions(
instructions as Array<{ entries?: Array<{ content?: unknown }> }> | undefined,
);
return { success: true as const, users, had404 };
return { success: true as const, users, nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
@@ -381,18 +388,19 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, users: firstAttempt.users };
return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users };
return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor };
}
// GraphQL Following can also return 404 (queryId churn / endpoint flakiness).
// Fallback to the internal v1.1 REST endpoint used by the web client (cookie-auth; no dev API key).
// Note: REST fallback does not support cursor pagination.
const restAttempt = await this.getFollowingViaRest(userId, count);
if (restAttempt.success) {
return restAttempt;
@@ -407,13 +415,17 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
/**
* Get users that follow a user
*/
async getFollowers(userId: string, count = 20): Promise<FollowingResult> {
const variables = {
async getFollowers(userId: string, count = 20, cursor?: string): Promise<FollowingResult> {
const variables: Record<string, unknown> = {
userId,
count,
includePromotedContent: false,
};
if (cursor) {
variables.cursor = cursor;
}
const features = buildFollowingFeatures();
const params = new URLSearchParams({
@@ -467,8 +479,11 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = parseUsersFromInstructions(instructions);
const nextCursor = extractCursorFromInstructions(
instructions as Array<{ entries?: Array<{ content?: unknown }> }> | undefined,
);
return { success: true as const, users, had404 };
return { success: true as const, users, nextCursor, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
@@ -479,18 +494,19 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return { success: true, users: firstAttempt.users };
return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users };
return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor };
}
// GraphQL Followers regularly returns 404 (queryId churn / endpoint flakiness).
// Fallback to the internal v1.1 REST endpoint used by the web client (cookie-auth; no dev API key).
// Note: REST fallback does not support cursor pagination.
const restAttempt = await this.getFollowersViaRest(userId, count);
if (restAttempt.success) {
return restAttempt;