@jackwener/opencli 1.7.12 → 1.7.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -7
- package/README.zh-CN.md +9 -8
- package/cli-manifest.json +12194 -6843
- 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/reply-dm.js +1 -1
- package/clis/twitter/reply.test.js +1 -29
- 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/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/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 +150 -0
- package/dist/src/commanderAdapter.js +0 -5
- 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 +23 -2
- package/dist/src/help.js +41 -19
- 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
|
@@ -9,13 +9,13 @@ cli({
|
|
|
9
9
|
domain: PAPERREVIEW_DOMAIN,
|
|
10
10
|
strategy: Strategy.PUBLIC,
|
|
11
11
|
browser: false,
|
|
12
|
-
timeoutSeconds: 30,
|
|
13
12
|
args: [
|
|
14
13
|
{ name: 'token', positional: true, required: true, help: 'Review token returned by paperreview.ai' },
|
|
15
14
|
{ name: 'helpfulness', required: true, type: 'int', help: 'Helpfulness score from 1 to 5' },
|
|
16
15
|
{ name: 'critical-error', required: true, choices: ['yes', 'no'], help: 'Whether the review contains a critical error' },
|
|
17
16
|
{ name: 'actionable-suggestions', required: true, choices: ['yes', 'no'], help: 'Whether the review contains actionable suggestions' },
|
|
18
17
|
{ name: 'additional-comments', help: 'Optional free-text feedback' },
|
|
18
|
+
{ name: 'timeout', type: 'int', required: false, default: 30, help: 'Max seconds for the overall command (default: 30)' },
|
|
19
19
|
],
|
|
20
20
|
columns: ['status', 'token', 'helpfulness', 'critical_error', 'actionable_suggestions', 'message'],
|
|
21
21
|
func: async (kwargs) => {
|
|
@@ -9,9 +9,9 @@ cli({
|
|
|
9
9
|
domain: PAPERREVIEW_DOMAIN,
|
|
10
10
|
strategy: Strategy.PUBLIC,
|
|
11
11
|
browser: false,
|
|
12
|
-
timeoutSeconds: 30,
|
|
13
12
|
args: [
|
|
14
13
|
{ name: 'token', positional: true, required: true, help: 'Review token returned by paperreview.ai' },
|
|
14
|
+
{ name: 'timeout', type: 'int', required: false, default: 30, help: 'Max seconds for the overall command (default: 30)' },
|
|
15
15
|
],
|
|
16
16
|
columns: ['status', 'title', 'venue', 'numerical_score', 'has_feedback', 'review_url'],
|
|
17
17
|
func: async (kwargs) => {
|
|
@@ -9,13 +9,13 @@ cli({
|
|
|
9
9
|
domain: PAPERREVIEW_DOMAIN,
|
|
10
10
|
strategy: Strategy.PUBLIC,
|
|
11
11
|
browser: false,
|
|
12
|
-
timeoutSeconds: 120,
|
|
13
12
|
args: [
|
|
14
13
|
{ name: 'pdf', positional: true, required: true, help: 'Path to the paper PDF' },
|
|
15
14
|
{ name: 'email', required: true, help: 'Email address for the submission' },
|
|
16
15
|
{ name: 'venue', help: 'Optional target venue such as ICLR or NeurIPS' },
|
|
17
16
|
{ name: 'dry-run', type: 'bool', default: false, help: 'Validate the input and stop before remote submission' },
|
|
18
17
|
{ name: 'prepare-only', type: 'bool', default: false, help: 'Request an upload slot but stop before uploading the PDF' },
|
|
18
|
+
{ name: 'timeout', type: 'int', required: false, default: 120, help: 'Max seconds for the overall command (default: 120)' },
|
|
19
19
|
],
|
|
20
20
|
columns: ['status', 'file', 'email', 'venue', 'token', 'review_url', 'message'],
|
|
21
21
|
footerExtra: (kwargs) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
3
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
-
import { createPageMock } from '
|
|
4
|
+
import { createPageMock } from '../test-utils.js';
|
|
5
5
|
// Mock download dependencies before importing the adapter
|
|
6
6
|
const { mockHttpDownload, mockMkdirSync } = vi.hoisted(() => ({
|
|
7
7
|
mockHttpDownload: vi.fn(),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { beforeAll, describe, expect, it } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
3
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
-
import { createPageMock } from '
|
|
4
|
+
import { createPageMock } from '../test-utils.js';
|
|
5
5
|
import './illusts.js';
|
|
6
6
|
let cmd;
|
|
7
7
|
beforeAll(() => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { beforeAll, describe, expect, it } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
3
|
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
-
import { createPageMock } from '
|
|
4
|
+
import { createPageMock } from '../test-utils.js';
|
|
5
5
|
import './search.js';
|
|
6
6
|
let cmd;
|
|
7
7
|
beforeAll(() => {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { eutilsFetch, parseArticleXml, requirePmid, truncateText } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'pubmed',
|
|
7
|
+
name: 'article',
|
|
8
|
+
aliases: ['paper', 'read'],
|
|
9
|
+
access: 'read',
|
|
10
|
+
description: 'Get detailed information for a PubMed article by PMID',
|
|
11
|
+
domain: 'pubmed.ncbi.nlm.nih.gov',
|
|
12
|
+
strategy: Strategy.PUBLIC,
|
|
13
|
+
browser: false,
|
|
14
|
+
args: [
|
|
15
|
+
{ name: 'pmid', positional: true, required: true, help: 'PubMed ID, e.g. 37780221' },
|
|
16
|
+
{ name: 'full-abstract', type: 'boolean', default: false, help: 'Do not truncate the abstract in table output' },
|
|
17
|
+
],
|
|
18
|
+
columns: ['field', 'value'],
|
|
19
|
+
func: async (args) => {
|
|
20
|
+
const pmid = requirePmid(args.pmid);
|
|
21
|
+
const xml = await eutilsFetch('efetch', {
|
|
22
|
+
id: pmid,
|
|
23
|
+
rettype: 'abstract',
|
|
24
|
+
}, { retmode: 'xml', label: 'pubmed article' });
|
|
25
|
+
const article = parseArticleXml(xml, pmid);
|
|
26
|
+
if (!article) {
|
|
27
|
+
throw new EmptyResultError('pubmed article', `No article found for PMID ${pmid}.`);
|
|
28
|
+
}
|
|
29
|
+
if (!article.title) {
|
|
30
|
+
throw new CommandExecutionError(`pubmed article ${pmid} did not include a title`, 'PubMed EFetch response shape may have changed.');
|
|
31
|
+
}
|
|
32
|
+
const abstract = args['full-abstract'] ? article.abstract : truncateText(article.abstract, 500);
|
|
33
|
+
return [
|
|
34
|
+
{ field: 'PMID', value: article.pmid },
|
|
35
|
+
{ field: 'Title', value: article.title },
|
|
36
|
+
{ field: 'Authors', value: article.authors.join(', ') },
|
|
37
|
+
{ field: 'Journal', value: article.journal },
|
|
38
|
+
{ field: 'Year', value: article.year },
|
|
39
|
+
{ field: 'Date', value: article.date },
|
|
40
|
+
{ field: 'Article Type', value: article.article_type },
|
|
41
|
+
{ field: 'Language', value: article.language },
|
|
42
|
+
{ field: 'DOI', value: article.doi || null },
|
|
43
|
+
{ field: 'PMC ID', value: article.pmc || null },
|
|
44
|
+
{ field: 'MeSH Terms', value: article.mesh_terms || null },
|
|
45
|
+
{ field: 'Keywords', value: article.keywords || null },
|
|
46
|
+
{ field: 'Abstract', value: abstract || null },
|
|
47
|
+
{ field: 'URL', value: article.url },
|
|
48
|
+
];
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import {
|
|
4
|
+
LINK_COLUMNS,
|
|
5
|
+
eutilsFetch,
|
|
6
|
+
fetchSummaryRows,
|
|
7
|
+
requireBoundedInt,
|
|
8
|
+
requireChoice,
|
|
9
|
+
requireText,
|
|
10
|
+
requireYear,
|
|
11
|
+
} from './utils.js';
|
|
12
|
+
|
|
13
|
+
cli({
|
|
14
|
+
site: 'pubmed',
|
|
15
|
+
name: 'author',
|
|
16
|
+
access: 'read',
|
|
17
|
+
description: 'Search PubMed articles by author name and optional affiliation',
|
|
18
|
+
domain: 'pubmed.ncbi.nlm.nih.gov',
|
|
19
|
+
strategy: Strategy.PUBLIC,
|
|
20
|
+
browser: false,
|
|
21
|
+
args: [
|
|
22
|
+
{ name: 'name', positional: true, required: true, help: 'Author name, e.g. "Smith J"' },
|
|
23
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results (1-100)' },
|
|
24
|
+
{ name: 'affiliation', help: 'Filter by author affiliation' },
|
|
25
|
+
{ name: 'position', default: 'any', choices: ['any', 'first', 'last'], help: 'Author position: any, first, or last' },
|
|
26
|
+
{ name: 'year-from', type: 'int', help: 'Filter publication year from' },
|
|
27
|
+
{ name: 'year-to', type: 'int', help: 'Filter publication year to' },
|
|
28
|
+
{ name: 'sort', default: 'date', choices: ['date', 'relevance'], help: 'Sort by date or relevance' },
|
|
29
|
+
],
|
|
30
|
+
columns: LINK_COLUMNS,
|
|
31
|
+
func: async (args) => {
|
|
32
|
+
const name = requireText(args.name, 'author');
|
|
33
|
+
const limit = requireBoundedInt(args.limit, 20, 100);
|
|
34
|
+
const position = requireChoice(args.position, ['any', 'first', 'last'], 'position', 'any');
|
|
35
|
+
const sort = requireChoice(args.sort, ['date', 'relevance'], 'sort', 'date');
|
|
36
|
+
const yearFrom = requireYear(args['year-from'], 'year-from');
|
|
37
|
+
const yearTo = requireYear(args['year-to'], 'year-to');
|
|
38
|
+
const authorTag = position === 'first' ? '1au' : position === 'last' ? 'lastau' : 'au';
|
|
39
|
+
const terms = [`${name}[${authorTag}]`];
|
|
40
|
+
if (args.affiliation) terms.push(`${requireText(args.affiliation, 'affiliation')}[ad]`);
|
|
41
|
+
if (yearFrom || yearTo) {
|
|
42
|
+
const from = yearFrom || 1800;
|
|
43
|
+
const to = yearTo || new Date().getFullYear();
|
|
44
|
+
if (from > to) {
|
|
45
|
+
throw new ArgumentError('pubmed year-from must be <= year-to');
|
|
46
|
+
}
|
|
47
|
+
terms.push(`${from}:${to}[PDAT]`);
|
|
48
|
+
}
|
|
49
|
+
const esearch = await eutilsFetch('esearch', {
|
|
50
|
+
term: terms.join(' AND '),
|
|
51
|
+
retmax: limit,
|
|
52
|
+
usehistory: 'y',
|
|
53
|
+
sort: sort === 'date' ? 'pub_date' : '',
|
|
54
|
+
}, { label: 'pubmed author' });
|
|
55
|
+
const pmids = esearch?.esearchresult?.idlist;
|
|
56
|
+
if (!Array.isArray(pmids)) {
|
|
57
|
+
throw new CommandExecutionError('pubmed author did not return an id list', 'PubMed ESearch response shape may have changed.');
|
|
58
|
+
}
|
|
59
|
+
if (pmids.length === 0) {
|
|
60
|
+
throw new EmptyResultError('pubmed author', `No articles found for author "${name}".`);
|
|
61
|
+
}
|
|
62
|
+
return fetchSummaryRows(pmids, 'pubmed author summary');
|
|
63
|
+
},
|
|
64
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { LINK_COLUMNS, eutilsFetch, fetchSummaryRows, requireBoundedInt, requireChoice, requirePmid } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'pubmed',
|
|
7
|
+
name: 'citations',
|
|
8
|
+
access: 'read',
|
|
9
|
+
description: 'Get PubMed citation relationships for an article',
|
|
10
|
+
domain: 'pubmed.ncbi.nlm.nih.gov',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: false,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'pmid', positional: true, required: true, help: 'PubMed ID, e.g. 37780221' },
|
|
15
|
+
{ name: 'direction', default: 'citedby', choices: ['citedby', 'references'], help: 'citedby or references' },
|
|
16
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results (1-100)' },
|
|
17
|
+
],
|
|
18
|
+
columns: LINK_COLUMNS,
|
|
19
|
+
func: async (args) => {
|
|
20
|
+
const pmid = requirePmid(args.pmid);
|
|
21
|
+
const direction = requireChoice(args.direction, ['citedby', 'references'], 'direction', 'citedby');
|
|
22
|
+
const limit = requireBoundedInt(args.limit, 20, 100);
|
|
23
|
+
const linkname = direction === 'citedby' ? 'pubmed_pubmed_citedin' : 'pubmed_pubmed_refs';
|
|
24
|
+
const result = await eutilsFetch('elink', {
|
|
25
|
+
id: pmid,
|
|
26
|
+
dbfrom: 'pubmed',
|
|
27
|
+
cmd: 'neighbor',
|
|
28
|
+
linkname,
|
|
29
|
+
}, { label: 'pubmed citations' });
|
|
30
|
+
const links = result?.linksets?.[0]?.linksetdbs?.[0]?.links;
|
|
31
|
+
if (!Array.isArray(links) || links.length === 0) {
|
|
32
|
+
throw new EmptyResultError('pubmed citations', `No ${direction} links found for PMID ${pmid}.`);
|
|
33
|
+
}
|
|
34
|
+
return fetchSummaryRows(links.slice(0, limit).map(String), 'pubmed citations summary');
|
|
35
|
+
},
|
|
36
|
+
});
|
|
@@ -0,0 +1,276 @@
|
|
|
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 {
|
|
5
|
+
LINK_COLUMNS,
|
|
6
|
+
RELATED_COLUMNS,
|
|
7
|
+
SEARCH_COLUMNS,
|
|
8
|
+
buildEutilsUrl,
|
|
9
|
+
buildSearchQuery,
|
|
10
|
+
parseArticleXml,
|
|
11
|
+
requireBoundedInt,
|
|
12
|
+
requirePmid,
|
|
13
|
+
} from './utils.js';
|
|
14
|
+
import './search.js';
|
|
15
|
+
import './article.js';
|
|
16
|
+
import './author.js';
|
|
17
|
+
import './citations.js';
|
|
18
|
+
import './related.js';
|
|
19
|
+
|
|
20
|
+
const SUMMARY_RESULT = {
|
|
21
|
+
result: {
|
|
22
|
+
uids: ['123', '456'],
|
|
23
|
+
123: {
|
|
24
|
+
uid: '123',
|
|
25
|
+
title: 'Cancer machine learning.',
|
|
26
|
+
authors: [{ name: 'Alice A' }, { name: 'Bob B' }, { name: 'Carol C' }, { name: 'Dan D' }],
|
|
27
|
+
fulljournalname: 'Journal of Tests',
|
|
28
|
+
pubdate: '2024 Jan',
|
|
29
|
+
pubtype: ['Journal Article', 'Review'],
|
|
30
|
+
articleids: [{ idtype: 'doi', value: '10.1000/test' }],
|
|
31
|
+
},
|
|
32
|
+
456: {
|
|
33
|
+
uid: '456',
|
|
34
|
+
title: 'Second article.',
|
|
35
|
+
authors: [{ name: 'Eve E' }],
|
|
36
|
+
source: 'Test Source',
|
|
37
|
+
pubdate: '2023',
|
|
38
|
+
pubtype: ['Journal Article'],
|
|
39
|
+
articleids: [],
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ARTICLE_XML = `<?xml version="1.0"?>
|
|
45
|
+
<PubmedArticle>
|
|
46
|
+
<MedlineCitation>
|
|
47
|
+
<Article>
|
|
48
|
+
<Journal>
|
|
49
|
+
<Title>Journal of Tests</Title>
|
|
50
|
+
<JournalIssue><PubDate><Year>2024</Year><Month>Jan</Month><Day>02</Day></PubDate></JournalIssue>
|
|
51
|
+
</Journal>
|
|
52
|
+
<ArticleTitle>Detailed PubMed article & title.</ArticleTitle>
|
|
53
|
+
<Abstract><AbstractText>Background text.</AbstractText><AbstractText>Conclusion text.</AbstractText></Abstract>
|
|
54
|
+
<AuthorList>
|
|
55
|
+
<Author><LastName>Alice</LastName><ForeName>Example</ForeName></Author>
|
|
56
|
+
<Author><LastName>Bob</LastName><Initials>B</Initials></Author>
|
|
57
|
+
</AuthorList>
|
|
58
|
+
<Language>eng</Language>
|
|
59
|
+
<PublicationTypeList><PublicationType>Review</PublicationType></PublicationTypeList>
|
|
60
|
+
</Article>
|
|
61
|
+
<MeshHeadingList><MeshHeading><DescriptorName>Neoplasms</DescriptorName></MeshHeading></MeshHeadingList>
|
|
62
|
+
<KeywordList><Keyword>machine learning</Keyword></KeywordList>
|
|
63
|
+
</MedlineCitation>
|
|
64
|
+
<PubmedData><ArticleIdList><ArticleId IdType="doi">10.1000/detail</ArticleId><ArticleId IdType="pmc">PMC123</ArticleId></ArticleIdList></PubmedData>
|
|
65
|
+
</PubmedArticle>`;
|
|
66
|
+
|
|
67
|
+
function jsonResponse(body, ok = true, status = 200) {
|
|
68
|
+
return {
|
|
69
|
+
ok,
|
|
70
|
+
status,
|
|
71
|
+
json: vi.fn().mockResolvedValue(body),
|
|
72
|
+
text: vi.fn().mockResolvedValue(typeof body === 'string' ? body : JSON.stringify(body)),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function xmlResponse(body, ok = true, status = 200) {
|
|
77
|
+
return {
|
|
78
|
+
ok,
|
|
79
|
+
status,
|
|
80
|
+
json: vi.fn().mockRejectedValue(new Error('not json')),
|
|
81
|
+
text: vi.fn().mockResolvedValue(body),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
vi.unstubAllGlobals();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('pubmed adapter registration', () => {
|
|
90
|
+
it('registers five public read commands with expected listing columns', () => {
|
|
91
|
+
const registry = getRegistry();
|
|
92
|
+
for (const name of ['search', 'article', 'author', 'citations', 'related']) {
|
|
93
|
+
const command = registry.get(`pubmed/${name}`);
|
|
94
|
+
expect(command).toBeDefined();
|
|
95
|
+
expect(command.strategy).toBe('public');
|
|
96
|
+
expect(command.browser).toBe(false);
|
|
97
|
+
expect(command.access).toBe('read');
|
|
98
|
+
}
|
|
99
|
+
expect(registry.get('pubmed/search').columns).toEqual(SEARCH_COLUMNS);
|
|
100
|
+
expect(registry.get('pubmed/author').columns).toEqual(LINK_COLUMNS);
|
|
101
|
+
expect(registry.get('pubmed/citations').columns).toEqual(LINK_COLUMNS);
|
|
102
|
+
expect(registry.get('pubmed/related').columns).toEqual(RELATED_COLUMNS);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('pubmed utility contracts', () => {
|
|
107
|
+
it('rejects invalid PMIDs and silently-clamped limits', () => {
|
|
108
|
+
expect(requirePmid('37780221')).toBe('37780221');
|
|
109
|
+
expect(() => requirePmid('PMID:37780221')).toThrow(ArgumentError);
|
|
110
|
+
expect(requireBoundedInt(undefined, 20, 100)).toBe(20);
|
|
111
|
+
expect(requireBoundedInt('100', 20, 100)).toBe(100);
|
|
112
|
+
expect(() => requireBoundedInt('2abc', 20, 100)).toThrow(ArgumentError);
|
|
113
|
+
expect(() => requireBoundedInt(101, 20, 100)).toThrow(/<= 100/);
|
|
114
|
+
expect(() => requireBoundedInt(0, 20, 100)).toThrow(ArgumentError);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('builds E-utilities URLs with optional NCBI metadata', () => {
|
|
118
|
+
vi.stubEnv('NCBI_API_KEY', 'key-1');
|
|
119
|
+
vi.stubEnv('NCBI_EMAIL', 'dev@example.com');
|
|
120
|
+
const url = buildEutilsUrl('esearch', { term: 'cancer', retmax: 5 });
|
|
121
|
+
expect(url).toContain('/esearch.fcgi?');
|
|
122
|
+
expect(url).toContain('db=pubmed');
|
|
123
|
+
expect(url).toContain('api_key=key-1');
|
|
124
|
+
expect(url).toContain('email=dev%40example.com');
|
|
125
|
+
expect(url).toContain('term=cancer');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('composes search filters without dropping date boundaries', () => {
|
|
129
|
+
expect(buildSearchQuery('cancer', {
|
|
130
|
+
author: 'Smith J',
|
|
131
|
+
journal: 'Nature',
|
|
132
|
+
yearFrom: 2020,
|
|
133
|
+
yearTo: 2024,
|
|
134
|
+
articleType: 'Review',
|
|
135
|
+
hasAbstract: true,
|
|
136
|
+
hasFullText: true,
|
|
137
|
+
humanOnly: true,
|
|
138
|
+
englishOnly: true,
|
|
139
|
+
})).toBe('cancer AND Smith J[Author] AND Nature[Journal] AND 2020:2024[PDAT] AND Review[PT] AND hasabstract[text] AND free full text[sb] AND humans[mesh] AND english[lang]');
|
|
140
|
+
expect(() => buildSearchQuery('cancer', { yearFrom: 2025, yearTo: 2020 })).toThrow(ArgumentError);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('parses EFetch XML into article details', () => {
|
|
144
|
+
const article = parseArticleXml(ARTICLE_XML, '123');
|
|
145
|
+
expect(article.title).toBe('Detailed PubMed article & title.');
|
|
146
|
+
expect(article.abstract).toBe('Background text. Conclusion text.');
|
|
147
|
+
expect(article.authors).toEqual(['Alice Example', 'Bob B']);
|
|
148
|
+
expect(article.journal).toBe('Journal of Tests');
|
|
149
|
+
expect(article.doi).toBe('10.1000/detail');
|
|
150
|
+
expect(article.mesh_terms).toBe('Neoplasms');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('pubmed search command', () => {
|
|
155
|
+
it('returns summary rows for ESearch ids', async () => {
|
|
156
|
+
vi.stubGlobal('fetch', vi.fn()
|
|
157
|
+
.mockResolvedValueOnce(jsonResponse({ esearchresult: { idlist: ['123', '456'] } }))
|
|
158
|
+
.mockResolvedValueOnce(jsonResponse(SUMMARY_RESULT)));
|
|
159
|
+
const rows = await getRegistry().get('pubmed/search').func({ query: 'cancer', limit: 2, sort: 'date' });
|
|
160
|
+
expect(rows).toHaveLength(2);
|
|
161
|
+
expect(rows[0]).toMatchObject({ rank: 1, pmid: '123', title: 'Cancer machine learning', article_type: 'Review', doi: '10.1000/test' });
|
|
162
|
+
expect(rows[0].url).toBe('https://pubmed.ncbi.nlm.nih.gov/123/');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('rejects bad query, limit, sort, and year args before fetch', async () => {
|
|
166
|
+
const fetchMock = vi.fn();
|
|
167
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
168
|
+
const command = getRegistry().get('pubmed/search');
|
|
169
|
+
await expect(command.func({ query: ' ', limit: 2 })).rejects.toBeInstanceOf(ArgumentError);
|
|
170
|
+
await expect(command.func({ query: 'cancer', limit: 101 })).rejects.toBeInstanceOf(ArgumentError);
|
|
171
|
+
await expect(command.func({ query: 'cancer', sort: 'bad' })).rejects.toBeInstanceOf(ArgumentError);
|
|
172
|
+
await expect(command.func({ query: 'cancer', 'year-from': 2025, 'year-to': 2020 })).rejects.toBeInstanceOf(ArgumentError);
|
|
173
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('maps empty and API error envelopes to typed errors', async () => {
|
|
177
|
+
const command = getRegistry().get('pubmed/search');
|
|
178
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({ esearchresult: { idlist: [] } })));
|
|
179
|
+
await expect(command.func({ query: 'nothing' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
180
|
+
|
|
181
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({ esearchresult: { errorlist: { phrasesnotfound: ['bad field'] } } })));
|
|
182
|
+
await expect(command.func({ query: 'bad' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('maps HTTP, fetch, JSON, and partial summary failures to CommandExecutionError', async () => {
|
|
186
|
+
const command = getRegistry().get('pubmed/search');
|
|
187
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({}, false, 500)));
|
|
188
|
+
await expect(command.func({ query: 'cancer' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
189
|
+
|
|
190
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(new Error('network down')));
|
|
191
|
+
await expect(command.func({ query: 'cancer' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
192
|
+
|
|
193
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockRejectedValue(new Error('bad json')) }));
|
|
194
|
+
await expect(command.func({ query: 'cancer' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
195
|
+
|
|
196
|
+
vi.stubGlobal('fetch', vi.fn()
|
|
197
|
+
.mockResolvedValueOnce(jsonResponse({ esearchresult: { idlist: ['123', '456'] } }))
|
|
198
|
+
.mockResolvedValueOnce(jsonResponse({ result: { 123: SUMMARY_RESULT.result[123] } })));
|
|
199
|
+
await expect(command.func({ query: 'cancer' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('pubmed article command', () => {
|
|
204
|
+
it('returns field/value rows for a valid article', async () => {
|
|
205
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(xmlResponse(ARTICLE_XML)));
|
|
206
|
+
const rows = await getRegistry().get('pubmed/article').func({ pmid: '123' });
|
|
207
|
+
expect(rows).toContainEqual({ field: 'PMID', value: '123' });
|
|
208
|
+
expect(rows).toContainEqual({ field: 'DOI', value: '10.1000/detail' });
|
|
209
|
+
expect(rows.find(row => row.field === 'Abstract').value).toContain('Background text');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('rejects invalid or missing articles with typed errors', async () => {
|
|
213
|
+
const command = getRegistry().get('pubmed/article');
|
|
214
|
+
await expect(command.func({ pmid: 'abc' })).rejects.toBeInstanceOf(ArgumentError);
|
|
215
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(xmlResponse('<ERROR>not found</ERROR>')));
|
|
216
|
+
await expect(command.func({ pmid: '123' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('pubmed author command', () => {
|
|
221
|
+
it('searches author position and affiliation filters', async () => {
|
|
222
|
+
const fetchMock = vi.fn()
|
|
223
|
+
.mockResolvedValueOnce(jsonResponse({ esearchresult: { idlist: ['123'] } }))
|
|
224
|
+
.mockResolvedValueOnce(jsonResponse({ result: { 123: SUMMARY_RESULT.result[123] } }));
|
|
225
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
226
|
+
const rows = await getRegistry().get('pubmed/author').func({ name: 'Smith J', position: 'first', affiliation: 'Harvard', limit: 1 });
|
|
227
|
+
expect(rows[0].pmid).toBe('123');
|
|
228
|
+
const url = fetchMock.mock.calls[0][0];
|
|
229
|
+
expect(url).toContain('Smith+J%5B1au%5D');
|
|
230
|
+
expect(url).toContain('Harvard%5Bad%5D');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('rejects invalid author filters and empty results', async () => {
|
|
234
|
+
const command = getRegistry().get('pubmed/author');
|
|
235
|
+
await expect(command.func({ name: '', position: 'any' })).rejects.toBeInstanceOf(ArgumentError);
|
|
236
|
+
await expect(command.func({ name: 'Smith', position: 'middle' })).rejects.toBeInstanceOf(ArgumentError);
|
|
237
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({ esearchresult: { idlist: [] } })));
|
|
238
|
+
await expect(command.func({ name: 'Smith' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('pubmed citations and related commands', () => {
|
|
243
|
+
it('returns cited-by summary rows with PMID round-trip ids', async () => {
|
|
244
|
+
vi.stubGlobal('fetch', vi.fn()
|
|
245
|
+
.mockResolvedValueOnce(jsonResponse({ linksets: [{ linksetdbs: [{ links: ['123'] }] }] }))
|
|
246
|
+
.mockResolvedValueOnce(jsonResponse({ result: { 123: SUMMARY_RESULT.result[123] } })));
|
|
247
|
+
const rows = await getRegistry().get('pubmed/citations').func({ pmid: '999', direction: 'citedby', limit: 1 });
|
|
248
|
+
expect(rows).toHaveLength(1);
|
|
249
|
+
expect(rows[0].pmid).toBe('123');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('rejects invalid citation args and empty relationships', async () => {
|
|
253
|
+
const command = getRegistry().get('pubmed/citations');
|
|
254
|
+
await expect(command.func({ pmid: 'bad' })).rejects.toBeInstanceOf(ArgumentError);
|
|
255
|
+
await expect(command.func({ pmid: '999', direction: 'sideways' })).rejects.toBeInstanceOf(ArgumentError);
|
|
256
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({ linksets: [{ linksetdbs: [] }] })));
|
|
257
|
+
await expect(command.func({ pmid: '999', direction: 'citedby' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('returns related rows with optional score', async () => {
|
|
261
|
+
vi.stubGlobal('fetch', vi.fn()
|
|
262
|
+
.mockResolvedValueOnce(jsonResponse({ linksets: [{ linksetdbs: [{ links: [{ id: '999', score: 999 }, { id: '123', score: 42 }] }] }] }))
|
|
263
|
+
.mockResolvedValueOnce(jsonResponse({ result: { 123: SUMMARY_RESULT.result[123] } })));
|
|
264
|
+
const rows = await getRegistry().get('pubmed/related').func({ pmid: '999', score: true, limit: 1 });
|
|
265
|
+
expect(rows).toHaveLength(1);
|
|
266
|
+
expect(rows[0]).toMatchObject({ pmid: '123', score: 42 });
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('rejects invalid related args and empty related links', async () => {
|
|
270
|
+
const command = getRegistry().get('pubmed/related');
|
|
271
|
+
await expect(command.func({ pmid: 'bad' })).rejects.toBeInstanceOf(ArgumentError);
|
|
272
|
+
await expect(command.func({ pmid: '999', limit: 101 })).rejects.toBeInstanceOf(ArgumentError);
|
|
273
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(jsonResponse({ linksets: [{ linksetdbs: [{ links: [{ id: '999' }] }] }] })));
|
|
274
|
+
await expect(command.func({ pmid: '999' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { RELATED_COLUMNS, eutilsFetch, fetchSummaryRows, requireBoundedInt, requirePmid } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'pubmed',
|
|
7
|
+
name: 'related',
|
|
8
|
+
access: 'read',
|
|
9
|
+
description: 'Find articles related to a PubMed article',
|
|
10
|
+
domain: 'pubmed.ncbi.nlm.nih.gov',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: false,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'pmid', positional: true, required: true, help: 'PubMed ID, e.g. 37780221' },
|
|
15
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results (1-100)' },
|
|
16
|
+
{ name: 'score', type: 'boolean', default: false, help: 'Show similarity scores when available' },
|
|
17
|
+
],
|
|
18
|
+
columns: RELATED_COLUMNS,
|
|
19
|
+
func: async (args) => {
|
|
20
|
+
const pmid = requirePmid(args.pmid);
|
|
21
|
+
const limit = requireBoundedInt(args.limit, 20, 100);
|
|
22
|
+
const result = await eutilsFetch('elink', {
|
|
23
|
+
id: pmid,
|
|
24
|
+
dbfrom: 'pubmed',
|
|
25
|
+
cmd: 'neighbor_score',
|
|
26
|
+
linkname: 'pubmed_pubmed',
|
|
27
|
+
}, { label: 'pubmed related' });
|
|
28
|
+
const rawLinks = result?.linksets?.[0]?.linksetdbs?.[0]?.links;
|
|
29
|
+
if (!Array.isArray(rawLinks) || rawLinks.length === 0) {
|
|
30
|
+
throw new EmptyResultError('pubmed related', `No related articles found for PMID ${pmid}.`);
|
|
31
|
+
}
|
|
32
|
+
const links = rawLinks
|
|
33
|
+
.map(link => typeof link === 'string' ? { id: link, score: null } : { id: String(link?.id ?? ''), score: Number.isFinite(Number(link?.score)) ? Number(link.score) : null })
|
|
34
|
+
.filter(link => link.id && link.id !== pmid)
|
|
35
|
+
.slice(0, limit);
|
|
36
|
+
if (links.length === 0) {
|
|
37
|
+
throw new EmptyResultError('pubmed related', `No related articles found for PMID ${pmid}.`);
|
|
38
|
+
}
|
|
39
|
+
const rows = await fetchSummaryRows(links.map(link => link.id), 'pubmed related summary');
|
|
40
|
+
return rows.map((row, index) => ({
|
|
41
|
+
...row,
|
|
42
|
+
score: args.score ? links[index].score : null,
|
|
43
|
+
}));
|
|
44
|
+
},
|
|
45
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import {
|
|
4
|
+
SEARCH_COLUMNS,
|
|
5
|
+
buildSearchQuery,
|
|
6
|
+
eutilsFetch,
|
|
7
|
+
fetchSummaryRows,
|
|
8
|
+
requireBoundedInt,
|
|
9
|
+
requireChoice,
|
|
10
|
+
requireText,
|
|
11
|
+
requireYear,
|
|
12
|
+
} from './utils.js';
|
|
13
|
+
|
|
14
|
+
cli({
|
|
15
|
+
site: 'pubmed',
|
|
16
|
+
name: 'search',
|
|
17
|
+
access: 'read',
|
|
18
|
+
description: 'Search PubMed articles with advanced filters',
|
|
19
|
+
domain: 'pubmed.ncbi.nlm.nih.gov',
|
|
20
|
+
strategy: Strategy.PUBLIC,
|
|
21
|
+
browser: false,
|
|
22
|
+
args: [
|
|
23
|
+
{ name: 'query', positional: true, required: true, help: 'Search query, e.g. "machine learning cancer"' },
|
|
24
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results (1-100)' },
|
|
25
|
+
{ name: 'author', help: 'Filter by author name' },
|
|
26
|
+
{ name: 'journal', help: 'Filter by journal name' },
|
|
27
|
+
{ name: 'year-from', type: 'int', help: 'Filter publication year from' },
|
|
28
|
+
{ name: 'year-to', type: 'int', help: 'Filter publication year to' },
|
|
29
|
+
{ name: 'article-type', help: 'Filter by publication type, e.g. Review or Clinical Trial' },
|
|
30
|
+
{ name: 'has-abstract', type: 'boolean', default: false, help: 'Only include articles with abstracts' },
|
|
31
|
+
{ name: 'free-full-text', type: 'boolean', default: false, help: 'Only include free full text articles' },
|
|
32
|
+
{ name: 'humans-only', type: 'boolean', default: false, help: 'Only include human studies' },
|
|
33
|
+
{ name: 'english-only', type: 'boolean', default: false, help: 'Only include English articles' },
|
|
34
|
+
{ name: 'sort', default: 'relevance', choices: ['relevance', 'date', 'author', 'journal'], help: 'Sort by relevance, date, author, or journal' },
|
|
35
|
+
],
|
|
36
|
+
columns: SEARCH_COLUMNS,
|
|
37
|
+
func: async (args) => {
|
|
38
|
+
const query = requireText(args.query, 'query');
|
|
39
|
+
const limit = requireBoundedInt(args.limit, 20, 100);
|
|
40
|
+
const yearFrom = requireYear(args['year-from'], 'year-from');
|
|
41
|
+
const yearTo = requireYear(args['year-to'], 'year-to');
|
|
42
|
+
const sort = requireChoice(args.sort, ['relevance', 'date', 'author', 'journal'], 'sort', 'relevance');
|
|
43
|
+
const sortMap = {
|
|
44
|
+
relevance: '',
|
|
45
|
+
date: 'pub_date',
|
|
46
|
+
author: 'Author',
|
|
47
|
+
journal: 'JournalName',
|
|
48
|
+
};
|
|
49
|
+
const searchQuery = buildSearchQuery(query, {
|
|
50
|
+
author: args.author,
|
|
51
|
+
journal: args.journal,
|
|
52
|
+
yearFrom,
|
|
53
|
+
yearTo,
|
|
54
|
+
articleType: args['article-type'],
|
|
55
|
+
hasAbstract: args['has-abstract'],
|
|
56
|
+
hasFullText: args['free-full-text'],
|
|
57
|
+
humanOnly: args['humans-only'],
|
|
58
|
+
englishOnly: args['english-only'],
|
|
59
|
+
});
|
|
60
|
+
const esearch = await eutilsFetch('esearch', {
|
|
61
|
+
term: searchQuery,
|
|
62
|
+
retmax: limit,
|
|
63
|
+
usehistory: 'y',
|
|
64
|
+
sort: sortMap[sort],
|
|
65
|
+
}, { label: 'pubmed search' });
|
|
66
|
+
const pmids = esearch?.esearchresult?.idlist;
|
|
67
|
+
if (!Array.isArray(pmids)) {
|
|
68
|
+
throw new CommandExecutionError('pubmed search did not return an id list', 'PubMed ESearch response shape may have changed.');
|
|
69
|
+
}
|
|
70
|
+
if (pmids.length === 0) {
|
|
71
|
+
throw new EmptyResultError('pubmed search', `No articles matched "${query}".`);
|
|
72
|
+
}
|
|
73
|
+
return fetchSummaryRows(pmids, 'pubmed search summary');
|
|
74
|
+
},
|
|
75
|
+
});
|