feat: KBO/MLB 운영 안정화 — 순위 탭·라이브 개선·취소/더블헤더 지원·야구 점수제

AI 예측
- 생성 시점을 킥오프 22시간 전으로 변경(선발투수 예고 이후), 잡 매시간 실행
- 경기 시작 후 생성 금지(사후 예측 방지), 경기 단위 커밋으로 중단 시 진행분 보존

일정·라이브 UI
- 야구 일정에 순위 탭 추가 (KBO 단일 순위표 · MLB 6개 디비전, /api/standings 신설)
- 경기중 카드에 현재 이닝·실시간 스코어 표시 (30s 폴링, 서버 15s 캐시)
- MLB 선발 누락 수정: statsapi 날짜 파라미터 미국 날짜 기준 보정(-1일)
- 라이브 대기타석 스크롤바 다크 테마 처리

운영 견고성
- 우천취소: 경기 삭제 → cancelled 상태 보존 (투표·예측 기록 유지, 정산/투표/AI 제외)
- 더블헤더: 시작시각순 seq로 1·2차전 분리 등록 (match_id _2, 정산·라이브·프리뷰 매핑)
- 축구 데이터 수집을 wc 리그로 한정 (야구 팀을 축구 API에 검색하던 쿼터 낭비 수정)

점수제
- 야구 판정 완화: 근접=득실차 ±1, 부분=한 팀 득점 ±1 (등급·배점은 리그 공통 유지)
- 채점 판정을 _grade_of() 단일 함수로 통합, 종료된 야구 경기 재채점
- 배점 안내 모달 야구 문구 분리, docs/SCORING.md 갱신

기타
- 야구 전용 댓글 닉네임 풀 (종목별 세션 캐시 분리)
- 리더보드 리그별 조회 연동 (?league=)
develop
jwkim 2026-07-22 11:01:10 +09:00
parent bfaf621949
commit 4e1ebc8989
46 changed files with 2560 additions and 188 deletions

View File

@ -87,12 +87,30 @@ class Settings(BaseSettings):
return "football-data" return "football-data"
return self.schedule_source.lower() return self.schedule_source.lower()
# ── 멀티리그 (wc=월드컵 축구 · kbo · mlb) ────────────────
# 활성 리그 (콤마구분). 야구 리그는 워커가 각자 소스에서 일정·결과를 동기화.
leagues: str = "wc,kbo,mlb"
# 야구 일정 수집 윈도우 — 오늘 기준 미래 며칠치.
# (투표 오픈·AI 예측 생성은 리그 공통: vote_open_hours_before / ai_generate_lookahead_hours)
baseball_days_ahead: int = 7
# 네이버 스포츠 비공식 API (KBO 일정·결과·프리뷰·문자중계) — 비공식, 로컬용.
naver_api_base: str = "https://api-gw.sports.naver.com"
# MLB 공식 Stats API (키 불필요).
mlb_api_base: str = "https://statsapi.mlb.com/api"
@property
def league_list(self) -> list[str]:
return [x.strip() for x in self.leagues.split(",") if x.strip()]
# AI 예측 생성 전체 스위치 — False 면 워커가 AI 호출을 전혀 하지 않음(로컬 테스트).
ai_enabled: bool = True
# 매일 AI 예측 생성 시각 (KST). 기능정의서: 매일 0시 1회 생성. # 매일 AI 예측 생성 시각 (KST). 기능정의서: 매일 0시 1회 생성.
ai_generate_hour: int = 0 ai_generate_hour: int = 0
ai_generate_minute: int = 5 ai_generate_minute: int = 5
# 투표 오픈(D-2) 선행 생성 시간(h). 1일 1회 생성이므로 다음 주기 전 오픈할 # 투표 오픈(D-2) 선행 생성 시간(h). 1일 1회 생성이므로 다음 주기 전 오픈할
# 경기를 미리 채우려면 ~24h 이상 필요. 30h = 하루치 + 여유. (only_missing 이라 총 호출 수 동일) # 경기를 미리 채우려면 ~24h 이상 필요. 30h = 하루치 + 여유. (only_missing 이라 총 호출 수 동일)
ai_generate_lookahead_hours: int = 30 # 킥오프 N시간 전부터 생성 — 야구 선발투수 예고(경기 전날 저녁) 이후 시점.
ai_generate_lookahead_hours: int = 22
# ── 결과 자동 정산(관리자 입력 불필요) ─────────────────── # ── 결과 자동 정산(관리자 입력 불필요) ───────────────────
# 킥오프 + 이 시간(시) 경과 후, 외부 소스에서 스코어를 자동 수집해 채점·집계·메일. # 킥오프 + 이 시간(시) 경과 후, 외부 소스에서 스코어를 자동 수집해 채점·집계·메일.

View File

