@jackwener/opencli 1.7.21 → 1.8.0

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 (238) hide show
  1. package/README.md +31 -148
  2. package/README.zh-CN.md +38 -211
  3. package/cli-manifest.json +6423 -4260
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/boss/utils.js +17 -1
  24. package/clis/boss/utils.test.js +34 -0
  25. package/clis/chatgpt/envelope.test.js +108 -0
  26. package/clis/chatgpt/image.js +2 -2
  27. package/clis/chatgpt/image.test.js +6 -0
  28. package/clis/chatgpt/utils.js +148 -41
  29. package/clis/chatgpt/utils.test.js +92 -2
  30. package/clis/douyin/_shared/browser-fetch.js +44 -20
  31. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  32. package/clis/douyin/_shared/evaluate-result.js +16 -0
  33. package/clis/douyin/_shared/tos-upload.js +105 -69
  34. package/clis/douyin/_shared/vod-upload.js +212 -0
  35. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  36. package/clis/douyin/delete.js +137 -4
  37. package/clis/douyin/delete.test.js +90 -1
  38. package/clis/douyin/publish-upload-id.test.js +170 -0
  39. package/clis/douyin/publish.js +88 -42
  40. package/clis/douyin/user-videos.js +9 -2
  41. package/clis/douyin/user-videos.test.js +43 -0
  42. package/clis/flomo/memos.js +228 -0
  43. package/clis/flomo/memos.test.js +144 -0
  44. package/clis/gitee/search.js +2 -2
  45. package/clis/gitee/search.test.js +65 -0
  46. package/clis/jike/post.js +27 -17
  47. package/clis/jike/read.test.js +86 -0
  48. package/clis/jike/topic.js +32 -19
  49. package/clis/jike/user.js +33 -20
  50. package/clis/lesswrong/comments.js +1 -1
  51. package/clis/lesswrong/curated.js +1 -1
  52. package/clis/lesswrong/frontpage.js +1 -1
  53. package/clis/lesswrong/frontpage.test.js +37 -0
  54. package/clis/lesswrong/new.js +1 -1
  55. package/clis/lesswrong/read.js +1 -1
  56. package/clis/lesswrong/sequences.js +1 -1
  57. package/clis/lesswrong/shortform.js +1 -1
  58. package/clis/lesswrong/tag.js +1 -1
  59. package/clis/lesswrong/top-month.js +1 -1
  60. package/clis/lesswrong/top-week.js +1 -1
  61. package/clis/lesswrong/top-year.js +1 -1
  62. package/clis/lesswrong/top.js +1 -1
  63. package/clis/linkedin/connect.js +401 -0
  64. package/clis/linkedin/connect.test.js +213 -0
  65. package/clis/linkedin/inbox.js +234 -0
  66. package/clis/linkedin/inbox.test.js +152 -0
  67. package/clis/linkedin/people-search.js +262 -0
  68. package/clis/linkedin/people-search.test.js +216 -0
  69. package/clis/linkedin/safe-send.js +357 -0
  70. package/clis/linkedin/safe-send.test.js +204 -0
  71. package/clis/linkedin/salesnav-inbox.js +210 -0
  72. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  73. package/clis/linkedin/salesnav-message.js +360 -0
  74. package/clis/linkedin/salesnav-message.test.js +172 -0
  75. package/clis/linkedin/salesnav-search.js +186 -0
  76. package/clis/linkedin/salesnav-search.test.js +76 -0
  77. package/clis/linkedin/salesnav-thread.js +212 -0
  78. package/clis/linkedin/salesnav-thread.test.js +79 -0
  79. package/clis/linkedin/sent-invitations.js +92 -0
  80. package/clis/linkedin/sent-invitations.test.js +62 -0
  81. package/clis/linkedin/thread-snapshot.js +214 -0
  82. package/clis/linkedin/thread-snapshot.test.js +89 -0
  83. package/clis/linkedin-learning/course.js +138 -0
  84. package/clis/linkedin-learning/course.test.js +114 -0
  85. package/clis/linkedin-learning/search.js +155 -0
  86. package/clis/linkedin-learning/search.test.js +144 -0
  87. package/clis/linkedin-learning/trending.js +133 -0
  88. package/clis/linkedin-learning/trending.test.js +123 -0
  89. package/clis/powerchina/search.js +3 -3
  90. package/clis/powerchina/search.test.js +27 -1
  91. package/clis/reddit/extract-media.test.js +149 -0
  92. package/clis/reddit/frontpage.js +47 -9
  93. package/clis/reddit/frontpage.test.js +34 -0
  94. package/clis/reddit/home.js +31 -1
  95. package/clis/reddit/home.test.js +46 -3
  96. package/clis/reddit/hot.js +32 -1
  97. package/clis/reddit/hot.test.js +15 -1
  98. package/clis/reddit/popular.js +39 -1
  99. package/clis/reddit/popular.test.js +26 -0
  100. package/clis/reddit/saved.js +1 -1
  101. package/clis/reddit/search.js +38 -1
  102. package/clis/reddit/search.test.js +26 -0
  103. package/clis/reddit/subreddit.js +52 -7
  104. package/clis/reddit/subreddit.test.js +31 -0
  105. package/clis/reddit/subscribed.js +165 -0
  106. package/clis/reddit/subscribed.test.js +168 -0
  107. package/clis/reddit/upvoted.js +1 -1
  108. package/clis/suno/commands.test.js +188 -0
  109. package/clis/suno/download.js +140 -0
  110. package/clis/suno/download.test.js +151 -0
  111. package/clis/suno/generate.js +226 -0
  112. package/clis/suno/generate.test.js +243 -0
  113. package/clis/suno/list.js +79 -0
  114. package/clis/suno/status.js +62 -0
  115. package/clis/suno/utils.js +540 -0
  116. package/clis/suno/utils.test.js +223 -0
  117. package/clis/twitter/device-follow.js +193 -0
  118. package/clis/twitter/device-follow.test.js +287 -0
  119. package/clis/twitter/download.js +443 -73
  120. package/clis/twitter/download.test.js +457 -0
  121. package/clis/twitter/list-create.js +155 -0
  122. package/clis/twitter/list-create.test.js +169 -0
  123. package/clis/twitter/list-remove.js +12 -5
  124. package/clis/twitter/list-remove.test.js +74 -0
  125. package/clis/twitter/list-tweets.js +6 -2
  126. package/clis/twitter/list-tweets.test.js +41 -1
  127. package/clis/twitter/lists.js +31 -4
  128. package/clis/twitter/lists.test.js +152 -16
  129. package/clis/twitter/search.js +6 -2
  130. package/clis/twitter/search.test.js +6 -0
  131. package/clis/twitter/shared.js +144 -0
  132. package/clis/twitter/shared.test.js +429 -1
  133. package/clis/twitter/thread.js +10 -2
  134. package/clis/twitter/thread.test.js +58 -0
  135. package/clis/twitter/timeline.js +6 -2
  136. package/clis/twitter/timeline.test.js +2 -0
  137. package/clis/twitter/tweets.js +3 -2
  138. package/clis/twitter/tweets.test.js +1 -1
  139. package/clis/weibo/comments.js +3 -4
  140. package/clis/weibo/delete.js +172 -0
  141. package/clis/weibo/delete.test.js +94 -0
  142. package/clis/weibo/envelope.test.js +85 -0
  143. package/clis/weibo/favorites.js +4 -4
  144. package/clis/weibo/feed.js +3 -5
  145. package/clis/weibo/hot.js +3 -4
  146. package/clis/weibo/me.js +3 -5
  147. package/clis/weibo/post.js +3 -4
  148. package/clis/weibo/publish.js +37 -14
  149. package/clis/weibo/publish.test.js +14 -5
  150. package/clis/weibo/search.js +4 -3
  151. package/clis/weibo/user-posts.js +234 -0
  152. package/clis/weibo/user-posts.test.js +92 -0
  153. package/clis/weibo/user.js +3 -4
  154. package/clis/weibo/utils.js +34 -5
  155. package/clis/weibo/utils.test.js +36 -0
  156. package/clis/weread/search-regression.test.js +18 -11
  157. package/clis/weread/search.js +15 -7
  158. package/clis/weread-official/book.js +135 -0
  159. package/clis/weread-official/commands.test.js +385 -0
  160. package/clis/weread-official/discover.js +107 -0
  161. package/clis/weread-official/list-apis.js +95 -0
  162. package/clis/weread-official/notes.js +171 -0
  163. package/clis/weread-official/readdata.js +158 -0
  164. package/clis/weread-official/review.js +93 -0
  165. package/clis/weread-official/search.js +106 -0
  166. package/clis/weread-official/shelf.js +97 -0
  167. package/clis/weread-official/utils.js +293 -0
  168. package/clis/weread-official/utils.test.js +242 -0
  169. package/clis/wikipedia/trending.js +7 -3
  170. package/clis/wikipedia/trending.test.js +57 -0
  171. package/clis/xianyu/chat.js +24 -109
  172. package/clis/xianyu/chat.test.js +5 -0
  173. package/clis/xianyu/im.js +322 -0
  174. package/clis/xianyu/im.test.js +253 -0
  175. package/clis/xianyu/inbox.js +96 -0
  176. package/clis/xianyu/messages.js +91 -0
  177. package/clis/xianyu/reply.js +82 -0
  178. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  179. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  180. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  181. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  182. package/clis/xiaohongshu/creator-notes.js +2 -1
  183. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  184. package/clis/xiaohongshu/creator-stats.js +2 -1
  185. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  186. package/clis/xiaohongshu/delete-note.js +260 -0
  187. package/clis/xiaohongshu/delete-note.test.js +172 -0
  188. package/clis/xiaohongshu/publish.js +48 -8
  189. package/clis/xiaohongshu/publish.test.js +65 -10
  190. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  191. package/clis/xiaohongshu/user.js +27 -4
  192. package/clis/xiaoyuzhou/download.js +1 -1
  193. package/clis/xiaoyuzhou/transcript.js +1 -1
  194. package/clis/youdao/note.js +258 -0
  195. package/clis/youdao/note.test.js +99 -0
  196. package/clis/youtube/transcript.js +397 -24
  197. package/clis/youtube/transcript.test.js +196 -6
  198. package/clis/zhihu/answer-comments.js +299 -0
  199. package/clis/zhihu/answer-comments.test.js +287 -0
  200. package/clis/zhihu/answer-detail.js +12 -0
  201. package/clis/zhihu/answer-detail.test.js +8 -0
  202. package/clis/zhihu/collection.js +15 -2
  203. package/clis/zhihu/collection.test.js +46 -0
  204. package/clis/zhihu/download.js +1 -1
  205. package/clis/zhihu/question.js +42 -9
  206. package/clis/zhihu/question.test.js +111 -9
  207. package/clis/zhihu/search.js +206 -43
  208. package/clis/zhihu/search.test.js +198 -0
  209. package/dist/src/browser/errors.js +4 -2
  210. package/dist/src/browser/errors.test.js +6 -0
  211. package/dist/src/browser/page.js +30 -4
  212. package/dist/src/browser/page.test.js +42 -0
  213. package/dist/src/browser/utils.d.ts +1 -1
  214. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  215. package/dist/src/cli-argv-preprocess.js +138 -0
  216. package/dist/src/cli-argv-preprocess.test.js +79 -0
  217. package/dist/src/cli.js +1 -1
  218. package/dist/src/convention-audit.js +15 -8
  219. package/dist/src/convention-audit.test.js +21 -0
  220. package/dist/src/download/media-download.js +15 -2
  221. package/dist/src/download/media-download.test.d.ts +1 -0
  222. package/dist/src/download/media-download.test.js +110 -0
  223. package/dist/src/electron-apps.js +1 -1
  224. package/dist/src/electron-apps.test.js +7 -2
  225. package/dist/src/errors.d.ts +17 -0
  226. package/dist/src/errors.js +22 -0
  227. package/dist/src/external-clis.yaml +20 -0
  228. package/dist/src/external.d.ts +6 -1
  229. package/dist/src/external.test.js +19 -0
  230. package/dist/src/main.js +14 -2
  231. package/dist/src/utils.d.ts +43 -0
  232. package/dist/src/utils.js +97 -0
  233. package/dist/src/utils.test.d.ts +1 -0
  234. package/dist/src/utils.test.js +155 -0
  235. package/package.json +8 -2
  236. package/scripts/silent-column-drop-baseline.json +0 -52
  237. package/scripts/typed-error-lint-baseline.json +28 -380
  238. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,385 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import {
4
+ ArgumentError,
5
+ AuthRequiredError,
6
+ EmptyResultError,
7
+ } from '@jackwener/opencli/errors';
8
+
9
+ import './search.js';
10
+ import './shelf.js';
11
+ import './book.js';
12
+ import './notes.js';
13
+ import './review.js';
14
+ import './readdata.js';
15
+ import './discover.js';
16
+ import './list-apis.js';
17
+
18
+ function jsonResponse(body, ok = true, status = 200) {
19
+ return {
20
+ ok,
21
+ status,
22
+ json: vi.fn().mockResolvedValue(body),
23
+ text: vi.fn().mockResolvedValue(JSON.stringify(body)),
24
+ };
25
+ }
26
+
27
+ function stubGateway(...payloads) {
28
+ const mock = vi.fn();
29
+ for (const payload of payloads) {
30
+ mock.mockResolvedValueOnce(jsonResponse(payload));
31
+ }
32
+ vi.stubGlobal('fetch', mock);
33
+ return mock;
34
+ }
35
+
36
+ beforeEach(() => {
37
+ vi.stubEnv('WEREAD_API_KEY', 'wrk-test');
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.unstubAllGlobals();
42
+ vi.unstubAllEnvs();
43
+ });
44
+
45
+ describe('weread-official command registration', () => {
46
+ it('registers eight read-only public commands without browser dependency', () => {
47
+ const registry = getRegistry();
48
+ const expected = ['search', 'shelf', 'book', 'notes', 'review', 'readdata', 'discover', 'list-apis'];
49
+ for (const name of expected) {
50
+ const command = registry.get(`weread-official/${name}`);
51
+ expect(command, `weread-official/${name}`).toBeDefined();
52
+ expect(command.access).toBe('read');
53
+ expect(command.strategy).toBe('public');
54
+ expect(command.browser).toBe(false);
55
+ expect(command.domain).toBe('weread.qq.com');
56
+ }
57
+ });
58
+ });
59
+
60
+ describe('weread-official search', () => {
61
+ const search = () => getRegistry().get('weread-official/search').func;
62
+
63
+ it('maps scope alias, flattens params, surfaces rating + searchIdx', async () => {
64
+ const mock = stubGateway({
65
+ errcode: 0,
66
+ results: [{
67
+ title: '电子书', scope: 10, scopeCount: 2, books: [
68
+ {
69
+ searchIdx: 1, readingCount: 1000, newRating: 950,
70
+ bookInfo: { bookId: 'b1', title: '三体', author: '刘慈欣', category: '科幻', cover: 'cover1', intro: '...' },
71
+ },
72
+ {
73
+ searchIdx: 2, readingCount: 200, newRating: 0,
74
+ bookInfo: { bookId: 'b2', title: '三体II', author: '刘慈欣' },
75
+ },
76
+ ],
77
+ }],
78
+ });
79
+ const rows = await search()({ keyword: '三体', scope: 'ebook' });
80
+ expect(rows).toHaveLength(2);
81
+ expect(rows[0]).toMatchObject({ rank: 1, scope: '电子书', bookId: 'b1', title: '三体', searchIdx: 1, readingCount: 1000 });
82
+ expect(rows[0].rating).toMatch(/神作/);
83
+ expect(rows[0].link).toBe('weread://reading?bId=b1');
84
+ expect(rows[1].rating).toBe('暂无');
85
+ // verify the request body
86
+ const [, init] = mock.mock.calls[0];
87
+ const body = JSON.parse(init.body);
88
+ expect(body.scope).toBe(10);
89
+ expect(body.keyword).toBe('三体');
90
+ expect(body.skill_version).toBeDefined();
91
+ });
92
+
93
+ it('rejects empty keyword and unknown scope before fetch', async () => {
94
+ const fetchMock = vi.fn();
95
+ vi.stubGlobal('fetch', fetchMock);
96
+ const fn = search();
97
+ await expect(fn({ keyword: ' ' })).rejects.toBeInstanceOf(ArgumentError);
98
+ await expect(fn({ keyword: '三体', scope: 'movie' })).rejects.toBeInstanceOf(ArgumentError);
99
+ expect(fetchMock).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it('throws EmptyResultError when the gateway returns no groups', async () => {
103
+ stubGateway({ errcode: 0, results: [] });
104
+ await expect(search()({ keyword: '三体' })).rejects.toBeInstanceOf(EmptyResultError);
105
+ });
106
+ });
107
+
108
+ describe('weread-official shelf', () => {
109
+ const shelf = () => getRegistry().get('weread-official/shelf').func;
110
+
111
+ it('returns books + albums + mp entry in a single flat list', async () => {
112
+ stubGateway({
113
+ errcode: 0,
114
+ books: [
115
+ { bookId: 'b1', title: '三体', author: '刘慈欣', category: '科幻', isTop: 1, secret: 0, finishReading: 1, readUpdateTime: 1748563200, cover: 'c1' },
116
+ ],
117
+ albums: [
118
+ {
119
+ albumInfo: { albumId: 'a1', name: '红楼梦广播剧', authorName: '播主', finish: 1, cover: 'cover-a', updateTime: 1700000000 },
120
+ albumInfoExtra: { secret: 1, isTop: 0, lectureReadUpdateTime: 1748000000 },
121
+ },
122
+ ],
123
+ mp: { entry: 'present' },
124
+ });
125
+ const rows = await shelf()({});
126
+ expect(rows).toHaveLength(3);
127
+ expect(rows[0]).toMatchObject({ kind: 'book', id: 'b1', isTop: true, finished: true, secret: false });
128
+ expect(rows[0].link).toBe('weread://reading?bId=b1');
129
+ expect(rows[1]).toMatchObject({ kind: 'album', id: 'a1', title: '红楼梦广播剧', author: '播主', secret: true, finished: true });
130
+ expect(rows[2]).toMatchObject({ kind: 'mp', title: '文章收藏', secret: true });
131
+ });
132
+
133
+ it('omits mp row when mp is empty / absent', async () => {
134
+ stubGateway({ errcode: 0, books: [{ bookId: 'b1', title: 't' }], albums: [], mp: {} });
135
+ const rows = await shelf()({});
136
+ expect(rows.map((r) => r.kind)).toEqual(['book']);
137
+ });
138
+
139
+ it('throws EmptyResultError on a fully empty shelf', async () => {
140
+ stubGateway({ errcode: 0, books: [], albums: [] });
141
+ await expect(shelf()({})).rejects.toBeInstanceOf(EmptyResultError);
142
+ });
143
+ });
144
+
145
+ describe('weread-official book', () => {
146
+ const book = () => getRegistry().get('weread-official/book').func;
147
+
148
+ it('runs all three calls by default and groups output into sections', async () => {
149
+ const fetchMock = stubGateway(
150
+ { errcode: 0, bookId: 'b1', title: '三体', author: '刘慈欣', newRating: 920, intro: 'intro', cover: 'cov' },
151
+ { errcode: 0, chapters: [{ chapterUid: 107, chapterIdx: 1, title: 'Ch1', wordCount: 1234, level: 1, paid: 1, price: 0 }] },
152
+ { errcode: 0, book: { chapterUid: 107, progress: 45, recordReadingTime: 3660, updateTime: 1748563200, isStartReading: 1 } },
153
+ );
154
+ const rows = await book()({ bookId: 'b1' });
155
+ expect(fetchMock).toHaveBeenCalledTimes(3);
156
+ expect(rows.find((r) => r.section === 'info' && r.key === 'title').value).toBe('三体');
157
+ expect(rows.find((r) => r.section === 'info' && r.key === 'rating').value).toMatch(/神作/);
158
+ const chapterRow = rows.find((r) => r.section === 'chapter');
159
+ expect(chapterRow).toMatchObject({ key: '107' });
160
+ expect(chapterRow.link).toBe('weread://reading?bId=b1&chapterUid=107');
161
+ const progressRow = rows.find((r) => r.section === 'progress' && r.key === 'progress');
162
+ expect(progressRow.value).toBe('45%');
163
+ const cumulative = rows.find((r) => r.section === 'progress' && r.key === 'cumulative');
164
+ expect(cumulative.value).toBe('1小时1分钟');
165
+ });
166
+
167
+ it('skips /book/chapterinfo when --no-chapters and /book/getprogress when --no-progress', async () => {
168
+ const fetchMock = stubGateway(
169
+ { errcode: 0, bookId: 'b1', title: '三体' },
170
+ );
171
+ await book()({ bookId: 'b1', 'no-chapters': true, 'no-progress': true });
172
+ expect(fetchMock).toHaveBeenCalledTimes(1);
173
+ });
174
+
175
+ it('rejects empty bookId before fetch', async () => {
176
+ const fetchMock = vi.fn();
177
+ vi.stubGlobal('fetch', fetchMock);
178
+ await expect(book()({ bookId: '' })).rejects.toBeInstanceOf(ArgumentError);
179
+ expect(fetchMock).not.toHaveBeenCalled();
180
+ });
181
+ });
182
+
183
+ describe('weread-official notes', () => {
184
+ const notes = () => getRegistry().get('weread-official/notes').func;
185
+
186
+ it('returns a notebooks overview with totalNotes = review + note + bookmark counts', async () => {
187
+ stubGateway({
188
+ errcode: 0,
189
+ totalBookCount: 1,
190
+ books: [{
191
+ bookId: 'b1',
192
+ book: { title: '三体', author: '刘慈欣' },
193
+ reviewCount: 3, noteCount: 12, bookmarkCount: 4,
194
+ readingProgress: 45, markedStatus: 0, sort: 1778312777,
195
+ }],
196
+ hasMore: 0,
197
+ });
198
+ const rows = await notes()({});
199
+ expect(rows).toHaveLength(1);
200
+ expect(rows[0]).toMatchObject({ kind: 'notebook', bookId: 'b1', totalNotes: 19, reviewCount: 3, noteCount: 12, bookmarkCount: 4 });
201
+ });
202
+
203
+ it('merges /book/bookmarklist + /review/list/mine when bookId is provided', async () => {
204
+ stubGateway(
205
+ {
206
+ errcode: 0,
207
+ chapters: [{ chapterUid: 107, title: '第一章' }],
208
+ updated: [{ bookmarkId: 'bm1', chapterUid: 107, markText: 'highlighted text', range: '900-2004', createTime: 1748563200 }],
209
+ },
210
+ {
211
+ errcode: 0,
212
+ reviews: [{ review: { reviewId: 'rv1', content: 'great chapter', chapterUid: 107, star: 100, isFinish: 0, createTime: 1748563200, chapterName: '第一章' } }],
213
+ },
214
+ );
215
+ const rows = await notes()({ bookId: 'b1' });
216
+ const highlight = rows.find((r) => r.kind === 'highlight');
217
+ const thought = rows.find((r) => r.kind === 'thought');
218
+ expect(highlight).toBeDefined();
219
+ expect(thought).toBeDefined();
220
+ expect(highlight.chapter).toBe('第一章');
221
+ expect(highlight.range).toBe('900-2004');
222
+ expect(highlight.link).toBe('weread://bestbookmark?bookId=b1&chapterUid=107&rangeStart=900&rangeEnd=2004');
223
+ expect(thought.thought).toBe('great chapter');
224
+ });
225
+
226
+ it('throws EmptyResultError when both bookmarks and reviews are empty for a book', async () => {
227
+ stubGateway(
228
+ { errcode: 0, chapters: [], updated: [] },
229
+ { errcode: 0, reviews: [] },
230
+ );
231
+ await expect(notes()({ bookId: 'b1' })).rejects.toBeInstanceOf(EmptyResultError);
232
+ });
233
+ });
234
+
235
+ describe('weread-official review', () => {
236
+ const review = () => getRegistry().get('weread-official/review').func;
237
+
238
+ it('maps the type alias to reviewListType and shapes nested reviews', async () => {
239
+ const mock = stubGateway({
240
+ errcode: 0,
241
+ reviewsCnt: 1,
242
+ reviews: [{
243
+ idx: 7,
244
+ review: {
245
+ reviewId: 'rv1',
246
+ review: {
247
+ reviewId: 'rv1',
248
+ content: 'amazing',
249
+ star: 100,
250
+ isFinish: 1,
251
+ chapterName: '终章',
252
+ createTime: 1748563200,
253
+ author: { name: 'Alice' },
254
+ },
255
+ },
256
+ }],
257
+ });
258
+ const rows = await review()({ bookId: 'b1', type: 'recommend' });
259
+ expect(rows).toHaveLength(1);
260
+ expect(rows[0]).toMatchObject({ reviewId: 'rv1', author: 'Alice', isFinish: true, chapter: '终章' });
261
+ expect(rows[0].starLabel).toBe('⭐⭐⭐⭐⭐');
262
+ const body = JSON.parse(mock.mock.calls[0][1].body);
263
+ expect(body.reviewListType).toBe(1);
264
+ });
265
+
266
+ it('rejects invalid type alias', async () => {
267
+ const fetchMock = vi.fn();
268
+ vi.stubGlobal('fetch', fetchMock);
269
+ await expect(review()({ bookId: 'b1', type: 'bogus' })).rejects.toBeInstanceOf(ArgumentError);
270
+ expect(fetchMock).not.toHaveBeenCalled();
271
+ });
272
+ });
273
+
274
+ describe('weread-official readdata', () => {
275
+ const readdata = () => getRegistry().get('weread-official/readdata').func;
276
+
277
+ it('produces summary + longest + readStat + preferCategory rows', async () => {
278
+ stubGateway({
279
+ errcode: 0,
280
+ baseTime: 1748563200,
281
+ readDays: 22,
282
+ totalReadTime: 7320,
283
+ dayAverageReadTime: 1200,
284
+ compare: 0.2,
285
+ readLongest: [{ book: { title: '三体', author: '刘慈欣' }, readTime: 3600, tags: ['笔记最多'] }],
286
+ readStat: [{ stat: '读过', counts: '12本' }, { stat: '笔记', counts: '120条' }],
287
+ preferCategory: [{ categoryTitle: '科幻', readingTime: 7200, readingCount: 3 }],
288
+ preferAuthor: [{ name: '刘慈欣', readTime: '5小时30分钟', count: 4 }],
289
+ });
290
+ const rows = await readdata()({ mode: 'weekly' });
291
+ const summaryTotal = rows.find((r) => r.section === 'summary' && r.key === 'totalReadTime');
292
+ expect(summaryTotal.value).toBe('2小时2分钟');
293
+ expect(summaryTotal.detail).toBe('7320s');
294
+ const compare = rows.find((r) => r.section === 'summary' && r.key === 'compareToPrev');
295
+ expect(compare.value).toBe('20.0%');
296
+ expect(rows.some((r) => r.section === 'longest' && r.key === '三体')).toBe(true);
297
+ expect(rows.some((r) => r.section === 'readStat' && r.key === '读过')).toBe(true);
298
+ expect(rows.some((r) => r.section === 'preferCategory' && r.key === '科幻')).toBe(true);
299
+ expect(rows.some((r) => r.section === 'preferAuthor' && r.key === '刘慈欣')).toBe(true);
300
+ });
301
+
302
+ it('rejects unknown mode before fetch', async () => {
303
+ const fetchMock = vi.fn();
304
+ vi.stubGlobal('fetch', fetchMock);
305
+ await expect(readdata()({ mode: 'fortnight' })).rejects.toBeInstanceOf(ArgumentError);
306
+ expect(fetchMock).not.toHaveBeenCalled();
307
+ });
308
+ });
309
+
310
+ describe('weread-official discover', () => {
311
+ const discover = () => getRegistry().get('weread-official/discover').func;
312
+
313
+ it('calls /book/recommend when no bookId provided', async () => {
314
+ const mock = stubGateway({
315
+ errcode: 0,
316
+ books: [{ bookId: 'b1', title: '三体', author: '刘慈欣', newRating: 920, searchIdx: 1, reason: '科幻经典' }],
317
+ });
318
+ const rows = await discover()({});
319
+ expect(rows).toHaveLength(1);
320
+ expect(rows[0]).toMatchObject({ mode: 'recommend', bookId: 'b1', reason: '科幻经典' });
321
+ const body = JSON.parse(mock.mock.calls[0][1].body);
322
+ expect(body.api_name).toBe('/book/recommend');
323
+ });
324
+
325
+ it('calls /book/similar when bookId given and unwraps booksimilar.books', async () => {
326
+ const mock = stubGateway({
327
+ errcode: 0,
328
+ booksimilar: {
329
+ sessionId: 'sess1',
330
+ books: [{ idx: 1, book: { bookInfo: { bookId: 'b2', title: '三体II', author: '刘慈欣' } } }],
331
+ },
332
+ });
333
+ const rows = await discover()({ bookId: 'b1' });
334
+ expect(rows).toHaveLength(1);
335
+ expect(rows[0]).toMatchObject({ mode: 'similar', bookId: 'b2', title: '三体II' });
336
+ const body = JSON.parse(mock.mock.calls[0][1].body);
337
+ expect(body.api_name).toBe('/book/similar');
338
+ expect(body.bookId).toBe('b1');
339
+ });
340
+ });
341
+
342
+ describe('weread-official list-apis', () => {
343
+ const listApis = () => getRegistry().get('weread-official/list-apis').func;
344
+
345
+ it('normalises gateway inventory shapes and appends a client SKILL_VERSION row', async () => {
346
+ stubGateway({
347
+ errcode: 0,
348
+ apis: [
349
+ { api_name: '/store/search', description: 'search store', required: ['keyword'], optional: ['scope', 'maxIdx'] },
350
+ { name: '/shelf/sync', summary: 'shelf', params: { required: [], optional: [] } },
351
+ ],
352
+ });
353
+ const rows = await listApis()({});
354
+ expect(rows.find((r) => r.apiName === '/store/search')).toMatchObject({ required: 'keyword', optional: 'scope, maxIdx' });
355
+ const client = rows[rows.length - 1];
356
+ expect(client.apiName).toBe('(client)');
357
+ expect(client.extras).toMatch(/^SKILL_VERSION=/);
358
+ });
359
+
360
+ it('throws EmptyResultError when the gateway returns no inventory shape', async () => {
361
+ stubGateway({ errcode: 0, apis: [] });
362
+ await expect(listApis()({})).rejects.toBeInstanceOf(EmptyResultError);
363
+ });
364
+ });
365
+
366
+ describe('weread-official auth wiring', () => {
367
+ it('every command refuses to run without WEREAD_API_KEY', async () => {
368
+ vi.unstubAllEnvs();
369
+ vi.stubEnv('WEREAD_API_KEY', '');
370
+ const fetchMock = vi.fn();
371
+ vi.stubGlobal('fetch', fetchMock);
372
+ // pick a representative subset; each command's first call is callGateway which checks env first
373
+ const cases = [
374
+ ['search', { keyword: '三体' }],
375
+ ['shelf', {}],
376
+ ['book', { bookId: 'b1' }],
377
+ ['readdata', { mode: 'weekly' }],
378
+ ['list-apis', {}],
379
+ ];
380
+ for (const [name, args] of cases) {
381
+ await expect(getRegistry().get(`weread-official/${name}`).func(args)).rejects.toBeInstanceOf(AuthRequiredError);
382
+ }
383
+ expect(fetchMock).not.toHaveBeenCalled();
384
+ });
385
+ });
@@ -0,0 +1,107 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import {
3
+ callGateway,
4
+ emptyResult,
5
+ formatRating,
6
+ makeDeepLink,
7
+ requireBookId,
8
+ requirePositiveInt,
9
+ truncate,
10
+ } from './utils.js';
11
+
12
+ /**
13
+ * Two endpoints share a single command surface:
14
+ * - no bookId → `/book/recommend` (personalized "for you" feed)
15
+ * - bookId → `/book/similar` (similar-books for a given book)
16
+ *
17
+ * Pagination differs: `/recommend` uses `searchIdx`; `/similar` uses `idx` plus
18
+ * an opaque `sessionId` cursor. The CLI surfaces both verbatim so multi-page
19
+ * fetches can use the SKILL contract without translation.
20
+ */
21
+ cli({
22
+ site: 'weread-official',
23
+ name: 'discover',
24
+ access: 'read',
25
+ description: 'Personalized or similar-book recommendations from WeRead',
26
+ domain: 'weread.qq.com',
27
+ strategy: Strategy.PUBLIC,
28
+ browser: false,
29
+ args: [
30
+ { name: 'bookId', positional: true, help: 'Anchor bookId for similar-book mode; omit for personalized recommendations' },
31
+ { name: 'count', type: 'int', default: 12, help: 'Page size (default 12)' },
32
+ { name: 'max-idx', type: 'int', default: 0, help: 'Pagination cursor (recommend: previous searchIdx; similar: previous idx)' },
33
+ { name: 'session-id', help: 'Carry-forward sessionId for /book/similar paging' },
34
+ ],
35
+ columns: ['rank', 'mode', 'bookId', 'title', 'author', 'rating', 'readingCount', 'category', 'idx', 'reason', 'cover', 'intro', 'link'],
36
+ func: async (args) => {
37
+ const count = requirePositiveInt(args.count, 'count', { defaultValue: 12, max: 50 });
38
+ const maxIdx = Number(args['max-idx'] ?? 0);
39
+ const rawBookId = args.bookId !== undefined && args.bookId !== null && String(args.bookId).trim() !== ''
40
+ ? requireBookId(args.bookId)
41
+ : null;
42
+
43
+ if (!rawBookId) {
44
+ return runRecommend({ count, maxIdx });
45
+ }
46
+ return runSimilar({ bookId: rawBookId, count, maxIdx, sessionId: args['session-id'] });
47
+ },
48
+ });
49
+
50
+ async function runRecommend({ count, maxIdx }) {
51
+ const payload = await callGateway('/book/recommend', { count, maxIdx });
52
+ const books = Array.isArray(payload?.books) ? payload.books : [];
53
+ if (books.length === 0) {
54
+ emptyResult('discover', 'No personalized recommendations available.');
55
+ }
56
+ return books.map((entry, i) => {
57
+ const bookId = String(entry?.bookId ?? '').trim();
58
+ return {
59
+ rank: i + 1,
60
+ mode: 'recommend',
61
+ bookId,
62
+ title: String(entry?.title ?? ''),
63
+ author: String(entry?.author ?? ''),
64
+ rating: formatRating(entry?.newRating),
65
+ readingCount: Number(entry?.readingCount ?? 0),
66
+ category: String(entry?.category ?? ''),
67
+ idx: Number(entry?.searchIdx ?? 0),
68
+ reason: String(entry?.reason ?? ''),
69
+ cover: String(entry?.cover ?? ''),
70
+ intro: truncate(entry?.intro, 200),
71
+ link: bookId ? makeDeepLink({ bookId }) : '',
72
+ };
73
+ });
74
+ }
75
+
76
+ async function runSimilar({ bookId, count, maxIdx, sessionId }) {
77
+ const params = { bookId, count, maxIdx };
78
+ const sid = String(sessionId ?? '').trim();
79
+ if (sid) params.sessionId = sid;
80
+
81
+ const payload = await callGateway('/book/similar', params);
82
+ const inner = payload?.booksimilar ?? payload;
83
+ const books = Array.isArray(inner?.books) ? inner.books : [];
84
+ if (books.length === 0) {
85
+ emptyResult('discover', `No similar books for bookId=${bookId}.`);
86
+ }
87
+ return books.map((wrapper, i) => {
88
+ // /book/similar nests bookInfo under entry.book.bookInfo
89
+ const info = wrapper?.book?.bookInfo ?? wrapper?.bookInfo ?? {};
90
+ const id = String(info?.bookId ?? '').trim();
91
+ return {
92
+ rank: i + 1,
93
+ mode: 'similar',
94
+ bookId: id,
95
+ title: String(info?.title ?? ''),
96
+ author: String(info?.author ?? ''),
97
+ rating: formatRating(info?.newRating),
98
+ readingCount: Number(info?.readingCount ?? 0),
99
+ category: String(info?.category ?? ''),
100
+ idx: Number(wrapper?.idx ?? 0),
101
+ reason: '', // /book/similar does not surface a recommendation reason
102
+ cover: String(info?.cover ?? ''),
103
+ intro: truncate(info?.intro, 200),
104
+ link: id ? makeDeepLink({ bookId: id }) : '',
105
+ };
106
+ });
107
+ }
@@ -0,0 +1,95 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { callGateway, emptyResult, SKILL_VERSION } from './utils.js';
3
+
4
+ /**
5
+ * `/_list` is the gateway's meta-endpoint. The response shape is not strongly
6
+ * specified in SKILL.md beyond "returns all available api_name + their param
7
+ * definitions", so we surface every visible field with light normalization
8
+ * (api_name + description/help + required/optional params, falling back to a
9
+ * raw-JSON column when the gateway changes shape).
10
+ *
11
+ * `clientSkillVersion` row is appended so debug output makes the local
12
+ * SKILL_VERSION explicit — useful when triaging upgrade_info mismatches.
13
+ */
14
+ cli({
15
+ site: 'weread-official',
16
+ name: 'list-apis',
17
+ access: 'read',
18
+ description: 'List every api_name supported by the WeRead agent gateway',
19
+ domain: 'weread.qq.com',
20
+ strategy: Strategy.PUBLIC,
21
+ browser: false,
22
+ args: [],
23
+ columns: ['rank', 'apiName', 'description', 'required', 'optional', 'extras'],
24
+ func: async () => {
25
+ const payload = await callGateway('/_list', {});
26
+ const apis = extractApiList(payload);
27
+ if (apis.length === 0) {
28
+ emptyResult('list-apis', 'Gateway returned no api inventory.');
29
+ }
30
+ const rows = apis.map((entry, i) => {
31
+ const required = formatParamList(entry?.required ?? entry?.requiredParams ?? entry?.params?.required);
32
+ const optional = formatParamList(entry?.optional ?? entry?.optionalParams ?? entry?.params?.optional);
33
+ const description = String(entry?.description ?? entry?.help ?? entry?.summary ?? '');
34
+ const extras = summarizeExtras(entry, ['api_name', 'apiName', 'name', 'description', 'help', 'summary', 'required', 'optional', 'requiredParams', 'optionalParams', 'params']);
35
+ return {
36
+ rank: i + 1,
37
+ apiName: String(entry?.api_name ?? entry?.apiName ?? entry?.name ?? ''),
38
+ description,
39
+ required,
40
+ optional,
41
+ extras,
42
+ };
43
+ });
44
+ rows.push({
45
+ rank: rows.length + 1,
46
+ apiName: '(client)',
47
+ description: 'Local skill version reported with every gateway request',
48
+ required: '',
49
+ optional: '',
50
+ extras: `SKILL_VERSION=${SKILL_VERSION}`,
51
+ });
52
+ return rows;
53
+ },
54
+ });
55
+
56
+ function extractApiList(payload) {
57
+ if (!payload || typeof payload !== 'object') return [];
58
+ const candidates = [payload?.apis, payload?.list, payload?.data, payload?.items, payload?.endpoints];
59
+ for (const candidate of candidates) {
60
+ if (Array.isArray(candidate) && candidate.length > 0) return candidate;
61
+ }
62
+ if (Array.isArray(payload)) return payload;
63
+ return [];
64
+ }
65
+
66
+ function formatParamList(value) {
67
+ if (!value) return '';
68
+ if (Array.isArray(value)) {
69
+ return value
70
+ .map((entry) => (typeof entry === 'string' ? entry : entry?.name ?? entry?.param ?? ''))
71
+ .filter(Boolean)
72
+ .join(', ');
73
+ }
74
+ if (typeof value === 'object') {
75
+ return Object.keys(value).filter(Boolean).join(', ');
76
+ }
77
+ return String(value);
78
+ }
79
+
80
+ function summarizeExtras(entry, knownKeys) {
81
+ if (!entry || typeof entry !== 'object') return '';
82
+ const known = new Set(knownKeys);
83
+ const rest = {};
84
+ for (const [key, value] of Object.entries(entry)) {
85
+ if (known.has(key)) continue;
86
+ rest[key] = value;
87
+ }
88
+ if (Object.keys(rest).length === 0) return '';
89
+ try {
90
+ return JSON.stringify(rest);
91
+ }
92
+ catch {
93
+ return '';
94
+ }
95
+ }