diff --git a/solution/site/scripts/mockup/.gitignore b/solution/site/scripts/mockup/.gitignore new file mode 100644 index 0000000..45d6234 --- /dev/null +++ b/solution/site/scripts/mockup/.gitignore @@ -0,0 +1,2 @@ +build/ +stay-payload-new.json diff --git a/solution/site/scripts/mockup/PROMPTS.md b/solution/site/scripts/mockup/PROMPTS.md deleted file mode 100644 index 723417b..0000000 --- a/solution/site/scripts/mockup/PROMPTS.md +++ /dev/null @@ -1,345 +0,0 @@ -# `/s/stay` 시연본의 콘텐츠를 만드는 프롬프트 - -시연본에 들어간 것(히어로 문구 100개 · 여행 일정 21개 · 가요 다방 25곡 · 인물 열전 57명 · -오늘의 엽서)을 **어떤 프롬프트로 뽑는지** 정리한 문서다. -지금 시연본은 이 프롬프트로 뽑은 뒤 사람이 검수해 스크립트에 박아 둔 상태다. - ---- - -## 0. 이 프롬프트들이 어디로 가야 하나 - -**단일 출처는 `solution/shared/src/lib/section-prompts.ts` 다.** -사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와, 서버가 지역 단위로 돌리는 생성 잡 -(`services/story_service.py`)이 **같은 문장**을 써야 한다. 두 벌로 두면 -"빌더에서 뽑은 것과 서버가 채운 것의 모양이 다르다"가 조용히 생긴다. - -``` -solution/shared/src/lib/section-prompts.ts ← 여기만 고친다 - │ npm run export:prompts - ▼ -solution/backend/services/prompts/section_prompts.json ← 산출물, 커밋됨. 손으로 고치지 않는다 -``` - -아래 프롬프트 중 **2·3 은 새로 만들 것**이고(지금 계약에 없다), **4·5·6 은 기존 것을 -고칠 것**이다. 옮길 때 `maxItems` 와 프롬프트 안의 숫자를 **같이** 바꾼다 — -어긋나면 받아 놓고 잘라 버린다. - -| 프롬프트 | 지금 상태 | 옮길 자리 | -|---|---|---| -| 2. 히어로 캐치프레이즈 | **없음** — 새로 만든다 | `SECTION_PROMPTS.catchphrase` (신규) | -| 3. 여행 일정 · 장소 대장 | 있으나 규칙이 부족 | `canvas/dataSpec.ts` `itinerary` 교체 | -| 4. 가요 다방 | 있음(8곡) | `SECTION_PROMPTS.songs` 개선 | -| 5. 인물 열전 | 있음(10명) | `SECTION_PROMPTS.people` 개선 | -| 6. 오늘의 엽서 | 있음(12개) | `SECTION_PROMPTS.postcard` 개선 | - ---- - -## 1. 공통 규칙 (모든 프롬프트 뒤에 붙는다) - -``` -[공통 규칙] -1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다. -2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 "미상" 이라고 쓰지 않는다. -3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다. -4. 근거가 확실하면 verified 를 "확인", 애매하면 "확인필요" 로 적는다. -5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다. -6. 설명 문장은 항목당 두 문장을 넘기지 않는다. -7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다. -``` - -★ 2번과 3번이 이 레포의 절대규칙을 프롬프트로 옮긴 것이다. 모델이 이걸 어기면 검증기가 -뒤에서 걸러야 하는데, 걸러진 항목은 결국 화면에서 빈자리가 된다. - ---- - -## 2. 히어로 캐치프레이즈 (신규) - -상호 아래에서 **7초마다 바뀌는 한 줄**이다. 100개를 미리 쌓아 두고, 계절·월·날씨에 맞는 것을 -섞어 순환 노출한다. - -### 프롬프트 - -``` -너는 숙소의 카피라이터다. 아래 조건에 맞는 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 는 없다. 사실이 아니라 문구이고, 근거는 [업소가 가진 사실] 이다. -``` - -### 잘 나온 예 (시연본에서) - -| 종류 | 문구 | 근거가 된 사실 | -|---|---|---| -| 일반 | 담 너머는 히로쓰 가옥입니다 | 위치 | -| 일반 | 침구는 매일 새것처럼 나갑니다 | 매일 세탁·살균 | -| 일반 | 네 사람까지 묶는 독채입니다 | 최대 4인 | -| 계절(가을) | 기와 위로 가을 볕이 마릅니다 | 기와 지붕 | -| 월(9월) | 가을이 담을 넘어오는 구월입니다 | 담·계절 | -| 날씨(비) | 카페 공간에서 빗소리를 들으실 수 있습니다 | 창고형 카페 | - -### 받은 뒤 검사할 것 - -- [ ] 100개인가. 종류별 개수가 40/24/24/12 인가 -- [ ] 25자를 넘는 것이 있는가 -- [ ] [업소가 가진 사실] 에 없는 말이 있는가 ← **이게 제일 많이 틀린다** -- [ ] 계절 문구가 그 계절에만 맞는가 -- [ ] 같은 말이 두 번 나오는가 - ---- - -## 3. 여행 일정 (신규 — 두 단계로 나눈다) - -★ **한 프롬프트로 일정을 통째로 받지 않는다.** -시연본 첫 판이 그랬고, 나온 것이 "둘째 날 해산물축제 → 근대건축관" 으로 끝나는 하루였다. -모델은 **시각 계산을 못 한다** — 이동시간을 지어내고, 점심을 09:40 에 넣고, -21시를 넘겨 화면에서 잘려 나가는 칸을 만든다. - -그래서 **3-1 로 장소 대장만 받고, 3-2 는 코드가 계산한다.** - -### 3-1. 장소 대장 (LLM 이 만든다) - -``` -너는 지역 여행 코디네이터다. 아래 조건에 맞는 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 에 그 페이지를 단다. -``` - -### 3-2. 일정 조립 (코드가 한다 — LLM 아님) - -`solution/site/scripts/mockup/build_itinerary.py` 가 하는 일이고, -제품에서는 `solution/backend/services/itinerary.py` 가 같은 규칙을 가져야 한다. - -**LLM 에게 맡기지 않는 것** - -| | 왜 | -|---|---| -| 이동시간 | 좌표에서 계산한다. 도보 4km/h, 1.5km 초과는 차 25km/h + 주차 5분 | -| 도착·출발 시각 | 시작 시각부터 누적 | -| 끼니 시각 맞추기 | 창을 벗어나면 앞 칸 체류를 범위 안에서 늘이고 줄인다 | -| 하루 구성 | 아래 뼈대 | - -**하루 뼈대** — `@` 는 숙소 - -``` -첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@ -가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@ -쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@ -마지막날 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@ -``` - -**지켜야 하는 규칙 14종** (어기면 그 일정을 만들지 않는다) - -1. 하루의 첫 칸·마지막 칸은 숙소 — 출발지와 복귀지 -2. **같은 자리가 연달아 서지 않는다** (숙소 칸 사이에는 반드시 바깥 정거장이 있다) -3. 가운데 숙소 칸은 '쉼'·'쉬는 날' 뿐 -4. 이동시간 = 좌표 계산값 -5. 입실 15:00+ · 점심 11:30~13:30 · 늦은 점심 13:00~15:00 · 저녁 17:30~20:00 · 밤산책 18:30~20:00 -6. 퇴실 11:00 전 -7. 21:00 초과 금지 — **넘는 칸은 렌더러가 조용히 버린다** -8. 끼니 자리에는 먹는 곳만 -9. 같은 날 같은 식당 두 번 금지 -10. 한 테마 안에서 같은 곳 반복 금지 -11. "비가 와도 되는" 테마는 `indoor: true` 만 -12. "걸어서만/차 없이" 테마에 `needsCar: true` 금지 -13. 1박 2일 = 2일, 2박 3일 = 3일 -14. 아이 동반 테마에 술집 금지 - -**테마 이름·대상·이유는 LLM 이 만들어도 된다** (아래 3-3). - -### 3-3. 테마 짓기 (LLM) - -``` -[해야 할 일] -[장소 대장] 을 재료로 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개 중 하나는 **숙소에서 거의 나가지 않는 날**을 포함한다. -``` - ---- - -## 4. 가요 다방 (기존 개선) - -지금 계약은 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곡에서 멈췄다. 출처에서 확인되는 군산 곡이 거기까지였다. -**모자라면 모자란 채로 내는 것이 맞다** — 지어낸 곡은 아는 사람이 바로 알아본다. - ---- - -## 5. 인물 열전 (기존 개선) - -지금 계약은 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 를 모르면 뺀다. -``` - -★ **사진은 프롬프트로 받지 않는다.** 시연본은 위키백과 API 로 문서 사진을 받고, -**라이선스가 확인된 것만**(CC BY / CC BY-SA / KOGL / 공용) 썼다 — 57명 중 17명. -CC BY-SA 는 저작자 표시가 라이선스 조건이라 화면에 그대로 찍는다. - ---- - -## 6. 오늘의 엽서 (기존 개선 — 링크가 붙게) - -``` -[해야 할 일] -[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다. -사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다. - -[스키마] -{ "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[ - { "line":"한 문장", "hashtags":["#태그"], - "place":"그 문장이 가리키는 장소 이름", - "searchQuery":"지도에서 검색할 말", - "postmark":"소인에 찍을 짧은 지명", - "verified":"확인|확인필요", - "source":{"name":"출처명","url":"https://..."} } ] } - -[이 아이템만의 규칙] -· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다. -· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다. -· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다. -· **place 와 searchQuery 를 반드시 채운다.** 화면이 그 값으로 지도 링크를 건다. - postmark 는 도장 문구라 지명이 아닐 수 있다 — 그걸로 지도를 열면 엉뚱한 데가 나온다. -``` - -★ 시연본에서 실제로 그랬다. 소인 글자(`군산 內港`)로 링크를 만들었더니 -`군산 군산` 같은 검색어가 나왔다. `place` 를 따로 받는 이유다. - ---- - -## 7. 프롬프트를 고칠 때 지킬 것 - -1. **`maxItems` 와 프롬프트 안의 숫자를 같이 바꾼다.** 어긋나면 받아 놓고 잘라 버린다. -2. **개수를 억지로 채우게 하지 않는다.** "확실한 것이 12개면 12개만 낸다" 를 규칙에 넣는다. - 채우라고 하면 채운다 — 지어내서. -3. **계산은 시키지 않는다.** 시각·거리·요금은 코드가 한다. 모델은 자신 있게 틀린 숫자를 준다. -4. **"설명" 이 아니라 "할 일" 을 시킨다.** 손님이 그 자리에서 무엇을 하는지가 콘텐츠다. -5. **출처의 종류를 지정한다.** "출처를 달아라" 로는 목록 페이지가 온다. -6. 고친 뒤 **`npm run export:prompts`** 를 돌린다. 안 돌리면 서버는 옛 프롬프트로 돈다. diff --git a/solution/site/scripts/mockup/README.md b/solution/site/scripts/mockup/README.md index 91eaf4a..1cdfb9c 100644 --- a/solution/site/scripts/mockup/README.md +++ b/solution/site/scripts/mockup/README.md @@ -1,65 +1,574 @@ -# `/s/stay` 시연본을 만드는 스크립트 — 인수인계 문서 +# `/s/stay` 시연본 — 지침서 한 장 -`https://web4ai.o2osolution.ai/s/stay` (로컬 `http://localhost/s/stay`) 한 페이지를 만드는 -도구 모음이다. **고칠 자리는 전부 이 디렉토리 안에 있다.** +> **이 문서 하나만 읽으면 된다.** 예전에는 README · SECTIONS · PROMPTS · REVIEW 로 흩어져 +> 있었고 무엇을 먼저 볼지 알 수 없었다. 전부 여기로 합쳤다(2026-09-11). +> 같은 폴더의 `TEXT.md` 만 따로다 — 그건 지침이 아니라 화면에 나가는 **텍스트 전문 덤프**다. -> **이어받는 사람에게 — 먼저 읽을 세 줄** -> 1. 이 사이트는 **payload 가 없는 목업**이다. 백엔드·DB·프리렌더를 건드리지 않는다. -> 고치는 것은 구워진 `index.html` 하나이고, 그 파일이 유일본이다. -> 2. **렌더러(`solution/site/src`)를 고치지 않는다.** 고치면 번들 해시가 바뀌어 -> 전 사이트 재굽기 + 전체 재업로드가 따라온다. 목업에만 필요한 것은 `inject.css`/`inject.js` 로 덮는다. -> 3. 무엇을 고치든 **`node audit-all.mjs` 가 34개 항목 전부 통과**해야 끝난 것이다. -> "코드에서 빠졌으니 됐다" 로 끝내면 안 된다 — 실제로 그렇게 틀린 적이 있다(§7). +대상 `https://web4ai.o2osolution.ai/s/stay` (스테이,머뭄 · 전북 군산) -> **빌더·백엔드에서 이걸 제품으로 옮기는 사람은 [SECTIONS.md](SECTIONS.md) 부터 읽는다.** -> 섹션 22개마다 "목업은 무엇으로 만들었고, 제품은 무엇으로 만들어야 하나" 를 적었다 — -> 계약(payload 필드) · 프롬프트 · 아직 없는 것 셋(캐치프레이즈 · 자작곡 · 일정 규칙). +## 지금 누가 무엇을 하나 + +| | 하는 일 | 읽을 곳 | +|---|---|---| +| **사장님(민아)** | `/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` | **프롬프트 단일 출처.** 고친 뒤 `npm run export:prompts` | + +### ② 생성 — 값을 만드는 곳 + +| 파일 | 무엇 | 관련 할 일 | +|---|---|---| +| `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` (규칙 14종) | `backend/services/itinerary.py` | +| `build_story.py` (위키 사진 수집) | `backend/services/external/wikimedia.py` | +| `inject.js` (렌더러에 **없는** 기능 3개) | `site/src/sections/` | --- -## 보고 규칙 — "했다" 고 말하기 전에 +## 1.2 섹션 22개 — 지금 상태 -2026-09-11 지시. **이 규칙을 어긴 기록이 §7 에 여섯 건 있다.** +`할 일` 이 비면 제품이 **이미** 만들 수 있다. 손댈 필요 없다. -1. **실행하지 않은 것을 됐다고 쓰지 않는다.** 코드에서 빠진 것을 보는 건 확인이 아니다. - 화면을 열어 세거나 명령을 돌려서 **숫자·출력**을 얻은 뒤에 말한다. -2. **"했다" 옆에 무엇으로 확인했는지 같이 쓴다.** `audit-all.mjs 34/34` · `md5 일치` · - `20초 동안 0px` 처럼. 근거를 못 쓰면 아직 안 끝난 것이다. -3. **절차를 안내할 때는 그 절차를 먼저 밟아 본다.** - "이것만 하면 나온다" 를 안 띄워 보고 쓴 적이 있다(2026-09-11, 다른 기계에서 흰 화면이었다). -4. **일부만 했으면 일부만 했다고 쓴다.** 못 한 것을 빼고 요약하지 않는다. -5. **사장님이 본 것과 내가 잰 것이 다르면, 내가 잰 것을 먼저 의심한다.** - 범위를 좁혀서 재지 말고 전수로 다시 센다. +| 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 수집 | | +| `itinerary` | **추천 일정** | `data(kind=itinerary)` | `services/itinerary.py` | **T2** | +| `story` | 군산 이야기 | 아래 6탭 껍데기 | — | | +| `songs` | 가요 다방 | `data(kind=songs)` | LLM `section-prompts.ts:67` | **T3 T5** | +| `daily` | 오늘의 한 장 | `data(kind=daily)` | LLM `:91` | 섹션 자리가 없다 | +| `people` | 인물 열전 | `data(kind=people)` | LLM `:113` | **T3 T5** | +| `chronicle` | 시간의 골목 | `data(kind=chronicle)` | LLM `:135` | | +| `postcard` | 오늘의 엽서 | `data(kind=postcard)` | LLM `:160` | | +| `quiz` | 뒤집어 보는 질문 | `data(kind=quiz)` | LLM `:188` | | +| `faq` | 자주 묻는 질문 | `faqs[]` | LLM + 사장님 | | +| `rules` | 이용 규정 | `facts[]` | 사장님이 적음 | | +| `weather` | 날씨 | `local.weather` | open-meteo | | +| `planner` | 계절별 추천 하루 | `data(kind=planner)` | **없음** | (시연본은 꺼 둠) | + +섹션이 아닌 것 하나가 더 있다 — 헤더 음악 플레이어(**T4**). --- -## 0. 먼저 알아야 할 것 — 왜 스크립트인가 +## 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. 히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다. + 순환 순서는 `일반 → 계절 → 일반 → 월 → 일반 → 날씨` 한 바퀴를 만들고 그 바퀴를 돈다. + 일반은 자루에서 뽑아 쓰고 비면 다시 채운다(같은 문장이 연달아 안 나오게). + 계절은 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** 로 짠다. +체류시간·이동시간·입퇴실·끼니 시각 개념이 없다. 그래서 나온 하루가 +"둘째 날 해산물축제 → 근대건축관" 으로 끝난다. + +**★ 프롬프트 하나로 일정을 받지 않는다.** 모델은 시각 계산을 못 한다 — +이동시간을 지어내고, 점심을 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 이 적은 좌표는 쓰지 않는다** | — | +| **③ 조립** | **코드** | 이동시간·도착시각·끼니 창 맞추기·규칙 14종 감사 | 없음 (`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` | +| **감사 14종** | 하나라도 어기면 **payload 를 쓰지 않고 멈춘다** | `:526` · 목록은 **2.4** | + +★ **감사는 생성기 안에 둔다.** 밖에 두면 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생긴다 — +실제로 그래서 33건이 통과했다(**3.2**). + +**하루 뼈대** — `@` 는 숙소 + +``` +첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@ +가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@ +쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@ +마지막날 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@ +``` + +**완료 기준** 새 업장에서 테마 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`)를 넣고 발행 때 같이 올린다. +헤더에 붙는 작은 플레이어를 만든다 — 아이콘 · 현재 곡명 · 재생/멈춤 · 목록. +**자동재생은 넣지 않는다**(2026-09-11 지시). 순서는 **랜덤 시작 후 순차**. +목업 구현은 `inject.js` 의 `startPlayer()`, 모양은 `inject.css` 의 `#w4d-mini` · `#w4d-panel`. + +--- + +### 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 이었다. 등록본 데이터를 쓴다. + +--- + +## 1.4 프롬프트 전문 + +**단일 출처는 `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 ① · 신규) + +``` +너는 지역 여행 코디네이터다. 아래 조건에 맞는 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 에 그 페이지를 단다. +``` + +--- + +### ③ 여행 일정 · 테마 짓기 (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` 하나이고, 그 파일이 유일본이다. +프리렌더는 payload 를 받은 사이트만 굽는다 — 이 사이트는 **재굽기 대상이 아니고**, +고칠 자리는 이미 구워져 있는 `index.html` 하나이고 그 파일이 유일본이다. -그래서 손으로 열어 고치지 않는다. 이 스크립트들이 **원본 + 바꿀 것 → 새 index.html** 을 만든다. -두 번째로 고칠 때 "무엇을 왜 바꿨는지" 가 파일이 아니라 여기 남아야 하기 때문이다. +그래서 손으로 열어 고치지 않는다. 스크립트가 **원본 + 바꿀 것 → 새 index.html** 을 만든다. +두 번째로 고칠 때 "무엇을 왜 바꿨는지"가 파일이 아니라 코드와 이 문서에 남아야 하기 때문이다. ``` stay-payload.json 원본 payload (건드리지 않는다 · 되돌릴 때 기준) orig/index.html 원본 HTML (건드리지 않는다) │ - ├─ build_itinerary.py 여행 일정을 계산해 넣는다 + ├─ build_itinerary.py 여행 일정을 계산해 넣는다 (+ 규칙 14종 감사) ├─ build_story.py 가요 다방 · 인물 열전을 넣는다 - └─ patch_stay.py 캐치프레이즈 · 객실 사진 · 자작곡 · 주입 CSS/JS 를 넣고 HTML 조립 + └─ patch_stay.py 캐치프레이즈 · 객실 사진 · 자작곡 · 주입 CSS/JS · 자산 경로 교정 │ ▼ - build/index.html ← 배포하는 파일 + build/index.html ← 배포하는 파일 (git 이그노어) ``` +★ **렌더러(`solution/site/src`)를 고치지 않는다.** 고치면 번들 해시가 바뀌어 +전 사이트 재굽기 + 전체 재업로드가 따라온다. 목업에만 필요한 것은 `inject.css`/`inject.js` 로 덮는다. + --- -## 1. 한 번에 굽기 +## 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/…` 를 가리켜 다른 기계에서 흰 화면이었다.) + +### 내용을 바꾼 뒤 (전체 굽기) + +```bash python3 build_itinerary.py && python3 build_story.py && python3 patch_stay.py ``` @@ -67,48 +576,52 @@ python3 build_itinerary.py && python3 build_story.py && python3 patch_stay.py `stay-payload-new.json` 을 만들고, 뒤 둘이 그 파일을 이어서 고친다. 순서를 바꾸면 앞 단계 결과가 지워진다. -로컬 반영: +로컬 반영 · 검사: ```bash docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html +node audit-all.mjs # 34개 항목 +node rails-test.mjs # 카로셀 13개 ``` -검사: +`/s/` 는 **5분 캐시**(`max-age=300`)다 — 확인할 때 ⌘⇧R. + +### 킹서버 ```bash -node audit-all.mjs # 34개 항목 전수 점검 (로컬) -node audit-all.mjs https://web4ai.o2osolution.ai/s/stay # 킹서버 +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. 무엇을 어디서 고치나 +## 2.3 무엇을 어디서 고치나 -### 히어로 문구 (캐치프레이즈 100개) → `patch_stay.py` - -화면 맨 위, 상호 아래에서 **7초마다 단어 단위로 바뀌는 한 줄**이다. +### 히어로 문구 100개 → `patch_stay.py` | 자리 | 줄 | 무엇 | |---|---|---| -| `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개 — 맑음/구름많음/흐림/비/눈 | +| `GENERAL` | `:20` | 일반 40개 | +| `SEASON` | `:62` | 계절별 24개 — 봄/여름/가을/겨울 각 6 | +| `MONTH` | `:72` | 월별 24개 — 1~12월 각 2 | +| `WEATHER` | `: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` 이면 애니메이션 없이 글자만 바뀐다. +순환 순서·계절 구분·날씨 연동은 **1.3 T1** 에 적어 둔 그대로다(`inject.js`). ### 여행 일정 → `build_itinerary.py` @@ -116,123 +629,37 @@ node audit-all.mjs https://web4ai.o2osolution.ai/s/stay # 킹서버 | 자리 | 줄 | 무엇 | |---|---|---| -| `PLACES` | `:40` | **장소 대장** — 좌표 · 지도 검색어 · 체류시간(최소/기본/최대) · **거기서 무엇을 하는지** | +| `PLACES` | `:40` | **장소 대장** — 좌표 · 지도 검색어 · 체류시간(min/base/max) · **거기서 무엇을 하는지** | | `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배) | +| `WINDOW` | `:156` | 시간대가 정해진 자리 | +| `SKELETON` | `:182` | 하루 뼈대. `@` 가 붙은 것은 머뭄 | +| `THEMES` | `:368` | 테마 21개 | +| `PACING` | `:470` | 테마별 시작 시각과 체류 배율 | -**새 장소를 넣으려면** `PLACES` 에 한 줄 더한다. 좌표는 카카오 로컬 검색에서 받은 실제 값이어야 -한다(지어내지 않는다). 그다음 `THEMES` 의 원하는 자리에 이름을 적는다. - -**새 테마를 넣으려면** `THEMES` 에 한 덩이, `PACING` 에 한 줄 더한다. -뼈대의 `@` 없는 자리 수와 장소 개수가 같아야 한다 — 틀리면 빌드가 멈추고 알려 준다. - -**계산되는 것 (손으로 적지 않는다)** -- **이동시간** — 좌표에서. 1.5km 까지 도보 4km/h, 그 위는 차 25km/h + 주차 5분, 최소 3분 -- **도착·출발 시각** — 시작 시각부터 누적 -- **끼니 시각 맞추기** — 창을 벗어나면 **앞 칸 체류를 범위 안에서 늘이고 줄인다**. - (사람이 실제로 그렇게 한다 — 밥 때가 이르면 박물관을 조금 더 본다.) - 그래도 안 맞으면 시작 시각을 10분씩 옮긴다. 그래도 안 되면 **그 조합을 만들지 않는다.** +**새 장소** → `PLACES` 에 한 줄. 좌표는 카카오 로컬에서 받은 실제 값이어야 한다(지어내지 않는다). +그다음 `THEMES` 의 원하는 자리에 이름을 적는다. +**새 테마** → `THEMES` 에 한 덩이, `PACING` 에 한 줄. 뼈대의 `@` 없는 자리 수와 장소 개수가 +같아야 한다 — 틀리면 빌드가 멈추고 알려 준다. ### 가요 다방 · 인물 열전 → `build_story.py` | 자리 | 줄 | 무엇 | |---|---|---| -| `SONGS` | `:28` | 군산을 노래한 25곡 — 곡명·가수·작사·작곡·연도·라벨색·한 줄 | -| `PEOPLE` | `:109` | 인물 57명 — 위키 문서 제목 · 표시 이름 · 연도 · 하는 일 · 한 줄 | +| `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): 이수현(독립운동가)에 걸그룹 멤버, 은성수(금융위원장)에 축구선수, - 박성현(양궁)에 골퍼, 전진(비전향 장기수)에 아이돌, 이길여에 건물 사진이 왔다. - **틀린 사진은 없는 것만 못하다.** 그 사람의 문서가 대표로 쓰는 사진만 신뢰한다. -- 숙소가 만든 곡은 **이 섹션에 넣지 않는다.** 도시의 노래를 모으는 자리다. +- 연도·작사·작곡을 모르면 **비운다**("미상"이라고 적지 않는다 — 렌더러가 없는 칸을 안 그린다). +- 듣기 링크는 **유튜브 검색 주소**다. 영상 ID 를 박으면 틀린 영상이 붙을 수 있다. +- 인물 사진은 두 곳 — ① `people-photos.json`(위키 문서 대표 사진 17장, 라이선스 확인분) + ② 원본 payload 에 있던 미러 사진(채만식). 새로 가져오는 게 아니라 자리로 돌리는 것이다. +- ★ 이름만으로 커먼즈를 검색하지 않는다(**1.3 T3**). +- 숙소가 만든 곡은 이 섹션에 넣지 않는다. 도시의 노래를 모으는 자리다. -### 객실 사진 · 자작곡 · 대표 사진 → `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 가 그린 `

