@jackwener/opencli 1.4.1 → 1.5.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 (369) hide show
  1. package/.github/workflows/build-extension.yml +2 -6
  2. package/.github/workflows/ci.yml +21 -1
  3. package/README.md +35 -6
  4. package/README.zh-CN.md +12 -5
  5. package/SKILL.md +2 -0
  6. package/dist/browser/cdp.d.ts +2 -1
  7. package/dist/browser/cdp.js +5 -0
  8. package/dist/browser/discover.d.ts +4 -1
  9. package/dist/browser/discover.js +6 -2
  10. package/dist/browser/errors.d.ts +2 -2
  11. package/dist/browser/errors.js +4 -12
  12. package/dist/browser/mcp.d.ts +2 -1
  13. package/dist/browser/page.d.ts +3 -0
  14. package/dist/browser/page.js +24 -1
  15. package/dist/build-manifest.d.ts +2 -0
  16. package/dist/build-manifest.js +39 -14
  17. package/dist/build-manifest.test.js +21 -0
  18. package/dist/capabilityRouting.d.ts +2 -0
  19. package/dist/capabilityRouting.js +2 -1
  20. package/dist/cli-manifest.json +1567 -108
  21. package/dist/cli.js +68 -6
  22. package/dist/clis/36kr/article.d.ts +1 -0
  23. package/dist/clis/36kr/article.js +62 -0
  24. package/dist/clis/36kr/hot.d.ts +3 -0
  25. package/dist/clis/36kr/hot.js +80 -0
  26. package/dist/clis/36kr/hot.test.d.ts +1 -0
  27. package/dist/clis/36kr/hot.test.js +15 -0
  28. package/dist/clis/36kr/news.d.ts +1 -0
  29. package/dist/clis/36kr/news.js +51 -0
  30. package/dist/clis/36kr/news.test.d.ts +1 -0
  31. package/dist/clis/36kr/news.test.js +85 -0
  32. package/dist/clis/36kr/search.d.ts +1 -0
  33. package/dist/clis/36kr/search.js +72 -0
  34. package/dist/clis/bilibili/comments.d.ts +5 -0
  35. package/dist/clis/bilibili/comments.js +40 -0
  36. package/dist/clis/bilibili/comments.test.d.ts +1 -0
  37. package/dist/clis/bilibili/comments.test.js +82 -0
  38. package/dist/clis/bluesky/feeds.yaml +29 -0
  39. package/dist/clis/bluesky/followers.yaml +33 -0
  40. package/dist/clis/bluesky/following.yaml +33 -0
  41. package/dist/clis/bluesky/profile.yaml +27 -0
  42. package/dist/clis/bluesky/search.yaml +34 -0
  43. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  44. package/dist/clis/bluesky/thread.yaml +32 -0
  45. package/dist/clis/bluesky/trending.yaml +27 -0
  46. package/dist/clis/bluesky/user.yaml +34 -0
  47. package/dist/clis/chatgpt/ask.js +29 -14
  48. package/dist/clis/chatgpt/ax.d.ts +6 -0
  49. package/dist/clis/chatgpt/ax.js +172 -1
  50. package/dist/clis/chatgpt/model.d.ts +1 -0
  51. package/dist/clis/chatgpt/model.js +24 -0
  52. package/dist/clis/chatgpt/send.js +12 -3
  53. package/dist/clis/douban/download.d.ts +1 -0
  54. package/dist/clis/douban/download.js +67 -0
  55. package/dist/clis/douban/download.test.d.ts +1 -0
  56. package/dist/clis/douban/download.test.js +170 -0
  57. package/dist/clis/douban/photos.d.ts +1 -0
  58. package/dist/clis/douban/photos.js +34 -0
  59. package/dist/clis/douban/utils.d.ts +25 -0
  60. package/dist/clis/douban/utils.js +190 -1
  61. package/dist/clis/douban/utils.test.d.ts +1 -0
  62. package/dist/clis/douban/utils.test.js +64 -0
  63. package/dist/clis/imdb/person.d.ts +1 -0
  64. package/dist/clis/imdb/person.js +203 -0
  65. package/dist/clis/imdb/reviews.d.ts +1 -0
  66. package/dist/clis/imdb/reviews.js +88 -0
  67. package/dist/clis/imdb/search.d.ts +1 -0
  68. package/dist/clis/imdb/search.js +161 -0
  69. package/dist/clis/imdb/title.d.ts +1 -0
  70. package/dist/clis/imdb/title.js +93 -0
  71. package/dist/clis/imdb/top.d.ts +1 -0
  72. package/dist/clis/imdb/top.js +53 -0
  73. package/dist/clis/imdb/trending.d.ts +1 -0
  74. package/dist/clis/imdb/trending.js +52 -0
  75. package/dist/clis/imdb/utils.d.ts +46 -0
  76. package/dist/clis/imdb/utils.js +285 -0
  77. package/dist/clis/imdb/utils.test.d.ts +1 -0
  78. package/dist/clis/imdb/utils.test.js +88 -0
  79. package/dist/clis/jd/item.d.ts +4 -0
  80. package/dist/clis/jd/item.js +16 -15
  81. package/dist/clis/jd/item.test.js +16 -1
  82. package/dist/clis/linux-do/categories.yaml +38 -9
  83. package/dist/clis/linux-do/category.d.ts +1 -0
  84. package/dist/clis/linux-do/category.js +36 -0
  85. package/dist/clis/linux-do/feed.d.ts +45 -0
  86. package/dist/clis/linux-do/feed.js +397 -0
  87. package/dist/clis/linux-do/feed.test.d.ts +1 -0
  88. package/dist/clis/linux-do/feed.test.js +118 -0
  89. package/dist/clis/linux-do/hot.d.ts +1 -0
  90. package/dist/clis/linux-do/hot.js +25 -0
  91. package/dist/clis/linux-do/latest.d.ts +1 -0
  92. package/dist/clis/linux-do/latest.js +18 -0
  93. package/dist/clis/linux-do/tags.yaml +41 -0
  94. package/dist/clis/linux-do/topic.yaml +41 -3
  95. package/dist/clis/linux-do/user-posts.yaml +67 -0
  96. package/dist/clis/linux-do/user-topics.yaml +54 -0
  97. package/dist/clis/paperreview/commands.test.d.ts +3 -0
  98. package/dist/clis/paperreview/commands.test.js +243 -0
  99. package/dist/clis/paperreview/feedback.d.ts +1 -0
  100. package/dist/clis/paperreview/feedback.js +52 -0
  101. package/dist/clis/paperreview/review.d.ts +1 -0
  102. package/dist/clis/paperreview/review.js +37 -0
  103. package/dist/clis/paperreview/submit.d.ts +1 -0
  104. package/dist/clis/paperreview/submit.js +85 -0
  105. package/dist/clis/paperreview/utils.d.ts +46 -0
  106. package/dist/clis/paperreview/utils.js +197 -0
  107. package/dist/clis/paperreview/utils.test.d.ts +1 -0
  108. package/dist/clis/paperreview/utils.test.js +49 -0
  109. package/dist/clis/producthunt/browse.d.ts +1 -0
  110. package/dist/clis/producthunt/browse.js +99 -0
  111. package/dist/clis/producthunt/hot.d.ts +1 -0
  112. package/dist/clis/producthunt/hot.js +110 -0
  113. package/dist/clis/producthunt/posts.d.ts +1 -0
  114. package/dist/clis/producthunt/posts.js +28 -0
  115. package/dist/clis/producthunt/today.d.ts +1 -0
  116. package/dist/clis/producthunt/today.js +35 -0
  117. package/dist/clis/producthunt/utils.d.ts +29 -0
  118. package/dist/clis/producthunt/utils.js +99 -0
  119. package/dist/clis/producthunt/utils.test.d.ts +1 -0
  120. package/dist/clis/producthunt/utils.test.js +64 -0
  121. package/dist/clis/twitter/article.js +4 -28
  122. package/dist/clis/twitter/likes.d.ts +24 -0
  123. package/dist/clis/twitter/likes.js +217 -0
  124. package/dist/clis/twitter/likes.test.d.ts +1 -0
  125. package/dist/clis/twitter/likes.test.js +85 -0
  126. package/dist/clis/twitter/profile.js +4 -28
  127. package/dist/clis/twitter/search.js +2 -1
  128. package/dist/clis/twitter/search.test.js +2 -0
  129. package/dist/clis/twitter/shared.d.ts +6 -0
  130. package/dist/clis/twitter/shared.js +35 -0
  131. package/dist/clis/twitter/timeline.js +2 -13
  132. package/dist/clis/twitter/trending.js +29 -61
  133. package/dist/clis/v2ex/hot.yaml +17 -3
  134. package/dist/clis/weixin/download.d.ts +17 -0
  135. package/dist/clis/weixin/download.js +88 -20
  136. package/dist/clis/weread/book.js +2 -2
  137. package/dist/clis/weread/commands.test.d.ts +3 -0
  138. package/dist/clis/weread/commands.test.js +43 -0
  139. package/dist/clis/weread/highlights.js +2 -2
  140. package/dist/clis/weread/notebooks.js +2 -2
  141. package/dist/clis/weread/notes.js +3 -3
  142. package/dist/clis/weread/shelf.js +2 -2
  143. package/dist/clis/weread/utils.d.ts +4 -4
  144. package/dist/clis/weread/utils.js +32 -14
  145. package/dist/clis/weread/utils.test.js +1 -28
  146. package/dist/clis/xiaohongshu/comments.d.ts +5 -0
  147. package/dist/clis/xiaohongshu/comments.js +74 -0
  148. package/dist/clis/xiaohongshu/comments.test.d.ts +1 -0
  149. package/dist/clis/xiaohongshu/comments.test.js +79 -0
  150. package/dist/clis/xiaohongshu/publish.js +179 -47
  151. package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
  152. package/dist/clis/xiaohongshu/publish.test.js +131 -0
  153. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  154. package/dist/clis/xiaohongshu/search.js +20 -1
  155. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  156. package/dist/clis/xiaohongshu/search.test.js +32 -1
  157. package/dist/commanderAdapter.d.ts +1 -0
  158. package/dist/commanderAdapter.js +176 -29
  159. package/dist/commanderAdapter.test.d.ts +1 -0
  160. package/dist/commanderAdapter.test.js +62 -0
  161. package/dist/daemon.js +17 -1
  162. package/dist/discovery.js +48 -42
  163. package/dist/doctor.d.ts +2 -2
  164. package/dist/doctor.js +11 -4
  165. package/dist/download/index.js +63 -51
  166. package/dist/download/index.test.js +17 -4
  167. package/dist/engine.test.js +42 -0
  168. package/dist/errors.d.ts +4 -2
  169. package/dist/errors.js +17 -34
  170. package/dist/execution.d.ts +1 -3
  171. package/dist/execution.js +66 -8
  172. package/dist/execution.test.d.ts +1 -0
  173. package/dist/execution.test.js +40 -0
  174. package/dist/external.js +6 -1
  175. package/dist/hooks.js +2 -0
  176. package/dist/main.js +6 -0
  177. package/dist/output.js +5 -1
  178. package/dist/pipeline/executor.js +3 -4
  179. package/dist/plugin-manifest.d.ts +70 -0
  180. package/dist/plugin-manifest.js +160 -0
  181. package/dist/plugin-manifest.test.d.ts +4 -0
  182. package/dist/plugin-manifest.test.js +179 -0
  183. package/dist/plugin-scaffold.d.ts +28 -0
  184. package/dist/plugin-scaffold.js +142 -0
  185. package/dist/plugin-scaffold.test.d.ts +4 -0
  186. package/dist/plugin-scaffold.test.js +83 -0
  187. package/dist/plugin.d.ts +82 -11
  188. package/dist/plugin.js +870 -84
  189. package/dist/plugin.test.js +1032 -17
  190. package/dist/registry.d.ts +4 -0
  191. package/dist/registry.js +2 -0
  192. package/dist/runtime-detect.d.ts +21 -0
  193. package/dist/runtime-detect.js +32 -0
  194. package/dist/runtime-detect.test.d.ts +1 -0
  195. package/dist/runtime-detect.test.js +27 -0
  196. package/dist/runtime.d.ts +1 -0
  197. package/dist/runtime.js +2 -2
  198. package/dist/serialization.d.ts +2 -0
  199. package/dist/serialization.js +6 -0
  200. package/dist/types.d.ts +3 -0
  201. package/dist/update-check.d.ts +22 -0
  202. package/dist/update-check.js +112 -0
  203. package/dist/weixin-download.test.d.ts +1 -0
  204. package/dist/weixin-download.test.js +30 -0
  205. package/dist/weread-private-api-regression.test.d.ts +1 -0
  206. package/dist/weread-private-api-regression.test.js +122 -0
  207. package/dist/yaml-schema.d.ts +3 -0
  208. package/dist/yaml-schema.js +18 -1
  209. package/docs/.vitepress/config.mts +4 -0
  210. package/docs/adapters/browser/36kr.md +47 -0
  211. package/docs/adapters/browser/bluesky.md +53 -0
  212. package/docs/adapters/browser/douban.md +14 -0
  213. package/docs/adapters/browser/imdb.md +47 -0
  214. package/docs/adapters/browser/jd.md +2 -2
  215. package/docs/adapters/browser/linux-do.md +181 -20
  216. package/docs/adapters/browser/paperreview.md +43 -0
  217. package/docs/adapters/browser/producthunt.md +49 -0
  218. package/docs/adapters/desktop/chatgpt.md +5 -0
  219. package/docs/adapters/index.md +6 -2
  220. package/docs/advanced/download.md +4 -0
  221. package/docs/advanced/rate-limiter-plugin.md +99 -0
  222. package/docs/guide/electron-app-cli.md +200 -0
  223. package/docs/guide/getting-started.md +1 -0
  224. package/docs/guide/plugins.md +97 -0
  225. package/docs/zh/guide/electron-app-cli.md +188 -0
  226. package/docs/zh/guide/getting-started.md +1 -0
  227. package/docs/zh/guide/plugins.md +65 -0
  228. package/extension/package.json +1 -0
  229. package/extension/scripts/package-release.mjs +179 -0
  230. package/extension/src/background.ts +2 -0
  231. package/package.json +4 -1
  232. package/scripts/postinstall.js +10 -0
  233. package/src/browser/cdp.ts +8 -1
  234. package/src/browser/discover.ts +8 -3
  235. package/src/browser/errors.ts +13 -14
  236. package/src/browser/mcp.ts +2 -1
  237. package/src/browser/page.ts +24 -1
  238. package/src/build-manifest.test.ts +23 -0
  239. package/src/build-manifest.ts +40 -15
  240. package/src/capabilityRouting.ts +2 -1
  241. package/src/cli.ts +69 -6
  242. package/src/clis/36kr/article.ts +69 -0
  243. package/src/clis/36kr/hot.test.ts +19 -0
  244. package/src/clis/36kr/hot.ts +100 -0
  245. package/src/clis/36kr/news.test.ts +90 -0
  246. package/src/clis/36kr/news.ts +54 -0
  247. package/src/clis/36kr/search.ts +78 -0
  248. package/src/clis/bilibili/comments.test.ts +102 -0
  249. package/src/clis/bilibili/comments.ts +44 -0
  250. package/src/clis/bluesky/feeds.yaml +29 -0
  251. package/src/clis/bluesky/followers.yaml +33 -0
  252. package/src/clis/bluesky/following.yaml +33 -0
  253. package/src/clis/bluesky/profile.yaml +27 -0
  254. package/src/clis/bluesky/search.yaml +34 -0
  255. package/src/clis/bluesky/starter-packs.yaml +34 -0
  256. package/src/clis/bluesky/thread.yaml +32 -0
  257. package/src/clis/bluesky/trending.yaml +27 -0
  258. package/src/clis/bluesky/user.yaml +34 -0
  259. package/src/clis/chatgpt/ask.ts +28 -14
  260. package/src/clis/chatgpt/ax.ts +180 -1
  261. package/src/clis/chatgpt/model.ts +27 -0
  262. package/src/clis/chatgpt/send.ts +16 -6
  263. package/src/clis/douban/download.test.ts +196 -0
  264. package/src/clis/douban/download.ts +78 -0
  265. package/src/clis/douban/photos.ts +36 -0
  266. package/src/clis/douban/utils.test.ts +97 -0
  267. package/src/clis/douban/utils.ts +232 -1
  268. package/src/clis/imdb/person.ts +232 -0
  269. package/src/clis/imdb/reviews.ts +111 -0
  270. package/src/clis/imdb/search.ts +179 -0
  271. package/src/clis/imdb/title.ts +121 -0
  272. package/src/clis/imdb/top.ts +67 -0
  273. package/src/clis/imdb/trending.ts +66 -0
  274. package/src/clis/imdb/utils.test.ts +117 -0
  275. package/src/clis/imdb/utils.ts +305 -0
  276. package/src/clis/jd/item.test.ts +18 -1
  277. package/src/clis/jd/item.ts +18 -15
  278. package/src/clis/linux-do/categories.yaml +38 -9
  279. package/src/clis/linux-do/category.ts +37 -0
  280. package/src/clis/linux-do/feed.test.ts +132 -0
  281. package/src/clis/linux-do/feed.ts +501 -0
  282. package/src/clis/linux-do/hot.ts +26 -0
  283. package/src/clis/linux-do/latest.ts +19 -0
  284. package/src/clis/linux-do/tags.yaml +41 -0
  285. package/src/clis/linux-do/topic.yaml +41 -3
  286. package/src/clis/linux-do/user-posts.yaml +67 -0
  287. package/src/clis/linux-do/user-topics.yaml +54 -0
  288. package/src/clis/paperreview/commands.test.ts +283 -0
  289. package/src/clis/paperreview/feedback.ts +64 -0
  290. package/src/clis/paperreview/review.ts +47 -0
  291. package/src/clis/paperreview/submit.ts +119 -0
  292. package/src/clis/paperreview/utils.test.ts +68 -0
  293. package/src/clis/paperreview/utils.ts +276 -0
  294. package/src/clis/producthunt/browse.ts +109 -0
  295. package/src/clis/producthunt/hot.ts +127 -0
  296. package/src/clis/producthunt/posts.ts +29 -0
  297. package/src/clis/producthunt/today.ts +37 -0
  298. package/src/clis/producthunt/utils.test.ts +72 -0
  299. package/src/clis/producthunt/utils.ts +122 -0
  300. package/src/clis/twitter/article.ts +5 -28
  301. package/src/clis/twitter/likes.test.ts +91 -0
  302. package/src/clis/twitter/likes.ts +256 -0
  303. package/src/clis/twitter/profile.ts +5 -28
  304. package/src/clis/twitter/search.test.ts +2 -0
  305. package/src/clis/twitter/search.ts +3 -1
  306. package/src/clis/twitter/shared.ts +45 -0
  307. package/src/clis/twitter/timeline.ts +2 -13
  308. package/src/clis/twitter/trending.ts +29 -77
  309. package/src/clis/v2ex/hot.yaml +17 -3
  310. package/src/clis/weixin/download.ts +114 -20
  311. package/src/clis/weread/book.ts +2 -2
  312. package/src/clis/weread/commands.test.ts +57 -0
  313. package/src/clis/weread/highlights.ts +2 -2
  314. package/src/clis/weread/notebooks.ts +2 -2
  315. package/src/clis/weread/notes.ts +3 -3
  316. package/src/clis/weread/shelf.ts +2 -2
  317. package/src/clis/weread/utils.test.ts +1 -32
  318. package/src/clis/weread/utils.ts +41 -16
  319. package/src/clis/xiaohongshu/comments.test.ts +96 -0
  320. package/src/clis/xiaohongshu/comments.ts +81 -0
  321. package/src/clis/xiaohongshu/publish.test.ts +151 -0
  322. package/src/clis/xiaohongshu/publish.ts +206 -54
  323. package/src/clis/xiaohongshu/search.test.ts +39 -1
  324. package/src/clis/xiaohongshu/search.ts +19 -1
  325. package/src/commanderAdapter.test.ts +78 -0
  326. package/src/commanderAdapter.ts +188 -24
  327. package/src/daemon.ts +19 -1
  328. package/src/discovery.ts +49 -48
  329. package/src/doctor.ts +15 -5
  330. package/src/download/index.test.ts +14 -4
  331. package/src/download/index.ts +67 -55
  332. package/src/engine.test.ts +38 -0
  333. package/src/errors.ts +26 -63
  334. package/src/execution.test.ts +47 -0
  335. package/src/execution.ts +67 -9
  336. package/src/external.ts +6 -1
  337. package/src/hooks.ts +1 -0
  338. package/src/main.ts +7 -0
  339. package/src/output.ts +3 -1
  340. package/src/pipeline/executor.ts +4 -6
  341. package/src/plugin-manifest.test.ts +223 -0
  342. package/src/plugin-manifest.ts +206 -0
  343. package/src/plugin-scaffold.test.ts +98 -0
  344. package/src/plugin-scaffold.ts +170 -0
  345. package/src/plugin.test.ts +1104 -17
  346. package/src/plugin.ts +1101 -86
  347. package/src/registry.ts +6 -1
  348. package/src/runtime-detect.test.ts +30 -0
  349. package/src/runtime-detect.ts +36 -0
  350. package/src/runtime.ts +3 -3
  351. package/src/serialization.ts +4 -0
  352. package/src/types.ts +3 -0
  353. package/src/update-check.ts +114 -0
  354. package/src/weixin-download.test.ts +64 -0
  355. package/src/weread-private-api-regression.test.ts +150 -0
  356. package/src/yaml-schema.ts +20 -0
  357. package/tests/e2e/browser-auth.test.ts +13 -9
  358. package/tests/e2e/browser-public-extended.test.ts +1 -1
  359. package/tests/e2e/browser-public.test.ts +62 -4
  360. package/tests/e2e/helpers.ts +2 -1
  361. package/tests/e2e/public-commands.test.ts +37 -3
  362. package/tests/smoke/api-health.test.ts +1 -1
  363. package/vitest.config.ts +10 -0
  364. package/dist/clis/linux-do/category.yaml +0 -51
  365. package/dist/clis/linux-do/hot.yaml +0 -50
  366. package/dist/clis/linux-do/latest.yaml +0 -40
  367. package/src/clis/linux-do/category.yaml +0 -51
  368. package/src/clis/linux-do/hot.yaml +0 -50
  369. package/src/clis/linux-do/latest.yaml +0 -40
