diff --git a/plugins.json b/plugins.json index f918e3dc..e78aac92 100644 --- a/plugins.json +++ b/plugins.json @@ -1395,12 +1395,11 @@ "plugin_path": "plugins/nfl-stat-leaders", "stars": 0, "downloads": 0, - "last_updated": "2026-09-27", + "last_updated": "2026-10-02", "verified": true, "screenshot": "", - "latest_version": "1.0.1", - "ledmatrix_min_version": "3.4.0", - "commit": "764eb4d05342252cb2677f0fa4ba9f3cd8e5706a" + "latest_version": "1.0.2", + "ledmatrix_min_version": "3.4.0" }, { "id": "fantasy-blitz", diff --git a/plugins/nfl-stat-leaders/README.md b/plugins/nfl-stat-leaders/README.md index f1792fce..a34736cf 100644 --- a/plugins/nfl-stat-leaders/README.md +++ b/plugins/nfl-stat-leaders/README.md @@ -140,15 +140,18 @@ happens. To fit more in: turn off categories you do not care about, lower ## Data -`https://site.web.api.espn.com/apis/common/v3/sports/football/nfl/leaders`, -with ESPN's older `site/v2` leaders endpoint as a fallback. Both are public and -need no key. - -The one thing neither embeds reliably is which club a player is on — it usually -arrives as a reference URL. Resolving those would be one request per player per -category, so the franchise id inside the reference is mapped locally instead -(`nfl_stat_teams.py`), which is also what makes the crest load from the core's -own assets with no network at all. +`https://sports.core.api.espn.com/v2/sports/football/leagues/nfl/seasons/{season}/types/{type}/leaders` +(ESPN's core API; public, no key). The season-scoped path matters: the bare +`/leagues/nfl/leaders` URL is all-time career leaders and ignores the season. + +Each leader arrives as a value plus two reference URLs, one to the player and +one to the club. The club is not looked up: the franchise id inside its +reference is mapped locally (`nfl_stat_teams.py`), which is also what lets the +crest load from the core's own assets with no network at all. The player is +looked up once for the name and position, then cached for a month, so only the +first refresh of a season makes a burst of requests (up to one per player +shown). If those lookups start failing the plugin stops trying for that +refresh instead of waiting out a timeout per player. Everything is fetched in `update()` and cached; `display()` only draws. If ESPN is unreachable the last successful leaderboards stay on the panel rather than diff --git a/plugins/nfl-stat-leaders/manifest.json b/plugins/nfl-stat-leaders/manifest.json index d8840ebf..df9fe845 100644 --- a/plugins/nfl-stat-leaders/manifest.json +++ b/plugins/nfl-stat-leaders/manifest.json @@ -1,7 +1,7 @@ { "id": "nfl-stat-leaders", "name": "NFL Stat Leaders", - "version": "1.0.1", + "version": "1.0.2", "author": "ChuckBuilds", "description": "A scrolling ticker of the NFL's statistical leaders -- passing, rushing and receiving yards and touchdowns, plus receptions, sacks, interceptions, tackles and passer rating. Each category is a separate leaderboard you can turn on or off, drawn with club crests and club colours. Data comes from ESPN's public feed, so no API key is needed.", "entry_point": "manager.py", @@ -31,6 +31,12 @@ ">=3.4.0" ], "versions": [ + { + "version": "1.0.2", + "released": "2026-10-02", + "ledmatrix_min_version": "3.4.0", + "notes": "Fix: the leaders endpoint moved and the plugin showed nothing. ESPN's site.web common/v3 and site/v2 leaders URLs now return 404, so the fetcher now reads the core API's season-scoped leaders (sports.core.api.espn.com .../seasons/{season}/types/{type}/leaders). Those leaders reference the player rather than embedding them, so names and positions are looked up once per player and cached for a month; this happens in update(), never display(). A run of failed lookups stops that refresh early instead of waiting out a timeout per player." + }, { "version": "1.0.1", "released": "2026-09-27", @@ -62,7 +68,7 @@ "name": "ESPN API", "required": true, "description": "ESPN's public NFL leaders feed. No API key and no account are required.", - "url": "https://site.web.api.espn.com/apis/common/v3/sports/football/nfl/leaders" + "url": "https://sports.core.api.espn.com/v2/sports/football/leagues/nfl/leaders" } ], "assets": { @@ -74,5 +80,5 @@ "verified": true, "screenshot": "", "license": "GPL-3.0", - "last_updated": "2026-09-27" + "last_updated": "2026-10-02" } diff --git a/plugins/nfl-stat-leaders/nfl_stat_fetcher.py b/plugins/nfl-stat-leaders/nfl_stat_fetcher.py index 8a650add..d45ffa5b 100644 --- a/plugins/nfl-stat-leaders/nfl_stat_fetcher.py +++ b/plugins/nfl-stat-leaders/nfl_stat_fetcher.py @@ -4,22 +4,22 @@ credentials of any kind -- nothing is read from ``config_secrets.json`` and nothing is ever logged that could leak one. -Two shapes of the same feed are tried in order. The ``common/v3`` endpoint -embeds the athlete object inside each leader, which is what makes a player's -name and position available without a second request; the older ``site/v2`` -endpoint is the fallback for the day ESPN moves something. Both are parsed -by the same normaliser, because the leader objects inside them agree even -where the envelopes do not. +The feed is ESPN's core API, scoped to a season and season type. Its leader +objects carry the value and two ``$ref`` links -- one to the athlete, one to +the club -- rather than embedding either. The club is mapped locally from the +franchise id in its ref (``nfl_stat_teams``); the athlete is fetched once per +player and cached for weeks, because a player's name and position do not +change and the same names recur across categories and refreshes. -The one thing neither endpoint reliably embeds is the club: it is usually a -``$ref``. Resolving those would be one request per leader, so the franchise -id in the ref is mapped locally instead (``nfl_stat_teams``). +(The unscoped ``/leagues/nfl/leaders`` path is all-time career leaders and +ignores the season, so it is not used.) Module name is plugin-unique so the core's flat module loading cannot bind another plugin's ``data_fetcher`` (monorepo CLAUDE.md non-negotiable #4). """ import logging +import re import time from datetime import datetime, timezone from typing import Any, Dict, List, Optional @@ -33,13 +33,11 @@ #: getting 403s in August 2026 (see nfl-draft 1.4.1); this one is accepted. USER_AGENT = "LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)" -#: Embeds the athlete object in each leader -- preferred. -LEADERS_URL_V3 = ( - "https://site.web.api.espn.com/apis/common/v3/sports/football/nfl/leaders" -) -#: Older envelope, same leader objects. Tried only if the first yields nothing. -LEADERS_URL_V2 = ( - "https://site.api.espn.com/apis/site/v2/sports/football/nfl/leaders" +#: Season-scoped leaders. ``{season}`` is the year the season kicks off in and +#: ``{season_type}`` is 2 (regular) or 3 (postseason). +LEADERS_URL = ( + "https://sports.core.api.espn.com/v2/sports/football/leagues/nfl" + "/seasons/{season}/types/{season_type}/leaders" ) #: ESPN season types. @@ -56,6 +54,18 @@ #: leaves room to drop a malformed entry without shortening the board. MAX_LEADERS_REQUESTED = 20 +#: A player's name and position are cached this long. They effectively never +#: change within a season; a month keeps a first run's burst of lookups from +#: repeating on every refresh. +_ATHLETE_MAX_AGE = 30 * 24 * 3600 + +#: Give up resolving athletes for the rest of one refresh after this many +#: lookups fail in a row, so an outage costs seconds rather than one timeout +#: per leader. +_MAX_CONSECUTIVE_ATHLETE_FAILURES = 3 + +_ATHLETE_REF_ID = re.compile(r"/athletes/(\d+)") + def current_season_year(now: Optional[datetime] = None) -> int: """The season ESPN would label "current". @@ -86,6 +96,7 @@ def __init__(self, cache_manager, logger: Optional[logging.Logger] = None, self.cache_manager = cache_manager self.logger = logger or logging.getLogger(__name__) self.request_timeout = request_timeout + self._athlete_failures = 0 # ------------------------------------------------------------------ # Public API @@ -116,6 +127,7 @@ def fetch_boards(self, categories: List[StatCategory], season: int, ) return [] + self._athlete_failures = 0 boards = [] for category in categories: entry = match_feed_category(category, feed_categories) @@ -219,38 +231,79 @@ def _read_cache(self, cache_key: str, max_age: int) -> Optional[Dict[str, Any]]: def _request_payload(self, season: int, season_type: int) -> Optional[Dict[str, Any]]: - """Ask ESPN, preferring the endpoint that embeds athletes.""" - params = { - "season": season, - "seasontype": season_type, - "limit": MAX_LEADERS_REQUESTED, - } + """Ask ESPN for the season's leaders.""" + url = LEADERS_URL.format(season=season, season_type=season_type) + payload = self._get_json(url, {"limit": MAX_LEADERS_REQUESTED}) + if isinstance(payload, dict) and _feed_categories(payload): + self.logger.info( + "Fetched NFL leaders for season %s type %s", season, season_type + ) + return payload + self.logger.debug("No categories in the response from %s", url) + return None + + def _get_json(self, url: str, params: Optional[Dict[str, Any]] = None): + """GET ``url`` as JSON, or None (logged) on any failure.""" headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} + try: + response = requests.get( + url, params=params, headers=headers, + timeout=self.request_timeout, + ) + response.raise_for_status() + return response.json() + except requests.RequestException as exc: + self.logger.warning("Request to %s failed: %s", url, exc) + except ValueError as exc: + self.logger.warning("Response from %s was not JSON: %s", url, exc) + return None - for url in (LEADERS_URL_V3, LEADERS_URL_V2): - try: - response = requests.get( - url, params=params, headers=headers, - timeout=self.request_timeout, - ) - response.raise_for_status() - payload = response.json() - except requests.RequestException as exc: - self.logger.warning("Leaders request to %s failed: %s", url, exc) - continue - except ValueError as exc: - self.logger.warning("Leaders response from %s was not JSON: %s", - url, exc) - continue + # ------------------------------------------------------------------ + # Athletes + # ------------------------------------------------------------------ - if isinstance(payload, dict) and _feed_categories(payload): - self.logger.info( - "Fetched NFL leaders for season %s type %s from %s", - season, season_type, url, - ) - return payload - self.logger.debug("No categories in the response from %s", url) - return None + def _resolve_athlete(self, ref: Any) -> Dict[str, str]: + """``{"name", "position"}`` for an athlete ``$ref``, or ``{}``. + + Cached per athlete id. Runs from ``update()`` only; ``display()`` + reads the finished boards. + """ + match = _ATHLETE_REF_ID.search(str(ref or "")) + if not match: + return {} + cache_key = f"nfl-stat-leaders_athlete_{match.group(1)}" + + try: + record = self.cache_manager.get(cache_key, max_age=_ATHLETE_MAX_AGE) + except Exception as exc: # noqa: BLE001 - a broken cache is not fatal + self.logger.warning("Could not read cached athlete: %s", exc) + record = None + if isinstance(record, dict) and record.get("name"): + return {"name": record["name"], + "position": record.get("position", "")} + + if self._athlete_failures >= _MAX_CONSECUTIVE_ATHLETE_FAILURES: + return {} + + # ESPN hands out http:// refs; the API answers on https. + url = str(ref).replace("http://", "https://", 1) + athlete = self._get_json(url) + if not isinstance(athlete, dict): + self._athlete_failures += 1 + return {} + self._athlete_failures = 0 + + resolved = { + "name": _athlete_name(athlete), + "position": _athlete_position(athlete), + } + if not resolved["name"]: + return {} + try: + self.cache_manager.set(cache_key, dict(resolved)) + except Exception as exc: # noqa: BLE001 - see _get_payload + self.logger.warning("Could not cache athlete: %s", exc) + return resolved # ------------------------------------------------------------------ # Normalising @@ -267,7 +320,7 @@ def _normalize_leaders(self, feed_category: Dict[str, Any], for item in raw: if len(rows) >= limit: break - row = _leader_row(item, len(rows) + 1) + row = _leader_row(item, len(rows) + 1, self._resolve_athlete) if row is not None: rows.append(row) return rows @@ -283,8 +336,9 @@ def _normalize_leaders(self, feed_category: Dict[str, Any], def _feed_categories(payload: Any) -> List[dict]: """The category list, whichever envelope ESPN used. - ``common/v3`` puts it at the root; ``site/v2`` nests it under - ``leaders``, and has also served ``leaders`` as the list itself. + The core API puts it at the root. Older ESPN envelopes nested it under + ``leaders`` (or served ``leaders`` as the list itself), which is still + accepted so a recorded payload in either shape parses. """ if not isinstance(payload, dict): return [] @@ -303,9 +357,13 @@ def _feed_categories(payload: Any) -> List[dict]: return [] -def _leader_row(item: Any, rank: int) -> Optional[LeaderEntry]: +def _leader_row(item: Any, rank: int, + resolve_athlete=None) -> Optional[LeaderEntry]: """One normalised row, or None if the entry is unusable. + ``resolve_athlete`` is called with the athlete's ``$ref`` when the leader + does not embed the athlete. + A leader with no name is dropped: an anonymous row on a leaderboard is worse than a shorter leaderboard. """ @@ -316,6 +374,11 @@ def _leader_row(item: Any, rank: int) -> Optional[LeaderEntry]: athlete = athlete if isinstance(athlete, dict) else {} name = _athlete_name(athlete) + position = _athlete_position(athlete) + if not name and resolve_athlete is not None and athlete.get("$ref"): + resolved = resolve_athlete(athlete["$ref"]) + name = resolved.get("name", "") + position = position or resolved.get("position", "") if not name: return None @@ -326,7 +389,7 @@ def _leader_row(item: Any, rank: int) -> Optional[LeaderEntry]: return LeaderEntry( rank=rank, name=name, - position=_athlete_position(athlete), + position=position, team=_leader_team(item, athlete) or "", value=value, ) diff --git a/plugins/nfl-stat-leaders/test_nfl_stat_leaders.py b/plugins/nfl-stat-leaders/test_nfl_stat_leaders.py index dcef46bf..011189d1 100644 --- a/plugins/nfl-stat-leaders/test_nfl_stat_leaders.py +++ b/plugins/nfl-stat-leaders/test_nfl_stat_leaders.py @@ -350,6 +350,117 @@ def payload_for(season, season_type): fetcher.resolve_season(0, 2, 3600) == current_season_year() - 1) +def test_request_goes_to_the_season_scoped_core_endpoint(): + """The unscoped /leagues/nfl/leaders path is career leaders.""" + import nfl_stat_fetcher as fetcher_module + + calls = [] + + class Response: + def raise_for_status(self): + pass + + def json(self): + return fake_payload() + + def fake_get(url, params=None, headers=None, timeout=None): + calls.append((url, params)) + return Response() + + real_get = fetcher_module.requests.get + fetcher_module.requests.get = fake_get + try: + payload = StatFetcher(FakeCache())._request_payload(2025, 3) + finally: + fetcher_module.requests.get = real_get + + check("a payload comes back", payload is not None) + check("one request, to the season-scoped core URL", + [c[0] for c in calls] == [ + "https://sports.core.api.espn.com/v2/sports/football/leagues/nfl" + "/seasons/2025/types/3/leaders"], str(calls)) + + +def test_athlete_refs_are_resolved_once_and_cached(): + """Core leaders carry only an athlete $ref; the name comes from a lookup.""" + import nfl_stat_fetcher as fetcher_module + + ref = ("http://sports.core.api.espn.com/v2/sports/football/leagues/nfl" + "/seasons/2025/athletes/12483?lang=en®ion=us") + payload = {"categories": [{"name": "passingYards", "leaders": [{ + "displayValue": "4707", + "athlete": {"$ref": ref}, + "team": {"$ref": "http://sports.core.api.espn.com/v2/sports/football" + "/leagues/nfl/seasons/2025/teams/14?lang=en"}, + }]}]} + cache = FakeCache({"nfl-stat-leaders_2025_2": + {"fetched_at": 0, "payload": payload}}) + urls = [] + + class Response: + def raise_for_status(self): + pass + + def json(self): + return {"shortName": "M. Stafford", + "position": {"abbreviation": "QB"}} + + def fake_get(url, params=None, headers=None, timeout=None): + urls.append(url) + return Response() + + real_get = fetcher_module.requests.get + fetcher_module.requests.get = fake_get + try: + fetcher = StatFetcher(cache) + first = fetcher.fetch_boards([CATEGORIES_BY_KEY["passing_yards"]], + 2025, 2, 5, 3600) + second = fetcher.fetch_boards([CATEGORIES_BY_KEY["passing_yards"]], + 2025, 2, 5, 3600) + finally: + fetcher_module.requests.get = real_get + + row = first[0]["leaders"][0] + check("name and position come from the athlete lookup", + (row["name"], row["position"], row["value"]) == + ("M. Stafford", "QB", "4707"), str(row)) + check("the lookup is made over https", urls and urls[0].startswith("https://"), + str(urls)) + check("the second refresh reuses the cached athlete", len(urls) == 1, + str(urls)) + check("both refreshes agree", first == second) + + +def test_failed_athlete_lookups_stop_after_a_few(): + import nfl_stat_fetcher as fetcher_module + + def leader(i): + return {"displayValue": str(i), "athlete": { + "$ref": "http://x/athletes/%d?lang=en" % i}} + + payload = {"categories": [{"name": "passingYards", + "leaders": [leader(i) for i in range(1, 11)]}]} + cache = FakeCache({"nfl-stat-leaders_2025_2": + {"fetched_at": 0, "payload": payload}}) + attempts = [] + + def fake_get(url, params=None, headers=None, timeout=None): + attempts.append(url) + raise fetcher_module.requests.ConnectionError("down") + + real_get = fetcher_module.requests.get + fetcher_module.requests.get = fake_get + try: + boards = StatFetcher(cache).fetch_boards( + [CATEGORIES_BY_KEY["passing_yards"]], 2025, 2, 10, 3600) + finally: + fetcher_module.requests.get = real_get + + check("an outage costs a few attempts, not one per leader", + len(attempts) == 3, str(attempts)) + check("nameless rows are dropped, so no board", boards == []) + + def _fail_no_network(): raise AssertionError("the fetcher went to the network with a fresh cache")