@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
@@ -12,13 +12,15 @@ describe('zhihu question', () => {
12
12
  // user-requested `--limit 3` is enforced by the dedup loop's
13
13
  // `answers.length >= answerLimit` break, not by the fetch URL.
14
14
  expect(js).toContain('questions/2021881398772981878/answers?limit=20');
15
+ expect(js).toContain('content,url,voteup_count');
15
16
  expect(js).toContain("credentials: 'include'");
16
17
  return {
17
18
  data: [
18
19
  {
20
+ id: '2036567240334653053',
19
21
  author: { name: 'alice' },
20
22
  voteup_count: 12,
21
- content: 'Hello Zhihu',
23
+ content: '<p>&#34;Hello&#34; &#x26; Zhihu</p>',
22
24
  },
23
25
  ],
24
26
  };
@@ -27,22 +29,95 @@ describe('zhihu question', () => {
27
29
  await expect(cmd.func(page, { id: '2021881398772981878', limit: 3 })).resolves.toEqual([
28
30
  {
29
31
  rank: 1,
32
+ id: '2036567240334653053',
30
33
  author: 'alice',
31
34
  votes: 12,
32
- content: 'Hello Zhihu',
35
+ url: 'https://www.zhihu.com/question/2021881398772981878/answer/2036567240334653053',
36
+ content: '"Hello" & Zhihu',
33
37
  },
34
38
  ]);
35
39
  expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/question/2021881398772981878');
36
40
  expect(evaluate).toHaveBeenCalledTimes(1);
37
41
  });
42
+ it('prefers the answer URL when extracting large answer IDs', async () => {
43
+ const cmd = getRegistry().get('zhihu/question');
44
+ const page = {
45
+ goto: vi.fn().mockResolvedValue(undefined),
46
+ evaluate: vi.fn().mockResolvedValue({
47
+ data: [
48
+ {
49
+ id: 2036567240334653000,
50
+ url: 'https://www.zhihu.com/api/v4/answers/2036567240334653053',
51
+ author: { name: 'alice' },
52
+ voteup_count: 12,
53
+ content: '<p>precise id</p>',
54
+ },
55
+ ],
56
+ paging: { is_end: true },
57
+ }),
58
+ };
59
+ await expect(cmd.func(page, { id: '2021881398772981878', limit: 1 })).resolves.toEqual([
60
+ {
61
+ rank: 1,
62
+ id: '2036567240334653053',
63
+ author: 'alice',
64
+ votes: 12,
65
+ url: 'https://www.zhihu.com/question/2021881398772981878/answer/2036567240334653053',
66
+ content: 'precise id',
67
+ },
68
+ ]);
69
+ });
70
+ it('deduplicates paginated answers by precise answer URL identity', async () => {
71
+ const cmd = getRegistry().get('zhihu/question');
72
+ const page = {
73
+ goto: vi.fn().mockResolvedValue(undefined),
74
+ evaluate: vi.fn().mockResolvedValue({
75
+ data: [
76
+ {
77
+ id: 2036567240334653000,
78
+ url: 'https://www.zhihu.com/api/v4/answers/2036567240334653053',
79
+ author: { name: 'alice' },
80
+ voteup_count: 12,
81
+ content: '<p>first precise id</p>',
82
+ },
83
+ {
84
+ id: 2036567240334653000,
85
+ url: 'https://www.zhihu.com/api/v4/answers/2036567240334653054',
86
+ author: { name: 'bob' },
87
+ voteup_count: 8,
88
+ content: '<p>second precise id</p>',
89
+ },
90
+ ],
91
+ paging: { is_end: true },
92
+ }),
93
+ };
94
+ await expect(cmd.func(page, { id: '2021881398772981878', limit: 2 })).resolves.toEqual([
95
+ {
96
+ rank: 1,
97
+ id: '2036567240334653053',
98
+ author: 'alice',
99
+ votes: 12,
100
+ url: 'https://www.zhihu.com/question/2021881398772981878/answer/2036567240334653053',
101
+ content: 'first precise id',
102
+ },
103
+ {
104
+ rank: 2,
105
+ id: '2036567240334653054',
106
+ author: 'bob',
107
+ votes: 8,
108
+ url: 'https://www.zhihu.com/question/2021881398772981878/answer/2036567240334653054',
109
+ content: 'second precise id',
110
+ },
111
+ ]);
112
+ });
38
113
  it('follows paging.next until the requested limit is reached', async () => {
39
114
  const cmd = getRegistry().get('zhihu/question');
40
115
  const goto = vi.fn().mockResolvedValue(undefined);
41
116
  const evaluate = vi.fn()
42
117
  .mockResolvedValueOnce({
43
118
  data: [
44
- { id: 'a1', author: { name: 'alice' }, voteup_count: 12, content: '<p>first</p>' },
45
- { id: 'a2', author: { name: 'bob' }, voteup_count: 8, content: '<p>second</p>' },
119
+ { id: '101', author: { name: 'alice' }, voteup_count: 12, content: '<p>first</p>' },
120
+ { id: '102', author: { name: 'bob' }, voteup_count: 8, content: '<p>second</p>' },
46
121
  ],
47
122
  paging: {
48
123
  is_end: false,
@@ -51,16 +126,16 @@ describe('zhihu question', () => {
51
126
  })
52
127
  .mockResolvedValueOnce({
53
128
  data: [
54
- { id: 'a2', author: { name: 'bob duplicate' }, voteup_count: 8, content: '<p>duplicate</p>' },
55
- { id: 'a3', author: { name: 'carol' }, voteup_count: 5, content: '<p>third</p>' },
129
+ { id: '102', author: { name: 'bob duplicate' }, voteup_count: 8, content: '<p>duplicate</p>' },
130
+ { id: '103', author: { name: 'carol' }, voteup_count: 5, content: '<p>third</p>' },
56
131
  ],
57
132
  paging: { is_end: true },
58
133
  });
59
134
  const page = { goto, evaluate };
60
135
  await expect(cmd.func(page, { id: '2021881398772981878', limit: 3 })).resolves.toEqual([
61
- { rank: 1, author: 'alice', votes: 12, content: 'first' },
62
- { rank: 2, author: 'bob', votes: 8, content: 'second' },
63
- { rank: 3, author: 'carol', votes: 5, content: 'third' },
136
+ { rank: 1, id: '101', author: 'alice', votes: 12, url: 'https://www.zhihu.com/question/2021881398772981878/answer/101', content: 'first' },
137
+ { rank: 2, id: '102', author: 'bob', votes: 8, url: 'https://www.zhihu.com/question/2021881398772981878/answer/102', content: 'second' },
138
+ { rank: 3, id: '103', author: 'carol', votes: 5, url: 'https://www.zhihu.com/question/2021881398772981878/answer/103', content: 'third' },
64
139
  ]);
65
140
  expect(evaluate).toHaveBeenCalledTimes(2);
66
141
  expect(evaluate.mock.calls[1][0]).toContain('offset=80');
@@ -73,7 +148,7 @@ describe('zhihu question', () => {
73
148
  return {
74
149
  data: [
75
150
  {
76
- id: 'a1',
151
+ id: '101',
77
152
  author: { name: 'newest' },
78
153
  voteup_count: 1,
79
154
  content: '<p>created order</p>',
@@ -84,10 +159,37 @@ describe('zhihu question', () => {
84
159
  });
85
160
  const page = { goto, evaluate };
86
161
  await expect(cmd.func(page, { id: '2021881398772981878', limit: 1, sort: 'created' })).resolves.toEqual([
87
- { rank: 1, author: 'newest', votes: 1, content: 'created order' },
162
+ { rank: 1, id: '101', author: 'newest', votes: 1, url: 'https://www.zhihu.com/question/2021881398772981878/answer/101', content: 'created order' },
88
163
  ]);
89
164
  expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/question/2021881398772981878/answers/updated');
90
165
  });
166
+ it('does not emit a fake answer URL for malformed answer IDs', async () => {
167
+ const cmd = getRegistry().get('zhihu/question');
168
+ const page = {
169
+ goto: vi.fn().mockResolvedValue(undefined),
170
+ evaluate: vi.fn().mockResolvedValue({
171
+ data: [
172
+ {
173
+ id: 'not-an-id',
174
+ author: { name: 'alice' },
175
+ voteup_count: 12,
176
+ content: '<p>malformed id</p>',
177
+ },
178
+ ],
179
+ paging: { is_end: true },
180
+ }),
181
+ };
182
+ await expect(cmd.func(page, { id: '2021881398772981878', limit: 1 })).resolves.toEqual([
183
+ {
184
+ rank: 1,
185
+ id: '',
186
+ author: 'alice',
187
+ votes: 12,
188
+ url: '',
189
+ content: 'malformed id',
190
+ },
191
+ ]);
192
+ });
91
193
  it('maps auth-like answer failures to AuthRequiredError', async () => {
92
194
  const cmd = getRegistry().get('zhihu/question');
93
195
  const page = {
@@ -1,54 +1,206 @@
1
- import { cli } from '@jackwener/opencli/registry';
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { stripHtml } from './text.js';
4
+
5
+ function itemKey(item) {
6
+ const obj = item.object || {};
7
+ if (obj.id != null) return `${obj.type || ''}:${obj.id}`;
8
+ return null;
9
+ }
10
+
11
+ function itemUrl(obj) {
12
+ const id = obj.id == null ? '' : String(obj.id);
13
+ if (obj.type === 'answer') {
14
+ const questionId = obj.question?.id == null ? '' : String(obj.question.id);
15
+ return questionId && id ? `https://www.zhihu.com/question/${questionId}/answer/${id}` : '';
16
+ }
17
+ if (obj.type === 'article') {
18
+ return id ? `https://zhuanlan.zhihu.com/p/${id}` : '';
19
+ }
20
+ if (obj.type === 'question') {
21
+ return id ? `https://www.zhihu.com/question/${id}` : '';
22
+ }
23
+ return '';
24
+ }
25
+
26
+ function normalizeSearchUrl(url) {
27
+ if (typeof url !== 'string' || !url) return '';
28
+ try {
29
+ const parsed = new URL(url);
30
+ if (parsed.hostname === 'api.zhihu.com' && parsed.pathname === '/search_v3') {
31
+ return `https://www.zhihu.com/api/v4/search_v3${parsed.search}`;
32
+ }
33
+ if (parsed.hostname === 'www.zhihu.com' && parsed.pathname === '/api/v4/search_v3') {
34
+ return parsed.toString();
35
+ }
36
+ } catch {
37
+ return '';
38
+ }
39
+ return '';
40
+ }
41
+
42
+ const MAX_LIMIT = 1000;
43
+ const PAGE_SIZE = 20;
44
+ const TYPES = ['all', 'answer', 'article', 'question'];
45
+
46
+ function parseLimit(value) {
47
+ const limit = Number(value ?? 10);
48
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
49
+ throw new ArgumentError(`zhihu search --limit must be a positive integer no greater than ${MAX_LIMIT}`, 'Use a normal-sized limit to avoid slow requests or Zhihu risk controls');
50
+ }
51
+ return limit;
52
+ }
53
+
54
+ function requireQuery(value) {
55
+ const query = String(value || '').trim();
56
+ if (!query) {
57
+ throw new ArgumentError('zhihu search query must not be empty', 'Example: opencli zhihu search codex');
58
+ }
59
+ return query;
60
+ }
61
+
62
+ function requireType(value) {
63
+ const type = String(value || 'all');
64
+ if (!TYPES.includes(type)) {
65
+ throw new ArgumentError(`zhihu search --type must be one of: ${TYPES.join(', ')}`, 'Example: opencli zhihu search codex --type answer');
66
+ }
67
+ return type;
68
+ }
69
+
70
+ function unwrapEvaluateResult(payload) {
71
+ if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
72
+ return payload;
73
+ }
74
+
75
+ function requireSearchPayload(data, url) {
76
+ const payload = unwrapEvaluateResult(data);
77
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
78
+ throw new CommandExecutionError('Zhihu search returned malformed payload');
79
+ }
80
+ if (payload.__httpError) {
81
+ const status = payload.__httpError;
82
+ if (status === 401 || status === 403) {
83
+ throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch search results from Zhihu');
84
+ }
85
+ throw new CommandExecutionError(`Zhihu search request failed${status ? ` (HTTP ${status})` : ''}`, 'Try again later or rerun with -v for more detail');
86
+ }
87
+ if (payload.__fetchError) {
88
+ throw new CommandExecutionError('Zhihu search request failed', String(payload.__fetchError));
89
+ }
90
+ if (!Array.isArray(payload.data)) {
91
+ throw new CommandExecutionError('Zhihu search returned malformed data list', `URL: ${url}`);
92
+ }
93
+ if (!payload.paging || typeof payload.paging !== 'object') {
94
+ throw new CommandExecutionError('Zhihu search returned malformed paging data', `URL: ${url}`);
95
+ }
96
+ return payload;
97
+ }
98
+
99
+ function normalizeResultItem(item) {
100
+ if (!item || typeof item !== 'object' || item.type !== 'search_result' || !item.object || typeof item.object !== 'object') {
101
+ return null;
102
+ }
103
+ const obj = item.object;
104
+ if (obj.type !== 'answer' && obj.type !== 'article' && obj.type !== 'question') return null;
105
+ const key = itemKey(item);
106
+ const url = itemUrl(obj);
107
+ const question = obj.question || {};
108
+ const title = stripHtml(obj.title || question.name || question.title || '');
109
+ if (!key || !url || !title) {
110
+ throw new CommandExecutionError('Zhihu search returned malformed result row identity');
111
+ }
112
+ return {
113
+ item,
114
+ key,
115
+ row: {
116
+ title,
117
+ type: obj.type,
118
+ author: obj.author?.name || '',
119
+ votes: obj.voteup_count || 0,
120
+ url,
121
+ },
122
+ };
123
+ }
124
+
2
125
  cli({
3
126
  site: 'zhihu',
4
127
  name: 'search',
5
128
  access: 'read',
6
129
  description: '知乎搜索',
7
130
  domain: 'www.zhihu.com',
131
+ strategy: Strategy.COOKIE,
8
132
  args: [
9
133
  { name: 'query', required: true, positional: true, help: 'Search query' },
10
- { name: 'limit', type: 'int', default: 10, help: 'Number of results' },
134
+ { name: 'limit', type: 'int', default: 10, help: 'Number of results (max 1000; use normal-sized requests)' },
135
+ { name: 'type', default: 'all', choices: TYPES, help: 'Result type: all, answer, article, or question' },
11
136
  ],
12
137
  columns: ['rank', 'title', 'type', 'author', 'votes', 'url'],
13
- pipeline: [
14
- { navigate: 'https://www.zhihu.com' },
15
- { evaluate: `(async () => {
16
- const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/<em>/g, '').replace(/<\\/em>/g, '').trim();
17
- const keyword = \${{ args.query | json }};
18
- const limit = \${{ args.limit }};
19
- var fetchLimit = Math.max(limit * 3, 30);
20
- const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + fetchLimit, {
21
- credentials: 'include'
22
- });
23
- const d = await res.json();
24
- return (d?.data || [])
25
- .filter(item => item.object && (item.object.type === 'answer' || item.object.type === 'article' || item.object.type === 'question'))
26
- .map(item => {
27
- const obj = item.object || {};
28
- const q = obj.question || {};
29
- return {
30
- type: obj.type,
31
- title: strip(obj.title || q.name || ''),
32
- excerpt: strip(obj.excerpt || '').substring(0, 100),
33
- author: obj.author?.name || '',
34
- votes: obj.voteup_count || 0,
35
- url: obj.type === 'answer'
36
- ? 'https://www.zhihu.com/question/' + q.id + '/answer/' + obj.id
37
- : obj.type === 'article'
38
- ? 'https://zhuanlan.zhihu.com/p/' + obj.id
39
- : 'https://www.zhihu.com/question/' + obj.id,
40
- };
41
- });
42
- })()
43
- ` },
44
- { map: {
45
- rank: '${{ index + 1 }}',
46
- title: '${{ item.title }}',
47
- type: '${{ item.type }}',
48
- author: '${{ item.author }}',
49
- votes: '${{ item.votes }}',
50
- url: '${{ item.url }}',
51
- } },
52
- { limit: '${{ args.limit }}' },
53
- ],
138
+ func: async (page, kwargs) => {
139
+ const query = requireQuery(kwargs.query);
140
+ const resultLimit = parseLimit(kwargs.limit);
141
+ const type = requireType(kwargs.type);
142
+ await page.goto('https://www.zhihu.com');
143
+ let url = 'https://www.zhihu.com/api/v4/search_v3'
144
+ + `?q=${encodeURIComponent(query)}&t=general&offset=0&limit=${PAGE_SIZE}`;
145
+ const results = [];
146
+ const seen = new Set();
147
+ const visited = new Set();
148
+ while (url && results.length < resultLimit && !visited.has(url)) {
149
+ visited.add(url);
150
+ const data = requireSearchPayload(await page.evaluate(`
151
+ (async () => {
152
+ try {
153
+ const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
154
+ if (!r.ok) return { __httpError: r.status };
155
+ return await r.json();
156
+ } catch (err) {
157
+ return { __fetchError: err?.message || String(err) };
158
+ }
159
+ })()
160
+ `), url);
161
+ for (const item of data.data) {
162
+ const rawType = item?.object?.type;
163
+ if (type !== 'all' && rawType && rawType !== type) continue;
164
+ const normalized = normalizeResultItem(item);
165
+ if (!normalized) continue;
166
+ if (type !== 'all' && normalized.row.type !== type) continue;
167
+ if (seen.has(normalized.key)) continue;
168
+ seen.add(normalized.key);
169
+ results.push(normalized.row);
170
+ if (results.length >= resultLimit) break;
171
+ }
172
+ if (results.length >= resultLimit) break;
173
+ if (data.paging?.is_end) break;
174
+ const next = normalizeSearchUrl(data.paging?.next);
175
+ if (!next) {
176
+ throw new CommandExecutionError('Zhihu search pagination returned malformed next URL');
177
+ }
178
+ if (visited.has(next)) {
179
+ throw new CommandExecutionError('Zhihu search pagination returned a repeated next URL');
180
+ }
181
+ url = next;
182
+ }
183
+ if (results.length === 0) {
184
+ throw new EmptyResultError('zhihu search', `No ${type === 'all' ? '' : `${type} `}results found for "${query}"`);
185
+ }
186
+ return results.map((row, i) => {
187
+ return {
188
+ rank: i + 1,
189
+ ...row,
190
+ };
191
+ });
192
+ },
54
193
  });
194
+
195
+ export const __test__ = {
196
+ stripHtml,
197
+ itemKey,
198
+ itemUrl,
199
+ normalizeSearchUrl,
200
+ parseLimit,
201
+ requireQuery,
202
+ requireType,
203
+ unwrapEvaluateResult,
204
+ requireSearchPayload,
205
+ normalizeResultItem,
206
+ };
@@ -0,0 +1,198 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import './search.js';
5
+
6
+ const {
7
+ normalizeSearchUrl,
8
+ requireSearchPayload,
9
+ normalizeResultItem,
10
+ } = await import('./search.js').then((m) => m.__test__);
11
+
12
+ describe('zhihu search', () => {
13
+ it('returns search_result entries from the Zhihu search API', async () => {
14
+ const cmd = getRegistry().get('zhihu/search');
15
+ expect(cmd?.func).toBeTypeOf('function');
16
+ const goto = vi.fn().mockResolvedValue(undefined);
17
+ const evaluate = vi.fn().mockImplementation(async (js) => {
18
+ expect(js).toContain('/api/v4/search_v3');
19
+ expect(js).toContain('limit=20');
20
+ expect(js).toContain("credentials: 'include'");
21
+ return {
22
+ data: [
23
+ {
24
+ type: 'hot_timing',
25
+ object: {
26
+ type: 'hot_timing',
27
+ content_items: [
28
+ { object: { id: 'discussion-1', type: 'article', title: 'discussion' } },
29
+ ],
30
+ },
31
+ },
32
+ {
33
+ type: 'search_result',
34
+ object: {
35
+ id: 'a1',
36
+ type: 'answer',
37
+ author: { name: 'alice' },
38
+ voteup_count: 12,
39
+ question: { id: 'q1', name: '<em>Codex</em> &#34;question&#34;' },
40
+ },
41
+ },
42
+ {
43
+ type: 'search_result',
44
+ object: {
45
+ id: 'p1',
46
+ type: 'article',
47
+ title: '<em>Codex</em> article',
48
+ author: { name: 'bob' },
49
+ voteup_count: 7,
50
+ },
51
+ },
52
+ ],
53
+ paging: { is_end: true },
54
+ };
55
+ });
56
+ const page = { goto, evaluate };
57
+ await expect(cmd.func(page, { query: 'codex', limit: 2 })).resolves.toEqual([
58
+ {
59
+ rank: 1,
60
+ title: 'Codex "question"',
61
+ type: 'answer',
62
+ author: 'alice',
63
+ votes: 12,
64
+ url: 'https://www.zhihu.com/question/q1/answer/a1',
65
+ },
66
+ {
67
+ rank: 2,
68
+ title: 'Codex article',
69
+ type: 'article',
70
+ author: 'bob',
71
+ votes: 7,
72
+ url: 'https://zhuanlan.zhihu.com/p/p1',
73
+ },
74
+ ]);
75
+ expect(goto).toHaveBeenCalledWith('https://www.zhihu.com');
76
+ expect(evaluate).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it('follows paging.next until the requested limit is reached', async () => {
80
+ const cmd = getRegistry().get('zhihu/search');
81
+ const page = {
82
+ goto: vi.fn().mockResolvedValue(undefined),
83
+ evaluate: vi.fn()
84
+ .mockResolvedValueOnce({
85
+ data: [
86
+ { type: 'search_result', object: { id: 'a1', type: 'answer', question: { id: 'q1', name: 'first' } } },
87
+ { type: 'search_result', object: { id: 'a2', type: 'answer', question: { id: 'q2', name: 'second' } } },
88
+ ],
89
+ paging: {
90
+ is_end: false,
91
+ next: 'https://api.zhihu.com/search_v3?offset=20&q=codex',
92
+ },
93
+ })
94
+ .mockResolvedValueOnce({
95
+ data: [
96
+ { type: 'search_result', object: { id: 'a2', type: 'answer', question: { id: 'q2', name: 'duplicate' } } },
97
+ { type: 'search_result', object: { id: 'q3', type: 'question', title: 'third' } },
98
+ ],
99
+ paging: { is_end: true },
100
+ }),
101
+ };
102
+ await expect(cmd.func(page, { query: 'codex', limit: 3 })).resolves.toEqual([
103
+ { rank: 1, title: 'first', type: 'answer', author: '', votes: 0, url: 'https://www.zhihu.com/question/q1/answer/a1' },
104
+ { rank: 2, title: 'second', type: 'answer', author: '', votes: 0, url: 'https://www.zhihu.com/question/q2/answer/a2' },
105
+ { rank: 3, title: 'third', type: 'question', author: '', votes: 0, url: 'https://www.zhihu.com/question/q3' },
106
+ ]);
107
+ expect(page.evaluate).toHaveBeenCalledTimes(2);
108
+ expect(page.evaluate.mock.calls[1][0]).toContain('https://www.zhihu.com/api/v4/search_v3?offset=20&q=codex');
109
+ });
110
+
111
+ it('filters by result type', async () => {
112
+ const cmd = getRegistry().get('zhihu/search');
113
+ const page = {
114
+ goto: vi.fn().mockResolvedValue(undefined),
115
+ evaluate: vi.fn().mockResolvedValue({
116
+ data: [
117
+ { type: 'search_result', object: { id: 'a1', type: 'answer' } },
118
+ { type: 'search_result', object: { id: 'p1', type: 'article', title: 'article' } },
119
+ ],
120
+ paging: { is_end: true },
121
+ }),
122
+ };
123
+ await expect(cmd.func(page, { query: 'codex', limit: 2, type: 'article' })).resolves.toEqual([
124
+ { rank: 1, title: 'article', type: 'article', author: '', votes: 0, url: 'https://zhuanlan.zhihu.com/p/p1' },
125
+ ]);
126
+ });
127
+
128
+ it('maps auth-like failures to AuthRequiredError', async () => {
129
+ const cmd = getRegistry().get('zhihu/search');
130
+ const page = {
131
+ goto: vi.fn().mockResolvedValue(undefined),
132
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 403 }),
133
+ };
134
+ await expect(cmd.func(page, { query: 'codex', limit: 3 })).rejects.toBeInstanceOf(AuthRequiredError);
135
+ });
136
+
137
+ it('preserves non-auth fetch failures as typed execution errors', async () => {
138
+ const cmd = getRegistry().get('zhihu/search');
139
+ const page = {
140
+ goto: vi.fn().mockResolvedValue(undefined),
141
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 500 }),
142
+ };
143
+ await expect(cmd.func(page, { query: 'codex', limit: 3 }))
144
+ .rejects.toBeInstanceOf(CommandExecutionError);
145
+ });
146
+
147
+ it('rejects invalid input before navigation', async () => {
148
+ const cmd = getRegistry().get('zhihu/search');
149
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
150
+ await expect(cmd.func(page, { query: '', limit: 1 })).rejects.toBeInstanceOf(ArgumentError);
151
+ await expect(cmd.func(page, { query: 'codex', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
152
+ await expect(cmd.func(page, { query: 'codex', limit: 1001 })).rejects.toBeInstanceOf(ArgumentError);
153
+ await expect(cmd.func(page, { query: 'codex', limit: 1, type: 'video' })).rejects.toBeInstanceOf(ArgumentError);
154
+ expect(page.goto).not.toHaveBeenCalled();
155
+ expect(page.evaluate).not.toHaveBeenCalled();
156
+ });
157
+
158
+ it('unwraps Browser Bridge envelopes and fails typed on malformed payloads', () => {
159
+ const payload = { data: [], paging: { is_end: true } };
160
+ expect(requireSearchPayload({ session: {}, data: payload }, 'https://www.zhihu.com/api/v4/search_v3')).toBe(payload);
161
+ expect(() => requireSearchPayload(null, 'url')).toThrow(CommandExecutionError);
162
+ expect(() => requireSearchPayload({ data: null, paging: { is_end: true } }, 'url')).toThrow(CommandExecutionError);
163
+ expect(() => requireSearchPayload({ data: [], paging: null }, 'url')).toThrow(CommandExecutionError);
164
+ expect(() => requireSearchPayload({ __fetchError: 'network down' }, 'url')).toThrow(CommandExecutionError);
165
+ });
166
+
167
+ it('fails typed on malformed supported result rows instead of emitting blank identity rows', () => {
168
+ expect(() => normalizeResultItem({ type: 'search_result', object: { type: 'answer', id: 'a1', question: { name: 'missing question id' } } }))
169
+ .toThrow(CommandExecutionError);
170
+ expect(() => normalizeResultItem({ type: 'search_result', object: { type: 'article', id: 'p1' } }))
171
+ .toThrow(CommandExecutionError);
172
+ expect(normalizeResultItem({ type: 'hot_timing', object: { type: 'article', id: 'p1' } })).toBe(null);
173
+ });
174
+
175
+ it('rejects malformed pagination next URLs and reports valid empty result separately', async () => {
176
+ expect(normalizeSearchUrl('https://api.zhihu.com/search_v3?offset=20&q=codex'))
177
+ .toBe('https://www.zhihu.com/api/v4/search_v3?offset=20&q=codex');
178
+ expect(normalizeSearchUrl('https://evil.example/search_v3?offset=20')).toBe('');
179
+
180
+ const cmd = getRegistry().get('zhihu/search');
181
+ const malformedNextPage = {
182
+ goto: vi.fn().mockResolvedValue(undefined),
183
+ evaluate: vi.fn().mockResolvedValue({
184
+ data: [],
185
+ paging: { is_end: false, next: 'https://evil.example/search_v3?offset=20' },
186
+ }),
187
+ };
188
+ await expect(cmd.func(malformedNextPage, { query: 'codex', limit: 3 }))
189
+ .rejects.toBeInstanceOf(CommandExecutionError);
190
+
191
+ const emptyPage = {
192
+ goto: vi.fn().mockResolvedValue(undefined),
193
+ evaluate: vi.fn().mockResolvedValue({ data: [], paging: { is_end: true } }),
194
+ };
195
+ await expect(cmd.func(emptyPage, { query: 'codex', limit: 3 }))
196
+ .rejects.toBeInstanceOf(EmptyResultError);
197
+ });
198
+ });
@@ -0,0 +1,29 @@
1
+ function decodeEntity(codePoint) {
2
+ return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10FFFF
3
+ ? String.fromCodePoint(codePoint)
4
+ : null;
5
+ }
6
+
7
+ export function stripHtml(html, { preserveBlocks = false } = {}) {
8
+ if (!html) return '';
9
+ let text = String(html);
10
+ if (preserveBlocks) {
11
+ text = text
12
+ .replace(/<br\s*\/?\s*>/gi, '\n')
13
+ .replace(/<\/(?:p|div|h[1-6]|li|blockquote)>/gi, '\n\n');
14
+ }
15
+ return text
16
+ .replace(/<[^>]+>/g, '')
17
+ .replace(/&nbsp;/g, ' ')
18
+ .replace(/&lt;/g, '<')
19
+ .replace(/&gt;/g, '>')
20
+ .replace(/&amp;/g, '&')
21
+ .replace(/&quot;/g, '"')
22
+ .replace(/&#39;/g, "'")
23
+ .replace(/&#(\d+);/g, (entity, value) => decodeEntity(Number(value)) ?? entity)
24
+ .replace(/&#x([0-9a-f]+);/gi, (entity, value) => decodeEntity(Number.parseInt(value, 16)) ?? entity)
25
+ .replace(/\n{3,}/g, '\n\n')
26
+ .trim();
27
+ }
28
+
29
+ export const __test__ = { decodeEntity };