soccer-cli 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/LICENSE +21 -0
- package/README.md +74 -0
- package/dist/index.js +802 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SpunkySarb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# ⚽ soccer-cli
|
|
2
|
+
|
|
3
|
+
Live football scores, **ball-by-ball commentary**, and a **live ASCII pitch** — right in your terminal. Built for the 2026 World Cup, works for every major league. **No API key required.**
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
⚽ FIFA WORLD CUP 2026 ● 1 LIVE · ⟳15s
|
|
7
|
+
MATCHES
|
|
8
|
+
───────────────────────────────────────────────────────────
|
|
9
|
+
▸ 🇲🇽 Mexico 2-1 South Africa 🇿🇦 ● 67'
|
|
10
|
+
🇰🇷 South Korea 0-0 Czechia 🇨🇿 31'
|
|
11
|
+
|
|
12
|
+
🇲🇽 Mexico 2 : 1 South Africa 🇿🇦 ● 67'
|
|
13
|
+
58% Possession 42% · 12 Shots 7 · 5 On target 3
|
|
14
|
+
╭────────────────────────────────────────────────────────╮
|
|
15
|
+
│ ·····┆ ★ ┆ ┆····· │
|
|
16
|
+
│▐ ★ ┆ ● ( • ) ┆ ▌│
|
|
17
|
+
│ ·····┆ ┆ ┆····· │
|
|
18
|
+
╰────────────────────────────────────────────────────────╯
|
|
19
|
+
● Mexico → ← South Africa ● 67' ★ GOAL
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install -g soccer-cli
|
|
26
|
+
soccer
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
…or run it without installing:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx soccer-cli
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
soccer # FIFA World Cup (default)
|
|
39
|
+
soccer --league eng.1 # Premier League
|
|
40
|
+
soccer --date 20260611 # a specific day's fixtures
|
|
41
|
+
soccer --once # print one snapshot and exit (great for piping)
|
|
42
|
+
soccer --help
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Keys
|
|
46
|
+
|
|
47
|
+
| Key | Action |
|
|
48
|
+
|-----|--------|
|
|
49
|
+
| `↑` / `↓` (or `j` / `k`) | Move between matches |
|
|
50
|
+
| `p` | Toggle the live pitch |
|
|
51
|
+
| `r` | Force refresh |
|
|
52
|
+
| `q` / `Esc` | Quit |
|
|
53
|
+
|
|
54
|
+
### Leagues
|
|
55
|
+
|
|
56
|
+
Any ESPN soccer slug works — a few common ones:
|
|
57
|
+
|
|
58
|
+
`fifa.world` (World Cup) · `eng.1` (Premier League) · `esp.1` (La Liga) · `ger.1` (Bundesliga) · `ita.1` (Serie A) · `fra.1` (Ligue 1) · `uefa.champions` · `usa.1` (MLS) · `bra.1` (Brasileirão)
|
|
59
|
+
|
|
60
|
+
## What you get
|
|
61
|
+
|
|
62
|
+
- **Live scores** that auto-refresh
|
|
63
|
+
- **Ball-by-ball commentary** in a clean, word-wrapped table
|
|
64
|
+
- **A live pitch** plotting where the action is — goals (★), the latest play (●), and a fading trail of recent events
|
|
65
|
+
- **Match stats** — possession, shots, on-target, corners, fouls
|
|
66
|
+
- **Country flags** for every nation
|
|
67
|
+
|
|
68
|
+
## Data
|
|
69
|
+
|
|
70
|
+
Powered by ESPN's public (unofficial) feed. Not affiliated with ESPN or FIFA. Endpoints are undocumented and may change without notice.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT © [SpunkySarb](https://github.com/SpunkySarb)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/index.ts
|
|
4
|
+
import process2 from "process";
|
|
5
|
+
|
|
6
|
+
// src/app.ts
|
|
7
|
+
import logUpdate from "log-update";
|
|
8
|
+
import readline from "readline";
|
|
9
|
+
import process from "process";
|
|
10
|
+
|
|
11
|
+
// src/flags.ts
|
|
12
|
+
import { createRequire } from "module";
|
|
13
|
+
import countries from "i18n-iso-countries";
|
|
14
|
+
var require2 = createRequire(import.meta.url);
|
|
15
|
+
try {
|
|
16
|
+
countries.registerLocale(require2("i18n-iso-countries/langs/en.json"));
|
|
17
|
+
} catch {
|
|
18
|
+
}
|
|
19
|
+
var WHITE = "\u{1F3F3}\uFE0F";
|
|
20
|
+
var FIFA_TO_ISO2 = {
|
|
21
|
+
// Hosts / CONCACAF
|
|
22
|
+
USA: "US",
|
|
23
|
+
MEX: "MX",
|
|
24
|
+
CAN: "CA",
|
|
25
|
+
CRC: "CR",
|
|
26
|
+
PAN: "PA",
|
|
27
|
+
HON: "HN",
|
|
28
|
+
JAM: "JM",
|
|
29
|
+
SLV: "SV",
|
|
30
|
+
GUA: "GT",
|
|
31
|
+
HAI: "HT",
|
|
32
|
+
TRI: "TT",
|
|
33
|
+
CUB: "CU",
|
|
34
|
+
CUW: "CW",
|
|
35
|
+
SUR: "SR",
|
|
36
|
+
// CONMEBOL
|
|
37
|
+
BRA: "BR",
|
|
38
|
+
ARG: "AR",
|
|
39
|
+
URU: "UY",
|
|
40
|
+
COL: "CO",
|
|
41
|
+
ECU: "EC",
|
|
42
|
+
PAR: "PY",
|
|
43
|
+
CHI: "CL",
|
|
44
|
+
PER: "PE",
|
|
45
|
+
BOL: "BO",
|
|
46
|
+
VEN: "VE",
|
|
47
|
+
// UEFA
|
|
48
|
+
FRA: "FR",
|
|
49
|
+
GER: "DE",
|
|
50
|
+
ESP: "ES",
|
|
51
|
+
POR: "PT",
|
|
52
|
+
NED: "NL",
|
|
53
|
+
ITA: "IT",
|
|
54
|
+
BEL: "BE",
|
|
55
|
+
CRO: "HR",
|
|
56
|
+
SUI: "CH",
|
|
57
|
+
DEN: "DK",
|
|
58
|
+
POL: "PL",
|
|
59
|
+
SRB: "RS",
|
|
60
|
+
AUT: "AT",
|
|
61
|
+
CZE: "CZ",
|
|
62
|
+
SWE: "SE",
|
|
63
|
+
NOR: "NO",
|
|
64
|
+
UKR: "UA",
|
|
65
|
+
TUR: "TR",
|
|
66
|
+
GRE: "GR",
|
|
67
|
+
HUN: "HU",
|
|
68
|
+
ROU: "RO",
|
|
69
|
+
RUS: "RU",
|
|
70
|
+
SVK: "SK",
|
|
71
|
+
SVN: "SI",
|
|
72
|
+
FIN: "FI",
|
|
73
|
+
ISL: "IS",
|
|
74
|
+
ALB: "AL",
|
|
75
|
+
BIH: "BA",
|
|
76
|
+
BUL: "BG",
|
|
77
|
+
MKD: "MK",
|
|
78
|
+
GEO: "GE",
|
|
79
|
+
IRL: "IE",
|
|
80
|
+
LUX: "LU",
|
|
81
|
+
MNE: "ME",
|
|
82
|
+
KVX: "XK",
|
|
83
|
+
// CAF
|
|
84
|
+
RSA: "ZA",
|
|
85
|
+
MAR: "MA",
|
|
86
|
+
SEN: "SN",
|
|
87
|
+
NGA: "NG",
|
|
88
|
+
EGY: "EG",
|
|
89
|
+
CMR: "CM",
|
|
90
|
+
GHA: "GH",
|
|
91
|
+
ALG: "DZ",
|
|
92
|
+
TUN: "TN",
|
|
93
|
+
CIV: "CI",
|
|
94
|
+
MLI: "ML",
|
|
95
|
+
BFA: "BF",
|
|
96
|
+
COD: "CD",
|
|
97
|
+
ANG: "AO",
|
|
98
|
+
CPV: "CV",
|
|
99
|
+
GUI: "GN",
|
|
100
|
+
GAB: "GA",
|
|
101
|
+
ZAM: "ZM",
|
|
102
|
+
KEN: "KE",
|
|
103
|
+
UGA: "UG",
|
|
104
|
+
NAM: "NA",
|
|
105
|
+
MTN: "MR",
|
|
106
|
+
EQG: "GQ",
|
|
107
|
+
BEN: "BJ",
|
|
108
|
+
MOZ: "MZ",
|
|
109
|
+
// AFC
|
|
110
|
+
JPN: "JP",
|
|
111
|
+
KOR: "KR",
|
|
112
|
+
KSA: "SA",
|
|
113
|
+
IRN: "IR",
|
|
114
|
+
AUS: "AU",
|
|
115
|
+
QAT: "QA",
|
|
116
|
+
IRQ: "IQ",
|
|
117
|
+
UAE: "AE",
|
|
118
|
+
UZB: "UZ",
|
|
119
|
+
JOR: "JO",
|
|
120
|
+
CHN: "CN",
|
|
121
|
+
OMA: "OM",
|
|
122
|
+
BHR: "BH",
|
|
123
|
+
SYR: "SY",
|
|
124
|
+
PRK: "KP",
|
|
125
|
+
THA: "TH",
|
|
126
|
+
VIE: "VN",
|
|
127
|
+
IDN: "ID",
|
|
128
|
+
IND: "IN",
|
|
129
|
+
LBN: "LB",
|
|
130
|
+
KGZ: "KG",
|
|
131
|
+
// OFC
|
|
132
|
+
NZL: "NZ",
|
|
133
|
+
NCL: "NC",
|
|
134
|
+
FIJ: "FJ",
|
|
135
|
+
TAH: "PF"
|
|
136
|
+
};
|
|
137
|
+
var SPECIAL = {
|
|
138
|
+
ENG: "\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}",
|
|
139
|
+
// England
|
|
140
|
+
SCO: "\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}",
|
|
141
|
+
// Scotland
|
|
142
|
+
WAL: "\u{1F3F4}\u{E0067}\u{E0062}\u{E0077}\u{E006C}\u{E0073}\u{E007F}"
|
|
143
|
+
// Wales
|
|
144
|
+
};
|
|
145
|
+
function iso2ToFlag(iso2) {
|
|
146
|
+
const up = iso2.toUpperCase();
|
|
147
|
+
if (up.length !== 2) return "";
|
|
148
|
+
const base = 127462;
|
|
149
|
+
return String.fromCodePoint(base + (up.charCodeAt(0) - 65)) + String.fromCodePoint(base + (up.charCodeAt(1) - 65));
|
|
150
|
+
}
|
|
151
|
+
function flagFor(code, name) {
|
|
152
|
+
const cc = code?.toUpperCase();
|
|
153
|
+
if (cc && SPECIAL[cc]) return SPECIAL[cc];
|
|
154
|
+
if (cc && FIFA_TO_ISO2[cc]) return iso2ToFlag(FIFA_TO_ISO2[cc]);
|
|
155
|
+
if (name) {
|
|
156
|
+
const a2 = countries.getAlpha2Code(name, "en");
|
|
157
|
+
if (a2) return iso2ToFlag(a2);
|
|
158
|
+
}
|
|
159
|
+
return WHITE;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/espn.ts
|
|
163
|
+
var BASE = "https://site.api.espn.com/apis/site/v2/sports/soccer";
|
|
164
|
+
var HEADERS = { "User-Agent": "worldcup-cli", Accept: "application/json" };
|
|
165
|
+
var EspnError = class extends Error {
|
|
166
|
+
};
|
|
167
|
+
async function getJson(url, signal) {
|
|
168
|
+
let res;
|
|
169
|
+
try {
|
|
170
|
+
res = await fetch(url, { headers: HEADERS, signal });
|
|
171
|
+
} catch (e) {
|
|
172
|
+
if (e?.name === "AbortError") throw e;
|
|
173
|
+
throw new EspnError(`Network error: ${e?.message ?? e}`);
|
|
174
|
+
}
|
|
175
|
+
if (!res.ok) throw new EspnError(`ESPN ${res.status} ${res.statusText}`);
|
|
176
|
+
return res.json();
|
|
177
|
+
}
|
|
178
|
+
function num(s) {
|
|
179
|
+
if (s === null || s === void 0 || s === "") return null;
|
|
180
|
+
const n = Number(s);
|
|
181
|
+
return Number.isFinite(n) ? n : null;
|
|
182
|
+
}
|
|
183
|
+
function toState(s) {
|
|
184
|
+
return s === "in" ? "in" : s === "post" ? "post" : "pre";
|
|
185
|
+
}
|
|
186
|
+
var STATE_ORDER = { in: 0, pre: 1, post: 2 };
|
|
187
|
+
function parseSide(c2) {
|
|
188
|
+
const t = c2?.team ?? {};
|
|
189
|
+
const abbr = t.abbreviation ?? t.shortDisplayName ?? "???";
|
|
190
|
+
return {
|
|
191
|
+
id: String(t.id ?? ""),
|
|
192
|
+
abbr,
|
|
193
|
+
name: t.displayName ?? t.name ?? abbr,
|
|
194
|
+
shortName: t.shortDisplayName ?? t.name ?? abbr,
|
|
195
|
+
score: num(c2?.score),
|
|
196
|
+
homeAway: c2?.homeAway === "away" ? "away" : "home",
|
|
197
|
+
flag: flagFor(abbr, t.displayName ?? t.name)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function parseMatch(e) {
|
|
201
|
+
const comp = e.competitions?.[0] ?? {};
|
|
202
|
+
const comps = comp.competitors ?? [];
|
|
203
|
+
const home = parseSide(comps.find((c2) => c2.homeAway === "home") ?? comps[0] ?? {});
|
|
204
|
+
const away = parseSide(comps.find((c2) => c2.homeAway === "away") ?? comps[1] ?? {});
|
|
205
|
+
const st = e.status?.type ?? {};
|
|
206
|
+
return {
|
|
207
|
+
id: String(e.id),
|
|
208
|
+
date: e.date ?? "",
|
|
209
|
+
state: toState(st.state),
|
|
210
|
+
clock: e.status?.displayClock ?? "",
|
|
211
|
+
shortDetail: st.shortDetail ?? st.description ?? "",
|
|
212
|
+
detail: st.detail ?? "",
|
|
213
|
+
completed: !!st.completed,
|
|
214
|
+
home,
|
|
215
|
+
away
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async function fetchScoreboard(league2, dateYYYYMMDD, signal) {
|
|
219
|
+
const q = dateYYYYMMDD ? `?dates=${dateYYYYMMDD}` : "";
|
|
220
|
+
const data = await getJson(`${BASE}/${league2}/scoreboard${q}`, signal);
|
|
221
|
+
const matches = (data.events ?? []).map(parseMatch);
|
|
222
|
+
matches.sort((a, b) => {
|
|
223
|
+
const s = STATE_ORDER[a.state] - STATE_ORDER[b.state];
|
|
224
|
+
if (s !== 0) return s;
|
|
225
|
+
return (a.date || "").localeCompare(b.date || "");
|
|
226
|
+
});
|
|
227
|
+
return matches;
|
|
228
|
+
}
|
|
229
|
+
function classifyEvent(typeText, scoringPlay) {
|
|
230
|
+
const t = typeText.toLowerCase();
|
|
231
|
+
if (t.includes("own goal")) return "own-goal";
|
|
232
|
+
if (t.includes("penalty") && /miss|saved|no goal|off target/.test(t)) return "penalty-miss";
|
|
233
|
+
if (t.includes("penalty") && (scoringPlay || t.includes("scored") || t.includes("goal"))) return "penalty-goal";
|
|
234
|
+
if (t.includes("goal") || scoringPlay) return "goal";
|
|
235
|
+
if (t.includes("yellow") && t.includes("red")) return "yellow-red";
|
|
236
|
+
if (t.includes("red")) return "red";
|
|
237
|
+
if (t.includes("yellow")) return "yellow";
|
|
238
|
+
if (t.includes("substitution")) return "sub";
|
|
239
|
+
if (t.includes("var")) return "var";
|
|
240
|
+
return "other";
|
|
241
|
+
}
|
|
242
|
+
var WANTED_STATS = [
|
|
243
|
+
{ name: "possessionPct", label: "Possession" },
|
|
244
|
+
{ name: "totalShots", label: "Shots" },
|
|
245
|
+
{ name: "shotsOnTarget", label: "On target" },
|
|
246
|
+
{ name: "wonCorners", label: "Corners" },
|
|
247
|
+
{ name: "foulsCommitted", label: "Fouls" },
|
|
248
|
+
{ name: "saves", label: "Saves" }
|
|
249
|
+
];
|
|
250
|
+
function fmtStat(name, v) {
|
|
251
|
+
if (v === void 0 || v === null) return "-";
|
|
252
|
+
if (name === "possessionPct") return `${v}%`;
|
|
253
|
+
return String(v);
|
|
254
|
+
}
|
|
255
|
+
function isGoalText(text) {
|
|
256
|
+
return /goal!/i.test(text) && !/disallowed|no goal|chalked off|ruled out/i.test(text);
|
|
257
|
+
}
|
|
258
|
+
async function fetchSummary(league2, eventId, signal) {
|
|
259
|
+
const data = await getJson(`${BASE}/${league2}/summary?event=${eventId}`, signal);
|
|
260
|
+
const hcomp = data.header?.competitions?.[0] ?? {};
|
|
261
|
+
const hcomps = hcomp.competitors ?? [];
|
|
262
|
+
const home = parseSide(hcomps.find((c2) => c2.homeAway === "home") ?? hcomps[0] ?? {});
|
|
263
|
+
const away = parseSide(hcomps.find((c2) => c2.homeAway === "away") ?? hcomps[1] ?? {});
|
|
264
|
+
const st = hcomp.status?.type ?? {};
|
|
265
|
+
const commentary = (data.commentary ?? []).map((c2) => {
|
|
266
|
+
const play = c2.play ?? {};
|
|
267
|
+
const px = play.fieldPositionX;
|
|
268
|
+
const py = play.fieldPositionY;
|
|
269
|
+
const hasPos = typeof px === "number" && typeof py === "number" && !(px === 0 && py === 0);
|
|
270
|
+
return {
|
|
271
|
+
sequence: Number(c2.sequence ?? 0),
|
|
272
|
+
time: c2.time?.displayValue ?? "",
|
|
273
|
+
text: c2.text ?? "",
|
|
274
|
+
isGoal: isGoalText(c2.text ?? ""),
|
|
275
|
+
x: hasPos ? px : void 0,
|
|
276
|
+
y: hasPos ? py : void 0,
|
|
277
|
+
teamId: play.team?.id ? String(play.team.id) : void 0,
|
|
278
|
+
playType: play.type?.text
|
|
279
|
+
};
|
|
280
|
+
}).sort((a, b) => b.sequence - a.sequence);
|
|
281
|
+
const keyEvents = (data.keyEvents ?? []).map((k) => {
|
|
282
|
+
const typeText = k.type?.text ?? "";
|
|
283
|
+
return {
|
|
284
|
+
id: String(k.id ?? ""),
|
|
285
|
+
kind: classifyEvent(typeText, !!k.scoringPlay),
|
|
286
|
+
typeText,
|
|
287
|
+
clock: k.clock?.displayValue ?? "",
|
|
288
|
+
teamId: k.team?.id ? String(k.team.id) : void 0,
|
|
289
|
+
team: k.team?.displayName,
|
|
290
|
+
player: k.participants?.[0]?.athlete?.displayName,
|
|
291
|
+
text: k.text ?? "",
|
|
292
|
+
scoringPlay: !!k.scoringPlay
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
const teams = data.boxscore?.teams ?? [];
|
|
296
|
+
const statsOf = (side, ha) => {
|
|
297
|
+
const byId = teams.find((t) => String(t.team?.id ?? "") === side.id);
|
|
298
|
+
if (byId) return byId.statistics ?? [];
|
|
299
|
+
const byHa = teams.find((t) => t.homeAway === ha);
|
|
300
|
+
return byHa?.statistics ?? [];
|
|
301
|
+
};
|
|
302
|
+
const homeStats = statsOf(home, "home");
|
|
303
|
+
const awayStats = statsOf(away, "away");
|
|
304
|
+
const val = (arr, name) => arr.find((s) => s.name === name)?.displayValue;
|
|
305
|
+
const stats = [];
|
|
306
|
+
for (const w of WANTED_STATS) {
|
|
307
|
+
const h = val(homeStats, w.name);
|
|
308
|
+
const a = val(awayStats, w.name);
|
|
309
|
+
if (h === void 0 && a === void 0) continue;
|
|
310
|
+
stats.push({ label: w.label, home: fmtStat(w.name, h), away: fmtStat(w.name, a) });
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
id: String(eventId),
|
|
314
|
+
state: toState(st.state),
|
|
315
|
+
clock: hcomp.status?.displayClock ?? "",
|
|
316
|
+
shortDetail: st.shortDetail ?? "",
|
|
317
|
+
detail: st.detail ?? "",
|
|
318
|
+
venue: data.gameInfo?.venue?.fullName,
|
|
319
|
+
home,
|
|
320
|
+
away,
|
|
321
|
+
commentary,
|
|
322
|
+
keyEvents,
|
|
323
|
+
stats
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// src/render.ts
|
|
328
|
+
import Table from "cli-table3";
|
|
329
|
+
import pc3 from "picocolors";
|
|
330
|
+
import stringWidth2 from "string-width";
|
|
331
|
+
|
|
332
|
+
// src/theme.ts
|
|
333
|
+
import pc from "picocolors";
|
|
334
|
+
var SYM = {
|
|
335
|
+
live: "\u25CF",
|
|
336
|
+
goal: "\u26BD",
|
|
337
|
+
yellow: "\u{1F7E8}",
|
|
338
|
+
// 🟨
|
|
339
|
+
red: "\u{1F7E5}",
|
|
340
|
+
// 🟥
|
|
341
|
+
sub: "\u{1F501}",
|
|
342
|
+
// 🔁
|
|
343
|
+
var: "\u{1F4FA}",
|
|
344
|
+
// 📺
|
|
345
|
+
dot: "\u2022",
|
|
346
|
+
refresh: "\u27F3",
|
|
347
|
+
ball: "\u26BD",
|
|
348
|
+
sel: "\u25B8"
|
|
349
|
+
};
|
|
350
|
+
var c = {
|
|
351
|
+
title: (s) => pc.bold(pc.magenta(s)),
|
|
352
|
+
live: (s) => pc.green(s),
|
|
353
|
+
finished: (s) => pc.cyan(s),
|
|
354
|
+
upcoming: (s) => pc.gray(s),
|
|
355
|
+
score: (s) => pc.bold(pc.white(s)),
|
|
356
|
+
dim: (s) => pc.gray(s),
|
|
357
|
+
goal: (s) => pc.bold(pc.green(s)),
|
|
358
|
+
yellow: (s) => pc.yellow(s),
|
|
359
|
+
red: (s) => pc.bold(pc.red(s)),
|
|
360
|
+
accent: (s) => pc.magenta(s),
|
|
361
|
+
sel: (s) => pc.bold(pc.magenta(s))
|
|
362
|
+
};
|
|
363
|
+
function commentaryColor(text, isGoal) {
|
|
364
|
+
const t = text.toLowerCase();
|
|
365
|
+
if (isGoal) return c.goal;
|
|
366
|
+
if (t.includes("red card")) return c.red;
|
|
367
|
+
if (t.includes("yellow card") || t.includes("booked")) return c.yellow;
|
|
368
|
+
if (t.includes("penalty")) return c.accent;
|
|
369
|
+
if (/(half time|full time|first half|second half|kick-?off|kicks off|begins|ends|lineups|added time)/.test(t)) {
|
|
370
|
+
return c.dim;
|
|
371
|
+
}
|
|
372
|
+
if (t.includes("substitution") || t.includes("replaces")) return pc.cyan;
|
|
373
|
+
return (s) => s;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/format.ts
|
|
377
|
+
function truncate(s, max) {
|
|
378
|
+
if (max <= 1) return s.slice(0, Math.max(0, max));
|
|
379
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
380
|
+
}
|
|
381
|
+
function kickoffLocal(iso) {
|
|
382
|
+
if (!iso) return "";
|
|
383
|
+
const d = new Date(iso);
|
|
384
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
385
|
+
return d.toLocaleString(void 0, {
|
|
386
|
+
weekday: "short",
|
|
387
|
+
hour: "numeric",
|
|
388
|
+
minute: "2-digit"
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/pitch.ts
|
|
393
|
+
import pc2 from "picocolors";
|
|
394
|
+
import stringWidth from "string-width";
|
|
395
|
+
var HOME = pc2.cyan;
|
|
396
|
+
var AWAY = pc2.magenta;
|
|
397
|
+
var LINE = (s) => pc2.gray(s);
|
|
398
|
+
function mapEvent(x, y, isHome, IW, IH) {
|
|
399
|
+
const xAbs = isHome ? 1 - x : x;
|
|
400
|
+
const yAbs = isHome ? y : 1 - y;
|
|
401
|
+
const col = Math.max(0, Math.min(IW - 1, Math.round(xAbs * (IW - 1))));
|
|
402
|
+
const row = Math.max(0, Math.min(IH - 1, Math.round(yAbs * (IH - 1))));
|
|
403
|
+
return { col, row };
|
|
404
|
+
}
|
|
405
|
+
function renderPitch(detail, innerWidth) {
|
|
406
|
+
const IW = Math.max(34, Math.min(innerWidth - 4, 60));
|
|
407
|
+
const IH = 9;
|
|
408
|
+
const grid = Array.from({ length: IH }, () => Array.from({ length: IW }, () => " "));
|
|
409
|
+
const rmid = Math.floor(IH / 2);
|
|
410
|
+
const cmid = Math.floor(IW / 2);
|
|
411
|
+
const boxW = 6;
|
|
412
|
+
for (let r = 0; r < IH; r++) grid[r][cmid] = LINE("\u2506");
|
|
413
|
+
if (cmid - 3 > boxW) {
|
|
414
|
+
grid[rmid][cmid - 3] = LINE("(");
|
|
415
|
+
grid[rmid][cmid + 3] = LINE(")");
|
|
416
|
+
}
|
|
417
|
+
grid[rmid][cmid] = LINE("\u2022");
|
|
418
|
+
const r1 = rmid - 2;
|
|
419
|
+
const r2 = rmid + 2;
|
|
420
|
+
for (let r = r1; r <= r2; r++) {
|
|
421
|
+
if (r < 0 || r >= IH) continue;
|
|
422
|
+
grid[r][boxW] = LINE("\u2506");
|
|
423
|
+
grid[r][IW - 1 - boxW] = LINE("\u2506");
|
|
424
|
+
}
|
|
425
|
+
for (let cc = 1; cc < boxW; cc++) {
|
|
426
|
+
if (r1 >= 0) {
|
|
427
|
+
if (grid[r1][cc] === " ") grid[r1][cc] = LINE("\xB7");
|
|
428
|
+
if (grid[r1][IW - 1 - cc] === " ") grid[r1][IW - 1 - cc] = LINE("\xB7");
|
|
429
|
+
}
|
|
430
|
+
if (r2 < IH) {
|
|
431
|
+
if (grid[r2][cc] === " ") grid[r2][cc] = LINE("\xB7");
|
|
432
|
+
if (grid[r2][IW - 1 - cc] === " ") grid[r2][IW - 1 - cc] = LINE("\xB7");
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
for (let r = rmid - 1; r <= rmid + 1; r++) {
|
|
436
|
+
if (r < 0 || r >= IH) continue;
|
|
437
|
+
grid[r][0] = LINE("\u2590");
|
|
438
|
+
grid[r][IW - 1] = LINE("\u258C");
|
|
439
|
+
}
|
|
440
|
+
const events = detail.commentary.filter((c2) => c2.x != null && c2.y != null);
|
|
441
|
+
const homeId = detail.home.id;
|
|
442
|
+
const plot = (e, ch) => {
|
|
443
|
+
const { col, row } = mapEvent(e.x, e.y, e.teamId === homeId, IW, IH);
|
|
444
|
+
grid[row][col] = ch;
|
|
445
|
+
};
|
|
446
|
+
const recent = events.slice(0, 7);
|
|
447
|
+
for (let i = recent.length - 1; i >= 1; i--) {
|
|
448
|
+
const e = recent[i];
|
|
449
|
+
plot(e, (e.teamId === homeId ? HOME : AWAY)(pc2.dim("\u2219")));
|
|
450
|
+
}
|
|
451
|
+
for (const g of events.filter((e) => e.isGoal)) plot(g, pc2.bold(pc2.green("\u2605")));
|
|
452
|
+
const newest = recent[0];
|
|
453
|
+
if (newest) {
|
|
454
|
+
plot(newest, newest.isGoal ? pc2.bold(pc2.green("\u2605")) : pc2.bold((newest.teamId === homeId ? HOME : AWAY)("\u25CF")));
|
|
455
|
+
}
|
|
456
|
+
const top = LINE(` \u256D${"\u2500".repeat(IW)}\u256E`);
|
|
457
|
+
const bot = LINE(` \u2570${"\u2500".repeat(IW)}\u256F`);
|
|
458
|
+
const body = grid.map((r) => `${LINE(" \u2502")}${r.join("")}${LINE("\u2502")}`);
|
|
459
|
+
const latest = events[0];
|
|
460
|
+
let legend = ` ${HOME("\u25CF")} ${detail.home.flag} ${pc2.bold(truncate(detail.home.name, 16))} ${pc2.gray("\u2192")} ${pc2.gray("\u2190")} ${pc2.bold(truncate(detail.away.name, 16))} ${detail.away.flag} ${AWAY("\u25CF")}`;
|
|
461
|
+
if (latest) {
|
|
462
|
+
const tag = latest.isGoal ? pc2.bold(pc2.green("\u2605 GOAL")) : pc2.gray(latest.playType || "ball");
|
|
463
|
+
legend += ` ${pc2.gray(latest.time)} ${tag}`;
|
|
464
|
+
}
|
|
465
|
+
void stringWidth;
|
|
466
|
+
return [top, ...body, bot, legend].join("\n");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/render.ts
|
|
470
|
+
var CHARS = {
|
|
471
|
+
top: "\u2500",
|
|
472
|
+
"top-mid": "\u252C",
|
|
473
|
+
"top-left": "\u256D",
|
|
474
|
+
"top-right": "\u256E",
|
|
475
|
+
bottom: "\u2500",
|
|
476
|
+
"bottom-mid": "\u2534",
|
|
477
|
+
"bottom-left": "\u2570",
|
|
478
|
+
"bottom-right": "\u256F",
|
|
479
|
+
left: "\u2502",
|
|
480
|
+
"left-mid": "\u251C",
|
|
481
|
+
mid: "\u2500",
|
|
482
|
+
"mid-mid": "\u253C",
|
|
483
|
+
right: "\u2502",
|
|
484
|
+
"right-mid": "\u2524",
|
|
485
|
+
middle: "\u2502"
|
|
486
|
+
};
|
|
487
|
+
function makeTable(opts) {
|
|
488
|
+
return new Table({
|
|
489
|
+
chars: CHARS,
|
|
490
|
+
style: { head: [], border: ["gray"], "padding-left": 1, "padding-right": 1 },
|
|
491
|
+
...opts
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
function padEndW(s, w) {
|
|
495
|
+
const d = w - stringWidth2(s);
|
|
496
|
+
return d > 0 ? s + " ".repeat(d) : s;
|
|
497
|
+
}
|
|
498
|
+
function padCenterW(s, w) {
|
|
499
|
+
const d = Math.max(0, w - stringWidth2(s));
|
|
500
|
+
const left = Math.floor(d / 2);
|
|
501
|
+
return " ".repeat(left) + s + " ".repeat(d - left);
|
|
502
|
+
}
|
|
503
|
+
function headerLine(s, width) {
|
|
504
|
+
const left = c.title(`${SYM.ball} ${s.title}`);
|
|
505
|
+
const live = s.matches.filter((m) => m.state === "in").length;
|
|
506
|
+
const liveTxt = live > 0 ? `${c.live(`${SYM.live} ${live} LIVE`)} ` : "";
|
|
507
|
+
const upd = s.updatedAt ? new Date(s.updatedAt).toLocaleTimeString() : "\u2014";
|
|
508
|
+
const cd = s.countdown != null ? ` ${SYM.refresh}${s.countdown}s` : "";
|
|
509
|
+
const right = `${liveTxt}${pc3.gray(`${s.matches.length} matches \xB7 ${upd}${cd}`)}`;
|
|
510
|
+
const pad = Math.max(1, width - stringWidth2(left) - stringWidth2(right));
|
|
511
|
+
return `${left}${" ".repeat(pad)}${right}`;
|
|
512
|
+
}
|
|
513
|
+
function fixturesList(s, width) {
|
|
514
|
+
const NAME = 14;
|
|
515
|
+
const COLW = NAME + 3;
|
|
516
|
+
const lines = [` ${pc3.bold(pc3.gray("MATCHES"))}`, pc3.gray(` ${"\u2500".repeat(Math.min(width - 2, 64))}`)];
|
|
517
|
+
s.matches.forEach((m, i) => {
|
|
518
|
+
const sel = i === s.selected;
|
|
519
|
+
const home = padEndW(`${m.home.flag} ${truncate(m.home.name, NAME)}`, COLW);
|
|
520
|
+
const away = padEndW(`${truncate(m.away.name, NAME)} ${m.away.flag}`, COLW);
|
|
521
|
+
const score = m.state === "pre" ? pc3.gray(" v ") : padCenterW(c.score(`${m.home.score ?? 0}-${m.away.score ?? 0}`), 5);
|
|
522
|
+
const status = m.state === "in" ? c.live(`${SYM.live} ${m.clock || "LIVE"}`) : m.state === "post" ? c.finished(m.shortDetail || "FT") : c.upcoming(kickoffLocal(m.date) || "Scheduled");
|
|
523
|
+
const arrow = sel ? c.sel(SYM.sel) : " ";
|
|
524
|
+
const row = ` ${arrow} ${sel ? c.sel(home) : home} ${score} ${sel ? c.sel(away) : away} ${status}`;
|
|
525
|
+
lines.push(row);
|
|
526
|
+
});
|
|
527
|
+
return lines.join("\n");
|
|
528
|
+
}
|
|
529
|
+
function scoreStrip(s, m) {
|
|
530
|
+
const d = s.detail && s.detail.id === m.id ? s.detail : null;
|
|
531
|
+
const home = d?.home ?? m.home;
|
|
532
|
+
const away = d?.away ?? m.away;
|
|
533
|
+
const state = d?.state ?? m.state;
|
|
534
|
+
const clock = d?.clock || m.clock;
|
|
535
|
+
const badge = state === "in" ? c.live(`${SYM.live} ${clock || "LIVE"}`) : state === "post" ? c.finished(d?.shortDetail || m.shortDetail || "FT") : c.upcoming(kickoffLocal(m.date) || "Scheduled");
|
|
536
|
+
const hs = home.score ?? "\u2013";
|
|
537
|
+
const as = away.score ?? "\u2013";
|
|
538
|
+
const hn = pc3.bold(truncate(home.name, 22));
|
|
539
|
+
const an = pc3.bold(truncate(away.name, 22));
|
|
540
|
+
return ` ${home.flag} ${hn} ${c.score(String(hs))} ${pc3.gray(":")} ${c.score(String(as))} ${an} ${away.flag} ${badge}`;
|
|
541
|
+
}
|
|
542
|
+
function statsStrip(d, width) {
|
|
543
|
+
if (!d || d.stats.length === 0) return "";
|
|
544
|
+
const sep = pc3.gray(" \xB7 ");
|
|
545
|
+
let line = " ";
|
|
546
|
+
let count = 0;
|
|
547
|
+
for (const st of d.stats) {
|
|
548
|
+
const piece = `${pc3.white(st.home)} ${pc3.gray(st.label)} ${pc3.white(st.away)}`;
|
|
549
|
+
const next = count === 0 ? line + piece : line + sep + piece;
|
|
550
|
+
if (stringWidth2(next) > width) break;
|
|
551
|
+
line = next;
|
|
552
|
+
count += 1;
|
|
553
|
+
}
|
|
554
|
+
return count > 0 ? line : "";
|
|
555
|
+
}
|
|
556
|
+
function commentaryTable(d, width, budget) {
|
|
557
|
+
const textCol = Math.max(30, width - 9);
|
|
558
|
+
const wrapW = textCol - 2;
|
|
559
|
+
const t = makeTable({
|
|
560
|
+
head: [pc3.gray("Min"), pc3.gray("Commentary")],
|
|
561
|
+
colWidths: [6, textCol],
|
|
562
|
+
wordWrap: true
|
|
563
|
+
});
|
|
564
|
+
let used = 0;
|
|
565
|
+
for (const line of d.commentary) {
|
|
566
|
+
const text = truncate(line.text, wrapW * 2);
|
|
567
|
+
const wrapped = Math.max(1, Math.ceil(stringWidth2(text) / wrapW));
|
|
568
|
+
if (used + wrapped > budget && used > 0) break;
|
|
569
|
+
used += wrapped;
|
|
570
|
+
t.push([c.accent(line.time), commentaryColor(line.text, line.isGoal)(text)]);
|
|
571
|
+
}
|
|
572
|
+
if (used === 0) return pc3.gray(" Awaiting first updates\u2026");
|
|
573
|
+
return t.toString();
|
|
574
|
+
}
|
|
575
|
+
function preNote(m, width) {
|
|
576
|
+
const ko = kickoffLocal(m.date) || m.detail || "TBD";
|
|
577
|
+
return [
|
|
578
|
+
` ${pc3.gray("Kickoff:")} ${pc3.bold(ko)}`,
|
|
579
|
+
` ${pc3.gray(truncate(m.detail || "", width))}`,
|
|
580
|
+
` ${pc3.gray(`Live commentary & stats appear at kickoff ${SYM.ball}`)}`
|
|
581
|
+
].join("\n");
|
|
582
|
+
}
|
|
583
|
+
function footer(s) {
|
|
584
|
+
const pitch = s.showPitch ? "[p] pitch\u2713" : "[p] pitch";
|
|
585
|
+
return pc3.gray(` [\u2191\u2193] navigate \xB7 ${pitch} \xB7 [q] quit \xB7 data: ESPN (unofficial) \xB7 ${s.league}`);
|
|
586
|
+
}
|
|
587
|
+
function renderFrame(s) {
|
|
588
|
+
const W = Math.min((s.cols || 80) - 2, 118);
|
|
589
|
+
if (s.error && s.matches.length === 0) {
|
|
590
|
+
return `${pc3.red(`\u26A0 Could not reach ESPN: ${s.error}`)}
|
|
591
|
+
${pc3.gray("Will keep retrying\u2026")}`;
|
|
592
|
+
}
|
|
593
|
+
if (s.matches.length === 0) {
|
|
594
|
+
return `${headerLine(s, W)}
|
|
595
|
+
${pc3.gray(" No matches found for this view.")}`;
|
|
596
|
+
}
|
|
597
|
+
const cur = s.matches[s.selected] ?? null;
|
|
598
|
+
const out = [headerLine(s, W), "", fixturesList(s, W)];
|
|
599
|
+
if (cur) {
|
|
600
|
+
out.push("", scoreStrip(s, cur));
|
|
601
|
+
const d = s.detail && s.detail.id === cur.id ? s.detail : null;
|
|
602
|
+
if (cur.state === "pre") {
|
|
603
|
+
out.push(preNote(cur, W));
|
|
604
|
+
} else if (s.detailLoading && !d) {
|
|
605
|
+
out.push(pc3.gray(" loading match\u2026"));
|
|
606
|
+
} else if (d) {
|
|
607
|
+
const stats = statsStrip(d, W);
|
|
608
|
+
if (stats) out.push(stats);
|
|
609
|
+
const hasPos = d.commentary.some((cl) => cl.x != null && cl.y != null);
|
|
610
|
+
const pitchH = s.showPitch && hasPos ? 12 : 0;
|
|
611
|
+
if (pitchH) out.push(renderPitch(d, W));
|
|
612
|
+
const overhead = 2 + (s.matches.length + 2) + 2 + (stats ? 1 : 0) + 1 + 4 + pitchH;
|
|
613
|
+
const budget = Math.min(40, Math.max(4, (s.rows || 24) - overhead));
|
|
614
|
+
out.push(commentaryTable(d, W, budget));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (!s.once) out.push(footer(s));
|
|
618
|
+
return out.join("\n");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// src/app.ts
|
|
622
|
+
async function runApp(opts) {
|
|
623
|
+
const boardMs = Math.max(5, opts.refreshSec) * 1e3;
|
|
624
|
+
const detailMs = Math.max(5, Math.min(opts.refreshSec, 12)) * 1e3;
|
|
625
|
+
const state = {
|
|
626
|
+
matches: [],
|
|
627
|
+
detail: null,
|
|
628
|
+
selected: 0,
|
|
629
|
+
error: null,
|
|
630
|
+
updatedAt: null,
|
|
631
|
+
detailLoading: false,
|
|
632
|
+
countdown: null,
|
|
633
|
+
showPitch: true
|
|
634
|
+
};
|
|
635
|
+
const draw = () => logUpdate(
|
|
636
|
+
renderFrame({
|
|
637
|
+
title: opts.title,
|
|
638
|
+
league: opts.league,
|
|
639
|
+
once: opts.once,
|
|
640
|
+
matches: state.matches,
|
|
641
|
+
detail: state.detail,
|
|
642
|
+
selected: state.selected,
|
|
643
|
+
error: state.error,
|
|
644
|
+
updatedAt: state.updatedAt,
|
|
645
|
+
detailLoading: state.detailLoading,
|
|
646
|
+
countdown: state.countdown,
|
|
647
|
+
showPitch: state.showPitch,
|
|
648
|
+
cols: process.stdout.columns ?? (Number(process.env.COLUMNS) || 80),
|
|
649
|
+
rows: process.stdout.rows ?? (Number(process.env.LINES) || 24)
|
|
650
|
+
})
|
|
651
|
+
);
|
|
652
|
+
const loadBoard = async () => {
|
|
653
|
+
try {
|
|
654
|
+
const matches = await fetchScoreboard(opts.league, opts.date);
|
|
655
|
+
state.matches = matches;
|
|
656
|
+
state.error = null;
|
|
657
|
+
state.updatedAt = Date.now();
|
|
658
|
+
if (state.selected > matches.length - 1) state.selected = Math.max(0, matches.length - 1);
|
|
659
|
+
} catch (e) {
|
|
660
|
+
if (e?.name !== "AbortError") state.error = e?.message ?? String(e);
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
let detailReq = 0;
|
|
664
|
+
const loadDetail = async () => {
|
|
665
|
+
const cur = state.matches[state.selected];
|
|
666
|
+
if (!cur) {
|
|
667
|
+
state.detail = null;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const id = cur.id;
|
|
671
|
+
const my = ++detailReq;
|
|
672
|
+
state.detailLoading = true;
|
|
673
|
+
try {
|
|
674
|
+
const detail = await fetchSummary(opts.league, id);
|
|
675
|
+
if (my !== detailReq) return;
|
|
676
|
+
state.detail = detail;
|
|
677
|
+
} catch {
|
|
678
|
+
} finally {
|
|
679
|
+
if (my === detailReq) state.detailLoading = false;
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
await loadBoard();
|
|
683
|
+
const liveIdx = state.matches.findIndex((m) => m.state === "in");
|
|
684
|
+
if (liveIdx >= 0) state.selected = liveIdx;
|
|
685
|
+
await loadDetail();
|
|
686
|
+
draw();
|
|
687
|
+
if (opts.once || !process.stdin.isTTY) {
|
|
688
|
+
logUpdate.done();
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
state.countdown = Math.round(boardMs / 1e3);
|
|
692
|
+
const boardTimer = setInterval(async () => {
|
|
693
|
+
await loadBoard();
|
|
694
|
+
state.countdown = Math.round(boardMs / 1e3);
|
|
695
|
+
draw();
|
|
696
|
+
}, boardMs);
|
|
697
|
+
const detailTimer = setInterval(async () => {
|
|
698
|
+
await loadDetail();
|
|
699
|
+
draw();
|
|
700
|
+
}, detailMs);
|
|
701
|
+
const tick = setInterval(() => {
|
|
702
|
+
if (state.countdown != null) state.countdown = Math.max(0, state.countdown - 1);
|
|
703
|
+
draw();
|
|
704
|
+
}, 1e3);
|
|
705
|
+
const cleanup = () => {
|
|
706
|
+
clearInterval(boardTimer);
|
|
707
|
+
clearInterval(detailTimer);
|
|
708
|
+
clearInterval(tick);
|
|
709
|
+
process.stdin.off("keypress", onKey);
|
|
710
|
+
try {
|
|
711
|
+
process.stdin.setRawMode(false);
|
|
712
|
+
} catch {
|
|
713
|
+
}
|
|
714
|
+
logUpdate.done();
|
|
715
|
+
process.stdout.write("\n");
|
|
716
|
+
process.exit(0);
|
|
717
|
+
};
|
|
718
|
+
const reselect = async (delta) => {
|
|
719
|
+
const next = Math.min(state.matches.length - 1, Math.max(0, state.selected + delta));
|
|
720
|
+
if (next === state.selected) return;
|
|
721
|
+
state.selected = next;
|
|
722
|
+
state.detail = null;
|
|
723
|
+
draw();
|
|
724
|
+
await loadDetail();
|
|
725
|
+
draw();
|
|
726
|
+
};
|
|
727
|
+
const onKey = (_str, key) => {
|
|
728
|
+
if (!key) return;
|
|
729
|
+
if (key.name === "q" || key.name === "escape" || key.ctrl && key.name === "c") {
|
|
730
|
+
cleanup();
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (key.name === "up" || key.name === "k") void reselect(-1);
|
|
734
|
+
else if (key.name === "down" || key.name === "j") void reselect(1);
|
|
735
|
+
else if (key.name === "p") {
|
|
736
|
+
state.showPitch = !state.showPitch;
|
|
737
|
+
draw();
|
|
738
|
+
} else if (key.name === "r") void loadBoard().then(draw);
|
|
739
|
+
};
|
|
740
|
+
readline.emitKeypressEvents(process.stdin);
|
|
741
|
+
process.stdin.setRawMode(true);
|
|
742
|
+
process.stdin.resume();
|
|
743
|
+
process.stdin.on("keypress", onKey);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// bin/index.ts
|
|
747
|
+
var argv = process2.argv.slice(2);
|
|
748
|
+
function opt(name) {
|
|
749
|
+
const i = argv.indexOf(name);
|
|
750
|
+
return i >= 0 ? argv[i + 1] : void 0;
|
|
751
|
+
}
|
|
752
|
+
function flag(name) {
|
|
753
|
+
return argv.includes(name);
|
|
754
|
+
}
|
|
755
|
+
if (flag("--help") || flag("-h")) {
|
|
756
|
+
console.log(`
|
|
757
|
+
\u26BD soccer \u2014 live football scores & commentary in your terminal
|
|
758
|
+
|
|
759
|
+
Usage
|
|
760
|
+
$ soccer [options]
|
|
761
|
+
|
|
762
|
+
Options
|
|
763
|
+
--league <slug> ESPN league slug (default: fifa.world)
|
|
764
|
+
e.g. eng.1, esp.1, ger.1, ita.1, uefa.champions
|
|
765
|
+
--date <YYYYMMDD> Show a specific day's fixtures
|
|
766
|
+
--refresh <sec> Auto-refresh interval (default: 15, min 5)
|
|
767
|
+
--once Print one snapshot and exit (good for piping/cron)
|
|
768
|
+
--title <text> Override the header title
|
|
769
|
+
-h, --help Show this help
|
|
770
|
+
|
|
771
|
+
Keys
|
|
772
|
+
\u2191/\u2193 or j/k Move between matches
|
|
773
|
+
r Force refresh
|
|
774
|
+
q or Esc Quit
|
|
775
|
+
|
|
776
|
+
Examples
|
|
777
|
+
$ soccer # FIFA World Cup, live
|
|
778
|
+
$ soccer --league eng.1 # Premier League
|
|
779
|
+
$ soccer --date 20260611 --once # snapshot of a given day
|
|
780
|
+
|
|
781
|
+
Data: ESPN's public (unofficial) feed. No API key required.
|
|
782
|
+
`);
|
|
783
|
+
process2.exit(0);
|
|
784
|
+
}
|
|
785
|
+
var league = opt("--league") ?? "fifa.world";
|
|
786
|
+
var date = opt("--date");
|
|
787
|
+
var refreshSec = Number(opt("--refresh") ?? "15") || 15;
|
|
788
|
+
var once = flag("--once") || !process2.stdin.isTTY;
|
|
789
|
+
var TITLES = {
|
|
790
|
+
"fifa.world": "FIFA WORLD CUP 2026",
|
|
791
|
+
"eng.1": "PREMIER LEAGUE",
|
|
792
|
+
"esp.1": "LA LIGA",
|
|
793
|
+
"ger.1": "BUNDESLIGA",
|
|
794
|
+
"ita.1": "SERIE A",
|
|
795
|
+
"fra.1": "LIGUE 1",
|
|
796
|
+
"uefa.champions": "CHAMPIONS LEAGUE"
|
|
797
|
+
};
|
|
798
|
+
var title = opt("--title") ?? TITLES[league] ?? league.toUpperCase();
|
|
799
|
+
runApp({ league, title, date, refreshSec, once }).catch((err) => {
|
|
800
|
+
console.error(err);
|
|
801
|
+
process2.exit(1);
|
|
802
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "soccer-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Live football scores, ball-by-ball commentary, and a live ASCII pitch in your terminal. Built for the 2026 World Cup, works for every major league. No API key required.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"soccer": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"dev": "tsx bin/index.ts",
|
|
17
|
+
"build": "tsup",
|
|
18
|
+
"start": "node dist/index.js",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"soccer",
|
|
24
|
+
"football",
|
|
25
|
+
"world-cup",
|
|
26
|
+
"fifa",
|
|
27
|
+
"cli",
|
|
28
|
+
"tui",
|
|
29
|
+
"live-scores",
|
|
30
|
+
"commentary",
|
|
31
|
+
"terminal",
|
|
32
|
+
"espn"
|
|
33
|
+
],
|
|
34
|
+
"author": "SpunkySarb <sarbsagar143@gmail.com>",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/SpunkySarb/soccer-cli.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/SpunkySarb/soccer-cli/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/SpunkySarb/soccer-cli#readme",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"cli-table3": "^0.6.5",
|
|
49
|
+
"i18n-iso-countries": "^7.13.0",
|
|
50
|
+
"log-update": "^6.1.0",
|
|
51
|
+
"picocolors": "^1.1.1",
|
|
52
|
+
"string-width": "^7.2.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^22.10.0",
|
|
56
|
+
"tsup": "^8.3.5",
|
|
57
|
+
"tsx": "^4.19.2",
|
|
58
|
+
"typescript": "^5.7.2"
|
|
59
|
+
}
|
|
60
|
+
}
|