Various pieces to create a dashboard to track various sporting events using the ESPN API for data collection.
  • JavaScript 46.1%
  • Python 36.4%
  • CSS 9.5%
  • PHP 5%
  • HTML 2.1%
  • Other 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Kurt Anderson 97a34f70fa Limit World Cup standings and player stats to each tournament's window
The 2-minute standings refresh and hourly player-stats refresh kept
requesting the 2026 men's tournament dates after it ended, and every
call came back 400 (~14k log warnings). Replace the single date pair with
per-league WORLD_CUP_WINDOWS, including the 2027 Women's World Cup
(June 24 - July 25), and only refresh each league inside its window
plus a day. Group standings now also cover fifa.worldw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:14:19 -07:00
game_canceled
game_completed
game_live
game_scheduled
league_helper
src Limit World Cup standings and player stats to each tournament's window 2026-10-06 00:14:19 -07:00
user_data
zabbix
.gitignore Add WEC and IMSA racing support via scraped schedules 2026-07-23 22:28:45 -07:00
add_game.php Treat league-only user_data files as the object format 2026-09-30 00:09:32 -07:00
build.sh
capture_event.py Fix ESPN 502 errors caused by Akamai blocking spoofed User-Agent 2026-08-06 19:42:49 -07:00
CHANGELOG.md Limit World Cup standings and player stats to each tournament's window 2026-10-06 00:14:19 -07:00
collate_player_stats.py Expand stats to all soccer leagues. 2026-07-06 01:14:25 -07:00
database_migration_plan.md
deploy.sh Update deploy to skip python artifacts. 2026-07-06 21:35:32 -07:00
espn_proxy.php Fix ESPN 502 errors caused by Akamai blocking spoofed User-Agent 2026-08-06 19:42:49 -07:00
game-watcher.service
game_watcher.py Limit World Cup standings and player stats to each tournament's window 2026-10-06 00:14:19 -07:00
get_watchlist.php Treat league-only user_data files as the object format 2026-09-30 00:09:32 -07:00
list_knockout_games.py Release v1.16.0 — yellow card display, 3rd-Place Match bracket support 2026-07-11 19:59:50 -07:00
README.md Add all-games league pages via a "league" key in user_data 2026-09-29 23:52:51 -07:00
watched_leagues.json Add all-games league pages via a "league" key in user_data 2026-09-29 23:52:51 -07:00
world_cup_bracket.json Release v1.16.0 — yellow card display, 3rd-Place Match bracket support 2026-07-11 19:59:50 -07:00

Game Watcher

A self-hosted ESPN game monitoring dashboard. A Python service polls the ESPN public API for live game updates and writes a JSON file that a PHP/JS frontend reads to display scores.

How it works

  1. Configure leagues to watch in watched_leagues.json — one entry per league, with optional team and favorite filters.
  2. The watcher service (game_watcher.py) runs in a loop. It is a thin entry point that delegates to modules in src/py/ (ESPN API, parsing, discovery, lifecycle, standings, output).
    • On startup, and then every Wednesday at 4 AM local time, it scans the ESPN scoreboard for each watched league and seeds game_scheduled/ with any new games in the next 14 days. (Anchored to Wednesday rather than a rolling weekly interval so college-football rank-based discovery reads a settled AP Top 25 poll — ESPN publishes it Mondays and it can still be updating through Tuesday. See DISCOVERY_WEEKDAY/DISCOVERY_HOUR in src/py/config.py.)
    • Every 120 seconds it checks all scheduled games against the ESPN API.
    • When a game goes live it moves the file to game_live/ and polls it every 8 seconds.
    • When a game ends it moves the file to game_completed/.
    • Completed games older than 10 days are automatically deleted.
  3. Every tick the watcher writes games_data.json — a snapshot of all games across all folders.
  4. The frontend (index.php) polls games_data.json every 10 seconds and renders the scoreboard. Scheduled games more than five days out are hidden from the main (unfiltered, global) view until closer to their start time — a frontend display cutoff, separate from the backend's 14-day discovery window above. It stays well under that 14-day window's weekly safety margin (14 − 7 = 7 days) so nothing is missed. A toggle button reveals the hidden count and expands the list on demand; selecting a league filter or viewing a /game_watcher/USER page always shows every scheduled game regardless of date.
  5. On card click, the frontend fetches richer data on-demand from espn_proxy.php and displays it in an overlay.

