@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.
- package/dist/cli-manifest.json +17 -31
- package/dist/clis/twitter/bookmarks.d.ts +1 -0
- package/dist/clis/twitter/bookmarks.js +171 -0
- package/dist/clis/twitter/delete.js +0 -1
- package/dist/clis/twitter/followers.js +5 -16
- package/dist/clis/twitter/following.js +3 -4
- package/dist/clis/twitter/like.js +0 -1
- package/dist/clis/twitter/notifications.js +17 -7
- package/dist/clis/twitter/search.js +14 -6
- package/dist/clis/twitter/timeline.js +174 -35
- package/dist/clis/twitter/trending.yaml +8 -2
- package/dist/setup.js +8 -2
- package/package.json +1 -1
- package/src/clis/twitter/bookmarks.ts +201 -0
- package/src/clis/twitter/delete.ts +0 -1
- package/src/clis/twitter/followers.ts +5 -16
- package/src/clis/twitter/following.ts +3 -5
- package/src/clis/twitter/like.ts +0 -1
- package/src/clis/twitter/notifications.ts +18 -9
- package/src/clis/twitter/search.ts +14 -7
- package/src/clis/twitter/timeline.ts +204 -36
- package/src/clis/twitter/trending.yaml +8 -2
- package/src/setup.ts +8 -2
- package/dist/clis/twitter/bookmarks.yaml +0 -85
- package/src/clis/twitter/bookmarks.yaml +0 -85
|
@@ -1,47 +1,186 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
// ── Twitter GraphQL constants ──────────────────────────────────────────
|
|
3
|
+
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
4
|
+
const HOME_TIMELINE_QUERY_ID = 'c-CzHF1LboFilMpsx4ZCrQ';
|
|
5
|
+
const FEATURES = {
|
|
6
|
+
rweb_video_screen_enabled: false,
|
|
7
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
8
|
+
rweb_tipjar_consumption_enabled: true,
|
|
9
|
+
verified_phone_label_enabled: false,
|
|
10
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
11
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
12
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
13
|
+
premium_content_api_read_enabled: false,
|
|
14
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
15
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
16
|
+
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
|
|
17
|
+
responsive_web_grok_analyze_post_followups_enabled: true,
|
|
18
|
+
responsive_web_jetfuel_frame: false,
|
|
19
|
+
responsive_web_grok_share_attachment_enabled: true,
|
|
20
|
+
articles_preview_enabled: true,
|
|
21
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
22
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
23
|
+
view_counts_everywhere_api_enabled: true,
|
|
24
|
+
longform_notetweets_consumption_enabled: true,
|
|
25
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
26
|
+
tweet_awards_web_tipping_enabled: false,
|
|
27
|
+
responsive_web_grok_show_grok_translated_post: false,
|
|
28
|
+
responsive_web_grok_analysis_button_from_backend: false,
|
|
29
|
+
creator_subscriptions_quote_tweet_preview_enabled: false,
|
|
30
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
31
|
+
standardized_nudges_misinfo: true,
|
|
32
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
33
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
34
|
+
longform_notetweets_inline_media_enabled: true,
|
|
35
|
+
responsive_web_grok_image_annotation_enabled: true,
|
|
36
|
+
responsive_web_enhance_cards_enabled: false,
|
|
37
|
+
};
|
|
38
|
+
function buildHomeTimelineUrl(count, cursor) {
|
|
39
|
+
const vars = {
|
|
40
|
+
count,
|
|
41
|
+
includePromotedContent: false,
|
|
42
|
+
latestControlAvailable: true,
|
|
43
|
+
requestContext: 'launch',
|
|
44
|
+
withCommunity: true,
|
|
45
|
+
};
|
|
46
|
+
if (cursor)
|
|
47
|
+
vars.cursor = cursor;
|
|
48
|
+
return `/i/api/graphql/${HOME_TIMELINE_QUERY_ID}/HomeTimeline`
|
|
49
|
+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
|
50
|
+
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
|
51
|
+
}
|
|
52
|
+
function extractTweet(result, seen) {
|
|
53
|
+
if (!result)
|
|
54
|
+
return null;
|
|
55
|
+
const tw = result.tweet || result;
|
|
56
|
+
const l = tw.legacy || {};
|
|
57
|
+
if (!tw.rest_id || seen.has(tw.rest_id))
|
|
58
|
+
return null;
|
|
59
|
+
seen.add(tw.rest_id);
|
|
60
|
+
const u = tw.core?.user_results?.result;
|
|
61
|
+
const screenName = u?.legacy?.screen_name || u?.core?.screen_name || 'unknown';
|
|
62
|
+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
63
|
+
const views = tw.views?.count ? parseInt(tw.views.count, 10) : 0;
|
|
64
|
+
return {
|
|
65
|
+
id: tw.rest_id,
|
|
66
|
+
author: screenName,
|
|
67
|
+
text: noteText || l.full_text || '',
|
|
68
|
+
likes: l.favorite_count || 0,
|
|
69
|
+
retweets: l.retweet_count || 0,
|
|
70
|
+
replies: l.reply_count || 0,
|
|
71
|
+
views,
|
|
72
|
+
created_at: l.created_at || '',
|
|
73
|
+
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function parseHomeTimeline(data, seen) {
|
|
77
|
+
const tweets = [];
|
|
78
|
+
let nextCursor = null;
|
|
79
|
+
const instructions = data?.data?.home?.home_timeline_urt?.instructions || [];
|
|
80
|
+
for (const inst of instructions) {
|
|
81
|
+
for (const entry of inst.entries || []) {
|
|
82
|
+
const c = entry.content;
|
|
83
|
+
// Cursor entries
|
|
84
|
+
if (c?.entryType === 'TimelineTimelineCursor' || c?.__typename === 'TimelineTimelineCursor') {
|
|
85
|
+
if (c.cursorType === 'Bottom')
|
|
86
|
+
nextCursor = c.value;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (entry.entryId?.startsWith('cursor-bottom-')) {
|
|
90
|
+
nextCursor = c?.value || nextCursor;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
// Single tweet entry
|
|
94
|
+
const tweetResult = c?.itemContent?.tweet_results?.result;
|
|
95
|
+
if (tweetResult) {
|
|
96
|
+
// Skip promoted content
|
|
97
|
+
if (c?.itemContent?.promotedMetadata)
|
|
98
|
+
continue;
|
|
99
|
+
const tw = extractTweet(tweetResult, seen);
|
|
100
|
+
if (tw)
|
|
101
|
+
tweets.push(tw);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Conversation module (grouped tweets)
|
|
105
|
+
for (const item of c?.items || []) {
|
|
106
|
+
const nested = item.item?.itemContent?.tweet_results?.result;
|
|
107
|
+
if (nested) {
|
|
108
|
+
if (item.item?.itemContent?.promotedMetadata)
|
|
109
|
+
continue;
|
|
110
|
+
const tw = extractTweet(nested, seen);
|
|
111
|
+
if (tw)
|
|
112
|
+
tweets.push(tw);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { tweets, nextCursor };
|
|
118
|
+
}
|
|
119
|
+
// ── CLI definition ────────────────────────────────────────────────────
|
|
2
120
|
cli({
|
|
3
121
|
site: 'twitter',
|
|
4
122
|
name: 'timeline',
|
|
5
|
-
description: 'Twitter Home Timeline',
|
|
123
|
+
description: 'Fetch Twitter Home Timeline',
|
|
6
124
|
domain: 'x.com',
|
|
7
125
|
strategy: Strategy.COOKIE,
|
|
126
|
+
browser: true,
|
|
8
127
|
args: [
|
|
9
128
|
{ name: 'limit', type: 'int', default: 20 },
|
|
10
129
|
],
|
|
11
|
-
columns: ['
|
|
130
|
+
columns: ['id', 'author', 'text', 'likes', 'retweets', 'replies', 'views', 'created_at', 'url'],
|
|
12
131
|
func: async (page, kwargs) => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
await page.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}, 0);
|
|
32
|
-
return res;
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
`);
|
|
36
|
-
// trigger scroll
|
|
37
|
-
for (let i = 0; i < 3; i++) {
|
|
38
|
-
await page.evaluate('() => window.scrollTo(0, document.body.scrollHeight)');
|
|
39
|
-
await page.wait(2);
|
|
132
|
+
const limit = kwargs.limit || 20;
|
|
133
|
+
// Navigate to x.com for cookie context
|
|
134
|
+
await page.goto('https://x.com');
|
|
135
|
+
await page.wait(3);
|
|
136
|
+
// Extract CSRF token
|
|
137
|
+
const ct0 = await page.evaluate(`() => {
|
|
138
|
+
return document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1] || null;
|
|
139
|
+
}`);
|
|
140
|
+
if (!ct0)
|
|
141
|
+
throw new Error('Not logged into x.com (no ct0 cookie)');
|
|
142
|
+
// Dynamically resolve queryId
|
|
143
|
+
const queryId = await page.evaluate(`async () => {
|
|
144
|
+
try {
|
|
145
|
+
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
146
|
+
if (ghResp.ok) {
|
|
147
|
+
const data = await ghResp.json();
|
|
148
|
+
const entry = data['HomeTimeline'];
|
|
149
|
+
if (entry && entry.queryId) return entry.queryId;
|
|
40
150
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
151
|
+
} catch {}
|
|
152
|
+
return null;
|
|
153
|
+
}`) || HOME_TIMELINE_QUERY_ID;
|
|
154
|
+
// Build auth headers
|
|
155
|
+
const headers = JSON.stringify({
|
|
156
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
157
|
+
'X-Csrf-Token': ct0,
|
|
158
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
159
|
+
'X-Twitter-Active-User': 'yes',
|
|
160
|
+
});
|
|
161
|
+
// Paginate — fetch in browser, parse in TypeScript
|
|
162
|
+
const allTweets = [];
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
let cursor = null;
|
|
165
|
+
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
|
|
166
|
+
const fetchCount = Math.min(40, limit - allTweets.length + 5); // over-fetch slightly for promoted filtering
|
|
167
|
+
const apiUrl = buildHomeTimelineUrl(fetchCount, cursor)
|
|
168
|
+
.replace(HOME_TIMELINE_QUERY_ID, queryId);
|
|
169
|
+
const data = await page.evaluate(`async () => {
|
|
170
|
+
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
|
171
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
172
|
+
}`);
|
|
173
|
+
if (data?.error) {
|
|
174
|
+
if (allTweets.length === 0)
|
|
175
|
+
throw new Error(`HTTP ${data.error}: Failed to fetch timeline. queryId may have expired.`);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
const { tweets, nextCursor } = parseHomeTimeline(data, seen);
|
|
179
|
+
allTweets.push(...tweets);
|
|
180
|
+
if (!nextCursor || nextCursor === cursor)
|
|
181
|
+
break;
|
|
182
|
+
cursor = nextCursor;
|
|
183
|
+
}
|
|
184
|
+
return allTweets.slice(0, limit);
|
|
185
|
+
},
|
|
47
186
|
});
|
|
@@ -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
|
|
30
|
-
|
|
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/dist/setup.js
CHANGED
|
@@ -159,12 +159,18 @@ export async function runSetup(opts = {}) {
|
|
|
159
159
|
console.log(` ${chalk.green('✓')} Browser connected in ${(result.durationMs / 1000).toFixed(1)}s`);
|
|
160
160
|
}
|
|
161
161
|
else {
|
|
162
|
+
console.log(` ${chalk.green('✓')} Token saved successfully.`);
|
|
162
163
|
console.log(` ${chalk.yellow('!')} Browser connectivity test failed: ${result.error ?? 'unknown'}`);
|
|
163
|
-
console.log(chalk.dim('
|
|
164
|
+
console.log(chalk.dim(' Token configuration is complete. To use opencli, make sure Chrome'));
|
|
165
|
+
console.log(chalk.dim(' is running with the Playwright MCP Bridge extension enabled.'));
|
|
166
|
+
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
|
|
164
167
|
}
|
|
165
168
|
}
|
|
166
169
|
catch {
|
|
167
|
-
console.log(` ${chalk.
|
|
170
|
+
console.log(` ${chalk.green('✓')} Token saved successfully.`);
|
|
171
|
+
console.log(` ${chalk.yellow('!')} Browser connectivity test skipped (Chrome may not be running).`);
|
|
172
|
+
console.log(chalk.dim(' Token configuration is complete. Start Chrome to begin using opencli.'));
|
|
173
|
+
console.log(chalk.dim(` Run ${chalk.bold('opencli doctor --live')} to re-test connectivity.`));
|
|
168
174
|
}
|
|
169
175
|
console.log();
|
|
170
176
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
|
|
3
|
+
const BEARER_TOKEN = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
4
|
+
const BOOKMARKS_QUERY_ID = 'Fy0QMy4q_aZCpkO0PnyLYw';
|
|
5
|
+
|
|
6
|
+
const FEATURES = {
|
|
7
|
+
rweb_video_screen_enabled: false,
|
|
8
|
+
profile_label_improvements_pcf_label_in_post_enabled: true,
|
|
9
|
+
responsive_web_profile_redirect_enabled: false,
|
|
10
|
+
rweb_tipjar_consumption_enabled: false,
|
|
11
|
+
verified_phone_label_enabled: false,
|
|
12
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
13
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
14
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
15
|
+
premium_content_api_read_enabled: false,
|
|
16
|
+
communities_web_enable_tweet_community_results_fetch: true,
|
|
17
|
+
c9s_tweet_anatomy_moderator_badge_enabled: true,
|
|
18
|
+
articles_preview_enabled: true,
|
|
19
|
+
responsive_web_edit_tweet_api_enabled: true,
|
|
20
|
+
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
|
|
21
|
+
view_counts_everywhere_api_enabled: true,
|
|
22
|
+
longform_notetweets_consumption_enabled: true,
|
|
23
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
24
|
+
tweet_awards_web_tipping_enabled: false,
|
|
25
|
+
content_disclosure_indicator_enabled: true,
|
|
26
|
+
content_disclosure_ai_generated_indicator_enabled: true,
|
|
27
|
+
freedom_of_speech_not_reach_fetch_enabled: true,
|
|
28
|
+
standardized_nudges_misinfo: true,
|
|
29
|
+
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
|
|
30
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
31
|
+
longform_notetweets_inline_media_enabled: false,
|
|
32
|
+
responsive_web_enhance_cards_enabled: false,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
interface BookmarkTweet {
|
|
36
|
+
id: string;
|
|
37
|
+
author: string;
|
|
38
|
+
name: string;
|
|
39
|
+
text: string;
|
|
40
|
+
likes: number;
|
|
41
|
+
retweets: number;
|
|
42
|
+
created_at: string;
|
|
43
|
+
url: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildBookmarksUrl(count: number, cursor?: string | null): string {
|
|
47
|
+
const vars: Record<string, any> = {
|
|
48
|
+
count,
|
|
49
|
+
includePromotedContent: false,
|
|
50
|
+
};
|
|
51
|
+
if (cursor) vars.cursor = cursor;
|
|
52
|
+
|
|
53
|
+
return `/i/api/graphql/${BOOKMARKS_QUERY_ID}/Bookmarks`
|
|
54
|
+
+ `?variables=${encodeURIComponent(JSON.stringify(vars))}`
|
|
55
|
+
+ `&features=${encodeURIComponent(JSON.stringify(FEATURES))}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractBookmarkTweet(result: any, seen: Set<string>): BookmarkTweet | null {
|
|
59
|
+
if (!result) return null;
|
|
60
|
+
const tw = result.tweet || result;
|
|
61
|
+
const legacy = tw.legacy || {};
|
|
62
|
+
if (!tw.rest_id || seen.has(tw.rest_id)) return null;
|
|
63
|
+
seen.add(tw.rest_id);
|
|
64
|
+
|
|
65
|
+
const user = tw.core?.user_results?.result;
|
|
66
|
+
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
|
|
67
|
+
const displayName = user?.legacy?.name || user?.core?.name || '';
|
|
68
|
+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
id: tw.rest_id,
|
|
72
|
+
author: screenName,
|
|
73
|
+
name: displayName,
|
|
74
|
+
text: noteText || legacy.full_text || '',
|
|
75
|
+
likes: legacy.favorite_count || 0,
|
|
76
|
+
retweets: legacy.retweet_count || 0,
|
|
77
|
+
created_at: legacy.created_at || '',
|
|
78
|
+
url: `https://x.com/${screenName}/status/${tw.rest_id}`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseBookmarks(data: any, seen: Set<string>): { tweets: BookmarkTweet[]; nextCursor: string | null } {
|
|
83
|
+
const tweets: BookmarkTweet[] = [];
|
|
84
|
+
let nextCursor: string | null = null;
|
|
85
|
+
|
|
86
|
+
const instructions =
|
|
87
|
+
data?.data?.bookmark_timeline_v2?.timeline?.instructions
|
|
88
|
+
|| data?.data?.bookmark_timeline?.timeline?.instructions
|
|
89
|
+
|| [];
|
|
90
|
+
|
|
91
|
+
for (const inst of instructions) {
|
|
92
|
+
for (const entry of inst.entries || []) {
|
|
93
|
+
const content = entry.content;
|
|
94
|
+
|
|
95
|
+
if (content?.entryType === 'TimelineTimelineCursor' || content?.__typename === 'TimelineTimelineCursor') {
|
|
96
|
+
if (content.cursorType === 'Bottom' || content.cursorType === 'ShowMore') nextCursor = content.value;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (entry.entryId?.startsWith('cursor-bottom-') || entry.entryId?.startsWith('cursor-showMore-')) {
|
|
100
|
+
nextCursor = content?.value || content?.itemContent?.value || nextCursor;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const direct = extractBookmarkTweet(content?.itemContent?.tweet_results?.result, seen);
|
|
105
|
+
if (direct) {
|
|
106
|
+
tweets.push(direct);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const item of content?.items || []) {
|
|
111
|
+
const nested = extractBookmarkTweet(item.item?.itemContent?.tweet_results?.result, seen);
|
|
112
|
+
if (nested) tweets.push(nested);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { tweets, nextCursor };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
cli({
|
|
121
|
+
site: 'twitter',
|
|
122
|
+
name: 'bookmarks',
|
|
123
|
+
description: 'Fetch Twitter/X bookmarks',
|
|
124
|
+
domain: 'x.com',
|
|
125
|
+
strategy: Strategy.COOKIE,
|
|
126
|
+
browser: true,
|
|
127
|
+
args: [
|
|
128
|
+
{ name: 'limit', type: 'int', default: 20 },
|
|
129
|
+
],
|
|
130
|
+
columns: ['author', 'text', 'likes', 'url'],
|
|
131
|
+
func: async (page, kwargs) => {
|
|
132
|
+
const limit = kwargs.limit || 20;
|
|
133
|
+
|
|
134
|
+
await page.goto('https://x.com');
|
|
135
|
+
await page.wait(3);
|
|
136
|
+
|
|
137
|
+
const ct0 = await page.evaluate(`() => {
|
|
138
|
+
return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('ct0='))?.split('=')[1] || null;
|
|
139
|
+
}`);
|
|
140
|
+
if (!ct0) throw new Error('Not logged into x.com (no ct0 cookie)');
|
|
141
|
+
|
|
142
|
+
const queryId = await page.evaluate(`async () => {
|
|
143
|
+
try {
|
|
144
|
+
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
145
|
+
if (ghResp.ok) {
|
|
146
|
+
const data = await ghResp.json();
|
|
147
|
+
const entry = data['Bookmarks'];
|
|
148
|
+
if (entry && entry.queryId) return entry.queryId;
|
|
149
|
+
}
|
|
150
|
+
} catch {}
|
|
151
|
+
try {
|
|
152
|
+
const scripts = performance.getEntriesByType('resource')
|
|
153
|
+
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
154
|
+
.map(r => r.name);
|
|
155
|
+
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
156
|
+
try {
|
|
157
|
+
const text = await (await fetch(scriptUrl)).text();
|
|
158
|
+
const re = /queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"Bookmarks"/;
|
|
159
|
+
const m = text.match(re);
|
|
160
|
+
if (m) return m[1];
|
|
161
|
+
} catch {}
|
|
162
|
+
}
|
|
163
|
+
} catch {}
|
|
164
|
+
return null;
|
|
165
|
+
}`) || BOOKMARKS_QUERY_ID;
|
|
166
|
+
|
|
167
|
+
const headers = JSON.stringify({
|
|
168
|
+
'Authorization': `Bearer ${decodeURIComponent(BEARER_TOKEN)}`,
|
|
169
|
+
'X-Csrf-Token': ct0,
|
|
170
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
171
|
+
'X-Twitter-Active-User': 'yes',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const allTweets: BookmarkTweet[] = [];
|
|
175
|
+
const seen = new Set<string>();
|
|
176
|
+
let cursor: string | null = null;
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < 5 && allTweets.length < limit; i++) {
|
|
179
|
+
const fetchCount = Math.min(100, limit - allTweets.length + 10);
|
|
180
|
+
const apiUrl = buildBookmarksUrl(fetchCount, cursor).replace(BOOKMARKS_QUERY_ID, queryId);
|
|
181
|
+
|
|
182
|
+
const data = await page.evaluate(`async () => {
|
|
183
|
+
const r = await fetch("${apiUrl}", { headers: ${headers}, credentials: 'include' });
|
|
184
|
+
return r.ok ? await r.json() : { error: r.status };
|
|
185
|
+
}`);
|
|
186
|
+
|
|
187
|
+
if (data?.error) {
|
|
188
|
+
if (allTweets.length === 0) throw new Error(`HTTP ${data.error}: Failed to fetch bookmarks. queryId may have expired.`);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { tweets, nextCursor } = parseBookmarks(data, seen);
|
|
193
|
+
allTweets.push(...tweets);
|
|
194
|
+
|
|
195
|
+
if (!nextCursor || nextCursor === cursor) break;
|
|
196
|
+
cursor = nextCursor;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return allTweets.slice(0, limit);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
@@ -15,7 +15,6 @@ cli({
|
|
|
15
15
|
func: async (page: IPage | null, kwargs: any) => {
|
|
16
16
|
if (!page) throw new Error('Requires browser');
|
|
17
17
|
|
|
18
|
-
console.log(`Navigating to tweet: ${kwargs.url}`);
|
|
19
18
|
await page.goto(kwargs.url);
|
|
20
19
|
await page.wait(5); // Wait for tweet to load completely
|
|
21
20
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
-
import * as fs from 'fs';
|
|
3
2
|
|
|
4
3
|
cli({
|
|
5
4
|
site: 'twitter',
|
|
@@ -37,8 +36,8 @@ cli({
|
|
|
37
36
|
await page.goto(`https://x.com/${targetUser}`);
|
|
38
37
|
await page.wait(3);
|
|
39
38
|
|
|
40
|
-
// 2. Inject interceptor for
|
|
41
|
-
await page.installInterceptor('
|
|
39
|
+
// 2. Inject interceptor for the followers GraphQL API
|
|
40
|
+
await page.installInterceptor('Followers');
|
|
42
41
|
|
|
43
42
|
// 3. Click the followers link inside the profile page
|
|
44
43
|
await page.evaluate(`() => {
|
|
@@ -53,24 +52,14 @@ cli({
|
|
|
53
52
|
|
|
54
53
|
// 4. Retrieve data from opencli's registered interceptors
|
|
55
54
|
const allRequests = await page.getInterceptedRequests();
|
|
55
|
+
const requestList = Array.isArray(allRequests) ? allRequests : [];
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
if (!allRequests || allRequests.length === 0) {
|
|
59
|
-
console.log('No GraphQL requests captured by the interceptor backend.');
|
|
57
|
+
if (requestList.length === 0) {
|
|
60
58
|
return [];
|
|
61
59
|
}
|
|
62
|
-
|
|
63
|
-
console.log('Intercepted keys:', allRequests.map((r: any) => {
|
|
64
|
-
try {
|
|
65
|
-
const u = new URL(r.url); return u.pathname;
|
|
66
|
-
} catch (e) {
|
|
67
|
-
return r.url;
|
|
68
|
-
}
|
|
69
|
-
}));
|
|
70
60
|
|
|
71
|
-
const requests =
|
|
61
|
+
const requests = requestList.filter((r: any) => r?.url?.includes('Followers'));
|
|
72
62
|
if (!requests || requests.length === 0) {
|
|
73
|
-
console.log('No specific Followers requests captured. Check keys printed above.');
|
|
74
63
|
return [];
|
|
75
64
|
}
|
|
76
65
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
-
import * as fs from 'fs';
|
|
3
2
|
|
|
4
3
|
cli({
|
|
5
4
|
site: 'twitter',
|
|
@@ -53,15 +52,14 @@ cli({
|
|
|
53
52
|
|
|
54
53
|
// 4. Retrieve data from opencli's registered interceptors
|
|
55
54
|
const requests = await page.getInterceptedRequests();
|
|
55
|
+
const requestList = Array.isArray(requests) ? requests : [];
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
if (!requests || requests.length === 0) {
|
|
59
|
-
console.log('No Following requests captured by the interceptor backend.');
|
|
57
|
+
if (requestList.length === 0) {
|
|
60
58
|
return [];
|
|
61
59
|
}
|
|
62
60
|
|
|
63
61
|
let results: any[] = [];
|
|
64
|
-
for (const req of
|
|
62
|
+
for (const req of requestList) {
|
|
65
63
|
try {
|
|
66
64
|
let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
|
|
67
65
|
if (!instructions) continue;
|
package/src/clis/twitter/like.ts
CHANGED
|
@@ -15,7 +15,6 @@ cli({
|
|
|
15
15
|
func: async (page: IPage | null, kwargs: any) => {
|
|
16
16
|
if (!page) throw new Error('Requires browser');
|
|
17
17
|
|
|
18
|
-
console.log(`Navigating to tweet: ${kwargs.url}`);
|
|
19
18
|
await page.goto(kwargs.url);
|
|
20
19
|
await page.wait(5); // Wait for tweet to load completely
|
|
21
20
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { cli, Strategy } from '../../registry.js';
|
|
2
|
-
import * as fs from 'fs';
|
|
3
2
|
|
|
4
3
|
cli({
|
|
5
4
|
site: 'twitter',
|
|
@@ -13,13 +12,16 @@ cli({
|
|
|
13
12
|
],
|
|
14
13
|
columns: ['id', 'action', 'author', 'text', 'url'],
|
|
15
14
|
func: async (page, kwargs) => {
|
|
15
|
+
// Install the interceptor before loading the notifications page so we
|
|
16
|
+
// capture the initial timeline request triggered during page load.
|
|
17
|
+
await page.goto('https://x.com');
|
|
18
|
+
await page.wait(2);
|
|
19
|
+
await page.installInterceptor('NotificationsTimeline');
|
|
20
|
+
|
|
16
21
|
// 1. Navigate to notifications
|
|
17
22
|
await page.goto('https://x.com/notifications');
|
|
18
23
|
await page.wait(5);
|
|
19
24
|
|
|
20
|
-
// 2. Inject interceptor
|
|
21
|
-
await page.installInterceptor('NotificationsTimeline');
|
|
22
|
-
|
|
23
25
|
// 3. Trigger API by scrolling (if we need to load more)
|
|
24
26
|
await page.autoScroll({ times: 2, delayMs: 2000 });
|
|
25
27
|
|
|
@@ -28,6 +30,7 @@ cli({
|
|
|
28
30
|
if (!requests || requests.length === 0) return [];
|
|
29
31
|
|
|
30
32
|
let results: any[] = [];
|
|
33
|
+
const seen = new Set<string>();
|
|
31
34
|
for (const req of requests) {
|
|
32
35
|
try {
|
|
33
36
|
let instructions: any[] = [];
|
|
@@ -75,14 +78,16 @@ cli({
|
|
|
75
78
|
if (item.__typename === 'TimelineNotification') {
|
|
76
79
|
// Greet likes, retweet, mentions
|
|
77
80
|
text = item.rich_message?.text || item.message?.text || '';
|
|
78
|
-
|
|
81
|
+
const fromUser = item.template?.from_users?.[0]?.user_results?.result;
|
|
82
|
+
author = fromUser?.legacy?.screen_name || fromUser?.core?.screen_name || 'unknown';
|
|
79
83
|
urlStr = item.notification_url?.url || '';
|
|
80
84
|
actionText = item.notification_icon || 'Activity';
|
|
81
85
|
|
|
82
86
|
// If there's an attached tweet
|
|
83
87
|
const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
|
|
84
88
|
if (targetTweet) {
|
|
85
|
-
|
|
89
|
+
const targetText = targetTweet.note_tweet?.note_tweet_results?.result?.text || targetTweet.legacy?.full_text || '';
|
|
90
|
+
text += text && targetText ? ' | ' + targetText : targetText;
|
|
86
91
|
if (!urlStr) {
|
|
87
92
|
urlStr = `https://x.com/i/status/${targetTweet.rest_id}`;
|
|
88
93
|
}
|
|
@@ -91,18 +96,22 @@ cli({
|
|
|
91
96
|
// Direct mention/reply
|
|
92
97
|
const tweet = item.tweet_result?.result;
|
|
93
98
|
author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown';
|
|
94
|
-
text = tweet?.legacy?.full_text || item.message?.text || '';
|
|
99
|
+
text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || '';
|
|
95
100
|
actionText = 'Mention/Reply';
|
|
96
101
|
urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
|
|
97
102
|
} else if (item.__typename === 'Tweet') {
|
|
98
103
|
author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown';
|
|
99
|
-
text = item.legacy?.full_text || '';
|
|
104
|
+
text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || '';
|
|
100
105
|
actionText = 'Mention';
|
|
101
106
|
urlStr = `https://x.com/i/status/${item.rest_id}`;
|
|
102
107
|
}
|
|
103
108
|
|
|
109
|
+
const id = item.id || item.rest_id || entryId;
|
|
110
|
+
if (seen.has(id)) return;
|
|
111
|
+
seen.add(id);
|
|
112
|
+
|
|
104
113
|
results.push({
|
|
105
|
-
id
|
|
114
|
+
id,
|
|
106
115
|
action: actionText,
|
|
107
116
|
author: author,
|
|
108
117
|
text: text,
|