템플릿 정보가 빌더·렌더러·백엔드에 따로 적혀 어긋나 있었다 — 백엔드 기본값이 없는 id 를 가리켰고, 음식점 강조색 오타, 섹션 간격이 빌더와 서버에서 달랐다. - shared/src/data/templates.json: 템플릿 4개(simple·magazine·retro·paper)와 업종별 허용·기본 - shared/lib/catalog.ts · backend/common/template_catalog.py: 같은 JSON 을 읽는다, 모르는 id 는 에러 - 저장·미리보기·발행에서 모르는 id 를 거절한다(site_service · site.py 422 · build_service 실패) - site: 레이아웃 등록표(basic·paper) + LayoutProvider, Shell→Frame, HomePage→SectionList - 연결 안 된 레이아웃 5개, 배치 고르기(variant), 서체 선택, 빌더 canvas/DevShowcase 삭제 - 빌더: 템플릿을 바꾸면 이전 템플릿이 켠 섹션을 끄고 안내 문구를 띄운다 - postgres-init/migrations/0023: stay-retro → retro, 병원 허용 밖은 NULL(운영 미적용) - Dockerfile·worker: 백엔드 이미지에 shared/src/data 복사 - docs: TEMPLATES.md 신설(세 폴더 역할·템플릿 추가·렌더링 순서), DATA_MODEL·ARCHITECTURE 등 갱신 shared·site·frontend tsc 통과, site vitest 통과, 백엔드 DB 없는 테스트 41 passed(DB 테스트 미실행) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
779 lines
31 KiB
Python
779 lines
31 KiB
Python
"""발행 payload — 정적 렌더러(solution/site)가 읽는 유일한 입력 JSON."""
|
|
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 common.template_catalog import TEMPLATES, industry_of, resolve_template_id
|
|
from services.intro_summary import summarize_intro
|
|
from services.stay_guide import nol_stay_guide
|
|
from services.weather_notes import weather_notes
|
|
|
|
# 렌더러가 확인하는 스키마 버전.
|
|
SCHEMA_VERSION = 1
|
|
|
|
# 출력 디렉토리.
|
|
PAYLOAD_DIR_ENV = "SITE_PAYLOAD_DIR"
|
|
DEFAULT_PAYLOAD_DIR = "/app/solution/site/payloads"
|
|
|
|
# 커스텀 도메인이 없을 때 쓰는 기본 호스트(sites.domain이 채워지면 그 값이 이긴다).
|
|
SITE_HOST_ENV = "SITE_PUBLIC_HOST"
|
|
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: "기타 채널",
|
|
}
|
|
|
|
# ── 값 변환 헬퍼 ──────────────────────────────────────────────────────────
|
|
def _first_sentence(text: str) -> str:
|
|
"""첫 문장."""
|
|
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 문자열."""
|
|
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 와 같은 규칙(로마자 음차하지 않는다)."""
|
|
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 코드.
|
|
_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:
|
|
"""주소 문자열에서 시·도 / 시·군·구 / 읍·면 을 뽑는다."""
|
|
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."""
|
|
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 를 그대로 싣는다.
|
|
"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, category: int) -> dict:
|
|
"""SiteTheme. 모양(look)은 템플릿 정의가, 색·섹션은 저장된 theme이 정한다. 잘못된 templateId면 UnknownTemplate."""
|
|
template_id = resolve_template_id(category, _text(_get(site, "template_id")))
|
|
template = TEMPLATES[template_id]
|
|
saved = _get(site, "theme")
|
|
if not isinstance(saved, dict):
|
|
saved = {}
|
|
|
|
# 렌더러는 색 6개를 모두 요구한다.
|
|
colors = dict(template["colors"])
|
|
for key, value in (saved.get("colors") or {}).items():
|
|
if isinstance(value, str) and value.strip():
|
|
colors[key] = value.strip()
|
|
|
|
return {
|
|
"templateId": template_id,
|
|
"colors": colors,
|
|
"look": dict(template["look"]),
|
|
"sections": _sections(saved.get("sections"), industry_of(category)["sections"]),
|
|
}
|
|
|
|
|
|
def _sections(saved_sections, defaults: list[dict]) -> list:
|
|
"""섹션 목록."""
|
|
by_id = {d["id"]: d for d in defaults}
|
|
if not isinstance(saved_sections, list) or not saved_sections:
|
|
return [{"id": d["id"], "name": d["name"], "enabled": d["enabled"], "locked": d["locked"]} for d in defaults]
|
|
|
|
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 = by_id.get(sid, {})
|
|
locked = bool(default.get("locked")) or bool(item.get("locked"))
|
|
entry = {
|
|
"id": sid,
|
|
"name": _text(item.get("name")) or default.get("name") or sid,
|
|
"enabled": bool(item.get("enabled", True)) or locked,
|
|
"locked": locked,
|
|
}
|
|
body = _text(item.get("body"))
|
|
if body:
|
|
entry["body"] = body
|
|
data = _text(item.get("data"))
|
|
if data:
|
|
entry["data"] = data
|
|
out.append(entry)
|
|
|
|
for d in defaults:
|
|
if d["id"] not in used:
|
|
out.append({"id": d["id"], "name": d["name"], "enabled": d["enabled"], "locked": d["locked"]})
|
|
return out
|
|
|
|
|
|
# WMO weather code → 한 줄 날씨.
|
|
_WEATHER_CONDITION_BY_CODE = {
|
|
0: "맑음",
|
|
1: "대체로 맑음",
|
|
2: "구름 조금",
|
|
3: "흐림",
|
|
45: "안개",
|
|
48: "착빙성 안개",
|
|
51: "가벼운 이슬비",
|
|
53: "보통 이슬비",
|
|
55: "강한 이슬비",
|
|
56: "가벼운 착빙성 이슬비",
|
|
57: "강한 착빙성 이슬비",
|
|
61: "약한 비",
|
|
63: "보통 비",
|
|
65: "강한 비",
|
|
66: "약한 착빙성 비",
|
|
67: "강한 착빙성 비",
|
|
71: "약한 눈",
|
|
73: "보통 눈",
|
|
75: "강한 눈",
|
|
77: "싸라기눈",
|
|
80: "약한 소나기",
|
|
81: "보통 소나기",
|
|
82: "강한 소나기",
|
|
85: "약한 소나기눈",
|
|
86: "강한 소나기눈",
|
|
95: "뇌우",
|
|
96: "약한 우박 뇌우",
|
|
99: "강한 우박 뇌우",
|
|
}
|
|
|
|
|
|
def _weather_condition(code) -> str:
|
|
try:
|
|
value = int(code)
|
|
except (TypeError, ValueError):
|
|
return "흐림"
|
|
return _WEATHER_CONDITION_BY_CODE.get(value, "흐림")
|
|
|
|
|
|
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 ""
|
|
|
|
|
|
# 시작 월 → 계절.
|
|
_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:
|
|
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
|
|
# 공식 홈페이지는 출처가 준 값일 때만 싣는다.
|
|
if homepage.startswith("http://") or homepage.startswith("https://"):
|
|
entry["officialUrl"] = homepage
|
|
# 업장 반경 캐시(place_contents)에서 온 축제는 거리·사진도 있다 — 카드 캐러셀이 맛집·명소와 같은 모양으로 그리려면 필요하다.
|
|
_put_distance(entry, body)
|
|
image = _text(body.get("imageUrl"))
|
|
if image:
|
|
entry["imageUrl"] = image
|
|
return entry
|
|
|
|
|
|
def _local_place(row: dict, category: str):
|
|
"""LocalPlace."""
|
|
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)에서 온다 — 공용 실체에는 거리가 없다(업장마다 다르다).
|
|
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."""
|
|
contents = (snapshot_local or {}).get("contents") or []
|
|
# courses(여행코스)는 백엔드만 채운다 — 렌더러 타입에 아직 자리가 없어 화면은 무시한다.
|
|
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:
|
|
# 지역 이야기는 **모양을 바꾸지 않는다.
|
|
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)."""
|
|
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:
|
|
"""이 사업장 사이트의 발행 슬러그."""
|
|
return _publish_target(site, str(_get(place, "place_id") or ""), _text(_get(place, "name")))["slug"]
|
|
|
|
|
|
def publish_origin() -> str:
|
|
"""발행본이 사는 오리진."""
|
|
return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}"
|
|
|
|
|
|
def publish_url(place, site) -> str:
|
|
"""이 사업장 사이트의 전체 발행 주소."""
|
|
return f"{publish_origin()}/s/{publish_slug(place, site)}"
|
|
|
|
|
|
def primary_media(snapshot: dict) -> dict | None:
|
|
"""대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장."""
|
|
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)."""
|
|
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)
|
|
|
|
# ── 사진 ─────────────────────────────────────────────
|
|
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).
|
|
"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 로 한 번 더 거른다.
|
|
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 = _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,
|
|
# 요약은 **첫 문장**이다.
|
|
"summary": _first_sentence(paragraphs[0]) if paragraphs else None,
|
|
}
|
|
|
|
theme = _theme(site, category)
|
|
|
|
# ── 지역 정보 ────────────────────────────────────────
|
|
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,
|
|
# 시·도 / 시·군·구 / 읍·면.
|
|
**_parse_address_parts(snap_place.get("road_address"), snap_place.get("address")),
|
|
"phone": snap_place.get("phone") or None,
|
|
# 스냅샷은 좌표를 문자열로 박제한다(Numeric 직렬화).
|
|
"latitude": _as_float(snap_place.get("latitude")),
|
|
"longitude": _as_float(snap_place.get("longitude")),
|
|
# 스냅샷이 실제로 쓴 지역 키가 이긴다.
|
|
"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)은 비어 있다.
|
|
"routes": [],
|
|
# 이 숙소의 노래.
|
|
"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"),
|
|
# 프리렌더가 복사해 놓을 자리.
|
|
"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()
|
|
],
|
|
# 미니 블로그 — 숙소 소개 아래 게시판(docs/MINI_BLOG.md).
|
|
"posts": [
|
|
{
|
|
"postId": row["post_id"],
|
|
"body": row["body"],
|
|
"publishedAt": row["published_at"],
|
|
}
|
|
for row in (snapshot.get("posts") or [])
|
|
if (row.get("body") or "").strip()
|
|
],
|
|
# 이용 후기 — 별점 없이 글만.
|
|
"reviews": [
|
|
{
|
|
"reviewId": row["review_id"],
|
|
"body": row["body"],
|
|
"nickname": row.get("nickname") or "",
|
|
"publishedAt": row["published_at"],
|
|
}
|
|
for row in (snapshot.get("reviews") or [])
|
|
if (row.get("body") or "").strip()
|
|
],
|
|
"socialPosts": [{"postId": r["post_id"], "provider": r["provider"], "body": r["body"],
|
|
"postedAt": r["posted_at"], "permalink": r.get("permalink")}
|
|
for r in snapshot.get("social_posts", [])],
|
|
"narrative": narrative,
|
|
"theme": theme,
|
|
# 검색 키워드(SiteOntology).
|
|
**_seo_entry(snapshot.get("seo")),
|
|
}
|
|
|
|
|
|
def _seo_entry(value) -> dict:
|
|
"""스냅샷의 seo(services/seo_keywords) → payload 의 `seo`."""
|
|
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` 으로 쓴다."""
|
|
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 조립 + 파일 쓰기."""
|
|
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
|