Files
2026FIFAWorldCup/platform/backend/workers/odds_ingestion.py

115 lines
3.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""非同步賠率抓取 Worker。"""
from __future__ import annotations
import asyncio
from typing import Any, Mapping
import aiohttp
from ..app.services.redis_manager import MatchCacheManager
TEAM_ALIAS = {
'usa': 'USMNT',
'united states': 'USMNT',
'usmnt': 'USMNT',
}
def normalize_team_name(raw_name: str) -> str:
"""將各莊家球隊名稱對齊到平台內部代號。"""
normalized = raw_name.strip().lower()
return TEAM_ALIAS.get(normalized, raw_name.strip())
class OddsIngestionWorker:
"""非同步抓取外部賠率並推入 Redis 快取。"""
def __init__(
self,
session: aiohttp.ClientSession,
endpoint: str,
api_key: str,
) -> None:
self.session = session
self.endpoint = endpoint
self.api_key = api_key
async def _request_with_backoff(
self,
url: str,
*,
max_attempts: int = 5,
base_delay: float = 0.4,
) -> Mapping[str, Any]:
delay = base_delay
attempt = 0
while attempt < max_attempts:
attempt += 1
try:
async with self.session.get(url, timeout=15) as resp:
if resp.status == 429:
if attempt >= max_attempts:
text = await resp.text()
raise RuntimeError(f'HTTP 429: {text}')
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status >= 500:
if attempt >= max_attempts:
resp.raise_for_status()
await asyncio.sleep(delay)
delay *= 2
continue
resp.raise_for_status()
return await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as exc:
if attempt >= max_attempts:
raise RuntimeError(f'fetch failed: {exc!r}') from exc
await asyncio.sleep(delay)
delay *= 2
raise RuntimeError('unreachable')
async def fetch_live_odds(self) -> list[dict[str, Any]]:
"""抓取原始賠率清單。"""
url = f'{self.endpoint.rstrip("/")}/v4/sports/soccer/odds?apiKey={self.api_key}'
payload = await self._request_with_backoff(url)
items = payload.get('data', []) if isinstance(payload, Mapping) else []
normalized: list[dict[str, Any]] = []
for row in items if isinstance(items, list) else []:
try:
normalized.append(
{
'match_id': str(row['id']),
'home_team': normalize_team_name(str(row['home_team'])),
'away_team': normalize_team_name(str(row['away_team'])),
'market_type': str(row['market_type']),
'selection': str(row['selection']),
'decimal_odds': float(row['odds']),
'bookmaker': str(row.get('bookmaker', '')),
},
)
except (KeyError, TypeError, ValueError):
continue
return normalized
async def run_once(self, cache: MatchCacheManager) -> int:
"""單輪更新:抓取並寫入 Redis回傳寫入比賽筆數。"""
rows = await self.fetch_live_odds()
match_map: dict[str, list[dict[str, Any]]] = {}
for row in rows:
match_map.setdefault(row['match_id'], []).append(row)
for match_id, payload in match_map.items():
await cache.set_match_odds(match_id, payload, finished=False)
return len(match_map)