` 를 찾아 단어 단위로 갈아 끼운다 -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 타일 `` 가 -140장이라 `load` 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. +### 객실 사진 → `patch_stay.py` `ROOM_PHOTOS`(`:142`) · `rooms.json` --- -## 3. 규칙 검사 — 어기면 빌드가 멈춘다 +## 2.4 규칙 14종 — 어기면 빌드가 멈춘다 `build_itinerary.py` 의 `audit()`(`:526`)이 매 실행 검사한다. 하나라도 어기면 **payload 파일을 쓰지 않고** 위반 목록을 찍고 멈춘다. @@ -258,101 +685,217 @@ node audit-all.mjs https://web4ai.o2osolution.ai/s/stay # 킹서버 그래서 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생겼고 사장님이 화면에서 먼저 찾으셨다. **검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다.** -`audit-all.mjs` 는 그다음 단계다 — 구워진 HTML 을 **실제 브라우저로 열어** -34개 항목을 센다(캐치프레이즈 개수, 링크 개수, 사진 개수, 모바일 레이아웃, 404, 콘솔 오류…). +`audit-all.mjs` 는 그다음 단계다 — 구워진 HTML 을 **실제 브라우저로 열어** 34개 항목을 센다. --- -## 4. 배포 +## 2.5 주입분(`inject.js` · `inject.css`) 다섯 -### 로컬 +렌더러에 없어서 이 목업에만 얹은 것이다. 제품으로 옮길 자리는 1부에 적었다. -```bash -docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html -``` +1. **캐치프레이즈 순환** — React 가 그린 `

