@jackwener/opencli 1.6.1 → 1.6.2

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 (384) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +27 -45
  3. package/README.zh-CN.md +32 -34
  4. package/autoresearch/browse-tasks.json +18 -20
  5. package/autoresearch/commands/debug.ts +163 -0
  6. package/autoresearch/commands/fix.ts +145 -0
  7. package/autoresearch/commands/plan.ts +88 -0
  8. package/autoresearch/commands/run.ts +138 -0
  9. package/autoresearch/config.ts +82 -0
  10. package/autoresearch/engine.ts +359 -0
  11. package/autoresearch/eval-all.ts +127 -0
  12. package/autoresearch/eval-browse.ts +1 -1
  13. package/autoresearch/eval-publish.ts +238 -0
  14. package/autoresearch/eval-save.ts +249 -0
  15. package/autoresearch/eval-skill.ts +14 -8
  16. package/autoresearch/eval-v2ex.ts +220 -0
  17. package/autoresearch/eval-zhihu.ts +230 -0
  18. package/autoresearch/logger.ts +69 -0
  19. package/autoresearch/presets/combined-reliability.ts +27 -0
  20. package/autoresearch/presets/index.ts +23 -0
  21. package/autoresearch/presets/operate-reliability.ts +24 -0
  22. package/autoresearch/presets/save-reliability.ts +26 -0
  23. package/autoresearch/presets/skill-quality.ts +20 -0
  24. package/autoresearch/presets/v2ex-reliability.ts +24 -0
  25. package/autoresearch/presets/zhihu-reliability.ts +25 -0
  26. package/autoresearch/publish-tasks.json +345 -0
  27. package/autoresearch/run-save.sh +11 -0
  28. package/autoresearch/save-adapters/xhs-explore-deep.ts +64 -0
  29. package/autoresearch/save-adapters/xhs-note-comments.ts +61 -0
  30. package/autoresearch/save-adapters/xhs-search-full.ts +62 -0
  31. package/autoresearch/save-adapters/zhihu-hot-detail.ts +52 -0
  32. package/autoresearch/save-adapters/zhihu-question-full.ts +57 -0
  33. package/autoresearch/save-adapters/zhihu-search-detail.ts +53 -0
  34. package/autoresearch/save-tasks.json +281 -0
  35. package/autoresearch/v2ex-tasks.json +899 -0
  36. package/autoresearch/zhihu-tasks.json +848 -0
  37. package/dist/browser/base-page.d.ts +4 -2
  38. package/dist/browser/base-page.js +37 -4
  39. package/dist/browser/bridge.js +10 -8
  40. package/dist/browser/cdp.js +2 -6
  41. package/dist/browser/daemon-client.d.ts +11 -1
  42. package/dist/browser/daemon-client.js +3 -0
  43. package/dist/browser/dom-helpers.d.ts +4 -2
  44. package/dist/browser/dom-helpers.js +42 -31
  45. package/dist/browser/dom-snapshot.js +23 -1
  46. package/dist/browser/page.d.ts +7 -2
  47. package/dist/browser/page.js +112 -30
  48. package/dist/browser.test.js +1 -1
  49. package/dist/build-manifest.d.ts +1 -0
  50. package/dist/build-manifest.js +1 -0
  51. package/dist/cli-manifest.json +1135 -184
  52. package/dist/cli.d.ts +2 -0
  53. package/dist/cli.js +48 -7
  54. package/dist/cli.test.d.ts +1 -0
  55. package/dist/cli.test.js +88 -0
  56. package/dist/clis/1688/item.d.ts +70 -0
  57. package/dist/clis/1688/item.js +187 -0
  58. package/dist/clis/1688/item.test.d.ts +1 -0
  59. package/dist/clis/1688/item.test.js +67 -0
  60. package/dist/clis/1688/search.d.ts +56 -0
  61. package/dist/clis/1688/search.js +309 -0
  62. package/dist/clis/1688/search.test.d.ts +1 -0
  63. package/dist/clis/1688/search.test.js +75 -0
  64. package/dist/clis/1688/shared.d.ts +112 -0
  65. package/dist/clis/1688/shared.js +514 -0
  66. package/dist/clis/1688/shared.test.d.ts +1 -0
  67. package/dist/clis/1688/shared.test.js +57 -0
  68. package/dist/clis/1688/store.d.ts +45 -0
  69. package/dist/clis/1688/store.js +226 -0
  70. package/dist/clis/1688/store.test.d.ts +1 -0
  71. package/dist/clis/1688/store.test.js +62 -0
  72. package/dist/clis/amazon/bestsellers.d.ts +0 -20
  73. package/dist/clis/amazon/bestsellers.js +6 -129
  74. package/dist/clis/amazon/bestsellers.test.js +12 -3
  75. package/dist/clis/amazon/movers-shakers.d.ts +1 -0
  76. package/dist/clis/amazon/movers-shakers.js +7 -0
  77. package/dist/clis/amazon/new-releases.d.ts +1 -0
  78. package/dist/clis/amazon/new-releases.js +7 -0
  79. package/dist/clis/amazon/rankings.d.ts +59 -0
  80. package/dist/clis/amazon/rankings.js +226 -0
  81. package/dist/clis/amazon/rankings.test.d.ts +1 -0
  82. package/dist/clis/amazon/rankings.test.js +41 -0
  83. package/dist/clis/amazon/shared.d.ts +11 -0
  84. package/dist/clis/amazon/shared.js +121 -11
  85. package/dist/clis/amazon/shared.test.js +11 -0
  86. package/dist/clis/bilibili/comments.js +2 -2
  87. package/dist/clis/bilibili/comments.test.js +3 -2
  88. package/dist/clis/bilibili/download.js +2 -1
  89. package/dist/clis/bilibili/subtitle.js +4 -3
  90. package/dist/clis/bilibili/subtitle.test.js +2 -1
  91. package/dist/clis/bilibili/utils.d.ts +5 -0
  92. package/dist/clis/bilibili/utils.js +30 -0
  93. package/dist/clis/bilibili/utils.test.d.ts +1 -0
  94. package/dist/clis/bilibili/utils.test.js +17 -0
  95. package/dist/clis/douban/marks.js +1 -1
  96. package/dist/clis/douban/subject.yaml +50 -19
  97. package/dist/clis/doubao/utils.js +32 -12
  98. package/dist/clis/douyin/_shared/browser-fetch.test.js +0 -1
  99. package/dist/clis/douyin/_shared/transcode.test.js +0 -2
  100. package/dist/clis/douyin/draft.test.js +0 -2
  101. package/dist/clis/facebook/search.test.js +0 -2
  102. package/dist/clis/gemini/ask.js +9 -3
  103. package/dist/clis/gemini/ask.test.d.ts +1 -0
  104. package/dist/clis/gemini/ask.test.js +100 -0
  105. package/dist/clis/gemini/reply-state.test.d.ts +1 -0
  106. package/dist/clis/gemini/reply-state.test.js +641 -0
  107. package/dist/clis/gemini/utils.d.ts +44 -1
  108. package/dist/clis/gemini/utils.js +528 -61
  109. package/dist/clis/gemini/utils.test.js +149 -2
  110. package/dist/clis/hupu/detail.d.ts +1 -0
  111. package/dist/clis/hupu/detail.js +72 -0
  112. package/dist/clis/hupu/hot.yaml +43 -0
  113. package/dist/clis/hupu/like.d.ts +1 -0
  114. package/dist/clis/hupu/like.js +75 -0
  115. package/dist/clis/hupu/reply.d.ts +1 -0
  116. package/dist/clis/hupu/reply.js +71 -0
  117. package/dist/clis/hupu/search.d.ts +1 -0
  118. package/dist/clis/hupu/search.js +59 -0
  119. package/dist/clis/hupu/unlike.d.ts +1 -0
  120. package/dist/clis/hupu/unlike.js +75 -0
  121. package/dist/clis/hupu/utils.d.ts +20 -0
  122. package/dist/clis/hupu/utils.js +319 -0
  123. package/dist/clis/instagram/_shared/private-publish.d.ts +138 -0
  124. package/dist/clis/instagram/_shared/private-publish.js +1030 -0
  125. package/dist/clis/instagram/_shared/private-publish.test.d.ts +1 -0
  126. package/dist/clis/instagram/_shared/private-publish.test.js +705 -0
  127. package/dist/clis/instagram/_shared/protocol-capture.d.ts +26 -0
  128. package/dist/clis/instagram/_shared/protocol-capture.js +282 -0
  129. package/dist/clis/instagram/_shared/protocol-capture.test.d.ts +1 -0
  130. package/dist/clis/instagram/_shared/protocol-capture.test.js +114 -0
  131. package/dist/clis/instagram/_shared/runtime-info.d.ts +9 -0
  132. package/dist/clis/instagram/_shared/runtime-info.js +81 -0
  133. package/dist/clis/instagram/note.d.ts +1 -0
  134. package/dist/clis/instagram/note.js +222 -0
  135. package/dist/clis/instagram/note.test.d.ts +1 -0
  136. package/dist/clis/instagram/note.test.js +81 -0
  137. package/dist/clis/instagram/post.d.ts +4 -0
  138. package/dist/clis/instagram/post.js +1496 -0
  139. package/dist/clis/instagram/post.test.d.ts +1 -0
  140. package/dist/clis/instagram/post.test.js +1647 -0
  141. package/dist/clis/instagram/reel.d.ts +1 -0
  142. package/dist/clis/instagram/reel.js +826 -0
  143. package/dist/clis/instagram/reel.test.d.ts +1 -0
  144. package/dist/clis/instagram/reel.test.js +167 -0
  145. package/dist/clis/instagram/story.d.ts +1 -0
  146. package/dist/clis/instagram/story.js +115 -0
  147. package/dist/clis/instagram/story.test.d.ts +1 -0
  148. package/dist/clis/instagram/story.test.js +167 -0
  149. package/dist/clis/sinafinance/stock-rank.d.ts +4 -0
  150. package/dist/clis/sinafinance/stock-rank.js +65 -0
  151. package/dist/clis/substack/utils.test.js +0 -2
  152. package/dist/clis/twitter/post.js +72 -45
  153. package/dist/clis/twitter/post.test.d.ts +1 -0
  154. package/dist/clis/twitter/post.test.js +116 -0
  155. package/dist/clis/twitter/reply.d.ts +12 -0
  156. package/dist/clis/twitter/reply.js +257 -35
  157. package/dist/clis/twitter/reply.test.d.ts +1 -0
  158. package/dist/clis/twitter/reply.test.js +151 -0
  159. package/dist/clis/xianyu/chat.d.ts +7 -0
  160. package/dist/clis/xianyu/chat.js +146 -0
  161. package/dist/clis/xianyu/chat.test.d.ts +1 -0
  162. package/dist/clis/xianyu/chat.test.js +15 -0
  163. package/dist/clis/xianyu/item.d.ts +7 -0
  164. package/dist/clis/xianyu/item.js +152 -0
  165. package/dist/clis/xianyu/item.test.d.ts +1 -0
  166. package/dist/clis/xianyu/item.test.js +56 -0
  167. package/dist/clis/xianyu/search.d.ts +10 -0
  168. package/dist/clis/xianyu/search.js +134 -0
  169. package/dist/clis/xianyu/search.test.d.ts +1 -0
  170. package/dist/clis/xianyu/search.test.js +17 -0
  171. package/dist/clis/xianyu/utils.d.ts +1 -0
  172. package/dist/clis/xianyu/utils.js +8 -0
  173. package/dist/clis/xiaoe/catalog.yaml +129 -0
  174. package/dist/clis/xiaoe/content.yaml +43 -0
  175. package/dist/clis/xiaoe/courses.yaml +73 -0
  176. package/dist/clis/xiaoe/detail.yaml +39 -0
  177. package/dist/clis/xiaoe/play-url.yaml +124 -0
  178. package/dist/clis/xiaohongshu/comments.test.js +0 -2
  179. package/dist/clis/xiaohongshu/creator-note-detail.test.js +0 -2
  180. package/dist/clis/xiaohongshu/creator-notes.test.js +0 -2
  181. package/dist/clis/xiaohongshu/download.test.js +0 -2
  182. package/dist/clis/xiaohongshu/note.test.js +0 -2
  183. package/dist/clis/xiaohongshu/publish.test.js +0 -2
  184. package/dist/clis/xiaohongshu/search.js +29 -20
  185. package/dist/clis/xiaohongshu/search.test.js +56 -48
  186. package/dist/clis/yuanbao/ask.d.ts +21 -0
  187. package/dist/clis/yuanbao/ask.js +427 -0
  188. package/dist/clis/yuanbao/ask.test.d.ts +1 -0
  189. package/dist/clis/yuanbao/ask.test.js +124 -0
  190. package/dist/clis/yuanbao/new.d.ts +1 -0
  191. package/dist/clis/yuanbao/new.js +70 -0
  192. package/dist/clis/yuanbao/new.test.d.ts +1 -0
  193. package/dist/clis/yuanbao/new.test.js +30 -0
  194. package/dist/clis/yuanbao/shared.d.ts +13 -0
  195. package/dist/clis/yuanbao/shared.js +49 -0
  196. package/dist/clis/zhihu/question.js +30 -19
  197. package/dist/clis/zhihu/question.test.js +34 -16
  198. package/dist/commanderAdapter.js +8 -4
  199. package/dist/commanderAdapter.test.js +42 -0
  200. package/dist/completion.js +3 -1
  201. package/dist/completion.test.d.ts +1 -0
  202. package/dist/completion.test.js +23 -0
  203. package/dist/doctor.js +1 -1
  204. package/dist/electron-apps.d.ts +2 -0
  205. package/dist/electron-apps.js +7 -1
  206. package/dist/errors.js +1 -1
  207. package/dist/execution.js +25 -35
  208. package/dist/explore.js +1 -1
  209. package/dist/launcher.d.ts +4 -0
  210. package/dist/launcher.js +64 -8
  211. package/dist/launcher.test.js +88 -7
  212. package/dist/output.d.ts +2 -0
  213. package/dist/output.js +10 -1
  214. package/dist/output.test.d.ts +0 -3
  215. package/dist/output.test.js +59 -92
  216. package/dist/pipeline/executor.test.js +0 -2
  217. package/dist/pipeline/steps/download.test.js +0 -2
  218. package/dist/registry.d.ts +2 -0
  219. package/dist/serialization.d.ts +1 -0
  220. package/dist/serialization.js +1 -0
  221. package/dist/types.d.ts +9 -2
  222. package/docs/.vitepress/config.mts +4 -0
  223. package/docs/adapters/browser/1688.md +52 -0
  224. package/docs/adapters/browser/36kr.md +2 -1
  225. package/docs/adapters/browser/doubao.md +5 -1
  226. package/docs/adapters/browser/hupu.md +53 -0
  227. package/docs/adapters/browser/sinafinance.md +32 -2
  228. package/docs/adapters/browser/weibo.md +6 -1
  229. package/docs/adapters/browser/wikipedia.md +2 -0
  230. package/docs/adapters/browser/xianyu.md +42 -0
  231. package/docs/adapters/browser/xiaoe.md +44 -0
  232. package/docs/adapters/browser/yuanbao.md +64 -0
  233. package/docs/adapters/index.md +14 -5
  234. package/docs/comparison.md +1 -1
  235. package/docs/developer/ai-workflow.md +2 -2
  236. package/docs/developer/contributing.md +1 -1
  237. package/docs/developer/testing.md +2 -0
  238. package/docs/guide/plugins.md +1 -0
  239. package/docs/guide/troubleshooting.md +11 -0
  240. package/docs/superpowers/specs/2026-04-03-v2ex-autoresearch-design.md +41 -0
  241. package/docs/zh/guide/plugins.md +1 -0
  242. package/extension/dist/background.js +1127 -0
  243. package/extension/src/background.test.ts +39 -0
  244. package/extension/src/background.ts +223 -34
  245. package/extension/src/cdp.ts +194 -4
  246. package/extension/src/protocol.ts +22 -1
  247. package/package.json +3 -2
  248. package/scripts/postinstall.js +1 -1
  249. package/skills/opencli-explorer/SKILL.md +1 -1
  250. package/skills/opencli-oneshot/SKILL.md +2 -2
  251. package/skills/opencli-operate/SKILL.md +120 -27
  252. package/skills/opencli-usage/SKILL.md +31 -20
  253. package/skills/opencli-usage/browser.md +114 -16
  254. package/skills/opencli-usage/public-api.md +32 -3
  255. package/skills/smart-search/SKILL.md +156 -0
  256. package/skills/smart-search/references/sources-ai.md +74 -0
  257. package/skills/smart-search/references/sources-info.md +43 -0
  258. package/skills/smart-search/references/sources-media.md +50 -0
  259. package/skills/smart-search/references/sources-other.md +42 -0
  260. package/skills/smart-search/references/sources-shopping.md +31 -0
  261. package/skills/smart-search/references/sources-social.md +51 -0
  262. package/skills/smart-search/references/sources-tech.md +42 -0
  263. package/skills/smart-search/references/sources-travel.md +20 -0
  264. package/src/browser/base-page.ts +41 -6
  265. package/src/browser/bridge.ts +11 -8
  266. package/src/browser/cdp.ts +1 -8
  267. package/src/browser/daemon-client.ts +11 -1
  268. package/src/browser/dom-helpers.ts +43 -31
  269. package/src/browser/dom-snapshot.ts +23 -1
  270. package/src/browser/page.ts +115 -31
  271. package/src/browser.test.ts +1 -1
  272. package/src/build-manifest.ts +2 -0
  273. package/src/cli.test.ts +133 -0
  274. package/src/cli.ts +73 -11
  275. package/src/clis/1688/item.test.ts +69 -0
  276. package/src/clis/1688/item.ts +282 -0
  277. package/src/clis/1688/search.test.ts +81 -0
  278. package/src/clis/1688/search.ts +402 -0
  279. package/src/clis/1688/shared.test.ts +75 -0
  280. package/src/clis/1688/shared.ts +623 -0
  281. package/src/clis/1688/store.test.ts +69 -0
  282. package/src/clis/1688/store.ts +300 -0
  283. package/src/clis/amazon/bestsellers.test.ts +12 -3
  284. package/src/clis/amazon/bestsellers.ts +6 -178
  285. package/src/clis/amazon/movers-shakers.ts +8 -0
  286. package/src/clis/amazon/new-releases.ts +8 -0
  287. package/src/clis/amazon/rankings.test.ts +47 -0
  288. package/src/clis/amazon/rankings.ts +312 -0
  289. package/src/clis/amazon/shared.test.ts +16 -0
  290. package/src/clis/amazon/shared.ts +134 -12
  291. package/src/clis/bilibili/comments.test.ts +4 -3
  292. package/src/clis/bilibili/comments.ts +2 -2
  293. package/src/clis/bilibili/download.ts +2 -1
  294. package/src/clis/bilibili/subtitle.test.ts +2 -1
  295. package/src/clis/bilibili/subtitle.ts +4 -3
  296. package/src/clis/bilibili/utils.test.ts +21 -0
  297. package/src/clis/bilibili/utils.ts +27 -0
  298. package/src/clis/douban/marks.ts +1 -1
  299. package/src/clis/douban/subject.yaml +50 -19
  300. package/src/clis/doubao/utils.ts +32 -12
  301. package/src/clis/douyin/_shared/browser-fetch.test.ts +0 -1
  302. package/src/clis/douyin/_shared/transcode.test.ts +0 -2
  303. package/src/clis/douyin/draft.test.ts +0 -2
  304. package/src/clis/facebook/search.test.ts +0 -2
  305. package/src/clis/gemini/ask.test.ts +116 -0
  306. package/src/clis/gemini/ask.ts +10 -3
  307. package/src/clis/gemini/reply-state.test.ts +708 -0
  308. package/src/clis/gemini/utils.test.ts +184 -2
  309. package/src/clis/gemini/utils.ts +588 -60
  310. package/src/clis/hupu/detail.ts +126 -0
  311. package/src/clis/hupu/hot.yaml +43 -0
  312. package/src/clis/hupu/like.ts +76 -0
  313. package/src/clis/hupu/reply.ts +76 -0
  314. package/src/clis/hupu/search.ts +95 -0
  315. package/src/clis/hupu/unlike.ts +76 -0
  316. package/src/clis/hupu/utils.ts +381 -0
  317. package/src/clis/instagram/_shared/private-publish.test.ts +827 -0
  318. package/src/clis/instagram/_shared/private-publish.ts +1303 -0
  319. package/src/clis/instagram/_shared/protocol-capture.test.ts +148 -0
  320. package/src/clis/instagram/_shared/protocol-capture.ts +321 -0
  321. package/src/clis/instagram/_shared/runtime-info.ts +91 -0
  322. package/src/clis/instagram/note.test.ts +96 -0
  323. package/src/clis/instagram/note.ts +254 -0
  324. package/src/clis/instagram/post.test.ts +1716 -0
  325. package/src/clis/instagram/post.ts +1620 -0
  326. package/src/clis/instagram/reel.test.ts +191 -0
  327. package/src/clis/instagram/reel.ts +886 -0
  328. package/src/clis/instagram/story.test.ts +191 -0
  329. package/src/clis/instagram/story.ts +151 -0
  330. package/src/clis/sinafinance/stock-rank.ts +68 -0
  331. package/src/clis/substack/utils.test.ts +0 -2
  332. package/src/clis/twitter/post.test.ts +157 -0
  333. package/src/clis/twitter/post.ts +82 -48
  334. package/src/clis/twitter/reply.test.ts +177 -0
  335. package/src/clis/twitter/reply.ts +285 -39
  336. package/src/clis/xianyu/chat.test.ts +20 -0
  337. package/src/clis/xianyu/chat.ts +175 -0
  338. package/src/clis/xianyu/item.test.ts +67 -0
  339. package/src/clis/xianyu/item.ts +172 -0
  340. package/src/clis/xianyu/search.test.ts +22 -0
  341. package/src/clis/xianyu/search.ts +151 -0
  342. package/src/clis/xianyu/utils.ts +9 -0
  343. package/src/clis/xiaoe/catalog.yaml +129 -0
  344. package/src/clis/xiaoe/content.yaml +43 -0
  345. package/src/clis/xiaoe/courses.yaml +73 -0
  346. package/src/clis/xiaoe/detail.yaml +39 -0
  347. package/src/clis/xiaoe/play-url.yaml +124 -0
  348. package/src/clis/xiaohongshu/comments.test.ts +0 -2
  349. package/src/clis/xiaohongshu/creator-note-detail.test.ts +0 -2
  350. package/src/clis/xiaohongshu/creator-notes.test.ts +0 -2
  351. package/src/clis/xiaohongshu/download.test.ts +0 -2
  352. package/src/clis/xiaohongshu/note.test.ts +0 -2
  353. package/src/clis/xiaohongshu/publish.test.ts +0 -2
  354. package/src/clis/xiaohongshu/search.test.ts +59 -48
  355. package/src/clis/xiaohongshu/search.ts +31 -21
  356. package/src/clis/yuanbao/ask.test.ts +156 -0
  357. package/src/clis/yuanbao/ask.ts +522 -0
  358. package/src/clis/yuanbao/new.test.ts +36 -0
  359. package/src/clis/yuanbao/new.ts +81 -0
  360. package/src/clis/yuanbao/shared.ts +57 -0
  361. package/src/clis/zhihu/question.test.ts +42 -17
  362. package/src/clis/zhihu/question.ts +31 -26
  363. package/src/commanderAdapter.test.ts +51 -0
  364. package/src/commanderAdapter.ts +8 -4
  365. package/src/completion.test.ts +30 -0
  366. package/src/completion.ts +3 -1
  367. package/src/doctor.ts +1 -1
  368. package/src/electron-apps.ts +9 -1
  369. package/src/errors.ts +1 -1
  370. package/src/execution.ts +26 -30
  371. package/src/explore.ts +1 -1
  372. package/src/launcher.test.ts +121 -7
  373. package/src/launcher.ts +87 -9
  374. package/src/output.test.ts +50 -90
  375. package/src/output.ts +10 -1
  376. package/src/pipeline/executor.test.ts +0 -2
  377. package/src/pipeline/steps/download.test.ts +0 -2
  378. package/src/registry.ts +2 -0
  379. package/src/serialization.ts +2 -0
  380. package/src/types.ts +9 -2
  381. package/tests/e2e/browser-auth.test.ts +9 -0
  382. package/CLI-EXPLORER.md +0 -724
  383. package/CLI-ONESHOT.md +0 -216
  384. package/SKILL.md +0 -59
