diff --git a/backend/app/config.py b/backend/app/config.py index bf2bc06..eb6f758 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -87,9 +87,9 @@ class Settings(BaseSettings): return "football-data" return self.schedule_source.lower() - # ── 멀티리그 (wc=월드컵 축구 · kbo · mlb · mls) ────────── - # 활성 리그 (콤마구분). 야구·MLS 리그는 워커가 각자 소스에서 일정·결과를 동기화. - leagues: str = "wc,kbo,mlb,mls" + # ── 멀티리그 (kbo · mlb · mls) — 월드컵(wc)은 2026-08 종료 ── + # 활성 리그 (콤마구분). 각 리그 워커가 자기 소스에서 일정·결과를 동기화. + leagues: str = "kbo,mlb,mls" # 야구 일정 수집 윈도우 — 오늘 기준 미래 며칠치. # (투표 오픈·AI 예측 생성은 리그 공통: vote_open_hours_before / ai_generate_lookahead_hours) baseball_days_ahead: int = 7 diff --git a/backend/app/routers/share.py b/backend/app/routers/share.py index c967129..3a48ba7 100644 --- a/backend/app/routers/share.py +++ b/backend/app/routers/share.py @@ -140,7 +140,7 @@ async def match_share(match_id: str, db: AsyncSession = Depends(get_db)) -> HTML tag = f"{league_ko} " if league_ko else "" title = f"TriplePick — {tag}{la} vs {lb} AI 승부예측" else: - title = "TriplePick — AI 스포츠 승부예측 (월드컵·KBO·MLB·MLS)" + title = "TriplePick — AI 스포츠 승부예측 (KBO·MLB·MLS)" desc = ( "GPT·Claude·Gemini 3대 AI가 이 경기를 서로 다르게 예측합니다. " "당신의 픽을 찍고 AI와 겨뤄보세요." diff --git a/backend/app/routers/songs.py b/backend/app/routers/songs.py index 842fefa..b7276b9 100644 --- a/backend/app/routers/songs.py +++ b/backend/app/routers/songs.py @@ -10,7 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..config import settings from ..database import get_db -from ..models import Song +from ..models import Match, Song from ..services.songs import poll_generating, start_due_songs from .admin import require_admin @@ -113,6 +113,11 @@ async def song_cover( if len(_cover_cache) > 200: _cover_cache.clear() logo_url = _logo_url(s) + if not logo_url and s.league == "mls": + # MLS 로고는 Match 행의 flag(ESPN PNG URL) 재사용 + m = await db.get(Match, s.match_id) + if m: + logo_url = m.team_a_flag if m.team_a_code == s.team_code else m.team_b_flag logo = await _fetch_bytes(logo_url) if logo_url else None _cover_cache[key] = _compose(logo) return Response( diff --git a/backend/app/services/songs.py b/backend/app/services/songs.py index c70acb8..98570eb 100644 --- a/backend/app/services/songs.py +++ b/backend/app/services/songs.py @@ -30,14 +30,20 @@ KST = timezone(timedelta(hours=9)) MAX_ATTEMPTS = 3 GENERATE_TIMEOUT_MIN = 30 # Suno 작업이 이 시간 넘게 미완이면 실패 처리 -# 곡 스타일 — 전 곡 고정 (야구장 웅장 앤섬 컨셉, 레퍼런스 확정본). -# LLM 은 가사·제목만 쓰고 스타일은 변주하지 않는다. +# 곡 스타일 — 전 곡 고정 (경기장 웅장 앤섬 컨셉, 레퍼런스 확정본). +# LLM 은 가사·제목만 쓰고 스타일은 변주하지 않는다. MLS 만 soccer 로 치환. DEFAULT_STYLE = ( "Korean baseball stadium cheer anthem, powerful brass fanfare, thumping " "drum corps, group chant call-and-response, gang vocals, energetic male " "crowd shouting, 128bpm, live stadium atmosphere" ) + +def style_for(league: str) -> str: + if league == "mls": + return DEFAULT_STYLE.replace("baseball", "soccer") + return DEFAULT_STYLE + # LLM 에게 주는 형식 레퍼런스 (가사 구조·스타일 문구의 톤) _REFERENCE = """[Intro - Brass Fanfare] (두! 산! 베어스!) (두! 산! 베어스!) @@ -95,7 +101,7 @@ async def _yesterday_info(db, m: Match, code: str, name: str) -> tuple[str | Non """(전날 결과 한 줄 + 활약 하이라이트, 시즌타율 맵). 경기 없으면 맵은 빈 dict.""" line, g = await _yesterday_result(db, m, code, name) avg_map: dict = {} - if g is not None: + if g is not None and m.league in ("kbo", "mlb"): try: stats = await ( _mlb_team_game_stats(g, code) @@ -105,10 +111,12 @@ async def _yesterday_info(db, m: Match, code: str, name: str) -> tuple[str | Non except Exception as e: # noqa: BLE001 log.warning("어제 경기 기록 조회 실패 %s: %s", g.match_id, e) stats = None - if stats: - avg_map = stats.get("avg") or {} - if line and stats.get("highlights"): - line += " · 활약: " + ", ".join(stats["highlights"]) + else: + stats = None + if stats: + avg_map = stats.get("avg") or {} + if line and stats.get("highlights"): + line += " · 활약: " + ", ".join(stats["highlights"]) return line, avg_map @@ -193,10 +201,15 @@ async def _series_line(db, m: Match) -> str | None: async def fetch_lineups(m: Match) -> dict | None: - """오늘 선발 라인업 조회 (발표 전이면 announced=False). KBO=네이버, MLB=공식 API.""" + """오늘 선발 라인업 조회 (발표 전이면 announced=False). KBO=네이버, MLB=공식 API. + + MLS 는 라인업 소스 미연동 — None (라인업 없이 생성). + """ if m.league == "mlb": return await _fetch_lineups_mlb(m) - return await _fetch_lineups_kbo(m) + if m.league == "kbo": + return await _fetch_lineups_kbo(m) + return None async def _fetch_lineups_kbo(m: Match) -> dict | None: @@ -434,7 +447,11 @@ async def build_context( # ── LLM 작사 ─────────────────────────────────────────────────── -_LEAGUE_LABEL = {"kbo": "한국 프로야구(KBO)", "mlb": "메이저리그(MLB)"} +_LEAGUE_LABEL = { + "kbo": "한국 프로야구(KBO)", + "mlb": "메이저리그(MLB)", + "mls": "미국 프로축구(MLS)", +} def _lyrics_prompt(team_name: str, context: str, league: str = "kbo") -> str: @@ -442,12 +459,12 @@ def _lyrics_prompt(team_name: str, context: str, league: str = "kbo") -> str: f"너는 {_LEAGUE_LABEL.get(league, '프로야구')} 응원가 전문 작사가다. " "아래 오늘 경기 정보를 바탕으로 " f"'{team_name}'의 **오늘의 응원가**를 한국어로 만들어라" - + (" (팀명·선수명은 한국 팬에게 익숙한 표기로)" if league == "mlb" else "") + + (" (팀명·선수명은 한국 팬에게 익숙한 표기로)" if league in ("mlb", "mls") else "") + ".\n\n" f"[오늘 경기 정보]\n{context}\n\n" "[요구사항]\n" - "- 야구장에서 수만 관중이 떼창하는 웅장한 스타디움 앤섬\n" - "- 오늘 경기 맥락(어제 결과 설욕/기세, 순위 싸움, 연전 차수, 선발투수)을 " + "- 경기장에서 수만 관중이 떼창하는 웅장한 스타디움 앤섬\n" + "- 오늘 경기 맥락(어제/최근 결과 설욕·기세, 순위 싸움, 연전 차수, 선발·라인업)을 " "가사에 구체적으로 녹일 것 — 선수 실명 사용 가능\n" "- 괄호로 관중 콜앤리스폰스 파트 표기, 섹션 태그에 연주 지시 포함\n" "- 분량은 1분 30초~2분 (Verse 2개 + Chorus 반복 + Bridge + Final Chorus)\n\n" @@ -496,7 +513,9 @@ async def write_lyrics(team_name: str, context: str, league: str = "kbo") -> dic # ── 생성 시작 · 폴링 ─────────────────────────────────────────── -SONG_LEAGUES = ("kbo", "mlb") # 응원가 대상 리그 (MLS 제외) +SONG_LEAGUES = ("kbo", "mlb", "mls") # 응원가 대상 리그 +# 라인업 발표를 기다리는 리그 — MLS 는 라인업 소스가 없어 윈도우 진입 즉시 생성 +LINEUP_LEAGUES = ("kbo", "mlb") def _song_leagues() -> list[str]: @@ -526,7 +545,7 @@ async def _start_one(db, m: Match, side: str, lineups: dict | None) -> bool: piece = await write_lyrics(name, context, m.league) title = str(piece.get("title") or f"{name} 오늘의 응원가").strip()[:40] - style = DEFAULT_STYLE # 전 곡 고정 스타일 — LLM 변주 없음 + style = style_for(m.league) # 전 곡 고정 스타일 — LLM 변주 없음 lyrics = str(piece.get("lyrics") or "").strip() if not lyrics: raise RuntimeError("LLM 가사 비어있음") @@ -589,8 +608,14 @@ async def start_due_songs(db, force_today: bool = False) -> int: log.warning("lineup 조회 실패 %s: %s", m.match_id, e) lineups = None announced = bool(lineups and lineups.get("announced")) - # 윈도우 내 라인업 대기 — 폴백 시점 전엔 발표될 때까지 생성 보류 - if not force_today and not announced and mins > settings.song_lineup_fallback_minutes_before: + # 윈도우 내 라인업 대기 — 폴백 시점 전엔 발표될 때까지 생성 보류. + # 라인업 소스가 없는 리그(MLS)는 윈도우 진입 즉시 생성. + if ( + not force_today + and m.league in LINEUP_LEAGUES + and not announced + and mins > settings.song_lineup_fallback_minutes_before + ): continue for side in ("a", "b"): try: diff --git a/frontend/index.html b/frontend/index.html index 83de024..418a7dc 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -13,19 +13,19 @@ rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" /> - TriplePick | AI 스포츠 승부예측 — 월드컵·KBO·MLB·MLS, GPT vs Claude vs Gemini + TriplePick | AI 스포츠 승부예측 — KBO·MLB·MLS, GPT vs Claude vs Gemini - + @@ -37,10 +37,10 @@ - + @@ -50,7 +50,7 @@ - + @@ -66,7 +66,7 @@ "alternateName": "트리플픽", "url": "https://triplepick.o2o.kr/", "logo": "https://triplepick.o2o.kr/assets/bi/og-image.png", - "description": "GPT·Claude·Gemini 3대 AI가 같은 경기 데이터로 승패·스코어·승리확률을 예측·비교하는 참여형 스포츠 승부예측 서비스. KBO 프로야구 승부예측·MLB 해외야구 승부예측·MLS·월드컵 지원.", + "description": "GPT·Claude·Gemini 3대 AI가 같은 경기 데이터로 승패·스코어·승리확률을 예측·비교하는 참여형 스포츠 승부예측 서비스. KBO 프로야구 승부예측·MLB 해외야구 승부예측·MLS 지원.", "sameAs": [ "https://www.youtube.com/channel/UCIiFvxaahQA-rLP8KpkDM0w", "https://www.instagram.com/triplepickai" @@ -98,7 +98,7 @@ "operatingSystem": "Web", "inLanguage": "ko-KR", "offers": { "@type": "Offer", "price": "0", "priceCurrency": "KRW" }, - "description": "GPT·Claude·Gemini의 예측을 확인하고 직접 승패와 스코어를 찍어 AI와 겨루는 무료 참여형 스포츠 승부예측 서비스. KBO 프로야구·MLB 해외야구·MLS·월드컵 경기 지원, 실시간 스코어·문자중계 제공." + "description": "GPT·Claude·Gemini의 예측을 확인하고 직접 승패와 스코어를 찍어 AI와 겨루는 무료 참여형 스포츠 승부예측 서비스. KBO 프로야구·MLB 해외야구·MLS 경기 지원, 실시간 스코어·문자중계 제공." } ] } @@ -113,19 +113,19 @@

