@jackwener/opencli 1.0.0 → 1.0.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.
Files changed (171) hide show
  1. package/.github/workflows/build-extension.yml +62 -0
  2. package/.github/workflows/ci.yml +6 -6
  3. package/.github/workflows/e2e-headed.yml +2 -2
  4. package/.github/workflows/pkg-pr-new.yml +2 -2
  5. package/.github/workflows/release.yml +2 -5
  6. package/.github/workflows/security.yml +2 -2
  7. package/CDP.md +1 -1
  8. package/CDP.zh-CN.md +1 -1
  9. package/README.md +35 -8
  10. package/README.zh-CN.md +35 -8
  11. package/SKILL.md +3 -5
  12. package/dist/browser/cdp.d.ts +27 -0
  13. package/dist/browser/cdp.js +295 -0
  14. package/dist/browser/daemon-client.d.ts +1 -1
  15. package/dist/browser/index.d.ts +4 -2
  16. package/dist/browser/index.js +5 -5
  17. package/dist/browser/mcp.d.ts +5 -8
  18. package/dist/browser/mcp.js +9 -10
  19. package/dist/browser/page.d.ts +8 -1
  20. package/dist/browser/page.js +25 -40
  21. package/dist/browser/utils.d.ts +10 -0
  22. package/dist/browser/utils.js +27 -0
  23. package/dist/browser.test.js +48 -7
  24. package/dist/chaoxing.d.ts +58 -0
  25. package/dist/chaoxing.js +225 -0
  26. package/dist/chaoxing.test.d.ts +1 -0
  27. package/dist/chaoxing.test.js +38 -0
  28. package/dist/cli-manifest.json +597 -14
  29. package/dist/cli.d.ts +1 -0
  30. package/dist/cli.js +197 -0
  31. package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
  32. package/dist/clis/apple-podcasts/episodes.js +28 -0
  33. package/dist/clis/apple-podcasts/search.d.ts +1 -0
  34. package/dist/clis/apple-podcasts/search.js +29 -0
  35. package/dist/clis/apple-podcasts/top.d.ts +1 -0
  36. package/dist/clis/apple-podcasts/top.js +34 -0
  37. package/dist/clis/apple-podcasts/utils.d.ts +11 -0
  38. package/dist/clis/apple-podcasts/utils.js +30 -0
  39. package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
  40. package/dist/clis/apple-podcasts/utils.test.js +57 -0
  41. package/dist/clis/boss/chatlist.d.ts +1 -0
  42. package/dist/clis/boss/chatlist.js +50 -0
  43. package/dist/clis/boss/chatmsg.d.ts +1 -0
  44. package/dist/clis/boss/chatmsg.js +73 -0
  45. package/dist/clis/boss/send.d.ts +1 -0
  46. package/dist/clis/boss/send.js +176 -0
  47. package/dist/clis/chaoxing/assignments.d.ts +1 -0
  48. package/dist/clis/chaoxing/assignments.js +74 -0
  49. package/dist/clis/chaoxing/exams.d.ts +1 -0
  50. package/dist/clis/chaoxing/exams.js +74 -0
  51. package/dist/clis/chatgpt/ask.js +15 -14
  52. package/dist/clis/chatgpt/ax.d.ts +1 -0
  53. package/dist/clis/chatgpt/ax.js +78 -0
  54. package/dist/clis/chatgpt/read.js +5 -6
  55. package/dist/clis/chatwise/history.js +18 -1
  56. package/dist/clis/discord-app/channels.js +33 -21
  57. package/dist/clis/twitter/accept.d.ts +1 -0
  58. package/dist/clis/twitter/accept.js +202 -0
  59. package/dist/clis/twitter/followers.js +30 -22
  60. package/dist/clis/twitter/following.js +19 -14
  61. package/dist/clis/twitter/notifications.js +29 -22
  62. package/dist/clis/twitter/post.js +9 -2
  63. package/dist/clis/twitter/reply-dm.d.ts +1 -0
  64. package/dist/clis/twitter/reply-dm.js +181 -0
  65. package/dist/clis/twitter/search.js +30 -11
  66. package/dist/clis/weread/book.d.ts +1 -0
  67. package/dist/clis/weread/book.js +26 -0
  68. package/dist/clis/weread/highlights.d.ts +1 -0
  69. package/dist/clis/weread/highlights.js +23 -0
  70. package/dist/clis/weread/notebooks.d.ts +1 -0
  71. package/dist/clis/weread/notebooks.js +21 -0
  72. package/dist/clis/weread/notes.d.ts +1 -0
  73. package/dist/clis/weread/notes.js +29 -0
  74. package/dist/clis/weread/ranking.d.ts +1 -0
  75. package/dist/clis/weread/ranking.js +28 -0
  76. package/dist/clis/weread/search.d.ts +1 -0
  77. package/dist/clis/weread/search.js +25 -0
  78. package/dist/clis/weread/shelf.d.ts +1 -0
  79. package/dist/clis/weread/shelf.js +24 -0
  80. package/dist/clis/weread/utils.d.ts +20 -0
  81. package/dist/clis/weread/utils.js +72 -0
  82. package/dist/clis/weread/utils.test.d.ts +1 -0
  83. package/dist/clis/weread/utils.test.js +85 -0
  84. package/dist/clis/xiaohongshu/download.d.ts +1 -1
  85. package/dist/clis/xiaohongshu/download.js +1 -1
  86. package/dist/daemon.js +2 -2
  87. package/dist/doctor.d.ts +0 -21
  88. package/dist/doctor.js +2 -24
  89. package/dist/engine.js +24 -13
  90. package/dist/explore.js +46 -101
  91. package/dist/main.js +4 -203
  92. package/dist/output.d.ts +1 -1
  93. package/dist/registry.d.ts +3 -3
  94. package/dist/runtime.d.ts +1 -4
  95. package/dist/runtime.js +1 -4
  96. package/dist/scripts/framework.d.ts +4 -0
  97. package/dist/scripts/framework.js +21 -0
  98. package/dist/scripts/interact.d.ts +4 -0
  99. package/dist/scripts/interact.js +20 -0
  100. package/dist/scripts/store.d.ts +9 -0
  101. package/dist/scripts/store.js +44 -0
  102. package/dist/setup.js +2 -2
  103. package/dist/synthesize.js +1 -1
  104. package/extension/dist/background.js +392 -0
  105. package/extension/manifest.json +3 -3
  106. package/extension/package.json +1 -1
  107. package/extension/src/background.ts +101 -24
  108. package/extension/src/protocol.ts +1 -1
  109. package/package.json +1 -1
  110. package/src/browser/cdp.ts +295 -0
  111. package/src/browser/daemon-client.ts +1 -1
  112. package/src/browser/index.ts +5 -6
  113. package/src/browser/mcp.ts +14 -15
  114. package/src/browser/page.ts +25 -41
  115. package/src/browser/utils.ts +27 -0
  116. package/src/browser.test.ts +52 -6
  117. package/src/chaoxing.test.ts +45 -0
  118. package/src/chaoxing.ts +268 -0
  119. package/src/cli.ts +185 -0
  120. package/src/clis/antigravity/SKILL.md +5 -0
  121. package/src/clis/apple-podcasts/episodes.ts +28 -0
  122. package/src/clis/apple-podcasts/search.ts +29 -0
  123. package/src/clis/apple-podcasts/top.ts +34 -0
  124. package/src/clis/apple-podcasts/utils.test.ts +72 -0
  125. package/src/clis/apple-podcasts/utils.ts +37 -0
  126. package/src/clis/boss/chatlist.ts +50 -0
  127. package/src/clis/boss/chatmsg.ts +70 -0
  128. package/src/clis/boss/send.ts +193 -0
  129. package/src/clis/chaoxing/README.md +36 -0
  130. package/src/clis/chaoxing/README.zh-CN.md +35 -0
  131. package/src/clis/chaoxing/assignments.ts +88 -0
  132. package/src/clis/chaoxing/exams.ts +88 -0
  133. package/src/clis/chatgpt/ask.ts +14 -15
  134. package/src/clis/chatgpt/ax.ts +81 -0
  135. package/src/clis/chatgpt/read.ts +5 -7
  136. package/src/clis/chatwise/history.ts +15 -1
  137. package/src/clis/discord-app/channels.ts +33 -21
  138. package/src/clis/twitter/accept.ts +213 -0
  139. package/src/clis/twitter/followers.ts +36 -29
  140. package/src/clis/twitter/following.ts +25 -20
  141. package/src/clis/twitter/notifications.ts +34 -27
  142. package/src/clis/twitter/post.ts +9 -2
  143. package/src/clis/twitter/reply-dm.ts +193 -0
  144. package/src/clis/twitter/search.ts +34 -12
  145. package/src/clis/weread/book.ts +28 -0
  146. package/src/clis/weread/highlights.ts +25 -0
  147. package/src/clis/weread/notebooks.ts +23 -0
  148. package/src/clis/weread/notes.ts +31 -0
  149. package/src/clis/weread/ranking.ts +29 -0
  150. package/src/clis/weread/search.ts +26 -0
  151. package/src/clis/weread/shelf.ts +26 -0
  152. package/src/clis/weread/utils.test.ts +104 -0
  153. package/src/clis/weread/utils.ts +74 -0
  154. package/src/clis/xiaohongshu/download.ts +1 -1
  155. package/src/daemon.ts +2 -2
  156. package/src/doctor.ts +2 -19
  157. package/src/engine.ts +20 -13
  158. package/src/explore.ts +51 -100
  159. package/src/main.ts +4 -186
  160. package/src/output.ts +12 -12
  161. package/src/registry.ts +3 -3
  162. package/src/runtime.ts +2 -6
  163. package/src/scripts/framework.ts +20 -0
  164. package/src/scripts/interact.ts +22 -0
  165. package/src/scripts/store.ts +40 -0
  166. package/src/setup.ts +2 -2
  167. package/src/synthesize.ts +1 -1
  168. package/tests/e2e/public-commands.test.ts +68 -1
  169. package/dist/clis/grok/debug.d.ts +0 -1
  170. package/dist/clis/grok/debug.js +0 -45
  171. package/src/clis/grok/debug.ts +0 -49
