공식채널 단일화, 한일옥 거리 반영 날씨 조건을 7종으로 세분화, 축제 종료 여부와 무관하게 상시 노출, '지역 읽기'갈래 축소, 야놀자(NOL) 브랜드명 제거.
1098 lines
60 KiB
Python
1098 lines
60 KiB
Python
"""발행 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
|
|
from services.weather_notes import weather_notes
|
|
|
|
# 렌더러가 확인하는 스키마 버전. 모양이 바뀌면 여기와 site-payload.ts 를 같이 올린다.
|
|
SCHEMA_VERSION = 1
|
|
|
|
# 출력 디렉토리. 컨테이너 밖(볼륨·오브젝트 스토리지)으로 빼기 쉬우라고 env 로 둔다.
|
|
# ★ 기본값이 `solution/site/payloads` 아래인 이유 — 렌더러(prerender.ts)가 songs·out 디렉토리를
|
|
# **자기 파일 위치 기준 상대경로**로 찾는다(SITE_ROOT = dist/prerender/../..). 워커가 그
|
|
# 렌더러를 subprocess 로 직접 띄우면서(render_service.py) 세 디렉토리(payloads·songs·out)가
|
|
# 그 렌더러가 실제로 설치된 자리(`/app/solution/site/`) 아래에 나란히 있어야 한다 —
|
|
# 어긋나면 워커는 payload 를 잘 쓰는데 렌더러는 다른 곳에서 songs 를 찾다가 못 찾는다.
|
|
PAYLOAD_DIR_ENV = "SITE_PAYLOAD_DIR"
|
|
DEFAULT_PAYLOAD_DIR = "/app/solution/site/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`)
|
|
# 이 키가 없으면 `<head>` 에 --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 value in (45, 48):
|
|
return "안개"
|
|
if value in (51, 53, 55):
|
|
return "이슬비"
|
|
if value in (56, 57, 66, 67):
|
|
return "어는비"
|
|
if value in (61, 63):
|
|
return "비"
|
|
if value == 65:
|
|
return "강한비"
|
|
if 70 <= value <= 79:
|
|
return "눈"
|
|
if value in (80, 81, 82, 85, 86):
|
|
return "소나기"
|
|
if 95 <= value <= 99:
|
|
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")),
|
|
**weather_notes(),
|
|
"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://<host>/s/<slug>`)이다. 서브도메인을 쓰지 않는 이유는
|
|
사이트가 하나 늘 때마다 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/<slug>.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, publish: bool = False) -> dict:
|
|
"""스냅샷 + 사이트/버전 행 + 채널 링크 → SitePayload(dict).
|
|
|
|
순수 변환 함수다. DB 도 파일도 건드리지 않는다 — 그래야 테스트가 쉽고,
|
|
같은 입력이면 언제나 같은 payload 가 나온다.
|
|
|
|
★ `publish` 는 렌더러(prerender.ts publishVersion)에게 "이 버전으로 공개 주소를
|
|
넘겨도 되는가"를 알리는 신호다. False(미리보기·게이트 통과 전 재빌드)면 렌더러가
|
|
`out/versions/<slug>/<version>/` 에만 굽고 `out/s/<slug>` 심볼릭 링크는 그대로 둔다."""
|
|
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),
|
|
"publish": bool(publish),
|
|
},
|
|
"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/<slug>)을 아는 곳이 여기다.
|
|
"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/<slug>`(또는 서브패스 마운트라면 그 앞에 접두어)다.
|
|
"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 를 `<slug>.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, publish: bool = False) -> dict:
|
|
"""미리보기·발행 공통 보강. 요약은 DB 스냅샷이 아니라 응답/산출물에만 싣는다."""
|
|
payload = to_site_payload(place, snapshot, site, version, links, publish)
|
|
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, publish: bool = False) -> str | None:
|
|
"""payload 조립 + 파일 쓰기. 실패해도 예외를 밖으로 내보내지 않는다.
|
|
|
|
★ 발행 자체를 실패시키면 안 된다 — 게이트를 통과해 DB 에 남은 발행 기록은 이미 정확하고,
|
|
payload 는 그것을 화면으로 옮기는 부수 산출물이다. 디스크가 없거나 권한이 없어서
|
|
발행이 되돌려지는 게 더 나쁘다. 대신 경고 로그로 반드시 드러낸다."""
|
|
try:
|
|
payload = await prepare_site_payload(place, snapshot, site, version, links, publish)
|
|
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
|