@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,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 './search.js';
|
|
5
|
+
import './entity.js';
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('wikidata search adapter', () => {
|
|
13
|
+
const cmd = getRegistry().get('wikidata/search');
|
|
14
|
+
|
|
15
|
+
it('rejects bad args before fetching', async () => {
|
|
16
|
+
const fetchMock = vi.fn();
|
|
17
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
18
|
+
await expect(cmd.func({ query: '' })).rejects.toThrow(ArgumentError);
|
|
19
|
+
await expect(cmd.func({ query: 'foo', limit: 999 })).rejects.toThrow(ArgumentError);
|
|
20
|
+
await expect(cmd.func({ query: 'foo', language: '!!' })).rejects.toThrow(ArgumentError);
|
|
21
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('maps HTTP 429 to CommandExecutionError', async () => {
|
|
25
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('throttled', { status: 429 })));
|
|
26
|
+
await expect(cmd.func({ query: 'einstein', limit: 5 })).rejects.toThrow(CommandExecutionError);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('throws EmptyResultError on empty search list', async () => {
|
|
30
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ search: [] }), { status: 200 })));
|
|
31
|
+
await expect(cmd.func({ query: 'no-such-thing', limit: 5 })).rejects.toThrow(EmptyResultError);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('round-trips Q-id from search row into wikidata.org URL', async () => {
|
|
35
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
36
|
+
search: [{ id: 'Q937', label: 'Albert Einstein', description: 'physicist', match: { type: 'alias', text: 'Einstein' } }],
|
|
37
|
+
}), { status: 200 })));
|
|
38
|
+
const rows = await cmd.func({ query: 'einstein', limit: 5 });
|
|
39
|
+
expect(rows[0]).toMatchObject({
|
|
40
|
+
rank: 1, qid: 'Q937', label: 'Albert Einstein', matchType: 'alias',
|
|
41
|
+
url: 'https://www.wikidata.org/wiki/Q937',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('wikidata entity adapter', () => {
|
|
47
|
+
const cmd = getRegistry().get('wikidata/entity');
|
|
48
|
+
|
|
49
|
+
it('rejects malformed entity ids before fetching', async () => {
|
|
50
|
+
const fetchMock = vi.fn();
|
|
51
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
52
|
+
await expect(cmd.func({ id: '' })).rejects.toThrow(ArgumentError);
|
|
53
|
+
await expect(cmd.func({ id: 'not-a-qid' })).rejects.toThrow(ArgumentError);
|
|
54
|
+
await expect(cmd.func({ id: 'Q' })).rejects.toThrow(ArgumentError);
|
|
55
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('maps HTTP 404 to EmptyResultError', async () => {
|
|
59
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('missing', { status: 404 })));
|
|
60
|
+
await expect(cmd.func({ id: 'Q9999999999' })).rejects.toThrow(EmptyResultError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('falls back to English label when requested language is missing', async () => {
|
|
64
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
65
|
+
entities: {
|
|
66
|
+
Q937: {
|
|
67
|
+
id: 'Q937', type: 'item', modified: '2026-01-01T00:00:00Z',
|
|
68
|
+
labels: { en: { value: 'Albert Einstein', language: 'en' } },
|
|
69
|
+
descriptions: { en: { value: 'physicist', language: 'en' } },
|
|
70
|
+
aliases: { en: [{ value: 'A. Einstein', language: 'en' }] },
|
|
71
|
+
claims: { P31: [], P21: [] },
|
|
72
|
+
sitelinks: { enwiki: { title: 'Albert Einstein', site: 'enwiki' } },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}), { status: 200 })));
|
|
76
|
+
const rows = await cmd.func({ id: 'Q937', language: 'qq' });
|
|
77
|
+
expect(rows[0]).toMatchObject({
|
|
78
|
+
qid: 'Q937', label: 'Albert Einstein', description: 'physicist',
|
|
79
|
+
aliases: 'A. Einstein', claimPropertyCount: 2, sitelinkCount: 1,
|
|
80
|
+
enwikiTitle: 'Albert Einstein', url: 'https://www.wikidata.org/wiki/Q937',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// wikipedia page — full article extract (plain text) with optional paragraph cap.
|
|
2
|
+
//
|
|
3
|
+
// Unlike `wikipedia summary` which returns the lead-section blurb truncated to
|
|
4
|
+
// 300 chars, this adapter returns the *complete* article body (or the first N
|
|
5
|
+
// paragraphs by explicit opt-in). No silent truncation: the caller decides.
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import {
|
|
8
|
+
ArgumentError,
|
|
9
|
+
CommandExecutionError,
|
|
10
|
+
EmptyResultError,
|
|
11
|
+
} from '@jackwener/opencli/errors';
|
|
12
|
+
|
|
13
|
+
cli({
|
|
14
|
+
site: 'wikipedia',
|
|
15
|
+
name: 'page',
|
|
16
|
+
access: 'read',
|
|
17
|
+
description: 'Full plain-text extract of a Wikipedia article (optional paragraph cap).',
|
|
18
|
+
domain: 'wikipedia.org',
|
|
19
|
+
strategy: Strategy.PUBLIC,
|
|
20
|
+
browser: false,
|
|
21
|
+
args: [
|
|
22
|
+
{ name: 'title', positional: true, required: true, type: 'string', help: 'Article title (e.g. "Transformer (machine learning model)")' },
|
|
23
|
+
{ name: 'lang', type: 'string', default: 'en', help: 'Language code (en, zh, ja, de, ...).' },
|
|
24
|
+
{ name: 'paragraphs', type: 'int', default: 0, help: 'Cap to first N paragraphs (0 = full article).' },
|
|
25
|
+
],
|
|
26
|
+
columns: ['title', 'description', 'pageId', 'paragraphs', 'extract', 'url'],
|
|
27
|
+
func: async (args) => {
|
|
28
|
+
const title = String(args.title ?? '').trim();
|
|
29
|
+
if (!title) {
|
|
30
|
+
throw new ArgumentError('wikipedia page title cannot be empty');
|
|
31
|
+
}
|
|
32
|
+
const lang = String(args.lang ?? 'en').trim().toLowerCase();
|
|
33
|
+
if (!/^[a-z]{2,3}(?:-[a-z0-9]+)?$/.test(lang)) {
|
|
34
|
+
throw new ArgumentError(`wikipedia lang must be a language code like en, zh, ja (got "${args.lang}")`);
|
|
35
|
+
}
|
|
36
|
+
const paragraphsCap = Number(args.paragraphs ?? 0);
|
|
37
|
+
if (!Number.isInteger(paragraphsCap) || paragraphsCap < 0) {
|
|
38
|
+
throw new ArgumentError('paragraphs must be a non-negative integer (0 = full article)');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const url = new URL(`https://${lang}.wikipedia.org/w/api.php`);
|
|
42
|
+
url.searchParams.set('action', 'query');
|
|
43
|
+
url.searchParams.set('format', 'json');
|
|
44
|
+
url.searchParams.set('formatversion', '2');
|
|
45
|
+
url.searchParams.set('prop', 'extracts|info|description');
|
|
46
|
+
url.searchParams.set('inprop', 'url');
|
|
47
|
+
url.searchParams.set('explaintext', '1');
|
|
48
|
+
url.searchParams.set('redirects', '1');
|
|
49
|
+
url.searchParams.set('titles', title);
|
|
50
|
+
|
|
51
|
+
let resp;
|
|
52
|
+
try {
|
|
53
|
+
resp = await fetch(url, {
|
|
54
|
+
headers: {
|
|
55
|
+
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
|
|
56
|
+
'Accept': 'application/json',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new CommandExecutionError(`wikipedia page request failed: ${error?.message || error}`);
|
|
61
|
+
}
|
|
62
|
+
if (!resp.ok) {
|
|
63
|
+
throw new CommandExecutionError(`wikipedia page failed: HTTP ${resp.status}`);
|
|
64
|
+
}
|
|
65
|
+
let data;
|
|
66
|
+
try {
|
|
67
|
+
data = await resp.json();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new CommandExecutionError(`wikipedia returned malformed JSON: ${error?.message || error}`);
|
|
70
|
+
}
|
|
71
|
+
if (data?.error) {
|
|
72
|
+
throw new CommandExecutionError(`wikipedia API error: ${data.error.info || data.error.code}`);
|
|
73
|
+
}
|
|
74
|
+
const pages = Array.isArray(data?.query?.pages) ? data.query.pages : [];
|
|
75
|
+
const page = pages[0];
|
|
76
|
+
if (!page || page.missing) {
|
|
77
|
+
throw new EmptyResultError('wikipedia page', `No article "${title}" on ${lang}.wikipedia.org. Try \`opencli wikipedia search\` first.`);
|
|
78
|
+
}
|
|
79
|
+
const fullExtract = String(page.extract ?? '');
|
|
80
|
+
if (!fullExtract.trim()) {
|
|
81
|
+
throw new EmptyResultError('wikipedia page', `Article "${page.title}" exists but has no plain-text extract (likely a disambiguation/redirect page).`);
|
|
82
|
+
}
|
|
83
|
+
const allParas = fullExtract.split(/\n{2,}/).map(s => s.trim()).filter(Boolean);
|
|
84
|
+
const paras = paragraphsCap > 0 ? allParas.slice(0, paragraphsCap) : allParas;
|
|
85
|
+
|
|
86
|
+
return [{
|
|
87
|
+
title: page.title,
|
|
88
|
+
description: page.description || '',
|
|
89
|
+
pageId: page.pageid ?? null,
|
|
90
|
+
paragraphs: paras.length,
|
|
91
|
+
extract: paras.join('\n\n'),
|
|
92
|
+
url: page.fullurl || `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(page.title.replace(/ /g, '_'))}`,
|
|
93
|
+
}];
|
|
94
|
+
},
|
|
95
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// wttr current — current weather conditions for a city / lat,lon / airport code.
|
|
2
|
+
//
|
|
3
|
+
// Endpoint: GET /<location>?format=j1 → returns current_condition + nearest_area.
|
|
4
|
+
// One row (current snapshot).
|
|
5
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { requireString, wttrFetch, pickWeatherDesc } from './utils.js';
|
|
8
|
+
|
|
9
|
+
cli({
|
|
10
|
+
site: 'wttr',
|
|
11
|
+
name: 'current',
|
|
12
|
+
access: 'read',
|
|
13
|
+
description: 'Current weather conditions for a location (city, lat,lon, or airport code)',
|
|
14
|
+
domain: 'wttr.in',
|
|
15
|
+
strategy: Strategy.PUBLIC,
|
|
16
|
+
browser: false,
|
|
17
|
+
args: [
|
|
18
|
+
{
|
|
19
|
+
name: 'location',
|
|
20
|
+
positional: true,
|
|
21
|
+
required: true,
|
|
22
|
+
help: 'City name, "lat,lon", airport ICAO code, or "@domain"',
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
columns: [
|
|
26
|
+
'location', 'region', 'country', 'latitude', 'longitude',
|
|
27
|
+
'observedAt', 'tempC', 'tempF', 'feelsLikeC', 'feelsLikeF',
|
|
28
|
+
'description', 'humidity', 'cloudCover', 'pressure',
|
|
29
|
+
'precipMm', 'visibilityKm', 'uvIndex',
|
|
30
|
+
'windKmph', 'windDirection', 'windDirectionDegree',
|
|
31
|
+
],
|
|
32
|
+
func: async (args) => {
|
|
33
|
+
const location = requireString(args.location, 'location');
|
|
34
|
+
const body = await wttrFetch(location, 'wttr current');
|
|
35
|
+
const cur = Array.isArray(body?.current_condition) ? body.current_condition[0] : null;
|
|
36
|
+
if (!cur) {
|
|
37
|
+
throw new EmptyResultError('wttr current', `wttr.in returned no current conditions for "${location}".`);
|
|
38
|
+
}
|
|
39
|
+
const area = Array.isArray(body?.nearest_area) ? body.nearest_area[0] : null;
|
|
40
|
+
return [{
|
|
41
|
+
location: pickWeatherDesc(area?.areaName) || location,
|
|
42
|
+
region: pickWeatherDesc(area?.region),
|
|
43
|
+
country: pickWeatherDesc(area?.country),
|
|
44
|
+
latitude: area?.latitude ?? null,
|
|
45
|
+
longitude: area?.longitude ?? null,
|
|
46
|
+
observedAt: cur.localObsDateTime ?? null,
|
|
47
|
+
tempC: cur.temp_C != null ? Number(cur.temp_C) : null,
|
|
48
|
+
tempF: cur.temp_F != null ? Number(cur.temp_F) : null,
|
|
49
|
+
feelsLikeC: cur.FeelsLikeC != null ? Number(cur.FeelsLikeC) : null,
|
|
50
|
+
feelsLikeF: cur.FeelsLikeF != null ? Number(cur.FeelsLikeF) : null,
|
|
51
|
+
description: pickWeatherDesc(cur.weatherDesc),
|
|
52
|
+
humidity: cur.humidity != null ? Number(cur.humidity) : null,
|
|
53
|
+
cloudCover: cur.cloudcover != null ? Number(cur.cloudcover) : null,
|
|
54
|
+
pressure: cur.pressure != null ? Number(cur.pressure) : null,
|
|
55
|
+
precipMm: cur.precipMM != null ? Number(cur.precipMM) : null,
|
|
56
|
+
visibilityKm: cur.visibility != null ? Number(cur.visibility) : null,
|
|
57
|
+
uvIndex: cur.uvIndex != null ? Number(cur.uvIndex) : null,
|
|
58
|
+
windKmph: cur.windspeedKmph != null ? Number(cur.windspeedKmph) : null,
|
|
59
|
+
windDirection: cur.winddir16Point ?? null,
|
|
60
|
+
windDirectionDegree: cur.winddirDegree != null ? Number(cur.winddirDegree) : null,
|
|
61
|
+
}];
|
|
62
|
+
},
|
|
63
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// wttr forecast — multi-day forecast for a location.
|
|
2
|
+
//
|
|
3
|
+
// Endpoint: GET /<location>?format=j1 → returns weather[] (3 days max on free tier).
|
|
4
|
+
// Each day is collapsed into a single row with min/max/avg + summary description.
|
|
5
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
+
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { requireString, wttrFetch, pickWeatherDesc } from './utils.js';
|
|
8
|
+
|
|
9
|
+
cli({
|
|
10
|
+
site: 'wttr',
|
|
11
|
+
name: 'forecast',
|
|
12
|
+
access: 'read',
|
|
13
|
+
description: 'Multi-day weather forecast (up to 3 days, wttr.in free tier max)',
|
|
14
|
+
domain: 'wttr.in',
|
|
15
|
+
strategy: Strategy.PUBLIC,
|
|
16
|
+
browser: false,
|
|
17
|
+
args: [
|
|
18
|
+
{
|
|
19
|
+
name: 'location',
|
|
20
|
+
positional: true,
|
|
21
|
+
required: true,
|
|
22
|
+
help: 'City name, "lat,lon", airport ICAO code, or "@domain"',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'days',
|
|
26
|
+
type: 'int',
|
|
27
|
+
default: 3,
|
|
28
|
+
help: 'Max forecast days (1-3, wttr.in caps the response at 3 days)',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
columns: [
|
|
32
|
+
'rank', 'date', 'minTempC', 'maxTempC', 'avgTempC',
|
|
33
|
+
'minTempF', 'maxTempF', 'avgTempF',
|
|
34
|
+
'sunHour', 'totalSnowCm', 'uvIndex',
|
|
35
|
+
'description', 'sunrise', 'sunset',
|
|
36
|
+
],
|
|
37
|
+
func: async (args) => {
|
|
38
|
+
const location = requireString(args.location, 'location');
|
|
39
|
+
const days = Number(args.days ?? 3);
|
|
40
|
+
if (!Number.isInteger(days) || days < 1 || days > 3) {
|
|
41
|
+
throw new ArgumentError('--days must be an integer between 1 and 3 (wttr.in caps the free-tier forecast at 3 days)');
|
|
42
|
+
}
|
|
43
|
+
const body = await wttrFetch(location, 'wttr forecast');
|
|
44
|
+
const list = Array.isArray(body?.weather) ? body.weather : [];
|
|
45
|
+
if (!list.length) {
|
|
46
|
+
throw new EmptyResultError('wttr forecast', `wttr.in returned no forecast for "${location}".`);
|
|
47
|
+
}
|
|
48
|
+
return list.slice(0, days).map((day, i) => {
|
|
49
|
+
// wttr.in's day-summary uses the noon hourly slot for "main" description.
|
|
50
|
+
// Index 4 = 12:00 in their 3-hour-step hourly array.
|
|
51
|
+
const noon = Array.isArray(day.hourly) && day.hourly[4] ? day.hourly[4] : day.hourly?.[0] ?? {};
|
|
52
|
+
const astro = Array.isArray(day.astronomy) ? day.astronomy[0] : null;
|
|
53
|
+
return {
|
|
54
|
+
rank: i + 1,
|
|
55
|
+
date: day.date ?? null,
|
|
56
|
+
minTempC: day.mintempC != null ? Number(day.mintempC) : null,
|
|
57
|
+
maxTempC: day.maxtempC != null ? Number(day.maxtempC) : null,
|
|
58
|
+
avgTempC: day.avgtempC != null ? Number(day.avgtempC) : null,
|
|
59
|
+
minTempF: day.mintempF != null ? Number(day.mintempF) : null,
|
|
60
|
+
maxTempF: day.maxtempF != null ? Number(day.maxtempF) : null,
|
|
61
|
+
avgTempF: day.avgtempF != null ? Number(day.avgtempF) : null,
|
|
62
|
+
sunHour: day.sunHour != null ? Number(day.sunHour) : null,
|
|
63
|
+
totalSnowCm: day.totalSnow_cm != null ? Number(day.totalSnow_cm) : null,
|
|
64
|
+
uvIndex: day.uvIndex != null ? Number(day.uvIndex) : null,
|
|
65
|
+
description: pickWeatherDesc(noon?.weatherDesc),
|
|
66
|
+
sunrise: astro?.sunrise ?? null,
|
|
67
|
+
sunset: astro?.sunset ?? null,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// wttr.in shared helpers — global weather (no auth, terminal-friendly JSON via ?format=j1).
|
|
2
|
+
//
|
|
3
|
+
// Coverage: worldwide. Unlike NWS (US-only), wttr.in geocodes any city/airport
|
|
4
|
+
// code/lat,lon string and serves a 3-day forecast + current conditions in one
|
|
5
|
+
// payload.
|
|
6
|
+
import { ArgumentError, EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
7
|
+
|
|
8
|
+
export const WTTR_BASE = 'https://wttr.in';
|
|
9
|
+
const UA = 'opencli-wttr/1.0';
|
|
10
|
+
|
|
11
|
+
export function requireString(value, name) {
|
|
12
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
13
|
+
throw new ArgumentError(`--${name} is required`);
|
|
14
|
+
}
|
|
15
|
+
return value.trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function wttrFetch(location, label) {
|
|
19
|
+
// wttr.in path-encodes the location. Spaces → %20 is fine; commas survive.
|
|
20
|
+
const url = `${WTTR_BASE}/${encodeURIComponent(location)}?format=j1`;
|
|
21
|
+
let resp;
|
|
22
|
+
try {
|
|
23
|
+
resp = await fetch(url, { headers: { 'User-Agent': UA, accept: 'application/json' } });
|
|
24
|
+
} catch (err) {
|
|
25
|
+
throw new CommandExecutionError(`${label} request failed: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
if (resp.status === 404) {
|
|
28
|
+
throw new EmptyResultError(label, `${label} could not find location "${location}".`);
|
|
29
|
+
}
|
|
30
|
+
if (!resp.ok) {
|
|
31
|
+
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}.`);
|
|
32
|
+
}
|
|
33
|
+
let body;
|
|
34
|
+
try {
|
|
35
|
+
body = await resp.json();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// wttr.in falls back to plain-text "Unknown location" for some bad inputs;
|
|
38
|
+
// promote that to EmptyResult instead of pretending we got JSON.
|
|
39
|
+
throw new EmptyResultError(label, `${label} returned non-JSON body (likely unknown location).`);
|
|
40
|
+
}
|
|
41
|
+
return body;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// wttr.in's "weatherDesc" / "lang_en" fields are arrays of `{ value: '...' }` objects.
|
|
45
|
+
// Single-element 99% of the time but the schema is a list.
|
|
46
|
+
export function pickWeatherDesc(arr) {
|
|
47
|
+
if (!Array.isArray(arr) || !arr.length) return '';
|
|
48
|
+
const first = arr[0];
|
|
49
|
+
return typeof first?.value === 'string' ? first.value.trim() : '';
|
|
50
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './current.js';
|
|
5
|
+
import './forecast.js';
|
|
6
|
+
|
|
7
|
+
const origFetch = global.fetch;
|
|
8
|
+
afterEach(() => { global.fetch = origFetch; });
|
|
9
|
+
|
|
10
|
+
const sampleBody = {
|
|
11
|
+
current_condition: [{
|
|
12
|
+
temp_C: '18', temp_F: '65', FeelsLikeC: '17', FeelsLikeF: '63',
|
|
13
|
+
humidity: '70', cloudcover: '50', pressure: '1015', precipMM: '0.2',
|
|
14
|
+
visibility: '10', uvIndex: '4', windspeedKmph: '12', winddir16Point: 'NE', winddirDegree: '45',
|
|
15
|
+
weatherDesc: [{ value: 'Partly cloudy' }],
|
|
16
|
+
localObsDateTime: '2026-05-06 10:00 AM',
|
|
17
|
+
}],
|
|
18
|
+
nearest_area: [{
|
|
19
|
+
areaName: [{ value: 'Tokyo' }],
|
|
20
|
+
country: [{ value: 'Japan' }],
|
|
21
|
+
region: [{ value: 'Tokyo' }],
|
|
22
|
+
latitude: '35.685', longitude: '139.752',
|
|
23
|
+
}],
|
|
24
|
+
weather: [
|
|
25
|
+
{
|
|
26
|
+
date: '2026-05-06', mintempC: '15', maxtempC: '22', avgtempC: '18',
|
|
27
|
+
mintempF: '59', maxtempF: '72', avgtempF: '65',
|
|
28
|
+
sunHour: '12.0', totalSnow_cm: '0.0', uvIndex: '4',
|
|
29
|
+
astronomy: [{ sunrise: '04:50 AM', sunset: '06:35 PM' }],
|
|
30
|
+
hourly: [
|
|
31
|
+
{}, {}, {}, {},
|
|
32
|
+
{ weatherDesc: [{ value: 'Sunny' }] },
|
|
33
|
+
{},
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('wttr current', () => {
|
|
40
|
+
const cmd = getRegistry().get('wttr/current');
|
|
41
|
+
|
|
42
|
+
it('rejects empty location', async () => {
|
|
43
|
+
await expect(cmd.func({ location: '' })).rejects.toBeInstanceOf(ArgumentError);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('promotes 404 to EmptyResultError', async () => {
|
|
47
|
+
global.fetch = vi.fn(() => Promise.resolve(new Response('not found', { status: 404 })));
|
|
48
|
+
await expect(cmd.func({ location: 'Atlantis' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('promotes non-JSON body to EmptyResultError', async () => {
|
|
52
|
+
global.fetch = vi.fn(() => Promise.resolve(new Response('Unknown location', { status: 200 })));
|
|
53
|
+
await expect(cmd.func({ location: 'asdf' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('shapes current row + numeric coercion', async () => {
|
|
57
|
+
global.fetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify(sampleBody), { status: 200 })));
|
|
58
|
+
const rows = await cmd.func({ location: 'Tokyo' });
|
|
59
|
+
expect(rows).toHaveLength(1);
|
|
60
|
+
expect(rows[0].location).toBe('Tokyo');
|
|
61
|
+
expect(rows[0].country).toBe('Japan');
|
|
62
|
+
expect(rows[0].tempC).toBe(18);
|
|
63
|
+
expect(rows[0].description).toBe('Partly cloudy');
|
|
64
|
+
expect(rows[0].windDirection).toBe('NE');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('wttr forecast', () => {
|
|
69
|
+
const cmd = getRegistry().get('wttr/forecast');
|
|
70
|
+
|
|
71
|
+
it('rejects --days out of range', async () => {
|
|
72
|
+
await expect(cmd.func({ location: 'Tokyo', days: 5 })).rejects.toBeInstanceOf(ArgumentError);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('shapes forecast rows + picks noon description', async () => {
|
|
76
|
+
global.fetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify(sampleBody), { status: 200 })));
|
|
77
|
+
const rows = await cmd.func({ location: 'Tokyo' });
|
|
78
|
+
expect(rows).toHaveLength(1);
|
|
79
|
+
expect(rows[0]).toMatchObject({
|
|
80
|
+
rank: 1, date: '2026-05-06', minTempC: 15, maxTempC: 22, avgTempC: 18,
|
|
81
|
+
description: 'Sunny', sunrise: '04:50 AM', sunset: '06:35 PM',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
package/clis/xianyu/chat.js
CHANGED
|
@@ -12,8 +12,9 @@ function buildExtractChatStateEvaluate() {
|
|
|
12
12
|
const requiresAuth = /请先登录|登录后/.test(bodyText);
|
|
13
13
|
|
|
14
14
|
const textarea = document.querySelector('textarea');
|
|
15
|
+
const normalizeBtn = (s) => (s || '').replace(/\\s+/g, '').trim();
|
|
15
16
|
const sendButton = Array.from(document.querySelectorAll('button'))
|
|
16
|
-
.find((btn) =>
|
|
17
|
+
.find((btn) => normalizeBtn(btn.textContent || '') === '发送');
|
|
17
18
|
const topbar = document.querySelector('[class*="message-topbar"]');
|
|
18
19
|
const itemCard = Array.from(document.querySelectorAll('a[href*="/item?id="]'))
|
|
19
20
|
.find((el) => el.closest('main'));
|
|
@@ -51,7 +52,7 @@ function buildExtractChatStateEvaluate() {
|
|
|
51
52
|
}
|
|
52
53
|
function buildSendMessageEvaluate(text) {
|
|
53
54
|
return `
|
|
54
|
-
(() => {
|
|
55
|
+
(async () => {
|
|
55
56
|
const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
|
|
56
57
|
const textarea = document.querySelector('textarea');
|
|
57
58
|
if (!textarea || textarea.disabled) {
|
|
@@ -63,13 +64,22 @@ function buildSendMessageEvaluate(text) {
|
|
|
63
64
|
return { ok: false, reason: 'textarea-setter-not-found' };
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
// Click textarea first to activate chat and trigger send button to appear
|
|
68
|
+
textarea.click();
|
|
66
69
|
textarea.focus();
|
|
67
70
|
setter.call(textarea, ${JSON.stringify(text)});
|
|
68
71
|
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
|
69
72
|
textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
|
70
73
|
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
// Poll up to 3s for send button (may appear after textarea interaction)
|
|
75
|
+
const normalizeBtn = (s) => (s || '').replace(/\\s+/g, '').trim();
|
|
76
|
+
let sendButton = null;
|
|
77
|
+
for (let i = 0; i < 30; i++) {
|
|
78
|
+
sendButton = Array.from(document.querySelectorAll('button'))
|
|
79
|
+
.find((btn) => normalizeBtn(btn.textContent || '') === '发送');
|
|
80
|
+
if (sendButton) break;
|
|
81
|
+
await new Promise(r => setTimeout(r, 100));
|
|
82
|
+
}
|
|
73
83
|
if (!sendButton) {
|
|
74
84
|
return { ok: false, reason: 'send-button-not-found' };
|
|
75
85
|
}
|
|
@@ -144,4 +154,6 @@ cli({
|
|
|
144
154
|
export const __test__ = {
|
|
145
155
|
normalizeNumericId,
|
|
146
156
|
buildChatUrl,
|
|
157
|
+
buildExtractChatStateEvaluate,
|
|
158
|
+
buildSendMessageEvaluate,
|
|
147
159
|
};
|
package/clis/xianyu/chat.test.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
+
import { JSDOM } from 'jsdom';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
3
|
import { __test__ } from './chat.js';
|
|
4
|
+
|
|
5
|
+
async function runBrowserScript(html, script, { url = 'https://www.goofish.com/im', beforeEval } = {}) {
|
|
6
|
+
const dom = new JSDOM(html, { url, runScripts: 'outside-only' });
|
|
7
|
+
beforeEval?.(dom.window);
|
|
8
|
+
return dom.window.eval(script);
|
|
9
|
+
}
|
|
10
|
+
|
|
3
11
|
describe('xianyu chat helpers', () => {
|
|
4
12
|
it('builds goofish im urls from ids', () => {
|
|
5
13
|
expect(__test__.buildChatUrl('1038951278192', '3650092411')).toBe('https://www.goofish.com/im?itemId=1038951278192&peerUserId=3650092411');
|
|
@@ -12,4 +20,60 @@ describe('xianyu chat helpers', () => {
|
|
|
12
20
|
expect(() => __test__.normalizeNumericId('abc', 'item_id', '1038951278192')).toThrow();
|
|
13
21
|
expect(() => __test__.normalizeNumericId('3650092411x', 'user_id', '3650092411')).toThrow();
|
|
14
22
|
});
|
|
23
|
+
|
|
24
|
+
it('detects send buttons with whitespace-split text in the in-browser state extractor', async () => {
|
|
25
|
+
const state = await runBrowserScript(`
|
|
26
|
+
<main>
|
|
27
|
+
<textarea></textarea>
|
|
28
|
+
<button>发 送</button>
|
|
29
|
+
<div id="message-list-scrollable"><div class="bubble">你好</div></div>
|
|
30
|
+
</main>
|
|
31
|
+
`, __test__.buildExtractChatStateEvaluate());
|
|
32
|
+
|
|
33
|
+
expect(state.can_input).toBe(true);
|
|
34
|
+
expect(state.can_send).toBe(true);
|
|
35
|
+
expect(state.visible_messages).toEqual(['你好']);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('activates the textarea and waits for a whitespace-split send button before clicking it', async () => {
|
|
39
|
+
let inputValue = '';
|
|
40
|
+
let sendClicked = false;
|
|
41
|
+
const result = await runBrowserScript(`
|
|
42
|
+
<main>
|
|
43
|
+
<textarea></textarea>
|
|
44
|
+
</main>
|
|
45
|
+
`, __test__.buildSendMessageEvaluate('还在吗?'), {
|
|
46
|
+
beforeEval(window) {
|
|
47
|
+
const textarea = window.document.querySelector('textarea');
|
|
48
|
+
textarea.addEventListener('input', () => {
|
|
49
|
+
inputValue = textarea.value;
|
|
50
|
+
});
|
|
51
|
+
textarea.addEventListener('click', () => {
|
|
52
|
+
const button = window.document.createElement('button');
|
|
53
|
+
button.textContent = '发 送';
|
|
54
|
+
button.addEventListener('click', () => {
|
|
55
|
+
sendClicked = true;
|
|
56
|
+
});
|
|
57
|
+
window.document.body.append(button);
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(result).toEqual({ ok: true });
|
|
63
|
+
expect(inputValue).toBe('还在吗?');
|
|
64
|
+
expect(sendClicked).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns a typed failure reason when activation still does not reveal the send button', async () => {
|
|
68
|
+
const result = await runBrowserScript('<textarea></textarea>', __test__.buildSendMessageEvaluate('ping'), {
|
|
69
|
+
beforeEval(window) {
|
|
70
|
+
window.setTimeout = (fn) => {
|
|
71
|
+
fn();
|
|
72
|
+
return 0;
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(result).toEqual({ ok: false, reason: 'send-button-not-found' });
|
|
78
|
+
});
|
|
15
79
|
});
|