From 09b2e3ae4265449917e7686705bd14b5579a680b Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Sun, 20 Sep 2026 19:28:41 -0400 Subject: [PATCH] fix(sports): ask the endpoint the league actually publishes for standings ESPNDataSource.fetch_standings tried /standings first regardless of league and fell back to /rankings only on a 404. College leagues answer /standings with a 200 that carries no poll, so the fallback never fired and the poll came back empty every time. Nothing failed; the rank badge simply never appeared, and anything keyed off rankings quietly did nothing. Endpoints are now ordered by whether the league publishes a poll, a 200 that lacks the key counts as a miss so a league answering both still ends up with whichever one carries the poll, and only a 404 is treated as routine -- it is how a league says it has none. A connection error, a timeout or an unparseable body is logged as an error again. This is the implementation the football, baseball and hockey boards already ship; core was the last copy still on the old one. Verified against live ESPN: mens-college-basketball returns a populated rankings key where it previously returned nothing, and nba still resolves from /standings alone. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_014RRtqXDCnvnY6EQwhT5CV9 --- src/base_classes/data_sources.py | 75 ++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 29 deletions(-) diff --git a/src/base_classes/data_sources.py b/src/base_classes/data_sources.py index ac60f638..8c73df6c 100644 --- a/src/base_classes/data_sources.py +++ b/src/base_classes/data_sources.py @@ -114,35 +114,52 @@ class ESPNDataSource(DataSource): return [] def fetch_standings(self, sport: str, league: str) -> Dict: - """Fetch standings from ESPN API.""" - # Try standings endpoint first (for professional leagues like NFL, NBA, etc.) - try: - url = f"{self.base_url}/{sport}/{league}/standings" - response = self.session.get(url, headers=self.get_headers(), timeout=15) - response.raise_for_status() - - data = response.json() - self.logger.debug(f"Fetched standings for {sport}/{league}") - return data - except Exception as e: - # If standings doesn't exist, try rankings (for college sports) - if hasattr(e, 'response') and hasattr(e.response, 'status_code') and e.response.status_code == 404: - try: - url = f"{self.base_url}/{sport}/{league}/rankings" - response = self.session.get(url, headers=self.get_headers(), timeout=15) - response.raise_for_status() - - data = response.json() - self.logger.debug(f"Fetched rankings for {sport}/{league}") - return data - except Exception: - # Both endpoints failed - standings/rankings may not be available for this sport/league - self.logger.debug(f"Standings/rankings not available for {sport}/{league} from ESPN API") - return {} - else: - # Non-404 error - log at debug level since standings are optional - self.logger.debug(f"Error fetching standings from ESPN for {sport}/{league}: {e}") - return {} + """Fetch standings, or the poll for leagues that have one. + + Order matters and used to be wrong. College leagues publish a poll at + /rankings and a records table at /standings; professional leagues have + only /standings. The old code tried /standings first and fell back to + /rankings only on a 404 -- but college /standings answers 200, so the + fallback never fired and college rankings came back empty forever. + Nothing failed; the AP rank badge simply never appeared, and anything + else keyed off rankings quietly did nothing. + + A 200 that lacks the key is treated as a miss, so a league answering + both endpoints still ends up with whichever one actually carries a poll. + """ + league_name = (league or "").lower() + wants_poll = "college" in league_name or "ncaa" in league_name + endpoints = ["rankings", "standings"] if wants_poll else ["standings", "rankings"] + + for endpoint in endpoints: + try: + url = f"{self.base_url}/{sport}/{league}/{endpoint}" + response = self.session.get( + url, headers=self.get_headers(), timeout=15 + ) + response.raise_for_status() + data = response.json() + if endpoint == "rankings" and not data.get("rankings"): + continue + self.logger.debug(f"Fetched {endpoint} for {sport}/{league}") + return data + except Exception as e: + status = getattr(getattr(e, "response", None), "status_code", None) + # Only a 404 is routine -- it is how a league says "no poll + # here". Everything else is worth an error, and `status is + # None` covers the ones that matter most: ConnectionError, + # Timeout, a body that would not parse. Silencing those left a + # board that could not reach ESPN with one debug line, and the + # ranked filter running on an empty table. + if status != 404: + self.logger.error( + f"Error fetching {endpoint} from ESPN for " + f"{sport}/{league}: {e}" + ) + self.logger.debug( + f"Standings/rankings not available for {sport}/{league} from ESPN API" + ) + return {} class MLBAPIDataSource(DataSource):