@jackwener/opencli 0.4.2 → 0.4.4

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.
Files changed (65) hide show
  1. package/{CLI-CREATOR.md → CLI-EXPLORER.md} +15 -11
  2. package/CLI-ONESHOT.md +216 -0
  3. package/LICENSE +28 -0
  4. package/README.md +114 -63
  5. package/README.zh-CN.md +115 -63
  6. package/SKILL.md +25 -6
  7. package/dist/browser.d.ts +53 -10
  8. package/dist/browser.js +491 -111
  9. package/dist/browser.test.d.ts +1 -0
  10. package/dist/browser.test.js +56 -0
  11. package/dist/build-manifest.js +4 -0
  12. package/dist/cli-manifest.json +279 -3
  13. package/dist/clis/boss/search.js +186 -30
  14. package/dist/clis/twitter/delete.d.ts +1 -0
  15. package/dist/clis/twitter/delete.js +73 -0
  16. package/dist/clis/twitter/followers.d.ts +1 -0
  17. package/dist/clis/twitter/followers.js +104 -0
  18. package/dist/clis/twitter/following.d.ts +1 -0
  19. package/dist/clis/twitter/following.js +90 -0
  20. package/dist/clis/twitter/like.d.ts +1 -0
  21. package/dist/clis/twitter/like.js +69 -0
  22. package/dist/clis/twitter/notifications.d.ts +1 -0
  23. package/dist/clis/twitter/notifications.js +109 -0
  24. package/dist/clis/twitter/post.d.ts +1 -0
  25. package/dist/clis/twitter/post.js +63 -0
  26. package/dist/clis/twitter/reply.d.ts +1 -0
  27. package/dist/clis/twitter/reply.js +57 -0
  28. package/dist/clis/v2ex/daily.d.ts +1 -0
  29. package/dist/clis/v2ex/daily.js +98 -0
  30. package/dist/clis/v2ex/me.d.ts +1 -0
  31. package/dist/clis/v2ex/me.js +99 -0
  32. package/dist/clis/v2ex/notifications.d.ts +1 -0
  33. package/dist/clis/v2ex/notifications.js +72 -0
  34. package/dist/doctor.d.ts +50 -0
  35. package/dist/doctor.js +372 -0
  36. package/dist/doctor.test.d.ts +1 -0
  37. package/dist/doctor.test.js +114 -0
  38. package/dist/main.js +47 -5
  39. package/dist/output.test.d.ts +1 -0
  40. package/dist/output.test.js +20 -0
  41. package/dist/registry.d.ts +4 -0
  42. package/dist/registry.js +1 -0
  43. package/dist/runtime.d.ts +3 -1
  44. package/dist/runtime.js +2 -2
  45. package/package.json +2 -2
  46. package/src/browser.test.ts +77 -0
  47. package/src/browser.ts +541 -99
  48. package/src/build-manifest.ts +4 -0
  49. package/src/clis/boss/search.ts +196 -29
  50. package/src/clis/twitter/delete.ts +78 -0
  51. package/src/clis/twitter/followers.ts +119 -0
  52. package/src/clis/twitter/following.ts +105 -0
  53. package/src/clis/twitter/like.ts +74 -0
  54. package/src/clis/twitter/notifications.ts +119 -0
  55. package/src/clis/twitter/post.ts +68 -0
  56. package/src/clis/twitter/reply.ts +62 -0
  57. package/src/clis/v2ex/daily.ts +105 -0
  58. package/src/clis/v2ex/me.ts +103 -0
  59. package/src/clis/v2ex/notifications.ts +77 -0
  60. package/src/doctor.test.ts +133 -0
  61. package/src/doctor.ts +424 -0
  62. package/src/main.ts +47 -4
  63. package/src/output.test.ts +27 -0
  64. package/src/registry.ts +5 -0
  65. package/src/runtime.ts +2 -1
