Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c96815c2e3 | |||
| 4035432c54 | |||
| d28c291a55 | |||
| 21a8173963 | |||
| f6fcff0faf | |||
| 55863d7744 | |||
| a330a5271c | |||
| e27fbfada1 | |||
| 7fb55a7be7 | |||
| 9a8df4908a | |||
| a8cbef75db | |||
| b6fd444dba | |||
| f2e23c1241 | |||
| c6850da4ac | |||
| 8283dab0de | |||
| 9faa1c5715 | |||
| 0e2d241e18 | |||
| 84c5877207 | |||
| cbafc1f959 | |||
| dce6b3e692 | |||
| 3d0dd24f27 | |||
| 2fafce0327 | |||
| 25ede4f478 | |||
| 2493bc72fb | |||
| dd6435eb86 | |||
| 94db1da045 | |||
| d8e4e0461c | |||
| 421e52b205 | |||
| 526d6a53e5 | |||
| 432840a38d | |||
| 597353e6d4 | |||
| bd43c99221 | |||
| 2c95fe49f3 | |||
| 8ccfc32749 | |||
| 67ef3c4bbf | |||
| ee54458bf0 | |||
| e1c3168d5c | |||
| 2d5972c25d | |||
| 1ddbd4ad0e | |||
| f75bf5d3e5 | |||
| 0fde916120 | |||
| 64c526488a | |||
| c655b655c9 | |||
| 005c0261c2 | |||
| 879bb2f25d | |||
| 82cbae7ae2 | |||
| a8b661b304 | |||
| b815c37064 |
59
.env.example
59
.env.example
@@ -1,17 +1,52 @@
|
|||||||
# timezone
|
# ---------------------------------------------------------------------------
|
||||||
|
# [Environment Configuration]
|
||||||
|
# 이 파일을 복사하여 .env 파일을 생성하고, 환경에 맞게 주석을 해제/수정하여 사용하세요.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# [COMMON]
|
||||||
|
APP_VERSION=dev
|
||||||
TZ=Asia/Seoul
|
TZ=Asia/Seoul
|
||||||
|
|
||||||
COMPOSE_PROJECT_NAME=webpage
|
|
||||||
|
|
||||||
# backend lotto collector sources
|
|
||||||
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
|
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
|
||||||
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
|
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
|
||||||
|
|
||||||
# travel-proxy
|
# [SECURITY]
|
||||||
TRAVEL_ROOT=/data/travel
|
WEBHOOK_SECRET=change_this_secret_in_prod
|
||||||
TRAVEL_THUMB_ROOT=/data/thumbs
|
|
||||||
TRAVEL_MEDIA_BASE=/media/travel
|
|
||||||
TRAVEL_CACHE_TTL=300
|
|
||||||
|
|
||||||
# CORS (travel-proxy)
|
# [PATHS]
|
||||||
CORS_ALLOW_ORIGINS=*
|
# 1. 런타임 데이터 루트 (docker-compose.yml이 실행되는 위치)
|
||||||
|
# NAS: /volume1/docker/webpage
|
||||||
|
# Local: . (현재 프로젝트 루트)
|
||||||
|
RUNTIME_PATH=.
|
||||||
|
|
||||||
|
# 2. Git 저장소 루트
|
||||||
|
# NAS: /volume1/workspace/web-page-backend
|
||||||
|
# Local: .
|
||||||
|
REPO_PATH=.
|
||||||
|
|
||||||
|
# 3. Frontend 정적 파일 경로
|
||||||
|
# NAS: /volume1/docker/webpage/frontend (업로드된 파일)
|
||||||
|
# Local: ./frontend/dist (빌드된 결과물)
|
||||||
|
FRONTEND_PATH=./frontend/dist
|
||||||
|
|
||||||
|
# 4. 여행 사진 원본 경로
|
||||||
|
# NAS: /volume1/web/images/webPage/travel
|
||||||
|
# Local: ./mock_data/photos
|
||||||
|
PHOTO_PATH=./mock_data/photos
|
||||||
|
|
||||||
|
# 5. 주식 데이터 저장 경로
|
||||||
|
# NAS: /volume1/docker/webpage/data/stock
|
||||||
|
# Local: ./data/stock
|
||||||
|
STOCK_DATA_PATH=./data/stock
|
||||||
|
|
||||||
|
# [PERMISSIONS]
|
||||||
|
# NAS: 1026:100
|
||||||
|
# Local: 1000:1000 (Windows Docker Desktop의 경우 크게 중요하지 않음)
|
||||||
|
PUID=1000
|
||||||
|
PGID=1000
|
||||||
|
|
||||||
|
# [STOCK LAB]
|
||||||
|
# NAS는 Windows AI Server로 요청을 중계(Proxy)하는 역할만 수행합니다.
|
||||||
|
# 실제 KIS API 호출 및 AI 분석은 Windows PC에서 수행됩니다.
|
||||||
|
|
||||||
|
# Windows AI Server (NAS 입장에서 바라본 Windows PC IP)
|
||||||
|
WINDOWS_AI_SERVER_URL=http://192.168.0.5:8000
|
||||||
262
README.md
262
README.md
@@ -0,0 +1,262 @@
|
|||||||
|
# 🏠 Home NAS Web Platform (webpage)
|
||||||
|
|
||||||
|
Synology NAS 기반의 개인 웹 플랫폼으로, 로또 분석/추천 서비스와 여행 사진 지도 서비스를 포함합니다.
|
||||||
|
Frontend, Backend, Travel Proxy, Auto Deployer를 Docker Compose로 통합하여 운영합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🖥️ NAS 환경 정보
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
| --------------- | ------------------------------------- |
|
||||||
|
| **NAS** | Synology NAS |
|
||||||
|
| **OS** | Synology DSM |
|
||||||
|
| **CPU** | Intel Celeron J4025 (2 Core, 2.0GHz) |
|
||||||
|
| **메모리** | 18 GB |
|
||||||
|
| **Docker** | Synology Container Manager |
|
||||||
|
| **Reverse Proxy** | Nginx (컨테이너) |
|
||||||
|
| **Git Server** | Gitea (self-hosted) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
/volume1
|
||||||
|
├── docker/
|
||||||
|
│ └── webpage/ # 🚀 운영 런타임 (Docker Compose 기준점)
|
||||||
|
│ ├── backend/ # lotto-backend
|
||||||
|
│ ├── stock-lab/ # 🟪 stock-lab (Stock + AI)
|
||||||
|
│ ├── travel-proxy/ # travel API + thumbnail generator
|
||||||
|
│ ├── deployer/ # webhook 기반 자동 배포 컨테이너
|
||||||
|
│ ├── frontend/ # 정적 파일 (Vite build 결과)
|
||||||
|
│ ├── nginx/
|
||||||
|
│ │ └── default.conf
|
||||||
|
│ ├── scripts/
|
||||||
|
│ │ └── deploy.sh # webhook이 호출하는 실행기
|
||||||
|
│ ├── docker-compose.yml
|
||||||
|
│ └── data/
|
||||||
|
│ └── lotto.db
|
||||||
|
│
|
||||||
|
├── workspace/
|
||||||
|
│ └── web-page-backend/ # 🧠 Git 레포 (backend + infra)
|
||||||
|
│ ├── backend/
|
||||||
|
│ ├── travel-proxy/
|
||||||
|
│ ├── deployer/
|
||||||
|
│ ├── nginx/
|
||||||
|
│ ├── scripts/
|
||||||
|
│ │ └── deploy-nas.sh # 실제 운영 반영 로직
|
||||||
|
│ ├── docker-compose.yml
|
||||||
|
│ ├── .env.example
|
||||||
|
│ └── README.md
|
||||||
|
│
|
||||||
|
└── web/
|
||||||
|
└── images/
|
||||||
|
└── webPage/
|
||||||
|
└── travel/ # 📷 원본 여행 사진 (RO)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧩 서비스 구성 개요
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
User[User Browser] -->|HTTP| Nginx
|
||||||
|
|
||||||
|
subgraph NAS [Synology NAS]
|
||||||
|
Nginx -->|/api/lotto| Lotto[Lotto Backend]
|
||||||
|
Nginx -->|/api/travel| Travel[Travel Proxy]
|
||||||
|
Nginx -->|/api/stock| Stock[Stock Lab]
|
||||||
|
Stock -->|Trading/Balance| KIS[KIS API (Korea Inv.)]
|
||||||
|
Stock -->|Market News| News[News Sites]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Windows [High-Performance PC]
|
||||||
|
Stock -->|Analyze Request| WinServer[Windows AI Server]
|
||||||
|
WinServer -->|LLM Inference| Ollama[Ollama (Llama 3.1)]
|
||||||
|
WinServer -->|GPU| GPU[NVIDIA 3070 Ti]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 개발 환경 설정 (Local Development)
|
||||||
|
|
||||||
|
이 프로젝트는 **Windows/Mac 로컬 환경**과 **Synology NAS 운영 환경**을 모두 지원하도록 구성되었습니다.
|
||||||
|
|
||||||
|
### 1. 환경 변수 설정
|
||||||
|
`docker-compose.yml`은 환경 변수에 의존합니다.
|
||||||
|
1. `.env.example` 파일을 복사하여 `.env` 파일을 생성하세요.
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
2. `.env` 파일의 경로(`RUNTIME_PATH`, `PHOTO_PATH` 등)를 로컬 환경에 맞게 수정하세요.
|
||||||
|
- 기본값은 현재 디렉토리(`.`) 기준으로 설정되어 있어 바로 실행 가능합니다.
|
||||||
|
|
||||||
|
### 2. 로컬 실행
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
- Frontend: http://localhost:8080
|
||||||
|
- Backend API: http://localhost:18000
|
||||||
|
- Travel API: http://localhost:19000
|
||||||
|
- Stock Lab API: http://localhost:18500
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟦 Frontend (lotto-frontend)
|
||||||
|
|
||||||
|
- **역할**: React + Vite 기반 SPA로, 로또 추천 및 여행 지도 UI를 제공합니다.
|
||||||
|
- **특징**:
|
||||||
|
- 정적 파일만 운영 서버에 배포합니다.
|
||||||
|
- 장기 캐시(`assets/`)와 `index.html` 캐시 무효화 전략을 사용합니다.
|
||||||
|
- Backend / Travel API는 Nginx에서 Reverse Proxy로 연결됩니다.
|
||||||
|
- **배포 방식**:
|
||||||
|
1. **로컬 개발**:
|
||||||
|
- `.env` 파일 설정 후 `docker compose up`으로 전체 스택 실행 가능
|
||||||
|
2. **운영 배포**:
|
||||||
|
- Code를 Git에 Push
|
||||||
|
- Webhook이 트리거되어 NAS가 자동 Pull & Deploy
|
||||||
|
- (Frontend 빌드 산출물은 별도 업로드 혹은 CI 연동 필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟩 Backend (lotto-backend)
|
||||||
|
|
||||||
|
- **역할**: 로또 데이터 수집, 분석, 추천 API를 제공하며 SQLite로 데이터를 관리합니다.
|
||||||
|
- **주요 기능**:
|
||||||
|
- 최신 및 특정 회차 조회
|
||||||
|
- 추천 번호 생성 및 히스토리 관리 (중복 제거)
|
||||||
|
- 즐겨찾기, 메모, 태그 관리
|
||||||
|
- 배치 추천 기능
|
||||||
|
- **기술 스택**: FastAPI, SQLite, APScheduler (정기 수집)
|
||||||
|
- **주요 엔드포인트**:
|
||||||
|
```http
|
||||||
|
GET /api/lotto/latest
|
||||||
|
GET /api/lotto/{drw_no}
|
||||||
|
GET /api/lotto/recommend
|
||||||
|
GET /api/lotto/recommend/batch
|
||||||
|
GET /api/history
|
||||||
|
PATCH /api/history/{id}
|
||||||
|
DELETE /api/history/{id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟪 Stock Lab (stock-lab)
|
||||||
|
|
||||||
|
- **역할**: 주식 시장 분석 및 AI 기반 투자 조언 제공. NAS의 편의성과 Windows PC의 고성능을 결합한 하이브리드 아키텍처입니다.
|
||||||
|
- **주요 기능**:
|
||||||
|
- **시장 뉴스 스크랩**: 네이버 증권, 해외 주요 뉴스 사이트 크롤링
|
||||||
|
- **자산 관리**: 한국투자증권(KIS) Open API 연동 (잔고 조회, 매수/매도)
|
||||||
|
- **AI 분석**: 고성능 PC의 로컬 LLM(Llama 3.1)을 활용하여 뉴스+포트폴리오 종합 분석
|
||||||
|
- **기술 스택**: Python, FastAPI, Ollama (Lava/Llama3), Docker
|
||||||
|
- **연동 구조**:
|
||||||
|
```
|
||||||
|
[NAS Stock-Lab] <--(HTTP)--> [Windows AI Server] <--(Localhost)--> [Ollama GPU]
|
||||||
|
```
|
||||||
|
- **주요 엔드포인트**:
|
||||||
|
```http
|
||||||
|
GET /api/stock/analyze # AI 시장 분석 요청
|
||||||
|
GET /api/stock/news # 최신 뉴스 데이터
|
||||||
|
GET /api/trade/balance # 계좌 잔고 조회
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟨 Travel Proxy (travel-proxy)
|
||||||
|
|
||||||
|
- **역할**: 여행 사진 API, 지역별 사진 매칭, 썸네일 자동 생성 및 캐시를 담당합니다.
|
||||||
|
- **설계 포인트**:
|
||||||
|
- 원본 사진은 읽기 전용(RO)으로 마운트합니다.
|
||||||
|
- 썸네일은 쓰기/읽기(RW) 전용 캐시 디렉토리를 사용합니다.
|
||||||
|
- 사진 메타데이터 변경 시 캐시가 자동으로 무효화됩니다.
|
||||||
|
- **데이터 구조**:
|
||||||
|
```
|
||||||
|
/data/travel/ # 원본 사진 (RO)
|
||||||
|
├── 24.09.jeju/
|
||||||
|
├── 25.07.maldives/
|
||||||
|
└── _meta/
|
||||||
|
├── region_map.json
|
||||||
|
└── regions.geojson
|
||||||
|
|
||||||
|
/data/thumbs/ # 썸네일 캐시 (RW)
|
||||||
|
├── 24.09.jeju/
|
||||||
|
└── 25.07.maldives/
|
||||||
|
```
|
||||||
|
- **주요 엔드포인트**:
|
||||||
|
```http
|
||||||
|
GET /api/travel/regions
|
||||||
|
GET /api/travel/photos?region=jeju
|
||||||
|
GET /media/travel/.thumb/{album}/{file}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟥 Deployer (webpage-deployer)
|
||||||
|
|
||||||
|
- **역할**: Gitea Webhook을 수신하여 Git pull 및 Docker 재기동을 자동화합니다.
|
||||||
|
- **흐름**: `Gitea Push` → `Webhook` → `deployer` → `/scripts/deploy.sh` → `docker compose up -d --build`
|
||||||
|
- **보안**: HMAC SHA256 서명(`X-Gitea-Signature`)을 `WEBHOOK_SECRET` 환경변수로 검증합니다.
|
||||||
|
- **특징**:
|
||||||
|
- Docker socket을 마운트하여 사용합니다.
|
||||||
|
- 롤백을 위해 `.releases/` 디렉토리에 자동 백업을 수행합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔁 배포 플로우 요약
|
||||||
|
|
||||||
|
- **Backend / Travel / Infra 변경**: `git push`를 통해 Gitea로 푸시하면 Webhook이 트리거되어 자동으로 배포됩니다.
|
||||||
|
- **Frontend 변경**: 로컬에서 빌드 후, 생성된 정적 파일만 NAS로 업로드합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 운영 체크 포인트
|
||||||
|
|
||||||
|
- `/health`: Backend 서비스 상태 확인
|
||||||
|
- `/api/travel/photos`: 응답 속도 확인
|
||||||
|
- `/media/travel/.thumb`: 썸네일 생성 여부 확인
|
||||||
|
- `deployer` 컨테이너 로그 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 TODO
|
||||||
|
|
||||||
|
### 🔥 로또 서비스 고도화
|
||||||
|
|
||||||
|
- [ ] 추천 결과 통계 시각화 (분포, 합계, 홀짝)
|
||||||
|
- [ ] 추천 히스토리 필터 및 검색 기능
|
||||||
|
- [ ] 추천 결과 즐겨찾기 UI
|
||||||
|
- [ ] 회차 대비 추천 성능 분석
|
||||||
|
|
||||||
|
### 🗺️ 여행 지도 UI
|
||||||
|
|
||||||
|
- [ ] 지도 영역 클릭 시 해당 지역 사진 로딩
|
||||||
|
- [ ] 사진 지연 로딩 (Lazy Load)
|
||||||
|
- [ ] 앨범 및 연도별 필터 기능
|
||||||
|
- [ ] 모바일 UX 개선
|
||||||
|
|
||||||
|
### ⚙️ 운영/인프라
|
||||||
|
|
||||||
|
- [ ] Docker 이미지 버전 태깅 자동화
|
||||||
|
- [ ] 배포 실패 시 자동 롤백 기능
|
||||||
|
- [ ] Health check 기반 배포 성공 판단 로직
|
||||||
|
- [ ] 로그 수집 및 관리 체계 개선
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 철학
|
||||||
|
|
||||||
|
> “NAS는 서버가 아니라 집이다.”
|
||||||
|
>
|
||||||
|
> 그래서 안전하고, 단순하며, 복구 가능한 구조를 최우선으로 합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Makefile 설정 사용 예시
|
||||||
|
|
||||||
|
- **배포**: `make deploy`
|
||||||
|
- **백엔드 로그**: `make logs S=backend`
|
||||||
|
- **전체 로그**: `make logs`
|
||||||
|
- **상태**: `make status`
|
||||||
|
|||||||
@@ -16,3 +16,7 @@ ENV PYTHONUNBUFFERED=1
|
|||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
LABEL org.opencontainers.image.version=$APP_VERSION
|
||||||
|
|||||||
66
backend/app/checker.py
Normal file
66
backend/app/checker.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import json
|
||||||
|
from .db import (
|
||||||
|
_conn, get_draw, update_recommendation_result
|
||||||
|
)
|
||||||
|
|
||||||
|
def _calc_rank(my_nums: list[int], win_nums: list[int], bonus: int) -> tuple[int, int, bool]:
|
||||||
|
"""
|
||||||
|
(rank, correct_cnt, has_bonus) 반환
|
||||||
|
rank: 1~5 (1등~5등), 0 (낙첨)
|
||||||
|
"""
|
||||||
|
matched = set(my_nums) & set(win_nums)
|
||||||
|
cnt = len(matched)
|
||||||
|
has_bonus = bonus in my_nums
|
||||||
|
|
||||||
|
if cnt == 6:
|
||||||
|
return 1, cnt, has_bonus
|
||||||
|
if cnt == 5 and has_bonus:
|
||||||
|
return 2, cnt, has_bonus
|
||||||
|
if cnt == 5:
|
||||||
|
return 3, cnt, has_bonus
|
||||||
|
if cnt == 4:
|
||||||
|
return 4, cnt, has_bonus
|
||||||
|
if cnt == 3:
|
||||||
|
return 5, cnt, has_bonus
|
||||||
|
|
||||||
|
return 0, cnt, has_bonus
|
||||||
|
|
||||||
|
def check_results_for_draw(drw_no: int) -> int:
|
||||||
|
"""
|
||||||
|
특정 회차(drw_no) 결과가 나왔을 때,
|
||||||
|
해당 회차를 타겟으로 했던(based_on_draw == drw_no - 1) 추천들을 채점한다.
|
||||||
|
반환값: 채점한 개수
|
||||||
|
"""
|
||||||
|
win_row = get_draw(drw_no)
|
||||||
|
if not win_row:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
win_nums = [
|
||||||
|
win_row["n1"], win_row["n2"], win_row["n3"],
|
||||||
|
win_row["n4"], win_row["n5"], win_row["n6"]
|
||||||
|
]
|
||||||
|
bonus = win_row["bonus"]
|
||||||
|
|
||||||
|
# based_on_draw가 (이번회차 - 1)인 것들 조회
|
||||||
|
# 즉, 1000회차 추첨 결과로는, 999회차 데이터를 바탕으로 1000회차를 예측한 것들을 채점
|
||||||
|
target_based_on = drw_no - 1
|
||||||
|
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, numbers
|
||||||
|
FROM recommendations
|
||||||
|
WHERE based_on_draw = ? AND checked = 0
|
||||||
|
""",
|
||||||
|
(target_based_on,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for r in rows:
|
||||||
|
my_nums = json.loads(r["numbers"])
|
||||||
|
rank, correct, has_bonus = _calc_rank(my_nums, win_nums, bonus)
|
||||||
|
|
||||||
|
update_recommendation_result(r["id"], rank, correct, has_bonus)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
return count
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import requests
|
import requests
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from .db import get_draw, upsert_draw
|
from .db import get_draw, upsert_draw, upsert_many_draws, get_latest_draw, count_draws
|
||||||
|
|
||||||
def _normalize_item(item: dict) -> dict:
|
def _normalize_item(item: dict) -> dict:
|
||||||
# smok95 all.json / latest.json 구조
|
# smok95 all.json / latest.json 구조
|
||||||
@@ -27,20 +27,13 @@ def sync_all_from_json(all_url: str) -> Dict[str, Any]:
|
|||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
data = r.json() # list[dict]
|
data = r.json() # list[dict]
|
||||||
|
|
||||||
inserted = 0
|
# 정규화
|
||||||
skipped = 0
|
rows = [_normalize_item(item) for item in data]
|
||||||
|
|
||||||
for item in data:
|
# Bulk Insert (성능 향상)
|
||||||
row = _normalize_item(item)
|
upsert_many_draws(rows)
|
||||||
|
|
||||||
if get_draw(row["drw_no"]):
|
return {"mode": "all_json", "url": all_url, "total": len(rows)}
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
upsert_draw(row)
|
|
||||||
inserted += 1
|
|
||||||
|
|
||||||
return {"mode": "all_json", "url": all_url, "inserted": inserted, "skipped": skipped, "total": len(data)}
|
|
||||||
|
|
||||||
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
def sync_latest(latest_url: str) -> Dict[str, Any]:
|
||||||
r = requests.get(latest_url, timeout=30)
|
r = requests.get(latest_url, timeout=30)
|
||||||
@@ -53,3 +46,40 @@ def sync_latest(latest_url: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
return {"mode": "latest_json", "url": latest_url, "was_new": (before is None), "drawNo": row["drw_no"]}
|
return {"mode": "latest_json", "url": latest_url, "was_new": (before is None), "drawNo": row["drw_no"]}
|
||||||
|
|
||||||
|
def sync_ensure_all(latest_url: str, all_url: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
1회부터 최신 회차까지 빠짐없이 있는지 확인하고, 없으면 전체 동기화 수행.
|
||||||
|
반환값: {"synced": bool, "reason": str, ...}
|
||||||
|
"""
|
||||||
|
# 1. 원격 최신 회차 확인
|
||||||
|
try:
|
||||||
|
r = requests.get(latest_url, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
remote_item = r.json()
|
||||||
|
remote_no = int(remote_item["draw_no"])
|
||||||
|
except Exception as e:
|
||||||
|
# 외부 통신 실패 시, 그냥 로컬 데이터로 진행하도록 에러 억제 (혹은 에러 발생)
|
||||||
|
# 여기서는 통계 기능 작동이 우선이므로 로그만 남기고 pass하고 싶지만,
|
||||||
|
# 확실한 동기화를 위해 에러를 던지거나 False 리턴
|
||||||
|
return {"synced": False, "error": str(e)}
|
||||||
|
|
||||||
|
# 2. 로컬 상태 확인
|
||||||
|
local_latest_row = get_latest_draw()
|
||||||
|
local_no = local_latest_row["drw_no"] if local_latest_row else 0
|
||||||
|
local_cnt = count_draws()
|
||||||
|
|
||||||
|
# 3. 동기화 필요 여부 판단
|
||||||
|
# - 전체 개수가 최신 회차 번호보다 적으면 중간에 빈 것 (1회부터 시작한다고 가정)
|
||||||
|
# - 혹은 DB 최신 번호가 원격보다 낮으면 업데이트 필요
|
||||||
|
need_sync = (local_no < remote_no) or (local_cnt < local_no)
|
||||||
|
|
||||||
|
if not need_sync:
|
||||||
|
return {"synced": True, "updated": False, "local_no": local_no}
|
||||||
|
|
||||||
|
# 4. 전체 동기화 실행
|
||||||
|
# (단순 latest sync로는 중간 구멍을 못 채우므로, 구멍이 있거나 차이가 크면 all_sync 수행)
|
||||||
|
# 만약 차이가 1회차 뿐이고 구멍이 없다면 sync_latest만 해도 되지만,
|
||||||
|
# 로직 단순화를 위해 missing 감지 시 그냥 all_sync (Bulk Insert라 빠름)
|
||||||
|
res = sync_all_from_json(all_url)
|
||||||
|
return {"synced": True, "updated": True, "detail": res}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,17 @@ def init_db() -> None:
|
|||||||
_ensure_column(conn, "recommendations", "tags",
|
_ensure_column(conn, "recommendations", "tags",
|
||||||
"ALTER TABLE recommendations ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';")
|
"ALTER TABLE recommendations ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';")
|
||||||
|
|
||||||
|
# ✅ 결과 채점용 컬럼 추가
|
||||||
|
_ensure_column(conn, "recommendations", "rank",
|
||||||
|
"ALTER TABLE recommendations ADD COLUMN rank INTEGER;")
|
||||||
|
_ensure_column(conn, "recommendations", "correct_count",
|
||||||
|
"ALTER TABLE recommendations ADD COLUMN correct_count INTEGER DEFAULT 0;")
|
||||||
|
_ensure_column(conn, "recommendations", "has_bonus",
|
||||||
|
"ALTER TABLE recommendations ADD COLUMN has_bonus INTEGER DEFAULT 0;")
|
||||||
|
_ensure_column(conn, "recommendations", "checked",
|
||||||
|
"ALTER TABLE recommendations ADD COLUMN checked INTEGER DEFAULT 0;")
|
||||||
|
|
||||||
|
|
||||||
# ✅ UNIQUE 인덱스(중복 저장 방지)
|
# ✅ UNIQUE 인덱스(중복 저장 방지)
|
||||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_reco_dedup ON recommendations(dedup_hash);")
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_reco_dedup ON recommendations(dedup_hash);")
|
||||||
|
|
||||||
@@ -88,6 +99,30 @@ def upsert_draw(row: Dict[str, Any]) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def upsert_many_draws(rows: List[Dict[str, Any]]) -> None:
|
||||||
|
data = [
|
||||||
|
(
|
||||||
|
int(r["drw_no"]), str(r["drw_date"]),
|
||||||
|
int(r["n1"]), int(r["n2"]), int(r["n3"]),
|
||||||
|
int(r["n4"]), int(r["n5"]), int(r["n6"]),
|
||||||
|
int(r["bonus"])
|
||||||
|
) for r in rows
|
||||||
|
]
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO draws (drw_no, drw_date, n1, n2, n3, n4, n5, n6, bonus)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(drw_no) DO UPDATE SET
|
||||||
|
drw_date=excluded.drw_date,
|
||||||
|
n1=excluded.n1, n2=excluded.n2, n3=excluded.n3,
|
||||||
|
n4=excluded.n4, n5=excluded.n5, n6=excluded.n6,
|
||||||
|
bonus=excluded.bonus,
|
||||||
|
updated_at=datetime('now')
|
||||||
|
""",
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
def get_latest_draw() -> Optional[Dict[str, Any]]:
|
def get_latest_draw() -> Optional[Dict[str, Any]]:
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
r = conn.execute("SELECT * FROM draws ORDER BY drw_no DESC LIMIT 1").fetchone()
|
r = conn.execute("SELECT * FROM draws ORDER BY drw_no DESC LIMIT 1").fetchone()
|
||||||
@@ -237,3 +272,15 @@ def delete_recommendation(rec_id: int) -> bool:
|
|||||||
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
|
cur = conn.execute("DELETE FROM recommendations WHERE id = ?", (rec_id,))
|
||||||
return cur.rowcount > 0
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
def update_recommendation_result(rec_id: int, rank: int, correct_count: int, has_bonus: bool) -> bool:
|
||||||
|
with _conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE recommendations
|
||||||
|
SET rank = ?, correct_count = ?, has_bonus = ?, checked = 1
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(rank, correct_count, 1 if has_bonus else 0, rec_id)
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|||||||
100
backend/app/generator.py
Normal file
100
backend/app/generator.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import random
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
|
from .db import _conn, save_recommendation_dedup, get_latest_draw, get_all_draw_numbers
|
||||||
|
from .recommender import recommend_numbers
|
||||||
|
from .utils import calc_metrics, calc_recent_overlap
|
||||||
|
|
||||||
|
# 순환 참조 방지를 위해 main.py의 calc_metrics 등을 utils.py가 아닌 여기서 재정의하거나
|
||||||
|
# main.py에서 generator를 import할 때 함수 내부에서 하도록 처리.
|
||||||
|
# 여기서는 코드가 중복되더라도 안전하게 독립적으로 구현하거나, db/collector만 import.
|
||||||
|
|
||||||
|
def _get_top_performing_params(limit: int = 20) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
최근 1~5등에 당첨된 추천들의 파라미터 조회
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
SELECT params
|
||||||
|
FROM recommendations
|
||||||
|
WHERE rank > 0 AND rank <= 5
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
with _conn() as conn:
|
||||||
|
rows = conn.execute(sql, (limit,)).fetchall()
|
||||||
|
|
||||||
|
return [json.loads(r["params"]) for r in rows]
|
||||||
|
|
||||||
|
def _perturb_param(val: float, delta: float, min_val: float, max_val: float, is_int: bool = False) -> float:
|
||||||
|
change = random.uniform(-delta, delta)
|
||||||
|
new_val = val + change
|
||||||
|
new_val = max(min_val, min(new_val, max_val))
|
||||||
|
return int(round(new_val)) if is_int else round(new_val, 2)
|
||||||
|
|
||||||
|
def generate_smart_recommendations(count: int = 10) -> int:
|
||||||
|
"""
|
||||||
|
지능형 자동 생성: 과거 성적 우수 파라미터 기반으로 생성
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
latest = get_latest_draw()
|
||||||
|
based_on = latest["drw_no"] if latest else None
|
||||||
|
|
||||||
|
# 1. 성공 사례 조회 (Feedback)
|
||||||
|
top_params = _get_top_performing_params()
|
||||||
|
|
||||||
|
generated_count = 0
|
||||||
|
|
||||||
|
for _ in range(count):
|
||||||
|
# 전략 선택: 이력이 있으면 70% 확률로 모방(Exploitation), 30%는 랜덤(Exploration)
|
||||||
|
use_history = (len(top_params) > 0) and (random.random() < 0.7)
|
||||||
|
|
||||||
|
if use_history:
|
||||||
|
# 과거 우수 파라미터 중 하나 선택하여 변형
|
||||||
|
base = random.choice(top_params)
|
||||||
|
|
||||||
|
# 파라미터 변형 (유전 알고리즘과 유사)
|
||||||
|
p_window = _perturb_param(base.get("recent_window", 200), 50, 10, 500, True)
|
||||||
|
p_weight = _perturb_param(base.get("recent_weight", 2.0), 1.0, 0.1, 10.0, False)
|
||||||
|
p_avoid = _perturb_param(base.get("avoid_recent_k", 5), 2, 0, 20, True)
|
||||||
|
|
||||||
|
# Constraints 로직은 복잡하니 일단 랜덤성 부여하거나 유지
|
||||||
|
# (여기서는 기본 파라미터 위주로 튜닝)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"recent_window": p_window,
|
||||||
|
"recent_weight": p_weight,
|
||||||
|
"avoid_recent_k": p_avoid,
|
||||||
|
"strategy": "smart_feedback"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# 완전 랜덤 탐색
|
||||||
|
params = {
|
||||||
|
"recent_window": random.randint(50, 400),
|
||||||
|
"recent_weight": round(random.uniform(0.5, 5.0), 2),
|
||||||
|
"avoid_recent_k": random.randint(0, 10),
|
||||||
|
"strategy": "random_exploration"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 생성 시도
|
||||||
|
try:
|
||||||
|
# recommend_numbers는 db.py/main.py 로직과 독립적이므로 여기서 사용 가능
|
||||||
|
# 단, recommend_numbers 함수가 어디 있는지 확인 (recommender.py)
|
||||||
|
res = recommend_numbers(
|
||||||
|
draws,
|
||||||
|
recent_window=params["recent_window"],
|
||||||
|
recent_weight=params["recent_weight"],
|
||||||
|
avoid_recent_k=params["avoid_recent_k"]
|
||||||
|
)
|
||||||
|
|
||||||
|
save_recommendation_dedup(based_on, res["numbers"], params)
|
||||||
|
generated_count += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Gen Error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return generated_count
|
||||||
@@ -9,8 +9,12 @@ from .db import (
|
|||||||
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
|
||||||
update_recommendation,
|
update_recommendation,
|
||||||
)
|
)
|
||||||
from .recommender import recommend_numbers
|
from .recommender import recommend_numbers, recommend_with_heatmap
|
||||||
from .collector import sync_latest
|
from .collector import sync_latest, sync_ensure_all
|
||||||
|
from .generator import generate_smart_recommendations
|
||||||
|
from .generator import generate_smart_recommendations
|
||||||
|
from .checker import check_results_for_draw
|
||||||
|
from .utils import calc_metrics, calc_recent_overlap
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||||
@@ -18,68 +22,23 @@ scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
|||||||
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
|
ALL_URL = os.getenv("LOTTO_ALL_URL", "https://smok95.github.io/lotto/results/all.json")
|
||||||
LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json")
|
LATEST_URL = os.getenv("LOTTO_LATEST_URL", "https://smok95.github.io/lotto/results/latest.json")
|
||||||
|
|
||||||
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
|
|
||||||
nums = sorted(numbers)
|
|
||||||
s = sum(nums)
|
|
||||||
odd = sum(1 for x in nums if x % 2 == 1)
|
|
||||||
even = len(nums) - odd
|
|
||||||
mn, mx = nums[0], nums[-1]
|
|
||||||
rng = mx - mn
|
|
||||||
|
|
||||||
# 1-10, 11-20, 21-30, 31-40, 41-45
|
|
||||||
buckets = {
|
|
||||||
"1-10": 0,
|
|
||||||
"11-20": 0,
|
|
||||||
"21-30": 0,
|
|
||||||
"31-40": 0,
|
|
||||||
"41-45": 0,
|
|
||||||
}
|
|
||||||
for x in nums:
|
|
||||||
if 1 <= x <= 10:
|
|
||||||
buckets["1-10"] += 1
|
|
||||||
elif 11 <= x <= 20:
|
|
||||||
buckets["11-20"] += 1
|
|
||||||
elif 21 <= x <= 30:
|
|
||||||
buckets["21-30"] += 1
|
|
||||||
elif 31 <= x <= 40:
|
|
||||||
buckets["31-40"] += 1
|
|
||||||
else:
|
|
||||||
buckets["41-45"] += 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"sum": s,
|
|
||||||
"odd": odd,
|
|
||||||
"even": even,
|
|
||||||
"min": mn,
|
|
||||||
"max": mx,
|
|
||||||
"range": rng,
|
|
||||||
"buckets": buckets,
|
|
||||||
}
|
|
||||||
|
|
||||||
def calc_recent_overlap(numbers: List[int], draws: List[Tuple[int, List[int]]], last_k: int) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
draws: [(drw_no, [n1..n6]), ...] 오름차순
|
|
||||||
last_k: 최근 k회 기준 중복
|
|
||||||
"""
|
|
||||||
if last_k <= 0:
|
|
||||||
return {"last_k": 0, "repeats": 0, "repeated_numbers": []}
|
|
||||||
|
|
||||||
recent = draws[-last_k:] if len(draws) >= last_k else draws
|
|
||||||
recent_set = set()
|
|
||||||
for _, nums in recent:
|
|
||||||
recent_set.update(nums)
|
|
||||||
|
|
||||||
repeated = sorted(set(numbers) & recent_set)
|
|
||||||
return {
|
|
||||||
"last_k": len(recent),
|
|
||||||
"repeats": len(repeated),
|
|
||||||
"repeated_numbers": repeated,
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup():
|
def on_startup():
|
||||||
init_db()
|
init_db()
|
||||||
scheduler.add_job(lambda: sync_latest(LATEST_URL), "cron", hour="9,21", minute=10)
|
|
||||||
|
# 1. 로또 당첨번호 동기화 (매일 9시, 21시 10분)
|
||||||
|
# 동기화 후 새로운 회차가 있으면 채점(check)까지 수행
|
||||||
|
def _sync_and_check():
|
||||||
|
res = sync_latest(LATEST_URL)
|
||||||
|
if res["was_new"]:
|
||||||
|
# 새로운 회차(예: 1000회)가 나오면, 999회차 기반 추천들을 채점
|
||||||
|
check_results_for_draw(res["drawNo"])
|
||||||
|
|
||||||
|
scheduler.add_job(_sync_and_check, "cron", hour="9,21", minute=10)
|
||||||
|
|
||||||
|
# 2. 매일 아침 8시: 지능형 자동 추천 (10개씩)
|
||||||
|
scheduler.add_job(lambda: generate_smart_recommendations(10), "cron", hour="8", minute=0)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -96,6 +55,7 @@ def api_latest():
|
|||||||
"date": row["drw_date"],
|
"date": row["drw_date"],
|
||||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||||
"bonus": row["bonus"],
|
"bonus": row["bonus"],
|
||||||
|
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.get("/api/lotto/{drw_no:int}")
|
@app.get("/api/lotto/{drw_no:int}")
|
||||||
@@ -108,11 +68,53 @@ def api_draw(drw_no: int):
|
|||||||
"date": row["drw_date"],
|
"date": row["drw_date"],
|
||||||
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
|
||||||
"bonus": row["bonus"],
|
"bonus": row["bonus"],
|
||||||
|
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
|
||||||
}
|
}
|
||||||
|
|
||||||
@app.post("/api/admin/sync_latest")
|
@app.post("/api/admin/sync_latest")
|
||||||
def admin_sync_latest():
|
def admin_sync_latest():
|
||||||
return sync_latest(LATEST_URL)
|
res = sync_latest(LATEST_URL)
|
||||||
|
# 수동 동기화 시에도 신규 회차면 채점
|
||||||
|
if res["was_new"]:
|
||||||
|
check_results_for_draw(res["drawNo"])
|
||||||
|
return res
|
||||||
|
|
||||||
|
@app.post("/api/admin/auto_gen")
|
||||||
|
def admin_auto_gen(count: int = 10):
|
||||||
|
"""지능형 자동 생성 수동 트리거"""
|
||||||
|
n = generate_smart_recommendations(count)
|
||||||
|
return {"generated": n}
|
||||||
|
|
||||||
|
@app.get("/api/lotto/stats")
|
||||||
|
def api_stats():
|
||||||
|
# 1. 데이터 완전성 보장 (없으면 가져옴)
|
||||||
|
sync_ensure_all(LATEST_URL, ALL_URL)
|
||||||
|
|
||||||
|
# 2. 전체 데이터 조회
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
|
||||||
|
# 1~45번 빈도 초기화
|
||||||
|
frequency = {n: 0 for n in range(1, 46)}
|
||||||
|
|
||||||
|
total_draws = len(draws)
|
||||||
|
|
||||||
|
for _, nums in draws:
|
||||||
|
for n in nums:
|
||||||
|
frequency[n] += 1
|
||||||
|
|
||||||
|
# 리스트 형태로 변환 (프론트엔드 차트용)
|
||||||
|
# x: 번호, y: 횟수
|
||||||
|
stats = [
|
||||||
|
{"number": n, "count": frequency[n]}
|
||||||
|
for n in range(1, 46)
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_draws": total_draws,
|
||||||
|
"frequency": stats
|
||||||
|
}
|
||||||
|
|
||||||
# ---------- ✅ recommend (dedup save) ----------
|
# ---------- ✅ recommend (dedup save) ----------
|
||||||
@app.get("/api/lotto/recommend")
|
@app.get("/api/lotto/recommend")
|
||||||
@@ -221,6 +223,124 @@ def api_recommend(
|
|||||||
"tries": tries,
|
"tries": tries,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------- ✅ heatmap-based recommend ----------
|
||||||
|
@app.get("/api/lotto/recommend/heatmap")
|
||||||
|
def api_recommend_heatmap(
|
||||||
|
heatmap_window: int = 20,
|
||||||
|
heatmap_weight: float = 1.5,
|
||||||
|
recent_window: int = 200,
|
||||||
|
recent_weight: float = 2.0,
|
||||||
|
avoid_recent_k: int = 5,
|
||||||
|
|
||||||
|
# ---- optional constraints ----
|
||||||
|
sum_min: Optional[int] = None,
|
||||||
|
sum_max: Optional[int] = None,
|
||||||
|
odd_min: Optional[int] = None,
|
||||||
|
odd_max: Optional[int] = None,
|
||||||
|
range_min: Optional[int] = None,
|
||||||
|
range_max: Optional[int] = None,
|
||||||
|
max_overlap_latest: Optional[int] = None,
|
||||||
|
max_try: int = 200,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
히트맵 기반 추천: 과거 추천 번호들의 적중률을 분석하여 가중치 부여
|
||||||
|
"""
|
||||||
|
draws = get_all_draw_numbers()
|
||||||
|
if not draws:
|
||||||
|
raise HTTPException(status_code=404, detail="No data yet")
|
||||||
|
|
||||||
|
# 과거 추천 데이터 가져오기 (적중 결과가 있는 것만)
|
||||||
|
past_recs = list_recommendations_ex(limit=100, sort="id_desc")
|
||||||
|
|
||||||
|
latest = get_latest_draw()
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"heatmap_window": heatmap_window,
|
||||||
|
"heatmap_weight": float(heatmap_weight),
|
||||||
|
"recent_window": recent_window,
|
||||||
|
"recent_weight": float(recent_weight),
|
||||||
|
"avoid_recent_k": avoid_recent_k,
|
||||||
|
"sum_min": sum_min,
|
||||||
|
"sum_max": sum_max,
|
||||||
|
"odd_min": odd_min,
|
||||||
|
"odd_max": odd_max,
|
||||||
|
"range_min": range_min,
|
||||||
|
"range_max": range_max,
|
||||||
|
"max_overlap_latest": max_overlap_latest,
|
||||||
|
"max_try": int(max_try),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _accept(nums: List[int]) -> bool:
|
||||||
|
m = calc_metrics(nums)
|
||||||
|
if sum_min is not None and m["sum"] < sum_min:
|
||||||
|
return False
|
||||||
|
if sum_max is not None and m["sum"] > sum_max:
|
||||||
|
return False
|
||||||
|
if odd_min is not None and m["odd"] < odd_min:
|
||||||
|
return False
|
||||||
|
if odd_max is not None and m["odd"] > odd_max:
|
||||||
|
return False
|
||||||
|
if range_min is not None and m["range"] < range_min:
|
||||||
|
return False
|
||||||
|
if range_max is not None and m["range"] > range_max:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if max_overlap_latest is not None:
|
||||||
|
ov = calc_recent_overlap(nums, draws, last_k=avoid_recent_k)
|
||||||
|
if ov["repeats"] > max_overlap_latest:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
chosen = None
|
||||||
|
explain = None
|
||||||
|
|
||||||
|
tries = 0
|
||||||
|
while tries < max_try:
|
||||||
|
tries += 1
|
||||||
|
result = recommend_with_heatmap(
|
||||||
|
draws,
|
||||||
|
past_recs,
|
||||||
|
heatmap_window=heatmap_window,
|
||||||
|
heatmap_weight=heatmap_weight,
|
||||||
|
recent_window=recent_window,
|
||||||
|
recent_weight=recent_weight,
|
||||||
|
avoid_recent_k=avoid_recent_k,
|
||||||
|
)
|
||||||
|
nums = result["numbers"]
|
||||||
|
if _accept(nums):
|
||||||
|
chosen = nums
|
||||||
|
explain = result["explain"]
|
||||||
|
break
|
||||||
|
|
||||||
|
if chosen is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Constraints too strict. No valid set found in max_try={max_try}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ✅ dedup save
|
||||||
|
saved = save_recommendation_dedup(
|
||||||
|
latest["drw_no"] if latest else None,
|
||||||
|
chosen,
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
|
||||||
|
metrics = calc_metrics(chosen)
|
||||||
|
overlap = calc_recent_overlap(chosen, draws, last_k=avoid_recent_k)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": saved["id"],
|
||||||
|
"saved": saved["saved"],
|
||||||
|
"deduped": saved["deduped"],
|
||||||
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||||
|
"numbers": chosen,
|
||||||
|
"explain": explain,
|
||||||
|
"params": params,
|
||||||
|
"metrics": metrics,
|
||||||
|
"recent_overlap": overlap,
|
||||||
|
"tries": tries,
|
||||||
|
}
|
||||||
|
|
||||||
# ---------- ✅ history list (filter/paging) ----------
|
# ---------- ✅ history list (filter/paging) ----------
|
||||||
@app.get("/api/history")
|
@app.get("/api/history")
|
||||||
def api_history(
|
def api_history(
|
||||||
@@ -322,7 +442,11 @@ def api_recommend_batch(
|
|||||||
return {
|
return {
|
||||||
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
"based_on_latest_draw": latest["drw_no"] if latest else None,
|
||||||
"count": count,
|
"count": count,
|
||||||
"items": [{"numbers": it["numbers"], "explain": it["explain"]} for it in items],
|
"items": [{
|
||||||
|
"numbers": it["numbers"],
|
||||||
|
"explain": it["explain"],
|
||||||
|
"metrics": calc_metrics(it["numbers"]),
|
||||||
|
} for it in items],
|
||||||
"params": params,
|
"params": params,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,3 +466,7 @@ def api_recommend_batch_save(body: BatchSave):
|
|||||||
|
|
||||||
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
def version():
|
||||||
|
import os
|
||||||
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||||
|
|||||||
@@ -66,3 +66,98 @@ def recommend_numbers(
|
|||||||
|
|
||||||
return {"numbers": chosen_sorted, "explain": explain}
|
return {"numbers": chosen_sorted, "explain": explain}
|
||||||
|
|
||||||
|
|
||||||
|
def recommend_with_heatmap(
|
||||||
|
draws: List[Tuple[int, List[int]]],
|
||||||
|
past_recommendations: List[Dict[str, Any]],
|
||||||
|
*,
|
||||||
|
heatmap_window: int = 10,
|
||||||
|
heatmap_weight: float = 1.5,
|
||||||
|
recent_window: int = 200,
|
||||||
|
recent_weight: float = 2.0,
|
||||||
|
avoid_recent_k: int = 5,
|
||||||
|
seed: int | None = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
히트맵 기반 가중치 추천:
|
||||||
|
- 과거 추천 번호들의 적중률을 분석하여 잘 맞춘 번호에 가중치 부여
|
||||||
|
- 기존 통계 기반 추천과 결합
|
||||||
|
|
||||||
|
Args:
|
||||||
|
draws: 실제 당첨 번호 리스트 [(회차, [번호들]), ...]
|
||||||
|
past_recommendations: 과거 추천 데이터 [{"numbers": [...], "correct_count": N, "based_on_draw": M}, ...]
|
||||||
|
heatmap_window: 히트맵 분석할 최근 추천 개수
|
||||||
|
heatmap_weight: 히트맵 가중치 (높을수록 과거 적중 번호 선호)
|
||||||
|
"""
|
||||||
|
if seed is not None:
|
||||||
|
random.seed(seed)
|
||||||
|
|
||||||
|
# 1. 기존 통계 기반 가중치 계산
|
||||||
|
all_nums = [n for _, nums in draws for n in nums]
|
||||||
|
freq_all = Counter(all_nums)
|
||||||
|
|
||||||
|
recent = draws[-recent_window:] if len(draws) >= recent_window else draws
|
||||||
|
recent_nums = [n for _, nums in recent for n in nums]
|
||||||
|
freq_recent = Counter(recent_nums)
|
||||||
|
|
||||||
|
last_k = draws[-avoid_recent_k:] if len(draws) >= avoid_recent_k else draws
|
||||||
|
last_k_nums = set(n for _, nums in last_k for n in nums)
|
||||||
|
|
||||||
|
# 2. 히트맵 생성: 과거 추천에서 적중한 번호들의 빈도
|
||||||
|
heatmap = Counter()
|
||||||
|
recent_recs = past_recommendations[-heatmap_window:] if len(past_recommendations) >= heatmap_window else past_recommendations
|
||||||
|
|
||||||
|
for rec in recent_recs:
|
||||||
|
if rec.get("correct_count", 0) > 0: # 적중한 추천만
|
||||||
|
# 적중 개수에 비례해서 가중치 부여 (많이 맞춘 추천일수록 높은 가중)
|
||||||
|
weight = rec["correct_count"] ** 1.5 # 제곱으로 강조
|
||||||
|
for num in rec["numbers"]:
|
||||||
|
heatmap[num] += weight
|
||||||
|
|
||||||
|
# 3. 최종 가중치 = 기존 통계 + 히트맵
|
||||||
|
weights = {}
|
||||||
|
for n in range(1, 46):
|
||||||
|
w = freq_all[n] + recent_weight * freq_recent[n]
|
||||||
|
|
||||||
|
# 히트맵 가중치 추가
|
||||||
|
if n in heatmap:
|
||||||
|
w += heatmap_weight * heatmap[n]
|
||||||
|
|
||||||
|
# 최근 출현 번호 패널티
|
||||||
|
if n in last_k_nums:
|
||||||
|
w *= 0.6
|
||||||
|
|
||||||
|
weights[n] = max(w, 0.1)
|
||||||
|
|
||||||
|
# 4. 가중 샘플링으로 6개 선택
|
||||||
|
chosen = []
|
||||||
|
pool = list(range(1, 46))
|
||||||
|
for _ in range(6):
|
||||||
|
total = sum(weights[n] for n in pool)
|
||||||
|
r = random.random() * total
|
||||||
|
acc = 0.0
|
||||||
|
for n in pool:
|
||||||
|
acc += weights[n]
|
||||||
|
if acc >= r:
|
||||||
|
chosen.append(n)
|
||||||
|
pool.remove(n)
|
||||||
|
break
|
||||||
|
|
||||||
|
chosen_sorted = sorted(chosen)
|
||||||
|
|
||||||
|
# 5. 설명 데이터
|
||||||
|
explain = {
|
||||||
|
"recent_window": recent_window,
|
||||||
|
"recent_weight": recent_weight,
|
||||||
|
"avoid_recent_k": avoid_recent_k,
|
||||||
|
"heatmap_window": heatmap_window,
|
||||||
|
"heatmap_weight": heatmap_weight,
|
||||||
|
"top_all": [n for n, _ in freq_all.most_common(10)],
|
||||||
|
"top_recent": [n for n, _ in freq_recent.most_common(10)],
|
||||||
|
"top_heatmap": [n for n, _ in heatmap.most_common(10)],
|
||||||
|
"last_k_draws": [d for d, _ in last_k],
|
||||||
|
"analyzed_recommendations": len(recent_recs),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"numbers": chosen_sorted, "explain": explain}
|
||||||
|
|
||||||
|
|||||||
59
backend/app/utils.py
Normal file
59
backend/app/utils.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
from typing import List, Dict, Any, Tuple
|
||||||
|
|
||||||
|
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
|
||||||
|
nums = sorted(numbers)
|
||||||
|
s = sum(nums)
|
||||||
|
odd = sum(1 for x in nums if x % 2 == 1)
|
||||||
|
even = len(nums) - odd
|
||||||
|
mn, mx = nums[0], nums[-1]
|
||||||
|
rng = mx - mn
|
||||||
|
|
||||||
|
# 1-10, 11-20, 21-30, 31-40, 41-45
|
||||||
|
buckets = {
|
||||||
|
"1-10": 0,
|
||||||
|
"11-20": 0,
|
||||||
|
"21-30": 0,
|
||||||
|
"31-40": 0,
|
||||||
|
"41-45": 0,
|
||||||
|
}
|
||||||
|
for x in nums:
|
||||||
|
if 1 <= x <= 10:
|
||||||
|
buckets["1-10"] += 1
|
||||||
|
elif 11 <= x <= 20:
|
||||||
|
buckets["11-20"] += 1
|
||||||
|
elif 21 <= x <= 30:
|
||||||
|
buckets["21-30"] += 1
|
||||||
|
elif 31 <= x <= 40:
|
||||||
|
buckets["31-40"] += 1
|
||||||
|
else:
|
||||||
|
buckets["41-45"] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sum": s,
|
||||||
|
"odd": odd,
|
||||||
|
"even": even,
|
||||||
|
"min": mn,
|
||||||
|
"max": mx,
|
||||||
|
"range": rng,
|
||||||
|
"buckets": buckets,
|
||||||
|
}
|
||||||
|
|
||||||
|
def calc_recent_overlap(numbers: List[int], draws: List[Tuple[int, List[int]]], last_k: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
draws: [(drw_no, [n1..n6]), ...] 오름차순
|
||||||
|
last_k: 최근 k회 기준 중복
|
||||||
|
"""
|
||||||
|
if last_k <= 0:
|
||||||
|
return {"last_k": 0, "repeats": 0, "repeated_numbers": []}
|
||||||
|
|
||||||
|
recent = draws[-last_k:] if len(draws) >= last_k else draws
|
||||||
|
recent_set = set()
|
||||||
|
for _, nums in recent:
|
||||||
|
recent_set.update(nums)
|
||||||
|
|
||||||
|
repeated = sorted(set(numbers) & recent_set)
|
||||||
|
return {
|
||||||
|
"last_k": len(recent),
|
||||||
|
"repeats": len(repeated),
|
||||||
|
"repeated_numbers": repeated,
|
||||||
|
}
|
||||||
16
deployer/Dockerfile
Normal file
16
deployer/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git rsync ca-certificates curl \
|
||||||
|
docker.io \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app.py /app/app.py
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
EXPOSE 9000
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "9000"]
|
||||||
52
deployer/app.py
Normal file
52
deployer/app.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import os, hmac, hashlib, subprocess
|
||||||
|
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger("deployer")
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
SECRET = os.getenv("WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
|
def verify(sig: str, body: bytes) -> bool:
|
||||||
|
if not SECRET or not sig:
|
||||||
|
return False
|
||||||
|
|
||||||
|
mac = hmac.new(SECRET.encode(), msg=body, digestmod=hashlib.sha256).hexdigest()
|
||||||
|
candidates = {mac, f"sha256={mac}"}
|
||||||
|
return any(hmac.compare_digest(sig, c) for c in candidates)
|
||||||
|
|
||||||
|
def run_deploy_script():
|
||||||
|
"""배포 스크립트를 백그라운드에서 실행하고 로그를 남김"""
|
||||||
|
logger.info("Starting deployment script...")
|
||||||
|
try:
|
||||||
|
# 타임아웃 10분 설정
|
||||||
|
p = subprocess.run(["/bin/bash", "/scripts/deploy.sh"], capture_output=True, text=True, timeout=600)
|
||||||
|
|
||||||
|
if p.returncode == 0:
|
||||||
|
logger.info(f"Deployment SUCCESS:\n{p.stdout}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Deployment FAILED ({p.returncode}):\n{p.stdout}\n{p.stderr}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Exception during deployment: {e}")
|
||||||
|
|
||||||
|
@app.post("/webhook")
|
||||||
|
async def webhook(req: Request, background_tasks: BackgroundTasks):
|
||||||
|
body = await req.body()
|
||||||
|
|
||||||
|
sig = (
|
||||||
|
req.headers.get("X-Gitea-Signature")
|
||||||
|
or req.headers.get("X-Hub-Signature-256")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
if not verify(sig, body):
|
||||||
|
raise HTTPException(401, "bad signature")
|
||||||
|
|
||||||
|
# ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행
|
||||||
|
background_tasks.add_task(run_deploy_script)
|
||||||
|
|
||||||
|
return {"ok": True, "message": "Deployment started in background"}
|
||||||
|
|
||||||
2
deployer/requirements.txt
Normal file
2
deployer/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
@@ -2,7 +2,10 @@ version: "3.8"
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
build: ./backend
|
build:
|
||||||
|
context: ./backend
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
container_name: lotto-backend
|
container_name: lotto-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -12,13 +15,28 @@ services:
|
|||||||
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
- LOTTO_ALL_URL=${LOTTO_ALL_URL:-https://smok95.github.io/lotto/results/all.json}
|
||||||
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
- LOTTO_LATEST_URL=${LOTTO_LATEST_URL:-https://smok95.github.io/lotto/results/latest.json}
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/docker/webpage/data:/app/data
|
- ${RUNTIME_PATH}/data:/app/data
|
||||||
|
|
||||||
|
stock-lab:
|
||||||
|
build:
|
||||||
|
context: ./stock-lab
|
||||||
|
args:
|
||||||
|
APP_VERSION: ${APP_VERSION:-dev}
|
||||||
|
container_name: stock-lab
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "18500:8000"
|
||||||
|
environment:
|
||||||
|
- TZ=${TZ:-Asia/Seoul}
|
||||||
|
- WINDOWS_AI_SERVER_URL=${WINDOWS_AI_SERVER_URL:-http://192.168.0.5:8000}
|
||||||
|
volumes:
|
||||||
|
- ${STOCK_DATA_PATH:-./data/stock}:/app/data
|
||||||
|
|
||||||
travel-proxy:
|
travel-proxy:
|
||||||
build: ./travel-proxy
|
build: ./travel-proxy
|
||||||
container_name: travel-proxy
|
container_name: travel-proxy
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "1026:100"
|
user: "${PUID}:${PGID}"
|
||||||
ports:
|
ports:
|
||||||
- "19000:8000" # 내부 확인용
|
- "19000:8000" # 내부 확인용
|
||||||
environment:
|
environment:
|
||||||
@@ -29,8 +47,8 @@ services:
|
|||||||
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
|
||||||
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
|
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:rw
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
@@ -39,9 +57,23 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
- /volume1/docker/webpage/frontend:/usr/share/nginx/html:ro
|
- ${FRONTEND_PATH}:/usr/share/nginx/html:ro
|
||||||
- /volume1/docker/webpage/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
- /volume1/web/images/webPage/travel:/data/travel:ro
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:ro
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
deployer:
|
||||||
|
build: ./deployer
|
||||||
|
container_name: webpage-deployer
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "19010:9000" # 외부 노출 필요 없으면 내부만 (리버스프록시로 /webhook만 노출 추천)
|
||||||
|
environment:
|
||||||
|
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
|
||||||
|
volumes:
|
||||||
|
- ${REPO_PATH}:/repo:rw
|
||||||
|
- ${RUNTIME_PATH}:/runtime:rw
|
||||||
|
- ${RUNTIME_PATH}/scripts:/scripts:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|||||||
@@ -54,6 +54,27 @@ server {
|
|||||||
proxy_pass http://travel-proxy:8000/api/travel/;
|
proxy_pass http://travel-proxy:8000/api/travel/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# stock API
|
||||||
|
location /api/stock/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
proxy_pass http://stock-lab:8000/api/stock/;
|
||||||
|
}
|
||||||
|
|
||||||
|
# trade API (Stock Lab Proxy)
|
||||||
|
location /api/trade/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
proxy_pass http://stock-lab:8000/api/trade/;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# API 프록시 (여기가 포인트: /api/ 중복 제거)
|
# API 프록시 (여기가 포인트: /api/ 중복 제거)
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -66,6 +87,27 @@ server {
|
|||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# webhook receiver (handle both /webhook and /webhook/)
|
||||||
|
location = /webhook {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
|
||||||
|
proxy_pass http://deployer:9000/webhook;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /webhook/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
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;
|
||||||
|
|
||||||
|
proxy_pass http://deployer:9000/webhook;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA 라우팅 (마지막에 두는 게 안전)
|
# SPA 라우팅 (마지막에 두는 게 안전)
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT="/volume1/docker/webpage"
|
|
||||||
|
|
||||||
cd "$ROOT"
|
|
||||||
|
|
||||||
echo "[1/5] git fetch + pull"
|
|
||||||
git fetch --all --prune
|
|
||||||
git pull --ff-only
|
|
||||||
|
|
||||||
echo "[2/5] docker compose build"
|
|
||||||
docker compose build --pull
|
|
||||||
|
|
||||||
echo "[3/5] docker compose up"
|
|
||||||
docker compose up -d
|
|
||||||
|
|
||||||
echo "[4/5] status"
|
|
||||||
docker compose ps
|
|
||||||
|
|
||||||
echo "[5/5] done"
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
BASE="http://127.0.0.1"
|
|
||||||
|
|
||||||
echo "backend health:"
|
|
||||||
curl -fsS "${BASE}:18000/health" | sed 's/^/ /'
|
|
||||||
|
|
||||||
echo "backend latest:"
|
|
||||||
curl -fsS "${BASE}:18000/api/lotto/latest" | head -c 200; echo
|
|
||||||
|
|
||||||
echo "travel regions:"
|
|
||||||
curl -fsS "${BASE}:19000/api/travel/regions" | head -c 200; echo
|
|
||||||
|
|
||||||
echo "OK"
|
|
||||||
59
scripts/deploy-nas.sh
Normal file
59
scripts/deploy-nas.sh
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||||
|
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||||
|
echo "Detected Docker Container environment."
|
||||||
|
SRC="/repo"
|
||||||
|
DST="/runtime"
|
||||||
|
else
|
||||||
|
# 2. Host 환경: .env 로드 시도
|
||||||
|
if [ -f ".env" ]; then
|
||||||
|
echo "Loading .env file..."
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 환경변수가 없으면 현재 디렉토리를 SRC로
|
||||||
|
SRC="${REPO_PATH:-$(pwd)}"
|
||||||
|
DST="${RUNTIME_PATH:-}"
|
||||||
|
|
||||||
|
if [ -z "$DST" ]; then
|
||||||
|
echo "Error: RUNTIME_PATH is not set. Please create .env file with RUNTIME_PATH defined."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Source: $SRC"
|
||||||
|
echo "Target: $DST"
|
||||||
|
|
||||||
|
cd "$SRC"
|
||||||
|
|
||||||
|
# 레포에서 운영으로 반영할 항목들만 복사/동기화 (필요한 것만 적기)
|
||||||
|
# backend, travel-proxy, deployer, nginx, scripts, docker-compose.yml, .env 등
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
--exclude ".releases" \
|
||||||
|
"$SRC/backend/" "$DST/backend/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/travel-proxy/" "$DST/travel-proxy/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/deployer/" "$DST/deployer/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/stock-lab/" "$DST/stock-lab/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/nginx/" "$DST/nginx/"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".git" \
|
||||||
|
"$SRC/scripts/" "$DST/scripts/"
|
||||||
|
|
||||||
|
# compose 파일 / env 파일
|
||||||
|
rsync -a "$SRC/docker-compose.yml" "$DST/docker-compose.yml"
|
||||||
|
if [ -f "$SRC/.env" ]; then
|
||||||
|
rsync -a "$SRC/.env" "$DST/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SYNC_OK"
|
||||||
64
scripts/deploy.sh
Normal file
64
scripts/deploy.sh
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 1. 자동 감지: Docker 컨테이너 내부인가?
|
||||||
|
if [ -d "/repo" ] && [ -d "/runtime" ]; then
|
||||||
|
echo "Detected Docker Container environment."
|
||||||
|
SRC="/repo"
|
||||||
|
DST="/runtime"
|
||||||
|
else
|
||||||
|
# 2. Host 환경: .env 로드 시도
|
||||||
|
if [ -f ".env" ]; then
|
||||||
|
echo "Loading .env file..."
|
||||||
|
set -a; source .env; set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 환경변수가 없으면 현재 디렉토리를 SRC로
|
||||||
|
SRC="${REPO_PATH:-$(pwd)}"
|
||||||
|
DST="${RUNTIME_PATH:-/volume1/docker/webpage}" # 기본값 설정
|
||||||
|
|
||||||
|
if [ -z "$DST" ]; then
|
||||||
|
echo "Error: RUNTIME_PATH is not set."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Source: $SRC"
|
||||||
|
echo "Target: $DST"
|
||||||
|
|
||||||
|
git config --global --add safe.directory "$SRC"
|
||||||
|
|
||||||
|
cd "$SRC"
|
||||||
|
git fetch --all --prune
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
# 릴리즈 백업(롤백용): 아래 5번과 연결
|
||||||
|
TAG="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mkdir -p "$DST/.releases/$TAG"
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude ".releases" \
|
||||||
|
"$DST/" "$DST/.releases/$TAG/"
|
||||||
|
|
||||||
|
# 소스 → 운영 반영 (네가 이미 만든 deploy-nas.sh가 있으면 그걸 호출해도 됨)
|
||||||
|
# 예: repo/scripts/deploy-nas.sh가 운영으로 복사/동기화하는 로직이라면:
|
||||||
|
bash "$SRC/scripts/deploy-nas.sh"
|
||||||
|
|
||||||
|
cd "$DST"
|
||||||
|
docker-compose up -d --build backend travel-proxy stock-lab frontend
|
||||||
|
|
||||||
|
# [Permission Fix]
|
||||||
|
# deployer가 root로 돌면서 생성한 파일들의 소유권을 호스트 사용자로 변경
|
||||||
|
# .env에 정의된 PUID:PGID가 있으면 사용, 없으면 1000:1000
|
||||||
|
TARGET_UID=$(grep PUID .env | cut -d '=' -f2 || echo 1000)
|
||||||
|
TARGET_GID=$(grep PGID .env | cut -d '=' -f2 || echo 1000)
|
||||||
|
|
||||||
|
echo "Fixing permissions to $TARGET_UID:$TARGET_GID ..."
|
||||||
|
chown -R "$TARGET_UID:$TARGET_GID" "$DST" || true
|
||||||
|
chmod -R 755 "$DST" || true
|
||||||
|
# Repo 쪽도 혹시 모르니
|
||||||
|
if [ "$SRC" != "$DST" ]; then
|
||||||
|
chown -R "$TARGET_UID:$TARGET_GID" "$SRC" || true
|
||||||
|
chmod -R 755 "$SRC" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "DEPLOY_OK $TAG"
|
||||||
59
scripts/healthcheck.sh
Normal file
59
scripts/healthcheck.sh
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# NAS 내부 헬스체크용 (localhost 사용)
|
||||||
|
# 포트: backend(18000), travel-proxy(19000), frontend(8080)
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
echo "======================================"
|
||||||
|
echo " Starting Health Check..."
|
||||||
|
echo "======================================"
|
||||||
|
|
||||||
|
check_url() {
|
||||||
|
local name="$1"
|
||||||
|
local url="$2"
|
||||||
|
|
||||||
|
# HTTP 상태 코드만 가져옴 (타임아웃 5초)
|
||||||
|
status=$(curl -o /dev/null -s -w "%{http_code}" --max-time 5 "$url" || echo "FAIL")
|
||||||
|
|
||||||
|
if [[ "$status" == "200" ]]; then
|
||||||
|
echo -e "[${GREEN}OK${NC}] $name ($url) -> $status"
|
||||||
|
else
|
||||||
|
echo -e "[${RED}XX${NC}] $name ($url) -> $status"
|
||||||
|
# 하나라도 실패하면 exit 1 (CI/CD용)
|
||||||
|
# exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 1. Backend Service ---"
|
||||||
|
check_url "Backend Health" "http://localhost:18000/health"
|
||||||
|
check_url "Lotto Latest" "http://localhost:18000/api/lotto/latest"
|
||||||
|
check_url "Stats API" "http://localhost:18000/api/lotto/stats"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 2. Travel Proxy Service ---"
|
||||||
|
# Travel Proxy는 Main.py에서 루트(/) 엔드포인트가 없을 수 있어서 regions 체크
|
||||||
|
check_url "Travel Regions" "http://localhost:19000/api/travel/regions"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 3. Stock Lab Service ---"
|
||||||
|
check_url "Stock Health" "http://localhost:18500/health"
|
||||||
|
check_url "Stock News" "http://localhost:18500/api/stock/news"
|
||||||
|
check_url "Stock Indices" "http://localhost:18500/api/stock/indices"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--- 4. Frontend (Nginx) ---"
|
||||||
|
# 외부 포트 8080으로 접속 테스트
|
||||||
|
check_url "Frontend Home" "http://localhost:8080"
|
||||||
|
# Nginx가 Backend로 잘 프록시하는지 체크 (실제 존재하는 api 호출)
|
||||||
|
check_url "Nginx->Backend Proxy" "http://localhost:8080/api/lotto/latest"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "======================================"
|
||||||
|
echo " Health Check Completed."
|
||||||
|
echo "======================================"
|
||||||
9
stock-lab/Dockerfile
Normal file
9
stock-lab/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
67
stock-lab/app/db.py
Normal file
67
stock-lab/app/db.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
DB_PATH = "/app/data/stock.db"
|
||||||
|
|
||||||
|
def _conn() -> sqlite3.Connection:
|
||||||
|
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
with _conn() as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS articles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hash TEXT UNIQUE NOT NULL,
|
||||||
|
category TEXT DEFAULT 'domestic',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
link TEXT,
|
||||||
|
summary TEXT,
|
||||||
|
press TEXT,
|
||||||
|
pub_date TEXT,
|
||||||
|
crawled_at TEXT
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_crawled ON articles(crawled_at DESC)")
|
||||||
|
|
||||||
|
# 컬럼 추가 (기존 테이블 마이그레이션)
|
||||||
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(articles)").fetchall()}
|
||||||
|
if "category" not in cols:
|
||||||
|
conn.execute("ALTER TABLE articles ADD COLUMN category TEXT DEFAULT 'domestic'")
|
||||||
|
|
||||||
|
def save_articles(articles: List[Dict[str, str]]) -> int:
|
||||||
|
count = 0
|
||||||
|
with _conn() as conn:
|
||||||
|
for a in articles:
|
||||||
|
# 중복 체크용 해시 (제목+링크)
|
||||||
|
unique_str = f"{a['title']}|{a['link']}"
|
||||||
|
h = hashlib.md5(unique_str.encode()).hexdigest()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cat = a.get("category", "domestic")
|
||||||
|
conn.execute("""
|
||||||
|
INSERT INTO articles (hash, category, title, link, summary, press, pub_date, crawled_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", (h, cat, a['title'], a['link'], a['summary'], a['press'], a['date'], a['crawled_at']))
|
||||||
|
count += 1
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
pass # 이미 존재함
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_latest_articles(limit: int = 20, category: str = None) -> List[Dict[str, Any]]:
|
||||||
|
with _conn() as conn:
|
||||||
|
if category:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM articles WHERE category = ? ORDER BY crawled_at DESC, id DESC LIMIT ?",
|
||||||
|
(category, limit)
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM articles ORDER BY crawled_at DESC, id DESC LIMIT ?",
|
||||||
|
(limit,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
132
stock-lab/app/main.py
Normal file
132
stock-lab/app/main.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
import requests
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .db import init_db, save_articles, get_latest_articles
|
||||||
|
from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# CORS 설정 (프론트엔드 접근 허용)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # 운영 시에는 구체적인 도메인으로 제한하는 것이 좋음
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler = BackgroundScheduler(timezone=os.getenv("TZ", "Asia/Seoul"))
|
||||||
|
|
||||||
|
# Windows AI Server URL (NAS .env에서 설정)
|
||||||
|
WINDOWS_AI_SERVER_URL = os.getenv("WINDOWS_AI_SERVER_URL", "http://192.168.0.5:8000")
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# 매일 아침 8시 뉴스 스크랩 (NAS 자체 수행)
|
||||||
|
scheduler.add_job(run_scraping_job, "cron", hour="8", minute="0")
|
||||||
|
|
||||||
|
# 앱 시작 시에도 한 번 실행 (데이터 없으면)
|
||||||
|
if not get_latest_articles(1):
|
||||||
|
run_scraping_job()
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
|
def run_scraping_job():
|
||||||
|
print("[StockLab] Starting news scraping...")
|
||||||
|
|
||||||
|
# 1. 국내
|
||||||
|
articles_kr = fetch_market_news()
|
||||||
|
count_kr = save_articles(articles_kr)
|
||||||
|
|
||||||
|
# 2. 해외 (임시 차단)
|
||||||
|
# articles_world = fetch_overseas_news()
|
||||||
|
# count_world = save_articles(articles_world)
|
||||||
|
count_world = 0
|
||||||
|
|
||||||
|
print(f"[StockLab] Saved {count_kr} domestic, {count_world} overseas articles.")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/api/stock/news")
|
||||||
|
def get_news(limit: int = 20, category: str = None):
|
||||||
|
"""최신 주식 뉴스 조회 (category: 'domestic' | 'overseas')"""
|
||||||
|
return get_latest_articles(limit, category)
|
||||||
|
|
||||||
|
@app.get("/api/stock/indices")
|
||||||
|
def get_indices():
|
||||||
|
"""주요 지표(KOSPI 등) 실시간 크롤링 조회"""
|
||||||
|
return fetch_major_indices()
|
||||||
|
|
||||||
|
@app.post("/api/stock/scrap")
|
||||||
|
def trigger_scrap():
|
||||||
|
"""수동 스크랩 트리거"""
|
||||||
|
run_scraping_job()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# --- Trading API (Windows Proxy) ---
|
||||||
|
|
||||||
|
@app.get("/api/trade/balance")
|
||||||
|
def get_balance():
|
||||||
|
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
|
||||||
|
print(f"[Proxy] Requesting Balance from {WINDOWS_AI_SERVER_URL}...")
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"[ProxyError] Balance Error: {resp.status_code} {resp.text}")
|
||||||
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
|
|
||||||
|
print("[Proxy] Balance Success")
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ProxyError] Connection Failed: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
|
||||||
|
)
|
||||||
|
|
||||||
|
class OrderRequest(BaseModel):
|
||||||
|
ticker: str # 종목 코드 (예: "005930")
|
||||||
|
action: str # "BUY" or "SELL"
|
||||||
|
quantity: int # 주문 수량
|
||||||
|
price: int = 0 # 0이면 시장가
|
||||||
|
reason: Optional[str] = "Manual Order" # 주문 사유 (AI 기록용)
|
||||||
|
|
||||||
|
@app.post("/api/trade/order")
|
||||||
|
def order_stock(req: OrderRequest):
|
||||||
|
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
|
||||||
|
print(f"[Proxy] Order Request: {req.dict()} to {WINDOWS_AI_SERVER_URL}...")
|
||||||
|
try:
|
||||||
|
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10)
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
print(f"[ProxyError] Order Error: {resp.status_code} {resp.text}")
|
||||||
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
|
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ProxyError] Order Connection Failed: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"error": "Connection Failed", "detail": str(e), "target": WINDOWS_AI_SERVER_URL}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
def version():
|
||||||
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||||
|
|
||||||
|
|
||||||
278
stock-lab/app/scraper.py
Normal file
278
stock-lab/app/scraper.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
import time
|
||||||
|
|
||||||
|
# 네이버 파이낸스 주요 뉴스
|
||||||
|
NAVER_FINANCE_NEWS_URL = "https://finance.naver.com/news/mainnews.naver"
|
||||||
|
# 해외증시 뉴스 (모바일 API 사용)
|
||||||
|
# NAVER_FINANCE_WORLD_NEWS_URL 사용 안함.
|
||||||
|
|
||||||
|
# 해외증시 메인 (지수용)
|
||||||
|
NAVER_FINANCE_WORLD_URL = "https://finance.naver.com/world/"
|
||||||
|
|
||||||
|
def fetch_market_news() -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
네이버 금융 '주요 뉴스' 크롤링
|
||||||
|
반환: [{"title": "...", "link": "...", "summary": "...", "date": "..."}, ...]
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.93 Safari/537.36"
|
||||||
|
}
|
||||||
|
resp = requests.get(NAVER_FINANCE_NEWS_URL, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
soup = BeautifulSoup(resp.content, "html.parser", from_encoding="cp949")
|
||||||
|
|
||||||
|
# 주요 뉴스 리스트 추출
|
||||||
|
# 구조: div.mainNewsList > ul > li
|
||||||
|
articles = []
|
||||||
|
news_list = soup.select(".mainNewsList ul li")
|
||||||
|
|
||||||
|
for li in news_list:
|
||||||
|
# 썸네일 있을 수도 있고 없을 수도 있음
|
||||||
|
dl = li.select_one("dl")
|
||||||
|
if not dl:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 제목 (dd.articleSubject > a)
|
||||||
|
subject_tag = dl.select_one(".articleSubject a")
|
||||||
|
if not subject_tag:
|
||||||
|
continue
|
||||||
|
|
||||||
|
title = subject_tag.get_text(strip=True)
|
||||||
|
link = "https://finance.naver.com" + subject_tag["href"]
|
||||||
|
|
||||||
|
# 요약 (dd.articleSummary)
|
||||||
|
summary_tag = dl.select_one(".articleSummary")
|
||||||
|
summary = ""
|
||||||
|
press = ""
|
||||||
|
date = ""
|
||||||
|
|
||||||
|
if summary_tag:
|
||||||
|
# 불필요한 태그 제거
|
||||||
|
for child in summary_tag.select(".press, .wdate"):
|
||||||
|
if "press" in child.get("class", []):
|
||||||
|
press = child.get_text(strip=True)
|
||||||
|
if "wdate" in child.get("class", []):
|
||||||
|
date = child.get_text(strip=True)
|
||||||
|
child.extract()
|
||||||
|
summary = summary_tag.get_text(strip=True)
|
||||||
|
|
||||||
|
articles.append({
|
||||||
|
"title": title,
|
||||||
|
"link": link,
|
||||||
|
"summary": summary,
|
||||||
|
"press": press,
|
||||||
|
"date": date,
|
||||||
|
"crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"category": "domestic"
|
||||||
|
})
|
||||||
|
|
||||||
|
return articles
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StockLab] Scraping failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_overseas_news() -> List[Dict[str, str]]:
|
||||||
|
"""
|
||||||
|
네이버 금융 해외증시 뉴스 크롤링 (모바일 API 사용)
|
||||||
|
"""
|
||||||
|
api_url = "https://api.stock.naver.com/news/overseas/mainnews"
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
|
||||||
|
}
|
||||||
|
resp = requests.get(api_url, headers=headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
if isinstance(data, list):
|
||||||
|
items = data
|
||||||
|
else:
|
||||||
|
items = data.get("result", [])
|
||||||
|
|
||||||
|
articles = []
|
||||||
|
for item in items:
|
||||||
|
# API 키 매핑 (subject/title/tit, summary/subContent/sub_tit 등)
|
||||||
|
title = item.get("subject") or item.get("title") or item.get("tit") or ""
|
||||||
|
summary = item.get("summary") or item.get("subContent") or item.get("sub_tit") or ""
|
||||||
|
press = item.get("officeName") or item.get("office_name") or item.get("cp_name") or ""
|
||||||
|
|
||||||
|
# 날짜 포맷팅 (20260126123000 -> 2026-01-26 12:30:00)
|
||||||
|
raw_dt = str(item.get("dt", ""))
|
||||||
|
if len(raw_dt) == 14:
|
||||||
|
date = f"{raw_dt[:4]}-{raw_dt[4:6]}-{raw_dt[6:8]} {raw_dt[8:10]}:{raw_dt[10:12]}:{raw_dt[12:]}"
|
||||||
|
else:
|
||||||
|
date = raw_dt
|
||||||
|
|
||||||
|
# 링크 생성
|
||||||
|
aid = item.get("articleId")
|
||||||
|
oid = item.get("officeId")
|
||||||
|
link = f"https://m.stock.naver.com/worldstock/news/read/{oid}/{aid}"
|
||||||
|
|
||||||
|
articles.append({
|
||||||
|
"title": title,
|
||||||
|
"link": link,
|
||||||
|
"summary": summary,
|
||||||
|
"press": press,
|
||||||
|
"date": date,
|
||||||
|
"crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
"category": "overseas"
|
||||||
|
})
|
||||||
|
|
||||||
|
return articles
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StockLab] Overseas news failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def fetch_major_indices() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
KOSPI, KOSDAQ, KOSPI200 등 주요 지표 (네이버 금융 홈)
|
||||||
|
"""
|
||||||
|
url = "https://finance.naver.com/"
|
||||||
|
headers = {
|
||||||
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
targets = [
|
||||||
|
{"key": "KOSPI", "selector": ".kospi_area", "url": "https://finance.naver.com/"},
|
||||||
|
{"key": "KOSDAQ", "selector": ".kosdaq_area", "url": "https://finance.naver.com/"},
|
||||||
|
{"key": "KOSPI200", "selector": ".kospi200_area", "url": "https://finance.naver.com/"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 해외 지수 (네이버 금융 해외 메인) - 여기서는 별도 URL 호출 필요하거나, 메인에 있는지 확인
|
||||||
|
# 네이버 메인에는 해외지수가 안 나옴. https://finance.naver.com/world/ 에서 긁어야 함
|
||||||
|
# 그러나 한 번에 처리하기 위해 함수 내에서 추가 호출
|
||||||
|
|
||||||
|
indices = []
|
||||||
|
|
||||||
|
# --- 국내 ---
|
||||||
|
resp_kr = requests.get("https://finance.naver.com/", headers=headers, timeout=5)
|
||||||
|
soup_kr = BeautifulSoup(resp_kr.content, "html.parser", from_encoding="cp949")
|
||||||
|
|
||||||
|
for t in targets:
|
||||||
|
area = soup_kr.select_one(t["selector"])
|
||||||
|
if not area: continue
|
||||||
|
|
||||||
|
# (기존 파싱 로직)
|
||||||
|
num_tag = area.select_one(".num")
|
||||||
|
value = num_tag.get_text(strip=True) if num_tag else ""
|
||||||
|
|
||||||
|
change_val_tag = area.select_one(".num2")
|
||||||
|
change_pct_tag = area.select_one(".num3")
|
||||||
|
change_val = change_val_tag.get_text(strip=True) if change_val_tag else ""
|
||||||
|
change_pct = change_pct_tag.get_text(strip=True) if change_pct_tag else ""
|
||||||
|
|
||||||
|
direction = ""
|
||||||
|
if area.select_one(".bu_p"): direction = "red"
|
||||||
|
elif area.select_one(".bu_m"): direction = "blue"
|
||||||
|
|
||||||
|
indices.append({
|
||||||
|
"name": t["key"],
|
||||||
|
"value": value,
|
||||||
|
"change_value": change_val,
|
||||||
|
"change_percent": change_pct,
|
||||||
|
"direction": direction,
|
||||||
|
"type": "domestic"
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- 해외 (DJI, NAS, SPI) ---
|
||||||
|
try:
|
||||||
|
resp_world = requests.get(NAVER_FINANCE_WORLD_URL, headers=headers, timeout=5)
|
||||||
|
soup_world = BeautifulSoup(resp_world.content, "html.parser", from_encoding="cp949")
|
||||||
|
|
||||||
|
world_targets = [
|
||||||
|
{"key": "DJI", "name": "다우산업", "sym": "DJI@DJI"},
|
||||||
|
{"key": "NAS", "name": "나스닥", "sym": "NAS@IXIC"},
|
||||||
|
{"key": "SPI", "name": "S&P500", "sym": "SPI@SPX"},
|
||||||
|
]
|
||||||
|
|
||||||
|
for wt in world_targets:
|
||||||
|
# 심볼 링크로 찾기 (가장 정확함)
|
||||||
|
a_tag = soup_world.select_one(f"a[href*='symbol={wt['sym']}']")
|
||||||
|
if not a_tag:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 상위 dl 태그 찾기
|
||||||
|
dl = a_tag.find_parent("dl")
|
||||||
|
if not dl:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 값 파싱 (dd.point_status)
|
||||||
|
status_dd = dl.select_one("dd.point_status")
|
||||||
|
if not status_dd:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 1. 현재가 (strong)
|
||||||
|
val_tag = status_dd.select_one("strong")
|
||||||
|
value = val_tag.get_text(strip=True) if val_tag else ""
|
||||||
|
|
||||||
|
# 2. 등락폭 (em)
|
||||||
|
change_val_tag = status_dd.select_one("em")
|
||||||
|
change_val = change_val_tag.get_text(strip=True) if change_val_tag else ""
|
||||||
|
|
||||||
|
# 3. 등락률 (span)
|
||||||
|
change_pct_tag = status_dd.select_one("span")
|
||||||
|
change_pct = change_pct_tag.get_text(strip=True) if change_pct_tag else ""
|
||||||
|
|
||||||
|
# 4. 방향 (dl 클래스 활용)
|
||||||
|
direction = ""
|
||||||
|
dl_classes = dl.get("class", [])
|
||||||
|
if "point_up" in dl_classes:
|
||||||
|
direction = "red"
|
||||||
|
elif "point_dn" in dl_classes:
|
||||||
|
direction = "blue"
|
||||||
|
|
||||||
|
indices.append({
|
||||||
|
"name": wt["name"], # 한글 이름 사용
|
||||||
|
"value": value,
|
||||||
|
"change_value": change_val,
|
||||||
|
"change_percent": change_pct,
|
||||||
|
"direction": direction,
|
||||||
|
"type": "overseas"
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StockLab] World indices failed: {e}")
|
||||||
|
|
||||||
|
# --- 환율 (USD/KRW) ---
|
||||||
|
try:
|
||||||
|
resp_ex = requests.get("https://finance.naver.com/marketindex/", headers=headers, timeout=5)
|
||||||
|
soup_ex = BeautifulSoup(resp_ex.content, "html.parser", from_encoding="cp949")
|
||||||
|
|
||||||
|
usd_item = soup_ex.select_one("#exchangeList li.on > a.head.usd")
|
||||||
|
if usd_item:
|
||||||
|
value = usd_item.select_one(".value").get_text(strip=True)
|
||||||
|
change_val = usd_item.select_one(".change").get_text(strip=True)
|
||||||
|
|
||||||
|
# 방향 (blind 텍스트: 상승, 하락)
|
||||||
|
direction = ""
|
||||||
|
blind_txt = usd_item.select_one(".blind").get_text(strip=True)
|
||||||
|
if "상승" in blind_txt: direction = "red"
|
||||||
|
elif "하락" in blind_txt: direction = "blue"
|
||||||
|
|
||||||
|
# 등락률은 리스트에는 안나오고 상세에 나오지만, 여기선 생략하거나 계산 가능.
|
||||||
|
# 일단 UI 통일성을 위해 빈값 혹은 계산된 값 등 처리.
|
||||||
|
# 네이버 메인 환율 영역엔 등락률이 텍스트로 바로 안보임 (title 속성 등에 있을수 있음).
|
||||||
|
# 여기서는 간단히 값만 처리.
|
||||||
|
|
||||||
|
indices.append({
|
||||||
|
"name": "원달러 환율",
|
||||||
|
"value": value,
|
||||||
|
"change_value": change_val,
|
||||||
|
"change_percent": "", # 메인 리스트에서 바로 안보임
|
||||||
|
"direction": direction,
|
||||||
|
"type": "exchange"
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StockLab] Exchange rate failed: {e}")
|
||||||
|
|
||||||
|
return {"indices": indices, "crawled_at": time.strftime("%Y-%m-%d %H:%M:%S")}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[StockLab] Indices scraping failed: {e}")
|
||||||
|
return {"indices": [], "error": str(e)}
|
||||||
7
stock-lab/requirements.txt
Normal file
7
stock-lab/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# 주식 서비스용 라이브러리
|
||||||
|
requests==2.32.3
|
||||||
|
beautifulsoup4==4.12.3
|
||||||
|
fastapi==0.115.6
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
apscheduler==3.10.4
|
||||||
|
python-dotenv==1.0.1
|
||||||
@@ -20,3 +20,7 @@ EXPOSE 8000
|
|||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
||||||
|
ARG APP_VERSION=dev
|
||||||
|
ENV APP_VERSION=$APP_VERSION
|
||||||
|
LABEL org.opencontainers.image.version=$APP_VERSION
|
||||||
|
|||||||
@@ -149,16 +149,19 @@ def scan_album(album: str) -> List[Dict[str, Any]]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for p in album_dir.iterdir():
|
# os.scandir이 iterdir보다 빠름 (파일 속성 한 번에 가져옴)
|
||||||
if p.is_file() and p.suffix.lower() in IMAGE_EXT:
|
with os.scandir(album_dir) as entries:
|
||||||
# ✅ 썸네일 생성 보장
|
for entry in entries:
|
||||||
ensure_thumb(p, album)
|
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
|
||||||
|
# ⚡ 성능 핵심: 여기서 썸네일 생성(ensure_thumb) 절대 하지 않음!
|
||||||
|
# 파일 존재 여부만 확인하고 바로 리턴
|
||||||
items.append({
|
items.append({
|
||||||
"album": album,
|
"album": album,
|
||||||
"file": p.name,
|
"file": entry.name,
|
||||||
"url": f"{MEDIA_BASE}/{album}/{p.name}",
|
"url": f"{MEDIA_BASE}/{album}/{entry.name}",
|
||||||
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{p.name}",
|
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{entry.name}",
|
||||||
|
# 정렬을 위해 수정시간/이름 필요하면 여기서 저장
|
||||||
|
"mtime": entry.stat().st_mtime
|
||||||
})
|
})
|
||||||
return items
|
return items
|
||||||
|
|
||||||
@@ -170,19 +173,28 @@ def regions():
|
|||||||
_meta_changed_invalidate_cache()
|
_meta_changed_invalidate_cache()
|
||||||
return load_regions_geojson()
|
return load_regions_geojson()
|
||||||
|
|
||||||
|
@app.post("/api/travel/reload")
|
||||||
|
def reload_cache():
|
||||||
|
"""강제로 캐시를 비워서 새로고침"""
|
||||||
|
CACHE.clear()
|
||||||
|
META_MTIME_CACHE.clear()
|
||||||
|
return {"ok": True, "message": "Cache cleared"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/travel/photos")
|
@app.get("/api/travel/photos")
|
||||||
def photos(
|
def photos(
|
||||||
region: str = Query(...),
|
region: str = Query(...),
|
||||||
limit: int = Query(500, le=5000),
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
):
|
):
|
||||||
_meta_changed_invalidate_cache()
|
_meta_changed_invalidate_cache()
|
||||||
|
|
||||||
|
# 1. 캐시 키에 페이지 정보 포함하지 않음 (전체 리스트 캐싱 후 슬라이싱 전략)
|
||||||
|
# 왜냐하면 파일 스캔 비용이 크지, 메모리 슬라이싱 비용은 작기 때문.
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = CACHE.get(region)
|
cached = CACHE.get(region)
|
||||||
if cached and now - cached["ts"] < CACHE_TTL:
|
|
||||||
return cached["data"]
|
|
||||||
|
|
||||||
|
if not cached or now - cached["ts"] >= CACHE_TTL:
|
||||||
region_map = load_region_map()
|
region_map = load_region_map()
|
||||||
albums = _get_albums_for_region(region, region_map)
|
albums = _get_albums_for_region(region, region_map)
|
||||||
|
|
||||||
@@ -194,19 +206,35 @@ def photos(
|
|||||||
matched.append({"album": album, "count": len(items)})
|
matched.append({"album": album, "count": len(items)})
|
||||||
all_items.extend(items)
|
all_items.extend(items)
|
||||||
|
|
||||||
|
# 정렬: 앨범명 > 파일명 (또는 찍은 날짜)
|
||||||
all_items.sort(key=lambda x: (x["album"], x["file"]))
|
all_items.sort(key=lambda x: (x["album"], x["file"]))
|
||||||
|
|
||||||
data = {
|
cached_data = {
|
||||||
"region": region,
|
"region": region,
|
||||||
"matched_albums": matched,
|
"matched_albums": matched,
|
||||||
"items": all_items[:limit],
|
"items": all_items,
|
||||||
"total": len(all_items),
|
"total": len(all_items),
|
||||||
"cached_at": int(now),
|
|
||||||
"cache_ttl": CACHE_TTL,
|
|
||||||
}
|
}
|
||||||
|
CACHE[region] = {"ts": now, "data": cached_data}
|
||||||
|
else:
|
||||||
|
cached_data = cached["data"]
|
||||||
|
|
||||||
CACHE[region] = {"ts": now, "data": data}
|
# 2. 페이지네이션 슬라이싱
|
||||||
return data
|
all_items = cached_data["items"]
|
||||||
|
total = len(all_items)
|
||||||
|
start = (page - 1) * size
|
||||||
|
end = start + size
|
||||||
|
|
||||||
|
paged_items = all_items[start:end]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"region": region,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
"total": total,
|
||||||
|
"has_next": end < total,
|
||||||
|
"items": paged_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/media/travel/.thumb/{album}/{filename}")
|
@app.get("/media/travel/.thumb/{album}/{filename}")
|
||||||
@@ -218,3 +246,8 @@ def get_thumb(album: str, filename: str):
|
|||||||
# src로부터 thumb 생성/확인 (원본 확장자 유지)
|
# src로부터 thumb 생성/확인 (원본 확장자 유지)
|
||||||
p = ensure_thumb(src, album)
|
p = ensure_thumb(src, album)
|
||||||
return FileResponse(str(p))
|
return FileResponse(str(p))
|
||||||
|
|
||||||
|
@app.get("/api/version")
|
||||||
|
def version():
|
||||||
|
import os
|
||||||
|
return {"version": os.getenv("APP_VERSION", "dev")}
|
||||||
|
|||||||
Reference in New Issue
Block a user