@@ -0,0 +1,312 @@
1
+ import { CommandExecutionError } from '../../errors.js';
2
+ import { Strategy, type CliOptions } from '../../registry.js';
3
+ import type { IPage } from '../../types.js';
4
+ import {
5
+ assertUsableState,
6
+ buildProvenance,
7
+ cleanText,
8
+ extractAsin,
9
+ extractCategoryNodeId,
10
+ extractReviewCountFromCardText,
11
+ firstMeaningfulLine,
12
+ gotoAndReadState,
13
+ isRankingPaginationUrl,
14
+ normalizeProductUrl,
15
+ parsePriceText,
16
+ parseRatingValue,
17
+ parseReviewCount,
18
+ resolveRankingUrl,
19
+ toAbsoluteAmazonUrl,
20
+ uniqueNonEmpty,
21
+ type AmazonRankingListType,
22
+ } from './shared.js';
23
+
24
+ export interface RankingCardPayload {
25
+ rank_text?: string | null;
26
+ asin?: string | null;
27
+ title?: string | null;
28
+ href?: string | null;
29
+ price_text?: string | null;
30
+ rating_text?: string | null;
31
+ review_count_text?: string | null;
32
+ card_text?: string | null;
33
+ }
34
+
35
+ interface RankingPagePayload {
36
+ href?: string;
37
+ title?: string;
38
+ list_title?: string;
39
+ category_title?: string;
40
+ category_path?: string[];
41
+ cards?: RankingCardPayload[];
42
+ page_links?: string[];
43
+ visible_category_links?: Array<{
44
+ title?: string | null;
45
+ url?: string | null;
46
+ node_id?: string | null;
47
+ }>;
48
+ }
49
+
50
+ interface RankingCommandDefinition {
51
+ commandName: string;
52
+ listType: AmazonRankingListType;
53
+ description: string;
54
+ }
55
+
56
+ interface RankingNormalizeContext {
57
+ listType: AmazonRankingListType;
58
+ rankFallback: number;
59
+ listTitle: string | null;
60
+ sourceUrl: string;
61
+ categoryTitle: string | null;
62
+ categoryUrl: string | null;
63
+ categoryPath: string[];
64
+ visibleCategoryLinks: Array<{ title: string; url: string; node_id: string | null }>;
65
+ }
66
+
67
+ function parseRank(rawRank: string | null | undefined, fallback: number): number {
68
+ const normalized = cleanText(rawRank);
69
+ const match = normalized.match(/(\d{1,4})/);
70
+ if (!match) return fallback;
71
+ const parsed = Number.parseInt(match[1], 10);
72
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
73
+ }
74
+
75
+ function normalizeVisibleCategoryLinks(
76
+ links: RankingPagePayload['visible_category_links'],
77
+ ): Array<{ title: string; url: string; node_id: string | null }> {
78
+ const normalized = (links ?? [])
79
+ .map((entry) => ({
80
+ title: cleanText(entry?.title),
81
+ url: toAbsoluteAmazonUrl(entry?.url) ?? '',
82
+ node_id: cleanText(entry?.node_id) || extractCategoryNodeId(entry?.url) || null,
83
+ }))
84
+ .filter((entry) => Boolean(entry.title) && Boolean(entry.url));
85
+
86
+ const seen = new Set<string>();
87
+ const deduped: Array<{ title: string; url: string; node_id: string | null }> = [];
88
+ for (const entry of normalized) {
89
+ if (seen.has(entry.url)) continue;
90
+ seen.add(entry.url);
91
+ deduped.push(entry);
92
+ }
93
+ return deduped;
94
+ }
95
+
96
+ export function normalizeRankingCandidate(
97
+ candidate: RankingCardPayload,
98
+ context: RankingNormalizeContext,
99
+ ): Record<string, unknown> {
100
+ const productUrl = normalizeProductUrl(candidate.href);
101
+ const asin = extractAsin(candidate.asin ?? '') ?? extractAsin(productUrl ?? '') ?? null;
102
+ const title = cleanText(candidate.title) || firstMeaningfulLine(candidate.card_text);
103
+ const price = parsePriceText(cleanText(candidate.price_text) || candidate.card_text);
104
+ const ratingText = cleanText(candidate.rating_text) || null;
105
+ const reviewCountText = cleanText(candidate.review_count_text)
106
+ || extractReviewCountFromCardText(candidate.card_text)
107
+ || null;
108
+ const provenance = buildProvenance(context.sourceUrl);
109
+ const categoryUrl = context.categoryUrl || context.sourceUrl;
110
+
111
+ return {
112
+ list_type: context.listType,
113
+ rank: parseRank(candidate.rank_text, context.rankFallback),
114
+ asin,
115
+ title: title || null,
116
+ product_url: productUrl,
117
+ price_text: price.price_text,
118
+ price_value: price.price_value,
119
+ currency: price.currency,
120
+ rating_text: ratingText,
121
+ rating_value: parseRatingValue(ratingText),
122
+ review_count_text: reviewCountText,
123
+ review_count: parseReviewCount(reviewCountText),
124
+ list_title: context.listTitle,
125
+ category_title: context.categoryTitle,
126
+ category_url: categoryUrl,
127
+ category_node_id: extractCategoryNodeId(categoryUrl),
128
+ category_path: context.categoryPath,
129
+ visible_category_links: context.visibleCategoryLinks,
130
+ ...provenance,
131
+ };
132
+ }
133
+
134
+ async function readRankingPage(
135
+ page: IPage,
136
+ listType: AmazonRankingListType,
137
+ url: string,
138
+ ): Promise<RankingPagePayload> {
139
+ const state = await gotoAndReadState(page, url, 2500, listType);
140
+ assertUsableState(state, listType);
141
+
142
+ return await page.evaluate(`
143
+ (() => ({
144
+ href: window.location.href,
145
+ title: document.title || '',
146
+ list_title:
147
+ document.querySelector('#zg_banner_text')?.textContent
148
+ || document.querySelector('h1')?.textContent
149
+ || '',
150
+ category_title:
151
+ document.querySelector('#zg_browseRoot .zg_selected')?.textContent
152
+ || document.querySelector('#wayfinding-breadcrumbs_feature_div ul li:last-child')?.textContent
153
+ || document.querySelector('#wayfinding-breadcrumbs_container ul li:last-child')?.textContent
154
+ || '',
155
+ category_path: Array.from(document.querySelectorAll(
156
+ '#zg_browseRoot ul li a, #zg_browseRoot ul li span, ' +
157
+ '#wayfinding-breadcrumbs_feature_div ul li a, #wayfinding-breadcrumbs_feature_div ul li span.a-list-item, ' +
158
+ '#wayfinding-breadcrumbs_container ul li a, #wayfinding-breadcrumbs_container ul li span.a-list-item'
159
+ ))
160
+ .map((entry) => (entry.textContent || '').trim())
161
+ .filter(Boolean),
162
+ cards: Array.from(document.querySelectorAll(
163
+ '.p13n-sc-uncoverable-faceout, .zg-grid-general-faceout, [data-asin][class*="p13n"]'
164
+ )).map((card) => ({
165
+ rank_text:
166
+ card.querySelector('.zg-bdg-text')?.textContent
167
+ || card.querySelector('[class*="rank"]')?.textContent
168
+ || '',
169
+ asin:
170
+ card.getAttribute('data-asin')
171
+ || card.getAttribute('id')
172
+ || '',
173
+ title:
174
+ card.querySelector('[class*="line-clamp"]')?.textContent
175
+ || card.querySelector('img')?.getAttribute('alt')
176
+ || card.querySelector('a[href*="/dp/"]')?.textContent
177
+ || '',
178
+ href:
179
+ card.querySelector('a[href*="/dp/"], a[href*="/gp/product/"]')?.href
180
+ || '',
181
+ price_text:
182
+ card.querySelector('.a-price .a-offscreen')?.textContent
183
+ || card.querySelector('.a-color-price')?.textContent
184
+ || '',
185
+ rating_text:
186
+ card.querySelector('[aria-label*="out of 5 stars"]')?.getAttribute('aria-label')
187
+ || '',
188
+ review_count_text:
189
+ card.querySelector('a[href*="#customerReviews"]')?.textContent
190
+ || card.querySelector('.a-size-small')?.textContent
191
+ || '',
192
+ card_text: card.innerText || '',
193
+ })),
194
+ page_links: Array.from(document.querySelectorAll('.a-pagination a[href], li.a-normal a[href], li.a-selected a[href]'))
195
+ .map((anchor) => anchor.href || '')
196
+ .filter(Boolean),
197
+ visible_category_links: Array.from(document.querySelectorAll(
198
+ '#zg_browseRoot a[href], #zg-left-col a[href], [class*="zg-browse"] a[href]'
199
+ )).map((anchor) => ({
200
+ title: (anchor.textContent || '').trim(),
201
+ url: anchor.href || '',
202
+ node_id:
203
+ anchor.getAttribute('data-node-id')
204
+ || anchor.dataset?.nodeid
205
+ || '',
206
+ }))
207
+ .filter((entry) => entry.title && entry.url),
208
+ }))()
209
+ `) as RankingPagePayload;
210
+ }
211
+
212
+ function createEmptyResultHint(commandName: string): string {
213
+ return [
214
+ `Open the same Amazon ${commandName} page in shared Chrome and verify ranked items are visible.`,
215
+ 'If the page shows a robot check, clear it manually and retry.',
216
+ ].join(' ');
217
+ }
218
+
219
+ export function createRankingCliOptions(definition: RankingCommandDefinition): CliOptions {
220
+ return {
221
+ site: 'amazon',
222
+ name: definition.commandName,
223
+ description: definition.description,
224
+ domain: 'amazon.com',
225
+ strategy: Strategy.COOKIE,
226
+ navigateBefore: false,
227
+ args: [
228
+ {
229
+ name: 'input',
230
+ positional: true,
231
+ help: 'Ranking URL or supported Amazon path. Omit to use the list root.',
232
+ },
233
+ {
234
+ name: 'limit',
235
+ type: 'int',
236
+ default: 100,
237
+ help: 'Maximum number of ranked items to return (default 100)',
238
+ },
239
+ ],
240
+ columns: ['list_type', 'rank', 'asin', 'title', 'price_text', 'rating_value', 'review_count'],
241
+ func: async (page, kwargs) => {
242
+ const limit = Math.max(1, Number(kwargs.limit) || 100);
243
+ const initialUrl = resolveRankingUrl(definition.listType, typeof kwargs.input === 'string' ? kwargs.input : undefined);
244
+
245
+ const queue = [initialUrl];
246
+ const visited = new Set<string>();
247
+ const seenEntityKeys = new Set<string>();
248
+ const results: Record<string, unknown>[] = [];
249
+ let listTitle: string | null = null;
250
+
251
+ while (queue.length > 0 && results.length < limit) {
252
+ const nextUrl = queue.shift()!;
253
+ if (visited.has(nextUrl)) continue;
254
+ visited.add(nextUrl);
255
+
256
+ const payload = await readRankingPage(page, definition.listType, nextUrl);
257
+ const sourceUrl = cleanText(payload.href) || nextUrl;
258
+ listTitle = cleanText(payload.list_title) || cleanText(payload.title) || listTitle;
259
+ const categoryPath = uniqueNonEmpty(payload.category_path ?? []);
260
+ const categoryTitle = cleanText(payload.category_title)
261
+ || (categoryPath.length > 0 ? categoryPath[categoryPath.length - 1] : '');
262
+ const visibleCategoryLinks = normalizeVisibleCategoryLinks(payload.visible_category_links);
263
+ const cards = payload.cards ?? [];
264
+
265
+ for (const card of cards) {
266
+ const normalized = normalizeRankingCandidate(card, {
267
+ listType: definition.listType,
268
+ rankFallback: results.length + 1,
269
+ listTitle,
270
+ sourceUrl,
271
+ categoryTitle: categoryTitle || null,
272
+ categoryUrl: sourceUrl,
273
+ categoryPath,
274
+ visibleCategoryLinks,
275
+ });
276
+
277
+ const dedupeKey = cleanText(String(normalized.asin ?? ''))
278
+ || cleanText(String(normalized.product_url ?? ''));
279
+ if (dedupeKey && seenEntityKeys.has(dedupeKey)) continue;
280
+ if (dedupeKey) seenEntityKeys.add(dedupeKey);
281
+
282
+ results.push(normalized);
283
+ if (results.length >= limit) break;
284
+ }
285
+
286
+ const pageLinks = uniqueNonEmpty(payload.page_links ?? []);
287
+ for (const href of pageLinks) {
288
+ const absolute = toAbsoluteAmazonUrl(href);
289
+ if (!absolute || !isRankingPaginationUrl(definition.listType, absolute)) continue;
290
+ if (!visited.has(absolute) && !queue.includes(absolute)) {
291
+ queue.push(absolute);
292
+ }
293
+ }
294
+ }
295
+
296
+ if (results.length === 0) {
297
+ throw new CommandExecutionError(
298
+ `amazon ${definition.commandName} did not expose any ranked items`,
299
+ createEmptyResultHint(definition.commandName),
300
+ );
301
+ }
302
+
303
+ return results.slice(0, limit);
304
+ },
305
+ };
306
+ }
307
+
308
+ export const __test__ = {
309
+ parseRank,
310
+ normalizeVisibleCategoryLinks,
311
+ normalizeRankingCandidate,
312
+ };
@@ -34,4 +34,20 @@ describe('amazon shared helpers', () => {
34
34
  expect(__test__.resolveBestsellersUrl('/Best-Sellers/zgbs')).toBe('https://www.amazon.com/Best-Sellers/zgbs');
35
35
  expect(() => __test__.resolveBestsellersUrl('desk shelf organizer')).toThrow('amazon bestsellers expects a best sellers URL or /zgbs path');
36
36
  });
