From 16d1ba3f8baaebc141791789e04e94a467efbb98 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:44:02 +0000 Subject: [PATCH 1/3] feat(scripts): add an ESPN endpoint probe that names the failure "The ESPN plugins went blank" arrives as a symptom and rarely as a diagnosis. The plugins call ESPN's undocumented API from ~15 places and, critically, do not speak with one voice: some callers send LEDMatrix/1.0, many send nothing at all and get the stdlib/requests default, and a few already send a browser string. Anti-bot filtering discriminates on exactly that header, so the same outage can hit some plugins and spare others, which makes the reports hard to read. check_espn_api.py probes each endpoint family under all three User-Agent profiles and turns the result into a diagnosis: a browser-works/ours-fail split means header filtering and a header fix; failure under every agent means the URL moved or the response shape changed. It also separates "never got an HTTP response" from "ESPN returned an error". A denied proxy or a DNS failure otherwise looks identical to a withdrawn endpoint, and sends whoever runs it off rewriting URLs that were never broken. That case now exits 2 and says so. Endpoint checks assert an expected key rather than a bare 200, since ESPN can serve a cheerful 200 whose body no longer holds the field the renderer reads. An empty events list stays a note, not a failure, so it does not cry wolf on an off-day. No plugin code or versions touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017mSAZ7o55QKTP8KQm3cv5c --- scripts/check_espn_api.py | 294 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100755 scripts/check_espn_api.py diff --git a/scripts/check_espn_api.py b/scripts/check_espn_api.py new file mode 100755 index 00000000..2bf5b40c --- /dev/null +++ b/scripts/check_espn_api.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Probe every shape of ESPN request this repo makes, and say what is actually wrong. + +ESPN has no public API. Every endpoint the scoreboards use is undocumented and +can change without notice, so "the ESPN plugins went blank" is a report we get +periodically and can rarely act on: it arrives as a symptom, and the interesting +question — *which* part broke — needs a machine that can reach ESPN to answer. +This script is that answer. Run it from a host with normal internet access (a +Pi running LEDMatrix, a laptop) and it reports, per endpoint, whether ESPN still +serves what the plugins expect. + +The important trick is the second axis. The plugins do not speak to ESPN with one +voice; they send three different ``User-Agent`` values depending on which file +happens to make the call: + +* ``python-urllib`` / ``python-requests`` — the stdlib or requests default, sent + by every caller that passes no headers at all (odds-ticker's data_fetcher, most + of the ``*_managers.py``, every ``base_odds_manager.py``, this repo's own + check_team_pickers.py). +* ``LEDMatrix/1.0`` — the custom agent set by the ``data_sources.py`` family. +* a browser string — what a handful of files already send, and what ESPN's own + site sends. + +Anti-bot filtering is the most common way an undocumented API "changes", and it +discriminates on exactly that header. So each endpoint is tried under all three +profiles. That turns an ambiguous outage into a diagnosis: + +* every profile fails -> ESPN moved or withdrew the endpoint; the URL needs work. +* only the non-browser profiles fail -> ESPN is filtering on User-Agent, and the + fix is a header change, not a URL change. +* everything passes -> ESPN is fine; look at the plugin, the cache, or the + network in front of it. + +Usage:: + + python scripts/check_espn_api.py # probe, non-zero exit if broken + python scripts/check_espn_api.py --verbose # show every profile's result + python scripts/check_espn_api.py --json # machine-readable report + python scripts/check_espn_api.py --timeout 30 +""" + +import argparse +import datetime +import json +import ssl +import sys +import urllib.error +import urllib.request + +# The distinct endpoint families the plugins call. Each entry is +# (label, url, key that must be present and non-empty in the JSON response). +# +# "expect" is what makes this a contract test rather than a ping: ESPN can return +# a cheerful 200 whose body no longer holds the field the renderer reads, and a +# status-code-only check would call that healthy. +TODAY = datetime.date.today().strftime("%Y%m%d") + +ENDPOINTS = [ + ( + "site.api scoreboard (MLB)", + "https://site.api.espn.com/apis/site/v2/sports/baseball/mlb/scoreboard", + "events", + ), + ( + "site.api scoreboard w/ dates (NFL)", + "https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard" + f"?dates={TODAY}&limit=1000", + "leagues", + ), + ( + "site.api scoreboard (NCAA FB, groups)", + "https://site.api.espn.com/apis/site/v2/sports/football/college-football/scoreboard" + "?groups=80&limit=1000", + "leagues", + ), + ( + "site.api teams (NHL)", + "https://site.api.espn.com/apis/site/v2/sports/hockey/nhl/teams?limit=1000", + "sports", + ), + ( + "site.api rankings (NCAA FB)", + "https://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings", + "rankings", + ), + ( + "site.api standings v2 (NBA)", + "https://site.api.espn.com/apis/v2/sports/basketball/nba/standings", + None, + ), + ( + "site.web.api scoreboard header (cricket)", + "https://site.web.api.espn.com/apis/v2/scoreboard/header?sport=cricket", + "sports", + ), + ( + "site.web.api common v3 (golf athlete)", + "https://site.web.api.espn.com/apis/common/v3/sports/golf/pga/athletes/9478", + None, + ), + ( + "sports.core.api league (NFL)", + "https://sports.core.api.espn.com/v2/sports/football/leagues/nfl", + None, + ), + ( + "site.api soccer scoreboard (EPL)", + "https://site.api.espn.com/apis/site/v2/sports/soccer/eng.1/scoreboard", + "leagues", + ), +] + +# The three voices the plugins actually use. Order matters: the browser profile +# is last so a "browser works, ours do not" split is easy to read off the table. +PROFILES = [ + ("default", {}), + ("LEDMatrix/1.0", {"User-Agent": "LEDMatrix/1.0", "Accept": "application/json"}), + ( + "browser", + { + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + ), + "Accept": "application/json, text/plain, */*", + }, + ), +] + + +def probe(url, headers, timeout): + """Fetch url and return a result dict. Never raises.""" + request = urllib.request.Request(url) + for name, value in headers.items(): + request.add_header(name, value) + + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = response.read() + status = response.getcode() + except urllib.error.HTTPError as exc: + return {"ok": False, "status": exc.code, "error": f"HTTP {exc.code} {exc.reason}"} + except urllib.error.URLError as exc: + return {"ok": False, "status": None, "error": f"unreachable: {exc.reason}"} + except (ssl.SSLError, OSError) as exc: + return {"ok": False, "status": None, "error": f"connection: {exc}"} + + try: + data = json.loads(body) + except (ValueError, UnicodeDecodeError): + head = body[:80].decode("utf-8", "replace").replace("\n", " ") + return { + "ok": False, + "status": status, + "error": f"not JSON (got {len(body)}B starting {head!r})", + } + + return {"ok": True, "status": status, "data": data, "bytes": len(body)} + + +def check_expected_key(result, expected): + """Fold the response-shape contract into the result dict.""" + if not result["ok"] or expected is None: + return result + + data = result.get("data") + if not isinstance(data, dict) or expected not in data: + result["ok"] = False + result["error"] = f"200 but no {expected!r} key (shape changed)" + elif not data[expected]: + # An empty events list is normal on a day with no games, so this is a + # note rather than a failure — flagging it would cry wolf every off-day. + result["note"] = f"{expected!r} present but empty" + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + parser.add_argument("--timeout", type=float, default=20.0, help="per-request timeout") + parser.add_argument("--verbose", action="store_true", help="show every profile") + parser.add_argument("--json", action="store_true", dest="as_json") + args = parser.parse_args() + + report = [] + for label, url, expected in ENDPOINTS: + row = {"endpoint": label, "url": url, "profiles": {}} + for profile_name, headers in PROFILES: + result = check_expected_key(probe(url, headers, args.timeout), expected) + row["profiles"][profile_name] = { + k: v for k, v in result.items() if k != "data" + } + report.append(row) + + if args.as_json: + print(json.dumps(report, indent=2)) + else: + render(report, args.verbose) + + return summarize(report, args.as_json) + + +def render(report, verbose): + print(f"ESPN endpoint probe — {datetime.datetime.now():%Y-%m-%d %H:%M:%S}\n") + for row in report: + states = row["profiles"] + worked = [name for name, r in states.items() if r["ok"]] + mark = "PASS" if len(worked) == len(states) else ("PART" if worked else "FAIL") + print(f"[{mark}] {row['endpoint']}") + + for name, result in states.items(): + if result["ok"] and not verbose: + if result.get("note"): + print(f" {name}: ok — {result['note']}") + continue + if result["ok"]: + detail = result.get("note") or f"{result.get('bytes', 0)}B" + print(f" {name}: ok — {detail}") + else: + print(f" {name}: {result['error']}") + if mark != "PASS": + print(f" {row['url']}") + print() + + +def summarize(report, as_json): + """Print the diagnosis and pick an exit code.""" + total = len(report) + failed = [r for r in report if not any(p["ok"] for p in r["profiles"].values())] + + # A request that never got an HTTP status never reached ESPN, and says nothing + # about ESPN. Keep it apart from a genuine ESPN error: a DNS failure, a proxy + # that denies the host, or a captive portal all look like "everything is down" + # while ESPN is perfectly healthy, and reporting that as a withdrawn endpoint + # sends whoever runs this off rewriting URLs that were never broken. + unreachable = [ + r + for r in failed + if all(p.get("status") is None for p in r["profiles"].values()) + ] + all_dead = [r for r in failed if r not in unreachable] + ua_split = [ + r + for r in report + if r["profiles"]["browser"]["ok"] + and not (r["profiles"]["default"]["ok"] and r["profiles"]["LEDMatrix/1.0"]["ok"]) + ] + + if as_json: + return 1 if failed or ua_split else 0 + + if unreachable and len(unreachable) == total: + reason = unreachable[0]["profiles"]["browser"]["error"] + print(f"CANNOT REACH ESPN: all {total} endpoints failed without ever getting " + "an HTTP response.") + print(f" First reason: {reason}") + print(" This is a problem between this machine and ESPN — DNS, a proxy that " + "denies the host, or no route out — not an ESPN API change. Re-run from " + "a host with normal internet access before concluding anything about ESPN.") + return 2 + + if not failed and not ua_split: + print(f"All {total} endpoints healthy under every User-Agent. ESPN is not " + "the problem — look at the plugin, its cache, or the local network.") + return 0 + + if ua_split: + print(f"USER-AGENT FILTERING: {len(ua_split)}/{total} endpoints answer a " + "browser agent but reject ours.") + print(" ESPN is filtering on User-Agent. The fix is a header change, not a " + "URL change: send a browser User-Agent from every ESPN caller.") + for row in ua_split: + print(f" - {row['endpoint']}") + + if all_dead: + print(f"\nENDPOINT DOWN: {len(all_dead)}/{total} endpoints fail under every " + "agent, browser included.") + print(" Not a header problem — the URL moved or was withdrawn, or the " + "response shape changed. These need per-endpoint work:") + for row in all_dead: + reason = row["profiles"]["browser"]["error"] + print(f" - {row['endpoint']}: {reason}") + + if unreachable: + print(f"\nUNREACHABLE: {len(unreachable)}/{total} endpoints never got an HTTP " + "response, so they are undiagnosed rather than broken:") + for row in unreachable: + print(f" - {row['endpoint']}: {row['profiles']['browser']['error']}") + + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 5724d69690318048739005324ce8f0df45e1dc29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 19:47:24 +0000 Subject: [PATCH 2/3] fix(scripts): pin the probe's URL scheme to https Codacy flagged the urlopen call (bandit B310, medium): urlopen honours file:// and custom schemes, so a URL reaching it unchecked is a path traversal waiting for a careless edit. The endpoints here are module constants today, but probe() is generic and the next person to add a row gets the guard for free. Follows the convention already set in check_team_pickers.py, with one difference: probe() documents that it never raises, so a bad scheme comes back as an ordinary failure result rather than an exception that would abort the remaining probes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017mSAZ7o55QKTP8KQm3cv5c --- scripts/check_espn_api.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/check_espn_api.py b/scripts/check_espn_api.py index 2bf5b40c..a3864f26 100755 --- a/scripts/check_espn_api.py +++ b/scripts/check_espn_api.py @@ -131,12 +131,21 @@ def probe(url, headers, timeout): """Fetch url and return a result dict. Never raises.""" + # The URLs come from the table above, but pin the scheme rather than trusting + # them: urlopen would honour file:// or a custom scheme if an entry ever + # arrived from somewhere less trustworthy. Reported as an ordinary failure so + # the promise above holds and one bad row cannot abort the whole run. + if not url.startswith("https://"): + return {"ok": False, "status": None, "error": f"refusing non-HTTPS URL: {url!r}"} + request = urllib.request.Request(url) for name, value in headers.items(): request.add_header(name, value) try: - with urllib.request.urlopen(request, timeout=timeout) as response: + # B310 is a syntactic blacklist rule and fires on the call regardless of + # the scheme guard above, which is what actually makes this safe. + with urllib.request.urlopen(request, timeout=timeout) as response: # nosec B310 body = response.read() status = response.getcode() except urllib.error.HTTPError as exc: From f8251fd83b20cc704c6ce315a2fd64b480101d79 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 16:09:51 -0400 Subject: [PATCH 3/3] fix(espn): send an identifying User-Agent so ESPN stops returning 403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Around 11:00 EDT on 2026-08-04 ESPN's site.api began rejecting the agents these plugins send, and every ESPN-backed scoreboard started returning `403 Client Error: Forbidden`. A device that had been running fine logged 287 ESPN errors in a day. The filter is not the familiar one. Probing site.api across agents, using requests as the plugins do: bare 'LEDMatrix/1.0' 403 (with or without Accept) browser string 403 (no header rescues it) 'LEDMatrix/1.0 (+https://github.com/...)' 200 requests / urllib / curl defaults 200 ESPN rejects browser-style strings outright and bare custom tokens, and accepts honest client tokens or an agent that identifies the client and links to it. The instinct to "just send a browser User-Agent" is now exactly backwards — that is the one thing guaranteed to stay blocked. Sweeping every User-Agent literal in the repo and probing each turned up ten blocked strings across 24 files in 14 plugins: the `LEDMatrix/1.0` family in the data_sources/dynamic_team_resolver files, per-plugin tokens like `LEDMatrix-F1/1.0` and `LEDMatrix Masters Plugin/2.1`, and browser strings in nfl-draft and masters-tournament. All now send one agent carrying the project URL — one string, so the next ESPN change is one grep rather than ten. Callers that reach other services are deliberately untouched: ledmatrix-flights, ledmatrix-stocks and stock-news send a browser agent to hosts ESPN's change never involved. Also fixes the probe this branch added, which reported "ESPN is not the problem" throughout the outage. It only tested for the old shape — browser works, ours does not — so an inverted filter read as healthy, and the fix it printed (send a browser agent) was the change that would have kept everything broken. The verdict is direction-agnostic now, names which agents were accepted and refused rather than assuming, and separates what the plugins ship from controls kept to characterise the filter. basketball, hockey and lacrosse skip a patch number so this cannot collide with the versions PR #252 already claims. Verified on a live device: ESPN errors went from a steady stream to zero across a restart, with live MLB games fetching again. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins.json | 50 +++---- plugins/afl-scoreboard/data_sources.py | 2 +- plugins/afl-scoreboard/manifest.json | 10 +- plugins/baseball-scoreboard/data_sources.py | 2 +- .../dynamic_team_resolver.py | 2 +- plugins/baseball-scoreboard/logo_manager.py | 2 +- plugins/baseball-scoreboard/manifest.json | 10 +- plugins/basketball-scoreboard/data_sources.py | 2 +- .../dynamic_team_resolver.py | 2 +- plugins/basketball-scoreboard/manifest.json | 8 +- plugins/f1-scoreboard/f1_data.py | 2 +- plugins/f1-scoreboard/manifest.json | 10 +- plugins/football-scoreboard/data_sources.py | 2 +- .../dynamic_team_resolver.py | 2 +- plugins/football-scoreboard/manifest.json | 10 +- plugins/hockey-scoreboard/data_sources.py | 2 +- .../dynamic_team_resolver.py | 2 +- plugins/hockey-scoreboard/manifest.json | 8 +- plugins/lacrosse-scoreboard/data_sources.py | 2 +- .../dynamic_team_resolver.py | 2 +- plugins/lacrosse-scoreboard/manifest.json | 8 +- .../test_lacrosse_plugin.py | 2 +- plugins/march-madness/manager.py | 2 +- plugins/march-madness/manifest.json | 10 +- plugins/masters-tournament/download_assets.py | 2 +- plugins/masters-tournament/logo_loader.py | 2 +- plugins/masters-tournament/manifest.json | 10 +- plugins/masters-tournament/masters_data.py | 2 +- plugins/news/manager.py | 2 +- plugins/news/manifest.json | 10 +- plugins/nfl-draft/manager.py | 12 +- plugins/nfl-draft/manifest.json | 10 +- plugins/nrl-scoreboard/data_sources.py | 2 +- plugins/nrl-scoreboard/manifest.json | 10 +- plugins/soccer-scoreboard/data_sources.py | 2 +- plugins/soccer-scoreboard/manifest.json | 10 +- plugins/ufc-scoreboard/data_sources.py | 2 +- plugins/ufc-scoreboard/headshot_downloader.py | 2 +- plugins/ufc-scoreboard/manifest.json | 10 +- scripts/check_espn_api.py | 129 +++++++++++++----- 40 files changed, 257 insertions(+), 114 deletions(-) diff --git a/plugins.json b/plugins.json index 89761232..926231b4 100644 --- a/plugins.json +++ b/plugins.json @@ -73,10 +73,10 @@ "plugin_path": "plugins/baseball-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.22.1" + "latest_version": "1.22.2" }, { "id": "basketball-scoreboard", @@ -101,7 +101,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.10.2" + "latest_version": "1.10.3" }, { "id": "calendar", @@ -212,10 +212,10 @@ "plugin_path": "plugins/f1-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-08-01", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.2" + "latest_version": "1.7.3" }, { "id": "football-scoreboard", @@ -237,10 +237,10 @@ "plugin_path": "plugins/football-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.11.1" + "latest_version": "2.11.2" }, { "id": "geochron", @@ -335,7 +335,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.2", + "latest_version": "1.7.3", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.7.2", + "latest_version": "1.7.3", "icon": "fas fa-baseball-ball" }, { @@ -460,10 +460,10 @@ "plugin_path": "plugins/march-madness", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.0.3" + "latest_version": "1.0.4" }, { "id": "masters-tournament", @@ -484,10 +484,10 @@ "plugin_path": "plugins/masters-tournament", "stars": 0, "downloads": 0, - "last_updated": "2026-07-17", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.5.3" + "latest_version": "2.5.4" }, { "id": "mqtt-notifications", @@ -554,10 +554,10 @@ "plugin_path": "plugins/news", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.0" + "latest_version": "1.3.1" }, { "id": "on-air", @@ -602,10 +602,10 @@ "plugin_path": "plugins/nfl-draft", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.4.0", + "latest_version": "1.4.1", "icon": "fas fa-football-ball" }, { @@ -757,10 +757,10 @@ "plugin_path": "plugins/soccer-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "2.6.1" + "latest_version": "2.6.2" }, { "id": "static-image", @@ -902,10 +902,10 @@ "plugin_path": "plugins/ufc-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.2", + "latest_version": "1.3.3", "icon": "fas fa-fist-raised" }, { @@ -1048,8 +1048,8 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.3.1", - "last_updated": "2026-07-31" + "latest_version": "1.3.2", + "last_updated": "2026-08-05" }, { "id": "tidbyt-baseball-scoreboard", @@ -1092,10 +1092,10 @@ "plugin_path": "plugins/nrl-scoreboard", "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.1" + "latest_version": "1.3.2" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/data_sources.py b/plugins/afl-scoreboard/data_sources.py index d5ecb5d6..315d44f9 100644 --- a/plugins/afl-scoreboard/data_sources.py +++ b/plugins/afl-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 40d32b25..bc3132fe 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.3.1", + "version": "1.3.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "version": "1.3.2", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.1", "released": "2026-08-05", @@ -70,7 +76,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/baseball-scoreboard/data_sources.py b/plugins/baseball-scoreboard/data_sources.py index 8fa8305e..b0d8cb3d 100644 --- a/plugins/baseball-scoreboard/data_sources.py +++ b/plugins/baseball-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/baseball-scoreboard/dynamic_team_resolver.py b/plugins/baseball-scoreboard/dynamic_team_resolver.py index cd7de5ed..9c060c81 100644 --- a/plugins/baseball-scoreboard/dynamic_team_resolver.py +++ b/plugins/baseball-scoreboard/dynamic_team_resolver.py @@ -142,7 +142,7 @@ def _fetch_rankings(self, sport: str) -> List[str]: url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/baseball-scoreboard/logo_manager.py b/plugins/baseball-scoreboard/logo_manager.py index 853d229a..10912f5d 100644 --- a/plugins/baseball-scoreboard/logo_manager.py +++ b/plugins/baseball-scoreboard/logo_manager.py @@ -294,7 +294,7 @@ def _download_headshot_image(self, url: str) -> Optional[Image.Image]: allowlisted by the caller.""" resp = requests.get( url, timeout=5, stream=True, - headers={"User-Agent": "LEDMatrix Baseball Plugin/1.0"}, + headers={"User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)"}, ) try: resp.raise_for_status() diff --git a/plugins/baseball-scoreboard/manifest.json b/plugins/baseball-scoreboard/manifest.json index d1b79896..66998a04 100644 --- a/plugins/baseball-scoreboard/manifest.json +++ b/plugins/baseball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "baseball-scoreboard", "name": "Baseball Scoreboard", - "version": "1.22.1", + "version": "1.22.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming baseball games across MLB, MiLB, and NCAA Baseball with real-time scores and schedules", "category": "sports", @@ -30,6 +30,12 @@ "branch": "main", "plugin_path": "plugins/baseball-scoreboard", "versions": [ + { + "version": "1.22.2", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.22.1", "released": "2026-08-05", @@ -315,7 +321,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/basketball-scoreboard/data_sources.py b/plugins/basketball-scoreboard/data_sources.py index d8589e4a..36032430 100644 --- a/plugins/basketball-scoreboard/data_sources.py +++ b/plugins/basketball-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/basketball-scoreboard/dynamic_team_resolver.py b/plugins/basketball-scoreboard/dynamic_team_resolver.py index f88755f7..eb14f803 100644 --- a/plugins/basketball-scoreboard/dynamic_team_resolver.py +++ b/plugins/basketball-scoreboard/dynamic_team_resolver.py @@ -138,7 +138,7 @@ def _fetch_rankings(self, sport: str) -> List[str]: url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index 73de8001..294f2164 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.10.2", + "version": "1.10.3", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "version": "1.10.3", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.10.2", "released": "2026-08-05", diff --git a/plugins/f1-scoreboard/f1_data.py b/plugins/f1-scoreboard/f1_data.py index 5a294393..53bec0ff 100644 --- a/plugins/f1-scoreboard/f1_data.py +++ b/plugins/f1-scoreboard/f1_data.py @@ -52,7 +52,7 @@ def __init__(self, cache_manager=None, config: Dict[str, Any] = None): self.session.mount("https://", adapter) self.session.mount("http://", adapter) self.session.headers.update({ - "User-Agent": "LEDMatrix-F1/1.0", + "User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)", "Accept": "application/json", }) diff --git a/plugins/f1-scoreboard/manifest.json b/plugins/f1-scoreboard/manifest.json index 8fe77182..fd43c9d1 100644 --- a/plugins/f1-scoreboard/manifest.json +++ b/plugins/f1-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "f1-scoreboard", "name": "F1 Scoreboard", - "version": "1.7.2", + "version": "1.7.3", "author": "ChuckBuilds", "class_name": "F1ScoreboardPlugin", "entry_point": "manager.py", @@ -29,6 +29,12 @@ "f1_calendar" ], "versions": [ + { + "version": "1.7.3", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-02", "version": "1.7.2", @@ -238,7 +244,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-08-01", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/football-scoreboard/data_sources.py b/plugins/football-scoreboard/data_sources.py index e85ad631..2dadcb0d 100644 --- a/plugins/football-scoreboard/data_sources.py +++ b/plugins/football-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/football-scoreboard/dynamic_team_resolver.py b/plugins/football-scoreboard/dynamic_team_resolver.py index 9b26ffcd..61cb0596 100644 --- a/plugins/football-scoreboard/dynamic_team_resolver.py +++ b/plugins/football-scoreboard/dynamic_team_resolver.py @@ -141,7 +141,7 @@ def _fetch_rankings(self, sport: str) -> List[str]: url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/football-scoreboard/manifest.json b/plugins/football-scoreboard/manifest.json index 1f5b52d6..9254aa87 100644 --- a/plugins/football-scoreboard/manifest.json +++ b/plugins/football-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "football-scoreboard", "name": "Football Scoreboard", - "version": "2.11.1", + "version": "2.11.2", "author": "ChuckBuilds", "class_name": "FootballScoreboardPlugin", "description": "Standalone plugin for live, recent, and upcoming football games across NFL and NCAA Football with real-time scores, down/distance, possession, and game status. Now with organized nested config!", @@ -24,6 +24,12 @@ "ncaa_fb_live" ], "versions": [ + { + "version": "2.11.2", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.11.1", "released": "2026-08-05", @@ -331,7 +337,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/hockey-scoreboard/data_sources.py b/plugins/hockey-scoreboard/data_sources.py index c9a5b237..acb54c12 100644 --- a/plugins/hockey-scoreboard/data_sources.py +++ b/plugins/hockey-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/hockey-scoreboard/dynamic_team_resolver.py b/plugins/hockey-scoreboard/dynamic_team_resolver.py index 6769c054..c0ff0fe5 100644 --- a/plugins/hockey-scoreboard/dynamic_team_resolver.py +++ b/plugins/hockey-scoreboard/dynamic_team_resolver.py @@ -150,7 +150,7 @@ def _fetch_rankings(self, sport: str) -> List[str]: url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index 50a778ca..cab5a1f8 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.7.2", + "version": "1.7.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "version": "1.7.3", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.7.2", "released": "2026-08-05", diff --git a/plugins/lacrosse-scoreboard/data_sources.py b/plugins/lacrosse-scoreboard/data_sources.py index c9a5b237..acb54c12 100644 --- a/plugins/lacrosse-scoreboard/data_sources.py +++ b/plugins/lacrosse-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/lacrosse-scoreboard/dynamic_team_resolver.py b/plugins/lacrosse-scoreboard/dynamic_team_resolver.py index 06567d54..f3e7d4cd 100644 --- a/plugins/lacrosse-scoreboard/dynamic_team_resolver.py +++ b/plugins/lacrosse-scoreboard/dynamic_team_resolver.py @@ -154,7 +154,7 @@ def _fetch_rankings(self, sport: str) -> List[str]: url = f"https://site.api.espn.com/apis/site/v2/sports/{endpoint}" headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index c6626b6e..0853526b 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.7.2", + "version": "1.7.3", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "version": "1.7.3", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.7.2", "released": "2026-08-05", diff --git a/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py index 851f3c63..038deef6 100644 --- a/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py +++ b/plugins/lacrosse-scoreboard/test_lacrosse_plugin.py @@ -131,7 +131,7 @@ class _NetworkUnavailable(Exception): def _fetch(url: str) -> dict: - req = urllib.request.Request(url, headers={"User-Agent": "LEDMatrix/1.0"}) + req = urllib.request.Request(url, headers={"User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)"}) with urllib.request.urlopen(req, timeout=15) as resp: return json.loads(resp.read()) diff --git a/plugins/march-madness/manager.py b/plugins/march-madness/manager.py index a3d49685..971b2e18 100644 --- a/plugins/march-madness/manager.py +++ b/plugins/march-madness/manager.py @@ -143,7 +143,7 @@ def __init__( adapter = HTTPAdapter(max_retries=retry) self.session.mount("https://", adapter) self.session.mount("http://", adapter) - self.headers = {"User-Agent": "LEDMatrix/2.0"} + self.headers = {"User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)"} # ScrollHelper if ScrollHelper: diff --git a/plugins/march-madness/manifest.json b/plugins/march-madness/manifest.json index 79c12f08..62ea6231 100644 --- a/plugins/march-madness/manifest.json +++ b/plugins/march-madness/manifest.json @@ -1,7 +1,7 @@ { "id": "march-madness", "name": "March Madness", - "version": "1.0.3", + "version": "1.0.4", "description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting", "author": "ChuckBuilds", "category": "sports", @@ -20,6 +20,12 @@ ">=2.0.0" ], "versions": [ + { + "version": "1.0.4", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.0.3", "released": "2026-07-31", @@ -45,7 +51,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/masters-tournament/download_assets.py b/plugins/masters-tournament/download_assets.py index d1cf997e..e82a6914 100644 --- a/plugins/masters-tournament/download_assets.py +++ b/plugins/masters-tournament/download_assets.py @@ -94,7 +94,7 @@ def download_player_headshots(): url = f"https://a.espncdn.com/combiner/i?img=/i/headshots/golf/players/full/{pid}.png&w=350&h=254" try: resp = requests.get(url, timeout=10, headers={ - "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + "User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)" }) resp.raise_for_status() img = Image.open(BytesIO(resp.content)).convert("RGBA") diff --git a/plugins/masters-tournament/logo_loader.py b/plugins/masters-tournament/logo_loader.py index 24196e70..d40946c5 100644 --- a/plugins/masters-tournament/logo_loader.py +++ b/plugins/masters-tournament/logo_loader.py @@ -190,7 +190,7 @@ def get_player_headshot(self, player_id: str, url: Optional[str], max_size: int if url: try: response = requests.get(url, timeout=5, headers={ - "User-Agent": "LEDMatrix Masters Plugin/2.0" + "User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)" }) response.raise_for_status() diff --git a/plugins/masters-tournament/manifest.json b/plugins/masters-tournament/manifest.json index 95f2ade3..60f85be8 100644 --- a/plugins/masters-tournament/manifest.json +++ b/plugins/masters-tournament/manifest.json @@ -1,7 +1,7 @@ { "id": "masters-tournament", "name": "Masters Tournament", - "version": "2.5.3", + "version": "2.5.4", "description": "Broadcast-quality Masters Tournament display with real ESPN player headshots, accurate Augusta National hole layouts, fun facts, past champions, live leaderboards, and pixel-perfect LED matrix rendering", "author": "ChuckBuilds", "class_name": "MastersTournamentPlugin", @@ -43,6 +43,12 @@ "height": 64 }, "versions": [ + { + "version": "2.5.4", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-07-17", "version": "2.5.3", @@ -160,7 +166,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-17", + "last_updated": "2026-08-05", "compatible_versions": [ ">=2.0.0" ] diff --git a/plugins/masters-tournament/masters_data.py b/plugins/masters-tournament/masters_data.py index 9d311c70..ef0da1a0 100644 --- a/plugins/masters-tournament/masters_data.py +++ b/plugins/masters-tournament/masters_data.py @@ -40,7 +40,7 @@ class MastersDataSource: ATHLETE_URL = "https://site.web.api.espn.com/apis/common/v3/sports/golf/pga/athletes/{player_id}" ATHLETE_OVERVIEW_URL = "https://site.web.api.espn.com/apis/common/v3/sports/golf/pga/athletes/{player_id}/overview" - HTTP_HEADERS = {"User-Agent": "LEDMatrix Masters Plugin/2.1"} + HTTP_HEADERS = {"User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)"} def __init__(self, cache_manager, config: Dict[str, Any]): self.cache_manager = cache_manager diff --git a/plugins/news/manager.py b/plugins/news/manager.py index 87af99fd..4d1701a8 100644 --- a/plugins/news/manager.py +++ b/plugins/news/manager.py @@ -922,7 +922,7 @@ def _fetch_feed_headlines(self, feed_name: str, feed_url: str) -> List[Dict]: try: self.logger.info(f"Fetching headlines from {feed_name}...") headers = { - 'User-Agent': 'LEDMatrix-NewsPlugin/1.0 (RSS Reader)' + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)' } response = requests.get(feed_url, timeout=self.background_config.get('request_timeout', 30), headers=headers) response.raise_for_status() diff --git a/plugins/news/manifest.json b/plugins/news/manifest.json index b592dbe4..aeed5db8 100644 --- a/plugins/news/manifest.json +++ b/plugins/news/manifest.json @@ -1,7 +1,7 @@ { "id": "news", "name": "News Ticker", - "version": "1.3.0", + "version": "1.3.1", "description": "Displays scrolling news headlines from RSS feeds including sports news from ESPN, NCAA updates, and custom RSS sources", "author": "ChuckBuilds", "category": "content", @@ -20,6 +20,12 @@ "branch": "main", "plugin_path": "plugins/news", "versions": [ + { + "version": "1.3.1", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.0", "released": "2026-07-31", @@ -82,7 +88,7 @@ ], "stars": 0, "downloads": 0, - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "verified": true, "screenshot": "", "display_modes": [ diff --git a/plugins/nfl-draft/manager.py b/plugins/nfl-draft/manager.py index 1c9a9d04..1f024be8 100644 --- a/plugins/nfl-draft/manager.py +++ b/plugins/nfl-draft/manager.py @@ -278,7 +278,7 @@ def _fetch_all_prospects(self) -> List[Dict[str, Any]]: self.logger.info(f"Fetching draft athletes list from {athletes_url}") - req = Request(athletes_url, headers={'User-Agent': 'Mozilla/5.0'}) + req = Request(athletes_url, headers={'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'}) with urlopen(req, timeout=30) as response: data = json.loads(response.read().decode()) @@ -298,7 +298,7 @@ def _fetch_all_prospects(self) -> List[Dict[str, Any]]: # Fetch athlete details in parallel (limit concurrency) def fetch_athlete(url: str) -> Optional[Dict[str, Any]]: try: - req = Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + req = Request(url, headers={'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'}) with urlopen(req, timeout=10) as response: return json.loads(response.read().decode()) except Exception as e: @@ -374,7 +374,7 @@ def _fetch_tankathon_mock_draft(self) -> List[Dict[str, Any]]: req = Request( self.TANKATHON_MOCK_DRAFT, headers={ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "en-US,en;q=0.9", "Referer": "https://www.tankathon.com/", @@ -487,7 +487,7 @@ def _fetch_nfl_teams(self) -> Dict[str, str]: teams: Dict[str, str] = {} try: url = "https://site.api.espn.com/apis/site/v2/sports/football/nfl/teams?limit=50" - req = Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + req = Request(url, headers={'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'}) with urlopen(req, timeout=15) as response: data = json.loads(response.read().decode()) @@ -537,7 +537,7 @@ def _fetch_historical_picks(self) -> List[Dict[str, Any]]: f"https://sports.core.api.espn.com/v2/sports/football/leagues/nfl" f"/seasons/{year}/draft/rounds?lang=en®ion=us&limit=10" ) - req = Request(rounds_url, headers={'User-Agent': 'Mozilla/5.0'}) + req = Request(rounds_url, headers={'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'}) with urlopen(req, timeout=15) as response: rounds_data = json.loads(response.read().decode()) @@ -559,7 +559,7 @@ def fetch_athlete_ref(url: str) -> Optional[Dict]: if not url: return None try: - req = Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + req = Request(url, headers={'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)'}) with urlopen(req, timeout=10) as response: return json.loads(response.read().decode()) except Exception as e: diff --git a/plugins/nfl-draft/manifest.json b/plugins/nfl-draft/manifest.json index e82989c3..520ef7bf 100644 --- a/plugins/nfl-draft/manifest.json +++ b/plugins/nfl-draft/manifest.json @@ -1,7 +1,7 @@ { "id": "nfl-draft", "name": "NFL Draft", - "version": "1.4.0", + "version": "1.4.1", "author": "ChuckBuilds", "description": "Displays projected NFL draft picks from ESPN with live draft tracking support during the annual NFL Draft event. Includes simulate_live mode to replay a completed draft using real ESPN core API data. Shows team logos, player names, positions, and pick numbers in a scrolling display.", "entry_point": "manager.py", @@ -24,6 +24,12 @@ ], "icon": "fas fa-football-ball", "versions": [ + { + "version": "1.4.1", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.4.0", "released": "2026-07-31", @@ -112,5 +118,5 @@ "nfl_logos": "assets/sports/nfl_logos/" }, "license": "GPL-3.0", - "last_updated": "2026-07-31" + "last_updated": "2026-08-05" } diff --git a/plugins/nrl-scoreboard/data_sources.py b/plugins/nrl-scoreboard/data_sources.py index 38d6afb2..3966db90 100644 --- a/plugins/nrl-scoreboard/data_sources.py +++ b/plugins/nrl-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index 2894b6fe..ccd5f6d0 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.3.1", + "version": "1.3.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "version": "1.3.2", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "1.3.1", "released": "2026-08-05", @@ -82,7 +88,7 @@ "ledmatrix_min": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/soccer-scoreboard/data_sources.py b/plugins/soccer-scoreboard/data_sources.py index 720f8b78..a24b9541 100644 --- a/plugins/soccer-scoreboard/data_sources.py +++ b/plugins/soccer-scoreboard/data_sources.py @@ -46,7 +46,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index c82593b8..93e128b9 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.6.1", + "version": "2.6.2", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "version": "2.6.2", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "version": "2.6.1", "released": "2026-08-05", @@ -170,7 +176,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/plugins/ufc-scoreboard/data_sources.py b/plugins/ufc-scoreboard/data_sources.py index 6ec73041..5eec7c96 100644 --- a/plugins/ufc-scoreboard/data_sources.py +++ b/plugins/ufc-scoreboard/data_sources.py @@ -47,7 +47,7 @@ def fetch_standings(self, sport: str, league: str) -> Dict: def get_headers(self) -> Dict[str, str]: """Get headers for API requests.""" return { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'application/json' } diff --git a/plugins/ufc-scoreboard/headshot_downloader.py b/plugins/ufc-scoreboard/headshot_downloader.py index 9a2de14a..bb69230a 100644 --- a/plugins/ufc-scoreboard/headshot_downloader.py +++ b/plugins/ufc-scoreboard/headshot_downloader.py @@ -41,7 +41,7 @@ def __init__(self, request_timeout: int = 30, retry_attempts: int = 3): # Set up headers self.headers = { - 'User-Agent': 'LEDMatrix/1.0', + 'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'Accept': 'image/png,image/jpeg,image/*', 'Accept-Language': 'en-US,en;q=0.9', 'Accept-Encoding': 'gzip, deflate, br', diff --git a/plugins/ufc-scoreboard/manifest.json b/plugins/ufc-scoreboard/manifest.json index 14206aca..19a03669 100644 --- a/plugins/ufc-scoreboard/manifest.json +++ b/plugins/ufc-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ufc-scoreboard", "name": "UFC Scoreboard", - "version": "1.3.2", + "version": "1.3.3", "author": "LegoGuy1000", "contributors": [ { @@ -32,6 +32,12 @@ "default_duration": 15, "config_schema": "config_schema.json", "versions": [ + { + "version": "1.3.3", + "released": "2026-08-05", + "notes": "Fix ESPN 403s. Around 2026-08-04 ESPN's site.api began rejecting both browser-style and bare custom User-Agent strings, which returned 403 Forbidden for every request this plugin made. Requests now send an identifying agent with the project URL, which ESPN accepts. No behaviour change beyond the header.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-02", "version": "1.3.2", @@ -95,7 +101,7 @@ "ledmatrix_min_version": "2.0.0" } ], - "last_updated": "2026-07-31", + "last_updated": "2026-08-05", "stars": 0, "downloads": 0, "verified": true, diff --git a/scripts/check_espn_api.py b/scripts/check_espn_api.py index a3864f26..5318813c 100755 --- a/scripts/check_espn_api.py +++ b/scripts/check_espn_api.py @@ -10,25 +10,30 @@ Pi running LEDMatrix, a laptop) and it reports, per endpoint, whether ESPN still serves what the plugins expect. -The important trick is the second axis. The plugins do not speak to ESPN with one -voice; they send three different ``User-Agent`` values depending on which file -happens to make the call: - -* ``python-urllib`` / ``python-requests`` — the stdlib or requests default, sent - by every caller that passes no headers at all (odds-ticker's data_fetcher, most - of the ``*_managers.py``, every ``base_odds_manager.py``, this repo's own - check_team_pickers.py). -* ``LEDMatrix/1.0`` — the custom agent set by the ``data_sources.py`` family. -* a browser string — what a handful of files already send, and what ESPN's own - site sends. - -Anti-bot filtering is the most common way an undocumented API "changes", and it -discriminates on exactly that header. So each endpoint is tried under all three -profiles. That turns an ambiguous outage into a diagnosis: +The important trick is the second axis: each endpoint is tried under several +``User-Agent`` values, because anti-bot filtering is the most common way an +undocumented API "changes" and it discriminates on exactly that header. + +Do not assume which side of that filter is the safe one. This script originally +tested for the familiar shape — a browser agent works, a script agent is +refused — and reported "healthy" all the way through the 2026-08-04 outage, +where ESPN had inverted it: + +* a browser string is refused unconditionally (no other header rescues it), +* a bare custom token like ``LEDMatrix/1.0`` is refused when the request also + sends no ``Accept`` header, +* honest client tokens (``python-requests``, ``curl``, ``Python-urllib``) and a + token carrying a project URL are accepted. + +So the profiles below are split into what the plugins actually send (``shipped``) +and controls kept only to characterise the filter, and the verdict is +direction-agnostic — it names which agents were accepted and which refused +rather than assuming. The diagnosis: * every profile fails -> ESPN moved or withdrew the endpoint; the URL needs work. -* only the non-browser profiles fail -> ESPN is filtering on User-Agent, and the - fix is a header change, not a URL change. +* some accepted, some refused -> ESPN is filtering on User-Agent. It only breaks + the plugins if a *shipped* profile is on the refused side; the fix is then a + header change, not a URL change. * everything passes -> ESPN is fine; look at the plugin, the cache, or the network in front of it. @@ -111,11 +116,27 @@ ), ] -# The three voices the plugins actually use. Order matters: the browser profile -# is last so a "browser works, ours do not" split is easy to read off the table. +# The voices to try. `shipped` marks the ones the plugins actually send, which +# is what decides the exit code: a control failing is information, a shipped +# profile failing is the outage. The controls are kept because *which* agents +# ESPN rejects is the diagnosis — on 2026-08-04 it started refusing browser +# strings and bare custom tokens while accepting honest client tokens, the +# reverse of the anti-bot filtering this script was first written to expect. PROFILES = [ - ("default", {}), - ("LEDMatrix/1.0", {"User-Agent": "LEDMatrix/1.0", "Accept": "application/json"}), + ("default", {}, True), + ( + "shipped", + { + "User-Agent": "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)", + "Accept": "application/json", + }, + True, + ), + ( + "bare-custom", + {"User-Agent": "LEDMatrix/1.0", "Accept": "application/json"}, + False, + ), ( "browser", { @@ -125,9 +146,12 @@ ), "Accept": "application/json, text/plain, */*", }, + False, ), ] +SHIPPED = [name for name, _headers, shipped in PROFILES if shipped] + def probe(url, headers, timeout): """Fetch url and return a result dict. Never raises.""" @@ -194,7 +218,7 @@ def main(): report = [] for label, url, expected in ENDPOINTS: row = {"endpoint": label, "url": url, "profiles": {}} - for profile_name, headers in PROFILES: + for profile_name, headers, _shipped in PROFILES: result = check_expected_key(probe(url, headers, args.timeout), expected) row["profiles"][profile_name] = { k: v for k, v in result.items() if k != "data" @@ -248,18 +272,28 @@ def summarize(report, as_json): if all(p.get("status") is None for p in r["profiles"].values()) ] all_dead = [r for r in failed if r not in unreachable] + + # Direction-agnostic on purpose. The first version of this asked only + # "does browser work where ours does not", so when ESPN inverted the rule + # and began rejecting browser strings instead, every endpoint looked + # healthy and the advice it printed — send a browser agent — was the exact + # change that would have kept the plugins broken. ua_split = [ - r - for r in report - if r["profiles"]["browser"]["ok"] - and not (r["profiles"]["default"]["ok"] and r["profiles"]["LEDMatrix/1.0"]["ok"]) + r for r in report + if any(p["ok"] for p in r["profiles"].values()) + and not all(p["ok"] for p in r["profiles"].values()) + ] + # Only a shipped profile failing actually breaks a plugin. + broken_shipped = [ + r for r in report + if r not in unreachable and not all(r["profiles"][n]["ok"] for n in SHIPPED) ] if as_json: - return 1 if failed or ua_split else 0 + return 1 if failed or broken_shipped else 0 if unreachable and len(unreachable) == total: - reason = unreachable[0]["profiles"]["browser"]["error"] + reason = next(iter(unreachable[0]["profiles"].values()))["error"] print(f"CANNOT REACH ESPN: all {total} endpoints failed without ever getting " "an HTTP response.") print(f" First reason: {reason}") @@ -274,12 +308,29 @@ def summarize(report, as_json): return 0 if ua_split: - print(f"USER-AGENT FILTERING: {len(ua_split)}/{total} endpoints answer a " - "browser agent but reject ours.") - print(" ESPN is filtering on User-Agent. The fix is a header change, not a " - "URL change: send a browser User-Agent from every ESPN caller.") + # Name the agents rather than assuming which side of the split is ours. + accepted, rejected = set(), set() for row in ua_split: - print(f" - {row['endpoint']}") + for name, result in row["profiles"].items(): + (accepted if result["ok"] else rejected).add(name) + + print(f"USER-AGENT FILTERING: {len(ua_split)}/{total} endpoints accept some " + "agents and reject others.") + print(f" accepted: {', '.join(sorted(accepted)) or 'none'}") + print(f" rejected: {', '.join(sorted(rejected)) or 'none'}") + + broken_names = sorted(n for n in SHIPPED if n in rejected) + if broken_names: + print(f" The plugins send {', '.join(broken_names)}, which ESPN is now " + "rejecting. This is a header change, not a URL change: switch every " + "ESPN caller to an agent in the accepted list above.") + else: + print(" Every agent the plugins actually send is still accepted, so this " + "does not break them today. It is a warning: ESPN is discriminating " + "on User-Agent, and which side is allowed has flipped before.") + for row in ua_split: + missing = sorted(n for n, r in row["profiles"].items() if not r["ok"]) + print(f" - {row['endpoint']}: rejects {', '.join(missing)}") if all_dead: print(f"\nENDPOINT DOWN: {len(all_dead)}/{total} endpoints fail under every " @@ -287,15 +338,23 @@ def summarize(report, as_json): print(" Not a header problem — the URL moved or was withdrawn, or the " "response shape changed. These need per-endpoint work:") for row in all_dead: - reason = row["profiles"]["browser"]["error"] + reason = next(iter(row["profiles"].values()))["error"] print(f" - {row['endpoint']}: {reason}") if unreachable: print(f"\nUNREACHABLE: {len(unreachable)}/{total} endpoints never got an HTTP " "response, so they are undiagnosed rather than broken:") for row in unreachable: - print(f" - {row['endpoint']}: {row['profiles']['browser']['error']}") + reason = next(iter(row["profiles"].values()))["error"] + print(f" - {row['endpoint']}: {reason}") + # A split that leaves every shipped agent working is a warning, not a + # failure: nothing is broken today, and exiting non-zero for it would train + # whoever runs this to ignore the one exit code that means "act now". + if not failed and not broken_shipped: + print("\nNothing the plugins send is being rejected — reporting the split " + "above as a warning, not a failure.") + return 0 return 1