git 저장소가 없어 히스토리·협업 기반이 아예 없던 상태를 연다.
함께 문서를 재편했다. 그동안 문서가 있어도 "이 제품이 뭘 푸는가"와
"어떻게 도는가"를 담은 문서가 없어서, 목표 문장이 backend/frontend
README 두 곳에 복붙돼 있었다 — 상위 문서가 없어 아래로 샌 것이다.
신설
README.md 레포 진입점 + 문서 지도 + 문서 규칙 4가지
AGENTS.md 에이전트·신규 합류자용 함정 목록과 규약
(CLAUDE.md 는 여기로 걸린 심볼릭 링크)
docs/PRODUCT.md 제품 정의 — 문제·사용자·원칙·**non-goals**·성공 기준
docs/ARCHITECTURE.md payload 경계·발행 파이프라인·서빙 결정·앱 분리 설계
이동
backend/docs/DECISIONS.md → docs/DECISIONS.md
백엔드만의 결정이 아니다. 게다가 코드 주석 ~25곳이 이미
`docs/DECISIONS.md` 로 적고 있어 레포 루트 기준으로는 그게 맞다.
갱신
docs/DEPLOY.md 서빙 결정 반영 — nginx 정적 서빙이 지금 경로(3절),
Azure 는 나중에 켤 때(4절)로 분리
docs/ARCHITECTURE.md 사이트 = 한 장(2026-08-31) 구조 반영
docs/COLLECTION_SEO_AEO_FLOW.md
robots.txt·sitemap.xml 은 오리진 루트에만 굽는다는 점 명시
frontend/site/scripts/prerender.ts
헤더 주석의 렌더 보고서 경로가 실제(422줄)와 달라 수정
.gitignore
★ CLAUDE.md 를 더 이상 무시하지 않는다. 에이전트 지침은 팀과 모든
에이전트가 공유하는 규약이라 커밋해야 한다 — 무시하면 클론한 사람이
"배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
개인용 오버라이드는 ~/.claude/CLAUDE.md 에 둔다.
108 lines
5.2 KiB
Python
108 lines
5.2 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 한정**으로 등록했다.
|
|
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 만 켜져 있습니다 — 실제 수집은 일어나지 않습니다")
|