@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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Shared helpers for the OEIS adapter (Online Encyclopedia of Integer Sequences).
|
|
2
|
+
//
|
|
3
|
+
// OEIS exposes a single search endpoint that handles both keyword search and
|
|
4
|
+
// id lookup via `q=id:Annnnnn`. JSON output via `fmt=json`. No API key.
|
|
5
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
6
|
+
|
|
7
|
+
export const OEIS_BASE = 'https://oeis.org';
|
|
8
|
+
const UA = 'opencli-oeis-adapter/1.0 (+https://github.com/jackwener/opencli; mailto:opencli@example.com)';
|
|
9
|
+
|
|
10
|
+
// OEIS ids are A followed by 6 zero-padded digits (older entries use 6 by convention,
|
|
11
|
+
// modern entries can be longer; OEIS itself accepts any digits after A).
|
|
12
|
+
const SEQUENCE_ID_PATTERN = /^A\d{1,7}$/;
|
|
13
|
+
|
|
14
|
+
export function requireString(value, label) {
|
|
15
|
+
const s = String(value ?? '').trim();
|
|
16
|
+
if (!s) throw new ArgumentError(`oeis ${label} cannot be empty`);
|
|
17
|
+
return s;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
|
|
21
|
+
const raw = value ?? defaultValue;
|
|
22
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
23
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
24
|
+
throw new ArgumentError(`oeis ${label} must be a positive integer`);
|
|
25
|
+
}
|
|
26
|
+
if (n > maxValue) {
|
|
27
|
+
throw new ArgumentError(`oeis ${label} must be <= ${maxValue}`);
|
|
28
|
+
}
|
|
29
|
+
return n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function requireSequenceId(value) {
|
|
33
|
+
const raw = String(value ?? '').trim().toUpperCase();
|
|
34
|
+
if (!raw) throw new ArgumentError('oeis sequence id is required (e.g. "A000045" for Fibonacci)');
|
|
35
|
+
// Tolerate common URL paste like `https://oeis.org/A000045`.
|
|
36
|
+
const stripped = raw.replace(/^HTTPS?:\/\/(?:WWW\.)?OEIS\.ORG\//, '').replace(/\/.*$/, '');
|
|
37
|
+
if (!SEQUENCE_ID_PATTERN.test(stripped)) {
|
|
38
|
+
throw new ArgumentError(
|
|
39
|
+
`oeis sequence id "${value}" is not a valid A-number`,
|
|
40
|
+
'Expected format: "A" + digits (e.g. "A000045").',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return stripped;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function oeisFetch(url, label) {
|
|
47
|
+
let resp;
|
|
48
|
+
try {
|
|
49
|
+
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw new CommandExecutionError(
|
|
53
|
+
`${label} request failed: ${err?.message ?? err}`,
|
|
54
|
+
'Check that oeis.org is reachable from this network.',
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (resp.status === 404) {
|
|
58
|
+
throw new EmptyResultError(label, `OEIS returned 404 for ${url}.`);
|
|
59
|
+
}
|
|
60
|
+
if (resp.status === 429) {
|
|
61
|
+
throw new CommandExecutionError(`${label} returned HTTP 429 (rate limited)`);
|
|
62
|
+
}
|
|
63
|
+
if (!resp.ok) {
|
|
64
|
+
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
|
|
65
|
+
}
|
|
66
|
+
let body;
|
|
67
|
+
try {
|
|
68
|
+
body = await resp.json();
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
|
|
72
|
+
}
|
|
73
|
+
return body;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Format OEIS' `number: 40` into the canonical zero-padded id `A000040`. */
|
|
77
|
+
export function formatId(number) {
|
|
78
|
+
if (typeof number !== 'number' || !Number.isInteger(number) || number < 0) return null;
|
|
79
|
+
return `A${String(number).padStart(6, '0')}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Take the first N comma-separated terms from OEIS' `data` string. */
|
|
83
|
+
export function previewTerms(data, max = 12) {
|
|
84
|
+
if (typeof data !== 'string') return '';
|
|
85
|
+
const terms = data.split(',').map((t) => t.trim()).filter(Boolean);
|
|
86
|
+
if (terms.length <= max) return terms.join(', ');
|
|
87
|
+
return [...terms.slice(0, max), `(+${terms.length - max})`].join(', ');
|
|
88
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// openalex search — search OpenAlex's Works index by free text.
|
|
2
|
+
//
|
|
3
|
+
// Hits `https://api.openalex.org/works?search=…&per-page=…`. Returns the
|
|
4
|
+
// agent-useful projection: OpenAlex Work id (round-trips into `openalex
|
|
5
|
+
// work`), DOI, title, year, citation count, first author, primary venue,
|
|
6
|
+
// open-access status.
|
|
7
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
9
|
+
import {
|
|
10
|
+
OPENALEX_BASE,
|
|
11
|
+
appendMailto,
|
|
12
|
+
bareDoi,
|
|
13
|
+
bareId,
|
|
14
|
+
openalexFetch,
|
|
15
|
+
requireBoundedInt,
|
|
16
|
+
requireString,
|
|
17
|
+
} from './utils.js';
|
|
18
|
+
|
|
19
|
+
const SELECT_FIELDS = [
|
|
20
|
+
'id', 'doi', 'title', 'publication_year', 'publication_date',
|
|
21
|
+
'cited_by_count', 'authorships', 'primary_location', 'open_access', 'type',
|
|
22
|
+
].join(',');
|
|
23
|
+
|
|
24
|
+
cli({
|
|
25
|
+
site: 'openalex',
|
|
26
|
+
name: 'search',
|
|
27
|
+
access: 'read',
|
|
28
|
+
description: 'Search OpenAlex Works (papers, books, preprints) by keyword',
|
|
29
|
+
domain: 'api.openalex.org',
|
|
30
|
+
strategy: Strategy.PUBLIC,
|
|
31
|
+
browser: false,
|
|
32
|
+
args: [
|
|
33
|
+
{ name: 'query', positional: true, required: true, help: 'Search text (e.g. "transformers", "open access scholarly")' },
|
|
34
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max works (1-200, single OpenAlex page)' },
|
|
35
|
+
],
|
|
36
|
+
columns: ['rank', 'id', 'title', 'year', 'citations', 'firstAuthor', 'venue', 'openAccess', 'type', 'doi', 'url'],
|
|
37
|
+
func: async (args) => {
|
|
38
|
+
const query = requireString(args.query, 'query');
|
|
39
|
+
const limit = requireBoundedInt(args.limit, 20, 200);
|
|
40
|
+
const url = appendMailto(
|
|
41
|
+
`${OPENALEX_BASE}/works?search=${encodeURIComponent(query)}&per-page=${limit}&select=${SELECT_FIELDS}`,
|
|
42
|
+
);
|
|
43
|
+
const body = await openalexFetch(url, 'openalex search');
|
|
44
|
+
const list = Array.isArray(body?.results) ? body.results : [];
|
|
45
|
+
if (!list.length) {
|
|
46
|
+
throw new EmptyResultError('openalex search', `No OpenAlex works matched "${query}".`);
|
|
47
|
+
}
|
|
48
|
+
return list.slice(0, limit).map((w, i) => {
|
|
49
|
+
const firstAuthor = Array.isArray(w.authorships) && w.authorships.length
|
|
50
|
+
? String(w.authorships[0]?.author?.display_name ?? '').trim()
|
|
51
|
+
: '';
|
|
52
|
+
const venue = String(w.primary_location?.source?.display_name ?? '').trim();
|
|
53
|
+
const id = bareId(w.id);
|
|
54
|
+
return {
|
|
55
|
+
rank: i + 1,
|
|
56
|
+
id,
|
|
57
|
+
title: String(w.title ?? '').trim(),
|
|
58
|
+
year: w.publication_year != null ? Number(w.publication_year) : null,
|
|
59
|
+
citations: w.cited_by_count != null ? Number(w.cited_by_count) : null,
|
|
60
|
+
firstAuthor,
|
|
61
|
+
venue,
|
|
62
|
+
openAccess: Boolean(w.open_access?.is_oa),
|
|
63
|
+
type: String(w.type ?? '').trim(),
|
|
64
|
+
doi: bareDoi(w.doi),
|
|
65
|
+
url: id ? `https://openalex.org/${id}` : '',
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Shared helpers for the OpenAlex (`api.openalex.org`) adapter.
|
|
2
|
+
//
|
|
3
|
+
// OpenAlex is a free, open scholarly works database. The REST API is
|
|
4
|
+
// unauthenticated; passing an email via `mailto=` opts into the polite pool
|
|
5
|
+
// (faster). Work IDs are `W` followed by digits (`W2741809807`) and
|
|
6
|
+
// round-trip via `https://api.openalex.org/works/<id>` or
|
|
7
|
+
// `https://openalex.org/W…`.
|
|
8
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
9
|
+
|
|
10
|
+
export const OPENALEX_BASE = 'https://api.openalex.org';
|
|
11
|
+
const UA = 'opencli-openalex-adapter (+https://github.com/jackwener/opencli)';
|
|
12
|
+
|
|
13
|
+
// OpenAlex stable IDs: a single-letter prefix (`W` works, `A` authors, `S`
|
|
14
|
+
// sources, `I` institutions…) + at least 4 digits. We accept just `W` here.
|
|
15
|
+
const WORK_ID = /^W\d{4,}$/;
|
|
16
|
+
// DOIs are loose — accept anything starting with "10." after the optional
|
|
17
|
+
// `doi.org/` prefix; OpenAlex itself does the normalization.
|
|
18
|
+
const DOI_BARE = /^10\.\S+$/;
|
|
19
|
+
|
|
20
|
+
export function requireString(value, label) {
|
|
21
|
+
const s = String(value ?? '').trim();
|
|
22
|
+
if (!s) throw new ArgumentError(`openalex ${label} cannot be empty`);
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
|
|
27
|
+
const raw = value ?? defaultValue;
|
|
28
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
29
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
30
|
+
throw new ArgumentError(`openalex ${label} must be a positive integer`);
|
|
31
|
+
}
|
|
32
|
+
if (n > maxValue) {
|
|
33
|
+
throw new ArgumentError(`openalex ${label} must be <= ${maxValue}`);
|
|
34
|
+
}
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a user-supplied work identifier to OpenAlex's canonical path
|
|
40
|
+
* segment. Accepts `W…` IDs, `doi:10.…`, raw DOIs, or full
|
|
41
|
+
* `https://doi.org/…` / `https://openalex.org/W…` URLs.
|
|
42
|
+
*/
|
|
43
|
+
export function requireWorkRef(value) {
|
|
44
|
+
const raw = String(value ?? '').trim();
|
|
45
|
+
if (!raw) {
|
|
46
|
+
throw new ArgumentError('openalex work id is required (e.g. "W2741809807", "10.7717/peerj.4375")');
|
|
47
|
+
}
|
|
48
|
+
// 1) full openalex URL
|
|
49
|
+
const oaUrl = raw.match(/^https?:\/\/(?:api\.)?openalex\.org\/(?:works\/)?([WAaSCFwIPwT]\d+)/i);
|
|
50
|
+
if (oaUrl) {
|
|
51
|
+
const id = oaUrl[1].toUpperCase();
|
|
52
|
+
if (id[0] !== 'W') {
|
|
53
|
+
throw new ArgumentError(`openalex work id "${value}" must be a Work (W…) ID, got "${id[0]}…"`);
|
|
54
|
+
}
|
|
55
|
+
return id;
|
|
56
|
+
}
|
|
57
|
+
// 2) bare W… id
|
|
58
|
+
if (WORK_ID.test(raw.toUpperCase())) {
|
|
59
|
+
return raw.toUpperCase();
|
|
60
|
+
}
|
|
61
|
+
// 3) doi:… prefix
|
|
62
|
+
if (/^doi:/i.test(raw)) {
|
|
63
|
+
const doi = raw.replace(/^doi:/i, '').trim();
|
|
64
|
+
if (DOI_BARE.test(doi)) return `doi:${doi}`;
|
|
65
|
+
}
|
|
66
|
+
// 4) full doi URL
|
|
67
|
+
const doiUrl = raw.match(/^https?:\/\/(?:dx\.)?doi\.org\/(.+)$/i);
|
|
68
|
+
if (doiUrl && DOI_BARE.test(doiUrl[1])) {
|
|
69
|
+
return `doi:${doiUrl[1]}`;
|
|
70
|
+
}
|
|
71
|
+
// 5) bare 10.xxxx/yyy DOI
|
|
72
|
+
if (DOI_BARE.test(raw)) {
|
|
73
|
+
return `doi:${raw}`;
|
|
74
|
+
}
|
|
75
|
+
throw new ArgumentError(
|
|
76
|
+
`openalex work id "${value}" is not recognised`,
|
|
77
|
+
'Use a Work id ("W2741809807"), a DOI ("10.7717/peerj.4375"), or a full openalex.org / doi.org URL.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function openalexFetch(url, label) {
|
|
82
|
+
let resp;
|
|
83
|
+
try {
|
|
84
|
+
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
throw new CommandExecutionError(
|
|
88
|
+
`${label} request failed: ${err?.message ?? err}`,
|
|
89
|
+
'Check that api.openalex.org is reachable from this network.',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (resp.status === 404) {
|
|
93
|
+
throw new EmptyResultError(label, `OpenAlex returned 404 for ${url}.`);
|
|
94
|
+
}
|
|
95
|
+
if (resp.status === 429) {
|
|
96
|
+
throw new CommandExecutionError(
|
|
97
|
+
`${label} returned HTTP 429 (rate limited)`,
|
|
98
|
+
'OpenAlex throttles unauthenticated traffic; wait a few seconds and retry, or set OPENALEX_MAILTO.',
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (!resp.ok) {
|
|
102
|
+
let detail = '';
|
|
103
|
+
try {
|
|
104
|
+
const text = await resp.text();
|
|
105
|
+
const match = text.match(/"message"\s*:\s*"([^"]+)"/);
|
|
106
|
+
if (match) detail = ` (${match[1]})`;
|
|
107
|
+
}
|
|
108
|
+
catch { /* ignore */ }
|
|
109
|
+
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}${detail}`);
|
|
110
|
+
}
|
|
111
|
+
let body;
|
|
112
|
+
try {
|
|
113
|
+
body = await resp.json();
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
|
|
117
|
+
}
|
|
118
|
+
return body;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Strip the `https://openalex.org/` prefix if present so columns surface just the bare id. */
|
|
122
|
+
export function bareId(value) {
|
|
123
|
+
const s = String(value ?? '').trim();
|
|
124
|
+
if (!s) return '';
|
|
125
|
+
return s.replace(/^https?:\/\/(?:api\.)?openalex\.org\//i, '').replace(/^works\//i, '');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Strip the `https://doi.org/` prefix so DOIs render as plain `10.…/…` strings. */
|
|
129
|
+
export function bareDoi(value) {
|
|
130
|
+
const s = String(value ?? '').trim();
|
|
131
|
+
if (!s) return '';
|
|
132
|
+
return s.replace(/^https?:\/\/(?:dx\.)?doi\.org\//i, '');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reconstruct a plain-text abstract from OpenAlex's
|
|
137
|
+
* `abstract_inverted_index` (token → [positions]). OpenAlex returns the
|
|
138
|
+
* abstract this way for licensing reasons.
|
|
139
|
+
*/
|
|
140
|
+
export function reconstructAbstract(invertedIndex) {
|
|
141
|
+
if (!invertedIndex || typeof invertedIndex !== 'object') return '';
|
|
142
|
+
const positions = [];
|
|
143
|
+
for (const [token, idxs] of Object.entries(invertedIndex)) {
|
|
144
|
+
if (!Array.isArray(idxs)) continue;
|
|
145
|
+
for (const i of idxs) {
|
|
146
|
+
if (Number.isInteger(i) && i >= 0 && i < 100000) {
|
|
147
|
+
positions[i] = token;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return positions.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Append the polite-pool `mailto` query param if the env var is set. */
|
|
155
|
+
export function appendMailto(url) {
|
|
156
|
+
const mailto = process.env.OPENALEX_MAILTO?.trim();
|
|
157
|
+
if (!mailto) return url;
|
|
158
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
159
|
+
return `${url}${sep}mailto=${encodeURIComponent(mailto)}`;
|
|
160
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// openalex work — fetch a single Work's record from OpenAlex.
|
|
2
|
+
//
|
|
3
|
+
// Hits `https://api.openalex.org/works/<id-or-doi>`. Accepts an OpenAlex
|
|
4
|
+
// Work id (`W2741809807`), a raw DOI (`10.7717/peerj.4375`), or a full
|
|
5
|
+
// `doi.org` / `openalex.org` URL. Returns one row plus the (decoded)
|
|
6
|
+
// abstract — OpenAlex stores abstracts as `abstract_inverted_index` so we
|
|
7
|
+
// reconstruct it for downstream readers.
|
|
8
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
9
|
+
import {
|
|
10
|
+
OPENALEX_BASE,
|
|
11
|
+
appendMailto,
|
|
12
|
+
bareDoi,
|
|
13
|
+
bareId,
|
|
14
|
+
openalexFetch,
|
|
15
|
+
reconstructAbstract,
|
|
16
|
+
requireWorkRef,
|
|
17
|
+
} from './utils.js';
|
|
18
|
+
|
|
19
|
+
const SELECT_FIELDS = [
|
|
20
|
+
'id', 'doi', 'title', 'publication_year', 'publication_date',
|
|
21
|
+
'cited_by_count', 'authorships', 'primary_location', 'open_access', 'type',
|
|
22
|
+
'referenced_works', 'related_works', 'language', 'abstract_inverted_index',
|
|
23
|
+
].join(',');
|
|
24
|
+
|
|
25
|
+
cli({
|
|
26
|
+
site: 'openalex',
|
|
27
|
+
name: 'work',
|
|
28
|
+
access: 'read',
|
|
29
|
+
description: 'Fetch a single OpenAlex Work (paper / preprint / book) — metadata + abstract',
|
|
30
|
+
domain: 'api.openalex.org',
|
|
31
|
+
strategy: Strategy.PUBLIC,
|
|
32
|
+
browser: false,
|
|
33
|
+
args: [
|
|
34
|
+
{ name: 'id', positional: true, required: true, help: 'OpenAlex Work id ("W2741809807"), DOI ("10.7717/peerj.4375"), or full URL' },
|
|
35
|
+
],
|
|
36
|
+
columns: ['id', 'title', 'type', 'year', 'date', 'language', 'authors', 'venue', 'citations', 'openAccess', 'openAccessUrl', 'referencedCount', 'doi', 'abstract', 'url'],
|
|
37
|
+
func: async (args) => {
|
|
38
|
+
const ref = requireWorkRef(args.id);
|
|
39
|
+
const url = appendMailto(`${OPENALEX_BASE}/works/${encodeURIComponent(ref)}?select=${SELECT_FIELDS}`);
|
|
40
|
+
const w = await openalexFetch(url, 'openalex work');
|
|
41
|
+
const authors = Array.isArray(w.authorships)
|
|
42
|
+
? w.authorships.map((a) => String(a?.author?.display_name ?? '').trim()).filter(Boolean).join(', ')
|
|
43
|
+
: '';
|
|
44
|
+
const venue = String(w.primary_location?.source?.display_name ?? '').trim();
|
|
45
|
+
const id = bareId(w.id);
|
|
46
|
+
const oaUrl = String(w.open_access?.oa_url ?? '').trim();
|
|
47
|
+
return [{
|
|
48
|
+
id,
|
|
49
|
+
title: String(w.title ?? '').trim(),
|
|
50
|
+
type: String(w.type ?? '').trim(),
|
|
51
|
+
year: w.publication_year != null ? Number(w.publication_year) : null,
|
|
52
|
+
date: String(w.publication_date ?? '').trim(),
|
|
53
|
+
language: String(w.language ?? '').trim(),
|
|
54
|
+
authors,
|
|
55
|
+
venue,
|
|
56
|
+
citations: w.cited_by_count != null ? Number(w.cited_by_count) : null,
|
|
57
|
+
openAccess: Boolean(w.open_access?.is_oa),
|
|
58
|
+
openAccessUrl: oaUrl,
|
|
59
|
+
referencedCount: Array.isArray(w.referenced_works) ? w.referenced_works.length : null,
|
|
60
|
+
doi: bareDoi(w.doi),
|
|
61
|
+
abstract: reconstructAbstract(w.abstract_inverted_index),
|
|
62
|
+
url: id ? `https://openalex.org/${id}` : '',
|
|
63
|
+
}];
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// openfda drug-label — FDA-approved drug label search.
|
|
2
|
+
//
|
|
3
|
+
// Endpoint: GET /drug/label.json?search=<lucene>&limit=<n>
|
|
4
|
+
// Default search field is brand_name OR generic_name (Lucene syntax via openfda
|
|
5
|
+
// query DSL). Returns label sections (purpose, warnings, dosage, etc.) and
|
|
6
|
+
// metadata (manufacturer, product_ndc, route, etc.).
|
|
7
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
9
|
+
import {
|
|
10
|
+
OPENFDA_BASE,
|
|
11
|
+
firstOrNull,
|
|
12
|
+
joinOrNull,
|
|
13
|
+
openfdaFetch,
|
|
14
|
+
requireBoundedInt,
|
|
15
|
+
requireString,
|
|
16
|
+
} from './utils.js';
|
|
17
|
+
|
|
18
|
+
cli({
|
|
19
|
+
site: 'openfda',
|
|
20
|
+
name: 'drug-label',
|
|
21
|
+
access: 'read',
|
|
22
|
+
description: 'Search FDA-approved drug labels (brand or generic name)',
|
|
23
|
+
domain: 'fda.gov',
|
|
24
|
+
strategy: Strategy.PUBLIC,
|
|
25
|
+
browser: false,
|
|
26
|
+
args: [
|
|
27
|
+
{ name: 'query', positional: true, required: true, help: 'Brand or generic drug name (e.g. "aspirin", "lisinopril")' },
|
|
28
|
+
{ name: 'limit', type: 'int', default: 5, help: 'Max rows (1-25, default 5; openFDA caps anonymous tier at 25/page)' },
|
|
29
|
+
],
|
|
30
|
+
columns: [
|
|
31
|
+
'rank', 'id', 'brandName', 'genericName', 'manufacturer',
|
|
32
|
+
'productType', 'route', 'productNdc', 'pharmClass',
|
|
33
|
+
'purpose', 'indications', 'warnings', 'dosage', 'effectiveTime',
|
|
34
|
+
],
|
|
35
|
+
func: async (args) => {
|
|
36
|
+
const query = requireString(args.query, 'query');
|
|
37
|
+
const limit = requireBoundedInt(args.limit, 5, 25);
|
|
38
|
+
const brand = `openfda.brand_name:"${query}"`;
|
|
39
|
+
const generic = `openfda.generic_name:"${query}"`;
|
|
40
|
+
// URLSearchParams encodes spaces/operators in ways openFDA's Lucene
|
|
41
|
+
// parser handles poorly. Keep the OR literal visible and encode only
|
|
42
|
+
// each clause, matching food-recall's manual +AND+ handling.
|
|
43
|
+
const search = `${encodeURIComponent(brand)}+OR+${encodeURIComponent(generic)}`;
|
|
44
|
+
const url = `${OPENFDA_BASE}/drug/label.json?search=${search}&limit=${limit}`;
|
|
45
|
+
const body = await openfdaFetch(url, 'openfda drug-label');
|
|
46
|
+
const list = Array.isArray(body?.results) ? body.results : [];
|
|
47
|
+
if (!list.length) {
|
|
48
|
+
throw new EmptyResultError('openfda drug-label', `openFDA returned no labels matching "${query}".`);
|
|
49
|
+
}
|
|
50
|
+
return list.map((r, i) => {
|
|
51
|
+
const o = r?.openfda ?? {};
|
|
52
|
+
// pharm_class fields: epc (established pharmacologic class) is the
|
|
53
|
+
// most user-meaningful — fall back through moa/cs/pe in that order.
|
|
54
|
+
const pharmClass = firstOrNull(o.pharm_class_epc) ?? firstOrNull(o.pharm_class_moa)
|
|
55
|
+
?? firstOrNull(o.pharm_class_cs) ?? firstOrNull(o.pharm_class_pe);
|
|
56
|
+
return {
|
|
57
|
+
rank: i + 1,
|
|
58
|
+
id: r?.id ?? null,
|
|
59
|
+
brandName: firstOrNull(o.brand_name),
|
|
60
|
+
genericName: firstOrNull(o.generic_name),
|
|
61
|
+
manufacturer: firstOrNull(o.manufacturer_name),
|
|
62
|
+
productType: firstOrNull(o.product_type),
|
|
63
|
+
route: joinOrNull(o.route),
|
|
64
|
+
productNdc: firstOrNull(o.product_ndc),
|
|
65
|
+
pharmClass,
|
|
66
|
+
purpose: firstOrNull(r.purpose),
|
|
67
|
+
indications: firstOrNull(r.indications_and_usage),
|
|
68
|
+
warnings: firstOrNull(r.warnings),
|
|
69
|
+
dosage: firstOrNull(r.dosage_and_administration),
|
|
70
|
+
effectiveTime: r?.effective_time ?? null,
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// openfda food-recall — FDA food enforcement (recalls, market withdrawals, alerts).
|
|
2
|
+
//
|
|
3
|
+
// Endpoint: GET /food/enforcement.json?search=<lucene>&limit=<n>
|
|
4
|
+
// Sorted by report_date descending (most recent first).
|
|
5
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { OPENFDA_BASE, openfdaFetch, requireBoundedInt } from './utils.js';
|
|
8
|
+
|
|
9
|
+
cli({
|
|
10
|
+
site: 'openfda',
|
|
11
|
+
name: 'food-recall',
|
|
12
|
+
access: 'read',
|
|
13
|
+
description: 'FDA food recall and enforcement actions (most recent first)',
|
|
14
|
+
domain: 'fda.gov',
|
|
15
|
+
strategy: Strategy.PUBLIC,
|
|
16
|
+
browser: false,
|
|
17
|
+
args: [
|
|
18
|
+
{ name: 'query', help: 'Free-text Lucene query (e.g. "salmonella", "listeria"); default: all recent recalls' },
|
|
19
|
+
{ name: 'status', help: 'Filter by status: "Ongoing", "Completed", "Terminated"' },
|
|
20
|
+
{ name: 'classification', help: 'Filter by class: "Class I" (most serious), "Class II", "Class III"' },
|
|
21
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Max rows (1-100, default 10; openFDA caps anonymous tier at 100/page)' },
|
|
22
|
+
],
|
|
23
|
+
columns: [
|
|
24
|
+
'rank', 'recallNumber', 'status', 'classification', 'voluntary',
|
|
25
|
+
'recallingFirm', 'city', 'state', 'country',
|
|
26
|
+
'productDescription', 'reasonForRecall', 'productQuantity',
|
|
27
|
+
'distributionPattern', 'reportDate', 'recallInitiationDate', 'terminationDate',
|
|
28
|
+
],
|
|
29
|
+
func: async (args) => {
|
|
30
|
+
const limit = requireBoundedInt(args.limit, 10, 100);
|
|
31
|
+
const filters = [];
|
|
32
|
+
if (args.query) filters.push(String(args.query).trim());
|
|
33
|
+
if (args.status) filters.push(`status:"${String(args.status).trim()}"`);
|
|
34
|
+
if (args.classification) filters.push(`classification:"${String(args.classification).trim()}"`);
|
|
35
|
+
// URLSearchParams percent-encodes the `+AND+` separator that openFDA's
|
|
36
|
+
// Lucene parser treats specially, so build the query string by hand.
|
|
37
|
+
const qs = filters.length
|
|
38
|
+
? `search=${filters.map(f => encodeURIComponent(f)).join('+AND+')}&limit=${limit}`
|
|
39
|
+
: `limit=${limit}`;
|
|
40
|
+
const url = `${OPENFDA_BASE}/food/enforcement.json?${qs}`;
|
|
41
|
+
const body = await openfdaFetch(url, 'openfda food-recall');
|
|
42
|
+
const list = Array.isArray(body?.results) ? body.results : [];
|
|
43
|
+
if (!list.length) {
|
|
44
|
+
throw new EmptyResultError('openfda food-recall', 'openFDA returned no food recall records matching the filter.');
|
|
45
|
+
}
|
|
46
|
+
return list.map((r, i) => ({
|
|
47
|
+
rank: i + 1,
|
|
48
|
+
recallNumber: r?.recall_number ?? null,
|
|
49
|
+
status: r?.status ?? null,
|
|
50
|
+
classification: r?.classification ?? null,
|
|
51
|
+
voluntary: r?.voluntary_mandated ?? null,
|
|
52
|
+
recallingFirm: r?.recalling_firm ?? null,
|
|
53
|
+
city: r?.city ?? null,
|
|
54
|
+
state: r?.state ?? null,
|
|
55
|
+
country: r?.country ?? null,
|
|
56
|
+
productDescription: r?.product_description ?? null,
|
|
57
|
+
reasonForRecall: r?.reason_for_recall ?? null,
|
|
58
|
+
productQuantity: r?.product_quantity ?? null,
|
|
59
|
+
distributionPattern: r?.distribution_pattern ?? null,
|
|
60
|
+
reportDate: r?.report_date ?? null,
|
|
61
|
+
recallInitiationDate: r?.recall_initiation_date ?? null,
|
|
62
|
+
terminationDate: r?.termination_date ?? null,
|
|
63
|
+
}));
|
|
64
|
+
},
|
|
65
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './drug-label.js';
|
|
5
|
+
import './food-recall.js';
|
|
6
|
+
|
|
7
|
+
const origFetch = global.fetch;
|
|
8
|
+
afterEach(() => { global.fetch = origFetch; });
|
|
9
|
+
|
|
10
|
+
const sampleLabel = {
|
|
11
|
+
id: 'abcde-12345',
|
|
12
|
+
effective_time: '20250101',
|
|
13
|
+
purpose: ['Pain reliever'],
|
|
14
|
+
indications_and_usage: ['For temporary relief of minor aches and pains.'],
|
|
15
|
+
warnings: ['Allergy alert: do not use if you are allergic to NSAIDs.'],
|
|
16
|
+
dosage_and_administration: ['Take 1-2 tablets every 4-6 hours.'],
|
|
17
|
+
openfda: {
|
|
18
|
+
brand_name: ['Aspirin Bayer'],
|
|
19
|
+
generic_name: ['ASPIRIN'],
|
|
20
|
+
manufacturer_name: ['Bayer HealthCare LLC'],
|
|
21
|
+
product_type: ['HUMAN OTC DRUG'],
|
|
22
|
+
route: ['ORAL'],
|
|
23
|
+
product_ndc: ['0280-1234'],
|
|
24
|
+
pharm_class_epc: ['Nonsteroidal Anti-inflammatory Drug [EPC]'],
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const sampleRecall = {
|
|
29
|
+
recall_number: 'F-1234-2026',
|
|
30
|
+
status: 'Ongoing',
|
|
31
|
+
classification: 'Class I',
|
|
32
|
+
voluntary_mandated: 'Voluntary',
|
|
33
|
+
recalling_firm: 'Acme Foods Inc',
|
|
34
|
+
city: 'Atlanta', state: 'GA', country: 'United States',
|
|
35
|
+
product_description: 'Acme Salad Mix 12oz',
|
|
36
|
+
reason_for_recall: 'Listeria monocytogenes contamination',
|
|
37
|
+
product_quantity: '20000 cases',
|
|
38
|
+
distribution_pattern: 'Nationwide',
|
|
39
|
+
report_date: '20260415',
|
|
40
|
+
recall_initiation_date: '20260410',
|
|
41
|
+
termination_date: null,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('openfda drug-label', () => {
|
|
45
|
+
const cmd = getRegistry().get('openfda/drug-label');
|
|
46
|
+
|
|
47
|
+
it('rejects empty query', async () => {
|
|
48
|
+
await expect(cmd.func({ query: '' })).rejects.toBeInstanceOf(ArgumentError);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects --limit out of range', async () => {
|
|
52
|
+
await expect(cmd.func({ query: 'aspirin', limit: 99 })).rejects.toBeInstanceOf(ArgumentError);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('promotes 404 to EmptyResultError', async () => {
|
|
56
|
+
global.fetch = vi.fn(() => Promise.resolve(new Response('{}', { status: 404 })));
|
|
57
|
+
await expect(cmd.func({ query: 'unobtainium' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('shapes drug-label rows + collapses 1-elem arrays', async () => {
|
|
61
|
+
global.fetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ results: [sampleLabel] }), { status: 200 })));
|
|
62
|
+
const rows = await cmd.func({ query: 'aspirin', limit: 1 });
|
|
63
|
+
expect(rows).toHaveLength(1);
|
|
64
|
+
expect(rows[0]).toMatchObject({
|
|
65
|
+
rank: 1, brandName: 'Aspirin Bayer', genericName: 'ASPIRIN',
|
|
66
|
+
manufacturer: 'Bayer HealthCare LLC', purpose: 'Pain reliever',
|
|
67
|
+
pharmClass: 'Nonsteroidal Anti-inflammatory Drug [EPC]',
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('uses brand OR generic search instead of requiring both fields to match', async () => {
|
|
72
|
+
const calls = [];
|
|
73
|
+
global.fetch = vi.fn((url) => {
|
|
74
|
+
calls.push(url);
|
|
75
|
+
return Promise.resolve(new Response(JSON.stringify({ results: [sampleLabel] }), { status: 200 }));
|
|
76
|
+
});
|
|
77
|
+
await cmd.func({ query: 'tylenol', limit: 1 });
|
|
78
|
+
expect(calls[0]).toContain('+OR+');
|
|
79
|
+
expect(calls[0]).toContain('openfda.brand_name');
|
|
80
|
+
expect(calls[0]).toContain('openfda.generic_name');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('openfda food-recall', () => {
|
|
85
|
+
const cmd = getRegistry().get('openfda/food-recall');
|
|
86
|
+
|
|
87
|
+
it('promotes 429 to CommandExecutionError', async () => {
|
|
88
|
+
global.fetch = vi.fn(() => Promise.resolve(new Response('rate', { status: 429 })));
|
|
89
|
+
await expect(cmd.func({})).rejects.toBeInstanceOf(CommandExecutionError);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('shapes food-recall rows + carries report_date', async () => {
|
|
93
|
+
global.fetch = vi.fn(() => Promise.resolve(new Response(JSON.stringify({ results: [sampleRecall] }), { status: 200 })));
|
|
94
|
+
const rows = await cmd.func({ classification: 'Class I' });
|
|
95
|
+
expect(rows).toHaveLength(1);
|
|
96
|
+
expect(rows[0]).toMatchObject({
|
|
97
|
+
rank: 1, recallNumber: 'F-1234-2026', status: 'Ongoing',
|
|
98
|
+
classification: 'Class I', recallingFirm: 'Acme Foods Inc',
|
|
99
|
+
reportDate: '20260415',
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('threads --query AND --status into Lucene query string', async () => {
|
|
104
|
+
const calls = [];
|
|
105
|
+
global.fetch = vi.fn((url) => {
|
|
106
|
+
calls.push(url);
|
|
107
|
+
return Promise.resolve(new Response(JSON.stringify({ results: [sampleRecall] }), { status: 200 }));
|
|
108
|
+
});
|
|
109
|
+
await cmd.func({ query: 'salmonella', status: 'Ongoing' });
|
|
110
|
+
// Verify both clauses survived URL encoding (the literal `+AND+` should NOT be percent-escaped).
|
|
111
|
+
expect(calls[0]).toContain('+AND+');
|
|
112
|
+
expect(calls[0]).toContain('salmonella');
|
|
113
|
+
});
|
|
114
|
+
});
|