@jackwener/opencli 1.7.12 → 1.7.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/README.zh-CN.md +9 -8
- package/cli-manifest.json +12128 -6665
- package/clis/1point3acres/digest.js +35 -0
- package/clis/1point3acres/forum.js +51 -0
- package/clis/1point3acres/forums.js +44 -0
- package/clis/1point3acres/hot.js +35 -0
- package/clis/1point3acres/latest.js +35 -0
- package/clis/1point3acres/notifications.js +64 -0
- package/clis/1point3acres/search.js +71 -0
- package/clis/1point3acres/thread.js +117 -0
- package/clis/1point3acres/user.js +77 -0
- package/clis/1point3acres/utils.js +247 -0
- package/clis/_shared/desktop-commands.js +4 -0
- package/clis/aibase/news.js +110 -0
- package/clis/aibase/news.test.js +59 -0
- package/clis/amazon/discussion.test.js +1 -28
- package/clis/antigravity/watch.js +3 -2
- package/clis/arxiv/author.js +44 -0
- package/clis/baidu-scholar/search.js +0 -1
- package/clis/bbc/topic.js +57 -0
- package/clis/bbc/utils.js +79 -0
- package/clis/chaoxing/assignments.js +1 -1
- package/clis/chaoxing/exams.js +1 -1
- package/clis/chatgpt/ask.js +57 -0
- package/clis/chatgpt/commands.test.js +45 -0
- package/clis/chatgpt/detail.js +46 -0
- package/clis/chatgpt/history.js +39 -0
- package/clis/chatgpt/image.js +12 -11
- package/clis/chatgpt/image.test.js +23 -0
- package/clis/chatgpt/new.js +25 -0
- package/clis/chatgpt/read.js +43 -0
- package/clis/chatgpt/send.js +46 -0
- package/clis/chatgpt/status.js +29 -0
- package/clis/chatgpt/utils.js +294 -4
- package/clis/chatgpt/utils.test.js +13 -0
- package/clis/chatgpt-app/ask.js +6 -3
- package/clis/chatwise/ask.js +16 -43
- package/clis/chatwise/composer.test.js +186 -0
- package/clis/chatwise/send.js +2 -24
- package/clis/chatwise/utils.js +143 -0
- package/clis/claude/ask.js +1 -1
- package/clis/claude/detail.js +1 -0
- package/clis/claude/history.js +1 -0
- package/clis/claude/new.js +1 -0
- package/clis/claude/read.js +1 -0
- package/clis/claude/send.js +1 -0
- package/clis/claude/status.js +1 -0
- package/clis/codex/ask.js +15 -9
- package/clis/codex/history.js +16 -33
- package/clis/codex/projects.js +28 -0
- package/clis/codex/read.js +10 -4
- package/clis/codex/send.js +10 -3
- package/clis/codex/sidebar.js +356 -0
- package/clis/codex/sidebar.test.js +329 -0
- package/clis/coingecko/categories.js +75 -0
- package/clis/coingecko/coin.js +107 -0
- package/clis/coingecko/coingecko.test.js +109 -0
- package/clis/coingecko/derivatives.js +84 -0
- package/clis/coingecko/exchanges.js +74 -0
- package/clis/coingecko/global.js +71 -0
- package/clis/coingecko/top.js +64 -0
- package/clis/coingecko/trending.js +55 -0
- package/clis/coupang/add-to-cart.js +21 -13
- package/clis/coupang/coupang.test.js +159 -0
- package/clis/coupang/product.js +257 -0
- package/clis/coupang/search.js +38 -16
- package/clis/coupang/utils.js +55 -1
- package/clis/crates/crate.js +62 -0
- package/clis/crates/search.js +44 -0
- package/clis/crates/utils.js +72 -0
- package/clis/ctrip/ctrip.test.js +234 -0
- package/clis/ctrip/hotel-suggest.js +45 -0
- package/clis/ctrip/search.js +22 -68
- package/clis/ctrip/utils.js +175 -0
- package/clis/cursor/ask.js +6 -3
- package/clis/dblp/author.js +133 -0
- package/clis/dblp/venue.js +64 -0
- package/clis/deepseek/ask.js +12 -7
- package/clis/deepseek/ask.test.js +13 -13
- package/clis/deepseek/detail.js +38 -0
- package/clis/deepseek/detail.test.js +81 -0
- package/clis/deepseek/history.js +1 -0
- package/clis/deepseek/new.js +1 -0
- package/clis/deepseek/read.js +1 -0
- package/clis/deepseek/send.js +140 -0
- package/clis/deepseek/send.test.js +107 -0
- package/clis/deepseek/status.js +1 -0
- package/clis/deepseek/utils.js +66 -0
- package/clis/deepseek/utils.test.js +107 -1
- package/clis/defillama/defillama.test.js +99 -0
- package/clis/defillama/protocol.js +84 -0
- package/clis/defillama/protocols.js +55 -0
- package/clis/defillama/utils.js +99 -0
- package/clis/devto/latest.js +74 -0
- package/clis/dockerhub/image.js +52 -0
- package/clis/dockerhub/search.js +47 -0
- package/clis/dockerhub/utils.js +100 -0
- package/clis/doubao/ask.js +7 -3
- package/clis/doubao/detail.js +1 -0
- package/clis/doubao/history.js +1 -0
- package/clis/doubao/meeting-summary.js +1 -0
- package/clis/doubao/meeting-transcript.js +1 -0
- package/clis/doubao/new.js +1 -0
- package/clis/doubao/read.js +1 -0
- package/clis/doubao/send.js +1 -0
- package/clis/doubao/status.js +1 -0
- package/clis/douyin/draft.test.js +1 -30
- package/clis/endoflife/endoflife.test.js +51 -0
- package/clis/endoflife/product.js +55 -0
- package/clis/endoflife/utils.js +89 -0
- package/clis/facebook/__fixtures__/notifications-page.html +13 -0
- package/clis/facebook/notifications.js +326 -30
- package/clis/facebook/notifications.test.js +458 -0
- package/clis/flathub/app.js +71 -0
- package/clis/flathub/flathub.test.js +90 -0
- package/clis/flathub/search.js +80 -0
- package/clis/flathub/utils.js +114 -0
- package/clis/gemini/ask.js +7 -3
- package/clis/gemini/ask.test.js +2 -2
- package/clis/gemini/deep-research-result.js +6 -2
- package/clis/gemini/deep-research-result.test.js +15 -14
- package/clis/gemini/deep-research.js +8 -4
- package/clis/gemini/deep-research.test.js +15 -18
- package/clis/gemini/image.js +7 -2
- package/clis/gemini/new.js +1 -0
- package/clis/gemini/utils.js +0 -4
- package/clis/google-scholar/cite.js +0 -1
- package/clis/google-scholar/profile.js +0 -1
- package/clis/google-scholar/search.js +0 -1
- package/clis/goproxy/goproxy.test.js +103 -0
- package/clis/goproxy/module.js +47 -0
- package/clis/goproxy/utils.js +165 -0
- package/clis/goproxy/versions.js +59 -0
- package/clis/gov-law/recent.js +0 -1
- package/clis/gov-law/search.js +0 -1
- package/clis/gov-policy/__fixtures__/recent.html +16 -0
- package/clis/gov-policy/__fixtures__/search.html +41 -0
- package/clis/gov-policy/gov-policy.test.js +224 -0
- package/clis/gov-policy/recent.js +66 -24
- package/clis/gov-policy/search.js +65 -23
- package/clis/gov-policy/utils.js +54 -0
- package/clis/grok/ask.js +49 -265
- package/clis/grok/ask.test.js +21 -46
- package/clis/grok/detail.js +60 -0
- package/clis/grok/history.js +48 -0
- package/clis/grok/{image.ts → image.js} +56 -70
- package/clis/grok/image.test.ts +20 -0
- package/clis/grok/new.js +20 -0
- package/clis/grok/read.js +39 -0
- package/clis/grok/send.js +50 -0
- package/clis/grok/status.js +41 -0
- package/clis/grok/utils.js +326 -0
- package/clis/grok/utils.test.js +103 -0
- package/clis/hf/datasets.js +88 -0
- package/clis/hf/hf.test.js +16 -0
- package/clis/hf/models.js +91 -0
- package/clis/hf/paper.js +79 -0
- package/clis/hf/spaces.js +101 -0
- package/clis/hf/top.js +1 -0
- package/clis/homebrew/cask.js +39 -0
- package/clis/homebrew/formula.js +41 -0
- package/clis/homebrew/popular.js +54 -0
- package/clis/homebrew/utils.js +100 -0
- package/clis/hupu/__fixtures__/hot-home.html +64 -0
- package/clis/hupu/detail.js +0 -1
- package/clis/hupu/hot.js +156 -35
- package/clis/hupu/hot.test.js +224 -0
- package/clis/hupu/search.js +0 -1
- package/clis/instagram/note.js +1 -1
- package/clis/instagram/note.test.js +1 -29
- package/clis/instagram/post.js +1 -1
- package/clis/instagram/post.test.js +1 -1
- package/clis/instagram/reel.js +1 -1
- package/clis/instagram/story.js +1 -1
- package/clis/instagram/story.test.js +1 -34
- package/clis/jd/commands.test.js +1 -24
- package/clis/lichess/lichess.test.js +85 -0
- package/clis/lichess/top.js +46 -0
- package/clis/lichess/user.js +91 -0
- package/clis/lichess/utils.js +97 -0
- package/clis/linkedin/search.js +107 -10
- package/clis/linkedin/search.test.js +222 -0
- package/clis/linux-do/feed.js +2 -5
- package/clis/linux-do/feed.test.js +35 -0
- package/clis/lobsters/domain.js +92 -0
- package/clis/maven/artifact.js +49 -0
- package/clis/maven/search.js +51 -0
- package/clis/maven/utils.js +110 -0
- package/clis/mdn/search.js +97 -0
- package/clis/medium/tag.js +135 -0
- package/clis/npm/downloads.js +59 -0
- package/clis/npm/package.js +70 -0
- package/clis/npm/search.js +49 -0
- package/clis/npm/utils.js +76 -0
- package/clis/nuget/nuget.test.js +111 -0
- package/clis/nuget/package.js +101 -0
- package/clis/nuget/search.js +69 -0
- package/clis/nuget/utils.js +87 -0
- package/clis/nvd/cve.js +121 -0
- package/clis/oeis/oeis.test.js +88 -0
- package/clis/oeis/search.js +63 -0
- package/clis/oeis/sequence.js +71 -0
- package/clis/oeis/utils.js +88 -0
- package/clis/openalex/search.js +69 -0
- package/clis/openalex/utils.js +160 -0
- package/clis/openalex/work.js +65 -0
- package/clis/openfda/drug-label.js +74 -0
- package/clis/openfda/food-recall.js +65 -0
- package/clis/openfda/openfda.test.js +114 -0
- package/clis/openfda/utils.js +67 -0
- package/clis/osv/osv.test.js +97 -0
- package/clis/osv/query.js +72 -0
- package/clis/osv/utils.js +169 -0
- package/clis/osv/vulnerability.js +54 -0
- package/clis/packagist/package.js +49 -0
- package/clis/packagist/search.js +43 -0
- package/clis/packagist/utils.js +113 -0
- package/clis/paperreview/feedback.js +1 -1
- package/clis/paperreview/review.js +1 -1
- package/clis/paperreview/submit.js +1 -1
- package/clis/pixiv/download.test.js +1 -1
- package/clis/pixiv/illusts.test.js +1 -1
- package/clis/pixiv/search.test.js +1 -1
- package/clis/pubmed/article.js +50 -0
- package/clis/pubmed/author.js +64 -0
- package/clis/pubmed/citations.js +36 -0
- package/clis/pubmed/pubmed.test.js +276 -0
- package/clis/pubmed/related.js +45 -0
- package/clis/pubmed/search.js +75 -0
- package/clis/pubmed/utils.js +309 -0
- package/clis/pypi/downloads.js +66 -0
- package/clis/pypi/package.js +79 -0
- package/clis/pypi/utils.js +55 -0
- package/clis/quark/mv.js +1 -1
- package/clis/quark/save.js +1 -1
- package/clis/qwen/ask.js +85 -0
- package/clis/qwen/detail.js +62 -0
- package/clis/qwen/history.js +61 -0
- package/clis/qwen/image.js +179 -0
- package/clis/qwen/new.js +23 -0
- package/clis/qwen/read.js +41 -0
- package/clis/qwen/send.js +55 -0
- package/clis/qwen/status.js +37 -0
- package/clis/qwen/utils.js +409 -0
- package/clis/qwen/utils.test.js +45 -0
- package/clis/rest-countries/country.js +65 -0
- package/clis/rest-countries/region.js +64 -0
- package/clis/rest-countries/rest-countries.test.js +83 -0
- package/clis/rest-countries/utils.js +126 -0
- package/clis/reuters/article-detail.js +53 -0
- package/clis/reuters/reuters.test.js +299 -0
- package/clis/reuters/search.js +45 -34
- package/clis/reuters/utils.js +159 -0
- package/clis/rfc/rfc.js +52 -0
- package/clis/rfc/rfc.test.js +74 -0
- package/clis/rfc/utils.js +72 -0
- package/clis/rubygems/gem.js +42 -0
- package/clis/rubygems/search.js +47 -0
- package/clis/rubygems/utils.js +86 -0
- package/clis/stackoverflow/related.js +66 -0
- package/clis/stackoverflow/stackoverflow.test.js +58 -0
- package/clis/stackoverflow/tag.js +60 -0
- package/clis/stackoverflow/user.js +50 -0
- package/clis/stackoverflow/utils.js +118 -0
- package/clis/steam/app.js +67 -0
- package/clis/steam/search.js +58 -0
- package/clis/steam/steam.test.js +46 -0
- package/clis/steam/utils.js +107 -0
- package/clis/taobao/commands.test.js +1 -24
- package/clis/test-utils.js +61 -0
- package/clis/tieba/hot.js +0 -1
- package/clis/tiktok/comment.js +128 -41
- package/clis/tiktok/creator-videos.js +270 -0
- package/clis/tiktok/creator-videos.test.js +113 -0
- package/clis/tiktok/explore.js +137 -29
- package/clis/tiktok/follow.js +115 -33
- package/clis/tiktok/following.js +157 -36
- package/clis/tiktok/friends.js +139 -37
- package/clis/tiktok/live.js +137 -41
- package/clis/tiktok/notifications.js +141 -38
- package/clis/tiktok/refactor.test.js +389 -0
- package/clis/tiktok/unfollow.js +124 -38
- package/clis/tiktok/user.js +203 -29
- package/clis/tiktok/utils.js +505 -0
- package/clis/tiktok/write-refactor.test.js +370 -0
- package/clis/toutiao/articles.js +36 -62
- package/clis/toutiao/hot.js +63 -0
- package/clis/toutiao/toutiao.test.js +378 -0
- package/clis/toutiao/utils.js +161 -0
- package/clis/tvmaze/search.js +61 -0
- package/clis/tvmaze/show.js +60 -0
- package/clis/tvmaze/tvmaze.test.js +93 -0
- package/clis/tvmaze/utils.js +110 -0
- package/clis/twitter/accept.js +1 -1
- package/clis/twitter/followers.js +134 -69
- package/clis/twitter/quote.js +139 -0
- package/clis/twitter/quote.test.js +106 -0
- package/clis/twitter/reply-dm.js +1 -1
- package/clis/twitter/reply.test.js +1 -29
- package/clis/twitter/retweet.js +99 -0
- package/clis/twitter/retweet.test.js +69 -0
- package/clis/twitter/shared.js +38 -0
- package/clis/twitter/shared.test.js +28 -1
- package/clis/twitter/unlike.js +87 -0
- package/clis/twitter/unlike.test.js +72 -0
- package/clis/twitter/unretweet.js +99 -0
- package/clis/twitter/unretweet.test.js +69 -0
- package/clis/uisdc/news.js +105 -0
- package/clis/uisdc/news.test.js +66 -0
- package/clis/wanfang/search.js +0 -1
- package/clis/web/read.js +47 -17
- package/clis/web/read.test.js +101 -1
- package/clis/weixin/create-draft.js +1 -1
- package/clis/weixin/drafts.js +1 -1
- package/clis/weixin/drafts.test.js +5 -1
- package/clis/weixin/search.js +157 -0
- package/clis/weixin/search.test.js +227 -0
- package/clis/wikidata/entity.js +60 -0
- package/clis/wikidata/search.js +50 -0
- package/clis/wikidata/utils.js +117 -0
- package/clis/wikidata/wikidata.test.js +83 -0
- package/clis/wikipedia/page.js +95 -0
- package/clis/wttr/current.js +63 -0
- package/clis/wttr/forecast.js +71 -0
- package/clis/wttr/utils.js +50 -0
- package/clis/wttr/wttr.test.js +84 -0
- package/clis/xianyu/chat.js +16 -4
- package/clis/xianyu/chat.test.js +64 -0
- package/clis/xianyu/publish.js +485 -0
- package/clis/xianyu/publish.test.js +220 -0
- package/clis/xiaoe/catalog.js +105 -40
- package/clis/xiaoe/content.js +164 -29
- package/clis/xiaoe/courses.js +86 -29
- package/clis/xiaoe/xiaoe.test.js +486 -0
- package/clis/xiaohongshu/creator-notes-summary.js +1 -1
- package/clis/xiaohongshu/publish.js +16 -3
- package/clis/xiaohongshu/publish.test.js +46 -1
- package/clis/youtube/transcript.js +13 -19
- package/clis/youtube/transcript.test.js +17 -0
- package/clis/yuanbao/ask.js +17 -66
- package/clis/yuanbao/ask.test.js +5 -5
- package/clis/yuanbao/detail.js +65 -0
- package/clis/yuanbao/history.js +51 -0
- package/clis/yuanbao/new.js +1 -0
- package/clis/yuanbao/read.js +38 -0
- package/clis/yuanbao/send.js +57 -0
- package/clis/yuanbao/shared.js +297 -5
- package/clis/yuanbao/shared.test.js +80 -0
- package/clis/yuanbao/status.js +44 -0
- package/clis/zlibrary/commands.test.js +1 -11
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +44 -1
- package/dist/src/browser/base-page.test.js +66 -0
- package/dist/src/browser/bridge.js +47 -45
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +51 -9
- package/dist/src/browser/daemon-client.d.ts +4 -0
- package/dist/src/browser/errors.js +1 -1
- package/dist/src/browser/page.d.ts +1 -1
- package/dist/src/browser/page.js +3 -1
- package/dist/src/browser/page.test.js +29 -0
- package/dist/src/browser/target-errors.d.ts +2 -1
- package/dist/src/browser/target-errors.js +1 -0
- package/dist/src/browser/target-resolver.d.ts +25 -0
- package/dist/src/browser/target-resolver.js +43 -0
- package/dist/src/browser.test.js +18 -0
- package/dist/src/build-manifest.js +9 -4
- package/dist/src/build-manifest.test.js +2 -8
- package/dist/src/capabilityRouting.d.ts +16 -1
- package/dist/src/capabilityRouting.js +24 -1
- package/dist/src/capabilityRouting.test.js +19 -1
- package/dist/src/cli.js +76 -11
- package/dist/src/cli.test.js +241 -1
- package/dist/src/commanderAdapter.js +23 -9
- package/dist/src/commanderAdapter.test.js +0 -1
- package/dist/src/discovery.js +2 -5
- package/dist/src/errors.js +1 -1
- package/dist/src/execution.d.ts +1 -1
- package/dist/src/execution.js +111 -27
- package/dist/src/execution.test.js +326 -17
- package/dist/src/help.d.ts +27 -2
- package/dist/src/help.js +196 -23
- package/dist/src/help.test.d.ts +1 -0
- package/dist/src/help.test.js +54 -0
- package/dist/src/main.js +14 -1
- package/dist/src/manifest-types.d.ts +5 -3
- package/dist/src/pipeline/executor.js +1 -1
- package/dist/src/pipeline/executor.test.js +8 -0
- package/dist/src/pipeline/registry.d.ts +9 -0
- package/dist/src/pipeline/registry.js +13 -1
- package/dist/src/pipeline/steps/browser.d.ts +1 -0
- package/dist/src/pipeline/steps/browser.js +10 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/registry-api.d.ts +1 -1
- package/dist/src/registry.d.ts +12 -11
- package/dist/src/registry.js +16 -6
- package/dist/src/registry.test.js +2 -2
- package/dist/src/runtime.d.ts +2 -1
- package/dist/src/runtime.js +1 -1
- package/dist/src/serialization.d.ts +2 -2
- package/dist/src/serialization.js +4 -6
- package/dist/src/serialization.test.js +17 -0
- package/dist/src/types.d.ts +17 -0
- package/dist/src/validate.js +15 -11
- package/dist/src/validate.test.d.ts +9 -0
- package/dist/src/validate.test.js +90 -0
- package/package.json +1 -1
- package/scripts/fetch-adapters.js +1 -1
- package/scripts/typed-error-lint-baseline.json +5 -77
- package/clis/ctrip/search.test.js +0 -64
- package/clis/gov-policy/commands.test.js +0 -27
- package/clis/linux-do/category.js +0 -37
- package/clis/linux-do/hot.js +0 -26
- package/clis/linux-do/latest.js +0 -19
- package/clis/pixiv/test-utils.js +0 -23
- package/clis/toutiao/articles.test.js +0 -30
- package/dist/src/analysis.d.ts +0 -40
- 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']
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
+
});
|
package/clis/coupang/search.js
CHANGED
|
@@ -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
|
-
|
|
417
|
-
|
|
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 (
|
|
420
|
-
throw new
|
|
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
|
|
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
|
-
|
|
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
|
|
448
|
-
|
|
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
|
},
|
package/clis/coupang/utils.js
CHANGED
|
@@ -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] ??
|
|
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);
|