- 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 갱신
1570 lines
78 KiB
Python
1570 lines
78 KiB
Python
"""`/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") + "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('<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 + "m²" if size else "")),
|
||
("정원", "기준 %s인 · 최대 %s인" % (unit_fact(unit, "standard_capacity"),
|
||
unit_fact(unit, "max_capacity"))),
|
||
("요금", unit_fact(unit, "price_range"))]
|
||
sid = sheets.add(sheet_body(
|
||
"%s · %s" % (unit_name(unit), label),
|
||
unit_note(unit),
|
||
[unit_fact(unit, "room_intro"),
|
||
"%s · %s%s · 기준 %s인, 최대 %s인" % (
|
||
unit_fact(unit, "room_type"), unit_fact(unit, "bed_type"),
|
||
" · " + size + "m²" if size else "",
|
||
unit_fact(unit, "standard_capacity"), unit_fact(unit, "max_capacity")),
|
||
"시설 — " + " · ".join(facils),
|
||
"요금은 날짜와 시즌에 따라 %s입니다. 기준 %s인, 최대 %s인이며 인원 추가 %s원입니다."
|
||
% (unit_fact(unit, "price_range"), unit_fact(unit, "standard_capacity"),
|
||
unit_fact(unit, "max_capacity"),
|
||
f'{int(re.sub(r"[^0-9]", "", fact(data, "extra_person_fee") or "0")):,}')],
|
||
image=shots[0]["url"] if shots else None))
|
||
return """
|
||
<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 & 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()
|