수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다. - NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가 - 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호 - 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시 - 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가 - 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선 검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
215 lines
14 KiB
Python
215 lines
14 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
|
|
|
|
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 교체. </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 스크립트를 못 찾았다"
|
|
|
|
# ★ 이용 정보 **맨 아래 구분선**을 지운다 (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 "</style>" not in css and "</script>" not in js
|
|
|
|
block = ("\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/` 를 파일로 주기만 하면 되고 번들 해시와 무관해진다.
|
|
# (대신 렌더러를 고쳐도 이 목업은 안 따라간다 — 목업은 원래 재굽기 대상이 아니라 그게 맞다.)
|
|
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")))
|