@jackwener/opencli 0.7.8 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,14 +13,17 @@ cli({
13
13
  ],
14
14
  columns: ['id', 'author', 'text', 'likes', 'views', 'url'],
15
15
  func: async (page, kwargs) => {
16
+ // Install the interceptor before opening the target page so we don't miss
17
+ // the initial SearchTimeline request fired during hydration.
18
+ await page.goto('https://x.com');
19
+ await page.wait(2);
20
+ await page.installInterceptor('SearchTimeline');
21
+
16
22
  // 1. Navigate to the search page
17
23
  const q = encodeURIComponent(kwargs.query);
18
24
  await page.goto(`https://x.com/search?q=${q}&f=top`);
19
25
  await page.wait(5);
20
26
 
21
- // 2. Inject XHR interceptor
22
- await page.installInterceptor('SearchTimeline');
23
-
24
27
  // 3. Trigger API by scrolling
25
28
  await page.autoScroll({ times: 3, delayMs: 2000 });
26
29
 
@@ -29,11 +32,13 @@ cli({
29
32
  if (!requests || requests.length === 0) return [];
30
33
 
31
34
  let results: any[] = [];
35
+ const seen = new Set<string>();
32
36
  for (const req of requests) {
33
37
  try {
34
- const insts = req.data.data.search_by_raw_query.search_timeline.timeline.instructions;
35
- const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries');
36
- if (!addEntries) continue;
38
+ const insts = req.data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
39
+ const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries')
40
+ || insts.find((i: any) => i.entries && Array.isArray(i.entries));
41
+ if (!addEntries?.entries) continue;
37
42
 
38
43
  for (const entry of addEntries.entries) {
39
44
  if (!entry.entryId.startsWith('tweet-')) continue;
@@ -45,11 +50,13 @@ cli({
45
50
  if (tweet.__typename === 'TweetWithVisibilityResults' && tweet.tweet) {
46
51
  tweet = tweet.tweet;
47
52
  }
53
+ if (!tweet.rest_id || seen.has(tweet.rest_id)) continue;
54
+ seen.add(tweet.rest_id);
48
55
 
49
56
  results.push({
50
57
  id: tweet.rest_id,
51
58
  author: tweet.core?.user_results?.result?.legacy?.screen_name || 'unknown',
52
- text: tweet.legacy?.full_text || '',
59
+ text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
53
60
  likes: tweet.legacy?.favorite_count || 0,
54
61
  views: tweet.views?.count || '0',
55
62
  url: `https://x.com/i/status/${tweet.rest_id}`
@@ -1,50 +1,218 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
 
3
+ // ── Twitter GraphQL constants ──────────────────────────────────────────
4
+
5
+ const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
6
+ const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
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
+ // ── Pure functions (type-safe, testable) ───────────────────────────────
43
+
44
+ interface TimelineTweet {
45
+ id: string;
46
+ author: string;
47
+ text: string;
48
+ likes: number;
49
+ retweets: number;
50
+ replies: number;
51
+ views: number;
52
+ created_at: string;
53
+ url: string;
54
+ }
55
+
56
+ function buildHomeTimelineUrl(count: number, cursor?: string | null): string {
57
+ const vars: Record<string, any> = {
58
+ count,
59
+ includePromotedContent: false,
60
+ latestControlAvailable: true,
61
+ requestContext: 'launch',
62
+ withCommunity: true,
63
+ };
64
+ if (cursor) vars.cursor = cursor;
65
+
66
+ return `/i/api/graphql/${HOME_TIMELINE_QUERY_ID}/HomeTimeline`
67
+ + `?variables=${encodeURIComponent(JSON.stringify(vars))}`
68
+ + `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
69
+ }
70
+
71
+ function extractTweet(result: any, seen: Set<string>): TimelineTweet | null {
72
+ if (!result) return null;
73
+ const tw = result.tweet || result;
74
+ const l = tw.legacy || {};
75
+ if (!tw.rest_id || seen.has(tw.rest_id)) return null;
76
+ seen.add(tw.rest_id);
77
+
78
+ const u = tw.core?.user_results?.result;
79
+ const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
80
+ const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
81
+ const views = tw.views?.count ? parseInt(tw.views.count, 10) : 0;
82
+
83
+ return {
84
+ id: tw.rest_id,
85
+ author: screenName,
86
+ text: noteText || l.full_text || '',
87
+ likes: l.favorite_count || 0,
88
+ retweets: l.retweet_count || 0,
89
+ replies: l.reply_count || 0,
90
+ views,
91
+ created_at: l.created_at || '',
92
+ url: `https://x.com/${screenName}/status/${tw.rest_id}`,
93
+ };
94
+ }
95
+
96
+ function parseHomeTimeline(data: any, seen: Set<string>): { tweets: TimelineTweet[]; nextCursor: string | null } {
97
+ const tweets: TimelineTweet[] = [];
98
+ let nextCursor: string | null = null;
99
+
100
+ const instructions =
101
+ data?.data?.home?.home_timeline_urt?.instructions || [];
102
+
103
+ for (const inst of instructions) {
104
+ for (const entry of inst.entries || []) {
105
+ const c = entry.content;
106
+
107
+ // Cursor entries
108
+ if (c?.entryType === 'TimelineTimelineCursor' || c?.__typename === 'TimelineTimelineCursor') {
109
+ if (c.cursorType === 'Bottom') nextCursor = c.value;
110
+ continue;
111
+ }
112
+ if (entry.entryId?.startsWith('cursor-bottom-')) {
113
+ nextCursor = c?.value || nextCursor;
114
+ continue;
115
+ }
116
+
117
+ // Single tweet entry
118
+ const tweetResult = c?.itemContent?.tweet_results?.result;
119
+ if (tweetResult) {
120
+ // Skip promoted content
121
+ if (c?.itemContent?.promotedMetadata) continue;
122
+ const tw = extractTweet(tweetResult, seen);
123
+ if (tw) tweets.push(tw);
124
+ continue;
125
+ }
126
+
127
+ // Conversation module (grouped tweets)
128
+ for (const item of c?.items || []) {
129
+ const nested = item.item?.itemContent?.tweet_results?.result;
130
+ if (nested) {
131
+ if (item.item?.itemContent?.promotedMetadata) continue;
132
+ const tw = extractTweet(nested, seen);
133
+ if (tw) tweets.push(tw);
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ return { tweets, nextCursor };
140
+ }
141
+
142
+ // ── CLI definition ────────────────────────────────────────────────────
143
+
3
144
  cli({
4
145
  site: 'twitter',
5
146
  name: 'timeline',
6
- description: 'Twitter Home Timeline',
147
+ description: 'Fetch Twitter Home Timeline',
7
148
  domain: 'x.com',
8
149
  strategy: Strategy.COOKIE,
150
+ browser: true,
9
151
  args: [
10
152
  { name: 'limit', type: 'int', default: 20 },
11
153
  ],
12
- columns: ['responseType', 'first'],
154
+ columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url'],
13
155
  func: async (page, kwargs) => {
14
- await page.goto('https://x.com/home');
15
- await page.wait(5);
16
- // Inject the fetch interceptor manually to see exactly what happens
17
- await page.evaluate(`
18
- () => {
19
- window.__intercept_data = [];
20
- const origFetch = window.fetch;
21
- window.fetch = async function(...args) {
22
- let u = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
23
- const res = await origFetch.apply(this, args);
24
- setTimeout(async () => {
25
- try {
26
- if (u.includes('HomeTimeline')) {
27
- const clone = res.clone();
28
- const j = await clone.json();
29
- window.__intercept_data.push(j);
30
- }
31
- } catch(e) {}
32
- }, 0);
33
- return res;
34
- };
156
+ const limit = kwargs.limit || 20;
157
+
158
+ // Navigate to x.com for cookie context
159
+ await page.goto('https://x.com');
160
+ await page.wait(3);
161
+
162
+ // Extract CSRF token
163
+ const ct0 = await page.evaluate(`() => {
164
+ return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
165
+ }`);
166
+ if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
167
+
168
+ // Dynamically resolve queryId
169
+ const queryId = await page.evaluate(`async () => {
170
+ try {
171
+ const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
172
+ if (ghResp.ok) {
173
+ const data = await ghResp.json();
174
+ const entry = data['HomeTimeline'];
175
+ if (entry && entry.queryId) return entry.queryId;
176
+ }
177
+ } catch {}
178
+ return null;
179
+ }`) || HOME_TIMELINE_QUERY_ID;
180
+
181
+ // Build auth headers
182
+ const headers = JSON.stringify({
183
+ 'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
184
+ 'X-Csrf-Token': ct0,
185
+ 'X-Twitter-Auth-Type': 'OAuth2Session',
186
+ 'X-Twitter-Active-User': 'yes',
187
+ });
188
+
189
+ // Paginate — fetch in browser, parse in TypeScript
190
+ const allTweets: TimelineTweet[] = [];
191
+ const seen = new Set<string>();
192
+ let cursor: string | null = null;
193
+
194
+ for (let i = 0; i < 5 && allTweets.length < limit; i++) {
195
+ const fetchCount = Math.min(40, limit - allTweets.length + 5); // over-fetch slightly for promoted filtering
196
+ const apiUrl = buildHomeTimelineUrl(fetchCount, cursor)
197
+ .replace(HOME_TIMELINE_QUERY_ID, queryId);
198
+
199
+ const data = await page.evaluate(`async () => {
200
+ const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
201
+ return r.ok ? await r.json() : { error: r.status };
202
+ }`);
203
+
204
+ if (data?.error) {
205
+ if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch timeline. queryId may have expired.`);
206
+ break;
35
207
  }
36
- `);
37
-
38
- // trigger scroll
39
- for(let i=0; i<3; i++) {
40
- await page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)');
41
- await page.wait(2);
208
+
209
+ const { tweets, nextCursor } = parseHomeTimeline(data, seen);
210
+ allTweets.push(...tweets);
211
+
212
+ if (!nextCursor || nextCursor === cursor) break;
213
+ cursor = nextCursor;
42
214
  }
43
-
44
- // extract
45
- const data = await page.evaluate('() => window.__intercept_data');
46
- if (!data || data.length === 0) return [{responseType: 'no data captured'}];
47
-
48
- return [{responseType: `captured ${data.length} responses`, first: JSON.stringify(data[0]).substring(0,300)}];
49
- }
215
+
216
+ return allTweets.slice(0, limit);
217
+ },
50
218
  });
@@ -25,9 +25,15 @@ pipeline:
25
25
  credentials: 'include',
26
26
  headers: { 'x-twitter-active-user': 'yes', 'x-csrf-token': csrfToken, 'authorization': 'Bearer ' + bearerToken }
27
27
  });
28
+ if (!res.ok) throw new Error('HTTP ' + res.status + '. Hint: trending endpoint may require login or API shape changed.');
28
29
  const data = await res.json();
29
- const trends = data?.timeline?.instructions?.[1]?.addEntries?.entries || [];
30
- return trends.filter(e => e.content?.timelineModule).flatMap(e => e.content.timelineModule.items || []).map(t => t?.item?.content?.trend).filter(Boolean);
30
+ const instructions = data?.timeline?.instructions || [];
31
+ const entries = instructions.flatMap(inst => inst?.addEntries?.entries || inst?.entries || []);
32
+ return entries
33
+ .filter(e => e.content?.timelineModule)
34
+ .flatMap(e => e.content.timelineModule.items || [])
35
+ .map(t => t?.item?.content?.trend)
36
+ .filter(Boolean);
31
37
  })()
32
38
 
33
39
  - map:
package/src/setup.ts CHANGED
@@ -184,11 +184,17 @@ export async function runSetup(opts: { cliVersion?: string; token?: string } = {
184
184
  if (result.ok) {
185
185
  console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
186
186
  } else {
187
+ console.log(` ${chalk.green('✓')} Token saved successfully.`);
187
188
  console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
188
- console.log(chalk.dim(' Make sure Chrome is running with the extension enabled.'));
189
+ console.log(chalk.dim(' Token configuration is complete. To use opencli, make sure Chrome'));
190
+ console.log(chalk.dim(' is running with the Playwright MCP Bridge extension enabled.'));
191
+ console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
189
192
  }
190
193
  } catch {
191
- console.log(` ${chalk.yellow('!')} Could not verify connectivity (Chrome may not be running)`);
194
+ console.log(` ${chalk.green('')} Token saved successfully.`);
195
+ console.log(` ${chalk.yellow('!')} Browser connectivity test skipped (Chrome may not be running).`);
196
+ console.log(chalk.dim(' Token configuration is complete. Start Chrome to begin using opencli.'));
197
+ console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
192
198
  }
193
199
  console.log();
194
200
  }
@@ -1,85 +0,0 @@
1
- site: twitter
2
- name: bookmarks
3
- description: 获取 Twitter 书签列表
4
- domain: x.com
5
- browser: true
6
-
7
- args:
8
- limit:
9
- type: int
10
- default: 20
11
- description: Number of bookmarks to return (default 20)
12
-
13
- pipeline:
14
- - navigate: https://x.com/i/bookmarks
15
- - wait: 2
16
- - evaluate: |
17
- (async () => {
18
- const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
19
- if (!ct0) throw new Error('No ct0 cookie. Hint: Not logged into x.com.');
20
- const bearer = decodeURIComponent('AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA');
21
- const _h = {'Authorization':'Bearer '+bearer, 'X-Csrf-Token':ct0, 'X-Twitter-Auth-Type':'OAuth2Session', 'X-Twitter-Active-User':'yes'};
22
-
23
- const count = Math.min(${{ args.limit }}, 100);
24
- const variables = JSON.stringify({count, includePromotedContent: false});
25
- const features = JSON.stringify({
26
- rweb_video_screen_enabled: false, profile_label_improvements_pcf_label_in_post_enabled: true,
27
- responsive_web_profile_redirect_enabled: false, rweb_tipjar_consumption_enabled: false,
28
- verified_phone_label_enabled: false, creator_subscriptions_tweet_preview_api_enabled: true,
29
- responsive_web_graphql_timeline_navigation_enabled: true,
30
- responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
31
- premium_content_api_read_enabled: false, communities_web_enable_tweet_community_results_fetch: true,
32
- c9s_tweet_anatomy_moderator_badge_enabled: true,
33
- articles_preview_enabled: true, responsive_web_edit_tweet_api_enabled: true,
34
- graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
35
- view_counts_everywhere_api_enabled: true, longform_notetweets_consumption_enabled: true,
36
- responsive_web_twitter_article_tweet_consumption_enabled: true,
37
- tweet_awards_web_tipping_enabled: false,
38
- content_disclosure_indicator_enabled: true, content_disclosure_ai_generated_indicator_enabled: true,
39
- freedom_of_speech_not_reach_fetch_enabled: true, standardized_nudges_misinfo: true,
40
- tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
41
- longform_notetweets_rich_text_read_enabled: true, longform_notetweets_inline_media_enabled: false,
42
- responsive_web_enhance_cards_enabled: false
43
- });
44
- const url = '/i/api/graphql/Fy0QMy4q_aZCpkO0PnyLYw/Bookmarks?variables=' + encodeURIComponent(variables) + '&features=' + encodeURIComponent(features);
45
- const resp = await fetch(url, {headers: _h, credentials: 'include'});
46
- if (!resp.ok) throw new Error('HTTP ' + resp.status + '. Hint: queryId may have changed.');
47
- const d = await resp.json();
48
-
49
- const instructions = d.data?.bookmark_timeline_v2?.timeline?.instructions || d.data?.bookmark_timeline?.timeline?.instructions || [];
50
- let tweets = [], seen = new Set();
51
- for (const inst of instructions) {
52
- for (const entry of (inst.entries || [])) {
53
- const r = entry.content?.itemContent?.tweet_results?.result;
54
- if (!r) continue;
55
- const tw = r.tweet || r;
56
- const l = tw.legacy || {};
57
- if (!tw.rest_id || seen.has(tw.rest_id)) continue;
58
- seen.add(tw.rest_id);
59
- const u = tw.core?.user_results?.result;
60
- const nt = tw.note_tweet?.note_tweet_results?.result?.text;
61
- const screenName = u?.legacy?.screen_name || u?.core?.screen_name;
62
- tweets.push({
63
- id: tw.rest_id,
64
- author: screenName,
65
- name: u?.legacy?.name || u?.core?.name,
66
- url: 'https://x.com/' + (screenName || '_') + '/status/' + tw.rest_id,
67
- text: nt || l.full_text || '',
68
- likes: l.favorite_count,
69
- retweets: l.retweet_count,
70
- created_at: l.created_at
71
- });
72
- }
73
- }
74
- return tweets;
75
- })()
76
-
77
- - map:
78
- author: ${{ item.author }}
79
- text: ${{ item.text }}
80
- likes: ${{ item.likes }}
81
- url: ${{ item.url }}
82
-
83
- - limit: ${{ args.limit }}
84
-
85
- columns: [author, text, likes, url]
@@ -1,85 +0,0 @@
1
- site: twitter
2
- name: bookmarks
3
- description: 获取 Twitter 书签列表
4
- domain: x.com
5
- browser: true
6
-
7
- args:
8
- limit:
9
- type: int
10
- default: 20
11
- description: Number of bookmarks to return (default 20)
12
-
13
- pipeline:
14
- - navigate: https://x.com/i/bookmarks
15
- - wait: 2
16
- - evaluate: |
17
- (async () => {
18
- const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
19
- if (!ct0) throw new Error('No ct0 cookie. Hint: Not logged into x.com.');
20
- const bearer = decodeURIComponent('AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA');
21
- const _h = {'Authorization':'Bearer '+bearer, 'X-Csrf-Token':ct0, 'X-Twitter-Auth-Type':'OAuth2Session', 'X-Twitter-Active-User':'yes'};
22
-
23
- const count = Math.min(${{ args.limit }}, 100);
24
- const variables = JSON.stringify({count, includePromotedContent: false});
25
- const features = JSON.stringify({
26
- rweb_video_screen_enabled: false, profile_label_improvements_pcf_label_in_post_enabled: true,
27
- responsive_web_profile_redirect_enabled: false, rweb_tipjar_consumption_enabled: false,
28
- verified_phone_label_enabled: false, creator_subscriptions_tweet_preview_api_enabled: true,
29
- responsive_web_graphql_timeline_navigation_enabled: true,
30
- responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
31
- premium_content_api_read_enabled: false, communities_web_enable_tweet_community_results_fetch: true,
32
- c9s_tweet_anatomy_moderator_badge_enabled: true,
33
- articles_preview_enabled: true, responsive_web_edit_tweet_api_enabled: true,
34
- graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
35
- view_counts_everywhere_api_enabled: true, longform_notetweets_consumption_enabled: true,
36
- responsive_web_twitter_article_tweet_consumption_enabled: true,
37
- tweet_awards_web_tipping_enabled: false,
38
- content_disclosure_indicator_enabled: true, content_disclosure_ai_generated_indicator_enabled: true,
39
- freedom_of_speech_not_reach_fetch_enabled: true, standardized_nudges_misinfo: true,
40
- tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
41
- longform_notetweets_rich_text_read_enabled: true, longform_notetweets_inline_media_enabled: false,
42
- responsive_web_enhance_cards_enabled: false
43
- });
44
- const url = '/i/api/graphql/Fy0QMy4q_aZCpkO0PnyLYw/Bookmarks?variables=' + encodeURIComponent(variables) + '&features=' + encodeURIComponent(features);
45
- const resp = await fetch(url, {headers: _h, credentials: 'include'});
46
- if (!resp.ok) throw new Error('HTTP ' + resp.status + '. Hint: queryId may have changed.');
47
- const d = await resp.json();
48
-
49
- const instructions = d.data?.bookmark_timeline_v2?.timeline?.instructions || d.data?.bookmark_timeline?.timeline?.instructions || [];
50
- let tweets = [], seen = new Set();
51
- for (const inst of instructions) {
52
- for (const entry of (inst.entries || [])) {
53
- const r = entry.content?.itemContent?.tweet_results?.result;
54
- if (!r) continue;
55
- const tw = r.tweet || r;
56
- const l = tw.legacy || {};
57
- if (!tw.rest_id || seen.has(tw.rest_id)) continue;
58
- seen.add(tw.rest_id);
59
- const u = tw.core?.user_results?.result;
60
- const nt = tw.note_tweet?.note_tweet_results?.result?.text;
61
- const screenName = u?.legacy?.screen_name || u?.core?.screen_name;
62
- tweets.push({
63
- id: tw.rest_id,
64
- author: screenName,
65
- name: u?.legacy?.name || u?.core?.name,
66
- url: 'https://x.com/' + (screenName || '_') + '/status/' + tw.rest_id,
67
- text: nt || l.full_text || '',
68
- likes: l.favorite_count,
69
- retweets: l.retweet_count,
70
- created_at: l.created_at
71
- });
72
- }
73
- }
74
- return tweets;
75
- })()
76
-
77
- - map:
78
- author: ${{ item.author }}
79
- text: ${{ item.text }}
80
- likes: ${{ item.likes }}
81
- url: ${{ item.url }}
82
-
83
- - limit: ${{ args.limit }}
84
-
85
- columns: [author, text, likes, url]