최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
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 만 켜져 있습니다 — 실제 수집은 일어나지 않습니다")
|