From df98e827f6804eca33fe5fabbd6e51b99061de50 Mon Sep 17 00:00:00 2001 From: cc-vps Date: Tue, 6 Jan 2026 18:11:45 -0800 Subject: [PATCH] feat: add user-tweets command to fetch tweets from a user's profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/cli/program.ts | 5 +- src/commands/user-tweets.ts | 140 ++++++ src/lib/twitter-client-constants.ts | 1 + src/lib/twitter-client-features.ts | 40 ++ src/lib/twitter-client-user-tweets.ts | 454 ++++++++++++++++++++ src/lib/twitter-client.ts | 12 +- tests/live/live.test.ts | 16 + tests/twitter-client.user-tweets.test.ts | 522 +++++++++++++++++++++++ 8 files changed, 1185 insertions(+), 5 deletions(-) create mode 100644 src/commands/user-tweets.ts create mode 100644 src/lib/twitter-client-user-tweets.ts create mode 100644 tests/twitter-client.user-tweets.test.ts diff --git a/src/cli/program.ts b/src/cli/program.ts index d901702..37e0e1d 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -9,6 +9,7 @@ import { registerQueryIdsCommand } from '../commands/query-ids.js'; import { registerReadCommands } from '../commands/read.js'; import { registerSearchCommands } from '../commands/search.js'; import { registerUnbookmarkCommand } from '../commands/unbookmark.js'; +import { registerUserTweetsCommand } from '../commands/user-tweets.js'; import { registerUserCommands } from '../commands/users.js'; import { getCliVersion } from '../lib/version.js'; import { type CliContext, collectCookieSource } from './shared.js'; @@ -30,6 +31,7 @@ export const KNOWN_COMMANDS = new Set([ 'lists', 'list-timeline', 'home', + 'user-tweets', 'help', 'whoami', 'check', @@ -88,7 +90,7 @@ export function createProgram(ctx: CliContext): Command { ].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[ formatExample('bird [--json]', 'Shorthand for `bird read `'), ].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( ` 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 --help')} to see per-command flags.)`)}`, @@ -135,6 +137,7 @@ export function createProgram(ctx: CliContext): Command { registerListsCommand(program, ctx); registerHomeCommand(program, ctx); registerUserCommands(program, ctx); + registerUserTweetsCommand(program, ctx); registerCheckCommand(program, ctx); return program; diff --git a/src/commands/user-tweets.ts b/src/commands/user-tweets.ts new file mode 100644 index 0000000..d3c5164 --- /dev/null +++ b/src/commands/user-tweets.ts @@ -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('', 'Username to fetch tweets from (e.g., @steipete or steipete)') + .option('-n, --count ', 'Number of tweets to fetch', '20') + .option('--pages ', 'Number of pages to fetch (max: 10)', '1') + .option('--delay ', 'Delay in ms between page fetches', '1000') + .option('--cursor ', '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>; + 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); + } + }, + ); +} diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index 09febbb..98276b1 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -21,6 +21,7 @@ export const FALLBACK_QUERY_IDS = { TweetDetail: '97JF30KziU00483E_8elBA', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', + UserTweets: 'Wms1GvIiHXAPBaCr9KblaA', Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw', Following: 'BEkNpEt5pNETESoqMsTEGA', Followers: 'kuFUYP9eV1FPoEy4N-pi7w', diff --git a/src/lib/twitter-client-features.ts b/src/lib/twitter-client-features.ts index 7454964..1c5d7ac 100644 --- a/src/lib/twitter-client-features.ts +++ b/src/lib/twitter-client-features.ts @@ -232,6 +232,46 @@ export function buildHomeTimelineFeatures(): Record { }); } +export function buildUserTweetsFeatures(): Record { + 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 { return applyFeatureOverrides('following', { rweb_video_screen_enabled: true, diff --git a/src/lib/twitter-client-user-tweets.ts b/src/lib/twitter-client-user-tweets.ts new file mode 100644 index 0000000..cb0d553 --- /dev/null +++ b/src/lib/twitter-client-user-tweets.ts @@ -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; + getUserTweets(userId: string, count?: number, options?: UserTweetsFetchOptions): Promise; + getUserTweetsPaged(userId: string, options?: UserTweetsPaginationOptions): Promise; +} + +export function withUserTweets>( + Base: TBase, +): Mixin { + 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 { + 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 { + // 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 { + 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 { + // 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 { + 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 { + const { includeRaw = false, maxPages = 1, pageDelayMs = 1000 } = options; + const features = buildUserTweetsFeatures(); + const pageSize = 20; + const seen = new Set(); + 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; +} diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index e32001d..813cc30 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -8,6 +8,7 @@ import { type TwitterClientPostingMethods, withPosting } from './twitter-client- import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.js'; import { type TwitterClientTimelineMethods, withTimelines } from './twitter-client-timelines.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'; type TwitterClientInstance = TwitterClientBase & @@ -19,11 +20,14 @@ type TwitterClientInstance = TwitterClientBase & TwitterClientSearchMethods & TwitterClientTimelineMethods & TwitterClientTweetDetailMethods & - TwitterClientUserMethods; + TwitterClientUserMethods & + TwitterClientUserTweetsMethods; -const MixedTwitterClient = withUsers( - withLists( - withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))), +const MixedTwitterClient = withUserTweets( + withUsers( + withLists( + withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase)))))))), + ), ), ) as AbstractConstructor; diff --git a/tests/live/live.test.ts b/tests/live/live.test.ts index 004eae3..59b759f 100644 --- a/tests/live/live.test.ts +++ b/tests/live/live.test.ts @@ -268,6 +268,22 @@ d('live CLI (Twitter/X)', () => { 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>(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 () => { const queryIds = await runBird([...baseArgs, '--cookie-timeout', cookieTimeoutArg, 'query-ids', '--json'], { timeoutMs: 60_000, diff --git a/tests/twitter-client.user-tweets.test.ts b/tests/twitter-client.user-tweets.test.ts new file mode 100644 index 0000000..7da8bca --- /dev/null +++ b/tests/twitter-client.user-tweets.test.ts @@ -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; + + 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; + + 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[], 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; + + 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[], 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'); + }); +});