fix(mls): ESPN 403 — 브라우저 위장 UA 제거

컨테이너(httpx)에서 풀 Chrome UA 를 보내면 ESPN WAF 가 403 차단
(TLS 핑거프린트-UA 불일치 감지로 추정). httpx 기본 UA 는 통과 —
일정 동기화가 계속 실패해 로컬에서 MLS 일정이 안 뜨던 원인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-14 14:38:48 +09:00
parent 0415ab7636
commit 13a4997cc2

View File

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