115 lines
3.7 KiB
Python
115 lines
3.7 KiB
Python
"""非同步賠率抓取 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)
|