@jackwener/opencli 1.7.22 → 1.8.1
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 +35 -194
- package/README.zh-CN.md +42 -260
- package/cli-manifest.json +8160 -4392
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/comment.js +125 -0
- package/clis/bilibili/comment.test.js +153 -0
- package/clis/bilibili/comments.js +116 -21
- package/clis/bilibili/comments.test.js +77 -18
- package/clis/bilibili/subtitle.js +76 -31
- package/clis/bilibili/subtitle.test.js +156 -9
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/bilibili/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/chess/analyze.js +35 -0
- package/clis/chess/analyze.test.js +79 -0
- package/clis/chess/game.js +114 -0
- package/clis/chess/game.test.js +178 -0
- package/clis/chess/games.js +67 -0
- package/clis/chess/games.test.js +164 -0
- package/clis/chess/stats.js +32 -0
- package/clis/chess/stats.test.js +79 -0
- package/clis/chess/utils.js +170 -0
- package/clis/chess/utils.test.js +230 -0
- package/clis/confluence/commands.test.js +195 -0
- package/clis/confluence/create.js +39 -0
- package/clis/confluence/page.js +23 -0
- package/clis/confluence/search.js +34 -0
- package/clis/confluence/shared.js +173 -0
- package/clis/confluence/update.js +38 -0
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -0
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/geogebra/add-circle.js +46 -0
- package/clis/geogebra/add-line.js +35 -0
- package/clis/geogebra/add-point.js +27 -0
- package/clis/geogebra/add-polygon.js +25 -0
- package/clis/geogebra/eval.js +35 -0
- package/clis/geogebra/geogebra.test.js +175 -0
- package/clis/geogebra/hexagon.js +62 -0
- package/clis/geogebra/info.js +72 -0
- package/clis/geogebra/list.js +35 -0
- package/clis/geogebra/triangle.js +60 -0
- package/clis/geogebra/utils.js +271 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/jira/attachments.js +28 -0
- package/clis/jira/commands.test.js +287 -0
- package/clis/jira/comments.js +28 -0
- package/clis/jira/issue.js +28 -0
- package/clis/jira/links.js +28 -0
- package/clis/jira/search.js +47 -0
- package/clis/jira/shared.js +256 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/job-detail.js +167 -0
- package/clis/linkedin/job-detail.test.js +38 -0
- package/clis/linkedin/jobs-preferences.js +113 -0
- package/clis/linkedin/jobs-preferences.test.js +43 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/post-analytics.js +74 -0
- package/clis/linkedin/post-analytics.test.js +40 -0
- package/clis/linkedin/posts-core.js +241 -0
- package/clis/linkedin/posts.js +22 -0
- package/clis/linkedin/posts.test.js +40 -0
- package/clis/linkedin/profile-analytics.js +104 -0
- package/clis/linkedin/profile-analytics.test.js +67 -0
- package/clis/linkedin/profile-experience.js +671 -0
- package/clis/linkedin/profile-experience.test.js +152 -0
- package/clis/linkedin/profile-projects.js +311 -0
- package/clis/linkedin/profile-projects.test.js +111 -0
- package/clis/linkedin/profile-read.js +148 -0
- package/clis/linkedin/profile-read.test.js +77 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin/timeline.js +14 -7
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/notebooklm/add-source.js +269 -0
- package/clis/notebooklm/add-source.test.js +97 -0
- package/clis/notebooklm/create.js +76 -0
- package/clis/notebooklm/create.test.js +58 -0
- package/clis/notebooklm/generate-audio.js +91 -0
- package/clis/notebooklm/generate-audio.test.js +63 -0
- package/clis/notebooklm/generate-slides.js +106 -0
- package/clis/notebooklm/generate-slides.test.js +75 -0
- package/clis/notebooklm/open.test.js +10 -10
- package/clis/notebooklm/rpc.js +20 -6
- package/clis/notebooklm/rpc.test.js +27 -1
- package/clis/notebooklm/utils.js +100 -24
- package/clis/notebooklm/utils.test.js +60 -1
- package/clis/notebooklm/write-note.js +103 -0
- package/clis/notebooklm/write-note.test.js +70 -0
- package/clis/pixiv/detail.js +41 -34
- package/clis/pixiv/detail.test.js +93 -0
- package/clis/pixiv/user.js +36 -31
- package/clis/pixiv/user.test.js +100 -0
- package/clis/pixiv/utils.js +56 -7
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +231 -0
- package/clis/suno/generate.test.js +252 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +63 -0
- package/clis/suno/utils.js +549 -0
- package/clis/suno/utils.test.js +329 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/followers.js +6 -2
- package/clis/twitter/followers.test.js +19 -1
- package/clis/twitter/following.js +14 -5
- package/clis/twitter/following.test.js +29 -0
- package/clis/twitter/likes.js +12 -4
- package/clis/twitter/likes.test.js +26 -1
- package/clis/twitter/list-add.js +1 -1
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +13 -6
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/notifications.js +4 -4
- package/clis/twitter/post.js +62 -4
- package/clis/twitter/post.test.js +35 -3
- package/clis/twitter/profile.js +81 -28
- package/clis/twitter/profile.test.js +113 -2
- package/clis/twitter/quote.js +9 -4
- package/clis/twitter/reply.js +13 -10
- package/clis/twitter/reply.test.js +41 -0
- package/clis/twitter/search.js +7 -3
- package/clis/twitter/search.test.js +41 -0
- package/clis/twitter/shared.js +155 -0
- package/clis/twitter/shared.test.js +465 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/twitter/utils.js +53 -16
- package/clis/upwork/detail.js +132 -0
- package/clis/upwork/feed.js +109 -0
- package/clis/upwork/search.js +115 -0
- package/clis/upwork/upwork.test.js +566 -0
- package/clis/upwork/utils.js +323 -0
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +98 -11
- package/clis/weread/search.js +32 -9
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +166 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +252 -2
- package/clis/xiaohongshu/creator-notes.test.js +90 -1
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +280 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +2 -19
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +17 -16
- package/clis/zhihu/collection.test.js +50 -3
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -17
- package/clis/zhihu/question.test.js +113 -11
- package/clis/zhihu/search.js +195 -43
- package/clis/zhihu/search.test.js +198 -0
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +112 -0
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/output.js +11 -1
- package/dist/src/output.test.js +6 -0
- package/dist/src/registry.js +1 -0
- package/dist/src/registry.test.js +11 -0
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeRead Official Agent Gateway helpers.
|
|
3
|
+
*
|
|
4
|
+
* Auth model: Bearer API key (WEREAD_API_KEY env var, format `wrk-*`).
|
|
5
|
+
* Transport: pure HTTP, no browser required. Body shape must keep every
|
|
6
|
+
* business parameter flat alongside `api_name` and `skill_version`
|
|
7
|
+
* (wrapping them in `params`/`data`/`body` silently breaks the
|
|
8
|
+
* gateway — see SKILL.md "请求 few-shot").
|
|
9
|
+
*
|
|
10
|
+
* This module is intentionally side-effect free outside `callGateway` so each
|
|
11
|
+
* command file can import only the helpers it needs.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
ArgumentError,
|
|
15
|
+
AuthRequiredError,
|
|
16
|
+
CommandExecutionError,
|
|
17
|
+
EmptyResultError,
|
|
18
|
+
TimeoutError,
|
|
19
|
+
} from '@jackwener/opencli/errors';
|
|
20
|
+
|
|
21
|
+
export const WEREAD_GATEWAY_URL = 'https://i.weread.qq.com/api/agent/gateway';
|
|
22
|
+
export const WEREAD_DOMAIN = 'weread.qq.com';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Skill version reported with every gateway request. Bump when official
|
|
26
|
+
* `weread-skills.zip` ships a new SKILL.md `version:` line.
|
|
27
|
+
*/
|
|
28
|
+
export const SKILL_VERSION = '1.0.3';
|
|
29
|
+
|
|
30
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
/** errcodes that mean "Bearer key invalid / token expired" — map to AuthRequiredError. */
|
|
33
|
+
const AUTH_ERRCODES = new Set([-2010, -2012]);
|
|
34
|
+
|
|
35
|
+
/** Resolve API key from env. Throws AuthRequiredError on missing / blank value. */
|
|
36
|
+
export function getApiKey() {
|
|
37
|
+
const key = String(process.env.WEREAD_API_KEY ?? '').trim();
|
|
38
|
+
if (!key) {
|
|
39
|
+
throw new AuthRequiredError(
|
|
40
|
+
WEREAD_DOMAIN,
|
|
41
|
+
'WEREAD_API_KEY is not set. Export it with `export WEREAD_API_KEY=<wrk-...>`.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return key;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build the gateway request body. Business params are flattened next to
|
|
49
|
+
* `api_name` and `skill_version` — never wrapped in a `params` / `data` /
|
|
50
|
+
* `body` object (the gateway silently drops them and returns page 1).
|
|
51
|
+
*/
|
|
52
|
+
export function buildGatewayBody(apiName, params = {}) {
|
|
53
|
+
if (!apiName || typeof apiName !== 'string') {
|
|
54
|
+
throw new ArgumentError('weread-official: api_name is required');
|
|
55
|
+
}
|
|
56
|
+
const body = { api_name: apiName, skill_version: SKILL_VERSION };
|
|
57
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
58
|
+
if (value === undefined || value === null || value === '') continue;
|
|
59
|
+
body[key] = value;
|
|
60
|
+
}
|
|
61
|
+
return body;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* POST to the agent gateway. Returns the parsed JSON payload on success.
|
|
66
|
+
* Maps every documented failure mode to a typed CliError:
|
|
67
|
+
* - missing env key → AuthRequiredError
|
|
68
|
+
* - HTTP non-2xx → CommandExecutionError
|
|
69
|
+
* - network timeout → TimeoutError
|
|
70
|
+
* - response includes upgrade_info → CommandExecutionError (with version hint)
|
|
71
|
+
* - errcode in AUTH_ERRCODES → AuthRequiredError (Bearer key likely revoked)
|
|
72
|
+
* - errcode != 0 → CommandExecutionError
|
|
73
|
+
*/
|
|
74
|
+
export async function callGateway(apiName, params = {}, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
75
|
+
const key = getApiKey();
|
|
76
|
+
const body = buildGatewayBody(apiName, params);
|
|
77
|
+
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
80
|
+
|
|
81
|
+
let response;
|
|
82
|
+
try {
|
|
83
|
+
response = await fetch(WEREAD_GATEWAY_URL, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: `Bearer ${key}`,
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
signal: controller.signal,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error?.name === 'AbortError') {
|
|
95
|
+
throw new TimeoutError(`weread-official ${apiName}`, Math.round(timeoutMs / 1000));
|
|
96
|
+
}
|
|
97
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
98
|
+
throw new CommandExecutionError(`weread-official ${apiName} request failed`, detail);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
throw new CommandExecutionError(
|
|
106
|
+
`weread-official ${apiName} HTTP ${response.status}`,
|
|
107
|
+
'Check WeRead gateway availability and that WEREAD_API_KEY is still valid.',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let payload;
|
|
112
|
+
try {
|
|
113
|
+
payload = await response.json();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
117
|
+
throw new CommandExecutionError(`weread-official ${apiName} returned invalid JSON`, detail);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (payload && typeof payload === 'object' && payload.upgrade_info) {
|
|
121
|
+
const info = payload.upgrade_info;
|
|
122
|
+
const required = info?.required_version ?? info?.version ?? 'unknown';
|
|
123
|
+
const message = info?.message ?? 'WeRead skill version is outdated';
|
|
124
|
+
throw new CommandExecutionError(
|
|
125
|
+
`WeRead skill 需升级: ${message}. Required skill_version=${required}, current=${SKILL_VERSION}`,
|
|
126
|
+
'Pull the latest weread-skills.zip and bump SKILL_VERSION in clis/weread-official/utils.js.',
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const errcode = Number(payload?.errcode ?? 0);
|
|
131
|
+
if (errcode !== 0) {
|
|
132
|
+
const errmsg = String(payload?.errmsg ?? 'unknown error');
|
|
133
|
+
if (AUTH_ERRCODES.has(errcode)) {
|
|
134
|
+
throw new AuthRequiredError(
|
|
135
|
+
WEREAD_DOMAIN,
|
|
136
|
+
`WEREAD_API_KEY rejected (errcode=${errcode}, ${errmsg}). Regenerate the key and re-export it.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
throw new CommandExecutionError(
|
|
140
|
+
`weread-official ${apiName} returned errcode=${errcode}`,
|
|
141
|
+
errmsg,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return payload;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ── Formatting helpers ──────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
/** Unix timestamp (sec) → YYYY-MM-DD using UTC for stable test snapshots. */
|
|
151
|
+
export function formatDate(ts) {
|
|
152
|
+
const seconds = Number(ts);
|
|
153
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return '';
|
|
154
|
+
const date = new Date(seconds * 1000);
|
|
155
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
156
|
+
const y = date.getUTCFullYear();
|
|
157
|
+
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
158
|
+
const d = String(date.getUTCDate()).padStart(2, '0');
|
|
159
|
+
return `${y}-${m}-${d}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Seconds → "X小时Y分钟" (or "Y分钟" if no hours, "0分钟" if < 1 minute). */
|
|
163
|
+
export function formatDuration(secs) {
|
|
164
|
+
if (secs === null || secs === undefined || secs === '') return '';
|
|
165
|
+
const total = Number(secs);
|
|
166
|
+
if (!Number.isFinite(total) || total < 0) return '';
|
|
167
|
+
const seconds = Math.floor(total);
|
|
168
|
+
const hours = Math.floor(seconds / 3600);
|
|
169
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
170
|
+
if (hours > 0) return `${hours}小时${minutes}分钟`;
|
|
171
|
+
return `${minutes}分钟`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* WeRead `star` field uses multiples of 20 (20=1⭐, 40=2⭐, …, 100=5⭐).
|
|
176
|
+
* `-1` and `0` both mean "no rating".
|
|
177
|
+
*/
|
|
178
|
+
export function formatStar(star) {
|
|
179
|
+
const value = Number(star);
|
|
180
|
+
if (!Number.isFinite(value) || value <= 0) return '无评分';
|
|
181
|
+
const count = Math.min(5, Math.floor(value / 20));
|
|
182
|
+
if (count <= 0) return '无评分';
|
|
183
|
+
return '⭐'.repeat(count);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* WeRead book rating uses a 0-1000 scale (1000 = 100%). Returns a short label
|
|
188
|
+
* with the canonical tier names from the official skill output examples.
|
|
189
|
+
*/
|
|
190
|
+
export function formatRating(rating) {
|
|
191
|
+
const value = Number(rating);
|
|
192
|
+
if (!Number.isFinite(value) || value <= 0) return '暂无';
|
|
193
|
+
const percent = value / 10;
|
|
194
|
+
if (percent >= 90) return `神作 ${Math.round(percent)}%`;
|
|
195
|
+
if (percent >= 80) return `力荐 ${Math.round(percent)}%`;
|
|
196
|
+
if (percent >= 70) return `好评 ${Math.round(percent)}%`;
|
|
197
|
+
return `${percent.toFixed(1)}分`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Truncate text to `maxLen` chars with an ellipsis suffix. Returns '' for nullish. */
|
|
201
|
+
export function truncate(text, maxLen = 200) {
|
|
202
|
+
const value = String(text ?? '');
|
|
203
|
+
if (!value) return '';
|
|
204
|
+
if (value.length <= maxLen) return value;
|
|
205
|
+
return `${value.slice(0, maxLen)}…`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Deep links ──────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Build a `weread://` deep link.
|
|
212
|
+
* - bookId only → open at last reading position
|
|
213
|
+
* - bookId + chapterUid → open chapter
|
|
214
|
+
* - bookId + chapterUid + rangeStart + rangeEnd → open bestbookmark position
|
|
215
|
+
*/
|
|
216
|
+
export function makeDeepLink({ bookId, chapterUid = '', rangeStart = '', rangeEnd = '', userVid = '' } = {}) {
|
|
217
|
+
const bid = String(bookId ?? '').trim();
|
|
218
|
+
if (!bid) return '';
|
|
219
|
+
const chapter = String(chapterUid ?? '').trim();
|
|
220
|
+
const start = String(rangeStart ?? '').trim();
|
|
221
|
+
const end = String(rangeEnd ?? '').trim();
|
|
222
|
+
if (chapter && start && end) {
|
|
223
|
+
const params = new URLSearchParams({ bookId: bid, chapterUid: chapter, rangeStart: start, rangeEnd: end });
|
|
224
|
+
const vid = String(userVid ?? '').trim();
|
|
225
|
+
if (vid) params.set('userVid', vid);
|
|
226
|
+
return `weread://bestbookmark?${params.toString()}`;
|
|
227
|
+
}
|
|
228
|
+
if (chapter) return `weread://reading?bId=${bid}&chapterUid=${chapter}`;
|
|
229
|
+
return `weread://reading?bId=${bid}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Split a WeRead `range` field ("900-2004") into `{rangeStart, rangeEnd}`.
|
|
234
|
+
* Returns empty strings when the input is missing/malformed.
|
|
235
|
+
*/
|
|
236
|
+
export function parseRange(range) {
|
|
237
|
+
const text = String(range ?? '').trim();
|
|
238
|
+
const match = text.match(/^(\d+)-(\d+)$/);
|
|
239
|
+
if (!match) return { rangeStart: '', rangeEnd: '' };
|
|
240
|
+
return { rangeStart: match[1], rangeEnd: match[2] };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Argument validation ─────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
export function requireText(value, label) {
|
|
246
|
+
const text = String(value ?? '').trim();
|
|
247
|
+
if (!text) throw new ArgumentError(`weread-official: ${label} cannot be empty`);
|
|
248
|
+
return text;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function requireBookId(value, label = 'bookId') {
|
|
252
|
+
const text = requireText(value, label);
|
|
253
|
+
if (!/^[A-Za-z0-9_-]+$/.test(text)) {
|
|
254
|
+
throw new ArgumentError(`weread-official: ${label} contains invalid characters`, 'Pass a bookId from `weread-official search`.');
|
|
255
|
+
}
|
|
256
|
+
return text;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function requirePositiveInt(value, label, { defaultValue, max } = {}) {
|
|
260
|
+
if (value === undefined || value === null || value === '') {
|
|
261
|
+
if (defaultValue === undefined) {
|
|
262
|
+
throw new ArgumentError(`weread-official: ${label} is required`);
|
|
263
|
+
}
|
|
264
|
+
return defaultValue;
|
|
265
|
+
}
|
|
266
|
+
const text = String(value).trim();
|
|
267
|
+
if (!/^\d+$/.test(text)) {
|
|
268
|
+
throw new ArgumentError(`weread-official: ${label} must be a positive integer`);
|
|
269
|
+
}
|
|
270
|
+
const n = Number(text);
|
|
271
|
+
if (!Number.isSafeInteger(n) || n < 1) {
|
|
272
|
+
throw new ArgumentError(`weread-official: ${label} must be a positive integer`);
|
|
273
|
+
}
|
|
274
|
+
if (max !== undefined && n > max) {
|
|
275
|
+
throw new ArgumentError(`weread-official: ${label} must be <= ${max}`);
|
|
276
|
+
}
|
|
277
|
+
return n;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function requireChoice(value, choices, label, defaultValue) {
|
|
281
|
+
const text = String(value ?? defaultValue ?? '').trim();
|
|
282
|
+
if (!choices.includes(text)) {
|
|
283
|
+
throw new ArgumentError(`weread-official: ${label} must be one of: ${choices.join(', ')}`);
|
|
284
|
+
}
|
|
285
|
+
return text;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Empty-result helper ────────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
/** Throw EmptyResultError with a stable command label. */
|
|
291
|
+
export function emptyResult(command, hint) {
|
|
292
|
+
throw new EmptyResultError(`weread-official ${command}`, hint);
|
|
293
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
ArgumentError,
|
|
4
|
+
AuthRequiredError,
|
|
5
|
+
CommandExecutionError,
|
|
6
|
+
EmptyResultError,
|
|
7
|
+
TimeoutError,
|
|
8
|
+
} from '@jackwener/opencli/errors';
|
|
9
|
+
import {
|
|
10
|
+
SKILL_VERSION,
|
|
11
|
+
WEREAD_GATEWAY_URL,
|
|
12
|
+
buildGatewayBody,
|
|
13
|
+
callGateway,
|
|
14
|
+
emptyResult,
|
|
15
|
+
formatDate,
|
|
16
|
+
formatDuration,
|
|
17
|
+
formatRating,
|
|
18
|
+
formatStar,
|
|
19
|
+
getApiKey,
|
|
20
|
+
makeDeepLink,
|
|
21
|
+
parseRange,
|
|
22
|
+
requireBookId,
|
|
23
|
+
requireChoice,
|
|
24
|
+
requirePositiveInt,
|
|
25
|
+
requireText,
|
|
26
|
+
truncate,
|
|
27
|
+
} from './utils.js';
|
|
28
|
+
|
|
29
|
+
function jsonResponse(body, ok = true, status = 200) {
|
|
30
|
+
return {
|
|
31
|
+
ok,
|
|
32
|
+
status,
|
|
33
|
+
json: vi.fn().mockResolvedValue(body),
|
|
34
|
+
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.unstubAllGlobals();
|
|
40
|
+
vi.unstubAllEnvs();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('weread-official utils — formatting', () => {
|
|
44
|
+
it('formats Unix timestamps as YYYY-MM-DD (UTC) and falls back to empty', () => {
|
|
45
|
+
expect(formatDate(1748563200)).toBe('2025-05-30');
|
|
46
|
+
expect(formatDate(0)).toBe('');
|
|
47
|
+
expect(formatDate(null)).toBe('');
|
|
48
|
+
expect(formatDate('not-a-number')).toBe('');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('formats duration seconds into "X小时Y分钟" / "Y分钟"', () => {
|
|
52
|
+
expect(formatDuration(3600)).toBe('1小时0分钟');
|
|
53
|
+
expect(formatDuration(3660)).toBe('1小时1分钟');
|
|
54
|
+
expect(formatDuration(59)).toBe('0分钟');
|
|
55
|
+
expect(formatDuration(0)).toBe('0分钟');
|
|
56
|
+
expect(formatDuration(-1)).toBe('');
|
|
57
|
+
expect(formatDuration(null)).toBe('');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('translates star multiples-of-20 into ⭐ glyphs (caps at 5)', () => {
|
|
61
|
+
expect(formatStar(20)).toBe('⭐');
|
|
62
|
+
expect(formatStar(80)).toBe('⭐⭐⭐⭐');
|
|
63
|
+
expect(formatStar(100)).toBe('⭐⭐⭐⭐⭐');
|
|
64
|
+
expect(formatStar(120)).toBe('⭐⭐⭐⭐⭐');
|
|
65
|
+
expect(formatStar(0)).toBe('无评分');
|
|
66
|
+
expect(formatStar(-1)).toBe('无评分');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('labels ratings by the official 0-1000 tiers', () => {
|
|
70
|
+
expect(formatRating(950)).toMatch(/^神作 95%$/);
|
|
71
|
+
expect(formatRating(820)).toMatch(/^力荐 82%$/);
|
|
72
|
+
expect(formatRating(710)).toMatch(/^好评 71%$/);
|
|
73
|
+
expect(formatRating(550)).toBe('55.0分');
|
|
74
|
+
expect(formatRating(0)).toBe('暂无');
|
|
75
|
+
expect(formatRating(null)).toBe('暂无');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('truncates text with an ellipsis suffix and tolerates nullish input', () => {
|
|
79
|
+
expect(truncate('hello', 100)).toBe('hello');
|
|
80
|
+
expect(truncate('1234567890', 5)).toBe('12345…');
|
|
81
|
+
expect(truncate(null, 5)).toBe('');
|
|
82
|
+
expect(truncate(undefined, 5)).toBe('');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('weread-official utils — deep links', () => {
|
|
87
|
+
it('builds book-level, chapter-level, and bestbookmark URLs', () => {
|
|
88
|
+
expect(makeDeepLink({ bookId: '3300045871' })).toBe('weread://reading?bId=3300045871');
|
|
89
|
+
expect(makeDeepLink({ bookId: '3300045871', chapterUid: '107' })).toBe('weread://reading?bId=3300045871&chapterUid=107');
|
|
90
|
+
expect(makeDeepLink({ bookId: '3300045871', chapterUid: '107', rangeStart: '900', rangeEnd: '2004' }))
|
|
91
|
+
.toBe('weread://bestbookmark?bookId=3300045871&chapterUid=107&rangeStart=900&rangeEnd=2004');
|
|
92
|
+
expect(makeDeepLink({ bookId: '3300045871', chapterUid: '107', rangeStart: '900', rangeEnd: '2004', userVid: '583802764' }))
|
|
93
|
+
.toBe('weread://bestbookmark?bookId=3300045871&chapterUid=107&rangeStart=900&rangeEnd=2004&userVid=583802764');
|
|
94
|
+
expect(makeDeepLink({})).toBe('');
|
|
95
|
+
expect(makeDeepLink({ bookId: '' })).toBe('');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('parses ranges like "900-2004" and returns empty on malformed input', () => {
|
|
99
|
+
expect(parseRange('900-2004')).toEqual({ rangeStart: '900', rangeEnd: '2004' });
|
|
100
|
+
expect(parseRange('garbage')).toEqual({ rangeStart: '', rangeEnd: '' });
|
|
101
|
+
expect(parseRange('')).toEqual({ rangeStart: '', rangeEnd: '' });
|
|
102
|
+
expect(parseRange(null)).toEqual({ rangeStart: '', rangeEnd: '' });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('weread-official utils — argument validation', () => {
|
|
107
|
+
it('rejects empty text and blank bookIds', () => {
|
|
108
|
+
expect(requireText('hello', 'q')).toBe('hello');
|
|
109
|
+
expect(() => requireText('', 'q')).toThrow(ArgumentError);
|
|
110
|
+
expect(() => requireText(' ', 'q')).toThrow(ArgumentError);
|
|
111
|
+
expect(requireBookId('book_123-AbC')).toBe('book_123-AbC');
|
|
112
|
+
expect(() => requireBookId('')).toThrow(ArgumentError);
|
|
113
|
+
expect(() => requireBookId('book id with space')).toThrow(ArgumentError);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('enforces positive integers with optional default and max', () => {
|
|
117
|
+
expect(requirePositiveInt(undefined, 'n', { defaultValue: 10 })).toBe(10);
|
|
118
|
+
expect(requirePositiveInt('15', 'n')).toBe(15);
|
|
119
|
+
expect(() => requirePositiveInt('0', 'n')).toThrow(ArgumentError);
|
|
120
|
+
expect(() => requirePositiveInt('-3', 'n')).toThrow(ArgumentError);
|
|
121
|
+
expect(() => requirePositiveInt('abc', 'n')).toThrow(ArgumentError);
|
|
122
|
+
expect(() => requirePositiveInt('200', 'n', { max: 100 })).toThrow(/<= 100/);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('requireChoice rejects values outside the allowlist', () => {
|
|
126
|
+
expect(requireChoice('weekly', ['weekly', 'monthly'], 'mode', 'monthly')).toBe('weekly');
|
|
127
|
+
expect(requireChoice(undefined, ['weekly', 'monthly'], 'mode', 'monthly')).toBe('monthly');
|
|
128
|
+
expect(() => requireChoice('quarterly', ['weekly', 'monthly'], 'mode', 'monthly')).toThrow(ArgumentError);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('weread-official utils — request building', () => {
|
|
133
|
+
it('flattens business params alongside api_name + skill_version', () => {
|
|
134
|
+
const body = buildGatewayBody('/store/search', { keyword: '三体', scope: 10, maxIdx: 0 });
|
|
135
|
+
expect(body.api_name).toBe('/store/search');
|
|
136
|
+
expect(body.skill_version).toBe(SKILL_VERSION);
|
|
137
|
+
expect(body.keyword).toBe('三体');
|
|
138
|
+
expect(body.scope).toBe(10);
|
|
139
|
+
// Empty values should be dropped so the gateway does not see empty strings.
|
|
140
|
+
const body2 = buildGatewayBody('/_list', { foo: '', bar: null, baz: undefined, keep: 'yes' });
|
|
141
|
+
expect(body2).not.toHaveProperty('foo');
|
|
142
|
+
expect(body2).not.toHaveProperty('bar');
|
|
143
|
+
expect(body2).not.toHaveProperty('baz');
|
|
144
|
+
expect(body2.keep).toBe('yes');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects empty api_name', () => {
|
|
148
|
+
expect(() => buildGatewayBody('', {})).toThrow(ArgumentError);
|
|
149
|
+
expect(() => buildGatewayBody(null, {})).toThrow(ArgumentError);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('weread-official utils — auth and callGateway', () => {
|
|
154
|
+
it('throws AuthRequiredError when WEREAD_API_KEY env var is missing', () => {
|
|
155
|
+
vi.stubEnv('WEREAD_API_KEY', '');
|
|
156
|
+
expect(() => getApiKey()).toThrow(AuthRequiredError);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('reads and trims WEREAD_API_KEY from env', () => {
|
|
160
|
+
vi.stubEnv('WEREAD_API_KEY', ' wrk-abc ');
|
|
161
|
+
expect(getApiKey()).toBe('wrk-abc');
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('posts with Bearer auth and JSON body to the gateway URL', async () => {
|
|
165
|
+
vi.stubEnv('WEREAD_API_KEY', 'wrk-test');
|
|
166
|
+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ errcode: 0, ok: 1 }));
|
|
167
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
168
|
+
const result = await callGateway('/store/search', { keyword: '三体', scope: 10 });
|
|
169
|
+
expect(result).toEqual({ errcode: 0, ok: 1 });
|
|
170
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
171
|
+
const [url, init] = fetchMock.mock.calls[0];
|
|
172
|
+
expect(url).toBe(WEREAD_GATEWAY_URL);
|
|
173
|
+
expect(init.method).toBe('POST');
|
|
174
|
+
expect(init.headers.Authorization).toBe('Bearer wrk-test');
|
|
175
|
+
expect(init.headers['Content-Type']).toBe('application/json');
|
|
176
|
+
const parsed = JSON.parse(init.body);
|
|
177
|
+
expect(parsed.api_name).toBe('/store/search');
|
|
178
|
+
expect(parsed.skill_version).toBe(SKILL_VERSION);
|
|
179
|
+
expect(parsed.keyword).toBe('三体');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('maps upgrade_info responses into CommandExecutionError naming both versions', async () => {
|
|
183
|
+
vi.stubEnv('WEREAD_API_KEY', 'wrk-test');
|
|
184
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({
|
|
185
|
+
upgrade_info: { message: '请升级 skill', required_version: '99.9.9' },
|
|
186
|
+
errcode: 0,
|
|
187
|
+
})));
|
|
188
|
+
await expect(callGateway('/_list', {})).rejects.toThrow(/Required skill_version=99\.9\.9/);
|
|
189
|
+
await expect(callGateway('/_list', {})).rejects.toThrow(/current=/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('maps Bearer-key reject errcodes into AuthRequiredError', async () => {
|
|
193
|
+
vi.stubEnv('WEREAD_API_KEY', 'wrk-test');
|
|
194
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ errcode: -2010, errmsg: 'token expired' })));
|
|
195
|
+
await expect(callGateway('/shelf/sync', {})).rejects.toBeInstanceOf(AuthRequiredError);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('maps non-zero non-auth errcodes into CommandExecutionError', async () => {
|
|
199
|
+
vi.stubEnv('WEREAD_API_KEY', 'wrk-test');
|
|
200
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({ errcode: 7, errmsg: 'oops' })));
|
|
201
|
+
await expect(callGateway('/shelf/sync', {})).rejects.toBeInstanceOf(CommandExecutionError);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('maps HTTP non-2xx into CommandExecutionError', async () => {
|
|
205
|
+
vi.stubEnv('WEREAD_API_KEY', 'wrk-test');
|
|
206
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, false, 500)));
|
|
207
|
+
await expect(callGateway('/shelf/sync', {})).rejects.toBeInstanceOf(CommandExecutionError);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('maps abort signals into TimeoutError', async () => {
|
|
211
|
+
vi.stubEnv('WEREAD_API_KEY', 'wrk-test');
|
|
212
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(Object.assign(new Error('aborted'), { name: 'AbortError' })));
|
|
213
|
+
await expect(callGateway('/shelf/sync', {})).rejects.toBeInstanceOf(TimeoutError);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('maps generic fetch errors into CommandExecutionError', async () => {
|
|
217
|
+
vi.stubEnv('WEREAD_API_KEY', 'wrk-test');
|
|
218
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
|
|
219
|
+
await expect(callGateway('/shelf/sync', {})).rejects.toBeInstanceOf(CommandExecutionError);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('refuses to call the gateway without an API key', async () => {
|
|
223
|
+
vi.stubEnv('WEREAD_API_KEY', '');
|
|
224
|
+
const fetchMock = vi.fn();
|
|
225
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
226
|
+
await expect(callGateway('/shelf/sync', {})).rejects.toBeInstanceOf(AuthRequiredError);
|
|
227
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('weread-official utils — empty-result helper', () => {
|
|
232
|
+
it('throws EmptyResultError with the canonical command label', () => {
|
|
233
|
+
try {
|
|
234
|
+
emptyResult('search', 'No hits');
|
|
235
|
+
expect.unreachable('emptyResult should have thrown');
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
expect(error).toBeInstanceOf(EmptyResultError);
|
|
239
|
+
expect(error.message).toContain('weread-official search');
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -26,10 +26,14 @@ cli({
|
|
|
26
26
|
const articles = data?.mostread?.articles;
|
|
27
27
|
if (!articles?.length)
|
|
28
28
|
throw new CliError('NOT_FOUND', 'No trending articles available', 'Try a different language with --lang');
|
|
29
|
-
|
|
29
|
+
const selectedArticles = articles.slice(0, limit);
|
|
30
|
+
if (selectedArticles.some((article) => !String(article?.title || '').trim())) {
|
|
31
|
+
throw new CliError('PARSE_ERROR', 'Wikipedia trending returned an article without title', 'Trending rows require a title so they can be opened with wikipedia page.');
|
|
32
|
+
}
|
|
33
|
+
return selectedArticles.map((a, i) => ({
|
|
30
34
|
rank: i + 1,
|
|
31
|
-
title: a.title
|
|
32
|
-
description: (a.description ?? '
|
|
35
|
+
title: a.title,
|
|
36
|
+
description: (a.description ?? '').slice(0, DESC_MAX_LEN),
|
|
33
37
|
views: a.views ?? 0,
|
|
34
38
|
}));
|
|
35
39
|
},
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
|
|
4
|
+
const { wikiFetchMock } = vi.hoisted(() => ({ wikiFetchMock: vi.fn() }));
|
|
5
|
+
vi.mock('./utils.js', async () => {
|
|
6
|
+
const actual = await vi.importActual('./utils.js');
|
|
7
|
+
return { ...actual, wikiFetch: wikiFetchMock };
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
import './trending.js';
|
|
11
|
+
|
|
12
|
+
describe('wikipedia trending', () => {
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
wikiFetchMock.mockReset();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('emits empty-string for missing description instead of a sentinel', async () => {
|
|
18
|
+
const command = getRegistry().get('wikipedia/trending');
|
|
19
|
+
expect(command?.func).toBeDefined();
|
|
20
|
+
wikiFetchMock.mockResolvedValueOnce({
|
|
21
|
+
mostread: {
|
|
22
|
+
articles: [
|
|
23
|
+
{ title: 'Has_Both', description: 'A real description', views: 100 },
|
|
24
|
+
{ title: 'Has_Title_Only', views: 25 },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
const rows = await command.func({ limit: 5, lang: 'en' });
|
|
29
|
+
expect(rows).toHaveLength(2);
|
|
30
|
+
expect(rows[0]).toMatchObject({ title: 'Has_Both', description: 'A real description', views: 100 });
|
|
31
|
+
expect(rows[1].title).toBe('Has_Title_Only');
|
|
32
|
+
expect(rows[1].description).toBe('');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('fails typed when a trending article is missing title identity', async () => {
|
|
36
|
+
const command = getRegistry().get('wikipedia/trending');
|
|
37
|
+
wikiFetchMock.mockResolvedValueOnce({
|
|
38
|
+
mostread: { articles: [{ views: 50 }] },
|
|
39
|
+
});
|
|
40
|
+
await expect(command.func({ limit: 5, lang: 'en' })).rejects.toMatchObject({ code: 'PARSE_ERROR' });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('validates only rows selected by --limit', async () => {
|
|
44
|
+
const command = getRegistry().get('wikipedia/trending');
|
|
45
|
+
wikiFetchMock.mockResolvedValueOnce({
|
|
46
|
+
mostread: {
|
|
47
|
+
articles: [
|
|
48
|
+
{ title: 'Selected', views: 100 },
|
|
49
|
+
{ views: 50 },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
await expect(command.func({ limit: 1, lang: 'en' })).resolves.toEqual([
|
|
54
|
+
{ rank: 1, title: 'Selected', description: '', views: 100 },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
});
|