o2o-site-AEO/solution/backend/scripts/yanolja_search_and_crawl.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- 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>
2026-09-28 16:05:19 +09:00

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()