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