o2o-site-AEO/solution/site/scripts/mockup/README.md
Mina Choi 43ba33dd65 [feat] site/scripts: /s/stay 시연본 조립 도구 일습 — 일정 생성기·주입분·검증
이 목업은 payload 가 없어 프리렌더 재굽기 대상이 아니다 — `index.html` 한 장이 유일본이고
자산이 지워지면 사람이 되돌려 넣어야 한다(CLAUDE.md 의 ★★ 함정). 지금까지 그 한 장을
이 맥에서만 만들고 있었다. 다른 사람이 이어받을 수 있도록 재료를 전부 올린다.
`build/index.html` 은 생성물이라 이그노어 그대로다 — `patch_stay.py` 로 다시 나온다.

- build_itinerary.py: 테마 21개 일정 생성. 좌표에서 이동시간을 계산하고(도보 4km/h,
  1.5km 초과는 차 25km/h + 주차 5분) 입·퇴실·끼니 창을 맞춘다. 규칙 14종 감사가
  **빌드 안**에 있어 하나라도 어기면 payload 를 쓰지 않고 멈춘다 — 검사가 빌드 밖에
  있던 동안 뼈대를 고칠 때마다 안 보는 규칙이 생겼다(2026-09-11 REVIEW)
- build_story.py: 노래 25곡·인물 57명. 사진은 위키백과 문서 pageimages 만 믿는다
  (이름 검색은 동명이인을 끌고 온다 — 이수현→걸그룹, 박성현→골퍼)
- patch_stay.py: 캐치프레이즈 100개·자작곡 5곡·객실 사진(A동 12/B동 10)을 넣고
  payload 를 갈아 끼운 뒤 inject.css/js 를 `</body>` 앞에 주입해 index.html 을 짠다
- inject.js/css: React 가 다시 그려도 살아남아야 하는 다섯 가지(캐치프레이즈 순환·
  헤더 미니 플레이어·지도 링크·사진 저작자 표시·카로셀 제어). `#root` 밖에 둔다
- audit-all.mjs / rails-test.mjs: 실제 브라우저로 34종 검사, 레일 13개 자동 넘김 전수
- README.md: 이어받는 사람이 먼저 읽는 문서. 배포·함정·§7 "내가 틀렸던 6가지"
- PROMPTS.md / TEXT.md / REVIEW-2026-09-11.md: 문구 생성 프롬프트 · 전체 텍스트 · 검수 보고

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:28:52 +09:00

20 KiB

/s/stay 시연본을 만드는 스크립트 — 인수인계 문서

https://web4ai.o2osolution.ai/s/stay (로컬 http://localhost/s/stay) 한 페이지를 만드는 도구 모음이다. 고칠 자리는 전부 이 디렉토리 안에 있다.

이어받는 사람에게 — 먼저 읽을 세 줄

  1. 이 사이트는 payload 가 없는 목업이다. 백엔드·DB·프리렌더를 건드리지 않는다. 고치는 것은 구워진 index.html 하나이고, 그 파일이 유일본이다.
  2. 렌더러(solution/site/src)를 고치지 않는다. 고치면 번들 해시가 바뀌어 전 사이트 재굽기 + 전체 재업로드가 따라온다. 목업에만 필요한 것은 inject.css/inject.js 로 덮는다.
  3. 무엇을 고치든 node audit-all.mjs 가 33개 항목 전부 통과해야 끝난 것이다. "코드에서 빠졌으니 됐다" 로 끝내면 안 된다 — 실제로 그렇게 틀린 적이 있다(§7).

0. 먼저 알아야 할 것 — 왜 스크립트인가

/s/stay 는 payload 가 없는 목업이다(stay2 · stay3 도 같다). 프리렌더는 payload 를 받은 사이트만 굽기 때문에, 이 사이트는 재굽기 대상이 아니다. 고칠 자리는 이미 구워져 있는 index.html 하나이고, 그 파일이 유일본이다.

그래서 손으로 열어 고치지 않는다. 이 스크립트들이 원본 + 바꿀 것 → 새 index.html 을 만든다. 두 번째로 고칠 때 "무엇을 왜 바꿨는지" 가 파일이 아니라 여기 남아야 하기 때문이다.

