"""상호·주소 → 네이버 플레이스 id.""" import re from html import unescape from typing import Optional import httpx from common.logger import LOG SEARCH_URL = "https://m.search.naver.com/search.naver" REQUEST_TIMEOUT = 20 HEADERS = { "User-Agent": ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) " "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"), "Accept-Language": "ko-KR,ko;q=0.9", } _PLACE_ID = re.compile(r"(?:place\.naver\.com/[a-z]+/|/place/|\"placeId\"\s*:\s*\"?)(\d{8,12})") # id 주변에서 상호를 찾을 때 훑는 범위. _CONTEXT_BEFORE = 600 _CONTEXT_AFTER = 300 def _normalize(text: str) -> str: """상호 비교용 정규화.""" return re.sub(r"[\s,·.\-_'\"()&]", "", (text or "")).lower() def region_hint(address: Optional[str]) -> str: """주소에서 검색을 좁힐 지역 토막.""" tokens = (address or "").split() picked = [t for t in tokens if t.endswith(("시", "군", "구"))] return " ".join(picked[:2]) async def _fetch_search_html(query: str) -> Optional[str]: """통합검색 결과 페이지 원문.""" try: async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, follow_redirects=True) as client: res = await client.get(SEARCH_URL, params={"query": query, "where": "m"}, headers=HEADERS) if res.status_code != 200: LOG.w(f"[naver_lookup] 검색 실패 HTTP {res.status_code} — query={query}") return None return res.text except httpx.HTTPError as ex: LOG.w(f"[naver_lookup] 검색 실패: {ex}") return None def _match_in_html(html: str, name: str) -> Optional[str]: """검색 결과 원문에서 이 상호에 해당하는 place id 를 고른다.""" target = _normalize(name) if not target: return None for match in _PLACE_ID.finditer(html): # 엔티티를 먼저 푼다. window = _normalize( unescape(html[max(0, match.start() - _CONTEXT_BEFORE): match.start() + _CONTEXT_AFTER]) ) if target in window: return match.group(1) return None async def find_place_ids(query: str, names: list[str]) -> dict[str, str]: """후보 상호들에 대해 {상호: place_id} 를 채운다.""" html = await _fetch_search_html(query) if not html: return {} found: dict[str, str] = {} for name in names: place_id = _match_in_html(html, name) if place_id: found[name] = place_id return found async def find_place_id(name: str, address: Optional[str] = None) -> Optional[str]: """상호(+주소)로 네이버 플레이스 id 를 찾는다.""" query = " ".join(x for x in (name, region_hint(address)) if x) html = await _fetch_search_html(query) if not html: return None place_id = _match_in_html(html, name) if place_id: LOG.i(f"[naver_lookup] '{name}' → place {place_id}") return place_id LOG.w(f"[naver_lookup] '{name}' 상호가 일치하는 후보를 찾지 못했다 — 자동 등록하지 않는다") return None def place_url(place_id: str) -> str: """수집 어댑터가 그대로 처리할 수 있는 정규 주소.""" return f"https://m.place.naver.com/place/{place_id}/home"