o2o-site-AEO/solution/backend/services/collector/registry.py
김성경 a093921b62 feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.

- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선

검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
2026-09-14 19:40:04 +09:00

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 만 켜져 있습니다 — 실제 수집은 일어나지 않습니다")