Compare commits

...

6 Commits

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:56:44 +09:00
e1b85aeba6 fix(mls): odds 원소가 null 인 경기에서 프리뷰 갱신 전체가 죽던 문제
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:50:59 +09:00
00494e9e93 feat(mlb): 문자중계 추가 — feed/live allPlays 규칙 기반 한글 변환
- 투구 호칭·eventType 한글 매핑, 타구 방향은 desc 의 야수 표현에서 추출
- 주자 이동(홈인·진루·주루사)은 정규식 파싱 — 선수명은 이니셜("J.P.")만
  마침표 허용해 문장 경계를 넘지 않게 함
- 교체는 "A replaces B" 구문 분해 (투수 교체·대타·대주자·수비 교체)
- 득점 강조는 KBO 와 동일하게 플레이 종료 점수 합 변화로 판정 + 홈런 결과 라인
- 미지원 문장·이벤트는 영문 원문 폴백. 노이즈(타자 타임·수비 위치이동 등) 제외
- KBO relay 와 동일 스키마라 프론트(LiveField RelayFeed) 변경 없음

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:38:48 +09:00
13a4997cc2 fix(mls): ESPN 403 — 브라우저 위장 UA 제거
컨테이너(httpx)에서 풀 Chrome UA 를 보내면 ESPN WAF 가 403 차단
(TLS 핑거프린트-UA 불일치 감지로 추정). httpx 기본 UA 는 통과 —
일정 동기화가 계속 실패해 로컬에서 MLS 일정이 안 뜨던 원인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:38:48 +09:00
0415ab7636 feat(kbo): 라이브 필드에 문자중계 추가 — 네이버 relay 전 이닝
- relay 응답에서 버리던 textRelays 를 타석 단위 그룹으로 서빙 (한국어 원문 그대로)
- 기본 응답은 현재 이닝만 담아, 지난 이닝은 ?inning=N 병렬 조회 후 캐시
  (현재·직전 이닝은 매번 재조회 — 이닝 전환 직후 마지막 타석 누락 방지)
- 득점 하이라이트는 텍스트 패턴이 아니라 currentGameState 점수 합 변화로 판정
  (홈런·적시타뿐 아니라 홈인·폭투/실책 득점·밀어내기까지 정확)
- 네이버 type 코드는 kind(pitch/result/run/sub/note)로 변환해 UI 에 노출하지 않음
- 진행 중엔 최근 40타석, 종료 후엔 전 경기 중계(다시보기)
- 프론트: 투구 접기/펼치기 토글 · 이닝 구분선 · 득점 강조

MLB 는 소스(feed/live allPlays)가 영문이라 별도 작업 — 이번 커밋은 KBO 만.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:57:02 +09:00
74de57fc1c feat(mls): MLS 리그 추가 — ESPN 비공식 API 연동
- 일정·결과·컨퍼런스 순위·프리뷰(폼/시즌전적/맞대결/머니라인) 수집
- 라이브 카드: 스코어·경기시간·득점/카드·선발 라인업(피치 뷰)·교체 현황
- 문자중계: ESPN commentary 규칙 기반 한글 변환 (미지원 템플릿은 팀명만 한글화)
- MLS 레코드는 야구와 키 구조가 같아 sync_baseball_schedule/settle 재사용
- AI 예측 데이터 블록에 MLS 전용 컨텍스트 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:47:13 +09:00
23 changed files with 2158 additions and 92 deletions

View File

@ -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,13 @@ 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"
# MLS 과거 경기 백필 일수 — MLS 는 주말 위주 편성이라 야구식 recheck 윈도우(3일)만으론
# 일정판에 긴 공백이 생기고 자체 DB 폼 데이터도 비어서, 최근 한 달을 함께 수집한다.
# (범위를 넓혀도 scoreboard 1콜, sync 는 추가형이라 과거 경기 재수집 무해)
mls_days_back: int = 30
@property
def league_list(self) -> list[str]:

View File

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

View File

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

View File

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

View File

