o2o-site-AEO/solution/backend/services/stay_guide.py
김성경 a093921b62 feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.

- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선

검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
2026-09-14 19:40:04 +09:00

78 lines
3.9 KiB
Python

"""확정된 NOL 링크의 수집 원문에서 공개 안내 섹션만 전달한다."""
import re
from urllib.parse import urlsplit
def nol_stay_guide(url: str, raw) -> dict:
try:
parsed = urlsplit(url)
except ValueError:
return {}
if parsed.scheme != "https" or parsed.hostname != "nol.yanolja.com":
return {}
if not re.fullmatch(r"/stay/domestic/\d+/?", parsed.path):
return {}
text = raw.get("text") if isinstance(raw, dict) else None
if not isinstance(text, str):
return {}
labels = {"시설/서비스": "service", "이용 안내": "policy", "예약 공지": "reservation"}
# 수집기가 붙인 경계로만 나눈다. 규정 안의 괄호나 문장은 분리하지 않는다.
sections = re.split(r"(?m)^\[(숙소 소개|시설/서비스|이용 안내|예약 공지)\]\s*\n", text)
guide = {}
for label, body in zip(sections[1::2], sections[2::2]):
if label not in labels:
continue
lines = body.strip().splitlines()
if lines and lines[0].strip() == label:
lines.pop(0)
# 제목 중복과 UI 버튼만 제외한다. 요약·추론 없이 수집 문장을 보존한다.
body = "\n".join(line for line in lines if line.strip() != "전체보기").strip()
if body:
guide[labels[label]] = body
if guide:
guide["fields"] = structured_fields(guide)
return guide
def structured_fields(guide: dict) -> list[dict]:
"""확실한 표기만 구조화한다. 매칭되지 않은 내용은 안내 원문에 남는다."""
policy = guide.get("policy", "")
service = guide.get("service", "")
reservation = guide.get("reservation", "")
lines = {line.strip().lstrip("- ") for line in (policy + "\n" + reservation).splitlines()}
facilities = {line.strip() for line in service.splitlines()}
fields = []
def add(key, label, value, group="rules", note=None):
fields.append(dict(key=key, label=label, value=value, group=group,
**({"note": note} if note else {})))
for token, key, label in (("체크인", "check_in_time", "체크인 시간"),
("체크아웃", "check_out_time", "체크아웃 시간")):
matches = set(re.findall(rf"{token}\s+([0-2]\d:[0-5]\d)(?!\d)", policy))
if len(matches) == 1:
value = matches.pop()
if int(value[:2]) < 24:
add(key, label, value)
fees = [re.fullmatch(r"전 연령 동일 1인당\s*([\d,]+)\s*(만)?원", line) for line in lines]
amounts = {int(m[1].replace(",", "")) * (10000 if m[2] else 1) for m in fees if m}
if len(amounts) == 1:
add("extra_person_fee", "인원 추가 요금", f"{amounts.pop():,}원", note="1인당 · 전 연령 동일")
if "반려동물 입실금지" in lines and not any("반려동물 입실가능" == line for line in lines):
add("pet_allowed", "반려동물 동반", "불가")
if "전 구역 금연" in lines:
add("smoking", "흡연", "불가", "facilities", "전 구역 금연")
for token, key, label in (("주차가능", "parking", "주차"), ("와이파이", "wifi", "와이파이"),
("취사가능", "cooking_allowed", "취사")):
if token in facilities:
restrictions = [line.strip().lstrip("- ") for line in reservation.splitlines()
if "조리금지" in line or "조리 금지" in line]
add(key, label, "가능", "facilities",
"\n".join(restrictions) if key == "cooking_allowed" and restrictions else None)
known = [name for name in ("욕조", "개별 화장실", "주방", "테라스/발코니", "OTT (스트리밍 서비스)",
"다이닝룸", "벽난로", "어메니티", "카페형룸") if name in facilities]
if known:
add("facilities", "부대시설", ", ".join(known), "facilities")
return fields