Files
2026FIFAWorldCup/platform/backend/app/ingestion/worker.py

169 lines
5.6 KiB
Python

from __future__ import annotations
import asyncio
import json
from collections import defaultdict
from dataclasses import dataclass
from typing import Any, Mapping
import aiohttp
from .cache import MatchCacheManager
TEAM_ALIAS_MAP = {
'USA': 'USMNT',
'United States': 'USMNT',
'United States of America': 'USMNT',
'USMNT': 'USMNT',
}
@dataclass(frozen=True)
class SourceOdds:
match_id: str
home_team: str
away_team: str
market_type: str
selection: str
decimal_odds: float
bookmaker: str
status: str = 'in-play'
def normalize_team_name(raw_name: str) -> str:
"""對齊來自不同博彩商的球隊名稱,返回標準化內部 ID。"""
normalized = raw_name.strip()
return TEAM_ALIAS_MAP.get(normalized, normalized)
class OddsIngestionWorker:
"""非同步抓取賽事賠率與推入 Redis 快取的 Worker。"""
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) -> Mapping[str, Any]:
delay = 0.5
attempts = 0
while True:
attempts += 1
try:
async with self.session.get(url, timeout=20) as resp:
if resp.status == 429:
if attempts >= max_attempts:
text = await resp.text()
raise RuntimeError(f'HTTP 429 Too Many Requests: {text}')
await asyncio.sleep(delay)
delay *= 2
continue
if resp.status >= 500:
if attempts >= 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) as exc:
if attempts >= max_attempts:
raise RuntimeError(f'HTTP request failed: {exc!s}') from exc
await asyncio.sleep(delay)
delay *= 2
async def fetch_latest_matches(self) -> list[SourceOdds]:
params = {'api_key': self.api_key}
url = f'{self.endpoint}/v1/odds'
payload = await self._request_with_backoff(url)
items = payload.get('data', []) if isinstance(payload, Mapping) else []
normalized: list[SourceOdds] = []
for row in items:
try:
raw_home = row['home_team']
raw_away = row['away_team']
normalized.append(
SourceOdds(
match_id=str(row['match_id']),
home_team=normalize_team_name(str(raw_home)),
away_team=normalize_team_name(str(raw_away)),
market_type=str(row['market_type']),
selection=str(row['selection']),
decimal_odds=float(row['odds']),
bookmaker=str(row.get('bookmaker', 'unknown')),
status=str(row.get('status', 'in-play')),
),
)
except (KeyError, TypeError, ValueError):
continue
return normalized
async def sync_to_cache(
self,
cache: MatchCacheManager,
*,
ttl_seconds: int = 45,
) -> dict[str, int]:
"""抓取賽事即時賠率並更新 Redis 快取。"""
rows = await self.fetch_latest_matches()
payload_by_match: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
payload_by_match[row.match_id].append(
{
'match_id': row.match_id,
'home_team': row.home_team,
'away_team': row.away_team,
'market_type': row.market_type,
'selection': row.selection,
'decimal_odds': row.decimal_odds,
'bookmaker': row.bookmaker,
'status': row.status,
},
)
for match_id, rows_payload in payload_by_match.items():
finished = any(item['status'] == 'finished' for item in rows_payload)
await cache.set_match_odds(match_id, rows_payload, ttl_seconds=ttl_seconds, finished=finished)
return {match_id: len(payload_rows) for match_id, payload_rows in payload_by_match.items()}
async def run_once(
self,
cache: MatchCacheManager,
*,
ttl_seconds: int = 45,
) -> dict[str, int]:
"""單次輪詢流程(可給排程器或事件輪詢器呼叫)。"""
return await self.sync_to_cache(cache, ttl_seconds=ttl_seconds)
def to_cache_payload(rows: list[SourceOdds]) -> list[dict[str, Any]]:
"""將來源資料轉為 Redis 快取可存取結構。"""
return [
{
'match_id': row.match_id,
'home_team': row.home_team,
'away_team': row.away_team,
'market_type': row.market_type,
'selection': row.selection,
'decimal_odds': row.decimal_odds,
'bookmaker': row.bookmaker,
'status': row.status,
}
for row in rows
]
def serialize_error(error: Exception) -> str:
"""錯誤訊息格式化,供上層日誌與警報系統使用。"""
return json.dumps({'error': str(error), 'type': error.__class__.__name__})