여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
379 lines
15 KiB
Python
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()
|