claudial 0.1.3 → 0.2.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 +8 -6
- package/dist/api/espn.js +66 -0
- package/dist/api/parse.js +79 -90
- package/dist/index.js +3 -3
- package/dist/snapshot.js +1 -1
- package/dist/ui/App.js +1 -1
- package/package.json +2 -2
- package/dist/api/sofascore.js +0 -51
package/README.md
CHANGED
|
@@ -123,18 +123,20 @@ v1 is being built live during the group stage. Follow the commits.
|
|
|
123
123
|
|
|
124
124
|
## Notes
|
|
125
125
|
|
|
126
|
-
- Match data comes from
|
|
127
|
-
|
|
126
|
+
- Match data comes from [ESPN's public soccer scoreboard](https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard)
|
|
127
|
+
— facts only (scores, scorers, cards), no logos or branding. See
|
|
128
|
+
[DATA.md](DATA.md) for why this source. Not affiliated with or endorsed by
|
|
129
|
+
ESPN or FIFA.
|
|
128
130
|
- Not affiliated with Anthropic. The aesthetic is a love letter to
|
|
129
131
|
[Claude Code](https://claude.com/claude-code), whose terminal UI this
|
|
130
132
|
proudly imitates.
|
|
131
|
-
- Polling is deliberately gentle (15 s live, 5 min fixtures)
|
|
132
|
-
it that way.
|
|
133
|
+
- Polling is deliberately gentle (15 s live, 5 min fixtures), and a single
|
|
134
|
+
scoreboard call serves all live matches at once. Please keep it that way.
|
|
133
135
|
|
|
134
136
|
```js
|
|
135
137
|
// Canada — Bosnia & Herzegovina, 12 June 2026, was on while this was built.
|
|
136
|
-
// Jovo Lukić's 21' goal was the first one this codebase ever saw — it
|
|
137
|
-
//
|
|
138
|
+
// Jovo Lukić's 21' goal was the first one this codebase ever saw — it showed
|
|
139
|
+
// up in a smoke test before any UI existed to celebrate it. Legendary.
|
|
138
140
|
```
|
|
139
141
|
|
|
140
142
|
## License
|
package/dist/api/espn.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { parseEvent, parseIncidents } from './parse.js';
|
|
2
|
+
// ESPN's public soccer scoreboard for the FIFA World Cup (league fifa.world).
|
|
3
|
+
// Keyless, served openly to ESPN's own web/apps, facts-only. One call returns
|
|
4
|
+
// every match for a date (or date range) with goals and cards inline.
|
|
5
|
+
const BASE = 'https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world';
|
|
6
|
+
async function get(path) {
|
|
7
|
+
const res = await fetch(`${BASE}${path}`, { headers: { Accept: 'application/json' } });
|
|
8
|
+
if (!res.ok)
|
|
9
|
+
throw new Error(`espn ${res.status} ${path}`);
|
|
10
|
+
const text = await res.text();
|
|
11
|
+
if (!text)
|
|
12
|
+
throw new Error(`espn empty body ${path}`);
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(text);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error(`espn: malformed JSON from ${path}: ${text.slice(0, 120)}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function yyyymmdd(d) {
|
|
21
|
+
return `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
|
|
22
|
+
}
|
|
23
|
+
// Brief cache so one live tick (live + per-match incidents) is a single fetch.
|
|
24
|
+
const cache = new Map();
|
|
25
|
+
const CACHE_MS = 5_000;
|
|
26
|
+
async function scoreboard(dates) {
|
|
27
|
+
const hit = cache.get(dates);
|
|
28
|
+
if (hit && Date.now() - hit.at < CACHE_MS)
|
|
29
|
+
return hit.events;
|
|
30
|
+
const data = await get(`/scoreboard?dates=${dates}`);
|
|
31
|
+
const events = data.events ?? [];
|
|
32
|
+
cache.set(dates, { at: Date.now(), events });
|
|
33
|
+
return events;
|
|
34
|
+
}
|
|
35
|
+
function todayRange(daysAhead) {
|
|
36
|
+
const now = new Date();
|
|
37
|
+
const start = yyyymmdd(now);
|
|
38
|
+
if (daysAhead <= 0)
|
|
39
|
+
return start;
|
|
40
|
+
const end = new Date(now.getTime() + daysAhead * 86_400_000);
|
|
41
|
+
return `${start}-${yyyymmdd(end)}`;
|
|
42
|
+
}
|
|
43
|
+
// Kept for call-site compatibility; ESPN needs no season id to query.
|
|
44
|
+
export async function resolveSeasonId() {
|
|
45
|
+
return 2026;
|
|
46
|
+
}
|
|
47
|
+
export async function fetchLive() {
|
|
48
|
+
const events = await scoreboard(todayRange(0));
|
|
49
|
+
return events.map(parseEvent).filter((m) => m.status === 'live' || m.status === 'halftime');
|
|
50
|
+
}
|
|
51
|
+
export async function fetchUpcoming(_seasonId) {
|
|
52
|
+
const events = await scoreboard(todayRange(10));
|
|
53
|
+
return events
|
|
54
|
+
.map(parseEvent)
|
|
55
|
+
.filter((m) => m.status === 'upcoming')
|
|
56
|
+
.sort((a, b) => a.startTimestamp - b.startTimestamp);
|
|
57
|
+
}
|
|
58
|
+
export async function fetchRecent(_seasonId) {
|
|
59
|
+
const events = await scoreboard(todayRange(0));
|
|
60
|
+
return events.map(parseEvent).filter((m) => m.status === 'finished');
|
|
61
|
+
}
|
|
62
|
+
export async function fetchIncidents(eventId) {
|
|
63
|
+
const events = await scoreboard(todayRange(0));
|
|
64
|
+
const event = events.find((e) => Number(e.id) === eventId);
|
|
65
|
+
return event ? parseIncidents(event) : [];
|
|
66
|
+
}
|
package/dist/api/parse.js
CHANGED
|
@@ -1,114 +1,103 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
// claudial parses ESPN's public soccer scoreboard (league fifa.world).
|
|
2
|
+
// One scoreboard response carries every match for a date plus, inline, each
|
|
3
|
+
// match's goals and cards — so a single request powers scores, scorer lines,
|
|
4
|
+
// and goal/red-card takeovers. Substitutions and VAR live only in ESPN's heavy
|
|
5
|
+
// per-match summary endpoint and are intentionally not used here.
|
|
5
6
|
function toStatus(raw) {
|
|
6
7
|
const t = raw?.status?.type;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (
|
|
8
|
+
const state = t?.state;
|
|
9
|
+
if (state === 'in')
|
|
10
|
+
return t?.name === 'STATUS_HALFTIME' ? 'halftime' : 'live';
|
|
11
|
+
if (state === 'post')
|
|
11
12
|
return 'finished';
|
|
12
13
|
return 'upcoming';
|
|
13
14
|
}
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
15
|
+
function competitor(raw, side) {
|
|
16
|
+
const comps = raw?.competitions?.[0]?.competitors ?? [];
|
|
17
|
+
return comps.find((c) => c.homeAway === side) ?? {};
|
|
18
|
+
}
|
|
19
|
+
function teamOf(c) {
|
|
20
|
+
const t = c?.team ?? {};
|
|
21
|
+
return {
|
|
22
|
+
name: t.displayName ?? t.shortDisplayName ?? t.name ?? '?',
|
|
23
|
+
code: t.abbreviation ?? t.shortDisplayName ?? '?',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function scoreOf(c, status) {
|
|
27
|
+
if (status === 'upcoming')
|
|
17
28
|
return null;
|
|
18
|
-
|
|
29
|
+
const n = Number(c?.score);
|
|
30
|
+
return Number.isFinite(n) ? n : null;
|
|
31
|
+
}
|
|
32
|
+
/** "21'" -> 21, "90'+7'" -> 90, "HT"/"" -> null */
|
|
33
|
+
function minuteOf(raw, status) {
|
|
34
|
+
if (status !== 'live')
|
|
19
35
|
return null;
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
const minute = Math.floor(played / 60) + 1;
|
|
23
|
-
return Math.min(minute, Math.ceil((time.max ?? 5400) / 60));
|
|
36
|
+
const m = /(\d+)/.exec(raw?.status?.displayClock ?? '');
|
|
37
|
+
return m ? Number(m[1]) : null;
|
|
24
38
|
}
|
|
25
|
-
export function parseEvent(raw
|
|
26
|
-
const
|
|
39
|
+
export function parseEvent(raw) {
|
|
40
|
+
const status = toStatus(raw);
|
|
41
|
+
const home = competitor(raw, 'home');
|
|
42
|
+
const away = competitor(raw, 'away');
|
|
27
43
|
return {
|
|
28
|
-
id: raw.id,
|
|
29
|
-
group,
|
|
30
|
-
home:
|
|
31
|
-
away:
|
|
32
|
-
homeScore:
|
|
33
|
-
awayScore:
|
|
34
|
-
status
|
|
35
|
-
statusText: raw
|
|
36
|
-
minute:
|
|
37
|
-
startTimestamp: raw
|
|
38
|
-
|
|
39
|
-
varInProgress: typeof raw.varInProgress === 'object' && raw.varInProgress !== null
|
|
40
|
-
? !!(raw.varInProgress.homeTeam || raw.varInProgress.awayTeam)
|
|
41
|
-
: !!raw.varInProgress,
|
|
44
|
+
id: Number(raw.id),
|
|
45
|
+
group: null, // ESPN's scoreboard does not expose the group letter
|
|
46
|
+
home: teamOf(home),
|
|
47
|
+
away: teamOf(away),
|
|
48
|
+
homeScore: scoreOf(home, status),
|
|
49
|
+
awayScore: scoreOf(away, status),
|
|
50
|
+
status,
|
|
51
|
+
statusText: raw?.status?.type?.shortDetail ?? raw?.status?.type?.description ?? '',
|
|
52
|
+
minute: minuteOf(raw, status),
|
|
53
|
+
startTimestamp: Math.floor(Date.parse(raw?.date ?? '') / 1000) || 0,
|
|
54
|
+
varInProgress: false, // not surfaced by the scoreboard feed
|
|
42
55
|
};
|
|
43
56
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
switch (i.incidentType) {
|
|
53
|
-
case 'goal':
|
|
54
|
-
return 'goal';
|
|
55
|
-
case 'card':
|
|
56
|
-
if (i.incidentClass === 'yellow')
|
|
57
|
-
return 'yellowCard';
|
|
58
|
-
if (i.incidentClass === 'red' || i.incidentClass === 'yellowRed')
|
|
59
|
-
return 'redCard';
|
|
60
|
-
return null;
|
|
61
|
-
case 'varDecision':
|
|
62
|
-
return 'var';
|
|
63
|
-
case 'inGamePenalty':
|
|
64
|
-
return 'penaltyMiss';
|
|
65
|
-
case 'substitution':
|
|
66
|
-
return 'substitution';
|
|
67
|
-
case 'injuryTime':
|
|
68
|
-
return 'injuryTime';
|
|
69
|
-
default:
|
|
70
|
-
return null; // 'period' and anything unknown
|
|
71
|
-
}
|
|
57
|
+
function kindOf(det) {
|
|
58
|
+
if (det?.scoringPlay)
|
|
59
|
+
return 'goal';
|
|
60
|
+
if (det?.redCard)
|
|
61
|
+
return 'redCard';
|
|
62
|
+
if (det?.yellowCard)
|
|
63
|
+
return 'yellowCard';
|
|
64
|
+
return null; // substitutions / VAR are not in the scoreboard feed
|
|
72
65
|
}
|
|
73
|
-
function
|
|
74
|
-
if (kind
|
|
75
|
-
return humanize(i.incidentClass ?? 'decision');
|
|
76
|
-
if (kind === 'goal') {
|
|
77
|
-
if (i.incidentClass === 'penalty')
|
|
78
|
-
return 'Penalty';
|
|
79
|
-
if (i.incidentClass === 'ownGoal')
|
|
80
|
-
return 'Own goal';
|
|
66
|
+
function detailOf(det, kind) {
|
|
67
|
+
if (kind !== 'goal')
|
|
81
68
|
return null;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return i.length != null ? `+${i.length}` : null;
|
|
87
|
-
if (kind === 'penaltyMiss')
|
|
88
|
-
return 'Penalty missed';
|
|
69
|
+
if (det?.ownGoal)
|
|
70
|
+
return 'Own goal';
|
|
71
|
+
if (det?.penaltyKick)
|
|
72
|
+
return 'Penalty';
|
|
89
73
|
return null;
|
|
90
74
|
}
|
|
75
|
+
function minuteFromClock(det) {
|
|
76
|
+
const m = /(\d+)/.exec(det?.clock?.displayValue ?? '');
|
|
77
|
+
return m ? Number(m[1]) : null;
|
|
78
|
+
}
|
|
91
79
|
export function parseIncidents(raw) {
|
|
80
|
+
const homeTeamId = competitor(raw, 'home')?.team?.id;
|
|
81
|
+
const details = raw?.competitions?.[0]?.details ?? [];
|
|
92
82
|
const out = [];
|
|
93
|
-
for (const
|
|
94
|
-
const kind =
|
|
83
|
+
for (const det of details) {
|
|
84
|
+
const kind = kindOf(det);
|
|
95
85
|
if (!kind)
|
|
96
86
|
continue;
|
|
97
|
-
const
|
|
87
|
+
const athlete = det?.athletesInvolved?.[0];
|
|
98
88
|
out.push({
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
id: String(i.id ?? `${i.incidentType}-${i.time}-${player?.id ?? 'x'}`),
|
|
89
|
+
// content-derived, stable across polls (the diff engine dedupes on it)
|
|
90
|
+
id: `${raw.id}-${det?.type?.id ?? '?'}-${det?.clock?.value ?? '?'}-${athlete?.id ?? '?'}`,
|
|
102
91
|
kind,
|
|
103
|
-
minute:
|
|
104
|
-
player:
|
|
105
|
-
playerShort:
|
|
106
|
-
detail:
|
|
107
|
-
isHome:
|
|
108
|
-
homeScore:
|
|
109
|
-
awayScore:
|
|
92
|
+
minute: minuteFromClock(det),
|
|
93
|
+
player: athlete?.displayName ?? null,
|
|
94
|
+
playerShort: athlete?.shortName ?? athlete?.displayName ?? null,
|
|
95
|
+
detail: detailOf(det, kind),
|
|
96
|
+
isHome: det?.team?.id != null && det.team.id === homeTeamId,
|
|
97
|
+
homeScore: null, // running score not provided per incident; unused downstream
|
|
98
|
+
awayScore: null,
|
|
110
99
|
});
|
|
111
100
|
}
|
|
112
|
-
//
|
|
113
|
-
return out
|
|
101
|
+
// ESPN already lists details chronologically (earliest first)
|
|
102
|
+
return out;
|
|
114
103
|
}
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { render } from 'ink';
|
|
4
4
|
import { App } from './ui/App.js';
|
|
5
|
-
import { resolveSeasonId } from './api/
|
|
5
|
+
import { resolveSeasonId } from './api/espn.js';
|
|
6
6
|
// exit quietly when the consumer of a pipe closes early (e.g. `claudial | head`)
|
|
7
7
|
process.stdout.on('error', (err) => {
|
|
8
8
|
if (err.code === 'EPIPE')
|
|
@@ -15,7 +15,7 @@ async function main() {
|
|
|
15
15
|
seasonId = await resolveSeasonId();
|
|
16
16
|
}
|
|
17
17
|
catch {
|
|
18
|
-
console.error('claudial: could not reach
|
|
18
|
+
console.error('claudial: could not reach ESPN. Check your connection and try again.');
|
|
19
19
|
process.exit(1);
|
|
20
20
|
}
|
|
21
21
|
if (!process.stdout.isTTY) {
|
|
@@ -27,6 +27,6 @@ async function main() {
|
|
|
27
27
|
render(_jsx(App, { seasonId: seasonId, mode: mode }));
|
|
28
28
|
}
|
|
29
29
|
main().catch(() => {
|
|
30
|
-
console.error('claudial: could not reach
|
|
30
|
+
console.error('claudial: could not reach ESPN. Check your connection and try again.');
|
|
31
31
|
process.exit(1);
|
|
32
32
|
});
|
package/dist/snapshot.js
CHANGED
package/dist/ui/App.js
CHANGED
|
@@ -3,7 +3,7 @@ import { useEffect, useReducer, useRef } from 'react';
|
|
|
3
3
|
import { Box, useApp, useInput } from 'ink';
|
|
4
4
|
import { initialState, reducer } from '../state.js';
|
|
5
5
|
import { startPoller } from '../engine/poller.js';
|
|
6
|
-
import { fetchIncidents, fetchLive, fetchRecent, fetchUpcoming } from '../api/
|
|
6
|
+
import { fetchIncidents, fetchLive, fetchRecent, fetchUpcoming } from '../api/espn.js';
|
|
7
7
|
import { Header } from './Header.js';
|
|
8
8
|
import { LiveSection } from './LiveSection.js';
|
|
9
9
|
import { UpcomingSection } from './UpcomingSection.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claudial",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Live World Cup 2026 fixtures in your terminal — goal, red-card and VAR takeovers included",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"node": ">=18.18"
|
|
14
14
|
},
|
|
15
15
|
"scripts": {
|
|
16
|
-
"build": "tsc",
|
|
16
|
+
"build": "rm -rf dist && tsc",
|
|
17
17
|
"dev": "tsx src/index.tsx",
|
|
18
18
|
"test": "vitest run",
|
|
19
19
|
"prepublishOnly": "npm run build"
|
package/dist/api/sofascore.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import { isWorldCup, parseEvent, parseIncidents } from './parse.js';
|
|
2
|
-
const HOST = 'https://api.sofascore.app';
|
|
3
|
-
// api.sofascore.com is challenge-blocked (403). The app host works with this
|
|
4
|
-
// exact recipe; Accept-Encoding is mandatory or some endpoints return empty 200s.
|
|
5
|
-
const HEADERS = {
|
|
6
|
-
'User-Agent': 'SofaScore/5.95.2 (Linux; Android 13)',
|
|
7
|
-
Accept: 'application/json',
|
|
8
|
-
'Accept-Encoding': 'gzip',
|
|
9
|
-
};
|
|
10
|
-
async function get(path) {
|
|
11
|
-
const res = await fetch(`${HOST}${path}`, { headers: HEADERS });
|
|
12
|
-
if (!res.ok)
|
|
13
|
-
throw new Error(`sofascore ${res.status} ${path}`);
|
|
14
|
-
const text = await res.text();
|
|
15
|
-
if (!text)
|
|
16
|
-
throw new Error(`sofascore empty body ${path}`);
|
|
17
|
-
try {
|
|
18
|
-
return JSON.parse(text);
|
|
19
|
-
}
|
|
20
|
-
catch {
|
|
21
|
-
throw new Error(`sofascore: malformed JSON from ${path}: ${text.slice(0, 120)}`);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
const now = () => Math.floor(Date.now() / 1000);
|
|
25
|
-
export async function resolveSeasonId() {
|
|
26
|
-
const data = await get(`/api/v1/unique-tournament/16/seasons`);
|
|
27
|
-
if (!data.seasons?.length)
|
|
28
|
-
throw new Error(`sofascore: no seasons for tournament 16`);
|
|
29
|
-
return data.seasons[0].id; // newest first; 2026 = 58210
|
|
30
|
-
}
|
|
31
|
-
export async function fetchLive() {
|
|
32
|
-
const data = await get(`/api/v1/sport/football/events/live`);
|
|
33
|
-
const t = now();
|
|
34
|
-
return (data.events ?? []).filter(isWorldCup).map((e) => parseEvent(e, t));
|
|
35
|
-
}
|
|
36
|
-
export async function fetchUpcoming(seasonId) {
|
|
37
|
-
const data = await get(`/api/v1/unique-tournament/16/season/${seasonId}/events/next/0`);
|
|
38
|
-
const t = now();
|
|
39
|
-
return (data.events ?? []).map((e) => parseEvent(e, t));
|
|
40
|
-
}
|
|
41
|
-
export async function fetchRecent(seasonId) {
|
|
42
|
-
const data = await get(`/api/v1/unique-tournament/16/season/${seasonId}/events/last/0`);
|
|
43
|
-
const t = now();
|
|
44
|
-
const dayAgo = t - 86_400;
|
|
45
|
-
return (data.events ?? [])
|
|
46
|
-
.map((e) => parseEvent(e, t))
|
|
47
|
-
.filter((m) => m.status === 'finished' && m.startTimestamp > dayAgo);
|
|
48
|
-
}
|
|
49
|
-
export async function fetchIncidents(eventId) {
|
|
50
|
-
return parseIncidents(await get(`/api/v1/event/${eventId}/incidents`));
|
|
51
|
-
}
|