"""발행 payload — 정적 렌더러(solution/site)가 먹는 유일한 입력 JSON. ★ 왜 필요한가 빌드 잡은 지금까지 HTML 을 굽고 그 **길이만** 재고 버렸다(발행 기록은 남는데 페이지가 없었다). 실제로 방문자에게 보여줄 페이지는 solution/site 의 SSG 가 굽는다. 그 렌더러의 유일한 입력이 이 payload 이므로, 빌드 잡이 이 JSON 만 파일로 떨어뜨리면 발행이 실제 페이지로 이어진다. ★ 스키마는 solution/shared/src/types/site-payload.ts 의 `SitePayload` 다. 필드명이 camelCase 인 이유는 그쪽이 원본이기 때문이다 — 여기서 스네이크로 바꾸면 렌더러가 못 읽는다. 스키마가 바뀌면 schemaVersion 을 올린다(렌더러는 모르는 버전을 조용히 반쪽 렌더하지 않고 실패한다). ★ DB 를 여기서 다시 읽지 않는다. 입력은 이미 박제된 스냅샷(site_versions.snapshot)과 그 빌드가 만든 행들뿐이다. 스냅샷이 정적 빌드의 경계다 — 여기서 DB 를 한 번 더 읽으면 '스냅샷과 다른 페이지'가 나올 수 있다. (channel 링크만 스냅샷에 없어서 호출측이 읽어 넘긴다.) """ import json import re import os import unicodedata from datetime import datetime, timezone from pathlib import Path from common.category_schema import get_schema from common.enums import ( PUBLISHABLE_FACT_STATUSES, FactStatus, LinkChannel, LocalContentType, PlaceCategory, SiteStatus, SourceType, ) from common.logger import LOG from services.intro_summary import summarize_intro from services.stay_guide import nol_stay_guide # 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다. SCHEMA_VERSION = 1 # 출력 디렉토리. 컨테이너 밖(볼륨·오브젝트 스토리지)으로 빼기 쉬우라고 env 로 둔다. PAYLOAD_DIR_ENV = "SITE_PAYLOAD_DIR" DEFAULT_PAYLOAD_DIR = "/app/out/payloads" # 커스텀 도메인이 없을 때 쓰는 기본 호스트. sites.domain 이 채워지면 그 값이 이긴다. # # ★ env 로 뺀 이유: 이 값이 canonical·og:url·사이트맵·IndexNow 통보에 **전부** 들어간다. # 상수로 박아 두면 스테이징에 올릴 때마다 코드를 고쳐야 하고, 안 고치면 발행은 성공하는데 # 검색엔진에는 열리지도 않는 주소가 등록된다(조용히 틀린다 — 아무도 눈치채지 못한다). # 프론트도 같은 이유로 VITE_PUBLISH_HOST 를 쓴다. 두 값은 **같아야 한다**. SITE_HOST_ENV = "SITE_PUBLIC_HOST" # 기본값은 localhost. 운영 도메인을 기본으로 두면 설정을 빠뜨린 환경이 조용히 운영 주소로 # canonical·sitemap 을 굽는다 — 틀렸다는 걸 아무도 모른다. DEFAULT_HOST = os.environ.get(SITE_HOST_ENV, "").strip() or "localhost" def _scheme(host: str) -> str: """localhost 는 http 다. shared/lib/slug.ts publishUrl 과 같은 규칙.""" return "http" if re.match(r"^(localhost|127\.0\.0\.1)(:\d+)?$", host) else "https" # 링크 제목이 비었을 때 채우는 채널 이름. 없는 채널명을 지어내지 않기 위한 고정 표다. _CHANNEL_TITLE = { LinkChannel.YANOLJA.value: "야놀자", LinkChannel.GOODCHOICE.value: "여기어때", LinkChannel.NAVER_PLACE.value: "네이버 플레이스", LinkChannel.INSTAGRAM.value: "인스타그램", LinkChannel.OFFICIAL_SITE.value: "공식 홈페이지", LinkChannel.BLOG.value: "블로그", LinkChannel.ETC.value: "기타 채널", } # 저장된 look 이 없을 때 쓰는 기본 생김새 — 에디터의 '심플' 템플릿 # (`solution/frontend/src/data/industryData.ts` 의 LOOK.simple)과 같은 값이다. # ★ 왜 필요한가 (실측 2026-09-08, `/s/stay-mumum-gunsan`) # 이 키가 없으면 `` 에 --tpl-font-heading·--tpl-radius·--tpl-texture 가 아예 # 안 실리고, 발행본은 렌더러 CSS 의 폴백으로 떨어진다. 그 폴백의 제목 서체는 # `--font-serif`(명조)다 — 그래서 위저드를 안 돈 사업장의 발행본만 제목이 명조로, # 모서리는 렌더러 기본값으로 나가 에디터 미리보기(고딕)와 눈에 띄게 갈렸다. # 색은 업종 기본이 있는데 생김새만 없어서 생긴 구멍이라, 고르지 않았을 때의 모습도 정해 둔다. _DEFAULT_LOOK = { "fontHeading": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", "fontBody": "'Pretendard Variable', 'Noto Sans KR', system-ui, sans-serif", "radius": "0.75rem", "borderWidth": "1px", "shadow": "0 1px 2px rgb(0 0 0 / 0.06)", "headingTracking": "-0.02em", "headingWeight": "700", "sectionSpace": "4rem", } # '옛 항구'(stay-retro)의 생김새. 프론트 `industryData.ts` 의 `LOOK.retro` 와 **같은 값이어야 한다.** # # ★ 왜 서버에도 두나 — 이 값은 프론트가 소유하지만, 백엔드는 TS 를 읽을 수 없고 # 저장값이 없는 사업장에는 이 표가 곧 발행본이다. 값이 없으면 `_DEFAULT_LOOK`(고딕·둥근 # 모서리)으로 떨어져 **색만 갱지고 서체는 고딕인** 페이지가 나간다 — 옛 항구가 아니게 된다. # ★ `texture` 가 이 템플릿의 정체성이다(갱지 결). `_DEFAULT_LOOK` 에는 이 칸 자체가 없다. _LOOK_RETRO = { "fontHeading": "'Gugi', 'Noto Sans KR', sans-serif", "fontBody": "'Gowun Batang', 'Noto Serif KR', serif", "radius": "0px", "borderWidth": "2px", "shadow": "4px 4px 0 rgb(27 26 21 / 0.16)", # 간판체는 자간을 벌리면 글자가 흩어지고, 굵기가 한 벌뿐이라 700 을 주면 가짜 볼드가 씌워진다. "headingTracking": "0em", "headingWeight": "400", "sectionSpace": "4rem", "texture": ( "repeating-linear-gradient(0deg,rgba(27,26,21,.028) 0 1px,transparent 1px 3px)," "repeating-linear-gradient(90deg,rgba(27,26,21,.02) 0 1px,transparent 1px 4px)" ), } # 업종별 **기본 디자인**. 사장님이 아직 아무것도 고르지 않았을 때 쓰는 폴백이다. # ★ 이제 여섯 가지가 모두 저장되는 자리를 갖는다: # templateId ← sites.template_id (POST /v1/place/{id}/site/template) # 색·서체·섹션 on/off·순서·배리에이션 ← sites.theme (POST /v1/place/{id}/site/theme) # 저장된 값이 있으면 **그게 이긴다**. 이 표는 저장값이 없을 때만 쓰인다 — # 고르지 않은 값을 고른 것처럼 굽지 않는다. # ★ 이 표의 섹션 목록은 에디터의 업종별 기본 목록 # (admin `src/data/industryData.ts` 의 `sections`)과 **id·순서·이름·잠금이 1:1로 같아야 한다.** # 여기가 에디터보다 적으면, 사장님이 에디터에서 본 섹션이 발행본에서 통째로 사라진다 — # 저장값이 없는 사업장은 이 표가 곧 발행본이기 때문이다(실측: 날씨·실시간 예약·대관 문의). # 에디터에 섹션을 늘릴 때는 이 표도 같이 늘린다. 어긋나면 tests/test_site_theme.py 가 잡는다. # ★ 이 표가 여전히 필요한 이유: 위저드를 끝까지 돌지 않은 사업장, 그리고 sections 의 # locked 판정 근거다(아래 _theme 주석 참조). 표의 세 번째 값이 locked 다. _DEFAULT_THEME = { PlaceCategory.LODGING.value: { # ★ 숙박의 기본 템플릿은 '옛 항구'(stay-retro)다 (2026-09-10, 사장님 지시). # 예전 값 "stay-o2o-editorial" 은 **어느 목록에도 없는 id** 였다 — 프론트가 가진 # 숙박 템플릿은 stay-simple · stay-magazine · stay-retro 셋뿐이라, 이 값이 실린 # 발행본은 에디터로 돌아왔을 때 고른 칩이 하나도 안 맞아 늘 첫 템플릿으로 그려졌다. "templateId": "stay-retro", "fontStyle": "옛 간판체", # 시안(/s/stay)의 :root 값 그대로 — paper / ink-soft / paper-2. # 주(朱) 잉크 accent 는 레트로의 정체성이라 업종 accent 로 갈아끼우지 않는다. "colors": {"primary": "#1b1a15", "secondary": "#4c4739", "bg": "#e4dac0", "card": "#efe7d3", "text": "#1b1a15", "accent": "#bf2f1b"}, "look": _LOOK_RETRO, # ★ 순서·구성이 시안(/s/stay)과 같다. 여기가 시안보다 적으면 새로 만든 사업장은 # 수집이 다 됐어도 그 섹션이 아예 안 나온다 — 저장값이 없는 사업장에는 이 표가 곧 발행본이다. # ★ "이용 규정"은 뺐다 (2026-09-09) — 발행본에 그 섹션이 없다. 체크인·취소·취사· # 반려동물 줄은 기본 정보 안에서 규정 덩이로 묶여 나간다(EssentialInfoSection). # ★ 가요·일력·인물·연표·엽서는 여기 넣지 않는다. 이 표의 항목은 전부 켜서 나가는데 # (`_sections`), 그것들은 '지역 이야기'(story) 탭 **안에서** 그려지는 것이라 # 켜면 탭 밖에 한 번 더 선다. story 하나만 두면 데이터가 있는 것만 탭이 된다. # ★ 퀴즈(quiz)도 넣지 않지만 사정이 다르다 — 탭이 아니라 **독립 섹션**이라 # ([+ 섹션 추가] 의 '뒤집어 보는 질문'), 켜지 않으면 지역 생성분이 어디에도 안 선다. # 기본으로 켜지 않는 것은 의도다: 손님이 예약하러 온 화면에 퀴즈를 기본값으로 # 세우지 않는다. 넣고 싶은 사장님이 직접 넣는다. "sections": [ ("hero", "히어로", True), ("intro", "소개", False), ("rooms", "객실 안내", False), ("event", "소식", False), ("info", "기본 정보", True), ("booking", "예약 안내", False), ("video", "영상", False), ("photos", "사진 갤러리", False), ("map", "오시는 길", True), ("festival", "계절별 축제", False), ("local", "지역 정보", False), ("itinerary", "추천 일정", False), ("story", "지역 이야기", False), ("faq", "자주 묻는 질문", False), ("weather", "날씨", False), ], }, PlaceCategory.CAFE.value: { "templateId": "cafe-modern-espresso", "fontStyle": "Sleek Roast", "colors": {"primary": "#1c1917", "secondary": "#78716c", "bg": "#ffffff", "card": "#fafaf9", "text": "#0c0a09", "accent": "#b45309"}, "sections": [ ("hero", "히어로", True), ("intro", "소개", False), ("menu", "시그니처 메뉴", False), ("info", "기본 정보", True), ("space", "공간 · 좌석 안내", False), ("photos", "사진 갤러리", False), ("inquiry", "대관 및 단체 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False), ("local", "주변 나들이", False), ("faq", "자주 묻는 질문", False), ], }, PlaceCategory.RESTAURANT.value: { "templateId": "rest-neat-table", "fontStyle": "Sophisticated Table", "colors": {"primary": "#1c1917", "secondary": "#57534e", "bg": "#ffffff", "card": "#fafaf9", "text": "#0c0a09", "accent": "#b45309"}, "sections": [ ("hero", "히어로", True), ("intro", "소개", False), ("menu", "코스 및 메뉴", False), ("info", "기본 정보", True), ("booking", "예약 · 포장 안내", False), ("photos", "사진 갤러리", False), ("inquiry", "단체 행사 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False), ("local", "주변 안내", False), ("faq", "자주 묻는 질문", False), ], }, PlaceCategory.CLINIC.value: { "templateId": "clinic-visual-clinic", "fontStyle": "Visual Journey", "colors": {"primary": "#0f172a", "secondary": "#475569", "bg": "#ffffff", "card": "#f8fafc", "text": "#020617", "accent": "#4A9DC4"}, "sections": [ ("hero", "히어로", True), ("intro", "병원 소개", False), ("programs", "시술 안내", False), ("info", "기본 정보", True), ("exhibition", "진료 안내", False), ("photos", "사진 갤러리", False), ("inquiry", "상담 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False), ("local", "주변 정보", False), ("faq", "자주 묻는 질문", False), ], }, } # ── 값 변환 헬퍼 ────────────────────────────────────────────────────────── def _first_sentence(text: str) -> str: """첫 문장. 마침표가 없으면 통째로 돌려준다. ★ 왜 필요한가: 히어로 아래 한 줄과 meta description 이 같은 값을 쓴다. 둘 다 **한 문장** 자리라, 문단이 들어가면 히어로는 세 줄로 부풀고 검색 결과에서는 뒤가 잘린다. ★ 마침표 뒤에 공백이 없어도 자른다("…입니다.일본식" 같은 생성물이 실제로 온다). 다만 숫자 사이의 점(1.5km)은 자르지 않는다 — 뒤가 숫자면 문장 끝이 아니다. """ import re as _re match = _re.search(r"[.!?](?![0-9])", text or "") return text[: match.end()].strip() if match else (text or "").strip() def _get(row, key, default=None): """ORM 행이든 dict 든 같은 방식으로 읽는다(테스트가 가짜 행을 넣기 쉬우라고).""" if isinstance(row, dict): return row.get(key, default) return getattr(row, key, default) def _text(value) -> str: return str(value).strip() if value is not None else "" def _iso(value) -> str | None: """ISO8601 문자열. ★ 화면·JSON-LD 의 dateModified 로 나가는 값이라 시간대를 명시한다. DB 의 timestamptz 는 여기서 naive UTC 로 올라오므로(GTime 규약) UTC 를 붙인다. naive 로 내보내면 렌더러·검색엔진이 로컬시각으로 오해한다.""" if value is None: return None if isinstance(value, str): return value if isinstance(value, datetime): if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.isoformat() return str(value) def slugify(text: str) -> str: """solution/shared 의 toSlug 와 같은 규칙(로마자 음차하지 않는다). ★ 한글을 음차하면 같은 가게가 두 주소를 갖는다. 한글은 그대로 두고 퍼센트 인코딩에 맡긴다. NFC 로 정규화하는 이유: macOS 가 준 NFD 문자열이 그대로 디렉토리명이 되면 같은 이름이 서로 다른 경로로 갈린다.""" normalized = unicodedata.normalize("NFC", str(text or "")).strip().lower() out = [] for ch in normalized: if ch.isalnum() or ch == "-": out.append(ch) elif ch.isspace() or ch == "_": out.append("-") # 나머지(쉼표·괄호·따옴표…)는 버린다. slug = "".join(out) while "--" in slug: slug = slug.replace("--", "-") return slug.strip("-") # ── 주소 구성요소 ────────────────────────────────────────────── # 시·도 이름 → ISO 3166-2:KR 코드. `geo.region` 메타에 쓰인다(네이버·다음이 읽는 자리). # 약칭과 정식명을 둘 다 적는다 — 외부 장소 API 가 둘을 섞어 준다("경기" / "경기도"). _SIDO_ISO = { "서울": "KR-11", "서울특별시": "KR-11", "부산": "KR-26", "부산광역시": "KR-26", "대구": "KR-27", "대구광역시": "KR-27", "인천": "KR-28", "인천광역시": "KR-28", "광주": "KR-29", "광주광역시": "KR-29", "대전": "KR-30", "대전광역시": "KR-30", "울산": "KR-31", "울산광역시": "KR-31", "세종": "KR-50", "세종시": "KR-50", "세종특별자치시": "KR-50", "경기": "KR-41", "경기도": "KR-41", "강원": "KR-42", "강원도": "KR-42", "강원특별자치도": "KR-42", "충북": "KR-43", "충청북도": "KR-43", "충남": "KR-44", "충청남도": "KR-44", "전북": "KR-45", "전라북도": "KR-45", "전북특별자치도": "KR-45", "전남": "KR-46", "전라남도": "KR-46", "경북": "KR-47", "경상북도": "KR-47", "경남": "KR-48", "경상남도": "KR-48", "제주": "KR-49", "제주도": "KR-49", "제주특별자치도": "KR-49", } def _parse_address_parts(*addresses: str | None) -> dict: """주소 문자열에서 시·도 / 시·군·구 / 읍·면 을 뽑는다. ★ **원문에 있는 조각을 그대로만 쓴다.** "경기" 를 "경기도" 로 펴지 않는다. addressRegion·addressLocality 는 JSON-LD 로 나가고, JSON-LD 는 화면 텍스트와 대조된다(solution/site/src/seo/verify.ts). 화면에는 원문 주소가 그대로 찍히므로 정식명으로 펴는 순간 '화면에 없는 값'이 되어 발행 게이트가 막는다. 정규화가 필요한 곳은 ISO 코드 하나뿐이고, 그건 화면 대조 대상이 아니다. ★ 첫 토큰이 아는 시·도가 아니면 **아무것도 돌려주지 않는다** — 추측해서 채우지 않는다. 도로명 주소를 먼저 보고, 못 읽으면 지번 주소로 넘어간다. """ for raw in addresses: tokens = str(raw or "").split() if len(tokens) < 2 or tokens[0] not in _SIDO_ISO: continue region = tokens[0] rest = tokens[1:] # 시·군 → (있으면) 그 아래 구. 광역시·특별시는 구가 바로 온다. locality: list[str] = [] i = 0 while i < len(rest): token = rest[i] if not locality and token.endswith(("시", "군")): locality.append(token) elif token.endswith("구") and (not locality or locality[-1].endswith("시")): locality.append(token) else: break i += 1 # 읍·면 — "애월 스테이" 처럼 실제 검색어가 되는 자리라 따로 들고 있는다. sub = rest[i] if i < len(rest) and rest[i].endswith(("읍", "면")) else None return { "addressRegion": region, "addressLocality": " ".join(locality) or None, "addressSubLocality": sub, "addressRegionCode": _SIDO_ISO[region], } return {} def _fact_entry(spec, row: dict) -> dict: """스냅샷 fact 1건 → FactEntry. label/type/unit/critical/required 는 업종 스키마가 유일한 소스다 — 스냅샷에 라벨이 박제돼 있어도 스키마가 있으면 스키마를 따른다(라벨 오탈자 수정이 재빌드로 반영된다).""" key = row.get("key") return { "key": key, "label": (spec.label if spec else row.get("label") or key), "value": row.get("value"), "unit": row.get("unit") or (spec.unit if spec else None), "type": (spec.type if spec else "text"), "scope": row.get("scope") or (spec.scope if spec else "place"), # ★ status 를 그대로 싣는다. 렌더러가 selectPublishable() 로 한 번 더 거른다(2중 방어). "status": row.get("status") or FactStatus.VERIFIED.value, # facts.source_type 은 NOT NULL 이라 여기 기본값은 옛 스냅샷용 안전망이다. "sourceType": row.get("source_type") or SourceType.OWNER.value, "sourceUrl": row.get("source_url"), "critical": bool(spec.critical) if spec else False, "required": bool(spec.required) if spec else False, "unitId": row.get("unit_id"), "collectedAt": row.get("collected_at"), "verifiedAt": row.get("verified_at"), } def _theme(site, theme_spec: dict) -> dict: """SiteTheme — 저장된 디자인이 이기고, 없는 것만 업종 기본으로 떨어진다. ★ 저장값 우선이 이 함수의 존재 이유다. 예전에는 이 자리가 업종 기본 표를 그대로 굽고 enabled 를 True 로 박아 넣었다 — 사장님이 섹션을 끄고 순서를 바꿔도 발행본은 언제나 업종 기본 모양이었다. 저장할 자리(sites.theme)가 생겼으니 여기서 읽는다. ★ 서버는 값을 해석하지 않는다. 섹션 id 도 배리에이션 키도 색 토큰도 프론트가 소유하므로 모르는 값이 와도 그대로 싣는다 — 렌더러가 모르는 키를 만나면 자기 기본으로 떨어진다. ★ 딱 하나 서버가 우기는 것이 locked 다. 아래 _sections 주석 참조.""" saved = _get(site, "theme") if not isinstance(saved, dict): saved = {} # 색: 저장값이 이기되 **업종 기본 위에 덮는다**. # ★ 렌더러 타입(SiteTheme.colors)은 6개 키를 모두 요구한다. 저장값이 일부만 담고 있을 때 # 그것만 실으면 나머지 색이 undefined 로 나가 화면이 깨진다 — 빠진 자리는 업종 기본이 메운다. colors = dict(theme_spec["colors"]) for key, value in (saved.get("colors") or {}).items(): if isinstance(value, str) and value.strip(): colors[key] = value.strip() font_style = _text(saved.get("fontStyle")) or theme_spec["fontStyle"] # ★ colorPaletteId 는 여기 싣지 않는다. 에디터 복원 전용 값이고 발행 계약(SiteTheme)에 없다 — # 계약에 없는 필드를 payload 에 흘리면 렌더러가 모르는 것이 발행본에 섞인다. out = { # 저장된 템플릿이 있으면 그것으로 굽는다(sites.template_id). 비어 있으면 업종 기본이다. # 여기서 안 읽으면 사장님이 고른 디자인과 실제 발행본이 갈린다(그게 이 컬럼이 생긴 이유다). "templateId": _text(_get(site, "template_id")) or theme_spec["templateId"], "colors": colors, "fontStyle": font_style, "sections": _sections(saved.get("sections"), theme_spec["sections"]), } # ★ 템플릿의 생김새(서체·모서리·테두리·그림자·여백). 색과 달리 업종 기본이 없다 — # 프론트가 소유하는 값이라 서버가 지어낼 수 없고, 없으면 렌더러가 자기 기본 서체로 떨어진다. # 이걸 안 실으면 발행본은 색만 템플릿을 따르고 서체는 늘 같은 것으로 나간다. # 저장된 look 이 있으면 그게 이긴다. 다만 **덮어쓰기가 아니라 덧칠이다** — 프론트가 # 일부 키만 보낸 옛 저장값에 빈칸이 생기면 그 칸만 명조·렌더러 기본값으로 떨어진다. look = saved.get("look") cleaned = ( {k: v for k, v in look.items() if isinstance(v, str) and v.strip()} if isinstance(look, dict) else {} ) # 업종 기본 look 이 있으면 그것을 바닥에 깐다(숙박 = 옛 항구). 없으면 공통 폴백이다. out["look"] = {**theme_spec.get("look", _DEFAULT_LOOK), **cleaned} return out def _sections(saved_sections, default_spec) -> list: """섹션 목록 — 저장된 **배열 순서**가 곧 발행본의 섹션 순서다. ★ locked 는 서버가 우긴다. 잠긴 섹션(히어로·기본 정보·오시는 길)은 SEO·필수 마크업 때문에 잠긴 것이라, 저장값이 껐다고 해도 켜서 내보낸다. 그리고 잠금 판정은 **업종 기본 표**가 하고 저장값의 locked 는 잠그는 방향으로만 더한다 — 저장값의 locked:false 를 그대로 믿으면 클라이언트가 locked 를 내려 보내는 것만으로 필수 섹션을 끌 수 있어 잠금 자체가 무의미해진다. ★ 저장값에 없는 기본 섹션은 **켜서** 목록 끝에 덧붙인다. 한때 잠기지 않은 섹션은 꺼서 붙였다 — "사장님이 목록에서 뺐다 = 안 쓰겠다는 뜻"이라고 봤다. 그 전제가 틀렸다. 에디터에는 섹션을 **빼는 기능이 없다**(toggleSection·reorderSection 뿐, admin/src/stores/builder.ts). 그러니 저장값에 없다는 건 "뺐다"가 아니라 **저장할 당시 그 섹션이 아직 없었다**는 뜻이다 — 우리가 나중에 추가한 섹션이다. 꺼서 붙이면 새 섹션은 기존 사업장에 영원히 나오지 않는다. 에디터에는 보이는데 발행본에는 없는 상태가 되고(실측: 날씨 섹션), 사장님은 켠 적도 끈 적도 없는 것이 안 나온다고 본다. 저장값이 아예 없을 때 전부 켜서 내보내는 것과 같은 규칙으로 맞춘다. ★ 통째로 버리지는 않는다 — 렌더러가 섹션 이름을 알아야 에디터에서 껐을 때 같은 이름으로 붙고, 무엇이 꺼져 있는지도 payload 만 보고 알 수 있다. ★ 저장값에만 있고 업종 기본에 없는 섹션(프론트가 새로 추가한 것)은 그대로 싣는다. 섹션 목록은 프론트가 소유한다 — 서버가 모른다고 버리면 새 섹션이 발행되지 않는다.""" defaults = {sid: (label, locked) for sid, label, locked in default_spec} # 저장값이 없으면(아직 아무것도 고르지 않았다) 업종 기본을 전부 켜서 내보낸다. if not isinstance(saved_sections, list) or not saved_sections: return [ {"id": sid, "name": label, "enabled": True, "locked": locked} for sid, label, locked in default_spec ] out = [] used = set() for item in saved_sections: if not isinstance(item, dict): continue sid = _text(item.get("id")) if not sid or sid in used: continue used.add(sid) default_label, default_locked = defaults.get(sid, ("", False)) # 서버가 아는 잠금(업종 기본)이 항상 이긴다. 저장값은 잠그는 방향으로만 보탠다. locked = bool(default_locked) or bool(item.get("locked")) entry = { "id": sid, # 사장님이 붙인 제목이 있으면 그게 발행본의 소제목이다. 없으면 업종 기본 이름, 그것도 없으면 id. "name": _text(item.get("name")) or default_label or sid, # ★ 잠긴 섹션은 꺼진 채로 나갈 수 없다. "enabled": bool(item.get("enabled", True)) or locked, "locked": locked, } # ★ 고른 배리에이션이 있을 때만 키를 붙인다. 서버는 이 값을 해석하지 않는다 — # 비어 있으면 렌더러가 그 섹션의 기본 레이아웃으로 떨어진다(null 을 실으면 타입이 안 맞는다). variant_id = _text(item.get("variantId")) if variant_id: entry["variantId"] = variant_id # ★ 사장님이 에디터에 직접 쓴 섹션 본문. variantId 와 같은 이유로 그대로 싣는다 — # 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서 통째로 버려졌다. # 저장(sites.theme)은 되는데 발행본에는 안 나오고, 고유 콘텐츠로도 세지 않아 # "소개를 썼는데 발행이 고유 콘텐츠 0건으로 막힌다" 가 됐다. # ★ fact 가 아니라 검증 대상이 아니다. 사장님이 자기 가게에 대해 쓴 자기 문장이고, # 섹션 제목(name)이 이미 같은 경로로 나간다. body = _text(item.get("body")) if body: entry["body"] = body # ★ 붙여넣기 아이템(가요·일력·승차권·인물…)의 원문 JSON. body 와 같은 이유로 그대로 싣는다. # 서버는 파싱하지 않는다 — 모양을 검사하면 프론트가 필드를 하나 늘린 날 조용히 떨어뜨린다. # 깨진 JSON 은 렌더러가 그 섹션만 비우고 넘어간다(shared/lib/section-data.ts). data = _text(item.get("data")) if data: entry["data"] = data out.append(entry) for sid, label, locked in default_spec: if sid in used: continue # ★ 켜서 붙인다. 저장값에 없는 건 사장님이 뺀 게 아니라 저장 당시 없던 섹션이다(위 주석). out.append({"id": sid, "name": label, "enabled": True, "locked": bool(locked)}) return out # WMO weather code → 한 줄 날씨. ★ solution/site/src/lib/use-live-weather.ts 의 condition() 과 # **같은 구간**이어야 한다. 렌더러는 프리렌더된 이 값으로 그리다가 하이드레이션 뒤 최신 캐시로 덮어쓰는데, # 두 곳이 다른 표를 쓰면 같은 날씨인데 화면 문구가 바뀐다(사장님 눈에는 버그로 보인다). def _weather_condition(code) -> str: try: value = int(code) except (TypeError, ValueError): return "흐림" if value == 0: return "맑음" if 1 <= value <= 3: return "구름많음" if 50 <= value <= 69: return "비" if 70 <= value <= 79: return "눈" return "흐림" def _weather(row: dict): """WeatherSnapshot. 값이 없으면 None — 없는 날씨를 지어내지 않는다.""" body = row.get("body") or {} temperature = _as_float(body.get("temperature")) observed_at = _text(body.get("observed_at")) if temperature is None or not observed_at: return None return { "temperature": temperature, "condition": _weather_condition(body.get("weather_code")), # note 는 LLM 이 쓰는 안내 문구다. 지금 그걸 만드는 경로가 없으므로 넣지 않는다(지어내지 않는다). "observedAt": observed_at, } def _yyyymmdd(value) -> str: digits = "".join(ch for ch in _text(value) if ch.isdigit()) return digits if len(digits) == 8 else "" # 시작 월 → 계절. 경계는 기상학 기준(3·6·9·12월 시작)이다 — 축제는 "몇 월에 가나"로 찾는다. _SEASON_BY_MONTH = { 3: "봄", 4: "봄", 5: "봄", 6: "여름", 7: "여름", 8: "여름", 9: "가을", 10: "가을", 11: "가을", 12: "겨울", 1: "겨울", 2: "겨울", } def _festival(row: dict): """FestivalEntry. 이름이 없으면 버린다 — 이름 없는 행사는 화면에 걸 수 없다.""" body = row.get("body") or {} name = _text(body.get("name")) or _text(row.get("title")) if not name: return None start, end = _yyyymmdd(body.get("eventstartdate")), _yyyymmdd(body.get("eventenddate")) # month 는 화면의 배지다(예: "10월"). 시작일이 없으면 만들지 않는다. month = f"{int(start[4:6])}월" if start else "" if start and end and start != end: period = f"{start[:4]}.{start[4:6]}.{start[6:]} ~ {end[:4]}.{end[4:6]}.{end[6:]}" elif start: period = f"{start[:4]}.{start[4:6]}.{start[6:]}" else: period = "" homepage = _text(body.get("homepage")) entry = { "name": name, "month": month, # ★ searchQuery 만 있고 우리가 URL 을 지어내지 않는다 — 틀린 링크는 방문자를 엉뚱한 데로 보내고 # 그 책임을 이 홈페이지가 진다(렌더러 LocalGuideSection 주석과 같은 규칙). "searchQuery": name, } if start: # ★ 계절은 **여기서 한 번만** 정한다 (실측 2026-09-10) # `FestivalEntry.season` 계약이 "시작일에서 한 번만 정해 payload 에 싣는다" 인데 # 아무도 안 실었다 — 그 결과 발행된 모든 사이트에서 계절 탭이 0개였다(군산·성남 모두 # 20건 전부 빈 값). 화면은 계절이 있는 것만 탭으로 세우므로, 축제가 스무 건 있어도 # "계절 없이 열리는 행사" 한 덩이로 쏟아졌다. `/s/stay` 시안에 4탭이 서 있는 건 # 그 payload 의 계절을 손으로 넣었기 때문이다. # 렌더러에서 월을 계절로 되돌리지 않는다 — 계약 주석이 금지한 자리다(수집과 갈라진다). entry["season"] = _SEASON_BY_MONTH[int(start[4:6])] # 정렬·계절 산출의 근거를 기계가 읽는 형식으로도 남긴다(`period` 는 사람이 읽는 문구다). entry["startDate"] = f"{start[:4]}-{start[4:6]}-{start[6:]}" if period: entry["period"] = period location = _text(body.get("location")) if location: entry["location"] = location description = _text(body.get("overview")) if description: entry["description"] = description # 공식 홈페이지는 출처가 준 값일 때만 싣는다. 형식이 URL 이 아니면 링크로 걸지 않는다. if homepage.startswith("http://") or homepage.startswith("https://"): entry["officialUrl"] = homepage # 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와 # 같은 모양으로 그리려면 필요하다(2026-09-07, 도보 시간 필터 형식 결정). _put_distance(entry, body) image = _text(body.get("imageUrl")) if image: entry["imageUrl"] = image return entry def _local_place(row: dict, category: str): """LocalPlace. ★ 2026-09-09 부터 `body` 가 **이미 렌더러 모양**이다(`name`·`location`·`imageUrl`) — 수집 시점에 바꿔 넣는다(`external/tour_api._normalize`). 예전에는 TourAPI 원문 이름을 저장하고 빌드마다 여기서 바꿔 실었다. 같은 변환을 발행할 때마다 다시 하는 셈이었고, 캔버스와 발행본이 각자 바꾸면 갈릴 자리였다. 그래서 여기가 하는 일은 둘뿐이다 — 업종 라벨을 붙이고, 사이트별 거리를 표기로 바꾼다. """ body = row.get("body") or {} name = _text(body.get("name")) or _text(row.get("title")) if not name: return None entry = {"name": name, "category": category, "searchQuery": _text(body.get("searchQuery")) or name} for key in ("location", "imageUrl"): value = _text(body.get(key)) if value: entry[key] = value # 수집한 소개를 버리면 stay 목업과 달리 실제 발행 카드에는 이름만 남는다. description = _text(body.get("description")) or _text(body.get("overview")) if description: entry["description"] = description _put_distance(entry, body) return entry def _put_distance(entry: dict, body: dict) -> None: """distanceMeters → distanceText("850m") + distanceMeters(850). 값이 없거나 음수면 둘 다 넣지 않는다.""" # ★ 원값은 사이트 개인화(site_sections.data.places[].distanceMeters)에서 온다 — # 공용 실체에는 거리가 없다(업장마다 다르다). 스냅샷이 그 값을 body 에 얹어 준다. meters = body.get("distanceMeters") distance = _distance_text(meters) if not distance: return entry["distanceText"] = distance entry["distanceMeters"] = int(meters) def _distance_text(meters) -> str: """850 → "850m", 1234 → "1.2km". 없으면 빈 문자열.""" try: m = int(meters) except (TypeError, ValueError): return "" if m < 0: return "" if m < 1000: return f"{m}m" # 반올림은 '5 는 올림'으로 — f"{1.45:.1f}" 는 부동소수 탓에 1.4 가 나온다. return f"{(m + 50) // 100 / 10:.1f}km" def _local(snapshot_local: dict, base_lat: float | None, base_lng: float | None) -> tuple[dict, str | None]: """스냅샷의 지역 정보 → LocalContents. ★ 스냅샷이 이미 걸렀다(PUBLISHED + 노출 기간 안). 여기서 더 거르지 않고 모양만 바꾼다 — fact·사진과 같은 분업이다. ★ 예전에는 이 자리가 무조건 빈 배열이었다. local_contents 에 검수·발행된 지역 정보가 있어도 payload 경계에서 통째로 버려져, 모든 발행 사이트의 지역 정보 섹션이 영구히 안 나왔다. ★ itineraries(1박2일·2박3일 각 5개)는 **스냅샷에서 읽는다.** 예전에는 이 자리에서 거리 기반으로 즉석 계산했다(services/itinerary.py) — 거리 계산은 공짜라 그게 맞았다. LLM 생성으로 바뀌면서 건당 20~50초·유료가 되어 표에 저장하고 그걸 읽는다 (services/itinerary_llm_service · tmp/superpowers/specs/2026-09-11-llm-itinerary-design.md). ★ 여기서 DB 를 읽지 않는다. 읽는 곳은 services/snapshot._local_contents 하나다 — 이 함수가 순수해야 "스냅샷과 다른 페이지"가 생기지 않는다(파일 상단 원칙). ★ services/itinerary.py 는 지우지 않았다. 고도화해서 되살릴 때 이 블록을 되돌린다.""" contents = (snapshot_local or {}).get("contents") or [] # courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다(2026-09-07). local = {"attractions": [], "restaurants": [], "festivals": [], "courses": []} synced_at = None for row in contents: if not isinstance(row, dict): continue collected_at = _text(row.get("collected_at")) # syncedAt 은 화면에 "○○ 갱신"으로 그대로 노출된다 — 가장 최근 수집 시각을 쓴다. # ★ 오래된 정보를 숨기지 않는다(렌더러 타입 주석). 그래서 최신값이 아니라 '실제 최신 수집 시각'이다. if collected_at and (synced_at is None or collected_at > synced_at): synced_at = collected_at content_type = row.get("content_type") if content_type == LocalContentType.WEATHER.value: weather = _weather(row) # 지역당 1행이지만 방어적으로 첫 유효값만 쓴다. if weather and "weather" not in local: local["weather"] = weather elif content_type == LocalContentType.FESTIVAL.value: entry = _festival(row) if entry: local["festivals"].append(entry) elif content_type == LocalContentType.ATTRACTION.value: entry = _local_place(row, "관광지") if entry: local["attractions"].append(entry) elif content_type == LocalContentType.RESTAURANT.value: entry = _local_place(row, "맛집") if entry: local["restaurants"].append(entry) elif content_type == LocalContentType.COURSE.value: entry = _local_place(row, "여행코스") if entry: local["courses"].append(entry) elif content_type == LocalContentType.STORY.value: # ★ 지역 이야기는 **모양을 바꾸지 않는다.** body 가 이미 렌더러 계약 # (`shared/lib/section-data.ts` 의 SongItem·PeopleItem…) 그대로다. # 여기서 키를 손대면 사장님이 손으로 붙여넣은 같은 종류의 JSON 과 모양이 갈린다 — # 화면은 둘을 한 배열로 이어 그린다. kind = _text(row.get("kind")) items = (row.get("body") or {}).get("items") if kind and isinstance(items, list) and items: local.setdefault("story", {})[kind] = [i for i in items if isinstance(i, dict)] # 그 밖의 content_type 은 버린다 — 렌더러 타입에 담을 자리가 없다. itineraries = [ course for course in ((snapshot_local or {}).get("itineraries") or []) if isinstance(course, dict) ] if itineraries: local["itineraries"] = itineraries return local, synced_at def _publish_target(site, place_id: str, name: str) -> dict: """발행 주소(origin/basePath/slug). ★ sites.domain 이 있으면 그게 사장님이 고른 주소다. 없으면 **임시값**이다 — 상호명은 유일하지 않으므로 place_id 앞자리를 붙여 사이트끼리 겹치지 않게 한다. ★ **언제나 경로형**(`https:///s/`)이다. 서브도메인을 쓰지 않는 이유는 사이트가 하나 늘 때마다 DNS 레코드와 TLS 인증서를 새로 만들어야 해서다 — 발행 시점에 그걸 대신 만들어 줄 방법이 없으니 서브도메인 주소는 화면에만 있고 실제로는 열리지 않는다. shared/lib/slug.ts 의 publishUrl 과 **같은 규칙**이어야 화면이 보여준 주소와 발행본의 주소가 갈리지 않는다.""" domain = _text(_get(site, "domain")) if domain: # domain 컬럼에는 slug 만 들어온다(site_slug 가 검증한 값). 옛 데이터가 호스트 형태로 # 남아 있을 수 있어 첫 라벨만 취한다. slug = slugify(domain.split(".")[0]) or slugify(name) or place_id else: name_slug = slugify(name) slug = f"{name_slug}-{place_id[:8]}" if name_slug else f"place-{place_id[:8]}" return {"origin": f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}", "basePath": f"/s/{slug}", "slug": slug} def publish_slug(place, site) -> str: """이 사업장 사이트의 발행 슬러그. ★ 렌더 보고서(payloads/.status/.json)를 찾으려면 payload 를 만들 때와 **같은 규칙**으로 슬러그를 구해야 한다. 그래서 여기 한 곳에서만 계산하고 밖에서는 이 함수를 부른다 — 규칙을 두 군데 두면 보고서를 못 찾아 "아직 안 구워졌다"고 잘못 답하게 된다.""" return _publish_target(site, str(_get(place, "place_id") or ""), _text(_get(place, "name")))["slug"] def publish_origin() -> str: """발행본이 사는 오리진. 썸네일 URL 도 여기서 나온다 — 호스트를 새 env 로 또 두면 canonical 과 갈릴 수 있다(CLAUDE.md '발행 호스트는 두 곳').""" return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}" def primary_media(snapshot: dict) -> dict | None: """대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None. ★ 썸네일도 이 함수를 쓴다. 규칙을 복제하면 검색 결과에 뜨는 그림과 쇼케이스 카드가 다른 사진이 되고, 그건 아무도 눈치채지 못한다.""" for row in (snapshot or {}).get("media") or []: if not row.get("unit_id"): return row return None def region_label(*addresses: str | None) -> str | None: """"강원특별자치도 양양군" — 시·도 + 시·군·구까지만. ★ 상세 주소는 붙이지 않는다. 로그인 없이 읽히는 목록(쇼케이스)에 쓰이므로 '어느 동네인지' 를 넘어서면 안 된다.""" parts = _parse_address_parts(*addresses) label = " ".join(p for p in (parts.get("addressRegion"), parts.get("addressLocality")) if p) return label or None # ── payload 조립 ────────────────────────────────────────────────────────── def to_site_payload(place, snapshot: dict, site, version, links) -> dict: """스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict). 순수 변환 함수다. DB 도 파일도 건드리지 않는다 — 그래야 테스트가 쉽고, 같은 입력이면 언제나 같은 payload 가 나온다.""" snapshot = snapshot or {} snap_place = snapshot.get("place") or {} place_id = str(_get(place, "place_id")) category = int(snap_place.get("category") or _get(place, "category")) schema = get_schema(category) name = _text(snap_place.get("name") or _get(place, "name")) target = _publish_target(site, place_id, name) # ── fact ───────────────────────────────────────────── all_facts = [_fact_entry(schema.get(r.get("key")), r) for r in (snapshot.get("facts") or [])] place_facts = [f for f in all_facts if not f["unitId"]] unit_facts: dict[str, list] = {} for entry in all_facts: if entry["unitId"]: unit_facts.setdefault(str(entry["unitId"]), []).append(entry) # ── 사진 ───────────────────────────────────────────── # ★ 스냅샷이 이미 걸렀다(APPROVED + alt 있음). 여기서 더 거르지 않고 모양만 바꾼다. # ★ sourceType/originUrl 을 반드시 싣는다 — 크롤링 이미지 재게시 권리가 미결이라(DECISIONS 1-2) # 결론이 나면 출처로 걸러낼 수 있어야 한다. 출처를 버리면 그때 다시 수집해야 한다. media = [] primary_row = primary_media(snapshot) for index, row in enumerate(snapshot.get("media") or []): unit_id = row.get("unit_id") is_primary = row is primary_row media.append({ "mediaId": str(row.get("media_id") or f"m-{index}"), "url": row.get("url"), "alt": _text(row.get("alt_text")), "category": row.get("label"), "width": row.get("width"), "height": row.get("height"), "isPrimary": is_primary, # 출처가 없는 건 옛 스냅샷뿐이다(media.source_type 은 NOT NULL). 그때는 CRAWL 로 본다 — # 재게시 권리가 결론 나면 걸러져야 할 쪽으로 기울이는 게 안전하다. "sourceType": row.get("source_type") or SourceType.CRAWL.value, "originUrl": row.get("origin_url"), "unitId": unit_id, }) media_by_unit: dict[str, list] = {} for item in media: if item["unitId"]: media_by_unit.setdefault(str(item["unitId"]), []).append(item["mediaId"]) # ── 하위 단위(객실·메뉴·프로그램) ───────────────────── units = [] for index, row in enumerate(snapshot.get("units") or []): unit_id = str(row.get("unit_id")) unit_name = _text(row.get("name")) units.append({ "unitId": unit_id, "name": unit_name, # slug 가 URL 이 된다. 이름이 비거나 기호뿐이면 순번으로 떨어뜨린다(빈 경로를 만들지 않는다). "slug": slugify(unit_name) or f"unit-{index + 1}", "sortOrder": int(row.get("sort_order") or index), "facts": unit_facts.get(unit_id, []), "mediaIds": media_by_unit.get(unit_id, []), }) # ── FAQ ────────────────────────────────────────────── faqs = [] for index, row in enumerate(snapshot.get("faqs") or []): faqs.append({ "faqId": str(row.get("faq_id") or f"faq-{index + 1}"), "question": _text(row.get("question")), "answer": _text(row.get("answer")), "status": row.get("status") or FactStatus.VERIFIED.value, "sourceType": row.get("generated_by") or SourceType.LLM.value, "sortOrder": int(row.get("sort_order") or index), }) # ── 채널 링크 ──────────────────────────────────────── # 확정(confirmed_at)되지 않은 링크도 실어 보낸다 — 렌더러가 confirmed 로 한 번 더 거른다. # 여기서 미리 빼면 "왜 안 나오는지"가 payload 만 봐서는 안 보인다. channel_links = [] for row in links or []: channel = _get(row, "channel") url = _text(_get(row, "url")) if not url or channel is None: continue channel_links.append({ "channel": int(channel), "url": url, "title": _text(_get(row, "title")) or _CHANNEL_TITLE.get(int(channel), "채널"), "confirmed": _get(row, "confirmed_at") is not None, **({"stayGuide": nol_stay_guide(url, _get(row, "raw"))} if category == PlaceCategory.LODGING.value and _get(row, "confirmed_at") is not None and int(channel) == LinkChannel.YANOLJA.value else {}), }) # ── 소개문 ─────────────────────────────────────────── # ★ 여기서 문장을 지어내지 않는다. intro fact(allow_llm=true 필드)에 있는 것만 옮긴다. # heroHeadline·tagline 은 저장되는 자리가 없어서 비운다 — 비면 렌더러가 상호명으로 대체한다. intro = _text(next((f["value"] for f in place_facts if f["key"] == "intro" and f["value"]), "")) paragraphs = [p.strip() for p in intro.split("\n") if p.strip()] if intro else [] narrative = { "about": paragraphs, # ★ 요약은 **첫 문장**이다. 문단이 아니다. # 계약이 "요약 한 문장"이라 적어 뒀는데(shared/site-payload.ts) 첫 문단을 통째로 # 넣고 있었다. 그 값은 두 곳으로 나간다 — 히어로 아래 한 줄과 meta description. # 문단이 들어가면 히어로가 세 문장을 이고 서고(실측 2026-09-10), meta description 은 # 검색 결과에서 잘린다. 문장을 새로 생성하지는 않는다 — 있는 글의 첫 문장을 뗄 뿐이다. "summary": _first_sentence(paragraphs[0]) if paragraphs else None, } theme_spec = _DEFAULT_THEME.get(category) or _DEFAULT_THEME[PlaceCategory.LODGING.value] theme = _theme(site, theme_spec) # ── 지역 정보 ──────────────────────────────────────── # ★ 스냅샷에서 읽는다 — 여기서 DB 를 다시 읽으면 '스냅샷과 다른 페이지'가 나온다(파일 상단 원칙). # 지역 정보를 스냅샷에 담는 필터링은 services/snapshot._local_contents 가 한다. # 옛 스냅샷에는 "local" 키가 없다. 그때는 빈 채로 나가고, 다음 빌드에서 채워진다. local, local_synced_at = _local( snapshot.get("local") or {}, _as_float(snap_place.get("latitude")), _as_float(snap_place.get("longitude")), ) if local_synced_at: local["syncedAt"] = local_synced_at published_at = _iso(_get(site, "published_at")) updated_at = ( _iso(_get(version, "built_at")) or _iso(_get(place, "content_updated_at")) or published_at or _iso(datetime.now(timezone.utc)) ) external_source = _get(place, "external_source") kakao_place_id = _text(_get(place, "external_place_id")) if external_source == 1 else "" return { "schemaVersion": SCHEMA_VERSION, "site": { "siteId": str(_get(site, "site_id") or ""), "placeId": place_id, "status": int(_get(site, "status") or SiteStatus.DRAFT.value), "origin": target["origin"], "basePath": target["basePath"], "slug": target["slug"], "publishedAt": published_at or updated_at, "updatedAt": updated_at, "version": int(_get(version, "version") or 1), }, "place": { "name": name, "category": category, "roadAddress": snap_place.get("road_address") or None, "address": snap_place.get("address") or None, # 시·도 / 시·군·구 / 읍·면. PostalAddress 의 지역 필드와 title·geo 메타가 이걸 쓴다 — # 없으면 지역 질의("성남 소금빵")에 걸릴 자리를 통째로 버리게 된다. **_parse_address_parts(snap_place.get("road_address"), snap_place.get("address")), "phone": snap_place.get("phone") or None, # 스냅샷은 좌표를 문자열로 박제한다(Numeric 직렬화). 렌더러 타입은 number 라 여기서 되돌린다. "latitude": _as_float(snap_place.get("latitude")), "longitude": _as_float(snap_place.get("longitude")), # ★ 스냅샷이 실제로 쓴 지역 키가 이긴다. places.region_code 가 비어 있어도 # 스냅샷이 주소에서 유도했으면(services/snapshot._local_contents) 그 값이 여기 실려야 # 렌더러의 실시간 날씨 조회가 산다 — use-live-weather 는 regionCode 없이는 fetch 하지 않고, # 그러면 날씨 섹션이 통째로 사라진다(에디터에는 보이는데 사이트에는 없는 그 자리). "regionCode": ( _text((snapshot.get("local") or {}).get("region_code")) or _text(_get(place, "region_code")) or None ), "kakaoPlaceId": kakao_place_id or None, }, "facts": place_facts, "units": units, "media": media, "faqs": faqs, "links": channel_links, "local": local, # ★ 가는 길(routes)은 비어 있다. local.routes 테이블에 행이 0이고, 그 테이블에 쓰는 코드 경로가 # 아직 어디에도 없다(모델과 DDL 만 있고 수집기·입력 API 가 없다). 여기서 조회 배선을 만들어 봐야 # 영원히 빈 결과를 도는 코드가 되고, 실제 수집기가 붙는 날 그 모양에 맞을지도 알 수 없다. # ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석). # 렌더러는 비면 해당 섹션을 그리지 않는다. "routes": [], # ★ 이 숙소의 노래. `audioUrl` 은 **우리 쪽 경로**다 — Suno 가 준 주소는 만료되므로 # 파일을 받아 두고(song_service) 프리렌더가 사이트 디렉토리로 복사한 것을 가리킨다. # 경로를 여기서 만드는 이유: 발행본의 주소 규칙(basePath + /s/)을 아는 곳이 여기다. "songs": [ { "songId": row["song_id"], "title": row["title"], "lyrics": row.get("lyrics") or None, "style": row.get("style") or None, "durationSec": row.get("duration_sec"), # 프리렌더가 복사해 놓을 자리. 파일명은 그대로 쓴다. # basePath 자체가 이미 `/s/`(또는 서브패스 마운트라면 그 앞에 접두어)다. "audioUrl": f"{target['basePath']}/{row['file_name']}", # 프리렌더가 원본을 찾을 때 쓰는 이름(솔루션 밖으로는 안 나간다). "fileName": row["file_name"], } for row in (snapshot.get("songs") or []) if (row.get("file_name") or "").strip() ], "narrative": narrative, "theme": theme, # ★ 검색 키워드(SiteOntology). 스냅샷에 있을 때만 싣는다 — 옛 스냅샷·SiteOntology 가 꺼진 빌드에는 없고, # 그때 렌더러는 제목·메타를 예전 그대로 굽는다(solution/site/src/seo/meta.ts). **_seo_entry(snapshot.get("seo")), } def _seo_entry(value) -> dict: """스냅샷의 seo(services/seo_keywords) → payload 의 `seo`. 없으면 키 자체를 만들지 않는다. ★ 여기서 다시 거르지 않는다 — 이 가게 자료로 거르는 곳은 seo_keywords 한 곳이다. 모양만 확인한다. ★ 빈 seo 를 만들지 않는다. 빈 배열은 '받았는데 비었다' 로 읽힌다(itineraries 와 같은 규칙).""" if not isinstance(value, dict): return {} keywords = [_text(k) for k in value.get("keywords") or [] if _text(k)] title = _text(value.get("titleKeyword")) if not keywords and not title: return {} return {"seo": {"keywords": keywords, **({"titleKeyword": title} if title else {})}} def _as_float(value): try: return float(value) if value not in (None, "") else None except (TypeError, ValueError): return None # ── 파일 출력 ───────────────────────────────────────────────────────────── def payload_dir() -> Path: """payload 출력 디렉토리. 컨테이너 밖 볼륨을 붙이기 쉬우라고 env 로 뺀다.""" return Path(os.environ.get(PAYLOAD_DIR_ENV) or DEFAULT_PAYLOAD_DIR) def write_payload(payload: dict) -> str: """payload 를 `.json` 으로 쓴다. 경로를 돌려준다. ★ 임시파일에 쓰고 rename 한다 — 렌더러가 디렉토리를 통째로 읽는 구조라 반쯤 쓰인 JSON 을 집어 빌드가 깨지는 일이 없어야 한다(rename 은 같은 파일시스템에서 원자적).""" directory = payload_dir() directory.mkdir(parents=True, exist_ok=True) site = payload.get("site") or {} # slug 는 slugify 를 거쳐 경로 구분자가 남을 수 없지만, 파일명은 마지막 한 겹을 더 막는다. stem = os.path.basename(_text(site.get("slug")) or _text(site.get("placeId")) or "site") path = directory / f"{stem}.json" tmp = directory / f".{stem}.json.tmp" tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") tmp.replace(path) return str(path) async def prepare_site_payload(place, snapshot: dict, site, version, links) -> dict: """미리보기·발행 공통 보강. 요약은 DB 스냅샷이 아니라 응답/산출물에만 싣는다.""" payload = to_site_payload(place, snapshot, site, version, links) for fact in payload["facts"]: if fact["key"] != "intro" or fact["status"] not in {s.value for s in PUBLISHABLE_FACT_STATUSES}: continue try: summary = await summarize_intro(fact["key"], fact["value"]) except Exception as ex: # 부가 요약 실패 때문에 미리보기·발행까지 막히면 원문도 읽을 수 없게 된다. LOG.w(f"[payload] 소개 요약 실패(원문 사용): {type(ex).__name__}") continue if summary and summary.strip(): fact["summary"] = summary.strip() return payload async def emit_payload(place, snapshot: dict, site, version, links) -> str | None: """payload 조립 + 파일 쓰기. 실패해도 예외를 밖으로 내보내지 않는다. ★ 발행 자체를 실패시키면 안 된다 — 게이트를 통과해 DB 에 남은 발행 기록은 이미 정확하고, payload 는 그것을 화면으로 옮기는 부수 산출물이다. 디스크가 없거나 권한이 없어서 발행이 되돌려지는 게 더 나쁘다. 대신 경고 로그로 반드시 드러낸다.""" try: payload = await prepare_site_payload(place, snapshot, site, version, links) path = write_payload(payload) LOG.i( f"[payload] {payload['site']['slug']} → {path} " f"(fact {len(payload['facts'])} · 단위 {len(payload['units'])} · " f"사진 {len(payload['media'])} · FAQ {len(payload['faqs'])} · 링크 {len(payload['links'])})" ) return path except Exception as ex: # noqa: BLE001 — 어떤 이유로도 발행을 되돌리지 않는다 LOG.w(f"[payload] 생성 실패(발행은 그대로 진행): {type(ex).__name__}: {ex}") return None