# `/s/stay` 시연본 — 지침서 한 장 > **이 문서 하나만 읽으면 된다.** 예전에는 README · SECTIONS · PROMPTS · REVIEW 로 흩어져 > 있었고 무엇을 먼저 볼지 알 수 없었다. 전부 여기로 합쳤다(2026-09-11). > 같은 폴더에 **생성물 두 개**가 더 있는데 지침이 아니라 **데이터**다 — > `TEXT.md`(화면에 나가는 텍스트 전문, `python3 dump_text.py`)와 > `SCHEDULE.md`(일정 21개 전수 검수표, `python3 audit_schedule.py`). 대상 `https://web4ai.o2osolution.ai/s/stay` (스테이,머뭄 · 전북 군산) ## 지금 누가 무엇을 하나 | | 하는 일 | 읽을 곳 | |---|---|---| | **사장님(민아)** | `/s/stay` 시연본 자체를 고친다 | **2부** | | **나머지 팀원** | 시연본에 있는 것을 **제품이 자동으로 만들게** 옮긴다 | **1부** | 시연본은 **payload 가 없는 목업**이다. 프리렌더가 굽지 않고 `index.html` 한 장을 손으로 조립했다. 그래서 "화면에 있는 것"과 "제품이 만들 수 있는 것"이 갈라져 있고, 1부가 그 간격을 메우는 작업이다. --- --- # 1부 — 실제 구현을 하는 사람 ## 1.1 어떤 파일을 여나 (결론부터) **목업 폴더는 사양서다. 코드는 여기서 고친다.** ### ① 계약 — 화면에 새 값을 내려면 여기부터 | 파일 | 무엇 | |---|---| | `solution/shared/src/types/site-payload.ts` | payload 필드 정의. `Narrative`(`:316`) · `MediaItem` · `Unit` | | `solution/shared/src/lib/section-data.ts` | 섹션 봉투(`{kind,version,title,items[]}`) 타입 + `planDay()`. **21시 넘는 칸을 버리는 규칙이 여기 있다**(`:535`) | | `solution/shared/src/lib/section-prompts.ts` | 프롬프트 — **서버 생성 잡이 쓰는 6종**(songs · daily · people · chronicle · postcard · quiz). 고친 뒤 `npm run export:prompts` | | `solution/frontend/src/features/builder/canvas/dataSpec.ts` | 프롬프트 — **빌더 [콘텐츠] 탭에서 붙여넣는 9종**. 위 6종은 shared 를 import 하고, **video · event · itinerary 셋은 여기에만 있다**(`itinerary` 는 `:556`) | ### ② 생성 — 값을 만드는 곳 | 파일 | 무엇 | 관련 할 일 | |---|---|---| | `solution/backend/services/site_payload.py` | 섹션 목록·기본 on/off(`:155` 부근) · payload 조립 | T1 | | `solution/backend/services/itinerary.py` | 여행 일정 생성 | **T2** | | `solution/backend/services/story_service.py` | 지역 이야기(노래·인물·연표·엽서·퀴즈) 생성 잡 | T3 T5 | | `solution/backend/services/external/wikimedia.py` | 위키 사진 | T3 | | `solution/backend/services/collector/` | 수집 어댑터 | T6 | ### ③ 화면 — 발행본 렌더러 | 파일 | 무엇 | |---|---| | `solution/site/src/sections/` | 섹션 컴포넌트. 히어로는 `HeroPension.tsx`, 일정은 `items/ItinerarySection.tsx` | | `solution/site/src/lib/ui/use-rail-autoplay.ts` | 카로셀 자동 넘김 정책 | ### ④ 사양 — 이 목업 폴더에서 가져갈 것 | 파일 | 옮길 자리 | |---|---| | 이 문서 **1.4 프롬프트 전문** | `shared/src/lib/section-prompts.ts` | | `build_itinerary.py` (규칙 17종) | `backend/services/itinerary.py` | | `build_story.py` (위키 사진 수집) | `backend/services/external/wikimedia.py` | | `inject.js` (렌더러에 **없는** 기능 3개) | `site/src/sections/` | --- ## 1.2 섹션 22개 — 지금 상태 `할 일` 이 비면 제품이 **이미** 만들 수 있다. 손댈 필요 없다. | id | 이름 | 값이 오는 자리 | 지금 누가 만드나 | 할 일 | |---|---|---|---|---| | `hero` | 히어로 | `narrative` | 사장님이 적음 | **T1** | | `intro` | 소개 | `narrative.about[]` | LLM(빌더) | | | `rooms` | 객실 안내 | `units[]` + `media[]` | 사장님이 올림 | **T6** | | `event` | 소식 | `data(kind=event)` | 사장님이 적음 | | | `info` | 기본 정보 | `facts[]` | 사장님이 적음 | | | `booking` | 실시간 예약 | — | (시연본은 꺼 둠) | | | `video` | 영상 | `data(kind=video)` | 사장님이 적음 | | | `photos` | 사진 갤러리 | `media[]` | 사장님이 올림 | | | `map` | 오시는 길 | `place` + `routes[]` | 수집 | | | `festival` | 계절별 축제 | `local.festivals` | TourAPI 수집 | | | `local` | 지역 정보 | `local.attractions/restaurants` | TourAPI 수집 (시연본 맛집은 `restaurants.json` 31곳) | | | `itinerary` | **추천 일정** | `data(kind=itinerary)` | `services/itinerary.py` + 빌더 프롬프트 `dataSpec.ts:556` | **T2** | | `story` | 군산 이야기 | 아래 6탭 껍데기 | — | | | `reading` | 지역 읽기 | `local.story.reading` | LLM `section-prompts.ts` `reading` | | | `songs` | 가요 다방 | `data(kind=songs)` | LLM `section-prompts.ts:67` | **T3 T5** | | `daily` | 오늘의 한 장 | `data(kind=daily)` | LLM `:91` | 시연본은 노래·문학 39장(`build_daily.py`) | | `people` | 인물 열전 | `data(kind=people)` | LLM `:113` | **T3 T5** | | `chronicle` | 시간의 골목 | `data(kind=chronicle)` | LLM `:135` | | | `postcard` | 오늘의 엽서 | `data(kind=postcard)` | LLM `:160` | 시연본은 탭을 비우고 독립 "엽서 쓰기" 로 대체(**2.5 항목 9**) | | `quiz` | 뒤집어 보는 질문 | `data(kind=quiz)` | LLM `:188` | | | `faq` | 자주 묻는 질문 | `faqs[]` | LLM + 사장님 | | | `rules` | 이용 규정 | `facts[]` | 사장님이 적음 | | | `weather` | 날씨 | `local.weather` | open-meteo | | | `planner` | 계절별 추천 하루 | `data(kind=planner)` | **없음** | (시연본은 꺼 둠) | 섹션이 아닌 것이 둘 더 있다 — 헤더 음악 플레이어(**T4**) · **엽서 쓰기**(독립 섹션, `festival` 바로 앞). **군산 읽기**(T7)는 2026-09-15 에 렌더러로 들어갔다 — 이제 payload 에 `local.story.reading` 만 있으면 새 업장에서도 탭이 선다. --- ## 1.3 할 일 여덟 ### T1 — 히어로 캐치프레이즈 **증상** 히어로 문구가 사이트마다 한 줄로 고정이다. 계절도 날씨도 안 탄다. 계약(`site-payload.ts:316` `Narrative`)에 문자열 `tagline` 하나뿐이고, 히어로는 `tagline ?? heroSubline ?? summary` 를 그대로 찍는다(`HeroPension.tsx:125`). **할 일** 1. `Narrative` 에 필드를 더한다. ```ts catchphrases?: { version: 1; items: { text: string; // 25자 안쪽 kind: 'general' | 'season' | 'month' | 'weather'; season?: '봄'|'여름'|'가을'|'겨울'; month?: number; weather?: '맑음'|'구름많음'|'흐림'|'비'|'눈'; }[] }; ``` 2. **대표 문구(`tagline`)는 고정하고, 순환은 그 아래 줄에서 돈다** (2026-09-11 사장님 지시). 대표 문구 자리를 갈아 끼우면 숙소의 한 줄이 첫 화면에서 사라진다. 아래 줄은 대표 문구보다 한 단 작고 옅게, 높이는 가장 긴 문구에 맞춰 잡는다(히어로가 아래 정렬이라 줄 수가 바뀌면 튄다). 히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다. 순환 순서는 `일반 → 계절 → 일반 → 월 → 일반 → 날씨` 한 바퀴를 만들고 그 바퀴를 돈다. 일반은 자루에서 뽑아 쓰고 비면 다시 채운다(같은 문장이 연달아 안 나오게). 계절은 3개월 단위(3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울). 날씨는 `/v1/local/weather` 를 10분마다 읽고, **못 받아 오면 날씨 문구를 순환에서 뺀다**. 목업 구현은 `inject.js` 의 `startCatchphrase()` · `buildCycle()`. 3. 프롬프트는 **1.4 ①** 을 쓴다. 100개(일반 40 · 계절 24 · 월 24 · 날씨 12). 4. **`STORY_KINDS` 에 등록한다.** 빠뜨리면 서버 생성 잡이 이 종류를 아예 모른다 — `daily` 가 그래서 한동안 빈칸이었다. **완료 기준** 새 업장 발행 시 문구 100개가 돌고, 9월에 겨울 문구가 안 나온다. --- ### T2 — 추천 일정 ★ 가장 큰 일 **증상 ①** 서버 생성기(`services/itinerary.py`)는 하루를 **관광지 2 + 맛집 2** 로 짠다. 체류시간·이동시간·입퇴실·끼니 시각 개념이 없다. 그래서 나온 하루가 "둘째 날 해산물축제 → 근대건축관" 으로 끝난다. **증상 ② 프롬프트가 두 곳으로 갈려 있다.** 일정 프롬프트는 `shared/src/lib/section-prompts.ts` 에 **없고** `frontend/src/features/builder/canvas/dataSpec.ts:556` 에만 있다. 그 파일은 **빌더에서 붙여넣는 9종**을 담는데, 그중 `songs` 등 6종은 shared 를 import 하고 **`video` · `event` · `itinerary` 셋은 자기 자리에 직접 적혀 있다.** → 서버 생성 잡(`story_service.py`)은 shared 만 읽으므로 **이 셋을 만들 줄 모른다.** `daily` 가 빈칸이던 것과 **같은 종류의 사고**다. 옮기면서 shared 로 올린다. **증상 ③ 지금 프롬프트가 모델에게 시각 계산을 시킨다.** `dataSpec.ts` 의 일정 규칙에 이런 줄이 있다 — "밤 9시를 넘기는 칸은 화면에서 빠진다 — **시각을 계산해 보고 넣는다**", 그리고 `minutes` · `moveMinutes` 를 모델이 적게 한다. 모델은 못 한다. 이게 시연본 첫 판이 망가진 직접 원인이다. **★ 프롬프트 하나로 일정을 받지 않는다.** 모델은 시각 계산을 못 한다 — 이동시간을 지어내고, 점심을 09:40 에 넣고, 21시를 넘겨 **렌더러가 조용히 버리는** 칸을 만든다(`shared/src/lib/section-data.ts:535`). 시연본 첫 판이 정확히 그랬다. **세 단계로 나눈다.** | 단계 | 누가 | 무엇을 내놓나 | 프롬프트 | |---|---|---|---| | **① 장소 대장** | LLM | 장소 30곳 × `{stayMinutes:{min,base,max}, todo, indoor, needsCar, bestTime, searchQuery}` | **1.4 ②** | | **② 좌표** | 코드 | 카카오 로컬로 `searchQuery` → 위경도. **LLM 이 적은 좌표는 쓰지 않는다** | — | | **③ 조립** | **코드** | 이동시간·도착시각·끼니 창 맞추기·규칙 17종 감사 | 없음 (`build_itinerary.py`) | | **④ 테마** | LLM | 테마 20개 × `{name, duration, audience, why, days[{kind, places[]}]}` | **1.4 ③** | **코드가 하는 일(③)에서 반드시 옮겨야 하는 것** — 근거는 `build_itinerary.py` 줄번호 | | 무엇 | 어디 | |---|---|---| | 이동시간 | 좌표 계산. 도보 4km/h(≤1.5km), 그 위는 차 25km/h + 주차 5분, 최소 3분 | `move_minutes()` | | 숙소 고정 | 모든 일정이 **숙소에서 출발해 숙소로 복귀**. 첫 칸·마지막 칸이 숙소 | `:108` `:182` | | 시각 창 | 입실 15:00+ · 퇴실 11:00− · 점심 11:30–13:30 · 늦은 점심 13:00–15:00 · 저녁 17:30–20:00 · 밤산책 18:30–20:00 | `:156` `:166` | | 창 맞추기 | 어긋나면 **앞 칸 체류를 범위(min~max) 안에서** 늘이고 줄인다. 이동시간을 고치지 않는다. 그래도 안 되면 시작 시각을 10분씩 옮기고, 그래도 안 되면 **그 조합을 만들지 않는다** | `plan_day()` | | 테마별 완급 | 시작 시각·체류 배율이 테마마다 다르다(쉬러 오면 1.25배, 사진 찍으면 0.8배) → 끝나는 시각이 13:35~21:00 로 퍼진다 | `:470` | | 끼니 간격 | 앞 끼니가 끝나고 **3시간**이 지나야 다음 끼니 창이 열린다(`MEAL_GAP`). 창만 지키면 통과하던 자리다 | `_window_of()` | | **감사 17종** | 하나라도 어기면 **payload 를 쓰지 않고 멈춘다** | `:575` · 목록은 **2.4** | ★ **감사는 생성기 안에 둔다.** 밖에 두면 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생긴다 — 실제로 그래서 33건이 통과했다(**3.2**). **하루 뼈대** — `@` 는 숙소 ``` 첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@ 가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@ 쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@ 마지막날(도보) 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@ ← 짐을 맡기고 저녁에 찾으러 온다 마지막날(차) 아침(퇴실·차)@ · 오전 · 점심 · 오후 ← 짐을 싣고 나섰다, 복귀 칸이 없다 ``` ★ **떠나는 날은 둘로 갈린다** (2026-09-14 대표 의견). 차로 온 손님에게 "짐을 맡기고 저녁에 찾으러 오세요" 는 **한 번 더 들르라는 말**이다 — 퇴실할 때 실으면 끝날 일이다. 그래서 차 테마의 마지막 날은 마지막 정거장에서 끝난다. 짐 보관 동선은 걸어 다니는 손님 것이고 그 테마만 남는다. 뼈대를 두 벌로 적지 않고 **복귀 칸만 떼어** 만든다(`build_itinerary.py` `CAR_KINDS`). **완료 기준** 새 업장에서 테마 20개가 나오고 `audit()` 위반 0건. 하루가 숙소로 시작해 숙소로 끝나고, 끼니가 제 시각에 있고, 21시를 안 넘는다. --- ### T3 — 인물·노래 사진 **증상** 프롬프트가 사진 URL 을 금지하고 `imageQuery` 만 받는다(`section-prompts.ts:128`). 초상권·저작권 때문이고 그 판단은 맞다. 그런데 **다음 단계가 없어서** 인물 열전이 활자만으로 선다. **할 일** 1. `services/external/wikimedia.py` 에 **문서 → `pageimages` → `imageinfo.extmetadata`** 경로를 붙인다. 2. ★ **이름으로 커먼즈를 검색하지 않는다.** 동명이인이 온다 — 실측(2026-09-11): 이수현(독립운동가)→걸그룹, 은성수(금융위원장)→축구선수, 박성현(양궁)→골퍼, 전진(비전향 장기수)→아이돌, 이길여→건물. **틀린 사진은 없는 것만 못하다.** 그 사람의 **위키백과 문서가 대표로 쓰는 사진**만 믿는다. 구현은 `build_story.py`. 3. 저작자·라이선스를 `imageCredit` 으로 payload 에 넣는다. ★ **문자열이다**(`site/src/sections/items/common.tsx:206`). 객체로 넣으면 렌더러가 조용히 아무것도 안 그린다 — CC BY-SA 는 출처 표시가 **라이선스 조건**이라 그건 침해다. 4. 사진 있는 사람을 **앞으로** 정렬한다. 빈 카드가 먼저 오면 목록이 비어 보인다. **완료 기준** 인물 카드에 사진과 저작자 줄이 같이 뜬다. 시연본 실적 57명 중 18명. --- ### T4 — 음악 플레이어 **증상** `media` 에 오디오 종류가 없다. 목업은 mp3 를 `/s/stay/audio/` 에 **직접 넣었다**. **할 일** `MediaItem` 에 `kind:'audio'`(+`title` `artist` `durationSec`)를 넣고 발행 때 같이 올린다. 헤더에 붙는 작은 플레이어를 만든다 — 아이콘 · 현재 곡명 · 재생/멈춤 · 목록. 순서는 **랜덤 시작 후 순차**. 목업 구현은 `inject.js` 의 `startPlayer()`, 모양은 `inject.css` 의 `#w4d-mini` · `#w4d-panel`. ★ **자동재생한다**(2026-09-14 대표 지시 — 2026-09-11 의 "자동 빼" 를 뒤집은 것이다). 다만 **브라우저가 막는다.** 크롬·사파리는 손님이 페이지를 한 번도 만지지 않았으면 소리 나는 재생을 거절한다(정책이고, 거절된 약속으로 온다). 실측 2026-09-14, 크롬: 불러온 직후 재생 0, 세로 스크롤 한 번에 0:02 부터 재생. 그래서 구현이 **두 단계**다 — ① 불러오자마자 걸어 본다 ② 막히면 손님이 **처음 만지는 순간**(스크롤·클릭·키·터치) 켠다. → 첫 손길 리스너는 `#w4d-mini`·`#w4d-panel` 안에서 난 이벤트를 **흘려보내야 한다.** 예전 판은 window 캡처라 재생 단추의 클릭을 먼저 먹었고, 단추 핸들러가 다시 꺼서 두 번 눌러야 켜졌다(2026-09-11). 검사는 `audit-all.mjs` 의 `자동재생`. --- ### T5 — 상한(`maxItems`) | 종류 | 지금 | 시연본 | 왜 | |---|---|---|---| | songs | 8 | 25 | 8곡이면 '다방'이 아니라 목록이다 | | people | 10 | 57 | 위키백과 '군산시 출신' 분류만으로 57명이 확인된다 | ★ **한 번에 다 받지 않는다.** 25곡을 한 번에 시키면 뒤쪽이 급격히 부실해진다. 15곡씩 나눠 받고 제목으로 중복을 지운다 — 나누는 문구는 **1.4 ④** 에 있다. ★ `maxItems` 와 프롬프트 안의 숫자를 **같이** 바꾼다. 어긋나면 받아 놓고 잘라 버린다. --- ### T6 — 객실 사진 출처 시연본은 야놀자 등록본(`nol.yanolja.com/stay/domestic/10068088`)의 Next.js 플라이트 데이터에서 `roomTypes[].photos` 를 파싱해 A동 12·B동 10 을 가져왔다(`rooms.json`). **손으로 한 것이고 제품 경로가 아니다.** 수집 어댑터를 만들려면 [docs/DECISIONS.md](../../../../docs/DECISIONS.md) 1-1 과 [docs/DATA_SOURCE_RESEARCH.md](../../../../docs/DATA_SOURCE_RESEARCH.md) 를 먼저 읽는다 — **봇 탐지 우회는 결론과 무관하게 영구 금지**다. 현실적인 경로는 **사장님이 올리는 것**이거나 **업소가 준 채널의 공개 API** 다. ★ 사진을 눈으로 보고 세지 않는다. 시연본에서 A/B 를 눈으로 배정했다가 11/5 가 나왔고 실제는 12/10 이었다. 등록본 데이터를 쓴다. --- ### T7 — 도시를 펼쳐 보이는 자리 ✔ 제품에 들어갔다 (2026-09-15) **증상** 도시 이야기를 담는 자리가 전부 한 번에 하나만 보인다. 하루에 한 장만 보여주는 일력이나, 넉 장짜리 엽서 레일 정도로는 군산이라는 도시를 소개하기엔 부족하다. **제품에서 어디에 있나** — 이제 주입분이 아니라 렌더러가 그린다. | 자리 | 파일 | |---|---| | 항목 계약 `ReadingItem` | `shared/src/lib/section-data.ts` | | 프롬프트(단일 출처) | `shared/src/lib/section-prompts.ts` `reading` | | 생성·출처 링크 | `backend/services/grounding/story.py` `_SEARCH_LINK_KINDS` | | 발행본 | `site/src/sections/items/ReadingSection.tsx` — '지역 이야기' 여섯 번째 탭 | | 빌더 캔버스 | `frontend/.../canvas/variants/reading/ReadingRail.tsx` | ★ 제품과 시연본이 **한 군데 다르다**: 탭 이름이 `{지명} 읽기` 다(시연본은 '군산 읽기' 고정). ★ 제품은 출처를 모델에게 받지 않는다 — 제목으로 만든 네이버 검색 링크를 서버가 붙인다 (`build_reading.py` `naver()` 를 그대로 옮긴 것이다). **할 일** 문학·역사·섬과 바다·장소·음식처럼 갈래로 나눈 도시 이야기를, 한 화면에서 옆으로 넘겨 가며 볼 수 있는 자리를 만든다. 1. 이 콘텐츠는 **사장님마다 새로 만드는 게 아니다.** 군산이라는 지역이 공유하는 고정 자료다(계절별 축제·주변 명소와 같은 성격) — 사이트를 만들 때마다 AI 가 새로 글을 지어내는 게 아니라, 군산에 있는 모든 사이트가 같은 이야기 목록을 나눠 쓴다. 2. 항목마다 출처 링크를 단다. 정확한 문서 주소를 일일이 확인하기 어려우니, 제목으로 검색되는 링크(예: 네이버 검색)를 걸어 둔다 — 없는 주소를 지어내 걸지 않는다. 3. 옆으로 넘겨 보는 방식은 **이 사이트가 이미 쓰고 있는 것**을 그대로 쓴다(사진 갤러리· 추천 일정에 쓰는 그 슬라이드). 새로 스크롤 상자를 만들지 않는다. 한 슬라이드에는 이야기 하나만 크게 보여준다 — 여러 개를 한 슬라이드에 욱여넣지 않는다. 4. 한 번 볼 때 전체 이야기를 다 보여주지 않는다. 그중 몇 개만 무작위로 뽑아 보여주고, 다시 오거나 새로고침하면 다른 조합이 뜬다. 화면 문구에 "전체 몇 개"라고 숫자를 박지 않는다 — 실제로 보이는 개수와 어긋나면 안 되기 때문이다. 5. 화면 안에서는 '군산 이야기'(가요 다방·인물 열전 등이 모여 있는 탭 묶음)의 탭 하나로 들어간다 — 독립된 섹션으로 따로 빼지 않는다. 다른 탭들처럼 탭 안에도 제목을 그대로 보여준다. 6. 이미 다른 자리에 나온 이야기는 다시 넣지 않는다 — 인물·가요·축제·연표·명소·맛집이 각자 자리를 갖고 있다. 같은 이야기를 두 번 보여주면 페이지만 길어진다. **완료 기준** 갈래를 가리지 않고 훑어볼 수 있고, 이야기마다 출처가 붙어 있고, 넘기는 방식이 이 사이트에서 이미 쓰는 방식과 같다. --- ### T8 — 엽서 쓰기 **증상** '오늘의 엽서'는 미리 써 둔 문구를 보여주기만 했다. 손님이 직접 한마디를 써서 자기만의 엽서를 만들고, 그걸 저장하거나 공유할 수 있게 바꾼다. **이건 AI 가 글을 짓는 기능이 아니다.** 새 데이터 형식이나 문구를 설계할 필요 없이, 이미 만들어 확인까지 끝낸 화면 기능 하나를 그대로 옮겨 붙이면 되는 일이다. 1. 숙소 사진(가진 사진 전체를 보여주고 손님이 그중 하나를 고르게 한다) + 손님이 직접 쓴 한마디 + 우표·소인 모양을 합쳐서 한 장의 카드 이미지로 만든다. 2. 문구는 최대 네 줄까지만 쓸 수 있게 막는다. 줄이 넘어가면 자동으로 다음 줄로 넘어가는데, 띄어쓰기 없이 길게 이어 쓴 글자도 카드 밖으로 삐져나오지 않고 글자 단위로 잘 접혀야 한다. 3. 완성한 카드는 사진으로 저장하거나 공유할 수 있다. 공유할 때는 카드 이미지와 함께, 지금 보고 있는 이 사이트 페이지로 가는 링크를 같이 보낸다. 손님이 쓴 문구는 이미 카드 그림 안에 박혀 있으니, 공유 문구 자리에 그 글을 따로 한 번 더 적지 않는다 — 같은 말이 두 번 보이면 안 된다. 서버에 새로 올리는 기능을 만들 필요 없이 휴대폰·브라우저가 원래 갖고 있는 공유 기능을 그대로 쓰고, 그래서 이 기능은 서버를 한 번도 부르지 않는다. 4. 공유 버튼이나 안내 문구에 특정 메신저 이름을 못 박지 않는다 — 기기에 따라 실제로 뜨는 공유 대상이 다르다(휴대폰은 설치된 메신저가 뜨지만, 컴퓨터 특히 맥은 카카오톡이 그 목록에 안 뜬다). 버튼 문구는 손끝으로 조작하는 기기에서만 "카카오톡 등으로 공유"라고 쓰고, 그 외에는 그냥 "공유하기"라고 쓴다. 5. 상호명을 코드 안에 고정으로 박아 넣지 않는다 — 이 기능은 다른 숙소나 카페에서도 그대로 가져다 써야 한다. 6. 화면 위치는 "계절별 축제" 섹션 바로 앞에 독립된 섹션으로 둔다. **완료 기준** 손님이 사진 고르고 글 쓰고 저장·공유하는 것까지 한 화면에서 끝난다. 새 서버 기능이나 AI 프롬프트가 필요 없다. --- ## 1.4 프롬프트 전문 **프롬프트는 지금 두 파일에 나뉘어 있다. 이게 문제다.** | 파일 | 담는 것 | 누가 읽나 | |---|---|---| | `shared/src/lib/section-prompts.ts` | songs · daily · people · chronicle · postcard · quiz | 빌더 **+ 서버 생성 잡** | | `frontend/.../canvas/dataSpec.ts` | 위 6종(shared 를 import) **+ video · event · itinerary** | 빌더만 | → **`video` · `event` · `itinerary` 는 서버가 만들 줄 모른다.** 옮기면서 shared 로 올린다. **단일 출처는 `solution/shared/src/lib/section-prompts.ts` 다.** 사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와, 서버 생성 잡(`services/story_service.py`)이 **같은 문장**을 써야 한다. 두 벌로 두면 "빌더에서 뽑은 것과 서버가 채운 것의 모양이 다르다"가 조용히 생긴다. ``` shared/src/lib/section-prompts.ts ← 여기만 고친다 │ npm run export:prompts ▼ backend/services/prompts/section_prompts.json ← 산출물. 손으로 고치지 않는다 ``` ### 공통 규칙 (모든 프롬프트 뒤에 붙는다) ``` [공통 규칙] 1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다. 2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 "미상" 이라고 쓰지 않는다. 3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다. 4. 근거가 확실하면 verified 를 "확인", 애매하면 "확인필요" 로 적는다. 5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다. 6. 설명 문장은 항목당 두 문장을 넘기지 않는다. 7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다. ``` ★ 2번과 3번이 이 레포의 절대규칙을 프롬프트로 옮긴 것이다. 모델이 이걸 어기면 검증기가 뒤에서 걸러야 하는데, 걸러진 항목은 결국 화면에서 빈자리가 된다. --- ### ① 히어로 캐치프레이즈 (T1 · 신규) ``` 너는 숙소의 카피라이터다. 아래 조건에 맞는 JSON 하나만 출력한다. [업소] {상호} ({업종}) [지역] {지명} [업소가 가진 사실] {fact 목록을 한 줄씩. 예: 1920년대 적산가옥 · 독채 2동 · 최대 4인 · 마당과 정원 · 창고형 카페 공간 · 침구 매일 세탁 · 히로쓰 가옥 옆 · 초원사진관 도보 3분} [해야 할 일] [업소] 의 히어로 문구를 100개 만든다. 상호와 대표 문구 아래에 한 줄로 뜨고 7초마다 바뀐다. 네 종류로 나눠 만든다. · 일반 40개 — 언제 나와도 되는 문구 · 계절 24개 — 봄·여름·가을·겨울 각 6개 (3개월 단위: 3~5 봄 / 6~8 여름 / 9~11 가을 / 12~2 겨울) · 월별 24개 — 1월부터 12월까지 각 2개 · 날씨 12개 — 맑음 3 · 구름많음 2 · 흐림 2 · 비 3 · 눈 2 [스키마] { "kind":"catchphrase", "version":1, "items":[ { "text":"한 줄", "kind":"general|season|month|weather", "season":"봄|여름|가을|겨울", // kind 가 season 일 때만 "month":9, // kind 가 month 일 때만 (1~12) "weather":"맑음|구름많음|흐림|비|눈" } // kind 가 weather 일 때만 ] } [이 아이템만의 규칙] · 한 줄은 25자 안쪽이다. 두 문장으로 쓰지 않는다. · **[업소가 가진 사실] 안에서만 쓴다.** 거기 없는 시설·거리·연도를 지어내지 않는다. · 느낌표와 이모지를 쓰지 않는다. "최고" "완벽" "힐링" 같은 광고 단어를 쓰지 않는다. · 상호를 문구에 넣지 않는다 — 바로 위에 이미 크게 떠 있다. · 같은 사실을 두 번 쓰지 않는다. 100개가 다 다른 것을 말해야 한다. · 계절 문구는 그 계절에만 맞는 말이어야 한다. "좋습니다" 처럼 아무 때나 되는 말은 일반으로 보낸다. · 날씨 문구는 그 날씨일 때 손님이 **무엇을 할 수 있는지**를 말한다. 날씨 묘사만 하지 않는다. · 월별 문구는 그 달의 날씨·행사·빛이 근거여야 한다. 숫자만 바꾼 문구를 12번 쓰지 않는다. · source 와 verified 는 없다. 사실이 아니라 문구이고, 근거는 [업소가 가진 사실] 이다. ``` **잘 나온 예** | 종류 | 문구 | 근거가 된 사실 | |---|---|---| | 일반 | 담 너머는 히로쓰 가옥입니다 | 위치 | | 일반 | 침구는 매일 새것처럼 나갑니다 | 매일 세탁·살균 | | 계절(가을) | 기와 위로 가을 볕이 마릅니다 | 기와 지붕 | | 월(9월) | 가을이 담을 넘어오는 구월입니다 | 담·계절 | | 날씨(비) | 카페 공간에서 빗소리를 들으실 수 있습니다 | 창고형 카페 | **받은 뒤 검사할 것** - [ ] 100개인가. 종류별 40/24/24/12 인가 - [ ] 25자를 넘는 것이 있는가 - [ ] [업소가 가진 사실] 에 없는 말이 있는가 ← **이게 제일 많이 틀린다** - [ ] 계절 문구가 그 계절에만 맞는가 - [ ] 같은 말이 두 번 나오는가 --- ### ② 여행 일정 · 장소 대장 (T2 ① · `dataSpec.ts:556` 의 일정 프롬프트를 **대체**한다) ``` 너는 지역 여행 코디네이터다. 아래 조건에 맞는 JSON 하나만 출력한다. [업소] {상호} ({업종}) [지역] {지명} [좌표] {위도},{경도} [해야 할 일] [업소] 에서 출발해 당일로 다녀올 수 있는 장소를 30곳까지 찾는다. 관광지·박물관·골목·바다·절 같은 볼거리 20곳과, 끼니를 해결할 곳 10곳을 섞는다. [업소] 에서 30km 안쪽만 고른다. [스키마] { "kind":"places", "version":1, "items":[ { "name":"장소 이름", "searchQuery":"지도에서 검색할 말", "category":"볼거리|식당|카페", "stayMinutes":{"min":20,"base":30,"max":45}, "todo":"거기서 무엇을 하는지 한 문장", "indoor":true, "needsCar":false, "bestTime":"오전|오후|저녁|밤|상관없음", "verified":"확인|확인필요", "source":{"name":"출처명","url":"https://..."} } ] } [이 아이템만의 규칙] · **todo 는 장소 설명이 아니라 할 일이다.** ✗ "1899년에 지어진 근대 건축물입니다" ✓ "옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다." 손님이 거기서 무엇을 하는지 모르면 그 줄은 쓸모가 없다. · stayMinutes 는 **실제로 걸리는 시간**이다. min 은 빨리 보고 나오는 사람, max 는 천천히 보는 사람. 박물관 60~120분, 골목 15~40분, 식당 40~70분이 기준이다. 무엇이든 90분으로 적지 않는다. · 식당은 **무엇을 먹는지**까지 적는다. "맛집입니다" 로는 무엇을 시킬지 모른다. · indoor 는 비 오는 날 갈 수 있는 곳만 true 다. 처마 밑·실내 전시가 여기 들어간다. · needsCar 는 [업소] 에서 걸어서 못 가는 곳(1.5km 초과)이면 true 다. · 좌표는 적지 않는다. 코드가 카카오 로컬에서 받는다 — 지어낸 좌표는 지도에 엉뚱한 핀을 찍는다. · 영업시간·휴무일이 확인되면 source 에 그 페이지를 단다. · **[업소]에서 40분 넘게 걸리는 곳을 넣었다면, 그 근처 식당도 하나 이상 넣는다.** 멀리 다녀온 뒤 시내까지 돌아와 밥을 먹으면 점심이 오후 3시가 된다 — 시연본에서 실제로 그랬다(섬에서 88분 달려와 15:47 에 늦은 점심). · 끼니 장소는 **한 끼 40~70분**이다. 여기에 이동시간을 얹는 것은 코드가 한다. ``` --- ### ③ 여행 일정 · 테마 짓기 (T2 ④ · 신규 · ②와 짝) ``` [해야 할 일] [장소 대장] 을 재료로 1박 2일 10개, 2박 3일 10개의 테마를 만든다. 테마는 **손님의 성격**으로 가른다 — 차가 있는지, 걷기를 좋아하는지, 비가 오는지, 아이가 있는지, 사진을 찍는지, 쉬러 오는지. [스키마] { "kind":"themes", "version":1, "items":[ { "name":"테마 이름", "duration":"1박 2일|2박 3일", "audience":"누구에게", "why":"왜 이 순서인지 한두 문장", "days":[ { "kind":"first|mid|mid_rest|mid_island|last", "places":["장소명","장소명"] } ] } ] } [이 아이템만의 규칙] · places 는 [장소 대장] 에 있는 이름만 쓴다. 없는 곳을 적으면 그 테마는 버려진다. · 뼈대의 자리 수와 places 개수가 같아야 한다. · 끼니 자리에는 category 가 식당·카페인 곳만 넣는다. · **한 테마 안에서 같은 곳을 두 번 넣지 않는다.** · 테마 이름에 "최고" "완벽" 을 쓰지 않는다. 무엇을 하는 일정인지가 이름이다. · 20개 중 하나는 **숙소에서 거의 나가지 않는 날**을 포함한다. ``` --- ### ④ 가요 다방 (T5 · 기존 8곡 → 50곡) ``` [해야 할 일] [지역]을 노래한 대중가요를 찾아 아래 JSON 으로 정리한다. 이번에는 {N}번째부터 {N+14}번째까지, 15곡을 낸다. 이미 받은 곡은 아래에 있다 — 같은 곡을 다시 내지 않는다. [이미 받은 곡] {곡명 목록} 지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다. 1960~80년대 곡을 먼저 찾고, 다 떨어지면 지자체가 만든 곡·지역 가수의 곡으로 넓힌다. [스키마] { "kind":"songs", "version":1, "title":"가요 다방", "items":[ { "title":"곡명", "artist":"가수", "lyricist":"작사", "composer":"작곡", "year":1966, "label":"음반사", "labelColor":"#d4551f", "story":"곡의 배경 (두 문장 이내, 가사 없이)", "verified":"확인|확인필요", "source":{"name":"출처명","url":"https://..."} } ] } [이 아이템만의 규칙] · lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다. · **곡이 실재하는지 출처로 확인한다.** 지역 노래 목록·언론 기사·지자체 음반 안내가 근거다. 검색으로 못 찾으면 그 곡을 내지 않는다 — 15곡을 억지로 채우지 않는다. · labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다. · 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다. · 유튜브 영상 ID 를 적지 않는다 — 코드가 검색 주소를 만든다. 틀린 영상이 붙는 것보다 낫다. · 숙소가 직접 만든 곡은 여기 넣지 않는다. 이 섹션은 **도시의 노래**를 모으는 자리다. ``` ★ 시연본은 25곡에서 멈췄다. 출처에서 확인되는 군산 곡이 거기까지였다. **모자라면 모자란 채로 내는 것이 맞다** — 지어낸 곡은 아는 사람이 바로 알아본다. --- ### ⑤ 인물 열전 (T3 T5 · 기존 10명 → 50명) ``` [해야 할 일] [지역] 출신이거나 [지역]과 깊이 얽힌 인물을 50명까지 찾는다. 문학·음악·미술·연기·체육·정치·학문·기업을 고루 섞는다. 한 분야가 절반을 넘지 않게 한다. 생존 인물은 공개된 활동 사실만 쓴다. [스키마] { "kind":"people", "version":1, "title":"인물 열전", "items":[ { "name":"이름", "aka":"호·예명", "years":"1902–1950", "role":"소설가", "oneLine":"한 문장 소개", "imageQuery":"사진 검색어", "verified":"확인|확인필요", "source":{"name":"출처명","url":"https://..."} } ] } [이 아이템만의 규칙] · **source.url 은 그 사람의 문서**여야 한다(위키백과 문서, 기관 소개 페이지). "OO시 출신 인물 목록" 같은 목록 페이지는 근거가 아니다. · oneLine 은 **그 사람이 무엇을 한 사람인지**다. "군산 출신입니다" 는 이름 아래 이미 있다. · "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다. · 생존 인물의 가족·거주지·건강·재산 같은 사생활은 쓰지 않는다. · 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사람이 확인한다. · years 를 모르면 뺀다. ``` ★ **사진은 프롬프트로 받지 않는다.** T3 참조 — 위키백과 **문서 대표 사진**만 받고 라이선스가 확인된 것만 쓴다. --- ### ⑥ 오늘의 엽서 (기존 개선 — 링크가 붙게) ``` [해야 할 일] [지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다. 사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다. [스키마] { "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[ { "line":"한 문장", "hashtags":["#태그"], "place":"그 문장이 가리키는 장소 이름", "searchQuery":"지도에서 검색할 말", "postmark":"소인에 찍을 짧은 지명", "verified":"확인|확인필요", "source":{"name":"출처명","url":"https://..."} } ] } [이 아이템만의 규칙] · 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다. · 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다. · 해시태그는 3개까지. 지역명 하나는 반드시 넣는다. · **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다. · 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다. · **place 와 searchQuery 를 반드시 채운다.** 화면이 그 값으로 지도 링크를 건다. postmark 는 도장 문구라 지명이 아닐 수 있다 — 그걸로 지도를 열면 엉뚱한 데가 나온다. ``` ★ 시연본에서 실제로 그랬다. 소인 글자(`군산 內港`)로 링크를 만들었더니 `군산 군산` 같은 검색어가 나왔다. `place` 를 따로 받는 이유다. --- ## 1.5 프롬프트를 고칠 때 지킬 것 1. **`STORY_KINDS` 에 등록한다.** 빠지면 서버 생성 잡이 그 종류를 모른다. 2. **`maxItems` 와 프롬프트 안의 숫자를 같이 바꾼다.** 어긋나면 받아 놓고 잘라 버린다. 3. **개수를 억지로 채우게 하지 않는다.** "확실한 것이 12개면 12개만 낸다" 를 규칙에 넣는다. 채우라고 하면 채운다 — 지어내서. 4. **계산은 시키지 않는다.** 시각·거리·요금은 코드가 한다. 모델은 자신 있게 틀린 숫자를 준다. 5. **"설명" 이 아니라 "할 일" 을 시킨다.** 손님이 그 자리에서 무엇을 하는지가 콘텐츠다. 6. **출처의 종류를 지정한다.** "출처를 달아라" 로는 목록 페이지가 온다. 7. 고친 뒤 **`npm run export:prompts`** 를 돌린다. 안 돌리면 서버는 옛 프롬프트로 돈다. --- --- # 2부 — 목업을 고치는 사람 ## 2.1 무엇을 고치는 건가 `/s/stay` 는 **payload 가 없는 목업**이다(`stay2` · `stay3` 도 같다). 프리렌더는 payload 를 받은 사이트만 굽는다 — 이 사이트는 **재굽기 대상이 아니고**, 고칠 자리는 이미 구워져 있는 `index.html` 하나이고 그 파일이 유일본이다. 그래서 손으로 열어 고치지 않는다. 스크립트가 **원본 + 바꿀 것 → 새 index.html** 을 만든다. 두 번째로 고칠 때 "무엇을 왜 바꿨는지"가 파일이 아니라 코드와 이 문서에 남아야 하기 때문이다. ``` stay-payload.json 원본 payload (건드리지 않는다 · 되돌릴 때 기준) orig/index.html 원본 HTML (건드리지 않는다) │ ├─ build_itinerary.py 여행 일정을 계산해 넣는다 (+ 규칙 17종 감사) ├─ build_story.py 가요 다방 · 인물 열전을 넣는다 ├─ build_reading.py 군산 읽기(카로셀 34꼭지, 화면엔 매번 5~6개 무작위)를 넣는다 ├─ build_daily.py 오늘의 한 장(일력 39장)을 넣는다 — 가요·인물·문학을 읽어 쓰므로 맨 뒤 └─ patch_stay.py 캐치프레이즈 · 주변 맛집 · 객실 사진 · 자작곡 · 주입 CSS/JS · 자산 경로 교정 │ ▼ build/index.html ← 배포하는 파일 (git 이그노어) ``` ★ **렌더러(`solution/site/src`)를 고치지 않는다.** 고치면 번들 해시가 바뀌어 전 사이트 재굽기 + 전체 재업로드가 따라온다. 목업에만 필요한 것은 `inject.css`/`inject.js` 로 덮는다. --- ## 2.2 굽기 · 배포 ### 다른 기계에서 처음 띄울 때 ```bash git clone && cd o2o-site-AEO cp .env.example .env && cp nginx/site.conf.example nginx/site.conf docker compose up -d cd solution/site/scripts/mockup python3 patch_stay.py C=o2o-web4ai-solution-prerender docker exec $C mkdir -p /app/solution/site/out/s/stay/img/people \ /app/solution/site/out/s/stay/img/mirror /app/solution/site/out/s/stay/audio \ /app/solution/site/out/s/stay/vendor docker cp build/index.html $C:/app/solution/site/out/s/stay/index.html docker cp img/. $C:/app/solution/site/out/s/stay/img/ docker cp audio/. $C:/app/solution/site/out/s/stay/audio/ docker cp vendor/. $C:/app/solution/site/out/s/stay/vendor/ node audit-all.mjs ``` → `http://localhost/s/stay` ★ **이 시연본은 레포 안에서 자급자족한다.** 번들 css/js 와 미러 사진 61장까지 `vendor/` · `img/mirror/` 에 들어 있고, `patch_stay.py` 가 경로를 `/s/stay/…` 로 바꿔 박는다. 그 기계에서 구운 번들 해시와 **무관하게** 뜬다. (2026-09-11 이전 판은 `/assets/…` 를 가리켜 다른 기계에서 흰 화면이었다.) ### 점검 도구는 어디서 도나 — 개발 PC에서, 주소를 받아서 `audit-all.mjs` · `rails-test.mjs` 는 **Playwright 로 진짜 크롬을 띄운다.** 그래서 - **킹서버에서는 못 돈다.** 그 서버에는 node 도 node_modules 도 없다(브라우저는 더더욱). - 대신 **개발 PC에서 주소를 주고 돌린다.** 대상이 로컬이든 킹서버든 명령은 같다. ```bash npm install # 레포 루트에서 한 번 (playwright 는 devDependencies) node audit-all.mjs # http://localhost/s/stay node audit-all.mjs https://web4ai.o2osolution.ai/s/stay ``` ★ 도커에 넣지 않는다. 점검은 **배포물이 아니라 개발 도구**다 — 운영 이미지에 브라우저(300MB+)를 넣으면 서버가 그만큼 무거워지는데, 정작 점검은 사람이 볼 때만 돌린다. ### 렌더러를 다시 구운 뒤 (번들만 갈아 끼운다) 목업은 **다시 만들지 않는다.** `vendor/` 안의 css·js 한 벌만 새 번들로 바꾸고 주소를 다시 박는다. ```bash C=o2o-web4ai-solution-prerender CSS=$(docker exec $C sh -c 'ls -t /app/solution/site/out/assets | grep -E "^index-.*\.css$" | head -1') JS=$(docker exec $C sh -c 'ls -t /app/solution/site/out/assets | grep -E "^index-.*\.js$" | head -1') mkdir -p vendor/retired && mv vendor/index-*.css vendor/index-*.js vendor/retired/ docker cp $C:/app/solution/site/out/assets/$CSS vendor/ docker cp $C:/app/solution/site/out/assets/$JS vendor/ python3 patch_stay.py # vendor 안의 한 벌을 찾아 주소만 박는다 ``` ★ `vendor/` 에는 css·js 가 **각각 한 개만** 있어야 한다(`patch_stay.py` 가 assert 로 막는다). ★ 번들이 낡으면 **payload 에 값이 있어도 화면이 빈다.** 소스에 섹션이 있다고 화면에 있는 게 아니다 — 실측(2026-09-14): 머지 뒤 이미지를 안 구워서 `StorySection`·`DailySection` 이 번들에 없었다. ### 점검 기준이 결정을 따라가야 한다 (2026-09-14) 화면과 `audit-all.mjs` 가 갈리면 점검이 거짓말을 한다. 그날 바뀐 결정 셋: | 자리 | 결정 | 점검 | |---|---|---| | 오늘의 한 장 | 탭을 세우지 않는다(사장님: "주석처리해주쇼") — `patch_stay.py` `HIDE_TABS` | `오늘의 한 장 숨김` — 탭이 **없어야** 통과 | | 엽서 | 도시 엽서 4장을 빼고 손님이 쓰는 엽서로(`inject.js` `postcardMaker`) | `엽서 쓰기` — `#w4d-pm-section` 유무 | | 군산 읽기 | 34가지 중 **무작위 5~6꼭지** 카로셀. 신문 조판(제호·면·격자)은 걷어냈다 | `군산 읽기 화면` — 5~6꼭지 | ### 내용을 바꾼 뒤 (전체 굽기) ```bash python3 build_itinerary.py && python3 build_story.py && python3 build_reading.py \ && python3 build_daily.py && python3 patch_stay.py ``` **순서를 지킨다.** `build_itinerary.py` 가 `stay-payload.json`(원본)을 읽어 `stay-payload-new.json` 을 만들고, 뒤 셋이 그 파일을 이어서 고친다. 순서를 바꾸면 앞 단계 결과가 지워진다. `build_daily.py` 는 가요 다방·인물 열전·군산 읽기를 **읽어서** 일력을 만든다 — 맨 뒤여야 한다. 손으로 적는 자리가 한 곳이어야 해서다: 문학 8꼭지의 문장은 `build_reading.py` 에만 있다. 주변 맛집을 다시 받을 때만 `solution/backend` 에서 `.venv/bin/python ../site/scripts/mockup/fetch_restaurants.py` 를 먼저 돌린다(TourAPI 키가 필요하다). 굽기는 그 결과 파일만 읽는다 — 굽기가 키에 매이면 다른 기계에서 못 굽는다. 그다음 `python3 fill_restaurant_photos.py` 로 사진 없는 집의 사진을 받는다. ⚠ 이 11장은 블로그·네이버 플레이스·관광 사이트에 올라온 것이라 **권리가 게시자에게 있다** — 시연 뒤 내리거나 사장님 사진으로 바꾼다. 제품으로 옮기지 않는다. 로컬 반영 · 검사: ```bash docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html node audit-all.mjs # 47개 항목 node rails-test.mjs # 카로셀 13개 (모바일은 +3) python3 audit_schedule.py # 일정 전수 검수표(SCHEDULE.md) ``` `/s/` 는 **5분 캐시**(`max-age=300`)다 — 확인할 때 ⌘⇧R. ### 킹서버 ```bash tar czf /tmp/stay-bundle.tgz -C . build/index.html img audio vendor 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 vendor && 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/img/mirror \ /app/solution/site/out/s/stay/audio /app/solution/site/out/s/stay/vendor docker cp build/index.html \$C:/app/solution/site/out/s/stay/index.html docker cp img/. \$C:/app/solution/site/out/s/stay/img/ docker cp audio/. \$C:/app/solution/site/out/s/stay/audio/ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/" ``` 확인: `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` 이 있다. --- ## 2.3 무엇을 어디서 고치나 ### 히어로 문구 100개 → `patch_stay.py` | 자리 | 줄 | 무엇 | |---|---|---| | `GENERAL` | `:29` | 일반 40개 | | `SEASON` | `:71` | 계절별 24개 — 봄/여름/가을/겨울 각 6 | | `MONTH` | `:81` | 월별 24개 — 1~12월 각 2 | | `WEATHER` | `:96` | 날씨별 12개 — 맑음/구름많음/흐림/비/눈 | 문장만 더하거나 지우면 된다. 개수 제한은 없다. 순환 순서·계절 구분·날씨 연동은 **1.3 T1** 에 적어 둔 그대로다(`inject.js`). ### 오늘의 날씨 문구 50개 → `patch_stay.py` | 자리 | 줄 | 무엇 | |---|---|---| | `SKY_NOTES` | `:114` | 하늘 25개 — 맑음/구름많음/그외/비/눈 각 5. **집 안**에서 뭘 할지 | | `TEMP_NOTES` | `:152` | 기온대 25개 — 혹서/더움/선선/쌀쌀/추움 각 5. **밖에서** 어디를 갈지 | 케이스마다 여러 벌이고 20초마다 자루에서 다음 것이 나온다(`inject.js` ⑥). 굽는 판에는 케이스마다 **첫 문장**이 박히고, 나머지는 `local.weather.noteSets` · `tempNoteSets` 로 실려 주입분만 읽는다 — 렌더러 계약에 없는 칸이라 그대로 지나간다. - 개수 제한은 없다. 다만 **문장이 겹치면 빌드가 멈춘다** — 주입분이 DOM 에서 바꿀 줄을 문장으로 찾기 때문에, 같은 문장이 둘이면 어느 줄인지 못 가른다. - 이름을 대는 곳은 이 사이트에 **이미 있는 자리**만 쓴다(주변 안내 12곳 · 맛집 31곳). 화면에 없는 곳을 날씨 칸에서만 권하면 손님이 그걸 찾을 데가 없다. - 기온대 경계(30·25·20·10)는 렌더러가 정한다(`site/src/lib/derive.ts weatherBand`). 화면의 배지는 렌더러가 붙이고 문장만 주입분이 갈아 끼우므로, 경계를 바꾸면 둘이 어긋난다. ### 여행 일정 → `build_itinerary.py` **사람은 "어느 자리에 어느 장소" 만 고르고, 시각은 스크립트가 계산한다.** | 자리 | 줄 | 무엇 | |---|---|---| | `PLACES` | `:40` | **장소 대장** — 좌표 · 지도 검색어 · 체류시간(min/base/max) · **거기서 무엇을 하는지** | | `STAY_ACTION` | `:108` | 머뭄 칸의 행위 — 체크인 · 아침 · 아침(퇴실) · **아침(퇴실·차)** · 쉼 · 쉬는 날 · 머뭄으로 · 마무리 | | `WINDOW` | `:162` | 시간대가 정해진 자리 | | `SKELETON` | `:194` | 하루 뼈대. `@` 가 붙은 것은 머뭄. **떠나는 날은 차·도보 두 벌**(`CAR_KINDS` 가 복귀 칸을 떼어 만든다) | | `THEMES` | `:414` | 테마 21개. 마지막 날 뼈대가 `last_car` 계열이면 **차로 떠나는 테마**다(지금 14개 — 1박 2일 7 · 2박 3일 7) | | `PACING` | `:516` | 테마별 시작 시각과 체류 배율 | **새 장소** → `PLACES` 에 한 줄. 좌표는 카카오 로컬에서 받은 실제 값이어야 한다(지어내지 않는다). 그다음 `THEMES` 의 원하는 자리에 이름을 적는다. **새 테마** → `THEMES` 에 한 덩이, `PACING` 에 한 줄. 뼈대의 `@` 없는 자리 수와 장소 개수가 같아야 한다 — 틀리면 빌드가 멈추고 알려 준다. ### 군산 읽기 → `build_reading.py` | 자리 | 무엇 | |---|---| | `GROUPS` | 갈래와 그 아래 한 줄. 화면에 이 순서로 선다 | | `ITEMS` | 꼭지 34개 — (갈래, 제목, 글, 연도). **문학 문장의 단일 출처**다 | | `naver(title)` | 항목마다 다는 출처 — 네이버 검색 링크 하나로 통일(아래 참고) | - **이미 다른 섹션에 선 것은 넣지 않는다.** 인물 57 · 가요 25 · 축제 10 · 연표 7 · 명소 12 · 맛집 31 이 각자 자리를 갖는다. 받은 엑셀 52주제에서 그걸 빼면 34꼭지가 남는다. - 연도는 **확인된 것만** 적는다(연표에서 이미 확인한 값, 엑셀이 명시한 1944 등). - 글은 **3~5문장**이다(2026-09-14 대표: "2줄있는애들 최소 3-5줄 이였으면 좋겠오") — 두 문장짜리 21꼭지를 네 문장으로 늘렸다. 새로 보탠 문장은 없는 사실을 지어낸 게 아니라 널리 알려진 배경 설명이다. - **`verified`("확인"/"확인필요") 필드는 뺐다.** 대신 `source` 를 **네이버 검색 링크**로 통일했다(`naver(title)` — `https://search.naver.com/search.naver?query=...`). 기관·위키 링크를 항목마다 골라 달던 방식(`CHAE`·`STAMP`·`TOUR`·`WIKI`·`KCI`·`GUIDE` 상수)은 없앴다 — 개별 문서 URL 은 실제로 확인 안 하고 적으면 없는 문서로 이어질 위험이 있어서다(레포 절대 규칙: 없는 사실은 안 짓는다). 검색 링크는 제목을 그대로 넘기니 항상 관련 결과로 뜬다. - 모양은 `inject.css` `.w4d-read-*` — 이 사이트의 실제 카로셀(embla)이다. 신문 조판(제호·면·단) 은 한 번 시도했다가 되돌렸다 — 경위는 **2.5 항목 3**. - 화면은 **전체 중 5~6개만 매번 무작위로** 보여준다(갈래 안 가림) — payload 에는 34개가 전부 있고, `subtitle` 에도 전체 개수를 안 박는다(**2.5 항목 3**). ### 오늘의 한 장(일력) → `build_daily.py` | 자리 | 무엇 | |---|---| | `POETS` | 시인 6명. 이름만 적으면 인물 열전에서 한 줄과 위키 출처를 가져온다 | | (자동) | 가요 25장은 가요 다방을, 문학 8장은 군산 읽기를 **그대로 읽는다.** 목록을 두 벌로 두지 않는다 | - 이 자리는 **도시를 소개하는 자리가 아니다** — 세 장만 화면에 선다. 소개는 '군산 읽기' 가 한다. - 날짜는 **고르게 편다**(39장 → 9일 간격). 곡이 나온 날·작가의 생일에 맞추고 싶지만 그 날짜를 출처로 확인한 것이 없다 — 지어낸 날짜는 해마다 틀린다. - 오늘 자리가 비면 렌더러가 **다음 장**을 편다(`DailySection.tsx`). 365장을 채우려면 같은 글을 여러 날짜에 복사해야 하는데, 인용되려고 만든 사이트에서 할 일이 아니다. - 받은 자료(`gunsan_365_story_db.xlsx`)를 그대로 싣지 않는 이유는 `build_daily.py` 머리말에 있다. ### 가요 다방 · 인물 열전 → `build_story.py` | 자리 | 줄 | 무엇 | |---|---|---| | `SONGS` | `:28` | 25곡 — 곡명·가수·작사·작곡·연도·라벨색·한 줄 | | `PEOPLE` | `:109` | 57명 — 위키 문서 제목 · 표시 이름 · 연도 · 하는 일 · 한 줄 | - 연도·작사·작곡을 모르면 **비운다**("미상"이라고 적지 않는다 — 렌더러가 없는 칸을 안 그린다). - 듣기 링크는 **유튜브 검색 주소**다. 영상 ID 를 박으면 틀린 영상이 붙을 수 있다. - 인물 사진은 두 곳 — ① `people-photos.json`(위키 문서 대표 사진 17장, 라이선스 확인분) ② 원본 payload 에 있던 미러 사진(채만식). 새로 가져오는 게 아니라 자리로 돌리는 것이다. - ★ 이름만으로 커먼즈를 검색하지 않는다(**1.3 T3**). - 숙소가 만든 곡은 이 섹션에 넣지 않는다. 도시의 노래를 모으는 자리다. ### 객실 사진 → `patch_stay.py` `ROOM_PHOTOS`(`:267`) · `rooms.json` --- ## 2.4 규칙 17종 — 어기면 빌드가 멈춘다 `build_itinerary.py` 의 `audit()` 가 매 실행 검사한다. 하나라도 어기면 **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 | 아이 동반 테마에 술집 금지 | | | **15** | **끼니 사이 3시간 이상** | 늦은 점심 15:47 종료 → 저녁 17:30. 창은 둘 다 합법인데 배가 안 꺼진다 | | **16** | **볼거리는 편도 이동보다 오래 머문다**(끼니 제외) | 사장님 지적 — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리라 뺀다 | | **17** | **떠나는 날의 끝맺음이 테마와 맞는다** — 차 테마는 복귀 칸이 없고, 도보 테마는 짐을 찾는 `마무리` 칸으로 끝난다 | 2026-09-14. 한쪽만 고치면 차인데 숙소로 되돌아오거나(한 번 더 들르기) 맡긴 짐을 못 찾고 끝난다 — 둘 다 화면만 봐서는 멀쩡하다 | ★ **왜 빌드 안인가** — 전에는 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다. 그래서 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생겼고 사장님이 화면에서 먼저 찾으셨다. **검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다.** `audit-all.mjs` 는 그다음 단계다 — 구워진 HTML 을 **실제 브라우저로 열어** 47개 항목을 센다. --- ## 2.5 주입분(`inject.js` · `inject.css`) 아홉 렌더러에 없어서 이 목업에만 얹은 것이다. 제품으로 옮길 자리는 1부에 적었다. 1. **캐치프레이즈 순환** — 대표 문구 `

