@jackwener/opencli 0.4.2 → 0.4.3
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/CLI-CREATOR.md +10 -10
- package/LICENSE +28 -0
- package/README.md +113 -63
- package/README.zh-CN.md +114 -63
- package/SKILL.md +21 -4
- package/dist/browser.d.ts +21 -2
- package/dist/browser.js +269 -15
- package/dist/browser.test.d.ts +1 -0
- package/dist/browser.test.js +43 -0
- package/dist/build-manifest.js +4 -0
- package/dist/cli-manifest.json +279 -3
- package/dist/clis/boss/search.js +186 -30
- package/dist/clis/twitter/delete.d.ts +1 -0
- package/dist/clis/twitter/delete.js +73 -0
- package/dist/clis/twitter/followers.d.ts +1 -0
- package/dist/clis/twitter/followers.js +104 -0
- package/dist/clis/twitter/following.d.ts +1 -0
- package/dist/clis/twitter/following.js +90 -0
- package/dist/clis/twitter/like.d.ts +1 -0
- package/dist/clis/twitter/like.js +69 -0
- package/dist/clis/twitter/notifications.d.ts +1 -0
- package/dist/clis/twitter/notifications.js +109 -0
- package/dist/clis/twitter/post.d.ts +1 -0
- package/dist/clis/twitter/post.js +63 -0
- package/dist/clis/twitter/reply.d.ts +1 -0
- package/dist/clis/twitter/reply.js +57 -0
- package/dist/clis/v2ex/daily.d.ts +1 -0
- package/dist/clis/v2ex/daily.js +98 -0
- package/dist/clis/v2ex/me.d.ts +1 -0
- package/dist/clis/v2ex/me.js +99 -0
- package/dist/clis/v2ex/notifications.d.ts +1 -0
- package/dist/clis/v2ex/notifications.js +72 -0
- package/dist/doctor.d.ts +50 -0
- package/dist/doctor.js +372 -0
- package/dist/doctor.test.d.ts +1 -0
- package/dist/doctor.test.js +114 -0
- package/dist/main.js +47 -5
- package/dist/output.test.d.ts +1 -0
- package/dist/output.test.js +20 -0
- package/dist/registry.d.ts +4 -0
- package/dist/registry.js +1 -0
- package/dist/runtime.d.ts +3 -1
- package/dist/runtime.js +2 -2
- package/package.json +2 -2
- package/src/browser.test.ts +51 -0
- package/src/browser.ts +318 -22
- package/src/build-manifest.ts +4 -0
- package/src/clis/boss/search.ts +196 -29
- package/src/clis/twitter/delete.ts +78 -0
- package/src/clis/twitter/followers.ts +119 -0
- package/src/clis/twitter/following.ts +105 -0
- package/src/clis/twitter/like.ts +74 -0
- package/src/clis/twitter/notifications.ts +119 -0
- package/src/clis/twitter/post.ts +68 -0
- package/src/clis/twitter/reply.ts +62 -0
- package/src/clis/v2ex/daily.ts +105 -0
- package/src/clis/v2ex/me.ts +103 -0
- package/src/clis/v2ex/notifications.ts +77 -0
- package/src/doctor.test.ts +133 -0
- package/src/doctor.ts +424 -0
- package/src/main.ts +47 -4
- package/src/output.test.ts +27 -0
- package/src/registry.ts +5 -0
- package/src/runtime.ts +2 -1
|
@@ -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 {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* V2EX Daily Check-in adapter.
|
|
3
|
+
*/
|
|
4
|
+
import { cli, Strategy } from '../../registry.js';
|
|
5
|
+
cli({
|
|
6
|
+
site: 'v2ex',
|
|
7
|
+
name: 'daily',
|
|
8
|
+
description: 'V2EX 每日签到并领取铜币',
|
|
9
|
+
domain: 'www.v2ex.com',
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
browser: true,
|
|
12
|
+
forceExtension: true,
|
|
13
|
+
args: [],
|
|
14
|
+
columns: ['status', 'message'],
|
|
15
|
+
func: async (page) => {
|
|
16
|
+
if (!page)
|
|
17
|
+
throw new Error('Browser page required');
|
|
18
|
+
if (process.env.OPENCLI_VERBOSE) {
|
|
19
|
+
console.error('[opencli:v2ex] Navigating to /mission/daily');
|
|
20
|
+
}
|
|
21
|
+
await page.goto('https://www.v2ex.com/mission/daily');
|
|
22
|
+
// Cloudflare challenge bypass wait
|
|
23
|
+
for (let i = 0; i < 5; i++) {
|
|
24
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
25
|
+
const title = await page.evaluate(`() => document.title`);
|
|
26
|
+
if (!title?.includes('Just a moment'))
|
|
27
|
+
break;
|
|
28
|
+
if (process.env.OPENCLI_VERBOSE)
|
|
29
|
+
console.error('[opencli:v2ex] Waiting for Cloudflare...');
|
|
30
|
+
}
|
|
31
|
+
// Evaluate DOM to find if we need to check in
|
|
32
|
+
const checkResult = await page.evaluate(`
|
|
33
|
+
async () => {
|
|
34
|
+
const btn = document.querySelector('input.super.normal.button');
|
|
35
|
+
if (!btn || !btn.value.includes('领取')) {
|
|
36
|
+
return { claimed: true, message: '今日奖励已发/无需领取' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const onclick = btn.getAttribute('onclick');
|
|
40
|
+
if (onclick) {
|
|
41
|
+
const match = onclick.match(/once=(\\d+)/);
|
|
42
|
+
if (match) {
|
|
43
|
+
return { claimed: false, once: match[1], message: btn.value };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
claimed: false,
|
|
49
|
+
error: '找到了按钮,但未能提取 once token',
|
|
50
|
+
debug_title: document.title,
|
|
51
|
+
debug_body: document.body.innerText.substring(0, 200).replace(/\\n/g, ' ')
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
`);
|
|
55
|
+
if (checkResult.error) {
|
|
56
|
+
if (process.env.OPENCLI_VERBOSE) {
|
|
57
|
+
console.error(`[opencli:v2ex:debug] Page Title: ${checkResult.debug_title}`);
|
|
58
|
+
console.error(`[opencli:v2ex:debug] Page Body: ${checkResult.debug_body}`);
|
|
59
|
+
}
|
|
60
|
+
throw new Error(checkResult.error);
|
|
61
|
+
}
|
|
62
|
+
if (checkResult.claimed) {
|
|
63
|
+
return [{ status: '✅ 已签到', message: checkResult.message }];
|
|
64
|
+
}
|
|
65
|
+
// Perform check in
|
|
66
|
+
if (process.env.OPENCLI_VERBOSE) {
|
|
67
|
+
console.error(`[opencli:v2ex] Found check-in token: once=${checkResult.once}. Checking in...`);
|
|
68
|
+
}
|
|
69
|
+
await page.goto(`https://www.v2ex.com/mission/daily/redeem?once=${checkResult.once}`);
|
|
70
|
+
await new Promise(resolve => setTimeout(resolve, 3000)); // wait longer for redirect
|
|
71
|
+
// Verify result
|
|
72
|
+
const verifyResult = await page.evaluate(`
|
|
73
|
+
async () => {
|
|
74
|
+
const btn = document.querySelector('input.super.normal.button');
|
|
75
|
+
if (!btn || !btn.value.includes('领取')) {
|
|
76
|
+
// fetch balance to show user
|
|
77
|
+
let balance = '';
|
|
78
|
+
const balanceLink = document.querySelector('a.balance_area');
|
|
79
|
+
if (balanceLink) {
|
|
80
|
+
balance = Array.from(balanceLink.childNodes)
|
|
81
|
+
.filter(n => n.nodeType === 3)
|
|
82
|
+
.map(n => n.textContent?.trim())
|
|
83
|
+
.join(' ')
|
|
84
|
+
.trim();
|
|
85
|
+
}
|
|
86
|
+
return { success: true, balance };
|
|
87
|
+
}
|
|
88
|
+
return { success: false };
|
|
89
|
+
}
|
|
90
|
+
`);
|
|
91
|
+
if (verifyResult.success) {
|
|
92
|
+
return [{ status: '🎉 签到成功', message: `当前余额: ${verifyResult.balance || '未知'}` }];
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
return [{ status: '❌ 签到失败', message: '未能确认签到结果,请手动检查' }];
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|