@jackwener/opencli 1.7.12 → 1.7.13

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 (407) hide show
  1. package/README.md +8 -7
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +12194 -6843
  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/reply-dm.js +1 -1
  298. package/clis/twitter/reply.test.js +1 -29
  299. package/clis/uisdc/news.js +105 -0
  300. package/clis/uisdc/news.test.js +66 -0
  301. package/clis/wanfang/search.js +0 -1
  302. package/clis/web/read.js +47 -17
  303. package/clis/web/read.test.js +101 -1
  304. package/clis/weixin/create-draft.js +1 -1
  305. package/clis/weixin/drafts.js +1 -1
  306. package/clis/weixin/drafts.test.js +5 -1
  307. package/clis/weixin/search.js +157 -0
  308. package/clis/weixin/search.test.js +227 -0
  309. package/clis/wikidata/entity.js +60 -0
  310. package/clis/wikidata/search.js +50 -0
  311. package/clis/wikidata/utils.js +117 -0
  312. package/clis/wikidata/wikidata.test.js +83 -0
  313. package/clis/wikipedia/page.js +95 -0
  314. package/clis/wttr/current.js +63 -0
  315. package/clis/wttr/forecast.js +71 -0
  316. package/clis/wttr/utils.js +50 -0
  317. package/clis/wttr/wttr.test.js +84 -0
  318. package/clis/xianyu/chat.js +16 -4
  319. package/clis/xianyu/chat.test.js +64 -0
  320. package/clis/xianyu/publish.js +485 -0
  321. package/clis/xianyu/publish.test.js +220 -0
  322. package/clis/xiaoe/catalog.js +105 -40
  323. package/clis/xiaoe/content.js +164 -29
  324. package/clis/xiaoe/courses.js +86 -29
  325. package/clis/xiaoe/xiaoe.test.js +486 -0
  326. package/clis/xiaohongshu/creator-notes-summary.js +1 -1
  327. package/clis/xiaohongshu/publish.js +16 -3
  328. package/clis/xiaohongshu/publish.test.js +46 -1
  329. package/clis/youtube/transcript.js +13 -19
  330. package/clis/youtube/transcript.test.js +17 -0
  331. package/clis/yuanbao/ask.js +17 -66
  332. package/clis/yuanbao/ask.test.js +5 -5
  333. package/clis/yuanbao/detail.js +65 -0
  334. package/clis/yuanbao/history.js +51 -0
  335. package/clis/yuanbao/new.js +1 -0
  336. package/clis/yuanbao/read.js +38 -0
  337. package/clis/yuanbao/send.js +57 -0
  338. package/clis/yuanbao/shared.js +297 -5
  339. package/clis/yuanbao/shared.test.js +80 -0
  340. package/clis/yuanbao/status.js +44 -0
  341. package/clis/zlibrary/commands.test.js +1 -11
  342. package/dist/src/browser/base-page.d.ts +9 -0
  343. package/dist/src/browser/base-page.js +44 -1
  344. package/dist/src/browser/base-page.test.js +66 -0
  345. package/dist/src/browser/cdp.d.ts +1 -0
  346. package/dist/src/browser/cdp.js +51 -9
  347. package/dist/src/browser/daemon-client.d.ts +4 -0
  348. package/dist/src/browser/errors.js +1 -1
  349. package/dist/src/browser/page.d.ts +1 -1
  350. package/dist/src/browser/page.js +3 -1
  351. package/dist/src/browser/page.test.js +29 -0
  352. package/dist/src/browser/target-errors.d.ts +2 -1
  353. package/dist/src/browser/target-errors.js +1 -0
  354. package/dist/src/browser/target-resolver.d.ts +25 -0
  355. package/dist/src/browser/target-resolver.js +43 -0
  356. package/dist/src/build-manifest.js +9 -4
  357. package/dist/src/build-manifest.test.js +2 -8
  358. package/dist/src/capabilityRouting.d.ts +16 -1
  359. package/dist/src/capabilityRouting.js +24 -1
  360. package/dist/src/capabilityRouting.test.js +19 -1
  361. package/dist/src/cli.js +76 -11
  362. package/dist/src/cli.test.js +150 -0
  363. package/dist/src/commanderAdapter.js +0 -5
  364. package/dist/src/commanderAdapter.test.js +0 -1
  365. package/dist/src/discovery.js +2 -5
  366. package/dist/src/errors.js +1 -1
  367. package/dist/src/execution.d.ts +1 -1
  368. package/dist/src/execution.js +111 -27
  369. package/dist/src/execution.test.js +326 -17
  370. package/dist/src/help.d.ts +23 -2
  371. package/dist/src/help.js +41 -19
  372. package/dist/src/help.test.d.ts +1 -0
  373. package/dist/src/help.test.js +54 -0
  374. package/dist/src/main.js +14 -1
  375. package/dist/src/manifest-types.d.ts +5 -3
  376. package/dist/src/pipeline/executor.js +1 -1
  377. package/dist/src/pipeline/executor.test.js +8 -0
  378. package/dist/src/pipeline/registry.d.ts +9 -0
  379. package/dist/src/pipeline/registry.js +13 -1
  380. package/dist/src/pipeline/steps/browser.d.ts +1 -0
  381. package/dist/src/pipeline/steps/browser.js +10 -0
  382. package/dist/src/pipeline/steps/download.test.js +1 -0
  383. package/dist/src/registry-api.d.ts +1 -1
  384. package/dist/src/registry.d.ts +12 -11
  385. package/dist/src/registry.js +16 -6
  386. package/dist/src/registry.test.js +2 -2
  387. package/dist/src/runtime.d.ts +2 -1
  388. package/dist/src/runtime.js +1 -1
  389. package/dist/src/serialization.d.ts +2 -2
  390. package/dist/src/serialization.js +4 -6
  391. package/dist/src/serialization.test.js +17 -0
  392. package/dist/src/types.d.ts +17 -0
  393. package/dist/src/validate.js +15 -11
  394. package/dist/src/validate.test.d.ts +9 -0
  395. package/dist/src/validate.test.js +90 -0
  396. package/package.json +1 -1
  397. package/scripts/fetch-adapters.js +1 -1
  398. package/scripts/typed-error-lint-baseline.json +5 -77
  399. package/clis/ctrip/search.test.js +0 -64
  400. package/clis/gov-policy/commands.test.js +0 -27
  401. package/clis/linux-do/category.js +0 -37
  402. package/clis/linux-do/hot.js +0 -26
  403. package/clis/linux-do/latest.js +0 -19
  404. package/clis/pixiv/test-utils.js +0 -23
  405. package/clis/toutiao/articles.test.js +0 -30
  406. package/dist/src/analysis.d.ts +0 -40
  407. package/dist/src/analysis.js +0 -172
