Compare commits

48 Commits
v0.1.0 ... main

Author SHA1 Message Date
c96815c2e3 stock-lab 오류 수정, lotto-lab 히트맵 기반 추천 기능 추가 2026-02-05 01:26:20 +09:00
4035432c54 stock-lab 의미없이 남겨진 소스로 오류 발생의 해결 2026-01-28 01:22:52 +09:00
d28c291a55 stock-lab 자동 매매 요청 삭제, 수동 매매 요청 추가 2026-01-28 01:20:31 +09:00
21a8173963 주요지수 해외 오류 수정 및 원달러환율 추가 2026-01-28 00:55:47 +09:00
f6fcff0faf 주식 해외 지수 응답 추가 2026-01-28 00:44:09 +09:00
55863d7744 자동매매 응답 로그 출력 추가 2026-01-27 03:12:50 +09:00
a330a5271c /api/trade/auto 요청 오류 스펙에 맞게 수정 2026-01-27 03:06:59 +09:00
e27fbfada1 요청에 대한 명시적 로그 표출 2026-01-27 03:00:07 +09:00
7fb55a7be7 api/trade 미동작 문제 해결: CORS 허용 추가 2026-01-27 02:53:44 +09:00
9a8df4908a NAS 앞단에 있는 nginx가 요청을 Docker 컨테이너로 넘겨줄 수 있도록 config api/trade 설정 2026-01-27 02:28:56 +09:00
a8cbef75db window pc AI server 구축 및 NAS 중계 서버 연결 설정 2026-01-27 01:26:23 +09:00
b6fd444dba 서비스 구체화 현 상태 README 정리 2026-01-27 01:08:05 +09:00
f2e23c1241 windows pc, how to set up ollama server 2026-01-26 23:39:08 +09:00
c6850da4ac 주식 증권 api 연동 및 window pc AI 연동 기능 구현 시작 2026-01-26 22:31:56 +09:00
8283dab0de fix: use correct mainnews endpoint for overseas news 2026-01-26 04:05:30 +09:00
9faa1c5715 fix: update overseas news API url and key mapping 2026-01-26 04:03:09 +09:00
0e2d241e18 fix: handle list response from Naver API in scraper 2026-01-26 04:00:48 +09:00
84c5877207 fix: rewrite scraper.py to fix syntax errors and use mobile API 2026-01-26 03:57:30 +09:00
cbafc1f959 scraper.py 오류 수정 2026-01-26 03:51:08 +09:00
dce6b3e692 scraper.py 오류 수정 2026-01-26 03:48:51 +09:00
3d0dd24f27 feat: add overseas financial news and indices support 2026-01-26 03:45:19 +09:00
2fafce0327 fix: change manual scrap endpoint to /api/stock/scrap 2026-01-26 03:31:26 +09:00
25ede4f478 fix: import Any in stock-lab scraper 2026-01-26 03:23:54 +09:00
2493bc72fb stock_lab 배포 경로 추가될 수 있게 추가 2026-01-26 03:19:25 +09:00
dd6435eb86 기타 추가 설정 2026-01-26 03:17:59 +09:00
94db1da045 feat: add stock indices scraping and update healthcheck 2026-01-26 03:14:46 +09:00
d8e4e0461c feat: add stock-lab service for financial news scraping and analysis 2026-01-26 02:56:52 +09:00
421e52b205 refactor: extract utils to fix circular import and enable smart generator 2026-01-26 01:21:57 +09:00
526d6a53e5 파일 권한 설정 추가 2026-01-26 01:17:40 +09:00
432840a38d feat: smart recommendation generator with feedback loop and result checker 2026-01-26 01:15:49 +09:00
597353e6d4 feat: auto-sync full lotto history on stats api access 2026-01-26 00:45:32 +09:00
bd43c99221 fix: revert deployer to root and fix permissions in script 2026-01-26 00:35:26 +09:00
2c95fe49f3 fix: healthcheck url 2026-01-26 00:28:13 +09:00
8ccfc32749 healthcheck(api test) 스크립트 추가 2026-01-26 00:20:36 +09:00
67ef3c4bbf git 배포 시 파일 권한 변경되는 부분 해결 2026-01-26 00:12:51 +09:00
ee54458bf0 fix: deployer webhook timeout - implement async background task 2026-01-26 00:05:17 +09:00
e1c3168d5c fix: deploy.sh path detection for host execution 2026-01-26 00:01:08 +09:00
2d5972c25d lotto lab 전 차수 로또 당첨 번호 그래프 시각화 api 추가 2026-01-25 23:56:00 +09:00
1ddbd4ad0e lotto 추천 결과 통계 시각화 (분포, 합계, 홀짝) 를 구현 2026-01-25 22:40:39 +09:00
f75bf5d3e5 deployer 스크립트 권한 오류 수정 2026-01-25 21:37:33 +09:00
0fde916120 travel-proxy 이미지 썸네일 로딩 최적화, 페이지네이션 추가 2026-01-25 19:40:26 +09:00
64c526488a fix: deploy-nas.sh path detection for host execution 2026-01-25 19:10:23 +09:00
c655b655c9 deploy-nas.sh 스크립트 수행 오류 수정 2026-01-25 19:09:04 +09:00
005c0261c2 Local PC 개발 및 테스트 - git 배포 - gitea webhook 단계별 설정 2026-01-25 19:00:22 +09:00
879bb2f25d README.md 현 스펙 정리 2026-01-25 17:42:19 +09:00
82cbae7ae2 webhook 설정 오류 수정
- deployer 배포 webhook 오류 설정 수정
2026-01-25 17:28:58 +09:00
a8b661b304 rename script folder 2026-01-25 15:44:39 +09:00
b815c37064 webhook 자동 배포 설정 2026-01-25 11:51:39 +09:00
27 changed files with 1812 additions and 166 deletions

