@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,409 @@
|
|
|
1
|
+
import { htmlToMarkdown } from '@jackwener/opencli/utils';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
export const QIANWEN_DOMAIN = 'www.qianwen.com';
|
|
5
|
+
export const QIANWEN_URL = 'https://www.qianwen.com/';
|
|
6
|
+
export const QIANWEN_API_DOMAIN = 'chat2-api.qianwen.com';
|
|
7
|
+
|
|
8
|
+
export const IS_VISIBLE_JS = `
|
|
9
|
+
const isVisible = (node) => {
|
|
10
|
+
if (!(node instanceof Element)) return false;
|
|
11
|
+
const style = window.getComputedStyle(node);
|
|
12
|
+
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
|
13
|
+
const rect = node.getBoundingClientRect();
|
|
14
|
+
return rect.width > 0 && rect.height > 0;
|
|
15
|
+
};
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const POLL_INTERVAL_SECONDS = 2;
|
|
19
|
+
const MIN_WAIT_MS = 6_000;
|
|
20
|
+
const STABLE_POLLS_REQUIRED = 2;
|
|
21
|
+
|
|
22
|
+
export function authRequired(detail) {
|
|
23
|
+
return new AuthRequiredError(QIANWEN_DOMAIN, detail || '请在浏览器里用千问 APP 扫码登录 qianwen.com 后再重试。');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeBooleanFlag(value, fallback = false) {
|
|
27
|
+
if (typeof value === 'boolean') return value;
|
|
28
|
+
if (value == null || value === '') return fallback;
|
|
29
|
+
const normalized = String(value).trim().toLowerCase();
|
|
30
|
+
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function isOnQianwen(page) {
|
|
34
|
+
const url = await page.evaluate('window.location.href').catch(() => '');
|
|
35
|
+
return typeof url === 'string' && url.includes('qianwen.com');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function ensureOnQianwen(page) {
|
|
39
|
+
if (await isOnQianwen(page)) return;
|
|
40
|
+
await page.goto(QIANWEN_URL);
|
|
41
|
+
await page.wait(2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function dismissLoginModal(page) {
|
|
45
|
+
return await page.evaluate(`(() => {
|
|
46
|
+
${IS_VISIBLE_JS}
|
|
47
|
+
const modal = document.querySelector('[role=alert-biz-modal]');
|
|
48
|
+
if (!modal || !isVisible(modal)) return { dismissed: false };
|
|
49
|
+
const close = modal.querySelector('[data-opencli-ref]:last-of-type')
|
|
50
|
+
|| modal.querySelector('svg')?.closest('[role=button], button, div[class*="close"]');
|
|
51
|
+
const closeCandidates = Array.from(modal.querySelectorAll('div, button, span'))
|
|
52
|
+
.filter((node) => node instanceof HTMLElement && isVisible(node))
|
|
53
|
+
.filter((node) => {
|
|
54
|
+
const cls = node.className || '';
|
|
55
|
+
if (typeof cls !== 'string') return false;
|
|
56
|
+
return /close|dismiss|cancel/i.test(cls) || node.getAttribute('aria-label') === '关闭';
|
|
57
|
+
});
|
|
58
|
+
const target = closeCandidates[0] || modal.querySelector('svg')?.parentElement;
|
|
59
|
+
if (target instanceof HTMLElement) {
|
|
60
|
+
target.click();
|
|
61
|
+
return { dismissed: true };
|
|
62
|
+
}
|
|
63
|
+
// Last resort: synth ESC key on document
|
|
64
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true }));
|
|
65
|
+
return { dismissed: true, method: 'escape' };
|
|
66
|
+
})()`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function hasLoginGate(page) {
|
|
70
|
+
const result = await page.evaluate(`(() => {
|
|
71
|
+
${IS_VISIBLE_JS}
|
|
72
|
+
const modal = document.querySelector('[role=alert-biz-modal]');
|
|
73
|
+
if (modal && isVisible(modal)) {
|
|
74
|
+
const iframe = modal.querySelector('iframe');
|
|
75
|
+
const src = iframe?.getAttribute('src') || '';
|
|
76
|
+
if (src.includes('passport.qianwen.com') || src.includes('login')) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
})()`);
|
|
80
|
+
return Boolean(result);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function isLoggedIn(page) {
|
|
84
|
+
const result = await page.evaluate(`(() => {
|
|
85
|
+
${IS_VISIBLE_JS}
|
|
86
|
+
const loginBtn = Array.from(document.querySelectorAll('button'))
|
|
87
|
+
.find((node) => (node.textContent || '').trim() === '登录' && isVisible(node));
|
|
88
|
+
if (loginBtn) return false;
|
|
89
|
+
const hint = Array.from(document.querySelectorAll('p'))
|
|
90
|
+
.find((node) => (node.textContent || '').includes('登录可同步历史对话'));
|
|
91
|
+
if (hint && isVisible(hint)) return false;
|
|
92
|
+
return true;
|
|
93
|
+
})()`);
|
|
94
|
+
return Boolean(result);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function getCurrentSessionId(page) {
|
|
98
|
+
const url = await page.evaluate('window.location.href').catch(() => '');
|
|
99
|
+
if (typeof url !== 'string') return '';
|
|
100
|
+
const match = url.match(/\/chat\/([A-Za-z0-9_-]+)/);
|
|
101
|
+
return match ? match[1] : '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const QIANWEN_SESSION_ID_RE = /^[a-f0-9]{32}$/i;
|
|
105
|
+
|
|
106
|
+
export function parseQianwenSessionId(input) {
|
|
107
|
+
const raw = String(input ?? '').trim();
|
|
108
|
+
if (!raw) {
|
|
109
|
+
throw new ArgumentError('id', 'must be a non-empty session ID or qianwen.com chat URL');
|
|
110
|
+
}
|
|
111
|
+
// Anchor the right-hand side so a 33+ hex URL does not silently truncate
|
|
112
|
+
// to its first 32 chars. Acceptable terminators: end-of-string, path slash,
|
|
113
|
+
// query string, or fragment. Without the boundary,
|
|
114
|
+
// `https://www.qianwen.com/chat/<33 hex>` would parse as a valid 32-char
|
|
115
|
+
// ID instead of being rejected — opening the wrong conversation is a
|
|
116
|
+
// worse failure mode than throwing.
|
|
117
|
+
const urlMatch = raw.match(/qianwen\.com\/chat\/([a-f0-9]{32})(?:[/?#]|$)/i);
|
|
118
|
+
const candidate = urlMatch ? urlMatch[1] : raw;
|
|
119
|
+
if (!QIANWEN_SESSION_ID_RE.test(candidate)) {
|
|
120
|
+
throw new ArgumentError(
|
|
121
|
+
'id',
|
|
122
|
+
`not a valid Qianwen session ID (got "${input}"); expected a 32-char hex ID like "abcd1234ef567890abcd1234ef567890" or a full https://www.qianwen.com/chat/<id> URL`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return candidate.toLowerCase();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function getModelLabel(page) {
|
|
129
|
+
const result = await page.evaluate(`(() => {
|
|
130
|
+
${IS_VISIBLE_JS}
|
|
131
|
+
const trigger = Array.from(document.querySelectorAll('[aria-haspopup=dialog]'))
|
|
132
|
+
.find((node) => isVisible(node) && (node.innerText || '').includes('Qwen'));
|
|
133
|
+
if (!trigger) return '';
|
|
134
|
+
const label = (trigger.innerText || '').split('\\n')[0].trim();
|
|
135
|
+
return label;
|
|
136
|
+
})()`);
|
|
137
|
+
return typeof result === 'string' ? result : '';
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function startNewChat(page) {
|
|
141
|
+
const result = await page.evaluate(`(() => {
|
|
142
|
+
${IS_VISIBLE_JS}
|
|
143
|
+
const button = Array.from(document.querySelectorAll('button'))
|
|
144
|
+
.find((node) => isVisible(node) && (node.innerText || '').trim() === '新建对话');
|
|
145
|
+
if (button instanceof HTMLElement) {
|
|
146
|
+
button.click();
|
|
147
|
+
return { ok: true, method: 'button' };
|
|
148
|
+
}
|
|
149
|
+
return { ok: false };
|
|
150
|
+
})()`);
|
|
151
|
+
if (result?.ok) {
|
|
152
|
+
await page.wait(1.5);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
await page.goto(QIANWEN_URL);
|
|
156
|
+
await page.wait(2);
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function getComposer(page) {
|
|
161
|
+
return await page.evaluate(`(() => {
|
|
162
|
+
${IS_VISIBLE_JS}
|
|
163
|
+
const editor = Array.from(document.querySelectorAll('[role=textbox][contenteditable=true]'))
|
|
164
|
+
.find((node) => isVisible(node));
|
|
165
|
+
return { found: !!editor, text: editor?.textContent || '' };
|
|
166
|
+
})()`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function sendMessage(page, prompt) {
|
|
170
|
+
return await page.evaluate(`(async () => {
|
|
171
|
+
${IS_VISIBLE_JS}
|
|
172
|
+
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
173
|
+
|
|
174
|
+
const editor = Array.from(document.querySelectorAll('[role=textbox][contenteditable=true]'))
|
|
175
|
+
.find((node) => isVisible(node));
|
|
176
|
+
if (!(editor instanceof HTMLElement)) {
|
|
177
|
+
return { ok: false, reason: 'Qianwen composer (contenteditable) not found.' };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
editor.focus();
|
|
181
|
+
// Clear existing content first
|
|
182
|
+
const selection = window.getSelection();
|
|
183
|
+
const range = document.createRange();
|
|
184
|
+
range.selectNodeContents(editor);
|
|
185
|
+
selection?.removeAllRanges();
|
|
186
|
+
selection?.addRange(range);
|
|
187
|
+
document.execCommand('delete', false);
|
|
188
|
+
await waitFor(100);
|
|
189
|
+
|
|
190
|
+
// Slate editor accepts content via beforeinput InputEvent
|
|
191
|
+
editor.dispatchEvent(new InputEvent('beforeinput', {
|
|
192
|
+
inputType: 'insertText',
|
|
193
|
+
data: ${JSON.stringify(prompt)},
|
|
194
|
+
bubbles: true,
|
|
195
|
+
cancelable: true,
|
|
196
|
+
}));
|
|
197
|
+
await waitFor(400);
|
|
198
|
+
|
|
199
|
+
const sendBtn = document.querySelector('button[aria-label="发送消息"]');
|
|
200
|
+
if (sendBtn instanceof HTMLElement && !sendBtn.disabled) {
|
|
201
|
+
sendBtn.click();
|
|
202
|
+
return { ok: true, action: 'click' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Fallback: dispatch Enter key
|
|
206
|
+
editor.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
|
207
|
+
editor.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
|
|
208
|
+
return { ok: true, action: 'enter-fallback' };
|
|
209
|
+
})()`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function getMessageBubbles(page) {
|
|
213
|
+
// Qianwen's chat DOM marks each turn with two siblings:
|
|
214
|
+
// [data-chat-question-wrap] - user message
|
|
215
|
+
// [data-chat-answers-wrap] - assistant message
|
|
216
|
+
// The earlier `[data-msgid]` selector matched citation cards inside
|
|
217
|
+
// assistant responses (which use `data-message-id`), so it would silently
|
|
218
|
+
// miss the actual chat turns after Qianwen reshipped its frontend.
|
|
219
|
+
//
|
|
220
|
+
// We walk both wrap selectors in DOM order to interleave Q/A correctly,
|
|
221
|
+
// and use the nearest sibling `data-req-id` (anchored on
|
|
222
|
+
// `.chat-msg-bottom-anchor`) as the stable per-turn identifier so polling
|
|
223
|
+
// dedupe + waitForAnswer's seenAssistantId tracking still work.
|
|
224
|
+
const result = await page.evaluate(`(() => {
|
|
225
|
+
${IS_VISIBLE_JS}
|
|
226
|
+
const wraps = Array.from(document.querySelectorAll('[data-chat-question-wrap], [data-chat-answers-wrap]'))
|
|
227
|
+
.filter((node) => node instanceof HTMLElement && isVisible(node));
|
|
228
|
+
|
|
229
|
+
const findTurnReqId = (node) => {
|
|
230
|
+
let parent = node.parentElement;
|
|
231
|
+
while (parent && parent !== document.body) {
|
|
232
|
+
const reqEl = parent.querySelector('[data-req-id]');
|
|
233
|
+
if (reqEl && reqEl.getAttribute('data-req-id')) {
|
|
234
|
+
return reqEl.getAttribute('data-req-id');
|
|
235
|
+
}
|
|
236
|
+
parent = parent.parentElement;
|
|
237
|
+
}
|
|
238
|
+
return '';
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const out = [];
|
|
242
|
+
let positional = 0;
|
|
243
|
+
for (const node of wraps) {
|
|
244
|
+
const isAnswer = node.hasAttribute('data-chat-answers-wrap');
|
|
245
|
+
const reqId = findTurnReqId(node);
|
|
246
|
+
const baseId = reqId || ('pos-' + positional);
|
|
247
|
+
const id = baseId + (isAnswer ? '-answer' : '-question');
|
|
248
|
+
positional += 1;
|
|
249
|
+
|
|
250
|
+
const contentNode = isAnswer
|
|
251
|
+
? (node.querySelector('#qk-markdown-react') || node.querySelector('[class*="markdown"]') || node)
|
|
252
|
+
: node;
|
|
253
|
+
const html = (contentNode instanceof HTMLElement) ? (contentNode.innerHTML || '') : '';
|
|
254
|
+
const text = (contentNode instanceof HTMLElement) ? (contentNode.innerText || contentNode.textContent || '') : '';
|
|
255
|
+
const role = isAnswer ? 'Assistant' : 'User';
|
|
256
|
+
out.push({ id, role, text: (text || '').replace(/\\s+/g, ' ').trim(), html });
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
})()`);
|
|
260
|
+
if (!Array.isArray(result)) return [];
|
|
261
|
+
return result
|
|
262
|
+
.map((item) => ({
|
|
263
|
+
id: String(item?.id || ''),
|
|
264
|
+
role: item?.role === 'Assistant' ? 'Assistant' : 'User',
|
|
265
|
+
text: String(item?.text || '').trim(),
|
|
266
|
+
html: String(item?.html || ''),
|
|
267
|
+
}))
|
|
268
|
+
.filter((item) => item.id && item.text);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function bubbleHtmlToMarkdown(html) {
|
|
272
|
+
try {
|
|
273
|
+
return htmlToMarkdown(html).trim();
|
|
274
|
+
} catch {
|
|
275
|
+
return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function stripNoise(text) {
|
|
280
|
+
return (text || '')
|
|
281
|
+
.replace(/\u00a0/g, ' ')
|
|
282
|
+
.replace(/复制\s*$/g, '')
|
|
283
|
+
.replace(/重新生成\s*$/g, '')
|
|
284
|
+
.trim();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function waitForAnswer(page, prompt, timeoutSeconds) {
|
|
288
|
+
const startTime = Date.now();
|
|
289
|
+
let previousText = '';
|
|
290
|
+
let stableCount = 0;
|
|
291
|
+
let lastCandidate = '';
|
|
292
|
+
let seenAssistantId = '';
|
|
293
|
+
|
|
294
|
+
while (Date.now() - startTime < timeoutSeconds * 1000) {
|
|
295
|
+
await page.wait(POLL_INTERVAL_SECONDS);
|
|
296
|
+
|
|
297
|
+
if (await hasLoginGate(page)) {
|
|
298
|
+
return { status: 'auth_required' };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const bubbles = await getMessageBubbles(page);
|
|
302
|
+
const lastAssistant = [...bubbles].reverse().find((b) => b.role === 'Assistant');
|
|
303
|
+
if (!lastAssistant) continue;
|
|
304
|
+
|
|
305
|
+
const text = stripNoise(lastAssistant.text);
|
|
306
|
+
if (!text || text === prompt) continue;
|
|
307
|
+
|
|
308
|
+
if (!seenAssistantId) seenAssistantId = lastAssistant.id;
|
|
309
|
+
lastCandidate = text;
|
|
310
|
+
|
|
311
|
+
const waitedLongEnough = Date.now() - startTime >= MIN_WAIT_MS;
|
|
312
|
+
if (text === previousText) {
|
|
313
|
+
stableCount += 1;
|
|
314
|
+
if (waitedLongEnough && stableCount >= STABLE_POLLS_REQUIRED) {
|
|
315
|
+
return { status: 'ok', assistant: lastAssistant };
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
previousText = text;
|
|
319
|
+
stableCount = 0;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (lastCandidate) {
|
|
324
|
+
const bubbles = await getMessageBubbles(page);
|
|
325
|
+
const lastAssistant = [...bubbles].reverse().find((b) => b.role === 'Assistant');
|
|
326
|
+
return { status: 'partial', assistant: lastAssistant };
|
|
327
|
+
}
|
|
328
|
+
return { status: 'timeout' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const FEATURE_LABELS = {
|
|
332
|
+
think: '深度思考',
|
|
333
|
+
research: '深度研究',
|
|
334
|
+
task: '任务助理',
|
|
335
|
+
image: 'AI生图',
|
|
336
|
+
ppt: 'PPT创作',
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export async function setFeatureToggle(page, feature, enabled) {
|
|
340
|
+
const label = FEATURE_LABELS[feature];
|
|
341
|
+
if (!label) return false;
|
|
342
|
+
const result = await page.evaluate(`(async () => {
|
|
343
|
+
${IS_VISIBLE_JS}
|
|
344
|
+
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
345
|
+
const label = ${JSON.stringify(label)};
|
|
346
|
+
const button = Array.from(document.querySelectorAll('button[aria-label]'))
|
|
347
|
+
.find((node) => isVisible(node) && node.getAttribute('aria-label') === label);
|
|
348
|
+
if (!(button instanceof HTMLElement)) return { found: false };
|
|
349
|
+
const selected = button.getAttribute('aria-pressed') === 'true'
|
|
350
|
+
|| /active|selected|bg-primary/.test(button.className || '');
|
|
351
|
+
if (selected === ${Boolean(enabled)}) return { found: true, changed: false, selected };
|
|
352
|
+
button.click();
|
|
353
|
+
await waitFor(300);
|
|
354
|
+
return { found: true, changed: true };
|
|
355
|
+
})()`);
|
|
356
|
+
return Boolean(result?.found);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export async function getSessionListFromApi(page, limit = 30) {
|
|
360
|
+
const pageSize = Number(limit ?? 30);
|
|
361
|
+
if (!Number.isInteger(pageSize) || pageSize <= 0 || pageSize > 100) {
|
|
362
|
+
throw new CommandExecutionError('Qianwen history page_size must be an integer between 1 and 100');
|
|
363
|
+
}
|
|
364
|
+
const result = await page.evaluate(`(async () => {
|
|
365
|
+
try {
|
|
366
|
+
const utdid = (document.cookie.match(/(?:^|;\\s*)b-user-id=([^;]+)/)?.[1])
|
|
367
|
+
|| (document.cookie.match(/(?:^|;\\s*)utdid=([^;]+)/)?.[1])
|
|
368
|
+
|| '';
|
|
369
|
+
const query = new URLSearchParams({
|
|
370
|
+
biz_id: 'ai_qwen',
|
|
371
|
+
chat_client: 'h5',
|
|
372
|
+
device: 'pc',
|
|
373
|
+
fr: 'pc',
|
|
374
|
+
pr: 'qwen',
|
|
375
|
+
ut: utdid,
|
|
376
|
+
la: 'zh-CN',
|
|
377
|
+
tz: 'Asia/Shanghai',
|
|
378
|
+
ve: '2.4.9',
|
|
379
|
+
}).toString();
|
|
380
|
+
const res = await fetch('https://${QIANWEN_API_DOMAIN}/api/v2/session/page/list?' + query, {
|
|
381
|
+
method: 'POST',
|
|
382
|
+
credentials: 'include',
|
|
383
|
+
headers: { 'Content-Type': 'application/json' },
|
|
384
|
+
body: JSON.stringify({ page_num: 1, page_size: ${pageSize}, page_no: 1 }),
|
|
385
|
+
});
|
|
386
|
+
const text = await res.text();
|
|
387
|
+
let body = null;
|
|
388
|
+
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
|
389
|
+
return { ok: res.ok, status: res.status, body, utdid };
|
|
390
|
+
} catch (error) {
|
|
391
|
+
return { ok: false, status: 0, error: String(error?.message || error) };
|
|
392
|
+
}
|
|
393
|
+
})()`);
|
|
394
|
+
if (!result || !result.ok) {
|
|
395
|
+
return { ok: false, status: result?.status || 0, error: result?.error || '', sessions: [] };
|
|
396
|
+
}
|
|
397
|
+
const data = result.body?.data || result.body?.result || {};
|
|
398
|
+
const rawList = Array.isArray(data?.list) ? data.list
|
|
399
|
+
: Array.isArray(data?.items) ? data.items
|
|
400
|
+
: Array.isArray(data?.page_list) ? data.page_list
|
|
401
|
+
: Array.isArray(result.body?.list) ? result.body.list
|
|
402
|
+
: [];
|
|
403
|
+
const sessions = rawList.map((item) => ({
|
|
404
|
+
id: String(item?.session_id || item?.sessionId || item?.id || ''),
|
|
405
|
+
title: String(item?.title || item?.name || item?.summary || '').trim(),
|
|
406
|
+
updated_at: Number(item?.updated_at || item?.last_req_timestamp || item?.updatedAt || item?.gmt_modified || item?.update_time || 0),
|
|
407
|
+
})).filter((item) => item.id);
|
|
408
|
+
return { ok: true, status: result.status, sessions };
|
|
409
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { parseQianwenSessionId } from './utils.js';
|
|
4
|
+
|
|
5
|
+
describe('qwen parseQianwenSessionId', () => {
|
|
6
|
+
const id = 'abcd1234ef567890abcd1234ef567890';
|
|
7
|
+
|
|
8
|
+
it('returns a bare 32-char hex ID unchanged', () => {
|
|
9
|
+
expect(parseQianwenSessionId(id)).toBe(id);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('lowercases an upper-case ID', () => {
|
|
13
|
+
expect(parseQianwenSessionId(id.toUpperCase())).toBe(id);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('extracts the session ID from a full qianwen.com chat URL', () => {
|
|
17
|
+
expect(parseQianwenSessionId(`https://www.qianwen.com/chat/${id}`)).toBe(id);
|
|
18
|
+
expect(parseQianwenSessionId(`https://www.qianwen.com/chat/${id}?from=share`)).toBe(id);
|
|
19
|
+
expect(parseQianwenSessionId(`http://qianwen.com/chat/${id}`)).toBe(id);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('throws ArgumentError on empty input', () => {
|
|
23
|
+
expect(() => parseQianwenSessionId('')).toThrow(ArgumentError);
|
|
24
|
+
expect(() => parseQianwenSessionId(null)).toThrow(ArgumentError);
|
|
25
|
+
expect(() => parseQianwenSessionId(undefined)).toThrow(ArgumentError);
|
|
26
|
+
expect(() => parseQianwenSessionId(' ')).toThrow(ArgumentError);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('throws ArgumentError on non-hex input', () => {
|
|
30
|
+
expect(() => parseQianwenSessionId('not-an-id')).toThrow(ArgumentError);
|
|
31
|
+
expect(() => parseQianwenSessionId('123')).toThrow(ArgumentError);
|
|
32
|
+
// 32 chars but not all hex
|
|
33
|
+
expect(() => parseQianwenSessionId('zbcd1234ef567890abcd1234ef567890')).toThrow(ArgumentError);
|
|
34
|
+
// 31 hex chars — too short
|
|
35
|
+
expect(() => parseQianwenSessionId('abcd1234ef567890abcd1234ef56789')).toThrow(ArgumentError);
|
|
36
|
+
// 33 hex chars — too long
|
|
37
|
+
expect(() => parseQianwenSessionId('abcd1234ef567890abcd1234ef5678900')).toThrow(ArgumentError);
|
|
38
|
+
// URL with the wrong path shape must not silently fall through.
|
|
39
|
+
expect(() => parseQianwenSessionId('https://www.qianwen.com/somewhere/else')).toThrow(ArgumentError);
|
|
40
|
+
// URL embedding a 33+ hex tail must not silently truncate to 32 chars
|
|
41
|
+
// and open the wrong conversation.
|
|
42
|
+
expect(() => parseQianwenSessionId(`https://www.qianwen.com/chat/${id}0`)).toThrow(ArgumentError);
|
|
43
|
+
expect(() => parseQianwenSessionId(`https://www.qianwen.com/chat/${id}abc`)).toThrow(ArgumentError);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// rest-countries country — look up a country by common / official name.
|
|
2
|
+
//
|
|
3
|
+
// REST Countries' `name/<query>` endpoint matches as substring across both
|
|
4
|
+
// common and official names; multiple matches are returned (e.g. "guinea"
|
|
5
|
+
// matches Guinea, Guinea-Bissau, Equatorial Guinea, Papua New Guinea).
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
8
|
+
import {
|
|
9
|
+
COUNTRY_FIELDS,
|
|
10
|
+
REST_COUNTRIES_BASE,
|
|
11
|
+
projectCountry,
|
|
12
|
+
requireBoundedInt,
|
|
13
|
+
requireString,
|
|
14
|
+
restCountriesFetch,
|
|
15
|
+
} from './utils.js';
|
|
16
|
+
|
|
17
|
+
cli({
|
|
18
|
+
site: 'rest-countries',
|
|
19
|
+
name: 'country',
|
|
20
|
+
access: 'read',
|
|
21
|
+
description: 'Look up countries by name (common / official, substring match)',
|
|
22
|
+
domain: 'restcountries.com',
|
|
23
|
+
strategy: Strategy.PUBLIC,
|
|
24
|
+
browser: false,
|
|
25
|
+
args: [
|
|
26
|
+
{ name: 'name', positional: true, required: true, help: 'Country name (e.g. "japan", "united kingdom")' },
|
|
27
|
+
{ name: 'limit', type: 'int', default: 25, help: 'Max rows (1-250)' },
|
|
28
|
+
],
|
|
29
|
+
columns: [
|
|
30
|
+
'rank',
|
|
31
|
+
'commonName',
|
|
32
|
+
'officialName',
|
|
33
|
+
'cca2',
|
|
34
|
+
'cca3',
|
|
35
|
+
'ccn3',
|
|
36
|
+
'capital',
|
|
37
|
+
'region',
|
|
38
|
+
'subregion',
|
|
39
|
+
'population',
|
|
40
|
+
'area',
|
|
41
|
+
'languages',
|
|
42
|
+
'currencies',
|
|
43
|
+
'latitude',
|
|
44
|
+
'longitude',
|
|
45
|
+
'timezones',
|
|
46
|
+
'independent',
|
|
47
|
+
'unMember',
|
|
48
|
+
'landlocked',
|
|
49
|
+
'flag',
|
|
50
|
+
'url',
|
|
51
|
+
],
|
|
52
|
+
func: async (args) => {
|
|
53
|
+
const name = requireString(args.name, 'name');
|
|
54
|
+
const limit = requireBoundedInt(args.limit, 25, 250);
|
|
55
|
+
const url = `${REST_COUNTRIES_BASE}/name/${encodeURIComponent(name)}?fields=${COUNTRY_FIELDS}`;
|
|
56
|
+
const body = await restCountriesFetch(url, 'rest-countries country');
|
|
57
|
+
const list = Array.isArray(body) ? body : [];
|
|
58
|
+
if (!list.length) {
|
|
59
|
+
throw new EmptyResultError('rest-countries country', `No countries matched "${name}".`);
|
|
60
|
+
}
|
|
61
|
+
// Sort by population descending so the most "expected" hit is first.
|
|
62
|
+
const sorted = [...list].sort((a, b) => (b?.population ?? 0) - (a?.population ?? 0));
|
|
63
|
+
return sorted.slice(0, limit).map((c, i) => ({ rank: i + 1, ...projectCountry(c) }));
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// rest-countries region — list every country in a region.
|
|
2
|
+
//
|
|
3
|
+
// Region values: africa / americas / asia / europe / oceania / antarctic.
|
|
4
|
+
// Subregions ("eastern asia") are not supported by this command — they go
|
|
5
|
+
// through the v3.1 `subregion/` endpoint which behaves identically.
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
8
|
+
import {
|
|
9
|
+
COUNTRY_FIELDS,
|
|
10
|
+
REST_COUNTRIES_BASE,
|
|
11
|
+
projectCountry,
|
|
12
|
+
requireBoundedInt,
|
|
13
|
+
requireRegion,
|
|
14
|
+
restCountriesFetch,
|
|
15
|
+
} from './utils.js';
|
|
16
|
+
|
|
17
|
+
cli({
|
|
18
|
+
site: 'rest-countries',
|
|
19
|
+
name: 'region',
|
|
20
|
+
access: 'read',
|
|
21
|
+
description: 'List countries in a region (africa / americas / asia / europe / oceania / antarctic)',
|
|
22
|
+
domain: 'restcountries.com',
|
|
23
|
+
strategy: Strategy.PUBLIC,
|
|
24
|
+
browser: false,
|
|
25
|
+
args: [
|
|
26
|
+
{ name: 'region', positional: true, required: true, help: 'Region name (case-insensitive)' },
|
|
27
|
+
{ name: 'limit', type: 'int', default: 250, help: 'Max rows (1-250)' },
|
|
28
|
+
],
|
|
29
|
+
columns: [
|
|
30
|
+
'rank',
|
|
31
|
+
'commonName',
|
|
32
|
+
'officialName',
|
|
33
|
+
'cca2',
|
|
34
|
+
'cca3',
|
|
35
|
+
'ccn3',
|
|
36
|
+
'capital',
|
|
37
|
+
'region',
|
|
38
|
+
'subregion',
|
|
39
|
+
'population',
|
|
40
|
+
'area',
|
|
41
|
+
'languages',
|
|
42
|
+
'currencies',
|
|
43
|
+
'latitude',
|
|
44
|
+
'longitude',
|
|
45
|
+
'timezones',
|
|
46
|
+
'independent',
|
|
47
|
+
'unMember',
|
|
48
|
+
'landlocked',
|
|
49
|
+
'flag',
|
|
50
|
+
'url',
|
|
51
|
+
],
|
|
52
|
+
func: async (args) => {
|
|
53
|
+
const region = requireRegion(args.region);
|
|
54
|
+
const limit = requireBoundedInt(args.limit, 250, 250);
|
|
55
|
+
const url = `${REST_COUNTRIES_BASE}/region/${encodeURIComponent(region)}?fields=${COUNTRY_FIELDS}`;
|
|
56
|
+
const body = await restCountriesFetch(url, 'rest-countries region');
|
|
57
|
+
const list = Array.isArray(body) ? body : [];
|
|
58
|
+
if (!list.length) {
|
|
59
|
+
throw new EmptyResultError('rest-countries region', `No countries returned for region "${region}".`);
|
|
60
|
+
}
|
|
61
|
+
const sorted = [...list].sort((a, b) => (b?.population ?? 0) - (a?.population ?? 0));
|
|
62
|
+
return sorted.slice(0, limit).map((c, i) => ({ rank: i + 1, ...projectCountry(c) }));
|
|
63
|
+
},
|
|
64
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './country.js';
|
|
5
|
+
import './region.js';
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('rest-countries country adapter', () => {
|
|
13
|
+
const cmd = getRegistry().get('rest-countries/country');
|
|
14
|
+
|
|
15
|
+
it('rejects bad args before fetching', async () => {
|
|
16
|
+
const fetchMock = vi.fn();
|
|
17
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
18
|
+
await expect(cmd.func({ name: '' })).rejects.toThrow(ArgumentError);
|
|
19
|
+
await expect(cmd.func({ name: 'japan', limit: 9999 })).rejects.toThrow(ArgumentError);
|
|
20
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('maps HTTP 429 to CommandExecutionError', async () => {
|
|
24
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('throttled', { status: 429 })));
|
|
25
|
+
await expect(cmd.func({ name: 'japan' })).rejects.toThrow(CommandExecutionError);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('maps HTTP 404 to EmptyResultError', async () => {
|
|
29
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not found', { status: 404 })));
|
|
30
|
+
await expect(cmd.func({ name: 'no-country-by-this-name' })).rejects.toThrow(EmptyResultError);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('round-trips cca3 from row into restcountries.com alpha URL', async () => {
|
|
34
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([
|
|
35
|
+
{
|
|
36
|
+
name: { common: 'Japan', official: 'Japan' },
|
|
37
|
+
cca2: 'JP', cca3: 'JPN', ccn3: '392',
|
|
38
|
+
capital: ['Tokyo'],
|
|
39
|
+
region: 'Asia', subregion: 'Eastern Asia',
|
|
40
|
+
population: 125000000, area: 377000,
|
|
41
|
+
languages: { jpn: 'Japanese' },
|
|
42
|
+
currencies: { JPY: { name: 'Japanese yen', symbol: '¥' } },
|
|
43
|
+
latlng: [36, 138], timezones: ['UTC+09:00'], independent: true, unMember: true, landlocked: false, flag: '🇯🇵',
|
|
44
|
+
},
|
|
45
|
+
]), { status: 200 })));
|
|
46
|
+
const rows = await cmd.func({ name: 'japan', limit: 5 });
|
|
47
|
+
expect(rows[0]).toMatchObject({
|
|
48
|
+
rank: 1, commonName: 'Japan', cca2: 'JP', cca3: 'JPN',
|
|
49
|
+
capital: 'Tokyo', region: 'Asia',
|
|
50
|
+
languages: 'Japanese', currencies: 'JPY (Japanese yen)',
|
|
51
|
+
latitude: 36, longitude: 138,
|
|
52
|
+
url: 'https://restcountries.com/v3.1/alpha/jpn',
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('rest-countries region adapter', () => {
|
|
58
|
+
const cmd = getRegistry().get('rest-countries/region');
|
|
59
|
+
|
|
60
|
+
it('rejects unknown regions before fetching', async () => {
|
|
61
|
+
const fetchMock = vi.fn();
|
|
62
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
63
|
+
await expect(cmd.func({ region: '' })).rejects.toThrow(ArgumentError);
|
|
64
|
+
await expect(cmd.func({ region: 'middle-earth' })).rejects.toThrow(ArgumentError);
|
|
65
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('throws EmptyResultError on empty payload', async () => {
|
|
69
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([]), { status: 200 })));
|
|
70
|
+
await expect(cmd.func({ region: 'oceania' })).rejects.toThrow(EmptyResultError);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('sorts by population descending so largest country is rank 1', async () => {
|
|
74
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([
|
|
75
|
+
{ name: { common: 'Tuvalu' }, cca3: 'TUV', population: 12000 },
|
|
76
|
+
{ name: { common: 'Australia' }, cca3: 'AUS', population: 26000000 },
|
|
77
|
+
{ name: { common: 'Fiji' }, cca3: 'FJI', population: 900000 },
|
|
78
|
+
]), { status: 200 })));
|
|
79
|
+
const rows = await cmd.func({ region: 'oceania' });
|
|
80
|
+
expect(rows.map((r) => r.commonName)).toEqual(['Australia', 'Fiji', 'Tuvalu']);
|
|
81
|
+
expect(rows[0]).toMatchObject({ cca3: 'AUS', url: 'https://restcountries.com/v3.1/alpha/aus' });
|
|
82
|
+
});
|
|
83
|
+
});
|