231 lines
9.4 KiB
Python
231 lines
9.4 KiB
Python
# app/services/restaurant_service.py
|
|
import aiohttp
|
|
import asyncio
|
|
import logging
|
|
from typing import List, Optional, Dict, Any
|
|
from ..config.settings import settings
|
|
from ..models.restaurant_models import RestaurantInfo
|
|
from ..utils.category_utils import extract_food_category
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
class RestaurantService:
|
|
"""음식점 API 연동 서비스"""
|
|
|
|
def __init__(self):
|
|
self.base_url = settings.get_restaurant_api_url()
|
|
self.timeout = aiohttp.ClientTimeout(total=settings.REQUEST_TIMEOUT)
|
|
|
|
async def find_store_by_name_and_region(self, region: str, store_name: str) -> Optional[RestaurantInfo]:
|
|
"""
|
|
지역과 가게명으로 가게를 찾습니다.
|
|
|
|
Args:
|
|
region: 지역 (시군구 + 읍면동)
|
|
store_name: 가게명
|
|
|
|
Returns:
|
|
찾은 가게 정보 (첫 번째 결과)
|
|
"""
|
|
try:
|
|
logger.info(f"가게 검색 시작: region={region}, store_name={store_name}")
|
|
|
|
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
|
# Restaurant API 호출
|
|
url = f"{self.base_url}/collect"
|
|
payload = {
|
|
"query": store_name,
|
|
"region": region,
|
|
"size": 15,
|
|
"pages": 3,
|
|
"save_to_file": False
|
|
}
|
|
|
|
logger.info(f"Restaurant API 호출: {url}")
|
|
async with session.post(url, json=payload) as response:
|
|
if response.status == 200:
|
|
data = await response.json()
|
|
restaurants = data.get('restaurants', [])
|
|
|
|
if restaurants:
|
|
# 첫 번째 결과 반환
|
|
restaurant_data = restaurants[0]
|
|
restaurant = RestaurantInfo(**restaurant_data)
|
|
|
|
logger.info(f"가게 찾기 성공: {restaurant.place_name}")
|
|
return restaurant
|
|
else:
|
|
logger.warning(f"가게를 찾을 수 없음: {store_name}")
|
|
return None
|
|
else:
|
|
logger.error(f"Restaurant API 호출 실패: HTTP {response.status}")
|
|
error_text = await response.text()
|
|
logger.error(f"Error response: {error_text}")
|
|
return None
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.error("Restaurant API 호출 타임아웃")
|
|
return None
|
|
except Exception as e:
|
|
logger.error(f"가게 검색 중 오류: {str(e)}")
|
|
return None
|
|
|
|
async def find_similar_stores(self, region: str, food_category: str, max_count: int = 50) -> List[RestaurantInfo]:
|
|
"""
|
|
동종 업체를 찾습니다.
|
|
|
|
Args:
|
|
region: 지역
|
|
food_category: 음식 카테고리
|
|
max_count: 최대 검색 개수
|
|
|
|
Returns:
|
|
동종 업체 목록
|
|
"""
|
|
try:
|
|
logger.info(f"동종 업체 검색 시작: region={region}, food_category={food_category}")
|
|
|
|
# 검색 쿼리 생성 (음식 카테고리만 포함)
|
|
search_query = self._clean_food_category(food_category)
|
|
logger.info(f"음식점 수집 요청: query='{search_query}' region='{region}'")
|
|
|
|
similar_stores = []
|
|
|
|
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
|
# 페이지별로 검색 (최대 5페이지)
|
|
max_pages = min(5, (max_count // 15) + 1)
|
|
|
|
for page in range(1, max_pages + 1):
|
|
if len(similar_stores) >= max_count:
|
|
break
|
|
|
|
url = f"{self.base_url}/collect"
|
|
payload = {
|
|
"query": search_query, # 음식 카테고리만 포함
|
|
"region": region, # 지역 정보는 별도 파라미터
|
|
"size": 15,
|
|
"pages": 1, # 페이지별로 하나씩 호출
|
|
"save_to_file": False
|
|
}
|
|
|
|
logger.info(f"동종 업체 검색 ({page}/{max_pages}): {url}")
|
|
|
|
try:
|
|
async with session.post(url, json=payload) as response:
|
|
if response.status == 200:
|
|
data = await response.json()
|
|
restaurants = data.get('restaurants', [])
|
|
|
|
for restaurant_data in restaurants:
|
|
if len(similar_stores) >= max_count:
|
|
break
|
|
|
|
try:
|
|
restaurant = RestaurantInfo(**restaurant_data)
|
|
|
|
# 카테고리 유사성 검사
|
|
if self._is_similar_category(food_category, restaurant.category_name):
|
|
similar_stores.append(restaurant)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"음식점 데이터 파싱 실패: {e}")
|
|
continue
|
|
|
|
logger.info(f"페이지 {page} 완료: {len(restaurants)}개 발견, 총 {len(similar_stores)}개 수집")
|
|
|
|
else:
|
|
logger.warning(f"페이지 {page} API 호출 실패: HTTP {response.status}")
|
|
break
|
|
|
|
except Exception as e:
|
|
logger.warning(f"페이지 {page} 처리 실패: {e}")
|
|
continue
|
|
|
|
# API 호출 간격 조절
|
|
await asyncio.sleep(settings.REQUEST_DELAY)
|
|
|
|
logger.info(f"동종 업체 검색 완료: {len(similar_stores)}개 발견")
|
|
return similar_stores
|
|
|
|
except Exception as e:
|
|
logger.error(f"동종 업체 검색 중 오류: {str(e)}")
|
|
return []
|
|
|
|
def _clean_food_category(self, food_category: str) -> str:
|
|
"""
|
|
음식 카테고리를 정리하여 검색 키워드로 변환합니다.
|
|
|
|
Args:
|
|
food_category: 원본 음식 카테고리 (예: "육류,고기")
|
|
|
|
Returns:
|
|
정리된 검색 키워드 (예: "육류 고기")
|
|
"""
|
|
if not food_category:
|
|
return "음식점"
|
|
|
|
# 콤마와 슬래시를 공백으로 변경
|
|
cleaned = food_category.replace(',', ' ').replace('/', ' ')
|
|
|
|
# 불필요한 단어 제거
|
|
stop_words = ['음식점', '요리', '전문점', '맛집']
|
|
keywords = []
|
|
|
|
for keyword in cleaned.split():
|
|
keyword = keyword.strip()
|
|
if keyword and keyword not in stop_words:
|
|
keywords.append(keyword)
|
|
|
|
# 키워드가 없으면 기본 검색어 사용
|
|
if not keywords:
|
|
return "음식점"
|
|
|
|
# 지역 정보는 포함하지 않고 음식 카테고리만 반환
|
|
return ' '.join(keywords)
|
|
|
|
def _is_similar_category(self, target_category: str, restaurant_category: str) -> bool:
|
|
"""
|
|
두 카테고리가 유사한지 판단합니다.
|
|
|
|
Args:
|
|
target_category: 대상 카테고리
|
|
restaurant_category: 음식점 카테고리
|
|
|
|
Returns:
|
|
유사성 여부
|
|
"""
|
|
if not target_category or not restaurant_category:
|
|
return False
|
|
|
|
# 정규화
|
|
target_lower = target_category.lower().strip()
|
|
restaurant_lower = restaurant_category.lower().strip()
|
|
|
|
# 완전 일치
|
|
if target_lower == restaurant_lower:
|
|
return True
|
|
|
|
# 키워드 기반 매칭
|
|
target_keywords = set(target_lower.replace(',', ' ').replace('/', ' ').split())
|
|
restaurant_keywords = set(restaurant_lower.replace(',', ' ').replace('/', ' ').split())
|
|
|
|
# 교집합이 있으면 유사한 것으로 판단
|
|
common_keywords = target_keywords.intersection(restaurant_keywords)
|
|
|
|
return len(common_keywords) > 0
|
|
|
|
async def health_check(self) -> bool:
|
|
"""
|
|
Restaurant API 상태를 확인합니다.
|
|
|
|
Returns:
|
|
API 상태 (True: 정상, False: 비정상)
|
|
"""
|
|
try:
|
|
async with aiohttp.ClientSession(timeout=self.timeout) as session:
|
|
url = f"{self.base_url}/health"
|
|
async with session.get(url) as response:
|
|
return response.status == 200
|
|
except Exception as e:
|
|
logger.error(f"Restaurant API 헬스체크 실패: {e}")
|
|
return False |