list-tweets.js
1 import { cli, Strategy } from '@jackwener/opencli/registry'; 2 import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors'; 3 4 const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA'; 5 const LIST_TWEETS_QUERY_ID = 'RlZzktZY_9wJynoepm8ZsA'; 6 const OPERATION_NAME = 'ListLatestTweetsTimeline'; 7 8 const FEATURES = { 9 rweb_video_screen_enabled: false, 10 profile_label_improvements_pcf_label_in_post_enabled: true, 11 rweb_tipjar_consumption_enabled: true, 12 verified_phone_label_enabled: false, 13 creator_subscriptions_tweet_preview_api_enabled: true, 14 responsive_web_graphql_timeline_navigation_enabled: true, 15 responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, 16 premium_content_api_read_enabled: false, 17 communities_web_enable_tweet_community_results_fetch: true, 18 c9s_tweet_anatomy_moderator_badge_enabled: true, 19 responsive_web_grok_analyze_button_fetch_trends_enabled: false, 20 responsive_web_grok_analyze_post_followups_enabled: true, 21 responsive_web_jetfuel_frame: false, 22 responsive_web_grok_share_attachment_enabled: true, 23 articles_preview_enabled: true, 24 responsive_web_edit_tweet_api_enabled: true, 25 graphql_is_translatable_rweb_tweet_is_translatable_enabled: true, 26 view_counts_everywhere_api_enabled: true, 27 longform_notetweets_consumption_enabled: true, 28 responsive_web_twitter_article_tweet_consumption_enabled: true, 29 tweet_awards_web_tipping_enabled: false, 30 responsive_web_grok_show_grok_translated_post: false, 31 responsive_web_grok_analysis_button_from_backend: false, 32 creator_subscriptions_quote_tweet_preview_enabled: false, 33 freedom_of_speech_not_reach_fetch_enabled: true, 34 standardized_nudges_misinfo: true, 35 tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true, 36 longform_notetweets_rich_text_read_enabled: true, 37 longform_notetweets_inline_media_enabled: true, 38 responsive_web_grok_image_annotation_enabled: true, 39 responsive_web_enhance_cards_enabled: false, 40 }; 41 42 function buildUrl(queryId, listId, count, cursor) { 43 const vars = { listId: String(listId), count }; 44 if (cursor) 45 vars.cursor = cursor; 46 return `/i/api/graphql/${queryId}/${OPERATION_NAME}` 47 + `?variables=${encodeURIComponent(JSON.stringify(vars))}` 48 + `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`; 49 } 50 51 export function extractTimelineTweet(result, seen) { 52 if (!result) 53 return null; 54 const tw = result.tweet || result; 55 const legacy = tw.legacy || {}; 56 if (!tw.rest_id || seen.has(tw.rest_id)) 57 return null; 58 seen.add(tw.rest_id); 59 const user = tw.core?.user_results?.result; 60 const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown'; 61 const displayName = user?.legacy?.name || user?.core?.name || ''; 62 const noteText = tw.note_tweet?.note_tweet_results?.result?.text; 63 return { 64 id: tw.rest_id, 65 author: screenName, 66 name: displayName, 67 text: noteText || legacy.full_text || '', 68 likes: legacy.favorite_count || 0, 69 retweets: legacy.retweet_count || 0, 70 replies: legacy.reply_count || 0, 71 created_at: legacy.created_at || '', 72 url: `https://x.com/${screenName}/status/${tw.rest_id}`, 73 }; 74 } 75 76 export function parseListTimeline(data, seen) { 77 const tweets = []; 78 let nextCursor = null; 79 const instructions = data?.data?.list?.tweets_timeline?.timeline?.instructions || []; 80 for (const inst of instructions) { 81 for (const entry of inst.entries || []) { 82 const content = entry.content; 83 if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') { 84 if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore') 85 nextCursor = content.value; 86 continue; 87 } 88 if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) { 89 nextCursor = content?.value || content?.itemContent?.value || nextCursor; 90 continue; 91 } 92 const direct = extractTimelineTweet(content?.itemContent?.tweet_results?.result, seen); 93 if (direct) { 94 tweets.push(direct); 95 continue; 96 } 97 for (const item of content?.items || []) { 98 const nested = extractTimelineTweet(item.item?.itemContent?.tweet_results?.result, seen); 99 if (nested) 100 tweets.push(nested); 101 } 102 } 103 } 104 return { tweets, nextCursor }; 105 } 106 107 cli({ 108 site: 'twitter', 109 name: 'list-tweets', 110 description: 'Fetch tweets from a Twitter/X list timeline', 111 domain: 'x.com', 112 strategy: Strategy.COOKIE, 113 browser: true, 114 args: [ 115 { name: 'listId', positional: true, type: 'string', required: true }, 116 { name: 'limit', type: 'int', default: 50 }, 117 ], 118 columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'created_at', 'url'], 119 func: async (page, kwargs) => { 120 const listId = String(kwargs.listId || '').trim(); 121 if (!listId || !/^\d+$/.test(listId)) { 122 throw new CommandExecutionError(`Invalid listId: ${JSON.stringify(kwargs.listId)}. Expected a numeric ID (see \`opencli twitter lists\`).`); 123 } 124 const limit = kwargs.limit || 50; 125 await page.goto('https://x.com'); 126 await page.wait(3); 127 const ct0 = await page.evaluate(`() => { 128 return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null; 129 }`); 130 if (!ct0) 131 throw new AuthRequiredError('x.com', 'Not logged into x.com (no ct0 cookie)'); 132 const queryId = await page.evaluate(`async () => { 133 try { 134 const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json'); 135 if (ghResp.ok) { 136 const data = await ghResp.json(); 137 const entry = data['${OPERATION_NAME}']; 138 if (entry && entry.queryId) return entry.queryId; 139 } 140 } catch {} 141 try { 142 const scripts = performance.getEntriesByType('resource') 143 .filter(r => r.name.includes('client-web') && r.name.endsWith('.js')) 144 .map(r => r.name); 145 for (const scriptUrl of scripts.slice(0, 15)) { 146 try { 147 const text = await (await fetch(scriptUrl)).text(); 148 const re = /queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"${OPERATION_NAME}"/; 149 const m = text.match(re); 150 if (m) return m[1]; 151 } catch {} 152 } 153 } catch {} 154 return null; 155 }`) || LIST_TWEETS_QUERY_ID; 156 const headers = JSON.stringify({ 157 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`, 158 'X-Csrf-Token': ct0, 159 'X-Twitter-Auth-Type': 'OAuth2Session', 160 'X-Twitter-Active-User': 'yes', 161 }); 162 const allTweets = []; 163 const seen = new Set(); 164 let cursor = null; 165 for (let i = 0; i < 10 && allTweets.length < limit; i++) { 166 const fetchCount = Math.min(100, limit - allTweets.length + 10); 167 const apiUrl = buildUrl(queryId, listId, fetchCount, cursor); 168 const data = await page.evaluate(`async () => { 169 const r = await fetch(${JSON.stringify(apiUrl)}, { headers: ${headers}, credentials: 'include' }); 170 return r.ok ? await r.json() : { error: r.status }; 171 }`); 172 if (data?.error) { 173 if (allTweets.length === 0) 174 throw new CommandExecutionError(`HTTP ${data.error}: Failed to fetch list timeline. queryId may have expired or list may be private.`); 175 break; 176 } 177 const { tweets, nextCursor } = parseListTimeline(data, seen); 178 allTweets.push(...tweets); 179 if (!nextCursor || nextCursor === cursor || tweets.length === 0) 180 break; 181 cursor = nextCursor; 182 } 183 return allTweets.slice(0, limit); 184 }, 185 });