Coverage for services / naver_finance_scraper_service.py: 93%
51 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 15:08 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-04 15:08 +0000
1# services/naver_finance_scraper_service.py
2import aiohttp
3import logging
4from bs4 import BeautifulSoup
5from typing import Optional
7class NaverFinanceScraperService:
8 """네이버 금융 웹페이지 스크래핑을 전담하는 서비스 클래스."""
10 def __init__(self, logger: Optional[logging.Logger] = None):
11 self._logger = logger or logging.getLogger(__name__)
12 # 차단 방지를 위한 User-Agent 설정
13 self._headers = {
14 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
15 }
17 async def fetch_yoy_profit_growth(self, code: str) -> float:
18 """
19 특정 종목의 최근 분기(또는 예상치)와 전년 동기 영업이익을 비교하여 YoY 성장률을 반환합니다.
20 가장 최근 분기(예상치 등) 데이터가 비어있을 경우, 직전 분기 실적으로 후퇴(Fallback)하여 계산합니다.
21 턴어라운드(적자 -> 흑자 전환) 시그널 발생 시 999.0을 반환합니다.
22 """
23 url = f"https://finance.naver.com/item/main.naver?code={code}"
25 try:
26 async with aiohttp.ClientSession() as session:
27 async with session.get(url, headers=self._headers, timeout=5) as response:
28 if response.status != 200:
29 self._logger.warning({"event": "scraping_http_error", "code": code, "status": response.status})
30 return 0.0
31 html = await response.text()
33 soup = BeautifulSoup(html, 'html.parser')
35 div_cop = soup.find('div', class_='cop_analysis')
36 if not div_cop: return 0.0
38 tbody = div_cop.find('tbody')
39 if not tbody: return 0.0 39 ↛ exitline 39 didn't return from function 'fetch_yoy_profit_growth' because the return on line 39 wasn't executed
41 op_row = None
42 for tr in tbody.find_all('tr'): 42 ↛ 48line 42 didn't jump to line 48 because the loop on line 42 didn't complete
43 th = tr.find('th')
44 if th and '영업이익' in th.text: 44 ↛ 42line 44 didn't jump to line 42 because the condition on line 44 was always true
45 op_row = tr
46 break
48 if not op_row: return 0.0 48 ↛ exitline 48 didn't return from function 'fetch_yoy_profit_growth' because the return on line 48 wasn't executed
50 tds = op_row.find_all('td')
51 quarterly_tds = tds[4:] # 앞 4열은 연간, 뒤 6열은 최근 분기
53 # 최소한 후퇴(Fallback) 로직을 처리할 수 있을 만큼의 열이 있는지 확인
54 if len(quarterly_tds) < 6: return 0.0 54 ↛ exitline 54 didn't return from function 'fetch_yoy_profit_growth' because the return on line 54 wasn't executed
56 # 1. 우선 가장 최근 분기(-1)와 전년 동기(-5) 데이터 추출 시도
57 latest_str = quarterly_tds[-1].text.replace(',', '').strip()
58 yoy_str = quarterly_tds[-5].text.replace(',', '').strip()
60 # 2. 대안(Fallback) 탐색: 최신 데이터가 비어있거나 '-'인 경우, 직전 확정 분기(-2)로 후퇴
61 if not latest_str or latest_str == '-':
62 latest_str = quarterly_tds[-2].text.replace(',', '').strip()
63 yoy_str = quarterly_tds[-6].text.replace(',', '').strip()
64 self._logger.debug({"event": "fallback_to_previous_quarter", "code": code})
66 # 3. 후퇴 후에도 데이터가 유효하지 않으면 0.0 반환
67 if not latest_str or latest_str == '-' or not yoy_str or yoy_str == '-':
68 return 0.0
70 latest_op = float(latest_str)
71 yoy_op = float(yoy_str)
73 # --- 턴어라운드 (적자 -> 흑자) 예외 처리 ---
74 if yoy_op <= 0 and latest_op > 0:
75 self._logger.debug({"event": "turnaround_detected", "code": code, "yoy": yoy_op, "latest": latest_op})
76 return 999.0
78 # --- 일반 YoY 성장률 계산 ---
79 if yoy_op > 0 and latest_op > 0:
80 return ((latest_op - yoy_op) / yoy_op) * 100.0
82 return 0.0
84 except Exception as e:
85 self._logger.warning({"event": "scraping_failed", "code": code, "error": str(e)})
86 return 0.0