@jackwener/opencli 1.7.22 → 1.8.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 (346) hide show
  1. package/README.md +35 -194
  2. package/README.zh-CN.md +42 -260
  3. package/cli-manifest.json +8160 -4392
  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/_atlassian/shared.js +577 -0
  16. package/clis/_atlassian/shared.test.js +170 -0
  17. package/clis/apple-podcasts/commands.test.js +20 -0
  18. package/clis/apple-podcasts/search.js +2 -2
  19. package/clis/barchart/greeks.js +144 -56
  20. package/clis/barchart/greeks.test.js +138 -0
  21. package/clis/bilibili/comment.js +125 -0
  22. package/clis/bilibili/comment.test.js +153 -0
  23. package/clis/bilibili/comments.js +116 -21
  24. package/clis/bilibili/comments.test.js +77 -18
  25. package/clis/bilibili/subtitle.js +76 -31
  26. package/clis/bilibili/subtitle.test.js +156 -9
  27. package/clis/bilibili/summary.js +167 -0
  28. package/clis/bilibili/summary.test.js +210 -0
  29. package/clis/bilibili/utils.js +63 -5
  30. package/clis/bilibili/utils.test.js +45 -1
  31. package/clis/booking/booking.test.js +356 -0
  32. package/clis/booking/search.js +351 -0
  33. package/clis/chatgpt/envelope.test.js +108 -0
  34. package/clis/chatgpt/image.js +2 -2
  35. package/clis/chatgpt/image.test.js +6 -0
  36. package/clis/chatgpt/utils.js +148 -41
  37. package/clis/chatgpt/utils.test.js +92 -2
  38. package/clis/chess/analyze.js +35 -0
  39. package/clis/chess/analyze.test.js +79 -0
  40. package/clis/chess/game.js +114 -0
  41. package/clis/chess/game.test.js +178 -0
  42. package/clis/chess/games.js +67 -0
  43. package/clis/chess/games.test.js +164 -0
  44. package/clis/chess/stats.js +32 -0
  45. package/clis/chess/stats.test.js +79 -0
  46. package/clis/chess/utils.js +170 -0
  47. package/clis/chess/utils.test.js +230 -0
  48. package/clis/confluence/commands.test.js +195 -0
  49. package/clis/confluence/create.js +39 -0
  50. package/clis/confluence/page.js +23 -0
  51. package/clis/confluence/search.js +34 -0
  52. package/clis/confluence/shared.js +173 -0
  53. package/clis/confluence/update.js +38 -0
  54. package/clis/douyin/_shared/browser-fetch.js +44 -20
  55. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  56. package/clis/douyin/_shared/evaluate-result.js +16 -0
  57. package/clis/douyin/_shared/tos-upload.js +105 -69
  58. package/clis/douyin/_shared/vod-upload.js +212 -0
  59. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  60. package/clis/douyin/delete.js +137 -4
  61. package/clis/douyin/delete.test.js +90 -1
  62. package/clis/douyin/hashtag.js +84 -23
  63. package/clis/douyin/hashtag.test.js +113 -0
  64. package/clis/douyin/publish-upload-id.test.js +170 -0
  65. package/clis/douyin/publish.js +88 -42
  66. package/clis/douyin/user-videos.js +9 -2
  67. package/clis/douyin/user-videos.test.js +43 -0
  68. package/clis/flomo/memos.js +228 -0
  69. package/clis/flomo/memos.test.js +144 -0
  70. package/clis/geogebra/add-circle.js +46 -0
  71. package/clis/geogebra/add-line.js +35 -0
  72. package/clis/geogebra/add-point.js +27 -0
  73. package/clis/geogebra/add-polygon.js +25 -0
  74. package/clis/geogebra/eval.js +35 -0
  75. package/clis/geogebra/geogebra.test.js +175 -0
  76. package/clis/geogebra/hexagon.js +62 -0
  77. package/clis/geogebra/info.js +72 -0
  78. package/clis/geogebra/list.js +35 -0
  79. package/clis/geogebra/triangle.js +60 -0
  80. package/clis/geogebra/utils.js +271 -0
  81. package/clis/gitee/search.js +2 -2
  82. package/clis/gitee/search.test.js +65 -0
  83. package/clis/jike/post.js +27 -17
  84. package/clis/jike/read.test.js +86 -0
  85. package/clis/jike/topic.js +32 -19
  86. package/clis/jike/user.js +33 -20
  87. package/clis/jira/attachments.js +28 -0
  88. package/clis/jira/commands.test.js +287 -0
  89. package/clis/jira/comments.js +28 -0
  90. package/clis/jira/issue.js +28 -0
  91. package/clis/jira/links.js +28 -0
  92. package/clis/jira/search.js +47 -0
  93. package/clis/jira/shared.js +256 -0
  94. package/clis/lesswrong/comments.js +1 -1
  95. package/clis/lesswrong/curated.js +1 -1
  96. package/clis/lesswrong/frontpage.js +1 -1
  97. package/clis/lesswrong/frontpage.test.js +37 -0
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/top-month.js +1 -1
  104. package/clis/lesswrong/top-week.js +1 -1
  105. package/clis/lesswrong/top-year.js +1 -1
  106. package/clis/lesswrong/top.js +1 -1
  107. package/clis/linkedin/connect.js +401 -0
  108. package/clis/linkedin/connect.test.js +213 -0
  109. package/clis/linkedin/inbox.js +234 -0
  110. package/clis/linkedin/inbox.test.js +152 -0
  111. package/clis/linkedin/job-detail.js +167 -0
  112. package/clis/linkedin/job-detail.test.js +38 -0
  113. package/clis/linkedin/jobs-preferences.js +113 -0
  114. package/clis/linkedin/jobs-preferences.test.js +43 -0
  115. package/clis/linkedin/people-search.js +262 -0
  116. package/clis/linkedin/people-search.test.js +216 -0
  117. package/clis/linkedin/post-analytics.js +74 -0
  118. package/clis/linkedin/post-analytics.test.js +40 -0
  119. package/clis/linkedin/posts-core.js +241 -0
  120. package/clis/linkedin/posts.js +22 -0
  121. package/clis/linkedin/posts.test.js +40 -0
  122. package/clis/linkedin/profile-analytics.js +104 -0
  123. package/clis/linkedin/profile-analytics.test.js +67 -0
  124. package/clis/linkedin/profile-experience.js +671 -0
  125. package/clis/linkedin/profile-experience.test.js +152 -0
  126. package/clis/linkedin/profile-projects.js +311 -0
  127. package/clis/linkedin/profile-projects.test.js +111 -0
  128. package/clis/linkedin/profile-read.js +148 -0
  129. package/clis/linkedin/profile-read.test.js +77 -0
  130. package/clis/linkedin/safe-send.js +357 -0
  131. package/clis/linkedin/safe-send.test.js +204 -0
  132. package/clis/linkedin/salesnav-inbox.js +210 -0
  133. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  134. package/clis/linkedin/salesnav-message.js +360 -0
  135. package/clis/linkedin/salesnav-message.test.js +172 -0
  136. package/clis/linkedin/salesnav-search.js +186 -0
  137. package/clis/linkedin/salesnav-search.test.js +76 -0
  138. package/clis/linkedin/salesnav-thread.js +212 -0
  139. package/clis/linkedin/salesnav-thread.test.js +79 -0
  140. package/clis/linkedin/sent-invitations.js +92 -0
  141. package/clis/linkedin/sent-invitations.test.js +62 -0
  142. package/clis/linkedin/services-read.js +213 -0
  143. package/clis/linkedin/services-read.test.js +105 -0
  144. package/clis/linkedin/shared.js +124 -0
  145. package/clis/linkedin/thread-snapshot.js +214 -0
  146. package/clis/linkedin/thread-snapshot.test.js +89 -0
  147. package/clis/linkedin/timeline.js +14 -7
  148. package/clis/linkedin-learning/course.js +138 -0
  149. package/clis/linkedin-learning/course.test.js +114 -0
  150. package/clis/linkedin-learning/search.js +155 -0
  151. package/clis/linkedin-learning/search.test.js +144 -0
  152. package/clis/linkedin-learning/trending.js +133 -0
  153. package/clis/linkedin-learning/trending.test.js +123 -0
  154. package/clis/notebooklm/add-source.js +269 -0
  155. package/clis/notebooklm/add-source.test.js +97 -0
  156. package/clis/notebooklm/create.js +76 -0
  157. package/clis/notebooklm/create.test.js +58 -0
  158. package/clis/notebooklm/generate-audio.js +91 -0
  159. package/clis/notebooklm/generate-audio.test.js +63 -0
  160. package/clis/notebooklm/generate-slides.js +106 -0
  161. package/clis/notebooklm/generate-slides.test.js +75 -0
  162. package/clis/notebooklm/open.test.js +10 -10
  163. package/clis/notebooklm/rpc.js +20 -6
  164. package/clis/notebooklm/rpc.test.js +27 -1
  165. package/clis/notebooklm/utils.js +100 -24
  166. package/clis/notebooklm/utils.test.js +60 -1
  167. package/clis/notebooklm/write-note.js +103 -0
  168. package/clis/notebooklm/write-note.test.js +70 -0
  169. package/clis/pixiv/detail.js +41 -34
  170. package/clis/pixiv/detail.test.js +93 -0
  171. package/clis/pixiv/user.js +36 -31
  172. package/clis/pixiv/user.test.js +100 -0
  173. package/clis/pixiv/utils.js +56 -7
  174. package/clis/powerchina/search.js +3 -3
  175. package/clis/powerchina/search.test.js +27 -1
  176. package/clis/reddit/extract-media.test.js +149 -0
  177. package/clis/reddit/frontpage.js +47 -9
  178. package/clis/reddit/frontpage.test.js +34 -0
  179. package/clis/reddit/home.js +31 -1
  180. package/clis/reddit/home.test.js +46 -3
  181. package/clis/reddit/hot.js +32 -1
  182. package/clis/reddit/hot.test.js +15 -1
  183. package/clis/reddit/popular.js +39 -1
  184. package/clis/reddit/popular.test.js +26 -0
  185. package/clis/reddit/saved.js +1 -1
  186. package/clis/reddit/search.js +38 -1
  187. package/clis/reddit/search.test.js +26 -0
  188. package/clis/reddit/subreddit.js +52 -7
  189. package/clis/reddit/subreddit.test.js +31 -0
  190. package/clis/reddit/subscribed.js +165 -0
  191. package/clis/reddit/subscribed.test.js +168 -0
  192. package/clis/reddit/upvoted.js +1 -1
  193. package/clis/suno/commands.test.js +188 -0
  194. package/clis/suno/download.js +140 -0
  195. package/clis/suno/download.test.js +151 -0
  196. package/clis/suno/generate.js +231 -0
  197. package/clis/suno/generate.test.js +252 -0
  198. package/clis/suno/list.js +79 -0
  199. package/clis/suno/status.js +63 -0
  200. package/clis/suno/utils.js +549 -0
  201. package/clis/suno/utils.test.js +329 -0
  202. package/clis/twitter/device-follow.js +193 -0
  203. package/clis/twitter/device-follow.test.js +287 -0
  204. package/clis/twitter/download.js +443 -73
  205. package/clis/twitter/download.test.js +457 -0
  206. package/clis/twitter/followers.js +6 -2
  207. package/clis/twitter/followers.test.js +19 -1
  208. package/clis/twitter/following.js +14 -5
  209. package/clis/twitter/following.test.js +29 -0
  210. package/clis/twitter/likes.js +12 -4
  211. package/clis/twitter/likes.test.js +26 -1
  212. package/clis/twitter/list-add.js +1 -1
  213. package/clis/twitter/list-create.js +155 -0
  214. package/clis/twitter/list-create.test.js +169 -0
  215. package/clis/twitter/list-remove.js +13 -6
  216. package/clis/twitter/list-remove.test.js +74 -0
  217. package/clis/twitter/list-tweets.js +6 -2
  218. package/clis/twitter/list-tweets.test.js +41 -1
  219. package/clis/twitter/lists.js +31 -4
  220. package/clis/twitter/lists.test.js +152 -16
  221. package/clis/twitter/notifications.js +4 -4
  222. package/clis/twitter/post.js +62 -4
  223. package/clis/twitter/post.test.js +35 -3
  224. package/clis/twitter/profile.js +81 -28
  225. package/clis/twitter/profile.test.js +113 -2
  226. package/clis/twitter/quote.js +9 -4
  227. package/clis/twitter/reply.js +13 -10
  228. package/clis/twitter/reply.test.js +41 -0
  229. package/clis/twitter/search.js +7 -3
  230. package/clis/twitter/search.test.js +41 -0
  231. package/clis/twitter/shared.js +155 -0
  232. package/clis/twitter/shared.test.js +465 -1
  233. package/clis/twitter/thread.js +10 -2
  234. package/clis/twitter/thread.test.js +58 -0
  235. package/clis/twitter/timeline.js +6 -2
  236. package/clis/twitter/timeline.test.js +2 -0
  237. package/clis/twitter/tweets.js +3 -2
  238. package/clis/twitter/tweets.test.js +1 -1
  239. package/clis/twitter/utils.js +53 -16
  240. package/clis/upwork/detail.js +132 -0
  241. package/clis/upwork/feed.js +109 -0
  242. package/clis/upwork/search.js +115 -0
  243. package/clis/upwork/upwork.test.js +566 -0
  244. package/clis/upwork/utils.js +323 -0
  245. package/clis/weibo/delete.js +172 -0
  246. package/clis/weibo/delete.test.js +94 -0
  247. package/clis/weibo/publish.js +37 -14
  248. package/clis/weibo/publish.test.js +14 -5
  249. package/clis/weibo/user-posts.js +234 -0
  250. package/clis/weibo/user-posts.test.js +92 -0
  251. package/clis/weread/book-search.js +438 -0
  252. package/clis/weread/book-search.test.js +242 -0
  253. package/clis/weread/search-regression.test.js +98 -11
  254. package/clis/weread/search.js +32 -9
  255. package/clis/weread-official/book.js +135 -0
  256. package/clis/weread-official/commands.test.js +385 -0
  257. package/clis/weread-official/discover.js +107 -0
  258. package/clis/weread-official/list-apis.js +95 -0
  259. package/clis/weread-official/notes.js +171 -0
  260. package/clis/weread-official/readdata.js +158 -0
  261. package/clis/weread-official/review.js +93 -0
  262. package/clis/weread-official/search.js +106 -0
  263. package/clis/weread-official/shelf.js +97 -0
  264. package/clis/weread-official/utils.js +293 -0
  265. package/clis/weread-official/utils.test.js +242 -0
  266. package/clis/wikipedia/trending.js +7 -3
  267. package/clis/wikipedia/trending.test.js +57 -0
  268. package/clis/xianyu/chat.js +24 -109
  269. package/clis/xianyu/chat.test.js +5 -0
  270. package/clis/xianyu/im.js +322 -0
  271. package/clis/xianyu/im.test.js +253 -0
  272. package/clis/xianyu/inbox.js +96 -0
  273. package/clis/xianyu/messages.js +91 -0
  274. package/clis/xianyu/reply.js +82 -0
  275. package/clis/xiaohongshu/creator-note-detail.js +166 -28
  276. package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
  277. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  278. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  279. package/clis/xiaohongshu/creator-notes.js +252 -2
  280. package/clis/xiaohongshu/creator-notes.test.js +90 -1
  281. package/clis/xiaohongshu/creator-stats.js +2 -1
  282. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  283. package/clis/xiaohongshu/delete-note.js +260 -0
  284. package/clis/xiaohongshu/delete-note.test.js +172 -0
  285. package/clis/xiaohongshu/download.js +97 -39
  286. package/clis/xiaohongshu/download.test.js +201 -0
  287. package/clis/xiaohongshu/publish.js +48 -8
  288. package/clis/xiaohongshu/publish.test.js +65 -10
  289. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  290. package/clis/xiaohongshu/user.js +27 -4
  291. package/clis/xiaoyuzhou/download.js +1 -1
  292. package/clis/xiaoyuzhou/transcript.js +1 -1
  293. package/clis/youdao/note.js +258 -0
  294. package/clis/youdao/note.test.js +99 -0
  295. package/clis/youtube/transcript.js +397 -24
  296. package/clis/youtube/transcript.test.js +196 -6
  297. package/clis/zhihu/answer-comments.js +280 -0
  298. package/clis/zhihu/answer-comments.test.js +287 -0
  299. package/clis/zhihu/answer-detail.js +2 -19
  300. package/clis/zhihu/answer-detail.test.js +8 -0
  301. package/clis/zhihu/collection.js +17 -16
  302. package/clis/zhihu/collection.test.js +50 -3
  303. package/clis/zhihu/download.js +1 -1
  304. package/clis/zhihu/question.js +42 -17
  305. package/clis/zhihu/question.test.js +113 -11
  306. package/clis/zhihu/search.js +195 -43
  307. package/clis/zhihu/search.test.js +198 -0
  308. package/clis/zhihu/text.js +29 -0
  309. package/clis/zhihu/text.test.js +24 -0
  310. package/dist/src/browser/errors.js +4 -2
  311. package/dist/src/browser/errors.test.js +6 -0
  312. package/dist/src/browser/network-cache.js +13 -1
  313. package/dist/src/browser/network-cache.test.js +17 -0
  314. package/dist/src/browser/page.js +30 -4
  315. package/dist/src/browser/page.test.js +42 -0
  316. package/dist/src/browser/utils.d.ts +1 -1
  317. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  318. package/dist/src/cli-argv-preprocess.js +138 -0
  319. package/dist/src/cli-argv-preprocess.test.js +79 -0
  320. package/dist/src/convention-audit.js +15 -8
  321. package/dist/src/convention-audit.test.js +21 -0
  322. package/dist/src/download/index.js +13 -1
  323. package/dist/src/download/index.test.js +23 -1
  324. package/dist/src/download/media-download.js +15 -2
  325. package/dist/src/download/media-download.test.d.ts +1 -0
  326. package/dist/src/download/media-download.test.js +112 -0
  327. package/dist/src/download/progress.js +2 -2
  328. package/dist/src/download/progress.test.js +12 -1
  329. package/dist/src/electron-apps.js +1 -1
  330. package/dist/src/electron-apps.test.js +7 -2
  331. package/dist/src/errors.d.ts +17 -0
  332. package/dist/src/errors.js +22 -0
  333. package/dist/src/external-clis.yaml +8 -0
  334. package/dist/src/main.js +14 -2
  335. package/dist/src/output.js +11 -1
  336. package/dist/src/output.test.js +6 -0
  337. package/dist/src/registry.js +1 -0
  338. package/dist/src/registry.test.js +11 -0
  339. package/dist/src/utils.d.ts +43 -0
  340. package/dist/src/utils.js +97 -0
  341. package/dist/src/utils.test.d.ts +1 -0
  342. package/dist/src/utils.test.js +155 -0
  343. package/package.json +8 -2
  344. package/scripts/silent-column-drop-baseline.json +0 -52
  345. package/scripts/typed-error-lint-baseline.json +28 -380
  346. package/clis/slock/_utils.js +0 -12
