"""`/s/stay6` — 2안 '사진' 판을 세 페이지로 굽는다. 옛 판(`build_stay6.py` → `build6/`)은 **같은 리액트 렌더러**에 `enabled` 만 갈라 SSR 을 세 번 돌린 것이라, 디자인은 `/s/stay` 와 한 몸이었다. 2안은 DOM 구조 자체가 다르다(히어로 위로 올라타는 카드·좌우 레일·하단 고정 예약 바·상단 탭). 렌더러를 고치면 `/s/stay` 와 이미 나가 있는 발행 사이트가 같이 바뀌므로 — 사장님이 발행한 적 없는데 화면이 달라진다 — **여기서는 payload 만 읽어 HTML 을 손으로 조립한다.** `solution/site/src` 는 건드리지 않는다. ★ `/s/stay6` 를 굽는 것은 이제 이 스크립트다. `build_stay6.py`·`build6/` 는 옛 판으로 남겨 둔다(배포하지 않는다). 두 스크립트가 같은 디렉토리에 쓰지 않도록 출력이 갈라져 있다. ★ payload 는 **구워진 `/s/stay` 에서 그대로 떠 온다** — `stay-payload-new.json` 은 patch_stay 중간 스냅샷이라 사진 주소 교체·탭 비우기가 아직 안 들어가 있다. ★ 사진·음원 주소는 `/s/stay/...` → `/s/stay6/...` 로만 바꾼다. `/s/stay` 를 지워도 안 깨지게 자기 디렉토리에서 받는다(배포 때 `img/` `audio/` 를 같이 넣는다). 입력: build/index.html (patch_stay.py 결과) 출력: build6p/{index.html, gunsan/index.html, booking/index.html} 배포: C=o2o-web4ai-solution-worker # README 2.2 와 같은 컨테이너다(out 볼륨을 같이 본다) docker cp build6p/index.html $C:/app/solution/site/out/s/stay6/index.html docker cp build6p/gunsan/index.html $C:/app/solution/site/out/s/stay6/gunsan/index.html docker cp build6p/booking/index.html $C:/app/solution/site/out/s/stay6/booking/index.html docker cp img/. $C:/app/solution/site/out/s/stay6/img/ docker cp audio/. $C:/app/solution/site/out/s/stay6/audio/ """ import html as H import json import re import sys from datetime import datetime from pathlib import Path from urllib.parse import quote SP = Path(__file__).parent SRC = SP / "build" / "index.html" ORIGIN = "https://web4ai.o2osolution.ai" # 같은 판을 다른 슬러그에도 올린다 — 주소가 HTML 안에 박히므로(자산·페이지 링크·canonical) # 슬러그마다 다시 구워야 한다. `python3 build_photo6.py stay2` → /s/stay2 용으로 굽는다. SLUG = sys.argv[1] if len(sys.argv) > 1 else "stay6" BASE = "/s/" + SLUG OUT = SP / ("build6p" if SLUG == "stay6" else "build6p-" + SLUG) SEASON_ORDER = {"봄": 0, "여름": 1, "가을": 2, "겨울": 3} PAGES = [("", "펜션 소개"), ("gunsan", "군산 소개"), ("booking", "이용안내 · 예약")] def e(value) -> str: return H.escape(str(value or ""), quote=True) def url(raw: str) -> str: """미러 사진·음원을 stay6 자기 디렉토리에서 준다.""" return re.sub(r"^/s/stay/", f"{BASE}/", raw or "") def walk_bucket(distance_text: str) -> str: """걸어서 몇 분 — 분속 80m 로 끊는다. /s/stay 의 주변 안내 탭과 같은 가름이다.""" raw = (distance_text or "").strip() meters = 0.0 if raw.endswith("km"): meters = float(re.sub(r"[^0-9.]", "", raw) or 0) * 1000 elif raw.endswith("m"): meters = float(re.sub(r"[^0-9.]", "", raw) or 0) if meters <= 400: return "5분" if meters <= 800: return "10분" return "그 밖" def naver(query) -> str: return f"https://search.naver.com/search.naver?query={quote(str(query))}" def payload() -> dict: html = SRC.read_text(encoding="utf-8") head = html.index("window.__SITE_PAYLOAD__=") + len("window.__SITE_PAYLOAD__=") return json.loads(html[head:html.index("", head)].rstrip().rstrip(";")) def section(data, sid): for s in data["theme"]["sections"]: if s["id"] == sid and s.get("data"): try: return json.loads(s["data"]).get("items") or [] except ValueError: return [] return [] def fact(data, key, default=""): for f in data["facts"]: if f["key"] == key: return f["value"] return default def unit_fact(unit, key, default=""): for f in unit["facts"]: if f["key"] == key: return f["value"] return default def unit_name(unit) -> str: return unit["name"].split("(")[0].strip() def unit_note(unit) -> str: raw = unit["name"] return raw[raw.find("(") + 1:raw.rfind(")")].strip() if "(" in raw else "" def media_of(data, unit): index = {m["mediaId"]: m for m in data["media"]} return [index[i] for i in unit["mediaIds"] if i in index] def pic(media, alt=None, ratio="4 / 3", cls=""): return (f'{e(alt or media.get(') # ── 2안 '사진' — 흰 바탕, 색은 사진이 낸다. 명조는 상호와 히어로 한 줄에만 쓴다 ────────── CSS = """ :root{ --paper:#fdfcfa; --ink:#1f1d19; --sub:#5b564e; --line:#e6e2da; --tint:#f2efe8; --soft:#f8f6f1; --pad:20px; --wide:640px; --bar:64px; } *{box-sizing:border-box} html,body{margin:0;padding:0} html{scroll-behavior:smooth} body{ background:var(--paper); color:var(--ink); font:15px/1.9 -apple-system,'Apple SD Gothic Neo','Noto Sans KR',sans-serif; -webkit-font-smoothing:antialiased; word-break:keep-all; padding-bottom:var(--bar); } .app{max-width:var(--wide);margin:0 auto;overflow-x:hidden;border-inline:1px solid var(--line)} img{display:block;width:100%;object-fit:cover;background:var(--tint)} a{color:inherit;text-decoration:none} p{margin:0 0 14px} .mj{font-family:'Noto Serif KR','AppleMyungjo','Nanum Myeongjo',serif;font-weight:400} .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)} /* 머리 — 상호 · 미니 플레이어 · 전화. 플레이어는 /s/stay 와 같은 자리(헤더 안)다 */ .top{height:56px;padding:0 6px 0 var(--pad);display:flex;align-items:center;gap:8px; border-bottom:1px solid var(--line)} .top .brand{flex:0 0 auto;font-size:16px;letter-spacing:.02em} .top .ico{flex:0 0 auto;width:40px;height:44px;display:grid;place-items:center} .mp{flex:1 1 auto;min-width:0;display:flex;align-items:center;justify-content:flex-end;gap:2px} .mp button{width:40px;height:40px;padding:8px;border:0;background:none;color:var(--ink);cursor:pointer} .mp svg{width:100%;height:100%} .mp .now{min-width:0;max-width:108px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; font-size:11.5px;color:var(--sub)} .mp.on .w4d-reel{transform-box:fill-box;transform-origin:center;animation:reel 2.4s linear infinite} @keyframes reel{to{transform:rotate(360deg)}} .mpsheet{display:none;border-bottom:1px solid var(--line);background:var(--soft)} .mpsheet.on{display:block} .mpsheet button{width:100%;height:46px;padding:0 var(--pad);border:0;border-bottom:1px solid var(--line); background:none;font:inherit;font-size:13px;color:var(--ink);text-align:left;cursor:pointer} .mpsheet button:last-child{border-bottom:0} .tabs{position:sticky;top:0;z-index:20;background:var(--paper);display:flex;gap:20px; padding:0 var(--pad);border-bottom:1px solid var(--line);overflow-x:auto; scrollbar-width:none;-webkit-overflow-scrolling:touch} .tabs::-webkit-scrollbar{display:none} .tabs a{flex:0 0 auto;height:48px;display:flex;align-items:center;font-size:13.5px;color:var(--sub)} .tabs a.on{color:var(--ink);font-weight:500;box-shadow:inset 0 -2px 0 var(--ink)} /* 히어로 — 사진이 넘어가고 그 위에 상호 · 소문구 · 캐치프레이즈가 얹힌다(/s/stay 와 같은 문구) */ .hero{position:relative;height:min(62svh,520px);min-height:390px;background:#12110e;overflow:hidden} .hero .vp,.hero .track{height:100%} .hero .track{display:flex} .hero .slide{flex:0 0 100%;min-width:0;height:100%} .hero img{height:100%} .veil{position:absolute;inset:0;pointer-events:none; background:linear-gradient(to top,rgba(10,9,7,.68) 0%,rgba(10,9,7,.24) 46%, rgba(10,9,7,.06) 72%,rgba(10,9,7,.28) 100%)} .herocopy{position:absolute;left:0;right:0;bottom:0;padding:0 var(--pad) 34px;color:#fff; pointer-events:none} .herocopy .eyebrow{color:#fff;opacity:.8;margin:0 0 10px} .herocopy h1{margin:0;font-size:25px;font-weight:300;letter-spacing:.16em;line-height:1.4} .herocopy .fixed{margin:12px 0 0;font-size:14.5px;line-height:1.7;opacity:.92} .hero .dots{position:absolute;left:0;right:0;bottom:12px;z-index:2}\n.hero .dots button{background:rgba(253,252,250,.45)}\n.hero .dots button.on{background:#fff}\n.herocopy .cp{margin:6px 0 0;font-size:14.5px;line-height:1.7;opacity:.78; min-height:1.7em;transition:opacity .5s ease} .eyebrow{margin:0 0 12px;font-size:10.5px;letter-spacing:.34em;color:var(--sub)} .sec{padding:40px var(--pad)} .sec--flush{padding:40px 0} .sec--tint{background:var(--soft)} .sec h2{margin:0 0 18px;font-family:'Noto Serif KR','AppleMyungjo',serif;font-size:19px; font-weight:400;letter-spacing:.05em;line-height:1.5} .sec h2 span{font-family:-apple-system,'Apple SD Gothic Neo',sans-serif;font-size:12px; font-weight:400;color:var(--sub);margin-left:6px;letter-spacing:0} .sec--flush h2,.sec--flush .eyebrow{padding:0 var(--pad)} .head{display:flex;align-items:baseline;justify-content:space-between;gap:12px} .head a{font-size:12px;color:var(--sub)} .lead{font-family:'Noto Serif KR','AppleMyungjo',serif;font-size:16.5px;line-height:2.05; font-weight:300;letter-spacing:.01em;color:#2a2823} .note{font-size:11.5px;line-height:1.75;color:var(--sub)} .rule{height:1px;background:var(--line)} figure{margin:0} figure figcaption{padding:12px var(--pad) 0;font-size:11.5px;line-height:1.8;color:var(--sub)} .today{padding:20px var(--pad);border-bottom:1px solid var(--line)} .today p{margin:0;font-size:13.5px;line-height:1.85;color:#2a2823} .pills{display:flex;flex-wrap:wrap;gap:8px} .pill{height:32px;padding:0 13px;display:flex;align-items:center;background:var(--tint);font-size:12.5px} /* 카로셀 — 렌더러가 쓰는 embla 를 그대로 쓴다(vendor/embla-carousel.umd.js) */ .car{position:relative} .car .vp{overflow:hidden} .car .track{display:flex;touch-action:pan-y pinch-zoom} .car .slide{flex:0 0 100%;min-width:0} .car .arrow{position:absolute;top:50%;transform:translateY(-50%);width:40px;height:40px; border:0;border-radius:50%;background:rgba(253,252,250,.86);color:var(--ink);cursor:pointer; display:grid;place-items:center;font-size:18px;line-height:1} .car .prev{left:10px} .car .next{right:10px} .car .count{position:absolute;right:12px;bottom:12px;margin:0;padding:3px 9px; background:rgba(16,15,12,.6);color:#fff;font-size:11px;letter-spacing:.06em} .car .dots{display:flex;justify-content:center;gap:6px;padding:12px 0 0} .car .dots button{width:6px;height:6px;padding:0;border:0;border-radius:50%; background:var(--line);cursor:pointer} .car .dots button.on{width:18px;border-radius:3px;background:var(--ink)} .rail{display:flex;gap:12px;overflow-x:auto;padding:0 var(--pad) 4px; scroll-snap-type:x mandatory;scrollbar-width:none;-webkit-overflow-scrolling:touch} .rail::-webkit-scrollbar{display:none} .rail>*{flex:0 0 auto;scroll-snap-align:start} .rail .w260{width:260px} .rail .w200{width:200px} .rail .w150{width:150px} .grid2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px} .grid2--tight{gap:4px} .cap{margin:10px 0 0;font-size:13px;line-height:1.6} .cap b{font-family:'Noto Serif KR','AppleMyungjo',serif;font-weight:400;font-size:14.5px;letter-spacing:.03em} .dim{color:var(--sub);font-size:11.5px} .clamp{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} .row{display:flex;align-items:baseline;justify-content:space-between;gap:14px; padding:12px 0;border-bottom:1px solid #efece6;font-size:13.5px} .row:last-child{border-bottom:0} .row .r{flex:0 0 auto;color:var(--sub);font-size:11.5px} dl.spec{margin:0;display:grid;grid-template-columns:82px minmax(0,1fr);row-gap:12px;font-size:13px} dl.spec dt{color:var(--sub)} dl.spec dd{margin:0} .cta{height:52px;display:flex;align-items:center;justify-content:center; background:var(--ink);color:var(--paper);font-size:14px;letter-spacing:.06em} .cta+.cta{margin-top:10px} .cta.ghost{background:none;border:1px solid var(--ink);color:var(--ink)} .more{width:100%;height:44px;margin-top:12px;border:1px solid var(--line);background:none; font:inherit;font-size:13px;color:var(--ink);cursor:pointer} details>summary{list-style:none;cursor:pointer} details>summary::-webkit-details-marker{display:none} details[open] .more{display:none} .faq details{border-bottom:1px solid var(--line)} .faq summary{padding:14px 0;font-size:14px;line-height:1.7;display:flex;gap:10px; align-items:baseline;justify-content:space-between} .faq summary::after{content:'+';color:var(--sub);flex:0 0 auto} .faq details[open] summary::after{content:'−'} .faq p{margin:0 0 16px;font-size:13px;line-height:1.85;color:#4a453a} .form label{display:block;margin-bottom:10px} .form span{display:block;margin-bottom:4px;font-size:11.5px;color:var(--sub)} .form input,.form textarea{width:100%;height:46px;padding:0 14px;border:1px solid #ddd9d2; background:var(--paper);font:inherit;font-size:13.5px;color:var(--ink);border-radius:0} .form textarea{height:84px;padding:12px 14px;line-height:1.7} .film{position:relative;background:var(--ink)} .film img{aspect-ratio:3/4;opacity:.9} .film .play{position:absolute;inset:0;display:grid;place-items:center} .film .play::after{content:'';border-left:14px solid #fff;border-top:9px solid transparent; border-bottom:9px solid transparent;filter:drop-shadow(0 0 6px rgba(0,0,0,.5))} footer{padding:26px var(--pad) 32px;border-top:1px solid var(--line); font-size:11.5px;line-height:1.95;color:var(--sub)} footer a{color:var(--sub)} /* 하단 고정 — 값은 넣지 않는다(2026-09-18 지시). 예약과 전화만 둔다 */ .bar{position:fixed;bottom:0;left:0;right:0;max-width:var(--wide);margin:0 auto;height:var(--bar); padding:9px var(--pad);display:flex;align-items:center;gap:10px; background:var(--paper);border-top:1px solid var(--line);z-index:30} .bar .cta{flex:1 1 auto;height:46px;margin:0} .bar .cta.ghost{flex:0 0 108px} @supports(padding:max(0px)){ .bar{height:calc(var(--bar) + env(safe-area-inset-bottom)); padding-bottom:calc(9px + env(safe-area-inset-bottom))} } /* 아이템을 누르면 뜨는 시트 — 새 창으로 보내면 손님이 사이트를 떠난다 */ .sheet{position:fixed;inset:0;z-index:40} .sheet[hidden]{display:none} .sbg{position:absolute;inset:0;background:rgba(14,13,10,.52)} .sbox{position:absolute;left:0;right:0;bottom:0;max-width:var(--wide);margin:0 auto; max-height:88svh;overflow:auto;background:var(--paper);padding:0 0 32px; -webkit-overflow-scrolling:touch} .sx{position:sticky;top:0;float:right;width:48px;height:48px;border:0;background:none; font-size:22px;line-height:1;color:var(--ink);cursor:pointer;z-index:1} .sin img{margin:0 0 18px} .sin h3{margin:0 0 6px;padding:0 var(--pad);font-family:'Noto Serif KR','AppleMyungjo',serif; font-size:19px;font-weight:400;line-height:1.5;letter-spacing:.03em} .sin .meta{margin:0 0 16px;padding:0 var(--pad);font-size:11.5px;letter-spacing:.06em;color:var(--sub)} .sin p{margin:0 0 14px;padding:0 var(--pad);font-size:13.5px;line-height:1.95;color:#39352e} .sin .stops{margin:0;padding:0 var(--pad);list-style:none} .sin .stops li{padding:12px 0;border-bottom:1px solid #efece6;font-size:13px;line-height:1.75} .sin .stops b{font-weight:500} .sin .stops span{display:block;color:var(--sub);font-size:11.5px} .sin .out{display:flex;margin:18px var(--pad) 0;height:46px;align-items:center; justify-content:center;border:1px solid var(--ink);font-size:13px} .itemcard{display:block;width:100%;padding:0;border:0;background:none;font:inherit; color:inherit;text-align:left;cursor:pointer} .itemrow{display:flex;align-items:baseline;justify-content:space-between;gap:14px;width:100%; padding:12px 0;border:0;border-bottom:1px solid #efece6;background:none;font:inherit; font-size:13.5px;color:inherit;text-align:left;cursor:pointer} .itemrow .r{flex:0 0 auto;color:var(--sub);font-size:11.5px} /* 엽서 쓰기 — 모양은 사이트의 엽서 카드를 그대로 베낀다(inject.js 主). 캔버스로 저장한다 */ .pm canvas{width:100%;aspect-ratio:1/1;background:var(--tint);border:1px solid var(--line)} .pm .thumbs{display:flex;gap:8px;overflow-x:auto;padding:12px 0 4px;scrollbar-width:none} .pm .thumbs::-webkit-scrollbar{display:none} .pm .thumbs button{flex:0 0 auto;width:64px;height:48px;padding:0;border:1px solid var(--line); background:none;cursor:pointer;overflow:hidden} .pm .thumbs button[aria-current="true"]{outline:2px solid var(--ink);outline-offset:-2px} .pm .thumbs img{width:100%;height:100%} .tabrow{display:flex;gap:8px;overflow-x:auto;margin:-4px 0 16px;padding-bottom:4px; scrollbar-width:none} .tabrow::-webkit-scrollbar{display:none} .tabrow button{flex:0 0 auto;height:34px;padding:0 13px;border:1px solid var(--line); background:none;font:inherit;font-size:12.5px;color:var(--sub);cursor:pointer} .tabrow button[aria-current="true"]{border-color:var(--ink);background:var(--ink);color:var(--paper)} .tabrow--flush{margin-left:var(--pad);margin-right:var(--pad)} [hidden]{display:none !important} .pm textarea{width:100%;height:76px;margin-top:10px;padding:12px 14px;border:1px solid #ddd9d2; background:var(--paper);font:inherit;font-size:13.5px;line-height:1.7;color:var(--ink);border-radius:0} """ # 히어로 한 줄은 계절·달·날씨를 탄다 — 문구는 payload 가 가진 100개에서 서버가 골라 심는다. JS = """ (function(){ var P = window.__STAY6__ || {}; /* ① 히어로 문구 — 고정 소문구는 그대로 두고 그 아래 줄만 바뀐다(/s/stay HeroCatchphrase 와 같은 구성) */ var cp = document.getElementById('cp'); if (cp) { var list = JSON.parse(cp.dataset.list || '[]'), i = 0; if (list.length > 1) setInterval(function(){ i = (i + 1) % list.length; cp.style.opacity = 0; setTimeout(function(){ cp.textContent = list[i]; cp.style.opacity = .78; }, 500); }, 6000); } /* ② 카로셀 — 렌더러와 같은 embla. 없으면 좌우 스크롤로 남는다(기능이 사라지지 않는다) */ if (window.EmblaCarousel) { [].forEach.call(document.querySelectorAll('[data-car]'), function(root){ var api = EmblaCarousel(root.querySelector('.vp'), {loop: true, align: 'start'}); var dots = root.querySelector('.dots'), count = root.querySelector('.count'); var snaps = api.scrollSnapList(), n = snaps.length; if (dots) snaps.forEach(function(_, k){ var b = document.createElement('button'); b.type = 'button'; b.setAttribute('aria-label', (k + 1) + '번째 사진'); b.addEventListener('click', function(){ api.scrollTo(k); }); dots.appendChild(b); }); function paint(){ var k = api.selectedScrollSnap(); if (dots) [].forEach.call(dots.children, function(b, j){ b.className = j === k ? 'on' : ''; }); if (count) count.textContent = (k + 1) + ' / ' + n; } api.on('select', paint); paint(); var prev = root.querySelector('.prev'), next = root.querySelector('.next'); if (prev) prev.addEventListener('click', function(){ api.scrollPrev(); }); if (next) next.addEventListener('click', function(){ api.scrollNext(); }); if (root.dataset.car === 'auto') { var timer = setInterval(function(){ if (!document.hidden) api.scrollNext(); }, 6000); root.addEventListener('pointerdown', function(){ clearInterval(timer); }, {once: true}); } }); } /* ③ 아이템 모달 — 카드/줄을 누르면 그 자리에서 상세가 열린다 */ var sh = document.getElementById('sheet'); if (sh) { var box = sh.querySelector('.sin'), keep = 0; function open(id){ var tpl = document.getElementById(id); if (!tpl) return; box.innerHTML = tpl.innerHTML; keep = window.scrollY; sh.hidden = false; document.body.style.position = 'fixed'; document.body.style.top = (-keep) + 'px'; document.body.style.width = '100%'; sh.querySelector('.sbox').scrollTop = 0; } function close(){ sh.hidden = true; document.body.style.position = ''; document.body.style.top = ''; document.body.style.width = ''; window.scrollTo(0, keep); } document.addEventListener('click', function(ev){ var hit = ev.target.closest && ev.target.closest('[data-modal]'); if (hit) { open(hit.dataset.modal); return; } if (ev.target.closest('.sbg') || ev.target.closest('.sx')) close(); }); document.addEventListener('keydown', function(ev){ if (ev.key === 'Escape') close(); }); } /* ④ 오늘의 날씨 — 문구를 20초마다 갈아 끼운다(/s/stay 와 같은 박자). 한 줄만 박아 두면 같은 날 두 번 들어온 손님에게 어제와 같은 화면이 된다. */ var wn = document.getElementById('wnote'), wlist = P.weather || []; if (wn && wlist.length > 1) { var wi = 0; setInterval(function(){ if (document.hidden) return; wi = (wi + 1) % wlist.length; wn.style.opacity = 0; setTimeout(function(){ wn.textContent = wlist[wi]; wn.style.opacity = 1; }, 400); }, 20000); } /* ⑤ 탭 — 계절 · 기간 · 걸어서 몇 분. /s/stay 가 같은 자리에서 같은 값으로 가른다 */ [].forEach.call(document.querySelectorAll('[data-filter]'), function(row){ var box = document.querySelector(row.dataset.filter); if (!box) return; row.addEventListener('click', function(ev){ var hit = ev.target.closest('button[data-v]'); if (!hit) return; [].forEach.call(row.querySelectorAll('button'), function(b){ b.setAttribute('aria-current', b === hit ? 'true' : 'false'); }); var want = hit.dataset.v; [].forEach.call(box.querySelectorAll('[data-tag]'), function(item){ item.hidden = !!want && (item.dataset.tag || '') !== want; }); var fold = box.querySelector('details'); if (fold) fold.open = !!want; }); }); /* ⑥ 엽서 쓰기 — 사진 고르고 한 마디 적으면 엽서가 된다. 저장은 캔버스 toBlob 이다. ★ 사진은 우리 오리진(/s/<슬러그>/img/…)에서 받는다 — 남의 도메인 사진을 캔버스에 그리면 오염돼 toBlob 이 막힌다(실측 2026-09-15, 발행 사이트 전부에서 저장이 죽어 있었다). */ var pm = document.getElementById('pm'); if (pm && P.postcard) { var cv = pm.querySelector('canvas'), ctx = cv.getContext('2d'); var ta = pm.querySelector('textarea'); var photos = P.postcard.photos || [], cur = 0; var SERIF = "'Noto Serif KR','AppleMyungjo',serif", INK = '#1f1d19', ACC = '#8c4a2f'; var cache = {}; function img(src){ if (cache[src]) return Promise.resolve(cache[src]); return new Promise(function(ok, no){ var im = new Image(); im.onload = function(){ cache[src] = im; ok(im); }; im.onerror = no; im.src = src; }); } function wrap(text, width){ var out = [], line = ''; text.split('').forEach(function(ch){ if (ctx.measureText(line + ch).width > width && line) { out.push(line); line = ch; } else line += ch; }); if (line) out.push(line); return out; } function draw(){ var size = 1080, margin = 64; ctx.fillStyle = '#fdfcfa'; ctx.fillRect(0, 0, size, size); if (!photos.length) return; img(photos[cur].url).then(function(im){ var pw = size - margin * 2, ph = pw / 1.5; var scale = Math.max(pw / im.width, ph / im.height); var w = im.width * scale, h = im.height * scale; ctx.save(); ctx.beginPath(); ctx.rect(margin, margin, pw, ph); ctx.clip(); ctx.drawImage(im, margin + (pw - w) / 2, margin + (ph - h) / 2, w, h); ctx.restore(); ctx.strokeStyle = 'rgba(31,29,25,.25)'; ctx.lineWidth = 1; ctx.strokeRect(margin + .5, margin + .5, pw - 1, ph - 1); var bodyY = margin + ph + 36, stampW = 190; var divX = size - margin - stampW, textW = divX - margin - 24; ctx.strokeStyle = 'rgba(31,29,25,.35)'; ctx.beginPath(); ctx.moveTo(divX, bodyY); ctx.lineTo(divX, size - margin - 46); ctx.stroke(); ctx.fillStyle = INK; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; ctx.font = '400 46px ' + SERIF; var lines = wrap('\u201c' + (ta.value.trim() || '오늘, 이 숙소에서 하루를 보냅니다.') + '\u201d', textW).slice(0, 4); lines.forEach(function(line, i){ ctx.fillText(line, margin, bodyY + 50 + i * 60); }); var cx = divX + stampW / 2; ctx.strokeStyle = INK; ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]); ctx.strokeRect(cx - 44, bodyY, 88, 108); ctx.setLineDash([]); ctx.textAlign = 'center'; ctx.font = '400 20px ' + SERIF; ctx.fillStyle = INK; ctx.fillText('\u90f5\u7968', cx, bodyY + 46); ctx.fillText('10원', cx, bodyY + 78); ctx.save(); ctx.translate(cx, bodyY + 210); ctx.rotate(-6 * Math.PI / 180); ctx.strokeStyle = ACC; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 0, 62, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = ACC; ctx.font = '400 22px ' + SERIF; ctx.fillText(P.postcard.name, 0, 8); ctx.restore(); var footY = size - margin - 30; ctx.strokeStyle = 'rgba(31,29,25,.3)'; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(margin, footY - 20); ctx.lineTo(size - margin, footY - 20); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(31,29,25,.6)'; ctx.font = '400 22px ' + SERIF; ctx.textAlign = 'left'; ctx.fillText(P.postcard.name + ' \u00b7 ' + P.postcard.region, margin, footY + 6); }); } [].forEach.call(pm.querySelectorAll('.thumbs button'), function(b, k){ b.addEventListener('click', function(){ cur = k; [].forEach.call(pm.querySelectorAll('.thumbs button'), function(x, j){ x.setAttribute('aria-current', j === k ? 'true' : 'false'); }); draw(); }); }); ta.addEventListener('input', draw); pm.querySelector('.save').addEventListener('click', function(){ cv.toBlob(function(blob){ var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = '엽서-' + P.postcard.name + '.png'; a.click(); setTimeout(function(){ URL.revokeObjectURL(a.href); }, 4000); }, 'image/png'); }); var shareBtn = pm.querySelector('.share'); if (navigator.canShare) { shareBtn.addEventListener('click', function(){ cv.toBlob(function(blob){ var file = new File([blob], 'postcard.png', {type: 'image/png'}); if (navigator.canShare({files: [file]})) navigator.share({files: [file], title: P.postcard.name}); }, 'image/png'); }); } else { shareBtn.remove(); } draw(); } /* ⑦ 헤더 미니 플레이어 — 시작 곡만 무작위, 그다음은 순차(/s/stay 규칙). 트는 것은 숙소가 만든 곡(ownSongs)뿐이다 — 가요 다방은 우리 음원이 아니다. */ var mp = document.getElementById('mp'), sheet = document.getElementById('mpsheet'); var songs = (P.songs || []).filter(function(s){ return s.audioUrl; }); if (mp && songs.length) { var audio = new Audio(), cur = Math.floor(Math.random() * songs.length), playing = false; var now = mp.querySelector('.now'); audio.preload = 'none'; function show(){ now.textContent = songs[cur].title; } function play(){ audio.play().then(function(){ playing = true; mp.classList.add('on'); }).catch(function(){}); } function load(k){ cur = k; audio.src = songs[cur].audioUrl; show(); play(); } show(); audio.addEventListener('ended', function(){ load((cur + 1) % songs.length); }); mp.querySelector('.cas').addEventListener('click', function(){ if (!audio.src) { load(cur); return; } if (playing) { audio.pause(); playing = false; mp.classList.remove('on'); } else play(); }); mp.querySelector('.lst').addEventListener('click', function(){ sheet.classList.toggle('on'); }); [].forEach.call(sheet.querySelectorAll('button[data-k]'), function(b){ b.addEventListener('click', function(){ load(+b.dataset.k); sheet.classList.remove('on'); }); }); } })(); """ # ── 껍데기 ─────────────────────────────────────────────────────────────────── # 아이콘은 /s/stay 주입분(inject.js)의 것을 그대로 쓴다 — 카세트여야 '노래' 로 읽힌다 # (2026-09-10 사장님: "전혀 뮤직플레이어 같지가 않아"). ICON_CASSETTE = ( '' '' '' '' '') ICON_LIST = ( '' '' '') ICON_PHONE = ( '') class Sheets: """아이템을 누르면 그 자리에서 열리는 상세. 네이버로 튕겨 보내면 손님이 사이트를 떠난다.""" def __init__(self): self.rows = [] def add(self, html) -> str: sid = "m%d" % (len(self.rows) + 1) self.rows.append('' % (sid, html)) return sid def render(self) -> str: return "".join(self.rows) def sheet_body(title, meta, paras, image=None, out=None, extra=""): parts = [] if image: parts.append('%s' % (url(image), e(title))) parts.append('