stay-payload.json   원본 payload (건드리지 않는다 · 되돌릴 때 기준)
orig/index.html     원본 HTML   (건드리지 않는다)
        │
        ├─ build_itinerary.py   여행 일정을 계산해 넣는다
        ├─ build_story.py       가요 다방 · 인물 열전을 넣는다
        └─ patch_stay.py        캐치프레이즈 · 객실 사진 · 자작곡 · 주입 CSS/JS 를 넣고 HTML 조립
        │
        ▼
   build/index.html  ← 배포하는 파일

1. 한 번에 굽기

cd solution/site/scripts/mockup
python3 build_itinerary.py && python3 build_story.py && python3 patch_stay.py

순서를 지킨다. build_itinerary.py 가 stay-payload.json(원본)을 읽어 stay-payload-new.json 을 만들고, 뒤 둘이 그 파일을 이어서 고친다. 순서를 바꾸면 앞 단계 결과가 지워진다.

로컬 반영:

docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html

검사:

node audit-all.mjs                      # 33개 항목 전수 점검 (로컬)
node audit-all.mjs https://web4ai.o2osolution.ai/s/stay   # 킹서버

2. 무엇을 어디서 고치나

히어로 문구 (캐치프레이즈 100개) → patch_stay.py

화면 맨 위, 상호 아래에서 7초마다 단어 단위로 바뀌는 한 줄이다.

자리 줄 무엇
GENERAL patch_stay.py:20 일반 40개 — 언제 나와도 되는 문구
SEASON patch_stay.py:62 계절별 24개 — 봄/여름/가을/겨울 각 6개
MONTH patch_stay.py:72 월별 24개 — 1~12월 각 2개
WEATHER patch_stay.py:87 날씨별 12개 — 맑음/구름많음/흐림/비/눈

문장만 더하거나 지우면 된다. 개수 제한은 없다.

나오는 순서는 inject.js 의 buildCycle() 이 정한다 — 일반 → 계절 → 일반 → 월 → 일반 → 날씨 한 바퀴를 만들고 그 바퀴를 돈다. 일반은 자루에서 뽑아 쓰고 비면 다시 채운다(같은 문장이 연달아 나오지 않게).

  • 계절 구분은 3개월 단위다: 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울 (inject.js SEASON_OF_MONTH)
  • 날씨는 실시간이다. /v1/local/weather 를 10분마다 읽어 분류한다. 분류 기준은 렌더러(site/src/lib/use-live-weather.ts)와 같은 값을 쓴다 — 갈리면 화면의 날씨와 문구가 어긋난다.
  • 못 받아 오면 날씨 문구는 순환에서 빠진다(거짓말을 하지 않는다).
  • 구워진 첫 문장은 5초 두고 시작한다. 들어오자마자 바뀌면 읽던 것을 놓친다.
  • prefers-reduced-motion 이면 애니메이션 없이 글자만 바뀐다.

여행 일정 → build_itinerary.py

사람은 "어느 자리에 어느 장소" 만 고르고, 시각은 스크립트가 계산한다.

자리 줄 무엇
PLACES :40 장소 대장 — 좌표 · 지도 검색어 · 체류시간(최소/기본/최대) · 거기서 무엇을 하는지
STAY_ACTION :108 머뭄 칸의 행위 — 체크인 · 아침 · 아침(퇴실) · 쉼 · 쉬는 날 · 머뭄으로 · 마무리
WINDOW :156 시간대가 정해진 자리 — 입실 15:00+ · 점심 11:30~13:30 · 저녁 17:30~20:00 …
SKELETON :182 하루 뼈대 — 자리 이름만 나열. @ 가 붙은 것은 머뭄
THEMES :368 테마 21개 — 이름 · 기간 · 누구에게 · 왜 · [(뼈대, [장소…])]
PACING :470 테마별 시작 시각과 체류 배율(쉬러 오면 1.25배, 사진 찍으면 0.8배)

새 장소를 넣으려면 PLACES 에 한 줄 더한다. 좌표는 카카오 로컬 검색에서 받은 실제 값이어야 한다(지어내지 않는다). 그다음 THEMES 의 원하는 자리에 이름을 적는다.