@ -42,6 +42,7 @@ def kst_iso(dt: datetime) -> str:
def compute_phase(m: Match, now: datetime | None = None) -> str: def compute_phase(m: Match, now: datetime | None = None) -> str:
"""투표/경기 단계: """투표/경기 단계:
cancelled 우천취소 취소 확정 "취소" (투표·정산 제외, 기록은 보존)
finished 결과 입력됨 "종료" finished 결과 입력됨 "종료"
live 킥오프 이후, 결과 입력 "경기중" live 킥오프 이후, 결과 입력 "경기중"
locked 투표 마감(킥오프 1h ) ~ 킥오프 "투표 종료"(비활성) locked 투표 마감(킥오프 1h ) ~ 킥오프 "투표 종료"(비활성)
@ -49,6 +50,8 @@ def compute_phase(m: Match, now: datetime | None = None) -> str:
open 투표 open 투표
""" """
now = now or now_utc() now = now or now_utc()
if m.status == "cancelled": # 우천취소 등 — 시간과 무관하게 고정
return "cancelled"
if m.result_outcome is not None: if m.result_outcome is not None:
return "finished" return "finished"
if now >= ensure_aware(m.kickoff_at): if now >= ensure_aware(m.kickoff_at):
@ -71,6 +74,8 @@ def is_open_for_voting(m: Match, now: datetime | None = None) -> bool:
now = now or now_utc() now = now or now_utc()
if not is_votable(m): if not is_votable(m):
return False return False
if m.status == "cancelled":
return False
if m.result_outcome is not None: if m.result_outcome is not None:
return False return False
if now >= ensure_aware(m.lock_at): if now >= ensure_aware(m.lock_at):
@ -146,6 +151,7 @@ def match_out(
lang: str = "ko", lang: str = "ko",
include_predictions: bool = True, include_predictions: bool = True,
now: datetime | None = None, now: datetime | None = None,
extras: dict | None = None,
) -> MatchOut: ) -> MatchOut:
result = None result = None
if m.result_outcome is not None: if m.result_outcome is not None:
@ -164,6 +170,7 @@ def match_out(
phase = compute_phase(m, now) phase = compute_phase(m, now)
return MatchOut( return MatchOut(
matchId=m.match_id, matchId=m.match_id,
league=m.league or "wc",
roundLabel=m.round_label, roundLabel=m.round_label,
group=m.group, group=m.group,
teamA=_team_a(m), teamA=_team_a(m),
@ -180,4 +187,5 @@ def match_out(
result=result, result=result,
predictions=preds, predictions=preds,
crowd=crowd_out(m.crowd, m.match_id), crowd=crowd_out(m.crowd, m.match_id),
extras=extras,
) )

View File

@ -20,6 +20,7 @@ from .routers import (
matches, matches,
predictions, predictions,
share, share,
standings,
visits, visits,
) )
from .scoring import load_scoring_data from .scoring import load_scoring_data
@ -52,6 +53,7 @@ app.include_router(matches.router)
app.include_router(comments.router) app.include_router(comments.router)
app.include_router(predictions.router) app.include_router(predictions.router)
app.include_router(leaderboard.router) app.include_router(leaderboard.router)
app.include_router(standings.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(visits.router) app.include_router(visits.router)
# 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다. # 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다.

View File

@ -35,6 +35,8 @@ class Match(Base):
__tablename__ = "matches" __tablename__ = "matches"
match_id: Mapped[str] = mapped_column(String, primary_key=True) match_id: Mapped[str] = mapped_column(String, primary_key=True)
# 리그: wc(월드컵 축구) | kbo | mlb — 멀티리그 단일 서비스의 분기 키
league: Mapped[str] = mapped_column(String, default="wc", index=True)
round_label: Mapped[str] = mapped_column(String, default="") round_label: Mapped[str] = mapped_column(String, default="")
group: Mapped[str] = mapped_column(String, default="A") group: Mapped[str] = mapped_column(String, default="A")
@ -246,3 +248,20 @@ class FootballCache(Base):
fetched_at: Mapped[datetime] = mapped_column( fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now() DateTime(timezone=True), server_default=func.now()
) )
class DataCache(Base):
"""야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용.
key 규칙:
preview:{match_id} 경기 프리뷰 요약(선발투수·시즌 상대전적)
standings:{league} 리그 순위표 (팀코드 순위·승률·최근5 )
"""
__tablename__ = "data_cache"
key: Mapped[str] = mapped_column(String, primary_key=True)
payload: Mapped[dict] = mapped_column(JSON, default=dict)
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

View File

@ -28,8 +28,8 @@ router = APIRouter(prefix="/api/matches", tags=["comments"])
# 같은 기기 연속 작성 최소 간격(초) — 도배 방지(기본 방어). # 같은 기기 연속 작성 최소 간격(초) — 도배 방지(기본 방어).
COMMENT_COOLDOWN_SECONDS = 5 COMMENT_COOLDOWN_SECONDS = 5
# 닉네임 조합: 2글자(코믹 수식) + 3글자(축구 역할) = 항상 한국어 5글자. # 닉네임 조합: 2글자(코믹 수식) + 3글자(스포츠 역할) = 항상 한국어 5글자.
# 40 × 30 = 1200가지. # 리그별 풀 — 축구(월드컵) / 야구(KBO·MLB). 각 40 × 30 = 1200가지.
_NICK_A = [ _NICK_A = [
"잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕", "잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕",
"국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재", "국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재",
@ -42,6 +42,18 @@ _NICK_B = [
"압박왕", "태클왕", "중거리", "발리슛", "빌드업", "백패스", "자책골", "골기계", "압박왕", "태클왕", "중거리", "발리슛", "빌드업", "백패스", "자책골", "골기계",
"어시왕", "역습왕", "수문장", "골부자", "골가뭄", "발연기", "어시왕", "역습왕", "수문장", "골부자", "골가뭄", "발연기",
] ]
_NICK_A_BB = [
"잔디", "벤치", "후보", "똥손", "노룩", "멘붕", "동네", "주말", "폭발", "광속",
"진지", "번개", "백수", "천재", "괴물", "폭격", "강철", "무적", "질풍", "돌풍",
"불꽃", "분노", "음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면",
"치킨", "출근", "직관", "치맥", "응원", "국대", "신인", "은퇴", "각성", "꾸준",
]
_NICK_B_BB = [
"홈런왕", "도루왕", "타격왕", "안타왕", "삼진왕", "수비왕", "번트왕", "대타왕",
"역전타", "끝내기", "결승타", "병살타", "유격수", "외야수", "내야수", "마무리",
"셋업맨", "불펜왕", "승리조", "강속구", "커브왕", "직구왕", "변화구", "풀스윙",
"헛스윙", "배트맨", "만루왕", "출루왕", "도루자", "홈스틸",
]
def _author_hash(device_id: str) -> str: def _author_hash(device_id: str) -> str:
@ -49,12 +61,19 @@ def _author_hash(device_id: str) -> str:
return hashlib.sha256(device_id.encode("utf-8")).hexdigest() return hashlib.sha256(device_id.encode("utf-8")).hexdigest()
_NICK_A_SET = set(_NICK_A) # 검증은 두 리그 풀 합집합 기준(발급 리그와 작성 리그가 달라도 정상 닉이면 통과)
_NICK_B_SET = set(_NICK_B) _NICK_A_SET = set(_NICK_A) | set(_NICK_A_BB)
_NICK_B_SET = set(_NICK_B) | set(_NICK_B_BB)
def _random_nickname() -> str: def _is_baseball(match_id: str) -> bool:
"""랜덤 닉네임. 두 단어 조합으로 항상 5글자.""" return match_id.startswith(("KBO_", "MLB_"))
def _random_nickname(baseball: bool = False) -> str:
"""랜덤 닉네임. 두 단어 조합으로 항상 5글자 — 리그에 맞는 풀 사용."""
if baseball:
return random.choice(_NICK_A_BB) + random.choice(_NICK_B_BB)
return random.choice(_NICK_A) + random.choice(_NICK_B) return random.choice(_NICK_A) + random.choice(_NICK_B)
@ -67,7 +86,7 @@ def _valid_nickname(n: str) -> bool:
@router.get("/{match_id}/comments/nickname", response_model=CommentNicknameOut) @router.get("/{match_id}/comments/nickname", response_model=CommentNicknameOut)
async def issue_nickname(match_id: str) -> CommentNicknameOut: async def issue_nickname(match_id: str) -> CommentNicknameOut:
"""세션 시작 시 랜덤 닉 발급(클라가 sessionStorage 에 캐싱해 재사용).""" """세션 시작 시 랜덤 닉 발급(클라가 sessionStorage 에 캐싱해 재사용)."""
return CommentNicknameOut(nickname=_random_nickname()) return CommentNicknameOut(nickname=_random_nickname(_is_baseball(match_id)))
def _out(c: Comment) -> CommentOut: def _out(c: Comment) -> CommentOut:
@ -130,7 +149,11 @@ async def create_comment(
raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN") raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN")
# 세션 캐싱된 닉을 사용하되, 풀에 없는(조작된) 값이면 새 랜덤으로 대체 # 세션 캐싱된 닉을 사용하되, 풀에 없는(조작된) 값이면 새 랜덤으로 대체
nickname = body.nickname if _valid_nickname(body.nickname) else _random_nickname() nickname = (
body.nickname
if _valid_nickname(body.nickname)
else _random_nickname(_is_baseball(match_id))
)
comment = Comment( comment = Comment(
match_id=match_id, match_id=match_id,
author_hash=author_hash, author_hash=author_hash,

View File

@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from ..database import get_db from ..database import get_db
from ..models import Match, UserPoints from ..models import Match, UserPoints, UserPrediction
from ..scoring import is_excluded, score_prediction from ..scoring import is_excluded, score_prediction
from ..schemas import ( from ..schemas import (
AILeaderboardOut, AILeaderboardOut,
@ -32,8 +32,52 @@ def _mask(email: str) -> str:
@router.get("", response_model=LeaderboardOut) @router.get("", response_model=LeaderboardOut)
async def leaderboard( async def leaderboard(
limit: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db) limit: int = Query(50, ge=1, le=200),
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체 합산"),
db: AsyncSession = Depends(get_db),
) -> LeaderboardOut: ) -> LeaderboardOut:
if league:
# 리그별 랭킹 — 채점된 픽(points)을 리그 경기로 한정해 이메일별 재집계.
picks = (
await db.execute(
select(UserPrediction, Match)
.join(Match, Match.match_id == UserPrediction.match_id)
.where(
Match.league == league,
Match.result_outcome.is_not(None),
UserPrediction.points.is_not(None),
UserPrediction.email.is_not(None),
)
)
).all()
agg: dict[str, list] = {} # email → [pts, exact, played, first_ts]
for pk, m in picks:
row = agg.setdefault(pk.email, [0, 0, 0, None])
row[0] += pk.points or 0
row[1] += 1 if (
pk.score_a == m.result_score_a and pk.score_b == m.result_score_b
) else 0
row[2] += 1
ranked_rows = sorted(
((e, v) for e, v in agg.items() if not is_excluded(e)),
key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2]),
)
scored_matches = (
await db.execute(
select(func.count()).select_from(Match).where(
Match.league == league, Match.result_outcome.is_not(None)
)
)
).scalar() or 0
standings = [
StandingOut(
rank=i + 1, emailMasked=_mask(e),
totalPoints=v[0], exactCount=v[1], matchesPlayed=v[2],
)
for i, (e, v) in enumerate(ranked_rows[:limit])
]
return LeaderboardOut(standings=standings, scoredMatches=scored_matches)
rows = ( rows = (
await db.execute(select(UserPoints)) await db.execute(select(UserPoints))
).scalars().all() ).scalars().all()
@ -71,25 +115,32 @@ async def leaderboard(
@router.get("/ai", response_model=AILeaderboardOut) @router.get("/ai", response_model=AILeaderboardOut)
async def ai_leaderboard(db: AsyncSession = Depends(get_db)) -> AILeaderboardOut: async def ai_leaderboard(
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체"),
db: AsyncSession = Depends(get_db),
) -> AILeaderboardOut:
"""AI 모델 누적 랭킹 — 종료된 경기의 AI 예측을 유저와 동일한 배점으로 채점·합산. """AI 모델 누적 랭킹 — 종료된 경기의 AI 예측을 유저와 동일한 배점으로 채점·합산.
별도 누적 테이블 없이 조회 종료 경기 전수 재계산 결과/배점 변동에 별도 누적 테이블 없이 조회 종료 경기 전수 재계산 결과/배점 변동에
항상 일관. (모델 3 × 경기 수라 비용 무시 가능.) 항상 일관. (모델 3 × 경기 수라 비용 무시 가능.)
""" """
matches = ( q = (
await db.execute(
select(Match) select(Match)
.where(Match.result_outcome.is_not(None)) .where(Match.result_outcome.is_not(None))
.options(selectinload(Match.predictions)) .options(selectinload(Match.predictions))
) )
).scalars().all() if league:
q = q.where(Match.league == league)
matches = (await db.execute(q)).scalars().all()
# model → [points, exact_count, matches_played] # model → [points, exact_count, matches_played]
agg: dict[str, list[int]] = {} agg: dict[str, list[int]] = {}
for m in matches: for m in matches:
bb = m.league in ("kbo", "mlb")
for p in m.predictions: for p in m.predictions:
pts = score_prediction(p.score_a, p.score_b, m.result_score_a, m.result_score_b) pts = score_prediction(
p.score_a, p.score_b, m.result_score_a, m.result_score_b, baseball=bb
)
row = agg.setdefault(p.model, [0, 0, 0]) row = agg.setdefault(p.model, [0, 0, 0])
row[0] += pts row[0] += pts
row[1] += 1 if (p.score_a == m.result_score_a and p.score_b == m.result_score_b) else 0 row[1] += 1 if (p.score_a == m.result_score_a and p.score_b == m.result_score_b) else 0

View File

@ -10,6 +10,7 @@ from ..database import get_db
from ..domain import match_out from ..domain import match_out
from ..models import Match from ..models import Match
from ..schemas import MatchOut from ..schemas import MatchOut
from ..services.baseball_details import fetch_live, get_extras
router = APIRouter(prefix="/api/matches", tags=["matches"]) router = APIRouter(prefix="/api/matches", tags=["matches"])
@ -17,16 +18,19 @@ router = APIRouter(prefix="/api/matches", tags=["matches"])
@router.get("", response_model=list[MatchOut]) @router.get("", response_model=list[MatchOut])
async def list_matches( async def list_matches(
lang: str = Query("ko"), lang: str = Query("ko"),
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> list[MatchOut]: ) -> list[MatchOut]:
rows = ( q = (
await db.execute(
select(Match) select(Match)
.options(selectinload(Match.predictions), selectinload(Match.crowd)) .options(selectinload(Match.predictions), selectinload(Match.crowd))
.order_by(Match.kickoff_at) .order_by(Match.kickoff_at)
) )
).scalars().all() if league:
return [match_out(m, lang) for m in rows] q = q.where(Match.league == league)
rows = (await db.execute(q)).scalars().all()
extras = await get_extras(db, rows)
return [match_out(m, lang, extras=extras.get(m.match_id)) for m in rows]
@router.get("/{match_id}", response_model=MatchOut) @router.get("/{match_id}", response_model=MatchOut)
@ -44,4 +48,19 @@ async def get_match(
).scalars().first() ).scalars().first()
if not m: if not m:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND") raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
return match_out(m, lang) extras = await get_extras(db, [m])
return match_out(m, lang, extras=extras.get(m.match_id))
@router.get("/{match_id}/live")
async def get_live(
match_id: str,
db: AsyncSession = Depends(get_db),
) -> dict:
"""야구 라이브 필드 뷰 (kbo=네이버 relay, mlb=공식 feed/live). 15초 TTL 캐시."""
m = await db.get(Match, match_id)
if not m:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
if m.league not in ("kbo", "mlb"):
return {"available": False}
return await fetch_live(m)

View File

@ -0,0 +1,53 @@
"""리그 순위표 API — 워커가 캐싱한 standings:{league} 를 팀 정보와 합쳐 서빙.
KBO: 단일 테이블(10, 순위순). MLB: 디비전(AL/NL × ··) 6그룹.
캐시가 아직 없으면 groups 반환한다(프론트는 안내 문구 표시).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import DataCache
from ..teams_baseball import team_info
router = APIRouter(prefix="/api/standings", tags=["standings"])
# MLB 디비전 표시 순서 (AL 동→중→서, NL 동→중→서)
_MLB_DIV_ORDER = ["ALE", "ALC", "ALW", "NLE", "NLC", "NLW"]
@router.get("")
async def get_standings(
league: str = Query(..., description="kbo | mlb"),
db: AsyncSession = Depends(get_db),
) -> dict:
if league not in ("kbo", "mlb"):
return {"league": league, "updatedAt": None, "groups": []}
row = await db.get(DataCache, f"standings:{league}")
table: dict = row.payload if row else {}
rows = [{**team_info(league, code), **st} for code, st in table.items()]
if league == "kbo":
rows.sort(key=lambda r: r.get("rank") or 99)
groups = [{"key": None, "rows": rows}] if rows else []
else:
by_div: dict[str, list] = {}
for r in rows:
by_div.setdefault(r.get("div") or "", []).append(r)
for lst in by_div.values():
lst.sort(key=lambda r: r.get("rank") or 99)
groups = [
{"key": d, "rows": by_div[d]} for d in _MLB_DIV_ORDER if d in by_div
]
if not groups and rows:
# 캐시가 div 주입 이전 버전이면 전체 승률순 단일 그룹으로 폴백
rows.sort(key=lambda r: -float(r.get("wra") or 0))
groups = [{"key": None, "rows": rows}]
return {
"league": league,
"updatedAt": row.fetched_at.isoformat() if row and row.fetched_at else None,
"groups": groups,
}

View File

@ -47,6 +47,7 @@ class CrowdStatsOut(BaseModel):
class MatchOut(BaseModel): class MatchOut(BaseModel):
matchId: str matchId: str
league: str = "wc" # wc | kbo | mlb
roundLabel: str roundLabel: str
group: str group: str
teamA: Team teamA: Team
@ -63,6 +64,8 @@ class MatchOut(BaseModel):
result: MatchResult | None = None result: MatchResult | None = None
predictions: list[AIPredictionOut] = Field(default_factory=list) predictions: list[AIPredictionOut] = Field(default_factory=list)
crowd: CrowdStatsOut | None = None crowd: CrowdStatsOut | None = None
# 야구 부가정보 (프리뷰·순위 캐시 — 축구/캐시없음이면 None)
extras: dict | None = None
# ── 픽 제출 ───────────────────────────────────────────────── # ── 픽 제출 ─────────────────────────────────────────────────
@ -70,8 +73,8 @@ class SubmitPredictionIn(BaseModel):
matchId: str matchId: str
deviceId: str = Field(min_length=8, max_length=64) deviceId: str = Field(min_length=8, max_length=64)
outcome: Outcome outcome: Outcome
scoreA: int = Field(ge=0, le=9) scoreA: int = Field(ge=0, le=25) # 야구(득점) 상한. 축구는 UI 가 0-9 로 제한.
scoreB: int = Field(ge=0, le=9) scoreB: int = Field(ge=0, le=25)
email: EmailStr | None = None email: EmailStr | None = None
notify: bool = False notify: bool = False

View File

@ -2,6 +2,8 @@
배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분( 득점 일치) 1 · 빗나감 0. 배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분( 득점 일치) 1 · 빗나감 0.
단조 증가(정확>근접>승패>부분>빗나감). 단조 증가(정확>근접>승패>부분>빗나감).
야구(KBO/MLB) 득점 범위가 넓어 같은 등급 체계에 판정만 완화한다:
근접 = 승패 + 득실차 오차 ±1, 부분 = 득점 오차 ±1.
배점·배제 대상은 backend/data/scoring.json 에서 기동 로드(없으면 아래 기본값). 배점·배제 대상은 backend/data/scoring.json 에서 기동 로드(없으면 아래 기본값).
""" """
from __future__ import annotations from __future__ import annotations
@ -24,19 +26,31 @@ def outcome_of(score_a: int, score_b: int) -> str:
return "DRAW" return "DRAW"
# 야구 판정 허용 오차 — 근접(득실차)·부분(한 팀 득점) 공통 ±1
BASEBALL_TOLERANCE = 1
def _grade_of(
pick_a: int, pick_b: int, result_a: int, result_b: int, baseball: bool = False
) -> str:
"""등급 판정 (단일 SSOT) — baseball 이면 근접/부분 오차 ±1 허용."""
tol = BASEBALL_TOLERANCE if baseball else 0
if pick_a == result_a and pick_b == result_b:
return "EXACT"
if outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b):
if abs((pick_a - pick_b) - (result_a - result_b)) <= tol:
return "CLOSE"
return "OUTCOME"
if abs(pick_a - result_a) <= tol or abs(pick_b - result_b) <= tol:
return "PARTIAL"
return "MISS"
def score_prediction( def score_prediction(
pick_a: int, pick_b: int, result_a: int, result_b: int pick_a: int, pick_b: int, result_a: int, result_b: int, baseball: bool = False
) -> int: ) -> int:
"""내 예측 vs 실제 결과 → 적중 포인트 (결정론적).""" """내 예측 vs 실제 결과 → 적중 포인트 (결정론적)."""
if pick_a == result_a and pick_b == result_b: return SCORE[_grade_of(pick_a, pick_b, result_a, result_b, baseball)]
return SCORE["EXACT"]
if outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b):
if (pick_a - pick_b) == (result_a - result_b):
return SCORE["CLOSE"]
return SCORE["OUTCOME"]
if pick_a == result_a or pick_b == result_b:
return SCORE["PARTIAL"]
return SCORE["MISS"]
# ── 자격 · 배제 (docs/SCORING.md §4, P1 구현) ────────────────── # ── 자격 · 배제 (docs/SCORING.md §4, P1 구현) ──────────────────
@ -65,11 +79,12 @@ _JSON_KEY = {
def grade_prediction( def grade_prediction(
pick_a: int, pick_b: int, result_a: int, result_b: int, pick_a: int, pick_b: int, result_a: int, result_b: int,
path: Path | None = None, path: Path | None = None,
baseball: bool = False,
) -> tuple[str, int]: ) -> tuple[str, int]:
"""유저 투표(픽) vs 실제 결과 → scoring.json 에서 해당 등급의 (key, value). """유저 투표(픽) vs 실제 결과 → scoring.json 에서 해당 등급의 (key, value).
score_prediction 같은 판정 기준. : 승패+득실차 일치 ("score_close", 3). score_prediction 같은 판정 기준(_grade_of 공유). : 승패+득실차 일치
scoring.json 없거나 키가 빠지면 기본 SCORE 값으로 대체. ("score_close", 3). scoring.json 없거나 키가 빠지면 기본 SCORE 값으로 대체.
""" """
p = path or DATA_FILE p = path or DATA_FILE
try: try:
@ -78,15 +93,7 @@ def grade_prediction(
log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e) log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e)
data = {} data = {}
if pick_a == result_a and pick_b == result_b: grade = _grade_of(pick_a, pick_b, result_a, result_b, baseball)
grade = "EXACT"
elif outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b):
grade = "CLOSE" if (pick_a - pick_b) == (result_a - result_b) else "OUTCOME"
elif pick_a == result_a or pick_b == result_b:
grade = "PARTIAL"
else:
grade = "MISS"
key = _JSON_KEY[grade] key = _JSON_KEY[grade]
value = data[key] if isinstance(data.get(key), int) else SCORE[grade] value = data[key] if isinstance(data.get(key), int) else SCORE[grade]
return key, value return key, value

View File

@ -26,11 +26,12 @@ class ProviderUnavailable(RuntimeError):
@dataclass @dataclass
class MatchContext: class MatchContext:
team_a: str # 표시명 (예: Korea Republic) team_a: str # 표시명 (예: Korea Republic / 한화 이글스)
team_b: str team_b: str
venue: str venue: str
kickoff: str # ISO kickoff: str # ISO
data_block: str | None = None # 실데이터(폼·H2H·랭킹·WC결과) 주입 블록. 없으면 이름만. data_block: str | None = None # 실데이터(폼·H2H·랭킹 등) 주입 블록. 없으면 이름만.
league: str = "wc" # wc(축구) | kbo | mlb — 프롬프트·스코어 범위 분기
# 모델별 분석 관점(페르소나) — 동일 경기라도 서로 다른 시각으로 보게 해 # 모델별 분석 관점(페르소나) — 동일 경기라도 서로 다른 시각으로 보게 해
@ -54,6 +55,61 @@ PERSONA = {
} }
# 야구용 페르소나 — 축구 페르소나와 같은 3분화(데이터/수비·투수/공격) 구도.
BASEBALL_PERSONA = {
"GPT": (
"You are a DATA-DRIVEN baseball analyst. Base your call on recent form, "
"season standings, head-to-head record and run-scored/allowed trends. "
"Be objective and evidence-led; pick the scoreline the numbers most support."
),
"Claude": (
"You are a PITCHING-AND-DEFENSE analyst. Weigh starting rotation strength, "
"bullpen fatigue, and defensive quality. Take low-scoring games, tight "
"margins and genuine upset potential seriously — do not just rubber-stamp "
"the favorite."
),
"Gemini": (
"You are an OFFENSE-MINDED analyst. Weigh lineup depth, power hitting, "
"momentum and ballpark factors. Lean toward open, higher-scoring scenarios "
"when the bats and conditions justify it."
),
}
_LEAGUE_LABEL = {
"kbo": "2026 KBO League (Korean professional baseball) regular-season game",
"mlb": "2026 MLB (Major League Baseball) regular-season game",
}
def _prompt_baseball(ctx: MatchContext, model: str) -> str:
persona = BASEBALL_PERSONA[model]
data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
draw_note = (
"KBO regular-season games can end in a DRAW after 12 innings, but draws "
"are rare (~1-2% of games); only predict a draw with strong reason.\n"
if ctx.league == "kbo"
else "MLB games cannot end in a draw — never predict DRAW.\n"
)
return (
f"{persona}\n"
f"Predict the result of this {_LEAGUE_LABEL[ctx.league]} using YOUR "
f"perspective above. Judge independently — it is fine to differ from the "
f"obvious consensus pick when your perspective warrants it.\n"
f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n"
f"Ballpark: {ctx.venue}\nFirst pitch: {ctx.kickoff}\n"
f"{data}"
f"Important: Team B is the HOME team — home advantage applies. {draw_note}\n"
f"Predict the final score in runs. "
f"Respond with a single JSON object and nothing else, with keys:\n"
f' "scoreA": integer 0-25 (Team A runs),\n'
f' "scoreB": integer 0-25 (Team B runs),\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 _prompt(ctx: MatchContext, persona: str) -> str: def _prompt(ctx: MatchContext, persona: str) -> str:
# 실데이터 블록이 있으면 팀/킥오프 다음에 삽입(없으면 빈 문자열 — 기존 동작 동일). # 실데이터 블록이 있으면 팀/킥오프 다음에 삽입(없으면 빈 문자열 — 기존 동작 동일).
data = f"\n{ctx.data_block}\n" if ctx.data_block else "" data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
@ -80,6 +136,13 @@ def _prompt(ctx: MatchContext, persona: str) -> str:
) )
def _build_prompt(ctx: MatchContext, model: str) -> str:
"""리그별 프롬프트 선택 — 야구(kbo/mlb)는 야구 프롬프트, 그 외 축구."""
if ctx.league in ("kbo", "mlb"):
return _prompt_baseball(ctx, model)
return _prompt(ctx, PERSONA[model])
# JSON Schema (구조화 출력용 — Anthropic/OpenAI 공통) # JSON Schema (구조화 출력용 — Anthropic/OpenAI 공통)
_SCHEMA = { _SCHEMA = {
"type": "object", "type": "object",
@ -96,9 +159,9 @@ _SCHEMA = {
} }
def _normalize(data: dict) -> dict: def _normalize(data: dict, max_score: int = 9) -> dict:
a = max(0, min(9, int(data["scoreA"]))) a = max(0, min(max_score, int(data["scoreA"])))
b = max(0, min(9, int(data["scoreB"]))) b = max(0, min(max_score, int(data["scoreB"])))
# outcome 은 스코어와 일관되도록 서버에서 재도출 (모델 불일치 방지) # outcome 은 스코어와 일관되도록 서버에서 재도출 (모델 불일치 방지)
outcome = "TEAM_A_WIN" if a > b else "TEAM_B_WIN" if a < b else "DRAW" outcome = "TEAM_A_WIN" if a > b else "TEAM_B_WIN" if a < b else "DRAW"
conf = max(0, min(100, int(data.get("confidencePct", 50)))) conf = max(0, min(100, int(data.get("confidencePct", 50))))
@ -123,12 +186,12 @@ async def predict_gpt(ctx: MatchContext) -> dict:
model=settings.openai_model, model=settings.openai_model,
messages=[ messages=[
{"role": "system", "content": "You output only valid JSON."}, {"role": "system", "content": "You output only valid JSON."},
{"role": "user", "content": _prompt(ctx, PERSONA["GPT"])}, {"role": "user", "content": _build_prompt(ctx, "GPT")},
], ],
response_format={"type": "json_object"}, response_format={"type": "json_object"},
) )
content = resp.choices[0].message.content or "{}" content = resp.choices[0].message.content or "{}"
return _normalize(json.loads(content)) return _normalize(json.loads(content), 25 if ctx.league in ("kbo", "mlb") else 9)
# ── Claude (Anthropic) ────────────────────────────────────── # ── Claude (Anthropic) ──────────────────────────────────────
@ -143,7 +206,7 @@ async def predict_claude(ctx: MatchContext) -> dict:
return await client.messages.create( return await client.messages.create(
model=settings.anthropic_model, model=settings.anthropic_model,
max_tokens=1024, max_tokens=1024,
messages=[{"role": "user", "content": _prompt(ctx, PERSONA["Claude"])}], messages=[{"role": "user", "content": _build_prompt(ctx, "Claude")}],
**extra, **extra,
) )
@ -157,7 +220,7 @@ async def predict_claude(ctx: MatchContext) -> dict:
except TypeError: except TypeError:
msg = await _create() msg = await _create()
text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text") text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
return _normalize(json.loads(text)) return _normalize(json.loads(text), 25 if ctx.league in ("kbo", "mlb") else 9)
# ── Gemini (Google) ───────────────────────────────────────── # ── Gemini (Google) ─────────────────────────────────────────
@ -170,10 +233,10 @@ async def predict_gemini(ctx: MatchContext) -> dict:
client = genai.Client(api_key=settings.google_api_key) client = genai.Client(api_key=settings.google_api_key)
resp = await client.aio.models.generate_content( resp = await client.aio.models.generate_content(
model=settings.google_model, model=settings.google_model,
contents=_prompt(ctx, PERSONA["Gemini"]), contents=_build_prompt(ctx, "Gemini"),
config=types.GenerateContentConfig(response_mime_type="application/json"), config=types.GenerateContentConfig(response_mime_type="application/json"),
) )
return _normalize(json.loads(resp.text or "{}")) return _normalize(json.loads(resp.text or "{}"), 25 if ctx.league in ("kbo", "mlb") else 9)
PROVIDERS = { PROVIDERS = {

View File

@ -0,0 +1,151 @@
"""야구 예측 프롬프트용 데이터 블록 — 자체 DB(정산 결과) + DataCache(프리뷰·순위).
결과 동기화가 쌓은 종료 경기에서 ·시즌 성적·상대전적을 계산하고,
캐시된 선발투수·공식 상대전적·순위를 덧붙인다. 모두 없으면 None(이름만 예측).
"""
from __future__ import annotations
from sqlalchemy import or_, select
from ..models import DataCache, Match
def _wdl(gf: int, ga: int) -> str:
return "W" if gf > ga else "L" if gf < ga else "D"
async def _team_games(db, league: str, code: str, before) -> list[dict]:
rows = (await db.execute(
select(Match)
.where(
Match.league == league,
or_(Match.team_a_code == code, Match.team_b_code == code),
Match.result_outcome.isnot(None),
Match.kickoff_at < before,
)
.order_by(Match.kickoff_at)
)).scalars().all()
out = []
for m in rows:
is_a = m.team_a_code == code # team_a = 원정
gf = m.result_score_a if is_a else m.result_score_b
ga = m.result_score_b if is_a else m.result_score_a
if gf is None or ga is None:
continue
out.append({
"r": _wdl(gf, ga), "gf": gf, "ga": ga,
"opp": m.team_b_short if is_a else m.team_a_short,
})
return out
def _team_lines(name: str, games: list[dict]) -> str:
head = f"[{name}]"
if not games:
return f"{head}\n (no season data yet — use general knowledge)"
n = len(games)
w = sum(1 for g in games if g["r"] == "W")
d = sum(1 for g in games if g["r"] == "D")
l = sum(1 for g in games if g["r"] == "L")
gf_avg = round(sum(g["gf"] for g in games) / n, 2)
ga_avg = round(sum(g["ga"] for g in games) / n, 2)
recent = games[-8:]
detail = "; ".join("{r} {gf}-{ga} vs {opp}".format(**g) for g in recent[-4:])
return (
f"{head}\n"
f" Season(in our data): {w}-{d}-{l} (W-D-L), "
f"runs avg {gf_avg} scored / {ga_avg} allowed over {n} games\n"
f" Form(last{len(recent)}): {' '.join(g['r'] for g in recent)} ({detail})"
)
async def _h2h_line(db, league: str, code_a: str, code_b: str, before) -> str:
rows = (await db.execute(
select(Match)
.where(
Match.league == league,
or_(
(Match.team_a_code == code_a) & (Match.team_b_code == code_b),
(Match.team_a_code == code_b) & (Match.team_b_code == code_a),
),
Match.result_outcome.isnot(None),
Match.kickoff_at < before,
)
.order_by(Match.kickoff_at)
)).scalars().all()
parts = [
f"{m.team_a_short} {m.result_score_a}-{m.result_score_b} {m.team_b_short}"
for m in rows[-5:]
if m.result_score_a is not None
]
return "; ".join(parts) if parts else "no meetings in our data yet"
def _starter_line(side: str, s: dict | None) -> str | None:
if not s or not s.get("name"):
return None
era = f", season ERA {s['era']}" if s.get("era") else ""
rec = (
f" ({s['w']}W-{s['l']}L)"
if s.get("w") is not None and s.get("l") is not None else ""
)
vs = f", ERA vs this opponent {s['vsEra']}" if s.get("vsEra") else ""
return f"[{side} starting pitcher] {s['name']}{era}{rec}{vs}"
def _standing_line(name: str, st: dict | None) -> str | None:
if not st:
return None
extra = f", last5 {st['last5']}" if st.get("last5") else ""
return (
f"[{name} standings] rank {st.get('rank')}, {st.get('w')}W-"
f"{st.get('l')}L (pct {st.get('wra')}){extra}"
)
async def _cached_extras(db, match: Match) -> list[str]:
lines: list[str] = []
prev = await db.get(DataCache, f"preview:{match.match_id}")
if prev:
p = prev.payload
for line in (
_starter_line("Away", p.get("starterA")),
_starter_line("Home", p.get("starterB")),
):
if line:
lines.append(line)
vs = p.get("seasonVs")
if vs and vs.get("aWin") is not None:
lines.append(
f"[Season head-to-head (official)] away {vs['aWin']}W - "
f"{vs.get('draw', 0)}D - home {vs.get('bWin')}W"
)
st_row = await db.get(DataCache, f"standings:{match.league}")
if st_row:
table = st_row.payload
for line in (
_standing_line(match.team_a_short, table.get(match.team_a_code)),
_standing_line(match.team_b_short, table.get(match.team_b_code)),
):
if line:
lines.append(line)
return lines
async def build_baseball_data_block(db, match: Match) -> str | None:
ga = await _team_games(db, match.league, match.team_a_code, match.kickoff_at)
gb = await _team_games(db, match.league, match.team_b_code, match.kickoff_at)
extras = await _cached_extras(db, match)
if not ga and not gb and not extras:
return None
h2h = await _h2h_line(
db, match.league, 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}",
*extras,
"===",
])

View File

@ -0,0 +1,540 @@
"""야구 부가 데이터 — 프리뷰(선발투수·상대전적)·리그 순위·라이브 필드 뷰.
KBO: 네이버 스포츠 비공식 API (프리뷰·순위·문자중계 relay)
MLB: 공식 Stats API (probablePitcher·standings·feed/live)
수집(refresh_*) 워커가 매일, 조회(get_extras) 라우터가 캐시만 읽음.
라이브(fetch_live) 요청 프록시 + 짧은 TTL 메모리 캐시.
"""
from __future__ import annotations
import logging
import time
from datetime import datetime, timedelta, timezone
from ..config import settings
from ..domain import ensure_aware, now_utc
from ..models import DataCache, Match
from ..teams_baseball import KBO_SHORT_TO_CODE, MLB_ID_TO_CODE, MLB_TEAMS
from .baseball_sync import match_seq
log = logging.getLogger("triplepick.baseball")
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"
)
}
def naver_game_id(m: Match, game_no: str = "0") -> str:
kst = ensure_aware(m.kickoff_at).astimezone(KST)
return f"{kst.strftime('%Y%m%d')}{m.team_a_code}{m.team_b_code}{game_no}{kst.year}"
def naver_game_id_candidates(m: Match) -> list[str]:
"""더블헤더 대응 gameId 후보 — 끝번호 0(단일)/1(DH 1차전)/2(DH 2차전).
seq1 단일을 먼저 시도하고 DH 1차전으로 폴백, seq2 2차전 고정."""
nos = ["2"] if match_seq(m.match_id) == 2 else ["0", "1"]
return [naver_game_id(m, n) for n in nos]
async def _naver_get_first(client, m: Match, suffix: str) -> dict | None:
"""gameId 후보를 순서대로 시도해 첫 성공 응답을 반환."""
for gid in naver_game_id_candidates(m):
try:
res = await _naver_get(client, f"/schedule/games/{gid}/{suffix}")
except Exception: # noqa: BLE001 — 후보 불일치(404 등)는 다음 후보로
continue
if res:
return res
return None
async def _naver_get(client, path: str) -> dict | None:
r = await client.get(settings.naver_api_base + path, headers=UA)
r.raise_for_status()
data = r.json()
return data.get("result") if data.get("success") else None
async def _upsert(db, key: str, payload: dict) -> None:
row = await db.get(DataCache, key)
if row:
row.payload = payload
row.fetched_at = now_utc()
else:
db.add(DataCache(key=key, payload=payload, fetched_at=now_utc()))
# ── 프리뷰 (선발투수·시즌 상대전적) ────────────────────────────
def _kbo_starter(raw: dict | None) -> dict | None:
if not raw:
return None
info = raw.get("playerInfo") or {}
season = raw.get("currentSeasonStats") or {}
vs = raw.get("currentSeasonStatsOnOpponents") or {}
out = {
"name": info.get("name", ""),
"hitType": info.get("hitType", ""),
"era": season.get("era"),
"w": season.get("w"), "l": season.get("l"),
"vsEra": vs.get("era"),
}
return out if out["name"] else None
async def _refresh_previews_kbo(db, matches: list[Match]) -> int:
import httpx
n = 0
async with httpx.AsyncClient(timeout=15) as client:
for m in matches:
try:
res = await _naver_get_first(client, m, "preview")
p = (res or {}).get("previewData") or {}
except Exception as e: # noqa: BLE001
log.warning("kbo preview 실패 %s: %s", m.match_id, e)
continue
vs = p.get("seasonVsResult") or {}
payload = {
"starterA": _kbo_starter(p.get("awayStarter")),
"starterB": _kbo_starter(p.get("homeStarter")),
"seasonVs": {
"aWin": vs.get("aw"), "draw": vs.get("ad") or 0, "bWin": vs.get("hw"),
} if vs else None,
}
if payload["starterA"] or payload["starterB"] or payload["seasonVs"]:
await _upsert(db, f"preview:{m.match_id}", payload)
n += 1
return n
_HAND_KO = {"L": "", "R": "", "S": ""}
def _mlb_starter(p: dict, stats: dict[int, dict]) -> dict | None:
if not p.get("fullName"):
return None
out: dict = {"name": p["fullName"]}
out.update(stats.get(p.get("id"), {}))
return out
async def _mlb_season_vs(c, m: Match, year: int) -> dict | None:
"""시즌 정규 상대전적 — 두 팀 간 완료 경기 승수 집계 (팀쌍당 1콜)."""
aid = (MLB_TEAMS.get(m.team_a_code) or {}).get("mlb_id")
bid = (MLB_TEAMS.get(m.team_b_code) or {}).get("mlb_id")
if not aid or not bid:
return None
r = await c.get(
f"{settings.mlb_api_base}/v1/schedule?sportId=1&season={year}&gameType=R"
f"&teamId={bid}&opponentId={aid}"
f"&startDate={year}-03-01&endDate={datetime.now(KST).date().isoformat()}"
)
r.raise_for_status()
wins = {aid: 0, bid: 0}
for day in r.json().get("dates") or []:
for g in day.get("games") or []:
if (g.get("status") or {}).get("abstractGameState") != "Final":
continue
for side in ("away", "home"):
t = g["teams"][side]
tid = (t.get("team") or {}).get("id")
if t.get("isWinner") and tid in wins:
wins[tid] += 1
if wins[aid] + wins[bid] == 0:
return None
return {"aWin": wins[aid], "draw": 0, "bWin": wins[bid]}
async def _refresh_previews_mlb(db, matches: list[Match]) -> int:
"""MLB 예고 선발(시즌 ERA·승패·투타 포함)·시즌 상대전적 — 공식 Stats API.
호출량: 일정 1 + 선발 스탯 일괄 1 + 상대전적 팀쌍당 1.
"""
import httpx
if not matches:
return 0
dates = sorted({ensure_aware(m.kickoff_at).astimezone(KST).date() for m in matches})
year = datetime.now(KST).year
# statsapi 의 start/endDate 는 미국 날짜 — KST 새벽~오전 경기는 미국 전날이라
# 시작일을 하루 앞당겨야 누락되지 않는다.
url = (
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
f"&startDate={(dates[0] - timedelta(days=1)).isoformat()}"
f"&endDate={dates[-1].isoformat()}"
"&hydrate=probablePitcher"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url)
r.raise_for_status()
data = r.json()
# (dateKst, away, home) → (원정 선발 raw, 홈 선발 raw)
starters: dict[tuple, tuple[dict, dict]] = {}
for day in data.get("dates") or []:
for g in day.get("games") or []:
a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id"))
b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id"))
gd = g.get("gameDate")
if not a or not b or not gd:
continue
d = (
datetime.fromisoformat(gd.replace("Z", "+00:00"))
.astimezone(KST).strftime("%Y%m%d")
)
pa = g["teams"]["away"].get("probablePitcher") or {}
pb = g["teams"]["home"].get("probablePitcher") or {}
starters[(d, a, b)] = (pa, pb)
# 선발 시즌 스탯 — people 일괄 조회 1콜 (ERA·승패·투타)
pids = sorted({
p["id"] for pair in starters.values() for p in pair if p.get("id")
})
pstats: dict[int, dict] = {}
if pids:
try:
r2 = await c.get(
f"{settings.mlb_api_base}/v1/people"
f"?personIds={','.join(map(str, pids))}"
f"&hydrate=stats(group=[pitching],type=[season],season={year})"
)
r2.raise_for_status()
for p in r2.json().get("people") or []:
splits = (p.get("stats") or [{}])[0].get("splits") or []
s = splits[0].get("stat", {}) if splits else {}
hand = _HAND_KO.get((p.get("pitchHand") or {}).get("code"))
bat = _HAND_KO.get((p.get("batSide") or {}).get("code"))
pstats[p["id"]] = {
"hitType": f"{hand}{bat}" if hand and bat else None,
"era": s.get("era"),
"w": s.get("wins"), "l": s.get("losses"),
}
except Exception as e: # noqa: BLE001 — 스탯 실패 시 이름만 표시
log.warning("mlb 선발 스탯 실패: %s", e)
n = 0
vs_cache: dict[tuple, dict | None] = {}
for m in matches:
d = ensure_aware(m.kickoff_at).astimezone(KST).strftime("%Y%m%d")
pa, pb = starters.get((d, m.team_a_code, m.team_b_code), ({}, {}))
pair = (m.team_a_code, m.team_b_code)
if pair not in vs_cache:
try:
vs_cache[pair] = await _mlb_season_vs(c, m, year)
except Exception as e: # noqa: BLE001
log.warning("mlb 상대전적 실패 %s: %s", m.match_id, e)
vs_cache[pair] = None
sa = _mlb_starter(pa, pstats)
sb = _mlb_starter(pb, pstats)
if sa or sb or vs_cache[pair]:
await _upsert(db, f"preview:{m.match_id}", {
"starterA": sa,
"starterB": sb,
"seasonVs": vs_cache[pair],
})
n += 1
return n
# ── 리그 순위 ──────────────────────────────────────────────────
async def _refresh_standings_kbo(db) -> bool:
import httpx
year = datetime.now(KST).year
try:
async with httpx.AsyncClient(timeout=15) as client:
res = await _naver_get(client, f"/stats/categories/kbo/seasons/{year}/teams")
except Exception as e: # noqa: BLE001
log.warning("kbo standings 실패: %s", e)
return False
table: dict[str, dict] = {}
for r in (res or {}).get("seasonTeamStats") or []:
code = KBO_SHORT_TO_CODE.get(r.get("teamShortName", ""))
if code:
table[code] = {
"rank": r.get("ranking"),
"w": r.get("winGameCount"), "d": r.get("drawnGameCount"),
"l": r.get("loseGameCount"), "wra": r.get("wra"),
"gb": r.get("gameBehind"), "last5": r.get("lastFiveGames"),
"avg": r.get("offenseHra"), "era": r.get("defenseEra"),
}
if not table:
return False
await _upsert(db, "standings:kbo", table)
return True
# statsapi division.id → 순위표 그룹 키 (AL/NL × 동·중·서)
_MLB_DIV = {201: "ALE", 202: "ALC", 200: "ALW", 204: "NLE", 205: "NLC", 203: "NLW"}
async def _refresh_standings_mlb(db) -> bool:
import httpx
year = datetime.now(KST).year
table: dict[str, dict] = {}
try:
async with httpx.AsyncClient(timeout=15) as c:
for lid in (103, 104): # AL, NL
r = await c.get(
f"{settings.mlb_api_base}/v1/standings?leagueId={lid}&season={year}"
)
r.raise_for_status()
for rec_div in r.json().get("records") or []:
div = _MLB_DIV.get((rec_div.get("division") or {}).get("id"))
for t in rec_div.get("teamRecords") or []:
code = MLB_ID_TO_CODE.get((t.get("team") or {}).get("id"))
if code:
table[code] = {
"div": div,
"rank": int(t.get("divisionRank") or 0) or None,
"w": t.get("wins"), "d": 0, "l": t.get("losses"),
"wra": t.get("winningPercentage"),
"gb": t.get("gamesBack"),
"last5": None, "avg": None, "era": None,
}
except Exception as e: # noqa: BLE001
log.warning("mlb standings 실패: %s", e)
return False
if not table:
return False
await _upsert(db, "standings:mlb", table)
return True
async def refresh_baseball_details(db, league: str, 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
]
if league == "kbo":
n = await _refresh_previews_kbo(db, targets)
await _refresh_standings_kbo(db)
elif league == "mlb":
n = await _refresh_previews_mlb(db, targets)
await _refresh_standings_mlb(db)
else:
return
await db.commit()
log.info("baseball details(%s): 프리뷰 %d경기 캐싱", league, n)
# ── extras 조회 (라우터 — 캐시만) ──────────────────────────────
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"):
continue
if m.league not in standings_cache:
row = await db.get(DataCache, f"standings:{m.league}")
standings_cache[m.league] = row.payload if row else {}
extras: dict = {}
prev = await db.get(DataCache, f"preview:{m.match_id}")
if prev:
extras.update(prev.payload)
st = standings_cache[m.league]
st_a, st_b = st.get(m.team_a_code), st.get(m.team_b_code)
if st_a or st_b:
extras["standings"] = {"a": st_a, "b": st_b}
if extras:
out[m.match_id] = extras
return out
# ── 라이브 필드 뷰 ─────────────────────────────────────────────
_LIVE_TTL_SEC = 15.0
_live_cache: dict[str, tuple[float, dict]] = {}
FIELD_POSITIONS = (
"포수", "1루수", "2루수", "3루수", "유격수", "좌익수", "중견수", "우익수",
)
# MLB 포지션 약어 → 한글 (필드 좌표 키와 통일)
MLB_POS = {
"C": "포수", "1B": "1루수", "2B": "2루수", "3B": "3루수", "SS": "유격수",
"LF": "좌익수", "CF": "중견수", "RF": "우익수",
}
def _current_slots(batters: list[dict]) -> list[dict]:
by_order: dict[int, dict] = {}
for b in batters or []:
o = b.get("batOrder")
if o is None:
continue
cur = by_order.get(o)
if cur is None or (b.get("seqno") or 0) > (cur.get("seqno") or 0):
by_order[o] = b
return [by_order[o] for o in sorted(by_order)]
def _batter_out(b: dict) -> dict:
return {
"order": b.get("batOrder"),
"name": b.get("name", ""),
"pos": b.get("posName", ""),
"avg": b.get("seasonHra"),
"sub": (b.get("seqno") or 1) > 1,
}
def _transform_naver_relay(t: dict) -> dict:
gs = t.get("currentGameState") or {}
home_batting = str(t.get("homeOrAway")) == "1"
home_lu = t.get("homeLineup") or {}
away_lu = t.get("awayLineup") or {}
offense_lu = home_lu if home_batting else away_lu
defense_lu = away_lu if home_batting else home_lu
offense = _current_slots(offense_lu.get("batter"))
defense = _current_slots(defense_lu.get("batter"))
pitchers = defense_lu.get("pitcher") or []
pitcher = max(pitchers, key=lambda p: p.get("seqno") or 0) if pitchers else {}
batter_code = str(gs.get("batter") or "")
batter = next((b for b in offense if str(b.get("pcode")) == batter_code), None)
return {
"available": True,
"inn": t.get("inn"),
"half": "B" if home_batting else "T",
"score": {"away": gs.get("awayScore"), "home": gs.get("homeScore")},
"bso": {"b": gs.get("ball"), "s": gs.get("strike"), "o": gs.get("out")},
"bases": [
str(gs.get(k) or "0") != "0" for k in ("base1", "base2", "base3")
],
"batter": _batter_out(batter) if batter else None,
"pitcher": {
"name": pitcher.get("name", ""),
"ballCount": pitcher.get("ballCount"),
} if pitcher else None,
"vsRecord": t.get("pitcherVsBatterCareerStats") or "",
"defense": [
{"pos": b.get("posName"), "name": b.get("name", "")}
for b in defense if b.get("posName") in FIELD_POSITIONS
],
"offenseLineup": [_batter_out(b) for b in offense],
}
def _transform_mlb_feed(feed: dict) -> dict:
ld = feed.get("liveData") or {}
ls = ld.get("linescore") or {}
box = (ld.get("boxscore") or {}).get("teams") or {}
half_top = (ls.get("inningHalf") or "").lower() == "top"
offense_side, defense_side = ("away", "home") if half_top else ("home", "away")
off = ls.get("offense") or {}
defn = ls.get("defense") or {}
def _players(side: str) -> dict:
return (box.get(side) or {}).get("players") or {}
# 수비 배치: boxscore players 의 position + 현재 출장(battingOrder 존재)
defense = []
for p in _players(defense_side).values():
pos = MLB_POS.get(((p.get("position") or {}).get("abbreviation") or ""))
name = ((p.get("person") or {}).get("fullName")) or ""
if pos and name and p.get("gameStatus", {}).get("isCurrentBatter") is not None:
defense.append({"pos": pos, "name": name})
# 같은 포지션 중복(교체) — 마지막 것만
dedup: dict[str, dict] = {f["pos"]: f for f in defense}
order_raw = (box.get(offense_side) or {}).get("battingOrder") or []
id_to_player = _players(offense_side)
lineup = []
for i, pid in enumerate(order_raw[:9]):
p = id_to_player.get(f"ID{pid}") or {}
lineup.append({
"order": i + 1,
"name": ((p.get("person") or {}).get("fullName")) or "",
"pos": MLB_POS.get(((p.get("position") or {}).get("abbreviation") or ""), ""),
"avg": None,
"sub": False,
})
batter_name = ((off.get("batter") or {}).get("fullName")) or ""
batter = next((b for b in lineup if b["name"] == batter_name), None)
pitcher = (defn.get("pitcher") or {}).get("fullName") or \
(off.get("pitcher") or {}).get("fullName") or ""
return {
"available": bool(ls.get("currentInning")),
"inn": ls.get("currentInning"),
"half": "T" if half_top else "B",
"score": {
"away": ((ls.get("teams") or {}).get("away") or {}).get("runs"),
"home": ((ls.get("teams") or {}).get("home") or {}).get("runs"),
},
"bso": {"b": ls.get("balls"), "s": ls.get("strikes"), "o": ls.get("outs")},
"bases": [bool(off.get("first")), bool(off.get("second")), bool(off.get("third"))],
"batter": batter or ({"order": None, "name": batter_name} if batter_name else None),
"pitcher": {"name": pitcher, "ballCount": None} if pitcher else None,
"vsRecord": "",
"defense": list(dedup.values()),
"offenseLineup": lineup,
}
async def fetch_live(m: Match) -> dict:
"""라이브 필드 뷰 페이로드 (리그별 소스). 미게시면 available=False."""
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:
if m.league == "kbo":
async with httpx.AsyncClient(timeout=10) as client:
res = await _naver_get_first(client, m, "relay")
t = (res or {}).get("textRelayData")
if t:
payload = _transform_naver_relay(t)
elif m.league == "mlb":
# gamePk 를 일정에서 재조회 — MLB 일정 API 의 date 는 미국 날짜라
# KST 기준 하루 전~당일 범위로 조회 후 dateKst 로 정확히 매칭.
kst = ensure_aware(m.kickoff_at).astimezone(KST)
date_kst = kst.strftime("%Y%m%d")
start = (kst.date() - timedelta(days=1)).isoformat()
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
f"&startDate={start}&endDate={kst.date().isoformat()}"
)
r.raise_for_status()
# 더블헤더 대응: 같은 날짜·팀쌍 경기를 시작시각순으로 모아
# match_id 의 차수(seq)에 해당하는 경기를 고른다.
cands: list[tuple[str, int]] = []
for day in r.json().get("dates") or []:
for g in day.get("games") or []:
a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id"))
b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id"))
gd = g.get("gameDate")
if not a or not b or not gd:
continue
g_kst = (
datetime.fromisoformat(gd.replace("Z", "+00:00"))
.astimezone(KST).strftime("%Y%m%d")
)
if a == m.team_a_code and b == m.team_b_code and g_kst == date_kst:
cands.append((gd, g.get("gamePk")))
cands.sort()
idx = match_seq(m.match_id) - 1
pk = cands[idx][1] if idx < len(cands) else None
if pk:
r2 = await c.get(f"{settings.mlb_api_base}/v1.1/game/{pk}/feed/live")
r2.raise_for_status()
payload = _transform_mlb_feed(r2.json())
except Exception as e: # noqa: BLE001
log.warning("live 실패 %s: %s", m.match_id, e)
payload = {"available": False}
_live_cache[m.match_id] = (time.monotonic(), payload)
return payload

View File

@ -0,0 +1,140 @@
"""야구 일정·결과 수집 — KBO(네이버 비공식) + MLB(공식 Stats API).
반환 표준 레코드 ( 리그 공통):
{league, teamA(원정), teamB(), dateKst 'YYYYMMDD', kickoffKst ISO(+09:00),
venue, cancelled, [scoreA, scoreB]}
야구는 같은 팀이 시즌 반복 대결 경기 식별에 반드시 dateKst 포함.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from ..config import settings
from ..teams_baseball import KBO_TEAMS, MLB_ID_TO_CODE
log = logging.getLogger("triplepick.baseball")
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"
)
}
# ── KBO (네이버) ───────────────────────────────────────────────
async def _fetch_kbo(days_back: int, days_ahead: int) -> list[dict]:
import httpx
today = datetime.now(KST).date()
url = (
f"{settings.naver_api_base}/schedule/games"
"?fields=basic,stadium,statusNum"
"&upperCategoryId=kbaseball&categoryId=kbo"
f"&fromDate={(today - timedelta(days=days_back)).isoformat()}"
f"&toDate={(today + timedelta(days=days_ahead)).isoformat()}&size=500"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url, headers=UA)
r.raise_for_status()
games = (r.json().get("result") or {}).get("games") or []
out: list[dict] = []
for g in games:
a = (g.get("awayTeamCode") or "").upper()
b = (g.get("homeTeamCode") or "").upper()
if a not in KBO_TEAMS or b not in KBO_TEAMS:
continue # 올스타/시범 등 제외
dt = g.get("gameDateTime") # KST, 오프셋 없음
if not dt:
continue
kickoff = datetime.fromisoformat(dt).replace(tzinfo=KST)
rec = {
"league": "kbo",
"teamA": a, "teamB": b,
"dateKst": kickoff.strftime("%Y%m%d"),
"kickoffKst": kickoff.isoformat(),
"venue": g.get("stadium", ""),
"cancelled": bool(g.get("cancel")),
}
if g.get("statusCode") == "RESULT" and not rec["cancelled"]:
sa, sb = g.get("awayTeamScore"), g.get("homeTeamScore")
if sa is not None and sb is not None:
rec["scoreA"], rec["scoreB"] = int(sa), int(sb)
out.append(rec)
return out
# ── MLB (공식 Stats API) ───────────────────────────────────────
async def _fetch_mlb(days_back: int, days_ahead: int) -> list[dict]:
import httpx
today = datetime.now(KST).date()
url = (
f"{settings.mlb_api_base}/v1/schedule?sportId=1"
f"&startDate={(today - timedelta(days=days_back)).isoformat()}"
f"&endDate={(today + timedelta(days=days_ahead)).isoformat()}"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url)
r.raise_for_status()
data = r.json()
out: list[dict] = []
for day in data.get("dates") or []:
for g in day.get("games") or []:
a = MLB_ID_TO_CODE.get((g["teams"]["away"]["team"] or {}).get("id"))
b = MLB_ID_TO_CODE.get((g["teams"]["home"]["team"] or {}).get("id"))
if not a or not b:
continue # 올스타전 등 제외
gd = g.get("gameDate") # ISO UTC
if not gd:
continue
kickoff = (
datetime.fromisoformat(gd.replace("Z", "+00:00")).astimezone(KST)
)
state = (g.get("status") or {}).get("detailedState", "")
rec = {
"league": "mlb",
"teamA": a, "teamB": b,
"dateKst": kickoff.strftime("%Y%m%d"),
"kickoffKst": kickoff.isoformat(),
"venue": (g.get("venue") or {}).get("name", ""),
"cancelled": state in ("Postponed", "Cancelled", "Suspended"),
"gamePk": g.get("gamePk"), # MLB 라이브 피드 키
}
if state == "Final" and not rec["cancelled"]:
sa = g["teams"]["away"].get("score")
sb = g["teams"]["home"].get("score")
if sa is not None and sb is not None:
rec["scoreA"], rec["scoreB"] = int(sa), int(sb)
out.append(rec)
return out
# ── 공개 API ────────────────────────────────────────────────────
async def fetch_baseball_schedule(league: str) -> list[dict]:
"""리그 일정+결과 수집 (취소 포함). 실패 시 빈 리스트 — 기존 일정 유지."""
try:
if league == "kbo":
records = await _fetch_kbo(settings.result_recheck_days, settings.baseball_days_ahead)
elif league == "mlb":
records = await _fetch_mlb(settings.result_recheck_days, settings.baseball_days_ahead)
else:
return []
# 더블헤더 차수 부여 — sync·정산이 같은 seq 로 경기를 식별한다.
from .baseball_sync import assign_seq
assign_seq(records)
log.info("baseball schedule(%s): %d경기 수집", league, len(records))
return records
except Exception as e: # noqa: BLE001
log.error("baseball schedule(%s) 실패: %s — 기존 일정 유지", league, e)
return []
async def fetch_baseball_results(league: str) -> list[dict]:
"""확정 스코어만 → [{league, teamA, teamB, dateKst, scoreA, scoreB}]."""
return [r for r in await fetch_baseball_schedule(league) if "scoreA" in r]

View File

@ -0,0 +1,130 @@
"""야구 일정 DB 반영 — (리그, KST 날짜, 팀쌍, 차수) 키 매칭. 워커가 매일 호출.
- 기존 경기: 매칭 시각/venue/투표시간 갱신 (결과·예측·crowd 보존)
- 신규 경기: 삽입 + crowd 초기화.
match_id = {LEAGUE}_{away}_{home}_{yyyymmdd} (더블헤더 2차전은 _2 접미)
- 취소(우천 ): 삭제하지 않고 status="cancelled" 보존 투표·예측 기록 유지,
정산·투표·AI 생성에서 제외. 소스가 취소를 번복하면 scheduled 복귀.
(보강 경기는 날짜의 신규 경기로 재등장)
- 더블헤더: 같은 (날짜, 팀쌍) 복수 경기를 시작시각순 seq(1,2) 구분해 모두 등록.
- 투표창: 리그 공통 오픈 오프셋(vote_open_hours_before, 기본 -168h)
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import CrowdStats, Match
from ..teams_baseball import team_info
log = logging.getLogger("triplepick.baseball")
KST = timezone(timedelta(hours=9))
def baseball_opens_at(kickoff: datetime) -> datetime:
# 축구와 동일 정책(vote_open_hours_before=168h) — 야구는 일정을 7일치만
# 수집하므로 사실상 동기화 즉시 투표 오픈된다.
return kickoff - timedelta(hours=settings.vote_open_hours_before)
def baseball_lock_at(kickoff: datetime) -> datetime:
return kickoff - timedelta(minutes=settings.vote_lock_minutes_before)
def _date_of(m: Match) -> str:
return m.kickoff_at.astimezone(KST).strftime("%Y%m%d") if m.kickoff_at else ""
def match_seq(match_id: str) -> int:
"""match_id 의 더블헤더 차수. `..._20260722` → 1, `..._20260722_2` → 2."""
tail = match_id.rsplit("_", 1)[-1]
return int(tail) if len(tail) <= 2 and tail.isdigit() else 1
def assign_seq(records: list[dict]) -> None:
"""같은 (날짜, 팀쌍) 레코드에 시작시각순 seq(1,2,…)를 부여 — 더블헤더 구분."""
groups: dict[tuple, list[dict]] = {}
for rec in records:
groups.setdefault((rec["dateKst"], rec["teamA"], rec["teamB"]), []).append(rec)
for recs in groups.values():
recs.sort(key=lambda r: r["kickoffKst"])
for i, rec in enumerate(recs, start=1):
rec["seq"] = i
async def sync_baseball_schedule(
db: AsyncSession, league: str, records: list[dict]
) -> dict:
if not records:
return {"updated": 0, "inserted": 0, "skipped": 0, "removed": 0}
assign_seq(records)
existing = (
await db.execute(select(Match).where(Match.league == league))
).scalars().all()
by_key: dict[tuple, Match] = {}
for m in existing:
d, s = _date_of(m), match_seq(m.match_id)
by_key[(d, m.team_a_code, m.team_b_code, s)] = m
by_key[(d, m.team_b_code, m.team_a_code, s)] = m
updated = inserted = skipped = removed = 0
for rec in records:
a, b, d = rec["teamA"], rec["teamB"], rec["dateKst"]
seq = rec.get("seq", 1)
m = by_key.get((d, a, b, seq))
if rec.get("cancelled"):
# 소프트 취소 — 투표/예측 기록 보존, 정산·투표·AI 대상에서 제외.
if m is not None and m.result_outcome is None and m.status != "cancelled":
m.status = "cancelled"
removed += 1
continue
kickoff = datetime.fromisoformat(rec["kickoffKst"]).astimezone(timezone.utc)
if m is not None:
if m.result_outcome is not None:
skipped += 1
continue
if m.status == "cancelled":
m.status = "scheduled" # 취소 번복 — 시간 기준 상태는 tick 이 복원
m.kickoff_at = kickoff
m.opens_at = baseball_opens_at(kickoff)
m.lock_at = baseball_lock_at(kickoff)
if rec.get("venue"):
m.venue = rec["venue"]
updated += 1
else:
ta, tb = team_info(league, a), team_info(league, b)
match_id = f"{league.upper()}_{a}_{b}_{d}" + (f"_{seq}" if seq > 1 else "")
new = Match(
match_id=match_id,
league=league,
round_label="정규시즌",
group="",
team_a_name=ta["name"], team_a_short=ta["shortName"],
team_a_code=ta["code"], team_a_flag=ta["flag"],
team_b_name=tb["name"], team_b_short=tb["shortName"],
team_b_code=tb["code"], team_b_flag=tb["flag"],
venue=rec.get("venue", ""),
hook_text=f"{ta['shortName']} vs {tb['shortName']}",
kickoff_at=kickoff,
opens_at=baseball_opens_at(kickoff),
lock_at=baseball_lock_at(kickoff),
status="scheduled",
)
db.add(new)
db.add(CrowdStats(match_id=match_id, total=0, team_a_win=0, draw=0, team_b_win=0))
by_key[(d, a, b, seq)] = new
by_key[(d, b, a, seq)] = new
inserted += 1
await db.commit()
result = {"updated": updated, "inserted": inserted, "skipped": skipped, "removed": removed}
log.info("baseball sync(%s): %s", league, result)
return result

View File

@ -32,8 +32,11 @@ async def apply_result(db: AsyncSession, match: Match, score_a: int, score_b: in
) )
).scalars().all() ).scalars().all()
baseball = match.league in ("kbo", "mlb")
for p in picks: for p in picks:
key, value = grade_prediction(p.score_a, p.score_b, score_a, score_b) key, value = grade_prediction(
p.score_a, p.score_b, score_a, score_b, baseball=baseball
)
p.points = value p.points = value
p.scored_at = now_utc() p.scored_at = now_utc()

View File

@ -66,7 +66,8 @@ async def accumulate_user_points(
agg: dict[str, dict] = {e: _empty() for e in targets} agg: dict[str, dict] = {e: _empty() for e in targets}
for pick, match in rows: for pick, match in rows:
key, value = grade_prediction( key, value = grade_prediction(
pick.score_a, pick.score_b, match.result_score_a, match.result_score_b pick.score_a, pick.score_b, match.result_score_a, match.result_score_b,
baseball=match.league in ("kbo", "mlb"),
) )
t = agg[pick.email.strip().lower()] t = agg[pick.email.strip().lower()]
t["total_points"] += value t["total_points"] += value

View File

@ -0,0 +1,78 @@
"""야구 팀 데이터 — KBO 10개 구단 + MLB 30개 구단 (코드 → 한글/약식/영문).
KBO 코드: 네이버/KBO 표준 2글자 (HT=KIA, OB=두산, WO=키움, LT=롯데, SK=SSG).
MLB 코드: 공식 약어 (NYY, LAD ) + mlb_id statsapi team.id (불변).
로고: frontend /assets/teams/{league}/{code소문자}.png
"""
from __future__ import annotations
# ── KBO ────────────────────────────────────────────────────────
KBO_TEAMS: dict[str, dict] = {
"HT": {"ko": "KIA 타이거즈", "short": "KIA", "en": "Kia Tigers", "home": "광주기아챔피언스필드"},
"LG": {"ko": "LG 트윈스", "short": "LG", "en": "LG Twins", "home": "잠실야구장"},
"OB": {"ko": "두산 베어스", "short": "두산", "en": "Doosan Bears", "home": "잠실야구장"},
"SS": {"ko": "삼성 라이온즈", "short": "삼성", "en": "Samsung Lions", "home": "대구삼성라이온즈파크"},
"LT": {"ko": "롯데 자이언츠", "short": "롯데", "en": "Lotte Giants", "home": "사직야구장"},
"SK": {"ko": "SSG 랜더스", "short": "SSG", "en": "SSG Landers", "home": "인천SSG랜더스필드"},
"KT": {"ko": "KT 위즈", "short": "KT", "en": "KT Wiz", "home": "수원KT위즈파크"},
"NC": {"ko": "NC 다이노스", "short": "NC", "en": "NC Dinos", "home": "창원NC파크"},
"WO": {"ko": "키움 히어로즈", "short": "키움", "en": "Kiwoom Heroes", "home": "고척스카이돔"},
"HH": {"ko": "한화 이글스", "short": "한화", "en": "Hanwha Eagles", "home": "대전한화생명볼파크"},
}
# ── MLB (statsapi team.id → 우리 코드) ─────────────────────────
MLB_TEAMS: dict[str, dict] = {
"LAD": {"ko": "LA 다저스", "short": "다저스", "en": "Los Angeles Dodgers", "mlb_id": 119},
"NYY": {"ko": "뉴욕 양키스", "short": "양키스", "en": "New York Yankees", "mlb_id": 147},
"NYM": {"ko": "뉴욕 메츠", "short": "메츠", "en": "New York Mets", "mlb_id": 121},
"BOS": {"ko": "보스턴 레드삭스", "short": "보스턴", "en": "Boston Red Sox", "mlb_id": 111},
"CHC": {"ko": "시카고 컵스", "short": "컵스", "en": "Chicago Cubs", "mlb_id": 112},
"CWS": {"ko": "시카고 화이트삭스", "short": "화이트삭스", "en": "Chicago White Sox", "mlb_id": 145},
"CLE": {"ko": "클리블랜드 가디언스", "short": "클리블랜드", "en": "Cleveland Guardians", "mlb_id": 114},
"DET": {"ko": "디트로이트 타이거스", "short": "디트로이트", "en": "Detroit Tigers", "mlb_id": 116},
"HOU": {"ko": "휴스턴 애스트로스", "short": "휴스턴", "en": "Houston Astros", "mlb_id": 117},
"KC": {"ko": "캔자스시티 로열스", "short": "캔자스시티", "en": "Kansas City Royals", "mlb_id": 118},
"LAA": {"ko": "LA 에인절스", "short": "에인절스", "en": "Los Angeles Angels", "mlb_id": 108},
"MIN": {"ko": "미네소타 트윈스", "short": "미네소타", "en": "Minnesota Twins", "mlb_id": 142},
"ATH": {"ko": "애슬레틱스", "short": "애슬레틱스", "en": "Athletics", "mlb_id": 133},
"SEA": {"ko": "시애틀 매리너스", "short": "시애틀", "en": "Seattle Mariners", "mlb_id": 136},
"TB": {"ko": "탬파베이 레이스", "short": "탬파베이", "en": "Tampa Bay Rays", "mlb_id": 139},
"TEX": {"ko": "텍사스 레인저스", "short": "텍사스", "en": "Texas Rangers", "mlb_id": 140},
"TOR": {"ko": "토론토 블루제이스", "short": "토론토", "en": "Toronto Blue Jays", "mlb_id": 141},
"ARI": {"ko": "애리조나 다이아몬드백스", "short": "애리조나", "en": "Arizona Diamondbacks", "mlb_id": 109},
"ATL": {"ko": "애틀랜타 브레이브스", "short": "애틀랜타", "en": "Atlanta Braves", "mlb_id": 144},
"BAL": {"ko": "볼티모어 오리올스", "short": "볼티모어", "en": "Baltimore Orioles", "mlb_id": 110},
"CIN": {"ko": "신시내티 레즈", "short": "신시내티", "en": "Cincinnati Reds", "mlb_id": 113},
"COL": {"ko": "콜로라도 로키스", "short": "콜로라도", "en": "Colorado Rockies", "mlb_id": 115},
"MIA": {"ko": "마이애미 말린스", "short": "마이애미", "en": "Miami Marlins", "mlb_id": 146},
"MIL": {"ko": "밀워키 브루어스", "short": "밀워키", "en": "Milwaukee Brewers", "mlb_id": 158},
"WSH": {"ko": "워싱턴 내셔널스", "short": "워싱턴", "en": "Washington Nationals", "mlb_id": 120},
"PHI": {"ko": "필라델피아 필리스", "short": "필라델피아", "en": "Philadelphia Phillies", "mlb_id": 143},
"PIT": {"ko": "피츠버그 파이리츠", "short": "피츠버그", "en": "Pittsburgh Pirates", "mlb_id": 134},
"SD": {"ko": "샌디에이고 파드리스", "short": "샌디에이고", "en": "San Diego Padres", "mlb_id": 135},
"SF": {"ko": "샌프란시스코 자이언츠", "short": "SF", "en": "San Francisco Giants", "mlb_id": 137},
"STL": {"ko": "세인트루이스 카디널스", "short": "세인트루이스", "en": "St. Louis Cardinals", "mlb_id": 138},
}
MLB_ID_TO_CODE: dict[int, str] = {v["mlb_id"]: k for k, v in MLB_TEAMS.items()}
# 순위표 등 팀 축약명 → 코드 (리그별)
KBO_SHORT_TO_CODE = {v["short"]: k for k, v in KBO_TEAMS.items()}
def teams_of(league: str) -> dict[str, dict]:
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."""
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"
else:
flag = f"/assets/teams/kbo/{c.lower()}.png"
return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": flag}

View File

@ -30,8 +30,10 @@ from .database import SessionLocal, init_db
from .domain import compute_phase, ensure_aware, now_utc from .domain import compute_phase, ensure_aware, now_utc
from .models import AIPrediction, Match, UserPrediction from .models import AIPrediction, Match, UserPrediction
from .scoring import load_scoring_data from .scoring import load_scoring_data
from .services import football_data from .services import baseball_data, baseball_details, football_data
from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable
from .services.baseball_fetch import fetch_baseball_results, fetch_baseball_schedule
from .services.baseball_sync import match_seq, sync_baseball_schedule
from .services.email import EmailUnavailable, build_result_email, send_email from .services.email import EmailUnavailable, build_result_email, send_email
from .services.grading import apply_result from .services.grading import apply_result
from .services.schedule_fetch import fetch_results, fetch_schedule from .services.schedule_fetch import fetch_results, fetch_schedule
@ -43,6 +45,9 @@ log = logging.getLogger("triplepick.worker")
# ── 0) 경기 일정 동기화 (외부 크롤링) ────────────────────── # ── 0) 경기 일정 동기화 (외부 크롤링) ──────────────────────
async def sync_schedule_job() -> None: async def sync_schedule_job() -> None:
"""월드컵(축구) 일정 동기화 — 기존 로직 그대로."""
if "wc" not in settings.league_list:
return
records = await fetch_schedule() records = await fetch_schedule()
if not records: if not records:
return return
@ -56,6 +61,34 @@ async def sync_schedule_job() -> None:
await generate_ai_predictions() await generate_ai_predictions()
async def sync_baseball_job() -> None:
"""야구(kbo/mlb) 일정 동기화 + 프리뷰·순위 캐시 갱신."""
inserted_any = False
for league in settings.league_list:
if league not in ("kbo", "mlb"):
continue
records = await fetch_baseball_schedule(league)
if not records:
continue
async with SessionLocal() as db:
result = await sync_baseball_schedule(db, league, records)
rows = (
await db.execute(
select(Match).where(
Match.league == league, Match.result_outcome.is_(None)
)
)
).scalars().all()
try:
await baseball_details.refresh_baseball_details(db, league, rows)
except Exception as e: # noqa: BLE001
log.warning("baseball details(%s) 갱신 실패: %s", league, e)
inserted_any = inserted_any or bool(result.get("inserted"))
await tick_status()
if inserted_any:
await generate_ai_predictions()
# ── 1) 상태 전이 ──────────────────────────────────────────── # ── 1) 상태 전이 ────────────────────────────────────────────
async def tick_status() -> None: async def tick_status() -> None:
async with SessionLocal() as db: async with SessionLocal() as db:
@ -83,11 +116,15 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
생성한다(직전에 실패/누락된 모델만 채워짐 API 비용, 예측 안정). 생성한다(직전에 실패/누락된 모델만 채워짐 API 비용, 예측 안정).
only_missing=False: 전부 재생성(덮어쓰기) 수동 재생성 스크립트용. only_missing=False: 전부 재생성(덮어쓰기) 수동 재생성 스크립트용.
""" """
if not settings.ai_enabled:
log.info("AI 예측 생성 스킵 — AI_ENABLED=false (로컬 테스트 모드)")
return
async with SessionLocal() as db: async with SessionLocal() as db:
# 투표 오픈(킥오프 D-2)이 임박/도래한 미종료 경기 — 조 무관. # 킥오프가 lookahead(기본 22h) 이내로 다가온 미시작 경기만 생성.
# lookahead_hours 만큼 선행 생성해 다음 생성 주기 전에 오픈할 경기도 미리 채운다 # 야구 선발투수 예고(전날 저녁)가 나온 뒤 시점이라 데이터 품질이 좋다.
# (1일 1회 생성 → 오픈 시점에 AI 가 비어 보이지 않도록). 더 먼 미래 경기는 제외. # 킥오프가 지난 경기는 제외(경기 중 생성 = 사후 예측 방지). 취소 경기 제외.
horizon = now_utc() + timedelta(hours=settings.ai_generate_lookahead_hours) now = now_utc()
horizon = now + timedelta(hours=settings.ai_generate_lookahead_hours)
rows = ( rows = (
await db.execute( await db.execute(
select(Match) select(Match)
@ -95,10 +132,17 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
.options(selectinload(Match.predictions)) .options(selectinload(Match.predictions))
) )
).scalars().all() ).scalars().all()
matches = [m for m in rows if ensure_aware(m.opens_at) <= horizon] matches = [
m for m in rows
if m.status != "cancelled"
and now < ensure_aware(m.kickoff_at) <= horizon
]
for m in matches: for m in matches:
# 실데이터 블록(폼·H2H·랭킹·WC결과) — 캐시만 읽음. 없으면 None(이름만). # 실데이터 블록 — 리그별 소스(축구=API-Football 캐시, 야구=자체DB+프리뷰).
if m.league in ("kbo", "mlb"):
data_block = await baseball_data.build_baseball_data_block(db, m)
else:
data_block = await football_data.build_data_block(db, m) data_block = await football_data.build_data_block(db, m)
ctx = MatchContext( ctx = MatchContext(
team_a=m.team_a_name, team_a=m.team_a_name,
@ -106,6 +150,7 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
venue=m.venue, venue=m.venue,
kickoff=m.kickoff_at.isoformat(), kickoff=m.kickoff_at.isoformat(),
data_block=data_block, data_block=data_block,
league=m.league,
) )
for model, fn in PROVIDERS.items(): for model, fn in PROVIDERS.items():
pred = next((p for p in m.predictions if p.model == model), None) pred = next((p for p in m.predictions if p.model == model), None)
@ -133,6 +178,8 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
pred.generated_at = now_utc() pred.generated_at = now_utc()
pred.source = "llm" pred.source = "llm"
log.info("AI %s%s %d-%d", model, m.match_id, data["scoreA"], data["scoreB"]) log.info("AI %s%s %d-%d", model, m.match_id, data["scoreA"], data["scoreB"])
# 경기 단위 커밋 — 수백 콜 도중 재시작/오류가 나도 진행분을 잃지
# 않는다(전체 커밋이면 중단 시 API 비용을 다시 지출하게 됨).
await db.commit() await db.commit()
@ -149,7 +196,11 @@ async def refresh_football_data() -> None:
return return
async with SessionLocal() as db: async with SessionLocal() as db:
rows = ( rows = (
await db.execute(select(Match).where(Match.result_outcome.is_(None))) await db.execute(
select(Match).where(
Match.result_outcome.is_(None), Match.league == "wc"
)
)
).scalars().all() ).scalars().all()
# 가까운 경기부터 우선 수집(하루 예산 소진 시 먼 경기 팀은 다음 잡에서). # 가까운 경기부터 우선 수집(하루 예산 소진 시 먼 경기 팀은 다음 잡에서).
matches = sorted(rows, key=lambda m: ensure_aware(m.kickoff_at)) matches = sorted(rows, key=lambda m: ensure_aware(m.kickoff_at))
@ -178,7 +229,11 @@ async def settle_matches() -> None:
) )
async with SessionLocal() as db: async with SessionLocal() as db:
pending = ( pending = (
await db.execute(select(Match).where(Match.result_outcome.is_(None))) await db.execute(
select(Match).where(
Match.league == "wc", Match.result_outcome.is_(None)
)
)
).scalars().all() ).scalars().all()
# 킥오프가 지난 경기만 (소스가 FINISHED 줄 수 있는 시점) # 킥오프가 지난 경기만 (소스가 FINISHED 줄 수 있는 시점)
due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now] due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now]
@ -188,6 +243,7 @@ async def settle_matches() -> None:
finished = ( finished = (
await db.execute( await db.execute(
select(Match).where( select(Match).where(
Match.league == "wc",
Match.result_outcome.is_not(None), Match.result_outcome.is_not(None),
Match.finished_at.is_not(None), Match.finished_at.is_not(None),
) )
@ -257,6 +313,50 @@ async def settle_matches() -> None:
await maybe_send_result_emails() await maybe_send_result_emails()
# ── 2.6) 야구 결과 자동 정산 — (리그, 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"):
continue
async with SessionLocal() as db:
pending = (
await db.execute(
select(Match).where(
Match.league == league,
Match.result_outcome.is_(None),
Match.status != "cancelled",
)
)
).scalars().all()
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)
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)
by_key[(d, a2, b2, s)] = (r["scoreA"], r["scoreB"])
by_key[(d, b2, a2, s)] = (r["scoreB"], r["scoreA"])
settled = 0
async with SessionLocal() as db:
for mid in due:
m = await db.get(Match, mid)
if m is None or m.result_outcome is not None:
continue
d = (ensure_aware(m.kickoff_at) + _KST).strftime("%Y%m%d")
sc = by_key.get((d, m.team_a_code, m.team_b_code, match_seq(mid)))
if not sc:
continue
await apply_result(db, m, sc[0], sc[1])
settled += 1
log.info("settle(%s): %s 자동 정산 %d-%d", league, mid, sc[0], sc[1])
if settled:
await maybe_send_result_emails()
# ── 3) 결과 메일 ──────────────────────────────────────────── # ── 3) 결과 메일 ────────────────────────────────────────────
async def maybe_send_result_emails() -> None: async def maybe_send_result_emails() -> None:
async with SessionLocal() as db: async with SessionLocal() as db:
@ -350,13 +450,14 @@ def build_scheduler() -> AsyncIOScheduler:
minute=settings.football_refresh_minute, minute=settings.football_refresh_minute,
id="football_refresh", id="football_refresh",
) )
# AI 예측 생성: 매일 KST 00:05 (실 API) # AI 예측 생성: 매시간 — 킥오프 22h 전에 든 경기를 놓치지 않게.
# only_missing 이라 이미 생성된 (경기×모델)은 API 호출 없이 건너뜀(비용 동일).
sched.add_job( sched.add_job(
generate_ai_predictions, generate_ai_predictions,
"cron", "interval",
hour=settings.ai_generate_hour, hours=1,
minute=settings.ai_generate_minute,
id="ai_generate", id="ai_generate",
next_run_time=now_utc(),
) )
# 결과 자동 정산 + 메일: 킥오프+Nh 지난 경기 스코어 수집·채점·발송 # 결과 자동 정산 + 메일: 킥오프+Nh 지난 경기 스코어 수집·채점·발송
sched.add_job( sched.add_job(
@ -366,29 +467,41 @@ def build_scheduler() -> AsyncIOScheduler:
id="settle", id="settle",
next_run_time=now_utc(), next_run_time=now_utc(),
) )
# 야구(kbo/mlb): 일정·프리뷰 동기화 매일 09:00 + AI 생성 직전 00:00
sched.add_job(
sync_baseball_job, "cron",
hour=settings.schedule_sync_hour, minute=settings.schedule_sync_minute,
id="baseball_sync",
)
sched.add_job(sync_baseball_job, "cron", hour=0, minute=0, id="baseball_sync_night")
# 야구 결과 정산 폴링
sched.add_job(
settle_baseball, "interval",
seconds=settings.settle_tick_seconds, id="baseball_settle",
)
return sched return sched
async def main() -> None: async def main() -> None:
load_scoring_data() # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용) load_scoring_data() # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용)
await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당. await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당.
# 기동 시 1회: 일정 동기화 → AI 예측 생성 → 밀린 경기 자동 정산 # 기동 시 1회: 일정 동기화(축구+야구) → AI 예측 생성 → 밀린 경기 자동 정산
await sync_schedule_job() await sync_schedule_job()
await sync_baseball_job()
await refresh_football_data() # 축구 데이터 캐시 선채움 (키 없으면 no-op) await refresh_football_data() # 축구 데이터 캐시 선채움 (키 없으면 no-op)
await generate_ai_predictions() await generate_ai_predictions()
await settle_matches() await settle_matches()
await settle_baseball()
sched = build_scheduler() sched = build_scheduler()
sched.start() sched.start()
log.info( log.info(
"scheduling-server started — schedule sync daily %02d:%02d · status every %ss · " "scheduling-server started — schedule sync daily %02d:%02d · status every %ss · "
"AI gen daily %02d:%02d %s · auto-settle on FINISHED (poll every %ss) · 맞춘사람만=%s", "AI gen hourly (kickoff-%dh) · auto-settle on FINISHED (poll every %ss) · 맞춘사람만=%s",
settings.schedule_sync_hour, settings.schedule_sync_hour,
settings.schedule_sync_minute, settings.schedule_sync_minute,
settings.status_tick_seconds, settings.status_tick_seconds,
settings.ai_generate_hour, settings.ai_generate_lookahead_hours,
settings.ai_generate_minute,
settings.timezone,
settings.settle_tick_seconds, settings.settle_tick_seconds,
settings.result_email_correct_only, settings.result_email_correct_only,
) )

