#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러 ========================================== ★ 운영 collector 파이프라인(services/collector/registry.py)에 등록되지 않은 수동 도구다. registry.py·static_html_adapter.py 에 야놀자를 구조적으로 막아둔 이유(HTTP 403 회피 목적의 봇 탐지 우회 금지, 재게시 관련 민사 판례)는 그대로 유효하다. 이 스크립트는 개발자가 필요할 때 수동으로만 실행하는 진단·백필용 도구이며, worker/collect_service 에서 자동으로 호출되지 않는다. 동작 순서 1. https://nol.yanolja.com/ 접속 2. 검색창에 주소를 입력하고 검색 실행 3. 검색결과 리스트에서 첫번째 업체(숙소)의 상세페이지 href 를 읽어 바로 이동 4. 이동한 상세페이지에서 객실/숙소소개/시설·서비스/이용안내/예약공지 + 객실별 사진을 크롤링 사전 준비 pip install playwright playwright install chromium 사용법 (solution/backend 에서) python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" -o result.json python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" --print python scripts/yanolja_search_and_crawl.py "전북특별자치도 군산시 절골길 18" --headful 주의 - 검색 페이지, 상세페이지 모두 Next.js 기반 CSR 페이지라서 Playwright로 실제 브라우저를 띄워 렌더링을 끝낸 뒤 DOM에서 데이터를 추출한다. - 페이지 구조(클래스명 등)는 야놀자 쪽에서 언제든 바뀔 수 있다. """ from __future__ import annotations import argparse import json import re import sys from dataclasses import asdict, dataclass, field from typing import Optional from playwright.sync_api import Page, TimeoutError as PWTimeoutError, sync_playwright SEARCH_URL = "https://nol.yanolja.com/" # 검색결과 카드는 RESULT_CARD_SELECTOR = 'a[data-card-type="basic"]' # 각 탭이 스크롤/렌더링되는 실제 섹션 id (2026-09 기준 확인됨) SECTION_IDS = { "rooms": "PLACE_SECTION", # 객실선택 "overview": "OVERVIEW_SECTION", # 숙소소개 "service": "SERVICE_SECTION", # 시설/서비스 "policy": "POLICY_SECTION", # 이용안내 "reservation": "RESERVATION_SECTION", # 예약공지 } @dataclass class RoomInfo: name: str description: Optional[str] = None capacity: Optional[str] = None # 예: "기준 2인 / 최대 4인" bed: Optional[str] = None # 예: "킹 침대 1개" raw_text: str = "" images: list[str] = field(default_factory=list) # 객실 사진 URL(아이콘 제외) @dataclass class StayData: url: str stay_id: Optional[str] = None name: Optional[str] = None search_address: Optional[str] = None picked_name: Optional[str] = None rooms: list[RoomInfo] = field(default_factory=list) overview: str = "" # 숙소 소개 service: str = "" # 시설/서비스 policy: str = "" # 이용 안내 reservation: str = "" # 예약 공지 photos: list[str] = field(default_factory=list) # 숙소소개 갤러리 사진 URL(있으면) def search_and_pick_first(page: Page, address: str, timeout_ms: int = 15000) -> tuple[str, Optional[str]]: """주소로 검색한 뒤, 첫번째 검색결과 카드의 href를 추출해서 상세페이지로 이동한다. 카드를 클릭하지 않고 href를 직접 읽어 page.goto()로 이동한다. 클릭 시 새 탭이 뜨거나 배너/모달에 클릭이 가로채이는 문제를 피하기 위함이다. """ page.goto(SEARCH_URL, wait_until="domcontentloaded") search_box = page.get_by_role("combobox", name="검색어 입력") search_box.click() search_box.fill(address) search_box.press("Enter") try: first_card = page.locator(RESULT_CARD_SELECTOR).first first_card.wait_for(state="visible", timeout=timeout_ms) except PWTimeoutError: raise RuntimeError(f"'{address}' 검색결과를 찾지 못했습니다.") name = first_card.get_attribute("aria-label") or first_card.inner_text() detail_url = first_card.get_attribute("href") if not detail_url: raise RuntimeError("검색결과 카드에서 href를 찾지 못했습니다.") page.goto(detail_url, wait_until="domcontentloaded") return page.url, name # page.url: href가 상대경로여도 절대 URL로 해석된 값 def _extract_stay_id(url: str) -> Optional[str]: m = re.search(r"/stay/domestic/(\d+)", url) return m.group(1) if m else None def _scroll_through_page(page: Page) -> None: """지연 로딩(lazy render) 섹션들이 모두 렌더링되도록 페이지를 끝까지 스크롤.""" prev_height = -1 for _ in range(30): page.mouse.wheel(0, 1200) page.wait_for_timeout(250) cur_height = page.evaluate("document.body.scrollHeight") if cur_height == prev_height: break prev_height = cur_height page.evaluate("window.scrollTo(0, 0)") def _get_section_text(page: Page, element_id: str) -> str: try: page.evaluate( f"""() => {{ const el = document.getElementById({json.dumps(element_id)}); if (el) el.scrollIntoView({{block: 'center'}}); }}""" ) page.wait_for_timeout(400) return page.evaluate( f"""() => {{ const el = document.getElementById({json.dumps(element_id)}); return el ? el.innerText : ''; }}""" ) or "" except Exception: return "" # img src 중 실제 사진(/v5/.../*.jpg 형태)만 남기고 UI 아이콘(static/images/... 의 침대·와이파이 # 아이콘, no-image 플레이스홀더 등)은 제외한다. def _is_real_photo_url(src: str) -> bool: return bool(src) and "static/images" not in src def _get_room_images_by_name(page: Page, element_id: str) -> list[tuple[str, list[str]]]: """PLACE_SECTION 안에서 각 객실 카드의 사진을, 그 카드의

