@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,18 +12,54 @@ cli({
12
12
  ],
13
13
  columns: ['id', 'author', 'text', 'likes', 'views', 'url'],
14
14
  func: async (page, kwargs) => {
15
- // Install the interceptor before opening the target page so we don't miss
16
- // the initial SearchTimeline request fired during hydration.
17
- await page.goto('https://x.com');
18
- await page.wait(2);
15
+ const query = kwargs.query;
16
+ // 1. Navigate to x.com/explore (has a search input at the top)
17
+ await page.goto('https://x.com/explore');
18
+ await page.wait(3);
19
+ // 2. Install interceptor BEFORE triggering search.
20
+ // SPA navigation preserves the JS context, so the monkey-patched
21
+ // fetch will capture the SearchTimeline API call.
19
22
  await page.installInterceptor('SearchTimeline');
20
- // 1. Navigate to the search page
21
- const q = encodeURIComponent(kwargs.query);
22
- await page.goto(`https://x.com/search?q=${q}&f=top`);
23
+ // 3. Use the search input to submit the query (SPA, no full reload).
24
+ // Find the search input, type the query, and submit.
25
+ await page.evaluate(`
26
+ (() => {
27
+ const input = document.querySelector('input[data-testid="SearchBox_Search_Input"]');
28
+ if (!input) throw new Error('Search input not found');
29
+ input.focus();
30
+ const nativeSetter = Object.getOwnPropertyDescriptor(
31
+ HTMLInputElement.prototype, 'value'
32
+ ).set;
33
+ nativeSetter.call(input, ${JSON.stringify(query)});
34
+ input.dispatchEvent(new Event('input', { bubbles: true }));
35
+ })()
36
+ `);
37
+ await page.wait(0.5);
38
+ // Press Enter to submit
39
+ await page.evaluate(`
40
+ (() => {
41
+ const input = document.querySelector('input[data-testid="SearchBox_Search_Input"]');
42
+ if (!input) throw new Error('Search input not found');
43
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
44
+ })()
45
+ `);
23
46
  await page.wait(5);
24
- // 3. Trigger API by scrolling
25
- await page.autoScroll({ times: 3, delayMs: 2000 });
26
- // 4. Retrieve data
47
+ // 4. Click "Top" tab if available (ensures we get top results)
48
+ try {
49
+ await page.evaluate(`
50
+ (() => {
51
+ const tabs = document.querySelectorAll('[role="tab"]');
52
+ for (const tab of tabs) {
53
+ if (tab.textContent.trim() === 'Top') { tab.click(); break; }
54
+ }
55
+ })()
56
+ `);
57
+ await page.wait(2);
58
+ }
59
+ catch { /* ignore if tab not found */ }
60
+ // 5. Scroll to trigger additional pagination
61
+ await page.autoScroll({ times: 2, delayMs: 2000 });
62
+ // 6. Retrieve captured data
27
63
  const requests = await page.getInterceptedRequests();
28
64
  if (!requests || requests.length === 0)
29
65
  return [];
@@ -31,7 +67,7 @@ cli({
31
67
  const seen = new Set();
32
68
  for (const req of requests) {
33
69
  try {
34
- const insts = req.data?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
70
+ const insts = req?.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
35
71
  const addEntries = insts.find((i) => i.type === 'TimelineAddEntries')
36
72
  || insts.find((i) => i.entries && Array.isArray(i.entries));
37
73
  if (!addEntries?.entries)
@@ -49,9 +85,11 @@ cli({
49
85
  if (!tweet.rest_id || seen.has(tweet.rest_id))
50
86
  continue;
51
87
  seen.add(tweet.rest_id);
88
+ // Twitter moved screen_name from legacy to core
89
+ const tweetUser = tweet.core?.user_results?.result;
52
90
  results.push({
53
91
  id: tweet.rest_id,
54
- author: tweet.core?.user_results?.result?.legacy?.screen_name || 'unknown',
92
+ author: tweetUser?.core?.screen_name || tweetUser?.legacy?.screen_name || 'unknown',
55
93
  text: tweet.note_tweet?.note_tweet_results?.result?.text || tweet.legacy?.full_text || '',
56
94
  likes: tweet.legacy?.favorite_count || 0,
57
95
  views: tweet.views?.count || '0',
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { fetchWithPage } from './utils.js';
3
+ cli({
4
+ site: 'weread',
5
+ name: 'book',
6
+ description: 'View book details on WeRead',
7
+ domain: 'weread.qq.com',
8
+ strategy: Strategy.COOKIE,
9
+ args: [
10
+ { name: 'bookId', positional: true, required: true, help: 'Book ID (numeric, from search or shelf results)' },
11
+ ],
12
+ columns: ['title', 'author', 'publisher', 'intro', 'category', 'rating'],
13
+ func: async (page, args) => {
14
+ const data = await fetchWithPage(page, '/book/info', { bookId: args.bookId });
15
+ // newRating is 0-1000 scale per community docs; needs runtime verification
16
+ const rating = data.newRating ? `${(data.newRating / 10).toFixed(1)}%` : '-';
17
+ return [{
18
+ title: data.title ?? '',
19
+ author: data.author ?? '',
20
+ publisher: data.publisher ?? '',
21
+ intro: data.intro ?? '',
22
+ category: data.category ?? '',
23
+ rating,
24
+ }];
25
+ },
26
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { fetchWithPage, formatDate } from './utils.js';
3
+ cli({
4
+ site: 'weread',
5
+ name: 'highlights',
6
+ description: 'List your highlights (underlines) in a book',
7
+ domain: 'weread.qq.com',
8
+ strategy: Strategy.COOKIE,
9
+ args: [
10
+ { name: 'bookId', positional: true, required: true, help: 'Book ID (from shelf or search results)' },
11
+ { name: 'limit', type: 'int', default: 20, help: 'Max results' },
12
+ ],
13
+ columns: ['chapter', 'text', 'createTime'],
14
+ func: async (page, args) => {
15
+ const data = await fetchWithPage(page, '/book/bookmarklist', { bookId: args.bookId });
16
+ const items = data?.updated ?? [];
17
+ return items.slice(0, Number(args.limit)).map((item) => ({
18
+ chapter: item.chapterName ?? '',
19
+ text: item.markText ?? '',
20
+ createTime: formatDate(item.createTime),
21
+ }));
22
+ },
23
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { fetchWithPage } from './utils.js';
3
+ cli({
4
+ site: 'weread',
5
+ name: 'notebooks',
6
+ description: 'List books that have highlights or notes',
7
+ domain: 'weread.qq.com',
8
+ strategy: Strategy.COOKIE,
9
+ columns: ['title', 'author', 'noteCount', 'bookId'],
10
+ func: async (page, _args) => {
11
+ const data = await fetchWithPage(page, '/user/notebooks');
12
+ const books = data?.books ?? [];
13
+ return books.map((item) => ({
14
+ title: item.book?.title ?? '',
15
+ author: item.book?.author ?? '',
16
+ // TODO: bookmarkCount/reviewCount field names from community docs, verify with real API
17
+ noteCount: (item.bookmarkCount ?? 0) + (item.reviewCount ?? 0),
18
+ bookId: item.bookId ?? '',
19
+ }));
20
+ },
21
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { fetchWithPage, formatDate } from './utils.js';
3
+ cli({
4
+ site: 'weread',
5
+ name: 'notes',
6
+ description: 'List your notes (thoughts) on a book',
7
+ domain: 'weread.qq.com',
8
+ strategy: Strategy.COOKIE,
9
+ args: [
10
+ { name: 'bookId', positional: true, required: true, help: 'Book ID (from shelf or search results)' },
11
+ { name: 'limit', type: 'int', default: 20, help: 'Max results' },
12
+ ],
13
+ columns: ['chapter', 'text', 'review', 'createTime'],
14
+ func: async (page, args) => {
15
+ const data = await fetchWithPage(page, '/review/list', {
16
+ bookId: args.bookId,
17
+ listType: '11',
18
+ mine: '1',
19
+ synckey: '0',
20
+ });
21
+ const items = data?.reviews ?? [];
22
+ return items.slice(0, Number(args.limit)).map((item) => ({
23
+ chapter: item.review?.chapterName ?? '',
24
+ text: item.review?.abstract ?? '',
25
+ review: item.review?.content ?? '',
26
+ createTime: formatDate(item.review?.createTime),
27
+ }));
28
+ },
29
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { fetchWebApi } from './utils.js';
3
+ cli({
4
+ site: 'weread',
5
+ name: 'ranking',
6
+ description: 'WeRead book rankings by category',
7
+ domain: 'weread.qq.com',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'category', positional: true, default: 'all', help: 'Category: all (default), rising, or numeric category ID' },
12
+ { name: 'limit', type: 'int', default: 20, help: 'Max results' },
13
+ ],
14
+ columns: ['rank', 'title', 'author', 'category', 'readingCount', 'bookId'],
15
+ func: async (_page, args) => {
16
+ const cat = encodeURIComponent(args.category ?? 'all');
17
+ const data = await fetchWebApi(`/bookListInCategory/${cat}`, { rank: '1' });
18
+ const books = data?.books ?? [];
19
+ return books.slice(0, Number(args.limit)).map((item, i) => ({
20
+ rank: i + 1,
21
+ title: item.bookInfo?.title ?? '',
22
+ author: item.bookInfo?.author ?? '',
23
+ category: item.bookInfo?.category ?? '',
24
+ readingCount: item.readingCount ?? 0,
25
+ bookId: item.bookInfo?.bookId ?? '',
26
+ }));
27
+ },
28
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { fetchWebApi } from './utils.js';
3
+ cli({
4
+ site: 'weread',
5
+ name: 'search',
6
+ description: 'Search books on WeRead',
7
+ domain: 'weread.qq.com',
8
+ strategy: Strategy.PUBLIC,
9
+ browser: false,
10
+ args: [
11
+ { name: 'keyword', positional: true, required: true, help: 'Search keyword' },
12
+ { name: 'limit', type: 'int', default: 10, help: 'Max results' },
13
+ ],
14
+ columns: ['rank', 'title', 'author', 'bookId'],
15
+ func: async (_page, args) => {
16
+ const data = await fetchWebApi('/search/global', { keyword: args.keyword });
17
+ const books = data?.books ?? [];
18
+ return books.slice(0, Number(args.limit)).map((item, i) => ({
19
+ rank: i + 1,
20
+ title: item.bookInfo?.title ?? '',
21
+ author: item.bookInfo?.author ?? '',
22
+ bookId: item.bookInfo?.bookId ?? '',
23
+ }));
24
+ },
25
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { fetchWithPage } from './utils.js';
3
+ cli({
4
+ site: 'weread',
5
+ name: 'shelf',
6
+ description: 'List books on your WeRead bookshelf',
7
+ domain: 'weread.qq.com',
8
+ strategy: Strategy.COOKIE,
9
+ args: [
10
+ { name: 'limit', type: 'int', default: 20, help: 'Max results' },
11
+ ],
12
+ columns: ['title', 'author', 'progress', 'bookId'],
13
+ func: async (page, args) => {
14
+ const data = await fetchWithPage(page, '/shelf/sync', { synckey: '0', lectureSynckey: '0' });
15
+ const books = data?.books ?? [];
16
+ return books.slice(0, Number(args.limit)).map((item) => ({
17
+ title: item.bookInfo?.title ?? item.title ?? '',
18
+ author: item.bookInfo?.author ?? item.author ?? '',
19
+ // TODO: readingProgress field name from community docs, verify with real API response
20
+ progress: item.readingProgress != null ? `${item.readingProgress}%` : '-',
21
+ bookId: item.bookId ?? item.bookInfo?.bookId ?? '',
22
+ }));
23
+ },
24
+ });
@@ -0,0 +1,20 @@
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
+ import type { IPage } from '../../types.js';
9
+ /**
10
+ * Fetch a public WeRead web endpoint (Node.js direct fetch).
11
+ * Used by search and ranking commands (browser: false).
12
+ */
13
+ export declare function fetchWebApi(path: string, params?: Record<string, string>): Promise<any>;
14
+ /**
15
+ * Fetch a private WeRead API endpoint via browser page.evaluate.
16
+ * Automatically carries cookies for authenticated requests.
17
+ */
18
+ export declare function fetchWithPage(page: IPage, path: string, params?: Record<string, string>): Promise<any>;
19
+ /** Format a Unix timestamp (seconds) to YYYY-MM-DD in UTC+8. Returns '-' for invalid input. */
20
+ export declare function formatDate(ts: number | undefined | null): string;
@@ -0,0 +1,72 @@
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
+ import { CliError } from '../../errors.js';
9
+ const WEB_API = 'https://weread.qq.com/web';
10
+ const API = 'https://i.weread.qq.com';
11
+ 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';
12
+ /**
13
+ * Fetch a public WeRead web endpoint (Node.js direct fetch).
14
+ * Used by search and ranking commands (browser: false).
15
+ */
16
+ export async function fetchWebApi(path, params) {
17
+ const url = new URL(`${WEB_API}${path}`);
18
+ if (params) {
19
+ for (const [k, v] of Object.entries(params))
20
+ url.searchParams.set(k, v);
21
+ }
22
+ const resp = await fetch(url.toString(), {
23
+ headers: { 'User-Agent': UA },
24
+ });
25
+ if (!resp.ok) {
26
+ throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'WeRead API may be temporarily unavailable');
27
+ }
28
+ try {
29
+ return await resp.json();
30
+ }
31
+ catch {
32
+ throw new CliError('PARSE_ERROR', `Invalid JSON response for ${path}`, 'WeRead may have returned an HTML error page');
33
+ }
34
+ }
35
+ /**
36
+ * Fetch a private WeRead API endpoint via browser page.evaluate.
37
+ * Automatically carries cookies for authenticated requests.
38
+ */
39
+ export async function fetchWithPage(page, path, params) {
40
+ const url = new URL(`${API}${path}`);
41
+ if (params) {
42
+ for (const [k, v] of Object.entries(params))
43
+ url.searchParams.set(k, v);
44
+ }
45
+ const urlStr = url.toString();
46
+ const data = await page.evaluate(`
47
+ async () => {
48
+ const res = await fetch(${JSON.stringify(urlStr)}, { credentials: "include" });
49
+ if (!res.ok) return { _httpError: String(res.status) };
50
+ try { return await res.json(); }
51
+ catch { return { _httpError: 'JSON parse error (status ' + res.status + ')' }; }
52
+ }
53
+ `);
54
+ if (data?._httpError) {
55
+ throw new CliError('FETCH_ERROR', `HTTP ${data._httpError} for ${path}`, 'WeRead API may be temporarily unavailable');
56
+ }
57
+ if (data?.errcode === -2010) {
58
+ throw new CliError('AUTH_REQUIRED', 'Not logged in to WeRead', 'Please log in to weread.qq.com in Chrome first');
59
+ }
60
+ if (data?.errcode != null && data.errcode !== 0) {
61
+ throw new CliError('API_ERROR', data.errmsg ?? `WeRead API error ${data.errcode}`);
62
+ }
63
+ return data;
64
+ }
65
+ /** Format a Unix timestamp (seconds) to YYYY-MM-DD in UTC+8. Returns '-' for invalid input. */
66
+ export function formatDate(ts) {
67
+ if (!Number.isFinite(ts) || ts <= 0)
68
+ return '-';
69
+ // WeRead timestamps are China-centric; offset to UTC+8 to avoid off-by-one near midnight
70
+ const d = new Date(ts * 1000 + 8 * 3600_000);
71
+ return d.toISOString().slice(0, 10);
72
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { formatDate, fetchWebApi, fetchWithPage } from './utils.js';
3
+ describe('formatDate', () => {
4
+ it('formats a typical Unix timestamp in UTC+8', () => {
5
+ // 1705276800 = 2024-01-15 00:00:00 UTC = 2024-01-15 08:00:00 Beijing
6
+ expect(formatDate(1705276800)).toBe('2024-01-15');
7
+ });
8
+ it('handles UTC midnight edge case with UTC+8 offset', () => {
9
+ // 1705190399 = 2024-01-13 23:59:59 UTC = 2024-01-14 07:59:59 Beijing
10
+ expect(formatDate(1705190399)).toBe('2024-01-14');
11
+ });
12
+ it('returns dash for zero', () => {
13
+ expect(formatDate(0)).toBe('-');
14
+ });
15
+ it('returns dash for negative', () => {
16
+ expect(formatDate(-1)).toBe('-');
17
+ });
18
+ it('returns dash for NaN', () => {
19
+ expect(formatDate(NaN)).toBe('-');
20
+ });
21
+ it('returns dash for Infinity', () => {
22
+ expect(formatDate(Infinity)).toBe('-');
23
+ });
24
+ it('returns dash for undefined', () => {
25
+ expect(formatDate(undefined)).toBe('-');
26
+ });
27
+ it('returns dash for null', () => {
28
+ expect(formatDate(null)).toBe('-');
29
+ });
30
+ });
31
+ describe('fetchWebApi', () => {
32
+ beforeEach(() => {
33
+ vi.restoreAllMocks();
34
+ });
35
+ it('returns parsed JSON for successful response', async () => {
36
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
37
+ ok: true,
38
+ json: () => Promise.resolve({ books: [{ title: 'Test' }] }),
39
+ }));
40
+ const result = await fetchWebApi('/search/global', { keyword: 'test' });
41
+ expect(result).toEqual({ books: [{ title: 'Test' }] });
42
+ });
43
+ it('throws CliError on HTTP error', async () => {
44
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
45
+ ok: false,
46
+ status: 403,
47
+ json: () => Promise.resolve({}),
48
+ }));
49
+ await expect(fetchWebApi('/search/global')).rejects.toThrow('HTTP 403');
50
+ });
51
+ it('throws PARSE_ERROR on non-JSON response', async () => {
52
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
53
+ ok: true,
54
+ json: () => Promise.reject(new SyntaxError('Unexpected token <')),
55
+ }));
56
+ await expect(fetchWebApi('/search/global')).rejects.toThrow('Invalid JSON');
57
+ });
58
+ });
59
+ describe('fetchWithPage', () => {
60
+ it('throws AUTH_REQUIRED on errcode -2010', async () => {
61
+ const mockPage = {
62
+ evaluate: vi.fn().mockResolvedValue({ errcode: -2010, errmsg: '用户不存在' }),
63
+ };
64
+ await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('Not logged in');
65
+ });
66
+ it('throws API_ERROR on unknown errcode', async () => {
67
+ const mockPage = {
68
+ evaluate: vi.fn().mockResolvedValue({ errcode: -1, errmsg: 'unknown error' }),
69
+ };
70
+ await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('unknown error');
71
+ });
72
+ it('returns data on success (errcode 0 or absent)', async () => {
73
+ const mockPage = {
74
+ evaluate: vi.fn().mockResolvedValue({ title: 'Test Book', errcode: 0 }),
75
+ };
76
+ const result = await fetchWithPage(mockPage, '/book/info');
77
+ expect(result.title).toBe('Test Book');
78
+ });
79
+ it('throws FETCH_ERROR on HTTP error', async () => {
80
+ const mockPage = {
81
+ evaluate: vi.fn().mockResolvedValue({ _httpError: '403' }),
82
+ };
83
+ await expect(fetchWithPage(mockPage, '/book/info')).rejects.toThrow('HTTP 403');
84
+ });
85
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * opencli micro-daemon — HTTP + WebSocket bridge between CLI and Chrome Extension.
3
+ *
4
+ * Architecture:
5
+ * CLI → HTTP POST /command → daemon → WebSocket → Extension
6
+ * Extension → WebSocket result → daemon → HTTP response → CLI
7
+ *
8
+ * Lifecycle:
9
+ * - Auto-spawned by opencli on first browser command
10
+ * - Auto-exits after 5 minutes of idle
11
+ * - Listens on localhost:19825
12
+ */
13
+ export {};