@@ -1,5 +1,6 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
1
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { canonicalizeProductUrl, normalizeProductId } from './utils.js';
3
+ import { canonicalizeProductUrl, normalizeProductId, requireProductIdArg } from './utils.js';
3
4
  function escapeJsString(value) {
4
5
  return JSON.stringify(value);
5
6
  }
@@ -105,31 +106,38 @@ cli({
105
106
  ],
106
107
  columns: ['ok', 'product_id', 'url', 'message'],
107
108
  func: async (page, kwargs) => {
108
- const rawProductId = kwargs['product-id'] ?? kwargs['product-id'];
109
- const productId = normalizeProductId(rawProductId);
110
- const targetUrl = canonicalizeProductUrl(kwargs.url, productId);
111
- if (!productId && !targetUrl) {
112
- throw new Error('Either --product-id or --url is required');
109
+ const rawProductId = kwargs['product-id'];
110
+ if (!rawProductId && !kwargs.url) {
111
+ throw new ArgumentError('Either --product-id or --url is required');
113
112
  }
113
+ const productId = rawProductId
114
+ ? requireProductIdArg(rawProductId, 'product-id')
115
+ : requireProductIdArg(kwargs.url, '--url');
116
+ const targetUrl = canonicalizeProductUrl(kwargs.url, productId);
114
117
  const finalUrl = targetUrl || canonicalizeProductUrl('', productId);
115
- await page.goto(finalUrl);
116
- const result = await page.evaluate(buildAddToCartEvaluate(productId));
118
+ await page.goto(finalUrl).catch((error) => {
119
+ throw new CommandExecutionError(`coupang add-to-cart navigation failed: ${error?.message || error}`);
120
+ });
121
+ const result = await page.evaluate(buildAddToCartEvaluate(productId)).catch((error) => {
122
+ throw new CommandExecutionError(`coupang add-to-cart evaluation failed: ${error?.message || error}`);
123
+ });
117
124
  const loginHints = result?.loginHints ?? {};
118
125
  if (loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
119
- throw new Error('Coupang login required. Please log into Coupang in Chrome and retry.');
126
+ throw new AuthRequiredError('coupang.com', 'Please log into Coupang in Chrome and retry.');
120
127
  }
121
128
  const actualProductId = normalizeProductId(result?.currentProductId || productId);
122
129
  if (result?.reason === 'PRODUCT_MISMATCH') {
123
- throw new Error(`Product mismatch: expected ${productId}, got ${actualProductId || 'unknown'}`);
130
+ const observed = actualProductId ? `got ${actualProductId}` : 'no product id observed';
131
+ throw new CommandExecutionError(`Product mismatch: expected ${productId}, ${observed}`);
124
132
  }
125
133
  if (result?.reason === 'OPTION_REQUIRED') {
126
- throw new Error('This product requires option selection and is not supported in v1.');
134
+ throw new CommandExecutionError('This product requires option selection and is not supported in v1.');
127
135
  }
128
136
  if (result?.reason === 'ADD_TO_CART_BUTTON_NOT_FOUND') {
129
- throw new Error('Could not find an add-to-cart button on the product page.');
137
+ throw new CommandExecutionError('Could not find an add-to-cart button on the product page.');
130
138
  }
131
139
  if (!result?.ok) {
132
- throw new Error('Failed to confirm add-to-cart success.');
140
+ throw new CommandExecutionError('Failed to confirm add-to-cart success.');
133
141
  }
134
142
  return [{
135
143
  ok: true,
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './search.js';
5
+ import './product.js';
6
+ import './add-to-cart.js';
7
+ import { parseLimitArg, parsePageArg, requireProductIdArg } from './utils.js';
8
+
9
+ describe('coupang utils — parseLimitArg / parsePageArg (no silent clamp)', () => {
10
+ it('parseLimitArg returns fallback for empty / undefined', () => {
11
+ expect(parseLimitArg(undefined, 20, 50)).toBe(20);
12
+ expect(parseLimitArg(null, 20, 50)).toBe(20);
13
+ expect(parseLimitArg('', 20, 50)).toBe(20);
14
+ });
15
+
16
+ it('parseLimitArg accepts integers in range', () => {
17
+ expect(parseLimitArg(1, 20, 50)).toBe(1);
18
+ expect(parseLimitArg(50, 20, 50)).toBe(50);
19
+ expect(parseLimitArg('25', 20, 50)).toBe(25);
20
+ });
21
+
22
+ it('parseLimitArg throws ArgumentError on out-of-range / non-integer (no silent clamp)', () => {
23
+ expect(() => parseLimitArg(0, 20, 50)).toThrow(ArgumentError);
24
+ expect(() => parseLimitArg(-1, 20, 50)).toThrow(ArgumentError);
25
+ expect(() => parseLimitArg(51, 20, 50)).toThrow(ArgumentError);
26
+ expect(() => parseLimitArg(999, 20, 50)).toThrow(ArgumentError);
27
+ expect(() => parseLimitArg('abc', 20, 50)).toThrow(ArgumentError);
28
+ expect(() => parseLimitArg(1.5, 20, 50)).toThrow(ArgumentError);
29
+ });
30
+
31
+ it('parsePageArg returns fallback for empty', () => {
32
+ expect(parsePageArg(undefined, 1)).toBe(1);
33
+ expect(parsePageArg('', 1)).toBe(1);
34
+ });
35
+
36
+ it('parsePageArg accepts positive integers', () => {
37
+ expect(parsePageArg(1, 1)).toBe(1);
38
+ expect(parsePageArg('5', 1)).toBe(5);
39
+ });
40
+
41
+ it('parsePageArg throws ArgumentError on non-positive (no silent lift to 1)', () => {
42
+ expect(() => parsePageArg(0, 1)).toThrow(ArgumentError);
43
+ expect(() => parsePageArg(-1, 1)).toThrow(ArgumentError);
44
+ expect(() => parsePageArg('abc', 1)).toThrow(ArgumentError);
45
+ });
46
+ });
47
+
48
+ describe('coupang utils — product id validation', () => {
49
+ it('extracts numeric ids from ids and URLs', () => {
50
+ expect(requireProductIdArg('123456789')).toBe('123456789');
51
+ expect(requireProductIdArg('https://www.coupang.com/vp/products/123456789?itemId=1', '--url')).toBe('123456789');
52
+ });
53
+
54
+ it('rejects malformed product ids instead of building fake URLs', () => {
55
+ expect(() => requireProductIdArg('abc')).toThrow(ArgumentError);
56
+ expect(() => requireProductIdArg('abc 123456789')).toThrow(ArgumentError);
57
+ expect(() => requireProductIdArg('https://www.coupang.com/not-a-product', '--url')).toThrow(ArgumentError);
58
+ expect(() => requireProductIdArg('https://www.coupang.com/not-a-product/123456789', '--url')).toThrow(ArgumentError);
59
+ expect(() => requireProductIdArg('https://notcoupang.com/vp/products/123456789', '--url')).toThrow(ArgumentError);
60
+ expect(() => requireProductIdArg('https://example.com/vp/products/123456789', '--url')).toThrow(ArgumentError);
61
+ });
62
+ });
63
+
64
+ describe('coupang adapter registry shape', () => {
65
+ it('search has product_id column for round-trip into product', () => {
66
+ const search = getRegistry().get('coupang/search');
67
+ expect(search).toBeDefined();
68
+ expect(search.access).toBe('read');
69
+ expect(search.columns).toContain('product_id');
70
+ // Listing pairs with detail: id-shaped column present.
71
+ const idShaped = search.columns.find((c) => /_id$|^id$/.test(c));
72
+ expect(idShaped).toBe('product_id');
73
+ });
74
+
75
+ it('product cmd is a registered read adapter that pairs with search', () => {
76
+ const product = getRegistry().get('coupang/product');
77
+ expect(product).toBeDefined();
78
+ expect(product.access).toBe('read');
79
+ expect(product.columns).toContain('product_id');
80
+ expect(product.columns).toContain('title');
81
+ expect(product.columns).toContain('price');
82
+ expect(product.columns).toContain('seller');
83
+ expect(product.columns).toContain('rating');
84
+ });
85
+
86
+ it('add-to-cart remains write-class', () => {
87
+ const cart = getRegistry().get('coupang/add-to-cart');
88
+ expect(cart).toBeDefined();
89
+ expect(cart.access).toBe('write');
90
+ });
91
+ });
92
+
93
+ describe('coupang search — typed errors (no silent fallback)', () => {
94
+ it('rejects empty query with ArgumentError', async () => {
95
+ const search = getRegistry().get('coupang/search');
96
+ // page object is irrelevant — we expect to fail before any browser call.
97
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
98
+ await expect(search.func(fakePage, { query: ' ' })).rejects.toThrow(ArgumentError);
99
+ await expect(search.func(fakePage, { query: '' })).rejects.toThrow(ArgumentError);
100
+ });
101
+
102
+ it('rejects unsupported --filter with ArgumentError', async () => {
103
+ const search = getRegistry().get('coupang/search');
104
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
105
+ await expect(search.func(fakePage, { query: 'mouse', filter: 'eco' })).rejects.toThrow(ArgumentError);
106
+ });
107
+
108
+ it('rejects out-of-range --limit with ArgumentError (no silent clamp to 50)', async () => {
109
+ const search = getRegistry().get('coupang/search');
110
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
111
+ await expect(search.func(fakePage, { query: 'mouse', limit: 999 })).rejects.toThrow(ArgumentError);
112
+ });
113
+
114
+ it('rejects out-of-range --page with ArgumentError', async () => {
115
+ const search = getRegistry().get('coupang/search');
116
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
117
+ await expect(search.func(fakePage, { query: 'mouse', page: 0 })).rejects.toThrow(ArgumentError);
118
+ });
119
+ });
120
+
121
+ describe('coupang product — typed errors', () => {
122
+ it('rejects missing --product-id and --url with ArgumentError', async () => {
123
+ const product = getRegistry().get('coupang/product');
124
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
125
+ await expect(product.func(fakePage, {})).rejects.toThrow(ArgumentError);
126
+ });
127
+
128
+ it('rejects malformed product id before navigation', async () => {
129
+ const product = getRegistry().get('coupang/product');
130
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
131
+ await expect(product.func(fakePage, { 'product-id': 'abc' })).rejects.toThrow(ArgumentError);
132
+ });
133
+
134
+ it('wraps browser failures as CommandExecutionError', async () => {
135
+ const product = getRegistry().get('coupang/product');
136
+ const fakePage = { goto: () => Promise.reject(new Error('browser down')) };
137
+ await expect(product.func(fakePage, { 'product-id': '123456789' })).rejects.toThrow(CommandExecutionError);
138
+ });
139
+ });
140
+
141
+ describe('coupang add-to-cart — typed errors', () => {
142
+ it('rejects missing --product-id and --url with ArgumentError', async () => {
143
+ const cart = getRegistry().get('coupang/add-to-cart');
144
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
145
+ await expect(cart.func(fakePage, {})).rejects.toThrow(ArgumentError);
146
+ });
147
+
148
+ it('rejects malformed product id before navigation', async () => {
149
+ const cart = getRegistry().get('coupang/add-to-cart');
150
+ const fakePage = { goto: () => { throw new Error('should not navigate'); } };
151
+ await expect(cart.func(fakePage, { 'product-id': 'abc' })).rejects.toThrow(ArgumentError);
152
+ });
153
+
154
+ it('wraps browser failures as CommandExecutionError', async () => {
155
+ const cart = getRegistry().get('coupang/add-to-cart');
156
+ const fakePage = { goto: () => Promise.reject(new Error('browser down')) };
157
+ await expect(cart.func(fakePage, { 'product-id': '123456789' })).rejects.toThrow(CommandExecutionError);
158
+ });
159
+ });
@@ -0,0 +1,257 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+ import { canonicalizeProductUrl, normalizeProductId, requireProductIdArg } from './utils.js';
4
+
5
+ function escapeJsString(value) {
6
+ return JSON.stringify(value);
7
+ }
8
+
9
+ /**
10
+ * Build the in-page extractor for a Coupang product detail page.
11
+ *
12
+ * Tries three sources in order, mirroring search.js's chain:
13
+ * 1. JSON-LD <script type="application/ld+json"> (Product schema, most stable)
14
+ * 2. window.__INITIAL_STATE__ / __NEXT_DATA__ / similar globals (rich)
15
+ * 3. DOM scrape (fallback when bootstrap state is server-side only)
16
+ *
17
+ * Returns either a partial product object or a structured failure
18
+ * `{ loginHints, ok: false, reason }` so the caller can map it to typed errors.
19
+ *
20
+ * Design note (no-silent-empty): empty strings / null fields here MUST mean
21
+ * "upstream did not provide this field" — they should not be conflated with
22
+ * "extraction failed". A failed extraction returns ok=false so the caller can
23
+ * surface AuthRequiredError or EmptyResultError; partial success returns the
24
+ * fields it found and the caller decides whether to treat the partial row as
25
+ * usable.
26
+ */
27
+ function buildProductDetailEvaluate(expectedProductId) {
28
+ return `
29
+ (async () => {
30
+ const expectedProductId = ${escapeJsString(expectedProductId)};
31
+ const normalizeText = (value) => (value == null ? '' : String(value).trim());
32
+ const parseNum = (value) => {
33
+ const text = normalizeText(value).replace(/[^\\d.]/g, '');
34
+ if (!text) return null;
35
+ const num = Number(text);
36
+ return Number.isFinite(num) ? num : null;
37
+ };
38
+
39
+ const loginHints = {
40
+ hasLoginLink: Boolean(document.querySelector('a[href*="login"], a[title*="로그인"]')),
41
+ hasMyCoupang: /마이쿠팡/.test(document.body.innerText || ''),
42
+ };
43
+
44
+ const pathMatch = location.pathname.match(/\\/vp\\/products\\/(\\d+)/);
45
+ const currentProductId = pathMatch?.[1] || '';
46
+ if (expectedProductId && currentProductId && expectedProductId !== currentProductId) {
47
+ return { ok: false, reason: 'PRODUCT_MISMATCH', currentProductId, loginHints };
48
+ }
49
+
50
+ // ── Source 1: JSON-LD Product schema ─────────────────────────────
51
+ const fromJsonLd = (() => {
52
+ const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
53
+ for (const script of scripts) {
54
+ try {
55
+ const docs = JSON.parse(script.textContent || 'null');
56
+ const items = Array.isArray(docs) ? docs : [docs];
57
+ for (const doc of items) {
58
+ if (!doc || typeof doc !== 'object') continue;
59
+ const t = doc['@type'];
60
+ const types = Array.isArray(t) ? t : [t];
61
+ if (!types.some((x) => /Product/i.test(String(x || '')))) continue;
62
+ const offers = Array.isArray(doc.offers) ? doc.offers[0] : doc.offers;
63
+ return {
64
+ title: normalizeText(doc.name),
65
+ brand: normalizeText(doc.brand?.name || doc.brand),
66
+ image_url: normalizeText(Array.isArray(doc.image) ? doc.image[0] : doc.image),
67
+ price: parseNum(offers?.price),
68
+ rating: parseNum(doc.aggregateRating?.ratingValue),
69
+ review_count: parseNum(doc.aggregateRating?.reviewCount),
70
+ seller: normalizeText(offers?.seller?.name),
71
+ };
72
+ }
73
+ } catch { /* malformed ld+json — skip and try next source */ }
74
+ }
75
+ return null;
76
+ })();
77
+
78
+ // ── Source 2: bootstrap globals (deeply nested vendorItem etc.) ──
79
+ const fromBootstrap = (() => {
80
+ const collect = (root) => {
81
+ if (!root || typeof root !== 'object') return null;
82
+ const queue = [root];
83
+ let depth = 0;
84
+ while (queue.length && depth < 5000) {
85
+ const node = queue.shift();
86
+ depth++;
87
+ if (!node || typeof node !== 'object') continue;
88
+ // A product-like leaf usually has both productId and salePrice / finalPrice / itemName.
89
+ const idCandidate = node.productId || node.product_id || node.id;
90
+ const titleCandidate = node.itemName || node.productName || node.name;
91
+ const priceCandidate = node.salePrice ?? node.finalPrice ?? node.sellingPrice ?? node.price;
92
+ if (idCandidate && titleCandidate && priceCandidate != null && /\\d{6,}/.test(String(idCandidate))) {
93
+ return {
94
+ product_id: String(idCandidate),
95
+ title: normalizeText(titleCandidate),
96
+ price: parseNum(priceCandidate),
97
+ original_price: parseNum(node.originalPrice ?? node.basePrice ?? node.listPrice),
98
+ discount_rate: parseNum(node.discountRate ?? node.discountPercent),
99
+ rating: parseNum(node.ratingAverage ?? node.rating ?? node.reviewRating),
100
+ review_count: parseNum(node.reviewCount ?? node.reviewsCount ?? node.ratingCount),
101
+ seller: normalizeText(node.vendorName ?? node.sellerName ?? node.merchantName),
102
+ brand: normalizeText(node.brandName ?? node.brand),
103
+ rocket: normalizeText(node.rocketType ?? node.deliveryBadgeType),
104
+ delivery_promise: normalizeText(node.deliveryPromise ?? node.arrivalText),
105
+ };
106
+ }
107
+ for (const value of Object.values(node)) {
108
+ if (value && typeof value === 'object') queue.push(value);
109
+ }
110
+ }
111
+ return null;
112
+ };
113
+ const candidates = [
114
+ window.__INITIAL_STATE__,
115
+ window.__NEXT_DATA__,
116
+ window.__APOLLO_STATE__,
117
+ window.__PRELOADED_STATE__,
118
+ ];
119
+ for (const c of candidates) {
120
+ const found = collect(c);
121
+ if (found) return found;
122
+ }
123
+ return null;
124
+ })();
125
+
126
+ // ── Source 3: DOM fallback ───────────────────────────────────────
127
+ const fromDom = (() => {
128
+ const titleNode = document.querySelector(
129
+ '.prod-buy-header__title, h1.prod-buy-header__title, h1[class*="prod-buy-header"], h2.prod-buy-header__title, h1[class*="ProductName"], h1[class*="product-name"]'
130
+ );
131
+ const priceNode = document.querySelector(
132
+ '.total-price strong, .prod-sale-price strong, [class*="finalPrice"], [class*="sellingPrice"], [class*="price-value"]'
133
+ );
134
+ const originalPriceNode = document.querySelector(
135
+ '.origin-price, .base-price, del[class*="origin"], del[class*="base"], [class*="strike"], [class*="origin-price"]'
136
+ );
137
+ const discountNode = document.querySelector(
138
+ '.discount-percentage, [class*="discount"][class*="percent"], [class*="discountRate"]'
139
+ );
140
+ const ratingNode = document.querySelector(
141
+ '.rating-star-num, [class*="ratingStar"], [class*="rating-star"], [class*="rating-num"], [class*="ProductRating"]'
142
+ );
143
+ const reviewCountNode = document.querySelector(
144
+ '.count, .rating-total-count, [class*="reviewCount"], [class*="review-count"]'
145
+ );
146
+ const sellerNode = document.querySelector(
147
+ '.prod-sale-vendor-name, [class*="vendor-name"], [class*="vendorName"], [class*="sellerName"]'
148
+ );
149
+ const imageNode = document.querySelector(
150
+ '.prod-image__detail, [class*="prod-image"] img, [class*="ProductImage"] img'
151
+ );
152
+ return {
153
+ title: normalizeText(titleNode?.textContent),
154
+ price: parseNum(priceNode?.textContent),
155
+ original_price: parseNum(originalPriceNode?.textContent),
156
+ discount_rate: parseNum(discountNode?.textContent),
157
+ rating: parseNum(ratingNode?.getAttribute?.('aria-label') || ratingNode?.textContent),
158
+ review_count: parseNum(reviewCountNode?.textContent),
159
+ seller: normalizeText(sellerNode?.textContent),
160
+ image_url: normalizeText(imageNode?.getAttribute?.('src') || imageNode?.getAttribute?.('data-src')),
161
+ };
162
+ })();
163
+
164
+ // Merge with priority: bootstrap > jsonld > dom (bootstrap is freshest /
165
+ // closest to the API; jsonld is well-typed; dom is last-resort).
166
+ const merge = (a, b) => {
167
+ if (!a) return b;
168
+ if (!b) return a;
169
+ const out = { ...a };
170
+ for (const [k, v] of Object.entries(b)) {
171
+ if (out[k] == null || out[k] === '') out[k] = v;
172
+ }
173
+ return out;
174
+ };
175
+ const merged = merge(merge(fromBootstrap, fromJsonLd), fromDom);
176
+ const hasAnyField = merged && (merged.title || merged.price != null);
177
+ if (!hasAnyField) {
178
+ return { ok: false, reason: 'NO_DATA_EXTRACTED', currentProductId, loginHints };
179
+ }
180
+
181
+ return {
182
+ ok: true,
183
+ currentProductId,
184
+ loginHints,
185
+ data: merged,
186
+ };
187
+ })()
188
+ `;
189
+ }
190
+
191
+ cli({
192
+ site: 'coupang',
193
+ name: 'product',
194
+ access: 'read',
195
+ description: 'Read full product detail (price, rating, seller, delivery) for a Coupang product',
196
+ domain: 'www.coupang.com',
197
+ strategy: Strategy.COOKIE,
198
+ browser: true,
199
+ args: [
200
+ { name: 'product-id', positional: true, required: false, help: 'Coupang product ID (digits only)' },
201
+ { name: 'url', required: false, help: 'Canonical Coupang product URL (alternative to --product-id)' },
202
+ ],
203
+ columns: [
204
+ 'product_id', 'title', 'price', 'original_price', 'discount_rate',
205
+ 'rating', 'review_count', 'seller', 'brand', 'rocket',
206
+ 'delivery_promise', 'image_url', 'url',
207
+ ],
208
+ func: async (page, kwargs) => {
209
+ const rawProductId = kwargs['product-id'];
210
+ if (!rawProductId && !kwargs.url) {
211
+ throw new ArgumentError('Either --product-id or --url is required');
212
+ }
213
+ const productId = rawProductId
214
+ ? requireProductIdArg(rawProductId, 'product-id')
215
+ : requireProductIdArg(kwargs.url, '--url');
216
+ const targetUrl = canonicalizeProductUrl(kwargs.url, productId);
217
+ const finalUrl = targetUrl || canonicalizeProductUrl('', productId);
218
+ await page.goto(finalUrl).catch((error) => {
219
+ throw new CommandExecutionError(`coupang product navigation failed: ${error?.message || error}`);
220
+ });
221
+ await page.wait(2).catch((error) => {
222
+ throw new CommandExecutionError(`coupang product wait failed: ${error?.message || error}`);
223
+ });
224
+ const result = await page.evaluate(buildProductDetailEvaluate(productId)).catch((error) => {
225
+ throw new CommandExecutionError(`coupang product extraction failed: ${error?.message || error}`);
226
+ });
227
+ const loginHints = result?.loginHints ?? {};
228
+ if (loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
229
+ throw new AuthRequiredError('coupang.com', 'Please log into Coupang in Chrome and retry.');
230
+ }
231
+ if (result?.reason === 'PRODUCT_MISMATCH') {
232
+ const actualProductId = normalizeProductId(result?.currentProductId || '');
233
+ const observed = actualProductId ? `got ${actualProductId}` : 'no product id observed';
234
+ throw new EmptyResultError('coupang product', `Product page redirected: expected ${productId}, ${observed} (item may be sold out or unavailable in your region)`);
235
+ }
236
+ if (!result?.ok || !result?.data) {
237
+ throw new EmptyResultError('coupang product', `No product data extracted from ${finalUrl}. The page may have failed to render or this product is restricted.`);
238
+ }
239
+ const actualProductId = normalizeProductId(result?.currentProductId || result.data.product_id || productId);
240
+ const data = result.data;
241
+ return [{
242
+ product_id: actualProductId,
243
+ title: data.title || null,
244
+ price: data.price ?? null,
245
+ original_price: data.original_price ?? null,
246
+ discount_rate: data.discount_rate ?? null,
247
+ rating: data.rating ?? null,
248
+ review_count: data.review_count ?? null,
249
+ seller: data.seller || null,
250
+ brand: data.brand || null,
251
+ rocket: data.rocket || null,
252
+ delivery_promise: data.delivery_promise || null,
253
+ image_url: data.image_url || null,
254
+ url: canonicalizeProductUrl('', actualProductId) || finalUrl,
255
+ }];
256
+ },
257
+ });
@@ -1,5 +1,6 @@
1
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
1
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { mergeSearchItems, normalizeSearchItem, sanitizeSearchItems } from './utils.js';
3
+ import { mergeSearchItems, normalizeSearchItem, parseLimitArg, parsePageArg, sanitizeSearchItems } from './utils.js';
3
4
  function escapeJsString(value) {
4
5
  return JSON.stringify(value);
5
6
  }
@@ -410,32 +411,50 @@ cli({
410
411
  { name: 'limit', type: 'int', default: 20, help: 'Max results (max 50)' },
411
412
  { name: 'filter', required: false, help: 'Optional search filter (currently supports: rocket)' },
412
413
  ],
413
- columns: ['rank', 'title', 'price', 'unit_price', 'rating', 'review_count', 'rocket', 'delivery_type', 'delivery_promise', 'url'],
414
+ columns: ['rank', 'product_id', 'title', 'price', 'unit_price', 'rating', 'review_count', 'rocket', 'delivery_type', 'delivery_promise', 'url'],
414
415
  func: async (page, kwargs) => {
415
416
  const query = String(kwargs.query || '').trim();
416
- const pageNumber = Math.max(Number(kwargs.page || 1), 1);
417
- const limit = Math.min(Math.max(Number(kwargs.limit || 20), 1), 50);
417
+ if (!query) {
418
+ throw new ArgumentError('query cannot be empty');
419
+ }
420
+ const pageNumber = parsePageArg(kwargs.page, 1);
421
+ const limit = parseLimitArg(kwargs.limit, 20, 50);
418
422
  const filter = String(kwargs.filter || '').trim().toLowerCase();
419
- if (!query)
420
- throw new Error('Query is required');
423
+ if (filter && filter !== 'rocket') {
424
+ throw new ArgumentError(`Unsupported --filter "${filter}" (supported: rocket)`);
425
+ }
421
426
  const initialPage = filter ? 1 : pageNumber;
422
427
  const url = `https://www.coupang.com/np/search?q=${encodeURIComponent(query)}&channel=user&page=${initialPage}`;
423
- await page.goto(url);
428
+ await page.goto(url).catch((error) => {
429
+ throw new CommandExecutionError(`coupang search navigation failed: ${error?.message || error}`);
430
+ });
424
431
  if (filter) {
425
- const filterResult = await page.evaluate(buildApplyFilterEvaluate(filter));
432
+ const filterResult = await page.evaluate(buildApplyFilterEvaluate(filter)).catch((error) => {
433
+ throw new CommandExecutionError(`coupang search filter evaluation failed: ${error?.message || error}`);
434
+ });
426
435
  if (!filterResult?.ok) {
427
- throw new Error(`Unsupported or unavailable filter: ${filter}`);
436
+ throw new EmptyResultError('coupang search', `Filter "${filter}" was not available on the current page; try without --filter or wait for Coupang to render the filter bar.`);
428
437
  }
429
- await page.wait(3);
438
+ await page.wait(3).catch((error) => {
439
+ throw new CommandExecutionError(`coupang search wait failed: ${error?.message || error}`);
440
+ });
430
441
  if (pageNumber > 1) {
431
- const locationInfo = await page.evaluate(buildCurrentLocationEvaluate());
442
+ const locationInfo = await page.evaluate(buildCurrentLocationEvaluate()).catch((error) => {
443
+ throw new CommandExecutionError(`coupang search location evaluation failed: ${error?.message || error}`);
444
+ });
432
445
  const filteredUrl = new URL(locationInfo?.href || url);
433
446
  filteredUrl.searchParams.set('page', String(pageNumber));
434
- await page.goto(filteredUrl.toString());
447
+ await page.goto(filteredUrl.toString()).catch((error) => {
448
+ throw new CommandExecutionError(`coupang search filtered navigation failed: ${error?.message || error}`);
449
+ });
435
450
  }
436
451
  }
437
- await page.autoScroll({ times: filter ? 3 : 2, delayMs: 1500 });
438
- const raw = await page.evaluate(buildSearchEvaluate(query, limit, pageNumber));
452
+ await page.autoScroll({ times: filter ? 3 : 2, delayMs: 1500 }).catch((error) => {
453
+ throw new CommandExecutionError(`coupang search scroll failed: ${error?.message || error}`);
454
+ });
455
+ const raw = await page.evaluate(buildSearchEvaluate(query, limit, pageNumber)).catch((error) => {
456
+ throw new CommandExecutionError(`coupang search extraction failed: ${error?.message || error}`);
457
+ });
439
458
  const loginHints = raw?.loginHints ?? {};
440
459
  const items = Array.isArray(raw?.items) ? raw.items : [];
441
460
  const domItems = Array.isArray(raw?.domItems) ? raw.domItems : [];
@@ -444,8 +463,11 @@ cli({
444
463
  const normalized = filter
445
464
  ? sanitizeSearchItems(normalizedDom, limit)
446
465
  : mergeSearchItems(normalizedBase, normalizedDom, limit);
447
- if (!normalized.length && loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
448
- throw new Error('Coupang login required. Please log into Coupang in Chrome and retry.');
466
+ if (!normalized.length) {
467
+ if (loginHints.hasLoginLink && !loginHints.hasMyCoupang) {
468
+ throw new AuthRequiredError('coupang.com', 'Please log into Coupang in Chrome and retry.');
469
+ }
470
+ throw new EmptyResultError('coupang search', `No products matched "${query}". Try a more specific keyword or remove --filter.`);
449
471
  }
450
472
  return normalized;
451
473
  },
@@ -1,3 +1,36 @@
1
+ import { ArgumentError } from '@jackwener/opencli/errors';
2
+
3
+ /**
4
+ * Parse a positive integer arg (--limit / --page / --review-page).
5
+ *
6
+ * Throws ArgumentError on out-of-range / non-integer values rather than
7
+ * silently clamping. We prefer typed-fail-fast over silent clamping for the
8
+ * same reason as feedback_typed_fail_fast_for_adapters: callers cannot tell
9
+ * that their value was rewritten and end up confused why "limit=999" returned
10
+ * 50 rows.
11
+ */
12
+ export function parseLimitArg(raw, fallback, max) {
13
+ if (raw === undefined || raw === null || raw === '') {
14
+ return fallback;
15
+ }
16
+ const num = Number(raw);
17
+ if (!Number.isInteger(num) || num < 1 || num > max) {
18
+ throw new ArgumentError(`--limit must be an integer between 1 and ${max} (got ${raw})`);
19
+ }
20
+ return num;
21
+ }
22
+
23
+ export function parsePageArg(raw, fallback) {
24
+ if (raw === undefined || raw === null || raw === '') {
25
+ return fallback;
26
+ }
27
+ const num = Number(raw);
28
+ if (!Number.isInteger(num) || num < 1) {
29
+ throw new ArgumentError(`--page must be a positive integer (got ${raw})`);
30
+ }
31
+ return num;
32
+ }
33
+
1
34
  function itemKey(item) {
2
35
  return item.url || item.product_id || `${item.title}:${item.price ?? ''}`;
3
36
  }
@@ -61,7 +94,28 @@ export function normalizeProductId(raw) {
61
94
  if (!text)
62
95
  return '';
63
96
  const match = text.match(/\/vp\/products\/(\d+)/) || text.match(/\b(\d{6,})\b/);
64
- return match?.[1] ?? text;
97
+ return match?.[1] ?? '';
98
+ }
99
+ export function requireProductIdArg(raw, label = '--product-id') {
100
+ const text = asString(raw);
101
+ if (label === '--url') {
102
+ try {
103
+ const url = new URL(text.startsWith('http') ? text : `https://www.coupang.com${text}`);
104
+ const match = url.pathname.match(/^\/vp\/products\/(\d{6,})(?:\/|$)/);
105
+ const isCoupangHost = url.hostname === 'coupang.com' || url.hostname.endsWith('.coupang.com');
106
+ if (isCoupangHost && match) {
107
+ return match[1];
108
+ }
109
+ }
110
+ catch {
111
+ // Fall through to the typed validation error below.
112
+ }
113
+ throw new ArgumentError(`${label} must be a Coupang product URL containing /vp/products/<id>`);
114
+ }
115
+ if (!/^\d{6,}$/.test(text)) {
116
+ throw new ArgumentError(`${label} must be a numeric Coupang product ID`);
117
+ }
118
+ return text;
65
119
  }
66
120
  export function canonicalizeProductUrl(rawUrl, productId) {
67
121
  const raw = asString(rawUrl);