38
docs/DATA_SOURCES.md Normal file
View File

@ -0,0 +1,38 @@
# 야구(KBO/MLB) 데이터 소스 카탈로그 (실검증: 2026-07-21)
## 소스 총평
| 소스 | 상태 | 요약 |
|---|---|---|
| **네이버 스포츠 API** (비공식) | ✅ KBO 주력 | 일정·결과·순위·프리뷰·박스스코어·문자중계(투구 단위)까지 전부. 키 불필요, UA 헤더만 |
| **MLB 공식 Stats API** | ✅ MLB 주력 | statsapi.mlb.com — 키 불필요·공식. 일정·결과·순위·예고선발·투구 단위 라이브 피드 |
| TheSportsDB | ⚠️ 유료 전용 | 무료 키 `123`은 next/past 조회당 1경기만(전 리그 공통). 유료 ~$10/월 |
| API-Sports 야구 | ❌ 미사용 | KBO=league 5 존재하나 무료는 2022~24 시즌만 |
## 네이버 (api-gw.sports.naver.com) — KBO
- 인증 없음, User-Agent 필수. gameId = `{yyyymmdd}{원정}{홈}0{연도}`
- 팀코드: HT(KIA) LG OB(두산) SS(삼성) LT(롯데) SK(SSG) KT NC WO(키움) HH(한화)
- `GET /schedule/games?...&categoryId=kbo&fromDate=&toDate=` — 일정·스코어·statusInfo("N회초/말")·cancel·suspended
- `GET /schedule/games/{id}/preview` — 예고 선발투수(구종·상대전적)·핫콜드존·시즌 상대전적·순위
- `GET /schedule/games/{id}/record` — R/H/E/B·이닝별·타자/투수 박스스코어·교체·홈런일지
- `GET /schedule/games/{id}/relay?inning=N` — currentGameState(투수/타자/볼카운트/주자)·라인업(seqno=교체)·투구단위 텍스트
- `GET /stats/categories/kbo/seasons/{year}/teams` — 순위·승률·최근5·팀 공격/수비 풀스탯
- 리스크: 비공식(약관·차단). 로컬/데모용 — 상용 전환 시 정식 소스 교체
## MLB 공식 (statsapi.mlb.com) — MLB
- `GET /api/v1/schedule?sportId=1&startDate=&endDate=` (+`&hydrate=probablePitcher`)
- `GET /api/v1/standings?leagueId=103|104&season=` — AL/NL 지구 순위
- `GET /api/v1.1/game/{gamePk}/feed/live` — linescore(B/S/O·주자)·offense(batter/onDeck/inHole/pitcher)·boxscore(타순·포지션)·plays(투구 단위)
- 팀 로고: `https://www.mlbstatic.com/team-logos/{teamId}.svg` (공식 CDN)
- 한글 팀명은 자체 매핑(`teams_baseball.py`), 선수명은 영문
## 구현 위치 (backend/app)
- `teams_baseball.py` — KBO 10 + MLB 30 (한글·statsapi id·로고)
- `services/baseball_fetch.py` — 일정·결과 수집 (리그별 어댑터)
- `services/baseball_sync.py` — (리그, KST날짜, 팀쌍) 키 동기화 · 우천취소 제거
- `services/baseball_details.py` — 프리뷰·순위 캐시(DataCache) + 라이브 필드 뷰 프록시(15s TTL)
- `services/baseball_data.py` — AI 프롬프트 데이터 블록 (자체 DB 폼 + 캐시)
- 호출량: 동기화 하루 2회 + 정산 5분 폴링 + 라이브는 경기당 최대 4콜/분(캐시 상한)