트리플픽(TriplePick)은 AI승부예측 서비스입니다. GPT·Claude·Gemini 3대 AI가 같은 경기 데이터로 승패·스코어·승리확률을 서로 다르게 예측하고, 당신은 직접 - 스포츠 승부예측 픽을 찍어 AI와 겨룹니다. 2026 글로벌 축구 축전(월드컵)은 물론 - KBO 프로야구·MLB·MLS 전 경기를 대상으로 무료로 참여할 수 있습니다. + 스포츠 승부예측 픽을 찍어 AI와 겨룹니다. KBO 프로야구·MLB·MLS 전 경기를 + 대상으로 무료로 참여할 수 있습니다.

프로야구 승부예측부터 해외야구 승부예측까지

KBO 프로야구 승부예측은 두산·LG·KIA·삼성 등 10개 구단 전 경기를 매일 지원하고, 해외야구 승부예측은 MLB 메이저리그 전 경기를 다룹니다. 야구뿐 - 아니라 해외축구(MLS) 승부예측과 월드컵 승부예측까지 한 곳에서 — 3대 AI의 + 아니라 해외축구(MLS) 승부예측까지 한 곳에서 — 3대 AI의 예측 근거와 승리확률을 비교하고 내 픽의 적중률을 확인해 보세요.

트리플픽은 이런 스포츠 승부예측 서비스입니다