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.
Files changed (43) hide show
  1. package/README.md +160 -12
  2. package/package.json +6 -2
  3. package/src/alerts.ts +47 -0
  4. package/src/api.ts +1 -1
  5. package/src/click-to-watch.ts +39 -0
  6. package/src/commands/_lib.ts +28 -12
  7. package/src/commands/analyze.ts +2 -2
  8. package/src/commands/ask.ts +1 -1
  9. package/src/commands/cross.ts +354 -0
  10. package/src/commands/fav.ts +19 -16
  11. package/src/commands/league-ai.ts +201 -0
  12. package/src/commands/league-bracket.ts +150 -0
  13. package/src/commands/league.ts +839 -0
  14. package/src/commands/live.ts +57 -42
  15. package/src/commands/me.ts +1 -1
  16. package/src/commands/predict.ts +2 -2
  17. package/src/commands/scorers.ts +1 -1
  18. package/src/commands/setup.ts +3 -3
  19. package/src/commands/subscriptions.ts +66 -0
  20. package/src/commands/table.ts +1 -1
  21. package/src/commands/watch.ts +19 -11
  22. package/src/config.ts +166 -28
  23. package/src/espn.ts +606 -33
  24. package/src/format.ts +87 -0
  25. package/src/game-events.ts +239 -0
  26. package/src/game.ts +51 -0
  27. package/src/index.ts +35 -16
  28. package/src/league-ai.ts +359 -0
  29. package/src/league-alerts.ts +139 -0
  30. package/src/league-detect.ts +104 -0
  31. package/src/league-overlay.ts +351 -0
  32. package/src/league-panels.ts +159 -0
  33. package/src/overlay.ts +96 -51
  34. package/src/playoff-bracket.ts +384 -0
  35. package/src/recap.ts +37 -8
  36. package/src/season.ts +182 -0
  37. package/src/sports/fifa.ts +9 -4
  38. package/src/sports/nba.ts +20 -0
  39. package/src/sports/nhl.ts +20 -0
  40. package/src/standings.ts +130 -0
  41. package/src/stream.ts +49 -8
  42. package/src/watch-route.ts +95 -0
  43. package/src/watchability.ts +183 -0
package/src/espn.ts CHANGED
@@ -1,18 +1,68 @@
1
- // ESPN's free, no-key JSON API — the stats source for the live 2026 World Cup.
2
- // Used by stats / predict. Returns per-team match statistics (possession,
3
- // shots, passes, cards…), rosters, and key events for `soccer/fifa.world`.
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
- const BASE = "https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world";
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 response. ESPN is unofficial and
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
- function normalizeEvent(e: any): EspnEvent {
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
- /** Scoreboard events for a date or `YYYYMMDD-YYYYMMDD` range. */
95
- export async function getScoreboard(dates: string, ttlMs = 60_000): Promise<EspnEvent[]> {
96
- const raw = await cached<any>(`espn_sb_${dates}`, ttlMs, async () => {
97
- const res = await fetch(`${BASE}/scoreboard?dates=${dates}`);
98
- if (!res.ok) throw new ApiError(res.status, `ESPN scoreboard request failed (HTTP ${res.status}).`);
99
- return res.json();
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.events ?? []).map(normalizeEvent);
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 cached<any>(`espn_h2h_${eventId}`, ttlMs, async () => {
145
- const res = await fetch(`${BASE}/summary?event=${eventId}`);
146
- if (!res.ok) throw new ApiError(res.status, `ESPN summary request failed (HTTP ${res.status}).`);
147
- return res.json();
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 cached<any>(`espn_live_${eventId}`, ttlMs, async () => {
188
- const res = await fetch(`${BASE}/summary?event=${eventId}`);
189
- if (!res.ok) throw new ApiError(res.status, `ESPN summary request failed (HTTP ${res.status}).`);
190
- return res.json();
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 cached<any>(`espn_sum_${eventId}`, ttlMs, async () => {
293
- const res = await fetch(`${BASE}/summary?event=${eventId}`);
294
- if (!res.ok) throw new ApiError(res.status, `ESPN summary request failed (HTTP ${res.status}).`);
295
- return res.json();
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 ?? "?",