You can still drop a game file manually into game_scheduled/ to track a one-off game outside the configured leagues.

Directory structure

game_watcher/
├── game_watcher.py           # entry point — starts the service loop
├── capture_event.py          # debug: capture raw ESPN payloads during a live event
├── list_knockout_games.py    # utility: list knockout-stage games for bracket setup
├── watched_leagues.json      # leagues, team filters, and favorites config
├── world_cup_bracket.json    # maps event IDs to World Cup bracket slots
├── index.php                 # web frontend (built from src/ by build.sh)
├── espn_proxy.php            # server-side ESPN API proxy (avoids CORS)
├── add_game.php              # API endpoint: add a game to the watch list
├── get_watchlist.php         # API endpoint: read a user's watch list
├── build.sh                  # concatenates src/ into index.php
├── deploy.sh                 # rsync deploy script
├── games_data.json           # generated — do not edit
├── player_stats.json         # generated — per-league player stat leaderboards
├── game_scheduled/           # drop new game files here (also auto-populated)
├── game_live/                # managed by the service
├── game_completed/           # managed by the service
├── game_canceled/            # managed by the service
├── user_data/                # per-user watch list JSON files
├── league_helper/            # cached ESPN API data (generated, 10-min TTL)
├── game_watcher.log          # service log
├── src/
│   ├── py/                   # Python modules imported by game_watcher.py
│   └── js/                   # frontend JS source modules (built into index.php)
└── zabbix/                   # Zabbix monitoring integration
    ├── game_watcher_stats.sh
    ├── game_watcher.conf
    └── template_game_watcher.yaml

Watched leagues

watched_leagues.json controls which leagues the service automatically discovers games for, and which teams get flagged as favorites in the UI.

[
  {"sport": "soccer",     "league": "fifa.world",  "favorite_abbrs": ["USA"]},
  {"sport": "soccer",     "league": "fifa.worldw", "favorite_abbrs": ["USA"]},
  {"sport": "soccer",     "league": "usa.nwsl",    "team_abbrs": ["BAY", "LA"], "favorite_abbrs": ["BAY", "LA"]},
  {"sport": "soccer",     "league": "usa.1",       "team_abbrs": ["SJ"],        "favorite_abbrs": ["SJ"]},
  {"sport": "basketball", "league": "wnba",        "team_abbrs": ["GS"],        "favorite_abbrs": ["GS"]},
  {"sport": "baseball",   "league": "mlb",         "team_abbrs": ["NYM", "SF"], "favorite_abbrs": ["NYM", "SF"]},
  {"sport": "football",   "league": "nfl",         "team_abbrs": ["SF", "NYJ"], "favorite_abbrs": ["SF", "NYJ"]},
  {"sport": "football",   "league": "college-football", "rank_limit": 25},
  {"sport": "racing",     "league": "f1"}
]
Field Required Description
sport yes ESPN sport path segment (soccer, baseball, basketball, football, racing)
league yes ESPN league path segment (mlb, nfl, wnba, usa.nwsl, fifa.world, f1, …)
team_abbrs no Only discover games involving these teams. Omit to discover all games in the league. Ignored for racing leagues — all races are always discovered.
rank_limit no Also discover games where either team's ESPN AP-style rank is ≤ this value. Combines with team_abbrs (either match is enough). Currently only meaningful for FBS college football — ESPN doesn't expose FCS-poll rank in this field. Rank is only checked once, at discovery time — a game found this way can still play out after the team has dropped out of the Top 25 by the time it airs, since DISCOVERY_LOOKAHEAD_DAYS (14 days) is longer than a week and discovery never re-checks or un-queues an already-discovered game. See the TODO in discovery.py.
favorite_abbrs no Teams to flag with a gold border in the UI. Can include teams not in team_abbrs.
schedule_source no Marks a league as static/manual rather than ESPN-driven: manual (schedule scraped once, status derived purely from wall-clock time — WEC, IMSA, WPBL) or stage_polled (scraped schedule, but with its own throttled live-results poll — WRC). Omit for normal ESPN-covered leagues. See the WEC/IMSA and WPBL sections below.

