Very Good Web Server Work
This commit is contained in:
@@ -1,113 +1,200 @@
|
||||
import requests
|
||||
import httpx
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Dict, Optional
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import update
|
||||
|
||||
# 프로젝트 구조에 따라 .models 또는 models에서 Asset을 가져옵니다.
|
||||
try:
|
||||
from .models import Asset
|
||||
except ImportError:
|
||||
from models import Asset
|
||||
|
||||
class DataFetcher:
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'
|
||||
})
|
||||
# 인베스팅닷컴은 헤더가 없으면 403 에러를 뱉습니다. 브라우저와 동일하게 설정합니다.
|
||||
self.headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Pragma': 'no-cache'
|
||||
}
|
||||
self.daily_closing_prices = {}
|
||||
|
||||
def fetch_investing_com(self, asset_code: str) -> Optional[float]:
|
||||
"""인베스팅닷컴 (윈도우 앱 방식 정규식 적용)"""
|
||||
async def fetch_google_finance(self, client: httpx.AsyncClient, asset_code: str) -> Optional[float]:
|
||||
"""인베스팅보다 빠르고 야후보다 안정적인 구글 파이낸스 우회"""
|
||||
try:
|
||||
url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}"
|
||||
# 구글 파이낸스 환율/지수 URL
|
||||
symbol = "USD-KRW" if asset_code == "USD/KRW" else "INDEXDXY:CURRENCY" if asset_code == "USD/DXY" else None
|
||||
if not symbol: return None
|
||||
|
||||
url = f"https://www.google.com/finance/quote/{symbol}"
|
||||
# 구글은 헤더만 있으면 응답 속도가 정말 빠릅니다.
|
||||
res = await client.get(url, timeout=5)
|
||||
if res.status_code != 200: return None
|
||||
|
||||
# 구글 특유의 가격 클래스 추출 (정규식)
|
||||
m = re.search(r'data-last-price="([\d,.]+)"', res.text)
|
||||
if m:
|
||||
return float(m.group(1).replace(",", ""))
|
||||
except Exception as e:
|
||||
print(f"⚠️ Google Finance 에러 ({asset_code}): {e}")
|
||||
return None
|
||||
|
||||
async def fetch_investing_com(self, client: httpx.AsyncClient, asset_code: str) -> Optional[float]:
|
||||
"""인베스팅닷컴 비동기 수집 (USD/KRW 포함 전용)"""
|
||||
try:
|
||||
# 자산별 URL 매핑
|
||||
if asset_code == "USD/DXY":
|
||||
url = "https://www.investing.com/indices/usdollar"
|
||||
# elif asset_code == "USD/KRW":
|
||||
# url = "https://kr.investing.com/currencies/usd-krw"
|
||||
else:
|
||||
url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}"
|
||||
|
||||
# allow_redirects를 True로 하여 주소 변경에 대응
|
||||
response = self.session.get(url, timeout=10, allow_redirects=True)
|
||||
# 인베스팅은 쿠키와 리다이렉트가 중요하므로 follow_redirects 사용
|
||||
response = await client.get(url, timeout=15, follow_redirects=True)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"⚠️ Investing 응답 에러 ({asset_code}): {response.status_code}")
|
||||
return None
|
||||
|
||||
html = response.text
|
||||
|
||||
# 윈도우에서 가장 잘 되던 패턴 순서대로 시도
|
||||
# 인베스팅닷컴의 다양한 HTML 구조에 대응하는 정규식 (우선순위 순)
|
||||
patterns = [
|
||||
r'data-test="instrument-price-last">([\d,.]+)<',
|
||||
r'last_last">([\d,.]+)<',
|
||||
r'instrument-price-last">([\d,.]+)<'
|
||||
r'data-test="instrument-price-last">([\d,.]+)<', # 최신 메인 패턴
|
||||
r'last_last">([\d,.]+)<', # 구형/세부 페이지 패턴
|
||||
r'instrument-price-last">([\d,.]+)<', # 클래식 패턴
|
||||
r'class="[^"]*text-2xl[^"]*">([\d,.]+)<' # 비상용 패턴
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
p = re.search(pattern, html)
|
||||
if p:
|
||||
return float(p.group(1).replace(',', ''))
|
||||
val_str = p.group(1).replace(',', '')
|
||||
return float(val_str)
|
||||
|
||||
print(f"⚠️ Investing 패턴 매칭 실패 ({asset_code})")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Investing 수집 실패 ({asset_code}): {e}")
|
||||
print(f"⚠️ Investing 시스템 에러 ({asset_code}): {e}")
|
||||
return None
|
||||
|
||||
def fetch_binance(self) -> Optional[float]:
|
||||
"""바이낸스 BTC/USDT (보내주신 윈도우 코드 로직)"""
|
||||
url = "https://api.binance.com/api/v3/ticker/price"
|
||||
async def fetch_binance(self, client: httpx.AsyncClient) -> Optional[float]:
|
||||
"""바이낸스 BTC/USDT"""
|
||||
try:
|
||||
response = requests.get(url, params={"symbol": "BTCUSDT"}, timeout=5)
|
||||
response.raise_for_status()
|
||||
return float(response.json()["price"])
|
||||
except Exception as e:
|
||||
print(f"❌ Binance API 실패: {e}")
|
||||
return None
|
||||
url = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSD"
|
||||
res = await client.get(url, timeout=5)
|
||||
return float(res.json()["price"])
|
||||
except: return None
|
||||
|
||||
def fetch_upbit(self) -> Optional[float]:
|
||||
"""업비트 BTC/KRW (보내주신 윈도우 코드 로직)"""
|
||||
url = "https://api.upbit.com/v1/ticker"
|
||||
async def fetch_upbit(self, client: httpx.AsyncClient) -> Optional[float]:
|
||||
"""업비트 BTC/KRW"""
|
||||
try:
|
||||
response = requests.get(url, params={"markets": "KRW-BTC"}, timeout=5)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return float(data[0]["trade_price"]) if data else None
|
||||
except Exception as e:
|
||||
print(f"❌ Upbit API 실패: {e}")
|
||||
return None
|
||||
url = "https://api.upbit.com/v1/ticker?markets=KRW-BTC"
|
||||
res = await client.get(url, timeout=5)
|
||||
return float(res.json()[0]["trade_price"])
|
||||
except: return None
|
||||
|
||||
def fetch_usd_krw(self) -> Optional[float]:
|
||||
"""USD/KRW 환율 (DNS 에러 방지 이중화)"""
|
||||
# 방법 1: 두나무 CDN (원래 주소)
|
||||
try:
|
||||
url = "https://quotation-api-cdn.dunamu.com/v1/forex/recent?codes=FRX.KRWUSD"
|
||||
res = requests.get(url, timeout=3)
|
||||
if res.status_code == 200:
|
||||
return float(res.json()[0]["basePrice"])
|
||||
except:
|
||||
pass # 실패하면 바로 인베스팅닷컴으로 전환
|
||||
|
||||
# 방법 2: 인베스팅닷컴에서 환율 가져오기 (가장 확실한 백업)
|
||||
return self.fetch_investing_com("USD/KRW")
|
||||
|
||||
def fetch_krx_gold(self) -> Optional[float]:
|
||||
"""금 시세 (네이버 금융 모바일)"""
|
||||
async def fetch_krx_gold(self, client: httpx.AsyncClient) -> Optional[float]:
|
||||
"""네이버 금 시세 (국내)"""
|
||||
try:
|
||||
url = "https://m.stock.naver.com/marketindex/metals/M04020000"
|
||||
res = requests.get(url, timeout=5)
|
||||
m = re.search(r'\"closePrice\":\"([\d,]+)\"', res.text)
|
||||
return float(m.group(1).replace(",", "")) if m else None
|
||||
except:
|
||||
return None
|
||||
res = await client.get(url, timeout=5)
|
||||
m = re.search(r'\"closePrice\":\"([\d,.]+)\"', res.text)
|
||||
if m:
|
||||
return float(m.group(1).replace(",", ""))
|
||||
except: return None
|
||||
|
||||
def fetch_all(self) -> Dict[str, Dict]:
|
||||
print(f"📊 [{time.strftime('%H:%M:%S')}] 수집 시작...")
|
||||
|
||||
# 1. 환율 먼저 수집 (계산의 핵심)
|
||||
usd_krw = self.fetch_usd_krw()
|
||||
|
||||
# 2. 나머지 자산 수집
|
||||
results = {
|
||||
"XAU/USD": {"가격": self.fetch_investing_com("XAU/USD"), "단위": "USD/oz"},
|
||||
"XAU/CNY": {"가격": self.fetch_investing_com("XAU/CNY"), "단위": "CNY/oz"},
|
||||
"XAU/GBP": {"가격": self.fetch_investing_com("XAU/GBP"), "단위": "GBP/oz"},
|
||||
"USD/DXY": {"가격": self.fetch_investing_com("USD/DXY"), "단위": "Index"},
|
||||
"USD/KRW": {"가격": usd_krw, "단위": "KRW"},
|
||||
"BTC/USD": {"가격": self.fetch_binance(), "단위": "USDT"},
|
||||
"BTC/KRW": {"가격": self.fetch_upbit(), "단위": "KRW"},
|
||||
"KRX/GLD": {"가격": self.fetch_krx_gold(), "단위": "KRW/g"},
|
||||
}
|
||||
|
||||
# 3. XAU/KRW 계산
|
||||
xau_krw = None
|
||||
if results["XAU/USD"]["가격"] and usd_krw:
|
||||
xau_krw = round((results["XAU/USD"]["가격"] / 31.1034768) * usd_krw, 0)
|
||||
results["XAU/KRW"] = {"가격": xau_krw, "단위": "KRW/g"}
|
||||
|
||||
success_count = sum(1 for v in results.values() if v['가격'] is not None)
|
||||
print(f"✅ 수집 완료 (성공: {success_count}/9)")
|
||||
return results
|
||||
async def update_closing_prices(self, db: Session):
|
||||
"""매일 아침 기준가를 스냅샷 찍어 메모리에 저장"""
|
||||
results = await self.update_realtime_prices(db)
|
||||
for symbol, price in results.items():
|
||||
if price:
|
||||
self.daily_closing_prices[symbol] = price
|
||||
print(f"📌 [기준가 업데이트] 완료: {self.daily_closing_prices}")
|
||||
|
||||
async def update_realtime_prices(self, db: Session) -> Dict:
|
||||
"""[핵심] 비동기 수집 후 DB 즉시 업데이트"""
|
||||
start_time = time.time()
|
||||
|
||||
async with httpx.AsyncClient(headers=self.headers, follow_redirects=True) as client:
|
||||
# 1. 병렬 수집 태스크 정의 (USD/KRW도 인베스팅닷컴 함수 사용)
|
||||
tasks = {
|
||||
"XAU/USD": self.fetch_investing_com(client, "XAU/USD"),
|
||||
"XAU/CNY": self.fetch_investing_com(client, "XAU/CNY"),
|
||||
"XAU/GBP": self.fetch_investing_com(client, "XAU/GBP"),
|
||||
"USD/DXY": self.fetch_investing_com(client, "USD/DXY"),
|
||||
"USD/KRW": self.fetch_google_finance(client, "USD/KRW"),
|
||||
"BTC/USD": self.fetch_binance(client),
|
||||
"BTC/KRW": self.fetch_upbit(client),
|
||||
"KRX/GLD": self.fetch_krx_gold(client),
|
||||
}
|
||||
|
||||
keys = list(tasks.keys())
|
||||
values = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
||||
|
||||
# 결과 가공
|
||||
raw_results = {}
|
||||
for i, val in enumerate(values):
|
||||
symbol = keys[i]
|
||||
if isinstance(val, (int, float)):
|
||||
raw_results[symbol] = val
|
||||
else:
|
||||
raw_results[symbol] = None
|
||||
print(f"❌ {symbol} 수집 실패: {val}")
|
||||
|
||||
# [수정 구간] 2. XAU/KRW 계산 및 프리미엄용 전일종가 매핑
|
||||
if raw_results.get("XAU/USD") and raw_results.get("USD/KRW"):
|
||||
# (현재가) 국제 금 시세 실시간 계산
|
||||
raw_results["XAU/KRW"] = round((raw_results["XAU/USD"] / 31.1034768) * raw_results["USD/KRW"], 0)
|
||||
|
||||
# [추가] XAU/KRW의 '전일종가' 필드에 'KRX/GLD 현재가'를 강제로 주입
|
||||
# 이렇게 하면 화면에서 (국제계산가 - 국내현물가)가 실시간 변동액으로 표시됨
|
||||
if raw_results.get("KRX/GLD"):
|
||||
from app.models import UserAsset, Asset
|
||||
asset_xau = db.query(Asset).filter(Asset.symbol == "XAU/KRW").first()
|
||||
if asset_xau:
|
||||
db.execute(
|
||||
update(UserAsset)
|
||||
.where(UserAsset.asset_id == asset_xau.id)
|
||||
.values(previous_close=raw_results["KRX/GLD"])
|
||||
)
|
||||
|
||||
# 2) [핵심 추가] 메모리에 저장된 기준가도 실시간 KRX 가격으로 갱신!
|
||||
# 그래야 밑에 있는 '3. DB 업데이트 수행' 루프에서 state(up/down)가 실시간으로 계산됩니다.
|
||||
self.daily_closing_prices["XAU/KRW"] = raw_results["KRX/GLD"]
|
||||
else:
|
||||
raw_results["XAU/KRW"] = None
|
||||
|
||||
# 3. DB 업데이트 수행
|
||||
for symbol, price in raw_results.items():
|
||||
if price is not None:
|
||||
state = "stable"
|
||||
if symbol in self.daily_closing_prices:
|
||||
closing = self.daily_closing_prices[symbol]
|
||||
if price > closing: state = "up"
|
||||
elif price < closing: state = "down"
|
||||
|
||||
# SQL 실행
|
||||
db.execute(
|
||||
update(Asset)
|
||||
.where(Asset.symbol == symbol)
|
||||
.values(
|
||||
current_price=price,
|
||||
price_state=state,
|
||||
last_updated=datetime.now()
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
print(f"✅ [{datetime.now().strftime('%H:%M:%S')}] 수집 및 DB 저장 완료 ({time.time()-start_time:.2f}s)")
|
||||
return raw_results
|
||||
|
||||
# 싱글톤 인스턴스 생성
|
||||
fetcher = DataFetcher()
|
||||
@@ -1,142 +0,0 @@
|
||||
import requests
|
||||
from typing import Dict, Optional
|
||||
from bs4 import BeautifulSoup
|
||||
import time
|
||||
|
||||
class DataFetcher:
|
||||
"""모든 자산 가격 수집 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
})
|
||||
self.investing_cache = {}
|
||||
self.cache_time = 0
|
||||
|
||||
def fetch_investing_com(self, asset_code: str) -> Optional[float]:
|
||||
"""Investing.com에서 가격 수집"""
|
||||
# 간단한 캐싱 (5초)
|
||||
if time.time() - self.cache_time < 5 and asset_code in self.investing_cache:
|
||||
return self.investing_cache[asset_code]
|
||||
|
||||
asset_map = {
|
||||
"XAU/USD": "8830",
|
||||
"XAU/CNY": "2186",
|
||||
"XAU/GBP": "8500",
|
||||
"USD/DXY": "8827"
|
||||
}
|
||||
|
||||
asset_id = asset_map.get(asset_code)
|
||||
if not asset_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
url = f"https://www.investing.com/currencies/{asset_code.lower().replace('/', '-')}"
|
||||
response = self.session.get(url, timeout=5)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
price_elem = soup.select_one('[data-test="instrument-price-last"]')
|
||||
|
||||
if price_elem:
|
||||
price_text = price_elem.text.strip().replace(',', '')
|
||||
price = float(price_text)
|
||||
self.investing_cache[asset_code] = price
|
||||
return price
|
||||
except Exception as e:
|
||||
print(f"Investing.com 수집 실패 ({asset_code}): {e}")
|
||||
|
||||
return None
|
||||
|
||||
def fetch_binance(self) -> Optional[float]:
|
||||
"""바이낸스 BTC/USDT 가격"""
|
||||
try:
|
||||
url = "https://api.binance.com/api/v3/ticker/price"
|
||||
response = self.session.get(url, params={"symbol": "BTCUSDT"}, timeout=5)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return float(data["price"]) if "price" in data else None
|
||||
except Exception as e:
|
||||
print(f"Binance API 실패: {e}")
|
||||
return None
|
||||
|
||||
def fetch_upbit(self) -> Optional[float]:
|
||||
"""업비트 BTC/KRW 가격"""
|
||||
try:
|
||||
url = "https://api.upbit.com/v1/ticker"
|
||||
response = self.session.get(url, params={"markets": "KRW-BTC"}, timeout=5)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data[0]["trade_price"] if data and "trade_price" in data[0] else None
|
||||
except Exception as e:
|
||||
print(f"Upbit API 실패: {e}")
|
||||
return None
|
||||
|
||||
def fetch_usd_krw(self) -> Optional[float]:
|
||||
"""USD/KRW 환율"""
|
||||
try:
|
||||
url = "https://quotation-api-cdn.dunamu.com/v1/forex/recent?codes=FRX.KRWUSD"
|
||||
response = self.session.get(url, timeout=5)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data[0]["basePrice"] if data else None
|
||||
except Exception as e:
|
||||
print(f"USD/KRW 수집 실패: {e}")
|
||||
return None
|
||||
|
||||
def fetch_krx_gold(self) -> Optional[float]:
|
||||
"""한국거래소 금 현물 가격"""
|
||||
try:
|
||||
url = "http://www.goldpr.co.kr/gms/default.asp"
|
||||
response = self.session.get(url, timeout=5)
|
||||
response.encoding = 'euc-kr'
|
||||
|
||||
soup = BeautifulSoup(response.text, 'lxml')
|
||||
|
||||
# 금 현물 가격 파싱 (사이트 구조에 따라 조정 필요)
|
||||
price_elem = soup.select_one('table tr:nth-of-type(2) td:nth-of-type(2)')
|
||||
if price_elem:
|
||||
price_text = price_elem.text.strip().replace(',', '').replace('원', '')
|
||||
return float(price_text)
|
||||
except Exception as e:
|
||||
print(f"KRX 금 가격 수집 실패: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def fetch_all(self) -> Dict[str, Dict]:
|
||||
"""모든 자산 가격 수집"""
|
||||
print("📊 데이터 수집 시작...")
|
||||
|
||||
# 개별 자산 수집
|
||||
xau_usd = self.fetch_investing_com("XAU/USD")
|
||||
xau_cny = self.fetch_investing_com("XAU/CNY")
|
||||
xau_gbp = self.fetch_investing_com("XAU/GBP")
|
||||
usd_dxy = self.fetch_investing_com("USD/DXY")
|
||||
usd_krw = self.fetch_usd_krw()
|
||||
btc_usd = self.fetch_binance()
|
||||
btc_krw = self.fetch_upbit()
|
||||
krx_gold = self.fetch_krx_gold()
|
||||
|
||||
# XAU/KRW 계산 (트로이온스 -> 그램당 원화)
|
||||
xau_krw = None
|
||||
if xau_usd and usd_krw:
|
||||
xau_krw = round((xau_usd / 31.1034768) * usd_krw, 0)
|
||||
|
||||
results = {
|
||||
"XAU/USD": {"가격": xau_usd, "단위": "USD/oz"},
|
||||
"XAU/CNY": {"가격": xau_cny, "단위": "CNY/oz"},
|
||||
"XAU/GBP": {"가격": xau_gbp, "단위": "GBP/oz"},
|
||||
"USD/DXY": {"가격": usd_dxy, "단위": "Index"},
|
||||
"USD/KRW": {"가격": usd_krw, "단위": "KRW"},
|
||||
"BTC/USD": {"가격": btc_usd, "단위": "USDT"},
|
||||
"BTC/KRW": {"가격": btc_krw, "단위": "KRW"},
|
||||
"KRX/GLD": {"가격": krx_gold, "단위": "KRW/g"},
|
||||
"XAU/KRW": {"가격": xau_krw, "단위": "KRW/g"},
|
||||
}
|
||||
|
||||
print(f"✅ 데이터 수집 완료 (성공: {sum(1 for v in results.values() if v['가격'])}/9)")
|
||||
return results
|
||||
|
||||
# 전역 인스턴스
|
||||
fetcher = DataFetcher()
|
||||
@@ -15,6 +15,12 @@ class Asset(Base):
|
||||
category = Column(String(50)) # 귀금속, 암호화폐, 환율 등
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# --- 새로 추가한 실시간 데이터 컬럼 ---
|
||||
current_price = Column(Float) # 실시간 현재가
|
||||
price_state = Column(String(20), default="stable") # up, down, stable
|
||||
last_updated = Column(DateTime) # 마지막 수집 시각
|
||||
# ------------------------------------
|
||||
|
||||
# 관계
|
||||
user_assets = relationship("UserAsset", back_populates="asset")
|
||||
price_history = relationship("PriceHistory", back_populates="asset")
|
||||
|
||||
Reference in New Issue
Block a user