@@ -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,41 @@ 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. Trigger SPA navigation to search results via history API.
28
+ // pushState + popstate triggers React Router's listener without
29
+ // a full page reload, so the interceptor stays alive.
30
+ // Note: the previous approach (nativeSetter + Enter keydown on the
31
+ // search input) does not reliably trigger Twitter's form submission.
32
+ const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=top`);
33
+ await page.evaluate(`
34
+ (() => {
35
+ window.history.pushState({}, '', ${searchUrl});
36
+ window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
37
+ })()
38
+ `);
25
39
  await page.wait(5);
26
40
 
27
- // 3. Trigger API by scrolling
41
+ // Verify SPA navigation succeeded
42
+ const currentPath = await page.evaluate('() => window.location.pathname');
43
+ if (!currentPath?.startsWith('/search')) {
44
+ throw new Error('SPA navigation to /search failed. Twitter may have changed its routing.');
45
+ }
46
+
47
+ // 4. Scroll to trigger additional pagination
28
48
  await page.autoScroll({ times: 3, delayMs: 2000 });
29
-
30
- // 4. Retrieve data
49
+
50
+ // 6. Retrieve captured data
31
51
  const requests = await page.getInterceptedRequests();
32
52
  if (!requests || requests.length === 0) return [];
33
53
 
@@ -35,7 +55,7 @@ cli({
35
55
  const seen = new Set<string>();
36
56
  for (const req of requests) {
37
57
  try {
38
- const insts = req.data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
58
+ const insts = req?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
39
59
  const addEntries = insts.find((i: any) => i.type === 'TimelineAddEntries')
40
60
  || insts.find((i: any) => i.entries && Array.isArray(i.entries));
41
61
  if (!addEntries?.entries) continue;
@@ -53,9 +73,11 @@ cli({
53
73
  if (!tweet.rest_id || seen.has(tweet.rest_id)) continue;
54
74
  seen.add(tweet.rest_id);
55
75
 
76
+ // Twitter moved screen_name from legacy to core
77
+ const tweetUser = tweet.core?.user_results?.result;
56
78
  results.push({
57
79
  id: tweet.rest_id,
58
- author: tweet.core?.user_results?.result?.legacy?.screen_name || 'unknown',
80
+ author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown',
59
81
  text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
60
82
  likes: tweet.legacy?.favorite_count || 0,
61
83
  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
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { formatDate, fetchWebApi, fetchWithPage } from './utils.js';
3
+
4
+ describe('formatDate', () => {
5
+ it('formats a typical Unix timestamp in UTC+8', () => {
6
+ // 1705276800 = 2024-01-15 00:00:00 UTC = 2024-01-15 08:00:00 Beijing
7
+ expect(formatDate(1705276800)).toBe('2024-01-15');
8
+ });
9
+
10
+ it('handles UTC midnight edge case with UTC+8 offset', () => {
11
+ // 1705190399 = 2024-01-13 23:59:59 UTC = 2024-01-14 07:59:59 Beijing
12
+ expect(formatDate(1705190399)).toBe('2024-01-14');
13
+ });
14
+
15
+ it('returns dash for zero', () => {
16
+ expect(formatDate(0)).toBe('-');
17
+ });
18
+
19
+ it('returns dash for negative', () => {
20
+ expect(formatDate(-1)).toBe('-');
21
+ });
22
+
23
+ it('returns dash for NaN', () => {
24
+ expect(formatDate(NaN)).toBe('-');
25
+ });
26
+
27
+ it('returns dash for Infinity', () => {
28
+ expect(formatDate(Infinity)).toBe('-');
29
+ });
30
+
31
+ it('returns dash for undefined', () => {
32
+ expect(formatDate(undefined)).toBe('-');
33
+ });
34
+
35
+ it('returns dash for null', () => {
36
+ expect(formatDate(null)).toBe('-');
37
+ });
38
+ });
39
+
40
+ describe('fetchWebApi', () => {
41
+ beforeEach(() => {
42
+ vi.restoreAllMocks();
43
+ });
44
+
45
+ it('returns parsed JSON for successful response', async () => {
46
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
47
+ ok: true,
48
+ json: () => Promise.resolve({ books: [{ title: 'Test' }] }),
49
+ }));
50
+
51
+ const result = await fetchWebApi('/search/global', { keyword: 'test' });
52
+ expect(result).toEqual({ books: [{ title: 'Test' }] });
53
+ });
54
+
55
+ it('throws CliError on HTTP error', async () => {
56
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
57
+ ok: false,
58
+ status: 403,
59
+ json: () => Promise.resolve({}),
60
+ }));
61
+
62
+ await expect(fetchWebApi('/search/global')).rejects.toThrow('HTTP 403');
63
+ });
64
+
65
+ it('throws PARSE_ERROR on non-JSON response', async () => {
66
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
67
+ ok: true,
68
+ json: () => Promise.reject(new SyntaxError('Unexpected token <')),
69
+ }));
70
+
71
+ await expect(fetchWebApi('/search/global')).rejects.toThrow('Invalid JSON');
72
+ });
73
+ });
74
+
75
+ describe('fetchWithPage', () => {
76
+ it('throws AUTH_REQUIRED on errcode -2010', async () => {
77
+ const mockPage = {
78
+ evaluate: vi.fn().mockResolvedValue({ errcode: -2010, errmsg: '用户不存在' }),
79
+ } as any;
80
+ await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('Not logged in');
81
+ });
82
+
83
+ it('throws API_ERROR on unknown errcode', async () => {
84
+ const mockPage = {
85
+ evaluate: vi.fn().mockResolvedValue({ errcode: -1, errmsg: 'unknown error' }),
86
+ } as any;
87
+ await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('unknown error');
88
+ });
89
+
90
+ it('returns data on success (errcode 0 or absent)', async () => {
91
+ const mockPage = {
92
+ evaluate: vi.fn().mockResolvedValue({ title: 'Test Book', errcode: 0 }),
93
+ } as any;
94
+ const result = await fetchWithPage(mockPage, '/book/info');
95
+ expect(result.title).toBe('Test Book');
96
+ });
97
+
98
+ it('throws FETCH_ERROR on HTTP error', async () => {
99
+ const mockPage = {
100
+ evaluate: vi.fn().mockResolvedValue({ _httpError: '403' }),
101
+ } as any;
102
+ await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('HTTP 403');
103
+ });
104
+ });
@@ -0,0 +1,74 @@
1
+ /**
2
+ * WeRead shared helpers: fetch wrappers and formatting.
3
+ *
4
+ * Two API domains:
5
+ * - WEB_API (weread.qq.com/web/*): public, Node.js fetch
6
+ * - API (i.weread.qq.com/*): private, browser page.evaluate with cookies
7
+ */
8
+
9
+ import { CliError } from '../../errors.js';
10
+ import type { IPage } from '../../types.js';
11
+
12
+ const WEB_API = 'https://weread.qq.com/web';
13
+ const API = 'https://i.weread.qq.com';
14
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
15
+
16
+ /**
17
+ * Fetch a public WeRead web endpoint (Node.js direct fetch).
18
+ * Used by search and ranking commands (browser: false).
19
+ */
20
+ export async function fetchWebApi(path: string, params?: Record<string, string>): Promise<any> {
21
+ const url = new URL(`${WEB_API}${path}`);
22
+ if (params) {
23
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
24
+ }
25
+ const resp = await fetch(url.toString(), {
26
+ headers: { 'User-Agent': UA },
27
+ });
28
+ if (!resp.ok) {
29
+ throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'WeRead API may be temporarily unavailable');
30
+ }
31
+ try {
32
+ return await resp.json();
33
+ } catch {
34
+ throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Fetch a private WeRead API endpoint via browser page.evaluate.
40
+ * Automatically carries cookies for authenticated requests.
41
+ */
42
+ export async function fetchWithPage(page: IPage, path: string, params?: Record<string, string>): Promise<any> {
43
+ const url = new URL(`${API}${path}`);
44
+ if (params) {
45
+ for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
46
+ }
47
+ const urlStr = url.toString();
48
+ const data = await page.evaluate(`
49
+ async () => {
50
+ const res = await fetch(${JSON.stringify(urlStr)}, { credentials: "include" });
51
+ if (!res.ok) return { _httpError: String(res.status) };
52
+ try { return await res.json(); }
53
+ catch { return { _httpError: 'JSON parse error (status ' + res.status + ')' }; }
54
+ }
55
+ `);
56
+ if (data?._httpError) {
57
+ throw new CliError('FETCH_ERROR', `HTTP ${data._httpError} for ${path}`, 'WeRead API may be temporarily unavailable');
58
+ }
59
+ if (data?.errcode === -2010) {
60
+ throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
61
+ }
62
+ if (data?.errcode != null && data.errcode !== 0) {
63
+ throw new CliError('API_ERROR', data.errmsg ?? `WeRead API error ${data.errcode}`);
64
+ }
65
+ return data;
66
+ }
67
+
68
+ /** Format a Unix timestamp (seconds) to YYYY-MM-DD in UTC+8. Returns '-' for invalid input. */
69
+ export function formatDate(ts: number | undefined | null): string {
70
+ if (!Number.isFinite(ts) || (ts as number) <= 0) return '-';
71
+ // WeRead timestamps are China-centric; offset to UTC+8 to avoid off-by-one near midnight
72
+ const d = new Date((ts as number) * 1000 + 8 * 3600_000);
73
+ return d.toISOString().slice(0, 10);
74
+ }
@@ -2,7 +2,7 @@
2
2
  * Xiaohongshu download — download images and videos from a note.
3
3
  *
4
4
  * Usage:
5
- * opencli xiaohongshu download --note-id abc123 --output ./xhs
5
+ * opencli xiaohongshu download --note_id abc123 --output ./xhs
6
6
  */
7
7
 
8
8
  import * as fs from 'node:fs';
package/src/daemon.ts CHANGED
@@ -114,8 +114,8 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
114
114
  const result = await new Promise<unknown>((resolve, reject) => {
115
115
  const timer = setTimeout(() => {
116
116
  pending.delete(body.id);
117
- reject(new Error('Command timeout (30s)'));
118
- }, 30000);
117
+ reject(new Error('Command timeout (120s)'));
118
+ }, 120000);
119
119
  pending.set(body.id, { resolve, reject, timer });
120
120
  extensionWs!.send(JSON.stringify(body));
121
121
  });