Changes to watched_leagues.json take effect on the next weekly discovery run (or on service restart). No code changes required.

League pages

A readOnly file in user_data/ shows up as a link in the page header (/game_watcher/{slug}/). Adding a league key turns that page into an all-games page for that league. It then shows every tracked game in the league and ignores games:

{"readOnly": true, "league": "nfl"}

On each discovery run, any league with such a page has every game discovered, not just the ones matching the entry's team_abbrs/rank_limit filter. Games that don't match are seeded with "featured": false, and the main dashboard hides them. They only appear on the league page. Games with no featured key, including anything added by hand or through the form, count as featured. Adding an already-tracked non-featured game through the form promotes it to featured.

To add a league page, drop a new {slug}.json in user_data/. The header link appears straight away, and the full schedule fills in on the next discovery run (or service restart).

Adding a game manually

To track a one-off game outside the configured leagues, create a JSON file in game_scheduled/:

{
    "event_id": "401696853",
    "sport": "baseball",
    "league": "mlb"
}

The filename can be anything ending in .json — a descriptive name like mlb_401696853.json works well. The event_id is the number at the end of the ESPN scoreboard URL for the game (e.g. espn.com/mlb/game/_/gameId/401696853).

Valid sport and league values

League sport league
American Football
NFL football nfl
NCAA Football football college-football
CFL football cfl
Baseball
MLB baseball mlb
WPBL baseball wpbl
Basketball
NBA basketball nba
WNBA basketball wnba
NCAA Men's Basketball basketball mens-college-basketball
NCAA Women's Basketball basketball womens-college-basketball
Hockey
NHL hockey nhl
Olympics — Ice Hockey Men hockey olympics-mens-ice-hockey
Olympics — Ice Hockey Women hockey olympics-womens-ice-hockey
US Soccer
NWSL (T1) soccer usa.nwsl
MLS (T1) soccer usa.1
USL Championship (T2) soccer usa.usl.1
USL League One (T3) soccer usa.usl.l1
NWSL Challenge Cup soccer usa.nwsl.cup
MLS Open Cup soccer usa.open
International Football
FIFA World Cup soccer fifa.world
FIFA Women's World Cup soccer fifa.worldw
CONCACAF Leagues Cup soccer concacaf.leagues.cup
CONCACAF Champions Cup soccer concacaf.champions
Premier League soccer eng.1
Women's Super League soccer eng.w.1
La Liga soccer esp.1
Bundesliga soccer ger.1
Ligue 1 soccer fra.1
Serie A soccer ita.1
Racing
Formula 1 racing f1
IndyCar racing irl
FIA WEC racing wec
IMSA WeatherTech Championship racing imsa

WEC / IMSA — scraped schedules, no live results