%s

' % e(title)) if meta: parts.append('

%s

' % e(meta)) for para in paras: if para: parts.append('

%s

' % e(para)) parts.append(extra) if out: parts.append('%s' % (e(out[1]), e(out[0]))) return "".join(parts) PAGE_LABEL = {"": "펜션 소개", "gunsan": "군산 소개", "booking": "이용안내 · 예약"} BOOL_FACT_LABEL = {"parking": "주차", "wifi": "와이파이", "cooking_allowed": "취사", "pet_allowed": "반려동물 동반", "smoking": "흡연", "bbq_available": "바비큐", "pickup_service": "픽업 서비스"} def ld_blocks(data, slug): """/s/stay 가 내는 다섯 덩이를 같은 모양으로 낸다 — LodgingBusiness · WebSite · WebPage · BreadcrumbList · FAQPage. 이게 없으면 사람 눈에는 같은 화면인데 **검색·AI 가 읽는 값이 0** 이다 (이 레포가 하려는 일 자체다). 값은 payload 에 있는 것만 쓴다 — 없는 건 키를 안 낸다.""" place, narrative = data["place"], data["narrative"] url_of = ORIGIN + BASE + ("/" + slug if slug else "") hero = next((m for m in data["media"] if m.get("isPrimary")), data["media"][0]) amenities = [] for f in data["facts"]: label = BOOL_FACT_LABEL.get(f["key"]) if label and f.get("type") == "bool": amenities.append({"@type": "LocationFeatureSpecification", "name": label, "value": f["value"] == "true"}) rooms = [] for unit in data["units"]: room = {"@type": "HotelRoom", "name": unit_name(unit) + " · " + unit_note(unit), "description": unit_fact(unit, "room_intro"), "bed": unit_fact(unit, "bed_type"), "occupancy": {"@type": "QuantitativeValue", "value": int(unit_fact(unit, "standard_capacity") or 0), "maxValue": int(unit_fact(unit, "max_capacity") or 0), "unitCode": "C62"}} size = unit_fact(unit, "room_size") if size: room["floorSize"] = {"@type": "QuantitativeValue", "value": float(size), "unitCode": "MTK"} rooms.append(room) lodging = { "@context": "https://schema.org", "@type": ["LodgingBusiness", "LocalBusiness", "Organization"], "@id": ORIGIN + BASE + "#lodging", "name": place["name"], "url": ORIGIN + BASE, "description": narrative.get("summary"), "telephone": place["phone"], "address": {"@type": "PostalAddress", "addressCountry": "KR", "addressRegion": place["addressRegion"], "addressLocality": place["addressLocality"], "streetAddress": place["roadAddress"]}, "geo": {"@type": "GeoCoordinates", "latitude": place["latitude"], "longitude": place["longitude"]}, "image": [ORIGIN + url(m["url"]) for m in data["media"][:6]], "priceRange": unit_fact(data["units"][0], "price_range"), "checkinTime": fact(data, "check_in_time"), "checkoutTime": fact(data, "check_out_time"), "amenityFeature": amenities, "sameAs": [l["url"] for l in data["links"]], "containsPlace": rooms, } website = {"@context": "https://schema.org", "@type": "WebSite", "@id": ORIGIN + BASE + "#website", "name": place["name"], "url": ORIGIN + BASE, "inLanguage": "ko-KR", "publisher": {"@id": ORIGIN + BASE + "#lodging"}} webpage = {"@context": "https://schema.org", "@type": "WebPage", "@id": url_of + "#webpage", "url": url_of, "name": place["name"] + (" · " + PAGE_LABEL[slug] if slug else ""), "inLanguage": "ko-KR", "isPartOf": {"@id": ORIGIN + BASE + "#website"}, "about": {"@id": ORIGIN + BASE + "#lodging"}, "publisher": {"@id": ORIGIN + BASE + "#lodging"}, "datePublished": data["site"].get("publishedAt"), "dateModified": data["site"].get("updatedAt"), "speakable": {"@type": "SpeakableSpecification", "cssSelector": [".lead", ".today p"]}} crumbs = [{"@type": "ListItem", "position": 1, "name": place["name"], "item": ORIGIN + BASE}] if slug: crumbs.append({"@type": "ListItem", "position": 2, "name": PAGE_LABEL[slug], "item": url_of}) breadcrumb = {"@context": "https://schema.org", "@type": "BreadcrumbList", "@id": url_of + "#breadcrumb", "itemListElement": crumbs} blocks = [lodging, website, webpage, breadcrumb] # FAQPage 는 **질문이 실제로 그려진 페이지에만** 낸다 — 화면에 없는 것을 구조화 데이터로만 # 내면 구글이 '숨은 콘텐츠' 로 본다. if slug == "booking": blocks.append({"@context": "https://schema.org", "@type": "FAQPage", "@id": url_of + "#faq", "mainEntity": [{"@type": "Question", "name": f["question"], "acceptedAnswer": {"@type": "Answer", "text": f["answer"]}} for f in data["faqs"]]}) return "".join('' % json.dumps(b, ensure_ascii=False, separators=(",", ":")).replace("' % (k, e(v)) for k, v in rows) out += '' return out def llms_txt(data): """AI 가 읽는 사실 목록. /s/stay 가 내는 것과 같은 성격이되 **세 페이지 구조**를 적는다.""" place, narrative = data["place"], data["narrative"] lines = ["# " + place["name"], "", "이 문서는 %s의 공식 홈페이지가 제공하는 사실 목록입니다. 확인되지 않은 정보는 담지 않습니다." % place["name"], "", "- 공식 홈페이지: " + ORIGIN + BASE, "- 업종 분류: LodgingBusiness", "- 최종 확인: " + str(data["site"].get("updatedAt") or ""), "", "## 기본 정보", "", "- 상호: " + place["name"], "- 주소: " + place["roadAddress"], "- 전화: " + place["phone"], "- 좌표: %s, %s" % (place["latitude"], place["longitude"]), "- 체크인: %s · 체크아웃: %s" % (fact(data, "check_in_time"), fact(data, "check_out_time")), "- 요금: " + unit_fact(data["units"][0], "price_range"), "- 정원: 기준 2인 · 최대 4인 (인원 추가 %s원)" % f'{int(re.sub(r"[^0-9]", "", fact(data, "extra_person_fee") or "0")):,}', "", "## 소개", ""] lines += [x for x in narrative["about"]] lines += ["", "## 객실", ""] for unit in data["units"]: lines.append("### " + unit_name(unit) + " · " + unit_note(unit)) lines.append("- " + unit_fact(unit, "room_intro")) lines.append("- 침대: " + unit_fact(unit, "bed_type")) if unit_fact(unit, "room_size"): lines.append("- 면적: " + unit_fact(unit, "room_size") + "m²") lines.append("- 시설: " + unit_fact(unit, "room_facilities")) lines.append("") lines += ["## 페이지", "", "- %s — 펜션 소개 · 객실 · 사진 · 영상 · 소식" % (ORIGIN + BASE), "- %s/gunsan — 주변 명소 12곳 · 주변 맛집 %d곳 · 계절별 축제 %d · 추천 일정 %d · 군산 이야기" % (ORIGIN + BASE, len(data["local"]["restaurants"]), len(data["local"]["festivals"]), len(section(data, "itinerary"))), "- %s/booking — 객실 상세 · 예약 · 기본 정보 · 이용 규정 · 자주 묻는 질문 %d" % (ORIGIN + BASE, len(data["faqs"])), "", "## 자주 묻는 질문", ""] for f in data["faqs"]: lines += ["### " + f["question"], f["answer"], ""] lines += ["## 공식 채널", ""] + ["- %s: %s" % (l["title"], l["url"]) for l in data["links"]] return "\n".join(lines) + "\n" def tabs(slug): out = [] for path, label in PAGES: href = BASE + ("/" + path if path else "") cls = ' class="on"' if path == slug else "" out.append('%s' % (href, cls, e(label))) return '' % "".join(out) def carousel(shots, ratio="4 / 3", auto=True): """렌더러와 같은 embla 를 쓴다. 스크립트가 안 떠도 좌우로 스크롤되는 판으로 남는다.""" slides = "".join('
%s
' % pic(m, ratio=ratio) for m in shots) return ('
%s
' '' '' '

' % ("auto" if auto else "manual", slides)) def tabrow(target, buckets, total_label="전체", flush=False): """탭 한 줄 — 값과 개수를 같이 낸다. 개수가 없으면 탭을 세우지 않는다(빈 탭이 선다).""" rows = ['' % (e(total_label), sum(n for _, n in buckets))] for label, count in buckets: if count: rows.append('' % (e(label[0]), e(label[1]), count)) return '
%s
' % ( " tabrow--flush" if flush else "", e(target), "".join(rows)) def unit_block(data, unit, sheets, eyebrow): """객실 한 동 — 사진 카로셀 + 확인된 fact 전부. ★ 동마다 예약 버튼을 달지 않는다(2026-09-04 사장님: "예약 버튼이 너무 많다"). 두 동이 같은 예약 창구로 가므로 버튼만 늘어난다 — 하단 고정 바가 그 몫을 한다.""" shots = media_of(data, unit) size = unit_fact(unit, "room_size") facils = [f.strip() for f in (unit_fact(unit, "room_facilities") or "").split("·") if f.strip()] label = "일본식" if unit["slug"].startswith("a") else "모던" spec = [("침대", unit_fact(unit, "bed_type") + (" · " + size + "m²" if size else "")), ("정원", "기준 %s인 · 최대 %s인" % (unit_fact(unit, "standard_capacity"), unit_fact(unit, "max_capacity"))), ("요금", unit_fact(unit, "price_range"))] sid = sheets.add(sheet_body( "%s · %s" % (unit_name(unit), label), unit_note(unit), [unit_fact(unit, "room_intro"), "%s · %s%s · 기준 %s인, 최대 %s인" % ( unit_fact(unit, "room_type"), unit_fact(unit, "bed_type"), " · " + size + "m²" if size else "", unit_fact(unit, "standard_capacity"), unit_fact(unit, "max_capacity")), "시설 — " + " · ".join(facils), "요금은 날짜와 시즌에 따라 %s입니다. 기준 %s인, 최대 %s인이며 인원 추가 %s원입니다." % (unit_fact(unit, "price_range"), unit_fact(unit, "standard_capacity"), unit_fact(unit, "max_capacity"), f'{int(re.sub(r"[^0-9]", "", fact(data, "extra_person_fee") or "0")):,}')], image=shots[0]["url"] if shots else None)) return """

%(eyebrow)s

%(name)s %(label)s · %(note)s

%(car)s

%(intro)s

%(spec)s
%(pills)s
""" % { "slug": e(unit["slug"]), "eyebrow": e(eyebrow), "name": e(unit_name(unit)), "label": e(label), "note": e(unit_note(unit)), "car": carousel(shots or data["media"][:1], ratio="3 / 2"), "intro": e(unit_fact(unit, "room_intro")), "spec": "".join("
%s
%s
" % (e(k), e(v)) for k, v in spec if v), "pills": "".join('%s' % e(f) for f in facils), "sid": sid, } def bar(data): book = next((l["url"] for l in data["links"] if l.get("channel") == 7), "#") return ('
전화' '예약하기
' % (e(data["place"]["phone"]), e(book))) def player(songs): if not songs: return "", "", [] mini = ('' '' '' '' % (ICON_CASSETTE, ICON_LIST)) rows = "".join('' % (i, e(s["title"]), e(s.get("artist"))) for i, s in enumerate(songs)) return mini, '
%s
' % rows, songs def stay6_state(place, songs, wnotes, pm_photos): """날씨는 엽서(pm_photos)가 있는 쪽에만 실리고 있었다 — 홈은 pm_photos 가 없어서 __STAY6__.weather 가 통째로 빠졌고, 그래서 홈의 '오늘의 날씨' 가 회전하지 않았다.""" state = {"songs": songs} if wnotes: state["weather"] = wnotes if pm_photos: state["postcard"] = {"photos": pm_photos, "name": place["name"], "region": place["addressRegion"] + " " + place["addressLocality"]} return state def shell(data, slug, title, desc, body, sheets="", pm_photos=None, wnotes=None): place = data["place"] canonical = ORIGIN + BASE + ("/" + slug if slug else "") own = [{"title": s["title"], "artist": s.get("artist"), "audioUrl": url(s.get("audioUrl"))} for s in ((data["narrative"].get("ownSongs") or {}).get("items") or []) if s.get("audioUrl")] mini, mpsheet, songs = player(own) return """ %s %s %s
%s %s %s
%s %s
%s
%s %s """ % ( e(title), e(desc), e(canonical), og_tags(data, slug, title, desc), ld_blocks(data, slug), CSS, BASE, e(place["name"]), mini, e(place["phone"]), ICON_PHONE, mpsheet, tabs(slug), body, e(place["name"]), e(place["roadAddress"]), e(fact(data, "check_in_time")), e(fact(data, "check_out_time")), e(place["phone"]), e(place["phone"]), e(next((l["url"] for l in data["links"] if l.get("channel") == 4), "#")), bar(data), sheets, BASE, json.dumps(stay6_state(place, songs, wnotes, pm_photos), ensure_ascii=False), JS, ) # ── 페이지 1 · 펜션 소개 ────────────────────────────────────────────────────── def catchphrases(data): """회전하는 줄은 **일반 40개**다 — /s/stay 의 HeroCatchphrase 가 general 만 돌린다. 계절·달·날씨 문구는 그 아래 '오늘' 띠에서 쓴다.""" items = ((data["narrative"].get("catchphrases") or {}).get("items")) or [] rows = [i["text"] for i in items if (i.get("kind") or "general") == "general"] return rows or [data["narrative"].get("tagline") or ""] def home(data): place = data["place"] sheets = Sheets() by_file = {m["url"].rsplit("/", 1)[-1]: m for m in data["media"]} outside = [m for m in data["media"] if m.get("category") == "외관"] hero = outside[0] if outside else data["media"][0] stage = [by_file[f] for f in ("h-02.jpg", "aa69a633f8e3a631.jpg", "h-01.jpg", "a-06.jpg") if f in by_file] or [hero] cps = catchphrases(data) weather = data["local"].get("weather") or {} wnotes = (weather.get("noteSets") or {}).get(weather.get("condition") or "") or [] figure = by_file.get("h-04.jpg") or hero facilities = [f.strip() for f in (unit_fact(data["units"][0], "room_facilities") or "").split("·")] pills = ["독채 · 한 팀만", "창고형 카페"] pills += [f for f in facilities if f in ("벽난로", "욕조", "주방", "테라스", "OTT")] if fact(data, "parking") == "true": pills.append("주차 가능") if fact(data, "wifi") == "true": pills.append("와이파이") shots = data["media"] def shot_button(m, ratio="4 / 3"): sid = sheets.add(sheet_body(m.get("alt") or place["name"], m.get("category") or "", [], image=m["url"])) return ('' % (sid, pic(m, ratio=ratio))) films = [] for item in section(data, "video"): vid = item["url"].rstrip("/").split("/")[-1].split("?")[0] films.append( '' '' '

%s

' % (e(item["url"]), e(vid), e(vid), e(item.get("caption")))) posts = data.get("posts") or [] def post_block(row): return ('
' '

%s

' '

%s

' % (e(row["publishedAt"][:10].replace("-", ". ")), e(row["body"]))) near = [] for spot in data["local"]["attractions"][:5]: sid = sheets.add(sheet_body(spot["name"], "%s · %s" % (spot["distanceText"], spot["category"]), [spot.get("description")], image=spot.get("imageUrl"), out=("네이버에서 보기", naver(spot.get("searchQuery") or spot["name"])))) near.append('' % (sid, url(spot.get("imageUrl")), e(spot["name"]), e(spot["name"]), e(spot["distanceText"]))) body = """
%(stage)s

%(locality)s

%(name)s

%(tagline)s

%(cp0)s

오늘 %(temp)s° %(cond)s — %(wline)s

THE HOUSE

스테이,머뭄 소개

%(summary)s

%(about0)s
더 읽기%(about1)s
%(pills)s
%(fig)s
%(figcap)s
%(unitA)s %(unitB)s

GALLERY

사진 갤러리 %(shots)d장

%(g1)s
사진 %(rest)d장 더 보기
%(g2)s

FILM

ADO2 영상 보기 %(films)d편

%(filmrail)s

JOURNAL

스테이,머뭄 소식

%(post0)s
지난 소식 %(npost)d개%(post1)s

TODAY

오늘의 날씨

%(temp)s° %(cond)s

%(wline)s

관측 %(wat)s · open-meteo

AROUND

주변 안내

군산 소개 →
%(near)s

RESERVATION

이용안내 및 예약

네이버 예약 이용안내 · 예약 요청

요금은 날짜와 시즌에 따라 %(price)s입니다. 기준 2인 · 최대 4인(추가 %(extra)s원).

""" % { "stage": "".join('
%s' '
' % (url(m["url"]), e(m.get("alt")), "" if i == 0 else ' loading="lazy"') for i, m in enumerate(stage)), "locality": e(place["addressLocality"]), "name": e(place["name"]), "tagline": e(data["narrative"].get("tagline")), "cps": e(json.dumps(cps, ensure_ascii=False)), "cp0": e(cps[0]), "addr": e(place["roadAddress"]), "temp": e(weather.get("temperature")), "cond": e(weather.get("condition")), "wline": e(wnotes[0] if wnotes else ""), "wat": e((weather.get("observedAt") or "").replace("T", " ")), "summary": e(data["narrative"].get("summary")), "about0": "

%s

" % e(data["narrative"]["about"][0]), "about1": "".join("

%s

" % e(x) for x in data["narrative"]["about"][1:]), "pills": "".join('%s' % e(x) for x in pills), "fig": '%s' % (url(figure["url"]), e(figure.get("alt"))), "figcap": e(figure.get("alt")), "unitA": unit_block(data, data["units"][0], sheets, "ROOM A"), "unitB": unit_block(data, data["units"][1], sheets, "ROOM B"), "shots": len(shots), "rest": max(0, len(shots) - 4), "g1": "".join(shot_button(m) for m in shots[:4]), "g2": "".join(shot_button(m) for m in shots[4:]), "films": len(films), "filmrail": "".join(films), "post0": "".join(post_block(x) for x in posts[:1]), "post1": "".join(post_block(x) for x in posts[1:]), "npost": max(0, len(posts) - 1), "near": "".join(near), "base": BASE, "book": e(next((l["url"] for l in data["links"] if l.get("channel") == 7), "#")), "price": e(unit_fact(data["units"][0], "price_range")), "extra": f'{int(re.sub(r"[^0-9]", "", fact(data, "extra_person_fee") or "0")):,}', } return body, sheets, None, wnotes PAGEHEAD = ('
' '

%s

' '

%s

') # ── 페이지 2 · 군산 소개 ────────────────────────────────────────────────────── def gunsan(data): local = data["local"] sheets = Sheets() weather = local.get("weather") or {} condition = weather.get("condition") or "" notes = (weather.get("noteSets") or {}).get(condition) or [] def place_card(row, ratio="3 / 2"): sid = sheets.add(sheet_body(row["name"], "%s · %s" % (row["distanceText"], row["category"]), [row.get("description")], image=row.get("imageUrl"), out=("네이버에서 보기", naver(row.get("searchQuery") or row["name"])))) return ('' % (sid, walk_bucket(row["distanceText"]), url(row.get("imageUrl")), e(row["name"]), ratio, e(row["name"]), e(row["distanceText"]), e(row.get("description")))) def place_row(row): sid = sheets.add(sheet_body(row["name"], "%s · %s" % (row["distanceText"], row["category"]), [row.get("description")], image=row.get("imageUrl"), out=("네이버에서 보기", naver(row.get("searchQuery") or row["name"])))) return ('' % (sid, walk_bucket(row["distanceText"]), e(row["name"]), e(row["distanceText"]))) def fest_card(row): sid = sheets.add(sheet_body(row["name"], "%s · %s" % (row.get("period") or row.get("month"), row.get("location")), [row.get("description")], image=row.get("imageUrl"), out=("네이버에서 보기", naver(row.get("searchQuery") or row["name"])))) shot = ('%s' % (url(row["imageUrl"]), e(row["name"]))) if row.get("imageUrl") else ( '%s' % e(row.get("season") or "")) return ('' % (sid, e(row.get("season") or ""), shot, e(row["name"]), e(row.get("month")), e(row.get("location")))) def plan_card(row): stops = [] for day in row.get("days") or []: stops.append('
  • %s %s 시작
  • ' % (e(day.get("label")), e(day.get("startTime")))) for stop in day.get("stops") or []: query = stop.get("searchQuery") or stop["name"].split("·")[-1].strip() stops.append('
  • %s ' '%s분%s
  • ' % (e(naver(query)), e(stop["name"]), e(stop.get("minutes")), e(stop.get("note")))) sid = sheets.add(sheet_body(row["name"], "%s · %s" % (row["duration"], row["audience"]), [row["why"]], extra='' % "".join(stops))) first = (row.get("days") or [{}])[0].get("stops") or [] line = " → ".join("%s %s분" % (s["name"].split("·")[-1].strip(), s.get("minutes") or 0) for s in first[:4]) return ('' % (sid, e(row["duration"]), e(row["name"]), e(row["duration"]), e(row["audience"]), e(row["why"]), e(line))) def song_row(row): meta = " / ".join(x for x in (row.get("lyricist"), row.get("composer")) if x) sid = sheets.add(sheet_body( row["title"], " · ".join(str(x) for x in (row.get("artist"), row.get("year"), meta) if x), [row.get("story")], out=("들어보기", row.get("listenUrl") or naver(row["title"])))) return ('' % (sid, e(row["title"]), e(row.get("artist")), e(row.get("year") or ""))) def face_card(row): source = row.get("source") or {} sid = sheets.add(sheet_body(row["name"], " · ".join(str(x) for x in (row.get("years"), row.get("role")) if x), [row.get("oneLine")], image=row.get("imageUrl"), out=((source.get("name") or "출처"), source.get("url") or naver(row["name"])))) return ('' % (sid, url(row.get("imageUrl")), e(row["name"]), e(row["name"]), e(row.get("role")), e(row.get("oneLine")))) def face_row(row): source = row.get("source") or {} sid = sheets.add(sheet_body(row["name"], " · ".join(str(x) for x in (row.get("years"), row.get("role")) if x), [row.get("oneLine")], image=row.get("imageUrl"), out=((source.get("name") or "출처"), source.get("url") or naver(row["name"])))) return ('' % (sid, e(row["name"]), e(row.get("role")), e(row.get("years") or ""))) def chron_row(row): source = row.get("source") or {} sid = sheets.add(sheet_body(row["title"], " · ".join(str(x) for x in (row.get("year"), row.get("place")) if x), [row.get("summary")], image=row.get("imageUrl"), out=((source.get("name") or "출처"), source.get("url") or naver(row["title"])))) return ('' % (sid, e(row["title"]), e(row.get("place") or ""), e(row["year"]))) def read_card(row): source = row.get("source") or {} sid = sheets.add(sheet_body(row["title"], " · ".join(str(x) for x in (row.get("group"), row.get("year")) if x), [row.get("body")], out=((source.get("name") or "출처"), source.get("url") or naver(row["title"])))) return ('' % (sid, e(row["title"]), e(row.get("year") or ""), e(row.get("body")))) pm_photos = [m for m in data["media"] if m.get("category") in ("외관", "카페", "전망")][:6] pm_photos += [{"url": a["imageUrl"], "alt": a["name"]} for a in local["attractions"][:4] if a.get("imageUrl")] pm_thumbs = "".join( '' % ("true" if i == 0 else "false", url(m["url"]), e(m.get("alt"))) for i, m in enumerate(pm_photos)) spots, eats = local["attractions"], local["restaurants"] walks = [walk_bucket(x["distanceText"]) for x in spots] eatwalks = [walk_bucket(x["distanceText"]) for x in eats] fests = sorted(local["festivals"], key=lambda f: (SEASON_ORDER.get(f.get("season"), 9), f.get("month") or "")) plans, songs = section(data, "itinerary"), section(data, "songs") durations = [x["duration"] for x in plans] seasons = [x.get("season") or "" for x in local["festivals"]] temp = weather.get("temperature") bucket = ("혹서" if temp >= 33 else "더움" if temp >= 25 else "선선" if temp >= 15 else "쌀쌀" if temp >= 5 else "추움") if isinstance(temp, (int, float)) else "" tnotes = (weather.get("tempNoteSets") or {}).get(bucket) or [] temp_note = tnotes[0] if tnotes else (weather.get("tempNotes") or {}).get(bucket, "") people = [x for x in section(data, "people") if x.get("imageUrl")] allpeople = section(data, "people") chron, reading = section(data, "chronicle"), section(data, "reading") body = PAGEHEAD % ("군산", "대문에서 걸어 닿는 자리와, 이 도시가 남긴 이야기. 누르면 상세가 열립니다.") body += """
    주변 명소주변 맛집 엽서축제 일정이야기 날씨

    AROUND

    주변 명소 %(nspot)d곳

    %(spottabs)s
    %(spots)s

    EAT

    주변 맛집 %(neat)d곳

    %(eattabs)s
    %(eat1)s
    %(eatrest)d곳 더 보기%(eat2)s

    POSTCARD

    엽서 쓰기

    사진 고르고 한마디 적으면 엽서 완성

    %(pmthumbs)s

    SEASON

    계절별 축제 %(nfest)d

    %(festtabs)s
    %(fests)s

    ITINERARY

    추천 일정 %(nplan)d

    %(daytabs)s
    %(plan1)s
    %(planrest)d개 더 보기%(plan2)s

    GUNSAN

    군산 이야기 노래 · 인물 · 연표 · 읽기

    MUSIC

    가요 다방 %(nsong)d곡

    %(song1)s
    %(songrest)d곡 더 보기%(song2)s

    PEOPLE

    인물 열전 %(npeople)d명

    %(faces)s
    %(prest)d명 더 보기%(prows)s

    사진이 있는 %(nface)d명을 먼저 뒀습니다.

    CHRONICLE

    시간의 골목

    %(chron)s

    READING

    군산 읽기 %(nread)d꼭지

    %(read)s
    %(readrest)d꼭지 더 보기%(read2)s

    TODAY

    오늘의 날씨

    %(temp)s° %(cond)s

    %(wfirst)s

    %(wnotes)s

    관측 %(wat)s · open-meteo

    """ % { "temp": e(weather.get("temperature")), "cond": e(condition), "wfirst": e(notes[0] if notes else ""), "wnotes": "".join('

    %s

    ' % e(x) for x in ([temp_note] if temp_note else [])), "wat": e((weather.get("observedAt") or "").replace("T", " ")), "pmthumbs": pm_thumbs, "spottabs": tabrow("#spotbox", [(("5분", "걸어서 5분 이내"), walks.count("5분")), (("10분", "걸어서 10분 이내"), walks.count("10분")), (("그 밖", "걸어서 10분 이상"), walks.count("그 밖"))]), "eattabs": tabrow("#eatbox", [(("5분", "걸어서 5분 이내"), eatwalks.count("5분")), (("10분", "걸어서 10분 이내"), eatwalks.count("10분")), (("그 밖", "걸어서 10분 이상"), eatwalks.count("그 밖"))]), "festtabs": tabrow("#festbox", [((s, s), seasons.count(s)) for s in SEASON_ORDER], flush=True), "daytabs": tabrow("#daybox", [((d, d), durations.count(d)) for d in ("1박 2일", "2박 3일")]), "nspot": len(spots), "spots": "".join(place_card(x) for x in spots), "neat": len(eats), "eat1": "".join(place_row(x) for x in eats[:8]), "eat2": "".join(place_row(x) for x in eats[8:]), "eatrest": max(0, len(eats) - 8), "nfest": len(fests), "fests": "".join(fest_card(x) for x in fests), "nplan": len(plans), "plan1": "".join(plan_card(x) for x in plans[:4]), "plan2": "".join(plan_card(x) for x in plans[4:]), "planrest": max(0, len(plans) - 4), "nsong": len(songs), "song1": "".join(song_row(x) for x in songs[:6]), "song2": "".join(song_row(x) for x in songs[6:]), "songrest": max(0, len(songs) - 6), "npeople": len(allpeople), "nface": len(people), "faces": "".join(face_card(x) for x in people[:4]), "prest": len(allpeople) - 4, "prows": "".join(face_row(x) for x in (people[4:] + [y for y in allpeople if not y.get("imageUrl")])), "chron": "".join(chron_row(x) for x in chron), "nread": len(reading), "readrest": max(0, len(reading) - 4), "read": "".join(read_card(x) for x in reading[:4]), "read2": "".join(read_card(x) for x in reading[4:]), } return (body, sheets, [{"url": url(m["url"]), "alt": m.get("alt") or ""} for m in pm_photos], notes) # ── 페이지 3 · 이용안내 · 예약 ──────────────────────────────────────────────── BOOL_LABEL = {"true": "가능", "false": "불가"} INFO_KEYS = ["check_in_time", "check_out_time", "parking", "wifi", "cooking_allowed", "pet_allowed", "smoking", "extra_person_fee", "bbq_available", "pickup_service"] def booking(data): place = data["place"] sheets = Sheets() book = next((l["url"] for l in data["links"] if l.get("channel") == 7), "#") info_rows = [] for key in INFO_KEYS: for f in data["facts"]: if f["key"] != key: continue value = f["value"] if f.get("type") == "bool": value = BOOL_LABEL.get(value, value) elif key == "extra_person_fee": value = f'{int(re.sub(r"[^0-9]", "", value or "0")):,}원' info_rows.append('
    %s%s
    ' % (e(f["label"]), e(value))) faqs = "".join('
    %s

    %s

    ' % (e(f["question"]), e(f["answer"])) for f in sorted(data["faqs"], key=lambda f: f.get("sortOrder") or 0)) body = PAGEHEAD % ("이용안내 · 예약", "객실 두 동의 상세와 요금, 예약하는 방법입니다.") body += unit_block(data, data["units"][0], sheets, "ROOM A") body += unit_block(data, data["units"][1], sheets, "ROOM B") body += """

    RESERVATION

    이용안내 및 예약

    네이버 예약 — 바로 확정 %(tel)s

    요금은 날짜와 시즌에 따라 %(price)s입니다. 인원 추가 %(extra)s원.

    INFORMATION

    예약 전 확인

    %(info)s

    이용 규정

    %(cancel)s

    전 구역 금연이며 반려동물은 동반하실 수 없습니다. 주방에서 간단한 조리는 가능하지만, 냄새가 잘 빠지지 않는 조리(생선·고기구이·튀김)는 하실 수 없습니다.

    Q & A

    자주 묻는 질문 %(nfaq)d

    %(faqs)s

    DIRECTIONS

    오시는 길

    주소
    %(road)s
    지번
    %(jibun)s
    주차
    가능 — 골목이 좁아 대문 앞까지는 차가 들어오지 못합니다. 절골길 초입에 세우고 짐만 들고 걸어 들어오세요.
    네이버 지도로 열기

    CHANNELS

    공식 채널

    %(channels)s
    """ % { "book": e(book), "tel": e(place["phone"]), "price": e(unit_fact(data["units"][0], "price_range")), "extra": f'{int(re.sub(r"[^0-9]", "", fact(data, "extra_person_fee") or "0")):,}', "info": "".join(info_rows), "cancel": e(fact(data, "cancel_policy")), "nfaq": len(data["faqs"]), "faqs": faqs, "road": e(place["roadAddress"]), "jibun": e(place["address"]), "map": naver(place["name"] + " " + place["roadAddress"]), "channels": "".join( '%s' '열기 \u2197' % (e(l["url"]), e(l["title"])) for l in data["links"]), } return body, sheets LOCAL = [ ('href="%s/gunsan' % BASE, 'href="gunsan.html'), ('href="%s/booking' % BASE, 'href="booking.html'), ('%s/img/' % BASE, '../../img/'), ('%s/vendor/' % BASE, '../../vendor/'), ('%s/audio/' % BASE, '../../audio/'), ('href="%s"' % BASE, 'href="index.html"'), ] def local_copy(doc: str) -> str: """브라우저에서 바로 열어 보는 판 — 주소를 레포 안 상대경로로 바꾼다. 배포물이 아니다. 사진을 복사하지 않고 mockup/img 를 그대로 가리킨다.""" for src, dst in LOCAL: doc = doc.replace(src, dst) return doc def main(): if not SRC.exists(): raise SystemExit("payload 원본이 없다: %s — 먼저 patch_stay.py 를 돌린다" % SRC) data = payload() name = data["place"]["name"] pages = [ ("", home(data), name, data["narrative"].get("summary")), ("gunsan", gunsan(data), "군산 — " + name, "걸어 닿는 자리 · 먹을 곳 · 계절별 축제 · 추천 하루 · 군산 이야기"), ("booking", booking(data), "이용안내 · 예약 — " + name, "객실 두 동의 상세와 요금, 기본 정보와 자주 묻는 질문."), ] for slug, built, title, desc in pages: body, sheets = built[0], built[1] pm_photos = built[2] if len(built) > 2 else None wnotes = built[3] if len(built) > 3 else None doc = shell(data, slug, title, desc, body, sheets.render(), pm_photos, wnotes) target = (OUT / slug / "index.html") if slug else (OUT / "index.html") target.parent.mkdir(parents=True, exist_ok=True) target.write_text(doc, encoding="utf-8") preview = OUT / "preview" / ((slug or "index") + ".html") preview.parent.mkdir(parents=True, exist_ok=True) preview.write_text(local_copy(doc), encoding="utf-8") print("%-22s %7d자" % (str(target.relative_to(OUT)), len(doc))) (OUT / "llms.txt").write_text(llms_txt(data), encoding="utf-8") print("%-22s %7d자" % ("llms.txt", (OUT / "llms.txt").stat().st_size)) if __name__ == "__main__": main()