import time import requests from bs4 import BeautifulSoup from typing import Optional # 캐시는 detail 단위(가격+세션+as_of)로 보관. 호환용 단순 가격은 여기서 추출. _cache: dict[str, tuple[Optional[dict], float]] = {} # ticker -> (detail | None, timestamp) _CACHE_TTL = 180 # 3분 _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" ) } def _parse_price_str(value) -> Optional[int]: if value is None: return None s = str(value).replace(",", "").strip() if not s: return None # 음수/소수점도 일단 정수 라운드(국내 주식은 정수) try: return int(float(s)) except ValueError: return None def _select_price_from_response(payload: dict) -> dict: """네이버 모바일 주식 API 응답 dict에서 (price, session, as_of)를 결정. 세션 분류: - "REGULAR" : 정규장(KRX) 운영중 — closePrice가 실시간 - "NXT_PRE" : 정규장 마감 + NXT 프리마켓 운영중 → overPrice 사용 - "NXT_AFTER" : 정규장 마감 + NXT 애프터마켓 운영중 → overPrice 사용 - "CLOSED" : 정규장 마감 + NXT 비운영/거래중지 → closePrice 사용 반환 dict: {"price": int | None, "session": str, "as_of": str | None} """ close_price = _parse_price_str(payload.get("closePrice") or payload.get("stockEndPrice")) top_as_of = payload.get("localTradedAt") market_status = (payload.get("marketStatus") or "").upper() if market_status == "OPEN": return {"price": close_price, "session": "REGULAR", "as_of": top_as_of} over = payload.get("overMarketPriceInfo") if isinstance(over, dict): over_status = (over.get("overMarketStatus") or "").upper() trade_stop_name = ((over.get("tradeStopType") or {}).get("name") or "").upper() if over_status == "OPEN" and trade_stop_name == "TRADING": over_price = _parse_price_str(over.get("overPrice")) if over_price is not None: session_type = (over.get("tradingSessionType") or "").upper() if session_type == "PRE_MARKET": session = "NXT_PRE" elif session_type == "AFTER_MARKET": session = "NXT_AFTER" else: # 알 수 없는 NXT 세션은 보수적으로 AFTER 취급 session = "NXT_AFTER" return { "price": over_price, "session": session, "as_of": over.get("localTradedAt") or top_as_of, } return {"price": close_price, "session": "CLOSED", "as_of": top_as_of} def _fetch_mobile_api_payload(ticker: str) -> Optional[dict]: """네이버 모바일 주식 API 응답 dict 반환.""" url = f"https://m.stock.naver.com/api/stock/{ticker}/basic" try: resp = requests.get(url, headers=_HEADERS, timeout=5) resp.raise_for_status() return resp.json() except Exception: return None def _fetch_close_price_from_html(ticker: str) -> Optional[int]: """네이버 금융 HTML 폴백 (정규장 종가만 가능, NXT 정보 없음).""" url = f"https://finance.naver.com/item/main.naver?code={ticker}" try: resp = requests.get(url, headers=_HEADERS, timeout=5) resp.raise_for_status() soup = BeautifulSoup(resp.content, "html.parser", from_encoding="cp949") tag = soup.select_one(".no_today .blind") if tag: return _parse_price_str(tag.get_text(strip=True)) return None except Exception: return None def get_current_price_info(ticker: str) -> Optional[dict]: """단건 상세 가격 정보 조회 (3분 캐시). 반환: {"price": int | None, "session": str, "as_of": str | None} | None """ now = time.time() cached = _cache.get(ticker) if cached and (now - cached[1]) < _CACHE_TTL: return cached[0] detail: Optional[dict] = None payload = _fetch_mobile_api_payload(ticker) if isinstance(payload, dict): detail = _select_price_from_response(payload) if detail.get("price") is None: detail = None # 폴백 시도 if detail is None: fallback_price = _fetch_close_price_from_html(ticker) if fallback_price is not None: detail = {"price": fallback_price, "session": "CLOSED", "as_of": None} _cache[ticker] = (detail, now) return detail def get_current_prices_detail(tickers: list[str]) -> dict[str, Optional[dict]]: """배치 상세 가격 조회 (캐시 미스 종목만 실제 호출).""" return {ticker: get_current_price_info(ticker) for ticker in tickers} def get_current_price(ticker: str) -> Optional[int]: """단건 현재가 조회 — 호환용. detail에서 price만 추출.""" detail = get_current_price_info(ticker) return detail["price"] if detail else None def get_current_prices(tickers: list[str]) -> dict[str, Optional[int]]: """배치 현재가 조회 — 호환용.""" return {ticker: get_current_price(ticker) for ticker in tickers}