37
+
38
+ it('resolves and validates all ranking list URLs', () => {
39
+ expect(__test__.resolveRankingUrl('new_releases')).toBe('https://www.amazon.com/gp/new-releases');
40
+ expect(__test__.resolveRankingUrl('movers_shakers')).toBe('https://www.amazon.com/gp/movers-and-shakers');
41
+ expect(__test__.resolveRankingUrl('new_releases', '/gp/new-releases/kitchen')).toBe('https://www.amazon.com/gp/new-releases/kitchen');
42
+ expect(__test__.resolveRankingUrl(
43
+ 'bestsellers',
44
+ 'https://www.amazon.com/Best-Sellers/zgbs/ref=zg_bsnr_tab_bs',
45
+ )).toBe('https://www.amazon.com/Best-Sellers/zgbs');
46
+ expect(() => __test__.resolveRankingUrl('movers_shakers', 'https://example.com/gp/movers-and-shakers')).toThrow('Invalid Amazon URL');
47
+ });
48
+
49
+ it('extracts category node id from URL best effort', () => {
50
+ expect(__test__.extractCategoryNodeId('https://www.amazon.com/Best-Sellers-Home-Kitchen/zgbs/home-garden/3744371')).toBe('3744371');
51
+ expect(__test__.extractCategoryNodeId('https://www.amazon.com/s?k=desk+organizer&rh=n%3A1064954')).toBe('1064954');
52
+ });
37
53
  });
