Merge pull request #6 from lockmeister/add-following-command
feat: add following and followers commands
This commit is contained in:
+2
-1
@@ -5,10 +5,11 @@
|
|||||||
### Added
|
### Added
|
||||||
- `likes` command to list your liked tweets (thanks @swairshah).
|
- `likes` command to list your liked tweets (thanks @swairshah).
|
||||||
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
|
- Quoted tweet data in JSON output + `--quote-depth` (thanks @alexknowshtml).
|
||||||
|
- `following`/`followers` commands to list users (thanks @lockmeister).
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Query ID updater now tracks the Likes GraphQL operation.
|
- Query ID updater now tracks the Likes GraphQL operation.
|
||||||
|
- Query ID updater now tracks Following/Followers GraphQL operations.
|
||||||
## 0.4.1 — 2025-12-31
|
## 0.4.1 — 2025-12-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<
|
|||||||
# Likes
|
# Likes
|
||||||
bird likes -n 5
|
bird likes -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)
|
# Refresh GraphQL query IDs cache (no rebuild)
|
||||||
bird query-ids --fresh
|
bird query-ids --fresh
|
||||||
```
|
```
|
||||||
@@ -73,6 +81,8 @@ bird query-ids --fresh
|
|||||||
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
|
- `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user).
|
||||||
- `bird bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder).
|
- `bird bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder).
|
||||||
- `bird likes [-n count] [--json]` — list your liked tweets.
|
- `bird likes [-n count] [--json]` — list your liked 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 whoami` — print which Twitter account your cookies belong to.
|
||||||
- `bird check` — show which credentials are available and where they were sourced from.
|
- `bird check` — show which credentials are available and where they were sourced from.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ const TARGET_OPERATIONS = [
|
|||||||
'TweetDetail',
|
'TweetDetail',
|
||||||
'SearchTimeline',
|
'SearchTimeline',
|
||||||
'Bookmarks',
|
'Bookmarks',
|
||||||
|
'Following',
|
||||||
|
'Followers',
|
||||||
'Likes',
|
'Likes',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
+130
-1
@@ -152,6 +152,8 @@ const KNOWN_COMMANDS = new Set([
|
|||||||
'search',
|
'search',
|
||||||
'mentions',
|
'mentions',
|
||||||
'bookmarks',
|
'bookmarks',
|
||||||
|
'following',
|
||||||
|
'followers',
|
||||||
'likes',
|
'likes',
|
||||||
'help',
|
'help',
|
||||||
'whoami',
|
'whoami',
|
||||||
@@ -368,6 +370,8 @@ program
|
|||||||
'SearchTimeline',
|
'SearchTimeline',
|
||||||
'UserArticlesTweets',
|
'UserArticlesTweets',
|
||||||
'Bookmarks',
|
'Bookmarks',
|
||||||
|
'Following',
|
||||||
|
'Followers',
|
||||||
'Likes',
|
'Likes',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -779,6 +783,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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Likes command - get user's liked tweets
|
// Likes command - get user's liked tweets
|
||||||
program
|
program
|
||||||
.command('likes')
|
.command('likes')
|
||||||
@@ -812,7 +942,6 @@ program
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Whoami command - show the logged-in account
|
// Whoami command - show the logged-in account
|
||||||
program
|
program
|
||||||
.command('whoami')
|
.command('whoami')
|
||||||
|
|||||||
@@ -4,5 +4,7 @@
|
|||||||
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
|
||||||
"TweetDetail": "97JF30KziU00483E_8elBA",
|
"TweetDetail": "97JF30KziU00483E_8elBA",
|
||||||
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
|
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
|
||||||
|
"Following": "BEkNpEt5pNETESoqMsTEGA",
|
||||||
|
"Followers": "kuFUYP9eV1FPoEy4N-pi7w",
|
||||||
"Likes": "JR2gceKucIKcVNB_9JkhsA"
|
"Likes": "JR2gceKucIKcVNB_9JkhsA"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const FALLBACK_QUERY_IDS = {
|
|||||||
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
||||||
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
||||||
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
||||||
|
Following: 'BEkNpEt5pNETESoqMsTEGA',
|
||||||
|
Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
|
||||||
Likes: 'JR2gceKucIKcVNB_9JkhsA',
|
Likes: 'JR2gceKucIKcVNB_9JkhsA',
|
||||||
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
|
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
|
||||||
} as const;
|
} as const;
|
||||||
@@ -259,6 +261,24 @@ export interface CurrentUserResult {
|
|||||||
error?: string;
|
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 {
|
export interface TwitterClientOptions {
|
||||||
cookies: TwitterCookies;
|
cookies: TwitterCookies;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
@@ -2045,6 +2065,310 @@ export class TwitterClient {
|
|||||||
|
|
||||||
return { success: false, error: firstAttempt.error };
|
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 async getFollowingQueryIds(): Promise<string[]> {
|
||||||
|
const primary = await this.getQueryId('Following');
|
||||||
|
return Array.from(new Set([primary, 'BEkNpEt5pNETESoqMsTEGA']));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getFollowersQueryIds(): Promise<string[]> {
|
||||||
|
const primary = await this.getQueryId('Followers');
|
||||||
|
return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w']));
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUsersFromInstructions(
|
||||||
|
instructions: Array<{ type?: string; entries?: Array<unknown> }> | undefined,
|
||||||
|
): TwitterUser[] {
|
||||||
|
if (!instructions) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const users: TwitterUser[] = [];
|
||||||
|
|
||||||
|
for (const instruction of instructions) {
|
||||||
|
if (!instruction.entries) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of instruction.entries) {
|
||||||
|
const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content;
|
||||||
|
const rawUserResult = content?.itemContent?.user_results?.result as
|
||||||
|
| {
|
||||||
|
__typename?: string;
|
||||||
|
rest_id?: string;
|
||||||
|
is_blue_verified?: boolean;
|
||||||
|
user?: unknown;
|
||||||
|
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;
|
||||||
|
|
||||||
|
const userResult =
|
||||||
|
rawUserResult?.__typename === 'UserWithVisibilityResults' && rawUserResult.user
|
||||||
|
? (rawUserResult.user as typeof rawUserResult)
|
||||||
|
: rawUserResult;
|
||||||
|
|
||||||
|
if (!userResult || userResult.__typename !== 'User') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = userResult.legacy;
|
||||||
|
const core = userResult.core;
|
||||||
|
const username = legacy?.screen_name ?? core?.screen_name;
|
||||||
|
if (!userResult.rest_id || !username) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
users.push({
|
||||||
|
id: userResult.rest_id,
|
||||||
|
username,
|
||||||
|
name: legacy?.name ?? core?.name ?? username,
|
||||||
|
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 tryOnce = async () => {
|
||||||
|
let lastError: string | undefined;
|
||||||
|
let had404 = false;
|
||||||
|
const queryIds = await this.getFollowingQueryIds();
|
||||||
|
|
||||||
|
for (const queryId of queryIds) {
|
||||||
|
const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithTimeout(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
had404 = true;
|
||||||
|
lastError = `HTTP ${response.status}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 as const, error: data.errors.map((e) => e.message).join(', '), had404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
|
||||||
|
const users = this.parseUsersFromInstructions(instructions);
|
||||||
|
|
||||||
|
return { success: true as const, users, had404 };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstAttempt = await tryOnce();
|
||||||
|
if (firstAttempt.success) {
|
||||||
|
return { success: true, users: firstAttempt.users };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstAttempt.had404) {
|
||||||
|
await this.refreshQueryIds();
|
||||||
|
const secondAttempt = await tryOnce();
|
||||||
|
if (secondAttempt.success) {
|
||||||
|
return { success: true, users: secondAttempt.users };
|
||||||
|
}
|
||||||
|
return { success: false, error: secondAttempt.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: firstAttempt.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 tryOnce = async () => {
|
||||||
|
let lastError: string | undefined;
|
||||||
|
let had404 = false;
|
||||||
|
const queryIds = await this.getFollowersQueryIds();
|
||||||
|
|
||||||
|
for (const queryId of queryIds) {
|
||||||
|
const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.fetchWithTimeout(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: this.getHeaders(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
had404 = true;
|
||||||
|
lastError = `HTTP ${response.status}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const text = await response.text();
|
||||||
|
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 as const, error: data.errors.map((e) => e.message).join(', '), had404 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
|
||||||
|
const users = this.parseUsersFromInstructions(instructions);
|
||||||
|
|
||||||
|
return { success: true as const, users, had404 };
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstAttempt = await tryOnce();
|
||||||
|
if (firstAttempt.success) {
|
||||||
|
return { success: true, users: firstAttempt.users };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstAttempt.had404) {
|
||||||
|
await this.refreshQueryIds();
|
||||||
|
const secondAttempt = await tryOnce();
|
||||||
|
if (secondAttempt.success) {
|
||||||
|
return { success: true, users: secondAttempt.users };
|
||||||
|
}
|
||||||
|
return { success: false, error: secondAttempt.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: firstAttempt.error };
|
||||||
|
}
|
||||||
private async getLikesQueryIds(): Promise<string[]> {
|
private async getLikesQueryIds(): Promise<string[]> {
|
||||||
const primary = await this.getQueryId('Likes');
|
const primary = await this.getQueryId('Likes');
|
||||||
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
|
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ describe('cli-args', () => {
|
|||||||
'thread',
|
'thread',
|
||||||
'search',
|
'search',
|
||||||
'mentions',
|
'mentions',
|
||||||
|
'bookmarks',
|
||||||
|
'following',
|
||||||
|
'followers',
|
||||||
|
'likes',
|
||||||
'help',
|
'help',
|
||||||
'whoami',
|
'whoami',
|
||||||
'check',
|
'check',
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ type ResponseLike = {
|
|||||||
|
|
||||||
type TwitterClientApiPrivate = TwitterClient & {
|
type TwitterClientApiPrivate = TwitterClient & {
|
||||||
getBookmarksQueryIds: () => Promise<string[]>;
|
getBookmarksQueryIds: () => Promise<string[]>;
|
||||||
|
getFollowingQueryIds: () => Promise<string[]>;
|
||||||
|
getFollowersQueryIds: () => Promise<string[]>;
|
||||||
getLikesQueryIds: () => Promise<string[]>;
|
getLikesQueryIds: () => Promise<string[]>;
|
||||||
getCurrentUser: () => Promise<{
|
getCurrentUser: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -211,6 +213,67 @@ describe('TwitterClient API coverage', () => {
|
|||||||
expect(result.error).toBe('HTTP 404');
|
expect(result.error).toBe('HTTP 404');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('following/followers error paths', () => {
|
||||||
|
it('returns an error for non-ok responses', async () => {
|
||||||
|
const mockFetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
||||||
|
|
||||||
|
const result = await client.getFollowing('123', 1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('HTTP 500');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns API errors from payloads', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValueOnce(
|
||||||
|
makeResponse({
|
||||||
|
json: async () => ({ errors: [{ message: 'bad' }] }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getFollowersQueryIds = async () => ['test'];
|
||||||
|
|
||||||
|
const result = await client.getFollowers('123', 1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('bad');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown error when no query ids are available', async () => {
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getFollowingQueryIds = async () => [];
|
||||||
|
|
||||||
|
const result = await client.getFollowing('123', 1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('Unknown error fetching following');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the second attempt error after 404s', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||||
|
clientPrivate.getFollowersQueryIds = async () => ['test'];
|
||||||
|
|
||||||
|
const result = await client.getFollowers('123', 1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBe('HTTP 404');
|
||||||
|
});
|
||||||
|
});
|
||||||
describe('likes error paths', () => {
|
describe('likes error paths', () => {
|
||||||
const stubCurrentUser = async () => ({
|
const stubCurrentUser = async () => ({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1241,6 +1241,192 @@ describe('TwitterClient', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('following/followers', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const makeUserResult = (id: string, username: string, name = username) => ({
|
||||||
|
__typename: 'User',
|
||||||
|
rest_id: id,
|
||||||
|
legacy: {
|
||||||
|
screen_name: username,
|
||||||
|
name,
|
||||||
|
description: `bio-${id}`,
|
||||||
|
followers_count: 10,
|
||||||
|
friends_count: 5,
|
||||||
|
profile_image_url_https: `https://example.com/${id}.jpg`,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches following users and filters invalid entries', 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: {
|
||||||
|
itemContent: {
|
||||||
|
user_results: {
|
||||||
|
result: { __typename: 'User', rest_id: '2' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
user_results: {
|
||||||
|
result: { __typename: 'TimelineUser' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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', 2);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.users?.length).toBe(1);
|
||||||
|
expect(result.users?.[0].username).toBe('alpha');
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
expect(String(url)).toContain('/Following?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches followers and unwraps visibility results', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
timeline: {
|
||||||
|
timeline: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
user_results: {
|
||||||
|
result: {
|
||||||
|
__typename: 'UserWithVisibilityResults',
|
||||||
|
user: makeUserResult('9', 'vis', 'Visible'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
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('123', 1);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.users?.[0].username).toBe('vis');
|
||||||
|
const [url] = mockFetch.mock.calls[0];
|
||||||
|
expect(String(url)).toContain('/Followers?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes query IDs after 404s', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({ ok: false, status: 404, text: async () => 'nope' }).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
timeline: {
|
||||||
|
timeline: {
|
||||||
|
instructions: [
|
||||||
|
{
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
itemContent: {
|
||||||
|
user_results: {
|
||||||
|
result: makeUserResult('1', 'alpha', 'Alpha'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const clientPrivate = client as unknown as TwitterClient & {
|
||||||
|
getFollowingQueryIds: () => Promise<string[]>;
|
||||||
|
refreshQueryIds: () => Promise<void>;
|
||||||
|
};
|
||||||
|
clientPrivate.getFollowingQueryIds = async () => ['test'];
|
||||||
|
let refreshed = false;
|
||||||
|
clientPrivate.refreshQueryIds = async () => {
|
||||||
|
refreshed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await client.getFollowing('123', 1);
|
||||||
|
|
||||||
|
expect(refreshed).toBe(true);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
describe('likes', () => {
|
describe('likes', () => {
|
||||||
let mockFetch: ReturnType<typeof vi.fn>;
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user