새 테마를 넣으려면 THEMES 에 한 덩이, PACING 에 한 줄 더한다. 뼈대의 @ 없는 자리 수와 장소 개수가 같아야 한다 — 틀리면 빌드가 멈추고 알려 준다.

계산되는 것 (손으로 적지 않는다)

  • 이동시간 — 좌표에서. 1.5km 까지 도보 4km/h, 그 위는 차 25km/h + 주차 5분, 최소 3분
  • 도착·출발 시각 — 시작 시각부터 누적
  • 끼니 시각 맞추기 — 창을 벗어나면 앞 칸 체류를 범위 안에서 늘이고 줄인다. (사람이 실제로 그렇게 한다 — 밥 때가 이르면 박물관을 조금 더 본다.) 그래도 안 맞으면 시작 시각을 10분씩 옮긴다. 그래도 안 되면 그 조합을 만들지 않는다.

가요 다방 · 인물 열전 → build_story.py

자리 줄 무엇
SONGS :28 군산을 노래한 25곡 — 곡명·가수·작사·작곡·연도·라벨색·한 줄
PEOPLE :109 인물 57명 — 위키 문서 제목 · 표시 이름 · 연도 · 하는 일 · 한 줄
  • 노래 출처는 '음악의 오솔길' 군산 지역노래 정리. 연도·작사·작곡을 모르면 비운다 ("미상"이라고 적지 않는다 — 렌더러가 없는 칸을 안 그린다).
  • 듣기 링크는 유튜브 검색 주소다. 영상 ID 를 박으면 틀린 영상이 붙을 수 있고 한 곡씩 확인하지 않았다. 검색은 틀릴 수가 없다.
  • 인물 사진은 두 곳에서 온다. ① people-photos.json — 위키백과 문서 대표 사진(pageimages) 중 자유 라이선스가 확인된 17장(CC BY / CC BY-SA / KOGL / 공용). CC BY-SA 는 저작자 표시가 라이선스 조건이라 화면에 그대로 찍는다 — 지우면 침해다. ② 원본 payload 에 있던 사진 — 위키에 사진이 없는 사람(채만식)은 이 사이트가 원래 쓰던 미러 사진으로 되돌린다. 새로 가져오는 게 아니라 자리로 돌리는 것이다.
  • ★ 이름만으로 커먼즈를 검색해 사진을 붙이지 않는다. 동명이인이 잡힌다 — 실측(2026-09-11): 이수현(독립운동가)에 걸그룹 멤버, 은성수(금융위원장)에 축구선수, 박성현(양궁)에 골퍼, 전진(비전향 장기수)에 아이돌, 이길여에 건물 사진이 왔다. 틀린 사진은 없는 것만 못하다. 그 사람의 문서가 대표로 쓰는 사진만 신뢰한다.
  • 숙소가 만든 곡은 이 섹션에 넣지 않는다. 도시의 노래를 모으는 자리다.

객실 사진 · 자작곡 · 대표 사진 → patch_stay.py

자리 줄 무엇
OWN_SONGS :109 머뭄이 만든 5곡 — 플레이어만 읽는다(narrative.ownSongs)
ROOM_PHOTOS :142 객실 사진 배정 — A동 12 · B동 10 · 공용
HERO_PHOTO :179 대표 사진. None 이면 원본 사진이 계속 대표(지금 그렇다)

★ 객실 A/B 배정은 눈으로 하지 않는다. 야놀자 등록본의 Next.js 데이터에 roomTypes[].photos 가 객실별로 들어 있고, 그걸 뽑아 둔 것이 rooms.json 이다. 눈으로 갈랐을 때 11/5 가 나왔고 실제는 12/10 이었다.

★ 히어로와 소개 사진은 원본(네이버 예약본 미러) 을 쓴다. 히어로는 isPrimary, 소개는 대표가 아닌 첫 사진을 쓰는 구조라 (site/src/sections/HeroSection.tsx:40 · AboutSection.tsx:23), 원본 사진을 목록 앞에 두고 등록본 사진을 뒤에 붙인다. 순서가 곧 그 두 자리다.

