From 13a4997cc2ff46d4da4ca2a68d18e99e4b48604a Mon Sep 17 00:00:00 2001 From: jwkim Date: Fri, 14 Aug 2026 14:38:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(mls):=20ESPN=20403=20=E2=80=94=20=EB=B8=8C?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=9C=84=EC=9E=A5=20UA=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 컨테이너(httpx)에서 풀 Chrome UA 를 보내면 ESPN WAF 가 403 차단 (TLS 핑거프린트-UA 불일치 감지로 추정). httpx 기본 UA 는 통과 — 일정 동기화가 계속 실패해 로컬에서 MLS 일정이 안 뜨던 원인. Co-Authored-By: Claude Opus 5 --- backend/app/services/mls_espn.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) 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)