수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다. - NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가 - 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호 - 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시 - 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가 - 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선 검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
112 lines
5.5 KiB
Python
112 lines
5.5 KiB
Python
"""어댑터 레지스트리 — 'URL 하나에 어댑터 하나' 를 결정하는 유일한 지점.
|
|
|
|
등록된 어댑터
|
|
naver_place 네이버 플레이스 상세(pcmap GraphQL). 이식 출처는
|
|
o2o-castad-backend/app/utils/nvMapScraper.py — 파싱 규칙을 두 벌 두지 않는다.
|
|
tour_api 한국관광공사 TourAPI(KorService2). 숙박 32 · 음식점 39. 공식 API 라 재게시가 자유롭고
|
|
사진마다 공공누리 유형이 붙어 온다 — 상업적 이용 불가 유형은 수집 단계에서 버린다.
|
|
static_html 사장님이 확정한 자기 홈페이지. JSON-LD·OpenGraph 만 읽고 robots.txt 를 따른다.
|
|
docs/DECISIONS.md 1-1 이 보류했던 어댑터로, 2026-08-28 실측 결론
|
|
(docs/DATA_SOURCE_RESEARCH.md)에 따라 **사장님 확정 URL 한정**으로 등록했다.
|
|
yanolja 야놀자(NOL) 국내숙소 상세페이지. Next.js CSR 페이지라 Playwright 로 렌더링해
|
|
읽는다. 캡차 우회·IP 회전 등은 하지 않으며, 차단되면 그대로 실패로 돌린다.
|
|
mock mock:// 전용. 네트워크 없이 파이프라인 전체를 돌리는 테스트용.
|
|
|
|
켜고 끄는 것은 환경변수 COLLECT_ADAPTERS 다(쉼표 구분). 코드를 고치지 않고 한 채널만
|
|
내릴 수 있어야 한다 — 특정 사이트가 막히거나 정책이 바뀌었을 때 배포 없이 멈추기 위해서다.
|
|
|
|
**금지 (검토 결과와 무관하게 영구)**
|
|
캡차 우회 · 봇 탐지 우회 · IP 회전. 어떤 어댑터도 이걸 구현하지 않는다.
|
|
막히면 폴백 3단계로 간다 — 공식 API → 사장님이 직접 붙여넣기 → 최소 정보로 생성 + 보완 요청.
|
|
수집이 막혔다고 생성 자체를 실패시키지 않는다.
|
|
"""
|
|
import os
|
|
from typing import Optional
|
|
|
|
from common.logger import LOG
|
|
from services.collector.base import AdapterDisabled, AdapterNotFound, SourceAdapter
|
|
from services.collector.mock_adapter import MockAdapter
|
|
from services.collector.naver_place_adapter import NaverPlaceAdapter
|
|
from services.collector.static_html_adapter import StaticHtmlAdapter
|
|
from services.collector.tour_api_adapter import TourApiAdapter
|
|
from services.collector.yanolja_adapter import YanoljaAdapter
|
|
|
|
# 이 환경에서 켜둘 어댑터 id 집합. 등록돼 있어도 여기 없으면 AdapterDisabled 로 막힌다.
|
|
ENABLED_ADAPTERS: frozenset = frozenset(
|
|
x.strip() for x in os.getenv("COLLECT_ADAPTERS", "mock,naver_place,tour_api,static_html,yanolja").split(",") if x.strip()
|
|
)
|
|
|
|
|
|
class AdapterRegistry:
|
|
"""등록 순서대로 can_handle 을 물어 첫 번째로 손드는 어댑터를 쓴다.
|
|
|
|
순서가 곧 우선순위다 — 좁은 어댑터(특정 도메인)를 먼저, 넓은 어댑터(범용 HTML)를 뒤에 둔다.
|
|
"""
|
|
|
|
def __init__(self, enabled: Optional[frozenset] = None):
|
|
self._adapters: list[SourceAdapter] = []
|
|
self._enabled = ENABLED_ADAPTERS if enabled is None else enabled
|
|
|
|
def register(self, adapter: SourceAdapter) -> None:
|
|
if any(a.id == adapter.id for a in self._adapters):
|
|
raise ValueError(f"어댑터 id 중복: {adapter.id}")
|
|
self._adapters.append(adapter)
|
|
|
|
def adapter_ids(self) -> list[str]:
|
|
"""등록된 어댑터 id — 기동 로그·테스트가 '지금 뭐가 켜져 있나' 를 확인하는 창구."""
|
|
return [a.id for a in self._adapters]
|
|
|
|
def enabled_ids(self) -> list[str]:
|
|
return [a.id for a in self._adapters if a.id in self._enabled]
|
|
|
|
def get_adapter(self, url: str) -> SourceAdapter:
|
|
"""URL 을 처리할 어댑터. 없으면 AdapterNotFound, 꺼져 있으면 AdapterDisabled."""
|
|
for adapter in self._adapters:
|
|
if not adapter.can_handle(url):
|
|
continue
|
|
if adapter.id not in self._enabled:
|
|
raise AdapterDisabled(f"어댑터 '{adapter.id}' 는 이 환경에서 꺼져 있다: {url}")
|
|
return adapter
|
|
raise AdapterNotFound(f"이 URL 을 처리할 어댑터가 없다: {url}")
|
|
|
|
def can_handle(self, url: str) -> bool:
|
|
"""예외 없이 처리 가능 여부만 — 확정 링크를 수집 대상으로 거를 때 쓴다."""
|
|
try:
|
|
self.get_adapter(url)
|
|
return True
|
|
except (AdapterNotFound, AdapterDisabled):
|
|
return False
|
|
|
|
|
|
def _register_default() -> AdapterRegistry:
|
|
registry = AdapterRegistry()
|
|
# 좁은 어댑터(특정 도메인)를 먼저, 넓은 어댑터를 뒤에. mock 은 mock:// 스킴 전용이라 순서 무관.
|
|
# ★ static_html 은 http(s) 를 통째로 받는 넓은 어댑터라 **반드시 맨 뒤**다.
|
|
# 앞에 두면 네이버 플레이스 URL 까지 이쪽으로 빨려 들어간다.
|
|
registry.register(NaverPlaceAdapter())
|
|
registry.register(TourApiAdapter())
|
|
registry.register(YanoljaAdapter())
|
|
registry.register(MockAdapter())
|
|
registry.register(StaticHtmlAdapter())
|
|
return registry
|
|
|
|
|
|
REGISTRY = _register_default()
|
|
|
|
|
|
def get_adapter(url: str) -> SourceAdapter:
|
|
"""기본 레지스트리에서 어댑터를 찾는다."""
|
|
return REGISTRY.get_adapter(url)
|
|
|
|
|
|
def adapter_ids() -> list[str]:
|
|
return REGISTRY.adapter_ids()
|
|
|
|
|
|
def log_registry() -> None:
|
|
"""기동 로그용 — 이 프로세스가 실제로 긁을 수 있는 소스."""
|
|
ids = REGISTRY.enabled_ids()
|
|
LOG.i(f"[collector] 사용 가능 어댑터: {', '.join(ids) if ids else '없음'}")
|
|
if ids == ["mock"]:
|
|
LOG.w("[collector] MockAdapter 만 켜져 있습니다 — 실제 수집은 일어나지 않습니다")
|