화면에 덧대는 것 → inject.css · inject.js

렌더러(solution/site/src)를 고치면 다른 사이트까지 바뀌고 전체 재굽기가 따라온다. 그래서 이 목업에만 필요한 것은 index.html 안에 주입한다.

inject.js 가 하는 일 다섯:

  1. 캐치프레이즈 순환 — React 가 그린 <p> 를 찾아 단어 단위로 갈아 끼운다
  2. 헤더 미니 플레이어 — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡, 랜덤 재생
  3. 링크 걸기 — 일정 정거장 187개 · 엽서 4개를 네이버 지도로
  4. 사진 저작자 표시 — 이 목업이 물고 있는 번들이 옛 판이라 imageCredit 을 안 그린다
  5. 카로셀 제어 — 만지는 동안 멈추고, 직접 민 레일은 다시 안 돈다

★ 카로셀 (tameRails) 은 렌더러 정책을 덮는 것이다. 렌더러의 자동 넘김(site/src/lib/ui/use-rail-autoplay.ts)은 4.5초 고정 타이머이고, 정지는 held 카운터 하나로 센다 — 레일 위 pointerdown 에 올리고 window pointerup· pointercancel 에 내린다. 주입분은 타이머를 건드리지 않고 그 카운터만 쓴다 (pointerdown 을 button: 2 로 쏜다 — embla 는 주 버튼이 아닌 것을 드래그로 치지 않는다). 구현이 둘(embla · 스크롤 상자)이어도 카운터는 하나라 한 번에 걸린다.

규칙 셋:

언제 얼마나
쿨타임 무엇이든 만지면(세로 스크롤·휠·키보드 포함) 7초
손이 닿아 있는 동안 손가락·마우스 버튼이 내려가 있는 내내 뗄 때까지
영구 정지 레일이 실제로 가로로 움직였을 때 그 레일은 끝

★ 손가락 수로 센다 — pointer 로 세면 안 된다 (2026-09-11). 브라우저는 터치가 페이지 스크롤로 넘어가는 순간 손가락이 아직 닿아 있는데 pointercancel 을 쏜다. 훅의 정지가 거기서 풀려, 손가락 밑에서 카드가 넘어간다. 실측: touchstart 0.0s → pointercancel 7.5s → 8.4s 에 넘어감. 그래서 event.touches.length 로 센다 — 손이 떨어져야 0 이 된다.

★ "만졌다" 와 "밀었다" 를 가른다 (2026-09-11). 전에는 레일 위 pointerdown·wheel 이면 곧바로 영구 정지였다. 그런데 일정 카로셀은 화면 가운데를 가득 채워서 페이지를 세로로 내릴 때 손가락·커서가 거의 언제나 그 위를 지난다 — 한 번 스크롤하면 그 레일이 영영 멈췄다(실측: 세로 휠 한 번 → 20초 0px). 지금은 제스처 시작 때 위치를 적어 두고 손을 뗀 뒤(관성 350ms) 다시 재서, 8px 넘게 달라졌을 때만 영구 정지다.

★ 호버 정지는 렌더러 것이고 그대로 둔다 — 커서를 얹으면 "읽는 중"이 맞다(사장님 확인).

검증: node rails-test.mjs — 레일 13개를 데스크톱·모바일에서 ①가만히 ②만지는 중 ③민 뒤로 각각 잰다. → 운영 사이트에는 이 문제들이 그대로 있다. 훅을 고치는 것은 별도 작업이다(§8).

★ React 가 다시 그리면 사라진다. 그래서

  • 플레이어 목록·패널은 #root 밖(body 직속)에 둔다
  • 헤더에 꽂는 단추는 MutationObserver 로 감시해 다시 꽂는다
  • 캐치프레이즈는 노드를 들고 있지 않고 매 순환마다 다시 찾는다

★ window.load 를 기다리지 않는다. 이 페이지는 경로 지도 때문에 OSM 타일 <img> 가 140장이라 load 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다.


3. 규칙 검사 — 어기면 빌드가 멈춘다

