@jackwener/opencli 0.6.2 → 0.7.0
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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/SKILL.md +7 -2
- package/dist/build-manifest.js +2 -0
- package/dist/cli-manifest.json +723 -104
- package/dist/clis/boss/detail.d.ts +1 -0
- package/dist/clis/boss/detail.js +104 -0
- package/dist/clis/boss/search.js +2 -1
- package/dist/clis/reddit/comment.d.ts +1 -0
- package/dist/clis/reddit/comment.js +57 -0
- package/dist/clis/reddit/popular.yaml +40 -0
- package/dist/clis/reddit/read.yaml +76 -0
- package/dist/clis/reddit/save.d.ts +1 -0
- package/dist/clis/reddit/save.js +51 -0
- package/dist/clis/reddit/saved.d.ts +1 -0
- package/dist/clis/reddit/saved.js +46 -0
- package/dist/clis/reddit/search.yaml +37 -11
- package/dist/clis/reddit/subreddit.yaml +14 -4
- package/dist/clis/reddit/subscribe.d.ts +1 -0
- package/dist/clis/reddit/subscribe.js +50 -0
- package/dist/clis/reddit/upvote.d.ts +1 -0
- package/dist/clis/reddit/upvote.js +64 -0
- package/dist/clis/reddit/upvoted.d.ts +1 -0
- package/dist/clis/reddit/upvoted.js +46 -0
- package/dist/clis/reddit/user-comments.yaml +45 -0
- package/dist/clis/reddit/user-posts.yaml +43 -0
- package/dist/clis/reddit/user.yaml +39 -0
- package/dist/clis/twitter/article.d.ts +1 -0
- package/dist/clis/twitter/article.js +157 -0
- package/dist/clis/twitter/bookmark.d.ts +1 -0
- package/dist/clis/twitter/bookmark.js +63 -0
- package/dist/clis/twitter/follow.d.ts +1 -0
- package/dist/clis/twitter/follow.js +65 -0
- package/dist/clis/twitter/profile.js +110 -42
- package/dist/clis/twitter/thread.d.ts +1 -0
- package/dist/clis/twitter/thread.js +150 -0
- package/dist/clis/twitter/unbookmark.d.ts +1 -0
- package/dist/clis/twitter/unbookmark.js +62 -0
- package/dist/clis/twitter/unfollow.d.ts +1 -0
- package/dist/clis/twitter/unfollow.js +71 -0
- package/dist/main.js +31 -8
- package/dist/registry.d.ts +1 -0
- package/package.json +1 -1
- package/src/build-manifest.ts +3 -0
- package/src/clis/boss/detail.ts +115 -0
- package/src/clis/boss/search.ts +2 -1
- package/src/clis/reddit/comment.ts +60 -0
- package/src/clis/reddit/popular.yaml +40 -0
- package/src/clis/reddit/read.yaml +76 -0
- package/src/clis/reddit/save.ts +54 -0
- package/src/clis/reddit/saved.ts +48 -0
- package/src/clis/reddit/search.yaml +37 -11
- package/src/clis/reddit/subreddit.yaml +14 -4
- package/src/clis/reddit/subscribe.ts +53 -0
- package/src/clis/reddit/upvote.ts +67 -0
- package/src/clis/reddit/upvoted.ts +48 -0
- package/src/clis/reddit/user-comments.yaml +45 -0
- package/src/clis/reddit/user-posts.yaml +43 -0
- package/src/clis/reddit/user.yaml +39 -0
- package/src/clis/twitter/article.ts +161 -0
- package/src/clis/twitter/bookmark.ts +67 -0
- package/src/clis/twitter/follow.ts +69 -0
- package/src/clis/twitter/profile.ts +113 -45
- package/src/clis/twitter/thread.ts +181 -0
- package/src/clis/twitter/unbookmark.ts +66 -0
- package/src/clis/twitter/unfollow.ts +75 -0
- package/src/main.ts +24 -5
- package/src/registry.ts +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'reddit',
|
|
4
|
+
name: 'upvoted',
|
|
5
|
+
description: 'Browse your upvoted Reddit posts',
|
|
6
|
+
domain: 'reddit.com',
|
|
7
|
+
strategy: Strategy.COOKIE,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'limit', type: 'int', default: 15 },
|
|
11
|
+
],
|
|
12
|
+
columns: ['title', 'subreddit', 'score', 'comments', 'url'],
|
|
13
|
+
func: async (page, kwargs) => {
|
|
14
|
+
if (!page)
|
|
15
|
+
throw new Error('Requires browser');
|
|
16
|
+
await page.goto('https://www.reddit.com');
|
|
17
|
+
await page.wait(3);
|
|
18
|
+
const result = await page.evaluate(`(async () => {
|
|
19
|
+
try {
|
|
20
|
+
// Get current username
|
|
21
|
+
const meRes = await fetch('/api/me.json?raw_json=1', { credentials: 'include' });
|
|
22
|
+
const me = await meRes.json();
|
|
23
|
+
const username = me?.name || me?.data?.name;
|
|
24
|
+
if (!username) return { error: 'Not logged in — cannot determine username' };
|
|
25
|
+
|
|
26
|
+
const limit = ${kwargs.limit};
|
|
27
|
+
const res = await fetch('/user/' + username + '/upvoted.json?limit=' + limit + '&raw_json=1', {
|
|
28
|
+
credentials: 'include'
|
|
29
|
+
});
|
|
30
|
+
const d = await res.json();
|
|
31
|
+
return (d?.data?.children || []).map(c => ({
|
|
32
|
+
title: c.data.title || '-',
|
|
33
|
+
subreddit: c.data.subreddit_name_prefixed || 'r/' + (c.data.subreddit || '?'),
|
|
34
|
+
score: c.data.score || 0,
|
|
35
|
+
comments: c.data.num_comments || 0,
|
|
36
|
+
url: 'https://www.reddit.com' + (c.data.permalink || ''),
|
|
37
|
+
}));
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return { error: e.toString() };
|
|
40
|
+
}
|
|
41
|
+
})()`);
|
|
42
|
+
if (result?.error)
|
|
43
|
+
throw new Error(result.error);
|
|
44
|
+
return (result || []).slice(0, kwargs.limit);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
site: reddit
|
|
2
|
+
name: user-comments
|
|
3
|
+
description: View a Reddit user's comment history
|
|
4
|
+
domain: reddit.com
|
|
5
|
+
strategy: cookie
|
|
6
|
+
browser: true
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
username:
|
|
10
|
+
type: string
|
|
11
|
+
required: true
|
|
12
|
+
limit:
|
|
13
|
+
type: int
|
|
14
|
+
default: 15
|
|
15
|
+
|
|
16
|
+
columns: [subreddit, score, body, url]
|
|
17
|
+
|
|
18
|
+
pipeline:
|
|
19
|
+
- navigate: https://www.reddit.com
|
|
20
|
+
- evaluate: |
|
|
21
|
+
(async () => {
|
|
22
|
+
const username = ${{ args.username | json }};
|
|
23
|
+
const name = username.startsWith('u/') ? username.slice(2) : username;
|
|
24
|
+
const limit = ${{ args.limit }};
|
|
25
|
+
const res = await fetch('/user/' + name + '/comments.json?limit=' + limit + '&raw_json=1', {
|
|
26
|
+
credentials: 'include'
|
|
27
|
+
});
|
|
28
|
+
const d = await res.json();
|
|
29
|
+
return (d?.data?.children || []).map(c => {
|
|
30
|
+
let body = c.data.body || '';
|
|
31
|
+
if (body.length > 300) body = body.slice(0, 300) + '...';
|
|
32
|
+
return {
|
|
33
|
+
subreddit: c.data.subreddit_name_prefixed,
|
|
34
|
+
score: c.data.score,
|
|
35
|
+
body: body,
|
|
36
|
+
url: 'https://www.reddit.com' + c.data.permalink,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
})()
|
|
40
|
+
- map:
|
|
41
|
+
subreddit: ${{ item.subreddit }}
|
|
42
|
+
score: ${{ item.score }}
|
|
43
|
+
body: ${{ item.body }}
|
|
44
|
+
url: ${{ item.url }}
|
|
45
|
+
- limit: ${{ args.limit }}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
site: reddit
|
|
2
|
+
name: user-posts
|
|
3
|
+
description: View a Reddit user's submitted posts
|
|
4
|
+
domain: reddit.com
|
|
5
|
+
strategy: cookie
|
|
6
|
+
browser: true
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
username:
|
|
10
|
+
type: string
|
|
11
|
+
required: true
|
|
12
|
+
limit:
|
|
13
|
+
type: int
|
|
14
|
+
default: 15
|
|
15
|
+
|
|
16
|
+
columns: [title, subreddit, score, comments, url]
|
|
17
|
+
|
|
18
|
+
pipeline:
|
|
19
|
+
- navigate: https://www.reddit.com
|
|
20
|
+
- evaluate: |
|
|
21
|
+
(async () => {
|
|
22
|
+
const username = ${{ args.username | json }};
|
|
23
|
+
const name = username.startsWith('u/') ? username.slice(2) : username;
|
|
24
|
+
const limit = ${{ args.limit }};
|
|
25
|
+
const res = await fetch('/user/' + name + '/submitted.json?limit=' + limit + '&raw_json=1', {
|
|
26
|
+
credentials: 'include'
|
|
27
|
+
});
|
|
28
|
+
const d = await res.json();
|
|
29
|
+
return (d?.data?.children || []).map(c => ({
|
|
30
|
+
title: c.data.title,
|
|
31
|
+
subreddit: c.data.subreddit_name_prefixed,
|
|
32
|
+
score: c.data.score,
|
|
33
|
+
comments: c.data.num_comments,
|
|
34
|
+
url: 'https://www.reddit.com' + c.data.permalink,
|
|
35
|
+
}));
|
|
36
|
+
})()
|
|
37
|
+
- map:
|
|
38
|
+
title: ${{ item.title }}
|
|
39
|
+
subreddit: ${{ item.subreddit }}
|
|
40
|
+
score: ${{ item.score }}
|
|
41
|
+
comments: ${{ item.comments }}
|
|
42
|
+
url: ${{ item.url }}
|
|
43
|
+
- limit: ${{ args.limit }}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
site: reddit
|
|
2
|
+
name: user
|
|
3
|
+
description: View a Reddit user profile
|
|
4
|
+
domain: reddit.com
|
|
5
|
+
strategy: cookie
|
|
6
|
+
browser: true
|
|
7
|
+
|
|
8
|
+
args:
|
|
9
|
+
username:
|
|
10
|
+
type: string
|
|
11
|
+
required: true
|
|
12
|
+
|
|
13
|
+
columns: [field, value]
|
|
14
|
+
|
|
15
|
+
pipeline:
|
|
16
|
+
- navigate: https://www.reddit.com
|
|
17
|
+
- evaluate: |
|
|
18
|
+
(async () => {
|
|
19
|
+
const username = ${{ args.username | json }};
|
|
20
|
+
const name = username.startsWith('u/') ? username.slice(2) : username;
|
|
21
|
+
const res = await fetch('/user/' + name + '/about.json?raw_json=1', {
|
|
22
|
+
credentials: 'include'
|
|
23
|
+
});
|
|
24
|
+
const d = await res.json();
|
|
25
|
+
const u = d?.data || d || {};
|
|
26
|
+
const created = u.created_utc ? new Date(u.created_utc * 1000).toISOString().split('T')[0] : '-';
|
|
27
|
+
return [
|
|
28
|
+
{ field: 'Username', value: 'u/' + (u.name || name) },
|
|
29
|
+
{ field: 'Post Karma', value: String(u.link_karma || 0) },
|
|
30
|
+
{ field: 'Comment Karma', value: String(u.comment_karma || 0) },
|
|
31
|
+
{ field: 'Total Karma', value: String(u.total_karma || (u.link_karma||0) + (u.comment_karma||0)) },
|
|
32
|
+
{ field: 'Account Created', value: created },
|
|
33
|
+
{ field: 'Gold', value: u.is_gold ? '⭐ Yes' : 'No' },
|
|
34
|
+
{ field: 'Verified', value: u.verified ? '✅ Yes' : 'No' },
|
|
35
|
+
];
|
|
36
|
+
})()
|
|
37
|
+
- map:
|
|
38
|
+
field: ${{ item.field }}
|
|
39
|
+
value: ${{ item.value }}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'twitter',
|
|
4
|
+
name: 'article',
|
|
5
|
+
description: 'Fetch a Twitter Article (long-form content) and export as Markdown',
|
|
6
|
+
domain: 'x.com',
|
|
7
|
+
strategy: Strategy.COOKIE,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'tweet_id', type: 'string', positional: true, required: true, help: 'Tweet ID or URL containing the article' },
|
|
11
|
+
],
|
|
12
|
+
columns: ['title', 'author', 'content', 'url'],
|
|
13
|
+
func: async (page, kwargs) => {
|
|
14
|
+
// Extract tweet ID from URL if needed
|
|
15
|
+
let tweetId = kwargs.tweet_id;
|
|
16
|
+
const urlMatch = tweetId.match(/\/(?:status|article)\/(\d+)/);
|
|
17
|
+
if (urlMatch)
|
|
18
|
+
tweetId = urlMatch[1];
|
|
19
|
+
// Navigate to the tweet page for cookie context
|
|
20
|
+
await page.goto(`https://x.com/i/status/${tweetId}`);
|
|
21
|
+
await page.wait(3);
|
|
22
|
+
const result = await page.evaluate(`
|
|
23
|
+
async () => {
|
|
24
|
+
const tweetId = "${tweetId}";
|
|
25
|
+
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
|
|
26
|
+
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
|
27
|
+
|
|
28
|
+
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
29
|
+
const headers = {
|
|
30
|
+
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
|
31
|
+
'X-Csrf-Token': ct0,
|
|
32
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
33
|
+
'X-Twitter-Active-User': 'yes'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const variables = JSON.stringify({
|
|
37
|
+
tweetId: tweetId,
|
|
38
|
+
withCommunity: false,
|
|
39
|
+
includePromotedContent: false,
|
|
40
|
+
withVoice: false,
|
|
41
|
+
});
|
|
42
|
+
const features = JSON.stringify({
|
|
43
|
+
longform_notetweets_consumption_enabled: true,
|
|
44
|
+
responsive_web_twitter_article_tweet_consumption_enabled: true,
|
|
45
|
+
longform_notetweets_rich_text_read_enabled: true,
|
|
46
|
+
longform_notetweets_inline_media_enabled: true,
|
|
47
|
+
articles_preview_enabled: true,
|
|
48
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
49
|
+
verified_phone_label_enabled: false,
|
|
50
|
+
});
|
|
51
|
+
const fieldToggles = JSON.stringify({
|
|
52
|
+
withArticleRichContentState: true,
|
|
53
|
+
withArticlePlainText: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Dynamically resolve queryId: GitHub community source → JS bundle scan → hardcoded fallback
|
|
57
|
+
async function resolveQueryId(operationName, fallbackId) {
|
|
58
|
+
try {
|
|
59
|
+
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
60
|
+
if (ghResp.ok) {
|
|
61
|
+
const data = await ghResp.json();
|
|
62
|
+
const entry = data[operationName];
|
|
63
|
+
if (entry && entry.queryId) return entry.queryId;
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
try {
|
|
67
|
+
const scripts = performance.getEntriesByType('resource')
|
|
68
|
+
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
69
|
+
.map(r => r.name);
|
|
70
|
+
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
71
|
+
try {
|
|
72
|
+
const text = await (await fetch(scriptUrl)).text();
|
|
73
|
+
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
|
74
|
+
const m = text.match(re);
|
|
75
|
+
if (m) return m[1];
|
|
76
|
+
} catch {}
|
|
77
|
+
}
|
|
78
|
+
} catch {}
|
|
79
|
+
return fallbackId;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const queryId = await resolveQueryId('TweetResultByRestId', '7xflPyRiUxGVbJd4uWmbfg');
|
|
83
|
+
const url = '/i/api/graphql/' + queryId + '/TweetResultByRestId?variables='
|
|
84
|
+
+ encodeURIComponent(variables)
|
|
85
|
+
+ '&features=' + encodeURIComponent(features)
|
|
86
|
+
+ '&fieldToggles=' + encodeURIComponent(fieldToggles);
|
|
87
|
+
|
|
88
|
+
const resp = await fetch(url, {headers, credentials: 'include'});
|
|
89
|
+
if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'Tweet may not exist or queryId expired'};
|
|
90
|
+
const d = await resp.json();
|
|
91
|
+
|
|
92
|
+
const result = d.data?.tweetResult?.result;
|
|
93
|
+
if (!result) return {error: 'Article not found'};
|
|
94
|
+
|
|
95
|
+
// Unwrap TweetWithVisibilityResults
|
|
96
|
+
const tw = result.tweet || result;
|
|
97
|
+
const legacy = tw.legacy || {};
|
|
98
|
+
const user = tw.core?.user_results?.result;
|
|
99
|
+
const screenName = user?.legacy?.screen_name || user?.core?.screen_name || 'unknown';
|
|
100
|
+
|
|
101
|
+
// Extract article content
|
|
102
|
+
const articleResults = tw.article?.article_results?.result;
|
|
103
|
+
if (!articleResults) {
|
|
104
|
+
// Fallback: return note_tweet text if present
|
|
105
|
+
const noteText = tw.note_tweet?.note_tweet_results?.result?.text;
|
|
106
|
+
if (noteText) {
|
|
107
|
+
return [{
|
|
108
|
+
title: '(Note Tweet)',
|
|
109
|
+
author: screenName,
|
|
110
|
+
content: noteText,
|
|
111
|
+
url: 'https://x.com/' + screenName + '/status/' + tweetId,
|
|
112
|
+
}];
|
|
113
|
+
}
|
|
114
|
+
return {error: 'Tweet ' + tweetId + ' has no article content'};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const title = articleResults.title || '(Untitled)';
|
|
118
|
+
const contentState = articleResults.content_state || {};
|
|
119
|
+
const blocks = contentState.blocks || [];
|
|
120
|
+
|
|
121
|
+
// Convert draft.js blocks to Markdown
|
|
122
|
+
const parts = [];
|
|
123
|
+
let orderedCounter = 0;
|
|
124
|
+
for (const block of blocks) {
|
|
125
|
+
const blockType = block.type || 'unstyled';
|
|
126
|
+
if (blockType === 'atomic') continue;
|
|
127
|
+
const text = block.text || '';
|
|
128
|
+
if (!text) continue;
|
|
129
|
+
if (blockType !== 'ordered-list-item') orderedCounter = 0;
|
|
130
|
+
|
|
131
|
+
if (blockType === 'header-one') parts.push('# ' + text);
|
|
132
|
+
else if (blockType === 'header-two') parts.push('## ' + text);
|
|
133
|
+
else if (blockType === 'header-three') parts.push('### ' + text);
|
|
134
|
+
else if (blockType === 'blockquote') parts.push('> ' + text);
|
|
135
|
+
else if (blockType === 'unordered-list-item') parts.push('- ' + text);
|
|
136
|
+
else if (blockType === 'ordered-list-item') {
|
|
137
|
+
orderedCounter++;
|
|
138
|
+
parts.push(orderedCounter + '. ' + text);
|
|
139
|
+
}
|
|
140
|
+
else if (blockType === 'code-block') parts.push('\`\`\`\\n' + text + '\\n\`\`\`');
|
|
141
|
+
else parts.push(text);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return [{
|
|
145
|
+
title,
|
|
146
|
+
author: screenName,
|
|
147
|
+
content: parts.join('\\n\\n') || legacy.full_text || '',
|
|
148
|
+
url: 'https://x.com/' + screenName + '/status/' + tweetId,
|
|
149
|
+
}];
|
|
150
|
+
}
|
|
151
|
+
`);
|
|
152
|
+
if (result?.error) {
|
|
153
|
+
throw new Error(result.error + (result.hint ? ` (${result.hint})` : ''));
|
|
154
|
+
}
|
|
155
|
+
return result || [];
|
|
156
|
+
}
|
|
157
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'twitter',
|
|
4
|
+
name: 'bookmark',
|
|
5
|
+
description: 'Bookmark a tweet',
|
|
6
|
+
domain: 'x.com',
|
|
7
|
+
strategy: Strategy.UI,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'url', type: 'string', positional: true, required: true, help: 'Tweet URL to bookmark' },
|
|
11
|
+
],
|
|
12
|
+
columns: ['status', 'message'],
|
|
13
|
+
func: async (page, kwargs) => {
|
|
14
|
+
if (!page)
|
|
15
|
+
throw new Error('Requires browser');
|
|
16
|
+
await page.goto(kwargs.url);
|
|
17
|
+
await page.wait(5);
|
|
18
|
+
const result = await page.evaluate(`(async () => {
|
|
19
|
+
try {
|
|
20
|
+
let attempts = 0;
|
|
21
|
+
let bookmarkBtn = null;
|
|
22
|
+
let removeBtn = null;
|
|
23
|
+
|
|
24
|
+
while (attempts < 20) {
|
|
25
|
+
// Check if already bookmarked
|
|
26
|
+
removeBtn = document.querySelector('[data-testid="removeBookmark"]');
|
|
27
|
+
if (removeBtn) {
|
|
28
|
+
return { ok: true, message: 'Tweet is already bookmarked.' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
bookmarkBtn = document.querySelector('[data-testid="bookmark"]');
|
|
32
|
+
if (bookmarkBtn) break;
|
|
33
|
+
|
|
34
|
+
await new Promise(r => setTimeout(r, 500));
|
|
35
|
+
attempts++;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!bookmarkBtn) {
|
|
39
|
+
return { ok: false, message: 'Could not find Bookmark button. Are you logged in?' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
bookmarkBtn.click();
|
|
43
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
44
|
+
|
|
45
|
+
// Verify
|
|
46
|
+
const verify = document.querySelector('[data-testid="removeBookmark"]');
|
|
47
|
+
if (verify) {
|
|
48
|
+
return { ok: true, message: 'Tweet successfully bookmarked.' };
|
|
49
|
+
} else {
|
|
50
|
+
return { ok: false, message: 'Bookmark action initiated but UI did not update.' };
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return { ok: false, message: e.toString() };
|
|
54
|
+
}
|
|
55
|
+
})()`);
|
|
56
|
+
if (result.ok)
|
|
57
|
+
await page.wait(2);
|
|
58
|
+
return [{
|
|
59
|
+
status: result.ok ? 'success' : 'failed',
|
|
60
|
+
message: result.message
|
|
61
|
+
}];
|
|
62
|
+
}
|
|
63
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
cli({
|
|
3
|
+
site: 'twitter',
|
|
4
|
+
name: 'follow',
|
|
5
|
+
description: 'Follow a Twitter user',
|
|
6
|
+
domain: 'x.com',
|
|
7
|
+
strategy: Strategy.UI,
|
|
8
|
+
browser: true,
|
|
9
|
+
args: [
|
|
10
|
+
{ name: 'username', type: 'string', positional: true, required: true, help: 'Twitter screen name (without @)' },
|
|
11
|
+
],
|
|
12
|
+
columns: ['status', 'message'],
|
|
13
|
+
func: async (page, kwargs) => {
|
|
14
|
+
if (!page)
|
|
15
|
+
throw new Error('Requires browser');
|
|
16
|
+
const username = kwargs.username.replace(/^@/, '');
|
|
17
|
+
await page.goto(`https://x.com/${username}`);
|
|
18
|
+
await page.wait(5);
|
|
19
|
+
const result = await page.evaluate(`(async () => {
|
|
20
|
+
try {
|
|
21
|
+
let attempts = 0;
|
|
22
|
+
let followBtn = null;
|
|
23
|
+
let unfollowTestId = null;
|
|
24
|
+
|
|
25
|
+
while (attempts < 20) {
|
|
26
|
+
// Check if already following (button shows screen_name-unfollow)
|
|
27
|
+
unfollowTestId = document.querySelector('[data-testid$="-unfollow"]');
|
|
28
|
+
if (unfollowTestId) {
|
|
29
|
+
return { ok: true, message: 'Already following @${username}.' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Look for the Follow button
|
|
33
|
+
followBtn = document.querySelector('[data-testid$="-follow"]');
|
|
34
|
+
if (followBtn) break;
|
|
35
|
+
|
|
36
|
+
await new Promise(r => setTimeout(r, 500));
|
|
37
|
+
attempts++;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!followBtn) {
|
|
41
|
+
return { ok: false, message: 'Could not find Follow button. Are you logged in?' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
followBtn.click();
|
|
45
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
46
|
+
|
|
47
|
+
// Verify
|
|
48
|
+
const verify = document.querySelector('[data-testid$="-unfollow"]');
|
|
49
|
+
if (verify) {
|
|
50
|
+
return { ok: true, message: 'Successfully followed @${username}.' };
|
|
51
|
+
} else {
|
|
52
|
+
return { ok: false, message: 'Follow action initiated but UI did not update.' };
|
|
53
|
+
}
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return { ok: false, message: e.toString() };
|
|
56
|
+
}
|
|
57
|
+
})()`);
|
|
58
|
+
if (result.ok)
|
|
59
|
+
await page.wait(2);
|
|
60
|
+
return [{
|
|
61
|
+
status: result.ok ? 'success' : 'failed',
|
|
62
|
+
message: result.message
|
|
63
|
+
}];
|
|
64
|
+
}
|
|
65
|
+
});
|
|
@@ -2,55 +2,123 @@ import { cli, Strategy } from '../../registry.js';
|
|
|
2
2
|
cli({
|
|
3
3
|
site: 'twitter',
|
|
4
4
|
name: 'profile',
|
|
5
|
-
description: 'Fetch
|
|
5
|
+
description: 'Fetch a Twitter user profile (bio, stats, etc.)',
|
|
6
6
|
domain: 'x.com',
|
|
7
|
-
strategy: Strategy.
|
|
7
|
+
strategy: Strategy.COOKIE,
|
|
8
8
|
browser: true,
|
|
9
9
|
args: [
|
|
10
|
-
{ name: 'username', type: 'string',
|
|
11
|
-
{ name: 'limit', type: 'int', default: 15 },
|
|
10
|
+
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (without @). Defaults to logged-in user.' },
|
|
12
11
|
],
|
|
13
|
-
columns: ['
|
|
12
|
+
columns: ['screen_name', 'name', 'bio', 'location', 'url', 'followers', 'following', 'tweets', 'likes', 'verified', 'created_at'],
|
|
14
13
|
func: async (page, kwargs) => {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
14
|
+
let username = (kwargs.username || '').replace(/^@/, '');
|
|
15
|
+
// If no username, detect the logged-in user
|
|
16
|
+
if (!username) {
|
|
17
|
+
await page.goto('https://x.com/home');
|
|
18
|
+
await page.wait(5);
|
|
19
|
+
const href = await page.evaluate(`() => {
|
|
20
|
+
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
21
|
+
return link ? link.getAttribute('href') : null;
|
|
22
|
+
}`);
|
|
23
|
+
if (!href)
|
|
24
|
+
throw new Error('Could not detect logged-in user. Are you logged in?');
|
|
25
|
+
username = href.replace('/', '');
|
|
26
|
+
}
|
|
27
|
+
// Navigate directly to the user's profile page (gives us cookie context)
|
|
28
|
+
await page.goto(`https://x.com/${username}`);
|
|
29
|
+
await page.wait(3);
|
|
30
|
+
const result = await page.evaluate(`
|
|
31
|
+
async () => {
|
|
32
|
+
const screenName = "${username}";
|
|
33
|
+
const ct0 = document.cookie.split(';').map(c=>c.trim()).find(c=>c.startsWith('ct0='))?.split('=')[1];
|
|
34
|
+
if (!ct0) return {error: 'No ct0 cookie — not logged into x.com'};
|
|
35
|
+
|
|
36
|
+
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
|
|
37
|
+
const headers = {
|
|
38
|
+
'Authorization': 'Bearer ' + decodeURIComponent(bearer),
|
|
39
|
+
'X-Csrf-Token': ct0,
|
|
40
|
+
'X-Twitter-Auth-Type': 'OAuth2Session',
|
|
41
|
+
'X-Twitter-Active-User': 'yes'
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const variables = JSON.stringify({
|
|
45
|
+
screen_name: screenName,
|
|
46
|
+
withSafetyModeUserFields: true,
|
|
47
|
+
});
|
|
48
|
+
const features = JSON.stringify({
|
|
49
|
+
hidden_profile_subscriptions_enabled: true,
|
|
50
|
+
rweb_tipjar_consumption_enabled: true,
|
|
51
|
+
responsive_web_graphql_exclude_directive_enabled: true,
|
|
52
|
+
verified_phone_label_enabled: false,
|
|
53
|
+
subscriptions_verification_info_is_identity_verified_enabled: true,
|
|
54
|
+
subscriptions_verification_info_verified_since_enabled: true,
|
|
55
|
+
highlights_tweets_tab_ui_enabled: true,
|
|
56
|
+
responsive_web_twitter_article_notes_tab_enabled: true,
|
|
57
|
+
subscriptions_feature_can_gift_premium: true,
|
|
58
|
+
creator_subscriptions_tweet_preview_api_enabled: true,
|
|
59
|
+
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
|
|
60
|
+
responsive_web_graphql_timeline_navigation_enabled: true,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Dynamically resolve queryId: GitHub community source → JS bundle scan → hardcoded fallback
|
|
64
|
+
async function resolveQueryId(operationName, fallbackId) {
|
|
65
|
+
try {
|
|
66
|
+
const ghResp = await fetch('https://raw.githubusercontent.com/fa0311/twitter-openapi/refs/heads/main/src/config/placeholder.json');
|
|
67
|
+
if (ghResp.ok) {
|
|
68
|
+
const data = await ghResp.json();
|
|
69
|
+
const entry = data[operationName];
|
|
70
|
+
if (entry && entry.queryId) return entry.queryId;
|
|
50
71
|
}
|
|
51
|
-
|
|
72
|
+
} catch {}
|
|
73
|
+
try {
|
|
74
|
+
const scripts = performance.getEntriesByType('resource')
|
|
75
|
+
.filter(r => r.name.includes('client-web') && r.name.endsWith('.js'))
|
|
76
|
+
.map(r => r.name);
|
|
77
|
+
for (const scriptUrl of scripts.slice(0, 15)) {
|
|
78
|
+
try {
|
|
79
|
+
const text = await (await fetch(scriptUrl)).text();
|
|
80
|
+
const re = new RegExp('queryId:"([A-Za-z0-9_-]+)"[^}]{0,200}operationName:"' + operationName + '"');
|
|
81
|
+
const m = text.match(re);
|
|
82
|
+
if (m) return m[1];
|
|
83
|
+
} catch {}
|
|
52
84
|
}
|
|
85
|
+
} catch {}
|
|
86
|
+
return fallbackId;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const queryId = await resolveQueryId('UserByScreenName', 'qRednkZG-rn1P6b48NINmQ');
|
|
90
|
+
const url = '/i/api/graphql/' + queryId + '/UserByScreenName?variables='
|
|
91
|
+
+ encodeURIComponent(variables)
|
|
92
|
+
+ '&features=' + encodeURIComponent(features);
|
|
93
|
+
|
|
94
|
+
const resp = await fetch(url, {headers, credentials: 'include'});
|
|
95
|
+
if (!resp.ok) return {error: 'HTTP ' + resp.status, hint: 'User may not exist or queryId expired'};
|
|
96
|
+
const d = await resp.json();
|
|
97
|
+
|
|
98
|
+
const result = d.data?.user?.result;
|
|
99
|
+
if (!result) return {error: 'User @' + screenName + ' not found'};
|
|
100
|
+
|
|
101
|
+
const legacy = result.legacy || {};
|
|
102
|
+
const expandedUrl = legacy.entities?.url?.urls?.[0]?.expanded_url || '';
|
|
103
|
+
|
|
104
|
+
return [{
|
|
105
|
+
screen_name: legacy.screen_name || screenName,
|
|
106
|
+
name: legacy.name || '',
|
|
107
|
+
bio: legacy.description || '',
|
|
108
|
+
location: legacy.location || '',
|
|
109
|
+
url: expandedUrl,
|
|
110
|
+
followers: legacy.followers_count || 0,
|
|
111
|
+
following: legacy.friends_count || 0,
|
|
112
|
+
tweets: legacy.statuses_count || 0,
|
|
113
|
+
likes: legacy.favourites_count || 0,
|
|
114
|
+
verified: result.is_blue_verified || legacy.verified || false,
|
|
115
|
+
created_at: legacy.created_at || '',
|
|
116
|
+
}];
|
|
117
|
+
}
|
|
118
|
+
`);
|
|
119
|
+
if (result?.error) {
|
|
120
|
+
throw new Error(result.error + (result.hint ? ` (${result.hint})` : ''));
|
|
53
121
|
}
|
|
54
|
-
return
|
|
122
|
+
return result || [];
|
|
55
123
|
}
|
|
56
124
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|