@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,326 @@
|
|
|
1
|
+
import { htmlToMarkdown } from '@jackwener/opencli/utils';
|
|
2
|
+
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
export const GROK_DOMAIN = 'grok.com';
|
|
5
|
+
export const GROK_URL = 'https://grok.com/';
|
|
6
|
+
|
|
7
|
+
export const IS_VISIBLE_JS = `
|
|
8
|
+
const isVisible = (node) => {
|
|
9
|
+
if (!(node instanceof Element)) return false;
|
|
10
|
+
const style = window.getComputedStyle(node);
|
|
11
|
+
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
|
12
|
+
const rect = node.getBoundingClientRect();
|
|
13
|
+
return rect.width > 0 && rect.height > 0;
|
|
14
|
+
};
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
export function authRequired(detail) {
|
|
18
|
+
return new AuthRequiredError(
|
|
19
|
+
GROK_DOMAIN,
|
|
20
|
+
detail || 'Sign in to grok.com in your browser, then retry.',
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeBooleanFlag(value, fallback = false) {
|
|
25
|
+
if (typeof value === 'boolean') return value;
|
|
26
|
+
if (value == null || value === '') return fallback;
|
|
27
|
+
const normalized = String(value).trim().toLowerCase();
|
|
28
|
+
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// UUID v4-shape: 8-4-4-4-12 hex with dashes (the format Grok uses for /c/<id>)
|
|
32
|
+
const GROK_SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
33
|
+
|
|
34
|
+
export function parseGrokSessionId(input) {
|
|
35
|
+
const raw = String(input ?? '').trim();
|
|
36
|
+
if (!raw) {
|
|
37
|
+
throw new ArgumentError('id', 'must be a non-empty session ID or grok.com chat URL');
|
|
38
|
+
}
|
|
39
|
+
let candidate = raw;
|
|
40
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) {
|
|
41
|
+
let parsed;
|
|
42
|
+
try {
|
|
43
|
+
parsed = new URL(raw);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new ArgumentError('id', `not a valid Grok URL (got "${input}")`);
|
|
46
|
+
}
|
|
47
|
+
const host = parsed.hostname.toLowerCase();
|
|
48
|
+
const pathMatch = parsed.pathname.match(
|
|
49
|
+
/^\/c\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i,
|
|
50
|
+
);
|
|
51
|
+
if (parsed.protocol !== 'https:' || (host !== 'grok.com' && !host.endsWith('.grok.com')) || !pathMatch) {
|
|
52
|
+
throw new ArgumentError(
|
|
53
|
+
'id',
|
|
54
|
+
`not a valid Grok conversation URL (got "${input}"); expected https://grok.com/c/<id>`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
candidate = pathMatch[1];
|
|
58
|
+
}
|
|
59
|
+
if (!GROK_SESSION_ID_RE.test(candidate)) {
|
|
60
|
+
throw new ArgumentError(
|
|
61
|
+
'id',
|
|
62
|
+
`not a valid Grok session ID (got "${input}"); expected a UUID like "7c4197f2-10a1-4ebb-a84a-fea89f4f1d06" or a full https://grok.com/c/<id> URL`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return candidate.toLowerCase();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function isOnGrok(page) {
|
|
69
|
+
const url = await page.evaluate('window.location.href').catch(() => '');
|
|
70
|
+
if (typeof url !== 'string' || !url) return false;
|
|
71
|
+
try {
|
|
72
|
+
const hostname = new URL(url).hostname;
|
|
73
|
+
return hostname === 'grok.com' || hostname.endsWith('.grok.com');
|
|
74
|
+
} catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function ensureOnGrok(page) {
|
|
80
|
+
if (await isOnGrok(page)) return;
|
|
81
|
+
await page.goto(GROK_URL);
|
|
82
|
+
await page.wait(2);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function isLoggedIn(page) {
|
|
86
|
+
// Composer presence is the most reliable signed-in marker — when the user
|
|
87
|
+
// is signed out, grok.com renders a sign-in CTA in place of the chat
|
|
88
|
+
// composer rather than the TipTap editor.
|
|
89
|
+
const result = await page.evaluate(`(() => {
|
|
90
|
+
${IS_VISIBLE_JS}
|
|
91
|
+
const composer = document.querySelector('.ProseMirror[contenteditable="true"]');
|
|
92
|
+
if (composer && isVisible(composer)) {
|
|
93
|
+
const signInCta = Array.from(document.querySelectorAll('button, a'))
|
|
94
|
+
.some((node) => isVisible(node) && /^(sign in|log in)$/i.test((node.textContent || '').trim()));
|
|
95
|
+
return !signInCta;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
})()`);
|
|
99
|
+
return Boolean(result);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function getCurrentSessionId(page) {
|
|
103
|
+
const url = await page.evaluate('window.location.href').catch(() => '');
|
|
104
|
+
if (typeof url !== 'string') return '';
|
|
105
|
+
const match = url.match(/\/c\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
|
|
106
|
+
return match ? match[1].toLowerCase() : '';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function getModelLabel(page) {
|
|
110
|
+
const result = await page.evaluate(`(() => {
|
|
111
|
+
${IS_VISIBLE_JS}
|
|
112
|
+
const trigger = Array.from(document.querySelectorAll('button[aria-label="Model select"]'))
|
|
113
|
+
.find((node) => isVisible(node));
|
|
114
|
+
if (!trigger) return '';
|
|
115
|
+
return (trigger.innerText || trigger.textContent || '').trim().split('\\n')[0].trim();
|
|
116
|
+
})()`);
|
|
117
|
+
return typeof result === 'string' ? result : '';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function getMessageBubbles(page) {
|
|
121
|
+
// Grok marks each turn with `[data-testid="user-message"]` or
|
|
122
|
+
// `[data-testid="assistant-message"]`. Their nearest ancestor with an
|
|
123
|
+
// `id="response-<uuid>"` is a stable per-turn ID we can use for polling
|
|
124
|
+
// dedupe. The older `div.message-bubble` selector still matches but does
|
|
125
|
+
// not distinguish role on its own.
|
|
126
|
+
const result = await page.evaluate(`(() => {
|
|
127
|
+
${IS_VISIBLE_JS}
|
|
128
|
+
const findResponseId = (node) => {
|
|
129
|
+
let parent = node.parentElement;
|
|
130
|
+
while (parent && parent !== document.body) {
|
|
131
|
+
const id = parent.getAttribute('id') || '';
|
|
132
|
+
if (id.startsWith('response-')) return id.slice('response-'.length);
|
|
133
|
+
parent = parent.parentElement;
|
|
134
|
+
}
|
|
135
|
+
return '';
|
|
136
|
+
};
|
|
137
|
+
const bubbles = Array.from(document.querySelectorAll('[data-testid="user-message"], [data-testid="assistant-message"]'))
|
|
138
|
+
.filter((node) => node instanceof HTMLElement && isVisible(node));
|
|
139
|
+
const out = [];
|
|
140
|
+
let positional = 0;
|
|
141
|
+
for (const node of bubbles) {
|
|
142
|
+
const isAssistant = node.getAttribute('data-testid') === 'assistant-message';
|
|
143
|
+
const responseId = findResponseId(node);
|
|
144
|
+
const baseId = responseId || ('pos-' + positional);
|
|
145
|
+
const id = baseId + (isAssistant ? '-assistant' : '-user');
|
|
146
|
+
positional += 1;
|
|
147
|
+
const html = node.innerHTML || '';
|
|
148
|
+
const text = (node.innerText || node.textContent || '').replace(/\\s+/g, ' ').trim();
|
|
149
|
+
out.push({ id, role: isAssistant ? 'Assistant' : 'User', text, html });
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
})()`);
|
|
153
|
+
if (!Array.isArray(result)) return [];
|
|
154
|
+
return result
|
|
155
|
+
.map((item) => ({
|
|
156
|
+
id: String(item?.id || ''),
|
|
157
|
+
role: item?.role === 'Assistant' ? 'Assistant' : 'User',
|
|
158
|
+
text: String(item?.text || '').trim(),
|
|
159
|
+
html: String(item?.html || ''),
|
|
160
|
+
}))
|
|
161
|
+
// User turns are always non-empty; an Assistant turn may legitimately
|
|
162
|
+
// hold only an image / non-text widget (text empty, html populated).
|
|
163
|
+
// Keep entries with either text OR html so image-only turns are not
|
|
164
|
+
// silently dropped from `read` / `detail`.
|
|
165
|
+
.filter((item) => item.id && (item.text || item.html));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function bubbleHtmlToMarkdown(html) {
|
|
169
|
+
try {
|
|
170
|
+
return htmlToMarkdown(html).trim();
|
|
171
|
+
} catch {
|
|
172
|
+
return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function getHistoryFromSidebar(page, limit) {
|
|
177
|
+
const result = await page.evaluate(`(() => {
|
|
178
|
+
${IS_VISIBLE_JS}
|
|
179
|
+
const re = /^\\/c\\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
const out = [];
|
|
182
|
+
const anchors = Array.from(document.querySelectorAll('a[href^="/c/"]'));
|
|
183
|
+
for (const a of anchors) {
|
|
184
|
+
if (!(a instanceof HTMLElement) || !isVisible(a)) continue;
|
|
185
|
+
const href = a.getAttribute('href') || '';
|
|
186
|
+
const m = href.match(re);
|
|
187
|
+
if (!m) continue;
|
|
188
|
+
const id = m[1].toLowerCase();
|
|
189
|
+
if (seen.has(id)) continue;
|
|
190
|
+
const title = (a.innerText || a.textContent || '').trim();
|
|
191
|
+
// Sidebar renders the conversation title in one anchor and a separate
|
|
192
|
+
// icon-only anchor for hover affordances. Keep the first occurrence with
|
|
193
|
+
// a non-empty title; if the icon anchor wins the DOM order, fall back to
|
|
194
|
+
// the second pass below.
|
|
195
|
+
seen.add(id);
|
|
196
|
+
out.push({ id, title });
|
|
197
|
+
}
|
|
198
|
+
// Second pass: backfill empty titles from later anchors with the same id.
|
|
199
|
+
const titleById = new Map(out.map((entry) => [entry.id, entry.title]));
|
|
200
|
+
for (const a of anchors) {
|
|
201
|
+
if (!(a instanceof HTMLElement) || !isVisible(a)) continue;
|
|
202
|
+
const href = a.getAttribute('href') || '';
|
|
203
|
+
const m = href.match(re);
|
|
204
|
+
if (!m) continue;
|
|
205
|
+
const id = m[1].toLowerCase();
|
|
206
|
+
const title = (a.innerText || a.textContent || '').trim();
|
|
207
|
+
if (title && !titleById.get(id)) {
|
|
208
|
+
titleById.set(id, title);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return out.map((entry) => ({ id: entry.id, title: titleById.get(entry.id) || entry.title }));
|
|
212
|
+
})()`);
|
|
213
|
+
if (!Array.isArray(result)) return [];
|
|
214
|
+
const sliced = result.slice(0, limit);
|
|
215
|
+
return sliced.map((item) => ({
|
|
216
|
+
id: String(item?.id || ''),
|
|
217
|
+
title: String(item?.title || ''),
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function startNewChat(page) {
|
|
222
|
+
// Grok's "new chat" path is just a navigation back to the homepage —
|
|
223
|
+
// there is no dedicated button in the current UI.
|
|
224
|
+
await page.goto(GROK_URL);
|
|
225
|
+
await page.wait(2);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function sendMessage(page, prompt) {
|
|
229
|
+
const promptJson = JSON.stringify(prompt);
|
|
230
|
+
return await page.evaluate(`(async () => {
|
|
231
|
+
const waitFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
232
|
+
const composerSelector = '.ProseMirror[contenteditable="true"]';
|
|
233
|
+
let composer = null;
|
|
234
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
235
|
+
const candidate = document.querySelector(composerSelector);
|
|
236
|
+
if (candidate instanceof HTMLElement) { composer = candidate; break; }
|
|
237
|
+
await waitFor(500);
|
|
238
|
+
}
|
|
239
|
+
if (!(composer instanceof HTMLElement)) {
|
|
240
|
+
return { ok: false, reason: 'Grok composer (.ProseMirror) was not found on grok.com.' };
|
|
241
|
+
}
|
|
242
|
+
const editor = composer.editor;
|
|
243
|
+
if (!editor || !editor.commands || typeof editor.commands.focus !== 'function' || typeof editor.commands.insertContent !== 'function') {
|
|
244
|
+
return { ok: false, reason: 'Grok composer editor API was unavailable (page may still be loading).' };
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
if (typeof editor.commands.clearContent === 'function') editor.commands.clearContent();
|
|
248
|
+
editor.commands.focus();
|
|
249
|
+
editor.commands.insertContent(${promptJson});
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return {
|
|
252
|
+
ok: false,
|
|
253
|
+
reason: 'Failed to insert the prompt into the Grok composer.',
|
|
254
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const isClickableSubmit = (node) => {
|
|
258
|
+
if (!(node instanceof HTMLButtonElement)) return false;
|
|
259
|
+
if (node.disabled) return false;
|
|
260
|
+
const style = window.getComputedStyle(node);
|
|
261
|
+
if (style.visibility === 'hidden' || style.display === 'none') return false;
|
|
262
|
+
const rect = node.getBoundingClientRect();
|
|
263
|
+
return rect.width > 0 && rect.height > 0;
|
|
264
|
+
};
|
|
265
|
+
let submit = null;
|
|
266
|
+
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
267
|
+
const candidate = Array.from(document.querySelectorAll('button[aria-label="Submit"]')).find(isClickableSubmit);
|
|
268
|
+
if (candidate instanceof HTMLButtonElement) { submit = candidate; break; }
|
|
269
|
+
await waitFor(500);
|
|
270
|
+
}
|
|
271
|
+
if (!(submit instanceof HTMLButtonElement)) {
|
|
272
|
+
return { ok: false, reason: 'Grok submit button did not reach a clickable state after prompt insertion.' };
|
|
273
|
+
}
|
|
274
|
+
submit.click();
|
|
275
|
+
return { ok: true };
|
|
276
|
+
})()`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const POLL_INTERVAL_SECONDS = 2;
|
|
280
|
+
const MIN_WAIT_MS = 6_000;
|
|
281
|
+
const STABLE_POLLS_REQUIRED = 2;
|
|
282
|
+
|
|
283
|
+
function stripNoise(text) {
|
|
284
|
+
return (text || '')
|
|
285
|
+
.replace(/\u00a0/g, ' ')
|
|
286
|
+
.trim();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function waitForAnswer(page, prompt, timeoutSeconds, baselineLastAssistantId) {
|
|
290
|
+
const startTime = Date.now();
|
|
291
|
+
let previousText = '';
|
|
292
|
+
let stableCount = 0;
|
|
293
|
+
let lastCandidate = '';
|
|
294
|
+
while (Date.now() - startTime < timeoutSeconds * 1000) {
|
|
295
|
+
await page.wait(POLL_INTERVAL_SECONDS);
|
|
296
|
+
const bubbles = await getMessageBubbles(page);
|
|
297
|
+
const lastAssistant = [...bubbles].reverse().find((b) => b.role === 'Assistant');
|
|
298
|
+
if (!lastAssistant) continue;
|
|
299
|
+
// Skip stale assistant turns from before our send: if the latest
|
|
300
|
+
// assistant ID matches the baseline, the new reply hasn't arrived yet.
|
|
301
|
+
if (baselineLastAssistantId && lastAssistant.id === baselineLastAssistantId) continue;
|
|
302
|
+
const text = stripNoise(lastAssistant.text);
|
|
303
|
+
if (!text || text === prompt.trim()) continue;
|
|
304
|
+
lastCandidate = text;
|
|
305
|
+
const waitedLongEnough = Date.now() - startTime >= MIN_WAIT_MS;
|
|
306
|
+
if (text === previousText) {
|
|
307
|
+
stableCount += 1;
|
|
308
|
+
if (waitedLongEnough && stableCount >= STABLE_POLLS_REQUIRED) {
|
|
309
|
+
return { status: 'ok', assistant: lastAssistant };
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
previousText = text;
|
|
313
|
+
stableCount = 0;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (lastCandidate) {
|
|
317
|
+
const bubbles = await getMessageBubbles(page);
|
|
318
|
+
const lastAssistant = [...bubbles].reverse().find((b) => b.role === 'Assistant');
|
|
319
|
+
return { status: 'partial', assistant: lastAssistant };
|
|
320
|
+
}
|
|
321
|
+
return { status: 'timeout' };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export const __test__ = {
|
|
325
|
+
GROK_SESSION_ID_RE,
|
|
326
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { isOnGrok, normalizeBooleanFlag, parseGrokSessionId } from './utils.js';
|
|
4
|
+
|
|
5
|
+
describe('grok parseGrokSessionId', () => {
|
|
6
|
+
const id = '7c4197f2-10a1-4ebb-a84a-fea89f4f1d06';
|
|
7
|
+
|
|
8
|
+
it('returns a bare UUID unchanged', () => {
|
|
9
|
+
expect(parseGrokSessionId(id)).toBe(id);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('lowercases an upper-case ID', () => {
|
|
13
|
+
expect(parseGrokSessionId(id.toUpperCase())).toBe(id);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('extracts the session ID from a full grok.com chat URL', () => {
|
|
17
|
+
expect(parseGrokSessionId(`https://grok.com/c/${id}`)).toBe(id);
|
|
18
|
+
expect(parseGrokSessionId(`https://grok.com/c/${id}/`)).toBe(id);
|
|
19
|
+
expect(parseGrokSessionId(`https://grok.com/c/${id}?rid=abc`)).toBe(id);
|
|
20
|
+
expect(parseGrokSessionId(`https://x.grok.com/c/${id}`)).toBe(id);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('throws ArgumentError on empty input', () => {
|
|
24
|
+
expect(() => parseGrokSessionId('')).toThrow(ArgumentError);
|
|
25
|
+
expect(() => parseGrokSessionId(null)).toThrow(ArgumentError);
|
|
26
|
+
expect(() => parseGrokSessionId(undefined)).toThrow(ArgumentError);
|
|
27
|
+
expect(() => parseGrokSessionId(' ')).toThrow(ArgumentError);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws ArgumentError on non-UUID input', () => {
|
|
31
|
+
expect(() => parseGrokSessionId('not-an-id')).toThrow(ArgumentError);
|
|
32
|
+
expect(() => parseGrokSessionId('123')).toThrow(ArgumentError);
|
|
33
|
+
// 32 hex chars (no dashes) — not the Grok UUID shape
|
|
34
|
+
expect(() => parseGrokSessionId('7c4197f210a14ebba84afea89f4f1d06')).toThrow(ArgumentError);
|
|
35
|
+
// UUID-shaped but bad hex
|
|
36
|
+
expect(() => parseGrokSessionId('zc4197f2-10a1-4ebb-a84a-fea89f4f1d06')).toThrow(ArgumentError);
|
|
37
|
+
// Bare-ID mode must not accept URL/query suffixes.
|
|
38
|
+
expect(() => parseGrokSessionId(`${id}?next=abc`)).toThrow(ArgumentError);
|
|
39
|
+
// URL with the wrong path shape must not silently fall through.
|
|
40
|
+
expect(() => parseGrokSessionId('https://grok.com/somewhere/else')).toThrow(ArgumentError);
|
|
41
|
+
expect(() => parseGrokSessionId(`http://grok.com/c/${id}`)).toThrow(ArgumentError);
|
|
42
|
+
expect(() => parseGrokSessionId(`https://evil.com/c/${id}`)).toThrow(ArgumentError);
|
|
43
|
+
expect(() => parseGrokSessionId(`https://fakegrok.com/c/${id}`)).toThrow(ArgumentError);
|
|
44
|
+
expect(() => parseGrokSessionId(`https://grok.com.evil.com/c/${id}`)).toThrow(ArgumentError);
|
|
45
|
+
expect(() => parseGrokSessionId(`https://evil.com/?next=https://grok.com/c/${id}`)).toThrow(ArgumentError);
|
|
46
|
+
expect(() => parseGrokSessionId(`https://grok.com/c/${id}/extra`)).toThrow(ArgumentError);
|
|
47
|
+
// URL embedding extra hex tail after the UUID must not silently truncate
|
|
48
|
+
// and open the wrong conversation.
|
|
49
|
+
expect(() => parseGrokSessionId(`https://grok.com/c/${id}0`)).toThrow(ArgumentError);
|
|
50
|
+
expect(() => parseGrokSessionId(`https://grok.com/c/${id}-extra`)).toThrow(ArgumentError);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('grok isOnGrok', () => {
|
|
55
|
+
const fakePage = (url) => ({
|
|
56
|
+
evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns true for grok.com URLs', async () => {
|
|
60
|
+
expect(await isOnGrok(fakePage('https://grok.com/'))).toBe(true);
|
|
61
|
+
expect(await isOnGrok(fakePage('https://grok.com/c/abc'))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('returns true for grok.com subdomains', async () => {
|
|
65
|
+
expect(await isOnGrok(fakePage('https://api.grok.com/v1'))).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns false for non-grok domains and rejects substring matches', async () => {
|
|
69
|
+
expect(await isOnGrok(fakePage('https://fakegrok.com/'))).toBe(false);
|
|
70
|
+
expect(await isOnGrok(fakePage('https://example.com/?next=grok.com'))).toBe(false);
|
|
71
|
+
expect(await isOnGrok(fakePage('about:blank'))).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns false when evaluate throws (detached tab)', async () => {
|
|
75
|
+
expect(await isOnGrok(fakePage(new Error('detached')))).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('grok normalizeBooleanFlag', () => {
|
|
80
|
+
it('passes through actual booleans', () => {
|
|
81
|
+
expect(normalizeBooleanFlag(true)).toBe(true);
|
|
82
|
+
expect(normalizeBooleanFlag(false)).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns the fallback for null/empty input', () => {
|
|
86
|
+
expect(normalizeBooleanFlag(null)).toBe(false);
|
|
87
|
+
expect(normalizeBooleanFlag(undefined)).toBe(false);
|
|
88
|
+
expect(normalizeBooleanFlag('')).toBe(false);
|
|
89
|
+
expect(normalizeBooleanFlag(null, true)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('parses common truthy strings', () => {
|
|
93
|
+
for (const v of ['true', 'TRUE', '1', 'yes', 'on', ' Yes ']) {
|
|
94
|
+
expect(normalizeBooleanFlag(v)).toBe(true);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('treats anything else as falsy', () => {
|
|
99
|
+
for (const v of ['no', 'off', '0', 'false', 'random']) {
|
|
100
|
+
expect(normalizeBooleanFlag(v)).toBe(false);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// hf datasets — list top Hugging Face datasets.
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import {
|
|
4
|
+
ArgumentError,
|
|
5
|
+
CommandExecutionError,
|
|
6
|
+
EmptyResultError,
|
|
7
|
+
} from '@jackwener/opencli/errors';
|
|
8
|
+
|
|
9
|
+
const SORT_OPTIONS = ['downloads', 'likes', 'trending', 'created_at', 'last_modified'];
|
|
10
|
+
const SORT_ALIAS = { lastmodified: 'last_modified', createdat: 'created_at' };
|
|
11
|
+
|
|
12
|
+
cli({
|
|
13
|
+
site: 'hf',
|
|
14
|
+
name: 'datasets',
|
|
15
|
+
access: 'read',
|
|
16
|
+
description: 'Top Hugging Face datasets (downloads / likes / trending / freshness).',
|
|
17
|
+
domain: 'huggingface.co',
|
|
18
|
+
strategy: Strategy.PUBLIC,
|
|
19
|
+
browser: false,
|
|
20
|
+
args: [
|
|
21
|
+
{ name: 'sort', type: 'string', default: 'downloads', help: `Sort key: ${SORT_OPTIONS.join(', ')}` },
|
|
22
|
+
{ name: 'search', type: 'string', required: false, help: 'Optional name/owner substring filter.' },
|
|
23
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max datasets (max 100; one API page).' },
|
|
24
|
+
],
|
|
25
|
+
columns: ['rank', 'id', 'author', 'downloads', 'likes', 'tags', 'lastModified', 'url'],
|
|
26
|
+
func: async (args) => {
|
|
27
|
+
const sortRaw = String(args.sort ?? 'downloads').toLowerCase();
|
|
28
|
+
const sort = SORT_ALIAS[sortRaw] ?? sortRaw;
|
|
29
|
+
if (!SORT_OPTIONS.includes(sort)) {
|
|
30
|
+
throw new ArgumentError(`hf datasets sort must be one of ${SORT_OPTIONS.join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
const limit = Number(args.limit ?? 20);
|
|
33
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
34
|
+
throw new ArgumentError('hf datasets limit must be a positive integer');
|
|
35
|
+
}
|
|
36
|
+
if (limit > 100) {
|
|
37
|
+
throw new ArgumentError('hf datasets limit must be <= 100');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const url = new URL('https://huggingface.co/api/datasets');
|
|
41
|
+
url.searchParams.set('sort', sort);
|
|
42
|
+
url.searchParams.set('direction', '-1');
|
|
43
|
+
url.searchParams.set('limit', String(limit));
|
|
44
|
+
url.searchParams.set('full', 'true');
|
|
45
|
+
if (args.search) url.searchParams.set('search', String(args.search));
|
|
46
|
+
|
|
47
|
+
let resp;
|
|
48
|
+
try {
|
|
49
|
+
resp = await fetch(url, {
|
|
50
|
+
headers: {
|
|
51
|
+
'Accept': 'application/json',
|
|
52
|
+
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
throw new CommandExecutionError(`hf datasets request failed: ${error?.message || error}`);
|
|
57
|
+
}
|
|
58
|
+
if (!resp.ok) {
|
|
59
|
+
throw new CommandExecutionError(`hf datasets failed: HTTP ${resp.status}`);
|
|
60
|
+
}
|
|
61
|
+
let data;
|
|
62
|
+
try {
|
|
63
|
+
data = await resp.json();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new CommandExecutionError(`hf datasets returned malformed JSON: ${error?.message || error}`);
|
|
66
|
+
}
|
|
67
|
+
const list = Array.isArray(data) ? data : [];
|
|
68
|
+
if (list.length === 0) {
|
|
69
|
+
throw new EmptyResultError('hf datasets', 'No matching datasets on huggingface.co.');
|
|
70
|
+
}
|
|
71
|
+
return list.slice(0, limit).map((d, i) => {
|
|
72
|
+
const id = d.id || '';
|
|
73
|
+
const slashIdx = id.indexOf('/');
|
|
74
|
+
const author = slashIdx > 0 ? id.slice(0, slashIdx) : '';
|
|
75
|
+
const tags = Array.isArray(d.tags) ? d.tags.filter(t => !t.startsWith('license:')).slice(0, 10).join(', ') : '';
|
|
76
|
+
return {
|
|
77
|
+
rank: i + 1,
|
|
78
|
+
id,
|
|
79
|
+
author,
|
|
80
|
+
downloads: d.downloads ?? 0,
|
|
81
|
+
likes: d.likes ?? 0,
|
|
82
|
+
tags,
|
|
83
|
+
lastModified: d.lastModified ? String(d.lastModified).slice(0, 10) : '',
|
|
84
|
+
url: id ? `https://huggingface.co/datasets/${id}` : '',
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './top.js';
|
|
4
|
+
import './paper.js';
|
|
5
|
+
|
|
6
|
+
describe('hf adapter registry contracts', () => {
|
|
7
|
+
it('declares hf top columns so paper ids round-trip into hf paper', () => {
|
|
8
|
+
const top = getRegistry().get('hf/top');
|
|
9
|
+
const paper = getRegistry().get('hf/paper');
|
|
10
|
+
|
|
11
|
+
expect(top).toBeDefined();
|
|
12
|
+
expect(paper).toBeDefined();
|
|
13
|
+
expect(top.columns).toEqual(['rank', 'id', 'title', 'upvotes', 'authors']);
|
|
14
|
+
expect(paper.columns).toContain('id');
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// hf models — list top Hugging Face models (by downloads / likes / trending).
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import {
|
|
4
|
+
ArgumentError,
|
|
5
|
+
CommandExecutionError,
|
|
6
|
+
EmptyResultError,
|
|
7
|
+
} from '@jackwener/opencli/errors';
|
|
8
|
+
|
|
9
|
+
const SORT_OPTIONS = ['downloads', 'likes', 'trending', 'created_at', 'last_modified'];
|
|
10
|
+
const SORT_ALIAS = { lastmodified: 'last_modified', createdat: 'created_at' };
|
|
11
|
+
|
|
12
|
+
cli({
|
|
13
|
+
site: 'hf',
|
|
14
|
+
name: 'models',
|
|
15
|
+
access: 'read',
|
|
16
|
+
description: 'Top Hugging Face models (downloads / likes / trending / freshness).',
|
|
17
|
+
domain: 'huggingface.co',
|
|
18
|
+
strategy: Strategy.PUBLIC,
|
|
19
|
+
browser: false,
|
|
20
|
+
args: [
|
|
21
|
+
{ name: 'sort', type: 'string', default: 'downloads', help: `Sort key: ${SORT_OPTIONS.join(', ')}` },
|
|
22
|
+
{ name: 'search', type: 'string', required: false, help: 'Optional name/owner substring filter (e.g. "llama", "mistralai/")' },
|
|
23
|
+
{ name: 'pipeline', type: 'string', required: false, help: 'Filter by pipeline tag (e.g. text-generation, image-classification)' },
|
|
24
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max models (max 100; one API page).' },
|
|
25
|
+
],
|
|
26
|
+
columns: ['rank', 'id', 'author', 'pipelineTag', 'downloads', 'likes', 'tags', 'lastModified', 'url'],
|
|
27
|
+
func: async (args) => {
|
|
28
|
+
const sortRaw = String(args.sort ?? 'downloads').toLowerCase();
|
|
29
|
+
const sort = SORT_ALIAS[sortRaw] ?? sortRaw;
|
|
30
|
+
if (!SORT_OPTIONS.includes(sort)) {
|
|
31
|
+
throw new ArgumentError(`hf models sort must be one of ${SORT_OPTIONS.join(', ')}`);
|
|
32
|
+
}
|
|
33
|
+
const limit = Number(args.limit ?? 20);
|
|
34
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
35
|
+
throw new ArgumentError('hf models limit must be a positive integer');
|
|
36
|
+
}
|
|
37
|
+
if (limit > 100) {
|
|
38
|
+
throw new ArgumentError('hf models limit must be <= 100');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const url = new URL('https://huggingface.co/api/models');
|
|
42
|
+
url.searchParams.set('sort', sort);
|
|
43
|
+
url.searchParams.set('direction', '-1');
|
|
44
|
+
url.searchParams.set('limit', String(limit));
|
|
45
|
+
url.searchParams.set('full', 'true');
|
|
46
|
+
if (args.search) url.searchParams.set('search', String(args.search));
|
|
47
|
+
if (args.pipeline) url.searchParams.set('pipeline_tag', String(args.pipeline));
|
|
48
|
+
|
|
49
|
+
let resp;
|
|
50
|
+
try {
|
|
51
|
+
resp = await fetch(url, {
|
|
52
|
+
headers: {
|
|
53
|
+
'Accept': 'application/json',
|
|
54
|
+
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
throw new CommandExecutionError(`hf models request failed: ${error?.message || error}`);
|
|
59
|
+
}
|
|
60
|
+
if (!resp.ok) {
|
|
61
|
+
throw new CommandExecutionError(`hf models failed: HTTP ${resp.status}`);
|
|
62
|
+
}
|
|
63
|
+
let data;
|
|
64
|
+
try {
|
|
65
|
+
data = await resp.json();
|
|
66
|
+
} catch (error) {
|
|
67
|
+
throw new CommandExecutionError(`hf models returned malformed JSON: ${error?.message || error}`);
|
|
68
|
+
}
|
|
69
|
+
const list = Array.isArray(data) ? data : [];
|
|
70
|
+
if (list.length === 0) {
|
|
71
|
+
throw new EmptyResultError('hf models', 'No matching models on huggingface.co.');
|
|
72
|
+
}
|
|
73
|
+
return list.slice(0, limit).map((m, i) => {
|
|
74
|
+
const id = m.id || m.modelId || '';
|
|
75
|
+
const slashIdx = id.indexOf('/');
|
|
76
|
+
const author = slashIdx > 0 ? id.slice(0, slashIdx) : '';
|
|
77
|
+
const tags = Array.isArray(m.tags) ? m.tags.filter(t => !t.startsWith('license:')).slice(0, 10).join(', ') : '';
|
|
78
|
+
return {
|
|
79
|
+
rank: i + 1,
|
|
80
|
+
id,
|
|
81
|
+
author,
|
|
82
|
+
pipelineTag: m.pipeline_tag || m.pipelineTag || '',
|
|
83
|
+
downloads: m.downloads ?? 0,
|
|
84
|
+
likes: m.likes ?? 0,
|
|
85
|
+
tags,
|
|
86
|
+
lastModified: m.lastModified ? String(m.lastModified).slice(0, 10) : '',
|
|
87
|
+
url: id ? `https://huggingface.co/${id}` : '',
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
});
|