sportsing 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 +160 -12
- package/package.json +6 -2
- package/src/alerts.ts +47 -0
- package/src/api.ts +1 -1
- package/src/click-to-watch.ts +39 -0
- package/src/commands/_lib.ts +28 -12
- package/src/commands/analyze.ts +2 -2
- package/src/commands/ask.ts +1 -1
- package/src/commands/cross.ts +354 -0
- package/src/commands/fav.ts +19 -16
- package/src/commands/league-ai.ts +201 -0
- package/src/commands/league-bracket.ts +150 -0
- package/src/commands/league.ts +839 -0
- package/src/commands/live.ts +57 -42
- package/src/commands/me.ts +1 -1
- package/src/commands/predict.ts +2 -2
- package/src/commands/scorers.ts +1 -1
- package/src/commands/setup.ts +3 -3
- package/src/commands/subscriptions.ts +66 -0
- package/src/commands/table.ts +1 -1
- package/src/commands/watch.ts +19 -11
- package/src/config.ts +166 -28
- package/src/espn.ts +606 -33
- package/src/format.ts +87 -0
- package/src/game-events.ts +239 -0
- package/src/game.ts +51 -0
- package/src/index.ts +35 -16
- package/src/league-ai.ts +359 -0
- package/src/league-alerts.ts +139 -0
- package/src/league-detect.ts +104 -0
- package/src/league-overlay.ts +351 -0
- package/src/league-panels.ts +159 -0
- package/src/overlay.ts +96 -51
- package/src/playoff-bracket.ts +384 -0
- package/src/recap.ts +37 -8
- package/src/season.ts +182 -0
- package/src/sports/fifa.ts +9 -4
- package/src/sports/nba.ts +20 -0
- package/src/sports/nhl.ts +20 -0
- package/src/standings.ts +130 -0
- package/src/stream.ts +49 -8
- package/src/watch-route.ts +95 -0
- package/src/watchability.ts +183 -0
package/src/espn.ts
CHANGED
|
@@ -1,18 +1,68 @@
|
|
|
1
|
-
// ESPN's free, no-key JSON API
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// ESPN's free, no-key JSON API. Originally the stats source for the live 2026
|
|
2
|
+
// World Cup (`soccer/fifa.world`, used by stats / predict / the overlay); now
|
|
3
|
+
// league-parametrized so NBA and NHL reuse the same client. Returns
|
|
4
|
+
// scoreboards, team schedules, team lists, standings, per-team match
|
|
5
|
+
// statistics, and key events.
|
|
4
6
|
//
|
|
5
7
|
// Undocumented/unofficial: the shapes here are observed, not contracted, and
|
|
6
8
|
// could change. All ESPN-specific parsing is contained in this module so a
|
|
7
|
-
// break is a one-file fix. Reuses api.ts's disk cache
|
|
9
|
+
// break is a one-file fix. Reuses api.ts's disk cache; every cache key carries
|
|
10
|
+
// the league so leagues never collide.
|
|
8
11
|
|
|
9
12
|
import { cached, ApiError } from "./api.ts";
|
|
10
13
|
import { c } from "./ansi.ts";
|
|
14
|
+
import type { Broadcast, BroadcastMarket, Game, GameCompetitor, SeasonPhase } from "./game.ts";
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
/** ESPN league paths this client speaks. A closed set — the path is
|
|
17
|
+
* interpolated into request URLs and cache filenames, so no free text. */
|
|
18
|
+
export const LEAGUES = {
|
|
19
|
+
fifa: "soccer/fifa.world",
|
|
20
|
+
nba: "basketball/nba",
|
|
21
|
+
nhl: "hockey/nhl",
|
|
22
|
+
} as const;
|
|
23
|
+
export type League = (typeof LEAGUES)[keyof typeof LEAGUES];
|
|
24
|
+
|
|
25
|
+
/** The default league — every pre-existing (World Cup) call site uses it. */
|
|
26
|
+
export const FIFA: League = LEAGUES.fifa;
|
|
27
|
+
|
|
28
|
+
/** ESPN season types for team schedules (`seasontype=`). 5 is the NBA play-in,
|
|
29
|
+
* which ESPN schedules separately from the postseason proper (3). */
|
|
30
|
+
export const SEASON_TYPES = { preseason: 1, regular: 2, postseason: 3, playIn: 5 } as const;
|
|
31
|
+
export type SeasonType = (typeof SEASON_TYPES)[keyof typeof SEASON_TYPES];
|
|
32
|
+
|
|
33
|
+
const SITE = "https://site.api.espn.com/apis/site/v2/sports";
|
|
34
|
+
// Standings live on a different path: `site/v2/.../standings` only returns a
|
|
35
|
+
// `fullViewLink` stub; the real tables are under `apis/v2`.
|
|
36
|
+
const STANDINGS_BASE = "https://site.api.espn.com/apis/v2/sports";
|
|
37
|
+
|
|
38
|
+
/** Full URL for a `site/v2` endpoint under `league`, e.g. `scoreboard?dates=…`. */
|
|
39
|
+
export function espnUrl(league: League, path: string): string {
|
|
40
|
+
return `${SITE}/${league}/${path}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Disk-cache key for an ESPN response: always includes the league, and is
|
|
44
|
+
* filename-safe (ids come from user terms / API data, never trusted as paths). */
|
|
45
|
+
export function espnCacheKey(league: League, kind: string, id: string | number = ""): string {
|
|
46
|
+
const safe = (s: string) => s.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
47
|
+
return ["espn", safe(league), kind, safe(String(id))].filter(Boolean).join("_");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ESPN's edge (Akamai) 403s Bun's default `Bun/x.y.z` User-Agent (observed
|
|
51
|
+
// 2026-10-04 — every ESPN call failed). Any other UA is served, so name ourselves.
|
|
52
|
+
const HEADERS = { "User-Agent": "sportsing" };
|
|
53
|
+
|
|
54
|
+
/** GET + JSON through the disk cache; non-2xx becomes an ApiError. */
|
|
55
|
+
function fetchEspn<T = any>(url: string, key: string, ttlMs: number, what: string): Promise<T> {
|
|
56
|
+
return cached<T>(key, ttlMs, async () => {
|
|
57
|
+
const res = await fetch(url, { headers: HEADERS });
|
|
58
|
+
if (!res.ok) throw new ApiError(res.status, `ESPN ${what} request failed (HTTP ${res.status}).`);
|
|
59
|
+
return res.json() as Promise<T>;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
13
62
|
|
|
14
63
|
/**
|
|
15
|
-
* Detect a *structurally* wrong scoreboard
|
|
64
|
+
* Detect a *structurally* wrong scoreboard (or team-schedule — same `events`
|
|
65
|
+
* shape) response, for any league. ESPN is unofficial and
|
|
16
66
|
* returns HTTP 200 even when its JSON shape drifts, so the parser's `?? ""`
|
|
17
67
|
* fallbacks would silently degrade to blank stats — indistinguishable from
|
|
18
68
|
* "no data yet". This keys on shape, NOT emptiness: a date with no matches
|
|
@@ -46,6 +96,32 @@ function warnDriftOnce(): void {
|
|
|
46
96
|
console.error(c.yellow("⚠ ESPN data looks off — its format may have changed; it's an unofficial API."));
|
|
47
97
|
}
|
|
48
98
|
|
|
99
|
+
/** Teams-list drift: `sports[0].leagues[0].teams` must be an array whose
|
|
100
|
+
* entries carry a `team` with an id and abbreviation. */
|
|
101
|
+
export function looksOffTeams(raw: any): boolean {
|
|
102
|
+
const teams = raw?.sports?.[0]?.leagues?.[0]?.teams;
|
|
103
|
+
if (!Array.isArray(teams)) return true;
|
|
104
|
+
return teams.some((t: any) => !t?.team?.id || !t?.team?.abbreviation);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Standings drift: either a `children` array of groups (conferences / WC
|
|
108
|
+
* groups — or conferences nesting divisions, with `level=3`) or a top-level
|
|
109
|
+
* `standings`; every leaf group has an `entries` array whose rows name a team.
|
|
110
|
+
* An empty table (offseason) is fine — shape, not emptiness. */
|
|
111
|
+
export function looksOffStandings(raw: any): boolean {
|
|
112
|
+
if (!raw || typeof raw !== "object") return true;
|
|
113
|
+
const groups = Array.isArray(raw.children) ? raw.children : raw.standings ? [raw] : null;
|
|
114
|
+
if (!groups) return true;
|
|
115
|
+
return groups.some(groupLooksOff);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function groupLooksOff(g: any): boolean {
|
|
119
|
+
if (Array.isArray(g?.children) && !g?.standings) return g.children.length === 0 || g.children.some(groupLooksOff);
|
|
120
|
+
const entries = g?.standings?.entries;
|
|
121
|
+
if (!Array.isArray(entries)) return true;
|
|
122
|
+
return !entries.every((e: any) => e?.team?.abbreviation && Array.isArray(e?.stats));
|
|
123
|
+
}
|
|
124
|
+
|
|
49
125
|
/** WC2026 scoreboard search window (YYYYMMDD) — opening day → final. */
|
|
50
126
|
export const TOURNAMENT_START = "20260611";
|
|
51
127
|
export const TOURNAMENT_END = "20260719";
|
|
@@ -74,7 +150,16 @@ export interface EspnTeamStats {
|
|
|
74
150
|
stats: { name: string; label: string; value: string }[];
|
|
75
151
|
}
|
|
76
152
|
|
|
77
|
-
|
|
153
|
+
/** ESPN scores arrive as a string on scoreboards but as `{ value, displayValue }`
|
|
154
|
+
* on team schedules (and `null` before tip-off) — flatten to a string. */
|
|
155
|
+
function scoreOf(raw: any): string {
|
|
156
|
+
if (raw == null) return "";
|
|
157
|
+
if (typeof raw === "object") return String(raw.displayValue ?? raw.value ?? "");
|
|
158
|
+
return String(raw);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One scoreboard / schedule event → EspnEvent. Exported for tests. */
|
|
162
|
+
export function normalizeEvent(e: any): EspnEvent {
|
|
78
163
|
const comp = e.competitions?.[0] ?? {};
|
|
79
164
|
return {
|
|
80
165
|
id: String(e.id),
|
|
@@ -86,20 +171,504 @@ function normalizeEvent(e: any): EspnEvent {
|
|
|
86
171
|
homeAway: c.homeAway,
|
|
87
172
|
name: c.team?.displayName ?? c.team?.name ?? "?",
|
|
88
173
|
abbreviation: c.team?.abbreviation ?? "",
|
|
89
|
-
score: c.score
|
|
174
|
+
score: scoreOf(c.score),
|
|
90
175
|
})),
|
|
91
176
|
};
|
|
92
177
|
}
|
|
93
178
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
179
|
+
// --- ESPN event → sport-neutral Game (NBA/NHL) ---
|
|
180
|
+
|
|
181
|
+
/** ESPN season-type number → phase. 5 is the NBA play-in, which leads into
|
|
182
|
+
* (and is shown with) the playoffs. 4 (off-season) and anything unknown → null. */
|
|
183
|
+
function seasonPhaseOf(n: unknown): SeasonPhase | null {
|
|
184
|
+
switch (Number(n)) {
|
|
185
|
+
case 1:
|
|
186
|
+
return "preseason";
|
|
187
|
+
case 2:
|
|
188
|
+
return "regular";
|
|
189
|
+
case 3:
|
|
190
|
+
case 5:
|
|
191
|
+
return "postseason";
|
|
192
|
+
default:
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const MARKETS: Record<string, BroadcastMarket> = { national: "national", home: "home", away: "away" };
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A competition's broadcasts. ESPN uses two shapes: team schedules (and the
|
|
201
|
+
* scoreboard's `geoBroadcasts`) list `{ market: { type: "Away" }, media:
|
|
202
|
+
* { shortName: "Utah 16" } }`; the scoreboard's `broadcasts` is the condensed
|
|
203
|
+
* `{ market: "national", names: ["NBA TV"] }`. Prefer the detailed form. Rows
|
|
204
|
+
* with an unknown market are dropped rather than guessed — mislabeling a
|
|
205
|
+
* regional feed as national would wrongly claim it's watchable anywhere.
|
|
206
|
+
*/
|
|
207
|
+
function broadcastsOf(comp: any): Broadcast[] {
|
|
208
|
+
const detailed: any[] = Array.isArray(comp?.geoBroadcasts) && comp.geoBroadcasts.length
|
|
209
|
+
? comp.geoBroadcasts
|
|
210
|
+
: (comp?.broadcasts ?? []).filter((b: any) => b && typeof b.market === "object");
|
|
211
|
+
const rows: { market: unknown; name: unknown }[] = detailed.length
|
|
212
|
+
? detailed.map((b: any) => ({ market: b?.market?.type, name: b?.media?.shortName ?? b?.media?.name }))
|
|
213
|
+
: (comp?.broadcasts ?? []).flatMap((b: any) =>
|
|
214
|
+
(Array.isArray(b?.names) ? b.names : []).map((name: unknown) => ({ market: b?.market, name })),
|
|
215
|
+
);
|
|
216
|
+
const out: Broadcast[] = [];
|
|
217
|
+
for (const r of rows) {
|
|
218
|
+
const market = MARKETS[String(r.market ?? "").toLowerCase()];
|
|
219
|
+
const name = typeof r.name === "string" ? r.name.trim() : "";
|
|
220
|
+
if (!market || !name) continue;
|
|
221
|
+
if (out.some((b) => b.name === name && b.market === market)) continue;
|
|
222
|
+
out.push({ name, market });
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function competitorOf(comp: any, side: "home" | "away"): GameCompetitor {
|
|
228
|
+
const entry = (comp?.competitors ?? []).find((x: any) => x?.homeAway === side);
|
|
229
|
+
return {
|
|
230
|
+
id: String(entry?.team?.id ?? entry?.id ?? ""),
|
|
231
|
+
name: entry?.team?.displayName ?? entry?.team?.name ?? "TBD",
|
|
232
|
+
abbreviation: entry?.team?.abbreviation ?? "",
|
|
233
|
+
score: scoreOf(entry?.score),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* One ESPN scoreboard or team-schedule event → `Game`. Season type comes from
|
|
239
|
+
* the event (`seasonType.type` on schedules, `season.type` on scoreboards),
|
|
240
|
+
* falling back to `requested` (the `seasontype=` the schedule was fetched
|
|
241
|
+
* with), then "regular". Exported for tests.
|
|
242
|
+
*/
|
|
243
|
+
export function toGame(e: any, requested?: SeasonType): Game {
|
|
244
|
+
const comp = e?.competitions?.[0] ?? {};
|
|
245
|
+
const status = comp.status ?? e?.status ?? {};
|
|
246
|
+
const state: Game["state"] = status.type?.state === "in" || status.type?.state === "post" ? status.type.state : "pre";
|
|
247
|
+
return {
|
|
248
|
+
id: String(e?.id ?? ""),
|
|
249
|
+
date: e?.date ?? comp.date ?? "",
|
|
250
|
+
name: e?.name ?? e?.shortName ?? "",
|
|
251
|
+
state,
|
|
252
|
+
detail: status.type?.shortDetail ?? status.type?.detail ?? "",
|
|
253
|
+
period: Number(status.period) || 0,
|
|
254
|
+
clock: state === "in" ? String(status.displayClock ?? "") : "",
|
|
255
|
+
seasonType:
|
|
256
|
+
seasonPhaseOf(e?.seasonType?.type) ?? seasonPhaseOf(e?.season?.type) ?? seasonPhaseOf(requested) ?? "regular",
|
|
257
|
+
home: competitorOf(comp, "home"),
|
|
258
|
+
away: competitorOf(comp, "away"),
|
|
259
|
+
broadcasts: broadcastsOf(comp),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- ESPN game summary (NBA/NHL box score + play-by-play) ---
|
|
264
|
+
|
|
265
|
+
/** One play from a summary's play-by-play, in feed (chronological) order. */
|
|
266
|
+
export interface EspnPlay {
|
|
267
|
+
period: number;
|
|
268
|
+
/** ESPN's period label, e.g. "4th Quarter", "2nd", "OT". */
|
|
269
|
+
periodLabel: string;
|
|
270
|
+
/** Clock as displayed (time remaining in the period), e.g. "4:21". */
|
|
271
|
+
clock: string;
|
|
272
|
+
/** e.g. "Jump Shot", "Goal", "Tripping", "End Period". */
|
|
273
|
+
type: string;
|
|
274
|
+
text: string;
|
|
275
|
+
/** ESPN team id of the acting team; "" for neutral plays (period ends). */
|
|
276
|
+
teamId: string;
|
|
277
|
+
homeScore: number;
|
|
278
|
+
awayScore: number;
|
|
279
|
+
scoring: boolean;
|
|
280
|
+
/** Penalty minutes, when the play is a (hockey) penalty. */
|
|
281
|
+
penaltyMinutes?: number;
|
|
282
|
+
/** Hockey manpower situation, e.g. "Power Play", "Even Strength". */
|
|
283
|
+
strength?: string;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface EspnSummarySide {
|
|
287
|
+
/** ESPN team id — unique only within a league. */
|
|
288
|
+
id: string;
|
|
289
|
+
abbreviation: string;
|
|
290
|
+
name: string;
|
|
291
|
+
score: string;
|
|
292
|
+
/** Points/goals per period, in order. */
|
|
293
|
+
linescores: string[];
|
|
294
|
+
/** Overall record as displayed, e.g. "1-0", "2-1-0, 4 PTS"; "" if absent. */
|
|
295
|
+
record: string;
|
|
296
|
+
/** Team box-score stats: the sport's own stat set, as ESPN names them. */
|
|
297
|
+
stats: { name: string; label: string; value: string }[];
|
|
298
|
+
/** Top player per leader category, e.g. { category: "Points", athlete: "Lauri Markkanen", value: "17" }. */
|
|
299
|
+
leaders: { category: string; athlete: string; value: string }[];
|
|
300
|
+
/** Goaltending lines (hockey); empty for sports without goalies. */
|
|
301
|
+
goalies: { athlete: string; saves: string; shotsAgainst: string; savePct: string }[];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** A game's summary: status, both sides' box score, and the play-by-play. */
|
|
305
|
+
export interface EspnGameSummary {
|
|
306
|
+
state: "pre" | "in" | "post";
|
|
307
|
+
/** Status text, e.g. "Final", "Final/OT", "4:21 - 3rd". */
|
|
308
|
+
detail: string;
|
|
309
|
+
period: number;
|
|
310
|
+
home: EspnSummarySide;
|
|
311
|
+
away: EspnSummarySide;
|
|
312
|
+
plays: EspnPlay[];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function summarySide(raw: any, comp: any, side: "home" | "away"): EspnSummarySide {
|
|
316
|
+
const entry = (comp?.competitors ?? []).find((x: any) => x?.homeAway === side) ?? {};
|
|
317
|
+
const id = String(entry.team?.id ?? entry.id ?? "");
|
|
318
|
+
const byId = (list: any[]) => (list ?? []).find((x: any) => String(x?.team?.id ?? "") === id);
|
|
319
|
+
const box = byId(raw?.boxscore?.teams);
|
|
320
|
+
const leaderBlock = byId(raw?.leaders);
|
|
321
|
+
const goalieGroup = (byId(raw?.boxscore?.players)?.statistics ?? []).find((g: any) => g?.name === "goalies");
|
|
322
|
+
const labels: string[] = goalieGroup?.labels ?? [];
|
|
323
|
+
const col = (stats: unknown[], label: string) => {
|
|
324
|
+
const i = labels.indexOf(label);
|
|
325
|
+
return i >= 0 && stats[i] != null ? String(stats[i]) : "";
|
|
326
|
+
};
|
|
327
|
+
const records: any[] = Array.isArray(entry.record) ? entry.record : [];
|
|
328
|
+
const total = records.find((r) => r?.type === "total") ?? records[0];
|
|
329
|
+
return {
|
|
330
|
+
id,
|
|
331
|
+
abbreviation: entry.team?.abbreviation ?? "",
|
|
332
|
+
name: entry.team?.displayName ?? entry.team?.name ?? "?",
|
|
333
|
+
score: scoreOf(entry.score),
|
|
334
|
+
linescores: (entry.linescores ?? []).map((l: any) => String(l?.displayValue ?? l?.value ?? "")),
|
|
335
|
+
record: String(total?.displayValue ?? total?.summary ?? ""),
|
|
336
|
+
stats: (box?.statistics ?? []).map((s: any) => ({
|
|
337
|
+
name: String(s?.name ?? ""),
|
|
338
|
+
label: String(s?.label ?? s?.name ?? ""),
|
|
339
|
+
value: String(s?.displayValue ?? s?.value ?? ""),
|
|
340
|
+
})),
|
|
341
|
+
leaders: (leaderBlock?.leaders ?? []).flatMap((cat: any) => {
|
|
342
|
+
const top = cat?.leaders?.[0];
|
|
343
|
+
if (!top?.athlete?.displayName) return [];
|
|
344
|
+
return [{ category: String(cat.displayName ?? cat.name ?? ""), athlete: String(top.athlete.displayName), value: String(top.displayValue ?? "") }];
|
|
345
|
+
}),
|
|
346
|
+
goalies: (goalieGroup?.athletes ?? [])
|
|
347
|
+
.filter((a: any) => Array.isArray(a?.stats) && a.stats.length > 0)
|
|
348
|
+
.map((a: any) => ({
|
|
349
|
+
athlete: String(a.athlete?.displayName ?? "?"),
|
|
350
|
+
saves: col(a.stats, "SV"),
|
|
351
|
+
shotsAgainst: col(a.stats, "SA"),
|
|
352
|
+
savePct: col(a.stats, "SV%"),
|
|
353
|
+
})),
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function playOf(p: any): EspnPlay {
|
|
358
|
+
const pm = Number(p?.type?.penaltyMinutes);
|
|
359
|
+
return {
|
|
360
|
+
period: Number(p?.period?.number) || 0,
|
|
361
|
+
periodLabel: String(p?.period?.displayValue ?? ""),
|
|
362
|
+
clock: String(p?.clock?.displayValue ?? ""),
|
|
363
|
+
type: String(p?.type?.text ?? ""),
|
|
364
|
+
text: String(p?.text ?? ""),
|
|
365
|
+
teamId: String(p?.team?.id ?? ""),
|
|
366
|
+
homeScore: Number(p?.homeScore) || 0,
|
|
367
|
+
awayScore: Number(p?.awayScore) || 0,
|
|
368
|
+
scoring: p?.scoringPlay === true,
|
|
369
|
+
...(Number.isFinite(pm) && pm > 0 ? { penaltyMinutes: pm } : {}),
|
|
370
|
+
...(p?.strength?.text ? { strength: String(p.strength.text) } : {}),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** A `summary?event=` response → EspnGameSummary, or null if it has no
|
|
375
|
+
* competition header. Teams are paired by ESPN id within the game. Exported for tests. */
|
|
376
|
+
export function parseGameSummary(raw: any): EspnGameSummary | null {
|
|
377
|
+
const comp = raw?.header?.competitions?.[0];
|
|
378
|
+
if (!comp) return null;
|
|
379
|
+
const status = comp.status ?? {};
|
|
380
|
+
const state = status.type?.state === "in" || status.type?.state === "post" ? status.type.state : "pre";
|
|
381
|
+
return {
|
|
382
|
+
state,
|
|
383
|
+
detail: String(status.type?.shortDetail ?? status.type?.detail ?? ""),
|
|
384
|
+
period: Number(status.period) || 0,
|
|
385
|
+
home: summarySide(raw, comp, "home"),
|
|
386
|
+
away: summarySide(raw, comp, "away"),
|
|
387
|
+
plays: (Array.isArray(raw?.plays) ? raw.plays : []).map(playOf),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Box score + play-by-play for one game in `league`. Short default TTL: it
|
|
392
|
+
* backs both finished-game analysis and the live catch-up. */
|
|
393
|
+
export async function getGameSummary(league: League, eventId: string, ttlMs = 30_000): Promise<EspnGameSummary | null> {
|
|
394
|
+
const raw = await fetchEspn(
|
|
395
|
+
espnUrl(league, `summary?event=${encodeURIComponent(eventId)}`),
|
|
396
|
+
espnCacheKey(league, "game", eventId),
|
|
397
|
+
ttlMs,
|
|
398
|
+
"summary",
|
|
399
|
+
);
|
|
400
|
+
return parseGameSummary(raw);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Scoreboard events for a date or `YYYYMMDD-YYYYMMDD` range in `league`. */
|
|
404
|
+
export async function getScoreboard(dates: string, ttlMs = 60_000, league: League = FIFA): Promise<EspnEvent[]> {
|
|
405
|
+
const raw = await fetchEspn(
|
|
406
|
+
espnUrl(league, `scoreboard?dates=${encodeURIComponent(dates)}`),
|
|
407
|
+
espnCacheKey(league, "sb", dates),
|
|
408
|
+
ttlMs,
|
|
409
|
+
"scoreboard",
|
|
410
|
+
);
|
|
101
411
|
if (looksOff(raw)) warnDriftOnce();
|
|
102
|
-
return (raw
|
|
412
|
+
return (raw?.events ?? []).map(normalizeEvent);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** One team's schedule for a season type (1 = preseason, 2 = regular,
|
|
416
|
+
* 3 = postseason) in `league`. `team` is an ESPN team id or abbreviation
|
|
417
|
+
* (e.g. Jazz `26`, Mammoth `129764`). ESPN returns `events: []` for a season
|
|
418
|
+
* type with nothing scheduled yet (e.g. postseason in October). */
|
|
419
|
+
export async function getTeamSchedule(
|
|
420
|
+
league: League,
|
|
421
|
+
team: string | number,
|
|
422
|
+
seasonType: SeasonType,
|
|
423
|
+
ttlMs = 5 * 60_000,
|
|
424
|
+
): Promise<EspnEvent[]> {
|
|
425
|
+
return (await rawTeamSchedule(league, team, seasonType, ttlMs)).map(normalizeEvent);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Raw schedule events (drift-checked) — shared by getTeamSchedule/getTeamGames/
|
|
429
|
+
* getTeamPlayoffGames. `season` (ESPN year, e.g. 2026 for 2025-26) defaults to
|
|
430
|
+
* the current one. */
|
|
431
|
+
async function rawTeamSchedule(
|
|
432
|
+
league: League,
|
|
433
|
+
team: string | number,
|
|
434
|
+
seasonType: SeasonType,
|
|
435
|
+
ttlMs: number,
|
|
436
|
+
season?: number,
|
|
437
|
+
): Promise<any[]> {
|
|
438
|
+
const seasonQs = season === undefined ? "" : `&season=${season}`;
|
|
439
|
+
const raw = await fetchEspn(
|
|
440
|
+
espnUrl(league, `teams/${encodeURIComponent(String(team))}/schedule?seasontype=${seasonType}${seasonQs}`),
|
|
441
|
+
espnCacheKey(league, `sched${seasonType}${season === undefined ? "" : `_${season}`}`, team),
|
|
442
|
+
ttlMs,
|
|
443
|
+
"team schedule",
|
|
444
|
+
);
|
|
445
|
+
if (looksOff(raw)) warnDriftOnce();
|
|
446
|
+
return raw?.events ?? [];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** One team's schedule for a season type as sport-neutral `Game`s. */
|
|
450
|
+
export async function getTeamGames(
|
|
451
|
+
league: League,
|
|
452
|
+
team: string | number,
|
|
453
|
+
seasonType: SeasonType,
|
|
454
|
+
ttlMs = 5 * 60_000,
|
|
455
|
+
): Promise<Game[]> {
|
|
456
|
+
return (await rawTeamSchedule(league, team, seasonType, ttlMs)).map((e) => toGame(e, seasonType));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// --- Postseason (bracket) ---
|
|
460
|
+
|
|
461
|
+
export type Conference = "East" | "West";
|
|
462
|
+
|
|
463
|
+
/** A postseason or play-in game plus where it sits in the bracket. */
|
|
464
|
+
export interface PlayoffGame {
|
|
465
|
+
game: Game;
|
|
466
|
+
/** 1 = first round … 4 = the league final; null for play-in or unrecognized. */
|
|
467
|
+
round: number | null;
|
|
468
|
+
/** The game's conference; null for the league final (and unrecognized notes). */
|
|
469
|
+
conference: Conference | null;
|
|
470
|
+
playIn: boolean;
|
|
471
|
+
/** The note without conference/game number, e.g. "7th Place vs 8th Place". */
|
|
472
|
+
label: string;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** ESPN competition type → round. Observed 2025-26 for both NBA and NHL. */
|
|
476
|
+
const ROUND_CODES: Record<string, number> = { RD16: 1, QTR: 2, SEMI: 3, FINAL: 4 };
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Interpret a postseason event's note and competition type. Notes look like
|
|
480
|
+
* "East 1st Round - Game 5", "West Final - Game 2", "NBA Finals - Game 4",
|
|
481
|
+
* "Stanley Cup Final - Game 1", and for the play-in (season type 5)
|
|
482
|
+
* "NBA Play-In - East - 7th Place vs 8th Place". Exported for tests.
|
|
483
|
+
*/
|
|
484
|
+
export function toPlayoffGame(e: any, requested?: SeasonType): PlayoffGame {
|
|
485
|
+
const game = toGame(e, requested);
|
|
486
|
+
const comp = e?.competitions?.[0] ?? {};
|
|
487
|
+
const headline = String(comp?.notes?.[0]?.headline ?? e?.notes?.[0]?.headline ?? "");
|
|
488
|
+
const type = Number(e?.seasonType?.type ?? e?.season?.type ?? requested);
|
|
489
|
+
const playIn = type === SEASON_TYPES.playIn || /play-?in/i.test(headline);
|
|
490
|
+
const conf = /\b(East|West)\b/.exec(headline)?.[1] as Conference | undefined;
|
|
491
|
+
if (playIn) {
|
|
492
|
+
const label = headline.split(/\s+-\s+/).slice(2).join(" - ") || headline;
|
|
493
|
+
return { game, round: null, conference: conf ?? null, playIn, label };
|
|
494
|
+
}
|
|
495
|
+
const label = headline.replace(/\s+-\s+Game\s+\d+$/i, "");
|
|
496
|
+
const round = ROUND_CODES[String(comp?.type?.abbreviation ?? "")] ?? null;
|
|
497
|
+
return { game, round, conference: round === 4 ? null : (conf ?? null), playIn, label };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** One team's postseason (3) or play-in (5) games, optionally for a past
|
|
501
|
+
* `season` (ESPN year). Empty when the team didn't make it / nothing's set. */
|
|
502
|
+
export async function getTeamPlayoffGames(
|
|
503
|
+
league: League,
|
|
504
|
+
team: string | number,
|
|
505
|
+
seasonType: typeof SEASON_TYPES.postseason | typeof SEASON_TYPES.playIn,
|
|
506
|
+
season?: number,
|
|
507
|
+
ttlMs = 5 * 60_000,
|
|
508
|
+
): Promise<PlayoffGame[]> {
|
|
509
|
+
return (await rawTeamSchedule(league, team, seasonType, ttlMs, season)).map((e) => toPlayoffGame(e, seasonType));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export interface LeagueSeason {
|
|
513
|
+
/** ESPN season year (the year the season ends). */
|
|
514
|
+
year: number;
|
|
515
|
+
/** ESPN season type now: 1 pre, 2 regular, 3 post, 4 off-season, 5 play-in. */
|
|
516
|
+
type: number;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Parse the league's current season from a scoreboard response; null if
|
|
520
|
+
* absent. Exported for tests. */
|
|
521
|
+
export function parseLeagueSeason(raw: any): LeagueSeason | null {
|
|
522
|
+
const s = raw?.leagues?.[0]?.season ?? raw?.season;
|
|
523
|
+
const year = Number(s?.year);
|
|
524
|
+
const type = Number(s?.type?.type ?? s?.type);
|
|
525
|
+
return Number.isInteger(year) && year > 0 && Number.isInteger(type) ? { year, type } : null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** The league's current season and phase, from today's scoreboard. */
|
|
529
|
+
export async function getLeagueSeason(league: League, ttlMs = 10 * 60_000): Promise<LeagueSeason | null> {
|
|
530
|
+
const raw = await fetchEspn(espnUrl(league, "scoreboard"), espnCacheKey(league, "sb_today"), ttlMs, "scoreboard");
|
|
531
|
+
if (looksOff(raw)) warnDriftOnce();
|
|
532
|
+
return parseLeagueSeason(raw);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** A single day's scoreboard (`YYYYMMDD`) in `league` as `Game`s. Same cache
|
|
536
|
+
* entry as getScoreboard. Single dates only — ESPN's `dates=A-B` ranges are
|
|
537
|
+
* unreliable for NBA/NHL. */
|
|
538
|
+
export async function getScoreboardGames(league: League, date: string, ttlMs = 60_000): Promise<Game[]> {
|
|
539
|
+
if (!/^\d{8}$/.test(date)) throw new Error(`getScoreboardGames expects one YYYYMMDD date, got "${date}".`);
|
|
540
|
+
const raw = await fetchEspn(
|
|
541
|
+
espnUrl(league, `scoreboard?dates=${encodeURIComponent(date)}`),
|
|
542
|
+
espnCacheKey(league, "sb", date),
|
|
543
|
+
ttlMs,
|
|
544
|
+
"scoreboard",
|
|
545
|
+
);
|
|
546
|
+
if (looksOff(raw)) warnDriftOnce();
|
|
547
|
+
return (raw?.events ?? []).map((e: any) => toGame(e));
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export interface EspnTeam {
|
|
551
|
+
id: string;
|
|
552
|
+
abbreviation: string;
|
|
553
|
+
/** Full name, e.g. "Utah Jazz". */
|
|
554
|
+
name: string;
|
|
555
|
+
/** Nickname, e.g. "Jazz". */
|
|
556
|
+
shortName: string;
|
|
557
|
+
location: string;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Parse a `/teams` response. Exported for tests. */
|
|
561
|
+
export function parseTeams(raw: any): EspnTeam[] {
|
|
562
|
+
const teams = raw?.sports?.[0]?.leagues?.[0]?.teams ?? [];
|
|
563
|
+
return (Array.isArray(teams) ? teams : []).map((t: any) => ({
|
|
564
|
+
id: String(t?.team?.id ?? ""),
|
|
565
|
+
abbreviation: t?.team?.abbreviation ?? "",
|
|
566
|
+
name: t?.team?.displayName ?? t?.team?.name ?? "?",
|
|
567
|
+
shortName: t?.team?.shortDisplayName ?? t?.team?.name ?? "",
|
|
568
|
+
location: t?.team?.location ?? "",
|
|
569
|
+
}));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Every team in `league`. Long TTL — rosters of franchises barely change. */
|
|
573
|
+
export async function getTeams(league: League, ttlMs = 24 * 60 * 60_000): Promise<EspnTeam[]> {
|
|
574
|
+
const raw = await fetchEspn(espnUrl(league, "teams"), espnCacheKey(league, "teams"), ttlMs, "teams");
|
|
575
|
+
if (looksOffTeams(raw)) warnDriftOnce();
|
|
576
|
+
return parseTeams(raw);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export interface EspnStandingsEntry {
|
|
580
|
+
teamId: string;
|
|
581
|
+
team: string;
|
|
582
|
+
abbreviation: string;
|
|
583
|
+
/** Stat name → display value (e.g. wins, losses, points, playoffSeed). */
|
|
584
|
+
stats: Record<string, string>;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export interface EspnStandingsGroup {
|
|
588
|
+
/** e.g. "Western Conference", "Pacific Division", "Group A". */
|
|
589
|
+
name: string;
|
|
590
|
+
abbreviation: string;
|
|
591
|
+
/** The enclosing group for a nested table (a division's conference), else null. */
|
|
592
|
+
parent: { name: string; abbreviation: string } | null;
|
|
593
|
+
entries: EspnStandingsEntry[];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export interface EspnStandings {
|
|
597
|
+
/** ESPN season year — the year the season ends (2026-27 → 2027); null if absent. */
|
|
598
|
+
season: number | null;
|
|
599
|
+
/** e.g. "2026-27"; "" if absent. */
|
|
600
|
+
seasonName: string;
|
|
601
|
+
groups: EspnStandingsGroup[];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Parse a standings response's tables (conference/group children, divisions
|
|
605
|
+
* nested under conferences, or a single top-level table), flattened to the
|
|
606
|
+
* leaf tables in ESPN's order. Exported for tests. */
|
|
607
|
+
export function parseStandings(raw: any): EspnStandingsGroup[] {
|
|
608
|
+
const groups: any[] = Array.isArray(raw?.children) ? raw.children : raw?.standings ? [raw] : [];
|
|
609
|
+
return groups.flatMap((g) => leafGroups(g, null));
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function leafGroups(g: any, parent: EspnStandingsGroup["parent"]): EspnStandingsGroup[] {
|
|
613
|
+
const name = g?.name ?? "";
|
|
614
|
+
const abbreviation = g?.abbreviation ?? "";
|
|
615
|
+
if (Array.isArray(g?.children) && !g?.standings) {
|
|
616
|
+
return g.children.flatMap((child: any) => leafGroups(child, { name, abbreviation }));
|
|
617
|
+
}
|
|
618
|
+
return [
|
|
619
|
+
{
|
|
620
|
+
name,
|
|
621
|
+
abbreviation,
|
|
622
|
+
parent,
|
|
623
|
+
entries: (g?.standings?.entries ?? []).map((e: any) => ({
|
|
624
|
+
teamId: String(e?.team?.id ?? ""),
|
|
625
|
+
team: e?.team?.displayName ?? e?.team?.name ?? "?",
|
|
626
|
+
abbreviation: e?.team?.abbreviation ?? "",
|
|
627
|
+
stats: Object.fromEntries(
|
|
628
|
+
(e?.stats ?? [])
|
|
629
|
+
.filter((s: any) => s?.name)
|
|
630
|
+
.map((s: any) => [s.name, String(s.displayValue ?? s.value ?? "")]),
|
|
631
|
+
),
|
|
632
|
+
})),
|
|
633
|
+
},
|
|
634
|
+
];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Parse a whole standings response: season metadata plus its tables. Exported for tests. */
|
|
638
|
+
export function parseStandingsResponse(raw: any): EspnStandings {
|
|
639
|
+
const year = Number(raw?.season?.year);
|
|
640
|
+
return {
|
|
641
|
+
season: Number.isInteger(year) && year > 0 ? year : null,
|
|
642
|
+
seasonName: String(raw?.season?.displayName ?? ""),
|
|
643
|
+
groups: parseStandings(raw),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Which standings to fetch. Omitted fields take ESPN's defaults (current
|
|
648
|
+
* season, its current season type, conference tables). */
|
|
649
|
+
export interface StandingsQuery {
|
|
650
|
+
/** ESPN season year (the year the season ends). */
|
|
651
|
+
season?: number;
|
|
652
|
+
seasonType?: SeasonType;
|
|
653
|
+
/** "division" nests division tables under conferences (`level=3`). */
|
|
654
|
+
level?: "conference" | "division";
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Standings for `league`, grouped (NBA/NHL conferences or divisions, WC groups). */
|
|
658
|
+
export async function getStandings(league: League, query: StandingsQuery = {}, ttlMs = 10 * 60_000): Promise<EspnStandings> {
|
|
659
|
+
const params = new URLSearchParams();
|
|
660
|
+
if (query.season !== undefined) params.set("season", String(query.season));
|
|
661
|
+
if (query.seasonType !== undefined) params.set("seasontype", String(query.seasonType));
|
|
662
|
+
if (query.level === "division") params.set("level", "3");
|
|
663
|
+
const qs = params.toString();
|
|
664
|
+
const raw = await fetchEspn(
|
|
665
|
+
`${STANDINGS_BASE}/${league}/standings${qs ? `?${qs}` : ""}`,
|
|
666
|
+
espnCacheKey(league, "standings", qs),
|
|
667
|
+
ttlMs,
|
|
668
|
+
"standings",
|
|
669
|
+
);
|
|
670
|
+
if (looksOffStandings(raw)) warnDriftOnce();
|
|
671
|
+
return parseStandingsResponse(raw);
|
|
103
672
|
}
|
|
104
673
|
|
|
105
674
|
/** Every tournament event — played and upcoming (opening day → final). The full
|
|
@@ -138,14 +707,16 @@ export interface H2HGame {
|
|
|
138
707
|
export async function getHeadToHead(
|
|
139
708
|
eventId: string,
|
|
140
709
|
ttlMs = 60 * 60_000,
|
|
710
|
+
league: League = FIFA,
|
|
141
711
|
): Promise<{ team: string; games: H2HGame[] }> {
|
|
142
712
|
// Separate cache key from getMatchStats (which also hits /summary on a short
|
|
143
713
|
// TTL) — a shared key would let the shorter TTL win and refetch H2H needlessly.
|
|
144
|
-
const raw = await
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
714
|
+
const raw = await fetchEspn(
|
|
715
|
+
espnUrl(league, `summary?event=${encodeURIComponent(eventId)}`),
|
|
716
|
+
espnCacheKey(league, "h2h", eventId),
|
|
717
|
+
ttlMs,
|
|
718
|
+
"summary",
|
|
719
|
+
);
|
|
149
720
|
const block = (raw.headToHeadGames ?? [])[0];
|
|
150
721
|
if (!block) return { team: "", games: [] };
|
|
151
722
|
const games: H2HGame[] = (block.events ?? []).map((e: any) => {
|
|
@@ -183,12 +754,13 @@ function mlToProb(ml: number): number {
|
|
|
183
754
|
|
|
184
755
|
/** Fresh live state for one match in a single call: clock + score (summary
|
|
185
756
|
* header) and stats (boxscore). Short TTL — this drives the live overlay. */
|
|
186
|
-
export async function getLiveMatch(eventId: string, ttlMs = 5_000): Promise<LiveMatch | null> {
|
|
187
|
-
const raw = await
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
757
|
+
export async function getLiveMatch(eventId: string, ttlMs = 5_000, league: League = FIFA): Promise<LiveMatch | null> {
|
|
758
|
+
const raw = await fetchEspn(
|
|
759
|
+
espnUrl(league, `summary?event=${encodeURIComponent(eventId)}`),
|
|
760
|
+
espnCacheKey(league, "live", eventId),
|
|
761
|
+
ttlMs,
|
|
762
|
+
"summary",
|
|
763
|
+
);
|
|
192
764
|
const comp = raw.header?.competitions?.[0];
|
|
193
765
|
if (!comp) return null;
|
|
194
766
|
const hc = (comp.competitors ?? []).find((c: any) => c.homeAway === "home");
|
|
@@ -288,12 +860,13 @@ export async function resolveWatchTarget(terms: string[], ttlMs = 15_000): Promi
|
|
|
288
860
|
}
|
|
289
861
|
|
|
290
862
|
/** Per-team statistics for one event (from the summary boxscore). */
|
|
291
|
-
export async function getMatchStats(eventId: string, ttlMs = 60_000): Promise<EspnTeamStats[]> {
|
|
292
|
-
const raw = await
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
863
|
+
export async function getMatchStats(eventId: string, ttlMs = 60_000, league: League = FIFA): Promise<EspnTeamStats[]> {
|
|
864
|
+
const raw = await fetchEspn(
|
|
865
|
+
espnUrl(league, `summary?event=${encodeURIComponent(eventId)}`),
|
|
866
|
+
espnCacheKey(league, "sum", eventId),
|
|
867
|
+
ttlMs,
|
|
868
|
+
"summary",
|
|
869
|
+
);
|
|
297
870
|
const teams = raw.boxscore?.teams ?? [];
|
|
298
871
|
return teams.map((t: any) => ({
|
|
299
872
|
team: t.team?.displayName ?? t.team?.name ?? "?",
|