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 에 둔다.
305 lines
16 KiB
Python
305 lines
16 KiB
Python
"""TourAPI 어댑터 — 값 매핑과 저작권 게이트.
|
|
|
|
네트워크를 타지 않는다. TourAPI 실제 응답에서 **그대로 복사한** 표본으로 매핑만 검증한다
|
|
(2026-08-31 실측: 가재와곰펜션 contentId=2708335, 가문 contentId=1052741).
|
|
|
|
가장 중요한 것 두 개:
|
|
1. 상업적 이용이 막힌 사진이 수집물에 섞이지 않는가 (docs/DECISIONS.md 1-2)
|
|
2. "불가 (인근 주차장 이용)" 같은 값의 부호가 뒤집히지 않는가
|
|
— 뒤집히면 주차 안 되는 집이 '주차 가능' 으로 발행된다
|
|
"""
|
|
import pytest
|
|
|
|
from common.category_schema import get_schema
|
|
from common.enums import PlaceCategory
|
|
from services.collector.tour_api_adapter import (
|
|
COMMERCIAL_OK_LICENSES,
|
|
NO_DERIVATIVE_LICENSES,
|
|
TourApiAdapter,
|
|
)
|
|
|
|
ADAPTER = TourApiAdapter()
|
|
|
|
# ── 실제 응답에서 복사한 표본 ──────────────────────────────────────────────
|
|
_LODGING_INTRO = {
|
|
"checkintime": "14:00", "checkouttime": "12:00",
|
|
"refundregulation": "예약완료-숙박 6일 전까지 수수료 10% 환불<br>금액 100%",
|
|
"chkcooking": "가능", "parkinglodging": "가능 (객실당 1대 무료 주차)",
|
|
"pickup": "부분 가능", "barbecue": "1", "karaoke": "0",
|
|
"roomcount": "13실", "scalelodging": "대지 면적 11,570㎡", "foodplace": "있음",
|
|
}
|
|
_ROOM_ROW = {
|
|
"roomtitle": "황토방", "roomsize1": "12", "roomsize2": "39.6",
|
|
"roombasecount": "2", "roommaxcount": "4",
|
|
"roomoffseasonminfee1": "100000", "roomoffseasonminfee2": "120000",
|
|
"roompeakseasonminfee1": "130000",
|
|
"roomcook": "Y", "roomaircondition": "Y", "roombathfacility": "Y",
|
|
"roomimg1": "http://tong.visitkorea.or.kr/a.jpg", "roomimg1alt": "황토방 5", "cpyrhtDivCd1": "Type3",
|
|
"roomimg2": "http://tong.visitkorea.or.kr/b.jpg", "roomimg2alt": "황토방 4", "cpyrhtDivCd2": "Type2",
|
|
}
|
|
_RESTAURANT_INTRO = {
|
|
"opentimefood": "- 08:30~20:00<br>- 준비시간 15:00~16:00",
|
|
"restdatefood": "매주 수요일", "firstmenu": "삼선자장",
|
|
"treatmenu": "게살해물요리 / 난자완스 / 특색냉채 등",
|
|
"parkingfood": "불가 (인근 주차장 이용)", "packing": "가능",
|
|
"kidsfacility": "0", "chkcreditcardfood": "없음", "smoking": "모두 금연석",
|
|
"infocenterfood": "02-2608-5255",
|
|
}
|
|
|
|
|
|
def _map(facts):
|
|
return {(f.key, f.unit_name): f.value for f in facts}
|
|
|
|
|
|
# ── URL 라우팅 ────────────────────────────────────────────────────────────
|
|
@pytest.mark.parametrize("url,expected", [
|
|
("https://korean.visitkorea.or.kr/detail/ms_detail.do?cotid=2708335", True),
|
|
("tour://32/2708335", True),
|
|
("tour://39/1052741", True),
|
|
("https://gangmunstay.kr/", False),
|
|
("https://m.place.naver.com/accommodation/1/home", False),
|
|
("https://www.yanolja.com/pension/1", False),
|
|
])
|
|
def test_can_handle(url, expected):
|
|
"""검증: 어떤 주소를 받는가.
|
|
기대결과: 구석구석 상세주소와 tour:// 만. 다른 어댑터 영역을 넘보지 않는다."""
|
|
assert ADAPTER.can_handle(url) is expected
|
|
|
|
|
|
# ── 부호 판정 (여기가 제일 위험한 자리) ────────────────────────────────────
|
|
@pytest.mark.parametrize("raw,expected", [
|
|
("가능", True),
|
|
("가능 (객실당 1대 무료 주차)", True),
|
|
("부분 가능", True),
|
|
("불가", False),
|
|
("불가 (인근 주차장 이용)", False), # ★ 뒤에 '이용' 이 있다고 true 가 되면 안 된다
|
|
("없음", False),
|
|
("", None),
|
|
("문의", None), # 모르면 None — '아니오' 로 단정하지 않는다
|
|
])
|
|
def test_head_bool_reads_leading_token_only(raw, expected):
|
|
"""검증: '가능/불가 (설명)' 형태의 부호 판정.
|
|
|
|
기대결과: **맨 앞 토큰**으로만 판정한다. 문자열 전체를 훑으면
|
|
"불가 (인근 주차장 이용)" 이 true 로 뒤집혀 주차 안 되는 집이 '주차 가능' 으로 발행된다."""
|
|
assert ADAPTER._head_bool(raw) is expected
|
|
|
|
|
|
def test_unknown_value_becomes_no_fact_not_false():
|
|
"""검증: 판정할 수 없는 값.
|
|
기대결과: fact 자체를 만들지 않는다. '모름' 과 '아니오' 는 다르다."""
|
|
facts = _map(ADAPTER._lodging_facts({"chkcooking": "문의 바랍니다"}))
|
|
assert ("cooking_allowed", None) not in facts
|
|
|
|
|
|
# ── 숙박 매핑 ─────────────────────────────────────────────────────────────
|
|
def test_lodging_place_facts():
|
|
"""검증: 숙박 detailIntro2 → 사업장 fact.
|
|
기대결과: 체크인·환불규정·취사·주차·픽업·바비큐가 스키마 key 로 들어온다."""
|
|
f = _map(ADAPTER._lodging_facts(_LODGING_INTRO))
|
|
assert f[("check_in_time", None)] == "14:00"
|
|
assert f[("check_out_time", None)] == "12:00"
|
|
assert f[("cooking_allowed", None)] == "true"
|
|
assert f[("parking", None)] == "true"
|
|
assert f[("pickup_service", None)] == "true"
|
|
assert f[("bbq_available", None)] == "true"
|
|
assert "<br>" not in f[("cancel_policy", None)]
|
|
|
|
|
|
def test_lodging_drops_fields_without_schema_slot():
|
|
"""검증: 스키마에 자리가 없는 TourAPI 필드(roomcount·scalelodging·foodplace).
|
|
|
|
기대결과: 아무 key 로도 들어오지 않는다. ★ 억지로 다른 key 에 넣으면
|
|
'식음료장 있음' 이 '조식 제공' 으로 둔갑한다."""
|
|
values = {v for v in _map(ADAPTER._lodging_facts(_LODGING_INTRO)).values()}
|
|
assert "13실" not in values and "있음" not in values
|
|
assert not any("11,570" in str(v) for v in values)
|
|
|
|
|
|
def test_room_facts_are_unit_scoped_and_use_square_meters():
|
|
"""검증: detailInfo2 → 객실 단위 fact.
|
|
|
|
기대결과: unit_name 이 객실명이고, 면적은 roomsize2(㎡)를 쓴다.
|
|
★ roomsize1 은 평이라 섞으면 3배 틀린 면적이 발행된다."""
|
|
f = _map(ADAPTER._room_facts([_ROOM_ROW]))
|
|
assert f[("room_type", "황토방")] == "황토방"
|
|
assert f[("standard_capacity", "황토방")] == "2"
|
|
assert f[("max_capacity", "황토방")] == "4"
|
|
assert f[("room_size", "황토방")] == "39.6" # 12(평)이 아니다
|
|
assert f[("weekday_price", "황토방")] == "100000"
|
|
assert f[("weekend_price", "황토방")] == "120000"
|
|
assert f[("peak_price", "황토방")] == "130000"
|
|
assert f[("has_kitchen", "황토방")] == "true"
|
|
|
|
|
|
def test_room_facts_keys_exist_in_lodging_schema():
|
|
"""검증: 객실 fact 의 key 가 전부 숙박 스키마에 있는가.
|
|
기대결과: 전부 존재. 없으면 FACT_INVALID_KEY 로 조용히 버려져 화면이 빈다."""
|
|
schema = get_schema(PlaceCategory.LODGING)
|
|
for fact in ADAPTER._room_facts([_ROOM_ROW]) + ADAPTER._lodging_facts(_LODGING_INTRO):
|
|
spec = schema.get(fact.key)
|
|
assert spec is not None, f"숙박 스키마에 없는 key: {fact.key}"
|
|
assert spec.scope == fact.scope, f"{fact.key} 스코프 불일치: {spec.scope} != {fact.scope}"
|
|
|
|
|
|
# ── 음식점 매핑 ───────────────────────────────────────────────────────────
|
|
def test_restaurant_facts():
|
|
"""검증: 음식점 detailIntro2 → 사업장 fact.
|
|
기대결과: 영업시간의 <br> 이 풀리고, 주차는 '불가' 로 정확히 읽힌다."""
|
|
f = _map(ADAPTER._restaurant_facts(_RESTAURANT_INTRO))
|
|
assert f[("business_hours", None)].startswith("- 08:30~20:00")
|
|
assert "<br>" not in f[("business_hours", None)]
|
|
assert f[("closed_days", None)] == "매주 수요일"
|
|
assert f[("signature_menu", None)] == "삼선자장"
|
|
assert f[("parking", None)] == "false"
|
|
assert f[("takeout", None)] == "true"
|
|
|
|
|
|
def test_restaurant_never_maps_kids_facility_to_kids_allowed():
|
|
"""검증: kidsfacility=0 인 음식점.
|
|
|
|
기대결과: kids_allowed fact 를 만들지 않는다. ★ 실측 회귀 —
|
|
'어린이놀이방 없음' 을 '아이 동반 불가' 로 올려 발행하면 손님을 돌려보내게 된다."""
|
|
assert ("kids_allowed", None) not in _map(ADAPTER._restaurant_facts(_RESTAURANT_INTRO))
|
|
|
|
|
|
def test_restaurant_never_maps_credit_card_flag_to_payment_methods():
|
|
"""검증: chkcreditcardfood='없음'.
|
|
기대결과: payment_methods 로 들어오지 않는다 — 그 필드는 결제수단 '목록' 이다."""
|
|
assert ("payment_methods", None) not in _map(ADAPTER._restaurant_facts(_RESTAURANT_INTRO))
|
|
|
|
|
|
def test_treat_menu_splits_and_drops_trailing_marker():
|
|
"""검증: treatmenu '게살해물요리 / 난자완스 / 특색냉채 등'.
|
|
기대결과: 메뉴 3건. 꼬리의 '등' 은 메뉴가 아니다."""
|
|
names = [f.unit_name for f in ADAPTER._restaurant_facts(_RESTAURANT_INTRO) if f.key == "menu_name"]
|
|
assert names == ["게살해물요리", "난자완스", "특색냉채"]
|
|
|
|
|
|
def test_restaurant_facts_keys_exist_in_schema():
|
|
"""검증: 음식점 fact 의 key 가 전부 스키마에 있는가."""
|
|
schema = get_schema(PlaceCategory.RESTAURANT)
|
|
for fact in ADAPTER._restaurant_facts(_RESTAURANT_INTRO):
|
|
spec = schema.get(fact.key)
|
|
assert spec is not None, f"음식점 스키마에 없는 key: {fact.key}"
|
|
assert spec.scope == fact.scope
|
|
|
|
|
|
# ── 저작권 게이트 (docs/DECISIONS.md 1-2) ─────────────────────────────────
|
|
def test_license_constants_match_kogl():
|
|
"""검증: 공공누리 유형 상수.
|
|
|
|
기대결과: 상업적 이용 가능은 1·3유형뿐이고, 변경금지는 3·4유형이다.
|
|
(2·4유형 = 상업적 이용금지 / 3·4유형 = 변경금지)"""
|
|
assert COMMERCIAL_OK_LICENSES == {"type1", "type3"}
|
|
assert NO_DERIVATIVE_LICENSES == {"type3", "type4"}
|
|
|
|
|
|
def test_media_drops_non_commercial_license():
|
|
"""검증: 같은 객실에 Type3 사진과 Type2 사진이 섞여 온다.
|
|
|
|
기대결과: Type3 만 담고 Type2 는 버린다. ★ 2유형은 상업적 이용금지라
|
|
사장님 홈페이지(=상업적 이용)에 실으면 조건 위반이다."""
|
|
media = ADAPTER._media({}, [], [_ROOM_ROW])
|
|
urls = [m.origin_url for m in media]
|
|
assert "http://tong.visitkorea.or.kr/a.jpg" in urls # Type3
|
|
assert "http://tong.visitkorea.or.kr/b.jpg" not in urls # Type2 — 버려야 한다
|
|
assert all(m.license in COMMERCIAL_OK_LICENSES for m in media)
|
|
|
|
|
|
def test_media_drops_unknown_license():
|
|
"""검증: 저작권 유형이 비었거나 처음 보는 코드.
|
|
|
|
기대결과: 버린다. 상업 사이트에 실을 사진이라 '모름' 은 안전한 쪽이 아니다 —
|
|
새 코드가 생겼을 때 조용히 통과시키는 것보다 빠뜨리는 쪽이 낫다."""
|
|
rows = [{"roomtitle": "A", "roomimg1": "http://x/1.jpg", "cpyrhtDivCd1": ""},
|
|
{"roomtitle": "B", "roomimg1": "http://x/2.jpg", "cpyrhtDivCd1": "Type9"}]
|
|
assert ADAPTER._media({}, [], rows) == []
|
|
|
|
|
|
def test_media_carries_license_so_no_derivative_can_be_honored():
|
|
"""검증: 담긴 사진이 라이선스를 들고 나가는가.
|
|
|
|
기대결과: license 가 채워진다. ★ Type3 는 **변경금지**라 뒤에서 크롭·리사이즈하면
|
|
조건을 어긴다. 어느 사진이 손대면 안 되는지는 수집 시점에만 알 수 있다."""
|
|
media = ADAPTER._media({}, [], [_ROOM_ROW])
|
|
assert media[0].license == "type3"
|
|
assert media[0].license in NO_DERIVATIVE_LICENSES
|
|
assert media[0].unit_name == "황토방"
|
|
|
|
|
|
def test_media_keeps_room_photo_attached_to_its_room():
|
|
"""검증: 객실 사진의 unit_name.
|
|
기대결과: 그 객실 이름이 붙는다 — 안 붙으면 전부 대표사진 더미로 섞인다."""
|
|
assert {m.unit_name for m in ADAPTER._media({}, [], [_ROOM_ROW])} == {"황토방"}
|
|
|
|
|
|
# ── 값 다듬기 ─────────────────────────────────────────────────────────────
|
|
@pytest.mark.parametrize("raw,expected", [
|
|
("13실", "13"), ("11,570㎡", "11570"), ("39.6", "39.6"),
|
|
("16명", "16"), ("", None), ("문의", None), ("0", None),
|
|
])
|
|
def test_number_strips_units_and_commas(raw, expected):
|
|
"""검증: '13실'·'11,570㎡' 같은 단위 붙은 숫자.
|
|
기대결과: 숫자만 남는다. 숫자로 시작하지 않으면 담지 않는다."""
|
|
assert ADAPTER._number(raw) == expected
|
|
|
|
|
|
def test_plain_unwraps_br_but_keeps_negation_words():
|
|
"""검증: <br> 제거가 글자를 지우지 않는가.
|
|
기대결과: '불가' 가 남는다 — 태그 제거하다 부정어가 사라지면 부호가 뒤집힌다."""
|
|
assert "불가" in ADAPTER._plain("주차<br>불가<br>합니다")
|
|
assert "<br>" not in ADAPTER._plain("주차<br>불가")
|
|
|
|
|
|
# ── 콘텐츠 조회 (services/external/tour_lookup.py) ────────────────────────
|
|
# 네트워크를 타지 않는다. 검색어 생성과 상호 대조 규칙만 본다.
|
|
from services.external.tour_lookup import ( # noqa: E402
|
|
MAX_QUERY_ATTEMPTS, _name_matches, _query_candidates, normalize,
|
|
)
|
|
|
|
|
|
def test_query_candidates_shortens_from_the_tail():
|
|
"""검증: 등록명보다 긴 상호.
|
|
|
|
기대결과: 전체 → 뒤 토큰을 하나씩 뗀 순서. ★ 실측 회귀 —
|
|
'그래비티 조선 서울 판교 오토그래프 컬렉션' 은 searchKeyword2 에서 0건인데
|
|
'그래비티 조선 서울 판교' 로 줄이면 잡힌다. 축약이 없으면 이 호텔은 영영 못 찾는다."""
|
|
got = _query_candidates("그래비티 조선 서울 판교 오토그래프 컬렉션")
|
|
assert got[0] == "그래비티 조선 서울 판교 오토그래프 컬렉션"
|
|
assert "그래비티 조선 서울 판교" in got
|
|
assert len(got) <= MAX_QUERY_ATTEMPTS
|
|
|
|
|
|
@pytest.mark.parametrize("name,expected", [
|
|
("가재와곰펜션", ["가재와곰펜션"]), # 한 토큰이면 후보도 하나
|
|
("", []),
|
|
(" ", []),
|
|
])
|
|
def test_query_candidates_edge_cases(name, expected):
|
|
"""검증: 토큰이 하나거나 비어 있는 상호.
|
|
기대결과: 불필요한 호출을 만들지 않는다."""
|
|
assert _query_candidates(name) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("ours,theirs,match", [
|
|
("그래비티 조선 서울 판교 오토그래프 컬렉션", "그래비티 조선 서울 판교", True), # 등록명이 더 짧다
|
|
("가재와곰", "가재와곰펜션", True), # 등록명이 더 길다
|
|
("롯데호텔 월드", "롯데호텔월드", True), # 공백만 다르다
|
|
("롯데호텔 월드", "롯데호텔 부산", False), # 다른 지점
|
|
("코트야드 바이 메리어트 서울 판교", "그래비티 조선 서울 판교", False), # 같은 동네 다른 호텔
|
|
])
|
|
def test_name_matches(ours, theirs, match):
|
|
"""검증: 상호 대조.
|
|
|
|
기대결과: 포함 관계면 같은 업소로 본다. ★ 다른 지점을 같다고 하면
|
|
남의 가게 객실 요금이 우리 사장님 사이트에 실린다 — 여기가 가장 비싼 실수다."""
|
|
assert _name_matches(ours, theirs) is match
|
|
|
|
|
|
def test_normalize_matches_naver_lookup_rule():
|
|
"""검증: 상호 정규화 규칙.
|
|
기대결과: 공백·기호를 걷어내고 소문자로 — naver_place_lookup 과 같은 기준이다."""
|
|
assert normalize("롯데호텔 월드") == "롯데호텔월드"
|
|
assert normalize("A-1 Cafe & Bar") == "a1cafebar"
|