Compare commits

..

17 Commits

Author SHA1 Message Date
Haewon Kam 996d5faaf4 Refine ranking placement, visibility, and consistency
- Move cumulative ranking above the gold ₩1M card (ranking → challenge flow)
- Drop redundant "전체 보기" link (inline folding shows full Top 10);
  /leaderboard route kept for direct access
- Make fold toggle purple + match its size to the tab buttons; enlarge
  title/tab/row text for readability
- Match email input & submit button radius to the ranking box (rounded-xl)
- Leaderboard full page: top button = "투표로 돌아가기" (history back, home fallback)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:47:57 +09:00
Haewon Kam 965bd374c3 Add cumulative ranking (TOP 10, voters/AI tabs, folding)
- New lib/leaderboard.ts: fetchLeaderboard(tab) contract + maskEmailId
  (PII) + deterministic demo; backend swaps body for getLeaderboard
- New components/Leaderboard.tsx: 2 tabs (참가자/AI 모델), folded by
  default with 랭킹 보기/접기, top-3 purple highlight, id + points
- Place folding ranking right after the gold ₩1M card on match page
- /leaderboard full page renders the same component expanded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:32:26 +09:00
Haewon Kam b69b1dbe97 Add "my past predictions" (email-based, no login) + cleanup
- New lib/myPredictions.ts: typed contract (fetch/save/remember/recall)
  with localStorage demo impl; backend dev swaps the two bodies for
  Firebase callables (getMyPredictions / submitPrediction)
- Arena: recall email on revisit, show my picks sorted newest-first,
  prefill + highlight the current match, upsert per match
- Fold list to 1 row by default with 더보기/접기 toggle (handles 10s of votes)
- Result badges: hit = purple (--share), miss = gray; date until scored
- Remove unused orange --claude token (orange-ban rule; logo image stays)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 14:32:17 +09:00
Haewon Kam cbe0e8e01d Right-size submit CTA + match input to card shape
- Submit button height/text now match the bottom "다른 경기 투표하기"
  CTA (py-3, text-15) — was oversized (py-5, text-19)
- Keep rounded-2xl so roundness stays consistent with the green card
- Email input matched to same rounded-2xl shape and height

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:56:27 +09:00
Haewon Kam cbab4a102a Make email input border purple (1px) so it reads before input
Dark --line-d border was nearly invisible on the dark card; switch to
a thin purple (--share) border + soft purple ring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:46:06 +09:00
Haewon Kam c8431e65f3 Unify vote-submit into single always-visible CTA card
- Collapse pick→form two-step into one card: email gate header +
  input + benefit checkbox + single purple submit button
- Purple (--share) for user submit action vs mint for AI/system
- Keep disabled CTA visible (opacity .72 + glow) to drive clicks
- Unify copy with header: "투표 제출하고 AI와 겨루기"

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 09:42:57 +09:00
Haewon Kam 7d4d50af40 Enlarge header buttons 1.5x + add bottom "다른 경기 투표하기" CTA (ADO2 purple)
- Hero "전체 일정"·ShareButton "투표 공유하기" 1.5배 (12→18px, 패딩 확대)
- 상세 페이지 하단(Crowd 아래·푸터 위)에 "다른 경기 투표하기 →" CTA 추가
  - ADO2 광고 버튼 동일 사양: 퍼플 #A65EFF · 라운드 풀 · 글로우 · 화살표
  - 텍스트 15px, 전체 일정(대시보드) 이동
- i18n moreMatches (KO/EN)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:13:13 +09:00
Haewon Kam 6c3a47b6ba Demo: mark opener as finished with 2-1 Mexico win on schedule board
- schedule.ts: opener(MEX vs RSA) status finished + result 2-1 (TEAM_A_WIN)
- i18n: phase.finished label 종료→투표종료 / Ended→Voting closed
- ScheduleBoard: show score around VS + winner label next to round when result exists

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:20:14 +09:00
Haewon Kam 0c91c902a8 Docs: ban all emoji in design/video/landing (DESIGN.md §7)
Decorative/pictogram emoji (👆🏆🟠 etc.) forbidden — use color,
typography, or real SVG/PNG icons. Typographic glyphs (→ ★ ✓ ·) allowed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:04:46 +09:00
Haewon Kam 7a375ee32f Switch 너→당신 (formal address) + handle @triplepick_ai→@triplepickai
- 너 reads as condescending (하대) in Korean, esp. for older viewers we
  optimized readability for; unify on 당신 to match product UI voice.
- Remove underscore from social handle across footer/before page.
- User accent on /before: amber 🟠 → brand point purple 🟣.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:59:10 +09:00
Haewon Kam 596ed9c094 Rebrand 월드컵/World Cup → 글로벌 축구 축전/Global Football Festival
Trademark avoidance: replace all branding/marketing uses across title,
hero pill (ko/en), before page, schedule comment. Disclaimers keep the
protective FIFA reference but drop the event mark (→ "공식 축구 대회" /
"any official football tournament").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:28:24 +09:00
Haewon Kam 6a094debbc Lock 2026-06-10 canonical Korea–Czechia prediction snapshot
GPT 무 1-1 (45%) / Claude 무 1-1 (55%) / Gemini 한국 2-1 (60%).
Claude flipped draw→win earlier; re-ran APIs and froze today's
immutable snapshot. Also corrects Gemini label 3.5-flash→2.5-flash
(actual model used). Landing now matches video plan + PR source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:27:42 +09:00
Haewon Kam 9de27dafd8 Show prediction basis (datetime + model versions) next to "AI 예측 대결"
- "AI의 예측 대결" 우측에 예측 기준(생성 시각) + 실제 모델 버전 표기
  - 라벨 "예측 기준 / As of" + 2026.06.10 00:00 KST (매일 0시 갱신 기준)
  - gpt-5.5 · claude-opus-4-8 · gemini-3.5-flash (MODEL_VERSIONS)
- 가운데 정렬, 가시성↑(11px), 본문 폰트 통일

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:06:38 +09:00
Haewon Kam f29dbd0454 Unify all flags to rectangular SVG (fix waving emoji inconsistency)
- 원인: MEX·RSA는 이모지 국기(OS별 휘날림/광택), KOR·CZE만 실제 이미지 → 불일치
- 해결: 4개국 전부 flagcdn 직사각형 SVG(public/icons/flags) + object-cover로 통일, 이모지 제거
- 미등록 국가는 이모지 fallback 유지

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:35:14 +09:00
Haewon Kam 9b35a759a9 Inject real 3-model predictions for remaining 5 Group A matches (local only)
- CURATED 맵: MEX-RSA, CZE-RSA, MEX-KOR, CZE-MEX, RSA-KOR 5경기 실제 예측
  - GPT(gpt-5.5)·Gemini(gemini-3.5-flash) 실제 API + Claude(opus-4-8) 직접
  - MEX-KOR은 GPT=멕시코 / Claude·Gemini=무승부로 갈림
