최상단을 프로젝트 단위로 평평하게 둔다 — 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
291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""MockAdapter — 네트워크를 타지 않는 가짜 수집기. **Phase 1 에 등록된 유일한 어댑터.**
|
|
|
|
크롤링 법적 검토가 끝나지 않아(docs/DECISIONS.md 1-1) 실제 수집 어댑터를 등록할 수 없다.
|
|
그렇다고 수집 이후 단계(fact 검증 전이 · 사람 확인 큐 · 사이트 빌드)를 못 만들 이유는 없으므로,
|
|
같은 계약(SourceAdapter)을 만족하는 가짜 소스를 두고 파이프라인 전체를 여기에 물려 돌린다.
|
|
|
|
**뱉는 fact key 는 전부 업종 스키마(common/category_schema)에 있는 것만 쓴다.**
|
|
스키마에 없는 key 는 fact 기록 단계에서 FACT_INVALID_KEY 로 전부 거부되므로,
|
|
그런 목데이터는 파이프라인을 검증하지 못하는 무의미한 데이터가 된다.
|
|
그래서 이 파일은 기동 시 자기 목데이터를 스키마와 대조하고, 어긋나면 즉시 예외를 던진다.
|
|
|
|
URL 규약 (업종을 URL 에서 읽어 결정적으로 동작한다)
|
|
mock://lodging/pension-1
|
|
mock://cafe/cafe-1?channel=naver_place
|
|
https://mock.test/restaurant/r-1
|
|
"""
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
from common.category_schema import get_schema
|
|
from common.enums import LinkChannel, PlaceCategory
|
|
from common.logger import LOG
|
|
from services.collector.base import CollectedFact, CollectedMedia, CollectError, RawSource
|
|
|
|
_SCHEMES = ("mock",)
|
|
_HOSTS = ("mock.test",)
|
|
|
|
# URL 에 쓰는 업종 이름 → PlaceCategory. category_schema 의 `name` 과 같은 표기를 쓴다.
|
|
_CATEGORY_BY_NAME = {
|
|
"lodging": PlaceCategory.LODGING,
|
|
"cafe": PlaceCategory.CAFE,
|
|
"restaurant": PlaceCategory.RESTAURANT,
|
|
"tour_activity": PlaceCategory.TOUR_ACTIVITY,
|
|
}
|
|
|
|
_CHANNEL_BY_NAME = {c.name.lower(): c for c in LinkChannel}
|
|
|
|
|
|
# ---- 업종별 목데이터 --------------------------------------------------------
|
|
# (key, value) — 전부 해당 업종 스키마에 존재하는 key 여야 한다.
|
|
# 소개문 계열(intro/room_intro/…)은 넣지 않는다. 그건 수집물이 아니라 generator 가 쓰는 문장이다.
|
|
_PLACE_FACTS = {
|
|
PlaceCategory.LODGING: [
|
|
("check_in_time", "15:00"),
|
|
("check_out_time", "11:00"),
|
|
("cancel_policy", "이용 7일 전 100% 환불, 3일 전 50% 환불, 당일 취소 불가"),
|
|
("cooking_allowed", "true"),
|
|
("pet_allowed", "false"),
|
|
("smoking", "false"),
|
|
("parking", "true"),
|
|
("parking_capacity", "10"),
|
|
("wifi", "true"),
|
|
("bbq_available", "true"),
|
|
("bbq_fee", "20000"),
|
|
("breakfast", "false"),
|
|
("reception_hours", "09:00~21:00"),
|
|
],
|
|
PlaceCategory.CAFE: [
|
|
("business_hours", "10:00~22:00"),
|
|
("closed_days", "매주 화요일"),
|
|
("break_time", "15:00~16:00"),
|
|
("last_order", "21:30"),
|
|
("pet_allowed", "true"),
|
|
("kids_allowed", "true"),
|
|
("parking", "true"),
|
|
("parking_free_hours", "2"),
|
|
("seat_count", "40"),
|
|
("wifi", "true"),
|
|
("power_outlet", "true"),
|
|
("takeout", "true"),
|
|
("signature_menu", "흑임자 라떼"),
|
|
("price_range", "5,000~9,000원"),
|
|
],
|
|
PlaceCategory.RESTAURANT: [
|
|
("business_hours", "11:00~21:00"),
|
|
("closed_days", "매주 월요일"),
|
|
("break_time", "15:00~17:00"),
|
|
("last_order", "20:30"),
|
|
("reservation_required", "true"),
|
|
("reservation_channel", "전화 예약"),
|
|
("pet_allowed", "false"),
|
|
("kids_allowed", "true"),
|
|
("room_available", "true"),
|
|
("group_seat_max", "20"),
|
|
("parking", "true"),
|
|
("takeout", "true"),
|
|
("signature_menu", "한우 물회"),
|
|
("price_range", "15,000~30,000원"),
|
|
],
|
|
PlaceCategory.TOUR_ACTIVITY: [
|
|
("operating_hours", "09:00~18:00"),
|
|
("closed_days", "매주 수요일"),
|
|
("reservation_required", "true"),
|
|
("reservation_channel", "네이버 예약"),
|
|
("cancel_policy", "체험 3일 전까지 100% 환불, 당일 취소 불가"),
|
|
("age_limit", "만 7세 이상"),
|
|
("safety_notice", "구명조끼 착용 필수, 음주 후 참여 불가"),
|
|
("weather_dependent", "우천 시 당일 오전 8시에 개별 안내"),
|
|
("what_to_bring", "여벌 옷, 수건"),
|
|
("parking", "true"),
|
|
],
|
|
}
|
|
|
|
# 단위(객실·메뉴·프로그램)별 목데이터 — {단위이름: [(key, value)]}
|
|
_UNIT_FACTS = {
|
|
PlaceCategory.LODGING: {
|
|
"A동 스탠다드": [
|
|
("room_type", "스탠다드"),
|
|
("standard_capacity", "2"),
|
|
("max_capacity", "4"),
|
|
("bed_type", "퀸 1"),
|
|
("has_kitchen", "true"),
|
|
("weekday_price", "120000"),
|
|
("weekend_price", "180000"),
|
|
],
|
|
"B동 복층": [
|
|
("room_type", "복층"),
|
|
("standard_capacity", "4"),
|
|
("max_capacity", "6"),
|
|
("bed_type", "퀸 1 + 싱글 2"),
|
|
("has_kitchen", "true"),
|
|
("weekday_price", "190000"),
|
|
("weekend_price", "260000"),
|
|
],
|
|
},
|
|
PlaceCategory.CAFE: {
|
|
"흑임자 라떼": [("menu_name", "흑임자 라떼"), ("menu_price", "6500")],
|
|
"바스크 치즈케이크": [("menu_name", "바스크 치즈케이크"), ("menu_price", "7000")],
|
|
},
|
|
PlaceCategory.RESTAURANT: {
|
|
"한우 물회": [("menu_name", "한우 물회"), ("menu_price", "18000")],
|
|
"성게 비빔밥": [("menu_name", "성게 비빔밥"), ("menu_price", "22000"), ("menu_min_order", "2")],
|
|
},
|
|
PlaceCategory.TOUR_ACTIVITY: {
|
|
"서핑 입문 클래스": [
|
|
("program_name", "서핑 입문 클래스"),
|
|
("program_duration", "120"),
|
|
("program_capacity", "8"),
|
|
("price_adult", "70000"),
|
|
("price_child", "50000"),
|
|
],
|
|
"패들보드 체험": [
|
|
("program_name", "패들보드 체험"),
|
|
("program_duration", "90"),
|
|
("program_capacity", "6"),
|
|
("price_adult", "55000"),
|
|
],
|
|
},
|
|
}
|
|
|
|
# 사진 — (라벨 후보, 단위 이름). 단위 이름이 None 이면 사업장 공용 사진.
|
|
_MEDIA = {
|
|
PlaceCategory.LODGING: [
|
|
("외관", None),
|
|
("A동 침실", "A동 스탠다드"),
|
|
("A동 거실", "A동 스탠다드"),
|
|
("B동 침실", "B동 복층"),
|
|
("바비큐장", None),
|
|
],
|
|
PlaceCategory.CAFE: [
|
|
("매장 내부", None),
|
|
("테라스", None),
|
|
("흑임자 라떼", "흑임자 라떼"),
|
|
("바스크 치즈케이크", "바스크 치즈케이크"),
|
|
],
|
|
PlaceCategory.RESTAURANT: [
|
|
("매장 외관", None),
|
|
("홀 전경", None),
|
|
("한우 물회", "한우 물회"),
|
|
("성게 비빔밥", "성게 비빔밥"),
|
|
],
|
|
PlaceCategory.TOUR_ACTIVITY: [
|
|
("해변 전경", None),
|
|
("장비 보관소", None),
|
|
("서핑 강습", "서핑 입문 클래스"),
|
|
("패들보드", "패들보드 체험"),
|
|
],
|
|
}
|
|
|
|
|
|
def _validate_mock_data():
|
|
"""목데이터의 모든 key 가 업종 스키마에 있는지 확인한다. 최초 import 시 1회.
|
|
|
|
어긋나면 즉시 예외 — 스키마에 없는 key 로 만든 목데이터는 fact 기록에서 전부 거부되므로,
|
|
'테스트는 도는데 실제로는 하나도 안 들어가는' 상태를 배포 전에 드러낸다."""
|
|
for category, rows in _PLACE_FACTS.items():
|
|
schema = get_schema(category)
|
|
for key, _value in rows:
|
|
spec = schema.get(key)
|
|
if spec is None:
|
|
raise CollectError(f"MockAdapter 목데이터 오류: {schema.name} 스키마에 없는 key={key}")
|
|
if spec.scope != "place":
|
|
raise CollectError(f"MockAdapter 목데이터 오류: {schema.name}.{key} 는 place 스코프가 아니다")
|
|
|
|
for category, units in _UNIT_FACTS.items():
|
|
schema = get_schema(category)
|
|
for unit_name, rows in units.items():
|
|
for key, _value in rows:
|
|
spec = schema.get(key)
|
|
if spec is None:
|
|
raise CollectError(f"MockAdapter 목데이터 오류: {schema.name} 스키마에 없는 key={key} (단위 {unit_name})")
|
|
if spec.scope != "unit":
|
|
raise CollectError(f"MockAdapter 목데이터 오류: {schema.name}.{key} 는 unit 스코프가 아니다")
|
|
|
|
|
|
_validate_mock_data()
|
|
|
|
|
|
class MockAdapter:
|
|
"""가짜 수집기. 같은 URL 이면 항상 같은 결과를 돌려준다(fetched_at 만 다르다).
|
|
|
|
업종은 URL 에서 읽는다 — SourceAdapter.fetch(url) 계약을 그대로 지키면서
|
|
업종별 목데이터를 고를 수 있게 하기 위함이다.
|
|
"""
|
|
|
|
id = "mock"
|
|
|
|
def can_handle(self, url: str) -> bool:
|
|
try:
|
|
parsed = urlparse(url or "")
|
|
except ValueError:
|
|
return False
|
|
if parsed.scheme in _SCHEMES:
|
|
return True
|
|
return parsed.scheme in ("http", "https") and parsed.hostname in _HOSTS
|
|
|
|
async def fetch(self, url: str) -> RawSource:
|
|
"""URL 에서 업종을 읽어 그 업종의 목데이터를 돌려준다.
|
|
|
|
업종을 못 읽으면 예외가 아니라 실패 결과(ok=False)로 돌려준다 —
|
|
채널 하나가 이상해도 나머지 수집이 멈추면 안 된다."""
|
|
if not self.can_handle(url):
|
|
return RawSource.failure(url, self.id, f"MockAdapter 가 처리할 수 없는 URL: {url}")
|
|
|
|
category = self._category_of(url)
|
|
if category is None:
|
|
return RawSource.failure(url, self.id, f"URL 에서 업종을 읽지 못했다(가능: {sorted(_CATEGORY_BY_NAME)}): {url}")
|
|
|
|
facts = [CollectedFact(key=k, value=v, scope="place") for k, v in _PLACE_FACTS[category]]
|
|
for unit_name, rows in _UNIT_FACTS[category].items():
|
|
facts += [CollectedFact(key=k, value=v, scope="unit", unit_name=unit_name) for k, v in rows]
|
|
|
|
media = [
|
|
CollectedMedia(
|
|
origin_url=f"https://mock.test/img/{category.name.lower()}/{idx}.jpg",
|
|
label=label,
|
|
unit_name=unit_name,
|
|
)
|
|
for idx, (label, unit_name) in enumerate(_MEDIA[category], start=1)
|
|
]
|
|
|
|
schema = get_schema(category)
|
|
LOG.d(f"[collector:{self.id}] {schema.name} fact {len(facts)}건 · 사진 {len(media)}장 (url={url})")
|
|
return RawSource(
|
|
url=url,
|
|
adapter_id=self.id,
|
|
channel=self._channel_of(url),
|
|
html=f"<html><body><h1>{schema.label} 목데이터</h1></body></html>",
|
|
text=f"{schema.label} 목데이터",
|
|
facts=facts,
|
|
media=media,
|
|
)
|
|
|
|
# ---- URL 파싱 ----------------------------------------------------------
|
|
@staticmethod
|
|
def _category_of(url: str):
|
|
"""mock://lodging/... 또는 https://mock.test/lodging/... 에서 업종을 읽는다."""
|
|
parsed = urlparse(url)
|
|
# mock:// 는 첫 조각이 hostname 으로, https://mock.test/ 는 path 첫 조각으로 들어온다.
|
|
candidates = [parsed.hostname or ""] + [p for p in parsed.path.split("/") if p]
|
|
for token in candidates:
|
|
category = _CATEGORY_BY_NAME.get(token.lower())
|
|
if category is not None:
|
|
return category
|
|
return None
|
|
|
|
@staticmethod
|
|
def _channel_of(url: str) -> LinkChannel:
|
|
"""?channel=yanolja 로 채널을 지정할 수 있다. 없으면 ETC."""
|
|
query = parse_qs(urlparse(url).query)
|
|
name = (query.get("channel") or [""])[0].lower()
|
|
return _CHANNEL_BY_NAME.get(name, LinkChannel.ETC)
|
|
|
|
# ---- 편의 ---------------------------------------------------------------
|
|
@staticmethod
|
|
def url_for(category, slug: str = "1", channel: str | None = None) -> str:
|
|
"""이 어댑터가 처리할 수 있는 mock URL 을 만든다(테스트·시드용)."""
|
|
cat = category if isinstance(category, PlaceCategory) else PlaceCategory(category)
|
|
name = next(n for n, c in _CATEGORY_BY_NAME.items() if c is cat)
|
|
suffix = f"?channel={channel}" if channel else ""
|
|
return f"mock://{name}/{slug}{suffix}"
|