@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.
Files changed (165) hide show
  1. package/CDP.md +1 -1
  2. package/CDP.zh-CN.md +1 -1
  3. package/CLI-ELECTRON.md +2 -2
  4. package/CLI-EXPLORER.md +4 -4
  5. package/README.md +35 -58
  6. package/README.zh-CN.md +36 -60
  7. package/SKILL.md +10 -8
  8. package/TESTING.md +7 -7
  9. package/dist/browser/daemon-client.d.ts +37 -0
  10. package/dist/browser/daemon-client.js +82 -0
  11. package/dist/browser/discover.d.ts +11 -34
  12. package/dist/browser/discover.js +15 -205
  13. package/dist/browser/errors.d.ts +6 -20
  14. package/dist/browser/errors.js +24 -63
  15. package/dist/browser/index.d.ts +2 -12
  16. package/dist/browser/index.js +2 -12
  17. package/dist/browser/mcp.d.ts +9 -21
  18. package/dist/browser/mcp.js +70 -285
  19. package/dist/browser/page.d.ts +36 -7
  20. package/dist/browser/page.js +212 -81
  21. package/dist/browser.test.js +10 -231
  22. package/dist/cli-manifest.json +561 -14
  23. package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
  24. package/dist/clis/apple-podcasts/episodes.js +28 -0
  25. package/dist/clis/apple-podcasts/search.d.ts +1 -0
  26. package/dist/clis/apple-podcasts/search.js +29 -0
  27. package/dist/clis/apple-podcasts/top.d.ts +1 -0
  28. package/dist/clis/apple-podcasts/top.js +34 -0
  29. package/dist/clis/apple-podcasts/utils.d.ts +11 -0
  30. package/dist/clis/apple-podcasts/utils.js +30 -0
  31. package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
  32. package/dist/clis/apple-podcasts/utils.test.js +57 -0
  33. package/dist/clis/chatwise/history.js +18 -1
  34. package/dist/clis/discord-app/channels.js +33 -21
  35. package/dist/clis/neteasemusic/like.d.ts +1 -0
  36. package/dist/clis/neteasemusic/like.js +25 -0
  37. package/dist/clis/neteasemusic/lyrics.d.ts +1 -0
  38. package/dist/clis/neteasemusic/lyrics.js +47 -0
  39. package/dist/clis/neteasemusic/next.d.ts +1 -0
  40. package/dist/clis/neteasemusic/next.js +26 -0
  41. package/dist/clis/neteasemusic/play.d.ts +1 -0
  42. package/dist/clis/neteasemusic/play.js +26 -0
  43. package/dist/clis/neteasemusic/playing.d.ts +1 -0
  44. package/dist/clis/neteasemusic/playing.js +59 -0
  45. package/dist/clis/neteasemusic/playlist.d.ts +1 -0
  46. package/dist/clis/neteasemusic/playlist.js +46 -0
  47. package/dist/clis/neteasemusic/prev.d.ts +1 -0
  48. package/dist/clis/neteasemusic/prev.js +25 -0
  49. package/dist/clis/neteasemusic/search.d.ts +1 -0
  50. package/dist/clis/neteasemusic/search.js +52 -0
  51. package/dist/clis/neteasemusic/status.d.ts +1 -0
  52. package/dist/clis/neteasemusic/status.js +16 -0
  53. package/dist/clis/neteasemusic/volume.d.ts +1 -0
  54. package/dist/clis/neteasemusic/volume.js +54 -0
  55. package/dist/clis/twitter/accept.d.ts +1 -0
  56. package/dist/clis/twitter/accept.js +202 -0
  57. package/dist/clis/twitter/followers.js +30 -22
  58. package/dist/clis/twitter/following.js +19 -14
  59. package/dist/clis/twitter/notifications.js +29 -22
  60. package/dist/clis/twitter/reply-dm.d.ts +1 -0
  61. package/dist/clis/twitter/reply-dm.js +181 -0
  62. package/dist/clis/twitter/search.js +50 -12
  63. package/dist/clis/weread/book.d.ts +1 -0
  64. package/dist/clis/weread/book.js +26 -0
  65. package/dist/clis/weread/highlights.d.ts +1 -0
  66. package/dist/clis/weread/highlights.js +23 -0
  67. package/dist/clis/weread/notebooks.d.ts +1 -0
  68. package/dist/clis/weread/notebooks.js +21 -0
  69. package/dist/clis/weread/notes.d.ts +1 -0
  70. package/dist/clis/weread/notes.js +29 -0
  71. package/dist/clis/weread/ranking.d.ts +1 -0
  72. package/dist/clis/weread/ranking.js +28 -0
  73. package/dist/clis/weread/search.d.ts +1 -0
  74. package/dist/clis/weread/search.js +25 -0
  75. package/dist/clis/weread/shelf.d.ts +1 -0
  76. package/dist/clis/weread/shelf.js +24 -0
  77. package/dist/clis/weread/utils.d.ts +20 -0
  78. package/dist/clis/weread/utils.js +72 -0
  79. package/dist/clis/weread/utils.test.d.ts +1 -0
  80. package/dist/clis/weread/utils.test.js +85 -0
  81. package/dist/daemon.d.ts +13 -0
  82. package/dist/daemon.js +187 -0
  83. package/dist/doctor.d.ts +10 -65
  84. package/dist/doctor.js +49 -602
  85. package/dist/doctor.test.js +30 -170
  86. package/dist/main.js +12 -41
  87. package/dist/pipeline/executor.test.js +1 -0
  88. package/dist/pipeline/steps/browser.js +2 -2
  89. package/dist/pipeline/steps/intercept.js +1 -2
  90. package/dist/runtime.d.ts +1 -4
  91. package/dist/runtime.js +1 -4
  92. package/dist/setup.d.ts +6 -0
  93. package/dist/setup.js +46 -160
  94. package/dist/types.d.ts +6 -0
  95. package/extension/dist/background.js +484 -0
  96. package/extension/icons/icon-128.png +0 -0
  97. package/extension/icons/icon-16.png +0 -0
  98. package/extension/icons/icon-32.png +0 -0
  99. package/extension/icons/icon-48.png +0 -0
  100. package/extension/manifest.json +31 -0
  101. package/extension/package.json +16 -0
  102. package/extension/src/background.ts +370 -0
  103. package/extension/src/cdp.ts +125 -0
  104. package/extension/src/protocol.ts +57 -0
  105. package/extension/store-assets/screenshot-1280x800.png +0 -0
  106. package/extension/tsconfig.json +15 -0
  107. package/extension/vite.config.ts +18 -0
  108. package/package.json +5 -5
  109. package/src/browser/daemon-client.ts +113 -0
  110. package/src/browser/discover.ts +18 -232
  111. package/src/browser/errors.ts +30 -100
  112. package/src/browser/index.ts +2 -13
  113. package/src/browser/mcp.ts +81 -282
  114. package/src/browser/page.ts +223 -83
  115. package/src/browser.test.ts +9 -239
  116. package/src/clis/apple-podcasts/episodes.ts +28 -0
  117. package/src/clis/apple-podcasts/search.ts +29 -0
  118. package/src/clis/apple-podcasts/top.ts +34 -0
  119. package/src/clis/apple-podcasts/utils.test.ts +72 -0
  120. package/src/clis/apple-podcasts/utils.ts +37 -0
  121. package/src/clis/chatgpt/README.md +1 -1
  122. package/src/clis/chatgpt/README.zh-CN.md +1 -1
  123. package/src/clis/chatwise/history.ts +15 -1
  124. package/src/clis/discord-app/channels.ts +33 -21
  125. package/src/clis/neteasemusic/README.md +31 -0
  126. package/src/clis/neteasemusic/README.zh-CN.md +31 -0
  127. package/src/clis/neteasemusic/like.ts +28 -0
  128. package/src/clis/neteasemusic/lyrics.ts +53 -0
  129. package/src/clis/neteasemusic/next.ts +30 -0
  130. package/src/clis/neteasemusic/play.ts +30 -0
  131. package/src/clis/neteasemusic/playing.ts +62 -0
  132. package/src/clis/neteasemusic/playlist.ts +51 -0
  133. package/src/clis/neteasemusic/prev.ts +29 -0
  134. package/src/clis/neteasemusic/search.ts +58 -0
  135. package/src/clis/neteasemusic/status.ts +18 -0
  136. package/src/clis/neteasemusic/volume.ts +61 -0
  137. package/src/clis/twitter/accept.ts +213 -0
  138. package/src/clis/twitter/followers.ts +36 -29
  139. package/src/clis/twitter/following.ts +25 -20
  140. package/src/clis/twitter/notifications.ts +34 -27
  141. package/src/clis/twitter/reply-dm.ts +193 -0
  142. package/src/clis/twitter/search.ts +53 -13
  143. package/src/clis/weread/book.ts +28 -0
  144. package/src/clis/weread/highlights.ts +25 -0
  145. package/src/clis/weread/notebooks.ts +23 -0
  146. package/src/clis/weread/notes.ts +31 -0
  147. package/src/clis/weread/ranking.ts +29 -0
  148. package/src/clis/weread/search.ts +26 -0
  149. package/src/clis/weread/shelf.ts +26 -0
  150. package/src/clis/weread/utils.test.ts +104 -0
  151. package/src/clis/weread/utils.ts +74 -0
  152. package/src/daemon.ts +217 -0
  153. package/src/doctor.test.ts +32 -193
  154. package/src/doctor.ts +58 -669
  155. package/src/main.ts +11 -34
  156. package/src/pipeline/executor.test.ts +1 -0
  157. package/src/pipeline/steps/browser.ts +2 -2
  158. package/src/pipeline/steps/intercept.ts +1 -2
  159. package/src/runtime.ts +2 -6
  160. package/src/setup.ts +47 -183
  161. package/src/types.ts +1 -0
  162. package/tests/e2e/public-commands.test.ts +68 -1
  163. package/dist/clis/grok/debug.d.ts +0 -1
  164. package/dist/clis/grok/debug.js +0 -45
  165. 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
