"""`/s/stay5` — 같은 내용, **펜션 공식 사이트** 레이아웃.
첫 판은 Airbnb 식 OTA 상세였다(2026-09-16). 그건 숙소를 **비교하러 온** 사람의 화면이고,
공식 사이트는 이미 이 집을 보러 온 사람의 화면이라 장르가 다르다 — 그래서 다시 만들었다.
가져온 것
· 구조 — single-property 부티크/빌라 템플릿 계열(풀블리드 사진 → 짧은 카피 → 객실 →
정보 → 큰 예약 CTA). 목록형 OTA 가 아니라 한 채를 소개하는 흐름이다.
· 기존 디자인 토큰(갱지 바탕·명조)은 쓰지 않는다. 흰 바탕 · 얇고 자간 넓은 표제 ·
색은 사진만. 국내 스테이 공식 사이트의 관용 문법이다.
· 야놀자·여기어때·스테이폴리오를 베끼지 않는다 — 민사 10억 선례가 non-goal 이다(PRODUCT.md 6절).
★ 심미와 효율을 위아래로 나눈다: 위는 사진·여백(브랜드), 아래는 접힌 정보(밀도).
★ `/s/stay` 를 건드리지 않는다. 이 스크립트는 stay5 만 만든다.
"""
import html as H
import json
import re
from pathlib import Path
SP = Path(__file__).parent
OUT = SP / "build" / "stay5.html"
PAYLOAD = SP / "stay-payload-new.json"
def esc(value) -> str:
return H.escape(str(value or ""), quote=True)
def img_url(url: str) -> str:
"""미러 사진은 stay5 자기 디렉토리에서 준다 — stay 를 지워도 안 깨진다."""
return re.sub(r"^/assets/mirror/", "/s/stay5/img/mirror/", url or "")
def fact_map(facts) -> dict:
out = {}
for f in facts:
if f.get("scope") != "place":
continue
value = (f.get("value") or "").strip()
if f.get("type") == "bool":
value = "가능" if value == "true" else "불가" if value == "false" else value
if (f.get("label") or "").endswith("여부"):
value = "있음" if value == "가능" else "없음" if value == "불가" else value
out[f["key"]] = {"label": f.get("label") or f["key"], "value": value,
"note": (f.get("summary") or "").strip(), "bool": f.get("type") == "bool"}
return out
def section_items(payload, section_id):
for s in payload["theme"]["sections"]:
if s["id"] == section_id and s.get("data"):
try:
return json.loads(s["data"]).get("items") or []
except Exception:
return []
return []
# ── 조각들 ────────────────────────────────────────────────────────────────────
def video_ids(payload) -> list:
"""유튜브 Shorts id. Shorts 는 9:16 이라 모바일 히어로와 비율이 맞는다."""
out = []
for item in section_items(payload, "video"):
m = re.search(r"(?:shorts/|watch\?v=|youtu\.be/|embed/)([A-Za-z0-9_-]{6,})", item.get("url") or "")
if m:
out.append({"id": m.group(1), "caption": item.get("caption") or ""})
return out
def hero(payload) -> str:
shots = ([m for m in payload["media"] if not m.get("unitId")] or payload["media"])[:5]
place = payload["place"]
where = (place.get("addressLocality") or place.get("addressRegion") or "").strip()
lines = [c["text"] for c in (payload["narrative"].get("catchphrases", {}).get("items") or [])
if (c.get("kind") or "general") == "general"][:24]
films = video_ids(payload)
layers = "".join(
f''
for i, m in enumerate(shots)
)
return f"""
{layers}
{esc(where or "STAY")}
{esc(place["name"])}
{esc(lines[0] if lines else "")}
"""
def film_block(payload) -> str:
films = video_ids(payload)
if not films:
return ""
cards = []
for i, f in enumerate(films):
uid = f"vid-{i}"
cards.append(
f'
'
f'
{esc(f["caption"])}
'
f'
'
)
return f'
영상 {len(films)}
{"".join(cards)}
'
def intro_block(payload) -> str:
about = (payload["narrative"].get("about") or [])[:2]
body = "".join(f'
{esc(t)}
' for t in about)
return f"""
About
{esc(payload["narrative"].get("summary") or "")}
{body}
"""
def gallery_block(payload) -> str:
shots = [m for m in payload["media"] if not m.get("unitId")][1:7]
if not shots:
return ""
cells = "".join(
f'
'
for m in shots
)
return f'
{cells}
'
def amenities(facts) -> str:
keys = ["parking", "wifi", "cooking_allowed", "smoking", "pickup_service", "barbecue"]
rows = [facts[k] for k in keys if k in facts and facts[k]["bool"]]
rows += [v for k, v in facts.items() if v["bool"] and k not in keys]
if not rows:
return ""
rows.sort(key=lambda r: r["value"] not in ("가능", "있음"))
cells = "".join(
f'
'
def rules(facts) -> str:
order = ["check_in", "check_out", "cancel_policy", "pet_allowed", "extra_person_fee"]
rows = [facts[k] for k in order if k in facts and facts[k]["value"]]
if not rows:
return ""
items = "".join(
f'
{esc(r["label"])}
{esc(r["value"])}'
f'{f"{esc(r['note'])}" if r["note"] else ""}
'
for r in rows
)
return f'
예약 전 확인
{items}
'
def units_block(payload) -> str:
by_id = {m["mediaId"]: m for m in payload["media"]}
cards = []
for unit in payload["units"]:
shots = [by_id[i] for i in unit.get("mediaIds", []) if i in by_id]
specs, intro = [], ""
for f in unit.get("facts", []):
key, value = f.get("key"), (f.get("value") or "").strip()
if key in ("standard_capacity", "max_capacity", "bed_type", "room_size") and value:
specs.append(f"
{esc(value)}
")
if key in ("room_intro", "description") and value and not intro:
intro = value
name = re.sub(r"\(.*\)$", "", unit["name"]).strip() or unit["name"]
sub = (re.search(r"\((.*)\)$", unit["name"]) or [None, ""])[1]
shot = (f'
') if shots else ""
rest = "".join(
f'
' for m in shots[1:3]
)
cards.append(f"""
{shot}
{esc(name)}
{esc(sub)}
{''.join(specs)}
{f'
{esc(intro)}
' if intro else ''}
{f'
{rest}
' if rest else ''}
""")
return "".join(cards)
def faq_block(payload) -> str:
faqs = [f for f in payload["faqs"] if (f.get("question") or "").strip()]
if not faqs:
return ""
items = "".join(
f'{esc(f["question"])}
{esc(f.get("answer"))}
'
for f in faqs
)
return (f'
자주 묻는 질문 {len(faqs)}'
f'
'
f'
{items}
')
def places_block(payload) -> str:
groups = [("eat", "맛집", payload["local"].get("restaurants") or []),
("see", "명소", payload["local"].get("attractions") or [])]
groups = [g for g in groups if g[2]]
if not groups:
return ""
cards, chips = [], ['']
for tag, label, rows in groups:
chips.append(f'')
for i, r in enumerate(rows):
img = img_url(r.get("imageUrl") or "")
uid = f"{tag}-{i}"
cards.append(
f'
'
+ f'
{esc(r.get("name"))}
'
+ f'
{esc(r.get("distanceText") or "")}'
+ f'{" · " + esc(r.get("address")) if r.get("address") else ""}
'
)
total = sum(len(g[2]) for g in groups)
return (f'
가까운 곳 {total}'
f'
'
f'
{"".join(chips)}
'
f'
{"".join(cards)}
')
def spots_row(payload) -> str:
"""정사각 사진에 라벨만 얹은 줄 — 레퍼런스 1 '국내 인기 여행지'."""
rows = [r for r in (payload["local"].get("attractions") or []) if r.get("imageUrl")][:6]
if not rows:
return ""
cells = "".join(
f''
for i, r in enumerate(rows)
)
return f'
걸어서 닿는 곳
{cells}
'
def festival_block(payload) -> str:
rows = payload["local"].get("festivals") or []
if not rows:
return ""
cards = []
for i, r in enumerate(rows):
img = img_url(r.get("imageUrl") or "")
when = " ".join(x for x in [r.get("season") or "", r.get("month") or ""] if x)
cards.append(
f'
'
+ f'
{esc(r.get("name"))}
'
+ f'
{esc(when)}{" · " + esc(r.get("location")) if r.get("location") else ""}
')
def itinerary_block(payload) -> str:
items = section_items(payload, "itinerary")
if not items:
items = payload["local"].get("itineraries") or []
cards = []
for item in items:
days = item.get("days") or [{"stops": item.get("stops") or []}]
stops = [s for d in days for s in (d.get("stops") or [])]
if not stops:
continue
lines = "".join(f"
{esc(s.get('name'))}
" for s in stops[:12])
cards.append(
f'{esc(item.get("name"))}'
f'{esc(item.get("duration") or "")} · {len(stops)}곳'
f'{lines}'
)
if not cards:
return ""
return f'
추천 일정 {len(cards)}
{"".join(cards)}
'
def story_block(payload) -> str:
out = []
for sid, title in (("reading", "군산 읽기"), ("people", "인물 열전"),
("chronicle", "시간의 골목"), ("songs", "가요 다방")):
rows = section_items(payload, sid)
if not rows:
continue
items = "".join(
f'{esc(r.get("title") or r.get("name"))}'
f'
{esc((r.get("body") or r.get("summary") or r.get("connection") or "")[:400])}
'
for r in rows
)
out.append(f'
{title} {len(rows)}
{items}
')
return "".join(out)
def close_block(payload) -> str:
place = payload["place"]
links = [l for l in payload["links"] if l.get("confirmed") and l.get("channel") in (1, 2, 3, 7)]
url = links[0]["url"] if links else ""
tel = place.get("phone") or ""
return f"""