import logging import time from datetime import datetime, timezone from typing import Optional import aiohttp from .sports_mappings import format_clean_date, format_clean_date_time, get_team_abbreviation class ESPNClient: """Client for ESPN API using aiohttp for asynchronous requests""" BASE_URL = "http://site.api.espn.com/apis/site/v2/sports" # Standings live under a different path prefix than the site v2 endpoints STANDINGS_BASE_URL = "http://site.api.espn.com/apis/v2/sports" def __init__(self, logger: Optional[logging.Logger] = None, timeout: int = 10, session: Optional[aiohttp.ClientSession] = None): """Initialize the ESPN API client. Args: logger: Logger instance for error and info logging. If None, creates a default logger. timeout: Request timeout in seconds (default: 10) session: Optional existing aiohttp session to reuse. If None, creates new sessions as needed. """ self.logger = logger or logging.getLogger(__name__) self.timeout = aiohttp.ClientTimeout(total=timeout) self.session = session async def _get_session(self) -> aiohttp.ClientSession: """Get or create an aiohttp session""" if self.session is None or self.session.closed: self.session = aiohttp.ClientSession(timeout=self.timeout) return self.session @staticmethod def _scoreboard_url( sport: str, league: str, start_date: Optional[str] = None, end_date: Optional[str] = None, cache_bust: bool = False, ) -> str: url = f"{ESPNClient.BASE_URL}/{sport}/{league}/scoreboard" params: list[str] = [] if start_date and end_date: params.append(f"dates={start_date}-{end_date}") if cache_bust: params.append(f"_={int(time.time() * 1000)}") if params: url += "?" + "&".join(params) return url @staticmethod def _event_timestamp(event: dict) -> Optional[float]: date_str = event.get('date', '') if not date_str: return None try: dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt.timestamp() except (TypeError, ValueError): return None async def fetch_scoreboard(self, sport: str, league: str) -> list[dict]: """Fetch and parse scoreboard data for a league""" url = f"{self.BASE_URL}/{sport}/{league}/scoreboard" try: session = await self._get_session() async with session.get(url) as response: response.raise_for_status() data = await response.json() events = data.get('events', []) parsed_events = [] for event in events: parsed = self.parse_league_game_event(event, sport, league) if parsed: parsed_events.append(parsed) return parsed_events except Exception as e: self.logger.error(f"ESPN fetch_scoreboard error for {sport}/{league}: {e}") return [] async def fetch_scoreboard_with_calendar( self, sport: str, league: str, start_date: Optional[str] = None, end_date: Optional[str] = None, ) -> Optional[dict]: """Fetch scoreboard plus tournament calendar/league metadata. Returns a dict with: - 'events': parsed game events (same shape as fetch_scoreboard) - 'calendar': list of {startDate, endDate, label} stage entries (may be empty) - 'league_name': human-readable league/tournament name - 'competitors': raw competitor dicts seen in today's events (id/name/abbr/location) Used by the World Cup command to determine whether a tournament is in season and to resolve nation names when standings are unavailable. Returns None on error. Optional start_date/end_date are YYYYMMDD strings for the dated scoreboard endpoint. """ url = self._scoreboard_url(sport, league, start_date, end_date) try: session = await self._get_session() async with session.get(url) as response: response.raise_for_status() data = await response.json() events_raw = data.get('events', []) parsed_events = [] competitors: list[dict] = [] for event in events_raw: parsed = self.parse_league_game_event(event, sport, league) if parsed: parsed_events.append(parsed) for comp in event.get('competitions', [{}])[0].get('competitors', []): team = comp.get('team', {}) if team: competitors.append(team) leagues = data.get('leagues', []) league_obj = leagues[0] if leagues else {} # The calendar nests the real stages (Group, Round of 32, ... Final) under # an 'entries' list inside a single top-level wrapper whose own end date spans # the whole season. Flatten to the actual stages so callers get true windows. calendar = [] def _add_cal(entry: dict) -> None: if isinstance(entry, dict) and entry.get('startDate') and entry.get('endDate'): calendar.append({ 'label': entry.get('label', ''), 'startDate': entry['startDate'], 'endDate': entry['endDate'], }) for entry in league_obj.get('calendar', []): nested = entry.get('entries') if isinstance(entry, dict) else None if isinstance(nested, list) and nested: for sub in nested: _add_cal(sub) else: _add_cal(entry) return { 'events': parsed_events, 'calendar': calendar, 'league_name': league_obj.get('name', ''), 'competitors': competitors, } except Exception as e: self.logger.error(f"ESPN fetch_scoreboard_with_calendar error for {sport}/{league}: {e}") return None async def fetch_team_fixtures(self, sport: str, league: str, team_id: str, start_date: str, end_date: str) -> list[dict]: """Fetch a team's matches across a date range via the dated scoreboard endpoint. Unlike the team /schedule endpoint (which, for tournament national teams, only returns matches already played), the dated scoreboard exposes the full set of known fixtures. start_date/end_date are 'YYYYMMDD' strings. Returns parsed events (past, live, and scheduled) involving team_id, or [] on error. """ url = f"{self.BASE_URL}/{sport}/{league}/scoreboard?dates={start_date}-{end_date}" try: session = await self._get_session() async with session.get(url) as response: response.raise_for_status() data = await response.json() results = [] for event in data.get('events', []): competition = event.get('competitions', [{}])[0] competitors = competition.get('competitors', []) if any(str(c.get('team', {}).get('id', '')) == str(team_id) for c in competitors): parsed = self.parse_game_event_with_timestamp(event, str(team_id), sport, league) if parsed: results.append(parsed) return results except Exception as e: self.logger.error(f"ESPN fetch_team_fixtures error for {team_id} {start_date}-{end_date}: {e}") return [] def _score_int(self, competitor: dict) -> int: """Best-effort integer score for a competitor (0 on failure).""" try: return int(float(self.extract_score(competitor))) except (TypeError, ValueError): return 0 async def fetch_match_states( self, sport: str, league: str, cache_bust: bool = False, start_date: Optional[str] = None, end_date: Optional[str] = None, ) -> list[dict]: """Return current per-match live state for the scoreboard (today's matches). Each item: {id, home_id, away_id, home_name, away_name, home_score, away_score, status, clock, home_pen, away_pen, goals, cards, event_timestamp}. Names are full team display names. Penalty fields are None unless a shootout score is present. ``goals`` is the chronological list of scoring plays, each {clock, scorer, team_id, own_goal, penalty, kind} (kind is 'header'/'volley'/''; penalty shootout kicks excluded). ``cards``/``yellows`` are the chronological lists of red/yellow cards, each {clock, player, team_id}. Used by the live-score service to detect kickoff / goal / half-time / full-time / card / stoppage transitions and name players. Returns [] on error. cache_bust appends a unique query param to bypass ESPN's edge cache, used when a fastcast push signals a change so the REST snapshot reflects it immediately. Optional start_date/end_date are YYYYMMDD strings for the dated scoreboard endpoint. """ url = self._scoreboard_url(sport, league, start_date, end_date, cache_bust=cache_bust) try: session = await self._get_session() async with session.get(url) as response: response.raise_for_status() data = await response.json() states = [] for event in data.get('events', []): # Parse each event defensively: a single malformed event/detail must not # wipe out the whole batch (which would silence the live-score service). try: competition = (event.get('competitions') or [{}])[0] competitors = competition.get('competitors') or [] if len(competitors) != 2: continue home = next((c for c in competitors if c.get('homeAway') == 'home'), competitors[0]) away = next((c for c in competitors if c.get('homeAway') == 'away'), competitors[1]) status_obj = competition.get('status') or event.get('status') or {} goals = [] red_cards = [] yellow_cards = [] for det in (competition.get('details') or []): athletes = det.get('athletesInvolved') or [] player = athletes[0].get('displayName', '') if athletes and isinstance(athletes[0], dict) else '' clock = (det.get('clock') or {}).get('displayValue', '') team_id = str((det.get('team') or {}).get('id', '')) if det.get('redCard'): red_cards.append({'clock': clock, 'player': player, 'team_id': team_id}) elif det.get('yellowCard'): yellow_cards.append({'clock': clock, 'player': player, 'team_id': team_id}) if not det.get('scoringPlay') or det.get('shootout'): continue type_text = ((det.get('type') or {}).get('text') or '').lower() kind = 'header' if 'header' in type_text else ('volley' if 'volley' in type_text else '') goals.append({ 'clock': clock, 'scorer': player, 'team_id': team_id, 'own_goal': bool(det.get('ownGoal')), 'penalty': bool(det.get('penaltyKick')), 'kind': kind, }) home_team = home.get('team') or {} away_team = away.get('team') or {} states.append({ 'id': str(event.get('id', '')), 'home_id': str(home_team.get('id', '')), 'away_id': str(away_team.get('id', '')), 'home_name': home_team.get('displayName') or home_team.get('name') or '?', 'away_name': away_team.get('displayName') or away_team.get('name') or '?', 'home_score': self._score_int(home), 'away_score': self._score_int(away), 'status': (status_obj.get('type') or {}).get('name', 'UNKNOWN'), 'clock': status_obj.get('displayClock', ''), 'home_pen': self.extract_shootout_score(home), 'away_pen': self.extract_shootout_score(away), 'goals': goals, 'cards': red_cards, 'yellows': yellow_cards, 'event_timestamp': self._event_timestamp(event), }) except Exception as e: self.logger.warning(f"ESPN fetch_match_states: skipping malformed event {event.get('id')}: {e}") continue return states except Exception as e: self.logger.error(f"ESPN fetch_match_states error for {sport}/{league}: {e}") return [] @staticmethod def _stat_value(stats: list[dict], *names: str) -> Optional[float]: """Look up a numeric stat value by its 'name' (or 'type') from a stats array.""" wanted = {n.lower() for n in names} for stat in stats: if str(stat.get('name', '')).lower() in wanted or str(stat.get('type', '')).lower() in wanted: value = stat.get('value') if isinstance(value, (int, float)): return value return None async def fetch_standings(self, sport: str, league: str) -> list[dict]: """Fetch and parse group standings for a league. Returns a list of groups, each: {'group_name': 'Group A', 'entries': [{'rank', 'name', 'abbr', 'gp', 'w', 'd', 'l', 'pts', 'gd'}, ...]} Entries are sorted by rank. Returns [] on error or if no standings exist. """ url = f"{self.STANDINGS_BASE_URL}/{sport}/{league}/standings" try: session = await self._get_session() async with session.get(url) as response: response.raise_for_status() data = await response.json() groups = [] for child in data.get('children', []): standings = child.get('standings', {}) entries = [] for entry in standings.get('entries', []): team = entry.get('team', {}) stats = entry.get('stats', []) def _i(*names: str) -> int: v = self._stat_value(stats, *names) return int(v) if v is not None else 0 entries.append({ 'rank': _i('rank'), 'id': str(team.get('id', '')), 'name': team.get('displayName', team.get('name', '')), 'abbr': team.get('abbreviation', ''), 'location': team.get('location', ''), 'gp': _i('gamesPlayed'), 'w': _i('wins'), 'd': _i('ties', 'draws'), 'l': _i('losses'), 'pts': _i('points'), 'gd': _i('pointDifferential'), }) entries.sort(key=lambda e: e['rank'] if e['rank'] else 999) groups.append({ 'group_name': child.get('name', ''), 'entries': entries, }) return groups except Exception as e: self.logger.error(f"ESPN fetch_standings error for {sport}/{league}: {e}") return [] async def fetch_team_schedule(self, sport: str, league: str, team_id: str) -> list[dict]: """Fetch and parse schedule data for a team For soccer teams, if the team schedule has no upcoming games, we fall back to searching the league scoreboard for games involving this team. """ url = f"{self.BASE_URL}/{sport}/{league}/teams/{team_id}/schedule" try: session = await self._get_session() async with session.get(url) as response: response.raise_for_status() data = await response.json() events = data.get('events', []) parsed_events = [] for event in events: parsed = self.parse_game_event_with_timestamp(event, team_id, sport, league) if parsed: parsed_events.append(parsed) # For soccer, if no upcoming games found, check league scoreboard if sport == 'soccer': from datetime import datetime, timezone now = datetime.now(timezone.utc).timestamp() has_upcoming = any( g.get('event_timestamp', 0) > now for g in parsed_events ) if not has_upcoming: # Fall back to league scoreboard to find this team's games scoreboard_games = await self._find_team_in_scoreboard(sport, league, team_id) if scoreboard_games: parsed_events.extend(scoreboard_games) return parsed_events except Exception as e: self.logger.error(f"ESPN fetch_team_schedule error for {team_id}: {e}") return [] async def _find_team_in_scoreboard(self, sport: str, league: str, team_id: str) -> list[dict]: """Find games for a specific team in the league scoreboard""" url = f"{self.BASE_URL}/{sport}/{league}/scoreboard" try: session = await self._get_session() async with session.get(url) as response: response.raise_for_status() data = await response.json() events = data.get('events', []) team_games = [] for event in events: # Check if this team is in this event competitions = event.get('competitions', []) if not competitions: continue competition = competitions[0] competitors = competition.get('competitors', []) # Check if our team is in this game team_in_game = False for competitor in competitors: if str(competitor.get('team', {}).get('id', '')) == str(team_id): team_in_game = True break if team_in_game: parsed = self.parse_game_event_with_timestamp(event, team_id, sport, league) if parsed: team_games.append(parsed) return team_games except Exception as e: self.logger.error(f"Error finding team in scoreboard: {e}") return [] async def fetch_live_event_data(self, event_id: str, sport: str, league: str) -> Optional[dict]: """Fetch live event data from the scoreboard endpoint for real-time scores The scoreboard endpoint provides more up-to-date scores for live games than the schedule endpoint. We fetch the scoreboard and find the matching event by ID. """ url = f"{self.BASE_URL}/{sport}/{league}/scoreboard" try: session = await self._get_session() async with session.get(url) as response: response.raise_for_status() data = await response.json() # Find the event with matching ID in the scoreboard # Convert event_id to string for comparison (API may return IDs as strings or ints) event_id_str = str(event_id) events = data.get('events', []) for event in events: event_id_from_api = str(event.get('id', '')) if event_id_from_api == event_id_str: return event # If not found in scoreboard, return None (event might not be live anymore) return None except Exception as e: self.logger.error(f"ESPN fetch_live_event_data error for {event_id}: {e}") return None def extract_score(self, competitor: dict) -> str: """Extract score value from competitor data""" score = competitor.get('score', '0') if isinstance(score, dict): if 'displayValue' in score: return str(score['displayValue']) elif 'value' in score: value = score['value'] if isinstance(value, float) and value.is_integer(): return str(int(value)) return str(value) return '0' if isinstance(score, str): return score if isinstance(score, (int, float)): if isinstance(score, float) and score.is_integer(): return str(int(score)) return str(score) return '0' def extract_shootout_score(self, competitor: dict) -> Optional[int]: """Extract penalty shootout score from competitor data""" score = competitor.get('score', {}) if isinstance(score, dict) and 'shootoutScore' in score: shootout = score['shootoutScore'] if isinstance(shootout, (int, float)): return int(shootout) return None def parse_game_event_with_timestamp(self, event: dict, team_id: str, sport: str, league: str) -> Optional[dict]: """Parse a game event and return structured data with timestamp for sorting""" try: competitions = event.get('competitions', []) if not competitions: return None competition = competitions[0] competitors = competition.get('competitors', []) if len(competitors) != 2: return None # Extract team info team1 = competitors[0] team2 = competitors[1] # Determine home/away home_team = team1 if team1.get('homeAway') == 'home' else team2 away_team = team2 if team1.get('homeAway') == 'home' else team1 home_id = home_team.get('team', {}).get('id', '') away_id = away_team.get('team', {}).get('id', '') home_abbr = home_team.get('team', {}).get('abbreviation', 'UNK') away_abbr = away_team.get('team', {}).get('abbreviation', 'UNK') home_name = get_team_abbreviation(home_id, home_abbr, sport, league) away_name = get_team_abbreviation(away_id, away_abbr, sport, league) home_score = self.extract_score(home_team) away_score = self.extract_score(away_team) # Get game status status_obj = competition.get('status', event.get('status', {})) status_type = status_obj.get('type', {}) status_name = status_type.get('name', 'UNKNOWN') # Get timestamp for sorting date_str = event.get('date', '') timestamp: float = 0 event_timestamp: Optional[float] = None if date_str: try: dt = datetime.fromisoformat(date_str.replace('Z', '+00:00')) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) event_timestamp = dt.timestamp() timestamp = event_timestamp except: pass # Format based on game status formatted = "" if status_name in ['STATUS_IN_PROGRESS', 'STATUS_FIRST_HALF', 'STATUS_SECOND_HALF', 'STATUS_END_PERIOD']: # Game is live clock = status_obj.get('displayClock', '') period = status_obj.get('period', 0) is_end_period = (status_name == 'STATUS_END_PERIOD') if sport == 'soccer': # Soccer: @Home Score-Score Away (Clock) period_str = clock if (clock and clock != '0:00' and clock != "0'") else f"{period}H" formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({period_str})" elif sport == 'baseball': short_detail = status_type.get('shortDetail', '') period_str = short_detail if ('Top' in short_detail or 'Bottom' in short_detail) else f"{period}I" if is_end_period: period_str = f"End {period_str}" formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({period_str})" elif sport == 'football': period_str = f"Q{period}" if is_end_period: period_str = f"End {period_str}" formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({clock} {period_str})" else: period_str = f"P{period}" if is_end_period: period_str = f"End {period_str}" formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({clock} {period_str})" timestamp = -1 # Live games first elif status_name == 'STATUS_SCHEDULED': # Scheduled if event_timestamp: dt = datetime.fromtimestamp(event_timestamp, tz=timezone.utc).astimezone() time_str = format_clean_date_time(dt) if sport == 'soccer': formatted = f"@{home_name} vs. {away_name} ({time_str})" else: formatted = f"{away_abbr} @ {home_abbr} ({time_str})" else: formatted = f"{away_abbr} @ {home_abbr} (TBD)" if sport != 'soccer' else f"@{home_name} vs. {away_name} (TBD)" timestamp = 9999999999 elif status_name == 'STATUS_HALFTIME': if sport == 'soccer': formatted = f"@{home_name} {home_score}-{away_score} {away_name} (HT)" else: formatted = f"{away_abbr} {away_score}-{home_score} @{home_abbr} (HT)" timestamp = -2 elif status_name in ['STATUS_FINAL', 'STATUS_FULL_TIME', 'STATUS_FINAL_PEN', 'STATUS_POSTPONED']: date_suffix = "" if event_timestamp: dt = datetime.fromtimestamp(event_timestamp, tz=timezone.utc).astimezone() if dt.date() != datetime.now().date(): date_suffix = f", {format_clean_date(dt)}" if status_name == 'STATUS_FINAL_PEN': home_shootout = self.extract_shootout_score(home_team) away_shootout = self.extract_shootout_score(away_team) pen_str = f"FT-PEN {home_shootout}-{away_shootout}" if home_shootout is not None else "FT-PEN" formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({pen_str}{date_suffix})" elif status_name == 'STATUS_FULL_TIME': formatted = f"@{home_name} {home_score}-{away_score} {away_name} (FT{date_suffix})" elif status_name == 'STATUS_POSTPONED': formatted = f"{away_abbr} @ {home_abbr} (Postponed{date_suffix})" else: formatted = f"{away_abbr} {away_score}-{home_score} @{home_abbr} (F{date_suffix})" timestamp = 9999999998 else: if sport == 'soccer': formatted = f"@{home_name} {home_score}-{away_score} {away_name} ({status_name})" else: formatted = f"{away_name} {away_score}-{home_score} @{home_name} ({status_name})" timestamp = 9999999997 return { 'id': event.get('id'), 'timestamp': timestamp, 'event_timestamp': event_timestamp, 'formatted': formatted, 'sport': sport, 'league': league, 'status': status_name } except Exception as e: self.logger.error(f"Error parsing ESPN event {event.get('id')}: {e}") return None def parse_league_game_event(self, event: dict, sport: str, league: str) -> Optional[dict]: """Parse a league game event (scoreboard)""" return self.parse_game_event_with_timestamp(event, "", sport, league)