"""외부 장소 DB 의 분류 → 우리 업종(PlaceCategory). ★ 업종 판별에 LLM 을 부르지 않는다. 상호명으로 카카오·네이버를 부르는 건 어차피 하는 일이고, 그 응답에 분류가 함께 온다(`category_name`, 카카오는 `category_group_code` 까지). 지금까지 받아 놓고 쓰지 않았을 뿐이다 — 여기서 그 값을 업종으로 옮긴다. ★ 못 정하면 None 이다. 억지로 하나를 고르지 않는다. 업종은 수집 스키마와 JSON-LD 타입을 통째로 정하는 값이라, 틀린 업종으로 시작하면 되돌리는 비용이 크다. None 이면 화면이 사장님에게 직접 묻는다. """ from typing import Optional from common.enums import PlaceCategory # 카카오 category_group_code. 한글 분류 문자열은 카카오가 언제든 바꾸지만 이 코드는 안 바뀐다. # ★ HP8(병원)은 여기 없다 — 병원 전체가 아니라 피부과·성형외과만 열려 있어서, # 코드만으로는 우리 업종인지 알 수 없다. 아래 키워드로 한 번 더 좁힌다. _BY_GROUP_CODE = { "AD5": PlaceCategory.LODGING, "CE7": PlaceCategory.CAFE, "FD6": PlaceCategory.RESTAURANT, } _HOSPITAL_GROUP_CODE = "HP8" # 분류 문자열 키워드. 네이버는 group_code 를 주지 않아 이 경로만 탄다. # ★ 순서가 결과를 바꾼다. 카카오·네이버 모두 카페를 "음식점 > 카페" 아래 두기 때문에 # CAFE 를 RESTAURANT 보다 먼저 봐야 한다. 뒤집으면 카페가 전부 음식점이 된다. _KEYWORDS: list[tuple[PlaceCategory, tuple[str, ...]]] = [ # ★ "피부"·"성형" 이 아니라 "피부과"·"성형외과" 다. 앞의 둘로 보면 피부관리실(에스테틱)이 # 병원으로 걸린다 — 의료 광고 규제가 걸리는 업종이라 잘못 붙이면 가장 비싸다. (PlaceCategory.CLINIC, ("피부과", "성형외과")), (PlaceCategory.LODGING, ("숙박", "펜션", "호텔", "모텔", "리조트", "게스트하우스", "민박")), (PlaceCategory.CAFE, ("카페", "커피", "베이커리", "제과", "디저트")), (PlaceCategory.RESTAURANT, ("음식점", "한식", "일식", "중식", "양식", "분식", "주점", "술집", "치킨", "횟집", "뷔페", "패스트푸드")), ] def guess_category( category_name: Optional[str], group_code: Optional[str] = None ) -> Optional[PlaceCategory]: """분류 문자열(+ 카카오 그룹코드)로 업종을 추정한다. 모르면 None. category_name 예 — 카카오 "가정,생활 > 숙박 > 펜션" · 네이버 "숙박>펜션" """ text = (category_name or "").replace(" ", "") code = (group_code or "").strip().upper() if code == _HOSPITAL_GROUP_CODE: # 병원이라는 것까지만 안다. 진료과가 문자열에 없으면 우리 업종인지 알 수 없다. return PlaceCategory.CLINIC if _match(text) is PlaceCategory.CLINIC else None if code in _BY_GROUP_CODE: by_code = _BY_GROUP_CODE[code] # ★ 그룹코드를 문자열로 덮는 경우가 하나 있다: 카카오가 베이커리·브런치 가게를 # FD6(음식점)으로 주면서 분류 문자열엔 "카페"를 다는 일이 있다. 어느 쪽이 맞는지는 # 실측하지 않았다 — 사장님이 바꿀 수 있으니 이름이 더 구체적인 쪽을 기본값으로 둔다. if by_code is PlaceCategory.RESTAURANT and _match(text) is PlaceCategory.CAFE: return PlaceCategory.CAFE return by_code return _match(text) def _match(text: str) -> Optional[PlaceCategory]: for category, words in _KEYWORDS: if any(word in text for word in words): return category return None # ── 음식 중분류(TourAPI lclsSystm2) 추정 ───────────────────────────────── # 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준이다(2026-09-08 결정). # TourAPI 분류체계(lclsSystmCode2 실측) — FD01 한식 · FD02 외국식(중·일·서양·기타외국·퓨전) # · FD03 간이음식(제과·피자/햄버거/샌드위치·치킨·김밥분식·이동음식) · FD04 주점 · FD05 카페/찻집. # # ★ 순서가 결과를 바꾼다. 카카오는 카페를 "음식점 > 카페 > …" 아래 두므로 "음식점"이 항상 붙어 있다 — # 그래서 "음식점" 은 판정어로 쓰지 않고, 구체적인 업태(카페·주점·간이·외국식)를 한식보다 먼저 본다. # ★ 한식은 마지막이고 판정어가 좁다("한식"·"한정식"·"백반"·"국밥"…). 고기·회 같은 재료명은 넣지 않는다 — # "양식 > 스테이크" 를 고기라고 한식으로 넣으면 서양식 스테이크집이 한식이 된다. _FOOD_CLASS_KEYWORDS: list[tuple[str, tuple[str, ...]]] = [ ("FD05", ("카페", "커피", "찻집", "디저트", "음료", "주스", "빙수", "케이크", "브런치")), ("FD04", ("주점", "술집", "호프", "맥주", "이자카야", "포차", "와인바", "칵테일", "펍")), ("FD03", ("제과", "베이커리", "빵", "도넛", "피자", "햄버거", "샌드위치", "치킨", "분식", "김밥", "떡볶이", "간식", "토스트", "패스트푸드")), ("FD02", ("중식", "중국", "일식", "일본", "초밥", "라멘", "양식", "서양", "이탈리", "파스타", "스테이크", "프렌치", "프랑스", "멕시", "아시아", "베트남", "태국", "인도", "퓨전")), ("FD01", ("한식", "한정식", "백반", "국밥", "찌개", "국수", "냉면", "삼겹", "갈비", "곱창", "족발", "보쌈", "해장국", "설렁탕", "감자탕", "칼국수", "횟집", "회")), ] def guess_food_class(category_name: Optional[str]) -> Optional[str]: """외부 분류 문자열 → TourAPI 음식 중분류 코드(FD01~FD05). 모르면 None(제외 없음). 예 — 카카오 "음식점 > 카페 > 커피전문점" → FD05 · 네이버 "카페,디저트" → FD05 · 카카오 "음식점 > 한식 > 육류,고기" → FD01 · "음식점 > 양식 > 스테이크,립" → FD02 """ text = (category_name or "").replace(" ", "") if not text: return None for code, words in _FOOD_CLASS_KEYWORDS: if any(word in text for word in words): return code return None