o2o-site-AEO/solution/backend/services/collector/naver_place_adapter.py
Mina Choi 951e451ef8 [fix] solution/backend: 네이버 플레이스 수집이 음식점·카페 요금표를 통째로 반려 — 업종별 unit key 분기
_to_unit_facts() 가 업종을 안 가리고 room_type·weekday_price/weekend_price 로만 냈다.
그 key 는 숙박 스키마에만 있어서, 음식점·카페·클리닉은 메뉴/프로그램 요금표가
FACT_INVALID_KEY 로 전량 거부됐다(실측: "도플로" fact 19건 중 19건 반려).

- naver_place_adapter.py: _UNIT_NAME_KEY·_UNIT_PRICE_KEY 로 업종별 key 매핑
  (숙박 room_type/weekday·weekend_price, 카페·음식점 menu_name/menu_price,
  클리닉 program_name/price_adult)
- SourceAdapter.fetch() 계약에 category 파라미터 추가, 어댑터 5개 시그니처 반영
- collect_service.py: fetch_one() 에 place.category 전달

검증: 재수집 후 stored 0 → 35(음식점), test_collector·test_category_schema·
test_fact_api·test_tour_api_adapter 173 passed
2026-09-23 13:17:28 +09:00

456 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""네이버 플레이스 어댑터 — 모바일 상세 페이지의 __APOLLO_STATE__ 를 읽는다.
★ 왜 GraphQL(pcmap-api)이 아니라 모바일 페이지인가
`o2o-castad-backend/app/utils/nvMapScraper.py` 는 pcmap-api GraphQL 을 직접 친다.
그 경로는 익명 요청을 IP 단위로 429 로 막는다(호스트·컨테이너 양쪽에서 확인).
반면 모바일 상세 페이지는 200 을 주고, 같은 데이터가 `window.__APOLLO_STATE__` 에
통째로 들어 있다 — 브라우저가 받는 것과 같은 응답을 한 번 받아 파싱할 뿐이다.
쿠키도 우회 장치도 필요 없다.
**금지 (docs/DECISIONS.md 1-1, 결론과 무관하게 영구)**
캡차 우회 · 봇 탐지 우회 · IP 회전. 이 어댑터는 공개 엔드포인트에 평범한 요청 한 번을
보내고, 막히면 그대로 실패로 돌려준다. 우회하지 않는다.
수집물은 전부 **후보**로 들어간다(UNVERIFIED). 사장님이 확인해야 사이트에 나간다 —
그 게이트는 fact 계층이 담당하므로 여기서는 값과 출처만 정확히 만든다.
"""
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/ 로 가지 않는다. 앱 딥링크로 리다이렉트되며 id 가
# 쿼리스트링에 담긴다: m.map.naver.com/appLink.naver?pinId=1273971279&…&id=1273971279
# 이 형태를 안 보면 사장님이 [공유]로 복사한 주소가 통째로 실패한다(실측).
_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/",
}
# 부정 표현. ★ 이걸 먼저 보지 않으면 "반려동물 동반 **불가**" 가 pet_allowed=true 로 뒤집힌다.
# pet_allowed 는 critical 항목이라(틀리면 예약 클레임) 반드시 부호를 먼저 판정한다.
_NEGATIONS = ("불가", "없음", "없슴", "미제공", "제공하지", "안됨", "안 됨", "불가능", "금지")
# 네이버가 주는 편의시설 문자열 → 업종 스키마의 bool key.
#
# ★ 표기가 업종마다 다르다. 숙박은 "와이파이", 호텔은 "무선 인터넷", 카페는 "무선인터넷" 으로 온다 —
# 한 표기만 넣으면 있는 시설을 통째로 놓친다(실측: 힐튼에서 wifi 를 못 잡아 fact 가 2건이었다).
# 그래서 실제로 관측된 표기를 전부 적는다. 여기 없는 표현은 fact 로 만들지 않는다 —
# 억지 매핑은 "없다"를 "있다"로 바꾼다.
#
# 여러 업종 스키마에 같은 key 가 있으므로(parking·wifi·pet_allowed) 업종을 가리지 않고 쓴다.
# 스키마에 없는 key 는 fact 기록 단계에서 거부되므로 잘못 들어가도 화면에는 안 나온다.
_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",
# ★ "단체 이용 가능"은 여기 넣지 않는다. 스키마의 group_seat_max 는 **인원수**(number)라
# true 를 넣으면 "단체석 최대 true명"이 된다. 값의 형이 다르면 매핑하지 않는다.
}
# 업종을 가리지 않고 "영업시간" 자리를 차지하는 key. 스키마에 없는 것은 기록 단계에서 걸러진다.
_HOURS_KEYS = ("business_hours", "operating_hours", "reception_hours")
# ★ 요금표(`Menu:*`) 항목 이름 앞에 붙는 요일 구분.
# 네이버는 모텔·펜션의 요금을 "평일 대실 / 주말(금,토) 숙박" 처럼 **요일 × 상품** 으로 준다.
# 이걸 이름 그대로 단위로 만들면 같은 상품이 요일 수만큼 쪼개져 "객실 4개"가 된다 —
# 실제로는 상품 2개(대실·숙박)에 주중·주말 요금이 각각 붙은 것이다.
# 그래서 요일 토큰을 떼어 **상품 이름으로 묶고**, 요금은 weekday/weekend 로 갈라 담는다.
_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:
"""요청 헤더. 쿠키는 있으면 얹고 없으면 그냥 간다.
★ NAVER_COOKIES 는 **우회 장치가 아니다**. 로그인 없이도 응답이 오지만 익명 요청은
자주 429 로 막히므로, 정상적으로 보유한 세션을 그대로 쓰는 통로만 열어둔다.
IP 회전·핑거프린트 위조 같은 봇 탐지 우회는 하지 않는다(docs/DECISIONS.md 1-1).
"""
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 가 아니라 여기로 나간다 — 생성 근거로만 쓰인다.
# 업소가 직접 쓴 description 을 앞에, 한 줄 요약(microReviews)을 뒤에 붙인다.
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(주변 맛집 카드) 적재용.
★ fetch()와 별개 계약이다. fetch()는 fact/media(사업장 자신의 사실)를 돌려주고,
이건 "이 업체가 누구인가"만 필요한 호출자(주변 맛집 보강)를 위한 것이다.
★ services/place_service.py 의 verify_place_by_url 이 이미 같은 필드
(name/roadAddress/address/coordinate.x·y)를 같은 방식으로 읽는다 — 필드명은 거기서 확인됐다.
★ 사진은 fetch()가 쓰는 _to_media()를 그대로 재사용한다 — 이미 받아온 state 에서
꺼낼 뿐이라 네트워크 호출이 추가로 들지 않는다.
"""
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]:
"""네이버 예약 화면 주소. **네이버가 준 값 그대로**다 — 조립하지 않는다.
★ 왜 조립하지 않나
응답에는 `bookingBusinessId`(1067685)와 `businessTypeId`(6)가 같이 있어서
`m.booking.naver.com/booking/{type}/bizes/{id}` 를 만들 수 있을 것처럼 보인다.
그러면 예약을 받지 않는 업소에도 그럴듯한 주소가 생기고, 눌렀는데 빈 화면이
나오면 손님은 그 가게가 예약을 안 받는 줄로 읽는다. 응답이 `naverBookingUrl` 을
줄 때만, 준 그대로 쓴다. 없으면 없는 것이다(실측: 예약 미사용 업소는 null).
★ 값은 ROOT_QUERY 의 placeDetail 응답 안에 있다. 키에 질의 인자가 통째로 박혀 있어
(`placeDetail({"input":{...}})`) 이름으로 못 찾는다 — 접두사로 찾는다.
"""
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 를 뽑는다.
★ 형태가 세 가지다 — 하나라도 빠지면 사장님이 복사한 주소가 통째로 실패한다.
.../place/1133638931 지도·플레이스 상세
pcmap.place.naver.com/accommodation/… PC 지도
?pinId=1273971279 naver.me 단축주소가 풀린 앱 딥링크
단축주소는 서버가 한 번 따라가서 최종 주소를 본다.
"""
found = self._match_id(url)
if found:
return found
# 단축주소·리다이렉트. naver.me 뿐 아니라 다른 짧은 형태도 한 번은 따라가 본다.
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__ 를 뽑는다.
업종에 따라 경로가 달라서(accommodation / place / restaurant) 200 이 올 때까지 순서대로 친다.
★ 막히면 그대로 실패로 돌려준다 — 재시도 폭주도 우회도 하지 않는다.
"""
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 만 만든다. 없는 key 는 fact 기록 단계에서 통째로 거부된다."""
facts: list[CollectedFact] = []
# ★ 소개문은 두 자리에 온다. microReviews(짧은 한 줄 요약)가 없어도
# description(업소가 직접 쓴 소개글)이 차 있는 경우가 있다 — 조이모텔은 둘 다
# 비었지만, 한쪽만 보면 있는 소개문을 통째로 놓친다. 순서는 업소가 쓴 글이 우선이다.
# ★ intro(소개문)는 수집하지 않는다 — allow_llm=True, 즉 LLM 의 출력 칸이다(절대규칙 7).
# 수집한 원문은 RawSource.text 로 나가 생성 근거로만 쓰인다.
# ★ 숙박은 체크인·체크아웃이 별도 블록에 있다(Naverhotel3HotelData). 이 둘은 critical 이라
# 놓치면 사장님이 손으로 채워야 하고, 비면 AI 검색이 OTA 설명을 대신 인용한다.
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))
# 영업시간. 업종마다 key 가 달라 스키마에 있는 것만 남는다(없는 key 는 기록 단계에서 거부).
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, 부호)를 읽는다.
#
# ★ 목록에 없다고 "없다"로 단정하지 않는다 — 네이버가 안 적었을 뿐일 수 있다.
# 반대로 "불가/없음"이 적혀 있으면 그건 명시적인 '아니오'이므로 false 로 담는다.
# 둘을 구분하지 못하면 "반려동물 동반 불가"가 "동반 가능"으로 뒤집힌다.
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.
★ 왜 필요한가
이 자리를 아무도 읽지 않아서 place.units 가 전 사업장 0건이었다 — 발행본의
'객실 안내' 가 영구히 비어 있었다. 수집 파이프라인(ensure_units)은 이미
unit 스코프 fact 의 unit_name 으로 단위를 만든다. 없던 것은 그 입력뿐이다.
★ 요일 × 상품을 상품으로 묶는다.
"평일 대실 / 주말(금,토) 대실 / 평일 숙박 / 주말(금,토) 숙박" 은 객실 4개가 아니라
상품 2개(대실·숙박)다. 요일 토큰을 떼어 묶고 요금만 주중·주말로 나눠 담는다.
★ 요일 표기가 없는 요금은 **담지 않는다.**
스키마의 요금 key 는 weekday_price·weekend_price 뿐이라, 요일이 안 적힌 값을
아무 쪽에나 넣으면 출처에 없는 조건을 우리가 지어내는 것이 된다. 손님이 그 금액으로
오면 클레임이다 — 이름(room_type)만 담고 요금은 사장님이 채운다.
"""
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,
))
# 요금. 네이버는 문자열 숫자("20000")로 준다 — 표기는 렌더 단계(format_value)가 만든다.
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:
"""영업시간 한 줄. 네이버가 요일별로 쪼개 주면 그대로 이어 붙인다.
★ 파싱해서 재조립하지 않는다 — "매일 09:00~21:00""평일만" 을 구조화하려다
틀리면 손님이 헛걸음한다. 원문 표기를 그대로 옮기고 판단은 사장님이 한다.
"""
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]:
"""상단 사진. 업소가 올린 것(mediaSource='business')을 앞에 둔다 —
방문자 리뷰 사진보다 재게시 근거가 분명하다(docs/DECISIONS.md 1-2)."""
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