# 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 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) 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}' size=15 pages=1 save_to_file=False") 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에는 음식 카테고리만, region은 분리 payload = { "query": search_query, # 🔧 음식 카테고리만 포함 "region": region, # 🔧 지역 정보는 별도 파라미터 "size": 15, "pages": 1, # 페이지별로 하나씩 호출 "save_to_file": False } logger.info(f"동종 업체 검색 페이지 {page}: query='{search_query}' region='{region}'") 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) # 카테고리 필터링 restaurant_category = extract_food_category(restaurant.category_name) if self._is_similar_food_category(food_category, restaurant_category): similar_stores.append(restaurant) logger.debug(f"유사 카테고리 매치: {restaurant.place_name} ({restaurant_category})") except Exception as e: logger.warning(f"음식점 데이터 파싱 실패: {e}") continue logger.info(f"페이지 {page} 완료: {len(restaurants)}개 음식점 수집") else: logger.warning(f"동종 업체 검색 실패 (페이지 {page}): HTTP {response.status}") continue 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 _is_similar_food_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