@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
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Bilibili comment — posts a top-level comment or a reply on a video via the official API.
3
+ * Uses /x/v2/reply/add, authenticated by the logged-in cookie + bili_jct CSRF token.
4
+ * @username mentions in the message are resolved to real mentions (at_name_to_mid).
5
+ */
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
8
+ import { apiGet, apiPost, resolveBvid, resolveUid } from './utils.js';
9
+
10
+ function isAuthLikeBilibiliError(code, message) {
11
+ return code === -101 || code === -111 || code === -403 || /csrf|登录|账号|权限|forbidden|permission|login/i.test(String(message ?? ''));
12
+ }
13
+
14
+ function requireOkPayload(payload, label) {
15
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Object.hasOwn(payload, 'code')) {
16
+ throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
17
+ }
18
+ if (payload.code !== 0) {
19
+ const message = payload.message ?? 'unknown error';
20
+ if (isAuthLikeBilibiliError(payload.code, message)) {
21
+ throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
22
+ }
23
+ throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
24
+ }
25
+ return payload.data;
26
+ }
27
+
28
+ function readPositiveInteger(value, label) {
29
+ const n = Number(value);
30
+ if (!Number.isInteger(n) || n <= 0) {
31
+ throw new ArgumentError(`bilibili comment ${label} must be a positive integer`);
32
+ }
33
+ return n;
34
+ }
35
+
36
+ cli({
37
+ site: 'bilibili',
38
+ name: 'comment',
39
+ access: 'write',
40
+ description: '在 B站视频下发表评论或回复(官方 API,需登录;消息里的 @用户 会被解析为真实提及)',
41
+ domain: 'www.bilibili.com',
42
+ strategy: Strategy.COOKIE,
43
+ args: [
44
+ { name: 'bvid', required: true, positional: true, help: 'Video BV ID / URL / b23.tv short link' },
45
+ { name: 'message', required: true, positional: true, help: 'Comment text. Any @username in it is resolved to a real mention' },
46
+ { name: 'parent', type: 'int', help: 'top-level/root rpid to reply under (omit for a top-level comment)' },
47
+ { name: 'execute', type: 'boolean', help: 'Actually post the comment. Without it the command refuses to write.' },
48
+ ],
49
+ columns: ['rpid', 'bvid', 'oid', 'message', 'url'],
50
+ func: async (page, kwargs) => {
51
+ if (!page) {
52
+ throw new CommandExecutionError('Browser session required for bilibili comment');
53
+ }
54
+ const message = String(kwargs.message ?? '').trim();
55
+ if (!message)
56
+ throw new ArgumentError('bilibili comment message cannot be empty');
57
+ // Write guard: posting is public and irreversible-ish, so require an explicit opt-in.
58
+ if (!kwargs.execute)
59
+ throw new ArgumentError('Refusing to post: pass --execute to actually publish this comment');
60
+ const parent = kwargs.parent != null ? readPositiveInteger(kwargs.parent, 'parent') : null;
61
+ let bvid;
62
+ try {
63
+ bvid = await resolveBvid(kwargs.bvid);
64
+ }
65
+ catch (error) {
66
+ throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${String(kwargs.bvid ?? '')}`, error instanceof Error ? error.message : String(error));
67
+ }
68
+ // Resolve bvid → aid (the reply API addresses videos by aid, as `oid`)
69
+ const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
70
+ const viewData = requireOkPayload(view, 'view');
71
+ const oid = viewData?.aid;
72
+ if (!oid)
73
+ throw new CommandExecutionError(`Cannot resolve aid for bvid: ${bvid}`);
74
+ // Resolve @username mentions to uids. Bilibili only turns "@name" into a real
75
+ // mention — one that notifies the mentioned user — when the request carries
76
+ // at_name_to_mid; a plain-text "@name" is otherwise inert and notifies nobody.
77
+ /** @type {Record<string, number>} */
78
+ const atNameToMid = {};
79
+ for (const match of message.matchAll(/@([^\s@]+)/g)) {
80
+ const name = match[1];
81
+ if (name in atNameToMid)
82
+ continue;
83
+ try {
84
+ const mid = Number(await resolveUid(page, name));
85
+ if (!Number.isInteger(mid) || mid <= 0) {
86
+ throw new CommandExecutionError(`Bilibili user search returned malformed mid for @${name}`);
87
+ }
88
+ atNameToMid[name] = mid;
89
+ }
90
+ catch (error) {
91
+ if (!(error instanceof EmptyResultError)) {
92
+ throw error;
93
+ }
94
+ // Unresolvable @name (typo, or not a user) — leave it as plain text.
95
+ }
96
+ }
97
+ // For a reply, Bilibili needs both `root` (top-level comment) and `parent`.
98
+ // Replying to a top-level comment means root === parent.
99
+ const params = {
100
+ oid,
101
+ type: 1,
102
+ message,
103
+ plat: 1,
104
+ ...(parent != null
105
+ ? { root: parent, parent }
106
+ : {}),
107
+ ...(Object.keys(atNameToMid).length > 0
108
+ ? { at_name_to_mid: JSON.stringify(atNameToMid) }
109
+ : {}),
110
+ };
111
+ const payload = await apiPost(page, '/x/v2/reply/add', { params });
112
+ const postData = requireOkPayload(payload, 'reply add');
113
+ const rpid = postData?.rpid;
114
+ if (!rpid) {
115
+ throw new CommandExecutionError('Bilibili reply add API did not return rpid for the posted comment');
116
+ }
117
+ return [{
118
+ rpid: String(rpid),
119
+ bvid,
120
+ oid: String(oid),
121
+ message,
122
+ url: `https://www.bilibili.com/video/${bvid}#reply${rpid}`,
123
+ }];
124
+ },
125
+ });
@@ -0,0 +1,153 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+
4
+ const { mockApiGet, mockApiPost, mockResolveUid } = vi.hoisted(() => ({
5
+ mockApiGet: vi.fn(),
6
+ mockApiPost: vi.fn(),
7
+ mockResolveUid: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('./utils.js', async (importOriginal) => ({
11
+ ...(await importOriginal()),
12
+ apiGet: mockApiGet,
13
+ apiPost: mockApiPost,
14
+ resolveUid: mockResolveUid,
15
+ }));
16
+
17
+ import { getRegistry } from '@jackwener/opencli/registry';
18
+ import './comment.js';
19
+
20
+ describe('bilibili comment', () => {
21
+ const command = getRegistry().get('bilibili/comment');
22
+
23
+ beforeEach(() => {
24
+ mockApiGet.mockReset();
25
+ mockApiPost.mockReset();
26
+ mockResolveUid.mockReset();
27
+ });
28
+
29
+ it('refuses to post without --execute', async () => {
30
+ await expect(
31
+ command.func({}, { bvid: 'BV1WtAGzYEBm', message: 'hi' }),
32
+ ).rejects.toThrow(/--execute/);
33
+ expect(mockApiPost).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it('rejects an empty message before calling the API', async () => {
37
+ await expect(
38
+ command.func({}, { bvid: 'BV1xxx', message: ' ', execute: true }),
39
+ ).rejects.toThrow(/empty/i);
40
+ expect(mockApiGet).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it('posts a top-level comment, resolving @mentions to at_name_to_mid', async () => {
44
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 12345 } });
45
+ mockResolveUid.mockResolvedValueOnce('1141159409'); // @AI视频小助理 → mid
46
+ mockApiPost.mockResolvedValueOnce({ code: 0, data: { rpid: 99887766 } });
47
+
48
+ const result = await command.func({}, {
49
+ bvid: 'BV1WtAGzYEBm', message: '@AI视频小助理 总结一下', execute: true,
50
+ });
51
+
52
+ expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
53
+ expect(mockResolveUid).toHaveBeenCalledWith({}, 'AI视频小助理');
54
+ expect(mockApiPost).toHaveBeenCalledWith({}, '/x/v2/reply/add', {
55
+ params: {
56
+ oid: 12345,
57
+ type: 1,
58
+ message: '@AI视频小助理 总结一下',
59
+ plat: 1,
60
+ at_name_to_mid: '{"AI视频小助理":1141159409}',
61
+ },
62
+ });
63
+ expect(result).toEqual([{
64
+ rpid: '99887766',
65
+ bvid: 'BV1WtAGzYEBm',
66
+ oid: '12345',
67
+ message: '@AI视频小助理 总结一下',
68
+ url: 'https://www.bilibili.com/video/BV1WtAGzYEBm#reply99887766',
69
+ }]);
70
+ });
71
+
72
+ it('still posts when an @mention cannot be resolved, leaving it as plain text', async () => {
73
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 7 } });
74
+ mockResolveUid.mockRejectedValueOnce(new EmptyResultError('bilibili user search'));
75
+ mockApiPost.mockResolvedValueOnce({ code: 0, data: { rpid: 5 } });
76
+
77
+ await command.func({}, { bvid: 'BV1xxx', message: '@幽灵用户zzz hi', execute: true });
78
+
79
+ expect(mockApiPost).toHaveBeenCalledWith({}, '/x/v2/reply/add', {
80
+ params: { oid: 7, type: 1, message: '@幽灵用户zzz hi', plat: 1 },
81
+ });
82
+ });
83
+
84
+ it('fails closed when mention resolution has parser or transport errors', async () => {
85
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 7 } });
86
+ mockResolveUid.mockRejectedValueOnce(new CommandExecutionError('search API drift'));
87
+
88
+ await expect(
89
+ command.func({}, { bvid: 'BV1xxx', message: '@用户 hi', execute: true }),
90
+ ).rejects.toBeInstanceOf(CommandExecutionError);
91
+ expect(mockApiPost).not.toHaveBeenCalled();
92
+ });
93
+
94
+ it('fails closed when mention resolution returns a malformed mid', async () => {
95
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 7 } });
96
+ mockResolveUid.mockResolvedValueOnce('not-a-mid');
97
+
98
+ await expect(
99
+ command.func({}, { bvid: 'BV1xxx', message: '@用户 hi', execute: true }),
100
+ ).rejects.toBeInstanceOf(CommandExecutionError);
101
+ expect(mockApiPost).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('posts a reply under an existing comment when --parent is given', async () => {
105
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 1 } });
106
+ mockApiPost.mockResolvedValueOnce({ code: 0, data: { rpid: 2 } });
107
+
108
+ await command.func({}, { bvid: 'BV1xxx', message: 'thanks', parent: 555, execute: true });
109
+
110
+ expect(mockApiPost).toHaveBeenCalledWith({}, '/x/v2/reply/add', {
111
+ params: { oid: 1, type: 1, message: 'thanks', plat: 1, root: 555, parent: 555 },
112
+ });
113
+ });
114
+
115
+ it('throws when the bvid cannot be resolved to an aid', async () => {
116
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: {} });
117
+ await expect(
118
+ command.func({}, { bvid: 'BVbroken', message: 'hi', execute: true }),
119
+ ).rejects.toBeInstanceOf(CommandExecutionError);
120
+ });
121
+
122
+ it('throws with the API code and message when Bilibili rejects the comment', async () => {
123
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 9 } });
124
+ mockApiPost.mockResolvedValueOnce({ code: 12025, message: '评论字数过多' });
125
+ await expect(
126
+ command.func({}, { bvid: 'BV1xxx', message: 'x', execute: true }),
127
+ ).rejects.toBeInstanceOf(CommandExecutionError);
128
+ });
129
+
130
+ it('maps login/csrf failures from the write API to AuthRequiredError', async () => {
131
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 9 } });
132
+ mockApiPost.mockResolvedValueOnce({ code: -111, message: 'csrf 校验失败' });
133
+ await expect(
134
+ command.func({}, { bvid: 'BV1xxx', message: 'x', execute: true }),
135
+ ).rejects.toBeInstanceOf(AuthRequiredError);
136
+ });
137
+
138
+ it('rejects invalid parent ids before posting', async () => {
139
+ await expect(
140
+ command.func({}, { bvid: 'BV1xxx', message: 'x', parent: 0, execute: true }),
141
+ ).rejects.toBeInstanceOf(ArgumentError);
142
+ expect(mockApiGet).not.toHaveBeenCalled();
143
+ expect(mockApiPost).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it('fails closed when the write API omits rpid', async () => {
147
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: { aid: 9 } });
148
+ mockApiPost.mockResolvedValueOnce({ code: 0, data: {} });
149
+ await expect(
150
+ command.func({}, { bvid: 'BV1xxx', message: 'x', execute: true }),
151
+ ).rejects.toBeInstanceOf(CommandExecutionError);
152
+ });
153
+ });
@@ -1,41 +1,136 @@
1
1
  /**
2
- * Bilibili comments — fetches top-level replies via the official API with WBI signing.
3
- * Uses the /x/v2/reply/main endpoint which is stable and doesn't depend on DOM structure.
2
+ * Bilibili comments — fetches comments via the official API.
3
+ * Top-level comments come from /x/v2/reply/main (WBI-signed); with --parent,
4
+ * the replies nested under a given comment come from /x/v2/reply/reply.
4
5
  */
