[fix] site/scripts: 시연본을 레포 안에서 자급자족하게 — 다른 기계에서 흰 화면이던 것
구워진 index.html 이 미러 사진 61장(`/assets/mirror/*`)과 번들 css/js 를 루트 절대경로로 가리켰다. 그 파일들은 레포가 아니라 도커 볼륨에만 있어서, 레포만 받은 사람은 흰 화면을 본다. 번들은 더 나쁘다 — 파일명이 콘텐츠 해시라 그 기계에서 구운 해시가 다르면 404 다. 실측: 구운 html 이 /assets 를 63곳 가리키고 있었다. - img/mirror/ 61장 · vendor/ 2개를 레포로 끌어들였다 - patch_stay.py: 경로를 `/s/stay/img/mirror/` · `/s/stay/vendor/` 로 바꿔 박고, 레포에 없는 자산이 있으면 **빌드를 멈춘다**. 끝에 `/assets` 잔재가 남아도 멈춘다 - audit-all.mjs: 히어로 사진 검사를 새 경로로 - SECTIONS.md: 설명서에서 **작업 지시서**로 다시 씀. 프롬프트를 여기 다시 적지 않고 PROMPTS.md 의 절을 가리킨다(일정은 §3-1/3-2/3-3 세 단계다). 제품 쪽 짝 파일 표 추가 - README.md: "보고 규칙" 절 신설 — 실행하지 않은 것을 됐다고 쓰지 않는다 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ -17,6 +17,22 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 보고 규칙 — "했다" 고 말하기 전에
|
||||||
|
|
||||||
|
2026-09-11 지시. **이 규칙을 어긴 기록이 §7 에 여섯 건 있다.**
|
||||||
|
|
||||||
|
1. **실행하지 않은 것을 됐다고 쓰지 않는다.** 코드에서 빠진 것을 보는 건 확인이 아니다.
|
||||||
|
화면을 열어 세거나 명령을 돌려서 **숫자·출력**을 얻은 뒤에 말한다.
|
||||||
|
2. **"했다" 옆에 무엇으로 확인했는지 같이 쓴다.** `audit-all.mjs 34/34` · `md5 일치` ·
|
||||||
|
`20초 동안 0px` 처럼. 근거를 못 쓰면 아직 안 끝난 것이다.
|
||||||
|
3. **절차를 안내할 때는 그 절차를 먼저 밟아 본다.**
|
||||||
|
"이것만 하면 나온다" 를 안 띄워 보고 쓴 적이 있다(2026-09-11, 다른 기계에서 흰 화면이었다).
|
||||||
|
4. **일부만 했으면 일부만 했다고 쓴다.** 못 한 것을 빼고 요약하지 않는다.
|
||||||
|
5. **사장님이 본 것과 내가 잰 것이 다르면, 내가 잰 것을 먼저 의심한다.**
|
||||||
|
범위를 좁혀서 재지 말고 전수로 다시 센다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0. 먼저 알아야 할 것 — 왜 스크립트인가
|
## 0. 먼저 알아야 할 것 — 왜 스크립트인가
|
||||||
|
|
||||||
`/s/stay` 는 **payload 가 없는 목업**이다(`stay2` · `stay3` 도 같다).
|
`/s/stay` 는 **payload 가 없는 목업**이다(`stay2` · `stay3` 도 같다).
|
||||||
|
|||||||
@ -1,279 +1,250 @@
|
|||||||
# /s/stay 섹션별 — 목업은 무엇으로 만들었고, 제품은 무엇으로 만들어야 하나
|
# 이 시연본을 제품으로 옮기기 — 개발자 작업 지시서
|
||||||
|
|
||||||
작성 2026-09-11 · 대상 `https://web4ai.o2osolution.ai/s/stay` (스테이,머뭄 · 전북 군산)
|
대상 `https://web4ai.o2osolution.ai/s/stay` (스테이,머뭄 · 전북 군산) · 작성 2026-09-11
|
||||||
**읽는 사람**: 빌더·백엔드에서 이 시연본을 실제 생성 파이프라인으로 옮기는 사람.
|
|
||||||
|
|
||||||
시연본은 **payload 가 없는 목업**이다 — 프리렌더가 굽지 않고, `index.html` 한 장을
|
**이 문서를 읽는 사람**: 이 시연본에 있는 것을 빌더·백엔드에서 **자동으로 만들게** 하려는 사람.
|
||||||
손으로 조립했다(`patch_stay.py`). 그래서 "화면에 있는 것" 과 "제품이 만들 수 있는 것"이
|
설명서가 아니라 **할 일 목록**이다. 프롬프트 전문은 [PROMPTS.md](PROMPTS.md) 에 있고
|
||||||
지금 **갈라져 있다.** 이 문서는 섹션마다 그 간격을 적는다.
|
여기서는 "어느 절을 쓰라"고만 가리킨다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. 한 줄 결론
|
## 0. 먼저 — 화면부터 띄운다 (3분)
|
||||||
|
|
||||||
22개 섹션 중 **16개는 이미 계약과 생성 경로가 있다.** 나머지 6개가 할 일이다:
|
```bash
|
||||||
|
git clone <repo> && cd o2o-site-AEO
|
||||||
|
cp .env.example .env && cp nginx/site.conf.example nginx/site.conf
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
| | 섹션 | 왜 |
|
cd solution/site/scripts/mockup
|
||||||
|
python3 patch_stay.py # 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. 어떤 파일을 여나
|
||||||
|
|
||||||
|
| 파일 | 무엇이 들어 있나 | 언제 연다 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| ① | **히어로 캐치프레이즈** | 계약에 없다. `narrative.tagline` 은 **문자열 하나**인데 목업은 100개를 돌린다 |
|
| [PROMPTS.md](PROMPTS.md) | **프롬프트 전문 7종** — 캐치프레이즈 · 장소 대장 · 테마 · 노래 · 인물 · 엽서 | 생성 프롬프트를 쓸 때 |
|
||||||
| ② | **음악 플레이어(자작곡)** | 계약에 없다. `media` 에 오디오 종류가 없다 |
|
| [README.md](README.md) | 목업 고치는 절차 · 배포 · 밟으면 조용히 틀리는 자리 | 목업 자체를 손볼 때 |
|
||||||
| ③ | **추천 일정** | 계약·생성기 둘 다 있는데 **깊이가 다르다**. 서버판은 하루 4칸(관광 2·끼니 2)이고 체류·이동·입퇴실 개념이 없다 |
|
| [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) | 일정이 왜 틀렸고 무엇을 고쳤나 | 일정 규칙을 옮기기 전에 |
|
||||||
| ④ | **인물·노래 사진** | 프롬프트가 `imageQuery` 만 받는다. 실제 사진을 찾아 붙이고 **라이선스를 표시하는** 경로가 없다 |
|
| [TEXT.md](TEXT.md) | 화면에 나가는 텍스트 전문 | 문구 톤을 볼 때 |
|
||||||
| ⑤ | **상한(maxItems)** | songs 8·people 10 인데 목업은 25·57 이다 |
|
| `build_itinerary.py` | 일정 생성기 **원본** — 규칙 14종이 여기 있다 | 일정을 서버로 옮길 때 |
|
||||||
| ⑥ | **객실 사진** | 목업은 야놀자 등록본에서 가져왔다. 수집 어댑터 정책([DECISIONS 1-1](../../../../docs/DECISIONS.md))에 걸리는지 결론이 필요하다 |
|
| `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` 부근 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 값이 화면까지 가는 길
|
## 2. 지금 상태 — 섹션 22개
|
||||||
|
|
||||||
```
|
`할 일` 칸이 비어 있으면 **제품이 이미 만들 수 있다.** 손댈 필요 없다.
|
||||||
수집·생성(백엔드) → area_contents / places / media → site_payload.py → payload JSON
|
|
||||||
↓
|
|
||||||
프리렌더(Node) → out/s/<slug>/index.html
|
|
||||||
↓
|
|
||||||
window.__SITE_PAYLOAD__ → React 하이드레이션
|
|
||||||
```
|
|
||||||
|
|
||||||
- **읽는 쪽 계약**은 `solution/shared/src/types/site-payload.ts` · `shared/src/lib/section-data.ts` 한 곳이다.
|
| id | 이름 | 값이 오는 자리 | 지금 누가 만드나 | 할 일 |
|
||||||
- **프롬프트 계약**은 `shared/src/lib/section-prompts.ts` 한 곳이고,
|
|---|---|---|---|---|
|
||||||
백엔드는 `npm run export:prompts` 산출물(`backend/services/prompts/section_prompts.json`)을 읽는다.
|
| `hero` | 히어로 | `narrative` | 사장님이 적음 | **T1** |
|
||||||
**파이썬 쪽을 손으로 고치지 않는다.**
|
| `intro` | 소개 | `narrative.about[]` | LLM(빌더) | |
|
||||||
- 섹션 목록·기본 on/off 는 업종별로 `services/site_payload.py:155` 부근에 있다.
|
| `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)` | **없음** | (이 시연본은 꺼 둠) |
|
||||||
|
|
||||||
payload 안에서 섹션이 값을 받는 자리는 셋뿐이다.
|
여기에 **섹션이 아닌 것** 하나가 더 있다 — 헤더 음악 플레이어(**T4**).
|
||||||
|
|
||||||
| 자리 | 무엇이 들어가나 | 예 |
|
|
||||||
|---|---|---|
|
|
||||||
| `theme.sections[].data` | JSON 봉투 문자열(`{kind, version, title, items[]}`) | songs · people · itinerary · event · video · planner |
|
|
||||||
| 최상단 필드 | 업소가 적은 사실·사진·문답 | `place` `facts` `units` `media` `faqs` `links` `narrative` |
|
|
||||||
| `local.*` | 지역에서 수집한 것 | `attractions`(12) `restaurants`(8) `festivals`(10) `weather` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 섹션 22개 — 한눈에
|
## 3. 할 일 여섯
|
||||||
|
|
||||||
`on` 은 이 목업에서 켜져 있는지다. `story` 안의 6개는 탭이라 최상단은 꺼져 있다.
|
### T1 — 히어로 캐치프레이즈
|
||||||
|
|
||||||
| # | id | 이름 | on | 값이 오는 자리 | 계약 | 프롬프트 | 목업에서 한 일 |
|
**증상** 히어로 문구가 사이트마다 한 줄로 고정이다. 계절도 날씨도 안 탄다.
|
||||||
|---|---|---|---|---|---|---|---|
|
지금 계약(`site-payload.ts:316` `Narrative`)에 문자열 `tagline` 하나뿐이고,
|
||||||
| 1 | `hero` | 히어로 | ● | `narrative` + 대표 사진 | 있음 | **없음** | 캐치프레이즈 100개를 주입분이 돌림 |
|
히어로는 `tagline ?? heroSubline ?? summary` 를 그대로 찍는다(`HeroPension.tsx:125`).
|
||||||
| 2 | `intro` | 소개 | ● | `narrative.about[]` | 있음 | 있음(빌더) | 원본 그대로 |
|
|
||||||
| 3 | `rooms` | 객실 안내 | ● | `units[]` + `media[]` | 있음 | — | 야놀자 등록본으로 **전면 교체**(A동 12·B동 10) |
|
|
||||||
| 4 | `event` | 소식 | ● | `data(kind=event)` 3건 | 있음 | — | 원본 그대로 |
|
|
||||||
| 5 | `info` | 기본 정보 | ● | `facts[]` 12건 | 있음 | — | 맨 아래 가로선만 CSS 로 제거 |
|
|
||||||
| 6 | `booking` | 실시간 예약 | ○ | — | 있음 | — | 껐다(시연에 결제 흐름 없음) |
|
|
||||||
| 7 | `video` | 영상 | ● | `data(kind=video)` 3건 | 있음 | — | 원본 그대로 |
|
|
||||||
| 8 | `photos` | 사진 갤러리 | ● | `media[]` 37장 | 있음 | — | 원본 그대로 |
|
|
||||||
| 9 | `map` | 오시는 길 | ● | `place` + `routes[]` | 있음 | — | `routes` 비어 있음 |
|
|
||||||
| 10 | `festival` | 계절별 축제 | ● | `local.festivals` 10건 | 있음 | — | 원본 그대로 |
|
|
||||||
| 11 | `local` | 지역 정보 | ● | `local.attractions/restaurants` | 있음 | — | 원본 그대로 |
|
|
||||||
| 12 | `itinerary` | **추천 일정** | ● | `data(kind=itinerary)` 21개 | 있음 | **없음** | **전부 다시 생성**(§4) |
|
|
||||||
| 13 | `story` | 군산 이야기 | ● | 아래 6탭의 껍데기 | 있음 | — | — |
|
|
||||||
| 14 | `songs` | 가요 다방 | 탭 | `data(kind=songs)` 25곡 | 있음 | 있음 `section-prompts.ts:67` | 8 → **25곡**으로 늘림 |
|
|
||||||
| 15 | `people` | 인물 열전 | 탭 | `data(kind=people)` 57명 | 있음 | 있음 `:113` | 10 → **57명** + 사진 18장 |
|
|
||||||
| 16 | `chronicle` | 시간의 골목 | 탭 | `data(kind=chronicle)` 7건 | 있음 | 있음 `:135` | 원본 그대로 |
|
|
||||||
| 17 | `postcard` | 오늘의 엽서 | 탭 | `data(kind=postcard)` 4건 | 있음 | 있음 `:160` | 지도 링크만 추가 |
|
|
||||||
| 18 | `quiz` | 뒤집어 보는 질문 | 탭 | `data(kind=quiz)` 4건 | 있음 | 있음 `:188` | 원본 그대로 |
|
|
||||||
| 19 | `faq` | 자주 묻는 질문 | ● | `faqs[]` 32건 | 있음 | 있음 | 원본 그대로 |
|
|
||||||
| 20 | `rules` | 이용 규정 | ● | `facts[]` | 있음 | — | 원본 그대로 |
|
|
||||||
| 21 | `weather` | 날씨 | ● | `local.weather` | 있음 | — | 애니메이션만 정지 |
|
|
||||||
| 22 | `planner` | 계절별 추천 하루 | ○ | `data(kind=planner)` 4건 | 있음 | **없음** | 껐다 |
|
|
||||||
|
|
||||||
`daily`(오늘의 한 장)는 프롬프트에는 있는데 **이 목업 섹션 목록에 없다.**
|
**할 일**
|
||||||
새 업장에서 켜려면 섹션 목록에 자리를 먼저 만들어야 한다.
|
|
||||||
|
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월에 겨울 문구가 안 나온다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 계약에 없는 것 — 새로 만들어야 하는 셋
|
### T2 — 추천 일정 ★ 가장 큰 일
|
||||||
|
|
||||||
### ① 히어로 캐치프레이즈 — 계약을 늘려야 한다
|
**증상** 서버 생성기(`services/itinerary.py`)는 하루를 **관광지 2 + 맛집 2** 로 짠다.
|
||||||
|
체류시간·이동시간·입퇴실·끼니 시각 개념이 없다. 그래서 나온 하루가
|
||||||
|
"둘째 날 해산물축제 → 근대건축관" 으로 끝난다.
|
||||||
|
|
||||||
**지금 계약**: `Narrative`(`site-payload.ts:316`)는 `heroHeadline` `heroSubline` `tagline`
|
**★ 프롬프트 하나로 일정을 받지 않는다.** 모델은 시각 계산을 못 한다 —
|
||||||
`about[]` `summary` 뿐이고 전부 **문자열 하나**다. 히어로는
|
이동시간을 지어내고, 점심을 09:40 에 넣고, 21시를 넘겨 **렌더러가 조용히 버리는** 칸을 만든다
|
||||||
`narrative.tagline ?? heroSubline ?? summary` 를 그대로 찍는다(`HeroPension.tsx:125`).
|
(`shared/src/lib/section-data.ts:535`). 시연본 첫 판이 정확히 그랬다.
|
||||||
|
|
||||||
**목업이 한 것**: `narrative.catchphrases = {version:1, items:[…100]}` 를 payload 에
|
**세 단계로 나눈다.** 전문은 [PROMPTS.md §3](PROMPTS.md).
|
||||||
끼워 넣고, 주입분이 7초마다 단어 단위로 갈아 끼운다. **렌더러는 이 필드를 모른다** —
|
|
||||||
주입분(`inject.js`)이 읽는다. 100개 구성은 `patch_stay.py:20`(일반 40) `:62`(계절 24)
|
|
||||||
`:72`(월 24) `:87`(날씨 12).
|
|
||||||
|
|
||||||
**제품에서 해야 할 일**
|
| 단계 | 누가 | 무엇을 내놓나 | 전문 |
|
||||||
|
|
||||||
```ts
|
|
||||||
// site-payload.ts — Narrative 에 추가
|
|
||||||
catchphrases?: {
|
|
||||||
version: 1;
|
|
||||||
items: {
|
|
||||||
text: string; // 18자 안쪽
|
|
||||||
type: 'general' | 'season' | 'month' | 'weather';
|
|
||||||
season?: '봄' | '여름' | '가을' | '겨울'; // type=season
|
|
||||||
month?: number; // 1–12, type=month
|
|
||||||
weather?: '맑음' | '흐림' | '비' | '눈'; // type=weather
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
고르는 규칙(목업이 쓰는 것 그대로): **지금 계절·이번 달·현재 날씨에 맞는 것만 후보**로
|
|
||||||
두고 일반과 섞어 돌린다. 계절은 3개월 단위(가을 9–11, 겨울 12–2). 날씨는 `local.weather.condition`.
|
|
||||||
|
|
||||||
**새로 쓸 프롬프트** — `SECTION_PROMPTS` 에 `catchphrase` 종류를 더한다.
|
|
||||||
|
|
||||||
```
|
|
||||||
[해야 할 일]
|
|
||||||
[업소]의 히어로에 돌려 쓸 짧은 문구를 100개 쓴다.
|
|
||||||
일반 40 · 계절 24(계절마다 6) · 월 24(달마다 2) · 날씨 12(맑음/흐림/비/눈 각 3).
|
|
||||||
|
|
||||||
[스키마]
|
|
||||||
{ "kind":"catchphrase", "version":1, "items":[
|
|
||||||
{ "text":"문구", "type":"general|season|month|weather",
|
|
||||||
"season":"봄|여름|가을|겨울", "month":9, "weather":"맑음|흐림|비|눈" } ] }
|
|
||||||
|
|
||||||
[이 아이템만의 규칙]
|
|
||||||
· 18자 안쪽. 두 문장으로 쓰지 않는다.
|
|
||||||
· 느낌표·이모지를 쓰지 않는다. 광고 문구로 들리면 실패다.
|
|
||||||
· **업소에서 실제로 일어나는 일**을 쓴다. 지역 자랑이 아니다.
|
|
||||||
("새소리로 눈이 떠지는 집" ○ / "천년의 역사가 살아 숨쉬는 군산" ×)
|
|
||||||
· 계절·월·날씨 문구는 그 조건에서만 말이 되어야 한다.
|
|
||||||
9월 문구가 1월에 나와도 어색하지 않으면 그건 일반 문구다.
|
|
||||||
· 시설명·가격·전화번호를 넣지 않는다.
|
|
||||||
· source 는 필요 없다. 사실 주장이 아니라 업소가 하는 말이다.
|
|
||||||
```
|
|
||||||
|
|
||||||
### ② 자작곡 플레이어 — `media` 에 오디오가 없다
|
|
||||||
|
|
||||||
**목업**: `narrative.ownSongs = {version:1, items:[…5]}`(`patch_stay.py:109`),
|
|
||||||
파일은 `/s/stay/audio/*.mp3` 로 **직접 넣었다**. nginx `^~ /s/` 가 Range 206 으로 준다.
|
|
||||||
플레이어는 주입분이 헤더에 그린다. **프로토타입이라 파일**이고, 제품에서는 미디어 파이프라인에
|
|
||||||
태워야 한다.
|
|
||||||
|
|
||||||
**해야 할 일**: `MediaItem` 에 `kind: 'audio'` 를 넣고(`url` `title` `artist` `durationSec`),
|
|
||||||
발행 시 다른 미디어와 같이 미러/업로드한다. 렌더러에는 플레이어 컴포넌트가 없다 —
|
|
||||||
헤더에 붙는 작은 플레이어(아이콘 · 곡명 · 재생/멈춤 · 목록)를 새로 만든다.
|
|
||||||
자동재생은 **넣지 않는다**(사장님 지시 2026-09-11). 재생 순서는 **랜덤 시작 후 순차**.
|
|
||||||
|
|
||||||
### ③ 추천 일정 — 생성기가 있는데 깊이가 다르다
|
|
||||||
|
|
||||||
**서버판**(`backend/services/itinerary.py`)은 payload 빌드 때 즉석 계산한다.
|
|
||||||
하루 뼈대가 **관광지 2 + 맛집 2**, 반경 30km, 최근접 이웃 순서다.
|
|
||||||
체류시간·이동시간·입퇴실·끼니 시각 개념이 **없다**.
|
|
||||||
|
|
||||||
**목업판**(`build_itinerary.py`)이 더한 것:
|
|
||||||
|
|
||||||
| | 목업이 넣은 것 | 어디 |
|
|
||||||
|---|---|---|
|
|
||||||
| 장소별 체류 | (최소, 기본, 최대) 분 — '오후' 칸이면 뭐든 90분이던 것을 장소마다 | `:40` |
|
|
||||||
| 이동시간 | 좌표에서 계산. 도보 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` |
|
|
||||||
| 테마 | 21개. 테마마다 시작 시각과 체류 배율이 다르다(끝나는 시각이 13:35~21:00 로 퍼진다) | `:368` `:470` |
|
|
||||||
| 제약 | 비 와도 되는 테마는 실내만 · 차 없는 테마는 도보권만 · 아이 동반에 술집 금지 | `:519` |
|
|
||||||
| **감사** | 규칙 14종. 하나라도 어기면 **payload 를 쓰지 않고 빌드가 멈춘다** | `:526` |
|
|
||||||
|
|
||||||
**해야 할 일**: 이 규칙을 `services/itinerary.py` 로 옮긴다. 재료가 모자라면 지어내지 않는
|
|
||||||
원칙은 그대로다 — **감사에 걸리면 그 테마를 빼고, 하루도 못 채우면 섹션을 안 낸다.**
|
|
||||||
|
|
||||||
장소별 체류시간이 관건이다. 지금 `local_contents` 에는 그 값이 없다.
|
|
||||||
LLM 에 물어 채우는 게 현실적이다 — 새 프롬프트:
|
|
||||||
|
|
||||||
```
|
|
||||||
[해야 할 일]
|
|
||||||
아래 장소들에 손님이 실제로 머무는 시간을 분으로 적고,
|
|
||||||
거기서 "무엇을 하는지" 한 문장으로 쓴다.
|
|
||||||
|
|
||||||
[스키마]
|
|
||||||
{ "kind":"stopmeta", "version":1, "items":[
|
|
||||||
{ "name":"장소명", "minMinutes":25, "baseMinutes":35, "maxMinutes":50,
|
|
||||||
"indoor":true, "needsCar":false, "kidFriendly":true,
|
|
||||||
"todo":"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다." } ] }
|
|
||||||
|
|
||||||
[이 아이템만의 규칙]
|
|
||||||
· todo 는 **거기서 하는 일**이다. "무엇인지" 설명하지 않는다.
|
|
||||||
("안에 들어가 사진관 세트에서 한 장 찍습니다" ○ / "1950년대 사진관입니다" ×)
|
|
||||||
· 한 바퀴 도는 데 15분이면 15분이라고 적는다. 30분으로 올리지 않는다.
|
|
||||||
· indoor 는 비가 와도 갈 수 있느냐다. 지붕이 있어도 가는 길이 야외면 false.
|
|
||||||
· needsCar 는 숙소에서 걸어서 20분을 넘느냐다.
|
|
||||||
```
|
|
||||||
|
|
||||||
### ④ 인물·노래 사진 — `imageQuery` 다음이 없다
|
|
||||||
|
|
||||||
프롬프트는 **사진 URL 을 금지**하고 `imageQuery` 만 받는다(`section-prompts.ts:128`).
|
|
||||||
초상권·저작권 때문이고 그 판단은 맞다. 그런데 **그 다음 단계가 없어서** 인물 열전이
|
|
||||||
활자만으로 선다.
|
|
||||||
|
|
||||||
목업이 한 것(`build_story.py`): 위키백과 **문서의 `pageimages`** 만 믿는다.
|
|
||||||
이름으로 커먼즈를 검색하면 동명이인이 온다(실측: 이수현→걸그룹, 박성현→골퍼, 이길여→건물).
|
|
||||||
57명 중 **18명**만 사진을 얻었고, 얻은 것은 `imageinfo.extmetadata` 에서 저작자·라이선스를
|
|
||||||
받아 **화면에 표시한다** — CC BY-SA 의 조건이라 안 쓰면 위반이다.
|
|
||||||
|
|
||||||
**해야 할 일**: `services/external/wikimedia.py` 에 "문서 → pageimages → 라이선스" 경로를 붙이고,
|
|
||||||
`imageCredit`(문자열)을 payload 에 넣는다. ★ **문자열이다** — 객체로 넣으면 렌더러가
|
|
||||||
조용히 아무것도 안 그린다(`site/src/sections/items/common.tsx:206`).
|
|
||||||
사진이 붙은 사람을 **앞으로** 정렬한다(빈 카드가 먼저 오면 목록이 비어 보인다).
|
|
||||||
|
|
||||||
### ⑤ 상한 — `maxItems` 를 올려야 한다
|
|
||||||
|
|
||||||
| 종류 | 지금 상한 | 목업 | 왜 |
|
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| songs | 8 | 25 | 도시 하나에 그만큼 있다. 8곡이면 '다방'이 아니라 목록이다 |
|
| **3-1 장소 대장** | LLM | 장소 30곳 × `{stayMinutes:{min,base,max}, todo, indoor, needsCar, bestTime, searchQuery}` | PROMPTS.md §3-1 |
|
||||||
| people | 10 | 57 | 위키백과 '군산시 출신' 분류만으로 57명이 확인된다 |
|
| **좌표** | 코드 | 카카오 로컬로 `searchQuery` → 위경도. **LLM 이 적은 좌표는 쓰지 않는다** | — |
|
||||||
| chronicle | 14 | 7 | 상한은 맞는데 **채우는 쪽이 모자라다** |
|
| **3-2 조립** | **코드** | 이동시간·도착시각·끼니 창 맞추기·규칙 14종 감사 | PROMPTS.md §3-2 · `build_itinerary.py` |
|
||||||
| postcard | 12 | 4 | 같다 |
|
| **3-3 테마** | LLM | 테마 20개 × `{name, duration, audience, why, days[{kind, places[]}]}` | PROMPTS.md §3-3 |
|
||||||
|
|
||||||
상한을 올릴 때 **한 번에 다 받지 않는다.** 목업은 곡을 8개씩 세 번 나눠 받았다 —
|
**코드가 하는 일(3-2)에서 반드시 옮겨야 하는 것** — 근거는 `build_itinerary.py` 줄번호
|
||||||
한 번에 25곡을 시키면 뒤쪽이 급격히 부실해진다. 나눠 받고 합칠 때 제목으로 중복을 지운다.
|
|
||||||
프롬프트 분할 방법은 [PROMPTS.md](PROMPTS.md) 에 있다.
|
|
||||||
|
|
||||||
### ⑥ 객실 사진 — 출처 정책 결론이 필요하다
|
|
||||||
|
|
||||||
목업은 야놀자 등록본(`nol.yanolja.com/stay/domestic/10068088`)의 Next.js 플라이트 데이터에서
|
|
||||||
`roomTypes[].photos` 를 파싱해 A동 12·B동 10 을 가져왔다(`rooms.json`).
|
|
||||||
**이건 시연용으로 손으로 한 것이고 제품 경로가 아니다.**
|
|
||||||
수집 어댑터를 만들려면 [DECISIONS 1-1](../../../../docs/DECISIONS.md) 과
|
|
||||||
[DATA_SOURCE_RESEARCH.md](../../../../docs/DATA_SOURCE_RESEARCH.md) 를 먼저 읽는다 —
|
|
||||||
**봇 탐지 우회는 결론과 무관하게 영구 금지**다.
|
|
||||||
현실적인 제품 경로는 **사장님이 올리는 것**이거나 **업소가 준 채널의 공개 API** 다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 프롬프트를 새로 쓸 때 — 이 레포의 규칙
|
|
||||||
|
|
||||||
`SECTION_PROMPT_RULES`(`section-prompts.ts:55`)가 공통 7줄이고, 종류별 규칙을 덧붙인다.
|
|
||||||
새 종류를 더할 때 지켜야 하는 것:
|
|
||||||
|
|
||||||
1. **`STORY_KINDS` 에 등록한다.** 빠지면 서버 생성 잡이 그 종류를 아예 모른다 —
|
|
||||||
`daily` 가 그래서 한동안 빈칸이었다(주석에 실측이 적혀 있다).
|
|
||||||
2. **`maxItems` 와 프롬프트 안의 숫자를 같게 둔다.** 어긋나면 받아 놓고 잘라 버린다.
|
|
||||||
3. **빈 값을 지어내지 않는다**(공통 2번). 확실한 게 12개면 12개만 낸다.
|
|
||||||
4. **출처는 실제로 열리는 페이지**여야 한다(공통 3번). 검색 결과 주소는 안 된다.
|
|
||||||
5. **가사·시·소설 원문을 한 줄도 옮기지 않는다**(공통 5번).
|
|
||||||
6. `[지역]` 같은 빈칸을 남기지 않는다 — `buildSectionPrompt` 가 채워서 내보낸다.
|
|
||||||
7. 고친 뒤 **`npm run export:prompts`** 를 돌린다. 백엔드는 그 산출물을 읽는다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 검증 — "됐다"고 말하기 전에
|
|
||||||
|
|
||||||
목업에서 쓰는 방식 그대로 제품에도 필요하다.
|
|
||||||
|
|
||||||
| | 무엇 | 어디 |
|
| | 무엇 | 어디 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 생성 단계 | 규칙 위반이면 **결과물을 쓰지 않고 멈춘다**. 검사를 생성기 **안**에 둔다 | `build_itinerary.py:526` |
|
| 이동시간 | 좌표 계산. 도보 4km/h(≤1.5km), 그 위는 차 25km/h + 주차 5분 | `move_minutes()` |
|
||||||
| 렌더 단계 | 실제 브라우저로 열어 숫자를 센다. 34종 | `audit-all.mjs` |
|
| 숙소 고정 | 모든 일정이 **숙소에서 출발해 숙소로 복귀**. 첫 칸·마지막 칸이 숙소 | `:108` `:182` |
|
||||||
| 카로셀 | 레일 13개 × 데스크톱·모바일 | `rails-test.mjs` |
|
| 시각 창 | 입실 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장).
|
||||||
경위는 [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) 2장.
|
|
||||||
|
**완료 기준** 새 업장에서 테마 20개가 나오고 `audit()` 위반 0건.
|
||||||
|
하루가 숙소로 시작해 숙소로 끝나고, 끼니가 제 시각에 있고, 21시를 안 넘는다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 같이 읽을 것
|
### T3 — 인물·노래 사진
|
||||||
|
|
||||||
| | |
|
**증상** 프롬프트가 사진 URL 을 금지하고 `imageQuery` 만 받는다(`section-prompts.ts:128`).
|
||||||
|---|---|
|
초상권·저작권 때문이고 그 판단은 맞다. 그런데 **다음 단계가 없어서** 인물 열전이 활자만으로 선다.
|
||||||
| 목업을 고치고 배포하는 절차 · 밟으면 조용히 틀리는 자리 | [README.md](README.md) |
|
|
||||||
| 실제로 쓴 프롬프트 전문과 나눠 받는 법 | [PROMPTS.md](PROMPTS.md) |
|
**할 일**
|
||||||
| 화면에 나가는 텍스트 전문 | [TEXT.md](TEXT.md) |
|
|
||||||
| 일정이 왜 틀렸고 무엇을 고쳤나 | [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) |
|
1. `services/external/wikimedia.py` 에 **문서 → `pageimages` → `imageinfo.extmetadata`** 경로를 붙인다.
|
||||||
| 값이 DB 에서 페이지까지 가는 길 | [docs/DATA_MODEL.md](../../../../docs/DATA_MODEL.md) |
|
2. ★ **이름으로 커먼즈를 검색하지 않는다.** 동명이인이 온다 —
|
||||||
| 미결 사항 · 수집 어댑터 정책 | [docs/DECISIONS.md](../../../../docs/DECISIONS.md) |
|
실측: 이수현→걸그룹, 은성수→축구선수, 박성현→골퍼, 이길여→건물.
|
||||||
|
**위키백과 문서에 붙은 대표사진만** 믿는다. 구현은 `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) 에 있다.
|
||||||
|
|||||||
@ -85,7 +85,7 @@ ok('자작곡 5곡', d.ownSongs === 5, `${d.ownSongs}곡`);
|
|||||||
ok('빨간 줄 없음', d.redRule === 0, `${d.redRule}개`);
|
ok('빨간 줄 없음', d.redRule === 0, `${d.redRule}개`);
|
||||||
ok('바비큐 항목 유지', d.bbqRow, d.bbqRow ? '있음' : '사라짐');
|
ok('바비큐 항목 유지', d.bbqRow, d.bbqRow ? '있음' : '사라짐');
|
||||||
ok('맨 아래 가로선 제거', d.divider === '0px', d.divider);
|
ok('맨 아래 가로선 제거', d.divider === '0px', d.divider);
|
||||||
ok('히어로 원본 사진', /assets\/mirror/.test(d.heroImg || ''), d.heroImg);
|
ok('히어로 원본 사진', /\/img\/mirror\//.test(d.heroImg || ''), d.heroImg);
|
||||||
ok('객실 사진 A12/B10', d.roomA === 12 && d.roomB === 10, `A ${d.roomA} · B ${d.roomB}`);
|
ok('객실 사진 A12/B10', d.roomA === 12 && d.roomB === 10, `A ${d.roomA} · B ${d.roomB}`);
|
||||||
ok('404 없음', failed.length === 0, failed.slice(0, 3).join(' / ') || '없음');
|
ok('404 없음', failed.length === 0, failed.slice(0, 3).join(' / ') || '없음');
|
||||||
ok('콘솔 오류 없음', errs.length === 0, errs.slice(0, 2).join(' / ') || '없음');
|
ok('콘솔 오류 없음', errs.length === 0, errs.slice(0, 2).join(' / ') || '없음');
|
||||||
|
|||||||
BIN
solution/site/scripts/mockup/img/mirror/07600bb67b91dd15.jpg
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
solution/site/scripts/mockup/img/mirror/0a1e0ff50ca7ee37.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
solution/site/scripts/mockup/img/mirror/12938240987c1f78.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
solution/site/scripts/mockup/img/mirror/149c18951bdcaff8.jpg
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
solution/site/scripts/mockup/img/mirror/1caf7fcac8ece19a.jpg
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
solution/site/scripts/mockup/img/mirror/1f35ef26139c6cb9.jpg
Normal file
|
After Width: | Height: | Size: 371 KiB |
BIN
solution/site/scripts/mockup/img/mirror/1f6dbdd78a3da2de.png
Normal file
|
After Width: | Height: | Size: 692 KiB |
BIN
solution/site/scripts/mockup/img/mirror/22af0bff45e5fce8.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
solution/site/scripts/mockup/img/mirror/2af9caa23a08a3ec.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
solution/site/scripts/mockup/img/mirror/2d94158a48480fcd.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
solution/site/scripts/mockup/img/mirror/3420ff7e357ab7ef.jpg
Normal file
|
After Width: | Height: | Size: 481 KiB |
BIN
solution/site/scripts/mockup/img/mirror/37c90494d28478e2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
solution/site/scripts/mockup/img/mirror/3a780a4a34df9289.jpg
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
solution/site/scripts/mockup/img/mirror/3dc384187a900e95.png
Normal file
|
After Width: | Height: | Size: 548 KiB |
BIN
solution/site/scripts/mockup/img/mirror/3e9e574d3b987c8d.jpg
Normal file
|
After Width: | Height: | Size: 494 KiB |
BIN
solution/site/scripts/mockup/img/mirror/43461e391f83b9e8.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
solution/site/scripts/mockup/img/mirror/4389ded2dee1ad98.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
solution/site/scripts/mockup/img/mirror/487643cdcfd19fdb.jpg
Normal file
|
After Width: | Height: | Size: 479 KiB |
BIN
solution/site/scripts/mockup/img/mirror/49558512d8d52e1a.jpg
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
solution/site/scripts/mockup/img/mirror/4b4086345c20b968.jpg
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
solution/site/scripts/mockup/img/mirror/4d71067dc7e3f671.jpg
Normal file
|
After Width: | Height: | Size: 309 KiB |
BIN
solution/site/scripts/mockup/img/mirror/4db345bd2cb4f0d7.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
solution/site/scripts/mockup/img/mirror/5000f0a4feb45788.jpg
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
solution/site/scripts/mockup/img/mirror/566bc326e8a58042.jpg
Normal file
|
After Width: | Height: | Size: 467 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6194651114d1e740.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
solution/site/scripts/mockup/img/mirror/669abc2b08640d3e.jpg
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6a0c445e6dd940af.jpg
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6a15360430298700.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
solution/site/scripts/mockup/img/mirror/6d0c447b6a0c32af.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6d59722cedddd566.jpg
Normal file
|
After Width: | Height: | Size: 648 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6ded14f21a17894b.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
solution/site/scripts/mockup/img/mirror/7095220ca156e25e.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
solution/site/scripts/mockup/img/mirror/7ba545fdbc8ad273.jpg
Normal file
|
After Width: | Height: | Size: 397 KiB |
BIN
solution/site/scripts/mockup/img/mirror/7ccd68de6b280bc2.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
solution/site/scripts/mockup/img/mirror/7f8f81d5eeb33f73.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
solution/site/scripts/mockup/img/mirror/8043bad52d95a84b.jpg
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
solution/site/scripts/mockup/img/mirror/82606e203454e3ff.jpg
Normal file
|
After Width: | Height: | Size: 346 KiB |
BIN
solution/site/scripts/mockup/img/mirror/881d1f7ff820942f.jpg
Normal file
|
After Width: | Height: | Size: 505 KiB |
BIN
solution/site/scripts/mockup/img/mirror/8e2ec99c433b6fe8.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
solution/site/scripts/mockup/img/mirror/8fc0a7522e3eb080.png
Normal file
|
After Width: | Height: | Size: 385 KiB |
BIN
solution/site/scripts/mockup/img/mirror/92f65116f3e58ab6.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
solution/site/scripts/mockup/img/mirror/9b073d4a1b5fb62c.jpg
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
solution/site/scripts/mockup/img/mirror/9b27266ddf8b8b70.jpg
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
solution/site/scripts/mockup/img/mirror/9c5594df6114561b.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
solution/site/scripts/mockup/img/mirror/a1b00af88aa06cd9.jpg
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
solution/site/scripts/mockup/img/mirror/a2344640498c5c86.png
Normal file
|
After Width: | Height: | Size: 352 KiB |
BIN
solution/site/scripts/mockup/img/mirror/aa69a633f8e3a631.jpg
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
solution/site/scripts/mockup/img/mirror/aaf32d2c8e5faffb.jpg
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
solution/site/scripts/mockup/img/mirror/af673cc75c0ed696.jpg
Normal file
|
After Width: | Height: | Size: 461 KiB |
BIN
solution/site/scripts/mockup/img/mirror/c8c6a295ae1c0810.jpg
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
solution/site/scripts/mockup/img/mirror/c971fb408ad897e7.jpg
Normal file
|
After Width: | Height: | Size: 270 KiB |
BIN
solution/site/scripts/mockup/img/mirror/caa5e076e7d56ec2.jpg
Normal file
|
After Width: | Height: | Size: 767 KiB |
BIN
solution/site/scripts/mockup/img/mirror/d2be5a053a944cfe.jpg
Normal file
|
After Width: | Height: | Size: 648 KiB |
BIN
solution/site/scripts/mockup/img/mirror/d364f605223524bd.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
solution/site/scripts/mockup/img/mirror/d5433fe48de3da05.jpg
Normal file
|
After Width: | Height: | Size: 486 KiB |
BIN
solution/site/scripts/mockup/img/mirror/d543dcde9f0eac6c.jpg
Normal file
|
After Width: | Height: | Size: 399 KiB |
BIN
solution/site/scripts/mockup/img/mirror/d8764592a03b0103.jpg
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
solution/site/scripts/mockup/img/mirror/d902ef61234bd897.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
solution/site/scripts/mockup/img/mirror/ddc9f804f4ef83b5.jpg
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
solution/site/scripts/mockup/img/mirror/e0ed8d1a27e09320.jpg
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
solution/site/scripts/mockup/img/mirror/f27b7440e2f1183f.jpg
Normal file
|
After Width: | Height: | Size: 485 KiB |
@ -260,6 +260,30 @@ block = ("\n <style id=\"w4d-css\">\n" + css + "\n </style>\n"
|
|||||||
html, n = re.subn(r"\n?\s*</body>", lambda _m: block + "</body>", html, count=1)
|
html, n = re.subn(r"\n?\s*</body>", lambda _m: block + "</body>", html, count=1)
|
||||||
assert n == 1, "</body> 를 못 찾았다"
|
assert n == 1, "</body> 를 못 찾았다"
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# 레포 안으로 끌어들이기 — 다른 사람 기계에서도 그대로 떠야 한다.
|
||||||
|
#
|
||||||
|
# ★ 왜 (2026-09-11, 사장님: "다른 환경에서 local/s/stay 치면 나오냐고")
|
||||||
|
# 구워진 index.html 은 미러 사진 61장(`/assets/mirror/*`)과 번들 css/js 를 **루트 절대경로**로
|
||||||
|
# 가리킨다. 그 파일들은 레포가 아니라 도커 볼륨에만 있다 — 레포만 받은 사람은 흰 화면을 본다.
|
||||||
|
# 번들은 더 나쁘다: 파일명이 콘텐츠 해시라 그 기계에서 구운 해시가 다르면 404 다.
|
||||||
|
# 그래서 둘 다 `img/mirror/` · `vendor/` 로 복사해 두고, 경로를 `/s/stay/…` 로 바꿔 박는다.
|
||||||
|
# 이러면 nginx 가 `/s/` 를 파일로 주기만 하면 되고 번들 해시와 무관해진다.
|
||||||
|
# (대신 렌더러를 고쳐도 이 목업은 안 따라간다 — 목업은 원래 재굽기 대상이 아니라 그게 맞다.)
|
||||||
|
missing = []
|
||||||
|
for name in sorted(set(re.findall(r"/assets/mirror/[0-9a-f]+\.\w+", html))):
|
||||||
|
if not (SP / "img" / "mirror" / name.rsplit("/", 1)[1]).exists():
|
||||||
|
missing.append(name)
|
||||||
|
for name in sorted(set(re.findall(r"/assets/index-[\w.-]+\.(?:css|js)", html))):
|
||||||
|
if not (SP / "vendor" / name.rsplit("/", 1)[1]).exists():
|
||||||
|
missing.append(name)
|
||||||
|
assert not missing, f"레포에 없는 자산 {len(missing)}개: {missing[:3]} … `vendor.sh` 를 돌려라"
|
||||||
|
|
||||||
|
html = re.sub(r"/assets/mirror/", "/s/stay/img/mirror/", html)
|
||||||
|
html = re.sub(r"/assets/(index-[\w.-]+\.(?:css|js))", r"/s/stay/vendor/\1", html)
|
||||||
|
left = re.findall(r"/assets/[^\"\\')\s]+", html)
|
||||||
|
assert not left, f"아직 /assets 를 가리키는 자리가 있다: {left[:5]}"
|
||||||
|
|
||||||
out = SP / "build"
|
out = SP / "build"
|
||||||
out.mkdir(exist_ok=True)
|
out.mkdir(exist_ok=True)
|
||||||
(out / "index.html").write_text(html, encoding="utf-8")
|
(out / "index.html").write_text(html, encoding="utf-8")
|
||||||
|
|||||||