Spaces:
Runtime error
Runtime error
| """ | |
| Football Data API Integrations | |
| This module provides integrations with multiple free football APIs: | |
| - API-Football (api-football.com) | |
| - Football-Data.org | |
| - OpenLigaDB (no API key required) | |
| """ | |
| import os | |
| import json | |
| import requests | |
| from datetime import datetime, timedelta | |
| from typing import Dict, List, Optional, Any | |
| from dataclasses import dataclass, asdict | |
| from pathlib import Path | |
| import hashlib | |
| class Team: | |
| """Standardized team representation""" | |
| id: str | |
| name: str | |
| short_name: Optional[str] = None | |
| logo_url: Optional[str] = None | |
| class Match: | |
| """Standardized match representation""" | |
| id: str | |
| home_team: Team | |
| away_team: Team | |
| kickoff: datetime | |
| league: str | |
| league_id: str | |
| season: str | |
| status: str # scheduled, live, finished | |
| home_score: Optional[int] = None | |
| away_score: Optional[int] = None | |
| venue: Optional[str] = None | |
| def to_dict(self) -> Dict: | |
| d = asdict(self) | |
| d['kickoff'] = self.kickoff.isoformat() | |
| return d | |
| class CacheManager: | |
| """Simple file-based cache to avoid hitting API rate limits""" | |
| def __init__(self, cache_dir: str = ".cache"): | |
| self.cache_dir = Path(cache_dir) | |
| self.cache_dir.mkdir(exist_ok=True) | |
| def _get_cache_path(self, key: str) -> Path: | |
| hashed = hashlib.md5(key.encode()).hexdigest() | |
| return self.cache_dir / f"{hashed}.json" | |
| def get(self, key: str, max_age_minutes: int = 60) -> Optional[Any]: | |
| """Get cached data if not expired""" | |
| path = self._get_cache_path(key) | |
| if not path.exists(): | |
| return None | |
| try: | |
| with open(path, 'r') as f: | |
| data = json.load(f) | |
| cached_at = datetime.fromisoformat(data['cached_at']) | |
| if datetime.now() - cached_at > timedelta(minutes=max_age_minutes): | |
| return None | |
| return data['value'] | |
| except: | |
| return None | |
| def set(self, key: str, value: Any): | |
| """Cache data with timestamp""" | |
| path = self._get_cache_path(key) | |
| with open(path, 'w') as f: | |
| json.dump({ | |
| 'cached_at': datetime.now().isoformat(), | |
| 'value': value | |
| }, f) | |
| class OpenLigaDBClient: | |
| """ | |
| OpenLigaDB - Completely free, no API key required! | |
| https://www.openligadb.de/ | |
| Best for: German Bundesliga and other leagues | |
| """ | |
| BASE_URL = "https://api.openligadb.de" | |
| LEAGUES = { | |
| # German Leagues (Free - No API Key!) | |
| 'bundesliga': 'bl1', | |
| 'bundesliga2': 'bl2', | |
| '3liga': 'bl3', | |
| 'dfb_pokal': 'dfb2025', # DFB-Pokal Cup | |
| # Other Leagues available on OpenLigaDB | |
| 'euro_2024': 'em2024', | |
| 'world_cup_2022': 'wm2022', | |
| 'champions_league': 'ucl2025', | |
| 'europa_league': 'uel2025', | |
| } | |
| def __init__(self): | |
| self.cache = CacheManager() | |
| self.session = requests.Session() | |
| def get_current_matchday(self, league: str = 'bundesliga') -> List[Match]: | |
| """Get current matchday fixtures""" | |
| league_code = self.LEAGUES.get(league, league) | |
| cache_key = f"openliga_current_{league_code}" | |
| cached = self.cache.get(cache_key, max_age_minutes=30) | |
| if cached: | |
| return self._parse_matches(cached, league) | |
| url = f"{self.BASE_URL}/getmatchdata/{league_code}" | |
| response = self.session.get(url) | |
| if response.status_code == 200: | |
| data = response.json() | |
| self.cache.set(cache_key, data) | |
| return self._parse_matches(data, league) | |
| return [] | |
| def get_upcoming_matches(self, league: str = 'bundesliga', days: int = 7) -> List[Match]: | |
| """Get upcoming matches for the next N days""" | |
| # OpenLigaDB returns current matchday, filter for upcoming | |
| matches = self.get_current_matchday(league) | |
| now = datetime.now() | |
| cutoff = now + timedelta(days=days) | |
| return [m for m in matches if now <= m.kickoff <= cutoff] | |
| def get_standings(self, league: str = 'bundesliga', season: int = None) -> List[Dict]: | |
| """Get current league standings""" | |
| league_code = self.LEAGUES.get(league, league) | |
| season = season or datetime.now().year | |
| cache_key = f"openliga_standings_{league_code}_{season}" | |
| cached = self.cache.get(cache_key, max_age_minutes=60) | |
| if cached: | |
| return cached | |
| url = f"{self.BASE_URL}/getbltable/{league_code}/{season}" | |
| response = self.session.get(url) | |
| if response.status_code == 200: | |
| data = response.json() | |
| self.cache.set(cache_key, data) | |
| return data | |
| return [] | |
| def _parse_matches(self, data: List[Dict], league: str) -> List[Match]: | |
| """Parse OpenLigaDB response into Match objects""" | |
| matches = [] | |
| for m in data: | |
| try: | |
| # Parse datetime | |
| kickoff_str = m.get('matchDateTime') or m.get('matchDateTimeUTC') | |
| if kickoff_str: | |
| kickoff = datetime.fromisoformat(kickoff_str.replace('Z', '+00:00')) | |
| else: | |
| continue | |
| # Determine status | |
| if m.get('matchIsFinished'): | |
| status = 'finished' | |
| elif kickoff <= datetime.now(): | |
| status = 'live' | |
| else: | |
| status = 'scheduled' | |
| # Get scores | |
| results = m.get('matchResults', []) | |
| home_score = away_score = None | |
| for r in results: | |
| if r.get('resultTypeID') == 2: # Final result | |
| home_score = r.get('pointsTeam1') | |
| away_score = r.get('pointsTeam2') | |
| match = Match( | |
| id=f"openliga_{m.get('matchID')}", | |
| home_team=Team( | |
| id=str(m['team1']['teamId']), | |
| name=m['team1']['teamName'], | |
| short_name=m['team1'].get('shortName'), | |
| logo_url=m['team1'].get('teamIconUrl') | |
| ), | |
| away_team=Team( | |
| id=str(m['team2']['teamId']), | |
| name=m['team2']['teamName'], | |
| short_name=m['team2'].get('shortName'), | |
| logo_url=m['team2'].get('teamIconUrl') | |
| ), | |
| kickoff=kickoff, | |
| league=league, | |
| league_id=m.get('leagueId', ''), | |
| season=str(m.get('leagueSeason', '')), | |
| status=status, | |
| home_score=home_score, | |
| away_score=away_score, | |
| venue=m.get('location', {}).get('locationCity') if m.get('location') else None | |
| ) | |
| matches.append(match) | |
| except Exception as e: | |
| print(f"Error parsing match: {e}") | |
| continue | |
| return matches | |
| class FootballDataOrgClient: | |
| """ | |
| Football-Data.org - Free tier with major leagues | |
| https://www.football-data.org/ | |
| Free tier: 10 requests/minute | |
| Leagues: EPL, La Liga, Bundesliga, Serie A, Ligue 1, Champions League | |
| """ | |
| BASE_URL = "https://api.football-data.org/v4" | |
| LEAGUES = { | |
| 'premier_league': 'PL', | |
| 'la_liga': 'PD', | |
| 'bundesliga': 'BL1', | |
| 'serie_a': 'SA', | |
| 'ligue_1': 'FL1', | |
| 'champions_league': 'CL', | |
| 'eredivisie': 'DED', | |
| 'primeira_liga': 'PPL', | |
| } | |
| def __init__(self, api_key: Optional[str] = None): | |
| self.api_key = api_key or os.getenv('FOOTBALL_DATA_API_KEY') | |
| self.cache = CacheManager() | |
| self.session = requests.Session() | |
| if self.api_key: | |
| self.session.headers['X-Auth-Token'] = self.api_key | |
| def get_upcoming_matches(self, league: str = 'premier_league', days: int = 7) -> List[Match]: | |
| """Get upcoming fixtures for a league""" | |
| if not self.api_key: | |
| print("Warning: Football-Data.org requires API key") | |
| return [] | |
| league_code = self.LEAGUES.get(league, league) | |
| cache_key = f"fdo_matches_{league_code}_{days}" | |
| cached = self.cache.get(cache_key, max_age_minutes=30) | |
| if cached: | |
| return self._parse_matches(cached, league) | |
| date_from = datetime.now().strftime('%Y-%m-%d') | |
| date_to = (datetime.now() + timedelta(days=days)).strftime('%Y-%m-%d') | |
| url = f"{self.BASE_URL}/competitions/{league_code}/matches" | |
| params = { | |
| 'dateFrom': date_from, | |
| 'dateTo': date_to, | |
| 'status': 'SCHEDULED' | |
| } | |
| try: | |
| response = self.session.get(url, params=params) | |
| if response.status_code == 200: | |
| data = response.json() | |
| self.cache.set(cache_key, data) | |
| return self._parse_matches(data, league) | |
| except Exception as e: | |
| print(f"Football-Data.org error: {e}") | |
| return [] | |
| def get_standings(self, league: str = 'premier_league') -> List[Dict]: | |
| """Get current standings""" | |
| if not self.api_key: | |
| return [] | |
| league_code = self.LEAGUES.get(league, league) | |
| cache_key = f"fdo_standings_{league_code}" | |
| cached = self.cache.get(cache_key, max_age_minutes=60) | |
| if cached: | |
| return cached | |
| url = f"{self.BASE_URL}/competitions/{league_code}/standings" | |
| try: | |
| response = self.session.get(url) | |
| if response.status_code == 200: | |
| data = response.json() | |
| self.cache.set(cache_key, data) | |
| return data.get('standings', []) | |
| except Exception as e: | |
| print(f"Football-Data.org standings error: {e}") | |
| return [] | |
| def get_team_matches(self, team_id: int, limit: int = 10) -> List[Dict]: | |
| """Get recent matches for a team""" | |
| if not self.api_key: | |
| return [] | |
| cache_key = f"fdo_team_matches_{team_id}_{limit}" | |
| cached = self.cache.get(cache_key, max_age_minutes=60) | |
| if cached: | |
| return cached | |
| url = f"{self.BASE_URL}/teams/{team_id}/matches" | |
| params = {'limit': limit, 'status': 'FINISHED'} | |
| try: | |
| response = self.session.get(url, params=params) | |
| if response.status_code == 200: | |
| data = response.json() | |
| self.cache.set(cache_key, data.get('matches', [])) | |
| return data.get('matches', []) | |
| except: | |
| pass | |
| return [] | |
| def get_head_to_head(self, match_id: int) -> Dict: | |
| """ | |
| Get head-to-head data for a specific match | |
| Returns: Dict with H2H statistics | |
| """ | |
| if not self.api_key: | |
| return {} | |
| cache_key = f"fdo_h2h_{match_id}" | |
| cached = self.cache.get(cache_key, max_age_minutes=1440) # 24hr cache | |
| if cached: | |
| return cached | |
| url = f"{self.BASE_URL}/matches/{match_id}/head2head" | |
| params = {'limit': 10} | |
| try: | |
| response = self.session.get(url, params=params) | |
| if response.status_code == 200: | |
| data = response.json() | |
| self.cache.set(cache_key, data) | |
| return data | |
| except Exception as e: | |
| print(f"H2H fetch error: {e}") | |
| return {} | |
| def get_team_by_name(self, team_name: str) -> Optional[Dict]: | |
| """ | |
| Search for team by name to get team ID | |
| """ | |
| if not self.api_key: | |
| return None | |
| cache_key = f"fdo_team_search_{team_name.lower()}" | |
| cached = self.cache.get(cache_key, max_age_minutes=1440) | |
| if cached: | |
| return cached | |
| # Try to find team in any league we support | |
| for league_code in self.LEAGUES.values(): | |
| url = f"{self.BASE_URL}/competitions/{league_code}/teams" | |
| try: | |
| response = self.session.get(url) | |
| if response.status_code == 200: | |
| data = response.json() | |
| for team in data.get('teams', []): | |
| if (team_name.lower() in team['name'].lower() or | |
| team_name.lower() in team.get('shortName', '').lower() or | |
| team_name.lower() == team.get('tla', '').lower()): | |
| self.cache.set(cache_key, team) | |
| return team | |
| except: | |
| continue | |
| return None | |
| def get_finished_matches(self, league: str = 'premier_league', limit: int = 50) -> List[Dict]: | |
| """Get finished matches for training data""" | |
| if not self.api_key: | |
| return [] | |
| league_code = self.LEAGUES.get(league, league) | |
| cache_key = f"fdo_finished_{league_code}_{limit}" | |
| cached = self.cache.get(cache_key, max_age_minutes=60) | |
| if cached: | |
| return cached | |
| url = f"{self.BASE_URL}/competitions/{league_code}/matches" | |
| params = {'status': 'FINISHED', 'limit': limit} | |
| try: | |
| response = self.session.get(url, params=params) | |
| if response.status_code == 200: | |
| data = response.json() | |
| matches = data.get('matches', []) | |
| self.cache.set(cache_key, matches) | |
| return matches | |
| except Exception as e: | |
| print(f"Error fetching finished matches: {e}") | |
| return [] | |
| def get_live_standings_parsed(self, league: str = 'premier_league') -> Dict[str, int]: | |
| """ | |
| Get live standings as team name -> position dict | |
| For use in predictions | |
| """ | |
| standings_raw = self.get_standings(league) | |
| positions = {} | |
| if standings_raw: | |
| for table in standings_raw: | |
| if table.get('type') == 'TOTAL': | |
| for entry in table.get('table', []): | |
| team_name = entry.get('team', {}).get('name', '') | |
| short_name = entry.get('team', {}).get('shortName', '') | |
| position = entry.get('position', 10) | |
| positions[team_name] = position | |
| if short_name: | |
| positions[short_name] = position | |
| return positions | |
| def _parse_matches(self, data: Dict, league: str) -> List[Match]: | |
| """Parse Football-Data.org response""" | |
| matches = [] | |
| for m in data.get('matches', []): | |
| try: | |
| kickoff = datetime.fromisoformat(m['utcDate'].replace('Z', '+00:00')) | |
| status_map = { | |
| 'SCHEDULED': 'scheduled', | |
| 'TIMED': 'scheduled', | |
| 'IN_PLAY': 'live', | |
| 'PAUSED': 'live', | |
| 'FINISHED': 'finished', | |
| } | |
| match = Match( | |
| id=f"fdo_{m['id']}", | |
| home_team=Team( | |
| id=str(m['homeTeam']['id']), | |
| name=m['homeTeam']['name'], | |
| short_name=m['homeTeam'].get('tla'), | |
| logo_url=m['homeTeam'].get('crest') | |
| ), | |
| away_team=Team( | |
| id=str(m['awayTeam']['id']), | |
| name=m['awayTeam']['name'], | |
| short_name=m['awayTeam'].get('tla'), | |
| logo_url=m['awayTeam'].get('crest') | |
| ), | |
| kickoff=kickoff, | |
| league=league, | |
| league_id=str(m.get('competition', {}).get('id', '')), | |
| season=str(m.get('season', {}).get('id', '')), | |
| status=status_map.get(m.get('status'), 'scheduled'), | |
| home_score=m.get('score', {}).get('fullTime', {}).get('home'), | |
| away_score=m.get('score', {}).get('fullTime', {}).get('away'), | |
| venue=m.get('venue') | |
| ) | |
| matches.append(match) | |
| except Exception as e: | |
| print(f"Error parsing FDO match: {e}") | |
| continue | |
| return matches | |
| class APIFootballClient: | |
| """ | |
| API-Football (RapidAPI) - Comprehensive football data | |
| https://www.api-football.com/ | |
| Free tier: 100 requests/day | |
| """ | |
| BASE_URL = "https://v3.football.api-sports.io" | |
| LEAGUES = { | |
| 'premier_league': 39, | |
| 'la_liga': 140, | |
| 'bundesliga': 78, | |
| 'serie_a': 135, | |
| 'ligue_1': 61, | |
| 'champions_league': 2, | |
| 'europa_league': 3, | |
| 'mls': 253, | |
| 'eredivisie': 88, | |
| } | |
| def __init__(self, api_key: Optional[str] = None): | |
| self.api_key = api_key or os.getenv('API_FOOTBALL_KEY') | |
| self.cache = CacheManager() | |
| self.session = requests.Session() | |
| if self.api_key: | |
| self.session.headers['x-apisports-key'] = self.api_key | |
| def get_upcoming_matches(self, league: str = 'premier_league', days: int = 7) -> List[Match]: | |
| """Get upcoming fixtures""" | |
| if not self.api_key: | |
| print("Warning: API-Football requires API key") | |
| return [] | |
| league_id = self.LEAGUES.get(league, league) | |
| cache_key = f"apifb_matches_{league_id}_{days}" | |
| cached = self.cache.get(cache_key, max_age_minutes=30) | |
| if cached: | |
| return self._parse_matches(cached, league) | |
| date_from = datetime.now().strftime('%Y-%m-%d') | |
| date_to = (datetime.now() + timedelta(days=days)).strftime('%Y-%m-%d') | |
| url = f"{self.BASE_URL}/fixtures" | |
| params = { | |
| 'league': league_id, | |
| 'season': datetime.now().year, | |
| 'from': date_from, | |
| 'to': date_to | |
| } | |
| try: | |
| response = self.session.get(url, params=params) | |
| if response.status_code == 200: | |
| data = response.json() | |
| self.cache.set(cache_key, data) | |
| return self._parse_matches(data, league) | |
| except Exception as e: | |
| print(f"API-Football error: {e}") | |
| return [] | |
| def _parse_matches(self, data: Dict, league: str) -> List[Match]: | |
| """Parse API-Football response""" | |
| matches = [] | |
| for m in data.get('response', []): | |
| try: | |
| fixture = m['fixture'] | |
| teams = m['teams'] | |
| goals = m['goals'] | |
| kickoff = datetime.fromtimestamp(fixture['timestamp']) | |
| status_map = { | |
| 'NS': 'scheduled', | |
| 'TBD': 'scheduled', | |
| '1H': 'live', | |
| '2H': 'live', | |
| 'HT': 'live', | |
| 'FT': 'finished', | |
| 'AET': 'finished', | |
| 'PEN': 'finished', | |
| } | |
| match = Match( | |
| id=f"apifb_{fixture['id']}", | |
| home_team=Team( | |
| id=str(teams['home']['id']), | |
| name=teams['home']['name'], | |
| logo_url=teams['home'].get('logo') | |
| ), | |
| away_team=Team( | |
| id=str(teams['away']['id']), | |
| name=teams['away']['name'], | |
| logo_url=teams['away'].get('logo') | |
| ), | |
| kickoff=kickoff, | |
| league=league, | |
| league_id=str(m.get('league', {}).get('id', '')), | |
| season=str(m.get('league', {}).get('season', '')), | |
| status=status_map.get(fixture.get('status', {}).get('short'), 'scheduled'), | |
| home_score=goals.get('home'), | |
| away_score=goals.get('away'), | |
| venue=fixture.get('venue', {}).get('name') | |
| ) | |
| matches.append(match) | |
| except Exception as e: | |
| print(f"Error parsing API-Football match: {e}") | |
| continue | |
| return matches | |
| class DataAggregator: | |
| """ | |
| Aggregates data from multiple sources with fallback | |
| """ | |
| def __init__(self): | |
| self.openliga = OpenLigaDBClient() | |
| self.fdo = FootballDataOrgClient() | |
| self.apifb = APIFootballClient() | |
| def get_upcoming_matches(self, leagues: List[str] = None, days: int = 7) -> List[Match]: | |
| """Get upcoming matches from all available sources""" | |
| if leagues is None: | |
| leagues = ['bundesliga', 'premier_league', 'la_liga', 'serie_a', 'ligue_1'] | |
| all_matches = [] | |
| seen_ids = set() | |
| for league in leagues: | |
| matches = [] | |
| # Try OpenLigaDB first (free, no key needed) | |
| if league in ['bundesliga', 'bundesliga2', '3liga', 'dfb_pokal', | |
| 'champions_league', 'europa_league', 'euro_2024', 'world_cup_2022']: | |
| matches = self.openliga.get_upcoming_matches(league, days) | |
| # Try Football-Data.org | |
| if not matches: | |
| matches = self.fdo.get_upcoming_matches(league, days) | |
| # Try API-Football as fallback | |
| if not matches: | |
| matches = self.apifb.get_upcoming_matches(league, days) | |
| # Deduplicate | |
| for m in matches: | |
| key = f"{m.home_team.name}_{m.away_team.name}_{m.kickoff.date()}" | |
| if key not in seen_ids: | |
| seen_ids.add(key) | |
| all_matches.append(m) | |
| # Sort by kickoff time | |
| all_matches.sort(key=lambda x: x.kickoff) | |
| return all_matches | |
| def get_all_standings(self) -> Dict[str, List[Dict]]: | |
| """Get standings for all available leagues""" | |
| standings = {} | |
| # OpenLigaDB | |
| for league in ['bundesliga']: | |
| data = self.openliga.get_standings(league) | |
| if data: | |
| standings[league] = data | |
| # Football-Data.org | |
| for league in ['premier_league', 'la_liga', 'serie_a', 'ligue_1']: | |
| data = self.fdo.get_standings(league) | |
| if data: | |
| standings[league] = data | |
| return standings | |