@jackwener/opencli 1.8.0 → 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 +8 -49
- package/README.zh-CN.md +8 -52
- package/cli-manifest.json +1796 -191
- package/clis/_atlassian/shared.js +577 -0
- package/clis/_atlassian/shared.test.js +170 -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/utils.js +63 -5
- package/clis/bilibili/utils.test.js +45 -1
- 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/hashtag.js +84 -23
- package/clis/douyin/hashtag.test.js +113 -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/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/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/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/services-read.js +213 -0
- package/clis/linkedin/services-read.test.js +105 -0
- package/clis/linkedin/shared.js +124 -0
- package/clis/linkedin/timeline.js +14 -7
- 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/suno/generate.js +5 -0
- package/clis/suno/generate.test.js +9 -0
- package/clis/suno/status.js +3 -2
- package/clis/suno/utils.js +33 -24
- package/clis/suno/utils.test.js +106 -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-remove.js +1 -1
- 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 +1 -1
- package/clis/twitter/search.test.js +35 -0
- package/clis/twitter/shared.js +11 -0
- package/clis/twitter/shared.test.js +37 -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/weread/book-search.js +438 -0
- package/clis/weread/book-search.test.js +242 -0
- package/clis/weread/search-regression.test.js +80 -0
- package/clis/weread/search.js +17 -2
- package/clis/xiaohongshu/creator-note-detail.js +165 -28
- package/clis/xiaohongshu/creator-note-detail.test.js +186 -37
- package/clis/xiaohongshu/creator-notes.js +251 -2
- package/clis/xiaohongshu/creator-notes.test.js +79 -2
- package/clis/xiaohongshu/download.js +97 -39
- package/clis/xiaohongshu/download.test.js +201 -0
- package/clis/zhihu/answer-comments.js +2 -21
- package/clis/zhihu/answer-detail.js +2 -31
- package/clis/zhihu/collection.js +2 -14
- package/clis/zhihu/collection.test.js +4 -3
- package/clis/zhihu/question.js +1 -9
- package/clis/zhihu/question.test.js +2 -2
- package/clis/zhihu/search.js +1 -12
- package/clis/zhihu/search.test.js +2 -2
- package/clis/zhihu/text.js +29 -0
- package/clis/zhihu/text.test.js +24 -0
- package/dist/src/browser/network-cache.js +13 -1
- package/dist/src/browser/network-cache.test.js +17 -0
- package/dist/src/download/index.js +13 -1
- package/dist/src/download/index.test.js +23 -1
- package/dist/src/download/media-download.test.js +3 -1
- package/dist/src/download/progress.js +2 -2
- package/dist/src/download/progress.test.js +12 -1
- 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/package.json +1 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a Chess.com game in the browser's analysis view. Thin wrapper:
|
|
3
|
+
* navigates the bound session to the `/analysis` form of the game URL
|
|
4
|
+
* and reports the resolved page URL.
|
|
5
|
+
*/
|
|
6
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
8
|
+
import { parseGameUrl } from './utils.js';
|
|
9
|
+
|
|
10
|
+
cli({
|
|
11
|
+
site: 'chess',
|
|
12
|
+
name: 'analyze',
|
|
13
|
+
access: 'read',
|
|
14
|
+
description: 'Open a Chess.com game in the browser analysis board',
|
|
15
|
+
domain: 'www.chess.com',
|
|
16
|
+
strategy: Strategy.UI,
|
|
17
|
+
browser: true,
|
|
18
|
+
navigateBefore: false,
|
|
19
|
+
args: [
|
|
20
|
+
{ name: 'game-url', type: 'string', required: true, positional: true, help: 'Full game URL, e.g. https://www.chess.com/game/live/168842570216' },
|
|
21
|
+
],
|
|
22
|
+
columns: ['kind', 'game_id', 'analysis_url'],
|
|
23
|
+
func: async (page, kwargs) => {
|
|
24
|
+
if (!page) throw new CommandExecutionError('Browser session required for chess analyze');
|
|
25
|
+
const { kind, id } = parseGameUrl(kwargs['game-url']);
|
|
26
|
+
const analysisUrl = `https://www.chess.com/analysis/game/${kind}/${id}`;
|
|
27
|
+
try {
|
|
28
|
+
await page.goto(analysisUrl);
|
|
29
|
+
await page.wait(2);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new CommandExecutionError(`Failed to open Chess.com analysis board: ${error?.message || error}`);
|
|
32
|
+
}
|
|
33
|
+
return [{ kind, game_id: id, analysis_url: analysisUrl }];
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import './analyze.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const manifestPath = resolve(__dirname, '../../cli-manifest.json');
|
|
11
|
+
|
|
12
|
+
function loadManifestCommand(name) {
|
|
13
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
14
|
+
return manifest.find(cmd => cmd.site === 'chess' && cmd.name === name);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makePage() {
|
|
18
|
+
return {
|
|
19
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('chess analyze command', () => {
|
|
25
|
+
it('navigates to /analysis/game/<kind>/<id> and reports the URL', async () => {
|
|
26
|
+
const cmd = getRegistry().get('chess/analyze');
|
|
27
|
+
const page = makePage();
|
|
28
|
+
const rows = await cmd.func(page, { 'game-url': 'https://www.chess.com/game/live/42' });
|
|
29
|
+
expect(rows).toEqual([{ kind: 'live', game_id: '42', analysis_url: 'https://www.chess.com/analysis/game/live/42' }]);
|
|
30
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.chess.com/analysis/game/live/42');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('preserves daily kind in the analysis URL', async () => {
|
|
34
|
+
const cmd = getRegistry().get('chess/analyze');
|
|
35
|
+
const page = makePage();
|
|
36
|
+
const rows = await cmd.func(page, { 'game-url': 'https://www.chess.com/game/daily/123' });
|
|
37
|
+
expect(rows[0].analysis_url).toBe('https://www.chess.com/analysis/game/daily/123');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects invalid URL with ArgumentError before navigation', async () => {
|
|
41
|
+
const cmd = getRegistry().get('chess/analyze');
|
|
42
|
+
const page = makePage();
|
|
43
|
+
await expect(cmd.func(page, { 'game-url': 'not-a-url' })).rejects.toBeInstanceOf(ArgumentError);
|
|
44
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('throws CommandExecutionError without a browser page', async () => {
|
|
48
|
+
const cmd = getRegistry().get('chess/analyze');
|
|
49
|
+
await expect(cmd.func(null, { 'game-url': 'https://www.chess.com/game/live/42' }))
|
|
50
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('throws CommandExecutionError when browser navigation fails', async () => {
|
|
54
|
+
const cmd = getRegistry().get('chess/analyze');
|
|
55
|
+
const page = makePage();
|
|
56
|
+
page.goto.mockRejectedValue(new Error('navigation failed'));
|
|
57
|
+
await expect(cmd.func(page, { 'game-url': 'https://www.chess.com/game/live/42' }))
|
|
58
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('registers with the expected columns + browser flag', () => {
|
|
62
|
+
const cmd = getRegistry().get('chess/analyze');
|
|
63
|
+
expect(cmd?.columns).toEqual(['kind', 'game_id', 'analysis_url']);
|
|
64
|
+
expect(cmd?.browser).toBe(true);
|
|
65
|
+
expect(cmd?.navigateBefore).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('build manifest keeps analyze pre-navigation disabled and game source attribution stable', () => {
|
|
69
|
+
expect(loadManifestCommand('analyze')).toMatchObject({
|
|
70
|
+
navigateBefore: false,
|
|
71
|
+
modulePath: 'chess/analyze.js',
|
|
72
|
+
sourceFile: 'chess/analyze.js',
|
|
73
|
+
});
|
|
74
|
+
expect(loadManifestCommand('game')).toMatchObject({
|
|
75
|
+
modulePath: 'chess/game.js',
|
|
76
|
+
sourceFile: 'chess/game.js',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -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
|
+
});
|