sportsing 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/package.json +53 -0
- package/src/ansi.ts +36 -0
- package/src/api.ts +157 -0
- package/src/ask-bus.ts +172 -0
- package/src/cdp.ts +85 -0
- package/src/commands/_lib.ts +88 -0
- package/src/commands/agent-setup.ts +57 -0
- package/src/commands/analyze.ts +81 -0
- package/src/commands/ask.ts +138 -0
- package/src/commands/bracket.ts +56 -0
- package/src/commands/fav.ts +47 -0
- package/src/commands/fixtures.ts +52 -0
- package/src/commands/highlights.ts +32 -0
- package/src/commands/live.ts +175 -0
- package/src/commands/me.ts +65 -0
- package/src/commands/next.ts +40 -0
- package/src/commands/predict.ts +121 -0
- package/src/commands/recap.ts +66 -0
- package/src/commands/results.ts +37 -0
- package/src/commands/schedule.ts +34 -0
- package/src/commands/scorers.ts +35 -0
- package/src/commands/setup.ts +40 -0
- package/src/commands/stats.ts +82 -0
- package/src/commands/table.ts +47 -0
- package/src/commands/teams.ts +66 -0
- package/src/commands/today.ts +58 -0
- package/src/commands/watch.ts +230 -0
- package/src/config.ts +144 -0
- package/src/espn.ts +307 -0
- package/src/events.ts +85 -0
- package/src/format.ts +180 -0
- package/src/index.ts +71 -0
- package/src/liveness.ts +82 -0
- package/src/match-detect.ts +136 -0
- package/src/match-util.ts +19 -0
- package/src/notify.ts +94 -0
- package/src/overlay.ts +625 -0
- package/src/recap.ts +111 -0
- package/src/sports/fifa.ts +139 -0
- package/src/stream.ts +160 -0
- package/src/types.ts +91 -0
package/src/liveness.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Liveness primitive for a running `watch --wait`: a pidfile so a supervisor
|
|
2
|
+
// (the agent-setup --check status + the /loop agent-setup skill) can tell whether
|
|
3
|
+
// the watcher is alive and restart it after a silent death. Zero-dep — plain fs
|
|
4
|
+
// over the sportsing cache dir.
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, writeFileSync, unlinkSync, readFileSync } from "fs";
|
|
7
|
+
import { dirname, join } from "path";
|
|
8
|
+
import { CACHE_DIR } from "./config.ts";
|
|
9
|
+
|
|
10
|
+
/** Stable, documented pidfile path for a `watch --wait` process. Other commands
|
|
11
|
+
* (e.g. `agent-setup --check`) locate the watcher here without guessing:
|
|
12
|
+
* `~/.cache/sportsing/watch-wait.pid` (CACHE_DIR/watch-wait.pid). */
|
|
13
|
+
export const WATCH_PIDFILE = join(CACHE_DIR, "watch-wait.pid");
|
|
14
|
+
|
|
15
|
+
/** True if `pid` names a process that currently exists. Uses signal 0, which does
|
|
16
|
+
* no actual signalling — it only probes existence (throws ESRCH if gone). */
|
|
17
|
+
export function pidAlive(pid: number): boolean {
|
|
18
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
19
|
+
try {
|
|
20
|
+
process.kill(pid, 0);
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false; // ESRCH (no such process) → not alive
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Record this process's PID in the watch pidfile and arrange its removal on a
|
|
29
|
+
* clean exit and on SIGINT/SIGTERM. Call once at the start of a `watch --wait`.
|
|
30
|
+
* Writes nothing to stdout (preserves the backgroundable `--quiet &` contract).
|
|
31
|
+
*/
|
|
32
|
+
export function writeWatchPidfile(): void {
|
|
33
|
+
mkdirSync(dirname(WATCH_PIDFILE), { recursive: true });
|
|
34
|
+
writeFileSync(WATCH_PIDFILE, String(process.pid));
|
|
35
|
+
|
|
36
|
+
let removed = false;
|
|
37
|
+
const cleanup = () => {
|
|
38
|
+
if (removed) return;
|
|
39
|
+
removed = true;
|
|
40
|
+
try {
|
|
41
|
+
unlinkSync(WATCH_PIDFILE);
|
|
42
|
+
} catch {
|
|
43
|
+
/* already gone */
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
process.on("exit", cleanup); // covers normal return + any process.exit()
|
|
47
|
+
|
|
48
|
+
// Remove the pidfile on signals too. We must NOT process.exit() here when
|
|
49
|
+
// another handler is already registered (the overlay installs a SIGINT handler
|
|
50
|
+
// that closes the Chrome window before exiting) — exiting first would orphan
|
|
51
|
+
// that window. So: clean up the pidfile, and only when we're the sole listener
|
|
52
|
+
// (the pre-overlay wait phase) re-raise the signal to terminate normally.
|
|
53
|
+
for (const sig of ["SIGINT", "SIGTERM"] as const) {
|
|
54
|
+
const handler = () => {
|
|
55
|
+
cleanup();
|
|
56
|
+
if (process.listenerCount(sig) <= 1) {
|
|
57
|
+
process.removeListener(sig, handler);
|
|
58
|
+
process.kill(process.pid, sig);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
process.on(sig, handler);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The PID recorded in the pidfile, or null if absent/malformed. */
|
|
66
|
+
export function readWatchPid(): number | null {
|
|
67
|
+
try {
|
|
68
|
+
const pid = Number(readFileSync(WATCH_PIDFILE, "utf8").trim());
|
|
69
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Whether a `watch --wait` is currently alive: the pidfile must exist AND name a
|
|
76
|
+
* live process. A stale pidfile (process gone) reports false — never a false
|
|
77
|
+
* "running". Scope: only `watch --wait` writes the pidfile, so a live *non*-wait
|
|
78
|
+
* `watch` (e.g. a one-shot hub open) correctly reports false here. */
|
|
79
|
+
export function isWatchAlive(): boolean {
|
|
80
|
+
const pid = readWatchPid();
|
|
81
|
+
return pid != null && pidAlive(pid);
|
|
82
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Detect which match the user is on from the stream page's document.title, so
|
|
2
|
+
// the overlay can follow them. Peacock/Telemundo titles are Spanish
|
|
3
|
+
// ("Catar v. Suiza - Peacock"); Fubo/Fox are English. We canonicalize each
|
|
4
|
+
// side to an ESPN team abbreviation (TLA) when we know the Spanish alias, else
|
|
5
|
+
// pass the raw token through (English names resolve by name match downstream).
|
|
6
|
+
|
|
7
|
+
/** Normalized Spanish (and a few English) team names → ESPN TLA. */
|
|
8
|
+
const NAME_TO_TLA: Record<string, string> = {
|
|
9
|
+
"estados unidos": "USA",
|
|
10
|
+
catar: "QAT",
|
|
11
|
+
suiza: "SUI",
|
|
12
|
+
brasil: "BRA",
|
|
13
|
+
marruecos: "MAR",
|
|
14
|
+
turquia: "TUR",
|
|
15
|
+
"corea del sur": "KOR",
|
|
16
|
+
sudafrica: "RSA",
|
|
17
|
+
mexico: "MEX",
|
|
18
|
+
chequia: "CZE",
|
|
19
|
+
"republica checa": "CZE",
|
|
20
|
+
"bosnia y herzegovina": "BIH",
|
|
21
|
+
canada: "CAN",
|
|
22
|
+
haiti: "HAI",
|
|
23
|
+
escocia: "SCO",
|
|
24
|
+
argelia: "ALG",
|
|
25
|
+
belgica: "BEL",
|
|
26
|
+
alemania: "GER",
|
|
27
|
+
espana: "ESP",
|
|
28
|
+
inglaterra: "ENG",
|
|
29
|
+
francia: "FRA",
|
|
30
|
+
croacia: "CRO",
|
|
31
|
+
"paises bajos": "NED",
|
|
32
|
+
holanda: "NED",
|
|
33
|
+
japon: "JPN",
|
|
34
|
+
// identical or near-identical across languages (still useful as explicit hits)
|
|
35
|
+
paraguay: "PAR",
|
|
36
|
+
australia: "AUS",
|
|
37
|
+
argentina: "ARG",
|
|
38
|
+
portugal: "POR",
|
|
39
|
+
uruguay: "URU",
|
|
40
|
+
colombia: "COL",
|
|
41
|
+
senegal: "SEN",
|
|
42
|
+
ecuador: "ECU",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Lowercase + strip accents/punctuation for forgiving lookup. */
|
|
46
|
+
function normalize(s: string): string {
|
|
47
|
+
return s
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.normalize("NFD")
|
|
50
|
+
.replace(/[̀-ͯ]/g, "")
|
|
51
|
+
.replace(/[^a-z ]/g, "")
|
|
52
|
+
.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function canonical(token: string): string {
|
|
56
|
+
const n = normalize(token);
|
|
57
|
+
return NAME_TO_TLA[n] ?? n; // TLA when known, else the normalized token
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type StreamLang = "english" | "spanish";
|
|
61
|
+
|
|
62
|
+
export type PageContext =
|
|
63
|
+
| { kind: "match"; teams: [string, string]; lang?: StreamLang }
|
|
64
|
+
| { kind: "today" }
|
|
65
|
+
| { kind: "unknown" };
|
|
66
|
+
|
|
67
|
+
// Team names that are *distinctively* one language (the normalized form differs
|
|
68
|
+
// across English/Spanish), so seeing one in a title reveals the broadcast cast.
|
|
69
|
+
// These are EXPLICIT (not derived from NAME_TO_TLA) on purpose: NAME_TO_TLA is a
|
|
70
|
+
// lookup table whose keys could gain English-form entries, which would silently
|
|
71
|
+
// corrupt language inference. Language-neutral names (Mexico, Paraguay, Australia,
|
|
72
|
+
// Argentina, Portugal, Uruguay, Colombia, Senegal, Ecuador, Canada, Haiti) carry
|
|
73
|
+
// no signal and appear in neither set.
|
|
74
|
+
const SPANISH_NAMES = new Set([
|
|
75
|
+
"estados unidos", "catar", "suiza", "brasil", "marruecos", "turquia", "corea del sur",
|
|
76
|
+
"sudafrica", "chequia", "republica checa", "bosnia y herzegovina", "escocia", "argelia",
|
|
77
|
+
"belgica", "alemania", "espana", "inglaterra", "francia", "croacia", "paises bajos",
|
|
78
|
+
"holanda", "japon",
|
|
79
|
+
]);
|
|
80
|
+
const ENGLISH_NAMES = new Set([
|
|
81
|
+
"united states", "qatar", "switzerland", "brazil", "morocco", "turkey", "turkiye",
|
|
82
|
+
"south korea", "south africa", "czechia", "czech republic", "bosnia and herzegovina",
|
|
83
|
+
"scotland", "algeria", "belgium", "germany", "spain", "england", "france", "croatia",
|
|
84
|
+
"netherlands", "holland", "japan",
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
/** Infer the broadcast language from the two team tokens + provider suffix.
|
|
88
|
+
* Conservative: returns undefined when undeterminable (so callers never warn on
|
|
89
|
+
* a guess). A distinctive team name decides it — Spanish is checked first, so a
|
|
90
|
+
* mixed title (one Spanish, one English token — not expected in a real cast)
|
|
91
|
+
* resolves to spanish. Otherwise Peacock's World Cup feed is Telemundo (Spanish). */
|
|
92
|
+
function inferLang(a: string, b: string, isPeacock: boolean): StreamLang | undefined {
|
|
93
|
+
const na = normalize(a);
|
|
94
|
+
const nb = normalize(b);
|
|
95
|
+
if (SPANISH_NAMES.has(na) || SPANISH_NAMES.has(nb)) return "spanish";
|
|
96
|
+
if (ENGLISH_NAMES.has(na) || ENGLISH_NAMES.has(nb)) return "english";
|
|
97
|
+
if (isPeacock) return "spanish";
|
|
98
|
+
return undefined; // e.g. Fubo + only language-neutral names — can't tell
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Classify a stream page from its title.
|
|
103
|
+
* - "<A> v. <B> - Provider" → match (teams canonicalized to TLA/name; lang when known)
|
|
104
|
+
* - World Cup hub / home → today
|
|
105
|
+
* - anything else → unknown (overlay keeps its current match)
|
|
106
|
+
*/
|
|
107
|
+
export function detectFromTitle(title: string): PageContext {
|
|
108
|
+
const raw = title || "";
|
|
109
|
+
const isPeacock = /peacock/i.test(raw);
|
|
110
|
+
const t = raw.replace(/\s*[-|]\s*(Peacock|Fubo|fubo\.tv).*$/i, "").trim();
|
|
111
|
+
|
|
112
|
+
const vs = t.match(/^(.+?)\s+(?:v\.?|vs\.?|versus)\s+(.+)$/i);
|
|
113
|
+
if (vs && vs[1] && vs[2]) {
|
|
114
|
+
const lang = inferLang(vs[1], vs[2], isPeacock);
|
|
115
|
+
const teams: [string, string] = [canonical(vs[1]), canonical(vs[2])];
|
|
116
|
+
return lang ? { kind: "match", teams, lang } : { kind: "match", teams };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const n = normalize(t);
|
|
120
|
+
if (/(copa mundial|world cup|fifa|home|inicio)/.test(n)) return { kind: "today" };
|
|
121
|
+
|
|
122
|
+
return { kind: "unknown" };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Candidate substrings to recognize a team on a provider page (English name,
|
|
126
|
+
* abbreviation, and any Spanish aliases) — for finding a game tile to click. */
|
|
127
|
+
export function searchTerms(name: string, abbr: string): string[] {
|
|
128
|
+
const out = new Set<string>();
|
|
129
|
+
if (name) out.add(normalize(name));
|
|
130
|
+
if (abbr) out.add(abbr.toLowerCase());
|
|
131
|
+
const tla = abbr.toUpperCase();
|
|
132
|
+
for (const [alias, t] of Object.entries(NAME_TO_TLA)) {
|
|
133
|
+
if (t === tla) out.add(alias);
|
|
134
|
+
}
|
|
135
|
+
return [...out].filter(Boolean);
|
|
136
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Pure, dependency-light helpers over the Match shape. Lives at the top level
|
|
2
|
+
// (not under commands/) so both the command layer (_lib.ts) and pure modules
|
|
3
|
+
// like events.ts can share one source of truth without events.ts taking a
|
|
4
|
+
// dependency on the I/O-bearing command layer.
|
|
5
|
+
|
|
6
|
+
import type { Match } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
/** True if either side of the match matches `needle` (lowercased) by name/tla/shortName. */
|
|
9
|
+
export function matchHasTeam(m: Match, needle: string): boolean {
|
|
10
|
+
const fields = [
|
|
11
|
+
m.homeTeam.name,
|
|
12
|
+
m.homeTeam.tla,
|
|
13
|
+
m.homeTeam.shortName,
|
|
14
|
+
m.awayTeam.name,
|
|
15
|
+
m.awayTeam.tla,
|
|
16
|
+
m.awayTeam.shortName,
|
|
17
|
+
];
|
|
18
|
+
return fields.some((f) => f?.toLowerCase().includes(needle));
|
|
19
|
+
}
|
package/src/notify.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Zero-dep OS-notification helper. Raises a desktop notification via the best
|
|
2
|
+
// available backend, optionally clickable to run a command, and degrades
|
|
3
|
+
// gracefully (terminal bell, never throws) where no notifier binary exists.
|
|
4
|
+
//
|
|
5
|
+
// Backend preference:
|
|
6
|
+
// macOS terminal-notifier (supports -execute on-click) → osascript (no click)
|
|
7
|
+
// Linux notify-send (no click)
|
|
8
|
+
// any terminal bell (\a) when nothing else is available
|
|
9
|
+
//
|
|
10
|
+
// Mirrors the Bun.which-guard pattern used for the `ui-leaf` CLI in
|
|
11
|
+
// src/stream.ts. Fire-and-forget: spawns the notifier without blocking and
|
|
12
|
+
// swallows spawn errors so a missing/odd binary never breaks a live tick.
|
|
13
|
+
|
|
14
|
+
export interface NotifyOptions {
|
|
15
|
+
/** Smaller line shown under the title (macOS only). */
|
|
16
|
+
subtitle?: string;
|
|
17
|
+
/** Play a sound: `true` for the default, or a named system sound (e.g. "Ping"). macOS only. */
|
|
18
|
+
sound?: boolean | string;
|
|
19
|
+
/** Collapse repeated alerts: notifications sharing a group replace each other. macOS + terminal-notifier only. */
|
|
20
|
+
group?: string;
|
|
21
|
+
/** Shell command run when the notification is clicked. macOS + terminal-notifier only. */
|
|
22
|
+
onClick?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type NotifyBackend = "terminal-notifier" | "osascript" | "notify-send" | "bell";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which notifier backend `notify` would use right now. On macOS, prefer
|
|
29
|
+
* terminal-notifier (the only backend that supports `onClick` via -execute);
|
|
30
|
+
* when it's absent, osascript still raises the notification but silently drops
|
|
31
|
+
* any click action. Callers that need click support can check for the
|
|
32
|
+
* "terminal-notifier" result before relying on `onClick`.
|
|
33
|
+
*/
|
|
34
|
+
export function notifierBackend(): NotifyBackend {
|
|
35
|
+
if (process.platform === "darwin") {
|
|
36
|
+
if (Bun.which("terminal-notifier")) return "terminal-notifier";
|
|
37
|
+
if (Bun.which("osascript")) return "osascript";
|
|
38
|
+
} else if (process.platform === "linux") {
|
|
39
|
+
if (Bun.which("notify-send")) return "notify-send";
|
|
40
|
+
}
|
|
41
|
+
return "bell";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Escape a string for embedding in an AppleScript double-quoted literal. */
|
|
45
|
+
function osaEscape(s: string): string {
|
|
46
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function spawnQuiet(cmd: string[]): void {
|
|
50
|
+
try {
|
|
51
|
+
Bun.spawn(cmd, { stdout: "ignore", stderr: "ignore" });
|
|
52
|
+
} catch {
|
|
53
|
+
// A flaky/missing binary must never break the caller — fall through silently.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Raise a desktop notification. Returns the backend that handled it; "bell" means
|
|
59
|
+
* no notifier binary was available and a terminal bell was emitted instead. Never throws.
|
|
60
|
+
*/
|
|
61
|
+
export function notify(title: string, body: string, opts: NotifyOptions = {}): NotifyBackend {
|
|
62
|
+
const backend = notifierBackend();
|
|
63
|
+
|
|
64
|
+
if (backend === "terminal-notifier") {
|
|
65
|
+
const cmd = ["terminal-notifier", "-title", title, "-message", body];
|
|
66
|
+
if (opts.subtitle) cmd.push("-subtitle", opts.subtitle);
|
|
67
|
+
if (opts.group) cmd.push("-group", opts.group);
|
|
68
|
+
if (opts.onClick) cmd.push("-execute", opts.onClick);
|
|
69
|
+
if (opts.sound) cmd.push("-sound", opts.sound === true ? "default" : opts.sound);
|
|
70
|
+
spawnQuiet(cmd);
|
|
71
|
+
return backend;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (backend === "osascript") {
|
|
75
|
+
let script = `display notification "${osaEscape(body)}" with title "${osaEscape(title)}"`;
|
|
76
|
+
if (opts.subtitle) script += ` subtitle "${osaEscape(opts.subtitle)}"`;
|
|
77
|
+
if (opts.sound) script += ` sound name "${osaEscape(opts.sound === true ? "default" : opts.sound)}"`;
|
|
78
|
+
spawnQuiet(["osascript", "-e", script]);
|
|
79
|
+
return backend;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (backend === "notify-send") {
|
|
83
|
+
spawnQuiet(["notify-send", title, body]);
|
|
84
|
+
return backend;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Last resort: a terminal bell. Never throws.
|
|
88
|
+
try {
|
|
89
|
+
process.stderr.write("\x07");
|
|
90
|
+
} catch {
|
|
91
|
+
/* even the bell is best-effort */
|
|
92
|
+
}
|
|
93
|
+
return "bell";
|
|
94
|
+
}
|