feat(mls): MLS 리그 추가 — ESPN 비공식 API 연동

- 일정·결과·컨퍼런스 순위·프리뷰(폼/시즌전적/맞대결/머니라인) 수집
- 라이브 카드: 스코어·경기시간·득점/카드·선발 라인업(피치 뷰)·교체 현황
- 문자중계: ESPN commentary 규칙 기반 한글 변환 (미지원 템플릿은 팀명만 한글화)
- MLS 레코드는 야구와 키 구조가 같아 sync_baseball_schedule/settle 재사용
- AI 예측 데이터 블록에 MLS 전용 컨텍스트 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-14 13:46:37 +09:00
parent ad5bbec82e
commit 74de57fc1c
22 changed files with 1723 additions and 83 deletions

View File

@ -87,9 +87,9 @@ class Settings(BaseSettings):
return "football-data" return "football-data"
return self.schedule_source.lower() return self.schedule_source.lower()
# ── 멀티리그 (wc=월드컵 축구 · kbo · mlb) ──────────────── # ── 멀티리그 (wc=월드컵 축구 · kbo · mlb · mls) ──────────
# 활성 리그 (콤마구분). 야구 리그는 워커가 각자 소스에서 일정·결과를 동기화. # 활성 리그 (콤마구분). 야구·MLS 리그는 워커가 각자 소스에서 일정·결과를 동기화.
leagues: str = "wc,kbo,mlb" leagues: str = "wc,kbo,mlb,mls"
# 야구 일정 수집 윈도우 — 오늘 기준 미래 며칠치. # 야구 일정 수집 윈도우 — 오늘 기준 미래 며칠치.
# (투표 오픈·AI 예측 생성은 리그 공통: vote_open_hours_before / ai_generate_lookahead_hours) # (투표 오픈·AI 예측 생성은 리그 공통: vote_open_hours_before / ai_generate_lookahead_hours)
baseball_days_ahead: int = 7 baseball_days_ahead: int = 7
@ -97,6 +97,9 @@ class Settings(BaseSettings):
naver_api_base: str = "https://api-gw.sports.naver.com" naver_api_base: str = "https://api-gw.sports.naver.com"
# MLB 공식 Stats API (키 불필요). # MLB 공식 Stats API (키 불필요).
mlb_api_base: str = "https://statsapi.mlb.com/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 @property
def league_list(self) -> list[str]: def league_list(self) -> list[str]:

View File

@ -57,10 +57,14 @@ async def get_live(
match_id: str, match_id: str,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> dict: ) -> dict:
"""야구 라이브 필드 뷰 (kbo=네이버 relay, mlb=공식 feed/live). 15초 TTL 캐시.""" """라이브 뷰 (kbo=네이버 relay, mlb=공식 feed/live, mls=ESPN). 15초 TTL 캐시."""
m = await db.get(Match, match_id) m = await db.get(Match, match_id)
if not m: if not m:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") 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"): if m.league not in ("kbo", "mlb"):
return {"available": False} return {"available": False}
return await fetch_live(m) return await fetch_live(m)

View File

