"""/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 GENERAL = json.loads((SP.parents[1] / "src/lib/lodging-catchphrases.json").read_text(encoding="utf-8")) items = [{"text": text, "kind": "general"} for text in GENERAL] payload = json.loads((SP / "stay-payload-new.json").read_text(encoding="utf-8")) payload["narrative"]["catchphrases"] = {"version": 1, "items": items} (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, []) # ── index.html 조립 ────────────────────────────────────────────────────────── html = (SP / "orig" / "index.html").read_text(encoding="utf-8") # payload 교체. 를 만들지 않도록 문자열 안의 '<' 를 이스케이프한다(구운 것과 같은 규약). blob = json.dumps(payload, ensure_ascii=False).replace("window\.__SITE_PAYLOAD__=.*?", lambda _m: "", html, count=1, flags=re.S) assert n == 1, "payload 스크립트를 못 찾았다" # ★ 이용 정보 **맨 아래 구분선**을 지운다 (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") assert "" not in css and "" not in js block = ("\n \n" " \n ") # 치환문(block)에 JS 의 정규식(\s 등)이 섞여 있어 그대로 넘기면 파이썬이 이스케이프로 읽는다 — 람다로 넘긴다. html, n = re.subn(r"\n?\s*", lambda _m: block + "", html, count=1) assert n == 1, " 를 못 찾았다" # ───────────────────────────────────────────────────────────────────────────── # 레포 안으로 끌어들이기 — 다른 사람 기계에서도 그대로 떠야 한다. # # ★ 왜 (2026-09-11, 사장님: "다른 환경에서 local/s/stay 치면 나오냐고") # 구워진 index.html 은 미러 사진 61장(`/assets/mirror/*`)과 번들 css/js 를 **루트 절대경로**로 # 가리킨다. 그 파일들은 레포가 아니라 도커 볼륨에만 있다 — 레포만 받은 사람은 흰 화면을 본다. # 번들은 더 나쁘다: 파일명이 콘텐츠 해시라 그 기계에서 구운 해시가 다르면 404 다. # 그래서 둘 다 `img/mirror/` · `vendor/` 로 복사해 두고, 경로를 `/s/stay/…` 로 바꿔 박는다. # 이러면 nginx 가 `/s/` 를 파일로 주기만 하면 되고 번들 해시와 무관해진다. # (대신 렌더러를 고쳐도 이 목업은 안 따라간다 — 목업은 원래 재굽기 대상이 아니라 그게 맞다.) 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)}개") print(" " + " · ".join(f"{k} {sum(1 for i in items if i['kind'] == k)}" for k in ("general", "season", "month", "weather")))