Compare commits

...

116 Commits

Author SHA1 Message Date
bb930cc59f 응원가 공유·플레이어 문구 '오늘의' → 제작일 표기 (예: 8월 28일 OO 응원가)
songs API에 dateKst(경기일) 추가, OG 공유 제목·프론트 shareText·artist에 적용
— 매일 갱신되는 콘텐츠임을 드러낸다

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 10:21:24 +09:00
df367070ce 일정 카드 AI 픽 부등호 확대(24→34px) + 글로우
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 10:20:11 +09:00
fdb04a2ccc 응원가 영구 보존·종료 경기 노출 — 음원 DB 저장 + 경기별 조회 API
- Suno CDN 임시 URL 만료 대비: 완성 곡 첫 트랙을 워커가 다운로드해
  song_audio(bytea)로 보존하고 audioUrl을 /api/songs/audio/{id}/{idx}로 재작성
  (틱당 5곡, 실패 3회 상한 — 기존 곡도 자동 백필)
- GET /api/songs/match/{match_id} 추가 — 날짜 무관 조회로 종료된 경기
  상세에서도 '응원가 듣기' 버튼 노출
- 프론트 getTeamSongTracks를 오늘 전체 캐시 → 경기별 조회·캐시로 전환
- 음원 서빙은 Range(206) 지원, immutable 캐시 + task_id 캐시버스터

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:39:16 +09:00
bc333d64f4 MLB 종료 판정을 detailedState→abstractGameState 기준으로 수정
우천 조기종료(detailedState="Completed Early") 등 "Final" 이외의 종료
변형 문자열은 감지되지 못해 해당 경기가 영원히 정산 안 된 채 live 로
고착되는 버그였다(MLB_PHI_BAL_20260803, 8:0 조기종료 확정임에도 누락).
abstractGameState(Preview/Live/Final 세 값만 존재)로 판정 기준 교체.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:09:05 +09:00
11dd968ced 정산 조회 범위 동적 확장 — 오래 방치된 미정산 경기가 영원히 live로 남는 버그 수정
settle_baseball 이 결과를 조회하는 윈도우(KBO/MLB=result_recheck_days 기본
3일, MLS=mls_days_back 기본 30일)가 고정폭이라, 최초 수집에 실패한 경기가
그 윈도우 밖으로 밀려나면 이후 영원히 매칭 대상에서 빠져 status=live로
고정되는 버그가 있었다(예: MLB_PHI_BAL_20260803, 8/2 이후 3주+ live 고착).

미정산 경기 중 가장 오래된 킥오프 날짜까지 덮도록 조회 범위를 매 틱 동적
계산(최대 120일 소급)하도록 수정.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 14:48:21 +09:00
746eaced80 가사 품질 보강 — 한국어 수사 규칙·전체 분량·캐시 TTL
- 서수 고유어(4번째→네 번째), 고유어 조수사(방·개·명·골), 아웃 카운트
  영어 수사(2아웃→투아웃)
- 가사 축약 방지: 필수 섹션 명시(Verse2·Bridge·Final Chorus), max_tokens
  3000→8000, 450자 미만은 재시도
- 프론트 곡 캐시 하루→5분 TTL (당일 재생성 교체 반영)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:40:45 +09:00
cb00945612 가사 한글화에서 [섹션 태그] 제외 — Suno 구조 인식 보존
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:24:12 +09:00
eb4064279b 가사 표기·발음 분리 — 화면엔 13:1 원문, Suno 전송본만 한글 발음 치환
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:18:20 +09:00
f8c24d1f3c AI 예측 입력 통일·데이터 전면 보강
- 3모델 페르소나 분화 제거 — 동일 프롬프트+동일 데이터 (차이는 모델 판단만)
- 순위 라인에 게임차·팀 타율·팀 ERA 추가
- 전날 결과+활약(홈런·맹타), 확정 라인업+타자 시즌 타율 주입
  (응원가 파이프라인 헬퍼 재사용, 라인업 미발표 시 생략)
- 선발 상대 ERA 0.00(기록 없음) 오표기 생략

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:08:40 +09:00
9e8993c67a 두산 상설 응원가 특례 제거 — 전 팀 동일하게 오늘의 생성곡만
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 10:03:03 +09:00
579ab768bf 가사 숫자 한글화 — Suno 오독 방지
- 프롬프트에 아라비아 숫자 금지 규칙+예시 추가
- 안전망 hangulize_numbers: 한자어 수사(일승·칠십팔승·이차전), 게임·경기는
  고유어(일곱 게임), 스코어 5:3→오 대 삼, 소수 타율 영점삼일이. 제목도 적용

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 10:18:09 +09:00
6770543318 월드컵 서비스 종료 + 응원가 MLS 확장
- 리그 탭에서 월드컵 제거, ?league=wc 는 KBO 강등, 기본 리그 kbo·mlb·mls
- index.html·OG 기본 타이틀에서 월드컵 문구 제거, 월드컵 특설 정적
  플레이리스트 제거
- 응원가 MLS 포함: 라인업 소스 없는 리그는 윈도우 진입 즉시 생성,
  스타일은 soccer 앤섬 변형, 커버 로고는 Match flag(ESPN PNG) 재사용
- MLB 도 한국 팬 표기 지시 적용, 전날 활약 조회는 kbo/mlb 만

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:47:49 +09:00
abcdf42aeb 가사 데이터 보강(활약·타율) + 커버는 팀 로고 100%
- 전날 경기 활약: KBO=네이버 record 타자 boxscore(홈런·3안타 이상),
  MLB=boxscore batting — 어제 결과 줄에 "활약: ..." 로 부가
- 확정 라인업에 타자 시즌 타율 병기 (MLB=seasonStats, KBO=최근 경기 hra 맵)
- 커버 합성에서 Suno 배경 제거 — 다크 단색 배경 + 팀 로고 최대 크기

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:42:55 +09:00
c895bc9c6b 가사 패널 스크롤바 다크 슬림 스타일 (흰색 기본 트랙 제거)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:37:51 +09:00
434f282e6b 응원가 커버를 팀 로고 합성본으로 교체
- /api/songs/cover/:matchId/:teamCode — Suno 앨범아트(감광 배경) 위에
  팀 로고 중앙 합성 (1200x630, OG 규격), 메모리 캐시 + task 캐시버스터
- 로고 소스: KBO=프론트 로컬 PNG(내부 프록시), MLB=mlbstatic spots PNG
- 플레이어 썸네일·/song OG 카드 모두 합성 커버 사용, pillow 의존성 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:26:49 +09:00
dbc26a4150 곡 스타일 전곡 고정 + 팀당 1곡만 노출
- 스타일은 레퍼런스 확정본(brass fanfare·drum corps·128bpm·live stadium)
  으로 고정, LLM 은 제목·가사만 작성
- Suno 생성 2트랙 중 첫 트랙만 API 노출

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:23:51 +09:00
3a5fc36ee8 플레이어에 가사 보기·공유하기 + 응원가 전용 OG 공유 카드
- 하단 MusicBar: 가사 패널 토글(바 위로 펼침)·공유 버튼(네이티브 공유,
  폴백 클립보드) — 응원가 트랙에만 노출
- 공유 링크 /song/:matchId/:teamCode — 크롤러(카톡·페북)는 "오늘의 OO
  응원가" 제목 + Suno 앨범아트 OG 카드, 사람은 경기 페이지로 리다이렉트
  후 그 팀 응원가 자동 로드(?song=)
- nginx /song/ 크롤러 분기 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:20:54 +09:00
dbd08e62c7 응원가를 MLB 로 확장 (MLS 제외)
- MLB 라인업: Stats API schedule→gamePk→boxscore battingOrder (양팀 9명 = 발표)
- 작사 프롬프트 리그 분기 (MLB 는 한국 팬 표기 지시)
- /api/songs/today league 미지정 시 전 리그 반환, 프론트는 전 리그 조회
- 상세 페이지 응원가 버튼 KBO+MLB 노출

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 10:05:27 +09:00
0fc7598d88 응원가 생성을 라인업 발표 이후로 게이트
- 네이버 preview fullLineUp 조회 — 양팀 타자 8명 이상이면 발표로 간주,
  확정 라인업(타순·포지션 실명)을 가사 컨텍스트에 주입
- 생성 윈도우(킥오프 150분 전~) 안에서 발표까지 대기, 킥오프 40분 전까지
  미발표면 라인업 없이 폴백 생성
- 라인업 없이 만든 곡은 발표 후 자동 재생성(업그레이드) — 기존 트랙을
  유지한 채 새 곡 완성 시점에 교체, 실패 시 이전 곡 복귀

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:59:19 +09:00
390e06113d 응원가 재생을 팀별 버튼 방식으로 전환
- 일정/메인 자동 로드 제거 — 상세 페이지 대결 카드의 팀 로고 아래
  "응원가 듣기" 버튼으로 각 팀 곡을 개별 재생 (재생 중 일시정지/이어듣기 토글)
- MusicBar 는 버튼이 실은 플레이리스트도 표시하도록 노출 조건 정리
- 두산 상설 응원가는 두산 버튼 트랙 뒤에 이어붙임

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:53:53 +09:00
277378477a 오늘의 응원가 자동 생성 파이프라인 (Suno)
- 경기 맥락(전날 결과·순위·연전 차수·선발투수) 조립 → LLM 작사
  (콜앤리스폰스·섹션 태그 형식) → Suno(sunoapi.org) 생성 → songs 테이블
- 워커: 킥오프 150분 전 윈도우 진입 시 생성 시작 + 2분 주기 폴링
- API: GET /api/songs/today · POST /api/songs/callback(싱크대)
  · POST /api/songs/generate(관리자 강제 생성)
- 프론트: MusicBar 가 경기 상세·메인에서 오늘의 응원가를 동적 로드
  (정적 플레이리스트는 폴백 유지)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:33:34 +09:00
2a8f2bc1fa 일정 카드 AI 아이콘 확대(24px)·VS를 픽 우세 부등호로
AI 픽이 있는 카드는 VS 대신 우세 방향 부등호(>, <, 팽팽하면 =) 표시.
픽 없는 경기는 기존 VS 유지.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:11:24 +09:00
8da8b7e7bd 일정 카드 AI 픽을 팀 아래 모델 아이콘으로 표시
기존 "AI 픽 삼성 3" 텍스트 줄 대신 GPT/Claude/Gemini 아이콘을
각자 픽한 팀 쪽 아래에 배치 (무승부 픽은 가운데). 참여수는 우측 유지.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 09:07:31 +09:00
d6b3ddeee0 SEO: 타이틀·공유카드 문구 원복 — 검색엔진용 요소만 유지
브라우저 탭 타이틀과 og/twitter 공유 카드는 기존 브랜드 문구로 되돌리고,
확장 키워드(프로야구·해외야구 승부예측)는 description·keywords·
seo-fallback 본문·JSON-LD 에만 유지.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 13:51:24 +09:00
6a9a7115fd SEO: 프로야구·해외야구 승부예측 정확 문구 키워드 반영
"프로야구 승부예측", "해외야구 승부예측", "메이저리그 승부예측" 등
확장 검색어의 정확 문구를 title·description·keywords·seo-fallback
본문·JSON-LD에 자연 문장으로 추가. sitemap lastmod 갱신.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 13:48:29 +09:00
ea4d1ab746 두산 응원가 커버 4장 추가 (gpt-image-1 생성, 640px webp)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:28:27 +09:00
d97023629e 두산 응원가 4곡 추가 — KBO 두산(OB) 전 경기 플레이리스트
경로 정확일치 방식은 경기 날짜마다 등록이 필요해, 두산 경기
(match_id 에 OB 포함)면 공통 플레이리스트를 반환하는 리졸버로 확장.
월드컵 경기별 목록(PATH_PLAYLISTS)은 기존대로 우선 적용.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:21:36 +09:00
22e2e8c29c share OG: 팀명 DB 조회(클럽 리그 한글명) + 문구 갱신 + 썸네일 캐시버스터
- /match/:id OG 프리렌더가 STL/CIN 같은 코드 대신 DB 의 한글 short 팀명을
  사용 (KBO·MLB·MLS 포함). 리그 태그도 제목에 표기
- 설명문을 멀티리그 기준 최신 카피로 갱신
- og:image 에 ?v=2 캐시버스터 — 메신저가 과거 실패/구버전 썸네일을
  캐시한 경우 재수집 유도 (index.html 정적 OG 동일 적용)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 09:42:10 +09:00
9b57d56daf frontend: SEO 멀티리그 전환 — 월드컵 단일 → 월드컵·KBO·MLB·MLS
- index.html: title/description/OG/twitter/keywords/JSON-LD/SEO 폴백 본문을
  "AI 스포츠 승부예측(4개 리그)" 기준으로 갱신. 문자중계·카카오 로그인 언급 추가
- sitemap.xml: 리그별 대시보드 URL(?league=kbo|mlb|mls) 추가, lastmod 갱신
- rss.xml: 채널명·설명 갱신 + 멀티리그 확장 item 추가
- llms.txt: 대상 리그·참여 방식·부가 기능(문자중계 등) 최신화
- worldcup-prediction.html 은 월드컵 전용 랜딩이라 유지

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 09:14:09 +09:00
b6fa863b58 frontend: 가독성 개선 — Pretendard 웹폰트 실제 로드 + 저대비 텍스트 상향
- index.html: Pretendard Variable(dynamic-subset) CDN 로드 추가.
  지금까지 font-family 에 이름만 있고 로드가 없어 Windows 에서
  맑은 고딕 폴백으로 렌더링 → 글씨가 가늘고 흐릿하게 보였음
- globals.css: 폰트 스택에 "Pretendard Variable" 추가
- ScheduleBoard: 메타/보조 텍스트 대비·크기 상향
  (11px→12px, white/35~55 → white/50~85, 상태 배지·순위 배지·칩 포함)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 09:02:17 +09:00
7705099c64 feat(auth): 카카오 로그인 — o2o-castad-backend 인증 스택 이식
백엔드 (castad 와 동일 흐름, triplepick 스타일로 재작성):
- users/refresh_tokens 테이블 (신규 — create_all 로 자동 생성)
- 카카오 OAuth (code → 토큰 → 사용자 정보), aiohttp 대신 httpx
- JWT HS256 access 60분 / refresh 7일, Refresh Token Rotation
  (재사용 감지 시 TOKEN_REVOKED, castad 동일 에러 코드 체계)
- /api/auth/{kakao/login, kakao/callback, refresh, logout, me}
- 콜백 → 프론트 리다이렉트는 쿼리 대신 해시 프래그먼트로 토큰 전달
  (서버 로그·Referer 에 토큰 노출 방지 — castad 방식에서 한 단계 보강)
- 크레딧·소셜계정 연동 등 castad 전용 개념은 제외

프론트:
- lib/auth.ts — 토큰 보관·자동 갱신·/me 캐시·useAuth 훅
- Hero 에 카카오 로그인 버튼(브랜드 노랑) / 프로필 칩 + 로그아웃
- 투표 폼(Arena) 이메일을 로그인 계정 이메일로 자동 채움
  (기존 이메일 기반 투표·리더보드 파이프라인은 그대로 유지)

설정: KAKAO_CLIENT_ID/SECRET/REDIRECT_URI, JWT_SECRET (.env.example 참고)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:42:54 +09:00
e2d1647100 fix(sched): 날짜 기본 선택·가운데 정렬 전 리그 통일
- 기본 날짜: 오늘 → 경기 없으면 다음 경기일 → 시즌 종료면 마지막 날짜
  (기존엔 첫 날짜(가장 과거)로 떨어져 백필 후 3주 전이 기본 선택되던 문제)
- '오늘' 을 KST 로 계산 (기존 UTC — KST 오전 9시 전엔 어제로 잡힘)
- 선택 칩 가운데 정렬을 리그/탭 전환뿐 아니라 선택 변경·비동기 로드에도 적용
- 목록 비동기 로드 시에도 세션 저장 선택 복원 동작

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:04:17 +09:00
eecc9829b4 feat(mls): 과거 30일 백필 — 주말 위주 편성으로 일정판에 생기던 공백 해소
야구식 recheck 윈도우(3일)만으론 MLS 직전 라운드(1~2주 전)가 수집되지 않아
일정판이 비고 자체 DB 폼 데이터도 부족했음. 범위를 넓혀도 scoreboard 1콜.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:56:44 +09:00
e1b85aeba6 fix(mls): odds 원소가 null 인 경기에서 프리뷰 갱신 전체가 죽던 문제
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:50:59 +09:00
00494e9e93 feat(mlb): 문자중계 추가 — feed/live allPlays 규칙 기반 한글 변환
- 투구 호칭·eventType 한글 매핑, 타구 방향은 desc 의 야수 표현에서 추출
- 주자 이동(홈인·진루·주루사)은 정규식 파싱 — 선수명은 이니셜("J.P.")만
  마침표 허용해 문장 경계를 넘지 않게 함
- 교체는 "A replaces B" 구문 분해 (투수 교체·대타·대주자·수비 교체)
- 득점 강조는 KBO 와 동일하게 플레이 종료 점수 합 변화로 판정 + 홈런 결과 라인
- 미지원 문장·이벤트는 영문 원문 폴백. 노이즈(타자 타임·수비 위치이동 등) 제외
- KBO relay 와 동일 스키마라 프론트(LiveField RelayFeed) 변경 없음

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:38:48 +09:00
13a4997cc2 fix(mls): ESPN 403 — 브라우저 위장 UA 제거
컨테이너(httpx)에서 풀 Chrome UA 를 보내면 ESPN WAF 가 403 차단
(TLS 핑거프린트-UA 불일치 감지로 추정). httpx 기본 UA 는 통과 —
일정 동기화가 계속 실패해 로컬에서 MLS 일정이 안 뜨던 원인.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:38:48 +09:00
0415ab7636 feat(kbo): 라이브 필드에 문자중계 추가 — 네이버 relay 전 이닝
- relay 응답에서 버리던 textRelays 를 타석 단위 그룹으로 서빙 (한국어 원문 그대로)
- 기본 응답은 현재 이닝만 담아, 지난 이닝은 ?inning=N 병렬 조회 후 캐시
  (현재·직전 이닝은 매번 재조회 — 이닝 전환 직후 마지막 타석 누락 방지)
- 득점 하이라이트는 텍스트 패턴이 아니라 currentGameState 점수 합 변화로 판정
  (홈런·적시타뿐 아니라 홈인·폭투/실책 득점·밀어내기까지 정확)
- 네이버 type 코드는 kind(pitch/result/run/sub/note)로 변환해 UI 에 노출하지 않음
- 진행 중엔 최근 40타석, 종료 후엔 전 경기 중계(다시보기)
- 프론트: 투구 접기/펼치기 토글 · 이닝 구분선 · 득점 강조

MLB 는 소스(feed/live allPlays)가 영문이라 별도 작업 — 이번 커밋은 KBO 만.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:57:02 +09:00
74de57fc1c feat(mls): MLS 리그 추가 — ESPN 비공식 API 연동
- 일정·결과·컨퍼런스 순위·프리뷰(폼/시즌전적/맞대결/머니라인) 수집
- 라이브 카드: 스코어·경기시간·득점/카드·선발 라인업(피치 뷰)·교체 현황
- 문자중계: ESPN commentary 규칙 기반 한글 변환 (미지원 템플릿은 팀명만 한글화)
- MLS 레코드는 야구와 키 구조가 같아 sync_baseball_schedule/settle 재사용
- AI 예측 데이터 블록에 MLS 전용 컨텍스트 추가

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:47:13 +09:00
ad5bbec82e 야구 리그 랭킹 UI 비노출 2026-07-22 16:06:36 +09:00
c73a9ad9f9 관리자 조회 API 추가(경기별 투표·이메일 랭킹, 토큰 인증) + ops/ gitignore
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:04:14 +09:00
e1fb1f6b1d 100만원 챌린지 카드 프론트에서 임시 삭제 2026-07-22 13:21:24 +09:00
4a7719895e 하단 문구 수정 2026-07-22 11:51:57 +09:00
bbad63ee92 ai 예측 대결 문구 변경 2026-07-22 11:26:00 +09:00
4e1ebc8989 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=)
2026-07-22 11:01:10 +09:00
bfaf621949 시작화면 토너먼트 탭으로 변경 2026-07-09 09:05:19 +09:00
0392a63d5e feat(seo): JSON-LD sameAs·parentOrganization 보강\ 2026-06-30 09:42:42 +09:00
590335b005 feat(seo): 검색등록 메타·robots·sitemap·llms·rss 배포 2026-06-29 10:56:50 +09:00
3864974ac0 토너먼트 추가 버그 수정 2026-06-29 09:22:20 +09:00
6815540ae6 토너먼트 대진 추가 2026-06-29 09:11:58 +09:00
59db1c4e9a 마스코트 한국vs남아공 only -> 전 경기에 반영 2026-06-26 17:15:26 +09:00
906896afad Merge branch 'develop' of https://gitea.o2o.kr/castad/o2o-triple-pick into develop
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-06-24 15:23:53 +09:00
a81c7f5644 투표 제출하기 UI 수정 2026-06-24 15:18:18 +09:00
56261e7667 Merge branch 'develop' of https://gitea.o2o.kr/castad/o2o-triple-pick into develop 2026-06-24 14:15:19 +09:00
a2507439d7 seo 페이지 수정 2026-06-24 14:15:10 +09:00
beaf65ef09 남아공전 투표 페이지에 마스코트 추가 2026-06-24 14:02:45 +09:00
3b5f9458c4 구글 소유권 확인용 html 추가 2026-06-24 13:51:42 +09:00
fd8493ae2d 사이트맵, 로봇txt, seo 페이지 추가 2026-06-24 13:19:34 +09:00
d6b7737955 Merge branch 'develop' of https://gitea.o2o.kr/castad/o2o-triple-pick into develop 2026-06-24 11:26:05 +09:00
ff7d84016a 네이버 사이트 확인 html 추가 2026-06-24 11:25:36 +09:00
6fe52db8d1 기본 노래 볼륨 80% -> 40%로 하향 2026-06-24 08:13:28 +09:00
7325c96f2c revert: 카드 썸네일(투표 픽 스코어) 기능 제거 — 16bd01f 상태로 복귀
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 14:50:17 +09:00
198f20a0f6 한국 남아공 경기 공유 썸네일에 투표 픽 스코어 카드 생성 2026-06-23 14:26:21 +09:00
39766bae55 한국 남아공 경기 공유 썸네일에 투표 픽 스코어 카드 생성 2026-06-23 14:19:31 +09:00
16bd01fc95 남아공전 공유하기 한국이 왼쪽으로 오게끔 수정 2026-06-22 17:32:24 +09:00
10b533af28 남아공전 응원가 3개 추가 2026-06-22 16:10:22 +09:00
d05e85e593 종료 경기 결과 외부 소스 재대조로 자동 정정 2026-06-22 15:06:19 +09:00
90d00a817b 한국 경기 한국 좌측 고정, 국가명 한글 통일 2026-06-22 10:21:13 +09:00
8580bbb713 Crowd Pick 비율 합이 항상 100% 되도록 반올림 보정 2026-06-19 11:05:13 +09:00
0a64a220ca Crowd Pick 합 100% 초과 버그 수정 및 투표 오픈을 킥오프 7일 전으로 변경 2026-06-19 10:36:58 +09:00
76eafceee8 날짜별 칩 첫 진입 시 오늘 날짜 가운데 정렬 2026-06-19 09:10:54 +09:00
f03d3e373c AI 모델 순위 부분 강조 수정 2026-06-18 17:18:14 +09:00
a8e20d5ac8 누적 랭킹 보기 탭 추가 2026-06-18 17:01:41 +09:00
39c2003080 내 지난 예측을 투표 후에만 표시 2026-06-18 15:57:40 +09:00
b4fc96f299 내 지난 얘측 보기 기능 추가, 한국 멕시코 경기 응원가 1곡 추가 2026-06-18 15:41:39 +09:00
0e5a63a28b fix: 음악자동재생 + 버그수정 2026-06-18 13:08:51 +09:00
2988a59749 투표 제출 CTA 단일 카드 통합 + Red Wave 2026 트랙 추가 2026-06-18 10:27:13 +09:00
7a68d27693 썸네일 변경 2026-06-17 17:27:44 +09:00
220eb946c1 Merge branch 'develop' of https://gitea.o2o.kr/castad/o2o-triple-pick into develop 2026-06-17 16:51:59 +09:00
7c42bd5277 feat: 사운드바 추가 2026-06-17 16:51:50 +09:00
bac505fa99 댓글 닉네임 새로고침 버튼 추가 2026-06-17 15:38:20 +09:00
b62bedc665 feat: 댓글 익명화 + 전 경기 적용
- 이메일/투표 게이트 제거 → 완전 익명
- 세션 단위 발급(sessionStorage 캐싱) — 탭 닫으면/시크릿이면 새 닉
2026-06-17 14:47:26 +09:00
8a67094510 한국 멕시코 경기 댓글 기능 추가 2026-06-17 11:22:29 +09:00
7625db36a0 API-Football 데이터 수집 로직 변경 2026-06-16 09:31:29 +09:00
8140cd9e02 모바일 카톡 인앱 브라우저에서 공유링크 안 열리는 문제 해결 2026-06-16 08:48:12 +09:00
48885ec80e remove: 경기 전용 비주얼 배너(한국-멕시코) 제거 2026-06-15 17:29:13 +09:00
65e7c7571c 한국-멕시코전 전용 공유 카드 + 앱 배너 이미지 2026-06-15 17:22:38 +09:00
dc262009f9 Speed up football refresh (batch 24) + skip caching on API errors 2026-06-15 16:08:30 +09:00
4246ad6936 Add data-driven AI predictions via API-Football
- football_data.py: 수집·캐싱·데이터블록 조립 (48팀 ID/FIFA랭킹)
- FootballCache 테이블: 폼·H2H·스쿼드 캐시 (외부 DB)
- 프롬프트에 랭킹·폼·H2H·스쿼드·WC실제결과 주입
- 키 없으면 기존(이름만) 동작 폴백, 무료 한도 점진 수집
2026-06-15 15:49:34 +09:00
356706e1b4 Update OG/Twitter share card title 2026-06-15 11:59:34 +09:00
a031d60f3a Update OG/Twitter share card title 2026-06-15 11:51:23 +09:00
6765e12054 Email autocomplete 2026-06-15 11:37:39 +09:00
4a0188a443 Vote lock 5min + email autocomplete + detail-page UI tweaks 2026-06-15 11:28:41 +09:00
c77d014a01 Fix auto-settle for non-Group-A: map results by TLA, not full name
_results_football_data resolved team codes via code_for(full name), which
only covered Group A teams, so FINISHED matches in other groups (e.g.
Canada vs Bosnia-Herzegovina) were silently dropped and never settled.
Use tla -> code_for_tla (same as schedule ingestion; covers all 48), with
full-name fallback. Verified: source FINISHED 2 -> 3, CAN-BIH settles 1-1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 10:58:33 +09:00
1d406f6a4e Diversify AI predictions via per-model analyst personas
GPT (data-driven), Claude (tactical/upset-aware), Gemini (attacking-minded)
each get a distinct perspective so the 3 picks genuinely diverge instead of
producing identical scorelines. Honest: still each model's own judgment.
Finished matches are untouched (generation filters result_outcome IS NULL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 17:33:59 +09:00
0f055dace1 Voting opens at D-5 (120h before kickoff), was D-2
vote_open_hours_before 48 → 120. opensAt is recomputed on schedule sync,
so existing matches shift to the wider window on next worker run. AI 30h
lookahead is relative to opensAt, so pre-fill-before-open still holds.

NOTE for deploy: set VOTE_OPEN_HOURS_BEFORE=120 in server .env (an existing
48 there would override the new default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 17:24:29 +09:00
3bcc47c0e8 Schedule chips: horizontal scroll with edge arrows (PC navigable)
Replace hidden-scrollbar row (inaccessible on desktop without touch) with
a ChipScroller: single-row scroll + left/right arrow buttons that auto-hide
at each end and a bg fade. Resets to start on tab switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 17:00:24 +09:00
955ffae4cf Open voting for all groups by D-2 time + 30h-ahead AI gen; misc fixes
- domain: is_votable → all groups (voting gated by D-2 open ~ kickoff-60m time window, not group)
- worker: AI generation selects matches by opens_at <= now+lookahead (all groups), pre-fills before D-2 open so picks aren't empty; only_missing keeps total calls = 3/match
- config: ai_generate_lookahead_hours=30 (covers daily-only gen cadence)
- Arena: default pick 0:0 (무승부)
- frontend Dockerfile: install from package.json only (macOS-generated lock leaves rollup darwin-x64 stub w/o version → npm "Invalid Version" on linux)

NOTE for deploy: set DEMO_FORCE_OPEN=false and real PUBLIC_ORIGIN in server .env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 16:43:28 +09:00
deb1332440 Load all 12 groups' schedule (72 matches) + Naver-style schedule UI
Backend
- teams_data.py: 48-team data (ko/short/en), TLA→FIFA code remap
- config: schedule_all_groups, featured_group("A") — only featured group is votable/AI-predicted
- schedule_fetch: fetch all GROUP_STAGE matches from football-data, KST serialize
- schedule_sync: ingest all groups, unordered-pair match to avoid Group A dup, derive votable from group
- worker: AI generation limited to featured group; settle still covers all
- domain/predictions: is_votable() + MatchOut.votable; reject non-votable submits with 403 MATCH_NOT_VOTABLE

Frontend
- types: Match.votable
- ScheduleBoard: Naver-style date/group tabs + chips; votable(A) cards link to detail with AI split + 참여수, others display-only

Verified: 72 matches (12×6), Group A not duplicated, votable A=6/others=66; A submit 200, B submit 403

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 14:56:24 +09:00
c9244bc42d feat(predictions): 이메일도 중복 투표 방지 (이메일 우선 식별)
같은 이메일은 같은 경기에 1픽만 — 다른 기기에서 같은 이메일로 제출 시
새 픽 추가 대신 기존 픽 수정 + crowd 보정. 이메일은 소문자 정규화 저장.
모델에 (match_id, email) 유니크 제약 추가(신규 DB 적용, 기존 테이블은 앱 로직 보장).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:51:42 +09:00
86290a7c69 feat(ui): AI 결과 배지 3단계 — 정확/적중/빗나감
스코어까지 맞으면 '정확 ✓'(강조), 승패만 맞으면 '적중 ✓', 그 외 '빗나감'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:25:56 +09:00
7a2affe10a feat(settle): 킥오프+3h 대기 제거 — 소스가 FINISHED 주면 즉시 정산
settle 게이트를 '킥오프 지남'으로 변경. football-data가 FINISHED 보고 시
다음 폴링(최대 5분) 내 자동 종료·채점·메일. 경기 중(IN_PLAY)엔 자동 스킵.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:25:56 +09:00
54ed8bf10d feat(settle): 결과 소스를 일정 소스와 분리(RESULT_SOURCE) + 순서 무관 매칭
- RESULT_SOURCE(비우면 SCHEDULE_SOURCE) 추가 — 일정=openfootball, 결과=football-data 가능
- fetch_results 가 result_source 사용
- 자동 정산 매칭을 홈/원정 순서 무관하게 보강(소스 순서 달라도 스코어 정합)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 09:41:36 +09:00
140e15fb9f feat(stats): 일별 방문자 집계 추가
page_visits 테이블((visit_date, device_id) 유니크) — 하루 1기기 1회 집계.
POST /api/visit(접속 기록), GET /api/admin/visits(일별 순수 방문자, Bearer).
프론트는 App 마운트 시 1회 recordVisit() 호출.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:31:23 +09:00
51352c7c5c feat(ui): 100만원 챌린지 룰 설명 모달 추가
'100만 원에 도전하기' 버튼 클릭 시 모달 표시 — 배점표(정확5/근접3/승패2/부분1/빗나감0),
누적 1위 상금, 이메일 기준 참여·집계 안내. KO/EN 지원.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 17:22:02 +09:00
d8db7ed0f4 perf(ai): 자동 생성은 비어있는 예측만 채우도록(기존 보존)
generate_ai_predictions(only_missing=True 기본): 이미 있는 (경기×모델)
예측은 보존하고 누락분만 생성 — 매일/기동/새 경기 시 기존 데이터 불변,
API 비용↓, 실패했던 모델만 자동 보충. 수동 재생성 스크립트는 강제 전체(only_missing=False).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 16:17:12 +09:00
4244518ffd feat(worker): 경기 결과 자동 정산 + '맞춘 사람만' 결과 메일
- 킥오프+result_settle_hours 경과 경기를 외부 스코어로 자동 채점(settle_matches)
- schedule_fetch.fetch_results: openfootball/football-data 확정 스코어 수집
- 결과 메일을 승패 적중자에게만 발송하는 옵션(result_email_correct_only)
- 스케줄러에 settle 잡 추가 + 기동 시 1회 밀린 경기 정산

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:25:23 +09:00
464145a234 feat(ai): 전체 AI 예측 재생성 스크립트 추가
워커의 generate_ai_predictions() 를 즉시 1회 실행하는 수동 트리거.
미종료 경기 전부에 대해 GPT/Claude/Gemini 재생성(덮어쓰기).
실행: docker compose run --rm worker python -m app.regenerate_ai

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:55:50 +09:00
5f8858c978 fix(ai): 월드컵 중립 경기장 명시 — 잘못된 '홈 이점' 예측 근거 수정
AI 프롬프트가 Team A를 home으로 단정해 "홈 경기 이점" 같은 오류 근거가
생성됨. 조별리그는 중립 경기장(개최국 멕시코/미국/캐나다만 홈)임을 명시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:50:36 +09:00
34f5aae010 feat(share): OG 공유 썸네일(BI 로고) 적용
- BI SVG → OG용 PNG 생성 (og-image 1200x630 + 정사각, 다크 브랜드 배경)
- index.html 에 Open Graph / Twitter 메타 추가 (카카오톡 등 미리보기)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 11:03:57 +09:00
1dc21e7916 Merge branch 'develop' of https://gitea.o2o.kr/castad/o2o-triple-pick into develop 2026-06-11 10:49:47 +09:00
8a4a23b0cb 전체일정 스코어 조정, utc 시간 -> kst 변경 2026-06-11 10:48:16 +09:00
c19ed6f1b5 frontend: 월드컵 표현을 글로벌 축구 축전으로 변경 2026-06-11 09:36:46 +09:00
7185cfbb72 mail config 적용 . 2026-06-11 09:21:10 +09:00
dc50fc6d8c docker compose 수정 , 2026-06-10 15:23:05 +09:00
1a803b7fa0 포인트 시스템 추가, 프론트 국기 반영 2026-06-10 15:11:56 +09:00
6ffe0dfca3 Refactor: Next.js+Firebase → React(Vite) + FastAPI + PostgreSQL
- 백엔드: FastAPI + SQLAlchemy(async) + PostgreSQL, 별도 워커(APScheduler)
- 프론트: React 19 + Vite + TS + Tailwind v4 (기존 UI 이식, API 연동)
- docker-compose: db · api · worker · frontend 일괄 실행
- 경기 일정: openfootball 매일 09:00 크롤링 → DB upsert (신규 경기 자동 삽입+예측 생성)
- AI 예측: GPT·Claude·Gemini 실 API 매일 생성 → DB 저장 → 조회 (목데이터 제거)
- 투표창: 오픈 킥오프 D-2 / 마감 킥오프 1시간 전, lockAt 백엔드 전달→프론트 카운트다운
- 채점/리더보드: docs/SCORING.md 기준 누적 포인트
- crowd: 0 시작, 실제 제출로만 증분
- 구 Next.js/Firebase 파일 제거, README/AGENTS 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:04:35 +09:00
241 changed files with 18298 additions and 9543 deletions

View File

@ -1,5 +0,0 @@
{
"projects": {
"default": "REPLACE_WITH_NEW_FIREBASE_PROJECT_ID"
}
}

53
.gitignore vendored
View File

@ -1,24 +1,32 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
node_modules/
.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
# build output
frontend/dist/
build/
/out/
# production
/build
# python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
# env files (실 키는 커밋하지 않음 — .env.example 만 커밋)
.env
.env.*
!.env.example
# docker volume (로컬)
pgdata/
# 로컬 검증 전용 compose (서버에 가면 안 됨)
docker-compose.local.yml
# misc
.DS_Store
@ -26,20 +34,17 @@
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# secrets
# secrets (구 firebase 잔재)
serviceAccount.json
.firebaserc
# 로컬 전용 댓글 관리 도구
tp-comment-admin/
# 개인 운영 대시보드 (관리자 토큰 포함 — 커밋 금지)
ops/

View File

@ -1,5 +1,46 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
# TriplePick — 아키텍처 (에이전트용 안내)
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
"AI(GPT·Claude·Gemini) vs 너" 글로벌 축구 승부예측 서비스. 모바일 우선.
## 스택 (2026 리팩토링)
- **frontend/** — React 19 + Vite + TypeScript + Tailwind v4, react-router. nginx 정적 서빙.
- **backend/** — Python FastAPI + SQLAlchemy(async) + PostgreSQL. 별도 워커(APScheduler).
- **docker-compose.yml** (루트) — db · api · worker · frontend 를 한 번에 실행.
## 디렉토리
```
backend/app/
config.py 설정(.env): DB·투표윈도우·스케줄러시각·외부키
database.py async 엔진/세션, init_db
models.py ORM: matches / ai_predictions / crowd_stats / user_predictions
schemas.py Pydantic API 계약 (프론트 lib/types.ts 와 정합)
scoring.py 채점(docs/SCORING.md SSOT) + 임직원 배제
schedule_data.py 경기 일정 SSOT (Group A 6경기, KST)
seed_data.py 부트스트랩(결정론적) 예측/crowd 생성기
seed.py DB 시드 (idempotent)
domain.py phase 계산 + ORM→응답 직렬화
services/ ai.py(3모델 실연동) · email.py(SMTP) · grading.py
routers/ matches · predictions · leaderboard · admin
main.py FastAPI 앱(API)
worker.py APScheduler 워커(상태전이·AI생성·결과메일)
frontend/src/
lib/ types · i18n · format · api(백엔드 호출) · useLang
components/ Hero · MatchupHUD · Arena · ScheduleBoard · TeamFlag · …
pages/ Dashboard · MatchDetail · Leaderboard · NotFound
```
## 시간/스케줄 규칙 (워커가 자동 처리)
- 투표 오픈 = 킥오프 120h(D-5, `VOTE_OPEN_HOURS_BEFORE`)
- 투표 마감 = 킥오프 5분 전(`VOTE_LOCK_MINUTES_BEFORE=5`)
- 상태 전이: scheduled → open → locked → finished (`STATUS_TICK_SECONDS` 주기)
- AI 예측 생성: 매일 KST `AI_GENERATE_HOUR:MINUTE` (실 LLM API 호출)
- 결과 메일: 경기 종료 후 `RESULT_EMAIL_DELAY_MINUTES`(기본 180=3h) 경과 시 발송
- `DEMO_FORCE_OPEN=true` 면 마감 전까지 항상 투표 가능(운영 시 false)
## SSOT 문서
- `docs/SCORING.md` — 채점·심사(100만원) 단일 기준
- `docs/DESIGN.md` — 룩앤필/디자인 토큰 (변경 시 먼저 갱신)
- `docs/BACKEND.md` — 초기 백엔드 설계 메모(원본 Firebase 기준 — 현 구현은 FastAPI)
## 실행
`cp backend/.env.example backend/.env` → 키 채우기 → `docker compose up --build` → http://localhost:8080

View File

@ -1,36 +1,90 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# TriplePick 2026
## Getting Started
**"AI(GPT · Claude · Gemini) vs 너"** — 글로벌 축구 승부예측 서비스.
3개 AI 모델이 매 경기를 서로 다르게 예측하고, 유저는 픽을 찍어 AI와 겨룬다.
끝까지 가장 정확하게 맞힌 1인에게 100만원 챌린지.
First, run the development server:
## 아키텍처
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
o2o-triple-pick/
├── docker-compose.yml # db · api · worker · frontend 한 번에 실행
├── backend/ # FastAPI + SQLAlchemy(async) + PostgreSQL
│ ├── app/ # API · 워커(APScheduler) · AI/이메일 실연동
│ └── .env.example
└── frontend/ # React + Vite + TypeScript + Tailwind v4
├── src/ # pages · components · lib(api 연동)
└── .env.example
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
- **frontend** — React SPA. nginx 가 정적 파일 서빙 + `/api` 를 백엔드로 프록시.
- **backend (api)** — FastAPI. 경기/AI예측/crowd 읽기, 픽 제출, 채점, 리더보드, 관리자.
- **backend (worker)** — APScheduler 별도 프로세스. 아래 "시간 기반 작업" 자동 처리.
- **db** — PostgreSQL.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## 빠른 시작 (Docker)
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
```bash
cp backend/.env.example backend/.env # 키(선택) 채우기
docker compose up --build
# 프론트: http://localhost:8080
# API 문서: http://localhost:8000/docs
```
## Learn More
DB 는 최초 기동 시 6경기 + AI 예측(부트스트랩) + crowd baseline 으로 자동 시드된다.
AI/이메일 키가 없어도 전체 플로우(예측·제출·채점·리더보드)는 즉시 동작한다.
To learn more about Next.js, take a look at the following resources:
## 시간 기반 작업 (워커가 자동 처리)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
조사된 "필요한 시간들"을 워커 단일 프로세스가 모두 담당한다:
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
| 작업 | 시점 | 설정 |
|---|---|---|
| 투표 오픈 | 킥오프 48h (D-2) | `VOTE_OPEN_HOURS_BEFORE` |
| 투표 마감 | 킥오프 5분 | `VOTE_LOCK_MINUTES_BEFORE` |
| 상태 전이 (scheduled→open→locked→finished) | 주기 틱 | `STATUS_TICK_SECONDS` (기본 60s) |
| AI 예측 생성 (3모델 실 API 호출) | 매일 KST 00:05 | `AI_GENERATE_HOUR` / `_MINUTE` |
| 결과 메일 발송 (구독자) | 경기 종료 +3h | `RESULT_EMAIL_DELAY_MINUTES` |
## Deploy on Vercel
> 데모에서는 `DEMO_FORCE_OPEN=true` 로 마감 전까지 항상 투표 가능. **운영 배포 시 false**.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
## 외부 연동 (실연동 — 키 없으면 해당 기능만 생략)
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
- **AI 3모델** — OpenAI(GPT) · Anthropic(Claude, `claude-opus-4-8`) · Google(Gemini).
`OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GOOGLE_API_KEY`.
- **이메일** — SMTP. `SMTP_HOST``backend/.env` 참조.
## API 요약
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | `/api/matches?lang=ko` | 전체 경기 (예측·crowd 포함) |
| GET | `/api/matches/{id}` | 경기 상세 |
| POST | `/api/predictions` | 픽 제출 (1회 수정·crowd 증분·매칭 모델) |
| GET | `/api/leaderboard` | 누적 포인트 랭킹 |
| POST | `/api/admin/result` | (Bearer) 결과 입력 → 채점 |
| POST | `/api/admin/ai-predictions` | (Bearer) AI 예측 수동 upsert |
## 로컬 개발 (Docker 없이)
```bash
# 백엔드
cd backend && python3.12 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# Postgres 띄우고 DATABASE_URL 지정 후:
uvicorn app.main:app --reload # API
python -m app.worker # 워커
# 프론트
cd frontend && npm install && npm run dev # http://localhost:5173 (/api → :8000 프록시)
```
## 채점 규칙
`docs/SCORING.md` (SSOT). 정확 스코어 5 · 근접 3 · 승패 2 · 부분 1 · 빗나감 0.
누적 포인트 1위에게 최종 상금. 임직원/운영진 배제.
## 문서
- `docs/SCORING.md` — 채점·심사 SSOT
- `docs/DESIGN.md` — 디자인 토큰 SSOT
- `docs/BACKEND.md` — 초기 백엔드 설계 메모(Firebase 기준 → 현 구현은 FastAPI)

View File

@ -1,251 +0,0 @@
"use client";
// ============================================================
// BEFORE — 최초 구축 버전 (다크 네이비 토큰 테마) · 개인 기록용 보존
// 002/003 리디자인 이전 모습. 자체 완결(explicit hex), 현재 globals와 독립.
// ============================================================
import { useEffect, useMemo, useState } from "react";
const C = {
bg: "#0a0e1a",
bg2: "#0d1322",
card: "#141b2d",
green: "#25e07f",
amber: "#ffb23e",
muted: "#8b94a7",
line: "#26304a",
gpt: "#25e07f",
claude: "#c08bff",
gemini: "#4d9bff",
};
const MATCH = {
teamA: { short: "한국", name: "Korea Republic", flag: "🇰🇷" },
teamB: { short: "체코", name: "Czechia", flag: "🇨🇿" },
kickoff: "2026-06-12T11:00:00+09:00",
lockAt: "2026-06-12T10:50:00+09:00",
venue: "Estadio Guadalajara",
};
const AIS = [
{ model: "GPT", color: C.gpt, outcome: "A", score: "2 - 1", conf: 62, reason: "전환 속도와 핵심 공격 자원에서 우위", out: "한국 승" },
{ model: "Claude", color: C.claude, outcome: "D", score: "1 - 1", conf: 41, reason: "체코 수비 조직력과 세트피스가 변수", out: "무승부" },
{ model: "Gemini", color: C.gemini, outcome: "B", score: "0 - 2", conf: 28, reason: "체코의 원정 폼과 역습 효율을 높게 평가", out: "체코 승" },
];
function useCountdown(iso: string) {
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
setNow(Date.now());
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
if (now === null) return null;
const diff = Math.max(0, new Date(iso).getTime() - now);
return {
d: Math.floor(diff / 86400000),
h: Math.floor((diff % 86400000) / 3600000),
m: Math.floor((diff % 3600000) / 60000),
s: Math.floor((diff % 60000) / 1000),
};
}
const pad = (n: number) => String(n).padStart(2, "0");
export default function Before() {
const cd = useCountdown(MATCH.kickoff);
const [outcome, setOutcome] = useState<string | null>(null);
const [a, setA] = useState(1);
const [b, setB] = useState(0);
const [step, setStep] = useState<"pick" | "form" | "done">("pick");
const [nick, setNick] = useState("");
const [total, setTotal] = useState(1284);
const [dist, setDist] = useState({ A: 48, D: 27, B: 25 });
const matched = useMemo(() => AIS.filter((p) => p.outcome === outcome).map((p) => p.model), [outcome]);
const wrap: React.CSSProperties = { background: C.bg, color: "#fff", minHeight: "100dvh" };
const card: React.CSSProperties = { background: C.card, border: `1px solid ${C.line}`, borderRadius: 12 };
return (
<div style={wrap}>
{/* 기록용 라벨 */}
<div style={{ background: "#1c2640", color: C.green, textAlign: "center", fontSize: 11, fontWeight: 700, padding: "6px" }}>
BEFORE () · <a href="/" style={{ color: "#fff", textDecoration: "underline" }}>/ </a>
</div>
{/* Sticky Top */}
<div style={{ position: "sticky", top: 0, zIndex: 50, background: "rgba(10,14,26,0.9)", borderBottom: `1px solid ${C.line}`, backdropFilter: "blur(6px)" }}>
<div style={{ maxWidth: 480, margin: "0 auto", padding: "0 16px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 800, color: C.green }}>TriplePick</span>
<span style={{ fontSize: 11, color: C.muted }}>🇰🇷 vs 🇨🇿</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ textAlign: "right", lineHeight: 1 }}>
<div style={{ fontSize: 9, color: C.muted }}></div>
<div style={{ fontSize: 12, fontWeight: 700, fontVariantNumeric: "tabular-nums" }}>
{cd ? `${cd.d}${pad(cd.h)}:${pad(cd.m)}:${pad(cd.s)}` : "—"}
</div>
</div>
<button onClick={() => document.getElementById("pick")?.scrollIntoView({ behavior: "smooth" })} style={{ background: C.green, color: "#06210f", fontSize: 12, fontWeight: 700, borderRadius: 6, padding: "6px 12px", border: 0 }}>
</button>
</div>
</div>
<div style={{ fontSize: 10, color: C.muted, paddingBottom: 6 }}> {total.toLocaleString()} </div>
</div>
</div>
<main style={{ maxWidth: 480, margin: "0 auto", padding: "0 16px 64px" }}>
{/* Hero */}
<header style={{ paddingTop: 20, paddingBottom: 12, textAlign: "center" }}>
<div style={{ fontSize: 20, fontWeight: 800 }}>Triple Pick <span style={{ color: C.green }}>2026</span></div>
<div style={{ fontSize: 12, color: C.muted }}>AI Prediction Arena</div>
<h1 style={{ fontSize: 22, fontWeight: 800, marginTop: 16, lineHeight: 1.35 }}>
,<br /><span style={{ color: C.green }}>AI의 </span>
</h1>
<p style={{ fontSize: 12.5, color: C.muted, marginTop: 8, lineHeight: 1.6 }}>
AI .<br /> , .
</p>
</header>
{/* Match card */}
<section style={{ ...card, padding: 16, marginTop: 12 }}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: C.muted }}>
<span style={{ border: `1px solid ${C.line}`, borderRadius: 999, padding: "2px 8px", color: C.green, fontWeight: 600 }}>Match 01</span>
<span>Matchday Prediction</span>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr auto 1fr", alignItems: "center", gap: 8, marginTop: 12 }}>
<Team flag={MATCH.teamA.flag} short={MATCH.teamA.short} name={MATCH.teamA.name} />
<div style={{ fontSize: 18, fontWeight: 800, color: C.muted }}>VS</div>
<Team flag={MATCH.teamB.flag} short={MATCH.teamB.short} name={MATCH.teamB.name} />
</div>
<div style={{ marginTop: 12, textAlign: "center", fontSize: 11, color: C.muted }}>6.12() 11:00 KST · Group A</div>
<div style={{ marginTop: 2, textAlign: "center", fontSize: 10, color: C.muted }}>{MATCH.venue}</div>
</section>
{/* AI predictions */}
<section style={{ marginTop: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
<h2 style={{ fontSize: 14, fontWeight: 800 }}>AI의 </h2>
<span style={{ fontSize: 10, color: C.muted }}> · </span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{AIS.map((p) => (
<div key={p.model} style={{ ...card, padding: 12 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ width: 28, height: 28, display: "grid", placeItems: "center", borderRadius: 6, fontSize: 11, fontWeight: 800, background: `${p.color}2e`, color: p.color }}>{p.model[0]}</span>
<div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{p.model}</div>
<div style={{ fontSize: 10, color: C.muted, marginTop: 2 }}>{p.out} </div>
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 16, fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{p.score}</div>
<div style={{ fontSize: 10, color: C.muted }}> {p.conf}%</div>
</div>
</div>
<div style={{ marginTop: 8, height: 6, borderRadius: 999, background: C.bg2, overflow: "hidden" }}>
<div style={{ width: `${p.conf}%`, height: "100%", background: p.color, borderRadius: 999 }} />
</div>
<p style={{ marginTop: 8, fontSize: 11, color: C.muted }}>{p.reason} <span style={{ opacity: 0.6 }}>· 2026-06-08</span></p>
</div>
))}
</div>
</section>
{/* Your pick */}
<section id="pick" style={{ marginTop: 20, scrollMarginTop: 80 }}>
<h2 style={{ fontSize: 14, fontWeight: 800, marginBottom: 8 }}> <span style={{ color: C.amber }}>🟠</span></h2>
<div style={{ ...card, padding: 16 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 6, background: C.bg2, borderRadius: 8, padding: 4 }}>
{([["A", "한국 승"], ["D", "무승부"], ["B", "체코 승"]] as [string, string][]).map(([v, l]) => (
<button key={v} onClick={() => setOutcome(v)} style={{ borderRadius: 6, padding: "8px 0", fontSize: 12.5, fontWeight: 700, border: 0, background: outcome === v ? C.green : "transparent", color: outcome === v ? "#06210f" : C.muted }}>{l}</button>
))}
</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, marginTop: 16 }}>
<Stepper label="한국" value={a} set={setA} c={C} />
<span style={{ fontSize: 18, fontWeight: 800, color: C.muted, paddingTop: 20 }}>:</span>
<Stepper label="체코" value={b} set={setB} c={C} />
</div>
{step === "pick" && (
<button onClick={() => outcome && setStep("form")} disabled={!outcome} style={{ marginTop: 16, width: "100%", borderRadius: 8, padding: "12px 0", fontSize: 15, fontWeight: 800, border: 0, background: C.green, color: "#06210f", opacity: outcome ? 1 : 0.4 }}>AI와 </button>
)}
{step === "form" && (
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 8 }}>
<input value={nick} onChange={(e) => setNick(e.target.value)} placeholder="닉네임 (2~16자)" style={{ width: "100%", borderRadius: 8, border: `1px solid ${C.line}`, background: C.bg2, padding: "10px 12px", fontSize: 13, color: "#fff" }} />
<button onClick={() => { if (nick.trim().length >= 2) { setTotal((t) => t + 1); setStep("done"); } }} disabled={nick.trim().length < 2} style={{ width: "100%", borderRadius: 8, padding: "12px 0", fontSize: 15, fontWeight: 800, border: 0, background: C.green, color: "#06210f", opacity: nick.trim().length >= 2 ? 1 : 0.4 }}> AI와 </button>
</div>
)}
</div>
{step === "done" && outcome && (
<div style={{ ...card, border: `1px solid ${C.green}80`, padding: 16, marginTop: 12 }}>
<div style={{ fontSize: 11, color: C.muted }}> </div>
<div style={{ fontSize: 20, fontWeight: 800, marginTop: 2 }}> {a}-{b} <span style={{ fontSize: 13, color: C.green }}>{outcome === "A" ? "한국 승" : outcome === "B" ? "체코 승" : "무승부"}</span></div>
<p style={{ fontSize: 12.5, color: "#cfd6e2", marginTop: 8, lineHeight: 1.6 }}>
{matched.length ? <> <b>{matched.join("·")}</b> . AI는 .</> : <> AI . ! 🔥</>}<br /> .
</p>
<button style={{ marginTop: 12, width: "100%", borderRadius: 8, padding: "10px 0", fontSize: 13, fontWeight: 700, border: 0, background: C.green, color: "#06210f" }}> </button>
</div>
)}
</section>
{/* Crowd (세로 바) */}
<section style={{ marginTop: 20 }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 8 }}>
<h2 style={{ fontSize: 14, fontWeight: 800 }}>Crowd Pick</h2>
<span style={{ fontSize: 10, color: C.muted }}> {total.toLocaleString()} </span>
</div>
<div style={{ ...card, padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
{([["한국 승", dist.A], ["무승부", dist.D], ["체코 승", dist.B]] as [string, number][]).map(([l, v]) => (
<div key={l}>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 11, marginBottom: 4 }}><span style={{ color: C.muted }}>{l}</span><span style={{ fontWeight: 700 }}>{v}%</span></div>
<div style={{ height: 8, borderRadius: 999, background: C.bg2, overflow: "hidden" }}><div style={{ width: `${v}%`, height: "100%", background: C.green, borderRadius: 999 }} /></div>
</div>
))}
</div>
</section>
{/* Prize */}
<section style={{ marginTop: 20 }}>
<div style={{ ...card, padding: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}><span style={{ fontSize: 18 }}>🏆</span><h2 style={{ fontSize: 14, fontWeight: 800 }}> 100 </h2></div>
<p style={{ marginTop: 6, fontSize: 12, color: C.muted, lineHeight: 1.6 }}>AI 100 . , .</p>
<button style={{ marginTop: 12, width: "100%", borderRadius: 8, padding: "10px 0", fontSize: 13, fontWeight: 700, background: `${C.green}1a`, color: C.green, border: `1px solid ${C.green}66` }}>100 </button>
</div>
</section>
{/* Footer */}
<footer style={{ marginTop: 32, borderTop: `1px solid ${C.line}`, paddingTop: 20 }}>
<p style={{ fontSize: 11, color: C.muted, lineHeight: 1.6 }}> AIO2O가 AI . GPT·Claude·Gemini에 .</p>
<p style={{ fontSize: 10, color: `${C.muted}cc`, marginTop: 8, lineHeight: 1.6 }}> · · . AI .</p>
<div style={{ marginTop: 12, display: "flex", justifyContent: "space-between", fontSize: 10, color: C.muted }}><span>© 2026 TriplePick · AIO2O</span><span>@triplepick_ai</span></div>
</footer>
</main>
</div>
);
}
function Team({ flag, short, name }: { flag: string; short: string; name: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 6 }}>
<div style={{ fontSize: 40, lineHeight: 1 }}>{flag}</div>
<div style={{ fontSize: 13, fontWeight: 700 }}>{short}</div>
<div style={{ fontSize: 9, color: "#8b94a7" }}>{name}</div>
</div>
);
}
function Stepper({ label, value, set, c }: { label: string; value: number; set: (n: number) => void; c: typeof C }) {
const btn: React.CSSProperties = { width: 36, height: 36, display: "grid", placeItems: "center", borderRadius: 8, border: `1px solid ${c.line}`, background: c.bg2, color: "#fff", fontSize: 18, fontWeight: 700 };
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
<div style={{ fontSize: 11, color: c.muted, marginBottom: 4 }}>{label}</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<button onClick={() => set(Math.max(0, value - 1))} style={btn}></button>
<div style={{ width: 36, textAlign: "center", fontSize: 22, fontWeight: 800 }}>{value}</div>
<button onClick={() => set(Math.min(9, value + 1))} style={btn}>+</button>
</div>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,38 +0,0 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "TriplePick 2026 | AI 2026 월드컵 승부예측 — GPT vs Claude vs Gemini",
description:
"GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지.",
applicationName: "TriplePick",
openGraph: {
title: "AI 셋이 갈렸다 — 당신의 픽은?",
description:
"GPT·Claude·Gemini의 예측을 확인하고 직접 승패와 스코어를 찍어보세요. TriplePick AI 예측 아레나.",
siteName: "TriplePick",
type: "website",
locale: "ko_KR",
},
twitter: {
card: "summary_large_image",
title: "AI 셋이 갈렸다 — TriplePick 2026",
description: "GPT vs Claude vs Gemini vs 너. 지금 픽하고 AI와 겨뤄요.",
},
};
export const viewport: Viewport = {
themeColor: "#0a0e1a",
width: "device-width",
initialScale: 1,
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}

View File

@ -1,26 +0,0 @@
import type { Metadata } from "next";
import Hero from "@/components/Hero";
import Footer from "@/components/Footer";
// L3. 리더보드 — 차기(금요일 제외). 진입점 404 방지용 stub.
export const metadata: Metadata = {
title: "랭킹 | TriplePick 2026",
description: "TriplePick 누적 랭킹 — 곧 공개됩니다.",
};
export default function LeaderboardPage() {
return (
<main className="shell">
<Hero back />
<section className="mt-10 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-8 text-center">
<div className="font-impact text-[28px] text-[var(--green)]">LEADERBOARD</div>
<p className="mt-3 text-[15px] font-bold"> </p>
<p className="mt-2 text-[13px] leading-relaxed text-[var(--ink-muted)]">
,
100 .
</p>
</section>
<Footer />
</main>
);
}

View File

@ -1,88 +0,0 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Hero from "@/components/Hero";
import MatchupHUD from "@/components/MatchupHUD";
import Arena from "@/components/Arena";
import Footer from "@/components/Footer";
import { GROUP_A } from "@/lib/schedule";
import { getPredictions, getCrowd, matchUrl } from "@/lib/mockData";
import { parseLang, dict, teamShort } from "@/lib/i18n";
// L2. 경기 상세(대결) 페이지 — 6경기 정적 생성
export function generateStaticParams() {
return GROUP_A.map((m) => ({ matchId: m.matchId }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ matchId: string }>;
}): Promise<Metadata> {
const { matchId } = await params;
const match = GROUP_A.find((m) => m.matchId === matchId);
if (!match) return { title: "TriplePick 2026" };
const title = `${match.teamA.shortName} vs ${match.teamB.shortName} AI 승부예측 | TriplePick`;
const desc = `GPT·Claude·Gemini가 ${match.teamA.name} vs ${match.teamB.name}를 서로 다르게 예측했습니다. 당신의 픽을 찍고 AI와 겨뤄보세요.`;
return {
title,
description: desc,
openGraph: { title, description: desc, siteName: "TriplePick", type: "website", locale: "ko_KR" },
twitter: { card: "summary_large_image", title, description: desc },
};
}
export default async function MatchPage({
params,
searchParams,
}: {
params: Promise<{ matchId: string }>;
searchParams: Promise<{ lang?: string }>;
}) {
const { matchId } = await params;
const { lang: langParam } = await searchParams;
const lang = parseLang(langParam);
const t = dict(lang);
const match = GROUP_A.find((m) => m.matchId === matchId);
if (!match) notFound();
const predictions = getPredictions(match, lang);
const crowd = getCrowd(match);
const aShort = teamShort(match.teamA, lang);
const bShort = teamShort(match.teamB, lang);
const hook = lang === "en" ? t.hook(aShort, bShort) : match.hookText;
return (
<main className="shell">
<Hero
back
lang={lang}
share={{
url: matchUrl(match.matchId),
title: `${aShort} vs ${bShort} — TriplePick`,
text:
lang === "en"
? `${hook} — see all 3 AI picks and make yours!`
: `${hook} — AI 셋의 예측을 보고 너의 픽을 찍어봐!`,
}}
/>
{/* 경기별 후킹 카피 (한 줄) */}
<h1 className="mt-6 flex items-center justify-center gap-2 whitespace-nowrap text-[19px] font-extrabold leading-snug">
<span className="text-[var(--green)]"></span>
{hook}
<span className="text-[var(--green)]"></span>
</h1>
<MatchupHUD match={match} lang={lang} />
<Arena
match={match}
predictions={predictions}
crowd={crowd}
shareUrl={matchUrl(match.matchId)}
lang={lang}
/>
<Footer lang={lang} />
</main>
);
}

View File

@ -1,37 +0,0 @@
import Hero from "@/components/Hero";
import ScheduleBoard from "@/components/ScheduleBoard";
import Ado2Ad from "@/components/Ado2Ad";
import Footer from "@/components/Footer";
import { parseLang, dict } from "@/lib/i18n";
// L1. 전체 일정 대시보드 (랜딩 진입)
export default async function Home({
searchParams,
}: {
searchParams: Promise<{ lang?: string }>;
}) {
const { lang: langParam } = await searchParams;
const lang = parseLang(langParam);
const t = dict(lang);
return (
<main className="shell">
<Hero lang={lang} />
{/* 기간 안내 + CTA 영역 */}
<div className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4 text-center">
<p className="whitespace-pre-line text-[14px] font-bold leading-snug">
{t.introLead}
</p>
<p className="mt-1 text-[14px] font-bold leading-snug text-[var(--green)]">
{t.prizeLine1}
<br />
<span className="whitespace-nowrap">{t.prizeLine2}</span>
</p>
</div>
<ScheduleBoard lang={lang} />
<Ado2Ad lang={lang} />
<Footer lang={lang} />
</main>
);
}

87
backend/.env.example Normal file
View File

@ -0,0 +1,87 @@
# ─────────────────────────────────────────────────────────────
# TriplePick 백엔드 환경변수 — 복사: cp .env.example .env
# ─────────────────────────────────────────────────────────────
# ── 데이터베이스 (외부 PostgreSQL 연결) ──
# DB_HOST 가 설정되면 아래 DB_* 로 접속 URL 을 조합한다(특수문자 비밀번호 안전 처리).
# 비밀번호에 # 가 들어가면 따옴표로 감쌀 것: DB_PASSWORD="비밀#번호"
DB_NAME=triplepick
DB_USER=o2o_db_admin
DB_PASSWORD=changeme
DB_HOST=172.30.1.36
DB_PORT=5432
# (대안) DB_HOST 를 비워두면 아래 DATABASE_URL 을 그대로 사용. 로컬 sqlite 테스트 등.
# DATABASE_URL=postgresql+asyncpg://triplepick:triplepick@localhost:5432/triplepick
# 일반
TIMEZONE=Asia/Seoul
CORS_ORIGINS=*
PUBLIC_ORIGIN=http://localhost:8080
# 투표 윈도우 (도메인 규칙)
VOTE_OPEN_HOURS_BEFORE=168 # 오픈 = 킥오프 D-7
VOTE_LOCK_MINUTES_BEFORE=5 # 마감 = 킥오프 5분 전
DEMO_FORCE_OPEN=true # 운영 배포 시 false
# 스케줄링 서버 (워커)
# 경기 일정 동적 수집: 매일 KST 09:00 외부 소스에서 일정·투표시간 갱신
SCHEDULE_SYNC_HOUR=9
SCHEDULE_SYNC_MINUTE=0
SCHEDULE_SOURCE=openfootball # openfootball(키 불필요) | football-data(토큰) | fallback
SCHEDULE_URL=https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json
SCHEDULE_GROUP=Group A
FOOTBALL_DATA_TOKEN= # football-data 사용 시 토큰 (football-data.org/client/register)
FOOTBALL_DATA_COMPETITION=WC
# 결과(스코어) 소스 — 일정과 분리. 비우면 SCHEDULE_SOURCE 사용.
# openfootball 은 결과 미게시 → 자동 종료 쓰려면 football-data 권장.
RESULT_SOURCE=football-data
AI_GENERATE_HOUR=0 # 매일 KST 00:05 AI 예측 생성
AI_GENERATE_MINUTE=5
RESULT_EMAIL_DELAY_MINUTES=180 # 경기 종료 3시간 후 결과 메일
STATUS_TICK_SECONDS=60
# 관리자 (강한 토큰으로 교체)
ADMIN_API_TOKEN=change-me-admin-token
# ── 카카오 로그인 (o2o-castad-backend 인증 이식) ──
# 카카오 개발자 콘솔(developers.kakao.com) 앱의 REST API 키.
# redirect_uri 는 콘솔 [카카오 로그인 > Redirect URI] 에 등록돼 있어야 한다.
# 운영: https://triplepick.o2o.kr/api/auth/kakao/callback
KAKAO_CLIENT_ID=
KAKAO_CLIENT_SECRET= # 콘솔에서 Client Secret 활성화한 경우만
KAKAO_REDIRECT_URI=http://localhost:8080/api/auth/kakao/callback
JWT_SECRET=change-me-triplepick-jwt-secret # 32자 이상 무작위 문자열로 교체
# ── 외부 연동: AI 3모델 (실연동, 키 없으면 해당 모델 생성 생략) ──
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-opus-4-8
GOOGLE_API_KEY=
GOOGLE_MODEL=gemini-2.5-flash
# ── 외부 연동: 축구 데이터 (API-Football 무료 티어) ──
# 키 없으면 데이터 수집/주입 생략 → 기존(이름만) 예측으로 동작.
# 발급: https://dashboard.api-football.com (무료 100req/일)
FOOTBALL_API_KEY=
# ── 외부 연동: 이메일 ──
# 1순위: Azure Communication Services(ACS) Email (endpoint + accesskey)
# AZURE_ACS_SENDER 는 검증된 MailFrom 주소(예: donotreply@triplepick.o2o.kr)
AZURE_ACS_ENDPOINT=https://o2o-common-acs.korea.communication.azure.com/
AZURE_ACS_ACCESSKEY=
AZURE_ACS_SENDER=donotreply@triplepick.o2o.kr
# 2순위(폴백): SMTP — ACS 미설정 시 사용
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=TriplePick <no-reply@triplepick.app>
SMTP_STARTTLS=true
# Suno (sunoapi.org 게이트웨이) — 오늘의 응원가 자동 생성
SUNO_API_KEY=
SONGS_ENABLED=true

23
backend/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
# TriplePick 백엔드 — API 서버 & 워커 공용 이미지.
# docker-compose 에서 command 를 달리해 두 서비스로 띄운다.
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY data ./data
EXPOSE 8000
# 기본: API 서버. 워커는 compose 에서 command 오버라이드.
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

224
backend/app/config.py Normal file
View File

@ -0,0 +1,224 @@
"""애플리케이션 설정 — 환경변수(.env)에서 로드.
pydantic-settings 타입 검증. 누락 조용한 실패 없이 명확하게 동작.
시간 윈도우(투표 오픈/마감), 스케줄러 시각, 외부 연동 키를 모두 여기서 관리.
"""
from __future__ import annotations
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
# ── 데이터베이스 ─────────────────────────────────────────
# 외부 DB 연결: DB_HOST 가 설정되면 아래 DB_* 항목으로 접속 URL 을 조합한다
# (비밀번호 특수문자는 SQLAlchemy 가 안전하게 인코딩). DB_HOST 미설정 시
# database_url(또는 sqlite 등 직접 지정값)을 그대로 사용.
db_host: str = ""
db_port: int = 5432
db_name: str = "triplepick"
db_user: str = "triplepick"
db_password: str = "triplepick"
# 직접 지정용 폴백 (DB_HOST 미설정 시 사용). 로컬 sqlite 테스트 등.
database_url: str = (
"postgresql+asyncpg://triplepick:triplepick@db:5432/triplepick"
)
def sqlalchemy_url(self): # noqa: ANN201
"""엔진 생성용 URL. DB_HOST 가 있으면 DB_* 로 조합(특수문자 안전)."""
if self.db_host:
from sqlalchemy import URL
return URL.create(
"postgresql+asyncpg",
username=self.db_user,
password=self.db_password,
host=self.db_host,
port=self.db_port,
database=self.db_name,
)
return self.database_url
# ── 일반 ────────────────────────────────────────────────
timezone: str = "Asia/Seoul" # 모든 경기 시각의 기준 (KST)
cors_origins: str = "*" # 콤마구분. nginx 프록시 사용 시 동일 출처라 보통 불필요.
public_origin: str = "http://localhost:8080" # 공유 딥링크 베이스
# ── 투표 윈도우 (도메인 규칙) ─────────────────────────────
# 오픈 = 킥오프 - VOTE_OPEN_HOURS_BEFORE (D-7 = 168h)
vote_open_hours_before: int = 168
# 마감 = 킥오프 5분 전 (경기 시작 5분 전까지 투표). lock 오프셋(분).
vote_lock_minutes_before: int = 5
# 데모: True 면 오픈 게이트 무시(항상 투표 가능). 운영 배포 시 False.
demo_force_open: bool = True
# ── 스케줄러 (별도 워커 프로세스 = "스케줄링 서버") ───────
# 경기 일정 동적 수집: 매일 KST 09:00 외부 소스 크롤링 → 경기·투표시간 갱신.
schedule_sync_hour: int = 9
schedule_sync_minute: int = 0
# 소스: openfootball(키 불필요·기본) | football-data(토큰 필요) | fallback(하드코딩만)
schedule_source: str = "openfootball"
schedule_url: str = (
"https://raw.githubusercontent.com/openfootball/worldcup.json/master/2026/worldcup.json"
)
schedule_group: str = "Group A" # (구) openfootball 단일 그룹 라벨 — 폴백용
# 전체 조별리그(12개조 72경기) 일정을 불러올지. 결과/스코어는 전 경기 표시.
schedule_all_groups: bool = True
# AI 예측·투표 대상 조(이 조만 예측 생성·투표 가능). 그 외는 일정/결과만 표시.
featured_group: str = "A"
football_data_token: str = "" # football-data 사용 시 토큰
football_data_competition: str = "WC"
# 결과(스코어) 소스 — 일정 소스와 분리. 빈값이면 자동 결정(아래 effective).
# openfootball 은 결과 미게시라, 자동 종료를 쓰려면 football-data 가 필요하다.
result_source: str = ""
@property
def effective_result_source(self) -> str:
# 명시값 우선. 없으면: 전체 조 모드는 football-data 기반이므로 결과도 동일 소스로
# 자동 정렬(openfootball 폴백 시 녹아웃·타 조 결과가 영영 안 들어오는 함정 방지).
if self.result_source:
return self.result_source.lower()
if self.schedule_all_groups:
return "football-data"
return self.schedule_source.lower()
# ── 멀티리그 (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
# 네이버 스포츠 비공식 API (KBO 일정·결과·프리뷰·문자중계) — 비공식, 로컬용.
naver_api_base: str = "https://api-gw.sports.naver.com"
# MLB 공식 Stats API (키 불필요).
mlb_api_base: str = "https://statsapi.mlb.com/api"
# ── 카카오 로그인 (o2o-castad-backend 인증 이식) ──────────
# 카카오 개발자 콘솔의 REST API 키. redirect_uri 는 콘솔에 등록돼 있어야 한다.
kakao_client_id: str = ""
kakao_client_secret: str = "" # 콘솔에서 활성화한 경우만
kakao_redirect_uri: str = "http://localhost:8080/api/auth/kakao/callback"
# JWT — castad 와 동일: HS256, access 60분 / refresh 7일 (rotation)
jwt_secret: str = "change-me-triplepick-jwt-secret"
jwt_access_expire_minutes: int = 60
jwt_refresh_expire_days: int = 7
# ESPN 비공식 API (MLS 일정·결과·순위·상세) — 키 불필요, 비공식.
espn_api_base: str = "https://site.api.espn.com/apis"
espn_mls_path: str = "sports/soccer/usa.1"
# MLS 과거 경기 백필 일수 — MLS 는 주말 위주 편성이라 야구식 recheck 윈도우(3일)만으론
# 일정판에 긴 공백이 생기고 자체 DB 폼 데이터도 비어서, 최근 한 달을 함께 수집한다.
# (범위를 넓혀도 scoreboard 1콜, sync 는 추가형이라 과거 경기 재수집 무해)
mls_days_back: int = 30
@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 이라 총 호출 수 동일)
# 킥오프 N시간 전부터 생성 — 야구 선발투수 예고(경기 전날 저녁) 이후 시점.
ai_generate_lookahead_hours: int = 22
# ── 결과 자동 정산(관리자 입력 불필요) ───────────────────
# 킥오프 + 이 시간(시) 경과 후, 외부 소스에서 스코어를 자동 수집해 채점·집계·메일.
result_settle_hours: int = 3
# 정산/메일 점검 주기(초). status_tick 와 함께 도는 별도 폴링.
settle_tick_seconds: int = 300
# 종료 경기 결과 재확인 기간(일). 최근 이 기간 내 종료 경기는 매 틱 외부 소스와
# 대조해, 소스가 스코어를 정정(예: 잠정값→확정값)하면 자동 갱신·재채점한다.
# 0이면 재확인 비활성(첫 정산값 고정). 오래된 경기는 대상에서 빠져 부하 bounded.
result_recheck_days: int = 3
# 결과 메일을 '맞춘 사람(승패 적중)'에게만 보낼지 여부. False면 구독자 전체.
result_email_correct_only: bool = True
# 결과 메일: 결과 확정 후 추가 지연(분). 자동정산은 이미 킥오프+Nh라 기본 0.
result_email_delay_minutes: int = 0
# 상태 전이 틱 주기(초): scheduled→open→locked 자동 갱신.
status_tick_seconds: int = 60
# ── 관리자 ──────────────────────────────────────────────
admin_api_token: str = "change-me-admin-token"
# ── 외부 연동: AI 3모델 (실연동) ─────────────────────────
openai_api_key: str = ""
openai_model: str = "gpt-4o"
anthropic_api_key: str = ""
anthropic_model: str = "claude-opus-4-8"
google_api_key: str = ""
google_model: str = "gemini-2.5-flash"
# ── 외부 연동: 축구 데이터 (API-Football 무료 티어) ───────
# 키 미설정 시 데이터 수집/주입 전부 no-op → 기존(이름만) 예측으로 폴백.
football_api_key: str = ""
football_api_base: str = "https://v3.football.api-sports.io"
# 팀당 1회만 수집(fetch-once): 과거 시즌 폼·스쿼드·H2H 는 변하지 않고, 2026
# 진행 결과(승패·스코어)는 build 시 우리 DB 에서 라이브로 읽는다. 한 번 캐시되면
# 다시 받지 않으므로, 전 팀이 채워지면 이후 잡은 호출 0(사실상 수집 자동 종료).
# 하루 호출 예산(무료 100/일 보호). 팀당 ~3콜이라 90이면 하루 ~30팀 →
# 48팀이 약 2일에 채워짐. 예산 소진 시 남은 팀은 다음 날 잡이 이어서 누적 수집.
football_daily_call_budget: int = 90
# API 호출 간 최소 간격(초) — 무료 10req/분 제한 회피.
football_call_interval_sec: float = 7.0
# 수집 잡 시각(KST) — 예측 생성(00:05)보다 앞서 캐시를 채워둔다.
football_refresh_hour: int = 0
football_refresh_minute: int = 0
# ── 외부 연동: Suno (sunoapi.org 서드파티 게이트웨이) ─────
# 오늘의 응원가 자동 생성. 키 미설정 시 전체 no-op.
suno_api_key: str = ""
suno_api_base: str = "https://api.sunoapi.org"
suno_model: str = "V5"
# 응원가 생성 전체 스위치 (KBO 만 대상 — 한국어 응원가)
songs_enabled: bool = True
# 킥오프 N분 전부터 생성 윈도우 시작 — 윈도우 안에서 라인업 발표를 기다린다
song_generate_minutes_before: int = 150
# 킥오프 N분 전까지 라인업이 안 뜨면 라인업 없이 폴백 생성 (곡 없는 날 방지)
song_lineup_fallback_minutes_before: int = 40
# 생성 대상 점검·생성 상태 폴링 주기(초)
song_tick_seconds: int = 120
# 응원가 커버 합성 시 KBO 로고를 가져올 내부 프론트 주소 (docker 네트워크)
internal_frontend_base: str = "http://frontend"
# ── 외부 연동: 이메일 ─────────────────────────────────────
# 1순위: Azure Communication Services(ACS) Email — endpoint + accesskey.
# AZURE_ACS_SENDER 는 검증된 MailFrom 주소(예: donotreply@triplepick.o2o.kr).
azure_acs_endpoint: str = ""
azure_acs_accesskey: str = ""
azure_acs_sender: str = ""
# 2순위(폴백): SMTP — ACS 미설정 시 사용.
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "TriplePick <no-reply@triplepick.app>"
smtp_starttls: bool = True
@property
def acs_configured(self) -> bool:
return bool(
self.azure_acs_endpoint and self.azure_acs_accesskey and self.azure_acs_sender
)
@property
def cors_origin_list(self) -> list[str]:
if self.cors_origins.strip() == "*":
return ["*"]
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

39
backend/app/database.py Normal file
View File

@ -0,0 +1,39 @@
"""SQLAlchemy 2.0 async 엔진 + 세션 팩토리.
API(main.py) 워커(worker.py) 공유한다. asyncpg 드라이버 사용.
"""
from __future__ import annotations
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from .config import settings
class Base(DeclarativeBase):
pass
engine = create_async_engine(settings.sqlalchemy_url(), echo=False, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI 의존성: 요청당 세션."""
async with SessionLocal() as session:
yield session
async def init_db() -> None:
"""테이블 생성 (없으면). 단순화를 위해 alembic 대신 create_all 사용."""
from . import models # noqa: F401 (모델 등록)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

191
backend/app/domain.py Normal file
View File

@ -0,0 +1,191 @@
"""도메인 헬퍼 — 투표 단계(phase) 계산 + ORM→응답 스키마 직렬화.
phase now 기준 동적 계산 (lib/schedule.ts matchPhase 동일 규칙):
result 존재 finished
now >= lockAt locked
now < opensAt scheduled
open
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from .config import settings
from .models import AIPrediction, CrowdStats, Match, UserPrediction
from .schemas import (
AIPredictionOut,
CrowdStatsOut,
MatchOut,
MatchResult,
MyPredictionOut,
MyResultOut,
Team,
)
def now_utc() -> datetime:
return datetime.now(timezone.utc)
KST = timezone(timedelta(hours=9))
def ensure_aware(dt: datetime) -> datetime:
"""naive datetime(일부 DB 드라이버 반환)을 UTC aware 로 보정."""
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
def kst_iso(dt: datetime) -> str:
"""저장값(UTC)을 KST(+09:00) ISO 문자열로 직렬화 — 프론트 표시 기준 통일."""
return ensure_aware(dt).astimezone(KST).isoformat()
def compute_phase(m: Match, now: datetime | None = None) -> str:
"""투표/경기 단계:
cancelled 우천취소 취소 확정 "취소" (투표·정산 제외, 기록은 보존)
finished 결과 입력됨 "종료"
live 킥오프 이후, 결과 입력 "경기중"
locked 투표 마감(킥오프 1h ) ~ 킥오프 "투표 종료"(비활성)
scheduled 오픈 "오픈 예정"
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):
return "live"
if now >= ensure_aware(m.lock_at):
return "locked"
if now < ensure_aware(m.opens_at):
return "scheduled"
return "open"
def is_votable(m: Match) -> bool:
"""투표 지원 경기 여부 — 전체 조 투표 가능.
실제 오픈/마감은 is_open_for_voting 시간창(D-2 오픈 ~ 킥오프 60 마감) 결정."""
return True
def is_open_for_voting(m: Match, now: datetime | None = None) -> bool:
"""제출 허용 여부. demo_force_open 이면 마감 전까지 항상 오픈."""
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):
return False
if settings.demo_force_open:
return True
return now >= ensure_aware(m.opens_at)
def _team_a(m: Match) -> Team:
return Team(
name=m.team_a_name, shortName=m.team_a_short, code=m.team_a_code, flag=m.team_a_flag
)
def _team_b(m: Match) -> Team:
return Team(
name=m.team_b_name, shortName=m.team_b_short, code=m.team_b_code, flag=m.team_b_flag
)
def prediction_out(p: AIPrediction, lang: str = "ko") -> AIPredictionOut:
reason = p.reason_en if lang == "en" and p.reason_en else p.reason_ko
return AIPredictionOut(
matchId=p.match_id,
model=p.model, # type: ignore[arg-type]
outcome=p.outcome, # type: ignore[arg-type]
scoreA=p.score_a,
scoreB=p.score_b,
confidencePct=p.confidence_pct,
reasonShort=reason,
generatedAt=p.generated_at.date().isoformat() if p.generated_at else "",
)
def crowd_out(c: CrowdStats | None, match_id: str) -> CrowdStatsOut:
if c is None:
return CrowdStatsOut(matchId=match_id, total=0, teamAWin=0, draw=0, teamBWin=0)
return CrowdStatsOut(
matchId=match_id,
total=c.total,
teamAWin=c.team_a_win,
draw=c.draw,
teamBWin=c.team_b_win,
)
def my_prediction_out(p: UserPrediction, m: Match) -> MyPredictionOut:
"""유저 픽 1건을 경기 정보와 합쳐 직렬화 (내 지난 예측 목록용)."""
result = None
if m.result_outcome is not None:
result = MyResultOut(
scoreA=m.result_score_a or 0,
scoreB=m.result_score_b or 0,
outcome=m.result_outcome, # type: ignore[arg-type]
hitOutcome=p.outcome == m.result_outcome,
)
return MyPredictionOut(
matchId=p.match_id,
teamA=_team_a(m),
teamB=_team_b(m),
kickoffKst=kst_iso(m.kickoff_at),
outcome=p.outcome, # type: ignore[arg-type]
scoreA=p.score_a,
scoreB=p.score_b,
submittedAt=kst_iso(p.updated_at or p.created_at),
result=result,
)
def match_out(
m: Match,
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:
result = MatchResult(
scoreA=m.result_score_a or 0,
scoreB=m.result_score_b or 0,
outcome=m.result_outcome, # type: ignore[arg-type]
)
preds: list[AIPredictionOut] = []
if include_predictions:
# 모델 순서 고정: GPT, Claude, Gemini
order = {"GPT": 0, "Claude": 1, "Gemini": 2}
for p in sorted(m.predictions, key=lambda x: order.get(x.model, 9)):
preds.append(prediction_out(p, lang))
# status·phase 를 동일한 실시간 계산값으로 통일 — 워커 틱 지연과 무관하게 일관.
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),
teamB=_team_b(m),
kickoffKst=kst_iso(m.kickoff_at),
venue=m.venue,
opensAt=kst_iso(m.opens_at),
lockAt=kst_iso(m.lock_at),
status=phase,
phase=phase,
votable=is_votable(m),
votingOpen=is_open_for_voting(m, now),
hookText=m.hook_text,
result=result,
predictions=preds,
crowd=crowd_out(m.crowd, m.match_id),
extras=extras,
)

69
backend/app/main.py Normal file
View File

@ -0,0 +1,69 @@
"""FastAPI 앱 진입점 — API 서버.
시작 DB 초기화 + 시드. CORS 허용. 라우터 등록.
스케줄러는 별도 워커 컨테이너(worker.py)에서 실행한다(중복 방지).
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .database import init_db
from .routers import (
admin,
auth,
comments,
leaderboard,
matches,
predictions,
share,
songs,
standings,
visits,
)
from .scoring import load_scoring_data
from .seed import seed_if_empty
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("triplepick")
@asynccontextmanager
async def lifespan(app: FastAPI): # noqa: ANN201
load_scoring_data() # data/scoring.json → 배점·배제 대상
await init_db()
await seed_if_empty()
log.info("API ready")
yield
app = FastAPI(title="TriplePick API", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router)
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)
app.include_router(songs.router)
# 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다.
app.include_router(share.router)
@app.get("/api/health")
async def health() -> dict:
return {"ok": True, "service": "triplepick-api"}

386
backend/app/models.py Normal file
View File

@ -0,0 +1,386 @@
"""ORM 모델 — 기능정의서 데이터모델 + lib/types.ts 와 정합.
테이블:
- matches 경기 (/시각/투표윈도우/상태/결과)
- ai_predictions GPT/Claude/Gemini 예측 (경기 × 모델)
- crowd_stats 군중 투표 분포 (경기당 1, 원자적 증분)
- user_predictions 유저 (이메일 식별, 채점/알림 플래그 포함)
- user_points 유저별 누적 포인트 (채점 갱신, 이메일당 1)
"""
from __future__ import annotations
from datetime import date, datetime
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
Date,
DateTime,
ForeignKey,
Integer,
LargeBinary,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
# Outcome: TEAM_A_WIN | DRAW | TEAM_B_WIN
# ModelName: GPT | Claude | Gemini
# MatchStatus: scheduled | open | locked | live | finished
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")
# 팀 정보 (표시명/약식/코드/이모지) — 분리 컬럼으로 저장
team_a_name: Mapped[str] = mapped_column(String)
team_a_short: Mapped[str] = mapped_column(String)
team_a_code: Mapped[str] = mapped_column(String)
team_a_flag: Mapped[str] = mapped_column(String, default="")
team_b_name: Mapped[str] = mapped_column(String)
team_b_short: Mapped[str] = mapped_column(String)
team_b_code: Mapped[str] = mapped_column(String)
team_b_flag: Mapped[str] = mapped_column(String, default="")
venue: Mapped[str] = mapped_column(String, default="")
hook_text: Mapped[str] = mapped_column(String, default="")
# 모든 시각은 timezone-aware (UTC 저장, KST 환산은 표현 계층)
kickoff_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
opens_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
lock_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
status: Mapped[str] = mapped_column(String, default="scheduled")
# 결과 (입력 전 None)
result_score_a: Mapped[int | None] = mapped_column(Integer, nullable=True)
result_score_b: Mapped[int | None] = mapped_column(Integer, nullable=True)
result_outcome: Mapped[str | None] = mapped_column(String, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
results_emailed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
predictions: Mapped[list["AIPrediction"]] = relationship(
back_populates="match", cascade="all, delete-orphan"
)
crowd: Mapped["CrowdStats | None"] = relationship(
back_populates="match", uselist=False, cascade="all, delete-orphan"
)
class AIPrediction(Base):
__tablename__ = "ai_predictions"
__table_args__ = (UniqueConstraint("match_id", "model", name="uq_match_model"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
match_id: Mapped[str] = mapped_column(
ForeignKey("matches.match_id", ondelete="CASCADE")
)
model: Mapped[str] = mapped_column(String) # GPT | Claude | Gemini
outcome: Mapped[str] = mapped_column(String)
score_a: Mapped[int] = mapped_column(Integer)
score_b: Mapped[int] = mapped_column(Integer)
confidence_pct: Mapped[int] = mapped_column(Integer)
reason_ko: Mapped[str] = mapped_column(String, default="")
reason_en: Mapped[str] = mapped_column(String, default="")
generated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
# 실연동 LLM 출력인지 시드 데이터인지 구분 (재생성 제어용)
source: Mapped[str] = mapped_column(String, default="seed") # seed | llm
match: Mapped["Match"] = relationship(back_populates="predictions")
class CrowdStats(Base):
__tablename__ = "crowd_stats"
match_id: Mapped[str] = mapped_column(
ForeignKey("matches.match_id", ondelete="CASCADE"), primary_key=True
)
total: Mapped[int] = mapped_column(Integer, default=0)
team_a_win: Mapped[int] = mapped_column(Integer, default=0)
draw: Mapped[int] = mapped_column(Integer, default=0)
team_b_win: Mapped[int] = mapped_column(Integer, default=0)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
match: Mapped["Match"] = relationship(back_populates="crowd")
class UserPrediction(Base):
__tablename__ = "user_predictions"
__table_args__ = (
UniqueConstraint("match_id", "device_id", name="uq_match_device"),
# 같은 이메일은 같은 경기에 1픽만 (NULL 이메일은 다수 허용 — NULL 은 서로 구별).
# 신규 DB 에만 자동 적용. 기존 테이블은 앱 로직(이메일 우선 식별)으로 보장.
UniqueConstraint("match_id", "email", name="uq_match_email"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
match_id: Mapped[str] = mapped_column(
ForeignKey("matches.match_id", ondelete="CASCADE")
)
device_id: Mapped[str] = mapped_column(String) # 비로그인 식별 (클라 생성 uuid)
outcome: Mapped[str] = mapped_column(String)
score_a: Mapped[int] = mapped_column(Integer)
score_b: Mapped[int] = mapped_column(Integer)
email: Mapped[str | None] = mapped_column(String, nullable=True)
notify: Mapped[bool] = mapped_column(Boolean, default=False)
# 채점 (결과 입력 후 갱신)
points: Mapped[int | None] = mapped_column(Integer, nullable=True)
scored_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
notified: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class UserPoints(Base):
"""유저별 누적 포인트 — 채점(grade_prediction) 결과를 이메일 단위로 집계.
등급별 횟수 컬럼은 scoring.json key(score_*) 1:1 대응.
exact_count 리더보드 동점 보정 1순위(정확 스코어 횟수) 사용.
"""
__tablename__ = "user_points"
email: Mapped[str] = mapped_column(String, primary_key=True) # 소문자 정규화
total_points: Mapped[int] = mapped_column(Integer, default=0)
exact_count: Mapped[int] = mapped_column(Integer, default=0)
close_count: Mapped[int] = mapped_column(Integer, default=0)
outcome_count: Mapped[int] = mapped_column(Integer, default=0)
partial_count: Mapped[int] = mapped_column(Integer, default=0)
miss_count: Mapped[int] = mapped_column(Integer, default=0)
matches_played: Mapped[int] = mapped_column(Integer, default=0)
first_scored_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class PageVisit(Base):
"""페이지 방문 — 하루(KST)에 같은 기기(device_id)는 1회만 기록(순수 방문자 수).
일별 집계 = visit_date GROUP BY COUNT. 같은 재방문은 유니크 제약으로 무시.
"""
__tablename__ = "page_visits"
__table_args__ = (
UniqueConstraint("visit_date", "device_id", name="uq_visit_date_device"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
visit_date: Mapped[date] = mapped_column(Date, index=True) # KST 기준 날짜
device_id: Mapped[str] = mapped_column(String) # 비로그인 식별 (localStorage uuid)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class Comment(Base):
"""경기별 한마디(댓글). 완전 익명 — 인증/이메일 없음.
- author_hash: sha256(device_id). 원본 기기ID는 저장하지 않음(추적 불가). 쿨다운 식별용.
- nickname: 축구+코믹 한국어 5글자(기기 해시로 자동 배정, 기기당 고정).
- id(PK)·author_hash 내부용으로 API 응답에 노출하지 않음.
- 최신순 조회(created_at DESC) + limit/offset 페이징. is_hidden=True 조회 제외.
"""
__tablename__ = "comments"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
match_id: Mapped[str] = mapped_column(
ForeignKey("matches.match_id", ondelete="CASCADE"), index=True
)
author_hash: Mapped[str] = mapped_column(String, index=True) # sha256(device_id) — 원본 비저장
nickname: Mapped[str] = mapped_column(String) # 축구 코믹 한국어 5글자
body: Mapped[str] = mapped_column(String) # 길이 제한은 스키마(200자)에서
is_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), index=True
)
class FootballCache(Base):
"""축구 데이터 캐시 (API-Football 수집 결과) — 예측 프롬프트 조립용.
key 규칙:
team:{CODE} 베이스라인(·평균득실·클린시트·스쿼드)
h2h:{A}-{B} 상대전적
teamid:{CODE} 팀코드API팀ID 매핑
payload 가공된 압축 JSON. fetched_at 으로 캐시 신선도(TTL) 판단.
api·worker 공유하는 유일한 영속 저장소가 DB 여기에 둔다.
"""
__tablename__ = "football_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()
)
class User(Base):
"""카카오 소셜 로그인 사용자 (o2o-castad-backend 인증 이식).
투표(user_predictions) 기존 이메일 식별을 유지하고,
로그인 카카오 이메일을 투표 이메일로 자동 사용해 연결한다.
"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
kakao_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
user_uuid: Mapped[str] = mapped_column(String, unique=True, index=True)
email: Mapped[str | None] = mapped_column(String, nullable=True)
nickname: Mapped[str | None] = mapped_column(String, nullable=True)
profile_image_url: Mapped[str | None] = mapped_column(String, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
last_login_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class RefreshToken(Base):
"""리프레시 토큰 (해시 저장·회전 시 폐기). castad 와 동일한 rotation 방식."""
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
user_uuid: Mapped[str] = mapped_column(String, index=True)
token_hash: Mapped[str] = mapped_column(String, unique=True, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
is_revoked: Mapped[bool] = mapped_column(Boolean, default=False)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
user_agent: Mapped[str | None] = mapped_column(String, nullable=True)
ip_address: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class Song(Base):
"""오늘의 응원가 — 경기×팀 단위 Suno 생성 트랙.
가사·스타일은 LLM(경기 맥락 주입) 쓰고, 음원은 Suno(sunoapi.org) 생성.
Suno CDN URL 임시라 완성 워커가 음원을 SongAudio 내려받아 보존하고
tracks audioUrl 자체 서빙 경로(/api/songs/audio/...) 바꾼다.
"""
__tablename__ = "songs"
__table_args__ = (
UniqueConstraint("match_id", "team_code", name="uq_song_match_team"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
league: Mapped[str] = mapped_column(String, index=True)
date_kst: Mapped[date] = mapped_column(Date, index=True) # 경기일 (KST)
match_id: Mapped[str] = mapped_column(
ForeignKey("matches.match_id", ondelete="CASCADE"), index=True
)
team_code: Mapped[str] = mapped_column(String)
team_name: Mapped[str] = mapped_column(String)
title: Mapped[str] = mapped_column(String, default="")
lyrics: Mapped[str] = mapped_column(String, default="")
style: Mapped[str] = mapped_column(String, default="")
task_id: Mapped[str] = mapped_column(String, default="")
# generating(Suno 작업 대기) | complete | failed
status: Mapped[str] = mapped_column(String, default="generating", index=True)
error: Mapped[str] = mapped_column(String, default="")
attempts: Mapped[int] = mapped_column(Integer, default=0) # 실패 재시도 상한용
# 라인업 발표 후 생성했는지 — False 면 라인업 공개 시 자동 재생성(업그레이드) 대상
with_lineup: Mapped[bool] = mapped_column(Boolean, default=False)
# 완성 트랙 [{title, audioUrl, imageUrl, duration}] — 보통 생성당 2곡
tracks: Mapped[list] = mapped_column(JSON, default=list)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class SongAudio(Base):
"""응원가 음원 원본 — Suno CDN 임시 URL 만료 대비 DB 보존.
노출 트랙( 트랙) 저장한다. 업그레이드(라인업 반영 재생성)
같은 (song_id, track_idx) 행을 음원으로 교체.
"""
__tablename__ = "song_audio"
__table_args__ = (
UniqueConstraint("song_id", "track_idx", name="uq_song_audio_track"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
song_id: Mapped[int] = mapped_column(
ForeignKey("songs.id", ondelete="CASCADE"), index=True
)
track_idx: Mapped[int] = mapped_column(Integer, default=0)
mime: Mapped[str] = mapped_column(String, default="audio/mpeg")
size: Mapped[int] = mapped_column(Integer, default=0)
data: Mapped[bytes] = mapped_column(LargeBinary)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
class DataCache(Base):
"""야구(KBO/MLB) 부가 데이터 캐시 — 프리뷰·순위, API 응답·AI 프롬프트 조립용.
key 규칙:
preview:{match_id} 경기 프리뷰 요약(선발투수·시즌 상대전적)
standings:{league} 리그 순위표 (팀코드 순위·승률·최근5 )
"""
__tablename__ = "data_cache"
key: Mapped[str] = mapped_column(String, primary_key=True)
payload: Mapped[dict] = mapped_column(JSON, default=dict)
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)

View File

@ -0,0 +1,58 @@
"""전체 AI 예측 재생성 — 수동 트리거.
워커의 generate_ai_predictions() 즉시 1 실행한다(매일 00:05 자동 생성과 동일 로직).
미종료(결과 미입력) 경기 전부에 대해 GPT/Claude/Gemini API 호출하여
ai_predictions 덮어쓴다(source='llm'). 없는/실패한 모델은 건너뛴다.
DB 접속은 backend/.env(DB_* 또는 DATABASE_URL) 따른다.
실행:
# Docker (운영 DB로 1회 실행)
docker compose run --rm worker python -m app.regenerate_ai
# 로컬
cd backend && python -m app.regenerate_ai
"""
from __future__ import annotations
import asyncio
import logging
from sqlalchemy import func, select
from .database import SessionLocal, init_db
from .models import AIPrediction, Match
from .worker import generate_ai_predictions
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("triplepick.regen")
async def main() -> None:
await init_db() # 테이블 보장(idempotent)
async with SessionLocal() as db:
targets = (
await db.execute(
select(func.count())
.select_from(Match)
.where(Match.result_outcome.is_(None))
)
).scalar_one()
log.info("재생성 대상(미종료) 경기: %d", targets)
# 수동 트리거 = 전부 강제 재생성(덮어쓰기). 프롬프트 수정 후 갱신 등에 사용.
await generate_ai_predictions(only_missing=False)
async with SessionLocal() as db:
rows = (
await db.execute(
select(AIPrediction.model, func.count())
.where(AIPrediction.source == "llm")
.group_by(AIPrediction.model)
)
).all()
log.info("재생성 완료. 모델별 LLM 예측 수: %s", {m: c for m, c in rows})
if __name__ == "__main__":
asyncio.run(main())

View File

View File

@ -0,0 +1,203 @@
"""관리자 API — Bearer 토큰 인증. 경기/AI예측 upsert + 결과 입력(채점)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..domain import now_utc
from ..models import AIPrediction, CrowdStats, Match, PageVisit, UserPrediction
from ..schemas import AdminAIPredictionIn, AdminSetResultIn, DailyVisitOut, GenericOk
from ..services.grading import apply_result
router = APIRouter(prefix="/api/admin", tags=["admin"])
def require_admin(authorization: str = Header(default="")) -> None:
token = authorization.removeprefix("Bearer ").strip()
if not token or token != settings.admin_api_token:
raise HTTPException(status_code=401, detail="UNAUTHORIZED")
@router.post("/ai-predictions", response_model=GenericOk, dependencies=[Depends(require_admin)])
async def upsert_ai_prediction(
body: AdminAIPredictionIn, db: AsyncSession = Depends(get_db)
) -> GenericOk:
match = (
await db.execute(select(Match).where(Match.match_id == body.matchId))
).scalars().first()
if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
pred = (
await db.execute(
select(AIPrediction).where(
AIPrediction.match_id == body.matchId, AIPrediction.model == body.model
)
)
).scalars().first()
if pred is None:
pred = AIPrediction(match_id=body.matchId, model=body.model)
db.add(pred)
pred.outcome = body.outcome
pred.score_a = body.scoreA
pred.score_b = body.scoreB
pred.confidence_pct = body.confidencePct
pred.reason_ko = body.reasonKo
pred.reason_en = body.reasonEn
pred.generated_at = now_utc()
pred.source = "admin"
await db.commit()
return GenericOk(ok=True, detail="upserted")
@router.post("/result", response_model=GenericOk, dependencies=[Depends(require_admin)])
async def set_result(
body: AdminSetResultIn, db: AsyncSession = Depends(get_db)
) -> GenericOk:
match = (
await db.execute(select(Match).where(Match.match_id == body.matchId))
).scalars().first()
if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
graded = await apply_result(db, match, body.scoreA, body.scoreB)
# 결과 메일은 워커가 result_email_delay_minutes 경과 후 발송(@finished_at 기준).
return GenericOk(
ok=True,
detail="result set & graded",
extra={"gradedPicks": graded, "emailsSentBy": "worker"},
)
@router.get(
"/visits", response_model=list[DailyVisitOut], dependencies=[Depends(require_admin)]
)
async def daily_visits(db: AsyncSession = Depends(get_db)) -> list[DailyVisitOut]:
"""일별 순수 방문자 수 (최신순)."""
rows = (
await db.execute(
select(PageVisit.visit_date, func.count())
.group_by(PageVisit.visit_date)
.order_by(PageVisit.visit_date.desc())
)
).all()
return [DailyVisitOut(date=d.isoformat(), uniqueVisitors=c) for d, c in rows]
@router.post(
"/recount-crowd", response_model=GenericOk, dependencies=[Depends(require_admin)]
)
async def recount_crowd(db: AsyncSession = Depends(get_db)) -> GenericOk:
"""crowd_stats 를 user_predictions(진실 원천)에서 재집계.
과거 같은 outcome 재제출로 컬럼만 부풀려진(= 100% 초과) 행을 복구한다.
"""
# 경기별 outcome 분포 집계
rows = (
await db.execute(
select(
UserPrediction.match_id,
UserPrediction.outcome,
func.count(),
).group_by(UserPrediction.match_id, UserPrediction.outcome)
)
).all()
tally: dict[str, dict[str, int]] = {}
for match_id, outcome, c in rows:
t = tally.setdefault(
match_id, {"total": 0, "team_a_win": 0, "draw": 0, "team_b_win": 0}
)
col = {"TEAM_A_WIN": "team_a_win", "DRAW": "draw", "TEAM_B_WIN": "team_b_win"}[outcome]
t[col] += c
t["total"] += c
stats = (await db.execute(select(CrowdStats))).scalars().all()
fixed = 0
for s in stats:
t = tally.get(s.match_id, {"total": 0, "team_a_win": 0, "draw": 0, "team_b_win": 0})
if (
s.total != t["total"]
or s.team_a_win != t["team_a_win"]
or s.draw != t["draw"]
or s.team_b_win != t["team_b_win"]
):
s.total = t["total"]
s.team_a_win = t["team_a_win"]
s.draw = t["draw"]
s.team_b_win = t["team_b_win"]
fixed += 1
await db.commit()
return GenericOk(ok=True, detail="crowd recounted", extra={"matchesFixed": fixed})
# ── 운영 대시보드용 읽기 API (개인 관리용 — 이메일 원본 노출 주의) ──
@router.get("/matches/{match_id}/votes", dependencies=[Depends(require_admin)])
async def match_votes(
match_id: str, db: AsyncSession = Depends(get_db)
) -> list[dict]:
"""경기별 투표 전체 — 누가(이메일 원본) 어떻게 찍었고 몇 점 받았는지."""
rows = (
await db.execute(
select(UserPrediction)
.where(UserPrediction.match_id == match_id)
.order_by(UserPrediction.created_at.desc())
)
).scalars().all()
return [
{
"email": r.email,
"outcome": r.outcome,
"scoreA": r.score_a,
"scoreB": r.score_b,
"points": r.points,
"notify": r.notify,
"createdAt": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
@router.get("/leaderboard", dependencies=[Depends(require_admin)])
async def full_leaderboard(
league: str = "", db: AsyncSession = Depends(get_db)
) -> list[dict]:
"""이메일 원본 랭킹 — 공개 리더보드와 같은 집계(채점된 픽 재집계).
배제 대상(운영진 도메인) 표시하되 excluded 플래그로 구분."""
from ..scoring import is_excluded
q = (
select(UserPrediction, Match)
.join(Match, Match.match_id == UserPrediction.match_id)
.where(
Match.result_outcome.is_not(None),
UserPrediction.points.is_not(None),
UserPrediction.email.is_not(None),
)
)
if league:
q = q.where(Match.league == league)
picks = (await db.execute(q)).all()
agg: dict[str, list[int]] = {}
for pk, m in picks:
row = agg.setdefault(pk.email, [0, 0, 0])
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 = sorted(agg.items(), key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2]))
return [
{
"rank": i + 1,
"email": e,
"totalPoints": v[0],
"exactCount": v[1],
"matchesPlayed": v[2],
"excluded": is_excluded(e),
}
for i, (e, v) in enumerate(ranked)
]

View File

@ -0,0 +1,76 @@
"""인증 API — 카카오 로그인·토큰 갱신·로그아웃·내 정보 (castad 이식).
콜백은 백엔드가 받아 JWT 발급 프론트로 `#access_token=..&refresh_token=..`
해시 프래그먼트로 리다이렉트한다 (쿼리스트링이 아니라 해시 서버 로그·Referer
토큰이 남지 않게 castad 방식에서 단계 보강).
"""
from __future__ import annotations
from typing import Optional
from fastapi import APIRouter, Depends, Header, Request, status
from fastapi.responses import RedirectResponse, Response
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..models import User
from ..services import auth_kakao
router = APIRouter(prefix="/api/auth", tags=["auth"])
class RefreshIn(BaseModel):
refreshToken: str
def _client_ip(request: Request) -> str | None:
fwd = request.headers.get("X-Forwarded-For")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else None
@router.get("/kakao/login")
async def kakao_login_url() -> dict:
"""카카오 인증 페이지 URL — 프론트가 이 URL 로 이동시킨다."""
return {"authUrl": auth_kakao.kakao_authorization_url()}
@router.get("/kakao/callback")
async def kakao_callback(
request: Request,
code: str,
db: AsyncSession = Depends(get_db),
user_agent: Optional[str] = Header(None, alias="User-Agent"),
) -> RedirectResponse:
"""카카오 콜백 — 인가 코드로 JWT 발급 후 프론트로 리다이렉트."""
result = await auth_kakao.kakao_login(
db, code, user_agent=user_agent, ip_address=_client_ip(request)
)
redirect = (
f"{settings.public_origin}/"
f"#access_token={result['access_token']}"
f"&refresh_token={result['refresh_token']}"
)
return RedirectResponse(url=redirect, status_code=302)
@router.post("/refresh")
async def refresh(body: RefreshIn, db: AsyncSession = Depends(get_db)) -> dict:
"""리프레시 토큰 회전 — 새 access/refresh 발급, 기존 refresh 폐기."""
return await auth_kakao.refresh_tokens(db, body.refreshToken)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(body: RefreshIn, db: AsyncSession = Depends(get_db)) -> Response:
"""리프레시 토큰 폐기. access 만료 후엔 재갱신 불가."""
await auth_kakao.logout(db, body.refreshToken)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/me")
async def me(user: User = Depends(auth_kakao.get_current_user)) -> dict:
"""현재 로그인 사용자 정보."""
return auth_kakao.user_out(user)

View File

@ -0,0 +1,166 @@
"""경기별 한마디(댓글) API — 완전 익명. 조회(최신순·페이징) · 작성(검증·쿨다운).
신원/인증 없음:
- author_hash = sha256(device_id). 기기 원본 ID는 저장하지 않음(추적 불가). 쿨다운 전용.
- nickname: 축구+코믹 한국어 5글자. **세션 단위 발급** 클라가 sessionStorage 캐싱해
세션 동안 같은 닉을 재사용, /창을 닫았다 켜거나 시크릿이면 새로 발급.
발급은 서버 단어풀에서만(욕설/사칭 방지) 작성 시에도 검증.
- id(PK)·author_hash 내부용으로 API 응답에 노출하지 않음.
- 방어(기본): body 길이 제한(스키마 200) · 같은 기기 연속 작성 쿨다운 · is_hidden 제외.
- 테이블은 범용(모든 경기)이고, 노출 경기 제한은 프론트가 담당.
"""
from __future__ import annotations
import hashlib
import random
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import Comment, Match
from ..schemas import CommentIn, CommentListOut, CommentNicknameOut, CommentOut
router = APIRouter(prefix="/api/matches", tags=["comments"])
# 같은 기기 연속 작성 최소 간격(초) — 도배 방지(기본 방어).
COMMENT_COOLDOWN_SECONDS = 5
# 닉네임 조합: 2글자(코믹 수식) + 3글자(스포츠 역할) = 항상 한국어 5글자.
# 리그별 풀 — 축구(월드컵) / 야구(KBO·MLB). 각 40 × 30 = 1200가지.
_NICK_A = [
"잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕",
"국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재",
"야수", "괴물", "폭격", "강철", "무적", "질풍", "돌풍", "불꽃", "발광", "분노",
"음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면", "치킨", "출근",
]
_NICK_B = [
"드리블", "골사냥", "해결사", "종결자", "자판기", "수비수", "골키퍼", "패스왕",
"헤더왕", "골게터", "오버랩", "프리킥", "발재간", "삽질러", "똥볼러", "돌파왕",
"압박왕", "태클왕", "중거리", "발리슛", "빌드업", "백패스", "자책골", "골기계",
"어시왕", "역습왕", "수문장", "골부자", "골가뭄", "발연기",
]
_NICK_A_BB = [
"잔디", "벤치", "후보", "똥손", "노룩", "멘붕", "동네", "주말", "폭발", "광속",
"진지", "번개", "백수", "천재", "괴물", "폭격", "강철", "무적", "질풍", "돌풍",
"불꽃", "분노", "음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면",
"치킨", "출근", "직관", "치맥", "응원", "국대", "신인", "은퇴", "각성", "꾸준",
]
_NICK_B_BB = [
"홈런왕", "도루왕", "타격왕", "안타왕", "삼진왕", "수비왕", "번트왕", "대타왕",
"역전타", "끝내기", "결승타", "병살타", "유격수", "외야수", "내야수", "마무리",
"셋업맨", "불펜왕", "승리조", "강속구", "커브왕", "직구왕", "변화구", "풀스윙",
"헛스윙", "배트맨", "만루왕", "출루왕", "도루자", "홈스틸",
]
def _author_hash(device_id: str) -> str:
"""기기 원본 ID는 저장하지 않고 해시만 사용(추적 불가)."""
return hashlib.sha256(device_id.encode("utf-8")).hexdigest()
# 검증은 두 리그 풀 합집합 기준(발급 리그와 작성 리그가 달라도 정상 닉이면 통과)
_NICK_A_SET = set(_NICK_A) | set(_NICK_A_BB)
_NICK_B_SET = set(_NICK_B) | set(_NICK_B_BB)
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)
def _valid_nickname(n: str) -> bool:
"""서버 단어풀에서 나온 정상 닉인지 검증(앞2 + 뒤3)."""
n = (n or "").strip()
return len(n) == 5 and n[:2] in _NICK_A_SET and n[2:] in _NICK_B_SET
@router.get("/{match_id}/comments/nickname", response_model=CommentNicknameOut)
async def issue_nickname(match_id: str) -> CommentNicknameOut:
"""세션 시작 시 랜덤 닉 발급(클라가 sessionStorage 에 캐싱해 재사용)."""
return CommentNicknameOut(nickname=_random_nickname(_is_baseball(match_id)))
def _out(c: Comment) -> CommentOut:
return CommentOut(
nickname=c.nickname,
body=c.body,
createdAt=c.created_at.isoformat(),
)
@router.get("/{match_id}/comments", response_model=CommentListOut)
async def list_comments(
match_id: str,
limit: int = Query(3, ge=1, le=50),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
) -> CommentListOut:
base = (
select(Comment)
.where(Comment.match_id == match_id, Comment.is_hidden.is_(False))
)
total = (
await db.execute(
select(func.count()).select_from(base.subquery())
)
).scalar() or 0
rows = (
await db.execute(
base.order_by(Comment.created_at.desc(), Comment.id.desc())
.limit(limit)
.offset(offset)
)
).scalars().all()
return CommentListOut(items=[_out(c) for c in rows], total=total)
@router.post("/{match_id}/comments", response_model=CommentOut)
async def create_comment(
match_id: str, body: CommentIn, db: AsyncSession = Depends(get_db)
) -> CommentOut:
match = (
await db.execute(select(Match).where(Match.match_id == match_id))
).scalars().first()
if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
author_hash = _author_hash(body.deviceId)
# 쿨다운: 같은 기기(해시)의 가장 최근 작성과의 간격 확인
last_at = (
await db.execute(
select(func.max(Comment.created_at)).where(
Comment.author_hash == author_hash
)
)
).scalar()
if last_at is not None:
elapsed = datetime.now(timezone.utc) - last_at
if elapsed < timedelta(seconds=COMMENT_COOLDOWN_SECONDS):
raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN")
# 세션 캐싱된 닉을 사용하되, 풀에 없는(조작된) 값이면 새 랜덤으로 대체
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,
nickname=nickname,
body=body.body,
)
db.add(comment)
await db.commit()
await db.refresh(comment)
return _out(comment)

View File

@ -0,0 +1,165 @@
"""리더보드 API — 누적 포인트 1위 (docs/SCORING.md §3 랭킹 규칙).
user_points 테이블(채점 누적 갱신) 기준 랭킹.
1 정렬: 누적 포인트. 동점: 정확스코어 횟수 적중률 참여수 최초도달.
주최측/임직원 배제(P1). 이메일은 마스킹하여 노출.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..models import Match, UserPoints, UserPrediction
from ..scoring import is_excluded, score_prediction
from ..schemas import (
AILeaderboardOut,
AIStandingOut,
LeaderboardOut,
StandingOut,
)
router = APIRouter(prefix="/api/leaderboard", tags=["leaderboard"])
def _mask(email: str) -> str:
name, _, domain = email.partition("@")
head = name[:2] if len(name) >= 2 else name
return f"{head}{'*' * max(1, len(name) - 2)}@{domain}"
@router.get("", response_model=LeaderboardOut)
async def leaderboard(
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()
ranked = sorted(
(r for r in rows if not is_excluded(r.email)),
key=lambda r: (
-r.total_points,
-r.exact_count,
-(r.total_points / r.matches_played if r.matches_played else 0),
-r.matches_played,
r.first_scored_at.timestamp() if r.first_scored_at else 0,
),
)
scored_matches = (
await db.execute(
select(func.count())
.select_from(Match)
.where(Match.result_outcome.is_not(None))
)
).scalar() or 0
standings = [
StandingOut(
rank=i + 1,
emailMasked=_mask(r.email),
totalPoints=r.total_points,
exactCount=r.exact_count,
matchesPlayed=r.matches_played,
)
for i, r in enumerate(ranked[:limit])
]
return LeaderboardOut(standings=standings, scoredMatches=scored_matches)
@router.get("/ai", response_model=AILeaderboardOut)
async def ai_leaderboard(
league: str = Query("", description="wc | kbo | mlb — 빈값이면 전체"),
db: AsyncSession = Depends(get_db),
) -> AILeaderboardOut:
"""AI 모델 누적 랭킹 — 종료된 경기의 AI 예측을 유저와 동일한 배점으로 채점·합산.
별도 누적 테이블 없이 조회 종료 경기 전수 재계산 결과/배점 변동에
항상 일관. (모델 3 × 경기 수라 비용 무시 가능.)
"""
q = (
select(Match)
.where(Match.result_outcome.is_not(None))
.options(selectinload(Match.predictions))
)
if league:
q = q.where(Match.league == league)
matches = (await db.execute(q)).scalars().all()
# model → [points, exact_count, matches_played]
agg: dict[str, list[int]] = {}
for m in matches:
bb = m.league in ("kbo", "mlb")
for p in m.predictions:
pts = score_prediction(
p.score_a, p.score_b, m.result_score_a, m.result_score_b, 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
row[2] += 1
order = {"GPT": 0, "Claude": 1, "Gemini": 2}
ranked = sorted(
agg.items(),
key=lambda kv: (-kv[1][0], -kv[1][1], -kv[1][2], order.get(kv[0], 9)),
)
standings = [
AIStandingOut(
rank=i + 1,
model=model, # type: ignore[arg-type]
totalPoints=pts,
exactCount=exact,
matchesPlayed=played,
)
for i, (model, (pts, exact, played)) in enumerate(ranked)
]
return AILeaderboardOut(standings=standings, scoredMatches=len(matches))

View File

@ -0,0 +1,70 @@
"""공개 읽기 API — 경기 목록 / 경기 상세 (AI예측 + crowd 포함)."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
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"])
@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]:
q = (
select(Match)
.options(selectinload(Match.predictions), selectinload(Match.crowd))
.order_by(Match.kickoff_at)
)
if league:
q = q.where(Match.league == league)
rows = (await db.execute(q)).scalars().all()
extras = await get_extras(db, rows)
return [match_out(m, lang, extras=extras.get(m.match_id)) for m in rows]
@router.get("/{match_id}", response_model=MatchOut)
async def get_match(
match_id: str,
lang: str = Query("ko"),
db: AsyncSession = Depends(get_db),
) -> MatchOut:
m = (
await db.execute(
select(Match)
.where(Match.match_id == match_id)
.options(selectinload(Match.predictions), selectinload(Match.crowd))
)
).scalars().first()
if not m:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
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, mls=ESPN). 15초 TTL 캐시."""
m = await db.get(Match, match_id)
if not m:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
if m.league == "mls":
from ..services.mls_espn import fetch_live_mls
return await fetch_live_mls(m)
if m.league not in ("kbo", "mlb"):
return {"available": False}
return await fetch_live(m)

View File

@ -0,0 +1,169 @@
"""픽 제출 API — 검증 · 중복방지(1회 수정) · crowd 원자적 증분 · 매칭 모델 계산."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..database import get_db
from ..domain import (
compute_phase,
crowd_out,
is_open_for_voting,
is_votable,
my_prediction_out,
)
from ..models import AIPrediction, CrowdStats, Match, UserPrediction
from ..scoring import outcome_of
from ..schemas import MyPredictionOut, SubmitPredictionIn, SubmitPredictionOut
router = APIRouter(prefix="/api/predictions", tags=["predictions"])
_COL = {"TEAM_A_WIN": "team_a_win", "DRAW": "draw", "TEAM_B_WIN": "team_b_win"}
async def _adjust_crowd(
db: AsyncSession, match_id: str, *, add: str | None, remove: str | None
) -> None:
"""crowd_stats 원자적 증분/보정 (Postgres UPDATE).
같은 outcome 재제출(add == remove) 분포 변화가 없으므로 no-op.
이를 보정하지 않으면 컬럼만 +1 되어 a+draw+b > total 100% 초과.
"""
if add == remove: # 결과 변동 없음(스코어만 수정 등) → 분포 그대로
return
values: dict = {}
if add:
col = _COL[add]
values[col] = CrowdStats.__table__.c[col] + 1
if not remove: # 신규 제출이면 total +1
values["total"] = CrowdStats.total + 1
if remove:
col = _COL[remove]
values[col] = CrowdStats.__table__.c[col] - 1
if values:
await db.execute(
update(CrowdStats).where(CrowdStats.match_id == match_id).values(**values)
)
@router.get("/mine", response_model=list[MyPredictionOut])
async def my_predictions(
email: str, db: AsyncSession = Depends(get_db)
) -> list[MyPredictionOut]:
"""이메일 기준 내 지난 예측 목록 (최신 제출순). 로그인 없음 — 이메일이 신원."""
e = email.strip().lower()
if not e:
return []
rows = (
await db.execute(
select(UserPrediction, Match)
.join(Match, Match.match_id == UserPrediction.match_id)
.where(func.lower(UserPrediction.email) == e)
.order_by(UserPrediction.updated_at.desc())
)
).all()
return [my_prediction_out(up, m) for up, m in rows]
@router.post("", response_model=SubmitPredictionOut)
async def submit_prediction(
body: SubmitPredictionIn, db: AsyncSession = Depends(get_db)
) -> SubmitPredictionOut:
match = (
await db.execute(
select(Match)
.where(Match.match_id == body.matchId)
.options(selectinload(Match.predictions))
)
).scalars().first()
if not match:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
if not is_votable(match):
raise HTTPException(status_code=403, detail="MATCH_NOT_VOTABLE")
if not is_open_for_voting(match):
# 종료 사유 정밀 구분: 종료 / 경기중 / 투표종료 / 오픈전
phase = compute_phase(match)
detail = {
"finished": "MATCH_FINISHED", # 결과 입력됨
"live": "MATCH_LIVE", # 경기중
"locked": "MATCH_LOCKED", # 투표 종료(킥오프 1h 전)
"scheduled": "MATCH_NOT_OPEN", # 아직 오픈 전
}.get(phase, "MATCH_LOCKED")
code = 409 if phase == "finished" else 423
raise HTTPException(status_code=code, detail=detail)
# outcome 은 스코어에서 도출 (입력과 불일치해도 스코어 기준으로 정규화)
outcome = outcome_of(body.scoreA, body.scoreB)
email = str(body.email).strip().lower() if body.email else None
# 중복 식별: ① 이메일(있으면 1차 신원 — 다른 기기여도 동일인) ② 기기(deviceId)
existing = None
if email:
existing = (
await db.execute(
select(UserPrediction).where(
UserPrediction.match_id == body.matchId,
func.lower(UserPrediction.email) == email,
)
)
).scalars().first()
if existing is None:
existing = (
await db.execute(
select(UserPrediction).where(
UserPrediction.match_id == body.matchId,
UserPrediction.device_id == body.deviceId,
)
)
).scalars().first()
if existing:
# 마감 전 1회 수정: 분포 보정 (이전 outcome 제거, 새 outcome 추가)
await _adjust_crowd(db, body.matchId, add=outcome, remove=existing.outcome)
existing.outcome = outcome
existing.score_a = body.scoreA
existing.score_b = body.scoreB
existing.device_id = body.deviceId # 최신 제출 기기로 갱신
if email:
existing.email = email
existing.notify = body.notify
pred = existing
else:
await _adjust_crowd(db, body.matchId, add=outcome, remove=None)
pred = UserPrediction(
match_id=body.matchId,
device_id=body.deviceId,
outcome=outcome,
score_a=body.scoreA,
score_b=body.scoreB,
email=email,
notify=body.notify,
)
db.add(pred)
await db.commit()
# 매칭 AI 모델 (같은 outcome) + 정확스코어 일치 여부
matched: list[str] = []
exact = False
for p in match.predictions:
if p.outcome == outcome:
matched.append(p.model)
if p.score_a == body.scoreA and p.score_b == body.scoreB:
exact = True
crowd = (
await db.execute(
select(CrowdStats).where(CrowdStats.match_id == body.matchId)
)
).scalars().first()
return SubmitPredictionOut(
ok=True,
predictionId=f"{body.matchId}_{body.deviceId}",
matchedModels=matched, # type: ignore[arg-type]
exactMatch=exact,
crowd=crowd_out(crowd, body.matchId),
)

View File

@ -0,0 +1,191 @@
"""공유 미리보기(OG) 프리렌더 — 카카오톡·트위터 등 크롤러 전용.
SPA(index.html) og:image 정적이라 모든 /match/:id 공유가 같은 썸네일로
나온다(크롤러는 JS 미실행). 그래서 nginx '크롤러 UA' /match/:id 요청만
라우트로 보내고, 여기서 경기별 og:image·og:title 박은 HTML 반환한다.
일반 사용자는 nginx 그대로 SPA 보내므로 영향 없음.
경기별 커스텀 이미지는 OG_IMAGES 등록된 매치업만 적용되고, 나머지는
기본 썸네일로 폴백한다. 이미지 파일은 frontend/public/assets/og/ 둔다.
"""
from __future__ import annotations
import html
from fastapi import APIRouter, Depends
from fastapi.responses import HTMLResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..models import Match, Song
from ..schedule_data import TEAMS
router = APIRouter()
# 경기별 커스텀 OG 이미지: 두 팀 코드(순서무관) → /assets/og/ 하위 파일명.
# 파일을 frontend/public/assets/og/ 에 두고 아래에 등록하면 적용된다.
# 미등록 매치업은 DEFAULT_OG 로 폴백.
# 예) frozenset({"KOR", "MEX"}): "kor_mex.png",
OG_IMAGES: dict[frozenset[str], str] = {
# 한국-멕시코전 공유 카드 — 가로 1200x630 합성본(앱 배너는 세로 원본 kor_mex.png).
frozenset({"KOR", "MEX"}): "kor_mex_card.png",
}
DEFAULT_OG = "/assets/bi/og-image.png"
OG_DIR = "/assets/og/"
# 전체 일정(타조 포함) 팀 한글명 — 타이틀용. schedule_data.TEAMS 우선, 없으면 코드.
_KOR_NAME = {code: t["shortName"] for code, t in TEAMS.items()}
def _parse_codes(match_id: str) -> tuple[str | None, str | None]:
"""경기 ID '{조}_{팀A}_{팀B}_{YYYYMMDD}' 에서 두 팀 코드 추출."""
parts = match_id.split("_")
if len(parts) >= 3:
return parts[1], parts[2]
return None, None
def _team_label(code: str | None) -> str:
if not code:
return ""
return _KOR_NAME.get(code, code)
@router.get("/song/{match_id}/{team_code}", response_class=HTMLResponse)
async def song_share(
match_id: str, team_code: str, db: AsyncSession = Depends(get_db)
) -> HTMLResponse:
"""응원가 공유 OG — '8월 28일 OO 응원가' 제목 + Suno 앨범아트 썸네일.
제목의 날짜는 제작일(경기일 KST) 매일 갱신되는 콘텐츠임을 드러낸다.
"""
s = (
await db.execute(
select(Song).where(Song.match_id == match_id, Song.team_code == team_code)
)
).scalar_one_or_none()
team = (s.team_name if s else "") or _team_label(team_code) or team_code
song_title = (s.title if s else "") or f"{team} 응원가"
day = f"{s.date_kst.month}{s.date_kst.day}" if s and s.date_kst else "오늘의"
title = f"{day} {team} 응원가 — {song_title}"
desc = (
"AI가 경기 당일(순위·선발 라인업·전날 결과)을 반영해 매일 새로 만드는 응원가. "
"듣고 나서 GPT·Claude·Gemini와 승부예측도 겨뤄보세요!"
)
origin = settings.public_origin.rstrip("/")
# 썸네일: 합성 커버(Suno 아트 + 팀 로고, 1200x630) → 곡 없으면 기본 카드
if s and s.tracks:
img_url = (
f"{origin}/api/songs/cover/{html.escape(match_id)}/"
f"{html.escape(team_code)}?v={(s.task_id or '')[:8]}"
)
else:
img_url = f"{origin}{DEFAULT_OG}?v=2"
page_url = f"{origin}/song/{html.escape(match_id)}/{html.escape(team_code)}"
t = html.escape(title)
d = html.escape(desc)
page = f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>{t}</title>
<meta name="description" content="{d}" />
<meta property="og:type" content="music.song" />
<meta property="og:site_name" content="TriplePick" />
<meta property="og:title" content="{t}" />
<meta property="og:description" content="{d}" />
<meta property="og:url" content="{page_url}" />
<meta property="og:image" content="{img_url}" />
<meta property="og:image:secure_url" content="{img_url}" />
<meta property="og:locale" content="ko_KR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{t}" />
<meta name="twitter:description" content="{d}" />
<meta name="twitter:image" content="{img_url}" />
<link rel="canonical" href="{page_url}" />
</head>
<body><a href="{page_url}">TriplePick {t}</a></body>
</html>"""
return HTMLResponse(page)
@router.get("/match/{match_id}", response_class=HTMLResponse)
async def match_share(match_id: str, db: AsyncSession = Depends(get_db)) -> HTMLResponse:
a, b = _parse_codes(match_id)
image = DEFAULT_OG
is_custom = False
if a and b:
key = frozenset({a, b})
if key in OG_IMAGES:
image = OG_DIR + OG_IMAGES[key]
is_custom = True
# 팀명: DB 의 경기 행이 있으면 한글 short 명(클럽 리그 포함),
# 없으면 월드컵 정적 테이블 → 코드 순으로 폴백.
m = (
await db.execute(select(Match).where(Match.match_id == match_id))
).scalar_one_or_none()
if m:
la, lb = m.team_a_short, m.team_b_short
a, b = m.team_a_code, m.team_b_code
else:
la, lb = _team_label(a), _team_label(b)
# 한국은 항상 왼쪽으로 표기(프론트 화면 규칙과 동일).
if b == "KOR" and a != "KOR":
la, lb = lb, la
league_ko = {"kbo": "KBO", "mlb": "MLB", "mls": "MLS"}.get(m.league if m else "", "")
if la and lb:
tag = f"{league_ko} " if league_ko else ""
title = f"TriplePick — {tag}{la} vs {lb} AI 승부예측"
else:
title = "TriplePick — AI 스포츠 승부예측 (KBO·MLB·MLS)"
desc = (
"GPT·Claude·Gemini 3대 AI가 이 경기를 서로 다르게 예측합니다. "
"당신의 픽을 찍고 AI와 겨뤄보세요."
)
origin = settings.public_origin.rstrip("/")
page_url = f"{origin}/match/{html.escape(match_id)}"
# ?v= 캐시버스터: 메신저가 과거 실패/구버전 썸네일을 캐시한 경우 재수집 유도
img_url = f"{origin}{image}?v=2"
t = html.escape(title)
d = html.escape(desc)
# 기본 썸네일만 규격이 1200x630 으로 확정 → width/height 명시.
# 커스텀 이미지는 규격이 제각각이라 태그를 빼고 크롤러가 직접 측정하게 둔다.
dims = (
""
if is_custom
else '<meta property="og:image:width" content="1200" />\n'
'<meta property="og:image:height" content="630" />\n'
)
page = f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<title>{t}</title>
<meta name="description" content="{d}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="TriplePick" />
<meta property="og:title" content="{t}" />
<meta property="og:description" content="{d}" />
<meta property="og:url" content="{page_url}" />
<meta property="og:image" content="{img_url}" />
<meta property="og:image:secure_url" content="{img_url}" />
<meta property="og:image:type" content="image/png" />
{dims}<meta property="og:locale" content="ko_KR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="{t}" />
<meta name="twitter:description" content="{d}" />
<meta name="twitter:image" content="{img_url}" />
<link rel="canonical" href="{page_url}" />
</head>
<body><a href="{page_url}">TriplePick</a></body>
</html>"""
return HTMLResponse(page)

View File

@ -0,0 +1,226 @@
"""오늘의 응원가 API — 조회(공개) · 커버 합성 · Suno 콜백 싱크 · 관리자 수동 생성."""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..models import Match, Song, SongAudio
from ..services.songs import persist_audio, poll_generating, start_due_songs
from .admin import require_admin
router = APIRouter(prefix="/api/songs", tags=["songs"])
log = logging.getLogger("triplepick.songs")
KST = timezone(timedelta(hours=9))
def _cover_path(s: Song) -> str:
# v= 캐시버스터: 업그레이드(재생성)로 task 가 바뀌면 새 커버로
return f"/api/songs/cover/{s.match_id}/{s.team_code}?v={(s.task_id or '')[:8]}"
def _song_out(s: Song) -> dict:
# Suno 는 생성당 2트랙을 주지만 노출은 팀당 1곡만 (첫 트랙).
# 커버는 Suno 아트 + 팀 로고 합성본으로 교체.
tracks = [
{**t, "imageUrl": _cover_path(s)} for t in (s.tracks or [])[:1]
]
return {
"matchId": s.match_id,
"league": s.league,
"teamCode": s.team_code,
"teamName": s.team_name,
"title": s.title,
"lyrics": s.lyrics,
# 제작일(경기일 KST) — 공유·플레이어 문구의 "8월 28일 OO 응원가" 표기용
"dateKst": s.date_kst.isoformat() if s.date_kst else None,
"tracks": tracks,
}
# ── 커버 합성 (Suno 아트 배경 + 팀 로고 중앙) ──────────────────
_COVER_W, _COVER_H = 1200, 630 # OG 권장 규격 — 플레이어 썸네일은 중앙 크롭
_cover_cache: dict[str, bytes] = {}
async def _fetch_bytes(url: str) -> bytes | None:
import httpx
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
r = await c.get(url)
r.raise_for_status()
return r.content
except Exception as e: # noqa: BLE001
log.warning("cover 소스 다운로드 실패 %s: %s", url, e)
return None
def _logo_url(s: Song) -> str | None:
if s.league == "kbo":
return f"{settings.internal_frontend_base}/assets/teams/kbo/{s.team_code.lower()}.png"
if s.league == "mlb":
from ..teams_baseball import MLB_TEAMS
mlb_id = (MLB_TEAMS.get(s.team_code) or {}).get("mlb_id")
if mlb_id:
return f"https://midfield.mlbstatic.com/v1/team/{mlb_id}/spots/500"
return None
def _compose(logo_bytes: bytes | None) -> bytes:
"""다크 단색 배경 + 팀 로고 최대 크기 중앙 배치 (배경 아트 없음)."""
import io
from PIL import Image
canvas = Image.new("RGB", (_COVER_W, _COVER_H), (16, 20, 26))
if logo_bytes:
try:
logo = Image.open(io.BytesIO(logo_bytes)).convert("RGBA")
# 캔버스에 여백 8%만 남기고 최대로 채운다
max_h = round(_COVER_H * 0.84)
max_w = round(_COVER_W * 0.84)
scale = min(max_w / logo.width, max_h / logo.height)
w, h = round(logo.width * scale), round(logo.height * scale)
logo = logo.resize((w, h))
canvas.paste(logo, ((_COVER_W - w) // 2, (_COVER_H - h) // 2), logo)
except Exception as e: # noqa: BLE001
log.warning("cover 로고 처리 실패: %s", e)
out = io.BytesIO()
canvas.save(out, "JPEG", quality=88)
return out.getvalue()
@router.get("/cover/{match_id}/{team_code}")
async def song_cover(
match_id: str, team_code: str, db: AsyncSession = Depends(get_db)
) -> Response:
s = (
await db.execute(
select(Song).where(Song.match_id == match_id, Song.team_code == team_code)
)
).scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="SONG_NOT_FOUND")
key = f"{match_id}:{team_code}"
if key not in _cover_cache:
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(
content=_cover_cache[key],
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"},
)
@router.get("/today")
async def today_songs(league: str = "", db: AsyncSession = Depends(get_db)) -> list[dict]:
"""오늘의 응원가 목록 — league 미지정 시 전 리그(kbo+mlb)."""
today = datetime.now(KST).date()
# 업그레이드(라인업 반영 재생성) 중인 곡도 이전 트랙을 계속 서빙 — 재생 공백 없음
conds = [Song.date_kst == today, Song.status.in_(("complete", "generating"))]
if league:
conds.append(Song.league == league)
rows = (
await db.execute(
select(Song).where(*conds).order_by(Song.match_id, Song.team_code)
)
).scalars().all()
return [_song_out(s) for s in rows if s.tracks]
@router.get("/match/{match_id}")
async def match_songs(match_id: str, db: AsyncSession = Depends(get_db)) -> list[dict]:
"""경기별 응원가 — 날짜 무관. 종료된 경기 상세에서도 그날 곡을 노출한다."""
rows = (
await db.execute(
select(Song)
.where(
Song.match_id == match_id,
Song.status.in_(("complete", "generating")),
)
.order_by(Song.team_code)
)
).scalars().all()
return [_song_out(s) for s in rows if s.tracks]
@router.get("/audio/{song_id}/{track_idx}")
async def song_audio_bytes(
song_id: int,
track_idx: int,
request: Request,
db: AsyncSession = Depends(get_db),
) -> Response:
"""DB 보존 음원 서빙 — Range 지원(재생 위치 탐색용). v= 쿼리로 캐시버스트."""
row = (
await db.execute(
select(SongAudio).where(
SongAudio.song_id == song_id, SongAudio.track_idx == track_idx
)
)
).scalar_one_or_none()
if not row:
raise HTTPException(status_code=404, detail="AUDIO_NOT_FOUND")
data, total = row.data, len(row.data)
headers = {
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=31536000, immutable",
}
m = re.fullmatch(r"bytes=(\d*)-(\d*)", request.headers.get("range") or "")
if m and (m.group(1) or m.group(2)):
if m.group(1):
start = int(m.group(1))
end = int(m.group(2)) if m.group(2) else total - 1
else: # suffix range: bytes=-N (마지막 N바이트)
start = max(0, total - int(m.group(2)))
end = total - 1
if start >= total or start > end:
return Response(
status_code=416, headers={"Content-Range": f"bytes */{total}"}
)
end = min(end, total - 1)
headers["Content-Range"] = f"bytes {start}-{end}/{total}"
return Response(
content=data[start : end + 1],
status_code=206,
media_type=row.mime,
headers=headers,
)
return Response(content=data, media_type=row.mime, headers=headers)
@router.post("/callback")
async def suno_callback(request: Request) -> dict:
"""Suno 게이트웨이 callBackUrl 싱크대 — 완료 감지는 워커 폴링이 담당."""
try:
await request.json()
except Exception: # noqa: BLE001 — 본문 형식 무관
pass
return {"ok": True}
@router.post("/generate", dependencies=[Depends(require_admin)])
async def force_generate(db: AsyncSession = Depends(get_db)) -> dict:
"""관리자: 오늘 KBO 전 경기 응원가 즉시 생성 시작 + 폴링 1회."""
started = await start_due_songs(db, force_today=True)
done = await poll_generating(db)
saved = await persist_audio(db)
return {"ok": True, "started": started, "completed": done, "persisted": saved}

View File

@ -0,0 +1,65 @@
"""리그 순위표 API — 워커가 캐싱한 standings:{league} 를 팀 정보와 합쳐 서빙.
KBO: 단일 테이블(10, 순위순). MLB: 디비전(AL/NL × ··) 6그룹.
MLS: 컨퍼런스(/서부) 2그룹 승점제.
캐시가 아직 없으면 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"]
# MLS 컨퍼런스 표시 순서 (동부 → 서부)
_MLS_CONF_ORDER = ["EAST", "WEST"]
@router.get("")
async def get_standings(
league: str = Query(..., description="kbo | mlb | mls"),
db: AsyncSession = Depends(get_db),
) -> dict:
if league not in ("kbo", "mlb", "mls"):
return {"league": league, "updatedAt": None, "groups": []}
row = await db.get(DataCache, f"standings:{league}")
table: dict = row.payload if row else {}
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 []
elif league == "mls":
by_conf: dict[str, list] = {}
for r in rows:
by_conf.setdefault(r.get("div") or "", []).append(r)
for lst in by_conf.values():
lst.sort(key=lambda r: r.get("rank") or 99)
groups = [
{"key": c, "rows": by_conf[c]} for c in _MLS_CONF_ORDER if c in by_conf
]
else:
by_div: dict[str, list] = {}
for r in rows:
by_div.setdefault(r.get("div") or "", []).append(r)
for lst in by_div.values():
lst.sort(key=lambda r: r.get("rank") or 99)
groups = [
{"key": d, "rows": by_div[d]} for d in _MLB_DIV_ORDER if d in by_div
]
if not groups and rows:
# 캐시가 div 주입 이전 버전이면 전체 승률순 단일 그룹으로 폴백
rows.sort(key=lambda r: -float(r.get("wra") or 0))
groups = [{"key": None, "rows": rows}]
return {
"league": league,
"updatedAt": row.fetched_at.isoformat() if row and row.fetched_at else None,
"groups": groups,
}

View File

@ -0,0 +1,26 @@
"""방문자 집계 — 페이지 접속 기록(일별 순수 방문자)."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..domain import KST, now_utc
from ..models import PageVisit
from ..schemas import VisitIn, VisitOut
router = APIRouter(prefix="/api/visit", tags=["visits"])
@router.post("", response_model=VisitOut)
async def record_visit(body: VisitIn, db: AsyncSession = Depends(get_db)) -> VisitOut:
"""페이지 접속 기록. 같은 기기가 같은 날(KST) 재접속하면 집계하지 않는다."""
today = now_utc().astimezone(KST).date()
db.add(PageVisit(visit_date=today, device_id=body.deviceId))
try:
await db.commit()
return VisitOut(ok=True, counted=True) # 오늘 첫 방문 → 집계
except IntegrityError:
await db.rollback()
return VisitOut(ok=True, counted=False) # 재방문 → 무시

View File

@ -0,0 +1,95 @@
"""경기 일정 SSOT — lib/schedule.ts(Group A 정식 일정, KST) 와 동일.
투표 오픈 = 킥오프 - settings.vote_open_hours_before (D-5 = 120h)
투표 마감 = 킥오프 - settings.vote_lock_minutes_before (기본 0 = 킥오프 정각)
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from .config import settings
# 팀 정의 (코드 → 표시명/약식/이모지)
TEAMS = {
"KOR": {"name": "Korea Republic", "shortName": "한국", "code": "KOR", "flag": "🇰🇷"},
"CZE": {"name": "Czechia", "shortName": "체코", "code": "CZE", "flag": "🇨🇿"},
"MEX": {"name": "Mexico", "shortName": "멕시코", "code": "MEX", "flag": "🇲🇽"},
"RSA": {"name": "South Africa", "shortName": "남아공", "code": "RSA", "flag": "🇿🇦"},
}
# 외부 소스의 팀 표기 → 내부 코드 매핑 (openfootball / football-data 등 변형 포괄).
NAME_TO_CODE = {
"korea republic": "KOR",
"south korea": "KOR",
"korea": "KOR",
"czechia": "CZE",
"czech republic": "CZE",
"mexico": "MEX",
"south africa": "RSA",
}
def code_for(name: str) -> str | None:
return NAME_TO_CODE.get(name.strip().lower())
@dataclass(frozen=True)
class MatchSeed:
match_id: str
round_label: str
team_a: str # 코드
team_b: str
kickoff_iso: str # ISO with +09:00
venue: str
hook_text: str
# Group A 전체 6경기 (출처: lib/schedule.ts)
GROUP_A: list[MatchSeed] = [
MatchSeed(
"A_MEX_RSA_20260612", "Match 01 · 개막전", "MEX", "RSA",
"2026-06-12T04:00:00+09:00", "Estadio Azteca · Mexico City",
"글로벌 축구 개막전, AI는 개최국을 믿을까",
),
MatchSeed(
"A_KOR_CZE_20260612", "Match 01", "KOR", "CZE",
"2026-06-12T11:00:00+09:00", "Estadio Guadalajara (Akron)",
"한국 첫 경기, AI의 선택은 갈렸다",
),
MatchSeed(
"A_CZE_RSA_20260619", "Match 02", "CZE", "RSA",
"2026-06-19T01:00:00+09:00", "USA (TBD)",
"체코 vs 남아공, AI의 예측은",
),
MatchSeed(
"A_MEX_KOR_20260619", "Match 02", "MEX", "KOR",
"2026-06-19T10:00:00+09:00", "Estadio Guadalajara (Akron)",
"개최국 멕시코 vs 한국, AI는 누구 편",
),
MatchSeed(
"A_CZE_MEX_20260625", "Match 03", "CZE", "MEX",
"2026-06-25T10:00:00+09:00", "Estadio Azteca · Mexico City",
"체코 vs 멕시코, 운명의 최종전",
),
MatchSeed(
"A_RSA_KOR_20260625", "Match 03", "RSA", "KOR",
"2026-06-25T10:00:00+09:00", "Estadio BBVA · Monterrey",
"한국 16강의 갈림길, AI의 마지막 예측",
),
]
FEATURED_MATCH_ID = "A_KOR_CZE_20260612"
def parse_kickoff(iso: str) -> datetime:
# 저장은 UTC 로 통일 (DB 종류 무관하게 절대시점 일관). 출력 시 KST 로 직렬화.
return datetime.fromisoformat(iso).astimezone(timezone.utc)
def opens_at(kickoff: datetime) -> datetime:
return kickoff - timedelta(hours=settings.vote_open_hours_before)
def lock_at(kickoff: datetime) -> datetime:
return kickoff - timedelta(minutes=settings.vote_lock_minutes_before)

211
backend/app/schemas.py Normal file
View File

@ -0,0 +1,211 @@
"""Pydantic 스키마 — API 요청/응답 계약.
프론트(lib/types.ts) 1:1 정합. 시각은 ISO8601 문자열로 직렬화.
"""
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
Outcome = Literal["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"]
ModelName = Literal["GPT", "Claude", "Gemini"]
class Team(BaseModel):
name: str
shortName: str
code: str
flag: str = ""
class MatchResult(BaseModel):
scoreA: int
scoreB: int
outcome: Outcome
class AIPredictionOut(BaseModel):
matchId: str
model: ModelName
outcome: Outcome
scoreA: int
scoreB: int
confidencePct: int
reasonShort: str
generatedAt: str
class CrowdStatsOut(BaseModel):
matchId: str
total: int
teamAWin: int
draw: int
teamBWin: int
class MatchOut(BaseModel):
matchId: str
league: str = "wc" # wc | kbo | mlb
roundLabel: str
group: str
teamA: Team
teamB: Team
kickoffKst: str
venue: str
opensAt: str
lockAt: str
status: str # = phase (실시간 계산값으로 통일)
phase: str # scheduled | open | locked | live | finished (now 기준 계산)
votable: bool # AI 예측·투표 대상 조인지 (그 외는 일정/결과만)
votingOpen: bool # 현재 제출 허용 여부 (demo_force_open 반영)
hookText: str
result: MatchResult | None = None
predictions: list[AIPredictionOut] = Field(default_factory=list)
crowd: CrowdStatsOut | None = None
# 야구 부가정보 (프리뷰·순위 캐시 — 축구/캐시없음이면 None)
extras: dict | None = None
# ── 픽 제출 ─────────────────────────────────────────────────
class SubmitPredictionIn(BaseModel):
matchId: str
deviceId: str = Field(min_length=8, max_length=64)
outcome: Outcome
scoreA: int = Field(ge=0, le=25) # 야구(득점) 상한. 축구는 UI 가 0-9 로 제한.
scoreB: int = Field(ge=0, le=25)
email: EmailStr | None = None
notify: bool = False
@field_validator("outcome")
@classmethod
def outcome_matches_score(cls, v: str, info): # noqa: ANN001
# outcome 은 스코어에서 파생되어야 일관됨 — 프론트가 자동계산하지만 서버에서 재검증
return v
class SubmitPredictionOut(BaseModel):
ok: bool
predictionId: str
matchedModels: list[ModelName]
exactMatch: bool
crowd: CrowdStatsOut
# ── 내 지난 예측 (이메일 기준, 로그인 없음) ──────────────────
class MyResultOut(BaseModel):
scoreA: int
scoreB: int
outcome: Outcome
hitOutcome: bool # 내 outcome 이 실제 결과와 일치했는지
class MyPredictionOut(BaseModel):
matchId: str
teamA: Team
teamB: Team
kickoffKst: str
outcome: Outcome
scoreA: int
scoreB: int
submittedAt: str # ISO — 최신순 정렬 기준(updated_at)
result: MyResultOut | None = None # 경기 종료 시에만 채워짐
# ── 리더보드 ────────────────────────────────────────────────
class StandingOut(BaseModel):
rank: int
emailMasked: str
totalPoints: int
exactCount: int
matchesPlayed: int
class LeaderboardOut(BaseModel):
standings: list[StandingOut]
scoredMatches: int
# ── AI 모델 랭킹 (종료 경기 누적, 매 조회 시 재계산) ──────────
class AIStandingOut(BaseModel):
rank: int
model: ModelName
totalPoints: int
exactCount: int
matchesPlayed: int
class AILeaderboardOut(BaseModel):
standings: list[AIStandingOut]
scoredMatches: int
# ── 관리자 ──────────────────────────────────────────────────
class AdminAIPredictionIn(BaseModel):
matchId: str
model: ModelName
outcome: Outcome
scoreA: int = Field(ge=0, le=20)
scoreB: int = Field(ge=0, le=20)
confidencePct: int = Field(ge=0, le=100)
reasonKo: str = ""
reasonEn: str = ""
class AdminSetResultIn(BaseModel):
matchId: str
scoreA: int = Field(ge=0, le=50)
scoreB: int = Field(ge=0, le=50)
# ── 댓글(경기별 한마디) — 완전 익명 ─────────────────────────
class CommentIn(BaseModel):
deviceId: str = Field(min_length=8, max_length=64) # 서버에서 해시 후 폐기(원본 비저장)
nickname: str = Field(min_length=1, max_length=12) # 세션 캐싱된 닉(서버 풀 검증)
body: str = Field(min_length=1, max_length=200)
@field_validator("body")
@classmethod
def body_not_blank(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("EMPTY_BODY")
return v
class CommentOut(BaseModel):
# id·author_hash 등 내부 식별자는 노출하지 않음(닉네임만 공개)
nickname: str # 축구 코믹 한국어 5글자 (세션 발급)
body: str
createdAt: str # ISO8601
class CommentNicknameOut(BaseModel):
nickname: str # 세션 시작 시 발급받는 랜덤 닉(클라이언트가 sessionStorage에 캐싱)
class CommentListOut(BaseModel):
items: list[CommentOut]
total: int # 숨김 제외 전체 개수 — "더보기" 남은 수 계산용
class GenericOk(BaseModel):
ok: bool
detail: str = ""
extra: dict | None = None
# ── 방문자 집계 ─────────────────────────────────────────────
class VisitIn(BaseModel):
deviceId: str = Field(min_length=8, max_length=64)
class VisitOut(BaseModel):
ok: bool
counted: bool # 오늘 첫 방문이면 True(집계됨), 재방문이면 False
class DailyVisitOut(BaseModel):
date: str # YYYY-MM-DD (KST)
uniqueVisitors: int

140
backend/app/scoring.py Normal file
View File

@ -0,0 +1,140 @@
"""채점 로직 — docs/SCORING.md (SSOT) 그대로 구현.
배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분( 득점 일치) 1 · 빗나감 0.
단조 증가(정확>근접>승패>부분>빗나감).
야구(KBO/MLB) 득점 범위가 넓어 같은 등급 체계에 판정만 완화한다:
근접 = 승패 + 득실차 오차 ±1, 부분 = 득점 오차 ±1.
배점·배제 대상은 backend/data/scoring.json 에서 기동 로드(없으면 아래 기본값).
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
log = logging.getLogger("triplepick.scoring")
# 기본 배점 — backend/data/scoring.json 로드 시 덮어씀 (docs/SCORING.md §2)
SCORE = {"EXACT": 5, "CLOSE": 3, "OUTCOME": 2, "PARTIAL": 1, "MISS": 0}
def outcome_of(score_a: int, score_b: int) -> str:
if score_a > score_b:
return "TEAM_A_WIN"
if score_a < score_b:
return "TEAM_B_WIN"
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, baseball: bool = False
) -> int:
"""내 예측 vs 실제 결과 → 적중 포인트 (결정론적)."""
return SCORE[_grade_of(pick_a, pick_b, result_a, result_b, baseball)]
# ── 자격 · 배제 (docs/SCORING.md §4, P1 구현) ──────────────────
STAFF_EMAILS: set[str] = set() # 운영진/임직원 블록리스트
STAFF_DOMAINS = ["o2o.kr", "aio2o.kr"]
def is_excluded(email: str) -> bool:
e = email.strip().lower()
return e in STAFF_EMAILS or any(e.endswith("@" + d) for d in STAFF_DOMAINS)
# ── 외부 설정 로드 (backend/data/scoring.json, key-value) ──────
DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "scoring.json"
# 등급 → scoring.json 의 key
_JSON_KEY = {
"EXACT": "score_exact",
"CLOSE": "score_close",
"OUTCOME": "score_outcome",
"PARTIAL": "score_partial",
"MISS": "score_miss",
}
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 같은 판정 기준(_grade_of 공유). : 승패+득실차 일치
("score_close", 3). scoring.json 없거나 키가 빠지면 기본 SCORE 값으로 대체.
"""
p = path or DATA_FILE
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e)
data = {}
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
_SCORE_KEYS = {
"score_exact": "EXACT",
"score_close": "CLOSE",
"score_outcome": "OUTCOME",
"score_partial": "PARTIAL",
"score_miss": "MISS",
}
def load_scoring_data(path: Path | None = None) -> dict:
"""data/scoring.json 을 읽어 배점(SCORE)·배제 대상을 갱신. 기동 시 1회 호출.
SCORE/STAFF_EMAILS/STAFF_DOMAINS 모듈이 import 객체라 재할당 대신
in-place 갱신. 파일이 없거나 깨져도 기본값으로 동작(부팅 실패 방지).
"""
p = path or DATA_FILE
try:
data = json.loads(p.read_text(encoding="utf-8"))
except FileNotFoundError:
log.warning("scoring data 없음 (%s) — 기본 배점 사용", p)
return {}
except (OSError, json.JSONDecodeError) as e:
log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e)
return {}
for key, score_key in _SCORE_KEYS.items():
if isinstance(data.get(key), int):
SCORE[score_key] = data[key]
if isinstance(data.get("excluded_emails"), list):
STAFF_EMAILS.clear()
STAFF_EMAILS.update(e.strip().lower() for e in data["excluded_emails"])
if isinstance(data.get("excluded_domains"), list):
STAFF_DOMAINS[:] = [
d.strip().lower().lstrip("@") for d in data["excluded_domains"]
]
log.info(
"scoring data 로드 (%s): 배점 %s · 배제 이메일 %d · 도메인 %s",
p, SCORE, len(STAFF_EMAILS), STAFF_DOMAINS,
)
return data

63
backend/app/seed.py Normal file
View File

@ -0,0 +1,63 @@
"""DB 시드 — 경기 6개 + AI 예측(부트스트랩) + crowd baseline.
init_db 직후 호출. 이미 경기가 있으면 건너뛴다(idempotent).
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from .database import SessionLocal
from .models import CrowdStats, Match
from .schedule_data import GROUP_A, lock_at, opens_at, parse_kickoff, TEAMS
from .seed_data import generate_crowd
log = logging.getLogger("triplepick.seed")
async def seed_if_empty() -> None:
async with SessionLocal() as db:
existing = (await db.execute(select(Match.match_id))).scalars().first()
if existing:
log.info("seed: matches already present — skip")
return
for s in GROUP_A:
kickoff = parse_kickoff(s.kickoff_iso)
ta, tb = TEAMS[s.team_a], TEAMS[s.team_b]
match = Match(
match_id=s.match_id,
round_label=s.round_label,
group="A",
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=s.venue,
hook_text=s.hook_text,
kickoff_at=kickoff,
opens_at=opens_at(kickoff),
lock_at=lock_at(kickoff),
status="scheduled",
)
db.add(match)
crowd = generate_crowd(s.match_id)
db.add(
CrowdStats(
match_id=s.match_id,
total=crowd["total"],
team_a_win=crowd["teamAWin"],
draw=crowd["draw"],
team_b_win=crowd["teamBWin"],
)
)
# AI 예측은 시드하지 않는다 — 워커가 실 API 로 생성.
await db.commit()
log.info("seed: %d matches + crowd seeded (예측 없음 — 워커가 실 API 생성)", len(GROUP_A))

11
backend/app/seed_data.py Normal file
View File

@ -0,0 +1,11 @@
"""부트스트랩 데이터.
AI 예측은 시드하지 않는다 3모델 전부 워커가 API 생성(기동 1 + 매일 00:05).
crowd(참여수/분포) 더미 baseline 없이 0 에서 시작한다 실제 유저 제출로만 증분.
"""
from __future__ import annotations
def generate_crowd(_match_id: str) -> dict:
"""초기 군중 = 0. 실제 유저 픽 제출로만 증가(순수 실데이터)."""
return {"total": 0, "teamAWin": 0, "draw": 0, "teamBWin": 0}

View File

243
backend/app/services/ai.py Normal file
View File

@ -0,0 +1,243 @@
"""AI 3모델 실연동 — GPT(OpenAI) · Claude(Anthropic) · Gemini(Google).
모델에 동일한 경기 컨텍스트를 주고 구조화된 예측 JSON 받는다.
키가 없으면 ProviderUnavailable 던지고(조용한 실패 0), 워커는 모델별로
독립 처리하여 가능한 것만 갱신한다.
반환 표준 dict:
{outcome, scoreA, scoreB, confidencePct, reasonKo, reasonEn}
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass
from ..config import settings
log = logging.getLogger("triplepick.ai")
OUTCOMES = {"TEAM_A_WIN", "DRAW", "TEAM_B_WIN"}
class ProviderUnavailable(RuntimeError):
"""API 키 미설정 등으로 해당 모델을 호출할 수 없음."""
@dataclass
class MatchContext:
team_a: str # 표시명 (예: Korea Republic / 한화 이글스)
team_b: str
venue: str
kickoff: str # ISO
data_block: str | None = None # 실데이터(폼·H2H·랭킹 등) 주입 블록. 없으면 이름만.
league: str = "wc" # wc(축구) | kbo | mlb | mls — 프롬프트·스코어 범위 분기
# 공통 페르소나 — 세 모델 모두 동일한 입력(동일 프롬프트+동일 데이터)을 받는다.
# 예측 차이는 모델 자체의 판단 차이에서만 나온다.
ANALYST = (
"You are an expert soccer analyst. Use ALL of the factual match data provided "
"below — recent form, standings, head-to-head and matchup context — and weigh "
"it over your prior knowledge to make the most accurate prediction possible."
)
BASEBALL_ANALYST = (
"You are an expert baseball analyst. Use ALL of the factual match data provided "
"below — recent form, standings with games-behind, team batting/ERA, "
"head-to-head record, starting pitchers, confirmed lineups and yesterday's "
"results — and weigh it over your prior knowledge to make the most accurate "
"prediction possible."
)
_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_ANALYST # 모델 공통 — 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]}.\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 ""
return (
f"{persona}\n"
f"Predict the result of this 2026 FIFA World Cup match.\n"
f"Team A: {ctx.team_a}\nTeam B: {ctx.team_b}\n"
f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n"
f"{data}"
f"Important: World Cup group-stage matches are played at NEUTRAL venues. "
f"Neither team has home advantage unless it is a host nation "
f"(Mexico, USA, or Canada). Do NOT claim home advantage otherwise.\n\n"
f"Predict the final regulation-time score. "
f"Respond with a single JSON object and nothing else, with keys:\n"
f' "scoreA": integer 0-9 (Team A goals),\n'
f' "scoreB": integer 0-9 (Team B goals),\n'
f' "outcome": one of "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN" (must match the score),\n'
f' "confidencePct": integer 0-100 (confidence in the predicted winner; '
f"for a draw, confidence in the draw),\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_mls(ctx: MatchContext, persona: str) -> str:
"""MLS 정규시즌 — 월드컵과 달리 홈 어드밴티지가 있고 무승부가 흔하다."""
data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
return (
f"{persona}\n"
f"Predict the result of this 2026 MLS (Major League Soccer) regular-season "
f"match.\n"
f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n"
f"Venue: {ctx.venue}\nKickoff: {ctx.kickoff}\n"
f"{data}"
f"Important: Team B is the HOME team — MLS home advantage is significant "
f"(long travel distances). Draws are common in MLS (~25% of matches) — "
f"predict one when the matchup genuinely points that way.\n\n"
f"Predict the final score. "
f"Respond with a single JSON object and nothing else, with keys:\n"
f' "scoreA": integer 0-9 (Team A goals),\n'
f' "scoreB": integer 0-9 (Team B goals),\n'
f' "outcome": one of "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN" (must match the score),\n'
f' "confidencePct": integer 0-100,\n'
f' "reasonKo": a short one-line rationale in Korean (max ~30 chars),\n'
f' "reasonEn": a short one-line rationale in English (max ~60 chars).\n'
)
def _build_prompt(ctx: MatchContext, model: str) -> str:
"""리그별 프롬프트 선택 — 야구(kbo/mlb)는 야구, mls 는 MLS 축구, 그 외 월드컵."""
if ctx.league in ("kbo", "mlb"):
return _prompt_baseball(ctx, model)
if ctx.league == "mls":
return _prompt_mls(ctx, ANALYST)
return _prompt(ctx, ANALYST)
# JSON Schema (구조화 출력용 — Anthropic/OpenAI 공통)
_SCHEMA = {
"type": "object",
"additionalProperties": False,
"properties": {
"scoreA": {"type": "integer"},
"scoreB": {"type": "integer"},
"outcome": {"type": "string", "enum": ["TEAM_A_WIN", "DRAW", "TEAM_B_WIN"]},
"confidencePct": {"type": "integer"},
"reasonKo": {"type": "string"},
"reasonEn": {"type": "string"},
},
"required": ["scoreA", "scoreB", "outcome", "confidencePct", "reasonKo", "reasonEn"],
}
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))))
return {
"outcome": outcome,
"scoreA": a,
"scoreB": b,
"confidencePct": conf,
"reasonKo": str(data.get("reasonKo", "")).strip()[:120],
"reasonEn": str(data.get("reasonEn", "")).strip()[:160],
}
# ── GPT (OpenAI) ────────────────────────────────────────────
async def predict_gpt(ctx: MatchContext) -> dict:
if not settings.openai_api_key:
raise ProviderUnavailable("OPENAI_API_KEY 미설정")
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=settings.openai_api_key)
resp = await client.chat.completions.create(
model=settings.openai_model,
messages=[
{"role": "system", "content": "You output only valid JSON."},
{"role": "user", "content": _build_prompt(ctx, "GPT")},
],
response_format={"type": "json_object"},
)
content = resp.choices[0].message.content or "{}"
return _normalize(json.loads(content), 25 if ctx.league in ("kbo", "mlb") else 9)
# ── Claude (Anthropic) ──────────────────────────────────────
async def predict_claude(ctx: MatchContext) -> dict:
if not settings.anthropic_api_key:
raise ProviderUnavailable("ANTHROPIC_API_KEY 미설정")
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
async def _create(**extra): # noqa: ANN003
return await client.messages.create(
model=settings.anthropic_model,
max_tokens=1024,
messages=[{"role": "user", "content": _build_prompt(ctx, "Claude")}],
**extra,
)
# 1순위: 구조화 출력(output_config.format) + Opus 4.8 어댑티브 thinking.
# SDK/모델 버전에 따라 미지원이면 평문 JSON 파싱으로 폴백.
try:
msg = await _create(
thinking={"type": "adaptive"},
output_config={"format": {"type": "json_schema", "schema": _SCHEMA}},
)
except TypeError:
msg = await _create()
text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
return _normalize(json.loads(text), 25 if ctx.league in ("kbo", "mlb") else 9)
# ── Gemini (Google) ─────────────────────────────────────────
async def predict_gemini(ctx: MatchContext) -> dict:
if not settings.google_api_key:
raise ProviderUnavailable("GOOGLE_API_KEY 미설정")
from google import genai
from google.genai import types
client = genai.Client(api_key=settings.google_api_key)
resp = await client.aio.models.generate_content(
model=settings.google_model,
contents=_build_prompt(ctx, "Gemini"),
config=types.GenerateContentConfig(response_mime_type="application/json"),
)
return _normalize(json.loads(resp.text or "{}"), 25 if ctx.league in ("kbo", "mlb") else 9)
PROVIDERS = {
"GPT": predict_gpt,
"Claude": predict_claude,
"Gemini": predict_gemini,
}

View File

@ -0,0 +1,342 @@
"""카카오 로그인 + JWT 인증 — o2o-castad-backend 의 인증 스택 이식.
흐름 (castad 동일):
1. GET /api/auth/kakao/login 카카오 인증 페이지 URL
2. 카카오 로그인 redirect_uri 인가 코드(code) 수신
3. 코드 카카오 액세스 토큰 사용자 정보 조회
4. users 조회/생성 JWT access(60)/refresh(7) 발급
5. refresh 해시로 DB 저장, 갱신 rotation(기존 폐기 + 신규 발급)
castad 대비 변경: aiohttphttpx(프로젝트 표준), 크레딧·소셜계정 연동 제거,
설정은 triplepick settings 통합.
"""
from __future__ import annotations
import hashlib
import logging
from datetime import timedelta
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import jwt
from jose.exceptions import ExpiredSignatureError, JWTError
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..domain import ensure_aware, now_utc
from ..models import RefreshToken, User
log = logging.getLogger("triplepick.auth")
JWT_ALGORITHM = "HS256"
KAKAO_AUTH_URL = "https://kauth.kakao.com/oauth/authorize"
KAKAO_TOKEN_URL = "https://kauth.kakao.com/oauth/token"
KAKAO_USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"
# ── 예외 (castad 코드 체계 유지) ───────────────────────────────
class AuthError(HTTPException):
def __init__(self, status_code: int, code: str, message: str):
super().__init__(
status_code=status_code, detail={"code": code, "message": message}
)
def _unauthorized(code: str, message: str) -> AuthError:
return AuthError(status.HTTP_401_UNAUTHORIZED, code, message)
# ── JWT ────────────────────────────────────────────────────────
def create_access_token(user_uuid: str) -> str:
expire = now_utc() + timedelta(minutes=settings.jwt_access_expire_minutes)
return jwt.encode(
{"sub": user_uuid, "exp": expire, "type": "access"},
settings.jwt_secret,
algorithm=JWT_ALGORITHM,
)
def create_refresh_token(user_uuid: str) -> str:
expire = now_utc() + timedelta(days=settings.jwt_refresh_expire_days)
return jwt.encode(
{"sub": user_uuid, "exp": expire, "type": "refresh"},
settings.jwt_secret,
algorithm=JWT_ALGORITHM,
)
def decode_token(token: str) -> dict | None:
"""유효하면 payload, 만료/위조면 None."""
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
except JWTError:
return None
def is_token_expired(token: str) -> bool:
try:
jwt.decode(token, settings.jwt_secret, algorithms=[JWT_ALGORITHM])
return False
except ExpiredSignatureError:
return True
except JWTError:
return False
def get_token_hash(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
# ── 카카오 OAuth ───────────────────────────────────────────────
def kakao_authorization_url() -> str:
return (
f"{KAKAO_AUTH_URL}?client_id={settings.kakao_client_id}"
f"&redirect_uri={settings.kakao_redirect_uri}&response_type=code"
)
async def _kakao_access_token(code: str) -> str:
import httpx
data = {
"grant_type": "authorization_code",
"client_id": settings.kakao_client_id,
"redirect_uri": settings.kakao_redirect_uri,
"code": code,
}
if settings.kakao_client_secret:
data["client_secret"] = settings.kakao_client_secret
async with httpx.AsyncClient(timeout=15) as c:
r = await c.post(KAKAO_TOKEN_URL, data=data)
result = r.json()
if "error" in result:
desc = result.get("error_description", result.get("error", "알 수 없는 오류"))
log.error("kakao 토큰 발급 실패: %s", desc)
raise AuthError(
status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED",
f"카카오 토큰 발급 실패: {desc}",
)
return result["access_token"]
async def _kakao_user_info(access_token: str) -> dict:
import httpx
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(
KAKAO_USER_INFO_URL, headers={"Authorization": f"Bearer {access_token}"}
)
result = r.json()
if "id" not in result:
log.error("kakao 사용자 정보 조회 실패: %s", result)
raise AuthError(
status.HTTP_400_BAD_REQUEST, "KAKAO_AUTH_FAILED",
"카카오 사용자 정보를 가져올 수 없습니다.",
)
return result
# ── 사용자 조회/생성 ───────────────────────────────────────────
def _profile_of(info: dict) -> tuple[str | None, str | None, str | None]:
"""카카오 응답 → (email, nickname, profile_image_url)."""
account = info.get("kakao_account") or {}
profile = account.get("profile") or {}
return (
account.get("email"),
profile.get("nickname"),
profile.get("profile_image_url"),
)
async def _get_or_create_user(db: AsyncSession, info: dict) -> tuple[User, bool]:
kakao_id = int(info["id"])
email, nickname, image = _profile_of(info)
user = (
await db.execute(select(User).where(User.kakao_id == kakao_id))
).scalar_one_or_none()
if user is not None:
# 기존 사용자 — 프로필 최신화
if nickname:
user.nickname = nickname
if image:
user.profile_image_url = image
if email:
user.email = email
await db.flush()
return user, False
import uuid
new_user = User(
kakao_id=kakao_id,
user_uuid=str(uuid.uuid4()),
email=email,
nickname=nickname,
profile_image_url=image,
)
db.add(new_user)
try:
await db.flush()
return new_user, True
except IntegrityError:
# 동시 요청으로 인한 중복 삽입 — 기존 사용자 재조회 (castad 동일 처리)
await db.rollback()
existing = (
await db.execute(select(User).where(User.kakao_id == kakao_id))
).scalar_one_or_none()
if existing is not None:
return existing, False
raise
# ── 로그인/갱신/로그아웃 ───────────────────────────────────────
async def kakao_login(
db: AsyncSession,
code: str,
user_agent: Optional[str] = None,
ip_address: Optional[str] = None,
) -> dict:
"""인가 코드 → JWT 발급. {access_token, refresh_token, expires_in, is_new_user, user}."""
kakao_token = await _kakao_access_token(code)
info = await _kakao_user_info(kakao_token)
user, is_new = await _get_or_create_user(db, info)
if not user.is_active:
raise AuthError(
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
)
access_token = create_access_token(user.user_uuid)
refresh_token = create_refresh_token(user.user_uuid)
db.add(RefreshToken(
user_id=user.id,
user_uuid=user.user_uuid,
token_hash=get_token_hash(refresh_token),
expires_at=now_utc() + timedelta(days=settings.jwt_refresh_expire_days),
user_agent=user_agent,
ip_address=ip_address,
))
user.last_login_at = now_utc()
await db.commit()
log.info("kakao 로그인: user_id=%s new=%s", user.id, is_new)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "Bearer",
"expires_in": settings.jwt_access_expire_minutes * 60,
"is_new_user": is_new,
"user": user_out(user),
}
async def refresh_tokens(db: AsyncSession, refresh_token: str) -> dict:
"""Refresh Token Rotation — 기존 토큰 폐기 + 새 access/refresh 발급."""
payload = decode_token(refresh_token)
if payload is None:
if is_token_expired(refresh_token):
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
if payload.get("type") != "refresh":
raise _unauthorized("INVALID_TOKEN", "리프레시 토큰이 아닙니다.")
token_hash = get_token_hash(refresh_token)
db_token = (
await db.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
).scalar_one_or_none()
if db_token is None:
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
if db_token.is_revoked: # 이미 폐기된 토큰 재사용 — replay 의심
log.warning("폐기된 refresh 재사용: user_uuid=%s", db_token.user_uuid)
raise _unauthorized("TOKEN_REVOKED", "취소된 토큰입니다. 다시 로그인해주세요.")
if ensure_aware(db_token.expires_at) < now_utc():
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
user = (
await db.execute(select(User).where(User.user_uuid == payload.get("sub")))
).scalar_one_or_none()
if user is None:
raise AuthError(
status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", "가입되지 않은 사용자 입니다."
)
if not user.is_active:
raise AuthError(
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
)
db_token.is_revoked = True
db_token.revoked_at = now_utc()
new_access = create_access_token(user.user_uuid)
new_refresh = create_refresh_token(user.user_uuid)
db.add(RefreshToken(
user_id=user.id,
user_uuid=user.user_uuid,
token_hash=get_token_hash(new_refresh),
expires_at=now_utc() + timedelta(days=settings.jwt_refresh_expire_days),
))
await db.commit() # 폐기 + 저장을 한 트랜잭션으로
return {
"access_token": new_access,
"refresh_token": new_refresh,
"token_type": "Bearer",
"expires_in": settings.jwt_access_expire_minutes * 60,
}
async def logout(db: AsyncSession, refresh_token: str) -> None:
await db.execute(
update(RefreshToken)
.where(RefreshToken.token_hash == get_token_hash(refresh_token))
.values(is_revoked=True, revoked_at=now_utc())
)
await db.commit()
def user_out(user: User) -> dict:
return {
"userUuid": user.user_uuid,
"email": user.email,
"nickname": user.nickname,
"profileImageUrl": user.profile_image_url,
}
# ── FastAPI 의존성 ─────────────────────────────────────────────
_security = HTTPBearer(auto_error=False)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_security),
db: AsyncSession = Depends(get_db),
) -> User:
"""Bearer access 토큰 → User. 실패 시 401/403/404."""
if credentials is None:
raise _unauthorized("MISSING_TOKEN", "인증 토큰이 필요합니다.")
payload = decode_token(credentials.credentials)
if payload is None:
if is_token_expired(credentials.credentials):
raise _unauthorized("TOKEN_EXPIRED", "토큰이 만료되었습니다. 다시 로그인해주세요.")
raise _unauthorized("INVALID_TOKEN", "유효하지 않은 토큰입니다.")
if payload.get("type") != "access":
raise _unauthorized("INVALID_TOKEN", "액세스 토큰이 아닙니다.")
user = (
await db.execute(select(User).where(User.user_uuid == payload.get("sub")))
).scalar_one_or_none()
if user is None:
raise AuthError(
status.HTTP_404_NOT_FOUND, "USER_NOT_FOUND", "가입되지 않은 사용자 입니다."
)
if not user.is_active:
raise AuthError(
status.HTTP_403_FORBIDDEN, "USER_INACTIVE", "활성화 상태가 아닌 사용자 입니다."
)
return user

View File

@ -0,0 +1,196 @@
"""야구 예측 프롬프트용 데이터 블록 — 자체 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 ""
)
# 상대 ERA 0.00 은 대부분 '대전 기록 없음' — 무실점으로 오해하지 않게 생략
vs_val = s.get("vsEra")
vs = (
f", ERA vs this opponent {vs_val}"
if vs_val not in (None, "", 0, "0", "0.00", "-")
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
gb = f", GB {st['gb']}" if st.get("gb") not in (None, "", 0, "0.0") else ""
ba = f", team AVG {st['avg']}" if st.get("avg") else ""
era = f", team ERA {st['era']}" if st.get("era") else ""
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')}){gb}{ba}{era}{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 _context_extras(db, match: Match) -> list[str]:
"""전날 결과·활약 + 확정 라인업(타자 시즌 타율) — 응원가 파이프라인 헬퍼 재사용.
라인업은 발표 전이면 생략(예측이 킥오프 22h 전부터 생성되므로 보통 미포함).
"""
from .songs import _lineup_line, _yesterday_info, fetch_lineups
lines: list[str] = []
avg_maps: dict[str, dict] = {}
for side, code, name in (
("a", match.team_a_code, match.team_a_short),
("b", match.team_b_code, match.team_b_short),
):
label = "Away" if side == "a" else "Home"
try:
y, avg_map = await _yesterday_info(db, match, code, name)
except Exception: # noqa: BLE001 — 부가 데이터 실패는 생략
y, avg_map = None, {}
avg_maps[side] = avg_map
if y:
lines.append(f"[{label} yesterday] {y}")
try:
lu = await fetch_lineups(match)
except Exception: # noqa: BLE001
lu = None
if lu and lu.get("announced"):
for side in ("a", "b"):
label = "Away" if side == "a" else "Home"
line = _lineup_line(lu, side, avg_maps.get(side))
if line:
lines.append(f"[{label}] {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
)
context = await _context_extras(db, match)
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,
*context,
"===",
])

View File

@ -0,0 +1,861 @@
"""야구 부가 데이터 — 프리뷰(선발투수·상대전적)·리그 순위·라이브 필드 뷰.
KBO: 네이버 스포츠 비공식 API (프리뷰·순위·문자중계 relay)
MLB: 공식 Stats API (probablePitcher·standings·feed/live)
수집(refresh_*) 워커가 매일, 조회(get_extras) 라우터가 캐시만 읽음.
라이브(fetch_live) 요청 프록시 + 짧은 TTL 메모리 캐시.
"""
from __future__ import annotations
import asyncio
import logging
import re
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_with_id(
client, m: Match, suffix: str
) -> tuple[str, dict] | None:
"""gameId 후보를 순서대로 시도해 (성공한 gameId, 응답) 을 반환.
같은 경기를 추가 조회할 (이닝별 문자중계) 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 gid, res
return None
async def _naver_get_first(client, m: Match, suffix: str) -> dict | None:
"""gameId 후보를 순서대로 시도해 첫 성공 응답을 반환."""
found = await _naver_get_first_with_id(client, m, suffix)
return found[1] if found else 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 조회 (라우터 — 캐시만) ──────────────────────────────
# MLS 도 동일 캐시 키(preview:{id}, standings:mls)를 쓰므로 여기서 함께 서빙.
async def get_extras(db, matches: list[Match]) -> dict[str, dict]:
standings_cache: dict[str, dict] = {}
out: dict[str, dict] = {}
for m in matches:
if m.league not in ("kbo", "mlb", "mls"):
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],
}
# ── KBO 문자중계 (네이버 relay textRelays) ─────────────────────
# 네이버는 이미 한국어 중계문을 타석 단위로 묶어 준다 — 번역 없이 그대로 서빙.
# relay 기본 응답은 "현재 이닝"만 담고, 지난 이닝은 ?inning=N 으로 따로 받아야 한다.
# textOptions.type → 프론트 표시용 의미 (네이버 숫자 코드를 UI 에 노출하지 않기 위함)
_RELAY_KIND = {
0: "inning", # 이닝 시작 ("1회초 한화 공격")
1: "pitch", # 투구 ("3구 헛스윙")
2: "sub", # 선수 교체
7: "note", # 투수판 이탈·비디오 판독·마운드 방문
8: "batter", # 타석 시작 (그룹 제목과 동일)
13: "result", # 타석 결과
23: "result", # 타석 결과 (주자 있을 때 표기)
14: "run", # 주루
24: "run", # 주루·홈인
99: "end", # 경기 종료·승리투수
}
# 라이브 페이로드에 싣는 최근 타석 수 — 전 이닝(약 80타석)을 매 폴링마다 보내지 않기 위한 상한
_RELAY_MAX_GROUPS = 40
# 지난 이닝 중계 캐시 (경기 → {이닝: 그룹[]}). 종료된 이닝은 불변이라 재조회하지 않는다.
_relay_innings: dict[str, dict[int, list[dict]]] = {}
def _score_total(gs: dict) -> int | None:
try:
return int(gs.get("homeScore") or 0) + int(gs.get("awayScore") or 0)
except (TypeError, ValueError):
return None
def _relay_commentary(groups: list[dict]) -> list[dict]:
"""relay 그룹 → 문자중계 [{inn, half, no, title, events[]}] (최신 타석 순).
득점 표시는 텍스트 패턴 대신 옵션마다 실린 currentGameState 점수 변화로
판정한다 홈런·적시타뿐 아니라 홈인·실책 득점·밀어내기까지 정확히 잡힌다.
"""
out: list[dict] = []
prev_total: int | None = None
for r in sorted(groups, key=lambda g: g.get("no") or 0):
title = (r.get("title") or "").strip()
if not title.strip("="): # 경기 종료 구분선 그룹 — 제목 없이 내용만
title = ""
events: list[dict] = []
for o in sorted(r.get("textOptions") or [], key=lambda o: o.get("seqno") or 0):
total = _score_total(o.get("currentGameState") or {})
scored = prev_total is not None and total is not None and total > prev_total
if total is not None:
prev_total = total
text = (o.get("text") or "").strip()
# 구분선("=====")·제목 중복 라인은 버린다
if not text or text == title or not text.strip("="):
continue
ev = {"kind": _RELAY_KIND.get(o.get("type"), "note"), "text": text}
if scored:
ev["score"] = True
events.append(ev)
# 이닝 헤더 그룹은 events 가 비지만 구분선으로 쓰이므로 남긴다
if not title and not events:
continue
out.append({
"inn": r.get("inn"),
"half": "B" if str(r.get("homeOrAway")) == "1" else "T",
"no": r.get("no"),
"title": title,
"events": events,
})
out.reverse()
# 종료된 경기는 폴링이 멈추므로 전 경기 중계를 그대로 준다(다시보기).
# 진행 중엔 20초마다 다시 실리므로 최근 타석만 — 스크롤 분량으로도 충분하다.
ended = any(e["kind"] == "end" for g in out for e in g["events"])
return out if ended else out[:_RELAY_MAX_GROUPS]
async def _kbo_relay_groups(client, m: Match, gid: str, t: dict) -> list[dict]:
"""전 이닝 문자중계 그룹. 이닝별 조회를 캐시와 병렬 조회로 채운다.
현재·직전 이닝은 매번 다시 받는다 기본 응답은 진행 중인 half 담을 때가 있고,
이닝이 넘어간 직후엔 직전 이닝 마지막 타석이 캐시에 들어와 있을 있다.
그보다 앞선 이닝은 변하지 않으므로 번만 받는다.
"""
cur_groups = t.get("textRelays") or []
cur = int(t.get("inn") or max((g.get("inn") or 0) for g in cur_groups) or 1)
if len(_relay_innings) > 40: # 하루치 경기 이상 쌓이면 통째로 비움
_relay_innings.clear()
cache = _relay_innings.setdefault(m.match_id, {})
volatile = {cur - 1, cur}
need = [i for i in range(1, cur + 1) if i in volatile or i not in cache]
if need:
res = await asyncio.gather(
*(
_naver_get(client, f"/schedule/games/{gid}/relay?inning={i}")
for i in need
),
return_exceptions=True,
)
for i, r in zip(need, res):
if isinstance(r, BaseException) or not r:
continue # 실패한 이닝은 기존 캐시 유지 → 다음 폴링에서 재시도
cache[i] = ((r.get("textRelayData") or {}).get("textRelays")) or []
cache.setdefault(cur, cur_groups) # 현재 이닝 조회 실패 시 기본 응답으로 대체
return [g for i in sorted(cache) for g in cache[i]]
# ── MLB 문자중계 (feed/live allPlays 규칙 기반 한글 변환) ──────
# MLB 중계문은 정형 영문 템플릿 — eventType·투구 호칭을 한글로 매핑하고
# 타구 방향·주자 진루는 정규식으로 뽑는다. 미지원 문장은 영문 원문 폴백.
_MLB_PITCH_KO = {
"Ball": "", "Ball In Dirt": "볼 (원바운드)", "Intent Ball": "고의사구 볼",
"Called Strike": "스트라이크", "Swinging Strike": "헛스윙",
"Swinging Strike (Blocked)": "헛스윙", "Foul": "파울", "Foul Tip": "파울팁",
"Foul Bunt": "번트 파울", "Missed Bunt": "번트 헛스윙",
"Hit By Pitch": "몸에 맞는 볼", "Pitchout": "피치아웃",
"Automatic Ball": "자동 볼 (피치클록)", "Automatic Strike": "자동 스트라이크 (피치클록)",
"In play, out(s)": "타격", "In play, no out": "타격", "In play, run(s)": "타격",
}
_MLB_EVENT_KO = {
"Single": "1루타", "Double": "2루타", "Triple": "3루타", "Home Run": "홈런",
"Walk": "볼넷", "Intent Walk": "고의4구", "Strikeout": "삼진 아웃",
"Strikeout Double Play": "삼진 (병살)", "Groundout": "땅볼 아웃",
"Flyout": "플라이 아웃", "Lineout": "직선타 아웃", "Pop Out": "팝플라이 아웃",
"Bunt Groundout": "번트 아웃", "Bunt Pop Out": "번트 팝플라이 아웃",
"Forceout": "포스 아웃", "Grounded Into DP": "병살타", "Double Play": "병살",
"Triple Play": "삼중살", "Sac Fly": "희생플라이", "Sac Bunt": "희생번트",
"Field Error": "실책 출루", "Fielders Choice": "야수선택",
"Fielders Choice Out": "야수선택 아웃", "Hit By Pitch": "몸에 맞는 볼",
"Catcher Interference": "포수 타격방해", "Runner Out": "주자 아웃",
"Caught Stealing 2B": "도루자 (2루)", "Caught Stealing 3B": "도루자 (3루)",
"Caught Stealing Home": "도루자 (홈)", "Pickoff 1B": "견제사 (1루)",
"Pickoff 2B": "견제사 (2루)", "Pickoff 3B": "견제사 (3루)",
}
# 타구 방향/처리 야수 — desc 에서 가장 먼저 나오는 표현 (긴 표현 우선 매칭)
_MLB_DIR_KO = [
("left fielder", "좌익수"), ("left field", "좌익수"),
("center fielder", "중견수"), ("center field", "중견수"),
("right fielder", "우익수"), ("right field", "우익수"),
("shortstop", "유격수"), ("first baseman", "1루수"),
("second baseman", "2루수"), ("third baseman", "3루수"),
("first base", "1루수"), ("second base", "2루수"), ("third base", "3루수"),
("pitcher", "투수"), ("catcher", "포수"),
]
# 대문자 시작 단어 연속 = 선수명 ("J.P. Crawford", "Wenceel Pérez").
# 마침표는 이니셜("J.P.")에만 허용 — 일반 단어 끝 마침표를 막아 문장 경계를 넘지 않게 한다.
_MLB_NAME_W = r"(?:(?:[A-ZÀ-Ý]\.)+|[A-ZÀ-Ý][\w'\-]*)"
_MLB_NAME = rf"{_MLB_NAME_W}(?: {_MLB_NAME_W})*"
_BASE_KO = {"2nd": "2루", "3rd": "3루", "home": ""}
def _mlb_direction(desc: str) -> str:
hits = [(desc.find(en), ko) for en, ko in _MLB_DIR_KO if en in desc]
return min(hits)[1] if hits else ""
def _mlb_runs(desc: str) -> list[dict]:
"""desc 의 주자 이동 문장 → run 이벤트 (홈인·진루·주루사)."""
out: list[dict] = []
for nm in re.findall(rf"({_MLB_NAME}) scores\b", desc):
out.append({"kind": "run", "text": f"{nm} : 홈인"})
for nm, base in re.findall(rf"({_MLB_NAME})(?: advances)? to (2nd|3rd)\b", desc):
out.append({"kind": "run", "text": f"{nm} : {_BASE_KO[base]}까지 진루"})
for nm, base in re.findall(rf"({_MLB_NAME}) out at (2nd|3rd|home)\b", desc):
out.append({"kind": "run", "text": f"{nm} : {_BASE_KO[base]}에서 아웃"})
seen: set[str] = set() # "to 3rd"+"advances to 3rd" 같은 중복 문장 제거
return [e for e in out if not (e["text"] in seen or seen.add(e["text"]))]
def _tr_mlb_result(res: dict, batter: str) -> str:
event, desc = res.get("event") or "", res.get("description") or ""
ko = _MLB_EVENT_KO.get(event)
if not ko: # 미지원 이벤트 — 영문 원문 폴백
return f"{batter} : {desc}" if desc else f"{batter} : {event}"
if event == "Strikeout":
if "called out on strikes" in desc:
ko += " (루킹)"
elif "strikes out swinging" in desc:
ko += " (헛스윙)"
direction = _mlb_direction(desc)
return f"{batter} : {direction} {ko}".replace(" ", " ").replace(" : ", " : ")
def _tr_mlb_action(det: dict) -> tuple[str, str] | None:
"""playEvents 액션 → (kind, text). None 이면 표시하지 않음 (노이즈)."""
event, desc = det.get("event") or "", det.get("description") or ""
if event in ("Game Advisory", "Batter Timeout") or not (event or desc):
return None
if event.startswith("Stolen Base"):
base = "2루" if event.endswith("2B") else "3루" if event.endswith("3B") else ""
nm = re.match(rf"({_MLB_NAME}) steals", desc)
who = nm.group(1) if nm else ""
return "run", f"{who} : 도루로 {base}까지 진루".replace(" ", " ")
if event.startswith("Caught Stealing"):
return "run", _MLB_EVENT_KO.get(event, "도루자")
if event == "Wild Pitch":
return "note", "폭투"
if event == "Passed Ball":
return "note", "포일"
if event == "Mound Visit":
return "note", "마운드 방문"
if event in ("Pitching Substitution", "Offensive Substitution",
"Defensive Sub", "Defensive Substitution"):
label = {
"Pitching Substitution": "투수 교체",
"Offensive Substitution": "대타" if "hitter" in desc else "대주자",
}.get(event, "수비 교체")
# "Pitching Change: A replaces B." / "...: Pinch-hitter A replaces
# right fielder B, batting 2nd..." → "{label} — B → A"
body = desc.split(": ", 1)[-1].rstrip(".")
if " replaces " in body:
new, old = body.split(" replaces ", 1)
new = re.sub(r"^Pinch-(?:hitter|runner) ", "", new)
old = re.sub(r"^[a-z][a-z ]* ", "", old) # 포지션 수식어 제거
old = old.split(", batting")[0].split(" because")[0]
return "sub", f"{label}{old}{new}"
return "sub", desc
if event == "Defensive Switch":
return None # 포지션 이동 — 중계 피드에선 노이즈
if desc.startswith("Pickoff Attempt"):
return "note", "견제 시도"
if desc.startswith("Pitcher Step Off"):
return None
return "note", desc or event # 미지원 액션 — 영문 폴백
def _transform_mlb_relay(feed: dict, m: Match) -> list[dict]:
"""allPlays → 문자중계 그룹 (KBO relay 와 동일 스키마, 최신 타석순)."""
plays = ((feed.get("liveData") or {}).get("plays") or {}).get("allPlays") or []
away_s = m.team_a_short or m.team_a_code
home_s = m.team_b_short or m.team_b_code
groups: list[dict] = []
prev_half: tuple | None = None
prev_total = 0
no = 0
for p in plays:
about = p.get("about") or {}
inn = about.get("inning")
top = about.get("halfInning") == "top"
if (inn, top) != prev_half: # 이닝 구분 헤더 (KBO 와 동일한 빈 그룹)
prev_half = (inn, top)
no += 1
groups.append({
"inn": inn, "half": "T" if top else "B", "no": no,
"title": f"{inn}{'' if top else ''} {away_s if top else home_s} 공격",
"events": [],
})
events: list[dict] = []
for ev in p.get("playEvents") or []:
det = ev.get("details") or {}
if ev.get("isPitch"):
call = det.get("description") or ""
n = ev.get("pitchNumber")
ko = _MLB_PITCH_KO.get(call, call)
events.append({"kind": "pitch", "text": f"{n}{ko}" if n else ko})
else:
ke = _tr_mlb_action(det)
if ke:
events.append({"kind": ke[0], "text": ke[1]})
events += _mlb_runs(det.get("description") or "") # 폭투 득점 등
res = p.get("result") or {}
batter = ((p.get("matchup") or {}).get("batter") or {}).get("fullName") or ""
if res.get("event"):
events.append({"kind": "result", "text": _tr_mlb_result(res, batter)})
events += _mlb_runs(res.get("description") or "")
# 득점 판정 — 플레이 종료 시점 점수 합 변화 (KBO 와 동일 원리)
if about.get("isComplete"):
total = int(res.get("awayScore") or 0) + int(res.get("homeScore") or 0)
if total > prev_total:
marked = False
for e in events:
if e["text"].endswith("홈인"):
e["score"] = True
marked = True
if not marked and events: # 홈인 문장 미파싱 (솔로 홈런 등) — 결과 라인 강조
events[-1]["score"] = True
# 홈런은 타자 본인 득점이 홈인 문장에 없음 — 결과 라인도 강조
if res.get("event") == "Home Run":
for e in events:
if e["kind"] == "result":
e["score"] = True
prev_total = total
if not events:
continue
no += 1
groups.append({
"inn": inn, "half": "T" if top else "B", "no": no,
"title": batter, "events": events,
})
groups.reverse()
ended = ((feed.get("gameData") or {}).get("status") or {}).get(
"abstractGameState") == "Final"
return groups if ended else groups[:_RELAY_MAX_GROUPS]
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:
found = await _naver_get_first_with_id(client, m, "relay")
gid, res = found if found else ("", None)
t = (res or {}).get("textRelayData")
if t:
payload = _transform_naver_relay(t)
try: # 문자중계는 부가 정보 — 실패해도 필드 뷰는 그대로 서빙
payload["relay"] = _relay_commentary(
await _kbo_relay_groups(client, m, gid, t)
)
except Exception as e: # noqa: BLE001
log.warning("kbo 문자중계 실패 %s: %s", m.match_id, e)
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()
feed = r2.json()
payload = _transform_mlb_feed(feed)
try: # 문자중계는 부가 정보 — 실패해도 필드 뷰는 그대로 서빙
relay = _transform_mlb_relay(feed, m)
if relay:
payload["relay"] = relay
payload["available"] = True # 종료 경기 다시보기 지원
except Exception as e: # noqa: BLE001
log.warning("mlb 문자중계 실패 %s: %s", m.match_id, e)
except Exception as e: # noqa: BLE001
log.warning("live 실패 %s: %s", m.match_id, e)
payload = {"available": False}
_live_cache[m.match_id] = (time.monotonic(), payload)
return payload

View File

@ -0,0 +1,153 @@
"""야구 일정·결과 수집 — 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)
)
status_obj = g.get("status") or {}
# detailedState 는 "Final"/"Game Over"/"Completed Early"(우천 조기종료) 등
# 종료를 뜻하는 문자열이 여러 갈래라 그것만으로 판정하면 일부가 누락된다.
# abstractGameState 는 Preview/Live/Final 세 값만 존재하는 신뢰 가능한 판정 기준.
abstract = status_obj.get("abstractGameState", "")
detailed = status_obj.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": abstract != "Final" and detailed.startswith(
("Postponed", "Cancelled", "Suspended")
),
"gamePk": g.get("gamePk"), # MLB 라이브 피드 키
}
if abstract == "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, days_back: int | None = None) -> list[dict]:
"""리그 일정+결과 수집 (취소 포함). 실패 시 빈 리스트 — 기존 일정 유지.
days_back 기본값은 result_recheck_days(짧은 재확인 윈도우). settle_baseball
미정산 경기 가장 오래된 날짜를 덮도록 값을 넘겨, 오래 방치된
미정산 경기(수집 실패 등으로 정산을 놓친 경기) 결국 잡히게 한다.
"""
days_back = settings.result_recheck_days if days_back is None else days_back
try:
if league == "kbo":
records = await _fetch_kbo(days_back, settings.baseball_days_ahead)
elif league == "mlb":
records = await _fetch_mlb(days_back, 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, days_back: int | None = None) -> list[dict]:
"""확정 스코어만 → [{league, teamA, teamB, dateKst, scoreA, scoreB}]."""
return [r for r in await fetch_baseball_schedule(league, days_back) if "scoreA" in r]

View File

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

View File

@ -0,0 +1,114 @@
"""결과 이메일 발송.
1순위: Azure Communication Services(ACS) Email (endpoint + accesskey).
2순위(폴백): SMTP (aiosmtplib).
미설정이면 EmailUnavailable 던진다(조용한 실패 0).
경기 종료 워커가 구독자(notify=True + email)에게 개인화 결과 메일 발송.
"""
from __future__ import annotations
import logging
from email.message import EmailMessage
from ..config import settings
log = logging.getLogger("triplepick.email")
class EmailUnavailable(RuntimeError):
pass
async def _send_acs(to: str, subject: str, html: str, text: str) -> None:
"""Azure Communication Services Email — 키(accesskey) 인증."""
from azure.communication.email.aio import EmailClient
conn = f"endpoint={settings.azure_acs_endpoint};accesskey={settings.azure_acs_accesskey}"
message = {
"senderAddress": settings.azure_acs_sender,
"recipients": {"to": [{"address": to}]},
"content": {"subject": subject, "plainText": text, "html": html},
}
async with EmailClient.from_connection_string(conn) as client:
poller = await client.begin_send(message)
await poller.result()
log.info("email sent via ACS → %s (%s)", to, subject)
async def _send_smtp(to: str, subject: str, html: str, text: str) -> None:
import aiosmtplib
msg = EmailMessage()
msg["From"] = settings.smtp_from
msg["To"] = to
msg["Subject"] = subject
msg.set_content(text)
msg.add_alternative(html, subtype="html")
await aiosmtplib.send(
msg,
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_user or None,
password=settings.smtp_password or None,
start_tls=settings.smtp_starttls,
)
log.info("email sent via SMTP → %s (%s)", to, subject)
async def send_email(to: str, subject: str, html: str, text: str) -> None:
if settings.acs_configured:
await _send_acs(to, subject, html, text)
return
if settings.smtp_host:
await _send_smtp(to, subject, html, text)
return
# ACS endpoint/accesskey 만 있고 sender 누락 시 명확히 안내
if settings.azure_acs_endpoint and not settings.azure_acs_sender:
raise EmailUnavailable("AZURE_ACS_SENDER(검증된 MailFrom 주소) 미설정")
raise EmailUnavailable("이메일 미설정 — ACS(endpoint+accesskey+sender) 또는 SMTP 필요")
def build_result_email(
*,
team_a: str,
team_b: str,
result_a: int,
result_b: int,
my_a: int,
my_b: int,
my_points: int,
ai_lines: list[tuple[str, bool]], # (모델명, 적중여부)
match_url: str,
) -> tuple[str, str, str]:
"""제목/HTML/텍스트 반환."""
hit = my_points > 0
headline = "적중! 🎯" if hit else "아쉽네요"
subject = f"[TriplePick] {team_a} {result_a}-{result_b} {team_b} 결과 — {headline}"
ai_html = "".join(
f"<li>{m}: {'적중 ✓' if ok else '빗나감'}</li>" for m, ok in ai_lines
)
ai_text = "\n".join(
f" - {m}: {'적중' if ok else '빗나감'}" for m, ok in ai_lines
)
html = f"""\
<div style="font-family:sans-serif;max-width:480px;margin:0 auto">
<h2>경기 결과</h2>
<p style="font-size:20px;font-weight:bold">{team_a} {result_a} - {result_b} {team_b}</p>
<p>당신의 예측: {team_a} {my_a} - {my_b} {team_b}
<strong>{my_points} ({headline})</strong></p>
<h3>AI 3모델 적중 여부</h3>
<ul>{ai_html}</ul>
<p><a href="{match_url}">다음 경기 예측하러 가기 </a></p>
<p style="color:#888;font-size:12px">100만원 챌린지 누적 포인트 1위에게 최종 상금.</p>
</div>"""
text = (
f"경기 결과: {team_a} {result_a}-{result_b} {team_b}\n"
f"당신의 예측: {team_a} {my_a}-{my_b} {team_b}{my_points}점 ({headline})\n\n"
f"AI 3모델 적중 여부:\n{ai_text}\n\n"
f"다음 경기 예측: {match_url}\n"
)
return subject, html, text

View File

@ -0,0 +1,319 @@
"""축구 데이터 연동 (API-Football 무료 티어) — 예측 프롬프트용 수집·캐싱·조립.
설계 요약
- 수집(refresh): 워커가 다가오는 경기의 /H2H 무료 한도(100/·10/) 안에서
점진 갱신해 FootballCache(DB) 저장. 호출 간격(throttle)으로 분당 제한 회피.
- 조립(build_data_block): 예측 생성 캐시만 읽어 프롬프트 블록을 만든다(외부 호출 X).
2026 실제 결과는 우리 DB(matches)에서 직접 계산해 보완(무료 API 2026 시즌 차단).
- FOOTBALL_API_KEY 미설정이면 전부 no-op 기존(이름만) 예측으로 자연 폴백.
무료 티어 제약 우회
- season=2026, last=N 파라미터는 막힘 season(2024,2023) 조회로 /H2H 확보.
- FIFA 랭킹은 API 없음 아래 FIFA_RANK(수동 관리, 외부 무료 소스 기반) 보완.
"""
from __future__ import annotations
import asyncio
import logging
import httpx
from sqlalchemy import or_, select
from ..config import settings
from ..domain import now_utc
from ..models import FootballCache, Match
log = logging.getLogger("triplepick.football")
# 무료 접근 가능한 시즌(최신 우선). 폼 표본 확보용.
_SEASONS = (2024, 2023)
# FIFA 랭킹(수동 관리) — API-Football 에 없어 외부 무료 소스값을 코드코어로 둔다.
# 없는 팀은 블록에서 랭킹 줄을 생략(폴백). 갱신은 가끔 수동.
# FIFA 세계랭킹 — 공식 발표 기준(2026-06-11). API-Football 엔 없어 외부값을 코드에 둔다.
# 갱신: FIFA 공식 랭킹 새로 나오면 숫자만 교체.
FIFA_RANK: dict[str, int] = {
"MEX": 13, "RSA": 61, "KOR": 22, "CZE": 43, # A
"SUI": 19, "BIH": 63, "CAN": 32, "QAT": 49, # B
"SCO": 38, "MAR": 7, "BRA": 6, "HAI": 84, # C
"USA": 15, "AUS": 23, "TUR": 26, "PAR": 42, # D
"GER": 9, "CIV": 29, "ECU": 28, "CUW": 82, # E
"NED": 8, "SWE": 35, "TUN": 56, "JPN": 18, # F
"BEL": 10, "EGY": 30, "IRN": 20, "NZL": 85, # G
"ESP": 2, "CPV": 67, "KSA": 60, "URU": 17, # H
"FRA": 3, "SEN": 16, "IRQ": 57, "NOR": 31, # I
"ARG": 1, "ALG": 27, "AUT": 24, "JOR": 64, # J
"POR": 5, "COD": 45, "UZB": 50, "COL": 14, # K
"ENG": 4, "CRO": 11, "GHA": 73, "PAN": 34, # L
}
# 팀코드→API-Football 팀ID 직접 매핑(이름검색 오인 방지 — 청소년/여자/표기차).
# 48개 본선 팀 전체. 2026 본선 확정명단 기준 시니어 대표팀 ID.
TEAM_ID_OVERRIDE: dict[str, int] = {
"MEX": 16, "RSA": 1531, "KOR": 17, "CZE": 770, # A
"SUI": 15, "BIH": 1113, "CAN": 5529, "QAT": 1569, # B
"SCO": 1108, "MAR": 31, "BRA": 6, "HAI": 2386, # C
"USA": 2384, "AUS": 20, "TUR": 777, "PAR": 2380, # D
"GER": 25, "CIV": 1501, "ECU": 2382, "CUW": 5530, # E
"NED": 1118, "SWE": 5, "TUN": 28, "JPN": 12, # F
"BEL": 1, "EGY": 32, "IRN": 22, "NZL": 4673, # G
"ESP": 9, "CPV": 1533, "KSA": 23, "URU": 7, # H
"FRA": 2, "SEN": 13, "IRQ": 1567, "NOR": 1090, # I
"ARG": 26, "ALG": 1532, "AUT": 775, "JOR": 1548, # J
"POR": 27, "COD": 1508, "UZB": 1568, "COL": 8, # K
"ENG": 10, "CRO": 3, "GHA": 1504, "PAN": 11, # L
}
def _is_senior(name: str) -> bool:
"""청소년/여자/올림픽 팀 제외(시니어 대표팀만)."""
n = name.upper()
if any(b in n for b in ("U23", "U21", "U20", "U19", "U17", "U-23", "WOMEN", "OLYMPIC")):
return False
return not n.endswith(" W")
def enabled() -> bool:
return bool(settings.football_api_key)
# ── 저수준: API 호출 (throttle 포함) ───────────────────────────
async def _get(client: httpx.AsyncClient, path: str, **params) -> dict:
r = await client.get(
settings.football_api_base + path,
headers={"x-apisports-key": settings.football_api_key},
params=params or None,
timeout=25,
)
r.raise_for_status()
data = r.json()
# API-Football 은 한도초과·레이트·파라미터 오류를 HTTP 200 + errors 로 준다.
# 빈 응답을 정상으로 오해해 빈 데이터를 캐싱하지 않도록 여기서 예외 발생.
errs = data.get("errors")
if errs: # 빈 리스트([])는 정상, 채워진 dict 면 오류
raise RuntimeError(f"API-Football error: {errs}")
await asyncio.sleep(settings.football_call_interval_sec) # 10/분 제한 회피
return data
# ── 캐시 upsert ────────────────────────────────────────────────
async def _upsert(db, key: str, payload: dict) -> None:
row = await db.get(FootballCache, key)
if row:
row.payload = payload
row.fetched_at = now_utc()
else:
db.add(FootballCache(key=key, payload=payload, fetched_at=now_utc()))
async def _team_id(db, client, code: str, name: str) -> int | None:
"""팀코드→API팀ID. 1)직접매핑 2)캐시 3)이름검색(시니어 대표팀 필터)."""
if code in TEAM_ID_OVERRIDE:
return TEAM_ID_OVERRIDE[code]
cached = await db.get(FootballCache, f"teamid:{code}")
if cached and cached.payload.get("id"):
return cached.payload["id"]
query = name.split(" (")[0].strip() # "Korea Republic (...)" → "Korea Republic"
data = await _get(client, "/teams", search=query)
nats = [
x["team"] for x in (data.get("response") or [])
if x["team"].get("national") and _is_senior(x["team"]["name"])
]
if not nats:
return None
tid = nats[0]["id"]
await _upsert(db, f"teamid:{code}", {"id": tid, "name": nats[0]["name"]})
return tid
# ── 팀 베이스라인 수집 → 압축 payload ──────────────────────────
async def _fetch_team(db, client, code: str, name: str) -> bool:
tid = await _team_id(db, client, code, name)
if not tid:
log.warning("football: team id 미확인 (%s/%s)", code, name)
return False
sq = await _get(client, "/players/squads", team=tid)
players = (sq.get("response") or [{}])[0].get("players", []) if sq.get("response") else []
fixtures = []
for season in _SEASONS:
d = await _get(client, "/fixtures", team=tid, season=season)
fixtures += d.get("response", [])
results = []
for f in fixtures:
g = f["goals"]
home, away = f["teams"]["home"], f["teams"]["away"]
is_home = home["id"] == tid
gf = g["home"] if is_home else g["away"]
ga = g["away"] if is_home else g["home"]
if gf is None or ga is None:
continue
r = "W" if gf > ga else "L" if gf < ga else "D"
results.append({"date": f["fixture"]["date"][:10], "r": r, "gf": gf, "ga": ga})
results.sort(key=lambda x: x["date"])
n = max(len(results), 1)
w = sum(1 for x in results if x["r"] == "W")
d_ = sum(1 for x in results if x["r"] == "D")
l = sum(1 for x in results if x["r"] == "L")
payload = {
"team_id": tid,
"form": " ".join(x["r"] for x in results[-8:]),
"w": w, "d": d_, "l": l,
"gf_avg": round(sum(x["gf"] for x in results) / n, 2),
"ga_avg": round(sum(x["ga"] for x in results) / n, 2),
"clean_sheets": sum(1 for x in results if x["ga"] == 0),
"stars": [p["name"] for p in players if p.get("position") == "Attacker"][:5],
"squad_n": len(players),
}
await _upsert(db, f"team:{code}", payload)
return True
async def _fetch_h2h(db, client, code_a, id_a, code_b, id_b) -> None:
d = await _get(client, "/fixtures/headtohead", h2h=f"{id_a}-{id_b}")
rows = []
for f in d.get("response", []):
g = f["goals"]
if g["home"] is None:
continue
rows.append({
"date": f["fixture"]["date"][:10],
"home": f["teams"]["home"]["name"], "hg": g["home"],
"ag": g["away"], "away": f["teams"]["away"]["name"],
})
await _upsert(db, f"h2h:{code_a}-{code_b}", {"rows": rows[-5:]})
# 팀 1건 수집 비용(API 호출 수): 스쿼드 1 + 시즌별 경기. H2H 는 1.
_TEAM_CALLS = 1 + len(_SEASONS)
_H2H_CALLS = 1
# ── 수집 잡 (워커 호출) ─────────────────────────────────────────
async def refresh(db, matches: list[Match]) -> int:
"""경기들의 팀/H2H 캐시를 '아직 없는 것만' 한 번씩 수집한다(fetch-once).
과거 시즌 ·스쿼드·H2H 변하지 않고, 2026 진행 결과는 build_data_block
우리 DB 에서 라이브로 읽으므로 팀당 1 수집이면 충분하다. 무료 한도를 넘지
않도록 하루 호출 예산(football_daily_call_budget) 안에서만 받고, 받은 팀은
다음 잡이 이어서 누적한다. 팀이 캐시되면 이후 호출 0(자동 무동작).
이번 잡에서 새로 수집한 반환."""
if not enabled():
return 0
budget = settings.football_daily_call_budget
refreshed = 0
calls = 0
async with httpx.AsyncClient() as client:
seen: set[str] = set()
for m in matches:
for code, name in ((m.team_a_code, m.team_a_name), (m.team_b_code, m.team_b_name)):
if code in seen:
continue
seen.add(code)
if await db.get(FootballCache, f"team:{code}"):
continue # 이미 수집됨 — 다시 받지 않음(fetch-once)
if calls + _TEAM_CALLS > budget:
continue # 오늘 호출 예산 소진 — 남은 팀은 다음 잡에서
try:
if await _fetch_team(db, client, code, name):
refreshed += 1
except Exception as e: # noqa: BLE001
log.warning("football: 팀 %s 수집 실패 %s", code, e)
calls += _TEAM_CALLS # 성공/실패 무관 호출은 소비됨(한도 보호)
# H2H — 양 팀이 캐시됐고 아직 안 받은 쌍만
for m in matches:
if calls + _H2H_CALLS > budget:
break
if await db.get(FootballCache, f"h2h:{m.team_a_code}-{m.team_b_code}"):
continue
ta = await db.get(FootballCache, f"team:{m.team_a_code}")
tb = await db.get(FootballCache, f"team:{m.team_b_code}")
if not (ta and tb):
continue
try:
await _fetch_h2h(db, client, m.team_a_code, ta.payload["team_id"],
m.team_b_code, tb.payload["team_id"])
except Exception as e: # noqa: BLE001
log.warning("football: H2H %s 수집 실패 %s", m.match_id, e)
calls += _H2H_CALLS
await db.commit()
if refreshed or calls:
log.info("football: 신규 팀 %d개 수집 (호출 ~%d/%d)", refreshed, calls, budget)
return refreshed
# ── 라이브 WC 결과 (우리 DB) ───────────────────────────────────
async def _live_wc(db, code: str, before) -> list[str]:
rows = (await db.execute(
select(Match)
.where(
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
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
opp = m.team_b_short if is_a else m.team_a_short
if gf is None or ga is None:
continue
r = "W" if gf > ga else "L" if gf < ga else "D"
out.append(f"{r} {gf}-{ga} vs {opp}")
return out
def _h2h_line(row, default_code: str) -> str:
if not row or not row.payload.get("rows"):
return "no recent meetings"
rows = row.payload["rows"][-3:]
return "; ".join(
f'{r["home"]} {r["hg"]}-{r["ag"]} {r["away"]} ({r["date"][:4]})' for r in rows
)
async def _team_lines(db, code, name, row, kickoff) -> str:
rank = FIFA_RANK.get(code)
head = f"[{name}]" + (f" FIFA #{rank}" if rank else "")
if not row:
return f"{head}\n (no external data — use general knowledge)"
p = row.payload
live = await _live_wc(db, code, kickoff)
wc = "; ".join(live) if live else "(none yet)"
return (
f"{head}\n"
f" Form(last8): {p.get('form','')} | {p.get('w',0)}-{p.get('d',0)}-{p.get('l',0)} "
f"(W-D-L), avg {p.get('gf_avg','?')}-{p.get('ga_avg','?')}, "
f"clean sheets {p.get('clean_sheets',0)}\n"
f" WC2026 so far: {wc}\n"
f" Key attackers: {', '.join(p.get('stars', []))}"
)
async def build_data_block(db, match: Match) -> str | None:
"""경기 예측 프롬프트에 끼울 데이터블록. 캐시만 읽음(외부 호출 X).
모두 데이터가 없으면 None 기존(이름만) 동작 폴백."""
if not enabled():
return None
ta = await db.get(FootballCache, f"team:{match.team_a_code}")
tb = await db.get(FootballCache, f"team:{match.team_b_code}")
# 라이브 WC 결과는 캐시 없어도 의미 있으므로, 캐시가 둘 다 없을 때만 폴백
has_live = bool(await _live_wc(db, match.team_a_code, match.kickoff_at)) or \
bool(await _live_wc(db, match.team_b_code, match.kickoff_at))
if not ta and not tb and not has_live:
return None
h2h = (await db.get(FootballCache, f"h2h:{match.team_a_code}-{match.team_b_code}")) or \
(await db.get(FootballCache, f"h2h:{match.team_b_code}-{match.team_a_code}"))
return "\n".join([
"=== MATCH DATA (factual, weigh heavily over priors) ===",
await _team_lines(db, match.team_a_code, match.team_a_name, ta, match.kickoff_at),
await _team_lines(db, match.team_b_code, match.team_b_name, tb, match.kickoff_at),
f"[Head-to-head] {_h2h_line(h2h, match.team_a_code)}",
"===",
])

View File

@ -0,0 +1,48 @@
"""채점 서비스 — 결과 입력 시 해당 경기 유저 픽 전수 채점 + 유저별 누적.
관리자 setResult 워커가 공용으로 사용. scoring.grade_prediction 적용
(scoring.json 배점) services.points user_points 누적 갱신.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..domain import now_utc
from ..models import Match, UserPrediction
from ..scoring import grade_prediction, outcome_of
from .points import accumulate_user_points
log = logging.getLogger("triplepick.grading")
async def apply_result(db: AsyncSession, match: Match, score_a: int, score_b: int) -> int:
"""결과 기록 + 해당 경기 픽 전수 채점 + 유저별 포인트 누적. 채점된 픽 수 반환."""
match.result_score_a = score_a
match.result_score_b = score_b
match.result_outcome = outcome_of(score_a, score_b)
match.status = "finished"
match.finished_at = now_utc()
picks = (
await db.execute(
select(UserPrediction).where(UserPrediction.match_id == match.match_id)
)
).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, baseball=baseball
)
p.points = value
p.scored_at = now_utc()
# 채점된 픽의 유저(이메일)별 누적 포인트 갱신 — 같은 트랜잭션
await accumulate_user_points(db, {p.email for p in picks})
await db.commit()
log.info("graded %d picks for %s (%d-%d)", len(picks), match.match_id, score_a, score_b)
return len(picks)

View File

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

View File

@ -0,0 +1,100 @@
"""유저별 포인트 누적 — grade_prediction 의 (key, value) 를 user_points 에 반영.
채점(apply_result)에서 호출. 결과 정정(재채점)에도 안전하도록 영향받은
이메일의 채점 가능한 전체를 재집계해 upsert 한다(idempotent 번을
다시 돌려도 같은 결과, 증분 방식의 이중 누적 위험 없음).
"""
from __future__ import annotations
import logging
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..domain import now_utc
from ..models import Match, UserPoints, UserPrediction
from ..scoring import grade_prediction
log = logging.getLogger("triplepick.points")
# scoring.json 의 key → user_points 등급별 횟수 컬럼
_KEY_TO_COL = {
"score_exact": "exact_count",
"score_close": "close_count",
"score_outcome": "outcome_count",
"score_partial": "partial_count",
"score_miss": "miss_count",
}
def _empty() -> dict:
return {
"total_points": 0,
"exact_count": 0,
"close_count": 0,
"outcome_count": 0,
"partial_count": 0,
"miss_count": 0,
"matches_played": 0,
"first_scored_at": None,
}
async def accumulate_user_points(
db: AsyncSession, emails: set[str | None]
) -> int:
"""이메일별 누적 포인트 재집계 → user_points upsert. 갱신 행 수 반환.
커밋은 호출자(apply_result) 책임 채점과 누적이 트랜잭션으로 묶인다.
"""
targets = {e.strip().lower() for e in emails if e and e.strip()}
if not targets:
return 0
rows = (
await db.execute(
select(UserPrediction, Match)
.join(Match, UserPrediction.match_id == Match.match_id)
.where(
func.lower(UserPrediction.email).in_(targets),
Match.result_score_a.is_not(None),
Match.result_score_b.is_not(None),
)
)
).all()
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,
baseball=match.league in ("kbo", "mlb"),
)
t = agg[pick.email.strip().lower()]
t["total_points"] += value
t[_KEY_TO_COL[key]] += 1
t["matches_played"] += 1
ts = pick.scored_at or pick.created_at
if ts and (t["first_scored_at"] is None or ts < t["first_scored_at"]):
t["first_scored_at"] = ts
existing = {
up.email: up
for up in (
await db.execute(select(UserPoints).where(UserPoints.email.in_(targets)))
).scalars()
}
for email, t in agg.items():
row = existing.get(email)
if row is None:
row = UserPoints(email=email)
db.add(row)
for col, val in t.items():
setattr(row, col, val)
row.updated_at = now_utc()
log.info(
"user_points: %d명 누적 갱신 (%s)",
len(agg),
", ".join(f"{e}={t['total_points']}p" for e, t in agg.items()),
)
return len(agg)

View File

@ -0,0 +1,284 @@
"""경기 일정 외부 수집 — 매일 워커가 호출(크롤링).
소스(SCHEDULE_SOURCE):
openfootball raw GitHub JSON ( 불필요, 기본). 2026 Group A = 서비스 6경기.
football-data football-data.org /v4/competitions/WC/matches (FOOTBALL_DATA_TOKEN 필요)
fallback 외부 호출 없이 결과 기존(시드) 일정 유지
반환 표준 레코드:
{teamA: code, teamB: code, kickoffKst: ISO(+09:00), venue: str}
team 코드로 매핑되지 않는 경기(다른 그룹/) 제외.
시각은 절대 시점(UTC)으로 환산 KST(+09:00) 고정 오프셋으로 표기.
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta, timezone
from ..config import settings
from ..schedule_data import code_for
from ..teams_data import code_for_tla
log = logging.getLogger("triplepick.schedule")
KST = timezone(timedelta(hours=9))
# football-data stage → 녹아웃 라운드 라벨(한글). 조별리그는 group 으로 따로 처리.
# 2026 포맷(48개국)은 32강(LAST_32)부터 토너먼트가 시작된다.
KO_STAGE_LABEL = {
"LAST_32": "32강",
"LAST_16": "16강",
"QUARTER_FINALS": "8강",
"SEMI_FINALS": "4강",
"THIRD_PLACE": "3·4위전",
"FINAL": "결승",
}
# "13:00 UTC-6" / "20:00 UTC+2" 형태 파싱
_TIME_RE = re.compile(r"(\d{1,2}):(\d{2})\s*UTC\s*([+-]\d{1,2})")
def _to_kst_iso(date_str: str, time_str: str) -> str | None:
m = _TIME_RE.search(time_str or "")
if not m:
return None
hh, mm, off = int(m.group(1)), int(m.group(2)), int(m.group(3))
try:
y, mo, d = (int(x) for x in date_str.split("-"))
except ValueError:
return None
local = datetime(y, mo, d, hh, mm, tzinfo=timezone(timedelta(hours=off)))
return local.astimezone(KST).isoformat()
async def _fetch_openfootball() -> list[dict]:
import httpx
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(settings.schedule_url)
r.raise_for_status()
data = r.json()
out: list[dict] = []
for m in data.get("matches", []):
if (m.get("group") or "").strip() != settings.schedule_group:
continue
a, b = code_for(m.get("team1", "")), code_for(m.get("team2", ""))
if not a or not b:
continue
kickoff = _to_kst_iso(m.get("date", ""), m.get("time", ""))
if not kickoff:
continue
out.append({"teamA": a, "teamB": b, "kickoffKst": kickoff, "venue": m.get("ground", "")})
return out
async def _fetch_football_data() -> list[dict]:
if not settings.football_data_token:
log.warning("schedule: football-data 토큰 미설정 — 수집 생략")
return []
import httpx
url = (
f"https://api.football-data.org/v4/competitions/"
f"{settings.football_data_competition}/matches"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url, headers={"X-Auth-Token": settings.football_data_token})
r.raise_for_status()
data = r.json()
out: list[dict] = []
for m in data.get("matches", []):
if (m.get("group") or "").replace("GROUP_", "Group ") != settings.schedule_group:
continue
a = code_for((m.get("homeTeam") or {}).get("name", "") or "")
b = code_for((m.get("awayTeam") or {}).get("name", "") or "")
if not a or not b:
continue
utc = m.get("utcDate") # ISO Z
if not utc:
continue
kickoff = (
datetime.fromisoformat(utc.replace("Z", "+00:00")).astimezone(KST).isoformat()
)
venue = m.get("venue") or ""
out.append({"teamA": a, "teamB": b, "kickoffKst": kickoff, "venue": venue})
return out
async def _fetch_all_groups_football_data() -> list[dict]:
"""football-data 에서 전체 조별리그 + 녹아웃 토너먼트 일정 수집.
반환: [{teamA, teamB, group('A'..'L' 또는 ''), roundLabel('' 또는 '32강'..), kickoffKst, venue}]
조별리그는 group 으로, 32 이후 토너먼트는 roundLabel 구분한다(tla코드).
녹아웃 경기는 대진이 확정되기 전엔 팀이 TBD 소스가 tla 주지 않아 자동 제외되고,
조별리그 종료로 대진이 확정되면 다음 동기화에서 자동 등장한다.
"""
if not settings.football_data_token:
log.warning("schedule(all): football-data 토큰 미설정 — 수집 생략")
return []
import httpx
url = (
f"https://api.football-data.org/v4/competitions/"
f"{settings.football_data_competition}/matches"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url, headers={"X-Auth-Token": settings.football_data_token})
r.raise_for_status()
data = r.json()
out: list[dict] = []
for m in data.get("matches", []):
stage = m.get("stage")
if stage == "GROUP_STAGE":
g = (m.get("group") or "").replace("GROUP_", "") # 'A'..'L'
round_label = ""
elif stage in KO_STAGE_LABEL:
g = "" # 녹아웃은 조 없음 — roundLabel 로 구분
round_label = KO_STAGE_LABEL[stage]
else:
continue # 예선/플레이오프 등은 제외
ht = (m.get("homeTeam") or {}).get("tla")
at = (m.get("awayTeam") or {}).get("tla")
if not ht or not at:
continue # 대진 미확정(TBD) — 확정 후 다음 동기화에서 등장
utc = m.get("utcDate")
if not utc:
continue
kickoff = (
datetime.fromisoformat(utc.replace("Z", "+00:00")).astimezone(KST).isoformat()
)
out.append(
{
"teamA": code_for_tla(ht),
"teamB": code_for_tla(at),
"group": g,
"roundLabel": round_label,
"kickoffKst": kickoff,
"venue": m.get("venue") or "",
}
)
return out
async def fetch_schedule() -> list[dict]:
"""일정 수집. 전체 조 모드면 football-data 전 조별리그, 아니면 단일 조."""
if settings.schedule_all_groups:
try:
records = await _fetch_all_groups_football_data()
log.info("schedule: 전체 조별리그 %d경기 수집", len(records))
if records:
return records
except Exception as e: # noqa: BLE001
log.error("schedule(all) 실패: %s — 단일 조 폴백", e)
src = settings.schedule_source.lower()
try:
if src == "openfootball":
records = await _fetch_openfootball()
elif src == "football-data":
records = await _fetch_football_data()
else:
return []
log.info("schedule: %s 에서 %d경기 수집", src, len(records))
return records
except Exception as e: # noqa: BLE001
log.error("schedule fetch 실패(%s): %s — 기존 일정 유지", src, e)
return []
# ── 경기 결과(스코어) 수집 — 자동 정산용 ─────────────────────
def _extract_ft(m: dict) -> tuple[int, int] | None:
"""openfootball 매치에서 정규시간 스코어 추출(다양한 표기 포괄)."""
sc = m.get("score")
if isinstance(sc, dict):
ft = sc.get("ft")
if isinstance(ft, (list, tuple)) and len(ft) == 2 and ft[0] is not None:
return int(ft[0]), int(ft[1])
if m.get("score1") is not None and m.get("score2") is not None:
return int(m["score1"]), int(m["score2"])
return None
async def _results_openfootball() -> list[dict]:
import httpx
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(settings.schedule_url)
r.raise_for_status()
data = r.json()
out: list[dict] = []
for m in data.get("matches", []):
if (m.get("group") or "").strip() != settings.schedule_group:
continue
a, b = code_for(m.get("team1", "")), code_for(m.get("team2", ""))
if not a or not b:
continue
ft = _extract_ft(m)
if ft is None: # 아직 미진행/미집계
continue
out.append({"teamA": a, "teamB": b, "scoreA": ft[0], "scoreB": ft[1]})
return out
async def _results_football_data() -> list[dict]:
if not settings.football_data_token:
return []
import httpx
url = (
f"https://api.football-data.org/v4/competitions/"
f"{settings.football_data_competition}/matches"
)
async with httpx.AsyncClient(timeout=20) as c:
r = await c.get(url, headers={"X-Auth-Token": settings.football_data_token})
r.raise_for_status()
data = r.json()
out: list[dict] = []
for m in data.get("matches", []):
if m.get("status") != "FINISHED":
continue
ht = m.get("homeTeam") or {}
at = m.get("awayTeam") or {}
# 일정 수집과 동일하게 tla(3글자) → 코드. 풀네임 매핑(code_for)은 일부 팀만
# 커버해 누락이 생기므로 tla 우선, 없을 때만 풀네임 폴백.
a = code_for_tla(ht.get("tla") or "") or code_for(ht.get("name") or "")
b = code_for_tla(at.get("tla") or "") or code_for(at.get("name") or "")
if not a or not b:
continue
ft = ((m.get("score") or {}).get("fullTime") or {})
if ft.get("home") is None or ft.get("away") is None:
continue
# 라운드 표기 — 같은 두 팀이 조별리그·토너먼트에서 만나도 정산을 구분(없으면 "").
round_label = KO_STAGE_LABEL.get(m.get("stage") or "", "")
out.append({
"teamA": a, "teamB": b, "scoreA": int(ft["home"]), "scoreB": int(ft["away"]),
"roundLabel": round_label,
})
return out
async def fetch_results() -> list[dict]:
"""확정된 경기 스코어 수집 → [{teamA, teamB, scoreA, scoreB}]. 실패 시 빈 리스트.
소스는 result_source(없으면 schedule_source). openfootball 결과 미게시라
자동 종료에는 football-data 권장.
"""
src = settings.effective_result_source
try:
if src == "openfootball":
out = await _results_openfootball()
elif src == "football-data":
out = await _results_football_data()
else:
return []
if out:
log.info("results: %s 에서 %d경기 스코어 수집", src, len(out))
return out
except Exception as e: # noqa: BLE001
log.error("results fetch 실패(%s): %s", src, e)
return []

View File

@ -0,0 +1,94 @@
"""수집한 일정을 DB에 반영 — 워커(스케줄링 서버)가 매일 호출.
전체 조별리그 모드: 12개조 72경기를 적재. 결과/스코어는 경기 표시.
- 기존 경기: (팀쌍, 순서 무관) 매칭 킥오프/venue/투표시간만 갱신. 결과·예측·crowd·팀명·라운드 보존.
- 신규 경기: teams_data(48개국) 구성 + matchId 생성 + crowd 초기화하여 삽입.
종료(result) 경기는 시각 변경하지 않음.
"""
from __future__ import annotations
import logging
from datetime import timedelta, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import CrowdStats, Match
from ..schedule_data import lock_at, opens_at, parse_kickoff
from ..teams_data import team_info
log = logging.getLogger("triplepick.schedule")
KST = timezone(timedelta(hours=9))
# 녹아웃 라운드 라벨 → match_id 접두어(조가 없는 토너먼트 경기 식별용)
KO_PREFIX = {"32강": "R32", "16강": "R16", "8강": "QF", "4강": "SF", "3·4위전": "P3", "결승": "F"}
def _stage_prefix(group: str, round_label: str) -> str:
"""경기 식별 접두어 — 조별리그는 조 문자, 녹아웃은 라운드 코드.
같은 팀이 조별리그와 결승에서 다시 만나도 서로 다른 경기로 구분된다."""
return group or KO_PREFIX.get(round_label, "KO")
async def sync_schedule(db: AsyncSession, records: list[dict]) -> dict:
if not records:
return {"updated": 0, "inserted": 0, "skipped": 0}
existing = (await db.execute(select(Match))).scalars().all()
# 순서 무관 매칭 — 소스의 홈/원정 순서가 우리와 달라도 기존 경기를 찾음(중복 방지).
# 단계(조/라운드)도 키에 포함 — 같은 두 팀의 조별리그 경기와 토너먼트 경기를 구분.
by_pair: dict[tuple, Match] = {}
for m in existing:
p = _stage_prefix(m.group, m.round_label)
by_pair[(p, m.team_a_code, m.team_b_code)] = m
by_pair[(p, m.team_b_code, m.team_a_code)] = m
updated = inserted = skipped = 0
for rec in records:
a, b = rec["teamA"], rec["teamB"]
round_label = rec.get("roundLabel", "")
# 조별리그는 조 문자, 녹아웃은 조 없음(""). roundLabel 이 있으면 토너먼트 경기.
group = "" if round_label else rec.get("group", settings.featured_group)
prefix = _stage_prefix(group, round_label)
kickoff = parse_kickoff(rec["kickoffKst"])
m = by_pair.get((prefix, a, b))
if m is not None:
if m.result_outcome is not None:
skipped += 1
continue
m.kickoff_at = kickoff
m.opens_at = opens_at(kickoff)
m.lock_at = lock_at(kickoff)
if rec.get("venue"):
m.venue = rec["venue"]
updated += 1
else:
ta, tb = team_info(a), team_info(b)
kst_date = kickoff.astimezone(KST).strftime("%Y%m%d")
match_id = f"{prefix}_{a}_{b}_{kst_date}"
new = Match(
match_id=match_id,
round_label=round_label,
group=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=opens_at(kickoff),
lock_at=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_pair[(prefix, a, b)] = new
by_pair[(prefix, b, a)] = new
inserted += 1
await db.commit()
result = {"updated": updated, "inserted": inserted, "skipped": skipped}
log.info("schedule sync: %s", result)
return result

View File

@ -0,0 +1,845 @@
"""오늘의 응원가 — 경기 맥락 반영 자동 생성 파이프라인 (KBO 전용).
흐름 (워커 tick_songs, song_tick_seconds 주기):
1) 킥오프 song_generate_minutes_before(기본 150) 전에 경기 팀별로
경기 맥락(전날 결과·순위·연전 차수·선발투수) 조립 LLM 가사·스타일 작성
Suno 생성 작업 시작 (Song status=generating)
2) generating 폴링 완료 트랙 URL 저장 (status=complete)
실패는 attempts 3회까지 다음 틱에 재시도. 데이터가 비어도(캐시 미스)
가사는 ·상대·경기 정보만으로 생성한다 조용한 전체 실패 없음.
"""
from __future__ import annotations
import json
import logging
import re
from datetime import date, timedelta, timezone
from sqlalchemy import select
from ..config import settings
from ..database import SessionLocal
from ..domain import ensure_aware, now_utc
from ..models import DataCache, Match, Song, SongAudio
from . import suno
log = logging.getLogger("triplepick.songs")
KST = timezone(timedelta(hours=9))
MAX_ATTEMPTS = 3
GENERATE_TIMEOUT_MIN = 30 # Suno 작업이 이 시간 넘게 미완이면 실패 처리
# 곡 스타일 — 전 곡 고정 (경기장 웅장 앤섬 컨셉, 레퍼런스 확정본).
# 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]
(! ! 베어스!) (! ! 베어스!)
[Verse 1]
잠실의 함성이 하늘을 울려
곰들의 심장이 뜨겁게 뛴다
[Chorus]
두산! (두산!) 베어스! (베어스!)
날려버려 담장 너머로
두산! (두산!) 베어스! (베어스!)
오늘 승리는 우리의
[Bridge - Chant]
(두산 승리! 두산 승리!)
잠실을 가득 채운 함성
[Final Chorus - Key Up]
두산! (두산!) 베어스! (베어스!)
잠실 하늘 높이 울려라
[Outro]
(! ! 베어스!) 최강 두산!"""
# ── 경기 맥락 조립 ─────────────────────────────────────────────
def _fmt_standing(name: str, st: dict | None) -> str:
if not st:
return f"{name}: 순위 정보 없음"
parts = [f"{st.get('rank')}"] if st.get("rank") else []
if st.get("w") is not None:
parts.append(f"{st.get('w')}{st.get('d') or 0}{st.get('l')}")
if st.get("wra"):
parts.append(f"승률 {st.get('wra')}")
if st.get("gb") not in (None, "", "0.0", 0):
parts.append(f"게임차 {st.get('gb')}")
if st.get("last5"):
parts.append(f"최근 5경기 {st.get('last5')}")
return f"{name}: " + ", ".join(parts)
def _fmt_starter(label: str, s: dict | None) -> str | None:
if not s or not s.get("name"):
return None
bits = [s["name"]]
if s.get("era") not in (None, ""):
bits.append(f"평균자책 {s['era']}")
if s.get("w") is not None:
bits.append(f"{s.get('w')}{s.get('l') or 0}")
return f"{label} 선발: " + " ".join(bits)
async def _yesterday_info(db, m: Match, code: str, name: str) -> tuple[str | None, dict]:
"""(전날 결과 한 줄 + 활약 하이라이트, 시즌타율 맵). 경기 없으면 맵은 빈 dict."""
line, g = await _yesterday_result(db, m, code, name)
avg_map: dict = {}
if g is not None and m.league in ("kbo", "mlb"):
try:
stats = await (
_mlb_team_game_stats(g, code)
if m.league == "mlb"
else _kbo_team_game_stats(g, code)
)
except Exception as e: # noqa: BLE001
log.warning("어제 경기 기록 조회 실패 %s: %s", g.match_id, e)
stats = None
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
async def _yesterday_result(
db, m: Match, code: str, name: str
) -> tuple[str | None, Match | None]:
"""해당 팀의 전날 경기 결과 한 줄 + 그 경기 행 (없으면 (안내문, None))."""
day = ensure_aware(m.kickoff_at).astimezone(KST).date() - timedelta(days=1)
lo = ensure_aware(m.kickoff_at).astimezone(KST).replace(
hour=0, minute=0, second=0, microsecond=0
) - timedelta(days=1)
hi = lo + timedelta(days=1)
rows = (
await db.execute(
select(Match).where(
Match.league == m.league,
Match.result_outcome.is_not(None),
Match.kickoff_at >= lo.astimezone(timezone.utc),
Match.kickoff_at < hi.astimezone(timezone.utc),
(Match.team_a_code == code) | (Match.team_b_code == code),
)
)
).scalars().all()
if not rows:
return f"어제({day.month}/{day.day})는 경기가 없었다", None
g = rows[-1]
is_a = g.team_a_code == code
my, opp = (
(g.result_score_a, g.result_score_b) if is_a else (g.result_score_b, g.result_score_a)
)
opp_name = g.team_b_short if is_a else g.team_a_short
if my is None or opp is None:
return None, None
verdict = "승리" if my > opp else "패배" if my < opp else "무승부"
margin = abs((my or 0) - (opp or 0))
tight = " (1점차 석패)" if verdict == "패배" and margin == 1 else (
" (1점차 신승)" if verdict == "승리" and margin == 1 else ""
)
return f"어제 {opp_name}{my}:{opp} {verdict}{tight}", g
async def _series_line(db, m: Match) -> str | None:
"""같은 팀 상대 연전 차수 — '3연전 중 2차전' 형태 (단일 경기면 None)."""
center = ensure_aware(m.kickoff_at).astimezone(KST).date()
lo = center - timedelta(days=3)
hi = center + timedelta(days=4)
pair = {m.team_a_code, m.team_b_code}
rows = (
await db.execute(
select(Match).where(
Match.league == m.league,
Match.team_a_code.in_(pair),
Match.team_b_code.in_(pair),
)
)
).scalars().all()
days: list[date] = sorted(
{
ensure_aware(r.kickoff_at).astimezone(KST).date()
for r in rows
if lo <= ensure_aware(r.kickoff_at).astimezone(KST).date() < hi
}
)
# 오늘을 포함해 연속된 날짜 구간만 자른다
if center not in days:
return None
run = [center]
for d in reversed([d for d in days if d < center]):
if (run[0] - d).days == 1:
run.insert(0, d)
else:
break
for d in [d for d in days if d > center]:
if (d - run[-1]).days == 1:
run.append(d)
else:
break
if len(run) < 2:
return None
idx = run.index(center) + 1
return f"{len(run)}연전 중 {idx}차전"
async def fetch_lineups(m: Match) -> dict | None:
"""오늘 선발 라인업 조회 (발표 전이면 announced=False). KBO=네이버, MLB=공식 API.
MLS 라인업 소스 미연동 None (라인업 없이 생성).
"""
if m.league == "mlb":
return await _fetch_lineups_mlb(m)
if m.league == "kbo":
return await _fetch_lineups_kbo(m)
return None
async def _fetch_lineups_kbo(m: Match) -> dict | None:
"""네이버 preview fullLineUp — 발표 전엔 선발투수 1명만. 양팀 타자 8명 이상 = 발표."""
import httpx
from .baseball_details import UA, naver_game_id_candidates
async with httpx.AsyncClient(timeout=15) as client:
for gid in naver_game_id_candidates(m):
try:
r = await client.get(
f"{settings.naver_api_base}/schedule/games/{gid}/preview",
headers=UA,
)
r.raise_for_status()
data = r.json()
except Exception: # noqa: BLE001 — gameId 후보 불일치는 다음 후보로
continue
if not data.get("success"):
continue
p = (data.get("result") or {}).get("previewData") or {}
def batters(key: str) -> list[dict]:
fl = ((p.get(key) or {}).get("fullLineUp")) or []
return [
e for e in fl
if e.get("playerName") and e.get("positionName") != "선발투수"
]
away, home = batters("awayTeamLineUp"), batters("homeTeamLineUp")
return {
"away": away,
"home": home,
"announced": len(away) >= 8 and len(home) >= 8,
}
return None
async def _mlb_game_pk(m: Match) -> int | None:
"""MLB schedule 에서 이 경기의 gamePk 해석 (더블헤더는 차수 매칭)."""
import httpx
from datetime import datetime
from ..teams_baseball import MLB_ID_TO_CODE
from .baseball_sync import match_seq
kick = ensure_aware(m.kickoff_at)
date_kst = kick.astimezone(KST).strftime("%Y%m%d")
start = (kick - timedelta(days=1)).date().isoformat()
end = kick.date().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={end}"
)
r.raise_for_status()
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
return cands[idx][1] if idx < len(cands) else None
async def _mlb_boxscore_teams(pk: int) -> dict | None:
import httpx
async with httpx.AsyncClient(timeout=15) as c:
r = await c.get(f"{settings.mlb_api_base}/v1/game/{pk}/boxscore")
r.raise_for_status()
return r.json().get("teams") or {}
async def _fetch_lineups_mlb(m: Match) -> dict | None:
"""MLB boxscore battingOrder — 발표 전엔 빈 배열. 양팀 9명 이상 = 발표.
boxscore seasonStats 타자의 시즌 타율도 함께 싣는다.
"""
pk = await _mlb_game_pk(m)
if not pk:
return None
teams = await _mlb_boxscore_teams(pk)
if teams is None:
return None
def batters(side_key: str) -> list[dict]:
t = teams.get(side_key) or {}
players = t.get("players") or {}
out = []
for pid in t.get("battingOrder") or []:
p = players.get(f"ID{pid}") or {}
name = (p.get("person") or {}).get("fullName")
if name:
avg = (((p.get("seasonStats") or {}).get("batting")) or {}).get("avg")
out.append({
"playerName": name,
"positionName": (p.get("position") or {}).get("abbreviation", ""),
"avg": avg,
})
return out
away, home = batters("away"), batters("home")
return {"away": away, "home": home, "announced": len(away) >= 9 and len(home) >= 9}
# ── 최근 경기 타자 기록 (활약 하이라이트 + 시즌 타율 맵) ────────
async def _kbo_team_game_stats(g: Match, code: str) -> dict | None:
"""종료된 KBO 경기 record → 그 팀 타자 시즌타율 맵 + 활약(홈런·멀티히트)."""
import httpx
from .baseball_details import UA, naver_game_id_candidates
is_away = g.team_a_code == code
async with httpx.AsyncClient(timeout=15) as client:
for gid in naver_game_id_candidates(g):
try:
r = await client.get(
f"{settings.naver_api_base}/schedule/games/{gid}/record",
headers=UA,
)
r.raise_for_status()
data = r.json()
except Exception: # noqa: BLE001 — gameId 후보 불일치는 다음 후보로
continue
if not data.get("success"):
continue
rec = (data.get("result") or {}).get("recordData") or {}
batters = (rec.get("battersBoxscore") or {}).get(
"away" if is_away else "home"
) or []
if not batters:
continue
avg = {b["name"]: b.get("hra") for b in batters if b.get("name")}
hi = []
for b in batters:
if b.get("hr"):
hi.append(f"{b['name']} 홈런 {b['hr']}")
elif (b.get("hit") or 0) >= 3:
hi.append(f"{b['name']} {b['hit']}안타 맹타")
return {"avg": avg, "highlights": hi[:4]}
return None
async def _mlb_team_game_stats(g: Match, code: str) -> dict | None:
"""종료된 MLB 경기 boxscore → 그 팀 활약(홈런·멀티히트)."""
pk = await _mlb_game_pk(g)
if not pk:
return None
teams = await _mlb_boxscore_teams(pk)
t = (teams or {}).get("away" if g.team_a_code == code else "home") or {}
hi = []
for p in (t.get("players") or {}).values():
st = ((p.get("stats") or {}).get("batting")) or {}
name = (p.get("person") or {}).get("fullName")
if not name or not st:
continue
if st.get("homeRuns"):
hi.append(f"{name} 홈런 {st['homeRuns']}")
elif (st.get("hits") or 0) >= 3:
hi.append(f"{name} {st['hits']}안타 맹타")
return {"avg": {}, "highlights": hi[:4]}
def _lineup_line(lineups: dict | None, side: str, avg_map: dict | None = None) -> str | None:
if not lineups or not lineups.get("announced"):
return None
avg_map = avg_map or {}
parts = []
for i, e in enumerate(lineups["away" if side == "a" else "home"][:10]):
avg = e.get("avg") or avg_map.get(e["playerName"])
tail = f", 타율 {avg}" if avg else ""
parts.append(f"{i + 1}{e['playerName']}({e.get('positionName', '')}{tail})")
return f"오늘 확정 선발 라인업: {', '.join(parts)}" if parts else None
async def build_context(
db, m: Match, side: str, lineups: dict | None = None
) -> tuple[str, str, str]:
"""(팀코드, 팀명, 맥락 텍스트). side = 'a'(원정) | 'b'(홈)."""
code = m.team_a_code if side == "a" else m.team_b_code
name = m.team_a_short if side == "a" else m.team_b_short
opp_name = m.team_b_short if side == "a" else m.team_a_short
opp_code = m.team_b_code if side == "a" else m.team_a_code
home = "" if side == "b" else "원정"
kick = ensure_aware(m.kickoff_at).astimezone(KST)
lines = [
f"우리 팀: {name} ({home} 경기)",
f"오늘 경기: {kick.month}/{kick.day} {kick:%H:%M} {m.venue or ''} — 상대 {opp_name}",
]
st_row = await db.get(DataCache, f"standings:{m.league}")
st = st_row.payload if st_row else {}
lines.append(_fmt_standing(name, st.get(code)))
lines.append(_fmt_standing(f"상대 {opp_name}", st.get(opp_code)))
prev_row = await db.get(DataCache, f"preview:{m.match_id}")
prev = prev_row.payload if prev_row else {}
my_starter = _fmt_starter("우리 팀", prev.get("starterA" if side == "a" else "starterB"))
opp_starter = _fmt_starter("상대", prev.get("starterB" if side == "a" else "starterA"))
for s in (my_starter, opp_starter):
if s:
lines.append(s)
vs = prev.get("seasonVs")
if vs:
mine = vs.get("aWin") if side == "a" else vs.get("bWin")
theirs = vs.get("bWin") if side == "a" else vs.get("aWin")
if mine is not None and theirs is not None:
lines.append(f"시즌 상대전적 {mine}{vs.get('draw') or 0}{theirs}")
y, avg_map = await _yesterday_info(db, m, code, name)
if y:
lines.append(y)
series = await _series_line(db, m)
if series:
lines.append(f"오늘은 {opp_name}와의 {series}")
lu = _lineup_line(lineups, side, avg_map)
if lu:
lines.append(lu)
return code, name, "\n".join(x for x in lines if x)
# ── LLM 작사 ───────────────────────────────────────────────────
_LEAGUE_LABEL = {
"kbo": "한국 프로야구(KBO)",
"mlb": "메이저리그(MLB)",
"mls": "미국 프로축구(MLS)",
}
def _lyrics_prompt(team_name: str, context: str, league: str = "kbo") -> str:
return (
f"너는 {_LEAGUE_LABEL.get(league, '프로야구')} 응원가 전문 작사가다. "
"아래 오늘 경기 정보를 바탕으로 "
f"'{team_name}'의 **오늘의 응원가**를 한국어로 만들어라"
+ (" (팀명·선수명은 한국 팬에게 익숙한 표기로)" if league in ("mlb", "mls") else "")
+ ".\n\n"
f"[오늘 경기 정보]\n{context}\n\n"
"[요구사항]\n"
"- 경기장에서 수만 관중이 떼창하는 웅장한 스타디움 앤섬\n"
"- 오늘 경기 맥락(어제/최근 결과 설욕·기세, 순위 싸움, 연전 차수, 선발·라인업)을 "
"가사에 구체적으로 녹일 것 — 선수 실명 사용 가능\n"
"- 숫자는 자연스럽게 표기 (13:1, 78승, 2차전 등 — 발음 변환은 시스템이 처리)\n"
"- 괄호로 관중 콜앤리스폰스 파트 표기, 섹션 태그에 연주 지시 포함\n"
"- 섹션 구성 필수: [Intro] → [Verse 1] → [Chorus] → [Verse 2] → [Chorus] → "
"[Bridge] → [Final Chorus] → [Outro] 전부 포함 (생략 금지), 가사 본문 700자 이상\n\n"
f"[가사 형식 레퍼런스 — 구조와 톤만 참고, 내용은 오늘 경기에 맞게 새로 쓸 것]\n{_REFERENCE}\n\n"
"다음 키를 가진 JSON 객체 하나만 출력하라:\n"
' "title": 곡 제목 (한국어, 25자 이내, 오늘 경기 느낌이 나게),\n'
' "lyrics": 위 형식의 전체 가사\n'
)
# ── 가사 숫자 한글화 (Suno 가 아라비아 숫자를 잘못 읽는 문제 방지) ──
_SINO = "영일이삼사오육칠팔구"
_NATIVE = {
1: "", 2: "", 3: "", 4: "", 5: "다섯", 6: "여섯", 7: "일곱",
8: "여덟", 9: "아홉", 10: "", 11: "열한", 12: "열두", 13: "열세",
14: "열네", 15: "열다섯", 16: "열여섯", 17: "열일곱", 18: "열여덟",
19: "열아홉", 20: "스무",
}
def _sino(n: int) -> str:
"""한자어 수사 — 78 → 칠십팔 (0~9999)."""
if n == 0:
return ""
parts = []
for unit, name in ((1000, ""), (100, ""), (10, "")):
d, n = divmod(n, unit)
if d:
parts.append(("" if d == 1 else _SINO[d]) + name)
if n:
parts.append(_SINO[n])
return "".join(parts)
def _native(n: int) -> str:
"""고유어 수사 — 7 → 일곱 (범위 밖은 한자어 폴백)."""
return _NATIVE.get(n) or _sino(n)
# 서수 관형형 — 1~4는 첫/두/세/네, 이후는 고유어 수사 그대로 (다섯 번째)
_ORDINAL = {1: "", 2: "", 3: "", 4: ""}
def hangulize_numbers(text: str) -> str:
"""가사 속 아라비아 숫자를 한글 발음으로 치환 (Suno 전송본 전용).
1일승, 78칠십팔승, 7게임일곱 게임, 5:3 , 0.312영점삼일이.
[Verse 1] 같은 섹션 태그는 Suno 구조 인식용이라 건드리지 않는다.
"""
parts = re.split(r"(\[[^\]]*\])", text)
return "".join(
p if p.startswith("[") else _hangulize_plain(p) for p in parts
)
def _hangulize_plain(text: str) -> str:
# 소수(타율 등): 0.312 → 영점삼일이 (소수부는 자리별 낭독)
text = re.sub(
r"(\d)\.(\d+)",
lambda m: _sino(int(m.group(1))) + "" + "".join(_SINO[int(c)] for c in m.group(2)),
text,
)
# 스코어: 5:3 / 5대3 → 오 대 삼
text = re.sub(
r"(\d+)\s*[:대]\s*(\d+)",
lambda m: f"{_sino(int(m.group(1)))}{_sino(int(m.group(2)))}",
text,
)
# 아웃 카운트는 야구 관례상 영어 수사 (2아웃→투아웃)
text = re.sub(
r"([123])\s*아웃",
lambda m: {1: "", 2: "", 3: "쓰리"}[int(m.group(1))] + "아웃",
text,
)
# 서수는 관형 고유어 수사 (1번째→첫 번째, 4번째→네 번째, 5번째→다섯 번째)
text = re.sub(
r"(\d+)\s*번째",
lambda m: (_ORDINAL.get(int(m.group(1))) or _native(int(m.group(1)))) + " 번째",
text,
)
# 고유어 조수사 (7게임→일곱 게임, 2방→두 방, 3개→세 개)
text = re.sub(
r"(\d+)\s*(게임|경기|개|명|방|골|마리|살|바퀴)",
lambda m: f"{_native(int(m.group(1)))} {m.group(2)}",
text,
)
# 남은 모든 숫자는 한자어 수사 (1위→일위, 2차전→이차전, 15호→십오호)
return re.sub(r"\d+", lambda m: _sino(int(m.group(0))), text)
def _parse_json(text: str) -> dict:
t = text.strip()
t = re.sub(r"^```(?:json)?\s*|\s*```$", "", t)
m = re.search(r"\{.*\}", t, re.S)
return json.loads(m.group(0) if m else t)
async def write_lyrics(team_name: str, context: str, league: str = "kbo") -> dict:
"""LLM 으로 {title, style, lyrics} 생성 — Claude 우선, GPT 폴백."""
prompt = _lyrics_prompt(team_name, context, league)
if settings.anthropic_api_key:
from anthropic import AsyncAnthropic
client = AsyncAnthropic(api_key=settings.anthropic_api_key)
msg = await client.messages.create(
model=settings.anthropic_model,
max_tokens=8000,
messages=[{"role": "user", "content": prompt}],
)
text = "".join(b.text for b in msg.content if getattr(b, "type", "") == "text")
return _parse_json(text)
if settings.openai_api_key:
from openai import AsyncOpenAI
client = AsyncOpenAI(api_key=settings.openai_api_key)
resp = await client.chat.completions.create(
model=settings.openai_model,
messages=[
{"role": "system", "content": "You output only valid JSON."},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
)
return _parse_json(resp.choices[0].message.content or "{}")
raise RuntimeError("작사용 LLM 키 미설정 (ANTHROPIC/OPENAI)")
# ── 생성 시작 · 폴링 ───────────────────────────────────────────
SONG_LEAGUES = ("kbo", "mlb", "mls") # 응원가 대상 리그
# 라인업 발표를 기다리는 리그 — MLS 는 라인업 소스가 없어 윈도우 진입 즉시 생성
LINEUP_LEAGUES = ("kbo", "mlb")
def _song_leagues() -> list[str]:
return [l for l in SONG_LEAGUES if l in settings.league_list]
def _enabled() -> bool:
return bool(settings.songs_enabled and settings.suno_api_key and _song_leagues())
async def _start_one(db, m: Match, side: str, lineups: dict | None) -> bool:
announced = bool(lineups and lineups.get("announced"))
code, name, context = await build_context(db, m, side, lineups)
row = (
await db.execute(
select(Song).where(Song.match_id == m.match_id, Song.team_code == code)
)
).scalars().first()
if row:
if row.attempts >= MAX_ATTEMPTS:
return False
if row.status == "generating":
return False
# complete 은 '라인업 없이 만든 곡 + 라인업 발표됨'일 때만 재생성(업그레이드)
if row.status == "complete" and (row.with_lineup or not announced):
return False
piece = await write_lyrics(name, context, m.league)
title = str(piece.get("title") or f"{name} 오늘의 응원가").strip()[:40]
style = style_for(m.league) # 전 곡 고정 스타일 — LLM 변주 없음
lyrics = str(piece.get("lyrics") or "").strip()
if not lyrics:
raise RuntimeError("LLM 가사 비어있음")
if len(lyrics) < 450:
raise RuntimeError(f"가사 너무 짧음({len(lyrics)}자) — 재시도")
# 표기용(화면 '가사 보기')은 원문 그대로 저장하고,
# Suno 전송본만 숫자를 한글 발음으로 치환 (13:1 → 십삼 대 일)
sung_lyrics = hangulize_numbers(lyrics)
task_id = await suno.start_generation(title, style, sung_lyrics)
upgrade = bool(row and row.status == "complete")
if row is None:
row = Song(match_id=m.match_id, team_code=code)
db.add(row)
row.league = m.league
row.date_kst = ensure_aware(m.kickoff_at).astimezone(KST).date()
row.team_name = name
row.title = title
row.style = style
row.lyrics = lyrics
row.task_id = task_id
row.status = "generating"
row.error = ""
row.with_lineup = announced
# 업그레이드는 기존 트랙을 유지 — 새 곡 완성 시점에 교체 (재생 공백 없음)
if not upgrade:
row.tracks = []
row.attempts = (row.attempts or 0) + 1
await db.commit()
log.info(
"song 생성 시작: %s %s (task=%s, 라인업=%s%s)",
m.match_id, name, task_id, announced, ", 업그레이드" if upgrade else "",
)
return True
async def start_due_songs(db, force_today: bool = False) -> int:
"""생성 윈도우에 든 경기의 팀별 응원가 생성 시작.
윈도우 안에서는 라인업 발표를 기다렸다가 생성하고, 킥오프
song_lineup_fallback_minutes_before 전까지 미발표면 라인업 없이 생성한다.
라인업 없이 만든 곡은 발표 자동 재생성(업그레이드).
force_today=오늘 경기 즉시(라인업 대기 없이) 생성 테스트용.
"""
now = now_utc()
conds = [
Match.league.in_(_song_leagues()),
Match.result_outcome.is_(None),
Match.status.notin_(("cancelled", "finished")),
]
matches = (await db.execute(select(Match).where(*conds))).scalars().all()
today = now.astimezone(KST).date()
started = 0
for m in matches:
kick = ensure_aware(m.kickoff_at)
mins = (kick - now).total_seconds() / 60
if force_today:
if kick.astimezone(KST).date() != today:
continue
elif not (0 < mins <= settings.song_generate_minutes_before):
continue
try:
lineups = await fetch_lineups(m)
except Exception as e: # noqa: BLE001
log.warning("lineup 조회 실패 %s: %s", m.match_id, e)
lineups = None
announced = bool(lineups and lineups.get("announced"))
# 윈도우 내 라인업 대기 — 폴백 시점 전엔 발표될 때까지 생성 보류.
# 라인업 소스가 없는 리그(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:
if await _start_one(db, m, side, lineups):
started += 1
except Exception as e: # noqa: BLE001 — 팀 단위 독립 실패
await db.rollback()
log.error("song 생성 실패 %s side=%s: %s", m.match_id, side, e)
return started
def _mark_failed(row: Song, error: str) -> None:
"""실패 처리 — 업그레이드 중이었으면(이전 트랙 보유) 이전 곡으로 복귀."""
row.error = error
if row.tracks:
row.status = "complete"
row.with_lineup = False # 다음 틱에 업그레이드 재시도 (attempts 상한 내)
else:
row.status = "failed"
async def poll_generating(db) -> int:
"""generating 상태 Suno 작업 폴링 → 완료/실패 반영."""
rows = (
await db.execute(select(Song).where(Song.status == "generating"))
).scalars().all()
done = 0
for row in rows:
try:
data = await suno.get_task(row.task_id)
except Exception as e: # noqa: BLE001
log.warning("song 폴링 실패 %s: %s", row.task_id, e)
continue
status = data.get("status") or ""
if status == suno.SUNO_DONE:
tracks = suno.extract_tracks(data)
if tracks:
row.tracks = tracks
row.status = "complete"
done += 1
log.info("song 완료: %s %s (%d트랙)", row.match_id, row.team_name, len(tracks))
else:
_mark_failed(row, "SUCCESS 인데 트랙 없음")
elif status in suno.SUNO_FAILED:
_mark_failed(row, f"{status}: {data.get('errorMessage') or ''}"[:300])
log.warning("song 실패: %s %s", row.match_id, row.error)
else:
# 진행 중 — 오래 걸리면 실패 처리 후 재시도 대상으로
created = ensure_aware(row.created_at) if row.created_at else now_utc()
if (now_utc() - created).total_seconds() > GENERATE_TIMEOUT_MIN * 60:
_mark_failed(row, f"타임아웃({status})")
await db.commit()
return done
PERSIST_MAX_FAILS = 3 # 트랙별 다운로드 실패 상한 (만료 URL 무한 재시도 방지)
PERSIST_PER_TICK = 5 # 틱당 다운로드 곡 수 상한 — 틱 지연 방지
async def persist_audio(db, limit: int = PERSIST_PER_TICK) -> int:
"""완성 곡의 노출 트랙(첫 트랙) 음원을 DB(SongAudio)로 보존.
Suno CDN URL 임시라 종료된 경기의 응원가도 계속 재생하려면 원본을
내려받아야 한다. audioUrl 아직 원격(http) complete 곡을 골라
다운로드 SongAudio 교체 저장 audioUrl 자체 경로로 재작성.
업그레이드로 트랙이 원격 URL 갈리면 자동으로 다시 저장된다.
"""
import httpx
rows = (
await db.execute(select(Song).where(Song.status == "complete"))
).scalars().all()
todo = []
for row in rows:
t = (row.tracks or [None])[0]
if not t or not (t.get("audioUrl") or "").startswith("http"):
continue
if (t.get("persistFails") or 0) >= PERSIST_MAX_FAILS:
continue
todo.append(row)
saved = 0
for row in todo[:limit]:
t = dict(row.tracks[0])
try:
async with httpx.AsyncClient(timeout=90, follow_redirects=True) as c:
r = await c.get(t["audioUrl"])
r.raise_for_status()
body = r.content
if not body:
raise ValueError("빈 응답")
mime = (r.headers.get("content-type") or "audio/mpeg").split(";")[0]
except Exception as e: # noqa: BLE001
t["persistFails"] = (t.get("persistFails") or 0) + 1
row.tracks = [t, *row.tracks[1:]]
await db.commit()
log.warning(
"song 음원 보존 실패 %s %s (%d회): %s",
row.match_id, row.team_code, t["persistFails"], e,
)
continue
existing = (
await db.execute(
select(SongAudio).where(
SongAudio.song_id == row.id, SongAudio.track_idx == 0
)
)
).scalar_one_or_none()
if existing:
await db.delete(existing)
await db.flush()
db.add(SongAudio(song_id=row.id, track_idx=0, mime=mime, size=len(body), data=body))
t["sourceUrl"] = t["audioUrl"]
t["audioUrl"] = f"/api/songs/audio/{row.id}/0?v={(row.task_id or '')[:8]}"
t.pop("persistFails", None)
row.tracks = [t, *row.tracks[1:]]
await db.commit()
saved += 1
log.info(
"song 음원 보존: %s %s (%.1fMB)", row.match_id, row.team_code, len(body) / 1e6
)
return saved
async def tick_songs() -> None:
"""워커 주기 작업 — 생성 시작 + 폴링 + 음원 보존. 미설정 시 no-op."""
if not _enabled():
return
async with SessionLocal() as db:
try:
await start_due_songs(db)
except Exception as e: # noqa: BLE001
log.error("song start 오류: %s", e)
try:
await poll_generating(db)
except Exception as e: # noqa: BLE001
log.error("song poll 오류: %s", e)
try:
await persist_audio(db)
except Exception as e: # noqa: BLE001
log.error("song 음원 보존 오류: %s", e)

View File

@ -0,0 +1,104 @@
"""Suno 음악 생성 클라이언트 — sunoapi.org 서드파티 게이트웨이.
공식 Suno API 없어 게이트웨이를 쓴다. 흐름:
POST /api/v1/generate (customMode: 가사·스타일·제목) taskId
GET /api/v1/generate/record-info?taskId= status 폴링 sunoData[] (보통 2)
오디오/커버는 게이트웨이 CDN URL 그대로 쓴다 (당일 소비 콘텐츠).
"""
from __future__ import annotations
import logging
from ..config import settings
log = logging.getLogger("triplepick.suno")
# record-info status 값
SUNO_DONE = "SUCCESS"
SUNO_FAILED = {
"CREATE_TASK_FAILED",
"GENERATE_AUDIO_FAILED",
"CALLBACK_EXCEPTION",
"SENSITIVE_WORD_ERROR",
}
class SunoUnavailable(RuntimeError):
"""SUNO_API_KEY 미설정 등으로 호출 불가."""
class SunoError(RuntimeError):
"""게이트웨이가 에러 코드를 반환."""
def _headers() -> dict:
if not settings.suno_api_key:
raise SunoUnavailable("SUNO_API_KEY 미설정")
return {
"Authorization": f"Bearer {settings.suno_api_key}",
"Content-Type": "application/json",
}
async def start_generation(title: str, style: str, lyrics: str) -> str:
"""생성 작업 시작 → taskId. customMode: 가사(prompt)·style·title 을 그대로 사용."""
import httpx
payload = {
"prompt": lyrics[:4900],
"style": style[:950],
"title": title[:80],
"customMode": True,
"instrumental": False,
"model": settings.suno_model,
# 게이트웨이 필수 파라미터 — 실제 완료 감지는 폴링으로 한다(콜백은 싱크대).
"callBackUrl": f"{settings.public_origin}/api/songs/callback",
}
async with httpx.AsyncClient(timeout=30) as c:
r = await c.post(
f"{settings.suno_api_base}/api/v1/generate",
json=payload,
headers=_headers(),
)
r.raise_for_status()
data = r.json()
if data.get("code") != 200 or not (data.get("data") or {}).get("taskId"):
raise SunoError(f"generate 실패: {data.get('code')} {data.get('msg')}")
return data["data"]["taskId"]
async def get_task(task_id: str) -> dict:
"""작업 상태 조회 — {status, response: {sunoData: [...]}} 형태의 data 반환."""
import httpx
async with httpx.AsyncClient(timeout=30) as c:
r = await c.get(
f"{settings.suno_api_base}/api/v1/generate/record-info",
params={"taskId": task_id},
headers=_headers(),
)
r.raise_for_status()
data = r.json()
if data.get("code") != 200:
raise SunoError(f"record-info 실패: {data.get('code')} {data.get('msg')}")
return data.get("data") or {}
def extract_tracks(task_data: dict) -> list[dict]:
"""record-info data → 표준 트랙 목록 [{title, audioUrl, imageUrl, duration}]."""
items = ((task_data.get("response") or {}).get("sunoData")) or []
out = []
for it in items:
url = it.get("audioUrl") or it.get("sourceAudioUrl") or ""
if not url:
continue
out.append(
{
"title": it.get("title") or "",
"audioUrl": url,
"imageUrl": it.get("imageUrl") or it.get("sourceImageUrl") or "",
"duration": it.get("duration"),
}
)
return out

View File

@ -0,0 +1,85 @@
"""야구 팀 데이터 — 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]:
if league == "mls":
from .teams_mls import MLS_TEAMS
return MLS_TEAMS
return KBO_TEAMS if league == "kbo" else MLB_TEAMS
def team_info(league: str, code: str) -> dict:
"""flag 에 로고 경로/URL 을 실어 프론트(TeamFlag)가 그대로 렌더한다.
KBO: 로컬 자산(/assets/teams/kbo/*.png) · MLB: 공식 CDN(mlbstatic) SVG ·
MLS: ESPN CDN PNG."""
c = (code or "").strip().upper()
t = teams_of(league).get(c)
if not t:
return {"name": c, "shortName": c, "code": c, "flag": ""}
if league == "mlb":
flag = f"https://www.mlbstatic.com/team-logos/{t['mlb_id']}.svg"
elif league == "mls":
flag = f"https://a.espncdn.com/i/teamlogos/soccer/500/{t['espn_id']}.png"
else:
flag = f"/assets/teams/kbo/{c.lower()}.png"
return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": flag}

76
backend/app/teams_data.py Normal file
View File

@ -0,0 +1,76 @@
"""2026 월드컵 48개국 팀 데이터 — 코드(대문자 FIFA) → 한글/약식/영문.
국기는 frontend /assets/flags/{code소문자}.svg 사용(48 자산 보유).
football-data tla 코드 변환은 TLA_REMAP(대부분 동일, CUR/URY만 예외).
"""
from __future__ import annotations
# football-data tla → 내부 코드(=FIFA 국기 코드). 대부분 동일, 예외만 매핑.
TLA_REMAP = {"CUR": "CUW", "URY": "URU"}
def code_for_tla(tla: str) -> str:
tla = (tla or "").strip().upper()
return TLA_REMAP.get(tla, tla)
# 코드 → {ko(전체명), short(약식), en}
TEAMS: dict[str, dict] = {
"ALG": {"ko": "알제리", "short": "알제리", "en": "Algeria"},
"ARG": {"ko": "아르헨티나", "short": "아르헨티나", "en": "Argentina"},
"AUS": {"ko": "호주", "short": "호주", "en": "Australia"},
"AUT": {"ko": "오스트리아", "short": "오스트리아", "en": "Austria"},
"BEL": {"ko": "벨기에", "short": "벨기에", "en": "Belgium"},
"BIH": {"ko": "보스니아 헤르체고비나", "short": "보스니아", "en": "Bosnia-Herzegovina"},
"BRA": {"ko": "브라질", "short": "브라질", "en": "Brazil"},
"CAN": {"ko": "캐나다", "short": "캐나다", "en": "Canada"},
"CIV": {"ko": "코트디부아르", "short": "코트디부아르", "en": "Ivory Coast"},
"COD": {"ko": "콩고민주공화국", "short": "콩고DR", "en": "Congo DR"},
"COL": {"ko": "콜롬비아", "short": "콜롬비아", "en": "Colombia"},
"CPV": {"ko": "카보베르데", "short": "카보베르데", "en": "Cape Verde"},
"CRO": {"ko": "크로아티아", "short": "크로아티아", "en": "Croatia"},
"CUW": {"ko": "퀴라소", "short": "퀴라소", "en": "Curaçao"},
"CZE": {"ko": "체코", "short": "체코", "en": "Czechia"},
"ECU": {"ko": "에콰도르", "short": "에콰도르", "en": "Ecuador"},
"EGY": {"ko": "이집트", "short": "이집트", "en": "Egypt"},
"ENG": {"ko": "잉글랜드", "short": "잉글랜드", "en": "England"},
"ESP": {"ko": "스페인", "short": "스페인", "en": "Spain"},
"FRA": {"ko": "프랑스", "short": "프랑스", "en": "France"},
"GER": {"ko": "독일", "short": "독일", "en": "Germany"},
"GHA": {"ko": "가나", "short": "가나", "en": "Ghana"},
"HAI": {"ko": "아이티", "short": "아이티", "en": "Haiti"},
"IRN": {"ko": "이란", "short": "이란", "en": "Iran"},
"IRQ": {"ko": "이라크", "short": "이라크", "en": "Iraq"},
"JOR": {"ko": "요르단", "short": "요르단", "en": "Jordan"},
"JPN": {"ko": "일본", "short": "일본", "en": "Japan"},
"KOR": {"ko": "대한민국", "short": "한국", "en": "Korea Republic"},
"KSA": {"ko": "사우디아라비아", "short": "사우디", "en": "Saudi Arabia"},
"MAR": {"ko": "모로코", "short": "모로코", "en": "Morocco"},
"MEX": {"ko": "멕시코", "short": "멕시코", "en": "Mexico"},
"NED": {"ko": "네덜란드", "short": "네덜란드", "en": "Netherlands"},
"NOR": {"ko": "노르웨이", "short": "노르웨이", "en": "Norway"},
"NZL": {"ko": "뉴질랜드", "short": "뉴질랜드", "en": "New Zealand"},
"PAN": {"ko": "파나마", "short": "파나마", "en": "Panama"},
"PAR": {"ko": "파라과이", "short": "파라과이", "en": "Paraguay"},
"POR": {"ko": "포르투갈", "short": "포르투갈", "en": "Portugal"},
"QAT": {"ko": "카타르", "short": "카타르", "en": "Qatar"},
"RSA": {"ko": "남아프리카 공화국", "short": "남아공", "en": "South Africa"},
"SCO": {"ko": "스코틀랜드", "short": "스코틀랜드", "en": "Scotland"},
"SEN": {"ko": "세네갈", "short": "세네갈", "en": "Senegal"},
"SUI": {"ko": "스위스", "short": "스위스", "en": "Switzerland"},
"SWE": {"ko": "스웨덴", "short": "스웨덴", "en": "Sweden"},
"TUN": {"ko": "튀니지", "short": "튀니지", "en": "Tunisia"},
"TUR": {"ko": "튀르키예", "short": "튀르키예", "en": "Turkey"},
"URU": {"ko": "우루과이", "short": "우루과이", "en": "Uruguay"},
"USA": {"ko": "미국", "short": "미국", "en": "United States"},
"UZB": {"ko": "우즈베키스탄", "short": "우즈벡", "en": "Uzbekistan"},
}
def team_info(code: str) -> dict:
c = (code or "").strip().upper()
t = TEAMS.get(c)
if t:
return {"name": t["ko"], "shortName": t["short"], "code": c, "flag": ""}
# 미등록 코드 폴백
return {"name": c, "shortName": c, "code": c, "flag": ""}

49
backend/app/teams_mls.py Normal file
View File

@ -0,0 +1,49 @@
"""MLS 팀 데이터 — 30개 구단 (ESPN 약어 코드 → 한글/약식/영문/ESPN ID).
코드: ESPN abbreviation (일정·순위·라이브 매칭 키와 동일해 변환 불필요).
espn_id: ESPN ID (불변) 로고 CDN URL 조립에 사용.
로고: https://a.espncdn.com/i/teamlogos/soccer/500/{espn_id}.png
"""
from __future__ import annotations
MLS_TEAMS: dict[str, dict] = {
# ── 동부(Eastern Conference) ─────────────────────────────
"ATL": {"ko": "애틀랜타 유나이티드", "short": "애틀랜타", "en": "Atlanta United FC", "espn_id": 18418},
"CLT": {"ko": "샬럿 FC", "short": "샬럿", "en": "Charlotte FC", "espn_id": 21300},
"CHI": {"ko": "시카고 파이어", "short": "시카고", "en": "Chicago Fire FC", "espn_id": 182},
"CIN": {"ko": "FC 신시내티", "short": "신시내티", "en": "FC Cincinnati", "espn_id": 18267},
"CLB": {"ko": "콜럼버스 크루", "short": "콜럼버스", "en": "Columbus Crew", "espn_id": 183},
"DC": {"ko": "DC 유나이티드", "short": "DC", "en": "D.C. United", "espn_id": 193},
"MIA": {"ko": "인터 마이애미", "short": "마이애미", "en": "Inter Miami CF", "espn_id": 20232},
"MTL": {"ko": "CF 몬트리올", "short": "몬트리올", "en": "CF Montréal", "espn_id": 9720},
"NSH": {"ko": "내슈빌 SC", "short": "내슈빌", "en": "Nashville SC", "espn_id": 18986},
"NE": {"ko": "뉴잉글랜드 레볼루션", "short": "뉴잉글랜드", "en": "New England Revolution", "espn_id": 189},
"NYC": {"ko": "뉴욕 시티 FC", "short": "뉴욕시티", "en": "New York City FC", "espn_id": 17606},
"RBNY": {"ko": "뉴욕 레드불스", "short": "레드불스", "en": "New York Red Bulls", "espn_id": 190},
"ORL": {"ko": "올랜도 시티", "short": "올랜도", "en": "Orlando City SC", "espn_id": 12011},
"PHI": {"ko": "필라델피아 유니언", "short": "필라델피아", "en": "Philadelphia Union", "espn_id": 10739},
"TOR": {"ko": "토론토 FC", "short": "토론토", "en": "Toronto FC", "espn_id": 7318},
# ── 서부(Western Conference) ─────────────────────────────
"ATX": {"ko": "오스틴 FC", "short": "오스틴", "en": "Austin FC", "espn_id": 20906},
"COL": {"ko": "콜로라도 래피즈", "short": "콜로라도", "en": "Colorado Rapids", "espn_id": 184},
"DAL": {"ko": "FC 댈러스", "short": "댈러스", "en": "FC Dallas", "espn_id": 185},
"HOU": {"ko": "휴스턴 다이너모", "short": "휴스턴", "en": "Houston Dynamo FC", "espn_id": 6077},
"LA": {"ko": "LA 갤럭시", "short": "LA갤럭시", "en": "LA Galaxy", "espn_id": 187},
"LAFC": {"ko": "LAFC", "short": "LAFC", "en": "LAFC", "espn_id": 18966},
"MIN": {"ko": "미네소타 유나이티드", "short": "미네소타", "en": "Minnesota United FC", "espn_id": 17362},
"POR": {"ko": "포틀랜드 팀버스", "short": "포틀랜드", "en": "Portland Timbers", "espn_id": 9723},
"RSL": {"ko": "레알 솔트레이크", "short": "솔트레이크", "en": "Real Salt Lake", "espn_id": 4771},
"SD": {"ko": "샌디에이고 FC", "short": "샌디에이고", "en": "San Diego FC", "espn_id": 22529},
"SJ": {"ko": "산호세 어스퀘이크스", "short": "산호세", "en": "San Jose Earthquakes", "espn_id": 191},
"SEA": {"ko": "시애틀 사운더스", "short": "시애틀", "en": "Seattle Sounders FC", "espn_id": 9726},
"SKC": {"ko": "스포팅 캔자스시티", "short": "캔자스시티", "en": "Sporting Kansas City", "espn_id": 186},
"STL": {"ko": "세인트루이스 시티", "short": "세인트루이스", "en": "St. Louis CITY SC", "espn_id": 21812},
"VAN": {"ko": "밴쿠버 화이트캡스", "short": "밴쿠버", "en": "Vancouver Whitecaps FC", "espn_id": 9727},
}
MLS_ID_TO_CODE: dict[int, str] = {v["espn_id"]: k for k, v in MLS_TEAMS.items()}
def mls_logo(code: str) -> str:
t = MLS_TEAMS.get(code)
return f"https://a.espncdn.com/i/teamlogos/soccer/500/{t['espn_id']}.png" if t else ""

550
backend/app/worker.py Normal file
View File

@ -0,0 +1,550 @@
"""백그라운드 워커 — APScheduler (별도 컨테이너).
조사된 '필요한 시간들' 모두 자동 처리하는 단일 프로세스:
1) 상태 전이 (status_tick_seconds 주기, 기본 60)
scheduled open(킥오프 D-2) locked(킥오프 정각) now 기준 자동 갱신.
2) AI 예측 생성 (매일 KST ai_generate_hour:ai_generate_minute, 기본 00:05)
남은(미종료) 경기에 대해 GPT/Claude/Gemini API 호출 ai_predictions 갱신.
모델별 독립 처리( 없거나 실패해도 나머지 모델은 진행).
3) 결과 메일 발송 (status_tick 함께 점검)
경기 종료(finished_at) result_email_delay_minutes(기본 180=3시간) 경과 ,
구독자(notify=True+email)에게 개인화 결과 메일 발송 notified 표시.
실행: python -m app.worker
"""
from __future__ import annotations
import asyncio
import logging
from datetime import timedelta, timezone
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from .config import settings
from .database import SessionLocal, init_db
from .domain import compute_phase, ensure_aware, now_utc
from .models import AIPrediction, Match, UserPrediction
from .scoring import load_scoring_data
from .services import baseball_data, baseball_details, football_data, mls_espn
from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable
from .services.baseball_fetch import fetch_baseball_results, fetch_baseball_schedule
from .services.baseball_sync import match_seq, sync_baseball_schedule
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
from .services.schedule_sync import sync_schedule
from .services.songs import tick_songs
logging.basicConfig(level=logging.INFO)
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
async with SessionLocal() as db:
result = await sync_schedule(db, records)
# 일정 변경 후 즉시 상태/투표시간 재평가
await tick_status()
# 새 경기가 삽입됐으면 그 경기 AI 예측을 바로 생성(다음 주기까지 비어있지 않게)
if result.get("inserted"):
log.info("schedule: 신규 %d경기 → AI 예측 즉시 생성", result["inserted"])
await generate_ai_predictions()
async def sync_baseball_job() -> None:
"""야구(kbo/mlb)+MLS 일정 동기화 + 프리뷰·순위 캐시 갱신.
MLS (리그, KST 날짜, 팀쌍, 차수) 구조가 동일해 야구 sync 그대로 탄다.
"""
inserted_any = False
for league in settings.league_list:
if league not in ("kbo", "mlb", "mls"):
continue
records = (
await mls_espn.fetch_mls_schedule()
if league == "mls"
else await fetch_baseball_schedule(league)
)
if not records:
continue
async with SessionLocal() as db:
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:
if league == "mls":
await mls_espn.refresh_mls_details(db, rows)
else:
await baseball_details.refresh_baseball_details(db, league, rows)
except Exception as e: # noqa: BLE001
log.warning("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:
now = now_utc()
matches = (await db.execute(select(Match))).scalars().all()
changed = 0
for m in matches:
# 화면용 phase 와 동일 기준으로 status 저장(단일 기준):
# scheduled → open → locked(투표종료) → live(경기중) → finished
target = compute_phase(m, now)
if m.status != target:
m.status = target
changed += 1
if changed:
await db.commit()
log.info("status_tick: %d matches updated", changed)
await maybe_send_result_emails()
# ── 2) AI 예측 생성 (실연동) ────────────────────────────────
async def generate_ai_predictions(only_missing: bool = True) -> None:
"""미종료 경기의 AI 예측 생성.
only_missing=True(기본): 이미 있는 (경기×모델) 예측은 보존하고 **비어 있는 것만**
생성한다(직전에 실패/누락된 모델만 채워짐 API 비용, 예측 안정).
only_missing=False: 전부 재생성(덮어쓰기) 수동 재생성 스크립트용.
"""
if not settings.ai_enabled:
log.info("AI 예측 생성 스킵 — AI_ENABLED=false (로컬 테스트 모드)")
return
async with SessionLocal() as db:
# 킥오프가 lookahead(기본 22h) 이내로 다가온 미시작 경기만 생성.
# 야구 선발투수 예고(전날 저녁)가 나온 뒤 시점이라 데이터 품질이 좋다.
# 킥오프가 지난 경기는 제외(경기 중 생성 = 사후 예측 방지). 취소 경기 제외.
now = now_utc()
horizon = now + timedelta(hours=settings.ai_generate_lookahead_hours)
rows = (
await db.execute(
select(Match)
.where(Match.result_outcome.is_(None))
.options(selectinload(Match.predictions))
)
).scalars().all()
matches = [
m for m in rows
if m.status != "cancelled"
and now < ensure_aware(m.kickoff_at) <= horizon
]
for m in matches:
# 실데이터 블록 — 리그별 소스(축구=API-Football 캐시, 야구=자체DB+프리뷰,
# MLS=자체DB+ESPN 프리뷰).
if m.league in ("kbo", "mlb"):
data_block = await baseball_data.build_baseball_data_block(db, m)
elif m.league == "mls":
data_block = await mls_espn.build_mls_data_block(db, m)
else:
data_block = await football_data.build_data_block(db, m)
ctx = MatchContext(
team_a=m.team_a_name,
team_b=m.team_b_name,
venue=m.venue,
kickoff=m.kickoff_at.isoformat(),
data_block=data_block,
league=m.league,
)
for model, fn in PROVIDERS.items():
pred = next((p for p in m.predictions if p.model == model), None)
if only_missing and pred is not None:
continue # 이미 작성됨 — 보존, API 호출 안 함
try:
data = await fn(ctx)
except ProviderUnavailable as e:
log.warning("AI %s skipped (%s)", model, e)
continue
except Exception as e: # noqa: BLE001
log.error("AI %s failed for %s: %s", model, m.match_id, e)
continue
if pred is None: # 신규 — 위에서 못 찾았으면 생성
pred = AIPrediction(match_id=m.match_id, model=model)
db.add(pred)
m.predictions.append(pred)
pred.outcome = data["outcome"]
pred.score_a = data["scoreA"]
pred.score_b = data["scoreB"]
pred.confidence_pct = data["confidencePct"]
pred.reason_ko = data["reasonKo"]
pred.reason_en = data["reasonEn"]
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()
# ── 2.1) 축구 데이터 수집 (예측 생성 전에 캐시를 채워둔다) ──
async def refresh_football_data() -> None:
"""전 팀의 팀/H2H 축구 데이터를 캐시에 한 번씩 선수집.
예측이 데이터를 쓰려면 미리 캐시에 있어야 하므로, 임박 경기만 기다리지 않고
모든 미종료 경기의 팀을 대상으로 한다. 팀당 1회만 받고(fetch-once), 무료 한도
(100/) 넘지 않게 하루 호출 예산만큼만 받아 며칠에 걸쳐 누적한다. 팀이
캐시되면 이후 잡은 호출 0(자동 무동작). 미설정이면 no-op.
"""
if not football_data.enabled():
return
async with SessionLocal() as db:
rows = (
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))
await football_data.refresh(db, matches)
# ── 2.5) 결과 자동 정산 (관리자 입력 불필요) ────────────────
async def settle_matches() -> None:
"""킥오프가 지난 미정산 경기를, 소스가 FINISHED 로 보고하는 즉시 채점·종료.
또한 최근 N일(result_recheck_days) 종료된 경기는 외부 소스와 대조해, 소스가
스코어를 정정하면(잠정값확정값 ) 자동 갱신·재채점한다.
킥오프 이후의 미정산 경기를 폴링하되, 외부 소스(football-data) FINISHED
확정 스코어를 때만 apply_result 채점·유저 포인트 집계·finished_at·메일.
경기 (IN_PLAY)에는 FINISHED 아니라 결과에 잡혀 자동 스킵된다(조기 종료 없음).
아직 FINISHED 아니면 다음 (5) 재시도 종료 최대 1 반영.
재확인은 '최근 N일 종료 경기'로만 한정 오래된 경기는 대상에서 빠져 부하 bounded.
소스 호출은 신규/재확인이 모두 같은 단일 fetch_results() 응답을 공유한다.
"""
now = now_utc()
recheck_cutoff = (
now - timedelta(days=settings.result_recheck_days)
if settings.result_recheck_days > 0
else None
)
async with SessionLocal() as db:
pending = (
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]
# 최근 종료 경기(소스 정정 반영용) — TZ 안전하게 파이썬에서 기간 필터.
recheck_ids: list[str] = []
if recheck_cutoff is not None:
finished = (
await db.execute(
select(Match).where(
Match.league == "wc",
Match.result_outcome.is_not(None),
Match.finished_at.is_not(None),
)
)
).scalars().all()
recheck_ids = [
m.match_id
for m in finished
if m.finished_at and ensure_aware(m.finished_at) >= recheck_cutoff
]
if due or recheck_ids:
results = await fetch_results()
# 정방향/역방향 키 모두 등록 — 소스의 홈/원정 순서가 우리와 달라도 매칭.
# 라운드(round_label)를 키에 포함해 같은 두 팀의 조별리그·토너먼트 경기를 구분.
# round_label 없는 소스(openfootball) 대비 라운드 무시 키도 함께 등록(폴백).
by_pair: dict[tuple, tuple[int, int]] = {}
for r in results:
rl = r.get("roundLabel", "")
a2, b2, sa, sb = r["teamA"], r["teamB"], r["scoreA"], r["scoreB"]
for key in ((rl, a2, b2), (a2, b2)):
by_pair[key] = (sa, sb)
for key in ((rl, b2, a2), (b2, a2)): # 뒤집어 저장
by_pair[key] = (sb, sa)
def _lookup(m: Match) -> tuple[int, int] | None:
# 라운드까지 일치하는 결과 우선, 없으면 팀쌍만으로 폴백.
return by_pair.get((m.round_label, m.team_a_code, m.team_b_code)) or by_pair.get(
(m.team_a_code, m.team_b_code)
)
settled = corrected = 0
async with SessionLocal() as db:
# 1) 신규 정산 — 미정산 경기에 FINISHED 스코어 반영
for mid in due:
m = await db.get(Match, mid)
if m is None or m.result_outcome is not None:
continue
sc = _lookup(m)
if not sc:
continue
await apply_result(db, m, sc[0], sc[1]) # 우리 팀A/팀B 기준으로 채점+집계+commit
settled += 1
log.info("settle: %s 자동 정산 %d-%d", mid, sc[0], sc[1])
# 2) 최근 종료 경기 재확인 — 소스 스코어가 DB와 다르면 정정+재채점
for mid in recheck_ids:
m = await db.get(Match, mid)
if m is None or m.result_outcome is None:
continue
sc = _lookup(m)
if not sc:
continue # 소스에 아직 없으면 기존값 유지
if (m.result_score_a, m.result_score_b) == (sc[0], sc[1]):
continue # 동일 — 변경 없음(대부분 여기서 종료, 재채점 안 함)
old_a, old_b = m.result_score_a, m.result_score_b
await apply_result(db, m, sc[0], sc[1]) # 정정+재채점+집계+commit
corrected += 1
log.warning(
"settle: %s 결과 정정 %s-%s%d-%d (소스 변경 반영)",
mid, old_a, old_b, sc[0], sc[1],
)
if not settled and not corrected:
log.info(
"settle: 신규 %d·재확인 %d경기 — 변경 없음", len(due), len(recheck_ids)
)
await maybe_send_result_emails()
# ── 2.6) 야구·MLS 결과 자동 정산 — (리그, KST 날짜, 팀쌍) 키 매칭 ──
async def settle_baseball() -> None:
_KST = timedelta(hours=9)
now = now_utc()
for league in settings.league_list:
if league not in ("kbo", "mlb", "mls"):
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_matches = [m for m in pending if ensure_aware(m.kickoff_at) <= now]
if not due_matches:
continue
due = [m.match_id for m in due_matches]
# 미정산 경기 중 가장 오래된 킥오프까지 덮도록 조회 범위를 동적 확장.
# 기본 윈도우(며칠)만 쓰면 첫 수집에 실패한 경기가 윈도우 밖으로
# 밀려나 영원히 "live"에 고정되는 문제가 있었다 — 최대 120일까지 소급.
oldest_kst_date = min(
ensure_aware(m.kickoff_at).astimezone(timezone(_KST)).date()
for m in due_matches
)
days_back = min((now.astimezone(timezone(_KST)).date() - oldest_kst_date).days + 1, 120)
results = (
await mls_espn.fetch_mls_results(days_back=days_back)
if league == "mls"
else await fetch_baseball_results(league, days_back=days_back)
)
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:
cutoff = now_utc() - timedelta(minutes=settings.result_email_delay_minutes)
matches = (
await db.execute(
select(Match)
.where(
Match.result_outcome.is_not(None),
Match.finished_at.is_not(None),
Match.results_emailed_at.is_(None),
)
.options(selectinload(Match.predictions))
)
).scalars().all()
for m in matches:
if m.finished_at and ensure_aware(m.finished_at) > cutoff:
continue # 아직 지연시간(3시간) 미경과
conds = [
UserPrediction.match_id == m.match_id,
UserPrediction.notify.is_(True),
UserPrediction.email.is_not(None),
UserPrediction.notified.is_(False),
]
# '맞춘 사람만' 옵션: 승패(outcome) 적중자에게만 발송
if settings.result_email_correct_only:
conds.append(UserPrediction.outcome == m.result_outcome)
picks = (
await db.execute(select(UserPrediction).where(*conds))
).scalars().all()
ai_lines = [(p.model, p.outcome == m.result_outcome) for p in m.predictions]
match_url = f"{settings.public_origin}/match/{m.match_id}"
sent_any = False
for pk in picks:
subject, html, text = build_result_email(
team_a=m.team_a_short,
team_b=m.team_b_short,
result_a=m.result_score_a or 0,
result_b=m.result_score_b or 0,
my_a=pk.score_a,
my_b=pk.score_b,
my_points=pk.points or 0,
ai_lines=ai_lines,
match_url=match_url,
)
try:
await send_email(pk.email, subject, html, text) # type: ignore[arg-type]
pk.notified = True
sent_any = True
except EmailUnavailable as e:
log.warning("result email skipped (%s)", e)
break # SMTP 미설정 — 이 경기는 다음 틱에 재시도
except Exception as e: # noqa: BLE001
log.error("result email failed → %s: %s", pk.email, e)
# 모든 구독자에게 시도 완료(또는 구독자 없음)면 발송완료 표시
still_pending = any(
(not pk.notified) for pk in picks
)
if not still_pending:
m.results_emailed_at = now_utc()
await db.commit()
def build_scheduler() -> AsyncIOScheduler:
sched = AsyncIOScheduler(timezone=settings.timezone)
# 경기 일정: 매일 KST 09:00 외부 크롤링 → 경기·투표시간 갱신
sched.add_job(
sync_schedule_job,
"cron",
hour=settings.schedule_sync_hour,
minute=settings.schedule_sync_minute,
id="schedule_sync",
)
# 투표시간 상태 전이 (open/locked)
sched.add_job(
tick_status,
"interval",
seconds=settings.status_tick_seconds,
id="status_tick",
next_run_time=now_utc(),
)
# 축구 데이터 수집: 매일 KST 00:00 (예측 생성보다 먼저 캐시를 채움)
sched.add_job(
refresh_football_data,
"cron",
hour=settings.football_refresh_hour,
minute=settings.football_refresh_minute,
id="football_refresh",
)
# AI 예측 생성: 매시간 — 킥오프 22h 전에 든 경기를 놓치지 않게.
# only_missing 이라 이미 생성된 (경기×모델)은 API 호출 없이 건너뜀(비용 동일).
sched.add_job(
generate_ai_predictions,
"interval",
hours=1,
id="ai_generate",
next_run_time=now_utc(),
)
# 결과 자동 정산 + 메일: 킥오프+Nh 지난 경기 스코어 수집·채점·발송
sched.add_job(
settle_matches,
"interval",
seconds=settings.settle_tick_seconds,
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",
)
# 오늘의 응원가: 킥오프 임박 경기 생성 시작 + Suno 작업 폴링
sched.add_job(
tick_songs, "interval",
seconds=settings.song_tick_seconds, id="songs",
next_run_time=now_utc(),
)
return sched
async def main() -> None:
load_scoring_data() # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용)
await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당.
# 기동 시 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 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_lookahead_hours,
settings.settle_tick_seconds,
settings.result_email_correct_only,
)
# 영구 대기
stop = asyncio.Event()
try:
await stop.wait()
finally:
sched.shutdown()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,7 @@
{
"score_exact": 5,
"score_close": 3,
"score_outcome": 2,
"score_partial": 1,
"score_miss": 0
}

17
backend/requirements.txt Normal file
View File

@ -0,0 +1,17 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
pydantic==2.10.4
pydantic-settings==2.7.1
email-validator==2.2.0
apscheduler==3.11.0
aiosmtplib==3.0.2
azure-communication-email==1.0.0
aiohttp==3.10.11
httpx==0.27.2
python-jose[cryptography]==3.5.0
anthropic==0.69.0
openai==1.59.6
google-genai==0.8.0
pillow==11.1.0

View File

@ -1,433 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { DEMO_FORCE_OPEN } from "@/lib/mockData";
import { outcomeLabel, pct, kickoffDisplay, winProb } from "@/lib/format";
import { type Lang, dict, teamShort } from "@/lib/i18n";
import type {
Outcome,
CrowdStats,
ModelName,
Match,
AIPrediction,
Team,
} from "@/lib/types";
import Countdown from "./Countdown";
import TeamFlag from "./TeamFlag";
type Step = "pick" | "form" | "done";
const MODEL_ICON: Record<ModelName, string> = {
GPT: "/icons/gpt.png",
Claude: "/icons/claude.jpg",
Gemini: "/icons/gemini.jpeg",
};
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export default function Arena({
match,
predictions,
crowd: initialCrowd,
shareUrl,
lang = "ko",
}: {
match: Match;
predictions: AIPrediction[];
crowd: CrowdStats;
shareUrl: string;
lang?: Lang;
}) {
const t = dict(lang);
const aShort = teamShort(match.teamA, lang);
const bShort = teamShort(match.teamB, lang);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [scoreA, setScoreA] = useState(2);
const [scoreB, setScoreB] = useState(1);
const [step, setStep] = useState<Step>("pick");
const [email, setEmail] = useState("");
const [notify, setNotify] = useState(true);
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
const [copied, setCopied] = useState(false);
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
useEffect(() => {
setOutcome(
scoreA > scoreB ? "TEAM_A_WIN" : scoreA < scoreB ? "TEAM_B_WIN" : "DRAW",
);
}, [scoreA, scoreB]);
// 투표 창: 오픈(D-2) ≤ now < 마감(킥오프 정각). 종료 = 결과 존재.
const now = Date.now();
const finished = !!match.result;
const notOpen = !DEMO_FORCE_OPEN && now < new Date(match.opensAt).getTime();
const locked = now >= new Date(match.lockAt).getTime();
const disabled = finished || locked || notOpen;
const ctaLabel = finished
? t.ctaResult
: notOpen
? t.ctaOpens(kickoffDisplay(match.opensAt, lang))
: locked
? t.ctaLocked
: t.ctaBeat;
const matched = useMemo(() => {
if (!outcome) return [];
return predictions
.filter((p) => p.outcome === outcome)
.map((p) => ({ model: p.model, exact: p.scoreA === scoreA && p.scoreB === scoreB }));
}, [outcome, scoreA, scoreB, predictions]);
const submitPick = () => {
if (!outcome) return;
setStep("form");
};
const confirmSubmit = () => {
if (!EMAIL_RE.test(email.trim())) return;
setCrowd((c) => ({
...c,
total: c.total + 1,
teamAWin: c.teamAWin + (outcome === "TEAM_A_WIN" ? 1 : 0),
draw: c.draw + (outcome === "DRAW" ? 1 : 0),
teamBWin: c.teamBWin + (outcome === "TEAM_B_WIN" ? 1 : 0),
}));
setStep("done");
};
const shareText = useMemo(() => {
if (!outcome) return "";
const me = `${aShort} ${scoreA}-${scoreB} ${bShort}`;
const sameAI = matched.map((m) => m.model).join("·");
if (lang === "en") {
return sameAI
? `I picked ${me}, same as ${sameAI}! You? — TriplePick`
: `I picked ${me} — different from all 3 AIs! You? — TriplePick`;
}
return sameAI
? `나는 ${me}. ${sameAI}와 같은 선택! 너는? — TriplePick`
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
}, [outcome, scoreA, scoreB, matched, aShort, bShort, lang]);
const copyLink = async () => {
try {
await navigator.clipboard.writeText(`${shareText}\n${shareUrl}`);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
};
const nativeShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: `${aShort} vs ${bShort} — TriplePick`,
text: shareText,
url: shareUrl,
});
} catch {
/* cancelled */
}
} else copyLink();
};
return (
<>
{/* ===== 카운트다운 (D7) — 투표 진행 중일 때만 ===== */}
{!finished && !notOpen && (
<div className="mt-4">
<Countdown to={match.lockAt} lang={lang} />
</div>
)}
{/* ===== 레이어드 화이트 시트 (002) ===== */}
<section className="sheet mt-4 p-5 text-[var(--ink)]">
<div className="mb-3">
<h2 className="text-[22px] font-extrabold">{t.aiBattle}</h2>
</div>
<div className="flex flex-col gap-3">
{predictions.map((p) => {
const wp = winProb(match, p);
const wpLabel = wp.team
? t.winProb(teamShort(wp.team, lang))
: t.drawOdds;
return (
<div
key={p.model}
className="rounded-2xl border border-[var(--line-l)] bg-[var(--sheet-card)] p-4"
>
<div className="flex items-center gap-3">
<img
src={MODEL_ICON[p.model]}
alt={p.model}
className="h-12 w-12 shrink-0 rounded-full object-cover"
/>
<div className="min-w-0">
<div className="text-[20px] font-extrabold leading-none">
{p.model}
</div>
{/* D3: 승리 예측 팀의 승리 확률 + 승리팀 국기 */}
<div className="mt-1.5 flex items-center gap-1.5">
{wp.team && (
<TeamFlag team={wp.team} className="h-3.5 w-5 shrink-0" />
)}
<span className="text-[12px] font-bold text-[var(--ink-muted)]">
{wpLabel} <span className="text-[var(--gpt)]">{wp.pct}%</span>
</span>
</div>
</div>
<div className="ml-auto shrink-0 whitespace-nowrap font-mono text-[28px] font-extrabold tabular-nums">
{p.scoreA}&nbsp;-&nbsp;{p.scoreB}
</div>
</div>
{/* 메인 리즌 강조 (D3: 확률은 텍스트, 바 제거) */}
<p className="mt-3 text-[15px] font-bold leading-snug text-[var(--ink)]">
{p.reasonShort}
</p>
</div>
);
})}
</div>
{/* 당신의 선택 */}
<div className="mt-5 flex items-center justify-between">
<span className="text-[13px] font-bold text-[var(--ink-muted)]">
{t.yourPickLabel}
</span>
<span className="text-[20px] font-extrabold">{t.yourChoice}</span>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{(
[
["TEAM_A_WIN", `${aShort} ${t.win}`],
["DRAW", t.drawLabel],
["TEAM_B_WIN", `${bShort} ${t.win}`],
] as [Outcome, string][]
).map(([val, label]) => (
<button
key={val}
disabled={disabled}
onClick={() => setOutcome(val)}
className={`rounded-xl border py-3.5 text-[16px] font-extrabold transition disabled:opacity-50 ${
outcome === val
? "border-transparent bg-[var(--mint)] text-[var(--mint-ink)]"
: "border-[var(--line-l)] bg-white text-[var(--ink-muted)]"
}`}
>
{label}
</button>
))}
</div>
<div className="mt-3 flex items-center justify-center gap-5 rounded-xl border border-[var(--line-l)] bg-white py-3">
<Stepper label={aShort} value={scoreA} onChange={setScoreA} disabled={disabled} />
<span className="text-[26px] font-extrabold text-[var(--ink-muted)]">:</span>
<Stepper label={bShort} value={scoreB} onChange={setScoreB} disabled={disabled} />
</div>
</section>
{/* ===== 종료된 경기: 결과 보기 ===== */}
{finished && match.result && (
<section className="mt-5 rounded-2xl border border-[var(--green)]/50 bg-[var(--bg2)] p-5">
<div className="text-[13px] text-[var(--ink-muted)]">{t.finalResult}</div>
<div className="mt-1 text-[26px] font-extrabold">
{aShort} {match.result.scoreA}-{match.result.scoreB} {bShort}
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
{outcomeLabel(match, match.result.outcome, lang)}
</span>
</div>
<div className="mt-3 flex flex-col gap-1.5">
{predictions.map((p) => {
const hit = p.outcome === match.result!.outcome;
return (
<div key={p.model} className="flex items-center justify-between text-[13px]">
<span className="font-bold text-white/85">{p.model}</span>
<span className={hit ? "font-bold text-[var(--green)]" : "text-white/45"}>
{hit ? t.hit : t.miss}
</span>
</div>
);
})}
</div>
</section>
)}
{/* ===== CTA 민트 #85FCA8 (진행 중일 때) ===== */}
{!finished && step === "pick" && (
<button
onClick={submitPick}
disabled={!outcome || disabled}
className="btn-mint mt-5 w-full rounded-2xl py-5 text-[20px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
>
{ctaLabel}
</button>
)}
{/* ===== 폼: 이메일만 (D5, 닉네임 제거) ===== */}
{!finished && step === "form" && (
<div className="mt-5 space-y-2.5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
<input
type="email"
inputMode="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t.emailPh}
className="w-full rounded-lg border border-[var(--line-d)] bg-[#0f1217] px-3 py-3 text-[15px] text-white outline-none focus:border-[var(--green)]"
/>
<label className="flex items-start gap-2 text-[13px] text-[var(--ink-muted)]">
<input
type="checkbox"
checked={notify}
onChange={(e) => setNotify(e.target.checked)}
className="mt-0.5 accent-[var(--mint)]"
/>
{t.notify}
</label>
<button
onClick={confirmSubmit}
disabled={!EMAIL_RE.test(email.trim())}
className="btn-mint w-full rounded-xl py-3.5 text-[17px] font-extrabold transition active:scale-[0.99] disabled:opacity-40 disabled:shadow-none"
>
{t.submit}
</button>
</div>
)}
{/* ===== 제출 완료: 같은 AI + 경기별 공유 (D8) ===== */}
{!finished && step === "done" && outcome && (
<div className="mt-5 rounded-2xl border border-[var(--green)]/50 bg-[var(--bg2)] p-5">
<div className="text-[13px] text-[var(--ink-muted)]">{t.myPick}</div>
<div className="mt-1 text-[26px] font-extrabold">
{aShort} {scoreA}-{scoreB} {bShort}
<span className="ml-2 text-[16px] font-bold text-[var(--green)]">
{outcomeLabel(match, outcome, lang)}
</span>
</div>
<p className="mt-2 text-[14px] leading-relaxed text-white/80">
{matched.length > 0
? t.sameAI(
matched.map((m) => m.model).join("·"),
matched.some((m) => m.exact),
)
: t.soloPick}
</p>
<div className="mt-4 grid grid-cols-2 gap-2.5">
<button onClick={nativeShare} className="btn-mint rounded-xl py-3 text-[15px] font-bold active:scale-[0.99]">
{t.shareThis}
</button>
<button onClick={copyLink} className="rounded-xl border border-[var(--line-d)] py-3 text-[15px] font-bold active:scale-[0.99]">
{copied ? t.copied : t.copyLink}
</button>
</div>
</div>
)}
{/* ===== 골드 상금 (003) ===== */}
<section className="gold-card mt-5 rounded-2xl p-5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-[18px] font-extrabold text-[var(--gold-border)]">
<span>1,000,000 Final Challenge</span>
<span></span>
</div>
<p className="mt-1.5 text-[13px] leading-snug text-white/75">
{t.goldDesc}
</p>
</div>
<button className="gold-btn shrink-0 whitespace-pre-line rounded-xl px-4 py-3.5 text-[14px] font-extrabold leading-tight active:scale-[0.98]">
{t.goldBtn}
</button>
</div>
</section>
{/* ===== Crowd Pick (003) — 보팅 비율(≠ AI 승리 확률) ===== */}
<section className="mt-6">
<div className="mb-2.5 text-[20px] font-extrabold">
Crowd Pick{" "}
<span className="text-[14px] text-[var(--ink-muted)]">{t.crowdSub}</span>
</div>
<div className="flex h-14 overflow-hidden rounded-2xl border border-[var(--line-d)]">
<CrowdSeg team={match.teamA} value={pct(crowd.teamAWin, crowd.total)} tone="a" />
<CrowdSeg value={pct(crowd.draw, crowd.total)} tone="draw" />
<CrowdSeg team={match.teamB} value={pct(crowd.teamBWin, crowd.total)} tone="b" />
</div>
<div className="mt-1.5 text-right text-[11px] text-[var(--ink-muted)]">
{t.joined(crowd.total.toLocaleString())}
</div>
</section>
</>
);
}
function Stepper({
label,
value,
onChange,
disabled,
}: {
label: string;
value: number;
onChange: (n: number) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-center gap-3">
<StepBtn disabled={disabled || value <= 0} onClick={() => onChange(Math.max(0, value - 1))}>
</StepBtn>
<div className="flex w-12 flex-col items-center">
<span className="font-mono text-[32px] font-extrabold leading-none tabular-nums">
{value}
</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>
</div>
);
}
function StepBtn({
children,
onClick,
disabled,
}: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
className="grid h-11 w-11 place-items-center rounded-xl border border-[var(--line-l)] bg-white text-[22px] font-bold leading-none text-[var(--ink)] active:scale-95 disabled:opacity-30"
>
{children}
</button>
);
}
function CrowdSeg({
team,
value,
tone,
}: {
team?: Team;
value: number;
tone: "a" | "draw" | "b";
}) {
const bg = tone === "draw" ? "#2b313c" : tone === "a" ? "rgba(74,255,160,0.22)" : "#222831";
return (
<div
className="flex items-center justify-center gap-1.5 border-r border-[var(--line-d)] text-[16px] font-extrabold last:border-r-0"
style={{ width: `${value}%`, background: bg, minWidth: 56 }}
>
{team && <TeamFlag team={team} className="h-4 w-6" />}
<span>{value}%</span>
</div>
);
}

View File

@ -1,51 +0,0 @@
import Link from "next/link";
import ShareButton from "./ShareButton";
import LangSwitch from "./LangSwitch";
import { type Lang, dict } from "@/lib/i18n";
type Share = { url: string; title: string; text: string };
// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출.
export default function Hero({
back = false,
share,
lang = "ko",
}: {
back?: boolean;
share?: Share;
lang?: Lang;
}) {
const t = dict(lang);
const home = lang === "en" ? "/?lang=en" : "/";
return (
<header className="pt-5 text-center">
{back && (
<div className="mb-3 flex items-center justify-between">
<Link
href={home}
className="flex items-center gap-1 rounded-full border border-white/12 bg-white/8 px-3 py-1.5 text-[12px] font-bold text-white/85 active:scale-95"
>
{t.back}
</Link>
{share && (
<ShareButton {...share} label={t.share} copiedLabel={t.shareCopied} />
)}
</div>
)}
<div className="font-impact text-[44px] leading-none tracking-tight">
TRIPLE PICK <span className="text-[var(--green)]">2026</span>
</div>
<div className="relative mt-2">
<div className="text-[16px] font-extrabold text-[var(--green)]">
AI Prediction Arena
</div>
<div className="absolute right-0 top-1/2 -translate-y-1/2">
<LangSwitch lang={lang} />
</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}
</div>
</header>
);
}

View File

@ -1,64 +0,0 @@
import type { Match } from "@/lib/types";
import { kickoffDisplay } from "@/lib/format";
import { type Lang, dict } from "@/lib/i18n";
import BallIcon from "./BallIcon";
import TeamFlag from "./TeamFlag";
export default function MatchupHUD({ match, lang = "ko" }: { match: Match; lang?: Lang }) {
const t = dict(lang);
const { group } = match;
const finished = !!match.result;
return (
<section className="mt-6">
{/* 대결 카드 (녹색 글로우 보더) */}
<div
className="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)" }}
>
{/* 헤더: 브랜드 아이콘 + 팀명 + 일시 */}
<div className="flex items-center gap-3">
<BallIcon className="h-14 w-14 shrink-0" />
<div className="min-w-0">
<div className="text-[22px] font-extrabold leading-tight">
{match.teamA.name} <span className="text-white/55">vs</span>{" "}
{match.teamB.name}
</div>
<div className="mt-1 text-[15px] font-bold text-[var(--green)]">
{kickoffDisplay(match.kickoffKst, lang)} · {t.group(group)}
</div>
<div className="mt-0.5 text-[12px] text-white/65">{match.venue}</div>
</div>
</div>
{/* 국기 + 라이트닝 VS (종료 시 최종 스코어) */}
<div className="mt-5 grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<TeamFlag team={match.teamA} className="mx-auto h-[68px] w-[104px]" />
<div className="relative grid place-items-center">
<div className="vs-glow absolute h-28 w-28" />
{finished && match.result ? (
<span className="relative whitespace-nowrap font-mono text-[34px] font-extrabold tabular-nums text-white">
{match.result.scoreA}
<span className="px-1 text-white/55">-</span>
{match.result.scoreB}
</span>
) : (
<span
className="relative font-impact text-[46px] italic leading-none text-[var(--green)]"
style={{ textShadow: "0 0 18px rgba(74,255,160,0.8)" }}
>
VS
</span>
)}
</div>
<TeamFlag team={match.teamB} className="mx-auto h-[68px] w-[104px]" />
</div>
{finished && (
<div className="mt-3 text-center text-[12px] font-bold text-[var(--green)]">
{t.matchEnded}
</div>
)}
</div>
</section>
);
}

View File

@ -1,106 +0,0 @@
import Link from "next/link";
import { matchesByDate, matchPhase, type MatchPhase } from "@/lib/schedule";
import { getPredictions, getCrowd } from "@/lib/mockData";
import { dateHeading, timeOnly } from "@/lib/format";
import { type Lang, dict, teamShort, roundLabel as tRound } from "@/lib/i18n";
import type { Match } from "@/lib/types";
import TeamFlag from "./TeamFlag";
const STROKE = "#94FBE0"; // 일정 텍스트·선택 아웃라인 스트로크 컬러
const PHASE_CLS: Record<MatchPhase, string> = {
open: "border-[#94FBE0] text-[#94FBE0]",
scheduled: "border-[var(--line-d)] text-[var(--ink-muted)]",
locked: "border-[var(--line-d)] text-[var(--ink-muted)]",
finished: "border-white/15 text-white/55",
};
export default function ScheduleBoard({ lang = "ko" }: { lang?: Lang }) {
const t = dict(lang);
const groups = matchesByDate();
return (
<section className="mt-6">
<div className="mb-3 flex items-baseline gap-2.5">
<h2 className="shrink-0 text-[22px] font-extrabold">{t.schedTitle}</h2>
<span className="whitespace-nowrap text-[12px] text-white/55">
{t.schedGuide}
</span>
</div>
<div className="flex flex-col gap-5">
{groups.map((g) => (
<div key={g.date}>
<div className="mb-2 text-[13px] font-bold" style={{ color: STROKE }}>
{dateHeading(g.date, lang)}
</div>
<div className="flex flex-col gap-2.5">
{g.matches.map((m) => (
<MatchCard key={m.matchId} match={m} lang={lang} />
))}
</div>
</div>
))}
</div>
</section>
);
}
function MatchCard({ match, lang }: { match: Match; lang: Lang }) {
const t = dict(lang);
const phase = matchPhase(match);
const preds = getPredictions(match, lang);
const crowd = getCrowd(match);
// AI 픽 갈림 요약
const tally = { a: 0, d: 0, b: 0 };
for (const p of preds) {
if (p.outcome === "TEAM_A_WIN") tally.a++;
else if (p.outcome === "DRAW") tally.d++;
else tally.b++;
}
const split: string[] = [];
if (tally.a) split.push(`${teamShort(match.teamA, lang)} ${tally.a}`);
if (tally.d) split.push(`${t.draw} ${tally.d}`);
if (tally.b) split.push(`${teamShort(match.teamB, lang)} ${tally.b}`);
const href = `/match/${match.matchId}${lang === "en" ? "?lang=en" : ""}`;
return (
<Link
href={href}
className="block rounded-2xl border-2 border-[#94FBE0]/45 bg-[#171b21] p-4 transition active:scale-[0.99] hover:border-[#94FBE0]"
>
<div className="flex items-center justify-between text-[11px]">
<span className="font-mono font-bold text-white/70">
{timeOnly(match.kickoffKst)} <span className="text-white/40">KST</span> ·{" "}
{tRound(match.roundLabel, lang)}
</span>
<span className={`rounded-md border px-2 py-0.5 font-semibold ${PHASE_CLS[phase]}`}>
{t.phase[phase]}
</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={match.teamA} className="h-6 w-9 shrink-0" />
<span className="truncate text-[15px] font-extrabold">{teamShort(match.teamA, lang)}</span>
</div>
<span className="font-impact text-[18px] italic text-[#94FBE0]">VS</span>
<div className="flex items-center justify-end gap-2">
<span className="truncate text-right text-[15px] font-extrabold">{teamShort(match.teamB, lang)}</span>
<TeamFlag team={match.teamB} className="h-6 w-9 shrink-0" />
</div>
</div>
<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> {split.join(" · ")}
</span>
<span className="flex items-center gap-2">
<span>{t.joined(crowd.total.toLocaleString())}</span>
<span className="text-[#94FBE0]"></span>
</span>
</div>
</Link>
);
}

View File

@ -1,47 +0,0 @@
import type { Team } from "@/lib/types";
// 모든 경기에 일반화된 국기 렌더.
// KOR = 실제 png 자산, CZE = 인라인 SVG(비율 보존), 그 외 = 이모지(컨테이너 높이에 비례, 안 잘림).
export default function TeamFlag({
team,
className = "",
}: {
team: Team;
className?: string;
}) {
if (team.code === "KOR") {
return (
<img
src="/icons/kor.png"
alt={team.name}
className={`flag-img object-cover ${className}`}
/>
);
}
if (team.code === "CZE") {
return (
<svg
viewBox="0 0 6 4"
className={`border border-[var(--line-d)] ${className}`}
preserveAspectRatio="xMidYMid meet"
aria-label={team.name}
>
<rect width="6" height="2" y="0" fill="#ffffff" />
<rect width="6" height="2" y="2" fill="#d7141a" />
<polygon points="0,0 3,2 0,4" fill="#11457e" />
</svg>
);
}
// 그 외 국가 — 이모지 국기. 컨테이너 높이(cqh) 기준으로 크기를 잡아 잘림/찌그러짐 방지.
return (
<span
className={`grid place-items-center overflow-hidden border border-[var(--line-d)] bg-white/[0.06] ${className}`}
style={{ containerType: "size" }}
aria-label={team.name}
>
<span className="leading-none" style={{ fontSize: "92cqh" }}>
{team.flag}
</span>
</span>
);
}

37
docker-compose.yml Normal file
View File

@ -0,0 +1,37 @@
# TriplePick — 외부 PostgreSQL 에 연결하는 구성 (로컬 db 컨테이너 없음).
# api FastAPI (uvicorn)
# worker APScheduler (상태전이 · 매일 AI 예측 생성 · 결과 메일)
# frontend Vite 빌드 → nginx (정적 + /api 프록시)
#
# DB 는 이미 떠 있는 외부 PostgreSQL(예: 172.30.1.36:5432)에 연결한다.
# backend/.env 에 DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD 를 채우면
# app 이 접속 URL 을 조합해 그 DB 에 스키마(테이블)를 생성한다.
#
# 실행: cp backend/.env.example backend/.env (DB·키 채우기) → docker compose up --build
# 접속: http://localhost:8080
services:
api:
build: ./backend
env_file:
- path: ./backend/.env
required: false
ports:
- "8000:8000"
worker:
build: ./backend
command: ["python", "-m", "app.worker"]
env_file:
- path: ./backend/.env
required: false
depends_on:
api:
condition: service_started
frontend:
build: ./frontend
depends_on:
- api
ports:
- "8080:80"

38
docs/DATA_SOURCES.md Normal file
View File

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

View File

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

View File

@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@ -1,11 +0,0 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"functions": {
"source": "functions",
"runtime": "nodejs20",
"region": "asia-northeast3"
}
}

View File

@ -1,43 +0,0 @@
{
"indexes": [
{
"_comment": "리더보드(R2): 경기별 점수 내림차순 + 동점자 정렬(정확스코어 적중수, 제출시각)",
"collectionGroup": "user_predictions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "matchId", "order": "ASCENDING" },
{ "fieldPath": "points", "order": "DESCENDING" },
{ "fieldPath": "createdAt", "order": "ASCENDING" }
]
},
{
"_comment": "결과 채점 트리거: 경기별 미채점 예측 조회",
"collectionGroup": "user_predictions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "matchId", "order": "ASCENDING" },
{ "fieldPath": "scoredAt", "order": "ASCENDING" }
]
},
{
"_comment": "결과 알림 발송: 경기별 알림 동의 구독자 조회",
"collectionGroup": "notify_subscriptions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "matchId", "order": "ASCENDING" },
{ "fieldPath": "notified", "order": "ASCENDING" }
]
},
{
"_comment": "참여 횟수 랭킹(R2): 상금 챌린지 후보군",
"collectionGroup": "event_participation",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "isCandidate", "order": "ASCENDING" },
{ "fieldPath": "totalPredictions", "order": "DESCENDING" },
{ "fieldPath": "currentPoints", "order": "DESCENDING" }
]
}
],
"fieldOverrides": []
}

View File

@ -1,43 +0,0 @@
rules_version = '2';
// TriplePick Firestore 보안 규칙
// 모델: 공개 데이터(경기/AI예측/집계)는 읽기 허용, 모든 쓰기는 서버(Admin SDK·Cloud Functions)만.
// 유저 예측/구독/참여는 클라이언트 직접 접근 금지 → 콜러블 함수로만 처리(검증·집계·PII 보호).
service cloud.firestore {
match /databases/{database}/documents {
// ---- 공개 읽기 전용 (쓰기는 Admin SDK가 규칙 우회) ----
match /matches/{matchId} {
allow read: if true;
allow write: if false;
}
match /ai_predictions/{docId} {
allow read: if true;
allow write: if false;
}
match /crowd_stats/{matchId} {
allow read: if true; // 군중 분포(퍼센트, PII 없음)
allow write: if false; // submitPrediction 함수가 트랜잭션으로 증분
}
// ---- 비공개: 함수/Admin 전용 (클라이언트 직접 접근 전면 금지) ----
match /user_predictions/{docId} {
allow read, write: if false; // 제출/조회는 콜러블 함수로만, 이메일 등 PII 보호
}
match /notify_subscriptions/{docId} {
allow read, write: if false; // 결과 알림 구독 (Resend 발송용)
}
match /event_participation/{deviceId} {
allow read, write: if false; // R2 — 상금 챌린지 누적/자격
}
// 그 외 전부 차단
match /{document=**} {
allow read, write: if false;
}
}
}
// 참고: 클라이언트 직접 쓰기 방식을 택할 경우(함수 없이), user_predictions에
// create 검증 규칙(opens_at ≤ now < lock_at[=킥오프 정각], outcome ∈ {TEAM_A_WIN,DRAW,TEAM_B_WIN},
// score 0~9 정수, nickname 2~16자, docId == matchId+"_"+deviceId)을 넣어야 한다.
// 단 crowd_stats 원자적 증분과 PII 보호 때문에 콜러블 함수 방식을 권장한다(docs/BACKEND.md).

8
frontend/.env.example Normal file
View File

@ -0,0 +1,8 @@
# 프론트엔드 환경변수 (Vite) — 복사: cp .env.example .env
# 운영(docker)에서는 nginx 가 /api 를 프록시하므로 비워두면 동일 출처(/api)를 사용.
# 백엔드 API 베이스 (기본: /api). 절대 URL 로 직접 호출하려면 지정.
# VITE_API_BASE=/api
# 로컬 vite dev 서버의 프록시 타깃 (기본: http://localhost:8000)
# VITE_API_TARGET=http://localhost:8000

15
frontend/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
# TriplePick 프론트엔드 — Vite 빌드 → nginx 정적 서빙 + /api 프록시.
# lock은 macOS(arm64)에서 생성돼 rollup darwin-x64 optional dep가 version 없는
# stub로 남는 npm 버그가 있어, linux 빌드에선 lock 없이 package.json만으로 설치한다.
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install --no-audit --no-fund
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

143
frontend/index.html Normal file
View File

@ -0,0 +1,143 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/icons/profile.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#14171C" />
<!-- Pretendard 웹폰트 — globals.css 의 font-family 가 참조. 미로드 시 Windows 는
맑은 고딕 폴백으로 떨어져 글씨가 가늘고 흐릿해진다. dynamic-subset: 쓰인 글자만 로드 -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css"
/>
<title>TriplePick | AI 스포츠 승부예측 — KBO·MLB·MLS, GPT vs Claude vs Gemini</title>
<meta
name="description"
content="프로야구 승부예측(KBO)부터 해외야구 승부예측(MLB 메이저리그), MLS까지 — GPT·Claude·Gemini 3대 AI가 매 경기를 서로 다르게 예측합니다. 실시간 스코어·문자중계를 보며 당신의 픽을 찍고 AI와 겨뤄보세요. 무료 참여."
/>
<!-- 공유 썸네일 (Open Graph / Twitter) — 카카오톡 등 소셜 미리보기 -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="TriplePick" />
<meta property="og:title" content="TriplePick — AI 스포츠 승부예측 (KBO·MLB·MLS)" />
<meta
property="og:description"
content="GPT·Claude·Gemini 3대 AI가 KBO·MLB·MLS 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 무료 참여."
/>
<meta property="og:url" content="https://triplepick.o2o.kr/" />
<meta property="og:image" content="https://triplepick.o2o.kr/assets/bi/og-image.png?v=2" />
<meta property="og:image:secure_url" content="https://triplepick.o2o.kr/assets/bi/og-image.png?v=2" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="TriplePick AI" />
<meta property="og:locale" content="ko_KR" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="TriplePick — AI 스포츠 승부예측 (KBO·MLB·MLS)" />
<meta
name="twitter:description"
content="GPT·Claude·Gemini 3대 AI가 KBO·MLB·MLS 매 경기를 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요."
/>
<meta name="twitter:image" content="https://triplepick.o2o.kr/assets/bi/og-image.png?v=2" />
<!-- 검색엔진 소유확인 (verification) -->
<meta name="google-site-verification" content="ka2LWpjjrTMayrSJ4_LYowl3w1rt6-RSeaT24xvmUiQ" />
<!-- 네이버 서치어드바이저 소유확인 토큰 (2026-06-29 발급·반영 완료) -->
<meta name="naver-site-verification" content="d31508b6376797f7a8c587e4f60b72ffb6c7407c" />
<!-- 검색 키워드 / 색인 지시 -->
<meta name="keywords" content="트리플픽, AI승부예측, 스포츠 승부예측, 프로야구 승부예측, 해외야구 승부예측, 메이저리그 승부예측, MLB 승부예측, KBO 승부예측, 야구 승부예측, 축구 승부예측, 해외축구 승부예측, MLS 승부예측, 프로야구 예측, GPT 예측, 클로드 예측, 제미나이 예측, AI vs 사람, 프로야구 문자중계, TriplePick" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<link rel="canonical" href="https://triplepick.o2o.kr/" />
<!-- 구조화 데이터 (JSON-LD): 브랜드 인지 + 지식그래프 보조 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": "https://triplepick.o2o.kr/#organization",
"name": "TriplePick",
"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 지원.",
"sameAs": [
"https://www.youtube.com/channel/UCIiFvxaahQA-rLP8KpkDM0w",
"https://www.instagram.com/triplepickai"
],
"parentOrganization": {
"@type": "Organization",
"name": "AIO2O",
"alternateName": "AI오투오",
"url": "https://www.o2osolution.ai/",
"sameAs": [
"https://www.facebook.com/aio2o",
"https://www.linkedin.com/company/aio2o"
]
}
},
{
"@type": "WebSite",
"@id": "https://triplepick.o2o.kr/#website",
"url": "https://triplepick.o2o.kr/",
"name": "TriplePick — AI 스포츠 승부예측 아레나",
"inLanguage": "ko-KR",
"publisher": { "@id": "https://triplepick.o2o.kr/#organization" }
},
{
"@type": "WebApplication",
"name": "TriplePick AI 승부예측",
"url": "https://triplepick.o2o.kr/",
"applicationCategory": "SportsApplication",
"operatingSystem": "Web",
"inLanguage": "ko-KR",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "KRW" },
"description": "GPT·Claude·Gemini의 예측을 확인하고 직접 승패와 스코어를 찍어 AI와 겨루는 무료 참여형 스포츠 승부예측 서비스. KBO 프로야구·MLB 해외야구·MLS 경기 지원, 실시간 스코어·문자중계 제공."
}
]
}
</script>
</head>
<body>
<div id="root">
<!-- 크롤 가능 본문 폴백(SPA 빈 root 보완). React 마운트 시 실제 앱으로 교체됨.
⚠️ CSS로 숨기지 말 것(클로킹 페널티) — 보이게 두면 React가 즉시 덮음. -->
<div class="seo-fallback">
<h1>트리플픽 — GPT·Claude·Gemini AI 스포츠 승부예측 대결</h1>
<p>
트리플픽(TriplePick)은 AI승부예측 서비스입니다. GPT·Claude·Gemini 3대 AI가
같은 경기 데이터로 승패·스코어·승리확률을 서로 다르게 예측하고, 당신은 직접
스포츠 승부예측 픽을 찍어 AI와 겨룹니다. KBO 프로야구·MLB·MLS 전 경기를
대상으로 무료로 참여할 수 있습니다.
</p>
<h2>프로야구 승부예측부터 해외야구 승부예측까지</h2>
<p>
KBO 프로야구 승부예측은 두산·LG·KIA·삼성 등 10개 구단 전 경기를 매일
지원하고, 해외야구 승부예측은 MLB 메이저리그 전 경기를 다룹니다. 야구뿐
아니라 해외축구(MLS) 승부예측까지 한 곳에서 — 3대 AI의
예측 근거와 승리확률을 비교하고 내 픽의 적중률을 확인해 보세요.
</p>
<h2>트리플픽은 이런 스포츠 승부예측 서비스입니다</h2>
<ul>
<li>KBO 프로야구·MLB 해외야구·MLS 3개 리그 경기별 승부예측 지원</li>
<li>3대 AI(GPT·Claude·Gemini)의 경기별 예측을 한눈에 비교</li>
<li>승패·정확 스코어를 직접 찍는 참여형 AI승부예측</li>
<li>진행 중 경기 실시간 스코어와 문자중계 제공</li>
<li>경기 종료 후 AI와 내 적중률을 채점·공개</li>
<li>무료 참여 — 카카오 간편 로그인 지원</li>
</ul>
<p>
AI 셋이 갈렸을 때, 당신의 픽은 누구와 같을까요? 트리플픽에서 스포츠 승부예측을
시작하세요. <a href="https://triplepick.o2o.kr/">triplepick.o2o.kr</a>
</p>
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

50
frontend/nginx.conf Normal file
View File

@ -0,0 +1,50 @@
# 공유 크롤러(카카오톡·트위터·페북 등) 판별 /match/:id 백엔드 OG 프리렌더로.
# 일반 사용자(JS 실행)는 0 기존 SPA 그대로.
map $http_user_agent $is_share_crawler {
default 0;
# kakaotalk-scrap = 카톡 공유 미리보기 봇만. (인앱 브라우저 UA 'KAKAOTALK x.x'
# 걸려야 사용자가 링크를 탭했을 SPA 정상 진입한다)
"~*(kakaotalk-scrap|facebookexternalhit|facebot|twitterbot|slackbot|discordbot|telegrambot|whatsapp|line\\b|skypeuripreview|pinterest|redditbot|googlebot|bingbot|daumoa|yeti)" 1;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# API 백엔드(api 서비스)로 프록시 브라우저는 동일 출처로 호출(CORS 불필요).
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 경기 공유 링크: 크롤러면 백엔드 OG 프리렌더, 사람이면 SPA.
# (418 내부 named location 으로 우회: if + try_files 충돌 회피 정석 패턴)
location /match/ {
error_page 418 = @og_prerender;
if ($is_share_crawler) { return 418; }
try_files $uri /index.html;
}
# 응원가 공유 링크(/song/:matchId/:teamCode) 동일 패턴
location /song/ {
error_page 418 = @og_prerender;
if ($is_share_crawler) { return 418; }
try_files $uri /index.html;
}
location @og_prerender {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA 폴백 클라이언트 라우팅(/match/:id 등)
location / {
try_files $uri $uri/ /index.html;
}
}

2485
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
frontend/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "triplepick-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.5"
}
}

View File

@ -0,0 +1,505 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 656.07 656.07">
<!-- Generator: Adobe Illustrator 30.5.1, SVG Export Plug-In . SVG Version: 2.1.4 Build 3) -->
<defs>
<style>
.st0 {
fill: #a9adad;
}
.st1 {
fill: #252b32;
}
.st2 {
fill: #010101;
}
.st3 {
fill: #fdfdfb;
}
.st4 {
fill: #7de9a7;
}
.st5 {
fill: #548570;
}
.st6 {
fill: #7deca6;
}
.st7 {
fill: #252b30;
}
.st8 {
fill: #252b31;
}
.st9 {
fill: #22262c;
}
.st10 {
fill: #262b33;
}
.st11 {
fill: #7deea8;
}
.st12 {
fill: #517c68;
}
.st13 {
fill: #212b2f;
}
.st14 {
fill: #252c31;
}
.st15 {
fill: #7feca8;
}
.st16 {
fill: #486d5c;
}
.st17 {
fill: #84e3ad;
}
.st18 {
fill: #4e7d65;
}
.st19 {
fill: #242e30;
}
.st20 {
fill: #53836c;
}
.st21 {
fill: #64967e;
}
.st22 {
fill: #272f35;
}
.st23 {
fill: #24272e;
}
.st24 {
fill: #4d7b66;
}
.st25 {
fill: #7deba6;
}
.st26 {
fill: #010201;
}
.st27 {
fill: #23282e;
}
.st28 {
fill: #010001;
}
.st29 {
fill: #262c32;
}
.st30 {
fill: #84ebad;
}
.st31 {
fill: #fdfdfc;
}
.st32 {
fill: #7eeca6;
}
.st33 {
fill: #7deba7;
}
.st34 {
fill: #7feea9;
}
.st35 {
fill: #4f7965;
}
.st36 {
fill: #7eeea9;
}
.st37 {
fill: #7ceaa6;
}
.st38 {
fill: #83ebaa;
}
.st39 {
fill: #4c7561;
}
.st40 {
fill: #262a31;
}
.st41 {
fill: #83e7a9;
}
.st42 {
fill: #94ddb2;
}
.st43 {
fill: #000100;
}
.st44 {
fill: #7deaa7;
}
.st45 {
fill: #263232;
}
.st46 {
fill: #7ce9a5;
}
.st47 {
fill: #7ceca6;
}
.st48 {
fill: #232a2f;
}
.st49 {
fill: #7beba4;
}
.st50 {
fill: #80eca8;
}
.st51 {
fill: #fefefe;
}
.st52 {
fill: #262a30;
}
.st53 {
fill: #7ceba6;
}
.st54 {
fill: #7feda8;
}
.st55 {
fill: #75d79d;
}
.st56 {
fill: #252930;
}
.st57 {
fill: #262b31;
}
.st58 {
fill: #7ce8a8;
}
.st59 {
fill: #7bd8a1;
}
.st60 {
fill: #4f7765;
}
.st61 {
fill: #7deda8;
}
.st62 {
fill: #7df0aa;
}
.st63 {
fill: #4e7962;
}
.st64 {
fill: #7deaa5;
}
.st65 {
fill: #242b31;
}
.st66 {
fill: #233132;
}
.st67 {
fill: #262e33;
}
.st68 {
fill: #7feba8;
}
.st69 {
fill: #24292f;
}
.st70 {
fill: #4e7663;
}
.st71 {
fill: #010202;
}
.st72 {
fill: #507b67;
}
.st73 {
fill: #252a30;
}
.st74 {
fill: #283235;
}
.st75 {
fill: #272b32;
}
.st76 {
fill: #7eeca9;
}
.st77 {
fill: #4b7260;
}
.st78 {
fill: #fdfdfd;
}
</style>
</defs>
<g id="Generative_Object">
<g>
<path class="st57" d="M338.61,0c39.93.15,80.37,9.62,117.48,25.08,41.71,17.68,79.44,43.94,110.56,76.83,31.14,32.91,53.43,69.6,69.27,111,11.4,31.05,18.2,63.2,19.82,94.7l-.32.25c.11,1.5.22,2.99.33,4.48.28.31.24,1.22,0,1.6.11,2.09.22,4.22.32,6.41v18.58l-.15.36c-.3,47.92-13.83,97-35.93,140.06l-15.65,27.09c-27.24,42.15-63.89,77.49-106.94,103.24-44.37,26.53-90.35,40.63-139.45,45.75-8.3.22-16.24.44-23.82.64h-11.85c-12.63-.15-25.7-.93-38.5-2.31-29.73-4.89-59.02-11.63-87.55-24.72-52.49-23.28-99.13-59.87-133.81-107.31C26.26,472.28,6.13,415.55.71,355.76c-.23-6.98-.47-13.77-.71-20.36l.07-15.03c.44-80.72,34.45-161.79,91.15-220.28C150.45,38.48,233.7.64,319.09.04l19.52-.04Z"/>
<path class="st0" d="M656.07,320.35c-.13.02-.31-.11-.31-.33v-6.07s.31,0,.31,0v6.41Z"/>
<circle class="st30" cx="328.1" cy="328.09" r="304.47"/>
<path class="st0" d="M655.75,312.34c-.22-.04-.34-.27-.34-.62v-3.86c.23.03.36.27.35.61l-.02,3.87Z"/>
<circle class="st57" cx="328.12" cy="328.13" r="300.93"/>
<g>
<path d="M328.29,575.49l-46.57-26.47-17.15-9.64-22.27-.05-66.53-37.55-56.66-32.03-17.89-10.09v-59.14s-2.64-.14-2.64-.14v-122.61s-14.86-.03-14.86-.03c-.34-.68-.35-1.19-.39-1.95l-.02-44.47h17.94s-.1-51.04-.1-51.04l19.06-11.03,12.05-6.92,54.96-31.6,23.92-.02,19.95-11.42,26.06-14.93,25.21-14.52,34.54-19.88,9.39-5.37c1.52-.87,2.77-.54,4.16.25l12.93,7.34,33.31,19.21,68.61,39.31h23.69s26.73,15.27,26.73,15.27l48.09,27.76,11.16,6.25-.05,82.7,14.35-.09,7.7-.17c.25-.12.64,0,.68.14.06.23-.08.63-.19,1.01l-12.42,40.68-6.37,21.19,8.96,35.99,9.9,38.99-21.38.06c-.37,0-.76-.12-1.07.03-.19.09-.13.48-.13.95v57.86c0,.58-.87,1.01-1.18,1.19l-14.2,7.98-39.23,22.19c-.35.2-.59.32-.6.75l-.11,4.31-21.13,12.25-37.51,21.71-5.24-2.75-21.6,12.4-22.69.07-46.13,26.23-17,9.84Z"/>
<g>
<g>
<path class="st73" d="M410.6,349.23v13.05c-.28.99-.62,1.95-1.03,2.88l-10.6,20.6-.74.05-.1-8.16-.02-41.24,7.78-1.97c.24-.07.61-.14.68.01,1.88,4.35,3.19,10.11,4.04,14.77Z"/>
<path class="st38" d="M410.61,362.27v9.93s-12.33,24.14-12.33,24.14l-.06-10.53,10.58-20.56c.87-1.71,1.37-3.05,1.81-2.97Z"/>
<path class="st27" d="M327.06,261.06c.08-.17.29-.31.63-.31h7.71s8.01-3.19,8.01-3.19l7.07-3.17v11.03c-4.38-2.26-8.85-4.31-13.78-4.33l-9.64-.03Z"/>
<path class="st16" d="M476.27,402.38l-.1.25-2.72.06c-.31,0-.53-.13-.44-.33l3.26.02Z"/>
<g>
<path class="st50" d="M559.79,386.91l-20.35.05-5.42-24.99-4.95-22.89c-.3-1.4-.14-2.71-.92-4.11l-.25,10.08-.09,41.87-18.8.03-.17-13.69.11-68.94.03-59.36,18.92.02-.06,45.53.17,27.04,11.63-41.38,19.68.02-5.95,19.61-8.69,28.78,8.46,34.94,6.64,27.38Z"/>
<path class="st15" d="M473.99,368.73c1.56,1.77,4.44,1.73,5.93.06,1.04-1.17,1.3-2.64,1.41-4.18v-23.84s18.43.03,18.43.03l.03,23.08c0,9.99-4.14,20.18-13.8,23.39-4.91,1.63-10.06,1.59-15.12.7-6.64-1.17-12.32-5.39-14.98-11.58-1.99-4.62-2.67-9.35-2.66-14.46l.1-62.7c.01-6.61,2.19-15.01,6.96-19.26,3.45-3.06,7.54-4.73,12.07-5.29,13.25-1.65,23.4,3,26.49,16.42.75,4.2.92,8.26.93,12.57l-.04,16.36h-18.3s-.02-21.48-.02-21.48c0-1.65-.33-3.16-1.33-4.42-1.17-1.47-3.07-1.86-4.97-1.18-1.34.48-2.61,2.11-2.62,4.03l-.11,33.17v7.87s0,12.88,0,12.88l.04,12.67c0,1.82.22,3.64,1.54,5.14Z"/>
<polygon class="st50" points="131.98 386.91 112.12 386.97 112.13 264.57 96.82 264.57 96.8 245 147.13 245.04 147.11 264.55 131.99 264.59 131.98 386.91"/>
<rect class="st15" x="279.35" y="245.02" width="19.18" height="141.94"/>
<path class="st15" d="M170.97,386.89l-19.02.07v-110.75s15.78-.01,15.78-.01l.23,6.19c2.93-6.25,11.23-9.75,17.54-6.51l-.04,19.94c-3.09-.94-6.2-1.26-9.23-.47-3.38.87-5.42,3.51-5.25,7.14l-.02,84.41Z"/>
<rect class="st50" x="424.14" y="276.16" width="19.24" height="110.8"/>
<rect class="st50" x="192.42" y="276.16" width="19.01" height="110.8"/>
<rect class="st32" x="422.74" y="246.37" width="22.03" height="19.22" transform="translate(177.53 689.59) rotate(-89.97)"/>
<rect class="st6" x="190.92" y="246.42" width="22.04" height="19.05" transform="translate(-54.09 457.79) rotate(-89.98)"/>
<path class="st58" d="M178.43,252.15l-.02,5.4c-.11,1.14-.23,2.27-.36,3.39l-.4.14-5.26.94h-1.32c-.24-.11-.36-.28-.38-.54s2.25-4.06,2.57-4.47l.78-.07,4-8.51c.23,1.47-.29,2.68.39,3.72Z"/>
<path class="st5" d="M410.5,341.22l.1,8-4.72-14.78,1.57-.64,1.38-.31c.98,2.46,1.53,5.03,1.67,7.73Z"/>
<path class="st45" d="M410.5,341.22l-2.01-7.15c-.11-.38-.88-.34-1.04-.27l3.22-1.64-.17,9.06Z"/>
<path class="st42" d="M459.27,264.55l-1.5.72c-.17,0-.3-.14-.3-.36l.06-4.56,1.53,2.99c.22.43.29.84.21,1.2Z"/>
<path class="st29" d="M177.65,261.07c.88-1.22.06-2.73.76-3.53l-.03,3.46c-.1.16-.57.04-.73.07Z"/>
<g>
<path class="st54" d="M415.95,298.93c-.08,4.21-1.23,8-2.82,11.69-1.83,4.25-4.92,7.77-9.07,9.78-3.41,1.65-6.94,2.64-10.7,2.7l-8.94.13-.05,63.64-20.39.06-.02-141.94,26.53.07c4.9.01,9.44.86,13.79,2.89,4.23,2.17,7.83,5.41,9.24,9.97l1.8,5.85,1.1,12.69-.45,22.46Z"/>
<path d="M396.19,294.35l-.52,4.53c-.32,2.77-2.13,4.71-4.89,5.14l-6.38.26v-40.16s6.12.1,6.12.1c3.72.06,5.6,3.01,5.61,6.64l.05,23.5Z"/>
</g>
<g>
<path class="st15" d="M241.18,384.65l-.08,16.43-.15,13.79-7.18.16-11.82-.09-.05-27.38-.02-111.4h15.56s.33,5.34.33,5.34c3.79-4.9,9.18-7.33,15.31-7.04,6.3.3,11.64,3.8,14.07,9.74,1.52,3.72,2.43,7.7,2.44,11.95l.1,67.71-.47,5.97c-.31,3.85-1.11,7.68-3.07,10.96-2.42,4.03-6.19,6.65-10.81,7.21-5.03.62-10.06-.21-14.17-3.34Z"/>
<path class="st43" d="M249.04,368.69c-1.88,2.14-4.92,1.32-6.49-.67-1.31-1.66-1.64-3.53-1.64-5.77l-.02-60.75c0-2.03.08-3.66.67-5.52.79-2.47,3.69-4.07,6.19-2.96,1.88.84,2.65,2.95,2.65,5.08l.09,66.06c0,1.63-.41,3.32-1.45,4.51Z"/>
</g>
<g>
<path class="st50" d="M354.06,342.96v21.26c0,10.78-4.09,20.88-15.06,23.49-5.7,1.35-11.53,1.29-17.03-.72-10.63-3.89-13.86-14.16-13.87-25.05l-.02-61.4c0-7.52,1.77-16.39,7.59-21.08,6.55-5.28,16.54-6.21,24.42-3.87,10.9,3.23,13.95,13.5,14.01,24.3l.02,34.59-27.27.03-.04,29.67c.23,1.86.55,3.62,1.81,4.87,1.41,1,3.04,1.32,4.67.68,1.37-.54,2.33-2.08,2.45-3.91l.04-22.83h18.28Z"/>
<path class="st28" d="M335.77,321.26l-9,.05.16-24.14c0-1.47.81-2.73,1.61-3.48,1.13-1.05,2.76-1.25,4.29-.84,1.97.53,2.92,2.69,2.97,4.75l-.03,23.65Z"/>
</g>
</g>
<g>
<polygon class="st61" points="551.22 182.4 551.22 262.35 542.67 262.33 542.63 187.16 475.75 148.66 451.18 134.64 467.98 134.55 480.54 141.59 498.43 151.93 551.22 182.4"/>
<polygon class="st78" points="138.4 231.21 126.03 231.21 126.02 194.29 144.62 183.67 192.5 156.01 212.98 144.23 210.86 149.06 205.31 162.76 138.42 201.35 138.4 231.21"/>
<path class="st78" d="M517.91,231.16l.03-29.82-60.13-34.7-6.85-3.89-6.64-16.01c-.34-.81-.93-1.32-1.01-2.55l31.92,18.4,36.32,20.98,18.59,10.83v36.76s-12.23,0-12.23,0Z"/>
<polygon class="st36" points="113.75 187.16 113.73 231.24 105.07 231.16 105.07 182.39 162.06 149.6 188.09 134.57 205.18 134.66 187.37 144.8 139.37 172.36 113.75 187.16"/>
<polygon class="st11" points="500.97 231.25 499.4 227.12 499.17 211.82 484.26 203.15 476.55 192.28 504.83 208.39 504.86 231.18 500.97 231.25"/>
<path class="st33" d="M156.94,226.67c-.02,1.69-1.24,3.04-1.68,4.44l-3.85.04-.05-22.71,20.58-11.81,8.13-4.51-8.06,11-14.89,8.57-.19,14.96Z"/>
<g>
<path class="st75" d="M396.59,142.52c-25.86-10.62-54.41-14.96-82.29-12.72-5.89.47-11.39.92-17.23,1.99-8.75,1.6-17.17,3.64-25.63,6.35-6.35,2.03-12.14,4.42-18.19,7.12-7.91,3.53-15.22,7.42-22.5,12.11-5.39,3.47-10.44,6.84-15.45,10.84-4.92,3.93-9.69,7.67-14.13,12.11l-6.27,6.28c-8.82,8.83-19.32,23.56-25.66,34.51-2.93,5.07-5.59,9.86-7.98,15.1l-.15-4.85-1.48-.13.8-2.91.37-14.69,13.38-7.5,10.42-14.15c2.64-2.73,2.76-3.6,2.49-7.45-1.16-.13-1.86.65-2.7,1.11l-7.68,4.21-21.55,12.35-7.27,4.2-.21,24.87-5.67.04v-27.98s19.44-11.14,19.44-11.14l30.22-17.47,16.6-9.6,8.97-21.54c.8-1.92,1.42-3.77,1.24-5.85-.97-.19-1.81.12-2.63.6l-15.98,9.33-16.35,9.32-20.09,11.57-15.58,9.12-25.52,14.68v38.88s-4.89.04-4.89.04l-.05-42.06,8.09-4.54,69.47-39.97,28.92-16.89,23.38-13.52,41.25-23.73,30.29-17.41,9.32-5.36,11.89,6.76,35.55,20.47,36.84,21.14,29.82,17.29,14.93,8.79,15.45,8.87,17.72,10.18,22.36,12.84,16.44,9.5,9.7,5.43.06,41.24c0,.37.02.63-.06.81-.06.13-.32.23-.63.23l-3.9-.03c-.29,0-.61-.07-.6-.2l.05-.8-.03-38.07-11.73-6.88-7.4-4.18-17.2-10-15.26-8.82-27.03-15.5-13.09-7.75c-1.27-.75-2.55-1.58-4.12-1.47-.36,1.79.09,3.38.73,4.87l3.93,9.14,5.19,13.46,13.88,7.88,13.35,7.76,12.81,7.4,20.27,11.74,5.25,2.78c.64.34,1.08.62,1.08,1.42l-.03,15.03.06,10.83c0,.34.72,1,.06,1.35l-5.93-.03-.13-24.9-21.15-12.23-17.77-9.79c-.82,5.14.57,5.46,1.94,7.34l10.24,14.04,13.83,8.05.21,13.29.92,4.05c-.44.21-1.04.2-1.78.23l.37,3.86c.31.3-.1.76-.33.34-8.1-17.06-18.53-32.49-31.05-46.48-5.28-5.9-10.86-11.05-16.89-16.19-15.02-12.8-32.04-22.83-50.36-30.35Z"/>
<g>
<path class="st26" d="M337.18,101.52l-9.14-5.2-8.05,4.67-69.95,40.46-18.6,10.87-12.81,7.38c-1.36.78-2.68,1.57-4.35,1.5.11-1.88.84-3.4,1.45-5.05l4.66-11.01,5.09-12.2,11.46-6.56,19.99-11.38,30.87-17.77,18.9-10.92,20.33-11.73c.59-.34,1.43-.42,2.09-.04l36.2,20.78,32.32,18.63,33.18,18.93,2.55,6.55,5.49,12.75,3.04,8.95c-1.19.15-2.13-.17-3.01-.69l-8.91-5.22-14.72-8.6-8.89-5.23-20.71-11.94-33.89-19.6-14.59-8.35Z"/>
<path class="st34" d="M327.17,92.69l-100.38,58.1-6.15,3.3,6.66-16.41c.69-1.7,1.34-2.42,2.91-3.31l11.81-6.71,15.14-8.61,42.03-24.23,28.91-16.69,29.08,16.71,41.43,23.88,29.33,16.87,7.53,18.26-8.49-4.59-33.63-19.51-64.36-37.1c-.55-.32-1.01-.43-1.83.04Z"/>
</g>
<g>
<path class="st2" d="M328.11,117.57l-6.48,3.56-8.68,5.04c-1.25.72-19.24,2.29-24.35,2.31-.51-.39-.59-1.96-.02-2.31l12.11-7.46,10.16-5.86,15.87-9.06c1.09-.62,1.83-.51,2.85.06l9.07,5.04,17.82,10.31c3.92,2.27,7.78,4.17,11.31,7.02.52.42.5,1.84.02,2.25-7.26.01-14.43-.97-21.55-1.76-2.01-.22-3.47-.63-5.21-1.64l-12.93-7.5Z"/>
<path class="st62" d="M328.19,113.51l-13.9,7.93c-1.5.86-2.88,1.58-4.64,1.78l-11.2,1.28c1.41-1.5,2.93-2.02,4.5-2.92l25.11-14.51,29.8,17.28-11.89-1.29c-1.11-.23-2.12-.54-3.11-1.11l-14.67-8.44Z"/>
</g>
</g>
<g>
<path class="st57" d="M481.55,261.09c-7.58-1.13-15.38.13-22.27,3.45-.49-.28-.87-.79-1.14-1.55.13.89.01,1.65-.36,2.27-.14.68-.94.21-.57-.24l.1-6.21-.14-27.51-20.16-.04c-.62-.21-1.01-.82-1.16-1.82l-10.79-26.15c-.21-.79-.35-1.32.68-1.28l23.3,9.79,10.93,4.82,5.98,11.29,10.06,18.75,4.8,9.35c.63,1.22-.34,3.14-.32,4.36.32.5.75.54,1.07.71Z"/>
<path class="st7" d="M241.48,262.52l-16.53.07-.04-12.98.17-.65,13.18-.91c.74-.14.66-.45.22-.96l-13.05,1.4-.53-.59v-16.58s-5.11-.08-5.11-.08l-.46-.49,5.95-11.29-1.15.53-5.94,10.93-.7.36-24.9-.05c.43-1,1.02-1.95,1.79-2.95l18.66-9.17,16.06-7.77,11.74,24.43,5.28,11.05-8.68,15.49,4.05.2Z"/>
<path class="st57" d="M325.15,261.16c-4.44,1-8.75,2.04-13.04,4.15l-.05-12.87.06-21.13-24.5-.03,18.38-8.92,22.94-10.96c.93-.34,1.39-.19,1.36.43l-.57,48.04c-.33.53-.92.63-1.76.29l-15.05-6.9v1.51s11.1,5.11,11.1,5.11c1.22.16,1.59.59,1.12,1.28Z"/>
<path class="st57" d="M375.49,231.23l-25.01.18v20.11s-.39.87-.39.87l-17.56,7.71c-.63.21-1.04-.12-1.25-.73l.49-46.89c0-.87.16-1.39,1.11-1.1l4.91,2.21,37.7,17.64Z"/>
<path class="st57" d="M434.02,231.24l-23.46.09.16,4.74c-5.36-2.49-10.92-4.6-16.66-4.65l8.9-9.75,6.76-7.03,7.84-8.15,4.64-5.03.79.03c3.82,9.73,7.65,19.46,11.48,29.18l-.46.58Z"/>
<path class="st57" d="M275.33,231.25l-9.47.06v5.11c-.35.05-.78.13-1.24.35l-14.69,6.8-4.99-10.58-10.54-21.93.59-.43,40.9,18.43c.8.8.95,1.3-.19,1.66.32.27.2.45-.37.53Z"/>
<path class="st65" d="M494.83,244.62c1.27,4.27-.04,9.44.38,14.2l-.54,5.77c-.69-.04-.76-.48-1.07-.63.15.02.4.12.7-.04-2.5-4.1-7.98-7.86-9.67-11.08l-7.63-14.52-11.54-21.42c-.66-1.33-1.29-2.52-1.48-3.99l-1.34-10.55,7.86,9.81c7.8,11.02,14.06,22.68,19.3,35.11,1.76,4.17,3.56,7.97,4.65,12.27.52-2.16-.03-4.1.14-6.16l.27-3.24-.03-5.55Z"/>
<path class="st12" d="M329.32,173.96l-.65-.27-.12-2.78c-15.59,18.9-31.12,37.83-46.6,56.78l-1.61,1.49c-1.44.69-2.82,1.42-4.6,1.56l1.38.52h-1.79c.12-.24.77-1.17.46-1.56l-39.27-17.68c-.85-.38-1.37-.62-2.12-.96-.43-.9-1.02-1.8-1.21-2.86.57-.62,1.39-.69,2.48-.19l1.78.63,39.87,18.13c16.74-20.38,33.63-40.65,50.67-60.82.25,0-.17,1.07.04-.36.36,0,1.47.19,1.49.77l.15,6.97c0,.35-.15.57-.34.64Z"/>
<path class="st29" d="M187.37,231.25l-8.87.08-.07,20.83c-.74.2-.94-.26-.98-.96l-2.63,5.59-.78.15c-.86.76-1.02.09-1.12-.85l4.69-29.64.78-2.57,4.07-6.5,9.3-12.87-2.03,21.45-2.36,5.29Z"/>
<path class="st63" d="M332.88,211.38c-.48.29-.46.71-.47,1.35l-.34,32.28-.15,14.66,18.56-8.15v2.87s-.26.79-.26.79l-12.57,5.36c-3.52.33-7.06.5-10.6.52-.65,0-1.31-.03-1.91.1-.24-.59-.9-.47-1.35-.68l-11.52-5.29v-2.95s16.84,7.73,16.84,7.73l.36-24.59.2-23.63c0-.38-.55-.42-.73-.36,1.37-.66,2.38-.72,3.94-.02Z"/>
<path class="st7" d="M171.07,262.02c.7-.82,1.14-.82,1.32,0-.41.64-1.26.27-1.94.28l-8.72.09c-.31,0-.6.13-.55-.31.57-.25.4-.99.62-1.58l5.89-15.62,6.49-13.62c.8-.49,1.08,0,1.06.87l-4.43,29.13.27.77Z"/>
<path class="st69" d="M265.86,260.72c0,1.22.17,2.36-.18,3.8-6.45-4-15.34-4.66-22.12-2.77l6.37-11.52c.23-.3.51-.48.83-.55,4.99,3.39,9.94,6.84,14.85,10.35l.25.69Z"/>
<path class="st22" d="M265.86,257.51l-.6.37c-4.39-2.93-8.79-5.86-13.19-8.79l.03-.75c.1-.13.26-.4.53-.53l13.25-6.22-.03,15.91Z"/>
<path class="st35" d="M425.73,202.02c-.49.69.1,1.31.4,2.03l10.31,25.15c.31.75.49,1.41.57,2.06l-2.98-.02-4.26-11.05-6.97-17.01c-.24-.6-.14-1.33-.58-1.73l.37-.5c.2-.28.76-.2,1.11-.02l2.03,1.08Z"/>
<path class="st20" d="M252.11,248.34l13.75,9.17v3.22c-.7,0-1.18-.56-1.82-1.01l-12.42-8.72c-.57-.4-1.01-.88-1.7-.76.49-.85,1.13-1.83,2.19-1.89Z"/>
<path class="st59" d="M219.79,231.25l-2.31.04c0-.42.15-.78.4-1.23l5.86-10.7c.97-.26,1.67-.83,2.69-.67l-6.64,12.56Z"/>
<path class="st55" d="M224.91,249.61v-1.71s13.6-1.43,13.6-1.43c.48-.05.91.82.98,1.15.09.39-.49,1-1.14,1.05l-13.44.93Z"/>
<path class="st57" d="M328.02,165.58c.12.13.57.34.59.53.03.24-.57.52-.7.67l-10.48,12.61-12.65,15.21-13.78,16.64-9.01,10.85-4.54,5.52-39.91-18.23c-1.53-.7-2.95-1.05-4.36-1.18-.07-.42.07-.77.33-1.31l16.07-33.18c.7-1.45,1.08-2.26,2.67-2.91l40.4-16.48c.82-.33,1.36-.56,2.23-.36l33.14,11.62Z"/>
<path class="st57" d="M329.32,173.96l-.26,10.25-.39,21.83-32.45,15.48-15.88,7.65c-.03-.2-.03-.47.11-.65l10.73-13.3,31.6-38.15,6.44-8.02.1,4.9Z"/>
<path class="st18" d="M174.03,256.93l-2.96,5.09c-.25,0-1.06.15-1-.27l4.4-28.96c.1-.64.32-1.2-.31-1.53l4.22-7.38c-.03,3.55-.8,6.89-1.37,10.51l-3.48,21.87c-.06.39.26.65.5.68Z"/>
<g>
<path class="st76" d="M494.83,244.62c.21,1.78.44,14.32-.07,14.86-.45.46-.74.37-1.05-.29-6.83-19.59-16.82-38.97-30.12-54.63.29,3.43.81,6.82,1.56,10.16,6.76,12.69,13.48,25.37,20.17,38.03,2.82,3.28,5.73,6.5,8.74,9.66.58,1.08.84,2.35-.45,1.55-2.6-1.29-5.39-2.12-8.22-2.69-1.22,0-2.41-.06-3.58-.18h-.26s-1.64-.49-1.64-.49c.27-1.46.37-2.91.3-4.33-6.82-13.08-13.73-26.13-20.71-39.13-11.24-4.86-22.49-9.74-33.73-14.64l-.04-.48-.6.41c-1-.74-1.97-1.07-2.91-.97v.91c-9.04,9.48-18.02,19.03-26.93,28.67l-1.21.38-18.58-.18-2.77-.59-39.69-18.49-.14-.77c-.47.37-1.12.42-1.95.14-.73.16-1.39.12-1.98-.12v.71s-38.68,18.6-38.68,18.6l-2.64.57h-10.51c-2.63-.22-1.13-1.29.71-1.66,1.01-.66,1.84-.8,2.51-.43.37-.55.88-1.03,1.51-1.44l46.19-22.18.63-31.14.65-.45c.16-1.38.28-3.46.08-4.84-.01-.08-.21-.13-.47-.17.29-1.72-.01-2.85-.9-3.38l-.42.53c-11.13-3.98-22.27-7.84-33.43-11.59-13.97,5.54-27.91,11.18-41.84,16.94l-1.11.73c-5.98,11.78-11.79,23.68-17.42,35.69l-.63.32c.88.29,1.94,2.15,1.21,2.86l1.29,1.21,14.53,30.41c4.98-2.3,9.97-4.56,14.99-6.79l.67.53v5.18s-.35.88-.35.88l-12.73,6.02-.68-.15c-.38.86-1.25,1.82-2.19,1.89.22.12.52.36.46.5-1.33,2.78-3.4,6.58-5.06,9.15-.37.89-.91,1.42-1.77,1.87l-2.07.77c-1.55.07-3.05-.03-4.49-.31,2.64-5.18,5.44-10.33,8.37-15.45l-16.63-34.53-33.95,16.53c-.56,1.21-1.3,2.04-2.2,2.47h-5.21s-.44-.58-.44-.58l2.19-4.88,1.78-18.94c-4.2,5.48-8.1,11.14-11.71,16.98l-.79.05c-.72,2.58-1.86,5.01-3.44,7.3l-.78.08c.11.72-.04,1.51-.43,2.35-4.58,9.1-8.48,18.55-11.58,28.23-.26.36-.6.44-.98.23l.03-16.94c6.51-15.03,14.45-29.16,24.44-42.12l8.57-10.38c5.08-6.15,10.9-11.18,16.9-16.4,4.27-3.71,8.6-6.92,13.2-10.19l5.29-3.75c21.86-14.1,46.24-23.71,72.11-27.4,8.6-1.23,16.99-1.97,25.66-2.13s16.98.96,25.53,1.88c7.82.85,15.03,2.6,22.6,4.62,10.92,2.92,21.15,7.01,31.31,11.92,7.75,3.74,14.97,7.7,22.04,12.58,8.71,6.02,16.9,12.33,24.38,19.78l3.56,3.54,10.19,11.29c8.09,9.66,14.94,19.94,20.69,31.18,2.56,4.99,5.52,9.64,7.14,15.06Z"/>
<g>
<path class="st57" d="M485.38,261.27l-3.25.12c-.23,0-.35-.15-.33-.3,1.22-.03,2.38-.06,3.58.18Z"/>
<path class="st57" d="M195.18,222.41l2-24.31c.08-.94,1.24-1.98,1.85-2.58l9.81-9.72,9.31-8.02,12.41-9.32,14.76,2.92-16.33,33.84c-.34.7-1.13,1.2-1.8,1.53l-31.99,15.68Z"/>
<path class="st57" d="M456.71,195.3l1.88,15.32-11.82-5.18-22.89-9.83-25.59-32.59-7.42-9.41c.45-1.05.92-1.91,1.86-2.41l16.41,7.62,11.19,6.43,12.76,8.73c6.02,4.12,11.21,8.88,16.52,13.87l7.1,7.44Z"/>
<path class="st57" d="M332.59,161.98l-33.53-11.86,10.93-8.6,2.73-2.23,18.21-.52c20.35.38,36.97,3.61,56.35,10.35-.47,1.63-1.63,2.29-3.32,2.63l-51.37,10.24Z"/>
<path class="st1" d="M248.67,166.9l-11.44-2.38c9.96-6.24,20.25-11.24,31.27-15.2,11.17-4.04,22.37-7.18,34.38-8.74l-10.63,8.53-43.57,17.8Z"/>
<g>
<path class="st57" d="M387.83,230.37c-.54.55-1.72.33-2.26.08l-17.04-7.93-15.71-7.37-19.19-8.97.75-39.41,20.97-4.27,31.57-6.26,16.93,21.67,15.17,19.59-11.68,12.48-5.31,5.62-5.14,5.46-9.07,9.32Z"/>
<path class="st17" d="M379.98,208.97l-6.9-26.04-14.3,9.28-17.39,11.42c-.62.41-1.86-.49-1.88-1.07l6.63-4.45,25.19-16.87-30.22-10.7c1.32-.45,2.87-1.12,4.14-.67l12.3,4.36,15.59,5.24,5.95-10.83,3.91-7.31c.65-.21,2.21.61,1.81,1.37l-9.53,17.71,17.57,6.59,17.94,6.98c.49.29.88.54,1.04.83.27.46.34,1.39-.16,1.66l-17.47-6.62-18.63-6.89,2.51,10.51,6.68,25.61c.44,1.68,1.03,3.09,1.06,5.07-1.37.05-2.36-1.05-2.71-2.44l-3.13-12.73Z"/>
</g>
</g>
</g>
</g>
</g>
</g>
<g>
<path class="st44" d="M174.17,486.92l86.05,48.65-17.03.03-56.28-31.85-54.85-30.95-26.91-15.13v-57.14s8.7-.06,8.7-.06l-.06,51.81c1.19,1.31,2.59,2.02,4.17,2.91l29.07,16.39,27.14,15.34Z"/>
<polygon class="st51" points="376.83 532.16 328.2 559.84 305.93 547.16 279.38 532.08 275.95 526.5 265.24 509.52 328.09 545.75 383.34 514.11 391.3 509.58 376.83 532.16"/>
<polygon class="st31" points="184.58 464.8 197.85 486.01 183.27 477.92 155.62 462.33 126.12 445.61 126.13 400.53 138.49 400.48 138.49 438.71 157.62 449.45 184.58 464.8"/>
<path class="st73" d="M356.83,400.48l2.13.64,21.58,10.57c1.51.33,2.05.89,1.61,1.68l-24.94,11.77-24.06,11.27c-.85.3-1.06-.24-1.09-1.05l-1.11-32.54.62-.49c6.84-.55,12.13-.92,18.81-4.53l.1,2.66h6.35Z"/>
<path class="st73" d="M331.22,402.36c-.09.61-.58.84-1.48.69.35,10.94.67,21.9.96,32.87-.4.05-.78,0-1.15-.17l-15.17-7.3-31.26-15.2-.08-.7c6.38-3.84,12.76-7.67,19.12-11.49l2.2-.58,7.72.02.16-3.18c5.4,3.02,11.06,4.52,17.09,4.86.63.04,1.51-.54,1.89.18Z"/>
<polygon class="st68" points="499.84 486.68 499.73 477.27 519.68 465.86 542.53 452.67 542.53 400.55 551.23 400.55 551.25 457.59 499.84 486.68"/>
<polygon class="st3" points="499.77 463.07 499.7 449 517.78 438.54 517.8 400.47 530.22 400.45 530.21 445.5 499.77 463.07"/>
<path class="st73" d="M382.45,427.31c-.27.78-.26,1.63-.26,2.61v18.21c0,.22-.25.37-.3.37-.66-.18-.87-.91-.65-2.2-.04-2.59.17-5.17.62-7.74l-.1-9.56-7.23,29.05-30.64-15.1-6.35-3.2,15.55-7.25,31.03-14.56-1.91,8.55c-.08.37.06.67.24.84Z"/>
<path class="st73" d="M381.85,468.05c.74,1.24.11,2.75.11,4.22v15.62s.14,7.41.14,7.41c-.57-1.73-.09-3.52-.28-5.64-1.38.76-2.69.8-4.19.53l-17.28-4.21-26.56-6.76,40.64-17.76,7.28,10.16.13-3.58Z"/>
<path class="st69" d="M397.23,418.61l-7.35-5.44,5.35-10.87c.32-.65.74-1.16.72-1.81l2.14-.04-.2-4.82-.05-17c0-.45.08-.77.27-.98.25.07.61.16.65.36.39,2.12.02,5.72-.55,7.79l.64.28.07,7.57,10.93-21.4.75-.05v28.24s17.6.1,17.6.1l-19.89,11.6-11.09,6.47Z"/>
<polygon class="st73" points="499.79 472.94 499.78 467.28 515.98 457.99 533.84 447.78 533.88 400.6 538.77 400.5 538.74 450.93 503.96 470.57 499.79 472.94"/>
<path class="st49" d="M168,434.12c1.87,1.07,4.09,1.81,5.32,3.41l8.09,10.47-30.25-16.59.1-31.24,5.59,11-.13,16.48,11.27,6.47Z"/>
<path class="st73" d="M206.56,400.5l.45.58-9.02,21.54c-.39.78-.71,1.01-1.33.32l-13.99-22.47,23.89.02Z"/>
<polygon class="st52" points="499.87 444.73 499.81 438.59 508.5 433.69 508.64 400.49 514.21 400.48 514.23 436.59 499.87 444.73"/>
<path class="st73" d="M255.05,402.33c-.25.33-.4.76-.39,1.28l.15,7.48,2.85-4.68,16.9,8.38.27.8c-6.33,4.23-12.76,8.33-19.29,12.31-.27.58-.52.97-1.21.69l.13-11.48-.11-8.02-.02-6.41c0-.41.54-.29.7-.35Z"/>
<polygon class="st46" points="413.1 535.65 396.39 535.55 423.8 519.86 432.37 524.72 413.1 535.65"/>
<path class="st23" d="M298.95,400.48c.11.23.22.61.07.7-4.89,3.13-10.37,6.29-15.46,9.14-1.19.97-2.39,1.43-3.62,1.39l-1.91-.96c-2.68-3.01-5.17-6.19-7.48-9.53l.24-.76h28.17Z"/>
<path class="st10" d="M390.78,400.47l-5.7,10.84c-.18.23-.41.4-.71.37-7.24-3.49-14.48-6.97-21.72-10.46l-.07-.74h28.2Z"/>
<polygon class="st47" points="499.78 434.2 499.5 429.58 499.38 411.31 504.92 400.17 505 431.52 499.78 434.2"/>
<polygon class="st73" points="495.56 424.91 485.94 419.36 493.27 406.44 496.08 400.59 500.16 400.54 495.74 410.24 495.56 424.91"/>
<path class="st14" d="M208.54,428.55l-9-3.49-.85-.81c3.09-6.88,5.93-13.86,8.53-20.93-.06-1.2.17-2.15.69-2.85.28,0,.38.27.37.6l-.19,11,.06,14.34c0,.65-.15,1.87.39,2.16Z"/>
<path class="st25" d="M478.6,415.08c-.39-.24-.57-.51-.54-.81,2.05-4,4.21-7.94,6.47-11.82l1.06-.67,6.96-2.11c-2.92,6.4-6.22,12-9.78,17.88l-4.17-2.47Z"/>
<path class="st39" d="M331.22,402.36l.34-.03.3,8.37.83,24.72c.01.44.22.76.45.98-.69.32-1.25.63-2.15.13l-1.43-.79c.11,0,.55.06.67-.12l-1.29-33.2,2.28-.07Z"/>
<path class="st1" d="M485.59,401.78l-6.13,11.04c-.44.8-.92,1.53-.86,2.26l-5.85-3.16c1.02-3.16,2.16-6.25,3.42-9.28-.24-.16-.19-.24.1-.25,3.2.02,6.38.29,9.32-.6Z"/>
<path class="st24" d="M362.58,400.48l22.5,10.84c-.55,1.04-1.92,1.58-2.94,2.05-.34-.75-1.15-.75-1.88-1.1l-22.76-11.15c-.36-.18-.54-.41-.67-.64h5.76Z"/>
<path class="st9" d="M470.43,401.95c-.18.37-.43.67-.75.91l-1.66,5.5-.74.25-13.76-8.02,3.94-.16c.07-1.11-.23-2.05.22-3.14,4.06,2.65,8.31,3.67,12.74,4.65Z"/>
<polygon class="st35" points="304.36 400.48 296.75 405.06 283.13 413.25 279.94 411.72 298.95 400.48 304.36 400.48"/>
<path class="st72" d="M207.91,400.45c.35,2.26-.26,4.28-1.19,6.46l-7.23,17.13c-.35.37-.2.81.05,1.01-1.04-.43-2.24-1.1-2.87-2.11.64-.07.72-.54.95-1.09l6.42-15.3,2.52-6.06,1.35-.04Z"/>
<polygon class="st4" points="411.44 512.69 397.14 520.74 404.87 508.79 411.44 512.69"/>
<path class="st41" d="M473.01,402.36c1.03.14,2.08.23,3.16.27.27.29.29.71.12,1.2l-2.72,7.58c-.16.45-.41.61-.82.5l-3.43-2.2c-.21-.16-.51-.4-.45-.59.22-.73,2.38-6.35,2.65-6.57.22-.18.5-.21.77-.19h.73Z"/>
<path class="st77" d="M270.78,400.47l6.64,8.93c.33.44.6.81.6,1.36l-5.75-2.63c.25-.02.46-.04.57-.15l-5.69-7.51h3.62Z"/>
<path class="st16" d="M470.43,401.95l1.85.41-2.97,7.35-2.03-1.1,1.69-5.63c.09-.31.21-.63.36-.76.27-.25.66-.26,1.1-.27Z"/>
<path class="st57" d="M278.17,416.43l1.18.6,2.03,1.6,45.59,59.76c.41.48.12.87-.9,1.17l-31.06,8.63-14.13-5.41-22.16-8.18-5.77-8.75-15.37-23.03c-.19-.28-.42-.66-.45-.91s.44-.54.5-.79l.82-.93,38.28-23.93,1.44.17Z"/>
<path class="st73" d="M327.99,473.12l-.28,2.96-.74.12c-13.95-18.59-27.97-36.96-42.08-55.11-.42-.87-.69-1.47.5-1.26l13.34,6.41,12.46,6.03,7.76,3.84,10.4,5.1-1.37,31.91Z"/>
<g>
<path class="st37" d="M170.48,400.49l1.01,1.07c6.58,12.06,13.62,24.43,22.58,34.61l-1.24-8.22c-5.14-8.94-10.38-17.88-15.74-26.8l.36-.65,5.23-.02,1.07.5c8.85,14.2,13.27,21.3,13.27,21.3l-.34.67c.52-.17,1.02.04,1.49.64.85.41,1.07.72,1.38,1.47.12-.22.32-.54.5-.49.75.17,7.55,2.79,7.87,3.07l.63.92,3.38.71,20.54,8.54,5.01-8.77c.6-.44,6.3-.29,6.44.05.18.43-3.21,7.73-4.37,7.91.63,1.13-.88,3.34-1.89,4.14.14.46.3.91.49,1.35l21.1,31.62c12.01,4.38,23.95,8.85,35.82,13.41l30.47-8.47.55.51c.88-.67,1.24-1.83,1.08-3.48l.56-.13.27-2.83-.63-.25,1.33-31.09c-13.89-7.08-27.94-13.98-42.15-20.69-.66-.32-1.04-.74-1.14-1.26l-.53.46c-2.06-.74-3.9-1.83-5.51-3.26-.49.82-1.38.3-1.18-.6-1.39.1-3.13-.14-3.61-1.64-.16.28-.4.43-.72.36l-15.93-7.9-2.5,4.05c-.34.16-.61.11-.81-.18-.24-.35-.19-8.05-.02-8.43.09-.2.25-.32.47-.36l1.85-.56c.86-.41,2.78-.33,3.71-.06,1.48.43,9.37,4.3,10.56,5.17.66.31,1.03.73,1.11,1.25.76-.34,1.63-.18,2.6.48,1.33.42,2.38,1.14,3.16,2.15.32-.92,2.18.06,1.91.96,1.07-.39,3.23.33,3.19,1.53l1.05-.2,45.31,21.96.07.74c.95-.25,1.07-.23,2,.35.67-.09,1.2,0,1.59.31l-.07-.67,48.03-22.58c.38-.1.76-.07,1.04.22.06-.67.54-1.15,1.44-1.45.5-.57,1-.77,1.5-.61l-.47-.49,5.22-9.93.95-.43,5.18.02c.65.17.69.56.38,1.11l-5.57,11.24c2.04,1.73,4.2,3.39,6.48,4.98v.79s-4.52,2.74-4.52,2.74l-.91.13-4.24-3.04c-.67,1.97-1.2,4.05-1.59,6.24l-.89,1.04-2.65,1.59c-.65.28-.92.02-.9-.67l1.65-7.58-44.09,20.65c11.54,5.89,23.19,11.68,34.93,17.38l7.06-28.07h.88c.09,3.22.05,6.4-.12,9.54-.93,6.3-2.33,12.52-4.2,18.67l3.94-.75c.4.7.58,1.84.25,2.6l-.26.74c-1.3.31-2.54.72-3.71,1.22l3.48,4.8.55,1.84-.05.37c.2.71.28,3.68-.31,3.84-.14.04-.31,0-.47-.06l-6.93-9.53-38.37,16.77c14.01,3.6,28,7.15,41.98,10.5,1.32.3,2.53.08,3.78-.35l.36.32c-.03,1.93.03,3.86.2,5.77.28.85,1.38.8,2.25,1.79-7.14,2.64-14.34,4.25-21.71,5.86l-12.93,2.12c-16.7,2.09-33.26,1.67-49.93-1.09s-32.03-7.27-46.91-14.28c-17.39-8.19-33.22-18.8-47.14-31.94l-7.22-7.25-7.34-8.09c-10.9-12.66-19.74-26.74-26.97-41.9l6.28-.04Z"/>
<g>
<path class="st48" d="M177.45,400.49l10.49,17.65,5.5,9.61,1.64,10.61-5.47-6.53c-7.48-9.83-13.73-20.37-19.12-31.34h6.97Z"/>
<path class="st67" d="M381.88,448.5l.09,8.49c0,.61.62,1.83-.13,2.08l-.05-2.03-5.04,1.11,5.1-19.59.03,9.94Z"/>
<path class="st74" d="M267.16,400.47l1.05.37c.44.34,4.97,6.32,5.06,6.73.18.79-.3.78-1,.56l-11.39-5.58c-1.36-.67-2.56-1.22-3.99-.79.04-.28.28-.37.65-.38,2.86-.04,5.53-.57,8-2.08.27,1.09.86,1.16,1.62,1.16Z"/>
<polygon class="st19" points="392.72 421.34 385.09 425.72 387 417.24 392.72 421.34"/>
<polygon class="st66" points="381.84 459.07 381.89 467.68 376.74 460.57 381.84 459.07"/>
<path class="st73" d="M253.27,475.01l-11.09,2.88c-7.62-4.82-15.08-9.59-22.15-15.24-6.67-5.32-12.77-10.8-18.58-17.01l-3.02-15.62,11.96,4.77,13.35,5.54,8.48,3.36,4.35,6.29,16.7,25.03Z"/>
<path class="st56" d="M353,498.79c-14.03,1.85-27.82,2.15-41.95.68l-11.11-7.49,30.34-8.43,6.48,1.76,36.41,9.05c-6.76,2.1-13.33,3.16-20.17,4.44Z"/>
<path class="st8" d="M332.91,477.35l1.24-34.28c.02-.68.16-1.16.61-1.48.29-.21.86-.53,1.45-.23l36.41,18.37-39.7,17.63Z"/>
<path class="st13" d="M301.03,498.4l-11.9-2.34c-7.67-1.51-14.77-3.98-22.07-6.75l-18.11-7.85,8.65-2.31,14.12,5.48,21.58,8.25c2.99,1.14,5.12,3.67,7.73,5.52Z"/>
</g>
</g>
<path class="st60" d="M285.4,419.84c-.17.09-.29.3-.29.49l6.12,7.7,36.39,47.97.37-2.87-.37,6-1.54.43.62-.48-9.53-12.4-18.24-24.03-19.58-25.62,6.04,2.8Z"/>
<path class="st70" d="M278.17,416.43c-.74,0-1.16.45-1.87.9l-36.6,22.88c-.76.47-1.32.82-2.06.92.3-1.43,1.23-2.82,1.89-4.14.04.24.19.25.3.24.24,0,.45-.22.67-.37l11.82-7.71c.6-.28,1.62.15,2.03-.57l.78-1.15,18.25-11.49c.55-.35.99-.6,1.19-1.16,1.18.58,2.42,1.04,3.61,1.64Z"/>
<g>
<path class="st40" d="M359.51,523.71l-31.41,18.02-12.26-7.09-30.07-17.3-18.19-10.61-5.94-3.1c-2.23-1.17-4.11-2.68-6.71-3.37l3.02,4.56,10.47,16.39,8.77,13.64,12.1,6.88,38.95,22.2,28.66-16.34,13.27-7.61c2.89-1.98,6.71-3.05,9.17-5.5l9.41-14.34,9.73-15.04c1.12.45,1.95,1.03,3.09,1.86l-14.87,23.68,20.21-11.23,8.19-4.61,4.97,3.06-15.28,8.69-13.73,7.73-8.1,4.58c.45.16.92.18,1.13.55l-23.83,13.46-32.14,18.45-36.4-20.84-19.51-11c-.3-.13-.21-.66.4-.9l-16.39-9.35-14.53-8.25-52.15-29.43-21.95-12.39-37.66-21.23-12.42-6.95-.03-49.53c0-.35-.07-.66.03-.81.08-.12.34-.19.64-.18l4.17.06.04,47.19,22.2,12.56,49.63,27.82,12.71,7.23c.33.19.75-.06.7-.35l-10.43-16.88-8.95-14.4-1.46-1.8-18.8-10.67-25.86-14.65v-36s5.53-.06,5.53-.06v33.06s20.78,11.43,20.78,11.43l15.94,8.78,11.07,5.92c.39.19.69-.05.47-.42l-9.71-11.52-10.57-13.57-15.01-8.37-.41-15.05-3.99-9.53c-.05-.13-.41-.35-.22-.63.08-.13.31-.17.61-.17l3.52.08,7.38,14.36c4.11,7.01,7.97,13.91,13.11,20.19l7.79,9.52,10.57,11.56c9.37,9.63,19.66,17.84,31.01,25.01l12.97,7.58c11.18,6.53,34.21,14.97,46.99,17.08l17.58,2.91c19.82,2.41,39.38,1.45,58.88-2.92,7.61-1.7,14.88-3.53,22.22-6.45l5.63,3.38c.2.12.56.1.6.42.05.49-.28.42-.65.63l-14.86,8.54-19.86,11.39Z"/>
<g>
<path class="st71" d="M267.45,526.66l2.46,3.79c-1.21.2-1.62-.51-2.43-.97l-29.45-16.61-23.17-12.95-17.04-27.54-3.98-6.21c1.16-.13,2.05.65,3.13,1.24l14.86,8.23,15.8,9.18,20.5,11.43,9.01,14.15,10.3,16.25Z"/>
<polygon class="st53" points="259.25 520.73 217.28 497.21 208.45 482.56 204.61 475.71 245.91 499.1 259.25 520.73"/>
</g>
<g>
<path class="st26" d="M375.38,508.4l-13.76,7.99-32.14,18.39c-1.07.61-1.76.47-2.74-.09l-8.96-5.13-14.31-8.14-21.71-12.83,30.06,3.74c2.43.3,4.18.91,6.22,2.24,3.27,2.13,6.66,4.21,10.21,5.73l13.3-7.31,9.23-1.11,24.6-3.47Z"/>
<path class="st37" d="M329.33,523.91l13.36-7.63,14.83-1.66-29.3,16.7-29.4-16.7,15.06,1.72,13.52,7.75c.57.33,1.3.23,1.93-.18Z"/>
</g>
</g>
<g>
<path class="st65" d="M254.34,428.58c.52.79-.07,1.18-1.77,1.15l-12.05,7.89c-.83.28-1.36.38-.99-.64l3.93-8-5.21-.05-5.49,9.68-24.22-10.08,14.83.21,19.14-.1,6.72.09,5.11-.17Z"/>
<path class="st21" d="M230.59,431.19c-.21.73-2.52.59-3.5-2.2l2.87.14c.47.02.91,1.1.63,2.06Z"/>
</g>
<g>
<path class="st73" d="M441.03,525.42l-16.95-9.59-14.62-8.49-23-13.46c-.55-.32-.65-1.05-.65-1.74l.09-62.53,10.1-6.02,18.85-10.97,20.78-12.15,10.86.04,8.92,5.53,31.59,18.3,8.97,5.29-.09,63.96-27.97,16.3-26.9,15.54Z"/>
<path class="st64" d="M442.92,518c-1.14.66-2.36.94-3.42.34l-17.31-9.89-12.43-7.14-16.92-10c-1.29-.76-1.98-2.55-1.98-4.14l-.05-51.14c0-2.31.95-3.84,2.83-4.95l16.73-9.85,28.36-16.41c1.55-.89,2.91-.94,4.47-.03l21.22,12.39,22.97,13.52c1.25.73,3.05,1.9,3.35,3.44l.02,53.69c0,1.87-1.02,3.16-2.49,4.01l-21.65,12.55-6.4,3.6-17.3,10.01Z"/>
<g>
<polygon class="st73" points="484.8 486.62 440.83 512.3 396.89 486.58 396.99 436.13 418.04 423.79 440.86 410.51 472.7 429.05 484.7 436.31 484.8 486.62"/>
<rect class="st51" x="443.63" y="456.47" width="39.08" height="9.18" transform="translate(2.01 924.13) rotate(-89.99)"/>
<g>
<polygon class="st51" points="441.2 472.33 422.6 472.26 418.99 480.57 409.67 480.56 423.51 448.88 426.63 441.59 436.71 441.6 441.03 450.86 454.45 480.55 444.76 480.55 441.2 472.33"/>
<polygon class="st57" points="438.23 464.97 425.53 465.01 431.63 450.2 438.23 464.97"/>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#fff" d="M0 0h900v600H0z"/><path fill="#063" d="M0 0h450v600H0z"/><path fill="#d21034" d="M579.904 225a150 150 0 1 0 0 150 120 120 0 1 1 0-150m5.772 75L450 255.916l83.853 115.413V228.671L450 344.084z"/></svg>

After

Width:  |  Height:  |  Size: 285 B

View File

@ -0,0 +1 @@
<svg width="800" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.0" height="500"><path fill="#74acdf" d="M0 0h800v500H0z"/><path fill="#fff" d="M0 166.67h800v166.67H0z"/><g id="c"><path id="a" stroke-width="1.112" stroke="#85340a" fill="#f6b40e" d="m396.84 251.31 28.454 61.992s.49 1.185 1.28.859c.79-.327.299-1.512.299-1.512l-23.715-63.956m-.68 24.12c-.347 9.428 5.452 14.613 4.694 23.032-.757 8.42 3.867 13.18 4.94 16.454 1.073 3.274-1.16 5.232-.198 5.698.963.466 3.07-2.12 2.383-6.775-.687-4.655-4.22-6.037-3.39-16.32.83-10.283-4.206-12.678-2.98-22.058"/><use xlink:href="#a" transform="rotate(22.5 400 250)"/><use xlink:href="#a" transform="rotate(45 400 250)"/><use xlink:href="#a" transform="rotate(67.5 400 250)"/><path id="b" fill="#85340a" d="M404.31 274.41c.453 9.054 5.587 13.063 4.579 21.314 2.213-6.525-3.124-11.583-2.82-21.22m-7.649-23.757 19.487 42.577-16.329-43.887"/><use xlink:href="#b" transform="rotate(22.5 400 250)"/><use xlink:href="#b" transform="rotate(45 400 250)"/><use xlink:href="#b" transform="rotate(67.5 400 250)"/></g><use xlink:href="#c" transform="rotate(90 400 250)"/><use xlink:href="#c" transform="rotate(180 400 250)"/><use xlink:href="#c" transform="rotate(270 400 250)"/><circle r="27.778" stroke="#85340a" cy="250" cx="400" stroke-width="1.5" fill="#f6b40e"/><path id="h" fill="#843511" d="M409.47 244.06c-1.897 0-3.713.822-4.781 2.531 2.136 1.923 6.856 2.132 10.062-.219a7.333 7.333 0 0 0-5.281-2.312zm-.031.438c1.846-.034 3.571.814 3.812 1.656-2.136 2.35-5.55 2.146-7.687.437.935-1.495 2.439-2.067 3.875-2.094z"/><use xlink:href="#d" transform="matrix(-1 0 0 1 800.25 0)"/><use xlink:href="#e" transform="matrix(-1 0 0 1 800.25 0)"/><use xlink:href="#f" transform="translate(18.862)"/><use xlink:href="#g" transform="matrix(-1 0 0 1 800.25 0)"/><path d="M395.75 253.84c-.913.167-1.563.977-1.563 1.906 0 1.062.878 1.906 1.938 1.906a1.89 1.89 0 0 0 1.563-.812c.739.556 1.764.615 2.312.625.084.002.193 0 .25 0 .548-.01 1.573-.069 2.313-.625.36.516.935.812 1.562.812 1.06 0 1.938-.844 1.938-1.906 0-.929-.65-1.74-1.563-1.906.513.18.844.676.844 1.219a1.28 1.28 0 0 1-1.281 1.281c-.68 0-1.242-.54-1.282-1.219-.208.417-1.034 1.655-2.656 1.719-1.622-.064-2.447-1.302-2.656-1.719-.04.679-.6 1.219-1.281 1.219a1.28 1.28 0 0 1-1.281-1.281c0-.542.33-1.038.843-1.219zM397.84 259.53c-2.138 0-2.983 1.937-4.906 3.219 1.068-.427 1.91-1.27 3.406-2.125 1.496-.855 2.772.187 3.625.187h.031c.853 0 2.13-1.041 3.625-.187 1.497.856 2.369 1.698 3.438 2.125-1.924-1.282-2.8-3.219-4.938-3.219-.426 0-1.271.23-2.125.656h-.031c-.853-.426-1.698-.656-2.125-.656z" fill="#85340a"/><path d="M397.12 262.06c-.844.037-1.96.207-3.563.688 3.848-.855 4.697.437 6.407.437h.03c1.71 0 2.56-1.292 6.407-.438-4.274-1.282-5.124-.437-6.406-.437h-.031c-.802 0-1.437-.312-2.844-.25z" fill="#85340a"/><path d="M393.75 262.72c-.248.003-.519.005-.813.031 4.488.428 2.331 3 7.032 3h.03c4.702 0 2.575-2.572 7.063-3-4.7-.426-3.214 2.344-7.062 2.344h-.031c-3.608 0-2.496-2.421-6.22-2.375zM403.85 269.66a3.848 3.848 0 0 0-3.846-3.846 3.848 3.848 0 0 0-3.847 3.846 3.955 3.955 0 0 1 3.847-3.04 3.952 3.952 0 0 1 3.846 3.04z" fill="#85340a"/><path id="e" fill="#85340a" d="M382.73 244.02c4.915-4.273 11.11-4.915 14.53-1.709.837 1.121 1.373 2.32 1.593 3.57.43 2.433-.33 5.062-2.236 7.756.215-.001.643.212.856.427 1.697-3.244 2.297-6.577 1.74-9.746a13.815 13.815 0 0 0-.67-2.436c-4.7-3.845-11.11-4.272-15.81 2.138z"/><path id="d" fill="#85340a" d="M390.42 242.74c2.777 0 3.419.642 4.7 1.71 1.284 1.068 1.924.854 2.137 1.068.213.215 0 .854-.426.64s-1.284-.64-2.564-1.708c-1.283-1.07-2.563-1.069-3.846-1.069-3.846 0-5.983 3.205-6.41 2.991-.426-.214 2.137-3.632 6.41-3.632z"/><use xlink:href="#h" transform="translate(-19.181)"/><circle id="f" cy="246.15" cx="390.54" r="1.923" fill="#85340a"/><path id="g" fill="#85340a" d="M385.29 247.44c3.633 2.778 7.265 2.564 9.402 1.282 2.136-1.282 2.136-1.709 1.71-1.709-.427 0-.853.427-2.564 1.281-1.71.856-4.273.856-8.546-.854z"/></svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1280" height="640" viewBox="0 0 10080 5040"><defs><clipPath id="a"><path d="M0 0h6v3H0z"/></clipPath><clipPath id="b"><path d="M0 0v1.5h6V3zm6 0H3v3H0z"/></clipPath><path id="c" d="m0-360 69.421 215.845 212.038-80.301L155.99-35.603l194.985 115.71-225.881 19.651 31.105 224.59L0 160l-156.198 164.349 31.105-224.59-225.881-19.651 194.986-115.711-125.471-188.853 212.038 80.301z"/><path id="d" d="M0-210 54.86-75.508l144.862 10.614L88.765 28.842l34.67 141.052L0 93.334l-123.435 76.56 34.67-141.052-110.957-93.736L-54.86-75.508z"/></defs><path fill="#012169" d="M0 0h10080v5040H0z"/><path d="m0 0 6 3m0-3L0 3" stroke="#fff" stroke-width=".6" clip-path="url(#a)" transform="scale(840)"/><path d="m0 0 6 3m0-3L0 3" stroke="#e4002b" stroke-width=".4" clip-path="url(#b)" transform="scale(840)"/><path d="M2520 0v2520M0 1260h5040" stroke="#fff" stroke-width="840"/><path d="M2520 0v2520M0 1260h5040" stroke="#e4002b" stroke-width="504"/><g fill="#fff"><use xlink:href="#c" transform="matrix(2.1 0 0 2.1 2520 3780)"/><use xlink:href="#c" x="7560" y="4200"/><use xlink:href="#c" x="6300" y="2205"/><use xlink:href="#c" x="7560" y="840"/><use xlink:href="#c" x="8680" y="1869"/><use xlink:href="#d" x="8064" y="2730"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#c8102e" d="M0 0h900v600H0z"/><path fill="#fff" d="M0 200h900v200H0z"/></svg>

After

Width:  |  Height:  |  Size: 154 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="780"><path fill="#ef3340" d="M0 0h900v780H0z"/><path fill="#fdda25" d="M0 0h600v780H0z"/><path d="M0 0h300v780H0z"/></svg>

After

Width:  |  Height:  |  Size: 182 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="800" height="400" viewBox="0 0 16 8"><path fill="#002395" d="M0 0h16v8H0z"/><path d="M4.24 0h8v8z" fill="#fecb00"/><g id="b"><path d="M2.353.525 2.8-.85 3.247.525l-1.17-.85h1.446z" fill="#fff" id="a"/><use xlink:href="#a" x="1" y="1"/><use xlink:href="#a" x="2" y="2"/></g><use xlink:href="#b" x="3" y="3"/><use xlink:href="#b" x="6" y="6"/></svg>

After

Width:  |  Height:  |  Size: 437 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1000" height="700" viewBox="-2100 -1470 4200 2940"><defs><path id="j" fill-rule="evenodd" d="M-31.5 0h33a30 30 0 0 0 30-30v-10a30 30 0 0 0-30-30h-33zm13-13h19a19 19 0 0 0 19-19v-6a19 19 0 0 0-19-19h-19z"/><path id="k" d="M0 0h63v-13H12v-18h40v-12H12v-14h48v-13H0z" transform="translate(-31.5)"/><path id="m" d="M-26.25 0h52.5v-12h-40.5v-16h33v-12h-33v-11H25v-12h-51.25z"/><path id="l" d="M-31.5 0h12v-48l14 48h11l14-48V0h12v-70H14L0-22l-14-48h-17.5z"/><path id="b" fill-rule="evenodd" d="M0 0a31.5 35 0 0 0 0-70A31.5 35 0 0 0 0 0m0-13a18.5 22 0 0 0 0-44 18.5 22 0 0 0 0 44"/><path id="c" fill-rule="evenodd" d="M-31.5 0h13v-26h28a22 22 0 0 0 0-44h-40zm13-39h27a9 9 0 0 0 0-18h-27z"/><path id="o" d="M-15.75-22C-15.75-15-9-11.5 1-11.5s14.74-3.25 14.75-7.75c0-14.25-46.75-5.25-46.5-30.25C-30.5-71-6-70 3-70s26 4 25.75 21.25H13.5c0-7.5-7-10.25-15-10.25-7.75 0-13.25 1.25-13.25 8.5-.25 11.75 46.25 4 46.25 28.75C31.5-3.5 13.5 0 0 0c-11.5 0-31.55-4.5-31.5-22z"/><use xlink:href="#f" id="p" transform="scale(31.5)"/><use xlink:href="#f" id="q" transform="scale(26.25)"/><use xlink:href="#f" id="u" transform="scale(21)"/><use xlink:href="#f" id="r" transform="scale(15)"/><use xlink:href="#f" id="v" transform="scale(10.5)"/><g id="n"><clipPath id="a"><path d="M-31.5 0v-70h63V0zM0-47v12h31.5v-12z"/></clipPath><use xlink:href="#b" clip-path="url(#a)"/><path d="M5-35h26.5v10H5z"/><path d="M21.5-35h10V0h-10z"/></g><g id="i"><use xlink:href="#c"/><path d="M28 0c0-10 0-32-15-32H-6c22 0 22 22 22 32"/></g><g id="f" fill="#fff"><g id="e"><path id="d" d="M0-1v1h.5" transform="rotate(18 0 -1)"/><use xlink:href="#d" transform="scale(-1 1)"/></g><use xlink:href="#e" transform="rotate(72)"/><use xlink:href="#e" transform="rotate(-72)"/><use xlink:href="#e" transform="rotate(144)"/><use xlink:href="#e" transform="rotate(216)"/></g></defs><clipPath id="h"><circle r="735"/></clipPath><path fill="#009440" d="M-2100-1470h4200v2940h-4200z"/><path fill="#ffcb00" d="M-1743 0 0 1113 1743 0 0-1113Z"/><circle r="735" fill="#302681"/><path fill="#fff" d="M-2205 1470a1785 1785 0 0 1 3570 0h-105a1680 1680 0 1 0-3360 0z" clip-path="url(#h)"/><g fill="#009440" transform="translate(-420 1470)"><use xlink:href="#b" y="-1697.5" transform="rotate(-7)"/><use xlink:href="#i" y="-1697.5" transform="rotate(-4)"/><use xlink:href="#j" y="-1697.5" transform="rotate(-1)"/><use xlink:href="#k" y="-1697.5" transform="rotate(2)"/><use xlink:href="#l" y="-1697.5" transform="rotate(5)"/><use xlink:href="#m" y="-1697.5" transform="rotate(9.75)"/><use xlink:href="#c" y="-1697.5" transform="rotate(14.5)"/><use xlink:href="#i" y="-1697.5" transform="rotate(17.5)"/><use xlink:href="#b" y="-1697.5" transform="rotate(20.5)"/><use xlink:href="#n" y="-1697.5" transform="rotate(23.5)"/><use xlink:href="#i" y="-1697.5" transform="rotate(26.5)"/><use xlink:href="#k" y="-1697.5" transform="rotate(29.5)"/><use xlink:href="#o" y="-1697.5" transform="rotate(32.5)"/><use xlink:href="#o" y="-1697.5" transform="rotate(35.5)"/><use xlink:href="#b" y="-1697.5" transform="rotate(38.5)"/></g><use xlink:href="#p" x="-600" y="-132"/><use xlink:href="#p" x="-535" y="177"/><use xlink:href="#q" x="-625" y="243"/><use xlink:href="#r" x="-463" y="132"/><use xlink:href="#q" x="-382" y="250"/><use xlink:href="#u" x="-404" y="323"/><use xlink:href="#p" x="228" y="-228"/><use xlink:href="#p" x="515" y="258"/><use xlink:href="#u" x="617" y="265"/><use xlink:href="#q" x="545" y="323"/><use xlink:href="#q" x="368" y="477"/><use xlink:href="#u" x="367" y="551"/><use xlink:href="#u" x="441" y="419"/><use xlink:href="#q" x="500" y="382"/><use xlink:href="#u" x="365" y="405"/><use xlink:href="#q" x="-280" y="30"/><use xlink:href="#u" x="200" y="-37"/><use xlink:href="#p" y="330"/><use xlink:href="#q" x="85" y="184"/><use xlink:href="#q" y="118"/><use xlink:href="#u" x="-74" y="184"/><use xlink:href="#r" x="-37" y="235"/><use xlink:href="#q" x="220" y="495"/><use xlink:href="#u" x="283" y="430"/><use xlink:href="#u" x="162" y="412"/><use xlink:href="#p" x="-295" y="390"/><use xlink:href="#v" y="575"/></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="600" viewBox="0 0 9600 4800"><path fill="red" d="M0 0h2400l99 99h4602l99-99h2400v4800H7200l-99-99H2499l-99 99H0z"/><path fill="#fff" d="M2400 0h4800v4800H2400zm2490 4430-45-863a95 95 0 0 1 111-98l859 151-116-320a65 65 0 0 1 20-73l941-762-212-99a65 65 0 0 1-34-79l186-572-542 115a65 65 0 0 1-73-38l-105-247-423 454a65 65 0 0 1-111-57l204-1052-327 189a65 65 0 0 1-91-27l-332-652-332 652a65 65 0 0 1-91 27l-327-189 204 1052a65 65 0 0 1-111 57l-423-454-105 247a65 65 0 0 1-73 38l-542-115 186 572a65 65 0 0 1-34 79l-212 99 941 762a65 65 0 0 1 20 73l-116 320 859-151a95 95 0 0 1 111 98l-45 863z"/></svg>

After

Width:  |  Height:  |  Size: 658 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#009e60" d="M0 0h900v600H0z"/><path fill="#fff" d="M0 0h600v600H0z"/><path fill="#f77f00" d="M0 0h300v600H0z"/></svg>

After

Width:  |  Height:  |  Size: 194 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><path style="fill:#007fff" d="M0 0h800v600H0z"/><path d="M36 120h84l26-84 26 84h84l-68 52 26 84-68-52-68 52 26-84-68-52zM750 0 0 450v150h50l750-450V0h-50" style="fill:#f7d618"/><path d="M800 0 0 480v120l800-480V0" style="fill:#ce1021"/></svg>

After

Width:  |  Height:  |  Size: 307 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#ffcd00" d="M0 0h900v600H0z"/><path fill="#003087" d="M0 300h900v300H0z"/><path fill="#c8102e" d="M0 450h900v150H0z"/></svg>

After

Width:  |  Height:  |  Size: 201 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="600"><path fill="#003893" d="M0 0h1020v600H0z"/><path fill="#fff" d="M0 300h1020v150H0z"/><path fill="#cf2027" d="M0 350h1020v50H0z"/><path fill="#f7d116" d="m382.5 198.715 5.933 18.119 19.066.043-15.4 11.242 5.852 18.148-15.452-11.173-15.45 11.173 5.851-18.148-15.4-11.242 19.066-.043zm-88.168 28.646 5.933 18.121 19.066.043-15.4 11.242 5.852 18.147-15.452-11.172-15.45 11.172 5.851-18.147-15.4-11.242 19.066-.043zm176.336 0 5.933 18.121 19.066.043-15.4 11.242 5.852 18.147-15.452-11.172-15.45 11.172 5.851-18.147-15.4-11.242 19.066-.043zm-230.826 73.863 5.933 18.12 19.067.043-15.4 11.242 5.85 18.148-15.45-11.174-15.452 11.174 5.852-18.148-15.4-11.242 19.066-.043zm285.316 0 5.934 18.12 19.066.043-15.4 11.242 5.851 18.148-15.451-11.174-15.451 11.174 5.851-18.148-15.4-11.242 19.066-.043zm-285.316 100 5.933 18.119 19.067.044-15.4 11.242 5.85 18.148-15.45-11.173-15.452 11.173 5.852-18.148-15.4-11.242 19.066-.043zm285.316 0 5.934 18.119 19.066.044-15.4 11.242 5.851 18.148-15.451-11.173-15.451 11.173 5.851-18.148-15.4-11.242 19.066-.043zm-230.826 68.842 5.933 18.121 19.066.044-15.4 11.242 5.852 18.146-15.452-11.172-15.45 11.172 5.851-18.146-15.4-11.242 19.066-.044zm176.336 0 5.933 18.121 19.066.044-15.4 11.242 5.852 18.146-15.452-11.172-15.45 11.172 5.851-18.146-15.4-11.242 19.066-.044zM382.5 498.715l5.933 18.119 19.066.043-15.4 11.242 5.852 18.149-15.452-11.174-15.45 11.174 5.851-18.149-15.4-11.242 19.066-.043z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 80 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600" viewBox="0 0 54 36"><path fill="#002b7f" d="M0 0h54v36H0"/><path fill="#f9e814" d="M0 22.5h54V27H0"/><path fill="#fff" d="m4.2 8.4.1-.1L6 3l1.8 5.4-4.6-3.3h5.7m.7 10.1L12 8l2.4 7.2-6.2-4.4h7.6"/></svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@ -0,0 +1 @@
<svg version="1.0" xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#d7141a" d="M0 0h900v600H0z"/><path fill="#fff" d="M0 0h900v300H0z"/><path d="M450 300 0 0v600z" fill="#11457e"/></svg>

After

Width:  |  Height:  |  Size: 210 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 212 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="480"><path fill="#FFF" d="M0 0h800v480H0"/><path stroke="#C8102E" stroke-width="96" d="M0 240h800M400 0v480"/></svg>

After

Width:  |  Height:  |  Size: 176 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 149 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#CE1126" d="M0 0h900v600H0"/><path fill="#fff" d="M0 0h600v600H0"/><path fill="#002654" d="M0 0h300v600H0"/></svg>

After

Width:  |  Height:  |  Size: 191 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="600" viewBox="0 0 5 3"><path d="M0 0h5v3H0z"/><path fill="#D00" d="M0 1h5v2H0z"/><path fill="#FFCE00" d="M0 2h5v1H0z"/></svg>

After

Width:  |  Height:  |  Size: 186 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="600"><path fill="#006b3f" d="M0 0h900v600H0"/><path fill="#fcd116" d="M0 0h900v400H0"/><path fill="#ce1126" d="M0 0h900v200H0"/><path d="m450 200 64.98 200-170.13-123.61h210.3L385.02 400"/></svg>

After

Width:  |  Height:  |  Size: 255 B

Some files were not shown because too many files have changed in this diff Show More