"""네이버 지역검색 API 클라이언트 — 동일 업소 검증 + 지역 정보.""" import html import re from collections import Counter from dataclasses import dataclass, field from typing import Optional import httpx from common.logger import LOG from config.server_configs import external_api_config # 판정 결과 enum 은 카카오 것을 그대로 쓴다 — 두 소스를 갈아끼워도 호출측 분기가 그대로여야 한다. from services.external.kakao import MatchOutcome _LOCAL_URL = "https://openapi.naver.com/v1/search/local.json" # 네이버 지역검색은 최대 5건이다. _MAX_DISPLAY = 5 # 프로세스 누적 호출 수 — 생성 1건당 검색 횟수를 세는 근거. _CALL_COUNTS: Counter = Counter() class NaverNotConfigured(RuntimeError): """NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 미설정 — 이 어댑터만 비활성이다.""" class NaverRequestFailed(RuntimeError): """네이버 지역검색 호출 실패(네트워크·타임아웃·5xx·파싱오류).""" # 값 객체 @dataclass(frozen=True) class NaverPlace: """네이버 지역검색이 돌려준 장소 1건.""" name: str # title 에서 태그·엔티티를 벗긴 값 road_address: Optional[str] # roadAddress address: Optional[str] # address (지번) phone: Optional[str] latitude: Optional[float] # mapy / 1e7 longitude: Optional[float] # mapx / 1e7 category_name: Optional[str] # "숙박>펜션" place_url: Optional[str] # link — 업체 홈페이지(네이버 플레이스 아님) naver_place_id: Optional[str] # link 가 네이버 플레이스 URL 일 때만. @classmethod def from_item(cls, item: dict) -> "NaverPlace": link = (item.get("link") or "").strip() or None return cls( name=strip_tags(item.get("title") or ""), road_address=(item.get("roadAddress") or None), address=(item.get("address") or None), phone=(item.get("telephone") or None), # 빈 문자열 → None latitude=_scaled_coord(item.get("mapy")), # mapy = 위도 longitude=_scaled_coord(item.get("mapx")), # mapx = 경도 category_name=(item.get("category") or None), place_url=link, naver_place_id=_extract_place_id(link), ) @dataclass class MatchResult: """동일 업소 판정 결과 + **판정 근거**.""" outcome: MatchOutcome place: Optional[NaverPlace] = None # MATCHED 일 때만 채워진다 candidates: list[NaverPlace] = field(default_factory=list) # 사람이 고를 후보 전체 reason: str = "" # 근거 코드(기계 판독용) detail: str = "" # 근거 설명(사람 판독용) @property def is_matched(self) -> bool: return self.outcome == MatchOutcome.MATCHED # 문자열 정리 _TAG_RE = re.compile(r"<[^>]+>") _WS_RE = re.compile(r"\s+") # 네이버 플레이스 URL 에서 place id 를 뽑는다. _PLACE_ID_RE = re.compile(r"(?:place\.naver\.com|map\.naver\.com)[^\s]*?/(\d{6,})") def strip_tags(text: str) -> str: """title 에서 `` 하이라이트 태그와 HTML 엔티티를 벗긴다.""" return html.unescape(_TAG_RE.sub("", text or "")).strip() def normalize_name(name: str) -> str: """상호명 비교용 정규화 — 공백 제거 + 소문자화.""" return _WS_RE.sub("", strip_tags(name)).lower() def _scaled_coord(value) -> Optional[float]: """mapx/mapy(정수 문자열) → WGS84 도(degree).""" try: return int(value) / 1e7 except (TypeError, ValueError): return None def _extract_place_id(link: Optional[str]) -> Optional[str]: if not link: return None m = _PLACE_ID_RE.search(link) return m.group(1) if m else None # 지역 캐시 키 _SIDO_CODES = { "서울특별시": "11", "서울": "11", "부산광역시": "26", "부산": "26", "대구광역시": "27", "대구": "27", "인천광역시": "28", "인천": "28", "광주광역시": "29", # ※ '광주' 단독은 광역시. "대전광역시": "30", "대전": "30", "울산광역시": "31", "울산": "31", "세종특별자치시": "36", "세종시": "36", "세종": "36", "경기도": "41", "경기": "41", "강원특별자치도": "51", "강원도": "51", "강원": "51", # 2023 개편 — 옛 이름도 같은 키로 "충청북도": "43", "충북": "43", "충청남도": "44", "충남": "44", "전북특별자치도": "52", "전라북도": "52", "전북": "52", # 2024 개편 — 옛 이름도 같은 키로 "전라남도": "46", "전남": "46", "경상북도": "47", "경북": "47", "경상남도": "48", "경남": "48", "제주특별자치도": "50", "제주도": "50", "제주": "50", } # 시군구가 없는 시도(단층제) — 시도 코드만으로 키를 만든다. _SINGLE_TIER = {"36"} # 세종특별자치시 _REGION_KEY_MAX = 10 # local_contents.region_code = VARCHAR(10) def region_key(road_address: Optional[str]) -> Optional[str]: """도로명주소 → 지역 캐시 키(시도코드 2자리 + 시군구명).""" if not road_address: return None tokens = _WS_RE.sub(" ", road_address.strip()).split(" ") if not tokens: return None sido_code = _SIDO_CODES.get(tokens[0]) if sido_code is None: # 매핑에 없는 시도(해외 주소·오타 등) — 억지로 만들지 않고 호출측에 넘긴다. LOG.w(f"[naver] 지역 키를 만들 수 없는 주소(시도 미매핑): {road_address[:60]}") return None if sido_code in _SINGLE_TIER: key = f"{sido_code}세종" else: if len(tokens) < 2: LOG.w(f"[naver] 지역 키를 만들 수 없는 주소(시군구 없음): {road_address[:60]}") return None key = f"{sido_code}{tokens[1]}" if len(key) > _REGION_KEY_MAX: LOG.w(f"[naver] 지역 키가 {len(key)}자라 {_REGION_KEY_MAX}자로 자릅니다: {key}") key = key[:_REGION_KEY_MAX] return key # 동일 업소 판정 def pick_match(name: str, candidates: list[NaverPlace], address_hint: Optional[str] = None) -> MatchResult: """검색 결과에서 '이 가게가 맞다' 를 판정한다.""" if not candidates: return MatchResult( outcome=MatchOutcome.NO_CANDIDATE, candidates=[], reason="no_candidate", detail=f"'{name}' 으로 네이버 지역검색에서 후보를 찾지 못했습니다.", ) pool = list(candidates) target = normalize_name(name) name_hits = [c for c in pool if normalize_name(c.name) == target] if len(name_hits) == 1: return MatchResult( outcome=MatchOutcome.MATCHED, place=name_hits[0], candidates=pool, reason="name_exact", detail=f"상호명이 정확히 1건과 일치합니다: {name_hits[0].name}", ) if len(name_hits) > 1: # 동명 업소 — 주소 힌트로 좁혀 본다. narrowed = _narrow_by_address(name_hits, address_hint) if len(narrowed) == 1: return MatchResult( outcome=MatchOutcome.MATCHED, place=narrowed[0], candidates=pool, reason="name_address", detail=( f"동명 업소 {len(name_hits)}건 중 주소('{address_hint}')로 1건으로 좁혔습니다: " f"{narrowed[0].name}({narrowed[0].road_address or narrowed[0].address or '주소없음'})" ), ) return MatchResult( outcome=MatchOutcome.AMBIGUOUS, candidates=name_hits, reason="name_duplicate", detail=( f"'{name}' 과 상호명이 같은 업소가 {len(name_hits)}건입니다. " f"네이버는 전화번호를 주지 않아 주소로 구분해야 합니다: " + " / ".join(f"{c.name}({c.road_address or c.address or '주소없음'})" for c in name_hits) ), ) return MatchResult( outcome=MatchOutcome.AMBIGUOUS, candidates=pool, reason="name_no_exact", detail=( f"'{name}' 과 정확히 일치하는 상호명이 없습니다. 후보 {len(pool)}건 중 사람이 골라야 합니다: " + " / ".join(f"{c.name}({c.road_address or c.address or '주소없음'})" for c in pool[:_MAX_DISPLAY]) ), ) def _narrow_by_address(candidates: list[NaverPlace], address_hint: Optional[str]) -> list[NaverPlace]: """주소 힌트에 들어 있는 토큰으로 후보를 좁힌다.""" if not address_hint: return list(candidates) tokens = [t for t in _WS_RE.sub(" ", address_hint.strip()).split(" ") if len(t) >= 2] if not tokens: return list(candidates) scored = [] for c in candidates: blob = f"{c.road_address or ''} {c.address or ''}" hits = sum(1 for t in tokens if t in blob) scored.append((hits, c)) best = max((h for h, _ in scored), default=0) if best == 0: return list(candidates) # 아무것도 안 걸리면 좁히지 않는다(잘못 좁히면 남의 가게로 확정된다) return [c for h, c in scored if h == best] # 클라이언트 class NaverLocalClient: """네이버 지역검색 API 호출기.""" def __init__( self, client_id: Optional[str] = None, client_secret: Optional[str] = None, transport=None, timeout: float = 10.0, ): # 명시하지 않으면 설정에서 읽는다. self._client_id = external_api_config.naver_client_id if client_id is None else client_id self._client_secret = external_api_config.naver_client_secret if client_secret is None else client_secret self._transport = transport self._timeout = timeout self._client: Optional[httpx.AsyncClient] = None # 내부 @property def enabled(self) -> bool: """키가 설정돼 있는지.""" return bool(self._client_id and self._client_secret) def _headers(self) -> dict: if not self.enabled: raise NaverNotConfigured( "NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 가 설정되지 않았습니다 — " ".env 또는 config 의 [ExternalApiConfig] 를 확인하세요" ) return { "X-Naver-Client-Id": self._client_id, "X-Naver-Client-Secret": self._client_secret, } def _get_client(self) -> httpx.AsyncClient: if self._client is None or self._client.is_closed: kwargs = {"timeout": httpx.Timeout(self._timeout, connect=5.0)} if self._transport is not None: kwargs["transport"] = self._transport self._client = httpx.AsyncClient(**kwargs) return self._client async def aclose(self): if self._client is not None and not self._client.is_closed: await self._client.aclose() self._client = None async def _get(self, params: dict, kind: str) -> dict: """공통 GET.""" headers = self._headers() # 키 없으면 여기서 NaverNotConfigured _CALL_COUNTS[kind] += 1 LOG.i(f"[naver] {kind} 호출 (누적 {_CALL_COUNTS[kind]}회, 일 25,000회 쿼터) display={params.get('display')}") try: resp = await self._get_client().get(_LOCAL_URL, params=params, headers=headers) except httpx.TimeoutException as ex: raise NaverRequestFailed(f"네이버 지역검색 {kind} 타임아웃: {ex}") from ex except httpx.HTTPError as ex: raise NaverRequestFailed(f"네이버 지역검색 {kind} 요청 실패: {type(ex).__name__}: {ex}") from ex if resp.status_code in (401, 403): # 키가 있지만 잘못됐거나 권한이 없다 — 설정 문제라 재시도해도 소용없다. raise NaverNotConfigured( f"네이버 지역검색 인증 실패({resp.status_code}) — 클라이언트 ID/Secret 과 " f"검색 API 사용 설정을 확인하세요: {resp.text[:200]}" ) if resp.status_code != 200: raise NaverRequestFailed( f"네이버 지역검색 {kind} 응답 오류 status={resp.status_code} body={resp.text[:200]}" ) try: return resp.json() except ValueError as ex: raise NaverRequestFailed(f"네이버 지역검색 {kind} 응답 파싱 실패: {ex}") from ex # 1) 상호명 → 주소·좌표 async def search_local(self, query: str, display: int = _MAX_DISPLAY, sort: str = "random") -> list[NaverPlace]: """지역검색.""" params = { "query": query, "display": max(1, min(display, _MAX_DISPLAY)), "sort": sort, } data = await self._get(params, "local") return [NaverPlace.from_item(it) for it in (data.get("items") or [])] # 2) 주변 맛집·시설 async def search_nearby( self, region_name: str, keyword: str, display: int = _MAX_DISPLAY, region_key_hint: Optional[str] = None, ) -> list[NaverPlace]: """주변 맛집·시설을 찾는다.""" if not region_key_hint: LOG.w( "[naver] search_nearby 를 지역 키 없이 호출했습니다 — " "지역 단위 캐시를 거치지 않으면 같은 지역을 사이트 수만큼 반복 조회합니다" ) places = await self.search_local(f"{region_name} {keyword}", display=display) LOG.i(f"[naver] 주변검색 '{region_name} {keyword}' region_key={region_key_hint or '미지정'} → {len(places)}건") return places # 조합: 상호명 하나로 동일 업소까지 async def verify_place( self, name: str, address_hint: Optional[str] = None, *, search_query: Optional[str] = None, ) -> MatchResult: """상호명(+주소 힌트)으로 검색해서 동일 업소를 판정한다.""" query = search_query or (f"{address_hint} {name}".strip() if address_hint else name) candidates = await self.search_local(query) if not candidates and query != name: # 넓은 검색어로 0건이면 상호명만으로 한 번 더 본다(표기 차이로 0건이 나는 경우가 있다). candidates = await self.search_local(name) result = pick_match(name, candidates, address_hint) detail = f"'{name}'" if query == name else f"'{name}' (검색어 '{query}')" LOG.i(f"[naver] 동일 업소 판정 {detail} → {result.outcome.value} ({result.reason})") return result def call_counts() -> dict: """프로세스 누적 호출 수 — 생성 1건당 검색 횟수를 세는 근거(쿼터 리포트용).""" return dict(_CALL_COUNTS) def reset_call_counts(): """테스트/리포트 구간 분리용.""" _CALL_COUNTS.clear()