#!/usr/bin/env python3 # -*- coding: utf-8 -*- """야놀자(NOL) 주소 검색 + 숙소 상세페이지 크롤러""" 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/" # 검색결과 카드는 tuple[str, Optional[str]]: """주소로 검색한 뒤, 첫번째 검색결과 카드의 href를 추출해서 상세페이지로 이동한다.""" 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/... 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, ...]), ...] 순서대로 돌려준다.""" 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 는 대략 아래 패턴이 객실 카드 수만큼 반복됩니다:""" 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()