여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
333 lines
12 KiB
Python
333 lines
12 KiB
Python
#!/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/"
|
|
|
|
# 검색결과 카드는 <a data-card-type="basic" ...
|
|
RESULT_CARD_SELECTOR = 'a[data-card-type="basic"]'
|
|
|
|
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를 추출해서 상세페이지로 이동한다."""
|
|
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 안에서 각 객실 카드의 사진을, 그 카드의 <h2>(객실명) 앞에 나오는 <img> 들로 묶어 [(객실명, [사진 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()
|