o2o-site-AEO/solution/site/scripts/mockup/build_photo6.py
Mina Choi 5e2def200b [chore] site/mockup: 번들 갱신·문서 정리 · 2안(사진) 빌더 스크립트 추가
- vendor/ 옛 해시 번들을 retired/ 로 옮기고 새 해시로 교체
- build_photo6.py 신설 — /s/stay6 "2안(사진)" 판, 슬러그별로 다시 구울 수 있다
  (python3 build_photo6.py <slug>)
- build_pension.py·build_reading.py·build_stay6.py·patch_stay.py 정리
- AUTOPLAY.md·README.md·audit-all.mjs 갱신
2026-09-23 13:18:51 +09:00

1570 lines
78 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""`/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("</script>", 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'<img src="{url(media["url"])}" alt="{e(alt or media.get("alt"))}" '
f'loading="lazy" class="{cls}" style="aspect-ratio:{ratio}">')
# ── 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 = (
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">'
'<rect x="2" y="5" width="20" height="14" rx="2"/>'
'<g class="w4d-reel"><circle cx="8.5" cy="12" r="2.6"/><path d="M8.5 9.4v5.2M5.9 12h5.2"/></g>'
'<g class="w4d-reel"><circle cx="15.5" cy="12" r="2.6"/><path d="M15.5 9.4v5.2M12.9 12h5.2"/></g>'
'<path d="M6 19l1.5-2.4h9L18 19"/></svg>')
ICON_LIST = (
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">'
'<path d="M4 7h11M4 12h11M4 17h7"/><path d="M19 17.5V9l3 1.2"/>'
'<circle cx="17.4" cy="17.6" r="1.7" fill="currentColor" stroke="none"/></svg>')
ICON_PHONE = (
'<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor"'
' stroke-width="1.6" stroke-linecap="round"><path d="M6 3h3l2 5-2.5 1.5a11 11 0 0 0 5 5L15 12l5 2v3'
'a2 2 0 0 1-2.2 2A16 16 0 0 1 4 5.2 2 2 0 0 1 6 3z"/></svg>')
class Sheets:
"""아이템을 누르면 그 자리에서 열리는 상세. 네이버로 튕겨 보내면 손님이 사이트를 떠난다."""
def __init__(self):
self.rows = []
def add(self, html) -> str:
sid = "m%d" % (len(self.rows) + 1)
self.rows.append('<template id="%s">%s</template>' % (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('<img src="%s" alt="%s" style="aspect-ratio:3 / 2">' % (url(image), e(title)))
parts.append('<h3>%s</h3>' % e(title))
if meta:
parts.append('<p class="meta">%s</p>' % e(meta))
for para in paras:
if para:
parts.append('<p>%s</p>' % e(para))
parts.append(extra)
if out:
parts.append('<a class="out" href="%s" target="_blank" rel="noopener">%s</a>'
% (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('<script type="application/ld+json">%s</script>'
% 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('<meta property="%s" content="%s">' % (k, e(v)) for k, v in rows)
out += '<meta name="twitter:card" content="summary_large_image">'
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") + "")
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('<a href="%s"%s>%s</a>' % (href, cls, e(label)))
return '<nav class="tabs">%s</nav>' % "".join(out)
def carousel(shots, ratio="4 / 3", auto=True):
"""렌더러와 같은 embla 를 쓴다. 스크립트가 안 떠도 좌우로 스크롤되는 판으로 남는다."""
slides = "".join('<div class="slide">%s</div>' % pic(m, ratio=ratio) for m in shots)
return ('<div class="car" data-car="%s"><div class="vp"><div class="track">%s</div></div>'
'<button type="button" class="arrow prev" aria-label="이전 사진">\u2039</button>'
'<button type="button" class="arrow next" aria-label="다음 사진">\u203a</button>'
'<p class="count"></p><div class="dots"></div></div>'
% ("auto" if auto else "manual", slides))
def tabrow(target, buckets, total_label="전체", flush=False):
"""탭 한 줄 — 값과 개수를 같이 낸다. 개수가 없으면 탭을 세우지 않는다(빈 탭이 선다)."""
rows = ['<button type="button" data-v="" aria-current="true">%s %d</button>'
% (e(total_label), sum(n for _, n in buckets))]
for label, count in buckets:
if count:
rows.append('<button type="button" data-v="%s">%s %d</button>'
% (e(label[0]), e(label[1]), count))
return '<div class="tabrow%s" data-filter="%s">%s</div>' % (
" 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 + "" 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 + "" 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 """
<section class="sec sec--flush" id="%(slug)s">
<p class="eyebrow">%(eyebrow)s</p>
<h2>%(name)s <span>%(label)s · %(note)s</span></h2>
%(car)s
<div style="padding:20px var(--pad) 0">
<p style="font-size:13.5px;line-height:1.9">%(intro)s</p>
<dl class="spec">%(spec)s</dl>
<div class="pills" style="margin-top:16px">%(pills)s</div>
<button type="button" class="more" data-modal="%(sid)s">%(name)s 자세히</button>
</div>
</section>""" % {
"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("<dt>%s</dt><dd>%s</dd>" % (e(k), e(v)) for k, v in spec if v),
"pills": "".join('<span class="pill">%s</span>' % 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 ('<div class="bar"><a class="cta ghost" href="tel:%s">전화</a>'
'<a class="cta" href="%s">예약하기</a></div>'
% (e(data["place"]["phone"]), e(book)))
def player(songs):
if not songs:
return "", "", []
mini = ('<span class="mp" id="mp">'
'<button type="button" class="cas" aria-label="음악 재생·멈춤">%s</button>'
'<span class="now"></span>'
'<button type="button" class="lst" aria-label="재생 목록">%s</button></span>'
% (ICON_CASSETTE, ICON_LIST))
rows = "".join('<button type="button" data-k="%d">%s<span class="dim"> · %s</span></button>'
% (i, e(s["title"]), e(s.get("artist"))) for i, s in enumerate(songs))
return mini, '<div class="mpsheet" id="mpsheet">%s</div>' % 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 """<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>%s</title>
<meta name="description" content="%s">
<meta name="robots" content="noindex">
<link rel="canonical" href="%s">
%s
%s
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@300;400&display=swap">
<style>%s</style>
</head>
<body>
<div class="app">
<header class="top">
<a class="brand mj" href="%s">%s</a>
%s
<a class="ico" href="tel:%s" aria-label="전화 걸기">%s</a>
</header>
%s
%s
<main>
%s
</main>
<footer>
%s · %s<br>
체크인 %s · 체크아웃 %s · 전 구역 금연<br>
<a href="tel:%s">%s</a> · <a href="%s">instagram</a>
</footer>
</div>
%s
<div class="sheet" id="sheet" hidden>
<div class="sbg"></div>
<div class="sbox"><button type="button" class="sx" aria-label="닫기">\u00d7</button><div class="sin"></div></div>
</div>
%s
<script src="%s/vendor/embla-carousel.umd.js"></script>
<script>window.__STAY6__=%s;</script>
<script>%s</script>
</body>
</html>""" % (
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 ('<button type="button" class="itemcard" data-modal="%s">%s</button>'
% (sid, pic(m, ratio=ratio)))
films = []
for item in section(data, "video"):
vid = item["url"].rstrip("/").split("/")[-1].split("?")[0]
films.append(
'<a class="w150" href="%s" target="_blank" rel="noopener">'
'<span class="film"><img src="https://i.ytimg.com/vi/%s/oar2.jpg" alt="" loading="lazy"'
' onerror="this.onerror=null;this.src=\'https://i.ytimg.com/vi/%s/hqdefault.jpg\'">'
'<span class="play"></span></span><p class="cap">%s</p></a>'
% (e(item["url"]), e(vid), e(vid), e(item.get("caption"))))
posts = data.get("posts") or []
def post_block(row):
return ('<div style="padding:14px 0;border-bottom:1px solid #efece6">'
'<p class="dim" style="margin:0 0 6px">%s</p>'
'<p style="margin:0;font-size:13.5px;line-height:1.85">%s</p></div>'
% (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('<button type="button" class="itemcard w150" data-modal="%s">'
'<img src="%s" alt="%s" loading="lazy" style="aspect-ratio:3 / 2">'
'<p class="cap">%s</p><p class="cap dim">%s</p></button>'
% (sid, url(spot.get("imageUrl")), e(spot["name"]), e(spot["name"]),
e(spot["distanceText"])))
body = """
<section class="hero">
<div class="car" data-car="auto" style="height:100%%">
<div class="vp"><div class="track">%(stage)s</div></div>
<div class="dots"></div>
</div>
<div class="veil"></div>
<div class="herocopy">
<p class="eyebrow">%(locality)s</p>
<h1 class="mj">%(name)s</h1>
<p class="fixed">%(tagline)s</p>
<p class="cp" id="cp" data-list='%(cps)s'>%(cp0)s</p>
</div>
</section>
<div class="today">
<p style="color:var(--sub);font-size:12.5px">오늘 %(temp)s° %(cond)s%(wline)s</p>
</div>
<section class="sec">
<p class="eyebrow">THE HOUSE</p>
<h2>스테이,머뭄 소개</h2>
<p class="lead">%(summary)s</p>
%(about0)s
<details><summary class="more" style="display:flex;align-items:center;justify-content:center">더 읽기</summary>%(about1)s</details>
<div class="pills" style="margin-top:22px">%(pills)s</div>
</section>
<figure>%(fig)s<figcaption>%(figcap)s</figcaption></figure>
<div class="rule" style="margin-top:40px"></div>
%(unitA)s
%(unitB)s
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">GALLERY</p>
<div class="head"><h2>사진 갤러리 <span>%(shots)d장</span></h2></div>
<div class="grid2 grid2--tight">%(g1)s</div>
<details><summary class="more" style="display:flex;align-items:center;justify-content:center">사진 %(rest)d장 더 보기</summary>
<div class="grid2 grid2--tight" style="margin-top:4px">%(g2)s</div>
</details>
</section>
<div class="rule"></div>
<section class="sec sec--flush">
<p class="eyebrow">FILM</p>
<h2>ADO2 영상 보기 <span>%(films)d편</span></h2>
<div class="rail">%(filmrail)s</div>
</section>
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">JOURNAL</p>
<h2>스테이,머뭄 소식</h2>
%(post0)s
<details><summary class="more" style="display:flex;align-items:center;justify-content:center">지난 소식 %(npost)d개</summary>%(post1)s</details>
</section>
<div class="rule"></div>
<section class="sec" id="weather">
<p class="eyebrow">TODAY</p>
<h2>오늘의 날씨</h2>
<p class="lead" style="margin-bottom:10px">%(temp)s° %(cond)s</p>
<p id="wnote" style="font-size:13.5px;line-height:1.9;min-height:3.8em">%(wline)s</p>
<p class="note" style="margin-top:14px">관측 %(wat)s · open-meteo</p>
</section>
<div class="rule"></div>
<section class="sec sec--flush">
<p class="eyebrow">AROUND</p>
<div class="head" style="padding:0 var(--pad)"><h2>주변 안내</h2><a href="%(base)s/gunsan">군산 소개 →</a></div>
<div class="rail">%(near)s</div>
</section>
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">RESERVATION</p>
<h2>이용안내 및 예약</h2>
<a class="cta" href="%(book)s">네이버 예약</a>
<a class="cta ghost" href="%(base)s/booking">이용안내 · 예약 요청</a>
<p class="note" style="margin-top:12px">요금은 날짜와 시즌에 따라 %(price)s입니다. 기준 2인 · 최대 4인(추가 %(extra)s원).</p>
</section>
""" % {
"stage": "".join('<div class="slide"><img src="%s" alt="%s"%s style="height:100%%">'
'</div>' % (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": "<p>%s</p>" % e(data["narrative"]["about"][0]),
"about1": "".join("<p>%s</p>" % e(x) for x in data["narrative"]["about"][1:]),
"pills": "".join('<span class="pill">%s</span>' % e(x) for x in pills),
"fig": '<img src="%s" alt="%s" loading="lazy" style="aspect-ratio:16 / 9">'
% (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 = ('<section class="sec" style="padding-top:34px">'
'<h1 class="mj" style="margin:0;font-size:24px;letter-spacing:.04em">%s</h1>'
'<p class="note" style="margin:10px 0 0">%s</p></section>')
# ── 페이지 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 ('<button type="button" class="itemcard" data-modal="%s" data-tag="%s">'
'<img src="%s" alt="%s" loading="lazy" style="aspect-ratio:%s">'
'<p class="cap"><b>%s</b> <span class="dim">%s</span></p>'
'<p class="cap dim clamp">%s</p></button>'
% (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 ('<button type="button" class="itemrow" data-modal="%s" data-tag="%s"><span>%s</span>'
'<span class="r">%s</span></button>'
% (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 = ('<img src="%s" alt="%s" loading="lazy" style="aspect-ratio:3 / 2">'
% (url(row["imageUrl"]), e(row["name"]))) if row.get("imageUrl") else (
'<span style="display:grid;place-items:center;aspect-ratio:3 / 2;background:var(--tint);'
'font-size:12px;color:var(--sub)">%s</span>' % e(row.get("season") or ""))
return ('<button type="button" class="itemcard w200" data-modal="%s" data-tag="%s">%s'
'<p class="cap"><b>%s</b></p><p class="cap dim">%s · %s</p></button>'
% (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('<li><b>%s</b> <span>%s 시작</span></li>'
% (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('<li><a href="%s" target="_blank" rel="noopener">%s</a> '
'<span class="r">%s분</span><span>%s</span></li>'
% (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='<ul class="stops">%s</ul>' % "".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 ('<button type="button" class="itemcard" data-modal="%s" data-tag="%s" '
'style="padding:16px 18px;border:1px solid var(--line);margin-bottom:10px">'
'<p style="margin:0 0 4px;font-size:14px;font-weight:500">%s</p>'
'<p class="cap dim" style="margin:0 0 8px">%s · %s</p>'
'<p style="margin:0 0 8px;font-size:13px;line-height:1.8">%s</p>'
'<p class="note" style="margin:0">%s</p></button>'
% (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 ('<button type="button" class="itemrow" data-modal="%s">'
'<span>%s <span class="dim">%s</span></span><span class="r">%s</span></button>'
% (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 ('<button type="button" class="itemcard" data-modal="%s">'
'<img src="%s" alt="%s" loading="lazy" style="aspect-ratio:1 / 1">'
'<p class="cap"><b>%s</b> <span class="dim">%s</span></p>'
'<p class="cap dim clamp">%s</p></button>'
% (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 ('<button type="button" class="itemrow" data-modal="%s">'
'<span>%s <span class="dim">%s</span></span><span class="r">%s</span></button>'
% (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 ('<button type="button" class="itemrow" data-modal="%s">'
'<span>%s<span class="dim" style="display:block">%s</span></span>'
'<span class="r">%s</span></button>'
% (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 ('<button type="button" class="itemcard" data-modal="%s"'
' style="padding:16px 18px;background:var(--soft);margin-bottom:10px">'
'<p style="margin:0 0 6px;font-size:14px"><b class="mj">%s</b> <span class="dim">%s</span></p>'
'<p style="margin:0;font-size:13px;line-height:1.85;color:#4a453a" class="clamp">%s</p></button>'
% (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(
'<button type="button" aria-current="%s"><img src="%s" alt="%s" loading="lazy"></button>'
% ("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 += """
<section class="sec" style="padding-top:20px;padding-bottom:24px"><div class="pills">
<a class="pill" href="#walk">주변 명소</a><a class="pill" href="#eat">주변 맛집</a>
<a class="pill" href="#pm">엽서</a><a class="pill" href="#season">축제</a>
<a class="pill" href="#day">일정</a><a class="pill" href="#story">이야기</a>
<a class="pill" href="#weather">날씨</a>
</div></section>
<div class="rule"></div>
<section class="sec" id="walk">
<p class="eyebrow">AROUND</p>
<h2>주변 명소 <span>%(nspot)d곳</span></h2>
%(spottabs)s
<div class="grid2" id="spotbox">%(spots)s</div>
</section>
<div class="rule"></div>
<section class="sec" id="eat">
<p class="eyebrow">EAT</p>
<h2>주변 맛집 <span>%(neat)d곳</span></h2>
%(eattabs)s
<div id="eatbox">%(eat1)s
<details><summary class="more" style="display:flex;align-items:center;justify-content:center">%(eatrest)d곳 더 보기</summary>%(eat2)s</details>
</div>
</section>
<div class="rule"></div>
<section class="sec pm" id="pm">
<p class="eyebrow">POSTCARD</p>
<h2>엽서 쓰기</h2>
<p class="note" style="margin:-8px 0 14px">사진 고르고 한마디 적으면 엽서 완성</p>
<canvas width="1080" height="1080" aria-label="내가 만든 엽서 미리보기"></canvas>
<div class="thumbs">%(pmthumbs)s</div>
<textarea maxlength="80" placeholder="엽서에 쓸 한 마디를 적어 보세요"></textarea>
<button type="button" class="cta save" style="width:100%%;margin-top:12px;border:0;font:inherit;font-size:14px;cursor:pointer">엽서 저장</button>
<button type="button" class="cta ghost share" style="width:100%%;font:inherit;font-size:14px;cursor:pointer">공유</button>
</section>
<div class="rule"></div>
<section class="sec sec--flush" id="season">
<p class="eyebrow">SEASON</p>
<h2>계절별 축제 <span>%(nfest)d</span></h2>
%(festtabs)s
<div class="rail" id="festbox">%(fests)s</div>
</section>
<div class="rule"></div>
<section class="sec" id="day">
<p class="eyebrow">ITINERARY</p>
<h2>추천 일정 <span>%(nplan)d</span></h2>
%(daytabs)s
<div id="daybox">%(plan1)s
<details><summary class="more" style="display:flex;align-items:center;justify-content:center">%(planrest)d개 더 보기</summary>%(plan2)s</details>
</div>
</section>
<div class="rule"></div>
<section class="sec" id="story" style="padding-bottom:18px">
<p class="eyebrow">GUNSAN</p>
<h2 style="margin-bottom:0">군산 이야기 <span>노래 · 인물 · 연표 · 읽기</span></h2>
</section>
<section class="sec" style="padding-top:18px">
<p class="eyebrow">MUSIC</p>
<h2>가요 다방 <span>%(nsong)d곡</span></h2>
%(song1)s
<details><summary class="more" style="display:flex;align-items:center;justify-content:center">%(songrest)d곡 더 보기</summary>%(song2)s</details>
</section>
<section class="sec">
<p class="eyebrow">PEOPLE</p>
<h2>인물 열전 <span>%(npeople)d명</span></h2>
<div class="grid2">%(faces)s</div>
<details><summary class="more" style="display:flex;align-items:center;justify-content:center">%(prest)d명 더 보기</summary>%(prows)s</details>
<p class="note" style="margin-top:14px">사진이 있는 %(nface)d명을 먼저 뒀습니다.</p>
</section>
<section class="sec">
<p class="eyebrow">CHRONICLE</p>
<h2>시간의 골목</h2>
%(chron)s
</section>
<section class="sec">
<p class="eyebrow">READING</p>
<h2>군산 읽기 <span>%(nread)d꼭지</span></h2>
%(read)s
<details><summary class="more" style="display:flex;align-items:center;justify-content:center">%(readrest)d꼭지 더 보기</summary>%(read2)s</details>
</section>
<div class="rule"></div>
<section class="sec" id="weather">
<p class="eyebrow">TODAY</p>
<h2>오늘의 날씨</h2>
<p class="lead" style="margin-bottom:10px">%(temp)s° %(cond)s</p>
<p id="wnote" style="font-size:13.5px;line-height:1.9;min-height:3.8em">%(wfirst)s</p>
%(wnotes)s
<p class="note" style="margin-top:14px">관측 %(wat)s · open-meteo</p>
</section>
""" % {
"temp": e(weather.get("temperature")), "cond": e(condition),
"wfirst": e(notes[0] if notes else ""),
"wnotes": "".join('<p style="font-size:13.5px;line-height:1.9">%s</p>' % 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('<div class="row"><span>%s</span><span class="r">%s</span></div>'
% (e(f["label"]), e(value)))
faqs = "".join('<details><summary>%s</summary><p>%s</p></details>'
% (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 += """
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">RESERVATION</p>
<h2>이용안내 및 예약</h2>
<a class="cta" href="%(book)s">네이버 예약 — 바로 확정</a>
<a class="cta ghost" href="tel:%(tel)s">%(tel)s</a>
<p class="note" style="margin-top:12px">요금은 날짜와 시즌에 따라 %(price)s입니다. 인원 추가 %(extra)s원.</p>
</section>
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">INFORMATION</p>
<h2>예약 전 확인</h2>
%(info)s
</section>
<div class="rule"></div>
<section class="sec">
<h2>이용 규정</h2>
<p style="font-size:13.5px;line-height:1.9">%(cancel)s</p>
<p style="font-size:13.5px;line-height:1.9">전 구역 금연이며 반려동물은 동반하실 수 없습니다. 주방에서 간단한 조리는 가능하지만, 냄새가 잘 빠지지 않는 조리(생선·고기구이·튀김)는 하실 수 없습니다.</p>
</section>
<div class="rule"></div>
<section class="sec faq">
<p class="eyebrow">Q &amp; A</p>
<h2>자주 묻는 질문 <span>%(nfaq)d</span></h2>
%(faqs)s
</section>
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">DIRECTIONS</p>
<h2>오시는 길</h2>
<dl class="spec">
<dt>주소</dt><dd>%(road)s</dd>
<dt>지번</dt><dd>%(jibun)s</dd>
<dt>주차</dt><dd>가능 — 골목이 좁아 대문 앞까지는 차가 들어오지 못합니다. 절골길 초입에 세우고 짐만 들고 걸어 들어오세요.</dd>
</dl>
<a class="cta ghost" style="margin-top:16px" href="%(map)s" target="_blank" rel="noopener">네이버 지도로 열기</a>
</section>
<div class="rule"></div>
<section class="sec">
<p class="eyebrow">CHANNELS</p>
<h2>공식 채널</h2>
%(channels)s
</section>
""" % {
"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(
'<a class="row" href="%s" target="_blank" rel="noopener"><span>%s</span>'
'<span class="r">열기 \u2197</span></a>' % (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()