@jackwener/opencli 0.9.8 → 1.0.1
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/CDP.md +1 -1
- package/CDP.zh-CN.md +1 -1
- package/CLI-ELECTRON.md +2 -2
- package/CLI-EXPLORER.md +4 -4
- package/README.md +35 -58
- package/README.zh-CN.md +36 -60
- package/SKILL.md +10 -8
- package/TESTING.md +7 -7
- package/dist/browser/daemon-client.d.ts +37 -0
- package/dist/browser/daemon-client.js +82 -0
- package/dist/browser/discover.d.ts +11 -34
- package/dist/browser/discover.js +15 -205
- package/dist/browser/errors.d.ts +6 -20
- package/dist/browser/errors.js +24 -63
- package/dist/browser/index.d.ts +2 -12
- package/dist/browser/index.js +2 -12
- package/dist/browser/mcp.d.ts +9 -21
- package/dist/browser/mcp.js +70 -285
- package/dist/browser/page.d.ts +36 -7
- package/dist/browser/page.js +212 -81
- package/dist/browser.test.js +10 -231
- package/dist/cli-manifest.json +561 -14
- package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
- package/dist/clis/apple-podcasts/episodes.js +28 -0
- package/dist/clis/apple-podcasts/search.d.ts +1 -0
- package/dist/clis/apple-podcasts/search.js +29 -0
- package/dist/clis/apple-podcasts/top.d.ts +1 -0
- package/dist/clis/apple-podcasts/top.js +34 -0
- package/dist/clis/apple-podcasts/utils.d.ts +11 -0
- package/dist/clis/apple-podcasts/utils.js +30 -0
- package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
- package/dist/clis/apple-podcasts/utils.test.js +57 -0
- package/dist/clis/chatwise/history.js +18 -1
- package/dist/clis/discord-app/channels.js +33 -21
- package/dist/clis/neteasemusic/like.d.ts +1 -0
- package/dist/clis/neteasemusic/like.js +25 -0
- package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
- package/dist/clis/neteasemusic/lyrics.js +47 -0
- package/dist/clis/neteasemusic/next.d.ts +1 -0
- package/dist/clis/neteasemusic/next.js +26 -0
- package/dist/clis/neteasemusic/play.d.ts +1 -0
- package/dist/clis/neteasemusic/play.js +26 -0
- package/dist/clis/neteasemusic/playing.d.ts +1 -0
- package/dist/clis/neteasemusic/playing.js +59 -0
- package/dist/clis/neteasemusic/playlist.d.ts +1 -0
- package/dist/clis/neteasemusic/playlist.js +46 -0
- package/dist/clis/neteasemusic/prev.d.ts +1 -0
- package/dist/clis/neteasemusic/prev.js +25 -0
- package/dist/clis/neteasemusic/search.d.ts +1 -0
- package/dist/clis/neteasemusic/search.js +52 -0
- package/dist/clis/neteasemusic/status.d.ts +1 -0
- package/dist/clis/neteasemusic/status.js +16 -0
- package/dist/clis/neteasemusic/volume.d.ts +1 -0
- package/dist/clis/neteasemusic/volume.js +54 -0
- package/dist/clis/twitter/accept.d.ts +1 -0
- package/dist/clis/twitter/accept.js +202 -0
- package/dist/clis/twitter/followers.js +30 -22
- package/dist/clis/twitter/following.js +19 -14
- package/dist/clis/twitter/notifications.js +29 -22
- package/dist/clis/twitter/reply-dm.d.ts +1 -0
- package/dist/clis/twitter/reply-dm.js +181 -0
- package/dist/clis/twitter/search.js +50 -12
- package/dist/clis/weread/book.d.ts +1 -0
- package/dist/clis/weread/book.js +26 -0
- package/dist/clis/weread/highlights.d.ts +1 -0
- package/dist/clis/weread/highlights.js +23 -0
- package/dist/clis/weread/notebooks.d.ts +1 -0
- package/dist/clis/weread/notebooks.js +21 -0
- package/dist/clis/weread/notes.d.ts +1 -0
- package/dist/clis/weread/notes.js +29 -0
- package/dist/clis/weread/ranking.d.ts +1 -0
- package/dist/clis/weread/ranking.js +28 -0
- package/dist/clis/weread/search.d.ts +1 -0
- package/dist/clis/weread/search.js +25 -0
- package/dist/clis/weread/shelf.d.ts +1 -0
- package/dist/clis/weread/shelf.js +24 -0
- package/dist/clis/weread/utils.d.ts +20 -0
- package/dist/clis/weread/utils.js +72 -0
- package/dist/clis/weread/utils.test.d.ts +1 -0
- package/dist/clis/weread/utils.test.js +85 -0
- package/dist/daemon.d.ts +13 -0
- package/dist/daemon.js +187 -0
- package/dist/doctor.d.ts +10 -65
- package/dist/doctor.js +49 -602
- package/dist/doctor.test.js +30 -170
- package/dist/main.js +12 -41
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/browser.js +2 -2
- package/dist/pipeline/steps/intercept.js +1 -2
- package/dist/runtime.d.ts +1 -4
- package/dist/runtime.js +1 -4
- package/dist/setup.d.ts +6 -0
- package/dist/setup.js +46 -160
- package/dist/types.d.ts +6 -0
- package/extension/dist/background.js +484 -0
- package/extension/icons/icon-128.png +0 -0
- package/extension/icons/icon-16.png +0 -0
- package/extension/icons/icon-32.png +0 -0
- package/extension/icons/icon-48.png +0 -0
- package/extension/manifest.json +31 -0
- package/extension/package.json +16 -0
- package/extension/src/background.ts +370 -0
- package/extension/src/cdp.ts +125 -0
- package/extension/src/protocol.ts +57 -0
- package/extension/store-assets/screenshot-1280x800.png +0 -0
- package/extension/tsconfig.json +15 -0
- package/extension/vite.config.ts +18 -0
- package/package.json +5 -5
- package/src/browser/daemon-client.ts +113 -0
- package/src/browser/discover.ts +18 -232
- package/src/browser/errors.ts +30 -100
- package/src/browser/index.ts +2 -13
- package/src/browser/mcp.ts +81 -282
- package/src/browser/page.ts +223 -83
- package/src/browser.test.ts +9 -239
- package/src/clis/apple-podcasts/episodes.ts +28 -0
- package/src/clis/apple-podcasts/search.ts +29 -0
- package/src/clis/apple-podcasts/top.ts +34 -0
- package/src/clis/apple-podcasts/utils.test.ts +72 -0
- package/src/clis/apple-podcasts/utils.ts +37 -0
- package/src/clis/chatgpt/README.md +1 -1
- package/src/clis/chatgpt/README.zh-CN.md +1 -1
- package/src/clis/chatwise/history.ts +15 -1
- package/src/clis/discord-app/channels.ts +33 -21
- package/src/clis/neteasemusic/README.md +31 -0
- package/src/clis/neteasemusic/README.zh-CN.md +31 -0
- package/src/clis/neteasemusic/like.ts +28 -0
- package/src/clis/neteasemusic/lyrics.ts +53 -0
- package/src/clis/neteasemusic/next.ts +30 -0
- package/src/clis/neteasemusic/play.ts +30 -0
- package/src/clis/neteasemusic/playing.ts +62 -0
- package/src/clis/neteasemusic/playlist.ts +51 -0
- package/src/clis/neteasemusic/prev.ts +29 -0
- package/src/clis/neteasemusic/search.ts +58 -0
- package/src/clis/neteasemusic/status.ts +18 -0
- package/src/clis/neteasemusic/volume.ts +61 -0
- package/src/clis/twitter/accept.ts +213 -0
- package/src/clis/twitter/followers.ts +36 -29
- package/src/clis/twitter/following.ts +25 -20
- package/src/clis/twitter/notifications.ts +34 -27
- package/src/clis/twitter/reply-dm.ts +193 -0
- package/src/clis/twitter/search.ts +53 -13
- package/src/clis/weread/book.ts +28 -0
- package/src/clis/weread/highlights.ts +25 -0
- package/src/clis/weread/notebooks.ts +23 -0
- package/src/clis/weread/notes.ts +31 -0
- package/src/clis/weread/ranking.ts +29 -0
- package/src/clis/weread/search.ts +26 -0
- package/src/clis/weread/shelf.ts +26 -0
- package/src/clis/weread/utils.test.ts +104 -0
- package/src/clis/weread/utils.ts +74 -0
- package/src/daemon.ts +217 -0
- package/src/doctor.test.ts +32 -193
- package/src/doctor.ts +58 -669
- package/src/main.ts +11 -34
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/browser.ts +2 -2
- package/src/pipeline/steps/intercept.ts +1 -2
- package/src/runtime.ts +2 -6
- package/src/setup.ts +47 -183
- package/src/types.ts +1 -0
- package/tests/e2e/public-commands.test.ts +68 -1
- package/dist/clis/grok/debug.d.ts +0 -1
- package/dist/clis/grok/debug.js +0 -45
- package/src/clis/grok/debug.ts +0 -49
|
@@ -12,20 +12,30 @@ cli({
|
|
|
12
12
|
],
|
|
13
13
|
columns: ['id', 'action', 'author', 'text', 'url'],
|
|
14
14
|
func: async (page, kwargs) => {
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
await page.
|
|
18
|
-
|
|
15
|
+
// 1. Navigate to home first (we need a loaded Twitter page for SPA navigation)
|
|
16
|
+
await page.goto('https://x.com/home');
|
|
17
|
+
await page.wait(3);
|
|
18
|
+
|
|
19
|
+
// 2. Install interceptor BEFORE SPA navigation
|
|
19
20
|
await page.installInterceptor('NotificationsTimeline');
|
|
20
21
|
|
|
21
|
-
//
|
|
22
|
-
await page.
|
|
22
|
+
// 3. SPA navigate to notifications via history API
|
|
23
|
+
await page.evaluate(`() => {
|
|
24
|
+
window.history.pushState({}, '', '/notifications');
|
|
25
|
+
window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
|
|
26
|
+
}`);
|
|
23
27
|
await page.wait(5);
|
|
24
28
|
|
|
25
|
-
//
|
|
29
|
+
// Verify SPA navigation succeeded
|
|
30
|
+
const currentUrl = await page.evaluate('() => window.location.pathname');
|
|
31
|
+
if (currentUrl !== '/notifications') {
|
|
32
|
+
throw new Error('SPA navigation to notifications failed. Twitter may have changed its routing.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 4. Scroll to trigger pagination
|
|
26
36
|
await page.autoScroll({ times: 2, delayMs: 2000 });
|
|
27
37
|
|
|
28
|
-
//
|
|
38
|
+
// 5. Retrieve data
|
|
29
39
|
const requests = await page.getInterceptedRequests();
|
|
30
40
|
if (!requests || requests.length === 0) return [];
|
|
31
41
|
|
|
@@ -33,22 +43,20 @@ cli({
|
|
|
33
43
|
const seen = new Set<string>();
|
|
34
44
|
for (const req of requests) {
|
|
35
45
|
try {
|
|
46
|
+
// GraphQL response: { data: { viewer: ... } } (one level of .data)
|
|
36
47
|
let instructions: any[] = [];
|
|
37
|
-
if (req.data?.
|
|
38
|
-
instructions = req.data.
|
|
39
|
-
} else if (req.data?.
|
|
40
|
-
instructions = req.data.
|
|
41
|
-
} else if (req.data?.
|
|
42
|
-
instructions = req.data.
|
|
48
|
+
if (req.data?.viewer?.timeline_response?.timeline?.instructions) {
|
|
49
|
+
instructions = req.data.viewer.timeline_response.timeline.instructions;
|
|
50
|
+
} else if (req.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
|
|
51
|
+
instructions = req.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
|
|
52
|
+
} else if (req.data?.timeline?.instructions) {
|
|
53
|
+
instructions = req.data.timeline.instructions;
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries');
|
|
46
|
-
|
|
47
|
-
// Sometimes it's the first object without a 'type' field but has 'entries'
|
|
48
57
|
if (!addEntries) {
|
|
49
58
|
addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries));
|
|
50
59
|
}
|
|
51
|
-
|
|
52
60
|
if (!addEntries) continue;
|
|
53
61
|
|
|
54
62
|
for (const entry of addEntries.entries) {
|
|
@@ -66,24 +74,22 @@ cli({
|
|
|
66
74
|
|
|
67
75
|
function processNotificationItem(itemContent: any, entryId: string) {
|
|
68
76
|
if (!itemContent) return;
|
|
69
|
-
|
|
70
|
-
// Twitter wraps standard notifications
|
|
77
|
+
|
|
71
78
|
let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent;
|
|
72
79
|
|
|
73
80
|
let actionText = 'Notification';
|
|
74
81
|
let author = 'unknown';
|
|
75
82
|
let text = '';
|
|
76
83
|
let urlStr = '';
|
|
77
|
-
|
|
84
|
+
|
|
78
85
|
if (item.__typename === 'TimelineNotification') {
|
|
79
|
-
// Greet likes, retweet, mentions
|
|
80
86
|
text = item.rich_message?.text || item.message?.text || '';
|
|
81
87
|
const fromUser = item.template?.from_users?.[0]?.user_results?.result;
|
|
82
|
-
|
|
88
|
+
// Twitter moved screen_name from legacy to core
|
|
89
|
+
author = fromUser?.core?.screen_name || fromUser?.legacy?.screen_name || 'unknown';
|
|
83
90
|
urlStr = item.notification_url?.url || '';
|
|
84
91
|
actionText = item.notification_icon || 'Activity';
|
|
85
|
-
|
|
86
|
-
// If there's an attached tweet
|
|
92
|
+
|
|
87
93
|
const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result;
|
|
88
94
|
if (targetTweet) {
|
|
89
95
|
const targetText = targetTweet.note_tweet?.note_tweet_results?.result?.text || targetTweet.legacy?.full_text || '';
|
|
@@ -93,14 +99,15 @@ cli({
|
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
101
|
} else if (item.__typename === 'TweetNotification') {
|
|
96
|
-
// Direct mention/reply
|
|
97
102
|
const tweet = item.tweet_result?.result;
|
|
98
|
-
|
|
103
|
+
const tweetUser = tweet?.core?.user_results?.result;
|
|
104
|
+
author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
|
|
99
105
|
text = tweet?.note_tweet?.note_tweet_results?.result?.text || tweet?.legacy?.full_text || item.message?.text || '';
|
|
100
106
|
actionText = 'Mention/Reply';
|
|
101
107
|
urlStr = `https://x.com/i/status/${tweet?.rest_id}`;
|
|
102
108
|
} else if (item.__typename === 'Tweet') {
|
|
103
|
-
|
|
109
|
+
const tweetUser = item.core?.user_results?.result;
|
|
110
|
+
author = tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown';
|
|
104
111
|
text = item.note_tweet?.note_tweet_results?.result?.text || item.legacy?.full_text || '';
|
|
105
112
|
actionText = 'Mention';
|
|
106
113
|
urlStr = `https://x.com/i/status/${item.rest_id}`;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'twitter',
|
|
6
|
+
name: 'reply-dm',
|
|
7
|
+
description: 'Send a message to recent DM conversations',
|
|
8
|
+
domain: 'x.com',
|
|
9
|
+
strategy: Strategy.UI,
|
|
10
|
+
browser: true,
|
|
11
|
+
timeoutSeconds: 600, // 10 min — batch operation
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'text', type: 'string', required: true, help: 'Message text to send (e.g. "我的微信 wxkabi")' },
|
|
14
|
+
{ name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of conversations to reply to (default: 20)' },
|
|
15
|
+
{ name: 'skip-replied', type: 'boolean', required: false, default: true, help: 'Skip conversations where you already sent the same text (default: true)' },
|
|
16
|
+
],
|
|
17
|
+
columns: ['index', 'status', 'user', 'message'],
|
|
18
|
+
func: async (page: IPage | null, kwargs: any) => {
|
|
19
|
+
if (!page) throw new Error('Requires browser');
|
|
20
|
+
|
|
21
|
+
const messageText: string = kwargs.text;
|
|
22
|
+
const maxSend: number = kwargs.max ?? 20;
|
|
23
|
+
const skipReplied: boolean = kwargs['skip-replied'] !== false;
|
|
24
|
+
const results: Array<{ index: number; status: string; user: string; message: string }> = [];
|
|
25
|
+
let sentCount = 0;
|
|
26
|
+
|
|
27
|
+
// Step 1: Navigate to messages to get conversation list
|
|
28
|
+
await page.goto('https://x.com/messages');
|
|
29
|
+
await page.wait(5);
|
|
30
|
+
|
|
31
|
+
// Step 2: Collect conversations with scroll-to-load
|
|
32
|
+
const needed = maxSend + 10; // extra buffer for skips
|
|
33
|
+
const convList = await page.evaluate(`(async () => {
|
|
34
|
+
try {
|
|
35
|
+
// Wait for initial items
|
|
36
|
+
let attempts = 0;
|
|
37
|
+
while (attempts < 10) {
|
|
38
|
+
const items = document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]');
|
|
39
|
+
if (items.length > 0) break;
|
|
40
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
41
|
+
attempts++;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Scroll to load more conversations
|
|
45
|
+
const needed = ${needed};
|
|
46
|
+
const seenIds = new Set();
|
|
47
|
+
let noNewCount = 0;
|
|
48
|
+
|
|
49
|
+
for (let scroll = 0; scroll < 30; scroll++) {
|
|
50
|
+
const items = Array.from(document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]'));
|
|
51
|
+
items.forEach(el => seenIds.add(el.getAttribute('data-testid')));
|
|
52
|
+
|
|
53
|
+
if (seenIds.size >= needed) break;
|
|
54
|
+
|
|
55
|
+
// Find the scrollable container and scroll it
|
|
56
|
+
const scrollContainer = document.querySelector('[data-testid="dm-inbox-panel"]') ||
|
|
57
|
+
items[items.length - 1]?.closest('[class*="scroll"]') ||
|
|
58
|
+
items[items.length - 1]?.parentElement;
|
|
59
|
+
if (scrollContainer) {
|
|
60
|
+
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
61
|
+
}
|
|
62
|
+
// Also try scrolling the last item into view
|
|
63
|
+
if (items.length > 0) {
|
|
64
|
+
items[items.length - 1].scrollIntoView({ behavior: 'instant', block: 'end' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
68
|
+
|
|
69
|
+
// Check if new items appeared
|
|
70
|
+
const newItems = Array.from(document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]'));
|
|
71
|
+
const newIds = new Set(newItems.map(el => el.getAttribute('data-testid')));
|
|
72
|
+
if (newIds.size <= seenIds.size) {
|
|
73
|
+
noNewCount++;
|
|
74
|
+
if (noNewCount >= 3) break; // No more loading after 3 tries
|
|
75
|
+
} else {
|
|
76
|
+
noNewCount = 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Collect all visible conversations
|
|
81
|
+
const finalItems = Array.from(document.querySelectorAll('[data-testid^="dm-conversation-item-"], [data-testid="conversation"]'));
|
|
82
|
+
const conversations = finalItems.map((item, idx) => {
|
|
83
|
+
const testId = item.getAttribute('data-testid') || '';
|
|
84
|
+
const text = item.innerText || '';
|
|
85
|
+
const lines = text.split('\\n').filter(l => l.trim());
|
|
86
|
+
const user = lines[0] || 'Unknown';
|
|
87
|
+
const match = testId.match(/dm-conversation-item-(.+)/);
|
|
88
|
+
const convId = match ? match[1].replace(':', '-') : '';
|
|
89
|
+
const link = item.querySelector('a[href*="/messages/"]');
|
|
90
|
+
const href = link ? link.href : '';
|
|
91
|
+
return { idx, user, convId, href, preview: text.substring(0, 100) };
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return { ok: true, conversations, total: conversations.length };
|
|
95
|
+
} catch(e) {
|
|
96
|
+
return { ok: false, error: String(e), conversations: [], total: 0 };
|
|
97
|
+
}
|
|
98
|
+
})()`);
|
|
99
|
+
|
|
100
|
+
if (!convList?.ok || !convList.conversations?.length) {
|
|
101
|
+
return [{ index: 1, status: 'info', user: 'System', message: 'No conversations found' }];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const conversations = convList.conversations;
|
|
105
|
+
|
|
106
|
+
// Step 3: Iterate through conversations and send message
|
|
107
|
+
for (const conv of conversations) {
|
|
108
|
+
if (sentCount >= maxSend) break;
|
|
109
|
+
|
|
110
|
+
const convUrl = conv.convId
|
|
111
|
+
? `https://x.com/messages/${conv.convId}`
|
|
112
|
+
: conv.href;
|
|
113
|
+
|
|
114
|
+
if (!convUrl) continue;
|
|
115
|
+
|
|
116
|
+
await page.goto(convUrl);
|
|
117
|
+
await page.wait(3);
|
|
118
|
+
|
|
119
|
+
const sendResult = await page.evaluate(`(async () => {
|
|
120
|
+
try {
|
|
121
|
+
const messageText = ${JSON.stringify(messageText)};
|
|
122
|
+
const skipReplied = ${skipReplied};
|
|
123
|
+
|
|
124
|
+
// Get username from conversation
|
|
125
|
+
const dmHeader = document.querySelector('[data-testid="DmActivityContainer"] [dir="ltr"] span') ||
|
|
126
|
+
document.querySelector('[data-testid="conversation-header"]') ||
|
|
127
|
+
document.querySelector('[data-testid="DmActivityContainer"] h2');
|
|
128
|
+
const username = dmHeader ? dmHeader.innerText.trim().split('\\\\n')[0] : '${conv.user}';
|
|
129
|
+
|
|
130
|
+
// Check if we already sent this message
|
|
131
|
+
if (skipReplied) {
|
|
132
|
+
const chatArea = document.querySelector('[data-testid="DmScrollerContainer"]') ||
|
|
133
|
+
document.querySelector('main');
|
|
134
|
+
const chatText = chatArea ? chatArea.innerText : '';
|
|
135
|
+
if (chatText.includes(messageText)) {
|
|
136
|
+
return { status: 'skipped', user: username, message: 'Already sent this message' };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Find the text input
|
|
141
|
+
const input = document.querySelector('[data-testid="dmComposerTextInput"]');
|
|
142
|
+
if (!input) {
|
|
143
|
+
return { status: 'error', user: username, message: 'No message input found' };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Focus and type into the DraftEditor
|
|
147
|
+
input.focus();
|
|
148
|
+
await new Promise(r => setTimeout(r, 300));
|
|
149
|
+
document.execCommand('insertText', false, messageText);
|
|
150
|
+
await new Promise(r => setTimeout(r, 500));
|
|
151
|
+
|
|
152
|
+
// Click send button
|
|
153
|
+
const sendBtn = document.querySelector('[data-testid="dmComposerSendButton"]');
|
|
154
|
+
if (!sendBtn) {
|
|
155
|
+
return { status: 'error', user: username, message: 'No send button found' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
sendBtn.click();
|
|
159
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
160
|
+
|
|
161
|
+
return { status: 'sent', user: username, message: 'Message sent: ' + messageText };
|
|
162
|
+
} catch(e) {
|
|
163
|
+
return { status: 'error', user: 'system', message: String(e) };
|
|
164
|
+
}
|
|
165
|
+
})()`);
|
|
166
|
+
|
|
167
|
+
if (sendResult?.status === 'sent') {
|
|
168
|
+
sentCount++;
|
|
169
|
+
results.push({
|
|
170
|
+
index: sentCount,
|
|
171
|
+
status: 'sent',
|
|
172
|
+
user: sendResult.user || conv.user,
|
|
173
|
+
message: sendResult.message,
|
|
174
|
+
});
|
|
175
|
+
} else if (sendResult?.status === 'skipped') {
|
|
176
|
+
results.push({
|
|
177
|
+
index: results.length + 1,
|
|
178
|
+
status: 'skipped',
|
|
179
|
+
user: sendResult.user || conv.user,
|
|
180
|
+
message: sendResult.message,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await page.wait(1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (results.length === 0) {
|
|
188
|
+
results.push({ index: 0, status: 'info', user: 'System', message: 'No conversations processed' });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return results;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
@@ -13,21 +13,59 @@ cli({
|
|
|
13
13
|
],
|
|
14
14
|
columns: ['id', 'author', 'text', 'likes', 'views', 'url'],
|
|
15
15
|
func: async (page, kwargs) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
await page.
|
|
16
|
+
const query = kwargs.query;
|
|
17
|
+
|
|
18
|
+
// 1. Navigate to x.com/explore (has a search input at the top)
|
|
19
|
+
await page.goto('https://x.com/explore');
|
|
20
|
+
await page.wait(3);
|
|
21
|
+
|
|
22
|
+
// 2. Install interceptor BEFORE triggering search.
|
|
23
|
+
// SPA navigation preserves the JS context, so the monkey-patched
|
|
24
|
+
// fetch will capture the SearchTimeline API call.
|
|
20
25
|
await page.installInterceptor('SearchTimeline');
|
|
21
26
|
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
await page.
|
|
27
|
+
// 3. Use the search input to submit the query (SPA, no full reload).
|
|
28
|
+
// Find the search input, type the query, and submit.
|
|
29
|
+
await page.evaluate(`
|
|
30
|
+
(() => {
|
|
31
|
+
const input = document.querySelector('input[data-testid="SearchBox_Search_Input"]');
|
|
32
|
+
if (!input) throw new Error('Search input not found');
|
|
33
|
+
input.focus();
|
|
34
|
+
const nativeSetter = Object.getOwnPropertyDescriptor(
|
|
35
|
+
HTMLInputElement.prototype, 'value'
|
|
36
|
+
).set;
|
|
37
|
+
nativeSetter.call(input, ${JSON.stringify(query)});
|
|
38
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
39
|
+
})()
|
|
40
|
+
`);
|
|
41
|
+
await page.wait(0.5);
|
|
42
|
+
// Press Enter to submit
|
|
43
|
+
await page.evaluate(`
|
|
44
|
+
(() => {
|
|
45
|
+
const input = document.querySelector('input[data-testid="SearchBox_Search_Input"]');
|
|
46
|
+
if (!input) throw new Error('Search input not found');
|
|
47
|
+
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
|
48
|
+
})()
|
|
49
|
+
`);
|
|
25
50
|
await page.wait(5);
|
|
26
51
|
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
52
|
+
// 4. Click "Top" tab if available (ensures we get top results)
|
|
53
|
+
try {
|
|
54
|
+
await page.evaluate(`
|
|
55
|
+
(() => {
|
|
56
|
+
const tabs = document.querySelectorAll('[role="tab"]');
|
|
57
|
+
for (const tab of tabs) {
|
|
58
|
+
if (tab.textContent.trim() === 'Top') { tab.click(); break; }
|
|
59
|
+
}
|
|
60
|
+
})()
|
|
61
|
+
`);
|
|
62
|
+
await page.wait(2);
|
|
63
|
+
} catch { /* ignore if tab not found */ }
|
|
64
|
+
|
|
65
|
+
// 5. Scroll to trigger additional pagination
|
|
66
|
+
await page.autoScroll({ times: 2, delayMs: 2000 });
|
|
67
|
+
|
|
68
|
+
// 6. Retrieve captured data
|
|
31
69
|
const requests = await page.getInterceptedRequests();
|
|
32
70
|
if (!requests || requests.length === 0) return [];
|
|
33
71
|
|
|
@@ -35,7 +73,7 @@ cli({
|
|
|
35
73
|
const seen = new Set<string>();
|
|
36
74
|
for (const req of requests) {
|
|
37
75
|
try {
|
|
38
|
-
const insts = req
|
|
76
|
+
const insts = req?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
|
|
39
77
|
const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries')
|
|
40
78
|
|| insts.find((i: any) => i.entries && Array.isArray(i.entries));
|
|
41
79
|
if (!addEntries?.entries) continue;
|
|
@@ -53,9 +91,11 @@ cli({
|
|
|
53
91
|
if (!tweet.rest_id || seen.has(tweet.rest_id)) continue;
|
|
54
92
|
seen.add(tweet.rest_id);
|
|
55
93
|
|
|
94
|
+
// Twitter moved screen_name from legacy to core
|
|
95
|
+
const tweetUser = tweet.core?.user_results?.result;
|
|
56
96
|
results.push({
|
|
57
97
|
id: tweet.rest_id,
|
|
58
|
-
author:
|
|
98
|
+
author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown',
|
|
59
99
|
text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
|
|
60
100
|
likes: tweet.legacy?.favorite_count || 0,
|
|
61
101
|
views: tweet.views?.count || '0',
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
import { fetchWithPage } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'weread',
|
|
7
|
+
name: 'book',
|
|
8
|
+
description: 'View book details on WeRead',
|
|
9
|
+
domain: 'weread.qq.com',
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'bookId', positional: true, required: true, help: 'Book ID (numeric, from search or shelf results)' },
|
|
13
|
+
],
|
|
14
|
+
columns: ['title', 'author', 'publisher', 'intro', 'category', 'rating'],
|
|
15
|
+
func: async (page: IPage, args) => {
|
|
16
|
+
const data = await fetchWithPage(page, '/book/info', { bookId: args.bookId });
|
|
17
|
+
// newRating is 0-1000 scale per community docs; needs runtime verification
|
|
18
|
+
const rating = data.newRating ? `${(data.newRating / 10).toFixed(1)}%` : '-';
|
|
19
|
+
return [{
|
|
20
|
+
title: data.title ?? '',
|
|
21
|
+
author: data.author ?? '',
|
|
22
|
+
publisher: data.publisher ?? '',
|
|
23
|
+
intro: data.intro ?? '',
|
|
24
|
+
category: data.category ?? '',
|
|
25
|
+
rating,
|
|
26
|
+
}];
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
import { fetchWithPage, formatDate } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'weread',
|
|
7
|
+
name: 'highlights',
|
|
8
|
+
description: 'List your highlights (underlines) in a book',
|
|
9
|
+
domain: 'weread.qq.com',
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'bookId', positional: true, required: true, help: 'Book ID (from shelf or search results)' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['chapter', 'text', 'createTime'],
|
|
16
|
+
func: async (page: IPage, args) => {
|
|
17
|
+
const data = await fetchWithPage(page, '/book/bookmarklist', { bookId: args.bookId });
|
|
18
|
+
const items: any[] = data?.updated ?? [];
|
|
19
|
+
return items.slice(0, Number(args.limit)).map((item: any) => ({
|
|
20
|
+
chapter: item.chapterName ?? '',
|
|
21
|
+
text: item.markText ?? '',
|
|
22
|
+
createTime: formatDate(item.createTime),
|
|
23
|
+
}));
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
import { fetchWithPage } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'weread',
|
|
7
|
+
name: 'notebooks',
|
|
8
|
+
description: 'List books that have highlights or notes',
|
|
9
|
+
domain: 'weread.qq.com',
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
columns: ['title', 'author', 'noteCount', 'bookId'],
|
|
12
|
+
func: async (page: IPage, _args) => {
|
|
13
|
+
const data = await fetchWithPage(page, '/user/notebooks');
|
|
14
|
+
const books: any[] = data?.books ?? [];
|
|
15
|
+
return books.map((item: any) => ({
|
|
16
|
+
title: item.book?.title ?? '',
|
|
17
|
+
author: item.book?.author ?? '',
|
|
18
|
+
// TODO: bookmarkCount/reviewCount field names from community docs, verify with real API
|
|
19
|
+
noteCount: (item.bookmarkCount ?? 0) + (item.reviewCount ?? 0),
|
|
20
|
+
bookId: item.bookId ?? '',
|
|
21
|
+
}));
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
import { fetchWithPage, formatDate } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'weread',
|
|
7
|
+
name: 'notes',
|
|
8
|
+
description: 'List your notes (thoughts) on a book',
|
|
9
|
+
domain: 'weread.qq.com',
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'bookId', positional: true, required: true, help: 'Book ID (from shelf or search results)' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['chapter', 'text', 'review', 'createTime'],
|
|
16
|
+
func: async (page: IPage, args) => {
|
|
17
|
+
const data = await fetchWithPage(page, '/review/list', {
|
|
18
|
+
bookId: args.bookId,
|
|
19
|
+
listType: '11',
|
|
20
|
+
mine: '1',
|
|
21
|
+
synckey: '0',
|
|
22
|
+
});
|
|
23
|
+
const items: any[] = data?.reviews ?? [];
|
|
24
|
+
return items.slice(0, Number(args.limit)).map((item: any) => ({
|
|
25
|
+
chapter: item.review?.chapterName ?? '',
|
|
26
|
+
text: item.review?.abstract ?? '',
|
|
27
|
+
review: item.review?.content ?? '',
|
|
28
|
+
createTime: formatDate(item.review?.createTime),
|
|
29
|
+
}));
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { fetchWebApi } from './utils.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'weread',
|
|
6
|
+
name: 'ranking',
|
|
7
|
+
description: 'WeRead book rankings by category',
|
|
8
|
+
domain: 'weread.qq.com',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: false,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'category', positional: true, default: 'all', help: 'Category: all (default), rising, or numeric category ID' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['rank', 'title', 'author', 'category', 'readingCount', 'bookId'],
|
|
16
|
+
func: async (_page, args) => {
|
|
17
|
+
const cat = encodeURIComponent(args.category ?? 'all');
|
|
18
|
+
const data = await fetchWebApi(`/bookListInCategory/${cat}`, { rank: '1' });
|
|
19
|
+
const books: any[] = data?.books ?? [];
|
|
20
|
+
return books.slice(0, Number(args.limit)).map((item: any, i: number) => ({
|
|
21
|
+
rank: i + 1,
|
|
22
|
+
title: item.bookInfo?.title ?? '',
|
|
23
|
+
author: item.bookInfo?.author ?? '',
|
|
24
|
+
category: item.bookInfo?.category ?? '',
|
|
25
|
+
readingCount: item.readingCount ?? 0,
|
|
26
|
+
bookId: item.bookInfo?.bookId ?? '',
|
|
27
|
+
}));
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { fetchWebApi } from './utils.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'weread',
|
|
6
|
+
name: 'search',
|
|
7
|
+
description: 'Search books on WeRead',
|
|
8
|
+
domain: 'weread.qq.com',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: false,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'keyword', positional: true, required: true, help: 'Search keyword' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['rank', 'title', 'author', 'bookId'],
|
|
16
|
+
func: async (_page, args) => {
|
|
17
|
+
const data = await fetchWebApi('/search/global', { keyword: args.keyword });
|
|
18
|
+
const books: any[] = data?.books ?? [];
|
|
19
|
+
return books.slice(0, Number(args.limit)).map((item: any, i: number) => ({
|
|
20
|
+
rank: i + 1,
|
|
21
|
+
title: item.bookInfo?.title ?? '',
|
|
22
|
+
author: item.bookInfo?.author ?? '',
|
|
23
|
+
bookId: item.bookInfo?.bookId ?? '',
|
|
24
|
+
}));
|
|
25
|
+
},
|
|
26
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import type { IPage } from '../../types.js';
|
|
3
|
+
import { fetchWithPage } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'weread',
|
|
7
|
+
name: 'shelf',
|
|
8
|
+
description: 'List books on your WeRead bookshelf',
|
|
9
|
+
domain: 'weread.qq.com',
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results' },
|
|
13
|
+
],
|
|
14
|
+
columns: ['title', 'author', 'progress', 'bookId'],
|
|
15
|
+
func: async (page: IPage, args) => {
|
|
16
|
+
const data = await fetchWithPage(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
|
|
17
|
+
const books: any[] = data?.books ?? [];
|
|
18
|
+
return books.slice(0, Number(args.limit)).map((item: any) => ({
|
|
19
|
+
title: item.bookInfo?.title ?? item.title ?? '',
|
|
20
|
+
author: item.bookInfo?.author ?? item.author ?? '',
|
|
21
|
+
// TODO: readingProgress field name from community docs, verify with real API response
|
|
22
|
+
progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
|
|
23
|
+
bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
|
|
24
|
+
}));
|
|
25
|
+
},
|
|
26
|
+
});
|