@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
@@ -0,0 +1,197 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { CliError, getErrorMessage } from '../../errors.js';
4
+ export const PAPERREVIEW_DOMAIN = 'paperreview.ai';
5
+ export const PAPERREVIEW_BASE_URL = `https://${PAPERREVIEW_DOMAIN}`;
6
+ export const MAX_PDF_BYTES = 10 * 1024 * 1024;
7
+ function asText(value) {
8
+ return value == null ? '' : String(value);
9
+ }
10
+ function trimOrEmpty(value) {
11
+ return asText(value).trim();
12
+ }
13
+ function toErrorMessage(payload, fallback) {
14
+ if (payload && typeof payload === 'object') {
15
+ const detail = trimOrEmpty(payload.detail);
16
+ const message = trimOrEmpty(payload.message);
17
+ const error = trimOrEmpty(payload.error);
18
+ if (detail)
19
+ return detail;
20
+ if (message)
21
+ return message;
22
+ if (error)
23
+ return error;
24
+ }
25
+ const text = trimOrEmpty(payload);
26
+ return text || fallback;
27
+ }
28
+ export function buildReviewUrl(token) {
29
+ return `${PAPERREVIEW_BASE_URL}/review?token=${encodeURIComponent(token)}`;
30
+ }
31
+ export function parseYesNo(value, name) {
32
+ const normalized = trimOrEmpty(value).toLowerCase();
33
+ if (normalized === 'yes')
34
+ return true;
35
+ if (normalized === 'no')
36
+ return false;
37
+ throw new CliError('ARGUMENT', `"${name}" must be either "yes" or "no".`);
38
+ }
39
+ export function normalizeVenue(value) {
40
+ return trimOrEmpty(value);
41
+ }
42
+ export function validateHelpfulness(value) {
43
+ const numeric = Number(value);
44
+ if (!Number.isInteger(numeric) || numeric < 1 || numeric > 5) {
45
+ throw new CliError('ARGUMENT', '"helpfulness" must be an integer from 1 to 5.');
46
+ }
47
+ return numeric;
48
+ }
49
+ export async function readPdfFile(inputPath) {
50
+ const rawPath = trimOrEmpty(inputPath);
51
+ if (!rawPath) {
52
+ throw new CliError('ARGUMENT', 'A PDF path is required.', 'Provide a local PDF file path');
53
+ }
54
+ const resolvedPath = path.resolve(rawPath);
55
+ const fileName = path.basename(resolvedPath);
56
+ if (!fileName.toLowerCase().endsWith('.pdf')) {
57
+ throw new CliError('ARGUMENT', 'The input file must end with .pdf.', 'Provide a PDF file path');
58
+ }
59
+ let fileStat;
60
+ try {
61
+ fileStat = await fs.stat(resolvedPath);
62
+ }
63
+ catch (error) {
64
+ if (error?.code === 'ENOENT') {
65
+ throw new CliError('FILE_NOT_FOUND', `File not found: ${resolvedPath}`, 'Provide a valid PDF file path');
66
+ }
67
+ throw new CliError('FILE_READ_ERROR', `Unable to inspect file: ${resolvedPath}`, 'Check file permissions and try again');
68
+ }
69
+ if (!fileStat.isFile()) {
70
+ throw new CliError('FILE_NOT_FOUND', `Not a file: ${resolvedPath}`, 'Provide a valid PDF file path');
71
+ }
72
+ if (fileStat.size < 100) {
73
+ throw new CliError('ARGUMENT', 'The PDF is too small. paperreview.ai requires at least 100 bytes.', 'Provide the final paper PDF');
74
+ }
75
+ if (fileStat.size > MAX_PDF_BYTES) {
76
+ throw new CliError('FILE_TOO_LARGE', 'The PDF is larger than paperreview.ai\'s 10MB limit.', 'Compress the PDF or submit a smaller file');
77
+ }
78
+ let buffer;
79
+ try {
80
+ buffer = await fs.readFile(resolvedPath);
81
+ }
82
+ catch {
83
+ throw new CliError('FILE_READ_ERROR', `Unable to read file: ${resolvedPath}`, 'Check file permissions and try again');
84
+ }
85
+ return {
86
+ buffer,
87
+ fileName,
88
+ resolvedPath,
89
+ sizeBytes: buffer.byteLength,
90
+ };
91
+ }
92
+ export async function requestJson(pathname, init = {}) {
93
+ let response;
94
+ try {
95
+ response = await fetch(`${PAPERREVIEW_BASE_URL}${pathname}`, init);
96
+ }
97
+ catch (error) {
98
+ throw new CliError('FETCH_ERROR', `Unable to reach paperreview.ai: ${getErrorMessage(error)}`, 'Check your network connection and try again');
99
+ }
100
+ const rawText = await response.text();
101
+ let payload = rawText;
102
+ if (rawText) {
103
+ try {
104
+ payload = JSON.parse(rawText);
105
+ }
106
+ catch {
107
+ payload = rawText;
108
+ }
109
+ }
110
+ return { response, payload };
111
+ }
112
+ export function ensureSuccess(response, payload, fallback, hint) {
113
+ if (!response.ok) {
114
+ const code = response.status === 404 ? 'NOT_FOUND' : 'API_ERROR';
115
+ throw new CliError(code, toErrorMessage(payload, fallback), hint);
116
+ }
117
+ }
118
+ export function ensureApiSuccess(payload, fallback, hint) {
119
+ if (!payload || typeof payload !== 'object' || payload.success !== true) {
120
+ throw new CliError('API_ERROR', toErrorMessage(payload, fallback), hint);
121
+ }
122
+ }
123
+ export function createUploadForm(urlData, pdfFile) {
124
+ const form = new FormData();
125
+ for (const [key, value] of Object.entries(urlData.presigned_fields ?? {})) {
126
+ form.append(key, value);
127
+ }
128
+ form.append('file', new Blob([new Uint8Array(pdfFile.buffer)], { type: 'application/pdf' }), pdfFile.fileName);
129
+ return form;
130
+ }
131
+ export async function uploadPresignedPdf(presignedUrl, pdfFile, urlData) {
132
+ let response;
133
+ try {
134
+ response = await fetch(presignedUrl, {
135
+ method: 'POST',
136
+ body: createUploadForm(urlData, pdfFile),
137
+ });
138
+ }
139
+ catch (error) {
140
+ throw new CliError('UPLOAD_ERROR', `S3 upload failed: ${getErrorMessage(error)}`, 'Try again in a moment');
141
+ }
142
+ if (!response.ok) {
143
+ const body = await response.text();
144
+ throw new CliError('UPLOAD_ERROR', body || `S3 upload failed with status ${response.status}.`, 'Try again in a moment');
145
+ }
146
+ }
147
+ export function summarizeSubmission(options) {
148
+ const { pdfFile, email, venue, token, message, s3Key, dryRun = false, status } = options;
149
+ return {
150
+ status: status ?? (dryRun ? 'dry-run' : 'submitted'),
151
+ file: pdfFile.fileName,
152
+ file_path: pdfFile.resolvedPath,
153
+ size_bytes: pdfFile.sizeBytes,
154
+ email,
155
+ venue,
156
+ token: token ?? '',
157
+ review_url: token ? buildReviewUrl(token) : '',
158
+ message: message ?? '',
159
+ s3_key: s3Key ?? '',
160
+ };
161
+ }
162
+ export function summarizeReview(token, payload, status = 'ready') {
163
+ const sections = payload?.sections ?? {};
164
+ const availableSections = Object.keys(sections);
165
+ return {
166
+ status,
167
+ token,
168
+ review_url: buildReviewUrl(token),
169
+ title: trimOrEmpty(payload?.title),
170
+ venue: trimOrEmpty(payload?.venue),
171
+ submission_date: trimOrEmpty(payload?.submission_date),
172
+ numerical_score: payload?.numerical_score ?? '',
173
+ has_feedback: payload?.has_feedback ?? '',
174
+ available_sections: availableSections.join(', '),
175
+ section_count: availableSections.length,
176
+ summary: trimOrEmpty(sections.summary),
177
+ strengths: trimOrEmpty(sections.strengths),
178
+ weaknesses: trimOrEmpty(sections.weaknesses),
179
+ detailed_comments: trimOrEmpty(sections.detailed_comments),
180
+ questions: trimOrEmpty(sections.questions),
181
+ assessment: trimOrEmpty(sections.assessment),
182
+ content: trimOrEmpty(payload?.content),
183
+ sections,
184
+ };
185
+ }
186
+ export function summarizeFeedback(options) {
187
+ const { token, helpfulness, criticalError, actionableSuggestions, comments, payload } = options;
188
+ return {
189
+ status: 'submitted',
190
+ token,
191
+ helpfulness,
192
+ critical_error: criticalError,
193
+ actionable_suggestions: actionableSuggestions,
194
+ additional_comments: comments,
195
+ message: trimOrEmpty(payload?.message) || 'Feedback submitted.',
196
+ };
197
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { CliError } from '../../errors.js';
6
+ import { MAX_PDF_BYTES, buildReviewUrl, parseYesNo, readPdfFile, requestJson, validateHelpfulness, } from './utils.js';
7
+ describe('paperreview utils', () => {
8
+ beforeEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+ it('builds review URLs from the token', () => {
12
+ expect(buildReviewUrl('tok 123')).toBe('https://paperreview.ai/review?token=tok%20123');
13
+ });
14
+ it('parses yes/no flags', () => {
15
+ expect(parseYesNo('yes', 'critical-error')).toBe(true);
16
+ expect(parseYesNo('NO', 'critical-error')).toBe(false);
17
+ });
18
+ it('rejects invalid yes/no flags with CliError', () => {
19
+ expect(() => parseYesNo('maybe', 'critical-error')).toThrow(CliError);
20
+ expect(() => parseYesNo('maybe', 'critical-error')).toThrow('"critical-error" must be either "yes" or "no".');
21
+ });
22
+ it('validates helpfulness scores', () => {
23
+ expect(validateHelpfulness(5)).toBe(5);
24
+ expect(() => validateHelpfulness(0)).toThrow(CliError);
25
+ });
26
+ it('reads a valid PDF file and returns metadata', async () => {
27
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'paperreview-'));
28
+ const pdfPath = path.join(tempDir, 'sample.pdf');
29
+ const pdfBytes = Buffer.concat([Buffer.from('%PDF-1.4\n'), Buffer.alloc(256, 1)]);
30
+ await fs.writeFile(pdfPath, pdfBytes);
31
+ const result = await readPdfFile(pdfPath);
32
+ expect(result.fileName).toBe('sample.pdf');
33
+ expect(result.resolvedPath).toBe(pdfPath);
34
+ expect(result.sizeBytes).toBe(pdfBytes.length);
35
+ expect(result.buffer.equals(pdfBytes)).toBe(true);
36
+ });
37
+ it('rejects PDFs larger than the paperreview.ai size limit', async () => {
38
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'paperreview-'));
39
+ const pdfPath = path.join(tempDir, 'large.pdf');
40
+ await fs.writeFile(pdfPath, Buffer.alloc(MAX_PDF_BYTES + 1, 1));
41
+ await expect(readPdfFile(pdfPath)).rejects.toThrow(CliError);
42
+ await expect(readPdfFile(pdfPath)).rejects.toThrow('The PDF is larger than paperreview.ai\'s 10MB limit.');
43
+ });
44
+ it('normalizes fetch failures into CliError', async () => {
45
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
46
+ await expect(requestJson('/api/review/token')).rejects.toThrow(CliError);
47
+ await expect(requestJson('/api/review/token')).rejects.toThrow('Unable to reach paperreview.ai');
48
+ });
49
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Product Hunt category browse — INTERCEPT strategy.
3
+ *
4
+ * Navigates to a Product Hunt category page and scrapes the top-rated products.
5
+ * Shows all-time best products for a category (ranked by review score, not daily votes).
6
+ */
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import { CliError } from '../../errors.js';
9
+ import { PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
10
+ cli({
11
+ site: 'producthunt',
12
+ name: 'browse',
13
+ description: 'Best products in a Product Hunt category',
14
+ domain: 'www.producthunt.com',
15
+ strategy: Strategy.INTERCEPT,
16
+ args: [
17
+ {
18
+ name: 'category',
19
+ type: 'string',
20
+ positional: true,
21
+ required: true,
22
+ help: `Category slug, e.g. vibe-coding, ai-agents, developer-tools`,
23
+ },
24
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
25
+ ],
26
+ columns: ['rank', 'name', 'tagline', 'reviews', 'url'],
27
+ func: async (page, args) => {
28
+ const count = Math.min(Number(args.limit) || 20, 50);
29
+ const slug = String(args.category || '').trim().toLowerCase();
30
+ await page.installInterceptor('producthunt.com');
31
+ await page.goto(`https://www.producthunt.com/categories/${slug}`);
32
+ await page.wait(5);
33
+ const domItems = await page.evaluate(`
34
+ (() => {
35
+ const seen = new Set();
36
+ const results = [];
37
+
38
+ // Card links: <a class="...flex-col" href="/products/<slug>"> (not review links)
39
+ const cardLinks = Array.from(document.querySelectorAll('a[href^="/products/"]')).filter(a => {
40
+ const href = a.getAttribute('href') || '';
41
+ const cls = a.className || '';
42
+ return cls.includes('flex-col') && !href.includes('/reviews');
43
+ });
44
+
45
+ for (const cardLink of cardLinks) {
46
+ const href = cardLink.getAttribute('href');
47
+ if (!href || seen.has(href)) continue;
48
+
49
+ // Child 0: div with name (strip "Launched this month/week/year" noise)
50
+ const nameDiv = cardLink.querySelector('div');
51
+ const rawName = nameDiv?.textContent?.trim() || '';
52
+ const name = rawName
53
+ .replace(/\\s*Launched\\s+this\\s+(month|week|year|day)\\s*/gi, '')
54
+ .replace(/\\s*Featured\\s*/gi, '')
55
+ .trim();
56
+
57
+ // Child 1: span.text-secondary — tagline
58
+ const taglineEl = cardLink.querySelector('span.text-secondary, span[class*="text-secondary"]');
59
+ const tagline = taglineEl?.textContent?.trim() || '';
60
+
61
+ if (!name) continue;
62
+
63
+ // Find reviews count from sibling /reviews link
64
+ let reviews = '';
65
+ let container = cardLink.parentElement;
66
+ for (let i = 0; i < 5 && container; i++) {
67
+ const reviewLink = container.querySelector('a[href="' + href + '/reviews"]');
68
+ if (reviewLink) {
69
+ reviews = (reviewLink.textContent?.trim() || '').replace(/\\s*reviews?\\s*/i, '').trim();
70
+ break;
71
+ }
72
+ container = container.parentElement;
73
+ }
74
+
75
+ seen.add(href);
76
+ results.push({
77
+ name,
78
+ tagline: tagline.slice(0, 120),
79
+ reviews: reviews || '0',
80
+ url: 'https://www.producthunt.com' + href,
81
+ });
82
+ }
83
+
84
+ return results;
85
+ })()
86
+ `);
87
+ const items = Array.isArray(domItems) ? domItems : [];
88
+ if (items.length === 0) {
89
+ throw new CliError('NO_DATA', `No products found for category "${slug}"`, 'Check the category slug or try: ' + PRODUCTHUNT_CATEGORY_SLUGS.slice(0, 5).join(', '));
90
+ }
91
+ return items.slice(0, count).map((item, i) => ({
92
+ rank: i + 1,
93
+ name: item.name,
94
+ tagline: item.tagline,
95
+ reviews: item.reviews,
96
+ url: item.url,
97
+ }));
98
+ },
99
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Product Hunt top posts with vote counts — INTERCEPT strategy.
3
+ *
4
+ * Navigates to the Product Hunt homepage and scrapes rendered product cards.
5
+ */
6
+ import { cli, Strategy } from '../../registry.js';
7
+ import { CliError } from '../../errors.js';
8
+ import { pickVoteCount } from './utils.js';
9
+ cli({
10
+ site: 'producthunt',
11
+ name: 'hot',
12
+ description: "Today's top Product Hunt launches with vote counts",
13
+ domain: 'www.producthunt.com',
14
+ strategy: Strategy.INTERCEPT,
15
+ args: [
16
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
17
+ ],
18
+ columns: ['rank', 'name', 'votes', 'url'],
19
+ func: async (page, args) => {
20
+ const count = Math.min(Number(args.limit) || 20, 50);
21
+ await page.installInterceptor('producthunt.com');
22
+ await page.goto('https://www.producthunt.com');
23
+ await page.wait(5);
24
+ const domItems = await page.evaluate(`
25
+ (() => {
26
+ const seen = new Set();
27
+ const results = [];
28
+
29
+ const cardLinks = Array.from(document.querySelectorAll('a[href^="/products/"]')).filter((el) => {
30
+ const href = el.getAttribute('href') || '';
31
+ const text = el.textContent?.trim() || '';
32
+ return href && !href.includes('/reviews') && text.length > 0 && text.length < 120;
33
+ });
34
+
35
+ const normalizeName = (text) => text
36
+ .replace(/^\\d+\\.\\s*/, '')
37
+ .replace(/\\s*Launched\\s+this\\s+(month|week|year|day)\\s*/gi, '')
38
+ .replace(/\\s*Featured\\s*/gi, '')
39
+ .trim();
40
+
41
+ for (const cardLink of cardLinks) {
42
+ const href = cardLink.getAttribute('href') || '';
43
+ if (!href || seen.has(href)) continue;
44
+
45
+ let card = cardLink;
46
+ let node = cardLink.parentElement;
47
+ for (let i = 0; i < 6 && node; i++) {
48
+ const hasReviewLink = !!node.querySelector('a[href="' + href + '/reviews"]');
49
+ const hasNumericNode = Array.from(node.querySelectorAll('button, [role="button"], p, span, div'))
50
+ .some((el) => /^\\d+$/.test(el.textContent?.trim() || ''));
51
+ if (hasReviewLink || hasNumericNode) {
52
+ card = node;
53
+ break;
54
+ }
55
+ node = node.parentElement;
56
+ }
57
+
58
+ const name = normalizeName(cardLink.textContent?.trim() || '');
59
+ if (!name) continue;
60
+
61
+ const voteCandidates = Array.from(card.querySelectorAll('button, [role="button"], a, p, span, div'))
62
+ .map((el) => {
63
+ const reviewLink = el.closest('a[href="' + href + '/reviews"]');
64
+ return {
65
+ text: el.textContent?.trim() || '',
66
+ tagName: el.tagName,
67
+ className: el.className || '',
68
+ role: el.getAttribute('role') || '',
69
+ inButton: !!el.closest('button, [role="button"]'),
70
+ inReviewLink: !!reviewLink,
71
+ };
72
+ })
73
+ .filter((candidate) => /^\\d+$/.test(candidate.text));
74
+
75
+ if (voteCandidates.length === 0) continue;
76
+
77
+ seen.add(href);
78
+ results.push({
79
+ name,
80
+ voteCandidates,
81
+ url: 'https://www.producthunt.com' + href,
82
+ });
83
+ }
84
+
85
+ return results;
86
+ })()
87
+ `);
88
+ const items = Array.isArray(domItems) ? domItems : [];
89
+ if (items.length === 0) {
90
+ throw new CliError('NO_DATA', 'Could not retrieve Product Hunt top posts', 'Product Hunt may have changed its layout');
91
+ }
92
+ const rankedItems = items
93
+ .map((item) => ({
94
+ name: item.name,
95
+ url: item.url,
96
+ votes: pickVoteCount(Array.isArray(item.voteCandidates) ? item.voteCandidates : []),
97
+ }))
98
+ .filter((item) => item.name && item.url && item.votes);
99
+ if (rankedItems.length === 0) {
100
+ throw new CliError('NO_DATA', 'Could not retrieve Product Hunt vote counts', 'Product Hunt may have changed its vote button structure');
101
+ }
102
+ rankedItems.sort((a, b) => parseInt(b.votes, 10) - parseInt(a.votes, 10));
103
+ return rankedItems.slice(0, count).map((item, i) => ({
104
+ rank: i + 1,
105
+ name: item.name,
106
+ votes: item.votes,
107
+ url: item.url,
108
+ }));
109
+ },
110
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Product Hunt latest posts — public Atom feed, no browser needed.
3
+ */
4
+ import { cli, Strategy } from '../../registry.js';
5
+ import { fetchFeed, PRODUCTHUNT_CATEGORY_SLUGS } from './utils.js';
6
+ cli({
7
+ site: 'producthunt',
8
+ name: 'posts',
9
+ description: 'Latest Product Hunt launches (optional category filter)',
10
+ domain: 'www.producthunt.com',
11
+ strategy: Strategy.PUBLIC,
12
+ args: [
13
+ { name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
14
+ {
15
+ name: 'category',
16
+ type: 'string',
17
+ default: '',
18
+ help: `Category filter: ${PRODUCTHUNT_CATEGORY_SLUGS.join(', ')}`,
19
+ },
20
+ ],
21
+ columns: ['rank', 'name', 'tagline', 'author', 'date', 'url'],
22
+ func: async (_page, args) => {
23
+ const count = Math.min(Number(args.limit) || 20, 50);
24
+ const category = String(args.category ?? '').trim() || undefined;
25
+ const posts = await fetchFeed(category);
26
+ return posts.slice(0, count);
27
+ },
28
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Product Hunt today's top launches — filtered from public Atom feed.
3
+ *
4
+ * Shows the most recently published day's products (Product Hunt runs on
5
+ * Pacific Time; the feed date may differ from UTC local date by up to 1 day).
6
+ */
7
+ import { cli, Strategy } from '../../registry.js';
8
+ import { fetchFeed } from './utils.js';
9
+ cli({
10
+ site: 'producthunt',
11
+ name: 'today',
12
+ description: "Today's Product Hunt launches (most recent day in feed)",
13
+ domain: 'www.producthunt.com',
14
+ strategy: Strategy.PUBLIC,
15
+ args: [
16
+ { name: 'limit', type: 'int', default: 20, help: 'Max results' },
17
+ ],
18
+ columns: ['rank', 'name', 'tagline', 'author', 'url'],
19
+ func: async (_page, args) => {
20
+ const count = Math.min(Number(args.limit) || 20, 50);
21
+ const posts = await fetchFeed();
22
+ if (posts.length === 0)
23
+ return [];
24
+ // Use the latest date in the feed (Product Hunt is PST-based)
25
+ const latestDate = posts.map(p => p.date).sort().reverse()[0];
26
+ const todayPosts = posts.filter(p => p.date === latestDate);
27
+ return todayPosts.slice(0, count).map((p, i) => ({
28
+ rank: i + 1,
29
+ name: p.name,
30
+ tagline: p.tagline,
31
+ author: p.author,
32
+ url: p.url,
33
+ }));
34
+ },
35
+ });
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Product Hunt shared helpers.
3
+ */
4
+ export interface PhPost {
5
+ rank: number;
6
+ name: string;
7
+ tagline: string;
8
+ author: string;
9
+ date: string;
10
+ url: string;
11
+ }
12
+ export interface ProductHuntVoteCandidate {
13
+ text: string;
14
+ tagName?: string;
15
+ className?: string;
16
+ role?: string;
17
+ inButton?: boolean;
18
+ inReviewLink?: boolean;
19
+ }
20
+ export declare const PRODUCTHUNT_CATEGORY_SLUGS: readonly ["ai-agents", "ai-coding-agents", "ai-code-editors", "ai-chatbots", "ai-workflow-automation", "vibe-coding", "developer-tools", "productivity", "design-creative", "marketing-sales", "no-code-platforms", "llms", "finance", "social-community", "engineering-development"];
21
+ /**
22
+ * Fetch Product Hunt Atom RSS feed.
23
+ * @param category Optional category slug (e.g. "ai", "developer-tools")
24
+ */
25
+ export declare function fetchFeed(category?: string): Promise<PhPost[]>;
26
+ export declare function parseFeed(xml: string): PhPost[];
27
+ export declare function pickVoteCount(candidates: ProductHuntVoteCandidate[]): string;
28
+ /** Format ISO date string to YYYY-MM-DD */
29
+ export declare function toDate(iso: string): string;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Product Hunt shared helpers.
3
+ */
4
+ export const PRODUCTHUNT_CATEGORY_SLUGS = [
5
+ 'ai-agents',
6
+ 'ai-coding-agents',
7
+ 'ai-code-editors',
8
+ 'ai-chatbots',
9
+ 'ai-workflow-automation',
10
+ 'vibe-coding',
11
+ 'developer-tools',
12
+ 'productivity',
13
+ 'design-creative',
14
+ 'marketing-sales',
15
+ 'no-code-platforms',
16
+ 'llms',
17
+ 'finance',
18
+ 'social-community',
19
+ 'engineering-development',
20
+ ];
21
+ const UA = 'Mozilla/5.0 (compatible; opencli/1.0)';
22
+ /**
23
+ * Fetch Product Hunt Atom RSS feed.
24
+ * @param category Optional category slug (e.g. "ai", "developer-tools")
25
+ */
26
+ export async function fetchFeed(category) {
27
+ const url = category
28
+ ? `https://www.producthunt.com/feed?category=${encodeURIComponent(category)}`
29
+ : 'https://www.producthunt.com/feed';
30
+ const resp = await fetch(url, { headers: { 'User-Agent': UA } });
31
+ if (!resp.ok)
32
+ return [];
33
+ const xml = await resp.text();
34
+ return parseFeed(xml);
35
+ }
36
+ export function parseFeed(xml) {
37
+ const posts = [];
38
+ const entryRegex = /<entry>([\s\S]*?)<\/entry>/g;
39
+ let match;
40
+ let rank = 1;
41
+ while ((match = entryRegex.exec(xml))) {
42
+ const block = match[1];
43
+ const name = block.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? '';
44
+ const author = block.match(/<name>([\s\S]*?)<\/name>/)?.[1]?.trim() ?? '';
45
+ const pubRaw = block.match(/<published>(.*?)<\/published>/)?.[1]?.trim() ?? '';
46
+ const date = pubRaw.slice(0, 10);
47
+ const link = block.match(/<link[^>]*href="([^"]+)"/)?.[1]?.trim() ?? '';
48
+ // Extract tagline from HTML content (first <p> text)
49
+ const contentRaw = block.match(/<content[^>]*>([\s\S]*?)<\/content>/)?.[1] ?? '';
50
+ const contentDecoded = contentRaw
51
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/&quot;/g, '"');
52
+ const tagline = contentDecoded
53
+ .replace(/<[^>]+>/g, ' ')
54
+ .replace(/\s+/g, ' ')
55
+ .replace(/\s*Discussion\s*\|?\s*/gi, '')
56
+ .replace(/\s*\|?\s*Link\s*$/gi, '')
57
+ .trim()
58
+ .slice(0, 120);
59
+ if (name) {
60
+ posts.push({ rank: rank++, name, tagline, author, date, url: link });
61
+ }
62
+ }
63
+ return posts;
64
+ }
65
+ export function pickVoteCount(candidates) {
66
+ const scored = candidates
67
+ .map((candidate) => {
68
+ const text = String(candidate.text ?? '').trim();
69
+ if (!/^\d+$/.test(text))
70
+ return null;
71
+ if (candidate.inReviewLink)
72
+ return null;
73
+ const value = parseInt(text, 10);
74
+ if (!Number.isFinite(value) || value <= 0)
75
+ return null;
76
+ const signal = `${candidate.tagName ?? ''} ${candidate.className ?? ''} ${candidate.role ?? ''}`.toLowerCase();
77
+ let score = 0;
78
+ if (candidate.inButton)
79
+ score += 4;
80
+ if (signal.includes('vote') || signal.includes('upvote'))
81
+ score += 3;
82
+ if (signal.includes('button'))
83
+ score += 1;
84
+ return { text, score, value };
85
+ })
86
+ .filter((candidate) => Boolean(candidate))
87
+ .sort((a, b) => {
88
+ if (b.score !== a.score)
89
+ return b.score - a.score;
90
+ if (b.value !== a.value)
91
+ return b.value - a.value;
92
+ return a.text.localeCompare(b.text);
93
+ });
94
+ return scored[0]?.text ?? '';
95
+ }
96
+ /** Format ISO date string to YYYY-MM-DD */
97
+ export function toDate(iso) {
98
+ return iso.slice(0, 10);
99
+ }
@@ -0,0 +1 @@
1
+ export {};