o2o-triple-pick/backend/app/services/mls_espn.py
jwkim eecc9829b4 feat(mls): 과거 30일 백필 — 주말 위주 편성으로 일정판에 생기던 공백 해소
야구식 recheck 윈도우(3일)만으론 MLS 직전 라운드(1~2주 전)가 수집되지 않아
일정판이 비고 자체 DB 폼 데이터도 부족했음. 범위를 넓혀도 scoreboard 1콜.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:56:44 +09:00

701 lines
29 KiB
Python

"""MLS 데이터 — ESPN 비공식 API (일정·결과·순위·프리뷰·라이브).
ESPN site API 는 키 불필요·비공식. 응답 표준 레코드는 야구와 동일 형태
({league, teamA(원정), teamB(홈), dateKst, kickoffKst, venue, cancelled,
[scoreA, scoreB]})라 sync_baseball_schedule/settle 파이프라인을 그대로 탄다.
- 일정: scoreboard?dates=YYYYMMDD-YYYYMMDD (dates 는 미국 동부 기준 날짜라
KST 매칭은 이벤트의 UTC 시각을 KST 로 환산해 산출)
- 순위: 동부/서부 컨퍼런스 → standings:mls 캐시 (승점제)
- 프리뷰: scoreboard 의 form(최근5)·records(시즌 W-D-L)·odds(머니라인)
+ summary 의 headToHeadGames(맞대결 최근 5) → preview:{match_id} 캐시.
odds 는 AI 프롬프트 참고용으로만 쓰고 UI 에는 노출하지 않는다.
- 라이브: scoreboard 이벤트의 clock·스코어·득점 이벤트(details) — 15초 TTL
"""
from __future__ import annotations
import logging
import re
import time
import unicodedata
from datetime import datetime, timedelta, timezone
from difflib import SequenceMatcher
from ..config import settings
from ..domain import ensure_aware, now_utc
from ..models import DataCache, Match
from ..teams_mls import MLS_ID_TO_CODE, MLS_TEAMS
log = logging.getLogger("triplepick.mls")
KST = timezone(timedelta(hours=9))
# 주의: ESPN 은 브라우저 위장 UA(풀 Chrome 문자열)를 보내면 WAF 가 403 으로 차단한다
# (TLS 핑거프린트와 UA 불일치 감지로 추정). httpx 기본 UA 로 보내야 통과 — 헤더 없이 호출.
# 취소로 취급하는 ESPN 상태 (연기 포함 — 보강 일정이 새 이벤트로 재등장)
_CANCEL_STATUS = {"STATUS_POSTPONED", "STATUS_CANCELED", "STATUS_ABANDONED"}
def _sb_url(dates: str) -> str:
return (
f"{settings.espn_api_base}/site/v2/{settings.espn_mls_path}"
f"/scoreboard?dates={dates}&limit=200"
)
def _summary_url(event_id: str) -> str:
return (
f"{settings.espn_api_base}/site/v2/{settings.espn_mls_path}"
f"/summary?event={event_id}"
)
def _standings_url() -> str:
return f"{settings.espn_api_base}/v2/{settings.espn_mls_path}/standings"
def _parse_event(e: dict) -> dict | None:
"""scoreboard 이벤트 1건 → 표준 레코드 (+espnId·form·records·odds 원본)."""
comp = (e.get("competitions") or [{}])[0]
sides: dict[str, dict] = {}
for t in comp.get("competitors") or []:
sides[t.get("homeAway", "")] = t
away, home = sides.get("away"), sides.get("home")
if not away or not home:
return None
a = (away.get("team") or {}).get("abbreviation", "")
b = (home.get("team") or {}).get("abbreviation", "")
if a not in MLS_TEAMS or b not in MLS_TEAMS:
return None # 올스타전 등 제외
gd = e.get("date")
if not gd:
return None
kickoff = datetime.fromisoformat(gd.replace("Z", "+00:00")).astimezone(KST)
stype = (e.get("status") or {}).get("type") or {}
rec: dict = {
"league": "mls",
"teamA": a, "teamB": b,
"dateKst": kickoff.strftime("%Y%m%d"),
"kickoffKst": kickoff.isoformat(),
"venue": ((comp.get("venue") or {}).get("fullName")) or "",
"cancelled": stype.get("name") in _CANCEL_STATUS,
"espnId": e.get("id"),
"_away": away, "_home": home, "_comp": comp, "_status": e.get("status"),
}
if stype.get("state") == "post" and stype.get("completed") and not rec["cancelled"]:
sa, sb = away.get("score"), home.get("score")
if sa is not None and sb is not None:
rec["scoreA"], rec["scoreB"] = int(sa), int(sb)
return rec
async def _fetch_events(client, dates: str) -> list[dict]:
r = await client.get(_sb_url(dates))
r.raise_for_status()
out = []
for e in r.json().get("events") or []:
rec = _parse_event(e)
if rec:
out.append(rec)
return out
def _clean(rec: dict) -> dict:
"""sync/정산에 넘길 때 원본 참조 필드 제거."""
return {k: v for k, v in rec.items() if not k.startswith("_")}
async def fetch_mls_schedule() -> list[dict]:
"""일정+결과 수집 (취소 포함). 실패 시 빈 리스트 — 기존 일정 유지."""
import httpx
today = datetime.now(KST).date()
# 시작 = 백필 윈도우(주말 위주 편성 공백 방지). ESPN dates 는 미국 동부 날짜라
# KST 범위를 놓치지 않게 하루 여유를 둔다.
start = today - timedelta(days=settings.mls_days_back + 1)
end = today + timedelta(days=settings.baseball_days_ahead)
try:
async with httpx.AsyncClient(timeout=20) as c:
records = await _fetch_events(
c, f"{start.strftime('%Y%m%d')}-{end.strftime('%Y%m%d')}"
)
records = [_clean(r) for r in records]
from .baseball_sync import assign_seq
assign_seq(records) # MLS 는 더블헤더가 없어 전부 seq=1 이지만 키 일관성 유지
log.info("mls schedule: %d경기 수집", len(records))
return records
except Exception as e: # noqa: BLE001
log.error("mls schedule 실패: %s — 기존 일정 유지", e)
return []
async def fetch_mls_results() -> list[dict]:
"""확정 스코어만 → 정산(settle) 입력."""
return [r for r in await fetch_mls_schedule() if "scoreA" in r]
# ── 순위 (동/서부 컨퍼런스, 승점제) ─────────────────────────────
_CONF_KEY = {"Eastern Conference": "EAST", "Western Conference": "WEST"}
async def _refresh_standings(db) -> bool:
import httpx
table: dict[str, dict] = {}
try:
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(_standings_url())
r.raise_for_status()
data = r.json()
for conf in data.get("children") or []:
key = _CONF_KEY.get(conf.get("name", ""), conf.get("abbreviation", ""))
for ent in ((conf.get("standings") or {}).get("entries")) or []:
code = (ent.get("team") or {}).get("abbreviation", "")
if code not in MLS_TEAMS:
continue
stats = {s.get("name"): s for s in ent.get("stats") or []}
def _v(name: str): # noqa: ANN202
s = stats.get(name) or {}
return s.get("value") if s.get("value") is not None else s.get("displayValue")
table[code] = {
"div": key,
"rank": int(_v("rank") or 0) or None,
"gp": int(_v("gamesPlayed") or 0),
"w": int(_v("wins") or 0),
"d": int(_v("ties") or 0),
"l": int(_v("losses") or 0),
"pts": int(_v("points") or 0),
"gf": int(_v("pointsFor") or 0),
"ga": int(_v("pointsAgainst") or 0),
"diff": (stats.get("pointDifferential") or {}).get("displayValue"),
}
except Exception as e: # noqa: BLE001
log.warning("mls standings 실패: %s", e)
return False
if not table:
return False
row = await db.get(DataCache, "standings:mls")
if row:
row.payload = table
row.fetched_at = now_utc()
else:
db.add(DataCache(key="standings:mls", payload=table, fetched_at=now_utc()))
return True
# ── 프리뷰 (폼·시즌 성적·맞대결·머니라인) ──────────────────────
def _odds_of(comp: dict) -> dict | None:
"""scoreboard odds → {home, draw, away, provider} (없으면 None)."""
o = (comp.get("odds") or [{}])[0] or {} # odds: [null] 인 경기도 있음
ml = o.get("moneyline") or {}
def _pick(side: dict | None) -> str | None:
if not side:
return None
for k in ("current", "close", "open"):
v = (side.get(k) or {}).get("odds")
if v and v != "OFF":
return str(v)
return None
home, away = _pick(ml.get("home")), _pick(ml.get("away"))
draw = _pick(ml.get("draw")) or (
str((o.get("drawOdds") or {}).get("moneyLine") or "") or None
)
if not (home or away or draw):
return None
return {
"home": home, "draw": draw, "away": away,
"provider": ((o.get("provider") or {}).get("displayName")) or "",
}
def _h2h_of(summary: dict) -> list[dict]:
"""summary.headToHeadGames → 최근 맞대결 [{date, home, away, scoreH, scoreA}]."""
out: list[dict] = []
for grp in summary.get("headToHeadGames") or []:
for ev in grp.get("events") or []:
h = MLS_ID_TO_CODE.get(int(ev.get("homeTeamId") or 0))
a = MLS_ID_TO_CODE.get(int(ev.get("awayTeamId") or 0))
sh, sa = ev.get("homeTeamScore"), ev.get("awayTeamScore")
if not h or not a or sh is None or sa is None:
continue
out.append({
"date": (ev.get("gameDate") or "")[:10],
"home": h, "away": a,
"scoreH": int(sh), "scoreA": int(sa),
})
break # 첫 그룹(상대팀 기준)만
return out[:5]
async def _refresh_previews(db, matches: list[Match]) -> int:
import httpx
if not matches:
return 0
dates = sorted({ensure_aware(m.kickoff_at).astimezone(KST).date() for m in matches})
span = f"{(dates[0] - timedelta(days=1)).strftime('%Y%m%d')}-{dates[-1].strftime('%Y%m%d')}"
n = 0
async with httpx.AsyncClient(timeout=20) as c:
events = await _fetch_events(c, span)
by_key = {(r["dateKst"], r["teamA"], r["teamB"]): r for r in events}
for m in matches:
d = ensure_aware(m.kickoff_at).astimezone(KST).strftime("%Y%m%d")
rec = by_key.get((d, m.team_a_code, m.team_b_code))
if not rec:
continue
payload: dict = {
"formA": (rec["_away"].get("form")) or None, # 최근5 "WWLDW"
"formB": (rec["_home"].get("form")) or None,
"recordA": next( # 시즌 전적 "8-2-4" (W-L-D)
(r.get("summary") for r in rec["_away"].get("records") or []
if r.get("type") == "total"), None),
"recordB": next(
(r.get("summary") for r in rec["_home"].get("records") or []
if r.get("type") == "total"), None),
"odds": _odds_of(rec["_comp"]),
}
try: # 맞대결(h2h)은 summary 1콜 — 실패해도 나머지 프리뷰는 유지
r2 = await c.get(_summary_url(rec["espnId"]))
r2.raise_for_status()
payload["h2h"] = _h2h_of(r2.json())
except Exception as e: # noqa: BLE001
log.warning("mls summary 실패 %s: %s", m.match_id, e)
if any(v for v in payload.values()):
row = await db.get(DataCache, f"preview:{m.match_id}")
if row:
row.payload = payload
row.fetched_at = now_utc()
else:
db.add(DataCache(
key=f"preview:{m.match_id}", payload=payload,
fetched_at=now_utc(),
))
n += 1
return n
async def refresh_mls_details(db, matches: list[Match]) -> None:
"""임박(48h 내) 미종료 경기 프리뷰 + 컨퍼런스 순위 캐시 갱신."""
horizon = now_utc() + timedelta(hours=48)
targets = [
m for m in matches
if m.result_outcome is None
and m.status != "cancelled"
and ensure_aware(m.kickoff_at) <= horizon
]
n = await _refresh_previews(db, targets)
await _refresh_standings(db)
await db.commit()
log.info("mls details: 프리뷰 %d경기 캐싱", n)
# ── AI 예측 프롬프트 데이터 블록 ────────────────────────────────
async def build_mls_data_block(db, match: Match) -> str | None:
"""자체 DB 축적 결과(폼·상대전적) + ESPN 프리뷰(폼·시즌·맞대결·배당) 결합."""
from .baseball_data import _h2h_line, _team_games, _team_lines
ga = await _team_games(db, "mls", match.team_a_code, match.kickoff_at)
gb = await _team_games(db, "mls", match.team_b_code, match.kickoff_at)
lines: list[str] = []
prev = await db.get(DataCache, f"preview:{match.match_id}")
p = prev.payload if prev else {}
if p.get("formA") or p.get("recordA"):
lines.append(
f"[{match.team_a_short} season] record {p.get('recordA') or '?'} (W-L-D), "
f"last5 {p.get('formA') or '?'}"
)
if p.get("formB") or p.get("recordB"):
lines.append(
f"[{match.team_b_short} season] record {p.get('recordB') or '?'} (W-L-D), "
f"last5 {p.get('formB') or '?'}"
)
if p.get("h2h"):
h2h = "; ".join(
f"{g['away']} {g['scoreA']}-{g['scoreH']} {g['home']} (away-home, {g['date']})"
for g in p["h2h"]
)
lines.append(f"[Recent head-to-head] {h2h}")
if p.get("odds"):
o = p["odds"]
lines.append(
f"[Bookmaker moneyline ({o.get('provider')})] "
f"home {o.get('home') or '?'} / draw {o.get('draw') or '?'} / "
f"away {o.get('away') or '?'} (American odds; use as market signal)"
)
st_row = await db.get(DataCache, "standings:mls")
if st_row:
for code, name in (
(match.team_a_code, match.team_a_short),
(match.team_b_code, match.team_b_short),
):
st = st_row.payload.get(code)
if st:
lines.append(
f"[{name} standings] {st.get('div')} rank {st.get('rank')}, "
f"{st.get('w')}W-{st.get('d')}D-{st.get('l')}L, "
f"{st.get('pts')}pts, GF {st.get('gf')} GA {st.get('ga')}"
)
if not ga and not gb and not lines:
return None
h2h_db = await _h2h_line(db, "mls", match.team_a_code, match.team_b_code, match.kickoff_at)
return "\n".join([
"=== MATCH DATA (factual; weigh heavily over priors) ===",
"Away " + _team_lines(match.team_a_name, ga),
"Home " + _team_lines(match.team_b_name, gb),
f"[Head-to-head in our data] {h2h_db}",
*lines,
"===",
])
# ── 문자중계 한글 번역 (규칙 기반) ─────────────────────────────
# ESPN 중계 문장은 정형 템플릿이라 정규식으로 한국어 요약 변환. 매칭 실패 시 원문.
# 선수명은 영문 유지, 팀 풀네임은 한글 축약으로 치환.
_TEAM_EN2KO: dict[str, str] = {
**{v["en"]: v["short"] for v in MLS_TEAMS.values()},
# ESPN 표기가 우리 en 값과 다른 팀 별칭
"Red Bull New York": "레드불스",
"Vancouver Whitecaps": "밴쿠버",
"St. Louis City SC": "세인트루이스",
}
_ATTEMPT_LABEL = {"missed": "슛 빗나감", "blocked": "슛 차단", "saved": "슛 (GK 선방)"}
# ESPN 팀 표기(중계 원문) → 팀 코드 — 교체 이벤트 팀 매칭용
_TEAM_EN2CODE: dict[str, str] = {
**{v["en"]: k for k, v in MLS_TEAMS.items()},
"Red Bull New York": "RBNY",
"Vancouver Whitecaps": "VAN",
"St. Louis City SC": "STL",
}
def _ko_teams(s: str) -> str:
for en, ko in _TEAM_EN2KO.items():
s = s.replace(en, ko)
return s
def _tr_commentary(text: str) -> str: # noqa: PLR0911
t = text.strip()
if t in ("First Half begins.", "First Half Kicks Off."):
return "전반전 시작"
if t.startswith("Second Half begins"):
return "후반전 시작"
m = re.match(r"^First Half ends, (.+)\.$", t)
if m:
return f"전반전 종료 — {_ko_teams(m.group(1))}"
m = re.match(r"^Match ends, (.+)\.$", t)
if m:
return f"경기 종료 — {_ko_teams(m.group(1))}"
m = re.match(r"^Second Half ends, (.+)\.$", t)
if m:
return f"후반전 종료 — {_ko_teams(m.group(1))}"
m = re.match(r"^Goal!\s+(.+?) (\d+), (.+?) (\d+)\.\s*(.+?) \((.+?)\)(.*)$", t)
if m:
t1, s1, t2, s2, player, team, rest = m.groups()
tag = " (PK)" if "penalty" in rest else " (헤딩)" if "header" in rest else ""
return (
f"골! {_ko_teams(t1)} {s1} : {s2} {_ko_teams(t2)} — "
f"{player} ({_ko_teams(team)}){tag}"
)
m = re.match(r"^Own Goal by (.+?), (.+?) (\d+), (.+?) (\d+)\.", t)
if m:
og, t1, s1, t2, s2 = m.groups()
return f"자책골 — {_ko_teams(og)} · {_ko_teams(t1)} {s1} : {s2} {_ko_teams(t2)}"
m = re.match(r"^Attempt (missed|blocked|saved)\.\s*(.+?) \((.+?)\)(.*)$", t)
if m:
kind, player, team, rest = m.groups()
head = "헤딩 " if "header" in rest else ""
am = re.search(r"Assisted by ([^.]+?)(?: with| following|\.|$)", rest)
assist = f" · 도움 {am.group(1).strip()}" if am else ""
return f"{head}{_ATTEMPT_LABEL[kind]} — {player} ({_ko_teams(team)}){assist}"
m = re.match(r"^(.+?) \((.+?)\) hits the (left post|right post|bar|crossbar)(.*)$", t)
if m:
return f"골대! — {m.group(1)} ({_ko_teams(m.group(2))})"
m = re.match(r"^Foul by (.+?) \((.+?)\)\.$", t)
if m:
return f"파울 — {m.group(1)} ({_ko_teams(m.group(2))})"
m = re.match(r"^(.+?) \((.+?)\) wins a free kick (.+)\.$", t)
if m:
return f"프리킥 획득 — {m.group(1)} ({_ko_teams(m.group(2))})"
m = re.match(r"^(.+?) \((.+?)\) is shown the (yellow|red) card(.*)\.$", t)
if m:
card = "옐로카드" if m.group(3) == "yellow" else "레드카드"
return f"{card} — {m.group(1)} ({_ko_teams(m.group(2))})"
m = re.match(
r"^Substitution, (.+?)\.\s*(.+?) replaces (.+?)( because of [^.]*)?\.$", t
)
if m:
inj = " (부상)" if m.group(4) else ""
return f"교체 ({_ko_teams(m.group(1))}) — IN {m.group(2)} · OUT {m.group(3)}{inj}"
m = re.match(r"^Corner,\s+(.+?)\. Conceded by (.+?)\.$", t)
if m:
return f"코너킥 — {_ko_teams(m.group(1))}"
m = re.match(r"^Offside, (.+?)\.(.*)$", t)
if m:
return f"오프사이드 — {_ko_teams(m.group(1))}"
m = re.match(r"^Hand ball by (.+?) \((.+?)\)\.$", t)
if m:
return f"핸드볼 — {m.group(1)} ({_ko_teams(m.group(2))})"
m = re.match(r"^Penalty conceded by (.+?) \((.+?)\)(.*)$", t)
if m:
return f"페널티 유발 — {m.group(1)} ({_ko_teams(m.group(2))})"
m = re.match(r"^Penalty (.+?)\. (.+?) draws a foul(.*)$", t)
if m:
return f"페널티 획득 — {_ko_teams(m.group(1))} ({m.group(2)})"
if t.startswith("Delay in match"):
return "경기 지연"
if t.startswith("Delay over"):
return "경기 재개"
m = re.match(r"^Fourth official has announced (\d+) minutes? of added time\.$", t)
if m:
return f"추가시간 {m.group(1)}분"
if t.startswith("Lineups are announced"):
return "라인업 발표 · 선수 몸풀기"
return _ko_teams(t) # 미지원 템플릿 — 팀명만 한글화한 원문
# ── 라이브 (스코어·경기시간·득점 이벤트·라인업) ────────────────
_LIVE_TTL_SEC = 15.0
_live_cache: dict[str, tuple[float, dict]] = {}
def _player_out(p: dict) -> dict:
ath = p.get("athlete") or {}
return {
"name": ath.get("displayName", ""),
"pos": ((p.get("position") or {}).get("abbreviation")) or "",
"jersey": p.get("jersey", ""),
"in": bool(p.get("subbedIn")),
"out": bool(p.get("subbedOut")),
}
def _lineups_of(summary: dict) -> dict | None:
"""summary.rosters → {away, home}: {formation, starters[], bench[]}.
starters 는 formationPlace 순(1=GK). subbedIn/Out 플래그로 실시간 교체 표시.
라인업 미발표(경기 한참 전)면 rosters 가 비어 None.
"""
out: dict = {}
for r in summary.get("rosters") or []:
side = r.get("homeAway")
players = r.get("roster") or []
if side not in ("home", "away") or not players:
continue
starters = sorted(
(p for p in players if p.get("starter")),
key=lambda p: int(p.get("formationPlace") or 99),
)
bench = [p for p in players if not p.get("starter")]
out[side] = {
"formation": r.get("formation"),
"starters": [_player_out(p) for p in starters],
"bench": [_player_out(p) for p in bench],
}
return out or None
def _norm_name(s: str) -> str:
"""악센트 제거·소문자 — 선수명 비교용."""
s = unicodedata.normalize("NFKD", s)
return "".join(ch for ch in s if not unicodedata.combining(ch)).lower().strip()
def _make_name_resolver(lineups: dict | None):
"""중계 원문의 선수명 → 로스터 표기명 스냅.
ESPN 중계와 로스터가 같은 선수를 다르게 적는 경우가 흔하다
(미들네임 생략 "Sékou Bangoura"↔"Sékou Tidiany Bangoura",
철자 차이 "Akhundzada"↔"Akhundzade", 애칭 "Máximo"↔"Maxi Carrizo").
로스터명과 못 맞추면 교체 대체·골/어시 마커가 피치에 못 붙으므로
정규화 일치 → 성(姓) 유일 일치 → 유사도(≥0.75) 순으로 맞춘다.
"""
roster: list[str] = []
for side in (lineups or {}).values():
for p in (side.get("starters") or []) + (side.get("bench") or []):
if p.get("name"):
roster.append(p["name"])
by_norm = {_norm_name(n): n for n in roster}
def resolve(name: str) -> str:
if not name or name in roster:
return name
n = _norm_name(name)
if n in by_norm:
return by_norm[n]
last = n.rsplit(" ", 1)[-1]
cands = [r for r in roster if _norm_name(r).rsplit(" ", 1)[-1] == last]
if len(cands) == 1:
return cands[0]
# 토큰 순서가 뒤바뀐 표기 ("Djé D'Avilla" ↔ "D'Avilla Dje Tah") — 집합 포함 관계로 매칭
toks = set(n.split())
cands = [
r for r in roster
if toks <= set(_norm_name(r).split()) or set(_norm_name(r).split()) <= toks
]
if len(cands) == 1:
return cands[0]
best, score = name, 0.0
for r in roster:
s = SequenceMatcher(None, n, _norm_name(r)).ratio()
if s > score:
best, score = r, s
return best if score >= 0.75 else name
return resolve
async def fetch_live_mls(m: Match) -> dict:
"""라이브 페이로드 {available, soccer, clock, period, score, goals[]}."""
import httpx
cached = _live_cache.get(m.match_id)
if cached and time.monotonic() - cached[0] < _LIVE_TTL_SEC:
return cached[1]
payload: dict = {"available": False}
try:
kst = ensure_aware(m.kickoff_at).astimezone(KST)
date_kst = kst.strftime("%Y%m%d")
span = (
f"{(kst.date() - timedelta(days=1)).strftime('%Y%m%d')}"
f"-{kst.date().strftime('%Y%m%d')}"
)
async with httpx.AsyncClient(timeout=15) as c:
events = await _fetch_events(c, span)
rec = next(
(r for r in events
if r["dateKst"] == date_kst
and r["teamA"] == m.team_a_code and r["teamB"] == m.team_b_code),
None,
)
if rec:
status = rec["_status"] or {}
stype = status.get("type") or {}
# 라인업+문자중계 — summary 1콜 (경기 전 선발 발표~경기 중 교체·중계 반영)
lineups = None
subs: list[dict] = []
assist_q: dict[str, list[str]] = {}
commentary: list[dict] = []
_rn = _make_name_resolver(None) # summary 실패 시 원문 그대로
try:
async with httpx.AsyncClient(timeout=15) as c2:
r2 = await c2.get(_summary_url(rec["espnId"]))
r2.raise_for_status()
sj = r2.json()
lineups = _lineups_of(sj)
_rn = _make_name_resolver(lineups)
# 문자중계 — 규칙 기반 한글 번역, 경기 시작부터 전체(최신순).
# 교체·어시스트는 details 에 없어 중계 원문에서 함께 추출한다.
for e in sj.get("commentary") or []:
raw = e.get("text", "")
clk = (e.get("time") or {}).get("displayValue", "")
ptype = (((e.get("play") or {}).get("type")) or {}).get("type", "")
sm = re.match(
r"^Substitution, (.+?)\.\s*(.+?) replaces (.+?)(?: because of [^.]*)?\.$",
raw,
)
if sm:
subs.append({
"clock": clk,
"team": _TEAM_EN2CODE.get(sm.group(1), ""),
"inName": _rn(sm.group(2).strip()),
"outName": _rn(sm.group(3).strip()),
})
if raw.startswith("Goal!"):
gm = re.match(r"^Goal!\s+.+?\d+, .+?\d+\.\s*(.+?) \(", raw)
am = re.search(r"Assisted by ([^.]+?)(?: with| following|\.|$)", raw)
if gm and am:
assist_q.setdefault(_rn(gm.group(1).strip()), []).append(
_rn(am.group(1).strip())
)
commentary.append({
"clock": clk,
"text": _tr_commentary(raw),
"goal": (
ptype == "goal"
or raw.startswith("Goal!")
or raw.startswith("Own Goal")
),
})
commentary.reverse()
except Exception as e: # noqa: BLE001 — 라인업 실패해도 스코어는 서빙
log.warning("mls lineups 실패 %s: %s", m.match_id, e)
if stype.get("state") in ("in", "post"):
goals = []
cards = []
for det in rec["_comp"].get("details") or []:
team_id = int((det.get("team") or {}).get("id") or 0)
aths = det.get("athletesInvolved") or []
if det.get("yellowCard") or det.get("redCard"):
cards.append({
"clock": (det.get("clock") or {}).get("displayValue", ""),
"team": MLS_ID_TO_CODE.get(team_id, ""),
"player": _rn((aths[0] if aths else {}).get("displayName", "")),
"red": bool(det.get("redCard")),
})
continue
if not det.get("scoringPlay"):
continue
player = _rn((aths[0] if aths else {}).get("displayName", ""))
# 어시스트: 중계 원문 큐(시간순) 우선, 없으면 details 두 번째 선수
queue = assist_q.get(player)
assist = queue.pop(0) if queue else (
_rn(aths[1].get("displayName", ""))
if len(aths) > 1 and not det.get("ownGoal")
else ""
)
goals.append({
"clock": (det.get("clock") or {}).get("displayValue", ""),
"team": MLS_ID_TO_CODE.get(team_id, ""),
"player": player,
"assist": assist,
"ownGoal": bool(det.get("ownGoal")),
"penalty": bool(det.get("penaltyKick")),
})
payload = {
"available": True,
"soccer": True,
"clock": status.get("displayClock", ""),
"period": status.get("period"),
"state": stype.get("state"),
"score": {
"away": rec["_away"].get("score"),
"home": rec["_home"].get("score"),
},
"goals": goals,
"cards": cards,
"subs": subs,
"lineups": lineups,
"commentary": commentary,
}
elif lineups:
# 경기 전 선발 라인업 발표됨 — 스코어 없이 라인업만
payload = {
"available": True,
"soccer": True,
"state": "pre",
"lineups": lineups,
"commentary": commentary,
}
except Exception as e: # noqa: BLE001
log.warning("mls live 실패 %s: %s", m.match_id, e)
payload = {"available": False}
_live_cache[m.match_id] = (time.monotonic(), payload)
return payload