수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다. - NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가 - 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호 - 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시 - 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가 - 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선 검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
398 lines
15 KiB
Python
398 lines
15 KiB
Python
#!/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/"
|
|
|
|
# 검색결과 카드는 <a data-card-type="basic" ... aria-label="{업체명} 상품 상세 보기" href="...">
|
|
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 안에서 각 객실 카드의 사진을, 그 카드의 <h2>(객실명) 앞에 나오는
|
|
<img> 들로 묶어 [(객실명, [사진 URL, ...]), ...] 순서대로 돌려준다.
|
|
|
|
카드 구조가 "사진 캐러셀 → <h2>객실명</h2> → 설명" 순이라, 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()
|