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