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,8 @@ export interface FollowingResult {
|
||||
success: boolean;
|
||||
users?: TwitterUser[];
|
||||
error?: string;
|
||||
/** Cursor for fetching the next page of results */
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface TwitterClientOptions {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user