o2o-site-AEO/solution/site/scripts/mockup/README.md
Mina Choi 5e2def200b [chore] site/mockup: 번들 갱신·문서 정리 · 2안(사진) 빌더 스크립트 추가
- vendor/ 옛 해시 번들을 retired/ 로 옮기고 새 해시로 교체
- build_photo6.py 신설 — /s/stay6 "2안(사진)" 판, 슬러그별로 다시 구울 수 있다
  (python3 build_photo6.py <slug>)
- build_pension.py·build_reading.py·build_stay6.py·patch_stay.py 정리
- AUTOPLAY.md·README.md·audit-all.mjs 갱신
2026-09-23 13:18:51 +09:00

1237 lines
78 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `/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 <repo> && 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-worker
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-worker
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` 유무 |
| 군산 읽기 | **문학 8 · 인물 23 = 31꼭지 전부** 카로셀(2026-09-18 대표: "인물·문학만 담아 랜덤 대신 전체 노출하고 약 30개로 추린다"). 섬과 바다·역사·장소·음식은 뺐다 — `build_reading.py` `KEEP` | `군산 읽기 화면` — 31꼭지 전체 |
### 내용을 바꾼 뒤 (전체 굽기)
```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-worker:/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-worker
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. **캐치프레이즈 순환** — 대표 문구 `<p>` 를 찾아 **그 아래에 줄(`#w4d-sub`)을 붙이고** 단어 단위로 갈아 끼운다. 대표 문구는 안 건드린다 → T1
2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡.
★ **자동재생한다 — 끄지 않는다** (2026-09-22 대표 지시, "내가 키라고 몇번을 말해").
`inject.js:600` 은 `autoplay();` 가 정답이고 `void autoplay;` 로 되돌리지 않는다.
09-11 빼 → 09-14 켜 → 09-15 끔 → 09-22 켜, 네 번 뒤집혔다. 뒤집힌 원인은 이 README 와
`AUTOPLAY.md` 가 서로 반대로 적혀 있어서다(238줄 "한다" ↔ 여기 "껐다").
브라우저 정책상 첫 손길 전에는 어차피 거절되지만(`AUTOPLAY.md`), **호출은 건다** → 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 타일 `<img>` 가 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-worker 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 에 안 남는다 |