@@ -5,6 +5,8 @@ export const SITE = 'amazon';
5
5
  export const DOMAIN = 'amazon.com';
6
6
  export const HOME_URL = 'https://www.amazon.com/';
7
7
  export const BESTSELLERS_URL = 'https://www.amazon.com/Best-Sellers/zgbs';
8
+ export const NEW_RELEASES_URL = 'https://www.amazon.com/gp/new-releases';
9
+ export const MOVERS_SHAKERS_URL = 'https://www.amazon.com/gp/movers-and-shakers';
8
10
  export const SEARCH_URL_PREFIX = 'https://www.amazon.com/s?k=';
9
11
  export const PRODUCT_URL_PREFIX = 'https://www.amazon.com/dp/';
10
12
  export const DISCUSSION_URL_PREFIX = 'https://www.amazon.com/product-reviews/';
@@ -28,6 +30,40 @@ const ROBOT_TEXT_PATTERNS = [
28
30
  'To discuss automated access to Amazon data please contact',
29
31
  ];
30
32
 
33
+ export type AmazonRankingListType = 'bestsellers' | 'new_releases' | 'movers_shakers';
34
+
35
+ interface AmazonRankingSpec {
36
+ commandName: string;
37
+ rootUrl: string;
38
+ pathPattern: RegExp;
39
+ invalidInputMessage: string;
40
+ invalidInputHint: string;
41
+ }
42
+
43
+ const AMAZON_RANKING_SPECS: Record<AmazonRankingListType, AmazonRankingSpec> = {
44
+ bestsellers: {
45
+ commandName: 'bestsellers',
46
+ rootUrl: BESTSELLERS_URL,
47
+ pathPattern: /(?:^|\/)zgbs(?:\/|$)/i,
48
+ invalidInputMessage: 'amazon bestsellers expects a best sellers URL or /zgbs path',
49
+ invalidInputHint: 'Example: opencli amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs',
50
+ },
51
+ new_releases: {
52
+ commandName: 'new-releases',
53
+ rootUrl: NEW_RELEASES_URL,
54
+ pathPattern: /\/gp\/new-releases(?:\/|$)/i,
55
+ invalidInputMessage: 'amazon new-releases expects a new releases URL or /gp/new-releases path',
56
+ invalidInputHint: 'Example: opencli amazon new-releases https://www.amazon.com/gp/new-releases',
57
+ },
58
+ movers_shakers: {
59
+ commandName: 'movers-shakers',
60
+ rootUrl: MOVERS_SHAKERS_URL,
61
+ pathPattern: /\/gp\/movers-and-shakers(?:\/|$)/i,
62
+ invalidInputMessage: 'amazon movers-shakers expects a movers-and-shakers URL or /gp/movers-and-shakers path',
63
+ invalidInputHint: 'Example: opencli amazon movers-shakers https://www.amazon.com/gp/movers-and-shakers',
64
+ },
65
+ };
66
+
31
67
  export interface ProvenanceFields {
32
68
  source_url: string;
33
69
  fetched_at: string;
@@ -115,23 +151,105 @@ export function buildDiscussionUrl(input: string): string {
115
151
  return `${DISCUSSION_URL_PREFIX}${asin}`;
116
152
  }
117
153
 
118
- export function resolveBestsellersUrl(input?: string): string {
154
+ function getRankingSpec(listType: AmazonRankingListType): AmazonRankingSpec {
155
+ return AMAZON_RANKING_SPECS[listType];
156
+ }
157
+
158
+ export function isSupportedRankingPath(listType: AmazonRankingListType, inputUrl: string): boolean {
159
+ try {
160
+ const url = new URL(inputUrl);
161
+ return getRankingSpec(listType).pathPattern.test(url.pathname);
162
+ } catch {
163
+ return false;
164
+ }
165
+ }
166
+
167
+ export function resolveRankingUrl(listType: AmazonRankingListType, input?: string): string {
168
+ const spec = getRankingSpec(listType);
119
169
  const normalized = cleanText(input);
120
- if (!normalized) return BESTSELLERS_URL;
121
- if (normalized === 'root') return BESTSELLERS_URL;
170
+ if (!normalized || normalized === 'root') return spec.rootUrl;
171
+
172
+ let candidateUrl: string;
122
173
  if (normalized.startsWith('/')) {
123
- return new URL(normalized, HOME_URL).toString();
174
+ candidateUrl = new URL(normalized, HOME_URL).toString();
175
+ } else if (/^https?:\/\//i.test(normalized)) {
176
+ candidateUrl = canonicalizeAmazonUrl(normalized);
177
+ } else if (normalized.includes('amazon.') && normalized.includes('/')) {
178
+ candidateUrl = canonicalizeAmazonUrl(`https://${normalized.replace(/^\/+/, '')}`);
179
+ } else {
180
+ throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint);
124
181
  }
125
- if (/^https?:\/\//i.test(normalized)) {
126
- return canonicalizeAmazonUrl(normalized);
182
+
183
+ if (!isSupportedRankingPath(listType, candidateUrl)) {
184
+ throw new ArgumentError(spec.invalidInputMessage, spec.invalidInputHint);
127
185
  }
128
- if (normalized.includes('/zgbs/')) {
129
- return canonicalizeAmazonUrl(`https://${normalized.replace(/^\/+/, '')}`);
186
+ return normalizeRankingInputUrl(candidateUrl);
187
+ }
188
+
189
+ function normalizeRankingInputUrl(inputUrl: string): string {
190
+ try {
191
+ const url = new URL(inputUrl);
192
+ const normalizedPathSegments = url.pathname
193
+ .split('/')
194
+ .filter(Boolean)
195
+ .filter((segment) => !/^ref=/i.test(segment));
196
+ url.pathname = `/${normalizedPathSegments.join('/')}`;
197
+ url.hash = '';
198
+ // Ranking pages are frequently shared with tracking refs that can land on unstable variants.
199
+ // Dropping ref keeps the canonical ranking path while preserving useful params (for example pg=2).
200
+ url.searchParams.delete('ref');
201
+ return url.toString();
202
+ } catch {
203
+ return inputUrl;
130
204
  }
131
- throw new ArgumentError(
132
- 'amazon bestsellers expects a best sellers URL or /zgbs path',
133
- 'Example: opencli amazon bestsellers https://www.amazon.com/Best-Sellers/zgbs',
134
- );
205
+ }
206
+
207
+ export function isRankingPaginationUrl(listType: AmazonRankingListType, inputUrl: string): boolean {
208
+ const absolute = toAbsoluteAmazonUrl(inputUrl);
209
+ if (!absolute || !isSupportedRankingPath(listType, absolute)) return false;
210
+
211
+ try {
212
+ const url = new URL(absolute);
213
+ const ref = cleanText(url.searchParams.get('ref')).toLowerCase();
214
+ // pg= query param is the most reliable pagination indicator across all ranking lists
215
+ return url.searchParams.has('pg')
216
+ || /(?:^|_)pg(?:_|$)/.test(ref)
217
+ // Amazon ranking pagination refs: zg_bs_pg_ (bestsellers), zg_bsnr_pg_ (new releases), zg_bsms_pg_ (movers & shakers)
218
+ || /zg_bs(?:nr|ms)?_pg_/.test(ref);
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ export function extractCategoryNodeId(inputUrl: string | null | undefined): string | null {
225
+ const absolute = toAbsoluteAmazonUrl(inputUrl);
226
+ if (!absolute) return null;
227
+
228
+ try {
229
+ const url = new URL(absolute);
230
+
231
+ for (const key of ['node', 'nodeid', 'nodeId', 'browseNode']) {
232
+ const value = cleanText(url.searchParams.get(key));
233
+ if (/^\d{4,}$/.test(value)) return value;
234
+ }
235
+
236
+ const rhValue = cleanText(url.searchParams.get('rh'));
237
+ const rhMatch = decodeURIComponent(rhValue).match(/(?:^|,)\s*n:(\d{4,})(?:,|$)/i);
238
+ if (rhMatch) return rhMatch[1];
239
+
240
+ const pathMatches = [...url.pathname.matchAll(/\/(\d{4,})(?=\/|$)/g)];
241
+ if (pathMatches.length > 0) {
242
+ return pathMatches[pathMatches.length - 1][1];
243
+ }
244
+ } catch {
245
+ return null;
246
+ }
247
+
248
+ return null;
249
+ }
250
+
251
+ export function resolveBestsellersUrl(input?: string): string {
252
+ return resolveRankingUrl('bestsellers', input);
135
253
  }
136
254
 
137
255
  export function canonicalizeAmazonUrl(input: string): string {
@@ -305,6 +423,10 @@ export const __test__ = {
305
423
  buildProductUrl,
306
424
  buildDiscussionUrl,
307
425
  resolveBestsellersUrl,
426
+ resolveRankingUrl,
427
+ isSupportedRankingPath,
428
+ isRankingPaginationUrl,
429
+ extractCategoryNodeId,
308
430
  parsePriceText,
309
431
  parseRatingValue,
310
432
  parseReviewCount,
@@ -4,7 +4,8 @@ const { mockApiGet } = vi.hoisted(() => ({
4
4
  mockApiGet: vi.fn(),
5
5
  }));
6
6
 
7
- vi.mock('./utils.js', () => ({
7
+ vi.mock('./utils.js', async (importOriginal) => ({
8
+ ...(await importOriginal<typeof import('./utils.js')>()),
8
9
  apiGet: mockApiGet,
9
10
  }));
10
11
 
@@ -58,8 +59,8 @@ describe('bilibili comments', () => {
58
59
  it('throws when aid cannot be resolved', async () => {
59
60
  mockApiGet.mockResolvedValueOnce({ data: {} }); // no aid
60
61
 
61
- await expect(command!.func!({} as any, { bvid: 'BV_invalid', limit: 5 })).rejects.toThrow(
62
- 'Cannot resolve aid for bvid: BV_invalid',
62
+ await expect(command!.func!({} as any, { bvid: 'BVinvalid123', limit: 5 })).rejects.toThrow(
63
+ 'Cannot resolve aid for bvid: BVinvalid123',
63
64
  );
64
65
  });
65
66
 
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { cli, Strategy } from '../../registry.js';
7
- import { apiGet } from './utils.js';
7
+ import { apiGet, resolveBvid } from './utils.js';
8
8
 
9
9
  cli({
10
10
  site: 'bilibili',
@@ -18,7 +18,7 @@ cli({
18
18
  ],
19
19
  columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
20
20
  func: async (page, kwargs) => {
21
- const bvid = String(kwargs.bvid).trim();
21
+ const bvid = await resolveBvid(kwargs.bvid);
22
22
  const limit = Math.min(Number(kwargs.limit) || 20, 50);
23
23
 
24
24
  // Resolve bvid → aid (required by reply API)
@@ -11,6 +11,7 @@
11
11
  import { cli, Strategy } from '../../registry.js';
12
12
  import { checkYtdlp, sanitizeFilename } from '../../download/index.js';
13
13
  import { downloadMedia } from '../../download/media-download.js';
14
+ import { resolveBvid } from './utils.js';
14
15
 
15
16
  cli({
16
17
  site: 'bilibili',
@@ -25,7 +26,7 @@ cli({
25
26
  ],
26
27
  columns: ['bvid', 'title', 'status', 'size'],
27
28
  func: async (page, kwargs) => {
28
- const bvid = kwargs.bvid;
29
+ const bvid = await resolveBvid(kwargs.bvid);
29
30
  const output = kwargs.output;
30
31
  const quality = kwargs.quality;
31
32
 
@@ -6,7 +6,8 @@ const { mockApiGet } = vi.hoisted(() => ({
6
6
  mockApiGet: vi.fn(),
7
7
  }));
8
8
 
9
- vi.mock('./utils.js', () => ({
9
+ vi.mock('./utils.js', async (importOriginal) => ({
10
+ ...(await importOriginal<typeof import('./utils.js')>()),
10
11
  apiGet: mockApiGet,
11
12
  }));
12
13
 
@@ -1,7 +1,7 @@
1
1
  import { cli, Strategy } from '../../registry.js';
2
2
  import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '../../errors.js';
3
3
  import type { IPage } from '../../types.js';
4
- import { apiGet } from './utils.js';
4
+ import { apiGet, resolveBvid } from './utils.js';
5
5
 
6
6
  cli({
7
7
  site: 'bilibili',
@@ -15,8 +15,9 @@ cli({
15
15
  columns: ['index', 'from', 'to', 'content'],
16
16
  func: async (page: IPage | null, kwargs: any) => {
17
17
  if (!page) throw new CommandExecutionError('Browser session required for bilibili subtitle');
18
+ const bvid = await resolveBvid(kwargs.bvid);
18
19
  // 1. 先前往视频详情页 (建立有鉴权的 Session,且这里不需要加载完整个视频)
19
- await page.goto(`https://www.bilibili.com/video/${kwargs.bvid}/`);
20
+ await page.goto(`https://www.bilibili.com/video/${bvid}/`);
20
21
 
21
22
  // 2. 利用 __INITIAL_STATE__ 获取基础信息,拿 CID
22
23
  const cid = await page.evaluate(`(async () => {
@@ -31,7 +32,7 @@ cli({
31
32
  // 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
32
33
  // 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
33
34
  const payload = await apiGet(page, '/x/player/wbi/v2', {
34
- params: { bvid: kwargs.bvid, cid },
35
+ params: { bvid, cid },
35
36
  signed: true, // 开启 wbi_sign 自动签名
36
37
  });
37
38
 
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { resolveBvid } from './utils.js';
3
+
4
+ describe('resolveBvid', () => {
5
+ it('passes through a valid BV ID', async () => {
6
+ expect(await resolveBvid('BV1MV9NBtENN')).toBe('BV1MV9NBtENN');
7
+ });
8
+
9
+ it('passes through BV ID with surrounding whitespace', async () => {
10
+ expect(await resolveBvid(' BV1MV9NBtENN ')).toBe('BV1MV9NBtENN');
11
+ });
12
+
13
+ it('handles non-string input via String() coercion', async () => {
14
+ expect(await resolveBvid('BV123abc' as any)).toBe('BV123abc');
15
+ });
16
+
17
+ it('rejects invalid input that cannot be resolved', async () => {
18
+ // A random string that b23.tv won't resolve — should timeout or fail
19
+ await expect(resolveBvid('not-a-valid-code-99999')).rejects.toThrow();
20
+ });
21
+ });
@@ -2,9 +2,36 @@
2
2
  * Bilibili shared helpers: WBI signing, authenticated fetch, nav data, UID resolution.
3
3
  */
4
4
 
5
+ import https from 'node:https';
5
6
  import type { IPage } from '../../types.js';
6
7
  import { AuthRequiredError, EmptyResultError } from '../../errors.js';
7
8
 
9
+ /**
10
+ * Resolve Bilibili short URL / short code to BV ID.
11
+ * Supports: BV1MV9NBtENN, XYzsqGa, b23.tv/XYzsqGa, https://b23.tv/XYzsqGa
12
+ */
13
+ export function resolveBvid(input: unknown): Promise<string> {
14
+ const trimmed = String(input).trim();
15
+ if (/^BV[A-Za-z0-9]+$/i.test(trimmed)) {
16
+ return Promise.resolve(trimmed);
17
+ }
18
+ const shortCode = trimmed.replace(/^https?:\/\//, '').replace(/^(www\.)?b23\.tv\//, '');
19
+ const url = 'https://b23.tv/' + shortCode;
20
+ return new Promise((resolve, reject) => {
21
+ const req = https.get(url, (res) => {
22
+ const location = res.headers.location;
23
+ if (location) {
24
+ const match = location.match(/\/video\/(BV[A-Za-z0-9]+)/);
25
+ if (match) { res.resume(); resolve(match[1]); return; }
26
+ }
27
+ res.resume();
28
+ reject(new Error(`Cannot resolve BV ID from short URL: ${trimmed}`));
29
+ });
30
+ req.on('error', reject);
31
+ req.setTimeout(5000, () => { req.destroy(); reject(new Error(`Timeout resolving short URL: ${trimmed}`)); });
32
+ });
33
+ }
34
+
8
35
  const MIXIN_KEY_ENC_TAB = [
9
36
  46,47,18,2,53,8,23,32,15,50,10,31,58,3,45,35,27,43,5,49,
10
37
  33,9,42,19,29,28,14,39,12,38,41,13,37,48,7,16,24,55,40,
@@ -50,7 +50,7 @@ async function fetchMarks(
50
50
  ): Promise<DoubanMark[]> {
51
51
  const marks: DoubanMark[] = [];
52
52
  let offset = 0;
53
- const pageSize = 30;
53
+ const pageSize = 15;
54
54
 
55
55
  while (true) {
56
56
  const url = `https://movie.douban.com/people/${uid}/${status}?start=${offset}&sort=time&rating=all&filter=all&mode=grid`;