- getPredictions가 CURATED 우선 사용, 없으면 결정론 생성
- ⚠️ 로컬+Vercel만 반영 (Gitea push 안 함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:26:44 +09:00
Haewon Kam 2fe60ab0cd Track .env.example (template, no secrets) with AI model config
- .gitignore: .env* 무시 유지 + !.env.example 예외(템플릿 추적)
- .env.example: 3 AI 모델 키 슬롯 + 최신 모델 ID + Gemini 함정 노트

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:22:28 +09:00
Haewon Kam 0a15906afb Document AI prediction generation (3 models) + .env.example AI keys
- docs/AI-PREDICTIONS.md: 최신 모델 ID(gpt-5.5/claude-opus-4-8/gemini-3.5-flash), 호출법, Gemini generationConfig 함정, 체코전 실제 출력
- .env.example: 3 AI 모델 키 슬롯 + 모델 ID + Gemini 주의 노트 (시크릿 없음)
- (목업 데이터 mockData.ts는 35414a8에서 실제 예측 반영 완료)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 09:21:35 +09:00
231 changed files with 10193 additions and 13709 deletions

38
.env.example Normal file
View File

@ -0,0 +1,38 @@
# ===== TriplePick 환경 변수 (.env.local 로 복사 후 채우기) =====
# 새 Firebase 프로젝트 생성 후 웹앱 config 6키 (Firebase 콘솔 > 프로젝트 설정 > 웹앱)
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
# (선택) GA4
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=
NEXT_PUBLIC_GA_ID=
# Cloud Functions 호출 리전 (서울 권장)
NEXT_PUBLIC_FUNCTIONS_REGION=asia-northeast3
# 공유/딥링크 베이스 URL
NEXT_PUBLIC_SITE_URL=https://triplepick-web.vercel.app
# ===== 서버 전용 (Cloud Functions 환경 · 클라이언트 노출 금지) =====
# 결과 알림 이메일 (Resend)
RESEND_API_KEY=
RESEND_FROM="TriplePick <noreply@triplepick.ai>"
# 관리자 HTTP 엔드포인트 보호용 베어러 토큰 (경기/예측/결과 입력)
ADMIN_API_TOKEN=
# 시드 스크립트용 서비스 계정 키 경로 (firebase-admin)
GOOGLE_APPLICATION_CREDENTIALS=./serviceAccount.json
# ===== AI 예측 생성 (3모델 · 상세 docs/AI-PREDICTIONS.md) =====
# 매일 KST 0시 남은 경기 예측 생성. 모델 ID는 각 콘솔 GET /models 로 최신 확인.
OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.5
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-opus-4-8
GEMINI_API_KEY=
GEMINI_MODEL=gemini-3.5-flash
# ⚠️ Gemini는 generationConfig(responseMimeType/thinkingConfig) 주면 빈 응답 → 평문 본문 + 응답 JSON 파싱

5
.firebaserc.example Normal file
View File

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

54
.gitignore vendored
View File

@ -1,32 +1,24 @@
# 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
# build output
frontend/dist/
build/
# testing
/coverage
# next.js
/.next/
/out/
# python
__pycache__/
*.py[cod]
.venv/
venv/
*.egg-info/
# env files (실 키는 커밋하지 않음 — .env.example 만 커밋)
.env
.env.*
!.env.example
# docker volume (로컬)
pgdata/
# 로컬 검증 전용 compose (서버에 가면 안 됨)
docker-compose.local.yml
# production
/build
# misc
.DS_Store
@ -34,17 +26,21 @@ docker-compose.local.yml
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (secrets ignored; template tracked)
.env*
!.env.example
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# secrets (구 firebase 잔재)
# secrets
serviceAccount.json
.firebaserc
# 로컬 전용 댓글 관리 도구
tp-comment-admin/
# 개인 운영 대시보드 (관리자 토큰 포함 — 커밋 금지)
ops/

View File

@ -1,46 +1,5 @@
# TriplePick — 아키텍처 (에이전트용 안내)
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
"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
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 -->

View File

@ -1,90 +1,36 @@
# TriplePick 2026
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).
**"AI(GPT · Claude · Gemini) vs 너"** — 글로벌 축구 승부예측 서비스.
3개 AI 모델이 매 경기를 서로 다르게 예측하고, 유저는 픽을 찍어 AI와 겨룬다.
끝까지 가장 정확하게 맞힌 1인에게 100만원 챌린지.
## Getting Started
## 아키텍처
```
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
```
- **frontend** — React SPA. nginx 가 정적 파일 서빙 + `/api` 를 백엔드로 프록시.
- **backend (api)** — FastAPI. 경기/AI예측/crowd 읽기, 픽 제출, 채점, 리더보드, 관리자.
- **backend (worker)** — APScheduler 별도 프로세스. 아래 "시간 기반 작업" 자동 처리.
- **db** — PostgreSQL.
## 빠른 시작 (Docker)
First, run the development server:
```bash
cp backend/.env.example backend/.env # 키(선택) 채우기
docker compose up --build
# 프론트: http://localhost:8080
# API 문서: http://localhost:8000/docs
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
DB 는 최초 기동 시 6경기 + AI 예측(부트스트랩) + crowd baseline 으로 자동 시드된다.
AI/이메일 키가 없어도 전체 플로우(예측·제출·채점·리더보드)는 즉시 동작한다.
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## 시간 기반 작업 (워커가 자동 처리)
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
조사된 "필요한 시간들"을 워커 단일 프로세스가 모두 담당한다:
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.
| 작업 | 시점 | 설정 |
|---|---|---|
| 투표 오픈 | 킥오프 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` |
## Learn More
> 데모에서는 `DEMO_FORCE_OPEN=true` 로 마감 전까지 항상 투표 가능. **운영 배포 시 false**.
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.
- **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` 참조.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## API 요약
## Deploy on Vercel
| 메서드 | 경로 | 설명 |
|---|---|---|
| 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 |
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.
## 로컬 개발 (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)
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

251
app/before/page.tsx Normal file
View File

@ -0,0 +1,251 @@
"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: "#a65eff" }}>🟣</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>@triplepickai</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>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -152,25 +152,6 @@ body {
box-shadow: 0 6px 18px rgba(21, 169, 155, 0.3);
}
/* 다크 스크롤바 — 흰색 기본 스크롤바 제거 */
.scroll-dark {
scrollbar-width: thin;
scrollbar-color: var(--line-d) transparent;
}
.scroll-dark::-webkit-scrollbar {
width: 6px;
}
.scroll-dark::-webkit-scrollbar-track {
background: transparent;
}
.scroll-dark::-webkit-scrollbar-thumb {
background: var(--line-d);
border-radius: 9999px;
}
.scroll-dark::-webkit-scrollbar-thumb:hover {
background: #3a414e;
}
@keyframes barfill {
from {
width: 0;

38
app/layout.tsx Normal file
View File

@ -0,0 +1,38 @@
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>
);
}

32
app/leaderboard/page.tsx Normal file
View File

@ -0,0 +1,32 @@
import type { Metadata } from "next";
import Hero from "@/components/Hero";
import Footer from "@/components/Footer";
import Leaderboard from "@/components/Leaderboard";
import { parseLang, dict } from "@/lib/i18n";
export const metadata: Metadata = {
title: "누적 랭킹 | TriplePick 2026",
description: "TriplePick 누적 랭킹 — 참가자·AI 모델 TOP 10.",
};
export default async function LeaderboardPage({
searchParams,
}: {
searchParams: Promise<{ lang?: string }>;
}) {
const { lang: langParam } = await searchParams;
const lang = parseLang(langParam);
const t = dict(lang);
return (
<main className="shell">
<Hero back backHistory />
<p className="mt-6 text-center text-[13px] leading-relaxed text-[var(--ink-muted)]">
{t.goldDesc}
</p>
{/* 전체 페이지: 펼친 상태(폴딩 토글 숨김) */}
<Leaderboard lang={lang} defaultOpen />
<Footer />
</main>
);
}

View File

@ -0,0 +1,100 @@
import type { Metadata } from "next";
import Link from "next/link";
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}
/>
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 사용해보기 버튼과 동일 사양(퍼플 #A65EFF) */}
<Link
href={lang === "en" ? "/?lang=en" : "/"}
className="mt-7 flex items-center justify-center gap-1.5 rounded-full bg-[#A65EFF] px-6 py-3 text-[15px] font-extrabold text-white transition active:scale-[0.99]"
style={{ boxShadow: "0 10px 28px rgba(166,94,255,0.40)" }}
>
{t.moreMatches}
<span aria-hidden></span>
</Link>
<Footer lang={lang} />
</main>
);
}

37
app/page.tsx Normal file
View File

@ -0,0 +1,37 @@
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>
);
}

View File

@ -1,74 +0,0 @@
# ─────────────────────────────────────────────────────────────
# 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
# ── 외부 연동: 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

View File

@ -1,23 +0,0 @@
# 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"]

View File

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

View File

@ -1,39 +0,0 @@
"""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)

View File

@ -1,191 +0,0 @@
"""도메인 헬퍼 — 투표 단계(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,
)

View File

@ -1,65 +0,0 @@
"""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,
comments,
leaderboard,
matches,
predictions,
share,
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(matches.router)
app.include_router(comments.router)
app.include_router(predictions.router)
app.include_router(leaderboard.router)
app.include_router(standings.router)
app.include_router(admin.router)
app.include_router(visits.router)
# 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다.
app.include_router(share.router)
@app.get("/api/health")
async def health() -> dict:
return {"ok": True, "service": "triplepick-api"}

View File

@ -1,267 +0,0 @@
"""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,
Boolean,
Date,
DateTime,
ForeignKey,
Integer,
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 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

@ -1,58 +0,0 @@
"""전체 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

@ -1,203 +0,0 @@
"""관리자 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

@ -1,166 +0,0 @@
"""경기별 한마디(댓글) 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

@ -1,165 +0,0 @@
"""리더보드 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

@ -1,66 +0,0 @@
"""공개 읽기 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). 15초 TTL 캐시."""
m = await db.get(Match, match_id)
if not m:
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
if m.league not in ("kbo", "mlb"):
return {"available": False}
return await fetch_live(m)

View File

@ -1,169 +0,0 @@
"""픽 제출 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

@ -1,112 +0,0 @@
"""공유 미리보기(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
from fastapi.responses import HTMLResponse
from ..config import settings
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("/match/{match_id}", response_class=HTMLResponse)
async def match_share(match_id: str) -> 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
la, lb = _team_label(a), _team_label(b)
# 한국은 항상 왼쪽으로 표기(프론트 화면 규칙과 동일).
if b == "KOR" and a != "KOR":
la, lb = lb, la
if la and lb:
title = f"TriplePick 2026 — {la} vs {lb} AI 승부예측"
else:
title = "TriplePick 2026 — AI 2026 글로벌 축구 축전 승부예측"
desc = "AI 셋이 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요."
origin = settings.public_origin.rstrip("/")
page_url = f"{origin}/match/{html.escape(match_id)}"
img_url = f"{origin}{image}"
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

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

View File

@ -1,26 +0,0 @@
"""방문자 집계 — 페이지 접속 기록(일별 순수 방문자)."""
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

@ -1,95 +0,0 @@
"""경기 일정 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)

View File

@ -1,211 +0,0 @@
"""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

View File

@ -1,140 +0,0 @@
"""채점 로직 — 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

View File

@ -1,63 +0,0 @@
"""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))

View File

@ -1,11 +0,0 @@
"""부트스트랩 데이터.
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

@ -1,246 +0,0 @@
"""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 — 프롬프트·스코어 범위 분기
# 모델별 분석 관점(페르소나) — 동일 경기라도 서로 다른 시각으로 보게 해
# 예측이 자연스럽게 갈리도록 한다(강제 분산이 아니라 진짜 판단의 다양화).
PERSONA = {
"GPT": (
"You are a DATA-DRIVEN analyst. Base your call on recent form, head-to-head, "
"FIFA ranking gaps and goals-scored/conceded trends. Be objective and "
"evidence-led; pick the scoreline the numbers most support."
),
"Claude": (
"You are a TACTICAL analyst. Focus on matchups, defensive organization, "
"midfield control and game-state. Take low-scoring games, tight margins and "
"genuine upset potential seriously — do not just rubber-stamp the favorite."
),
"Gemini": (
"You are an ATTACKING-MINDED analyst. Weigh momentum, attacking quality, "
"star players and scoring potential. Lean toward open, higher-scoring "
"scenarios when the talent and tempo justify it."
),
}
# 야구용 페르소나 — 축구 페르소나와 같은 3분화(데이터/수비·투수/공격) 구도.
BASEBALL_PERSONA = {
"GPT": (
"You are a DATA-DRIVEN baseball analyst. Base your call on recent form, "
"season standings, head-to-head record and run-scored/allowed trends. "
"Be objective and evidence-led; pick the scoreline the numbers most support."
),
"Claude": (
"You are a PITCHING-AND-DEFENSE analyst. Weigh starting rotation strength, "
"bullpen fatigue, and defensive quality. Take low-scoring games, tight "
"margins and genuine upset potential seriously — do not just rubber-stamp "
"the favorite."
),
"Gemini": (
"You are an OFFENSE-MINDED analyst. Weigh lineup depth, power hitting, "
"momentum and ballpark factors. Lean toward open, higher-scoring scenarios "
"when the bats and conditions justify it."
),
}
_LEAGUE_LABEL = {
"kbo": "2026 KBO League (Korean professional baseball) regular-season game",
"mlb": "2026 MLB (Major League Baseball) regular-season game",
}
def _prompt_baseball(ctx: MatchContext, model: str) -> str:
persona = BASEBALL_PERSONA[model]
data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
draw_note = (
"KBO regular-season games can end in a DRAW after 12 innings, but draws "
"are rare (~1-2% of games); only predict a draw with strong reason.\n"
if ctx.league == "kbo"
else "MLB games cannot end in a draw — never predict DRAW.\n"
)
return (
f"{persona}\n"
f"Predict the result of this {_LEAGUE_LABEL[ctx.league]} using YOUR "
f"perspective above. Judge independently — it is fine to differ from the "
f"obvious consensus pick when your perspective warrants it.\n"
f"Team A (away): {ctx.team_a}\nTeam B (home): {ctx.team_b}\n"
f"Ballpark: {ctx.venue}\nFirst pitch: {ctx.kickoff}\n"
f"{data}"
f"Important: Team B is the HOME team — home advantage applies. {draw_note}\n"
f"Predict the final score in runs. "
f"Respond with a single JSON object and nothing else, with keys:\n"
f' "scoreA": integer 0-25 (Team A runs),\n'
f' "scoreB": integer 0-25 (Team B runs),\n'
f' "outcome": one of "TEAM_A_WIN" | "DRAW" | "TEAM_B_WIN" (must match the score),\n'
f' "confidencePct": integer 0-100,\n'
f' "reasonKo": a short one-line rationale in Korean (max ~30 chars),\n'
f' "reasonEn": a short one-line rationale in English (max ~60 chars).\n'
)
def _prompt(ctx: MatchContext, persona: str) -> str:
# 실데이터 블록이 있으면 팀/킥오프 다음에 삽입(없으면 빈 문자열 — 기존 동작 동일).
data = f"\n{ctx.data_block}\n" if ctx.data_block else ""
return (
f"{persona}\n"
f"Predict the result of this 2026 FIFA World Cup match using YOUR perspective "
f"above. Judge independently — it is fine to differ from the obvious consensus "
f"pick when your perspective warrants it.\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 _build_prompt(ctx: MatchContext, model: str) -> str:
"""리그별 프롬프트 선택 — 야구(kbo/mlb)는 야구 프롬프트, 그 외 축구."""
if ctx.league in ("kbo", "mlb"):
return _prompt_baseball(ctx, model)
return _prompt(ctx, PERSONA[model])
# JSON Schema (구조화 출력용 — Anthropic/OpenAI 공통)
_SCHEMA = {
"type": "object",
"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

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

View File

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

View File

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

View File

@ -1,130 +0,0 @@
"""야구 일정 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

@ -1,114 +0,0 @@
"""결과 이메일 발송.
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

@ -1,319 +0,0 @@
"""축구 데이터 연동 (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

@ -1,48 +0,0 @@
"""채점 서비스 — 결과 입력 시 해당 경기 유저 픽 전수 채점 + 유저별 누적.
관리자 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

@ -1,100 +0,0 @@
"""유저별 포인트 누적 — 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

@ -1,284 +0,0 @@
"""경기 일정 외부 수집 — 매일 워커가 호출(크롤링).
소스(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

@ -1,94 +0,0 @@
"""수집한 일정을 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

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

View File

@ -1,76 +0,0 @@
"""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": ""}

View File

@ -1,517 +0,0 @@
"""백그라운드 워커 — 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
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
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
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) 일정 동기화 + 프리뷰·순위 캐시 갱신."""
inserted_any = False
for league in settings.league_list:
if league not in ("kbo", "mlb"):
continue
records = await fetch_baseball_schedule(league)
if not records:
continue
async with SessionLocal() as db:
result = await sync_baseball_schedule(db, league, records)
rows = (
await db.execute(
select(Match).where(
Match.league == league, Match.result_outcome.is_(None)
)
)
).scalars().all()
try:
await baseball_details.refresh_baseball_details(db, league, rows)
except Exception as e: # noqa: BLE001
log.warning("baseball details(%s) 갱신 실패: %s", league, e)
inserted_any = inserted_any or bool(result.get("inserted"))
await tick_status()
if inserted_any:
await generate_ai_predictions()
# ── 1) 상태 전이 ────────────────────────────────────────────
async def tick_status() -> None:
async with SessionLocal() as db:
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+프리뷰).
if m.league in ("kbo", "mlb"):
data_block = await baseball_data.build_baseball_data_block(db, m)
else:
data_block = await football_data.build_data_block(db, m)
ctx = MatchContext(
team_a=m.team_a_name,
team_b=m.team_b_name,
venue=m.venue,
kickoff=m.kickoff_at.isoformat(),
data_block=data_block,
league=m.league,
)
for model, fn in PROVIDERS.items():
pred = next((p for p in m.predictions if p.model == model), None)
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) 야구 결과 자동 정산 — (리그, KST 날짜, 팀쌍) 키 매칭 ──
async def settle_baseball() -> None:
_KST = timedelta(hours=9)
now = now_utc()
for league in settings.league_list:
if league not in ("kbo", "mlb"):
continue
async with SessionLocal() as db:
pending = (
await db.execute(
select(Match).where(
Match.league == league,
Match.result_outcome.is_(None),
Match.status != "cancelled",
)
)
).scalars().all()
due = [m.match_id for m in pending if ensure_aware(m.kickoff_at) <= now]
if not due:
continue
results = await fetch_baseball_results(league)
by_key: dict[tuple, tuple[int, int]] = {}
for r in results:
d, a2, b2, s = r["dateKst"], r["teamA"], r["teamB"], r.get("seq", 1)
by_key[(d, a2, b2, s)] = (r["scoreA"], r["scoreB"])
by_key[(d, b2, a2, s)] = (r["scoreB"], r["scoreA"])
settled = 0
async with SessionLocal() as db:
for mid in due:
m = await db.get(Match, mid)
if m is None or m.result_outcome is not None:
continue
d = (ensure_aware(m.kickoff_at) + _KST).strftime("%Y%m%d")
sc = by_key.get((d, m.team_a_code, m.team_b_code, match_seq(mid)))
if not sc:
continue
await apply_result(db, m, sc[0], sc[1])
settled += 1
log.info("settle(%s): %s 자동 정산 %d-%d", league, mid, sc[0], sc[1])
if settled:
await maybe_send_result_emails()
# ── 3) 결과 메일 ────────────────────────────────────────────
async def maybe_send_result_emails() -> None:
async with SessionLocal() as db:
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",
)
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

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

View File

@ -1,15 +0,0 @@
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
anthropic==0.69.0
openai==1.59.6
google-genai==0.8.0

570
components/Arena.tsx Normal file
View File

@ -0,0 +1,570 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { DEMO_FORCE_OPEN, MODEL_VERSIONS } 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";
import Leaderboard from "./Leaderboard";
import {
fetchMyPredictions,
saveMyPrediction,
rememberEmail,
recallEmail,
type MyPrediction,
} from "@/lib/myPredictions";
type Step = "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@]+$/;
// 제출 시각 ISO → "6.18" (내 예측 목록 우측 라벨)
function fmtSubmitted(iso: string): string {
const m = iso.match(/\d{4}-(\d{2})-(\d{2})/);
return m ? `${+m[1]}.${+m[2]}` : "";
}
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 genDate = (predictions[0]?.generatedAt ?? "").replace(/-/g, ".");
const modelVersions = predictions.map((p) => MODEL_VERSIONS[p.model]).join(" · ");
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [scoreA, setScoreA] = useState(2);
const [scoreB, setScoreB] = useState(1);
const [step, setStep] = useState<Step>("form");
const [email, setEmail] = useState("");
const [notify, setNotify] = useState(true);
const [crowd, setCrowd] = useState<CrowdStats>(initialCrowd);
const [copied, setCopied] = useState(false);
const [myPreds, setMyPreds] = useState<MyPrediction[]>([]);
const [votesExpanded, setVotesExpanded] = useState(false);
// 재방문 시: 기억된 이메일 복원 → 내 지난 예측 로드 (로그인 대체)
useEffect(() => {
const saved = recallEmail();
if (!saved) return;
setEmail(saved);
fetchMyPredictions(saved).then(setMyPreds);
}, []);
// 이 경기에 대한 내 기존 픽 (있으면 스코어 프리필 + 강조)
const myThisMatch = useMemo(
() => myPreds.find((p) => p.matchId === match.matchId),
[myPreds, match.matchId],
);
useEffect(() => {
if (myThisMatch) {
setScoreA(myThisMatch.scoreA);
setScoreB(myThisMatch.scoreB);
}
}, [myThisMatch]);
// 이메일 입력이 유효해지면 그 즉시 내 기록 조회(다른 기기/세션 흔적 표시)
const onEmailChange = (v: string) => {
setEmail(v);
if (EMAIL_RE.test(v.trim())) fetchMyPredictions(v).then(setMyPreds);
};
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
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 emailValid = EMAIL_RE.test(email.trim());
const confirmSubmit = async () => {
if (!emailValid || !outcome) return;
// 군중 분포 증분은 새 투표일 때만 (이미 투표한 경기 수정 시 중복 카운트 방지)
if (!myThisMatch) {
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),
}));
}
// 내 예측 저장(경기당 1건 upsert) + 이메일 기억 → 재방문 자동 복원
await saveMyPrediction(email, {
matchId: match.matchId,
teamAShort: aShort,
teamBShort: bShort,
kickoffKst: match.kickoffKst,
outcome,
scoreA,
scoreB,
submittedAt: new Date().toISOString(),
});
rememberEmail(email);
setMyPreds(await fetchMyPredictions(email));
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 flex items-end justify-between gap-2">
<h2 className="shrink-0 text-[22px] font-extrabold">{t.aiBattle}</h2>
<div className="flex-1 pb-1 text-center text-[11px] leading-snug">
<div className="font-bold text-[var(--ink)]">
{t.genLabel} · {genDate} 00:00 KST
</div>
<div className="text-[var(--ink-muted)]">{modelVersions}</div>
</div>
</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>
)}
{/* ===== 투표 마감/오픈 전: 상태 버튼만 ===== */}
{!finished && disabled && (
<button
disabled
className="btn-mint mt-5 w-full rounded-2xl py-5 text-[20px] font-extrabold opacity-60"
>
{ctaLabel}
</button>
)}
{/* ===== 투표 제출 (D5): 이메일 + 단일 CTA를 한 카드로 통합 ===== */}
{!finished && !disabled && step === "form" && (
<div className="mt-5 rounded-2xl border-2 border-[var(--green)]/60 bg-[var(--bg2)] p-4 shadow-[0_0_24px_rgba(74,255,160,0.08)]">
<div className="mb-2.5 text-center text-[15px] font-extrabold text-[var(--green)]">
{t.emailGate}
</div>
<input
type="email"
inputMode="email"
value={email}
onChange={(e) => onEmailChange(e.target.value)}
placeholder={t.emailPh}
className="w-full rounded-xl border border-[var(--share)] bg-[#0f1217] px-4 py-3 text-[16px] text-white shadow-[0_0_0_2px_rgba(166,94,255,0.12)] outline-none focus:shadow-[0_0_0_2px_rgba(166,94,255,0.3)]"
/>
<label className="mt-2.5 flex items-start gap-2 text-[13px] leading-snug text-[var(--ink-muted)]">
<input
type="checkbox"
checked={notify}
onChange={(e) => setNotify(e.target.checked)}
className="mt-0.5 accent-[var(--share)]"
/>
{t.notify}
</label>
<button
onClick={confirmSubmit}
disabled={!emailValid}
className="btn-share mt-3.5 flex w-full items-center justify-center gap-1.5 rounded-xl py-3 text-[15px] font-extrabold transition active:scale-[0.99] disabled:opacity-[0.72]"
>
{t.submit} <span aria-hidden></span>
</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>
)}
{/* ===== 내 지난 예측 (이메일 기준, 로그인 없음) ===== */}
{myPreds.length > 0 && (
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
<div className="mb-3 flex items-baseline justify-between gap-2">
<h3 className="text-[16px] font-extrabold">{t.myVotesTitle}</h3>
<span className="shrink-0 text-[12px] text-[var(--ink-muted)]">
{t.myVotesSub}
</span>
</div>
<ul className="flex flex-col gap-2">
{(votesExpanded ? myPreds : myPreds.slice(0, 1)).map((p) => {
const isThis = p.matchId === match.matchId;
const pickTxt =
p.outcome === "TEAM_A_WIN"
? `${p.teamAShort} ${t.win}`
: p.outcome === "TEAM_B_WIN"
? `${p.teamBShort} ${t.win}`
: t.drawLabel;
return (
<li
key={p.matchId}
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 ${
isThis
? "border-[var(--share)] bg-[rgba(166,94,255,0.1)]"
: "border-[var(--line-d)]"
}`}
>
<div className="min-w-0">
<div className="text-[15px] font-bold text-white">
{p.teamAShort} {p.scoreA}-{p.scoreB} {p.teamBShort}
</div>
<div className="mt-0.5 text-[12px] text-[var(--ink-muted)]">
{pickTxt}
{isThis && (
<span className="text-[var(--share)]"> · {t.thisMatch}</span>
)}
</div>
</div>
{p.result ? (
<span
className={`shrink-0 text-[13px] font-bold ${
p.result.hitOutcome
? "text-[var(--share)]"
: "text-[var(--ink-muted)]"
}`}
>
{p.result.hitOutcome ? t.hit : t.miss}
</span>
) : (
<span className="shrink-0 text-[11px] text-[var(--ink-muted)]">
{fmtSubmitted(p.submittedAt)}
</span>
)}
</li>
);
})}
</ul>
{myPreds.length > 1 && (
<button
onClick={() => setVotesExpanded((v) => !v)}
className="mt-3 flex w-full items-center justify-center gap-1 rounded-xl border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-[var(--ink-muted)] transition active:scale-[0.99]"
>
{votesExpanded ? t.showLess : t.showMore(myPreds.length - 1)}
<span aria-hidden>{votesExpanded ? "▲" : "▼"}</span>
</button>
)}
</section>
)}
{/* ===== 누적 랭킹 TOP 10 (골드 카드 위 · 폴딩) — 랭킹→상금 도전 흐름 ===== */}
<Leaderboard lang={lang} />
{/* ===== 골드 상금 (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>
);
}

27
components/BackButton.tsx Normal file
View File

@ -0,0 +1,27 @@
"use client";
import { useRouter } from "next/navigation";
// 뒤로가기 — 직전 페이지(예: 투표하던 매치 페이지)로 복귀.
// 히스토리가 없으면(직접 진입) 홈으로 폴백.
export default function BackButton({
label,
home,
}: {
label: string;
home: string;
}) {
const router = useRouter();
const goBack = () => {
if (typeof window !== "undefined" && window.history.length > 1) router.back();
else router.push(home);
};
return (
<button
onClick={goBack}
className="flex items-center gap-1.5 rounded-full border border-white/12 bg-white/8 px-[18px] py-[9px] text-[18px] font-bold text-white/85 active:scale-95"
>
{label}
</button>
);
}

View File

@ -1,7 +1,10 @@
"use client";
import { useEffect, useState } from "react";
import { type Lang, dict } from "@/lib/i18n";
// "투표 종료까지" 카운트다운 (D7). 마감 = 킥오프 정각(lockAt).
// 서버/클라 hydration mismatch 방지: 시간 계산은 마운트 후 useEffect에서만.
export default function Countdown({ to, lang = "ko" }: { to: string; lang?: Lang }) {
const t = dict(lang);
const label = t.cdUntil;
@ -26,6 +29,7 @@ export default function Countdown({ to, lang = "ko" }: { to: string; lang?: Lang
<span
className="font-mono text-[18px] font-extrabold tabular-nums text-[var(--green)]"
style={{ textShadow: "0 0 12px rgba(74,255,160,0.45)" }}
suppressHydrationWarning
>
{left === null ? "--:--:--" : fmt(left, lang)}
</span>

View File

@ -1,22 +1,14 @@
import { type Lang, dict } from "@/lib/i18n";
// league 를 주면 비제휴 고지를 리그에 맞게 표시 (야구 = KBO/MLB, 기본 = 월드컵)
export default function Footer({
lang = "ko",
league = "wc",
}: {
lang?: Lang;
league?: string;
}) {
export default function Footer({ lang = "ko" }: { lang?: Lang }) {
const t = dict(lang);
const isBaseball = league === "kbo" || league === "mlb";
return (
<footer className="mt-6 text-center">
<p className="text-[10.5px] font-semibold text-white/75">
{isBaseball ? t.footerNotOfficialBB : t.footerNotOfficial}
{t.footerNotOfficial}
</p>
<p className="mt-1 text-[10.5px] leading-relaxed text-white/60">
{isBaseball ? t.footerDisc1BB : t.footerDisc1}
{t.footerDisc1}
</p>
<div className="mx-auto mt-3 max-w-[400px] space-y-1 text-[9.5px] leading-relaxed text-white/55">
@ -25,7 +17,7 @@ export default function Footer({
</div>
<div className="mt-3 text-[10px] text-white/70">
© 2026 TriplePick · AIO2O · @triplepick_ai
© 2026 TriplePick · AIO2O · @triplepickai
</div>
</footer>
);

58
components/Hero.tsx Normal file
View File

@ -0,0 +1,58 @@
import Link from "next/link";
import ShareButton from "./ShareButton";
import LangSwitch from "./LangSwitch";
import BackButton from "./BackButton";
import { type Lang, dict } from "@/lib/i18n";
type Share = { url: string; title: string; text: string };
// 브랜드 헤더 (대시보드·상세 공용). 경기별 후킹 카피는 상세 페이지에서 별도 노출.
export default function Hero({
back = false,
backHistory = false,
share,
lang = "ko",
}: {
back?: boolean;
backHistory?: boolean; // true면 홈 대신 직전 페이지로 복귀(뒤로가기)
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">
{backHistory ? (
<BackButton label={t.backPrev} home={home} />
) : (
<Link
href={home}
className="flex items-center gap-1.5 rounded-full border border-white/12 bg-white/8 px-[18px] py-[9px] text-[18px] 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,9 +1,12 @@
import { Link, useLocation } from "react-router-dom";
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { Lang } from "@/lib/i18n";
// KO / EN 토글. 현재 경로 유지 + ?lang 토글.
export default function LangSwitch({ lang }: { lang: Lang }) {
const { pathname } = useLocation();
const pathname = usePathname() || "/";
const href = (l: Lang) => (l === "en" ? `${pathname}?lang=en` : pathname);
const langs: Lang[] = ["ko", "en"];
return (
@ -11,7 +14,8 @@ export default function LangSwitch({ lang }: { lang: Lang }) {
{langs.map((l) => (
<Link
key={l}
to={href(l)}
href={href(l)}
scroll={false}
className={`px-2.5 py-1 transition ${
lang === l ? "bg-white text-[#14171C]" : "text-white/65"
}`}

117
components/Leaderboard.tsx Normal file
View File

@ -0,0 +1,117 @@
"use client";
import { useEffect, useState } from "react";
import { type Lang, dict } from "@/lib/i18n";
import {
fetchLeaderboard,
type LeaderEntry,
type LeaderTab,
} from "@/lib/leaderboard";
// 누적 랭킹 — 참가자 / AI 모델 2탭, 폴딩(기본 접힘)
// 매치 페이지(골드 카드 직후)에 삽입. defaultOpen=true 면 전체 페이지용 펼친 상태.
export default function Leaderboard({
lang = "ko",
defaultOpen = false,
}: {
lang?: Lang;
defaultOpen?: boolean;
}) {
const t = dict(lang);
const [open, setOpen] = useState(defaultOpen);
const [tab, setTab] = useState<LeaderTab>("voters");
const [rows, setRows] = useState<LeaderEntry[]>([]);
// 펼쳐졌을 때 + 탭 변경 시에만 조회 (접힌 동안은 네트워크 안 씀)
useEffect(() => {
if (!open) return;
let alive = true;
fetchLeaderboard(tab).then((r) => alive && setRows(r));
return () => {
alive = false;
};
}, [open, tab]);
return (
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
<h3 className="text-[19px] font-extrabold">{t.lbTitle}</h3>
{open && (
<>
{/* 탭 */}
<div className="mt-3 grid grid-cols-2 gap-2">
{(
[
["voters", t.lbTabVoters],
["ai", t.lbTabAI],
] as [LeaderTab, string][]
).map(([val, label]) => (
<button
key={val}
onClick={() => setTab(val)}
className={`rounded-xl py-2.5 text-[15px] font-extrabold transition ${
tab === val
? "bg-[var(--share)] text-white"
: "border border-[var(--line-d)] text-[var(--ink-muted)]"
}`}
>
{label}
</button>
))}
</div>
{/* 헤더 라벨 */}
<div className="mt-3 flex items-center justify-between px-3 text-[12px] font-bold text-[var(--ink-muted)]">
<span>{tab === "ai" ? t.lbTabAI : t.lbTabVoters}</span>
<span>{t.lbPoints}</span>
</div>
{/* TOP 10 목록 */}
<ol className="mt-1.5 flex flex-col gap-1.5">
{rows.map((r, i) => {
const top3 = i < 3;
return (
<li
key={`${r.label}-${i}`}
className={`flex items-center gap-3 rounded-xl border px-3 py-2.5 ${
top3
? "border-[var(--share)]/50 bg-[rgba(166,94,255,0.08)]"
: "border-[var(--line-d)]"
}`}
>
<span
className={`w-6 shrink-0 text-center font-mono text-[16px] font-extrabold tabular-nums ${
top3 ? "text-[var(--share)]" : "text-[var(--ink-muted)]"
}`}
>
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate text-[16px] font-bold text-white">
{r.label}
</span>
<span className="shrink-0 font-mono text-[16px] font-extrabold tabular-nums text-white">
{r.points.toLocaleString()}
<span className="ml-0.5 text-[12px] text-[var(--ink-muted)]">
{t.lbUnit}
</span>
</span>
</li>
);
})}
</ol>
</>
)}
{/* 폴딩 토글 — defaultOpen(전체 페이지)에서는 숨김 */}
{!defaultOpen && (
<button
onClick={() => setOpen((v) => !v)}
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-xl border border-[var(--share)] bg-[rgba(166,94,255,0.12)] py-2.5 text-[15px] font-extrabold text-[var(--share)] transition active:scale-[0.99]"
>
{open ? t.lbClose : t.lbOpen}
<span aria-hidden>{open ? "▲" : "▼"}</span>
</button>
)}
</section>
);
}

