From df98e827f6804eca33fe5fabbd6e51b99061de50 Mon Sep 17 00:00:00 2001 From: cc-vps Date: Tue, 6 Jan 2026 18:11:45 -0800 Subject: [PATCH 1/4] 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'); + }); +}); From 4eac0927c1db57f2631ae7468e1fd1e66b0cc3ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 01:35:00 +0000 Subject: [PATCH 2/4] fix: paginate user-tweets by --count --- src/commands/user-tweets.ts | 54 ++++++++++---------- src/lib/twitter-client-user-tweets.ts | 63 ++++++++++++++---------- src/lib/twitter-client.ts | 2 +- tests/twitter-client.user-tweets.test.ts | 12 ++--- 4 files changed, 69 insertions(+), 62 deletions(-) diff --git a/src/commands/user-tweets.ts b/src/commands/user-tweets.ts index d3c5164..72906f8 100644 --- a/src/commands/user-tweets.ts +++ b/src/commands/user-tweets.ts @@ -12,7 +12,7 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo .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('--max-pages ', 'Stop after N pages (max: 10)') .option('--delay ', 'Delay in ms between page fetches', '1000') .option('--cursor ', 'Resume pagination from a cursor') .option('--json', 'Output as JSON') @@ -23,7 +23,8 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo `\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 -n 50', 'Fetch 50 tweets (paged)'), + formatExample('bird user-tweets @steipete --max-pages 3 -n 200', 'Safety cap (max 3 pages)'), formatExample('bird user-tweets @steipete --json', 'Output as JSON'), formatExample('bird user-tweets @steipete --cursor "DAABCg..."', 'Resume from cursor'), ].join('\n')}`, @@ -33,7 +34,7 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo handle: string, cmdOpts: { count?: string; - pages?: string; + maxPages?: string; delay?: string; cursor?: string; json?: boolean; @@ -44,7 +45,7 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo 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 maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; const pageDelayMs = Number.parseInt(cmdOpts.delay || '1000', 10); // Validate inputs @@ -52,8 +53,17 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo 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).`); + const pageSize = 20; + const hardMaxPages = 10; + const hardMaxTweets = pageSize * hardMaxPages; + if (count > hardMaxTweets) { + console.error( + `${ctx.p('err')}Invalid --count. Max ${hardMaxTweets} tweets per run (safety cap: ${hardMaxPages} pages). Use --cursor to continue.`, + ); + process.exit(2); + } + if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0 || maxPages > hardMaxPages)) { + console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer (max: ${hardMaxPages}).`); process.exit(2); } if (!Number.isFinite(pageDelayMs) || pageDelayMs < 0) { @@ -95,33 +105,21 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo 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 }); - } + const wantsPaginationOutput = Boolean(cmdOpts.cursor) || maxPages !== undefined || count > pageSize; + const result = await client.getUserTweetsPaged(userLookup.userId, count, { + includeRaw, + maxPages, + cursor: cmdOpts.cursor, + pageDelayMs, + }); 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) { + if (isJson && wantsPaginationOutput) { // Include nextCursor for pagination consumers - console.log(JSON.stringify({ tweets, nextCursor: result.nextCursor ?? null }, null, 2)); + console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2)); } else { - ctx.printTweets(tweets, { + ctx.printTweets(result.tweets, { json: isJson, emptyMessage: `No tweets found for @${username}.`, }); diff --git a/src/lib/twitter-client-user-tweets.ts b/src/lib/twitter-client-user-tweets.ts index cb0d553..453d5b4 100644 --- a/src/lib/twitter-client-user-tweets.ts +++ b/src/lib/twitter-client-user-tweets.ts @@ -33,7 +33,7 @@ export interface UserLookupResult { export interface TwitterClientUserTweetsMethods { getUserIdByUsername(username: string): Promise; getUserTweets(userId: string, count?: number, options?: UserTweetsFetchOptions): Promise; - getUserTweetsPaged(userId: string, options?: UserTweetsPaginationOptions): Promise; + getUserTweetsPaged(userId: string, limit: number, options?: UserTweetsPaginationOptions): Promise; } export function withUserTweets>( @@ -169,7 +169,7 @@ export function withUserTweets { // Normalize and validate handle @@ -245,24 +245,22 @@ export function withUserTweets { - 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; + return this.getUserTweetsPaged(userId, count, options); } /** * 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; + async getUserTweetsPaged( + userId: string, + limit: number, + options: UserTweetsPaginationOptions = {}, + ): Promise { + if (!Number.isFinite(limit) || limit <= 0) { + return { success: false, error: `Invalid limit: ${limit}` }; + } + + const { includeRaw = false, maxPages, pageDelayMs = 1000 } = options; const features = buildUserTweetsFeatures(); const pageSize = 20; const seen = new Set(); @@ -270,15 +268,18 @@ export function withUserTweets { + const fetchPage = async (pageCount: number, pageCursor?: string) => { let lastError: string | undefined; let had404 = false; const queryIds = await this.getUserTweetsQueryIds(); const variables = { userId, - count: pageSize, + count: pageCount, includePromotedContent: false, // Filter out ads withQuickPromoteEligibilityTweetFields: true, withVoice: true, @@ -396,14 +397,14 @@ export function withUserTweets { - const firstAttempt = await fetchPage(pageCursor); + const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => { + const firstAttempt = await fetchPage(pageCount, pageCursor); if (firstAttempt.success) { return firstAttempt; } if (firstAttempt.had404) { await this.refreshQueryIds(); - const secondAttempt = await fetchPage(pageCursor); + const secondAttempt = await fetchPage(pageCount, pageCursor); if (secondAttempt.success) { return secondAttempt; } @@ -412,36 +413,44 @@ export function withUserTweets 0 && pageDelayMs > 0) { await this.sleep(pageDelayMs); } - const page = await fetchWithRefresh(cursor); + const remaining = limit - tweets.length; + const pageCount = Math.min(pageSize, remaining); + const page = await fetchWithRefresh(pageCount, 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; + let added = 0; for (const tweet of page.tweets) { if (seen.has(tweet.id)) { continue; } seen.add(tweet.id); tweets.push(tweet); + added += 1; + if (tweets.length >= limit) { + break; + } } const pageCursor = page.cursor; - if (!pageCursor || pageCursor === cursor || page.tweets.length === 0) { + if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) { nextCursor = undefined; break; } + if (pagesFetched >= effectiveMaxPages) { + nextCursor = pageCursor; + break; + } + cursor = pageCursor; nextCursor = pageCursor; } diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 813cc30..f4c5838 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -26,7 +26,7 @@ type TwitterClientInstance = TwitterClientBase & const MixedTwitterClient = withUserTweets( withUsers( withLists( - withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase)))))))), + withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))), ), ), ) as AbstractConstructor; diff --git a/tests/twitter-client.user-tweets.test.ts b/tests/twitter-client.user-tweets.test.ts index 7da8bca..6935e7d 100644 --- a/tests/twitter-client.user-tweets.test.ts +++ b/tests/twitter-client.user-tweets.test.ts @@ -299,7 +299,7 @@ describe('TwitterClient getUserTweets', () => { }); const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getUserTweetsPaged('12345', { maxPages: 1 }); + const result = await client.getUserTweetsPaged('12345', 20, { maxPages: 1 }); expect(result.success).toBe(true); expect(result.nextCursor).toBe('next-cursor-abc'); @@ -408,7 +408,7 @@ describe('TwitterClient getUserTweetsPaged', () => { }); const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getUserTweetsPaged('12345', { + const result = await client.getUserTweetsPaged('12345', 40, { maxPages: 2, pageDelayMs: 0, // No delay for tests }); @@ -435,7 +435,7 @@ describe('TwitterClient getUserTweetsPaged', () => { }); const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getUserTweetsPaged('12345', { + const result = await client.getUserTweetsPaged('12345', 40, { maxPages: 2, pageDelayMs: 0, }); @@ -459,7 +459,7 @@ describe('TwitterClient getUserTweetsPaged', () => { }); const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getUserTweetsPaged('12345', { + const result = await client.getUserTweetsPaged('12345', 100, { maxPages: 5, pageDelayMs: 0, }); @@ -488,7 +488,7 @@ describe('TwitterClient getUserTweetsPaged', () => { }); const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getUserTweetsPaged('12345', { + const result = await client.getUserTweetsPaged('12345', 40, { maxPages: 2, pageDelayMs: 0, }); @@ -506,7 +506,7 @@ describe('TwitterClient getUserTweetsPaged', () => { }); const client = new TwitterClient({ cookies: validCookies }); - const result = await client.getUserTweetsPaged('12345', { + const result = await client.getUserTweetsPaged('12345', 20, { maxPages: 1, cursor: 'resume-cursor-xyz', pageDelayMs: 0, From 7b5f4b3f2d557c7435237c835ed157aabd2c07c6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 01:35:04 +0000 Subject: [PATCH 3/4] docs: document user-tweets --- CHANGELOG.md | 1 + README.md | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6526f44..665a9d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `list-timeline` now supports pagination (`--all`, `--max-pages`, `--cursor`) (#30) — thanks @zheli. - `home` command for the "For You" and "Following" home timelines (#31) — thanks @odysseus0. - Rich text output now shows article previews, quoted tweets, and media links (#32) — thanks @odysseus0. +- `user-tweets` command to fetch a user's profile timeline (#34) — thanks @crcatala. ### Fixed - macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst. diff --git a/README.md b/README.md index 215cad5..e946953 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ bird search "from:steipete" -n 5 bird mentions -n 5 bird mentions --user @steipete -n 5 +# User tweets (profile timeline) +bird user-tweets @steipete -n 20 +bird user-tweets @steipete -n 50 --json + # Bookmarks bird bookmarks -n 5 bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/ @@ -100,6 +104,7 @@ const result = await client.search('from:steipete', 50); - `bird thread [--json]` — show the full conversation thread. - `bird search "" [-n count] [--json]` — search for tweets matching a query. - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). +- `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline. - `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--json]` — list your bookmarked tweets (or a specific bookmark folder); `--max-pages` requires `--all`. - `bird unbookmark ` — remove one or more bookmarks by tweet ID or URL. - `bird likes [-n count] [--json]` — list your liked tweets. @@ -171,7 +176,8 @@ Environment shortcuts: ## Output -- `--json` prints raw tweet objects for read/replies/thread/search/mentions/bookmarks/likes. +- `--json` prints raw tweet objects for read/replies/thread/search/mentions/user-tweets/bookmarks/likes. +- When using `--json` with pagination (`--cursor`, `--max-pages`, or `-n > 20`), `user-tweets` prints `{ tweets, nextCursor }`. - `read` returns full text for Notes and Articles when present. - Use `--plain` for stable, script-friendly output (no emoji, no color). From 21701bad5a0a18ad48e1aa7cb29e01ec702d932f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 01:41:10 +0000 Subject: [PATCH 4/4] fix: repair pnpm patchedDependencies --- package.json | 5 +++++ patches/@steipete__sweet-cookie.patch | 2 +- pnpm-lock.yaml | 6 +++--- pnpm-workspace.yaml | 3 --- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index b3bcdb4..2cd8f01 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,11 @@ "json5": "^2.2.3", "kleur": "^4.1.5" }, + "pnpm": { + "patchedDependencies": { + "@steipete/sweet-cookie": "patches/@steipete__sweet-cookie.patch" + } + }, "devDependencies": { "@biomejs/biome": "^2.3.10", "@types/node": "^25.0.3", diff --git a/patches/@steipete__sweet-cookie.patch b/patches/@steipete__sweet-cookie.patch index b89afec..12ab8a5 100644 --- a/patches/@steipete__sweet-cookie.patch +++ b/patches/@steipete__sweet-cookie.patch @@ -54,7 +54,7 @@ index 71c815f77378d03e07061976ac372b3a55a61ac4..b21c15640955a8e7faa8f1d6ea6fed7c return { cookies: [], warnings }; } // Chromium uses PBKDF2(password, "saltysalt", 1003, 16, sha1) for AES-128-CBC cookie values on macOS. -@@ -46,15 +64,19 @@ export async function getCookiesFromChromeSqliteMac(options, origins, allowlistN +@@ -46,14 +64,18 @@ export async function getCookiesFromChromeSqliteMac(options, origins, allowlistN result.warnings.unshift(...warnings); return result; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bab9939..bf333b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: '@steipete/sweet-cookie': - hash: 05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340 + hash: 4ff44e9d3b5770a7b751c7a3470572bdf485763c28ead8055468dd0ae9a6e986 path: patches/@steipete__sweet-cookie.patch importers: @@ -15,7 +15,7 @@ importers: dependencies: '@steipete/sweet-cookie': specifier: 0.1.0 - version: 0.1.0(patch_hash=05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340) + version: 0.1.0(patch_hash=4ff44e9d3b5770a7b751c7a3470572bdf485763c28ead8055468dd0ae9a6e986) commander: specifier: ^14.0.2 version: 14.0.2 @@ -1056,7 +1056,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@steipete/sweet-cookie@0.1.0(patch_hash=05180a0fa74ba5074775f965db7d4561e59ba51978bd5e955c0e4f058a4ed340)': {} + '@steipete/sweet-cookie@0.1.0(patch_hash=4ff44e9d3b5770a7b751c7a3470572bdf485763c28ead8055468dd0ae9a6e986)': {} '@types/chai@5.2.3': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index eebef26..efc037a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,2 @@ onlyBuiltDependencies: - esbuild - -patchedDependencies: - '@steipete/sweet-cookie': patches/@steipete__sweet-cookie.patch