(객실명) 앞에 나오는 들로 묶어 [(객실명, [사진 URL, ...]), ...] 순서대로 돌려준다. 카드 구조가 "사진 캐러셀 →

객실명

→ 설명" 순이라, h2 를 만나기 전까지 쌓인 이미지가 그 h2 의 몫이다. """ try: page.evaluate( f"""() => {{ const el = document.getElementById({json.dumps(element_id)}); if (el) el.scrollIntoView({{block: 'center'}}); }}""" ) page.wait_for_timeout(400) raw = page.evaluate( f"""() => {{ const el = document.getElementById({json.dumps(element_id)}); if (!el) return []; const nodes = el.querySelectorAll('img, h2'); const result = []; let buf = []; for (const n of nodes) {{ if (n.tagName === 'IMG') {{ if (n.src) buf.push(n.src); }} else if (n.tagName === 'H2') {{ result.push({{name: n.textContent.trim(), images: [...new Set(buf)]}}); buf = []; }} }} return result; }}""" ) or [] except Exception: return [] return [ (item["name"], [u for u in item["images"] if _is_real_photo_url(u)]) for item in raw ] def _get_section_images(page: Page, element_id: str) -> list[str]: """섹션 안의 사진 URL을 순서대로(중복 제거, 아이콘 제외).""" try: page.evaluate( f"""() => {{ const el = document.getElementById({json.dumps(element_id)}); if (el) el.scrollIntoView({{block: 'center'}}); }}""" ) page.wait_for_timeout(400) srcs = page.evaluate( f"""() => {{ const el = document.getElementById({json.dumps(element_id)}); if (!el) return []; return [...new Set(Array.from(el.querySelectorAll('img')).map(i => i.src).filter(Boolean))]; }}""" ) or [] except Exception: return [] return [u for u in srcs if _is_real_photo_url(u)] def _get_stay_name(page: Page) -> Optional[str]: try: h1 = page.query_selector("h1") return h1.inner_text().strip() if h1 else None except Exception: return None def _parse_rooms_from_section_text(section_text: str) -> list[RoomInfo]: """ PLACE_SECTION.innerText 는 대략 아래 패턴이 객실 카드 수만큼 반복됩니다: 1 / 10 B동 (모던한 현대식 컨셉으로 꾸며진 따뜻한 공간) 기준 2인 / 최대 4인 킹 침대 1개 숙박 체크인 15:00 ~ 체크아웃 11:00 취소 및 환불 불가 상세보기 198,000 원 NOL 머니 결제 시 최대 3,960P 적립 예약하기 우리가 필요한 건 이름/설명/기준·최대인원/침대 뿐이므로 가격 이하는 무시한다. 사이트 구조가 바뀌면 이 정규식도 함께 손봐야 한다. """ rooms: list[RoomInfo] = [] # "N / M" (사진 장수) 로 카드 시작 지점을 나눈다 chunks = re.split(r"\n?\d+\s*\n/\n\d+\n", section_text) for chunk in chunks[1:]: # 첫 chunk는 "객실 선택" 타이틀 등 헤더 lines = [l.strip() for l in chunk.split("\n") if l.strip()] if not lines: continue name = lines[0] capacity_m = re.search(r"기준\s*\d+인\s*/\s*최대\s*\d+인", chunk) bed_m = re.search(r"(킹|퀸|더블|싱글|트윈)\s*침대\s*\d+개", chunk) # 이름과 "기준 N인" 줄 사이의 줄들을 설명으로 간주 desc_lines = [] for l in lines[1:]: if l.startswith("기준") or "체크인" in l or l == "숙박": break desc_lines.append(l.strip("()")) rooms.append( RoomInfo( name=name, description=" ".join(desc_lines) if desc_lines else None, capacity=capacity_m.group(0) if capacity_m else None, bed=bed_m.group(0) if bed_m else None, raw_text=chunk.strip(), ) ) return rooms def crawl_stay(page: Page, url: str) -> StayData: page.goto(url, wait_until="domcontentloaded", timeout=60000) try: page.wait_for_selector("h1", timeout=15000) except PWTimeoutError: pass _scroll_through_page(page) data = StayData(url=url, stay_id=_extract_stay_id(url)) data.name = _get_stay_name(page) rooms_text = _get_section_text(page, SECTION_IDS["rooms"]) data.rooms = _parse_rooms_from_section_text(rooms_text) # 카드 순서대로 이미지를 묶어서 뽑은 뒤, 순서가 맞아떨어지면 그대로 붙이고 # (카드 수가 어긋나면 사이트 구조가 달라진 것이니) 이름이 같은 것끼리 다시 맞춘다. room_images = _get_room_images_by_name(page, SECTION_IDS["rooms"]) if len(room_images) == len(data.rooms): for room, (_, images) in zip(data.rooms, room_images): room.images = images else: images_by_name = {name: images for name, images in room_images} for room in data.rooms: room.images = images_by_name.get(room.name, []) data.overview = _get_section_text(page, SECTION_IDS["overview"]) data.photos = _get_section_images(page, SECTION_IDS["overview"]) data.service = _get_section_text(page, SECTION_IDS["service"]) data.policy = _get_section_text(page, SECTION_IDS["policy"]) data.reservation = _get_section_text(page, SECTION_IDS["reservation"]) return data def search_and_crawl(page: Page, address: str) -> StayData: detail_url, picked_name = search_and_pick_first(page, address) data = crawl_stay(page, detail_url) data.search_address = address data.picked_name = picked_name return data def _print_stay(data: StayData) -> None: print(f"\n===== {data.name} ({data.url}) =====") if data.search_address: print(f"검색 주소: {data.search_address}") if data.picked_name: print(f"검색결과 선택: {data.picked_name}") print("\n[객실 선택]") for r in data.rooms: print(f" - {r.name}") if r.description: print(f" 설명: {r.description}") if r.capacity: print(f" 인원: {r.capacity}") if r.bed: print(f" 침대: {r.bed}") if r.images: print(f" 사진: {len(r.images)}장 (예: {r.images[0]})") if data.photos: print(f"\n갤러리 사진: {len(data.photos)}장") for label, key in [("숙소 소개", "overview"), ("시설/서비스", "service"), ("이용 안내", "policy"), ("예약 공지", "reservation")]: print(f"\n[{label}]") text = getattr(data, key) print(text if text else "(내용 없음)") def main() -> None: parser = argparse.ArgumentParser(description="야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러") parser.add_argument( "address", nargs="?", default="전북특별자치도 군산시 절골길 18", help="검색할 주소 (예: '전북특별자치도 군산시 절골길 18')", ) parser.add_argument("-o", "--output", default="yanolja_result.json", help="결과 저장 파일명(JSON)") parser.add_argument("--print", action="store_true", dest="do_print", help="결과를 터미널에도 보기 좋게 출력") parser.add_argument("--headful", action="store_true", help="브라우저 창을 보이게 실행(디버깅용)") args = parser.parse_args() print(f"[검색 시작] 주소: {args.address}", file=sys.stderr) with sync_playwright() as p: browser = p.chromium.launch(headless=not args.headful) context = browser.new_context( locale="ko-KR", user_agent=( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" ), ) page = context.new_page() try: data = search_and_crawl(page, args.address) except Exception as e: browser.close() print(f"실패: {e}", file=sys.stderr) sys.exit(1) browser.close() print(f" -> 완료: {data.name} (객실 {len(data.rooms)}개)", file=sys.stderr) if args.do_print: _print_stay(data) with open(args.output, "w", encoding="utf-8") as f: json.dump(asdict(data), f, ensure_ascii=False, indent=2) print(f"\n완료: 결과를 {args.output} 에 저장했습니다.") if __name__ == "__main__": main()