/ clis / twitter / list-tweets.js
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  });