o2o-site-AEO/solution/site/scripts/mockup/build_stay6.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

215 lines
9.1 KiB
Python
Raw Permalink 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/stay 를 세 페이지로 나눈 판을 /s/stay6 으로 굽는다.
stay3·stay4 는 HTML5 UP 템플릿으로 새로 만든 것이라 렌더러와 무관했다. stay6 은 반대다 —
**/s/stay 와 같은 리액트 렌더러·같은 payload** 를 쓰고, `theme.sections[].enabled` 만
페이지별로 갈라 SSR 을 세 번 돌린다. 그래서 캐러셀·미니 플레이어·엽서 캔버스·후기 모달이
전부 /s/stay 와 같은 것으로 선다.
★ 렌더러(solution/site/src)는 건드리지 않는다. 페이지마다 다른 것은 payload 의 enabled 와
주입하는 css·js 뿐이다.
★ 후기(ReviewSection)·엽서(PostcardMakerSection)는 HomePage 가 섹션 목록과 무관하게
항상 그린다. 페이지를 나누려면 렌더러를 고쳐야 하는데 그건 /s/stay 를 같이 바꾸는 일이라,
여기서는 주입 css 로 감춘다 — DOM 에는 남는다.
★ 히어로도 항상 나온다. 페이지 머리로 쓴다.
입력: build/index.html (patch_stay.py 결과 — 껍데기·head·주입분) · stay-payload-new.json
출력: build6/{index.html, gunsan/index.html, booking/index.html}
"""
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
SP = Path(__file__).parent
SITE = SP.parents[1]
OUT = SP / "build6"
SSR = SITE / "dist" / "ssr6" / "ssr6.js"
SHELL = SP / "build" / "index.html"
PAYLOAD = SP / "build" / "index.html"
BASE = "/s/stay6"
PAGES = [
("", "소개", {"hero", "intro", "rooms", "video", "photos", "map", "weather"},
{"reviews"}),
# ★ map 을 끄면 HomePage 가 '오시는 길' 을 기본으로 한 번 더 낸다(HomePage.tsx:118).
# 군산 페이지에는 필요 없어서 주입 css 로 감춘다.
("gunsan", "군산", {"hero", "festival", "local", "itinerary", "story",
"songs", "people", "chronicle", "reading"},
{"reviews", "postcard-maker", "location", "hero"}),
("booking", "예약·후기", {"hero", "info", "booking", "faq", "rules", "map"},
{"postcard-maker", "hero"}),
]
# 화면 앵커 id → 그 섹션이 사는 페이지.
# ★ 페이지를 나누면 헤더 nav 의 `#festival` 같은 앵커가 **그 페이지에 없는 자리**를 가리킨다.
# 바를 하나 더 얹지 않고, 사이트가 이미 가진 nav 의 href 만 페이지 주소로 고친다.
ANCHOR_PAGE = {
"top": "", "about": "", "units": "", "video": "", "gallery": "",
"location": "", "weather": "", "postcard-maker": "",
"festival": "gunsan", "guide": "gunsan", "itinerary": "gunsan",
"story": "gunsan", "songs": "gunsan",
"info": "booking", "booking": "booking", "faq": "booking", "reviews": "booking",
}
# 하위 페이지의 머리.
# ★ 실물 대조(2026-09-18, 星のや京都 /dining/ · 모바일 390×844): 하위 페이지에는 히어로가
# 없다. 얇은 헤더 → 여백 → **페이지 이름 한 줄(가운데·명조)** → 바로 내용이다.
# 사진도 캐치프레이즈도 CTA 도 없다. 홈 히어로를 하위 페이지가 되풀이하는 판은 없었다.
NAV_CSS = """
[data-w4d-here] { font-weight: 800; text-decoration: underline; text-underline-offset: 5px; }
.w4d-pagehead {
padding: clamp(2.5rem, 9vw, 4.5rem) 1.25rem clamp(1.75rem, 6vw, 3rem);
text-align: center;
}
.w4d-pagehead h1 {
margin: 0; font-family: var(--font-serif, serif); font-weight: 400;
font-size: clamp(1.75rem, 7vw, 2.4rem); letter-spacing: .04em; line-height: 1.3;
}
"""
# ★ HTML 을 고쳐도 소용없다 — 리액트가 하이드레이션하면서 헤더를 다시 그려 덮는다(실측).
# 그래서 런타임에 고친다. 다시 그려도 되도록 클릭을 가로채는 쪽이 본체이고,
# href 를 바꾸는 것은 마우스 올렸을 때 주소가 맞게 보이라고 덤으로 한다.
NAV_JS = """
<script>
(function () {
var BASE = %(base)s, HERE = %(here)s, PAGE = %(page)s;
function target(id) {
var page = PAGE[id];
if (page === undefined || page === HERE) return null;
return page ? BASE + '/' + page + '#' + id : BASE + '#' + id;
}
function fix() {
var anchors = document.querySelectorAll('a[href^="#"]');
for (var i = 0; i < anchors.length; i++) {
var id = anchors[i].getAttribute('href').slice(1);
var to = target(id);
if (to) anchors[i].setAttribute('href', to);
else if (PAGE[id] === HERE) anchors[i].setAttribute('data-w4d-here', '');
}
}
document.addEventListener('click', function (event) {
var a = event.target.closest && event.target.closest('a[href^="#"]');
if (!a) return;
var to = target(a.getAttribute('href').slice(1));
if (!to) return;
event.preventDefault();
location.href = to;
}, true);
var TITLE = %(title)s;
function head() {
if (!TITLE) return;
var header = document.querySelector('#root header');
if (!header || document.querySelector('.w4d-pagehead')) return;
var band = document.createElement('div');
band.className = 'w4d-pagehead';
var h1 = document.createElement('h1');
h1.textContent = TITLE;
band.appendChild(h1);
header.parentNode.insertBefore(band, header.nextSibling);
}
function tick() { fix(); head(); }
[0, 400, 1200, 3000].forEach(function (ms) { setTimeout(tick, ms); });
window.addEventListener('pageshow', tick);
if (window.MutationObserver) {
new MutationObserver(tick).observe(document.getElementById('root'), {childList: true, subtree: false});
}
})();
</script>
"""
# 하위 페이지는 히어로를 쓰지 않는다(위 실물 대조). 렌더러가 무조건 그리므로 여기서 감춘다.
HIDE_CSS = {
"hero": "#top { display: none !important; }",
"reviews": "#reviews { display: none !important; }",
"postcard-maker": "#postcard-maker { display: none !important; }",
"location": "#location { display: none !important; }",
}
def shell_parts():
html = SHELL.read_text(encoding="utf-8")
open_tag = '<div id="root">'
start = html.index(open_tag) + len(open_tag)
end = html.index('<script>window.__SITE_PAYLOAD__')
close = html.rindex("</div>", start, end)
payload_end = html.index("</script>", end) + len("</script>")
return html[:start], html[close:end], html[payload_end:], html
def baked_payload():
"""굽힌 /s/stay 에서 payload 를 그대로 떠 온다 — stay-payload-new.json 은 patch_stay 중간
스냅샷이라 booking 켜기·탭 비우기·사진 주소 교체가 아직 안 들어가 있다."""
html = PAYLOAD.read_text(encoding="utf-8")
start = html.index("window.__SITE_PAYLOAD__=") + len("window.__SITE_PAYLOAD__=")
end = html.index("</script>", start)
return json.loads(html[start:end].rstrip().rstrip(";"))
def variant(payload, keep):
"""stay 가 켜 둔 섹션 중 이 페이지 몫만 남긴다. 꺼져 있던 것을 켜지 않는다 —
'군산 이야기' 안의 탭(가요·인물·시간의 골목·읽기)이 따로 서 버린다."""
out = json.loads(json.dumps(payload))
for section in out["theme"]["sections"]:
section["enabled"] = bool(section.get("enabled")) and section["id"] in keep
return out
PAGE_TITLE = {"": None, "gunsan": "군산", "booking": "이용안내 및 예약"}
def nav_js(current):
return NAV_JS % {
"base": json.dumps(BASE),
"here": json.dumps(current),
"page": json.dumps(ANCHOR_PAGE, ensure_ascii=False),
"title": json.dumps(PAGE_TITLE[current], ensure_ascii=False),
}
def main():
if not SSR.exists():
sys.exit(f"SSR 번들이 없다: {SSR}\n cd {SITE} && npx vite build --ssr scripts/mockup/ssr6.ts --outDir dist/ssr6")
if not SHELL.exists():
sys.exit(f"껍데기가 없다: {SHELL} — 먼저 patch_stay.py 를 돌린다")
base_payload = baked_payload()
head, tail_root, tail, whole = shell_parts()
if OUT.exists():
shutil.rmtree(OUT)
OUT.mkdir(parents=True)
for slug, _label, keep, hide in PAGES:
page_payload = variant(base_payload, keep)
with tempfile.TemporaryDirectory() as tmp:
src = Path(tmp) / "p.json"
dst = Path(tmp) / "p.html"
src.write_text(json.dumps(page_payload, ensure_ascii=False), encoding="utf-8")
subprocess.run(["node", str(SSR), str(src), str(dst)], check=True)
app = dst.read_text(encoding="utf-8")
payload_js = f"<script>window.__SITE_PAYLOAD__={json.dumps(page_payload, ensure_ascii=False)}</script>"
html = head + app + tail_root + payload_js + tail
html = html.replace("</body>", nav_js(slug) + "</body>", 1)
extra = NAV_CSS + "".join(HIDE_CSS[key] for key in sorted(hide))
html = html.replace("</head>", f"<style>{extra}</style></head>", 1)
html = html.replace("/s/stay/", f"{BASE}/")
html = re.sub(r'(<link rel="canonical" href="[^"]*?)/s/stay"',
rf'\1{BASE}/{slug}"' if slug else rf'\1{BASE}"', html)
target = (OUT / slug / "index.html") if slug else (OUT / "index.html")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(html, encoding="utf-8")
print(f"{target.relative_to(OUT)} {target.stat().st_size:,}자 섹션 {len(keep)}")
if __name__ == "__main__":
main()