@ -1,6 +1,7 @@
"""리그 순위표 API — 워커가 캐싱한 standings:{league} 를 팀 정보와 합쳐 서빙. """리그 순위표 API — 워커가 캐싱한 standings:{league} 를 팀 정보와 합쳐 서빙.
KBO: 단일 테이블(10팀, 순위순). MLB: 디비전(AL/NL × 동·중·서) 6그룹. KBO: 단일 테이블(10팀, 순위순). MLB: 디비전(AL/NL × 동·중·서) 6그룹.
MLS: 컨퍼런스(동/서부) 2그룹 — 승점제.
캐시가 아직 없으면 빈 groups 를 반환한다(프론트는 안내 문구 표시). 캐시가 아직 없으면 빈 groups 를 반환한다(프론트는 안내 문구 표시).
""" """
from __future__ import annotations from __future__ import annotations
@ -16,14 +17,16 @@ router = APIRouter(prefix="/api/standings", tags=["standings"])
# MLB 디비전 표시 순서 (AL 동→중→서, NL 동→중→서) # MLB 디비전 표시 순서 (AL 동→중→서, NL 동→중→서)
_MLB_DIV_ORDER = ["ALE", "ALC", "ALW", "NLE", "NLC", "NLW"] _MLB_DIV_ORDER = ["ALE", "ALC", "ALW", "NLE", "NLC", "NLW"]
# MLS 컨퍼런스 표시 순서 (동부 → 서부)
_MLS_CONF_ORDER = ["EAST", "WEST"]
@router.get("") @router.get("")
async def get_standings( async def get_standings(
league: str = Query(..., description="kbo | mlb"), league: str = Query(..., description="kbo | mlb | mls"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> dict: ) -> dict:
if league not in ("kbo", "mlb"): if league not in ("kbo", "mlb", "mls"):
return {"league": league, "updatedAt": None, "groups": []} return {"league": league, "updatedAt": None, "groups": []}
row = await db.get(DataCache, f"standings:{league}") row = await db.get(DataCache, f"standings:{league}")
table: dict = row.payload if row else {} table: dict = row.payload if row else {}
@ -32,6 +35,15 @@ async def get_standings(
if league == "kbo": if league == "kbo":
rows.sort(key=lambda r: r.get("rank") or 99) rows.sort(key=lambda r: r.get("rank") or 99)
groups = [{"key": None, "rows": rows}] if rows else [] 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: else:
by_div: dict[str, list] = {} by_div: dict[str, list] = {}
for r in rows: for r in rows:

View File

@ -31,7 +31,7 @@ class MatchContext:
venue: str venue: str
kickoff: str # ISO kickoff: str # ISO
data_block: str | None = None # 실데이터(폼·H2H·랭킹 등) 주입 블록. 없으면 이름만. 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: def _build_prompt(ctx: MatchContext, model: str) -> str:
"""리그별 프롬프트 선택 — 야구(kbo/mlb)는 야구 프롬프트, 그 외 축구.""" """리그별 프롬프트 선택 — 야구(kbo/mlb)는 야구, mls 는 MLS 축구, 그 외 월드컵."""
if ctx.league in ("kbo", "mlb"): if ctx.league in ("kbo", "mlb"):
return _prompt_baseball(ctx, model) return _prompt_baseball(ctx, model)
if ctx.league == "mls":
return _prompt_mls(ctx, PERSONA[model])
return _prompt(ctx, PERSONA[model]) return _prompt(ctx, PERSONA[model])

View File

@ -329,11 +329,12 @@ async def refresh_baseball_details(db, league: str, matches: list[Match]) -> Non
# ── extras 조회 (라우터 — 캐시만) ────────────────────────────── # ── extras 조회 (라우터 — 캐시만) ──────────────────────────────
# MLS 도 동일 캐시 키(preview:{id}, standings:mls)를 쓰므로 여기서 함께 서빙.
async def get_extras(db, matches: list[Match]) -> dict[str, dict]: async def get_extras(db, matches: list[Match]) -> dict[str, dict]:
standings_cache: dict[str, dict] = {} standings_cache: dict[str, dict] = {}
out: dict[str, dict] = {} out: dict[str, dict] = {}
for m in matches: for m in matches:
if m.league not in ("kbo", "mlb"): if m.league not in ("kbo", "mlb", "mls"):
continue continue
if m.league not in standings_cache: if m.league not in standings_cache:
row = await db.get(DataCache, f"standings:{m.league}") row = await db.get(DataCache, f"standings:{m.league}")

View File

@ -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

View File

@ -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]: 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 return KBO_TEAMS if league == "kbo" else MLB_TEAMS
def team_info(league: str, code: str) -> dict: def team_info(league: str, code: str) -> dict:
"""flag 에 로고 경로/URL 을 실어 프론트(TeamFlag)가 그대로 렌더한다. """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() c = (code or "").strip().upper()
t = teams_of(league).get(c) t = teams_of(league).get(c)
if not t: if not t:
return {"name": c, "shortName": c, "code": c, "flag": ""} return {"name": c, "shortName": c, "code": c, "flag": ""}
if league == "mlb": if league == "mlb":
flag = f"https://www.mlbstatic.com/team-logos/{t['mlb_id']}.svg" 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: else:
flag = f"/assets/teams/kbo/{c.lower()}.png" flag = f"/assets/teams/kbo/{c.lower()}.png"
return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": flag} return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": flag}

49
backend/app/teams_mls.py Normal file
View File

@ -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 ""

View File

@ -30,7 +30,7 @@ from .database import SessionLocal, init_db
from .domain import compute_phase, ensure_aware, now_utc from .domain import compute_phase, ensure_aware, now_utc
from .models import AIPrediction, Match, UserPrediction from .models import AIPrediction, Match, UserPrediction
from .scoring import load_scoring_data 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.ai import MatchContext, PROVIDERS, ProviderUnavailable
from .services.baseball_fetch import fetch_baseball_results, fetch_baseball_schedule from .services.baseball_fetch import fetch_baseball_results, fetch_baseball_schedule
from .services.baseball_sync import match_seq, sync_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: async def sync_baseball_job() -> None:
"""야구(kbo/mlb) 일정 동기화 + 프리뷰·순위 캐시 갱신.""" """야구(kbo/mlb)+MLS 일정 동기화 + 프리뷰·순위 캐시 갱신.
MLS 도 (리그, KST 날짜, 팀쌍, 차수) 키 구조가 동일해 야구 sync 를 그대로 탄다.
"""
inserted_any = False inserted_any = False
for league in settings.league_list: for league in settings.league_list:
if league not in ("kbo", "mlb"): if league not in ("kbo", "mlb", "mls"):
continue 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: if not records:
continue continue
async with SessionLocal() as db: async with SessionLocal() as db:
@ -80,9 +87,12 @@ async def sync_baseball_job() -> None:
) )
).scalars().all() ).scalars().all()
try: 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 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")) inserted_any = inserted_any or bool(result.get("inserted"))
await tick_status() await tick_status()
if inserted_any: if inserted_any:
@ -139,9 +149,12 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
] ]
for m in matches: for m in matches:
# 실데이터 블록 — 리그별 소스(축구=API-Football 캐시, 야구=자체DB+프리뷰). # 실데이터 블록 — 리그별 소스(축구=API-Football 캐시, 야구=자체DB+프리뷰,
# MLS=자체DB+ESPN 프리뷰).
if m.league in ("kbo", "mlb"): if m.league in ("kbo", "mlb"):
data_block = await baseball_data.build_baseball_data_block(db, m) 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: else:
data_block = await football_data.build_data_block(db, m) data_block = await football_data.build_data_block(db, m)
ctx = MatchContext( ctx = MatchContext(
@ -313,12 +326,12 @@ async def settle_matches() -> None:
await maybe_send_result_emails() await maybe_send_result_emails()
# ── 2.6) 야구 결과 자동 정산 — (리그, KST 날짜, 팀쌍) 키 매칭 ── # ── 2.6) 야구·MLS 결과 자동 정산 — (리그, KST 날짜, 팀쌍) 키 매칭 ──
async def settle_baseball() -> None: async def settle_baseball() -> None:
_KST = timedelta(hours=9) _KST = timedelta(hours=9)
now = now_utc() now = now_utc()
for league in settings.league_list: for league in settings.league_list:
if league not in ("kbo", "mlb"): if league not in ("kbo", "mlb", "mls"):
continue continue
async with SessionLocal() as db: async with SessionLocal() as db:
pending = ( 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] due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now]
if not due: if not due:
continue 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]] = {} by_key: dict[tuple, tuple[int, int]] = {}
for r in results: for r in results:
d, a2, b2, s = r["dateKst"], r["teamA"], r["teamB"], r.get("seq", 1) d, a2, b2, s = r["dateKst"], r["teamA"], r["teamB"], r.get("seq", 1)

View File

