diff --git a/src/index.ts b/src/index.ts index fe23bb6..054e3bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -115,6 +115,52 @@ program } }); +// Read command - fetch tweet content +program + .command('read') + .description('Read/fetch a tweet by ID or URL') + .argument('', 'Tweet ID or URL to read') + .option('--json', 'Output as JSON') + .action(async (tweetIdOrUrl: string, cmdOpts: { json?: boolean }) => { + const opts = program.opts(); + const { cookies, warnings } = await resolveCredentials({ + authToken: opts.authToken, + ct0: opts.ct0, + chromeProfile: opts.chromeProfile, + }); + + for (const warning of warnings) { + console.error(`āš ļø ${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error('āŒ Missing required credentials'); + process.exit(1); + } + + const tweetId = extractTweetId(tweetIdOrUrl); + const client = new TwitterClient({ cookies }); + const result = await client.getTweet(tweetId); + + if (result.success && result.tweet) { + if (cmdOpts.json) { + console.log(JSON.stringify(result.tweet, null, 2)); + } else { + console.log(`@${result.tweet.author.username} (${result.tweet.author.name}):`); + console.log(result.tweet.text); + if (result.tweet.createdAt) { + console.log(`\nšŸ“… ${result.tweet.createdAt}`); + } + console.log( + `ā¤ļø ${result.tweet.likeCount ?? 0} šŸ” ${result.tweet.retweetCount ?? 0} šŸ’¬ ${result.tweet.replyCount ?? 0}`, + ); + } + } else { + console.error(`āŒ Failed to read tweet: ${result.error}`); + process.exit(1); + } + }); + // Check command - verify credentials program .command('check') diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index c9ded09..3566e48 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -12,6 +12,7 @@ const QUERY_IDS = { CreateTweet: 'znCVAd692dKBq9MgkEhKPQ', CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', + TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ', }; export interface TweetResult { @@ -20,6 +21,25 @@ export interface TweetResult { error?: string; } +export interface TweetData { + id: string; + text: string; + author: { + username: string; + name: string; + }; + createdAt?: string; + replyCount?: number; + retweetCount?: number; + likeCount?: number; +} + +export interface GetTweetResult { + success: boolean; + tweet?: TweetData; + error?: string; +} + export interface TwitterClientOptions { cookies: TwitterCookies; userAgent?: string; @@ -73,6 +93,213 @@ export class TwitterClient { }; } + /** + * Get tweet details by ID + */ + async getTweet(tweetId: string): Promise { + const variables = { + focalTweetId: tweetId, + with_rux_injections: false, + rankingMode: 'Relevance', + includePromotedContent: true, + withCommunity: true, + withQuickPromoteEligibilityTweetFields: true, + withBirdwatchNotes: true, + withVoice: true, + }; + + const features = { + rweb_tipjar_consumption_enabled: true, + responsive_web_graphql_exclude_directive_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, + communities_web_enable_tweet_community_results_fetch: true, + c9s_tweet_anatomy_moderator_badge_enabled: true, + 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, + 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, + rweb_video_timestamps_enabled: true, + longform_notetweets_rich_text_read_enabled: true, + longform_notetweets_inline_media_enabled: true, + responsive_web_enhance_cards_enabled: false, + }; + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const url = `${TWITTER_API_BASE}/${QUERY_IDS.TweetDetail}/TweetDetail?${params}`; + + try { + const response = await fetch(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + const text = await response.text(); + return { + success: false, + error: `HTTP ${response.status}: ${text.slice(0, 200)}`, + }; + } + + const data = (await response.json()) as { + data?: { + tweetResult?: { + result?: { + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + }; + core?: { + user_results?: { + result?: { + legacy?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + }; + }; + threaded_conversation_with_injections_v2?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: { + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + }; + core?: { + user_results?: { + result?: { + legacy?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + }; + }; + }; + }; + }>; + }>; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { + success: false, + error: data.errors.map((e) => e.message).join(', '), + }; + } + + // Try to find the tweet in the conversation thread + const instructions = data.data?.threaded_conversation_with_injections_v2?.instructions; + let tweetResult: + | { + rest_id?: string; + legacy?: { + full_text?: string; + created_at?: string; + reply_count?: number; + retweet_count?: number; + favorite_count?: number; + }; + core?: { + user_results?: { + result?: { + legacy?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + } + | undefined; + + if (instructions) { + for (const instruction of instructions) { + for (const entry of instruction.entries || []) { + const result = entry.content?.itemContent?.tweet_results?.result; + if (result?.rest_id === tweetId) { + tweetResult = result; + break; + } + } + if (tweetResult) break; + } + } + + if (!tweetResult) { + return { + success: false, + error: 'Tweet not found in response', + }; + } + + const legacy = tweetResult.legacy; + const userLegacy = tweetResult.core?.user_results?.result?.legacy; + + if (!legacy?.full_text || !userLegacy?.screen_name) { + return { + success: false, + error: 'Incomplete tweet data', + }; + } + + return { + success: true, + tweet: { + id: tweetResult.rest_id || tweetId, + text: legacy.full_text, + author: { + username: userLegacy.screen_name, + name: userLegacy.name || userLegacy.screen_name, + }, + createdAt: legacy.created_at, + replyCount: legacy.reply_count, + retweetCount: legacy.retweet_count, + likeCount: legacy.favorite_count, + }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + /** * Post a new tweet */