feat: add following and followers commands
Add two new commands to list users: - `bird following` - list users that you (or another user) follow - `bird followers` - list users that follow you (or another user) Both commands support: - `--user <userId>` to query another user's following/followers - `-n <count>` to limit results - `--json` for structured output Uses Twitter's GraphQL Following/Followers endpoints with proper feature flags and query ID rotation support.
This commit is contained in:
@@ -51,6 +51,14 @@ bird mentions --user @steipete -n 5
|
||||
# Bookmarks
|
||||
bird bookmarks -n 5
|
||||
|
||||
# Following (who you follow)
|
||||
bird following -n 20
|
||||
bird following --user 12345678 -n 10 # by user ID
|
||||
|
||||
# Followers (who follows you)
|
||||
bird followers -n 20
|
||||
bird followers --user 12345678 -n 10 # by user ID
|
||||
|
||||
# Refresh GraphQL query IDs cache (no rebuild)
|
||||
bird query-ids --fresh
|
||||
```
|
||||
@@ -68,6 +76,8 @@ bird query-ids --fresh
|
||||
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
||||
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
|
||||
- `bird bookmarks [-n count] [--json]` — list your bookmarked tweets.
|
||||
- `bird following [--user <userId>] [-n count] [--json]` — list users that you (or another user) follow.
|
||||
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user).
|
||||
- `bird whoami` — print which Twitter account your cookies belong to.
|
||||
- `bird check` — show which credentials are available and where they were sourced from.
|
||||
|
||||
|
||||
+128
@@ -150,6 +150,8 @@ const KNOWN_COMMANDS = new Set([
|
||||
'search',
|
||||
'mentions',
|
||||
'bookmarks',
|
||||
'following',
|
||||
'followers',
|
||||
'help',
|
||||
'whoami',
|
||||
'check',
|
||||
@@ -741,6 +743,132 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Following command - get users that a user follows
|
||||
program
|
||||
.command('following')
|
||||
.description('Get users that you (or another user) follow')
|
||||
.option('--user <userId>', 'User ID to get following for (defaults to current user)')
|
||||
.option('-n, --count <number>', 'Number of users to fetch', '20')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||
|
||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.error(`${p('warn')}${warning}`);
|
||||
}
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
|
||||
// Get user ID - either from option or current user
|
||||
let userId = cmdOpts.user;
|
||||
if (!userId) {
|
||||
const currentUser = await client.getCurrentUser();
|
||||
if (!currentUser.success || !currentUser.user?.id) {
|
||||
console.error(`${p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
userId = currentUser.user.id;
|
||||
}
|
||||
|
||||
const result = await client.getFollowing(userId, count);
|
||||
|
||||
if (result.success && result.users) {
|
||||
if (cmdOpts.json) {
|
||||
console.log(JSON.stringify(result.users, null, 2));
|
||||
} else {
|
||||
if (result.users.length === 0) {
|
||||
console.log('No users found.');
|
||||
} else {
|
||||
for (const user of result.users) {
|
||||
console.log(`@${user.username} (${user.name})`);
|
||||
if (user.description) {
|
||||
console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`);
|
||||
}
|
||||
if (user.followers_count !== undefined) {
|
||||
console.log(` ${p('info')}${user.followers_count.toLocaleString()} followers`);
|
||||
}
|
||||
console.log('──────────────────────────────────────────────────');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`${p('err')}Failed to fetch following: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Followers command - get users that follow a user
|
||||
program
|
||||
.command('followers')
|
||||
.description('Get users that follow you (or another user)')
|
||||
.option('--user <userId>', 'User ID to get followers for (defaults to current user)')
|
||||
.option('-n, --count <number>', 'Number of users to fetch', '20')
|
||||
.option('--json', 'Output as JSON')
|
||||
.action(async (cmdOpts: { user?: string; count?: string; json?: boolean }) => {
|
||||
const opts = program.opts();
|
||||
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||
|
||||
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||
|
||||
for (const warning of warnings) {
|
||||
console.error(`${p('warn')}${warning}`);
|
||||
}
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
console.error(`${p('err')}Missing required credentials`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
|
||||
// Get user ID - either from option or current user
|
||||
let userId = cmdOpts.user;
|
||||
if (!userId) {
|
||||
const currentUser = await client.getCurrentUser();
|
||||
if (!currentUser.success || !currentUser.user?.id) {
|
||||
console.error(`${p('err')}Failed to get current user: ${currentUser.error || 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
userId = currentUser.user.id;
|
||||
}
|
||||
|
||||
const result = await client.getFollowers(userId, count);
|
||||
|
||||
if (result.success && result.users) {
|
||||
if (cmdOpts.json) {
|
||||
console.log(JSON.stringify(result.users, null, 2));
|
||||
} else {
|
||||
if (result.users.length === 0) {
|
||||
console.log('No users found.');
|
||||
} else {
|
||||
for (const user of result.users) {
|
||||
console.log(`@${user.username} (${user.name})`);
|
||||
if (user.description) {
|
||||
console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`);
|
||||
}
|
||||
if (user.followers_count !== undefined) {
|
||||
console.log(` ${p('info')}${user.followers_count.toLocaleString()} followers`);
|
||||
}
|
||||
console.log('──────────────────────────────────────────────────');
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(`${p('err')}Failed to fetch followers: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Whoami command - show the logged-in account
|
||||
program
|
||||
.command('whoami')
|
||||
|
||||
@@ -3,5 +3,7 @@
|
||||
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
|
||||
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||
"TweetDetail": "97JF30KziU00483E_8elBA",
|
||||
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
|
||||
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
|
||||
"Following": "BEkNpEt5pNETESoqMsTEGA",
|
||||
"Followers": "kuFUYP9eV1FPoEy4N-pi7w"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const FALLBACK_QUERY_IDS = {
|
||||
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
||||
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
||||
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
||||
Following: 'BEkNpEt5pNETESoqMsTEGA',
|
||||
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
|
||||
} as const;
|
||||
|
||||
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
|
||||
@@ -240,6 +242,24 @@ export interface CurrentUserResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TwitterUser {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
followers_count?: number;
|
||||
following_count?: number;
|
||||
is_blue_verified?: boolean;
|
||||
profile_image_url?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface FollowingResult {
|
||||
success: boolean;
|
||||
users?: TwitterUser[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TwitterClientOptions {
|
||||
cookies: TwitterCookies;
|
||||
userAgent?: string;
|
||||
@@ -1987,4 +2007,226 @@ export class TwitterClient {
|
||||
|
||||
return { success: false, error: firstAttempt.error };
|
||||
}
|
||||
|
||||
private buildFollowingFeatures(): Record<string, boolean> {
|
||||
return {
|
||||
rweb_video_screen_enabled: true,
|
||||
profile_label_improvements_pcf_label_in_post_enabled: false,
|
||||
responsive_web_profile_redirect_enabled: true,
|
||||
rweb_tipjar_consumption_enabled: true,
|
||||
verified_phone_label_enabled: false,
|
||||
creator_subscriptions_tweet_preview_api_enabled: true,
|
||||
responsive_web_graphql_timeline_navigation_enabled: true,
|
||||
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
||||
premium_content_api_read_enabled: true,
|
||||
communities_web_enable_tweet_community_results_fetch: true,
|
||||
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
||||
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
||||
responsive_web_grok_analyze_post_followups_enabled: false,
|
||||
responsive_web_jetfuel_frame: false,
|
||||
responsive_web_grok_share_attachment_enabled: false,
|
||||
articles_preview_enabled: true,
|
||||
responsive_web_edit_tweet_api_enabled: true,
|
||||
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
||||
view_counts_everywhere_api_enabled: true,
|
||||
longform_notetweets_consumption_enabled: true,
|
||||
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
||||
tweet_awards_web_tipping_enabled: true,
|
||||
responsive_web_grok_show_grok_translated_post: false,
|
||||
responsive_web_grok_analysis_button_from_backend: false,
|
||||
creator_subscriptions_quote_tweet_preview_enabled: false,
|
||||
freedom_of_speech_not_reach_fetch_enabled: true,
|
||||
standardized_nudges_misinfo: true,
|
||||
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
||||
longform_notetweets_rich_text_read_enabled: true,
|
||||
longform_notetweets_inline_media_enabled: true,
|
||||
responsive_web_grok_image_annotation_enabled: false,
|
||||
responsive_web_grok_imagine_annotation_enabled: false,
|
||||
responsive_web_grok_community_note_auto_translation_is_enabled: false,
|
||||
responsive_web_enhance_cards_enabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
private parseUsersFromInstructions(
|
||||
instructions: Array<{ type?: string; entries?: Array<unknown> }> | undefined,
|
||||
): TwitterUser[] {
|
||||
if (!instructions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const users: TwitterUser[] = [];
|
||||
|
||||
for (const instruction of instructions) {
|
||||
if (instruction.type !== 'TimelineAddEntries' || !instruction.entries) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of instruction.entries) {
|
||||
const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content;
|
||||
const userResult = content?.itemContent?.user_results?.result as
|
||||
| {
|
||||
__typename?: string;
|
||||
rest_id?: string;
|
||||
is_blue_verified?: boolean;
|
||||
legacy?: {
|
||||
screen_name?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
followers_count?: number;
|
||||
friends_count?: number;
|
||||
profile_image_url_https?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
core?: {
|
||||
screen_name?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
avatar?: {
|
||||
image_url?: string;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (!userResult || userResult.__typename !== 'User') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const legacy = userResult.legacy;
|
||||
const core = userResult.core;
|
||||
|
||||
users.push({
|
||||
id: userResult.rest_id ?? '',
|
||||
username: legacy?.screen_name ?? core?.screen_name ?? '',
|
||||
name: legacy?.name ?? core?.name ?? '',
|
||||
description: legacy?.description,
|
||||
followers_count: legacy?.followers_count,
|
||||
following_count: legacy?.friends_count,
|
||||
is_blue_verified: userResult.is_blue_verified,
|
||||
profile_image_url: legacy?.profile_image_url_https ?? userResult.avatar?.image_url,
|
||||
created_at: legacy?.created_at ?? core?.created_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users that a user is following
|
||||
*/
|
||||
async getFollowing(userId: string, count = 20): Promise<FollowingResult> {
|
||||
const variables = {
|
||||
userId,
|
||||
count,
|
||||
includePromotedContent: false,
|
||||
};
|
||||
|
||||
const features = this.buildFollowingFeatures();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
features: JSON.stringify(features),
|
||||
});
|
||||
|
||||
const queryId = await this.getQueryId('Following');
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
user?: {
|
||||
result?: {
|
||||
timeline?: {
|
||||
timeline?: {
|
||||
instructions?: Array<{ type?: string; entries?: Array<unknown> }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
|
||||
}
|
||||
|
||||
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
|
||||
const users = this.parseUsersFromInstructions(instructions);
|
||||
|
||||
return { success: true, users };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users that follow a user
|
||||
*/
|
||||
async getFollowers(userId: string, count = 20): Promise<FollowingResult> {
|
||||
const variables = {
|
||||
userId,
|
||||
count,
|
||||
includePromotedContent: false,
|
||||
};
|
||||
|
||||
const features = this.buildFollowingFeatures();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
features: JSON.stringify(features),
|
||||
});
|
||||
|
||||
const queryId = await this.getQueryId('Followers');
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
data?: {
|
||||
user?: {
|
||||
result?: {
|
||||
timeline?: {
|
||||
timeline?: {
|
||||
instructions?: Array<{ type?: string; entries?: Array<unknown> }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
|
||||
}
|
||||
|
||||
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
|
||||
const users = this.parseUsersFromInstructions(instructions);
|
||||
|
||||
return { success: true, users };
|
||||
} catch (error) {
|
||||
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user