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 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_generate_hour: int = 0
ai_generate_minute: int = 5
# 투표 오픈(D-2) 선행 생성 시간(h). 1일 1회 생성이므로 다음 주기 전 오픈할
# 경기를 미리 채우려면 ~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:
"""투표/경기 단계:
cancelled 우천취소 취소 확정 "취소" (투표·정산 제외, 기록은 보존)
finished 결과 입력됨 "종료"
live 킥오프 이후, 결과 입력 "경기중"
locked 투표 마감(킥오프 1h ) ~ 킥오프 "투표 종료"(비활성)
@ -49,6 +50,8 @@ def compute_phase(m: Match, now: datetime | None = None) -> str:
open 투표
"""
now = now or now_utc()
if m.status == "cancelled": # 우천취소 등 — 시간과 무관하게 고정
return "cancelled"
if m.result_outcome is not None:
return "finished"
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()
if not is_votable(m):
return False
if m.status == "cancelled":
return False
if m.result_outcome is not None:
return False
if now >= ensure_aware(m.lock_at):
@ -146,6 +151,7 @@ def match_out(
lang: str = "ko",
include_predictions: bool = True,
now: datetime | None = None,
extras: dict | None = None,
) -> MatchOut:
result = None
if m.result_outcome is not None:
@ -164,6 +170,7 @@ def match_out(
phase = compute_phase(m, now)
return MatchOut(
matchId=m.match_id,
league=m.league or "wc",
roundLabel=m.round_label,
group=m.group,
teamA=_team_a(m),
@ -180,4 +187,5 @@ def match_out(
result=result,
predictions=preds,
crowd=crowd_out(m.crowd, m.match_id),
extras=extras,
)

View File

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

View File

@ -35,6 +35,8 @@ class Match(Base):
__tablename__ = "matches"
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="")
group: Mapped[str] = mapped_column(String, default="A")
@ -246,3 +248,20 @@ class FootballCache(Base):
fetched_at: Mapped[datetime] = mapped_column(
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
# 닉네임 조합: 2글자(코믹 수식) + 3글자(축구 역할) = 항상 한국어 5글자.
# 40 × 30 = 1200가지.
# 닉네임 조합: 2글자(코믹 수식) + 3글자(스포츠 역할) = 항상 한국어 5글자.
# 리그별 풀 — 축구(월드컵) / 야구(KBO·MLB). 각 40 × 30 = 1200가지.
_NICK_A = [
"잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕",
"국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재",
@ -42,6 +42,18 @@ _NICK_B = [
"압박왕", "태클왕", "중거리", "발리슛", "빌드업", "백패스", "자책골", "골기계",
"어시왕", "역습왕", "수문장", "골부자", "골가뭄", "발연기",
]
_NICK_A_BB = [
"잔디", "벤치", "후보", "똥손", "노룩", "멘붕", "동네", "주말", "폭발", "광속",
"진지", "번개", "백수", "천재", "괴물", "폭격", "강철", "무적", "질풍", "돌풍",
"불꽃", "분노", "음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면",
"치킨", "출근", "직관", "치맥", "응원", "국대", "신인", "은퇴", "각성", "꾸준",
]
_NICK_B_BB = [
"홈런왕", "도루왕", "타격왕", "안타왕", "삼진왕", "수비왕", "번트왕", "대타왕",
"역전타", "끝내기", "결승타", "병살타", "유격수", "외야수", "내야수", "마무리",
"셋업맨", "불펜왕", "승리조", "강속구", "커브왕", "직구왕", "변화구", "풀스윙",
"헛스윙", "배트맨", "만루왕", "출루왕", "도루자", "홈스틸",
]
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()
_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:
"""랜덤 닉네임. 두 단어 조합으로 항상 5글자."""
def _is_baseball(match_id: str) -> bool:
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)
@ -67,7 +86,7 @@ def _valid_nickname(n: str) -> bool:
@router.get("/{match_id}/comments/nickname", response_model=CommentNicknameOut)
async def issue_nickname(match_id: str) -> CommentNicknameOut:
"""세션 시작 시 랜덤 닉 발급(클라가 sessionStorage 에 캐싱해 재사용)."""
return CommentNicknameOut(nickname=_random_nickname())
return CommentNicknameOut(nickname=_random_nickname(_is_baseball(match_id)))
def _out(c: Comment) -> CommentOut:
@ -130,7 +149,11 @@ async def create_comment(
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(
match_id=match_id,
author_hash=author_hash,

View File

@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..models import Match, UserPoints
from ..models import Match, UserPoints, UserPrediction
from ..scoring import is_excluded, score_prediction
from ..schemas import (
AILeaderboardOut,
@ -32,8 +32,52 @@ def _mask(email: str) -> str:
@router.get("", response_model=LeaderboardOut)
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:
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 = (
await db.execute(select(UserPoints))
).scalars().all()
@ -71,25 +115,32 @@ async def leaderboard(
@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 예측을 유저와 동일한 배점으로 채점·합산.
별도 누적 테이블 없이 조회 종료 경기 전수 재계산 결과/배점 변동에
항상 일관. (모델 3 × 경기 수라 비용 무시 가능.)
"""
matches = (
await db.execute(
select(Match)
.where(Match.result_outcome.is_not(None))
.options(selectinload(Match.predictions))
)
).scalars().all()
q = (
select(Match)
.where(Match.result_outcome.is_not(None))
.options(selectinload(Match.predictions))
)
if league:
q = q.where(Match.league == league)
matches = (await db.execute(q)).scalars().all()
# model → [points, exact_count, matches_played]
agg: dict[str, list[int]] = {}
for m in matches:
bb = m.league in ("kbo", "mlb")
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[0] += pts
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 ..models import Match
from ..schemas import MatchOut
from ..services.baseball_details import fetch_live, get_extras
router = APIRouter(prefix="/api/matches", tags=["matches"])
@ -17,16 +18,19 @@ router = APIRouter(prefix="/api/matches", tags=["matches"])
@router.get("", response_model=list[MatchOut])
async def list_matches(
lang: str = Query("ko"),
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체"),
db: AsyncSession = Depends(get_db),
) -> list[MatchOut]:
rows = (
await db.execute(
select(Match)
.options(selectinload(Match.predictions), selectinload(Match.crowd))
.order_by(Match.kickoff_at)
)
).scalars().all()
return [match_out(m, lang) for m in rows]
q = (
select(Match)
.options(selectinload(Match.predictions), selectinload(Match.crowd))
.order_by(Match.kickoff_at)
)
if league:
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)
@ -44,4 +48,19 @@ async def get_match(
).scalars().first()
if not m:
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):
matchId: str
league: str = "wc" # wc | kbo | mlb
roundLabel: str
group: str
teamA: Team
@ -63,6 +64,8 @@ class MatchOut(BaseModel):
result: MatchResult | None = None
predictions: list[AIPredictionOut] = Field(default_factory=list)
crowd: CrowdStatsOut | None = None
# 야구 부가정보 (프리뷰·순위 캐시 — 축구/캐시없음이면 None)
extras: dict | None = None
# ── 픽 제출 ─────────────────────────────────────────────────
@ -70,8 +73,8 @@ class SubmitPredictionIn(BaseModel):
matchId: str
deviceId: str = Field(min_length=8, max_length=64)
outcome: Outcome
scoreA: int = Field(ge=0, le=9)
scoreB: int = Field(ge=0, le=9)
scoreA: int = Field(ge=0, le=25) # 야구(득점) 상한. 축구는 UI 가 0-9 로 제한.
scoreB: int = Field(ge=0, le=25)
email: EmailStr | None = None
notify: bool = False

View File

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

View File

@ -26,11 +26,12 @@ class ProviderUnavailable(RuntimeError):
@dataclass
class MatchContext:
team_a: str # 표시명 (예: Korea Republic)
team_a: str # 표시명 (예: Korea Republic / 한화 이글스)
team_b: str
venue: str
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:
# 실데이터 블록이 있으면 팀/킥오프 다음에 삽입(없으면 빈 문자열 — 기존 동작 동일).
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 공통)
_SCHEMA = {
"type": "object",
@ -96,9 +159,9 @@ _SCHEMA = {
}
def _normalize(data: dict) -> dict:
a = max(0, min(9, int(data["scoreA"])))
b = max(0, min(9, int(data["scoreB"])))
def _normalize(data: dict, max_score: int = 9) -> dict:
a = max(0, min(max_score, int(data["scoreA"])))
b = max(0, min(max_score, int(data["scoreB"])))
# outcome 은 스코어와 일관되도록 서버에서 재도출 (모델 불일치 방지)
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))))
@ -123,12 +186,12 @@ async def predict_gpt(ctx: MatchContext) -> dict:
model=settings.openai_model,
messages=[
{"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"},
)
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) ──────────────────────────────────────
@ -143,7 +206,7 @@ async def predict_claude(ctx: MatchContext) -> dict:
return await client.messages.create(
model=settings.anthropic_model,
max_tokens=1024,
messages=[{"role": "user", "content": _prompt(ctx, PERSONA["Claude"])}],
messages=[{"role": "user", "content": _build_prompt(ctx, "Claude")}],
**extra,
)
@ -157,7 +220,7 @@ async def predict_claude(ctx: MatchContext) -> dict:
except TypeError:
msg = await _create()
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) ─────────────────────────────────────────
@ -170,10 +233,10 @@ async def predict_gemini(ctx: MatchContext) -> dict:
client = genai.Client(api_key=settings.google_api_key)
resp = await client.aio.models.generate_content(
model=settings.google_model,
contents=_prompt(ctx, PERSONA["Gemini"]),
contents=_build_prompt(ctx, "Gemini"),
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 = {

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()
baseball = match.league in ("kbo", "mlb")
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.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}
for pick, match in rows:
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["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 .models import AIPrediction, Match, UserPrediction
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.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.grading import apply_result
from .services.schedule_fetch import fetch_results, fetch_schedule
@ -43,6 +45,9 @@ log = logging.getLogger("triplepick.worker")
# ── 0) 경기 일정 동기화 (외부 크롤링) ──────────────────────
async def sync_schedule_job() -> None:
"""월드컵(축구) 일정 동기화 — 기존 로직 그대로."""
if "wc" not in settings.league_list:
return
records = await fetch_schedule()
if not records:
return
@ -56,6 +61,34 @@ async def sync_schedule_job() -> None:
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) 상태 전이 ────────────────────────────────────────────
async def tick_status() -> None:
async with SessionLocal() as db:
@ -83,11 +116,15 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
생성한다(직전에 실패/누락된 모델만 채워짐 API 비용, 예측 안정).
only_missing=False: 전부 재생성(덮어쓰기) 수동 재생성 스크립트용.
"""
if not settings.ai_enabled:
log.info("AI 예측 생성 스킵 — AI_ENABLED=false (로컬 테스트 모드)")
return
async with SessionLocal() as db:
# 투표 오픈(킥오프 D-2)이 임박/도래한 미종료 경기 — 조 무관.
# lookahead_hours 만큼 선행 생성해 다음 생성 주기 전에 오픈할 경기도 미리 채운다
# (1일 1회 생성 → 오픈 시점에 AI 가 비어 보이지 않도록). 더 먼 미래 경기는 제외.
horizon = now_utc() + timedelta(hours=settings.ai_generate_lookahead_hours)
# 킥오프가 lookahead(기본 22h) 이내로 다가온 미시작 경기만 생성.
# 야구 선발투수 예고(전날 저녁)가 나온 뒤 시점이라 데이터 품질이 좋다.
# 킥오프가 지난 경기는 제외(경기 중 생성 = 사후 예측 방지). 취소 경기 제외.
now = now_utc()
horizon = now + timedelta(hours=settings.ai_generate_lookahead_hours)
rows = (
await db.execute(
select(Match)
@ -95,17 +132,25 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
.options(selectinload(Match.predictions))
)
).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:
# 실데이터 블록(폼·H2H·랭킹·WC결과) — 캐시만 읽음. 없으면 None(이름만).
data_block = await football_data.build_data_block(db, m)
# 실데이터 블록 — 리그별 소스(축구=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)
ctx = MatchContext(
team_a=m.team_a_name,
team_b=m.team_b_name,
venue=m.venue,
kickoff=m.kickoff_at.isoformat(),
data_block=data_block,
league=m.league,
)
for model, fn in PROVIDERS.items():
pred = next((p for p in m.predictions if p.model == model), None)
@ -133,7 +178,9 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
pred.generated_at = now_utc()
pred.source = "llm"
log.info("AI %s%s %d-%d", model, m.match_id, data["scoreA"], data["scoreB"])
await db.commit()
# 경기 단위 커밋 — 수백 콜 도중 재시작/오류가 나도 진행분을 잃지
# 않는다(전체 커밋이면 중단 시 API 비용을 다시 지출하게 됨).
await db.commit()
# ── 2.1) 축구 데이터 수집 (예측 생성 전에 캐시를 채워둔다) ──
@ -149,7 +196,11 @@ async def refresh_football_data() -> None:
return
async with SessionLocal() as db:
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()
# 가까운 경기부터 우선 수집(하루 예산 소진 시 먼 경기 팀은 다음 잡에서).
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:
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()
# 킥오프가 지난 경기만 (소스가 FINISHED 줄 수 있는 시점)
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 = (
await db.execute(
select(Match).where(
Match.league == "wc",
Match.result_outcome.is_not(None),
Match.finished_at.is_not(None),
)
@ -257,6 +313,50 @@ async def settle_matches() -> None:
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) 결과 메일 ────────────────────────────────────────────
async def maybe_send_result_emails() -> None:
async with SessionLocal() as db:
@ -350,13 +450,14 @@ def build_scheduler() -> AsyncIOScheduler:
minute=settings.football_refresh_minute,
id="football_refresh",
)
# AI 예측 생성: 매일 KST 00:05 (실 API)
# AI 예측 생성: 매시간 — 킥오프 22h 전에 든 경기를 놓치지 않게.
# only_missing 이라 이미 생성된 (경기×모델)은 API 호출 없이 건너뜀(비용 동일).
sched.add_job(
generate_ai_predictions,
"cron",
hour=settings.ai_generate_hour,
minute=settings.ai_generate_minute,
"interval",
hours=1,
id="ai_generate",
next_run_time=now_utc(),
)
# 결과 자동 정산 + 메일: 킥오프+Nh 지난 경기 스코어 수집·채점·발송
sched.add_job(
@ -366,29 +467,41 @@ def build_scheduler() -> AsyncIOScheduler:
id="settle",
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
async def main() -> None:
load_scoring_data() # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용)
await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당.
# 기동 시 1회: 일정 동기화 → AI 예측 생성 → 밀린 경기 자동 정산
# 기동 시 1회: 일정 동기화(축구+야구) → AI 예측 생성 → 밀린 경기 자동 정산
await sync_schedule_job()
await sync_baseball_job()
await refresh_football_data() # 축구 데이터 캐시 선채움 (키 없으면 no-op)
await generate_ai_predictions()
await settle_matches()
await settle_baseball()
sched = build_scheduler()
sched.start()
log.info(
"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_minute,
settings.status_tick_seconds,
settings.ai_generate_hour,
settings.ai_generate_minute,
settings.timezone,
settings.ai_generate_lookahead_hours,
settings.settle_tick_seconds,
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점)**.
- **배점 수치는 황 본부장 확정 후 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)
2) else if pick.outcome == result.outcome:
(pick.scoreA - pick.scoreB) == (result.scoreA - result.scoreB) ? 근접(3) : 승패(2)
3) else if pick.scoreA == result.scoreA || pick.scoreB == result.scoreB → 부분(1)
|득실차 오차| <= tol ? 근접(3) : 승패(2)
3) else if |pick.scoreA - result.scoreA| <= tol || |pick.scoreB - result.scoreB| <= tol → 부분(1)
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 [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 [scoreB, setScoreB] = useState(0);
const [step, setStep] = useState<Step>("form");
@ -227,7 +230,8 @@ export default function Arena({
{/* ===== 레이어드 화이트 시트 (002) ===== */}
<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">
<TalkingMascot
src="/assets/mascots/lion_point.webp"
@ -236,10 +240,18 @@ export default function Arena({
draggable
/>
</div>
)}
<div className="mb-3">
<h2 className="text-[22px] font-extrabold">{t.aiBattle}</h2>
</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">
{predictions.map((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">
{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>
<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>
<Stepper label={bShort} value={scoreB} onChange={setScoreB} disabled={disabled} />
<Stepper label={bShort} value={scoreB} onChange={setScoreB} disabled={disabled} max={maxScore} />
</>
)}
</div>
@ -378,14 +390,14 @@ export default function Arena({
)}
{/* 투표하기 버튼 — 모든 경기에서 양옆에 캐릭터(자동 말풍선) */}
<div className="mt-3.5 flex items-end justify-center gap-1">
<TalkingMascot
{!isBaseball && <TalkingMascot
src="/assets/mascots/dog_point.webp"
lines={DOG_LINES}
className="h-16 w-auto select-none sm:h-20"
auto
autoDelayMs={1200}
autoIntervalMs={6000}
/>
/>}
<button
onClick={confirmSubmit}
disabled={!emailValid || submitting}
@ -393,14 +405,14 @@ export default function Arena({
>
{submitting ? "…" : <>{t.submit} <span aria-hidden></span></>}
</button>
<TalkingMascot
{!isBaseball && <TalkingMascot
src="/assets/mascots/tiger_point.webp"
lines={TIGER_LINES}
className="h-16 w-auto select-none sm:h-20"
auto
autoDelayMs={4200}
autoIntervalMs={6000}
/>
/>}
</div>
</div>
)}
@ -554,8 +566,8 @@ export default function Arena({
</section>
)}
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */}
<RankingBoard lang={lang} />
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 현재 리그 기준 집계 ===== */}
<RankingBoard lang={lang} league={match.league} />
{/* ===== 골드 상금 (003) ===== */}
<section className="gold-card mt-5 rounded-2xl p-5">
@ -599,9 +611,9 @@ export default function Arena({
{(
[
[t.rulesExact, 5],
[t.rulesCloseGrade, 3],
[isBaseball ? t.rulesCloseGradeBB : t.rulesCloseGrade, 3],
[t.rulesOutcome, 2],
[t.rulesPartial, 1],
[isBaseball ? t.rulesPartialBB : t.rulesPartial, 1],
[t.rulesMiss, 0],
] as [string, number][]
).map(([label, pt]) => (
@ -670,11 +682,13 @@ function Stepper({
value,
onChange,
disabled,
max = 9,
}: {
label: string;
value: number;
onChange: (n: number) => void;
disabled?: boolean;
max?: number;
}) {
return (
<div className="flex items-center gap-3">
@ -687,7 +701,7 @@ function Stepper({
</span>
<span className="mt-1 text-[11px] text-[var(--ink-muted)]">{label}</span>
</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>
</div>

View File

@ -2,6 +2,7 @@ import { Link, useNavigate } from "react-router-dom";
import ShareButton from "./ShareButton";
import LangSwitch from "./LangSwitch";
import { type Lang, dict } from "@/lib/i18n";
import type { League } from "@/lib/types";
type Share = { url: string; title: string; text: string };
@ -10,10 +11,12 @@ export default function Hero({
back = false,
share,
lang = "ko",
league = "wc",
}: {
back?: boolean;
share?: Share;
lang?: Lang;
league?: League;
}) {
const t = dict(lang);
const navigate = useNavigate();
@ -54,8 +57,55 @@ export default function Hero({
</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">
{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>
<LeagueTabs lang={lang} current={league} />
</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 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 }) {
const t = dict(lang);
const { group } = match;
const finished = !!match.result;
// 한국을 항상 왼쪽에 표시(요청). 데이터/투표는 원본 A/B 프레임 유지, 화면 좌우만 교체.
const flip = match.teamB.code === "KOR" && match.teamA.code !== "KOR";
const isBaseball = match.league === "kbo" || match.league === "mlb";
// 축구: 한국을 항상 왼쪽에. 야구: A=원정(좌), B=홈(우) 고정.
const flip = !isBaseball && match.teamB.code === "KOR" && match.teamA.code !== "KOR";
const left = flip ? match.teamB : match.teamA;
const right = flip ? match.teamA : match.teamB;
const leftScore = flip ? match.result?.scoreB : match.result?.scoreA;
const rightScore = flip ? match.result?.scoreA : match.result?.scoreB;
// 마스코트(파도타기 등)는 모든 경기에서 노출.
const 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 (
<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"
style={{ boxShadow: "0 0 28px rgba(74,255,160,0.35), inset 0 0 24px rgba(74,255,160,0.06)" }}
>
{/* 박스 오른쪽 상단 파도타기 (응원 5마리) — 모든 경기 노출 */}
<div className="absolute right-2 top-2 z-10">
<WaveStrip />
</div>
{/* 파도타기 마스코트 — 축구 전용 (야구는 미노출) */}
{!isBaseball && (
<div className="absolute right-2 top-2 z-10">
<WaveStrip />
</div>
)}
{/* 헤더: 브랜드 아이콘 + 팀명 + 일시 */}
<div className="flex items-center gap-3">
@ -38,15 +62,22 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</div>
<div className="mt-1 text-[15px] font-bold text-[var(--green)]">
{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 className="mt-0.5 text-[12px] text-white/65">{match.venue}</div>
</div>
</div>
{/* 국기 + 라이트닝 VS (종료 시 최종 스코어) */}
{/* 국기/로고 + 라이트닝 VS (종료 시 최종 스코어) */}
<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="vs-glow absolute h-28 w-28" />
{finished && match.result ? (
@ -64,9 +95,78 @@ export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?
</span>
)}
</div>
<TeamFlag team={right} className="mx-auto h-[68px] w-[104px]" />
<TeamFlag team={right} className={flagCls} />
</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 && (
<div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]">
{t.matchEnded}

View File

@ -9,9 +9,11 @@ import type { RankingEntry, RankingTab } from "@/lib/types";
export default function RankingBoard({
lang = "ko",
defaultOpen = false,
league = "",
}: {
lang?: Lang;
defaultOpen?: boolean;
league?: string; // 리그별 집계 ("" = 전체 통합)
}) {
const t = dict(lang);
const [open, setOpen] = useState(defaultOpen);
@ -24,7 +26,7 @@ export default function RankingBoard({
if (!open) return;
let alive = true;
setLoaded(false);
fetchRanking(tab)
fetchRanking(tab, league)
.then((r) => {
if (!alive) return;
setRows(r);
@ -38,7 +40,7 @@ export default function RankingBoard({
return () => {
alive = false;
};
}, [open, tab]);
}, [open, tab, league]);
return (
<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 { Link } from "react-router-dom";
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 { Match, MatchPhase } from "@/lib/types";
import type { League, Match, MatchPhase } from "@/lib/types";
import TeamFlag from "./TeamFlag";
import StandingsTable from "./StandingsTable";
// 칩용 짧은 날짜: "6.12 (금)" / "Jun 12 (Fri)"
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",
live: "border-[#FF5B5B] text-[#FF5B5B]",
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 isGroupLetter = (s: string) => /^[A-Z]$/.test(s);
type Tab = "date" | "group" | "tournament";
type Tab = "date" | "group" | "tournament" | "team" | "rank";
export default function ScheduleBoard({
matches,
lang = "ko",
league = "wc",
}: {
matches: Match[];
lang?: Lang;
league?: League;
}) {
const t = dict(lang);
// 야구(KBO/MLB)는 조/토너먼트 대신 날짜별/팀별 탭 제공
const isBaseball = league !== "wc";
// 날짜 목록 (정렬)
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");
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";
});
// 리그에 없는 탭이 저장돼 있으면 날짜별로 강등 (야구↔축구 전환 시)
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조 / 현재 진행 라운드.
// 단, 직전 세션 선택을 sessionStorage 에 보관 → 상세 진입 후 뒤로가기 시 그대로 복원(오늘로 초기화 X).
@ -87,6 +117,9 @@ export default function ScheduleBoard({
if (saved && rounds.includes(saved)) return saved;
return currentRound;
});
const [selTeam, setSelTeam] = useState(
() => sessionStorage.getItem("tp.sched.team") ?? "",
);
// 선택 변경 시 보관(다음 진입/뒤로가기에서 복원)
useEffect(() => {
@ -101,29 +134,58 @@ export default function ScheduleBoard({
useEffect(() => {
sessionStorage.setItem("tp.sched.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(() => {
let list: Match[];
if (tab === "date") list = matches.filter((m) => dateKey(m.kickoffKst) === selDate);
else if (tab === "group") list = matches.filter((m) => !!m.group && m.group === selGroup);
if (effTab === "date") list = matches.filter((m) => dateKey(m.kickoffKst) === selDate);
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); // 토너먼트
return [...list].sort(
(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) =>
k === "date"
? lang === "en"
? "By date"
: "날짜별"
: k === "group"
: k === "team"
? lang === "en"
? "Groups"
: "조별리그"
: lang === "en"
? "Tournament"
: "토너먼트";
? "By team"
: "팀별"
: k === "rank"
? lang === "en"
? "Standings"
: "순위"
: k === "group"
? lang === "en"
? "Groups"
: "조별리그"
: lang === "en"
? "Tournament"
: "토너먼트";
const leagueTabs: Tab[] = isBaseball
? ["date", "team", "rank"]
: ["date", "group", "tournament"];
return (
<section className="mt-6">
@ -132,14 +194,14 @@ export default function ScheduleBoard({
<span className="whitespace-nowrap text-[12px] text-white/55">{t.schedGuide}</span>
</div>
{/* 탭: 날짜별 / 조별리그 / 토너먼트 */}
{/* 탭: 축구 = 날짜별/조별리그/토너먼트 · 야구 = 날짜별/팀별 */}
<div className="mb-3 flex gap-5 border-b border-[var(--line-d)]">
{(["date", "group", "tournament"] as Tab[]).map((k) => (
{leagueTabs.map((k) => (
<button
key={k}
onClick={() => setTab(k)}
className={`-mb-px border-b-2 pb-2 text-[15px] font-bold transition ${
tab === k
effTab === k
? "border-[var(--green)] text-white"
: "border-transparent text-white/45"
}`}
@ -149,32 +211,47 @@ export default function ScheduleBoard({
))}
</div>
{/* 순위 탭: 칩/카드 대신 순위표 */}
{effTab === "rank" && <StandingsTable league={league} lang={lang} />}
{/* 칩: 날짜 / 조 / 라운드 — 단일 줄 가로 스크롤 + 양 끝 화살표(PC에서 마우스로 넘김, 끝 도달 시 숨김) */}
<ChipScroller resetKey={tab}>
{tab === "date"
{effTab !== "rank" && (
<ChipScroller resetKey={`${league}:${effTab}`}>
{effTab === "date"
? dates.map((d) => (
<Chip key={d} active={d === selDate} onClick={() => setSelDate(d)}>
{shortDate(d, lang)}
</Chip>
))
: tab === "group"
? groups.map((g) => (
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
{lang === "en" ? `Group ${g}` : `${g}`}
: 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>
))
: rounds.map((r) => (
<Chip key={r} active={r === selRound} onClick={() => setSelRound(r)}>
{tRound(r, lang)}
</Chip>
))}
: effTab === "group"
? groups.map((g) => (
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
{lang === "en" ? `Group ${g}` : `${g}`}
</Chip>
))
: rounds.map((r) => (
<Chip key={r} active={r === selRound} onClick={() => setSelRound(r)}>
{tRound(r, lang)}
</Chip>
))}
</ChipScroller>
)}
{/* 경기 카드 */}
{effTab !== "rank" && (
<div className="flex flex-col gap-2.5">
{shown.length === 0 && (
<p className="py-6 text-center text-[13px] text-white/45">
{tab === "tournament"
{effTab === "tournament"
? lang === "en"
? "Bracket is set after the group stage."
: "토너먼트 대진은 조별리그 종료 후 확정됩니다"
@ -184,9 +261,10 @@ export default function ScheduleBoard({
</p>
)}
{shown.map((m) => (
<MatchCard key={m.matchId} match={m} lang={lang} />
<MatchCard key={m.matchId} match={m} lang={lang} showDate={effTab === "team"} />
))}
</div>
)}
</section>
);
}
@ -301,44 +379,99 @@ 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 phase = match.phase;
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 유지, 화면 좌우만 교체.
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 right = flip ? match.teamA : match.teamB;
const leftScore = flip ? match.result?.scoreB : match.result?.scoreA;
const rightScore = flip ? match.result?.scoreA : match.result?.scoreB;
const flagCls = isBaseball ? "h-7 w-7 shrink-0" : "h-6 w-9 shrink-0";
// 비투표(타 조)는 클릭 비활성, 투표 가능 조(A)는 상세로 이동
const inner = (
<>
<div className="flex items-center justify-between text-[11px]">
<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>
{/* 조별리그 seed 의 부가 라벨(예: 개막전)만 여기에. 녹아웃 라운드는 우측에 표시. */}
{match.group && match.roundLabel ? <> · {tRound(match.roundLabel, lang)}</> : null}
{isBaseball && match.venue ? (
<span className="text-white/40"> · {match.venue}</span>
) : match.group && match.roundLabel ? (
<> · {tRound(match.roundLabel, lang)}</>
) : null}
</span>
<span className="flex items-center gap-1.5">
<span className="text-[11px] font-bold text-white/45">
{match.group
? lang === "en"
? `Group ${match.group}`
: `${match.group}`
: tRound(match.roundLabel, lang)}
</span>
{!isBaseball && (
<span className="text-[11px] font-bold text-white/45">
{match.group
? lang === "en"
? `Group ${match.group}`
: `${match.group}`
: tRound(match.roundLabel, lang)}
</span>
)}
<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>
</div>
<div className="mt-3 grid grid-cols-[1fr_auto_1fr] 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>
{isBaseball && <RankBadge rank={match.extras?.standings?.a?.rank} lang={lang} />}
</div>
{match.result ? (
<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>
{rightScore}
</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>
)}
<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>
<TeamFlag team={right} className="h-6 w-9 shrink-0" />
<TeamFlag team={right} className={flagCls} />
</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]">
<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";
// 취소 경기: 클릭 불가·저채도 표시 전용
if (phase === "cancelled") {
return <div className={`${base} border-[var(--line-d)] opacity-60`}>{inner}</div>;
}
if (match.votable && !finished) {
return (
<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 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({
team,
className = "",
@ -12,6 +13,19 @@ export default function TeamFlag({
className?: string;
}) {
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) {
return (

View File

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

View File

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

View File

@ -2,7 +2,13 @@
export type Outcome = "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN";
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 {
name: string;
@ -36,8 +42,50 @@ export interface CrowdStats {
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 {
matchId: string;
league: League;
roundLabel: string;
group: string;
teamA: Team;
@ -54,6 +102,7 @@ export interface Match {
result?: MatchResult | null;
predictions: AIPrediction[];
crowd?: CrowdStats | null;
extras?: MatchExtras | null;
}
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 { dict } from "@/lib/i18n";
import { useLang } from "@/lib/useLang";
import { useLeague } from "@/lib/useLeague";
import { listMatches } from "@/lib/api";
import type { Match } from "@/lib/types";
// L1. 전체 일정 대시보드 (랜딩 진입)
// L1. 전체 일정 대시보드 (랜딩 진입) — 리그 탭(월드컵/KBO/MLB)별 경기 목록
export default function Dashboard() {
const lang = useLang();
const [league] = useLeague();
const t = dict(lang);
const [matches, setMatches] = useState<Match[] | null>(null);
const [error, setError] = useState(false);
@ -19,17 +21,17 @@ export default function Dashboard() {
let alive = true;
setMatches(null);
setError(false);
listMatches(lang)
listMatches(lang, league)
.then((m) => alive && setMatches(m))
.catch(() => alive && setError(true));
return () => {
alive = false;
};
}, [lang]);
}, [lang, league]);
return (
<main className="shell">
<Hero lang={lang} />
<Hero lang={lang} league={league} />
{/* 기간 안내 + CTA 영역 */}
<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." : "경기 정보를 불러오지 못했습니다."}
</p>
)}
{matches && <ScheduleBoard matches={matches} lang={lang} />}
{matches && <ScheduleBoard matches={matches} lang={lang} league={league} />}
<Ado2Ad lang={lang} />
<Footer lang={lang} />

View File

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

View File

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