` 를 찾아 단어 단위로 갈아 끼운다 → T1 +2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡 → T4 +3. **링크 걸기** — 일정 정거장 187개 · 엽서 4개를 네이버 지도로 +4. **사진 저작자 표시** — 이 목업이 물고 있는 번들이 옛 판이라 `imageCredit` 을 안 그린다 → T3 +5. **카로셀 제어** — 아래 -`/s/` 는 **5분 캐시**(`max-age=300`)다 — 확인할 때 ⌘⇧R. +### 카로셀 제어 (`tameRails`) -### 킹서버 +렌더러의 자동 넘김(`site/src/lib/ui/use-rail-autoplay.ts`)은 4.5초 **고정 타이머**이고, +정지는 `held` 카운터 하나로 센다 — 레일 위 `pointerdown` 에 올리고 window `pointerup`· +`pointercancel` 에 내린다. 주입분은 타이머를 건드리지 않고 **그 카운터만** 쓴다 +(`pointerdown` 을 `button: 2` 로 쏜다 — embla 는 주 버튼이 아닌 것을 드래그로 치지 않는다). +구현이 둘(embla · 스크롤 상자)이어도 카운터는 하나라 한 번에 걸린다. -```bash -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" -``` +| | 언제 | 얼마나 | +|---|---|---| +| 쿨타임 | 무엇이든 만지면(세로 스크롤·휠·키보드 포함) | 7초 | +| 손이 닿아 있는 동안 | 손가락·마우스 버튼이 내려가 있는 내내 | 뗄 때까지 | +| 영구 정지 | **레일이 실제로 가로로 8px 넘게 움직였을 때** | 그 레일은 끝 | -확인: `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`. +★ **손가락 수로 센다 — pointer 로 세면 안 된다.** 브라우저는 터치가 페이지 스크롤로 +넘어가는 순간 **손가락이 아직 닿아 있는데 `pointercancel` 을 쏜다.** 훅의 정지가 거기서 +풀려 손가락 밑에서 카드가 넘어간다. 실측: touchstart 0.0s → pointercancel 7.5s → +8.4s 에 넘어감. 그래서 `event.touches.length` 로 센다. -되돌리기: 컨테이너 안에 `index.html.bak-20260910` 이 있다. +★ **"만졌다"와 "밀었다"를 가른다.** 전에는 레일 위 pointerdown·wheel 이면 곧바로 영구 +정지였다. 그런데 일정 카로셀은 화면 가운데를 가득 채워서 페이지를 세로로 내릴 때 손가락· +커서가 거의 언제나 그 위를 지난다 — **한 번 스크롤하면 그 레일이 영영 멈췄다** +(실측: 세로 휠 한 번 → 20초 0px). + +★ **호버 정지는 렌더러 것이고 그대로 둔다** — 커서를 얹으면 "읽는 중"이 맞다(사장님 확인). + +검증: `node rails-test.mjs`. +→ **운영 사이트에는 이 문제들이 그대로 있다.** 훅을 고치는 것은 별도 작업이다(**3.4**). + +### 주입분을 쓸 때 지킬 것 + +★ **React 가 다시 그리면 사라진다.** 그래서 +- 플레이어 목록·패널은 `#root` **밖**(body 직속)에 둔다 +- 헤더에 꽂는 단추는 `MutationObserver` 로 감시해 다시 꽂는다 +- 캐치프레이즈는 노드를 들고 있지 않고 **매 순환마다 다시 찾는다** + +★ **`window.load` 를 기다리지 않는다.** 경로 지도 때문에 OSM 타일 `` 가 140장이라 +`load` 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. --- -## 5. 밟으면 조용히 틀리는 자리 +## 2.6 밟으면 조용히 틀리는 자리 -- **`out/assets` 에 파일을 넣지 않는다.** 거기는 프리렌더가 관리한다(해시 번들 대장 · - 30일 보관). 손으로 넣은 사진·음원은 `/s/stay/img/` · `/s/stay/audio/` 에 둔다 — - nginx 의 `^~ /s/` 가 그대로 서빙하고(Range 206 확인) 정리 대상이 아니다. +- **`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`** 도 마찬가지다. 화면은 새 사진, 카톡 공유 썸네일만 옛것이다. + +--- +--- + +# 3부 — 공통 + +## 3.1 보고 규칙 — "했다" 고 말하기 전에 + +2026-09-11 지시. **이 규칙을 어긴 기록이 3.2 에 있다.** + +1. **실행하지 않은 것을 됐다고 쓰지 않는다.** 코드에서 빠진 것을 보는 건 확인이 아니다. + 화면을 열어 세거나 명령을 돌려서 **숫자·출력**을 얻은 뒤에 말한다. +2. **"했다" 옆에 무엇으로 확인했는지 같이 쓴다.** `audit-all.mjs 34/34` · `md5 일치` · + `20초 동안 0px` 처럼. 근거를 못 쓰면 아직 안 끝난 것이다. +3. **절차를 안내할 때는 그 절차를 먼저 밟아 본다.** +4. **일부만 했으면 일부만 했다고 쓴다.** 못 한 것을 빼고 요약하지 않는다. +5. **사장님이 본 것과 내가 잰 것이 다르면, 내가 잰 것을 먼저 의심한다.** + 범위를 좁혀서 재지 말고 전수로 다시 센다. --- -## 6. 남아 있는 일 · 넘기는 판단 - -| | 상태 | -|---|---| -| 하루에 숙소가 3번 서는 날(`아침 → … → 쉼 → 저녁 → 복귀`) | **사장님 컨펌 대기.** 점심~저녁 4시간을 바깥 두 곳으로 못 채워 넣은 칸이다. 빼려면 오후 정거장을 하나 더 넣어야 한다 | -| 가요 다방 25곡 (목표 50) | 출처에서 확인되는 군산 곡이 거기까지다. 더 채우려면 **지어내야 한다** — 하지 않는다 | -| 인물 사진 18/57 | 위키 문서에 사진이 있는 사람이 18명뿐이다. 동명이인 함정 때문에 이름 검색으로 늘리지 않는다 | -| 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 `use-rail-autoplay.ts` 에 "손님이 직접 밀면 타이머를 끝낸다" 를 넣고 **전체 재굽기 + republish_all** | -| `og:image` | 구운 head 에 옛 주소가 남아 있다. 화면은 새 사진, 카톡 공유 썸네일만 옛것 | - ---- - -## 7. 이 목업을 고치다 실제로 틀렸던 방식 — 되풀이하지 말 것 +## 3.2 실제로 틀렸던 방식 — 되풀이하지 말 것 | 한 짓 | 어떻게 틀렸나 | 대신 할 것 | |---|---|---| -| 코드에서 빠진 것만 보고 "고쳤다" 고 보고 | 화면에는 그대로 있었다 | `node audit-all.mjs` 로 **브라우저에서** 확인하고 숫자를 적는다 | +| 코드에서 빠진 것만 보고 "고쳤다" 고 보고 | 화면에는 그대로 있었다(캐치프레이즈 빨간 줄) | `node audit-all.mjs` 로 **브라우저에서** 확인하고 숫자를 적는다 | +| 안 띄워 보고 "이것만 하면 나온다" 고 안내 | 미러 사진 61장·번들이 레포 밖이라 다른 기계에서 흰 화면 | 절차를 **그대로 밟아 본 뒤** 쓴다 | | 지시받은 한 줄만 고침 | "1번은 숙소" 를 넣으면서 그 하루 전체를 안 읽어 숙소가 3번 서고 두 칸이 연달아 섰다 | 한 곳을 고치면 **같은 종류를 전수 검사**한다 | -| 규칙 검사를 매번 일회용 스크립트로 | 뼈대를 고칠 때마다 "이번에 안 본 규칙" 이 생겼다 | 검사를 **빌드 안**에 둔다(`build_itinerary.py` `audit()`) | -| 사진을 눈으로 보고 A/B 배정 | 11/5 가 나왔는데 실제는 12/10 이었다 | 등록본 데이터(`rooms.json`)를 쓴다 | +| 규칙 검사를 매번 일회용 스크립트로 | 뼈대를 고칠 때마다 "이번에 안 본 규칙" 이 생겼다 | 검사를 **빌드 안**에 둔다(`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번은 항상 머뭄. 단 마지막 날은 퇴실이 있으니 + 그건 네가 판단해서 제안하고 물어봐." — 예외가 안 정해져 있어서 칸을 하나 더 만들었고 + 그게 연속 칸이 됐다. + --- -## 8. 파일 목록 +## 3.3 파일 목록 · 지울 것 · git + +### 쓰는 파일 | 파일 | 무엇 | |---|---| -| `build_itinerary.py` | 여행 일정 계산 + 규칙 14종 검사 | -| `build_story.py` | 가요 다방 · 인물 열전 | -| `patch_stay.py` | 캐치프레이즈 · 객실 · 자작곡 · HTML 조립 | -| `inject.css` · `inject.js` | 화면에 덧대는 것(플레이어 · 문구 순환 · 링크 · 크레딧) | +| `README.md` | **이 문서.** 지침은 전부 여기 | +| `TEXT.md` | 화면에 나가는 텍스트 전문 (생성물 — `python3 dump_text.py`) | +| `build_itinerary.py` | 여행 일정 계산 + 규칙 14종 감사 | +| `build_story.py` | 가요 다방 · 인물 열전 + 위키 사진 | +| `patch_stay.py` | 캐치프레이즈 · 객실 · 자작곡 · HTML 조립 · 자산 경로 교정 | +| `dump_text.py` | `TEXT.md` 생성 | +| `inject.css` · `inject.js` | 화면에 덧대는 다섯 가지 | | `audit-all.mjs` | 브라우저로 34개 항목 전수 점검 | -| `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` | 이 콘텐츠를 만드는 프롬프트 — 제품으로 옮길 자리까지 | +| `rails-test.mjs` | 카로셀 13개 × 데스크톱·모바일 | +| `kingcheck.mjs` | 킹서버 반영분 확인 | + +### 원본 · 원자료 (건드리지 않는다) + +| 파일 | 무엇 | +|---|---| +| `stay-payload.json` · `orig/index.html` | **원본.** 되돌릴 때 기준 | +| `rooms.json` | 야놀자 등록본에서 뽑은 객실 사진 목록 (A동 12 · B동 10) | +| `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/` | 2개 | 번들 css/js. 해시가 달라도 뜨게 하려고 | + +합쳐서 약 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" +``` + +`stay2` · `stay3` 도 같은 성격의 목업이다. 함께 정리한다. +★ 지우기 전에 [CLAUDE.md 의 ★★ 함정](../../../../CLAUDE.md)을 읽는다 — +`out/s/**` 이 참조하는 자산을 먼저 빼야 다른 사이트가 안 끊긴다. + +--- + +## 3.4 남아 있는 판단 (사장님 결정 대기) + +| | 상태 | +|---|---| +| 하루에 숙소가 3번 서는 날(`아침 → … → 쉼 → 저녁 → 복귀`) | 점심~저녁 4시간을 바깥 두 곳으로 못 채워 넣은 칸이다. 빼려면 오후 정거장을 하나 더 넣어야 한다 | +| 가요 다방 25곡 (목표 50) | 출처에서 확인되는 군산 곡이 거기까지다. 더 채우려면 **지어내야 한다** — 하지 않는다 | +| 인물 사진 18/57 | 위키 문서에 사진이 있는 사람이 18명뿐이다. 동명이인 함정 때문에 이름 검색으로 늘리지 않는다 | +| 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 `use-rail-autoplay.ts` 를 고치고 **전체 재굽기 + `republish_all.py`** | +| 일정 정거장의 체류 대비 이동 | 해망굴처럼 **이동 21분에 체류 30분**인 자리가 몇 개 있다. 감사에 "체류 ≥ 편도 이동" 규칙(15번)을 넣을지 결정 필요 | +| 일정을 이미지/링크로 공유 | 이미지 저장은 가능(카드를 PNG 로). 링크는 지금 섹션까지만 간다 — 탭·코스가 URL 에 안 남는다 | diff --git a/solution/site/scripts/mockup/REVIEW-2026-09-11.md b/solution/site/scripts/mockup/REVIEW-2026-09-11.md deleted file mode 100644 index e6d8d60..0000000 --- a/solution/site/scripts/mockup/REVIEW-2026-09-11.md +++ /dev/null @@ -1,176 +0,0 @@ -# /s/stay 시연본 — 무엇이 잘못됐고 무엇을 고쳤나 - -작성 2026-09-11 · 대상 `https://web4ai.o2osolution.ai/s/stay` -**컨펌 요청**: 아래 3장을 보고, 틀린 판단이 있으면 짚어 주세요. 컨펌 후 킹서버에 올립니다. - ---- - -## 1장. 사장님이 찾은 것 — 스케줄이 왜 말이 안 됐나 - -### 잘못된 것 - -| # | 증상 | 몇 건 | -|---|---|---| -| A | **`아침 · 스테이 머뭄` 다음 칸이 `체크아웃 · 스테이 머뭄`** — 같은 자리에 두 칸이 연달아 섰다. 그 사이에 손님은 아무 데도 가지 않는다 | 1건 | -| B | **마지막 날 머뭄이 3번**(아침 · 체크아웃 · 마무리) — 하루의 절반이 숙소 칸 | 21건 | -| C | 마지막 날 섬 코스가 **저녁도 없이 19:14 에 돌아와 짐만 찾고 끝** | 1건 | -| D | 아침 칸이 **2시간 14분** — 끼니 시각을 맞추려고 앞 칸을 늘리다 생긴 값 | 1건 | - -### 왜 이렇게 됐나 - -**A·B 의 원인은 하나입니다.** 사장님이 "1번은 스테이 머뭄이어야 한다"고 하셔서 하루의 첫 칸에 -`아침 · 머뭄` 을 넣었습니다. 그런데 마지막 날 뼈대에는 **이미** `체크아웃 · 머뭄` 과 -`마무리 · 머뭄` 이 있었습니다. 첫 칸만 보고 넣었고, **그 하루 전체를 다시 읽지 않았습니다.** -그래서 머뭄이 3번이 됐고, 그중 둘이 붙어 있는 날이 생겼습니다. - -**C·D 는 검사에 그 항목이 없어서** 통과했습니다. 저는 그때까지 "끼니 시각 · 이동시간 · -21시 초과" 만 봤고, "하루에 저녁이 있는가", "한 칸이 지나치게 길지 않은가" 는 세지 않았습니다. - -### 고친 것 - -- **마지막 날 아침과 퇴실을 한 칸으로 합쳤습니다.** - `아침 · 스테이 머뭄 (75분)` — "아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 - 나섰다가 저녁에 찾아 가세요." → 하루가 **머뭄 → 바깥 3~4곳 → 머뭄** 으로 정리됩니다. -- **섬 가는 마지막 날**은 바깥 정거장을 둘로 줄였습니다(퇴실 → 선유도 → 늦은 점심 → 마무리). - 왕복 두 시간이라 그 이상은 못 넣습니다. -- **아침 상한을 110분**으로 내렸습니다. - -### 지금 상태 (예: 걸어서만 도는 1박 2일) - -``` -첫째 날 15:00 - 15:00–15:49 체크인 · 스테이 머뭄 - 15:52–16:37 오후 · 히로쓰 가옥 (3분 이동) - 16:41–17:26 오후 · 초원사진관 (4분 이동) - 17:30–18:22 저녁 · 일흥옥 (4분 이동) - 18:35–19:17 밤 산책 · 군산 내항 (13분 이동) - 19:33–20:05 머뭄으로 · 스테이 머뭄 (16분 이동) - -둘째 날 09:20 - 09:20–10:39 아침 · 스테이 머뭄 ← 퇴실까지 이 칸에서 끝냅니다 - 10:49–11:36 오전 · 군산근대건축관 (10분 이동) - 11:40–12:32 점심 · 한일옥 (4분 이동) - 12:36–13:02 오후 · 우체통거리 (4분 이동) - 13:10–13:52 마무리 · 스테이 머뭄 (8분 이동) -``` - -### 하루에 머뭄이 3번인 날이 아직 있습니다 — 의도한 것입니다 - -`아침 → … → **쉼**(머뭄) → 저녁 → 머뭄으로` 형태입니다. -점심은 늦어도 13:30, 저녁은 일러도 17:30 입니다. 그 사이 **네 시간**을 바깥 두 곳으로는 -못 채웁니다. 억지로 채우면 골목 하나에 두 시간 세우게 되고(전에 실제로 그랬습니다), -독채 펜션에 묵는 손님은 그 시간에 실제로 들어와 쉽니다. **다만 머뭄 칸이 연달아 서는 일은 -이제 없습니다.** 이게 아니라고 보시면 오후 정거장을 하나 더 넣는 쪽으로 바꾸겠습니다. - ---- - -## 2장. 같은 실수가 다시 안 나오게 한 것 - -**원인은 "검사를 안 했다" 가 아니라 "검사가 빌드 밖에 있었다" 입니다.** -지금까지 규칙 검사를 매번 그때그때 쓴 일회용 스크립트로 돌렸습니다. 그래서 뼈대를 고칠 때마다 -**이번에 안 본 규칙**이 생겼고, 그걸 사장님이 화면에서 먼저 찾으셨습니다. - -이제 검사를 **일정 생성 스크립트 안**에 넣었습니다 -(`solution/site/scripts/mockup/build_itinerary.py`). -규칙을 하나라도 어기면 **payload 파일을 아예 쓰지 않고 빌드가 멈춥니다.** - -검사하는 규칙 14종: - -| | 규칙 | -|---|---| -| 1 | 하루의 첫 칸과 마지막 칸은 스테이 머뭄 | -| 2 | **같은 자리가 연달아 서지 않는다** ← 이번에 걸린 것 | -| 3 | 가운데 머뭄 칸은 '쉼'·'쉬는 날' 뿐 | -| 4 | 이동시간 = 좌표에서 계산한 값 (도보 4km/h, 1.5km 초과는 차 25km/h + 주차 5분) | -| 5 | 입실 15:00 이후 · 점심 11:30–13:30 · 늦은 점심 13:00–15:00 · 저녁 17:30–20:00 · 밤 산책 18:30–20:00 | -| 6 | 퇴실은 11:00 전 | -| 7 | 21:00 초과 금지 (넘는 칸은 렌더러가 조용히 버립니다) | -| 8 | 끼니 자리에는 먹는 곳만 | -| 9 | 같은 날 같은 식당 두 번 금지 | -| 10 | 한 테마 안에서 같은 곳 반복 금지 | -| 11 | "비가 와도 되는" 테마는 실내만 | -| 12 | "걸어서만/차 없이" 테마에 차가 필요한 곳 금지 | -| 13 | 1박 2일 = 2일, 2박 3일 = 3일 | -| 14 | 아이 동반 테마에 술집 금지 | - -현재 **21개 테마 · 위반 0건**. - ---- - -## 3장. 앞으로 저에게 어떻게 지시하시면 되는지 - -제가 이번에 반복한 실수는 두 종류였습니다. 각각에 대응하는 지시 방법을 적습니다. - -### ① "고쳐" 라고만 하면 저는 **그 한 줄만** 고칩니다 - -이번 예: "1번은 머뭄이어야지" → 첫 칸만 넣고 그 하루 전체를 다시 안 읽었습니다. - -**이렇게 말씀해 주시면 됩니다** -> "X 고치고, **그 화면 전체를 다시 훑어서** 같은 종류로 이상한 데 있으면 같이 잡아." - -'같은 종류'라는 말이 있으면 저는 한 건이 아니라 그 규칙을 어긴 **전수**를 찾습니다. - -### ② 제가 "확인했다"고 할 때 **무엇으로** 확인했는지 물어 주세요 - -이번에 빨간 줄 건에서, 저는 **코드에서 빠진 것만 보고** "고쳤다"고 말씀드렸습니다. -화면으로 확인하지 않았습니다. 그건 제 잘못입니다. - -**이렇게 말씀해 주시면 됩니다** -> "브라우저로 열어서 확인하고 말해." -> "검증 결과를 숫자로 보여줘." - -지금은 playwright 로 실제 화면을 열어 확인합니다 -(`scripts/mockup/verify.mjs` · `player.mjs`). 앞으로 "확인했다"는 말에는 **무엇을 몇 건 셌는지** -같이 적겠습니다. - -### ③ 규칙을 주실 때는 "예외"까지 같이 주시면 한 번에 끝납니다 - -이번 예: "1번은 머뭄" → 마지막 날 퇴실은 어떻게 되는지가 안 정해져 있었습니다. -그래서 제가 임의로 칸을 하나 더 만들었고, 그게 연속 칸이 됐습니다. - -**이렇게 말씀해 주시면 됩니다** -> "1번은 항상 머뭄. **단 마지막 날은 퇴실이 있으니 그건 네가 판단해서 제안하고 물어봐.**" - -### ④ 판단이 갈리는 자리는 제가 먼저 여쭙겠습니다 - -지금 갈릴 수 있는 자리는 이것입니다 — **오후에 숙소로 돌아와 쉬는 칸(위 1장 마지막)**. -넣는 게 맞는지 빼는 게 맞는지 사장님 판단을 듣고 정하겠습니다. - ---- - -## 참고 — 이번 작업에서 바뀐 것 전체 (메일용) - -**여행 일정 (추천 일정)** -- 테마 21개 전부 다시 짬. 모든 일정이 **스테이 머뭄에서 출발해 스테이 머뭄으로 복귀** -- 하루가 오전만 있고 끝나던 것 → **오전·점심·오후·저녁이 다 있는 하루**로 -- 이동시간을 좌표에서 계산 (전에는 담 하나 건넌 곳이 8분, 45분 걸리는 섬이 10분이었음) -- 체류시간을 장소별로 (전에는 '오후' 칸이면 무엇이든 90~115분이라 골목에 2시간 서 있었음) -- 입실 15:00 · 퇴실 11:00 · 끼니 시각을 규칙으로 못박음 -- 정거장 설명을 "그곳이 무엇인지" → **"거기서 무엇을 하는지"** 로 전부 교체 -- 정거장 사진 제거(카드가 너무 길어짐), 정거장마다 **네이버 지도 링크** (191개) -- "숙소에서 조용히 쉬는 날" 테마 추가 - -**가요 다방** -- 군산을 노래한 **25곡**으로 교체 (곡명·가수·작사·작곡·연도·유튜브 링크·출처) -- 숙소가 만든 곡은 이 섹션에서 뺌 — 도시의 노래를 모으는 자리라서 - -**인물 열전** -- **57명** (위키백과 '군산시 출신' 분류 · 각자 문서 링크) -- 사진 **17장** — 자유 라이선스가 확인된 것만. 저작자 표시를 화면에 남김(라이선스 조건) - -**음악 플레이어 (신규)** -- 헤더 안 카세트 아이콘 + 재생/멈춤 + 목록. 재생 중에는 카세트 릴이 돎 -- 머뭄이 만든 곡 5곡, **랜덤 재생** -- 목록: 곡별 판 · 제목 · 가수 · 재생 중 막대 애니메이션, **모바일 전용 레이아웃** - -**캐치프레이즈 (신규)** -- 100개 (일반 40 · 계절 24 · 월 24 · 날씨 12), 7초마다 단어 단위 애니메이션으로 교체 -- 계절/월/실시간 날씨에 맞는 문구가 섞여 나옴 - -**객실 안내** -- 야놀자 등록본 사진으로 전면 교체 — **A동 12장 · B동 10장** (등록본이 객실에 붙여 둔 순서 그대로) -- 침대 구성(A동 퀸 / B동 킹) · 면적(A동 52m²) · 객실 시설 추가 - -**기타** -- 오늘의 엽서에 지도 링크 추가 -- 이용 정보 맨 아래 가로선 제거 diff --git a/solution/site/scripts/mockup/SECTIONS.md b/solution/site/scripts/mockup/SECTIONS.md deleted file mode 100644 index 161cf38..0000000 --- a/solution/site/scripts/mockup/SECTIONS.md +++ /dev/null @@ -1,250 +0,0 @@ -# 이 시연본을 제품으로 옮기기 — 개발자 작업 지시서 - -대상 `https://web4ai.o2osolution.ai/s/stay` (스테이,머뭄 · 전북 군산) · 작성 2026-09-11 - -**이 문서를 읽는 사람**: 이 시연본에 있는 것을 빌더·백엔드에서 **자동으로 만들게** 하려는 사람. -설명서가 아니라 **할 일 목록**이다. 프롬프트 전문은 [PROMPTS.md](PROMPTS.md) 에 있고 -여기서는 "어느 절을 쓰라"고만 가리킨다. - ---- - -## 0. 먼저 — 화면부터 띄운다 (3분) - -```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 # build/index.html 을 만든다 - -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 # 34개 항목 전수 점검 -``` - -→ `http://localhost/s/stay` - -★ **이 시연본은 레포 안에서 자급자족한다.** 번들 css/js 와 미러 사진 61장까지 -`vendor/` · `img/mirror/` 에 들어 있고, `patch_stay.py` 가 경로를 `/s/stay/…` 로 바꿔 박는다. -그래서 그 기계에서 구운 번들 해시와 **무관하게** 뜬다. (2026-09-11 이전 판은 안 그랬다 — -`/assets/…` 를 가리켜서 다른 기계에서는 흰 화면이었다.) - ---- - -## 1. 어떤 파일을 여나 - -| 파일 | 무엇이 들어 있나 | 언제 연다 | -|---|---|---| -| [PROMPTS.md](PROMPTS.md) | **프롬프트 전문 7종** — 캐치프레이즈 · 장소 대장 · 테마 · 노래 · 인물 · 엽서 | 생성 프롬프트를 쓸 때 | -| [README.md](README.md) | 목업 고치는 절차 · 배포 · 밟으면 조용히 틀리는 자리 | 목업 자체를 손볼 때 | -| [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) | 일정이 왜 틀렸고 무엇을 고쳤나 | 일정 규칙을 옮기기 전에 | -| [TEXT.md](TEXT.md) | 화면에 나가는 텍스트 전문 | 문구 톤을 볼 때 | -| `build_itinerary.py` | 일정 생성기 **원본** — 규칙 14종이 여기 있다 | 일정을 서버로 옮길 때 | -| `build_story.py` | 노래·인물 + 위키 사진 수집 | 인물 사진 경로를 만들 때 | -| `patch_stay.py` | payload 조립 + 캐치프레이즈·자작곡·객실사진 주입 | 목업 데이터를 바꿀 때 | -| `inject.js` / `inject.css` | 렌더러에 **없는 기능**을 목업에 얹은 것 | 그 기능을 렌더러로 옮길 때 | -| `audit-all.mjs` / `rails-test.mjs` | 브라우저 전수 점검 | 언제나. 끝났다고 말하기 전에 | - -제품 쪽 짝은 이것들이다. - -| 이 목업 | 제품에서 같은 일을 하는 곳 | -|---|---| -| `build_itinerary.py` | `solution/backend/services/itinerary.py` | -| `build_story.py` | `solution/backend/services/story_service.py` | -| PROMPTS.md | `solution/shared/src/lib/section-prompts.ts` (→ `npm run export:prompts`) | -| payload 필드 | `solution/shared/src/types/site-payload.ts` · `shared/src/lib/section-data.ts` | -| 섹션 목록·기본 on/off | `solution/backend/services/site_payload.py:155` 부근 | - ---- - -## 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 수집 | | -| `itinerary` | **추천 일정** | `data(kind=itinerary)` | `services/itinerary.py` | **T2** | -| `story` | 군산 이야기 | 아래 6탭 껍데기 | — | | -| `songs` | 가요 다방 | `data(kind=songs)` | LLM `section-prompts.ts:67` | **T3 T5** | -| `daily` | 오늘의 한 장 | `data(kind=daily)` | LLM `:91` | 섹션 자리가 없다 | -| `people` | 인물 열전 | `data(kind=people)` | LLM `:113` | **T3 T5** | -| `chronicle` | 시간의 골목 | `data(kind=chronicle)` | LLM `:135` | | -| `postcard` | 오늘의 엽서 | `data(kind=postcard)` | LLM `:160` | | -| `quiz` | 뒤집어 보는 질문 | `data(kind=quiz)` | LLM `:188` | | -| `faq` | 자주 묻는 질문 | `faqs[]` | LLM + 사장님 | | -| `rules` | 이용 규정 | `facts[]` | 사장님이 적음 | | -| `weather` | 날씨 | `local.weather` | open-meteo | | -| `planner` | 계절별 추천 하루 | `data(kind=planner)` | **없음** | (이 시연본은 꺼 둠) | - -여기에 **섹션이 아닌 것** 하나가 더 있다 — 헤더 음악 플레이어(**T4**). - ---- - -## 3. 할 일 여섯 - -### T1 — 히어로 캐치프레이즈 - -**증상** 히어로 문구가 사이트마다 한 줄로 고정이다. 계절도 날씨도 안 탄다. -지금 계약(`site-payload.ts:316` `Narrative`)에 문자열 `tagline` 하나뿐이고, -히어로는 `tagline ?? heroSubline ?? summary` 를 그대로 찍는다(`HeroPension.tsx:125`). - -**할 일** - -1. `Narrative` 에 필드를 더한다. - ```ts - catchphrases?: { version: 1; items: { - text: string; // 18자 안쪽 - kind: 'general' | 'season' | 'month' | 'weather'; - season?: '봄'|'여름'|'가을'|'겨울'; month?: number; weather?: '맑음'|'흐림'|'비'|'눈'; - }[] }; - ``` -2. 히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다. - 계절은 3개월 단위(가을 9–11, 겨울 12–2), 날씨는 `local.weather.condition`. - 목업 구현은 `inject.js` 의 `startCatchphrase()` — 7초마다 단어 단위 교체. -3. 프롬프트는 **[PROMPTS.md §2](PROMPTS.md)** 전문을 그대로 쓴다. 100개(일반 40·계절 24·월 24·날씨 12). -4. `STORY_KINDS` 에 등록한다. **빠뜨리면 서버 생성 잡이 이 종류를 아예 모른다** — - `daily` 가 그래서 한동안 빈칸이었다. - -**완료 기준** 새 업장을 발행했을 때 히어로 문구가 100개 돌고, 9월에 겨울 문구가 안 나온다. - ---- - -### T2 — 추천 일정 ★ 가장 큰 일 - -**증상** 서버 생성기(`services/itinerary.py`)는 하루를 **관광지 2 + 맛집 2** 로 짠다. -체류시간·이동시간·입퇴실·끼니 시각 개념이 없다. 그래서 나온 하루가 -"둘째 날 해산물축제 → 근대건축관" 으로 끝난다. - -**★ 프롬프트 하나로 일정을 받지 않는다.** 모델은 시각 계산을 못 한다 — -이동시간을 지어내고, 점심을 09:40 에 넣고, 21시를 넘겨 **렌더러가 조용히 버리는** 칸을 만든다 -(`shared/src/lib/section-data.ts:535`). 시연본 첫 판이 정확히 그랬다. - -**세 단계로 나눈다.** 전문은 [PROMPTS.md §3](PROMPTS.md). - -| 단계 | 누가 | 무엇을 내놓나 | 전문 | -|---|---|---|---| -| **3-1 장소 대장** | LLM | 장소 30곳 × `{stayMinutes:{min,base,max}, todo, indoor, needsCar, bestTime, searchQuery}` | PROMPTS.md §3-1 | -| **좌표** | 코드 | 카카오 로컬로 `searchQuery` → 위경도. **LLM 이 적은 좌표는 쓰지 않는다** | — | -| **3-2 조립** | **코드** | 이동시간·도착시각·끼니 창 맞추기·규칙 14종 감사 | PROMPTS.md §3-2 · `build_itinerary.py` | -| **3-3 테마** | LLM | 테마 20개 × `{name, duration, audience, why, days[{kind, places[]}]}` | PROMPTS.md §3-3 | - -**코드가 하는 일(3-2)에서 반드시 옮겨야 하는 것** — 근거는 `build_itinerary.py` 줄번호 - -| | 무엇 | 어디 | -|---|---|---| -| 이동시간 | 좌표 계산. 도보 4km/h(≤1.5km), 그 위는 차 25km/h + 주차 5분 | `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) 안에서** 늘리고 줄인다. 이동시간을 고치지 않는다 | `plan_day()` | -| 테마별 완급 | 시작 시각·체류 배율이 테마마다 다르다 → 끝나는 시각이 13:35~21:00 로 퍼진다 | `:470` | -| **감사 14종** | 하나라도 어기면 **payload 를 쓰지 않고 멈춘다** | `:526` | - -★ **감사는 생성기 안에 둔다.** 밖에 두면 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생긴다 — -실제로 그렇게 33건이 통과했다([REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) 2장). - -**완료 기준** 새 업장에서 테마 20개가 나오고 `audit()` 위반 0건. -하루가 숙소로 시작해 숙소로 끝나고, 끼니가 제 시각에 있고, 21시를 안 넘는다. - ---- - -### T3 — 인물·노래 사진 - -**증상** 프롬프트가 사진 URL 을 금지하고 `imageQuery` 만 받는다(`section-prompts.ts:128`). -초상권·저작권 때문이고 그 판단은 맞다. 그런데 **다음 단계가 없어서** 인물 열전이 활자만으로 선다. - -**할 일** - -1. `services/external/wikimedia.py` 에 **문서 → `pageimages` → `imageinfo.extmetadata`** 경로를 붙인다. -2. ★ **이름으로 커먼즈를 검색하지 않는다.** 동명이인이 온다 — - 실측: 이수현→걸그룹, 은성수→축구선수, 박성현→골퍼, 이길여→건물. - **위키백과 문서에 붙은 대표사진만** 믿는다. 구현은 `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`)를 넣고 발행 때 같이 올린다. -헤더에 붙는 작은 플레이어를 만든다 — 아이콘 · 현재 곡명 · 재생/멈춤 · 목록. -**자동재생은 넣지 않는다**(2026-09-11 지시). 순서는 **랜덤 시작 후 순차**. -목업 구현은 `inject.js` 의 `startPlayer()`, 모양은 `inject.css` 의 `#w4d-mini` · `#w4d-panel`. - ---- - -### T5 — 상한(`maxItems`) - -| 종류 | 지금 | 목업 | 왜 | -|---|---|---|---| -| songs | 8 | 25 | 8곡이면 '다방'이 아니라 목록이다 | -| people | 10 | 57 | 위키백과 '군산시 출신' 분류만으로 57명이 확인된다 | - -★ **한 번에 다 받지 않는다.** 25곡을 한 번에 시키면 뒤쪽이 급격히 부실해진다. -8개씩 나눠 받고 제목으로 중복을 지운다 — 나누는 방법은 [PROMPTS.md §4](PROMPTS.md). - ---- - -### 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** 다. - ---- - -## 4. 프롬프트를 새로 쓸 때 지킬 것 - -`SECTION_PROMPT_RULES`(`section-prompts.ts:55`)가 공통 7줄이고 종류별 규칙을 덧붙인다. - -1. **`STORY_KINDS` 에 등록한다.** 빠지면 서버 생성 잡이 그 종류를 모른다. -2. **`maxItems` 와 프롬프트 안의 숫자를 같게 둔다.** 어긋나면 받아 놓고 잘라 버린다. -3. **빈 값을 지어내지 않는다.** 확실한 게 12개면 12개만 낸다. -4. **출처는 실제로 열리는 페이지**여야 한다. 검색 결과 주소는 안 된다. -5. **가사·시·소설 원문을 한 줄도 옮기지 않는다.** -6. 고친 뒤 **`npm run export:prompts`** 를 돌린다 — 백엔드는 그 산출물을 읽는다. - `backend/services/prompts/section_prompts.json` 을 손으로 고치지 않는다. - ---- - -## 5. 끝났다고 말하기 전에 - -| | 무엇 | 어떻게 | -|---|---|---| -| 생성 단계 | 규칙 위반이면 **결과물을 쓰지 않고 멈춘다** | 감사를 생성기 **안**에 (`build_itinerary.py:526`) | -| 렌더 단계 | 브라우저로 열어 숫자를 센다 | `node audit-all.mjs` — 34/34 | -| 카로셀 | 레일 13개 × 데스크톱·모바일 | `node rails-test.mjs` | -| 다른 기계 | 레포만 받아 띄워 본다 | §0 을 그대로 실행 | - -★ 여기 있는 것 중 **"코드에서 확인했다"로 끝낸 것은 하나도 없다.** -전부 화면에서 숫자를 세서 붙였다. 그렇게 안 해서 틀린 기록이 -[README.md §7](README.md) 과 [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) 에 있다. diff --git a/solution/site/scripts/mockup/align.mjs b/solution/site/scripts/mockup/align.mjs deleted file mode 100644 index fc13fa1..0000000 --- a/solution/site/scripts/mockup/align.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const page = await b.newPage({viewport: {width: 1280, height: 900}}); -await page.goto('https://web4ai.o2osolution.ai/s/stay', {waitUntil: 'domcontentloaded'}); -await page.waitForTimeout(5000); -const r = await page.evaluate(() => { - const li = document.querySelector('#itinerary ol li'); - const title = li.querySelector('span.block.text-sm, span[class*="text-sm"][class*="font-bold"]'); - const badge = title.querySelector('span'); - const rb = badge.getBoundingClientRect(); - const rt = title.getBoundingClientRect(); - // 제목 첫 글자의 실제 상자 - const node = [...title.childNodes].find((n) => n.nodeType === 3 && n.textContent.trim()); - const rng = document.createRange(); rng.selectNodeContents(node); - const rr = rng.getBoundingClientRect(); - const cs = getComputedStyle(title); - return { - badge: {top: +rb.top.toFixed(2), h: +rb.height.toFixed(2), mid: +(rb.top + rb.height / 2).toFixed(2)}, - textRect: {top: +rr.top.toFixed(2), h: +rr.height.toFixed(2), mid: +(rr.top + rr.height / 2).toFixed(2)}, - line: {fontSize: cs.fontSize, lineHeight: cs.lineHeight, display: getComputedStyle(badge).display, va: getComputedStyle(badge).verticalAlign, tf: getComputedStyle(badge).transform}, - text: title.textContent.trim().slice(0, 20), - delta: +((rb.top + rb.height / 2) - (rr.top + rr.height / 2)).toFixed(2), - }; -}); -console.log(JSON.stringify(r, null, 1)); -await b.close(); diff --git a/solution/site/scripts/mockup/arrow.mjs b/solution/site/scripts/mockup/arrow.mjs deleted file mode 100644 index 0aba151..0000000 --- a/solution/site/scripts/mockup/arrow.mjs +++ /dev/null @@ -1,40 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const p = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true}); -await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await p.waitForTimeout(5000); -await p.evaluate(() => { - const rail = [...document.querySelectorAll('#gallery *')].find((el) => { - const s = getComputedStyle(el); - return /auto|scroll/.test(s.overflowX) && el.scrollWidth - el.clientWidth > 40; - }); - rail.dataset.w4dArrow = '1'; - rail.scrollIntoView({block: 'center'}); -}); -await p.waitForTimeout(700); -const read = () => p.evaluate(() => Math.round(document.querySelector('[data-w4d-arrow]').scrollLeft)); - -// ① 사람이 민다 (트러스티드 드래그) -const bb = await (await p.$('[data-w4d-arrow]')).boundingBox(); -await p.mouse.move(bb.x + bb.width * 0.7, bb.y + bb.height / 2); -await p.mouse.down(); -await p.mouse.move(bb.x + bb.width * 0.2, bb.y + bb.height / 2, {steps: 10}); -await p.mouse.up(); -await p.waitForTimeout(800); -const afterSwipe = await read(); - -// ② 자동으로 더 넘어가는지 6초 -await p.waitForTimeout(6000); -const afterIdle = await read(); - -// ③ 화살표를 누르면 여전히 넘어가는지 -const next = await p.$('#gallery button[aria-label="다음 사진"]'); -let arrowWorks = null; -if (next) { - const before = await read(); - await next.click(); - await p.waitForTimeout(900); - arrowWorks = (await read()) - before; -} -console.log(JSON.stringify({afterSwipe, afterIdle, 자동이동: afterIdle - afterSwipe, 화살표이동: arrowWorks})); -await b.close(); diff --git a/solution/site/scripts/mockup/carousel.mjs b/solution/site/scripts/mockup/carousel.mjs deleted file mode 100644 index 41051ea..0000000 --- a/solution/site/scripts/mockup/carousel.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const p = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true}); -await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await p.waitForTimeout(5000); -await p.evaluate(() => document.querySelector('#itinerary').scrollIntoView({block: 'center'})); -await p.waitForTimeout(1200); -const pos = () => p.evaluate(() => { - const t = document.querySelector('#itinerary .slider-track'); - const m = /matrix.*?,\s*(-?[\d.]+),\s*-?[\d.]+\)$/.exec(getComputedStyle(t).transform); - return m ? Math.round(parseFloat(m[1])) : 0; -}); - -// ① 손 안 대고 둔다 — 자동 넘김이 살아 있는지 -const idle = []; -for (let i = 0; i < 6; i++) { await p.waitForTimeout(1000); idle.push(await pos()); } -console.log('① 손 안 댐 ', JSON.stringify(idle)); - -// ② 페이지를 세로로 스크롤(일정 읽으려고) → 그 뒤 자동으로 넘어가는지 -await p.mouse.wheel(0, 200); -await p.waitForTimeout(300); -const afterScroll = await pos(); -const scrolled = []; -for (let i = 0; i < 6; i++) { await p.waitForTimeout(1000); scrolled.push(await pos()); } -console.log('② 세로 스크롤 후', afterScroll, '→', JSON.stringify(scrolled), '· 이동', scrolled[5] - afterScroll); - -// ③ 쿨타임(7초)이 지나면 다시 도는지 -await p.waitForTimeout(4000); -const resumed = await pos(); -console.log('③ 10초 더 기다림', resumed, '· 쿨타임 뒤 이동', resumed - scrolled[5]); - -// ④ 손으로 드래그가 여전히 되는지 -const box = await p.evaluate(() => { - const v = document.querySelector('#itinerary .slider-viewport').getBoundingClientRect(); - return {x: Math.round(v.x + v.width / 2), y: Math.round(v.y + v.height / 2)}; -}); -const before = await pos(); -await p.mouse.move(box.x + 100, box.y); -await p.mouse.down(); -await p.mouse.move(box.x - 110, box.y, {steps: 14}); -await p.mouse.up(); -await p.waitForTimeout(900); -const dragged = await pos(); -console.log('④ 손 드래그 ', before, '→', dragged, '· 이동', dragged - before); - -// ⑤ 드래그한 레일은 이후 자동으로 안 넘어가는지 (10초) -await p.waitForTimeout(10000); -console.log('⑤ 드래그 후 10초', await pos(), '· 추가 이동', (await pos()) - dragged); -await b.close(); diff --git a/solution/site/scripts/mockup/carousel2.mjs b/solution/site/scripts/mockup/carousel2.mjs deleted file mode 100644 index 627f77a..0000000 --- a/solution/site/scripts/mockup/carousel2.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const p = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true}); -await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await p.waitForTimeout(4500); -await p.evaluate(() => document.querySelector('#itinerary').scrollIntoView({block: 'center'})); -const pos = () => p.evaluate(() => { - const t = document.querySelector('#itinerary .slider-track'); - const m = /matrix.*?,\s*(-?[\d.]+),\s*-?[\d.]+\)$/.exec(getComputedStyle(t).transform); - return m ? Math.round(parseFloat(m[1])) : 0; -}); -// 쿨타임(7초)이 지나도록 아무것도 안 만지고 기다린다 -await p.waitForTimeout(9000); -const base = await pos(); -const log = []; -for (let i = 0; i < 8; i++) { await p.waitForTimeout(1500); log.push(await pos()); } -console.log('손 안 대고 9초 뒤부터 12초 관찰:', base, '→', JSON.stringify(log), '· 자동 이동', log[7] - base); -await b.close(); diff --git a/solution/site/scripts/mockup/check2.mjs b/solution/site/scripts/mockup/check2.mjs deleted file mode 100644 index 14d1f93..0000000 --- a/solution/site/scripts/mockup/check2.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const page = await b.newPage({viewport: {width: 1280, height: 900}}); -const bad = []; -page.on('response', (r) => { if (r.status() >= 400 && r.url().includes('/s/stay/')) bad.push(r.status() + ' ' + decodeURIComponent(r.url().split('/s/stay/')[1])); }); -await page.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await page.waitForTimeout(6000); -const r = await page.evaluate(() => ({ - miniBtns: [...document.querySelectorAll('#w4d-mini button')].map((b) => b.id), - peoplePhotos: document.querySelectorAll('#people img').length, - peopleCards: document.querySelectorAll('#people article').length, - credits: [...document.querySelectorAll('#people a')].filter((a) => /CC |KOGL|Public/.test(a.textContent)).length, - wrapUp: [...document.querySelectorAll('#itinerary li')].filter((li) => li.textContent.includes('마무리 ·')).length, - bagPick: document.body.textContent.includes('짐 찾기 ·'), - bbq: document.body.textContent.includes('바비큐 이용'), - shortStay: [...document.querySelectorAll('#itinerary li')].filter((li) => /스테이 머뭄/.test(li.textContent) && /1[0-5]분/.test(li.textContent)).length, -})); -console.log(JSON.stringify(r)); -console.log('실패요청', bad.slice(0, 6)); -await page.evaluate(() => document.querySelector('#people')?.scrollIntoView()); -await page.waitForTimeout(1200); -await page.screenshot({path: '/tmp/s-people.png'}); -await b.close(); diff --git a/solution/site/scripts/mockup/cool.mjs b/solution/site/scripts/mockup/cool.mjs deleted file mode 100644 index 03b4637..0000000 --- a/solution/site/scripts/mockup/cool.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import {chromium} from 'playwright'; -const url = process.argv[2] || 'http://localhost/s/stay'; -const b = await chromium.launch({channel: 'chrome'}); -const page = await b.newPage({viewport: {width: 1280, height: 900}}); -await page.goto(url, {waitUntil: 'domcontentloaded'}); -await page.waitForTimeout(5000); - -// 일정 레일을 화면 안에 둔다 -await page.evaluate(() => document.querySelector('#itinerary').scrollIntoView({block: 'center'})); -await page.waitForTimeout(1500); - -// 스크롤 이벤트가 언제 오는지 센다 -await page.evaluate(() => { - window.__log = []; - window.addEventListener('scroll', (e) => { - window.__log.push({t: Date.now(), trusted: e.isTrusted, tag: e.target === document ? 'document' : (e.target.className || '').toString().slice(0, 30)}); - }, true); - const rail = document.querySelector('#itinerary .slider-viewport'); - window.__rail = rail; - window.__pos = () => { - const c = rail.firstElementChild; - return Math.round(new DOMMatrix(getComputedStyle(c).transform).m41); - }; - window.__t0 = Date.now(); -}); - -const pos0 = await page.evaluate(() => window.__pos()); -// 손으로 세로 스크롤 한 번 -await page.mouse.move(640, 500); -await page.mouse.wheel(0, 200); -const marks = []; -for (let i = 1; i <= 20; i++) { - await page.waitForTimeout(1000); - marks.push(await page.evaluate((p0) => ({s: Math.round((Date.now() - window.__t0) / 1000), d: window.__pos() - p0}), pos0)); -} -const log = await page.evaluate(() => window.__log.map((l) => ({s: +((l.t - window.__t0) / 1000).toFixed(1), tr: l.trusted, tag: l.tag}))); -console.log('이동:', marks.map((m) => `${m.s}s:${m.d}`).join(' ')); -console.log('scroll 이벤트 수:', log.length, '· 마지막 5건:', JSON.stringify(log.slice(-5))); -await b.close(); diff --git a/solution/site/scripts/mockup/cool2.mjs b/solution/site/scripts/mockup/cool2.mjs deleted file mode 100644 index 953eb90..0000000 --- a/solution/site/scripts/mockup/cool2.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import {chromium} from 'playwright'; -const url = process.argv[2] || 'http://localhost/s/stay'; -const mode = process.argv[3] || 'idle'; -const b = await chromium.launch({channel: 'chrome'}); -const page = await b.newPage({viewport: {width: 1280, height: 900}}); -const w4 = []; -page.on('console', (m) => { if (m.text().includes('[w4d]')) w4.push(m.text().slice(0, 80)); }); -await page.goto(url, {waitUntil: 'domcontentloaded'}); -await page.waitForTimeout(5000); -await page.evaluate(() => document.querySelector('#itinerary').scrollIntoView({block: 'center'})); -await page.waitForTimeout(9000); // scrollIntoView 쿨타임 7초를 넘긴다 -const setup = await page.evaluate(() => { - const rail = document.querySelector('#itinerary .slider-viewport'); - window.__rail = rail; - window.__pos = () => { - const c = rail.firstElementChild; - let m = 0; try { m = new DOMMatrix(getComputedStyle(c).transform).m41; } catch {} - return Math.round(rail.scrollLeft + m); - }; - const r = rail.getBoundingClientRect(); - return {pos: window.__pos(), rect: {top: Math.round(r.top), h: Math.round(r.height)}, vh: innerHeight}; -}); -if (mode === 'wheel') { await page.mouse.move(640, 450); await page.mouse.wheel(0, 200); } -const marks = []; -for (let i = 1; i <= 16; i++) { - await page.waitForTimeout(1000); - marks.push(await page.evaluate((p0) => window.__pos() - p0, setup.pos)); -} -console.log(mode, JSON.stringify(setup), marks.join(' ')); -console.log(w4.join(' | ')); -await b.close(); diff --git a/solution/site/scripts/mockup/cool3.mjs b/solution/site/scripts/mockup/cool3.mjs deleted file mode 100644 index c225cb9..0000000 --- a/solution/site/scripts/mockup/cool3.mjs +++ /dev/null @@ -1,26 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const page = await b.newPage({viewport: {width: 1280, height: 900}}); -await page.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await page.waitForTimeout(5000); -await page.evaluate(() => document.querySelector('#itinerary').scrollIntoView({block: 'center'})); -await page.waitForTimeout(9000); -await page.evaluate(() => { - const rail = document.querySelector('#itinerary .slider-viewport'); - window.__rail = rail; - window.__pos = () => { const c = rail.firstElementChild; let m = 0; - try { m = new DOMMatrix(getComputedStyle(c).transform).m41; } catch {} return Math.round(rail.scrollLeft + m); }; -}); -await page.mouse.move(640, 450); -await page.mouse.wheel(0, 200); -await page.waitForTimeout(10000); // 쿨타임 7초 + 여유 -const p0 = await page.evaluate(() => window.__pos()); -// 해제 pointerup 을 하나씩 더 쏘면서 몇 번째에 다시 도는지 본다 -for (let i = 1; i <= 6; i++) { - await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, button: 2}))); - await page.waitForTimeout(5200); - const d = await page.evaluate((p) => window.__pos() - p, p0); - console.log(`추가 pointerup ${i}회 후 → ${d}px`); - if (d !== 0) break; -} -await b.close(); diff --git a/solution/site/scripts/mockup/cool4.mjs b/solution/site/scripts/mockup/cool4.mjs deleted file mode 100644 index 263e47e..0000000 --- a/solution/site/scripts/mockup/cool4.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import {chromium, devices} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); - -// ① 데스크톱 — 휠로 내린 뒤 커서를 레일 밖으로 뺀다 -{ - const page = await b.newPage({viewport: {width: 1280, height: 900}}); - await page.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); - await page.waitForTimeout(5000); - await page.evaluate(() => document.querySelector('#itinerary').scrollIntoView({block: 'center'})); - await page.waitForTimeout(9000); - await page.evaluate(() => { const r = document.querySelector('#itinerary .slider-viewport'); - window.__pos = () => { const c = r.firstElementChild; let m = 0; - try { m = new DOMMatrix(getComputedStyle(c).transform).m41; } catch {} return Math.round(r.scrollLeft + m); }; }); - await page.mouse.move(640, 450); - await page.mouse.wheel(0, 200); - const p0 = await page.evaluate(() => window.__pos()); - await page.waitForTimeout(10000); - const stay = await page.evaluate((p) => window.__pos() - p, p0); - await page.mouse.move(5, 5); // 커서를 레일 밖으로 - await page.waitForTimeout(10000); - const after = await page.evaluate((p) => window.__pos() - p, p0); - console.log(`데스크톱: 커서가 레일 위 10초 → ${stay}px · 커서 뺀 뒤 10초 → ${after}px`); - await page.close(); -} - -// ② 모바일 — 손가락으로 세로 스와이프 -{ - const ctx = await b.newContext({...devices['iPhone 13'], hasTouch: true, isMobile: true}); - const page = await ctx.newPage(); - await page.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); - await page.waitForTimeout(6000); - await page.evaluate(() => document.querySelector('#itinerary').scrollIntoView({block: 'center'})); - await page.waitForTimeout(9000); - await page.evaluate(() => { const r = document.querySelector('#itinerary .slider-viewport'); - window.__pos = () => { const c = r.firstElementChild; let m = 0; - try { m = new DOMMatrix(getComputedStyle(c).transform).m41; } catch {} return Math.round(r.scrollLeft + m); }; }); - const p0 = await page.evaluate(() => window.__pos()); - // 세로 스와이프 (레일 위에서) - await page.touchscreen.tap(200, 400); - await page.waitForTimeout(600); - const t1 = await page.evaluate((p) => window.__pos() - p, p0); - await page.waitForTimeout(6000); - const t6 = await page.evaluate((p) => window.__pos() - p, p0); - await page.waitForTimeout(8000); - const t14 = await page.evaluate((p) => window.__pos() - p, p0); - console.log(`모바일: 탭 직후 ${t1}px · 6초 ${t6}px · 14초 ${t14}px`); - await ctx.close(); -} -await b.close(); diff --git a/solution/site/scripts/mockup/embla.mjs b/solution/site/scripts/mockup/embla.mjs deleted file mode 100644 index 1df6570..0000000 --- a/solution/site/scripts/mockup/embla.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const p = await b.newPage({viewport: {width: 1280, height: 900}}); -await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await p.waitForTimeout(4500); -console.log(await p.evaluate(() => JSON.stringify({ - embla: document.querySelectorAll('[class*="embla"]').length, - transformRails: [...document.querySelectorAll('#root div')].filter((d) => /translate3d|translateX/.test(getComputedStyle(d).transform === 'none' ? '' : d.style.transform || '')).length, - scrollRails: [...document.querySelectorAll('#root *')].filter((el) => /auto|scroll/.test(getComputedStyle(el).overflowX) && el.scrollWidth - el.clientWidth > 40).map((el) => el.closest('section')?.id || '-'), -}))); -await b.close(); diff --git a/solution/site/scripts/mockup/hdr.mjs b/solution/site/scripts/mockup/hdr.mjs deleted file mode 100644 index c24534d..0000000 --- a/solution/site/scripts/mockup/hdr.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const p = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true}); -await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await p.waitForTimeout(5000); -console.log(await p.evaluate(() => { - const h = document.querySelector('#root header'); - const group = h.querySelector('.shell > div:last-child'); - return JSON.stringify([...group.children].map((el) => ({ - tag: el.tagName, id: el.id || '-', cls: (el.className || '').toString().slice(0, 55), - label: el.getAttribute('aria-label') || el.textContent.trim().slice(0, 12), - w: Math.round(el.getBoundingClientRect().width), - })), null, 1); -})); -await b.close(); diff --git a/solution/site/scripts/mockup/hshot.mjs b/solution/site/scripts/mockup/hshot.mjs deleted file mode 100644 index c4b642f..0000000 --- a/solution/site/scripts/mockup/hshot.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -for (const [tag, vp] of [['mobile', {width: 390, height: 780}], ['desktop', {width: 1280, height: 900}]]) { - const p = await b.newPage({viewport: vp, isMobile: tag === 'mobile', hasTouch: tag === 'mobile', deviceScaleFactor: 2}); - await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); - await p.waitForTimeout(5000); - await p.screenshot({path: `/tmp/h-${tag}.png`, clip: {x: 0, y: 0, width: vp.width, height: 70}}); - const info = await p.evaluate(() => { - const tel = document.querySelector('#root header a[href^="tel:"]'); - const nav = [...document.querySelectorAll('#root nav')].find((n) => getComputedStyle(n).position === 'fixed'); - return {telShown: tel ? getComputedStyle(tel).display : '-', - tabbarHasTel: nav ? /전화/.test(nav.textContent) : false, - headerOverflow: document.querySelector('#root header .shell').scrollWidth > window.innerWidth}; - }); - console.log(tag, JSON.stringify(info)); - await p.close(); -} -await b.close(); diff --git a/solution/site/scripts/mockup/island.mjs b/solution/site/scripts/mockup/island.mjs deleted file mode 100644 index 19700e1..0000000 --- a/solution/site/scripts/mockup/island.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const page = await b.newPage({viewport: {width: 1280, height: 900}}); -await page.goto(process.argv[2] || 'https://web4ai.o2osolution.ai/s/stay', {waitUntil: 'domcontentloaded'}); -await page.waitForTimeout(5000); -const r = await page.evaluate(() => { - const out = []; - document.querySelectorAll('#itinerary article').forEach((a) => { - const title = a.querySelector('h3')?.textContent || ''; - if (!title.includes('섬')) return; - const badge = a.querySelector('span')?.textContent || ''; - const rows = [...a.querySelectorAll('ol li')].map((li) => li.innerText.replace(/\n+/g, ' | ')); - out.push({title, badge, rows}); - }); - return out; -}); -for (const d of r) { console.log('##', d.title, d.badge); d.rows.forEach((x) => console.log(' ', x)); } -await b.close(); diff --git a/solution/site/scripts/mockup/play.mjs b/solution/site/scripts/mockup/play.mjs deleted file mode 100644 index 2970d15..0000000 --- a/solution/site/scripts/mockup/play.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -for (const tag of ['desktop', 'mobile']) { - const p = await b.newPage(tag === 'mobile' - ? {viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true} - : {viewport: {width: 1280, height: 900}}); - await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); - await p.waitForTimeout(5000); - const before = await p.evaluate(() => ({ - playing: document.getElementById('w4d-play').dataset.playing, - title: document.getElementById('w4d-now')?.textContent, - titleShown: document.getElementById('w4d-now') ? getComputedStyle(document.getElementById('w4d-now')).display : '-', - })); - await p.click('#w4d-play'); // ← 한 번만 누른다 - await p.waitForTimeout(2500); - const after = await p.evaluate(() => ({ - playing: document.getElementById('w4d-play').dataset.playing, - title: document.getElementById('w4d-now')?.textContent, - icon: document.getElementById('w4d-play').querySelector('path')?.getAttribute('d')?.slice(0, 12), - })); - console.log(tag, '누르기 전', JSON.stringify(before), '→ 한 번 누른 뒤', JSON.stringify(after)); - if (tag === 'desktop') { - // 곡이 끝나면 다음 곡으로 (순차) — 끝 2초 앞으로 보낸다 - const seq = await p.evaluate(async () => { - const first = document.getElementById('w4d-now').textContent; - const a = document.querySelector('audio') || null; - // Audio 객체는 DOM 밖이라 이벤트로 끝을 흉내낸다 - document.getElementById('w4d-next') ? null : null; - const rows = [...document.querySelectorAll('.w4d-item')]; - return {first, rows: rows.length}; - }); - console.log(' 현재 곡', JSON.stringify(seq)); - } - await p.close(); -} -await b.close(); diff --git a/solution/site/scripts/mockup/player.mjs b/solution/site/scripts/mockup/player.mjs deleted file mode 100644 index ad691ef..0000000 --- a/solution/site/scripts/mockup/player.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -for (const vp of [{width: 1280, height: 900, tag: 'desktop'}, {width: 390, height: 780, tag: 'mobile'}]) { - const p = await b.newPage({viewport: {width: vp.width, height: vp.height}, isMobile: vp.tag === 'mobile', hasTouch: vp.tag === 'mobile'}); - await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); - await p.waitForTimeout(5000); - await p.click('#w4d-toggle'); - await p.waitForTimeout(400); - await p.click('.w4d-item'); - await p.waitForTimeout(1800); - const r = await p.evaluate(() => { - const panel = document.getElementById('w4d-panel'); - const box = panel.getBoundingClientRect(); - const rows = [...panel.querySelectorAll('.w4d-item')].map((el) => { - const b = el.getBoundingClientRect(); - return {h: Math.round(b.height), title: el.querySelector('.w4d-t').textContent, - sub: el.querySelector('.w4d-sub').textContent, time: el.querySelector('.w4d-time').textContent, - playing: el.dataset.playing}; - }); - return {panel: {x: Math.round(box.x), w: Math.round(box.width), right: Math.round(window.innerWidth - box.right)}, - overflow: box.right > window.innerWidth || box.left < 0, rows}; - }); - console.log(vp.tag, JSON.stringify(r)); - await p.screenshot({path: `/tmp/p-${vp.tag}.png`, clip: {x: 0, y: 0, width: vp.width, height: Math.min(620, vp.height)}}); - await p.close(); -} -await b.close(); diff --git a/solution/site/scripts/mockup/probe2.mjs b/solution/site/scripts/mockup/probe2.mjs deleted file mode 100644 index 44e31db..0000000 --- a/solution/site/scripts/mockup/probe2.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const p = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true}); -await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await p.waitForTimeout(5000); -console.log(await p.evaluate(() => { - const out = []; - document.querySelectorAll('#root *').forEach((el) => { - const s = getComputedStyle(el); - if (!/auto|scroll/.test(s.overflowX)) return; - if (el.scrollWidth - el.clientWidth < 40) return; - let sec = el.closest('section'); - let name = sec?.id || ''; - if (!name) { const h = sec?.querySelector('h2'); name = h ? h.textContent.trim().slice(0, 10) : '-'; } - out.push({sec: name, scrollW: el.scrollWidth, clientW: el.clientWidth}); - }); - const itin = document.querySelector('#itinerary'); - return JSON.stringify({rails: out, itinExists: !!itin, - itinH2: itin?.querySelector('h2')?.textContent.trim(), - sections: [...document.querySelectorAll('#root section')].map((s) => s.id || (s.querySelector('h2')?.textContent.trim().slice(0, 8) || '-'))}, null, 1); -})); -await b.close(); diff --git a/solution/site/scripts/mockup/probe3.mjs b/solution/site/scripts/mockup/probe3.mjs deleted file mode 100644 index e967d94..0000000 --- a/solution/site/scripts/mockup/probe3.mjs +++ /dev/null @@ -1,18 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const p = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true}); -await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await p.waitForTimeout(5000); -await p.evaluate(() => document.querySelector('#itinerary').scrollIntoView({block: 'start'})); -await p.waitForTimeout(1500); -console.log(await p.evaluate(() => { - const itin = document.querySelector('#itinerary'); - const out = []; - itin.querySelectorAll('*').forEach((el) => { - const s = getComputedStyle(el); - const dx = el.scrollWidth - el.clientWidth; - if (dx > 20) out.push({tag: el.tagName, overflowX: s.overflowX, dx, cls: (el.className || '').toString().slice(0, 60)}); - }); - return JSON.stringify({found: out.slice(0, 6), scrollY: Math.round(window.scrollY)}, null, 1); -})); -await b.close(); diff --git a/solution/site/scripts/mockup/rail.mjs b/solution/site/scripts/mockup/rail.mjs deleted file mode 100644 index 4055f02..0000000 --- a/solution/site/scripts/mockup/rail.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const p = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true}); -await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await p.waitForTimeout(5000); - -const box = await p.evaluate(() => { - const rail = [...document.querySelectorAll('#gallery *')].find((el) => { - const s = getComputedStyle(el); - return /auto|scroll/.test(s.overflowX) && el.scrollWidth - el.clientWidth > 40; - }); - if (!rail) return null; - rail.scrollIntoView({block: 'center'}); - rail.dataset.w4dTest = '1'; - const r = rail.getBoundingClientRect(); - return {x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2)}; -}); -await p.waitForTimeout(700); -const read = () => p.evaluate(() => Math.round(document.querySelector('[data-w4d-test]').scrollLeft)); - -// ① 손 안 대고 두는 동안 자동으로 넘어가는지 (자동 넘김이 살아 있는지 확인) -const idle = []; -for (let i = 0; i < 6; i++) { await p.waitForTimeout(1000); idle.push(await read()); } -console.log('손 안 댐 →', JSON.stringify(idle)); - -// ② 진짜 터치로 스와이프한 뒤 -await p.touchscreen.tap(box.x, box.y); -await p.mouse.move(box.x + 120, box.y); -await p.mouse.down(); -await p.mouse.move(box.x - 100, box.y, {steps: 12}); -await p.mouse.up(); -await p.waitForTimeout(600); -const afterSwipe = await read(); -const log = []; -for (let i = 0; i < 8; i++) { await p.waitForTimeout(1000); log.push(await read()); } -console.log('스와이프 후', afterSwipe, '→', JSON.stringify(log), '· 자동 이동량', log[log.length - 1] - afterSwipe); -await b.close(); diff --git a/solution/site/scripts/mockup/rails-all.mjs b/solution/site/scripts/mockup/rails-all.mjs deleted file mode 100644 index 9bf9b62..0000000 --- a/solution/site/scripts/mockup/rails-all.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import {chromium} from 'playwright'; -const url = process.argv[2] || 'http://localhost/s/stay'; -const b = await chromium.launch({channel: 'chrome'}); -const page = await b.newPage({viewport: {width: 1280, height: 900}}); -await page.goto(url, {waitUntil: 'domcontentloaded'}); -await page.waitForTimeout(6000); -const list = await page.evaluate(() => { - const out = []; - const add = (el, kind) => { - const sec = el.closest('section[id], div[id]'); - out.push({kind, sec: sec ? sec.id : '?', label: el.getAttribute('aria-label') || '', - w: Math.round(el.getBoundingClientRect().width), - over: Math.round(el.scrollWidth - el.clientWidth), hidden: !el.getClientRects().length}); - }; - document.querySelectorAll('#root .slider-viewport').forEach((el) => add(el, 'embla')); - document.querySelectorAll('#root *').forEach((el) => { - if (el.classList.contains('slider-viewport')) return; - const f = getComputedStyle(el).overflowX; - if ((f === 'auto' || f === 'scroll') && el.scrollWidth - el.clientWidth > 40) add(el, 'scroll상자'); - }); - return out; -}); -console.table(list); -await b.close(); diff --git a/solution/site/scripts/mockup/rails-focus.mjs b/solution/site/scripts/mockup/rails-focus.mjs deleted file mode 100644 index 74fac34..0000000 --- a/solution/site/scripts/mockup/rails-focus.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const page = await b.newPage({viewport: {width: 1280, height: 900}}); -await page.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await page.waitForTimeout(6000); -await page.evaluate(() => { - window.__rails = []; - document.querySelectorAll('#root .slider-viewport').forEach((el) => window.__rails.push(el)); - document.querySelectorAll('#root *').forEach((el) => { - if (el.classList.contains('slider-viewport')) return; - const f = getComputedStyle(el).overflowX; - if ((f === 'auto' || f === 'scroll') && el.scrollWidth - el.clientWidth > 40) window.__rails.push(el); - }); - window.__st = (i) => { const r = window.__rails[i]; const c = r.firstElementChild; let m = 0; - try { m = new DOMMatrix(getComputedStyle(c).transform).m41; } catch {} - const bb = r.getBoundingClientRect(); - return {pos: Math.round(r.scrollLeft + m), max: Math.round(r.scrollWidth - r.clientWidth), - label: r.getAttribute('aria-label') || '?', cx: Math.round(bb.left + bb.width/2), - cy: Math.round(bb.top + bb.height/2), top: Math.round(bb.top), h: Math.round(bb.height), vh: innerHeight}; - }; -}); -for (const i of [4, 12]) { - await page.mouse.move(5, 5); - await page.evaluate((k) => window.__rails[k].scrollIntoView({block: 'center'}), i); - await page.waitForTimeout(9000); - let s = await page.evaluate((k) => window.__st(k), i); - console.log(`\n[${s.label}] max=${s.max} pos=${s.pos} 화면 top=${s.top} h=${s.h} vh=${s.vh}`); - await page.waitForTimeout(6000); // 진행 중인 애니메이션까지 가라앉힌다 - s = await page.evaluate((k) => window.__st(k), i); - const y = Math.min(Math.max(s.cy, 100), 800); - const p1 = s.pos; - await page.mouse.move(s.cx, y); - await page.mouse.wheel(0, 150); - await page.mouse.move(5, 5); - const marks = []; - for (let t = 1; t <= 14; t++) { - await page.waitForTimeout(1000); - const q = await page.evaluate((k) => window.__st(k), i); - marks.push(`${t}s:${q.pos - p1}`); - } - const f = await page.evaluate((k) => window.__st(k), i); - console.log('휠 뒤 14초:', marks.join(' '), `| 끝까지=${f.max} 현재=${f.pos} 화면top=${f.top}`); -} -await b.close(); diff --git a/solution/site/scripts/mockup/seq.mjs b/solution/site/scripts/mockup/seq.mjs deleted file mode 100644 index ebae041..0000000 --- a/solution/site/scripts/mockup/seq.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const p = await b.newPage({viewport: {width: 1280, height: 900}}); -await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await p.waitForTimeout(5000); -await p.click('#w4d-toggle'); await p.waitForTimeout(300); -const rows = await p.$$('.w4d-item'); -await rows[0].click(); // 1번 곡 -await p.waitForTimeout(1500); -const seq = []; -seq.push(await p.evaluate(() => document.getElementById('w4d-now').textContent)); -// 목록에서 다음 줄을 눌러 순서 확인 + 재생 중 표시가 따라오는지 -for (let i = 1; i < 5; i++) { - await rows[i].click(); - await p.waitForTimeout(900); - seq.push(await p.evaluate(() => document.getElementById('w4d-now').textContent)); -} -const cur = await p.evaluate(() => { - const row = document.querySelector('.w4d-item[data-playing="1"]'); - return {row: row?.querySelector('.w4d-t').textContent, time: row?.querySelector('.w4d-time').textContent}; -}); -console.log('목록 순서', JSON.stringify(seq)); -console.log('재생 중 줄', JSON.stringify(cur)); -await b.close(); diff --git a/solution/site/scripts/mockup/spin.mjs b/solution/site/scripts/mockup/spin.mjs deleted file mode 100644 index 6334f09..0000000 --- a/solution/site/scripts/mockup/spin.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -for (const rm of ['no-preference', 'reduce']) { - const p = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true, reducedMotion: rm}); - await p.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); - await p.waitForTimeout(4500); - await p.evaluate(() => document.querySelector('#songs').scrollIntoView({block: 'center'})); - await p.waitForTimeout(900); - const r = await p.evaluate(() => { - const disc = document.querySelector('#songs .w4-spin'); - if (!disc) return {err: '판 없음'}; - const s = getComputedStyle(disc); - const box = disc.getBoundingClientRect(); - return {animation: s.animationName + ' ' + s.animationDuration, playState: s.animationPlayState, - w: Math.round(box.width), h: Math.round(box.height), visible: box.width > 0 && s.visibility !== 'hidden'}; - }); - console.log(`reduced-motion:${rm.padEnd(14)}`, JSON.stringify(r)); - await p.close(); -} -await b.close(); diff --git a/solution/site/scripts/mockup/touch.mjs b/solution/site/scripts/mockup/touch.mjs deleted file mode 100644 index b5082e6..0000000 --- a/solution/site/scripts/mockup/touch.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import {chromium, devices} from 'playwright'; -const url = process.argv[2] || 'http://localhost/s/stay'; -const b = await chromium.launch({channel: 'chrome'}); -const ctx = await b.newContext({...devices['iPhone 13'], hasTouch: true, isMobile: true}); -const page = await ctx.newPage(); -const ev = []; -await page.goto(url, {waitUntil: 'domcontentloaded'}); -await page.waitForTimeout(6000); -await page.evaluate(() => document.querySelector('#itinerary').scrollIntoView({block: 'center'})); -await page.waitForTimeout(9000); -await page.evaluate(() => { - const r = document.querySelector('#itinerary .slider-viewport'); - window.__r = r; - window.__pos = () => { const c = r.firstElementChild; let m = 0; - try { m = new DOMMatrix(getComputedStyle(c).transform).m41; } catch {} return Math.round(r.scrollLeft + m); }; - window.__ev = []; - ['pointerdown','pointerup','pointercancel','touchstart','touchmove','touchend'].forEach((t) => { - window.addEventListener(t, (e) => { if (e.isTrusted) window.__ev.push(`${((Date.now()-window.__t0)/1000).toFixed(1)} ${t}`); }, true); - }); - window.__t0 = Date.now(); -}); -const box = await page.evaluate(() => { const b = window.__r.getBoundingClientRect(); return {x: Math.round(b.left + b.width/2), y: Math.round(Math.min(Math.max(b.top + b.height/2, 120), 600))}; }); -const p0 = await page.evaluate(() => window.__pos()); -// 손가락을 대고 천천히 세로로 끌면서 10초 유지 (CDP 로 직접 터치) -const cdp = await ctx.newCDPSession(page); -const pt = (type, y) => cdp.send('Input.dispatchTouchEvent', {type, touchPoints: type === 'touchEnd' ? [] : [{x: box.x, y}]}); -await pt('touchStart', box.y); -const marks = []; -for (let i = 0; i < 12; i++) { - await page.waitForTimeout(900); - await pt('touchMove', box.y - (i + 1) * 2); // 아주 조금씩 — 손가락 올린 채 유지 - marks.push(await page.evaluate((p) => window.__pos() - p, p0)); -} -await pt('touchEnd', box.y); -console.log('손가락 올린 채 11초:', marks.join(' ')); -await page.waitForTimeout(11000); -console.log('뗀 뒤 11초:', await page.evaluate((p) => window.__pos() - p, p0)); -console.log('이벤트:', (await page.evaluate(() => window.__ev)).slice(0, 14).join(' | ')); -await b.close(); diff --git a/solution/site/scripts/mockup/verify.mjs b/solution/site/scripts/mockup/verify.mjs deleted file mode 100644 index cfd4c14..0000000 --- a/solution/site/scripts/mockup/verify.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import {chromium} from 'playwright'; -const b = await chromium.launch({channel: 'chrome'}); -const page = await b.newPage({viewport: {width: 1280, height: 900}}); -const logs = []; -page.on('console', (m) => { if (m.text().includes('[w4d]') || m.type() === 'error') logs.push(`${m.type()}: ${m.text().slice(0,110)}`); }); -await page.goto('http://localhost/s/stay', {waitUntil: 'domcontentloaded'}); -await page.waitForTimeout(6000); - -const r = await page.evaluate(() => { - const txt = (el) => (el?.textContent || '').trim(); - const songTitles = [...document.querySelectorAll('#songs .w4d-t, #songs button span')].map(txt).filter(Boolean); - return { - mini: !!document.getElementById('w4d-mini'), - tape: !!document.getElementById('w4d-tape'), - miniBtns: document.querySelectorAll('#w4d-mini button').length, - songsCount: document.querySelectorAll('#songs button[aria-pressed]').length, - songsHasOwn: document.body.innerHTML.includes('머뭄의 아침') && !!document.querySelector('#songs'), - ownInSection: [...document.querySelectorAll('#songs')].some((s) => s.textContent.includes('Stay in 머뭄')), - people: document.querySelectorAll('#people article').length, - itinLinks: document.querySelectorAll('#itinerary a.w4d-link').length, - postcardLinks: document.querySelectorAll('#postcard a.w4d-link').length, - itinImgs: document.querySelectorAll('#itinerary img:not([src*="openstreetmap"])').length, - firstStops: [...document.querySelectorAll('#itinerary article')].slice(0, 6).map((a) => txt(a.querySelector('ol li span'))), - bbq: document.body.textContent.includes('바비큐'), - redRule: document.querySelectorAll('.w4d-rule').length, - }; -}); -console.log(JSON.stringify(r, null, 1)); -const sample = await page.evaluate(() => { - const a = document.querySelector('#itinerary a.w4d-link'); - const p = document.querySelector('#postcard a.w4d-link'); - return {itin: a?.getAttribute('href'), postcard: p?.getAttribute('href')}; -}); -console.log('링크 샘플', JSON.stringify(sample)); -console.log(logs.join('\n')); -await page.screenshot({path: '/tmp/v-itin.png', clip: {x: 0, y: 0, width: 1280, height: 900}}); -await b.close();