"""`/s/stay2` — 같은 내용, **펜션 공식 사이트** 레이아웃. 첫 판은 Airbnb 식 OTA 상세였다(2026-09-16). 그건 숙소를 **비교하러 온** 사람의 화면이고, 공식 사이트는 이미 이 집을 보러 온 사람의 화면이라 장르가 다르다 — 그래서 다시 만들었다. 가져온 것 · 구조 — single-property 부티크/빌라 템플릿 계열(풀블리드 사진 → 짧은 카피 → 객실 → 정보 → 큰 예약 CTA). 목록형 OTA 가 아니라 한 채를 소개하는 흐름이다. · 기존 디자인 토큰(갱지 바탕·명조)은 쓰지 않는다. 흰 바탕 · 얇고 자간 넓은 표제 · 색은 사진만. 국내 스테이 공식 사이트의 관용 문법이다. · 야놀자·여기어때·스테이폴리오를 베끼지 않는다 — 민사 10억 선례가 non-goal 이다(PRODUCT.md 6절). ★ 심미와 효율을 위아래로 나눈다: 위는 사진·여백(브랜드), 아래는 접힌 정보(밀도). ★ `/s/stay` 를 건드리지 않는다. 이 스크립트는 stay2 만 만든다. """ import html as H import json import re from pathlib import Path SP = Path(__file__).parent OUT = SP / "build" / "stay2.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: """미러 사진은 stay2 자기 디렉토리에서 준다 — stay 를 지워도 안 깨진다.""" return re.sub(r"^/assets/mirror/", "/s/stay2/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'
  • ' ) return f'

    영상 {len(films)}

    ' 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'
  • {esc(m.get(
  • ' for m in shots ) return f'' 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'
  • ' f'{esc(r["label"].replace(" 가능", "").replace(" 여부", ""))}' f'{esc(r["value"])}
  • ' for r in rows ) return 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)}

    {f'

    {esc(intro)}

    ' if intro else ''}
    {f'' 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'
  • ' ) total = sum(len(g[2]) for g in groups) return (f'

    가까운 곳 {total}' f'

    ' f'
    {"".join(chips)}
    ' f'') 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'

    걸어서 닿는 곳

    ' 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'
  • ' ) return (f'

    계절별 축제 {len(rows)}' f'

    ' f'') 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"""

    Reservation

    머무실 날을 알려 주세요

    {f'네이버 예약으로 이동' if url else ''} {f'전화 {esc(tel)}' if tel else ''}
    """ CSS = """ :root{ --paper:#fff; --ink:#141414; --soft:#6f6f6f; --line:#e6e4df; --pad:22px; --wide:640px; } *{box-sizing:border-box} html,body{margin:0;padding:0} html{scroll-behavior:smooth} body{ background:var(--paper); color:var(--ink); font:15.5px/1.9 -apple-system,'Apple SD Gothic Neo','Noto Sans KR',sans-serif; -webkit-font-smoothing:antialiased; word-break:keep-all; } .app{max-width:var(--wide);margin:0 auto;overflow-x:hidden} img{display:block;width:100%;height:100%;object-fit:cover} a{color:inherit;text-decoration:none} .eyebrow{font-size:11px;letter-spacing:.34em;text-transform:uppercase;color:var(--soft); font-weight:500;margin:0 0 14px} h2{font-size:23px;line-height:1.55;font-weight:400;letter-spacing:.06em;margin:0 0 18px} h3{font-size:16px;font-weight:600;letter-spacing:.02em;margin:0 0 14px; display:flex;align-items:baseline;gap:7px} h3 span{font-size:12px;font-weight:400;color:var(--soft);letter-spacing:.1em} p{margin:0 0 16px} .lead{font-size:16.5px;line-height:2;color:#2a2a2a} .sec{padding:76px var(--pad)} .sec--tight{padding:44px var(--pad)} .rule{height:1px;background:var(--line);margin:0 var(--pad)} /* ── 히어로 — 사진이 천천히 교차하고 문구가 갈린다 ── */ .hero{position:relative;height:92svh;min-height:560px;background:#0e0e0e;overflow:hidden} .stage{position:absolute;inset:0} .stage img{position:absolute;inset:0;opacity:0;transform:scale(1.08); transition:opacity 1.6s ease, transform 8s linear} .stage img.on{opacity:.9;transform:scale(1)} .film{position:absolute;inset:0;opacity:0;transition:opacity 1.2s ease;pointer-events:none} .film.on{opacity:.92} .film iframe{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); width:100vw;height:calc(100vw * 16 / 9);min-height:100%;min-width:calc(100svh * 9 / 16);border:0} .film-card .play{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%); width:48px;height:48px;border-radius:50%;background:#0000005c;backdrop-filter:blur(2px)} .film-card .play::after{content:'';position:absolute;top:50%;left:54%;transform:translate(-50%,-50%); border-left:13px solid #fff;border-top:8px solid transparent;border-bottom:8px solid transparent} .films .shotcard{aspect-ratio:9/16} .tube{position:relative;aspect-ratio:9/16;margin-bottom:18px;background:#000} .tube iframe{position:absolute;inset:0;width:100%;height:100%;border:0} .veil{position:absolute;inset:0; background:linear-gradient(180deg,rgba(0,0,0,.4) 0%,rgba(0,0,0,.05) 42%,rgba(0,0,0,.62) 100%)} .hero .copy{position:absolute;left:0;right:0;bottom:0;padding:0 var(--pad) 74px;color:#fff} .hero .copy .eyebrow{color:#ffffffc4;animation:rise .9s .1s both} .hero h1{font-size:31px;line-height:1.45;font-weight:300;letter-spacing:.11em;margin:0 0 14px; animation:rise 1s .25s both} .hero .say{font-size:15.5px;color:#fff;margin:0;letter-spacing:.04em;min-height:2.6em; text-shadow:0 1px 14px rgba(0,0,0,.35)} .hero .say.out{opacity:0;transform:translateY(6px)} .hero .say{transition:opacity .5s ease, transform .5s ease} @keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}} .down{position:absolute;left:50%;bottom:22px;transform:translateX(-50%); width:44px;height:44px;display:grid;place-items:center} .down i{width:1px;height:26px;background:#fff9;animation:drop 2.2s ease-in-out infinite} @keyframes drop{0%,100%{transform:scaleY(.4);transform-origin:top;opacity:.4} 50%{transform:scaleY(1);opacity:1}} .top{position:fixed;top:0;left:0;right:0;z-index:40;max-width:var(--wide);margin:0 auto; display:flex;align-items:center;gap:10px;padding:0 var(--pad);height:56px; background:#fffffff2;border-bottom:1px solid var(--line); transform:translateY(-100%);transition:transform .25s ease} .top.on{transform:none} .top .name{font-size:15px;letter-spacing:.08em;flex:1} .top .book{font-size:13px;letter-spacing:.06em;border:1px solid var(--ink); padding:0 16px;min-height:44px;display:flex;align-items:center} /* 스크롤에 맞춰 조용히 올라온다 */ .rise{opacity:0;transform:translateY(18px);transition:opacity .7s ease, transform .7s ease} .rise.seen{opacity:1;transform:none} .duo{display:grid;grid-template-columns:1fr 1fr;gap:6px;padding:0 6px;margin:0} .duo li{list-style:none;aspect-ratio:3/4;overflow:hidden} .duo li:first-child{grid-column:1 / -1;aspect-ratio:4/3} /* ── 사진 카드 — 그라데이션 위에 카피 (모달을 연다) ── */ .seeall{margin-left:auto;border:0;background:none;font:inherit;font-size:13px; color:var(--soft);letter-spacing:.04em;cursor:pointer;min-height:44px;padding:0 0 0 12px} .chips{display:flex;gap:7px;overflow-x:auto;touch-action:pan-y pinch-zoom; margin:0 0 16px;padding:2px 0 4px;scrollbar-width:none;list-style:none} .chips::-webkit-scrollbar{display:none} .chip{flex:none;min-height:44px;padding:0 16px;border:1px solid var(--line);border-radius:999px; background:none;font:inherit;font-size:14px;color:var(--soft);cursor:pointer;white-space:nowrap} .chip.on{background:var(--ink);border-color:var(--ink);color:#fff} .tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:0 0 8px;padding:0;list-style:none} .tile{position:relative;display:block;width:100%;aspect-ratio:1/1;overflow:hidden; border:0;padding:0;background:#eee;cursor:pointer} .tile b{position:absolute;left:8px;bottom:8px;color:#fff;font-size:13px;font-weight:500; letter-spacing:.01em;text-align:left;line-height:1.35;padding-right:8px} .shots2{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:0;padding:0;list-style:none} .shotcard{position:relative;display:block;width:100%;aspect-ratio:3/4;overflow:hidden; border:0;padding:0;background:#eee;cursor:pointer;text-align:left} .shotcard img{transition:transform .7s ease} .shotcard:active img{transform:scale(1.04)} .shotcard .noimg{display:block;width:100%;height:100%;background:#efece6} .shotcard .grad{position:absolute;inset:0; background:linear-gradient(180deg,rgba(0,0,0,0) 38%,rgba(0,0,0,.72) 100%)} .shotcard .cap{position:absolute;left:12px;right:12px;bottom:12px;color:#fff;display:block} .shotcard .cap em{display:block;font-style:normal;font-size:11px;letter-spacing:.14em; color:#ffffffc4;margin-bottom:3px} .shotcard .cap b{display:block;font-size:15px;font-weight:500;line-height:1.45;letter-spacing:.02em} /* ── 모달 ── */ .sheet{position:fixed;inset:0;z-index:60;display:none} .sheet.on{display:block} .sheet .bg{position:absolute;inset:0;background:#0009;opacity:0;transition:opacity .25s} .sheet.on .bg{opacity:1} .sheet .box{position:absolute;left:0;right:0;bottom:0;max-width:var(--wide);margin:0 auto; background:#fff;max-height:88svh;overflow:auto;padding:26px var(--pad) 34px; transform:translateY(100%);transition:transform .3s cubic-bezier(.2,.8,.2,1)} .sheet.on .box{transform:none} .sheet h4{font-size:20px;font-weight:400;letter-spacing:.05em;margin:0 0 6px} .sheet .meta{font-size:12px;letter-spacing:.1em;color:var(--soft);margin:0 0 18px} .sheet img{aspect-ratio:4/3;margin:0 0 18px} .sheet p{color:#3a3a3a} .sheet .out{display:flex;align-items:center;justify-content:center;min-height:52px; border:1px solid var(--ink);font-size:14px;letter-spacing:.08em;margin-top:8px} .sheet .x{position:absolute;top:-52px;right:var(--pad);width:44px;height:44px;border:0; background:none;color:#fff;font-size:26px;line-height:1;cursor:pointer} .room{margin:0 0 64px} .room .shot{aspect-ratio:4/3;overflow:hidden;margin-bottom:22px} .room .body{padding:0 var(--pad)} .room h3{font-size:19px;font-weight:400;letter-spacing:.08em;margin:0 0 6px;display:block} .room .sub{color:var(--soft);font-size:13.5px;letter-spacing:.04em;margin:0 0 14px} .room .specs{display:flex;flex-wrap:wrap;gap:0 18px;margin:0 0 14px;padding:0;list-style:none} .room .specs li{font-size:13.5px;color:var(--soft);position:relative;padding-left:11px} .room .specs li::before{content:'';position:absolute;left:0;top:.9em;width:3px;height:3px; border-radius:50%;background:var(--soft)} .room p{color:#3a3a3a} .amen{display:grid;grid-template-columns:1fr 1fr;gap:0 18px;margin:0;padding:0;list-style:none} .amen li{display:flex;gap:8px;padding:10px 0;border-bottom:1px solid var(--line);font-size:14.5px} .amen li b{font-weight:500} .amen .off{color:#a8a8a8} .amen .off b{text-decoration:line-through;text-decoration-thickness:1px} .rows{margin:0;padding:0} .rows>div{padding:14px 0;border-bottom:1px solid var(--line)} .rows dt{font-size:12px;letter-spacing:.14em;color:var(--soft);margin:0 0 4px;text-transform:uppercase} .rows dd{margin:0;font-size:15px} .rows em{display:block;font-style:normal;color:var(--soft);font-size:13.5px;margin-top:3px} /* FAQ — Q 표식 + 펼침 (레퍼런스 2) */ .acc details{border-bottom:1px solid var(--line)} .acc summary{list-style:none;cursor:pointer;min-height:56px;display:flex;align-items:center; gap:12px;font-size:14.5px;padding:8px 0} .acc summary::-webkit-details-marker{display:none} .acc summary::before{content:'Q';flex:none;width:22px;height:22px;border-radius:50%; background:#f2efe9;color:var(--soft);font-size:11px;display:grid;place-items:center; letter-spacing:0;margin-top:1px} .acc summary::after{content:'';flex:none;margin-left:auto;width:8px;height:8px; border-right:1px solid #9a9a9a;border-bottom:1px solid #9a9a9a;transform:rotate(45deg); transition:transform .2s;margin-bottom:4px} .acc details[open] summary::after{transform:rotate(-135deg);margin-bottom:0} .acc summary em{font-style:normal;color:var(--soft);font-size:13px;margin-left:auto;padding-right:10px} .acc p,.acc ol{margin:0;padding:0 0 18px 34px;color:var(--soft);font-size:14.5px;line-height:1.85} .stops{padding-left:52px !important;list-style:decimal} .more{width:100%;min-height:48px;margin-top:18px;border:1px solid var(--line); background:none;font:inherit;font-size:13px;letter-spacing:.12em;color:var(--soft);cursor:pointer} .close{padding:86px var(--pad) 96px;text-align:center;border-top:1px solid var(--line)} .close h2{margin-bottom:26px} .close .book{border:1px solid var(--ink);min-height:56px; display:flex;align-items:center;justify-content:center;font-size:15px;letter-spacing:.1em} .close .tel{display:block;margin-top:14px;color:var(--soft);font-size:14px;letter-spacing:.06em} footer{padding:0 var(--pad) 40px;color:#a8a8a8;font-size:12px;text-align:center;letter-spacing:.06em} @media (prefers-reduced-motion:reduce){ *{transition:none!important;animation:none!important} .rise{opacity:1;transform:none} } """ JS = """ (function(){ var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; // 히어로 — 사진 교차 + 문구 순환 var stage=document.querySelectorAll('.stage img'), say=document.getElementById('say'), raw=document.getElementById('lines'); var lines=[]; try{ lines=JSON.parse(raw.textContent)||[]; }catch(e){} if(stage.length>1 && !reduce){ var si=0; setInterval(function(){ stage[si].classList.remove('on'); si=(si+1)%stage.length; stage[si].classList.add('on'); }, 6000); } if(say && lines.length>1){ var li=0; setInterval(function(){ say.classList.add('out'); setTimeout(function(){ li=(li+1)%lines.length; say.textContent=lines[li]; say.classList.remove('out'); }, 480); }, 5000); } // 히어로 영상 — 사진을 먼저 깔고, 준비되면 그 위를 덮는다. // ★ 무음이어야 자동재생이 허용된다(소리는 사용자 조작 없이는 절대 안 난다). // ★ 실패하거나 느리면 사진이 그대로 남는다 — 검은 화면을 만들지 않는다. var filmBox=document.getElementById('film'), fraw=document.getElementById('films'), films=[]; try{ films=JSON.parse(fraw.textContent)||[]; }catch(e){} var save=(navigator.connection&&navigator.connection.saveData)===true; if(filmBox && films.length && !reduce && !save){ var f=document.createElement('iframe'); f.src='https://www.youtube.com/embed/'+films[0].id +'?autoplay=1&mute=1&loop=1&playlist='+films[0].id +'&controls=0&playsinline=1&modestbranding=1&rel=0&disablekb=1&fs=0'; f.title=''; f.tabIndex=-1; f.setAttribute('aria-hidden','true'); f.setAttribute('allow','autoplay; encrypted-media'); f.addEventListener('load', function(){ setTimeout(function(){ filmBox.classList.add('on'); }, 600); }); filmBox.appendChild(f); } // 상단 바 var bar=document.getElementById('topbar'), hero=document.querySelector('.hero'); if(bar&&hero&&'IntersectionObserver' in window){ new IntersectionObserver(function(e){ bar.classList.toggle('on', !e[0].isIntersecting); }, {rootMargin:'-70px 0px 0px 0px'}).observe(hero); } // 스크롤에 맞춰 올라오기 if('IntersectionObserver' in window){ var io=new IntersectionObserver(function(es){ es.forEach(function(e){ if(e.isIntersecting){ e.target.classList.add('seen'); io.unobserve(e.target); } }); },{rootMargin:'0px 0px -12% 0px'}); document.querySelectorAll('.sec, .room, .duo').forEach(function(el){ el.classList.add('rise'); io.observe(el); }); } // 필터 칩 — 레퍼런스 1(인기 추천 숙소) document.querySelectorAll('.chip').forEach(function(chip){ chip.addEventListener('click', function(){ var kind=chip.dataset.filter, box=document.getElementById('nearby'); if(!box) return; chip.parentNode.querySelectorAll('.chip').forEach(function(c){ c.classList.toggle('on', c===chip); }); [].slice.call(box.children).forEach(function(li){ li.dataset.hidden = (kind==='all' || li.dataset.kind===kind) ? '' : '1'; li.style.display = li.dataset.hidden ? 'none' : ''; }); var btn=box.parentNode.querySelector('.more'); if(btn) btn.remove(); }); }); // 섹션 헤더의 '더보기 ›' — 그 섹션의 목록을 전부 편다 document.querySelectorAll('[data-all]').forEach(function(btn){ btn.addEventListener('click', function(){ var sec=btn.closest('section') || document; sec.querySelectorAll('[data-more]').forEach(function(box){ [].slice.call(box.children).forEach(function(el){ if(!el.dataset.hidden) el.style.display=''; }); }); sec.querySelectorAll('.more').forEach(function(m){ m.remove(); }); btn.remove(); }); }); // 더 보기 document.querySelectorAll('[data-more]').forEach(function(box){ var keep=parseInt(box.dataset.more,10), kids=[].slice.call(box.children); if(kids.length<=keep) return; kids.forEach(function(el,i){ if(i>=keep) el.style.display='none'; }); var btn=document.createElement('button'); btn.className='more'; btn.type='button'; btn.textContent='더 보기 ('+(kids.length-keep)+')'; btn.addEventListener('click',function(){ kids.forEach(function(el){ el.style.display=''; }); btn.remove(); }); box.parentNode.insertBefore(btn, box.nextSibling); }); // 모달 — 새 페이지로 보내지 않는다 var sheet=document.createElement('div'); sheet.className='sheet'; sheet.innerHTML='
    '; document.body.appendChild(sheet); var box=sheet.querySelector('.in'), scrollY=0; function open(id){ var tpl=document.getElementById(id); if(!tpl) return; box.innerHTML=tpl.innerHTML; scrollY=window.scrollY; sheet.classList.add('on'); document.body.style.position='fixed'; document.body.style.top=(-scrollY)+'px'; document.body.style.width='100%'; } function close(){ sheet.classList.remove('on'); document.body.style.position=''; document.body.style.top=''; document.body.style.width=''; window.scrollTo(0, scrollY); } document.addEventListener('click', function(e){ var card=e.target.closest('[data-modal]'); if(card){ open(card.dataset.modal); return; } if(e.target.closest('.sheet .bg') || e.target.closest('.sheet .x')) close(); }); document.addEventListener('keydown', function(e){ if(e.key==='Escape') close(); }); })(); """ def build() -> str: payload = json.loads(PAYLOAD.read_text(encoding="utf-8")) facts = fact_map(payload["facts"]) place = payload["place"] return f""" {esc(place["name"])}
    {hero(payload)} {intro_block(payload)} {gallery_block(payload)}

    Film

    머뭄의 하루

    {film_block(payload)}

    Rooms

    두 채가 각각 한 팀만 씁니다

    {units_block(payload)}

    Information

    머물기 전에

    {amenities(facts)} {rules(facts)}

    Around

    걸어서 닿는 곳

    {spots_row(payload)} {places_block(payload)} {festival_block(payload)}

    Gunsan

    이 동네를 걷는 법

    {itinerary_block(payload)} {story_block(payload)}

    Q & A

    {faq_block(payload)}
    {close_block(payload)}
    """ if __name__ == "__main__": OUT.parent.mkdir(parents=True, exist_ok=True) doc = build() OUT.write_text(doc, encoding="utf-8") print(f"stay2.html {len(doc):,}자")