tweets.js
1 import { cli, Strategy } from '@jackwener/opencli/registry'; 2 import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors'; 3 import { resolveTwitterQueryId, sanitizeQueryId, extractMedia } from './shared.js'; 4 5 const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; 6 const USER_TWEETS_QUERY_ID = '6fWQaBPK51aGyC_VC7t9GQ'; 7 const USER_BY_SCREEN_NAME_QUERY_ID = 'IGgvgiOx4QZndDHuD3x9TQ'; 8 9 const USER_TWEETS_FEATURES = { 10 rweb_video_screen_enabled: false, 11 payments_enabled: false, 12 profile_label_improvements_pcf_label_in_post_enabled: true, 13 rweb_tipjar_consumption_enabled: true, 14 verified_phone_label_enabled: false, 15 creator_subscriptions_tweet_preview_api_enabled: true, 16 responsive_web_graphql_timeline_navigation_enabled: true, 17 responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, 18 premium_content_api_read_enabled: false, 19 communities_web_enable_tweet_community_results_fetch: true, 20 c9s_tweet_anatomy_moderator_badge_enabled: true, 21 responsive_web_grok_analyze_button_fetch_trends_enabled: false, 22 responsive_web_grok_analyze_post_followups_enabled: true, 23 responsive_web_jetfuel_frame: true, 24 responsive_web_grok_share_attachment_enabled: true, 25 responsive_web_grok_annotations_enabled: true, 26 articles_preview_enabled: true, 27 responsive_web_edit_tweet_api_enabled: true, 28 graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, 29 view_counts_everywhere_api_enabled: true, 30 longform_notetweets_consumption_enabled: true, 31 responsive_web_twitter_article_tweet_consumption_enabled: true, 32 tweet_awards_web_tipping_enabled: false, 33 content_disclosure_indicator_enabled: true, 34 content_disclosure_ai_generated_indicator_enabled: true, 35 responsive_web_grok_show_grok_translated_post: false, 36 responsive_web_grok_analysis_button_from_backend: true, 37 post_ctas_fetch_enabled: false, 38 freedom_of_speech_not_reach_fetch_enabled: true, 39 standardized_nudges_misinfo: true, 40 tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, 41 longform_notetweets_rich_text_read_enabled: true, 42 longform_notetweets_inline_media_enabled: true, 43 responsive_web_grok_image_annotation_enabled: true, 44 responsive_web_grok_imagine_annotation_enabled: true, 45 responsive_web_grok_community_note_auto_translation_is_enabled: false, 46 responsive_web_enhance_cards_enabled: false, 47 }; 48 49 const USER_BY_SCREEN_NAME_FEATURES = { 50 hidden_profile_subscriptions_enabled: true, 51 rweb_tipjar_consumption_enabled: true, 52 responsive_web_graphql_exclude_directive_enabled: true, 53 verified_phone_label_enabled: false, 54 subscriptions_verification_info_is_identity_verified_enabled: true, 55 subscriptions_verification_info_verified_since_enabled: true, 56 highlights_tweets_tab_ui_enabled: true, 57 responsive_web_twitter_article_notes_tab_enabled: true, 58 subscriptions_feature_can_gift_premium: true, 59 creator_subscriptions_tweet_preview_api_enabled: true, 60 responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, 61 responsive_web_graphql_timeline_navigation_enabled: true, 62 }; 63 64 function buildUserTweetsUrl(queryId, userId, count, cursor) { 65 const vars = { 66 userId, 67 count, 68 includePromotedContent: false, 69 withQuickPromoteEligibilityTweetFields: true, 70 withVoice: true, 71 }; 72 if (cursor) vars.cursor = cursor; 73 return `/i/api/graphql/${queryId}/UserTweets` 74 + `?variables=${encodeURIComponent(JSON.stringify(vars))}` 75 + `&features=${encodeURIComponent(JSON.stringify(USER_TWEETS_FEATURES))}`; 76 } 77 78 function buildUserByScreenNameUrl(queryId, screenName) { 79 const vars = { screen_name: screenName, withSafetyModeUserFields: true }; 80 return `/i/api/graphql/${queryId}/UserByScreenName` 81 + `?variables=${encodeURIComponent(JSON.stringify(vars))}` 82 + `&features=${encodeURIComponent(JSON.stringify(USER_BY_SCREEN_NAME_FEATURES))}`; 83 } 84 85 function extractTweet(result, seen) { 86 if (!result) return null; 87 const tw = result.tweet || result; 88 const legacy = tw.legacy || {}; 89 if (!tw.rest_id || seen.has(tw.rest_id)) return null; 90 seen.add(tw.rest_id); 91 const user = tw.core?.user_results?.result; 92 const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown'; 93 const displayName = user?.legacy?.name || user?.core?.name || ''; 94 const noteText = tw.note_tweet?.note_tweet_results?.result?.text; 95 const isRetweet = Boolean(legacy.retweeted_status_result || legacy.full_text?.startsWith('RT @')); 96 return { 97 id: tw.rest_id, 98 author: screenName, 99 name: displayName, 100 text: noteText || legacy.full_text || '', 101 likes: legacy.favorite_count || 0, 102 retweets: legacy.retweet_count || 0, 103 replies: legacy.reply_count || 0, 104 views: Number(tw.views?.count) || 0, 105 is_retweet: isRetweet, 106 created_at: legacy.created_at || '', 107 url: `https://x.com/${screenName}/status/${tw.rest_id}`, 108 ...extractMedia(legacy), 109 }; 110 } 111 112 function parseUserTweets(data, seen) { 113 const tweets = []; 114 let nextCursor = null; 115 const instructions = data?.data?.user?.result?.timeline_v2?.timeline?.instructions 116 || data?.data?.user?.result?.timeline?.timeline?.instructions 117 || []; 118 for (const inst of instructions) { 119 if (inst.type === 'TimelinePinEntry') continue; 120 for (const entry of inst.entries || []) { 121 const content = entry.content; 122 if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') { 123 if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore') nextCursor = content.value; 124 continue; 125 } 126 if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) { 127 nextCursor = content?.value || content?.itemContent?.value || nextCursor; 128 continue; 129 } 130 const direct = extractTweet(content?.itemContent?.tweet_results?.result, seen); 131 if (direct) { 132 tweets.push(direct); 133 continue; 134 } 135 for (const item of content?.items || []) { 136 const nested = extractTweet(item.item?.itemContent?.tweet_results?.result, seen); 137 if (nested) tweets.push(nested); 138 } 139 } 140 } 141 return { tweets, nextCursor }; 142 } 143 144 cli({ 145 site: 'twitter', 146 name: 'tweets', 147 description: "Fetch a Twitter user's most recent tweets (chronological, excludes pinned)", 148 domain: 'x.com', 149 strategy: Strategy.COOKIE, 150 browser: true, 151 args: [ 152 { name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (with or without @)' }, 153 { name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' }, 154 ], 155 columns: ['author', 'created_at', 'is_retweet', 'text', 'likes', 'retweets', 'replies', 'views', 'url', 'has_media', 'media_urls'], 156 func: async (page, kwargs) => { 157 const limit = Math.max(1, Math.min(200, kwargs.limit || 20)); 158 const username = String(kwargs.username || '').replace(/^@/, '').trim(); 159 if (!username) throw new CommandExecutionError('username is required'); 160 161 await page.goto('https://x.com'); 162 await page.wait(3); 163 164 const ct0 = await page.evaluate(`() => { 165 return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null; 166 }`); 167 if (!ct0) throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); 168 169 const userTweetsQueryId = await resolveTwitterQueryId(page, 'UserTweets', USER_TWEETS_QUERY_ID); 170 const userByScreenNameQueryId = await resolveTwitterQueryId(page, 'UserByScreenName', USER_BY_SCREEN_NAME_QUERY_ID); 171 172 const headers = JSON.stringify({ 173 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`, 174 'X-Csrf-Token': ct0, 175 'X-Twitter-Auth-Type': 'OAuth2Session', 176 'X-Twitter-Active-User': 'yes', 177 }); 178 179 const ubsUrl = buildUserByScreenNameUrl(userByScreenNameQueryId, username); 180 const userId = await page.evaluate(`async () => { 181 const resp = await fetch("${ubsUrl}", { headers: ${headers}, credentials: 'include' }); 182 if (!resp.ok) return null; 183 const d = await resp.json(); 184 return d?.data?.user?.result?.rest_id || null; 185 }`); 186 if (!userId) throw new CommandExecutionError(`Could not resolve @${username}`); 187 188 const seen = new Set(); 189 const all = []; 190 let cursor = null; 191 for (let i = 0; i < 5 && all.length < limit; i++) { 192 const fetchCount = Math.min(100, limit - all.length + 10); 193 const url = buildUserTweetsUrl(userTweetsQueryId, userId, fetchCount, cursor); 194 const data = await page.evaluate(`async () => { 195 const r = await fetch("${url}", { headers: ${headers}, credentials: 'include' }); 196 return r.ok ? await r.json() : { error: r.status }; 197 }`); 198 if (data?.error) { 199 if (all.length === 0) throw new CommandExecutionError(`HTTP ${data.error}: UserTweets fetch failed — queryId may have expired`); 200 break; 201 } 202 const { tweets, nextCursor } = parseUserTweets(data, seen); 203 all.push(...tweets); 204 if (!nextCursor || nextCursor === cursor) break; 205 cursor = nextCursor; 206 } 207 208 if (all.length === 0) throw new EmptyResultError(`@${username} has no recent tweets`, 'Account may be private or suspended'); 209 return all.slice(0, limit); 210 }, 211 }); 212 213 export const __test__ = { 214 sanitizeQueryId, 215 buildUserTweetsUrl, 216 buildUserByScreenNameUrl, 217 extractTweet, 218 parseUserTweets, 219 };