From 74de57fc1c1ce822d8e9d3091a126cf9da3dd3a1 Mon Sep 17 00:00:00 2001 From: jwkim Date: Fri, 14 Aug 2026 13:46:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(mls):=20MLS=20=EB=A6=AC=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20ESPN=20=EB=B9=84=EA=B3=B5?= =?UTF-8?q?=EC=8B=9D=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일정·결과·컨퍼런스 순위·프리뷰(폼/시즌전적/맞대결/머니라인) 수집 - 라이브 카드: 스코어·경기시간·득점/카드·선발 라인업(피치 뷰)·교체 현황 - 문자중계: ESPN commentary 규칙 기반 한글 변환 (미지원 템플릿은 팀명만 한글화) - MLS 레코드는 야구와 키 구조가 같아 sync_baseball_schedule/settle 재사용 - AI 예측 데이터 블록에 MLS 전용 컨텍스트 추가 Co-Authored-By: Claude Opus 5 --- backend/app/config.py | 9 +- backend/app/routers/matches.py | 6 +- backend/app/routers/standings.py | 16 +- backend/app/services/ai.py | 31 +- backend/app/services/baseball_details.py | 3 +- backend/app/services/mls_espn.py | 703 +++++++++++++++++++++ backend/app/teams_baseball.py | 9 +- backend/app/teams_mls.py | 49 ++ backend/app/worker.py | 37 +- frontend/src/components/Arena.tsx | 27 +- frontend/src/components/Footer.tsx | 4 +- frontend/src/components/Hero.tsx | 7 +- frontend/src/components/LiveSoccer.tsx | 563 +++++++++++++++++ frontend/src/components/MatchupHUD.tsx | 95 ++- frontend/src/components/ScheduleBoard.tsx | 98 ++- frontend/src/components/StandingsTable.tsx | 39 +- frontend/src/lib/api.ts | 56 ++ frontend/src/lib/i18n.ts | 6 +- frontend/src/lib/types.ts | 25 +- frontend/src/lib/useLeague.ts | 4 +- frontend/src/pages/Leaderboard.tsx | 4 +- frontend/src/pages/MatchDetail.tsx | 15 +- 22 files changed, 1723 insertions(+), 83 deletions(-) create mode 100644 backend/app/services/mls_espn.py create mode 100644 backend/app/teams_mls.py create mode 100644 frontend/src/components/LiveSoccer.tsx diff --git a/backend/app/config.py b/backend/app/config.py index 0c2c1a6..a0b102c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -87,9 +87,9 @@ class Settings(BaseSettings): return "football-data" return self.schedule_source.lower() - # ── 멀티리그 (wc=월드컵 축구 · kbo · mlb) ──────────────── - # 활성 리그 (콤마구분). 야구 리그는 워커가 각자 소스에서 일정·결과를 동기화. - leagues: str = "wc,kbo,mlb" + # ── 멀티리그 (wc=월드컵 축구 · kbo · mlb · mls) ────────── + # 활성 리그 (콤마구분). 야구·MLS 리그는 워커가 각자 소스에서 일정·결과를 동기화. + leagues: str = "wc,kbo,mlb,mls" # 야구 일정 수집 윈도우 — 오늘 기준 미래 며칠치. # (투표 오픈·AI 예측 생성은 리그 공통: vote_open_hours_before / ai_generate_lookahead_hours) baseball_days_ahead: int = 7 @@ -97,6 +97,9 @@ class Settings(BaseSettings): naver_api_base: str = "https://api-gw.sports.naver.com" # MLB 공식 Stats API (키 불필요). mlb_api_base: str = "https://statsapi.mlb.com/api" + # ESPN 비공식 API (MLS 일정·결과·순위·상세) — 키 불필요, 비공식. + espn_api_base: str = "https://site.api.espn.com/apis" + espn_mls_path: str = "sports/soccer/usa.1" @property def league_list(self) -> list[str]: diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 12bb81d..8922c38 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -57,10 +57,14 @@ async def get_live( match_id: str, db: AsyncSession = Depends(get_db), ) -> dict: - """야구 라이브 필드 뷰 (kbo=네이버 relay, mlb=공식 feed/live). 15초 TTL 캐시.""" + """라이브 뷰 (kbo=네이버 relay, mlb=공식 feed/live, mls=ESPN). 15초 TTL 캐시.""" m = await db.get(Match, match_id) if not m: raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") + if m.league == "mls": + from ..services.mls_espn import fetch_live_mls + + return await fetch_live_mls(m) if m.league not in ("kbo", "mlb"): return {"available": False} return await fetch_live(m) diff --git a/backend/app/routers/standings.py b/backend/app/routers/standings.py index b56ad07..c4f30b7 100644 --- a/backend/app/routers/standings.py +++ b/backend/app/routers/standings.py @@ -1,6 +1,7 @@ """리그 순위표 API — 워커가 캐싱한 standings:{league} 를 팀 정보와 합쳐 서빙. KBO: 단일 테이블(10팀, 순위순). MLB: 디비전(AL/NL × 동·중·서) 6그룹. +MLS: 컨퍼런스(동/서부) 2그룹 — 승점제. 캐시가 아직 없으면 빈 groups 를 반환한다(프론트는 안내 문구 표시). """ from __future__ import annotations @@ -16,14 +17,16 @@ router = APIRouter(prefix="/api/standings", tags=["standings"]) # MLB 디비전 표시 순서 (AL 동→중→서, NL 동→중→서) _MLB_DIV_ORDER = ["ALE", "ALC", "ALW", "NLE", "NLC", "NLW"] +# MLS 컨퍼런스 표시 순서 (동부 → 서부) +_MLS_CONF_ORDER = ["EAST", "WEST"] @router.get("") async def get_standings( - league: str = Query(..., description="kbo | mlb"), + league: str = Query(..., description="kbo | mlb | mls"), db: AsyncSession = Depends(get_db), ) -> dict: - if league not in ("kbo", "mlb"): + if league not in ("kbo", "mlb", "mls"): return {"league": league, "updatedAt": None, "groups": []} row = await db.get(DataCache, f"standings:{league}") table: dict = row.payload if row else {} @@ -32,6 +35,15 @@ async def get_standings( if league == "kbo": rows.sort(key=lambda r: r.get("rank") or 99) groups = [{"key": None, "rows": rows}] if rows else [] + elif league == "mls": + by_conf: dict[str, list] = {} + for r in rows: + by_conf.setdefault(r.get("div") or "", []).append(r) + for lst in by_conf.values(): + lst.sort(key=lambda r: r.get("rank") or 99) + groups = [ + {"key": c, "rows": by_conf[c]} for c in _MLS_CONF_ORDER if c in by_conf + ] else: by_div: dict[str, list] = {} for r in rows: diff --git a/backend/app/services/ai.py b/backend/app/services/ai.py index c779ea8..f73c817 100644 --- a/backend/app/services/ai.py +++ b/backend/app/services/ai.py @@ -31,7 +31,7 @@ class MatchContext: venue: str kickoff: str # ISO data_block: str | None = None # 실데이터(폼·H2H·랭킹 등) 주입 블록. 없으면 이름만. - league: str = "wc" # wc(축구) | kbo | mlb — 프롬프트·스코어 범위 분기 + league: str = "wc" # wc(축구) | kbo | mlb | mls — 프롬프트·스코어 범위 분기 # 모델별 분석 관점(페르소나) — 동일 경기라도 서로 다른 시각으로 보게 해 @@ -136,10 +136,37 @@ def _prompt(ctx: MatchContext, persona: str) -> str: ) +def _prompt_mls(ctx: MatchContext, persona: str) -> str: + """MLS 정규시즌 — 월드컵과 달리 홈 어드밴티지가 있고 무승부가 흔하다.""" + data = f"\n{ctx.data_block}\n" if ctx.data_block else "" + return ( + f"{persona}\n" + f"Predict the result of this 2026 MLS (Major League Soccer) regular-season " + f"match using YOUR perspective above. Judge independently — it is fine to " + f"differ from the obvious consensus pick when your perspective warrants it.\n" + f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n" + f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n" + f"{data}" + f"Important: Team B is the HOME team — MLS home advantage is significant " + f"(long travel distances). Draws are common in MLS (~25% of matches) — " + f"predict one when the matchup genuinely points that way.\n\n" + f"Predict the final score. " + f"Respond with a single JSON object and nothing else, with keys:\n" + f' "scoreA": integer 0-9 (Team A goals),\n' + f' "scoreB": integer 0-9 (Team B goals),\n' + f' "outcome": one of "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN" (must match the score),\n' + f' "confidencePct": integer 0-100,\n' + f' "reasonKo": a short one-line rationale in Korean (max ~30 chars),\n' + f' "reasonEn": a short one-line rationale in English (max ~60 chars).\n' + ) + + def _build_prompt(ctx: MatchContext, model: str) -> str: - """리그별 프롬프트 선택 — 야구(kbo/mlb)는 야구 프롬프트, 그 외 축구.""" + """리그별 프롬프트 선택 — 야구(kbo/mlb)는 야구, mls 는 MLS 축구, 그 외 월드컵.""" if ctx.league in ("kbo", "mlb"): return _prompt_baseball(ctx, model) + if ctx.league == "mls": + return _prompt_mls(ctx, PERSONA[model]) return _prompt(ctx, PERSONA[model]) diff --git a/backend/app/services/baseball_details.py b/backend/app/services/baseball_details.py index 3fe656e..2c38e4d 100644 --- a/backend/app/services/baseball_details.py +++ b/backend/app/services/baseball_details.py @@ -329,11 +329,12 @@ async def refresh_baseball_details(db, league: str, matches: list[Match]) -> Non # ── extras 조회 (라우터 — 캐시만) ────────────────────────────── +# MLS 도 동일 캐시 키(preview:{id}, standings:mls)를 쓰므로 여기서 함께 서빙. async def get_extras(db, matches: list[Match]) -> dict[str, dict]: standings_cache: dict[str, dict] = {} out: dict[str, dict] = {} for m in matches: - if m.league not in ("kbo", "mlb"): + if m.league not in ("kbo", "mlb", "mls"): continue if m.league not in standings_cache: row = await db.get(DataCache, f"standings:{m.league}") diff --git a/backend/app/services/mls_espn.py b/backend/app/services/mls_espn.py new file mode 100644 index 0000000..2647ea3 --- /dev/null +++ b/backend/app/services/mls_espn.py @@ -0,0 +1,703 @@ +"""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)) +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 상태 (연기 포함 — 보강 일정이 새 이벤트로 재등장) +_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), headers=UA) + 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.result_recheck_days + 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(), headers=UA) + 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] + 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"]), headers=UA) + 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"]), headers=UA) + 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 diff --git a/backend/app/teams_baseball.py b/backend/app/teams_baseball.py index 5773874..86b7e98 100644 --- a/backend/app/teams_baseball.py +++ b/backend/app/teams_baseball.py @@ -61,18 +61,25 @@ KBO_SHORT_TO_CODE = {v["short"]: k for k, v in KBO_TEAMS.items()} def teams_of(league: str) -> dict[str, dict]: + if league == "mls": + from .teams_mls import MLS_TEAMS + + return MLS_TEAMS return KBO_TEAMS if league == "kbo" else MLB_TEAMS def team_info(league: str, code: str) -> dict: """flag 에 로고 경로/URL 을 실어 프론트(TeamFlag)가 그대로 렌더한다. - KBO: 로컬 자산(/assets/teams/kbo/*.png) · MLB: 공식 CDN(mlbstatic) SVG.""" + KBO: 로컬 자산(/assets/teams/kbo/*.png) · MLB: 공식 CDN(mlbstatic) SVG · + MLS: ESPN CDN PNG.""" c = (code or "").strip().upper() t = teams_of(league).get(c) if not t: return {"name": c, "shortName": c, "code": c, "flag": ""} if league == "mlb": flag = f"https://www.mlbstatic.com/team-logos/{t['mlb_id']}.svg" + elif league == "mls": + flag = f"https://a.espncdn.com/i/teamlogos/soccer/500/{t['espn_id']}.png" else: flag = f"/assets/teams/kbo/{c.lower()}.png" return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": flag} diff --git a/backend/app/teams_mls.py b/backend/app/teams_mls.py new file mode 100644 index 0000000..787bf81 --- /dev/null +++ b/backend/app/teams_mls.py @@ -0,0 +1,49 @@ +"""MLS 팀 데이터 — 30개 구단 (ESPN 약어 코드 → 한글/약식/영문/ESPN ID). + +코드: ESPN abbreviation (일정·순위·라이브 매칭 키와 동일해 변환 불필요). +espn_id: ESPN 팀 ID (불변) — 로고 CDN URL 조립에 사용. +로고: https://a.espncdn.com/i/teamlogos/soccer/500/{espn_id}.png +""" +from __future__ import annotations + +MLS_TEAMS: dict[str, dict] = { + # ── 동부(Eastern Conference) ───────────────────────────── + "ATL": {"ko": "애틀랜타 유나이티드", "short": "애틀랜타", "en": "Atlanta United FC", "espn_id": 18418}, + "CLT": {"ko": "샬럿 FC", "short": "샬럿", "en": "Charlotte FC", "espn_id": 21300}, + "CHI": {"ko": "시카고 파이어", "short": "시카고", "en": "Chicago Fire FC", "espn_id": 182}, + "CIN": {"ko": "FC 신시내티", "short": "신시내티", "en": "FC Cincinnati", "espn_id": 18267}, + "CLB": {"ko": "콜럼버스 크루", "short": "콜럼버스", "en": "Columbus Crew", "espn_id": 183}, + "DC": {"ko": "DC 유나이티드", "short": "DC", "en": "D.C. United", "espn_id": 193}, + "MIA": {"ko": "인터 마이애미", "short": "마이애미", "en": "Inter Miami CF", "espn_id": 20232}, + "MTL": {"ko": "CF 몬트리올", "short": "몬트리올", "en": "CF Montréal", "espn_id": 9720}, + "NSH": {"ko": "내슈빌 SC", "short": "내슈빌", "en": "Nashville SC", "espn_id": 18986}, + "NE": {"ko": "뉴잉글랜드 레볼루션", "short": "뉴잉글랜드", "en": "New England Revolution", "espn_id": 189}, + "NYC": {"ko": "뉴욕 시티 FC", "short": "뉴욕시티", "en": "New York City FC", "espn_id": 17606}, + "RBNY": {"ko": "뉴욕 레드불스", "short": "레드불스", "en": "New York Red Bulls", "espn_id": 190}, + "ORL": {"ko": "올랜도 시티", "short": "올랜도", "en": "Orlando City SC", "espn_id": 12011}, + "PHI": {"ko": "필라델피아 유니언", "short": "필라델피아", "en": "Philadelphia Union", "espn_id": 10739}, + "TOR": {"ko": "토론토 FC", "short": "토론토", "en": "Toronto FC", "espn_id": 7318}, + # ── 서부(Western Conference) ───────────────────────────── + "ATX": {"ko": "오스틴 FC", "short": "오스틴", "en": "Austin FC", "espn_id": 20906}, + "COL": {"ko": "콜로라도 래피즈", "short": "콜로라도", "en": "Colorado Rapids", "espn_id": 184}, + "DAL": {"ko": "FC 댈러스", "short": "댈러스", "en": "FC Dallas", "espn_id": 185}, + "HOU": {"ko": "휴스턴 다이너모", "short": "휴스턴", "en": "Houston Dynamo FC", "espn_id": 6077}, + "LA": {"ko": "LA 갤럭시", "short": "LA갤럭시", "en": "LA Galaxy", "espn_id": 187}, + "LAFC": {"ko": "LAFC", "short": "LAFC", "en": "LAFC", "espn_id": 18966}, + "MIN": {"ko": "미네소타 유나이티드", "short": "미네소타", "en": "Minnesota United FC", "espn_id": 17362}, + "POR": {"ko": "포틀랜드 팀버스", "short": "포틀랜드", "en": "Portland Timbers", "espn_id": 9723}, + "RSL": {"ko": "레알 솔트레이크", "short": "솔트레이크", "en": "Real Salt Lake", "espn_id": 4771}, + "SD": {"ko": "샌디에이고 FC", "short": "샌디에이고", "en": "San Diego FC", "espn_id": 22529}, + "SJ": {"ko": "산호세 어스퀘이크스", "short": "산호세", "en": "San Jose Earthquakes", "espn_id": 191}, + "SEA": {"ko": "시애틀 사운더스", "short": "시애틀", "en": "Seattle Sounders FC", "espn_id": 9726}, + "SKC": {"ko": "스포팅 캔자스시티", "short": "캔자스시티", "en": "Sporting Kansas City", "espn_id": 186}, + "STL": {"ko": "세인트루이스 시티", "short": "세인트루이스", "en": "St. Louis CITY SC", "espn_id": 21812}, + "VAN": {"ko": "밴쿠버 화이트캡스", "short": "밴쿠버", "en": "Vancouver Whitecaps FC", "espn_id": 9727}, +} + +MLS_ID_TO_CODE: dict[int, str] = {v["espn_id"]: k for k, v in MLS_TEAMS.items()} + + +def mls_logo(code: str) -> str: + t = MLS_TEAMS.get(code) + return f"https://a.espncdn.com/i/teamlogos/soccer/500/{t['espn_id']}.png" if t else "" diff --git a/backend/app/worker.py b/backend/app/worker.py index 6d4b7d3..8c2a89d 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -30,7 +30,7 @@ from .database import SessionLocal, init_db from .domain import compute_phase, ensure_aware, now_utc from .models import AIPrediction, Match, UserPrediction from .scoring import load_scoring_data -from .services import baseball_data, baseball_details, football_data +from .services import baseball_data, baseball_details, football_data, mls_espn from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable from .services.baseball_fetch import fetch_baseball_results, fetch_baseball_schedule from .services.baseball_sync import match_seq, sync_baseball_schedule @@ -62,12 +62,19 @@ async def sync_schedule_job() -> None: async def sync_baseball_job() -> None: - """야구(kbo/mlb) 일정 동기화 + 프리뷰·순위 캐시 갱신.""" + """야구(kbo/mlb)+MLS 일정 동기화 + 프리뷰·순위 캐시 갱신. + + MLS 도 (리그, KST 날짜, 팀쌍, 차수) 키 구조가 동일해 야구 sync 를 그대로 탄다. + """ inserted_any = False for league in settings.league_list: - if league not in ("kbo", "mlb"): + if league not in ("kbo", "mlb", "mls"): continue - records = await fetch_baseball_schedule(league) + records = ( + await mls_espn.fetch_mls_schedule() + if league == "mls" + else await fetch_baseball_schedule(league) + ) if not records: continue async with SessionLocal() as db: @@ -80,9 +87,12 @@ async def sync_baseball_job() -> None: ) ).scalars().all() try: - await baseball_details.refresh_baseball_details(db, league, rows) + if league == "mls": + await mls_espn.refresh_mls_details(db, rows) + else: + await baseball_details.refresh_baseball_details(db, league, rows) except Exception as e: # noqa: BLE001 - log.warning("baseball details(%s) 갱신 실패: %s", league, e) + log.warning("details(%s) 갱신 실패: %s", league, e) inserted_any = inserted_any or bool(result.get("inserted")) await tick_status() if inserted_any: @@ -139,9 +149,12 @@ async def generate_ai_predictions(only_missing: bool = True) -> None: ] for m in matches: - # 실데이터 블록 — 리그별 소스(축구=API-Football 캐시, 야구=자체DB+프리뷰). + # 실데이터 블록 — 리그별 소스(축구=API-Football 캐시, 야구=자체DB+프리뷰, + # MLS=자체DB+ESPN 프리뷰). if m.league in ("kbo", "mlb"): data_block = await baseball_data.build_baseball_data_block(db, m) + elif m.league == "mls": + data_block = await mls_espn.build_mls_data_block(db, m) else: data_block = await football_data.build_data_block(db, m) ctx = MatchContext( @@ -313,12 +326,12 @@ async def settle_matches() -> None: await maybe_send_result_emails() -# ── 2.6) 야구 결과 자동 정산 — (리그, KST 날짜, 팀쌍) 키 매칭 ── +# ── 2.6) 야구·MLS 결과 자동 정산 — (리그, KST 날짜, 팀쌍) 키 매칭 ── async def settle_baseball() -> None: _KST = timedelta(hours=9) now = now_utc() for league in settings.league_list: - if league not in ("kbo", "mlb"): + if league not in ("kbo", "mlb", "mls"): continue async with SessionLocal() as db: pending = ( @@ -333,7 +346,11 @@ async def settle_baseball() -> None: due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now] if not due: continue - results = await fetch_baseball_results(league) + results = ( + await mls_espn.fetch_mls_results() + if league == "mls" + else await fetch_baseball_results(league) + ) by_key: dict[tuple, tuple[int, int]] = {} for r in results: d, a2, b2, s = r["dateKst"], r["teamA"], r["teamB"], r.get("seq", 1) diff --git a/frontend/src/components/Arena.tsx b/frontend/src/components/Arena.tsx index 5dfef70..a0061d5 100644 --- a/frontend/src/components/Arena.tsx +++ b/frontend/src/components/Arena.tsx @@ -95,8 +95,11 @@ export default function Arena({ const t = dict(lang); const aShort = teamShort(match.teamA, lang); const bShort = teamShort(match.teamB, lang); - // 한국을 항상 왼쪽에 표시(요청). 상태·투표 제출은 원본 A/B 프레임 그대로, 화면 좌우만 교체. - const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR"; + // 월드컵: 한국을 항상 왼쪽에. MLS: 홈 팀을 왼쪽에(축구 관례). + // 상태·투표 제출은 원본 A/B 프레임 그대로, 화면 좌우만 교체. + const flip = + match.league === "mls" || + (match.teamB.code === "KOR" && match.teamA.code !== "KOR"); const leftShort = flip ? bShort : aShort; const rightShort = flip ? aShort : bShort; // 마스코트(사자·버튼 양옆 캐릭터)는 모든 경기에서 노출. @@ -230,8 +233,8 @@ export default function Arena({ {/* ===== 레이어드 화이트 시트 (002) ===== */}
- {/* AI 박스 오른쪽 위 사자 — 축구 전용 */} - {!isBaseball && ( + {/* AI 박스 오른쪽 위 사자 — 월드컵 전용 (야구·MLS 미노출) */} + {match.league === "wc" && (
- {!isBaseball && {submitting ? "…" : <>{t.submit} } - {!isBaseball && {(votesExpanded ? myPreds : myPreds.slice(0, 1)).map((p) => { const isThis = p.matchId === match.matchId; - const pFlip = p.teamB.code === "KOR" && p.teamA.code !== "KOR"; + const pFlip = + /^MLS_/.test(p.matchId) || + (p.teamB.code === "KOR" && p.teamA.code !== "KOR"); const pA = teamShort(p.teamA, lang); const pB = teamShort(p.teamB, lang); const pLeft = pFlip ? pB : pA; @@ -566,11 +571,11 @@ export default function Arena({
)} - {/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 야구(KBO/MLB)는 임시 비노출(운영 방침 미확정) ===== */} - {!isBaseball && } + {/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 월드컵 외 리그는 임시 비노출(운영 방침 미확정) ===== */} + {match.league === "wc" && } - {/* ===== 골드 상금 (003) — 야구(KBO/MLB)는 임시 비노출 ===== */} - {!isBaseball && ( + {/* ===== 골드 상금 (003) — 월드컵 외 리그는 임시 비노출 ===== */} + {match.league === "wc" && (
diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 820d237..228a1ec 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -1,6 +1,6 @@ import { type Lang, dict } from "@/lib/i18n"; -// league 를 주면 비제휴 고지를 리그에 맞게 표시 (야구 = KBO/MLB, 기본 = 월드컵) +// league 를 주면 비제휴 고지를 리그에 맞게 표시 (클럽 리그 = KBO/MLB/MLS, 기본 = 월드컵) export default function Footer({ lang = "ko", league = "wc", @@ -9,7 +9,7 @@ export default function Footer({ league?: string; }) { const t = dict(lang); - const isBaseball = league === "kbo" || league === "mlb"; + const isBaseball = league !== "wc"; return (

diff --git a/frontend/src/components/Hero.tsx b/frontend/src/components/Hero.tsx index 42bf15b..5c6a14e 100644 --- a/frontend/src/components/Hero.tsx +++ b/frontend/src/components/Hero.tsx @@ -65,7 +65,11 @@ export default function Hero({ ? lang === "en" ? "AI-powered 2026 MLB game predictions" : "AI와 함께하는 2026 MLB 승부예측 챌린지" - : t.heroPill} + : league === "mls" + ? lang === "en" + ? "AI-powered 2026 MLS game predictions" + : "AI와 함께하는 2026 MLS 승부예측 챌린지" + : t.heroPill}

@@ -79,6 +83,7 @@ function LeagueTabs({ lang, current }: { lang: Lang; current: League }) { { key: "wc", label: lang === "en" ? "World Cup" : "월드컵" }, { key: "kbo", label: "KBO" }, { key: "mlb", label: "MLB" }, + { key: "mls", label: "MLS" }, ]; return (
diff --git a/frontend/src/components/LiveSoccer.tsx b/frontend/src/components/LiveSoccer.tsx new file mode 100644 index 0000000..31b5b4c --- /dev/null +++ b/frontend/src/components/LiveSoccer.tsx @@ -0,0 +1,563 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Lang } from "@/lib/i18n"; +import { teamShort } from "@/lib/i18n"; +import { getLive, type LiveData, type SoccerLineup, type SoccerPlayer } from "@/lib/api"; +import type { Match } from "@/lib/types"; + +const POLL_MS = 20_000; +// 라인업은 보통 킥오프 60~75분 전 발표 — 이 시간부터 경기 전 폴링 시작 +const PREGAME_MIN = 90; +// 자책골 마커 — 빨간 축구공 (이모지는 색 변경이 안 돼 인라인 SVG 로 그린다) +function OgBall() { + return ( + + + + + ); +} + +// MLS 라이브 카드 — 경기 분·스코어·득점·선발 라인업·실시간 교체 현황. +// 경기 전(킥오프 90분 이내, 라인업 발표 시)·경기 중·종료 후(경기 기록) 렌더. (서버 15초 캐시) +export default function LiveSoccer({ match, lang = "ko" }: { match: Match; lang?: Lang }) { + const isMls = match.league === "mls"; + const isLive = match.phase === "live"; + const isDone = match.phase === "finished"; + // 경기 전 폴링 여부 — 킥오프까지 90분 이내 + const nearKickoff = useMemo(() => { + const diff = new Date(match.kickoffKst).getTime() - Date.now(); + return diff > 0 && diff <= PREGAME_MIN * 60_000; + }, [match.kickoffKst]); + const active = isMls && (isLive || isDone || nearKickoff); + + const [live, setLive] = useState(null); + const [showBench, setShowBench] = useState(false); + + useEffect(() => { + if (!active) return; + let alive = true; + const load = () => + getLive(match.matchId) + .then((d) => { + if (alive && d.available) setLive(d); + }) + .catch(() => {}); + load(); + if (isDone) return () => { alive = false; }; // 종료 경기는 1회 조회로 충분 + const t = setInterval(load, POLL_MS); + return () => { + alive = false; + clearInterval(t); + }; + }, [active, isDone, match.matchId]); + + if (!active || !live?.available) return null; + + const inPlay = live.state !== "pre"; + const ended = live.state === "post"; + const awayGoals = (live.goals ?? []).filter((g) => g.team === match.teamA.code); + const homeGoals = (live.goals ?? []).filter((g) => g.team === match.teamB.code); + // 득점 표기: 첫 줄 득점자 ⚽ / 둘째 줄(들여쓰기) 도움 선수 👟 — 줄바꿈 어색함 방지 + const goalLine = ( + g: { + clock: string; + player: string; + assist?: string; + ownGoal?: boolean; + penalty?: boolean; + }, + right = false, + ) => ( + <> +
+ {g.clock} {g.player} + {g.penalty ? " (PK)" : ""} + {g.ownGoal ? " (OG)" : ""}{" "} + {g.ownGoal ? : "⚽"} +
+ {g.assist && ( +
+ {g.assist} 👟 +
+ )} + + ); + // 교체 시각 매핑 (선수명 → 분) — 라인업 옆에 표시 + const subClock = new Map(); + // 나간 선수 → 들어온 선수 매핑 — 피치에서 투입 선수가 그 자리를 대체 + const outToIn = new Map(); + for (const s of live.subs ?? []) { + if (s.inName) subClock.set(s.inName, s.clock); + if (s.outName) subClock.set(s.outName, s.clock); + if (s.outName && s.inName) outToIn.set(s.outName, { inName: s.inName, clock: s.clock }); + } + // 득점(⚽)·도움(👟)·자책골(빨간 ⚽) 횟수 — 피치 이름표에 표시 + const goalsBy = new Map(); + const assistsBy = new Map(); + const ogBy = new Map(); + for (const g of live.goals ?? []) { + if (g.player && !g.ownGoal) goalsBy.set(g.player, (goalsBy.get(g.player) ?? 0) + 1); + if (g.player && g.ownGoal) ogBy.set(g.player, (ogBy.get(g.player) ?? 0) + 1); + if (g.assist) assistsBy.set(g.assist, (assistsBy.get(g.assist) ?? 0) + 1); + } + // 카드(🟨/🟥) 누적 — 피치·교체 명단 이름표에 표시 + const cardsBy = new Map(); + for (const c of live.cards ?? []) { + if (!c.player) continue; + const cur = cardsBy.get(c.player) ?? { y: 0, red: false }; + if (c.red) cur.red = true; + else cur.y += 1; + cardsBy.set(c.player, cur); + } + + return ( +
+ {/* 헤더: LIVE/경기 기록/선발 라인업 + 경기 시간 */} +
+ {ended ? ( + + {lang === "en" ? "Match recap" : "경기 기록"} + + ) : inPlay ? ( + + + LIVE + + ) : ( + + {lang === "en" ? "Starting lineups" : "선발 라인업"} + + )} + {inPlay && ( + + {ended ? ( + + {lang === "en" ? "FT" : "경기 종료"} + + ) : ( + <> + {live.clock} + {live.period != null && ( + + {live.period === 1 + ? lang === "en" ? "1st half" : "전반" + : live.period === 2 + ? lang === "en" ? "2nd half" : "후반" + : lang === "en" ? "ET" : "연장"} + + )} + + )} + + )} +
+ + {/* 스코어 (경기 중에만) — 홈 팀 먼저 (축구 관례) */} + {inPlay && ( +
+ {teamShort(match.teamB, lang)} + + {live.score?.home ?? 0} + : + {live.score?.away ?? 0} + + + {teamShort(match.teamA, lang)} + +
+ )} + + {/* 득점 이벤트 (양 팀 분리, 시간순) — 좌=홈 / 우=원정 */} + {(awayGoals.length > 0 || homeGoals.length > 0) && ( +
+
+ {homeGoals.map((g, i) => ( +
{goalLine(g)}
+ ))} +
+
+ {awayGoals.map((g, i) => ( +
{goalLine(g, true)}
+ ))} +
+
+ )} + + + {/* 문자중계 (최신순) — 세로 스크롤 (다크 스크롤바, 흰 배경 없음) */} + {(live.commentary?.length ?? 0) > 0 && ( +
+
+ + {lang === "en" ? "PLAY-BY-PLAY" : "문자중계"} + + ESPN +
+
    + {live.commentary!.map((c, i) => ( +
  • + + {c.clock} + + + {c.text} + +
  • + ))} +
+
+ )} + + {/* 선발 라인업 — 축구장 하나에 반코트씩 (위=홈 · 아래=원정), 교체 아웃은 흐리게 */} + {live.lineups?.away && live.lineups?.home && ( +
+
+ + {teamShort(match.teamB, lang)} + {live.lineups.home.formation && ( + + {live.lineups.home.formation} + + )} + + + {lang === "en" ? "STARTING XI" : "선발 라인업"} + + + {live.lineups.away.formation && ( + + {live.lineups.away.formation} + + )} + {teamShort(match.teamA, lang)} + +
+ + + + {/* 교체 명단 (벤치) — 투입된 선수는 ▲분 표시 */} + + {showBench && ( +
+ + +
+ )} +
+ )} +
+ ); +} + +// 포메이션 문자열("4-2-3-1") → 줄별 인원. 합이 필드 플레이어 수와 다르면 4-4-2 폴백. +function parseRows(formation: string | null | undefined, n: number): number[] { + const rows = (formation ?? "").split("-").map(Number).filter((x) => x > 0); + if (rows.length && rows.reduce((a, b) => a + b, 0) === n) return rows; + return n === 10 ? [4, 4, 2] : [n]; +} + +// 포지션 약어 → 세로 라인 가중치 (작을수록 수비 라인) +// F/ST 는 CF 보다 반 칸 앞 — 3-4-2-1 의 섀도 투톱(CF-R/CF-L) 뒤 원톱(F) 구분. +// 실제로 같은 줄인 4-3-3 등은 아래 병합 단계가 포메이션 숫자에 맞춰 다시 합친다. +const POS_LINE: Record = { + G: 0, + RB: 1, LB: 1, CD: 1, CB: 1, SW: 1, + RWB: 1.5, LWB: 1.5, + DM: 2, + CM: 2.5, + RM: 3, LM: 3, + AM: 3.5, + RW: 3.7, LW: 3.7, + RF: 4, LF: 4, CF: 4, + F: 4.2, ST: 4.2, +}; +const posBase = (pos: string) => pos.toUpperCase().replace(/-[RL]$/, ""); +// 줄 안 좌→우 정렬 키 (상단 팀 기준; 하단 팀은 미러로 좌우 반전) +function posX(pos: string): number { + const P = pos.toUpperCase(); + if (P.startsWith("L")) return 0; + if (P.startsWith("R")) return 4; + if (P.endsWith("-L")) return 1; + if (P.endsWith("-R")) return 3; + return 2; +} + +// ESPN formationPlace 는 줄 순서가 아니라 전통 등번호식 슬롯(2=RB, 3=LB, 4=CM…)이라 +// 순서대로 줄에 채우면 배치가 어긋난다 → 포지션 약어(CD-R, CM-L…) 기반으로 줄 구성. +function layoutRows(field: SoccerPlayer[], formation?: string | null): SoccerPlayer[][] { + const lines = field.map((p) => POS_LINE[posBase(p.pos || "")]); + let rows: SoccerPlayer[][]; + if (lines.some((l) => l == null)) { + // 포지션 불명 선수가 있으면 formationPlace 순 폴백 + const cnts = parseRows(formation, field.length); + let i = 0; + rows = cnts.map((c) => field.slice(i, (i += c))); + } else { + const groups = new Map(); + field.forEach((p, i) => { + const arr = groups.get(lines[i]) ?? []; + arr.push(p); + groups.set(lines[i], arr); + }); + rows = [...groups.entries()].sort((a, b) => a[0] - b[0]).map(([, ps]) => ps); + // 포메이션 숫자에 줄 수 맞추기 — 앞 줄부터 인원수대로 병합 (4-4-2 의 중미+윙미 등) + const target = parseRows(formation, field.length); + if (target.reduce((a, b) => a + b, 0) === field.length && target.length < rows.length) { + const merged: SoccerPlayer[][] = []; + let gi = 0; + let ok = true; + for (const cnt of target) { + let acc: SoccerPlayer[] = []; + while (acc.length < cnt && gi < rows.length) acc = acc.concat(rows[gi++]); + if (acc.length !== cnt) { ok = false; break; } + merged.push(acc); + } + if (ok && gi === rows.length) rows = merged; + } + } + return rows.map((r) => r.slice().sort((a, b) => posX(a.pos || "") - posX(b.pos || ""))); +} + +// 축구장 피치 — 세로형 풀피치, 위 반코트=홈 / 아래 반코트=원정 (홈 먼저 — 축구 관례). +// 각 포메이션 라인이 화면 가로폭 전체를 쓰므로 이름 공간이 넓다. +// 현재 그라운드 기준: 교체 투입 선수가 나간 선수 자리를 대체(빨간 ▲분), +// 나간 선수는 교체 명단(BenchList)에 ▼로 표시된다. +function Pitch({ + away, + home, + outToIn, + goalsBy, + assistsBy, + ogBy, + cardsBy, +}: { + away: SoccerLineup; + home: SoccerLineup; + outToIn?: Map; + goalsBy?: Map; + assistsBy?: Map; + ogBy?: Map; + cardsBy?: Map; +}) { + const place = (lu: SoccerLineup, half: "top" | "bottom") => { + const [gk, ...field] = lu.starters; + const out: { p: SoccerPlayer; x: number; y: number }[] = []; + if (gk) out.push({ p: gk, x: 0.5, y: 0.055 }); + const rows = layoutRows(field, lu.formation); + rows.forEach((row, r) => { + // 반코트(0.055~0.45) 안에서 줄을 균등 배치, 마지막 줄이 센터라인 쪽 + const y = 0.055 + ((r + 1) / (rows.length + 1)) * (0.45 - 0.055); + row.forEach((p, i) => out.push({ p, x: (i + 0.5) / row.length, y })); + }); + // 위 팀은 아래로 공격 → 뷰어 기준 좌우 반전. 아래 팀은 위로 공격 → 좌우 그대로, 상하만 미러. + return half === "top" + ? out.map((o) => ({ ...o, x: 1 - o.x })) + : out.map((o) => ({ ...o, y: 1 - o.y })); + }; + const markers = [ + ...place(home, "top").map((o) => ({ ...o, side: "home" as const })), + ...place(away, "bottom").map((o) => ({ ...o, side: "away" as const })), + ]; + const lastName = (name: string) => name.split(" ").slice(-1)[0]; + + return ( +
+ {/* 피치 라인: 외곽·센터라인·센터서클·페널티박스 (세로형) */} +
+
+
+
+
+ + {markers.map(({ p, x, y, side }) => { + // 교체됐으면 투입 선수가 그 자리를 대체 (빨간 ▲분). 매핑 없으면 기존 흐림 표시. + const sub = p.out ? outToIn?.get(p.name) : undefined; + const lu = side === "away" ? away : home; + const shown = sub + ? lu.bench.find((b) => b.name === sub.inName) ?? { ...p, name: sub.inName, jersey: "" } + : p; + const dimmed = p.out && !sub; + const nGoals = goalsBy?.get(shown.name) ?? 0; + const nAssists = assistsBy?.get(shown.name) ?? 0; + const nOgs = ogBy?.get(shown.name) ?? 0; + const card = cardsBy?.get(shown.name); + return ( +
+ + {shown.jersey || "·"} + + + {nGoals > 0 && {"⚽".repeat(Math.min(nGoals, 3))} } + {nOgs > 0 && ( + + {Array.from({ length: Math.min(nOgs, 2) }, (_, i) => ( + + ))}{" "} + + )} + {nAssists > 0 && 👟 } + {card && ( + {"🟨".repeat(Math.min(card.y, 2))}{card.red ? "🟥" : ""} + )} + {lastName(shown.name)} + {sub && ▲{sub.clock}} + {dimmed && } + +
+ ); + })} +
+ ); +} + +// 교체 명단 — 교체 아웃된 선수(▼분)를 먼저, 이어서 미투입 벤치. +// 투입된 선수는 피치 위로 올라가므로 여기서는 제외. +function BenchList({ + lu, + subClock, + goalsBy, + assistsBy, + ogBy, + cardsBy, + right = false, +}: { + lu: SoccerLineup; + subClock: Map; + goalsBy?: Map; + assistsBy?: Map; + ogBy?: Map; + cardsBy?: Map; + right?: boolean; +}) { + const rows = [ + ...lu.starters.filter((p) => p.out).map((p) => ({ p, kind: "out" as const })), + ...lu.bench.filter((p) => !p.in).map((p) => ({ p, kind: "bench" as const })), + ]; + return ( +
    + {rows.map(({ p, kind }, i) => { + const clock = subClock.get(p.name); + const mark = + kind === "out" ? ( + ▼{clock ? ` ${clock}` : ""} + ) : null; + // 교체 아웃 전 기록한 득점·자책골·도움·카드도 표시 + const nGoals = goalsBy?.get(p.name) ?? 0; + const nAssists = assistsBy?.get(p.name) ?? 0; + const nOgs = ogBy?.get(p.name) ?? 0; + const card = cardsBy?.get(p.name); + const feats = ( + <> + {nGoals > 0 && {"⚽".repeat(Math.min(nGoals, 3))} } + {nOgs > 0 && ( + + {Array.from({ length: Math.min(nOgs, 2) }, (_, i) => ( + + ))}{" "} + + )} + {nAssists > 0 && 👟 } + {card && ( + {"🟨".repeat(Math.min(card.y, 2))}{card.red ? "🟥" : ""} + )} + + ); + return ( +
  • + {right ? ( + <> + {mark} + {feats} + {p.name}{" "} + + {p.jersey}{p.pos ? ` ${p.pos}` : ""} + + + ) : ( + <> + + {p.jersey}{p.pos ? ` ${p.pos}` : ""} + {" "} + {feats} + {p.name} + {mark} + + )} +
  • + ); + })} +
+ ); +} diff --git a/frontend/src/components/MatchupHUD.tsx b/frontend/src/components/MatchupHUD.tsx index 1e64931..56a85b7 100644 --- a/frontend/src/components/MatchupHUD.tsx +++ b/frontend/src/components/MatchupHUD.tsx @@ -1,7 +1,8 @@ -import type { Match } from "@/lib/types"; +import type { Match, TeamStanding } from "@/lib/types"; import { kickoffDisplay } from "@/lib/format"; import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n"; import BallIcon from "./BallIcon"; +import { FormBadges } from "./ScheduleBoard"; import TeamFlag from "./TeamFlag"; import WaveStrip from "./WaveStrip"; @@ -17,13 +18,36 @@ function standingLine( return `${rank} · ${rec}${st.wra ? ` (${st.wra})` : ""}`; } +// MLS 순위 캐시 → "동부 3위 · 8승2무4패 (승점 26)" 한 줄 +function mlsStandingLine(st: TeamStanding & { div?: string | null } | null | undefined, lang: Lang): string | null { + if (!st?.rank) return null; + const conf = + st.div === "EAST" + ? lang === "en" ? "East" : "동부" + : st.div === "WEST" + ? lang === "en" ? "West" : "서부" + : ""; + const rank = lang === "en" ? `#${st.rank}` : `${st.rank}위`; + const rec = + st.w != null && st.l != null + ? lang === "en" + ? ` · ${st.w}W-${st.d ?? 0}D-${st.l}L` + : ` · ${st.w}승${st.d ?? 0}무${st.l}패` + : ""; + const pts = st.pts != null ? (lang === "en" ? ` (${st.pts}pts)` : ` (승점 ${st.pts})`) : ""; + return `${conf} ${rank}${rec}${pts}`.trim(); +} + export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?: Lang }) { const t = dict(lang); const { group } = match; const finished = !!match.result; const isBaseball = match.league === "kbo" || match.league === "mlb"; - // 축구: 한국을 항상 왼쪽에. 야구: A=원정(좌), B=홈(우) 고정. - const flip = !isBaseball && match.teamB.code === "KOR" && match.teamA.code !== "KOR"; + const isMls = match.league === "mls"; + const isClub = isBaseball || isMls; // 클럽 리그 공통 분기 (로고·venue 등) + // 월드컵: 한국을 항상 왼쪽에. 야구: 원정-홈. MLS: 홈-원정 (축구 관례 — 화면만 교체). + const flip = + isMls || (!isClub && match.teamB.code === "KOR" && match.teamA.code !== "KOR"); const left = flip ? match.teamB : match.teamA; const right = flip ? match.teamA : match.teamB; const leftScore = flip ? match.result?.scoreB : match.result?.scoreA; @@ -36,7 +60,11 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang? const stR = standingLine(ex?.standings?.b, lang); const vs = ex?.seasonVs; const hasVs = !!vs && vs.aWin != null && vs.bWin != null; - const flagCls = isBaseball ? "mx-auto h-[72px] w-[72px]" : "mx-auto h-[68px] w-[104px]"; + // MLS 프리뷰 (폼·시즌 전적·순위·맞대결) — 왼쪽=홈(teamB) 기준으로 교체 + const mlsStL = mlsStandingLine(ex?.standings?.b, lang); + const mlsStR = mlsStandingLine(ex?.standings?.a, lang); + const h2h = ex?.h2h ?? []; + const flagCls = isClub ? "mx-auto h-[72px] w-[72px]" : "mx-auto h-[68px] w-[104px]"; return (
@@ -45,8 +73,8 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang? className="relative rounded-3xl border-2 border-[var(--green)] bg-[#171b21] p-5" style={{ boxShadow: "0 0 28px rgba(74,255,160,0.35), inset 0 0 24px rgba(74,255,160,0.06)" }} > - {/* 파도타기 마스코트 — 축구 전용 (야구는 미노출) */} - {!isBaseball && ( + {/* 파도타기 마스코트 — 월드컵 전용 (야구·MLS 미노출) */} + {!isClub && (
@@ -66,9 +94,9 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
{match.venue} - {isBaseball && ( + {isClub && ( - {" "}· {lang === "en" ? "Home: " : "홈 "}{teamShort(right, lang)} + {" "}· {lang === "en" ? "Home: " : "홈 "}{teamShort(match.teamB, lang)} )}
@@ -167,6 +195,57 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
)} + {/* MLS 정보 패널: 최근 5경기 폼 · 컨퍼런스 순위 · 최근 맞대결 (캐시 있을 때만) */} + {isMls && (ex?.formA || ex?.formB || mlsStL || mlsStR || h2h.length > 0) && ( +
+ {(ex?.formA || ex?.formB) && ( +
+
+ + {ex?.recordB && ( + {ex.recordB} + )} +
+ + {lang === "en" ? "Last 5 · W-L-D" : "최근 5경기 · 시즌"} + +
+ {ex?.recordA && ( + {ex.recordA} + )} + +
+
+ )} + + {(mlsStL || mlsStR) && ( +
+ {mlsStL ?? "—"} + {lang === "en" ? "STANDINGS" : "순위"} + {mlsStR ?? "—"} +
+ )} + + {h2h.length > 0 && ( +
+
+ {lang === "en" ? "RECENT H2H" : "최근 맞대결"} +
+
    + {h2h.slice(0, 3).map((g, i) => ( +
  • + {g.date.slice(5).replace("-", ".")}{" "} + {g.home} {g.scoreH} + : + {g.scoreA} {g.away} +
  • + ))} +
+
+ )} +
+ )} + {finished && (
{t.matchEnded} diff --git a/frontend/src/components/ScheduleBoard.tsx b/frontend/src/components/ScheduleBoard.tsx index 9c6d661..e002d56 100644 --- a/frontend/src/components/ScheduleBoard.tsx +++ b/frontend/src/components/ScheduleBoard.tsx @@ -379,7 +379,30 @@ function Chip({ ); } -// 팀명 옆 순위 배지 (야구 — 순위 캐시 없으면 렌더 안 함) +// MLS 최근 5경기 폼 — "WWLDW" 를 W/D/L 색으로 표시 +export function FormBadges({ form }: { form?: string | null }) { + if (!form) return ?; + return ( + + {form.split("").map((c, i) => ( + + {c} + + ))} + + ); +} + +// 팀명 옆 순위 배지 (야구·MLS — 순위 캐시 없으면 렌더 안 함) function RankBadge({ rank, lang = "ko" }: { rank?: number | null; lang?: Lang }) { if (!rank) return null; return ( @@ -402,11 +425,13 @@ function MatchCard({ const phase = match.phase; const finished = !!match.result; const isBaseball = match.league === "kbo" || match.league === "mlb"; + const isMls = match.league === "mls"; + const isClub = isBaseball || isMls; // 클럽 리그 공통 UI (원정-홈 고정·로고·순위 뱃지) - // 야구 진행 중: 회차·스코어 표시 (서버 15초 캐시라 부담 없음, 30초 폴링) + // 진행 중: 회차(야구)/경기 분(MLS)·스코어 표시 (서버 15초 캐시, 30초 폴링) const [live, setLive] = useState(null); useEffect(() => { - if (phase !== "live" || !isBaseball) return; + if (phase !== "live" || !isClub) return; let alive = true; const load = () => getLive(match.matchId) @@ -420,21 +445,27 @@ function MatchCard({ alive = false; clearInterval(id); }; - }, [match.matchId, phase, isBaseball]); - // "6회초" / "Top 6" - const inning = - live?.inn != null + }, [match.matchId, phase, isClub]); + // 야구 "6회초" / "Top 6" · MLS 경기 분 "45'" + const liveBadge = isMls + ? live?.clock || null + : live?.inn != null ? lang === "en" ? `${live.half === "B" ? "Bot" : "Top"} ${live.inn}` : `${live.inn}회${live.half === "B" ? "말" : "초"}` : null; - // 한국을 항상 왼쪽에 표시(상세 페이지와 동일 규칙). 데이터는 원본 A/B 유지, 화면 좌우만 교체. - const flip = !isBaseball && match.teamB.code === "KOR" && match.teamA.code !== "KOR"; + // 킥오프 시각은 지났지만 소스가 아직 시작 전(state=pre)이라는 경기 — 지연 등 + const notStarted = isMls && phase === "live" && live?.state === "pre"; + // 월드컵: 한국을 항상 왼쪽에. MLS: 홈 팀을 왼쪽에(축구 관례). 데이터는 원본 A/B 유지, 화면 좌우만 교체. + const flip = + isMls || (!isClub && match.teamB.code === "KOR" && match.teamA.code !== "KOR"); const left = flip ? match.teamB : match.teamA; const right = flip ? match.teamA : match.teamB; const leftScore = flip ? match.result?.scoreB : match.result?.scoreA; const rightScore = flip ? match.result?.scoreA : match.result?.scoreB; - const flagCls = isBaseball ? "h-7 w-7 shrink-0" : "h-6 w-9 shrink-0"; + const leftSt = flip ? match.extras?.standings?.b : match.extras?.standings?.a; + const rightSt = flip ? match.extras?.standings?.a : match.extras?.standings?.b; + const flagCls = isClub ? "h-7 w-7 shrink-0" : "h-6 w-9 shrink-0"; // 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동 const inner = ( @@ -445,14 +476,14 @@ function MatchCard({ {shortDate(dateKey(match.kickoffKst), lang)} )} {timeOnly(match.kickoffKst)} KST - {isBaseball && match.venue ? ( + {isClub && match.venue ? ( · {match.venue} ) : match.group && match.roundLabel ? ( <> · {tRound(match.roundLabel, lang)} ) : null} - {!isBaseball && ( + {!isClub && ( {match.group ? lang === "en" @@ -461,8 +492,16 @@ function MatchCard({ : tRound(match.roundLabel, lang)} )} - - {phase === "live" && inning ? inning : t.phase[phase]} + + {notStarted + ? lang === "en" ? "Not started" : "시작 전" + : phase === "live" && liveBadge + ? liveBadge + : t.phase[phase]}
@@ -471,7 +510,7 @@ function MatchCard({
{teamShort(left, lang)} - {isBaseball && } + {isClub && }
{match.result ? ( @@ -480,17 +519,17 @@ function MatchCard({ {rightScore} ) : phase === "live" && live?.score ? ( - // 진행 중 실시간 스코어 (A=원정, B=홈) + // 진행 중 실시간 스코어 — flip 시(MLS) 홈 먼저 - {live.score.away ?? 0} + {(flip ? live.score.home : live.score.away) ?? 0} : - {live.score.home ?? 0} + {(flip ? live.score.away : live.score.home) ?? 0} ) : ( VS )}
- {isBaseball && } + {isClub && } {teamShort(right, lang)}
@@ -506,6 +545,16 @@ function MatchCard({
)} + {/* MLS: 최근 5경기 폼 (프리뷰 캐시 있을 때만) — 홈(왼쪽) 먼저 */} + {isMls && !match.result && (match.extras?.formA || match.extras?.formB) && ( +
+ {lang === "en" ? "Last 5" : "최근 5경기"}{" "} + + vs + +
+ )} + {/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 (취소 경기는 숨김) */} {match.votable && phase !== "cancelled" && (
@@ -556,9 +605,12 @@ function aiSplit(match: Match, lang: Lang): string[] { else if (p.outcome === "DRAW") tally.d++; else tally.b++; } - const out: string[] = []; - if (tally.a) out.push(`${teamShort(match.teamA, lang)} ${tally.a}`); - if (tally.d) out.push(`${t.draw} ${tally.d}`); - if (tally.b) out.push(`${teamShort(match.teamB, lang)} ${tally.b}`); + const entA = tally.a ? `${teamShort(match.teamA, lang)} ${tally.a}` : null; + const entD = tally.d ? `${t.draw} ${tally.d}` : null; + const entB = tally.b ? `${teamShort(match.teamB, lang)} ${tally.b}` : null; + // MLS 는 홈 팀 먼저 (카드 좌우 표시와 동일 순서) + const out = (match.league === "mls" ? [entB, entD, entA] : [entA, entD, entB]).filter( + (x): x is string => !!x, + ); return out.length ? out : ["-"]; } diff --git a/frontend/src/components/StandingsTable.tsx b/frontend/src/components/StandingsTable.tsx index cb5374a..62b272b 100644 --- a/frontend/src/components/StandingsTable.tsx +++ b/frontend/src/components/StandingsTable.tsx @@ -4,7 +4,7 @@ import { type Lang, teamShort } from "@/lib/i18n"; import type { StandingRow, StandingsOut } from "@/lib/types"; import TeamFlag from "./TeamFlag"; -// 야구 리그 순위표 — KBO: 단일 테이블 · MLB: 디비전 6그룹. +// 리그 순위표 — KBO: 단일 테이블 · MLB: 디비전 6그룹 · MLS: 컨퍼런스 2그룹(승점제). // 데이터는 워커가 캐싱한 시즌 순위(/api/standings)로, 하루 수회 갱신된다. const DIV_LABEL: Record = { ALE: { ko: "AL 동부", en: "AL East" }, @@ -13,6 +13,8 @@ const DIV_LABEL: Record = { NLE: { ko: "NL 동부", en: "NL East" }, NLC: { ko: "NL 중부", en: "NL Central" }, NLW: { ko: "NL 서부", en: "NL West" }, + EAST: { ko: "동부 컨퍼런스", en: "Eastern Conference" }, + WEST: { ko: "서부 컨퍼런스", en: "Western Conference" }, }; function fmtWra(v: StandingRow["wra"]): string { @@ -71,7 +73,8 @@ export default function StandingsTable({ ); } - const hasDraw = league === "kbo"; // KBO 만 무승부 존재 + const hasDraw = league === "kbo" || league === "mls"; // 무승부 존재 리그 + const isPoints = league === "mls"; // 승점제 (승률/게임차 대신 승점/득실차) return (
@@ -87,13 +90,25 @@ export default function StandingsTable({ {lang === "en" ? "#" : "순위"} {lang === "en" ? "Team" : "팀"} + {isPoints && ( + {lang === "en" ? "GP" : "경기"} + )} {lang === "en" ? "W" : "승"} {hasDraw && ( {lang === "en" ? "D" : "무"} )} {lang === "en" ? "L" : "패"} - {lang === "en" ? "PCT" : "승률"} - {lang === "en" ? "GB" : "게임차"} + {isPoints ? ( + <> + {lang === "en" ? "Pts" : "승점"} + {lang === "en" ? "GD" : "득실"} + + ) : ( + <> + {lang === "en" ? "PCT" : "승률"} + {lang === "en" ? "GB" : "게임차"} + + )} @@ -112,13 +127,25 @@ export default function StandingsTable({ {teamShort(r, lang)} + {isPoints && ( + {r.gp ?? "-"} + )} {r.w ?? "-"} {hasDraw && ( {r.d ?? "-"} )} {r.l ?? "-"} - {fmtWra(r.wra)} - {fmtGb(r.gb)} + {isPoints ? ( + <> + {r.pts ?? "-"} + {r.diff ?? "-"} + + ) : ( + <> + {fmtWra(r.wra)} + {fmtGb(r.gb)} + + )} ))} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index debeb89..cc5d68f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -60,6 +60,22 @@ export interface LiveBatter { sub?: boolean; } +export interface LiveGoal { + clock: string; // "9'" + team: string; // 팀 코드 + player: string; + assist?: string; // 어시스트 선수 (없으면 "") + ownGoal?: boolean; + penalty?: boolean; +} + +export interface LiveCard { + clock: string; + team: string; // 팀 코드 + player: string; + red?: boolean; // true=레드(퇴장), false=옐로 +} + export interface LiveData { available: boolean; inn?: number; @@ -72,6 +88,46 @@ export interface LiveData { vsRecord?: string; defense?: { pos?: string; name: string }[]; offenseLineup?: LiveBatter[]; + // MLS(축구) 라이브 — soccer=true 일 때만 + soccer?: boolean; + clock?: string; // "45'" + period?: number; // 1=전반 2=후반 + state?: string; // pre | in | post + goals?: LiveGoal[]; + cards?: LiveCard[]; + subs?: LiveSub[]; + lineups?: { + away?: SoccerLineup; + home?: SoccerLineup; + } | null; + commentary?: LiveCommentary[]; // 문자중계 (ESPN 영문, 최신순) +} + +export interface LiveCommentary { + clock: string; + text: string; + goal?: boolean; +} + +export interface LiveSub { + clock: string; + team: string; + inName: string; + outName: string; +} + +export interface SoccerPlayer { + name: string; + pos: string; + jersey: string; + in: boolean; // 교체 투입됨 + out: boolean; // 교체 아웃됨 +} + +export interface SoccerLineup { + formation?: string | null; + starters: SoccerPlayer[]; + bench: SoccerPlayer[]; } export function getLive(matchId: string): Promise { diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index 9bd20ed..3a8a482 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -229,8 +229,8 @@ export const DICT: Record = { "Independent AI prediction game — not affiliated with, endorsed by, or sponsored by FIFA or the official World Cup.", footerDisc1: "FIFA 및 공식 월드컵과 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).", footerNotOfficialBB: - "Independent AI prediction game — not affiliated with, endorsed by, or sponsored by KBO, MLB, or any club.", - footerDisc1BB: "KBO·MLB 리그 및 각 구단과 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).", + "Independent AI prediction game — not affiliated with, endorsed by, or sponsored by KBO, MLB, MLS, or any club.", + footerDisc1BB: "KBO·MLB·MLS 리그 및 각 구단과 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).", footerDisc2: "스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.", footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.", hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`, @@ -337,7 +337,7 @@ export const DICT: Record = { "Independent AI prediction game — not affiliated with, endorsed by, or sponsored by FIFA or the official World Cup.", footerDisc1: "A fan-run prediction game using public match schedules; all picks are independent.", footerNotOfficialBB: - "Independent AI prediction game — not affiliated with, endorsed by, or sponsored by KBO, MLB, or any club.", + "Independent AI prediction game — not affiliated with, endorsed by, or sponsored by KBO, MLB, MLS, or any club.", footerDisc1BB: "A fan-run prediction game using public match schedules; all picks are independent.", footerDisc2: "A sports-analysis & entertainment prediction game. No betting or gambling. AI predictions do not guarantee real outcomes.", footerDisc3: "The ₩1,000,000 event is a free-to-enter challenge. Payout & tie-break terms follow separate rules. Email is used only for result & event alerts.", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 30470ca..2c08171 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -42,8 +42,8 @@ export interface CrowdStats { teamBWin: number; } -// 리그: wc(월드컵 축구) | kbo | mlb -export type League = "wc" | "kbo" | "mlb"; +// 리그: wc(월드컵 축구) | kbo | mlb | mls +export type League = "wc" | "kbo" | "mlb" | "mls"; // 야구 부가정보 (프리뷰·순위 캐시 — 없으면 undefined) export interface StarterInfo { @@ -63,11 +63,18 @@ export interface TeamStanding { wra?: string | number; gb?: string | number; last5?: string | null; + // MLS(승점제) 전용 + gp?: number; + pts?: number; + gf?: number; + ga?: number; + diff?: string | null; } // /standings 응답 — 팀 정보(Team) + 시즌 성적 한 행 export interface StandingRow extends Team, TeamStanding { - div?: string | null; // MLB 디비전 키 (ALE/ALC/ALW/NLE/NLC/NLW) + // MLB 디비전(ALE/ALC/ALW/NLE/NLC/NLW) 또는 MLS 컨퍼런스(EAST/WEST) 키 + div?: string | null; } export interface StandingsOut { @@ -81,6 +88,18 @@ export interface MatchExtras { starterB?: StarterInfo | null; seasonVs?: { aWin?: number; draw?: number; bWin?: number } | null; standings?: { a?: TeamStanding | null; b?: TeamStanding | null }; + // MLS 프리뷰 (ESPN) — 최근5 폼("WWLDW")·시즌 전적("8-2-4")·맞대결 + formA?: string | null; + formB?: string | null; + recordA?: string | null; + recordB?: string | null; + h2h?: { + date: string; + home: string; + away: string; + scoreH: number; + scoreA: number; + }[]; } export interface Match { diff --git a/frontend/src/lib/useLeague.ts b/frontend/src/lib/useLeague.ts index de30a65..524116b 100644 --- a/frontend/src/lib/useLeague.ts +++ b/frontend/src/lib/useLeague.ts @@ -9,7 +9,9 @@ export function useLeague(): [League, (l: League) => void] { const [params, setParams] = useSearchParams(); const raw = params.get("league"); const league: League = - raw === "wc" || raw === "kbo" || raw === "mlb" ? raw : DEFAULT_LEAGUE; + raw === "wc" || raw === "kbo" || raw === "mlb" || raw === "mls" + ? raw + : DEFAULT_LEAGUE; const setLeague = (l: League) => { const next = new URLSearchParams(params); next.set("league", l); diff --git a/frontend/src/pages/Leaderboard.tsx b/frontend/src/pages/Leaderboard.tsx index 47acaf0..83e6386 100644 --- a/frontend/src/pages/Leaderboard.tsx +++ b/frontend/src/pages/Leaderboard.tsx @@ -9,8 +9,8 @@ import { useLeague } from "@/lib/useLeague"; export default function Leaderboard() { const lang = useLang(); const [league] = useLeague(); - // 야구(KBO/MLB)는 랭킹 운영 방침(상금 등) 미확정 — 임시 비노출 - const isBaseball = league === "kbo" || league === "mlb"; + // 월드컵 외 리그(KBO/MLB/MLS)는 랭킹 운영 방침(상금 등) 미확정 — 임시 비노출 + const isBaseball = league !== "wc"; return (
diff --git a/frontend/src/pages/MatchDetail.tsx b/frontend/src/pages/MatchDetail.tsx index 250b63f..5c66a39 100644 --- a/frontend/src/pages/MatchDetail.tsx +++ b/frontend/src/pages/MatchDetail.tsx @@ -3,6 +3,7 @@ import { Link, useParams } from "react-router-dom"; import Hero from "@/components/Hero"; import MatchupHUD from "@/components/MatchupHUD"; import LiveField from "@/components/LiveField"; +import LiveSoccer from "@/components/LiveSoccer"; import Arena from "@/components/Arena"; import Comments from "@/components/Comments"; import Footer from "@/components/Footer"; @@ -58,11 +59,17 @@ export default function MatchDetail() { const aShort = teamShort(match.teamA, lang); const bShort = teamShort(match.teamB, lang); - // 한국을 항상 왼쪽에 표시(요청)와 동일하게 공유 제목/후킹 카피도 한국 먼저. - const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR"; + // 월드컵: 한국을 항상 왼쪽에. MLS: 홈 팀 먼저(축구 관례) — 제목/후킹 카피 동일 순서. + const isMls = match.league === "mls"; + const flip = isMls || (match.teamB.code === "KOR" && match.teamA.code !== "KOR"); const leftShort = flip ? bShort : aShort; const rightShort = flip ? aShort : bShort; - const hook = lang === "en" ? t.hook(leftShort, rightShort) : match.hookText; + const hook = + lang === "en" + ? t.hook(leftShort, rightShort) + : isMls + ? `${leftShort} vs ${rightShort}` // 저장된 hookText 는 원정-홈 순이라 재구성 + : match.hookText; const url = matchUrl(match.matchId); return ( @@ -90,6 +97,8 @@ export default function MatchDetail() { + {/* MLS 라이브 (축구) — 경기중에만 렌더, 야구 리그에선 null */} +