o2o-site-AEO/solution/backend/services/external/naver.py
Mina Choi 9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:12:09 +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
# ---- 지역 캐시 키 --------------------------------------------------------
# ★ 네이버는 행정구역 코드를 주지 않는다. 도로명주소에서 '시도 + 시군구' 를 뽑아 캐시 키를 만든다.
# 이 키가 local.local_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회여야 한다.
캐싱은 이 클라이언트가 하지 않는다 — local 모듈이 local.local_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()