수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다. - NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가 - 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호 - 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시 - 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가 - 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선 검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
187 lines
8.5 KiB
Python
187 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
야놀자 크롤링 결과를 테스트 place 하나에 적재하는 1회성 스크립트.
|
|
=================================================================
|
|
|
|
★ 운영 파이프라인이 아니다 — services/collector/registry.py 에 등록하지 않고,
|
|
worker 잡큐에도 연결하지 않는다. 개발자가 수동으로 실행하는 CLI 전용.
|
|
(registry.py·static_html_adapter.py 의 야놀자 관련 구조적 차단은 그대로 유효하다 —
|
|
이 스크립트는 그 결론을 뒤집지 않는다. 테스트 목적 한정, 실제 배포 전 별도 협의 필요.)
|
|
|
|
동작
|
|
1. place_id 로 places 테이블에서 업장을 조회한다. 주소는 따로 입력받지 않고
|
|
그 업장의 road_address(없으면 address)를 그대로 검색어로 쓴다.
|
|
2. verified_at 이 비어 있으면 중단한다(운영 run_collect 와 같은 가드 —
|
|
동일 업소 검증 전에는 수집하지 않는다).
|
|
3. scripts/yanolja_search_and_crawl.py 로 그 주소를 검색·크롤링한다.
|
|
4. 결과를 CollectedFact/CollectedMedia 로 매핑해서, 기존 collect_service.py 의
|
|
ensure_units/store_facts/store_media 를 그대로 호출한다 — FactService 를 그대로
|
|
통과하므로 새 값은 UNVERIFIED/PENDING_OWNER/PENDING_REVIEW 로만 만들어진다.
|
|
이 상태를 이 스크립트가 직접 VERIFIED/APPROVED 로 바꾸는 일은 없다.
|
|
|
|
사용법 (solution/backend 에서, 가상환경 안에서)
|
|
python scripts/import_yanolja_test_place.py --place-id <UUID> (dry-run: 매핑만 출력)
|
|
python scripts/import_yanolja_test_place.py --place-id <UUID> --commit (실제 DB 적재)
|
|
|
|
PGSSLMODE=disable DB_PASSWORD=... 를 dev-env-quirks 메모대로 주입해야 한다(APP_ENV=local 기본).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import os
|
|
import re
|
|
import sys
|
|
import uuid
|
|
|
|
# scripts/ 는 solution/backend 바로 아래이므로, backend 자체(부모 디렉터리)를
|
|
# sys.path 에 넣어야 common/services 등을 import 할 수 있다.
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
os.environ.setdefault("APP_ENV", "local")
|
|
|
|
from playwright.sync_api import sync_playwright # noqa: E402
|
|
from sqlalchemy import select # noqa: E402
|
|
|
|
from scripts.yanolja_search_and_crawl import StayData, search_and_crawl # noqa: E402
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG # noqa: E402
|
|
from common.database.model.models import places # noqa: E402
|
|
from common.enums import DBWRType, ErrorType # noqa: E402
|
|
from services.collector.base import CollectedFact, CollectedMedia, RawSource # noqa: E402
|
|
from services.collect_service import ensure_units, store_facts, store_media # noqa: E402
|
|
|
|
|
|
def _parse_capacity(capacity: str | None) -> tuple[str | None, str | None]:
|
|
"""'기준 2인 / 최대 4인' → ('2', '4')."""
|
|
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 stay_data_to_source(data: StayData) -> RawSource:
|
|
"""StayData → RawSource(facts/media). lodging.json 의 scope=unit 필드 중
|
|
크롤러가 실제로 채울 수 있는 것만 매핑한다(room_type/standard_capacity/max_capacity 는
|
|
required=true 라 반드시 채운다. weekday_price 등 요금은 크롤러가 안 모으므로 비워둔다).
|
|
"""
|
|
facts: list[CollectedFact] = []
|
|
media: list[CollectedMedia] = []
|
|
|
|
if data.overview.strip():
|
|
facts.append(CollectedFact(key="intro", value=data.overview.strip(), scope="place"))
|
|
for url in data.photos:
|
|
media.append(CollectedMedia(origin_url=url, label="갤러리"))
|
|
|
|
for room in data.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))
|
|
if room.description:
|
|
facts.append(CollectedFact(key="room_intro", value=room.description, scope="unit", unit_name=room.name))
|
|
for url in room.images:
|
|
media.append(CollectedMedia(origin_url=url, label="객실 사진", unit_name=room.name))
|
|
|
|
return RawSource(url=data.url, adapter_id="yanolja_test_import", facts=facts, media=media)
|
|
|
|
|
|
def _print_preview(source: RawSource) -> None:
|
|
print(f"\n[미리보기] {source.url}")
|
|
place_facts = [f for f in source.facts if f.scope == "place"]
|
|
print(f" place-scope fact {len(place_facts)}건: {[f.key for f in place_facts]}")
|
|
for unit_name in source.unit_names():
|
|
unit_facts = [f for f in source.facts if f.unit_name == unit_name]
|
|
unit_media = [m for m in source.media if m.unit_name == unit_name]
|
|
print(f" [{unit_name}] fact {len(unit_facts)}건, 사진 {len(unit_media)}장")
|
|
for f in unit_facts:
|
|
print(f" {f.key} = {f.value}")
|
|
place_media = [m for m in source.media if m.unit_name is None]
|
|
print(f" place-scope 사진 {len(place_media)}장")
|
|
|
|
|
|
async def _load_place(place_id: str):
|
|
stmt = select(places).where(places.place_id == uuid.UUID(place_id), places.deleted == False) # noqa: E712
|
|
err, row = await DB_SESSION_MNG.execute_lambda(
|
|
places.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: DB_SESSION_MNG.execute(s, stmt),
|
|
)
|
|
if err != ErrorType.SUCCESS or not row:
|
|
return None
|
|
return row[0]
|
|
|
|
|
|
def _crawl_sync(address: str, headful: bool) -> StayData:
|
|
"""동기 Playwright API는 asyncio 루프가 도는 스레드에서 못 쓴다 — 별도 스레드에서 실행한다."""
|
|
with sync_playwright() as p:
|
|
browser = p.chromium.launch(headless=not 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:
|
|
return search_and_crawl(page, address)
|
|
finally:
|
|
browser.close()
|
|
|
|
|
|
async def run(place_id: str, commit: bool, headful: bool) -> None:
|
|
place = await _load_place(place_id)
|
|
if place is None:
|
|
raise SystemExit(f"place를 찾을 수 없습니다: {place_id}")
|
|
if place.verified_at is None:
|
|
raise SystemExit(
|
|
"동일 업소 검증(verify) 전에는 수집하지 않습니다 — 운영 collect_service.run_collect 와 같은 가드."
|
|
)
|
|
|
|
address = place.road_address or place.address
|
|
if not address:
|
|
raise SystemExit("이 place에는 주소가 없습니다.")
|
|
|
|
print(f"[대상] {place.name} ({place_id}) — 검색 주소: {address}")
|
|
|
|
data = await asyncio.to_thread(_crawl_sync, address, headful)
|
|
|
|
print(f"[크롤링 완료] {data.name} — 객실 {len(data.rooms)}개, 갤러리 사진 {len(data.photos)}장")
|
|
source = stay_data_to_source(data)
|
|
_print_preview(source)
|
|
|
|
if not commit:
|
|
print("\n(dry-run) DB에는 쓰지 않았습니다. 실제로 저장하려면 --commit 을 주세요.")
|
|
await DB_SESSION_MNG.dispose_all()
|
|
return
|
|
|
|
from common.models.gmodel import UserInfo
|
|
|
|
actor = UserInfo(user_id=str(place.owner_user_id), id="yanolja_test_import", role=1)
|
|
|
|
unit_map = await ensure_units(place_id, [source])
|
|
facts_stat = await store_facts(actor, place_id, [source], unit_map)
|
|
media_stat = await store_media(place_id, [source], unit_map)
|
|
|
|
print(f"\n[적재 완료] unit {len(unit_map)}개, fact={facts_stat}, media={media_stat}")
|
|
await DB_SESSION_MNG.dispose_all()
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="야놀자 크롤링 → 테스트 place 객실 데이터 임포트(1회성)")
|
|
parser.add_argument("--place-id", required=True, help="대상 place_id(UUID)")
|
|
parser.add_argument("--commit", action="store_true", help="실제로 DB에 적재(기본은 dry-run)")
|
|
parser.add_argument("--headful", action="store_true", help="브라우저 창을 보이게 실행(디버깅용)")
|
|
args = parser.parse_args()
|
|
asyncio.run(run(args.place_id, args.commit, args.headful))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|