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
|
|
@ -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
|
||||
|
||||
# ── 결과 자동 정산(관리자 입력 불필요) ───────────────────
|
||||
# 킥오프 + 이 시간(시) 경과 후, 외부 소스에서 스코어를 자동 수집해 채점·집계·메일.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 만 여기로 보낸다.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
q = (
|
||||
select(Match)
|
||||
.where(Match.result_outcome.is_not(None))
|
||||
.options(selectinload(Match.predictions))
|
||||
)
|
||||
).scalars().all()
|
||||
if league:
|
||||
q = q.where(Match.league == league)
|
||||
matches = (await db.execute(q)).scalars().all()
|
||||
|
||||
# model → [points, exact_count, matches_played]
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
q = (
|
||||
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]
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
"===",
|
||||
])
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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,10 +132,17 @@ 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(이름만).
|
||||
# 실데이터 블록 — 리그별 소스(축구=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,
|
||||
|
|
@ -106,6 +150,7 @@ async def generate_ai_predictions(only_missing: bool = True) -> None:
|
|||
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,6 +178,8 @@ 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"])
|
||||
# 경기 단위 커밋 — 수백 콜 도중 재시작/오류가 나도 진행분을 잃지
|
||||
# 않는다(전체 커밋이면 중단 시 API 비용을 다시 지출하게 됨).
|
||||
await db.commit()
|
||||
|
||||
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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콜/분(캐시 상한)
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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마리) — 모든 경기 노출 */}
|
||||
{/* 파도타기 마스코트 — 축구 전용 (야구는 미노출) */}
|
||||
{!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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,22 +134,47 @@ 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 === "team"
|
||||
? lang === "en"
|
||||
? "By team"
|
||||
: "팀별"
|
||||
: k === "rank"
|
||||
? lang === "en"
|
||||
? "Standings"
|
||||
: "순위"
|
||||
: k === "group"
|
||||
? lang === "en"
|
||||
? "Groups"
|
||||
|
|
@ -125,6 +183,10 @@ export default function ScheduleBoard({
|
|||
? "Tournament"
|
||||
: "토너먼트";
|
||||
|
||||
const leagueTabs: Tab[] = isBaseball
|
||||
? ["date", "team", "rank"]
|
||||
: ["date", "group", "tournament"];
|
||||
|
||||
return (
|
||||
<section className="mt-6">
|
||||
<div className="mb-3 flex items-baseline gap-2.5">
|
||||
|
|
@ -132,14 +194,14 @@ export default function ScheduleBoard({
|
|||
<span className="whitespace-nowrap text-[12px] text-white/55">{t.schedGuide}</span>
|
||||
</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,15 +211,28 @@ 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"
|
||||
: effTab === "team"
|
||||
? teams.map((tm) => (
|
||||
<Chip key={tm.code} active={tm.code === selTeam} onClick={() => setSelTeam(tm.code)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<TeamFlag team={tm} className="h-4 w-4 shrink-0" />
|
||||
{teamShort(tm, lang)}
|
||||
</span>
|
||||
</Chip>
|
||||
))
|
||||
: effTab === "group"
|
||||
? groups.map((g) => (
|
||||
<Chip key={g} active={g === selGroup} onClick={() => setSelGroup(g)}>
|
||||
{lang === "en" ? `Group ${g}` : `${g}조`}
|
||||
|
|
@ -169,12 +244,14 @@ export default function ScheduleBoard({
|
|||
</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,27 +379,80 @@ function Chip({
|
|||
);
|
||||
}
|
||||
|
||||
function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
|
||||
// 팀명 옆 순위 배지 (야구 — 순위 캐시 없으면 렌더 안 함)
|
||||
function RankBadge({ rank, lang = "ko" }: { rank?: number | null; lang?: Lang }) {
|
||||
if (!rank) return null;
|
||||
return (
|
||||
<span className="shrink-0 rounded border border-white/15 px-1 py-px text-[10px] font-bold text-white/45">
|
||||
{lang === "en" ? `#${rank}` : `${rank}위`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchCard({
|
||||
match,
|
||||
lang,
|
||||
showDate = false,
|
||||
}: {
|
||||
match: Match;
|
||||
lang: Lang;
|
||||
showDate?: boolean;
|
||||
}) {
|
||||
const t = dict(lang);
|
||||
const 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">
|
||||
{!isBaseball && (
|
||||
<span className="text-[11px] font-bold text-white/45">
|
||||
{match.group
|
||||
? lang === "en"
|
||||
|
|
@ -329,16 +460,18 @@ function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
|
|||
: `${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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||