o2o-site-AEO/solution/backend/services/external/naver.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:05:19 +09:00

379 lines
15 KiB
Python

"""네이버 지역검색 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 에서 <b> 태그·엔티티를 벗긴 값
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 에서 `<b>` 하이라이트 태그와 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()