` 를 찾아 **그 아래에 줄(`#w4d-sub`)을 붙이고** 단어 단위로 갈아 끼운다. 대표 문구는 안 건드린다 → T1 2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡. ★ **자동재생은 껐다** (2026-09-15 대표: "자동재생 안 되고 클릭하고 나서 되니까 사람들이 에러로 보잖아 그냥 꺼놔"). 세 번째 뒤집기다(09-11 빼 → 09-14 켜 → 09-15 끔). 이번 이유는 앞의 둘과 다르다 — 정책을 못 이겨서가 아니라, **정책에 걸린 그 모습이 고장으로 읽혀서**다. `inject.js` 의 `autoplay()` 는 남아 있고 호출만 뺐다(`void autoplay;`) → T4 3. **군산 읽기** — ★ **2026-09-15 부터 렌더러에 있다**(`ReadingSection.tsx`, **T7**). 아래는 시연본 주입분이 어떻게 굴러가는지의 기록이고, 제품을 고칠 때는 렌더러를 본다. '군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가 34꼭지 중 5~6개를 매번 무작위로 카로셀(embla, 한 슬라이드에 한 꼭지) 로 보여준다. `startReading()` 이 story 탭 패널들의 마지막(postcard) 뒤에 자기 패널을 꽂고, 자기 탭 버튼은 진짜 탭들의 `[role="tablist"]` 끝에 붙인다 → **T7** - 처음엔 "신문 조판"(제호·면·단)으로 시작했다가, 3~4개씩 갈래별로 뽑아 한 카로셀에 잇는 중간 단계를 거쳐, 지금은 갈래를 안 가리고 **전체에서 5~6개**로 정착했다 (2026-09-14 하루 안에서: "신문처럼" → "포스트 하나씩만…나랑 장난하자는거?" → "문학면? 말이 안 되는거 같네" → "34개중 15~19개인데 왜 34개라고 써놔?" → "그냥 5-6개 정도만…랜덤으로"). - ★ **탭 전환 버그** (2026-09-14 대표 제보: "시간의 골목 누르면 사라지기만 함, 탭활성화도 그렇고"). 진짜 탭 다섯 개의 `hidden`·`aria-selected`·style 은 전부 React 가 `active` state 로 매번 다시 계산해 주는 값인데, 예전 코드가 내 탭으로 넘어갈 때 그 값들을 **직접 덮어썼다.** 그 뒤 손님이 (내 탭에 있다가) **직전까지 활성이던 그 탭을 다시 누르면** `setActive(sameIndex)` 는 React 입장에서 상태가 안 바뀐 것이라 **리렌더를 생략한다**(bail-out) — 내가 덮은 값이 영영 안 돌아왔다. → **React 가 매 렌더 계산해 주는 속성은 아예 안 건드린다.** 대신 내가 전적으로 소유한 `style.display`(JSX 에 없는 속성이라 React 가 절대 안 건드린다)로만 가리고, 되돌아올 때는 그 오버라이드만 지운다 — `hidden` 은 그동안 한 번도 틀린 적이 없으니 지우자마자 바로 맞다. 방금 누른 진짜 탭의 스타일도 직접 켠다(React 리렌더가 생략돼도 항상 맞게). 4. **링크 걸기** — 일정 정거장 187개 · 엽서 4개를 네이버 지도로 5. **사진 저작자 표시** — 이 목업이 물고 있는 번들이 옛 판이라 `imageCredit` 을 안 그린다 → T3 6. **카로셀 제어** — 아래 7. **날씨 카드 문구 순환** (`startWeatherNote`) — 하늘 5 · 기온대 5, 케이스마다 다섯 벌을 자루에서 뽑아 20초마다 갈아 끼운다. 렌더러는 케이스마다 한 칸씩만 읽는다. 하늘이 다섯인 것도 여기서만 가능하다 — 카드는 넷으로만 가르므로 구름많음(1~3)과 그 외(안개·소나기·뇌우…)가 `notes.흐림` **한 칸**을 같이 본다 → **2.3** 8. **가요 다방 판꽂이** (`inject.css` + `inject.js` `startSongsRail()`) — 렌더러는 판을 `flex-wrap` 으로 편다. 4곡일 때 만든 모양이라 25곡이면 세 줄, 50곡이면 여섯 줄짜리 음반 진열대가 된다. 한 줄 가로 스크롤로 눕힌다(넘치면 옆으로 민다). **곡명은 항상 보인다** — 렌더러가 원래 판 아래에 그리는 그 글자를 그대로 두고 줄 폭만 맞춘다. ★ 한 번 이 자리를 hover 로만 곡명이 뜨게 고친 적이 있다(2026-09-14, 판을 겹쳐 꽂고 손을 얹거나 고른 판에만 이름표를 띄우는 식). 데스크톱 벽은 없앴지만 모바일엔 hover 가 없어 **곡명이 아예 안 보이는 화면**이 됐다 — 데스크톱만 보고 고친 값이었다(같은 날 대표 지적으로 되돌림). ★ **화살표 + 실제 카로셀**(같은 날: "옆으로 갈수있는 버튼도 주고 slider.js 적용해줘"). 렌더러가 이 판 목록을 `Carousel.tsx` 로 안 감싸서(그냥 `flex flex-wrap` 상자다) "군산 읽기" 처럼 기존 `.slider-viewport` 를 마크업에서 못 가져다 쓴다 — 그래서 `startSongsRail()` 이 그 상자(`:has()` 로 잡는 판 목록 div) 하나를 통째로 새 상자로 감싸 `.slider-viewport` 를 붙이고, 그 위에 embla-carousel 코어(`vendor/embla-carousel.umd.js`, 군산 읽기와 같은 라이브러리)를 얹는다. **판 버튼 25개를 옮기지 않고 그 부모 하나만 옮긴다** — React 는 그 노드를 그대로 갖고 있으니(`setPlaying` 이 속성만 고친다) 감싼 뒤에도 클릭이 그대로 먹는다. ★ **마크업을 고치지 않는다**(React 가 되돌린다) — `:has()` 로 판이 든 상자만 집는다. → 렌더러도 같은 문제다(**3.4**) 9. **엽서 쓰기** (`postcardMaker()`, `#w4d-pm-section`) — "오늘의 엽서" 탭을 비우고 (`patch_stay.py` 가 `postcard` 섹션 `data` 를 빈 `items:[]` 로 덮는다), 그 자리 대신 **손님이 직접 문구를 써서 자기 것으로 저장·공유하는 카드**를 `festival` 섹션 바로 앞에 독립 섹션으로 얹는다(2026-09-14: "오늘의 엽서 칸을 빼고 이 엽서 포맷으로…이건 축제소개 전에 섹션으로 빼줘"). 캔버스(1080×1080)에 숙소 사진(`P.media` 전체, 카로셀로 고른다) + 문구 + 우표·소인을 그려 PNG 로 만들고, `navigator.share({files:[...]})` (Web Share API) 로 공유하거나 다운로드한다 — 새 익명 업로드 백엔드를 만들지 않으려고 파일 공유 쪽을 골랐다 (`solution/backend` 미디어 라우터는 전부 로그인 필요, `IsValidAccessToken`). - 공유 버튼 문구는 기기별로 다르다 — `matchMedia('(pointer: coarse)')` 로 터치 기기에서만 "카카오톡 등으로 공유", 나머지(주로 macOS 데스크톱)는 "공유하기". macOS 는 카카오톡이 시스템 공유 확장을 안 만들어 시트에 메시지·메모 정도만 뜨는 게 OS 사양이라 그렇다. - 상호명(placeName)·소인 텍스트·플레이스홀더 전부 하드코딩 안 한다 — 다른 숙소·카페도 이 스크립트를 그대로 쓸 수 있어야 해서다("스테이머뭄뿐만 아니라 다른 숙박업소나 카페도 써야 하는데"). 소인은 `stampLines(name, maxWidth)` 가 콤마·공백 경계로, 안 되면 글자 단위로 접는다. - ★ **줄바꿈이 폭을 안 넘던 버그** (2026-09-14 대표 제보 — 스크린샷: 공백 없이 이어 친 글자열이 우표 칸까지 넘쳐 나감). `wrapLines()` 가 **공백 단위로만** 접어서, 단어 하나가 이미 칸보다 넓으면 아예 안 잘랐다. → `stampLines()` 와 같은 글자 단위 폴백을 추가했다. - ★ **4줄 제한** (2026-09-14 대표: "4줄이상 못쓰게 막으라고"). 글자 수로는 몇 줄이 될지 못 잰다(줄바꿈은 폭·서체에 달렸다) — `textarea` 의 `input` 이벤트마다 `wrapLines()` 로 **실제로 접히는 줄 수**를 재서, 4줄을 넘으면 방금 입력을 되돌린다(엔터를 여러 번 눌러도, 자음만 이어 쳐도 똑같이 막힌다). `draw()` 쪽에도 4줄로 자르고 말줄임표(…)를 붙이는 방어선을 하나 더 둔다 — 붙여넣기처럼 입력 쪽 검사를 안 거치는 경로까지 대비한다. ### 카로셀 제어 (`tameRails`) 렌더러의 자동 넘김(`site/src/lib/ui/use-rail-autoplay.ts`)은 4초 **고정 타이머**이고, 정지는 `held` 카운터 하나로 센다 — 레일 위 `pointerdown` 에 올리고 window `pointerup`· `pointercancel` 에 내린다. 주입분은 타이머를 건드리지 않고 **그 카운터만** 쓴다 (`pointerdown` 을 `button: 2` 로 쏜다 — embla 는 주 버튼이 아닌 것을 드래그로 치지 않는다). 구현이 둘(embla · 스크롤 상자)이어도 카운터는 하나라 한 번에 걸린다. | | 언제 | 얼마나 | |---|---|---| | 쿨타임 | 무엇이든 만지면(세로 스크롤·휠·키보드 포함) | 7초 | | 손이 닿아 있는 동안 | 손가락·마우스 버튼이 내려가 있는 내내 | 뗄 때까지 | | 영구 정지 | **레일이 실제로 가로로 8px 넘게 움직였을 때** | 그 레일은 끝 | ★ **손가락 수로 센다 — pointer 로 세면 안 된다.** 브라우저는 터치가 페이지 스크롤로 넘어가는 순간 **손가락이 아직 닿아 있는데 `pointercancel` 을 쏜다.** 훅의 정지가 거기서 풀려 손가락 밑에서 카드가 넘어간다. 실측: touchstart 0.0s → pointercancel 7.5s → 8.4s 에 넘어감. 그래서 `event.touches.length` 로 센다. ★ **"만졌다"와 "밀었다"를 가른다.** 전에는 레일 위 pointerdown·wheel 이면 곧바로 영구 정지였다. 그런데 일정 카로셀은 화면 가운데를 가득 채워서 페이지를 세로로 내릴 때 손가락· 커서가 거의 언제나 그 위를 지난다 — **한 번 스크롤하면 그 레일이 영영 멈췄다** (실측: 세로 휠 한 번 → 20초 0px). ★ **호버 정지는 렌더러 것이고 그대로 둔다** — 커서를 얹으면 "읽는 중"이 맞다(사장님 확인). 검증: `node rails-test.mjs`. → **운영 사이트에는 이 문제들이 그대로 있다.** 훅을 고치는 것은 별도 작업이다(**3.4**). ### 주입분을 쓸 때 지킬 것 ★ **React 가 다시 그리면 사라진다.** 그래서 - 플레이어 목록·패널은 `#root` **밖**(body 직속)에 둔다 - 헤더에 꽂는 단추는 `MutationObserver` 로 감시해 다시 꽂는다 - 캐치프레이즈 줄은 노드를 들고 있지 않고 **매 순환마다 붙어 있는지 보고, 없으면 대표 문구를 다시 찾아 새로 붙인다** ★ **`window.load` 를 기다리지 않는다.** 경로 지도 때문에 OSM 타일 `` 가 140장이라 `load` 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. --- ## 2.6 밟으면 조용히 틀리는 자리 - **`out/assets` 에 파일을 넣지 않는다.** 거기는 프리렌더가 관리한다(해시 번들 대장 · 30일 보관). 손으로 넣은 사진·음원·번들은 `/s/stay/…` 에 둔다 — 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 에 인용되는 값은 아니다. - **`og:image`** 도 마찬가지다. 화면은 새 사진, 카톡 공유 썸네일만 옛것이다. - **React 가 `hidden`·`aria-selected`·style 로 계산해 주는 속성은 주입분이 직접 안 건드린다.** 건드리면 그 값과 React 내부 상태(`useState`)가 어긋나고, 손님이 **같은 값으로 되돌리는 클릭**(예: 방금까지 활성이던 탭을 다시 누름)을 하면 `setState` 가 같은 값이라 **리렌더를 생략**해 버려(bail-out) 주입분이 덮은 값이 영영 안 돌아온다(2026-09-14, 군산 읽기 탭 버그 — **2.5 항목 3**). React 가 그 요소에 아예 안 쓰는 속성(`style.display` 등)으로만 가리고, 걷을 때는 그 오버라이드만 지운다. - **캔버스 줄바꿈은 공백 기준만으로는 모자란다.** 단어 하나가 이미 칸보다 넓으면 (예: 공백 없이 이어 친 자·모음) 안 잘리고 그대로 넘친다 — 글자 단위 폴백을 같이 둬야 한다(`wrapLines()`·`stampLines()`, **2.5 항목 9**). --- --- # 3부 — 공통 ## 3.1 보고 규칙 — "했다" 고 말하기 전에 2026-09-11 지시. **이 규칙을 어긴 기록이 3.2 에 있다.** 1. **실행하지 않은 것을 됐다고 쓰지 않는다.** 코드에서 빠진 것을 보는 건 확인이 아니다. 화면을 열어 세거나 명령을 돌려서 **숫자·출력**을 얻은 뒤에 말한다. 2. **"했다" 옆에 무엇으로 확인했는지 같이 쓴다.** `audit-all.mjs 38/38` · `md5 일치` · `20초 동안 0px` 처럼. 근거를 못 쓰면 아직 안 끝난 것이다. 3. **절차를 안내할 때는 그 절차를 먼저 밟아 본다.** 4. **일부만 했으면 일부만 했다고 쓴다.** 못 한 것을 빼고 요약하지 않는다. 5. **사장님이 본 것과 내가 잰 것이 다르면, 내가 잰 것을 먼저 의심한다.** 범위를 좁혀서 재지 말고 전수로 다시 센다. --- ## 3.2 실제로 틀렸던 방식 — 되풀이하지 말 것 | 한 짓 | 어떻게 틀렸나 | 대신 할 것 | |---|---|---| | 코드에서 빠진 것만 보고 "고쳤다" 고 보고 | 화면에는 그대로 있었다(캐치프레이즈 빨간 줄) | `node audit-all.mjs` 로 **브라우저에서** 확인하고 숫자를 적는다 | | 안 띄워 보고 "이것만 하면 나온다" 고 안내 | 미러 사진 61장·번들이 레포 밖이라 다른 기계에서 흰 화면 | 절차를 **그대로 밟아 본 뒤** 쓴다 | | 지시받은 한 줄만 고침 | "1번은 숙소" 를 넣으면서 그 하루 전체를 안 읽어 숙소가 3번 서고 두 칸이 연달아 섰다 | 한 곳을 고치면 **같은 종류를 전수 검사**한다 | | 규칙 검사를 매번 일회용 스크립트로 | 뼈대를 고칠 때마다 "이번에 안 본 규칙" 이 생겼다 | 검사를 **빌드 안**에 둔다(`audit()`) | | 6초만 재고 "쿨타임 된다" 고 보고 | 쿨타임이 7초라 재개를 못 본 것 | 기대값보다 **길게** 재고 구간을 찍는다 | | 사진을 눈으로 보고 A/B 배정 | 11/5 가 나왔는데 실제는 12/10 | 등록본 데이터(`rooms.json`)를 쓴다 | | 마크업을 고쳐서 요소 제거 | 하이드레이션이 되돌렸다 | 주입 CSS 로 끈다 | | 이름으로 사진 검색 | 동명이인이 붙었다 | 그 사람 문서의 대표 사진만 | ### 일정이 왜 틀렸나 (2026-09-11 검수 결과) 사장님이 화면에서 먼저 찾으신 것: | | 증상 | 몇 건 | |---|---|---| | A | `아침 · 머뭄` 다음 칸이 `체크아웃 · 머뭄` — 같은 자리에 두 칸이 연달아 섰다 | 1 | | B | 마지막 날 머뭄이 3번(아침 · 체크아웃 · 마무리) — 하루의 절반이 숙소 칸 | 21 | | C | 마지막 날 섬 코스가 저녁도 없이 19:14 에 돌아와 짐만 찾고 끝 | 1 | | D | 아침 칸이 2시간 14분 — 끼니 시각을 맞추려고 앞 칸을 늘리다 생긴 값 | 1 | **A·B 의 원인은 하나다.** "1번은 숙소" 지시를 받고 하루의 첫 칸에 `아침 · 머뭄` 을 넣었는데, 마지막 날 뼈대에는 **이미** `체크아웃 · 머뭄` 과 `마무리 · 머뭄` 이 있었다. 첫 칸만 보고 넣고 그 하루 전체를 다시 읽지 않았다. **C·D 는 검사에 그 항목이 없어서** 통과했다. 고친 것 — 마지막 날 아침과 퇴실을 **한 칸으로 합쳤다**(`아침 · 스테이 머뭄`, "아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요"). 섬 가는 마지막 날은 바깥 정거장을 둘로 줄였다. 아침 상한을 110분으로 내렸다. 그리고 **검사를 빌드 안으로 옮겼다**(2.4). ### 지시를 주실 때 — 이렇게 주시면 한 번에 끝납니다 1. **"X 고치고, 그 화면 전체를 다시 훑어서 같은 종류로 이상한 데 있으면 같이 잡아."** '같은 종류'라는 말이 있으면 한 건이 아니라 그 규칙을 어긴 **전수**를 찾는다. 2. **"브라우저로 열어서 확인하고 말해." / "검증 결과를 숫자로 보여줘."** 3. **규칙을 주실 때 예외까지 같이.** "1번은 항상 머뭄. 단 마지막 날은 퇴실이 있으니 그건 네가 판단해서 제안하고 물어봐." — 예외가 안 정해져 있어서 칸을 하나 더 만들었고 그게 연속 칸이 됐다. --- ## 3.3 파일 목록 · 지울 것 · git ### 쓰는 파일 | 파일 | 무엇 | |---|---| | `README.md` | **이 문서.** 지침은 전부 여기 | | `TEXT.md` | 화면에 나가는 텍스트 전문 (생성물 — `python3 dump_text.py`) | | `build_itinerary.py` | 여행 일정 계산 + 규칙 17종 감사 | | `build_story.py` | 가요 다방 · 인물 열전 + 위키 사진 | | `build_reading.py` | 군산 읽기 — 카로셀 34꼭지(문학·섬과 바다·역사·장소·음식), 화면엔 매번 5~6개 무작위 | | `build_daily.py` | 오늘의 한 장 — 노래·문학 일력 39장 | | `patch_stay.py` | 캐치프레이즈 · 주변 맛집 · 객실 · 자작곡 · HTML 조립 · 자산 경로 교정 | | `fetch_restaurants.py` | 주변 맛집을 TourAPI 2km 로 다시 받아 `restaurants.json` · 사진은 `img/mirror/` (굽기와 따로, 한 번만) | | `fill_restaurant_photos.py` | TourAPI 에 사진이 없는 맛집 11곳 사진을 받아 1600px 로 줄여 둔다 — ⚠ **공공누리 아님, 시연본 전용** | | `dump_text.py` | `TEXT.md` 생성 | | `inject.css` · `inject.js` | 화면에 덧대는 다섯 가지 | | `audit-all.mjs` | 브라우저로 47개 항목 전수 점검 | | `rails-test.mjs` | 카로셀 13개 × 데스크톱·모바일. 모바일에는 이름 없는 가로 상자 3개가 더 잡힌다 — 그중 하나가 가요 다방 판꽂이다(자동으로 안 넘어가므로 전 칸 0 이 맞다) | | `kingcheck.mjs` | 킹서버 반영분 확인 | | `audit_schedule.py` | 일정 전수 검수표 생성 → `SCHEDULE.md` (규칙이 안 보는 논리 13가지) | | `SCHEDULE.md` | 그 결과물 (생성물) | ### 원본 · 원자료 (건드리지 않는다) | 파일 | 무엇 | |---|---| | `stay-payload.json` · `orig/index.html` | **원본.** 되돌릴 때 기준 | | `rooms.json` | 야놀자 등록본에서 뽑은 객실 사진 목록 (A동 12 · B동 10) | | `restaurants.json` | 주변 맛집 31곳 — 원본 8 + TourAPI 2km 23 (사진 20) | | `restaurant-photos.json` | 사진 없던 맛집 11곳의 사진 출처 주소·게시물 제목. 굽기가 사진 없는 집에만 덧댄다 | | `people-photos.json` · `people-images.json` · `people-images-meta.json` | 위키 문서 사진 + 라이선스 | | `people-raw.json` · `people-rows.json` | 인물 수집 원자료 | | `coords.json` | 카카오 로컬에서 받은 좌표 | ### 자산 (레포에 **일부러** 올렸다) | | 개수 | 왜 올리나 | |---|---|---| | `img/` | 28장 | 2026-09-07 에 목업 자산이 통째로 404 났을 때 복구한 곳이 서버가 아니라 git 워크트리였다 | | `img/people/` | 17장 | 위키 사진. 저작자 표시가 라이선스 조건이라 같이 관리한다 | | `img/mirror/` | 61장 | 히어로·소개·객실. 없으면 **다른 기계에서 흰 화면** | | `audio/` | 5곡 | 사장님 자작곡 | | `vendor/` | 3개 | 번들 css/js(해시가 달라도 뜨게 하려고) + `embla-carousel.umd.js`(군산 읽기·가요 다방 카로셀이 쓰는 코어, `node_modules/embla-carousel` 그대로 복사) | 합쳐서 약 77M 이다. 전부 **한 번 넣고 안 바뀌는 파일**이라 git 이 싫어하는 경우가 아니다. 자산은 커밋을 따로 떼어 뒀다 — 히스토리에서 덩치를 걷어낼 일이 생기면 그 커밋만 건드린다. ### git 이그노어 | 대상 | 어디 | 왜 | |---|---|---| | `build/` | 레포 루트 `.gitignore:30` | 생성물. `patch_stay.py` 로 다시 나온다 | | `stay-payload-new.json` | `scripts/mockup/.gitignore` | 중간 산출물 | ### 이 시연이 끝나면 지울 것 목업은 **한시적**이다. 실제 구현(1부)이 끝나면 이 폴더는 통째로 필요 없다. ```bash # ① 레포에서 git rm -r solution/site/scripts/mockup # ② 서버 볼륨에서 (킹서버 · 로컬 각각) docker exec o2o-web4ai-solution-prerender rm -rf /app/solution/site/out/s/stay # ③ 킹서버 배포 임시 폴더 ssh King_admin "rm -rf ~/data2/o2o-site-AEO/tmp-stay-deploy" ``` ★ **지우기 전에 1부를 `docs/` 로 옮긴다.** 1부(제품 구현 지침)는 목업이 사라져도 남아야 하는 내용이다 — 이 폴더째 지우면 같이 없어진다. 2·3부는 목업과 함께 버린다. `stay2` · `stay3` 도 같은 성격의 목업이다. 함께 정리한다. ★ 지우기 전에 [CLAUDE.md 의 ★★ 함정](../../../../CLAUDE.md)을 읽는다 — `out/s/**` 이 참조하는 자산을 먼저 빼야 다른 사이트가 안 끊긴다. --- ## 3.4 남아 있는 판단 (사장님 결정 대기) | | 상태 | |---|---| | 하루에 숙소가 3번 서는 날(`아침 → … → 쉼 → 저녁 → 복귀`) | 점심~저녁 4시간을 바깥 두 곳으로 못 채워 넣은 칸이다. 빼려면 오후 정거장을 하나 더 넣어야 한다 | | 가요 다방 25곡 (목표 50) | 출처에서 확인되는 군산 곡이 거기까지다. 더 채우려면 **지어내야 한다** — 하지 않는다 | | 인물 사진 18/57 | 위키 문서에 사진이 있는 사람이 18명뿐이다. 동명이인 함정 때문에 이름 검색으로 늘리지 않는다 | | 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 `use-rail-autoplay.ts` 를 고치고 **전체 재굽기 + `republish_all.py`** | | ~~일정 정거장의 체류 대비 이동~~ | **해결(2026-09-11)** — 규칙 15·16 을 감사에 넣었다. 전수 검수 21/21 (`SCHEDULE.md`) | | 렌더러의 가요 다방 판 배치 | 운영 사이트도 `flex-wrap` 이라 곡이 늘면 진열대가 된다(`SongsSection.tsx`). 목업은 CSS 로 덮었다(**2.5** 7). 제품에 옮기려면 **전체 재굽기 + `republish_all.py`** | | 날씨 문구를 만드는 경로 | 2026-09-15: 제품에 공통 안내 50개를 읽어 `noteSets`·`tempNoteSets`로 싣고 20초마다 순환하는 경로를 추가했다([WEATHER.md](../../../../docs/WEATHER.md)). 이 목업의 군산 전용 문구는 그대로다. 지역별 장소 추천을 자동 생성하는 잡은 아직 없다 | | 오늘의 한 장 39장 (365일 중 39일) | 확인되는 소재가 거기까지다. 받은 `gunsan_365_story_db.xlsx` 는 365행이지만 실제 주제가 52개(한 주제를 운영 슬롯 7가지로 복제)이고 231행이 `검증필요`, 시·수필은 빈 슬롯이다. 더 채우려면 **군산을 소재로 한 시·수필을 출처와 함께 찾아야** 한다 | | 일정을 이미지/링크로 공유 | 이미지 저장은 가능(카드를 PNG 로). 링크는 지금 섹션까지만 간다 — 탭·코스가 URL 에 안 남는다 |