@jackwener/opencli 1.4.1 → 1.5.0

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