o2o-site-AEO/solution/site/scripts/mockup/patch_stay.py
김성경 a093921b62 feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.

- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선

검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
2026-09-14 19:40:04 +09:00

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")))