- // Install the interceptor before loading the notifications page so we
16
- // capture the initial timeline request triggered during page load.
17
- await page.goto('https://x.com');
18
- await page.wait(2);
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
- // 1. Navigate to notifications
22
- await page.goto('https://x.com/notifications');
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
- // 3. Trigger API by scrolling (if we need to load more)
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
- // 4. Retrieve data
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?.data?.viewer?.timeline_response?.timeline?.instructions) {
38
- instructions = req.data.data.viewer.timeline_response.timeline.instructions;
39
- } else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) {
40
- instructions = req.data.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions;
41
- } else if (req.data?.data?.timeline?.instructions) {
42
- instructions = req.data.data.timeline.instructions;
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
- author = fromUser?.legacy?.screen_name || fromUser?.core?.screen_name || 'unknown';
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
- author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown';
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
- author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown';
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
- // Install the interceptor before opening the target page so we don't miss
17
- // the initial SearchTimeline request fired during hydration.
18
- await page.goto('https://x.com');
19
- await page.wait(2);
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
- // 1. Navigate to the search page
23
- const q = encodeURIComponent(kwargs.query);
24
- await page.goto(`https://x.com/search?q=${q}&f=top`);
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
- // 3. Trigger API by scrolling
28
- await page.autoScroll({ times: 3, delayMs: 2000 });
29
-
30
- // 4. Retrieve data
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.data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
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: tweet.core?.user_results?.result?.legacy?.screen_name || 'unknown',
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
+ });