5
6
  import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
6
8
  import { apiGet, resolveBvid } from './utils.js';
9
+
10
+ const MAX_LIMIT = 50;
11
+
12
+ function isAuthLikeBilibiliError(code, message) {
13
+ return code === -101 || code === -403 || /登录|账号|权限|forbidden|permission|login/i.test(String(message ?? ''));
14
+ }
15
+
16
+ function parseLimit(value) {
17
+ const raw = value == null ? 20 : value;
18
+ const limit = Number(raw);
19
+ if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
20
+ throw new ArgumentError(`bilibili comments limit must be an integer between 1 and ${MAX_LIMIT}`);
21
+ }
22
+ return limit;
23
+ }
24
+
25
+ function parseParent(value) {
26
+ if (value == null) {
27
+ return null;
28
+ }
29
+ const parent = Number(value);
30
+ if (!Number.isInteger(parent) || parent <= 0) {
31
+ throw new ArgumentError('bilibili comments parent must be a positive integer rpid');
32
+ }
33
+ return parent;
34
+ }
35
+
36
+ function requireOkPayload(payload, label) {
37
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) || !Object.hasOwn(payload, 'code')) {
38
+ throw new CommandExecutionError(`Bilibili ${label} API returned a malformed payload`);
39
+ }
40
+ if (payload.code !== 0) {
41
+ const message = payload.message ?? 'unknown error';
42
+ if (isAuthLikeBilibiliError(payload.code, message)) {
43
+ throw new AuthRequiredError('bilibili.com', `Bilibili ${label} API requires login or permission: ${message} (${payload.code})`);
44
+ }
45
+ throw new CommandExecutionError(`Bilibili ${label} API failed: ${message} (${payload.code})`);
46
+ }
47
+ return payload.data;
48
+ }
49
+
50
+ function requireReplies(data, label) {
51
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
52
+ throw new CommandExecutionError(`Bilibili ${label} API returned malformed data`);
53
+ }
54
+ if (!Object.hasOwn(data, 'replies')) {
55
+ throw new CommandExecutionError(`Bilibili ${label} API did not return replies`);
56
+ }
57
+ if (data.replies === null) {
58
+ return [];
59
+ }
60
+ if (!Array.isArray(data.replies)) {
61
+ throw new CommandExecutionError(`Bilibili ${label} API returned malformed replies`);
62
+ }
63
+ return data.replies;
64
+ }
65
+
66
+ function formatReplyRow(reply, index) {
67
+ if (!reply || typeof reply !== 'object' || Array.isArray(reply)) {
68
+ throw new CommandExecutionError(`Bilibili comments reply ${index + 1} was malformed`);
69
+ }
70
+ const rpid = String(reply.rpid ?? '').trim();
71
+ if (!rpid) {
72
+ throw new CommandExecutionError(`Bilibili comments reply ${index + 1} was missing rpid`);
73
+ }
74
+ const ctime = Number(reply.ctime);
75
+ if (!Number.isFinite(ctime)) {
76
+ throw new CommandExecutionError(`Bilibili comments reply ${index + 1} was missing ctime`);
77
+ }
78
+ return {
79
+ rank: index + 1,
80
+ rpid,
81
+ author: String(reply.member?.uname ?? ''),
82
+ text: String(reply.content?.message ?? '').replace(/\n/g, ' ').trim(),
83
+ likes: reply.like ?? 0,
84
+ replies: reply.rcount ?? 0,
85
+ time: new Date(ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
86
+ };
87
+ }
88
+
7
89
  cli({
8
90
  site: 'bilibili',
9
91
  name: 'comments',
10
92
  access: 'read',
11
- description: '获取 B站视频评论(使用官方 API + WBI 签名)',
93
+ description: '获取 B站视频评论(官方 API;用 --parent <rpid> 读取某条评论下的「楼中楼」回复)',
12
94
  domain: 'www.bilibili.com',
13
95
  strategy: Strategy.COOKIE,
14
96
  args: [
15
97
  { name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g. BV1WtAGzYEBm)' },
98
+ { name: 'parent', type: 'int', help: 'rpid of a comment — fetch the replies under it instead of top-level comments' },
16
99
  { name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
17
100
  ],
18
- columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
101
+ columns: ['rank', 'rpid', 'author', 'text', 'likes', 'replies', 'time'],
19
102
  func: async (page, kwargs) => {
20
- const bvid = await resolveBvid(kwargs.bvid);
21
- const limit = Math.min(Number(kwargs.limit) || 20, 50);
103
+ if (!page) {
104
+ throw new CommandExecutionError('Browser session required for bilibili comments');
105
+ }
106
+ let bvid;
107
+ try {
108
+ bvid = await resolveBvid(kwargs.bvid);
109
+ }
110
+ catch (error) {
111
+ throw new ArgumentError(`Cannot resolve Bilibili BV ID from input: ${String(kwargs.bvid ?? '')}`, error instanceof Error ? error.message : String(error));
112
+ }
113
+ const limit = parseLimit(kwargs.limit);
114
+ const parent = parseParent(kwargs.parent);
22
115
  // Resolve bvid → aid (required by reply API)
23
116
  const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
24
- const aid = view?.data?.aid;
117
+ const viewData = requireOkPayload(view, 'view');
118
+ const aid = viewData?.aid;
25
119
  if (!aid)
26
- throw new Error(`Cannot resolve aid for bvid: ${bvid}`);
27
- const payload = await apiGet(page, '/x/v2/reply/main', {
28
- params: { oid: aid, type: 1, mode: 3, ps: limit },
29
- signed: true,
30
- });
31
- const replies = payload?.data?.replies ?? [];
32
- return replies.slice(0, limit).map((r, i) => ({
33
- rank: i + 1,
34
- author: r.member?.uname ?? '',
35
- text: (r.content?.message ?? '').replace(/\n/g, ' ').trim(),
36
- likes: r.like ?? 0,
37
- replies: r.rcount ?? 0,
38
- time: new Date(r.ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
39
- }));
120
+ throw new CommandExecutionError(`Cannot resolve aid for bvid: ${bvid}`);
121
+ const payload = parent != null
122
+ ? await apiGet(page, '/x/v2/reply/reply', {
123
+ params: { oid: aid, type: 1, root: parent, pn: 1, ps: limit },
124
+ })
125
+ : await apiGet(page, '/x/v2/reply/main', {
126
+ params: { oid: aid, type: 1, mode: 3, ps: limit },
127
+ signed: true,
128
+ });
129
+ const label = parent != null ? 'reply thread' : 'reply main';
130
+ const replies = requireReplies(requireOkPayload(payload, label), label);
131
+ if (replies.length === 0) {
132
+ throw new EmptyResultError(parent != null ? `bilibili comment replies: ${parent}` : `bilibili comments: ${bvid}`);
133
+ }
134
+ return replies.slice(0, limit).map(formatReplyRow);
40
135
  },
41
136
  });
@@ -1,4 +1,5 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
3
  const { mockApiGet } = vi.hoisted(() => ({
3
4
  mockApiGet: vi.fn(),
4
5
  }));
@@ -15,11 +16,13 @@ describe('bilibili comments', () => {
15
16
  });
16
17
  it('resolves bvid to aid and fetches replies', async () => {
17
18
  mockApiGet
18
- .mockResolvedValueOnce({ data: { aid: 12345 } }) // view endpoint
19
+ .mockResolvedValueOnce({ code: 0, data: { aid: 12345 } }) // view endpoint
19
20
  .mockResolvedValueOnce({
21
+ code: 0,
20
22
  data: {
21
23
  replies: [
22
24
  {
25
+ rpid: 777,
23
26
  member: { uname: 'Alice' },
24
27
  content: { message: 'Great video!' },
25
28
  like: 42,
@@ -38,6 +41,7 @@ describe('bilibili comments', () => {
38
41
  expect(result).toEqual([
39
42
  {
40
43
  rank: 1,
44
+ rpid: '777',
41
45
  author: 'Alice',
42
46
  text: 'Great video!',
43
47
  likes: 42,
@@ -46,38 +50,93 @@ describe('bilibili comments', () => {
46
50
  },
47
51
  ]);
48
52
  });
53
+ it('fetches replies under a comment via /x/v2/reply/reply when --parent is given', async () => {
54
+ mockApiGet
55
+ .mockResolvedValueOnce({ code: 0, data: { aid: 12345 } }) // view endpoint
56
+ .mockResolvedValueOnce({
57
+ code: 0,
58
+ data: {
59
+ replies: [
60
+ {
61
+ rpid: 888,
62
+ member: { uname: 'AI视频小助理' },
63
+ content: { message: '视频总结:作者开了一家咖啡馆' },
64
+ like: 8,
65
+ rcount: 0,
66
+ ctime: 1700000000,
67
+ },
68
+ ],
69
+ },
70
+ });
71
+ const result = await command.func({}, { bvid: 'BV1WtAGzYEBm', parent: 777, limit: 5 });
72
+ expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
73
+ expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/reply', {
74
+ params: { oid: 12345, type: 1, root: 777, pn: 1, ps: 5 },
75
+ });
76
+ expect(result[0].author).toBe('AI视频小助理');
77
+ expect(result[0].rpid).toBe('888');
78
+ expect(result[0].text).toBe('视频总结:作者开了一家咖啡馆');
79
+ });
49
80
  it('throws when aid cannot be resolved', async () => {
50
- mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
51
- await expect(command.func({}, { bvid: 'BVinvalid123', limit: 5 })).rejects.toThrow('Cannot resolve aid for bvid: BVinvalid123');
81
+ mockApiGet.mockResolvedValueOnce({ code: 0, data: {} }); // no aid
82
+ await expect(command.func({}, { bvid: 'BVinvalid123', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
83
+ });
84
+ it('throws CommandExecutionError when replies is missing', async () => {
85
+ mockApiGet
86
+ .mockResolvedValueOnce({ code: 0, data: { aid: 99 } })
87
+ .mockResolvedValueOnce({ code: 0, data: {} }); // no replies key
88
+ await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
89
+ .rejects.toBeInstanceOf(CommandExecutionError);
90
+ });
91
+ it('rejects out-of-range limits instead of silently clamping', async () => {
92
+ await expect(command.func({}, { bvid: 'BV1xxx', limit: 999 }))
93
+ .rejects.toBeInstanceOf(ArgumentError);
94
+ expect(mockApiGet).not.toHaveBeenCalled();
52
95
  });
53
- it('returns empty array when replies is missing', async () => {
96
+ it('rejects invalid parent ids before fetching comments', async () => {
97
+ await expect(command.func({}, { bvid: 'BV1xxx', parent: 0, limit: 5 }))
98
+ .rejects.toBeInstanceOf(ArgumentError);
99
+ expect(mockApiGet).not.toHaveBeenCalled();
100
+ });
101
+ it('maps auth-like API errors to AuthRequiredError', async () => {
54
102
  mockApiGet
55
- .mockResolvedValueOnce({ data: { aid: 99 } })
56
- .mockResolvedValueOnce({ data: {} }); // no replies key
57
- const result = await command.func({}, { bvid: 'BV1xxx', limit: 5 });
58
- expect(result).toEqual([]);
103
+ .mockResolvedValueOnce({ code: -101, message: '账号未登录', data: null });
104
+ await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
105
+ .rejects.toBeInstanceOf(AuthRequiredError);
59
106
  });
60
- it('caps limit at 50', async () => {
107
+ it('throws EmptyResultError for explicit empty comments', async () => {
61
108
  mockApiGet
62
- .mockResolvedValueOnce({ data: { aid: 1 } })
63
- .mockResolvedValueOnce({ data: { replies: [] } });
64
- await command.func({}, { bvid: 'BV1xxx', limit: 999 });
65
- expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
66
- params: { oid: 1, type: 1, mode: 3, ps: 50 },
67
- signed: true,
68
- });
109
+ .mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
110
+ .mockResolvedValueOnce({ code: 0, data: { replies: [] } });
111
+ await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
112
+ .rejects.toBeInstanceOf(EmptyResultError);
69
113
  });
70
114
  it('collapses newlines in comment text', async () => {
71
115
  mockApiGet
72
- .mockResolvedValueOnce({ data: { aid: 1 } })
116
+ .mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
73
117
  .mockResolvedValueOnce({
118
+ code: 0,
74
119
  data: {
75
120
  replies: [
76
- { member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
121
+ { rpid: 123, member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
77
122
  ],
78
123
  },
79
124
  });
80
125
  const result = (await command.func({}, { bvid: 'BV1xxx', limit: 5 }));
81
126
  expect(result[0].text).toBe('line1 line2 line3');
82
127
  });
128
+ it('throws CommandExecutionError when a comment row lacks rpid', async () => {
129
+ mockApiGet
130
+ .mockResolvedValueOnce({ code: 0, data: { aid: 1 } })
131
+ .mockResolvedValueOnce({
132
+ code: 0,
133
+ data: {
134
+ replies: [
135
+ { member: { uname: 'Bob' }, content: { message: 'hi' }, like: 0, rcount: 0, ctime: 0 },
136
+ ],
137
+ },
138
+ });
139
+ await expect(command.func({}, { bvid: 'BV1xxx', limit: 5 }))
140
+ .rejects.toBeInstanceOf(CommandExecutionError);
141
+ });
83
142
  });