diff --git a/backend/app/services/mls_espn.py b/backend/app/services/mls_espn.py index 2647ea3..8164880 100644 --- a/backend/app/services/mls_espn.py +++ b/backend/app/services/mls_espn.py @@ -29,12 +29,8 @@ from ..teams_mls import MLS_ID_TO_CODE, MLS_TEAMS log = logging.getLogger("triplepick.mls") KST = timezone(timedelta(hours=9)) -UA = { - "User-Agent": ( - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " - "(KHTML, like Gecko) Chrome/126.0 Safari/537.36" - ) -} +# 주의: ESPN 은 브라우저 위장 UA(풀 Chrome 문자열)를 보내면 WAF 가 403 으로 차단한다 +# (TLS 핑거프린트와 UA 불일치 감지로 추정). httpx 기본 UA 로 보내야 통과 — 헤더 없이 호출. # 취소로 취급하는 ESPN 상태 (연기 포함 — 보강 일정이 새 이벤트로 재등장) _CANCEL_STATUS = {"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_ABANDONED"} @@ -94,7 +90,7 @@ def _parse_event(e: dict) -> dict | None: async def _fetch_events(client, dates: str) -> list[dict]: - r = await client.get(_sb_url(dates), headers=UA) + r = await client.get(_sb_url(dates)) r.raise_for_status() out = [] for e in r.json().get("events") or []: @@ -148,7 +144,7 @@ async def _refresh_standings(db) -> bool: table: dict[str, dict] = {} try: async with httpx.AsyncClient(timeout=15) as c: - r = await c.get(_standings_url(), headers=UA) + r = await c.get(_standings_url()) r.raise_for_status() data = r.json() for conf in data.get("children") or []: @@ -263,7 +259,7 @@ async def _refresh_previews(db, matches: list[Match]) -> int: "odds": _odds_of(rec["_comp"]), } try: # 맞대결(h2h)은 summary 1콜 — 실패해도 나머지 프리뷰는 유지 - r2 = await c.get(_summary_url(rec["espnId"]), headers=UA) + r2 = await c.get(_summary_url(rec["espnId"])) r2.raise_for_status() payload["h2h"] = _h2h_of(r2.json()) except Exception as e: # noqa: BLE001 @@ -598,7 +594,7 @@ async def fetch_live_mls(m: Match) -> dict: _rn = _make_name_resolver(None) # summary 실패 시 원문 그대로 try: async with httpx.AsyncClient(timeout=15) as c2: - r2 = await c2.get(_summary_url(rec["espnId"]), headers=UA) + r2 = await c2.get(_summary_url(rec["espnId"])) r2.raise_for_status() sj = r2.json() lineups = _lineups_of(sj)