Compare commits
17 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
996d5faaf4 | |
|
|
965bd374c3 | |
|
|
b69b1dbe97 | |
|
|
cbe0e8e01d | |
|
|
cbab4a102a | |
|
|
c8431e65f3 | |
|
|
7d4d50af40 | |
|
|
6c3a47b6ba | |
|
|
0c91c902a8 | |
|
|
7a375ee32f | |
|
|
596ed9c094 | |
|
|
6a094debbc | |
|
|
9de27dafd8 | |
|
|
f29dbd0454 | |
|
|
9b35a759a9 | |
|
|
2fe60ab0cd | |
|
|
0a15906afb |
|
|
@ -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 파싱
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"projects": {
|
||||
"default": "REPLACE_WITH_NEW_FIREBASE_PROJECT_ID"
|
||||
}
|
||||
}
|
||||
|
|
@ -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/
|
||||
|
|
|
|||
49
AGENTS.md
|
|
@ -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 -->
|
||||
|
|
|
|||
96
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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()
|
||||
)
|
||||
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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))
|
||||
|
|
@ -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)
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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) # 재방문 → 무시
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
|
|
@ -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}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
"===",
|
||||
])
|
||||
|
|
@ -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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)}",
|
||||
"===",
|
||||
])
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 []
|
||||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
@ -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": ""}
|
||||
|
|
@ -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())
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"score_exact": 5,
|
||||
"score_close": 3,
|
||||
"score_outcome": 2,
|
||||
"score_partial": 1,
|
||||
"score_miss": 0
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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} - {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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
}`}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
// 투표(경기) 공유 — 모바일 네이티브 공유, 미지원 시 클립보드 복사 폴백 (D8)
|
||||
// 투표(경기) 공유 — 모바일 네이티브 공유(invoke), 미지원 시 클립보드 복사 폴백 (D8)
|
||||
export default function ShareButton({
|
||||
url,
|
||||
title,
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 자동 호출 포함
|
||||
|
|
@ -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콜/분(캐시 상한)
|
||||
|
|
@ -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에서 한글 텍스트만 오버레이. 새 경기는 썸네일 베이스 동물만 교체(한국=호랑이 고정).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
},
|
||||
"functions": {
|
||||
"source": "functions",
|
||||
"runtime": "nodejs20",
|
||||
"region": "asia-northeast3"
|
||||
}
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
@ -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
|
||||
|
|
@ -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;"]
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
Before Width: | Height: | Size: 80 KiB |
|
|
@ -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 |
|
Before Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
|
@ -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 |