Various pieces to create a dashboard to track various sporting events using the ESPN API for data collection.
  • JavaScript 47.7%
  • Python 33.7%
  • CSS 10.3%
  • PHP 5.2%
  • HTML 2.1%
  • Other 1%
Find a file
2026-08-22 16:19:00 -07:00
game_canceled Add dummy files to sync the folders for the live system. 2026-06-25 16:38:51 -07:00
game_completed Add dummy files to sync the folders for the live system. 2026-06-25 16:38:51 -07:00
game_live Add dummy files to sync the folders for the live system. 2026-06-25 16:38:51 -07:00
game_scheduled Add dummy files to sync the folders for the live system. 2026-06-25 16:38:51 -07:00
league_helper Add dummy files to sync the folders for the live system. 2026-06-25 16:38:51 -07:00
src Try to compute winner of matches when API takes too long to update. 2026-08-22 16:19:00 -07:00
user_data Add dummy files to sync the folders for the live system. 2026-06-25 16:38:51 -07:00
zabbix Bug fix: the traffic stats now represent unique visits (best effort) instead of every load. 2026-06-19 16:45:10 -07:00
.gitignore Add WEC and IMSA racing support via scraped schedules 2026-07-23 22:28:45 -07:00
add_game.php Add readOnly key/value pair for "system" lists of certain teams to use as examples of custom user filtered lists. 2026-06-17 21:11:32 -07:00
build.sh Make build file executable. 2026-06-27 15:51:03 -07:00
capture_event.py Fix ESPN 502 errors caused by Akamai blocking spoofed User-Agent 2026-08-06 19:42:49 -07:00
CHANGELOG.md Track with a key/value pair leagues that are static or stage polled versus leagues pulled through API. API leagues are queried regularly for updates; the others are not. 2026-08-15 20:45:38 -07:00
collate_player_stats.py Expand stats to all soccer leagues. 2026-07-06 01:14:25 -07:00
database_migration_plan.md Migration plan to use a database for the future. 2026-06-21 00:32:52 -07:00
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 Move the log files to the standard on linux /var/log 2026-06-19 17:03:10 -07:00
game_watcher.py Scrape WPBL events daily. 2026-08-19 16:05:04 -07:00
get_watchlist.php Add readOnly key/value pair for "system" lists of certain teams to use as examples of custom user filtered lists. 2026-06-17 21:11: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 Track with a key/value pair leagues that are static or stage polled versus leagues pulled through API. API leagues are queried regularly for updates; the others are not. 2026-08-15 20:45:38 -07:00
watched_leagues.json Track with a key/value pair leagues that are static or stage polled versus leagues pulled through API. API leagues are queried regularly for updates; the others are not. 2026-08-15 20:45:38 -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 weekly it scans the ESPN scoreboard for each watched league and seeds game_scheduled/ with any new games in the next 14 days.
    • 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": "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.
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.

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 HA 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).