@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,227 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { __test__ } from './search.js';
|
|
4
|
+
|
|
5
|
+
describe('weixin search command', () => {
|
|
6
|
+
const command = getRegistry().get('weixin/search');
|
|
7
|
+
|
|
8
|
+
it('registers as a public browser command', () => {
|
|
9
|
+
expect(command).toBeDefined();
|
|
10
|
+
expect(command.site).toBe('weixin');
|
|
11
|
+
expect(command.strategy).toBe('public');
|
|
12
|
+
expect(command.browser).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects empty queries before browser navigation', async () => {
|
|
16
|
+
const page = { goto: vi.fn() };
|
|
17
|
+
|
|
18
|
+
await expect(command.func(page, { query: ' ' })).rejects.toMatchObject({
|
|
19
|
+
name: 'ArgumentError',
|
|
20
|
+
code: 'ARGUMENT',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects invalid page and limit values before browser navigation', async () => {
|
|
27
|
+
const page = { goto: vi.fn() };
|
|
28
|
+
|
|
29
|
+
await expect(command.func(page, { query: 'AI', page: 0 })).rejects.toMatchObject({
|
|
30
|
+
name: 'ArgumentError',
|
|
31
|
+
code: 'ARGUMENT',
|
|
32
|
+
});
|
|
33
|
+
await expect(command.func(page, { query: 'AI', limit: 11 })).rejects.toMatchObject({
|
|
34
|
+
name: 'ArgumentError',
|
|
35
|
+
code: 'ARGUMENT',
|
|
36
|
+
});
|
|
37
|
+
await expect(command.func(page, { query: 'AI', limit: '2abc' })).rejects.toMatchObject({
|
|
38
|
+
name: 'ArgumentError',
|
|
39
|
+
code: 'ARGUMENT',
|
|
40
|
+
});
|
|
41
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('uses page and limit while preserving per-page ranking', async () => {
|
|
45
|
+
const page = {
|
|
46
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
47
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
48
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
49
|
+
blocked: false,
|
|
50
|
+
empty: false,
|
|
51
|
+
cardCount: 2,
|
|
52
|
+
invalidCount: 0,
|
|
53
|
+
rows: [
|
|
54
|
+
{
|
|
55
|
+
title: 'First article',
|
|
56
|
+
url: 'https://weixin.sogou.com/link?url=abc',
|
|
57
|
+
summary: 'First summary',
|
|
58
|
+
publish_time: '2小时前',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
title: 'Second article',
|
|
62
|
+
url: 'https://weixin.sogou.com/link?url=def',
|
|
63
|
+
summary: 'Second summary',
|
|
64
|
+
publish_time: '1小时前',
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const result = await command.func(page, { query: 'AI', page: 2, limit: 1 });
|
|
71
|
+
|
|
72
|
+
expect(page.goto).toHaveBeenCalledWith('https://weixin.sogou.com/weixin?query=AI&type=2&page=2&ie=utf8');
|
|
73
|
+
expect(result).toEqual([
|
|
74
|
+
{
|
|
75
|
+
rank: 11,
|
|
76
|
+
page: 2,
|
|
77
|
+
title: 'First article',
|
|
78
|
+
url: 'https://weixin.sogou.com/link?url=abc',
|
|
79
|
+
summary: 'First summary',
|
|
80
|
+
publish_time: '2小时前',
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('preserves browser-side cleanup regex escapes', async () => {
|
|
86
|
+
const page = {
|
|
87
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
88
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
89
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
90
|
+
blocked: false,
|
|
91
|
+
empty: false,
|
|
92
|
+
cardCount: 1,
|
|
93
|
+
invalidCount: 0,
|
|
94
|
+
rows: [
|
|
95
|
+
{
|
|
96
|
+
title: 'Article',
|
|
97
|
+
url: 'https://weixin.sogou.com/link?url=abc',
|
|
98
|
+
summary: 'Summary',
|
|
99
|
+
publish_time: '2024-4-28',
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
}),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
await command.func(page, { query: 'AI' });
|
|
106
|
+
|
|
107
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
108
|
+
expect(script).toContain(".replace(/\\s+/g, ' ')");
|
|
109
|
+
expect(script).toContain(".replace(/document\\.write\\(timeConvert\\('\\d+'\\)\\)/g, '')");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('maps browser navigation failures to CommandExecutionError', async () => {
|
|
113
|
+
const page = {
|
|
114
|
+
goto: vi.fn().mockRejectedValue(new Error('net::ERR_FAILED')),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
await expect(command.func(page, { query: 'AI' })).rejects.toMatchObject({
|
|
118
|
+
name: 'CommandExecutionError',
|
|
119
|
+
code: 'COMMAND_EXEC',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('fails fast on unreadable payloads, verification blocks, and partial card extraction', async () => {
|
|
124
|
+
const page = {
|
|
125
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
126
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
127
|
+
evaluate: vi.fn(),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
page.evaluate.mockResolvedValueOnce(null);
|
|
131
|
+
await expect(command.func(page, { query: 'AI' })).rejects.toMatchObject({
|
|
132
|
+
name: 'CommandExecutionError',
|
|
133
|
+
code: 'COMMAND_EXEC',
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
page.evaluate.mockResolvedValueOnce({ blocked: true, empty: false, cardCount: 0, invalidCount: 0, rows: [] });
|
|
137
|
+
await expect(command.func(page, { query: 'AI' })).rejects.toMatchObject({
|
|
138
|
+
name: 'CommandExecutionError',
|
|
139
|
+
code: 'COMMAND_EXEC',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
page.evaluate.mockResolvedValueOnce({ blocked: false, empty: false, cardCount: 2, invalidCount: 1, rows: [{ title: 'Article', url: 'https://example.com' }] });
|
|
143
|
+
await expect(command.func(page, { query: 'AI' })).rejects.toMatchObject({
|
|
144
|
+
name: 'CommandExecutionError',
|
|
145
|
+
code: 'COMMAND_EXEC',
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('distinguishes explicit empty result pages from selector drift', async () => {
|
|
150
|
+
const page = {
|
|
151
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
152
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
153
|
+
evaluate: vi.fn()
|
|
154
|
+
.mockResolvedValueOnce({ blocked: false, empty: true, cardCount: 0, invalidCount: 0, rows: [] })
|
|
155
|
+
.mockResolvedValueOnce({ blocked: false, empty: false, cardCount: 0, invalidCount: 0, rows: [] }),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
await expect(command.func(page, { query: 'no-result' })).rejects.toMatchObject({
|
|
159
|
+
name: 'EmptyResultError',
|
|
160
|
+
code: 'EMPTY_RESULT',
|
|
161
|
+
});
|
|
162
|
+
await expect(command.func(page, { query: 'AI' })).rejects.toMatchObject({
|
|
163
|
+
name: 'CommandExecutionError',
|
|
164
|
+
code: 'COMMAND_EXEC',
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('extracts browser DOM payload without silently dropping malformed result cards', async () => {
|
|
169
|
+
const document = {
|
|
170
|
+
body: { innerText: 'Article title Summary' },
|
|
171
|
+
querySelector: vi.fn(() => null),
|
|
172
|
+
querySelectorAll: vi.fn((selector) => {
|
|
173
|
+
if (selector !== '.news-list li')
|
|
174
|
+
return [];
|
|
175
|
+
return [
|
|
176
|
+
{
|
|
177
|
+
querySelector: vi.fn((cardSelector) => {
|
|
178
|
+
if (cardSelector === 'h3 a[href]') {
|
|
179
|
+
return {
|
|
180
|
+
textContent: 'Article title',
|
|
181
|
+
getAttribute: vi.fn(() => '/link?url=abc'),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (cardSelector === 'p.txt-info')
|
|
185
|
+
return { textContent: 'Summary' };
|
|
186
|
+
if (cardSelector === '.s-p .s2')
|
|
187
|
+
return { textContent: '2小时前' };
|
|
188
|
+
return null;
|
|
189
|
+
}),
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
querySelector: vi.fn(() => null),
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
}),
|
|
196
|
+
};
|
|
197
|
+
const window = {
|
|
198
|
+
location: { origin: 'https://weixin.sogou.com' },
|
|
199
|
+
URL,
|
|
200
|
+
};
|
|
201
|
+
const script = __test__.buildExtractSearchResultsEvaluate();
|
|
202
|
+
const payload = Function('document', 'window', 'URL', `return ${script};`)(document, window, URL);
|
|
203
|
+
|
|
204
|
+
expect(payload).toMatchObject({
|
|
205
|
+
blocked: false,
|
|
206
|
+
empty: false,
|
|
207
|
+
cardCount: 2,
|
|
208
|
+
invalidCount: 1,
|
|
209
|
+
rows: [
|
|
210
|
+
{
|
|
211
|
+
title: 'Article title',
|
|
212
|
+
url: 'https://weixin.sogou.com/link?url=abc',
|
|
213
|
+
summary: 'Summary',
|
|
214
|
+
publish_time: '2小时前',
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('exposes pure normalizers for direct regression coverage', () => {
|
|
221
|
+
expect(__test__.normalizePage(undefined)).toBe(1);
|
|
222
|
+
expect(__test__.normalizeLimit(undefined)).toBe(10);
|
|
223
|
+
expect(__test__.normalizeLimit(10)).toBe(10);
|
|
224
|
+
expect(() => __test__.normalizeLimit(11)).toThrow(/out of range/);
|
|
225
|
+
expect(__test__.buildSearchUrl('AI tools', 2)).toBe('https://weixin.sogou.com/weixin?query=AI+tools&type=2&page=2&ie=utf8');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// wikidata entity — fetch a single Wikidata entity by Q/P/L identifier.
|
|
2
|
+
//
|
|
3
|
+
// Hits `Special:EntityData/<id>.json`, the canonical public dump. Surfaces the
|
|
4
|
+
// agent-useful projection: localised label/description/aliases plus high-level
|
|
5
|
+
// counts (claim properties, sitelinks). The full claim graph is huge; we keep
|
|
6
|
+
// the projection narrow by design.
|
|
7
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
9
|
+
import { WIKIDATA_BASE, joinAliases, pickLocalised, requireEntityId, requireLanguage, wikidataFetch } from './utils.js';
|
|
10
|
+
|
|
11
|
+
cli({
|
|
12
|
+
site: 'wikidata',
|
|
13
|
+
name: 'entity',
|
|
14
|
+
access: 'read',
|
|
15
|
+
description: 'Fetch a Wikidata entity by Q/P/L id (label, description, aliases, claim summary)',
|
|
16
|
+
domain: 'www.wikidata.org',
|
|
17
|
+
strategy: Strategy.PUBLIC,
|
|
18
|
+
browser: false,
|
|
19
|
+
args: [
|
|
20
|
+
{ name: 'id', positional: true, required: true, help: 'Entity id (e.g. Q937 = Albert Einstein, P31 = instance of)' },
|
|
21
|
+
{ name: 'language', default: 'en', help: 'Display language (ISO 639, falls back to English when missing)' },
|
|
22
|
+
],
|
|
23
|
+
columns: [
|
|
24
|
+
'qid',
|
|
25
|
+
'type',
|
|
26
|
+
'label',
|
|
27
|
+
'description',
|
|
28
|
+
'aliases',
|
|
29
|
+
'claimPropertyCount',
|
|
30
|
+
'sitelinkCount',
|
|
31
|
+
'enwikiTitle',
|
|
32
|
+
'modified',
|
|
33
|
+
'url',
|
|
34
|
+
],
|
|
35
|
+
func: async (args) => {
|
|
36
|
+
const qid = requireEntityId(args.id);
|
|
37
|
+
const language = requireLanguage(args.language);
|
|
38
|
+
const url = `${WIKIDATA_BASE}/wiki/Special:EntityData/${encodeURIComponent(qid)}.json`;
|
|
39
|
+
const body = await wikidataFetch(url, 'wikidata entity');
|
|
40
|
+
const entity = body?.entities?.[qid];
|
|
41
|
+
if (!entity) {
|
|
42
|
+
throw new EmptyResultError('wikidata entity', `Wikidata entity ${qid} returned no payload.`);
|
|
43
|
+
}
|
|
44
|
+
const claims = entity.claims && typeof entity.claims === 'object' ? entity.claims : {};
|
|
45
|
+
const sitelinks = entity.sitelinks && typeof entity.sitelinks === 'object' ? entity.sitelinks : {};
|
|
46
|
+
const enwiki = sitelinks?.enwiki?.title;
|
|
47
|
+
return [{
|
|
48
|
+
qid,
|
|
49
|
+
type: typeof entity.type === 'string' ? entity.type : null,
|
|
50
|
+
label: pickLocalised(entity.labels, language),
|
|
51
|
+
description: pickLocalised(entity.descriptions, language),
|
|
52
|
+
aliases: joinAliases(entity.aliases, language),
|
|
53
|
+
claimPropertyCount: Object.keys(claims).length,
|
|
54
|
+
sitelinkCount: Object.keys(sitelinks).length,
|
|
55
|
+
enwikiTitle: typeof enwiki === 'string' && enwiki.trim() ? enwiki : null,
|
|
56
|
+
modified: typeof entity.modified === 'string' ? entity.modified : null,
|
|
57
|
+
url: `${WIKIDATA_BASE}/wiki/${qid}`,
|
|
58
|
+
}];
|
|
59
|
+
},
|
|
60
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// wikidata search — find Wikidata entities (items) by keyword.
|
|
2
|
+
//
|
|
3
|
+
// Hits `wbsearchentities` on the public MediaWiki API. Returns Q-IDs that
|
|
4
|
+
// round-trip into `wikidata entity` for full detail.
|
|
5
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { WIKIDATA_BASE, requireBoundedInt, requireLanguage, requireString, wikidataFetch } from './utils.js';
|
|
8
|
+
|
|
9
|
+
cli({
|
|
10
|
+
site: 'wikidata',
|
|
11
|
+
name: 'search',
|
|
12
|
+
access: 'read',
|
|
13
|
+
description: 'Search Wikidata items by keyword (returns Q-IDs)',
|
|
14
|
+
domain: 'www.wikidata.org',
|
|
15
|
+
strategy: Strategy.PUBLIC,
|
|
16
|
+
browser: false,
|
|
17
|
+
args: [
|
|
18
|
+
{ name: 'query', positional: true, required: true, help: 'Search keyword (label / alias)' },
|
|
19
|
+
{ name: 'language', default: 'en', help: 'Search & display language (ISO 639, e.g. en, fr, zh)' },
|
|
20
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max items (1-50)' },
|
|
21
|
+
],
|
|
22
|
+
columns: ['rank', 'qid', 'label', 'description', 'matchType', 'matchText', 'url'],
|
|
23
|
+
func: async (args) => {
|
|
24
|
+
const query = requireString(args.query, 'query');
|
|
25
|
+
const language = requireLanguage(args.language);
|
|
26
|
+
const limit = requireBoundedInt(args.limit, 20, 50);
|
|
27
|
+
const url = `${WIKIDATA_BASE}/w/api.php?action=wbsearchentities`
|
|
28
|
+
+ `&search=${encodeURIComponent(query)}`
|
|
29
|
+
+ `&language=${encodeURIComponent(language)}`
|
|
30
|
+
+ `&uselang=${encodeURIComponent(language)}`
|
|
31
|
+
+ `&type=item&format=json&limit=${limit}&origin=*`;
|
|
32
|
+
const body = await wikidataFetch(url, 'wikidata search');
|
|
33
|
+
const list = Array.isArray(body?.search) ? body.search : [];
|
|
34
|
+
if (!list.length) {
|
|
35
|
+
throw new EmptyResultError('wikidata search', `No Wikidata items matched "${query}" in language "${language}".`);
|
|
36
|
+
}
|
|
37
|
+
return list.slice(0, limit).map((item, i) => {
|
|
38
|
+
const qid = String(item?.id ?? '').trim();
|
|
39
|
+
return {
|
|
40
|
+
rank: i + 1,
|
|
41
|
+
qid,
|
|
42
|
+
label: typeof item?.label === 'string' ? item.label : null,
|
|
43
|
+
description: typeof item?.description === 'string' ? item.description : null,
|
|
44
|
+
matchType: typeof item?.match?.type === 'string' ? item.match.type : null,
|
|
45
|
+
matchText: typeof item?.match?.text === 'string' ? item.match.text : null,
|
|
46
|
+
url: qid ? `${WIKIDATA_BASE}/wiki/${qid}` : '',
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Shared helpers for the Wikidata adapters.
|
|
2
|
+
//
|
|
3
|
+
// Wikidata exposes two complementary public endpoints:
|
|
4
|
+
// • `wbsearchentities` on `www.wikidata.org/w/api.php` for keyword → Q-IDs
|
|
5
|
+
// • `Special:EntityData/<qid>.json` for the canonical entity dump
|
|
6
|
+
// No API key. Anonymous traffic is rate-limited but generous; we set a polite UA.
|
|
7
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
8
|
+
|
|
9
|
+
export const WIKIDATA_BASE = 'https://www.wikidata.org';
|
|
10
|
+
const UA = 'opencli-wikidata-adapter/1.0 (+https://github.com/jackwener/opencli; mailto:opencli@example.com)';
|
|
11
|
+
|
|
12
|
+
// Q-ID = an item; P-ID = a property; L-ID = a lexeme. We accept all three so the
|
|
13
|
+
// adapter can be reused for properties / lexemes without a separate command, but
|
|
14
|
+
// search only returns Q-IDs by default.
|
|
15
|
+
const ENTITY_ID_PATTERN = /^[QPL]\d+$/;
|
|
16
|
+
|
|
17
|
+
export function requireString(value, label) {
|
|
18
|
+
const s = String(value ?? '').trim();
|
|
19
|
+
if (!s) throw new ArgumentError(`wikidata ${label} cannot be empty`);
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
|
|
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(`wikidata ${label} must be a positive integer`);
|
|
28
|
+
}
|
|
29
|
+
if (n > maxValue) {
|
|
30
|
+
throw new ArgumentError(`wikidata ${label} must be <= ${maxValue}`);
|
|
31
|
+
}
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function requireEntityId(value) {
|
|
36
|
+
const raw = String(value ?? '').trim().toUpperCase();
|
|
37
|
+
if (!raw) throw new ArgumentError('wikidata entity id is required (e.g. "Q937")');
|
|
38
|
+
// Tolerate URL-paste like `https://www.wikidata.org/wiki/Q937`.
|
|
39
|
+
const stripped = raw.replace(/^HTTPS?:\/\/[^/]+\/WIKI\//i, '');
|
|
40
|
+
if (!ENTITY_ID_PATTERN.test(stripped)) {
|
|
41
|
+
throw new ArgumentError(
|
|
42
|
+
`wikidata entity id "${value}" is not a valid Q/P/L identifier`,
|
|
43
|
+
'Expected format: "Q<digits>" (item), "P<digits>" (property), or "L<digits>" (lexeme).',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return stripped;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function requireLanguage(value, defaultValue = 'en') {
|
|
50
|
+
const raw = String(value ?? defaultValue).trim().toLowerCase();
|
|
51
|
+
// Wikidata language codes are 2-3 letter ISO 639 codes plus optional region (`zh-hans`).
|
|
52
|
+
if (!/^[a-z]{2,3}(-[a-z]{2,8})?$/.test(raw)) {
|
|
53
|
+
throw new ArgumentError(
|
|
54
|
+
`wikidata language "${value}" is not a valid language code`,
|
|
55
|
+
'Expected an ISO 639 language code such as "en", "fr", "zh", "zh-hans".',
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return raw;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function wikidataFetch(url, label) {
|
|
62
|
+
let resp;
|
|
63
|
+
try {
|
|
64
|
+
resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw new CommandExecutionError(
|
|
68
|
+
`${label} request failed: ${err?.message ?? err}`,
|
|
69
|
+
'Check that www.wikidata.org is reachable from this network.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (resp.status === 404) {
|
|
73
|
+
throw new EmptyResultError(label, `Wikidata returned 404 for ${url}.`);
|
|
74
|
+
}
|
|
75
|
+
if (resp.status === 429) {
|
|
76
|
+
throw new CommandExecutionError(
|
|
77
|
+
`${label} returned HTTP 429 (rate limited)`,
|
|
78
|
+
'Wikidata throttles anonymous traffic; back off and retry.',
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
if (!resp.ok) {
|
|
82
|
+
throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
|
|
83
|
+
}
|
|
84
|
+
let body;
|
|
85
|
+
try {
|
|
86
|
+
body = await resp.json();
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
|
|
90
|
+
}
|
|
91
|
+
return body;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pick a localised label / description from a `{<lang>: {value}}` map.
|
|
96
|
+
* Falls back to English if the requested language is missing.
|
|
97
|
+
*/
|
|
98
|
+
export function pickLocalised(map, language) {
|
|
99
|
+
if (!map || typeof map !== 'object') return null;
|
|
100
|
+
const direct = map[language];
|
|
101
|
+
if (direct && typeof direct.value === 'string' && direct.value.trim()) return direct.value;
|
|
102
|
+
if (language !== 'en' && map.en && typeof map.en.value === 'string' && map.en.value.trim()) {
|
|
103
|
+
return map.en.value;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function joinAliases(aliases, language, max = 5) {
|
|
109
|
+
if (!aliases || typeof aliases !== 'object') return '';
|
|
110
|
+
// Mirror the label/description fallback: prefer requested language, then English.
|
|
111
|
+
let list = Array.isArray(aliases[language]) ? aliases[language] : [];
|
|
112
|
+
if (list.length === 0 && language !== 'en' && Array.isArray(aliases.en)) list = aliases.en;
|
|
113
|
+
const names = list.map((a) => (a && typeof a.value === 'string' ? a.value : '')).filter(Boolean);
|
|
114
|
+
if (names.length === 0) return '';
|
|
115
|
+
if (names.length > max) return [...names.slice(0, max), `(+${names.length - max})`].join(', ');
|
|
116
|
+
return names.join(', ');
|
|
117
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './search.js';
|
|
5
|
+
import './entity.js';
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.unstubAllGlobals();
|
|
9
|
+
vi.restoreAllMocks();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('wikidata search adapter', () => {
|
|
13
|
+
const cmd = getRegistry().get('wikidata/search');
|
|
14
|
+
|
|
15
|
+
it('rejects bad args before fetching', async () => {
|
|
16
|
+
const fetchMock = vi.fn();
|
|
17
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
18
|
+
await expect(cmd.func({ query: '' })).rejects.toThrow(ArgumentError);
|
|
19
|
+
await expect(cmd.func({ query: 'foo', limit: 999 })).rejects.toThrow(ArgumentError);
|
|
20
|
+
await expect(cmd.func({ query: 'foo', language: '!!' })).rejects.toThrow(ArgumentError);
|
|
21
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('maps HTTP 429 to CommandExecutionError', async () => {
|
|
25
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('throttled', { status: 429 })));
|
|
26
|
+
await expect(cmd.func({ query: 'einstein', limit: 5 })).rejects.toThrow(CommandExecutionError);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('throws EmptyResultError on empty search list', async () => {
|
|
30
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ search: [] }), { status: 200 })));
|
|
31
|
+
await expect(cmd.func({ query: 'no-such-thing', limit: 5 })).rejects.toThrow(EmptyResultError);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('round-trips Q-id from search row into wikidata.org URL', async () => {
|
|
35
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
36
|
+
search: [{ id: 'Q937', label: 'Albert Einstein', description: 'physicist', match: { type: 'alias', text: 'Einstein' } }],
|
|
37
|
+
}), { status: 200 })));
|
|
38
|
+
const rows = await cmd.func({ query: 'einstein', limit: 5 });
|
|
39
|
+
expect(rows[0]).toMatchObject({
|
|
40
|
+
rank: 1, qid: 'Q937', label: 'Albert Einstein', matchType: 'alias',
|
|
41
|
+
url: 'https://www.wikidata.org/wiki/Q937',
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('wikidata entity adapter', () => {
|
|
47
|
+
const cmd = getRegistry().get('wikidata/entity');
|
|
48
|
+
|
|
49
|
+
it('rejects malformed entity ids before fetching', async () => {
|
|
50
|
+
const fetchMock = vi.fn();
|
|
51
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
52
|
+
await expect(cmd.func({ id: '' })).rejects.toThrow(ArgumentError);
|
|
53
|
+
await expect(cmd.func({ id: 'not-a-qid' })).rejects.toThrow(ArgumentError);
|
|
54
|
+
await expect(cmd.func({ id: 'Q' })).rejects.toThrow(ArgumentError);
|
|
55
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('maps HTTP 404 to EmptyResultError', async () => {
|
|
59
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('missing', { status: 404 })));
|
|
60
|
+
await expect(cmd.func({ id: 'Q9999999999' })).rejects.toThrow(EmptyResultError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('falls back to English label when requested language is missing', async () => {
|
|
64
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
|
65
|
+
entities: {
|
|
66
|
+
Q937: {
|
|
67
|
+
id: 'Q937', type: 'item', modified: '2026-01-01T00:00:00Z',
|
|
68
|
+
labels: { en: { value: 'Albert Einstein', language: 'en' } },
|
|
69
|
+
descriptions: { en: { value: 'physicist', language: 'en' } },
|
|
70
|
+
aliases: { en: [{ value: 'A. Einstein', language: 'en' }] },
|
|
71
|
+
claims: { P31: [], P21: [] },
|
|
72
|
+
sitelinks: { enwiki: { title: 'Albert Einstein', site: 'enwiki' } },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}), { status: 200 })));
|
|
76
|
+
const rows = await cmd.func({ id: 'Q937', language: 'qq' });
|
|
77
|
+
expect(rows[0]).toMatchObject({
|
|
78
|
+
qid: 'Q937', label: 'Albert Einstein', description: 'physicist',
|
|
79
|
+
aliases: 'A. Einstein', claimPropertyCount: 2, sitelinkCount: 1,
|
|
80
|
+
enwikiTitle: 'Albert Einstein', url: 'https://www.wikidata.org/wiki/Q937',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// wikipedia page — full article extract (plain text) with optional paragraph cap.
|
|
2
|
+
//
|
|
3
|
+
// Unlike `wikipedia summary` which returns the lead-section blurb truncated to
|
|
4
|
+
// 300 chars, this adapter returns the *complete* article body (or the first N
|
|
5
|
+
// paragraphs by explicit opt-in). No silent truncation: the caller decides.
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import {
|
|
8
|
+
ArgumentError,
|
|
9
|
+
CommandExecutionError,
|
|
10
|
+
EmptyResultError,
|
|
11
|
+
} from '@jackwener/opencli/errors';
|
|
12
|
+
|
|
13
|
+
cli({
|
|
14
|
+
site: 'wikipedia',
|
|
15
|
+
name: 'page',
|
|
16
|
+
access: 'read',
|
|
17
|
+
description: 'Full plain-text extract of a Wikipedia article (optional paragraph cap).',
|
|
18
|
+
domain: 'wikipedia.org',
|
|
19
|
+
strategy: Strategy.PUBLIC,
|
|
20
|
+
browser: false,
|
|
21
|
+
args: [
|
|
22
|
+
{ name: 'title', positional: true, required: true, type: 'string', help: 'Article title (e.g. "Transformer (machine learning model)")' },
|
|
23
|
+
{ name: 'lang', type: 'string', default: 'en', help: 'Language code (en, zh, ja, de, ...).' },
|
|
24
|
+
{ name: 'paragraphs', type: 'int', default: 0, help: 'Cap to first N paragraphs (0 = full article).' },
|
|
25
|
+
],
|
|
26
|
+
columns: ['title', 'description', 'pageId', 'paragraphs', 'extract', 'url'],
|
|
27
|
+
func: async (args) => {
|
|
28
|
+
const title = String(args.title ?? '').trim();
|
|
29
|
+
if (!title) {
|
|
30
|
+
throw new ArgumentError('wikipedia page title cannot be empty');
|
|
31
|
+
}
|
|
32
|
+
const lang = String(args.lang ?? 'en').trim().toLowerCase();
|
|
33
|
+
if (!/^[a-z]{2,3}(?:-[a-z0-9]+)?$/.test(lang)) {
|
|
34
|
+
throw new ArgumentError(`wikipedia lang must be a language code like en, zh, ja (got "${args.lang}")`);
|
|
35
|
+
}
|
|
36
|
+
const paragraphsCap = Number(args.paragraphs ?? 0);
|
|
37
|
+
if (!Number.isInteger(paragraphsCap) || paragraphsCap < 0) {
|
|
38
|
+
throw new ArgumentError('paragraphs must be a non-negative integer (0 = full article)');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const url = new URL(`https://${lang}.wikipedia.org/w/api.php`);
|
|
42
|
+
url.searchParams.set('action', 'query');
|
|
43
|
+
url.searchParams.set('format', 'json');
|
|
44
|
+
url.searchParams.set('formatversion', '2');
|
|
45
|
+
url.searchParams.set('prop', 'extracts|info|description');
|
|
46
|
+
url.searchParams.set('inprop', 'url');
|
|
47
|
+
url.searchParams.set('explaintext', '1');
|
|
48
|
+
url.searchParams.set('redirects', '1');
|
|
49
|
+
url.searchParams.set('titles', title);
|
|
50
|
+
|
|
51
|
+
let resp;
|
|
52
|
+
try {
|
|
53
|
+
resp = await fetch(url, {
|
|
54
|
+
headers: {
|
|
55
|
+
'User-Agent': 'opencli/1.0 (+https://github.com/jackwener/opencli)',
|
|
56
|
+
'Accept': 'application/json',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
} catch (error) {
|
|
60
|
+
throw new CommandExecutionError(`wikipedia page request failed: ${error?.message || error}`);
|
|
61
|
+
}
|
|
62
|
+
if (!resp.ok) {
|
|
63
|
+
throw new CommandExecutionError(`wikipedia page failed: HTTP ${resp.status}`);
|
|
64
|
+
}
|
|
65
|
+
let data;
|
|
66
|
+
try {
|
|
67
|
+
data = await resp.json();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new CommandExecutionError(`wikipedia returned malformed JSON: ${error?.message || error}`);
|
|
70
|
+
}
|
|
71
|
+
if (data?.error) {
|
|
72
|
+
throw new CommandExecutionError(`wikipedia API error: ${data.error.info || data.error.code}`);
|
|
73
|
+
}
|
|
74
|
+
const pages = Array.isArray(data?.query?.pages) ? data.query.pages : [];
|
|
75
|
+
const page = pages[0];
|
|
76
|
+
if (!page || page.missing) {
|
|
77
|
+
throw new EmptyResultError('wikipedia page', `No article "${title}" on ${lang}.wikipedia.org. Try \`opencli wikipedia search\` first.`);
|
|
78
|
+
}
|
|
79
|
+
const fullExtract = String(page.extract ?? '');
|
|
80
|
+
if (!fullExtract.trim()) {
|
|
81
|
+
throw new EmptyResultError('wikipedia page', `Article "${page.title}" exists but has no plain-text extract (likely a disambiguation/redirect page).`);
|
|
82
|
+
}
|
|
83
|
+
const allParas = fullExtract.split(/\n{2,}/).map(s => s.trim()).filter(Boolean);
|
|
84
|
+
const paras = paragraphsCap > 0 ? allParas.slice(0, paragraphsCap) : allParas;
|
|
85
|
+
|
|
86
|
+
return [{
|
|
87
|
+
title: page.title,
|
|
88
|
+
description: page.description || '',
|
|
89
|
+
pageId: page.pageid ?? null,
|
|
90
|
+
paragraphs: paras.length,
|
|
91
|
+
extract: paras.join('\n\n'),
|
|
92
|
+
url: page.fullurl || `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(page.title.replace(/ /g, '_'))}`,
|
|
93
|
+
}];
|
|
94
|
+
},
|
|
95
|
+
});
|