o2o-site-AEO/solution/backend/services/collector/naver_place_adapter.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- 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>
2026-09-28 16:05:19 +09:00

357 lines
14 KiB
Python

"""네이버 플레이스 어댑터 — 모바일 상세 페이지의 __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