Compare commits
2 Commits
a134805b3f
...
b86a055278
| Author | SHA1 | Date | |
|---|---|---|---|
| b86a055278 | |||
| ea1daae576 |
2
solution/site/scripts/mockup/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
build/
|
||||
stay-payload-new.json
|
||||
@ -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`** 를 돌린다. 안 돌리면 서버는 옛 프롬프트로 돈다.
|
||||
@ -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²) · 객실 시설 추가
|
||||
|
||||
**기타**
|
||||
- 오늘의 엽서에 지도 링크 추가
|
||||
- 이용 정보 맨 아래 가로선 제거
|
||||
@ -1,279 +0,0 @@
|
||||
# /s/stay 섹션별 — 목업은 무엇으로 만들었고, 제품은 무엇으로 만들어야 하나
|
||||
|
||||
작성 2026-09-11 · 대상 `https://web4ai.o2osolution.ai/s/stay` (스테이,머뭄 · 전북 군산)
|
||||
**읽는 사람**: 빌더·백엔드에서 이 시연본을 실제 생성 파이프라인으로 옮기는 사람.
|
||||
|
||||
시연본은 **payload 가 없는 목업**이다 — 프리렌더가 굽지 않고, `index.html` 한 장을
|
||||
손으로 조립했다(`patch_stay.py`). 그래서 "화면에 있는 것" 과 "제품이 만들 수 있는 것"이
|
||||
지금 **갈라져 있다.** 이 문서는 섹션마다 그 간격을 적는다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 한 줄 결론
|
||||
|
||||
22개 섹션 중 **16개는 이미 계약과 생성 경로가 있다.** 나머지 6개가 할 일이다:
|
||||
|
||||
| | 섹션 | 왜 |
|
||||
|---|---|---|
|
||||
| ① | **히어로 캐치프레이즈** | 계약에 없다. `narrative.tagline` 은 **문자열 하나**인데 목업은 100개를 돌린다 |
|
||||
| ② | **음악 플레이어(자작곡)** | 계약에 없다. `media` 에 오디오 종류가 없다 |
|
||||
| ③ | **추천 일정** | 계약·생성기 둘 다 있는데 **깊이가 다르다**. 서버판은 하루 4칸(관광 2·끼니 2)이고 체류·이동·입퇴실 개념이 없다 |
|
||||
| ④ | **인물·노래 사진** | 프롬프트가 `imageQuery` 만 받는다. 실제 사진을 찾아 붙이고 **라이선스를 표시하는** 경로가 없다 |
|
||||
| ⑤ | **상한(maxItems)** | songs 8·people 10 인데 목업은 25·57 이다 |
|
||||
| ⑥ | **객실 사진** | 목업은 야놀자 등록본에서 가져왔다. 수집 어댑터 정책([DECISIONS 1-1](../../../../docs/DECISIONS.md))에 걸리는지 결론이 필요하다 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 값이 화면까지 가는 길
|
||||
|
||||
```
|
||||
수집·생성(백엔드) → 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` 한 곳이다.
|
||||
- **프롬프트 계약**은 `shared/src/lib/section-prompts.ts` 한 곳이고,
|
||||
백엔드는 `npm run export:prompts` 산출물(`backend/services/prompts/section_prompts.json`)을 읽는다.
|
||||
**파이썬 쪽을 손으로 고치지 않는다.**
|
||||
- 섹션 목록·기본 on/off 는 업종별로 `services/site_payload.py:155` 부근에 있다.
|
||||
|
||||
payload 안에서 섹션이 값을 받는 자리는 셋뿐이다.
|
||||
|
||||
| 자리 | 무엇이 들어가나 | 예 |
|
||||
|---|---|---|
|
||||
| `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개 — 한눈에
|
||||
|
||||
`on` 은 이 목업에서 켜져 있는지다. `story` 안의 6개는 탭이라 최상단은 꺼져 있다.
|
||||
|
||||
| # | id | 이름 | on | 값이 오는 자리 | 계약 | 프롬프트 | 목업에서 한 일 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | `hero` | 히어로 | ● | `narrative` + 대표 사진 | 있음 | **없음** | 캐치프레이즈 100개를 주입분이 돌림 |
|
||||
| 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`(오늘의 한 장)는 프롬프트에는 있는데 **이 목업 섹션 목록에 없다.**
|
||||
새 업장에서 켜려면 섹션 목록에 자리를 먼저 만들어야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 계약에 없는 것 — 새로 만들어야 하는 셋
|
||||
|
||||
### ① 히어로 캐치프레이즈 — 계약을 늘려야 한다
|
||||
|
||||
**지금 계약**: `Narrative`(`site-payload.ts:316`)는 `heroHeadline` `heroSubline` `tagline`
|
||||
`about[]` `summary` 뿐이고 전부 **문자열 하나**다. 히어로는
|
||||
`narrative.tagline ?? heroSubline ?? summary` 를 그대로 찍는다(`HeroPension.tsx:125`).
|
||||
|
||||
**목업이 한 것**: `narrative.catchphrases = {version:1, items:[…100]}` 를 payload 에
|
||||
끼워 넣고, 주입분이 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곡이면 '다방'이 아니라 목록이다 |
|
||||
| people | 10 | 57 | 위키백과 '군산시 출신' 분류만으로 57명이 확인된다 |
|
||||
| chronicle | 14 | 7 | 상한은 맞는데 **채우는 쪽이 모자라다** |
|
||||
| postcard | 12 | 4 | 같다 |
|
||||
|
||||
상한을 올릴 때 **한 번에 다 받지 않는다.** 목업은 곡을 8개씩 세 번 나눠 받았다 —
|
||||
한 번에 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` |
|
||||
| 렌더 단계 | 실제 브라우저로 열어 숫자를 센다. 34종 | `audit-all.mjs` |
|
||||
| 카로셀 | 레일 13개 × 데스크톱·모바일 | `rails-test.mjs` |
|
||||
|
||||
★ 목업에서 같은 실수가 반복된 원인은 "검사를 안 했다"가 아니라 **"검사가 빌드 밖에 있었다"** 였다.
|
||||
그때그때 쓴 일회용 스크립트로 돌리니, 뼈대를 고칠 때마다 이번에 안 본 규칙이 생겼다.
|
||||
경위는 [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) 2장.
|
||||
|
||||
---
|
||||
|
||||
## 6. 같이 읽을 것
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 목업을 고치고 배포하는 절차 · 밟으면 조용히 틀리는 자리 | [README.md](README.md) |
|
||||
| 실제로 쓴 프롬프트 전문과 나눠 받는 법 | [PROMPTS.md](PROMPTS.md) |
|
||||
| 화면에 나가는 텍스트 전문 | [TEXT.md](TEXT.md) |
|
||||
| 일정이 왜 틀렸고 무엇을 고쳤나 | [REVIEW-2026-09-11.md](REVIEW-2026-09-11.md) |
|
||||
| 값이 DB 에서 페이지까지 가는 길 | [docs/DATA_MODEL.md](../../../../docs/DATA_MODEL.md) |
|
||||
| 미결 사항 · 수집 어댑터 정책 | [docs/DECISIONS.md](../../../../docs/DECISIONS.md) |
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -85,7 +85,7 @@ ok('자작곡 5곡', d.ownSongs === 5, `${d.ownSongs}곡`);
|
||||
ok('빨간 줄 없음', d.redRule === 0, `${d.redRule}개`);
|
||||
ok('바비큐 항목 유지', d.bbqRow, d.bbqRow ? '있음' : '사라짐');
|
||||
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('404 없음', failed.length === 0, failed.slice(0, 3).join(' / ') || '없음');
|
||||
ok('콘솔 오류 없음', errs.length === 0, errs.slice(0, 2).join(' / ') || '없음');
|
||||
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
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 |
@ -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();
|
||||
@ -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)
|
||||
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.mkdir(exist_ok=True)
|
||||
(out / "index.html").write_text(html, encoding="utf-8")
|
||||
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
@ -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();
|
||||
298
solution/site/scripts/mockup/vendor/index-DUeGvBPS.js
vendored
Normal file
1
solution/site/scripts/mockup/vendor/index-k3f3eOzb.css
vendored
Normal file
@ -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();
|
||||