footypredict-pro / src /data /api_clients.py
NetBoss
feat: Complete 100% real data integration (6 phases)
c230fe3
"""
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
@dataclass
class Team:
"""Standardized team representation"""
id: str
name: str
short_name: Optional[str] = None
logo_url: Optional[str] = None
@dataclass
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