여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - 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>
304 lines
12 KiB
Python
304 lines
12 KiB
Python
"""야놀자(NOL) 국내숙소 어댑터 — Playwright 로 상세페이지를 렌더링해 객실·사진을 수집한다."""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
from playwright.async_api import Page, TimeoutError as PWTimeoutError, async_playwright
|
|
|
|
from common.enums import LinkChannel, PlaceCategory
|
|
from common.logger import LOG
|
|
from services.collector.base import CollectedFact, CollectedMedia, RawSource
|
|
|
|
SEARCH_URL = "https://nol.yanolja.com/"
|
|
DETAIL_URL_RE = re.compile(r"nol\.yanolja\.com/stay/domestic/\d+", re.IGNORECASE)
|
|
|
|
# 검색결과 카드는 <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",
|
|
}
|
|
|
|
# 원문을 보존할 섹션들.
|
|
_TEXT_ONLY_SECTIONS = (
|
|
("overview", "숙소 소개"),
|
|
("service", "시설/서비스"),
|
|
("policy", "이용 안내"),
|
|
("reservation", "예약 공지"),
|
|
)
|
|
|
|
_UA = (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _RoomInfo:
|
|
name: str
|
|
description: Optional[str] = None
|
|
capacity: Optional[str] = None
|
|
bed: Optional[str] = None
|
|
images: list[str] = field(default_factory=list)
|
|
|
|
|
|
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 _is_real_photo_url(src: str) -> bool:
|
|
return bool(src) and "static/images" not in src
|
|
|
|
|
|
def _parse_capacity(capacity: Optional[str]) -> tuple[Optional[str], Optional[str]]:
|
|
if not capacity:
|
|
return None, None
|
|
std_m = re.search(r"기준\s*(\d+)인", capacity)
|
|
max_m = re.search(r"최대\s*(\d+)인", capacity)
|
|
return (std_m.group(1) if std_m else None, max_m.group(1) if max_m else None)
|
|
|
|
|
|
def _parse_rooms_from_section_text(section_text: str) -> list[_RoomInfo]:
|
|
"""PLACE_SECTION.innerText 는 "N / M"(사진 장수) 로 객실 카드 수만큼 반복된다."""
|
|
rooms: list[_RoomInfo] = []
|
|
chunks = re.split(r"\n?\d+\s*\n/\n\d+\n", section_text)
|
|
for chunk in chunks[1:]:
|
|
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)
|
|
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,
|
|
)
|
|
)
|
|
return rooms
|
|
|
|
|
|
async def _scroll_through_page(page: Page) -> None:
|
|
prev_height = -1
|
|
for _ in range(30):
|
|
await page.mouse.wheel(0, 1200)
|
|
await page.wait_for_timeout(250)
|
|
cur_height = await page.evaluate("document.body.scrollHeight")
|
|
if cur_height == prev_height:
|
|
break
|
|
prev_height = cur_height
|
|
await page.evaluate("window.scrollTo(0, 0)")
|
|
|
|
|
|
async def _get_section_text(page: Page, element_id: str) -> str:
|
|
try:
|
|
await page.evaluate(
|
|
"(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }",
|
|
element_id,
|
|
)
|
|
await page.wait_for_timeout(400)
|
|
return await page.evaluate(
|
|
"(id) => { const el = document.getElementById(id); return el ? el.innerText : ''; }",
|
|
element_id,
|
|
) or ""
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
async def _get_room_images_by_name(page: Page, element_id: str) -> list[tuple[str, list[str]]]:
|
|
"""카드 구조가 "사진 캐러셀 → <h2>객실명</h2> → 설명" 순이라, h2 를 만나기 전까지 쌓인 이미지가 그 h2 의 몫이다."""
|
|
try:
|
|
await page.evaluate(
|
|
"(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }",
|
|
element_id,
|
|
)
|
|
await page.wait_for_timeout(400)
|
|
raw = await page.evaluate(
|
|
"""(id) => {
|
|
const el = document.getElementById(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;
|
|
}""",
|
|
element_id,
|
|
) or []
|
|
except Exception:
|
|
return []
|
|
return [(item["name"], [u for u in item["images"] if _is_real_photo_url(u)]) for item in raw]
|
|
|
|
|
|
async def _get_section_images(page: Page, element_id: str) -> list[str]:
|
|
try:
|
|
await page.evaluate(
|
|
"(id) => { const el = document.getElementById(id); if (el) el.scrollIntoView({block:'center'}); }",
|
|
element_id,
|
|
)
|
|
await page.wait_for_timeout(400)
|
|
srcs = await page.evaluate(
|
|
"""(id) => {
|
|
const el = document.getElementById(id);
|
|
if (!el) return [];
|
|
return [...new Set(Array.from(el.querySelectorAll('img')).map(i => i.src).filter(Boolean))];
|
|
}""",
|
|
element_id,
|
|
) or []
|
|
except Exception:
|
|
return []
|
|
return [u for u in srcs if _is_real_photo_url(u)]
|
|
|
|
|
|
async def _get_stay_name(page: Page) -> Optional[str]:
|
|
try:
|
|
h1 = await page.query_selector("h1")
|
|
return (await h1.inner_text()).strip() if h1 else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
async def _new_page():
|
|
"""호출마다 브라우저를 새로 띄우고 닫는다 — 다른 어댑터처럼 상태를 들고 있지 않는다."""
|
|
pw = await async_playwright().start()
|
|
browser = await pw.chromium.launch(headless=True)
|
|
context = await browser.new_context(locale="ko-KR", user_agent=_UA)
|
|
page = await context.new_page()
|
|
return pw, browser, page
|
|
|
|
|
|
async def search_by_address(address: str, timeout_ms: int = 15000) -> Optional[tuple[str, str]]:
|
|
"""주소로 검색해 첫 검색결과의 (상세 URL, 업체명)을 돌려준다."""
|
|
pw, browser, page = await _new_page()
|
|
try:
|
|
await page.goto(SEARCH_URL, wait_until="domcontentloaded")
|
|
search_box = page.get_by_role("combobox", name="검색어 입력")
|
|
await search_box.click()
|
|
await search_box.fill(address)
|
|
await search_box.press("Enter")
|
|
|
|
try:
|
|
first_card = page.locator(RESULT_CARD_SELECTOR).first
|
|
await first_card.wait_for(state="visible", timeout=timeout_ms)
|
|
except PWTimeoutError:
|
|
return None
|
|
|
|
name = await first_card.get_attribute("aria-label") or await first_card.inner_text()
|
|
detail_url = await first_card.get_attribute("href")
|
|
if not detail_url:
|
|
return None
|
|
|
|
await page.goto(detail_url, wait_until="domcontentloaded")
|
|
return page.url, name
|
|
except Exception as ex:
|
|
LOG.w(f"[yanolja] 주소 검색 실패(계속): {type(ex).__name__}: {ex}")
|
|
return None
|
|
finally:
|
|
await browser.close()
|
|
await pw.stop()
|
|
|
|
|
|
class YanoljaAdapter:
|
|
"""야놀자(NOL) 국내숙소 상세페이지 → 객실 fact·사진."""
|
|
|
|
id = "yanolja"
|
|
|
|
def can_handle(self, url: str) -> bool:
|
|
return bool(DETAIL_URL_RE.search((url or "").lower()))
|
|
|
|
async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
|
|
pw, browser, page = await _new_page()
|
|
try:
|
|
await page.goto(url, wait_until="domcontentloaded", timeout=60000)
|
|
try:
|
|
await page.wait_for_selector("h1", timeout=15000)
|
|
except PWTimeoutError:
|
|
# 우회하지 않는다 — 차단·비정상 응답이면 그대로 실패로 돌린다.
|
|
pass
|
|
|
|
name = await _get_stay_name(page)
|
|
if not name:
|
|
return RawSource.failure(url, self.id, "상세페이지를 읽지 못했다(차단되었거나 존재하지 않는 페이지)", LinkChannel.YANOLJA)
|
|
|
|
await _scroll_through_page(page)
|
|
|
|
rooms_text = await _get_section_text(page, SECTION_IDS["rooms"])
|
|
rooms = _parse_rooms_from_section_text(rooms_text)
|
|
|
|
room_images = await _get_room_images_by_name(page, SECTION_IDS["rooms"])
|
|
if len(room_images) == len(rooms):
|
|
for room, (_, images) in zip(rooms, room_images):
|
|
room.images = images
|
|
else:
|
|
images_by_name = dict(room_images)
|
|
for room in rooms:
|
|
room.images = images_by_name.get(room.name, [])
|
|
|
|
gallery = await _get_section_images(page, SECTION_IDS["overview"])
|
|
|
|
# 숙소소개·시설서비스·이용안내·예약공지 — fact 로 만들 스키마 필드가 없어 RawSource.text 로만 싣는다(생성 근거).
|
|
section_texts: list[str] = []
|
|
for section_key, label in _TEXT_ONLY_SECTIONS:
|
|
body = await _get_section_text(page, SECTION_IDS[section_key])
|
|
if body.strip():
|
|
section_texts.append(f"[{label}]\n{body.strip()}")
|
|
page_text = "\n\n".join(section_texts)[:20000]
|
|
except Exception as ex:
|
|
return RawSource.failure(url, self.id, f"{type(ex).__name__}: {ex}", LinkChannel.YANOLJA)
|
|
finally:
|
|
await browser.close()
|
|
await pw.stop()
|
|
|
|
facts: list[CollectedFact] = []
|
|
media: list[CollectedMedia] = []
|
|
|
|
for src in gallery:
|
|
media.append(CollectedMedia(origin_url=src, label="갤러리"))
|
|
|
|
for room in rooms:
|
|
std_cap, max_cap = _parse_capacity(room.capacity)
|
|
facts.append(CollectedFact(key="room_type", value=room.name, scope="unit", unit_name=room.name))
|
|
if std_cap:
|
|
facts.append(CollectedFact(key="standard_capacity", value=std_cap, scope="unit", unit_name=room.name))
|
|
if max_cap:
|
|
facts.append(CollectedFact(key="max_capacity", value=max_cap, scope="unit", unit_name=room.name))
|
|
if room.bed:
|
|
facts.append(CollectedFact(key="bed_type", value=room.bed, scope="unit", unit_name=room.name))
|
|
# room_intro 도 allow_llm=True 라 수집하지 않는다(위 intro 와 같은 이유).
|
|
for src in room.images:
|
|
media.append(CollectedMedia(origin_url=src, label="객실 사진", unit_name=room.name))
|
|
|
|
if not facts and not media:
|
|
return RawSource.failure(url, self.id, "객실·소개 정보를 찾지 못했다", LinkChannel.YANOLJA)
|
|
|
|
LOG.i(f"[yanolja] {name} — 객실 {len(rooms)}개 · fact {len(facts)}건 · 사진 {len(media)}장")
|
|
return RawSource(
|
|
url=url,
|
|
adapter_id=self.id,
|
|
channel=LinkChannel.YANOLJA,
|
|
text=page_text,
|
|
facts=facts,
|
|
media=media,
|
|
)
|