@ -95,8 +95,11 @@ export default function Arena({
const t = dict(lang); const t = dict(lang);
const aShort = teamShort(match.teamA, lang); const aShort = teamShort(match.teamA, lang);
const bShort = teamShort(match.teamB, lang); const bShort = teamShort(match.teamB, lang);
// 한국을 항상 왼쪽에 표시(요청). 상태·투표 제출은 원본 A/B 프레임 그대로, 화면 좌우만 교체. // 월드컵: 한국을 항상 왼쪽에. MLS: 홈 팀을 왼쪽에(축구 관례).
const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR"; // 상태·투표 제출은 원본 A/B 프레임 그대로, 화면 좌우만 교체.
const flip =
match.league === "mls" ||
(match.teamB.code === "KOR" && match.teamA.code !== "KOR");
const leftShort = flip ? bShort : aShort; const leftShort = flip ? bShort : aShort;
const rightShort = flip ? aShort : bShort; const rightShort = flip ? aShort : bShort;
// 마스코트(사자·버튼 양옆 캐릭터)는 모든 경기에서 노출. // 마스코트(사자·버튼 양옆 캐릭터)는 모든 경기에서 노출.
@ -230,8 +233,8 @@ export default function Arena({
{/* ===== 레이어드 화이트 시트 (002) ===== */} {/* ===== 레이어드 화이트 시트 (002) ===== */}
<section className="sheet relative mt-4 p-5 text-[var(--ink)]"> <section className="sheet relative mt-4 p-5 text-[var(--ink)]">
{/* AI 박스 오른쪽 위 사자 — 축구 전용 */} {/* AI 박스 오른쪽 위 사자 — 월드컵 전용 (야구·MLS 미노출) */}
{!isBaseball && ( {match.league === "wc" && (
<div className="absolute right-1 -top-4 z-10 h-20 sm:h-24"> <div className="absolute right-1 -top-4 z-10 h-20 sm:h-24">
<TalkingMascot <TalkingMascot
src="/assets/mascots/lion_point.webp" src="/assets/mascots/lion_point.webp"
@ -390,7 +393,7 @@ export default function Arena({
)} )}
{/* 투표하기 버튼 — 모든 경기에서 양옆에 캐릭터(자동 말풍선) */} {/* 투표하기 버튼 — 모든 경기에서 양옆에 캐릭터(자동 말풍선) */}
<div className="mt-3.5 flex items-end justify-center gap-1"> <div className="mt-3.5 flex items-end justify-center gap-1">
{!isBaseball && <TalkingMascot {match.league === "wc" && <TalkingMascot
src="/assets/mascots/dog_point.webp" src="/assets/mascots/dog_point.webp"
lines={DOG_LINES} lines={DOG_LINES}
className="h-16 w-auto select-none sm:h-20" className="h-16 w-auto select-none sm:h-20"
@ -405,7 +408,7 @@ export default function Arena({
> >
{submitting ? "…" : <>{t.submit} <span aria-hidden>→</span></>} {submitting ? "…" : <>{t.submit} <span aria-hidden>→</span></>}
</button> </button>
{!isBaseball && <TalkingMascot {match.league === "wc" && <TalkingMascot
src="/assets/mascots/tiger_point.webp" src="/assets/mascots/tiger_point.webp"
lines={TIGER_LINES} lines={TIGER_LINES}
className="h-16 w-auto select-none sm:h-20" className="h-16 w-auto select-none sm:h-20"
@ -502,7 +505,9 @@ export default function Arena({
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{(votesExpanded ? myPreds : myPreds.slice(0, 1)).map((p) => { {(votesExpanded ? myPreds : myPreds.slice(0, 1)).map((p) => {
const isThis = p.matchId === match.matchId; 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 pA = teamShort(p.teamA, lang);
const pB = teamShort(p.teamB, lang); const pB = teamShort(p.teamB, lang);
const pLeft = pFlip ? pB : pA; const pLeft = pFlip ? pB : pA;
@ -566,11 +571,11 @@ export default function Arena({
</section> </section>
)} )}
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 야구(KBO/MLB)는 임시 비노출(운영 방침 미확정) ===== */} {/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 월드컵 외 리그는 임시 비노출(운영 방침 미확정) ===== */}
{!isBaseball && <RankingBoard lang={lang} league={match.league} />} {match.league === "wc" && <RankingBoard lang={lang} league={match.league} />}
{/* ===== 골드 상금 (003) — 야구(KBO/MLB)는 임시 비노출 ===== */} {/* ===== 골드 상금 (003) — 월드컵 외 리그는 임시 비노출 ===== */}
{!isBaseball && ( {match.league === "wc" && (
<section className="gold-card mt-5 rounded-2xl p-5"> <section className="gold-card mt-5 rounded-2xl p-5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">

View File

@ -1,6 +1,6 @@
import { type Lang, dict } from "@/lib/i18n"; import { type Lang, dict } from "@/lib/i18n";
// league 를 주면 비제휴 고지를 리그에 맞게 표시 (야구 = KBO/MLB, 기본 = 월드컵) // league 를 주면 비제휴 고지를 리그에 맞게 표시 (클럽 리그 = KBO/MLB/MLS, 기본 = 월드컵)
export default function Footer({ export default function Footer({
lang = "ko", lang = "ko",
league = "wc", league = "wc",
@ -9,7 +9,7 @@ export default function Footer({
league?: string; league?: string;
}) { }) {
const t = dict(lang); const t = dict(lang);
const isBaseball = league === "kbo" || league === "mlb"; const isBaseball = league !== "wc";
return ( return (
<footer className="mt-6 text-center"> <footer className="mt-6 text-center">
<p className="text-[10.5px] font-semibold text-white/75"> <p className="text-[10.5px] font-semibold text-white/75">

View File

@ -65,7 +65,11 @@ export default function Hero({
? lang === "en" ? lang === "en"
? "AI-powered 2026 MLB game predictions" ? "AI-powered 2026 MLB game predictions"
: "AI와 함께하는 2026 MLB 승부예측 챌린지" : "AI와 함께하는 2026 MLB 승부예측 챌린지"
: t.heroPill} : league === "mls"
? lang === "en"
? "AI-powered 2026 MLS game predictions"
: "AI와 함께하는 2026 MLS 승부예측 챌린지"
: t.heroPill}
</div> </div>
<LeagueTabs lang={lang} current={league} /> <LeagueTabs lang={lang} current={league} />
</header> </header>
@ -79,6 +83,7 @@ function LeagueTabs({ lang, current }: { lang: Lang; current: League }) {
{ key: "wc", label: lang === "en" ? "World Cup" : "월드컵" }, { key: "wc", label: lang === "en" ? "World Cup" : "월드컵" },
{ key: "kbo", label: "KBO" }, { key: "kbo", label: "KBO" },
{ key: "mlb", label: "MLB" }, { key: "mlb", label: "MLB" },
{ key: "mls", label: "MLS" },
]; ];
return ( return (
<div className="mt-3 flex justify-center"> <div className="mt-3 flex justify-center">

View File

@ -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 (
<svg viewBox="0 0 12 12" className="inline h-[11px] w-[11px] align-[-1.5px]">
<circle cx="6" cy="6" r="5.4" fill="#E5484D" stroke="#fff" strokeWidth="0.9" />
<path d="M6 3.1 4.2 4.4l.7 2.1h2.2l.7-2.1z" fill="#fff" />
</svg>
);
}
// 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<LiveData | null>(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,
) => (
<>
<div>
{g.clock} {g.player}
{g.penalty ? " (PK)" : ""}
{g.ownGoal ? " (OG)" : ""}{" "}
{g.ownGoal ? <OgBall /> : "⚽"}
</div>
{g.assist && (
<div className={`text-white/35 ${right ? "pr-4" : "pl-4"}`}>
{g.assist} 👟
</div>
)}
</>
);
// 교체 시각 매핑 (선수명 → 분) — 라인업 옆에 표시
const subClock = new Map<string, string>();
// 나간 선수 → 들어온 선수 매핑 — 피치에서 투입 선수가 그 자리를 대체
const outToIn = new Map<string, { inName: string; clock: string }>();
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<string, number>();
const assistsBy = new Map<string, number>();
const ogBy = new Map<string, number>();
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<string, { y: number; red: boolean }>();
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 (
<section
className={`mt-5 rounded-2xl border bg-[var(--bg2)] p-4 ${
inPlay && !ended ? "border-[#FF5B5B]/40" : "border-[var(--line-d)]"
}`}
>
{/* 헤더: LIVE/경기 기록/선발 라인업 + 경기 시간 */}
<div className="flex items-center justify-between">
{ended ? (
<span className="text-[13px] font-extrabold text-white">
{lang === "en" ? "Match recap" : "경기 기록"}
</span>
) : inPlay ? (
<span className="flex items-center gap-1.5 text-[12px] font-extrabold text-[#FF5B5B]">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-[#FF5B5B]" />
LIVE
</span>
) : (
<span className="text-[13px] font-extrabold text-white">
{lang === "en" ? "Starting lineups" : "선발 라인업"}
</span>
)}
{inPlay && (
<span className="font-mono text-[13px] font-extrabold text-white/80">
{ended ? (
<span className="text-[11px] font-bold text-white/40">
{lang === "en" ? "FT" : "경기 종료"}
</span>
) : (
<>
{live.clock}
{live.period != null && (
<span className="ml-1.5 text-[11px] font-bold text-white/40">
{live.period === 1
? lang === "en" ? "1st half" : "전반"
: live.period === 2
? lang === "en" ? "2nd half" : "후반"
: lang === "en" ? "ET" : "연장"}
</span>
)}
</>
)}
</span>
)}
</div>
{/* 스코어 (경기 중에만) — 홈 팀 먼저 (축구 관례) */}
{inPlay && (
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<span className="truncate text-[15px] font-extrabold">{teamShort(match.teamB, lang)}</span>
<span className="whitespace-nowrap font-mono text-[26px] font-extrabold tabular-nums text-white">
{live.score?.home ?? 0}
<span className="px-2 text-[#FF5B5B]">:</span>
{live.score?.away ?? 0}
</span>
<span className="truncate text-right text-[15px] font-extrabold">
{teamShort(match.teamA, lang)}
</span>
</div>
)}
{/* 득점 이벤트 (양 팀 분리, 시간순) — 좌=홈 / 우=원정 */}
{(awayGoals.length > 0 || homeGoals.length > 0) && (
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-white/10 pt-2.5 text-[11px] font-semibold text-white/60">
<div className="space-y-0.5">
{homeGoals.map((g, i) => (
<div key={i}>{goalLine(g)}</div>
))}
</div>
<div className="space-y-0.5 text-right">
{awayGoals.map((g, i) => (
<div key={i}>{goalLine(g, true)}</div>
))}
</div>
</div>
)}
{/* 문자중계 (최신순) — 세로 스크롤 (다크 스크롤바, 흰 배경 없음) */}
{(live.commentary?.length ?? 0) > 0 && (
<div className="mt-3 border-t border-white/10 pt-2.5">
<div className="mb-1 flex items-baseline justify-between">
<span className="text-[10px] font-bold text-white/35">
{lang === "en" ? "PLAY-BY-PLAY" : "문자중계"}
</span>
<span className="text-[9px] text-white/25">ESPN</span>
</div>
<ul className="max-h-[200px] space-y-1 overflow-y-auto pr-1 text-[11px] leading-snug [scrollbar-width:thin] [scrollbar-color:rgba(255,255,255,.22)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-white/20">
{live.commentary!.map((c, i) => (
<li
key={`${c.clock}-${i}`}
className={`flex gap-1.5 ${
c.goal
? "rounded-lg border border-[#94FBE0]/30 bg-[#94FBE0]/10 px-1.5 py-1"
: ""
}`}
>
<span
className={`w-7 shrink-0 text-right font-mono font-bold ${
c.goal ? "text-[#94FBE0]" : "text-white/40"
}`}
>
{c.clock}
</span>
<span
className={
c.goal ? "font-extrabold text-[#94FBE0]" : "font-medium text-white/65"
}
>
{c.text}
</span>
</li>
))}
</ul>
</div>
)}
{/* 선발 라인업 — 축구장 하나에 반코트씩 (위=홈 · 아래=원정), 교체 아웃은 흐리게 */}
{live.lineups?.away && live.lineups?.home && (
<div className="mt-3 border-t border-white/10 pt-2.5">
<div className="mb-1.5 flex items-center justify-between text-[11px] font-extrabold text-white/85">
<span>
{teamShort(match.teamB, lang)}
{live.lineups.home.formation && (
<span className="ml-1 font-mono text-[10px] font-bold text-white/40">
{live.lineups.home.formation}
</span>
)}
</span>
<span className="text-[10px] font-bold text-white/35">
{lang === "en" ? "STARTING XI" : "선발 라인업"}
</span>
<span className="text-right">
{live.lineups.away.formation && (
<span className="mr-1 font-mono text-[10px] font-bold text-white/40">
{live.lineups.away.formation}
</span>
)}
{teamShort(match.teamA, lang)}
</span>
</div>
<Pitch
away={live.lineups.away}
home={live.lineups.home}
outToIn={outToIn}
goalsBy={goalsBy}
assistsBy={assistsBy}
ogBy={ogBy}
cardsBy={cardsBy}
/>
{/* 교체 명단 (벤치) — 투입된 선수는 ▲분 표시 */}
<button
onClick={() => setShowBench((v) => !v)}
className="mt-2.5 flex w-full items-center justify-center gap-1 rounded-xl border border-[var(--line-d)] py-2 text-[12px] font-bold text-[var(--ink-muted)] transition active:scale-[0.99]"
>
{showBench
? lang === "en" ? "Hide bench" : "교체 명단 접기"
: lang === "en" ? "Show bench" : "교체 명단 보기"}
<span aria-hidden>{showBench ? "▲" : "▼"}</span>
</button>
{showBench && (
<div className="mt-2 grid grid-cols-2 gap-3 text-[11px]">
<BenchList
lu={live.lineups.home}
subClock={subClock}
goalsBy={goalsBy}
assistsBy={assistsBy}
ogBy={ogBy}
cardsBy={cardsBy}
/>
<BenchList
lu={live.lineups.away}
subClock={subClock}
goalsBy={goalsBy}
assistsBy={assistsBy}
ogBy={ogBy}
cardsBy={cardsBy}
right
/>
</div>
)}
</div>
)}
</section>
);
}
// 포메이션 문자열("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<string, number> = {
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<number, SoccerPlayer[]>();
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<string, { inName: string; clock: string }>;
goalsBy?: Map<string, number>;
assistsBy?: Map<string, number>;
ogBy?: Map<string, number>;
cardsBy?: Map<string, { y: number; red: boolean }>;
}) {
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 (
<div
className="relative w-full overflow-hidden rounded-xl border border-white/10"
style={{
aspectRatio: "3 / 4",
background:
"repeating-linear-gradient(0deg, #2e7d46 0 12.5%, #2a7340 12.5% 25%)",
}}
>
{/* 피치 라인: 외곽·센터라인·센터서클·페널티박스 (세로형) */}
<div className="pointer-events-none absolute inset-[2.5%] rounded-sm border border-white/30" />
<div className="pointer-events-none absolute inset-x-[2.5%] top-1/2 h-px -translate-y-1/2 bg-white/30" />
<div className="pointer-events-none absolute left-1/2 top-1/2 w-[22%] aspect-square -translate-x-1/2 -translate-y-1/2 rounded-full border border-white/30" />
<div className="pointer-events-none absolute left-1/2 top-[2.5%] h-[10%] w-[46%] -translate-x-1/2 border border-t-0 border-white/30" />
<div className="pointer-events-none absolute bottom-[2.5%] left-1/2 h-[10%] w-[46%] -translate-x-1/2 border border-b-0 border-white/30" />
{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 (
<div
key={`${side}-${p.name}`}
className={`absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center ${
dimmed ? "opacity-45" : ""
}`}
style={{ left: `${x * 100}%`, top: `${y * 100}%` }}
>
<span
className={`grid h-[24px] w-[24px] place-items-center rounded-full font-mono text-[11px] font-extrabold shadow ${
side === "away"
? "bg-[#0f1216] text-[#94FBE0] ring-1 ring-[#94FBE0]/60"
: "bg-white text-black ring-1 ring-black/20"
}`}
>
{shown.jersey || "·"}
</span>
<span className="mt-0.5 max-w-[96px] truncate rounded bg-black/45 px-1 py-px text-[10px] font-bold leading-tight text-white">
{nGoals > 0 && <span>{"⚽".repeat(Math.min(nGoals, 3))} </span>}
{nOgs > 0 && (
<span>
{Array.from({ length: Math.min(nOgs, 2) }, (_, i) => (
<OgBall key={i} />
))}{" "}
</span>
)}
{nAssists > 0 && <span>👟 </span>}
{card && (
<span>{"🟨".repeat(Math.min(card.y, 2))}{card.red ? "🟥" : ""} </span>
)}
{lastName(shown.name)}
{sub && <span className="text-[#FF7B7B]"> ▲{sub.clock}</span>}
{dimmed && <span className="text-[#ffb3b3]"> ▼</span>}
</span>
</div>
);
})}
</div>
);
}
// 교체 명단 — 교체 아웃된 선수(▼분)를 먼저, 이어서 미투입 벤치.
// 투입된 선수는 피치 위로 올라가므로 여기서는 제외.
function BenchList({
lu,
subClock,
goalsBy,
assistsBy,
ogBy,
cardsBy,
right = false,
}: {
lu: SoccerLineup;
subClock: Map<string, string>;
goalsBy?: Map<string, number>;
assistsBy?: Map<string, number>;
ogBy?: Map<string, number>;
cardsBy?: Map<string, { y: number; red: boolean }>;
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 (
<ul className={`space-y-0.5 ${right ? "text-right" : "text-left"}`}>
{rows.map(({ p, kind }, i) => {
const clock = subClock.get(p.name);
const mark =
kind === "out" ? (
<span className="text-[#FF7B7B]"> ▼{clock ? ` ${clock}` : ""}</span>
) : 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 && <span>{"⚽".repeat(Math.min(nGoals, 3))} </span>}
{nOgs > 0 && (
<span>
{Array.from({ length: Math.min(nOgs, 2) }, (_, i) => (
<OgBall key={i} />
))}{" "}
</span>
)}
{nAssists > 0 && <span>👟 </span>}
{card && (
<span>{"🟨".repeat(Math.min(card.y, 2))}{card.red ? "🟥" : ""} </span>
)}
</>
);
return (
<li
key={`${p.name}-${i}`}
className={`truncate ${kind === "out" ? "text-white/75" : "text-white/45"}`}
>
{right ? (
<>
{mark}
{feats}
<span className="font-semibold">{p.name}</span>{" "}
<span className="font-mono text-[10px] text-white/35">
{p.jersey}{p.pos ? ` ${p.pos}` : ""}
</span>
</>
) : (
<>
<span className="font-mono text-[10px] text-white/35">
{p.jersey}{p.pos ? ` ${p.pos}` : ""}
</span>{" "}
{feats}
<span className="font-semibold">{p.name}</span>
{mark}
</>
)}
</li>
);
})}
</ul>
);
}

View File

@ -1,7 +1,8 @@
import type { Match } from "@/lib/types"; import type { Match, TeamStanding } from "@/lib/types";
import { kickoffDisplay } from "@/lib/format"; import { kickoffDisplay } from "@/lib/format";
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n"; import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
import BallIcon from "./BallIcon"; import BallIcon from "./BallIcon";
import { FormBadges } from "./ScheduleBoard";
import TeamFlag from "./TeamFlag"; import TeamFlag from "./TeamFlag";
import WaveStrip from "./WaveStrip"; import WaveStrip from "./WaveStrip";
@ -17,13 +18,36 @@ function standingLine(
return `${rank} · ${rec}${st.wra ? ` (${st.wra})` : ""}`; 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 }) { export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?: Lang }) {
const t = dict(lang); const t = dict(lang);
const { group } = match; const { group } = match;
const finished = !!match.result; const finished = !!match.result;
const isBaseball = match.league === "kbo" || match.league === "mlb"; const isBaseball = match.league === "kbo" || match.league === "mlb";
// 축구: 한국을 항상 왼쪽에. 야구: A=원정(좌), B=홈(우) 고정. const isMls = match.league === "mls";
const flip = !isBaseball && match.teamB.code === "KOR" && match.teamA.code !== "KOR"; 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 left = flip ? match.teamB : match.teamA;
const right = flip ? match.teamA : match.teamB; const right = flip ? match.teamA : match.teamB;
const leftScore = flip ? match.result?.scoreB : match.result?.scoreA; 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 stR = standingLine(ex?.standings?.b, lang);
const vs = ex?.seasonVs; const vs = ex?.seasonVs;
const hasVs = !!vs && vs.aWin != null && vs.bWin != null; 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 ( return (
<section className="mt-6"> <section className="mt-6">
@ -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" 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)" }} style={{ boxShadow: "0 0 28px rgba(74,255,160,0.35), inset 0 0 24px rgba(74,255,160,0.06)" }}
> >
{/* 파도타기 마스코트 — 축구 전용 (야구는 미노출) */} {/* 파도타기 마스코트 — 월드컵 전용 (야구·MLS 미노출) */}
{!isBaseball && ( {!isClub && (
<div className="absolute right-2 top-2 z-10"> <div className="absolute right-2 top-2 z-10">
<WaveStrip /> <WaveStrip />
</div> </div>
@ -66,9 +94,9 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</div> </div>
<div className="mt-0.5 text-[12px] text-white/65"> <div className="mt-0.5 text-[12px] text-white/65">
{match.venue} {match.venue}
{isBaseball && ( {isClub && (
<span className="text-white/40"> <span className="text-white/40">
{" "}· {lang === "en" ? "Home: " : "홈 "}{teamShort(right, lang)} {" "}· {lang === "en" ? "Home: " : "홈 "}{teamShort(match.teamB, lang)}
</span> </span>
)} )}
</div> </div>
@ -167,6 +195,57 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</div> </div>
)} )}
{/* MLS 정보 패널: 최근 5경기 폼 · 컨퍼런스 순위 · 최근 맞대결 (캐시 있을 때만) */}
{isMls && (ex?.formA || ex?.formB || mlsStL || mlsStR || h2h.length > 0) && (
<div className="mt-4 rounded-2xl bg-white/[0.04] p-3.5 text-[12px]">
{(ex?.formA || ex?.formB) && (
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<div className="min-w-0">
<FormBadges form={ex?.formB} />
{ex?.recordB && (
<span className="ml-1.5 text-white/45">{ex.recordB}</span>
)}
</div>
<span className="text-[11px] font-bold text-[var(--green)]">
{lang === "en" ? "Last 5 · W-L-D" : "최근 5경기 · 시즌"}
</span>
<div className="min-w-0 text-right">
{ex?.recordA && (
<span className="mr-1.5 text-white/45">{ex.recordA}</span>
)}
<FormBadges form={ex?.formA} />
</div>
</div>
)}
{(mlsStL || mlsStR) && (
<div className="mt-2.5 flex items-center justify-between border-t border-white/10 pt-2.5 text-white/55">
<span className="truncate">{mlsStL ?? "—"}</span>
<span className="px-2 text-[10px] text-white/30">{lang === "en" ? "STANDINGS" : "순위"}</span>
<span className="truncate text-right">{mlsStR ?? "—"}</span>
</div>
)}
{h2h.length > 0 && (
<div className="mt-2.5 border-t border-white/10 pt-2.5">
<div className="mb-1 text-center text-[10px] text-white/35">
{lang === "en" ? "RECENT H2H" : "최근 맞대결"}
</div>
<ul className="space-y-0.5 text-center font-mono text-[11px] text-white/60">
{h2h.slice(0, 3).map((g, i) => (
<li key={i}>
<span className="text-white/35">{g.date.slice(5).replace("-", ".")}</span>{" "}
{g.home} {g.scoreH}
<span className="px-1 text-white/30">:</span>
{g.scoreA} {g.away}
</li>
))}
</ul>
</div>
)}
</div>
)}
{finished && ( {finished && (
<div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]"> <div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]">
{t.matchEnded} {t.matchEnded}

View File

@ -379,7 +379,30 @@ function Chip({
); );
} }
// 팀명 옆 순위 배지 (야구 — 순위 캐시 없으면 렌더 안 함) // MLS 최근 5경기 폼 — "WWLDW" 를 W/D/L 색으로 표시
export function FormBadges({ form }: { form?: string | null }) {
if (!form) return <span className="text-white/35">?</span>;
return (
<span className="inline-flex gap-px font-mono font-extrabold tracking-tight">
{form.split("").map((c, i) => (
<span
key={i}
className={
c === "W"
? "text-[#94FBE0]"
: c === "L"
? "text-[#FF5B5B]/80"
: "text-white/45"
}
>
{c}
</span>
))}
</span>
);
}
// 팀명 옆 순위 배지 (야구·MLS — 순위 캐시 없으면 렌더 안 함)
function RankBadge({ rank, lang = "ko" }: { rank?: number | null; lang?: Lang }) { function RankBadge({ rank, lang = "ko" }: { rank?: number | null; lang?: Lang }) {
if (!rank) return null; if (!rank) return null;
return ( return (
@ -402,11 +425,13 @@ function MatchCard({
const phase = match.phase; const phase = match.phase;
const finished = !!match.result; const finished = !!match.result;
const isBaseball = match.league === "kbo" || match.league === "mlb"; 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<LiveData | null>(null); const [live, setLive] = useState<LiveData | null>(null);
useEffect(() => { useEffect(() => {
if (phase !== "live" || !isBaseball) return; if (phase !== "live" || !isClub) return;
let alive = true; let alive = true;
const load = () => const load = () =>
getLive(match.matchId) getLive(match.matchId)
@ -420,21 +445,27 @@ function MatchCard({
alive = false; alive = false;
clearInterval(id); clearInterval(id);
}; };
}, [match.matchId, phase, isBaseball]); }, [match.matchId, phase, isClub]);
// "6회초" / "Top 6" // 야구 "6회초" / "Top 6" · MLS 경기 분 "45'"
const inning = const liveBadge = isMls
live?.inn != null ? live?.clock || null
: live?.inn != null
? lang === "en" ? lang === "en"
? `${live.half === "B" ? "Bot" : "Top"} ${live.inn}` ? `${live.half === "B" ? "Bot" : "Top"} ${live.inn}`
: `${live.inn}회${live.half === "B" ? "말" : "초"}` : `${live.inn}회${live.half === "B" ? "말" : "초"}`
: null; : null;
// 한국을 항상 왼쪽에 표시(상세 페이지와 동일 규칙). 데이터는 원본 A/B 유지, 화면 좌우만 교체. // 킥오프 시각은 지났지만 소스가 아직 시작 전(state=pre)이라는 경기 — 지연 등
const flip = !isBaseball && match.teamB.code === "KOR" && match.teamA.code !== "KOR"; 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 left = flip ? match.teamB : match.teamA;
const right = flip ? match.teamA : match.teamB; const right = flip ? match.teamA : match.teamB;
const leftScore = flip ? match.result?.scoreB : match.result?.scoreA; const leftScore = flip ? match.result?.scoreB : match.result?.scoreA;
const rightScore = flip ? match.result?.scoreA : match.result?.scoreB; 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)는 상세로 이동 // 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동
const inner = ( const inner = (
@ -445,14 +476,14 @@ function MatchCard({
<span className="text-white/55">{shortDate(dateKey(match.kickoffKst), lang)} </span> <span className="text-white/55">{shortDate(dateKey(match.kickoffKst), lang)} </span>
)} )}
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> {timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span>
{isBaseball && match.venue ? ( {isClub && match.venue ? (
<span className="text-white/40"> · {match.venue}</span> <span className="text-white/40"> · {match.venue}</span>
) : match.group && match.roundLabel ? ( ) : match.group && match.roundLabel ? (
<> · {tRound(match.roundLabel, lang)}</> <> · {tRound(match.roundLabel, lang)}</>
) : null} ) : null}
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
{!isBaseball && ( {!isClub && (
<span className="text-[11px] font-bold text-white/45"> <span className="text-[11px] font-bold text-white/45">
{match.group {match.group
? lang === "en" ? lang === "en"
@ -461,8 +492,16 @@ function MatchCard({
: tRound(match.roundLabel, lang)} : tRound(match.roundLabel, lang)}
</span> </span>
)} )}
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}> <span
{phase === "live" && inning ? inning : t.phase[phase]} className={`rounded-md border px-2 py-0.5 font-semibold ${
notStarted ? PHASE_CLS.scheduled : PHASE_CLS[phase]
}`}
>
{notStarted
? lang === "en" ? "Not started" : "시작 전"
: phase === "live" && liveBadge
? liveBadge
: t.phase[phase]}
</span> </span>
</span> </span>
</div> </div>
@ -471,7 +510,7 @@ function MatchCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TeamFlag team={left} className={flagCls} /> <TeamFlag team={left} className={flagCls} />
<span className="truncate text-[15px] font-extrabold">{teamShort(left, lang)}</span> <span className="truncate text-[15px] font-extrabold">{teamShort(left, lang)}</span>
{isBaseball && <RankBadge rank={match.extras?.standings?.a?.rank} lang={lang} />} {isClub && <RankBadge rank={leftSt?.rank} lang={lang} />}
</div> </div>
{match.result ? ( {match.result ? (
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white"> <span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
@ -480,17 +519,17 @@ function MatchCard({
{rightScore} {rightScore}
</span> </span>
) : phase === "live" && live?.score ? ( ) : phase === "live" && live?.score ? (
// 진행 중 실시간 스코어 (A=원정, B=홈) // 진행 중 실시간 스코어 — flip 시(MLS) 홈 먼저
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white"> <span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
{live.score.away ?? 0} {(flip ? live.score.home : live.score.away) ?? 0}
<span className="px-1.5 text-[#FF5B5B]">:</span> <span className="px-1.5 text-[#FF5B5B]">:</span>
{live.score.home ?? 0} {(flip ? live.score.away : live.score.home) ?? 0}
</span> </span>
) : ( ) : (
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span> <span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
)} )}
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{isBaseball && <RankBadge rank={match.extras?.standings?.b?.rank} lang={lang} />} {isClub && <RankBadge rank={rightSt?.rank} lang={lang} />}
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(right, lang)}</span> <span className="truncate text-right text-[15px] font-extrabold">{teamShort(right, lang)}</span>
<TeamFlag team={right} className={flagCls} /> <TeamFlag team={right} className={flagCls} />
</div> </div>
@ -506,6 +545,16 @@ function MatchCard({
</div> </div>
)} )}
{/* MLS: 최근 5경기 폼 (프리뷰 캐시 있을 때만) — 홈(왼쪽) 먼저 */}
{isMls && !match.result && (match.extras?.formA || match.extras?.formB) && (
<div className="mt-2 text-center text-[11px] font-semibold text-white/50">
<span className="text-white/35">{lang === "en" ? "Last 5" : "최근 5경기"}</span>{" "}
<FormBadges form={match.extras?.formB} />
<span className="px-1 text-white/30">vs</span>
<FormBadges form={match.extras?.formA} />
</div>
)}
{/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 (취소 경기는 숨김) */} {/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 (취소 경기는 숨김) */}
{match.votable && phase !== "cancelled" && ( {match.votable && phase !== "cancelled" && (
<div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]"> <div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]">
@ -556,9 +605,12 @@ function aiSplit(match: Match, lang: Lang): string[] {
else if (p.outcome === "DRAW") tally.d++; else if (p.outcome === "DRAW") tally.d++;
else tally.b++; else tally.b++;
} }
const out: string[] = []; const entA = tally.a ? `${teamShort(match.teamA, lang)} ${tally.a}` : null;
if (tally.a) out.push(`${teamShort(match.teamA, lang)} ${tally.a}`); const entD = tally.d ? `${t.draw} ${tally.d}` : null;
if (tally.d) out.push(`${t.draw} ${tally.d}`); const entB = tally.b ? `${teamShort(match.teamB, lang)} ${tally.b}` : null;
if (tally.b) out.push(`${teamShort(match.teamB, lang)} ${tally.b}`); // MLS 는 홈 팀 먼저 (카드 좌우 표시와 동일 순서)
const out = (match.league === "mls" ? [entB, entD, entA] : [entA, entD, entB]).filter(
(x): x is string => !!x,
);
return out.length ? out : ["-"]; return out.length ? out : ["-"];
} }

View File

@ -4,7 +4,7 @@ import { type Lang, teamShort } from "@/lib/i18n";
import type { StandingRow, StandingsOut } from "@/lib/types"; import type { StandingRow, StandingsOut } from "@/lib/types";
import TeamFlag from "./TeamFlag"; import TeamFlag from "./TeamFlag";
// 야구 리그 순위표 — KBO: 단일 테이블 · MLB: 디비전 6그룹. // 리그 순위표 — KBO: 단일 테이블 · MLB: 디비전 6그룹 · MLS: 컨퍼런스 2그룹(승점제).
// 데이터는 워커가 캐싱한 시즌 순위(/api/standings)로, 하루 수회 갱신된다. // 데이터는 워커가 캐싱한 시즌 순위(/api/standings)로, 하루 수회 갱신된다.
const DIV_LABEL: Record<string, { ko: string; en: string }> = { const DIV_LABEL: Record<string, { ko: string; en: string }> = {
ALE: { ko: "AL 동부", en: "AL East" }, ALE: { ko: "AL 동부", en: "AL East" },
@ -13,6 +13,8 @@ const DIV_LABEL: Record<string, { ko: string; en: string }> = {
NLE: { ko: "NL 동부", en: "NL East" }, NLE: { ko: "NL 동부", en: "NL East" },
NLC: { ko: "NL 중부", en: "NL Central" }, NLC: { ko: "NL 중부", en: "NL Central" },
NLW: { ko: "NL 서부", en: "NL West" }, NLW: { ko: "NL 서부", en: "NL West" },
EAST: { ko: "동부 컨퍼런스", en: "Eastern Conference" },
WEST: { ko: "서부 컨퍼런스", en: "Western Conference" },
}; };
function fmtWra(v: StandingRow["wra"]): string { 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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@ -87,13 +90,25 @@ export default function StandingsTable({
<tr className="text-[11px] font-bold text-white/40"> <tr className="text-[11px] font-bold text-white/40">
<th className="w-7 py-1 text-center font-bold">{lang === "en" ? "#" : "순위"}</th> <th className="w-7 py-1 text-center font-bold">{lang === "en" ? "#" : "순위"}</th>
<th className="py-1 text-left font-bold">{lang === "en" ? "Team" : "팀"}</th> <th className="py-1 text-left font-bold">{lang === "en" ? "Team" : "팀"}</th>
{isPoints && (
<th className="w-8 py-1 text-center font-bold">{lang === "en" ? "GP" : "경기"}</th>
)}
<th className="w-8 py-1 text-center font-bold">{lang === "en" ? "W" : "승"}</th> <th className="w-8 py-1 text-center font-bold">{lang === "en" ? "W" : "승"}</th>
{hasDraw && ( {hasDraw && (
<th className="w-8 py-1 text-center font-bold">{lang === "en" ? "D" : "무"}</th> <th className="w-8 py-1 text-center font-bold">{lang === "en" ? "D" : "무"}</th>
)} )}
<th className="w-8 py-1 text-center font-bold">{lang === "en" ? "L" : "패"}</th> <th className="w-8 py-1 text-center font-bold">{lang === "en" ? "L" : "패"}</th>
<th className="w-12 py-1 text-center font-bold">{lang === "en" ? "PCT" : "승률"}</th> {isPoints ? (
<th className="w-10 py-1 text-center font-bold">{lang === "en" ? "GB" : "게임차"}</th> <>
<th className="w-10 py-1 text-center font-bold">{lang === "en" ? "Pts" : "승점"}</th>
<th className="w-10 py-1 text-center font-bold">{lang === "en" ? "GD" : "득실"}</th>
</>
) : (
<>
<th className="w-12 py-1 text-center font-bold">{lang === "en" ? "PCT" : "승률"}</th>
<th className="w-10 py-1 text-center font-bold">{lang === "en" ? "GB" : "게임차"}</th>
</>
)}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -112,13 +127,25 @@ export default function StandingsTable({
<span className="truncate font-bold">{teamShort(r, lang)}</span> <span className="truncate font-bold">{teamShort(r, lang)}</span>
</span> </span>
</td> </td>
{isPoints && (
<td className="py-1.5 text-center font-mono text-white/60">{r.gp ?? "-"}</td>
)}
<td className="py-1.5 text-center font-mono text-white/80">{r.w ?? "-"}</td> <td className="py-1.5 text-center font-mono text-white/80">{r.w ?? "-"}</td>
{hasDraw && ( {hasDraw && (
<td className="py-1.5 text-center font-mono text-white/50">{r.d ?? "-"}</td> <td className="py-1.5 text-center font-mono text-white/50">{r.d ?? "-"}</td>
)} )}
<td className="py-1.5 text-center font-mono text-white/80">{r.l ?? "-"}</td> <td className="py-1.5 text-center font-mono text-white/80">{r.l ?? "-"}</td>
<td className="py-1.5 text-center font-mono font-bold text-white">{fmtWra(r.wra)}</td> {isPoints ? (
<td className="py-1.5 text-center font-mono text-white/60">{fmtGb(r.gb)}</td> <>
<td className="py-1.5 text-center font-mono font-bold text-white">{r.pts ?? "-"}</td>
<td className="py-1.5 text-center font-mono text-white/60">{r.diff ?? "-"}</td>
</>
) : (
<>
<td className="py-1.5 text-center font-mono font-bold text-white">{fmtWra(r.wra)}</td>
<td className="py-1.5 text-center font-mono text-white/60">{fmtGb(r.gb)}</td>
</>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@ -60,6 +60,22 @@ export interface LiveBatter {
sub?: boolean; 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 { export interface LiveData {
available: boolean; available: boolean;
inn?: number; inn?: number;
@ -72,6 +88,46 @@ export interface LiveData {
vsRecord?: string; vsRecord?: string;
defense?: { pos?: string; name: string }[]; defense?: { pos?: string; name: string }[];
offenseLineup?: LiveBatter[]; 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<LiveData> { export function getLive(matchId: string): Promise<LiveData> {

View File

@ -229,8 +229,8 @@ export const DICT: Record<Lang, Dict> = {
"Independent AI prediction game — not affiliated with, endorsed by, or sponsored by FIFA or the official World Cup.", "Independent AI prediction game — not affiliated with, endorsed by, or sponsored by FIFA or the official World Cup.",
footerDisc1: "FIFA 및 공식 월드컵과 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).", footerDisc1: "FIFA 및 공식 월드컵과 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).",
footerNotOfficialBB: 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: "KBO·MLB 리그 및 각 구단과 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).", footerDisc1BB: "KBO·MLB·MLS 리그 및 각 구단과 무관한 독립 AI 예측 게임입니다 (제휴·후원·운영 아님).",
footerDisc2: "스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.", footerDisc2: "스포츠 분석·엔터테인먼트 목적의 예측 게임이며 베팅·도박을 권유하지 않습니다. AI 예측은 실제 결과를 보장하지 않습니다.",
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.", footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`, hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
@ -337,7 +337,7 @@ export const DICT: Record<Lang, Dict> = {
"Independent AI prediction game — not affiliated with, endorsed by, or sponsored by FIFA or the official World Cup.", "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.", footerDisc1: "A fan-run prediction game using public match schedules; all picks are independent.",
footerNotOfficialBB: 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.", 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.", 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.", 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.",

View File

@ -42,8 +42,8 @@ export interface CrowdStats {
teamBWin: number; teamBWin: number;
} }
// 리그: wc(월드컵 축구) | kbo | mlb // 리그: wc(월드컵 축구) | kbo | mlb | mls
export type League = "wc" | "kbo" | "mlb"; export type League = "wc" | "kbo" | "mlb" | "mls";
// 야구 부가정보 (프리뷰·순위 캐시 — 없으면 undefined) // 야구 부가정보 (프리뷰·순위 캐시 — 없으면 undefined)
export interface StarterInfo { export interface StarterInfo {
@ -63,11 +63,18 @@ export interface TeamStanding {
wra?: string | number; wra?: string | number;
gb?: string | number; gb?: string | number;
last5?: string | null; last5?: string | null;
// MLS(승점제) 전용
gp?: number;
pts?: number;
gf?: number;
ga?: number;
diff?: string | null;
} }
// /standings 응답 — 팀 정보(Team) + 시즌 성적 한 행 // /standings 응답 — 팀 정보(Team) + 시즌 성적 한 행
export interface StandingRow extends Team, TeamStanding { 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 { export interface StandingsOut {
@ -81,6 +88,18 @@ export interface MatchExtras {
starterB?: StarterInfo | null; starterB?: StarterInfo | null;
seasonVs?: { aWin?: number; draw?: number; bWin?: number } | null; seasonVs?: { aWin?: number; draw?: number; bWin?: number } | null;
standings?: { a?: TeamStanding | null; b?: TeamStanding | 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 { export interface Match {

View File

@ -9,7 +9,9 @@ export function useLeague(): [League, (l: League) => void] {
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const raw = params.get("league"); const raw = params.get("league");
const league: 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 setLeague = (l: League) => {
const next = new URLSearchParams(params); const next = new URLSearchParams(params);
next.set("league", l); next.set("league", l);

View File

@ -9,8 +9,8 @@ import { useLeague } from "@/lib/useLeague";
export default function Leaderboard() { export default function Leaderboard() {
const lang = useLang(); const lang = useLang();
const [league] = useLeague(); const [league] = useLeague();
// 야구(KBO/MLB)는 랭킹 운영 방침(상금 등) 미확정 — 임시 비노출 // 월드컵 외 리그(KBO/MLB/MLS)는 랭킹 운영 방침(상금 등) 미확정 — 임시 비노출
const isBaseball = league === "kbo" || league === "mlb"; const isBaseball = league !== "wc";
return ( return (
<main className="shell"> <main className="shell">

View File

@ -3,6 +3,7 @@ import { Link, useParams } from "react-router-dom";
import Hero from "@/components/Hero"; import Hero from "@/components/Hero";
import MatchupHUD from "@/components/MatchupHUD"; import MatchupHUD from "@/components/MatchupHUD";
import LiveField from "@/components/LiveField"; import LiveField from "@/components/LiveField";
import LiveSoccer from "@/components/LiveSoccer";
import Arena from "@/components/Arena"; import Arena from "@/components/Arena";
import Comments from "@/components/Comments"; import Comments from "@/components/Comments";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
@ -58,11 +59,17 @@ export default function MatchDetail() {
const aShort = teamShort(match.teamA, lang); const aShort = teamShort(match.teamA, lang);
const bShort = teamShort(match.teamB, lang); const bShort = teamShort(match.teamB, lang);
// 한국을 항상 왼쪽에 표시(요청)와 동일하게 공유 제목/후킹 카피도 한국 먼저. // 월드컵: 한국을 항상 왼쪽에. MLS: 홈 팀 먼저(축구 관례) — 제목/후킹 카피 동일 순서.
const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR"; const isMls = match.league === "mls";
const flip = isMls || (match.teamB.code === "KOR" && match.teamA.code !== "KOR");
const leftShort = flip ? bShort : aShort; const leftShort = flip ? bShort : aShort;
const rightShort = flip ? aShort : bShort; 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); const url = matchUrl(match.matchId);
return ( return (
@ -90,6 +97,8 @@ export default function MatchDetail() {
<MatchupHUD match={match} lang={lang} /> <MatchupHUD match={match} lang={lang} />
<LiveField match={match} lang={lang} /> <LiveField match={match} lang={lang} />
{/* MLS 라이브 (축구) — 경기중에만 렌더, 야구 리그에선 null */}
<LiveSoccer match={match} lang={lang} />
<Arena <Arena
match={match} match={match}
predictions={match.predictions} predictions={match.predictions}