@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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// crates crate — fetch a single crate's metadata.
|
|
2
|
+
//
|
|
3
|
+
// Hits `https://crates.io/api/v1/crates/<name>`. Returns the agent-useful
|
|
4
|
+
// projection: name, latest version, description, total + recent downloads,
|
|
5
|
+
// homepage / docs / repo, license (from latest version row), version count,
|
|
6
|
+
// created / updated timestamps.
|
|
7
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
9
|
+
import { CRATES_BASE, cratesFetch, requireCrateName } from './utils.js';
|
|
10
|
+
|
|
11
|
+
cli({
|
|
12
|
+
site: 'crates',
|
|
13
|
+
name: 'crate',
|
|
14
|
+
access: 'read',
|
|
15
|
+
description: 'Single crates.io crate metadata (latest version, downloads, license, repo)',
|
|
16
|
+
domain: 'crates.io',
|
|
17
|
+
strategy: Strategy.PUBLIC,
|
|
18
|
+
browser: false,
|
|
19
|
+
args: [
|
|
20
|
+
{ name: 'name', positional: true, required: true, help: 'crates.io crate name (e.g. "serde", "tokio")' },
|
|
21
|
+
],
|
|
22
|
+
columns: [
|
|
23
|
+
'name', 'latestVersion', 'description', 'downloads', 'recentDownloads', 'versions',
|
|
24
|
+
'license', 'homepage', 'documentation', 'repository', 'keywords', 'categories', 'created', 'updated', 'url',
|
|
25
|
+
],
|
|
26
|
+
func: async (args) => {
|
|
27
|
+
const name = requireCrateName(args.name);
|
|
28
|
+
const body = await cratesFetch(`${CRATES_BASE}/api/v1/crates/${encodeURIComponent(name)}`, `crates crate ${name}`);
|
|
29
|
+
const c = body?.crate;
|
|
30
|
+
if (!c || !c.id) {
|
|
31
|
+
throw new EmptyResultError('crates crate', `crates.io returned no metadata for "${name}".`);
|
|
32
|
+
}
|
|
33
|
+
const versions = Array.isArray(body.versions) ? body.versions : [];
|
|
34
|
+
const latestRow = versions.find((v) => v.num === c.newest_version)
|
|
35
|
+
|| versions.find((v) => v.num === c.max_stable_version)
|
|
36
|
+
|| versions[0]
|
|
37
|
+
|| {};
|
|
38
|
+
const keywords = Array.isArray(body.keywords)
|
|
39
|
+
? body.keywords.map((k) => k?.keyword || k?.id || '').filter(Boolean).join(', ')
|
|
40
|
+
: '';
|
|
41
|
+
const categories = Array.isArray(body.categories)
|
|
42
|
+
? body.categories.map((cat) => cat?.category || cat?.slug || '').filter(Boolean).join(', ')
|
|
43
|
+
: '';
|
|
44
|
+
return [{
|
|
45
|
+
name: String(c.name ?? c.id),
|
|
46
|
+
latestVersion: String(c.newest_version ?? c.max_stable_version ?? c.max_version ?? ''),
|
|
47
|
+
description: String(c.description ?? '').trim(),
|
|
48
|
+
downloads: c.downloads != null ? Number(c.downloads) : null,
|
|
49
|
+
recentDownloads: c.recent_downloads != null ? Number(c.recent_downloads) : null,
|
|
50
|
+
versions: c.num_versions != null ? Number(c.num_versions) : versions.length,
|
|
51
|
+
license: String(latestRow.license ?? ''),
|
|
52
|
+
homepage: String(c.homepage ?? ''),
|
|
53
|
+
documentation: String(c.documentation ?? ''),
|
|
54
|
+
repository: String(c.repository ?? ''),
|
|
55
|
+
keywords,
|
|
56
|
+
categories,
|
|
57
|
+
created: String(c.created_at ?? '').slice(0, 10),
|
|
58
|
+
updated: String(c.updated_at ?? '').slice(0, 10),
|
|
59
|
+
url: `https://crates.io/crates/${c.name ?? c.id}`,
|
|
60
|
+
}];
|
|
61
|
+
},
|
|
62
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// crates search — search the crates.io registry by free-text query.
|
|
2
|
+
//
|
|
3
|
+
// Hits `https://crates.io/api/v1/crates?q=…&per_page=…`. Returns name (round-
|
|
4
|
+
// trips into `crates crate`), latest version, description, downloads, recent
|
|
5
|
+
// downloads, repository, last-update.
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
8
|
+
import { CRATES_BASE, cratesFetch, requireBoundedInt, requireString } from './utils.js';
|
|
9
|
+
|
|
10
|
+
cli({
|
|
11
|
+
site: 'crates',
|
|
12
|
+
name: 'search',
|
|
13
|
+
access: 'read',
|
|
14
|
+
description: 'Search the public crates.io registry by keyword',
|
|
15
|
+
domain: 'crates.io',
|
|
16
|
+
strategy: Strategy.PUBLIC,
|
|
17
|
+
browser: false,
|
|
18
|
+
args: [
|
|
19
|
+
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "serde", "async runtime")' },
|
|
20
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results (1-100)' },
|
|
21
|
+
],
|
|
22
|
+
columns: ['rank', 'name', 'latestVersion', 'description', 'downloads', 'recentDownloads', 'repository', 'updated', 'url'],
|
|
23
|
+
func: async (args) => {
|
|
24
|
+
const query = requireString(args.query, 'query');
|
|
25
|
+
const limit = requireBoundedInt(args.limit, 20, 100);
|
|
26
|
+
const url = `${CRATES_BASE}/api/v1/crates?q=${encodeURIComponent(query)}&per_page=${limit}`;
|
|
27
|
+
const body = await cratesFetch(url, 'crates search');
|
|
28
|
+
const list = Array.isArray(body?.crates) ? body.crates : [];
|
|
29
|
+
if (!list.length) {
|
|
30
|
+
throw new EmptyResultError('crates search', `No crates.io results matched "${query}".`);
|
|
31
|
+
}
|
|
32
|
+
return list.slice(0, limit).map((c, i) => ({
|
|
33
|
+
rank: i + 1,
|
|
34
|
+
name: String(c.name ?? c.id ?? ''),
|
|
35
|
+
latestVersion: String(c.newest_version ?? c.max_stable_version ?? c.max_version ?? ''),
|
|
36
|
+
description: String(c.description ?? '').trim(),
|
|
37
|
+
downloads: c.downloads != null ? Number(c.downloads) : null,
|
|
38
|
+
recentDownloads: c.recent_downloads != null ? Number(c.recent_downloads) : null,
|
|
39
|
+
repository: String(c.repository ?? c.homepage ?? ''),
|
|
40
|
+
updated: String(c.updated_at ?? '').slice(0, 10),
|
|
41
|
+
url: c.name ? `https://crates.io/crates/${c.name}` : '',
|
|
42
|
+
}));
|
|
43
|
+
},
|
|
44
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Shared helpers for the crates.io adapters.
|
|
2
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
export const CRATES_BASE = 'https://crates.io';
|
|
5
|
+
const UA = 'opencli-crates-adapter (+https://github.com/jackwener/opencli)';
|
|
6
|
+
|
|
7
|
+
// crates.io crate names: 1-64 chars, ascii letters/digits/-_, must start with a letter.
|
|
8
|
+
const CRATE_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
9
|
+
|
|
10
|
+
export function requireString(value, label) {
|
|
11
|
+
const s = String(value ?? '').trim();
|
|
12
|
+
if (!s) throw new ArgumentError(`crates ${label} cannot be empty`);
|
|
13
|
+
return s;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function requireCrateName(value) {
|
|
17
|
+
const s = String(value ?? '').trim();
|
|
18
|
+
if (!s) throw new ArgumentError('crates crate name is required (e.g. "serde", "tokio")');
|
|
19
|
+
if (!CRATE_NAME.test(s)) {
|
|
20
|
+
throw new ArgumentError(
|
|
21
|
+
`crates crate name "${value}" is not a valid crates.io name`,
|
|
22
|
+
'Names start with an ASCII letter, then 0-63 chars of letters / digits / "_-".',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
|
|
29
|
+
const raw = value ?? defaultValue;
|
|
30
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
31
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
32
|
+
throw new ArgumentError(`crates ${label} must be a positive integer`);
|
|
33
|
+
}
|
|
34
|
+
if (n > maxValue) {
|
|
35
|
+
throw new ArgumentError(`crates ${label} must be <= ${maxValue}`);
|
|
36
|
+
}
|
|
37
|
+
return n;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function cratesFetch(url, label) {
|
|
41
|
+
let resp;
|
|
42
|
+
try {
|
|
43
|
+
// crates.io requires a descriptive User-Agent per https://crates.io/data-access
|
|
44
|
+
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
throw new CommandExecutionError(
|
|
48
|
+
`${label} request failed: ${err?.message ?? err}`,
|
|
49
|
+
'Check that crates.io is reachable from this network.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (resp.status === 404) {
|
|
53
|
+
throw new EmptyResultError(label, `crates.io returned 404 for ${url}.`);
|
|
54
|
+
}
|
|
55
|
+
if (resp.status === 429) {
|
|
56
|
+
throw new CommandExecutionError(
|
|
57
|
+
`${label} returned HTTP 429 (rate limited)`,
|
|
58
|
+
'crates.io rate-limits unauthenticated traffic; wait a few seconds and retry.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (!resp.ok) {
|
|
62
|
+
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
|
|
63
|
+
}
|
|
64
|
+
let body;
|
|
65
|
+
try {
|
|
66
|
+
body = await resp.json();
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
|
|
70
|
+
}
|
|
71
|
+
return body;
|
|
72
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './search.js';
|
|
4
|
+
import './hotel-suggest.js';
|
|
5
|
+
import { buildUrl, mapSuggestRow, parseLimit, pickCoords } from './utils.js';
|
|
6
|
+
|
|
7
|
+
function ok(payload) {
|
|
8
|
+
return new Response(JSON.stringify(payload), { status: 200 });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const SHANGHAI_CITY = {
|
|
12
|
+
id: '2', type: 'City', word: '上海', cityId: 2, cityName: '上海',
|
|
13
|
+
provinceName: '上海', countryName: '中国', cityEName: 'Shanghai',
|
|
14
|
+
countryEName: 'China', displayName: '上海, 中国', displayType: '城市',
|
|
15
|
+
eName: 'Shanghai', commentScore: 0,
|
|
16
|
+
lat: 0, lon: 0, gLat: 0, gLon: 0, gdLat: 31.2304, gdLon: 121.4737,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const FORBIDDEN_CITY = {
|
|
20
|
+
id: '4189051', type: 'Markland', word: '故宫博物院', cityId: 1, cityName: '北京',
|
|
21
|
+
provinceName: '北京', countryName: '中国', displayName: '故宫博物院, 北京, 中国',
|
|
22
|
+
displayType: '地标', eName: 'The Palace Museum', commentScore: 4.8, cStar: 0,
|
|
23
|
+
lat: 0, lon: 0, gLat: 0, gLon: 0, gdLat: 39.9177, gdLon: 116.397,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const HANOI_LANDMARK = {
|
|
27
|
+
id: '6790582', type: 'Markland', word: '升龙皇城', cityId: 286, cityName: '河内',
|
|
28
|
+
provinceName: '', countryName: '越南', displayName: '升龙皇城, 河内, 越南',
|
|
29
|
+
displayType: '地标', eName: 'Imperial Citadel of Thang Long', commentScore: 0,
|
|
30
|
+
lat: 0, lon: 0, gLat: 21.0352, gLon: 105.8403, gdLat: 0, gdLon: 0,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const HOTEL_ROW = {
|
|
34
|
+
id: '133133582', type: 'Hotel', word: '汉庭酒店上海陆家嘴店', cityId: 2,
|
|
35
|
+
cityName: '上海', provinceName: '上海', countryName: '中国',
|
|
36
|
+
displayName: '汉庭酒店上海陆家嘴店, 上海, 中国', displayType: '酒店',
|
|
37
|
+
cStar: 4.2, commentScore: 0,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('ctrip parseLimit', () => {
|
|
41
|
+
it('returns fallback for undefined / null / empty', () => {
|
|
42
|
+
expect(parseLimit(undefined)).toBe(15);
|
|
43
|
+
expect(parseLimit(null)).toBe(15);
|
|
44
|
+
expect(parseLimit('')).toBe(15);
|
|
45
|
+
});
|
|
46
|
+
it('accepts integers in [1, 50]', () => {
|
|
47
|
+
expect(parseLimit(1)).toBe(1);
|
|
48
|
+
expect(parseLimit(50)).toBe(50);
|
|
49
|
+
expect(parseLimit('25')).toBe(25);
|
|
50
|
+
});
|
|
51
|
+
it('rejects non-integer', () => {
|
|
52
|
+
expect(() => parseLimit('abc')).toThrow('--limit must be an integer');
|
|
53
|
+
expect(() => parseLimit(3.5)).toThrow('--limit must be an integer');
|
|
54
|
+
});
|
|
55
|
+
it('rejects out-of-range without silent clamp', () => {
|
|
56
|
+
expect(() => parseLimit(0)).toThrow('--limit must be between 1 and 50, got 0');
|
|
57
|
+
expect(() => parseLimit(51)).toThrow('--limit must be between 1 and 50, got 51');
|
|
58
|
+
expect(() => parseLimit(-3)).toThrow('--limit must be between 1 and 50');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('ctrip pickCoords', () => {
|
|
63
|
+
it('prefers gd coords (mainland) when present', () => {
|
|
64
|
+
expect(pickCoords(SHANGHAI_CITY)).toEqual({ lat: 31.2304, lon: 121.4737 });
|
|
65
|
+
});
|
|
66
|
+
it('falls back to g coords (international) when gd is zero', () => {
|
|
67
|
+
expect(pickCoords(HANOI_LANDMARK)).toEqual({ lat: 21.0352, lon: 105.8403 });
|
|
68
|
+
});
|
|
69
|
+
it('returns null/null when all coord variants are zero', () => {
|
|
70
|
+
expect(pickCoords(HOTEL_ROW)).toEqual({ lat: null, lon: null });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('ctrip buildUrl', () => {
|
|
75
|
+
it('constructs city URL', () => {
|
|
76
|
+
expect(buildUrl(SHANGHAI_CITY)).toBe('https://you.ctrip.com/place/%E4%B8%8A%E6%B5%B72.html');
|
|
77
|
+
});
|
|
78
|
+
it('constructs landmark URL', () => {
|
|
79
|
+
expect(buildUrl(FORBIDDEN_CITY)).toBe('https://you.ctrip.com/sight/%E5%8C%97%E4%BA%AC1/4189051.html');
|
|
80
|
+
});
|
|
81
|
+
it('constructs hotel URL', () => {
|
|
82
|
+
expect(buildUrl(HOTEL_ROW)).toBe('https://hotels.ctrip.com/hotels/detail/?hotelid=133133582');
|
|
83
|
+
});
|
|
84
|
+
it('returns null for unknown type rather than fabricating', () => {
|
|
85
|
+
expect(buildUrl({ type: 'WhoKnows', id: '1', cityId: 1, cityName: 'X' })).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('ctrip mapSuggestRow', () => {
|
|
90
|
+
it('preserves all geo / english / id columns (no silent column drop)', () => {
|
|
91
|
+
const row = mapSuggestRow(FORBIDDEN_CITY, 0);
|
|
92
|
+
expect(row).toEqual({
|
|
93
|
+
rank: 1,
|
|
94
|
+
id: '4189051',
|
|
95
|
+
type: 'Markland',
|
|
96
|
+
displayType: '地标',
|
|
97
|
+
name: '故宫博物院, 北京, 中国',
|
|
98
|
+
eName: 'The Palace Museum',
|
|
99
|
+
cityId: 1,
|
|
100
|
+
cityName: '北京',
|
|
101
|
+
provinceName: '北京',
|
|
102
|
+
countryName: '中国',
|
|
103
|
+
lat: 39.9177,
|
|
104
|
+
lon: 116.397,
|
|
105
|
+
score: 4.8,
|
|
106
|
+
url: 'https://you.ctrip.com/sight/%E5%8C%97%E4%BA%AC1/4189051.html',
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
it('uses cStar as score fallback when commentScore is 0', () => {
|
|
110
|
+
const row = mapSuggestRow({ ...FORBIDDEN_CITY, commentScore: 0, cStar: 4.5 }, 2);
|
|
111
|
+
expect(row.score).toBe(4.5);
|
|
112
|
+
});
|
|
113
|
+
it('returns null score when both commentScore and cStar are missing/zero', () => {
|
|
114
|
+
expect(mapSuggestRow(SHANGHAI_CITY, 0).score).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('ctrip search command (registry-level)', () => {
|
|
119
|
+
const cmd = getRegistry().get('ctrip/search');
|
|
120
|
+
beforeEach(() => vi.unstubAllGlobals());
|
|
121
|
+
|
|
122
|
+
it('declares Strategy.PUBLIC + browser:false + access:read', () => {
|
|
123
|
+
expect(cmd.access).toBe('read');
|
|
124
|
+
expect(cmd.browser).toBe(false);
|
|
125
|
+
expect(String(cmd.strategy)).toContain('public');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('maps live response with full column shape', async () => {
|
|
129
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(ok({
|
|
130
|
+
Result: true, ErrorCode: 0,
|
|
131
|
+
Response: { searchResults: [SHANGHAI_CITY, FORBIDDEN_CITY] },
|
|
132
|
+
}))));
|
|
133
|
+
const rows = await cmd.func({ query: '上海', limit: 5 });
|
|
134
|
+
expect(rows).toHaveLength(2);
|
|
135
|
+
expect(rows[0].cityId).toBe(2);
|
|
136
|
+
expect(rows[0].lat).toBeCloseTo(31.2304);
|
|
137
|
+
expect(rows[1].url).toContain('/sight/');
|
|
138
|
+
// shape parity: every row has every declared column key
|
|
139
|
+
for (const row of rows) {
|
|
140
|
+
for (const col of cmd.columns) expect(row).toHaveProperty(col);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('rejects empty query with ArgumentError', async () => {
|
|
145
|
+
await expect(cmd.func({ query: ' ', limit: 3 })).rejects.toThrow('Search keyword cannot be empty');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('surfaces fetch failures as typed FETCH_ERROR', async () => {
|
|
149
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('{}', { status: 503 }))));
|
|
150
|
+
await expect(cmd.func({ query: '上海', limit: 3 })).rejects.toMatchObject({
|
|
151
|
+
code: 'FETCH_ERROR',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('wraps network failures as typed FETCH_ERROR', async () => {
|
|
156
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new Error('socket hang up'))));
|
|
157
|
+
await expect(cmd.func({ query: '上海', limit: 3 })).rejects.toMatchObject({
|
|
158
|
+
code: 'FETCH_ERROR',
|
|
159
|
+
message: expect.stringContaining('socket hang up'),
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('wraps invalid JSON as typed COMMAND_EXEC', async () => {
|
|
164
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response('not json', { status: 200 }))));
|
|
165
|
+
await expect(cmd.func({ query: '上海', limit: 3 })).rejects.toMatchObject({
|
|
166
|
+
code: 'COMMAND_EXEC',
|
|
167
|
+
message: expect.stringContaining('invalid JSON'),
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('surfaces in-band Result=false as typed COMMAND_EXEC', async () => {
|
|
172
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(ok({
|
|
173
|
+
Result: false, ErrorCode: 17,
|
|
174
|
+
}))));
|
|
175
|
+
await expect(cmd.func({ query: '上海', limit: 3 })).rejects.toMatchObject({
|
|
176
|
+
code: 'COMMAND_EXEC',
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('surfaces empty results as EmptyResultError', async () => {
|
|
181
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(ok({
|
|
182
|
+
Result: true, ErrorCode: 0, Response: { searchResults: [] },
|
|
183
|
+
}))));
|
|
184
|
+
await expect(cmd.func({ query: '上海', limit: 3 })).rejects.toThrow('ctrip search returned no data');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('rejects --limit 0 / 51 with ArgumentError (no silent clamp)', async () => {
|
|
188
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(ok({
|
|
189
|
+
Result: true, ErrorCode: 0, Response: { searchResults: [SHANGHAI_CITY] },
|
|
190
|
+
}))));
|
|
191
|
+
await expect(cmd.func({ query: '上海', limit: 0 })).rejects.toThrow('--limit');
|
|
192
|
+
await expect(cmd.func({ query: '上海', limit: 51 })).rejects.toThrow('--limit');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('ctrip hotel-suggest command (registry-level)', () => {
|
|
197
|
+
const cmd = getRegistry().get('ctrip/hotel-suggest');
|
|
198
|
+
beforeEach(() => vi.unstubAllGlobals());
|
|
199
|
+
|
|
200
|
+
it('declares Strategy.PUBLIC + browser:false + access:read', () => {
|
|
201
|
+
expect(cmd.access).toBe('read');
|
|
202
|
+
expect(cmd.browser).toBe(false);
|
|
203
|
+
expect(String(cmd.strategy)).toContain('public');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('maps Hotel rows with hotel detail URL', async () => {
|
|
207
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(ok({
|
|
208
|
+
Result: true, ErrorCode: 0,
|
|
209
|
+
Response: { searchResults: [SHANGHAI_CITY, HOTEL_ROW] },
|
|
210
|
+
}))));
|
|
211
|
+
const rows = await cmd.func({ query: '汉庭', limit: 5 });
|
|
212
|
+
expect(rows).toHaveLength(2);
|
|
213
|
+
const hotel = rows.find((r) => r.type === 'Hotel');
|
|
214
|
+
expect(hotel.url).toBe('https://hotels.ctrip.com/hotels/detail/?hotelid=133133582');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('passes searchType=H to the upstream endpoint', async () => {
|
|
218
|
+
const fetchMock = vi.fn(() => Promise.resolve(ok({
|
|
219
|
+
Result: true, ErrorCode: 0,
|
|
220
|
+
Response: { searchResults: [HOTEL_ROW] },
|
|
221
|
+
})));
|
|
222
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
223
|
+
await cmd.func({ query: '汉庭', limit: 5 });
|
|
224
|
+
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
225
|
+
expect(body.searchType).toBe('H');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('surfaces empty hotel-context lookup as EmptyResultError', async () => {
|
|
229
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(ok({
|
|
230
|
+
Result: true, ErrorCode: 0, Response: { searchResults: [] },
|
|
231
|
+
}))));
|
|
232
|
+
await expect(cmd.func({ query: 'zzz', limit: 5 })).rejects.toThrow('ctrip hotel-suggest returned no data');
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 携程酒店 hotel-context suggest — public city/business-area/hotel lookup.
|
|
3
|
+
*
|
|
4
|
+
* Distinct from `ctrip/search` (destination): the same backing endpoint is
|
|
5
|
+
* called with `searchType=H`, surfacing Hotel and BusinessArea rows that the
|
|
6
|
+
* destination flavour does not return.
|
|
7
|
+
*/
|
|
8
|
+
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
9
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
10
|
+
import { fetchSuggest, mapSuggestRow, parseLimit } from './utils.js';
|
|
11
|
+
|
|
12
|
+
cli({
|
|
13
|
+
site: 'ctrip',
|
|
14
|
+
name: 'hotel-suggest',
|
|
15
|
+
access: 'read',
|
|
16
|
+
description: '搜索携程酒店上下文联想:城市、商圈、单酒店匹配',
|
|
17
|
+
strategy: Strategy.PUBLIC,
|
|
18
|
+
browser: false,
|
|
19
|
+
args: [
|
|
20
|
+
{ name: 'query', required: true, positional: true, help: 'Search keyword (city, business area, or hotel name)' },
|
|
21
|
+
{ name: 'limit', type: 'int', default: 15, help: 'Number of results (1-50)' },
|
|
22
|
+
],
|
|
23
|
+
columns: [
|
|
24
|
+
'rank', 'id', 'type', 'displayType', 'name', 'eName',
|
|
25
|
+
'cityId', 'cityName', 'provinceName', 'countryName',
|
|
26
|
+
'lat', 'lon', 'score', 'url',
|
|
27
|
+
],
|
|
28
|
+
func: async (kwargs) => {
|
|
29
|
+
const query = String(kwargs.query || '').trim();
|
|
30
|
+
if (!query) {
|
|
31
|
+
throw new ArgumentError('Search keyword cannot be empty');
|
|
32
|
+
}
|
|
33
|
+
const limit = parseLimit(kwargs.limit);
|
|
34
|
+
const raw = await fetchSuggest(query, 'H');
|
|
35
|
+
const rows = raw
|
|
36
|
+
.filter((item) => !!item && typeof item === 'object')
|
|
37
|
+
.slice(0, limit)
|
|
38
|
+
.map(mapSuggestRow)
|
|
39
|
+
.filter((row) => row.name);
|
|
40
|
+
if (!rows.length) {
|
|
41
|
+
throw new EmptyResultError('ctrip hotel-suggest', 'Try a city, business area, or hotel keyword such as "陆家嘴" or "汉庭酒店"');
|
|
42
|
+
}
|
|
43
|
+
return rows;
|
|
44
|
+
},
|
|
45
|
+
});
|
package/clis/ctrip/search.js
CHANGED
|
@@ -1,87 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 携程旅行 destination suggest — public city/landmark/scenic-spot lookup.
|
|
3
3
|
*/
|
|
4
|
-
import { ArgumentError,
|
|
4
|
+
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
5
5
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
if (!Number.isFinite(parsed))
|
|
9
|
-
return fallback;
|
|
10
|
-
return Math.max(1, Math.min(Math.floor(parsed), 50));
|
|
11
|
-
}
|
|
12
|
-
function mapSearchResults(results, limit) {
|
|
13
|
-
return results
|
|
14
|
-
.filter((item) => !!item && typeof item === 'object')
|
|
15
|
-
.slice(0, limit)
|
|
16
|
-
.map((item, index) => ({
|
|
17
|
-
rank: index + 1,
|
|
18
|
-
name: String(item.displayName || item.word || item.cityName || '').replace(/\s+/g, ' ').trim(),
|
|
19
|
-
type: String(item.displayType || item.type || '').replace(/\s+/g, ' ').trim(),
|
|
20
|
-
score: item.commentScore ?? item.cStar ?? '',
|
|
21
|
-
price: item.price ?? item.minPrice ?? '',
|
|
22
|
-
url: '',
|
|
23
|
-
}))
|
|
24
|
-
.filter((item) => item.name);
|
|
25
|
-
}
|
|
6
|
+
import { fetchSuggest, mapSuggestRow, parseLimit } from './utils.js';
|
|
7
|
+
|
|
26
8
|
cli({
|
|
27
9
|
site: 'ctrip',
|
|
28
10
|
name: 'search',
|
|
29
11
|
access: 'read',
|
|
30
|
-
description: '
|
|
12
|
+
description: '搜索携程目的地、景区、火车站和地标联想结果',
|
|
31
13
|
strategy: Strategy.PUBLIC,
|
|
32
14
|
browser: false,
|
|
33
15
|
args: [
|
|
34
|
-
{ name: 'query', required: true, positional: true, help: 'Search keyword (city
|
|
35
|
-
{ name: 'limit', type: 'int', default: 15, help: 'Number of results' },
|
|
16
|
+
{ name: 'query', required: true, positional: true, help: 'Search keyword (city, scenic spot, landmark)' },
|
|
17
|
+
{ name: 'limit', type: 'int', default: 15, help: 'Number of results (1-50)' },
|
|
18
|
+
],
|
|
19
|
+
columns: [
|
|
20
|
+
'rank', 'id', 'type', 'displayType', 'name', 'eName',
|
|
21
|
+
'cityId', 'cityName', 'provinceName', 'countryName',
|
|
22
|
+
'lat', 'lon', 'score', 'url',
|
|
36
23
|
],
|
|
37
|
-
columns: ['rank', 'name', 'type', 'score', 'price', 'url'],
|
|
38
24
|
func: async (kwargs) => {
|
|
39
25
|
const query = String(kwargs.query || '').trim();
|
|
40
26
|
if (!query) {
|
|
41
27
|
throw new ArgumentError('Search keyword cannot be empty');
|
|
42
28
|
}
|
|
43
|
-
const limit =
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
platform: 'online',
|
|
53
|
-
pageID: '102001',
|
|
54
|
-
head: {
|
|
55
|
-
Locale: 'zh-CN',
|
|
56
|
-
LocaleController: 'zh_cn',
|
|
57
|
-
Currency: 'CNY',
|
|
58
|
-
PageId: '102001',
|
|
59
|
-
clientID: 'opencli-ctrip-search',
|
|
60
|
-
group: 'ctrip',
|
|
61
|
-
Frontend: {
|
|
62
|
-
sessionID: 1,
|
|
63
|
-
pvid: 1,
|
|
64
|
-
},
|
|
65
|
-
HotelExtension: {
|
|
66
|
-
group: 'CTRIP',
|
|
67
|
-
WebpSupport: false,
|
|
68
|
-
},
|
|
69
|
-
},
|
|
70
|
-
}),
|
|
71
|
-
});
|
|
72
|
-
if (!response.ok) {
|
|
73
|
-
throw new CliError('FETCH_ERROR', `ctrip search failed with status ${response.status}`, 'Retry the command or verify ctrip.com is reachable');
|
|
74
|
-
}
|
|
75
|
-
const payload = await response.json();
|
|
76
|
-
const rawResults = Array.isArray(payload?.Response?.searchResults) ? payload.Response.searchResults : [];
|
|
77
|
-
const results = mapSearchResults(rawResults, limit);
|
|
78
|
-
if (!results.length) {
|
|
79
|
-
throw new EmptyResultError('ctrip search', 'Try a destination, scenic spot, or hotel keyword such as "苏州" or "朱家尖"');
|
|
29
|
+
const limit = parseLimit(kwargs.limit);
|
|
30
|
+
const raw = await fetchSuggest(query, 'D');
|
|
31
|
+
const rows = raw
|
|
32
|
+
.filter((item) => !!item && typeof item === 'object')
|
|
33
|
+
.slice(0, limit)
|
|
34
|
+
.map(mapSuggestRow)
|
|
35
|
+
.filter((row) => row.name);
|
|
36
|
+
if (!rows.length) {
|
|
37
|
+
throw new EmptyResultError('ctrip search', 'Try a destination, scenic spot, or landmark keyword such as "苏州" or "故宫"');
|
|
80
38
|
}
|
|
81
|
-
return
|
|
39
|
+
return rows;
|
|
82
40
|
},
|
|
83
41
|
});
|
|
84
|
-
export const __test__ = {
|
|
85
|
-
clampLimit,
|
|
86
|
-
mapSearchResults,
|
|
87
|
-
};
|