build_itinerary.py 의 audit()(:526)이 매 실행 검사한다. 하나라도 어기면 payload 파일을 쓰지 않고 위반 목록을 찍고 멈춘다.

규칙 이 규칙이 없어서 났던 일
1 하루의 첫 칸·마지막 칸은 스테이 머뭄 출발지가 화면에서 사라졌다
2 같은 자리가 연달아 서지 않는다 아침 · 머뭄 다음이 체크아웃 · 머뭄
3 가운데 머뭄 칸은 '쉼'·'쉬는 날' 뿐 마지막 날 머뭄이 3번
4 이동시간 = 좌표 계산값 담 하나 건넌 곳 8분, 45분 걸리는 섬 10분
5 입실·점심·늦은 점심·저녁·밤 산책 시간대 점심 09:40, 저녁 13:09
6 퇴실 11:00 전 체크아웃 11:35
7 21:00 초과 금지 렌더러가 그 칸을 조용히 버린다
8 끼니 자리에는 먹는 곳만
9 같은 날 같은 식당 두 번 금지
10 한 테마 안에서 같은 곳 반복 금지 같은 테마에 한일옥이 두 번
11 "비가 와도 되는" 테마는 실내만 비 코스에 야외 두 곳
12 "걸어서만/차 없이" 테마에 차 필요한 곳 금지
13 1박 2일 = 2일, 2박 3일 = 3일
14 아이 동반 테마에 술집 금지

★ 왜 빌드 안인가 — 전에는 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다. 그래서 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생겼고 사장님이 화면에서 먼저 찾으셨다. 검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다.

audit-all.mjs 는 그다음 단계다 — 구워진 HTML 을 실제 브라우저로 열어 33개 항목을 센다(캐치프레이즈 개수, 링크 개수, 사진 개수, 모바일 레이아웃, 404, 콘솔 오류…).


4. 배포

로컬

docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html

/s/ 는 5분 캐시(max-age=300)다 — 확인할 때 ⌘⇧R.

킹서버

