o2o-site-AEO/solution/backend/services/place_category.py
Mina Choi 2026fde80f [feat] solution/backend: 상호명 공개 검색 + 업종 자동 판별 — 랜딩이 로그인 앞에서 부른다
랜딩 첫 화면이 상호명을 받으려면 검색이 로그인 앞에 있어야 하는데, 후보 조회는
place_id 와 토큰을 둘 다 요구했다. 로그인 관문을 에디터 진입 하나로 되돌려 놓고도
(b94daa9) API 는 그대로였다.

업종은 AI 를 한 번 더 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가 이미
들어 있고(category_group_code / category_name), 지금까지 받아 놓고 안 썼다.

- place.py: GET /v1/place/search 신설(인증 없음). ★ /{place_id} 앞에 둬야 한다 —
  뒤에 두면 "search" 가 place_id 로 잡혀 422 다
- place_category: AD5·CE7·FD6 우선, 없으면 분류 문자열. 못 정하면 None —
  억지로 고르면 틀린 스키마로 시작한다. HP8 은 피부과·성형외과일 때만
- kakao: KakaoPlace 에 category_group_code. 한글 분류는 바뀌어도 코드는 안 바뀐다
- rate_limit: 인증 없이 유료 API 를 부르는 경로라 IP 당 분당 20회(프로세스 메모리)
- 확정 경로(verify/candidates)는 인증 유지 — 남의 place_id 존재 여부를 열지 않는다

전체 562 passed
2026-09-03 09:41:34 +09:00

71 lines
3.7 KiB
Python

"""외부 장소 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