@@ -1,5 +1,6 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
4
  import './search.js';
4
5
  describe('weread/search regression', () => {
5
6
  beforeEach(() => {
@@ -146,7 +147,7 @@ describe('weread/search regression', () => {
146
147
  },
147
148
  ]);
148
149
  });
149
- it('falls back to empty urls when the search html request fails', async () => {
150
+ it('surfaces search html request failures instead of emitting empty urls', async () => {
150
151
  const command = getRegistry().get('weread/search');
151
152
  expect(command?.func).toBeTypeOf('function');
152
153
  const fetchMock = vi.fn()
@@ -166,16 +167,22 @@ describe('weread/search regression', () => {
166
167
  })
167
168
  .mockRejectedValueOnce(new Error('network timeout'));
168
169
  vi.stubGlobal('fetch', fetchMock);
169
- const result = await command.func({ query: 'deep work', limit: 5 });
170
- expect(result).toEqual([
171
- {
172
- rank: 1,
173
- title: 'Deep Work',
174
- author: 'Cal Newport',
175
- bookId: 'abc123',
176
- url: '',
177
- },
178
- ]);
170
+ await expect(command.func({ query: 'deep work', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
171
+ });
172
+ it('throws EmptyResultError when the public search API returns no books', async () => {
173
+ const command = getRegistry().get('weread/search');
174
+ expect(command?.func).toBeTypeOf('function');
175
+ const fetchMock = vi.fn()
176
+ .mockResolvedValueOnce({
177
+ ok: true,
178
+ json: () => Promise.resolve({ books: [] }),
179
+ })
180
+ .mockResolvedValueOnce({
181
+ ok: true,
182
+ text: () => Promise.resolve('<html></html>'),
183
+ });
184
+ vi.stubGlobal('fetch', fetchMock);
185
+ await expect(command.func({ query: 'definitely-missing-book', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
179
186
  });
180
187
  it('binds reader urls with title and author instead of title alone', async () => {
181
188
  const command = getRegistry().get('weread/search');
@@ -238,6 +245,86 @@ describe('weread/search regression', () => {
238
245
  },
239
246
  ]);
240
247
  });
248
+ it('decodes named and astral HTML entities before matching search cards', async () => {
249
+ const command = getRegistry().get('weread/search');
250
+ expect(command?.func).toBeTypeOf('function');
251
+ const fetchMock = vi.fn()
252
+ .mockResolvedValueOnce({
253
+ ok: true,
254
+ json: () => Promise.resolve({
255
+ books: [
256
+ {
257
+ bookInfo: {
258
+ title: 'A <B> 😊',
259
+ author: "O'Neil & Co",
260
+ bookId: 'entity-book',
261
+ },
262
+ },
263
+ ],
264
+ }),
265
+ })
266
+ .mockResolvedValueOnce({
267
+ ok: true,
268
+ text: () => Promise.resolve(`
269
+ <ul class="search_bookDetail_list">
270
+ <li class="wr_bookList_item">
271
+ <a class="wr_bookList_item_link" href="/web/reader/entity-reader"></a>
272
+ <p class="wr_bookList_item_title">A &lt;B&gt; &#x1F60A;</p>
273
+ <p class="wr_bookList_item_author">O&apos;Neil &amp; Co</p>
274
+ </li>
275
+ </ul>
276
+ `),
277
+ });
278
+ vi.stubGlobal('fetch', fetchMock);
279
+ const result = await command.func({ query: 'entities', limit: 5 });
280
+ expect(result).toEqual([
281
+ {
282
+ rank: 1,
283
+ title: 'A <B> 😊',
284
+ author: "O'Neil & Co",
285
+ bookId: 'entity-book',
286
+ url: 'https://weread.qq.com/web/reader/entity-reader',
287
+ },
288
+ ]);
289
+ });
290
+ it('leaves invalid numeric HTML entities literal instead of throwing raw RangeError', async () => {
291
+ const command = getRegistry().get('weread/search');
292
+ expect(command?.func).toBeTypeOf('function');
293
+ const fetchMock = vi.fn()
294
+ .mockResolvedValueOnce({
295
+ ok: true,
296
+ json: () => Promise.resolve({
297
+ books: [
298
+ {
299
+ bookInfo: {
300
+ title: 'Bad &#xFFFFFFFF; Entity',
301
+ author: 'Tester',
302
+ bookId: 'bad-entity-book',
303
+ },
304
+ },
305
+ ],
306
+ }),
307
+ })
308
+ .mockResolvedValueOnce({
309
+ ok: true,
310
+ text: () => Promise.resolve(`
311
+ <ul class="search_bookDetail_list">
312
+ <li class="wr_bookList_item">
313
+ <a class="wr_bookList_item_link" href="/web/reader/bad-entity-reader"></a>
314
+ <p class="wr_bookList_item_title">Bad &#xFFFFFFFF; Entity</p>
315
+ <p class="wr_bookList_item_author">Tester</p>
316
+ </li>
317
+ </ul>
318
+ `),
319
+ });
320
+ vi.stubGlobal('fetch', fetchMock);
321
+ const result = await command.func({ query: 'bad entity', limit: 5 });
322
+ expect(result[0]).toMatchObject({
323
+ title: 'Bad &#xFFFFFFFF; Entity',
324
+ bookId: 'bad-entity-book',
325
+ url: 'https://weread.qq.com/web/reader/bad-entity-reader',
326
+ });
327
+ });
241
328
  it('leaves urls empty when same-title results are ambiguous and html cards have no author', async () => {
242
329
  const command = getRegistry().get('weread/search');
243
330
  expect(command?.func).toBeTypeOf('function');
@@ -1,13 +1,29 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
3
  import { fetchWebApi, WEREAD_UA, WEREAD_WEB_ORIGIN } from './utils.js';
4
+ function decodeNumericHtmlEntity(raw, radix) {
5
+ const codePoint = parseInt(raw, radix);
6
+ if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10FFFF) {
7
+ return null;
8
+ }
9
+ try {
10
+ return String.fromCodePoint(codePoint);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
3
16
  function decodeHtmlText(value) {
4
17
  return value
5
18
  .replace(/<[^>]+>/g, '')
6
- .replace(/&#x([0-9a-fA-F]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)))
7
- .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
19
+ .replace(/&#x([0-9a-fA-F]+);/gi, (entity, n) => decodeNumericHtmlEntity(n, 16) ?? entity)
20
+ .replace(/&#(\d+);/g, (entity, n) => decodeNumericHtmlEntity(n, 10) ?? entity)
8
21
  .replace(/&nbsp;/g, ' ')
9
22
  .replace(/&amp;/g, '&')
10
23
  .replace(/&quot;/g, '"')
24
+ .replace(/&apos;/g, "'")
25
+ .replace(/&lt;/g, '<')
26
+ .replace(/&gt;/g, '>')
11
27
  .trim();
12
28
  }
13
29
  function normalizeSearchTitle(value) {
@@ -84,18 +100,19 @@ function resolveSearchResultUrl(params) {
84
100
  async function loadSearchHtmlEntries(query) {
85
101
  const url = new URL('/web/search/books', WEREAD_WEB_ORIGIN);
86
102
  url.searchParams.set('keyword', query);
87
- let html = '';
103
+ let resp;
88
104
  try {
89
- const resp = await fetch(url.toString(), {
105
+ resp = await fetch(url.toString(), {
90
106
  headers: { 'User-Agent': WEREAD_UA },
91
107
  });
92
- if (!resp.ok)
93
- return [];
94
- html = await resp.text();
95
108
  }
96
- catch {
97
- return [];
109
+ catch (error) {
110
+ throw new CommandExecutionError(`Failed to fetch WeRead search page: ${error instanceof Error ? error.message : String(error)}`);
111
+ }
112
+ if (!resp.ok) {
113
+ throw new CommandExecutionError(`WeRead search page request failed: HTTP ${resp.status}`);
98
114
  }
115
+ const html = await resp.text();
99
116
  const items = Array.from(html.matchAll(/<li[^>]*class="wr_bookList_item"[^>]*>([\s\S]*?)<\/li>/g));
100
117
  return items.map((match) => {
101
118
  const chunk = match[1];
@@ -131,6 +148,12 @@ cli({
131
148
  loadSearchHtmlEntries(String(args.query ?? '')),
132
149
  ]);
133
150
  const books = data?.books ?? [];
151
+ if (!Array.isArray(books)) {
152
+ throw new CommandExecutionError('WeRead search API returned an unreadable books payload');
153
+ }
154
+ if (books.length === 0) {
155
+ throw new EmptyResultError('weread search', `No books were returned for query ${args.query}.`);
156
+ }
134
157
  const { exactQueues, titleOnlyQueues } = buildSearchUrlQueues(htmlEntries);
135
158
  const apiIdentityCounts = countSearchIdentities(books.map((item) => ({
136
159
  title: item.bookInfo?.title ?? '',
@@ -0,0 +1,135 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import {
3
+ callGateway,
4
+ emptyResult,
5
+ formatDate,
6
+ formatDuration,
7
+ formatRating,
8
+ makeDeepLink,
9
+ requireBookId,
10
+ truncate,
11
+ } from './utils.js';
12
+
13
+ /**
14
+ * 3-in-1 book lookup: `/book/info` always runs; `/book/chapterinfo` and
15
+ * `/book/getprogress` are opt-out via `--no-chapters` / `--no-progress`
16
+ * so users can shave 1-2 gateway calls when only a single section is needed.
17
+ *
18
+ * Output is a flat row list with a `section` column so table/json/yaml
19
+ * consumers can filter without ad-hoc joins:
20
+ * - section=info — single row, basic metadata
21
+ * - section=chapter — one row per chapter (level/title/wordCount)
22
+ * - section=progress — single row, progress % + cumulative read time
23
+ */
24
+ cli({
25
+ site: 'weread-official',
26
+ name: 'book',
27
+ access: 'read',
28
+ description: 'Show WeRead book metadata, chapters, and reading progress',
29
+ domain: 'weread.qq.com',
30
+ strategy: Strategy.PUBLIC,
31
+ browser: false,
32
+ args: [
33
+ { name: 'bookId', positional: true, required: true, help: 'WeRead bookId (from `weread-official search`)' },
34
+ { name: 'no-chapters', type: 'boolean', default: false, help: 'Skip /book/chapterinfo call' },
35
+ { name: 'no-progress', type: 'boolean', default: false, help: 'Skip /book/getprogress call' },
36
+ ],
37
+ columns: ['section', 'idx', 'key', 'value', 'link'],
38
+ func: async (args) => {
39
+ const bookId = requireBookId(args.bookId);
40
+
41
+ const tasks = [callGateway('/book/info', { bookId })];
42
+ const want = {
43
+ chapters: !args['no-chapters'],
44
+ progress: !args['no-progress'],
45
+ };
46
+ if (want.chapters) tasks.push(callGateway('/book/chapterinfo', { bookId }));
47
+ if (want.progress) tasks.push(callGateway('/book/getprogress', { bookId }));
48
+
49
+ const results = await Promise.all(tasks);
50
+ let cursor = 0;
51
+ const info = results[cursor++];
52
+ const chapters = want.chapters ? results[cursor++] : null;
53
+ const progress = want.progress ? results[cursor++] : null;
54
+
55
+ const rows = [];
56
+
57
+ // ── info section ────────────────────────────────────────────────────
58
+ const infoPairs = [
59
+ ['bookId', String(info?.bookId ?? bookId)],
60
+ ['title', String(info?.title ?? '')],
61
+ ['author', String(info?.author ?? '')],
62
+ ['translator', String(info?.translator ?? '')],
63
+ ['category', String(info?.category ?? '')],
64
+ ['publisher', String(info?.publisher ?? '')],
65
+ ['publishTime', String(info?.publishTime ?? '')],
66
+ ['isbn', String(info?.isbn ?? '')],
67
+ ['wordCount', String(info?.wordCount ?? '')],
68
+ ['rating', formatRating(info?.newRating)],
69
+ ['ratingCount', String(info?.newRatingCount ?? '')],
70
+ ['intro', truncate(info?.intro, 400)],
71
+ ['cover', String(info?.cover ?? '')],
72
+ ];
73
+ for (let i = 0; i < infoPairs.length; i += 1) {
74
+ const [key, value] = infoPairs[i];
75
+ rows.push({ section: 'info', idx: i + 1, key, value, link: '' });
76
+ }
77
+ rows.push({
78
+ section: 'info',
79
+ idx: infoPairs.length + 1,
80
+ key: 'link',
81
+ value: '',
82
+ link: makeDeepLink({ bookId }),
83
+ });
84
+
85
+ // ── chapters section ────────────────────────────────────────────────
86
+ if (chapters) {
87
+ const list = Array.isArray(chapters?.chapters) ? chapters.chapters : [];
88
+ list.forEach((ch, i) => {
89
+ const chapterUid = String(ch?.chapterUid ?? '').trim();
90
+ const level = Number(ch?.level ?? 1);
91
+ const indent = ' '.repeat(Math.max(0, level - 1));
92
+ const title = `${indent}${String(ch?.title ?? '')}`;
93
+ const wordCount = Number(ch?.wordCount ?? 0);
94
+ const paid = Number(ch?.paid ?? 0) === 1;
95
+ const price = Number(ch?.price ?? 0);
96
+ const meta = [`${wordCount}字`];
97
+ if (price > 0) meta.push(paid ? '已购买' : `${price}元`);
98
+ rows.push({
99
+ section: 'chapter',
100
+ idx: Number(ch?.chapterIdx ?? i + 1),
101
+ key: chapterUid,
102
+ value: `${title} (${meta.join(' · ')})`,
103
+ link: chapterUid ? makeDeepLink({ bookId, chapterUid }) : '',
104
+ });
105
+ });
106
+ }
107
+
108
+ // ── progress section ────────────────────────────────────────────────
109
+ if (progress) {
110
+ const p = progress?.book ?? {};
111
+ const pct = Number(p?.progress ?? 0);
112
+ const updateTime = formatDate(p?.updateTime);
113
+ const finishTime = pct === 100 ? formatDate(p?.finishTime) : '';
114
+ const cumulative = formatDuration(p?.recordReadingTime);
115
+ const isStart = Number(p?.isStartReading ?? 0) === 1;
116
+ const progressPairs = [
117
+ ['progress', `${pct}%`],
118
+ ['cumulative', cumulative],
119
+ ['lastReadAt', updateTime],
120
+ ['finishedAt', finishTime],
121
+ ['isStartReading', isStart ? 'true' : 'false'],
122
+ ['currentChapterUid', String(p?.chapterUid ?? '')],
123
+ ];
124
+ for (let i = 0; i < progressPairs.length; i += 1) {
125
+ const [key, value] = progressPairs[i];
126
+ rows.push({ section: 'progress', idx: i + 1, key, value, link: '' });
127
+ }
128
+ }
129
+
130
+ if (rows.length === 0) {
131
+ emptyResult('book', `No data returned for bookId=${bookId}`);
132
+ }
133
+ return rows;
134
+ },
135
+ });