@jackwener/opencli 1.7.12 → 1.7.14

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 (419) hide show
  1. package/README.md +8 -7
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +12128 -6665
  4. package/clis/1point3acres/digest.js +35 -0
  5. package/clis/1point3acres/forum.js +51 -0
  6. package/clis/1point3acres/forums.js +44 -0
  7. package/clis/1point3acres/hot.js +35 -0
  8. package/clis/1point3acres/latest.js +35 -0
  9. package/clis/1point3acres/notifications.js +64 -0
  10. package/clis/1point3acres/search.js +71 -0
  11. package/clis/1point3acres/thread.js +117 -0
  12. package/clis/1point3acres/user.js +77 -0
  13. package/clis/1point3acres/utils.js +247 -0
  14. package/clis/_shared/desktop-commands.js +4 -0
  15. package/clis/aibase/news.js +110 -0
  16. package/clis/aibase/news.test.js +59 -0
  17. package/clis/amazon/discussion.test.js +1 -28
  18. package/clis/antigravity/watch.js +3 -2
  19. package/clis/arxiv/author.js +44 -0
  20. package/clis/baidu-scholar/search.js +0 -1
  21. package/clis/bbc/topic.js +57 -0
  22. package/clis/bbc/utils.js +79 -0
  23. package/clis/chaoxing/assignments.js +1 -1
  24. package/clis/chaoxing/exams.js +1 -1
  25. package/clis/chatgpt/ask.js +57 -0
  26. package/clis/chatgpt/commands.test.js +45 -0
  27. package/clis/chatgpt/detail.js +46 -0
  28. package/clis/chatgpt/history.js +39 -0
  29. package/clis/chatgpt/image.js +12 -11
  30. package/clis/chatgpt/image.test.js +23 -0
  31. package/clis/chatgpt/new.js +25 -0
  32. package/clis/chatgpt/read.js +43 -0
  33. package/clis/chatgpt/send.js +46 -0
  34. package/clis/chatgpt/status.js +29 -0
  35. package/clis/chatgpt/utils.js +294 -4
  36. package/clis/chatgpt/utils.test.js +13 -0
  37. package/clis/chatgpt-app/ask.js +6 -3
  38. package/clis/chatwise/ask.js +16 -43
  39. package/clis/chatwise/composer.test.js +186 -0
  40. package/clis/chatwise/send.js +2 -24
  41. package/clis/chatwise/utils.js +143 -0
  42. package/clis/claude/ask.js +1 -1
  43. package/clis/claude/detail.js +1 -0
  44. package/clis/claude/history.js +1 -0
  45. package/clis/claude/new.js +1 -0
  46. package/clis/claude/read.js +1 -0
  47. package/clis/claude/send.js +1 -0
  48. package/clis/claude/status.js +1 -0
  49. package/clis/codex/ask.js +15 -9
  50. package/clis/codex/history.js +16 -33
  51. package/clis/codex/projects.js +28 -0
  52. package/clis/codex/read.js +10 -4
  53. package/clis/codex/send.js +10 -3
  54. package/clis/codex/sidebar.js +356 -0
  55. package/clis/codex/sidebar.test.js +329 -0
  56. package/clis/coingecko/categories.js +75 -0
  57. package/clis/coingecko/coin.js +107 -0
  58. package/clis/coingecko/coingecko.test.js +109 -0
  59. package/clis/coingecko/derivatives.js +84 -0
  60. package/clis/coingecko/exchanges.js +74 -0
  61. package/clis/coingecko/global.js +71 -0
  62. package/clis/coingecko/top.js +64 -0
  63. package/clis/coingecko/trending.js +55 -0
  64. package/clis/coupang/add-to-cart.js +21 -13
  65. package/clis/coupang/coupang.test.js +159 -0
  66. package/clis/coupang/product.js +257 -0
  67. package/clis/coupang/search.js +38 -16
  68. package/clis/coupang/utils.js +55 -1
  69. package/clis/crates/crate.js +62 -0
  70. package/clis/crates/search.js +44 -0
  71. package/clis/crates/utils.js +72 -0
  72. package/clis/ctrip/ctrip.test.js +234 -0
  73. package/clis/ctrip/hotel-suggest.js +45 -0
  74. package/clis/ctrip/search.js +22 -68
  75. package/clis/ctrip/utils.js +175 -0
  76. package/clis/cursor/ask.js +6 -3
  77. package/clis/dblp/author.js +133 -0
  78. package/clis/dblp/venue.js +64 -0
  79. package/clis/deepseek/ask.js +12 -7
  80. package/clis/deepseek/ask.test.js +13 -13
  81. package/clis/deepseek/detail.js +38 -0
  82. package/clis/deepseek/detail.test.js +81 -0
  83. package/clis/deepseek/history.js +1 -0
  84. package/clis/deepseek/new.js +1 -0
  85. package/clis/deepseek/read.js +1 -0
  86. package/clis/deepseek/send.js +140 -0
  87. package/clis/deepseek/send.test.js +107 -0
  88. package/clis/deepseek/status.js +1 -0
  89. package/clis/deepseek/utils.js +66 -0
  90. package/clis/deepseek/utils.test.js +107 -1
  91. package/clis/defillama/defillama.test.js +99 -0
  92. package/clis/defillama/protocol.js +84 -0
  93. package/clis/defillama/protocols.js +55 -0
  94. package/clis/defillama/utils.js +99 -0
  95. package/clis/devto/latest.js +74 -0
  96. package/clis/dockerhub/image.js +52 -0
  97. package/clis/dockerhub/search.js +47 -0
  98. package/clis/dockerhub/utils.js +100 -0
  99. package/clis/doubao/ask.js +7 -3
  100. package/clis/doubao/detail.js +1 -0
  101. package/clis/doubao/history.js +1 -0
  102. package/clis/doubao/meeting-summary.js +1 -0
  103. package/clis/doubao/meeting-transcript.js +1 -0
  104. package/clis/doubao/new.js +1 -0
  105. package/clis/doubao/read.js +1 -0
  106. package/clis/doubao/send.js +1 -0
  107. package/clis/doubao/status.js +1 -0
  108. package/clis/douyin/draft.test.js +1 -30
  109. package/clis/endoflife/endoflife.test.js +51 -0
  110. package/clis/endoflife/product.js +55 -0
  111. package/clis/endoflife/utils.js +89 -0
  112. package/clis/facebook/__fixtures__/notifications-page.html +13 -0
  113. package/clis/facebook/notifications.js +326 -30
  114. package/clis/facebook/notifications.test.js +458 -0
  115. package/clis/flathub/app.js +71 -0
  116. package/clis/flathub/flathub.test.js +90 -0
  117. package/clis/flathub/search.js +80 -0
  118. package/clis/flathub/utils.js +114 -0
  119. package/clis/gemini/ask.js +7 -3
  120. package/clis/gemini/ask.test.js +2 -2
  121. package/clis/gemini/deep-research-result.js +6 -2
  122. package/clis/gemini/deep-research-result.test.js +15 -14
  123. package/clis/gemini/deep-research.js +8 -4
  124. package/clis/gemini/deep-research.test.js +15 -18
  125. package/clis/gemini/image.js +7 -2
  126. package/clis/gemini/new.js +1 -0
  127. package/clis/gemini/utils.js +0 -4
  128. package/clis/google-scholar/cite.js +0 -1
  129. package/clis/google-scholar/profile.js +0 -1
  130. package/clis/google-scholar/search.js +0 -1
  131. package/clis/goproxy/goproxy.test.js +103 -0
  132. package/clis/goproxy/module.js +47 -0
  133. package/clis/goproxy/utils.js +165 -0
  134. package/clis/goproxy/versions.js +59 -0
  135. package/clis/gov-law/recent.js +0 -1
  136. package/clis/gov-law/search.js +0 -1
  137. package/clis/gov-policy/__fixtures__/recent.html +16 -0
  138. package/clis/gov-policy/__fixtures__/search.html +41 -0
  139. package/clis/gov-policy/gov-policy.test.js +224 -0
  140. package/clis/gov-policy/recent.js +66 -24
  141. package/clis/gov-policy/search.js +65 -23
  142. package/clis/gov-policy/utils.js +54 -0
  143. package/clis/grok/ask.js +49 -265
  144. package/clis/grok/ask.test.js +21 -46
  145. package/clis/grok/detail.js +60 -0
  146. package/clis/grok/history.js +48 -0
  147. package/clis/grok/{image.ts → image.js} +56 -70
  148. package/clis/grok/image.test.ts +20 -0
  149. package/clis/grok/new.js +20 -0
  150. package/clis/grok/read.js +39 -0
  151. package/clis/grok/send.js +50 -0
  152. package/clis/grok/status.js +41 -0
  153. package/clis/grok/utils.js +326 -0
  154. package/clis/grok/utils.test.js +103 -0
  155. package/clis/hf/datasets.js +88 -0
  156. package/clis/hf/hf.test.js +16 -0
  157. package/clis/hf/models.js +91 -0
  158. package/clis/hf/paper.js +79 -0
  159. package/clis/hf/spaces.js +101 -0
  160. package/clis/hf/top.js +1 -0
  161. package/clis/homebrew/cask.js +39 -0
  162. package/clis/homebrew/formula.js +41 -0
  163. package/clis/homebrew/popular.js +54 -0
  164. package/clis/homebrew/utils.js +100 -0
  165. package/clis/hupu/__fixtures__/hot-home.html +64 -0
  166. package/clis/hupu/detail.js +0 -1
  167. package/clis/hupu/hot.js +156 -35
  168. package/clis/hupu/hot.test.js +224 -0
  169. package/clis/hupu/search.js +0 -1
  170. package/clis/instagram/note.js +1 -1
  171. package/clis/instagram/note.test.js +1 -29
  172. package/clis/instagram/post.js +1 -1
  173. package/clis/instagram/post.test.js +1 -1
  174. package/clis/instagram/reel.js +1 -1
  175. package/clis/instagram/story.js +1 -1
  176. package/clis/instagram/story.test.js +1 -34
  177. package/clis/jd/commands.test.js +1 -24
  178. package/clis/lichess/lichess.test.js +85 -0
  179. package/clis/lichess/top.js +46 -0
  180. package/clis/lichess/user.js +91 -0
  181. package/clis/lichess/utils.js +97 -0
  182. package/clis/linkedin/search.js +107 -10
  183. package/clis/linkedin/search.test.js +222 -0
  184. package/clis/linux-do/feed.js +2 -5
  185. package/clis/linux-do/feed.test.js +35 -0
  186. package/clis/lobsters/domain.js +92 -0
  187. package/clis/maven/artifact.js +49 -0
  188. package/clis/maven/search.js +51 -0
  189. package/clis/maven/utils.js +110 -0
  190. package/clis/mdn/search.js +97 -0
  191. package/clis/medium/tag.js +135 -0
  192. package/clis/npm/downloads.js +59 -0
  193. package/clis/npm/package.js +70 -0
  194. package/clis/npm/search.js +49 -0
  195. package/clis/npm/utils.js +76 -0
  196. package/clis/nuget/nuget.test.js +111 -0
  197. package/clis/nuget/package.js +101 -0
  198. package/clis/nuget/search.js +69 -0
  199. package/clis/nuget/utils.js +87 -0
  200. package/clis/nvd/cve.js +121 -0
  201. package/clis/oeis/oeis.test.js +88 -0
  202. package/clis/oeis/search.js +63 -0
  203. package/clis/oeis/sequence.js +71 -0
  204. package/clis/oeis/utils.js +88 -0
  205. package/clis/openalex/search.js +69 -0
  206. package/clis/openalex/utils.js +160 -0
  207. package/clis/openalex/work.js +65 -0
  208. package/clis/openfda/drug-label.js +74 -0
  209. package/clis/openfda/food-recall.js +65 -0
  210. package/clis/openfda/openfda.test.js +114 -0
  211. package/clis/openfda/utils.js +67 -0
  212. package/clis/osv/osv.test.js +97 -0
  213. package/clis/osv/query.js +72 -0
  214. package/clis/osv/utils.js +169 -0
  215. package/clis/osv/vulnerability.js +54 -0
  216. package/clis/packagist/package.js +49 -0
  217. package/clis/packagist/search.js +43 -0
  218. package/clis/packagist/utils.js +113 -0
  219. package/clis/paperreview/feedback.js +1 -1
  220. package/clis/paperreview/review.js +1 -1
  221. package/clis/paperreview/submit.js +1 -1
  222. package/clis/pixiv/download.test.js +1 -1
  223. package/clis/pixiv/illusts.test.js +1 -1
  224. package/clis/pixiv/search.test.js +1 -1
  225. package/clis/pubmed/article.js +50 -0
  226. package/clis/pubmed/author.js +64 -0
  227. package/clis/pubmed/citations.js +36 -0
  228. package/clis/pubmed/pubmed.test.js +276 -0
  229. package/clis/pubmed/related.js +45 -0
  230. package/clis/pubmed/search.js +75 -0
  231. package/clis/pubmed/utils.js +309 -0
  232. package/clis/pypi/downloads.js +66 -0
  233. package/clis/pypi/package.js +79 -0
  234. package/clis/pypi/utils.js +55 -0
  235. package/clis/quark/mv.js +1 -1
  236. package/clis/quark/save.js +1 -1
  237. package/clis/qwen/ask.js +85 -0
  238. package/clis/qwen/detail.js +62 -0
  239. package/clis/qwen/history.js +61 -0
  240. package/clis/qwen/image.js +179 -0
  241. package/clis/qwen/new.js +23 -0
  242. package/clis/qwen/read.js +41 -0
  243. package/clis/qwen/send.js +55 -0
  244. package/clis/qwen/status.js +37 -0
  245. package/clis/qwen/utils.js +409 -0
  246. package/clis/qwen/utils.test.js +45 -0
  247. package/clis/rest-countries/country.js +65 -0
  248. package/clis/rest-countries/region.js +64 -0
  249. package/clis/rest-countries/rest-countries.test.js +83 -0
  250. package/clis/rest-countries/utils.js +126 -0
  251. package/clis/reuters/article-detail.js +53 -0
  252. package/clis/reuters/reuters.test.js +299 -0
  253. package/clis/reuters/search.js +45 -34
  254. package/clis/reuters/utils.js +159 -0
  255. package/clis/rfc/rfc.js +52 -0
  256. package/clis/rfc/rfc.test.js +74 -0
  257. package/clis/rfc/utils.js +72 -0
  258. package/clis/rubygems/gem.js +42 -0
  259. package/clis/rubygems/search.js +47 -0
  260. package/clis/rubygems/utils.js +86 -0
  261. package/clis/stackoverflow/related.js +66 -0
  262. package/clis/stackoverflow/stackoverflow.test.js +58 -0
  263. package/clis/stackoverflow/tag.js +60 -0
  264. package/clis/stackoverflow/user.js +50 -0
  265. package/clis/stackoverflow/utils.js +118 -0
  266. package/clis/steam/app.js +67 -0
  267. package/clis/steam/search.js +58 -0
  268. package/clis/steam/steam.test.js +46 -0
  269. package/clis/steam/utils.js +107 -0
  270. package/clis/taobao/commands.test.js +1 -24
  271. package/clis/test-utils.js +61 -0
  272. package/clis/tieba/hot.js +0 -1
  273. package/clis/tiktok/comment.js +128 -41
  274. package/clis/tiktok/creator-videos.js +270 -0
  275. package/clis/tiktok/creator-videos.test.js +113 -0
  276. package/clis/tiktok/explore.js +137 -29
  277. package/clis/tiktok/follow.js +115 -33
  278. package/clis/tiktok/following.js +157 -36
  279. package/clis/tiktok/friends.js +139 -37
  280. package/clis/tiktok/live.js +137 -41
  281. package/clis/tiktok/notifications.js +141 -38
  282. package/clis/tiktok/refactor.test.js +389 -0
  283. package/clis/tiktok/unfollow.js +124 -38
  284. package/clis/tiktok/user.js +203 -29
  285. package/clis/tiktok/utils.js +505 -0
  286. package/clis/tiktok/write-refactor.test.js +370 -0
  287. package/clis/toutiao/articles.js +36 -62
  288. package/clis/toutiao/hot.js +63 -0
  289. package/clis/toutiao/toutiao.test.js +378 -0
  290. package/clis/toutiao/utils.js +161 -0
  291. package/clis/tvmaze/search.js +61 -0
  292. package/clis/tvmaze/show.js +60 -0
  293. package/clis/tvmaze/tvmaze.test.js +93 -0
  294. package/clis/tvmaze/utils.js +110 -0
  295. package/clis/twitter/accept.js +1 -1
  296. package/clis/twitter/followers.js +134 -69
  297. package/clis/twitter/quote.js +139 -0
  298. package/clis/twitter/quote.test.js +106 -0
  299. package/clis/twitter/reply-dm.js +1 -1
  300. package/clis/twitter/reply.test.js +1 -29
  301. package/clis/twitter/retweet.js +99 -0
  302. package/clis/twitter/retweet.test.js +69 -0
  303. package/clis/twitter/shared.js +38 -0
  304. package/clis/twitter/shared.test.js +28 -1
  305. package/clis/twitter/unlike.js +87 -0
  306. package/clis/twitter/unlike.test.js +72 -0
  307. package/clis/twitter/unretweet.js +99 -0
  308. package/clis/twitter/unretweet.test.js +69 -0
  309. package/clis/uisdc/news.js +105 -0
  310. package/clis/uisdc/news.test.js +66 -0
  311. package/clis/wanfang/search.js +0 -1
  312. package/clis/web/read.js +47 -17
  313. package/clis/web/read.test.js +101 -1
  314. package/clis/weixin/create-draft.js +1 -1
  315. package/clis/weixin/drafts.js +1 -1
  316. package/clis/weixin/drafts.test.js +5 -1
  317. package/clis/weixin/search.js +157 -0
  318. package/clis/weixin/search.test.js +227 -0
  319. package/clis/wikidata/entity.js +60 -0
  320. package/clis/wikidata/search.js +50 -0
  321. package/clis/wikidata/utils.js +117 -0
  322. package/clis/wikidata/wikidata.test.js +83 -0
  323. package/clis/wikipedia/page.js +95 -0
  324. package/clis/wttr/current.js +63 -0
  325. package/clis/wttr/forecast.js +71 -0
  326. package/clis/wttr/utils.js +50 -0
  327. package/clis/wttr/wttr.test.js +84 -0
  328. package/clis/xianyu/chat.js +16 -4
  329. package/clis/xianyu/chat.test.js +64 -0
  330. package/clis/xianyu/publish.js +485 -0
  331. package/clis/xianyu/publish.test.js +220 -0
  332. package/clis/xiaoe/catalog.js +105 -40
  333. package/clis/xiaoe/content.js +164 -29
  334. package/clis/xiaoe/courses.js +86 -29
  335. package/clis/xiaoe/xiaoe.test.js +486 -0
  336. package/clis/xiaohongshu/creator-notes-summary.js +1 -1
  337. package/clis/xiaohongshu/publish.js +16 -3
  338. package/clis/xiaohongshu/publish.test.js +46 -1
  339. package/clis/youtube/transcript.js +13 -19
  340. package/clis/youtube/transcript.test.js +17 -0
  341. package/clis/yuanbao/ask.js +17 -66
  342. package/clis/yuanbao/ask.test.js +5 -5
  343. package/clis/yuanbao/detail.js +65 -0
  344. package/clis/yuanbao/history.js +51 -0
  345. package/clis/yuanbao/new.js +1 -0
  346. package/clis/yuanbao/read.js +38 -0
  347. package/clis/yuanbao/send.js +57 -0
  348. package/clis/yuanbao/shared.js +297 -5
  349. package/clis/yuanbao/shared.test.js +80 -0
  350. package/clis/yuanbao/status.js +44 -0
  351. package/clis/zlibrary/commands.test.js +1 -11
  352. package/dist/src/browser/base-page.d.ts +9 -0
  353. package/dist/src/browser/base-page.js +44 -1
  354. package/dist/src/browser/base-page.test.js +66 -0
  355. package/dist/src/browser/bridge.js +47 -45
  356. package/dist/src/browser/cdp.d.ts +1 -0
  357. package/dist/src/browser/cdp.js +51 -9
  358. package/dist/src/browser/daemon-client.d.ts +4 -0
  359. package/dist/src/browser/errors.js +1 -1
  360. package/dist/src/browser/page.d.ts +1 -1
  361. package/dist/src/browser/page.js +3 -1
  362. package/dist/src/browser/page.test.js +29 -0
  363. package/dist/src/browser/target-errors.d.ts +2 -1
  364. package/dist/src/browser/target-errors.js +1 -0
  365. package/dist/src/browser/target-resolver.d.ts +25 -0
  366. package/dist/src/browser/target-resolver.js +43 -0
  367. package/dist/src/browser.test.js +18 -0
  368. package/dist/src/build-manifest.js +9 -4
  369. package/dist/src/build-manifest.test.js +2 -8
  370. package/dist/src/capabilityRouting.d.ts +16 -1
  371. package/dist/src/capabilityRouting.js +24 -1
  372. package/dist/src/capabilityRouting.test.js +19 -1
  373. package/dist/src/cli.js +76 -11
  374. package/dist/src/cli.test.js +241 -1
  375. package/dist/src/commanderAdapter.js +23 -9
  376. package/dist/src/commanderAdapter.test.js +0 -1
  377. package/dist/src/discovery.js +2 -5
  378. package/dist/src/errors.js +1 -1
  379. package/dist/src/execution.d.ts +1 -1
  380. package/dist/src/execution.js +111 -27
  381. package/dist/src/execution.test.js +326 -17
  382. package/dist/src/help.d.ts +27 -2
  383. package/dist/src/help.js +196 -23
  384. package/dist/src/help.test.d.ts +1 -0
  385. package/dist/src/help.test.js +54 -0
  386. package/dist/src/main.js +14 -1
  387. package/dist/src/manifest-types.d.ts +5 -3
  388. package/dist/src/pipeline/executor.js +1 -1
  389. package/dist/src/pipeline/executor.test.js +8 -0
  390. package/dist/src/pipeline/registry.d.ts +9 -0
  391. package/dist/src/pipeline/registry.js +13 -1
  392. package/dist/src/pipeline/steps/browser.d.ts +1 -0
  393. package/dist/src/pipeline/steps/browser.js +10 -0
  394. package/dist/src/pipeline/steps/download.test.js +1 -0
  395. package/dist/src/registry-api.d.ts +1 -1
  396. package/dist/src/registry.d.ts +12 -11
  397. package/dist/src/registry.js +16 -6
  398. package/dist/src/registry.test.js +2 -2
  399. package/dist/src/runtime.d.ts +2 -1
  400. package/dist/src/runtime.js +1 -1
  401. package/dist/src/serialization.d.ts +2 -2
  402. package/dist/src/serialization.js +4 -6
  403. package/dist/src/serialization.test.js +17 -0
  404. package/dist/src/types.d.ts +17 -0
  405. package/dist/src/validate.js +15 -11
  406. package/dist/src/validate.test.d.ts +9 -0
  407. package/dist/src/validate.test.js +90 -0
  408. package/package.json +1 -1
  409. package/scripts/fetch-adapters.js +1 -1
  410. package/scripts/typed-error-lint-baseline.json +5 -77
  411. package/clis/ctrip/search.test.js +0 -64
  412. package/clis/gov-policy/commands.test.js +0 -27
  413. package/clis/linux-do/category.js +0 -37
  414. package/clis/linux-do/hot.js +0 -26
  415. package/clis/linux-do/latest.js +0 -19
  416. package/clis/pixiv/test-utils.js +0 -23
  417. package/clis/toutiao/articles.test.js +0 -30
  418. package/dist/src/analysis.d.ts +0 -40
  419. package/dist/src/analysis.js +0 -172
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Shared helpers for the reuters adapter.
3
+ */
4
+ import { ArgumentError } from '@jackwener/opencli/errors';
5
+
6
+ const MIN_LIMIT = 1;
7
+ const MAX_LIMIT = 40;
8
+
9
+ export function parseLimit(raw, fallback = 10) {
10
+ if (raw === undefined || raw === null || raw === '') return fallback;
11
+ const parsed = Number(raw);
12
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
13
+ throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}, got ${JSON.stringify(raw)}`);
14
+ }
15
+ if (parsed < MIN_LIMIT || parsed > MAX_LIMIT) {
16
+ throw new ArgumentError(`--limit must be between ${MIN_LIMIT} and ${MAX_LIMIT}, got ${parsed}`);
17
+ }
18
+ return parsed;
19
+ }
20
+
21
+ /**
22
+ * Build the in-page IIFE that fetches the Reuters search API.
23
+ *
24
+ * Returns a raw envelope `{ ok, status, body, error? }` so that error-handling
25
+ * lives in node space (not silently swallowed by `catch(e) {}` inside the
26
+ * browser).
27
+ */
28
+ export function buildSearchScript(query, count) {
29
+ return `
30
+ (async () => {
31
+ const apiQuery = JSON.stringify({
32
+ keyword: ${JSON.stringify(query)},
33
+ offset: 0,
34
+ orderby: 'display_date:desc',
35
+ size: ${count},
36
+ website: 'reuters'
37
+ });
38
+ const apiUrl = 'https://www.reuters.com/pf/api/v3/content/fetch/articles-by-search-v2?query=' + encodeURIComponent(apiQuery);
39
+ try {
40
+ const resp = await fetch(apiUrl, { credentials: 'include' });
41
+ const status = resp.status;
42
+ const statusText = resp.statusText || '';
43
+ const text = await resp.text();
44
+ let body = null;
45
+ let parseError = null;
46
+ if ((text || '').trim()) {
47
+ try { body = JSON.parse(text); } catch (e) { parseError = String((e && e.message) || e); }
48
+ }
49
+ return { ok: resp.ok, status, statusText, body, parseError, textPreview: (text || '').slice(0, 500) };
50
+ } catch (e) {
51
+ return { ok: false, status: 0, body: null, error: String((e && e.message) || e) };
52
+ }
53
+ })()
54
+ `;
55
+ }
56
+
57
+ /**
58
+ * In-page IIFE that walks the Reuters article page DOM and surfaces
59
+ * canonical metadata + body text. Returns a raw `{ ok, body, error? }`
60
+ * envelope so node-side handling can throw typed errors.
61
+ */
62
+ export function buildArticleDetailScript() {
63
+ return `
64
+ (() => {
65
+ try {
66
+ const pageText = document.body ? (document.body.innerText || '') : '';
67
+ const authRequired = ${looksAuthWallText.toString()}([
68
+ window.location.href || '',
69
+ document.title || '',
70
+ pageText.slice(0, 4000),
71
+ ].join('\\n')) || Boolean(document.querySelector('[data-testid*="paywall" i], [class*="paywall" i], [id*="paywall" i], [data-testid*="captcha" i], [class*="captcha" i]'));
72
+ const meta = document.getElementById('fusion-metadata');
73
+ let fusion = null;
74
+ if (meta) {
75
+ try { fusion = JSON.parse(meta.textContent || '{}'); } catch (e) { fusion = null; }
76
+ }
77
+ const article = fusion?.globalContent || null;
78
+ const paragraphs = Array.from(document.querySelectorAll('article [data-testid^="paragraph-"], article p'))
79
+ .map((p) => (p.textContent || '').trim())
80
+ .filter(Boolean);
81
+ const bodyText = paragraphs.length ? paragraphs.join('\\n\\n') : ((document.querySelector('article')?.innerText || '').trim() || null);
82
+ return { ok: true, authRequired, body: { article, bodyText } };
83
+ } catch (e) {
84
+ return { ok: false, error: String((e && e.message) || e) };
85
+ }
86
+ })()
87
+ `;
88
+ }
89
+
90
+ export function isAuthStatus(status) {
91
+ return Number(status) === 401 || Number(status) === 403;
92
+ }
93
+
94
+ export function looksAuthWallText(value) {
95
+ const text = String(value ?? '').toLowerCase();
96
+ return /datadome|captcha|verify you are human|human verification|access to this page has been denied|unusual traffic|subscribe to continue|subscription required|sign in to (continue|read|access)|log in to (continue|read|access)/.test(text);
97
+ }
98
+
99
+ function pickAuthors(a) {
100
+ if (!Array.isArray(a)) return null;
101
+ const names = a
102
+ .map((au) => (typeof au === 'string' ? au : au?.name || au?.byline || ''))
103
+ .map((s) => String(s).trim())
104
+ .filter(Boolean);
105
+ return names.length ? names.join(', ') : null;
106
+ }
107
+
108
+ function trimOrNull(v) {
109
+ const s = String(v ?? '').trim();
110
+ return s ? s : null;
111
+ }
112
+
113
+ function dateOnly(v) {
114
+ const s = String(v ?? '').trim();
115
+ if (!s) return null;
116
+ return s.split('T')[0];
117
+ }
118
+
119
+ function articleUrl(canonical) {
120
+ if (!canonical) return null;
121
+ const c = String(canonical);
122
+ if (/^https?:\/\//i.test(c)) return c;
123
+ return 'https://www.reuters.com' + c;
124
+ }
125
+
126
+ export function mapSearchArticles(body, limit) {
127
+ const articles = body?.result?.articles || body?.articles || [];
128
+ if (!Array.isArray(articles)) return [];
129
+ return articles
130
+ .filter((a) => a && typeof a === 'object')
131
+ .slice(0, limit)
132
+ .map((a, i) => ({
133
+ rank: i + 1,
134
+ title: trimOrNull(a.title || a.headlines?.basic),
135
+ date: dateOnly(a.display_date || a.published_time),
136
+ section: trimOrNull(a.taxonomy?.section?.name),
137
+ section_path: trimOrNull(a.taxonomy?.section?.path),
138
+ authors: pickAuthors(a.authors),
139
+ url: articleUrl(a.canonical_url),
140
+ }))
141
+ .filter((row) => row.title && row.url);
142
+ }
143
+
144
+ export function mapArticleDetail(article, bodyText, fallbackUrl = null) {
145
+ if (!article && !bodyText) return null;
146
+ return {
147
+ title: trimOrNull(article?.title || article?.headlines?.basic),
148
+ date: dateOnly(article?.display_date || article?.published_time),
149
+ section: trimOrNull(article?.taxonomy?.section?.name),
150
+ section_path: trimOrNull(article?.taxonomy?.section?.path),
151
+ authors: pickAuthors(article?.authors),
152
+ description: trimOrNull(article?.description?.basic || article?.subheadlines?.basic),
153
+ word_count: Number.isFinite(article?.word_count) ? article.word_count : null,
154
+ url: articleUrl(article?.canonical_url) || trimOrNull(fallbackUrl),
155
+ body: trimOrNull(bodyText),
156
+ };
157
+ }
158
+
159
+ export const __test__ = { MIN_LIMIT, MAX_LIMIT };
@@ -0,0 +1,52 @@
1
+ // rfc rfc — fetch a single IETF RFC's metadata.
2
+ //
3
+ // Hits `https://datatracker.ietf.org/doc/rfc<N>/doc.json` and projects the
4
+ // agent-useful fields: title, abstract (full text — RFCs don't truncate well),
5
+ // page count, working group, authors, std level, publish date, plus rendered URLs.
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { EmptyResultError } from '@jackwener/opencli/errors';
8
+ import { RFC_BASE, requireRfcNumber, rfcFetch, trimDate } from './utils.js';
9
+
10
+ cli({
11
+ site: 'rfc',
12
+ name: 'rfc',
13
+ access: 'read',
14
+ description: 'Single IETF RFC metadata (title, abstract, working group, authors, std level)',
15
+ domain: 'datatracker.ietf.org',
16
+ strategy: Strategy.PUBLIC,
17
+ browser: false,
18
+ args: [
19
+ { name: 'number', positional: true, type: 'int', required: true, help: 'RFC number (e.g. 9000, 791, 2616)' },
20
+ ],
21
+ columns: [
22
+ 'rfc', 'title', 'state', 'stdLevel', 'group', 'groupType',
23
+ 'pages', 'published', 'authors', 'abstract', 'rfcEditorUrl', 'url',
24
+ ],
25
+ func: async (args) => {
26
+ const number = requireRfcNumber(args.number);
27
+ const name = `rfc${number}`;
28
+ const doc = await rfcFetch(`${RFC_BASE}/doc/${name}/doc.json`, `rfc ${number}`);
29
+ if (!doc || !doc.name) {
30
+ throw new EmptyResultError('rfc rfc', `IETF datatracker returned no metadata for RFC ${number}.`);
31
+ }
32
+ const authors = Array.isArray(doc.authors)
33
+ ? doc.authors.map((a) => String(a?.name ?? '').trim()).filter(Boolean).join(', ')
34
+ : '';
35
+ const groupName = String(doc.group?.name ?? '').trim();
36
+ const groupType = String(doc.group?.type ?? '').trim();
37
+ return [{
38
+ rfc: number,
39
+ title: String(doc.title ?? '').trim(),
40
+ state: String(doc.state ?? '').trim(),
41
+ stdLevel: String(doc.std_level ?? '').trim(),
42
+ group: groupName,
43
+ groupType,
44
+ pages: doc.pages == null ? null : Number(doc.pages),
45
+ published: trimDate(doc.time),
46
+ authors,
47
+ abstract: String(doc.abstract ?? '').trim(),
48
+ rfcEditorUrl: `https://www.rfc-editor.org/rfc/rfc${number}`,
49
+ url: `${RFC_BASE}/doc/${name}/`,
50
+ }];
51
+ },
52
+ });
@@ -0,0 +1,74 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import './rfc.js';
5
+
6
+ afterEach(() => {
7
+ vi.unstubAllGlobals();
8
+ vi.restoreAllMocks();
9
+ });
10
+
11
+ describe('rfc rfc adapter', () => {
12
+ const cmd = getRegistry().get('rfc/rfc');
13
+
14
+ it('rejects malformed RFC numbers before fetching', async () => {
15
+ const fetchMock = vi.fn();
16
+ vi.stubGlobal('fetch', fetchMock);
17
+
18
+ await expect(cmd.func({ number: 'abc' })).rejects.toThrow(ArgumentError);
19
+ await expect(cmd.func({ number: 0 })).rejects.toThrow(ArgumentError);
20
+ await expect(cmd.func({ number: -5 })).rejects.toThrow(ArgumentError);
21
+ await expect(cmd.func({ number: 1000000 })).rejects.toThrow(ArgumentError);
22
+ expect(fetchMock).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it('maps HTTP 404 to EmptyResultError', async () => {
26
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not found', { status: 404 })));
27
+ await expect(cmd.func({ number: 999998 })).rejects.toThrow(EmptyResultError);
28
+ });
29
+
30
+ it('maps HTTP 429 to CommandExecutionError', async () => {
31
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('rate limited', { status: 429 })));
32
+ await expect(cmd.func({ number: 9000 })).rejects.toThrow(CommandExecutionError);
33
+ });
34
+
35
+ it('flattens authors + group, normalises space-separated date, and emits rfcEditorUrl', async () => {
36
+ const doc = {
37
+ name: 'rfc9000',
38
+ title: 'QUIC: A UDP-Based Multiplexed and Secure Transport',
39
+ state: 'Published',
40
+ std_level: 'Proposed Standard',
41
+ group: { name: 'QUIC', type: 'WG' },
42
+ pages: 151,
43
+ time: '2022-02-19 08:46:51',
44
+ authors: [
45
+ { name: 'Jana Iyengar', email: 'jri@example' },
46
+ { name: 'Martin Thomson', email: 'mt@example' },
47
+ ],
48
+ abstract: 'This document defines QUIC.',
49
+ };
50
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(doc), { status: 200 })));
51
+
52
+ const rows = await cmd.func({ number: 9000 });
53
+ expect(rows[0]).toMatchObject({
54
+ rfc: 9000,
55
+ title: 'QUIC: A UDP-Based Multiplexed and Secure Transport',
56
+ stdLevel: 'Proposed Standard',
57
+ group: 'QUIC',
58
+ groupType: 'WG',
59
+ pages: 151,
60
+ published: '2022-02-19',
61
+ authors: 'Jana Iyengar, Martin Thomson',
62
+ rfcEditorUrl: 'https://www.rfc-editor.org/rfc/rfc9000',
63
+ url: 'https://datatracker.ietf.org/doc/rfc9000/',
64
+ });
65
+ });
66
+
67
+ it('accepts "rfc<N>" prefix as a courtesy', async () => {
68
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
69
+ name: 'rfc791', title: 'Internet Protocol', authors: [], group: {},
70
+ }), { status: 200 })));
71
+ const rows = await cmd.func({ number: 'rfc791' });
72
+ expect(rows[0].rfc).toBe(791);
73
+ });
74
+ });
@@ -0,0 +1,72 @@
1
+ // Shared helpers for the IETF RFC adapter.
2
+ //
3
+ // datatracker.ietf.org publishes a free, unauthenticated REST API. The
4
+ // `/doc/<name>/doc.json` endpoint returns rich metadata for any IETF document
5
+ // (RFCs, internet drafts, etc.). Docs: https://datatracker.ietf.org/api/
6
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
7
+
8
+ export const RFC_BASE = 'https://datatracker.ietf.org';
9
+ const UA = 'opencli-rfc-adapter (+https://github.com/jackwener/opencli)';
10
+
11
+ export function requireRfcNumber(value) {
12
+ const raw = value;
13
+ if (raw == null || String(raw).trim() === '') {
14
+ throw new ArgumentError(
15
+ 'rfc number is required (e.g. 9000, 791, 2616)',
16
+ 'Pass the integer RFC number; do not include the "rfc" prefix.',
17
+ );
18
+ }
19
+ // Accept "9000" or 9000 or "rfc9000" as a courtesy.
20
+ const s = String(raw).trim().toLowerCase().replace(/^rfc/, '');
21
+ const n = Number.parseInt(s, 10);
22
+ if (!Number.isInteger(n) || n <= 0 || String(n) !== s) {
23
+ throw new ArgumentError(
24
+ `rfc number "${value}" is not a valid RFC number`,
25
+ 'Pass a positive integer (e.g. 9000, 791, 2616).',
26
+ );
27
+ }
28
+ if (n > 999999) {
29
+ throw new ArgumentError('rfc number must be <= 999999');
30
+ }
31
+ return n;
32
+ }
33
+
34
+ export async function rfcFetch(url, label) {
35
+ let resp;
36
+ try {
37
+ resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
38
+ }
39
+ catch (err) {
40
+ throw new CommandExecutionError(
41
+ `${label} request failed: ${err?.message ?? err}`,
42
+ 'Check that datatracker.ietf.org is reachable from this network.',
43
+ );
44
+ }
45
+ if (resp.status === 404) {
46
+ throw new EmptyResultError(label, `IETF datatracker returned 404 for ${url}.`);
47
+ }
48
+ if (resp.status === 429) {
49
+ throw new CommandExecutionError(`${label} returned HTTP 429 (rate limited)`);
50
+ }
51
+ if (!resp.ok) {
52
+ throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
53
+ }
54
+ let body;
55
+ try {
56
+ body = await resp.json();
57
+ }
58
+ catch (err) {
59
+ throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
60
+ }
61
+ return body;
62
+ }
63
+
64
+ // IETF datatracker timestamps are like "2022-02-19 08:46:51" (no T, no Z)
65
+ // or ISO with offset like "2016-07-08T21:03:52+00:00". Normalise to YYYY-MM-DD.
66
+ export function trimDate(value) {
67
+ const s = String(value ?? '').trim();
68
+ if (!s) return null;
69
+ // Take the first 10 chars only if they form a YYYY-MM-DD prefix.
70
+ if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
71
+ return null;
72
+ }
@@ -0,0 +1,42 @@
1
+ // rubygems gem — fetch a single gem's metadata from RubyGems.org.
2
+ //
3
+ // Hits `https://rubygems.org/api/v1/gems/<name>.json`. Returns a one-row
4
+ // projection: latest version + release date, lifetime / version downloads,
5
+ // license(s), author(s), homepage, source, bug tracker, short info.
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { GEMS_BASE, gemsFetch, requireGemName, trimDate } from './utils.js';
8
+
9
+ cli({
10
+ site: 'rubygems',
11
+ name: 'gem',
12
+ access: 'read',
13
+ description: 'Fetch a RubyGems.org gem\'s metadata (version, downloads, license, links)',
14
+ domain: 'rubygems.org',
15
+ strategy: Strategy.PUBLIC,
16
+ browser: false,
17
+ args: [
18
+ { name: 'name', positional: true, required: true, help: 'Gem name (e.g. "rails", "sidekiq")' },
19
+ ],
20
+ columns: ['gem', 'version', 'releasedAt', 'downloads', 'versionDownloads', 'license', 'authors', 'homepage', 'source', 'bugs', 'info', 'url'],
21
+ func: async (args) => {
22
+ const name = requireGemName(args.name);
23
+ const url = `${GEMS_BASE}/gems/${encodeURIComponent(name)}.json`;
24
+ const body = await gemsFetch(url, 'rubygems gem');
25
+ const licenses = Array.isArray(body?.licenses) ? body.licenses.filter(Boolean).join(', ') : '';
26
+ const meta = body?.metadata ?? {};
27
+ return [{
28
+ gem: String(body?.name ?? name).trim(),
29
+ version: String(body?.version ?? '').trim(),
30
+ releasedAt: trimDate(body?.version_created_at),
31
+ downloads: body?.downloads != null ? Number(body.downloads) : null,
32
+ versionDownloads: body?.version_downloads != null ? Number(body.version_downloads) : null,
33
+ license: licenses,
34
+ authors: String(body?.authors ?? '').trim(),
35
+ homepage: String(body?.homepage_uri ?? '').trim(),
36
+ source: String(body?.source_code_uri ?? meta.source_code_uri ?? '').trim(),
37
+ bugs: String(body?.bug_tracker_uri ?? meta.bug_tracker_uri ?? '').trim(),
38
+ info: String(body?.info ?? '').trim(),
39
+ url: String(body?.project_uri ?? `https://rubygems.org/gems/${name}`).trim(),
40
+ }];
41
+ },
42
+ });
@@ -0,0 +1,47 @@
1
+ // rubygems search — search the RubyGems.org public index.
2
+ //
3
+ // Hits `https://rubygems.org/api/v1/search.json?query=…&page=1`. Returns an
4
+ // agent-useful projection: gem name (round-trips into `rubygems gem`), latest
5
+ // version, lifetime downloads, license(s), author(s), short info, project URL.
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { EmptyResultError } from '@jackwener/opencli/errors';
8
+ import { GEMS_BASE, gemsFetch, requireBoundedInt, requireString } from './utils.js';
9
+
10
+ cli({
11
+ site: 'rubygems',
12
+ name: 'search',
13
+ access: 'read',
14
+ description: 'Search RubyGems.org gems by keyword',
15
+ domain: 'rubygems.org',
16
+ strategy: Strategy.PUBLIC,
17
+ browser: false,
18
+ args: [
19
+ { name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "rails", "redis")' },
20
+ { name: 'limit', type: 'int', default: 30, help: 'Max gems (1-100, single RubyGems page)' },
21
+ ],
22
+ columns: ['rank', 'gem', 'version', 'downloads', 'license', 'authors', 'info', 'url'],
23
+ func: async (args) => {
24
+ const query = requireString(args.query, 'query');
25
+ const limit = requireBoundedInt(args.limit, 30, 100);
26
+ const url = `${GEMS_BASE}/search.json?query=${encodeURIComponent(query)}&page=1`;
27
+ const body = await gemsFetch(url, 'rubygems search');
28
+ const list = Array.isArray(body) ? body : [];
29
+ if (!list.length) {
30
+ throw new EmptyResultError('rubygems search', `No gems matched "${query}".`);
31
+ }
32
+ return list.slice(0, limit).map((g, i) => {
33
+ const name = String(g.name ?? '').trim();
34
+ const licenses = Array.isArray(g.licenses) ? g.licenses.filter(Boolean).join(', ') : '';
35
+ return {
36
+ rank: i + 1,
37
+ gem: name,
38
+ version: String(g.version ?? '').trim(),
39
+ downloads: g.downloads != null ? Number(g.downloads) : null,
40
+ license: licenses,
41
+ authors: String(g.authors ?? '').trim(),
42
+ info: String(g.info ?? '').trim(),
43
+ url: name ? `https://rubygems.org/gems/${name}` : '',
44
+ };
45
+ });
46
+ },
47
+ });
@@ -0,0 +1,86 @@
1
+ // Shared helpers for the RubyGems.org adapters.
2
+ //
3
+ // Hits the public, unauthenticated `rubygems.org/api/v1` REST endpoints. No
4
+ // auth required for read-only metadata; the API is friendly to anonymous CLI
5
+ // traffic. Gem names follow the RubyGems convention: lowercase ASCII +
6
+ // `-_.`, 1-100 chars, must start with a letter or digit.
7
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
8
+
9
+ export const GEMS_BASE = 'https://rubygems.org/api/v1';
10
+ const UA = 'opencli-rubygems-adapter (+https://github.com/jackwener/opencli)';
11
+
12
+ // RubyGems gem name pattern (mirrors the rubygems-server validation).
13
+ const GEM_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
14
+
15
+ export function requireString(value, label) {
16
+ const s = String(value ?? '').trim();
17
+ if (!s) throw new ArgumentError(`rubygems ${label} cannot be empty`);
18
+ return s;
19
+ }
20
+
21
+ export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
22
+ const raw = value ?? defaultValue;
23
+ const n = typeof raw === 'number' ? raw : Number(raw);
24
+ if (!Number.isInteger(n) || n <= 0) {
25
+ throw new ArgumentError(`rubygems ${label} must be a positive integer`);
26
+ }
27
+ if (n > maxValue) {
28
+ throw new ArgumentError(`rubygems ${label} must be <= ${maxValue}`);
29
+ }
30
+ return n;
31
+ }
32
+
33
+ export function requireGemName(value) {
34
+ const s = String(value ?? '').trim();
35
+ if (!s) {
36
+ throw new ArgumentError('rubygems gem name is required (e.g. "rails", "sidekiq")');
37
+ }
38
+ if (s.length > 100 || !GEM_NAME.test(s)) {
39
+ throw new ArgumentError(
40
+ `rubygems gem "${value}" is not a valid gem name`,
41
+ 'Use letters / digits / "._-", starting with a letter or digit (max 100 chars).',
42
+ );
43
+ }
44
+ return s;
45
+ }
46
+
47
+ export async function gemsFetch(url, label) {
48
+ let resp;
49
+ try {
50
+ resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
51
+ }
52
+ catch (err) {
53
+ throw new CommandExecutionError(
54
+ `${label} request failed: ${err?.message ?? err}`,
55
+ 'Check that rubygems.org is reachable from this network.',
56
+ );
57
+ }
58
+ if (resp.status === 404) {
59
+ throw new EmptyResultError(label, `RubyGems returned 404 for ${url}.`);
60
+ }
61
+ if (resp.status === 429) {
62
+ throw new CommandExecutionError(
63
+ `${label} returned HTTP 429 (rate limited)`,
64
+ 'RubyGems throttles bursts; wait a few seconds and retry.',
65
+ );
66
+ }
67
+ if (!resp.ok) {
68
+ throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
69
+ }
70
+ let body;
71
+ try {
72
+ body = await resp.json();
73
+ }
74
+ catch (err) {
75
+ throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
76
+ }
77
+ return body;
78
+ }
79
+
80
+ /** Trim "2026-03-24T20:27:42.098Z" → "2026-03-24T20:27:42Z" so timestamps share a uniform precision. */
81
+ export function trimDate(value) {
82
+ const s = String(value ?? '').trim();
83
+ if (!s) return null;
84
+ const noFrac = s.replace(/\.\d+/, '');
85
+ return noFrac.endsWith('Z') ? noFrac : `${noFrac}Z`;
86
+ }
@@ -0,0 +1,66 @@
1
+ // stackoverflow related — find Stack Overflow questions related to a given question id.
2
+ //
3
+ // Hits the public `/questions/{id}/related` endpoint. Useful for follow-up
4
+ // research from a single SO question — agents can read one question with
5
+ // `stackoverflow read`, then expand the search to related/duplicate threads
6
+ // without rerunning a free-text search.
7
+ import { cli, Strategy } from '@jackwener/opencli/registry';
8
+ import { ArgumentError } from '@jackwener/opencli/errors';
9
+ import {
10
+ seFetch,
11
+ normalizeLimit,
12
+ epochToDate,
13
+ ensureItems,
14
+ decodeHtmlEntities,
15
+ } from './utils.js';
16
+
17
+ const SORT_OPTIONS = ['rank', 'activity', 'votes', 'creation'];
18
+
19
+ cli({
20
+ site: 'stackoverflow',
21
+ name: 'related',
22
+ access: 'read',
23
+ description: 'List Stack Overflow questions related to a given question id.',
24
+ domain: 'stackoverflow.com',
25
+ strategy: Strategy.PUBLIC,
26
+ browser: false,
27
+ args: [
28
+ { name: 'id', positional: true, required: true, type: 'string', help: 'Stack Overflow question id (numeric, e.g. 79935770).' },
29
+ { name: 'sort', type: 'string', default: 'rank', help: `Sort key: ${SORT_OPTIONS.join(', ')} (rank = SO relevance default).` },
30
+ { name: 'limit', type: 'int', default: 20, help: 'Max related questions (1-100).' },
31
+ ],
32
+ columns: ['rank', 'id', 'title', 'score', 'answers', 'views', 'isAnswered', 'tags', 'author', 'createdAt', 'lastActivityAt', 'url'],
33
+ func: async (args) => {
34
+ const id = String(args.id ?? '').trim();
35
+ if (!/^\d+$/.test(id)) {
36
+ throw new ArgumentError(`stackoverflow related id must be a numeric question id, got ${JSON.stringify(args.id)}`);
37
+ }
38
+ const sort = String(args.sort ?? 'rank').toLowerCase();
39
+ if (!SORT_OPTIONS.includes(sort)) {
40
+ throw new ArgumentError(`stackoverflow related sort must be one of ${SORT_OPTIONS.join(', ')}`);
41
+ }
42
+ const limit = normalizeLimit(args.limit, 20, 100, 'limit');
43
+ const data = await seFetch(`/questions/${encodeURIComponent(id)}/related`, {
44
+ searchParams: {
45
+ order: 'desc',
46
+ sort,
47
+ pagesize: limit,
48
+ },
49
+ });
50
+ const items = ensureItems(data, `stackoverflow related ${id}`);
51
+ return items.slice(0, limit).map((q, i) => ({
52
+ rank: i + 1,
53
+ id: q.question_id,
54
+ title: decodeHtmlEntities(q.title || ''),
55
+ score: q.score ?? 0,
56
+ answers: q.answer_count ?? 0,
57
+ views: q.view_count ?? 0,
58
+ isAnswered: Boolean(q.is_answered),
59
+ tags: Array.isArray(q.tags) ? q.tags.join(', ') : '',
60
+ author: decodeHtmlEntities(q.owner?.display_name || ''),
61
+ createdAt: epochToDate(q.creation_date),
62
+ lastActivityAt: epochToDate(q.last_activity_date),
63
+ url: q.link || (q.question_id ? `https://stackoverflow.com/questions/${q.question_id}` : ''),
64
+ }));
65
+ },
66
+ });
@@ -6,6 +6,8 @@ import './search.js';
6
6
  import './unanswered.js';
