GUNSAN
"""`/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'')
# ── 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('%s' % (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('
' % (url(image), e(title)))
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("", "<\\/") for b in blocks) def og_tags(data, slug, title, desc): place = data["place"] hero = next((m for m in data["media"] if m.get("isPrimary")), data["media"][0]) rows = [("og:type", "website"), ("og:site_name", place["name"]), ("og:locale", "ko_KR"), ("og:title", title), ("og:description", desc), ("og:url", ORIGIN + BASE + ("/" + slug if slug else "")), ("og:image", ORIGIN + url(hero["url"])), ("og:image:alt", hero.get("alt") or place["name"])] out = "".join('' % (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('' % pic(m, ratio=ratio) for m in shots) return ('%(eyebrow)s
%(intro)s
'
'%s
' % (e(item["url"]), e(vid), e(vid), e(item.get("caption")))) posts = data.get("posts") or [] def post_block(row): return ('%s
' '%s
%(locality)s
%(tagline)s
%(cp0)s
오늘 %(temp)s° %(cond)s — %(wline)s
THE HOUSE
%(summary)s
%(about0)sGALLERY
FILM
JOURNAL
TODAY
%(temp)s° %(cond)s
%(wline)s
관측 %(wat)s · open-meteo
AROUND
RESERVATION
요금은 날짜와 시즌에 따라 %(price)s입니다. 기준 2인 · 최대 4인(추가 %(extra)s원).
%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
AROUND
EAT
POSTCARD
사진 고르고 한마디 적으면 엽서 완성
SEASON
ITINERARY
GUNSAN
MUSIC
PEOPLE
사진이 있는 %(nface)d명을 먼저 뒀습니다.
CHRONICLE
READING
TODAY
%(temp)s° %(cond)s
%(wfirst)s
%(wnotes)s관측 %(wat)s · open-meteo
%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
RESERVATION
요금은 날짜와 시즌에 따라 %(price)s입니다. 인원 추가 %(extra)s원.
INFORMATION
%(cancel)s
전 구역 금연이며 반려동물은 동반하실 수 없습니다. 주방에서 간단한 조리는 가능하지만, 냄새가 잘 빠지지 않는 조리(생선·고기구이·튀김)는 하실 수 없습니다.
Q & A
DIRECTIONS
CHANNELS