tar czf /tmp/stay-bundle.tgz -C . build/index.html img audio
scp -C /tmp/stay-bundle.tgz King_admin:~/data2/o2o-site-AEO/tmp-stay-deploy/
ssh King_admin "cd ~/data2/o2o-site-AEO/tmp-stay-deploy && rm -rf build img audio && tar xzf stay-bundle.tgz
C=o2o-web4ai-solution-prerender
docker exec \$C mkdir -p /app/solution/site/out/s/stay/img/people /app/solution/site/out/s/stay/audio
docker cp build/index.html \$C:/app/solution/site/out/s/stay/index.html
for f in img/*.jpg;        do docker cp \"\$f\" \$C:/app/solution/site/out/s/stay/img/ ; done
for f in img/people/*;     do docker cp \"\$f\" \$C:/app/solution/site/out/s/stay/img/people/ ; done
for f in audio/*.mp3;      do docker cp \"\$f\" \$C:/app/solution/site/out/s/stay/audio/ ; done"

확인: md5 -q build/index.html 과 ssh King_admin "docker exec o2o-web4ai-solution-site md5sum /srv/sites/s/stay/index.html" 이 같아야 한다. 그다음 node audit-all.mjs https://web4ai.o2osolution.ai/s/stay.

되돌리기: 컨테이너 안에 index.html.bak-20260910 이 있다.


5. 밟으면 조용히 틀리는 자리

  • out/assets 에 파일을 넣지 않는다. 거기는 프리렌더가 관리한다(해시 번들 대장 · 30일 보관). 손으로 넣은 사진·음원은 /s/stay/img/ · /s/stay/audio/ 에 둔다 — nginx 의 ^~ /s/ 가 그대로 서빙하고(Range 206 확인) 정리 대상이 아니다.
  • 마크업을 고쳐 지우지 않는다. React 가 payload 로 다시 그리면서 되돌린다. 화면에서 없애야 하면 주입 CSS 로 끈다(이용 정보 맨 아래 가로선이 그 경우다).
  • 렌더러가 21시를 넘기는 칸을 조용히 버린다(shared/section-data.ts PLAN_ENDS_BY). 적어 놓고 화면에 안 나오는 칸이 생긴다 — 규칙 7이 이걸 막는다.
  • imageCredit 은 문자열이다(site/src/sections/items/common.tsx:206). 객체를 넣으면 조용히 안 나오고, 그건 라이선스 위반이 된다.
  • 자동 넘김을 끄려고 렌더러를 고치지 않는다. 번들 해시가 바뀌면 이 목업은 옛 해시를 가리킨 채 남고(보관 기간 안에는 살아 있다), 다른 사이트는 전부 재굽기 대상이 된다.
  • 크롤러가 보는 HTML 은 옛 내용이다. 이 페이지는 payload 로 하이드레이션해서 화면을 바꾸는 것이라, SSR 마크업과 llms.txt 는 구운 날 그대로다. 시연에는 문제없지만 검색·AI 에 인용되는 값은 아니다.

6. 남아 있는 일 · 넘기는 판단

상태
하루에 숙소가 3번 서는 날(아침 → … → 쉼 → 저녁 → 복귀) 사장님 컨펌 대기. 점심~저녁 4시간을 바깥 두 곳으로 못 채워 넣은 칸이다. 빼려면 오후 정거장을 하나 더 넣어야 한다
가요 다방 25곡 (목표 50) 출처에서 확인되는 군산 곡이 거기까지다. 더 채우려면 지어내야 한다 — 하지 않는다
인물 사진 18/57 위키 문서에 사진이 있는 사람이 18명뿐이다. 동명이인 함정 때문에 이름 검색으로 늘리지 않는다
렌더러의 카로셀 자동 넘김 운영 사이트에 그대로 있다. 고치려면 use-rail-autoplay.ts 에 "손님이 직접 밀면 타이머를 끝낸다" 를 넣고 전체 재굽기 + republish_all
og:image 구운 head 에 옛 주소가 남아 있다. 화면은 새 사진, 카톡 공유 썸네일만 옛것

7. 이 목업을 고치다 실제로 틀렸던 방식 — 되풀이하지 말 것

한 짓 어떻게 틀렸나 대신 할 것
코드에서 빠진 것만 보고 "고쳤다" 고 보고 화면에는 그대로 있었다 node audit-all.mjs 로 브라우저에서 확인하고 숫자를 적는다
지시받은 한 줄만 고침 "1번은 숙소" 를 넣으면서 그 하루 전체를 안 읽어 숙소가 3번 서고 두 칸이 연달아 섰다 한 곳을 고치면 같은 종류를 전수 검사한다
규칙 검사를 매번 일회용 스크립트로 뼈대를 고칠 때마다 "이번에 안 본 규칙" 이 생겼다 검사를 빌드 안에 둔다(build_itinerary.py audit())
사진을 눈으로 보고 A/B 배정 11/5 가 나왔는데 실제는 12/10 이었다 등록본 데이터(rooms.json)를 쓴다
마크업을 고쳐서 요소 제거 하이드레이션이 되돌렸다 주입 CSS 로 끈다
이름으로 사진 검색 동명이인이 붙었다 그 사람 문서의 대표 사진만

8. 파일 목록

파일 무엇
build_itinerary.py 여행 일정 계산 + 규칙 14종 검사
build_story.py 가요 다방 · 인물 열전
patch_stay.py 캐치프레이즈 · 객실 · 자작곡 · HTML 조립
inject.css · inject.js 화면에 덧대는 것(플레이어 · 문구 순환 · 링크 · 크레딧)
audit-all.mjs 브라우저로 33개 항목 전수 점검
verify.mjs · player.mjs · check2.mjs · kingcheck.mjs · rail.mjs · arrow.mjs 부분 점검
stay-payload.json · orig/index.html 원본. 건드리지 않는다
stay-payload-new.json 중간 산출물
build/index.html 배포하는 파일
img/ (28장) · img/people/ (17장) · audio/ (5곡) 같이 올리는 자산
rooms.json · people-photos.json · people-raw.json · coords.json 외부에서 뽑아 둔 원자료
REVIEW-2026-09-11.md 스케줄 오류 보고서(사장님 보고용)
PROMPTS.md 이 콘텐츠를 만드는 프롬프트 — 제품으로 옮길 자리까지