7
7
  import './bounties.js';
8
8
  import './read.js';
9
+ import './tag.js';
10
+ import './user.js';
9
11
 
10
12
  afterEach(() => {
11
13
  vi.unstubAllGlobals();
@@ -63,6 +65,62 @@ describe('stackoverflow listing adapters surface question_id/tags/views/owner',
63
65
  bounty: '${{ item.bounty_amount }}',
64
66
  });
65
67
  });
68
+
69
+ it('stackoverflow/tag exposes id so rows round-trip into read <id>', async () => {
70
+ const cmd = getRegistry().get('stackoverflow/tag');
71
+ expect(cmd?.columns).toEqual([
72
+ 'rank', 'id', 'title', 'score', 'answers', 'views',
73
+ 'isAnswered', 'tags', 'author', 'createdAt', 'lastActivityAt', 'url',
74
+ ]);
75
+
76
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(
77
+ new Response(JSON.stringify({
78
+ items: [{
79
+ question_id: 123,
80
+ title: 'Rust &amp; async',
81
+ score: 5,
82
+ answer_count: 2,
83
+ view_count: 100,
84
+ is_answered: true,
85
+ tags: ['rust', 'async'],
86
+ owner: { display_name: 'Ferris&#39;s friend' },
87
+ creation_date: 1700000000,
88
+ last_activity_date: 1700003600,
89
+ link: 'https://stackoverflow.com/questions/123/rust-async',
90
+ }],
91
+ }), { status: 200 }),
92
+ ));
93
+
94
+ const rows = await cmd.func({ tag: 'rust', sort: 'activity', limit: 1 });
95
+
96
+ expect(rows).toEqual([expect.objectContaining({
97
+ id: 123,
98
+ title: 'Rust & async',
99
+ author: "Ferris's friend",
100
+ url: 'https://stackoverflow.com/questions/123/rust-async',
101
+ })]);
102
+ expect(rows[0]).not.toHaveProperty('questionId');
103
+ });
104
+
105
+ it('stackoverflow/tag rejects bad sort and limit before fetching', async () => {
106
+ const cmd = getRegistry().get('stackoverflow/tag');
107
+ const fetchMock = vi.fn();
108
+ vi.stubGlobal('fetch', fetchMock);
109
+
110
+ await expect(cmd.func({ tag: 'rust', sort: 'invalid', limit: 1 }))
111
+ .rejects.toThrow(ArgumentError);
112
+ await expect(cmd.func({ tag: 'rust', sort: 'activity', limit: 101 }))
113
+ .rejects.toThrow(ArgumentError);
114
+ expect(fetchMock).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it('stackoverflow/user exposes userId profile rows without pretending they feed read <id>', async () => {
118
+ const cmd = getRegistry().get('stackoverflow/user');
119
+ expect(cmd?.columns).toEqual([
120
+ 'userId', 'displayName', 'reputation', 'goldBadges', 'silverBadges',
121
+ 'bronzeBadges', 'location', 'createdAt', 'lastAccessAt', 'url',
122
+ ]);
123
+ });
66
124
  });
67
125
 
68
126
  describe('stackoverflow/read adapter', () => {