View File

@ -44,13 +44,28 @@
- 무승부: 승패 적중이면서 득실차(=0) 일치 → 정확이 아니면 **근접(3점)**. - 무승부: 승패 적중이면서 득실차(=0) 일치 → 정확이 아니면 **근접(3점)**.
- **배점 수치는 황 본부장 확정 후 dev 전달**(회의 05:48). 위는 기본 제안값. - **배점 수치는 황 본부장 확정 후 dev 전달**(회의 05:48). 위는 기본 제안값.
### 야구(KBO/MLB) 판정 완화 (2026-07-22)
야구는 득점 범위가 넓어(0~12+) 축구 기준 그대로면 정확/근접/부분이 거의 나오지
않는다. **등급 체계·배점은 리그 공통**으로 유지하고, 야구만 판정에 허용 오차
±1(`scoring.BASEBALL_TOLERANCE`)을 둔다. 리그별 시상이므로 리그 간 점수 크기
차이는 문제되지 않는다.
| 등급 | 야구 조건 | 포인트 |
|---|---|---|
| 근접 | 승패 적중 + **득실차 오차 ≤1** | 3 |
| 부분 | **한 팀 득점 오차 ≤1** | 1 |
(정확·승패·빗나감 조건은 축구와 동일)
### 채점 알고리즘 (결정론적) ### 채점 알고리즘 (결정론적)
``` ```
tol = 야구 ? 1 : 0
1) pick.scoreA == result.scoreA && pick.scoreB == result.scoreB → 정확(5) 1) pick.scoreA == result.scoreA && pick.scoreB == result.scoreB → 정확(5)
2) else if pick.outcome == result.outcome: 2) else if pick.outcome == result.outcome:
(pick.scoreA - pick.scoreB) == (result.scoreA - result.scoreB) ? 근접(3) : 승패(2) |득실차 오차| <= tol ? 근접(3) : 승패(2)
3) else if pick.scoreA == result.scoreA || pick.scoreB == result.scoreB → 부분(1) 3) else if |pick.scoreA - result.scoreA| <= tol || |pick.scoreB - result.scoreB| <= tol → 부분(1)
4) else → 0 4) else → 0
``` ```

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

