refactor: 전체 코드베이스 감사 기반 리팩토링 — 버그 수정, 데드코드 제거, 보안 강화
P0 버그 수정: - stock-lab: trade 엔드포인트 NameError 수정 (resp 미정의) - deployer: 동시 배포 시 HTTP 200 → 503 반환 P1 데드코드 제거: - stock-lab: fetch_overseas_news(), get_broker_cash() 제거 - blog-lab: 미사용 urlparse import 제거 - lotto-lab: 중복 inline import json 7곳 제거 P2 성능/효율 개선: - lotto-lab: 가중 샘플링 3중 복사 → utils.weighted_sample_6() 통합 - lotto-lab: DB 인덱스 3개 추가 (recommendations, purchase_history) - stock-lab: Pydantic .dict() → .model_dump() 호환 - blog-lab: 페이지네이션 상한(le=100) 추가 P3 보안/인프라: - nginx: X-Frame-Options, X-Content-Type-Options, Referrer-Policy 헤더 추가 - docker-compose: travel-proxy CORS 와일드카드 → localhost 전용 - Dockerfile: music-lab, blog-lab, realestate-lab에 PYTHONUNBUFFERED 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -272,6 +272,11 @@ def init_db() -> None:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── 추가 인덱스 ───────────────────────────────────────────────────────
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_reco_based_checked ON recommendations(based_on_draw, checked)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_purchase_strategy ON purchase_history(source_strategy)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_purchase_checked ON purchase_history(draw_no, checked)")
|
||||||
|
|
||||||
|
|
||||||
# ── todos CRUD ───────────────────────────────────────────────────────────────
|
# ── todos CRUD ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -512,8 +517,6 @@ def list_recommendations_ex(
|
|||||||
q: Optional[str] = None,
|
q: Optional[str] = None,
|
||||||
sort: str = "id_desc", # id_desc|created_desc|favorite_desc
|
sort: str = "id_desc", # id_desc|created_desc|favorite_desc
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
import json
|
|
||||||
|
|
||||||
where = []
|
where = []
|
||||||
args: list[Any] = []
|
args: list[Any] = []
|
||||||
|
|
||||||
@@ -810,7 +813,7 @@ def get_simulation_candidates(run_id: int, limit: int = 100) -> List[Dict[str, A
|
|||||||
# ── purchase_history CRUD ─────────────────────────────────────────────────────
|
# ── purchase_history CRUD ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _purchase_row_to_dict(r) -> Dict[str, Any]:
|
def _purchase_row_to_dict(r) -> Dict[str, Any]:
|
||||||
import json as _json
|
|
||||||
keys = r.keys()
|
keys = r.keys()
|
||||||
numbers_raw = r["numbers"] if "numbers" in keys else "[]"
|
numbers_raw = r["numbers"] if "numbers" in keys else "[]"
|
||||||
detail_raw = r["source_detail"] if "source_detail" in keys else "{}"
|
detail_raw = r["source_detail"] if "source_detail" in keys else "{}"
|
||||||
@@ -823,12 +826,12 @@ def _purchase_row_to_dict(r) -> Dict[str, Any]:
|
|||||||
"prize": r["prize"],
|
"prize": r["prize"],
|
||||||
"note": r["note"],
|
"note": r["note"],
|
||||||
"created_at": r["created_at"],
|
"created_at": r["created_at"],
|
||||||
"numbers": _json.loads(numbers_raw) if numbers_raw else [],
|
"numbers": json.loads(numbers_raw) if numbers_raw else [],
|
||||||
"is_real": r["is_real"] if "is_real" in keys else 1,
|
"is_real": r["is_real"] if "is_real" in keys else 1,
|
||||||
"source_strategy": r["source_strategy"] if "source_strategy" in keys else "manual",
|
"source_strategy": r["source_strategy"] if "source_strategy" in keys else "manual",
|
||||||
"source_detail": _json.loads(detail_raw) if detail_raw else {},
|
"source_detail": json.loads(detail_raw) if detail_raw else {},
|
||||||
"checked": r["checked"] if "checked" in keys else 0,
|
"checked": r["checked"] if "checked" in keys else 0,
|
||||||
"results": _json.loads(results_raw) if results_raw else [],
|
"results": json.loads(results_raw) if results_raw else [],
|
||||||
"total_prize": r["total_prize"] if "total_prize" in keys else 0,
|
"total_prize": r["total_prize"] if "total_prize" in keys else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -836,9 +839,9 @@ def _purchase_row_to_dict(r) -> Dict[str, Any]:
|
|||||||
def add_purchase(draw_no: int, amount: int, sets: int, prize: int = 0, note: str = "",
|
def add_purchase(draw_no: int, amount: int, sets: int, prize: int = 0, note: str = "",
|
||||||
numbers: list = None, is_real: bool = True,
|
numbers: list = None, is_real: bool = True,
|
||||||
source_strategy: str = "manual", source_detail: dict = None) -> Dict[str, Any]:
|
source_strategy: str = "manual", source_detail: dict = None) -> Dict[str, Any]:
|
||||||
import json as _json
|
|
||||||
numbers_json = _json.dumps(numbers or [], ensure_ascii=False)
|
numbers_json = json.dumps(numbers or [], ensure_ascii=False)
|
||||||
detail_json = _json.dumps(source_detail or {}, ensure_ascii=False)
|
detail_json = json.dumps(source_detail or {}, ensure_ascii=False)
|
||||||
is_real_int = 1 if is_real else 0
|
is_real_int = 1 if is_real else 0
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -880,7 +883,7 @@ def get_purchases(draw_no: int = None, days: int = None,
|
|||||||
|
|
||||||
|
|
||||||
def update_purchase(purchase_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
def update_purchase(purchase_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
import json as _json
|
|
||||||
allowed = {"draw_no", "amount", "sets", "prize", "note", "numbers", "is_real", "source_strategy"}
|
allowed = {"draw_no", "amount", "sets", "prize", "note", "numbers", "is_real", "source_strategy"}
|
||||||
updates = {k: v for k, v in data.items() if k in allowed}
|
updates = {k: v for k, v in data.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
@@ -889,7 +892,7 @@ def update_purchase(purchase_id: int, data: Dict[str, Any]) -> Optional[Dict[str
|
|||||||
return _purchase_row_to_dict(row) if row else None
|
return _purchase_row_to_dict(row) if row else None
|
||||||
# SQLite에 전달 전 타입 변환
|
# SQLite에 전달 전 타입 변환
|
||||||
if "numbers" in updates:
|
if "numbers" in updates:
|
||||||
updates["numbers"] = _json.dumps(updates["numbers"], ensure_ascii=False)
|
updates["numbers"] = json.dumps(updates["numbers"], ensure_ascii=False)
|
||||||
if "is_real" in updates:
|
if "is_real" in updates:
|
||||||
updates["is_real"] = 1 if updates["is_real"] else 0
|
updates["is_real"] = 1 if updates["is_real"] else 0
|
||||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||||
@@ -911,7 +914,7 @@ def delete_purchase(purchase_id: int) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def get_purchase_stats() -> Dict[str, Any]:
|
def get_purchase_stats() -> Dict[str, Any]:
|
||||||
import json as _json
|
|
||||||
|
|
||||||
def _calc_group(rows):
|
def _calc_group(rows):
|
||||||
if not rows:
|
if not rows:
|
||||||
@@ -951,7 +954,7 @@ def get_purchase_stats() -> Dict[str, Any]:
|
|||||||
for r in srows:
|
for r in srows:
|
||||||
results_raw = r.get("results", "[]")
|
results_raw = r.get("results", "[]")
|
||||||
try:
|
try:
|
||||||
results = _json.loads(results_raw) if isinstance(results_raw, str) else (results_raw or [])
|
results = json.loads(results_raw) if isinstance(results_raw, str) else (results_raw or [])
|
||||||
except Exception:
|
except Exception:
|
||||||
results = []
|
results = []
|
||||||
for res in results:
|
for res in results:
|
||||||
@@ -1015,8 +1018,8 @@ def get_weekly_report(drw_no: int) -> Optional[Dict[str, Any]]:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
import json as _json
|
|
||||||
return {"drw_no": row["drw_no"], "generated_at": row["generated_at"], **_json.loads(row["report"])}
|
return {"drw_no": row["drw_no"], "generated_at": row["generated_at"], **json.loads(row["report"])}
|
||||||
|
|
||||||
|
|
||||||
def get_all_recommendation_numbers() -> List[List[int]]:
|
def get_all_recommendation_numbers() -> List[List[int]]:
|
||||||
@@ -1086,10 +1089,10 @@ def update_strategy_weight(strategy: str, weight: float, ema_score: float,
|
|||||||
|
|
||||||
def update_purchase_results(purchase_id: int, results: list, total_prize: int) -> None:
|
def update_purchase_results(purchase_id: int, results: list, total_prize: int) -> None:
|
||||||
"""구매 건의 결과를 갱신 (체커 호출 후)"""
|
"""구매 건의 결과를 갱신 (체커 호출 후)"""
|
||||||
import json as _json
|
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE purchase_history SET results=?, total_prize=?, checked=1 WHERE id=?",
|
"UPDATE purchase_history SET results=?, total_prize=?, checked=1 WHERE id=?",
|
||||||
(_json.dumps(results, ensure_ascii=False), total_prize, purchase_id),
|
(json.dumps(results, ensure_ascii=False), total_prize, purchase_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -24,26 +24,7 @@ from .db import (
|
|||||||
replace_best_picks,
|
replace_best_picks,
|
||||||
)
|
)
|
||||||
from .analyzer import build_analysis_cache, build_number_weights, score_combination
|
from .analyzer import build_analysis_cache, build_number_weights, score_combination
|
||||||
|
from .utils import weighted_sample_6
|
||||||
|
|
||||||
def _weighted_sample_6(weights: Dict[int, float]) -> List[int]:
|
|
||||||
"""
|
|
||||||
가중 확률 샘플링으로 중복 없이 6개 번호 추출.
|
|
||||||
weights: {1: w1, 2: w2, ..., 45: w45}
|
|
||||||
"""
|
|
||||||
pool = list(range(1, 46))
|
|
||||||
chosen: List[int] = []
|
|
||||||
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
|
|
||||||
return chosen
|
|
||||||
|
|
||||||
|
|
||||||
def run_simulation(
|
def run_simulation(
|
||||||
@@ -82,7 +63,7 @@ def run_simulation(
|
|||||||
attempts = 0
|
attempts = 0
|
||||||
while len(candidates) < n_candidates and attempts < max_attempts:
|
while len(candidates) < n_candidates and attempts < max_attempts:
|
||||||
attempts += 1
|
attempts += 1
|
||||||
nums = _weighted_sample_6(weights)
|
nums = weighted_sample_6(weights)
|
||||||
key = tuple(sorted(nums))
|
key = tuple(sorted(nums))
|
||||||
if key in seen_keys:
|
if key in seen_keys:
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import random
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from typing import Dict, Any, List, Tuple
|
from typing import Dict, Any, List, Tuple
|
||||||
|
|
||||||
|
from .utils import weighted_sample_6
|
||||||
|
|
||||||
def recommend_numbers(
|
def recommend_numbers(
|
||||||
draws: List[Tuple[int, List[int]]],
|
draws: List[Tuple[int, List[int]]],
|
||||||
*,
|
*,
|
||||||
@@ -40,20 +42,7 @@ def recommend_numbers(
|
|||||||
weights[n] = max(w, 0.1)
|
weights[n] = max(w, 0.1)
|
||||||
|
|
||||||
# 중복 없이 6개 뽑기(가중 샘플링)
|
# 중복 없이 6개 뽑기(가중 샘플링)
|
||||||
chosen = []
|
chosen_sorted = sorted(weighted_sample_6(weights))
|
||||||
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)
|
|
||||||
|
|
||||||
explain = {
|
explain = {
|
||||||
"recent_window": recent_window,
|
"recent_window": recent_window,
|
||||||
@@ -130,20 +119,7 @@ def recommend_with_heatmap(
|
|||||||
weights[n] = max(w, 0.1)
|
weights[n] = max(w, 0.1)
|
||||||
|
|
||||||
# 4. 가중 샘플링으로 6개 선택
|
# 4. 가중 샘플링으로 6개 선택
|
||||||
chosen = []
|
chosen_sorted = sorted(weighted_sample_6(weights))
|
||||||
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. 설명 데이터
|
# 5. 설명 데이터
|
||||||
explain = {
|
explain = {
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
|
import random
|
||||||
from typing import List, Dict, Any, Tuple
|
from typing import List, Dict, Any, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def weighted_sample_6(weights: Dict[int, float]) -> List[int]:
|
||||||
|
"""
|
||||||
|
가중 확률 샘플링으로 중복 없이 6개 번호 추출.
|
||||||
|
weights: {1: w1, 2: w2, ..., 45: w45}
|
||||||
|
"""
|
||||||
|
pool = list(range(1, 46))
|
||||||
|
chosen: List[int] = []
|
||||||
|
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
|
||||||
|
return chosen
|
||||||
|
|
||||||
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
|
def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
|
||||||
nums = sorted(numbers)
|
nums = sorted(numbers)
|
||||||
s = sum(nums)
|
s = sum(nums)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM python:3.12-alpine
|
FROM python:3.12-alpine
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -94,7 +94,7 @@ def start_research(req: ResearchRequest, background_tasks: BackgroundTasks):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/blog-marketing/research/history")
|
@app.get("/api/blog-marketing/research/history")
|
||||||
def list_research(limit: int = 30):
|
def list_research(limit: int = Query(30, ge=1, le=100)):
|
||||||
return {"analyses": get_keyword_analyses(limit)}
|
return {"analyses": get_keyword_analyses(limit)}
|
||||||
|
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ def start_regenerate(post_id: int, background_tasks: BackgroundTasks):
|
|||||||
# ── 포스트 CRUD API ──────────────────────────────────────────────────────────
|
# ── 포스트 CRUD API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/blog-marketing/posts")
|
@app.get("/api/blog-marketing/posts")
|
||||||
def list_posts(status: str = None, limit: int = 50):
|
def list_posts(status: str = None, limit: int = Query(50, ge=1, le=100)):
|
||||||
return {"posts": get_posts(status=status, limit=limit)}
|
return {"posts": get_posts(status=status, limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
@@ -409,7 +409,7 @@ def start_market(post_id: int, background_tasks: BackgroundTasks):
|
|||||||
# ── 수익 추적 API ────────────────────────────────────────────────────────────
|
# ── 수익 추적 API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.get("/api/blog-marketing/commissions")
|
@app.get("/api/blog-marketing/commissions")
|
||||||
def list_commissions(post_id: int = None, limit: int = 100):
|
def list_commissions(post_id: int = None, limit: int = Query(100, ge=1, le=100)):
|
||||||
return {"commissions": get_commissions(post_id=post_id, limit=limit)}
|
return {"commissions": get_commissions(post_id=post_id, limit=limit)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import os, hmac, hashlib, subprocess, threading
|
import os, hmac, hashlib, subprocess, threading
|
||||||
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -64,6 +65,13 @@ async def webhook(req: Request, background_tasks: BackgroundTasks):
|
|||||||
if not verify(sig, body):
|
if not verify(sig, body):
|
||||||
raise HTTPException(401, "bad signature")
|
raise HTTPException(401, "bad signature")
|
||||||
|
|
||||||
|
# 동시 배포 방지: 이미 진행 중이면 503 반환
|
||||||
|
if _deploy_lock.locked():
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=503,
|
||||||
|
content={"ok": False, "message": "Deploy already in progress"},
|
||||||
|
)
|
||||||
|
|
||||||
# ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행
|
# ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행
|
||||||
background_tasks.add_task(run_deploy_script)
|
background_tasks.add_task(run_deploy_script)
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ services:
|
|||||||
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
|
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
|
||||||
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
|
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
|
||||||
- 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:-http://localhost:3007,http://localhost:8080}
|
||||||
volumes:
|
volumes:
|
||||||
- ${PHOTO_PATH}:/data/travel:ro
|
- ${PHOTO_PATH}:/data/travel:ro
|
||||||
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM python:3.12-alpine
|
FROM python:3.12-alpine
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM python:3.12-alpine
|
FROM python:3.12-alpine
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|||||||
@@ -183,12 +183,6 @@ def get_all_broker_cash() -> List[Dict[str, Any]]:
|
|||||||
return [dict(r) for r in rows]
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def get_broker_cash(broker: str) -> Dict[str, Any] | None:
|
|
||||||
with _conn() as conn:
|
|
||||||
row = conn.execute("SELECT * FROM broker_cash WHERE broker = ?", (broker,)).fetchone()
|
|
||||||
return dict(row) if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def delete_broker_cash(broker: str) -> bool:
|
def delete_broker_cash(broker: str) -> bool:
|
||||||
with _conn() as conn:
|
with _conn() as conn:
|
||||||
cur = conn.execute("DELETE FROM broker_cash WHERE broker = ?", (broker,))
|
cur = conn.execute("DELETE FROM broker_cash WHERE broker = ?", (broker,))
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ from .db import (
|
|||||||
init_db, save_articles, get_latest_articles,
|
init_db, save_articles, get_latest_articles,
|
||||||
add_portfolio_item, get_all_portfolio, get_portfolio_item,
|
add_portfolio_item, get_all_portfolio, get_portfolio_item,
|
||||||
update_portfolio_item, delete_portfolio_item,
|
update_portfolio_item, delete_portfolio_item,
|
||||||
upsert_broker_cash, get_all_broker_cash, get_broker_cash, delete_broker_cash,
|
upsert_broker_cash, get_all_broker_cash, delete_broker_cash,
|
||||||
upsert_asset_snapshot, get_asset_snapshots,
|
upsert_asset_snapshot, get_asset_snapshots,
|
||||||
add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
|
add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
|
||||||
)
|
)
|
||||||
from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news
|
from .scraper import fetch_market_news, fetch_major_indices
|
||||||
from .price_fetcher import get_current_prices
|
from .price_fetcher import get_current_prices
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
@@ -119,16 +119,10 @@ def on_startup():
|
|||||||
def run_scraping_job():
|
def run_scraping_job():
|
||||||
logger.info("뉴스 스크래핑 시작")
|
logger.info("뉴스 스크래핑 시작")
|
||||||
|
|
||||||
# 1. 국내
|
|
||||||
articles_kr = fetch_market_news()
|
articles_kr = fetch_market_news()
|
||||||
count_kr = save_articles(articles_kr)
|
count_kr = save_articles(articles_kr)
|
||||||
|
|
||||||
# 2. 해외 (임시 차단)
|
logger.info(f"스크래핑 완료: 국내 {count_kr}건")
|
||||||
# articles_world = fetch_overseas_news()
|
|
||||||
# count_world = save_articles(articles_world)
|
|
||||||
count_world = 0
|
|
||||||
|
|
||||||
logger.info(f"스크래핑 완료: 국내 {count_kr}건, 해외 {count_world}건")
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
@@ -156,14 +150,16 @@ def trigger_scrap():
|
|||||||
def get_balance():
|
def get_balance():
|
||||||
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
|
"""계좌 잔고 조회 (Windows AI Server Proxy)"""
|
||||||
logger.info(f"Requesting Balance from {WINDOWS_AI_SERVER_URL}")
|
logger.info(f"Requesting Balance from {WINDOWS_AI_SERVER_URL}")
|
||||||
|
resp = None
|
||||||
try:
|
try:
|
||||||
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
|
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logger.error(f"Balance Error: {resp.status_code}")
|
logger.error(f"Balance Error: {resp.status_code}")
|
||||||
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
return resp.json()
|
return resp.json()
|
||||||
except requests.JSONDecodeError:
|
except ValueError:
|
||||||
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
|
status = resp.status_code if resp is not None else 502
|
||||||
|
return JSONResponse(status_code=status, content={"error": f"Upstream error {status}"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Balance Connection Failed: {e}")
|
logger.error(f"Balance Connection Failed: {e}")
|
||||||
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
|
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
|
||||||
@@ -179,14 +175,16 @@ class OrderRequest(BaseModel):
|
|||||||
def order_stock(req: OrderRequest):
|
def order_stock(req: OrderRequest):
|
||||||
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
|
"""주식 매수/매도 주문 (Windows AI Server Proxy)"""
|
||||||
logger.info(f"Order Request: {req.action} {req.ticker} x{req.quantity}")
|
logger.info(f"Order Request: {req.action} {req.ticker} x{req.quantity}")
|
||||||
|
resp = None
|
||||||
try:
|
try:
|
||||||
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10)
|
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.model_dump(), timeout=10)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
logger.error(f"Order Error: {resp.status_code}")
|
logger.error(f"Order Error: {resp.status_code}")
|
||||||
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
return JSONResponse(status_code=resp.status_code, content=resp.json())
|
||||||
return resp.json()
|
return resp.json()
|
||||||
except requests.JSONDecodeError:
|
except ValueError:
|
||||||
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"})
|
status = resp.status_code if resp is not None else 502
|
||||||
|
return JSONResponse(status_code=status, content={"error": f"Upstream error {status}"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Order Connection Failed: {e}")
|
logger.error(f"Order Connection Failed: {e}")
|
||||||
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
|
return JSONResponse(status_code=500, content={"error": "Connection Failed"})
|
||||||
@@ -368,7 +366,7 @@ def update_portfolio(item_id: int, req: PortfolioUpdateRequest):
|
|||||||
"""포트폴리오 종목 수정"""
|
"""포트폴리오 종목 수정"""
|
||||||
if get_portfolio_item(item_id) is None:
|
if get_portfolio_item(item_id) is None:
|
||||||
return JSONResponse(status_code=404, content={"error": "Item not found"})
|
return JSONResponse(status_code=404, content={"error": "Item not found"})
|
||||||
update_portfolio_item(item_id, **req.dict())
|
update_portfolio_item(item_id, **req.model_dump())
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -79,59 +79,6 @@ def fetch_market_news() -> List[Dict[str, str]]:
|
|||||||
logger.error(f"국내 뉴스 스크래핑 실패: {e}")
|
logger.error(f"국내 뉴스 스크래핑 실패: {e}")
|
||||||
return []
|
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:
|
|
||||||
logger.error(f"해외 뉴스 스크래핑 실패: {e}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def fetch_major_indices() -> Dict[str, Any]:
|
def fetch_major_indices() -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
KOSPI, KOSDAQ, KOSPI200 등 주요 지표 (네이버 금융 홈)
|
KOSPI, KOSDAQ, KOSPI200 등 주요 지표 (네이버 금융 홈)
|
||||||
|
|||||||
Reference in New Issue
Block a user