claudial 0.1.2 → 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 CHANGED
@@ -50,7 +50,10 @@ Four seconds of glory, then back to the board.
50
50
 
51
51
  The whole point. Two commands and your Claude Code becomes a World Cup
52
52
  workstation — Claude working on top, every live score ticking in a strip
53
- below, forever:
53
+ below, forever.
54
+
55
+ Needs [tmux](https://github.com/tmux/tmux) (`sudo pacman -S tmux` /
56
+ `sudo apt install tmux` / `brew install tmux`), then:
54
57
 
55
58
  ```bash
56
59
  npm install -g claudial
@@ -120,18 +123,20 @@ v1 is being built live during the group stage. Follow the commits.
120
123
 
121
124
  ## Notes
122
125
 
123
- - Match data comes from SofaScore's public endpoints. This project is
124
- unofficial and not affiliated with SofaScore.
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.
125
130
  - Not affiliated with Anthropic. The aesthetic is a love letter to
126
131
  [Claude Code](https://claude.com/claude-code), whose terminal UI this
127
132
  proudly imitates.
128
- - Polling is deliberately gentle (15 s live, 5 min fixtures). Please keep
129
- 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.
130
135
 
131
136
  ```js
132
137
  // Canada — Bosnia & Herzegovina, 12 June 2026, was on while this was built.
133
- // Jovo Lukić's 21' goal was the first one this codebase ever saw — it hit
134
- // the API smoke test before any UI existed to celebrate it. Legendary.
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.
135
140
  ```
136
141
 
137
142
  ## License
@@ -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
- export const WORLD_CUP_ID = 16;
2
- export function isWorldCup(raw) {
3
- return raw?.tournament?.uniqueTournament?.id === WORLD_CUP_ID;
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
- if (t === 'inprogress') {
8
- return raw.status?.description === 'Halftime' ? 'halftime' : 'live';
9
- }
10
- if (t === 'finished')
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
- export function toMinute(raw, nowSeconds) {
15
- const time = raw?.time;
16
- if (raw?.status?.type !== 'inprogress' || !time?.currentPeriodStartTimestamp)
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
- if (raw.status?.description === 'Halftime')
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 played = (time.initial ?? 0) + (nowSeconds - time.currentPeriodStartTimestamp);
21
- // football minutes are 1-based: 0–59s of play is the 1st minute
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, nowSeconds) {
26
- const group = /Group [A-L]/.exec(raw?.tournament?.name ?? '')?.[0] ?? null;
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: { name: raw.homeTeam?.name ?? '?', code: raw.homeTeam?.nameCode ?? raw.homeTeam?.shortName ?? '' },
31
- away: { name: raw.awayTeam?.name ?? '?', code: raw.awayTeam?.nameCode ?? raw.awayTeam?.shortName ?? '' },
32
- homeScore: raw.homeScore?.current ?? null,
33
- awayScore: raw.awayScore?.current ?? null,
34
- status: toStatus(raw),
35
- statusText: raw.status?.description ?? '',
36
- minute: toMinute(raw, nowSeconds),
37
- startTimestamp: raw.startTimestamp ?? 0,
38
- // live feed sends an object ({ homeTeam, awayTeam }); be tolerant of a plain boolean too
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
- /** "goalNotAwarded" → "Goal not awarded" */
45
- function humanize(klass) {
46
- if (!klass)
47
- return '';
48
- const words = klass.replace(/([A-Z])/g, ' $1').toLowerCase().trim();
49
- return words.charAt(0).toUpperCase() + words.slice(1);
50
- }
51
- function toKind(i) {
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 toDetail(i, kind) {
74
- if (kind === 'var')
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
- if (kind === 'substitution')
84
- return i.playerOut?.name ? `for ${i.playerOut.name}` : null;
85
- if (kind === 'injuryTime')
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 i of raw?.incidents ?? []) {
94
- const kind = toKind(i);
83
+ for (const det of details) {
84
+ const kind = kindOf(det);
95
85
  if (!kind)
96
86
  continue;
97
- const player = kind === 'substitution' ? i.playerIn : i.player;
87
+ const athlete = det?.athletesInvolved?.[0];
98
88
  out.push({
99
- // fallback id must be stable across polls (the diff engine dedupes on it),
100
- // so derive it from incident content, never from array position
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: i.time ?? null,
104
- player: player?.name ?? null,
105
- playerShort: player?.shortName ?? player?.name ?? null,
106
- detail: toDetail(i, kind),
107
- isHome: !!i.isHome,
108
- homeScore: kind === 'goal' ? i.homeScore ?? null : null,
109
- awayScore: kind === 'goal' ? i.awayScore ?? null : null,
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
- // API returns newest first; we want chronological
113
- return out.reverse();
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/sofascore.js';
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 SofaScore. Check your connection and try again.');
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 SofaScore. Check your connection and try again.');
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
@@ -1,4 +1,4 @@
1
- import { fetchLive, fetchRecent, fetchUpcoming } from './api/sofascore.js';
1
+ import { fetchLive, fetchRecent, fetchUpcoming } from './api/espn.js';
2
2
  import { formatKickoff } from './ui/UpcomingSection.js';
3
3
  function line(m) {
4
4
  if (m.status === 'upcoming')
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/sofascore.js';
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.1.2",
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"
@@ -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
- }