ESPN doesn't carry WEC or IMSA at all (their scoreboard endpoint 400s for both), and their timing data is proprietary to Al Kamel Systems with no public API — so these two leagues work differently from every other entry in this table. Both are marked "schedule_source": "manual" in watched_leagues.json, which is what routes them away from the normal ESPN discovery/live-refresh path (see state.schedule_sources).

  • Schedule only, no live updates. src/py/scrape_racing.py scrapes the official calendar (WEC from fiawec.com's own schedule pages; IMSA from its Wikipedia season page, since imsa.com returns HTTP 403 to any automated request) during the normal weekly discovery run. A card's Scheduled → Live → Final status is computed purely from start_time/duration_hours vs. the current time — nothing is ever fetched live, so results stay empty forever.
  • WEC gets a full session list (practice/qualifying/warm-up/race) with real times, scraped from each race's page. IMSA's Wikipedia table only has a date (no time-of-day, no sessions), so its start_time is an approximate placeholder and sessions is always empty.
  • Requires beautifulsoup4 (pip install beautifulsoup4) in addition to requests — install this on the server before deploying.
  • WEC_SEASON_YEAR/IMSA_SEASON_YEAR in src/py/config.py need bumping once a year when the new season's calendar is published.
  • If a season's page structure changes, the scraper logs a warning ("no races found"/"could not find schedule table") and simply discovers nothing that week rather than crashing — check game_watcher.log if WEC/IMSA stop appearing.

WPBL — scraped schedule, no live results

ESPN doesn't carry WPBL at all — its core API lists no women's-baseball league. WPBL works like WEC/IMSA above (sport: "baseball", league: "wpbl", also marked "schedule_source": "manual"):

  • Schedule only, no live updates. src/py/scrape_wpbl.py pulls the league's own hidden calendar API (wp-json/wpbl/v1/calendar-events) during the normal weekly discovery run, keeping only games the API itself still marks "scheduled" with a confirmed matchup (already-played games and TBD placeholder slots are skipped). A card's Scheduled → Live → Final status is computed purely from start_time + DEFAULT_WPBL_GAME_DURATION_HOURS vs. the current time — nothing is ever fetched live, so real scores/status are never pulled.
  • Team abbreviations are invented, not sourced — the calendar API only gives full city names. WPBL_TEAM_ABBRS in src/py/config.py hardcodes the 4 inaugural-season teams; update it if the league expands.
  • Getting real live status/scores from WPBL is a possible future enhancement — the league's calendar API does carry a live "status" field and final scores, just unused for now.

Racing sessions (--session, capture_event.py only)

A racing weekend is one ESPN event ID containing multiple sessions. Use --session to pick which one to target:

Session --session value
Practice 1 practice1
Practice 2 practice2
Practice 3 practice3
Sprint Qualifying / Sprint Shootout sprint-qualifying
Sprint Race sprint
Qualifying qualifying
Race (default) race

Game card overlays

Clicking any game card opens a full-screen overlay with richer detail. Overlays are sport-aware:

  • Soccer (FIFA World Cup): goal timeline, halftime score, match stats (possession, shots, saves, fouls, cards), group standings with advancement indicators, a cross-group 3rd-place race table showing which third-place teams are currently on track to advance, and a full knockout bracket from Round of 32 through the Final with group-position labels on each slot.
  • Basketball (NBA/WNBA/NCAA): points/rebounds/assists leaders for each team, technical and flagrant foul counts, a per-event Fouls list (quarter, clock, player, Technical/Flagrant tag, team), and a dynamic overtime linescore (OT, 2OT, ...) with an end-of-regulation checkpoint score for OT games.
  • All other sports: scoreboard, linescore (if available), venue, and broadcast info.

The overlay fetches data on-demand from espn_proxy.php when opened, so live game stats are always current. Group standings for all 12 World Cup groups are cached in league_helper/ for 10 minutes to avoid redundant API calls.

Each game card also shows all available broadcast channels, sorted by local TV → national TV → streaming.

3rd Place Race overlay — live projection

The cross-group 3rd Place Race table projects standings in real time during live group-stage games. ESPN's API updates goals-for/goals-against immediately but delays pts/W/D/L until the match ends. The frontend applies the missing pts/W/D/L delta directly so the projected standings are current throughout a match.

Indicator Meaning
Yellow row + ● Live H–A Team is playing now; stats are projected from the current score
Blue border + ○ game remaining Team has an unplayed group-stage game
Green border + ↑ projected to advance Official 3rd-place team whose live result would move them to 1st/2nd
Amber border + ↓ projected 4th Official 3rd-place team whose live result would drop them to 4th

When a live result shifts a team's projected rank within their group, the race table shows the re-ranked team (e.g., a currently-4th team winning and projecting to 3rd) at their projected position. A footnote marks all projected rows as estimates pending the final result. All projection state is browser-only — nothing is written to games_data.json.

June 28 note (World Cup)

Starting June 28, ESPN's API transitions from group-stage to knockout format. Knockout-stage games (identifiable by having no group_name) skip the group advancement UI automatically.

About page

An "About" button in the page footer opens an overlay with contact info and an in-app changelog viewer that fetches and renders this project's CHANGELOG.md so you can see what's new without leaving the page.

ESPN proxy

espn_proxy.php is a thin server-side proxy that forwards requests to ESPN's public API endpoints. It exists to avoid CORS issues with direct browser fetches. Supported actions:

Action Description
groups Division/conference hierarchy for a league (24h cache)
teams Team list, optionally filtered by group/conference
schedule Team schedule, with scoreboard supplement for tournament leagues
summary Full event summary (box score, stats, standings) for a single game
group_standings All-group standings for a tournament league (10-min cache)

Developer utilities

capture_event.py

Polls the ESPN API for a single event and saves timestamped raw + parsed JSON snapshots to the working directory. Useful for capturing data during a live game to test or debug parsing changes.

python capture_event.py --sport soccer --league fifa.world --event-id 401696853
Option Description
--sport, --league, --event-id (required) identify the event
--dump-now fetch immediately without waiting for the game to go live
--poll-live N poll interval in seconds while the game is live (default: 10)
--max-hours N how many hours to keep capturing after the game ends (default: 6)
--session VALUE racing only: which session within a multi-session weekend to target (default: race) — see the Racing sessions table above

Works with any sport/league supported by the ESPN API.

list_knockout_games.py

Prints all knockout-stage FIFA World Cup games grouped by round (Round of 32 → Round of 16 → Quarterfinal → Semifinal → Final), with event IDs, teams, date, and venue. Use this after the bracket is set to populate world_cup_bracket.json with the correct event_id values.

collate_player_stats.py

Tallies per-player goals, assists, own-goals, and clean sheets for a soccer league, or points, assists, rebounds, and blocks for a basketball league, straight from ESPN — independent of game_completed/, so it still works for games already removed by the 10-day completed-game cleanup. Stats are kept in a separate leaderboard per league (player_stats.json's leagues key), so stats from different competitions never mix. Safe to rerun; already-processed events are skipped unless --force.

The running service already calls this hourly on its own for every watched soccer or basketball league (tournament or season-long) to keep the header's Player Stats overlay fresh — this script is a thin CLI wrapper around that same logic, kept for manual runs, backfills, and --force rebuilds.

python collate_player_stats.py --league fifa.world
python collate_player_stats.py --sport soccer --league usa.nwsl
python collate_player_stats.py --all-soccer            # refresh every watched soccer league
python collate_player_stats.py --league fifa.world --force   # rebuild that league from scratch
python collate_player_stats.py --sport basketball --league wnba

Writes player_stats.json (gitignored, regenerate any time by rerunning). Tournament leagues (fifa.world, fifa.worldw) always use their fixed date window; season-long leagues use a 120-day backfill on their first refresh, then a 10-day rolling window afterwards.

Running the service

sudo systemctl start game-watcher
sudo systemctl enable game-watcher
sudo systemctl status game-watcher

Logs are written to game_watcher.log and also to stdout (captured by journald).

Zabbix monitoring

The zabbix/ directory contains a Zabbix Agent2 integration that exposes service metrics:

  • Game counts (live, scheduled, completed, total)
  • ESPN API call and error counters
  • Age of games_data.json (staleness check)
  • nginx visitor hits to /game_watcher/ in the last hour

Deploy

sudo mkdir -p /etc/zabbix/scripts
sudo cp zabbix/game_watcher_stats.sh /etc/zabbix/scripts/
sudo chmod +x /etc/zabbix/scripts/game_watcher_stats.sh
sudo cp zabbix/game_watcher.conf /etc/zabbix/zabbix_agent2.d/
sudo usermod -aG adm zabbix
sudo systemctl restart zabbix-agent2

Then import zabbix/template_game_watcher.yaml into Zabbix (Configuration → Templates → Import) and link it to the host.


Development

This project is actively developed with assistance from Claude (Anthropic).