o2o-site-AEO/solution/backend/services/external/naver.py
Mina Choi 328d9e18ee [feat] solution: 지역 이야기 생성 · 발행본 섹션 손질 · 마이그레이션 주석 축약
- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story ·
  section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다
- 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) ·
  Festival · LocalGuide · Weather · Gallery · Header/Footer
- 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게
- DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트
- 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%.
  실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다

검증: site·frontend 빌드 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:36:00 +09:00

498 lines
25 KiB
Python

"""네이버 지역검색 API 클라이언트 — 동일 업소 검증 + 지역 정보.
카카오 REST 키가 없어서 네이버로 간다. `services/external/kakao.py` 와 **같은 형태**로 만들어
나중에 카카오 키가 나오면 갈아끼울 수 있게 한다(판정 결과 enum 은 카카오 것을 그대로 쓴다).
── 카카오 대비 제약 (실측 확인, 2026-08-27) ─────────────────────────────
후보 수 : **최대 5건** (카카오 15건). display 를 6·30 으로 줘도 400 이 아니라
조용히 5건으로 잘린다 — 그래서 클라이언트에서 명시적으로 5 로 클램프한다.
전화번호 : **항상 빈 문자열** — 동명 업소를 가르는 가장 강한 근거가 없다.
고유 place id: **없다.** `link` 는 네이버 플레이스 URL 이 아니라 **업체 자체 홈페이지**다
(예: 스타벅스 → http://www.starbucks.co.kr/). 그래서 naver_place_id 는 보통 None 이다.
행정구역 코드: **없다.** 대신 도로명주소에서 region_key() 로 캐시 키를 만든다.
→ ★ 판정 근거가 카카오보다 약하다. 그만큼 pick_match 가 **더 쉽게 AMBIGUOUS 로 떨어진다.**
후보를 억지로 하나 고르면 남의 가게 정보가 우리 사이트로 나가고, 그건 발행된 뒤에야 드러난다.
── 응답 필드 (실측) ─────────────────────────────────────────────────────
title : `<b>` 태그와 HTML 엔티티가 섞여 온다 → strip_tags 로 반드시 벗긴다
link : 업체 홈페이지 URL (없으면 빈 문자열). 채널 URL 발견에 쓸 수 있는 부수입이다
category : "숙박>펜션" 처럼 '>' 로 구분된 문자열
telephone : 항상 ""
address : 지번 주소 / roadAddress : 도로명 주소
mapx, mapy : **WGS84 를 1e7 배한 정수 문자열**. mapx=경도, mapy=위도.
네이버가 2021년 이후 TM128(KATEC) 에서 WGS84*1e7 로 바꿨다.
검산(2026-08-27): 서울특별시청 mapx=1269783882 mapy=375666103 → 126.97839, 37.56661
(실제 37.5663, 126.9779 / 오차 0.0005 이내). 경복궁·해운대해수욕장도 동일하게 일치.
→ 그래서 단순히 1e7 로 나눈다. 별도 좌표계 변환이 필요 없다.
── 비용 ─────────────────────────────────────────────────────────────────
네이버 검색 API 는 무료지만 **일 25,000회 쿼터**가 있다(애플리케이션당).
호출 횟수는 LOG.i 로 남긴다 — 생성 1건당 검색 횟수를 세는 근거.
주변 정보는 반경 검색이 없어 "지역명 + 키워드" 로 찾으므로, ★ 반드시 지역 캐시를 거쳐 부른다.
"""
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건이다. 더 요청해도 조용히 5건으로 잘린다(실측).
_MAX_DISPLAY = 5
# 프로세스 누적 호출 수 — 생성 1건당 검색 횟수를 세는 근거.
_CALL_COUNTS: Counter = Counter()
class NaverNotConfigured(RuntimeError):
"""NAVER_CLIENT_ID / NAVER_CLIENT_SECRET 미설정 — 이 어댑터만 비활성이다.
서버 부팅을 막지 않는다(외부 계약에 부팅이 묶이면 안 된다). 호출측이 잡아
ErrorType.LOCAL_NOT_CONFIGURED 로 응답한다."""
class NaverRequestFailed(RuntimeError):
"""네이버 지역검색 호출 실패(네트워크·타임아웃·5xx·파싱오류).
★ 실패했다고 빈 값을 내보내면 안 된다 — 호출측은 직전 값을 유지하고 내부 알림만 낸다."""
# ---- 값 객체 -------------------------------------------------------------
@dataclass(frozen=True)
class NaverPlace:
"""네이버 지역검색이 돌려준 장소 1건. KakaoPlace 와 같은 필드 이름을 쓴다(갈아끼우기용).
★ phone 은 거의 항상 None 이다(네이버가 telephone 을 빈 값으로 준다).
★ place_url 은 **업체 자체 홈페이지**다 — 네이버 플레이스 페이지가 아니다."""
name: str # title 에서 <b> 태그·엔티티를 벗긴 값
road_address: Optional[str] # roadAddress
address: Optional[str] # address (지번)
phone: Optional[str] # telephone — 실측상 항상 None
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 일 때만. 보통 None
@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:
"""동일 업소 판정 결과 + **판정 근거**. KakaoMatchResult 와 구조가 같다.
근거를 같이 들고 다니는 이유: ambiguous 로 떨어졌을 때 사람이 무엇을 보고 골라야 하는지
알아야 하고, matched 로 확정됐을 때도 나중에 '왜 이 가게로 붙었나' 를 추적해야 한다."""
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 를 뽑는다. link 는 보통 업체 홈페이지라 대개 안 걸린다.
_PLACE_ID_RE = re.compile(r"(?:place\.naver\.com|map\.naver\.com)[^\s]*?/(\d{6,})")
def strip_tags(text: str) -> str:
"""title 에서 `<b>` 하이라이트 태그와 HTML 엔티티를 벗긴다.
★ 순서가 중요하다 — 태그를 먼저 지우고 그 다음에 엔티티를 푼다.
엔티티를 먼저 풀면 본문에 있던 '&lt;b&gt;'(진짜 텍스트)가 태그로 둔갑해 지워진다."""
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).
네이버는 WGS84 를 1e7 배한 정수로 준다(2021년 TM128 에서 전환). 검산은 모듈 독스트링 참고."""
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
# ---- 지역 캐시 키 --------------------------------------------------------
# ★ 네이버는 행정구역 코드를 주지 않는다. 도로명주소에서 '시도 + 시군구' 를 뽑아 캐시 키를 만든다.
# 이 키가 area_contents.region_code(VARCHAR(10)) 에 들어간다 — 길이를 반드시 지켜야 한다.
#
# 시도 이름은 흔들린다(강원도 ↔ 강원특별자치도). 별칭을 전부 같은 코드로 모아야
# 같은 지역이 두 키로 갈리지 않는다 — 갈리면 캐시가 무의미해진다.
_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자리 + 시군구명). 못 만들면 None.
★ 이 키 단위로 지역 정보(날씨·축제·관광지·맛집)를 캐싱한다.
같은 지역에 사이트가 50개 생겨도 외부 조회는 1회여야 한다.
예)
"강원특별자치도 양양군 현북면 하조대3길 25" → "51양양군"
"강원도 양양군 ..." → "51양양군" (옛 이름도 같은 키)
"서울특별시 강남구 도산대로57길 24" → "11강남구"
"세종특별자치시 한누리대로 2130" → "36세종" (시군구 단층제)
"경기도 성남시 분당구 ..." → "41성남시" (일반구는 시 단위로 묶는다)
"Tokyo, Japan" → None
일반구(성남시 분당구 등)를 시 단위로 묶는 이유: 날씨·축제·관광지는 구 단위로 다르지 않고,
묶을수록 캐시 히트율이 올라간다. 특별시·광역시의 자치구(강남구 등)는 시도 바로 다음 토큰이라
그대로 구 단위로 남는다 — 생활권이 실제로 다르다."""
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:
# 실측상 최대 8자라 여기 오지 않는다. 와도 DB 가 자르기 전에 우리가 자르고 남긴다.
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:
"""★ 검색 결과에서 '이 가게가 맞다' 를 판정한다. 이 서비스에서 가장 비싼 실수가 나는 지점이다.
카카오보다 **보수적이다.** 네이버는 전화번호를 주지 않아 동명 업소를 가를 결정적 근거가
하나 없고, 후보도 5건까지만 온다. 그만큼 더 쉽게 AMBIGUOUS 로 떨어뜨린다.
판정 순서
1. 후보 0건 → NO_CANDIDATE
2. 정규화 상호명이 정확히 1건 일치 → MATCHED (name_exact)
3. 정확 일치가 2건 이상 → address_hint 로 좁혀 1건이면 MATCHED (name_address)
그래도 여럿이면 AMBIGUOUS (name_duplicate)
4. 정확히 일치하는 상호명이 없음 → AMBIGUOUS (name_no_exact)
★ 부분일치로는 절대 확정하지 않는다
★ 억지로 하나 고르면 남의 가게 정보가 섞이고, 그건 사이트가 발행된 뒤에야 드러난다."""
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 호출기.
키가 없으면 생성은 되지만 호출 시 NaverNotConfigured 를 던진다 —
부팅이 외부 계약에 묶이지 않게 하기 위함이다(설정이 비면 이 어댑터만 비활성)."""
def __init__(
self,
client_id: Optional[str] = None,
client_secret: Optional[str] = None,
transport=None,
timeout: float = 10.0,
):
# 명시하지 않으면 설정에서 읽는다. 테스트는 transport 를 주입한다.
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. 호출 1건마다 누적 횟수를 로그로 남긴다(쿼터 추적)."""
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]:
"""지역검색. **동일 업소 검증의 입력**이다.
★ display 는 5 가 상한이다. 더 요청해도 조용히 5건으로 잘리므로 여기서 명시적으로 클램프한다
— '15건 요청했는데 5건만 왔다' 를 장애로 오해하지 않게 하려는 것이다.
반환된 목록은 그대로 pick_match 에 넘긴다 — 여기서 하나를 고르지 않는다."""
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]:
"""주변 맛집·시설을 찾는다.
★ 네이버 지역검색에는 **반경 검색이 없다.** 그래서 좌표가 아니라 "지역명 + 키워드"
(예: "양양군 맛집") 로 찾는다. 카카오의 category 검색과 결과 성격이 다르다 —
거리순이 아니고, 그 지역 안이라는 것만 보장된다.
★ 반드시 지역 캐시를 거쳐 부른다. 같은 지역에 사이트가 50개 생겨도 조회는 1회여야 한다.
캐싱은 이 클라이언트가 하지 않는다 — 지역 모듈이 area_contents 에
`region_code + content_type` 키로 저장하고, 캐시 미스일 때만 여기를 부른다.
`region_key_hint` 는 그 캐시 키를 호출 지점에서 명시하게 하려고 받는다(로그에도 남는다)."""
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:
"""상호명(+주소 힌트)으로 검색해서 동일 업소를 판정한다.
지역검색 1회만 쓴다. 주소 힌트가 있으면 검색어에도 섞어 후보를 깨끗하게 만든다
(5건 상한이라 후보 품질이 카카오보다 중요하다).
★ **name 에는 상호명만 넣는다.** pick_match 가 후보 상호명과 정확일치를 보는데,
여기에 지역까지 붙은 검색어('타코튜즈데이 성수 서울 성동구')를 넣으면 후보명
('타코튜즈데이 성수 본점')과 정확히 같을 수가 없다 — 판정이 구조적으로 언제나
AMBIGUOUS(name_no_exact) 로 떨어진다. 실측(2026-08-28) 10건 전부 그랬고,
후보가 단 1건일 때도 "비슷한 이름의 가게가 여럿입니다" 가 떴다.
검색은 넓게, 판정은 좁게 — 그래서 검색어를 따로 받는다.
결과가 MATCHED 가 아니면 사람이 골라야 한다 — 호출측은
AMBIGUOUS → ErrorType.PLACE_VERIFY_AMBIGUOUS,
NO_CANDIDATE → ErrorType.PLACE_VERIFY_NO_CANDIDATE 로 응답한다."""
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()