- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: 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>
498 lines
25 KiB
Python
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 엔티티를 벗긴다.
|
|
|
|
★ 순서가 중요하다 — 태그를 먼저 지우고 그 다음에 엔티티를 푼다.
|
|
엔티티를 먼저 풀면 본문에 있던 '<b>'(진짜 텍스트)가 태그로 둔갑해 지워진다."""
|
|
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()
|