64
components/MatchupHUD.tsx Normal file
View File

@ -0,0 +1,64 @@
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

@ -0,0 +1,127 @@
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 result = match.result;
const winLabel = result
? result.outcome === "TEAM_A_WIN"
? `${teamShort(match.teamA, lang)} ${t.win}`
: result.outcome === "TEAM_B_WIN"
? `${teamShort(match.teamB, lang)} ${t.win}`
: t.drawLabel
: null;
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)}
{winLabel && (
<span className="ml-1 font-semibold text-[#94FBE0]">· {winLabel}</span>
)}
</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>
{result ? (
<span className="flex items-center gap-1.5 font-impact text-[18px] italic">
<span className="tabular-nums text-white">{result.scoreA}</span>
<span className="text-[13px] text-[#94FBE0]">VS</span>
<span className="tabular-nums text-white">{result.scoreB}</span>
</span>
) : (
<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,6 +1,8 @@
"use client";
import { useState } from "react";
// 투표(경기) 공유 — 모바일 네이티브 공유, 미지원 시 클립보드 복사 폴백 (D8)
// 투표(경기) 공유 — 모바일 네이티브 공유(invoke), 미지원 시 클립보드 복사 폴백 (D8)
export default function ShareButton({
url,
title,

41
components/TeamFlag.tsx Normal file
View File

@ -0,0 +1,41 @@
import type { Team } from "@/lib/types";
// 모든 국기를 직사각형 SVG로 통일 (flagcdn, 퍼블릭 도메인).
// 이모지(OS별 휘날림/광택 렌더) 제거 → 일관된 사각형. object-cover로 박스를 꽉 채움(왜곡 없음).
const FLAG_FILE: Record<string, string> = {
KOR: "kr",
CZE: "cz",
MEX: "mx",
RSA: "za",
};
export default function TeamFlag({
team,
className = "",
}: {
team: Team;
className?: string;
}) {
const file = FLAG_FILE[team.code];
if (file) {
return (
<img
src={`/icons/flags/${file}.svg`}
alt={team.name}
className={`border border-[var(--line-d)] object-cover ${className}`}
/>
);
}
// 미등록 국가 fallback — 이모지(직사각형 박스, 잘림 없음)
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>
);
}

View File

@ -1,37 +0,0 @@
# 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"

56
docs/AI-PREDICTIONS.md Normal file
View File

@ -0,0 +1,56 @@
# AI 예측 생성 — 3모델 (GPT · Claude · Gemini)
최종 갱신: 2026-06-10 KST
관련: `lib/mockData.ts`(FEATURED_PREDICTIONS), `docs/BACKEND.md`(자동화), `.env.example`
> 매일 KST 0시, 남은 경기를 GPT·Claude·Gemini 3개 모델로 예측 생성(immutable). 현재는 수동 주입, 추후 Cloud Functions 자동화.
---
## 1. 모델 ID (실측 확정 · 2026-06-10)
각 API의 `GET /models` 엔드포인트로 실제 사용 가능한 최신 ID를 확인해 고정. (추측 금지)
| 모델 | 최신 ID | 호출 엔드포인트 |
|---|---|---|
| **GPT** | `gpt-5.5` | `POST https://api.openai.com/v1/chat/completions` |
| **Claude** | `claude-opus-4-8` | `POST https://api.anthropic.com/v1/messages` (`anthropic-version: 2023-06-01`) |
| **Gemini** | `gemini-3.5-flash` | `POST https://generativelanguage.googleapis.com/v1beta/models/{id}:generateContent?key=` |
> 키는 `004. Dev/.env`(저장소 밖, 시크릿). 저장소엔 `.env.example`의 빈 슬롯만.
## 2. ⚠️ 함정 / 운영 노트
- **Gemini는 `generationConfig`(responseMimeType·thinkingConfig) 주면 빈 응답** 반환. → **평문 본문(contents만)으로 호출하고 응답 text에서 JSON 정규식 파싱**할 것. thinking 모델이라 추론 토큰을 먼저 쓴다.
- **Anthropic 자동 호출엔 크레딧 필요**: 현재 키는 `credit balance too low`. console.anthropic.com 결제 전까지 Claude는 수동 주입.
- **Python urllib는 macOS에서 SSL 인증서 실패** 가능 → `curl`(시스템 인증서) 사용 권장.
- 출력은 JSON 스키마로 강제: `{winner, score_korea, score_czechia, confidence, reason}`.
## 3. 체코전(한국 vs 체코) — 실제 출력 (2026-06-10)
`lib/mockData.ts``FEATURED_PREDICTIONS`에 주입됨.
| 모델 | 결과 | 스코어(한:체) | 확신 | 근거(KO) |
|---|---|---|---|---|
| GPT (gpt-5.5) | 무승부 | 1 : 1 | 54% | 한국 역습과 체코 제공권이 팽팽 |
| Claude (claude-opus-4-8) | 한국 승 | 2 : 1 | 57% | 손흥민·이강인 측면 창의성이 체코 블록을 연다 |
| Gemini (gemini-3.5-flash) | 한국 승 | 2 : 1 | 55% | 이강인·손흥민 공격력, 체코 수비에 근소 우세 |
→ "AI가 갈렸다": GPT 무승부 vs Gemini·Claude 한국 2-1.
## 4. 호출 프롬프트 (참고)
```
2026년 6월 한국(Korea Republic) vs 체코(Czechia) 축구 경기를 최근 전력 기반으로 예측해줘.
다른 설명 없이 이 JSON 한 줄만:
{"winner":"한국|무승부|체코","score_korea":정수,"score_czechia":정수,"confidence":0-100,"reason":"한국어 한 줄"}
```
- winner는 teamA(한국)=score_korea, teamB(체코)=score_czechia 기준으로 매핑.
- 랜딩 타입 변환: 한국 승=`TEAM_A_WIN` / 무승부=`DRAW` / 체코 승=`TEAM_B_WIN`.
## 5. 다음 단계
- [ ] 나머지 Group A 5경기도 동일 호출로 주입
- [ ] Cloud Functions로 매일 KST 0시 자동 생성 → Firestore 저장(immutable), 탈락팀 제외 (`docs/BACKEND.md`)
- [ ] Anthropic 크레딧 충전 후 Claude 자동 호출 포함

View File

@ -1,38 +0,0 @@
# 야구(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

@ -86,8 +86,25 @@
## 7. Do / Don't
- ✅ 실제 모델 아이콘 · 클래식 축구공(투명) · 라이트닝 그린 VS · 화이트 시트 + 다크 하이브리드 · 민트 CTA
- ✅ 3색(틸그린/오렌지/블루)으로 모델 구분, 글로우는 그린
- ❌ "월드컵" 표현 / 골드 톤 / 단색 초록 일변도 / 레터박스 가짜 아이콘 / 이모지 축구공 / 임의 다크 단색(=v1 회귀)
- ❌ "월드컵" 표현 / 골드 톤 / 단색 초록 일변도 / 레터박스 가짜 아이콘 / 임의 다크 단색(=v1 회귀)
- ❌ 한글을 이미지로 굽기(가독성↓) — 텍스트 레이어로
- ❌ **모든 이모지 절대 금지** (👆 손가락·🏆·⚽·🤖·🟠 등 픽토그램·컬러 이모지). 강조·지시는 **컬러·타이포·실제 SVG/PNG 아이콘**으로 표현. (typographic 글리프 `→ ★ ✓ ·` 는 허용 — 이건 이모지가 아님). 디자인·영상·랜딩 전부 동일 적용. (2026-06-10 확정)
- ❌ **글로우(glow) 이펙트 금지** (네온 textShadow·box-shadow 발광·펄스 글로우). 가독용 **어두운 드롭섀도/스크림은 허용**(발광 아님). 강조는 컬러·크기·굵기·보더로. (2026-06-11 확정)
- ❌ **em dash(—) 사용 금지** (카피·자막 전부). 대신 느낌표·쉼표·줄바꿈·중점(·)으로. (리더보드 '미기록' 표기는 하이픈 `-`). (2026-06-11 확정)
- ⚠️ **브랜드 그린 = `#4AFFA0` 정확값** 사용. 영상 mp4 인코딩 시 yuv420p 채도 저하로 탁해 보이면 인코딩에서 채도 보정(`eq=saturation`)으로 살릴 것.
---
## 10. SNS 콘텐츠 템플릿 (SSOT: `Worldcup 2026/006. SNS/TriplePick_Social Content.pptx`)
> 사용자가 만든 PPTX = BG·BI·자막위치·CI·ending·Thumbnail 공식 템플릿. 원본 미디어는 PPTX에서 추출(`003. video/assets/sns-template/`).
- **BG**: 다크 네이비/차콜 + 사선 그린→블루 그라디언트 (원본 `image1.png`, 어둡게 깔아 사용).
- **BI** (TriplePick 프로필 hex 배지 + "TRIPLE PICK 2026" 워드마크): **좌상단 고정**, 가로 락업. 로고 벡터 = `image2.svg`.
- **CI** (AIO2O): **하단 중앙 고정**. 원본 `image3.png`(고해상).
- **자막 위치**: 매치업 배너(상단·국기) → 훅 질문 중앙(예 "어떤 AI 모델이 맞췄을까?") → "AI 셋 vs 당신 / 누가 맞히나?" 하단 중앙.
- **Thumbnail**: **동물 매치업**(베이스 `image4.png`). 규칙 — **국가는 그 나라를 상징하는 힘센 동물로, 한국은 호랑이 고정**, 상대국은 상징 동물(체코=사자). 사선 분할(왼쪽위→오른쪽아래), 레드(한국)/블루(상대). 한글 텍스트는 이미지에 굽지 말고 Remotion 오버레이.
- **Ending**: TriplePick 로고 + 워드마크 + AIO2O CI, **중앙 정렬**. 글로우 없음.
- **활용 파이프라인**: PPTX 미디어를 추출해 직접 사용(BG=image1, 로고=image2.svg, CI=image3, 썸네일 베이스=image4) → Remotion에서 한글 텍스트만 오버레이. 새 경기는 썸네일 베이스 동물만 교체(한국=호랑이 고정).
---

View File

@ -44,28 +44,13 @@
- 무승부: 승패 적중이면서 득실차(=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:
|득실차 오차| <= tol ? 근접(3) : 승패(2)
3) else if |pick.scoreA - result.scoreA| <= tol || |pick.scoreB - result.scoreB| <= tol → 부분(1)
(pick.scoreA - pick.scoreB) == (result.scoreA - result.scoreB) ? 근접(3) : 승패(2)
3) else if pick.scoreA == result.scoreA || pick.scoreB == result.scoreB → 부분(1)
4) else → 0
```

18
eslint.config.mjs Normal file
View File

@ -0,0 +1,18 @@
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;

11
firebase.json Normal file
View File

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

43
firestore.indexes.json Normal file
View File

@ -0,0 +1,43 @@
{
"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": []
}

43
firestore.rules Normal file
View File

@ -0,0 +1,43 @@
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).

View File

@ -1,8 +0,0 @@
# 프론트엔드 환경변수 (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

View File

@ -1,15 +0,0 @@
# 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;"]

View File

@ -1,126 +0,0 @@
<!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" />
<title>TriplePick 2026 | AI 2026 글로벌 축구 축전 승부예측 — GPT vs Claude vs Gemini</title>
<meta
name="description"
content="GPT·Claude·Gemini가 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지."
/>
<!-- 공유 썸네일 (Open Graph / Twitter) — 카카오톡 등 소셜 미리보기 -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="TriplePick" />
<meta property="og:title" content="TriplePick 2026 — AI 2026 글로벌 축구 축전 승부예측" />
<meta
property="og:description"
content="AI 셋이 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요. 끝까지 잘 맞히면 100만 원 챌린지."
/>
<meta property="og:url" content="https://triplepick.o2o.kr/" />
<meta property="og:image" content="https://triplepick.o2o.kr/assets/bi/og-image.png" />
<meta property="og:image:secure_url" content="https://triplepick.o2o.kr/assets/bi/og-image.png" />
<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 2026 — AI 2026 글로벌 축구 축전 승부예측" />
<meta
name="twitter:description"
content="AI 셋이 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요."
/>
<meta name="twitter:image" content="https://triplepick.o2o.kr/assets/bi/og-image.png" />
<!-- 검색엔진 소유확인 (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승부예측, 스포츠 승부예측, 축구 승부예측, 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가 같은 경기 데이터로 승패·스코어·승리확률을 예측·비교하는 참여형 축구 승부예측 서비스.",
"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와 겨루는 무료·비로그인 참여형 축구 승부예측 서비스."
}
]
}
</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와 겨룹니다. 2026 글로벌 축구 축전 전 경기를 대상으로
무료·비로그인으로 참여할 수 있으며, 끝까지 잘 맞히면 100만 원 챌린지 상금에 도전합니다.
</p>
<h2>트리플픽은 이런 축구 승부예측 서비스입니다</h2>
<ul>
<li>3대 AI(GPT·Claude·Gemini)의 경기별 예측을 한눈에 비교</li>
<li>승패·정확 스코어를 직접 찍는 참여형 AI승부예측</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>

View File

@ -1,44 +0,0 @@
# 공유 크롤러(카카오톡·트위터·페북 등) 판별 /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;
}
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;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +0,0 @@
{
"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

@ -1,505 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 285 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 154 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 182 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 437 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 658 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 194 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 307 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 201 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 80 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 266 B

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 212 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 176 B

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