feat: add user-tweets command to fetch tweets from a user's profile

Adds new CLI command to fetch tweets from any user's profile timeline.

Usage:
  bird user-tweets @username [-n count] [--pages N] [--json]

Features:
- GraphQL UserByScreenName for username → userId resolution
- Cursor-based pagination with configurable delay (rate-limit safe)
- Hard cap of 10 pages to prevent abuse
- Input validation via normalizeHandle()

Includes 17 unit tests and live smoke test.

Co-authored-by: Christian Catalan <[email protected]>
This commit is contained in:
cc-vps
2026-01-12 01:28:04 +00:00
committed by Peter Steinberger
co-authored by Christian Catalan
parent 46255e8a38
commit df98e827f6
8 changed files with 1185 additions and 5 deletions
+4 -1
View File
@@ -9,6 +9,7 @@ import { registerQueryIdsCommand } from '../commands/query-ids.js';
import { registerReadCommands } from '../commands/read.js'; import { registerReadCommands } from '../commands/read.js';
import { registerSearchCommands } from '../commands/search.js'; import { registerSearchCommands } from '../commands/search.js';
import { registerUnbookmarkCommand } from '../commands/unbookmark.js'; import { registerUnbookmarkCommand } from '../commands/unbookmark.js';
import { registerUserTweetsCommand } from '../commands/user-tweets.js';
import { registerUserCommands } from '../commands/users.js'; import { registerUserCommands } from '../commands/users.js';
import { getCliVersion } from '../lib/version.js'; import { getCliVersion } from '../lib/version.js';
import { type CliContext, collectCookieSource } from './shared.js'; import { type CliContext, collectCookieSource } from './shared.js';
@@ -30,6 +31,7 @@ export const KNOWN_COMMANDS = new Set([
'lists', 'lists',
'list-timeline', 'list-timeline',
'home', 'home',
'user-tweets',
'help', 'help',
'whoami', 'whoami',
'check', 'check',
@@ -88,7 +90,7 @@ export function createProgram(ctx: CliContext): Command {
].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[ ].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[
formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'), formatExample('bird <tweet-id-or-url> [--json]', 'Shorthand for `bird read <tweet-id-or-url>`'),
].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted( ].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted(
` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, lists, list-timeline, query-ids`, ` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, lists, list-timeline, user-tweets, query-ids`,
)}\n${ctx.colors.muted( )}\n${ctx.colors.muted(
` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`, ` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`,
)}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`, )}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird <command> --help')} to see per-command flags.)`)}`,
@@ -135,6 +137,7 @@ export function createProgram(ctx: CliContext): Command {
registerListsCommand(program, ctx); registerListsCommand(program, ctx);
registerHomeCommand(program, ctx); registerHomeCommand(program, ctx);
registerUserCommands(program, ctx); registerUserCommands(program, ctx);
registerUserTweetsCommand(program, ctx);
registerCheckCommand(program, ctx); registerCheckCommand(program, ctx);
return program; return program;
+140
View File
@@ -0,0 +1,140 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerUserTweetsCommand(program: Command, ctx: CliContext): void {
const formatExample = (cmd: string, desc: string): string =>
` ${ctx.colors.command(cmd)}\n ${ctx.colors.muted(desc)}`;
program
.command('user-tweets')
.description("Get tweets from a user's profile timeline")
.argument('<handle>', 'Username to fetch tweets from (e.g., @steipete or steipete)')
.option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--pages <number>', 'Number of pages to fetch (max: 10)', '1')
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
.addHelpText(
'after',
() =>
`\n${ctx.colors.section('Command Examples')}\n${[
formatExample('bird user-tweets @steipete', 'Get recent tweets from a user'),
formatExample('bird user-tweets steipete -n 10', 'Get 10 tweets (@ is optional)'),
formatExample('bird user-tweets @steipete --pages 3', 'Fetch 3 pages of tweets'),
formatExample('bird user-tweets @steipete --json', 'Output as JSON'),
formatExample('bird user-tweets @steipete --cursor "DAABCg..."', 'Resume from cursor'),
].join('\n')}`,
)
.action(
async (
handle: string,
cmdOpts: {
count?: string;
pages?: string;
delay?: string;
cursor?: string;
json?: boolean;
jsonFull?: boolean;
},
) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const quoteDepth = ctx.resolveQuoteDepthFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const maxPages = Math.min(Number.parseInt(cmdOpts.pages || '1', 10), 10); // Hard cap at 10 pages
const pageDelayMs = Number.parseInt(cmdOpts.delay || '1000', 10);
// Validate inputs
if (!Number.isFinite(count) || count <= 0) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(2);
}
if (!Number.isFinite(maxPages) || maxPages <= 0) {
console.error(`${ctx.p('err')}Invalid --pages. Expected a positive integer (max: 10).`);
process.exit(2);
}
if (!Number.isFinite(pageDelayMs) || pageDelayMs < 0) {
console.error(`${ctx.p('err')}Invalid --delay. Expected a non-negative integer.`);
process.exit(2);
}
// Normalize handle (strip @ if present)
const username = normalizeHandle(handle);
if (!username) {
console.error(`${ctx.p('err')}Invalid handle: ${handle}`);
process.exit(2);
}
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
for (const warning of warnings) {
console.error(`${ctx.p('warn')}${warning}`);
}
if (!cookies.authToken || !cookies.ct0) {
console.error(`${ctx.p('err')}Missing required credentials`);
process.exit(1);
}
const client = new TwitterClient({ cookies, timeoutMs, quoteDepth });
// Look up user ID from username
console.error(`${ctx.p('info')}Looking up @${username}...`);
const userLookup = await client.getUserIdByUsername(username);
if (!userLookup.success || !userLookup.userId) {
console.error(`${ctx.p('err')}${userLookup.error || `Could not find user @${username}`}`);
process.exit(1);
}
const displayName = userLookup.name
? `${userLookup.name} (@${userLookup.username})`
: `@${userLookup.username}`;
console.error(`${ctx.p('info')}Fetching tweets from ${displayName}...`);
const includeRaw = cmdOpts.jsonFull ?? false;
const usePagination = maxPages > 1 || cmdOpts.cursor;
let result: Awaited<ReturnType<typeof client.getUserTweetsPaged>>;
if (usePagination) {
result = await client.getUserTweetsPaged(userLookup.userId, {
includeRaw,
maxPages,
cursor: cmdOpts.cursor,
pageDelayMs,
});
} else {
result = await client.getUserTweets(userLookup.userId, count, { includeRaw });
}
if (result.success && result.tweets) {
// Trim to count if single page
let tweets = result.tweets;
if (!usePagination && tweets.length > count) {
tweets = tweets.slice(0, count);
}
const isJson = cmdOpts.json || cmdOpts.jsonFull;
if (isJson && usePagination) {
// Include nextCursor for pagination consumers
console.log(JSON.stringify({ tweets, nextCursor: result.nextCursor ?? null }, null, 2));
} else {
ctx.printTweets(tweets, {
json: isJson,
emptyMessage: `No tweets found for @${username}.`,
});
}
// Show pagination hint if there's more
if (result.nextCursor && !cmdOpts.json && !cmdOpts.jsonFull) {
console.error(`${ctx.p('info')}More tweets available. Use --cursor "${result.nextCursor}" to continue.`);
}
} else {
console.error(`${ctx.p('err')}Failed to fetch tweets: ${result.error}`);
process.exit(1);
}
},
);
}
+1
View File
@@ -21,6 +21,7 @@ export const FALLBACK_QUERY_IDS = {
TweetDetail: '97JF30KziU00483E_8elBA', TweetDetail: '97JF30KziU00483E_8elBA',
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
UserTweets: 'Wms1GvIiHXAPBaCr9KblaA',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
Following: 'BEkNpEt5pNETESoqMsTEGA', Following: 'BEkNpEt5pNETESoqMsTEGA',
Followers: 'kuFUYP9eV1FPoEy4N-pi7w', Followers: 'kuFUYP9eV1FPoEy4N-pi7w',
+40
View File
@@ -232,6 +232,46 @@ export function buildHomeTimelineFeatures(): Record<string, boolean> {
}); });
} }
export function buildUserTweetsFeatures(): Record<string, boolean> {
return applyFeatureOverrides('userTweets', {
rweb_video_screen_enabled: false,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: false,
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: false,
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: true,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_grok_annotations_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: false,
responsive_web_grok_show_grok_translated_post: true,
responsive_web_grok_analysis_button_from_backend: true,
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: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
});
}
export function buildFollowingFeatures(): Record<string, boolean> { export function buildFollowingFeatures(): Record<string, boolean> {
return applyFeatureOverrides('following', { return applyFeatureOverrides('following', {
rweb_video_screen_enabled: true, rweb_video_screen_enabled: true,
+454
View File
@@ -0,0 +1,454 @@
import { normalizeHandle } from './normalize-handle.js';
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildUserTweetsFeatures } from './twitter-client-features.js';
import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
/** Options for user tweets fetch methods */
export interface UserTweetsFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
/** Options for paginated user tweets fetch */
export interface UserTweetsPaginationOptions extends UserTweetsFetchOptions {
/** Maximum number of pages to fetch (default: 1) */
maxPages?: number;
/** Starting cursor for pagination (resume from previous fetch) */
cursor?: string;
/** Delay in milliseconds between page fetches (default: 1000) */
pageDelayMs?: number;
}
/** Result of username to userId lookup */
export interface UserLookupResult {
success: boolean;
userId?: string;
username?: string;
name?: string;
error?: string;
}
export interface TwitterClientUserTweetsMethods {
getUserIdByUsername(username: string): Promise<UserLookupResult>;
getUserTweets(userId: string, count?: number, options?: UserTweetsFetchOptions): Promise<SearchResult>;
getUserTweetsPaged(userId: string, options?: UserTweetsPaginationOptions): Promise<SearchResult>;
}
export function withUserTweets<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientUserTweetsMethods> {
abstract class TwitterClientUserTweets extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
private async getUserTweetsQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('UserTweets');
// Fallback query ID observed from web client
return Array.from(new Set([primary, 'Wms1GvIiHXAPBaCr9KblaA']));
}
private async getUserByScreenNameGraphQL(screenName: string): Promise<UserLookupResult> {
// UserByScreenName query IDs observed from web client
const queryIds = ['xc8f1g7BYqr6VTzTbvNlGw', 'qW5u-DAuXpMEG0zA1F7UGQ', 'sLVLhk0bGj3MVFEKTdax1w'];
const variables = {
screen_name: screenName,
withSafetyModeUserFields: true,
};
const features = {
hidden_profile_subscriptions_enabled: true,
hidden_profile_likes_enabled: true,
rweb_tipjar_consumption_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
subscriptions_verification_info_is_identity_verified_enabled: true,
subscriptions_verification_info_verified_since_enabled: true,
highlights_tweets_tab_ui_enabled: true,
responsive_web_twitter_article_notes_tab_enabled: true,
subscriptions_feature_can_gift_premium: true,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
responsive_web_graphql_timeline_navigation_enabled: true,
blue_business_profile_image_shape_enabled: true,
};
const fieldToggles = {
withAuxiliaryUserLabels: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
fieldToggles: JSON.stringify(fieldToggles),
});
let lastError: string | undefined;
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/UserByScreenName?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
if (response.status === 404) {
// Try next query ID
lastError = `HTTP ${response.status}`;
continue;
}
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
__typename?: string;
rest_id?: string;
id?: string;
legacy?: {
screen_name?: string;
name?: string;
};
core?: {
screen_name?: string;
name?: string;
};
};
};
};
errors?: Array<{ message: string }>;
};
// Check for user not found
if (data.data?.user?.result?.__typename === 'UserUnavailable') {
return { success: false, error: `User @${screenName} not found or unavailable` };
}
const userResult = data.data?.user?.result;
const userId = userResult?.rest_id;
const username = userResult?.legacy?.screen_name ?? userResult?.core?.screen_name;
const name = userResult?.legacy?.name ?? userResult?.core?.name;
if (userId && username) {
return {
success: true,
userId,
username,
name,
};
}
if (data.errors && data.errors.length > 0) {
lastError = data.errors.map((e) => e.message).join(', ');
continue;
}
lastError = 'Could not parse user data from response';
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false, error: lastError ?? 'Unknown error looking up user' };
}
private async sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Look up a user's ID by their username/handle.
* Uses Twitter's REST API endpoint.
*/
async getUserIdByUsername(username: string): Promise<UserLookupResult> {
// Normalize and validate handle
const cleanUsername = normalizeHandle(username);
if (!cleanUsername) {
return { success: false, error: `Invalid username: ${username}` };
}
// Try GraphQL UserByScreenName first (more reliable with cookie auth)
const graphqlResult = await this.getUserByScreenNameGraphQL(cleanUsername);
if (graphqlResult.success) {
return graphqlResult;
}
// If GraphQL definitively says user is unavailable, don't retry with REST
if (graphqlResult.error?.includes('not found or unavailable')) {
return graphqlResult;
}
// Fallback to REST API for transient GraphQL errors
const urls = [
`https://x.com/i/api/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`,
`https://api.twitter.com/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`,
];
let lastError: string | undefined = graphqlResult.error;
for (const url of urls) {
try {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
const text = await response.text();
// Check for user not found
if (response.status === 404) {
return { success: false, error: `User @${cleanUsername} not found` };
}
lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`;
continue;
}
const data = (await response.json()) as {
id_str?: string;
id?: number;
screen_name?: string;
name?: string;
};
const userId = data.id_str ?? (data.id ? String(data.id) : null);
if (!userId) {
lastError = 'Could not parse user ID from response';
continue;
}
return {
success: true,
userId,
username: data.screen_name ?? cleanUsername,
name: data.name,
};
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false, error: lastError ?? 'Unknown error looking up user' };
}
/**
* Get tweets from a user's profile timeline (single page).
*/
async getUserTweets(userId: string, count = 20, options: UserTweetsFetchOptions = {}): Promise<SearchResult> {
const result = await this.getUserTweetsPaged(userId, {
...options,
maxPages: 1,
});
// Trim to requested count
if (result.success && result.tweets && result.tweets.length > count) {
result.tweets = result.tweets.slice(0, count);
}
return result;
}
/**
* Get tweets from a user's profile timeline with pagination support.
*/
async getUserTweetsPaged(userId: string, options: UserTweetsPaginationOptions = {}): Promise<SearchResult> {
const { includeRaw = false, maxPages = 1, pageDelayMs = 1000 } = options;
const features = buildUserTweetsFeatures();
const pageSize = 20;
const seen = new Set<string>();
const tweets: TweetData[] = [];
let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const fetchPage = async (pageCursor?: string) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getUserTweetsQueryIds();
const variables = {
userId,
count: pageSize,
includePromotedContent: false, // Filter out ads
withQuickPromoteEligibilityTweetFields: true,
withVoice: true,
...(pageCursor ? { cursor: pageCursor } : {}),
};
const fieldToggles = {
withArticlePlainText: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
fieldToggles: JSON.stringify(fieldToggles),
});
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/UserTweets?${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<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
items?: Array<{
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
cursorType?: string;
value?: string;
};
}>;
}>;
};
};
};
};
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
// Check for common errors
const errorMsg = data.errors.map((e) => e.message).join(', ');
if (errorMsg.includes('User has been suspended') || errorMsg.includes('User not found')) {
return { success: false as const, error: errorMsg, had404 };
}
// Some errors are non-fatal if we got data
if (!data.data?.user?.result?.timeline?.timeline?.instructions) {
return { success: false as const, error: errorMsg, had404 };
}
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
const pageCursorValue = extractCursorFromInstructions(instructions);
return { success: true as const, tweets: pageTweets, cursor: pageCursorValue, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false as const, error: lastError ?? 'Unknown error fetching user tweets', had404 };
};
const fetchWithRefresh = async (pageCursor?: string) => {
const firstAttempt = await fetchPage(pageCursor);
if (firstAttempt.success) {
return firstAttempt;
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCursor);
if (secondAttempt.success) {
return secondAttempt;
}
return { success: false as const, error: secondAttempt.error };
}
return { success: false as const, error: firstAttempt.error };
};
while (pagesFetched < maxPages) {
// Add delay between pages (but not before the first page)
if (pagesFetched > 0 && pageDelayMs > 0) {
await this.sleep(pageDelayMs);
}
const page = await fetchWithRefresh(cursor);
if (!page.success) {
// If we have some tweets already, return them with the error
if (tweets.length > 0) {
return { success: true, tweets, nextCursor: cursor, error: page.error };
}
return { success: false, error: page.error };
}
pagesFetched += 1;
for (const tweet of page.tweets) {
if (seen.has(tweet.id)) {
continue;
}
seen.add(tweet.id);
tweets.push(tweet);
}
const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0) {
nextCursor = undefined;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
}
return { success: true, tweets, nextCursor };
}
}
return TwitterClientUserTweets;
}
+8 -4
View File
@@ -8,6 +8,7 @@ import { type TwitterClientPostingMethods, withPosting } from './twitter-client-
import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.js'; import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.js';
import { type TwitterClientTimelineMethods, withTimelines } from './twitter-client-timelines.js'; import { type TwitterClientTimelineMethods, withTimelines } from './twitter-client-timelines.js';
import { type TwitterClientTweetDetailMethods, withTweetDetails } from './twitter-client-tweet-detail.js'; import { type TwitterClientTweetDetailMethods, withTweetDetails } from './twitter-client-tweet-detail.js';
import { type TwitterClientUserTweetsMethods, withUserTweets } from './twitter-client-user-tweets.js';
import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js'; import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js';
type TwitterClientInstance = TwitterClientBase & type TwitterClientInstance = TwitterClientBase &
@@ -19,11 +20,14 @@ type TwitterClientInstance = TwitterClientBase &
TwitterClientSearchMethods & TwitterClientSearchMethods &
TwitterClientTimelineMethods & TwitterClientTimelineMethods &
TwitterClientTweetDetailMethods & TwitterClientTweetDetailMethods &
TwitterClientUserMethods; TwitterClientUserMethods &
TwitterClientUserTweetsMethods;
const MixedTwitterClient = withUsers( const MixedTwitterClient = withUserTweets(
withLists( withUsers(
withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))), withLists(
withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase)))))))),
),
), ),
) as AbstractConstructor<TwitterClientInstance>; ) as AbstractConstructor<TwitterClientInstance>;
+16
View File
@@ -268,6 +268,22 @@ d('live CLI (Twitter/X)', () => {
expect(Array.isArray(followerUsers)).toBe(true); expect(Array.isArray(followerUsers)).toBe(true);
}); });
it('user-tweets returns JSON array', async () => {
// Use a known active account for reliable testing (authenticated user may have no tweets)
const testHandle = process.env.BIRD_LIVE_USER_TWEETS_HANDLE || 'X';
const userTweets = await runBird(
[...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'user-tweets', testHandle, '-n', '5', '--json'],
{
timeoutMs: 45_000,
},
);
expect(userTweets.exitCode).toBe(0);
const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout);
expect(Array.isArray(tweets)).toBe(true);
expect(tweets.length).toBeGreaterThan(0);
expect(tweets[0].author?.username?.toLowerCase()).toBe(testHandle.toLowerCase());
});
it('query-ids returns JSON', async () => { it('query-ids returns JSON', async () => {
const queryIds = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'query-ids', '--json'], { const queryIds = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'query-ids', '--json'], {
timeoutMs: 60_000, timeoutMs: 60_000,
+522
View File
@@ -0,0 +1,522 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TwitterClient } from '../src/lib/twitter-client.js';
import { validCookies } from './twitter-client-fixtures.js';
const originalFetch = global.fetch;
afterEach(() => {
global.fetch = originalFetch;
vi.restoreAllMocks();
});
describe('TwitterClient getUserIdByUsername', () => {
let mockFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('resolves username to userId via GraphQL', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'User',
rest_id: '12345',
legacy: {
screen_name: 'testuser',
name: 'Test User',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('@testuser');
expect(result.success).toBe(true);
expect(result.userId).toBe('12345');
expect(result.username).toBe('testuser');
expect(result.name).toBe('Test User');
});
it('handles username without @ prefix', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'User',
rest_id: '67890',
legacy: {
screen_name: 'anotheruser',
name: 'Another User',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('anotheruser');
expect(result.success).toBe(true);
expect(result.userId).toBe('67890');
});
it('returns error for invalid username format', async () => {
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('invalid handle with spaces');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid username');
expect(mockFetch).not.toHaveBeenCalled();
});
it('returns error for empty username', async () => {
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid username');
expect(mockFetch).not.toHaveBeenCalled();
});
it('returns error for username exceeding 15 characters', async () => {
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('thisusernameiswaytoolong');
expect(result.success).toBe(false);
expect(result.error).toContain('Invalid username');
expect(mockFetch).not.toHaveBeenCalled();
});
it('returns error when user is unavailable', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'UserUnavailable',
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('suspended');
expect(result.success).toBe(false);
expect(result.error).toContain('not found or unavailable');
});
it('retries with fallback query IDs on 404', async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not found',
})
.mockResolvedValueOnce({
ok: false,
status: 404,
text: async () => 'Not found',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
__typename: 'User',
rest_id: '99999',
legacy: {
screen_name: 'founduser',
name: 'Found User',
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserIdByUsername('founduser');
expect(result.success).toBe(true);
expect(result.userId).toBe('99999');
expect(mockFetch).toHaveBeenCalledTimes(3);
});
});
describe('TwitterClient getUserTweets', () => {
let mockFetch: ReturnType<typeof vi.fn>;
const makeTweetResult = (id: string, text: string, username = 'testuser') => ({
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 1,
retweet_count: 2,
favorite_count: 3,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: '12345',
legacy: {
screen_name: username,
name: 'Test User',
},
},
},
},
});
const makeTimelineResponse = (tweets: ReturnType<typeof makeTweetResult>[], cursor?: string) => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
...tweets.map((t) => ({
entryId: `tweet-${t.rest_id}`,
content: {
entryType: 'TimelineTimelineItem',
itemContent: {
tweet_results: {
result: t,
},
},
},
})),
...(cursor
? [
{
entryId: 'cursor-bottom-123',
content: {
entryType: 'TimelineTimelineCursor',
cursorType: 'Bottom',
value: cursor,
},
},
]
: []),
],
},
],
},
},
},
},
},
});
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches and parses user tweets', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse([makeTweetResult('1', 'First tweet'), makeTweetResult('2', 'Second tweet')]),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
expect(result.tweets?.[0].id).toBe('1');
expect(result.tweets?.[0].text).toBe('First tweet');
expect(result.tweets?.[1].id).toBe('2');
expect(result.tweets?.[1].text).toBe('Second tweet');
});
it('respects count parameter', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse([
makeTweetResult('1', 'First'),
makeTweetResult('2', 'Second'),
makeTweetResult('3', 'Third'),
]),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweets('12345', 2);
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
});
it('returns empty array for user with no tweets', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([]),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(true);
expect(result.tweets).toEqual([]);
});
it('extracts cursor for pagination', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet')], 'next-cursor-abc'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', { maxPages: 1 });
expect(result.success).toBe(true);
expect(result.nextCursor).toBe('next-cursor-abc');
});
it('handles API errors gracefully', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal Server Error',
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweets('12345', 10);
expect(result.success).toBe(false);
expect(result.error).toContain('HTTP 500');
});
});
describe('TwitterClient getUserTweetsPaged', () => {
let mockFetch: ReturnType<typeof vi.fn>;
const makeTweetResult = (id: string, text: string) => ({
rest_id: id,
legacy: {
full_text: text,
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: id,
},
core: {
user_results: {
result: {
rest_id: '12345',
legacy: {
screen_name: 'testuser',
name: 'Test User',
},
},
},
},
});
const makeTimelineResponse = (tweets: ReturnType<typeof makeTweetResult>[], cursor?: string) => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
type: 'TimelineAddEntries',
entries: [
...tweets.map((t) => ({
entryId: `tweet-${t.rest_id}`,
content: {
entryType: 'TimelineTimelineItem',
itemContent: {
tweet_results: {
result: t,
},
},
},
})),
...(cursor
? [
{
entryId: 'cursor-bottom-123',
content: {
entryType: 'TimelineTimelineCursor',
cursorType: 'Bottom',
value: cursor,
},
},
]
: []),
],
},
],
},
},
},
},
},
});
beforeEach(() => {
mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
});
it('fetches multiple pages with cursor', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Page 1 Tweet')], 'cursor-page-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('2', 'Page 2 Tweet')], 'cursor-page-3'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', {
maxPages: 2,
pageDelayMs: 0, // No delay for tests
});
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
expect(result.tweets?.[0].id).toBe('1');
expect(result.tweets?.[1].id).toBe('2');
expect(result.nextCursor).toBe('cursor-page-3');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('respects maxPages limit', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet 1')], 'cursor-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('2', 'Tweet 2')], 'cursor-3'),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', {
maxPages: 2,
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result.nextCursor).toBe('cursor-3'); // Has more but stopped at maxPages
});
it('stops when no more cursor is returned', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet 1')], 'cursor-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('2', 'Tweet 2')]), // No cursor = end
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', {
maxPages: 5,
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('deduplicates tweets across pages', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('1', 'Tweet 1')], 'cursor-2'),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () =>
makeTimelineResponse([
makeTweetResult('1', 'Tweet 1'), // Duplicate
makeTweetResult('2', 'Tweet 2'),
]),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', {
maxPages: 2,
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(result.tweets?.length).toBe(2); // Not 3
expect(result.tweets?.map((t) => t.id)).toEqual(['1', '2']);
});
it('resumes from provided cursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => makeTimelineResponse([makeTweetResult('5', 'Resumed Tweet')]),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getUserTweetsPaged('12345', {
maxPages: 1,
cursor: 'resume-cursor-xyz',
pageDelayMs: 0,
});
expect(result.success).toBe(true);
expect(result.tweets?.[0].id).toBe('5');
// Verify cursor was passed in the request
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('resume-cursor-xyz');
});
});