@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,222 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { __test__ } from './search.js';
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
parseCsvArg,
|
|
8
|
+
parseIntegerArg,
|
|
9
|
+
mapFilterValues,
|
|
10
|
+
decodeLinkedinRedirect,
|
|
11
|
+
looksLinkedInAuthWallText,
|
|
12
|
+
enrichJobDetails,
|
|
13
|
+
EXPERIENCE_LEVELS,
|
|
14
|
+
JOB_TYPES,
|
|
15
|
+
DATE_POSTED,
|
|
16
|
+
REMOTE_TYPES,
|
|
17
|
+
} = __test__;
|
|
18
|
+
|
|
19
|
+
const getSearchCommand = () => getRegistry().get('linkedin/search');
|
|
20
|
+
|
|
21
|
+
describe('linkedin parseCsvArg', () => {
|
|
22
|
+
it('returns empty array for empty / null / undefined', () => {
|
|
23
|
+
expect(parseCsvArg(undefined)).toEqual([]);
|
|
24
|
+
expect(parseCsvArg(null)).toEqual([]);
|
|
25
|
+
expect(parseCsvArg('')).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('splits and trims comma-separated values', () => {
|
|
29
|
+
expect(parseCsvArg('full-time, contract')).toEqual(['full-time', 'contract']);
|
|
30
|
+
expect(parseCsvArg(' a , b , , c ')).toEqual(['a', 'b', 'c']);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('linkedin mapFilterValues', () => {
|
|
35
|
+
it('maps known values to upstream codes and dedupes', () => {
|
|
36
|
+
expect(mapFilterValues('full-time, contract, full', JOB_TYPES, 'job_type')).toEqual(['F', 'C']);
|
|
37
|
+
expect(mapFilterValues('remote, hybrid', REMOTE_TYPES, 'remote')).toEqual(['2', '3']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('throws ArgumentError on unknown filter values (no silent drop)', () => {
|
|
41
|
+
expect(() => mapFilterValues('martian', JOB_TYPES, 'job_type')).toThrow(ArgumentError);
|
|
42
|
+
expect(() => mapFilterValues('full-time, ufo', JOB_TYPES, 'job_type')).toThrow(ArgumentError);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns empty array for empty input', () => {
|
|
46
|
+
expect(mapFilterValues('', EXPERIENCE_LEVELS, 'experience_level')).toEqual([]);
|
|
47
|
+
expect(mapFilterValues(undefined, DATE_POSTED, 'date_posted')).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('linkedin argument validation', () => {
|
|
52
|
+
it('rejects --limit outside 1..100 instead of silently clamping', () => {
|
|
53
|
+
expect(() => parseIntegerArg(0, '--limit', 10, 1, 100)).toThrow(ArgumentError);
|
|
54
|
+
expect(() => parseIntegerArg(101, '--limit', 10, 1, 100)).toThrow(ArgumentError);
|
|
55
|
+
expect(() => parseIntegerArg('10.5', '--limit', 10, 1, 100)).toThrow(ArgumentError);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects negative --start instead of silently clamping to zero', () => {
|
|
59
|
+
expect(() => parseIntegerArg(-1, '--start', 0, 0)).toThrow(ArgumentError);
|
|
60
|
+
expect(parseIntegerArg(undefined, '--start', 0, 0)).toBe(0);
|
|
61
|
+
expect(parseIntegerArg('25', '--start', 0, 0)).toBe(25);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('validates command args before browser navigation', async () => {
|
|
65
|
+
const command = getSearchCommand();
|
|
66
|
+
const page = { goto: vi.fn(), wait: vi.fn(), evaluate: vi.fn() };
|
|
67
|
+
|
|
68
|
+
await expect(command.func(page, { query: 'engineer', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
|
|
69
|
+
await expect(command.func(page, { query: 'engineer', start: -1 })).rejects.toBeInstanceOf(ArgumentError);
|
|
70
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('linkedin auth wall detection', () => {
|
|
75
|
+
it('recognizes login/authwall signals', () => {
|
|
76
|
+
expect(looksLinkedInAuthWallText('https://www.linkedin.com/authwall?trk=guest Sign in to continue')).toBe(true);
|
|
77
|
+
expect(looksLinkedInAuthWallText('LinkedIn Login, Sign in')).toBe(true);
|
|
78
|
+
expect(looksLinkedInAuthWallText('About the job Senior infrastructure engineer')).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('throws AuthRequiredError when search lands on a login wall', async () => {
|
|
82
|
+
const command = getSearchCommand();
|
|
83
|
+
const page = {
|
|
84
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
85
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
86
|
+
evaluate: vi.fn().mockResolvedValue(true),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
await expect(command.func(page, { query: 'engineer', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('linkedin decodeLinkedinRedirect', () => {
|
|
94
|
+
it('extracts the underlying url from a /redir/redirect/ wrapper', () => {
|
|
95
|
+
const target = 'https://example.com/jobs/apply?id=42';
|
|
96
|
+
const wrapped = `https://www.linkedin.com/redir/redirect/?url=${encodeURIComponent(target)}&source=jobs`;
|
|
97
|
+
expect(decodeLinkedinRedirect(wrapped)).toBe(target);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns the input unchanged for non-redirect urls', () => {
|
|
101
|
+
const direct = 'https://example.com/jobs/42/';
|
|
102
|
+
expect(decodeLinkedinRedirect(direct)).toBe(direct);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns empty string for falsy input', () => {
|
|
106
|
+
expect(decodeLinkedinRedirect('')).toBe('');
|
|
107
|
+
expect(decodeLinkedinRedirect(null)).toBe('');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('linkedin enrichJobDetails (silent failure fix)', () => {
|
|
112
|
+
function makeFakePage({ evaluateResults = [], gotoFails = [], evaluateFails = [] } = {}) {
|
|
113
|
+
let evalCall = 0;
|
|
114
|
+
let gotoCall = 0;
|
|
115
|
+
return {
|
|
116
|
+
goto: vi.fn(async () => {
|
|
117
|
+
if (gotoFails[gotoCall++]) {
|
|
118
|
+
throw new Error(gotoFails[gotoCall - 1]);
|
|
119
|
+
}
|
|
120
|
+
}),
|
|
121
|
+
wait: vi.fn(async () => undefined),
|
|
122
|
+
evaluate: vi.fn(async () => {
|
|
123
|
+
const idx = evalCall++;
|
|
124
|
+
if (evaluateFails[idx]) throw new Error(evaluateFails[idx]);
|
|
125
|
+
return evaluateResults[idx];
|
|
126
|
+
}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
it('surfaces detail_error="no url" when row has no URL (instead of silent empty string)', async () => {
|
|
131
|
+
const page = makeFakePage();
|
|
132
|
+
const out = await enrichJobDetails(page, [
|
|
133
|
+
{ rank: 1, title: 'No URL Job', company: 'X', url: '' },
|
|
134
|
+
]);
|
|
135
|
+
expect(out).toHaveLength(1);
|
|
136
|
+
expect(out[0]).toMatchObject({
|
|
137
|
+
description: null,
|
|
138
|
+
apply_url: null,
|
|
139
|
+
detail_error: 'no url',
|
|
140
|
+
});
|
|
141
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('surfaces detail_error="fetch failed: ..." when goto throws (no silent swallow)', async () => {
|
|
145
|
+
const page = makeFakePage({ gotoFails: ['network down'] });
|
|
146
|
+
const out = await enrichJobDetails(page, [
|
|
147
|
+
{ rank: 1, title: 'Fetch Fail', company: 'X', url: 'https://www.linkedin.com/jobs/view/1' },
|
|
148
|
+
]);
|
|
149
|
+
expect(out).toHaveLength(1);
|
|
150
|
+
expect(out[0].description).toBeNull();
|
|
151
|
+
expect(out[0].apply_url).toBeNull();
|
|
152
|
+
expect(out[0].detail_error).toMatch(/^fetch failed: .*network down/);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('surfaces detail_error="missing description" on empty description (signals upstream gap, not crash)', async () => {
|
|
156
|
+
const page = makeFakePage({
|
|
157
|
+
evaluateResults: [
|
|
158
|
+
false, // auth wall probe
|
|
159
|
+
undefined, // expand-show-more click result (ignored)
|
|
160
|
+
{ description: '', applyUrl: '' },
|
|
161
|
+
],
|
|
162
|
+
});
|
|
163
|
+
const out = await enrichJobDetails(page, [
|
|
164
|
+
{ rank: 1, title: 'Empty Desc', company: 'X', url: 'https://www.linkedin.com/jobs/view/2' },
|
|
165
|
+
]);
|
|
166
|
+
expect(out[0].description).toBeNull();
|
|
167
|
+
expect(out[0].apply_url).toBeNull();
|
|
168
|
+
expect(out[0].detail_error).toBe('missing description');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('surfaces detail_error=null on a fully successful enrichment', async () => {
|
|
172
|
+
const page = makeFakePage({
|
|
173
|
+
evaluateResults: [
|
|
174
|
+
false, // auth wall probe
|
|
175
|
+
undefined,
|
|
176
|
+
{ description: ' An interesting role ', applyUrl: 'https://example.com/apply' },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
const out = await enrichJobDetails(page, [
|
|
180
|
+
{ rank: 1, title: 'OK', company: 'X', url: 'https://www.linkedin.com/jobs/view/3' },
|
|
181
|
+
]);
|
|
182
|
+
expect(out[0]).toMatchObject({
|
|
183
|
+
description: 'An interesting role',
|
|
184
|
+
apply_url: 'https://example.com/apply',
|
|
185
|
+
detail_error: null,
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('processes multiple rows with mixed outcomes without aborting the batch', async () => {
|
|
190
|
+
const page = makeFakePage({
|
|
191
|
+
evaluateResults: [
|
|
192
|
+
// Row 1 (success)
|
|
193
|
+
false,
|
|
194
|
+
undefined,
|
|
195
|
+
{ description: 'Good', applyUrl: 'https://a.example/' },
|
|
196
|
+
// Row 3 (success — row 2 had no URL so didn't navigate)
|
|
197
|
+
false,
|
|
198
|
+
undefined,
|
|
199
|
+
{ description: 'Also good', applyUrl: 'https://b.example/' },
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
const out = await enrichJobDetails(page, [
|
|
203
|
+
{ rank: 1, title: 'A', company: 'X', url: 'https://www.linkedin.com/jobs/view/10' },
|
|
204
|
+
{ rank: 2, title: 'B', company: 'X', url: '' },
|
|
205
|
+
{ rank: 3, title: 'C', company: 'X', url: 'https://www.linkedin.com/jobs/view/30' },
|
|
206
|
+
]);
|
|
207
|
+
expect(out).toHaveLength(3);
|
|
208
|
+
expect(out[0].detail_error).toBeNull();
|
|
209
|
+
expect(out[1].detail_error).toBe('no url');
|
|
210
|
+
expect(out[2].detail_error).toBeNull();
|
|
211
|
+
// Goto was only called for rows with URL (1 and 3)
|
|
212
|
+
expect(page.goto).toHaveBeenCalledTimes(2);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('throws AuthRequiredError on detail auth wall instead of burying it in detail_error', async () => {
|
|
216
|
+
const page = makeFakePage({ evaluateResults: [true] });
|
|
217
|
+
|
|
218
|
+
await expect(enrichJobDetails(page, [
|
|
219
|
+
{ rank: 1, title: 'Needs Auth', company: 'X', url: 'https://www.linkedin.com/jobs/view/4' },
|
|
220
|
+
])).rejects.toBeInstanceOf(AuthRequiredError);
|
|
221
|
+
});
|
|
222
|
+
});
|
package/clis/linux-do/feed.js
CHANGED
|
@@ -355,16 +355,13 @@ export const LINUX_DO_FEED_ARGS = [
|
|
|
355
355
|
choices: ['all', 'daily', 'weekly', 'monthly', 'quarterly', 'yearly'],
|
|
356
356
|
},
|
|
357
357
|
];
|
|
358
|
-
|
|
358
|
+
async function runLinuxDoFeed(page, kwargs) {
|
|
359
359
|
const limit = (kwargs.limit || 20);
|
|
360
360
|
await ensureLinuxDoHome(page);
|
|
361
361
|
const request = await resolveFeedRequest(page, kwargs);
|
|
362
362
|
const data = await fetchLinuxDoJson(page, request.url, { skipNavigate: true });
|
|
363
363
|
return topicListRichFromJson(data, limit);
|
|
364
364
|
}
|
|
365
|
-
export function buildLinuxDoCompatFooter(replacement) {
|
|
366
|
-
return `Deprecated compatibility command. Prefer: ${replacement}`;
|
|
367
|
-
}
|
|
368
365
|
cli({
|
|
369
366
|
site: 'linux-do',
|
|
370
367
|
name: 'feed',
|
|
@@ -375,7 +372,7 @@ cli({
|
|
|
375
372
|
browser: true,
|
|
376
373
|
columns: ['title', 'replies', 'created', 'likes', 'views', 'url'],
|
|
377
374
|
args: LINUX_DO_FEED_ARGS,
|
|
378
|
-
func:
|
|
375
|
+
func: runLinuxDoFeed,
|
|
379
376
|
});
|
|
380
377
|
export const __test__ = {
|
|
381
378
|
resetMetadataCaches() {
|
|
@@ -7,6 +7,21 @@ describe('linux-do feed metadata resolution', () => {
|
|
|
7
7
|
afterEach(() => {
|
|
8
8
|
__test__.resetMetadataCaches();
|
|
9
9
|
});
|
|
10
|
+
it('builds the replacement URL for legacy latest', async () => {
|
|
11
|
+
const request = await __test__.resolveFeedRequest(null, {
|
|
12
|
+
view: 'latest',
|
|
13
|
+
limit: 20,
|
|
14
|
+
});
|
|
15
|
+
expect(request.url).toBe('/latest.json?per_page=20');
|
|
16
|
+
});
|
|
17
|
+
it('builds the replacement URL for legacy hot weekly', async () => {
|
|
18
|
+
const request = await __test__.resolveFeedRequest(null, {
|
|
19
|
+
view: 'top',
|
|
20
|
+
period: 'weekly',
|
|
21
|
+
limit: 20,
|
|
22
|
+
});
|
|
23
|
+
expect(request.url).toBe('/top.json?per_page=20&period=weekly');
|
|
24
|
+
});
|
|
10
25
|
it('prefers live tag metadata over the bundled snapshot', async () => {
|
|
11
26
|
__test__.setLiveMetadataForTests({
|
|
12
27
|
tags: [{ id: 9999, slug: 'fresh-tag', name: 'Fresh Tag' }],
|
|
@@ -86,6 +101,26 @@ describe('linux-do feed metadata resolution', () => {
|
|
|
86
101
|
});
|
|
87
102
|
expect(request.url).toBe('/c/parent/fresh-child/11.json?per_page=20');
|
|
88
103
|
});
|
|
104
|
+
it('builds the replacement URL for legacy category id', async () => {
|
|
105
|
+
__test__.setLiveMetadataForTests({
|
|
106
|
+
categories: [
|
|
107
|
+
{
|
|
108
|
+
id: 4,
|
|
109
|
+
name: '开发调优',
|
|
110
|
+
description: '',
|
|
111
|
+
slug: 'develop',
|
|
112
|
+
parentCategoryId: null,
|
|
113
|
+
parent: null,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
const request = await __test__.resolveFeedRequest(null, {
|
|
118
|
+
category: '4',
|
|
119
|
+
view: 'latest',
|
|
120
|
+
limit: 20,
|
|
121
|
+
});
|
|
122
|
+
expect(request.url).toBe('/c/develop/4.json?per_page=20');
|
|
123
|
+
});
|
|
89
124
|
it('falls back to cached metadata when live metadata is unavailable', async () => {
|
|
90
125
|
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-linux-do-cache-'));
|
|
91
126
|
__test__.setCacheDirForTests(cacheDir);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// lobsters domain — list Lobste.rs stories submitted from a specific domain.
|
|
2
|
+
//
|
|
3
|
+
// Hits the public `https://lobste.rs/domains/<domain>.json` endpoint
|
|
4
|
+
// (returns the same per-story shape used by `lobsters tag` / `lobsters
|
|
5
|
+
// hot`). Lets agents ask "what did Lobsters surface from github.com /
|
|
6
|
+
// blog.cloudflare.com / arxiv.org lately?".
|
|
7
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
9
|
+
|
|
10
|
+
const DOMAIN_PATTERN = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i;
|
|
11
|
+
|
|
12
|
+
function requireDomain(value) {
|
|
13
|
+
const s = String(value ?? '').trim().toLowerCase();
|
|
14
|
+
if (!s) {
|
|
15
|
+
throw new ArgumentError('lobsters domain is required (e.g. "github.com" or "arxiv.org")');
|
|
16
|
+
}
|
|
17
|
+
if (!DOMAIN_PATTERN.test(s)) {
|
|
18
|
+
throw new ArgumentError(`lobsters domain "${value}" is not a valid hostname`);
|
|
19
|
+
}
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function requireBoundedInt(value, defaultValue, maxValue) {
|
|
24
|
+
const raw = value ?? defaultValue;
|
|
25
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
26
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
27
|
+
throw new ArgumentError('lobsters limit must be a positive integer');
|
|
28
|
+
}
|
|
29
|
+
if (n > maxValue) {
|
|
30
|
+
throw new ArgumentError(`lobsters limit must be <= ${maxValue}`);
|
|
31
|
+
}
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
cli({
|
|
36
|
+
site: 'lobsters',
|
|
37
|
+
name: 'domain',
|
|
38
|
+
access: 'read',
|
|
39
|
+
description: 'Lobste.rs stories submitted from a specific domain',
|
|
40
|
+
domain: 'lobste.rs',
|
|
41
|
+
strategy: Strategy.PUBLIC,
|
|
42
|
+
browser: false,
|
|
43
|
+
args: [
|
|
44
|
+
{ name: 'domain', positional: true, required: true, help: 'Source domain (e.g. github.com, arxiv.org, blog.cloudflare.com)' },
|
|
45
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of stories (1-25 — single page)' },
|
|
46
|
+
],
|
|
47
|
+
columns: ['rank', 'id', 'title', 'score', 'author', 'comments', 'created_at', 'tags', 'submission_url', 'comments_url'],
|
|
48
|
+
func: async (args) => {
|
|
49
|
+
const domain = requireDomain(args.domain);
|
|
50
|
+
const limit = requireBoundedInt(args.limit, 20, 25);
|
|
51
|
+
const url = `https://lobste.rs/domains/${encodeURIComponent(domain)}.json`;
|
|
52
|
+
let resp;
|
|
53
|
+
try {
|
|
54
|
+
resp = await fetch(url, { headers: { 'user-agent': 'opencli-lobsters-adapter (+https://github.com/jackwener/opencli)' } });
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
throw new CommandExecutionError(
|
|
58
|
+
`lobsters domain request failed: ${err?.message ?? err}`,
|
|
59
|
+
'Check that lobste.rs is reachable from this network.',
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (resp.status === 404) {
|
|
63
|
+
throw new EmptyResultError('lobsters domain', `No Lobste.rs stories found for domain "${domain}".`);
|
|
64
|
+
}
|
|
65
|
+
if (!resp.ok) {
|
|
66
|
+
throw new CommandExecutionError(`lobsters domain returned HTTP ${resp.status}`);
|
|
67
|
+
}
|
|
68
|
+
let body;
|
|
69
|
+
try {
|
|
70
|
+
body = await resp.json();
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
throw new CommandExecutionError(`lobsters domain returned malformed JSON: ${err?.message ?? err}`);
|
|
74
|
+
}
|
|
75
|
+
const list = Array.isArray(body) ? body : [];
|
|
76
|
+
if (!list.length) {
|
|
77
|
+
throw new EmptyResultError('lobsters domain', `No Lobste.rs stories found for domain "${domain}".`);
|
|
78
|
+
}
|
|
79
|
+
return list.slice(0, limit).map((item, i) => ({
|
|
80
|
+
rank: i + 1,
|
|
81
|
+
id: String(item.short_id ?? ''),
|
|
82
|
+
title: String(item.title ?? ''),
|
|
83
|
+
score: item.score != null ? Number(item.score) : null,
|
|
84
|
+
author: String(item.submitter_user ?? ''),
|
|
85
|
+
comments: item.comment_count != null ? Number(item.comment_count) : null,
|
|
86
|
+
created_at: String(item.created_at ?? '').slice(0, 10),
|
|
87
|
+
tags: Array.isArray(item.tags) ? item.tags.join(', ') : '',
|
|
88
|
+
submission_url: String(item.url ?? ''),
|
|
89
|
+
comments_url: String(item.comments_url ?? ''),
|
|
90
|
+
}));
|
|
91
|
+
},
|
|
92
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// maven artifact — fetch a Maven Central artifact's recent version history.
|
|
2
|
+
//
|
|
3
|
+
// Hits Solr's `gav` core (`q=g:<groupId>+AND+a:<artifactId>` with
|
|
4
|
+
// `core=gav`) which returns one row per published version, newest first.
|
|
5
|
+
// Returns the agent-useful projection: each version + publish timestamp +
|
|
6
|
+
// packaging. If a specific `:version` is supplied, only that version is
|
|
7
|
+
// returned.
|
|
8
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
9
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
10
|
+
import { MAVEN_BASE, mavenFetch, epochMsToIso, requireBoundedInt, requireCoord } from './utils.js';
|
|
11
|
+
|
|
12
|
+
cli({
|
|
13
|
+
site: 'maven',
|
|
14
|
+
name: 'artifact',
|
|
15
|
+
access: 'read',
|
|
16
|
+
description: 'Fetch a Maven Central artifact\'s version history (groupId:artifactId[:version])',
|
|
17
|
+
domain: 'search.maven.org',
|
|
18
|
+
strategy: Strategy.PUBLIC,
|
|
19
|
+
browser: false,
|
|
20
|
+
args: [
|
|
21
|
+
{ name: 'coordinate', positional: true, required: true, help: 'Maven coord "groupId:artifactId" or "groupId:artifactId:version"' },
|
|
22
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max versions (1-200, ignored when version is pinned)' },
|
|
23
|
+
],
|
|
24
|
+
columns: ['groupId', 'artifactId', 'version', 'packaging', 'publishedAt', 'tags', 'url'],
|
|
25
|
+
func: async (args) => {
|
|
26
|
+
const { groupId, artifactId, version } = requireCoord(args.coordinate);
|
|
27
|
+
const limit = requireBoundedInt(args.limit, 20, 200);
|
|
28
|
+
const filters = [`g:${groupId}`, `a:${artifactId}`];
|
|
29
|
+
if (version) filters.push(`v:${version}`);
|
|
30
|
+
const q = filters.join(' AND ');
|
|
31
|
+
const rows = version ? 1 : limit;
|
|
32
|
+
const url = `${MAVEN_BASE}?q=${encodeURIComponent(q)}&core=gav&rows=${rows}&wt=json`;
|
|
33
|
+
const body = await mavenFetch(url, 'maven artifact');
|
|
34
|
+
const docs = Array.isArray(body?.response?.docs) ? body.response.docs : [];
|
|
35
|
+
const coordLabel = version ? `${groupId}:${artifactId}:${version}` : `${groupId}:${artifactId}`;
|
|
36
|
+
if (!docs.length) {
|
|
37
|
+
throw new EmptyResultError('maven artifact', `Maven Central has no published versions for ${coordLabel}.`);
|
|
38
|
+
}
|
|
39
|
+
return docs.map((d) => ({
|
|
40
|
+
groupId: String(d.g ?? groupId).trim(),
|
|
41
|
+
artifactId: String(d.a ?? artifactId).trim(),
|
|
42
|
+
version: String(d.v ?? '').trim(),
|
|
43
|
+
packaging: String(d.p ?? '').trim(),
|
|
44
|
+
publishedAt: epochMsToIso(d.timestamp),
|
|
45
|
+
tags: Array.isArray(d.tags) ? d.tags.filter(Boolean).join(', ') : '',
|
|
46
|
+
url: `https://central.sonatype.com/artifact/${groupId}/${artifactId}/${d.v ?? ''}`.replace(/\/$/, ''),
|
|
47
|
+
}));
|
|
48
|
+
},
|
|
49
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// maven search — search Maven Central by free-text keyword.
|
|
2
|
+
//
|
|
3
|
+
// Hits the Solr endpoint at `https://search.maven.org/solrsearch/select`.
|
|
4
|
+
// Returns the agent-useful projection: `groupId:artifactId` (round-trips
|
|
5
|
+
// into `maven artifact`), latest version, packaging, version count, last
|
|
6
|
+
// publish timestamp, repository.
|
|
7
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
9
|
+
import { MAVEN_BASE, mavenFetch, epochMsToIso, requireBoundedInt, requireString } from './utils.js';
|
|
10
|
+
|
|
11
|
+
cli({
|
|
12
|
+
site: 'maven',
|
|
13
|
+
name: 'search',
|
|
14
|
+
access: 'read',
|
|
15
|
+
description: 'Search Maven Central by keyword (artifact name, groupId, tag)',
|
|
16
|
+
domain: 'search.maven.org',
|
|
17
|
+
strategy: Strategy.PUBLIC,
|
|
18
|
+
browser: false,
|
|
19
|
+
args: [
|
|
20
|
+
{ name: 'query', positional: true, required: true, help: 'Search keyword (e.g. "jackson", "guava", "ai.koog")' },
|
|
21
|
+
{ name: 'limit', type: 'int', default: 30, help: 'Max artifacts (1-200)' },
|
|
22
|
+
],
|
|
23
|
+
columns: ['rank', 'coordinate', 'groupId', 'artifactId', 'latestVersion', 'packaging', 'versions', 'lastPublished', 'repository', 'url'],
|
|
24
|
+
func: async (args) => {
|
|
25
|
+
const query = requireString(args.query, 'query');
|
|
26
|
+
const limit = requireBoundedInt(args.limit, 30, 200);
|
|
27
|
+
const url = `${MAVEN_BASE}?q=${encodeURIComponent(query)}&rows=${limit}&wt=json`;
|
|
28
|
+
const body = await mavenFetch(url, 'maven search');
|
|
29
|
+
const docs = Array.isArray(body?.response?.docs) ? body.response.docs : [];
|
|
30
|
+
if (!docs.length) {
|
|
31
|
+
throw new EmptyResultError('maven search', `No Maven Central artifacts matched "${query}".`);
|
|
32
|
+
}
|
|
33
|
+
return docs.slice(0, limit).map((d, i) => {
|
|
34
|
+
const groupId = String(d.g ?? '').trim();
|
|
35
|
+
const artifactId = String(d.a ?? '').trim();
|
|
36
|
+
const coord = groupId && artifactId ? `${groupId}:${artifactId}` : '';
|
|
37
|
+
return {
|
|
38
|
+
rank: i + 1,
|
|
39
|
+
coordinate: coord,
|
|
40
|
+
groupId,
|
|
41
|
+
artifactId,
|
|
42
|
+
latestVersion: String(d.latestVersion ?? '').trim(),
|
|
43
|
+
packaging: String(d.p ?? '').trim(),
|
|
44
|
+
versions: d.versionCount != null ? Number(d.versionCount) : null,
|
|
45
|
+
lastPublished: epochMsToIso(d.timestamp),
|
|
46
|
+
repository: String(d.repositoryId ?? '').trim(),
|
|
47
|
+
url: coord ? `https://central.sonatype.com/artifact/${groupId}/${artifactId}` : '',
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Shared helpers for the Maven Central (search.maven.org) adapter.
|
|
2
|
+
//
|
|
3
|
+
// Hits the public, unauthenticated `search.maven.org/solrsearch/select` Solr
|
|
4
|
+
// endpoint that powers the Maven Central search UI. No auth required for
|
|
5
|
+
// read-only queries.
|
|
6
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
|
|
8
|
+
export const MAVEN_BASE = 'https://search.maven.org/solrsearch/select';
|
|
9
|
+
export const MAVEN_REPO_BASE = 'https://repo1.maven.org/maven2';
|
|
10
|
+
const UA = 'opencli-maven-adapter (+https://github.com/jackwener/opencli)';
|
|
11
|
+
|
|
12
|
+
// Maven groupId / artifactId tokens — Java-package-ish (letters / digits /
|
|
13
|
+
// `_-.`), 1-200 chars; reverse-DNS dots are allowed in groupId.
|
|
14
|
+
const COORD_TOKEN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
15
|
+
|
|
16
|
+
export function requireString(value, label) {
|
|
17
|
+
const s = String(value ?? '').trim();
|
|
18
|
+
if (!s) throw new ArgumentError(`maven ${label} cannot be empty`);
|
|
19
|
+
return s;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
|
|
23
|
+
const raw = value ?? defaultValue;
|
|
24
|
+
const n = typeof raw === 'number' ? raw : Number(raw);
|
|
25
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
26
|
+
throw new ArgumentError(`maven ${label} must be a positive integer`);
|
|
27
|
+
}
|
|
28
|
+
if (n > maxValue) {
|
|
29
|
+
throw new ArgumentError(`maven ${label} must be <= ${maxValue}`);
|
|
30
|
+
}
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a Maven coordinate `groupId:artifactId[:version]` into segments.
|
|
36
|
+
* groupId / artifactId are required; version is optional.
|
|
37
|
+
*/
|
|
38
|
+
export function requireCoord(value) {
|
|
39
|
+
const raw = String(value ?? '').trim();
|
|
40
|
+
if (!raw) {
|
|
41
|
+
throw new ArgumentError('maven coordinate is required (e.g. "com.fasterxml.jackson.core:jackson-databind")');
|
|
42
|
+
}
|
|
43
|
+
const parts = raw.split(':');
|
|
44
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
45
|
+
throw new ArgumentError(
|
|
46
|
+
`maven coordinate "${value}" must be "groupId:artifactId" or "groupId:artifactId:version"`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
const [groupId, artifactId, version] = parts;
|
|
50
|
+
if (!groupId || !artifactId) {
|
|
51
|
+
throw new ArgumentError(`maven coordinate "${value}" is missing groupId or artifactId`);
|
|
52
|
+
}
|
|
53
|
+
if (groupId.length > 200 || !COORD_TOKEN.test(groupId)) {
|
|
54
|
+
throw new ArgumentError(
|
|
55
|
+
`maven groupId "${groupId}" is not a valid token`,
|
|
56
|
+
'Use letters / digits / "_-." (max 200 chars), starting with a letter or digit.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (artifactId.length > 200 || !COORD_TOKEN.test(artifactId)) {
|
|
60
|
+
throw new ArgumentError(
|
|
61
|
+
`maven artifactId "${artifactId}" is not a valid token`,
|
|
62
|
+
'Use letters / digits / "_-." (max 200 chars), starting with a letter or digit.',
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (version != null && version.length > 200) {
|
|
66
|
+
throw new ArgumentError(`maven version "${version}" is too long (max 200 chars).`);
|
|
67
|
+
}
|
|
68
|
+
return { groupId, artifactId, version: version ?? null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function mavenFetch(url, label) {
|
|
72
|
+
let resp;
|
|
73
|
+
try {
|
|
74
|
+
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
throw new CommandExecutionError(
|
|
78
|
+
`${label} request failed: ${err?.message ?? err}`,
|
|
79
|
+
'Check that search.maven.org is reachable from this network.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (resp.status === 404) {
|
|
83
|
+
throw new EmptyResultError(label, `Maven Central returned 404 for ${url}.`);
|
|
84
|
+
}
|
|
85
|
+
if (resp.status === 429) {
|
|
86
|
+
throw new CommandExecutionError(
|
|
87
|
+
`${label} returned HTTP 429 (rate limited)`,
|
|
88
|
+
'Maven Central throttles bursts; wait a few seconds and retry.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (!resp.ok) {
|
|
92
|
+
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
|
|
93
|
+
}
|
|
94
|
+
let body;
|
|
95
|
+
try {
|
|
96
|
+
body = await resp.json();
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
|
|
100
|
+
}
|
|
101
|
+
return body;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Convert epoch-ms (Maven Solr `timestamp`) to ISO-8601 UTC. Returns null for falsy/invalid. */
|
|
105
|
+
export function epochMsToIso(value) {
|
|
106
|
+
if (value == null) return null;
|
|
107
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
108
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
109
|
+
return new Date(n).toISOString().replace(/\.\d+Z$/, 'Z');
|
|
110
|
+
}
|