@ -8,7 +8,9 @@ MLB: 공식 Stats API (probablePitcher·standings·feed/live)
"""
from __future__ import annotations
import asyncio
import logging
import re
import time
from datetime import datetime, timedelta, timezone
@ -41,18 +43,29 @@ def naver_game_id_candidates(m: Match) -> list[str]:
return [naver_game_id(m, n) for n in nos]
async def _naver_get_first(client, m: Match, suffix: str) -> dict | None:
"""gameId 후보를 순서대로 시도해 첫 성공 응답을 반환."""
async def _naver_get_first_with_id(
client, m: Match, suffix: str
) -> tuple[str, dict] | None:
"""gameId 후보를 순서대로 시도해 (성공한 gameId, 응답) 을 반환.
같은 경기를 추가 조회할 때(이닝별 문자중계) gameId 를 다시 찾지 않게 함께 돌려준다.
"""
for gid in naver_game_id_candidates(m):
try:
res = await _naver_get(client, f"/schedule/games/{gid}/{suffix}")
except Exception: # noqa: BLE001 — 후보 불일치(404 등)는 다음 후보로
continue
if res:
return res
return gid, res
return None
async def _naver_get_first(client, m: Match, suffix: str) -> dict | None:
"""gameId 후보를 순서대로 시도해 첫 성공 응답을 반환."""
found = await _naver_get_first_with_id(client, m, suffix)
return found[1] if found else None
async def _naver_get(client, path: str) -> dict | None:
r = await client.get(settings.naver_api_base + path, headers=UA)
r.raise_for_status()
@ -329,11 +342,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}")
@ -425,6 +439,298 @@ def _transform_naver_relay(t: dict) -> dict:
}
# ── KBO 문자중계 (네이버 relay textRelays) ─────────────────────
# 네이버는 이미 한국어 중계문을 타석 단위로 묶어 준다 — 번역 없이 그대로 서빙.
# relay 기본 응답은 "현재 이닝"만 담고, 지난 이닝은 ?inning=N 으로 따로 받아야 한다.
# textOptions.type → 프론트 표시용 의미 (네이버 숫자 코드를 UI 에 노출하지 않기 위함)
_RELAY_KIND = {
0: "inning", # 이닝 시작 ("1회초 한화 공격")
1: "pitch", # 투구 ("3구 헛스윙")
2: "sub", # 선수 교체
7: "note", # 투수판 이탈·비디오 판독·마운드 방문
8: "batter", # 타석 시작 (그룹 제목과 동일)
13: "result", # 타석 결과
23: "result", # 타석 결과 (주자 있을 때 표기)
14: "run", # 주루
24: "run", # 주루·홈인
99: "end", # 경기 종료·승리투수
}
# 라이브 페이로드에 싣는 최근 타석 수 — 전 이닝(약 80타석)을 매 폴링마다 보내지 않기 위한 상한
_RELAY_MAX_GROUPS = 40
# 지난 이닝 중계 캐시 (경기 → {이닝: 그룹[]}). 종료된 이닝은 불변이라 재조회하지 않는다.
_relay_innings: dict[str, dict[int, list[dict]]] = {}
def _score_total(gs: dict) -> int | None:
try:
return int(gs.get("homeScore") or 0) + int(gs.get("awayScore") or 0)
except (TypeError, ValueError):
return None
def _relay_commentary(groups: list[dict]) -> list[dict]:
"""relay 그룹 → 문자중계 [{inn, half, no, title, events[]}] (최신 타석 순).
득점 표시는 텍스트 패턴 대신 옵션마다 실린 currentGameState 의 점수 합 변화로
판정한다 — 홈런·적시타뿐 아니라 홈인·실책 득점·밀어내기까지 정확히 잡힌다.
"""
out: list[dict] = []
prev_total: int | None = None
for r in sorted(groups, key=lambda g: g.get("no") or 0):
title = (r.get("title") or "").strip()
if not title.strip("="): # 경기 종료 구분선 그룹 — 제목 없이 내용만
title = ""
events: list[dict] = []
for o in sorted(r.get("textOptions") or [], key=lambda o: o.get("seqno") or 0):
total = _score_total(o.get("currentGameState") or {})
scored = prev_total is not None and total is not None and total > prev_total
if total is not None:
prev_total = total
text = (o.get("text") or "").strip()
# 구분선("=====")·제목 중복 라인은 버린다
if not text or text == title or not text.strip("="):
continue
ev = {"kind": _RELAY_KIND.get(o.get("type"), "note"), "text": text}
if scored:
ev["score"] = True
events.append(ev)
# 이닝 헤더 그룹은 events 가 비지만 구분선으로 쓰이므로 남긴다
if not title and not events:
continue
out.append({
"inn": r.get("inn"),
"half": "B" if str(r.get("homeOrAway")) == "1" else "T",
"no": r.get("no"),
"title": title,
"events": events,
})
out.reverse()
# 종료된 경기는 폴링이 멈추므로 전 경기 중계를 그대로 준다(다시보기).
# 진행 중엔 20초마다 다시 실리므로 최근 타석만 — 스크롤 분량으로도 충분하다.
ended = any(e["kind"] == "end" for g in out for e in g["events"])
return out if ended else out[:_RELAY_MAX_GROUPS]
async def _kbo_relay_groups(client, m: Match, gid: str, t: dict) -> list[dict]:
"""전 이닝 문자중계 그룹. 이닝별 조회를 캐시와 병렬 조회로 채운다.
현재·직전 이닝은 매번 다시 받는다 — 기본 응답은 진행 중인 half 만 담을 때가 있고,
이닝이 넘어간 직후엔 직전 이닝 마지막 타석이 캐시에 안 들어와 있을 수 있다.
그보다 앞선 이닝은 더 변하지 않으므로 한 번만 받는다.
"""
cur_groups = t.get("textRelays") or []
cur = int(t.get("inn") or max((g.get("inn") or 0) for g in cur_groups) or 1)
if len(_relay_innings) > 40: # 하루치 경기 이상 쌓이면 통째로 비움
_relay_innings.clear()
cache = _relay_innings.setdefault(m.match_id, {})
volatile = {cur - 1, cur}
need = [i for i in range(1, cur + 1) if i in volatile or i not in cache]
if need:
res = await asyncio.gather(
*(
_naver_get(client, f"/schedule/games/{gid}/relay?inning={i}")
for i in need
),
return_exceptions=True,
)
for i, r in zip(need, res):
if isinstance(r, BaseException) or not r:
continue # 실패한 이닝은 기존 캐시 유지 → 다음 폴링에서 재시도
cache[i] = ((r.get("textRelayData") or {}).get("textRelays")) or []
cache.setdefault(cur, cur_groups) # 현재 이닝 조회 실패 시 기본 응답으로 대체
return [g for i in sorted(cache) for g in cache[i]]
# ── MLB 문자중계 (feed/live allPlays 규칙 기반 한글 변환) ──────
# MLB 중계문은 정형 영문 템플릿 — eventType·투구 호칭을 한글로 매핑하고
# 타구 방향·주자 진루는 정규식으로 뽑는다. 미지원 문장은 영문 원문 폴백.
_MLB_PITCH_KO = {
"Ball": "볼", "Ball In Dirt": "볼 (원바운드)", "Intent Ball": "고의사구 볼",
"Called Strike": "스트라이크", "Swinging Strike": "헛스윙",
"Swinging Strike (Blocked)": "헛스윙", "Foul": "파울", "Foul Tip": "파울팁",
"Foul Bunt": "번트 파울", "Missed Bunt": "번트 헛스윙",
"Hit By Pitch": "몸에 맞는 볼", "Pitchout": "피치아웃",
"Automatic Ball": "자동 볼 (피치클록)", "Automatic Strike": "자동 스트라이크 (피치클록)",
"In play, out(s)": "타격", "In play, no out": "타격", "In play, run(s)": "타격",
}
_MLB_EVENT_KO = {
"Single": "1루타", "Double": "2루타", "Triple": "3루타", "Home Run": "홈런",
"Walk": "볼넷", "Intent Walk": "고의4구", "Strikeout": "삼진 아웃",
"Strikeout Double Play": "삼진 (병살)", "Groundout": "땅볼 아웃",
"Flyout": "플라이 아웃", "Lineout": "직선타 아웃", "Pop Out": "팝플라이 아웃",
"Bunt Groundout": "번트 아웃", "Bunt Pop Out": "번트 팝플라이 아웃",
"Forceout": "포스 아웃", "Grounded Into DP": "병살타", "Double Play": "병살",
"Triple Play": "삼중살", "Sac Fly": "희생플라이", "Sac Bunt": "희생번트",
"Field Error": "실책 출루", "Fielders Choice": "야수선택",
"Fielders Choice Out": "야수선택 아웃", "Hit By Pitch": "몸에 맞는 볼",
"Catcher Interference": "포수 타격방해", "Runner Out": "주자 아웃",
"Caught Stealing 2B": "도루자 (2루)", "Caught Stealing 3B": "도루자 (3루)",
"Caught Stealing Home": "도루자 (홈)", "Pickoff 1B": "견제사 (1루)",
"Pickoff 2B": "견제사 (2루)", "Pickoff 3B": "견제사 (3루)",
}
# 타구 방향/처리 야수 — desc 에서 가장 먼저 나오는 표현 (긴 표현 우선 매칭)
_MLB_DIR_KO = [
("left fielder", "좌익수"), ("left field", "좌익수"),
("center fielder", "중견수"), ("center field", "중견수"),
("right fielder", "우익수"), ("right field", "우익수"),
("shortstop", "유격수"), ("first baseman", "1루수"),
("second baseman", "2루수"), ("third baseman", "3루수"),
("first base", "1루수"), ("second base", "2루수"), ("third base", "3루수"),
("pitcher", "투수"), ("catcher", "포수"),
]
# 대문자 시작 단어 연속 = 선수명 ("J.P. Crawford", "Wenceel Pérez").
# 마침표는 이니셜("J.P.")에만 허용 — 일반 단어 끝 마침표를 막아 문장 경계를 넘지 않게 한다.
_MLB_NAME_W = r"(?:(?:[A-ZÀ-Ý]\.)+|[A-ZÀ-Ý][\w'\-]*)"
_MLB_NAME = rf"{_MLB_NAME_W}(?: {_MLB_NAME_W})*"
_BASE_KO = {"2nd": "2루", "3rd": "3루", "home": "홈"}
def _mlb_direction(desc: str) -> str:
hits = [(desc.find(en), ko) for en, ko in _MLB_DIR_KO if en in desc]
return min(hits)[1] if hits else ""
def _mlb_runs(desc: str) -> list[dict]:
"""desc 의 주자 이동 문장 → run 이벤트 (홈인·진루·주루사)."""
out: list[dict] = []
for nm in re.findall(rf"({_MLB_NAME}) scores\b", desc):
out.append({"kind": "run", "text": f"{nm} : 홈인"})
for nm, base in re.findall(rf"({_MLB_NAME})(?: advances)? to (2nd|3rd)\b", desc):
out.append({"kind": "run", "text": f"{nm} : {_BASE_KO[base]}까지 진루"})
for nm, base in re.findall(rf"({_MLB_NAME}) out at (2nd|3rd|home)\b", desc):
out.append({"kind": "run", "text": f"{nm} : {_BASE_KO[base]}에서 아웃"})
seen: set[str] = set() # "to 3rd"+"advances to 3rd" 같은 중복 문장 제거
return [e for e in out if not (e["text"] in seen or seen.add(e["text"]))]
def _tr_mlb_result(res: dict, batter: str) -> str:
event, desc = res.get("event") or "", res.get("description") or ""
ko = _MLB_EVENT_KO.get(event)
if not ko: # 미지원 이벤트 — 영문 원문 폴백
return f"{batter} : {desc}" if desc else f"{batter} : {event}"
if event == "Strikeout":
if "called out on strikes" in desc:
ko += " (루킹)"
elif "strikes out swinging" in desc:
ko += " (헛스윙)"
direction = _mlb_direction(desc)
return f"{batter} : {direction} {ko}".replace(" ", " ").replace(" : ", " : ")
def _tr_mlb_action(det: dict) -> tuple[str, str] | None:
"""playEvents 액션 → (kind, text). None 이면 표시하지 않음 (노이즈)."""
event, desc = det.get("event") or "", det.get("description") or ""
if event in ("Game Advisory", "Batter Timeout") or not (event or desc):
return None
if event.startswith("Stolen Base"):
base = "2루" if event.endswith("2B") else "3루" if event.endswith("3B") else "홈"
nm = re.match(rf"({_MLB_NAME}) steals", desc)
who = nm.group(1) if nm else ""
return "run", f"{who} : 도루로 {base}까지 진루".replace(" ", " ")
if event.startswith("Caught Stealing"):
return "run", _MLB_EVENT_KO.get(event, "도루자")
if event == "Wild Pitch":
return "note", "폭투"
if event == "Passed Ball":
return "note", "포일"
if event == "Mound Visit":
return "note", "마운드 방문"
if event in ("Pitching Substitution", "Offensive Substitution",
"Defensive Sub", "Defensive Substitution"):
label = {
"Pitching Substitution": "투수 교체",
"Offensive Substitution": "대타" if "hitter" in desc else "대주자",
}.get(event, "수비 교체")
# "Pitching Change: A replaces B." / "...: Pinch-hitter A replaces
# right fielder B, batting 2nd..." → "{label} — B → A"
body = desc.split(": ", 1)[-1].rstrip(".")
if " replaces " in body:
new, old = body.split(" replaces ", 1)
new = re.sub(r"^Pinch-(?:hitter|runner) ", "", new)
old = re.sub(r"^[a-z][a-z ]* ", "", old) # 포지션 수식어 제거
old = old.split(", batting")[0].split(" because")[0]
return "sub", f"{label} — {old} → {new}"
return "sub", desc
if event == "Defensive Switch":
return None # 포지션 이동 — 중계 피드에선 노이즈
if desc.startswith("Pickoff Attempt"):
return "note", "견제 시도"
if desc.startswith("Pitcher Step Off"):
return None
return "note", desc or event # 미지원 액션 — 영문 폴백
def _transform_mlb_relay(feed: dict, m: Match) -> list[dict]:
"""allPlays → 문자중계 그룹 (KBO relay 와 동일 스키마, 최신 타석순)."""
plays = ((feed.get("liveData") or {}).get("plays") or {}).get("allPlays") or []
away_s = m.team_a_short or m.team_a_code
home_s = m.team_b_short or m.team_b_code
groups: list[dict] = []
prev_half: tuple | None = None
prev_total = 0
no = 0
for p in plays:
about = p.get("about") or {}
inn = about.get("inning")
top = about.get("halfInning") == "top"
if (inn, top) != prev_half: # 이닝 구분 헤더 (KBO 와 동일한 빈 그룹)
prev_half = (inn, top)
no += 1
groups.append({
"inn": inn, "half": "T" if top else "B", "no": no,
"title": f"{inn}회{'초' if top else '말'} {away_s if top else home_s} 공격",
"events": [],
})
events: list[dict] = []
for ev in p.get("playEvents") or []:
det = ev.get("details") or {}
if ev.get("isPitch"):
call = det.get("description") or ""
n = ev.get("pitchNumber")
ko = _MLB_PITCH_KO.get(call, call)
events.append({"kind": "pitch", "text": f"{n}구 {ko}" if n else ko})
else:
ke = _tr_mlb_action(det)
if ke:
events.append({"kind": ke[0], "text": ke[1]})
events += _mlb_runs(det.get("description") or "") # 폭투 득점 등
res = p.get("result") or {}
batter = ((p.get("matchup") or {}).get("batter") or {}).get("fullName") or ""
if res.get("event"):
events.append({"kind": "result", "text": _tr_mlb_result(res, batter)})
events += _mlb_runs(res.get("description") or "")
# 득점 판정 — 플레이 종료 시점 점수 합 변화 (KBO 와 동일 원리)
if about.get("isComplete"):
total = int(res.get("awayScore") or 0) + int(res.get("homeScore") or 0)
if total > prev_total:
marked = False
for e in events:
if e["text"].endswith("홈인"):
e["score"] = True
marked = True
if not marked and events: # 홈인 문장 미파싱 (솔로 홈런 등) — 결과 라인 강조
events[-1]["score"] = True
# 홈런은 타자 본인 득점이 홈인 문장에 없음 — 결과 라인도 강조
if res.get("event") == "Home Run":
for e in events:
if e["kind"] == "result":
e["score"] = True
prev_total = total
if not events:
continue
no += 1
groups.append({
"inn": inn, "half": "T" if top else "B", "no": no,
"title": batter, "events": events,
})
groups.reverse()
ended = ((feed.get("gameData") or {}).get("status") or {}).get(
"abstractGameState") == "Final"
return groups if ended else groups[:_RELAY_MAX_GROUPS]
def _transform_mlb_feed(feed: dict) -> dict:
ld = feed.get("liveData") or {}
ls = ld.get("linescore") or {}
@ -494,10 +800,17 @@ async def fetch_live(m: Match) -> dict:
try:
if m.league == "kbo":
async with httpx.AsyncClient(timeout=10) as client:
res = await _naver_get_first(client, m, "relay")
t = (res or {}).get("textRelayData")
if t:
payload = _transform_naver_relay(t)
found = await _naver_get_first_with_id(client, m, "relay")
gid, res = found if found else ("", None)
t = (res or {}).get("textRelayData")
if t:
payload = _transform_naver_relay(t)
try: # 문자중계는 부가 정보 — 실패해도 필드 뷰는 그대로 서빙
payload["relay"] = _relay_commentary(
await _kbo_relay_groups(client, m, gid, t)
)
except Exception as e: # noqa: BLE001
log.warning("kbo 문자중계 실패 %s: %s", m.match_id, e)
elif m.league == "mlb":
# gamePk 를 일정에서 재조회 — MLB 일정 API 의 date 는 미국 날짜라
# KST 기준 하루 전~당일 범위로 조회 후 dateKst 로 정확히 매칭.
@ -532,7 +845,15 @@ async def fetch_live(m: Match) -> dict:
if pk:
r2 = await c.get(f"{settings.mlb_api_base}/v1.1/game/{pk}/feed/live")
r2.raise_for_status()
payload = _transform_mlb_feed(r2.json())
feed = r2.json()
payload = _transform_mlb_feed(feed)
try: # 문자중계는 부가 정보 — 실패해도 필드 뷰는 그대로 서빙
relay = _transform_mlb_relay(feed, m)
if relay:
payload["relay"] = relay
payload["available"] = True # 종료 경기 다시보기 지원
except Exception as e: # noqa: BLE001
log.warning("mlb 문자중계 실패 %s: %s", m.match_id, e)
except Exception as e: # noqa: BLE001
log.warning("live 실패 %s: %s", m.match_id, e)
payload = {"available": False}

View File

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

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]:
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}

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 .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)

View File

@ -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) ===== */}
<section className="sheet relative mt-4 p-5 text-[var(--ink)]">
{/* AI 박스 오른쪽 위 사자 — 축구 전용 */}
{!isBaseball && (
{/* AI 박스 오른쪽 위 사자 — 월드컵 전용 (야구·MLS 미노출) */}
{match.league === "wc" && (
<div className="absolute right-1 -top-4 z-10 h-20 sm:h-24">
<TalkingMascot
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">
{!isBaseball && <TalkingMascot
{match.league === "wc" && <TalkingMascot
src="/assets/mascots/dog_point.webp"
lines={DOG_LINES}
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></>}
</button>
{!isBaseball && <TalkingMascot
{match.league === "wc" && <TalkingMascot
src="/assets/mascots/tiger_point.webp"
lines={TIGER_LINES}
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">
{(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({
</section>
)}
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 야구(KBO/MLB)는 임시 비노출(운영 방침 미확정) ===== */}
{!isBaseball && <RankingBoard lang={lang} league={match.league} />}
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 월드컵 외 리그는 임시 비노출(운영 방침 미확정) ===== */}
{match.league === "wc" && <RankingBoard lang={lang} league={match.league} />}
{/* ===== 골드 상금 (003) — 야구(KBO/MLB)는 임시 비노출 ===== */}
{!isBaseball && (
{/* ===== 골드 상금 (003) — 월드컵 외 리그는 임시 비노출 ===== */}
{match.league === "wc" && (
<section className="gold-card mt-5 rounded-2xl p-5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">

View File

@ -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 (
<footer className="mt-6 text-center">
<p className="text-[10.5px] font-semibold text-white/75">

View File

@ -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}
</div>
<LeagueTabs lang={lang} current={league} />
</header>
@ -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 (
<div className="mt-3 flex justify-center">

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import type { Lang } from "@/lib/i18n";
import { timeOnly } from "@/lib/format";
import { getLive, type LiveData } from "@/lib/api";
import { getLive, type LiveData, type RelayEvent, type RelayGroup } from "@/lib/api";
import type { Match } from "@/lib/types";
// 라이브 필드 뷰 — 수비 배치 다이아몬드 + 현재 타자/투수 + B/S/O + 주자 + 대기타석.
@ -111,6 +111,91 @@ function BallparkSvg({ bases }: { bases?: boolean[] }) {
);
}
// 이벤트 종류별 텍스트 스타일 — 투구는 흐리게, 타석 결과는 강조.
const RELAY_STYLE: Record<RelayEvent["kind"], string> = {
pitch: "text-white/35",
result: "font-extrabold text-white/90",
run: "text-[#8FD3FF]",
sub: "text-white/45",
note: "text-white/40",
inning: "text-white/60",
batter: "text-white/60",
end: "font-bold text-white/70",
};
// 문자중계 — 타석 단위 그룹(최신순). events 가 비면 이닝 구분 헤더.
// 원문이 한국어(네이버)라 영문 모드에서도 그대로 노출한다.
function RelayFeed({ groups, lang }: { groups: RelayGroup[]; lang: Lang }) {
const [showPitches, setShowPitches] = useState(true);
return (
<div className="mt-3 border-t border-white/10 pt-2.5">
<div className="mb-1.5 flex items-baseline justify-between">
<span className="text-[10px] font-bold text-white/35">
{lang === "en" ? "PLAY-BY-PLAY" : "문자중계"}
</span>
<div className="flex items-baseline gap-2">
<button
onClick={() => setShowPitches((v) => !v)}
className="text-[10px] font-bold text-white/45 transition active:scale-95"
>
{showPitches
? lang === "en" ? "Hide pitches" : "투구 접기"
: lang === "en" ? "Show pitches" : "투구 펼치기"}
</button>
<span className="text-[9px] text-white/25">NAVER</span>
</div>
</div>
<ul className="max-h-[260px] space-y-2 overflow-y-auto pr-1 [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">
{groups.map((g) => {
const events = showPitches ? g.events : g.events.filter((e) => e.kind !== "pitch");
// 이닝 구분 헤더 (내용 없는 그룹)
if (g.events.length === 0) {
return (
<li key={g.no} className="flex items-center gap-2 pt-1">
<span className="h-px flex-1 bg-white/10" />
<span className="text-[10px] font-extrabold text-white/40">{g.title}</span>
<span className="h-px flex-1 bg-white/10" />
</li>
);
}
if (events.length === 0) return null; // 투구만 있던 타석 — 접기 모드에선 숨김
return (
<li key={g.no}>
{g.title && (
<div className="flex items-baseline gap-1.5">
{g.inn != null && (
<span className="shrink-0 font-mono text-[10px] font-bold text-white/30">
{g.inn}
{g.half === "B" ? "말" : "초"}
</span>
)}
<span className="text-[11px] font-extrabold text-white/75">{g.title}</span>
</div>
)}
<ul className="mt-0.5 space-y-px pl-1.5 text-[11px] leading-snug">
{events.map((e, i) => (
<li
key={i}
className={
e.score
? "rounded-lg border border-[#94FBE0]/30 bg-[#94FBE0]/10 px-1.5 py-1 font-extrabold text-[#94FBE0]"
: RELAY_STYLE[e.kind]
}
>
{e.text}
</li>
))}
</ul>
</li>
);
})}
</ul>
</div>
);
}
export default function LiveField({ match, lang = "ko" }: { match: Match; lang?: Lang }) {
const isLive = match.phase === "live";
// 경기 중엔 20초 폴링, 그 외(경기 전·종료)는 1회 로드.
@ -289,6 +374,9 @@ export default function LiveField({ match, lang = "ko" }: { match: Match; lang?:
))}
</div>
)}
{/* 문자중계 (KBO 만 — MLB 는 소스 미연동) */}
{(data.relay?.length ?? 0) > 0 && <RelayFeed groups={data.relay!} lang={lang} />}
</div>
</section>
);

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 { 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 (
<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"
style={{ boxShadow: "0 0 28px rgba(74,255,160,0.35), inset 0 0 24px rgba(74,255,160,0.06)" }}
>
{/* 파도타기 마스코트 — 축구 전용 (야구는 미노출) */}
{!isBaseball && (
{/* 파도타기 마스코트 — 월드컵 전용 (야구·MLS 미노출) */}
{!isClub && (
<div className="absolute right-2 top-2 z-10">
<WaveStrip />
</div>
@ -66,9 +94,9 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</div>
<div className="mt-0.5 text-[12px] text-white/65">
{match.venue}
{isBaseball && (
{isClub && (
<span className="text-white/40">
{" "}· {lang === "en" ? "Home: " : "홈 "}{teamShort(right, lang)}
{" "}· {lang === "en" ? "Home: " : "홈 "}{teamShort(match.teamB, lang)}
</span>
)}
</div>
@ -167,6 +195,57 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</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 && (
<div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]">
{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 }) {
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<LiveData | null>(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({
<span className="text-white/55">{shortDate(dateKey(match.kickoffKst), lang)} </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>
) : match.group && match.roundLabel ? (
<> · {tRound(match.roundLabel, lang)}</>
) : null}
</span>
<span className="flex items-center gap-1.5">
{!isBaseball && (
{!isClub && (
<span className="text-[11px] font-bold text-white/45">
{match.group
? lang === "en"
@ -461,8 +492,16 @@ function MatchCard({
: tRound(match.roundLabel, lang)}
</span>
)}
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
{phase === "live" && inning ? inning : t.phase[phase]}
<span
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>
</div>
@ -471,7 +510,7 @@ function MatchCard({
<div className="flex items-center gap-2">
<TeamFlag team={left} className={flagCls} />
<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>
{match.result ? (
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
@ -480,17 +519,17 @@ function MatchCard({
{rightScore}
</span>
) : phase === "live" && live?.score ? (
// 진행 중 실시간 스코어 (A=원정, B=홈)
// 진행 중 실시간 스코어 — flip 시(MLS) 홈 먼저
<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>
{live.score.home ?? 0}
{(flip ? live.score.away : live.score.home) ?? 0}
</span>
) : (
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
)}
<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>
<TeamFlag team={right} className={flagCls} />
</div>
@ -506,6 +545,16 @@ function MatchCard({
</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 픽 갈림 + 참여수 + 이동 화살표 (취소 경기는 숨김) */}
{match.votable && phase !== "cancelled" && (
<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 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 : ["-"];
}

View File

@ -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<string, { ko: string; en: string }> = {
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" },
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 (
<div className="flex flex-col gap-4">
@ -87,13 +90,25 @@ export default function StandingsTable({
<tr className="text-[11px] font-bold text-white/40">
<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>
{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>
{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" ? "L" : "패"}</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>
{isPoints ? (
<>
<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>
</thead>
<tbody>
@ -112,13 +127,25 @@ export default function StandingsTable({
<span className="truncate font-bold">{teamShort(r, lang)}</span>
</span>
</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>
{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/80">{r.l ?? "-"}</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>
{isPoints ? (
<>
<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>
))}
</tbody>

View File

@ -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,63 @@ export interface LiveData {
vsRecord?: string;
defense?: { pos?: string; name: string }[];
offenseLineup?: LiveBatter[];
relay?: RelayGroup[]; // 야구 문자중계 (KBO=네이버, 최신 타석순)
// 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 영문, 최신순)
}
// 야구 문자중계 — 타석 단위 그룹. events 가 비면 이닝 구분 헤더("7회초 한화 공격").
export interface RelayGroup {
inn?: number;
half?: "T" | "B";
no?: number;
title: string; // "5번타자 노시환"
events: RelayEvent[];
}
export interface RelayEvent {
// pitch=투구 · result=타석결과 · run=주루 · sub=교체 · note=기타 · inning/end=구분
kind: "pitch" | "result" | "run" | "sub" | "note" | "inning" | "batter" | "end";
text: string;
score?: boolean; // 이 이벤트로 점수가 났음
}
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> {

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.",
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<Lang, Dict> = {
"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.",

View File

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

View File

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

View File

@ -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 (
<main className="shell">

View File

@ -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() {
<MatchupHUD match={match} lang={lang} />
<LiveField match={match} lang={lang} />
{/* MLS 라이브 (축구) — 경기중에만 렌더, 야구 리그에선 null */}
<LiveSoccer match={match} lang={lang} />
<Arena
match={match}
predictions={match.predictions}