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
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// The NBA/NHL live-stats overlay (`sportsing nba watch --overlay`), injected
|
|
2
|
+
// onto the stream page over CDP. Same chrome as the FIFA overlay (overlay.ts):
|
|
3
|
+
// a floating gear, a settings modal with per-league panel checkboxes and the
|
|
4
|
+
// spoiler-delay slider, and a stats window that appears once a panel is on.
|
|
5
|
+
// It follows the game you open: the page title's English team names
|
|
6
|
+
// (league-detect.ts) pick the game off ESPN's scoreboard, and its box score
|
|
7
|
+
// fills the panels (league-panels.ts). "Get caught up" recaps the game up to
|
|
8
|
+
// the delayed stream's moment through the serve bus (league-ai.ts). Panel
|
|
9
|
+
// choices persist per provider and sport (`overlayPanels["fubo:nba"]`); the
|
|
10
|
+
// delay is per provider, shared with the FIFA overlay — it's the stream's latency.
|
|
11
|
+
|
|
12
|
+
import { c } from "./ansi.ts";
|
|
13
|
+
import { getGameSummary, getTeams, type EspnGameSummary, type EspnTeam } from "./espn.ts";
|
|
14
|
+
import { getOverlayPanels, getStreamDelay, setOverlayPanel, setStreamDelay } from "./config.ts";
|
|
15
|
+
import { attachToPage, freePort, type CdpSession } from "./cdp.ts";
|
|
16
|
+
import { spawnStreamWindow, type StreamWindow } from "./stream.ts";
|
|
17
|
+
import type { Game } from "./game.ts";
|
|
18
|
+
import type { LeagueConfig } from "./commands/league.ts";
|
|
19
|
+
import { gameBetween, teamsFromTitle } from "./league-detect.ts";
|
|
20
|
+
import { LEAGUE_PANELS, SPECIAL_PANELS, panelDefaults, type LeaguePanels, type PanelRow } from "./league-panels.ts";
|
|
21
|
+
import { catchupInput, sportAiFor } from "./league-ai.ts";
|
|
22
|
+
import { requestRecap } from "./recap.ts";
|
|
23
|
+
import { isServing } from "./ask-bus.ts";
|
|
24
|
+
import {
|
|
25
|
+
JS_CALL,
|
|
26
|
+
JS_CB,
|
|
27
|
+
JS_DRAGGABLE,
|
|
28
|
+
JS_ESC,
|
|
29
|
+
JS_ROW,
|
|
30
|
+
JS_SHELL_CREATE,
|
|
31
|
+
JS_SHELL_WIRE,
|
|
32
|
+
JS_SHOW,
|
|
33
|
+
JS_SYNC_SETTINGS,
|
|
34
|
+
STATS_CSS,
|
|
35
|
+
snapshotAtDelay,
|
|
36
|
+
} from "./overlay.ts";
|
|
37
|
+
|
|
38
|
+
/** What the page renders for one game at one moment. */
|
|
39
|
+
export interface LeagueSnapshot {
|
|
40
|
+
away: string;
|
|
41
|
+
home: string;
|
|
42
|
+
awayScore: string;
|
|
43
|
+
homeScore: string;
|
|
44
|
+
detail: string;
|
|
45
|
+
rows: Record<string, PanelRow[]>;
|
|
46
|
+
/** Local time the snapshot was fetched. */
|
|
47
|
+
at: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The page-side script for a league's overlay. `window.__sb.update(d)` takes
|
|
52
|
+
* `{ panels, delay, serving, game?: LeagueSnapshot }` — with no game yet, an
|
|
53
|
+
* enabled stats window says to open one. Rows render away-left, home-right.
|
|
54
|
+
* `window.__sb.catchupResult(text)` fills the "Get caught up" output.
|
|
55
|
+
*/
|
|
56
|
+
export function leagueBootstrap(spec: LeaguePanels, icon: string): string {
|
|
57
|
+
return [
|
|
58
|
+
"(function(){",
|
|
59
|
+
"if(window.__sbInit)return;window.__sbInit=true;",
|
|
60
|
+
"var D=" + JSON.stringify(panelDefaults(spec)) + ";",
|
|
61
|
+
"var PANELS=" + JSON.stringify(spec.panels) + ";",
|
|
62
|
+
JS_ESC,
|
|
63
|
+
JS_ROW,
|
|
64
|
+
JS_SHOW,
|
|
65
|
+
JS_CB,
|
|
66
|
+
JS_DRAGGABLE,
|
|
67
|
+
JS_CALL,
|
|
68
|
+
"function mk(){",
|
|
69
|
+
" if(!document.body){return setTimeout(mk,200);}",
|
|
70
|
+
" if(document.getElementById('sb-gear'))return;",
|
|
71
|
+
...JS_SHELL_CREATE,
|
|
72
|
+
" var stats=document.createElement('div');stats.id='sb-stats';",
|
|
73
|
+
" stats.style.cssText='" + STATS_CSS + "';stats.style.width='300px';",
|
|
74
|
+
" stats.innerHTML='"
|
|
75
|
+
+ "<div id=\"sb-stats-head\" style=\"display:flex;align-items:center;justify-content:space-between;padding:7px 11px;cursor:move;color:#8b949e;font-size:11px;border-bottom:1px solid #21262d\"><span>" + icon + " live</span><span id=\"sb-fresh\"></span></div>"
|
|
76
|
+
+ "<div style=\"padding:9px 13px 11px\"><div id=\"sb-body\"></div>"
|
|
77
|
+
// "Get caught up" sits outside sb-body (re-rendered every tick) so its output persists.
|
|
78
|
+
+ "<div id=\"sb-pl-catchup\" style=\"display:none\"><button id=\"sb-catchup\" style=\"margin-top:10px;width:100%;padding:6px;background:#6e40c9;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:12px\">Get caught up</button><div id=\"sb-catchup-status\" style=\"font-size:10px;margin-top:6px\"></div><div id=\"sb-catchup-out\" style=\"margin-top:8px;color:#c9d1d9;font-size:12px;white-space:pre-wrap\"></div></div>"
|
|
79
|
+
+ "</div>';",
|
|
80
|
+
" document.body.appendChild(stats);",
|
|
81
|
+
...JS_SHELL_WIRE,
|
|
82
|
+
" document.getElementById('sb-catchup').addEventListener('click',function(){document.getElementById('sb-catchup-out').textContent='Catching you up\u2026';call({fn:'catchup'});});",
|
|
83
|
+
"}",
|
|
84
|
+
"var SPECIAL=" + JSON.stringify(SPECIAL_PANELS) + ";",
|
|
85
|
+
"function anyPanel(P){for(var i=0;i<PANELS.length;i++)if(P[PANELS[i][0]])return true;return false;}",
|
|
86
|
+
"var DIM='<div style=\"color:#8b949e;font-size:11px\">';",
|
|
87
|
+
"window.__sb={",
|
|
88
|
+
" update:function(d){mk();var P=d.panels||{};",
|
|
89
|
+
...JS_SYNC_SETTINGS,
|
|
90
|
+
" show('sb-stats',anyPanel(P));var g=d.game,h='';",
|
|
91
|
+
" if(!g){h=DIM+'Open a game \\u2014 the overlay follows it.</div>';}else{",
|
|
92
|
+
" if(P.score)h+='<div style=\"display:flex;align-items:baseline;gap:8px\"><b style=\"flex:1\">'+esc(g.away+' '+g.awayScore+' @ '+g.home+' '+g.homeScore)+'</b><span style=\"color:#58a6ff;font-size:11px\">'+esc(g.detail)+'</span></div>';",
|
|
93
|
+
" for(var i=0;i<PANELS.length;i++){var k=PANELS[i][0];if(SPECIAL.indexOf(k)>=0||!P[k])continue;var rs=(g.rows&&g.rows[k])||[];",
|
|
94
|
+
" h+='<div style=\"margin-top:8px\">'+DIM+esc(PANELS[i][1])+' \\u00b7 '+esc(g.away)+' / '+esc(g.home)+'</div>';",
|
|
95
|
+
" if(!rs.length)h+=DIM+'no stats yet</div>';for(var j=0;j<rs.length;j++)h+=row(esc(rs[j].label),rs[j].away,rs[j].home);h+='</div>';}",
|
|
96
|
+
" }",
|
|
97
|
+
" var b=document.getElementById('sb-body');if(b)b.innerHTML=h;",
|
|
98
|
+
" show('sb-pl-catchup',!!P.catchup);var cst=document.getElementById('sb-catchup-status');if(P.catchup&&cst){if(d.serving){cst.textContent='\u25cf Claude agent connected';cst.style.color='#3fb950';}else{cst.textContent='\u25cb No agent \u2014 run /loop sportsing serve to enable recaps';cst.style.color='#d29922';}}",
|
|
99
|
+
" var fr=document.getElementById('sb-fresh');if(fr)fr.textContent=g&&g.at?'\\u27f3 '+g.at:'';}",
|
|
100
|
+
" ,catchupResult:function(t){mk();var o=document.getElementById('sb-catchup-out');if(o)o.textContent=t||'(no response)';}",
|
|
101
|
+
"};",
|
|
102
|
+
"mk();",
|
|
103
|
+
"})();",
|
|
104
|
+
].join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A summary → what the page renders (abbreviations, score, status, panel rows). */
|
|
108
|
+
export function snapshotOf(spec: LeaguePanels, s: EspnGameSummary): LeagueSnapshot {
|
|
109
|
+
return {
|
|
110
|
+
away: s.away.abbreviation || "?",
|
|
111
|
+
home: s.home.abbreviation || "?",
|
|
112
|
+
awayScore: s.away.score || "0",
|
|
113
|
+
homeScore: s.home.score || "0",
|
|
114
|
+
detail: s.detail,
|
|
115
|
+
rows: spec.rows(s),
|
|
116
|
+
at: new Date().toLocaleTimeString(),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
type OverlayLeague = Pick<LeagueConfig, "sport" | "league" | "label" | "icon" | "aliases">;
|
|
121
|
+
|
|
122
|
+
function specFor(cfg: OverlayLeague): LeaguePanels {
|
|
123
|
+
const spec = LEAGUE_PANELS[cfg.league];
|
|
124
|
+
if (!spec) throw new Error(`No overlay panels for ${cfg.label}.`);
|
|
125
|
+
return spec;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Open the stream window with a debug port and inject the overlay. Null
|
|
129
|
+
* (exit code set) if the window can't launch; session undefined if CDP fails. */
|
|
130
|
+
async function openWithOverlay(
|
|
131
|
+
url: string,
|
|
132
|
+
providerLabel: string,
|
|
133
|
+
bootstrap: string,
|
|
134
|
+
windowSize: { width: number; height: number } | undefined,
|
|
135
|
+
): Promise<{ win: StreamWindow; session?: CdpSession } | null> {
|
|
136
|
+
const port = await freePort();
|
|
137
|
+
const win = await spawnStreamWindow(url, providerLabel, { debugPort: port, windowSize });
|
|
138
|
+
if (!win) {
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const session = await attachToPage(port);
|
|
144
|
+
await session.send("Runtime.enable");
|
|
145
|
+
await session.send("Page.enable");
|
|
146
|
+
await session.send("Runtime.addBinding", { name: "__sbCall" });
|
|
147
|
+
await session.send("Page.addScriptToEvaluateOnNewDocument", { source: bootstrap });
|
|
148
|
+
await session.send("Runtime.evaluate", { expression: bootstrap });
|
|
149
|
+
return { win, session };
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.error(c.yellow("Overlay unavailable (CDP attach failed) — stream is still open."));
|
|
152
|
+
console.error(c.dim(e instanceof Error ? e.message : String(e)));
|
|
153
|
+
return { win };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Launch the stream with the NBA/NHL overlay; blocks until the window closes.
|
|
159
|
+
* `game` is the game to start on (null = wait for the page to name one);
|
|
160
|
+
* `gamesNow` lists the games the page could be showing (recent + today), for
|
|
161
|
+
* following a game picked on the page.
|
|
162
|
+
*/
|
|
163
|
+
export async function runLeagueOverlay(
|
|
164
|
+
cfg: OverlayLeague,
|
|
165
|
+
opts: {
|
|
166
|
+
url: string;
|
|
167
|
+
provider: { key: string; label: string };
|
|
168
|
+
game: Game | null;
|
|
169
|
+
gamesNow: () => Promise<Game[]>;
|
|
170
|
+
windowSize?: { width: number; height: number };
|
|
171
|
+
},
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
const spec = specFor(cfg);
|
|
174
|
+
const opened = await openWithOverlay(opts.url, opts.provider.label, leagueBootstrap(spec, cfg.icon), opts.windowSize);
|
|
175
|
+
if (!opened) return;
|
|
176
|
+
const { win, session } = opened;
|
|
177
|
+
|
|
178
|
+
console.log(c.bold(c.cyan(`${cfg.icon} Opening ${opts.provider.label} with the ${cfg.label} overlay`)) + c.dim(` ${opts.url}`));
|
|
179
|
+
console.log(c.dim("Just a floating ⚙ by default — click it to pick panels and sync the delay. The overlay follows the game you open."));
|
|
180
|
+
|
|
181
|
+
const panelsKey = `${opts.provider.key}:${cfg.sport}`;
|
|
182
|
+
let panels = await getOverlayPanels(panelsKey, panelDefaults(spec));
|
|
183
|
+
let delaySec = (await getStreamDelay(opts.provider.key)) ?? 0;
|
|
184
|
+
let current = opts.game;
|
|
185
|
+
// Each snapshot keeps its summary so "Get caught up" can recap up to the
|
|
186
|
+
// delayed stream's moment — never past it.
|
|
187
|
+
let buffer: { t: number; data: { snap: LeagueSnapshot; summary: EspnGameSummary } }[] = [];
|
|
188
|
+
let teams: EspnTeam[] = [];
|
|
189
|
+
let lastTitle = "";
|
|
190
|
+
let ticks = 0;
|
|
191
|
+
let running = false;
|
|
192
|
+
let serving = false; // whether a Claude agent is answering the serve bus
|
|
193
|
+
const ai = sportAiFor(cfg.league);
|
|
194
|
+
|
|
195
|
+
const push = (data: unknown) =>
|
|
196
|
+
session?.send("Runtime.evaluate", { expression: "window.__sb&&window.__sb.update(" + JSON.stringify(data) + ")" }).catch(() => {});
|
|
197
|
+
const render = () => push({ panels, delay: delaySec, serving, game: snapshotAtDelay(buffer, delaySec, Date.now())?.snap ?? null });
|
|
198
|
+
|
|
199
|
+
// "Get caught up": recap the delayed summary in the sport's terms via the serve
|
|
200
|
+
// bus; requestRecap handles the nothing-yet / no-agent / timeout cases.
|
|
201
|
+
const runCatchup = async (): Promise<string> => {
|
|
202
|
+
const at = snapshotAtDelay(buffer, delaySec, Date.now());
|
|
203
|
+
if (!at || !current) return "Open a game first — nothing to catch up on yet.";
|
|
204
|
+
if (!ai) return `No recap voice for ${cfg.label}.`;
|
|
205
|
+
const res = await requestRecap(catchupInput(ai, current, at.summary), { maxChars: 600 });
|
|
206
|
+
return res.ok ? res.recap : res.message;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const readTitle = async (): Promise<string> => {
|
|
210
|
+
try {
|
|
211
|
+
const r = await session?.send("Runtime.evaluate", { expression: "document.title", returnByValue: true });
|
|
212
|
+
return r?.result?.result?.value ?? "";
|
|
213
|
+
} catch {
|
|
214
|
+
return "";
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
// Follow the page: a title naming two of the league's teams switches to
|
|
219
|
+
// their game (if it's one of today's). Anything else keeps the current game.
|
|
220
|
+
const follow = async () => {
|
|
221
|
+
const title = await readTitle();
|
|
222
|
+
if (title === lastTitle) return;
|
|
223
|
+
lastTitle = title;
|
|
224
|
+
if (!teams.length) teams = await getTeams(cfg.league).catch(() => []);
|
|
225
|
+
const ids = teamsFromTitle(title, teams, cfg.aliases);
|
|
226
|
+
if (!ids) return;
|
|
227
|
+
const found = gameBetween(await opts.gamesNow().catch(() => []), ids, Date.now());
|
|
228
|
+
if (found && found.id !== current?.id) {
|
|
229
|
+
current = found;
|
|
230
|
+
buffer = [];
|
|
231
|
+
console.log(c.dim(`Following ${found.name}.`));
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const tick = async () => {
|
|
236
|
+
if (running || !session) return;
|
|
237
|
+
running = true;
|
|
238
|
+
try {
|
|
239
|
+
await follow();
|
|
240
|
+
if (ticks % 5 === 0) serving = await isServing().catch(() => false);
|
|
241
|
+
if (current && ticks % 5 === 0) {
|
|
242
|
+
try {
|
|
243
|
+
const summary = await getGameSummary(cfg.league, current.id, 5_000);
|
|
244
|
+
if (summary) {
|
|
245
|
+
buffer.push({ t: Date.now(), data: { snap: snapshotOf(spec, summary), summary } });
|
|
246
|
+
buffer = buffer.filter((b) => Date.now() - b.t <= 330_000); // keep > 5 min so a max delay has data
|
|
247
|
+
}
|
|
248
|
+
} catch {
|
|
249
|
+
/* transient */
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
render();
|
|
253
|
+
ticks++;
|
|
254
|
+
} finally {
|
|
255
|
+
running = false;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
if (session) {
|
|
260
|
+
session.onEvent(async (method, params) => {
|
|
261
|
+
if (method !== "Runtime.bindingCalled" || params?.name !== "__sbCall") return;
|
|
262
|
+
try {
|
|
263
|
+
const msg = JSON.parse(params.payload ?? "{}");
|
|
264
|
+
if (msg.fn === "delay" && typeof msg.set === "number") {
|
|
265
|
+
delaySec = Math.min(300, Math.max(0, Math.round(msg.set))); // 0–5 min
|
|
266
|
+
await setStreamDelay(opts.provider.key, delaySec);
|
|
267
|
+
render();
|
|
268
|
+
} else if (msg.fn === "pref" && typeof msg.key === "string") {
|
|
269
|
+
panels = { ...panels, [msg.key]: !!msg.on };
|
|
270
|
+
await setOverlayPanel(panelsKey, msg.key, !!msg.on);
|
|
271
|
+
render();
|
|
272
|
+
} else if (msg.fn === "catchup") {
|
|
273
|
+
const text = await runCatchup();
|
|
274
|
+
session?.send("Runtime.evaluate", { expression: "window.__sb&&window.__sb.catchupResult(" + JSON.stringify(text) + ")" }).catch(() => {});
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
/* malformed */
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
await tick();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const poll = session ? setInterval(tick, 1_000) : null;
|
|
284
|
+
const stop = () => {
|
|
285
|
+
if (poll) clearInterval(poll);
|
|
286
|
+
session?.close();
|
|
287
|
+
win.close();
|
|
288
|
+
process.exit(0);
|
|
289
|
+
};
|
|
290
|
+
process.on("SIGINT", stop);
|
|
291
|
+
process.on("SIGTERM", stop);
|
|
292
|
+
|
|
293
|
+
await win.exited;
|
|
294
|
+
if (poll) clearInterval(poll);
|
|
295
|
+
session?.close();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* `watch --overlay --smoke`: open the window, inject the overlay, render every
|
|
300
|
+
* panel from an empty box score, and confirm over CDP that the gear and each
|
|
301
|
+
* panel came up — then tear down and exit (1 on any failure). Bounded; for
|
|
302
|
+
* scripts and agents.
|
|
303
|
+
*/
|
|
304
|
+
export async function smokeLeagueOverlay(
|
|
305
|
+
cfg: OverlayLeague,
|
|
306
|
+
url: string,
|
|
307
|
+
providerLabel: string,
|
|
308
|
+
windowSize: { width: number; height: number } | undefined,
|
|
309
|
+
): Promise<void> {
|
|
310
|
+
const spec = specFor(cfg);
|
|
311
|
+
const opened = await openWithOverlay(url, providerLabel, leagueBootstrap(spec, cfg.icon), windowSize);
|
|
312
|
+
if (!opened) return;
|
|
313
|
+
const { win, session } = opened;
|
|
314
|
+
try {
|
|
315
|
+
if (!session) throw new Error("CDP attach failed");
|
|
316
|
+
const side = { id: "", name: "", score: "0", linescores: [], record: "", stats: [], leaders: [], goalies: [] };
|
|
317
|
+
const empty: EspnGameSummary = {
|
|
318
|
+
state: "pre",
|
|
319
|
+
detail: "smoke",
|
|
320
|
+
period: 1,
|
|
321
|
+
away: { ...side, abbreviation: "AWY" },
|
|
322
|
+
home: { ...side, abbreviation: "HOM" },
|
|
323
|
+
plays: [],
|
|
324
|
+
};
|
|
325
|
+
const game = snapshotOf(spec, empty);
|
|
326
|
+
const all = Object.fromEntries(spec.panels.map(([k]) => [k, true]));
|
|
327
|
+
// Stat panels render a titled section; "Get caught up" its button.
|
|
328
|
+
const titles = spec.panels.filter(([k]) => !SPECIAL_PANELS.includes(k)).map(([, label]) => label).concat(["Get caught up"]);
|
|
329
|
+
const check =
|
|
330
|
+
"(function(){window.__sb.update(" + JSON.stringify({ panels: all, delay: 0, game }) + ");" +
|
|
331
|
+
"var s=document.getElementById('sb-stats'),b=document.getElementById('sb-body');" +
|
|
332
|
+
"if(!document.getElementById('sb-gear')||!s||s.style.display==='none'||!b)return false;" +
|
|
333
|
+
"var t=s.textContent;return " + JSON.stringify(titles) + ".every(function(x){return t.indexOf(x)>=0;})&&t.indexOf('AWY 0 @ HOM 0')>=0;})()";
|
|
334
|
+
// The window is still navigating (redirect view → provider), which re-injects
|
|
335
|
+
// the overlay on the new document — so retry until the panels render there.
|
|
336
|
+
let ok = false;
|
|
337
|
+
for (let i = 0; i < 15 && !ok; i++) {
|
|
338
|
+
if (i) await new Promise((r) => setTimeout(r, 1_000));
|
|
339
|
+
const r = await session.send("Runtime.evaluate", { expression: check, returnByValue: true }).catch(() => null);
|
|
340
|
+
ok = r?.result?.result?.value === true;
|
|
341
|
+
}
|
|
342
|
+
session.close();
|
|
343
|
+
if (!ok) throw new Error("the overlay didn't render its panels within 15s");
|
|
344
|
+
console.log(c.green(`✓ watch --overlay --smoke: ${providerLabel} window opened and the ${cfg.label} overlay rendered — tearing it down.`));
|
|
345
|
+
} catch (e) {
|
|
346
|
+
console.error(c.yellow(`watch --overlay --smoke: ${e instanceof Error ? e.message : String(e)}`));
|
|
347
|
+
process.exitCode = 1;
|
|
348
|
+
} finally {
|
|
349
|
+
win.close();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// What the NBA/NHL stream overlay shows: each league's panel list (the
|
|
2
|
+
// settings checkboxes) and the pure mapping from a game summary (espn.ts
|
|
3
|
+
// getGameSummary) to those panels' rows. No I/O — src/league-overlay.ts
|
|
4
|
+
// fetches and renders.
|
|
5
|
+
//
|
|
6
|
+
// Rows read US-style, away team on the left and home on the right, matching
|
|
7
|
+
// the "AWAY @ HOME" score line.
|
|
8
|
+
|
|
9
|
+
import { LEAGUES, type EspnGameSummary, type EspnSummarySide, type League } from "./espn.ts";
|
|
10
|
+
|
|
11
|
+
/** One stat row: a centered label between the two teams' values. */
|
|
12
|
+
export interface PanelRow {
|
|
13
|
+
label: string;
|
|
14
|
+
away: string;
|
|
15
|
+
home: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LeaguePanels {
|
|
19
|
+
/** [key, settings label] in display order. "score" is the score/clock line
|
|
20
|
+
* and "catchup" the "Get caught up" button; the rest are stat panels. */
|
|
21
|
+
panels: [string, string][];
|
|
22
|
+
/** Rows for every stat panel, keyed like `panels`. */
|
|
23
|
+
rows: (s: EspnGameSummary) => Record<string, PanelRow[]>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Panels the page renders itself rather than from `rows`. */
|
|
27
|
+
export const SPECIAL_PANELS = ["score", "catchup"];
|
|
28
|
+
|
|
29
|
+
/** NBA teams get 7 timeouts in regulation (OT periods add 2 each). */
|
|
30
|
+
export const NBA_REGULATION_TIMEOUTS = 7;
|
|
31
|
+
const NBA_REGULATION_PERIODS = 4;
|
|
32
|
+
|
|
33
|
+
/** A box-score value by ESPN stat name, or "" when the feed doesn't have it (yet). */
|
|
34
|
+
function statOf(side: EspnSummarySide, name: string): string {
|
|
35
|
+
return side.stats.find((x) => x.name === name)?.value ?? "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stat(side: EspnSummarySide, name: string): string {
|
|
39
|
+
return statOf(side, name) || "—";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** "39-93 · 42%" — made-attempted plus the percentage, whichever exist. */
|
|
43
|
+
function shooting(side: EspnSummarySide, madeAtt: string, pct: string): string {
|
|
44
|
+
const p = statOf(side, pct);
|
|
45
|
+
const parts = [statOf(side, madeAtt), p ? p + "%" : ""].filter(Boolean);
|
|
46
|
+
return parts.length ? parts.join(" · ") : "—";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** "Lauri Markkanen" → "L. Markkanen" — keeps two names on one panel row. */
|
|
50
|
+
export function shortName(full: string): string {
|
|
51
|
+
const [first, ...rest] = full.trim().split(/\s+/);
|
|
52
|
+
return rest.length && first ? `${first[0]}. ${rest.join(" ")}` : full.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function leader(side: EspnSummarySide, category: string): string {
|
|
56
|
+
const l = side.leaders.find((x) => x.category === category);
|
|
57
|
+
return l ? `${shortName(l.athlete)} ${l.value}`.trim() : "—";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The current (or last) period: the header's, else the latest play's. */
|
|
61
|
+
export function currentPeriod(s: EspnGameSummary): number {
|
|
62
|
+
return s.period || s.plays.reduce((max, p) => Math.max(max, p.period), 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Timeouts a team has called, counted from the play-by-play. */
|
|
66
|
+
export function timeoutsUsed(s: EspnGameSummary, teamId: string): number {
|
|
67
|
+
return s.plays.filter((p) => p.teamId === teamId && /timeout/i.test(p.type)).length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Team fouls toward the bonus in `period` (the fouling team is the play's
|
|
71
|
+
* team; offensive fouls, their turnover rows, and technicals don't count). */
|
|
72
|
+
export function periodFouls(s: EspnGameSummary, teamId: string, period: number): number {
|
|
73
|
+
return s.plays.filter(
|
|
74
|
+
(p) => p.teamId === teamId && p.period === period && /foul/i.test(p.type) && !/offensive|technical|turnover/i.test(p.type),
|
|
75
|
+
).length;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function nbaRows(s: EspnGameSummary): Record<string, PanelRow[]> {
|
|
79
|
+
const both = (label: string, f: (side: EspnSummarySide) => string): PanelRow => ({ label, away: f(s.away), home: f(s.home) });
|
|
80
|
+
const period = currentPeriod(s);
|
|
81
|
+
const used = (side: EspnSummarySide) => timeoutsUsed(s, side.id);
|
|
82
|
+
const timeouts: PanelRow[] = [both("used", (side) => String(used(side)))];
|
|
83
|
+
// Remaining is only knowable from the rules in regulation; OT resets to 2.
|
|
84
|
+
if (period <= NBA_REGULATION_PERIODS) {
|
|
85
|
+
timeouts.push(both("left", (side) => String(Math.max(0, NBA_REGULATION_TIMEOUTS - used(side)))));
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
shooting: [
|
|
89
|
+
both("FG", (side) => shooting(side, "fieldGoalsMade-fieldGoalsAttempted", "fieldGoalPct")),
|
|
90
|
+
both("3PT", (side) => shooting(side, "threePointFieldGoalsMade-threePointFieldGoalsAttempted", "threePointFieldGoalPct")),
|
|
91
|
+
],
|
|
92
|
+
leaders: [
|
|
93
|
+
both("PTS", (side) => leader(side, "Points")),
|
|
94
|
+
both("REB", (side) => leader(side, "Rebounds")),
|
|
95
|
+
both("AST", (side) => leader(side, "Assists")),
|
|
96
|
+
],
|
|
97
|
+
fouls: [both("total", (side) => stat(side, "fouls")), both("this period", (side) => String(periodFouls(s, side.id, period)))],
|
|
98
|
+
timeouts,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One row per goalie slot: "S. Cossa 17/18 .944" each side. */
|
|
103
|
+
function goalieRows(s: EspnGameSummary): PanelRow[] {
|
|
104
|
+
const line = (side: EspnSummarySide, i: number): string => {
|
|
105
|
+
const gl = side.goalies[i];
|
|
106
|
+
if (!gl) return "";
|
|
107
|
+
return `${shortName(gl.athlete)} ${gl.saves || "0"}/${gl.shotsAgainst || "0"}${gl.savePct ? " " + gl.savePct : ""}`;
|
|
108
|
+
};
|
|
109
|
+
const n = Math.max(s.away.goalies.length, s.home.goalies.length);
|
|
110
|
+
return Array.from({ length: n }, (_, i) => ({ label: "saves", away: line(s.away, i), home: line(s.home, i) }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function nhlRows(s: EspnGameSummary): Record<string, PanelRow[]> {
|
|
114
|
+
const both = (label: string, f: (side: EspnSummarySide) => string): PanelRow => ({ label, away: f(s.away), home: f(s.home) });
|
|
115
|
+
return {
|
|
116
|
+
shots: [both("SOG", (side) => stat(side, "shotsTotal"))],
|
|
117
|
+
powerplay: [
|
|
118
|
+
both("PP", (side) =>
|
|
119
|
+
statOf(side, "powerPlayGoals") || statOf(side, "powerPlayOpportunities")
|
|
120
|
+
? `${stat(side, "powerPlayGoals")}/${stat(side, "powerPlayOpportunities")}`
|
|
121
|
+
: "—",
|
|
122
|
+
),
|
|
123
|
+
both("PIM", (side) => stat(side, "penaltyMinutes")),
|
|
124
|
+
],
|
|
125
|
+
faceoffs: [both("FO%", (side) => (statOf(side, "faceoffPercent") ? statOf(side, "faceoffPercent") + "%" : "—"))],
|
|
126
|
+
goalies: goalieRows(s),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Panel specs for the leagues the overlay supports (by ESPN league path). */
|
|
131
|
+
export const LEAGUE_PANELS: Partial<Record<League, LeaguePanels>> = {
|
|
132
|
+
[LEAGUES.nba]: {
|
|
133
|
+
panels: [
|
|
134
|
+
["score", "Score & clock"],
|
|
135
|
+
["shooting", "FG% / 3P%"],
|
|
136
|
+
["leaders", "Leaders (PTS / REB / AST)"],
|
|
137
|
+
["fouls", "Fouls"],
|
|
138
|
+
["timeouts", "Timeouts"],
|
|
139
|
+
["catchup", "Get caught up"],
|
|
140
|
+
],
|
|
141
|
+
rows: nbaRows,
|
|
142
|
+
},
|
|
143
|
+
[LEAGUES.nhl]: {
|
|
144
|
+
panels: [
|
|
145
|
+
["score", "Score & clock"],
|
|
146
|
+
["shots", "Shots on goal"],
|
|
147
|
+
["powerplay", "Power plays"],
|
|
148
|
+
["faceoffs", "Faceoff %"],
|
|
149
|
+
["goalies", "Goalie saves"],
|
|
150
|
+
["catchup", "Get caught up"],
|
|
151
|
+
],
|
|
152
|
+
rows: nhlRows,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/** Every panel off — a fresh stream shows just the gear, like the FIFA overlay. */
|
|
157
|
+
export function panelDefaults(spec: LeaguePanels): Record<string, boolean> {
|
|
158
|
+
return Object.fromEntries(spec.panels.map(([k]) => [k, false]));
|
|
159
|
+
}
|