patch_stay.py·inject.js/css·audit-all.mjs 갱신, 벤더 번들 교체(index-D9cPXCE3 → index-D4jgGFP4 등), stay3 백업 스냅샷과 stay2/stay3 빌드 스크립트(build_stay2.py· build_stay3.py) 및 원본 템플릿(tpl-story·tpl-creative) 추가. 옛 번들은 vendor/retired/ 에 보관. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
586 lines
44 KiB
Python
586 lines
44 KiB
Python
"""/s/stay 목업 index.html 을 다시 만든다 — payload 교체 + 주입분(css·js) 삽입.
|
||
|
||
★ 이 사이트는 payload 가 없다(목업). 프리렌더 재굽기 대상이 아니라서, 고칠 자리는
|
||
구워진 index.html 하나다. 손으로 열어 고치지 않고 이 스크립트로 만든다 —
|
||
두 번째로 고칠 때 무엇을 바꿨는지가 파일 대신 여기 남아야 한다.
|
||
|
||
입력: stay-orig/index.html (볼륨에서 꺼낸 원본) · stay-payload-new.json · inject.css · inject.js
|
||
출력: stay-work/index.html
|
||
"""
|
||
import json
|
||
import re
|
||
import uuid
|
||
from pathlib import Path
|
||
|
||
SP = Path(__file__).parent
|
||
|
||
# ── 최종 업데이트 시각 ────────────────────────────────────────────────────────
|
||
# 화면 아래 "최종 업데이트", head 의 dateModified, JSON-LD, payload 의 site.updatedAt ·
|
||
# local.syncedAt 이 전부 이 한 값을 쓴다. 구운 원본에는 2026-09-09 가 박혀 있어, 문구·맛집을
|
||
# 고친 뒤에도 화면에는 옛 날짜가 남았다 (2026-09-11 사장님: "이거 업뎃해야지").
|
||
# ★ now() 를 쓰지 않는다 — 내용을 안 고치고 다시 구워도 날짜가 올라가 버린다. 구글은 lastmod 를
|
||
# 페이지의 실제 수정과 대조해 맞을 때만 쓰고 어긋나면 그 필드를 무시한다(seo/directory.ts).
|
||
# 내용을 고친 날에 이 값을 손으로 올린다.
|
||
UPDATED_AT = "2026-09-14T07:42:00+00:00"
|
||
|
||
# ── 캐치프레이즈 100개 ────────────────────────────────────────────────────────
|
||
# 사실이 아니라 문구다(출처가 붙는 값이 아니다). 근거는 payload 의 시설·위치 값 —
|
||
# 적산가옥 두 동 · 히로쓰 가옥 옆 · 마당과 정원 · 창고형 카페 · 매일 세탁하는 침구 · 최대 4인.
|
||
GENERAL = [
|
||
"백 년 된 집에서 하룻밤",
|
||
"담 너머는 히로쓰 가옥입니다",
|
||
"독채 두 동, 마당은 각자 씁니다",
|
||
"기와 아래에서 아침을 맞습니다",
|
||
"새소리로 눈이 떠지는 집",
|
||
"골목이 조용해지는 시간에 도착하세요",
|
||
"침구는 매일 새것처럼 나갑니다",
|
||
"마당 평상에 앉으면 항구 소리가 들립니다",
|
||
"1920년대에 지은 집, 지금 쓰는 집",
|
||
"대문을 닫으면 골목이 멀어집니다",
|
||
"창고를 고친 카페 공간이 딸려 있습니다",
|
||
"밤에는 정원 석등만 켜 둡니다",
|
||
"초원사진관까지 걸어서 3분",
|
||
"원도심 한가운데, 차는 세워 두세요",
|
||
"A동은 일본식, B동은 모던입니다",
|
||
"네 사람까지 묶는 독채입니다",
|
||
"오후 세 시부터 이 집은 손님 것입니다",
|
||
"이 골목에서는 서두를 일이 없습니다",
|
||
"백 년 된 기와가 비를 받습니다",
|
||
"여행이라기보다 하루의 이사입니다",
|
||
"군산에서 가장 조용한 담 안쪽",
|
||
"이 집의 시간은 조금 느립니다",
|
||
"마당, 정원, 그리고 평상 하나",
|
||
"짐을 풀면 골목부터 걸어 보세요",
|
||
"창을 열면 옆집 기와가 보입니다",
|
||
"담배 냄새가 없는 집입니다",
|
||
"이불에서 볕 냄새가 납니다",
|
||
"두 동 모두 남의 인기척이 없습니다",
|
||
"내항까지 걸어 십 분",
|
||
"오래된 것을 고쳐 쓰는 집",
|
||
"부엌이 있어 아침은 지어 드셔도 됩니다",
|
||
"밤에는 골목 등만 남습니다",
|
||
"백 년 된 집이 아직 손님을 받습니다",
|
||
"개항장 골목의 끝집입니다",
|
||
"문패보다 기와를 보고 찾아오세요",
|
||
"걸어서 도는 여행에 알맞은 자리",
|
||
"마루에 앉는 시간이 일정의 절반입니다",
|
||
"좁은 골목 끝, 대문 하나",
|
||
"여기서는 계획을 반쯤 접어도 됩니다",
|
||
"하루를 통째로 쓰는 숙소",
|
||
]
|
||
SEASON = {
|
||
"봄": ["정원에 꽃이 먼저 옵니다", "봄에는 대문을 열어 둡니다", "벚꽃 지는 골목을 걸어 오세요",
|
||
"마당에 앉는 계절이 시작됩니다", "새소리가 가장 많은 철입니다", "창을 열면 봄이 방까지 들어옵니다"],
|
||
"여름": ["처마 그늘이 가장 긴 계절입니다", "마당에 물을 뿌리면 시원해집니다", "여름 밤에는 평상이 제일 좋습니다",
|
||
"창고형 카페가 낮에는 서늘합니다", "소나기는 기와가 받아 줍니다", "해가 길어 저녁이 넉넉합니다"],
|
||
"가을": ["기와 위로 가을 볕이 마릅니다", "가을 바람이 담을 넘어옵니다", "가을 골목은 해가 빨리 눕습니다",
|
||
"창을 열어 두고 자도 되는 밤입니다", "가을에는 평상이 가장 좋은 자리입니다", "마당에 잎이 떨어지는 계절입니다"],
|
||
"겨울": ["기와에 눈이 앉으면 골목이 조용해집니다", "겨울 아침 마당은 서리 밭입니다", "이불 속에서 겨울 바람 소리를 듣습니다",
|
||
"눈 오면 정원 석등만 켜 둡니다", "항구 바람이 매운 계절입니다", "겨울에는 카페 공간이 가장 아늑합니다"],
|
||
}
|
||
MONTH = {
|
||
1: ["새해 첫 아침을 백 년 집에서", "겨울 골목이 가장 조용한 달입니다"],
|
||
2: ["아직 바람이 찬 원도심입니다", "겨울 끝을 마당에서 봅니다"],
|
||
3: ["정원에 첫 꽃이 오는 달입니다", "대문을 열어 두기 시작합니다"],
|
||
4: ["벚꽃 지는 골목을 걷는 달입니다", "마당에 앉는 시간이 길어집니다"],
|
||
5: ["새소리가 가장 많은 달입니다", "창을 열어 두고 자는 밤입니다"],
|
||
6: ["처마 그늘이 길어지는 달입니다", "여름 앞의 원도심은 한가합니다"],
|
||
7: ["소나기를 기와가 받는 달입니다", "여름 밤 평상이 제일 좋습니다"],
|
||
8: ["낮에는 카페 공간이 서늘합니다", "해가 길어 저녁이 넉넉한 달입니다"],
|
||
9: ["볕이 마르기 시작하는 달입니다", "가을이 담을 넘어오는 구월입니다"],
|
||
10: ["잎이 마당에 떨어지는 달입니다", "걷기에 가장 좋은 달입니다"],
|
||
11: ["해가 빨리 눕는 골목입니다", "이불이 가장 따뜻한 달입니다"],
|
||
12: ["기와에 눈이 앉는 달입니다", "한 해의 끝을 조용히 보내는 자리"],
|
||
}
|
||
# 키는 렌더러(use-live-weather.ts)의 분류와 같아야 한다.
|
||
WEATHER = {
|
||
"맑음": ["마당 평상에 앉으면 새소리만 들립니다", "정원 쪽으로 그늘이 길게 눕는 시간입니다", "기와가 마르는 냄새가 납니다"],
|
||
"구름많음": ["빛이 부드러운 날입니다", "골목 사진이 제일 잘 나오는 빛입니다"],
|
||
"흐림": ["빛이 낮게 깔리는 날입니다", "이런 날은 카페 공간이 가장 아늑합니다"],
|
||
"비": ["백 년 된 기와를 타고 내리는 소리가 다릅니다", "카페 공간에서 빗소리를 들으실 수 있습니다", "비 오는 골목에는 사람이 없습니다"],
|
||
"눈": ["기와에 눈이 앉으면 골목이 통째로 조용해집니다", "눈 밟는 소리가 담 안에서 크게 들립니다"],
|
||
}
|
||
|
||
# ── 오늘의 날씨 문구 ──────────────────────────────────────────────────────────
|
||
# ★ 케이스마다 한 줄뿐이었다 (2026-09-14 사장님: "케이스당 여러개로 만들어서 랜덤으로")
|
||
# 같은 하늘·같은 기온이면 늘 같은 문장이 떴다. 시연에서 새로고침해도 안 바뀐다.
|
||
# ★ 축을 둘로 갈라 둔 것은 그대로다(site-payload.ts WeatherSnapshot 주석) —
|
||
# 하늘은 **집 안에서** 뭘 할지, 기온은 **밖에서** 어디를 갈지다. 한 칸에 담으면
|
||
# 5×5 스물다섯 벌을 적어야 한다.
|
||
# ★ 이름을 대는 곳은 이 사이트에 **이미 있는 자리**만 쓴다(주변 안내 12곳 · 맛집 31곳).
|
||
# 화면에 없는 곳을 날씨 칸에서만 권하면 손님이 그걸 찾을 데가 없다.
|
||
# ★ 하늘은 아홉이다 — Open-Meteo WMO 코드를 그대로 가른 값(use-live-weather.ts condition).
|
||
# 그림(w4-sky)은 넷으로만 그리고, 문구만 아홉으로 고른다.
|
||
SKY_NOTES = {
|
||
"맑음": [
|
||
"마당 평상에 앉으면 새소리만 들립니다. 정원 쪽으로 그늘이 길게 눕는 시간입니다.",
|
||
"기와가 마르는 냄새가 나는 날입니다. 아침에 널어 두신 것은 오후면 다 마릅니다.",
|
||
"볕이 좋아 대문을 열어 둡니다. 마당 사진은 해가 기울기 시작할 때가 가장 곱습니다.",
|
||
"창을 다 열어도 좋은 날입니다. A동 장지문으로 드는 빛이 오전에 가장 깊습니다.",
|
||
"해가 길게 남는 날입니다. 짐을 풀고 골목부터 한 바퀴 돌고 오셔도 늦지 않습니다.",
|
||
],
|
||
"구름많음": [
|
||
"빛이 부드러운 날입니다. 골목 사진이 제일 잘 나오는 빛입니다.",
|
||
"그늘이 옅어 마당이 눈부시지 않습니다. 평상에 오래 앉아 계시기 좋습니다.",
|
||
"해가 들었다 숨었다 합니다. 창고형 CAFÉ 공간과 마당을 오가며 쉬시면 됩니다.",
|
||
"구름이 얇은 날입니다. 우체통거리까지 걷기에 덥지도 춥지도 않습니다.",
|
||
"빛이 고른 날입니다. 초원사진관까지 걸어서 삼 분, 사진은 오늘 같은 하늘이 낫습니다.",
|
||
],
|
||
"흐림": [
|
||
"해가 숨은 날입니다. 기와와 담이 한 색으로 가라앉고, 마당 평상에 앉아도 눈이 부시지 않습니다.",
|
||
"빛이 낮게 깔리는 날입니다. 창고형 CAFÉ 공간이 오늘은 가장 아늑합니다.",
|
||
"하늘이 자주 바뀌는 날입니다. 나가실 때 우산을 챙기시는 편이 낫습니다.",
|
||
"그늘이 없는 날이라 골목을 오래 걸으셔도 덜 지칩니다.",
|
||
"색이 차분한 하늘입니다. 신흥동 담길 사진은 오늘 같은 날이 깊게 나옵니다.",
|
||
],
|
||
"안개": [
|
||
"안개가 낮게 깔리면 골목 끝이 흐려집니다. 서두르지 마시고 늦게 나가셔도 됩니다.",
|
||
"담 너머 히로쓰 가옥이 흐릿하게 보이는 아침입니다. 마당에서 한참 서 계셔도 좋습니다.",
|
||
"시야가 짧은 날입니다. 차로 나가실 계획이면 여유 있게 출발하세요.",
|
||
"안개가 걷히는 시간을 기다리며 아침을 천천히 드셔도 됩니다.",
|
||
"뿌연 공기 너머로 보이는 골목도 오늘만의 그림입니다.",
|
||
],
|
||
"이슬비": [
|
||
"가는 비가 내립니다. 우산 하나면 골목을 걷는 데 무리가 없습니다.",
|
||
"빗방울이 약해 오히려 조용한 날입니다. 히로쓰 가옥 담길이 한산합니다.",
|
||
"젖은 디딤돌이 미끄럽습니다. 편한 신발을 권합니다.",
|
||
"얇은 겉옷 하나면 충분한 비입니다. 대문 안쪽에 우산을 두었습니다.",
|
||
"빗소리가 작게 깔리는 날, 창고형 CAFÉ 공간에서 쉬어 가셔도 좋습니다.",
|
||
],
|
||
"소나기": [
|
||
"소나기가 지나갈 수 있는 하늘입니다. 창고형 CAFÉ 공간에서 한 차례 보내고 나가셔도 됩니다.",
|
||
"갑자기 쏟아질 수 있으니 우산을 꼭 챙겨 나가세요.",
|
||
"비가 그친 뒤 기와가 마르는 냄새가 가장 좋습니다. 그때를 노려 보세요.",
|
||
"짧고 굵게 지나가는 비입니다. 일정을 통째로 바꾸실 필요는 없습니다.",
|
||
"바람이 먼저 바뀌는 날입니다. 마당에 널어 두신 것은 들여놓으시는 편이 좋습니다.",
|
||
],
|
||
"뇌우": [
|
||
"천둥이 치는 날입니다. 마당보다 실내에서 보내시는 편이 낫습니다.",
|
||
"비바람이 강합니다. 정원 석등만 켜 두고 오늘은 안에서 쉬어 가세요.",
|
||
"번개가 잦을 때는 높은 곳과 물가를 피하는 편이 안전합니다.",
|
||
"날이 거칩니다. 이동 계획이 있으시면 시간을 넉넉히 잡으세요.",
|
||
"궂은 하늘이 지나가면 금강 쪽이 크게 열립니다. 조금만 기다려 보세요.",
|
||
],
|
||
"비": [
|
||
"백 년 된 기와를 타고 내리는 소리가 다릅니다. 창고형 CAFÉ 공간에서 빗소리를 들으실 수 있습니다.",
|
||
"비 오는 골목에는 사람이 없습니다. 히로쓰 가옥 담길은 오늘 같은 날이 가장 조용합니다.",
|
||
"마당 디딤돌이 미끄럽습니다. 밤에 나가실 때는 정원 석등 쪽으로 도세요.",
|
||
"실내로 도는 편이 낫습니다. 근대건축관과 군산근대미술관이 걸어서 십 분 안에 나란히 있습니다.",
|
||
"우산은 대문 안쪽에 두시면 됩니다. 젖은 것은 테라스 쪽이 잘 마릅니다.",
|
||
],
|
||
"눈": [
|
||
"기와에 눈이 앉으면 골목이 통째로 조용해집니다. 정원 석등만 켜 두겠습니다.",
|
||
"눈 밟는 소리가 담 안에서 크게 들립니다. 마당은 아침에 한 번 쓸어 두겠습니다.",
|
||
"눈 오는 날의 원도심은 사람이 적습니다. 초원사진관 골목이 오늘 가장 한산합니다.",
|
||
"디딤돌에 눈이 얼 수 있습니다. 밤에 드나드실 때는 조심해서 딛으세요.",
|
||
"창을 닫아 두시는 편이 낫습니다. 창고형 CAFÉ 공간이 이런 날 가장 아늑합니다.",
|
||
],
|
||
}
|
||
# 기온대 경계는 렌더러가 정한다(`site/src/lib/derive.ts weatherBand`) — 30·25·20·10.
|
||
TEMP_NOTES = {
|
||
"혹서": [
|
||
"낮에는 군산근대미술관·근대건축관처럼 실내로 도는 편이 낫습니다. 월명호수 한 바퀴는 해가 넘어간 뒤가 시원합니다.",
|
||
"한낮 골목에는 그늘이 없습니다. 초원사진관·영화의 거리는 이른 아침이나 해 질 무렵에 도세요.",
|
||
"옛 군산세관과 근대건축관은 걸어서 이어집니다. 실내를 징검다리 삼아 도시면 덜 지칩니다.",
|
||
"경암동 철길마을은 그늘이 적습니다. 오늘은 저녁에 가시는 편이 낫습니다.",
|
||
"물은 나가시기 전에 챙기세요. 이성당까지는 걸어서 오백 미터, 해가 기운 뒤가 낫습니다.",
|
||
],
|
||
"더움": [
|
||
"선유도까지 다녀오기 좋은 기온입니다. 골목은 오후 늦게 도세요 — 경암동 철길마을은 해 질 때가 사람이 적습니다.",
|
||
"낮에는 월명호수 그늘길이 시원합니다. 물가를 따라 걷다 돌아오시면 됩니다.",
|
||
"우체통거리와 영화의 거리는 그늘이 이어집니다. 걸어서 도시기에 무리가 없습니다.",
|
||
"해가 길어 저녁이 넉넉합니다. 내항 쪽으로 나가 노을을 보고 오셔도 됩니다.",
|
||
"가장 더운 시간만 실내가 낫습니다. 군산근대미술관에서 한 차례 쉬어 가세요.",
|
||
],
|
||
"선선": [
|
||
"걷기에 가장 좋은 날입니다. 히로쓰 가옥에서 초원사진관·영화의 거리까지 걸어서 그대로 이어집니다.",
|
||
"원도심은 오늘 하루면 다 걸립니다. 군산 시간여행마을까지 천천히 다녀오셔도 됩니다.",
|
||
"차를 두고 걷는 편이 낫습니다. 우체통거리에서 옛 군산세관까지 골목이 계속 이어집니다.",
|
||
"해가 부담스럽지 않습니다. 경암동 철길마을까지 다녀오기에 알맞은 기온입니다.",
|
||
"낮에는 월명호수를 한 바퀴 돌고, 밤에는 마당에 앉아 계셔도 좋습니다.",
|
||
],
|
||
"쌀쌀": [
|
||
"월명호수를 한 바퀴 돌고 이성당이나 국제반점에서 따뜻한 것을 드시면 알맞습니다.",
|
||
"겉옷을 하나 더 챙기세요. 해가 지면 항구 쪽 바람이 먼저 찹니다.",
|
||
"실내와 골목을 섞어 도는 편이 낫습니다. 근대건축관에서 몸을 녹이고 다시 나오시면 됩니다.",
|
||
"따뜻한 국물이 도는 날입니다. 복성루·빈해원처럼 가까운 집부터 보세요.",
|
||
"오후 볕이 짧습니다. 초원사진관·영화의 거리를 먼저 돌고 저녁에 들어오세요.",
|
||
],
|
||
"추움": [
|
||
"밖은 바람이 매섭습니다. 근대건축관·근대미술관과 시간여행마을 실내를 묶어 도는 편이 낫습니다.",
|
||
"항구 바람이 매운 날입니다. 골목은 짧게 돌고 실내를 길게 잡으세요.",
|
||
"장갑과 목도리를 챙기세요. 옛 군산세관 앞은 바람이 그대로 지나갑니다.",
|
||
"따뜻한 것을 먼저 드시는 편이 낫습니다. 이성당·국제반점은 걸어서 오백 미터입니다.",
|
||
"일찍 들어오셔도 됩니다. 벽난로가 있는 거실이 이런 날 가장 좋습니다.",
|
||
],
|
||
}
|
||
|
||
items = [{"text": t, "kind": "general"} for t in GENERAL]
|
||
items += [{"text": t, "kind": "season", "season": s} for s, lst in SEASON.items() for t in lst]
|
||
items += [{"text": t, "kind": "month", "month": m} for m, lst in MONTH.items() for t in lst]
|
||
items += [{"text": t, "kind": "weather", "weather": w} for w, lst in WEATHER.items() for t in lst]
|
||
|
||
payload = json.loads((SP / "stay-payload-new.json").read_text(encoding="utf-8"))
|
||
payload["narrative"]["catchphrases"] = {"version": 1, "items": items}
|
||
|
||
# ── '오늘의 엽서' 탭을 없앤다 ─────────────────────────────────────────────────
|
||
# ★ 왜 (2026-09-14 대표: "오늘의 엽서 칸을 빼고 이 엽서 포맷으로 스테이 머뭄 관련
|
||
# 엽서를 쓰는 걸로 하자") — 도시 엽서 4장은 이제 안 쓴다. 대신 손님이 직접 쓰는
|
||
# 엽서 섹션을 새로 만들고(inject.js `postcardMaker`), 그건 '군산 이야기' 탭이
|
||
# 아니라 `#location`–`#festival` 사이의 **독립 섹션**이다.
|
||
# ★ 마크업을 안 건드리고 payload 만 비운다 — `StorySection.tsx` 는
|
||
# `sectionItems(payload,'postcard').items.length > 0` 인 탭만 세우므로, data 를
|
||
# 비우면 렌더러 코드는 그대로 두고도 탭이 저절로 사라진다(README 2.6 의 원칙과 같다:
|
||
# "마크업을 고쳐 지우지 않는다").
|
||
for _sec in payload["theme"]["sections"]:
|
||
if _sec["id"] == "postcard":
|
||
_sec["data"] = json.dumps({"kind": "postcard", "version": 1, "items": []}, ensure_ascii=False)
|
||
break
|
||
|
||
# 굽는 판에는 케이스마다 **첫 문장**을 박는다 — 크롤러와 첫 화면이 보는 값이고,
|
||
# inject.js 가 DOM 에서 갈아 끼울 때 찾는 닻이기도 하다. 문장이 겹치면 엉뚱한 줄을 바꾼다.
|
||
_all_notes = [t for lst in SKY_NOTES.values() for t in lst] + \
|
||
[t for lst in TEMP_NOTES.values() for t in lst]
|
||
assert len(_all_notes) == len(set(_all_notes)), "날씨 문구가 겹친다 — DOM 에서 어느 줄인지 못 가른다"
|
||
payload["local"]["weather"].update({
|
||
# 렌더러가 아는 넷. 구름많음은 '흐림' 칸을 쓴다(그 외와 가르는 것은 inject.js ⑦).
|
||
"notes": {"맑음": SKY_NOTES["맑음"][0], "흐림": SKY_NOTES["흐림"][0],
|
||
"비": SKY_NOTES["비"][0], "눈": SKY_NOTES["눈"][0]},
|
||
"tempNotes": {band: lines[0] for band, lines in TEMP_NOTES.items()},
|
||
# 렌더러가 모르는 칸이다(계약에 없어 그대로 지나간다). 주입분만 읽는다.
|
||
"noteSets": SKY_NOTES,
|
||
"tempNoteSets": TEMP_NOTES,
|
||
})
|
||
|
||
# ── 주변 맛집 8곳 → 2km 안 31곳 ──────────────────────────────────────────────
|
||
# ★ 원본 8곳이 전부 467m 안이라 '걸어서 10분 이상' 탭에서 맛집 칸이 비었다 (2026-09-11 사장님:
|
||
# "주변맛집 데이터가 너무 적은거같아"). 목록은 fetch_restaurants.py 가 TourAPI 에서 받아 둔 것이다 —
|
||
# 여기서 API 를 부르지 않는다. 굽기가 네트워크·키에 매이면 다른 기계에서 못 굽는다.
|
||
payload["local"]["restaurants"] = json.loads((SP / "restaurants.json").read_text(encoding="utf-8"))
|
||
# 사진 없는 집에 시연본 전용 사진을 덧댄다(fill_restaurant_photos.py — 공공누리 아님, 제품으로 옮기지 않는다).
|
||
# TourAPI 사진이 있는 집은 건드리지 않는다.
|
||
_photos = json.loads((SP / "restaurant-photos.json").read_text(encoding="utf-8"))
|
||
for _r in payload["local"]["restaurants"]:
|
||
if not _r.get("imageUrl") and _photos.get(_r["name"], {}).get("file"):
|
||
_r["imageUrl"] = f"/assets/mirror/{_photos[_r['name']]['file']}"
|
||
# ── 군산 국가유산 야행 ───────────────────────────────────────────────────────
|
||
# ★ 왜 (2026-09-14 대표: 받은 `gunsan_365_story_db.xlsx` 52주제 대조 — 사이트에 없던 둘 중 하나)
|
||
# 나머지 축제는 TourAPI 가 준 것이라 이 목록에 없었다. 원도심 국가유산을 밤에 여는
|
||
# 행사라 머뭄(신흥동)에서 걸어 닿는 범위이고, 여름 저녁 일정과 맞는다.
|
||
# ★ **이름은 '문화재야행' 이 아니다.** 엑셀에는 옛 이름으로 적혀 있는데, 2024년 문화재청이
|
||
# 국가유산청으로 바뀌면서 행사명도 '국가유산 야행' 으로 갈렸다(군산시 문화예술과 공지 확인).
|
||
# 옛 이름으로 두면 검색 링크가 엉뚱한 해의 문서로 간다.
|
||
# ★ 날짜(period·startDate)는 **적지 않는다.** 확인한 공지는 2025년분이고 2026년 일정은
|
||
# 아직 공고 전이다 — 지어내지 않는다(레포 절대 규칙). 축제 10개 중 5개가 이미 날짜 없이
|
||
# 서 있어 화면도 그 경우를 감당한다.
|
||
payload["local"]["festivals"] = [
|
||
_f for _f in payload["local"]["festivals"] if _f.get("name") != "군산 국가유산 야행"
|
||
]
|
||
payload["local"]["festivals"].append({
|
||
"name": "군산 국가유산 야행",
|
||
"month": "8월",
|
||
"season": "여름",
|
||
"location": "구 조선식량영단 등 원도심 국가유산 일원 · 걸어서 20분",
|
||
"description": "원도심의 국가유산을 밤에 여는 행사다. 야경·야로·야사 등 여덟 갈래로 나뉘어 "
|
||
"신흥동 일본식 가옥의 정원 조명, 스탬프 투어, 옛 건물에서 여는 공연과 야시장이 "
|
||
"저녁부터 밤까지 이어진다.",
|
||
"searchQuery": "군산 국가유산 야행",
|
||
})
|
||
|
||
payload["site"]["updatedAt"] = UPDATED_AT
|
||
# 주변 정보(맛집·명소)를 갈아 끼운 시각도 같다 — 이 스크립트가 그 둘을 같이 바꾼다.
|
||
payload["local"]["syncedAt"] = UPDATED_AT
|
||
(SP / "stay-payload-new.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||
|
||
# ── 머뭄이 만든 노래 5곡 ──────────────────────────────────────────────────────
|
||
# ★ 가요 다방 섹션에는 **넣지 않는다** (2026-09-10 사장님: "가요다방에 왜 우리 노래 넣음???").
|
||
# 그 섹션은 '이 도시를 노래한 곡' 을 모으는 자리다 — 숙소가 만든 곡을 거기 섞으면
|
||
# 군산 노래를 보러 온 사람에게 광고를 끼워 넣는 꼴이고, 출처·연도 칸도 채울 수 없다.
|
||
# 플레이어(헤더)만 이 목록을 읽는다. 자리는 narrative.ownSongs 다.
|
||
OWN_SONGS = [
|
||
("머뭄의 아침", "01-mumum-morning.mp3", "#c8873a"),
|
||
("CHECK IN · 머뭄", "02-check-in.mp3", "#2f6b8f"),
|
||
("Stay in 머뭄", "03-stay-in.mp3", "#6b7f5b"),
|
||
("오늘은 안 갈란다", "04-not-going.mp3", "#a34a2a"),
|
||
("군산에 두고 온 님", "05-gunsan-nim.mp3", "#4a3f6b"),
|
||
]
|
||
payload["narrative"]["ownSongs"] = {
|
||
"version": 1,
|
||
"items": [{"title": title, "artist": "스테이,머뭄", "labelColor": color, "own": True,
|
||
"audioUrl": "/s/stay/audio/" + filename}
|
||
for title, filename, color in OWN_SONGS],
|
||
}
|
||
|
||
# ── 객실 사진·내용을 야놀자 등록본으로 **교체**한다 ──────────────────────────────
|
||
# 출처: https://nol.yanolja.com/stay/domestic/10068088 (사장님이 직접 올린 등록본)
|
||
#
|
||
# ★ 배정을 눈으로 하지 않는다 (2026-09-10 사장님: "B동 10개 A동 12개로 파악이 되는데")
|
||
# 처음엔 사진을 열어 보고 A/B 를 갈랐다 — 11/5/공용 6 이 나왔고 실제는 **A동 12 · B동 10** 이다.
|
||
# 등록본 페이지의 Next.js 데이터에 `roomTypes[].photos` 가 객실별로 들어 있다.
|
||
# `scripts/mockup/rooms.json` 이 그걸 뽑아 둔 것이고, 여기 순서도 등록본 순서 그대로다.
|
||
#
|
||
# ★ 촬영본(h-*)과 객실 사진(a-*·b-*)은 다른 묶음이다.
|
||
# 등록본에는 업로드 배치가 둘 있다 — 2026/03/25(객실별 22장, 현장 사진)과
|
||
# 2026/04/03(촬영본 6장, 밝고 선명하다). **히어로·대표는 촬영본에서 고른다**
|
||
# (2026-09-10: "히어로 이미지 화질이 별론데" — 현장 사진을 대표로 세웠던 것).
|
||
# 촬영본은 객실 목록에 안 들어간다 — 등록본이 객실에 붙여 놓지 않았다.
|
||
#
|
||
# ★ alt 는 사진을 열어 보고 적는다. 렌더러는 alt 없는 사진을 객실에서 아예 뺀다(derive.ts:112).
|
||
# ★ 파일은 `/s/stay/img/` 에 둔다 — `out/assets` 는 프리렌더가 관리하는 자리다(음원과 같은 규약).
|
||
YANOLJA_URL = "https://nol.yanolja.com/stay/domestic/10068088"
|
||
|
||
# (파일, 객실, alt, 분류) — 객실은 A동·B동, None 이면 숙소 공용(갤러리에만 선다)
|
||
ROOM_PHOTOS = [
|
||
("h-01.jpg", None, "기와 지붕 아래로 넓은 우드 데크와 정원이 이어지는 외관", "외관"),
|
||
("h-02.jpg", None, "동백이 핀 마당과 우드 파사드 현관으로 이어지는 데크", "외관"),
|
||
("h-03.jpg", None, "흰 벽과 밝은 바닥에 올리브색 소파와 긴 벽난로를 둔 거실", "거실"),
|
||
("h-04.jpg", None, "옛 서까래를 드러낸 천장에 라탄 조명이 걸린 거실", "거실"),
|
||
("h-05.jpg", None, "문 사이로 보이는 흰 앤티크 침대와 검은 서랍장", "침실"),
|
||
("h-06.jpg", None, "캡슐 커피머신과 레트로 전자레인지가 놓인 주방 어메니티", "주방"),
|
||
|
||
("a-01.jpg", "A동", "긴 벽난로와 벽걸이 TV, 좌식 스툴을 둔 거실", "거실"),
|
||
("a-02.jpg", "A동", "베이지 소파와 원형 원목 테이블, 장지문 창이 있는 거실", "거실"),
|
||
("a-03.jpg", "A동", "옛 서까래 천장에 라탄 조명이 걸린 거실과 벽난로", "거실"),
|
||
("a-04.jpg", "A동", "어두운 원목 하부장에 냉장고와 전자레인지를 둔 주방", "주방"),
|
||
("a-05.jpg", "A동", "붉은 벽돌 벽에 벤치와 색색 의자를 둔 창고형 카페 공간", "카페"),
|
||
("a-06.jpg", "A동", "석재 담과 소나무가 둘러싼 마당의 야외 티테이블", "외관"),
|
||
("a-07.jpg", "A동", "원목 벽과 바닥을 지나 침실로 이어지는 복도", "실내"),
|
||
("a-08.jpg", "A동", "장지문 창 옆에 흰 침구를 깐 침대가 놓인 침실", "침실"),
|
||
("a-09.jpg", "A동", "정원이 보이는 큰 장지문 창과 벽걸이 TV", "거실"),
|
||
("a-10.jpg", "A동", "원형 거울과 욕조, 어메니티를 둔 욕실", "욕실"),
|
||
("a-11.jpg", "A동", "우드 외벽 건물과 담 사이 마당에 놓인 야외 테이블", "외관"),
|
||
("a-12.jpg", "A동", "흰 벽돌 벽과 우드 담에 걸린 스테이 머뭄 간판", "외관"),
|
||
|
||
("b-01.jpg", "B동", "초가 파라솔을 세운 데크와 검은 현관문이 있는 외관", "외관"),
|
||
("b-02.jpg", "B동", "유리문을 열어 둔 카페 공간과 데크의 파라솔 테이블", "외관"),
|
||
("b-03.jpg", "B동", "큰 창으로 빛이 드는 거실의 다크그린 소파와 샹들리에", "거실"),
|
||
("b-04.jpg", "B동", "흰 타일 벽에 민트색 냉장고와 토스터를 둔 주방", "주방"),
|
||
("b-05.jpg", "B동", "흰 앤티크 침대와 꽃 모양 조명이 있는 침실", "침실"),
|
||
("b-06.jpg", "B동", "초록 서랍장과 거울, 유리문이 있는 침실 앞 공간", "실내"),
|
||
("b-07.jpg", "B동", "커피 레터링 벽과 빨간 스툴을 둔 카페 공간", "카페"),
|
||
("b-08.jpg", "B동", "욕조와 세면대, 블라인드 창이 있는 욕실", "욕실"),
|
||
("b-09.jpg", "B동", "흰 벽돌 아치 현관에 우편함이 걸린 대문", "외관"),
|
||
("b-10.jpg", "B동", "흰 벽돌 벽을 따라 디딤돌이 놓인 통로", "외관"),
|
||
]
|
||
# ★ 히어로와 소개 사진은 **원래 것으로 되돌린다** (2026-09-10 사장님: "히어로랑 소개 이미지 원복해")
|
||
# 히어로는 대표 사진(isPrimary)을, 소개는 **대표가 아닌 첫 사진**을 쓴다
|
||
# (HeroSection.tsx:40 primaryImage · AboutSection.tsx:23 galleryImages().find(!isPrimary)).
|
||
# 그래서 원본 media 를 앞에 그대로 두고 등록본 사진을 뒤에 붙인다 — 순서가 곧 그 두 자리다.
|
||
# 객실 안내는 그대로 등록본 사진만 쓴다(units.mediaIds).
|
||
HERO_PHOTO = None # 대표는 원본 사진이 계속 갖는다
|
||
|
||
_NS = uuid.UUID("6f1f3f5e-0000-4000-8000-000000000000") # 재실행해도 같은 mediaId 가 나오게
|
||
|
||
new_media = []
|
||
per_unit = {"A동": [], "B동": []}
|
||
for filename, room, alt, category in ROOM_PHOTOS:
|
||
media_id = str(uuid.uuid5(_NS, filename))
|
||
new_media.append({
|
||
"mediaId": media_id,
|
||
"url": "/s/stay/img/" + filename,
|
||
"alt": alt,
|
||
"category": category,
|
||
"width": 1280,
|
||
"height": 800,
|
||
"isPrimary": filename == HERO_PHOTO,
|
||
"sourceType": 3,
|
||
"originUrl": YANOLJA_URL,
|
||
"unitId": None,
|
||
})
|
||
if room:
|
||
per_unit[room].append(media_id)
|
||
|
||
# 원본(네이버 예약본 미러) 사진을 앞에, 등록본 사진을 뒤에.
|
||
ORIGINAL_MEDIA = json.loads((SP / "stay-payload.json").read_text(encoding="utf-8"))["media"]
|
||
payload["media"] = ORIGINAL_MEDIA + new_media
|
||
|
||
# ★ 등록본에만 있던 값 — 침대 구성 · 면적 · 객실 시설.
|
||
# bed_type·room_size 는 렌더러가 칩으로 세우는 키다(derive.ts:87). 없으면 '기준 2인 / 최대 4인'
|
||
# 만 서고, 손님이 가장 먼저 묻는 "침대가 뭔가 · 몇 평인가" 가 화면에 없다.
|
||
NOW = "2026-09-10T00:00:00+00:00"
|
||
ROOM_FACTS = {
|
||
"A동": [("bed_type", "침대", "퀸 침대 1개"), ("room_size", "면적", "52")],
|
||
"B동": [("bed_type", "침대", "킹 침대 1개")],
|
||
}
|
||
ROOM_UNITS = {"room_size": "m²"}
|
||
FACILITIES = "욕조 · 개별 화장실 · 주방 · 다이닝룸 · 테라스 · 벽난로 · OTT · 어메니티 · 취사 가능"
|
||
|
||
for unit in payload["units"]:
|
||
room = unit["name"][:2]
|
||
extra = list(ROOM_FACTS.get(room, [])) + [("room_facilities", "객실 시설", FACILITIES)]
|
||
have = {f["key"] for f in unit["facts"]}
|
||
for key, label, value in extra:
|
||
if key in have:
|
||
for fact in unit["facts"]:
|
||
if fact["key"] == key:
|
||
fact["value"] = value
|
||
continue
|
||
unit["facts"].append({
|
||
"key": key, "label": label, "value": value, "unit": ROOM_UNITS.get(key),
|
||
"type": "number" if key == "room_size" else "text",
|
||
"scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": YANOLJA_URL,
|
||
"critical": False, "required": False, "unitId": unit["unitId"],
|
||
"collectedAt": NOW, "verifiedAt": NOW,
|
||
})
|
||
# 옛 사진은 남기지 않는다 — 등록본이 그 객실에 붙여 놓은 사진만 그 순서로 세운다.
|
||
unit["mediaIds"] = per_unit.get(room, [])
|
||
|
||
# ── 화면에서 뺄 탭 ───────────────────────────────────────────────────────────
|
||
# ★ `enabled = False` 로는 안 꺼진다 (2026-09-14 사장님: "오늘의 한장은 주석처리해주쇼")
|
||
# 이 다섯은 '군산 이야기' 안의 탭이고, 탭은 **항목이 있는 것만** 선다(StorySection.tsx:41).
|
||
# enabled 는 아예 보지 않는다 — 실측: daily 가 enabled=False 인데도 탭이 서 있었다.
|
||
# ★ 그래서 항목을 비운다. 원값은 stay-payload-new.json 에 그대로 있으므로(위에서 이미 썼다)
|
||
# 되살리려면 아래 집합에서 id 만 빼면 된다. 다시 만들 필요 없다.
|
||
HIDE_TABS = {"daily", "event"} # 오늘의 한 장 · 소식
|
||
# ★ 소식 3건이 전부 '공지'(입실 시간·인원 추가·취사 제한)인데, 셋 다 '이용안내 및 예약' 에
|
||
# 이미 있다. 같은 말을 두 섹션이 하면서 1.4화면을 더 먹었다(2026-09-16 대표: "중복되는
|
||
# 컨텐츠 없어야 한다"). 렌더러의 소식 섹션은 그대로 둔다 — 사장님이 진짜 소식을 적는 자리다.
|
||
|
||
for _section in payload["theme"]["sections"]:
|
||
if _section["id"] not in HIDE_TABS:
|
||
continue
|
||
_env = json.loads(_section["data"])
|
||
_env["items"] = []
|
||
_section["data"] = json.dumps(_env, ensure_ascii=False)
|
||
_section["enabled"] = False
|
||
|
||
# ── 예약 안내를 켠다 ─────────────────────────────────────────────────────────
|
||
# ★ 왜 (2026-09-14 사장님: "예약 섹션을 안 가져다가 렌더링하네, 그거 구현되었으니 가져오셈")
|
||
# 원본 payload 의 booking 은 enabled=False 였다. 숙박이면 이 자리는 요금·인원·창구에
|
||
# 날짜/시간 목업까지 붙은 `StayBookingSection` 이 그린다(HomePage.tsx:69) — 회의록의
|
||
# "예약 페이지는 연동이 어려워 우선 목업 형태로" 가 이미 구현돼 있던 것이다.
|
||
# ★ 기본값(항목이 없으면 알아서 나가는 HomePage.tsx:122)에 기대지 못한다 — 항목이
|
||
# **있는데 꺼진** 상태라 `hasSection()` 이 참이고, 그 분기는 "사장님이 끈 것" 으로 존중한다.
|
||
# ★ 지어낸 값이 없다. 요금·인원·취소 규정은 payload 의 확인된 fact 뿐이고
|
||
# (`derive.ts stayBookingView`), 근거가 하나도 없으면 섹션째 안 그린다.
|
||
for _section in payload["theme"]["sections"]:
|
||
if _section["id"] == "booking":
|
||
_section["enabled"] = True
|
||
|
||
# ── 네이버 플레이스 → 네이버 예약 ────────────────────────────────────────────
|
||
for _link in payload["links"]:
|
||
if _link.get("channel") == 3:
|
||
_link["channel"] = 7
|
||
_link["title"] = "네이버 예약"
|
||
|
||
# ── index.html 조립 ──────────────────────────────────────────────────────────
|
||
html = (SP / "orig" / "index.html").read_text(encoding="utf-8")
|
||
|
||
# payload 교체. </script> 를 만들지 않도록 문자열 안의 '<' 를 이스케이프한다(구운 것과 같은 규약).
|
||
blob = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
|
||
html, n = re.subn(r"<script>window\.__SITE_PAYLOAD__=.*?</script>",
|
||
lambda _m: "<script>window.__SITE_PAYLOAD__=" + blob + "</script>",
|
||
html, count=1, flags=re.S)
|
||
assert n == 1, "payload 스크립트를 못 찾았다"
|
||
|
||
# ★ 날짜는 payload 밖에도 박혀 있다 — head 의 dateModified · article:modified_time ·
|
||
# JSON-LD · 화면의 <time>. payload 만 고치면 **크롤러와 첫 화면은 옛 날짜를 본다**
|
||
# (React 가 다시 그리기 전까지, 그리고 JS 를 안 돌리는 크롤러에게는 영영).
|
||
OLD_UPDATED_AT = "2026-09-09T13:40:00+00:00"
|
||
html = html.replace(OLD_UPDATED_AT, UPDATED_AT)
|
||
html = html.replace(f">{OLD_UPDATED_AT[:10]}</time>", f">{UPDATED_AT[:10]}</time>")
|
||
assert OLD_UPDATED_AT not in html and OLD_UPDATED_AT[:10] not in html, "옛 날짜가 남았다"
|
||
|
||
# ── 푸터 '만든 곳' 한 줄 ──────────────────────────────────────────────────────
|
||
# ★ 왜 (2026-09-15 대표: "푸터 하단에 Web4ai ai o2o 얘기도 적어주셈 목업 빌더 다 똑같아")
|
||
# 렌더러가 박는 줄이라(`SiteFooter.tsx`) 굽는 사이트에는 전부 들어가는데, 이 한 장은
|
||
# 2026-09-09 판 프리렌더 결과물(`orig/index.html`)이라 그 줄이 없다.
|
||
# ★ 마크업은 렌더러가 내보내는 것과 **글자 하나까지 같아야** 한다 — 정적 HTML 위에
|
||
# 하이드레이션이 얹히므로(entry-client.tsx) 어긋나면 React 가 그 자리를 다시 그린다.
|
||
# 위 문자열은 `render(payload)` 출력에서 그대로 떠 온 것이다(속성 순서까지).
|
||
# ★ 화면이 실제로 따라오는 것은 `vendor/` 번들을 다시 복사한 다음이다(아래 ★ 갱신 절차) —
|
||
# 지금 넣는 것은 크롤러와 첫 화면이 보는 값이고, 날짜를 payload 밖에서도 고치는 것과
|
||
# 같은 이유다.
|
||
MADE_BY = ('<p class="pt-3 text-[length:var(--fs-xs)] opacity-55">'
|
||
'<a href="https://www.o2osolution.ai/" target="_blank" rel="noopener noreferrer" '
|
||
'class="underline-offset-2 hover:underline">AI O2O</a>'
|
||
'의 Web4Ai로 만든 사이트입니다.</p>')
|
||
html, n = re.subn(r"</div></footer>", lambda _m: MADE_BY + "</div></footer>", html, count=1)
|
||
assert n == 1, "푸터 끝(</div></footer>)을 못 찾았다"
|
||
|
||
# ★ 이용 정보 **맨 아래 구분선**을 지운다 (2026-09-10 사장님 지시).
|
||
# 항목이 끝난 자리에 선이 한 줄 더 그어져 있어, 빈 칸이 하나 더 있는 것처럼 보인다.
|
||
# 항목(바비큐 이용) 자체는 그대로 둔다 — 지우라고 한 것은 선이다.
|
||
# ★ 마크업을 고쳐 지우지 않는다 — React 가 payload 로 다시 그리면서 원래 class 를 되돌린다
|
||
# (실측 2026-09-10: 치환은 됐는데 화면에는 선이 그대로 있었다). 지우는 자리는 주입 CSS 다.
|
||
|
||
css = (SP / "inject.css").read_text(encoding="utf-8")
|
||
js = (SP / "inject.js").read_text(encoding="utf-8")
|
||
# 빌드 도장 — 콘솔에서 "지금 보는 게 언제 구운 것인가"를 바로 읽게 한다(위 ★ 주석, inject.js).
|
||
assert "__W4D_BUILD__" in js, "inject.js 에서 빌드 도장 자리가 사라졌다"
|
||
js = js.replace("__W4D_BUILD__", UPDATED_AT)
|
||
assert "</style>" not in css and "</script>" not in js
|
||
|
||
# ★ 군산 읽기 카로셀은 이 사이트가 실제 쓰는 그 라이브러리(embla-carousel)로 움직인다
|
||
# (2026-09-14 대표: "기존 카로셀 쓰라고, 니가 뭔데 니맘대로 그지같이 구현해" — 맞는 말이다).
|
||
# `inject.js` 는 React 번들 밖의 순정 스크립트라 앱이 물고 있는 embla 인스턴스를 가져다
|
||
# 쓸 수 없다 — 그래서 **코어(라이브러리 자체, React 래퍼 없이)의 UMD 빌드**를 이 목업
|
||
# 전용으로 하나 더 들여왔다. 의존성 0 · 17.9KB(비압축, 사이트 자체 주석의 "~5KB gzip"과
|
||
# 같은 값) — `node_modules/embla-carousel/embla-carousel.umd.js` 를 그대로 복사한 것이고
|
||
# 손으로 고치지 않는다(라이선스 MIT, 버전은 site/package.json 의 embla-carousel-react 와 맞춘다).
|
||
assert (SP / "vendor" / "embla-carousel.umd.js").exists(), \
|
||
"vendor/embla-carousel.umd.js 가 없다 — node_modules/embla-carousel/embla-carousel.umd.js 를 복사한다"
|
||
|
||
block = ("\n <script src=\"/s/stay/vendor/embla-carousel.umd.js\"></script>\n"
|
||
" <style id=\"w4d-css\">\n" + css + "\n </style>\n"
|
||
" <script id=\"w4d-js\">\n" + js + "\n </script>\n ")
|
||
# 치환문(block)에 JS 의 정규식(\s 등)이 섞여 있어 그대로 넘기면 파이썬이 이스케이프로 읽는다 — 람다로 넘긴다.
|
||
html, n = re.subn(r"\n?\s*</body>", lambda _m: block + "</body>", html, count=1)
|
||
assert n == 1, "</body> 를 못 찾았다"
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 레포 안으로 끌어들이기 — 다른 사람 기계에서도 그대로 떠야 한다.
|
||
#
|
||
# ★ 왜 (2026-09-11, 사장님: "다른 환경에서 local/s/stay 치면 나오냐고")
|
||
# 구워진 index.html 은 미러 사진 61장(`/assets/mirror/*`)과 번들 css/js 를 **루트 절대경로**로
|
||
# 가리킨다. 그 파일들은 레포가 아니라 도커 볼륨에만 있다 — 레포만 받은 사람은 흰 화면을 본다.
|
||
# 번들은 더 나쁘다: 파일명이 콘텐츠 해시라 그 기계에서 구운 해시가 다르면 404 다.
|
||
# 그래서 둘 다 `img/mirror/` · `vendor/` 로 복사해 두고, 경로를 `/s/stay/…` 로 바꿔 박는다.
|
||
# 이러면 nginx 가 `/s/` 를 파일로 주기만 하면 되고 번들 해시와 무관해진다.
|
||
# (대신 렌더러를 고쳐도 이 목업은 안 따라간다 — 목업은 원래 재굽기 대상이 아니라 그게 맞다.)
|
||
# ★ 번들은 **레포에서 지금 구운 것**으로 갈아 끼운다 — `orig/index.html` 에 박힌 해시는
|
||
# 2026-09-09 판이라 그 뒤에 들어온 화면이 없다(실측: 숙박 예약 섹션 `StayBookingSection`
|
||
# 이 그 번들에 아예 없어서, payload 로 켜도 화면에 아무것도 안 그려졌다).
|
||
# 목업은 자기 번들을 `vendor/` 에 들고 다니므로(아래 주석) 해시를 맞출 상대가 없다 —
|
||
# `vendor/` 에 있는 한 벌이 정답이고, 원본의 옛 이름을 그 이름으로 바꾼다.
|
||
# 갱신 절차: `solution/site` 에서 `npm run build:client` → `dist/client/assets/index-*.{js,css}`
|
||
# 를 `vendor/` 로 복사하고 옛 파일을 지운다. (여기는 이름만 맞추므로 손댈 것이 없다.)
|
||
# ★ `<body class="site">` — 새 번들에서는 이게 없으면 35줄이 조용히 죽는다.
|
||
# 2026-09-09 의 `b1386dd`(에디터와 발행본을 한 렌더러로) 이후 발행본 공용 스타일이
|
||
# `:where(.site,.site-canvas) …` 로 스코프됐다. `orig/index.html` 은 그 전에 구운 것이라
|
||
# body 에 클래스가 없다 — 실측: 카로셀 상자(`.slider-viewport`)의 `overflow-x` 가 통째로
|
||
# 빠져 모바일에서 판 25장이 화면 밖으로 흘러 **가로 스크롤이 생겼다.**
|
||
# 프리렌더가 지금 내보내는 것과 같은 마크업으로 맞춘다(`scripts/prerender.ts:384`).
|
||
html, n = re.subn(r"<body(?![^>]*\bclass=)", '<body class="site"', html, count=1) if True else (html,1)
|
||
assert n == 1 or 'class="site"' in html, "body 에 site 클래스를 못 붙였다"
|
||
|
||
_vendor = {ext: sorted((SP / "vendor").glob(f"index-*.{ext}")) for ext in ("js", "css")}
|
||
for _ext, _found in _vendor.items():
|
||
assert len(_found) == 1, f"vendor/ 의 {_ext} 번들이 {len(_found)}개다 — 한 벌만 둔다: {_found}"
|
||
html = re.sub(r"/assets/index-[\w.-]+\.js", "/assets/" + _vendor["js"][0].name, html)
|
||
html = re.sub(r"/assets/index-[\w.-]+\.css", "/assets/" + _vendor["css"][0].name, html)
|
||
|
||
missing = []
|
||
for name in sorted(set(re.findall(r"/assets/mirror/[0-9a-f]+\.\w+", html))):
|
||
if not (SP / "img" / "mirror" / name.rsplit("/", 1)[1]).exists():
|
||
missing.append(name)
|
||
for name in sorted(set(re.findall(r"/assets/index-[\w.-]+\.(?:css|js)", html))):
|
||
if not (SP / "vendor" / name.rsplit("/", 1)[1]).exists():
|
||
missing.append(name)
|
||
assert not missing, f"레포에 없는 자산 {len(missing)}개: {missing[:3]} … `vendor.sh` 를 돌려라"
|
||
|
||
html = re.sub(r"/assets/mirror/", "/s/stay/img/mirror/", html)
|
||
html = re.sub(r"/assets/(index-[\w.-]+\.(?:css|js))", r"/s/stay/vendor/\1", html)
|
||
left = re.findall(r"/assets/[^\"\\')\s]+", html)
|
||
assert not left, f"아직 /assets 를 가리키는 자리가 있다: {left[:5]}"
|
||
|
||
out = SP / "build"
|
||
out.mkdir(exist_ok=True)
|
||
(out / "index.html").write_text(html, encoding="utf-8")
|
||
|
||
env = json.loads({s["id"]: s for s in payload["theme"]["sections"]}["itinerary"]["data"])
|
||
print(f"index.html {len(html):,}자 · 일정 {len(env['items'])}개 · 캐치프레이즈 {len(items)}개 "
|
||
f"· 맛집 {len(payload['local']['restaurants'])}곳")
|
||
print(" " + " · ".join(f"{k} {sum(1 for i in items if i['kind'] == k)}"
|
||
for k in ("general", "season", "month", "weather")))
|