최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
371 lines
19 KiB
Python
371 lines
19 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
|
||
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*")
|
||
|
||
|
||
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) -> 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)
|
||
media = self._to_media(state)
|
||
|
||
LOG.i(f"[naver_place] {base.get('name')} — fact {len(facts)}건 · 사진 {len(media)}장 (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,
|
||
)
|
||
|
||
# ---- 내부 ----------------------------------------------------------
|
||
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) -> 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))
|
||
return facts
|
||
|
||
def _to_unit_facts(self, state: dict) -> 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))
|
||
|
||
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)
|
||
# room_type 은 숙박 스키마의 unit 필수 필드다. 이름 자체가 상품 구분이므로 그대로 싣는다.
|
||
out.append(CollectedFact(
|
||
key="room_type", 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 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
|