"""발행 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 다.""" 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.""" 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.""" 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 출력 디렉토리.""" return Path(os.environ.get(PAYLOAD_DIR_ENV) or DEFAULT_PAYLOAD_DIR) def write_payload(payload: dict) -> str: """payload 를 `.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: """미리보기·발행 공통 보강.""" 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