169 lines
5.6 KiB
Python
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__})
|