@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,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chess.com single-game detail by URL, via the internal callback
|
|
3
|
+
* endpoint `/callback/{live|daily}/game/{id}`. Returns the canonical
|
|
4
|
+
* PGN headers + move data plus per-player metadata.
|
|
5
|
+
*/
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
8
|
+
import { UA, formatDate, isPlainObject, parseGameUrl } from './utils.js';
|
|
9
|
+
|
|
10
|
+
const CALLBACK_BASE = 'https://www.chess.com/callback';
|
|
11
|
+
|
|
12
|
+
function stringOrEmpty(value) {
|
|
13
|
+
return typeof value === 'string' ? value : '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function scalarOrEmpty(value) {
|
|
17
|
+
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function summarizeGame({ kind, id, payload }) {
|
|
21
|
+
if (!isPlainObject(payload) || !isPlainObject(payload.game)) {
|
|
22
|
+
throw new CommandExecutionError('Chess.com callback returned no game payload');
|
|
23
|
+
}
|
|
24
|
+
const g = payload.game;
|
|
25
|
+
if (g.pgnHeaders !== undefined && !isPlainObject(g.pgnHeaders)) {
|
|
26
|
+
throw new CommandExecutionError('Chess.com callback returned malformed PGN headers');
|
|
27
|
+
}
|
|
28
|
+
if (payload.players !== undefined && !isPlainObject(payload.players)) {
|
|
29
|
+
throw new CommandExecutionError('Chess.com callback returned malformed player metadata');
|
|
30
|
+
}
|
|
31
|
+
const players = payload.players || {};
|
|
32
|
+
const byColor = {};
|
|
33
|
+
for (const slot of ['top', 'bottom']) {
|
|
34
|
+
const p = players[slot];
|
|
35
|
+
if (p !== undefined && !isPlainObject(p)) {
|
|
36
|
+
throw new CommandExecutionError('Chess.com callback returned malformed player metadata');
|
|
37
|
+
}
|
|
38
|
+
if (p?.color) byColor[p.color] = p;
|
|
39
|
+
}
|
|
40
|
+
const white = byColor.white || {};
|
|
41
|
+
const black = byColor.black || {};
|
|
42
|
+
const headers = g.pgnHeaders || {};
|
|
43
|
+
const whiteName = stringOrEmpty(white.username) || stringOrEmpty(headers.White);
|
|
44
|
+
const blackName = stringOrEmpty(black.username) || stringOrEmpty(headers.Black);
|
|
45
|
+
const result = stringOrEmpty(headers.Result);
|
|
46
|
+
if (!whiteName || !blackName || !result) {
|
|
47
|
+
throw new CommandExecutionError('Chess.com callback payload is missing stable game summary fields');
|
|
48
|
+
}
|
|
49
|
+
const headerDate = stringOrEmpty(headers.Date);
|
|
50
|
+
return {
|
|
51
|
+
kind,
|
|
52
|
+
game_id: id,
|
|
53
|
+
date: headerDate ? headerDate.replace(/\./g, '-') : formatDate(g.endTime),
|
|
54
|
+
white: whiteName,
|
|
55
|
+
white_rating: scalarOrEmpty(white.rating) || scalarOrEmpty(headers.WhiteElo),
|
|
56
|
+
black: blackName,
|
|
57
|
+
black_rating: scalarOrEmpty(black.rating) || scalarOrEmpty(headers.BlackElo),
|
|
58
|
+
result,
|
|
59
|
+
winner_color: stringOrEmpty(g.colorOfWinner),
|
|
60
|
+
termination: stringOrEmpty(headers.Termination) || stringOrEmpty(g.resultMessage),
|
|
61
|
+
eco: stringOrEmpty(headers.ECO),
|
|
62
|
+
time_control: stringOrEmpty(headers.TimeControl) || (typeof g.daysPerTurn === 'number' ? `${g.daysPerTurn}d/turn` : ''),
|
|
63
|
+
rated: g.isRated === true,
|
|
64
|
+
ply_count: g.plyCount ?? '',
|
|
65
|
+
url: `https://www.chess.com/game/${kind}/${id}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
cli({
|
|
70
|
+
site: 'chess',
|
|
71
|
+
name: 'game',
|
|
72
|
+
access: 'read',
|
|
73
|
+
description: 'Chess.com single-game detail (white, black, result, ECO, time control) by full game URL',
|
|
74
|
+
domain: 'www.chess.com',
|
|
75
|
+
strategy: Strategy.PUBLIC,
|
|
76
|
+
browser: false,
|
|
77
|
+
args: [
|
|
78
|
+
{ name: 'game-url', type: 'string', required: true, positional: true, help: 'Full game URL, e.g. https://www.chess.com/game/live/168842570216' },
|
|
79
|
+
],
|
|
80
|
+
columns: [
|
|
81
|
+
'kind', 'game_id', 'date',
|
|
82
|
+
'white', 'white_rating', 'black', 'black_rating',
|
|
83
|
+
'result', 'winner_color', 'termination',
|
|
84
|
+
'eco', 'time_control', 'rated', 'ply_count', 'url',
|
|
85
|
+
],
|
|
86
|
+
func: async (kwargs) => {
|
|
87
|
+
const { kind, id } = parseGameUrl(kwargs['game-url']);
|
|
88
|
+
const url = `${CALLBACK_BASE}/${kind}/game/${id}`;
|
|
89
|
+
let resp;
|
|
90
|
+
try {
|
|
91
|
+
resp = await fetch(url, { headers: { 'User-Agent': UA, accept: 'application/json' } });
|
|
92
|
+
} catch (error) {
|
|
93
|
+
throw new CommandExecutionError(`Failed to fetch Chess.com callback ${url}: ${error?.message || error}`);
|
|
94
|
+
}
|
|
95
|
+
if (!resp || typeof resp !== 'object') {
|
|
96
|
+
throw new CommandExecutionError(`Chess.com callback returned an invalid response object for ${url}`);
|
|
97
|
+
}
|
|
98
|
+
if (resp.status === 404) {
|
|
99
|
+
throw new EmptyResultError(`Chess.com has no ${kind} game with id ${id}`);
|
|
100
|
+
}
|
|
101
|
+
if (!resp.ok) {
|
|
102
|
+
throw new CommandExecutionError(`Chess.com callback returned HTTP ${resp.status} for ${url}`);
|
|
103
|
+
}
|
|
104
|
+
let payload;
|
|
105
|
+
try {
|
|
106
|
+
payload = await resp.json();
|
|
107
|
+
} catch (error) {
|
|
108
|
+
throw new CommandExecutionError(`Chess.com callback returned malformed JSON for ${url}: ${error?.message || error}`);
|
|
109
|
+
}
|
|
110
|
+
return [summarizeGame({ kind, id, payload })];
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
export const __test__ = { parseGameUrl, summarizeGame };
|
|
@@ -0,0 +1,178 @@
|
|
|
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 './game.js';
|
|
5
|
+
|
|
6
|
+
const { summarizeGame } = await import('./game.js').then((m) => m.__test__);
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function mockFetch(payload, status = 200) {
|
|
13
|
+
return vi.fn().mockResolvedValue({
|
|
14
|
+
ok: status === 200,
|
|
15
|
+
status,
|
|
16
|
+
json: () => Promise.resolve(payload),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('chess game command', () => {
|
|
21
|
+
it('summarizeGame maps the callback payload to the canonical row shape', () => {
|
|
22
|
+
const row = summarizeGame({
|
|
23
|
+
kind: 'live',
|
|
24
|
+
id: '999',
|
|
25
|
+
payload: {
|
|
26
|
+
game: {
|
|
27
|
+
pgnHeaders: {
|
|
28
|
+
Date: '2026.05.17',
|
|
29
|
+
White: 'Hikaru',
|
|
30
|
+
Black: 'tactic',
|
|
31
|
+
Result: '1-0',
|
|
32
|
+
ECO: 'A01',
|
|
33
|
+
WhiteElo: 3454,
|
|
34
|
+
BlackElo: 2869,
|
|
35
|
+
TimeControl: '180',
|
|
36
|
+
Termination: 'Hikaru won by resignation',
|
|
37
|
+
},
|
|
38
|
+
colorOfWinner: 'white',
|
|
39
|
+
isRated: true,
|
|
40
|
+
plyCount: 111,
|
|
41
|
+
endTime: 1747584400,
|
|
42
|
+
},
|
|
43
|
+
players: {
|
|
44
|
+
top: { username: 'tactic', color: 'black', rating: 2869 },
|
|
45
|
+
bottom: { username: 'Hikaru', color: 'white', rating: 3454 },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
expect(row).toMatchObject({
|
|
50
|
+
kind: 'live',
|
|
51
|
+
game_id: '999',
|
|
52
|
+
date: '2026-05-17',
|
|
53
|
+
white: 'Hikaru',
|
|
54
|
+
white_rating: 3454,
|
|
55
|
+
black: 'tactic',
|
|
56
|
+
black_rating: 2869,
|
|
57
|
+
result: '1-0',
|
|
58
|
+
winner_color: 'white',
|
|
59
|
+
termination: 'Hikaru won by resignation',
|
|
60
|
+
eco: 'A01',
|
|
61
|
+
time_control: '180',
|
|
62
|
+
rated: true,
|
|
63
|
+
ply_count: 111,
|
|
64
|
+
url: 'https://www.chess.com/game/live/999',
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('summarizeGame falls back to pgnHeaders when players are missing', () => {
|
|
69
|
+
const row = summarizeGame({
|
|
70
|
+
kind: 'daily',
|
|
71
|
+
id: '1',
|
|
72
|
+
payload: {
|
|
73
|
+
game: {
|
|
74
|
+
pgnHeaders: { White: 'A', Black: 'B', Result: '1/2-1/2', WhiteElo: 1200, BlackElo: 1300 },
|
|
75
|
+
colorOfWinner: '',
|
|
76
|
+
isRated: false,
|
|
77
|
+
daysPerTurn: 3,
|
|
78
|
+
},
|
|
79
|
+
players: {},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
expect(row.white).toBe('A');
|
|
83
|
+
expect(row.black_rating).toBe(1300);
|
|
84
|
+
expect(row.time_control).toBe('3d/turn');
|
|
85
|
+
expect(row.rated).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('summarizeGame throws CommandExecutionError on missing game payload', () => {
|
|
89
|
+
expect(() => summarizeGame({ kind: 'live', id: '1', payload: {} })).toThrow(CommandExecutionError);
|
|
90
|
+
expect(() => summarizeGame({ kind: 'live', id: '1', payload: null })).toThrow(CommandExecutionError);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('summarizeGame throws CommandExecutionError on malformed nested payloads', () => {
|
|
94
|
+
expect(() => summarizeGame({
|
|
95
|
+
kind: 'live',
|
|
96
|
+
id: '1',
|
|
97
|
+
payload: { game: { pgnHeaders: [] } },
|
|
98
|
+
})).toThrow(CommandExecutionError);
|
|
99
|
+
expect(() => summarizeGame({
|
|
100
|
+
kind: 'live',
|
|
101
|
+
id: '1',
|
|
102
|
+
payload: { game: { pgnHeaders: { White: 'A', Black: 'B' } }, players: [] },
|
|
103
|
+
})).toThrow(CommandExecutionError);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('summarizeGame requires stable players and result evidence', () => {
|
|
107
|
+
expect(() => summarizeGame({
|
|
108
|
+
kind: 'live',
|
|
109
|
+
id: '1',
|
|
110
|
+
payload: { game: { pgnHeaders: { White: 'A', Black: 'B' } }, players: {} },
|
|
111
|
+
})).toThrow(CommandExecutionError);
|
|
112
|
+
expect(() => summarizeGame({
|
|
113
|
+
kind: 'live',
|
|
114
|
+
id: '1',
|
|
115
|
+
payload: { game: { pgnHeaders: { White: 'A', Result: '1-0' } }, players: {} },
|
|
116
|
+
})).toThrow(CommandExecutionError);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('command fetches the callback URL and returns a single row', async () => {
|
|
120
|
+
const fetchMock = mockFetch({
|
|
121
|
+
game: { pgnHeaders: { White: 'A', Black: 'B', Result: '1-0', WhiteElo: 100, BlackElo: 90 } },
|
|
122
|
+
players: {},
|
|
123
|
+
});
|
|
124
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
125
|
+
const cmd = getRegistry().get('chess/game');
|
|
126
|
+
const rows = await cmd.func({ 'game-url': 'https://www.chess.com/game/live/42' });
|
|
127
|
+
expect(rows).toHaveLength(1);
|
|
128
|
+
expect(rows[0].url).toBe('https://www.chess.com/game/live/42');
|
|
129
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
130
|
+
'https://www.chess.com/callback/live/game/42',
|
|
131
|
+
expect.objectContaining({ headers: expect.any(Object) }),
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('command surfaces 404 as EmptyResultError', async () => {
|
|
136
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
|
137
|
+
const cmd = getRegistry().get('chess/game');
|
|
138
|
+
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
|
|
139
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('command surfaces non-2xx as CommandExecutionError', async () => {
|
|
143
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
|
144
|
+
const cmd = getRegistry().get('chess/game');
|
|
145
|
+
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
|
|
146
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('command maps fetch and JSON failures to CommandExecutionError', async () => {
|
|
150
|
+
const cmd = getRegistry().get('chess/game');
|
|
151
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('network down')));
|
|
152
|
+
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
|
|
153
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
154
|
+
|
|
155
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
156
|
+
ok: true,
|
|
157
|
+
status: 200,
|
|
158
|
+
json: () => Promise.reject(new SyntaxError('bad json')),
|
|
159
|
+
}));
|
|
160
|
+
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
|
|
161
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('command maps wrong-shape callback JSON to CommandExecutionError', async () => {
|
|
165
|
+
vi.stubGlobal('fetch', mockFetch([]));
|
|
166
|
+
const cmd = getRegistry().get('chess/game');
|
|
167
|
+
await expect(cmd.func({ 'game-url': 'https://www.chess.com/game/live/1' }))
|
|
168
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('rejects invalid URL with ArgumentError before any fetch', async () => {
|
|
172
|
+
const fetchMock = vi.fn();
|
|
173
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
174
|
+
const cmd = getRegistry().get('chess/game');
|
|
175
|
+
await expect(cmd.func({ 'game-url': 'not-a-url' })).rejects.toBeInstanceOf(ArgumentError);
|
|
176
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chess.com recent games from monthly archives. Walks the archive
|
|
3
|
+
* list newest-first and fetches as few months as needed to fill --limit.
|
|
4
|
+
*/
|
|
5
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { chessApi, validateUsername, mapGameRow } from './utils.js';
|
|
8
|
+
|
|
9
|
+
const MAX_LIMIT = 100;
|
|
10
|
+
const MAX_ARCHIVE_FETCHES = 6;
|
|
11
|
+
|
|
12
|
+
function parseLimit(value) {
|
|
13
|
+
if (value === undefined || value === null || value === '') return 10;
|
|
14
|
+
const limit = Number(value);
|
|
15
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
|
|
16
|
+
throw new ArgumentError(`--limit must be an integer between 1 and ${MAX_LIMIT}`);
|
|
17
|
+
}
|
|
18
|
+
return limit;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
cli({
|
|
22
|
+
site: 'chess',
|
|
23
|
+
name: 'games',
|
|
24
|
+
access: 'read',
|
|
25
|
+
description: 'Chess.com recent games for a player, newest first',
|
|
26
|
+
domain: 'api.chess.com',
|
|
27
|
+
strategy: Strategy.PUBLIC,
|
|
28
|
+
browser: false,
|
|
29
|
+
args: [
|
|
30
|
+
{ name: 'username', type: 'string', required: true, positional: true, help: 'Chess.com username' },
|
|
31
|
+
{ name: 'limit', type: 'int', default: 10, help: `Number of recent games (1-${MAX_LIMIT})` },
|
|
32
|
+
],
|
|
33
|
+
columns: ['date', 'time_class', 'rated', 'my_color', 'my_rating', 'my_result', 'opponent', 'opponent_rating', 'accuracy_white', 'accuracy_black', 'eco', 'opening_name', 'url'],
|
|
34
|
+
func: async (kwargs) => {
|
|
35
|
+
const username = validateUsername(kwargs.username);
|
|
36
|
+
const limit = parseLimit(kwargs.limit);
|
|
37
|
+
const archivesList = await chessApi(`/player/${encodeURIComponent(username)}/games/archives`);
|
|
38
|
+
if (!Array.isArray(archivesList.archives)) {
|
|
39
|
+
throw new CommandExecutionError('Chess.com archives payload is missing archives array');
|
|
40
|
+
}
|
|
41
|
+
const archives = archivesList.archives.slice().reverse();
|
|
42
|
+
if (archives.length === 0) {
|
|
43
|
+
throw new EmptyResultError(`Chess.com has no game archives for ${username}`);
|
|
44
|
+
}
|
|
45
|
+
const rows = [];
|
|
46
|
+
for (let i = 0; i < archives.length && i < MAX_ARCHIVE_FETCHES && rows.length < limit; i++) {
|
|
47
|
+
if (typeof archives[i] !== 'string' || !archives[i].startsWith('https://api.chess.com/pub/player/')) {
|
|
48
|
+
throw new CommandExecutionError('Chess.com archives payload contains an unexpected archive URL');
|
|
49
|
+
}
|
|
50
|
+
const monthly = await chessApi(archives[i]);
|
|
51
|
+
if (!Array.isArray(monthly.games)) {
|
|
52
|
+
throw new CommandExecutionError('Chess.com monthly archive payload is missing games array');
|
|
53
|
+
}
|
|
54
|
+
const games = monthly.games.slice().reverse();
|
|
55
|
+
for (const g of games) {
|
|
56
|
+
rows.push(mapGameRow(g, username));
|
|
57
|
+
if (rows.length >= limit) break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (rows.length === 0) {
|
|
61
|
+
throw new EmptyResultError(`Chess.com has games archives for ${username} but no games in the most recent ${MAX_ARCHIVE_FETCHES} months`);
|
|
62
|
+
}
|
|
63
|
+
return rows.slice(0, limit);
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const __test__ = { parseLimit };
|
|
@@ -0,0 +1,164 @@
|
|
|
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 './games.js';
|
|
5
|
+
|
|
6
|
+
const { parseLimit } = await import('./games.js').then((m) => m.__test__);
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function fetchFor(map) {
|
|
13
|
+
return vi.fn().mockImplementation((url) => {
|
|
14
|
+
if (map.has(url)) {
|
|
15
|
+
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(map.get(url)) });
|
|
16
|
+
}
|
|
17
|
+
return Promise.resolve({ ok: false, status: 404 });
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function game(white, whiteRating, black, blackRating, endTime, extra = {}) {
|
|
22
|
+
return {
|
|
23
|
+
url: `https://www.chess.com/game/live/${endTime}`,
|
|
24
|
+
end_time: endTime,
|
|
25
|
+
time_class: 'blitz',
|
|
26
|
+
rated: true,
|
|
27
|
+
eco: 'C50',
|
|
28
|
+
white: { username: white, rating: whiteRating, result: 'win' },
|
|
29
|
+
black: { username: black, rating: blackRating, result: 'resigned' },
|
|
30
|
+
...extra,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('chess games command', () => {
|
|
35
|
+
it('parseLimit accepts 1-100, rejects everything else', () => {
|
|
36
|
+
expect(parseLimit(undefined)).toBe(10);
|
|
37
|
+
expect(parseLimit(1)).toBe(1);
|
|
38
|
+
expect(parseLimit(100)).toBe(100);
|
|
39
|
+
expect(() => parseLimit(0)).toThrow(ArgumentError);
|
|
40
|
+
expect(() => parseLimit(101)).toThrow(ArgumentError);
|
|
41
|
+
expect(() => parseLimit(1.5)).toThrow(ArgumentError);
|
|
42
|
+
expect(() => parseLimit('abc')).toThrow(ArgumentError);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns recent games newest-first sliced to --limit', async () => {
|
|
46
|
+
const map = new Map([
|
|
47
|
+
['https://api.chess.com/pub/player/hikaru/games/archives', {
|
|
48
|
+
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/04', 'https://api.chess.com/pub/player/hikaru/games/2026/05'],
|
|
49
|
+
}],
|
|
50
|
+
['https://api.chess.com/pub/player/hikaru/games/2026/05', {
|
|
51
|
+
games: [
|
|
52
|
+
game('Hikaru', 3286, 'A', 2900, 1777737000),
|
|
53
|
+
game('Hikaru', 3286, 'B', 2950, 1777737500),
|
|
54
|
+
game('Hikaru', 3286, 'C', 3000, 1777737900),
|
|
55
|
+
],
|
|
56
|
+
}],
|
|
57
|
+
]);
|
|
58
|
+
vi.stubGlobal('fetch', fetchFor(map));
|
|
59
|
+
const cmd = getRegistry().get('chess/games');
|
|
60
|
+
const rows = await cmd.func({ username: 'Hikaru', limit: 2 });
|
|
61
|
+
expect(rows).toHaveLength(2);
|
|
62
|
+
// archive is reversed (newest month first), games within are reversed
|
|
63
|
+
// so the first row corresponds to the LAST game in the JSON array.
|
|
64
|
+
expect(rows[0].opponent).toBe('C');
|
|
65
|
+
expect(rows[1].opponent).toBe('B');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('walks multiple months until --limit is filled', async () => {
|
|
69
|
+
const map = new Map([
|
|
70
|
+
['https://api.chess.com/pub/player/hikaru/games/archives', {
|
|
71
|
+
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/03', 'https://api.chess.com/pub/player/hikaru/games/2026/04'],
|
|
72
|
+
}],
|
|
73
|
+
['https://api.chess.com/pub/player/hikaru/games/2026/04', {
|
|
74
|
+
games: [game('Hikaru', 3286, 'A', 2900, 1777737000)],
|
|
75
|
+
}],
|
|
76
|
+
['https://api.chess.com/pub/player/hikaru/games/2026/03', {
|
|
77
|
+
games: [game('Hikaru', 3286, 'B', 2950, 1774000000)],
|
|
78
|
+
}],
|
|
79
|
+
]);
|
|
80
|
+
vi.stubGlobal('fetch', fetchFor(map));
|
|
81
|
+
const cmd = getRegistry().get('chess/games');
|
|
82
|
+
const rows = await cmd.func({ username: 'Hikaru', limit: 2 });
|
|
83
|
+
expect(rows.map((r) => r.opponent)).toEqual(['A', 'B']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('throws EmptyResultError when archives list is empty', async () => {
|
|
87
|
+
const map = new Map([
|
|
88
|
+
['https://api.chess.com/pub/player/someuser/games/archives', { archives: [] }],
|
|
89
|
+
]);
|
|
90
|
+
vi.stubGlobal('fetch', fetchFor(map));
|
|
91
|
+
const cmd = getRegistry().get('chess/games');
|
|
92
|
+
await expect(cmd.func({ username: 'someuser', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('throws CommandExecutionError when archives payload is wrong-shape', async () => {
|
|
96
|
+
const map = new Map([
|
|
97
|
+
['https://api.chess.com/pub/player/someuser/games/archives', { archives: {} }],
|
|
98
|
+
]);
|
|
99
|
+
vi.stubGlobal('fetch', fetchFor(map));
|
|
100
|
+
const cmd = getRegistry().get('chess/games');
|
|
101
|
+
await expect(cmd.func({ username: 'someuser', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('throws CommandExecutionError when monthly archive games payload is wrong-shape', async () => {
|
|
105
|
+
const map = new Map([
|
|
106
|
+
['https://api.chess.com/pub/player/hikaru/games/archives', {
|
|
107
|
+
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'],
|
|
108
|
+
}],
|
|
109
|
+
['https://api.chess.com/pub/player/hikaru/games/2026/05', { games: null }],
|
|
110
|
+
]);
|
|
111
|
+
vi.stubGlobal('fetch', fetchFor(map));
|
|
112
|
+
const cmd = getRegistry().get('chess/games');
|
|
113
|
+
await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('throws CommandExecutionError when a game row lacks stable identity', async () => {
|
|
117
|
+
const map = new Map([
|
|
118
|
+
['https://api.chess.com/pub/player/hikaru/games/archives', {
|
|
119
|
+
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'],
|
|
120
|
+
}],
|
|
121
|
+
['https://api.chess.com/pub/player/hikaru/games/2026/05', {
|
|
122
|
+
games: [{
|
|
123
|
+
end_time: 1777737000,
|
|
124
|
+
white: { username: 'Hikaru', rating: 3286, result: 'win' },
|
|
125
|
+
black: { username: 'A', rating: 2900, result: 'resigned' },
|
|
126
|
+
}],
|
|
127
|
+
}],
|
|
128
|
+
]);
|
|
129
|
+
vi.stubGlobal('fetch', fetchFor(map));
|
|
130
|
+
const cmd = getRegistry().get('chess/games');
|
|
131
|
+
await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('throws CommandExecutionError when a game row does not include the requested player', async () => {
|
|
135
|
+
const map = new Map([
|
|
136
|
+
['https://api.chess.com/pub/player/hikaru/games/archives', {
|
|
137
|
+
archives: ['https://api.chess.com/pub/player/hikaru/games/2026/05'],
|
|
138
|
+
}],
|
|
139
|
+
['https://api.chess.com/pub/player/hikaru/games/2026/05', {
|
|
140
|
+
games: [game('A', 2900, 'B', 2800, 1777737000)],
|
|
141
|
+
}],
|
|
142
|
+
]);
|
|
143
|
+
vi.stubGlobal('fetch', fetchFor(map));
|
|
144
|
+
const cmd = getRegistry().get('chess/games');
|
|
145
|
+
await expect(cmd.func({ username: 'Hikaru', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('throws ArgumentError on invalid username before any fetch', async () => {
|
|
149
|
+
const fetchMock = vi.fn();
|
|
150
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
151
|
+
const cmd = getRegistry().get('chess/games');
|
|
152
|
+
await expect(cmd.func({ username: 'a b', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
|
|
153
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('registers with the expected columns', () => {
|
|
157
|
+
const cmd = getRegistry().get('chess/games');
|
|
158
|
+
expect(cmd?.columns).toEqual([
|
|
159
|
+
'date', 'time_class', 'rated', 'my_color', 'my_rating', 'my_result',
|
|
160
|
+
'opponent', 'opponent_rating', 'accuracy_white', 'accuracy_black',
|
|
161
|
+
'eco', 'opening_name', 'url',
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chess.com player stats across game kinds (rapid / blitz / bullet /
|
|
3
|
+
* daily / chess960 / etc) via the public stats endpoint.
|
|
4
|
+
*/
|
|
5
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
6
|
+
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { chessApi, validateUsername, summarizeStats } from './utils.js';
|
|
8
|
+
|
|
9
|
+
const KINDS = ['chess_rapid', 'chess_blitz', 'chess_bullet', 'chess_daily', 'chess960_daily', 'chess_daily_960'];
|
|
10
|
+
|
|
11
|
+
cli({
|
|
12
|
+
site: 'chess',
|
|
13
|
+
name: 'stats',
|
|
14
|
+
access: 'read',
|
|
15
|
+
description: 'Chess.com player ratings + win/loss record across game kinds',
|
|
16
|
+
domain: 'api.chess.com',
|
|
17
|
+
strategy: Strategy.PUBLIC,
|
|
18
|
+
browser: false,
|
|
19
|
+
args: [
|
|
20
|
+
{ name: 'username', type: 'string', required: true, positional: true, help: 'Chess.com username (case-insensitive)' },
|
|
21
|
+
],
|
|
22
|
+
columns: ['kind', 'rating_current', 'rating_best', 'wins', 'losses', 'draws'],
|
|
23
|
+
func: async (kwargs) => {
|
|
24
|
+
const username = validateUsername(kwargs.username);
|
|
25
|
+
const stats = await chessApi(`/player/${encodeURIComponent(username)}/stats`);
|
|
26
|
+
const rows = KINDS.map((k) => summarizeStats(stats, k)).filter(Boolean);
|
|
27
|
+
if (rows.length === 0) {
|
|
28
|
+
throw new EmptyResultError(`Chess.com returned no stats for ${username}`);
|
|
29
|
+
}
|
|
30
|
+
return rows;
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
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 './stats.js';
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.unstubAllGlobals();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function mockFetch(body) {
|
|
11
|
+
return vi.fn().mockResolvedValue({
|
|
12
|
+
ok: true,
|
|
13
|
+
status: 200,
|
|
14
|
+
json: () => Promise.resolve(body),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('chess stats command', () => {
|
|
19
|
+
it('rejects empty username via validateUsername', async () => {
|
|
20
|
+
const cmd = getRegistry().get('chess/stats');
|
|
21
|
+
await expect(cmd.func({ username: '' })).rejects.toBeInstanceOf(ArgumentError);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects invalid username characters', async () => {
|
|
25
|
+
const cmd = getRegistry().get('chess/stats');
|
|
26
|
+
await expect(cmd.func({ username: 'user name' })).rejects.toBeInstanceOf(ArgumentError);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns one row per known game kind populated in the stats response', async () => {
|
|
30
|
+
const fetchMock = mockFetch({
|
|
31
|
+
chess_rapid: { last: { rating: 1700 }, best: { rating: 1800 }, record: { win: 50, loss: 20, draw: 5 } },
|
|
32
|
+
chess_blitz: { last: { rating: 1500 }, best: { rating: 1600 }, record: { win: 100, loss: 80, draw: 10 } },
|
|
33
|
+
});
|
|
34
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
35
|
+
const cmd = getRegistry().get('chess/stats');
|
|
36
|
+
const rows = await cmd.func({ username: 'someuser' });
|
|
37
|
+
expect(rows).toHaveLength(2);
|
|
38
|
+
expect(rows[0].kind).toBe('rapid');
|
|
39
|
+
expect(rows[1].kind).toBe('blitz');
|
|
40
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
41
|
+
'https://api.chess.com/pub/player/someuser/stats',
|
|
42
|
+
expect.objectContaining({ headers: expect.any(Object) }),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('lowercases username in the URL', async () => {
|
|
47
|
+
const fetchMock = mockFetch({ chess_rapid: { last: { rating: 1 }, best: {}, record: {} } });
|
|
48
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
49
|
+
const cmd = getRegistry().get('chess/stats');
|
|
50
|
+
await cmd.func({ username: 'MixedCase' });
|
|
51
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
52
|
+
'https://api.chess.com/pub/player/mixedcase/stats',
|
|
53
|
+
expect.any(Object),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('throws EmptyResultError when the stats response has no known kinds', async () => {
|
|
58
|
+
vi.stubGlobal('fetch', mockFetch({}));
|
|
59
|
+
const cmd = getRegistry().get('chess/stats');
|
|
60
|
+
await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('throws CommandExecutionError when a populated stats kind is malformed', async () => {
|
|
64
|
+
vi.stubGlobal('fetch', mockFetch({ chess_rapid: 'bad' }));
|
|
65
|
+
const cmd = getRegistry().get('chess/stats');
|
|
66
|
+
await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('throws EmptyResultError on HTTP 404', async () => {
|
|
70
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
|
71
|
+
const cmd = getRegistry().get('chess/stats');
|
|
72
|
+
await expect(cmd.func({ username: 'someuser' })).rejects.toBeInstanceOf(EmptyResultError);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('registers with the expected columns', () => {
|
|
76
|
+
const cmd = getRegistry().get('chess/stats');
|
|
77
|
+
expect(cmd?.columns).toEqual(['kind', 'rating_current', 'rating_best', 'wins', 'losses', 'draws']);
|
|
78
|
+
});
|
|
79
|
+
});
|