"""어댑터 레지스트리 — '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 한정**으로 등록했다. 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 # 이 환경에서 켜둘 어댑터 id 집합. 등록돼 있어도 여기 없으면 AdapterDisabled 로 막힌다. ENABLED_ADAPTERS: frozenset = frozenset( x.strip() for x in os.getenv("COLLECT_ADAPTERS", "mock,naver_place,tour_api,static_html").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(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 만 켜져 있습니다 — 실제 수집은 일어나지 않습니다")