@ -101,6 +101,9 @@ export default function Arena({
const rightShort = flip ? aShort : bShort; const rightShort = flip ? aShort : bShort;
// 마스코트(사자·버튼 양옆 캐릭터)는 모든 경기에서 노출. // 마스코트(사자·버튼 양옆 캐릭터)는 모든 경기에서 노출.
const [outcome, setOutcome] = useState<Outcome | null>("DRAW"); const [outcome, setOutcome] = useState<Outcome | null>("DRAW");
// 야구(KBO/MLB): 스코어 상한 25, 마스코트 미노출
const isBaseball = match.league === "kbo" || match.league === "mlb";
const maxScore = isBaseball ? 25 : 9;
const [scoreA, setScoreA] = useState(0); const [scoreA, setScoreA] = useState(0);
const [scoreB, setScoreB] = useState(0); const [scoreB, setScoreB] = useState(0);
const [step, setStep] = useState<Step>("form"); const [step, setStep] = useState<Step>("form");
@ -227,7 +230,8 @@ export default function Arena({
{/* ===== 레이어드 화이트 시트 (002) ===== */} {/* ===== 레이어드 화이트 시트 (002) ===== */}
<section className="sheet relative mt-4 p-5 text-[var(--ink)]"> <section className="sheet relative mt-4 p-5 text-[var(--ink)]">
{/* AI 박스 오른쪽 위 사자 — 모든 경기 노출 */} {/* AI 박스 오른쪽 위 사자 — 축구 전용 */}
{!isBaseball && (
<div className="absolute right-1 -top-4 z-10 h-20 sm:h-24"> <div className="absolute right-1 -top-4 z-10 h-20 sm:h-24">
<TalkingMascot <TalkingMascot
src="/assets/mascots/lion_point.webp" src="/assets/mascots/lion_point.webp"
@ -236,10 +240,18 @@ export default function Arena({
draggable draggable
/> />
</div> </div>
)}
<div className="mb-3"> <div className="mb-3">
<h2 className="text-[22px] font-extrabold">{t.aiBattle}</h2> <h2 className="text-[22px] font-extrabold">{t.aiBattle}</h2>
</div> </div>
{predictions.length === 0 && (
<p className="rounded-xl bg-black/[0.04] px-3 py-2.5 text-[12px] font-semibold text-[var(--ink-muted)]">
{lang === "en"
? "AI picks are generated daily at midnight — check back soon."
: "AI 예측은 매일 자정에 생성됩니다. 잠시 후 다시 확인해주세요."}
</p>
)}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
{predictions.map((p) => { {predictions.map((p) => {
const wp = winProb(match, p); const wp = winProb(match, p);
@ -328,15 +340,15 @@ export default function Arena({
<div className="mt-3 flex items-center justify-center gap-5 rounded-xl border border-[var(--line-l)] bg-white py-3"> <div className="mt-3 flex items-center justify-center gap-5 rounded-xl border border-[var(--line-l)] bg-white py-3">
{flip ? ( {flip ? (
<> <>
<Stepper label={bShort} value={scoreB} onChange={setScoreB} disabled={disabled} /> <Stepper label={bShort} value={scoreB} onChange={setScoreB} disabled={disabled} max={maxScore} />
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span> <span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
<Stepper label={aShort} value={scoreA} onChange={setScoreA} disabled={disabled} /> <Stepper label={aShort} value={scoreA} onChange={setScoreA} disabled={disabled} max={maxScore} />
</> </>
) : ( ) : (
<> <>
<Stepper label={aShort} value={scoreA} onChange={setScoreA} disabled={disabled} /> <Stepper label={aShort} value={scoreA} onChange={setScoreA} disabled={disabled} max={maxScore} />
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span> <span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
<Stepper label={bShort} value={scoreB} onChange={setScoreB} disabled={disabled} /> <Stepper label={bShort} value={scoreB} onChange={setScoreB} disabled={disabled} max={maxScore} />
</> </>
)} )}
</div> </div>
@ -378,14 +390,14 @@ export default function Arena({
)} )}
{/* 투표하기 버튼 — 모든 경기에서 양옆에 캐릭터(자동 말풍선) */} {/* 투표하기 버튼 — 모든 경기에서 양옆에 캐릭터(자동 말풍선) */}
<div className="mt-3.5 flex items-end justify-center gap-1"> <div className="mt-3.5 flex items-end justify-center gap-1">
<TalkingMascot {!isBaseball && <TalkingMascot
src="/assets/mascots/dog_point.webp" src="/assets/mascots/dog_point.webp"
lines={DOG_LINES} lines={DOG_LINES}
className="h-16 w-auto select-none sm:h-20" className="h-16 w-auto select-none sm:h-20"
auto auto
autoDelayMs={1200} autoDelayMs={1200}
autoIntervalMs={6000} autoIntervalMs={6000}
/> />}
<button <button
onClick={confirmSubmit} onClick={confirmSubmit}
disabled={!emailValid || submitting} disabled={!emailValid || submitting}
@ -393,14 +405,14 @@ export default function Arena({
> >
{submitting ? "…" : <>{t.submit} <span aria-hidden></span></>} {submitting ? "…" : <>{t.submit} <span aria-hidden></span></>}
</button> </button>
<TalkingMascot {!isBaseball && <TalkingMascot
src="/assets/mascots/tiger_point.webp" src="/assets/mascots/tiger_point.webp"
lines={TIGER_LINES} lines={TIGER_LINES}
className="h-16 w-auto select-none sm:h-20" className="h-16 w-auto select-none sm:h-20"
auto auto
autoDelayMs={4200} autoDelayMs={4200}
autoIntervalMs={6000} autoIntervalMs={6000}
/> />}
</div> </div>
</div> </div>
)} )}
@ -554,8 +566,8 @@ export default function Arena({
</section> </section>
)} )}
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */} {/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 현재 리그 기준 집계 ===== */}
<RankingBoard lang={lang} /> <RankingBoard lang={lang} league={match.league} />
{/* ===== 골드 상금 (003) ===== */} {/* ===== 골드 상금 (003) ===== */}
<section className="gold-card mt-5 rounded-2xl p-5"> <section className="gold-card mt-5 rounded-2xl p-5">
@ -599,9 +611,9 @@ export default function Arena({
{( {(
[ [
[t.rulesExact, 5], [t.rulesExact, 5],
[t.rulesCloseGrade, 3], [isBaseball ? t.rulesCloseGradeBB : t.rulesCloseGrade, 3],
[t.rulesOutcome, 2], [t.rulesOutcome, 2],
[t.rulesPartial, 1], [isBaseball ? t.rulesPartialBB : t.rulesPartial, 1],
[t.rulesMiss, 0], [t.rulesMiss, 0],
] as [string, number][] ] as [string, number][]
).map(([label, pt]) => ( ).map(([label, pt]) => (
@ -670,11 +682,13 @@ function Stepper({
value, value,
onChange, onChange,
disabled, disabled,
max = 9,
}: { }: {
label: string; label: string;
value: number; value: number;
onChange: (n: number) => void; onChange: (n: number) => void;
disabled?: boolean; disabled?: boolean;
max?: number;
}) { }) {
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -687,7 +701,7 @@ function Stepper({
</span> </span>
<span className="mt-1 text-[11px] text-[var(--ink-muted)]">{label}</span> <span className="mt-1 text-[11px] text-[var(--ink-muted)]">{label}</span>
</div> </div>
<StepBtn disabled={disabled || value >= 9} onClick={() => onChange(Math.min(9, value + 1))}> <StepBtn disabled={disabled || value >= max} onClick={() => onChange(Math.min(max, value + 1))}>
+ +
</StepBtn> </StepBtn>
</div> </div>

View File

@ -2,6 +2,7 @@ import { Link, useNavigate } from "react-router-dom";
import ShareButton from "./ShareButton"; import ShareButton from "./ShareButton";
import LangSwitch from "./LangSwitch"; import LangSwitch from "./LangSwitch";
import { type Lang, dict } from "@/lib/i18n"; import { type Lang, dict } from "@/lib/i18n";
import type { League } from "@/lib/types";
type Share = { url: string; title: string; text: string }; type Share = { url: string; title: string; text: string };
@ -10,10 +11,12 @@ export default function Hero({
back = false, back = false,
share, share,
lang = "ko", lang = "ko",
league = "wc",
}: { }: {
back?: boolean; back?: boolean;
share?: Share; share?: Share;
lang?: Lang; lang?: Lang;
league?: League;
}) { }) {
const t = dict(lang); const t = dict(lang);
const navigate = useNavigate(); const navigate = useNavigate();
@ -54,8 +57,55 @@ export default function Hero({
</div> </div>
</div> </div>
<div className="mt-2 inline-block rounded-full border border-white/12 bg-white/8 px-3 py-1 text-[13px] font-semibold text-white/85"> <div className="mt-2 inline-block rounded-full border border-white/12 bg-white/8 px-3 py-1 text-[13px] font-semibold text-white/85">
{t.heroPill} {league === "kbo"
? lang === "en"
? "AI-powered 2026 KBO League game predictions"
: "AI와 함께하는 2026 프로야구 KBO 승부예측 챌린지"
: league === "mlb"
? lang === "en"
? "AI-powered 2026 MLB game predictions"
: "AI와 함께하는 2026 MLB 승부예측 챌린지"
: t.heroPill}
</div> </div>
<LeagueTabs lang={lang} current={league} />
</header> </header>
); );
} }
// 리그 전환 탭 — 단일 앱 내 전환. 탭 클릭 시 해당 리그 대시보드로 이동.
function LeagueTabs({ lang, current }: { lang: Lang; current: League }) {
const navigate = useNavigate();
const tabs: { key: League; label: string }[] = [
{ key: "wc", label: lang === "en" ? "World Cup" : "월드컵" },
{ key: "kbo", label: "KBO" },
{ key: "mlb", label: "MLB" },
];
return (
<div className="mt-3 flex justify-center">
<div className="inline-flex rounded-full border border-white/12 bg-white/5 p-1 text-[13px] font-bold">
{tabs.map((tab) =>
tab.key === current ? (
<span
key={tab.key}
className="rounded-full bg-[var(--green)] px-4 py-1.5 text-black"
>
{tab.label}
</span>
) : (
<button
key={tab.key}
onClick={() =>
navigate(
`/?league=${tab.key}${lang === "en" ? "&lang=en" : ""}`
)
}
className="rounded-full px-4 py-1.5 text-white/45 transition hover:text-white/75"
>
{tab.label}
</button>
),
)}
</div>
</div>
);
}

View File

@ -0,0 +1,295 @@
import { useEffect, useState } from "react";
import type { Lang } from "@/lib/i18n";
import { timeOnly } from "@/lib/format";
import { getLive, type LiveData } from "@/lib/api";
import type { Match } from "@/lib/types";
// 라이브 필드 뷰 — 수비 배치 다이아몬드 + 현재 타자/투수 + B/S/O + 주자 + 대기타석.
// phase === "live" 일 때 20초 폴링. (?debugLive=1 로 종료 경기에서도 강제 표시)
// 포지션 → 필드 위 좌표(%) — left/top (SVG viewBox 100×70 과 동일 비율)
const POS_XY: Record<string, [number, number]> = {
: [50, 90],
"1루수": [71, 55],
"2루수": [61, 39],
: [39, 39],
"3루수": [29, 55],
: [20, 26],
: [50, 14],
: [80, 26],
};
// 필드 SVG 상 베이스 좌표 (viewBox 0 0 100 70) — 1루·2루·3루
const BASE_XY: [number, number][] = [
[63.5, 42.5],
[50, 29],
[36.5, 42.5],
];
function NameChip({
name,
sub,
accent = false,
x,
y,
}: {
name: string;
sub?: string;
accent?: boolean;
x: number;
y: number;
}) {
return (
<div
className="absolute -translate-x-1/2 -translate-y-1/2 text-center"
style={{ left: `${x}%`, top: `${y}%` }}
>
<div
className={`whitespace-nowrap rounded-full px-2.5 py-1 text-[11px] font-bold shadow-[0_1px_3px_rgba(0,0,0,0.25)] ${
accent ? "bg-[#d94141] text-white" : "bg-white text-[#222]"
}`}
>
{name}
</div>
{sub && (
<div className="mt-0.5 text-[9px] font-bold text-[#245c2a]/80">{sub}</div>
)}
</div>
);
}
function Dot({ on, color }: { on: boolean; color: string }) {
return (
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ background: on ? color : "rgba(255,255,255,0.14)" }}
/>
);
}
// 구장 SVG — 부채꼴 외야 + 파울라인 + 흙 내야 + 마운드 + 베이스(주자 점등).
// 경기 전(빈 구장)·경기 중·종료 스냅샷 모두 공용.
function BallparkSvg({ bases }: { bases?: boolean[] }) {
return (
<svg
viewBox="0 0 100 70"
preserveAspectRatio="none"
className="absolute inset-0 h-full w-full"
>
<rect width="100" height="70" fill="#4e8a48" />
<path
d="M50,62 L8,20 A60,60 0 0 1 92,20 Z"
fill="#6cb163"
stroke="rgba(255,255,255,0.85)"
strokeWidth="0.5"
/>
<polygon points="50,20 73,43 50,66 27,43" fill="#cd9f63" />
<polygon
points="50,29 63.5,42.5 50,56 36.5,42.5"
fill="#6cb163"
stroke="rgba(255,255,255,0.4)"
strokeWidth="0.4"
/>
<circle cx="50" cy="43" r="3.2" fill="#cd9f63" />
<circle cx="50" cy="60.5" r="4.2" fill="#cd9f63" />
{BASE_XY.map(([cx, cy], i) => (
<rect
key={i}
x={cx - 1.5}
y={cy - 1.5}
width="3"
height="3"
rx="0.5"
transform={`rotate(45 ${cx} ${cy})`}
fill={bases?.[i] ? "#ffd23e" : "#ffffff"}
stroke="rgba(0,0,0,0.15)"
strokeWidth="0.3"
/>
))}
<rect x="48.8" y="59.3" width="2.4" height="2.4" rx="0.4" fill="#ffffff" />
</svg>
);
}
export default function LiveField({ match, lang = "ko" }: { match: Match; lang?: Lang }) {
const isLive = match.phase === "live";
// 경기 중엔 20초 폴링, 그 외(경기 전·종료)는 1회 로드.
// 경기 전엔 중계 미게시(available=false)라도 빈 구장 + "경기 전"으로 표시.
const [data, setData] = useState<LiveData | null>(null);
const isBaseball = match.league === "kbo" || match.league === "mlb";
useEffect(() => {
if (!isBaseball) return;
let alive = true;
const load = () =>
getLive(match.matchId)
.then((d) => alive && setData(d))
.catch(() => {});
load();
if (!isLive) return () => { alive = false; };
const id = setInterval(load, 20_000);
return () => {
alive = false;
clearInterval(id);
};
}, [isBaseball, isLive, match.matchId]);
// 야구 전용 위젯 — 축구(월드컵)에서는 렌더하지 않음
if (!isBaseball) return null;
// 중계 데이터 없음 (경기 전 또는 시작 직후) — 빈 구장 + 상태 라벨
if (!data?.available) {
if (match.phase === "finished") return null; // 종료인데 기록 없음 — 표시할 것 없음
return (
<section className="mt-4">
<div className="rounded-3xl border border-white/10 bg-[#171b21] p-4">
<div className="flex items-center justify-between">
<span className="text-[13px] font-extrabold text-white/45">
{lang === "en" ? "FIELD" : "필드"}
</span>
<span className="font-mono text-[13px] font-bold text-white/55">
{timeOnly(match.kickoffKst)} KST
</span>
</div>
<div className="relative mt-3 aspect-[10/7] overflow-hidden rounded-2xl">
<BallparkSvg />
<div className="absolute inset-0 grid place-items-center">
<span className="rounded-full bg-black/50 px-4 py-1.5 text-[13px] font-extrabold text-white/95">
{isLive
? lang === "en"
? "Starting soon…"
: "경기 시작 대기 중"
: lang === "en"
? "Pre-game · lineups TBA"
: "경기 전 · 라인업 발표 전"}
</span>
</div>
</div>
</div>
</section>
);
}
const half = data.half === "B" ? (lang === "en" ? "Bot" : "말") : lang === "en" ? "Top" : "초";
const battingTeam = data.half === "B" ? match.teamB : match.teamA;
const num = (v: unknown) => Number(v ?? 0) || 0;
return (
<section className="mt-4">
<div className="rounded-3xl border border-white/10 bg-[#171b21] p-4">
{/* 헤더: 이닝·스코어·B/S/O */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{isLive ? (
<>
<span className="flex h-2 w-2">
<span className="absolute inline-flex h-2 w-2 animate-ping rounded-full bg-[#FF5B5B] opacity-60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#FF5B5B]" />
</span>
<span className="text-[13px] font-extrabold text-[#FF5B5B]">
LIVE · {data.inn}
{lang === "en" ? ` (${half})` : `${half}`}
</span>
</>
) : (
<span className="text-[13px] font-extrabold text-white/45">
{lang === "en" ? "FINAL" : "경기 종료"} · {data.inn}
{lang === "en" ? ` (${half})` : `${half}`}
</span>
)}
</div>
<span className="font-mono text-[18px] font-extrabold tabular-nums">
{match.teamA.shortName} {num(data.score?.away)}
<span className="px-1 text-white/40">:</span>
{num(data.score?.home)} {match.teamB.shortName}
</span>
</div>
{/* B/S/O + 주자 다이아몬드 */}
<div className="mt-3 flex items-center justify-between rounded-2xl bg-white/[0.04] px-3 py-2">
<div className="space-y-1 text-[10px] font-bold text-white/50">
<div className="flex items-center gap-1.5">
B{" "}
{[0, 1, 2].map((i) => (
<Dot key={i} on={num(data.bso?.b) > i} color="#4ade80" />
))}
</div>
<div className="flex items-center gap-1.5">
S{" "}
{[0, 1].map((i) => (
<Dot key={i} on={num(data.bso?.s) > i} color="#facc15" />
))}
</div>
<div className="flex items-center gap-1.5">
O{" "}
{[0, 1].map((i) => (
<Dot key={i} on={num(data.bso?.o) > i} color="#f87171" />
))}
</div>
</div>
<div className="text-right text-[11px] font-semibold text-white/55">
<div>
{lang === "en" ? "AB" : "타석"}{" "}
<span className="font-extrabold text-white/90">{battingTeam.shortName}</span>
</div>
{data.vsRecord && <div className="mt-0.5 text-[10px] text-white/35">{data.vsRecord}</div>}
</div>
</div>
{/* 필드: 구장 SVG + 수비 배치/투수/타자 칩 */}
<div className="relative mt-3 aspect-[10/7] overflow-hidden rounded-2xl">
<BallparkSvg bases={data.bases} />
{data.defense?.map((f) => {
const xy = POS_XY[f.pos ?? ""];
if (!xy) return null;
return <NameChip key={f.pos} name={f.name} sub={f.pos} x={xy[0]} y={xy[1]} />;
})}
{data.pitcher && (
<NameChip
name={data.pitcher.name}
sub={`${lang === "en" ? "P" : "투구수"} ${num(data.pitcher.ballCount)}`}
x={50}
y={61}
/>
)}
{data.batter && (
<NameChip
name={`${data.batter.order ?? ""}. ${data.batter.name}`}
accent
x={50}
y={79}
/>
)}
</div>
{/* 대기타석 */}
{data.batter && data.offenseLineup && (
<div className="mt-3 flex items-center gap-2 overflow-x-auto pb-1 text-[11px] font-semibold text-white/55 [scrollbar-width:thin] [scrollbar-color:rgba(255,255,255,.22)_transparent] [&::-webkit-scrollbar]:h-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-white/20">
<span className="shrink-0 text-white/35">{lang === "en" ? "Next" : "대기타석"}</span>
{data.offenseLineup
.filter((b) => {
const cur = data.batter!.order ?? 0;
const diff = ((b.order ?? 0) - cur + 9) % 9;
return diff >= 1 && diff <= 3;
})
.sort((a, b) => {
const cur = data.batter!.order ?? 0;
return (((a.order ?? 0) - cur + 9) % 9) - (((b.order ?? 0) - cur + 9) % 9);
})
.map((b) => (
<span
key={b.order}
className="shrink-0 rounded-full border border-white/10 px-2 py-0.5"
>
{b.order}. {b.name}
{b.sub ? " ↺" : ""}
</span>
))}
</div>
)}
</div>
</section>
);
}

View File

@ -5,17 +5,39 @@ import BallIcon from "./BallIcon";
import TeamFlag from "./TeamFlag"; import TeamFlag from "./TeamFlag";
import WaveStrip from "./WaveStrip"; import WaveStrip from "./WaveStrip";
// 순위 캐시 → "3위 · 52승37패 (0.584)" 한 줄 (야구)
function standingLine(
st: { rank?: number | null; w?: number; l?: number; wra?: string | number } | null | undefined,
lang: Lang,
): string | null {
if (!st?.rank) return null;
const rank = lang === "en" ? `#${st.rank}` : `${st.rank}`;
if (st.w == null || st.l == null) return rank;
const rec = lang === "en" ? `${st.w}W ${st.l}L` : `${st.w}${st.l}`;
return `${rank} · ${rec}${st.wra ? ` (${st.wra})` : ""}`;
}
export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?: Lang }) { export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?: Lang }) {
const t = dict(lang); const t = dict(lang);
const { group } = match; const { group } = match;
const finished = !!match.result; const finished = !!match.result;
// 한국을 항상 왼쪽에 표시(요청). 데이터/투표는 원본 A/B 프레임 유지, 화면 좌우만 교체. const isBaseball = match.league === "kbo" || match.league === "mlb";
const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR"; // 축구: 한국을 항상 왼쪽에. 야구: A=원정(좌), B=홈(우) 고정.
const flip = !isBaseball && match.teamB.code === "KOR" && match.teamA.code !== "KOR";
const left = flip ? match.teamB : match.teamA; const left = flip ? match.teamB : match.teamA;
const right = flip ? match.teamA : match.teamB; const right = flip ? match.teamA : match.teamB;
const leftScore = flip ? match.result?.scoreB : match.result?.scoreA; const leftScore = flip ? match.result?.scoreB : match.result?.scoreA;
const rightScore = flip ? match.result?.scoreA : match.result?.scoreB; const rightScore = flip ? match.result?.scoreA : match.result?.scoreB;
// 마스코트(파도타기 등)는 모든 경기에서 노출.
const ex = match.extras;
const spL = ex?.starterA;
const spR = ex?.starterB;
const stL = standingLine(ex?.standings?.a, 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]";
return ( return (
<section className="mt-6"> <section className="mt-6">
{/* 대결 카드 (녹색 글로우 보더) */} {/* 대결 카드 (녹색 글로우 보더) */}
@ -23,10 +45,12 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
className="relative rounded-3xl border-2 border-[var(--green)] bg-[#171b21] p-5" className="relative rounded-3xl border-2 border-[var(--green)] bg-[#171b21] p-5"
style={{ boxShadow: "0 0 28px rgba(74,255,160,0.35), inset 0 0 24px rgba(74,255,160,0.06)" }} style={{ boxShadow: "0 0 28px rgba(74,255,160,0.35), inset 0 0 24px rgba(74,255,160,0.06)" }}
> >
{/* 박스 오른쪽 상단 파도타기 (응원 5마리) — 모든 경기 노출 */} {/* 파도타기 마스코트 — 축구 전용 (야구는 미노출) */}
{!isBaseball && (
<div className="absolute right-2 top-2 z-10"> <div className="absolute right-2 top-2 z-10">
<WaveStrip /> <WaveStrip />
</div> </div>
)}
{/* 헤더: 브랜드 아이콘 + 팀명 + 일시 */} {/* 헤더: 브랜드 아이콘 + 팀명 + 일시 */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -38,15 +62,22 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</div> </div>
<div className="mt-1 text-[15px] font-bold text-[var(--green)]"> <div className="mt-1 text-[15px] font-bold text-[var(--green)]">
{kickoffDisplay(match.kickoffKst, lang)} ·{" "} {kickoffDisplay(match.kickoffKst, lang)} ·{" "}
{group ? t.group(group) : tRound(match.roundLabel, lang)} {!isBaseball && group ? t.group(group) : tRound(match.roundLabel, lang)}
</div>
<div className="mt-0.5 text-[12px] text-white/65">
{match.venue}
{isBaseball && (
<span className="text-white/40">
{" "}· {lang === "en" ? "Home: " : "홈 "}{teamShort(right, lang)}
</span>
)}
</div> </div>
<div className="mt-0.5 text-[12px] text-white/65">{match.venue}</div>
</div> </div>
</div> </div>
{/* 국기 + 라이트닝 VS (종료 시 최종 스코어) */} {/* 국기/로고 + 라이트닝 VS (종료 시 최종 스코어) */}
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-3"> <div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<TeamFlag team={left} className="mx-auto h-[68px] w-[104px]" /> <TeamFlag team={left} className={flagCls} />
<div className="relative grid place-items-center"> <div className="relative grid place-items-center">
<div className="vs-glow absolute h-28 w-28" /> <div className="vs-glow absolute h-28 w-28" />
{finished && match.result ? ( {finished && match.result ? (
@ -64,9 +95,78 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</span> </span>
)} )}
</div> </div>
<TeamFlag team={right} className="mx-auto h-[68px] w-[104px]" /> <TeamFlag team={right} className={flagCls} />
</div> </div>
{/* 야구 정보 패널: 선발 매치업 · 순위 · 시즌 상대전적 (캐시 있을 때만) */}
{isBaseball && (spL || spR || stL || stR || hasVs) && (
<div className="mt-4 rounded-2xl bg-white/[0.04] p-3.5 text-[12px]">
{(spL || spR) && (
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-2">
<div className="min-w-0">
<div className="truncate font-extrabold text-white/90">
{spL?.name ?? (lang === "en" ? "TBA" : "미정")}
{spL?.hitType ? (
<span className="ml-1 font-semibold text-white/40">{spL.hitType.slice(0, 2)}</span>
) : null}
</div>
{spL?.era != null && (
<div className="mt-0.5 text-white/50">
ERA {spL.era}
{spL.w != null ? ` · ${spL.w}${lang === "en" ? "W" : "승"}${spL.l ?? 0}${lang === "en" ? "L" : "패"}` : ""}
</div>
)}
</div>
<span className="pt-0.5 text-[11px] font-bold text-[var(--green)]">
{lang === "en" ? "Starting P" : "선발 투수"}
</span>
<div className="min-w-0 text-right">
<div className="truncate font-extrabold text-white/90">
{spR?.hitType ? (
<span className="mr-1 font-semibold text-white/40">{spR.hitType.slice(0, 2)}</span>
) : null}
{spR?.name ?? (lang === "en" ? "TBA" : "미정")}
</div>
{spR?.era != null && (
<div className="mt-0.5 text-white/50">
ERA {spR.era}
{spR.w != null ? ` · ${spR.w}${lang === "en" ? "W" : "승"}${spR.l ?? 0}${lang === "en" ? "L" : "패"}` : ""}
</div>
)}
</div>
</div>
)}
{(stL || stR) && (
<div className="mt-2.5 flex items-center justify-between border-t border-white/10 pt-2.5 text-white/55">
<span className="truncate">{stL ?? "—"}</span>
<span className="px-2 text-[10px] text-white/30">{lang === "en" ? "STANDINGS" : "순위"}</span>
<span className="truncate text-right">{stR ?? "—"}</span>
</div>
)}
{hasVs && (
<div className="mt-2.5 border-t border-white/10 pt-2.5">
<div className="flex items-center justify-between font-bold text-white/75">
<span>{vs!.aWin}{lang === "en" ? "W" : "승"}</span>
<span className="text-[10px] text-white/35">
{lang === "en" ? "SEASON H2H" : "시즌 상대전적"}
{vs!.draw ? ` · ${vs!.draw}${lang === "en" ? "D" : "무"}` : ""}
</span>
<span>{vs!.bWin}{lang === "en" ? "W" : "승"}</span>
</div>
<div className="mt-1.5 flex h-1.5 gap-0.5 overflow-hidden rounded-full">
<div
className="bg-[var(--green)]/80"
style={{ width: `${(100 * (vs!.aWin ?? 0)) / Math.max(1, (vs!.aWin ?? 0) + (vs!.bWin ?? 0))}%` }}
/>
<div className="flex-1 bg-white/20" />
</div>
</div>
)}
</div>
)}
{finished && ( {finished && (
<div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]"> <div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]">
{t.matchEnded} {t.matchEnded}

View File

@ -9,9 +9,11 @@ import type { RankingEntry, RankingTab } from "@/lib/types";
export default function RankingBoard({ export default function RankingBoard({
lang = "ko", lang = "ko",
defaultOpen = false, defaultOpen = false,
league = "",
}: { }: {
lang?: Lang; lang?: Lang;
defaultOpen?: boolean; defaultOpen?: boolean;
league?: string; // 리그별 집계 ("" = 전체 통합)
}) { }) {
const t = dict(lang); const t = dict(lang);
const [open, setOpen] = useState(defaultOpen); const [open, setOpen] = useState(defaultOpen);
@ -24,7 +26,7 @@ export default function RankingBoard({
if (!open) return; if (!open) return;
let alive = true; let alive = true;
setLoaded(false); setLoaded(false);
fetchRanking(tab) fetchRanking(tab, league)
.then((r) => { .then((r) => {
if (!alive) return; if (!alive) return;
setRows(r); setRows(r);
@ -38,7 +40,7 @@ export default function RankingBoard({
return () => { return () => {
alive = false; alive = false;
}; };
}, [open, tab]); }, [open, tab, league]);
return ( return (
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4"> <section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">

View File

@ -1,9 +1,11 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { timeOnly, dateKey } from "@/lib/format"; import { timeOnly, dateKey } from "@/lib/format";
import { getLive, type LiveData } from "@/lib/api";
import { type Lang, dict, teamShort, roundLabel as tRound, dayName, monthEn } from "@/lib/i18n"; import { type Lang, dict, teamShort, roundLabel as tRound, dayName, monthEn } from "@/lib/i18n";
import type { Match, MatchPhase } from "@/lib/types"; import type { League, Match, MatchPhase } from "@/lib/types";
import TeamFlag from "./TeamFlag"; import TeamFlag from "./TeamFlag";
import StandingsTable from "./StandingsTable";
// 칩용 짧은 날짜: "6.12 (금)" / "Jun 12 (Fri)" // 칩용 짧은 날짜: "6.12 (금)" / "Jun 12 (Fri)"
function shortDate(iso: string, lang: Lang): string { function shortDate(iso: string, lang: Lang): string {
@ -20,22 +22,27 @@ const PHASE_CLS: Record<MatchPhase, string> = {
locked: "border-[var(--line-d)] text-white/45", locked: "border-[var(--line-d)] text-white/45",
live: "border-[#FF5B5B] text-[#FF5B5B]", live: "border-[#FF5B5B] text-[#FF5B5B]",
finished: "border-white/15 text-white/55", finished: "border-white/15 text-white/55",
cancelled: "border-white/10 text-white/35",
}; };
// 토너먼트 탭 라운드 칩 정렬(대진 순서) // 토너먼트 탭 라운드 칩 정렬(대진 순서)
const KO_ROUND_ORDER = ["32강", "16강", "8강", "4강", "3·4위전", "결승"]; const KO_ROUND_ORDER = ["32강", "16강", "8강", "4강", "3·4위전", "결승"];
const isGroupLetter = (s: string) => /^[A-Z]$/.test(s); const isGroupLetter = (s: string) => /^[A-Z]$/.test(s);
type Tab = "date" | "group" | "tournament"; type Tab = "date" | "group" | "tournament" | "team" | "rank";
export default function ScheduleBoard({ export default function ScheduleBoard({
matches, matches,
lang = "ko", lang = "ko",
league = "wc",
}: { }: {
matches: Match[]; matches: Match[];
lang?: Lang; lang?: Lang;
league?: League;
}) { }) {
const t = dict(lang); const t = dict(lang);
// 야구(KBO/MLB)는 조/토너먼트 대신 날짜별/팀별 탭 제공
const isBaseball = league !== "wc";
// 날짜 목록 (정렬) // 날짜 목록 (정렬)
const dates = useMemo( const dates = useMemo(
@ -63,11 +70,34 @@ export default function ScheduleBoard({
// 시작 탭: 토너먼트가 시작됐으면 '토너먼트', 아니면 '날짜별'. // 시작 탭: 토너먼트가 시작됐으면 '토너먼트', 아니면 '날짜별'.
// 단, 직전 세션 선택이 있으면 우선(상세 진입 후 뒤로가기 복원). // 단, 직전 세션 선택이 있으면 우선(상세 진입 후 뒤로가기 복원).
const [tab, setTab] = useState<Tab>(() => { const [tab, setTabRaw] = useState<Tab>(() => {
const saved = sessionStorage.getItem("tp.sched.tab"); const saved = sessionStorage.getItem("tp.sched.tab");
if (saved === "date" || saved === "group" || saved === "tournament") return saved; if (
saved === "date" || saved === "group" || saved === "tournament" ||
saved === "team" || saved === "rank"
)
return saved;
if (isBaseball) return "date";
return rounds.length > 0 ? "tournament" : "date"; return rounds.length > 0 ? "tournament" : "date";
}); });
// 리그에 없는 탭이 저장돼 있으면 날짜별로 강등 (야구↔축구 전환 시)
const effTab: Tab = isBaseball
? (tab === "team" || tab === "rank" ? tab : "date")
: (tab === "team" || tab === "rank" ? "date" : tab);
const setTab = setTabRaw;
// 팀별 탭: 등장 팀 목록 (짧은 이름 가나다/알파벳 순)
const teams = useMemo(() => {
if (!isBaseball) return [];
const byCode = new Map<string, Match["teamA"]>();
for (const m of matches) {
byCode.set(m.teamA.code, m.teamA);
byCode.set(m.teamB.code, m.teamB);
}
return [...byCode.values()].sort((a, b) =>
teamShort(a, lang).localeCompare(teamShort(b, lang), lang === "en" ? "en" : "ko"),
);
}, [matches, isBaseball, lang]);
// 기본 선택: 오늘(없으면 첫 날짜) / A조 / 현재 진행 라운드. // 기본 선택: 오늘(없으면 첫 날짜) / A조 / 현재 진행 라운드.
// 단, 직전 세션 선택을 sessionStorage 에 보관 → 상세 진입 후 뒤로가기 시 그대로 복원(오늘로 초기화 X). // 단, 직전 세션 선택을 sessionStorage 에 보관 → 상세 진입 후 뒤로가기 시 그대로 복원(오늘로 초기화 X).
@ -87,6 +117,9 @@ export default function ScheduleBoard({
if (saved && rounds.includes(saved)) return saved; if (saved && rounds.includes(saved)) return saved;
return currentRound; return currentRound;
}); });
const [selTeam, setSelTeam] = useState(
() => sessionStorage.getItem("tp.sched.team") ?? "",
);
// 선택 변경 시 보관(다음 진입/뒤로가기에서 복원) // 선택 변경 시 보관(다음 진입/뒤로가기에서 복원)
useEffect(() => { useEffect(() => {
@ -101,22 +134,47 @@ export default function ScheduleBoard({
useEffect(() => { useEffect(() => {
sessionStorage.setItem("tp.sched.tab", tab); sessionStorage.setItem("tp.sched.tab", tab);
}, [tab]); }, [tab]);
useEffect(() => {
if (selTeam) sessionStorage.setItem("tp.sched.team", selTeam);
}, [selTeam]);
useEffect(() => {
if (dates.length && !dates.includes(selDate)) {
setSelDate(dates.includes(todayKey) ? todayKey : dates[0]);
}
}, [dates, selDate, todayKey]);
// 리그 전환 시 목록에 없는 팀이면 첫 팀으로
useEffect(() => {
if (teams.length && !teams.some((tm) => tm.code === selTeam)) {
setSelTeam(teams[0].code);
}
}, [teams, selTeam]);
const shown = useMemo(() => { const shown = useMemo(() => {
let list: Match[]; let list: Match[];
if (tab === "date") list = matches.filter((m) => dateKey(m.kickoffKst) === selDate); if (effTab === "date") list = matches.filter((m) => dateKey(m.kickoffKst) === selDate);
else if (tab === "group") list = matches.filter((m) => !!m.group && m.group === selGroup); else if (effTab === "team")
list = matches.filter((m) => m.teamA.code === selTeam || m.teamB.code === selTeam);
else if (effTab === "group") list = matches.filter((m) => !!m.group && m.group === selGroup);
else list = matches.filter((m) => m.roundLabel === selRound); // 토너먼트 else list = matches.filter((m) => m.roundLabel === selRound); // 토너먼트
return [...list].sort( return [...list].sort(
(a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(), (a, b) => new Date(a.kickoffKst).getTime() - new Date(b.kickoffKst).getTime(),
); );
}, [matches, tab, selDate, selGroup, selRound]); }, [matches, effTab, selDate, selGroup, selRound, selTeam]);
const tabLabel = (k: Tab) => const tabLabel = (k: Tab) =>
k === "date" k === "date"
? lang === "en" ? lang === "en"
? "By date" ? "By date"
: "날짜별" : "날짜별"
: k === "team"
? lang === "en"
? "By team"
: "팀별"
: k === "rank"
? lang === "en"
? "Standings"
: "순위"
: k === "group" : k === "group"
? lang === "en" ? lang === "en"
? "Groups" ? "Groups"
@ -125,6 +183,10 @@ export default function ScheduleBoard({
? "Tournament" ? "Tournament"
: "토너먼트"; : "토너먼트";
const leagueTabs: Tab[] = isBaseball
? ["date", "team", "rank"]
: ["date", "group", "tournament"];
return ( return (
<section className="mt-6"> <section className="mt-6">
<div className="mb-3 flex items-baseline gap-2.5"> <div className="mb-3 flex items-baseline gap-2.5">
@ -132,14 +194,14 @@ export default function ScheduleBoard({
<span className="whitespace-nowrap text-[12px] text-white/55">{t.schedGuide}</span> <span className="whitespace-nowrap text-[12px] text-white/55">{t.schedGuide}</span>
</div> </div>
{/* 탭: 날짜별 / 조별리그 / 토너먼트 */} {/* 탭: 축구 = 날짜별/조별리그/토너먼트 · 야구 = 날짜별/팀별 */}
<div className="mb-3 flex gap-5 border-b border-[var(--line-d)]"> <div className="mb-3 flex gap-5 border-b border-[var(--line-d)]">
{(["date", "group", "tournament"] as Tab[]).map((k) => ( {leagueTabs.map((k) => (
<button <button
key={k} key={k}
onClick={() => setTab(k)} onClick={() => setTab(k)}
className={`-mb-px border-b-2 pb-2 text-[15px] font-bold transition ${ className={`-mb-px border-b-2 pb-2 text-[15px] font-bold transition ${
tab === k effTab === k
? "border-[var(--green)] text-white" ? "border-[var(--green)] text-white"
: "border-transparent text-white/45" : "border-transparent text-white/45"
}`} }`}
@ -149,15 +211,28 @@ export default function ScheduleBoard({
))} ))}
</div> </div>
{/* 순위 탭: 칩/카드 대신 순위표 */}
{effTab === "rank" && <StandingsTable league={league} lang={lang} />}
{/* 칩: 날짜 / 조 / 라운드 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */} {/* 칩: 날짜 / 조 / 라운드 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */}
<ChipScroller resetKey={tab}> {effTab !== "rank" && (
{tab === "date" <ChipScroller resetKey={`${league}:${effTab}`}>
{effTab === "date"
? dates.map((d) => ( ? dates.map((d) => (
<Chip key={d} active={d === selDate} onClick={() => setSelDate(d)}> <Chip key={d} active={d === selDate} onClick={() => setSelDate(d)}>
{shortDate(d, lang)} {shortDate(d, lang)}
</Chip> </Chip>
)) ))
: tab === "group" : effTab === "team"
? teams.map((tm) => (
<Chip key={tm.code} active={tm.code === selTeam} onClick={() => setSelTeam(tm.code)}>
<span className="flex items-center gap-1.5">
<TeamFlag team={tm} className="h-4 w-4 shrink-0" />
{teamShort(tm, lang)}
</span>
</Chip>
))
: effTab === "group"
? groups.map((g) => ( ? groups.map((g) => (
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}> <Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
{lang === "en" ? `Group ${g}` : `${g}`} {lang === "en" ? `Group ${g}` : `${g}`}
@ -169,12 +244,14 @@ export default function ScheduleBoard({
</Chip> </Chip>
))} ))}
</ChipScroller> </ChipScroller>
)}
{/* 경기 카드 */} {/* 경기 카드 */}
{effTab !== "rank" && (
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{shown.length === 0 && ( {shown.length === 0 && (
<p className="py-6 text-center text-[13px] text-white/45"> <p className="py-6 text-center text-[13px] text-white/45">
{tab === "tournament" {effTab === "tournament"
? lang === "en" ? lang === "en"
? "Bracket is set after the group stage." ? "Bracket is set after the group stage."
: "토너먼트 대진은 조별리그 종료 후 확정됩니다" : "토너먼트 대진은 조별리그 종료 후 확정됩니다"
@ -184,9 +261,10 @@ export default function ScheduleBoard({
</p> </p>
)} )}
{shown.map((m) => ( {shown.map((m) => (
<MatchCard key={m.matchId} match={m} lang={lang} /> <MatchCard key={m.matchId} match={m} lang={lang} showDate={effTab === "team"} />
))} ))}
</div> </div>
)}
</section> </section>
); );
} }
@ -301,27 +379,80 @@ function Chip({
); );
} }
function MatchCard({ match, lang }: { match: Match; lang: Lang }) { // 팀명 옆 순위 배지 (야구 — 순위 캐시 없으면 렌더 안 함)
function RankBadge({ rank, lang = "ko" }: { rank?: number | null; lang?: Lang }) {
if (!rank) return null;
return (
<span className="shrink-0 rounded border border-white/15 px-1 py-px text-[10px] font-bold text-white/45">
{lang === "en" ? `#${rank}` : `${rank}`}
</span>
);
}
function MatchCard({
match,
lang,
showDate = false,
}: {
match: Match;
lang: Lang;
showDate?: boolean;
}) {
const t = dict(lang); const t = dict(lang);
const phase = match.phase; const phase = match.phase;
const finished = !!match.result; const finished = !!match.result;
const isBaseball = match.league === "kbo" || match.league === "mlb";
// 야구 진행 중: 회차·스코어 표시 (서버 15초 캐시라 부담 없음, 30초 폴링)
const [live, setLive] = useState<LiveData | null>(null);
useEffect(() => {
if (phase !== "live" || !isBaseball) return;
let alive = true;
const load = () =>
getLive(match.matchId)
.then((d) => {
if (alive && d.available) setLive(d);
})
.catch(() => {});
load();
const id = setInterval(load, 30_000);
return () => {
alive = false;
clearInterval(id);
};
}, [match.matchId, phase, isBaseball]);
// "6회초" / "Top 6"
const inning =
live?.inn != null
? lang === "en"
? `${live.half === "B" ? "Bot" : "Top"} ${live.inn}`
: `${live.inn}${live.half === "B" ? "말" : "초"}`
: null;
// 한국을 항상 왼쪽에 표시(상세 페이지와 동일 규칙). 데이터는 원본 A/B 유지, 화면 좌우만 교체. // 한국을 항상 왼쪽에 표시(상세 페이지와 동일 규칙). 데이터는 원본 A/B 유지, 화면 좌우만 교체.
const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR"; const flip = !isBaseball && match.teamB.code === "KOR" && match.teamA.code !== "KOR";
const left = flip ? match.teamB : match.teamA; const left = flip ? match.teamB : match.teamA;
const right = flip ? match.teamA : match.teamB; const right = flip ? match.teamA : match.teamB;
const leftScore = flip ? match.result?.scoreB : match.result?.scoreA; const leftScore = flip ? match.result?.scoreB : match.result?.scoreA;
const rightScore = flip ? match.result?.scoreA : match.result?.scoreB; const rightScore = flip ? match.result?.scoreA : match.result?.scoreB;
const flagCls = isBaseball ? "h-7 w-7 shrink-0" : "h-6 w-9 shrink-0";
// 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동 // 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동
const inner = ( const inner = (
<> <>
<div className="flex items-center justify-between text-[11px]"> <div className="flex items-center justify-between text-[11px]">
<span className="font-mono font-bold text-white/70"> <span className="font-mono font-bold text-white/70">
{showDate && (
<span className="text-white/55">{shortDate(dateKey(match.kickoffKst), lang)} </span>
)}
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> {timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span>
{/* 조별리그 seed 의 부가 라벨(예: 개막전)만 여기에. 녹아웃 라운드는 우측에 표시. */} {isBaseball && match.venue ? (
{match.group && match.roundLabel ? <> · {tRound(match.roundLabel, lang)}</> : null} <span className="text-white/40"> · {match.venue}</span>
) : match.group && match.roundLabel ? (
<> · {tRound(match.roundLabel, lang)}</>
) : null}
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
{!isBaseball && (
<span className="text-[11px] font-bold text-white/45"> <span className="text-[11px] font-bold text-white/45">
{match.group {match.group
? lang === "en" ? lang === "en"
@ -329,16 +460,18 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
: `${match.group}` : `${match.group}`
: tRound(match.roundLabel, lang)} : tRound(match.roundLabel, lang)}
</span> </span>
)}
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}> <span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
{t.phase[phase]} {phase === "live" && inning ? inning : t.phase[phase]}
</span> </span>
</span> </span>
</div> </div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2"> <div className="mt-3 grid grid-cols-[1fr_auto_1fr] items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TeamFlag team={left} className="h-6 w-9 shrink-0" /> <TeamFlag team={left} className={flagCls} />
<span className="truncate text-[15px] font-extrabold">{teamShort(left, lang)}</span> <span className="truncate text-[15px] font-extrabold">{teamShort(left, lang)}</span>
{isBaseball && <RankBadge rank={match.extras?.standings?.a?.rank} lang={lang} />}
</div> </div>
{match.result ? ( {match.result ? (
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white"> <span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
@ -346,17 +479,35 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
<span className="px-1.5 text-white/40">:</span> <span className="px-1.5 text-white/40">:</span>
{rightScore} {rightScore}
</span> </span>
) : phase === "live" && live?.score ? (
// 진행 중 실시간 스코어 (A=원정, B=홈)
<span className="whitespace-nowrap font-mono text-[18px] font-extrabold tabular-nums text-white">
{live.score.away ?? 0}
<span className="px-1.5 text-[#FF5B5B]">:</span>
{live.score.home ?? 0}
</span>
) : ( ) : (
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span> <span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
)} )}
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
{isBaseball && <RankBadge rank={match.extras?.standings?.b?.rank} lang={lang} />}
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(right, lang)}</span> <span className="truncate text-right text-[15px] font-extrabold">{teamShort(right, lang)}</span>
<TeamFlag team={right} className="h-6 w-9 shrink-0" /> <TeamFlag team={right} className={flagCls} />
</div> </div>
</div> </div>
{/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 */} {/* 야구: 선발 매치업 (프리뷰 캐시 있을 때만) */}
{match.votable && ( {isBaseball && !match.result && (match.extras?.starterA || match.extras?.starterB) && (
<div className="mt-2 text-center text-[11px] font-semibold text-white/50">
<span className="text-white/35">{lang === "en" ? "SP" : "선발"}</span>{" "}
{match.extras?.starterA?.name ?? "?"}
<span className="px-1 text-white/30">vs</span>
{match.extras?.starterB?.name ?? "?"}
</div>
)}
{/* 투표 가능 조만: AI 픽 갈림 + 참여수 + 이동 화살표 (취소 경기는 숨김) */}
{match.votable && phase !== "cancelled" && (
<div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]"> <div className="mt-3 flex items-center justify-between text-[11px] font-bold text-[#F4F3FE]">
<span> <span>
<span className="font-semibold text-white/55">{t.aiPicks}</span>{" "} <span className="font-semibold text-white/55">{t.aiPicks}</span>{" "}
@ -372,6 +523,10 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
); );
const base = "block rounded-2xl border-2 bg-[#171b21] p-4"; const base = "block rounded-2xl border-2 bg-[#171b21] p-4";
// 취소 경기: 클릭 불가·저채도 표시 전용
if (phase === "cancelled") {
return <div className={`${base} border-[var(--line-d)] opacity-60`}>{inner}</div>;
}
if (match.votable && !finished) { if (match.votable && !finished) {
return ( return (
<Link <Link

View File

@ -0,0 +1,142 @@
import { useEffect, useState } from "react";
import { getStandings } from "@/lib/api";
import { type Lang, teamShort } from "@/lib/i18n";
import type { StandingRow, StandingsOut } from "@/lib/types";
import TeamFlag from "./TeamFlag";
// 야구 리그 순위표 — KBO: 단일 테이블 · MLB: 디비전 6그룹.
// 데이터는 워커가 캐싱한 시즌 순위(/api/standings)로, 하루 수회 갱신된다.
const DIV_LABEL: Record<string, { ko: string; en: string }> = {
ALE: { ko: "AL 동부", en: "AL East" },
ALC: { ko: "AL 중부", en: "AL Central" },
ALW: { ko: "AL 서부", en: "AL West" },
NLE: { ko: "NL 동부", en: "NL East" },
NLC: { ko: "NL 중부", en: "NL Central" },
NLW: { ko: "NL 서부", en: "NL West" },
};
function fmtWra(v: StandingRow["wra"]): string {
if (v == null || v === "") return "-";
const n = typeof v === "number" ? v : parseFloat(v);
return Number.isNaN(n) ? String(v) : n.toFixed(3);
}
function fmtGb(v: StandingRow["gb"]): string {
if (v == null || v === "" || v === "-") return "-";
const n = Number(v);
return !Number.isNaN(n) && n === 0 ? "-" : String(v);
}
export default function StandingsTable({
league,
lang = "ko",
}: {
league: string;
lang?: Lang;
}) {
const [data, setData] = useState<StandingsOut | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
let alive = true;
setLoaded(false);
getStandings(league)
.then((d) => {
if (!alive) return;
setData(d);
setLoaded(true);
})
.catch(() => {
if (!alive) return;
setData(null);
setLoaded(true);
});
return () => {
alive = false;
};
}, [league]);
if (!loaded) {
return (
<p className="py-6 text-center text-[13px] text-white/45">
{lang === "en" ? "Loading…" : "불러오는 중…"}
</p>
);
}
if (!data || data.groups.length === 0) {
return (
<p className="py-6 text-center text-[13px] text-white/45">
{lang === "en" ? "Standings not available yet" : "순위 정보가 아직 없습니다"}
</p>
);
}
const hasDraw = league === "kbo"; // KBO 만 무승부 존재
return (
<div className="flex flex-col gap-4">
{data.groups.map((g, gi) => (
<div key={g.key ?? gi} className="rounded-2xl border border-[var(--line-d)] bg-[#171b21] px-3 py-2.5">
{g.key && (
<div className="mb-1.5 px-1 text-[13px] font-extrabold text-[#94FBE0]">
{DIV_LABEL[g.key]?.[lang === "en" ? "en" : "ko"] ?? g.key}
</div>
)}
<table className="w-full text-[13px] tabular-nums">
<thead>
<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>
<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>
</tr>
</thead>
<tbody>
{g.rows.map((r) => (
<tr key={r.code} className="border-t border-white/[0.06]">
<td
className={`py-1.5 text-center font-mono font-extrabold ${
r.rank === 1 ? "text-[#94FBE0]" : "text-white/60"
}`}
>
{r.rank ?? "-"}
</td>
<td className="py-1.5">
<span className="flex items-center gap-1.5">
<TeamFlag team={r} className="h-[18px] w-[18px] shrink-0" />
<span className="truncate font-bold">{teamShort(r, lang)}</span>
</span>
</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>
</tr>
))}
</tbody>
</table>
</div>
))}
{data.updatedAt && (
<p className="-mt-1 text-right text-[11px] text-white/35">
{(() => {
const d = new Date(data.updatedAt);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return lang === "en"
? `As of ${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}`
: `${d.getMonth() + 1}.${d.getDate()} ${hh}:${mm} 기준`;
})()}
</p>
)}
</div>
);
}

View File

@ -1,9 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import type { Team } from "@/lib/types"; import type { Team } from "@/lib/types";
// 모든 경기에 일반화된 국기 렌더. // 모든 경기에 일반화된 국기/팀로고 렌더.
// 글로벌 축구 축전 출전 48개국 SVG 자산(/assets/flags/{fifa코드}.svg, 예: kor.svg)을 사용하고, // 야구(KBO/MLB): 백엔드가 flag 필드에 로고 경로/URL 을 실어줌 → 그대로 img.
// 자산이 없는 코드는 이모지 폴백(컨테이너 높이에 비례, 안 잘림). // 축구(월드컵): 48개국 SVG 자산(/assets/flags/{fifa코드}.svg) 사용.
// 없으면 이모지 폴백(컨테이너 높이에 비례, 안 잘림).
export default function TeamFlag({ export default function TeamFlag({
team, team,
className = "", className = "",
@ -12,6 +13,19 @@ export default function TeamFlag({
className?: string; className?: string;
}) { }) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
const isLogoUrl =
!!team.flag && (team.flag.startsWith("/") || team.flag.startsWith("http"));
if (!failed && isLogoUrl) {
return (
<img
src={team.flag}
alt={team.name}
onError={() => setFailed(true)}
className={`object-contain ${className}`}
/>
);
}
if (!failed && team.code) { if (!failed && team.code) {
return ( return (

View File

@ -10,6 +10,7 @@ import type {
MyPrediction, MyPrediction,
RankingEntry, RankingEntry,
RankingTab, RankingTab,
StandingsOut,
} from "./types"; } from "./types";
import type { Lang } from "./i18n"; import type { Lang } from "./i18n";
@ -42,14 +43,41 @@ export class ApiError extends Error {
} }
} }
export function listMatches(lang: Lang): Promise<Match[]> { export function listMatches(lang: Lang, league = ""): Promise<Match[]> {
return http<Match[]>(`/matches?lang=${lang}`); return http<Match[]>(`/matches?lang=${lang}${league ? `&league=${league}` : ""}`);
} }
export function getMatch(matchId: string, lang: Lang): Promise<Match> { export function getMatch(matchId: string, lang: Lang): Promise<Match> {
return http<Match>(`/matches/${matchId}?lang=${lang}`); return http<Match>(`/matches/${matchId}?lang=${lang}`);
} }
// ── 야구 라이브 필드 뷰 ──────────────────────────────────────
export interface LiveBatter {
order?: number | null;
name: string;
pos?: string;
avg?: number | string | null;
sub?: boolean;
}
export interface LiveData {
available: boolean;
inn?: number;
half?: "T" | "B";
score?: { away?: number | string; home?: number | string };
bso?: { b?: number | string; s?: number | string; o?: number | string };
bases?: boolean[];
batter?: LiveBatter | null;
pitcher?: { name: string; ballCount?: number | string | null } | null;
vsRecord?: string;
defense?: { pos?: string; name: string }[];
offenseLineup?: LiveBatter[];
}
export function getLive(matchId: string): Promise<LiveData> {
return http<LiveData>(`/matches/${matchId}/live`);
}
export interface SubmitResult { export interface SubmitResult {
ok: boolean; ok: boolean;
predictionId: string; predictionId: string;
@ -73,23 +101,28 @@ export function submitPrediction(body: {
}); });
} }
export function getLeaderboard(): Promise<Leaderboard> { export function getLeaderboard(league = ""): Promise<Leaderboard> {
return http<Leaderboard>(`/leaderboard`); return http<Leaderboard>(`/leaderboard${league ? `?league=${league}` : ""}`);
}
// 리그 순위표 (야구 — 워커가 캐싱한 시즌 순위)
export function getStandings(league: string): Promise<StandingsOut> {
return http<StandingsOut>(`/standings?league=${league}`);
} }
// AI 모델 누적 랭킹 (종료 경기 합산 — 서버가 매 조회 시 재계산) // AI 모델 누적 랭킹 (종료 경기 합산 — 서버가 매 조회 시 재계산)
export function getAILeaderboard(): Promise<AILeaderboard> { export function getAILeaderboard(league = ""): Promise<AILeaderboard> {
return http<AILeaderboard>(`/leaderboard/ai`); return http<AILeaderboard>(`/leaderboard/ai${league ? `?league=${league}` : ""}`);
} }
// 랭킹 위젯 공용 — 탭에 맞춰 TOP 10 을 {label, points} 로 정규화해 반환. // 랭킹 위젯 공용 — 탭에 맞춰 TOP 10 을 {label, points} 로 정규화해 반환.
// 경기 종료·채점 때마다 서버 값이 갱신되므로 호출 시점 최신 누적이 내려옴. // league 를 주면 해당 리그 경기만 집계(빈 값 = 전체 통합).
export async function fetchRanking(tab: RankingTab): Promise<RankingEntry[]> { export async function fetchRanking(tab: RankingTab, league = ""): Promise<RankingEntry[]> {
if (tab === "ai") { if (tab === "ai") {
const { standings } = await getAILeaderboard(); const { standings } = await getAILeaderboard(league);
return standings.map((s) => ({ label: s.model, points: s.totalPoints })); return standings.map((s) => ({ label: s.model, points: s.totalPoints }));
} }
const { standings } = await getLeaderboard(); const { standings } = await getLeaderboard(league);
return standings return standings
.slice(0, 10) .slice(0, 10)
.map((s) => ({ label: s.emailMasked, points: s.totalPoints })); .map((s) => ({ label: s.emailMasked, points: s.totalPoints }));
@ -125,10 +158,14 @@ export function postComment(
// ── 세션 닉네임: 세션당 한 번 발급받아 sessionStorage 에 캐싱 ── // ── 세션 닉네임: 세션당 한 번 발급받아 sessionStorage 에 캐싱 ──
// 탭/창을 닫으면 비워지고(껐다 켜면 새 닉), 시크릿창은 별도 저장소라 새로 발급됨. // 탭/창을 닫으면 비워지고(껐다 켜면 새 닉), 시크릿창은 별도 저장소라 새로 발급됨.
// 종목별(축구/야구) 캐시 분리 — 서버가 리그에 맞는 단어풀로 발급하므로
// 축구 닉이 야구 경기에 재사용되지 않게 키를 나눈다.
const SESSION_NICK_KEY = "tp_session_nick"; const SESSION_NICK_KEY = "tp_session_nick";
const nickKey = (matchId: string) =>
`${SESSION_NICK_KEY}.${/^(KBO|MLB)_/.test(matchId) ? "bb" : "wc"}`;
export async function getSessionNickname(matchId: string): Promise<string> { export async function getSessionNickname(matchId: string): Promise<string> {
try { try {
const cached = sessionStorage.getItem(SESSION_NICK_KEY); const cached = sessionStorage.getItem(nickKey(matchId));
if (cached) return cached; if (cached) return cached;
} catch { } catch {
/* sessionStorage 불가 환경은 매번 발급 */ /* sessionStorage 불가 환경은 매번 발급 */
@ -137,7 +174,7 @@ export async function getSessionNickname(matchId: string): Promise<string> {
`/matches/${matchId}/comments/nickname`, `/matches/${matchId}/comments/nickname`,
); );
try { try {
sessionStorage.setItem(SESSION_NICK_KEY, nickname); sessionStorage.setItem(nickKey(matchId), nickname);
} catch { } catch {
/* noop */ /* noop */
} }
@ -150,7 +187,7 @@ export async function rerollSessionNickname(matchId: string): Promise<string> {
`/matches/${matchId}/comments/nickname`, `/matches/${matchId}/comments/nickname`,
); );
try { try {
sessionStorage.setItem(SESSION_NICK_KEY, nickname); sessionStorage.setItem(nickKey(matchId), nickname);
} catch { } catch {
/* noop */ /* noop */
} }

View File

@ -58,7 +58,14 @@ type Dict = {
prizeLine2: string; prizeLine2: string;
schedTitle: string; schedTitle: string;
schedGuide: string; schedGuide: string;
phase: { open: string; scheduled: string; locked: string; live: string; finished: string }; phase: {
open: string;
scheduled: string;
locked: string;
live: string;
finished: string;
cancelled: string;
};
aiPicks: string; aiPicks: string;
draw: string; draw: string;
joined: (n: string) => string; joined: (n: string) => string;
@ -90,8 +97,10 @@ type Dict = {
rulesCumulative: string; rulesCumulative: string;
rulesExact: string; rulesExact: string;
rulesCloseGrade: string; rulesCloseGrade: string;
rulesCloseGradeBB: string; // 야구: 득실차 ±1 허용
rulesOutcome: string; rulesOutcome: string;
rulesPartial: string; rulesPartial: string;
rulesPartialBB: string; // 야구: 한 팀 득점 ±1 허용
rulesMiss: string; rulesMiss: string;
rulesEmailRequired: string; rulesEmailRequired: string;
rulesCloseBtn: string; rulesCloseBtn: string;
@ -163,7 +172,7 @@ export const DICT: Record<Lang, Dict> = {
prizeLine2: "최종 우승자에게 100만원 상금", prizeLine2: "최종 우승자에게 100만원 상금",
schedTitle: "전체 일정", schedTitle: "전체 일정",
schedGuide: "투표하고 싶은 경기를 선택해주세요", schedGuide: "투표하고 싶은 경기를 선택해주세요",
phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 종료", live: "경기중", finished: "종료" }, phase: { open: "투표 중", scheduled: "오픈 예정", locked: "투표 종료", live: "경기중", finished: "종료", cancelled: "취소" },
aiPicks: "AI 픽", aiPicks: "AI 픽",
draw: "무", draw: "무",
joined: (n) => `${n}명 참여`, joined: (n) => `${n}명 참여`,
@ -196,8 +205,10 @@ export const DICT: Record<Lang, Dict> = {
rulesCumulative: "경기마다 적중 포인트를 쌓아, 대회 누적 포인트 1위 한 분에게 최종 100만 원을 드립니다.", rulesCumulative: "경기마다 적중 포인트를 쌓아, 대회 누적 포인트 1위 한 분에게 최종 100만 원을 드립니다.",
rulesExact: "정확 스코어 적중", rulesExact: "정확 스코어 적중",
rulesCloseGrade: "근접 (승패 + 득실차 일치)", rulesCloseGrade: "근접 (승패 + 득실차 일치)",
rulesCloseGradeBB: "근접 (승패 + 득실차 ±1)",
rulesOutcome: "승패만 적중", rulesOutcome: "승패만 적중",
rulesPartial: "부분 (한 팀 스코어만 일치)", rulesPartial: "부분 (한 팀 스코어만 일치)",
rulesPartialBB: "부분 (한 팀 득점 ±1)",
rulesMiss: "빗나감", rulesMiss: "빗나감",
rulesEmailRequired: rulesEmailRequired:
"픽 제출 시 입력한 이메일로 챌린지에 참여됩니다. 같은 이메일의 모든 경기 포인트가 합산되어 누적 순위가 매겨집니다.", "픽 제출 시 입력한 이메일로 챌린지에 참여됩니다. 같은 이메일의 모든 경기 포인트가 합산되어 누적 순위가 매겨집니다.",
@ -266,7 +277,7 @@ export const DICT: Record<Lang, Dict> = {
prizeLine2: "overall winner takes ₩1,000,000", prizeLine2: "overall winner takes ₩1,000,000",
schedTitle: "All Matches", schedTitle: "All Matches",
schedGuide: "Pick a match to predict", schedGuide: "Pick a match to predict",
phase: { open: "Voting", scheduled: "Opens soon", locked: "Closed", live: "Live", finished: "Ended" }, phase: { open: "Voting", scheduled: "Opens soon", locked: "Closed", live: "Live", finished: "Ended", cancelled: "Canceled" },
aiPicks: "AI picks", aiPicks: "AI picks",
draw: "Draw", draw: "Draw",
joined: (n) => `${n} joined`, joined: (n) => `${n} joined`,
@ -299,8 +310,10 @@ export const DICT: Record<Lang, Dict> = {
rulesCumulative: "Earn points each match — the player with the highest cumulative total wins ₩1,000,000.", rulesCumulative: "Earn points each match — the player with the highest cumulative total wins ₩1,000,000.",
rulesExact: "Exact score", rulesExact: "Exact score",
rulesCloseGrade: "Close (result + goal difference)", rulesCloseGrade: "Close (result + goal difference)",
rulesCloseGradeBB: "Close (result + run diff ±1)",
rulesOutcome: "Correct result only", rulesOutcome: "Correct result only",
rulesPartial: "Partial (one team's score)", rulesPartial: "Partial (one team's score)",
rulesPartialBB: "Partial (one team's runs ±1)",
rulesMiss: "Miss", rulesMiss: "Miss",
rulesEmailRequired: rulesEmailRequired:
"You enter the challenge with the email you provide when submitting a pick. Points from all matches under the same email are summed for the overall ranking.", "You enter the challenge with the email you provide when submitting a pick. Points from all matches under the same email are summed for the overall ranking.",

View File

@ -2,7 +2,13 @@
export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN"; export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN";
export type ModelName = "GPT" | "Claude" | "Gemini"; export type ModelName = "GPT" | "Claude" | "Gemini";
export type MatchPhase = "scheduled" | "open" | "locked" | "live" | "finished"; export type MatchPhase =
| "scheduled"
| "open"
| "locked"
| "live"
| "finished"
| "cancelled";
export interface Team { export interface Team {
name: string; name: string;
@ -36,8 +42,50 @@ export interface CrowdStats {
teamBWin: number; teamBWin: number;
} }
// 리그: wc(월드컵 축구) | kbo | mlb
export type League = "wc" | "kbo" | "mlb";
// 야구 부가정보 (프리뷰·순위 캐시 — 없으면 undefined)
export interface StarterInfo {
name: string;
hitType?: string;
era?: string | number | null;
w?: number | null;
l?: number | null;
vsEra?: string | number | null;
}
export interface TeamStanding {
rank?: number | null;
w?: number;
d?: number;
l?: number;
wra?: string | number;
gb?: string | number;
last5?: string | null;
}
// /standings 응답 — 팀 정보(Team) + 시즌 성적 한 행
export interface StandingRow extends Team, TeamStanding {
div?: string | null; // MLB 디비전 키 (ALE/ALC/ALW/NLE/NLC/NLW)
}
export interface StandingsOut {
league: string;
updatedAt?: string | null;
groups: { key: string | null; rows: StandingRow[] }[];
}
export interface MatchExtras {
starterA?: StarterInfo | null;
starterB?: StarterInfo | null;
seasonVs?: { aWin?: number; draw?: number; bWin?: number } | null;
standings?: { a?: TeamStanding | null; b?: TeamStanding | null };
}
export interface Match { export interface Match {
matchId: string; matchId: string;
league: League;
roundLabel: string; roundLabel: string;
group: string; group: string;
teamA: Team; teamA: Team;
@ -54,6 +102,7 @@ export interface Match {
result?: MatchResult | null; result?: MatchResult | null;
predictions: AIPrediction[]; predictions: AIPrediction[];
crowd?: CrowdStats | null; crowd?: CrowdStats | null;
extras?: MatchExtras | null;
} }
export interface UserPick { export interface UserPick {

View File

@ -0,0 +1,19 @@
import { useSearchParams } from "react-router-dom";
import type { League } from "./types";
// 리그 상태 — URL 쿼리(?league=)가 SSOT. 기본값은 KBO(메인 탭).
// 링크 공유 시에도 리그가 보존되도록 쿼리 파라미터를 쓴다.
export const DEFAULT_LEAGUE: League = "kbo";
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;
const setLeague = (l: League) => {
const next = new URLSearchParams(params);
next.set("league", l);
setParams(next, { replace: false });
};
return [league, setLeague];
}

View File

@ -5,12 +5,14 @@ import Ado2Ad from "@/components/Ado2Ad";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import { dict } from "@/lib/i18n"; import { dict } from "@/lib/i18n";
import { useLang } from "@/lib/useLang"; import { useLang } from "@/lib/useLang";
import { useLeague } from "@/lib/useLeague";
import { listMatches } from "@/lib/api"; import { listMatches } from "@/lib/api";
import type { Match } from "@/lib/types"; import type { Match } from "@/lib/types";
// L1. 전체 일정 대시보드 (랜딩 진입) // L1. 전체 일정 대시보드 (랜딩 진입) — 리그 탭(월드컵/KBO/MLB)별 경기 목록
export default function Dashboard() { export default function Dashboard() {
const lang = useLang(); const lang = useLang();
const [league] = useLeague();
const t = dict(lang); const t = dict(lang);
const [matches, setMatches] = useState<Match[] | null>(null); const [matches, setMatches] = useState<Match[] | null>(null);
const [error, setError] = useState(false); const [error, setError] = useState(false);
@ -19,17 +21,17 @@ export default function Dashboard() {
let alive = true; let alive = true;
setMatches(null); setMatches(null);
setError(false); setError(false);
listMatches(lang) listMatches(lang, league)
.then((m) => alive && setMatches(m)) .then((m) => alive && setMatches(m))
.catch(() => alive && setError(true)); .catch(() => alive && setError(true));
return () => { return () => {
alive = false; alive = false;
}; };
}, [lang]); }, [lang, league]);
return ( return (
<main className="shell"> <main className="shell">
<Hero lang={lang} /> <Hero lang={lang} league={league} />
{/* 기간 안내 + CTA 영역 */} {/* 기간 안내 + CTA 영역 */}
<div className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4 text-center"> <div className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4 text-center">
@ -48,7 +50,7 @@ export default function Dashboard() {
{lang === "en" ? "Failed to load matches." : "경기 정보를 불러오지 못했습니다."} {lang === "en" ? "Failed to load matches." : "경기 정보를 불러오지 못했습니다."}
</p> </p>
)} )}
{matches && <ScheduleBoard matches={matches} lang={lang} />} {matches && <ScheduleBoard matches={matches} lang={lang} league={league} />}
<Ado2Ad lang={lang} /> <Ado2Ad lang={lang} />
<Footer lang={lang} /> <Footer lang={lang} />

View File

@ -2,11 +2,13 @@ import Hero from "@/components/Hero";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
import RankingBoard from "@/components/RankingBoard"; import RankingBoard from "@/components/RankingBoard";
import { useLang } from "@/lib/useLang"; import { useLang } from "@/lib/useLang";
import { useLeague } from "@/lib/useLeague";
// L3. 리더보드 — 누적 포인트 랭킹(참가자/AI 탭). 펼친 상태로 전체 표시. // L3. 리더보드 — 누적 포인트 랭킹(참가자/AI 탭). 펼친 상태로 전체 표시.
// 빈 상태·AI 탭·TOP10 렌더는 RankingBoard 위젯이 일괄 처리(매치 페이지와 동일 UI). // ?league= 쿼리에 따라 해당 리그 경기만 집계(기본 KBO).
export default function Leaderboard() { export default function Leaderboard() {
const lang = useLang(); const lang = useLang();
const [league] = useLeague();
return ( return (
<main className="shell"> <main className="shell">
@ -19,7 +21,7 @@ export default function Leaderboard() {
: "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."} : "매 경기 누적 점수로 순위를 매기고, 끝까지 가장 잘 맞힌 한 분께 최종 100만원을 드립니다."}
</p> </p>
{/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */} {/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */}
<RankingBoard lang={lang} defaultOpen /> <RankingBoard lang={lang} defaultOpen league={league} />
</section> </section>
<Footer lang={lang} /> <Footer lang={lang} />
</main> </main>

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import Hero from "@/components/Hero"; import Hero from "@/components/Hero";
import MatchupHUD from "@/components/MatchupHUD"; import MatchupHUD from "@/components/MatchupHUD";
import LiveField from "@/components/LiveField";
import Arena from "@/components/Arena"; import Arena from "@/components/Arena";
import Comments from "@/components/Comments"; import Comments from "@/components/Comments";
import Footer from "@/components/Footer"; import Footer from "@/components/Footer";
@ -69,6 +70,7 @@ export default function MatchDetail() {
<Hero <Hero
back back
lang={lang} lang={lang}
league={match.league}
share={{ share={{
url, url,
title: `${leftShort} vs ${rightShort} — TriplePick`, title: `${leftShort} vs ${rightShort} — TriplePick`,
@ -87,6 +89,7 @@ export default function MatchDetail() {
</h1> </h1>
<MatchupHUD match={match} lang={lang} /> <MatchupHUD match={match} lang={lang} />
<LiveField match={match} lang={lang} />
<Arena <Arena
match={match} match={match}
predictions={match.predictions} predictions={match.predictions}