@jackwener/opencli 0.7.8 → 0.7.9

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.
@@ -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
  });
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
  }