"""네이버 플레이스 어댑터 — 모바일 상세 페이지의 __APOLLO_STATE__ 를 읽는다.""" import json import os import re from typing import Optional import httpx from common.enums import LinkChannel, PlaceCategory from common.logger import LOG from services.collector.base import CollectedFact, CollectedMedia, RawSource DETAIL_URL = "https://m.place.naver.com/{kind}/{place_id}/home" REQUEST_TIMEOUT = 30 MAX_MEDIA = 30 # 업종별 상세 경로. DETAIL_KINDS = ("accommodation", "place", "restaurant") _APOLLO = re.compile(r"window\.__APOLLO_STATE__\s*=\s*(\{.*?\});", re.S) _HOSTS = ("map.naver.com", "pcmap.place.naver.com", "m.place.naver.com", "place.naver.com", "naver.me") _PLACE_ID = re.compile(r"/place/(\d+)") _PCMAP_ID = re.compile(r"pcmap\.place\.naver\.com/[a-z]+/(\d+)") # naver.me 단축주소는 /place/ 로 가지 않는다. _QUERY_ID = re.compile(r"[?&](?:pinId|id|entryId)=(\d{6,12})") 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", "Referer": "https://m.place.naver.com/", } # 부정 표현. _NEGATIONS = ("불가", "없음", "없슴", "미제공", "제공하지", "안됨", "안 됨", "불가능", "금지") # 네이버가 주는 편의시설 문자열 → 업종 스키마의 bool key. _CONVENIENCE_BOOL = { "주차": "parking", "발렛파킹": "parking", "발렛": "parking", "무료주차": "parking", "와이파이": "wifi", "무선 인터넷": "wifi", "무선인터넷": "wifi", "바비큐": "bbq_available", "바베큐": "bbq_available", "취사": "cooking_allowed", "조식": "breakfast", "반려동물": "pet_allowed", "애견동반": "pet_allowed", "픽업": "pickup_service", "유아용품": "baby_amenities", "에어컨": "has_aircon", "테라스": "terrace", "포장": "takeout", "배달": "delivery", "콘센트": "power_outlet", "휠체어": "wheelchair_accessible", "장애인": "wheelchair_accessible", # "단체 이용 가능"은 여기 넣지 않는다. } # 업종을 가리지 않고 "영업시간" 자리를 차지하는 key. _HOURS_KEYS = ("business_hours", "operating_hours", "reception_hours") # 요금표(`Menu:*`) 항목 이름 앞에 붙는 요일 구분. _WEEKDAY_TOKENS = ("평일", "주중") _WEEKEND_TOKENS = ("주말", "금토", "토일", "공휴일") # 요일 토큰과, 바로 뒤에 붙는 괄호 보충설명("주말(금,토)")까지 한 번에 걷어낸다. _DAY_TAG = re.compile(rf"({'|'.join(_WEEKDAY_TOKENS + _WEEKEND_TOKENS)})\s*(\([^)]*\))?\s*") _UNIT_NAME_KEY = { PlaceCategory.LODGING: "room_type", PlaceCategory.CAFE: "menu_name", PlaceCategory.RESTAURANT: "menu_name", PlaceCategory.CLINIC: "program_name", } _UNIT_PRICE_KEY = { PlaceCategory.CAFE: "menu_price", PlaceCategory.RESTAURANT: "menu_price", PlaceCategory.CLINIC: "price_adult", } class NaverPlaceAdapter: """네이버 플레이스 상세 → fact·사진 후보.""" id = "naver_place" def _headers(self) -> dict: """요청 헤더.""" headers = dict(HEADERS) cookies = os.getenv("NAVER_COOKIES", "").strip() if cookies: headers["Cookie"] = cookies return headers def can_handle(self, url: str) -> bool: u = (url or "").lower() return any(h in u for h in _HOSTS) async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: channel = LinkChannel.NAVER_PLACE try: place_id = await self._resolve_place_id(url) except Exception as ex: return RawSource.failure(url, self.id, f"place id 를 찾지 못했다: {ex}", channel) try: state = await self._load_state(place_id) except Exception as ex: return RawSource.failure(url, self.id, str(ex), channel) base = state.get(f"PlaceDetailBase:{place_id}") or next( (v for k, v in state.items() if k.startswith("PlaceDetailBase")), None ) if not base: return RawSource.failure(url, self.id, "응답에 PlaceDetailBase 가 없다", channel) facts = self._to_facts(base, state, category) media = self._to_media(state) booking_url = self._booking_url(state) LOG.i( f"[naver_place] {base.get('name')} — fact {len(facts)}건 · 사진 {len(media)}장" f"{' · 예약 주소 있음' if booking_url else ''} (id={place_id})" ) return RawSource( url=url, adapter_id=self.id, channel=channel, # 소개 원문은 fact 가 아니라 여기로 나간다 — 생성 근거로만 쓰인다. text=" ".join( x for x in ( str(base.get("description") or "").strip(), *(base.get("microReviews") or []), ) if x ), facts=facts, media=media, booking_url=booking_url, ) async def fetch_summary(self, url: str) -> Optional[dict]: """이름·주소·좌표·대표사진 요약 — area_contents(주변 맛집 카드) 적재용.""" try: place_id = await self._resolve_place_id(url) state = await self._load_state(place_id) except Exception as ex: LOG.w(f"[naver_place] 요약 조회 실패: {ex}") return None base = state.get(f"PlaceDetailBase:{place_id}") or next( (v for k, v in state.items() if k.startswith("PlaceDetailBase")), None ) name = str((base or {}).get("name") or "").strip() if not base or not name: return None coord = base.get("coordinate") or {} summary = {"place_id": place_id, "name": name} address = str(base.get("roadAddress") or base.get("address") or "").strip() if address: summary["address"] = address if coord.get("y"): summary["latitude"] = str(coord["y"]) if coord.get("x"): summary["longitude"] = str(coord["x"]) media = self._to_media(state) if media: summary["imageUrl"] = media[0].origin_url return summary # 내부 @staticmethod def _booking_url(state: dict) -> Optional[str]: """네이버 예약 화면 주소.""" root = state.get("ROOT_QUERY") if not isinstance(root, dict): return None detail = next( (v for k, v in root.items() if k.startswith("placeDetail") and isinstance(v, dict)), None ) booking = (detail or {}).get("naverBooking") if not isinstance(booking, dict): return None url = str(booking.get("naverBookingUrl") or "").strip() return url or None async def _resolve_place_id(self, url: str) -> str: """URL 에서 place id 를 뽑는다.""" found = self._match_id(url) if found: return found # 단축주소·리다이렉트. try: async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, follow_redirects=True) as client: res = await client.get(url, headers=self._headers()) found = self._match_id(str(res.url)) or self._match_id(res.text[:20000]) if found: return found except httpx.HTTPError as ex: raise ValueError(f"주소를 따라가지 못했다: {ex}") raise ValueError(f"place id 없음: {url}") @staticmethod def _match_id(text: str) -> Optional[str]: m = _PLACE_ID.search(text) or _PCMAP_ID.search(text) or _QUERY_ID.search(text) return m.group(1) if m else None async def _load_state(self, place_id: str) -> dict: """모바일 상세 페이지에서 __APOLLO_STATE__ 를 뽑는다.""" last = "알 수 없는 오류" try: async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT, follow_redirects=True) as client: for kind in DETAIL_KINDS: res = await client.get( DETAIL_URL.format(kind=kind, place_id=place_id), headers=self._headers() ) if res.status_code == 429: last = "네이버가 요청을 제한했다(429). 잠시 후 재시도" continue if res.status_code != 200: last = f"HTTP {res.status_code} ({kind})" continue m = _APOLLO.search(res.text) if not m: last = f"__APOLLO_STATE__ 를 찾지 못했다 ({kind})" continue return json.loads(m.group(1)) except httpx.TimeoutException: raise RuntimeError(f"{REQUEST_TIMEOUT}s 안에 응답이 없다") except httpx.HTTPError as ex: raise RuntimeError(f"네트워크 오류: {ex}") raise RuntimeError(last) def _to_facts(self, base: dict, state: dict, category: Optional[PlaceCategory] = None) -> list[CollectedFact]: """스키마에 있는 key 만 만든다.""" facts: list[CollectedFact] = [] # 소개문은 두 자리에 온다. # 숙박은 체크인·체크아웃이 별도 블록에 있다(Naverhotel3HotelData). hotel = next((v for k, v in state.items() if k.startswith("Naverhotel3HotelData")), None) if hotel: for field, key in (("checkInStr", "check_in_time"), ("checkOutStr", "check_out_time")): value = str(hotel.get(field) or "").strip() if value: facts.append(CollectedFact(key=key, value=value)) # 영업시간. hours = self._business_hours(state) if hours: for key in _HOURS_KEYS: facts.append(CollectedFact(key=key, value=hours)) # 편의시설 신호는 두 곳에 흩어져 있다 — base.conveniences 간단 태그("주차") InformationFacilities:* 상세 시설 목록("와이파이", "개별샤워실", …) 둘을 합쳐서 본다. labels = [str(x) for x in (base.get("conveniences") or [])] labels += [ str(v.get("name") or v.get("i18nName") or "") for k, v in state.items() if k.startswith("InformationFacilities") ] # 항목 하나에서 (key, 부호)를 읽는다. seen: set[str] = set() for text in labels: if not text: continue negated = any(neg in text for neg in _NEGATIONS) for needle, key in _CONVENIENCE_BOOL.items(): if needle not in text or key in seen: continue seen.add(key) facts.append(CollectedFact(key=key, value="false" if negated else "true")) facts.extend(self._to_unit_facts(state, category)) return facts def _to_unit_facts(self, state: dict, category: Optional[PlaceCategory] = None) -> list[CollectedFact]: """요금표(`Menu:*`) → 단위(객실·메뉴·프로그램) 스코프 fact.""" rows = [v for k, v in state.items() if k.startswith("Menu") and isinstance(v, dict)] rows.sort(key=lambda v: int(v.get("index") or 0)) name_key = _UNIT_NAME_KEY.get(category, "room_type") flat_price_key = _UNIT_PRICE_KEY.get(category) if category is not None else None out: list[CollectedFact] = [] seen_names: list[str] = [] for row in rows: raw_name = str(row.get("name") or "").strip() if not raw_name: continue # 상품 이름 = 요일 토큰을 걷어낸 나머지. unit_name = _DAY_TAG.sub("", raw_name).strip(" -·/") or "" if not unit_name: continue if unit_name not in seen_names: seen_names.append(unit_name) out.append(CollectedFact( key=name_key, value=unit_name, scope="unit", unit_name=unit_name, )) # 요금. price = str(row.get("price") or "").strip().replace(",", "") if not price.isdigit(): continue if flat_price_key: price_key = flat_price_key elif any(t in raw_name for t in _WEEKEND_TOKENS): price_key = "weekend_price" elif any(t in raw_name for t in _WEEKDAY_TOKENS): price_key = "weekday_price" else: # 요일이 안 적힌 요금 — 위 주석대로 지어내지 않는다. LOG.i(f"[naver_place] 요일 표기 없는 요금은 건너뛴다: {raw_name} ({price})") continue out.append(CollectedFact( key=price_key, value=price, scope="unit", unit_name=unit_name, )) if out: LOG.i(f"[naver_place] 요금표 → 단위 {len(seen_names)}개 · fact {len(out)}건") return out def _business_hours(self, state: dict) -> str: """영업시간 한 줄.""" rows = [v for k, v in state.items() if "BusinessHour" in k or "NewBusinessHour" in k] parts: list[str] = [] for row in rows: if not isinstance(row, dict): continue day = str(row.get("day") or row.get("dayOfWeek") or "").strip() span = str(row.get("businessHours") or row.get("time") or "").strip() text = f"{day} {span}".strip() if day else span if text and text not in parts: parts.append(text) return " · ".join(parts)[:500] def _to_media(self, state: dict) -> list[CollectedMedia]: """상단 사진.""" items = [v for k, v in state.items() if k.startswith("PlaceDetailTopPhotoItem")] items.sort(key=lambda v: 0 if v.get("mediaSource") == "business" else 1) out: list[CollectedMedia] = [] for item in items[:MAX_MEDIA]: origin = item.get("originalUrl") or item.get("thumbnailUrl") if origin: out.append(CollectedMedia(origin_url=origin, label=item.get("title") or None)) return out