package/src/cli.ts CHANGED
@@ -76,9 +76,10 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
76
76
  for (const [site, cmds] of sites) {
77
77
  console.log(chalk.bold.cyan(` ${site}`));
78
78
  for (const cmd of cmds) {
79
- const tag = strategyLabel(cmd) === 'public'
79
+ const label = strategyLabel(cmd);
80
+ const tag = label === 'public'
80
81
  ? chalk.green('[public]')
81
- : chalk.yellow(`[${strategyLabel(cmd)}]`);
82
+ : chalk.yellow(`[${label}]`);
82
83
  console.log(` ${cmd.name} ${tag}${cmd.description ? chalk.dim(` — ${cmd.description}`) : ''}`);
83
84
  }
84
85
  console.log();
@@ -252,15 +253,23 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
252
253
 
253
254
  pluginCmd
254
255
  .command('install')
255
- .description('Install a plugin from GitHub')
256
+ .description('Install a plugin from a git repository')
256
257
  .argument('<source>', 'Plugin source (e.g. github:user/repo)')
257
258
  .action(async (source: string) => {
258
259
  const { installPlugin } = await import('./plugin.js');
259
260
  const { discoverPlugins } = await import('./discovery.js');
260
261
  try {
261
- const name = installPlugin(source);
262
+ const result = installPlugin(source);
262
263
  await discoverPlugins();
263
- console.log(chalk.green(`✅ Plugin "${name}" installed successfully. Commands are ready to use.`));
264
+ if (Array.isArray(result)) {
265
+ if (result.length === 0) {
266
+ console.log(chalk.yellow('No plugins were installed (all skipped or incompatible).'));
267
+ } else {
268
+ console.log(chalk.green(`\u2705 Installed ${result.length} plugin(s) from monorepo: ${result.join(', ')}`));
269
+ }
270
+ } else {
271
+ console.log(chalk.green(`\u2705 Plugin "${result}" installed successfully. Commands are ready to use.`));
272
+ }
264
273
  } catch (err) {
265
274
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
266
275
  process.exitCode = 1;
@@ -368,17 +377,71 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
368
377
  console.log();
369
378
  console.log(chalk.bold(' Installed plugins'));
370
379
  console.log();
380
+
381
+ // Group by monorepo
382
+ const standalone = plugins.filter((p) => !p.monorepoName);
383
+ const monoGroups = new Map<string, typeof plugins>();
371
384
  for (const p of plugins) {
385
+ if (!p.monorepoName) continue;
386
+ const g = monoGroups.get(p.monorepoName) ?? [];
387
+ g.push(p);
388
+ monoGroups.set(p.monorepoName, g);
389
+ }
390
+
391
+ for (const p of standalone) {
372
392
  const version = p.version ? chalk.green(` @${p.version}`) : '';
393
+ const desc = p.description ? chalk.dim(` — ${p.description}`) : '';
373
394
  const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
374
395
  const src = p.source ? chalk.dim(` ← ${p.source}`) : '';
375
- console.log(` ${chalk.cyan(p.name)}${version}${cmds}${src}`);
396
+ console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}${src}`);
376
397
  }
398
+
399
+ for (const [mono, group] of monoGroups) {
400
+ console.log();
401
+ console.log(chalk.bold.magenta(` 📦 ${mono}`) + chalk.dim(' (monorepo)'));
402
+ for (const p of group) {
403
+ const version = p.version ? chalk.green(` @${p.version}`) : '';
404
+ const desc = p.description ? chalk.dim(` — ${p.description}`) : '';
405
+ const cmds = p.commands.length > 0 ? chalk.dim(` (${p.commands.join(', ')})`) : '';
406
+ console.log(` ${chalk.cyan(p.name)}${version}${desc}${cmds}`);
407
+ }
408
+ }
409
+
377
410
  console.log();
378
411
  console.log(chalk.dim(` ${plugins.length} plugin(s) installed`));
379
412
  console.log();
380
413
  });
381
414
 
415
+ pluginCmd
416
+ .command('create')
417
+ .description('Create a new plugin scaffold')
418
+ .argument('<name>', 'Plugin name (lowercase, hyphens allowed)')
419
+ .option('-d, --dir <path>', 'Output directory (default: ./<name>)')
420
+ .option('--description <text>', 'Plugin description')
421
+ .action(async (name: string, opts: { dir?: string; description?: string }) => {
422
+ const { createPluginScaffold } = await import('./plugin-scaffold.js');
423
+ try {
424
+ const result = createPluginScaffold(name, {
425
+ dir: opts.dir,
426
+ description: opts.description,
427
+ });
428
+ console.log(chalk.green(`✅ Plugin scaffold created at ${result.dir}`));
429
+ console.log();
430
+ console.log(chalk.bold(' Files created:'));
431
+ for (const f of result.files) {
432
+ console.log(` ${chalk.cyan(f)}`);
433
+ }
434
+ console.log();
435
+ console.log(chalk.dim(' Next steps:'));
436
+ console.log(chalk.dim(` cd ${result.dir}`));
437
+ console.log(chalk.dim(` opencli plugin install file://${result.dir}`));
438
+ console.log(chalk.dim(` opencli ${name} hello`));
439
+ } catch (err) {
440
+ console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
441
+ process.exitCode = 1;
442
+ }
443
+ });
444
+
382
445
  // ── External CLIs ─────────────────────────────────────────────────────────
383
446
 
384
447
  const externalClis = loadExternalClis();
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 36kr article detail — INTERCEPT strategy.
3
+ *
4
+ * Fetches the full content of a 36kr article given its ID or URL.
5
+ */
6
+ import { cli, Strategy } from '../../registry.js';
7
+ import { CliError } from '../../errors.js';
8
+ import type { IPage } from '../../types.js';
9
+
10
+ /** Extract article ID from a full URL or a bare numeric ID string */
11
+ function parseArticleId(input: string): string {
12
+ const m = input.match(/\/p\/(\d+)/);
13
+ return m ? m[1] : input.replace(/\D/g, '');
14
+ }
15
+
16
+ cli({
17
+ site: '36kr',
18
+ name: 'article',
19
+ description: '获取36氪文章正文内容',
20
+ domain: 'www.36kr.com',
21
+ strategy: Strategy.INTERCEPT,
22
+ args: [
23
+ { name: 'id', positional: true, required: true, help: 'Article ID or full 36kr article URL' },
24
+ ],
25
+ columns: ['field', 'value'],
26
+ func: async (page: IPage, args) => {
27
+ const articleId = parseArticleId(String(args.id ?? ''));
28
+ if (!articleId) {
29
+ throw new CliError('INVALID_ARGUMENT', 'Invalid article ID or URL');
30
+ }
31
+
32
+ await page.installInterceptor('36kr.com/api');
33
+ await page.goto(`https://www.36kr.com/p/${articleId}`);
34
+ await page.wait(5);
35
+
36
+ const data: any = await page.evaluate(`
37
+ (() => {
38
+ // Title: 36kr uses class "article-title" on h1
39
+ const title = document.querySelector('.article-title, h1')?.textContent?.trim() || '';
40
+ // Author: second .author-name (first is empty nav link, second has real name)
41
+ const authorEls = document.querySelectorAll('.author-name');
42
+ const author = Array.from(authorEls).map(el => el.textContent?.trim()).filter(Boolean)[0] || '';
43
+ // Date: 36kr uses class "title-icon-item item-time" for the publish date
44
+ const dateRaw = document.querySelector('.item-time')?.textContent?.trim() || '';
45
+ const date = dateRaw.replace(/^[·\s]+/, '').trim();
46
+ // Article body paragraphs
47
+ const bodyEls = document.querySelectorAll('[class*="article-content"] p, [class*="rich-text"] p, .article p');
48
+ const body = Array.from(bodyEls)
49
+ .map(el => el.textContent?.trim())
50
+ .filter(t => t && t.length > 10)
51
+ .join(' ')
52
+ .slice(0, 800);
53
+ return { title, author, date, body };
54
+ })()
55
+ `);
56
+
57
+ if (!data?.title) {
58
+ throw new CliError('NOT_FOUND', 'Article not found or failed to load', 'Check the article ID');
59
+ }
60
+
61
+ return [
62
+ { field: 'title', value: data.title },
63
+ { field: 'author', value: data.author || '-' },
64
+ { field: 'date', value: data.date || '-' },
65
+ { field: 'url', value: `https://36kr.com/p/${articleId}` },
66
+ { field: 'body', value: data.body || '-' },
67
+ ];
68
+ },
69
+ });
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { buildHotListUrl, getShanghaiDate } from './hot.js';
4
+
5
+ describe('36kr/hot date routing', () => {
6
+ it('formats dates in Asia/Shanghai instead of UTC', () => {
7
+ const date = new Date('2026-03-25T18:30:00.000Z');
8
+ expect(getShanghaiDate(date)).toBe('2026-03-26');
9
+ });
10
+
11
+ it('builds dated hot-list routes with Shanghai-local date', () => {
12
+ const date = new Date('2026-03-25T18:30:00.000Z');
13
+ expect(buildHotListUrl('renqi', date)).toBe('https://www.36kr.com/hot-list/renqi/2026-03-26/1');
14
+ });
15
+
16
+ it('keeps catalog on the static route', () => {
17
+ expect(buildHotListUrl('catalog')).toBe('https://www.36kr.com/hot-list/catalog');
18
+ });
19
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 36kr hot-list — INTERCEPT strategy.
3
+ *
4
+ * Navigates to the 36kr hot-list page and scrapes rendered article links.
5
+ * Supports category types: renqi (人气), zonghe (综合), shoucang (收藏), catalog (综合热门).
6
+ */
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import { CliError } from '../../errors.js';
9
+ import type { IPage } from '../../types.js';
10
+
11
+ const TYPE_MAP: Record<string, string> = {
12
+ renqi: '人气榜',
13
+ zonghe: '综合榜',
14
+ shoucang: '收藏榜',
15
+ catalog: '热门资讯',
16
+ };
17
+
18
+ function getShanghaiDate(date = new Date()): string {
19
+ // Shanghai stays on UTC+8 year-round, so a fixed offset is sufficient here
20
+ // and avoids the slow Intl timezone path that timed out on Windows CI.
21
+ return new Date(date.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
22
+ }
23
+
24
+ function buildHotListUrl(listType: string, date = new Date()): string {
25
+ if (listType === 'catalog') {
26
+ return 'https://www.36kr.com/hot-list/catalog';
27
+ }
28
+
29
+ return `https://www.36kr.com/hot-list/${listType}/${getShanghaiDate(date)}/1`;
30
+ }
31
+
32
+ cli({
33
+ site: '36kr',
34
+ name: 'hot',
35
+ description: '36氪热榜 — trending articles (renqi/zonghe/shoucang/catalog)',
36
+ domain: 'www.36kr.com',
37
+ strategy: Strategy.INTERCEPT,
38
+ args: [
39
+ { name: 'limit', type: 'int', default: 20, help: 'Number of items (max 50)' },
40
+ {
41
+ name: 'type',
42
+ type: 'string',
43
+ default: 'catalog',
44
+ help: 'List type: renqi (人气), zonghe (综合), shoucang (收藏), catalog (热门资讯)',
45
+ },
46
+ ],
47
+ columns: ['rank', 'title', 'url'],
48
+ func: async (page: IPage, args) => {
49
+ const count = Math.min(Number(args.limit) || 20, 50);
50
+ const listType = String(args.type ?? 'catalog');
51
+
52
+ if (!TYPE_MAP[listType]) {
53
+ throw new CliError(
54
+ 'INVALID_ARGUMENT',
55
+ `Unknown type "${listType}". Valid types: ${Object.keys(TYPE_MAP).join(', ')}`,
56
+ );
57
+ }
58
+
59
+ const url = buildHotListUrl(listType);
60
+
61
+ await page.installInterceptor('36kr.com/api');
62
+ await page.goto(url);
63
+ await page.wait(6);
64
+
65
+ // Scrape rendered article links from DOM (deduplicated)
66
+ const domItems: any = await page.evaluate(`
67
+ (() => {
68
+ const seen = new Set();
69
+ const results = [];
70
+ const links = document.querySelectorAll('a[href*="/p/"]');
71
+ for (const el of links) {
72
+ const href = el.getAttribute('href') || '';
73
+ const title = el.textContent?.trim() || '';
74
+ if (!title || title.length < 5 || seen.has(href) || seen.has(title)) continue;
75
+ seen.add(href);
76
+ seen.add(title);
77
+ results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href });
78
+ }
79
+ return results;
80
+ })()
81
+ `);
82
+
83
+ const items = Array.isArray(domItems) ? (domItems as any[]) : [];
84
+ if (items.length === 0) {
85
+ throw new CliError(
86
+ 'NO_DATA',
87
+ 'Could not retrieve 36kr hot list',
88
+ '36kr may have changed its DOM structure',
89
+ );
90
+ }
91
+
92
+ return items.slice(0, count).map((item: any, i: number) => ({
93
+ rank: i + 1,
94
+ title: item.title,
95
+ url: item.url,
96
+ }));
97
+ },
98
+ });
99
+
100
+ export { buildHotListUrl, getShanghaiDate };
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, vi, afterEach } from 'vitest';
2
+
3
+ const SAMPLE_RSS = `<?xml version="1.0" encoding="UTF-8"?>
4
+ <rss version="2.0"><channel><title>36氪</title>
5
+ <item>
6
+ <title>红杉中国领投AI公司「示例」,金额近2亿元</title>
7
+ <link><![CDATA[https://36kr.com/p/1111111111111111?f=rss]]></link>
8
+ <pubDate>2026-03-26 10:00:00 +0800</pubDate>
9
+ </item>
10
+ <item>
11
+ <title>马斯克旗下xAI估值突破1000亿美元</title>
12
+ <link><![CDATA[https://36kr.com/p/2222222222222222?f=rss]]></link>
13
+ <pubDate>2026-03-26 09:00:00 +0800</pubDate>
14
+ </item>
15
+ <item>
16
+ <title>OpenAI发布GPT-5,多模态能力大幅提升</title>
17
+ <link><![CDATA[https://36kr.com/p/3333333333333333?f=rss]]></link>
18
+ <pubDate>2026-03-25 20:00:00 +0800</pubDate>
19
+ </item>
20
+ </channel></rss>`;
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ describe('36kr/news RSS parsing', () => {
27
+ it('parses RSS feed into ranked news items', async () => {
28
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
29
+ ok: true,
30
+ text: async () => SAMPLE_RSS,
31
+ } as Response);
32
+
33
+ // Direct RSS parse test using the same regex logic as news.ts
34
+ const xml = SAMPLE_RSS;
35
+ const items: { rank: number; title: string; date: string; url: string }[] = [];
36
+ const itemRegex = /<item>([\s\S]*?)<\/item>/g;
37
+ let match;
38
+ while ((match = itemRegex.exec(xml)) && items.length < 10) {
39
+ const block = match[1];
40
+ const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
41
+ const url =
42
+ block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
43
+ block.match(/<link>(.*?)<\/link>/)?.[1] ??
44
+ '';
45
+ const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
46
+ const date = pubDate.slice(0, 10);
47
+ if (title) items.push({ rank: items.length + 1, title, date, url: url.trim() });
48
+ }
49
+
50
+ expect(items).toHaveLength(3);
51
+ expect(items[0].rank).toBe(1);
52
+ expect(items[0].title).toBe('红杉中国领投AI公司「示例」,金额近2亿元');
53
+ expect(items[0].date).toBe('2026-03-26');
54
+ expect(items[0].url).toBe('https://36kr.com/p/1111111111111111?f=rss');
55
+ });
56
+
57
+ it('respects limit — returns at most N items', async () => {
58
+ const xml = SAMPLE_RSS;
59
+ const limit = 2;
60
+ const items: { rank: number; title: string; date: string; url: string }[] = [];
61
+ const itemRegex = /<item>([\s\S]*?)<\/item>/g;
62
+ let match;
63
+ while ((match = itemRegex.exec(xml)) && items.length < limit) {
64
+ const block = match[1];
65
+ const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
66
+ const url = block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ?? '';
67
+ const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
68
+ const date = pubDate.slice(0, 10);
69
+ if (title) items.push({ rank: items.length + 1, title, date, url: url.trim() });
70
+ }
71
+ expect(items).toHaveLength(2);
72
+ });
73
+
74
+ it('skips items with empty title', async () => {
75
+ const xml = `<rss><channel>
76
+ <item><title></title><link>https://36kr.com/p/0</link><pubDate>2026-01-01</pubDate></item>
77
+ <item><title>有标题的文章</title><link>https://36kr.com/p/1</link><pubDate>2026-01-01</pubDate></item>
78
+ </channel></rss>`;
79
+ const items: any[] = [];
80
+ const itemRegex = /<item>([\s\S]*?)<\/item>/g;
81
+ let match;
82
+ while ((match = itemRegex.exec(xml))) {
83
+ const block = match[1];
84
+ const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
85
+ if (title) items.push({ title });
86
+ }
87
+ expect(items).toHaveLength(1);
88
+ expect(items[0].title).toBe('有标题的文章');
89
+ });
90
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 36kr latest news — public RSS feed, no browser needed.
3
+ */
4
+ import { cli, Strategy } from '../../registry.js';
5
+
6
+ cli({
7
+ site: '36kr',
8
+ name: 'news',
9
+ description: 'Latest tech/startup news from 36kr (36氪)',
10
+ domain: 'www.36kr.com',
11
+ strategy: Strategy.PUBLIC,
12
+ args: [
13
+ { name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
14
+ ],
15
+ columns: ['rank', 'title', 'summary', 'date', 'url'],
16
+ func: async (_page, kwargs) => {
17
+ const count = Math.min(kwargs.limit || 20, 50);
18
+ const resp = await fetch('https://www.36kr.com/feed', {
19
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli/1.0)' },
20
+ });
21
+ if (!resp.ok) return [];
22
+ const xml = await resp.text();
23
+
24
+ const items: { rank: number; title: string; summary: string; date: string; url: string }[] = [];
25
+ const itemRegex = /<item>([\s\S]*?)<\/item>/g;
26
+ let match;
27
+ while ((match = itemRegex.exec(xml)) && items.length < count) {
28
+ const block = match[1];
29
+ const title = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
30
+ const url =
31
+ block.match(/<link><!\[CDATA\[(.*?)\]\]>/)?.[1] ??
32
+ block.match(/<link>(.*?)<\/link>/)?.[1] ??
33
+ '';
34
+ const pubDate = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1]?.trim() ?? '';
35
+ const date = pubDate.slice(0, 10);
36
+ // Extract plain-text summary from HTML description (first ~120 chars)
37
+ const rawDesc = block.match(/<description><!\[CDATA\[([\s\S]*?)\]\]>/)?.[1] ?? '';
38
+ const summary = rawDesc
39
+ .replace(/<[^>]+>/g, ' ')
40
+ .replace(/&nbsp;/g, ' ')
41
+ .replace(/&amp;/g, '&')
42
+ .replace(/&lt;/g, '<')
43
+ .replace(/&gt;/g, '>')
44
+ .replace(/\s+/g, ' ')
45
+ .trim()
46
+ .slice(0, 120);
47
+
48
+ if (title) {
49
+ items.push({ rank: items.length + 1, title, summary, date, url: url.trim() });
50
+ }
51
+ }
52
+ return items;
53
+ },
54
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 36kr article search — INTERCEPT strategy.
3
+ *
4
+ * Navigates to the 36kr search results page and scrapes rendered articles.
5
+ */
6
+ import { cli, Strategy } from '../../registry.js';
7
+ import { CliError } from '../../errors.js';
8
+ import type { IPage } from '../../types.js';
9
+
10
+ cli({
11
+ site: '36kr',
12
+ name: 'search',
13
+ description: '搜索36氪文章',
14
+ domain: 'www.36kr.com',
15
+ strategy: Strategy.INTERCEPT,
16
+ args: [
17
+ { name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "AI", "OpenAI")' },
18
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
19
+ ],
20
+ columns: ['rank', 'title', 'date', 'url'],
21
+ func: async (page: IPage, args) => {
22
+ const count = Math.min(Number(args.limit) || 20, 50);
23
+ const query = encodeURIComponent(String(args.query ?? ''));
24
+
25
+ await page.installInterceptor('36kr.com/api');
26
+ await page.goto(`https://www.36kr.com/search/articles/${query}`);
27
+ await page.wait(6);
28
+
29
+ const domItems: any = await page.evaluate(`
30
+ (() => {
31
+ const seen = new Set();
32
+ const results = [];
33
+ // article-item-title contains the clickable title link
34
+ const titleEls = document.querySelectorAll('.article-item-title a[href*="/p/"], .article-item-title[href*="/p/"]');
35
+ for (const el of titleEls) {
36
+ const href = el.getAttribute('href') || '';
37
+ const title = el.textContent?.trim() || '';
38
+ if (!title || seen.has(href)) continue;
39
+ seen.add(href);
40
+ // Look for date near the article item
41
+ const item = el.closest('[class*="article-item"]') || el.parentElement;
42
+ const dateEl = item?.querySelector('[class*="time"], [class*="date"], time');
43
+ const date = dateEl?.textContent?.trim() || '';
44
+ results.push({
45
+ title,
46
+ url: href.startsWith('http') ? href : 'https://36kr.com' + href,
47
+ date,
48
+ });
49
+ }
50
+ // Fallback: generic /p/ links with meaningful text
51
+ if (results.length === 0) {
52
+ const links = document.querySelectorAll('a[href*="/p/"]');
53
+ for (const el of links) {
54
+ const href = el.getAttribute('href') || '';
55
+ const title = el.textContent?.trim() || '';
56
+ if (!title || title.length < 8 || seen.has(href) || seen.has(title)) continue;
57
+ seen.add(href);
58
+ seen.add(title);
59
+ results.push({ title, url: href.startsWith('http') ? href : 'https://36kr.com' + href, date: '' });
60
+ }
61
+ }
62
+ return results;
63
+ })()
64
+ `);
65
+
66
+ const items = Array.isArray(domItems) ? (domItems as any[]) : [];
67
+ if (items.length === 0) {
68
+ throw new CliError('NO_DATA', 'No results found', `Try a different query or check your keyword`);
69
+ }
70
+
71
+ return items.slice(0, count).map((item: any, i: number) => ({
72
+ rank: i + 1,
73
+ title: item.title,
74
+ date: item.date,
75
+ url: item.url,
76
+ }));
77
+ },
78
+ });
@@ -0,0 +1,102 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { mockApiGet } = vi.hoisted(() => ({
4
+ mockApiGet: vi.fn(),
5
+ }));
6
+
7
+ vi.mock('./utils.js', () => ({
8
+ apiGet: mockApiGet,
9
+ }));
10
+
11
+ import { getRegistry } from '../../registry.js';
12
+ import './comments.js';
13
+
14
+ describe('bilibili comments', () => {
15
+ const command = getRegistry().get('bilibili/comments');
16
+
17
+ beforeEach(() => {
18
+ mockApiGet.mockReset();
19
+ });
20
+
21
+ it('resolves bvid to aid and fetches replies', async () => {
22
+ mockApiGet
23
+ .mockResolvedValueOnce({ data: { aid: 12345 } }) // view endpoint
24
+ .mockResolvedValueOnce({
25
+ data: {
26
+ replies: [
27
+ {
28
+ member: { uname: 'Alice' },
29
+ content: { message: 'Great video!' },
30
+ like: 42,
31
+ rcount: 3,
32
+ ctime: 1700000000,
33
+ },
34
+ ],
35
+ },
36
+ });
37
+
38
+ const result = await command!.func!({} as any, { bvid: 'BV1WtAGzYEBm', limit: 5 });
39
+
40
+ expect(mockApiGet).toHaveBeenNthCalledWith(1, {}, '/x/web-interface/view', { params: { bvid: 'BV1WtAGzYEBm' } });
41
+ expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
42
+ params: { oid: 12345, type: 1, mode: 3, ps: 5 },
43
+ signed: true,
44
+ });
45
+
46
+ expect(result).toEqual([
47
+ {
48
+ rank: 1,
49
+ author: 'Alice',
50
+ text: 'Great video!',
51
+ likes: 42,
52
+ replies: 3,
53
+ time: new Date(1700000000 * 1000).toISOString().slice(0, 16).replace('T', ' '),
54
+ },
55
+ ]);
56
+ });
57
+
58
+ it('throws when aid cannot be resolved', async () => {
59
+ mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
60
+
61
+ await expect(command!.func!({} as any, { bvid: 'BV_invalid', limit: 5 })).rejects.toThrow(
62
+ 'Cannot resolve aid for bvid: BV_invalid',
63
+ );
64
+ });
65
+
66
+ it('returns empty array when replies is missing', async () => {
67
+ mockApiGet
68
+ .mockResolvedValueOnce({ data: { aid: 99 } })
69
+ .mockResolvedValueOnce({ data: {} }); // no replies key
70
+
71
+ const result = await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 });
72
+ expect(result).toEqual([]);
73
+ });
74
+
75
+ it('caps limit at 50', async () => {
76
+ mockApiGet
77
+ .mockResolvedValueOnce({ data: { aid: 1 } })
78
+ .mockResolvedValueOnce({ data: { replies: [] } });
79
+
80
+ await command!.func!({} as any, { bvid: 'BV1xxx', limit: 999 });
81
+
82
+ expect(mockApiGet).toHaveBeenNthCalledWith(2, {}, '/x/v2/reply/main', {
83
+ params: { oid: 1, type: 1, mode: 3, ps: 50 },
84
+ signed: true,
85
+ });
86
+ });
87
+
88
+ it('collapses newlines in comment text', async () => {
89
+ mockApiGet
90
+ .mockResolvedValueOnce({ data: { aid: 1 } })
91
+ .mockResolvedValueOnce({
92
+ data: {
93
+ replies: [
94
+ { member: { uname: 'Bob' }, content: { message: 'line1\nline2\nline3' }, like: 0, rcount: 0, ctime: 0 },
95
+ ],
96
+ },
97
+ });
98
+
99
+ const result = (await command!.func!({} as any, { bvid: 'BV1xxx', limit: 5 })) as any[];
100
+ expect(result[0].text).toBe('line1 line2 line3');
101
+ });
102
+ });
@@ -0,0 +1,44 @@
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.
4
+ */
5
+
6
+ import { cli, Strategy } from '../../registry.js';
7
+ import { apiGet } from './utils.js';
8
+
9
+ cli({
10
+ site: 'bilibili',
11
+ name: 'comments',
12
+ description: '获取 B站视频评论(使用官方 API + WBI 签名)',
13
+ domain: 'www.bilibili.com',
14
+ strategy: Strategy.COOKIE,
15
+ args: [
16
+ { name: 'bvid', required: true, positional: true, help: 'Video BV ID (e.g. BV1WtAGzYEBm)' },
17
+ { name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
18
+ ],
19
+ columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
20
+ func: async (page, kwargs) => {
21
+ const bvid = String(kwargs.bvid).trim();
22
+ const limit = Math.min(Number(kwargs.limit) || 20, 50);
23
+
24
+ // Resolve bvid → aid (required by reply API)
25
+ const view = await apiGet(page, '/x/web-interface/view', { params: { bvid } });
26
+ const aid = view?.data?.aid;
27
+ if (!aid) throw new Error(`Cannot resolve aid for bvid: ${bvid}`);
28
+
29
+ const payload = await apiGet(page, '/x/v2/reply/main', {
30
+ params: { oid: aid, type: 1, mode: 3, ps: limit },
31
+ signed: true,
32
+ });
33
+
34
+ const replies: any[] = payload?.data?.replies ?? [];
35
+ return replies.slice(0, limit).map((r: any, i: number) => ({
36
+ rank: i + 1,
37
+ author: r.member?.uname ?? '',
38
+ text: (r.content?.message ?? '').replace(/\n/g, ' ').trim(),
39
+ likes: r.like ?? 0,
40
+ replies: r.rcount ?? 0,
41
+ time: new Date(r.ctime * 1000).toISOString().slice(0, 16).replace('T', ' '),
42
+ }));
43
+ },
44
+ });