@@ -0,0 +1,73 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'delete',
5
+ description: 'Delete a specific tweet by URL',
6
+ domain: 'x.com',
7
+ strategy: Strategy.UI, // Utilizes internal DOM flows for interaction
8
+ browser: true,
9
+ args: [
10
+ { name: 'url', type: 'string', required: true, help: 'The URL of the tweet to delete' },
11
+ ],
12
+ columns: ['status', 'message'],
13
+ func: async (page, kwargs) => {
14
+ if (!page)
15
+ throw new Error('Requires browser');
16
+ console.log(`Navigating to tweet: ${kwargs.url}`);
17
+ await page.goto(kwargs.url);
18
+ await page.wait(5); // Wait for tweet to load completely
19
+ const result = await page.evaluate(`(async () => {
20
+ try {
21
+ // Wait for caret button (which has 'More' aria-label) within the main tweet body
22
+ // Getting the first 'More' usually corresponds to the main displayed tweet of the URL
23
+ const moreMenu = document.querySelector('[aria-label="More"]');
24
+ if (!moreMenu) {
25
+ return { ok: false, message: 'Could not find the "More" context menu on this tweet. Are you sure you are logged in and looking at a valid tweet?' };
26
+ }
27
+
28
+ // Click the 'More' 3 dots button to open the dropdown menu
29
+ moreMenu.click();
30
+ await new Promise(r => setTimeout(r, 1000));
31
+
32
+ // Wait for dropdown pop-out to appear and look for the 'Delete' option
33
+ const items = document.querySelectorAll('[role="menuitem"]');
34
+ let deleteBtn = null;
35
+ for (const item of items) {
36
+ if (item.textContent.includes('Delete') && !item.textContent.includes('List')) {
37
+ deleteBtn = item;
38
+ break;
39
+ }
40
+ }
41
+
42
+ if (!deleteBtn) {
43
+ // If there's no Delete button, it's not our tweet OR localization is not English.
44
+ // Assuming English default for now.
45
+ return { ok: false, message: 'This tweet does not seem to belong to you, or the Delete option is missing (not your tweet).' };
46
+ }
47
+
48
+ // Click Delete
49
+ deleteBtn.click();
50
+ await new Promise(r => setTimeout(r, 1000));
51
+
52
+ // Find and click the confirmation 'Delete' prompt inside the modal
53
+ const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]');
54
+ if (confirmBtn) {
55
+ confirmBtn.click();
56
+ return { ok: true, message: 'Tweet successfully deleted.' };
57
+ } else {
58
+ return { ok: false, message: 'Delete confirmation dialog did not appear.' };
59
+ }
60
+ } catch (e) {
61
+ return { ok: false, message: e.toString() };
62
+ }
63
+ })()`);
64
+ if (result.ok) {
65
+ // Wait for the deletion request to be processed
66
+ await page.wait(2);
67
+ }
68
+ return [{
69
+ status: result.ok ? 'success' : 'failed',
70
+ message: result.message
71
+ }];
72
+ }
73
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'followers',
5
+ description: 'Get accounts following a Twitter/X user',
6
+ domain: 'x.com',
7
+ strategy: Strategy.INTERCEPT,
8
+ browser: true,
9
+ args: [
10
+ { name: 'user', type: 'string', required: false },
11
+ { name: 'limit', type: 'int', default: 50 },
12
+ ],
13
+ columns: ['screen_name', 'name', 'bio', 'followers'],
14
+ func: async (page, kwargs) => {
15
+ let targetUser = kwargs.user;
16
+ // If no user is specified, we must figure out the logged-in user's handle
17
+ if (!targetUser) {
18
+ await page.goto('https://x.com/home');
19
+ // wait for home page navigation
20
+ await page.wait(5);
21
+ const href = await page.evaluate(`() => {
22
+ const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
23
+ return link ? link.getAttribute('href') : null;
24
+ }`);
25
+ if (!href) {
26
+ throw new Error('Could not find logged-in user profile link. Are you logged in?');
27
+ }
28
+ targetUser = href.replace('/', '');
29
+ }
30
+ // 1. Navigate to user profile page
31
+ await page.goto(`https://x.com/${targetUser}`);
32
+ await page.wait(3);
33
+ // 2. Inject interceptor for Followers GraphQL API (or user_flow.json)
34
+ await page.installInterceptor('graphql');
35
+ // 3. Click the followers link inside the profile page
36
+ await page.evaluate(`() => {
37
+ const target = '${targetUser}';
38
+ const link = document.querySelector('a[href="/' + target + '/followers"]');
39
+ if (link) link.click();
40
+ }`);
41
+ await page.wait(3);
42
+ // 4. Trigger API by scrolling
43
+ await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
44
+ // 4. Retrieve data from opencli's registered interceptors
45
+ const allRequests = await page.getInterceptedRequests();
46
+ // Debug: Force dump all intercepted XHRs that match followers
47
+ if (!allRequests || allRequests.length === 0) {
48
+ console.log('No GraphQL requests captured by the interceptor backend.');
49
+ return [];
50
+ }
51
+ console.log('Intercepted keys:', allRequests.map((r) => {
52
+ try {
53
+ const u = new URL(r.url);
54
+ return u.pathname;
55
+ }
56
+ catch (e) {
57
+ return r.url;
58
+ }
59
+ }));
60
+ const requests = allRequests.filter((r) => r.url.includes('Followers'));
61
+ if (!requests || requests.length === 0) {
62
+ console.log('No specific Followers requests captured. Check keys printed above.');
63
+ return [];
64
+ }
65
+ let results = [];
66
+ for (const req of requests) {
67
+ try {
68
+ let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
69
+ if (!instructions)
70
+ continue;
71
+ let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
72
+ if (!addEntries) {
73
+ addEntries = instructions.find((i) => i.entries && Array.isArray(i.entries));
74
+ }
75
+ if (!addEntries)
76
+ continue;
77
+ for (const entry of addEntries.entries) {
78
+ if (!entry.entryId.startsWith('user-'))
79
+ continue;
80
+ const item = entry.content?.itemContent?.user_results?.result;
81
+ if (!item || item.__typename !== 'User')
82
+ continue;
83
+ // Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
84
+ const core = item.core || {};
85
+ const legacy = item.legacy || {};
86
+ results.push({
87
+ screen_name: core.screen_name || legacy.screen_name || 'unknown',
88
+ name: core.name || legacy.name || 'unknown',
89
+ bio: legacy.description || item.profile_bio?.description || '',
90
+ followers: legacy.followers_count || legacy.normal_followers_count || 0
91
+ });
92
+ }
93
+ }
94
+ catch (e) {
95
+ // ignore parsing errors for individual payloads
96
+ }
97
+ }
98
+ // Deduplicate by screen_name in case multiple scrolls caught the same
99
+ const unique = new Map();
100
+ results.forEach(r => unique.set(r.screen_name, r));
101
+ const deduplicatedResults = Array.from(unique.values());
102
+ return deduplicatedResults.slice(0, kwargs.limit);
103
+ }
104
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,90 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'following',
5
+ description: 'Get accounts a Twitter/X user is following',
6
+ domain: 'x.com',
7
+ strategy: Strategy.INTERCEPT,
8
+ browser: true,
9
+ args: [
10
+ { name: 'user', type: 'string', required: false },
11
+ { name: 'limit', type: 'int', default: 50 },
12
+ ],
13
+ columns: ['screen_name', 'name', 'bio', 'followers'],
14
+ func: async (page, kwargs) => {
15
+ let targetUser = kwargs.user;
16
+ // If no user is specified, we must figure out the logged-in user's handle
17
+ if (!targetUser) {
18
+ await page.goto('https://x.com/home');
19
+ // wait for home page navigation
20
+ await page.wait(5);
21
+ const href = await page.evaluate(`() => {
22
+ const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
23
+ return link ? link.getAttribute('href') : null;
24
+ }`);
25
+ if (!href) {
26
+ throw new Error('Could not find logged-in user profile link. Are you logged in?');
27
+ }
28
+ targetUser = href.replace('/', '');
29
+ }
30
+ // 1. Navigate to user profile page
31
+ await page.goto(`https://x.com/${targetUser}`);
32
+ await page.wait(3);
33
+ // 2. Inject interceptor for Following GraphQL API
34
+ await page.installInterceptor('Following');
35
+ // 3. Click the following link inside the profile page
36
+ await page.evaluate(`() => {
37
+ const target = '${targetUser}';
38
+ const link = document.querySelector('a[href="/' + target + '/following"]');
39
+ if (link) link.click();
40
+ }`);
41
+ await page.wait(3);
42
+ // 4. Trigger API by scrolling
43
+ await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
44
+ // 4. Retrieve data from opencli's registered interceptors
45
+ const requests = await page.getInterceptedRequests();
46
+ // Debug: Force dump all intercepted XHRs that match following
47
+ if (!requests || requests.length === 0) {
48
+ console.log('No Following requests captured by the interceptor backend.');
49
+ return [];
50
+ }
51
+ let results = [];
52
+ for (const req of requests) {
53
+ try {
54
+ let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions;
55
+ if (!instructions)
56
+ continue;
57
+ let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
58
+ if (!addEntries) {
59
+ addEntries = instructions.find((i) => i.entries && Array.isArray(i.entries));
60
+ }
61
+ if (!addEntries)
62
+ continue;
63
+ for (const entry of addEntries.entries) {
64
+ if (!entry.entryId.startsWith('user-'))
65
+ continue;
66
+ const item = entry.content?.itemContent?.user_results?.result;
67
+ if (!item || item.__typename !== 'User')
68
+ continue;
69
+ // Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state
70
+ const core = item.core || {};
71
+ const legacy = item.legacy || {};
72
+ results.push({
73
+ screen_name: core.screen_name || legacy.screen_name || 'unknown',
74
+ name: core.name || legacy.name || 'unknown',
75
+ bio: legacy.description || item.profile_bio?.description || '',
76
+ followers: legacy.followers_count || legacy.normal_followers_count || 0
77
+ });
78
+ }
79
+ }
80
+ catch (e) {
81
+ // ignore parsing errors for individual payloads
82
+ }
83
+ }
84
+ // Deduplicate by screen_name in case multiple scrolls caught the same
85
+ const unique = new Map();
86
+ results.forEach(r => unique.set(r.screen_name, r));
87
+ const deduplicatedResults = Array.from(unique.values());
88
+ return deduplicatedResults.slice(0, kwargs.limit);
89
+ }
90
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'like',
5
+ description: 'Like a specific tweet',
6
+ domain: 'x.com',
7
+ strategy: Strategy.UI, // Utilizes internal DOM flows for interaction
8
+ browser: true,
9
+ args: [
10
+ { name: 'url', type: 'string', required: true, help: 'The URL of the tweet to like' },
11
+ ],
12
+ columns: ['status', 'message'],
13
+ func: async (page, kwargs) => {
14
+ if (!page)
15
+ throw new Error('Requires browser');
16
+ console.log(`Navigating to tweet: ${kwargs.url}`);
17
+ await page.goto(kwargs.url);
18
+ await page.wait(5); // Wait for tweet to load completely
19
+ const result = await page.evaluate(`(async () => {
20
+ try {
21
+ // Poll for the tweet to render
22
+ let attempts = 0;
23
+ let likeBtn = null;
24
+ let unlikeBtn = null;
25
+
26
+ while (attempts < 20) {
27
+ unlikeBtn = document.querySelector('[data-testid="unlike"]');
28
+ likeBtn = document.querySelector('[data-testid="like"]');
29
+
30
+ if (unlikeBtn || likeBtn) break;
31
+
32
+ await new Promise(r => setTimeout(r, 500));
33
+ attempts++;
34
+ }
35
+
36
+ // Check if it's already liked
37
+ if (unlikeBtn) {
38
+ return { ok: true, message: 'Tweet is already liked.' };
39
+ }
40
+
41
+ if (!likeBtn) {
42
+ return { ok: false, message: 'Could not find the Like button on this tweet after waiting 10 seconds. Are you logged in?' };
43
+ }
44
+
45
+ // Click Like
46
+ likeBtn.click();
47
+ await new Promise(r => setTimeout(r, 1000));
48
+
49
+ // Verify success by checking if the 'unlike' button appeared
50
+ const verifyBtn = document.querySelector('[data-testid="unlike"]');
51
+ if (verifyBtn) {
52
+ return { ok: true, message: 'Tweet successfully liked.' };
53
+ } else {
54
+ return { ok: false, message: 'Like action was initiated but UI did not update as expected.' };
55
+ }
56
+ } catch (e) {
57
+ return { ok: false, message: e.toString() };
58
+ }
59
+ })()`);
60
+ if (result.ok) {
61
+ // Wait for the like network request to be processed
62
+ await page.wait(2);
63
+ }
64
+ return [{
65
+ status: result.ok ? 'success' : 'failed',
66
+ message: result.message
67
+ }];
68
+ }
69
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'notifications',
5
+ description: 'Get Twitter/X notifications',
6
+ domain: 'x.com',
7
+ strategy: Strategy.INTERCEPT,
8
+ browser: true,
9
+ args: [
10
+ { name: 'limit', type: 'int', default: 20 },
11
+ ],
12
+ columns: ['id', 'action', 'author', 'text', 'url'],
13
+ func: async (page, kwargs) => {
14
+ // 1. Navigate to notifications
15
+ await page.goto('https://x.com/notifications');
16
+ await page.wait(5);
17
+ // 2. Inject interceptor
18
+ await page.installInterceptor('NotificationsTimeline');
19
+ // 3. Trigger API by scrolling (if we need to load more)
20
+ await page.autoScroll({ times: 2, delayMs: 2000 });
21
+ // 4. Retrieve data
22
+ const requests = await page.getInterceptedRequests();
23
+ if (!requests || requests.length === 0)
24
+ return [];
25
+ let results = [];
26
+ for (const req of requests) {
27
+ try {
28
+ let instructions = [];
29
+ if (req.data?.data?.viewer?.timeline_response?.timeline?.instructions) {
30
+ instructions = req.data.data.viewer.timeline_response.timeline.instructions;
31
+ }
32
+ else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
33
+ instructions = req.data.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
34
+ }
35
+ else if (req.data?.data?.timeline?.instructions) {
36
+ instructions = req.data.data.timeline.instructions;
37
+ }
38
+ let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
39
+ // Sometimes it's the first object without a 'type' field but has 'entries'
40
+ if (!addEntries) {
41
+ addEntries = instructions.find((i) => i.entries && Array.isArray(i.entries));
42
+ }
43
+ if (!addEntries)
44
+ continue;
45
+ for (const entry of addEntries.entries) {
46
+ if (!entry.entryId.startsWith('notification-')) {
47
+ if (entry.content?.items) {
48
+ for (const subItem of entry.content.items) {
49
+ processNotificationItem(subItem.item?.itemContent, subItem.entryId);
50
+ }
51
+ }
52
+ continue;
53
+ }
54
+ processNotificationItem(entry.content?.itemContent, entry.entryId);
55
+ }
56
+ function processNotificationItem(itemContent, entryId) {
57
+ if (!itemContent)
58
+ return;
59
+ // Twitter wraps standard notifications
60
+ let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
61
+ let actionText = 'Notification';
62
+ let author = 'unknown';
63
+ let text = '';
64
+ let urlStr = '';
65
+ if (item.__typename === 'TimelineNotification') {
66
+ // Greet likes, retweet, mentions
67
+ text = item.rich_message?.text || item.message?.text || '';
68
+ author = item.template?.from_users?.[0]?.user_results?.result?.core?.screen_name || 'unknown';
69
+ urlStr = item.notification_url?.url || '';
70
+ actionText = item.notification_icon || 'Activity';
71
+ // If there's an attached tweet
72
+ const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
73
+ if (targetTweet) {
74
+ text += ' | ' + (targetTweet.legacy?.full_text || '');
75
+ if (!urlStr) {
76
+ urlStr = `https://x.com/i/status/${targetTweet.rest_id}`;
77
+ }
78
+ }
79
+ }
80
+ else if (item.__typename === 'TweetNotification') {
81
+ // Direct mention/reply
82
+ const tweet = item.tweet_result?.result;
83
+ author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown';
84
+ text = tweet?.legacy?.full_text || item.message?.text || '';
85
+ actionText = 'Mention/Reply';
86
+ urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
87
+ }
88
+ else if (item.__typename === 'Tweet') {
89
+ author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown';
90
+ text = item.legacy?.full_text || '';
91
+ actionText = 'Mention';
92
+ urlStr = `https://x.com/i/status/${item.rest_id}`;
93
+ }
94
+ results.push({
95
+ id: item.id || item.rest_id || entryId,
96
+ action: actionText,
97
+ author: author,
98
+ text: text,
99
+ url: urlStr || `https://x.com/notifications`
100
+ });
101
+ }
102
+ }
103
+ catch (e) {
104
+ // ignore parsing errors for individual payloads
105
+ }
106
+ }
107
+ return results.slice(0, kwargs.limit);
108
+ }
109
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,63 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'post',
5
+ description: 'Post a new tweet/thread',
6
+ domain: 'x.com',
7
+ strategy: Strategy.UI,
8
+ browser: true,
9
+ args: [
10
+ { name: 'text', type: 'string', required: true, help: 'The text content of the tweet' },
11
+ ],
12
+ columns: ['status', 'message', 'text'],
13
+ func: async (page, kwargs) => {
14
+ if (!page)
15
+ throw new Error('Requires browser');
16
+ // 1. Navigate directly to the compose tweet modal
17
+ await page.goto('https://x.com/compose/tweet');
18
+ await page.wait(3); // Wait for the modal and React app to hydrate
19
+ // 2. Automate typing and clicking
20
+ const result = await page.evaluate(`(async () => {
21
+ try {
22
+ // Find the active text area
23
+ const box = document.querySelector('[data-testid="tweetTextarea_0"]');
24
+ if (box) {
25
+ box.focus();
26
+ // insertText is the most reliable way to trigger React's onChange events
27
+ document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
28
+ } else {
29
+ return { ok: false, message: 'Could not find the tweet composer text area.' };
30
+ }
31
+
32
+ // Wait a brief moment for the button state to update
33
+ await new Promise(r => setTimeout(r, 1000));
34
+
35
+ // Click the post button
36
+ const btn = document.querySelector('[data-testid="tweetButton"]');
37
+ if (btn && !btn.disabled) {
38
+ btn.click();
39
+ return { ok: true, message: 'Tweet posted successfully.' };
40
+ } else {
41
+ // Sometimes it's rendered inline depending on the viewport
42
+ const inlineBtn = document.querySelector('[data-testid="tweetButtonInline"]');
43
+ if (inlineBtn && !inlineBtn.disabled) {
44
+ inlineBtn.click();
45
+ return { ok: true, message: 'Tweet posted successfully.' };
46
+ }
47
+ return { ok: false, message: 'Tweet button is disabled or not found.' };
48
+ }
49
+ } catch (e) {
50
+ return { ok: false, message: e.toString() };
51
+ }
52
+ })()`);
53
+ // 3. Wait a few seconds for the network request to finish sending
54
+ if (result.ok) {
55
+ await page.wait(3);
56
+ }
57
+ return [{
58
+ status: result.ok ? 'success' : 'failed',
59
+ message: result.message,
60
+ text: kwargs.text
61
+ }];
62
+ }
63
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,57 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ cli({
3
+ site: 'twitter',
4
+ name: 'reply',
5
+ description: 'Reply to a specific tweet',
6
+ domain: 'x.com',
7
+ strategy: Strategy.UI, // Uses the UI directly to input and click post
8
+ browser: true,
9
+ args: [
10
+ { name: 'url', type: 'string', required: true, help: 'The URL of the tweet to reply to' },
11
+ { name: 'text', type: 'string', required: true, help: 'The text content of your reply' },
12
+ ],
13
+ columns: ['status', 'message', 'text'],
14
+ func: async (page, kwargs) => {
15
+ if (!page)
16
+ throw new Error('Requires browser');
17
+ // 1. Navigate to the tweet page
18
+ await page.goto(kwargs.url);
19
+ await page.wait(5); // Wait for the react application to hydrate
20
+ // 2. Automate typing the reply and clicking reply
21
+ const result = await page.evaluate(`(async () => {
22
+ try {
23
+ // Find the reply text area on the tweet page.
24
+ // The placeholder is usually "Post your reply"
25
+ const box = document.querySelector('[data-testid="tweetTextarea_0"]');
26
+ if (box) {
27
+ box.focus();
28
+ document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
29
+ } else {
30
+ return { ok: false, message: 'Could not find the reply text area. Are you logged in?' };
31
+ }
32
+
33
+ // Wait for React state to register the input and enable the button
34
+ await new Promise(r => setTimeout(r, 1000));
35
+
36
+ // Find the Reply button. It usually shares the same test id tweetButtonInline in this context
37
+ const btn = document.querySelector('[data-testid="tweetButtonInline"]');
38
+ if (btn && !btn.disabled) {
39
+ btn.click();
40
+ return { ok: true, message: 'Reply posted successfully.' };
41
+ } else {
42
+ return { ok: false, message: 'Reply button is disabled or not found.' };
43
+ }
44
+ } catch (e) {
45
+ return { ok: false, message: e.toString() };
46
+ }
47
+ })()`);
48
+ if (result.ok) {
49
+ await page.wait(3); // Wait for network submission to complete
50
+ }
51
+ return [{
52
+ status: result.ok ? 'success' : 'failed',
53
+ message: result.message,
54
+ text: kwargs.text
55
+ }];
56
+ }
57
+ });
@@ -0,0 +1 @@
1
+ export {};