11elo 0.1.0
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 +222 -0
- package/package.json +37 -0
- package/src/client.test.js +214 -0
- package/src/index.js +359 -0
package/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# 11elo
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/11elo)
|
|
4
|
+
[](https://www.npmjs.com/package/11elo)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
JavaScript/Node.js client for the **[11elo](https://11elo.com) Soccer ELO API** — live and historical Elo ratings for German football (Bundesliga, 2. Bundesliga, 3. Liga).
|
|
8
|
+
|
|
9
|
+
Works in **Node.js 18+**, modern browsers, Deno and Bun — uses the native `fetch` API with no extra dependencies.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install 11elo
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { Client } from '11elo';
|
|
21
|
+
|
|
22
|
+
const client = new Client({ apiKey: '11e_fre_your_key_here' });
|
|
23
|
+
|
|
24
|
+
// List all teams
|
|
25
|
+
const teams = await client.getTeams();
|
|
26
|
+
teams.forEach(t => console.log(t.teamName, t.currentElo));
|
|
27
|
+
|
|
28
|
+
// Get detailed info for one team
|
|
29
|
+
const { team, eloHistory } = await client.getTeam('Bayern München');
|
|
30
|
+
console.log(team.currentElo);
|
|
31
|
+
|
|
32
|
+
// Upcoming matches
|
|
33
|
+
const upcoming = await client.getUpcomingMatches({ league: 'BL1', limit: 10 });
|
|
34
|
+
upcoming.forEach(m => console.log(m.homeTeam, 'vs', m.awayTeam));
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Getting an API key
|
|
38
|
+
|
|
39
|
+
Register for free at <https://11elo.com/developer>.
|
|
40
|
+
Keys follow the format `11e_<tier>_<hex>` and are sent via the `X-API-Key` request header (handled automatically by this client).
|
|
41
|
+
|
|
42
|
+
**Rate limits by tier:**
|
|
43
|
+
|
|
44
|
+
| Tier | Requests / day |
|
|
45
|
+
|-------|---------------|
|
|
46
|
+
| Free | 100 |
|
|
47
|
+
| Basic | 1,000 |
|
|
48
|
+
| Pro | 10,000 |
|
|
49
|
+
|
|
50
|
+
## API reference
|
|
51
|
+
|
|
52
|
+
### `new Client(options)`
|
|
53
|
+
|
|
54
|
+
| Option | Default | Description |
|
|
55
|
+
|-------------|-----------------------|------------------------------------------------|
|
|
56
|
+
| `apiKey` | — | Your 11elo API key (**required**) |
|
|
57
|
+
| `baseUrl` | `https://11elo.com` | Override for self-hosted / local dev |
|
|
58
|
+
| `timeoutMs` | `30000` | Request timeout in milliseconds |
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
### Teams
|
|
63
|
+
|
|
64
|
+
#### `client.getTeams() → Promise<object[]>`
|
|
65
|
+
|
|
66
|
+
Returns all teams with current ELO stats, league info and trend data.
|
|
67
|
+
|
|
68
|
+
```js
|
|
69
|
+
const teams = await client.getTeams();
|
|
70
|
+
// [{ teamName: 'Bayern München', currentElo: 1847, league: 'BL1', ... }, ...]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
#### `client.getTeam(teamName) → Promise<object>`
|
|
74
|
+
|
|
75
|
+
Full detail for one team — ELO history, recent form, upcoming matches, career stats.
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
const { team, eloHistory, recentForm, upcomingMatches } =
|
|
79
|
+
await client.getTeam('Borussia Dortmund');
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
#### `client.getHeadToHead(team1, team2) → Promise<object[]>`
|
|
83
|
+
|
|
84
|
+
Historical head-to-head match results between two teams.
|
|
85
|
+
|
|
86
|
+
```js
|
|
87
|
+
const h2h = await client.getHeadToHead('Bayern München', 'Borussia Dortmund');
|
|
88
|
+
// [{ date: '2026-02-15', result: '2:1', winner: 'Bayern München', ... }, ...]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
### Matches
|
|
94
|
+
|
|
95
|
+
#### `client.getMatches(options?) → Promise<object[]>`
|
|
96
|
+
|
|
97
|
+
Paginated match history. All options are optional.
|
|
98
|
+
|
|
99
|
+
| Option | Description |
|
|
100
|
+
|----------|-----------------------------------------|
|
|
101
|
+
| `season` | Filter by season, e.g. `"2024/2025"` |
|
|
102
|
+
| `from` | ISO-8601 start date (`"YYYY-MM-DD"`) |
|
|
103
|
+
| `to` | ISO-8601 end date (`"YYYY-MM-DD"`) |
|
|
104
|
+
| `limit` | Max results (default 100, max 500) |
|
|
105
|
+
| `offset` | Pagination offset (default 0) |
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const matches = await client.getMatches({ season: '2024/2025', limit: 50 });
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### `client.getUpcomingMatches(options?) → Promise<object[]>`
|
|
112
|
+
|
|
113
|
+
Upcoming fixtures with ELO-difference predictions.
|
|
114
|
+
|
|
115
|
+
| Option | Description |
|
|
116
|
+
|----------|------------------------------------------|
|
|
117
|
+
| `league` | League code filter, e.g. `"BL1"` |
|
|
118
|
+
| `sort` | Sort order (default `"date"`) |
|
|
119
|
+
| `limit` | Max results (default 50, max 200) |
|
|
120
|
+
|
|
121
|
+
```js
|
|
122
|
+
const upcoming = await client.getUpcomingMatches({ league: 'BL1' });
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
#### `client.getMatch(matchId) → Promise<object>`
|
|
126
|
+
|
|
127
|
+
Full detail for a single match.
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
const { match, homeRecentForm, headToHead } = await client.getMatch(12345);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
### Seasons
|
|
136
|
+
|
|
137
|
+
#### `client.getSeasons() → Promise<{ seasons: string[], latestSeason: string }>`
|
|
138
|
+
|
|
139
|
+
List all available seasons.
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
const { seasons, latestSeason } = await client.getSeasons();
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
#### `client.getSeason(season, options?) → Promise<object[]>`
|
|
146
|
+
|
|
147
|
+
Per-team ELO change statistics for a given season.
|
|
148
|
+
|
|
149
|
+
| Option | Description |
|
|
150
|
+
|----------|-----------------------------------------|
|
|
151
|
+
| `league` | Optional league filter, e.g. `"BL1"` |
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
const entries = await client.getSeason('2024/2025', { league: 'BL1' });
|
|
155
|
+
// [{ teamName: 'Bayern München', startElo: 1820, endElo: 1847, change: 27, ... }, ...]
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
### Comparison
|
|
161
|
+
|
|
162
|
+
#### `client.getComparisonHistory(teams) → Promise<object>`
|
|
163
|
+
|
|
164
|
+
Time-series ELO data for multiple teams in one call.
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
const history = await client.getComparisonHistory([
|
|
168
|
+
'Bayern München',
|
|
169
|
+
'Borussia Dortmund',
|
|
170
|
+
]);
|
|
171
|
+
// {
|
|
172
|
+
// 'Bayern München': [{ Date: 1709856000000, ELO: 1847 }, ...],
|
|
173
|
+
// 'Borussia Dortmund': [{ Date: 1709856000000, ELO: 1720 }, ...]
|
|
174
|
+
// }
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## Error handling
|
|
180
|
+
|
|
181
|
+
All exceptions extend `ElevenEloError`.
|
|
182
|
+
|
|
183
|
+
| Class | When thrown |
|
|
184
|
+
|----------------------|--------------------------------------------------|
|
|
185
|
+
| `AuthenticationError`| API key is missing or invalid (HTTP 401) |
|
|
186
|
+
| `RateLimitError` | Daily quota exceeded (HTTP 429) |
|
|
187
|
+
| `NotFoundError` | Resource does not exist (HTTP 404) |
|
|
188
|
+
| `ApiError` | Any other non-2xx response |
|
|
189
|
+
|
|
190
|
+
```js
|
|
191
|
+
import { Client, AuthenticationError, RateLimitError, NotFoundError } from '11elo';
|
|
192
|
+
|
|
193
|
+
const client = new Client({ apiKey: '11e_fre_your_key_here' });
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const team = await client.getTeam('Unknown FC');
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err instanceof NotFoundError) {
|
|
199
|
+
console.error('Team not found');
|
|
200
|
+
} else if (err instanceof RateLimitError) {
|
|
201
|
+
console.error('Rate limit hit, resets at', err.resetAt);
|
|
202
|
+
} else if (err instanceof AuthenticationError) {
|
|
203
|
+
console.error('Bad API key');
|
|
204
|
+
} else {
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## CommonJS usage
|
|
211
|
+
|
|
212
|
+
The package ships as ES modules (`"type": "module"`). For CommonJS projects, use dynamic `import()`:
|
|
213
|
+
|
|
214
|
+
```js
|
|
215
|
+
const { Client } = await import('11elo');
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Or use a bundler (webpack, Rollup, esbuild) which handles the interop automatically.
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "11elo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "JavaScript/Node.js client for the 11elo Soccer ELO API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"soccer",
|
|
15
|
+
"football",
|
|
16
|
+
"elo",
|
|
17
|
+
"bundesliga",
|
|
18
|
+
"api",
|
|
19
|
+
"client"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"homepage": "https://11elo.com",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/Chafficui/11elo-sdk.git",
|
|
26
|
+
"directory": "javascript"
|
|
27
|
+
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/Chafficui/11elo-sdk/issues"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test src/client.test.js"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the 11elo JavaScript client.
|
|
3
|
+
* Uses the built-in Node.js test runner (node:test) – no extra dependencies.
|
|
4
|
+
*
|
|
5
|
+
* Run: node --test src/client.test.js
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
Client,
|
|
13
|
+
ElevenEloError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
RateLimitError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
ApiError,
|
|
18
|
+
} from './index.js';
|
|
19
|
+
|
|
20
|
+
const BASE = 'https://11elo.com';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Minimal fetch mock helpers
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
function mockFetch(status, body, headers = {}) {
|
|
27
|
+
return async () =>
|
|
28
|
+
new Response(JSON.stringify(body), {
|
|
29
|
+
status,
|
|
30
|
+
headers: { 'Content-Type': 'application/json', ...headers },
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeMockClient(fetchFn) {
|
|
35
|
+
const client = new Client({ apiKey: '11e_fre_testkey' });
|
|
36
|
+
// Replace global fetch for the duration of the test
|
|
37
|
+
client._fetch = fetchFn;
|
|
38
|
+
// Patch internal _get to use _fetch
|
|
39
|
+
const originalGet = client._get.bind(client);
|
|
40
|
+
client._get = async (path, params = {}) => {
|
|
41
|
+
const url = client._url(path) + client._buildQuery(params);
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const timerId = setTimeout(() => controller.abort(), client._timeoutMs);
|
|
44
|
+
let response;
|
|
45
|
+
try {
|
|
46
|
+
response = await client._fetch(url, {
|
|
47
|
+
method: 'GET',
|
|
48
|
+
headers: { 'X-API-Key': client._apiKey, Accept: 'application/json' },
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
});
|
|
51
|
+
} finally {
|
|
52
|
+
clearTimeout(timerId);
|
|
53
|
+
}
|
|
54
|
+
return client._handleResponse(response);
|
|
55
|
+
};
|
|
56
|
+
return client;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Constructor tests
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
test('throws when apiKey is missing', () => {
|
|
64
|
+
assert.throws(() => new Client({}), /apiKey is required/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('accepts custom baseUrl and timeoutMs', () => {
|
|
68
|
+
const c = new Client({ apiKey: 'k', baseUrl: 'http://localhost:3001', timeoutMs: 5000 });
|
|
69
|
+
assert.equal(c._baseUrl, 'http://localhost:3001');
|
|
70
|
+
assert.equal(c._timeoutMs, 5000);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Successful responses
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
test('getTeams returns parsed JSON', async () => {
|
|
78
|
+
const payload = [{ teamName: 'Bayern München', currentElo: 1847 }];
|
|
79
|
+
const client = makeMockClient(mockFetch(200, payload));
|
|
80
|
+
const result = await client.getTeams();
|
|
81
|
+
assert.deepEqual(result, payload);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('getTeam encodes team name in URL', async () => {
|
|
85
|
+
const payload = { team: { name: 'Bayern München' } };
|
|
86
|
+
let capturedUrl;
|
|
87
|
+
const client = makeMockClient(async (url, opts) => {
|
|
88
|
+
capturedUrl = url;
|
|
89
|
+
return new Response(JSON.stringify(payload), { status: 200 });
|
|
90
|
+
});
|
|
91
|
+
await client.getTeam('Bayern München');
|
|
92
|
+
assert.ok(capturedUrl.includes('Bayern%20M%C3%BCnchen'));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('getHeadToHead encodes both team names', async () => {
|
|
96
|
+
const payload = [{ result: '2:1' }];
|
|
97
|
+
let capturedUrl;
|
|
98
|
+
const client = makeMockClient(async (url) => {
|
|
99
|
+
capturedUrl = url;
|
|
100
|
+
return new Response(JSON.stringify(payload), { status: 200 });
|
|
101
|
+
});
|
|
102
|
+
await client.getHeadToHead('Bayern München', 'Borussia Dortmund');
|
|
103
|
+
assert.ok(capturedUrl.includes('head-to-head'));
|
|
104
|
+
assert.ok(capturedUrl.includes('Bayern%20M%C3%BCnchen'));
|
|
105
|
+
assert.ok(capturedUrl.includes('Borussia%20Dortmund'));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('getMatches passes query params', async () => {
|
|
109
|
+
const payload = [{ id: 1 }];
|
|
110
|
+
let capturedUrl;
|
|
111
|
+
const client = makeMockClient(async (url) => {
|
|
112
|
+
capturedUrl = url;
|
|
113
|
+
return new Response(JSON.stringify(payload), { status: 200 });
|
|
114
|
+
});
|
|
115
|
+
await client.getMatches({ season: '2024/2025', limit: 10, offset: 0 });
|
|
116
|
+
assert.ok(capturedUrl.includes('limit=10'));
|
|
117
|
+
assert.ok(capturedUrl.includes('offset=0'));
|
|
118
|
+
assert.ok(capturedUrl.includes('season='));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('getUpcomingMatches passes league param', async () => {
|
|
122
|
+
const payload = [{ id: 2 }];
|
|
123
|
+
let capturedUrl;
|
|
124
|
+
const client = makeMockClient(async (url) => {
|
|
125
|
+
capturedUrl = url;
|
|
126
|
+
return new Response(JSON.stringify(payload), { status: 200 });
|
|
127
|
+
});
|
|
128
|
+
await client.getUpcomingMatches({ league: 'BL1' });
|
|
129
|
+
assert.ok(capturedUrl.includes('league=BL1'));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('getMatch appends matchId to path', async () => {
|
|
133
|
+
const payload = { match: { id: 12345 } };
|
|
134
|
+
let capturedUrl;
|
|
135
|
+
const client = makeMockClient(async (url) => {
|
|
136
|
+
capturedUrl = url;
|
|
137
|
+
return new Response(JSON.stringify(payload), { status: 200 });
|
|
138
|
+
});
|
|
139
|
+
await client.getMatch(12345);
|
|
140
|
+
assert.ok(capturedUrl.endsWith('/api/matches/12345'));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('getSeasons returns seasons object', async () => {
|
|
144
|
+
const payload = { seasons: ['2025/2026'], latestSeason: '2025/2026' };
|
|
145
|
+
const client = makeMockClient(mockFetch(200, payload));
|
|
146
|
+
const result = await client.getSeasons();
|
|
147
|
+
assert.deepEqual(result, payload);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('getSeason encodes season in URL', async () => {
|
|
151
|
+
const payload = [{ teamName: 'Bayern München', change: 27 }];
|
|
152
|
+
let capturedUrl;
|
|
153
|
+
const client = makeMockClient(async (url) => {
|
|
154
|
+
capturedUrl = url;
|
|
155
|
+
return new Response(JSON.stringify(payload), { status: 200 });
|
|
156
|
+
});
|
|
157
|
+
await client.getSeason('2024/2025', { league: 'BL1' });
|
|
158
|
+
assert.ok(capturedUrl.includes('2024'));
|
|
159
|
+
assert.ok(capturedUrl.includes('league=BL1'));
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('getComparisonHistory joins teams with comma', async () => {
|
|
163
|
+
const payload = { 'Bayern München': [], 'Borussia Dortmund': [] };
|
|
164
|
+
let capturedUrl;
|
|
165
|
+
const client = makeMockClient(async (url) => {
|
|
166
|
+
capturedUrl = url;
|
|
167
|
+
return new Response(JSON.stringify(payload), { status: 200 });
|
|
168
|
+
});
|
|
169
|
+
await client.getComparisonHistory(['Bayern München', 'Borussia Dortmund']);
|
|
170
|
+
assert.ok(capturedUrl.includes('teams='));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Error handling
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
test('getTeams throws AuthenticationError on 401', async () => {
|
|
178
|
+
const client = makeMockClient(mockFetch(401, { error: 'Unauthorized' }));
|
|
179
|
+
await assert.rejects(() => client.getTeams(), AuthenticationError);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('getTeams throws RateLimitError on 429 with resetAt', async () => {
|
|
183
|
+
const client = makeMockClient(
|
|
184
|
+
mockFetch(429, {}, { 'X-RateLimit-Reset': '2026-03-13T00:00:00Z' }),
|
|
185
|
+
);
|
|
186
|
+
try {
|
|
187
|
+
await client.getTeams();
|
|
188
|
+
assert.fail('expected RateLimitError');
|
|
189
|
+
} catch (err) {
|
|
190
|
+
assert.ok(err instanceof RateLimitError);
|
|
191
|
+
assert.equal(err.resetAt, '2026-03-13T00:00:00Z');
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('getTeam throws NotFoundError on 404', async () => {
|
|
196
|
+
const client = makeMockClient(mockFetch(404, {}));
|
|
197
|
+
await assert.rejects(() => client.getTeam('Unknown FC'), NotFoundError);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('getTeams throws ApiError on 500', async () => {
|
|
201
|
+
const client = makeMockClient(mockFetch(500, {}));
|
|
202
|
+
try {
|
|
203
|
+
await client.getTeams();
|
|
204
|
+
assert.fail('expected ApiError');
|
|
205
|
+
} catch (err) {
|
|
206
|
+
assert.ok(err instanceof ApiError);
|
|
207
|
+
assert.equal(err.statusCode, 500);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('getComparisonHistory throws when fewer than two teams given', () => {
|
|
212
|
+
const client = new Client({ apiKey: 'k' });
|
|
213
|
+
assert.throws(() => client.getComparisonHistory(['Bayern München']), /at least two/);
|
|
214
|
+
});
|
package/src/index.js
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JavaScript/Node.js client for the 11elo Soccer ELO API.
|
|
3
|
+
*
|
|
4
|
+
* Requires Node.js 18+ (uses native `fetch`) or any environment that
|
|
5
|
+
* exposes the global `fetch` function (modern browsers, Deno, Bun, etc.).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```js
|
|
9
|
+
* import { Client } from '11elo';
|
|
10
|
+
*
|
|
11
|
+
* const client = new Client({ apiKey: '11e_fre_your_key_here' });
|
|
12
|
+
*
|
|
13
|
+
* const teams = await client.getTeams();
|
|
14
|
+
* console.log(teams[0].teamName, teams[0].currentElo);
|
|
15
|
+
* ```
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const DEFAULT_BASE_URL = 'https://11elo.com';
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Custom errors
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Base error class for all 11elo client errors.
|
|
28
|
+
*/
|
|
29
|
+
export class ElevenEloError extends Error {
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'ElevenEloError';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Thrown when the API key is missing or invalid (HTTP 401).
|
|
38
|
+
*/
|
|
39
|
+
export class AuthenticationError extends ElevenEloError {
|
|
40
|
+
constructor(message = 'Invalid or missing API key. Obtain one at https://11elo.com/developer') {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = 'AuthenticationError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Thrown when the daily rate limit for the API key tier has been exceeded (HTTP 429).
|
|
48
|
+
* @property {string|null} resetAt - ISO-8601 timestamp when the limit resets.
|
|
49
|
+
*/
|
|
50
|
+
export class RateLimitError extends ElevenEloError {
|
|
51
|
+
constructor(message = 'Daily rate limit exceeded. Upgrade your plan at https://11elo.com/developer', resetAt = null) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'RateLimitError';
|
|
54
|
+
this.resetAt = resetAt;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Thrown when the requested resource does not exist (HTTP 404).
|
|
60
|
+
*/
|
|
61
|
+
export class NotFoundError extends ElevenEloError {
|
|
62
|
+
constructor(message) {
|
|
63
|
+
super(message);
|
|
64
|
+
this.name = 'NotFoundError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Thrown for unexpected API errors (any non-2xx response not covered above).
|
|
70
|
+
* @property {number} statusCode - The HTTP status code.
|
|
71
|
+
*/
|
|
72
|
+
export class ApiError extends ElevenEloError {
|
|
73
|
+
constructor(message, statusCode) {
|
|
74
|
+
super(message);
|
|
75
|
+
this.name = 'ApiError';
|
|
76
|
+
this.statusCode = statusCode;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Client
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Synchronous-style async client for the 11elo public API.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```js
|
|
89
|
+
* const client = new Client({ apiKey: '11e_fre_your_key_here' });
|
|
90
|
+
* const team = await client.getTeam('Bayern München');
|
|
91
|
+
* console.log(team.team.currentElo);
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export class Client {
|
|
95
|
+
/**
|
|
96
|
+
* @param {object} options
|
|
97
|
+
* @param {string} options.apiKey - Your 11elo API key (required).
|
|
98
|
+
* @param {string} [options.baseUrl='https://11elo.com'] - Override the base URL.
|
|
99
|
+
* @param {number} [options.timeoutMs=30000] - Request timeout in milliseconds.
|
|
100
|
+
*/
|
|
101
|
+
constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
102
|
+
if (!apiKey) {
|
|
103
|
+
throw new Error('apiKey is required');
|
|
104
|
+
}
|
|
105
|
+
this._apiKey = apiKey;
|
|
106
|
+
this._baseUrl = baseUrl.replace(/\/$/, '');
|
|
107
|
+
this._timeoutMs = timeoutMs;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// Internal helpers
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
_url(path) {
|
|
115
|
+
return `${this._baseUrl}${path}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
_buildQuery(params) {
|
|
119
|
+
const filtered = Object.fromEntries(
|
|
120
|
+
Object.entries(params).filter(([, v]) => v !== undefined && v !== null),
|
|
121
|
+
);
|
|
122
|
+
const qs = new URLSearchParams(filtered).toString();
|
|
123
|
+
return qs ? `?${qs}` : '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async _get(path, params = {}) {
|
|
127
|
+
const url = this._url(path) + this._buildQuery(params);
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const timerId = setTimeout(() => controller.abort(), this._timeoutMs);
|
|
130
|
+
|
|
131
|
+
let response;
|
|
132
|
+
try {
|
|
133
|
+
response = await fetch(url, {
|
|
134
|
+
method: 'GET',
|
|
135
|
+
headers: {
|
|
136
|
+
'X-API-Key': this._apiKey,
|
|
137
|
+
Accept: 'application/json',
|
|
138
|
+
},
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
if (err.name === 'AbortError') {
|
|
143
|
+
throw new ElevenEloError(`Request timed out after ${this._timeoutMs}ms`);
|
|
144
|
+
}
|
|
145
|
+
throw new ElevenEloError(`Network error: ${err.message}`);
|
|
146
|
+
} finally {
|
|
147
|
+
clearTimeout(timerId);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return this._handleResponse(response);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async _handleResponse(response) {
|
|
154
|
+
if (response.status === 401) {
|
|
155
|
+
throw new AuthenticationError();
|
|
156
|
+
}
|
|
157
|
+
if (response.status === 429) {
|
|
158
|
+
const resetAt = response.headers.get('X-RateLimit-Reset');
|
|
159
|
+
throw new RateLimitError(undefined, resetAt);
|
|
160
|
+
}
|
|
161
|
+
if (response.status === 404) {
|
|
162
|
+
throw new NotFoundError(`Resource not found: ${response.url}`);
|
|
163
|
+
}
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
const body = await response.text().catch(() => '');
|
|
166
|
+
throw new ApiError(
|
|
167
|
+
`API request failed with status ${response.status}: ${body}`,
|
|
168
|
+
response.status,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
return await response.json();
|
|
174
|
+
} catch {
|
|
175
|
+
const text = await response.text().catch(() => '');
|
|
176
|
+
throw new ElevenEloError(`Failed to parse JSON response: ${text}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// -------------------------------------------------------------------------
|
|
181
|
+
// Teams
|
|
182
|
+
// -------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Return all teams with their current ELO stats and league info.
|
|
186
|
+
*
|
|
187
|
+
* @returns {Promise<Array<object>>} Array of team objects.
|
|
188
|
+
*
|
|
189
|
+
* @example
|
|
190
|
+
* ```js
|
|
191
|
+
* const teams = await client.getTeams();
|
|
192
|
+
* teams.forEach(t => console.log(t.teamName, t.currentElo));
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
getTeams() {
|
|
196
|
+
return this._get('/api/teams');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Return detailed information for a single team.
|
|
201
|
+
*
|
|
202
|
+
* @param {string} teamName - The canonical team name, e.g. `"Bayern München"`.
|
|
203
|
+
* @returns {Promise<object>} Object with `team`, `eloHistory`, `recentForm`,
|
|
204
|
+
* `significantMatches`, `stats`, and `upcomingMatches`.
|
|
205
|
+
*
|
|
206
|
+
* @example
|
|
207
|
+
* ```js
|
|
208
|
+
* const { team, eloHistory } = await client.getTeam('Bayern München');
|
|
209
|
+
* console.log(team.currentElo);
|
|
210
|
+
* ```
|
|
211
|
+
*/
|
|
212
|
+
getTeam(teamName) {
|
|
213
|
+
return this._get(`/api/teams/${encodeURIComponent(teamName)}`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Return head-to-head match history between two teams.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} team1 - Name of the first team.
|
|
220
|
+
* @param {string} team2 - Name of the second team.
|
|
221
|
+
* @returns {Promise<Array<object>>} Array of match result objects.
|
|
222
|
+
*
|
|
223
|
+
* @example
|
|
224
|
+
* ```js
|
|
225
|
+
* const h2h = await client.getHeadToHead('Bayern München', 'Borussia Dortmund');
|
|
226
|
+
* console.log(h2h[0].result);
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
getHeadToHead(team1, team2) {
|
|
230
|
+
return this._get(
|
|
231
|
+
`/api/teams/${encodeURIComponent(team1)}/head-to-head/${encodeURIComponent(team2)}`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// -------------------------------------------------------------------------
|
|
236
|
+
// Matches
|
|
237
|
+
// -------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Return a paginated list of historical matches.
|
|
241
|
+
*
|
|
242
|
+
* @param {object} [options]
|
|
243
|
+
* @param {string} [options.season] - Season string, e.g. `"2024/2025"`.
|
|
244
|
+
* @param {string} [options.from] - ISO-8601 start date (`"YYYY-MM-DD"`).
|
|
245
|
+
* @param {string} [options.to] - ISO-8601 end date (`"YYYY-MM-DD"`).
|
|
246
|
+
* @param {number} [options.limit] - Max results (default 100, max 500).
|
|
247
|
+
* @param {number} [options.offset] - Pagination offset (default 0).
|
|
248
|
+
* @returns {Promise<Array<object>>} Array of match objects.
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* ```js
|
|
252
|
+
* const matches = await client.getMatches({ season: '2024/2025', limit: 20 });
|
|
253
|
+
* ```
|
|
254
|
+
*/
|
|
255
|
+
getMatches({ season, from, to, limit, offset } = {}) {
|
|
256
|
+
return this._get('/api/matches', { season, from, to, limit, offset });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Return upcoming fixtures with ELO-difference predictions.
|
|
261
|
+
*
|
|
262
|
+
* @param {object} [options]
|
|
263
|
+
* @param {string} [options.league] - League code filter, e.g. `"BL1"`.
|
|
264
|
+
* @param {string} [options.sort] - Sort order (default `"date"`).
|
|
265
|
+
* @param {number} [options.limit] - Max results (default 50, max 200).
|
|
266
|
+
* @returns {Promise<Array<object>>}
|
|
267
|
+
*
|
|
268
|
+
* @example
|
|
269
|
+
* ```js
|
|
270
|
+
* const upcoming = await client.getUpcomingMatches({ league: 'BL1', limit: 10 });
|
|
271
|
+
* ```
|
|
272
|
+
*/
|
|
273
|
+
getUpcomingMatches({ league, sort, limit } = {}) {
|
|
274
|
+
return this._get('/api/matches/upcoming', { league, sort, limit });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Return full details for a single match.
|
|
279
|
+
*
|
|
280
|
+
* @param {number|string} matchId - The numeric match identifier.
|
|
281
|
+
* @returns {Promise<object>} Object with `match`, `homeRecentForm`, `awayRecentForm`,
|
|
282
|
+
* `homeStats`, `awayStats`, and `headToHead`.
|
|
283
|
+
*
|
|
284
|
+
* @example
|
|
285
|
+
* ```js
|
|
286
|
+
* const { match, headToHead } = await client.getMatch(12345);
|
|
287
|
+
* console.log(match.homeTeam, match.homeElo);
|
|
288
|
+
* ```
|
|
289
|
+
*/
|
|
290
|
+
getMatch(matchId) {
|
|
291
|
+
return this._get(`/api/matches/${matchId}`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// -------------------------------------------------------------------------
|
|
295
|
+
// Seasons
|
|
296
|
+
// -------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Return all available seasons and the latest one.
|
|
300
|
+
*
|
|
301
|
+
* @returns {Promise<{seasons: string[], latestSeason: string}>}
|
|
302
|
+
*
|
|
303
|
+
* @example
|
|
304
|
+
* ```js
|
|
305
|
+
* const { seasons, latestSeason } = await client.getSeasons();
|
|
306
|
+
* console.log(latestSeason);
|
|
307
|
+
* ```
|
|
308
|
+
*/
|
|
309
|
+
getSeasons() {
|
|
310
|
+
return this._get('/api/seasons');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Return per-team ELO change statistics for a specific season.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} season - Season string, e.g. `"2024/2025"`.
|
|
317
|
+
* @param {object} [options]
|
|
318
|
+
* @param {string} [options.league] - Optional league filter, e.g. `"BL1"`.
|
|
319
|
+
* @returns {Promise<Array<object>>}
|
|
320
|
+
*
|
|
321
|
+
* @example
|
|
322
|
+
* ```js
|
|
323
|
+
* const data = await client.getSeason('2024/2025', { league: 'BL1' });
|
|
324
|
+
* data.forEach(e => console.log(e.teamName, e.change));
|
|
325
|
+
* ```
|
|
326
|
+
*/
|
|
327
|
+
getSeason(season, { league } = {}) {
|
|
328
|
+
return this._get(`/api/seasons/${encodeURIComponent(season)}`, { league });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// -------------------------------------------------------------------------
|
|
332
|
+
// Comparison
|
|
333
|
+
// -------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Return historical ELO time-series for multiple teams side-by-side.
|
|
337
|
+
*
|
|
338
|
+
* @param {string[]} teams - Array of team names (minimum two).
|
|
339
|
+
* @returns {Promise<object>} Keys are team names; values are arrays of
|
|
340
|
+
* `{Date: number, ELO: number}` data points.
|
|
341
|
+
*
|
|
342
|
+
* @example
|
|
343
|
+
* ```js
|
|
344
|
+
* const history = await client.getComparisonHistory([
|
|
345
|
+
* 'Bayern München',
|
|
346
|
+
* 'Borussia Dortmund',
|
|
347
|
+
* ]);
|
|
348
|
+
* Object.entries(history).forEach(([team, points]) => {
|
|
349
|
+
* console.log(team, points.at(-1).ELO);
|
|
350
|
+
* });
|
|
351
|
+
* ```
|
|
352
|
+
*/
|
|
353
|
+
getComparisonHistory(teams) {
|
|
354
|
+
if (!Array.isArray(teams) || teams.length < 2) {
|
|
355
|
+
throw new Error('getComparisonHistory requires an array of at least two team names');
|
|
356
|
+
}
|
|
357
|
+
return this._get('/api/comparison/history', { teams: teams.join(',') });
|
|
358
|
+
}
|
|
359
|
+
}
|