Compare commits
2 Commits
4d5a77453b
...
f0b5c444bc
| Author | SHA1 | Date | |
|---|---|---|---|
| f0b5c444bc | |||
| 43ba33dd65 |
345
solution/site/scripts/mockup/PROMPTS.md
Normal file
@ -0,0 +1,345 @@
|
||||
# `/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`** 를 돌린다. 안 돌리면 서버는 옛 프롬프트로 돈다.
|
||||
338
solution/site/scripts/mockup/README.md
Normal file
@ -0,0 +1,338 @@
|
||||
# `/s/stay` 시연본을 만드는 스크립트 — 인수인계 문서
|
||||
|
||||
`https://web4ai.o2osolution.ai/s/stay` (로컬 `http://localhost/s/stay`) 한 페이지를 만드는
|
||||
도구 모음이다. **고칠 자리는 전부 이 디렉토리 안에 있다.**
|
||||
|
||||
> **이어받는 사람에게 — 먼저 읽을 세 줄**
|
||||
> 1. 이 사이트는 **payload 가 없는 목업**이다. 백엔드·DB·프리렌더를 건드리지 않는다.
|
||||
> 고치는 것은 구워진 `index.html` 하나이고, 그 파일이 유일본이다.
|
||||
> 2. **렌더러(`solution/site/src`)를 고치지 않는다.** 고치면 번들 해시가 바뀌어
|
||||
> 전 사이트 재굽기 + 전체 재업로드가 따라온다. 목업에만 필요한 것은 `inject.css`/`inject.js` 로 덮는다.
|
||||
> 3. 무엇을 고치든 **`node audit-all.mjs` 가 33개 항목 전부 통과**해야 끝난 것이다.
|
||||
> "코드에서 빠졌으니 됐다" 로 끝내면 안 된다 — 실제로 그렇게 틀린 적이 있다(§7).
|
||||
|
||||
---
|
||||
|
||||
## 0. 먼저 알아야 할 것 — 왜 스크립트인가
|
||||
|
||||
`/s/stay` 는 **payload 가 없는 목업**이다(`stay2` · `stay3` 도 같다).
|
||||
프리렌더는 payload 를 받은 사이트만 굽기 때문에, 이 사이트는 **재굽기 대상이 아니다.**
|
||||
고칠 자리는 이미 구워져 있는 `index.html` 하나이고, 그 파일이 유일본이다.
|
||||
|
||||
그래서 손으로 열어 고치지 않는다. 이 스크립트들이 **원본 + 바꿀 것 → 새 index.html** 을 만든다.
|
||||
두 번째로 고칠 때 "무엇을 왜 바꿨는지" 가 파일이 아니라 여기 남아야 하기 때문이다.
|
||||
|
||||
```
|
||||
stay-payload.json 원본 payload (건드리지 않는다 · 되돌릴 때 기준)
|
||||
orig/index.html 원본 HTML (건드리지 않는다)
|
||||
│
|
||||
├─ build_itinerary.py 여행 일정을 계산해 넣는다
|
||||
├─ build_story.py 가요 다방 · 인물 열전을 넣는다
|
||||
└─ patch_stay.py 캐치프레이즈 · 객실 사진 · 자작곡 · 주입 CSS/JS 를 넣고 HTML 조립
|
||||
│
|
||||
▼
|
||||
build/index.html ← 배포하는 파일
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 한 번에 굽기
|
||||
|
||||
```bash
|
||||
cd solution/site/scripts/mockup
|
||||
python3 build_itinerary.py && python3 build_story.py && python3 patch_stay.py
|
||||
```
|
||||
|
||||
**순서를 지킨다.** `build_itinerary.py` 가 `stay-payload.json`(원본)을 읽어
|
||||
`stay-payload-new.json` 을 만들고, 뒤 둘이 그 파일을 이어서 고친다.
|
||||
순서를 바꾸면 앞 단계 결과가 지워진다.
|
||||
|
||||
로컬 반영:
|
||||
|
||||
```bash
|
||||
docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html
|
||||
```
|
||||
|
||||
검사:
|
||||
|
||||
```bash
|
||||
node audit-all.mjs # 33개 항목 전수 점검 (로컬)
|
||||
node audit-all.mjs https://web4ai.o2osolution.ai/s/stay # 킹서버
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 무엇을 어디서 고치나
|
||||
|
||||
### 히어로 문구 (캐치프레이즈 100개) → `patch_stay.py`
|
||||
|
||||
화면 맨 위, 상호 아래에서 **7초마다 단어 단위로 바뀌는 한 줄**이다.
|
||||
|
||||
| 자리 | 줄 | 무엇 |
|
||||
|---|---|---|
|
||||
| `GENERAL` | `patch_stay.py:20` | 일반 40개 — 언제 나와도 되는 문구 |
|
||||
| `SEASON` | `patch_stay.py:62` | 계절별 24개 — 봄/여름/가을/겨울 각 6개 |
|
||||
| `MONTH` | `patch_stay.py:72` | 월별 24개 — 1~12월 각 2개 |
|
||||
| `WEATHER` | `patch_stay.py:87` | 날씨별 12개 — 맑음/구름많음/흐림/비/눈 |
|
||||
|
||||
문장만 더하거나 지우면 된다. 개수 제한은 없다.
|
||||
|
||||
**나오는 순서**는 `inject.js` 의 `buildCycle()` 이 정한다 —
|
||||
`일반 → 계절 → 일반 → 월 → 일반 → 날씨` 한 바퀴를 만들고 그 바퀴를 돈다.
|
||||
일반은 자루에서 뽑아 쓰고 비면 다시 채운다(같은 문장이 연달아 나오지 않게).
|
||||
|
||||
- **계절 구분은 3개월 단위**다: 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울
|
||||
(`inject.js` `SEASON_OF_MONTH`)
|
||||
- **날씨는 실시간**이다. `/v1/local/weather` 를 10분마다 읽어 분류한다.
|
||||
분류 기준은 렌더러(`site/src/lib/use-live-weather.ts`)와 같은 값을 쓴다 — 갈리면
|
||||
화면의 날씨와 문구가 어긋난다.
|
||||
- 못 받아 오면 날씨 문구는 **순환에서 빠진다**(거짓말을 하지 않는다).
|
||||
- 구워진 첫 문장은 5초 두고 시작한다. 들어오자마자 바뀌면 읽던 것을 놓친다.
|
||||
- `prefers-reduced-motion` 이면 애니메이션 없이 글자만 바뀐다.
|
||||
|
||||
### 여행 일정 → `build_itinerary.py`
|
||||
|
||||
**사람은 "어느 자리에 어느 장소" 만 고르고, 시각은 스크립트가 계산한다.**
|
||||
|
||||
| 자리 | 줄 | 무엇 |
|
||||
|---|---|---|
|
||||
| `PLACES` | `:40` | **장소 대장** — 좌표 · 지도 검색어 · 체류시간(최소/기본/최대) · **거기서 무엇을 하는지** |
|
||||
| `STAY_ACTION` | `:108` | 머뭄 칸의 행위 — 체크인 · 아침 · 아침(퇴실) · 쉼 · 쉬는 날 · 머뭄으로 · 마무리 |
|
||||
| `WINDOW` | `:156` | **시간대가 정해진 자리** — 입실 15:00+ · 점심 11:30~13:30 · 저녁 17:30~20:00 … |
|
||||
| `SKELETON` | `:182` | **하루 뼈대** — 자리 이름만 나열. `@` 가 붙은 것은 머뭄 |
|
||||
| `THEMES` | `:368` | **테마 21개** — 이름 · 기간 · 누구에게 · 왜 · [(뼈대, [장소…])] |
|
||||
| `PACING` | `:470` | 테마별 시작 시각과 체류 배율(쉬러 오면 1.25배, 사진 찍으면 0.8배) |
|
||||
|
||||
**새 장소를 넣으려면** `PLACES` 에 한 줄 더한다. 좌표는 카카오 로컬 검색에서 받은 실제 값이어야
|
||||
한다(지어내지 않는다). 그다음 `THEMES` 의 원하는 자리에 이름을 적는다.
|
||||
|
||||
**새 테마를 넣으려면** `THEMES` 에 한 덩이, `PACING` 에 한 줄 더한다.
|
||||
뼈대의 `@` 없는 자리 수와 장소 개수가 같아야 한다 — 틀리면 빌드가 멈추고 알려 준다.
|
||||
|
||||
**계산되는 것 (손으로 적지 않는다)**
|
||||
- **이동시간** — 좌표에서. 1.5km 까지 도보 4km/h, 그 위는 차 25km/h + 주차 5분, 최소 3분
|
||||
- **도착·출발 시각** — 시작 시각부터 누적
|
||||
- **끼니 시각 맞추기** — 창을 벗어나면 **앞 칸 체류를 범위 안에서 늘이고 줄인다**.
|
||||
(사람이 실제로 그렇게 한다 — 밥 때가 이르면 박물관을 조금 더 본다.)
|
||||
그래도 안 맞으면 시작 시각을 10분씩 옮긴다. 그래도 안 되면 **그 조합을 만들지 않는다.**
|
||||
|
||||
### 가요 다방 · 인물 열전 → `build_story.py`
|
||||
|
||||
| 자리 | 줄 | 무엇 |
|
||||
|---|---|---|
|
||||
| `SONGS` | `:28` | 군산을 노래한 25곡 — 곡명·가수·작사·작곡·연도·라벨색·한 줄 |
|
||||
| `PEOPLE` | `:109` | 인물 57명 — 위키 문서 제목 · 표시 이름 · 연도 · 하는 일 · 한 줄 |
|
||||
|
||||
- 노래 출처는 '음악의 오솔길' 군산 지역노래 정리. **연도·작사·작곡을 모르면 비운다**
|
||||
("미상"이라고 적지 않는다 — 렌더러가 없는 칸을 안 그린다).
|
||||
- 듣기 링크는 **유튜브 검색 주소**다. 영상 ID 를 박으면 틀린 영상이 붙을 수 있고
|
||||
한 곡씩 확인하지 않았다. 검색은 틀릴 수가 없다.
|
||||
- 인물 사진은 두 곳에서 온다.
|
||||
① `people-photos.json` — 위키백과 **문서 대표 사진**(pageimages) 중 자유 라이선스가
|
||||
확인된 17장(CC BY / CC BY-SA / KOGL / 공용). CC BY-SA 는 저작자 표시가
|
||||
**라이선스 조건**이라 화면에 그대로 찍는다 — 지우면 침해다.
|
||||
② 원본 payload 에 있던 사진 — 위키에 사진이 없는 사람(채만식)은 이 사이트가 **원래 쓰던**
|
||||
미러 사진으로 되돌린다. 새로 가져오는 게 아니라 자리로 돌리는 것이다.
|
||||
- ★ **이름만으로 커먼즈를 검색해 사진을 붙이지 않는다.** 동명이인이 잡힌다 —
|
||||
실측(2026-09-11): 이수현(독립운동가)에 걸그룹 멤버, 은성수(금융위원장)에 축구선수,
|
||||
박성현(양궁)에 골퍼, 전진(비전향 장기수)에 아이돌, 이길여에 건물 사진이 왔다.
|
||||
**틀린 사진은 없는 것만 못하다.** 그 사람의 문서가 대표로 쓰는 사진만 신뢰한다.
|
||||
- 숙소가 만든 곡은 **이 섹션에 넣지 않는다.** 도시의 노래를 모으는 자리다.
|
||||
|
||||
### 객실 사진 · 자작곡 · 대표 사진 → `patch_stay.py`
|
||||
|
||||
| 자리 | 줄 | 무엇 |
|
||||
|---|---|---|
|
||||
| `OWN_SONGS` | `:109` | 머뭄이 만든 5곡 — 플레이어만 읽는다(`narrative.ownSongs`) |
|
||||
| `ROOM_PHOTOS` | `:142` | 객실 사진 배정 — A동 12 · B동 10 · 공용 |
|
||||
| `HERO_PHOTO` | `:179` | 대표 사진. `None` 이면 **원본 사진이 계속 대표**(지금 그렇다) |
|
||||
|
||||
★ **객실 A/B 배정은 눈으로 하지 않는다.** 야놀자 등록본의 Next.js 데이터에
|
||||
`roomTypes[].photos` 가 객실별로 들어 있고, 그걸 뽑아 둔 것이 `rooms.json` 이다.
|
||||
눈으로 갈랐을 때 11/5 가 나왔고 실제는 12/10 이었다.
|
||||
|
||||
★ 히어로와 소개 사진은 **원본(네이버 예약본 미러)** 을 쓴다.
|
||||
히어로는 `isPrimary`, 소개는 **대표가 아닌 첫 사진**을 쓰는 구조라
|
||||
(`site/src/sections/HeroSection.tsx:40` · `AboutSection.tsx:23`),
|
||||
원본 사진을 목록 앞에 두고 등록본 사진을 뒤에 붙인다. **순서가 곧 그 두 자리다.**
|
||||
|
||||
### 화면에 덧대는 것 → `inject.css` · `inject.js`
|
||||
|
||||
렌더러(`solution/site/src`)를 고치면 **다른 사이트까지 바뀌고 전체 재굽기가 따라온다.**
|
||||
그래서 이 목업에만 필요한 것은 `index.html` 안에 주입한다.
|
||||
|
||||
`inject.js` 가 하는 일 다섯:
|
||||
|
||||
1. **캐치프레이즈 순환** — React 가 그린 `<p>` 를 찾아 단어 단위로 갈아 끼운다
|
||||
2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡, 랜덤 재생
|
||||
3. **링크 걸기** — 일정 정거장 187개 · 엽서 4개를 네이버 지도로
|
||||
4. **사진 저작자 표시** — 이 목업이 물고 있는 번들이 옛 판이라 `imageCredit` 을 안 그린다
|
||||
5. **카로셀 제어** — 만지는 동안 멈추고, 직접 민 레일은 다시 안 돈다
|
||||
|
||||
★ **카로셀 (`tameRails`) 은 렌더러 정책을 덮는 것이다.**
|
||||
렌더러의 자동 넘김(`site/src/lib/ui/use-rail-autoplay.ts`)은 4.5초 **고정 타이머**이고,
|
||||
정지는 `held` 카운터 하나로 센다 — 레일 위 `pointerdown` 에 올리고 window `pointerup`·
|
||||
`pointercancel` 에 내린다. 주입분은 타이머를 건드리지 않고 **그 카운터만** 쓴다
|
||||
(`pointerdown` 을 `button: 2` 로 쏜다 — embla 는 주 버튼이 아닌 것을 드래그로 치지 않는다).
|
||||
구현이 둘(embla · 스크롤 상자)이어도 카운터는 하나라 한 번에 걸린다.
|
||||
|
||||
규칙 셋:
|
||||
|
||||
| | 언제 | 얼마나 |
|
||||
|---|---|---|
|
||||
| 쿨타임 | 무엇이든 만지면(세로 스크롤·휠·키보드 포함) | 7초 |
|
||||
| 손이 닿아 있는 동안 | 손가락·마우스 버튼이 내려가 있는 내내 | 뗄 때까지 |
|
||||
| 영구 정지 | **레일이 실제로 가로로 움직였을 때** | 그 레일은 끝 |
|
||||
|
||||
★ **손가락 수로 센다 — pointer 로 세면 안 된다** (2026-09-11).
|
||||
브라우저는 터치가 페이지 스크롤로 넘어가는 순간 **손가락이 아직 닿아 있는데
|
||||
`pointercancel` 을 쏜다.** 훅의 정지가 거기서 풀려, 손가락 밑에서 카드가 넘어간다.
|
||||
실측: touchstart 0.0s → pointercancel 7.5s → 8.4s 에 넘어감. 그래서 `event.touches.length`
|
||||
로 센다 — 손이 떨어져야 0 이 된다.
|
||||
|
||||
★ **"만졌다" 와 "밀었다" 를 가른다** (2026-09-11).
|
||||
전에는 레일 위 pointerdown·wheel 이면 곧바로 영구 정지였다. 그런데 일정 카로셀은 화면
|
||||
가운데를 가득 채워서 페이지를 세로로 내릴 때 손가락·커서가 거의 언제나 그 위를 지난다 —
|
||||
**한 번 스크롤하면 그 레일이 영영 멈췄다**(실측: 세로 휠 한 번 → 20초 0px). 지금은 제스처
|
||||
시작 때 위치를 적어 두고 손을 뗀 뒤(관성 350ms) 다시 재서, 8px 넘게 달라졌을 때만 영구 정지다.
|
||||
|
||||
★ **호버 정지는 렌더러 것이고 그대로 둔다** — 커서를 얹으면 "읽는 중"이 맞다(사장님 확인).
|
||||
|
||||
검증: `node rails-test.mjs` — 레일 13개를 데스크톱·모바일에서 ①가만히 ②만지는 중
|
||||
③민 뒤로 각각 잰다.
|
||||
→ **운영 사이트에는 이 문제들이 그대로 있다.** 훅을 고치는 것은 별도 작업이다(§8).
|
||||
|
||||
★ **React 가 다시 그리면 사라진다.** 그래서
|
||||
- 플레이어 목록·패널은 `#root` **밖**(body 직속)에 둔다
|
||||
- 헤더에 꽂는 단추는 `MutationObserver` 로 감시해 다시 꽂는다
|
||||
- 캐치프레이즈는 노드를 들고 있지 않고 **매 순환마다 다시 찾는다**
|
||||
|
||||
★ **`window.load` 를 기다리지 않는다.** 이 페이지는 경로 지도 때문에 OSM 타일 `<img>` 가
|
||||
140장이라 `load` 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 규칙 검사 — 어기면 빌드가 멈춘다
|
||||
|
||||
`build_itinerary.py` 의 `audit()`(`:526`)이 매 실행 검사한다.
|
||||
하나라도 어기면 **payload 파일을 쓰지 않고** 위반 목록을 찍고 멈춘다.
|
||||
|
||||
| | 규칙 | 이 규칙이 없어서 났던 일 |
|
||||
|---|---|---|
|
||||
| 1 | 하루의 첫 칸·마지막 칸은 스테이 머뭄 | 출발지가 화면에서 사라졌다 |
|
||||
| 2 | **같은 자리가 연달아 서지 않는다** | `아침 · 머뭄` 다음이 `체크아웃 · 머뭄` |
|
||||
| 3 | 가운데 머뭄 칸은 '쉼'·'쉬는 날' 뿐 | 마지막 날 머뭄이 3번 |
|
||||
| 4 | 이동시간 = 좌표 계산값 | 담 하나 건넌 곳 8분, 45분 걸리는 섬 10분 |
|
||||
| 5 | 입실·점심·늦은 점심·저녁·밤 산책 시간대 | 점심 09:40, 저녁 13:09 |
|
||||
| 6 | 퇴실 11:00 전 | 체크아웃 11:35 |
|
||||
| 7 | 21:00 초과 금지 | 렌더러가 그 칸을 **조용히 버린다** |
|
||||
| 8 | 끼니 자리에는 먹는 곳만 | |
|
||||
| 9 | 같은 날 같은 식당 두 번 금지 | |
|
||||
| 10 | 한 테마 안에서 같은 곳 반복 금지 | 같은 테마에 한일옥이 두 번 |
|
||||
| 11 | "비가 와도 되는" 테마는 실내만 | 비 코스에 야외 두 곳 |
|
||||
| 12 | "걸어서만/차 없이" 테마에 차 필요한 곳 금지 | |
|
||||
| 13 | 1박 2일 = 2일, 2박 3일 = 3일 | |
|
||||
| 14 | 아이 동반 테마에 술집 금지 | |
|
||||
|
||||
★ **왜 빌드 안인가** — 전에는 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다.
|
||||
그래서 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생겼고 사장님이 화면에서 먼저 찾으셨다.
|
||||
**검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다.**
|
||||
|
||||
`audit-all.mjs` 는 그다음 단계다 — 구워진 HTML 을 **실제 브라우저로 열어**
|
||||
33개 항목을 센다(캐치프레이즈 개수, 링크 개수, 사진 개수, 모바일 레이아웃, 404, 콘솔 오류…).
|
||||
|
||||
---
|
||||
|
||||
## 4. 배포
|
||||
|
||||
### 로컬
|
||||
|
||||
```bash
|
||||
docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html
|
||||
```
|
||||
|
||||
`/s/` 는 **5분 캐시**(`max-age=300`)다 — 확인할 때 ⌘⇧R.
|
||||
|
||||
### 킹서버
|
||||
|
||||
```bash
|
||||
tar czf /tmp/stay-bundle.tgz -C . build/index.html img audio
|
||||
scp -C /tmp/stay-bundle.tgz King_admin:~/data2/o2o-site-AEO/tmp-stay-deploy/
|
||||
ssh King_admin "cd ~/data2/o2o-site-AEO/tmp-stay-deploy && rm -rf build img audio && tar xzf stay-bundle.tgz
|
||||
C=o2o-web4ai-solution-prerender
|
||||
docker exec \$C mkdir -p /app/solution/site/out/s/stay/img/people /app/solution/site/out/s/stay/audio
|
||||
docker cp build/index.html \$C:/app/solution/site/out/s/stay/index.html
|
||||
for f in img/*.jpg; do docker cp \"\$f\" \$C:/app/solution/site/out/s/stay/img/ ; done
|
||||
for f in img/people/*; do docker cp \"\$f\" \$C:/app/solution/site/out/s/stay/img/people/ ; done
|
||||
for f in audio/*.mp3; do docker cp \"\$f\" \$C:/app/solution/site/out/s/stay/audio/ ; done"
|
||||
```
|
||||
|
||||
확인: `md5 -q build/index.html` 과
|
||||
`ssh King_admin "docker exec o2o-web4ai-solution-site md5sum /srv/sites/s/stay/index.html"` 이 같아야 한다.
|
||||
그다음 `node audit-all.mjs https://web4ai.o2osolution.ai/s/stay`.
|
||||
|
||||
되돌리기: 컨테이너 안에 `index.html.bak-20260910` 이 있다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 밟으면 조용히 틀리는 자리
|
||||
|
||||
- **`out/assets` 에 파일을 넣지 않는다.** 거기는 프리렌더가 관리한다(해시 번들 대장 ·
|
||||
30일 보관). 손으로 넣은 사진·음원은 `/s/stay/img/` · `/s/stay/audio/` 에 둔다 —
|
||||
nginx 의 `^~ /s/` 가 그대로 서빙하고(Range 206 확인) 정리 대상이 아니다.
|
||||
- **마크업을 고쳐 지우지 않는다.** React 가 payload 로 다시 그리면서 되돌린다.
|
||||
화면에서 없애야 하면 **주입 CSS** 로 끈다(이용 정보 맨 아래 가로선이 그 경우다).
|
||||
- **렌더러가 21시를 넘기는 칸을 조용히 버린다**(`shared/section-data.ts` `PLAN_ENDS_BY`).
|
||||
적어 놓고 화면에 안 나오는 칸이 생긴다 — 규칙 7이 이걸 막는다.
|
||||
- **`imageCredit` 은 문자열이다**(`site/src/sections/items/common.tsx:206`).
|
||||
객체를 넣으면 조용히 안 나오고, 그건 라이선스 위반이 된다.
|
||||
- **자동 넘김을 끄려고 렌더러를 고치지 않는다.** 번들 해시가 바뀌면 이 목업은 옛 해시를
|
||||
가리킨 채 남고(보관 기간 안에는 살아 있다), 다른 사이트는 전부 재굽기 대상이 된다.
|
||||
- **크롤러가 보는 HTML 은 옛 내용이다.** 이 페이지는 payload 로 하이드레이션해서 화면을
|
||||
바꾸는 것이라, SSR 마크업과 `llms.txt` 는 구운 날 그대로다. 시연에는 문제없지만
|
||||
검색·AI 에 인용되는 값은 아니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 남아 있는 일 · 넘기는 판단
|
||||
|
||||
| | 상태 |
|
||||
|---|---|
|
||||
| 하루에 숙소가 3번 서는 날(`아침 → … → 쉼 → 저녁 → 복귀`) | **사장님 컨펌 대기.** 점심~저녁 4시간을 바깥 두 곳으로 못 채워 넣은 칸이다. 빼려면 오후 정거장을 하나 더 넣어야 한다 |
|
||||
| 가요 다방 25곡 (목표 50) | 출처에서 확인되는 군산 곡이 거기까지다. 더 채우려면 **지어내야 한다** — 하지 않는다 |
|
||||
| 인물 사진 18/57 | 위키 문서에 사진이 있는 사람이 18명뿐이다. 동명이인 함정 때문에 이름 검색으로 늘리지 않는다 |
|
||||
| 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 `use-rail-autoplay.ts` 에 "손님이 직접 밀면 타이머를 끝낸다" 를 넣고 **전체 재굽기 + republish_all** |
|
||||
| `og:image` | 구운 head 에 옛 주소가 남아 있다. 화면은 새 사진, 카톡 공유 썸네일만 옛것 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 이 목업을 고치다 실제로 틀렸던 방식 — 되풀이하지 말 것
|
||||
|
||||
| 한 짓 | 어떻게 틀렸나 | 대신 할 것 |
|
||||
|---|---|---|
|
||||
| 코드에서 빠진 것만 보고 "고쳤다" 고 보고 | 화면에는 그대로 있었다 | `node audit-all.mjs` 로 **브라우저에서** 확인하고 숫자를 적는다 |
|
||||
| 지시받은 한 줄만 고침 | "1번은 숙소" 를 넣으면서 그 하루 전체를 안 읽어 숙소가 3번 서고 두 칸이 연달아 섰다 | 한 곳을 고치면 **같은 종류를 전수 검사**한다 |
|
||||
| 규칙 검사를 매번 일회용 스크립트로 | 뼈대를 고칠 때마다 "이번에 안 본 규칙" 이 생겼다 | 검사를 **빌드 안**에 둔다(`build_itinerary.py` `audit()`) |
|
||||
| 사진을 눈으로 보고 A/B 배정 | 11/5 가 나왔는데 실제는 12/10 이었다 | 등록본 데이터(`rooms.json`)를 쓴다 |
|
||||
| 마크업을 고쳐서 요소 제거 | 하이드레이션이 되돌렸다 | 주입 CSS 로 끈다 |
|
||||
| 이름으로 사진 검색 | 동명이인이 붙었다 | 그 사람 문서의 대표 사진만 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 파일 목록
|
||||
|
||||
| 파일 | 무엇 |
|
||||
|---|---|
|
||||
| `build_itinerary.py` | 여행 일정 계산 + 규칙 14종 검사 |
|
||||
| `build_story.py` | 가요 다방 · 인물 열전 |
|
||||
| `patch_stay.py` | 캐치프레이즈 · 객실 · 자작곡 · HTML 조립 |
|
||||
| `inject.css` · `inject.js` | 화면에 덧대는 것(플레이어 · 문구 순환 · 링크 · 크레딧) |
|
||||
| `audit-all.mjs` | 브라우저로 33개 항목 전수 점검 |
|
||||
| `verify.mjs` · `player.mjs` · `check2.mjs` · `kingcheck.mjs` · `rail.mjs` · `arrow.mjs` | 부분 점검 |
|
||||
| `stay-payload.json` · `orig/index.html` | **원본. 건드리지 않는다** |
|
||||
| `stay-payload-new.json` | 중간 산출물 |
|
||||
| `build/index.html` | 배포하는 파일 |
|
||||
| `img/` (28장) · `img/people/` (17장) · `audio/` (5곡) | 같이 올리는 자산 |
|
||||
| `rooms.json` · `people-photos.json` · `people-raw.json` · `coords.json` | 외부에서 뽑아 둔 원자료 |
|
||||
| `REVIEW-2026-09-11.md` | 스케줄 오류 보고서(사장님 보고용) |
|
||||
| `PROMPTS.md` | 이 콘텐츠를 만드는 프롬프트 — 제품으로 옮길 자리까지 |
|
||||
176
solution/site/scripts/mockup/REVIEW-2026-09-11.md
Normal file
@ -0,0 +1,176 @@
|
||||
# /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²) · 객실 시설 추가
|
||||
|
||||
**기타**
|
||||
- 오늘의 엽서에 지도 링크 추가
|
||||
- 이용 정보 맨 아래 가로선 제거
|
||||
301
solution/site/scripts/mockup/TEXT.md
Normal file
@ -0,0 +1,301 @@
|
||||
# /s/stay 시연본 — 들어간 문구 전부
|
||||
|
||||
`python3 dump_text.py` 산출물. **고치는 자리는 스크립트다** — 이 파일을 고쳐도 반영되지 않는다.
|
||||
|
||||
## 1. 히어로 문구 100개 — `patch_stay.py`
|
||||
|
||||
상호 아래 한 줄. 7초마다 `일반 → 계절 → 일반 → 월 → 일반 → 날씨` 순으로 바뀐다.
|
||||
|
||||
### 일반 40개 (`patch_stay.py` `GENERAL`)
|
||||
|
||||
1. 백 년 된 집에서 하룻밤
|
||||
2. 담 너머는 히로쓰 가옥입니다
|
||||
3. 독채 두 동, 마당은 각자 씁니다
|
||||
4. 기와 아래에서 아침을 맞습니다
|
||||
5. 새소리로 눈이 떠지는 집
|
||||
6. 골목이 조용해지는 시간에 도착하세요
|
||||
7. 침구는 매일 새것처럼 나갑니다
|
||||
8. 마당 평상에 앉으면 항구 소리가 들립니다
|
||||
9. 1920년대에 지은 집, 지금 쓰는 집
|
||||
10. 대문을 닫으면 골목이 멀어집니다
|
||||
11. 창고를 고친 카페 공간이 딸려 있습니다
|
||||
12. 밤에는 정원 석등만 켜 둡니다
|
||||
13. 초원사진관까지 걸어서 3분
|
||||
14. 원도심 한가운데, 차는 세워 두세요
|
||||
15. A동은 일본식, B동은 모던입니다
|
||||
16. 네 사람까지 묶는 독채입니다
|
||||
17. 오후 세 시부터 이 집은 손님 것입니다
|
||||
18. 이 골목에서는 서두를 일이 없습니다
|
||||
19. 백 년 된 기와가 비를 받습니다
|
||||
20. 여행이라기보다 하루의 이사입니다
|
||||
21. 군산에서 가장 조용한 담 안쪽
|
||||
22. 이 집의 시간은 조금 느립니다
|
||||
23. 마당, 정원, 그리고 평상 하나
|
||||
24. 짐을 풀면 골목부터 걸어 보세요
|
||||
25. 창을 열면 옆집 기와가 보입니다
|
||||
26. 담배 냄새가 없는 집입니다
|
||||
27. 이불에서 볕 냄새가 납니다
|
||||
28. 두 동 모두 남의 인기척이 없습니다
|
||||
29. 내항까지 걸어 십 분
|
||||
30. 오래된 것을 고쳐 쓰는 집
|
||||
31. 부엌이 있어 아침은 지어 드셔도 됩니다
|
||||
32. 밤에는 골목 등만 남습니다
|
||||
33. 백 년 된 집이 아직 손님을 받습니다
|
||||
34. 개항장 골목의 끝집입니다
|
||||
35. 문패보다 기와를 보고 찾아오세요
|
||||
36. 걸어서 도는 여행에 알맞은 자리
|
||||
37. 마루에 앉는 시간이 일정의 절반입니다
|
||||
38. 좁은 골목 끝, 대문 하나
|
||||
39. 여기서는 계획을 반쯤 접어도 됩니다
|
||||
40. 하루를 통째로 쓰는 숙소
|
||||
|
||||
### 계절별 24개 (`SEASON`)
|
||||
|
||||
**봄**
|
||||
- 정원에 꽃이 먼저 옵니다
|
||||
- 봄에는 대문을 열어 둡니다
|
||||
- 벚꽃 지는 골목을 걸어 오세요
|
||||
- 마당에 앉는 계절이 시작됩니다
|
||||
- 새소리가 가장 많은 철입니다
|
||||
- 창을 열면 봄이 방까지 들어옵니다
|
||||
|
||||
**여름**
|
||||
- 처마 그늘이 가장 긴 계절입니다
|
||||
- 마당에 물을 뿌리면 시원해집니다
|
||||
- 여름 밤에는 평상이 제일 좋습니다
|
||||
- 창고형 카페가 낮에는 서늘합니다
|
||||
- 소나기는 기와가 받아 줍니다
|
||||
- 해가 길어 저녁이 넉넉합니다
|
||||
|
||||
**가을**
|
||||
- 기와 위로 가을 볕이 마릅니다
|
||||
- 가을 바람이 담을 넘어옵니다
|
||||
- 가을 골목은 해가 빨리 눕습니다
|
||||
- 창을 열어 두고 자도 되는 밤입니다
|
||||
- 가을에는 평상이 가장 좋은 자리입니다
|
||||
- 마당에 잎이 떨어지는 계절입니다
|
||||
|
||||
**겨울**
|
||||
- 기와에 눈이 앉으면 골목이 조용해집니다
|
||||
- 겨울 아침 마당은 서리 밭입니다
|
||||
- 이불 속에서 겨울 바람 소리를 듣습니다
|
||||
- 눈 오면 정원 석등만 켜 둡니다
|
||||
- 항구 바람이 매운 계절입니다
|
||||
- 겨울에는 카페 공간이 가장 아늑합니다
|
||||
|
||||
### 월별 24개 (`MONTH`)
|
||||
|
||||
**1월** — 새해 첫 아침을 백 년 집에서 · 겨울 골목이 가장 조용한 달입니다
|
||||
**2월** — 아직 바람이 찬 원도심입니다 · 겨울 끝을 마당에서 봅니다
|
||||
**3월** — 정원에 첫 꽃이 오는 달입니다 · 대문을 열어 두기 시작합니다
|
||||
**4월** — 벚꽃 지는 골목을 걷는 달입니다 · 마당에 앉는 시간이 길어집니다
|
||||
**5월** — 새소리가 가장 많은 달입니다 · 창을 열어 두고 자는 밤입니다
|
||||
**6월** — 처마 그늘이 길어지는 달입니다 · 여름 앞의 원도심은 한가합니다
|
||||
**7월** — 소나기를 기와가 받는 달입니다 · 여름 밤 평상이 제일 좋습니다
|
||||
**8월** — 낮에는 카페 공간이 서늘합니다 · 해가 길어 저녁이 넉넉한 달입니다
|
||||
**9월** — 볕이 마르기 시작하는 달입니다 · 가을이 담을 넘어오는 구월입니다
|
||||
**10월** — 잎이 마당에 떨어지는 달입니다 · 걷기에 가장 좋은 달입니다
|
||||
**11월** — 해가 빨리 눕는 골목입니다 · 이불이 가장 따뜻한 달입니다
|
||||
**12월** — 기와에 눈이 앉는 달입니다 · 한 해의 끝을 조용히 보내는 자리
|
||||
|
||||
### 날씨별 12개 (`WEATHER`)
|
||||
|
||||
**맑음**
|
||||
- 마당 평상에 앉으면 새소리만 들립니다
|
||||
- 정원 쪽으로 그늘이 길게 눕는 시간입니다
|
||||
- 기와가 마르는 냄새가 납니다
|
||||
|
||||
**구름많음**
|
||||
- 빛이 부드러운 날입니다
|
||||
- 골목 사진이 제일 잘 나오는 빛입니다
|
||||
|
||||
**흐림**
|
||||
- 빛이 낮게 깔리는 날입니다
|
||||
- 이런 날은 카페 공간이 가장 아늑합니다
|
||||
|
||||
**비**
|
||||
- 백 년 된 기와를 타고 내리는 소리가 다릅니다
|
||||
- 카페 공간에서 빗소리를 들으실 수 있습니다
|
||||
- 비 오는 골목에는 사람이 없습니다
|
||||
|
||||
**눈**
|
||||
- 기와에 눈이 앉으면 골목이 통째로 조용해집니다
|
||||
- 눈 밟는 소리가 담 안에서 크게 들립니다
|
||||
|
||||
## 2. 가요 다방 25곡 — `build_story.py` `SONGS`
|
||||
|
||||
| # | 곡명 | 가수 | 작사 / 작곡 | 연도 | 한 줄 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 잘 있거라 군산항 | 안다성 | 손석우 / 손석우 | 1963 | 월명산 고갯길로 시작하는 이별가. 군산을 노래한 곡 가운데 가장 널리 알려졌습니다. |
|
||||
| 2 | 헤어진 군산항 | 박제연 | 송운선 / 송운선 | 1965 | 비 내리는 밤 부두에서 헤어지는 노래입니다. |
|
||||
| 3 | 님 떠난 군산항 | 이미자 | 조형식 / 백영호 | 1966 | 떠나는 배를 보내는 항구의 정서를 담았습니다. |
|
||||
| 4 | 금강 나그네 | 이미자 | 이병훈 / 박춘석 | 1966 | 금강 하구와 군산항 부두를 배경으로 한 곡입니다. |
|
||||
| 5 | 잘 있거라 군산항구 | 최정자 | 최치수 / 유금춘 | 1967 | 떠나는 사람의 말로 부르는 이별가입니다. |
|
||||
| 6 | 군산 마도로스 | 이상열 | — | 1967 | 항구를 드나들던 뱃사람의 노래입니다. |
|
||||
| 7 | 군산 부르스 | 유주용 | 반야월 / 강도미 | 1969 | 1960년대 말 군산 거리의 분위기를 담은 곡입니다. |
|
||||
| 8 | 이별의 군산항 부두 | 최정자 | 최치수 / 유금춘 | 1969 | 부두에서의 이별을 다시 한 번 부른 곡입니다. |
|
||||
| 9 | 군산항 부르스 | 최하우 | 최하우 / 김성근 | 1984 | 1980년대에 나온 군산항 노래입니다. |
|
||||
| 10 | 나의 사랑 군산이여 | 오단비 | 이수 / 정종택 | 1989 | 군산을 향한 애정을 정면으로 부르는 곡입니다. |
|
||||
| 11 | 군산항 엘레지 | 오단비 | 이수 / 정종택 | — | 항구 도시의 비가입니다. |
|
||||
| 12 | 선유도 아가씨 | 오단비 | 이수 / 서승일 | — | 고군산군도 선유도를 배경으로 한 곡입니다. |
|
||||
| 13 | 군산찬가 | 오단비 | 이수 / 서승일 | — | 은파와 새만금이 가사에 나오는 군산 찬가입니다. |
|
||||
| 14 | 오성산 | 오단비 | 이수 / 서승일 | — | 금강 하구를 내려다보는 오성산을 노래했습니다. |
|
||||
| 15 | 함께하는 군산 | 오은주 | 김동찬 / 김동찬 | — | 군산시가 제작한 음반의 첫 곡입니다. |
|
||||
| 16 | 새만금 연가 | 채미영 | 김동찬 / 김동찬 · 김수환 | — | 새만금을 소재로 한 곡입니다. |
|
||||
| 17 | 군산시민의 노래 | 오단비 | 군산문화원 / 군산예총 | — | 금만경 들녘과 서해 바다로 시작하는 시민의 노래입니다. |
|
||||
| 18 | 군산 아줌마 | 박솔미 | 송두호 / 송두호 | — | 선창가 거리와 새만금이 함께 나오는 곡입니다. |
|
||||
| 19 | 내 고향 군산항 | 정애란 | 정애란 / 정애란 | — | 고향으로서의 군산항을 부른 곡입니다. |
|
||||
| 20 | 군산 가는 길 | 황상준 | — | — | 군산으로 향하는 길을 제목으로 삼은 곡입니다. |
|
||||
| 21 | 군산 추억 | 임인건 | — | — | 재즈 피아니스트 임인건이 군산을 소재로 만든 곡입니다. |
|
||||
| 22 | 군산에서 | 임인건 | — | — | 같은 연주자가 군산에서 받은 인상을 담았습니다. |
|
||||
| 23 | 헤어진 군산항 | 김창환 | 송운선 / 송운선 | — | 군산시 제작 음반에 다시 실린 판입니다. |
|
||||
| 24 | 금강 아가씨 | 이미자 | — | — | 금강을 배경으로 한 이미자의 또 다른 곡입니다. |
|
||||
| 25 | 금강의 비가 | 전승애 | — | — | 금강을 제목에 올린 곡입니다. |
|
||||
|
||||
출처: 음악의 오솔길 — 군산 관련 노래
|
||||
|
||||
## 3. 인물 열전 57명 (사진 18장) — `build_story.py` `PEOPLE`
|
||||
|
||||
사진 있는 사람이 앞에 온다.
|
||||
|
||||
| # | 사진 | 이름 | 연도 | 하는 일 | 한 줄 |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 📷 | 채만식 | 1902–1950 | 소설가 | 군산을 무대로 한 『탁류』를 썼습니다. 시내에 문학관이 있습니다. |
|
||||
| 2 | 📷 | 고은 | 1933– | 시인 | 군산에서 태어나 60년 넘게 시를 써 온 시인입니다. |
|
||||
| 3 | 📷 | 김선기 | 1907–1992 | 언어학자 | 호는 무돌. 한글 연구에 힘쓴 언어학자이자 교육자입니다. |
|
||||
| 4 | 📷 | 김수미 | 1949–2024 | 배우 | 「전원일기」 일용 엄니로 오래 기억되는 배우입니다. |
|
||||
| 5 | 📷 | 송새벽 | 1979– | 배우 | 군산 출신 배우입니다. |
|
||||
| 6 | 📷 | 장신영 | 1984– | 배우 | 군산 출신 배우입니다. |
|
||||
| 7 | 📷 | 이서원 | 1997– | 배우 | 군산 출신 배우입니다. |
|
||||
| 8 | 📷 | 병헌 | 1993– | 배우 | 그룹 틴탑 활동을 거쳐 배우로 옮겼습니다. |
|
||||
| 9 | 📷 | 정혁 | 1991– | 모델 | 2015년 서울패션위크로 데뷔한 모델 겸 방송인입니다. |
|
||||
| 10 | 📷 | 박화요비 | 1982– | 가수 | R&B 를 주로 부르는 가수입니다. |
|
||||
| 11 | 📷 | 고건 | 1938– | 정치인 | 제30·35대 국무총리를 지냈습니다. 아버지는 철학자 고형곤입니다. |
|
||||
| 12 | 📷 | 김관영 | 1969– | 정치인 | 전북특별자치도지사입니다. 회계사·변호사를 거쳤습니다. |
|
||||
| 13 | 📷 | 함운경 | 1964– | 정치인 | 1980년대 학생운동가 출신입니다. |
|
||||
| 14 | 📷 | 차우찬 | 1987– | 야구 선수 | 투수로 뛰었고 지금은 야구 해설위원입니다. |
|
||||
| 15 | 📷 | 이진영 | 1980– | 야구 선수 | 외야수로 뛰었고 지금은 코치입니다. |
|
||||
| 16 | 📷 | 오지환 | 1990– | 야구 선수 | LG 트윈스 내야수입니다. |
|
||||
| 17 | 📷 | 최서우 | 1982– | 스키점프 선수 | 1998년부터 국가대표로 뛴 스키점프 선수입니다. |
|
||||
| 18 | 📷 | 문호준 | 1997– | 프로게이머 | 카트라이더 리그 최다 우승자입니다. |
|
||||
| 19 | | 이병훈 | 1925–2009 | 시인 | 옥구(지금의 군산) 출신으로 1950년대에 등단한 현대 시인입니다. |
|
||||
| 20 | | 심호택 | 1947–2010 | 시인 | 민중의 삶을 시로 옮긴 시인입니다. |
|
||||
| 21 | | 문효치 | 1943– | 시인 | 군산에서 태어나 1966년 신춘문예로 등단했습니다. |
|
||||
| 22 | | 이연주 | 1953–1992 | 시인 | 짧게 활동하고 떠난 시인입니다. |
|
||||
| 23 | | 주하림 | — | 시인 | 군산에서 태어나 2009년 창비신인시인상으로 등단했습니다. |
|
||||
| 24 | | 고형곤 | 1906–2004 | 철학자 | 선(禪) 연구로 알려진 철학자이자 정치가입니다. |
|
||||
| 25 | | 반석평 | 1472–1540 | 조선 문신 | 노비 출신으로 문과에 급제해 판서에 오른 조선 중종 때 문신입니다. |
|
||||
| 26 | | 장재호 | 1986– | 배우 | 군산 출신 배우입니다. |
|
||||
| 27 | | 강민지 | 1996– | 배우 · 모델 | 모델로 시작해 드라마와 영화로 활동 범위를 넓혔습니다. |
|
||||
| 28 | | 김혜선 | 1983– | 희극인 | 군산 출신 희극 배우입니다. |
|
||||
| 29 | | 이옥주 | 1969– | 희극인 | 군산중앙여고를 나와 1988년 MBC 개그콘테스트 대상을 받았습니다. |
|
||||
| 30 | | 박윤경 | 1970– | 가수 | 군산 출신 가수 겸 기업인입니다. |
|
||||
| 31 | | 하준 | 1965– | 가수 | 군산 출신 가수입니다. |
|
||||
| 32 | | 강봉균 | 1943–2017 | 정치인 | 재정경제부 장관과 3선 국회의원을 지냈습니다. |
|
||||
| 33 | | 양일동 | 1912–1980 | 정치인 | 옥구군 서수면 출신으로 야당 정치인으로 활동했습니다. |
|
||||
| 34 | | 채영석 | 1934–2006 | 정치인 | 군산에서 3선 국회의원을 지냈습니다. |
|
||||
| 35 | | 신영대 | 1968– | 정치인 | 군산 지역구 국회의원입니다. |
|
||||
| 36 | | 김의겸 | 1963– | 언론인 · 정치인 | 한겨레 기자를 거쳐 청와대 대변인과 국회의원을 지냈습니다. |
|
||||
| 37 | | 임병찬 | 1851–1916 | 의병장 | 최익현과 함께 거병한 구한말 의병장으로, 단식 끝에 순국했습니다. |
|
||||
| 38 | | 이수현 | 1895–1980 | 독립운동가 | 호는 산남. 군산 출신 독립운동가입니다. |
|
||||
| 39 | | 전진 | 1923–2012 | 비전향 장기수 | 군산 출신으로 오래 수감 생활을 했습니다. |
|
||||
| 40 | | 이길여 | 1932– | 의료인 · 교육자 | 가천대학교 길병원을 세우고 가천대 총장을 맡고 있습니다. |
|
||||
| 41 | | 최길선 | 1946– | 기업인 | 현대중공업 회장을 지낸 조선업 경영인입니다. |
|
||||
| 42 | | 은성수 | 1961– | 공무원 | 제7대 금융위원회 위원장을 지냈습니다. |
|
||||
| 43 | | 채금석 | 1904–1995 | 축구 선수 | 일제강점기 경성 축구단 선수로 경평축구대항전에 나섰습니다. |
|
||||
| 44 | | 박경완 | 1972– | 야구 선수 | SK 와이번스 포수로 한국 야구의 대표적인 포수였습니다. |
|
||||
| 45 | | 조계현 | 1964– | 야구 선수 | 해태 타이거즈 투수로 뛰었습니다. |
|
||||
| 46 | | 정명원 | 1966– | 야구 선수 | 현대 유니콘스 투수로 뛰었습니다. |
|
||||
| 47 | | 조규제 | 1967– | 야구 선수 | 왼손 투수로 뛰고 지금은 투수코치입니다. |
|
||||
| 48 | | 이천웅 | 1988– | 야구 선수 | LG 트윈스 외야수로 뛰었습니다. |
|
||||
| 49 | | 노상래 | 1970– | 축구 선수 | 공격수로 뛰었고 지도자로 일하고 있습니다. |
|
||||
| 50 | | 노수진 | 1962– | 축구 선수 | 공격수로 뛰었고 지금은 체육 교사입니다. |
|
||||
| 51 | | 유동우 | 1968– | 축구 선수 | 수비수로 뛰었고 대학 축구부 감독을 맡고 있습니다. |
|
||||
| 52 | | 이정효 | 1975– | 축구 감독 | 풀백으로 뛰었고 지금은 프로팀 감독입니다. |
|
||||
| 53 | | 박성현 | 1983– | 양궁 선수 | 군산 소룡초에서 활을 처음 잡아 올림픽 금메달을 땄습니다. |
|
||||
| 54 | | 김광선 | 1964– | 권투 선수 | 1988년 서울 올림픽 복싱 플라이급 금메달리스트입니다. |
|
||||
| 55 | | 정경미 | 1985– | 유도 선수 | 군산 출신 유도 선수입니다. |
|
||||
| 56 | | 전미라 | 1978– | 테니스 선수 | 테니스 선수로 뛰었고 지금은 해설위원입니다. |
|
||||
| 57 | | 한왕용 | 1966– | 산악인 | 히말라야 8000m 14좌를 세계 11번째로 완등했습니다. |
|
||||
|
||||
## 4. 일정 — 정거장에서 할 일 36줄 — `build_itinerary.py` `PLACES`·`STAY_ACTION`
|
||||
|
||||
| 장소 | 무엇을 하는지 | 머무는 시간 |
|
||||
|---|---|---|
|
||||
| 경암동 철길마을 | 선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다. | 80분 |
|
||||
| 고군산군도 선유도 | 다리를 건너 들어갑니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다. | 150분 |
|
||||
| 구 군산세관 본관 | 빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다. | 28분 |
|
||||
| 국제반점 | 영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다. | 55분 |
|
||||
| 군산 내항 | 조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다. | 40분 |
|
||||
| 군산 내항 부잔교 | 뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다. | 37분 |
|
||||
| 군산 시간여행마을 | 골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다. | 57분 |
|
||||
| 군산근대건축관 | 옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다. | 47분 |
|
||||
| 군산근대미술관 | 옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다. | 44분 |
|
||||
| 군산근대역사박물관 | 3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다. | 88분 |
|
||||
| 도란 | 감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다. | 72분 |
|
||||
| 동국사 | 대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다. | 33분 |
|
||||
| 동국사다온 | 대추차나 오미자차를 시켜 절 마당을 보며 마십니다. | 48분 |
|
||||
| 만남스넥 | 군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다. | 36분 |
|
||||
| 명월갈비 | 한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다. | 71분 |
|
||||
| 물밀소 | 저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다. | 40분 |
|
||||
| 빈해원 | 2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다. | 70분 |
|
||||
| 스테이 머뭄 | 돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다. | 30분 |
|
||||
| 스테이 머뭄 | 맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다. | 40분 |
|
||||
| 스테이 머뭄 | 부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다. | 60분 |
|
||||
| 스테이 머뭄 | 숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다. | 173분 |
|
||||
| 스테이 머뭄 | 아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요. | 75분 |
|
||||
| 스테이 머뭄 | 오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다. | 223분 |
|
||||
| 스테이 머뭄 | 짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다. | 45분 |
|
||||
| 우체통거리 | 주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다. | 26분 |
|
||||
| 월명호수 | 호수를 한 바퀴 돕니다. 천천히 걸어 40분 코스입니다. | 42분 |
|
||||
| 이성당 | 단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다. | 50분 |
|
||||
| 일흥옥 | 소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다. | 52분 |
|
||||
| 장자도 | 대장봉까지 올라갔다 내려옵니다. 섬과 섬 사이를 걸어 넘습니다. | 50분 |
|
||||
| 째보선창 | 선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다. | 32분 |
|
||||
| 째보선창 근처 카페 | 창가에 앉아 비가 그칠 때까지 기다립니다. 항구가 보이는 자리입니다. | 69분 |
|
||||
| 채만식문학관 | 배 모양 전시관을 돌고 금강 쪽 데크로 나가 강을 봅니다. | 66분 |
|
||||
| 초원사진관 | 안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다. | 45분 |
|
||||
| 한일옥 | 무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다. | 52분 |
|
||||
| 해망굴 | 굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다. | 19분 |
|
||||
| 히로쓰 가옥 | 담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다. | 45분 |
|
||||
|
||||
## 5. 일정 테마 21개 — `build_itinerary.py` `THEMES`
|
||||
|
||||
| 테마 | 기간 | 누구에게 | 왜 |
|
||||
|---|---|---|---|
|
||||
| 섬까지 다녀오는 1박 2일 | 1박 2일 | 차를 가져온 손님 | 첫날은 걸어서 도심, 이튿날은 다리를 건너 섬입니다. 두 날의 성격이 아주 다릅니다. |
|
||||
| 걸어서만 도는 1박 2일 | 1박 2일 | 차 없이 오는 손님 | 모든 정거장이 머뭄에서 걸어 닿습니다. 이틀 동안 차를 한 번도 타지 않습니다. |
|
||||
| 비가 와도 되는 1박 2일 | 1박 2일 | 비 예보를 보고 오는 손님 | 실내가 이어집니다. 비가 그치기를 기다리지 않아도 하루가 찹니다. |
|
||||
| 근대건축만 보는 1박 2일 | 1박 2일 | 건물을 보러 오는 손님 | 1900년대 건물만 골라 돕니다. 머뭄도 그 시기에 지은 집입니다. |
|
||||
| 이성당으로 시작하는 1박 2일 | 1박 2일 | 빵을 좋아하는 손님 | 이튿날 아침을 빵으로 엽니다. 문 여는 시간에 맞춰 걸어갑니다. |
|
||||
| 철길마을까지 가는 1박 2일 | 1박 2일 | 사진을 찍는 손님 | 빛이 좋은 자리만 이어 붙였습니다. 철길마을은 오전 빛이 낫습니다. |
|
||||
| 항구만 도는 1박 2일 | 1박 2일 | 바다가 보고 싶은 손님 | 부잔교에서 째보선창까지, 이틀 동안 물가만 걷습니다. |
|
||||
| 소설 『탁류』를 따라 걷는 1박 2일 | 1박 2일 | 채만식을 읽어 본 손님 | 소설이 지나간 자리를 순서대로 밟습니다. 세관·선창·문학관이 한 줄로 이어집니다. |
|
||||
| 아이와 함께 1박 2일 | 1박 2일 | 아이를 데려온 손님 | 걷는 거리가 짧고, 아이가 지루해할 자리를 뺐습니다. |
|
||||
| 늦게 도착하는 1박 2일 | 1박 2일 | 퇴근하고 오는 손님 | 첫날은 저녁 한 끼로 끝냅니다. 대신 이튿날을 아침부터 저녁까지 씁니다. |
|
||||
| 천천히 도는 2박 3일 | 2박 3일 | 쉬러 오는 손님 | 하루에 두어 곳만 봅니다. 마당에 앉아 있는 시간을 일정에 넣었습니다. |
|
||||
| 섬과 도심을 반씩 도는 2박 3일 | 2박 3일 | 차를 가져온 손님 | 가운데 날을 섬에 다 씁니다. 첫날과 마지막 날은 걸어서 도심입니다. |
|
||||
| 골목을 세 번 나눠 걷는 2박 3일 | 2박 3일 | 차 없이 오는 손님 | 원도심을 세 덩이로 나눠 하루에 하나씩 걷습니다. 같은 길을 두 번 걷지 않습니다. |
|
||||
| 먹으러 오는 2박 3일 | 2박 3일 | 맛집을 도는 손님 | 끼니가 중심입니다. 사이에 걸을 자리를 넣어 배를 비웁니다. |
|
||||
| 아무 데도 안 가는 2박 3일 | 2박 3일 | 쉬려고만 오는 손님 | 가운데 날은 대문 밖으로 두 번만 나갑니다. 나머지는 마당과 카페 공간에서 보냅니다. |
|
||||
| 사진만 찍는 2박 3일 | 2박 3일 | 사진을 찍는 손님 | 시간대에 맞춰 자리를 배치했습니다. 철길마을은 오전, 부잔교는 해 질 때입니다. |
|
||||
| 비가 와도 되는 2박 3일 | 2박 3일 | 비 예보를 보고 오는 손님 | 사흘 내리 비가 와도 도는 순서가 바뀌지 않습니다. 실내와 처마 밑만 잇습니다. |
|
||||
| 늦게 시작하는 2박 3일 | 2박 3일 | 아침이 힘든 손님 | 가운데 날을 열한 시에 엽니다. 오전을 비우고 저녁을 늦게까지 씁니다. |
|
||||
| 섬에서 하루를 다 쓰는 2박 3일 | 2박 3일 | 차를 가져온 손님 | 가운데 날은 아침에 나가 저녁에 들어옵니다. 섬에서 하루를 통째로 씁니다. |
|
||||
| 소설 『탁류』를 따라 걷는 2박 3일 | 2박 3일 | 채만식을 읽어 본 손님 | 소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다. |
|
||||
| 철길마을과 항구를 같이 보는 2박 3일 | 2박 3일 | 사진을 찍는 손님 | 기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다. |
|
||||
|
||||
## 6. 오늘의 엽서 4장 — 원본 payload (아직 다시 안 뽑음)
|
||||
|
||||
- 부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다. `#군산 #내항 #부잔교`
|
||||
- 짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다. `#군산짬뽕 #항구도시`
|
||||
- 단팥빵을 사러 줄을 서는 일이 여행이 되는 도시. `#이성당 #오래된가게`
|
||||
- 섬과 섬 사이를 다리로 건너면, 바다가 길이 된다. `#고군산군도 #선유도`
|
||||
|
||||
## 7. 머뭄이 만든 노래 5곡 — `patch_stay.py` `OWN_SONGS`
|
||||
|
||||
- 머뭄의 아침 — `/s/stay/audio/01-mumum-morning.mp3`
|
||||
- CHECK IN · 머뭄 — `/s/stay/audio/02-check-in.mp3`
|
||||
- Stay in 머뭄 — `/s/stay/audio/03-stay-in.mp3`
|
||||
- 오늘은 안 갈란다 — `/s/stay/audio/04-not-going.mp3`
|
||||
- 군산에 두고 온 님 — `/s/stay/audio/05-gunsan-nim.mp3`
|
||||
26
solution/site/scripts/mockup/align.mjs
Normal file
@ -0,0 +1,26 @@
|
||||
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();
|
||||
40
solution/site/scripts/mockup/arrow.mjs
Normal file
@ -0,0 +1,40 @@
|
||||
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();
|
||||
BIN
solution/site/scripts/mockup/audio/01-mumum-morning.mp3
Normal file
BIN
solution/site/scripts/mockup/audio/02-check-in.mp3
Normal file
BIN
solution/site/scripts/mockup/audio/03-stay-in.mp3
Normal file
BIN
solution/site/scripts/mockup/audio/04-not-going.mp3
Normal file
BIN
solution/site/scripts/mockup/audio/05-gunsan-nim.mp3
Normal file
128
solution/site/scripts/mockup/audit-all.mjs
Normal file
@ -0,0 +1,128 @@
|
||||
/* /s/stay 시연본 전수 점검 — 사장님이 지시한 항목을 하나씩 화면에서 센다. */
|
||||
import {chromium} from 'playwright';
|
||||
const URL = process.argv[2] || 'http://localhost/s/stay';
|
||||
const b = await chromium.launch({channel: 'chrome'});
|
||||
const out = [];
|
||||
const ok = (name, pass, detail) => out.push({name, pass, detail});
|
||||
|
||||
// ── 데스크톱 ──────────────────────────────────────────────────────────
|
||||
const page = await b.newPage({viewport: {width: 1280, height: 900}});
|
||||
const failed = [];
|
||||
page.on('response', (r) => { if (r.status() >= 400 && r.url().includes('/s/stay')) failed.push(r.status() + ' ' + decodeURIComponent(r.url().slice(-36))); });
|
||||
const errs = [];
|
||||
page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text().slice(0, 90)); });
|
||||
await page.goto(URL, {waitUntil: 'domcontentloaded'});
|
||||
await page.waitForTimeout(6500);
|
||||
|
||||
const d = await page.evaluate(() => {
|
||||
const P = window.__SITE_PAYLOAD__;
|
||||
const sec = (id) => { const s = P.theme.sections.find((x) => x.id === id); try { return JSON.parse(s.data); } catch { return {items: []}; } };
|
||||
const itin = sec('itinerary').items;
|
||||
const stops = itin.flatMap((i) => i.days.flatMap((day) => day.stops));
|
||||
const days = itin.flatMap((i) => i.days);
|
||||
return {
|
||||
catch: (P.narrative.catchphrases?.items || []).length,
|
||||
catchKinds: ['general', 'season', 'month', 'weather'].map((k) => (P.narrative.catchphrases?.items || []).filter((x) => x.kind === k).length),
|
||||
themes: itin.length,
|
||||
firstIsStay: days.every((day) => day.stops[0].name.endsWith('스테이 머뭄')),
|
||||
lastIsStay: days.every((day) => day.stops[day.stops.length - 1].name.endsWith('스테이 머뭄')),
|
||||
consecutive: days.filter((day) => day.stops.some((s, i) => i && s.name.split(' · ').pop() === day.stops[i - 1].name.split(' · ').pop())).length,
|
||||
hasNote: stops.every((s) => s.name.endsWith('스테이 머뭄') || s.note),
|
||||
stopImages: stops.filter((s) => s.imageUrl).length,
|
||||
restDays: days.filter((day) => day.stops.some((s) => s.name.startsWith('머뭄에서 쉬는 날'))).length,
|
||||
endTimes: (() => { const t = days.map((day) => { let c = +day.startTime.slice(0, 2) * 60 + +day.startTime.slice(3); day.stops.forEach((s) => { c += s.moveMinutes + s.minutes; }); return c; }); return [Math.min(...t), Math.max(...t)]; })(),
|
||||
songs: sec('songs').items.length,
|
||||
ownInSongs: sec('songs').items.filter((s) => s.own).length,
|
||||
songLinks: sec('songs').items.filter((s) => s.listenUrl).length,
|
||||
people: sec('people').items.length,
|
||||
peoplePhotos: sec('people').items.filter((p) => p.imageUrl).length,
|
||||
peopleCredits: sec('people').items.filter((p) => p.imageCredit).length,
|
||||
// 사진 있는 사람이 앞에 모여 있는가 — 레일 앞머리가 글자만 남으면 안 된다
|
||||
photoFirst: (() => { const a = sec('people').items.map((p) => !!p.imageUrl); const n = a.filter(Boolean).length; return a.slice(0, n).every(Boolean); })(),
|
||||
ownSongs: (P.narrative.ownSongs?.items || []).length,
|
||||
// 화면
|
||||
domPeopleImg: document.querySelectorAll('#people img').length,
|
||||
domCredit: document.querySelectorAll('#people .w4d-credit').length,
|
||||
domItinLinks: document.querySelectorAll('#itinerary a.w4d-link').length,
|
||||
domPostcardLinks: document.querySelectorAll('#postcard a.w4d-link').length,
|
||||
domItinImg: document.querySelectorAll('#itinerary img:not([src*="openstreetmap"])').length,
|
||||
miniBtns: [...document.querySelectorAll('#w4d-mini button')].map((x) => x.id),
|
||||
cassette: !!document.getElementById('w4d-tape'),
|
||||
redRule: document.querySelectorAll('.w4d-rule').length,
|
||||
bbqRow: !!document.body.textContent.match(/바비큐 이용/),
|
||||
divider: (() => { const el = document.querySelector('#root div.border-line.flex.flex-col.gap-3.border-t.pt-6'); return el ? getComputedStyle(el).borderTopWidth : 'none'; })(),
|
||||
heroImg: (P.media.find((m) => m.isPrimary) || {}).url,
|
||||
roomA: P.units[0].mediaIds.length, roomB: P.units[1].mediaIds.length,
|
||||
roomMirror: P.units.flatMap((u) => u.mediaIds).length,
|
||||
tagline: document.querySelector('#root h1')?.nextElementSibling?.textContent.trim(),
|
||||
};
|
||||
});
|
||||
|
||||
ok('캐치프레이즈 100개', d.catch === 100, `${d.catch}개 · 일반${d.catchKinds[0]}/계절${d.catchKinds[1]}/월${d.catchKinds[2]}/날씨${d.catchKinds[3]}`);
|
||||
ok('캐치프레이즈 순환', d.tagline && d.tagline !== '히로쓰 가옥 담 너머, 백 년 된 집 한 채', `현재 "${d.tagline}"`);
|
||||
ok('일정 테마', d.themes === 21, `${d.themes}개`);
|
||||
ok('1번 = 스테이 머뭄', d.firstIsStay, d.firstIsStay ? '전 일자' : '어긋남');
|
||||
ok('마지막 = 스테이 머뭄', d.lastIsStay, d.lastIsStay ? '전 일자' : '어긋남');
|
||||
ok('같은 자리 연속 없음', d.consecutive === 0, `${d.consecutive}건`);
|
||||
ok('정거장마다 할 일 문구', d.hasNote, d.hasNote ? '전 정거장' : '빠짐');
|
||||
ok('일정 사진 없음', d.stopImages === 0 && d.domItinImg === 0, `payload ${d.stopImages} · 화면 ${d.domItinImg}`);
|
||||
ok('숙소에서 쉬는 날', d.restDays >= 3, `${d.restDays}일`);
|
||||
ok('종료 시각 분산', d.endTimes[1] - d.endTimes[0] > 240, `${(d.endTimes[0] / 60 | 0)}:${String(d.endTimes[0] % 60).padStart(2, '0')} ~ ${(d.endTimes[1] / 60 | 0)}:${String(d.endTimes[1] % 60).padStart(2, '0')}`);
|
||||
ok('가요 다방 곡', d.songs >= 25, `${d.songs}곡`);
|
||||
ok('가요 다방에 자작곡 없음', d.ownInSongs === 0, `${d.ownInSongs}곡`);
|
||||
ok('노래마다 링크', d.songLinks === d.songs, `${d.songLinks}/${d.songs}`);
|
||||
ok('인물 50명 이상', d.people >= 50, `${d.people}명`);
|
||||
ok('인물 사진', d.peoplePhotos >= 18 && d.domPeopleImg === d.peoplePhotos, `payload ${d.peoplePhotos} · 화면 ${d.domPeopleImg}`);
|
||||
// 저작자 표시는 **위키에서 받은 사진에만** 붙는다(라이선스 조건). 원본 사이트가 원래 쓰던
|
||||
// 미러 사진(채만식)은 이 사이트 것이라 표시 대상이 아니다 — 그래서 두 수가 다르다.
|
||||
ok('사진 있는 사람이 앞', d.photoFirst, d.photoFirst ? `앞 ${d.peoplePhotos}명` : '흩어져 있다');
|
||||
ok('사진 저작자 표시', d.domCredit === d.peopleCredits, `${d.domCredit}/${d.peopleCredits} (미러 ${d.peoplePhotos - d.peopleCredits}장 제외)`);
|
||||
ok('일정 지도 링크', d.domItinLinks > 150, `${d.domItinLinks}개`);
|
||||
ok('엽서 지도 링크', d.domPostcardLinks === 4, `${d.domPostcardLinks}개`);
|
||||
ok('플레이어 단추 2개', d.miniBtns.length === 2 && !d.miniBtns.includes('w4d-next'), d.miniBtns.join(','));
|
||||
ok('카세트 아이콘', d.cassette, d.cassette ? '있음' : '없음');
|
||||
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('객실 사진 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(' / ') || '없음');
|
||||
|
||||
// 플레이어 동작
|
||||
await page.click('#w4d-toggle'); await page.waitForTimeout(350);
|
||||
await page.click('.w4d-item'); await page.waitForTimeout(2200);
|
||||
const play = await page.evaluate(() => {
|
||||
const row = document.querySelector('.w4d-item[data-playing="1"]');
|
||||
return {playing: !!row, time: row?.querySelector('.w4d-time')?.textContent,
|
||||
btn: document.getElementById('w4d-play')?.dataset.playing};
|
||||
});
|
||||
ok('음원 재생', play.playing && /\d:\d\d \/ \d:\d\d/.test(play.time || ''), `${play.time}`);
|
||||
await page.close();
|
||||
|
||||
// ── 모바일 ────────────────────────────────────────────────────────────
|
||||
const m = await b.newPage({viewport: {width: 390, height: 780}, isMobile: true, hasTouch: true});
|
||||
await m.goto(URL, {waitUntil: 'domcontentloaded'});
|
||||
await m.waitForTimeout(5500);
|
||||
await m.click('#w4d-toggle'); await m.waitForTimeout(400);
|
||||
const mob = await m.evaluate(() => {
|
||||
const p = document.getElementById('w4d-panel').getBoundingClientRect();
|
||||
const nav = [...document.querySelectorAll('#root nav')].find((n) => getComputedStyle(n).position === 'fixed');
|
||||
const mini = document.getElementById('w4d-mini').getBoundingClientRect();
|
||||
const rows = [...document.querySelectorAll('.w4d-item')].map((el) => Math.round(el.getBoundingClientRect().height));
|
||||
return {panelIn: p.left >= 0 && p.right <= window.innerWidth, panelW: Math.round(p.width),
|
||||
rowMin: Math.min(...rows), tabbarOverlap: nav ? mini.bottom > nav.getBoundingClientRect().top : false,
|
||||
bodyScrollX: document.documentElement.scrollWidth > window.innerWidth};
|
||||
});
|
||||
ok('모바일 패널 화면 안', mob.panelIn, `폭 ${mob.panelW}px`);
|
||||
ok('모바일 줄 높이 ≥48px', mob.rowMin >= 48, `${mob.rowMin}px`);
|
||||
ok('모바일 탭바 안 겹침', !mob.tabbarOverlap, mob.tabbarOverlap ? '겹침' : '안 겹침');
|
||||
ok('가로 스크롤 없음', !mob.bodyScrollX, mob.bodyScrollX ? '생김' : '없음');
|
||||
await m.close();
|
||||
await b.close();
|
||||
|
||||
const bad = out.filter((x) => !x.pass);
|
||||
out.forEach((x) => console.log(`${x.pass ? ' OK ' : ' ✗✗ '} ${x.name.padEnd(22)} ${x.detail}`));
|
||||
console.log(`\n${out.length}개 중 통과 ${out.length - bad.length} · 실패 ${bad.length}`);
|
||||
process.exit(bad.length ? 1 : 0);
|
||||
634
solution/site/scripts/mockup/build_itinerary.py
Normal file
@ -0,0 +1,634 @@
|
||||
"""/s/stay 목업의 '추천 일정' 을 짠다 — 펜션에서 나서고 펜션으로 돌아오는 하루.
|
||||
|
||||
★ 왜 스크립트인가
|
||||
20개 테마 × 하루 5~6칸을 손으로 적으면 시각이 어긋난다. 렌더러(shared `planDay`)는
|
||||
**21시를 넘기는 칸을 조용히 버린다** — 적어 놓고 화면에 안 나오는 칸이 생긴다.
|
||||
그래서 사람은 **어느 자리에 어느 장소**만 고르고, 시각은 여기서 계산한다.
|
||||
|
||||
★ 2026-09-10 전면 재작성. 앞선 판이 논리적으로 틀린 자리가 네 군데였다(전수 감사).
|
||||
① 이동시간을 슬롯에 박아 뒀다 — 담 하나 건너인 히로쓰 가옥이 8분, 45분 걸리는 선유도가 10분.
|
||||
→ **좌표에서 계산한다**(1.5km 까지 도보 4km/h, 그 위는 차 25km/h + 주차 5분).
|
||||
② 체류시간도 슬롯이 정했다 — '오후' 칸이면 무엇이든 90~115분이라 우체통거리에 2시간 서 있었다.
|
||||
→ **장소마다 (최소·기본·최대)** 를 준다.
|
||||
③ 입실이 14:30 에 잡혔다(payload `check_in_time` 은 15:00), 퇴실은 11:35(규정 11:00).
|
||||
④ 점심이 09:40, 저녁이 13:09 에 잡혔다.
|
||||
→ ③④ 는 **창(WINDOW)** 으로 못 박고, 앞 칸 체류를 범위 안에서 늘이고 줄여 맞춘다.
|
||||
맞추지 못하면 시작 시각을 당긴다. 그래도 안 되면 그 조합은 만들지 않는다(조용히 틀리게 두지 않는다).
|
||||
|
||||
★ 지어내지 않는다. 좌표는 payload(기존 정거장)와 카카오 로컬 검색(새 장소)에서 온 값이고,
|
||||
한 줄 설명은 **거기서 무엇을 하는지**다 — 장소 소개는 이미 '주변 정보' 섹션이 한다.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
SP = Path(__file__).parent
|
||||
SRC = SP / "stay-payload.json"
|
||||
DST = SP / "stay-payload-new.json"
|
||||
|
||||
payload = json.loads(SRC.read_text(encoding="utf-8"))
|
||||
SECT = {s["id"]: s for s in payload["theme"]["sections"]}
|
||||
|
||||
BASE = (payload["place"]["latitude"], payload["place"]["longitude"])
|
||||
STAY = "스테이 머뭄"
|
||||
|
||||
|
||||
# ── 장소 대장 ─────────────────────────────────────────────────────────────────
|
||||
# (위도, 경도, 지도 검색어, (최소·기본·최대 체류분), 거기서 무엇을 하는지)
|
||||
# ★ 설명이 아니라 **할 일**이다 (2026-09-10 사장님: "가서 뭐 할지를 추천해 줘야지").
|
||||
# "1899년에 지어진 건물입니다" 는 손님이 거기서 뭘 해야 하는지 안 알려 준다.
|
||||
PLACES = {
|
||||
STAY: (35.9864175, 126.7061254, None, (30, 40, 60), None),
|
||||
|
||||
"히로쓰 가옥": (35.9861696, 126.7060575, "군산 신흥동 일본식가옥", (20, 30, 45),
|
||||
"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다."),
|
||||
"초원사진관": (35.9877370, 126.7083826, "군산 초원사진관", (20, 30, 45),
|
||||
"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다."),
|
||||
"군산근대역사박물관": (35.9908197, 126.7121232, "군산근대역사박물관", (60, 90, 120),
|
||||
"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다."),
|
||||
"군산근대건축관": (35.9894202, 126.7121232, "군산근대건축관", (30, 45, 70),
|
||||
"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다."),
|
||||
"군산근대미술관": (35.9899368, 126.7108000, "군산근대미술관", (30, 45, 70),
|
||||
"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다."),
|
||||
"구 군산세관 본관": (35.9915088, 126.7135000, "구 군산세관", (20, 30, 45),
|
||||
"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다."),
|
||||
"해망굴": (35.9913181, 126.7050000, "군산 해망굴", (15, 20, 30),
|
||||
"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다."),
|
||||
"경암동 철길마을": (35.9813520, 126.7362331, "군산 경암동 철길마을", (35, 50, 80),
|
||||
"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다."),
|
||||
"군산 내항 부잔교": (35.9892997, 126.7174508, "군산 내항 부잔교", (25, 35, 55),
|
||||
"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다."),
|
||||
"째보선창": (35.9875345, 126.7199875, "군산 째보선창", (20, 30, 45),
|
||||
"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다."),
|
||||
"군산 시간여행마을": (35.9883192, 126.7109377, "군산 시간여행마을", (40, 60, 90),
|
||||
"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다."),
|
||||
"월명호수": (35.9766391, 126.6913696, "군산 월명호수", (35, 50, 80),
|
||||
"호수를 한 바퀴 돕니다. 천천히 걸어 40분 코스입니다."),
|
||||
"은적사": (35.9748304, 126.6854726, "군산 은적사", (30, 40, 60),
|
||||
"대웅전까지 올라갔다 내려옵니다. 절 마당에서 한 번 앉아 갑니다."),
|
||||
"채만식문학관": (36.0085981, 126.7578853, "군산 채만식문학관", (40, 60, 90),
|
||||
"배 모양 전시관을 돌고 금강 쪽 데크로 나가 강을 봅니다."),
|
||||
"동국사": (35.9827663, 126.7074296, "군산 동국사", (25, 35, 50),
|
||||
"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다."),
|
||||
"우체통거리": (35.9859965, 126.7119708, "군산 우체통거리", (15, 25, 40),
|
||||
"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다."),
|
||||
"고군산군도 선유도": (35.8102050, 126.4124053, "군산 선유도", (120, 150, 210),
|
||||
"다리를 건너 들어갑니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다."),
|
||||
"장자도": (35.8101000, 126.3973000, "군산 장자도", (40, 50, 80),
|
||||
"대장봉까지 올라갔다 내려옵니다. 섬과 섬 사이를 걸어 넘습니다."),
|
||||
"군산 내항": (35.9892997, 126.7174508, "군산 내항", (30, 40, 60),
|
||||
"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다."),
|
||||
|
||||
# 끼니 — 무엇을 먹는지까지 적는다. "맛집입니다" 로는 무엇을 시킬지 모른다.
|
||||
"빈해원": (35.9888841, 126.7139765, "군산 빈해원", (55, 70, 90),
|
||||
"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다."),
|
||||
"일흥옥": (35.9851498, 126.7091839, "군산 일흥옥", (40, 50, 65),
|
||||
"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다."),
|
||||
"한일옥": (35.9874081, 126.7100000, "군산 한일옥", (40, 50, 65),
|
||||
"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다."),
|
||||
"명월갈비": (35.9863914, 126.7101663, "군산 명월갈비", (60, 75, 95),
|
||||
"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다."),
|
||||
"국제반점": (35.9881100, 126.7108985, "군산 국제반점", (45, 55, 70),
|
||||
"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다."),
|
||||
"만남스넥": (35.9841788, 126.7097763, "군산 만남스넥", (30, 40, 55),
|
||||
"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다."),
|
||||
"도란": (35.9868220, 126.7075100, "군산 도란", (60, 80, 100),
|
||||
"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다."),
|
||||
"물밀소": (35.9845243, 126.7073211, "군산 물밀소", (25, 35, 50),
|
||||
"저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다."),
|
||||
"이성당": (35.9870443, 126.7111681, "군산 이성당", (25, 35, 50),
|
||||
"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다."),
|
||||
"째보선창 근처 카페": (35.9875345, 126.7199875, "군산 째보선창 카페", (45, 60, 90),
|
||||
"창가에 앉아 비가 그칠 때까지 기다립니다. 항구가 보이는 자리입니다."),
|
||||
"동국사다온": (35.9860907, 126.7144400, "군산 동국사 다온", (30, 40, 55),
|
||||
"대추차나 오미자차를 시켜 절 마당을 보며 마십니다."),
|
||||
}
|
||||
|
||||
# 펜션 칸은 장소가 아니라 **행위**가 시간을 정한다.
|
||||
STAY_ACTION = {
|
||||
"체크인": ((30, 40, 50), "짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다."),
|
||||
"머뭄으로": ((20, 30, 45), "돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다."),
|
||||
# ★ 오후에 한 번 숙소로 돌아온다. 넣은 이유가 둘이다.
|
||||
# ① 점심(≤13:30)과 저녁(≥17:30) 사이 네 시간을 바깥 두 곳으로는 못 채운다 —
|
||||
# 억지로 채우면 골목 하나에 두 시간 서 있는 일정이 된다(그래서 실제로 그랬다).
|
||||
# ② 독채 펜션에 묵는 손님은 실제로 그렇게 한다. 이 집이 목적지인 일정이다.
|
||||
"쉼": ((30, 70, 190), "숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다."),
|
||||
# ★ 하루의 1번은 **언제나 머뭄**이다 (2026-09-10 사장님: "1은 스테이 머뭄이어야 한다고 말했지?").
|
||||
# 한때 이 칸을 빼고 "시작 시각 = 나서는 시각" 으로 뒀다 — 출발지가 화면에서 사라졌다.
|
||||
# 빈 칸으로 두지 않으려면 **실제로 하는 일**이 있어야 한다. 이 집은 부엌과 창고형 카페가
|
||||
# 딸린 독채라 아침을 여기서 보낸다 — 그게 이 숙소를 고른 이유이기도 하다.
|
||||
"아침": ((40, 60, 110), "부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다."),
|
||||
# ★ 마지막 날 아침은 **퇴실까지 한 칸**이다 (2026-09-11 사장님: "체크인하고 바로 체크아웃하는
|
||||
# 일정도 있어"). 아침(머뭄)과 체크아웃(머뭄)을 따로 세웠더니 **같은 자리에 두 칸이 연달아**
|
||||
# 섰다 — 손님은 그 사이에 아무 데도 가지 않는다. 한 칸이 맞다.
|
||||
# 마지막 날 아침은 체류 범위가 다르다 — 짐을 싸고 퇴실까지 한 칸에서 끝낸다.
|
||||
"아침(퇴실)": ((45, 75, 115),
|
||||
"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요."),
|
||||
"머뭄에서 쉬는 날": ((120, 210, 360),
|
||||
"오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다."),
|
||||
# ★ 10분짜리 칸을 시간표에 세우지 않는다 (2026-09-10 사장님: "10분 정도 왜 잡아 놓는 거야?").
|
||||
# 짐만 찾는 데는 10분이면 되지만, 그건 한 칸이 될 만한 일이 아니다 — 손님이 시간표에서
|
||||
# 읽는 것은 "여기서 무엇을 하다 가는가" 다. 마당에 한 번 더 앉았다 나서는 시간으로 잡는다.
|
||||
"마무리": ((30, 40, 60), "맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다."),
|
||||
}
|
||||
|
||||
# ── 이동시간 — 좌표에서 계산한다 ────────────────────────────────────────────────
|
||||
WALK_KMH, DRIVE_KMH, PARK_MIN, WALK_LIMIT_KM = 4.0, 25.0, 5, 1.5
|
||||
|
||||
|
||||
def haversine_km(a: tuple, b: tuple) -> float:
|
||||
r, rad = 6371.0, math.pi / 180
|
||||
h = (math.sin((b[0] - a[0]) * rad / 2) ** 2
|
||||
+ math.cos(a[0] * rad) * math.cos(b[0] * rad) * math.sin((b[1] - a[1]) * rad / 2) ** 2)
|
||||
return 2 * r * math.asin(math.sqrt(h))
|
||||
|
||||
|
||||
def move_minutes(a: tuple, b: tuple) -> int:
|
||||
dist = haversine_km(a, b)
|
||||
if dist < 0.05:
|
||||
return 3 # 같은 자리(부잔교↔내항 야경)라도 옮겨 서는 시간은 있다
|
||||
raw = dist / WALK_KMH * 60 if dist <= WALK_LIMIT_KM else dist / DRIVE_KMH * 60 + PARK_MIN
|
||||
return max(3, int(round(raw)))
|
||||
|
||||
|
||||
# ── 시간대가 정해진 약속 ───────────────────────────────────────────────────────
|
||||
# 도착이 이 창 안에 들어와야 한다. 입·퇴실은 payload 의 check_in_time·check_out_time 과 같다.
|
||||
WINDOW = {
|
||||
"체크인": (15 * 60, 20 * 60),
|
||||
"점심": (11 * 60 + 30, 13 * 60 + 30),
|
||||
"저녁": (17 * 60 + 30, 20 * 60),
|
||||
"늦은 점심": (13 * 60, 15 * 60),
|
||||
"밤 산책": (18 * 60 + 30, 20 * 60),
|
||||
}
|
||||
DAY_ENDS_BY = 21 * 60
|
||||
# 퇴실 시각. payload 의 check_out_time 과 같아야 한다 — 시간표가 규정을 어기면
|
||||
# 사장님이 손님에게 설명할 수 없는 일정이 된다.
|
||||
CHECK_OUT_BY = 11 * 60
|
||||
|
||||
|
||||
# ── 하루 뼈대 ─────────────────────────────────────────────────────────────────
|
||||
# 자리 이름만 정한다. 장소는 테마가 주고, 시간은 계산된다.
|
||||
# ★ 펜션 칸은 **실제로 무슨 일이 일어나는 자리**만 남긴다 — 입실·퇴실·돌아옴.
|
||||
# 한때 '아침 · 머뭄 20분' 을 세워 뒀는데, 이미 그 집에 있는 사람에게 아무 뜻이 없는 칸이었다
|
||||
# (2026-09-10 사장님: "이건 무슨 의미야???"). 지금은 **하루의 시작 시각 = 머뭄에서 나서는 시각**이다.
|
||||
# ★ 규칙 셋을 뼈대가 지킨다(아래 audit 이 매 실행 검사해서 어기면 빌드를 멈춘다).
|
||||
# ① 하루의 처음과 끝은 머뭄이다 — 출발지와 복귀지.
|
||||
# ② **같은 자리가 연달아 서지 않는다.** 머뭄 칸 사이에는 반드시 바깥 정거장이 있다.
|
||||
# 한때 '아침 · 머뭄' 다음 칸이 '체크아웃 · 머뭄' 이었다 — 그 사이에 손님은 아무 데도
|
||||
# 가지 않는데 시간표에는 두 줄이 섰다(2026-09-11 사장님 지적). 지금은 한 칸이다.
|
||||
# ③ 가운데 머뭄 칸은 '쉼'·'쉬는 날' 뿐이다. 점심(≤13:30)과 저녁(≥17:30) 사이 네 시간을
|
||||
# 바깥 두 곳으로는 못 채운다 — 억지로 채우면 골목 하나에 두 시간 세우게 된다(실측).
|
||||
# 독채 펜션에 묵는 손님은 실제로 그때 들어와 쉰다.
|
||||
SKELETON = {
|
||||
"first": ["체크인@", "오후", "오후", "저녁", "밤 산책", "머뭄으로@"],
|
||||
"first_late": ["체크인@", "저녁", "머뭄으로@"],
|
||||
"mid": ["아침@", "오전", "점심", "오후", "오후", "쉼@", "저녁", "머뭄으로@"],
|
||||
"mid_island": ["아침@", "오전", "오후", "늦은 점심", "쉼@", "저녁", "머뭄으로@"],
|
||||
"mid_late": ["아침@", "점심", "오후", "오후", "쉼@", "저녁", "머뭄으로@"],
|
||||
# ★ 숙소에서 노는 날. 오후에 머뭄으로 돌아와 쉬는 것이 이 테마의 내용이다
|
||||
# (2026-09-10 사장님: "펜션에서 조용히 노는 날도 있어야지").
|
||||
"mid_rest": ["머뭄에서 쉬는 날@", "점심", "쉼@", "저녁", "머뭄으로@"],
|
||||
"last": ["아침@", "오전", "점심", "오후", "마무리@"],
|
||||
"last_plus": ["아침@", "오전", "점심", "오후", "오후", "마무리@"],
|
||||
# ★ 떠나는 날에 섬을 가는 코스. 왕복 두 시간이라 **바깥 정거장은 둘**이다
|
||||
# (2026-09-11 감사: 섬을 오후에 넣었더니 저녁도 없이 19시에 돌아와 짐만 찾고 끝났다).
|
||||
# 퇴실하고 바로 섬으로 가서, 돌아오는 길에 늦은 점심을 먹고 짐을 찾는다.
|
||||
"last_island": ["아침@", "오전", "늦은 점심", "마무리@"],
|
||||
}
|
||||
# 이 뼈대의 첫 칸(아침 · 퇴실)은 **11시 전에 끝나야** 한다 — payload 의 check_out_time.
|
||||
CHECKOUT_KINDS = {"last", "last_plus", "last_island"}
|
||||
# 하루의 시작 = **머뭄에서 아침을 여는 시각**이다(첫날만 입실 시각).
|
||||
START = {"first": "15:00", "first_late": "19:00", "mid": "08:30", "mid_island": "07:40",
|
||||
"mid_late": "09:40", "mid_rest": "10:00", "last": "08:10", "last_plus": "07:50",
|
||||
"last_island": "08:00"}
|
||||
# 시작을 앞당길 수 있는 한계. 첫날은 입실(15:00)이 있어 당길 이유가 없다.
|
||||
FLOOR = {"first": "15:00", "first_late": "18:00", "mid": "07:20", "mid_island": "07:00",
|
||||
"mid_late": "09:00", "mid_rest": "09:30", "last": "07:20", "last_plus": "07:20",
|
||||
"last_island": "07:20"}
|
||||
DAY_LABEL = ["첫째 날", "둘째 날", "셋째 날"]
|
||||
DURATION = {2: "1박 2일", 3: "2박 3일"}
|
||||
|
||||
|
||||
def _minutes(text: str) -> int:
|
||||
hour, minute = text.split(":")
|
||||
return int(hour) * 60 + int(minute)
|
||||
|
||||
|
||||
def _action(slot: str, kind: str) -> tuple:
|
||||
"""머뭄 칸의 (체류범위, 한 줄). 마지막 날 아침만 퇴실을 겸한다."""
|
||||
if slot == "아침" and kind in CHECKOUT_KINDS:
|
||||
return STAY_ACTION["아침(퇴실)"]
|
||||
return STAY_ACTION[slot]
|
||||
|
||||
|
||||
def _stay_spec(slot: str, place: str, kind: str) -> tuple:
|
||||
if place == STAY:
|
||||
return _action(slot, kind)[0]
|
||||
return PLACES[place][3]
|
||||
|
||||
|
||||
def _note(slot: str, place: str, kind: str) -> str | None:
|
||||
if place == STAY:
|
||||
return _action(slot, kind)[1]
|
||||
return PLACES[place][4]
|
||||
|
||||
|
||||
def plan_day(kind: str, places: list[str], label: str, start: int, pace: float):
|
||||
"""하루 하나를 시각까지 계산한다. 창을 못 맞추면 None.
|
||||
|
||||
★ 창을 맞추는 방법은 **앞 칸에 더 머무는 것**이다. 이동시간을 늘리면 거짓말이 되고,
|
||||
빈 시간을 끼우면 시간표에 구멍이 난다. 사람은 실제로 그렇게 한다 — 밥 때가 이르면
|
||||
박물관을 조금 더 본다. 범위(min~max)를 벗어나면 그 조합은 포기한다.
|
||||
"""
|
||||
slots = SKELETON[kind]
|
||||
need = [s for s in slots if not s.endswith("@")]
|
||||
if len(places) != len(need):
|
||||
raise ValueError(f"{label}/{kind}: 자리 {len(need)}개인데 장소 {len(places)}개")
|
||||
|
||||
feed = iter(places)
|
||||
rows = []
|
||||
for slot in slots:
|
||||
name = slot[:-1] if slot.endswith("@") else slot
|
||||
place = STAY if slot.endswith("@") else next(feed)
|
||||
low, base, high = _stay_spec(name, place, kind)
|
||||
rows.append({"slot": name, "place": place, "kind": kind,
|
||||
"stay": max(low, min(high, int(round(base * pace)))),
|
||||
"low": low, "high": high})
|
||||
|
||||
# ★ 마지막 날 첫 칸(아침 겸 퇴실)은 11시 전에 **끝나야** 한다.
|
||||
# 체크아웃을 따로 세우던 때는 이 칸이 머뭄에 두 번 서게 만들었다(2026-09-11 감사).
|
||||
if kind in CHECKOUT_KINDS:
|
||||
if start + rows[0]["stay"] > CHECK_OUT_BY:
|
||||
rows[0]["stay"] = max(rows[0]["low"], CHECK_OUT_BY - start)
|
||||
if start + rows[0]["stay"] > CHECK_OUT_BY or rows[0]["stay"] < rows[0]["low"]:
|
||||
return None
|
||||
|
||||
clock = start
|
||||
prev = BASE
|
||||
for index, row in enumerate(rows):
|
||||
coords = (PLACES[row["place"]][0], PLACES[row["place"]][1])
|
||||
# 하루의 1번은 머뭄이다 — 이미 그 자리에 있으니 이동시간이 없다.
|
||||
row["move"] = 0 if index == 0 else move_minutes(prev, coords)
|
||||
arrive = clock + row["move"]
|
||||
|
||||
window = WINDOW.get(row["slot"])
|
||||
if window:
|
||||
early, late = window
|
||||
if arrive < early:
|
||||
# 앞 칸들에 더 머문다. 뒤에서부터 늘려야 방금 지나온 자리가 길어진다.
|
||||
short = early - arrive
|
||||
for back in reversed(rows[:index]):
|
||||
room = back["high"] - back["stay"]
|
||||
take = min(room, short)
|
||||
back["stay"] += take
|
||||
short -= take
|
||||
if short == 0:
|
||||
break
|
||||
if short > 0:
|
||||
return None # 늘려도 못 채운다 — 시작을 늦춰야 하는 조합
|
||||
return plan_day_fixed(rows, start)
|
||||
if arrive > late:
|
||||
short = arrive - late
|
||||
for back in reversed(rows[:index]):
|
||||
room = back["stay"] - back["low"]
|
||||
take = min(room, short)
|
||||
back["stay"] -= take
|
||||
short -= take
|
||||
if short == 0:
|
||||
break
|
||||
if short > 0:
|
||||
return None # 줄여도 못 당긴다 — 시작을 앞당겨야 하는 조합
|
||||
return plan_day_fixed(rows, start)
|
||||
|
||||
clock = arrive + row["stay"]
|
||||
prev = coords
|
||||
|
||||
if clock > DAY_ENDS_BY:
|
||||
return None
|
||||
return _render(rows, start, label)
|
||||
|
||||
|
||||
def plan_day_fixed(rows: list[dict], start: int):
|
||||
"""체류시간을 고친 뒤 처음부터 다시 훑는다(앞 칸을 바꿨으니 뒤 시각이 전부 달라진다)."""
|
||||
clock = start
|
||||
prev = BASE
|
||||
for index, row in enumerate(rows):
|
||||
coords = (PLACES[row["place"]][0], PLACES[row["place"]][1])
|
||||
row["move"] = 0 if index == 0 else move_minutes(prev, coords)
|
||||
arrive = clock + row["move"]
|
||||
window = WINDOW.get(row["slot"])
|
||||
if window and not (window[0] <= arrive <= window[1]):
|
||||
return None
|
||||
clock = arrive + row["stay"]
|
||||
prev = coords
|
||||
if clock > DAY_ENDS_BY:
|
||||
return None
|
||||
return rows, start
|
||||
|
||||
|
||||
def _render(rows: list[dict], start: int, label: str) -> dict:
|
||||
stops = []
|
||||
for row in rows:
|
||||
place = row["place"]
|
||||
stop = {"name": f"{row['slot']} · {place}", "minutes": row["stay"],
|
||||
"moveMinutes": row["move"],
|
||||
"latitude": PLACES[place][0], "longitude": PLACES[place][1]}
|
||||
note = _note(row["slot"], place, row.get("kind", ""))
|
||||
if note:
|
||||
stop["note"] = note
|
||||
query = PLACES[place][2]
|
||||
if query:
|
||||
stop["searchQuery"] = query
|
||||
stops.append(stop)
|
||||
return {"label": label, "startTime": f"{start // 60:02d}:{start % 60:02d}", "stops": stops}
|
||||
|
||||
|
||||
def build_day(kind: str, places: list[str], label: str, start: str | None, pace: float) -> dict:
|
||||
"""시작 시각을 옮겨 가며 모든 창을 맞추는 하루를 찾는다."""
|
||||
begin = _minutes(start or START[kind])
|
||||
floor = _minutes(FLOOR[kind])
|
||||
candidates = [begin] + [begin + delta for delta in range(10, 190, 10)] \
|
||||
+ [begin - delta for delta in range(10, 190, 10) if begin - delta >= floor]
|
||||
for scale in (pace, pace * 0.92, pace * 0.85, 1.0, 0.8):
|
||||
for clock in candidates:
|
||||
if clock < floor or clock > 20 * 60:
|
||||
continue
|
||||
result = plan_day(kind, places, label, clock, scale)
|
||||
if result is None:
|
||||
continue
|
||||
if isinstance(result, tuple): # 창을 맞추느라 체류를 고친 경우
|
||||
rows, begin_at = result
|
||||
return _render(rows, begin_at, label)
|
||||
return result
|
||||
raise ValueError(f"{label}/{kind}: 창(입·퇴실·끼니·21시)을 다 맞추는 시각이 없다")
|
||||
|
||||
|
||||
# ── 테마 20개 ────────────────────────────────────────────────────────────────
|
||||
# (이름, 기간, 누구에게, 왜, [(뼈대, [장소…])])
|
||||
THEMES = [
|
||||
("섬까지 다녀오는 1박 2일", "1박 2일", "차를 가져온 손님",
|
||||
"첫날은 걸어서 도심, 이튿날은 다리를 건너 섬입니다. 두 날의 성격이 아주 다릅니다.",
|
||||
[("first", ["히로쓰 가옥", "초원사진관", "빈해원", "군산 내항"]),
|
||||
("last_island", ["고군산군도 선유도", "국제반점"])]),
|
||||
("걸어서만 도는 1박 2일", "1박 2일", "차 없이 오는 손님",
|
||||
"모든 정거장이 머뭄에서 걸어 닿습니다. 이틀 동안 차를 한 번도 타지 않습니다.",
|
||||
[("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]),
|
||||
("last", ["군산근대건축관", "한일옥", "우체통거리"])]),
|
||||
("비가 와도 되는 1박 2일", "1박 2일", "비 예보를 보고 오는 손님",
|
||||
"실내가 이어집니다. 비가 그치기를 기다리지 않아도 하루가 찹니다.",
|
||||
[("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]),
|
||||
("last", ["군산근대건축관", "일흥옥", "째보선창 근처 카페"])]),
|
||||
("근대건축만 보는 1박 2일", "1박 2일", "건물을 보러 오는 손님",
|
||||
"1900년대 건물만 골라 돕니다. 머뭄도 그 시기에 지은 집입니다.",
|
||||
[("first", ["구 군산세관 본관", "군산근대건축관", "명월갈비", "군산 내항"]),
|
||||
("last", ["군산근대미술관", "일흥옥", "해망굴"])]),
|
||||
("이성당으로 시작하는 1박 2일", "1박 2일", "빵을 좋아하는 손님",
|
||||
"이튿날 아침을 빵으로 엽니다. 문 여는 시간에 맞춰 걸어갑니다.",
|
||||
[("first", ["초원사진관", "우체통거리", "도란", "군산 내항"]),
|
||||
("last", ["이성당", "만남스넥", "군산근대역사박물관"])]),
|
||||
("철길마을까지 가는 1박 2일", "1박 2일", "사진을 찍는 손님",
|
||||
"빛이 좋은 자리만 이어 붙였습니다. 철길마을은 오전 빛이 낫습니다.",
|
||||
[("first", ["초원사진관", "동국사", "국제반점", "군산 내항"]),
|
||||
("last", ["경암동 철길마을", "일흥옥", "군산 시간여행마을"])]),
|
||||
("항구만 도는 1박 2일", "1박 2일", "바다가 보고 싶은 손님",
|
||||
"부잔교에서 째보선창까지, 이틀 동안 물가만 걷습니다.",
|
||||
[("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]),
|
||||
("last", ["째보선창", "국제반점", "구 군산세관 본관"])]),
|
||||
("소설 『탁류』를 따라 걷는 1박 2일", "1박 2일", "채만식을 읽어 본 손님",
|
||||
"소설이 지나간 자리를 순서대로 밟습니다. 세관·선창·문학관이 한 줄로 이어집니다.",
|
||||
[("first", ["째보선창", "군산 내항 부잔교", "한일옥", "군산 내항"]),
|
||||
("last", ["채만식문학관", "일흥옥", "구 군산세관 본관"])]),
|
||||
("아이와 함께 1박 2일", "1박 2일", "아이를 데려온 손님",
|
||||
"걷는 거리가 짧고, 아이가 지루해할 자리를 뺐습니다.",
|
||||
[("first", ["초원사진관", "우체통거리", "만남스넥", "군산 내항"]),
|
||||
("last", ["경암동 철길마을", "이성당", "월명호수"])]),
|
||||
("늦게 도착하는 1박 2일", "1박 2일", "퇴근하고 오는 손님",
|
||||
"첫날은 저녁 한 끼로 끝냅니다. 대신 이튿날을 아침부터 저녁까지 씁니다.",
|
||||
[("first_late", ["명월갈비"]),
|
||||
("last_plus", ["군산근대역사박물관", "한일옥", "군산근대건축관", "해망굴"])]),
|
||||
|
||||
("천천히 도는 2박 3일", "2박 3일", "쉬러 오는 손님",
|
||||
"하루에 두어 곳만 봅니다. 마당에 앉아 있는 시간을 일정에 넣었습니다.",
|
||||
[("first", ["히로쓰 가옥", "동국사", "도란", "군산 내항"]),
|
||||
("mid_rest", ["한일옥", "빈해원"]),
|
||||
("last", ["이성당", "만남스넥", "군산 시간여행마을"])]),
|
||||
("섬과 도심을 반씩 도는 2박 3일", "2박 3일", "차를 가져온 손님",
|
||||
"가운데 날을 섬에 다 씁니다. 첫날과 마지막 날은 걸어서 도심입니다.",
|
||||
[("first", ["구 군산세관 본관", "군산근대건축관", "빈해원", "군산 내항"]),
|
||||
("mid_island", ["고군산군도 선유도", "장자도", "국제반점", "명월갈비"]),
|
||||
("last", ["초원사진관", "일흥옥", "이성당"])]),
|
||||
("골목을 세 번 나눠 걷는 2박 3일", "2박 3일", "차 없이 오는 손님",
|
||||
"원도심을 세 덩이로 나눠 하루에 하나씩 걷습니다. 같은 길을 두 번 걷지 않습니다.",
|
||||
[("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]),
|
||||
("mid", ["군산근대역사박물관", "한일옥", "군산근대건축관", "해망굴", "명월갈비"]),
|
||||
("last", ["우체통거리", "만남스넥", "군산 시간여행마을"])]),
|
||||
("먹으러 오는 2박 3일", "2박 3일", "맛집을 도는 손님",
|
||||
"끼니가 중심입니다. 사이에 걸을 자리를 넣어 배를 비웁니다.",
|
||||
[("first", ["물밀소", "초원사진관", "명월갈비", "군산 내항"]),
|
||||
("mid", ["군산 시간여행마을", "일흥옥", "이성당", "우체통거리", "빈해원"]),
|
||||
("last", ["동국사", "국제반점", "군산근대역사박물관"])]),
|
||||
("아무 데도 안 가는 2박 3일", "2박 3일", "쉬려고만 오는 손님",
|
||||
"가운데 날은 대문 밖으로 두 번만 나갑니다. 나머지는 마당과 카페 공간에서 보냅니다.",
|
||||
[("first", ["히로쓰 가옥", "초원사진관", "도란", "군산 내항"]),
|
||||
("mid_rest", ["만남스넥", "일흥옥"]),
|
||||
("last", ["동국사", "물밀소", "월명호수"])]),
|
||||
("사진만 찍는 2박 3일", "2박 3일", "사진을 찍는 손님",
|
||||
"시간대에 맞춰 자리를 배치했습니다. 철길마을은 오전, 부잔교는 해 질 때입니다.",
|
||||
[("first", ["초원사진관", "동국사", "도란", "군산 내항"]),
|
||||
("mid", ["경암동 철길마을", "만남스넥", "째보선창", "해망굴", "국제반점"]),
|
||||
("last", ["우체통거리", "일흥옥", "군산근대미술관"])]),
|
||||
("비가 와도 되는 2박 3일", "2박 3일", "비 예보를 보고 오는 손님",
|
||||
"사흘 내리 비가 와도 도는 순서가 바뀌지 않습니다. 실내와 처마 밑만 잇습니다.",
|
||||
[("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]),
|
||||
("mid", ["군산근대건축관", "일흥옥", "째보선창 근처 카페", "동국사다온", "명월갈비"]),
|
||||
("last", ["채만식문학관", "한일옥", "이성당"])]),
|
||||
("늦게 시작하는 2박 3일", "2박 3일", "아침이 힘든 손님",
|
||||
"가운데 날을 열한 시에 엽니다. 오전을 비우고 저녁을 늦게까지 씁니다.",
|
||||
[("first", ["히로쓰 가옥", "우체통거리", "도란", "군산 내항"]),
|
||||
("mid_late", ["일흥옥", "군산근대역사박물관", "해망굴", "빈해원"]),
|
||||
("last", ["이성당", "만남스넥", "초원사진관"])]),
|
||||
("섬에서 하루를 다 쓰는 2박 3일", "2박 3일", "차를 가져온 손님",
|
||||
"가운데 날은 아침에 나가 저녁에 들어옵니다. 섬에서 하루를 통째로 씁니다.",
|
||||
[("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]),
|
||||
("mid_island", ["고군산군도 선유도", "장자도", "한일옥", "명월갈비"]),
|
||||
("last", ["군산근대건축관", "만남스넥", "경암동 철길마을"])]),
|
||||
("소설 『탁류』를 따라 걷는 2박 3일", "2박 3일", "채만식을 읽어 본 손님",
|
||||
"소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다.",
|
||||
[("first", ["째보선창", "군산 내항 부잔교", "일흥옥", "군산 내항"]),
|
||||
("mid_rest", ["한일옥", "빈해원"]),
|
||||
("last", ["군산근대역사박물관", "국제반점", "초원사진관"])]),
|
||||
("철길마을과 항구를 같이 보는 2박 3일", "2박 3일", "사진을 찍는 손님",
|
||||
"기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다.",
|
||||
[("first", ["경암동 철길마을", "초원사진관", "국제반점", "군산 내항"]),
|
||||
("mid", ["군산 내항 부잔교", "한일옥", "째보선창", "해망굴", "명월갈비"]),
|
||||
("last", ["구 군산세관 본관", "일흥옥", "군산근대미술관"])]),
|
||||
]
|
||||
|
||||
# 테마 이름 → (날짜별 시작 시각, 체류 배율). 시각과 배율이 그 테마의 성격이다 —
|
||||
# 쉬러 오는 손님(1.25)과 사진만 찍는 손님(0.8)은 같은 곳을 봐도 하루 길이가 다르다.
|
||||
# 창(끼니·입퇴실)에 걸리면 build_day 가 시작을 옮겨 맞춘다.
|
||||
PACING = {
|
||||
"섬까지 다녀오는 1박 2일": (["15:00", "08:20"], 1.0),
|
||||
"걸어서만 도는 1박 2일": (["15:00", "09:20"], 1.05),
|
||||
"비가 와도 되는 1박 2일": (["15:20", "09:40"], 1.15),
|
||||
"근대건축만 보는 1박 2일": (["15:00", "08:50"], 0.95),
|
||||
"이성당으로 시작하는 1박 2일": (["16:10", "07:30"], 0.9),
|
||||
"철길마을까지 가는 1박 2일": (["15:40", "08:30"], 0.95),
|
||||
"항구만 도는 1박 2일": (["16:30", "09:20"], 1.05),
|
||||
"소설 『탁류』를 따라 걷는 1박 2일": (["15:10", "09:00"], 1.1),
|
||||
"아이와 함께 1박 2일": (["15:30", "10:10"], 0.85),
|
||||
"늦게 도착하는 1박 2일": (["19:00", "08:40"], 1.0),
|
||||
"천천히 도는 2박 3일": (["16:20", "10:40", "09:50"], 1.25),
|
||||
"섬과 도심을 반씩 도는 2박 3일": (["15:10", "07:40", "09:20"], 1.0),
|
||||
"골목을 세 번 나눠 걷는 2박 3일": (["15:00", "09:10", "08:20"], 1.1),
|
||||
"먹으러 오는 2박 3일": (["16:40", "10:20", "08:40"], 1.15),
|
||||
"사진만 찍는 2박 3일": (["15:50", "07:20", "07:40"], 0.8),
|
||||
"비가 와도 되는 2박 3일": (["15:20", "10:10", "09:40"], 1.2),
|
||||
"아무 데도 안 가는 2박 3일": (["15:00", "10:00", "09:00"], 1.3),
|
||||
"늦게 시작하는 2박 3일": (["17:20", "10:30", "10:40"], 1.0),
|
||||
"섬에서 하루를 다 쓰는 2박 3일": (["15:00", "07:30", "09:40"], 0.95),
|
||||
"소설 『탁류』를 따라 걷는 2박 3일": (["15:00", "09:50", "08:30"], 1.1),
|
||||
"철길마을과 항구를 같이 보는 2박 3일": (["16:00", "07:50", "09:30"], 0.9),
|
||||
}
|
||||
|
||||
items = []
|
||||
for name, duration, audience, why, days in THEMES:
|
||||
starts, pace = PACING.get(name, (None, 1.0))
|
||||
built = [build_day(kind, places, DAY_LABEL[i],
|
||||
(starts[i] if starts and i < len(starts) else None), pace)
|
||||
for i, (kind, places) in enumerate(days)]
|
||||
items.append({"name": name, "duration": duration, "audience": audience, "why": why,
|
||||
"days": built})
|
||||
|
||||
env = {"kind": "itinerary", "version": 1, "title": "추천 일정",
|
||||
"subtitle": "머뭄에서 나서고 머뭄으로 돌아옵니다", "items": items}
|
||||
SECT["itinerary"]["data"] = json.dumps(env, ensure_ascii=False)
|
||||
|
||||
|
||||
# ── 검증: 규칙을 어기면 **빌드를 멈춘다** ────────────────────────────────────
|
||||
#
|
||||
# ★ 왜 스크립트 안인가 (2026-09-11)
|
||||
# 지금까지 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다. 그래서 뼈대를 고칠 때마다
|
||||
# "이번에 안 본 규칙" 이 생겼고, 사장님이 화면에서 먼저 찾았다 —
|
||||
# '아침 · 머뭄' 다음 칸이 '체크아웃 · 머뭄' 인 일정이 그렇게 나갔다.
|
||||
# 검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다. 이제 어기면 payload 를 안 만든다.
|
||||
FOOD_SLOTS = {"점심", "저녁", "늦은 점심"}
|
||||
FOOD = {"빈해원", "일흥옥", "한일옥", "명월갈비", "국제반점", "만남스넥", "도란", "물밀소",
|
||||
"이성당", "째보선창 근처 카페", "동국사다온"}
|
||||
INDOOR = {"군산근대역사박물관", "군산근대건축관", "군산근대미술관", "채만식문학관", STAY} | FOOD
|
||||
NEEDS_CAR = {"고군산군도 선유도", "장자도", "채만식문학관", "경암동 철길마을", "월명호수", "은적사"}
|
||||
REST_SLOTS = {"쉼", "머뭄에서 쉬는 날"}
|
||||
NIGHT_ONLY = {"군산 내항"}
|
||||
BAR = {"도란"}
|
||||
DAYS_OF = {"1박 2일": 2, "2박 3일": 3}
|
||||
|
||||
|
||||
def audit(built: list[dict]) -> list[str]:
|
||||
bad: list[str] = []
|
||||
|
||||
def flag(item, day, text):
|
||||
bad.append(f"{item['name']} / {day['label'] if day else '-'} — {text}")
|
||||
|
||||
for item in built:
|
||||
want = DAYS_OF.get(item["duration"])
|
||||
if want and len(item["days"]) != want:
|
||||
flag(item, None, f"{item['duration']}인데 {len(item['days'])}일")
|
||||
seen_all: list[str] = []
|
||||
|
||||
for day in item["days"]:
|
||||
slots = [s["name"].split(" · ")[0] for s in day["stops"]]
|
||||
places = [s["name"].split(" · ")[-1] for s in day["stops"]]
|
||||
seen_all += [p for p in places if p != STAY]
|
||||
|
||||
# ① 하루의 처음과 끝은 머뭄
|
||||
if places[0] != STAY:
|
||||
flag(item, day, f"1번이 머뭄이 아니다 ({places[0]})")
|
||||
if places[-1] != STAY:
|
||||
flag(item, day, f"마지막이 머뭄이 아니다 ({places[-1]})")
|
||||
|
||||
# ② 같은 자리가 연달아 서지 않는다
|
||||
for i in range(len(places) - 1):
|
||||
if places[i] == places[i + 1]:
|
||||
flag(item, day, f"같은 자리 연속: {slots[i]} → {slots[i + 1]} ({places[i]})")
|
||||
|
||||
# ③ 가운데 머뭄 칸은 쉼·쉬는 날만
|
||||
for i in range(1, len(places) - 1):
|
||||
if places[i] == STAY and slots[i] not in REST_SLOTS:
|
||||
flag(item, day, f"가운데 머뭄 칸이 쉼이 아니다 ({slots[i]})")
|
||||
|
||||
clock = _minutes(day["startTime"])
|
||||
prev = BASE
|
||||
for i, stop in enumerate(day["stops"]):
|
||||
# ④ 이동시간은 좌표 계산값과 같아야 한다
|
||||
real = 0 if i == 0 else move_minutes(prev, (stop["latitude"], stop["longitude"]))
|
||||
if stop["moveMinutes"] != real:
|
||||
flag(item, day, f"이동시간 {stop['moveMinutes']}분 (실거리 {real}분) — {places[i]}")
|
||||
arrive = clock + stop["moveMinutes"]
|
||||
|
||||
# ⑤ 시간대가 정해진 자리
|
||||
window = WINDOW.get(slots[i])
|
||||
if window and not (window[0] <= arrive <= window[1]):
|
||||
flag(item, day, f"{slots[i]} 도착 {arrive // 60}:{arrive % 60:02d} — 창 밖")
|
||||
|
||||
# ⑥ 마지막 날 첫 칸(아침 겸 퇴실)은 11시 전에 끝난다
|
||||
if i == 0 and slots[0] == "아침" and len(item["days"]) == want and day is item["days"][-1]:
|
||||
if arrive + stop["minutes"] > CHECK_OUT_BY:
|
||||
end = arrive + stop["minutes"]
|
||||
flag(item, day, f"퇴실이 {end // 60}:{end % 60:02d} — 11시를 넘긴다")
|
||||
|
||||
# ⑧ 끼니 자리에는 먹는 곳만
|
||||
if slots[i] in FOOD_SLOTS and places[i] not in FOOD:
|
||||
flag(item, day, f"{slots[i]} 자리에 식당이 아닌 {places[i]}")
|
||||
if slots[i] == "밤 산책" and places[i] not in NIGHT_ONLY:
|
||||
flag(item, day, f"밤 산책 자리에 {places[i]}")
|
||||
|
||||
clock = arrive + stop["minutes"]
|
||||
prev = (stop["latitude"], stop["longitude"])
|
||||
|
||||
# ⑦ 21시를 넘기지 않는다(넘기면 렌더러가 그 칸을 조용히 버린다)
|
||||
if clock > DAY_ENDS_BY:
|
||||
flag(item, day, f"{clock // 60}:{clock % 60:02d} 종료 — 21시 초과")
|
||||
|
||||
# ⑨ 같은 날 같은 식당 두 번
|
||||
meals = [p for sl, p in zip(slots, places) if sl in FOOD_SLOTS]
|
||||
if len(meals) != len(set(meals)):
|
||||
flag(item, day, f"같은 날 같은 식당 반복 {meals}")
|
||||
|
||||
# ⑪⑫⑭ 테마가 약속한 성격
|
||||
if "비가 와도" in item["name"]:
|
||||
for sl, pl in zip(slots, places):
|
||||
if sl in ("오전", "오후") and pl not in INDOOR:
|
||||
flag(item, day, f"비 테마인데 실외 {pl}")
|
||||
if "걸어서만" in item["name"] or "차 없이" in item["audience"]:
|
||||
for pl in places:
|
||||
if pl in NEEDS_CAR:
|
||||
flag(item, day, f"걷는 테마인데 차가 필요한 {pl}")
|
||||
if "아이와" in item["name"]:
|
||||
for pl in places:
|
||||
if pl in BAR:
|
||||
flag(item, day, f"아이 동반인데 {pl}")
|
||||
|
||||
# ⑩ 한 테마 안에서 같은 곳을 두 번 가지 않는다
|
||||
repeat = sorted({p for p in seen_all if seen_all.count(p) > 1})
|
||||
if repeat:
|
||||
flag(item, None, f"테마 안에서 반복 {repeat}")
|
||||
|
||||
return bad
|
||||
|
||||
|
||||
problems = audit(items)
|
||||
for line in problems:
|
||||
print(" ✗", line)
|
||||
if problems:
|
||||
raise SystemExit(f"\n규칙 위반 {len(problems)}건 — payload 를 쓰지 않았다.")
|
||||
|
||||
for item in items:
|
||||
for day in item["days"]:
|
||||
clock = _minutes(day["startTime"])
|
||||
for stop in day["stops"]:
|
||||
clock += stop["moveMinutes"] + stop["minutes"]
|
||||
print(f"{item['name'][:24]:26} {day['label']} {day['startTime']}–"
|
||||
f"{clock // 60:02d}:{clock % 60:02d} {len(day['stops'])}칸")
|
||||
|
||||
DST.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"\n테마 {len(items)}개 · 규칙 14종 위반 0건 · payload {DST.stat().st_size:,}B")
|
||||
229
solution/site/scripts/mockup/build_story.py
Normal file
@ -0,0 +1,229 @@
|
||||
"""가요 다방(군산 노래)과 인물 열전을 다시 채운다.
|
||||
|
||||
★ 지어내지 않는다.
|
||||
· 노래: '음악의 오솔길' 의 군산·전북 지역노래 정리에서 온 목록이다(곡명·가수·작사·작곡·연도).
|
||||
링크는 **유튜브 검색 주소**다 — 영상 ID 를 박으면 틀린 영상이 붙을 수 있고, 우리가 그걸
|
||||
한 곡씩 확인하지 않았다. 검색은 틀릴 수가 없다.
|
||||
· 인물: 한국어 위키백과 '군산시 출신' 분류에서 뽑아 문서 첫 문단으로 한 줄을 적었다.
|
||||
사진은 넣지 않는다(초상권 · 2026-09-10 결정). 출처 링크는 그 사람의 위키 문서다.
|
||||
|
||||
★ 50명을 억지로 채우지 않는다 — 분류에 실제로 있는 사람만 담는다.
|
||||
노래도 마찬가지다. 확인된 곡이 25곡이면 25곡이다.
|
||||
"""
|
||||
import json
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
SP = Path(__file__).parent
|
||||
SRC = SP / "stay-payload-new.json"
|
||||
|
||||
payload = json.loads(SRC.read_text(encoding="utf-8"))
|
||||
SECT = {s["id"]: s for s in payload["theme"]["sections"]}
|
||||
|
||||
SONG_SOURCE = {"name": "음악의 오솔길 — 군산 관련 노래",
|
||||
"url": "https://old-music.tistory.com/entry/%EA%B5%B0%EC%82%B0-%EA%B4%80%EB%A0%A8-%EB%85%B8%EB%9E%98"}
|
||||
|
||||
# (곡명, 가수, 작사, 작곡, 연도, 라벨색, 한 줄)
|
||||
# ★ 연도·작사·작곡을 모르면 비운다. "미상" 이라고 적지 않는다 — 렌더러가 없는 칸을 안 그린다.
|
||||
SONGS = [
|
||||
("잘 있거라 군산항", "안다성", "손석우", "손석우", 1963, "#2f6b4f",
|
||||
"월명산 고갯길로 시작하는 이별가. 군산을 노래한 곡 가운데 가장 널리 알려졌습니다."),
|
||||
("헤어진 군산항", "박제연", "송운선", "송운선", 1965, "#2a6053",
|
||||
"비 내리는 밤 부두에서 헤어지는 노래입니다."),
|
||||
("님 떠난 군산항", "이미자", "조형식", "백영호", 1966, "#1d3c74",
|
||||
"떠나는 배를 보내는 항구의 정서를 담았습니다."),
|
||||
("금강 나그네", "이미자", "이병훈", "박춘석", 1966, "#d4551f",
|
||||
"금강 하구와 군산항 부두를 배경으로 한 곡입니다."),
|
||||
("잘 있거라 군산항구", "최정자", "최치수", "유금춘", 1967, "#8a4b2a",
|
||||
"떠나는 사람의 말로 부르는 이별가입니다."),
|
||||
("군산 마도로스", "이상열", None, None, 1967, "#334b6d",
|
||||
"항구를 드나들던 뱃사람의 노래입니다."),
|
||||
("군산 부르스", "유주용", "반야월", "강도미", 1969, "#4a3f6b",
|
||||
"1960년대 말 군산 거리의 분위기를 담은 곡입니다."),
|
||||
("이별의 군산항 부두", "최정자", "최치수", "유금춘", 1969, "#6b2f3a",
|
||||
"부두에서의 이별을 다시 한 번 부른 곡입니다."),
|
||||
("군산항 부르스", "최하우", "최하우", "김성근", 1984, "#2f5b6b",
|
||||
"1980년대에 나온 군산항 노래입니다."),
|
||||
("나의 사랑 군산이여", "오단비", "이수", "정종택", 1989, "#a34a2a",
|
||||
"군산을 향한 애정을 정면으로 부르는 곡입니다."),
|
||||
("군산항 엘레지", "오단비", "이수", "정종택", None, "#7a3b52",
|
||||
"항구 도시의 비가입니다."),
|
||||
("선유도 아가씨", "오단비", "이수", "서승일", None, "#2b6b7a",
|
||||
"고군산군도 선유도를 배경으로 한 곡입니다."),
|
||||
("군산찬가", "오단비", "이수", "서승일", None, "#c8873a",
|
||||
"은파와 새만금이 가사에 나오는 군산 찬가입니다."),
|
||||
("오성산", "오단비", "이수", "서승일", None, "#4f6b3a",
|
||||
"금강 하구를 내려다보는 오성산을 노래했습니다."),
|
||||
("함께하는 군산", "오은주", "김동찬", "김동찬", None, "#b5603a",
|
||||
"군산시가 제작한 음반의 첫 곡입니다."),
|
||||
("새만금 연가", "채미영", "김동찬", "김동찬 · 김수환", None, "#356b8a",
|
||||
"새만금을 소재로 한 곡입니다."),
|
||||
("군산시민의 노래", "오단비", "군산문화원", "군산예총", None, "#5a5a4a",
|
||||
"금만경 들녘과 서해 바다로 시작하는 시민의 노래입니다."),
|
||||
("군산 아줌마", "박솔미", "송두호", "송두호", None, "#a3562a",
|
||||
"선창가 거리와 새만금이 함께 나오는 곡입니다."),
|
||||
("내 고향 군산항", "정애란", "정애란", "정애란", None, "#6b4a2a",
|
||||
"고향으로서의 군산항을 부른 곡입니다."),
|
||||
("군산 가는 길", "황상준", None, None, None, "#3a5a4a",
|
||||
"군산으로 향하는 길을 제목으로 삼은 곡입니다."),
|
||||
("군산 추억", "임인건", None, None, None, "#4a4a6b",
|
||||
"재즈 피아니스트 임인건이 군산을 소재로 만든 곡입니다."),
|
||||
("군산에서", "임인건", None, None, None, "#3f5f6b",
|
||||
"같은 연주자가 군산에서 받은 인상을 담았습니다."),
|
||||
("헤어진 군산항", "김창환", "송운선", "송운선", None, "#2a6053",
|
||||
"군산시 제작 음반에 다시 실린 판입니다."),
|
||||
("금강 아가씨", "이미자", None, None, None, "#d4551f",
|
||||
"금강을 배경으로 한 이미자의 또 다른 곡입니다."),
|
||||
("금강의 비가", "전승애", None, None, None, "#7a4a6b",
|
||||
"금강을 제목에 올린 곡입니다."),
|
||||
]
|
||||
|
||||
|
||||
def youtube(title: str, artist: str | None) -> str:
|
||||
query = f"{title} {artist or ''}".strip()
|
||||
return "https://www.youtube.com/results?search_query=" + urllib.parse.quote(query)
|
||||
|
||||
|
||||
songs = []
|
||||
for title, artist, lyricist, composer, year, color, story in SONGS:
|
||||
item = {"title": title, "artist": artist, "labelColor": color, "story": story,
|
||||
"verified": "확인" if year else "확인필요",
|
||||
"listenUrl": youtube(title, artist), "source": SONG_SOURCE}
|
||||
if lyricist:
|
||||
item["lyricist"] = lyricist
|
||||
if composer:
|
||||
item["composer"] = composer
|
||||
if year:
|
||||
item["year"] = year
|
||||
songs.append(item)
|
||||
|
||||
SECT["songs"]["data"] = json.dumps(
|
||||
{"kind": "songs", "version": 1, "title": "가요 다방",
|
||||
"subtitle": f"군산을 노래한 {len(songs)}곡 — 눌러서 들어 보세요", "items": songs},
|
||||
ensure_ascii=False)
|
||||
|
||||
|
||||
# ── 인물 열전 ────────────────────────────────────────────────────────────────
|
||||
# (위키 문서 제목, 표시 이름, 연도, 하는 일, 한 줄)
|
||||
# ★ 한 줄은 위키 첫 문단을 줄인 것이다. 군산과의 관계가 문서에 적힌 사람만 담는다.
|
||||
PEOPLE = [
|
||||
("채만식", "채만식", "1902–1950", "소설가", "군산을 무대로 한 『탁류』를 썼습니다. 시내에 문학관이 있습니다."),
|
||||
("고은", "고은", "1933–", "시인", "군산에서 태어나 60년 넘게 시를 써 온 시인입니다."),
|
||||
("이병훈 (시인)", "이병훈", "1925–2009", "시인", "옥구(지금의 군산) 출신으로 1950년대에 등단한 현대 시인입니다."),
|
||||
("심호택", "심호택", "1947–2010", "시인", "민중의 삶을 시로 옮긴 시인입니다."),
|
||||
("문효치", "문효치", "1943–", "시인", "군산에서 태어나 1966년 신춘문예로 등단했습니다."),
|
||||
("이연주 (시인)", "이연주", "1953–1992", "시인", "짧게 활동하고 떠난 시인입니다."),
|
||||
("주하림", "주하림", None, "시인", "군산에서 태어나 2009년 창비신인시인상으로 등단했습니다."),
|
||||
("고형곤", "고형곤", "1906–2004", "철학자", "선(禪) 연구로 알려진 철학자이자 정치가입니다."),
|
||||
("김선기 (언어철학자)", "김선기", "1907–1992", "언어학자", "호는 무돌. 한글 연구에 힘쓴 언어학자이자 교육자입니다."),
|
||||
("반석평", "반석평", "1472–1540", "조선 문신", "노비 출신으로 문과에 급제해 판서에 오른 조선 중종 때 문신입니다."),
|
||||
|
||||
("김수미", "김수미", "1949–2024", "배우", "「전원일기」 일용 엄니로 오래 기억되는 배우입니다."),
|
||||
("송새벽", "송새벽", "1979–", "배우", "군산 출신 배우입니다."),
|
||||
("장신영", "장신영", "1984–", "배우", "군산 출신 배우입니다."),
|
||||
("이서원", "이서원", "1997–", "배우", "군산 출신 배우입니다."),
|
||||
("장재호 (배우)", "장재호", "1986–", "배우", "군산 출신 배우입니다."),
|
||||
("강민지 (배우)", "강민지", "1996–", "배우 · 모델", "모델로 시작해 드라마와 영화로 활동 범위를 넓혔습니다."),
|
||||
("병헌", "병헌", "1993–", "배우", "그룹 틴탑 활동을 거쳐 배우로 옮겼습니다."),
|
||||
("정혁 (모델)", "정혁", "1991–", "모델", "2015년 서울패션위크로 데뷔한 모델 겸 방송인입니다."),
|
||||
("김혜선 (희극인)", "김혜선", "1983–", "희극인", "군산 출신 희극 배우입니다."),
|
||||
("이옥주 (희극인)", "이옥주", "1969–", "희극인", "군산중앙여고를 나와 1988년 MBC 개그콘테스트 대상을 받았습니다."),
|
||||
("박화요비", "박화요비", "1982–", "가수", "R&B 를 주로 부르는 가수입니다."),
|
||||
("박윤경 (가수)", "박윤경", "1970–", "가수", "군산 출신 가수 겸 기업인입니다."),
|
||||
("하준 (가수)", "하준", "1965–", "가수", "군산 출신 가수입니다."),
|
||||
|
||||
("고건", "고건", "1938–", "정치인", "제30·35대 국무총리를 지냈습니다. 아버지는 철학자 고형곤입니다."),
|
||||
("강봉균 (정치인)", "강봉균", "1943–2017", "정치인", "재정경제부 장관과 3선 국회의원을 지냈습니다."),
|
||||
("양일동", "양일동", "1912–1980", "정치인", "옥구군 서수면 출신으로 야당 정치인으로 활동했습니다."),
|
||||
("김관영", "김관영", "1969–", "정치인", "전북특별자치도지사입니다. 회계사·변호사를 거쳤습니다."),
|
||||
("채영석", "채영석", "1934–2006", "정치인", "군산에서 3선 국회의원을 지냈습니다."),
|
||||
("신영대", "신영대", "1968–", "정치인", "군산 지역구 국회의원입니다."),
|
||||
("김의겸", "김의겸", "1963–", "언론인 · 정치인", "한겨레 기자를 거쳐 청와대 대변인과 국회의원을 지냈습니다."),
|
||||
("함운경", "함운경", "1964–", "정치인", "1980년대 학생운동가 출신입니다."),
|
||||
("임병찬", "임병찬", "1851–1916", "의병장", "최익현과 함께 거병한 구한말 의병장으로, 단식 끝에 순국했습니다."),
|
||||
("이수현 (1895년)", "이수현", "1895–1980", "독립운동가", "호는 산남. 군산 출신 독립운동가입니다."),
|
||||
("전진 (1923년)", "전진", "1923–2012", "비전향 장기수", "군산 출신으로 오래 수감 생활을 했습니다."),
|
||||
|
||||
("이길여", "이길여", "1932–", "의료인 · 교육자", "가천대학교 길병원을 세우고 가천대 총장을 맡고 있습니다."),
|
||||
("최길선", "최길선", "1946–", "기업인", "현대중공업 회장을 지낸 조선업 경영인입니다."),
|
||||
("은성수 (공무원)", "은성수", "1961–", "공무원", "제7대 금융위원회 위원장을 지냈습니다."),
|
||||
("채금석", "채금석", "1904–1995", "축구 선수", "일제강점기 경성 축구단 선수로 경평축구대항전에 나섰습니다."),
|
||||
|
||||
("박경완", "박경완", "1972–", "야구 선수", "SK 와이번스 포수로 한국 야구의 대표적인 포수였습니다."),
|
||||
("조계현", "조계현", "1964–", "야구 선수", "해태 타이거즈 투수로 뛰었습니다."),
|
||||
("정명원", "정명원", "1966–", "야구 선수", "현대 유니콘스 투수로 뛰었습니다."),
|
||||
("조규제", "조규제", "1967–", "야구 선수", "왼손 투수로 뛰고 지금은 투수코치입니다."),
|
||||
("차우찬", "차우찬", "1987–", "야구 선수", "투수로 뛰었고 지금은 야구 해설위원입니다."),
|
||||
("이진영 (1980년)", "이진영", "1980–", "야구 선수", "외야수로 뛰었고 지금은 코치입니다."),
|
||||
("오지환", "오지환", "1990–", "야구 선수", "LG 트윈스 내야수입니다."),
|
||||
("이천웅", "이천웅", "1988–", "야구 선수", "LG 트윈스 외야수로 뛰었습니다."),
|
||||
("노상래", "노상래", "1970–", "축구 선수", "공격수로 뛰었고 지도자로 일하고 있습니다."),
|
||||
("노수진", "노수진", "1962–", "축구 선수", "공격수로 뛰었고 지금은 체육 교사입니다."),
|
||||
("유동우", "유동우", "1968–", "축구 선수", "수비수로 뛰었고 대학 축구부 감독을 맡고 있습니다."),
|
||||
("이정효", "이정효", "1975–", "축구 감독", "풀백으로 뛰었고 지금은 프로팀 감독입니다."),
|
||||
("박성현 (양궁 선수)", "박성현", "1983–", "양궁 선수", "군산 소룡초에서 활을 처음 잡아 올림픽 금메달을 땄습니다."),
|
||||
("김광선 (권투 선수)", "김광선", "1964–", "권투 선수", "1988년 서울 올림픽 복싱 플라이급 금메달리스트입니다."),
|
||||
("정경미 (유도 선수)", "정경미", "1985–", "유도 선수", "군산 출신 유도 선수입니다."),
|
||||
("전미라", "전미라", "1978–", "테니스 선수", "테니스 선수로 뛰었고 지금은 해설위원입니다."),
|
||||
("최서우", "최서우", "1982–", "스키점프 선수", "1998년부터 국가대표로 뛴 스키점프 선수입니다."),
|
||||
("한왕용", "한왕용", "1966–", "산악인", "히말라야 8000m 14좌를 세계 11번째로 완등했습니다."),
|
||||
("문호준", "문호준", "1997–", "프로게이머", "카트라이더 리그 최다 우승자입니다."),
|
||||
]
|
||||
|
||||
# ★ 사진은 **자유 라이선스인 것만** 싣는다.
|
||||
# 위키백과 문서에 사진이 있는 사람은 57명 중 18명이고, 그중 라이선스가 확인되는 17장만 받았다
|
||||
# (CC BY / CC BY-SA / KOGL / 공용). 라이선스가 안 읽히는 한 장은 뺐다 — 초상권과 저작권이
|
||||
# 미결인 이미지는 싣지 않는다는 이 레포의 규약(DECISIONS 1-2)과 같은 자리다.
|
||||
# CC BY-SA 는 저작자 표시가 조건이라 imageCredit 으로 화면에 남긴다(렌더러가 출처 줄에 붙인다).
|
||||
PHOTOS = {p["name"]: p for p in json.loads((SP / "people-photos.json").read_text(encoding="utf-8"))}
|
||||
|
||||
# ★ 위키에 사진이 없는 사람은 **원본 payload 의 사진을 그대로 쓴다**
|
||||
# (2026-09-11 사장님: "원래 나오던 것도 안 나옴"). 인물 목록을 새로 만들면서
|
||||
# 원본에 있던 8장을 같이 날렸다 — 채만식처럼 위키에 사진이 없는 사람이 빈칸이 됐다.
|
||||
# 이 사진들은 원래 이 사이트가 쓰던 것이라 자리로 되돌리는 것이지 새로 가져오는 게 아니다.
|
||||
_ORIGINAL = json.loads((SP / "stay-payload.json").read_text(encoding="utf-8"))
|
||||
ORIGINAL_PHOTOS = {}
|
||||
for _sec in _ORIGINAL["theme"]["sections"]:
|
||||
if _sec["id"] != "people":
|
||||
continue
|
||||
for _item in json.loads(_sec["data"])["items"]:
|
||||
if _item.get("imageUrl"):
|
||||
ORIGINAL_PHOTOS[_item["name"]] = _item["imageUrl"]
|
||||
|
||||
# ★ 이름만으로 커먼즈를 검색해 사진을 붙이지 않는다 (2026-09-11 실측).
|
||||
# 동명이인이 잡힌다 — 이수현(독립운동가)에 걸그룹 멤버, 은성수(금융위원장)에 축구선수,
|
||||
# 박성현(양궁)에 골퍼, 이길여에 건물 사진이 왔다. **틀린 사진은 없는 것만 못하다.**
|
||||
# 그 사람의 위키 문서가 대표 사진으로 쓰는 것(pageimages)만 신뢰한다.
|
||||
|
||||
people = []
|
||||
for title, name, years, role, one_line in PEOPLE:
|
||||
item = {"name": name, "role": role, "oneLine": one_line, "verified": "확인",
|
||||
"source": {"name": "한국어 위키백과",
|
||||
"url": "https://ko.wikipedia.org/wiki/" + urllib.parse.quote(title.replace(" ", "_"))}}
|
||||
if years:
|
||||
item["years"] = years
|
||||
photo = PHOTOS.get(name)
|
||||
if photo:
|
||||
item["imageUrl"] = photo["url"]
|
||||
# ★ 렌더러의 imageCredit 은 **문자열**이다(common.tsx:206). 객체를 넣으면 조용히 안 나온다 —
|
||||
# CC BY-SA 는 저작자 표시가 조건이라 안 나오면 라이선스를 어기는 것이다.
|
||||
item["imageCredit"] = f"사진 {photo['author']} · {photo['license']}"
|
||||
elif name in ORIGINAL_PHOTOS:
|
||||
item["imageUrl"] = ORIGINAL_PHOTOS[name]
|
||||
people.append(item)
|
||||
|
||||
# ★ 사진 있는 사람을 앞으로 당긴다 (2026-09-11 사장님 지시).
|
||||
# 목록은 가로 레일이라 손님이 보는 것은 앞쪽 몇 장이다. 분야별로만 묶어 두면
|
||||
# 사진이 흩어져(1·2번 있고 3~8번 없고 9번에 또 있는 식) 레일 앞머리가 글자만 남는다.
|
||||
# **분야 순서는 그대로 두고** 사진 유무로만 두 덩이로 가른다 — 안정 정렬이라
|
||||
# 같은 덩이 안에서는 문인 → 배우·가수 → 정치 → 기업 → 체육 순서가 유지된다.
|
||||
people.sort(key=lambda item: 0 if item.get("imageUrl") else 1)
|
||||
|
||||
SECT["people"]["data"] = json.dumps(
|
||||
{"kind": "people", "version": 1, "title": "인물 열전",
|
||||
"subtitle": f"군산이 낳은 사람 {len(people)}명", "items": people},
|
||||
ensure_ascii=False)
|
||||
|
||||
SRC.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"가요 다방 {len(songs)}곡 · 인물 열전 {len(people)}명 (사진 {sum(1 for p in people if p.get(chr(34)+chr(34)) or p.get('imageUrl'))}장)")
|
||||
print(" 자작곡 섞임:", any("머뭄" in s["title"] for s in songs))
|
||||
49
solution/site/scripts/mockup/carousel.mjs
Normal file
@ -0,0 +1,49 @@
|
||||
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();
|
||||
18
solution/site/scripts/mockup/carousel2.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
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();
|
||||
23
solution/site/scripts/mockup/check2.mjs
Normal file
@ -0,0 +1,23 @@
|
||||
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();
|
||||
39
solution/site/scripts/mockup/cool.mjs
Normal file
@ -0,0 +1,39 @@
|
||||
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();
|
||||
31
solution/site/scripts/mockup/cool2.mjs
Normal file
@ -0,0 +1,31 @@
|
||||
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();
|
||||
26
solution/site/scripts/mockup/cool3.mjs
Normal file
@ -0,0 +1,26 @@
|
||||
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();
|
||||
49
solution/site/scripts/mockup/cool4.mjs
Normal file
@ -0,0 +1,49 @@
|
||||
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();
|
||||
80
solution/site/scripts/mockup/coords.json
Normal file
@ -0,0 +1,80 @@
|
||||
{
|
||||
"군산 신흥동 일본식가옥": {
|
||||
"name": "신흥동일본식가옥",
|
||||
"lat": 35.9861696060316,
|
||||
"lng": 126.706057532726,
|
||||
"addr": "전북특별자치도 군산시 구영1길 17"
|
||||
},
|
||||
"군산 동국사": {
|
||||
"name": "동국사",
|
||||
"lat": 35.98276630107591,
|
||||
"lng": 126.70742960285295,
|
||||
"addr": "전북특별자치도 군산시 동국사길 16"
|
||||
},
|
||||
"군산 우체통거리": {
|
||||
"name": "우체통거리",
|
||||
"lat": 35.9859964914028,
|
||||
"lng": 126.711970794777,
|
||||
"addr": "전북특별자치도 군산시 신창동"
|
||||
},
|
||||
"군산 월명호수": {
|
||||
"name": "월명호수",
|
||||
"lat": 35.97663913860391,
|
||||
"lng": 126.69136962041439,
|
||||
"addr": "전북특별자치도 군산시 소룡동 1071-4"
|
||||
},
|
||||
"군산 시간여행마을": {
|
||||
"name": "군산시간여행마을",
|
||||
"lat": 35.988319185729,
|
||||
"lng": 126.710937704825,
|
||||
"addr": "전북특별자치도 군산시 영화동"
|
||||
},
|
||||
"군산 은적사": {
|
||||
"name": "은적사",
|
||||
"lat": 35.9748303718065,
|
||||
"lng": 126.685472639398,
|
||||
"addr": "전북특별자치도 군산시 설림3길 49"
|
||||
},
|
||||
"도란 군산 월명동": {
|
||||
"name": "도란",
|
||||
"lat": 35.9868220429033,
|
||||
"lng": 126.707509996005,
|
||||
"addr": "전북특별자치도 군산시 구영신창길 61"
|
||||
},
|
||||
"물밀소 군산": {
|
||||
"name": "물밀소",
|
||||
"lat": 35.98452434115725,
|
||||
"lng": 126.70732109672761,
|
||||
"addr": "전북특별자치도 군산시 월명로 491"
|
||||
},
|
||||
"일흥옥 군산": {
|
||||
"name": "일흥옥",
|
||||
"lat": 35.98514982005064,
|
||||
"lng": 126.70918392566266,
|
||||
"addr": "전북특별자치도 군산시 구영7길 25"
|
||||
},
|
||||
"명월갈비 군산": {
|
||||
"name": "명월갈비",
|
||||
"lat": 35.98639139165697,
|
||||
"lng": 126.71016628964314,
|
||||
"addr": "전북특별자치도 군산시 구영7길 59"
|
||||
},
|
||||
"만남스넥 군산": {
|
||||
"name": "만남스넥",
|
||||
"lat": 35.984178824622944,
|
||||
"lng": 126.70977629761406,
|
||||
"addr": "전북특별자치도 군산시 대학로 67-3"
|
||||
},
|
||||
"동국사다온": {
|
||||
"name": "다온 군산점",
|
||||
"lat": 35.98609071445503,
|
||||
"lng": 126.71443996128968,
|
||||
"addr": "전북특별자치도 군산시 영동로 27-3"
|
||||
},
|
||||
"국제반점 군산": {
|
||||
"name": "국제반점",
|
||||
"lat": 35.9881100055579,
|
||||
"lng": 126.710898546645,
|
||||
"addr": "전북특별자치도 군산시 구영6길 100"
|
||||
}
|
||||
}
|
||||
105
solution/site/scripts/mockup/dump_text.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""시연본에 들어간 **문구 전부**를 읽기 좋은 문서로 뽑는다.
|
||||
|
||||
★ 왜 따로 뽑나 — 문구는 파이썬 리스트 안에 흩어져 있다(캐치프레이즈는 patch_stay.py,
|
||||
노래·인물은 build_story.py, 정거장 할 일은 build_itinerary.py).
|
||||
사장님이 **읽고 고칠 것**은 문구뿐이라, 코드를 열지 않고 볼 수 있게 한 장으로 모은다.
|
||||
고치는 자리는 여전히 스크립트다 — 이 파일은 산출물이고 손으로 고치지 않는다.
|
||||
"""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
SP = Path(__file__).parent
|
||||
|
||||
# ★ 최종 산출물(build/index.html)에서 읽는다. 캐치프레이즈·자작곡·객실 사진은
|
||||
# patch_stay.py 가 HTML 을 조립하면서 넣으므로 중간 파일(stay-payload-new.json)에는 없다.
|
||||
# "실제로 나간 값" 을 적는 문서라 최종본이 맞다.
|
||||
import re
|
||||
_html = (SP / "build" / "index.html").read_text(encoding="utf-8")
|
||||
payload = json.loads(re.search(r"<script>window\.__SITE_PAYLOAD__=(.*?)</script>", _html, re.S).group(1))
|
||||
SECT = {s["id"]: s for s in payload["theme"]["sections"]}
|
||||
env = lambda sid: json.loads(SECT[sid]["data"])
|
||||
|
||||
out = ["# /s/stay 시연본 — 들어간 문구 전부",
|
||||
"",
|
||||
"`python3 dump_text.py` 산출물. **고치는 자리는 스크립트다** — 이 파일을 고쳐도 반영되지 않는다.",
|
||||
""]
|
||||
|
||||
# ── 캐치프레이즈 ──────────────────────────────────────────────────────────────
|
||||
box = payload["narrative"]["catchphrases"]["items"]
|
||||
out += [f"## 1. 히어로 문구 {len(box)}개 — `patch_stay.py`", "",
|
||||
"상호 아래 한 줄. 7초마다 `일반 → 계절 → 일반 → 월 → 일반 → 날씨` 순으로 바뀐다.", ""]
|
||||
general = [x for x in box if x["kind"] == "general"]
|
||||
out += [f"### 일반 {len(general)}개 (`patch_stay.py` `GENERAL`)", ""]
|
||||
out += [f"{i}. {x['text']}" for i, x in enumerate(general, 1)] + [""]
|
||||
|
||||
out += [f"### 계절별 {sum(1 for x in box if x['kind'] == 'season')}개 (`SEASON`)", ""]
|
||||
for season in ("봄", "여름", "가을", "겨울"):
|
||||
rows = [x["text"] for x in box if x.get("season") == season]
|
||||
out += [f"**{season}**"] + [f"- {t}" for t in rows] + [""]
|
||||
|
||||
out += [f"### 월별 {sum(1 for x in box if x['kind'] == 'month')}개 (`MONTH`)", ""]
|
||||
for month in range(1, 13):
|
||||
rows = [x["text"] for x in box if x.get("month") == month]
|
||||
out += [f"**{month}월** — " + " · ".join(rows)]
|
||||
out += [""]
|
||||
|
||||
out += [f"### 날씨별 {sum(1 for x in box if x['kind'] == 'weather')}개 (`WEATHER`)", ""]
|
||||
for weather in ("맑음", "구름많음", "흐림", "비", "눈"):
|
||||
rows = [x["text"] for x in box if x.get("weather") == weather]
|
||||
out += [f"**{weather}**"] + [f"- {t}" for t in rows] + [""]
|
||||
|
||||
# ── 가요 다방 ────────────────────────────────────────────────────────────────
|
||||
songs = env("songs")["items"]
|
||||
out += [f"## 2. 가요 다방 {len(songs)}곡 — `build_story.py` `SONGS`", "",
|
||||
"| # | 곡명 | 가수 | 작사 / 작곡 | 연도 | 한 줄 |", "|---|---|---|---|---|---|"]
|
||||
for i, s in enumerate(songs, 1):
|
||||
credit = " / ".join(filter(None, [s.get("lyricist"), s.get("composer")])) or "—"
|
||||
out.append(f"| {i} | {s['title']} | {s.get('artist', '—')} | {credit} | {s.get('year', '—')} | {s.get('story', '')} |")
|
||||
out += ["", f"출처: {songs[0]['source']['name']}", ""]
|
||||
|
||||
# ── 인물 열전 ────────────────────────────────────────────────────────────────
|
||||
people = env("people")["items"]
|
||||
shot = sum(1 for p in people if p.get("imageUrl"))
|
||||
out += [f"## 3. 인물 열전 {len(people)}명 (사진 {shot}장) — `build_story.py` `PEOPLE`", "",
|
||||
"사진 있는 사람이 앞에 온다.", "",
|
||||
"| # | 사진 | 이름 | 연도 | 하는 일 | 한 줄 |", "|---|---|---|---|---|---|"]
|
||||
for i, p in enumerate(people, 1):
|
||||
out.append(f"| {i} | {'📷' if p.get('imageUrl') else ''} | {p['name']} | {p.get('years', '—')} | {p['role']} | {p['oneLine']} |")
|
||||
out += [""]
|
||||
|
||||
# ── 일정 정거장 ──────────────────────────────────────────────────────────────
|
||||
itin = env("itinerary")["items"]
|
||||
seen = {}
|
||||
for item in itin:
|
||||
for day in item["days"]:
|
||||
for stop in day["stops"]:
|
||||
place = stop["name"].split(" · ")[-1]
|
||||
if stop.get("note"):
|
||||
seen.setdefault(f"{place}|{stop['note']}", (place, stop["note"], stop["minutes"]))
|
||||
out += [f"## 4. 일정 — 정거장에서 할 일 {len(seen)}줄 — `build_itinerary.py` `PLACES`·`STAY_ACTION`", "",
|
||||
"| 장소 | 무엇을 하는지 | 머무는 시간 |", "|---|---|---|"]
|
||||
for place, note, minutes in sorted(seen.values()):
|
||||
out.append(f"| {place} | {note} | {minutes}분 |")
|
||||
out += [""]
|
||||
|
||||
# ── 일정 테마 ────────────────────────────────────────────────────────────────
|
||||
out += [f"## 5. 일정 테마 {len(itin)}개 — `build_itinerary.py` `THEMES`", "",
|
||||
"| 테마 | 기간 | 누구에게 | 왜 |", "|---|---|---|---|"]
|
||||
for item in itin:
|
||||
out.append(f"| {item['name']} | {item['duration']} | {item['audience']} | {item['why']} |")
|
||||
out += [""]
|
||||
|
||||
# ── 오늘의 엽서 ──────────────────────────────────────────────────────────────
|
||||
cards = env("postcard")["items"]
|
||||
out += [f"## 6. 오늘의 엽서 {len(cards)}장 — 원본 payload (아직 다시 안 뽑음)", ""]
|
||||
out += [f"- {c['line']} `{' '.join(c.get('hashtags', []))}`" for c in cards] + [""]
|
||||
|
||||
# ── 머뭄이 만든 노래 ─────────────────────────────────────────────────────────
|
||||
own = payload["narrative"]["ownSongs"]["items"]
|
||||
out += [f"## 7. 머뭄이 만든 노래 {len(own)}곡 — `patch_stay.py` `OWN_SONGS`", ""]
|
||||
out += [f"- {s['title']} — `{s['audioUrl']}`" for s in own] + [""]
|
||||
|
||||
path = SP / "TEXT.md"
|
||||
path.write_text("\n".join(out), encoding="utf-8")
|
||||
print(f"{path.name} 캐치프레이즈 {len(box)} · 노래 {len(songs)} · 인물 {len(people)} · "
|
||||
f"정거장 문구 {len(seen)} · 테마 {len(itin)} · 엽서 {len(cards)} · 자작곡 {len(own)}")
|
||||
11
solution/site/scripts/mockup/embla.mjs
Normal file
@ -0,0 +1,11 @@
|
||||
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();
|
||||
15
solution/site/scripts/mockup/hdr.mjs
Normal file
@ -0,0 +1,15 @@
|
||||
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();
|
||||
18
solution/site/scripts/mockup/hshot.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
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/a-01.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
solution/site/scripts/mockup/img/a-02.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
solution/site/scripts/mockup/img/a-03.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
solution/site/scripts/mockup/img/a-04.jpg
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
solution/site/scripts/mockup/img/a-05.jpg
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
solution/site/scripts/mockup/img/a-06.jpg
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
solution/site/scripts/mockup/img/a-07.jpg
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
solution/site/scripts/mockup/img/a-08.jpg
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
solution/site/scripts/mockup/img/a-09.jpg
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
solution/site/scripts/mockup/img/a-10.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
solution/site/scripts/mockup/img/a-11.jpg
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
solution/site/scripts/mockup/img/a-12.jpg
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
solution/site/scripts/mockup/img/b-01.jpg
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
solution/site/scripts/mockup/img/b-02.jpg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
solution/site/scripts/mockup/img/b-03.jpg
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
solution/site/scripts/mockup/img/b-04.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
solution/site/scripts/mockup/img/b-05.jpg
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
solution/site/scripts/mockup/img/b-06.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
solution/site/scripts/mockup/img/b-07.jpg
Normal file
|
After Width: | Height: | Size: 269 KiB |
BIN
solution/site/scripts/mockup/img/b-08.jpg
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
solution/site/scripts/mockup/img/b-09.jpg
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
solution/site/scripts/mockup/img/b-10.jpg
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
solution/site/scripts/mockup/img/h-01.jpg
Normal file
|
After Width: | Height: | Size: 217 KiB |
BIN
solution/site/scripts/mockup/img/h-02.jpg
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
solution/site/scripts/mockup/img/h-03.jpg
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
solution/site/scripts/mockup/img/h-04.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
solution/site/scripts/mockup/img/h-05.jpg
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
solution/site/scripts/mockup/img/h-06.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
solution/site/scripts/mockup/img/people/고건.jpg
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
solution/site/scripts/mockup/img/people/고은.jpg
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
solution/site/scripts/mockup/img/people/김관영.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
solution/site/scripts/mockup/img/people/김선기.jpg
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
solution/site/scripts/mockup/img/people/김수미.png
Normal file
|
After Width: | Height: | Size: 692 KiB |
BIN
solution/site/scripts/mockup/img/people/문호준.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
solution/site/scripts/mockup/img/people/박화요비.jpg
Normal file
|
After Width: | Height: | Size: 319 KiB |
BIN
solution/site/scripts/mockup/img/people/병헌.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
solution/site/scripts/mockup/img/people/송새벽.png
Normal file
|
After Width: | Height: | Size: 352 KiB |
BIN
solution/site/scripts/mockup/img/people/오지환.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
solution/site/scripts/mockup/img/people/이서원.png
Normal file
|
After Width: | Height: | Size: 443 KiB |
BIN
solution/site/scripts/mockup/img/people/이진영.jpg
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
solution/site/scripts/mockup/img/people/장신영.jpg
Normal file
|
After Width: | Height: | Size: 238 KiB |
BIN
solution/site/scripts/mockup/img/people/정혁.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
solution/site/scripts/mockup/img/people/차우찬.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
solution/site/scripts/mockup/img/people/최서우.jpg
Normal file
|
After Width: | Height: | Size: 6.5 MiB |
BIN
solution/site/scripts/mockup/img/people/함운경.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
329
solution/site/scripts/mockup/inject.css
Normal file
@ -0,0 +1,329 @@
|
||||
/* /s/stay 목업 주입분 — 헤더 미니 플레이어 · 캐치프레이즈 전환.
|
||||
★ 색·테두리·서체를 전부 템플릿 변수(--tpl-*)에서 가져온다. 값을 박으면 템플릿을 바꿀 때
|
||||
플레이어만 옛 색으로 남는다.
|
||||
★ 플레이어는 헤더 **안**이다. 하단 바는 모바일 탭바(fixed bottom:0 · 61px)를 덮었고,
|
||||
상단 바는 헤더와 두 줄이 됐다. 헤더 안이면 화면에서 차지하는 줄이 늘지 않는다. */
|
||||
|
||||
/* ── 헤더에 꽂는 아이콘 세 개 ─────────────────────────────────────── */
|
||||
#w4d-mini {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-right: 6px;
|
||||
padding-right: 8px;
|
||||
border-right: 1px solid var(--tpl-border, #bcb49e);
|
||||
}
|
||||
#w4d-mini button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
color: var(--tpl-text, #1b1a15);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--tpl-radius, 0);
|
||||
cursor: pointer;
|
||||
opacity: 0.75;
|
||||
}
|
||||
#w4d-mini button:hover { opacity: 1; background: rgb(27 26 21 / 0.06); }
|
||||
#w4d-mini svg { width: 16px; height: 16px; }
|
||||
|
||||
/* 지금 나오는 곡명. 좁은 화면에서는 헤더에 자리가 없어 접는다 —
|
||||
실측(390px): 상호 115px + 오른쪽 묶음 94px 이라 남는 폭이 55px 뿐이다.
|
||||
그 화면에서는 목록을 열면 재생 중인 줄에 곡명과 시간이 있다. */
|
||||
#w4d-now {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--tpl-accent, #bf2f1b);
|
||||
padding-right: 2px;
|
||||
}
|
||||
#w4d-mini[data-playing='0'] #w4d-now { opacity: 0.55; font-weight: 400; }
|
||||
@media (max-width: 860px) { #w4d-now { display: none; } }
|
||||
|
||||
/* 카세트 — 단추가 아니라 표지다. 이 자리가 무엇인지 한눈에 말해 준다. */
|
||||
#w4d-tape {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 4px 0 2px;
|
||||
color: var(--tpl-text, #1b1a15);
|
||||
opacity: 0.8;
|
||||
}
|
||||
#w4d-tape svg { width: 20px; height: 20px; }
|
||||
#w4d-mini[data-playing='1'] #w4d-tape { color: var(--tpl-accent, #bf2f1b); opacity: 1; }
|
||||
/* 재생 중에는 릴이 돈다 — 소리가 나고 있다는 것을 글자 없이 말한다. */
|
||||
#w4d-mini[data-playing='1'] .w4d-reel {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: w4d-reel 2.6s linear infinite;
|
||||
}
|
||||
@keyframes w4d-reel { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .w4d-reel { animation: none !important; } }
|
||||
/* 재생 중일 때만 강조색 — 헤더에 곡명을 안 두므로 색이 유일한 '지금 나온다' 표시다. */
|
||||
#w4d-mini button[data-playing='1'] { color: var(--tpl-accent, #bf2f1b); opacity: 1; }
|
||||
@media (max-width: 400px) {
|
||||
#w4d-mini { margin-right: 2px; padding-right: 4px; }
|
||||
#w4d-mini button { width: 27px; height: 27px; }
|
||||
}
|
||||
|
||||
/* ── 목록 패널 (헤더 아래에 떠 있다) ──────────────────────────────
|
||||
★ 좁은 화면에서는 좌우 8px 만 남기고 펼친다. 320px 고정이었을 때 390px 화면에서
|
||||
제목이 두 줄로 접히고 시간이 잘렸다(2026-09-11 사장님: "모바일 대응이 안 되어 있음"). */
|
||||
#w4d-panel {
|
||||
position: fixed;
|
||||
z-index: 45; /* 사이트 헤더가 z-40 이라 그 위 */
|
||||
width: 344px;
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: min(64vh, 520px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
font-family: var(--tpl-font-body, serif);
|
||||
color: var(--tpl-text, #1b1a15);
|
||||
background: var(--tpl-card, #efe7d3);
|
||||
border: var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15);
|
||||
box-shadow: var(--tpl-shadow, 4px 4px 0 rgb(27 26 21 / 0.16));
|
||||
}
|
||||
#w4d-panel[hidden] { display: none; }
|
||||
@media (max-width: 640px) { #w4d-panel { width: auto; } }
|
||||
|
||||
#w4d-panel .w4d-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
padding: 11px 8px 11px 14px;
|
||||
border-bottom: var(--tpl-border-width, 2px) solid var(--tpl-primary, #1b1a15);
|
||||
}
|
||||
.w4d-head-t { font-size: 12px; font-weight: 700; letter-spacing: 0.06em; }
|
||||
.w4d-head-n { flex: 1 1 auto; font-size: 11px; opacity: 0.5; }
|
||||
#w4d-panel .w4d-head button {
|
||||
display: inline-flex;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
#w4d-panel .w4d-head button:hover { opacity: 1; }
|
||||
#w4d-panel .w4d-head svg { width: 14px; height: 14px; }
|
||||
|
||||
#w4d-panel ol {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* 한 줄 = 판 + 제목/가수 + 시간. 48px 아래로 내리지 않는다 — 손가락으로 누르는 자리다. */
|
||||
.w4d-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 8px 14px;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.w4d-item + .w4d-item { border-top: 1px dashed var(--tpl-border, #bcb49e); }
|
||||
.w4d-item:hover { background: rgb(27 26 21 / 0.05); }
|
||||
.w4d-item[aria-current='true'] { background: rgb(27 26 21 / 0.06); }
|
||||
.w4d-item[aria-current='true'] .w4d-t { color: var(--tpl-accent, #bf2f1b); }
|
||||
|
||||
/* 판 자리. 재생 중인 줄에서는 판 대신 막대가 뛴다. */
|
||||
.w4d-mark { position: relative; flex: 0 0 26px; height: 26px; }
|
||||
.w4d-disc-sm {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, var(--w4d-lbl, #bf2f1b) 0 34%, transparent 34%),
|
||||
repeating-radial-gradient(circle at 50% 50%, rgb(255 255 255 / 0.12) 0 1px, transparent 1px 3px),
|
||||
#14110f;
|
||||
}
|
||||
.w4d-disc-sm::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 45%;
|
||||
border-radius: 50%;
|
||||
background: var(--tpl-card, #efe7d3);
|
||||
}
|
||||
.w4d-eq {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.w4d-eq i {
|
||||
width: 3px;
|
||||
height: 6px;
|
||||
background: var(--tpl-accent, #bf2f1b);
|
||||
animation: w4d-eq 0.9s ease-in-out infinite;
|
||||
}
|
||||
.w4d-eq i:nth-child(2) { animation-delay: 0.18s; }
|
||||
.w4d-eq i:nth-child(3) { animation-delay: 0.36s; }
|
||||
@keyframes w4d-eq { 0%, 100% { height: 5px; } 50% { height: 17px; } }
|
||||
.w4d-item[data-playing='1'] .w4d-disc-sm { display: none; }
|
||||
.w4d-item[data-playing='1'] .w4d-eq { display: flex; }
|
||||
@media (prefers-reduced-motion: reduce) { .w4d-eq i { animation: none; height: 11px; } }
|
||||
|
||||
.w4d-info { min-width: 0; flex: 1 1 auto; }
|
||||
.w4d-t {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-size: 13.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.w4d-sub {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.w4d-time {
|
||||
flex: 0 0 auto;
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 가사 — 우리 곡만. 종이 위 인쇄물처럼 둔다. */
|
||||
#w4d-lyrics-box {
|
||||
padding: 10px 12px 14px;
|
||||
border-top: 1px dashed var(--tpl-border, #bcb49e);
|
||||
white-space: pre-line;
|
||||
font-size: 12px;
|
||||
line-height: 1.9;
|
||||
}
|
||||
#w4d-lyrics-box[hidden] { display: none; }
|
||||
|
||||
/* ★ 이용 정보 맨 아래 가로선을 지운다 (2026-09-10 사장님 지시).
|
||||
<hr> 이 아니라 바로 뒤 '예약은 아래로 받습니다' 블록의 border-t 다 — 마지막 항목 밑에 선이
|
||||
한 줄 더 그어져 빈 칸이 하나 더 있는 것처럼 보인다. 항목 자체는 그대로 둔다.
|
||||
마크업을 고치면 하이드레이션이 되돌리므로 CSS 로 끈다. */
|
||||
#root div.border-line.flex.flex-col.gap-3.border-t.pt-6 { border-top-width: 0; }
|
||||
|
||||
/* 사진 저작자 표시 — 출처 줄과 같은 크기, 한 단 더 옅게. */
|
||||
.w4d-credit { margin-top: 2px; font-size: 10px; opacity: 0.55; }
|
||||
|
||||
/* ★ 모바일 헤더에서 전화 단추를 접는다 (2026-09-11 사장님: "저 전화 아이콘은 빼도 되잖아").
|
||||
좁은 화면 헤더에 상호 + 플레이어 3개 + 전화 + 메뉴가 다 서서 난잡했다.
|
||||
★ 전화가 사라지는 게 아니다 — 이 화면 폭에서는 **하단 탭바에 전화가 이미 있다**
|
||||
(실측: NAV fixed bottom:0 · 위치 · 전화 · 네이버 예약). 같은 것이 두 번 서 있던 것이다.
|
||||
★ 넓은 화면에서는 탭바가 없으므로 그대로 둔다. */
|
||||
@media (max-width: 860px) {
|
||||
#root header .shell > div:last-child > a[href^="tel:"] { display: none; }
|
||||
}
|
||||
|
||||
/* 덧붙인 링크 — 본문 글자보다 눈에 띄되 강조색을 남발하지 않는다. */
|
||||
.w4d-link {
|
||||
color: var(--tpl-accent, #bf2f1b);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── 캐치프레이즈 등장 ────────────────────────────────────────────
|
||||
★ 줄 전체를 페이드하지 않고 **단어가 마스크 안에서 올라온다.**
|
||||
한 줄짜리 문장은 통째로 흐려지면 '바뀌었다'만 보이고 문장이 안 읽힌다 —
|
||||
왼쪽부터 차례로 서면 눈이 첫 단어에서 읽기를 시작한다.
|
||||
★ 나갈 때는 반대로 위로 빠지며 흐려진다. 들어오는 방향과 나가는 방향이 같으면
|
||||
두 문장이 같은 자리에서 겹쳐 보인다.
|
||||
★ 자리 흔들림: 처음 구워진 줄 높이를 min-height 로 박아 둔다(스크립트가 잰다).
|
||||
문장 길이가 달라 한 줄↔두 줄이 오갈 때 히어로 아래가 튀는 것을 막는다. */
|
||||
.w4d-line { display: block; }
|
||||
.w4d-line--out {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
filter: blur(3px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease;
|
||||
}
|
||||
.w4d-line--in { opacity: 1; transform: none; filter: none; transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease; }
|
||||
|
||||
/* 단어 한 칸 = 마스크. 안쪽 글자가 아래에서 올라온다. */
|
||||
.w4d-w { display: inline-block; overflow: hidden; vertical-align: bottom; }
|
||||
.w4d-w > span {
|
||||
display: inline-block;
|
||||
transform: translateY(112%);
|
||||
opacity: 0;
|
||||
filter: blur(4px);
|
||||
}
|
||||
.w4d-line--in .w4d-w > span {
|
||||
animation: w4d-rise 0.62s cubic-bezier(0.16, 0.84, 0.28, 1) forwards;
|
||||
animation-delay: var(--w4d-d, 0ms);
|
||||
}
|
||||
@keyframes w4d-rise {
|
||||
from { transform: translateY(112%); opacity: 0; filter: blur(4px); }
|
||||
60% { filter: blur(0); }
|
||||
to { transform: translateY(0); opacity: 1; filter: blur(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.w4d-line--out, .w4d-line--in { transition: none; opacity: 1; transform: none; filter: none; }
|
||||
.w4d-w > span { transform: none; opacity: 1; filter: none; animation: none; }
|
||||
}
|
||||
|
||||
/* 가사 — 우리 곡만. 종이 위 인쇄물처럼 둔다. */
|
||||
#w4d-lyrics-box {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px 16px;
|
||||
border-top: 1px dashed var(--tpl-border, #bcb49e);
|
||||
white-space: pre-line;
|
||||
font-size: 13px;
|
||||
line-height: 1.9;
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#w4d-lyrics-box[hidden] { display: none; }
|
||||
|
||||
/*
|
||||
* 일정 정거장 번호 — 제목 글자와 세로 중심을 맞춘다.
|
||||
*
|
||||
* 번호 동그라미는 inline-grid 라 **안에 든 숫자(10px)의 베이스라인**이 제목(14px)의
|
||||
* 베이스라인에 붙는다. 숫자가 17px 상자 한가운데 있으니 동그라미 중심은 그만큼 아래로
|
||||
* 내려가고, 렌더러가 얹은 translate-y-px 가 1px 더 내린다.
|
||||
* 실측(1280·390 공통): 동그라미 중심이 제목 줄 중심보다 3.14px 아래.
|
||||
* 2px 올리면 0.14px — 반올림해서 눈에 안 걸리는 자리다.
|
||||
*/
|
||||
#itinerary ol li span.inline-grid.rounded-full {
|
||||
translate: 0 -2px;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* 날씨 그림 — 움직임만 끈다 (2026-09-11, 사장님: "날씨 애니메이션 지워줘")
|
||||
* 그림 자체는 남는다. `prefers-reduced-motion` 에서 보이는 것과 같은 상태다
|
||||
* (weather.css 가 "멈춘 상태도 그림으로 성립해야 한다"는 전제로 그려져 있다).
|
||||
* 그림까지 없애려면 아래 두 줄의 주석을 푼다.
|
||||
*/
|
||||
#weather .w4-sky::before,
|
||||
#weather .w4-sky::after {
|
||||
animation: none !important;
|
||||
}
|
||||
/* #weather .w4-sky { display: none; } */
|
||||
708
solution/site/scripts/mockup/inject.js
Normal file
@ -0,0 +1,708 @@
|
||||
/* /s/stay 목업 주입분 — ① 캐치프레이즈 순환 ② 헤더 안 미니 플레이어.
|
||||
*
|
||||
* ★ 플레이어는 자리를 세 번 옮겼다. 지금은 **사이트 헤더 안**이다.
|
||||
* ① 하단 바 → 모바일에서 발행본의 탭바(fixed bottom:0 · 61px · z-50 — 위치·전화·예약)를 덮었다.
|
||||
* ② 상단 바 → 헤더와 두 줄이 되어 화면 위 120px 이 크롬이 됐다("조금 이상한데").
|
||||
* ③ 헤더 안 아이콘 — 줄이 늘지 않는다. 원판·곡명 없이 재생·다음·목록 세 단추뿐이고,
|
||||
* 곡 정보는 목록을 열 때만 보인다.
|
||||
*
|
||||
* ★ 헤더는 React 가 그린다. 그래서 두 가지를 지킨다.
|
||||
* · 단추는 헤더 안에 꽂지만, 없어졌는지 감시해서 다시 꽂는다(하이드레이션 재렌더가 지운다).
|
||||
* · 목록 패널은 #root **밖**(body 직속)에 둔다 — 안에 두면 재렌더에 통째로 날아간다.
|
||||
*
|
||||
* ★ 재생은 곡의 audioUrl(사장님이 직접 만든 음원)만 쓴다. 파일은 `/s/<slug>/audio/…` 에 있고,
|
||||
* nginx 의 `^~ /s/` 가 그대로 서빙한다(Range 206 확인). 옛 가요 네 곡은 우리 음원이 아니라
|
||||
* 플레이어에 올리지 않는다 — 가요 다방 섹션에는 그대로 남는다.
|
||||
* ★ 가사는 payload 에 있을 때만 낸다. 직접 만든 곡이라 원문을 실어도 되는 유일한 경우다.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var P = window.__SITE_PAYLOAD__;
|
||||
if (!P) return;
|
||||
|
||||
var REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
function pick(list) {
|
||||
return list && list.length ? list[Math.floor(Math.random() * list.length)] : null;
|
||||
}
|
||||
|
||||
/* ══ ① 캐치프레이즈 ══════════════════════════════════════════════════════
|
||||
* 일반(랜덤) → 계절 → 일반 → 월 → 일반 → 날씨 순으로 한 바퀴를 만들고 그 바퀴를 돈다.
|
||||
* 일반은 자루에서 뽑아 쓰고 자루가 비면 다시 채운다 — 매번 랜덤이면 같은 문장이 연달아 나온다.
|
||||
*/
|
||||
function catchphrases() {
|
||||
var box = P.narrative && P.narrative.catchphrases;
|
||||
return (box && box.items) || [];
|
||||
}
|
||||
|
||||
/* 3개월 단위. 12·1·2 겨울 / 3·4·5 봄 / 6·7·8 여름 / 9·10·11 가을 */
|
||||
var SEASON_OF_MONTH = ['겨울', '겨울', '봄', '봄', '봄', '여름', '여름', '여름', '가을', '가을', '가을', '겨울'];
|
||||
|
||||
function startCatchphrase() {
|
||||
var items = catchphrases();
|
||||
if (items.length === 0) return;
|
||||
|
||||
var base = ((P.narrative && P.narrative.tagline) || '').trim();
|
||||
var node = null;
|
||||
var last = null;
|
||||
|
||||
/* ★ 노드를 들고 있지 않고 매번 찾는다.
|
||||
이 목업은 payload 를 손으로 고친 것이라 구워진 마크업과 어긋나고, React 는 하이드레이션에
|
||||
실패하면 #root 를 **통째로 다시 그린다** — 그때 히어로의 <p> 가 새 노드로 바뀐다.
|
||||
한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */
|
||||
function findNode() {
|
||||
var wanted = [base];
|
||||
if (last) wanted.push(last);
|
||||
var candidates = document.querySelectorAll('#root p');
|
||||
for (var i = 0; i < candidates.length; i += 1) {
|
||||
if (wanted.indexOf(candidates[i].textContent.trim()) >= 0) return candidates[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!findNode()) return;
|
||||
|
||||
var byKind = function (kind) {
|
||||
return items.filter(function (it) { return it.kind === kind; });
|
||||
};
|
||||
var weather = null; // 못 받아 오면 날씨 문장은 아예 순환에 넣지 않는다.
|
||||
var bag = [];
|
||||
var cycle = [];
|
||||
|
||||
function nextGeneral() {
|
||||
if (bag.length === 0) {
|
||||
bag = byKind('general').slice();
|
||||
for (var j = bag.length - 1; j > 0; j -= 1) {
|
||||
var k = Math.floor(Math.random() * (j + 1));
|
||||
var t = bag[j]; bag[j] = bag[k]; bag[k] = t;
|
||||
}
|
||||
}
|
||||
return bag.pop();
|
||||
}
|
||||
|
||||
function buildCycle() {
|
||||
var now = new Date();
|
||||
var month = now.getMonth() + 1;
|
||||
var season = SEASON_OF_MONTH[month - 1];
|
||||
var out = [nextGeneral()];
|
||||
out.push(pick(byKind('season').filter(function (it) { return it.season === season; })));
|
||||
out.push(nextGeneral());
|
||||
out.push(pick(byKind('month').filter(function (it) { return it.month === month; })));
|
||||
out.push(nextGeneral());
|
||||
if (weather) {
|
||||
out.push(pick(byKind('weather').filter(function (it) { return it.weather === weather; })));
|
||||
}
|
||||
return out.filter(Boolean);
|
||||
}
|
||||
|
||||
/* 문장 하나를 단어 칸으로 조립한다. innerHTML 을 안 쓰는 이유는 문구가 데이터라서다 —
|
||||
지금은 우리가 쓴 문장이지만, 이 자리는 나중에 서버가 채운다. */
|
||||
function paint(text) {
|
||||
var line = document.createElement('span');
|
||||
line.className = 'w4d-line';
|
||||
text.split(/\s+/).forEach(function (word, index) {
|
||||
if (index > 0) line.appendChild(document.createTextNode(' '));
|
||||
var mask = document.createElement('span');
|
||||
mask.className = 'w4d-w';
|
||||
var inner = document.createElement('span');
|
||||
inner.textContent = word;
|
||||
// 단어마다 조금씩 늦게 선다. 55ms 면 열 단어가 0.5초 안에 다 서서 읽기를 안 기다리게 한다.
|
||||
inner.style.setProperty('--w4d-d', index * 55 + 'ms');
|
||||
mask.appendChild(inner);
|
||||
line.appendChild(mask);
|
||||
});
|
||||
node.textContent = '';
|
||||
node.appendChild(line);
|
||||
// 클래스를 한 프레임 뒤에 건다 — 같은 프레임에 붙이면 애니메이션 시작 상태가 없다.
|
||||
window.requestAnimationFrame(function () { line.classList.add('w4d-line--in'); });
|
||||
}
|
||||
|
||||
function show(text) {
|
||||
last = text;
|
||||
if (REDUCED) { node.textContent = text; return; }
|
||||
var current = node.firstElementChild;
|
||||
if (current && current.classList.contains('w4d-line')) {
|
||||
current.classList.remove('w4d-line--in');
|
||||
current.classList.add('w4d-line--out');
|
||||
}
|
||||
window.setTimeout(function () {
|
||||
if (!node || !node.isConnected) node = findNode();
|
||||
if (!node) return;
|
||||
paint(text);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!node || !node.isConnected) node = findNode();
|
||||
if (!node) return;
|
||||
if (cycle.length === 0) cycle = buildCycle();
|
||||
var next = cycle.shift();
|
||||
if (next && next.text) show(next.text);
|
||||
}
|
||||
|
||||
/* 날씨는 렌더러가 쓰는 그 엔드포인트로 받는다(같은 오리진). 실패하면 조용히 넘어간다 —
|
||||
날씨 문장 몇 개 때문에 순환이 멈추면 안 된다. */
|
||||
function refreshWeather() {
|
||||
var place = P.place || {};
|
||||
if (!place.regionCode || place.latitude == null) return;
|
||||
var query = 'region_code=' + encodeURIComponent(place.regionCode) +
|
||||
'&latitude=' + place.latitude + '&longitude=' + place.longitude;
|
||||
fetch('/v1/local/weather?' + query)
|
||||
.then(function (r) { return r.ok ? r.json() : null; })
|
||||
.then(function (body) {
|
||||
if (!body || !body.weather) return;
|
||||
var code = Number(body.weather.weather_code);
|
||||
// 렌더러(use-live-weather.ts)와 같은 분류를 쓴다 — 갈리면 화면의 날씨와 문장이 어긋난다.
|
||||
weather = code === 0 ? '맑음'
|
||||
: code >= 1 && code <= 3 ? '구름많음'
|
||||
: code >= 50 && code <= 69 ? '비'
|
||||
: code >= 70 && code <= 79 ? '눈' : '흐림';
|
||||
})
|
||||
.catch(function () { /* 구운 값 그대로 둔다 */ });
|
||||
}
|
||||
|
||||
refreshWeather();
|
||||
window.setInterval(refreshWeather, 10 * 60 * 1000);
|
||||
// 구워진 줄 높이를 하한으로 박는다 — 문장이 짧아 한 줄이 되면 아래 단추가 위로 튄다.
|
||||
(function reserve() {
|
||||
var found = findNode();
|
||||
if (!found) return;
|
||||
var h = found.getBoundingClientRect().height;
|
||||
if (h > 0) found.style.minHeight = Math.ceil(h) + 'px';
|
||||
})();
|
||||
// 첫 문장은 구워진 그대로 5초 두고 시작한다 — 들어오자마자 글자가 바뀌면 읽던 것을 놓친다.
|
||||
window.setTimeout(function () {
|
||||
tick();
|
||||
window.setInterval(tick, 7000);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/* ══ ② 헤더 안 미니 플레이어 ═══════════════════════════════════════════════
|
||||
* ★ 단추는 둘뿐이다 — 재생/멈춤과 목록 (2026-09-10 사장님: 셔플 단추 "얘는 빼도 되지 않을까?").
|
||||
* 곡이 끝나면 어차피 무작위로 다음 곡이 걸리므로 셔플 단추가 할 일이 없었다.
|
||||
* 다른 곡을 지금 듣고 싶으면 목록에서 고른다 — 그게 더 짧은 길이다. */
|
||||
var ICON = {
|
||||
/* ★ 카세트가 먼저 선다 (2026-09-10 사장님: "전혀 뮤직플레이어 같지가 않아").
|
||||
세모·셔플·줄 세 개만 두면 헤더의 다른 아이콘과 구별이 안 된다 — 무엇을 하는 물건인지가
|
||||
한눈에 안 읽힌다. 카세트는 그 자체가 '노래' 라는 표시이고, 재생 중에는 릴이 돈다. */
|
||||
cassette: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">' +
|
||||
'<rect x="2" y="5" width="20" height="14" rx="2"/>' +
|
||||
'<g class="w4d-reel"><circle cx="8.5" cy="12" r="2.6"/><path d="M8.5 9.4v5.2M5.9 12h5.2"/></g>' +
|
||||
'<g class="w4d-reel"><circle cx="15.5" cy="12" r="2.6"/><path d="M15.5 9.4v5.2M12.9 12h5.2"/></g>' +
|
||||
'<path d="M6 19l1.5-2.4h9L18 19"/></svg>',
|
||||
note: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 4v10.5a3.5 3.5 0 1 1-2-3.16V7.8l-8 1.6v7.1a3.5 3.5 0 1 1-2-3.16V6.2z"/></svg>',
|
||||
play: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>',
|
||||
pause: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h3.4v14H7zm6.6 0H17v14h-3.4z"/></svg>',
|
||||
/* ★ 햄버거(줄 세 개)를 쓰지 않는다 (2026-09-11 사장님: "햄버거 메뉴 버튼이랑 음악 메뉴
|
||||
아이콘이랑 같아서 헷갈림"). 바로 옆에 사이트 메뉴 단추가 같은 모양으로 서 있었다.
|
||||
줄 끝에 음표를 달아 **재생 목록**으로 읽히게 한다. */
|
||||
list: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
|
||||
'stroke-linecap="round"><path d="M4 7h11M4 12h11M4 17h7"/>' +
|
||||
'<path d="M19 17.5V9l3 1.2"/><circle cx="17.4" cy="17.6" r="1.7" fill="currentColor" stroke="none"/></svg>',
|
||||
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>',
|
||||
};
|
||||
|
||||
/* 플레이어가 트는 것은 **숙소가 만든 곡**이다(payload.narrative.ownSongs).
|
||||
가요 다방 섹션(theme.sections.songs)은 이 도시를 노래한 옛 곡이라 우리 음원이 없다 —
|
||||
목록에 세우면 누를 때마다 유튜브로 튕겨 나간다. 두 목록은 섞지 않는다. */
|
||||
function songsOfPayload() {
|
||||
var box = P.narrative && P.narrative.ownSongs;
|
||||
return (box && box.items) || [];
|
||||
}
|
||||
|
||||
function startPlayer() {
|
||||
var songs = songsOfPayload().filter(function (song) { return song.audioUrl; });
|
||||
if (songs.length === 0) return;
|
||||
|
||||
/* ★ **시작 곡만 무작위, 그다음은 순차**다 (2026-09-11 사장님 지시).
|
||||
매번 무작위로 뽑으면 다섯 곡을 한 바퀴 도는 데 순서가 없어 "아까 그 곡" 을 다시 찾기 어렵다.
|
||||
손님마다 다른 곡으로 시작하되(들어올 때마다 같은 곡이면 배경음악이 하나인 것처럼 들린다),
|
||||
한 번 시작하면 A면 순서대로 끝까지 돌고 처음으로 돌아온다. */
|
||||
var cur = Math.floor(Math.random() * songs.length);
|
||||
var playing = false;
|
||||
|
||||
function nextIndex() {
|
||||
return (cur + 1) % songs.length;
|
||||
}
|
||||
|
||||
/* ── 헤더에 꽂을 단추 세 개 ─────────────────────────────────────── */
|
||||
var mini = document.createElement('span');
|
||||
mini.id = 'w4d-mini';
|
||||
mini.innerHTML =
|
||||
'<span id="w4d-tape" aria-hidden="true" title="머뭄이 만든 노래">' + ICON.cassette + '</span>' +
|
||||
'<span id="w4d-now" aria-live="polite"></span>' +
|
||||
'<button type="button" id="w4d-play" title="머뭄의 노래 재생">' + ICON.play + '</button>' +
|
||||
'<button type="button" id="w4d-toggle" title="노래 목록" aria-expanded="false">' + ICON.list + '</button>';
|
||||
|
||||
/* ── 목록 패널 (body 직속) ──────────────────────────────────────── */
|
||||
var panel = document.createElement('div');
|
||||
panel.id = 'w4d-panel';
|
||||
panel.hidden = true;
|
||||
panel.innerHTML =
|
||||
'<div class="w4d-head">' +
|
||||
'<span class="w4d-head-t">머뭄이 만든 노래</span>' +
|
||||
'<span class="w4d-head-n">' + songs.length + '곡</span>' +
|
||||
'<button type="button" id="w4d-close" title="닫기" aria-label="닫기">' + ICON.close + '</button></div>' +
|
||||
'<ol></ol>' +
|
||||
'<div id="w4d-lyrics-box" hidden></div>';
|
||||
document.body.appendChild(panel);
|
||||
|
||||
var listOl = panel.querySelector('ol');
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
|
||||
var audio = new Audio();
|
||||
audio.preload = 'none'; // 손님이 누를 때까지 6MB 를 받지 않는다
|
||||
audio.addEventListener('timeupdate', paintRow);
|
||||
audio.addEventListener('durationchange', paintRow);
|
||||
audio.addEventListener('ended', function () { load(nextIndex(), true); });
|
||||
audio.addEventListener('error', function () { playing = false; render(); });
|
||||
|
||||
function clock(sec) {
|
||||
if (typeof sec !== 'number' || !isFinite(sec)) return '';
|
||||
var m = Math.floor(sec / 60);
|
||||
var s = Math.floor(sec % 60);
|
||||
return m + ':' + (s < 10 ? '0' : '') + s;
|
||||
}
|
||||
|
||||
function meta(song) {
|
||||
return [song.artist, song.year].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/* 목록 한 줄 = 판 하나 + 제목/가수 + 시간.
|
||||
★ 줄 높이를 48px 아래로 내리지 않는다 — 손가락으로 누르는 자리다(모바일). */
|
||||
function buildList() {
|
||||
listOl.innerHTML = '';
|
||||
songs.forEach(function (song, index) {
|
||||
var li = document.createElement('li');
|
||||
var btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'w4d-item';
|
||||
btn.innerHTML =
|
||||
'<span class="w4d-mark">' +
|
||||
'<span class="w4d-disc-sm"></span>' +
|
||||
'<span class="w4d-eq" aria-hidden="true"><i></i><i></i><i></i></span>' +
|
||||
'</span>' +
|
||||
'<span class="w4d-info"><span class="w4d-t"></span><span class="w4d-sub"></span></span>' +
|
||||
'<span class="w4d-time"></span>';
|
||||
btn.querySelector('.w4d-disc-sm').style.setProperty('--w4d-lbl', song.labelColor || '#bf2f1b');
|
||||
btn.querySelector('.w4d-t').textContent = song.title;
|
||||
btn.querySelector('.w4d-sub').textContent = song.artist || '';
|
||||
btn.addEventListener('click', function () { load(index, true); });
|
||||
li.appendChild(btn);
|
||||
listOl.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
/* 시간은 지금 재생 중인 줄에만 적는다 — 헤더에는 곡명도 시간도 두지 않기로 했다.
|
||||
★ 나머지 줄은 비워 둔다. 예전에는 가수 이름을 넣었는데 다섯 곡이 전부 '스테이,머뭄' 이라
|
||||
같은 글자가 다섯 줄 반복됐다(가수는 줄 안에 이미 있다). */
|
||||
function paintRow() {
|
||||
var rows = listOl.querySelectorAll('.w4d-item');
|
||||
for (var i = 0; i < rows.length; i += 1) {
|
||||
var slot = rows[i].querySelector('.w4d-time');
|
||||
if (i !== cur || !audio.src) { slot.textContent = ''; continue; }
|
||||
slot.textContent = playing || audio.currentTime > 0
|
||||
? clock(audio.currentTime) + (audio.duration ? ' / ' + clock(audio.duration) : '')
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
var song = songs[cur] || {};
|
||||
/* 헤더에 **지금 나오는 곡명**을 적는다 (2026-09-11 사장님: "현재 곡명이 나오는 게 맞지 않나").
|
||||
소리가 나는데 무슨 곡인지 화면 어디에도 없으면 손님이 목록을 열어 봐야 안다. */
|
||||
$('w4d-now').textContent = song.title || '';
|
||||
$('w4d-play').innerHTML = playing ? ICON.pause : ICON.play;
|
||||
$('w4d-play').dataset.playing = playing ? '1' : '0';
|
||||
mini.dataset.playing = playing ? '1' : '0';
|
||||
$('w4d-play').title = (playing ? '멈춤 · ' : '재생 · ') + (song.title || '');
|
||||
var rows = listOl.querySelectorAll('.w4d-item');
|
||||
for (var i = 0; i < rows.length; i += 1) {
|
||||
rows[i].setAttribute('aria-current', i === cur ? 'true' : 'false');
|
||||
rows[i].dataset.playing = i === cur && playing ? '1' : '0';
|
||||
}
|
||||
var box = $('w4d-lyrics-box');
|
||||
if (song.lyrics) { box.textContent = song.lyrics; box.hidden = false; } else { box.hidden = true; }
|
||||
paintRow();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
playing = false;
|
||||
audio.pause(); // 자리는 남긴다 — 다시 누르면 이어 듣는다
|
||||
}
|
||||
|
||||
function load(index, alsoPlay) {
|
||||
cur = (index + songs.length) % songs.length;
|
||||
stop();
|
||||
audio.removeAttribute('src');
|
||||
render();
|
||||
if (alsoPlay) play();
|
||||
}
|
||||
|
||||
function play() {
|
||||
var song = songs[cur] || {};
|
||||
if (!song.audioUrl) return;
|
||||
if (audio.getAttribute('src') !== song.audioUrl) audio.src = song.audioUrl;
|
||||
var started = audio.play();
|
||||
// 자동재생 차단·파일 오류는 예외가 아니라 거절된 약속으로 온다.
|
||||
if (started && started.catch) started.catch(function () { playing = false; render(); });
|
||||
playing = true;
|
||||
render();
|
||||
}
|
||||
|
||||
/* 패널은 fixed 라 헤더 밑에 손으로 붙인다 — 헤더가 sticky 라 스크롤해도 자리가 같다. */
|
||||
/* 패널 자리. 좁은 화면에서는 좌우를 화면에 맞춰 펼친다 — 320px 고정이면 390px 화면에서
|
||||
한쪽이 잘리거나 글자가 두 줄로 접힌다(2026-09-11 사장님: "모바일 대응이 안 되어 있음"). */
|
||||
function placePanel() {
|
||||
var header = document.querySelector('#root header');
|
||||
var box = header ? header.getBoundingClientRect() : null;
|
||||
panel.style.top = (box ? box.bottom + 6 : 66) + 'px';
|
||||
if (window.innerWidth <= 640) {
|
||||
panel.style.left = '8px';
|
||||
panel.style.right = '8px';
|
||||
} else {
|
||||
panel.style.left = 'auto';
|
||||
var anchor = mini.getBoundingClientRect();
|
||||
panel.style.right = Math.max(12, window.innerWidth - anchor.right) + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
function togglePanel(force) {
|
||||
var open = typeof force === 'boolean' ? force : panel.hidden;
|
||||
panel.hidden = !open;
|
||||
$('w4d-toggle').setAttribute('aria-expanded', String(open));
|
||||
if (open) placePanel();
|
||||
}
|
||||
|
||||
mini.addEventListener('click', function (event) {
|
||||
var btn = event.target.closest('button');
|
||||
if (!btn) return;
|
||||
if (btn.id === 'w4d-play') { if (playing) { stop(); render(); } else { play(); } }
|
||||
if (btn.id === 'w4d-toggle') { togglePanel(); }
|
||||
});
|
||||
panel.querySelector('#w4d-close').addEventListener('click', function () { togglePanel(false); });
|
||||
window.addEventListener('resize', function () { if (!panel.hidden) placePanel(); });
|
||||
window.addEventListener('scroll', function () { if (!panel.hidden) placePanel(); }, {passive: true});
|
||||
document.addEventListener('click', function (event) {
|
||||
if (panel.hidden) return;
|
||||
if (panel.contains(event.target) || mini.contains(event.target)) return;
|
||||
togglePanel(false);
|
||||
});
|
||||
|
||||
/* 가요 다방 섹션의 판을 누르면 그 곡이 얹힌다. 섹션에는 옛 가요도 서 있어 순번이 어긋나므로
|
||||
제목으로 찾는다. 우리 목록에 없는 곡(옛 가요)이면 섹션이 알아서 한다. */
|
||||
document.addEventListener('click', function (event) {
|
||||
var btn = event.target && event.target.closest ? event.target.closest('#songs button') : null;
|
||||
if (!btn) return;
|
||||
var label = (btn.textContent || '').trim();
|
||||
var index = -1;
|
||||
songs.forEach(function (song, i) { if (index < 0 && song.title === label) index = i; });
|
||||
if (index >= 0) load(index, false);
|
||||
});
|
||||
|
||||
/* ★ 헤더에 꽂고, 지워지면 다시 꽂는다.
|
||||
React 가 하이드레이션에 실패하면 #root 를 통째로 다시 그린다 — 그때 단추가 사라진다.
|
||||
한 번 꽂고 끝내면 그 순간부터 조용히 없어진다(오류도 안 난다). */
|
||||
function mount() {
|
||||
if (mini.isConnected) return true;
|
||||
var header = document.querySelector('#root header');
|
||||
if (!header) return false;
|
||||
// 헤더 오른쪽 묶음(전화·메뉴 단추가 있는 자리) 맨 앞에 꽂는다.
|
||||
var slot = header.querySelector('.shell > div:last-child') ||
|
||||
header.querySelector('div:last-child');
|
||||
if (!slot) return false;
|
||||
slot.insertBefore(mini, slot.firstChild);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ★ 자동재생은 두지 않는다 (2026-09-11 사장님: "그럼 자동 빼").
|
||||
크롬·사파리는 소리 나는 재생을 사용자 조작 없이 허용하지 않는다. 그래서 "막히면
|
||||
손님이 처음 만지는 순간 켠다" 를 넣어 봤는데, 그 리스너가 window 캡처라
|
||||
**재생 단추의 클릭을 먼저 먹었다** — 첫 클릭에 켜졌다가 단추 핸들러가 다시 꺼서
|
||||
두 번 눌러야 켜졌다. 자동재생을 빼면 그 충돌 자체가 없어진다.
|
||||
시작 곡이 무작위인 것은 그대로다 — 손님이 재생을 누르면 그 곡부터 순서대로 돈다. */
|
||||
|
||||
buildList();
|
||||
mount();
|
||||
render();
|
||||
if (window.MutationObserver) {
|
||||
new MutationObserver(function () {
|
||||
if (!mini.isConnected) { mount(); render(); }
|
||||
}).observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
|
||||
}
|
||||
console.log('[w4d] 미니 플레이어 — 머뭄이 만든 곡 ' + songs.length + '곡');
|
||||
}
|
||||
|
||||
|
||||
/* ══ ③ 링크 걸기 ═══════════════════════════════════════════════════════════
|
||||
* 렌더러는 일정의 "지도 검색 …" 과 엽서의 장소를 **글자로만** 낸다. 손님이 그 자리에서
|
||||
* 지도를 열 방법이 없다 — 이름을 복사해 다른 앱에 붙여야 한다.
|
||||
* 렌더러를 고치면 다른 사이트까지 바뀌므로(목업만 손대는 규약), 여기서 DOM 을 덧칠한다.
|
||||
*
|
||||
* ★ 네이버 지도 검색 주소를 쓴다. 좌표로 핀을 찍는 주소는 앱·웹에서 형태가 달라 한쪽이 깨진다.
|
||||
* ★ React 가 다시 그리면 사라지므로 감시해서 다시 건다.
|
||||
*/
|
||||
function sectionItems(id) {
|
||||
var sections = (P.theme && P.theme.sections) || [];
|
||||
for (var i = 0; i < sections.length; i += 1) {
|
||||
if (sections[i].id !== id) continue;
|
||||
try {
|
||||
var env = typeof sections[i].data === 'string' ? JSON.parse(sections[i].data) : sections[i].data;
|
||||
return (env && env.items) || [];
|
||||
} catch (err) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function mapUrl(query) {
|
||||
return 'https://map.naver.com/p/search/' + encodeURIComponent(query);
|
||||
}
|
||||
|
||||
function linkify() {
|
||||
credits();
|
||||
// 일정: "10:20–11:50 · 지도 검색 군산근대역사박물관"
|
||||
var rows = document.querySelectorAll('#itinerary li p');
|
||||
Array.prototype.forEach.call(rows, function (row) {
|
||||
if (row.querySelector('a')) return;
|
||||
var text = row.textContent || '';
|
||||
var mark = text.indexOf('지도 검색 ');
|
||||
if (mark < 0) return;
|
||||
var query = text.slice(mark + 6).trim();
|
||||
if (!query) return;
|
||||
var head = document.createTextNode(text.slice(0, mark));
|
||||
var link = document.createElement('a');
|
||||
link.href = mapUrl(query);
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer nofollow';
|
||||
link.className = 'w4d-link';
|
||||
link.textContent = '지도에서 열기 ↗';
|
||||
row.textContent = '';
|
||||
row.appendChild(head);
|
||||
row.appendChild(link);
|
||||
});
|
||||
|
||||
/* 엽서: **payload 의 place** 로 링크를 만든다.
|
||||
화면의 소인 글자를 긁었더니 "군산 군산" 같은 주소가 나왔다 — 소인은 지명이 아니라
|
||||
도장 문구다(예: "군산 內港"). 카드 순서와 payload 항목 순서가 같으므로 자리로 짝짓는다. */
|
||||
var cards = document.querySelectorAll('#postcard article');
|
||||
var postcards = sectionItems('postcard');
|
||||
Array.prototype.forEach.call(cards, function (card, index) {
|
||||
if (card.querySelector('a.w4d-link')) return;
|
||||
var item = postcards[index];
|
||||
var place = item && (item.place || item.postmark);
|
||||
if (!place) return;
|
||||
var tags = Array.prototype.filter.call(card.querySelectorAll('p'), function (el) {
|
||||
return (el.textContent || '').trim().charAt(0) === '#';
|
||||
})[0];
|
||||
if (!tags) return;
|
||||
var link = document.createElement('a');
|
||||
link.href = mapUrl(place.indexOf('군산') >= 0 ? place : '군산 ' + place);
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer nofollow';
|
||||
link.className = 'w4d-link';
|
||||
link.textContent = ' 지도에서 열기 ↗';
|
||||
tags.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
/* 인물 사진의 저작자 표시.
|
||||
★ 이 목업이 물고 있는 렌더러 번들은 `imageCredit` 을 그리지 않는 옛 판이다(고쳐도 다른
|
||||
사이트까지 다시 구워야 해서 손대지 않는다). 그런데 사진은 CC BY·BY-SA·공공누리라
|
||||
**저작자 표시가 라이선스 조건**이다 — 안 나오면 침해다. 그래서 여기서 붙인다. */
|
||||
function credits() {
|
||||
var cards = document.querySelectorAll('#people article');
|
||||
var items = sectionItems('people');
|
||||
Array.prototype.forEach.call(cards, function (card, index) {
|
||||
if (card.querySelector('.w4d-credit')) return;
|
||||
var item = items[index];
|
||||
if (!item || !item.imageCredit || !card.querySelector('img')) return;
|
||||
var line = card.querySelector('p:last-of-type') || card;
|
||||
var span = document.createElement('p');
|
||||
span.className = 'w4d-credit';
|
||||
span.textContent = item.imageCredit;
|
||||
line.parentElement.appendChild(span);
|
||||
});
|
||||
}
|
||||
|
||||
function watchLinks() {
|
||||
linkify();
|
||||
if (!window.MutationObserver) return;
|
||||
var timer = null;
|
||||
new MutationObserver(function () {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(linkify, 300);
|
||||
}).observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
|
||||
}
|
||||
|
||||
|
||||
/* ══ ④ 카로셀 — 손님이 만지는 동안에는 자동으로 넘기지 않는다 ══════════════════
|
||||
*
|
||||
* ★ 이 페이지의 카로셀은 **구현이 둘**이다.
|
||||
* ① embla-carousel(라이브러리) — 일정·축제·주변 안내. `.slider-viewport` 를 쓰고
|
||||
* 자동 넘김은 `embla.scrollNext()` 로 움직인다.
|
||||
* ② 손으로 만든 가로 스크롤 상자 — 객실·영상·갤러리. `box.scrollBy()` 로 움직인다.
|
||||
* 정책만 `site/src/lib/ui/use-rail-autoplay.ts` 한 벌을 같이 쓴다.
|
||||
* 처음에는 ②만 막았다(`scrollBy` 를 삼키는 방식) — 그래서 **일정 카로셀만 계속 넘어갔다**
|
||||
* (2026-09-11 사장님: "스케줄 보려고 밑에 스크롤 했는데 그다음에 바로 자동 넘어가").
|
||||
*
|
||||
* ★ 그래서 훅이 **이미 가진 정지 장치**를 쓴다.
|
||||
* 훅은 레일 위 `pointerdown` 에 정지 카운터를 올리고 window `pointerup` 에 내린다.
|
||||
* 구현이 둘이어도 이 카운터는 하나다 — 여기에 손을 얹으면 embla 든 스크롤 상자든 같이 멈춘다.
|
||||
* (`button: 2` 로 쏜다. embla 는 주 버튼이 아닌 pointerdown 을 드래그로 치지 않아
|
||||
* 손님의 진짜 드래그를 방해하지 않는다.)
|
||||
*
|
||||
* ★ 두 가지로 나눠 대응한다.
|
||||
* · **쿨타임** — 페이지를 세로로 스크롤하는 것도 "읽는 중" 이다. 무엇이든 만지면
|
||||
* 7초 동안 모든 레일이 멈춘다. 손을 떼고 7초가 지나야 다시 돈다.
|
||||
* · **영구 정지** — 레일을 직접 민 경우. 자동 넘김이 하려던 일("옆에 더 있다")은
|
||||
* 그 손님에게 이미 전달됐다. 그 레일은 다시 돌지 않는다.
|
||||
*/
|
||||
var COOLDOWN_MS = 7000;
|
||||
|
||||
function tameRails() {
|
||||
var quietUntil = 0;
|
||||
var holding = false;
|
||||
var forever = []; // 손님이 직접 민 레일
|
||||
var rails = [];
|
||||
/*
|
||||
* ★ 손이 화면에 닿아 있는 동안은 무조건 멈춘다 (2026-09-11, 사장님: "유저가 터치로 잡고 있는데 자동으로 넘어가는 게 문제")
|
||||
* 쿨타임(7초)만으로는 못 막는다. 손가락을 대고 가만히 읽고 있으면 touchmove 도 scroll 도
|
||||
* 안 오니 쿨타임이 그냥 끝난다. 게다가 브라우저는 터치가 페이지 스크롤로 넘어가는 순간
|
||||
* **손가락이 아직 닿아 있는데 `pointercancel` 을 쏜다** — 훅의 정지 카운터가 거기서 풀린다.
|
||||
* 실측: touchstart 0.0s → pointercancel 7.5s → 8.4s 에 손가락 밑에서 카드가 넘어갔다.
|
||||
* 그래서 pointer 가 아니라 **touches 개수**로 센다. 손이 떨어져야 0 이 된다.
|
||||
*/
|
||||
var fingers = 0;
|
||||
var mouseDown = false;
|
||||
|
||||
function collect() {
|
||||
rails = Array.prototype.slice.call(document.querySelectorAll('#root .slider-viewport'));
|
||||
Array.prototype.forEach.call(document.querySelectorAll('#root *'), function (el) {
|
||||
if (rails.indexOf(el) >= 0) return;
|
||||
var flow = window.getComputedStyle(el).overflowX;
|
||||
if ((flow === 'auto' || flow === 'scroll') && el.scrollWidth - el.clientWidth > 40) {
|
||||
rails.push(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function press(rail) {
|
||||
rail.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, button: 2, buttons: 2}));
|
||||
}
|
||||
|
||||
function holdAll() {
|
||||
collect();
|
||||
rails.forEach(press);
|
||||
holding = true;
|
||||
}
|
||||
|
||||
function releaseAll() {
|
||||
// 해제는 window 하나로 전부에게 간다(훅이 window 에서 받는다). 영구 정지분은 곧바로 다시 잡는다.
|
||||
window.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, button: 2}));
|
||||
holding = false;
|
||||
forever.forEach(press);
|
||||
}
|
||||
|
||||
function railOf(node) {
|
||||
while (node && node.nodeType === 1 && node !== document.body) {
|
||||
if (rails.indexOf(node) >= 0) return node;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* ★ "만졌다" 와 "밀었다" 를 가른다 (2026-09-11, 사장님: "한번 스크롤한뒤에는 auto play 아예 안되는데")
|
||||
* 전에는 레일 위에서 일어난 pointerdown·touchstart·wheel 이면 **곧바로** 영구 정지로 보냈다.
|
||||
* 그런데 페이지를 세로로 내릴 때 손가락(과 마우스 커서)은 거의 항상 레일 위를 지난다 —
|
||||
* 일정 카로셀은 화면 가운데를 가득 채우니까. 그래서 **한 번 스크롤하면 그 레일이 영영 멈췄다.**
|
||||
* 실측: 세로 휠 한 번 → 20초 동안 0px.
|
||||
* 이제 **레일이 실제로 가로로 움직였을 때만** 영구 정지다. 제스처 시작 때 위치를 적어 두고
|
||||
* 손을 뗀 뒤(관성 350ms) 다시 재서, 달라졌으면 손님이 민 것이다.
|
||||
*/
|
||||
function railPos(rail) {
|
||||
var slid = 0;
|
||||
var first = rail.firstElementChild;
|
||||
if (first) {
|
||||
var tf = window.getComputedStyle(first).transform;
|
||||
if (tf && tf !== 'none') {
|
||||
try { slid = new DOMMatrix(tf).m41; } catch (err) { slid = 0; }
|
||||
}
|
||||
}
|
||||
return rail.scrollLeft + slid;
|
||||
}
|
||||
|
||||
var pending = null; // {rail, pos} — 지금 진행 중인 제스처
|
||||
|
||||
function markIfMoved() {
|
||||
if (!pending) return;
|
||||
var rail = pending.rail;
|
||||
var was = pending.pos;
|
||||
pending = null;
|
||||
window.setTimeout(function () {
|
||||
// 8px 여유 — 탭 한 번에도 embla 가 스냅을 다시 잡느라 소수점이 흔들린다.
|
||||
if (Math.abs(railPos(rail) - was) < 8) return;
|
||||
if (forever.indexOf(rail) < 0) { forever.push(rail); press(rail); }
|
||||
}, 350);
|
||||
}
|
||||
|
||||
['pointerdown', 'touchstart', 'touchmove', 'wheel', 'keydown', 'scroll'].forEach(function (type) {
|
||||
window.addEventListener(type, function (event) {
|
||||
if (!event.isTrusted) return;
|
||||
quietUntil = Date.now() + COOLDOWN_MS;
|
||||
if (event.touches) fingers = event.touches.length;
|
||||
if (type === 'pointerdown' && event.pointerType === 'mouse') mouseDown = true;
|
||||
if (type === 'scroll' || type === 'keydown' || type === 'touchmove') return;
|
||||
collect();
|
||||
var rail = railOf(event.target);
|
||||
if (!rail) return;
|
||||
if (type === 'wheel') {
|
||||
// 가로 휠·트랙패드 가로 스와이프만 "민 것"이다. 세로는 페이지를 내리는 중이다.
|
||||
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) && Math.abs(event.deltaX) > 2) {
|
||||
pending = {rail: rail, pos: railPos(rail)};
|
||||
markIfMoved();
|
||||
}
|
||||
return;
|
||||
}
|
||||
pending = {rail: rail, pos: railPos(rail)};
|
||||
}, true);
|
||||
});
|
||||
|
||||
['pointerup', 'pointercancel', 'touchend', 'touchcancel'].forEach(function (type) {
|
||||
window.addEventListener(type, function (event) {
|
||||
if (!event.isTrusted) return;
|
||||
quietUntil = Date.now() + COOLDOWN_MS;
|
||||
// 손가락 수는 touch 계열에서만 갱신한다 — pointercancel 은 손이 닿아 있어도 온다.
|
||||
if (event.touches) fingers = event.touches.length;
|
||||
if (event.pointerType === 'mouse') mouseDown = false;
|
||||
if (fingers === 0 && !mouseDown) markIfMoved();
|
||||
}, true);
|
||||
});
|
||||
|
||||
// 250ms 마다 조용해야 할 때인지 본다. 타이머를 직접 만지지 않고 훅의 정지 장치만 건드린다.
|
||||
window.setInterval(function () {
|
||||
var quiet = fingers > 0 || mouseDown || Date.now() < quietUntil;
|
||||
if (quiet && !holding) holdAll();
|
||||
else if (!quiet && holding) releaseAll();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
/* ★ `load` 를 기다리지 않는다. 이 페이지는 경로 지도 때문에 OSM 타일 <img> 가 151장이라
|
||||
(일정 20개 × 2~3일) load 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. */
|
||||
console.log('[w4d] 주입분 로드');
|
||||
try { startPlayer(); } catch (err) { console.warn('[w4d] 플레이어 실패', err); }
|
||||
try { tameRails(); console.log('[w4d] 카로셀 — 만지는 동안 정지(7초) · 직접 민 레일은 영구 정지'); }
|
||||
catch (err) { console.warn('[w4d] 카로셀 실패', err); }
|
||||
window.setTimeout(function () {
|
||||
try {
|
||||
watchLinks();
|
||||
console.log('[w4d] 링크 걸기 — 일정 · 엽서');
|
||||
} catch (err) { console.warn('[w4d] 링크 실패', err); }
|
||||
try {
|
||||
startCatchphrase();
|
||||
console.log('[w4d] 캐치프레이즈 ' + catchphrases().length + '개 · 7초마다 전환');
|
||||
} catch (err) { console.warn('[w4d] 캐치프레이즈 실패', err); }
|
||||
}, 1200);
|
||||
})();
|
||||
18
solution/site/scripts/mockup/island.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
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();
|
||||
20
solution/site/scripts/mockup/kingcheck.mjs
Normal file
@ -0,0 +1,20 @@
|
||||
import {chromium} from 'playwright';
|
||||
const b = await chromium.launch({channel: 'chrome'});
|
||||
const p = await b.newPage({viewport: {width: 1280, height: 900}});
|
||||
await p.goto(process.argv[2] || 'http://localhost/s/stay', {waitUntil: 'domcontentloaded'});
|
||||
await p.waitForTimeout(5500);
|
||||
const r = await p.evaluate(() => {
|
||||
const dl = [...document.querySelectorAll('dl')].find((d) => d.textContent.includes('픽업'));
|
||||
if (!dl) return {err: 'dl 없음'};
|
||||
const rows = [...dl.children].map((el) => el.textContent.trim().replace(/\s+/g, ' ').slice(0, 24));
|
||||
const last = dl.lastElementChild;
|
||||
const lastStyle = getComputedStyle(last);
|
||||
const section = dl.closest('section');
|
||||
const after = section?.nextElementSibling;
|
||||
return {rows: rows.slice(-4), lastBorderBottom: lastStyle.borderBottomWidth + ' ' + lastStyle.borderBottomStyle,
|
||||
dlBorderBottom: getComputedStyle(dl).borderBottomWidth,
|
||||
afterTag: after?.tagName, afterCls: (after?.className||'').toString().slice(0,60),
|
||||
afterBorderTop: after ? getComputedStyle(after).borderTopWidth : null};
|
||||
});
|
||||
console.log(JSON.stringify(r, null, 1));
|
||||
await b.close();
|
||||
88
solution/site/scripts/mockup/orig/index.html
Normal file
270
solution/site/scripts/mockup/patch_stay.py
Normal file
@ -0,0 +1,270 @@
|
||||
"""/s/stay 목업 index.html 을 다시 만든다 — payload 교체 + 주입분(css·js) 삽입.
|
||||
|
||||
★ 이 사이트는 payload 가 없다(목업). 프리렌더 재굽기 대상이 아니라서, 고칠 자리는
|
||||
구워진 index.html 하나다. 손으로 열어 고치지 않고 이 스크립트로 만든다 —
|
||||
두 번째로 고칠 때 무엇을 바꿨는지가 파일 대신 여기 남아야 한다.
|
||||
|
||||
입력: stay-orig/index.html (볼륨에서 꺼낸 원본) · stay-payload-new.json · inject.css · inject.js
|
||||
출력: stay-work/index.html
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
SP = Path(__file__).parent
|
||||
|
||||
# ── 캐치프레이즈 100개 ────────────────────────────────────────────────────────
|
||||
# 사실이 아니라 문구다(출처가 붙는 값이 아니다). 근거는 payload 의 시설·위치 값 —
|
||||
# 적산가옥 두 동 · 히로쓰 가옥 옆 · 마당과 정원 · 창고형 카페 · 매일 세탁하는 침구 · 최대 4인.
|
||||
GENERAL = [
|
||||
"백 년 된 집에서 하룻밤",
|
||||
"담 너머는 히로쓰 가옥입니다",
|
||||
"독채 두 동, 마당은 각자 씁니다",
|
||||
"기와 아래에서 아침을 맞습니다",
|
||||
"새소리로 눈이 떠지는 집",
|
||||
"골목이 조용해지는 시간에 도착하세요",
|
||||
"침구는 매일 새것처럼 나갑니다",
|
||||
"마당 평상에 앉으면 항구 소리가 들립니다",
|
||||
"1920년대에 지은 집, 지금 쓰는 집",
|
||||
"대문을 닫으면 골목이 멀어집니다",
|
||||
"창고를 고친 카페 공간이 딸려 있습니다",
|
||||
"밤에는 정원 석등만 켜 둡니다",
|
||||
"초원사진관까지 걸어서 3분",
|
||||
"원도심 한가운데, 차는 세워 두세요",
|
||||
"A동은 일본식, B동은 모던입니다",
|
||||
"네 사람까지 묶는 독채입니다",
|
||||
"오후 세 시부터 이 집은 손님 것입니다",
|
||||
"이 골목에서는 서두를 일이 없습니다",
|
||||
"백 년 된 기와가 비를 받습니다",
|
||||
"여행이라기보다 하루의 이사입니다",
|
||||
"군산에서 가장 조용한 담 안쪽",
|
||||
"이 집의 시간은 조금 느립니다",
|
||||
"마당, 정원, 그리고 평상 하나",
|
||||
"짐을 풀면 골목부터 걸어 보세요",
|
||||
"창을 열면 옆집 기와가 보입니다",
|
||||
"담배 냄새가 없는 집입니다",
|
||||
"이불에서 볕 냄새가 납니다",
|
||||
"두 동 모두 남의 인기척이 없습니다",
|
||||
"내항까지 걸어 십 분",
|
||||
"오래된 것을 고쳐 쓰는 집",
|
||||
"부엌이 있어 아침은 지어 드셔도 됩니다",
|
||||
"밤에는 골목 등만 남습니다",
|
||||
"백 년 된 집이 아직 손님을 받습니다",
|
||||
"개항장 골목의 끝집입니다",
|
||||
"문패보다 기와를 보고 찾아오세요",
|
||||
"걸어서 도는 여행에 알맞은 자리",
|
||||
"마루에 앉는 시간이 일정의 절반입니다",
|
||||
"좁은 골목 끝, 대문 하나",
|
||||
"여기서는 계획을 반쯤 접어도 됩니다",
|
||||
"하루를 통째로 쓰는 숙소",
|
||||
]
|
||||
SEASON = {
|
||||
"봄": ["정원에 꽃이 먼저 옵니다", "봄에는 대문을 열어 둡니다", "벚꽃 지는 골목을 걸어 오세요",
|
||||
"마당에 앉는 계절이 시작됩니다", "새소리가 가장 많은 철입니다", "창을 열면 봄이 방까지 들어옵니다"],
|
||||
"여름": ["처마 그늘이 가장 긴 계절입니다", "마당에 물을 뿌리면 시원해집니다", "여름 밤에는 평상이 제일 좋습니다",
|
||||
"창고형 카페가 낮에는 서늘합니다", "소나기는 기와가 받아 줍니다", "해가 길어 저녁이 넉넉합니다"],
|
||||
"가을": ["기와 위로 가을 볕이 마릅니다", "가을 바람이 담을 넘어옵니다", "가을 골목은 해가 빨리 눕습니다",
|
||||
"창을 열어 두고 자도 되는 밤입니다", "가을에는 평상이 가장 좋은 자리입니다", "마당에 잎이 떨어지는 계절입니다"],
|
||||
"겨울": ["기와에 눈이 앉으면 골목이 조용해집니다", "겨울 아침 마당은 서리 밭입니다", "이불 속에서 겨울 바람 소리를 듣습니다",
|
||||
"눈 오면 정원 석등만 켜 둡니다", "항구 바람이 매운 계절입니다", "겨울에는 카페 공간이 가장 아늑합니다"],
|
||||
}
|
||||
MONTH = {
|
||||
1: ["새해 첫 아침을 백 년 집에서", "겨울 골목이 가장 조용한 달입니다"],
|
||||
2: ["아직 바람이 찬 원도심입니다", "겨울 끝을 마당에서 봅니다"],
|
||||
3: ["정원에 첫 꽃이 오는 달입니다", "대문을 열어 두기 시작합니다"],
|
||||
4: ["벚꽃 지는 골목을 걷는 달입니다", "마당에 앉는 시간이 길어집니다"],
|
||||
5: ["새소리가 가장 많은 달입니다", "창을 열어 두고 자는 밤입니다"],
|
||||
6: ["처마 그늘이 길어지는 달입니다", "여름 앞의 원도심은 한가합니다"],
|
||||
7: ["소나기를 기와가 받는 달입니다", "여름 밤 평상이 제일 좋습니다"],
|
||||
8: ["낮에는 카페 공간이 서늘합니다", "해가 길어 저녁이 넉넉한 달입니다"],
|
||||
9: ["볕이 마르기 시작하는 달입니다", "가을이 담을 넘어오는 구월입니다"],
|
||||
10: ["잎이 마당에 떨어지는 달입니다", "걷기에 가장 좋은 달입니다"],
|
||||
11: ["해가 빨리 눕는 골목입니다", "이불이 가장 따뜻한 달입니다"],
|
||||
12: ["기와에 눈이 앉는 달입니다", "한 해의 끝을 조용히 보내는 자리"],
|
||||
}
|
||||
# 키는 렌더러(use-live-weather.ts)의 분류와 같아야 한다 — 맑음·구름많음·비·눈·흐림.
|
||||
WEATHER = {
|
||||
"맑음": ["마당 평상에 앉으면 새소리만 들립니다", "정원 쪽으로 그늘이 길게 눕는 시간입니다", "기와가 마르는 냄새가 납니다"],
|
||||
"구름많음": ["빛이 부드러운 날입니다", "골목 사진이 제일 잘 나오는 빛입니다"],
|
||||
"흐림": ["빛이 낮게 깔리는 날입니다", "이런 날은 카페 공간이 가장 아늑합니다"],
|
||||
"비": ["백 년 된 기와를 타고 내리는 소리가 다릅니다", "카페 공간에서 빗소리를 들으실 수 있습니다", "비 오는 골목에는 사람이 없습니다"],
|
||||
"눈": ["기와에 눈이 앉으면 골목이 통째로 조용해집니다", "눈 밟는 소리가 담 안에서 크게 들립니다"],
|
||||
}
|
||||
|
||||
items = [{"text": t, "kind": "general"} for t in GENERAL]
|
||||
items += [{"text": t, "kind": "season", "season": s} for s, lst in SEASON.items() for t in lst]
|
||||
items += [{"text": t, "kind": "month", "month": m} for m, lst in MONTH.items() for t in lst]
|
||||
items += [{"text": t, "kind": "weather", "weather": w} for w, lst in WEATHER.items() for t in lst]
|
||||
|
||||
payload = json.loads((SP / "stay-payload-new.json").read_text(encoding="utf-8"))
|
||||
payload["narrative"]["catchphrases"] = {"version": 1, "items": items}
|
||||
(SP / "stay-payload-new.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
# ── 머뭄이 만든 노래 5곡 ──────────────────────────────────────────────────────
|
||||
# ★ 가요 다방 섹션에는 **넣지 않는다** (2026-09-10 사장님: "가요다방에 왜 우리 노래 넣음???").
|
||||
# 그 섹션은 '이 도시를 노래한 곡' 을 모으는 자리다 — 숙소가 만든 곡을 거기 섞으면
|
||||
# 군산 노래를 보러 온 사람에게 광고를 끼워 넣는 꼴이고, 출처·연도 칸도 채울 수 없다.
|
||||
# 플레이어(헤더)만 이 목록을 읽는다. 자리는 narrative.ownSongs 다.
|
||||
OWN_SONGS = [
|
||||
("머뭄의 아침", "01-mumum-morning.mp3", "#c8873a"),
|
||||
("CHECK IN · 머뭄", "02-check-in.mp3", "#2f6b8f"),
|
||||
("Stay in 머뭄", "03-stay-in.mp3", "#6b7f5b"),
|
||||
("오늘은 안 갈란다", "04-not-going.mp3", "#a34a2a"),
|
||||
("군산에 두고 온 님", "05-gunsan-nim.mp3", "#4a3f6b"),
|
||||
]
|
||||
payload["narrative"]["ownSongs"] = {
|
||||
"version": 1,
|
||||
"items": [{"title": title, "artist": "스테이,머뭄", "labelColor": color, "own": True,
|
||||
"audioUrl": "/s/stay/audio/" + filename}
|
||||
for title, filename, color in OWN_SONGS],
|
||||
}
|
||||
|
||||
# ── 객실 사진·내용을 야놀자 등록본으로 **교체**한다 ──────────────────────────────
|
||||
# 출처: https://nol.yanolja.com/stay/domestic/10068088 (사장님이 직접 올린 등록본)
|
||||
#
|
||||
# ★ 배정을 눈으로 하지 않는다 (2026-09-10 사장님: "B동 10개 A동 12개로 파악이 되는데")
|
||||
# 처음엔 사진을 열어 보고 A/B 를 갈랐다 — 11/5/공용 6 이 나왔고 실제는 **A동 12 · B동 10** 이다.
|
||||
# 등록본 페이지의 Next.js 데이터에 `roomTypes[].photos` 가 객실별로 들어 있다.
|
||||
# `scripts/mockup/rooms.json` 이 그걸 뽑아 둔 것이고, 여기 순서도 등록본 순서 그대로다.
|
||||
#
|
||||
# ★ 촬영본(h-*)과 객실 사진(a-*·b-*)은 다른 묶음이다.
|
||||
# 등록본에는 업로드 배치가 둘 있다 — 2026/03/25(객실별 22장, 현장 사진)과
|
||||
# 2026/04/03(촬영본 6장, 밝고 선명하다). **히어로·대표는 촬영본에서 고른다**
|
||||
# (2026-09-10: "히어로 이미지 화질이 별론데" — 현장 사진을 대표로 세웠던 것).
|
||||
# 촬영본은 객실 목록에 안 들어간다 — 등록본이 객실에 붙여 놓지 않았다.
|
||||
#
|
||||
# ★ alt 는 사진을 열어 보고 적는다. 렌더러는 alt 없는 사진을 객실에서 아예 뺀다(derive.ts:112).
|
||||
# ★ 파일은 `/s/stay/img/` 에 둔다 — `out/assets` 는 프리렌더가 관리하는 자리다(음원과 같은 규약).
|
||||
YANOLJA_URL = "https://nol.yanolja.com/stay/domestic/10068088"
|
||||
|
||||
# (파일, 객실, alt, 분류) — 객실은 A동·B동, None 이면 숙소 공용(갤러리에만 선다)
|
||||
ROOM_PHOTOS = [
|
||||
("h-01.jpg", None, "기와 지붕 아래로 넓은 우드 데크와 정원이 이어지는 외관", "외관"),
|
||||
("h-02.jpg", None, "동백이 핀 마당과 우드 파사드 현관으로 이어지는 데크", "외관"),
|
||||
("h-03.jpg", None, "흰 벽과 밝은 바닥에 올리브색 소파와 긴 벽난로를 둔 거실", "거실"),
|
||||
("h-04.jpg", None, "옛 서까래를 드러낸 천장에 라탄 조명이 걸린 거실", "거실"),
|
||||
("h-05.jpg", None, "문 사이로 보이는 흰 앤티크 침대와 검은 서랍장", "침실"),
|
||||
("h-06.jpg", None, "캡슐 커피머신과 레트로 전자레인지가 놓인 주방 어메니티", "주방"),
|
||||
|
||||
("a-01.jpg", "A동", "긴 벽난로와 벽걸이 TV, 좌식 스툴을 둔 거실", "거실"),
|
||||
("a-02.jpg", "A동", "베이지 소파와 원형 원목 테이블, 장지문 창이 있는 거실", "거실"),
|
||||
("a-03.jpg", "A동", "옛 서까래 천장에 라탄 조명이 걸린 거실과 벽난로", "거실"),
|
||||
("a-04.jpg", "A동", "어두운 원목 하부장에 냉장고와 전자레인지를 둔 주방", "주방"),
|
||||
("a-05.jpg", "A동", "붉은 벽돌 벽에 벤치와 색색 의자를 둔 창고형 카페 공간", "카페"),
|
||||
("a-06.jpg", "A동", "석재 담과 소나무가 둘러싼 마당의 야외 티테이블", "외관"),
|
||||
("a-07.jpg", "A동", "원목 벽과 바닥을 지나 침실로 이어지는 복도", "실내"),
|
||||
("a-08.jpg", "A동", "장지문 창 옆에 흰 침구를 깐 침대가 놓인 침실", "침실"),
|
||||
("a-09.jpg", "A동", "정원이 보이는 큰 장지문 창과 벽걸이 TV", "거실"),
|
||||
("a-10.jpg", "A동", "원형 거울과 욕조, 어메니티를 둔 욕실", "욕실"),
|
||||
("a-11.jpg", "A동", "우드 외벽 건물과 담 사이 마당에 놓인 야외 테이블", "외관"),
|
||||
("a-12.jpg", "A동", "흰 벽돌 벽과 우드 담에 걸린 스테이 머뭄 간판", "외관"),
|
||||
|
||||
("b-01.jpg", "B동", "초가 파라솔을 세운 데크와 검은 현관문이 있는 외관", "외관"),
|
||||
("b-02.jpg", "B동", "유리문을 열어 둔 카페 공간과 데크의 파라솔 테이블", "외관"),
|
||||
("b-03.jpg", "B동", "큰 창으로 빛이 드는 거실의 다크그린 소파와 샹들리에", "거실"),
|
||||
("b-04.jpg", "B동", "흰 타일 벽에 민트색 냉장고와 토스터를 둔 주방", "주방"),
|
||||
("b-05.jpg", "B동", "흰 앤티크 침대와 꽃 모양 조명이 있는 침실", "침실"),
|
||||
("b-06.jpg", "B동", "초록 서랍장과 거울, 유리문이 있는 침실 앞 공간", "실내"),
|
||||
("b-07.jpg", "B동", "커피 레터링 벽과 빨간 스툴을 둔 카페 공간", "카페"),
|
||||
("b-08.jpg", "B동", "욕조와 세면대, 블라인드 창이 있는 욕실", "욕실"),
|
||||
("b-09.jpg", "B동", "흰 벽돌 아치 현관에 우편함이 걸린 대문", "외관"),
|
||||
("b-10.jpg", "B동", "흰 벽돌 벽을 따라 디딤돌이 놓인 통로", "외관"),
|
||||
]
|
||||
# ★ 히어로와 소개 사진은 **원래 것으로 되돌린다** (2026-09-10 사장님: "히어로랑 소개 이미지 원복해")
|
||||
# 히어로는 대표 사진(isPrimary)을, 소개는 **대표가 아닌 첫 사진**을 쓴다
|
||||
# (HeroSection.tsx:40 primaryImage · AboutSection.tsx:23 galleryImages().find(!isPrimary)).
|
||||
# 그래서 원본 media 를 앞에 그대로 두고 등록본 사진을 뒤에 붙인다 — 순서가 곧 그 두 자리다.
|
||||
# 객실 안내는 그대로 등록본 사진만 쓴다(units.mediaIds).
|
||||
HERO_PHOTO = None # 대표는 원본 사진이 계속 갖는다
|
||||
|
||||
_NS = uuid.UUID("6f1f3f5e-0000-4000-8000-000000000000") # 재실행해도 같은 mediaId 가 나오게
|
||||
|
||||
new_media = []
|
||||
per_unit = {"A동": [], "B동": []}
|
||||
for filename, room, alt, category in ROOM_PHOTOS:
|
||||
media_id = str(uuid.uuid5(_NS, filename))
|
||||
new_media.append({
|
||||
"mediaId": media_id,
|
||||
"url": "/s/stay/img/" + filename,
|
||||
"alt": alt,
|
||||
"category": category,
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"isPrimary": filename == HERO_PHOTO,
|
||||
"sourceType": 3,
|
||||
"originUrl": YANOLJA_URL,
|
||||
"unitId": None,
|
||||
})
|
||||
if room:
|
||||
per_unit[room].append(media_id)
|
||||
|
||||
# 원본(네이버 예약본 미러) 사진을 앞에, 등록본 사진을 뒤에.
|
||||
ORIGINAL_MEDIA = json.loads((SP / "stay-payload.json").read_text(encoding="utf-8"))["media"]
|
||||
payload["media"] = ORIGINAL_MEDIA + new_media
|
||||
|
||||
# ★ 등록본에만 있던 값 — 침대 구성 · 면적 · 객실 시설.
|
||||
# bed_type·room_size 는 렌더러가 칩으로 세우는 키다(derive.ts:87). 없으면 '기준 2인 / 최대 4인'
|
||||
# 만 서고, 손님이 가장 먼저 묻는 "침대가 뭔가 · 몇 평인가" 가 화면에 없다.
|
||||
NOW = "2026-09-10T00:00:00+00:00"
|
||||
ROOM_FACTS = {
|
||||
"A동": [("bed_type", "침대", "퀸 침대 1개"), ("room_size", "면적", "52")],
|
||||
"B동": [("bed_type", "침대", "킹 침대 1개")],
|
||||
}
|
||||
ROOM_UNITS = {"room_size": "m²"}
|
||||
FACILITIES = "욕조 · 개별 화장실 · 주방 · 다이닝룸 · 테라스 · 벽난로 · OTT · 어메니티 · 취사 가능"
|
||||
|
||||
for unit in payload["units"]:
|
||||
room = unit["name"][:2]
|
||||
extra = list(ROOM_FACTS.get(room, [])) + [("room_facilities", "객실 시설", FACILITIES)]
|
||||
have = {f["key"] for f in unit["facts"]}
|
||||
for key, label, value in extra:
|
||||
if key in have:
|
||||
for fact in unit["facts"]:
|
||||
if fact["key"] == key:
|
||||
fact["value"] = value
|
||||
continue
|
||||
unit["facts"].append({
|
||||
"key": key, "label": label, "value": value, "unit": ROOM_UNITS.get(key),
|
||||
"type": "number" if key == "room_size" else "text",
|
||||
"scope": "unit", "status": 3, "sourceType": 3, "sourceUrl": YANOLJA_URL,
|
||||
"critical": False, "required": False, "unitId": unit["unitId"],
|
||||
"collectedAt": NOW, "verifiedAt": NOW,
|
||||
})
|
||||
# 옛 사진은 남기지 않는다 — 등록본이 그 객실에 붙여 놓은 사진만 그 순서로 세운다.
|
||||
unit["mediaIds"] = per_unit.get(room, [])
|
||||
|
||||
# ── index.html 조립 ──────────────────────────────────────────────────────────
|
||||
html = (SP / "orig" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
# payload 교체. </script> 를 만들지 않도록 문자열 안의 '<' 를 이스케이프한다(구운 것과 같은 규약).
|
||||
blob = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
|
||||
html, n = re.subn(r"<script>window\.__SITE_PAYLOAD__=.*?</script>",
|
||||
lambda _m: "<script>window.__SITE_PAYLOAD__=" + blob + "</script>",
|
||||
html, count=1, flags=re.S)
|
||||
assert n == 1, "payload 스크립트를 못 찾았다"
|
||||
|
||||
# ★ 이용 정보 **맨 아래 구분선**을 지운다 (2026-09-10 사장님 지시).
|
||||
# 항목이 끝난 자리에 선이 한 줄 더 그어져 있어, 빈 칸이 하나 더 있는 것처럼 보인다.
|
||||
# 항목(바비큐 이용) 자체는 그대로 둔다 — 지우라고 한 것은 선이다.
|
||||
# ★ 마크업을 고쳐 지우지 않는다 — React 가 payload 로 다시 그리면서 원래 class 를 되돌린다
|
||||
# (실측 2026-09-10: 치환은 됐는데 화면에는 선이 그대로 있었다). 지우는 자리는 주입 CSS 다.
|
||||
|
||||
css = (SP / "inject.css").read_text(encoding="utf-8")
|
||||
js = (SP / "inject.js").read_text(encoding="utf-8")
|
||||
assert "</style>" not in css and "</script>" not in js
|
||||
|
||||
block = ("\n <style id=\"w4d-css\">\n" + css + "\n </style>\n"
|
||||
" <script id=\"w4d-js\">\n" + js + "\n </script>\n ")
|
||||
# 치환문(block)에 JS 의 정규식(\s 등)이 섞여 있어 그대로 넘기면 파이썬이 이스케이프로 읽는다 — 람다로 넘긴다.
|
||||
html, n = re.subn(r"\n?\s*</body>", lambda _m: block + "</body>", html, count=1)
|
||||
assert n == 1, "</body> 를 못 찾았다"
|
||||
|
||||
out = SP / "build"
|
||||
out.mkdir(exist_ok=True)
|
||||
(out / "index.html").write_text(html, encoding="utf-8")
|
||||
|
||||
env = json.loads({s["id"]: s for s in payload["theme"]["sections"]}["itinerary"]["data"])
|
||||
print(f"index.html {len(html):,}자 · 일정 {len(env['items'])}개 · 캐치프레이즈 {len(items)}개")
|
||||
print(" " + " · ".join(f"{k} {sum(1 for i in items if i['kind'] == k)}"
|
||||
for k in ("general", "season", "month", "weather")))
|
||||
128
solution/site/scripts/mockup/people-images-meta.json
Normal file
@ -0,0 +1,128 @@
|
||||
[
|
||||
{
|
||||
"title": "고건",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/a/af/Goh_Kun_at_SDPC.JPG",
|
||||
"file": "Goh_Kun_at_SDPC.JPG",
|
||||
"license": "CC BY-SA 4.0",
|
||||
"author": "서울특별시 소방재난본부"
|
||||
},
|
||||
{
|
||||
"title": "고은",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/e/eb/Sv%C4%9Bt_knihy_2011_-_Ko_Un.jpg",
|
||||
"file": "Svět_knihy_2011_-_Ko_Un.jpg",
|
||||
"license": "CC BY-SA 3.0",
|
||||
"author": "Matěj Baťha"
|
||||
},
|
||||
{
|
||||
"title": "김관영",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/c/cf/Kim_Kwan-young_%282024%29.jpg",
|
||||
"file": "Kim_Kwan-young_(2024).jpg",
|
||||
"license": "KOGL Type 1",
|
||||
"author": "대한민국 국회 의장비서실"
|
||||
},
|
||||
{
|
||||
"title": "김선기 (언어철학자)",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/f/f6/%EA%B9%80%EC%84%A0%EA%B8%B0_%EC%84%A0%EC%83%9D.jpg",
|
||||
"file": "김선기_선생.jpg",
|
||||
"license": "Public domain",
|
||||
"author": "미상Unknown author"
|
||||
},
|
||||
{
|
||||
"title": "김수미",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/3/30/Kim_Soo-mi_at_SBS_Radio_-_230506.png",
|
||||
"file": "Kim_Soo-mi_at_SBS_Radio_-_230506.png",
|
||||
"license": "CC BY 3.0",
|
||||
"author": "Seoul Broadcasting System"
|
||||
},
|
||||
{
|
||||
"title": "박화요비",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/4/4a/Hwayobi_in_Cyworld_Dream_Music_Festival.jpg",
|
||||
"file": "Hwayobi_in_Cyworld_Dream_Music_Festival.jpg",
|
||||
"license": "CC BY-SA 2.0",
|
||||
"author": "Jinho.Jung"
|
||||
},
|
||||
{
|
||||
"title": "송새벽",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/f/f5/Songsaebyuk2015.png",
|
||||
"file": "Songsaebyuk2015.png",
|
||||
"license": "CC BY 3.0",
|
||||
"author": "틈새TV"
|
||||
},
|
||||
{
|
||||
"title": "이서원",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/9/95/Lee_Seo-won_KBS_View_Interview.png",
|
||||
"file": "Lee_Seo-won_KBS_View_Interview.png",
|
||||
"license": "CC BY 3.0",
|
||||
"author": "KBS 뷰"
|
||||
},
|
||||
{
|
||||
"title": "장신영",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/b/b7/Jang_Shin-Young_from_acrofan.jpg",
|
||||
"file": "Jang_Shin-Young_from_acrofan.jpg",
|
||||
"license": "CC BY-SA 3.0",
|
||||
"author": "acrofan.com"
|
||||
},
|
||||
{
|
||||
"title": "병헌",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/e/ee/181024_%EB%B3%91%ED%97%8C.jpg",
|
||||
"file": "181024_병헌.jpg",
|
||||
"license": "CC BY 3.0",
|
||||
"author": "뉴스인스타"
|
||||
},
|
||||
{
|
||||
"title": "오지환",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/e/e6/%EC%98%A4%EC%A7%80%ED%99%98LG%EC%A3%BC%EC%9E%A5.png",
|
||||
"file": "오지환LG주장.png",
|
||||
"license": "CC BY-SA 3.0",
|
||||
"author": "OSEN SPORTS"
|
||||
},
|
||||
{
|
||||
"title": "이진영 (1980년)",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/8/8c/130822_%EB%AC%B8%ED%95%99%EA%B5%AC%EC%9E%A5.jpg",
|
||||
"file": "130822_문학구장.jpg",
|
||||
"license": "CC BY 4.0",
|
||||
"author": "itsmygabble"
|
||||
},
|
||||
{
|
||||
"title": "정명원",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/ko/d/db/%EC%A0%95%EB%AA%85%EC%9B%90.JPG",
|
||||
"file": "정명원.JPG",
|
||||
"license": "?",
|
||||
"author": "User:Sienic"
|
||||
},
|
||||
{
|
||||
"title": "정혁 (모델)",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/5/5d/%EC%A0%95%ED%98%81_%EB%AA%A8%EB%8D%B8.jpg",
|
||||
"file": "정혁_모델.jpg",
|
||||
"license": "CC BY 3.0",
|
||||
"author": "파리게이츠"
|
||||
},
|
||||
{
|
||||
"title": "문호준",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/4/42/Moon_Ho-jun_%28%EB%AC%B8%ED%98%B8%EC%A4%80%29_from_acrofan.jpg",
|
||||
"file": "Moon_Ho-jun_(문호준)_from_acrofan.jpg",
|
||||
"license": "CC BY-SA 3.0",
|
||||
"author": "acrofan.com"
|
||||
},
|
||||
{
|
||||
"title": "차우찬",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/b/b8/Cha_Woo-chan.JPG",
|
||||
"file": "Cha_Woo-chan.JPG",
|
||||
"license": "CC BY-SA 2.5",
|
||||
"author": "원래 올린이는 한국어 위키백과의 Realidad y Illusion"
|
||||
},
|
||||
{
|
||||
"title": "최서우",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/0/0c/FIS_Ski_Jumping_World_Cup_2014_-_Engelberg_-_20141221_-_Seou_Choi.jpg",
|
||||
"file": "FIS_Ski_Jumping_World_Cup_2014_-_Engelberg_-_20141221_-_Seou_Choi.jpg",
|
||||
"license": "CC BY-SA 3.0",
|
||||
"author": "Clément Bucco-Lechat"
|
||||
},
|
||||
{
|
||||
"title": "함운경",
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/9/9b/%ED%95%A8%EC%9A%B4%EA%B2%BD_%EC%86%8C%EC%9E%A5.jpg",
|
||||
"file": "함운경_소장.jpg",
|
||||
"license": "CC BY-SA 4.0",
|
||||
"author": "Teakim11"
|
||||
}
|
||||
]
|
||||
74
solution/site/scripts/mockup/people-images.json
Normal file
@ -0,0 +1,74 @@
|
||||
{
|
||||
"고은": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/e/eb/Sv%C4%9Bt_knihy_2011_-_Ko_Un.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"김선기 (언어철학자)": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/f/f6/%EA%B9%80%EC%84%A0%EA%B8%B0_%EC%84%A0%EC%83%9D.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"김수미": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/3/30/Kim_Soo-mi_at_SBS_Radio_-_230506.png?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"병헌": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/e/ee/181024_%EB%B3%91%ED%97%8C.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"송새벽": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/f/f5/Songsaebyuk2015.png?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"이서원": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/9/95/Lee_Seo-won_KBS_View_Interview.png?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"장신영": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/b/b7/Jang_Shin-Young_from_acrofan.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"정혁 (모델)": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/5/5d/%EC%A0%95%ED%98%81_%EB%AA%A8%EB%8D%B8.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"고건": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/a/af/Goh_Kun_at_SDPC.JPG?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"김관영": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/c/cf/Kim_Kwan-young_%282024%29.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"박화요비": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/4/4a/Hwayobi_in_Cyworld_Dream_Music_Festival.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"함운경": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/9/9b/%ED%95%A8%EC%9A%B4%EA%B2%BD_%EC%86%8C%EC%9E%A5.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"문호준": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/4/42/Moon_Ho-jun_%28%EB%AC%B8%ED%98%B8%EC%A4%80%29_from_acrofan.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"오지환": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/e/e6/%EC%98%A4%EC%A7%80%ED%99%98LG%EC%A3%BC%EC%9E%A5.png?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"이진영 (1980년)": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/8/8c/130822_%EB%AC%B8%ED%95%99%EA%B5%AC%EC%9E%A5.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"정명원": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/ko/d/db/%EC%A0%95%EB%AA%85%EC%9B%90.JPG?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"차우찬": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/b/b8/Cha_Woo-chan.JPG?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
},
|
||||
"최서우": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/0/0c/FIS_Ski_Jumping_World_Cup_2014_-_Engelberg_-_20141221_-_Seou_Choi.jpg?utm_source=ko.wikipedia.org&utm_campaign=api&utm_content=original",
|
||||
"via": "문서대표"
|
||||
}
|
||||
}
|
||||
121
solution/site/scripts/mockup/people-photos.json
Normal file
@ -0,0 +1,121 @@
|
||||
[
|
||||
{
|
||||
"name": "고건",
|
||||
"url": "/s/stay/img/people/%EA%B3%A0%EA%B1%B4.jpg",
|
||||
"license": "CC BY-SA 4.0",
|
||||
"author": "서울특별시 소방재난본부",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3AGoh_Kun_at_SDPC.JPG"
|
||||
},
|
||||
{
|
||||
"name": "고은",
|
||||
"url": "/s/stay/img/people/%EA%B3%A0%EC%9D%80.jpg",
|
||||
"license": "CC BY-SA 3.0",
|
||||
"author": "Matěj Baťha",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3ASv%C4%9Bt_knihy_2011_-_Ko_Un.jpg"
|
||||
},
|
||||
{
|
||||
"name": "김관영",
|
||||
"url": "/s/stay/img/people/%EA%B9%80%EA%B4%80%EC%98%81.jpg",
|
||||
"license": "KOGL Type 1",
|
||||
"author": "대한민국 국회 의장비서실",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3AKim_Kwan-young_%282024%29.jpg"
|
||||
},
|
||||
{
|
||||
"name": "김선기",
|
||||
"url": "/s/stay/img/people/%EA%B9%80%EC%84%A0%EA%B8%B0.jpg",
|
||||
"license": "Public domain",
|
||||
"author": "미상Unknown author",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3A%EA%B9%80%EC%84%A0%EA%B8%B0_%EC%84%A0%EC%83%9D.jpg"
|
||||
},
|
||||
{
|
||||
"name": "김수미",
|
||||
"url": "/s/stay/img/people/%EA%B9%80%EC%88%98%EB%AF%B8.png",
|
||||
"license": "CC BY 3.0",
|
||||
"author": "Seoul Broadcasting System",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3AKim_Soo-mi_at_SBS_Radio_-_230506.png"
|
||||
},
|
||||
{
|
||||
"name": "박화요비",
|
||||
"url": "/s/stay/img/people/%EB%B0%95%ED%99%94%EC%9A%94%EB%B9%84.jpg",
|
||||
"license": "CC BY-SA 2.0",
|
||||
"author": "Jinho.Jung",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3AHwayobi_in_Cyworld_Dream_Music_Festival.jpg"
|
||||
},
|
||||
{
|
||||
"name": "송새벽",
|
||||
"url": "/s/stay/img/people/%EC%86%A1%EC%83%88%EB%B2%BD.png",
|
||||
"license": "CC BY 3.0",
|
||||
"author": "틈새TV",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3ASongsaebyuk2015.png"
|
||||
},
|
||||
{
|
||||
"name": "이서원",
|
||||
"url": "/s/stay/img/people/%EC%9D%B4%EC%84%9C%EC%9B%90.png",
|
||||
"license": "CC BY 3.0",
|
||||
"author": "KBS 뷰",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3ALee_Seo-won_KBS_View_Interview.png"
|
||||
},
|
||||
{
|
||||
"name": "장신영",
|
||||
"url": "/s/stay/img/people/%EC%9E%A5%EC%8B%A0%EC%98%81.jpg",
|
||||
"license": "CC BY-SA 3.0",
|
||||
"author": "acrofan.com",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3AJang_Shin-Young_from_acrofan.jpg"
|
||||
},
|
||||
{
|
||||
"name": "병헌",
|
||||
"url": "/s/stay/img/people/%EB%B3%91%ED%97%8C.jpg",
|
||||
"license": "CC BY 3.0",
|
||||
"author": "뉴스인스타",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3A181024_%EB%B3%91%ED%97%8C.jpg"
|
||||
},
|
||||
{
|
||||
"name": "오지환",
|
||||
"url": "/s/stay/img/people/%EC%98%A4%EC%A7%80%ED%99%98.png",
|
||||
"license": "CC BY-SA 3.0",
|
||||
"author": "OSEN SPORTS",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3A%EC%98%A4%EC%A7%80%ED%99%98LG%EC%A3%BC%EC%9E%A5.png"
|
||||
},
|
||||
{
|
||||
"name": "이진영",
|
||||
"url": "/s/stay/img/people/%EC%9D%B4%EC%A7%84%EC%98%81.jpg",
|
||||
"license": "CC BY 4.0",
|
||||
"author": "itsmygabble",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3A130822_%EB%AC%B8%ED%95%99%EA%B5%AC%EC%9E%A5.jpg"
|
||||
},
|
||||
{
|
||||
"name": "정혁",
|
||||
"url": "/s/stay/img/people/%EC%A0%95%ED%98%81.jpg",
|
||||
"license": "CC BY 3.0",
|
||||
"author": "파리게이츠",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3A%EC%A0%95%ED%98%81_%EB%AA%A8%EB%8D%B8.jpg"
|
||||
},
|
||||
{
|
||||
"name": "문호준",
|
||||
"url": "/s/stay/img/people/%EB%AC%B8%ED%98%B8%EC%A4%80.jpg",
|
||||
"license": "CC BY-SA 3.0",
|
||||
"author": "acrofan.com",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3AMoon_Ho-jun_%28%EB%AC%B8%ED%98%B8%EC%A4%80%29_from_acrofan.jpg"
|
||||
},
|
||||
{
|
||||
"name": "차우찬",
|
||||
"url": "/s/stay/img/people/%EC%B0%A8%EC%9A%B0%EC%B0%AC.jpg",
|
||||
"license": "CC BY-SA 2.5",
|
||||
"author": "원래 올린이는 한국어 위키백과의 Realidad y Illusion",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3ACha_Woo-chan.JPG"
|
||||
},
|
||||
{
|
||||
"name": "최서우",
|
||||
"url": "/s/stay/img/people/%EC%B5%9C%EC%84%9C%EC%9A%B0.jpg",
|
||||
"license": "CC BY-SA 3.0",
|
||||
"author": "Clément Bucco-Lechat",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3AFIS_Ski_Jumping_World_Cup_2014_-_Engelberg_-_20141221_-_Seou_Choi.jpg"
|
||||
},
|
||||
{
|
||||
"name": "함운경",
|
||||
"url": "/s/stay/img/people/%ED%95%A8%EC%9A%B4%EA%B2%BD.jpg",
|
||||
"license": "CC BY-SA 4.0",
|
||||
"author": "Teakim11",
|
||||
"file": "https://ko.wikipedia.org/wiki/%ED%8C%8C%EC%9D%BC%3A%ED%95%A8%EC%9A%B4%EA%B2%BD_%EC%86%8C%EC%9E%A5.jpg"
|
||||
}
|
||||
]
|
||||
59
solution/site/scripts/mockup/people-raw.json
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"고은": "고은(髙銀, 본명: 고은태, 본명 한자: 髙銀泰, 1933년 8월 1일~)은 대한민국의 시인이자 대학 교수로, 1952년경에서부터 어언 66년 동안 시단을 비롯한 문단에서 활약하였으며, 2018년 3월 6일에 은퇴했다. 본은 제주(濟州)이며, 어릴때에 쓴 일제 가 있고 창씨개명을 했었다. 이름은 다카바야시 도라스케(高林虎助)이고, 이후의 한때의 10년간의 불교 승려 시절의 승명(僧名)은 일초(一超)이며, 호(號)는 파옹(波翁)이다.",
|
||||
"고형곤": "고형곤(高亨坤, 1906년 4월 16일~2004년 6월 25일)은 일제강점기와 대한민국의 철학자, 정치가이다. 호는 청송(廳松)이다. 본관은 제주(濟州)이다. 고건 제30대·35대 국무총리의 아버지이다.",
|
||||
"김선기 (언어철학자)": "김선기(金善琪, 1907년 3월 21일~1992년 11월 11일)는 대한민국의 언어학자자이며 교육자이다. 본관은 광산(光山)이고 호는 무돌(무지개돌)이다.",
|
||||
"김수미": "김수미(金守美, 본명: 김영옥, 金永玉, 1949년 10월 24일~2024년 10월 25일)는 대한민국의 배우이다. 1970년 MBC 공채 텔런트로 연기 경력을 시작하였으며 1980년에 방송된 텔레비전 드라마 《전원일기》에서 일용네 역으로 인기를 누렸다. 이후에도 연기 활동과 예능 활동을 병행하여 활동하였으며 요리 솜씨로 인해 관련된 프로그램들의 진행을 맡기도 했다. ",
|
||||
"문효치": "문효치(1943년 7월 15일 ~ )는 대한민국 시인이다. 전라북도 군산에서 태어나, 동국대학교 국문학과, 고려대학교 교육대학원을 졸업. 1966년에 [한국일보], [서울신문] 신춘문예 당선으로 문단에 나왔다. 저서로는 《연기 속에 서서》, 《무령왕의 나무새》, 《남내리엽서》, 《왕인의 수염》등 10권의 시집과 《백제시집》, 《사랑이여 어디든 가서》 등 4권의 시선집을 내는 등 지금까지 꾸준한 작품집필로 한국대표 작가로 활발히 활동하고 있으며 현재 <미네르바> 주간으로 있다.",
|
||||
"박화요비": "박화요비(朴火曜飛, 본명: 박레아, 1982년 2월 11일~) 또는 화요비(火曜飛)는 대한민국의 가수이다. 음악 분야 중 R&B가 주요 활동 분야이다. 화요비는 R&B의 '비'와 이름을 지은 날인 화요일의 '화요'의 합성어이다. 본명은 '박미영'이었다가 '박레아'로 개명하였다. 박화요비의 출생일은 목요일로, 예명의 '화요'가 화요일과는 관계가 없다. 2000년 '박화요비'라는 이름으로 데뷔했으나 2006년 일본에 진출하면서 예명을 '화요비'로 사용하였고 이후 대한민국 내 예명도 '화요비'로 변경하였다. 2017년 소속사를 이전함과 동시에 초심으로 돌아가고자 예명을 다시 ‘박화요비’로 환원하였다.",
|
||||
"송새벽": "송새벽(1979년 12월 26일 ~ )은 대한민국의 배우이다.",
|
||||
"심호택": "심호택(1947년 4월 12일 ~ 2010년 1월 30일)은 대한민국의 시인이다. 민중의 삶을 시로 잘 녹여냈던 시인이다.",
|
||||
"이병훈 (시인)": "이병훈(李炳勳, 1925년 4월 15일 ~ 2009년 2월 15일)은 전라북도 옥구 출생으로 현대 시인이다. 그는 1956년 6월에 창간된 월간 순문예지인 자유문학에 〈단층(斷層)〉(59), 〈흰줄기의 길〉(59), 〈길〉(60) 등으로 추천을 받았다. 그 후 그는 시인 활동과 같이 언론계에 종사하였다. 한국문인협회, 외솔회, 한국시인협회에 소속되어 있다.",
|
||||
"이서원": "이서원(李曙原, 1997년 2월 17일 ~ )은 대한민국의 前 배우이다.",
|
||||
"이연주 (시인)": "이연주(1953년 ~ 1992년)는 대한민국의 시인이다.",
|
||||
"장신영": "장신영(張伸瑛, 1984년 1월 17일 ~ )은 대한민국의 배우이다.",
|
||||
"주하림": "주하림(朱夏林은 대한민국의 시인이다. 전라북도 군산에서 태어나 서울에서 자랐으며, 단국대학교 문예창작과를 졸업하였다. 2009년 창비가 주관하는 제9회 창비신인시인상을 수상하며 등단했다. 2013년 3월 첫 시집 《비버리힐스의 포르노 배우와 유령들》을 출간했다.",
|
||||
"채만식": "채만식(蔡萬植, 1902년 7월 21일 (음력 6월 17일) ~ 1950년 6월 11일)은 일제강점기와 대한민국의 소설가, 극작가, 문학평론가, 수필가이다. 본관은 평강(平康)이며 호는 백릉(白菱), 채옹(采翁)이다.",
|
||||
"하준 (가수)": "하준(본명: 강동기, 1965년 1월 16일 ~ )은 대한민국의 가수이다.",
|
||||
"강민지 (배우)": "강민지(1996년 5월 12일~)는 대한민국의 배우이자 모델이다. 2017년 모델 활동을 시작한 이후 드라마, 영화, 광고 등 다양한 분야에서 활동하고 있다. 과거에는 강나루라는 예명으로 활동했으며 이후 본명인 강민지로 활동명을 변경하였다.",
|
||||
"강봉균 (정치인)": "강봉균(康奉均, 1943년 8월 13일 ~ 2017년 1월 31일)은 정보통신부 장관과 재정경제부 장관, 16·17·18대 국회의원을 지낸 대한민국의 정치인이다.",
|
||||
"고건": "고건(髙建, 1938년 1월 2일~)은 대한민국의 고위정치인이다. 제30대·35대 국무총리이다. 본관은 제주(濟州)이다. 호는 우민(又民, 于民)이다.",
|
||||
"김관영": "김관영(金寬永, 1969년 11월 15일~)은 대한민국의 공인회계사, 공무원, 변호사, 정치인이다. 제19·20대 국회의원을 지냈고, 민선 8기 전북특별자치도지사(이전 명칭: 전라북도지사)이다.",
|
||||
"김혜선 (희극인)": "김혜선(1983년 11월 11일 ~ )은 대한민국의 희극 배우다.",
|
||||
"박윤경 (가수)": "박윤경(1970년 8월 28일 ~ )은 대한민국의 가수 겸 기업인이다. 현재 거주지는 경기도 가평군이다.",
|
||||
"병헌": "신성하(본명: 이병헌 , 본명 한자: 李秉憲, 1993년 11월 23일 ~ )은 대한민국의 배우이다. 틴탑으로 활동할 당시에 리드래퍼를 맡았었다. 일원으로 활동할 당시에는 엘조라는 예명을 사용했다.",
|
||||
"신영대": "신영대(申榮大, 1968년 1월 15일~)는 대한민국의 정치인이며, 제21·22대 국회의원이다.",
|
||||
"양일동": "양일동(梁一東, 1912년 12월 30일 ~ 1980년 4월 1일)은 대한민국 정치인이다. 전라북도 옥구군(現 군산시) 서수면 마룡리 출신이다. 본관은 남원이고, 호는 현곡(玄谷)이다. 제3·4·5·8·10대 국회의원을 지냈다. 1971년 신민당 주류의 유진산과 결별하고 1973년 민주통일당을 창당, 대표 최고위원과 총재를 지냈다. 1973년 중앙정보부 정치공작사건으로 기록된 김대중 납치 사건 때 참고인으로 곤욕을 치르기도 하였다.",
|
||||
"이수현 (1895년)": "이수현(李守鉉, 1895년 5월 8일 ~ 1980년 3월 8일)은 대한민국의 독립운동가이다. 본관은 전의(全義)이고 호(號)는 산남(山南)·석남(石南)·와당(瓦堂)이다.",
|
||||
"이옥주 (희극인)": "이옥주(1969년 3월 6일 ~ )는 대한민국의 희극 배우이다. 전라북도 군산시 출신으로 군산중앙여자고등학교, 개정간호전문대학을 졸업했다. 1988년에 MBC 개그콘테스트에서 대상을 수상하면서 데뷔했으며 1989년 MBC 연기대상 시상식에서 코미디 부문 여자 신인상을 수상했다. 1998년 콜로라도주 출신의 미국인 사업가인 토마스 가슬러와 결혼했으며, 이후 미국으로 이주해 뉴멕시코주에 거주 중이다.",
|
||||
"임병찬": "임병찬(林炳瓚, 1851년 2월 5일 ~ 1916년 5월 23일)은 최익현과 함께 거병했던 구한말의 의병장이다. 본관은 평택이며 최익현의 제자이며 단식 순국하였다. 아호는 돈헌(遯軒)이다.",
|
||||
"장재호 (배우)": "장재호(1986년 1월 8일 ~ )는 대한민국의 배우이다.",
|
||||
"정혁 (모델)": "정혁(1991년 9월 20일~)은 대한민국의 남자 모델 겸 방송인이다. 2015년 DDP에서 열린 S/S 서울패션위크 블라인드니스에서 정식으로 데뷔했다. 2025년 토킹바(위스키 바에서 대화)에서 아르바이트를 하고 있다고 밝혔다.",
|
||||
"채영석": "채영석(蔡映錫, 1934년 12월 7일~2006년 3월 3일)은 대한민국의 정치인이다. 제13·14·15대 국회의원을 지냈다. 전라북도 군산 출신이며, 본관은 평강이다.",
|
||||
"김광선 (권투 선수)": "김광선(金光善, 1964년 7월 16일~)은 대한민국의 권투 선수, 지도자이며, 1988년 하계 올림픽 남자 복싱 플라이급에서 금메달을 획득했다. 은퇴 후에는 권투 도장을 운영하고 있으며, 얼마 전까지는 KBS 권투 해설가를 맡고 있었다.",
|
||||
"박경완": "박경완(朴勍完, 1972년 7월 11일 ~ )은 전 KBO 리그 SK 와이번스의 포수이자, 전 KBO 리그 LG 트윈스의 배터리코치였다.",
|
||||
"박성현 (양궁 선수)": "박성현(朴成賢, 1983년 1월 1일~)은 대한민국의 전직 양궁 선수이자, 현재 전라북도청의 양궁 감독이다. 전라북도 군산 출신으로 소룡초등학교 4학년때 양궁에 입문하게 되었고, 2001년 3월 종별선수권대회에서 생애 처음 개인종합 우승을 차지하면서 두각을 나타내기 시작하였다. 그리고 그 해 2001년 국가 대표로 발탁되어, 생애 첫 태극마크를 달고 2001년 9월에 열린 제41회 세계선수권대회에 출전해서 개인전 금메달, 단체전에서는 동메달을 획득하였다. 2004년 아테네올림픽 여자 양궁 랭킹라운드에서 72발 합계 682점을 쏴, 이탈리아의 양궁 선수 나탈리아 발레바 가 보유한 세계기록(679점)에 3점을 더하면서 세계 신기록을 세웠다. 여자 양궁 단체전에서는 윤미진, 이성진과 함께 금메달을 획득하고, 개인",
|
||||
"오지환": "오지환(吳智煥, 1990년 3월 12일 ~ )은 KBO 리그 LG 트윈스의 내야수이다. 그의 사촌 동생은 KBO 리그 KIA 타이거즈의 내야수인 오정환이다.",
|
||||
"은성수 (공무원)": "은성수(殷成洙, 1961년 ~ )는 대한민국의 제7대 금융위원회 위원장을 역임한 공무원이다.",
|
||||
"이길여": "이길여(李吉女, 1932년 6월 12일 ~ )는 대한민국의 의료인이자 교육자이다. 가천대학교 길병원 설립자로 가천대학교 총장, 경인일보 회장, 가천길재단 회장을 맡고 있다. 이길여 총장의 호(號) '가천(嘉泉)'은 한국정신문화연구원장을 지낸 류승국박사가 지어줬다.",
|
||||
"이진영 (1980년)": "이진영(李晋暎, 1980년 6월 15일 ~ )은 전 KBO 리그 kt 위즈의 외야수, 내야수이자, 현 대한민국 야구 국가대표팀의 QC코치, KBO 리그 두산 베어스의 코치이다.",
|
||||
"이천웅": "이천웅(李天雄, 1988년 10월 20일~)은 전 KBO 리그 LG 트윈스의 외야수이다.",
|
||||
"정명원": "정명원(鄭明源, 1966년 6월 14일 ~ )은 전 KBO 리그 현대 유니콘스의 투수이다. 그의 남동생은 전 KBO 리그 쌍방울 레이더스의 내야수인 정학원이다.",
|
||||
"조계현": "조계현(趙啓顯, 1964년 5월 1일 ~ )은 전 KBO 리그 해태타이거즈의 투수이자, 현 협성대학교의 특임교수이다.",
|
||||
"조규제": "조규제(曺圭帝, 1967년 10월 7일 ~ )는 전 KBO 리그 KIA 타이거즈의 투수이자, 현 제주고등학교 야구부의 투수코치이다. 원래 오른손잡이였으나 야구를 시작할 무렵 왼손잡이 글러브를 사용해서 좌투우타가 됐다. 1991년에 연세대학교 체육교육학과를 졸업하고 쌍방울 레이더스의 1차 지명을 받아 입단하였다. 입단 첫 해인 1991년에는 49경기에 등판해 9승 7패, 27세이브, 1점대 평균자책점을 기록하며 신인왕을 수상했다. 이후 마무리 투수로서 꾸준한 활약을 보여주며, 팬들로부터 '완산 특급(完山特急)'이라고 불렸다. 현역 후반기에는 주로 좌완 원포인트 스페셜리스트 중간 계투로 등판했다. 현대 유니콘스의 한국시리즈 우승에 공헌했던 2003년 시즌 직후 13년 만에 36세의 나이로 국가대표팀에 선발되어 ",
|
||||
"차우찬": "차우찬(車雨燦, 1987년 5월 31일 ~ )은 전 KBO 리그 롯데 자이언츠의 투수로, 현 KBS N 스포츠 야구해설위원이다. 개그맨 도광록과 함께 유튜브 방송을 진행하는 인터넷 방송인이다. ",
|
||||
"채금석": "채금석(蔡金錫, 1904년 4월 9일 ~ 1995년 12월 26일)은 일제강점기 당시 경성 축구단 소속의 축구 선수이다. 경평축구대항전과 1936년 하계 올림픽 예선전에 출전하였으며, 은퇴 이후 프로축구, 실업, 대학, 초·중·고 등 후진 선수양성에 공헌하였다.",
|
||||
"최길선": "최길선(崔吉善, 1946년 2월 25일 ~ )은 현대중공업 대표이사 회장에 재직 중인 대한민국의 기업인이다. 본관은 탐진이며, 전라북도 군산시 출신이다. 조선업 분야의 전문 경영인이다.",
|
||||
"함운경": "함운경(咸雲炅, 1964년 3월 18일~)은 대한민국의 민주화 운동권 학생운동가 출신의 정치인이다. 전북 군산 생이기도 하고, 공화주의 아카데미 상임대표 소장을 지냈으며, 2006년 5월 31일 당시 제4회 전국 동시 지선 전북 군산시장 선거 열린우리당 후보로 입후보 및 낙마한 적이 있다.",
|
||||
"김의겸": "김의겸(金宜謙, 1963년 5월 22일~)은 대한민국의 기자 출신 정치인이다. 1990년부터 2017년까지 한겨레신문 기자로 활동했고, 문재인 정부 시절 대통령비서실 대변인을 거쳐 제21·22대 국회의원을 역임하고 있다. 1963년 5월 22일 경상북도 칠곡군에서 태어났다. 국민학교 입학 전 당시 모친이 공무원(교사)으로 전라북도 군산시로 발령받아 이주하여 군산금광국민학교 군산남중학교 군산제일고등학교를 졸업하였다. 1982년 고려대학교 법학과에 입학했다. 1985년 11월에 전국학생총연합 '민중민주정부 수립과 민족자주통일을 위한 투쟁위' 산하 파쇼헌법철폐투쟁위원회 소속으로 민주정의당 중앙정치연수원 점거농성에 참여했다가 1달 뒤 현주건조물 방화예비 혐의 등으로 구속기소됐다. 1990년 한겨레신문에 입사해 국제",
|
||||
"노상래": "노상래(한국 한자: 盧相來, 1970년 12월 15일)는 대한민국의 전 축구 선수이자 현 지도자로 현역 시절 포지션은 공격수였으며 현재 울산 HD의 유소년 디렉터와 대한축구협회 기술발전위원을 맡고 있다.",
|
||||
"노수진": "노수진(魯壽珍, 1962년 1월 10일~)은 대한민국의 전 축구 선수 및 지도자이다. 현역 시절 포지션은 공격수였다. 현재 영등포공업고등학교의 체육교사를 맡고 있다.",
|
||||
"문호준": "문호준(1997년 6월 23일~)은 대한민국의 전직 카트라이더 프로게이머로 아이디는 HoJun을 사용했다. 카트라이더 리그 통산 최다 우승자로, 카트라이더 프로게이머 최초로 개인 헌정 카트, 문블레이드 X를 가졌다.",
|
||||
"반석평": "반석평(潘碩枰, 1472년 ~ 1540년 6월 24일)은 조선의 문신이다. 본관은 광주(光州). 자는 공문(公文), 호는 송애(松厓)이다. 1507년(중종 2년) 문과에 급제하여 충청도관찰사, 공조판서, 한성부판윤, 형조판서 등을 역임하였다. 시호는 장절(壯節)이다.",
|
||||
"유동우": "유동우(柳東雨, 1968년 3월 7일~)는 대한민국의 축구 선수, 지도자로 선수 시절 수비수로 활동했다. 현재 사이버한국외국어대학교 축구부 감독을 맡고 있다.",
|
||||
"이정효": "이정효(1975년 7월 23일 ~ )는 대한민국의 축구 선수 출신 축구 감독으로 현재 수원 삼성 블루윙즈의 감독을 맡고있다. 현역 시절 풀백으로 활동했으며 프로 선수 경력을 시작한 1998년부터 2008년 은퇴할 때까지 11시즌 동안 부산 아이파크의 원클럽맨으로 활동했다. ",
|
||||
"전미라": "전미라(田美邏, 1978년 2월 6일~)는 대한민국의 전직 테니스 선수이다. 현재 JTBC 골프&스포츠의 테니스 해설위원을 맡고 있다. 본관은 담양 전씨이다.",
|
||||
"전진 (1923년)": "전진(1923년 7월 6일 ~ 2012년 4월 29일)은 대한민국의 비전향 장기수이다.",
|
||||
"정경미 (유도 선수)": "정경미(鄭敬美, 1985년 7월 26일~)는 대한민국의 유도 선수이다.",
|
||||
"최서우": "최서우(崔栖瑀, 1982년 12월 3일~)는 대한민국의 스키 점프 선수이다. 종교는 불교다. 1998년부터 선수로 활동하였으며, 1998년 동계 올림픽 경기에선 라지힐 단체전에서 373.8점으로 13위위에 올랐다. 노멀힐 개인전과 라지힐 개인전에서 53위를 거둔 바 있다. 그리고 2002년 동계 올림픽, 2006년 동계 올림픽, 2010년 동계 올림픽, 2014년 동계 올림픽, 2018년 동계 올림픽에도 출전하였다. 2011년 이름을 최용직(崔鎔直)에서 최서우로 개명하였다. 최서우 선수는 김현기 선수와 함께 스키점프의 전설로 불리며, 한국의 스키점프 1세대 선수이다. 유명한 영화인 ‘국가대표’가 이 선수들을 모티브로 만들어졌고, 이 영화를 계기로 스키점프는 한국에서 더 알려졌다. 최서우 선수는 올림픽만 6번",
|
||||
"한왕용": "한왕용(韓王龍, 1966년 7월 17일 ~ )은 히말라야 8000미터급 14좌를 세계에서 11번째, 국내에서 3번째로 완등한 대한민국 산악인이다."
|
||||
}
|
||||
401
solution/site/scripts/mockup/people-rows.json
Normal file
@ -0,0 +1,401 @@
|
||||
[
|
||||
{
|
||||
"name": "고은",
|
||||
"title": "고은",
|
||||
"years": "1933–",
|
||||
"role": null,
|
||||
"one": "고은은 대한민국의 시인이자 대학 교수로, 1952년경에서부터 어언 66년 동안 시단을 비롯한 문단에서 활약하였으며, 2018년 3월 6일에 은퇴했다."
|
||||
},
|
||||
{
|
||||
"name": "고형곤",
|
||||
"title": "고형곤",
|
||||
"years": "1906–2004",
|
||||
"role": "철학자, 정치가",
|
||||
"one": "고형곤은 일제강점기와 대한민국의 철학자, 정치가이다."
|
||||
},
|
||||
{
|
||||
"name": "김선기",
|
||||
"title": "김선기 (언어철학자)",
|
||||
"years": "1907–1992",
|
||||
"role": "언어학자자",
|
||||
"one": "김선기는 대한민국의 언어학자자이며 교육자이다."
|
||||
},
|
||||
{
|
||||
"name": "김수미",
|
||||
"title": "김수미",
|
||||
"years": "1949–2024",
|
||||
"role": "배우",
|
||||
"one": "김수미는 대한민국의 배우이다."
|
||||
},
|
||||
{
|
||||
"name": "문효치",
|
||||
"title": "문효치",
|
||||
"years": "1943–",
|
||||
"role": null,
|
||||
"one": "문효치는 대한민국 시인이다."
|
||||
},
|
||||
{
|
||||
"name": "박화요비",
|
||||
"title": "박화요비",
|
||||
"years": "1982–",
|
||||
"role": "가수",
|
||||
"one": "박화요비 또는 화요비는 대한민국의 가수이다."
|
||||
},
|
||||
{
|
||||
"name": "송새벽",
|
||||
"title": "송새벽",
|
||||
"years": "1979–",
|
||||
"role": "배우",
|
||||
"one": "송새벽은 대한민국의 배우이다."
|
||||
},
|
||||
{
|
||||
"name": "심호택",
|
||||
"title": "심호택",
|
||||
"years": "1947–2010",
|
||||
"role": "시인",
|
||||
"one": "심호택은 대한민국의 시인이다."
|
||||
},
|
||||
{
|
||||
"name": "이병훈",
|
||||
"title": "이병훈 (시인)",
|
||||
"years": "1925–2009",
|
||||
"role": "한국시인",
|
||||
"one": "이병훈은 전라북도 옥구 출생으로 현대 시인이다."
|
||||
},
|
||||
{
|
||||
"name": "이서원",
|
||||
"title": "이서원",
|
||||
"years": "1997–",
|
||||
"role": null,
|
||||
"one": "이서원은 대한민국의 前 배우이다."
|
||||
},
|
||||
{
|
||||
"name": "이연주",
|
||||
"title": "이연주 (시인)",
|
||||
"years": "1953–1992",
|
||||
"role": "시인",
|
||||
"one": "이연주는 대한민국의 시인이다."
|
||||
},
|
||||
{
|
||||
"name": "장신영",
|
||||
"title": "장신영",
|
||||
"years": "1984–",
|
||||
"role": "배우",
|
||||
"one": "장신영은 대한민국의 배우이다."
|
||||
},
|
||||
{
|
||||
"name": "주하림",
|
||||
"title": "주하림",
|
||||
"years": "2009",
|
||||
"role": "시인",
|
||||
"one": "주하림(朱夏林은 대한민국의 시인이다."
|
||||
},
|
||||
{
|
||||
"name": "채만식",
|
||||
"title": "채만식",
|
||||
"years": "1902",
|
||||
"role": "소설가, 극작가, 문학평론가, 수필가",
|
||||
"one": "채만식 ~ 1950년 6월 11일)은 일제강점기와 대한민국의 소설가, 극작가, 문학평론가, 수필가이다."
|
||||
},
|
||||
{
|
||||
"name": "하준",
|
||||
"title": "하준 (가수)",
|
||||
"years": "1965–",
|
||||
"role": "가수",
|
||||
"one": "하준은 대한민국의 가수이다."
|
||||
},
|
||||
{
|
||||
"name": "강민지",
|
||||
"title": "강민지 (배우)",
|
||||
"years": "1996–",
|
||||
"role": "배우 · 모델",
|
||||
"one": "강민지는 대한민국의 배우이자 모델이다."
|
||||
},
|
||||
{
|
||||
"name": "강봉균",
|
||||
"title": "강봉균 (정치인)",
|
||||
"years": "1943–2017",
|
||||
"role": "정치인",
|
||||
"one": "강봉균은 정보통신부 장관과 재정경제부 장관, 16·17·18대 국회의원을 지낸 대한민국의 정치인이다."
|
||||
},
|
||||
{
|
||||
"name": "고건",
|
||||
"title": "고건",
|
||||
"years": "1938–",
|
||||
"role": "고위정치인",
|
||||
"one": "고건은 대한민국의 고위정치인이다."
|
||||
},
|
||||
{
|
||||
"name": "김관영",
|
||||
"title": "김관영",
|
||||
"years": "1969–",
|
||||
"role": "공인회계사, 공무원, 변호사, 정치인",
|
||||
"one": "김관영은 대한민국의 공인회계사, 공무원, 변호사, 정치인이다."
|
||||
},
|
||||
{
|
||||
"name": "김혜선",
|
||||
"title": "김혜선 (희극인)",
|
||||
"years": "1983–",
|
||||
"role": null,
|
||||
"one": "김혜선은 대한민국의 희극 배우다."
|
||||
},
|
||||
{
|
||||
"name": "박윤경",
|
||||
"title": "박윤경 (가수)",
|
||||
"years": "1970–",
|
||||
"role": "가수 겸 기업인",
|
||||
"one": "박윤경은 대한민국의 가수 겸 기업인이다."
|
||||
},
|
||||
{
|
||||
"name": "병헌",
|
||||
"title": "병헌",
|
||||
"years": "1993–",
|
||||
"role": "배우",
|
||||
"one": "신성하은 대한민국의 배우이다."
|
||||
},
|
||||
{
|
||||
"name": "신영대",
|
||||
"title": "신영대",
|
||||
"years": "1968–",
|
||||
"role": "정치인",
|
||||
"one": "신영대는 대한민국의 정치인이며, 제21·22대 국회의원이다."
|
||||
},
|
||||
{
|
||||
"name": "양일동",
|
||||
"title": "양일동",
|
||||
"years": "1912–1980",
|
||||
"role": null,
|
||||
"one": "양일동은 대한민국 정치인이다."
|
||||
},
|
||||
{
|
||||
"name": "이수현",
|
||||
"title": "이수현 (1895년)",
|
||||
"years": "1895–1980",
|
||||
"role": "독립운동가",
|
||||
"one": "이수현은 대한민국의 독립운동가이다."
|
||||
},
|
||||
{
|
||||
"name": "이옥주",
|
||||
"title": "이옥주 (희극인)",
|
||||
"years": "1969–",
|
||||
"role": "희극 배우",
|
||||
"one": "이옥주는 대한민국의 희극 배우이다."
|
||||
},
|
||||
{
|
||||
"name": "임병찬",
|
||||
"title": "임병찬",
|
||||
"years": "1851–1916",
|
||||
"role": null,
|
||||
"one": "임병찬은 최익현과 함께 거병했던 구한말의 의병장이다."
|
||||
},
|
||||
{
|
||||
"name": "장재호",
|
||||
"title": "장재호 (배우)",
|
||||
"years": "1986–",
|
||||
"role": "배우",
|
||||
"one": "장재호는 대한민국의 배우이다."
|
||||
},
|
||||
{
|
||||
"name": "정혁",
|
||||
"title": "정혁 (모델)",
|
||||
"years": "1991–",
|
||||
"role": "남자 모델 겸 방송인",
|
||||
"one": "정혁은 대한민국의 남자 모델 겸 방송인이다."
|
||||
},
|
||||
{
|
||||
"name": "채영석",
|
||||
"title": "채영석",
|
||||
"years": "1934–2006",
|
||||
"role": "정치인",
|
||||
"one": "채영석은 대한민국의 정치인이다."
|
||||
},
|
||||
{
|
||||
"name": "김광선",
|
||||
"title": "김광선 (권투 선수)",
|
||||
"years": "1964–",
|
||||
"role": "권투 선수, 지도자",
|
||||
"one": "김광선은 대한민국의 권투 선수, 지도자이며, 1988년 하계 올림픽 남자 복싱 플라이급에서 금메달을 획득했다."
|
||||
},
|
||||
{
|
||||
"name": "박경완",
|
||||
"title": "박경완",
|
||||
"years": "1972–",
|
||||
"role": null,
|
||||
"one": "박경완은 전 KBO 리그 SK 와이번스의 포수이자, 전 KBO 리그 LG 트윈스의 배터리코치였다."
|
||||
},
|
||||
{
|
||||
"name": "박성현",
|
||||
"title": "박성현 (양궁 선수)",
|
||||
"years": "1983–",
|
||||
"role": "종별선수",
|
||||
"one": "박성현은 대한민국의 전직 양궁 선수이자, 현재 전라북도청의 양궁 감독이다."
|
||||
},
|
||||
{
|
||||
"name": "오지환",
|
||||
"title": "오지환",
|
||||
"years": "1990–",
|
||||
"role": null,
|
||||
"one": "오지환은 KBO 리그 LG 트윈스의 내야수이다."
|
||||
},
|
||||
{
|
||||
"name": "은성수",
|
||||
"title": "은성수 (공무원)",
|
||||
"years": "1961–",
|
||||
"role": null,
|
||||
"one": "은성수는 대한민국의 제7대 금융위원회 위원장을 역임한 공무원이다."
|
||||
},
|
||||
{
|
||||
"name": "이길여",
|
||||
"title": "이길여",
|
||||
"years": "1932–",
|
||||
"role": "의료인 · 교육자",
|
||||
"one": "이길여는 대한민국의 의료인이자 교육자이다."
|
||||
},
|
||||
{
|
||||
"name": "이진영",
|
||||
"title": "이진영 (1980년)",
|
||||
"years": "1980–",
|
||||
"role": null,
|
||||
"one": "이진영은 전 KBO 리그 kt 위즈의 외야수, 내야수이자, 현 대한민국 야구 국가대표팀의 QC코치, KBO 리그 두산 베어스의 코치이다."
|
||||
},
|
||||
{
|
||||
"name": "이천웅",
|
||||
"title": "이천웅",
|
||||
"years": "1988–",
|
||||
"role": null,
|
||||
"one": "이천웅은 전 KBO 리그 LG 트윈스의 외야수이다."
|
||||
},
|
||||
{
|
||||
"name": "정명원",
|
||||
"title": "정명원",
|
||||
"years": "1966–",
|
||||
"role": null,
|
||||
"one": "정명원은 전 KBO 리그 현대 유니콘스의 투수이다."
|
||||
},
|
||||
{
|
||||
"name": "조계현",
|
||||
"title": "조계현",
|
||||
"years": "1964–",
|
||||
"role": "특임교수",
|
||||
"one": "조계현은 전 KBO 리그 해태타이거즈의 투수이자, 현 협성대학교의 특임교수이다."
|
||||
},
|
||||
{
|
||||
"name": "조규제",
|
||||
"title": "조규제",
|
||||
"years": "1967–",
|
||||
"role": null,
|
||||
"one": "조규제는 전 KBO 리그 KIA 타이거즈의 투수이자, 현 제주고등학교 야구부의 투수코치이다."
|
||||
},
|
||||
{
|
||||
"name": "차우찬",
|
||||
"title": "차우찬",
|
||||
"years": "1987–",
|
||||
"role": null,
|
||||
"one": "차우찬은 전 KBO 리그 롯데 자이언츠의 투수로, 현 KBS N 스포츠 야구해설위원이다."
|
||||
},
|
||||
{
|
||||
"name": "채금석",
|
||||
"title": "채금석",
|
||||
"years": "1904–1995",
|
||||
"role": null,
|
||||
"one": "채금석은 일제강점기 당시 경성 축구단 소속의 축구 선수이다."
|
||||
},
|
||||
{
|
||||
"name": "최길선",
|
||||
"title": "최길선",
|
||||
"years": "1946–",
|
||||
"role": "기업인",
|
||||
"one": "최길선은 현대중공업 대표이사 회장에 재직 중인 대한민국의 기업인이다."
|
||||
},
|
||||
{
|
||||
"name": "함운경",
|
||||
"title": "함운경",
|
||||
"years": "1964–",
|
||||
"role": "민주화 운동권 학생운동가 출신의 정치",
|
||||
"one": "함운경은 대한민국의 민주화 운동권 학생운동가 출신의 정치인이다."
|
||||
},
|
||||
{
|
||||
"name": "김의겸",
|
||||
"title": "김의겸",
|
||||
"years": "1963–",
|
||||
"role": "기자 출신 정치인",
|
||||
"one": "김의겸은 대한민국의 기자 출신 정치인이다."
|
||||
},
|
||||
{
|
||||
"name": "노상래",
|
||||
"title": "노상래",
|
||||
"years": "1970",
|
||||
"role": "전 축구 선수 · 현 지도자",
|
||||
"one": "노상래는 대한민국의 전 축구 선수이자 현 지도자로 현역 시절 포지션은 공격수였으며 현재 울산 HD의 유소년 디렉터와 대한축구협회 기술발전위원을 맡고 있다."
|
||||
},
|
||||
{
|
||||
"name": "노수진",
|
||||
"title": "노수진",
|
||||
"years": "1962–",
|
||||
"role": "전 축구 선수 및 지도자",
|
||||
"one": "노수진은 대한민국의 전 축구 선수 및 지도자이다."
|
||||
},
|
||||
{
|
||||
"name": "문호준",
|
||||
"title": "문호준",
|
||||
"years": "1997–",
|
||||
"role": "전직 카트라이더 프로게이머",
|
||||
"one": "문호준은 대한민국의 전직 카트라이더 프로게이머로 아이디는 HoJun을 사용했다."
|
||||
},
|
||||
{
|
||||
"name": "반석평",
|
||||
"title": "반석평",
|
||||
"years": "1472–1540",
|
||||
"role": null,
|
||||
"one": "반석평은 조선의 문신이다."
|
||||
},
|
||||
{
|
||||
"name": "유동우",
|
||||
"title": "유동우",
|
||||
"years": "1968–",
|
||||
"role": "축구 선수, 지도자",
|
||||
"one": "유동우는 대한민국의 축구 선수, 지도자로 선수 시절 수비수로 활동했다."
|
||||
},
|
||||
{
|
||||
"name": "이정효",
|
||||
"title": "이정효",
|
||||
"years": "1975–",
|
||||
"role": "축구 선수 출신 축구 감독으",
|
||||
"one": "이정효는 대한민국의 축구 선수 출신 축구 감독으로 현재 수원 삼성 블루윙즈의 감독을 맡고있다."
|
||||
},
|
||||
{
|
||||
"name": "전미라",
|
||||
"title": "전미라",
|
||||
"years": "1978–",
|
||||
"role": "전직 테니스 선수",
|
||||
"one": "전미라는 대한민국의 전직 테니스 선수이다."
|
||||
},
|
||||
{
|
||||
"name": "전진",
|
||||
"title": "전진 (1923년)",
|
||||
"years": "1923–2012",
|
||||
"role": "비전향 장기수",
|
||||
"one": "전진은 대한민국의 비전향 장기수이다."
|
||||
},
|
||||
{
|
||||
"name": "정경미",
|
||||
"title": "정경미 (유도 선수)",
|
||||
"years": "1985–",
|
||||
"role": "유도 선수",
|
||||
"one": "정경미는 대한민국의 유도 선수이다."
|
||||
},
|
||||
{
|
||||
"name": "최서우",
|
||||
"title": "최서우",
|
||||
"years": "1982–",
|
||||
"role": "스키 점프 선수",
|
||||
"one": "최서우는 대한민국의 스키 점프 선수이다."
|
||||
},
|
||||
{
|
||||
"name": "한왕용",
|
||||
"title": "한왕용",
|
||||
"years": "1966–",
|
||||
"role": null,
|
||||
"one": "한왕용은 히말라야 8000미터급 14좌를 세계에서 11번째, 국내에서 3번째로 완등한 대한민국 산악인이다."
|
||||
}
|
||||
]
|
||||
36
solution/site/scripts/mockup/play.mjs
Normal file
@ -0,0 +1,36 @@
|
||||
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();
|
||||
27
solution/site/scripts/mockup/player.mjs
Normal file
@ -0,0 +1,27 @@
|
||||
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();
|
||||
22
solution/site/scripts/mockup/probe2.mjs
Normal file
@ -0,0 +1,22 @@
|
||||
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();
|
||||
18
solution/site/scripts/mockup/probe3.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
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();
|
||||
37
solution/site/scripts/mockup/rail.mjs
Normal file
@ -0,0 +1,37 @@
|
||||
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();
|
||||
24
solution/site/scripts/mockup/rails-all.mjs
Normal file
@ -0,0 +1,24 @@
|
||||
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();
|
||||
44
solution/site/scripts/mockup/rails-focus.mjs
Normal file
@ -0,0 +1,44 @@
|
||||
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();
|
||||
111
solution/site/scripts/mockup/rails-test.mjs
Normal file
@ -0,0 +1,111 @@
|
||||
import {chromium, devices} from 'playwright';
|
||||
const url = process.argv[2] || 'http://localhost/s/stay';
|
||||
const b = await chromium.launch({channel: 'chrome'});
|
||||
|
||||
const prep = async (page) => 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.__pos = (i) => { const r = window.__rails[i]; const c = r.firstElementChild; let m = 0;
|
||||
try { m = new DOMMatrix(getComputedStyle(c).transform).m41; } catch {} return Math.round(r.scrollLeft + m); };
|
||||
window.__info = (i) => { const r = window.__rails[i]; const b = r.getBoundingClientRect();
|
||||
return {label: r.getAttribute('aria-label') || '?', vis: !!r.getClientRects().length,
|
||||
cx: Math.round(b.left + b.width / 2), cy: Math.round(b.top + b.height / 2)}; };
|
||||
return window.__rails.length;
|
||||
});
|
||||
|
||||
// ── 데스크톱 ───────────────────────────────────────────────
|
||||
const page = await b.newPage({viewport: {width: 1280, height: 900}});
|
||||
await page.goto(url, {waitUntil: 'domcontentloaded'});
|
||||
await page.waitForTimeout(6000);
|
||||
const n = await prep(page);
|
||||
const rows = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
let info = await page.evaluate((k) => window.__info(k), i);
|
||||
if (!info.vis) { rows.push({레일: info.label, 판정: '숨은 탭'}); continue; }
|
||||
await page.mouse.move(5, 5);
|
||||
await page.evaluate((k) => window.__rails[k].scrollIntoView({block: 'center'}), i);
|
||||
await page.waitForTimeout(9000);
|
||||
info = await page.evaluate((k) => window.__info(k), i);
|
||||
const y = Math.min(Math.max(info.cy, 100), 800);
|
||||
|
||||
const p0 = await page.evaluate((k) => window.__pos(k), i);
|
||||
await page.waitForTimeout(10000); // ① 가만히
|
||||
const idle = await page.evaluate(([k, p]) => window.__pos(k) - p, [i, p0]);
|
||||
|
||||
await page.mouse.move(info.cx, y); // ② 레일 위 세로 휠
|
||||
await page.mouse.wheel(0, 150);
|
||||
await page.mouse.move(5, 5); // 커서는 뺀다(호버 정지는 별개)
|
||||
const p1 = await page.evaluate((k) => window.__pos(k), i);
|
||||
await page.waitForTimeout(4000);
|
||||
const during = await page.evaluate(([k, p]) => window.__pos(k) - p, [i, p1]);
|
||||
await page.waitForTimeout(9000);
|
||||
const after = await page.evaluate(([k, p]) => window.__pos(k) - p, [i, p1]);
|
||||
|
||||
const i2 = await page.evaluate((k) => window.__info(k), i); // ③ 손으로 민다
|
||||
const y2 = Math.min(Math.max(i2.cy, 100), 800);
|
||||
await page.mouse.move(i2.cx + 180, y2);
|
||||
await page.mouse.down();
|
||||
for (let s = 1; s <= 10; s++) { await page.mouse.move(i2.cx + 180 - s * 24, y2); await page.waitForTimeout(16); }
|
||||
await page.mouse.up();
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(1500);
|
||||
const dragged = await page.evaluate((k) => window.__pos(k), i);
|
||||
await page.waitForTimeout(13000);
|
||||
const frozen = await page.evaluate(([k, p]) => window.__pos(k) - p, [i, dragged]);
|
||||
|
||||
rows.push({레일: info.label, '①가만히': idle, '②휠직후': during, '②13초뒤': after, '③민뒤13초': frozen,
|
||||
판정: (idle !== 0 && during === 0 && after !== 0 && frozen === 0) ? 'OK' : '확인'});
|
||||
}
|
||||
console.log('── 데스크톱 (1280) ──'); console.table(rows);
|
||||
await page.close();
|
||||
|
||||
// ── 모바일 ───────────────────────────────────────────────
|
||||
const ctx = await b.newContext({...devices['iPhone 13'], hasTouch: true, isMobile: true});
|
||||
const mp = await ctx.newPage();
|
||||
await mp.goto(url, {waitUntil: 'domcontentloaded'});
|
||||
await mp.waitForTimeout(6000);
|
||||
const mn = await prep(mp);
|
||||
const cdp = await ctx.newCDPSession(mp);
|
||||
const mrows = [];
|
||||
for (let i = 0; i < mn; i++) {
|
||||
let info = await mp.evaluate((k) => window.__info(k), i);
|
||||
if (!info.vis) { mrows.push({레일: info.label, 판정: '숨은 탭'}); continue; }
|
||||
await mp.evaluate((k) => window.__rails[k].scrollIntoView({block: 'center'}), i);
|
||||
await mp.waitForTimeout(9000);
|
||||
info = await mp.evaluate((k) => window.__info(k), i);
|
||||
const x = info.cx, y = Math.min(Math.max(info.cy, 120), 620);
|
||||
const pt = (type, yy, xx) => cdp.send('Input.dispatchTouchEvent',
|
||||
{type, touchPoints: type === 'touchEnd' ? [] : [{x: xx ?? x, y: yy ?? y}]});
|
||||
|
||||
const p0 = await mp.evaluate((k) => window.__pos(k), i);
|
||||
await mp.waitForTimeout(10000); // ① 가만히
|
||||
const idle = await mp.evaluate(([k, p]) => window.__pos(k) - p, [i, p0]);
|
||||
|
||||
const p1 = await mp.evaluate((k) => window.__pos(k), i); // ② 손가락 대고 11초
|
||||
await pt('touchStart');
|
||||
for (let s = 0; s < 11; s++) { await mp.waitForTimeout(1000); await pt('touchMove', y - s * 2); }
|
||||
const held = await mp.evaluate(([k, p]) => window.__pos(k) - p, [i, p1]);
|
||||
await pt('touchEnd');
|
||||
await mp.waitForTimeout(11000);
|
||||
const released = await mp.evaluate(([k, p]) => window.__pos(k) - p, [i, p1]);
|
||||
|
||||
const i2 = await mp.evaluate((k) => window.__info(k), i); // ③ 가로 스와이프
|
||||
const y2 = Math.min(Math.max(i2.cy, 120), 620);
|
||||
await pt('touchStart', y2, i2.cx + 120);
|
||||
for (let s = 1; s <= 8; s++) { await pt('touchMove', y2, i2.cx + 120 - s * 22); await mp.waitForTimeout(20); }
|
||||
await pt('touchEnd');
|
||||
await mp.waitForTimeout(1500);
|
||||
const dragged = await mp.evaluate((k) => window.__pos(k), i);
|
||||
await mp.waitForTimeout(13000);
|
||||
const frozen = await mp.evaluate(([k, p]) => window.__pos(k) - p, [i, dragged]);
|
||||
|
||||
mrows.push({레일: info.label, '①가만히': idle, '②손댄채11초': held, '②뗀뒤11초': released, '③민뒤13초': frozen,
|
||||
판정: (idle !== 0 && held === 0 && released !== 0 && frozen === 0) ? 'OK' : '확인'});
|
||||
}
|
||||
console.log('── 모바일 (iPhone 13) ──'); console.table(mrows);
|
||||
await b.close();
|
||||
66
solution/site/scripts/mockup/rooms.json
Normal file
@ -0,0 +1,66 @@
|
||||
{
|
||||
"B동": {
|
||||
"subTitle": "(모던한 현대식 컨셉으로 꾸며진 따뜻한 공간)",
|
||||
"infos": [
|
||||
"기준 2명 / 최대 4명",
|
||||
"킹 침대 1개",
|
||||
"금연객실"
|
||||
],
|
||||
"photos": [
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af2f19c81.25015124.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af397e9d4.68027694.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3682927.79759196.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af312bf49.58781436.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3272203.12163270.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af33cdad5.42938273.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3809799.93524916.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af353ce48.12311777.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3afee39.79745096.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af2dbc5b2.24229660.jpg"
|
||||
]
|
||||
},
|
||||
"A동": {
|
||||
"subTitle": "일본식 가정집 느낌으로 디딤돌마당과 예쁜정원",
|
||||
"infos": [
|
||||
"기준 2명 / 최대 4명 | 52m²",
|
||||
"퀸 침대 1개",
|
||||
"금연객실"
|
||||
],
|
||||
"photos": [
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac4b11857.68455813.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac457cdf7.96020058.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac46d7ba5.10027562.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac483e6e4.98042378.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac3d39471.34452263.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac3eec2d5.01012597.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac412b973.03778247.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac4292315.62625559.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac49c67d4.10748755.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac4403cf8.84927578.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac3baabd6.33353455.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38ac4c49626.22997229.jpg"
|
||||
]
|
||||
},
|
||||
"_etc": [
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af2f19c81.25015124.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af397e9d4.68027694.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3682927.79759196.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af312bf49.58781436.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3272203.12163270.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af33cdad5.42938273.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3809799.93524916.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af353ce48.12311777.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3afee39.79745096.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af2dbc5b2.24229660.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af2f19c81.25015124.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af397e9d4.68027694.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3682927.79759196.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af312bf49.58781436.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3272203.12163270.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af33cdad5.42938273.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3809799.93524916.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af353ce48.12311777.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af3afee39.79745096.jpg",
|
||||
"https://yaimg.yanolja.com/v5/2026/03/25/07/1280/69c38af2dbc5b2.24229660.jpg"
|
||||
]
|
||||
}
|
||||
24
solution/site/scripts/mockup/seq.mjs
Normal file
@ -0,0 +1,24 @@
|
||||
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();
|
||||
20
solution/site/scripts/mockup/spin.mjs
Normal file
@ -0,0 +1,20 @@
|
||||
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
solution/site/scripts/mockup/stay-payload-new.json
Normal file
1
solution/site/scripts/mockup/stay-payload.json
Normal file
39
solution/site/scripts/mockup/touch.mjs
Normal file
@ -0,0 +1,39 @@
|
||||
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();
|
||||
37
solution/site/scripts/mockup/verify.mjs
Normal file
@ -0,0 +1,37 @@
|
||||
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();
|
||||