Spaces:
Running
Running
| from __future__ import annotations | |
| from datetime import datetime, timedelta, timezone | |
| import asyncio | |
| import logging | |
| from typing import Any | |
| from app.providers.http_client import ResilientHTTP, ProviderError | |
| logger = logging.getLogger(__name__) | |
| def _odds_api_timestamp(value: datetime) -> str: | |
| """Serialize UTC timestamps using the exact second-level format required by v4.""" | |
| return value.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") | |
| class OddsAPIProvider: | |
| BASE = "https://api.the-odds-api.com/v4" | |
| def __init__(self, api_key: str, http: ResilientHTTP, regions: str = "eu"): | |
| self.api_key = api_key | |
| self.http = http | |
| self.regions = regions | |
| self.region_count = max(1, len([part for part in regions.split(",") if part])) | |
| self.quota = {"remaining": None, "used": None, "last": None} | |
| self.inactive_keys: list[str] = [] | |
| self.queried_keys: list[str] = [] | |
| self.errors: list[str] = [] | |
| async def _active_sport_keys(self) -> set[str] | None: | |
| try: | |
| payload, _ = await self.http.get_json( | |
| f"{self.BASE}/sports/", | |
| params={"apiKey": self.api_key}, | |
| ) | |
| return { | |
| str(item.get("key")) | |
| for item in (payload or []) | |
| if item.get("active") is not False and item.get("key") | |
| } | |
| except Exception as exc: | |
| logger.warning("Não foi possível consultar lista gratuita de esportes ativos: %s", exc) | |
| return None | |
| async def fetch_events(self, sport_keys: tuple[str, ...], horizon_hours: int) -> list[dict[str, Any]]: | |
| if not self.api_key: | |
| raise RuntimeError("ODDS_API_KEY não configurado") | |
| active = await self._active_sport_keys() | |
| if active is not None: | |
| self.inactive_keys = [k for k in sport_keys if k not in active] | |
| sport_keys = tuple(k for k in sport_keys if k in active) | |
| now = datetime.now(timezone.utc) | |
| end = now + timedelta(hours=horizon_hours) | |
| out: list[dict[str, Any]] = [] | |
| successful_requests = 0 | |
| for key in sport_keys: | |
| remaining = self.quota.get("remaining") | |
| reserve = max(25, self.region_count * 3) | |
| if isinstance(remaining, int) and remaining <= reserve: | |
| logger.warning( | |
| "Quota Guardian: apenas %d créditos restantes (reserva %d); interrompendo novas ligas", | |
| remaining, | |
| reserve, | |
| ) | |
| break | |
| params = { | |
| "apiKey": self.api_key, | |
| "regions": self.regions, | |
| "markets": "h2h", | |
| "oddsFormat": "decimal", | |
| "dateFormat": "iso", | |
| "commenceTimeFrom": _odds_api_timestamp(now), | |
| "commenceTimeTo": _odds_api_timestamp(end), | |
| } | |
| self.queried_keys.append(key) | |
| try: | |
| payload, headers = await self.http.get_json( | |
| f"{self.BASE}/sports/{key}/odds/", | |
| params=params, | |
| allow_status={404}, | |
| ) | |
| except ProviderError as exc: | |
| if exc.status_code in {401, 403, 429}: | |
| raise | |
| logger.warning("Odds API falhou para %s: %s", key, exc) | |
| self.errors.append(f"{key}: {exc}") | |
| continue | |
| self._read_quota(headers) | |
| if payload is None: | |
| if key not in self.inactive_keys: | |
| self.inactive_keys.append(key) | |
| successful_requests += 1 | |
| continue | |
| if not isinstance(payload, list): | |
| self.errors.append(f"{key}: resposta de odds fora do formato esperado") | |
| continue | |
| successful_requests += 1 | |
| if payload: | |
| for event in payload: | |
| if not isinstance(event, dict): | |
| continue | |
| event["_sport_key"] = key | |
| out.append(event) | |
| await asyncio.sleep(0.10) | |
| if self.queried_keys and successful_requests == 0 and self.errors: | |
| raise ProviderError( | |
| "Todas as consultas de odds falharam; o estado anterior foi preservado" | |
| ) | |
| unique: dict[str, dict[str, Any]] = {} | |
| for event in out: | |
| event_id = str(event.get("id") or "") | |
| if event_id: | |
| unique[event_id] = event | |
| logger.info( | |
| "The Odds API: %d eventos carregados (%d ligas inativas ignoradas)", | |
| len(unique), | |
| len(self.inactive_keys), | |
| ) | |
| return list(unique.values()) | |
| def _read_quota(self, headers) -> None: | |
| def _number(name: str): | |
| raw = headers.get(name) | |
| try: | |
| return int(raw) if raw is not None else None | |
| except (TypeError, ValueError): | |
| return raw | |
| remaining = _number("x-requests-remaining") | |
| used = _number("x-requests-used") | |
| last = _number("x-requests-last") | |
| if remaining is not None: | |
| self.quota["remaining"] = remaining | |
| if used is not None: | |
| self.quota["used"] = used | |
| if last is not None: | |
| self.quota["last"] = last | |