View File

@@ -1,17 +1,52 @@
# timezone
# ---------------------------------------------------------------------------
# [Environment Configuration]
# 이 파일을 복사하여 .env 파일을 생성하고, 환경에 맞게 주석을 해제/수정하여 사용하세요.
# ---------------------------------------------------------------------------
# [COMMON]
APP_VERSION=dev
TZ=Asia/Seoul
COMPOSE_PROJECT_NAME=webpage
# backend lotto collector sources
LOTTO_ALL_URL=https://smok95.github.io/lotto/results/all.json
LOTTO_LATEST_URL=https://smok95.github.io/lotto/results/latest.json
# travel-proxy
TRAVEL_ROOT=/data/travel
TRAVEL_THUMB_ROOT=/data/thumbs
TRAVEL_MEDIA_BASE=/media/travel
TRAVEL_CACHE_TTL=300
# [SECURITY]
WEBHOOK_SECRET=change_this_secret_in_prod
# CORS (travel-proxy)
CORS_ALLOW_ORIGINS=*
# [PATHS]
# 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
View File

@@ -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`

View File

@@ -16,3 +16,7 @@ ENV PYTHONUNBUFFERED=1
EXPOSE 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
View 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

View File

@@ -1,7 +1,7 @@
import requests
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:
# smok95 all.json / latest.json 구조
@@ -27,20 +27,13 @@ def sync_all_from_json(all_url: str) -> Dict[str, Any]:
r.raise_for_status()
data = r.json() # list[dict]
inserted = 0
skipped = 0
# 정규화
rows = [_normalize_item(item) for item in data]
# Bulk Insert (성능 향상)
upsert_many_draws(rows)
for item in data:
row = _normalize_item(item)
if get_draw(row["drw_no"]):
skipped += 1
continue
upsert_draw(row)
inserted += 1
return {"mode": "all_json", "url": all_url, "inserted": inserted, "skipped": skipped, "total": len(data)}
return {"mode": "all_json", "url": all_url, "total": len(rows)}
def sync_latest(latest_url: str) -> Dict[str, Any]:
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"]}
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}

View File

@@ -63,6 +63,17 @@ def init_db() -> None:
_ensure_column(conn, "recommendations", "tags",
"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 인덱스(중복 저장 방지)
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]]:
with _conn() as conn:
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,))
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
View 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

View File

@@ -9,8 +9,12 @@ from .db import (
save_recommendation_dedup, list_recommendations_ex, delete_recommendation,
update_recommendation,
)
from .recommender import recommend_numbers
from .collector import sync_latest
from .recommender import recommend_numbers, recommend_with_heatmap
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()
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")
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")
def on_startup():
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()
@app.get("/health")
@@ -96,6 +55,7 @@ def api_latest():
"date": row["drw_date"],
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
"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}")
@@ -108,11 +68,53 @@ def api_draw(drw_no: int):
"date": row["drw_date"],
"numbers": [row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]],
"bonus": row["bonus"],
"metrics": calc_metrics([row["n1"], row["n2"], row["n3"], row["n4"], row["n5"], row["n6"]]),
}
@app.post("/api/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) ----------
@app.get("/api/lotto/recommend")
@@ -221,6 +223,124 @@ def api_recommend(
"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) ----------
@app.get("/api/history")
def api_history(
@@ -322,7 +442,11 @@ def api_recommend_batch(
return {
"based_on_latest_draw": latest["drw_no"] if latest else None,
"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,
}
@@ -342,3 +466,7 @@ def api_recommend_batch_save(body: BatchSave):
return {"saved": True, "created_ids": created, "deduped_ids": deduped}
@app.get("/api/version")
def version():
import os
return {"version": os.getenv("APP_VERSION", "dev")}

View File

@@ -66,3 +66,98 @@ def recommend_numbers(
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
View 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
View 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
View 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"}

View File

@@ -0,0 +1,2 @@
fastapi
uvicorn

View File

@@ -2,7 +2,10 @@ version: "3.8"
services:
backend:
build: ./backend
build:
context: ./backend
args:
APP_VERSION: ${APP_VERSION:-dev}
container_name: lotto-backend
restart: unless-stopped
ports:
@@ -12,13 +15,28 @@ services:
- 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}
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:
build: ./travel-proxy
container_name: travel-proxy
restart: unless-stopped
user: "1026:100"
user: "${PUID}:${PGID}"
ports:
- "19000:8000" # 내부 확인용
environment:
@@ -29,8 +47,8 @@ services:
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*}
volumes:
- /volume1/web/images/webPage/travel:/data/travel:ro
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:rw
- ${PHOTO_PATH}:/data/travel:ro
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
frontend:
image: nginx:alpine
@@ -39,9 +57,23 @@ services:
ports:
- "8080:80"
volumes:
- /volume1/docker/webpage/frontend:/usr/share/nginx/html:ro
- /volume1/docker/webpage/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- /volume1/web/images/webPage/travel:/data/travel:ro
- /volume1/docker/webpage/travel-thumbs:/data/thumbs:ro
- ${FRONTEND_PATH}:/usr/share/nginx/html:ro
- ${RUNTIME_PATH}/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ${PHOTO_PATH}:/data/travel:ro
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:ro
extra_hosts:
- "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

View File

@@ -54,6 +54,27 @@ server {
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/ 중복 제거)
location /api/ {
proxy_http_version 1.1;
@@ -66,6 +87,27 @@ server {
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 라우팅 (마지막에 두는 게 안전)
location / {
try_files $uri $uri/ /index.html;

View File

@@ -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"

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)}

View 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

View File

@@ -20,3 +20,7 @@ EXPOSE 8000
ENV PYTHONUNBUFFERED=1
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

View File

@@ -149,17 +149,20 @@ def scan_album(album: str) -> List[Dict[str, Any]]:
return []
items = []
for p in album_dir.iterdir():
if p.is_file() and p.suffix.lower() in IMAGE_EXT:
# ✅ 썸네일 생성 보장
ensure_thumb(p, album)
items.append({
"album": album,
"file": p.name,
"url": f"{MEDIA_BASE}/{album}/{p.name}",
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{p.name}",
})
# os.scandir이 iterdir보다 빠름 (파일 속성 한 번에 가져옴)
with os.scandir(album_dir) as entries:
for entry in entries:
if entry.is_file() and Path(entry.name).suffix.lower() in IMAGE_EXT:
# ⚡ 성능 핵심: 여기서 썸네일 생성(ensure_thumb) 절대 하지 않음!
# 파일 존재 여부만 확인하고 바로 리턴
items.append({
"album": album,
"file": entry.name,
"url": f"{MEDIA_BASE}/{album}/{entry.name}",
"thumb": f"{MEDIA_BASE}/.thumb/{album}/{entry.name}",
# 정렬을 위해 수정시간/이름 필요하면 여기서 저장
"mtime": entry.stat().st_mtime
})
return items
# -----------------------------
@@ -170,44 +173,69 @@ def regions():
_meta_changed_invalidate_cache()
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")
def photos(
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()
# 1. 캐시 키에 페이지 정보 포함하지 않음 (전체 리스트 캐싱 후 슬라이싱 전략)
# 왜냐하면 파일 스캔 비용이 크지, 메모리 슬라이싱 비용은 작기 때문.
now = time.time()
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()
albums = _get_albums_for_region(region, region_map)
region_map = load_region_map()
albums = _get_albums_for_region(region, region_map)
all_items = []
matched = []
all_items = []
matched = []
for album in albums:
items = scan_album(album)
matched.append({"album": album, "count": len(items)})
all_items.extend(items)
for album in albums:
items = scan_album(album)
matched.append({"album": album, "count": len(items)})
all_items.extend(items)
# 정렬: 앨범명 > 파일명 (또는 찍은 날짜)
all_items.sort(key=lambda x: (x["album"], x["file"]))
cached_data = {
"region": region,
"matched_albums": matched,
"items": all_items,
"total": len(all_items),
}
CACHE[region] = {"ts": now, "data": cached_data}
else:
cached_data = cached["data"]
all_items.sort(key=lambda x: (x["album"], x["file"]))
# 2. 페이지네이션 슬라이싱
all_items = cached_data["items"]
total = len(all_items)
start = (page - 1) * size
end = start + size
paged_items = all_items[start:end]
data = {
return {
"region": region,
"matched_albums": matched,
"items": all_items[:limit],
"total": len(all_items),
"cached_at": int(now),
"cache_ttl": CACHE_TTL,
"page": page,
"size": size,
"total": total,
"has_next": end < total,
"items": paged_items,
}
CACHE[region] = {"ts": now, "data": data}
return data
@app.get("/media/travel/.thumb/{album}/{filename}")
def get_thumb(album: str, filename: str):
@@ -218,3 +246,8 @@ def get_thumb(album: str, filename: str):
# src로부터 thumb 생성/확인 (원본 확장자 유지)
p = ensure_thumb(src, album)
return FileResponse(str(p))
@app.get("/api/version")
def version():
import os
return {"version": os.getenv("APP_VERSION", "dev")}