Merge branch 'main' into feature/scheduler
# Conflicts: # solution/backend/services/build_service.py
@ -36,6 +36,13 @@ KAKAO_REST_API_KEY=
|
|||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
|
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
|
||||||
TOUR_API_KEY=
|
TOUR_API_KEY=
|
||||||
|
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
||||||
|
# 비우면 그 단계만 건너뛴다 — 발행은 그대로 된다.
|
||||||
|
# ★ 콜백은 쓰지 않고 폴링한다(우리 서버는 Suno 가 닿을 수 있는 주소가 아니다).
|
||||||
|
# 그래도 API 가 필수로 요구하는 필드라 값을 채워 보낸다.
|
||||||
|
SUNO_API_KEY=
|
||||||
|
SUNO_CALLBACK_URL=https://example.com/api/suno/callback
|
||||||
|
|
||||||
|
|
||||||
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
||||||
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
|
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
|
||||||
|
|||||||
@ -49,6 +49,9 @@ services:
|
|||||||
SCHEDULER_ENABLED: "1"
|
SCHEDULER_ENABLED: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/out/payloads
|
- ./solution/site/payloads:/app/out/payloads
|
||||||
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
||||||
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
|
- ./solution/site/songs:/app/out/songs
|
||||||
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
|
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
|
||||||
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
|
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
|
||||||
- ./postgres-init:/app/postgres-init:ro
|
- ./postgres-init:/app/postgres-init:ro
|
||||||
@ -81,6 +84,9 @@ services:
|
|||||||
disable: true
|
disable: true
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/out/payloads
|
- ./solution/site/payloads:/app/out/payloads
|
||||||
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
||||||
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
|
- ./solution/site/songs:/app/out/songs
|
||||||
- site-out:/app/out/sites:ro
|
- site-out:/app/out/sites:ro
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
@ -119,6 +125,9 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/out/payloads
|
- ./solution/site/payloads:/app/out/payloads
|
||||||
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
||||||
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
|
- ./solution/site/songs:/app/out/songs
|
||||||
ports:
|
ports:
|
||||||
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
|
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
|
||||||
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
|
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
|
||||||
|
|||||||
@ -13,8 +13,14 @@
|
|||||||
```
|
```
|
||||||
backend (Python) ──쓴다──▶ out/payloads/<slug>.json ◀──읽는다── prerender (Node)
|
backend (Python) ──쓴다──▶ out/payloads/<slug>.json ◀──읽는다── prerender (Node)
|
||||||
out/payloads/.status/<slug>.json ──보고──▶
|
out/payloads/.status/<slug>.json ──보고──▶
|
||||||
|
──쓴다──▶ out/songs/<song_id>.mp3 ◀──복사──
|
||||||
```
|
```
|
||||||
|
|
||||||
|
★ 노래(mp3)도 **같은 약속**을 쓴다(2026-09-11). 백엔드는 발행물 디렉토리를 모른 채 파일을
|
||||||
|
`out/songs/` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다. 백엔드가 발행물
|
||||||
|
디렉토리에 직접 쓰기 시작하면 이 경계가 무너진다 — 그때부터 두 쪽이 out/ 의 모양을 함께
|
||||||
|
알아야 한다.
|
||||||
|
|
||||||
이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다.
|
이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다.
|
||||||
**둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가
|
**둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가
|
||||||
React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.
|
React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. 표 14개, 스키마는 `public` 한 벌
|
## 0. 표 15개, 스키마는 `public` 한 벌
|
||||||
|
|
||||||
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
|
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
|
||||||
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
|
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
|
||||||
@ -27,13 +27,14 @@ users 사장님 계정
|
|||||||
│ ├ place_photos 사진
|
│ ├ place_photos 사진
|
||||||
│ ├ place_facts ★ 사실. 이 제품의 심장
|
│ ├ place_facts ★ 사실. 이 제품의 심장
|
||||||
│ ├ place_faqs FAQ
|
│ ├ place_faqs FAQ
|
||||||
|
│ ├ place_songs 이 숙소의 노래 — 발행할 때마다 한 곡(가사 Gemini → 작곡 Suno)
|
||||||
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
|
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
|
||||||
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
|
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
|
||||||
└ sites 발행 사이트 — 사업장당 1개
|
└ sites 발행 사이트 — 사업장당 1개
|
||||||
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
|
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
|
||||||
├ site_versions ★ 빌드 버전 — snapshot 박제
|
├ site_versions ★ 빌드 버전 — snapshot 박제
|
||||||
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
|
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
|
||||||
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기
|
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래
|
||||||
```
|
```
|
||||||
|
|
||||||
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
|
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
|
||||||
@ -137,6 +138,22 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
|
|||||||
`source_fact_ids` 가 비면 **발행 게이트가 반려한다.** 확보된 fact 만 근거로 쓴다는 규칙이
|
`source_fact_ids` 가 비면 **발행 게이트가 반려한다.** 확보된 fact 만 근거로 쓴다는 규칙이
|
||||||
데이터 모양으로 강제된 자리다.
|
데이터 모양으로 강제된 자리다.
|
||||||
|
|
||||||
|
### `place_songs` — 이 숙소의 노래
|
||||||
|
|
||||||
|
발행할 때마다 한 곡 만든다. 가사는 소개문과 **같은 재료**(확인된 fact + 조사 근거 + 소개문)로
|
||||||
|
Gemini 가 쓰고, 곡은 Suno 가 붙인다.
|
||||||
|
|
||||||
|
★ **검증 상태(`FactStatus`)가 없다.** 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
||||||
|
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(`SongStatus`:
|
||||||
|
`GENERATING` · `READY` · `FAILED`). 스냅샷은 **`READY` 만** 싣는다.
|
||||||
|
|
||||||
|
★ **`origin_url`(Suno 가 준 주소)은 발행본에 나가지 않는다.** 만료되는 주소라 그대로 실으면
|
||||||
|
발행 직후에는 재생되고 몇 주 뒤 조용히 죽는다. mp3 를 받아 `solution/site/songs/<song_id>.mp3`
|
||||||
|
에 두고, 프리렌더가 사이트 디렉토리로 복사한 것(`/s/<slug>/<song_id>.mp3`)만 나간다.
|
||||||
|
표에는 추적용으로만 남긴다.
|
||||||
|
|
||||||
|
★ 새 곡이 실패해도 직전 곡이 그대로 남는다 — `latest_ready` 가 `READY` 중 최신 하나를 고른다.
|
||||||
|
|
||||||
### `area_contents` + `place_area_refs` — 지역 콘텐츠
|
### `area_contents` + `place_area_refs` — 지역 콘텐츠
|
||||||
|
|
||||||
★ **키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.
|
★ **키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.
|
||||||
|
|||||||
@ -5,6 +5,68 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno)
|
||||||
|
|
||||||
|
**무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라
|
||||||
|
새로 발행한 사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.
|
||||||
|
|
||||||
|
**흐름** — ★ **발행이 노래를 기다린다.**
|
||||||
|
```
|
||||||
|
발행 누름 → BUILD 잡
|
||||||
|
1. 가사(Gemini) → 2. 작곡(Suno, 실측 30~40초 · 상한 5분)
|
||||||
|
3. mp3 를 out/songs/ 에 보관
|
||||||
|
4. 스냅샷 → 게이트 → 발행 ← 여기서 비로소 사이트가 나간다
|
||||||
|
프리렌더가 mp3 를 사이트 디렉토리로 복사
|
||||||
|
```
|
||||||
|
|
||||||
|
**왜 기다리나** — 먼저 굽고 나중에 붙이는 방식으로 먼저 만들어 봤는데, 그러면 발행 직후의
|
||||||
|
사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다. 사장님이 [사이트 열기] 로 보는 **첫 화면에
|
||||||
|
그 기능이 빠져 있다.** 값은 발행이 그만큼 늦어지는 것이고, 그건 감수한다.
|
||||||
|
★ 단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이 실패하면
|
||||||
|
노래 없이 발행되고 사유가 빌드 로그와 `place_songs.last_error` 에 남는다.
|
||||||
|
★ 미리보기 빌드(publish=false)에는 만들지 않는다. 유료 호출이라 눌러 보는 것만으로 돈이 나가면 안 된다.
|
||||||
|
|
||||||
|
**왜 가사를 우리가 쓰나** — Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 짓는다.
|
||||||
|
그 가사에는 이 숙소에 없는 것(수영장·조식·오션뷰)이 섞이고 우리는 검증할 방법이 없다 —
|
||||||
|
사이트의 다른 모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이다.
|
||||||
|
→ 가사는 **소개문과 같은 재료**(확인된 fact + 조사 근거 + 소개문)로 Gemini 가 쓰고,
|
||||||
|
Suno 는 곡만 붙인다. 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다
|
||||||
|
(요금·전화번호를 노래에 넣으면 틀렸을 때 고쳐 부를 수가 없다).
|
||||||
|
★ 가사에는 `ground_check` 를 걸지 않는다. "밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는
|
||||||
|
없다 — 문장 단위로 근거를 맞추면 전부 반려된다. 가사는 사실 진술이 아니라 정서다.
|
||||||
|
|
||||||
|
**★ Suno 주소를 그대로 싣지 않는다**
|
||||||
|
Suno 가 주는 audio_url 은 **만료된다.** payload 에 그 주소를 실으면 발행 직후에는 재생되고
|
||||||
|
몇 주 뒤 조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. mp3 를 받아 보관하고
|
||||||
|
우리 경로(`/s/<slug>/<song_id>.mp3`)만 발행본에 내보낸다.
|
||||||
|
|
||||||
|
**★ 콜백이 아니라 폴링이다**
|
||||||
|
Suno 는 `callBackUrl` 로 완료를 알려 주는데, 그러려면 Suno 가 우리 백엔드에 닿아야 한다.
|
||||||
|
이 서버는 로컬(:9800)이거나 사내망이라 그런 주소가 없다 — 콜백을 믿게 만들어 두면
|
||||||
|
"요청은 성공했는데 결과가 영영 안 옴" 이 되고, 화면상 아무 일도 안 일어나는 실패다.
|
||||||
|
(API 가 필수로 요구해서 값은 채워 보내되, 그 주소를 듣지 않는다.)
|
||||||
|
|
||||||
|
**경계는 그대로다** — 백엔드는 여전히 발행물 디렉토리를 모른다. payload 와 같은 약속으로
|
||||||
|
`out/songs/<song_id>.mp3` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다.
|
||||||
|
프리렌더는 복사하면서 **지난 발행의 mp3 를 치운다** — 발행마다 새 곡이라 안 치우면 1MB 짜리가
|
||||||
|
발행 횟수만큼 쌓이고, Azure 에도 그대로 올라간다.
|
||||||
|
|
||||||
|
**화면** — 헤더의 작은 플레이어(`SongPlayer`). 곡이 없으면 **아무것도 그리지 않는다** —
|
||||||
|
노래는 발행보다 늦게 도착하므로 그 사이 빈 플레이어를 그리면 고장난 버튼이다.
|
||||||
|
자동 재생하지 않고(소리가 갑자기 나는 페이지는 닫힌다), 가사를 함께 싣는다
|
||||||
|
(오디오 안의 말은 크롤러가 못 듣는다).
|
||||||
|
|
||||||
|
**표** — `place_songs`. 검증 상태가 없다(창작물이라 "맞는가" 를 물을 대상이 아니다).
|
||||||
|
상태는 `GENERATING`·`READY`·`FAILED` 셋이고 스냅샷은 READY 만 싣는다. 새 곡이 실패하면
|
||||||
|
직전 곡이 그대로 남는다. → [DATA_MODEL.md](DATA_MODEL.md)
|
||||||
|
|
||||||
|
**검증** — 실제로 발행해 봤다(스테이,머뭄 v15): 가사 '시간이 머무는 고요한 밤'(acoustic
|
||||||
|
ballad, 154자, $0.0014) → 작곡 40초 → 1.98MB mp3 → **그 다음** 스냅샷(노래 1) → 발행 완료.
|
||||||
|
`/s/스테이머뭄-99a887f8` 200, mp3 200 `audio/mpeg`, HTML 에 제목·가사·재생 주소 확인.
|
||||||
|
지난 발행의 곡은 404 로 치워졌다. `tsc --noEmit` · `eslint` · vitest 55건 통과(신규 4건).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
|
## 2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
|
||||||
|
|
||||||
**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`,
|
**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`,
|
||||||
|
|||||||
@ -167,6 +167,27 @@ CREATE TABLE IF NOT EXISTS public.place_units (
|
|||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- 이 숙소의 노래. 발행할 때마다 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
||||||
|
-- ★ 곡은 사실이 아니라 만들어진 창작물이라 fact·사진과 같은 검증 상태를 두지 않는다.
|
||||||
|
-- 대신 완성(READY)된 것만 사이트에 나간다 — 생성 중인 곡을 실으면 재생 버튼이 빈 파일을 가리킨다.
|
||||||
|
CREATE TABLE IF NOT EXISTS public.place_songs (
|
||||||
|
song_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
place_id uuid NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
lyrics TEXT NULL, -- 화면에 같이 싣는다. 손님이 무슨 노래인지 읽을 수 있어야 한다
|
||||||
|
style VARCHAR(200) NULL, -- Suno 에 넘긴 장르·분위기 문자열
|
||||||
|
provider VARCHAR(40) NOT NULL DEFAULT 'suno',
|
||||||
|
provider_task_id VARCHAR(120) NULL, -- Suno taskId. 폴링·재조회의 유일한 열쇠
|
||||||
|
origin_url VARCHAR(1000) NULL, -- ★ Suno 가 준 원본 주소. 만료되므로 이 주소를 사이트에 싣지 않는다
|
||||||
|
file_name VARCHAR(200) NULL, -- 우리가 받아 둔 파일(solution/site/songs/<이것>). 프리렌더가 사이트로 복사한다
|
||||||
|
duration_sec NUMERIC(6,2) NULL,
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1, -- SongStatus: 1=generating 2=ready 3=failed. ★ 2 만 사이트에 나간다
|
||||||
|
last_error TEXT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.place_photos (
|
CREATE TABLE IF NOT EXISTS public.place_photos (
|
||||||
media_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
media_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
place_id uuid NOT NULL,
|
place_id uuid NOT NULL,
|
||||||
@ -382,6 +403,7 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_place_links_place_url ON public.place_chann
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_units_place ON public.place_units (place_id);
|
CREATE INDEX IF NOT EXISTS idx_units_place ON public.place_units (place_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_place ON public.place_photos (place_id);
|
CREATE INDEX IF NOT EXISTS idx_media_place ON public.place_photos (place_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_place ON public.place_songs (place_id, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_unit ON public.place_photos (unit_id);
|
CREATE INDEX IF NOT EXISTS idx_media_unit ON public.place_photos (unit_id);
|
||||||
|
|
||||||
-- place_facts
|
-- place_facts
|
||||||
|
|||||||
30
postgres-init/migrations/0010_place_songs.sql
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-- 0010 · 숙소의 노래 표(place_songs)
|
||||||
|
--
|
||||||
|
-- 발행할 때마다 이 숙소의 노래를 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno.
|
||||||
|
-- 곡은 **사실이 아니라 창작물**이라 fact·사진 같은 검증 상태(FactStatus)를 두지 않는다.
|
||||||
|
-- 대신 상태는 "만들어졌는가" 하나다: 1=generating · 2=ready · 3=failed.
|
||||||
|
-- ★ 사이트에는 2 만 나간다. 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다.
|
||||||
|
--
|
||||||
|
-- ★ origin_url 을 그대로 싣지 않는 이유: Suno 가 주는 주소는 만료된다. 받아서 우리 쪽에
|
||||||
|
-- 보관한 파일(file_name)만 사이트에 나간다 — 안 그러면 몇 주 뒤 조용히 재생이 죽는다.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.place_songs (
|
||||||
|
song_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
place_id uuid NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
lyrics TEXT NULL,
|
||||||
|
style VARCHAR(200) NULL,
|
||||||
|
provider VARCHAR(40) NOT NULL DEFAULT 'suno',
|
||||||
|
provider_task_id VARCHAR(120) NULL,
|
||||||
|
origin_url VARCHAR(1000) NULL,
|
||||||
|
file_name VARCHAR(200) NULL,
|
||||||
|
duration_sec NUMERIC(6,2) NULL,
|
||||||
|
status SMALLINT NOT NULL DEFAULT 1,
|
||||||
|
last_error TEXT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 한 업장의 최신 곡을 꺼내는 경로. 발행본은 늘 "가장 최근에 완성된 한 곡"을 싣는다.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_place ON public.place_songs (place_id, created_at DESC);
|
||||||
@ -14,6 +14,7 @@ from common.enums import (
|
|||||||
FactStatus,
|
FactStatus,
|
||||||
SourceType,
|
SourceType,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
|
SongStatus,
|
||||||
SiteStatus,
|
SiteStatus,
|
||||||
BuildStatus,
|
BuildStatus,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
@ -190,6 +191,30 @@ class place_photos(MainTableMixin, MAIN_BASE):
|
|||||||
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
|
sort_order = Column(Integer, nullable=False, server_default=text("0"), default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class place_songs(MainTableMixin, MAIN_BASE):
|
||||||
|
"""이 숙소의 노래. 발행할 때마다 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno.
|
||||||
|
|
||||||
|
★ 검증 상태(FactStatus)가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
||||||
|
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(SongStatus).
|
||||||
|
★ origin_url(Suno 가 준 주소)은 **사이트에 싣지 않는다.** 만료되는 주소라 그대로 두면
|
||||||
|
몇 주 뒤 재생만 조용히 죽는다 — 받아서 보관한 file_name 만 발행본으로 나간다."""
|
||||||
|
|
||||||
|
__tablename__ = "place_songs"
|
||||||
|
|
||||||
|
song_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
|
title = Column(String(200), nullable=False)
|
||||||
|
lyrics = Column(Text, nullable=True) # 화면에 함께 싣는다
|
||||||
|
style = Column(String(200), nullable=True) # Suno 에 넘긴 장르·분위기
|
||||||
|
provider = Column(String(40), nullable=False, server_default=text("'suno'"), default="suno")
|
||||||
|
provider_task_id = Column(String(120), nullable=True) # Suno taskId — 폴링의 유일한 열쇠
|
||||||
|
origin_url = Column(String(1000), nullable=True) # ★ 만료되는 주소. 보관용 기록일 뿐이다
|
||||||
|
file_name = Column(String(200), nullable=True) # solution/site/songs/<이것>
|
||||||
|
duration_sec = Column(Numeric(6, 2), nullable=True)
|
||||||
|
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SongStatus.GENERATING.value)
|
||||||
|
last_error = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# fact : 사실 / FAQ
|
# fact : 사실 / FAQ
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@ -283,6 +283,18 @@ class MediaStatus(CodeEnum):
|
|||||||
REJECTED = 3
|
REJECTED = 3
|
||||||
|
|
||||||
|
|
||||||
|
class SongStatus(CodeEnum):
|
||||||
|
"""place_songs.status 코드값.
|
||||||
|
|
||||||
|
★ fact·사진과 달리 검증 상태가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
||||||
|
"맞는가" 를 물을 대상이 아니다. 물을 것은 "만들어졌는가" 하나다.
|
||||||
|
★ 사이트에는 READY 만 나간다 — 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다."""
|
||||||
|
|
||||||
|
GENERATING = 1 # Suno 가 작곡 중(또는 파일을 아직 못 받았다)
|
||||||
|
READY = 2 # 파일까지 받아 뒀다 — 사이트에 나간다
|
||||||
|
FAILED = 3 # 생성 실패. 발행은 그대로 진행된다(노래만 없다)
|
||||||
|
|
||||||
|
|
||||||
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다.
|
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다.
|
||||||
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
|
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
|
||||||
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
|
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
|
||||||
@ -419,6 +431,7 @@ class JobType(CodeEnum):
|
|||||||
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
|
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
|
||||||
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
|
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
|
||||||
AI_CHECK = 6 # AI 검색 노출 점검
|
AI_CHECK = 6 # AI 검색 노출 점검
|
||||||
|
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(CodeEnum):
|
class JobStatus(CodeEnum):
|
||||||
|
|||||||
@ -131,6 +131,11 @@ class ExternalApiConfig(BaseSettings):
|
|||||||
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
|
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
|
||||||
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
|
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
|
||||||
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")
|
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")
|
||||||
|
# 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service). 비면 그 단계만 건너뛴다.
|
||||||
|
suno_api_key: str = Field("", validation_alias="SUNO_API_KEY")
|
||||||
|
# ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
|
||||||
|
# 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석).
|
||||||
|
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
|
||||||
|
|
||||||
|
|
||||||
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.
|
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.
|
||||||
|
|||||||
39
solution/backend/crud/song_crud.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
from sqlalchemy import and_, select, update
|
||||||
|
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import place_songs
|
||||||
|
from common.enums import SongStatus
|
||||||
|
from common.utils.gtime import GTime
|
||||||
|
|
||||||
|
|
||||||
|
class SongCRUD:
|
||||||
|
"""place_songs 접근. 발행본이 읽는 것은 `latest_ready` 하나뿐이다."""
|
||||||
|
|
||||||
|
async def insert(self, db, row):
|
||||||
|
return await DB_SESSION_MNG.insert(db, row)
|
||||||
|
|
||||||
|
async def update(self, db, song_id, data: dict):
|
||||||
|
return await DB_SESSION_MNG.add_with_rowcount(
|
||||||
|
db,
|
||||||
|
update(place_songs)
|
||||||
|
.where(place_songs.song_id == song_id, place_songs.deleted == False) # noqa: E712
|
||||||
|
.values(**data, updated_at=GTime.UTC()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def latest_ready(self, db, place_id):
|
||||||
|
"""이 업장의 **가장 최근에 완성된** 곡 하나.
|
||||||
|
|
||||||
|
★ READY 만 본다. 발행마다 새 곡을 만들므로 GENERATING 행이 함께 있을 수 있는데,
|
||||||
|
그걸 집으면 아직 없는 파일을 사이트가 가리킨다. 실패(FAILED)도 마찬가지다 —
|
||||||
|
새 곡이 실패하면 사이트는 **직전 곡을 그대로 유지**한다(빈 플레이어보다 낫다)."""
|
||||||
|
return await DB_SESSION_MNG.execute(
|
||||||
|
db,
|
||||||
|
select(place_songs)
|
||||||
|
.where(and_(
|
||||||
|
place_songs.place_id == place_id,
|
||||||
|
place_songs.status == SongStatus.READY.value,
|
||||||
|
place_songs.deleted == False, # noqa: E712
|
||||||
|
))
|
||||||
|
.order_by(place_songs.created_at.desc())
|
||||||
|
.limit(1),
|
||||||
|
)
|
||||||
@ -33,6 +33,9 @@ def _content_type(path: Path) -> str:
|
|||||||
".json": "application/json; charset=utf-8",
|
".json": "application/json; charset=utf-8",
|
||||||
".xml": "application/xml; charset=utf-8",
|
".xml": "application/xml; charset=utf-8",
|
||||||
".txt": "text/plain; charset=utf-8",
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
# 노래. guess_type 도 audio/mpeg 를 주지만 플랫폼마다 갈려서 못 박아 둔다 —
|
||||||
|
# octet-stream 으로 올라가면 브라우저가 재생 대신 내려받기로 처리한다.
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
}
|
}
|
||||||
return overrides.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
return overrides.get(path.suffix.lower()) or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
|
|
||||||
@ -46,6 +49,10 @@ def _cache_control(path: Path) -> str:
|
|||||||
# 60초로 두면 방문자가 수 MB 짜리 폰트를 계속 다시 받는다.
|
# 60초로 두면 방문자가 수 MB 짜리 폰트를 계속 다시 받는다.
|
||||||
if head == "fonts":
|
if head == "fonts":
|
||||||
return "public, max-age=604800"
|
return "public, max-age=604800"
|
||||||
|
# 노래 파일명은 song_id(UUID)다 — 곡이 바뀌면 이름도 바뀌므로 영구 캐시가 안전하다.
|
||||||
|
# 1MB 남짓한 파일을 60초마다 다시 받게 두면 헤더 버튼 하나가 트래픽을 먹는다.
|
||||||
|
if path.suffix.lower() == ".mp3":
|
||||||
|
return "public, max-age=31536000, immutable"
|
||||||
# HTML · robots.txt · sitemap.xml — 발행하면 곧바로 반영되어야 한다.
|
# HTML · robots.txt · sitemap.xml — 발행하면 곧바로 반영되어야 한다.
|
||||||
return "public, max-age=60, must-revalidate"
|
return "public, max-age=60, must-revalidate"
|
||||||
|
|
||||||
|
|||||||
@ -29,7 +29,7 @@ from common.logger import LOG
|
|||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from crud.site_crud import SiteCRUD
|
from crud.site_crud import SiteCRUD
|
||||||
from crud.place_crud import PlaceCRUD
|
from crud.place_crud import PlaceCRUD
|
||||||
from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail
|
from services import azure_static, indexnow, publish_gate, render_report, site_payload, site_thumbnail, song_service
|
||||||
from services.local_content_service import LocalContentService
|
from services.local_content_service import LocalContentService
|
||||||
from services.site_payload import emit_payload
|
from services.site_payload import emit_payload
|
||||||
from services.snapshot import build_snapshot
|
from services.snapshot import build_snapshot
|
||||||
@ -126,6 +126,23 @@ async def run_build(job: dict) -> dict:
|
|||||||
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
|
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
|
||||||
LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}")
|
LOG.w(f"[build] place={place_id} 주변정보 갱신 실패(직전 값 사용): {type(ex).__name__}: {ex}")
|
||||||
|
|
||||||
|
# ★ 발행이면 **노래를 먼저 만들고** 스냅샷을 뜬다 (2026-09-11 결정).
|
||||||
|
# 순서가 뒤집히면(먼저 굽고 나중에 붙이기) 발행 직후의 사이트에는 노래가 없고 몇 분 뒤
|
||||||
|
# 조용히 생긴다 — 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
|
||||||
|
# 값은 발행이 30초~3분 늦어지는 것이고(Suno 폴링 상한 5분), 그건 감수한다.
|
||||||
|
# ★ 실패해도 빌드는 계속한다. 주변 정보와 같은 규칙이다 — 곁들이 하나가 사장님 사이트
|
||||||
|
# 발행을 막을 이유가 없다. 노래 없이 나가고, 사유는 아래 로그와 place_songs 에 남는다.
|
||||||
|
# ★ 미리보기 빌드(publish=False)에는 만들지 않는다 — 유료 호출이라 눌러 보는 것만으로
|
||||||
|
# 비용이 나가면 안 된다.
|
||||||
|
song_result: dict | None = None
|
||||||
|
if want_publish:
|
||||||
|
try:
|
||||||
|
song_result = await song_service.ensure_song(place_id, owner_user_id)
|
||||||
|
LOG.i(f"[build] place={place_id} 노래 — {song_result}")
|
||||||
|
except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다
|
||||||
|
song_result = {"error": f"{type(ex).__name__}: {ex}"}
|
||||||
|
LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}")
|
||||||
|
|
||||||
# ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
|
# ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
|
||||||
# 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories).
|
# 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories).
|
||||||
# 이미 있는 기간은 부르지 않으므로 매 빌드가 유료 호출이 되지는 않는다.
|
# 이미 있는 기간은 부르지 않으므로 매 빌드가 유료 호출이 되지는 않는다.
|
||||||
@ -160,6 +177,9 @@ async def run_build(job: dict) -> dict:
|
|||||||
|
|
||||||
result = {"place_id": place_id, "site_id": str(site.site_id), "version": version_no,
|
result = {"place_id": place_id, "site_id": str(site.site_id), "version": version_no,
|
||||||
"site_version_id": str(version.site_version_id)}
|
"site_version_id": str(version.site_version_id)}
|
||||||
|
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
|
||||||
|
if song_result is not None:
|
||||||
|
result["song"] = song_result
|
||||||
now = GTime.UTC()
|
now = GTime.UTC()
|
||||||
|
|
||||||
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):
|
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):
|
||||||
|
|||||||
@ -194,3 +194,77 @@ async def generate_copy(
|
|||||||
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
|
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GeneratedSong:
|
||||||
|
"""가사 생성 결과. 곡은 여기서 만들지 않는다 — 작곡은 services/external/suno 다."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
lyrics: str
|
||||||
|
style: str
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_song(
|
||||||
|
place_name: str,
|
||||||
|
category: PlaceCategory,
|
||||||
|
*,
|
||||||
|
region: str,
|
||||||
|
grounding: list[str],
|
||||||
|
intro: str = "",
|
||||||
|
model: str = DEFAULT_TEXT_MODEL,
|
||||||
|
max_retries: int = 2,
|
||||||
|
client: Optional[httpx.AsyncClient] = None,
|
||||||
|
) -> GeneratedSong:
|
||||||
|
"""이 업소의 노래 가사를 쓴다.
|
||||||
|
|
||||||
|
★ `ground_check` 를 걸지 않는다. 가사는 사실 진술이 아니라 정서라 문장 단위로 근거를
|
||||||
|
맞추면 전부 반려된다("밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는 없다).
|
||||||
|
대신 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다(services/prompts/song 머리주석).
|
||||||
|
★ 재료가 하나도 없으면 부르지 않는다 — 소개문과 같은 규칙이다. 상호와 지역만으로 쓴 노래는
|
||||||
|
어느 숙소에 붙여도 말이 되는 노래이고, 그건 이 기능이 하려던 일이 아니다.
|
||||||
|
"""
|
||||||
|
if not is_configured():
|
||||||
|
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
|
||||||
|
if not grounding and not (intro or "").strip():
|
||||||
|
raise GeminiInvalidOutput("가사를 쓸 재료가 없다 — 확인된 fact 도 소개문도 없다")
|
||||||
|
|
||||||
|
from common.category_schema import get_schema
|
||||||
|
from services.prompts.song import RESPONSE_SCHEMA as SONG_SCHEMA, build_prompt as build_song_prompt
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"contents": [{"role": "user", "parts": [{
|
||||||
|
"text": build_song_prompt(place_name, get_schema(category).label, region, grounding, intro)
|
||||||
|
}]}],
|
||||||
|
"generationConfig": {
|
||||||
|
"responseMimeType": "application/json",
|
||||||
|
"responseSchema": SONG_SCHEMA,
|
||||||
|
# 소개문(0.2)보다 높다 — 노래는 정확해야 하는 글이 아니라 흥얼거릴 글이다.
|
||||||
|
"temperature": 0.9,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
owns_client = client is None
|
||||||
|
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
|
||||||
|
try:
|
||||||
|
payload = await call(client, model, body, max_retries)
|
||||||
|
parsed = json.loads(extract_text(payload))
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
raise GeminiInvalidOutput(f"가사 파싱 실패: {ex}") from ex
|
||||||
|
finally:
|
||||||
|
if owns_client:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
title = (parsed.get("title") or "").strip()
|
||||||
|
lyrics = (parsed.get("lyrics") or "").strip()
|
||||||
|
style = (parsed.get("style") or "").strip()
|
||||||
|
if not lyrics:
|
||||||
|
raise GeminiInvalidOutput("가사가 비어 있다")
|
||||||
|
|
||||||
|
usage = read_usage(payload)
|
||||||
|
LOG.i(
|
||||||
|
f"[gemini-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · "
|
||||||
|
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
|
||||||
|
)
|
||||||
|
# 제목이 비면 상호를 쓴다 — 빈 제목은 플레이어에서 빈 줄로 보인다.
|
||||||
|
return GeneratedSong(title=title or place_name, lyrics=lyrics, style=style or "acoustic ballad")
|
||||||
|
|||||||
158
solution/backend/services/external/suno.py
vendored
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
"""Suno API — 가사를 받아 40초짜리 곡 한 편을 만든다.
|
||||||
|
|
||||||
|
API 문서 https://docs.sunoapi.org
|
||||||
|
가사 services/external/gemini_text.generate_song (여기는 작곡만 한다)
|
||||||
|
쓰는 곳 services/song_service (잡 흐름·저장)
|
||||||
|
|
||||||
|
★ **콜백을 쓰지 않고 폴링한다.**
|
||||||
|
Suno 는 완료 시 `callBackUrl` 로 POST 를 보내 주는데, 그러려면 Suno 쪽에서 우리 백엔드에
|
||||||
|
닿아야 한다. 이 서버는 로컬(:9800)이거나 사내망(킹서버)이라 그런 주소가 없다 —
|
||||||
|
콜백을 믿게 만들어 두면 "요청은 성공했는데 결과가 영영 안 옴" 이 되고, 그건 화면상
|
||||||
|
아무 일도 안 일어나는 종류의 실패다. 그래서 `generate` 로 taskId 를 받고 `record-info` 를
|
||||||
|
직접 물어본다. 잡 워커에서 도는 코드라 몇 분 기다리는 것이 문제가 되지 않는다.
|
||||||
|
(`callBackUrl` 은 API 가 필수로 요구해서 값만 채워 보낸다. 우리는 그 주소를 듣지 않는다.)
|
||||||
|
|
||||||
|
★ **한 요청에 곡이 두 편 온다.** Suno 는 같은 가사로 변주 두 개를 만들어 준다(sunoData 배열).
|
||||||
|
우리는 **첫 번째 한 곡만** 쓴다 — 사장님에게 고르라고 묻는 화면이 없고, 두 곡을 다 실으면
|
||||||
|
손님이 무엇을 듣는지 우리도 모른다.
|
||||||
|
|
||||||
|
★ **오디오 주소는 만료된다.** 여기서 돌려주는 `audio_url` 을 그대로 사이트에 싣지 않는다.
|
||||||
|
받는 쪽(song_service)이 파일을 내려받아 우리 쪽에 보관한다.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from common.logger import LOG
|
||||||
|
from config.server_configs import external_api_config
|
||||||
|
|
||||||
|
BASE_URL = "https://api.sunoapi.org/api/v1"
|
||||||
|
|
||||||
|
# 실측(참고 프로젝트 o2o-castad-backend): 스트림 주소는 30~40초, 내려받을 수 있는 주소는 2~3분.
|
||||||
|
# 우리는 파일을 받아야 하므로 뒤쪽 기준으로 기다린다.
|
||||||
|
POLL_INTERVAL_SEC = 10
|
||||||
|
POLL_TIMEOUT_SEC = 300
|
||||||
|
|
||||||
|
REQUEST_TIMEOUT = httpx.Timeout(60.0, connect=10.0)
|
||||||
|
|
||||||
|
# 40초짜리를 만든다. 헤더의 작은 플레이어에서 듣는 곡이라 길 이유가 없고,
|
||||||
|
# 길수록 생성 시간과 요금이 같이 는다.
|
||||||
|
SONG_SECONDS = 40
|
||||||
|
MODEL = "V5"
|
||||||
|
|
||||||
|
|
||||||
|
class SunoNotConfigured(RuntimeError):
|
||||||
|
"""SUNO_API_KEY 가 없다 — 노래만 건너뛰고 발행은 계속한다."""
|
||||||
|
|
||||||
|
|
||||||
|
class SunoError(RuntimeError):
|
||||||
|
"""호출 실패·거절. 잡의 last_error 로 남는다."""
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
return bool((external_api_config.suno_api_key or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {external_api_config.suno_api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def generate(lyrics: str, *, title: str, style: str, client: httpx.AsyncClient) -> str:
|
||||||
|
"""작곡 요청. taskId 를 돌려준다.
|
||||||
|
|
||||||
|
★ `customMode=True` 다 — prompt 를 '주제' 가 아니라 **가사 그대로** 쓰라는 뜻이다.
|
||||||
|
false 로 두면 Suno 가 가사를 자기가 새로 쓴다. 우리는 이 숙소의 사실로 쓴 가사를
|
||||||
|
넘기는 것이므로, 그걸 버리면 이 기능의 의미가 없다.
|
||||||
|
"""
|
||||||
|
if not is_configured():
|
||||||
|
raise SunoNotConfigured("SUNO_API_KEY 미설정")
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": MODEL,
|
||||||
|
"customMode": True,
|
||||||
|
"instrumental": False,
|
||||||
|
# 길이는 API 파라미터가 아니라 프롬프트로 지시한다(참고 프로젝트와 같은 방식).
|
||||||
|
"prompt": f"[Song Duration: Around {SONG_SECONDS} seconds]\n{lyrics}",
|
||||||
|
"title": title[:80],
|
||||||
|
"style": style,
|
||||||
|
# 듣지 않는 주소다(머리주석). 비워서 보내면 거절당한다.
|
||||||
|
"callBackUrl": external_api_config.suno_callback_url or "https://example.com/api/suno/callback",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = await client.post(f"{BASE_URL}/generate", headers=_headers(), json=body, timeout=REQUEST_TIMEOUT)
|
||||||
|
except httpx.HTTPError as ex:
|
||||||
|
raise SunoError(f"generate 호출 실패: {type(ex).__name__}: {ex}") from ex
|
||||||
|
|
||||||
|
if res.status_code != 200:
|
||||||
|
raise SunoError(f"generate HTTP {res.status_code}: {res.text[:300]}")
|
||||||
|
|
||||||
|
data = res.json() or {}
|
||||||
|
if data.get("code") != 200:
|
||||||
|
raise SunoError(f"generate 거절: {data.get('msg')}")
|
||||||
|
|
||||||
|
task_id = ((data.get("data") or {}).get("taskId"))
|
||||||
|
if not task_id:
|
||||||
|
raise SunoError(f"generate 응답에 taskId 가 없다: {str(data)[:300]}")
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
def _first_clip(payload: dict) -> Optional[dict]:
|
||||||
|
"""완성된 클립 하나. 아직이면 None.
|
||||||
|
|
||||||
|
★ 상태 문자열을 믿기 전에 **주소가 실제로 있는지** 본다. SUCCESS 인데 audioUrl 이
|
||||||
|
아직 비어 오는 응답을 참고 프로젝트가 겪었다(스트림만 먼저 나오는 구간).
|
||||||
|
"""
|
||||||
|
data = (payload or {}).get("data") or {}
|
||||||
|
status = (data.get("status") or "").upper()
|
||||||
|
if status in {"CREATE_TASK_FAILED", "GENERATE_AUDIO_FAILED", "CALLBACK_EXCEPTION", "SENSITIVE_WORD_ERROR"}:
|
||||||
|
raise SunoError(f"작곡 실패: {status} {data.get('errorMessage') or ''}".strip())
|
||||||
|
|
||||||
|
clips = ((data.get("response") or {}).get("sunoData")) or []
|
||||||
|
for clip in clips:
|
||||||
|
if clip.get("audioUrl") or clip.get("sourceAudioUrl"):
|
||||||
|
return clip
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for_clip(task_id: str, *, client: httpx.AsyncClient) -> dict[str, Any]:
|
||||||
|
"""완성될 때까지 물어본다. 돌려주는 것은 첫 클립 하나.
|
||||||
|
|
||||||
|
★ 상한(POLL_TIMEOUT_SEC)을 둔다. Suno 가 영영 안 끝내는 경우 잡이 그대로 매달리면
|
||||||
|
워커 한 자리를 계속 차지한다 — 노래 하나 때문에 다른 사업장의 수집이 멈춘다.
|
||||||
|
"""
|
||||||
|
waited = 0
|
||||||
|
while waited < POLL_TIMEOUT_SEC:
|
||||||
|
await asyncio.sleep(POLL_INTERVAL_SEC)
|
||||||
|
waited += POLL_INTERVAL_SEC
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = await client.get(
|
||||||
|
f"{BASE_URL}/generate/record-info",
|
||||||
|
headers=_headers(),
|
||||||
|
params={"taskId": task_id},
|
||||||
|
timeout=REQUEST_TIMEOUT,
|
||||||
|
)
|
||||||
|
res.raise_for_status()
|
||||||
|
except httpx.HTTPError as ex:
|
||||||
|
# 폴링 한 번 실패는 실패가 아니다 — 다음 차례에 다시 묻는다.
|
||||||
|
LOG.w(f"[suno] 상태 조회 실패(계속 기다린다) task={task_id}: {type(ex).__name__}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
clip = _first_clip(res.json() or {})
|
||||||
|
if clip:
|
||||||
|
LOG.i(f"[suno] 작곡 완료 task={task_id} ({waited}초)")
|
||||||
|
return clip
|
||||||
|
|
||||||
|
raise SunoError(f"{POLL_TIMEOUT_SEC}초 안에 완성되지 않았다 task={task_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def download(url: str, *, client: httpx.AsyncClient) -> bytes:
|
||||||
|
"""오디오 파일을 받아 온다. 보관은 부르는 쪽이 한다."""
|
||||||
|
res = await client.get(url, timeout=httpx.Timeout(180.0, connect=10.0), follow_redirects=True)
|
||||||
|
res.raise_for_status()
|
||||||
|
return res.content
|
||||||
62
solution/backend/services/prompts/song.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""이 숙소의 노래 — 가사 프롬프트와 응답 스키마.
|
||||||
|
|
||||||
|
★ 왜 가사를 **우리가** 쓰고 Suno 에는 작곡만 시키나
|
||||||
|
Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 지어낸다. 그 가사에는 이 숙소에
|
||||||
|
없는 것(수영장·조식·오션뷰)이 섞이고, 우리는 그걸 검증할 방법이 없다 — 사이트의 다른
|
||||||
|
모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이 된다.
|
||||||
|
그래서 가사는 소개문과 **같은 재료**(확인된 fact + 소개문)로 여기서 쓰고, Suno 는 그 가사에
|
||||||
|
곡을 붙이기만 한다.
|
||||||
|
|
||||||
|
★ 그래도 가사는 사실 진술이 아니다
|
||||||
|
"밤이 깊어도 불이 켜져 있다" 같은 줄은 fact 가 아니라 분위기다. 그래서 `ground_check` 를
|
||||||
|
걸지 않는다 — 대신 프롬프트가 **없는 시설·없는 숫자를 말하지 말라**고 못 박는다.
|
||||||
|
요금·전화번호·주소를 가사에 넣지 않는 것도 같은 이유다(틀리면 예약 클레임이고, 노래는
|
||||||
|
고쳐 부르기도 어렵다).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Suno 가 받는 style 문자열. 장르를 모델이 고르게 두되 후보를 좁힌다 —
|
||||||
|
# 열어 두면 숙소 사이트에 어울리지 않는 것(하드록·트랩)이 나온다.
|
||||||
|
STYLE_CHOICES = [
|
||||||
|
"acoustic ballad", "city pop", "folk pop", "lo-fi", "bossa nova", "soft rock", "jazz",
|
||||||
|
]
|
||||||
|
|
||||||
|
RESPONSE_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "곡 제목. 상호를 그대로 쓰지 말고 한 구절로."},
|
||||||
|
"lyrics": {"type": "string", "description": "가사. [Verse]/[Chorus] 구조 태그를 포함한다."},
|
||||||
|
"style": {"type": "string", "description": f"장르·분위기. 다음 중 하나에서 시작한다: {', '.join(STYLE_CHOICES)}"},
|
||||||
|
},
|
||||||
|
"required": ["title", "lyrics", "style"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_prompt(place_name: str, category_label: str, region: str, grounding: list[str], intro: str) -> str:
|
||||||
|
"""가사 프롬프트. 재료는 소개문 생성과 같은 것을 받는다."""
|
||||||
|
material = "\n".join(f"- {line}" for line in grounding) or "- (확인된 항목 없음)"
|
||||||
|
intro_block = f"\n[이 숙소 소개문]\n{intro.strip()}\n" if (intro or "").strip() else ""
|
||||||
|
|
||||||
|
return f"""너는 작은 가게의 노래를 쓰는 작사가다. 아래 업소의 노래 가사를 쓴다.
|
||||||
|
|
||||||
|
[업소]
|
||||||
|
- 상호: {place_name}
|
||||||
|
- 업종: {category_label}
|
||||||
|
- 지역: {region or "(모름)"}
|
||||||
|
|
||||||
|
[확인된 항목 — 이 안에서만 말한다]
|
||||||
|
{material}
|
||||||
|
{intro_block}
|
||||||
|
[쓰는 법]
|
||||||
|
- 한국어. 40초 안에 불리는 길이다 — [Verse] 한 덩이 + [Chorus] 한 덩이면 충분하다.
|
||||||
|
- 구조 태그([Verse], [Chorus])를 반드시 넣는다. Suno 가 이 태그로 곡을 나눈다.
|
||||||
|
- **없는 것을 말하지 않는다.** 위 목록에 없는 시설·풍경·서비스를 지어내지 않는다.
|
||||||
|
지역과 계절, 머무는 마음처럼 사실 확인이 필요 없는 정서는 자유롭게 쓴다.
|
||||||
|
- **숫자를 넣지 않는다.** 요금·전화번호·주소·객실 수는 가사에 쓰지 않는다.
|
||||||
|
틀리면 손님이 손해를 보고, 노래는 고쳐 부르기 어렵다.
|
||||||
|
- 광고 문구처럼 쓰지 않는다("최고", "1등", "예약하세요"). 손님이 흥얼거릴 노래다.
|
||||||
|
- 상호는 후렴에 한 번쯤 자연스럽게 넣는다.
|
||||||
|
|
||||||
|
[출력]
|
||||||
|
title(곡 제목) · lyrics(가사) · style(장르·분위기) 를 JSON 으로 준다.
|
||||||
|
style 은 이 업소의 분위기에 맞는 것을 고른다: {', '.join(STYLE_CHOICES)}.
|
||||||
|
"""
|
||||||
@ -954,6 +954,25 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
|
|||||||
# ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석).
|
# ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석).
|
||||||
# 렌더러는 비면 해당 섹션을 그리지 않는다.
|
# 렌더러는 비면 해당 섹션을 그리지 않는다.
|
||||||
"routes": [],
|
"routes": [],
|
||||||
|
# ★ 이 숙소의 노래. `audioUrl` 은 **우리 쪽 경로**다 — Suno 가 준 주소는 만료되므로
|
||||||
|
# 파일을 받아 두고(song_service) 프리렌더가 사이트 디렉토리로 복사한 것을 가리킨다.
|
||||||
|
# 경로를 여기서 만드는 이유: 발행본의 주소 규칙(basePath + /s/<slug>)을 아는 곳이 여기다.
|
||||||
|
"songs": [
|
||||||
|
{
|
||||||
|
"songId": row["song_id"],
|
||||||
|
"title": row["title"],
|
||||||
|
"lyrics": row.get("lyrics") or None,
|
||||||
|
"style": row.get("style") or None,
|
||||||
|
"durationSec": row.get("duration_sec"),
|
||||||
|
# 프리렌더가 복사해 놓을 자리. 파일명은 그대로 쓴다.
|
||||||
|
# basePath 자체가 이미 `/s/<slug>`(또는 서브패스 마운트라면 그 앞에 접두어)다.
|
||||||
|
"audioUrl": f"{target['basePath']}/{row['file_name']}",
|
||||||
|
# 프리렌더가 원본을 찾을 때 쓰는 이름(솔루션 밖으로는 안 나간다).
|
||||||
|
"fileName": row["file_name"],
|
||||||
|
}
|
||||||
|
for row in (snapshot.get("songs") or [])
|
||||||
|
if (row.get("file_name") or "").strip()
|
||||||
|
],
|
||||||
"narrative": narrative,
|
"narrative": narrative,
|
||||||
"theme": theme,
|
"theme": theme,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -560,6 +560,11 @@ class SiteService:
|
|||||||
if job_id is None:
|
if job_id is None:
|
||||||
res.result.SetResult(ErrorType.COLLECT_ALREADY_RUNNING)
|
res.result.SetResult(ErrorType.COLLECT_ALREADY_RUNNING)
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
# ★ 노래는 여기서 따로 걸지 않는다. BUILD 잡이 스냅샷을 뜨기 **전에** 직접 만든다
|
||||||
|
# (`build_service.run_build` → `song_service.ensure_song`) — 발행이 노래를 기다린다.
|
||||||
|
# 따로 걸면 먼저 구워지고 노래가 몇 분 뒤 붙는데, 그러면 사장님이 [사이트 열기] 로
|
||||||
|
# 보는 첫 화면에 그 기능이 빠져 있다(2026-09-11 결정).
|
||||||
res.job_id = uuid.UUID(job_id)
|
res.job_id = uuid.UUID(job_id)
|
||||||
res.status = JobStatus.PENDING
|
res.status = JobStatus.PENDING
|
||||||
res.created = created
|
res.created = created
|
||||||
|
|||||||
@ -23,7 +23,7 @@ from sqlalchemy import or_, select
|
|||||||
from common.category_schema import get_schema
|
from common.category_schema import get_schema
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import (
|
from common.database.model.models import (
|
||||||
place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_units,
|
place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_songs, place_units,
|
||||||
site_sections, sites,
|
site_sections, sites,
|
||||||
)
|
)
|
||||||
from common.enums import (
|
from common.enums import (
|
||||||
@ -35,6 +35,7 @@ from common.enums import (
|
|||||||
LocalSource,
|
LocalSource,
|
||||||
MediaStatus,
|
MediaStatus,
|
||||||
PlaceCategory,
|
PlaceCategory,
|
||||||
|
SongStatus,
|
||||||
)
|
)
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from services.external.naver import region_key
|
from services.external.naver import region_key
|
||||||
@ -89,6 +90,16 @@ async def build_snapshot(place) -> dict:
|
|||||||
).order_by(place_photos.sort_order.asc())
|
).order_by(place_photos.sort_order.asc())
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ★ 완성(READY)된 최신 곡 하나. 발행마다 새 곡을 만들므로 생성 중인 행이 함께 있을 수 있는데,
|
||||||
|
# 그걸 실으면 사이트가 아직 없는 파일을 가리킨다. 새 곡이 실패하면 직전 곡이 그대로 남는다.
|
||||||
|
song_rows = await _select(
|
||||||
|
select(place_songs).where(
|
||||||
|
place_songs.place_id == pid,
|
||||||
|
place_songs.deleted == False, # noqa: E712
|
||||||
|
place_songs.status == SongStatus.READY.value,
|
||||||
|
).order_by(place_songs.created_at.desc()).limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
local_rows = await _local_contents(place)
|
local_rows = await _local_contents(place)
|
||||||
|
|
||||||
snapshot = {
|
snapshot = {
|
||||||
@ -161,11 +172,26 @@ async def build_snapshot(place) -> dict:
|
|||||||
# ★ 원문(body)을 거의 그대로 싣는다. 렌더러 타입으로의 변환은 site_payload 가 한다 —
|
# ★ 원문(body)을 거의 그대로 싣는다. 렌더러 타입으로의 변환은 site_payload 가 한다 —
|
||||||
# fact·사진과 같은 분업이다(여기는 '무엇이 나갈 수 있는가', 거기는 '어떤 모양으로 나가는가').
|
# fact·사진과 같은 분업이다(여기는 '무엇이 나갈 수 있는가', 거기는 '어떤 모양으로 나가는가').
|
||||||
"local": local_rows,
|
"local": local_rows,
|
||||||
|
# ★ 이 숙소의 노래. 파일은 DB 가 아니라 `solution/site/songs/<file_name>` 에 있고,
|
||||||
|
# 프리렌더가 그걸 사이트 디렉토리로 복사한다(services/song_service 머리주석).
|
||||||
|
# ★ origin_url(Suno 주소)은 싣지 않는다 — 만료되는 주소라 발행본에 나가면 안 된다.
|
||||||
|
"songs": [
|
||||||
|
{
|
||||||
|
"song_id": str(r.song_id),
|
||||||
|
"title": r.title,
|
||||||
|
"lyrics": r.lyrics,
|
||||||
|
"style": r.style,
|
||||||
|
"file_name": r.file_name,
|
||||||
|
"duration_sec": float(r.duration_sec) if r.duration_sec is not None else None,
|
||||||
|
}
|
||||||
|
for r in song_rows
|
||||||
|
if (r.file_name or "").strip()
|
||||||
|
],
|
||||||
}
|
}
|
||||||
LOG.i(
|
LOG.i(
|
||||||
f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · "
|
f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · "
|
||||||
f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · "
|
f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · "
|
||||||
f"지역 {len(snapshot['local']['contents'])}"
|
f"지역 {len(snapshot['local']['contents'])} · 노래 {len(snapshot['songs'])}"
|
||||||
)
|
)
|
||||||
return snapshot
|
return snapshot
|
||||||
|
|
||||||
|
|||||||
258
solution/backend/services/song_service.py
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
"""이 숙소의 노래 — SONG 잡이 하는 일.
|
||||||
|
|
||||||
|
가사 services/external/gemini_text.generate_song (확인된 fact + 소개문으로 쓴다)
|
||||||
|
작곡 services/external/suno (폴링 · 40초 · 한 곡)
|
||||||
|
여기 재료 모으기 → 가사 → 작곡 → 파일 보관 (발행이 이걸 기다린다)
|
||||||
|
|
||||||
|
★ **발행은 노래를 기다린다** (2026-09-11 결정).
|
||||||
|
BUILD 잡이 스냅샷을 뜨기 **전에** 여기를 부른다(`build_service.run_build`). 그래서 발행된
|
||||||
|
사이트에는 처음부터 노래가 들어 있다 — 사장님이 [사이트 열기] 를 눌러 본 화면과 손님이
|
||||||
|
보는 화면이 같다.
|
||||||
|
값은 발행이 그만큼 늦어지는 것이다(실측 30초~3분, 상한 5분). 먼저 굽고 나중에 붙이는
|
||||||
|
방식도 만들어 봤지만, 그러면 발행 직후의 사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다 —
|
||||||
|
"발행했는데 그 기능이 없다" 를 사장님이 먼저 본다.
|
||||||
|
|
||||||
|
★ 잡 타입(JobType.SONG)은 그대로 둔다.
|
||||||
|
발행과 무관하게 **곡만 다시 만들** 때 쓰는 길이다(운영자가 잡을 직접 넣는다).
|
||||||
|
발행 경로와 같은 함수(`ensure_song`)를 부르므로 둘이 갈라지지 않는다.
|
||||||
|
|
||||||
|
★ 왜 파일을 받아서 보관하나
|
||||||
|
Suno 가 주는 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후에는 재생되고 몇 주 뒤
|
||||||
|
조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. 그래서 mp3 를 받아
|
||||||
|
`solution/site/songs/` 에 두고, 프리렌더가 사이트 디렉토리로 복사한다.
|
||||||
|
(백엔드는 여전히 HTML 을 만들지 않는다 — 파일과 payload 를 같은 약속된 자리에 둘 뿐이다.)
|
||||||
|
|
||||||
|
★ Blob Storage 로는 **발행이 올린다** — 여기서 따로 올리지 않는다.
|
||||||
|
파일이 `out/s/<slug>/` 안에 있으므로 `azure_static.publish(slug)` 가 사이트를 통째로
|
||||||
|
올릴 때 함께 올라간다(content-type `audio/mpeg`, 해시 파일명이라 immutable 캐시).
|
||||||
|
지난 곡은 `_remove_stale_site_files` 가 블롭에서도 지운다.
|
||||||
|
→ 업로더를 하나 더 두면 같은 컨테이너에 **두 규칙**이 생긴다(경로·캐시·정리 주체).
|
||||||
|
참고 프로젝트(o2o-castad-backend)는 사이트가 없어 직접 올릴 수밖에 없었지만,
|
||||||
|
여기서는 노래가 사이트의 일부라 사이트와 같은 길로 나가는 것이 맞다.
|
||||||
|
덕분에 주소도 같은 오리진(`/s/<slug>/<song_id>.mp3`)이라 CORS·혼합콘텐츠 문제가 없다.
|
||||||
|
|
||||||
|
★ 실패는 발행을 막지 않는다
|
||||||
|
키가 없거나(SUNO_API_KEY·GEMINI_API_KEY), 재료가 없거나, Suno 가 늦으면(상한 5분)
|
||||||
|
**노래만 없이** 발행된다. 기다리는 것과 막는 것은 다르다 — 음악 API 가 느린 날
|
||||||
|
사장님 사이트가 아예 안 나가는 것은 맞바꿀 수 없는 손해다.
|
||||||
|
사유는 place_songs.last_error 와 빌드 로그에 남는다.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from common.category_schema import CategorySchemaError, get_schema
|
||||||
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
|
from common.database.model.models import place_channels, place_facts, place_songs, places
|
||||||
|
from common.enums import DBWRType, ErrorType, PlaceCategory, SongStatus
|
||||||
|
from common.logger import LOG
|
||||||
|
from common.utils.gtime import GTime
|
||||||
|
from crud.fact_crud import FactCRUD
|
||||||
|
from crud.place_crud import PlaceCRUD
|
||||||
|
from crud.song_crud import SongCRUD
|
||||||
|
from services import place_research, site_payload
|
||||||
|
from services.external import gemini_text, suno
|
||||||
|
from services.llm.gemini import GeminiError, GeminiInvalidOutput, GeminiNotConfigured
|
||||||
|
|
||||||
|
_fact_crud = FactCRUD()
|
||||||
|
_place_crud = PlaceCRUD()
|
||||||
|
_song_crud = SongCRUD()
|
||||||
|
|
||||||
|
# payload 와 나란히 두는 자리. 프리렌더가 이 디렉토리에서 파일을 찾는다.
|
||||||
|
# payload_dir 이 `/app/out/payloads` 면 여기는 `/app/out/songs` 다 — 컴포즈가 둘 다 호스트의
|
||||||
|
# `solution/site/` 아래로 붙인다. 한 디렉토리 약속(ARCHITECTURE 1절)을 노래에도 그대로 쓴다.
|
||||||
|
SONGS_DIRNAME = "songs"
|
||||||
|
|
||||||
|
|
||||||
|
class SongAborted(RuntimeError):
|
||||||
|
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다."""
|
||||||
|
|
||||||
|
|
||||||
|
def songs_dir() -> Path:
|
||||||
|
return site_payload.payload_dir().parent / SONGS_DIRNAME
|
||||||
|
|
||||||
|
|
||||||
|
async def _grounding(place, place_id: str) -> tuple[list[str], str]:
|
||||||
|
"""가사 재료 — 확인된 fact + 수집/조사 원문, 그리고 소개문.
|
||||||
|
|
||||||
|
★ 소개문 생성과 **같은 재료**를 쓴다(copy_service). 노래만 다른 출처를 보면 사이트의
|
||||||
|
글과 노래가 서로 다른 숙소를 말하게 된다.
|
||||||
|
"""
|
||||||
|
schema = get_schema(PlaceCategory(place.category))
|
||||||
|
pid = uuid.UUID(place_id)
|
||||||
|
|
||||||
|
f_err, fact_rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
place_facts.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: _fact_crud.list_facts(s, pid, None, None, True, True),
|
||||||
|
)
|
||||||
|
if f_err != ErrorType.SUCCESS:
|
||||||
|
raise SongAborted(f"fact 조회 실패: {f_err.name}")
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
intro = ""
|
||||||
|
for row in (fact_rows or []):
|
||||||
|
value = (row.value or "").strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
if row.key == "intro":
|
||||||
|
intro = value
|
||||||
|
continue
|
||||||
|
if row.key == "meta_description":
|
||||||
|
continue
|
||||||
|
spec = schema.get(row.key)
|
||||||
|
label = spec.label if spec else row.key
|
||||||
|
lines.append(f"{label}: {value}{(' ' + row.unit) if row.unit else ''}")
|
||||||
|
|
||||||
|
# 조사 근거(남이 쓴 글)도 재료다 — 이 숙소의 내력이 거기에만 있는 경우가 많다.
|
||||||
|
l_err, link_rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
place_channels.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: _place_crud.list_links(s, pid, False),
|
||||||
|
)
|
||||||
|
if l_err == ErrorType.SUCCESS:
|
||||||
|
for link in (link_rows or []):
|
||||||
|
raw = link.raw if isinstance(link.raw, dict) else {}
|
||||||
|
if link.confirmed_at is None and raw.get("kind") != place_research.RAW_KIND:
|
||||||
|
continue
|
||||||
|
text = (raw.get("text") or "").strip()
|
||||||
|
if text:
|
||||||
|
lines.append(text[:1500])
|
||||||
|
|
||||||
|
return lines, intro
|
||||||
|
|
||||||
|
|
||||||
|
async def run_song(job: dict) -> dict:
|
||||||
|
"""SONG 잡 핸들러 — 발행과 무관하게 곡만 다시 만들 때 쓴다.
|
||||||
|
|
||||||
|
발행 경로는 이 잡을 거치지 않고 `ensure_song` 을 직접 부른다(build_service)."""
|
||||||
|
payload = job["payload"]
|
||||||
|
return await ensure_song(payload["place_id"], payload["owner_user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_song(place_id: str, owner_user_id: str) -> dict:
|
||||||
|
"""이 업장의 노래를 한 곡 만든다. 돌려주는 dict 가 곧 잡 결과이자 빌드 로그다.
|
||||||
|
|
||||||
|
★ 실패를 예외로 올리지 않는다(사업장을 못 찾는 것 같은 진짜 고장만 예외다).
|
||||||
|
발행이 이 함수를 기다리는데 여기서 예외가 나면 **노래 때문에 발행이 통째로 실패**한다.
|
||||||
|
음악 API 가 느린 날 사장님 사이트가 안 나가는 것은 맞바꿀 수 없는 손해다."""
|
||||||
|
if not suno.is_configured():
|
||||||
|
# 키가 없는 것은 고장이 아니라 설정이다 — 예외로 올려 재시도·DEAD 로 만들지 않는다.
|
||||||
|
LOG.i(f"[song] place={place_id} 건너뜀 — SUNO_API_KEY 미설정")
|
||||||
|
return {"place_id": place_id, "skipped": "SUNO_API_KEY 미설정"}
|
||||||
|
|
||||||
|
err, place = await DB_SESSION_MNG.execute_lambda(
|
||||||
|
places.DBType(),
|
||||||
|
DBWRType.DB_READ.value,
|
||||||
|
lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
|
||||||
|
)
|
||||||
|
if err != ErrorType.SUCCESS or place is None:
|
||||||
|
raise SongAborted(f"사업장을 찾을 수 없다: {place_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
get_schema(PlaceCategory(place.category))
|
||||||
|
except (CategorySchemaError, ValueError) as ex:
|
||||||
|
raise SongAborted(f"지원하지 않는 업종: {place.category}") from ex
|
||||||
|
|
||||||
|
lines, intro = await _grounding(place, place_id)
|
||||||
|
region = site_payload.region_label(place.road_address, place.address) or ""
|
||||||
|
|
||||||
|
# ── 1. 가사 ───────────────────────────────────────────────
|
||||||
|
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0)) as client:
|
||||||
|
try:
|
||||||
|
song = await gemini_text.generate_song(
|
||||||
|
place.name, PlaceCategory(place.category),
|
||||||
|
region=region, grounding=lines, intro=intro, client=client,
|
||||||
|
)
|
||||||
|
except GeminiNotConfigured:
|
||||||
|
LOG.i(f"[song] place={place_id} 건너뜀 — GEMINI_API_KEY 미설정")
|
||||||
|
return {"place_id": place_id, "skipped": "GEMINI_API_KEY 미설정"}
|
||||||
|
except GeminiInvalidOutput as ex:
|
||||||
|
LOG.i(f"[song] place={place_id} 건너뜀 — {ex}")
|
||||||
|
return {"place_id": place_id, "skipped": str(ex)}
|
||||||
|
except GeminiError as ex:
|
||||||
|
raise SongAborted(f"가사 생성 실패: {ex}") from ex
|
||||||
|
|
||||||
|
# 가사를 먼저 적재한다 — 작곡이 실패해도 "무엇을 만들려 했는가" 가 남아야 한다.
|
||||||
|
row = place_songs(
|
||||||
|
song_id=uuid.uuid4(),
|
||||||
|
place_id=uuid.UUID(place_id),
|
||||||
|
title=song.title[:200],
|
||||||
|
lyrics=song.lyrics,
|
||||||
|
style=song.style[:200],
|
||||||
|
status=SongStatus.GENERATING.value,
|
||||||
|
)
|
||||||
|
add_err = await DB_SESSION_MNG.execute_lambda_run(
|
||||||
|
[place_songs.DBType()], [lambda s: _song_crud.insert(s, row)],
|
||||||
|
)
|
||||||
|
if add_err != ErrorType.SUCCESS:
|
||||||
|
raise SongAborted(f"노래 행 생성 실패: {add_err.name}")
|
||||||
|
|
||||||
|
async def _fail(reason: str) -> dict:
|
||||||
|
await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
|
place_songs.DBType(),
|
||||||
|
lambda s: _song_crud.update(s, row.song_id, {
|
||||||
|
"status": SongStatus.FAILED.value, "last_error": reason[:2000],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
LOG.w(f"[song] place={place_id} 실패: {reason}")
|
||||||
|
return {"place_id": place_id, "song_id": str(row.song_id), "error": reason}
|
||||||
|
|
||||||
|
# ── 2. 작곡 ───────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
task_id = await suno.generate(song.lyrics, title=song.title, style=song.style, client=client)
|
||||||
|
except suno.SunoNotConfigured as ex:
|
||||||
|
return await _fail(str(ex))
|
||||||
|
except suno.SunoError as ex:
|
||||||
|
return await _fail(f"작곡 요청 실패: {ex}")
|
||||||
|
|
||||||
|
await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
|
place_songs.DBType(),
|
||||||
|
lambda s: _song_crud.update(s, row.song_id, {"provider_task_id": task_id}),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
clip = await suno.wait_for_clip(task_id, client=client)
|
||||||
|
except suno.SunoError as ex:
|
||||||
|
return await _fail(str(ex))
|
||||||
|
|
||||||
|
audio_url = clip.get("audioUrl") or clip.get("sourceAudioUrl") or ""
|
||||||
|
try:
|
||||||
|
content = await suno.download(audio_url, client=client)
|
||||||
|
except httpx.HTTPError as ex:
|
||||||
|
return await _fail(f"오디오 내려받기 실패: {type(ex).__name__}: {ex}")
|
||||||
|
|
||||||
|
# ── 3. 보관 ───────────────────────────────────────────────
|
||||||
|
# 파일명은 song_id 다 — 발행마다 새 곡이 생기므로 이름이 겹치면 옛 곡이 새 곡으로 바뀐다.
|
||||||
|
file_name = f"{row.song_id}.mp3"
|
||||||
|
directory = songs_dir()
|
||||||
|
try:
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = directory / f".{file_name}.tmp"
|
||||||
|
tmp.write_bytes(content)
|
||||||
|
# payload 와 같은 이유로 rename 이다 — 프리렌더가 반쯤 쓰인 파일을 집으면 안 된다.
|
||||||
|
tmp.replace(directory / file_name)
|
||||||
|
except OSError as ex:
|
||||||
|
return await _fail(f"파일 저장 실패: {ex}")
|
||||||
|
|
||||||
|
duration = clip.get("duration")
|
||||||
|
await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
|
place_songs.DBType(),
|
||||||
|
lambda s: _song_crud.update(s, row.song_id, {
|
||||||
|
"status": SongStatus.READY.value,
|
||||||
|
"file_name": file_name,
|
||||||
|
"origin_url": audio_url[:1000] or None,
|
||||||
|
"duration_sec": round(float(duration), 2) if isinstance(duration, (int, float)) else None,
|
||||||
|
"title": (clip.get("title") or song.title)[:200],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG.i(f"[song] place={place_id} '{song.title}' ({song.style}) 완성 — {len(content)} bytes · {file_name}")
|
||||||
|
|
||||||
|
# ★ 여기서 재빌드를 걸지 않는다. 발행이 이 함수를 **기다리고 있고**, 돌아가면 바로 그
|
||||||
|
# 스냅샷에 이 곡이 실린다. 잡으로 따로 돌 때(운영자 재생성)도 같다 — 다음 발행에 실린다.
|
||||||
|
return {
|
||||||
|
"place_id": place_id, "song_id": str(row.song_id), "title": song.title,
|
||||||
|
"style": song.style, "file": file_name,
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@
|
|||||||
JobType.COPY ✓ services/copy_service.run_copy — 소개문·FAQ (확보된 fact 만 근거)
|
JobType.COPY ✓ services/copy_service.run_copy — 소개문·FAQ (확보된 fact 만 근거)
|
||||||
JobType.BUILD ✓ services/build_service.run_build — 정적 빌드 + 발행 검수 게이트
|
JobType.BUILD ✓ services/build_service.run_build — 정적 빌드 + 발행 검수 게이트
|
||||||
JobType.LOCAL_SYNC ✓ services/story_service.run_local_sync — 지역 이야기 생성(지역당 1회)
|
JobType.LOCAL_SYNC ✓ services/story_service.run_local_sync — 지역 이야기 생성(지역당 1회)
|
||||||
|
JobType.SONG ✓ services/song_service.run_song — 이 숙소의 노래 한 곡(가사 Gemini → 작곡 Suno)
|
||||||
JobType.AI_CHECK → reports 모듈이 붙을 때
|
JobType.AI_CHECK → reports 모듈이 붙을 때
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -80,6 +81,7 @@ def _register_builtin():
|
|||||||
from services.build_service import run_build
|
from services.build_service import run_build
|
||||||
from services.copy_service import run_copy
|
from services.copy_service import run_copy
|
||||||
from services.story_service import run_local_sync
|
from services.story_service import run_local_sync
|
||||||
|
from services.song_service import run_song
|
||||||
from services.vision_service import run_vision
|
from services.vision_service import run_vision
|
||||||
|
|
||||||
if JobType.COLLECT.value not in HANDLERS:
|
if JobType.COLLECT.value not in HANDLERS:
|
||||||
@ -92,6 +94,8 @@ def _register_builtin():
|
|||||||
HANDLERS[JobType.BUILD.value] = run_build
|
HANDLERS[JobType.BUILD.value] = run_build
|
||||||
if JobType.LOCAL_SYNC.value not in HANDLERS:
|
if JobType.LOCAL_SYNC.value not in HANDLERS:
|
||||||
HANDLERS[JobType.LOCAL_SYNC.value] = run_local_sync
|
HANDLERS[JobType.LOCAL_SYNC.value] = run_local_sync
|
||||||
|
if JobType.SONG.value not in HANDLERS:
|
||||||
|
HANDLERS[JobType.SONG.value] = run_song
|
||||||
|
|
||||||
|
|
||||||
_register_builtin()
|
_register_builtin()
|
||||||
|
|||||||
@ -118,5 +118,7 @@ export function sanitizePayloadForPublish(payload: SitePayload): SitePayload {
|
|||||||
links: payload.links.filter((link) => link.confirmed),
|
links: payload.links.filter((link) => link.confirmed),
|
||||||
// 대체 텍스트 없는 이미지는 어차피 렌더하지 않는다 — 목록에서도 뺀다.
|
// 대체 텍스트 없는 이미지는 어차피 렌더하지 않는다 — 목록에서도 뺀다.
|
||||||
media: payload.media.filter((item) => item.alt?.trim()),
|
media: payload.media.filter((item) => item.alt?.trim()),
|
||||||
|
// 재생 주소가 없는 곡은 버튼만 있고 소리가 없다. 목록에서 뺀다.
|
||||||
|
songs: (payload.songs ?? []).filter((song) => song.audioUrl?.trim()),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,15 @@ export interface SitePayload {
|
|||||||
/** 주요 거점까지의 이동 시간. */
|
/** 주요 거점까지의 이동 시간. */
|
||||||
routes: RouteEntry[];
|
routes: RouteEntry[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이 숙소의 노래. 발행할 때마다 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
||||||
|
*
|
||||||
|
* ★ 발행이 노래를 기다리므로 발행본에는 보통 한 곡이 실려 있다. 그래도 빌 수 있다 —
|
||||||
|
* 키가 없거나(SUNO_API_KEY) 작곡이 실패하면 **노래 없이** 발행한다(발행을 막지는 않는다).
|
||||||
|
* 렌더러는 비면 플레이어를 아예 그리지 않는다.
|
||||||
|
*/
|
||||||
|
songs: SongTrack[];
|
||||||
|
|
||||||
/** LLM 이 쓴 문장(allow_llm=true 필드). 사실이 아니라 문장이라 fact 와 분리한다. */
|
/** LLM 이 쓴 문장(allow_llm=true 필드). 사실이 아니라 문장이라 fact 와 분리한다. */
|
||||||
narrative: Narrative;
|
narrative: Narrative;
|
||||||
|
|
||||||
@ -323,6 +332,25 @@ export interface Narrative {
|
|||||||
summary?: string;
|
summary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SongTrack {
|
||||||
|
songId: string;
|
||||||
|
title: string;
|
||||||
|
/** 가사. 화면에서 펼쳐 볼 수 있게 함께 싣는다 — 무슨 노래인지 읽히지 않으면 아무도 안 누른다. */
|
||||||
|
lyrics?: string | null;
|
||||||
|
/** 장르·분위기(Suno 에 넘긴 값). */
|
||||||
|
style?: string | null;
|
||||||
|
durationSec?: number | null;
|
||||||
|
/**
|
||||||
|
* 재생 주소. **우리 쪽 경로**다(`/s/<slug>/<파일>`).
|
||||||
|
*
|
||||||
|
* ★ Suno 가 준 주소를 그대로 쓰지 않는다 — 만료되는 주소라, 발행 직후에는 재생되고
|
||||||
|
* 몇 주 뒤 조용히 죽는다. 백엔드가 파일을 받아 두고 프리렌더가 사이트로 복사한다.
|
||||||
|
*/
|
||||||
|
audioUrl: string;
|
||||||
|
/** 프리렌더가 원본을 찾을 때 쓰는 파일명. 렌더러는 쓰지 않는다. */
|
||||||
|
fileName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SiteTheme {
|
export interface SiteTheme {
|
||||||
templateId: string;
|
templateId: string;
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
2
solution/site/scripts/mockup/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
build/
|
||||||
|
stay-payload-new.json
|
||||||
938
solution/site/scripts/mockup/README.md
Normal file
@ -0,0 +1,938 @@
|
|||||||
|
# `/s/stay` 시연본 — 지침서 한 장
|
||||||
|
|
||||||
|
> **이 문서 하나만 읽으면 된다.** 예전에는 README · SECTIONS · PROMPTS · REVIEW 로 흩어져
|
||||||
|
> 있었고 무엇을 먼저 볼지 알 수 없었다. 전부 여기로 합쳤다(2026-09-11).
|
||||||
|
> 같은 폴더에 **생성물 두 개**가 더 있는데 지침이 아니라 **데이터**다 —
|
||||||
|
> `TEXT.md`(화면에 나가는 텍스트 전문, `python3 dump_text.py`)와
|
||||||
|
> `SCHEDULE.md`(일정 21개 전수 검수표, `python3 audit_schedule.py`).
|
||||||
|
|
||||||
|
대상 `https://web4ai.o2osolution.ai/s/stay` (스테이,머뭄 · 전북 군산)
|
||||||
|
|
||||||
|
## 지금 누가 무엇을 하나
|
||||||
|
|
||||||
|
| | 하는 일 | 읽을 곳 |
|
||||||
|
|---|---|---|
|
||||||
|
| **사장님(민아)** | `/s/stay` 시연본 자체를 고친다 | **2부** |
|
||||||
|
| **나머지 팀원** | 시연본에 있는 것을 **제품이 자동으로 만들게** 옮긴다 | **1부** |
|
||||||
|
|
||||||
|
시연본은 **payload 가 없는 목업**이다. 프리렌더가 굽지 않고 `index.html` 한 장을 손으로
|
||||||
|
조립했다. 그래서 "화면에 있는 것"과 "제품이 만들 수 있는 것"이 갈라져 있고,
|
||||||
|
1부가 그 간격을 메우는 작업이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1부 — 실제 구현을 하는 사람
|
||||||
|
|
||||||
|
## 1.1 어떤 파일을 여나 (결론부터)
|
||||||
|
|
||||||
|
**목업 폴더는 사양서다. 코드는 여기서 고친다.**
|
||||||
|
|
||||||
|
### ① 계약 — 화면에 새 값을 내려면 여기부터
|
||||||
|
|
||||||
|
| 파일 | 무엇 |
|
||||||
|
|---|---|
|
||||||
|
| `solution/shared/src/types/site-payload.ts` | payload 필드 정의. `Narrative`(`:316`) · `MediaItem` · `Unit` |
|
||||||
|
| `solution/shared/src/lib/section-data.ts` | 섹션 봉투(`{kind,version,title,items[]}`) 타입 + `planDay()`. **21시 넘는 칸을 버리는 규칙이 여기 있다**(`:535`) |
|
||||||
|
| `solution/shared/src/lib/section-prompts.ts` | 프롬프트 — **서버 생성 잡이 쓰는 6종**(songs · daily · people · chronicle · postcard · quiz). 고친 뒤 `npm run export:prompts` |
|
||||||
|
| `solution/frontend/src/features/builder/canvas/dataSpec.ts` | 프롬프트 — **빌더 [콘텐츠] 탭에서 붙여넣는 9종**. 위 6종은 shared 를 import 하고, **video · event · itinerary 셋은 여기에만 있다**(`itinerary` 는 `:556`) |
|
||||||
|
|
||||||
|
### ② 생성 — 값을 만드는 곳
|
||||||
|
|
||||||
|
| 파일 | 무엇 | 관련 할 일 |
|
||||||
|
|---|---|---|
|
||||||
|
| `solution/backend/services/site_payload.py` | 섹션 목록·기본 on/off(`:155` 부근) · payload 조립 | T1 |
|
||||||
|
| `solution/backend/services/itinerary.py` | 여행 일정 생성 | **T2** |
|
||||||
|
| `solution/backend/services/story_service.py` | 지역 이야기(노래·인물·연표·엽서·퀴즈) 생성 잡 | T3 T5 |
|
||||||
|
| `solution/backend/services/external/wikimedia.py` | 위키 사진 | T3 |
|
||||||
|
| `solution/backend/services/collector/` | 수집 어댑터 | T6 |
|
||||||
|
|
||||||
|
### ③ 화면 — 발행본 렌더러
|
||||||
|
|
||||||
|
| 파일 | 무엇 |
|
||||||
|
|---|---|
|
||||||
|
| `solution/site/src/sections/` | 섹션 컴포넌트. 히어로는 `HeroPension.tsx`, 일정은 `items/ItinerarySection.tsx` |
|
||||||
|
| `solution/site/src/lib/ui/use-rail-autoplay.ts` | 카로셀 자동 넘김 정책 |
|
||||||
|
|
||||||
|
### ④ 사양 — 이 목업 폴더에서 가져갈 것
|
||||||
|
|
||||||
|
| 파일 | 옮길 자리 |
|
||||||
|
|---|---|
|
||||||
|
| 이 문서 **1.4 프롬프트 전문** | `shared/src/lib/section-prompts.ts` |
|
||||||
|
| `build_itinerary.py` (규칙 16종) | `backend/services/itinerary.py` |
|
||||||
|
| `build_story.py` (위키 사진 수집) | `backend/services/external/wikimedia.py` |
|
||||||
|
| `inject.js` (렌더러에 **없는** 기능 3개) | `site/src/sections/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.2 섹션 22개 — 지금 상태
|
||||||
|
|
||||||
|
`할 일` 이 비면 제품이 **이미** 만들 수 있다. 손댈 필요 없다.
|
||||||
|
|
||||||
|
| id | 이름 | 값이 오는 자리 | 지금 누가 만드나 | 할 일 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `hero` | 히어로 | `narrative` | 사장님이 적음 | **T1** |
|
||||||
|
| `intro` | 소개 | `narrative.about[]` | LLM(빌더) | |
|
||||||
|
| `rooms` | 객실 안내 | `units[]` + `media[]` | 사장님이 올림 | **T6** |
|
||||||
|
| `event` | 소식 | `data(kind=event)` | 사장님이 적음 | |
|
||||||
|
| `info` | 기본 정보 | `facts[]` | 사장님이 적음 | |
|
||||||
|
| `booking` | 실시간 예약 | — | (시연본은 꺼 둠) | |
|
||||||
|
| `video` | 영상 | `data(kind=video)` | 사장님이 적음 | |
|
||||||
|
| `photos` | 사진 갤러리 | `media[]` | 사장님이 올림 | |
|
||||||
|
| `map` | 오시는 길 | `place` + `routes[]` | 수집 | |
|
||||||
|
| `festival` | 계절별 축제 | `local.festivals` | TourAPI 수집 | |
|
||||||
|
| `local` | 지역 정보 | `local.attractions/restaurants` | TourAPI 수집 | |
|
||||||
|
| `itinerary` | **추천 일정** | `data(kind=itinerary)` | `services/itinerary.py` + 빌더 프롬프트 `dataSpec.ts:556` | **T2** |
|
||||||
|
| `story` | 군산 이야기 | 아래 6탭 껍데기 | — | |
|
||||||
|
| `songs` | 가요 다방 | `data(kind=songs)` | LLM `section-prompts.ts:67` | **T3 T5** |
|
||||||
|
| `daily` | 오늘의 한 장 | `data(kind=daily)` | LLM `:91` | 섹션 자리가 없다 |
|
||||||
|
| `people` | 인물 열전 | `data(kind=people)` | LLM `:113` | **T3 T5** |
|
||||||
|
| `chronicle` | 시간의 골목 | `data(kind=chronicle)` | LLM `:135` | |
|
||||||
|
| `postcard` | 오늘의 엽서 | `data(kind=postcard)` | LLM `:160` | |
|
||||||
|
| `quiz` | 뒤집어 보는 질문 | `data(kind=quiz)` | LLM `:188` | |
|
||||||
|
| `faq` | 자주 묻는 질문 | `faqs[]` | LLM + 사장님 | |
|
||||||
|
| `rules` | 이용 규정 | `facts[]` | 사장님이 적음 | |
|
||||||
|
| `weather` | 날씨 | `local.weather` | open-meteo | |
|
||||||
|
| `planner` | 계절별 추천 하루 | `data(kind=planner)` | **없음** | (시연본은 꺼 둠) |
|
||||||
|
|
||||||
|
섹션이 아닌 것 하나가 더 있다 — 헤더 음악 플레이어(**T4**).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.3 할 일 여섯
|
||||||
|
|
||||||
|
### T1 — 히어로 캐치프레이즈
|
||||||
|
|
||||||
|
**증상** 히어로 문구가 사이트마다 한 줄로 고정이다. 계절도 날씨도 안 탄다.
|
||||||
|
계약(`site-payload.ts:316` `Narrative`)에 문자열 `tagline` 하나뿐이고,
|
||||||
|
히어로는 `tagline ?? heroSubline ?? summary` 를 그대로 찍는다(`HeroPension.tsx:125`).
|
||||||
|
|
||||||
|
**할 일**
|
||||||
|
|
||||||
|
1. `Narrative` 에 필드를 더한다.
|
||||||
|
```ts
|
||||||
|
catchphrases?: { version: 1; items: {
|
||||||
|
text: string; // 25자 안쪽
|
||||||
|
kind: 'general' | 'season' | 'month' | 'weather';
|
||||||
|
season?: '봄'|'여름'|'가을'|'겨울'; month?: number;
|
||||||
|
weather?: '맑음'|'구름많음'|'흐림'|'비'|'눈';
|
||||||
|
}[] };
|
||||||
|
```
|
||||||
|
2. 히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다.
|
||||||
|
순환 순서는 `일반 → 계절 → 일반 → 월 → 일반 → 날씨` 한 바퀴를 만들고 그 바퀴를 돈다.
|
||||||
|
일반은 자루에서 뽑아 쓰고 비면 다시 채운다(같은 문장이 연달아 안 나오게).
|
||||||
|
계절은 3개월 단위(3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울).
|
||||||
|
날씨는 `/v1/local/weather` 를 10분마다 읽고, **못 받아 오면 날씨 문구를 순환에서 뺀다**.
|
||||||
|
목업 구현은 `inject.js` 의 `startCatchphrase()` · `buildCycle()`.
|
||||||
|
3. 프롬프트는 **1.4 ①** 을 쓴다. 100개(일반 40 · 계절 24 · 월 24 · 날씨 12).
|
||||||
|
4. **`STORY_KINDS` 에 등록한다.** 빠뜨리면 서버 생성 잡이 이 종류를 아예 모른다 —
|
||||||
|
`daily` 가 그래서 한동안 빈칸이었다.
|
||||||
|
|
||||||
|
**완료 기준** 새 업장 발행 시 문구 100개가 돌고, 9월에 겨울 문구가 안 나온다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T2 — 추천 일정 ★ 가장 큰 일
|
||||||
|
|
||||||
|
**증상 ①** 서버 생성기(`services/itinerary.py`)는 하루를 **관광지 2 + 맛집 2** 로 짠다.
|
||||||
|
체류시간·이동시간·입퇴실·끼니 시각 개념이 없다. 그래서 나온 하루가
|
||||||
|
"둘째 날 해산물축제 → 근대건축관" 으로 끝난다.
|
||||||
|
|
||||||
|
**증상 ② 프롬프트가 두 곳으로 갈려 있다.**
|
||||||
|
일정 프롬프트는 `shared/src/lib/section-prompts.ts` 에 **없고**
|
||||||
|
`frontend/src/features/builder/canvas/dataSpec.ts:556` 에만 있다.
|
||||||
|
그 파일은 **빌더에서 붙여넣는 9종**을 담는데, 그중 `songs` 등 6종은 shared 를 import 하고
|
||||||
|
**`video` · `event` · `itinerary` 셋은 자기 자리에 직접 적혀 있다.**
|
||||||
|
→ 서버 생성 잡(`story_service.py`)은 shared 만 읽으므로 **이 셋을 만들 줄 모른다.**
|
||||||
|
`daily` 가 빈칸이던 것과 **같은 종류의 사고**다. 옮기면서 shared 로 올린다.
|
||||||
|
|
||||||
|
**증상 ③ 지금 프롬프트가 모델에게 시각 계산을 시킨다.**
|
||||||
|
`dataSpec.ts` 의 일정 규칙에 이런 줄이 있다 — "밤 9시를 넘기는 칸은 화면에서 빠진다 —
|
||||||
|
**시각을 계산해 보고 넣는다**", 그리고 `minutes` · `moveMinutes` 를 모델이 적게 한다.
|
||||||
|
모델은 못 한다. 이게 시연본 첫 판이 망가진 직접 원인이다.
|
||||||
|
|
||||||
|
**★ 프롬프트 하나로 일정을 받지 않는다.** 모델은 시각 계산을 못 한다 —
|
||||||
|
이동시간을 지어내고, 점심을 09:40 에 넣고, 21시를 넘겨 **렌더러가 조용히 버리는** 칸을
|
||||||
|
만든다(`shared/src/lib/section-data.ts:535`). 시연본 첫 판이 정확히 그랬다.
|
||||||
|
|
||||||
|
**세 단계로 나눈다.**
|
||||||
|
|
||||||
|
| 단계 | 누가 | 무엇을 내놓나 | 프롬프트 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **① 장소 대장** | LLM | 장소 30곳 × `{stayMinutes:{min,base,max}, todo, indoor, needsCar, bestTime, searchQuery}` | **1.4 ②** |
|
||||||
|
| **② 좌표** | 코드 | 카카오 로컬로 `searchQuery` → 위경도. **LLM 이 적은 좌표는 쓰지 않는다** | — |
|
||||||
|
| **③ 조립** | **코드** | 이동시간·도착시각·끼니 창 맞추기·규칙 16종 감사 | 없음 (`build_itinerary.py`) |
|
||||||
|
| **④ 테마** | LLM | 테마 20개 × `{name, duration, audience, why, days[{kind, places[]}]}` | **1.4 ③** |
|
||||||
|
|
||||||
|
**코드가 하는 일(③)에서 반드시 옮겨야 하는 것** — 근거는 `build_itinerary.py` 줄번호
|
||||||
|
|
||||||
|
| | 무엇 | 어디 |
|
||||||
|
|---|---|---|
|
||||||
|
| 이동시간 | 좌표 계산. 도보 4km/h(≤1.5km), 그 위는 차 25km/h + 주차 5분, 최소 3분 | `move_minutes()` |
|
||||||
|
| 숙소 고정 | 모든 일정이 **숙소에서 출발해 숙소로 복귀**. 첫 칸·마지막 칸이 숙소 | `:108` `:182` |
|
||||||
|
| 시각 창 | 입실 15:00+ · 퇴실 11:00− · 점심 11:30–13:30 · 늦은 점심 13:00–15:00 · 저녁 17:30–20:00 · 밤산책 18:30–20:00 | `:156` `:166` |
|
||||||
|
| 창 맞추기 | 어긋나면 **앞 칸 체류를 범위(min~max) 안에서** 늘이고 줄인다. 이동시간을 고치지 않는다. 그래도 안 되면 시작 시각을 10분씩 옮기고, 그래도 안 되면 **그 조합을 만들지 않는다** | `plan_day()` |
|
||||||
|
| 테마별 완급 | 시작 시각·체류 배율이 테마마다 다르다(쉬러 오면 1.25배, 사진 찍으면 0.8배) → 끝나는 시각이 13:35~21:00 로 퍼진다 | `:470` |
|
||||||
|
| 끼니 간격 | 앞 끼니가 끝나고 **3시간**이 지나야 다음 끼니 창이 열린다(`MEAL_GAP`). 창만 지키면 통과하던 자리다 | `_window_of()` |
|
||||||
|
| **감사 16종** | 하나라도 어기면 **payload 를 쓰지 않고 멈춘다** | `:526` · 목록은 **2.4** |
|
||||||
|
|
||||||
|
★ **감사는 생성기 안에 둔다.** 밖에 두면 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생긴다 —
|
||||||
|
실제로 그래서 33건이 통과했다(**3.2**).
|
||||||
|
|
||||||
|
**하루 뼈대** — `@` 는 숙소
|
||||||
|
|
||||||
|
```
|
||||||
|
첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@
|
||||||
|
가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@
|
||||||
|
쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@
|
||||||
|
마지막날 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@
|
||||||
|
```
|
||||||
|
|
||||||
|
**완료 기준** 새 업장에서 테마 20개가 나오고 `audit()` 위반 0건.
|
||||||
|
하루가 숙소로 시작해 숙소로 끝나고, 끼니가 제 시각에 있고, 21시를 안 넘는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T3 — 인물·노래 사진
|
||||||
|
|
||||||
|
**증상** 프롬프트가 사진 URL 을 금지하고 `imageQuery` 만 받는다(`section-prompts.ts:128`).
|
||||||
|
초상권·저작권 때문이고 그 판단은 맞다. 그런데 **다음 단계가 없어서** 인물 열전이 활자만으로 선다.
|
||||||
|
|
||||||
|
**할 일**
|
||||||
|
|
||||||
|
1. `services/external/wikimedia.py` 에 **문서 → `pageimages` → `imageinfo.extmetadata`** 경로를 붙인다.
|
||||||
|
2. ★ **이름으로 커먼즈를 검색하지 않는다.** 동명이인이 온다 — 실측(2026-09-11):
|
||||||
|
이수현(독립운동가)→걸그룹, 은성수(금융위원장)→축구선수, 박성현(양궁)→골퍼,
|
||||||
|
전진(비전향 장기수)→아이돌, 이길여→건물. **틀린 사진은 없는 것만 못하다.**
|
||||||
|
그 사람의 **위키백과 문서가 대표로 쓰는 사진**만 믿는다. 구현은 `build_story.py`.
|
||||||
|
3. 저작자·라이선스를 `imageCredit` 으로 payload 에 넣는다.
|
||||||
|
★ **문자열이다**(`site/src/sections/items/common.tsx:206`). 객체로 넣으면 렌더러가
|
||||||
|
조용히 아무것도 안 그린다 — CC BY-SA 는 출처 표시가 **라이선스 조건**이라 그건 침해다.
|
||||||
|
4. 사진 있는 사람을 **앞으로** 정렬한다. 빈 카드가 먼저 오면 목록이 비어 보인다.
|
||||||
|
|
||||||
|
**완료 기준** 인물 카드에 사진과 저작자 줄이 같이 뜬다. 시연본 실적 57명 중 18명.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T4 — 음악 플레이어
|
||||||
|
|
||||||
|
**증상** `media` 에 오디오 종류가 없다. 목업은 mp3 를 `/s/stay/audio/` 에 **직접 넣었다**.
|
||||||
|
|
||||||
|
**할 일** `MediaItem` 에 `kind:'audio'`(+`title` `artist` `durationSec`)를 넣고 발행 때 같이 올린다.
|
||||||
|
헤더에 붙는 작은 플레이어를 만든다 — 아이콘 · 현재 곡명 · 재생/멈춤 · 목록.
|
||||||
|
**자동재생은 넣지 않는다**(2026-09-11 지시). 순서는 **랜덤 시작 후 순차**.
|
||||||
|
목업 구현은 `inject.js` 의 `startPlayer()`, 모양은 `inject.css` 의 `#w4d-mini` · `#w4d-panel`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T5 — 상한(`maxItems`)
|
||||||
|
|
||||||
|
| 종류 | 지금 | 시연본 | 왜 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| songs | 8 | 25 | 8곡이면 '다방'이 아니라 목록이다 |
|
||||||
|
| people | 10 | 57 | 위키백과 '군산시 출신' 분류만으로 57명이 확인된다 |
|
||||||
|
|
||||||
|
★ **한 번에 다 받지 않는다.** 25곡을 한 번에 시키면 뒤쪽이 급격히 부실해진다.
|
||||||
|
15곡씩 나눠 받고 제목으로 중복을 지운다 — 나누는 문구는 **1.4 ④** 에 있다.
|
||||||
|
★ `maxItems` 와 프롬프트 안의 숫자를 **같이** 바꾼다. 어긋나면 받아 놓고 잘라 버린다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### T6 — 객실 사진 출처
|
||||||
|
|
||||||
|
시연본은 야놀자 등록본(`nol.yanolja.com/stay/domestic/10068088`)의 Next.js 플라이트 데이터에서
|
||||||
|
`roomTypes[].photos` 를 파싱해 A동 12·B동 10 을 가져왔다(`rooms.json`).
|
||||||
|
**손으로 한 것이고 제품 경로가 아니다.**
|
||||||
|
|
||||||
|
수집 어댑터를 만들려면 [docs/DECISIONS.md](../../../../docs/DECISIONS.md) 1-1 과
|
||||||
|
[docs/DATA_SOURCE_RESEARCH.md](../../../../docs/DATA_SOURCE_RESEARCH.md) 를 먼저 읽는다 —
|
||||||
|
**봇 탐지 우회는 결론과 무관하게 영구 금지**다.
|
||||||
|
현실적인 경로는 **사장님이 올리는 것**이거나 **업소가 준 채널의 공개 API** 다.
|
||||||
|
|
||||||
|
★ 사진을 눈으로 보고 세지 않는다. 시연본에서 A/B 를 눈으로 배정했다가 11/5 가 나왔고
|
||||||
|
실제는 12/10 이었다. 등록본 데이터를 쓴다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.4 프롬프트 전문
|
||||||
|
|
||||||
|
**프롬프트는 지금 두 파일에 나뉘어 있다. 이게 문제다.**
|
||||||
|
|
||||||
|
| 파일 | 담는 것 | 누가 읽나 |
|
||||||
|
|---|---|---|
|
||||||
|
| `shared/src/lib/section-prompts.ts` | songs · daily · people · chronicle · postcard · quiz | 빌더 **+ 서버 생성 잡** |
|
||||||
|
| `frontend/.../canvas/dataSpec.ts` | 위 6종(shared 를 import) **+ video · event · itinerary** | 빌더만 |
|
||||||
|
|
||||||
|
→ **`video` · `event` · `itinerary` 는 서버가 만들 줄 모른다.** 옮기면서 shared 로 올린다.
|
||||||
|
|
||||||
|
**단일 출처는 `solution/shared/src/lib/section-prompts.ts` 다.**
|
||||||
|
사장님이 [콘텐츠] 탭에서 복사해 가는 프롬프트와, 서버 생성 잡(`services/story_service.py`)이
|
||||||
|
**같은 문장**을 써야 한다. 두 벌로 두면 "빌더에서 뽑은 것과 서버가 채운 것의 모양이 다르다"가
|
||||||
|
조용히 생긴다.
|
||||||
|
|
||||||
|
```
|
||||||
|
shared/src/lib/section-prompts.ts ← 여기만 고친다
|
||||||
|
│ npm run export:prompts
|
||||||
|
▼
|
||||||
|
backend/services/prompts/section_prompts.json ← 산출물. 손으로 고치지 않는다
|
||||||
|
```
|
||||||
|
|
||||||
|
### 공통 규칙 (모든 프롬프트 뒤에 붙는다)
|
||||||
|
|
||||||
|
```
|
||||||
|
[공통 규칙]
|
||||||
|
1. JSON 하나만 출력한다. 인사말·설명·코드펜스를 붙이지 않는다.
|
||||||
|
2. 확인되지 않은 값은 필드를 통째로 뺀다. 빈 문자열로 채우거나 "미상" 이라고 쓰지 않는다.
|
||||||
|
3. source.url 은 실제로 열리는 공식·기관·언론 페이지여야 한다. 검색 결과 주소는 쓰지 않는다.
|
||||||
|
4. 근거가 확실하면 verified 를 "확인", 애매하면 "확인필요" 로 적는다.
|
||||||
|
5. 가사·시·소설의 원문을 한 줄도 옮기지 않는다. 제목과 배경만 쓴다.
|
||||||
|
6. 설명 문장은 항목당 두 문장을 넘기지 않는다.
|
||||||
|
7. 이미지 주소는 만들지 않는다. 필요하면 imageQuery 에 검색어만 적는다.
|
||||||
|
```
|
||||||
|
|
||||||
|
★ 2번과 3번이 이 레포의 절대규칙을 프롬프트로 옮긴 것이다. 모델이 이걸 어기면 검증기가
|
||||||
|
뒤에서 걸러야 하는데, 걸러진 항목은 결국 화면에서 빈자리가 된다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ① 히어로 캐치프레이즈 (T1 · 신규)
|
||||||
|
|
||||||
|
```
|
||||||
|
너는 숙소의 카피라이터다. 아래 조건에 맞는 JSON 하나만 출력한다.
|
||||||
|
|
||||||
|
[업소] {상호} ({업종})
|
||||||
|
[지역] {지명}
|
||||||
|
[업소가 가진 사실]
|
||||||
|
{fact 목록을 한 줄씩. 예: 1920년대 적산가옥 · 독채 2동 · 최대 4인 · 마당과 정원 ·
|
||||||
|
창고형 카페 공간 · 침구 매일 세탁 · 히로쓰 가옥 옆 · 초원사진관 도보 3분}
|
||||||
|
|
||||||
|
[해야 할 일]
|
||||||
|
[업소] 의 히어로 문구를 100개 만든다. 상호 바로 아래에 한 줄로 뜨고 7초마다 바뀐다.
|
||||||
|
네 종류로 나눠 만든다.
|
||||||
|
|
||||||
|
· 일반 40개 — 언제 나와도 되는 문구
|
||||||
|
· 계절 24개 — 봄·여름·가을·겨울 각 6개 (3개월 단위: 3~5 봄 / 6~8 여름 / 9~11 가을 / 12~2 겨울)
|
||||||
|
· 월별 24개 — 1월부터 12월까지 각 2개
|
||||||
|
· 날씨 12개 — 맑음 3 · 구름많음 2 · 흐림 2 · 비 3 · 눈 2
|
||||||
|
|
||||||
|
[스키마]
|
||||||
|
{ "kind":"catchphrase", "version":1, "items":[
|
||||||
|
{ "text":"한 줄", "kind":"general|season|month|weather",
|
||||||
|
"season":"봄|여름|가을|겨울", // kind 가 season 일 때만
|
||||||
|
"month":9, // kind 가 month 일 때만 (1~12)
|
||||||
|
"weather":"맑음|구름많음|흐림|비|눈" } // kind 가 weather 일 때만
|
||||||
|
] }
|
||||||
|
|
||||||
|
[이 아이템만의 규칙]
|
||||||
|
· 한 줄은 25자 안쪽이다. 두 문장으로 쓰지 않는다.
|
||||||
|
· **[업소가 가진 사실] 안에서만 쓴다.** 거기 없는 시설·거리·연도를 지어내지 않는다.
|
||||||
|
· 느낌표와 이모지를 쓰지 않는다. "최고" "완벽" "힐링" 같은 광고 단어를 쓰지 않는다.
|
||||||
|
· 상호를 문구에 넣지 않는다 — 바로 위에 이미 크게 떠 있다.
|
||||||
|
· 같은 사실을 두 번 쓰지 않는다. 100개가 다 다른 것을 말해야 한다.
|
||||||
|
· 계절 문구는 그 계절에만 맞는 말이어야 한다. "좋습니다" 처럼 아무 때나 되는 말은 일반으로 보낸다.
|
||||||
|
· 날씨 문구는 그 날씨일 때 손님이 **무엇을 할 수 있는지**를 말한다. 날씨 묘사만 하지 않는다.
|
||||||
|
· 월별 문구는 그 달의 날씨·행사·빛이 근거여야 한다. 숫자만 바꾼 문구를 12번 쓰지 않는다.
|
||||||
|
· source 와 verified 는 없다. 사실이 아니라 문구이고, 근거는 [업소가 가진 사실] 이다.
|
||||||
|
```
|
||||||
|
|
||||||
|
**잘 나온 예**
|
||||||
|
|
||||||
|
| 종류 | 문구 | 근거가 된 사실 |
|
||||||
|
|---|---|---|
|
||||||
|
| 일반 | 담 너머는 히로쓰 가옥입니다 | 위치 |
|
||||||
|
| 일반 | 침구는 매일 새것처럼 나갑니다 | 매일 세탁·살균 |
|
||||||
|
| 계절(가을) | 기와 위로 가을 볕이 마릅니다 | 기와 지붕 |
|
||||||
|
| 월(9월) | 가을이 담을 넘어오는 구월입니다 | 담·계절 |
|
||||||
|
| 날씨(비) | 카페 공간에서 빗소리를 들으실 수 있습니다 | 창고형 카페 |
|
||||||
|
|
||||||
|
**받은 뒤 검사할 것**
|
||||||
|
|
||||||
|
- [ ] 100개인가. 종류별 40/24/24/12 인가
|
||||||
|
- [ ] 25자를 넘는 것이 있는가
|
||||||
|
- [ ] [업소가 가진 사실] 에 없는 말이 있는가 ← **이게 제일 많이 틀린다**
|
||||||
|
- [ ] 계절 문구가 그 계절에만 맞는가
|
||||||
|
- [ ] 같은 말이 두 번 나오는가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ② 여행 일정 · 장소 대장 (T2 ① · `dataSpec.ts:556` 의 일정 프롬프트를 **대체**한다)
|
||||||
|
|
||||||
|
```
|
||||||
|
너는 지역 여행 코디네이터다. 아래 조건에 맞는 JSON 하나만 출력한다.
|
||||||
|
|
||||||
|
[업소] {상호} ({업종})
|
||||||
|
[지역] {지명}
|
||||||
|
[좌표] {위도},{경도}
|
||||||
|
|
||||||
|
[해야 할 일]
|
||||||
|
[업소] 에서 출발해 당일로 다녀올 수 있는 장소를 30곳까지 찾는다.
|
||||||
|
관광지·박물관·골목·바다·절 같은 볼거리 20곳과, 끼니를 해결할 곳 10곳을 섞는다.
|
||||||
|
[업소] 에서 30km 안쪽만 고른다.
|
||||||
|
|
||||||
|
[스키마]
|
||||||
|
{ "kind":"places", "version":1, "items":[
|
||||||
|
{ "name":"장소 이름",
|
||||||
|
"searchQuery":"지도에서 검색할 말",
|
||||||
|
"category":"볼거리|식당|카페",
|
||||||
|
"stayMinutes":{"min":20,"base":30,"max":45},
|
||||||
|
"todo":"거기서 무엇을 하는지 한 문장",
|
||||||
|
"indoor":true,
|
||||||
|
"needsCar":false,
|
||||||
|
"bestTime":"오전|오후|저녁|밤|상관없음",
|
||||||
|
"verified":"확인|확인필요",
|
||||||
|
"source":{"name":"출처명","url":"https://..."} } ] }
|
||||||
|
|
||||||
|
[이 아이템만의 규칙]
|
||||||
|
· **todo 는 장소 설명이 아니라 할 일이다.**
|
||||||
|
✗ "1899년에 지어진 근대 건축물입니다"
|
||||||
|
✓ "옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다."
|
||||||
|
손님이 거기서 무엇을 하는지 모르면 그 줄은 쓸모가 없다.
|
||||||
|
· stayMinutes 는 **실제로 걸리는 시간**이다. min 은 빨리 보고 나오는 사람, max 는 천천히 보는 사람.
|
||||||
|
박물관 60~120분, 골목 15~40분, 식당 40~70분이 기준이다. 무엇이든 90분으로 적지 않는다.
|
||||||
|
· 식당은 **무엇을 먹는지**까지 적는다. "맛집입니다" 로는 무엇을 시킬지 모른다.
|
||||||
|
· indoor 는 비 오는 날 갈 수 있는 곳만 true 다. 처마 밑·실내 전시가 여기 들어간다.
|
||||||
|
· needsCar 는 [업소] 에서 걸어서 못 가는 곳(1.5km 초과)이면 true 다.
|
||||||
|
· 좌표는 적지 않는다. 코드가 카카오 로컬에서 받는다 — 지어낸 좌표는 지도에 엉뚱한 핀을 찍는다.
|
||||||
|
· 영업시간·휴무일이 확인되면 source 에 그 페이지를 단다.
|
||||||
|
· **[업소]에서 40분 넘게 걸리는 곳을 넣었다면, 그 근처 식당도 하나 이상 넣는다.**
|
||||||
|
멀리 다녀온 뒤 시내까지 돌아와 밥을 먹으면 점심이 오후 3시가 된다 —
|
||||||
|
시연본에서 실제로 그랬다(섬에서 88분 달려와 15:47 에 늦은 점심).
|
||||||
|
· 끼니 장소는 **한 끼 40~70분**이다. 여기에 이동시간을 얹는 것은 코드가 한다.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ③ 여행 일정 · 테마 짓기 (T2 ④ · 신규 · ②와 짝)
|
||||||
|
|
||||||
|
```
|
||||||
|
[해야 할 일]
|
||||||
|
[장소 대장] 을 재료로 1박 2일 10개, 2박 3일 10개의 테마를 만든다.
|
||||||
|
테마는 **손님의 성격**으로 가른다 — 차가 있는지, 걷기를 좋아하는지, 비가 오는지,
|
||||||
|
아이가 있는지, 사진을 찍는지, 쉬러 오는지.
|
||||||
|
|
||||||
|
[스키마]
|
||||||
|
{ "kind":"themes", "version":1, "items":[
|
||||||
|
{ "name":"테마 이름", "duration":"1박 2일|2박 3일",
|
||||||
|
"audience":"누구에게", "why":"왜 이 순서인지 한두 문장",
|
||||||
|
"days":[ { "kind":"first|mid|mid_rest|mid_island|last", "places":["장소명","장소명"] } ] } ] }
|
||||||
|
|
||||||
|
[이 아이템만의 규칙]
|
||||||
|
· places 는 [장소 대장] 에 있는 이름만 쓴다. 없는 곳을 적으면 그 테마는 버려진다.
|
||||||
|
· 뼈대의 자리 수와 places 개수가 같아야 한다.
|
||||||
|
· 끼니 자리에는 category 가 식당·카페인 곳만 넣는다.
|
||||||
|
· **한 테마 안에서 같은 곳을 두 번 넣지 않는다.**
|
||||||
|
· 테마 이름에 "최고" "완벽" 을 쓰지 않는다. 무엇을 하는 일정인지가 이름이다.
|
||||||
|
· 20개 중 하나는 **숙소에서 거의 나가지 않는 날**을 포함한다.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ④ 가요 다방 (T5 · 기존 8곡 → 50곡)
|
||||||
|
|
||||||
|
```
|
||||||
|
[해야 할 일]
|
||||||
|
[지역]을 노래한 대중가요를 찾아 아래 JSON 으로 정리한다.
|
||||||
|
이번에는 {N}번째부터 {N+14}번째까지, 15곡을 낸다.
|
||||||
|
이미 받은 곡은 아래에 있다 — 같은 곡을 다시 내지 않는다.
|
||||||
|
[이미 받은 곡] {곡명 목록}
|
||||||
|
|
||||||
|
지명·항구·강·다리가 제목이나 배경에 나오는 곡을 고른다.
|
||||||
|
1960~80년대 곡을 먼저 찾고, 다 떨어지면 지자체가 만든 곡·지역 가수의 곡으로 넓힌다.
|
||||||
|
|
||||||
|
[스키마]
|
||||||
|
{ "kind":"songs", "version":1, "title":"가요 다방", "items":[
|
||||||
|
{ "title":"곡명", "artist":"가수", "lyricist":"작사", "composer":"작곡",
|
||||||
|
"year":1966, "label":"음반사", "labelColor":"#d4551f",
|
||||||
|
"story":"곡의 배경 (두 문장 이내, 가사 없이)",
|
||||||
|
"verified":"확인|확인필요",
|
||||||
|
"source":{"name":"출처명","url":"https://..."} } ] }
|
||||||
|
|
||||||
|
[이 아이템만의 규칙]
|
||||||
|
· lyrics 필드는 스키마에 없다. 어떤 이유로도 만들지 마라. 가사 한 소절도 안 된다.
|
||||||
|
· **곡이 실재하는지 출처로 확인한다.** 지역 노래 목록·언론 기사·지자체 음반 안내가 근거다.
|
||||||
|
검색으로 못 찾으면 그 곡을 내지 않는다 — 15곡을 억지로 채우지 않는다.
|
||||||
|
· labelColor 는 레코드 라벨 색이다. 곡의 분위기에 맞춰 진한 색 하나를 hex 로 고른다.
|
||||||
|
· 작사·작곡·발표연도를 모르면 그 필드를 뺀다. "미상" 이라고 쓰지 않는다.
|
||||||
|
· 유튜브 영상 ID 를 적지 않는다 — 코드가 검색 주소를 만든다. 틀린 영상이 붙는 것보다 낫다.
|
||||||
|
· 숙소가 직접 만든 곡은 여기 넣지 않는다. 이 섹션은 **도시의 노래**를 모으는 자리다.
|
||||||
|
```
|
||||||
|
|
||||||
|
★ 시연본은 25곡에서 멈췄다. 출처에서 확인되는 군산 곡이 거기까지였다.
|
||||||
|
**모자라면 모자란 채로 내는 것이 맞다** — 지어낸 곡은 아는 사람이 바로 알아본다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⑤ 인물 열전 (T3 T5 · 기존 10명 → 50명)
|
||||||
|
|
||||||
|
```
|
||||||
|
[해야 할 일]
|
||||||
|
[지역] 출신이거나 [지역]과 깊이 얽힌 인물을 50명까지 찾는다.
|
||||||
|
문학·음악·미술·연기·체육·정치·학문·기업을 고루 섞는다. 한 분야가 절반을 넘지 않게 한다.
|
||||||
|
생존 인물은 공개된 활동 사실만 쓴다.
|
||||||
|
|
||||||
|
[스키마]
|
||||||
|
{ "kind":"people", "version":1, "title":"인물 열전", "items":[
|
||||||
|
{ "name":"이름", "aka":"호·예명", "years":"1902–1950", "role":"소설가",
|
||||||
|
"oneLine":"한 문장 소개", "imageQuery":"사진 검색어",
|
||||||
|
"verified":"확인|확인필요",
|
||||||
|
"source":{"name":"출처명","url":"https://..."} } ] }
|
||||||
|
|
||||||
|
[이 아이템만의 규칙]
|
||||||
|
· **source.url 은 그 사람의 문서**여야 한다(위키백과 문서, 기관 소개 페이지).
|
||||||
|
"OO시 출신 인물 목록" 같은 목록 페이지는 근거가 아니다.
|
||||||
|
· oneLine 은 **그 사람이 무엇을 한 사람인지**다. "군산 출신입니다" 는 이름 아래 이미 있다.
|
||||||
|
· "~ 출신으로 알려진" 처럼 근거가 전언뿐이면 verified 를 "확인필요" 로 한다.
|
||||||
|
· 생존 인물의 가족·거주지·건강·재산 같은 사생활은 쓰지 않는다.
|
||||||
|
· 사진 URL 을 넣지 않는다. imageQuery 만 넣는다 — 초상권과 저작권은 사람이 확인한다.
|
||||||
|
· years 를 모르면 뺀다.
|
||||||
|
```
|
||||||
|
|
||||||
|
★ **사진은 프롬프트로 받지 않는다.** T3 참조 — 위키백과 **문서 대표 사진**만 받고
|
||||||
|
라이선스가 확인된 것만 쓴다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ⑥ 오늘의 엽서 (기존 개선 — 링크가 붙게)
|
||||||
|
|
||||||
|
```
|
||||||
|
[해야 할 일]
|
||||||
|
[지역]에 대해 손님이 자기 SNS 에 그대로 붙여 쓸 만한 한 문장을 12개 쓴다.
|
||||||
|
사실 하나가 반드시 들어가되, 설명하지 말고 툭 던지는 문장으로 쓴다.
|
||||||
|
|
||||||
|
[스키마]
|
||||||
|
{ "kind":"postcard", "version":1, "title":"오늘의 엽서", "items":[
|
||||||
|
{ "line":"한 문장", "hashtags":["#태그"],
|
||||||
|
"place":"그 문장이 가리키는 장소 이름",
|
||||||
|
"searchQuery":"지도에서 검색할 말",
|
||||||
|
"postmark":"소인에 찍을 짧은 지명",
|
||||||
|
"verified":"확인|확인필요",
|
||||||
|
"source":{"name":"출처명","url":"https://..."} } ] }
|
||||||
|
|
||||||
|
[이 아이템만의 규칙]
|
||||||
|
· 한 문장은 40자 안쪽이다. 두 문장으로 쓰지 않는다.
|
||||||
|
· 느낌표와 이모지를 쓰지 않는다. 광고 문구처럼 들리면 실패다.
|
||||||
|
· 해시태그는 3개까지. 지역명 하나는 반드시 넣는다.
|
||||||
|
· **같은 대상을 두 번 쓰지 않는다.** 열두 장이 전부 다른 곳·다른 이야기여야 한다.
|
||||||
|
· 운영시간·휴관일·주소·요금은 엽서에 적지 않는다. 그건 이용 정보지 엽서 문장이 아니다.
|
||||||
|
· **place 와 searchQuery 를 반드시 채운다.** 화면이 그 값으로 지도 링크를 건다.
|
||||||
|
postmark 는 도장 문구라 지명이 아닐 수 있다 — 그걸로 지도를 열면 엉뚱한 데가 나온다.
|
||||||
|
```
|
||||||
|
|
||||||
|
★ 시연본에서 실제로 그랬다. 소인 글자(`군산 內港`)로 링크를 만들었더니
|
||||||
|
`군산 군산` 같은 검색어가 나왔다. `place` 를 따로 받는 이유다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.5 프롬프트를 고칠 때 지킬 것
|
||||||
|
|
||||||
|
1. **`STORY_KINDS` 에 등록한다.** 빠지면 서버 생성 잡이 그 종류를 모른다.
|
||||||
|
2. **`maxItems` 와 프롬프트 안의 숫자를 같이 바꾼다.** 어긋나면 받아 놓고 잘라 버린다.
|
||||||
|
3. **개수를 억지로 채우게 하지 않는다.** "확실한 것이 12개면 12개만 낸다" 를 규칙에 넣는다.
|
||||||
|
채우라고 하면 채운다 — 지어내서.
|
||||||
|
4. **계산은 시키지 않는다.** 시각·거리·요금은 코드가 한다. 모델은 자신 있게 틀린 숫자를 준다.
|
||||||
|
5. **"설명" 이 아니라 "할 일" 을 시킨다.** 손님이 그 자리에서 무엇을 하는지가 콘텐츠다.
|
||||||
|
6. **출처의 종류를 지정한다.** "출처를 달아라" 로는 목록 페이지가 온다.
|
||||||
|
7. 고친 뒤 **`npm run export:prompts`** 를 돌린다. 안 돌리면 서버는 옛 프롬프트로 돈다.
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2부 — 목업을 고치는 사람
|
||||||
|
|
||||||
|
## 2.1 무엇을 고치는 건가
|
||||||
|
|
||||||
|
`/s/stay` 는 **payload 가 없는 목업**이다(`stay2` · `stay3` 도 같다).
|
||||||
|
프리렌더는 payload 를 받은 사이트만 굽는다 — 이 사이트는 **재굽기 대상이 아니고**,
|
||||||
|
고칠 자리는 이미 구워져 있는 `index.html` 하나이고 그 파일이 유일본이다.
|
||||||
|
|
||||||
|
그래서 손으로 열어 고치지 않는다. 스크립트가 **원본 + 바꿀 것 → 새 index.html** 을 만든다.
|
||||||
|
두 번째로 고칠 때 "무엇을 왜 바꿨는지"가 파일이 아니라 코드와 이 문서에 남아야 하기 때문이다.
|
||||||
|
|
||||||
|
```
|
||||||
|
stay-payload.json 원본 payload (건드리지 않는다 · 되돌릴 때 기준)
|
||||||
|
orig/index.html 원본 HTML (건드리지 않는다)
|
||||||
|
│
|
||||||
|
├─ build_itinerary.py 여행 일정을 계산해 넣는다 (+ 규칙 16종 감사)
|
||||||
|
├─ build_story.py 가요 다방 · 인물 열전을 넣는다
|
||||||
|
└─ patch_stay.py 캐치프레이즈 · 객실 사진 · 자작곡 · 주입 CSS/JS · 자산 경로 교정
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
build/index.html ← 배포하는 파일 (git 이그노어)
|
||||||
|
```
|
||||||
|
|
||||||
|
★ **렌더러(`solution/site/src`)를 고치지 않는다.** 고치면 번들 해시가 바뀌어
|
||||||
|
전 사이트 재굽기 + 전체 재업로드가 따라온다. 목업에만 필요한 것은 `inject.css`/`inject.js` 로 덮는다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.2 굽기 · 배포
|
||||||
|
|
||||||
|
### 다른 기계에서 처음 띄울 때
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> && cd o2o-site-AEO
|
||||||
|
cp .env.example .env && cp nginx/site.conf.example nginx/site.conf
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
cd solution/site/scripts/mockup
|
||||||
|
python3 patch_stay.py
|
||||||
|
|
||||||
|
C=o2o-web4ai-solution-prerender
|
||||||
|
docker exec $C mkdir -p /app/solution/site/out/s/stay/img/people \
|
||||||
|
/app/solution/site/out/s/stay/img/mirror /app/solution/site/out/s/stay/audio \
|
||||||
|
/app/solution/site/out/s/stay/vendor
|
||||||
|
docker cp build/index.html $C:/app/solution/site/out/s/stay/index.html
|
||||||
|
docker cp img/. $C:/app/solution/site/out/s/stay/img/
|
||||||
|
docker cp audio/. $C:/app/solution/site/out/s/stay/audio/
|
||||||
|
docker cp vendor/. $C:/app/solution/site/out/s/stay/vendor/
|
||||||
|
|
||||||
|
node audit-all.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
→ `http://localhost/s/stay`
|
||||||
|
|
||||||
|
★ **이 시연본은 레포 안에서 자급자족한다.** 번들 css/js 와 미러 사진 61장까지
|
||||||
|
`vendor/` · `img/mirror/` 에 들어 있고, `patch_stay.py` 가 경로를 `/s/stay/…` 로 바꿔 박는다.
|
||||||
|
그 기계에서 구운 번들 해시와 **무관하게** 뜬다.
|
||||||
|
(2026-09-11 이전 판은 `/assets/…` 를 가리켜 다른 기계에서 흰 화면이었다.)
|
||||||
|
|
||||||
|
### 내용을 바꾼 뒤 (전체 굽기)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 build_itinerary.py && python3 build_story.py && python3 patch_stay.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**순서를 지킨다.** `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
|
||||||
|
node audit-all.mjs # 34개 항목
|
||||||
|
node rails-test.mjs # 카로셀 13개
|
||||||
|
```
|
||||||
|
|
||||||
|
`/s/` 는 **5분 캐시**(`max-age=300`)다 — 확인할 때 ⌘⇧R.
|
||||||
|
|
||||||
|
### 킹서버
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar czf /tmp/stay-bundle.tgz -C . build/index.html img audio vendor
|
||||||
|
scp -C /tmp/stay-bundle.tgz King_admin:~/data2/o2o-site-AEO/tmp-stay-deploy/
|
||||||
|
ssh King_admin "cd ~/data2/o2o-site-AEO/tmp-stay-deploy && rm -rf build img audio vendor && tar xzf stay-bundle.tgz
|
||||||
|
C=o2o-web4ai-solution-prerender
|
||||||
|
docker exec \$C mkdir -p /app/solution/site/out/s/stay/img/people /app/solution/site/out/s/stay/img/mirror \
|
||||||
|
/app/solution/site/out/s/stay/audio /app/solution/site/out/s/stay/vendor
|
||||||
|
docker cp build/index.html \$C:/app/solution/site/out/s/stay/index.html
|
||||||
|
docker cp img/. \$C:/app/solution/site/out/s/stay/img/
|
||||||
|
docker cp audio/. \$C:/app/solution/site/out/s/stay/audio/
|
||||||
|
docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
|
||||||
|
```
|
||||||
|
|
||||||
|
확인: `md5 -q build/index.html` 과
|
||||||
|
`ssh King_admin "docker exec o2o-web4ai-solution-site md5sum /srv/sites/s/stay/index.html"` 이 같아야 한다.
|
||||||
|
그다음 `node audit-all.mjs https://web4ai.o2osolution.ai/s/stay`.
|
||||||
|
|
||||||
|
되돌리기: 컨테이너 안에 `index.html.bak-20260910` 이 있다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.3 무엇을 어디서 고치나
|
||||||
|
|
||||||
|
### 히어로 문구 100개 → `patch_stay.py`
|
||||||
|
|
||||||
|
| 자리 | 줄 | 무엇 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GENERAL` | `:20` | 일반 40개 |
|
||||||
|
| `SEASON` | `:62` | 계절별 24개 — 봄/여름/가을/겨울 각 6 |
|
||||||
|
| `MONTH` | `:72` | 월별 24개 — 1~12월 각 2 |
|
||||||
|
| `WEATHER` | `:87` | 날씨별 12개 — 맑음/구름많음/흐림/비/눈 |
|
||||||
|
|
||||||
|
문장만 더하거나 지우면 된다. 개수 제한은 없다.
|
||||||
|
순환 순서·계절 구분·날씨 연동은 **1.3 T1** 에 적어 둔 그대로다(`inject.js`).
|
||||||
|
|
||||||
|
### 여행 일정 → `build_itinerary.py`
|
||||||
|
|
||||||
|
**사람은 "어느 자리에 어느 장소" 만 고르고, 시각은 스크립트가 계산한다.**
|
||||||
|
|
||||||
|
| 자리 | 줄 | 무엇 |
|
||||||
|
|---|---|---|
|
||||||
|
| `PLACES` | `:40` | **장소 대장** — 좌표 · 지도 검색어 · 체류시간(min/base/max) · **거기서 무엇을 하는지** |
|
||||||
|
| `STAY_ACTION` | `:108` | 머뭄 칸의 행위 — 체크인 · 아침 · 아침(퇴실) · 쉼 · 쉬는 날 · 머뭄으로 · 마무리 |
|
||||||
|
| `WINDOW` | `:156` | 시간대가 정해진 자리 |
|
||||||
|
| `SKELETON` | `:182` | 하루 뼈대. `@` 가 붙은 것은 머뭄 |
|
||||||
|
| `THEMES` | `:368` | 테마 21개 |
|
||||||
|
| `PACING` | `:470` | 테마별 시작 시각과 체류 배율 |
|
||||||
|
|
||||||
|
**새 장소** → `PLACES` 에 한 줄. 좌표는 카카오 로컬에서 받은 실제 값이어야 한다(지어내지 않는다).
|
||||||
|
그다음 `THEMES` 의 원하는 자리에 이름을 적는다.
|
||||||
|
**새 테마** → `THEMES` 에 한 덩이, `PACING` 에 한 줄. 뼈대의 `@` 없는 자리 수와 장소 개수가
|
||||||
|
같아야 한다 — 틀리면 빌드가 멈추고 알려 준다.
|
||||||
|
|
||||||
|
### 가요 다방 · 인물 열전 → `build_story.py`
|
||||||
|
|
||||||
|
| 자리 | 줄 | 무엇 |
|
||||||
|
|---|---|---|
|
||||||
|
| `SONGS` | `:28` | 25곡 — 곡명·가수·작사·작곡·연도·라벨색·한 줄 |
|
||||||
|
| `PEOPLE` | `:109` | 57명 — 위키 문서 제목 · 표시 이름 · 연도 · 하는 일 · 한 줄 |
|
||||||
|
|
||||||
|
- 연도·작사·작곡을 모르면 **비운다**("미상"이라고 적지 않는다 — 렌더러가 없는 칸을 안 그린다).
|
||||||
|
- 듣기 링크는 **유튜브 검색 주소**다. 영상 ID 를 박으면 틀린 영상이 붙을 수 있다.
|
||||||
|
- 인물 사진은 두 곳 — ① `people-photos.json`(위키 문서 대표 사진 17장, 라이선스 확인분)
|
||||||
|
② 원본 payload 에 있던 미러 사진(채만식). 새로 가져오는 게 아니라 자리로 돌리는 것이다.
|
||||||
|
- ★ 이름만으로 커먼즈를 검색하지 않는다(**1.3 T3**).
|
||||||
|
- 숙소가 만든 곡은 이 섹션에 넣지 않는다. 도시의 노래를 모으는 자리다.
|
||||||
|
|
||||||
|
### 객실 사진 → `patch_stay.py` `ROOM_PHOTOS`(`:142`) · `rooms.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.4 규칙 16종 — 어기면 빌드가 멈춘다
|
||||||
|
|
||||||
|
`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 | 아이 동반 테마에 술집 금지 | |
|
||||||
|
| **15** | **끼니 사이 3시간 이상** | 늦은 점심 15:47 종료 → 저녁 17:30. 창은 둘 다 합법인데 배가 안 꺼진다 |
|
||||||
|
| **16** | **볼거리는 편도 이동보다 오래 머문다**(끼니 제외) | 사장님 지적 — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리라 뺀다 |
|
||||||
|
|
||||||
|
★ **왜 빌드 안인가** — 전에는 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다.
|
||||||
|
그래서 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생겼고 사장님이 화면에서 먼저 찾으셨다.
|
||||||
|
**검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다.**
|
||||||
|
|
||||||
|
`audit-all.mjs` 는 그다음 단계다 — 구워진 HTML 을 **실제 브라우저로 열어** 34개 항목을 센다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.5 주입분(`inject.js` · `inject.css`) 다섯
|
||||||
|
|
||||||
|
렌더러에 없어서 이 목업에만 얹은 것이다. 제품으로 옮길 자리는 1부에 적었다.
|
||||||
|
|
||||||
|
1. **캐치프레이즈 순환** — React 가 그린 `<p>` 를 찾아 단어 단위로 갈아 끼운다 → T1
|
||||||
|
2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡 → T4
|
||||||
|
3. **링크 걸기** — 일정 정거장 187개 · 엽서 4개를 네이버 지도로
|
||||||
|
4. **사진 저작자 표시** — 이 목업이 물고 있는 번들이 옛 판이라 `imageCredit` 을 안 그린다 → T3
|
||||||
|
5. **카로셀 제어** — 아래
|
||||||
|
|
||||||
|
### 카로셀 제어 (`tameRails`)
|
||||||
|
|
||||||
|
렌더러의 자동 넘김(`site/src/lib/ui/use-rail-autoplay.ts`)은 4.5초 **고정 타이머**이고,
|
||||||
|
정지는 `held` 카운터 하나로 센다 — 레일 위 `pointerdown` 에 올리고 window `pointerup`·
|
||||||
|
`pointercancel` 에 내린다. 주입분은 타이머를 건드리지 않고 **그 카운터만** 쓴다
|
||||||
|
(`pointerdown` 을 `button: 2` 로 쏜다 — embla 는 주 버튼이 아닌 것을 드래그로 치지 않는다).
|
||||||
|
구현이 둘(embla · 스크롤 상자)이어도 카운터는 하나라 한 번에 걸린다.
|
||||||
|
|
||||||
|
| | 언제 | 얼마나 |
|
||||||
|
|---|---|---|
|
||||||
|
| 쿨타임 | 무엇이든 만지면(세로 스크롤·휠·키보드 포함) | 7초 |
|
||||||
|
| 손이 닿아 있는 동안 | 손가락·마우스 버튼이 내려가 있는 내내 | 뗄 때까지 |
|
||||||
|
| 영구 정지 | **레일이 실제로 가로로 8px 넘게 움직였을 때** | 그 레일은 끝 |
|
||||||
|
|
||||||
|
★ **손가락 수로 센다 — pointer 로 세면 안 된다.** 브라우저는 터치가 페이지 스크롤로
|
||||||
|
넘어가는 순간 **손가락이 아직 닿아 있는데 `pointercancel` 을 쏜다.** 훅의 정지가 거기서
|
||||||
|
풀려 손가락 밑에서 카드가 넘어간다. 실측: touchstart 0.0s → pointercancel 7.5s →
|
||||||
|
8.4s 에 넘어감. 그래서 `event.touches.length` 로 센다.
|
||||||
|
|
||||||
|
★ **"만졌다"와 "밀었다"를 가른다.** 전에는 레일 위 pointerdown·wheel 이면 곧바로 영구
|
||||||
|
정지였다. 그런데 일정 카로셀은 화면 가운데를 가득 채워서 페이지를 세로로 내릴 때 손가락·
|
||||||
|
커서가 거의 언제나 그 위를 지난다 — **한 번 스크롤하면 그 레일이 영영 멈췄다**
|
||||||
|
(실측: 세로 휠 한 번 → 20초 0px).
|
||||||
|
|
||||||
|
★ **호버 정지는 렌더러 것이고 그대로 둔다** — 커서를 얹으면 "읽는 중"이 맞다(사장님 확인).
|
||||||
|
|
||||||
|
검증: `node rails-test.mjs`.
|
||||||
|
→ **운영 사이트에는 이 문제들이 그대로 있다.** 훅을 고치는 것은 별도 작업이다(**3.4**).
|
||||||
|
|
||||||
|
### 주입분을 쓸 때 지킬 것
|
||||||
|
|
||||||
|
★ **React 가 다시 그리면 사라진다.** 그래서
|
||||||
|
- 플레이어 목록·패널은 `#root` **밖**(body 직속)에 둔다
|
||||||
|
- 헤더에 꽂는 단추는 `MutationObserver` 로 감시해 다시 꽂는다
|
||||||
|
- 캐치프레이즈는 노드를 들고 있지 않고 **매 순환마다 다시 찾는다**
|
||||||
|
|
||||||
|
★ **`window.load` 를 기다리지 않는다.** 경로 지도 때문에 OSM 타일 `<img>` 가 140장이라
|
||||||
|
`load` 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.6 밟으면 조용히 틀리는 자리
|
||||||
|
|
||||||
|
- **`out/assets` 에 파일을 넣지 않는다.** 거기는 프리렌더가 관리한다(해시 번들 대장 · 30일 보관).
|
||||||
|
손으로 넣은 사진·음원·번들은 `/s/stay/…` 에 둔다 — nginx `^~ /s/` 가 그대로 서빙하고
|
||||||
|
(Range 206 확인) 정리 대상이 아니다.
|
||||||
|
- **마크업을 고쳐 지우지 않는다.** React 가 payload 로 다시 그리면서 되돌린다.
|
||||||
|
화면에서 없애야 하면 **주입 CSS** 로 끈다(이용 정보 맨 아래 가로선이 그 경우다).
|
||||||
|
- **렌더러가 21시를 넘기는 칸을 조용히 버린다**(`shared/section-data.ts` `PLAN_ENDS_BY`).
|
||||||
|
적어 놓고 화면에 안 나오는 칸이 생긴다 — 규칙 7이 이걸 막는다.
|
||||||
|
- **`imageCredit` 은 문자열이다**(`site/src/sections/items/common.tsx:206`).
|
||||||
|
객체를 넣으면 조용히 안 나오고, 그건 라이선스 위반이 된다.
|
||||||
|
- **크롤러가 보는 HTML 은 옛 내용이다.** 이 페이지는 payload 로 하이드레이션해서 화면을
|
||||||
|
바꾸는 것이라, SSR 마크업과 `llms.txt` 는 구운 날 그대로다. 시연에는 문제없지만
|
||||||
|
검색·AI 에 인용되는 값은 아니다.
|
||||||
|
- **`og:image`** 도 마찬가지다. 화면은 새 사진, 카톡 공유 썸네일만 옛것이다.
|
||||||
|
|
||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3부 — 공통
|
||||||
|
|
||||||
|
## 3.1 보고 규칙 — "했다" 고 말하기 전에
|
||||||
|
|
||||||
|
2026-09-11 지시. **이 규칙을 어긴 기록이 3.2 에 있다.**
|
||||||
|
|
||||||
|
1. **실행하지 않은 것을 됐다고 쓰지 않는다.** 코드에서 빠진 것을 보는 건 확인이 아니다.
|
||||||
|
화면을 열어 세거나 명령을 돌려서 **숫자·출력**을 얻은 뒤에 말한다.
|
||||||
|
2. **"했다" 옆에 무엇으로 확인했는지 같이 쓴다.** `audit-all.mjs 34/34` · `md5 일치` ·
|
||||||
|
`20초 동안 0px` 처럼. 근거를 못 쓰면 아직 안 끝난 것이다.
|
||||||
|
3. **절차를 안내할 때는 그 절차를 먼저 밟아 본다.**
|
||||||
|
4. **일부만 했으면 일부만 했다고 쓴다.** 못 한 것을 빼고 요약하지 않는다.
|
||||||
|
5. **사장님이 본 것과 내가 잰 것이 다르면, 내가 잰 것을 먼저 의심한다.**
|
||||||
|
범위를 좁혀서 재지 말고 전수로 다시 센다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.2 실제로 틀렸던 방식 — 되풀이하지 말 것
|
||||||
|
|
||||||
|
| 한 짓 | 어떻게 틀렸나 | 대신 할 것 |
|
||||||
|
|---|---|---|
|
||||||
|
| 코드에서 빠진 것만 보고 "고쳤다" 고 보고 | 화면에는 그대로 있었다(캐치프레이즈 빨간 줄) | `node audit-all.mjs` 로 **브라우저에서** 확인하고 숫자를 적는다 |
|
||||||
|
| 안 띄워 보고 "이것만 하면 나온다" 고 안내 | 미러 사진 61장·번들이 레포 밖이라 다른 기계에서 흰 화면 | 절차를 **그대로 밟아 본 뒤** 쓴다 |
|
||||||
|
| 지시받은 한 줄만 고침 | "1번은 숙소" 를 넣으면서 그 하루 전체를 안 읽어 숙소가 3번 서고 두 칸이 연달아 섰다 | 한 곳을 고치면 **같은 종류를 전수 검사**한다 |
|
||||||
|
| 규칙 검사를 매번 일회용 스크립트로 | 뼈대를 고칠 때마다 "이번에 안 본 규칙" 이 생겼다 | 검사를 **빌드 안**에 둔다(`audit()`) |
|
||||||
|
| 6초만 재고 "쿨타임 된다" 고 보고 | 쿨타임이 7초라 재개를 못 본 것 | 기대값보다 **길게** 재고 구간을 찍는다 |
|
||||||
|
| 사진을 눈으로 보고 A/B 배정 | 11/5 가 나왔는데 실제는 12/10 | 등록본 데이터(`rooms.json`)를 쓴다 |
|
||||||
|
| 마크업을 고쳐서 요소 제거 | 하이드레이션이 되돌렸다 | 주입 CSS 로 끈다 |
|
||||||
|
| 이름으로 사진 검색 | 동명이인이 붙었다 | 그 사람 문서의 대표 사진만 |
|
||||||
|
|
||||||
|
### 일정이 왜 틀렸나 (2026-09-11 검수 결과)
|
||||||
|
|
||||||
|
사장님이 화면에서 먼저 찾으신 것:
|
||||||
|
|
||||||
|
| | 증상 | 몇 건 |
|
||||||
|
|---|---|---|
|
||||||
|
| A | `아침 · 머뭄` 다음 칸이 `체크아웃 · 머뭄` — 같은 자리에 두 칸이 연달아 섰다 | 1 |
|
||||||
|
| B | 마지막 날 머뭄이 3번(아침 · 체크아웃 · 마무리) — 하루의 절반이 숙소 칸 | 21 |
|
||||||
|
| C | 마지막 날 섬 코스가 저녁도 없이 19:14 에 돌아와 짐만 찾고 끝 | 1 |
|
||||||
|
| D | 아침 칸이 2시간 14분 — 끼니 시각을 맞추려고 앞 칸을 늘리다 생긴 값 | 1 |
|
||||||
|
|
||||||
|
**A·B 의 원인은 하나다.** "1번은 숙소" 지시를 받고 하루의 첫 칸에 `아침 · 머뭄` 을 넣었는데,
|
||||||
|
마지막 날 뼈대에는 **이미** `체크아웃 · 머뭄` 과 `마무리 · 머뭄` 이 있었다.
|
||||||
|
첫 칸만 보고 넣고 그 하루 전체를 다시 읽지 않았다.
|
||||||
|
**C·D 는 검사에 그 항목이 없어서** 통과했다.
|
||||||
|
|
||||||
|
고친 것 — 마지막 날 아침과 퇴실을 **한 칸으로 합쳤다**(`아침 · 스테이 머뭄`,
|
||||||
|
"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요").
|
||||||
|
섬 가는 마지막 날은 바깥 정거장을 둘로 줄였다. 아침 상한을 110분으로 내렸다.
|
||||||
|
그리고 **검사를 빌드 안으로 옮겼다**(2.4).
|
||||||
|
|
||||||
|
### 지시를 주실 때 — 이렇게 주시면 한 번에 끝납니다
|
||||||
|
|
||||||
|
1. **"X 고치고, 그 화면 전체를 다시 훑어서 같은 종류로 이상한 데 있으면 같이 잡아."**
|
||||||
|
'같은 종류'라는 말이 있으면 한 건이 아니라 그 규칙을 어긴 **전수**를 찾는다.
|
||||||
|
2. **"브라우저로 열어서 확인하고 말해." / "검증 결과를 숫자로 보여줘."**
|
||||||
|
3. **규칙을 주실 때 예외까지 같이.** "1번은 항상 머뭄. 단 마지막 날은 퇴실이 있으니
|
||||||
|
그건 네가 판단해서 제안하고 물어봐." — 예외가 안 정해져 있어서 칸을 하나 더 만들었고
|
||||||
|
그게 연속 칸이 됐다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.3 파일 목록 · 지울 것 · git
|
||||||
|
|
||||||
|
### 쓰는 파일
|
||||||
|
|
||||||
|
| 파일 | 무엇 |
|
||||||
|
|---|---|
|
||||||
|
| `README.md` | **이 문서.** 지침은 전부 여기 |
|
||||||
|
| `TEXT.md` | 화면에 나가는 텍스트 전문 (생성물 — `python3 dump_text.py`) |
|
||||||
|
| `build_itinerary.py` | 여행 일정 계산 + 규칙 16종 감사 |
|
||||||
|
| `build_story.py` | 가요 다방 · 인물 열전 + 위키 사진 |
|
||||||
|
| `patch_stay.py` | 캐치프레이즈 · 객실 · 자작곡 · HTML 조립 · 자산 경로 교정 |
|
||||||
|
| `dump_text.py` | `TEXT.md` 생성 |
|
||||||
|
| `inject.css` · `inject.js` | 화면에 덧대는 다섯 가지 |
|
||||||
|
| `audit-all.mjs` | 브라우저로 34개 항목 전수 점검 |
|
||||||
|
| `rails-test.mjs` | 카로셀 13개 × 데스크톱·모바일 |
|
||||||
|
| `kingcheck.mjs` | 킹서버 반영분 확인 |
|
||||||
|
| `audit_schedule.py` | 일정 전수 검수표 생성 → `SCHEDULE.md` (규칙이 안 보는 논리 12가지) |
|
||||||
|
| `SCHEDULE.md` | 그 결과물 (생성물) |
|
||||||
|
|
||||||
|
### 원본 · 원자료 (건드리지 않는다)
|
||||||
|
|
||||||
|
| 파일 | 무엇 |
|
||||||
|
|---|---|
|
||||||
|
| `stay-payload.json` · `orig/index.html` | **원본.** 되돌릴 때 기준 |
|
||||||
|
| `rooms.json` | 야놀자 등록본에서 뽑은 객실 사진 목록 (A동 12 · B동 10) |
|
||||||
|
| `people-photos.json` · `people-images.json` · `people-images-meta.json` | 위키 문서 사진 + 라이선스 |
|
||||||
|
| `people-raw.json` · `people-rows.json` | 인물 수집 원자료 |
|
||||||
|
| `coords.json` | 카카오 로컬에서 받은 좌표 |
|
||||||
|
|
||||||
|
### 자산 (레포에 **일부러** 올렸다)
|
||||||
|
|
||||||
|
| | 개수 | 왜 올리나 |
|
||||||
|
|---|---|---|
|
||||||
|
| `img/` | 28장 | 2026-09-07 에 목업 자산이 통째로 404 났을 때 복구한 곳이 서버가 아니라 git 워크트리였다 |
|
||||||
|
| `img/people/` | 17장 | 위키 사진. 저작자 표시가 라이선스 조건이라 같이 관리한다 |
|
||||||
|
| `img/mirror/` | 61장 | 히어로·소개·객실. 없으면 **다른 기계에서 흰 화면** |
|
||||||
|
| `audio/` | 5곡 | 사장님 자작곡 |
|
||||||
|
| `vendor/` | 2개 | 번들 css/js. 해시가 달라도 뜨게 하려고 |
|
||||||
|
|
||||||
|
합쳐서 약 77M 이다. 전부 **한 번 넣고 안 바뀌는 파일**이라 git 이 싫어하는 경우가 아니다.
|
||||||
|
자산은 커밋을 따로 떼어 뒀다 — 히스토리에서 덩치를 걷어낼 일이 생기면 그 커밋만 건드린다.
|
||||||
|
|
||||||
|
### git 이그노어
|
||||||
|
|
||||||
|
| 대상 | 어디 | 왜 |
|
||||||
|
|---|---|---|
|
||||||
|
| `build/` | 레포 루트 `.gitignore:30` | 생성물. `patch_stay.py` 로 다시 나온다 |
|
||||||
|
| `stay-payload-new.json` | `scripts/mockup/.gitignore` | 중간 산출물 |
|
||||||
|
|
||||||
|
### 이 시연이 끝나면 지울 것
|
||||||
|
|
||||||
|
목업은 **한시적**이다. 실제 구현(1부)이 끝나면 이 폴더는 통째로 필요 없다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ① 레포에서
|
||||||
|
git rm -r solution/site/scripts/mockup
|
||||||
|
|
||||||
|
# ② 서버 볼륨에서 (킹서버 · 로컬 각각)
|
||||||
|
docker exec o2o-web4ai-solution-prerender rm -rf /app/solution/site/out/s/stay
|
||||||
|
|
||||||
|
# ③ 킹서버 배포 임시 폴더
|
||||||
|
ssh King_admin "rm -rf ~/data2/o2o-site-AEO/tmp-stay-deploy"
|
||||||
|
```
|
||||||
|
|
||||||
|
★ **지우기 전에 1부를 `docs/` 로 옮긴다.** 1부(제품 구현 지침)는 목업이 사라져도 남아야 하는
|
||||||
|
내용이다 — 이 폴더째 지우면 같이 없어진다. 2·3부는 목업과 함께 버린다.
|
||||||
|
|
||||||
|
`stay2` · `stay3` 도 같은 성격의 목업이다. 함께 정리한다.
|
||||||
|
★ 지우기 전에 [CLAUDE.md 의 ★★ 함정](../../../../CLAUDE.md)을 읽는다 —
|
||||||
|
`out/s/**` 이 참조하는 자산을 먼저 빼야 다른 사이트가 안 끊긴다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3.4 남아 있는 판단 (사장님 결정 대기)
|
||||||
|
|
||||||
|
| | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| 하루에 숙소가 3번 서는 날(`아침 → … → 쉼 → 저녁 → 복귀`) | 점심~저녁 4시간을 바깥 두 곳으로 못 채워 넣은 칸이다. 빼려면 오후 정거장을 하나 더 넣어야 한다 |
|
||||||
|
| 가요 다방 25곡 (목표 50) | 출처에서 확인되는 군산 곡이 거기까지다. 더 채우려면 **지어내야 한다** — 하지 않는다 |
|
||||||
|
| 인물 사진 18/57 | 위키 문서에 사진이 있는 사람이 18명뿐이다. 동명이인 함정 때문에 이름 검색으로 늘리지 않는다 |
|
||||||
|
| 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 `use-rail-autoplay.ts` 를 고치고 **전체 재굽기 + `republish_all.py`** |
|
||||||
|
| ~~일정 정거장의 체류 대비 이동~~ | **해결(2026-09-11)** — 규칙 15·16 을 감사에 넣었다. 전수 검수 21/21 (`SCHEDULE.md`) |
|
||||||
|
| 일정을 이미지/링크로 공유 | 이미지 저장은 가능(카드를 PNG 로). 링크는 지금 섹션까지만 간다 — 탭·코스가 URL 에 안 남는다 |
|
||||||
764
solution/site/scripts/mockup/SCHEDULE.md
Normal file
@ -0,0 +1,764 @@
|
|||||||
|
# `/s/stay` 여행 일정 전수 검수표
|
||||||
|
|
||||||
|
생성 `python3 audit_schedule.py` · 테마 21개 · 53일 · 304정거장
|
||||||
|
|
||||||
|
표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).
|
||||||
|
규칙 14종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,
|
||||||
|
이 표는 그 규칙이 **안 보는 논리**를 따로 센다.
|
||||||
|
|
||||||
|
## 결과 — 21/21 검수완료
|
||||||
|
|
||||||
|
**전부 통과.** 12개 항목 위반 0건.
|
||||||
|
|
||||||
|
| 코드 | 검수 항목 |
|
||||||
|
|---|---|
|
||||||
|
| L0 | 21시를 넘겨 화면에서 버려지는 칸이 없다 |
|
||||||
|
| L1 | **체류 ≥ 편도 이동** — 볼거리는 가는 데 든 시간보다 오래 머문다 (끼니 제외 — 밥집은 돌아오는 길에 들르는 자리다) |
|
||||||
|
| L2 | 가운데 날은 점심과 저녁이 둘 다 있다 |
|
||||||
|
| L3 | 점심 끝나고 저녁까지 3시간 이상 |
|
||||||
|
| L4 | 하루의 40% 넘게 길에서 쓰지 않는다 |
|
||||||
|
| L5 | 바깥 정거장 체류 15분 이상 |
|
||||||
|
| L6 | 하루가 14시간을 넘지 않는다 |
|
||||||
|
| L7 | 입실 15:00 이후 · 퇴실 11:00 전 |
|
||||||
|
| L8 | 하루의 첫 칸·마지막 칸이 숙소 |
|
||||||
|
| L9 | 같은 자리가 연달아 서지 않는다 |
|
||||||
|
| L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 |
|
||||||
|
| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섬까지 다녀오는 1박 2일
|
||||||
|
|
||||||
|
1박 2일 · 차를 가져온 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 첫날은 걸어서 도심, 이튿날은 다리를 건너 섬입니다. 두 날의 성격이 아주 다릅니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:00–20:11 (총 5시간 11분 · 이동 36분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:00–15:45 | 체크인 | 스테이 머뭄 | –분 | 45분 |
|
||||||
|
| 2 | 15:48–16:33 | 오후 | 히로쓰 가옥 | 3분 | 45분 |
|
||||||
|
| 3 | 16:37–17:22 | 오후 | 초원사진관 | 4분 | 45분 |
|
||||||
|
| 4 | 17:30–18:40 | 저녁 | 빈해원 | 8분 | 70분 |
|
||||||
|
| 5 | 18:45–19:25 | 밤 산책 | 군산 내항 | 5분 | 40분 |
|
||||||
|
| 6 | 19:41–20:11 | 머뭄으로 | 스테이 머뭄 | 16분 | 30분 |
|
||||||
|
|
||||||
|
**둘째 날** 08:20–16:36 (총 8시간 16분 · 이동 176분 · 정거장 4)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 08:20–09:35 | 아침 | 스테이 머뭄 | –분 | 75분 |
|
||||||
|
| 2 | 10:59–13:29 | 오전 | 고군산군도 선유도 | 84분 | 150분 |
|
||||||
|
| 3 | 14:54–15:49 | 늦은 점심 | 국제반점 | 85분 | 55분 |
|
||||||
|
| 4 | 15:56–16:36 | 마무리 | 스테이 머뭄 | 7분 | 40분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 걸어서만 도는 1박 2일
|
||||||
|
|
||||||
|
1박 2일 · 차 없이 오는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 모든 정거장이 머뭄에서 걸어 닿습니다. 이틀 동안 차를 한 번도 타지 않습니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:00–20:05 (총 5시간 5분 · 이동 40분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:00–15:49 | 체크인 | 스테이 머뭄 | –분 | 49분 |
|
||||||
|
| 2 | 15:52–16:37 | 오후 | 히로쓰 가옥 | 3분 | 45분 |
|
||||||
|
| 3 | 16:41–17:26 | 오후 | 초원사진관 | 4분 | 45분 |
|
||||||
|
| 4 | 17:30–18:22 | 저녁 | 일흥옥 | 4분 | 52분 |
|
||||||
|
| 5 | 18:35–19:17 | 밤 산책 | 군산 내항 | 13분 | 42분 |
|
||||||
|
| 6 | 19:33–20:05 | 머뭄으로 | 스테이 머뭄 | 16분 | 32분 |
|
||||||
|
|
||||||
|
**둘째 날** 09:20–13:52 (총 4시간 32분 · 이동 26분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:20–10:39 | 아침 | 스테이 머뭄 | –분 | 79분 |
|
||||||
|
| 2 | 10:49–11:36 | 오전 | 군산근대건축관 | 10분 | 47분 |
|
||||||
|
| 3 | 11:40–12:32 | 점심 | 한일옥 | 4분 | 52분 |
|
||||||
|
| 4 | 12:36–13:02 | 오후 | 우체통거리 | 4분 | 26분 |
|
||||||
|
| 5 | 13:10–13:52 | 마무리 | 스테이 머뭄 | 8분 | 42분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 비가 와도 되는 1박 2일
|
||||||
|
|
||||||
|
1박 2일 · 비 예보를 보고 오는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 실내가 이어집니다. 비가 그치기를 기다리지 않아도 하루가 찹니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:10–20:57 (총 5시간 47분 · 이동 40분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:10–15:49 | 체크인 | 스테이 머뭄 | –분 | 39분 |
|
||||||
|
| 2 | 16:00–17:28 | 오후 | 군산근대역사박물관 | 11분 | 88분 |
|
||||||
|
| 3 | 17:31–18:15 | 오후 | 군산근대미술관 | 3분 | 44분 |
|
||||||
|
| 4 | 18:20–19:28 | 저녁 | 빈해원 | 5분 | 68분 |
|
||||||
|
| 5 | 19:33–20:12 | 밤 산책 | 군산 내항 | 5분 | 39분 |
|
||||||
|
| 6 | 20:28–20:57 | 머뭄으로 | 스테이 머뭄 | 16분 | 29분 |
|
||||||
|
|
||||||
|
**둘째 날** 09:40–15:36 (총 5시간 56분 · 이동 52분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:40–11:00 | 아침 | 스테이 머뭄 | –분 | 80분 |
|
||||||
|
| 2 | 11:10–12:02 | 오전 | 군산근대건축관 | 10분 | 52분 |
|
||||||
|
| 3 | 12:10–13:07 | 점심 | 일흥옥 | 8분 | 57분 |
|
||||||
|
| 4 | 13:22–14:31 | 오후 | 째보선창 근처 카페 | 15분 | 69분 |
|
||||||
|
| 5 | 14:50–15:36 | 마무리 | 스테이 머뭄 | 19분 | 46분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 근대건축만 보는 1박 2일
|
||||||
|
|
||||||
|
1박 2일 · 건물을 보러 오는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 1900년대 건물만 골라 돕니다. 머뭄도 그 시기에 지은 집입니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:00–20:14 (총 5시간 14분 · 이동 50분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:00–15:38 | 체크인 | 스테이 머뭄 | –분 | 38분 |
|
||||||
|
| 2 | 15:51–16:19 | 오후 | 구 군산세관 본관 | 13분 | 28분 |
|
||||||
|
| 3 | 16:23–17:24 | 오후 | 군산근대건축관 | 4분 | 61분 |
|
||||||
|
| 4 | 17:30–18:41 | 저녁 | 명월갈비 | 6분 | 71분 |
|
||||||
|
| 5 | 18:52–19:30 | 밤 산책 | 군산 내항 | 11분 | 38분 |
|
||||||
|
| 6 | 19:46–20:14 | 머뭄으로 | 스테이 머뭄 | 16분 | 28분 |
|
||||||
|
|
||||||
|
**둘째 날** 08:50–13:35 (총 4시간 45분 · 이동 37분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 08:50–10:03 | 아침 | 스테이 머뭄 | –분 | 73분 |
|
||||||
|
| 2 | 10:12–11:22 | 오전 | 군산근대미술관 | 9분 | 70분 |
|
||||||
|
| 3 | 11:30–12:18 | 점심 | 일흥옥 | 8분 | 48분 |
|
||||||
|
| 4 | 12:30–12:49 | 오후 | 해망굴 | 12분 | 19분 |
|
||||||
|
| 5 | 12:57–13:35 | 마무리 | 스테이 머뭄 | 8분 | 38분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이성당으로 시작하는 1박 2일
|
||||||
|
|
||||||
|
1박 2일 · 빵을 좋아하는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 이튿날 아침을 빵으로 엽니다. 문 여는 시간에 맞춰 걸어갑니다.
|
||||||
|
|
||||||
|
**첫째 날** 16:10–20:36 (총 4시간 26분 · 이동 46분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 16:10–16:46 | 체크인 | 스테이 머뭄 | –분 | 36분 |
|
||||||
|
| 2 | 16:50–17:17 | 오후 | 초원사진관 | 4분 | 27분 |
|
||||||
|
| 3 | 17:23–17:45 | 오후 | 우체통거리 | 6분 | 22분 |
|
||||||
|
| 4 | 17:51–19:03 | 저녁 | 도란 | 6분 | 72분 |
|
||||||
|
| 5 | 19:17–19:53 | 밤 산책 | 군산 내항 | 14분 | 36분 |
|
||||||
|
| 6 | 20:09–20:36 | 머뭄으로 | 스테이 머뭄 | 16분 | 27분 |
|
||||||
|
|
||||||
|
**둘째 날** 08:40–14:26 (총 5시간 46분 · 이동 35분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 08:40–10:28 | 아침 | 스테이 머뭄 | –분 | 108분 |
|
||||||
|
| 2 | 10:35–11:25 | 오전 | 이성당 | 7분 | 50분 |
|
||||||
|
| 3 | 11:30–12:06 | 점심 | 만남스넥 | 5분 | 36분 |
|
||||||
|
| 4 | 12:18–13:39 | 오후 | 군산근대역사박물관 | 12분 | 81분 |
|
||||||
|
| 5 | 13:50–14:26 | 마무리 | 스테이 머뭄 | 11분 | 36분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 철길마을까지 가는 1박 2일
|
||||||
|
|
||||||
|
1박 2일 · 사진을 찍는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 빛이 좋은 자리만 이어 붙였습니다. 철길마을은 오전 빛이 낫습니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:40–20:04 (총 4시간 24분 · 이동 47분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:40–16:18 | 체크인 | 스테이 머뭄 | –분 | 38분 |
|
||||||
|
| 2 | 16:22–16:50 | 오후 | 초원사진관 | 4분 | 28분 |
|
||||||
|
| 3 | 16:58–17:31 | 오후 | 동국사 | 8분 | 33분 |
|
||||||
|
| 4 | 17:41–18:33 | 저녁 | 국제반점 | 10분 | 52분 |
|
||||||
|
| 5 | 18:42–19:20 | 밤 산책 | 군산 내항 | 9분 | 38분 |
|
||||||
|
| 6 | 19:36–20:04 | 머뭄으로 | 스테이 머뭄 | 16분 | 28분 |
|
||||||
|
|
||||||
|
**둘째 날** 08:30–14:06 (총 5시간 36분 · 이동 36분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 08:30–09:47 | 아침 | 스테이 머뭄 | –분 | 77분 |
|
||||||
|
| 2 | 09:59–11:19 | 오전 | 경암동 철길마을 | 12분 | 80분 |
|
||||||
|
| 3 | 11:30–12:18 | 점심 | 일흥옥 | 11분 | 48분 |
|
||||||
|
| 4 | 12:24–13:21 | 오후 | 군산 시간여행마을 | 6분 | 57분 |
|
||||||
|
| 5 | 13:28–14:06 | 마무리 | 스테이 머뭄 | 7분 | 38분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 항구만 도는 1박 2일
|
||||||
|
|
||||||
|
1박 2일 · 바다가 보고 싶은 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 부잔교에서 째보선창까지, 이틀 동안 물가만 걷습니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:40–20:55 (총 5시간 15분 · 이동 67분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:40–16:22 | 체크인 | 스테이 머뭄 | –분 | 42분 |
|
||||||
|
| 2 | 16:38–17:15 | 오후 | 군산 내항 부잔교 | 16분 | 37분 |
|
||||||
|
| 3 | 17:32–17:53 | 오후 | 해망굴 | 17분 | 21분 |
|
||||||
|
| 4 | 18:06–19:20 | 저녁 | 빈해원 | 13분 | 74분 |
|
||||||
|
| 5 | 19:25–20:07 | 밤 산책 | 군산 내항 | 5분 | 42분 |
|
||||||
|
| 6 | 20:23–20:55 | 머뭄으로 | 스테이 머뭄 | 16분 | 32분 |
|
||||||
|
|
||||||
|
**둘째 날** 09:20–14:14 (총 4시간 54분 · 이동 51분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:20–10:39 | 아침 | 스테이 머뭄 | –분 | 79분 |
|
||||||
|
| 2 | 10:58–11:30 | 오전 | 째보선창 | 19분 | 32분 |
|
||||||
|
| 3 | 11:42–12:40 | 점심 | 국제반점 | 12분 | 58분 |
|
||||||
|
| 4 | 12:47–13:19 | 오후 | 구 군산세관 본관 | 7분 | 32분 |
|
||||||
|
| 5 | 13:32–14:14 | 마무리 | 스테이 머뭄 | 13분 | 42분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 소설 『탁류』를 따라 걷는 1박 2일
|
||||||
|
|
||||||
|
1박 2일 · 채만식을 읽어 본 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 소설이 지나간 자리를 순서대로 밟습니다. 세관·선창·문학관이 한 줄로 이어집니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:10–20:19 (총 5시간 9분 · 이동 62분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:10–15:54 | 체크인 | 스테이 머뭄 | –분 | 44분 |
|
||||||
|
| 2 | 16:13–16:46 | 오후 | 째보선창 | 19분 | 33분 |
|
||||||
|
| 3 | 16:51–17:29 | 오후 | 군산 내항 부잔교 | 5분 | 38분 |
|
||||||
|
| 4 | 17:40–18:35 | 저녁 | 한일옥 | 11분 | 55분 |
|
||||||
|
| 5 | 18:46–19:30 | 밤 산책 | 군산 내항 | 11분 | 44분 |
|
||||||
|
| 6 | 19:46–20:19 | 머뭄으로 | 스테이 머뭄 | 16분 | 33분 |
|
||||||
|
|
||||||
|
**둘째 날** 09:00–14:40 (총 5시간 40분 · 이동 60분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:00–10:22 | 아침 | 스테이 머뭄 | –분 | 82분 |
|
||||||
|
| 2 | 10:40–11:46 | 오전 | 채만식문학관 | 18분 | 66분 |
|
||||||
|
| 3 | 12:03–12:58 | 점심 | 일흥옥 | 17분 | 55분 |
|
||||||
|
| 4 | 13:10–13:43 | 오후 | 구 군산세관 본관 | 12분 | 33분 |
|
||||||
|
| 5 | 13:56–14:40 | 마무리 | 스테이 머뭄 | 13분 | 44분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 아이와 함께 1박 2일
|
||||||
|
|
||||||
|
1박 2일 · 아이를 데려온 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 걷는 거리가 짧고, 아이가 지루해할 자리를 뺐습니다.
|
||||||
|
|
||||||
|
**첫째 날** 16:00–19:46 (총 3시간 46분 · 이동 43분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 16:00–16:34 | 체크인 | 스테이 머뭄 | –분 | 34분 |
|
||||||
|
| 2 | 16:38–17:04 | 오후 | 초원사진관 | 4분 | 26분 |
|
||||||
|
| 3 | 17:10–17:31 | 오후 | 우체통거리 | 6분 | 21분 |
|
||||||
|
| 4 | 17:35–18:17 | 저녁 | 만남스넥 | 4분 | 42분 |
|
||||||
|
| 5 | 18:30–19:04 | 밤 산책 | 군산 내항 | 13분 | 34분 |
|
||||||
|
| 6 | 19:20–19:46 | 머뭄으로 | 스테이 머뭄 | 16분 | 26분 |
|
||||||
|
|
||||||
|
**둘째 날** 10:10–14:10 (총 4시간 0분 · 이동 42분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 10:10–11:00 | 아침 | 스테이 머뭄 | –분 | 50분 |
|
||||||
|
| 2 | 11:12–11:54 | 오전 | 경암동 철길마을 | 12분 | 42분 |
|
||||||
|
| 3 | 12:05–12:35 | 점심 | 이성당 | 11분 | 30분 |
|
||||||
|
| 4 | 12:45–13:27 | 오후 | 월명호수 | 10분 | 42분 |
|
||||||
|
| 5 | 13:36–14:10 | 마무리 | 스테이 머뭄 | 9분 | 34분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 늦게 도착하는 1박 2일
|
||||||
|
|
||||||
|
1박 2일 · 퇴근하고 오는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 첫날은 저녁 한 끼로 끝냅니다. 대신 이튿날을 아침부터 저녁까지 씁니다.
|
||||||
|
|
||||||
|
**첫째 날** 18:20–20:55 (총 2시간 35분 · 이동 10분 · 정거장 3)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 18:20–19:00 | 체크인 | 스테이 머뭄 | –분 | 40분 |
|
||||||
|
| 2 | 19:05–20:20 | 저녁 | 명월갈비 | 5분 | 75분 |
|
||||||
|
| 3 | 20:25–20:55 | 머뭄으로 | 스테이 머뭄 | 5분 | 30분 |
|
||||||
|
|
||||||
|
**둘째 날** 08:40–14:39 (총 5시간 59분 · 이동 39분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 08:40–09:55 | 아침 | 스테이 머뭄 | –분 | 75분 |
|
||||||
|
| 2 | 10:06–11:36 | 오전 | 군산근대역사박물관 | 11분 | 90분 |
|
||||||
|
| 3 | 11:42–12:32 | 점심 | 한일옥 | 6분 | 50분 |
|
||||||
|
| 4 | 12:36–13:21 | 오후 | 군산근대건축관 | 4분 | 45분 |
|
||||||
|
| 5 | 13:31–13:51 | 오후 | 해망굴 | 10분 | 20분 |
|
||||||
|
| 6 | 13:59–14:39 | 마무리 | 스테이 머뭄 | 8분 | 40분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 천천히 도는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 쉬러 오는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 하루에 두어 곳만 봅니다. 마당에 앉아 있는 시간을 일정에 넣었습니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:20–21:00 (총 5시간 40분 · 이동 46분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:20–16:06 | 체크인 | 스테이 머뭄 | –분 | 46분 |
|
||||||
|
| 2 | 16:09–16:44 | 오후 | 히로쓰 가옥 | 3분 | 35분 |
|
||||||
|
| 3 | 16:50–17:30 | 오후 | 동국사 | 6분 | 40분 |
|
||||||
|
| 4 | 17:37–19:09 | 저녁 | 도란 | 7분 | 92분 |
|
||||||
|
| 5 | 19:23–20:09 | 밤 산책 | 군산 내항 | 14분 | 46분 |
|
||||||
|
| 6 | 20:25–21:00 | 머뭄으로 | 스테이 머뭄 | 16분 | 35분 |
|
||||||
|
|
||||||
|
**둘째 날** 09:40–19:27 (총 9시간 47분 · 이동 32분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:40–13:23 | 머뭄에서 쉬는 날 | 스테이 머뭄 | –분 | 223분 |
|
||||||
|
| 2 | 13:28–14:21 | 점심 | 한일옥 | 5분 | 53분 |
|
||||||
|
| 3 | 14:26–17:19 | 쉼 | 스테이 머뭄 | 5분 | 173분 |
|
||||||
|
| 4 | 17:30–18:44 | 저녁 | 빈해원 | 11분 | 74분 |
|
||||||
|
| 5 | 18:55–19:27 | 머뭄으로 | 스테이 머뭄 | 11분 | 32분 |
|
||||||
|
|
||||||
|
**셋째 날** 09:50–15:05 (총 5시간 15분 · 이동 26분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:50–11:00 | 아침 | 스테이 머뭄 | –분 | 70분 |
|
||||||
|
| 2 | 11:07–11:51 | 오전 | 이성당 | 7분 | 44분 |
|
||||||
|
| 3 | 11:56–12:46 | 점심 | 만남스넥 | 5분 | 50분 |
|
||||||
|
| 4 | 12:53–14:08 | 오후 | 군산 시간여행마을 | 7분 | 75분 |
|
||||||
|
| 5 | 14:15–15:05 | 마무리 | 스테이 머뭄 | 7분 | 50분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섬과 도심을 반씩 도는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 차를 가져온 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 가운데 날을 섬에 다 씁니다. 첫날과 마지막 날은 걸어서 도심입니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:10–20:11 (총 5시간 1분 · 이동 41분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:10–15:50 | 체크인 | 스테이 머뭄 | –분 | 40분 |
|
||||||
|
| 2 | 16:03–16:33 | 오후 | 구 군산세관 본관 | 13분 | 30분 |
|
||||||
|
| 3 | 16:37–17:27 | 오후 | 군산근대건축관 | 4분 | 50분 |
|
||||||
|
| 4 | 17:30–18:40 | 저녁 | 빈해원 | 3분 | 70분 |
|
||||||
|
| 5 | 18:45–19:25 | 밤 산책 | 군산 내항 | 5분 | 40분 |
|
||||||
|
| 6 | 19:41–20:11 | 머뭄으로 | 스테이 머뭄 | 16분 | 30분 |
|
||||||
|
|
||||||
|
**둘째 날** 07:20–20:37 (총 13시간 17분 · 이동 209분 · 정거장 7)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 07:20–08:20 | 아침 | 스테이 머뭄 | –분 | 60분 |
|
||||||
|
| 2 | 09:44–12:14 | 오전 | 고군산군도 선유도 | 84분 | 150분 |
|
||||||
|
| 3 | 12:34–13:24 | 오후 | 장자도 | 20분 | 50분 |
|
||||||
|
| 4 | 14:52–15:47 | 늦은 점심 | 국제반점 | 88분 | 55분 |
|
||||||
|
| 5 | 15:54–18:42 | 쉼 | 스테이 머뭄 | 7분 | 168분 |
|
||||||
|
| 6 | 18:47–20:02 | 저녁 | 명월갈비 | 5분 | 75분 |
|
||||||
|
| 7 | 20:07–20:37 | 머뭄으로 | 스테이 머뭄 | 5분 | 30분 |
|
||||||
|
|
||||||
|
**셋째 날** 09:20–13:46 (총 4시간 26분 · 이동 19분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:20–10:37 | 아침 | 스테이 머뭄 | –분 | 77분 |
|
||||||
|
| 2 | 10:41–11:26 | 오전 | 초원사진관 | 4분 | 45분 |
|
||||||
|
| 3 | 11:30–12:20 | 점심 | 일흥옥 | 4분 | 50분 |
|
||||||
|
| 4 | 12:24–12:59 | 오후 | 이성당 | 4분 | 35분 |
|
||||||
|
| 5 | 13:06–13:46 | 마무리 | 스테이 머뭄 | 7분 | 40분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 골목을 세 번 나눠 걷는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 차 없이 오는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 원도심을 세 덩이로 나눠 하루에 하나씩 걷습니다. 같은 길을 두 번 걷지 않습니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:00–20:11 (총 5시간 11분 · 이동 40분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:00–15:49 | 체크인 | 스테이 머뭄 | –분 | 49분 |
|
||||||
|
| 2 | 15:52–16:37 | 오후 | 히로쓰 가옥 | 3분 | 45분 |
|
||||||
|
| 3 | 16:41–17:26 | 오후 | 초원사진관 | 4분 | 45분 |
|
||||||
|
| 4 | 17:30–18:25 | 저녁 | 일흥옥 | 4분 | 55분 |
|
||||||
|
| 5 | 18:38–19:22 | 밤 산책 | 군산 내항 | 13분 | 44분 |
|
||||||
|
| 6 | 19:38–20:11 | 머뭄으로 | 스테이 머뭄 | 16분 | 33분 |
|
||||||
|
|
||||||
|
**둘째 날** 09:10–19:30 (총 10시간 20분 · 이동 49분 · 정거장 8)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:10–10:16 | 아침 | 스테이 머뭄 | –분 | 66분 |
|
||||||
|
| 2 | 10:27–12:06 | 오전 | 군산근대역사박물관 | 11분 | 99분 |
|
||||||
|
| 3 | 12:12–13:07 | 점심 | 한일옥 | 6분 | 55분 |
|
||||||
|
| 4 | 13:11–14:01 | 오후 | 군산근대건축관 | 4분 | 50분 |
|
||||||
|
| 5 | 14:11–14:33 | 오후 | 해망굴 | 10분 | 22분 |
|
||||||
|
| 6 | 14:41–17:25 | 쉼 | 스테이 머뭄 | 8분 | 164분 |
|
||||||
|
| 7 | 17:30–18:52 | 저녁 | 명월갈비 | 5분 | 82분 |
|
||||||
|
| 8 | 18:57–19:30 | 머뭄으로 | 스테이 머뭄 | 5분 | 33분 |
|
||||||
|
|
||||||
|
**셋째 날** 08:50–14:18 (총 5시간 28분 · 이동 26분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 08:50–10:38 | 아침 | 스테이 머뭄 | –분 | 108분 |
|
||||||
|
| 2 | 10:46–11:26 | 오전 | 우체통거리 | 8분 | 40분 |
|
||||||
|
| 3 | 11:30–12:14 | 점심 | 만남스넥 | 4분 | 44분 |
|
||||||
|
| 4 | 12:21–13:27 | 오후 | 군산 시간여행마을 | 7분 | 66분 |
|
||||||
|
| 5 | 13:34–14:18 | 마무리 | 스테이 머뭄 | 7분 | 44분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 먹으러 오는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 맛집을 도는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 끼니가 중심입니다. 사이에 걸을 자리를 넣어 배를 비웁니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:30–20:56 (총 5시간 26분 · 이동 40분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:30–16:16 | 체크인 | 스테이 머뭄 | –분 | 46분 |
|
||||||
|
| 2 | 16:20–17:00 | 오후 | 물밀소 | 4분 | 40분 |
|
||||||
|
| 3 | 17:06–17:40 | 오후 | 초원사진관 | 6분 | 34분 |
|
||||||
|
| 4 | 17:43–19:09 | 저녁 | 명월갈비 | 3분 | 86분 |
|
||||||
|
| 5 | 19:20–20:06 | 밤 산책 | 군산 내항 | 11분 | 46분 |
|
||||||
|
| 6 | 20:22–20:56 | 머뭄으로 | 스테이 머뭄 | 16분 | 34분 |
|
||||||
|
|
||||||
|
**둘째 날** 10:20–19:35 (총 9시간 15분 · 이동 50분 · 정거장 8)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 10:20–11:29 | 아침 | 스테이 머뭄 | –분 | 69분 |
|
||||||
|
| 2 | 11:36–12:45 | 오전 | 군산 시간여행마을 | 7분 | 69분 |
|
||||||
|
| 3 | 12:51–13:48 | 점심 | 일흥옥 | 6분 | 57분 |
|
||||||
|
| 4 | 13:52–14:32 | 오후 | 이성당 | 4분 | 40분 |
|
||||||
|
| 5 | 14:35–15:04 | 오후 | 우체통거리 | 3분 | 29분 |
|
||||||
|
| 6 | 15:12–17:19 | 쉼 | 스테이 머뭄 | 8분 | 127분 |
|
||||||
|
| 7 | 17:30–18:50 | 저녁 | 빈해원 | 11분 | 80분 |
|
||||||
|
| 8 | 19:01–19:35 | 머뭄으로 | 스테이 머뭄 | 11분 | 34분 |
|
||||||
|
|
||||||
|
**셋째 날** 08:40–15:18 (총 6시간 38분 · 이동 32분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 08:40–10:24 | 아침 | 스테이 머뭄 | –분 | 104분 |
|
||||||
|
| 2 | 10:30–11:20 | 오전 | 동국사 | 6분 | 50분 |
|
||||||
|
| 3 | 11:30–12:33 | 점심 | 국제반점 | 10분 | 63분 |
|
||||||
|
| 4 | 12:38–14:21 | 오후 | 군산근대역사박물관 | 5분 | 103분 |
|
||||||
|
| 5 | 14:32–15:18 | 마무리 | 스테이 머뭄 | 11분 | 46분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 아무 데도 안 가는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 쉬려고만 오는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 가운데 날은 대문 밖으로 두 번만 나갑니다. 나머지는 마당과 카페 공간에서 보냅니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:00–21:00 (총 6시간 0분 · 이동 40분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:00–15:50 | 체크인 | 스테이 머뭄 | –분 | 50분 |
|
||||||
|
| 2 | 15:53–16:38 | 오후 | 히로쓰 가옥 | 3분 | 45분 |
|
||||||
|
| 3 | 16:42–17:27 | 오후 | 초원사진관 | 4분 | 45분 |
|
||||||
|
| 4 | 17:30–19:06 | 저녁 | 도란 | 3분 | 96분 |
|
||||||
|
| 5 | 19:20–20:08 | 밤 산책 | 군산 내항 | 14분 | 48분 |
|
||||||
|
| 6 | 20:24–21:00 | 머뭄으로 | 스테이 머뭄 | 16분 | 36분 |
|
||||||
|
|
||||||
|
**둘째 날** 09:30–19:03 (총 9시간 33분 · 이동 22분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:30–13:22 | 머뭄에서 쉬는 날 | 스테이 머뭄 | –분 | 232분 |
|
||||||
|
| 2 | 13:28–14:12 | 점심 | 만남스넥 | 6분 | 44분 |
|
||||||
|
| 3 | 14:18–17:25 | 쉼 | 스테이 머뭄 | 6분 | 187분 |
|
||||||
|
| 4 | 17:30–18:25 | 저녁 | 일흥옥 | 5분 | 55분 |
|
||||||
|
| 5 | 18:30–19:03 | 머뭄으로 | 스테이 머뭄 | 5분 | 33분 |
|
||||||
|
|
||||||
|
**셋째 날** 09:00–14:34 (총 5시간 34분 · 이동 27분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:00–10:38 | 아침 | 스테이 머뭄 | –분 | 98분 |
|
||||||
|
| 2 | 10:44–11:30 | 오전 | 동국사 | 6분 | 46분 |
|
||||||
|
| 3 | 11:33–12:19 | 점심 | 물밀소 | 3분 | 46분 |
|
||||||
|
| 4 | 12:28–13:33 | 오후 | 월명호수 | 9분 | 65분 |
|
||||||
|
| 5 | 13:42–14:34 | 마무리 | 스테이 머뭄 | 9분 | 52분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사진만 찍는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 사진을 찍는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 시간대에 맞춰 자리를 배치했습니다. 철길마을은 오전, 부잔교는 해 질 때입니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:50–20:03 (총 4시간 13분 · 이동 49분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:50–16:22 | 체크인 | 스테이 머뭄 | –분 | 32분 |
|
||||||
|
| 2 | 16:26–16:50 | 오후 | 초원사진관 | 4분 | 24분 |
|
||||||
|
| 3 | 16:58–17:26 | 오후 | 동국사 | 8분 | 28분 |
|
||||||
|
| 4 | 17:33–18:37 | 저녁 | 도란 | 7분 | 64분 |
|
||||||
|
| 5 | 18:51–19:23 | 밤 산책 | 군산 내항 | 14분 | 32분 |
|
||||||
|
| 6 | 19:39–20:03 | 머뭄으로 | 스테이 머뭄 | 16분 | 24분 |
|
||||||
|
|
||||||
|
**둘째 날** 09:40–18:46 (총 9시간 6분 · 이동 81분 · 정거장 8)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:40–10:28 | 아침 | 스테이 머뭄 | –분 | 48분 |
|
||||||
|
| 2 | 10:40–11:20 | 오전 | 경암동 철길마을 | 12분 | 40분 |
|
||||||
|
| 3 | 11:31–12:14 | 점심 | 만남스넥 | 11분 | 43분 |
|
||||||
|
| 4 | 12:29–13:14 | 오후 | 째보선창 | 15분 | 45분 |
|
||||||
|
| 5 | 13:35–14:05 | 오후 | 해망굴 | 21분 | 30분 |
|
||||||
|
| 6 | 14:13–17:23 | 쉼 | 스테이 머뭄 | 8분 | 190분 |
|
||||||
|
| 7 | 17:30–18:15 | 저녁 | 국제반점 | 7분 | 45분 |
|
||||||
|
| 8 | 18:22–18:46 | 머뭄으로 | 스테이 머뭄 | 7분 | 24분 |
|
||||||
|
|
||||||
|
**셋째 날** 08:50–13:35 (총 4시간 45분 · 이동 29분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 08:50–10:38 | 아침 | 스테이 머뭄 | –분 | 108분 |
|
||||||
|
| 2 | 10:46–11:26 | 오전 | 우체통거리 | 8분 | 40분 |
|
||||||
|
| 3 | 11:30–12:10 | 점심 | 일흥옥 | 4분 | 40분 |
|
||||||
|
| 4 | 12:18–12:54 | 오후 | 군산근대미술관 | 8분 | 36분 |
|
||||||
|
| 5 | 13:03–13:35 | 마무리 | 스테이 머뭄 | 9분 | 32분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 비가 와도 되는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 비 예보를 보고 오는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 사흘 내리 비가 와도 도는 순서가 바뀌지 않습니다. 실내와 처마 밑만 잇습니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:00–20:55 (총 5시간 55분 · 이동 40분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:00–15:40 | 체크인 | 스테이 머뭄 | –분 | 40분 |
|
||||||
|
| 2 | 15:51–17:21 | 오후 | 군산근대역사박물관 | 11분 | 90분 |
|
||||||
|
| 3 | 17:24–18:09 | 오후 | 군산근대미술관 | 3분 | 45분 |
|
||||||
|
| 4 | 18:14–19:24 | 저녁 | 빈해원 | 5분 | 70분 |
|
||||||
|
| 5 | 19:29–20:09 | 밤 산책 | 군산 내항 | 5분 | 40분 |
|
||||||
|
| 6 | 20:25–20:55 | 머뭄으로 | 스테이 머뭄 | 16분 | 30분 |
|
||||||
|
|
||||||
|
**둘째 날** 10:10–19:48 (총 9시간 38분 · 이동 62분 · 정거장 8)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 10:10–11:22 | 아침 | 스테이 머뭄 | –분 | 72분 |
|
||||||
|
| 2 | 11:32–12:26 | 오전 | 군산근대건축관 | 10분 | 54분 |
|
||||||
|
| 3 | 12:34–13:34 | 점심 | 일흥옥 | 8분 | 60분 |
|
||||||
|
| 4 | 13:49–15:01 | 오후 | 째보선창 근처 카페 | 15분 | 72분 |
|
||||||
|
| 5 | 15:09–15:57 | 오후 | 동국사다온 | 8분 | 48분 |
|
||||||
|
| 6 | 16:08–17:32 | 쉼 | 스테이 머뭄 | 11분 | 84분 |
|
||||||
|
| 7 | 17:37–19:07 | 저녁 | 명월갈비 | 5분 | 90분 |
|
||||||
|
| 8 | 19:12–19:48 | 머뭄으로 | 스테이 머뭄 | 5분 | 36분 |
|
||||||
|
|
||||||
|
**셋째 날** 09:40–15:27 (총 5시간 47분 · 이동 45분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:40–11:00 | 아침 | 스테이 머뭄 | –분 | 80분 |
|
||||||
|
| 2 | 11:18–12:30 | 오전 | 채만식문학관 | 18분 | 72분 |
|
||||||
|
| 3 | 12:47–13:47 | 점심 | 한일옥 | 17분 | 60분 |
|
||||||
|
| 4 | 13:50–14:32 | 오후 | 이성당 | 3분 | 42분 |
|
||||||
|
| 5 | 14:39–15:27 | 마무리 | 스테이 머뭄 | 7분 | 48분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 늦게 시작하는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 아침이 힘든 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 가운데 날을 열한 시에 엽니다. 오전을 비우고 저녁을 늦게까지 씁니다.
|
||||||
|
|
||||||
|
**첫째 날** 16:00–20:52 (총 4시간 52분 · 이동 47분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 16:00–16:40 | 체크인 | 스테이 머뭄 | –분 | 40분 |
|
||||||
|
| 2 | 16:43–17:13 | 오후 | 히로쓰 가옥 | 3분 | 30분 |
|
||||||
|
| 3 | 17:21–17:46 | 오후 | 우체통거리 | 8분 | 25분 |
|
||||||
|
| 4 | 17:52–19:12 | 저녁 | 도란 | 6분 | 80분 |
|
||||||
|
| 5 | 19:26–20:06 | 밤 산책 | 군산 내항 | 14분 | 40분 |
|
||||||
|
| 6 | 20:22–20:52 | 머뭄으로 | 스테이 머뭄 | 16분 | 30분 |
|
||||||
|
|
||||||
|
**둘째 날** 10:30–19:21 (총 8시간 51분 · 이동 55분 · 정거장 7)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 10:30–11:30 | 아침 | 스테이 머뭄 | –분 | 60분 |
|
||||||
|
| 2 | 11:35–12:25 | 점심 | 일흥옥 | 5분 | 50분 |
|
||||||
|
| 3 | 12:35–14:05 | 오후 | 군산근대역사박물관 | 10분 | 90분 |
|
||||||
|
| 4 | 14:15–14:35 | 오후 | 해망굴 | 10분 | 20분 |
|
||||||
|
| 5 | 14:43–17:19 | 쉼 | 스테이 머뭄 | 8분 | 156분 |
|
||||||
|
| 6 | 17:30–18:40 | 저녁 | 빈해원 | 11분 | 70분 |
|
||||||
|
| 7 | 18:51–19:21 | 머뭄으로 | 스테이 머뭄 | 11분 | 30분 |
|
||||||
|
|
||||||
|
**셋째 날** 10:10–13:47 (총 3시간 37분 · 이동 22분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 10:10–11:00 | 아침 | 스테이 머뭄 | –분 | 50분 |
|
||||||
|
| 2 | 11:07–11:42 | 오전 | 이성당 | 7분 | 35분 |
|
||||||
|
| 3 | 11:47–12:27 | 점심 | 만남스넥 | 5분 | 40분 |
|
||||||
|
| 4 | 12:33–13:03 | 오후 | 초원사진관 | 6분 | 30분 |
|
||||||
|
| 5 | 13:07–13:47 | 마무리 | 스테이 머뭄 | 4분 | 40분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 섬에서 하루를 다 쓰는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 차를 가져온 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 가운데 날은 아침에 나가 저녁에 들어옵니다. 섬에서 하루를 통째로 씁니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:00–20:03 (총 5시간 3분 · 이동 67분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:00–15:38 | 체크인 | 스테이 머뭄 | –분 | 38분 |
|
||||||
|
| 2 | 15:54–16:30 | 오후 | 군산 내항 부잔교 | 16분 | 36분 |
|
||||||
|
| 3 | 16:47–17:17 | 오후 | 해망굴 | 17분 | 30분 |
|
||||||
|
| 4 | 17:30–18:36 | 저녁 | 빈해원 | 13분 | 66분 |
|
||||||
|
| 5 | 18:41–19:19 | 밤 산책 | 군산 내항 | 5분 | 38분 |
|
||||||
|
| 6 | 19:35–20:03 | 머뭄으로 | 스테이 머뭄 | 16분 | 28분 |
|
||||||
|
|
||||||
|
**둘째 날** 07:30–20:21 (총 12시간 51분 · 이동 207분 · 정거장 7)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 07:30–08:27 | 아침 | 스테이 머뭄 | –분 | 57분 |
|
||||||
|
| 2 | 09:51–12:13 | 오전 | 고군산군도 선유도 | 84분 | 142분 |
|
||||||
|
| 3 | 12:33–13:21 | 오후 | 장자도 | 20분 | 48분 |
|
||||||
|
| 4 | 14:49–15:37 | 늦은 점심 | 한일옥 | 88분 | 48분 |
|
||||||
|
| 5 | 15:42–18:32 | 쉼 | 스테이 머뭄 | 5분 | 170분 |
|
||||||
|
| 6 | 18:37–19:48 | 저녁 | 명월갈비 | 5분 | 71분 |
|
||||||
|
| 7 | 19:53–20:21 | 머뭄으로 | 스테이 머뭄 | 5분 | 28분 |
|
||||||
|
|
||||||
|
**셋째 날** 09:40–14:20 (총 4시간 40분 · 이동 42분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:40–10:51 | 아침 | 스테이 머뭄 | –분 | 71분 |
|
||||||
|
| 2 | 11:01–11:44 | 오전 | 군산근대건축관 | 10분 | 43분 |
|
||||||
|
| 3 | 11:53–12:31 | 점심 | 만남스넥 | 9분 | 38분 |
|
||||||
|
| 4 | 12:42–13:30 | 오후 | 경암동 철길마을 | 11분 | 48분 |
|
||||||
|
| 5 | 13:42–14:20 | 마무리 | 스테이 머뭄 | 12분 | 38분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 소설 『탁류』를 따라 걷는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 채만식을 읽어 본 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다.
|
||||||
|
|
||||||
|
**첫째 날** 15:00–20:13 (총 5시간 13분 · 이동 66분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 15:00–15:44 | 체크인 | 스테이 머뭄 | –분 | 44분 |
|
||||||
|
| 2 | 16:03–16:36 | 오후 | 째보선창 | 19분 | 33분 |
|
||||||
|
| 3 | 16:41–17:19 | 오후 | 군산 내항 부잔교 | 5분 | 38분 |
|
||||||
|
| 4 | 17:32–18:27 | 저녁 | 일흥옥 | 13분 | 55분 |
|
||||||
|
| 5 | 18:40–19:24 | 밤 산책 | 군산 내항 | 13분 | 44분 |
|
||||||
|
| 6 | 19:40–20:13 | 머뭄으로 | 스테이 머뭄 | 16분 | 33분 |
|
||||||
|
|
||||||
|
**둘째 날** 09:30–19:31 (총 10시간 1분 · 이동 32분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:30–13:21 | 머뭄에서 쉬는 날 | 스테이 머뭄 | –분 | 231분 |
|
||||||
|
| 2 | 13:26–14:21 | 점심 | 한일옥 | 5분 | 55분 |
|
||||||
|
| 3 | 14:26–17:19 | 쉼 | 스테이 머뭄 | 5분 | 173분 |
|
||||||
|
| 4 | 17:30–18:47 | 저녁 | 빈해원 | 11분 | 77분 |
|
||||||
|
| 5 | 18:58–19:31 | 머뭄으로 | 스테이 머뭄 | 11분 | 33분 |
|
||||||
|
|
||||||
|
**셋째 날** 08:30–14:12 (총 5시간 42분 · 이동 23분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 08:30–09:52 | 아침 | 스테이 머뭄 | –분 | 82분 |
|
||||||
|
| 2 | 10:03–11:42 | 오전 | 군산근대역사박물관 | 11분 | 99분 |
|
||||||
|
| 3 | 11:47–12:48 | 점심 | 국제반점 | 5분 | 61분 |
|
||||||
|
| 4 | 12:51–13:24 | 오후 | 초원사진관 | 3분 | 33분 |
|
||||||
|
| 5 | 13:28–14:12 | 마무리 | 스테이 머뭄 | 4분 | 44분 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 철길마을과 항구를 같이 보는 2박 3일
|
||||||
|
|
||||||
|
2박 3일 · 사진을 찍는 손님 — ✅ **검수완료**
|
||||||
|
|
||||||
|
> 기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다.
|
||||||
|
|
||||||
|
**첫째 날** 16:00–20:32 (총 4시간 32분 · 이동 51분 · 정거장 6)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 16:00–16:36 | 체크인 | 스테이 머뭄 | –분 | 36분 |
|
||||||
|
| 2 | 16:48–17:33 | 오후 | 경암동 철길마을 | 12분 | 45분 |
|
||||||
|
| 3 | 17:44–18:11 | 오후 | 초원사진관 | 11분 | 27분 |
|
||||||
|
| 4 | 18:14–19:04 | 저녁 | 국제반점 | 3분 | 50분 |
|
||||||
|
| 5 | 19:13–19:49 | 밤 산책 | 군산 내항 | 9분 | 36분 |
|
||||||
|
| 6 | 20:05–20:32 | 머뭄으로 | 스테이 머뭄 | 16분 | 27분 |
|
||||||
|
|
||||||
|
**둘째 날** 09:40–19:10 (총 9시간 30분 · 이동 79분 · 정거장 8)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:40–10:34 | 아침 | 스테이 머뭄 | –분 | 54분 |
|
||||||
|
| 2 | 10:50–11:22 | 오전 | 군산 내항 부잔교 | 16분 | 32분 |
|
||||||
|
| 3 | 11:33–12:18 | 점심 | 한일옥 | 11분 | 45분 |
|
||||||
|
| 4 | 12:31–13:16 | 오후 | 째보선창 | 13분 | 45분 |
|
||||||
|
| 5 | 13:37–14:07 | 오후 | 해망굴 | 21분 | 30분 |
|
||||||
|
| 6 | 14:15–17:25 | 쉼 | 스테이 머뭄 | 8분 | 190분 |
|
||||||
|
| 7 | 17:30–18:38 | 저녁 | 명월갈비 | 5분 | 68분 |
|
||||||
|
| 8 | 18:43–19:10 | 머뭄으로 | 스테이 머뭄 | 5분 | 27분 |
|
||||||
|
|
||||||
|
**셋째 날** 09:30–13:48 (총 4시간 18분 · 이동 42분 · 정거장 5)
|
||||||
|
|
||||||
|
| | 시각 | 자리 | 장소 | 이동 | 체류 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | 09:30–10:38 | 아침 | 스테이 머뭄 | –분 | 68분 |
|
||||||
|
| 2 | 10:51–11:18 | 오전 | 구 군산세관 본관 | 13분 | 27분 |
|
||||||
|
| 3 | 11:30–12:15 | 점심 | 일흥옥 | 12분 | 45분 |
|
||||||
|
| 4 | 12:23–13:03 | 오후 | 군산근대미술관 | 8분 | 40분 |
|
||||||
|
| 5 | 13:12–13:48 | 마무리 | 스테이 머뭄 | 9분 | 36분 |
|
||||||
|
|
||||||
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`
|
||||||
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('히어로 원본 사진', /\/img\/mirror\//.test(d.heroImg || ''), d.heroImg);
|
||||||
|
ok('객실 사진 A12/B10', d.roomA === 12 && d.roomB === 10, `A ${d.roomA} · B ${d.roomB}`);
|
||||||
|
ok('404 없음', failed.length === 0, failed.slice(0, 3).join(' / ') || '없음');
|
||||||
|
ok('콘솔 오류 없음', errs.length === 0, errs.slice(0, 2).join(' / ') || '없음');
|
||||||
|
|
||||||
|
// 플레이어 동작
|
||||||
|
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);
|
||||||
181
solution/site/scripts/mockup/audit_schedule.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
"""구워진 payload 의 일정 21개를 표로 뽑고, 규칙 14종이 **안 보는** 논리까지 검수한다.
|
||||||
|
|
||||||
|
★ 왜 따로 있나 — build_itinerary.py 의 audit() 는 "만들 때" 지키는 규칙이다.
|
||||||
|
이 스크립트는 **만들어진 결과**를 사람이 읽는 표로 펴고, 비율·간격처럼
|
||||||
|
규칙에 없던 것을 센다. 사장님이 화면에서 찾아내신 종류가 전부 여기 있다.
|
||||||
|
"""
|
||||||
|
import json, re, sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SP = Path(__file__).resolve().parent
|
||||||
|
html = (SP / "build" / "index.html").read_text(encoding="utf-8")
|
||||||
|
payload = json.loads(re.search(r"window\.__SITE_PAYLOAD__\s*=\s*(\{.*?\});?\s*</script>", html, re.S).group(1))
|
||||||
|
sec = next(s for s in payload["theme"]["sections"] if s["id"] == "itinerary")
|
||||||
|
items = json.loads(sec["data"])["items"]
|
||||||
|
|
||||||
|
STAY = "스테이 머뭄"
|
||||||
|
MEAL_SLOTS = {"점심", "늦은 점심", "저녁"}
|
||||||
|
|
||||||
|
|
||||||
|
def hm(m): return f"{m // 60:02d}:{m % 60:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def plan(day):
|
||||||
|
"""렌더러(shared planDay)와 같은 계산. 21시 넘는 칸은 렌더러가 버린다."""
|
||||||
|
clock = int(day["startTime"][:2]) * 60 + int(day["startTime"][3:])
|
||||||
|
rows, dropped = [], 0
|
||||||
|
for s in day["stops"]:
|
||||||
|
move = max(0, s.get("moveMinutes", 0))
|
||||||
|
stay = max(1, s.get("minutes", 60))
|
||||||
|
arrive = clock + move
|
||||||
|
if arrive + stay > 21 * 60:
|
||||||
|
dropped += 1
|
||||||
|
continue
|
||||||
|
rows.append({"name": s["name"], "slot": s["name"].split(" · ")[0],
|
||||||
|
"place": s["name"].split(" · ")[-1], "move": move, "stay": stay,
|
||||||
|
"from": arrive, "to": arrive + stay})
|
||||||
|
clock = arrive + stay
|
||||||
|
return rows, dropped
|
||||||
|
|
||||||
|
|
||||||
|
def check(item):
|
||||||
|
"""규칙 14종이 안 보는 논리. (코드, 설명) 목록을 돌려준다."""
|
||||||
|
bad = []
|
||||||
|
name = item["name"]
|
||||||
|
seen_places = []
|
||||||
|
for day in item["days"]:
|
||||||
|
rows, dropped = plan(day)
|
||||||
|
label = day["label"]
|
||||||
|
if dropped:
|
||||||
|
bad.append(("L0", f"{label}: 21시를 넘겨 화면에서 버려지는 칸 {dropped}개"))
|
||||||
|
if not rows:
|
||||||
|
bad.append(("L0", f"{label}: 칸이 없다"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
outs = [r for r in rows if r["place"] != STAY]
|
||||||
|
# L1 체류 ≥ 편도 이동 — 가는 데 든 시간보다 짧게 머물면 다녀온 의미가 없다.
|
||||||
|
# ★ 끼니는 뺀다. 밥집은 목적지가 아니라 **돌아오는 길에 들르는 자리**다 —
|
||||||
|
# 섬에서 85분 달려와 55분 먹는 것은 이상하지 않고, 섬에 30분 있는 것이 이상하다.
|
||||||
|
# (2026-09-11 사장님 지적의 실제 대상은 선유도였고, 선유도는 150분이다.)
|
||||||
|
for r in outs:
|
||||||
|
if r["slot"] in MEAL_SLOTS:
|
||||||
|
continue
|
||||||
|
if r["stay"] < r["move"]:
|
||||||
|
bad.append(("L1", f"{label} {r['place']}: 이동 {r['move']}분인데 체류 {r['stay']}분"))
|
||||||
|
# L2 끼니 — 둘째 날부터는 점심과 저녁이 다 있어야 한다(마지막 날은 저녁 없이 떠난다)
|
||||||
|
meals = [r for r in rows if r["slot"] in MEAL_SLOTS]
|
||||||
|
is_first = label == item["days"][0]["label"]
|
||||||
|
is_last = label == item["days"][-1]["label"]
|
||||||
|
if not meals:
|
||||||
|
bad.append(("L2", f"{label}: 끼니가 하나도 없다"))
|
||||||
|
elif not is_first and not is_last:
|
||||||
|
slots = {r["slot"] for r in meals}
|
||||||
|
if not (slots & {"점심", "늦은 점심"}) or "저녁" not in slots:
|
||||||
|
bad.append(("L2", f"{label}: 점심/저녁 중 하나가 없다 ({sorted(slots)})"))
|
||||||
|
# L3 끼니 간격 — 점심과 저녁이 3시간 미만이면 배가 안 꺼진다
|
||||||
|
lunch = next((r for r in meals if r["slot"] in {"점심", "늦은 점심"}), None)
|
||||||
|
dinner = next((r for r in meals if r["slot"] == "저녁"), None)
|
||||||
|
if lunch and dinner and dinner["from"] - lunch["to"] < 180:
|
||||||
|
bad.append(("L3", f"{label}: 점심 끝나고 저녁까지 {dinner['from'] - lunch['to']}분"))
|
||||||
|
# L4 하루 이동 비율 — 절반을 길에서 쓰면 그건 관광이 아니라 운전이다
|
||||||
|
span = rows[-1]["to"] - rows[0]["from"]
|
||||||
|
move_sum = sum(r["move"] for r in rows)
|
||||||
|
if span and move_sum / span > 0.40:
|
||||||
|
bad.append(("L4", f"{label}: 하루 {span}분 중 이동 {move_sum}분 ({move_sum / span:.0%})"))
|
||||||
|
# L5 너무 짧은 정거장 — 15분은 들렀다 가는 것도 아니다
|
||||||
|
for r in outs:
|
||||||
|
if r["stay"] < 15:
|
||||||
|
bad.append(("L5", f"{label} {r['place']}: 체류 {r['stay']}분"))
|
||||||
|
# L6 하루 길이 — 14시간을 넘으면 손님이 못 따라온다
|
||||||
|
if span > 14 * 60:
|
||||||
|
bad.append(("L6", f"{label}: 하루가 {span // 60}시간 {span % 60}분"))
|
||||||
|
# L7 첫날 입실 15:00 이후 · 마지막 날 퇴실 11:00 전
|
||||||
|
if is_first and rows[0]["place"] == STAY and rows[0]["from"] < 15 * 60:
|
||||||
|
bad.append(("L7", f"{label}: 입실이 {hm(rows[0]['from'])}"))
|
||||||
|
if is_last and rows[0]["place"] == STAY and rows[0]["to"] > 11 * 60:
|
||||||
|
bad.append(("L7", f"{label}: 퇴실이 {hm(rows[0]['to'])}"))
|
||||||
|
# L8 시작·끝이 숙소인가
|
||||||
|
if rows[0]["place"] != STAY:
|
||||||
|
bad.append(("L8", f"{label}: 첫 칸이 {rows[0]['place']}"))
|
||||||
|
if rows[-1]["place"] != STAY:
|
||||||
|
bad.append(("L8", f"{label}: 마지막 칸이 {rows[-1]['place']}"))
|
||||||
|
# L9 같은 자리 연속
|
||||||
|
for i in range(1, len(rows)):
|
||||||
|
if rows[i]["place"] == rows[i - 1]["place"]:
|
||||||
|
bad.append(("L9", f"{label}: {rows[i]['place']} 가 연달아"))
|
||||||
|
seen_places += [r["place"] for r in outs]
|
||||||
|
# L10 테마 안에서 같은 곳 두 번
|
||||||
|
dup = {p for p in seen_places if seen_places.count(p) > 1}
|
||||||
|
if dup:
|
||||||
|
bad.append(("L10", f"테마 안에서 반복: {', '.join(sorted(dup))}"))
|
||||||
|
# L11 테마 이름·대상과 내용 — '걸어서'/'차 없이' 인데 편도 25분 넘는 이동이 있나
|
||||||
|
walkish = any(k in name + item.get("audience", "") for k in ("걸어서", "차 없이", "뚜벅"))
|
||||||
|
if walkish:
|
||||||
|
for day in item["days"]:
|
||||||
|
rows, _ = plan(day)
|
||||||
|
far = [r for r in rows if r["move"] > 25]
|
||||||
|
if far:
|
||||||
|
bad.append(("L11", f"걷는 테마인데 {far[0]['place']} 이동 {far[0]['move']}분"))
|
||||||
|
return bad
|
||||||
|
|
||||||
|
|
||||||
|
lines = ["# `/s/stay` 여행 일정 전수 검수표", "",
|
||||||
|
f"생성 `python3 audit_schedule.py` · 테마 {len(items)}개 · "
|
||||||
|
f"{sum(len(i['days']) for i in items)}일 · "
|
||||||
|
f"{sum(len(d['stops']) for i in items for d in i['days'])}정거장", "",
|
||||||
|
"표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).",
|
||||||
|
"규칙 14종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,",
|
||||||
|
"이 표는 그 규칙이 **안 보는 논리**를 따로 센다.", "",
|
||||||
|
"| 코드 | 검수 항목 |", "|---|---|",
|
||||||
|
"| L0 | 21시를 넘겨 화면에서 버려지는 칸이 없다 |",
|
||||||
|
"| L1 | **체류 ≥ 편도 이동** — 볼거리는 가는 데 든 시간보다 오래 머문다 (끼니 제외 — 밥집은 돌아오는 길에 들르는 자리다) |",
|
||||||
|
"| L2 | 가운데 날은 점심과 저녁이 둘 다 있다 |",
|
||||||
|
"| L3 | 점심 끝나고 저녁까지 3시간 이상 |",
|
||||||
|
"| L4 | 하루의 40% 넘게 길에서 쓰지 않는다 |",
|
||||||
|
"| L5 | 바깥 정거장 체류 15분 이상 |",
|
||||||
|
"| L6 | 하루가 14시간을 넘지 않는다 |",
|
||||||
|
"| L7 | 입실 15:00 이후 · 퇴실 11:00 전 |",
|
||||||
|
"| L8 | 하루의 첫 칸·마지막 칸이 숙소 |",
|
||||||
|
"| L9 | 같은 자리가 연달아 서지 않는다 |",
|
||||||
|
"| L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 |",
|
||||||
|
"| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |", ""]
|
||||||
|
|
||||||
|
fails = []
|
||||||
|
for item in items:
|
||||||
|
bad = check(item)
|
||||||
|
mark = "✅ **검수완료**" if not bad else "⚠️ **확인필요**"
|
||||||
|
lines += ["---", "", f"## {item['name']}", "",
|
||||||
|
f"{item['duration']} · {item.get('audience', '')} — {mark}", "",
|
||||||
|
f"> {item.get('why', '')}", ""]
|
||||||
|
for day in item["days"]:
|
||||||
|
rows, _ = plan(day)
|
||||||
|
span = rows[-1]["to"] - rows[0]["from"]
|
||||||
|
move_sum = sum(r["move"] for r in rows)
|
||||||
|
lines += [f"**{day['label']}** {hm(rows[0]['from'])}–{hm(rows[-1]['to'])} "
|
||||||
|
f"(총 {span // 60}시간 {span % 60}분 · 이동 {move_sum}분 · 정거장 {len(rows)})", "",
|
||||||
|
"| | 시각 | 자리 | 장소 | 이동 | 체류 |", "|---|---|---|---|---|---|"]
|
||||||
|
for n, r in enumerate(rows, 1):
|
||||||
|
lines.append(f"| {n} | {hm(r['from'])}–{hm(r['to'])} | {r['slot']} | {r['place']} | "
|
||||||
|
f"{r['move'] or '–'}분 | {r['stay']}분 |")
|
||||||
|
lines.append("")
|
||||||
|
if bad:
|
||||||
|
fails.append((item["name"], bad))
|
||||||
|
lines += ["**확인이 필요한 것**", ""]
|
||||||
|
lines += [f"- `{code}` {msg}" for code, msg in bad]
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
head = [f"## 결과 — {len(items) - len(fails)}/{len(items)} 검수완료", ""]
|
||||||
|
if fails:
|
||||||
|
head += ["| 테마 | 걸린 항목 |", "|---|---|"]
|
||||||
|
head += [f"| {n} | {', '.join(sorted({c for c, _ in b}))} |" for n, b in fails]
|
||||||
|
else:
|
||||||
|
head += ["**전부 통과.** 12개 항목 위반 0건."]
|
||||||
|
head.append("")
|
||||||
|
lines = lines[:8] + head + lines[8:]
|
||||||
|
|
||||||
|
(SP / "SCHEDULE.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
print(f"SCHEDULE.md · 검수완료 {len(items) - len(fails)}/{len(items)}")
|
||||||
|
for n, b in fails:
|
||||||
|
print(f" ⚠️ {n}")
|
||||||
|
for code, msg in b:
|
||||||
|
print(f" {code} {msg}")
|
||||||
681
solution/site/scripts/mockup/build_itinerary.py
Normal file
@ -0,0 +1,681 @@
|
|||||||
|
"""/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
|
||||||
|
# ★ 끼니 사이 최소 간격 (2026-09-11 검수).
|
||||||
|
# 섬 코스에서 늦은 점심이 15:47 에 끝나고 저녁이 17:30 이었다 — 103분이다.
|
||||||
|
# 창(窓)만 지키면 통과한다: 늦은 점심은 15:00 까지, 저녁은 17:30 부터라 둘 다 합법인데
|
||||||
|
# 손님은 배가 안 꺼진 채 두 번째 상을 받는다. 창 밖에 이 규칙이 따로 필요하다.
|
||||||
|
MEAL_GAP = 180
|
||||||
|
MEAL_SLOTS = {"점심", "늦은 점심", "저녁"}
|
||||||
|
# 퇴실 시각. 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 _window_of(slot: str, last_meal_end: int | None):
|
||||||
|
"""그 자리의 도착 창. 끼니는 **앞 끼니가 끝난 뒤 MEAL_GAP** 이 지나야 연다."""
|
||||||
|
window = WINDOW.get(slot)
|
||||||
|
if not window:
|
||||||
|
return None
|
||||||
|
early, late = window
|
||||||
|
if slot in MEAL_SLOTS and last_meal_end is not None:
|
||||||
|
early = max(early, last_meal_end + MEAL_GAP)
|
||||||
|
return (early, late) if early <= late else None
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
last_meal_end = None
|
||||||
|
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_of(row["slot"], last_meal_end)
|
||||||
|
if window is None and row["slot"] in WINDOW:
|
||||||
|
return None # 앞 끼니와 겹쳐 창이 닫혔다 — 이 조합은 포기한다
|
||||||
|
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"]
|
||||||
|
if row["slot"] in MEAL_SLOTS:
|
||||||
|
last_meal_end = clock
|
||||||
|
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
|
||||||
|
last_meal_end = None
|
||||||
|
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_of(row["slot"], last_meal_end)
|
||||||
|
if row["slot"] in WINDOW and (window is None or not (window[0] <= arrive <= window[1])):
|
||||||
|
return None
|
||||||
|
clock = arrive + row["stay"]
|
||||||
|
if row["slot"] in MEAL_SLOTS:
|
||||||
|
last_meal_end = clock
|
||||||
|
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시 초과")
|
||||||
|
|
||||||
|
# ⑮ 끼니 사이 최소 간격 (2026-09-11 검수에서 걸린 것)
|
||||||
|
# 창만 지키면 통과한다 — 늦은 점심 15:47 종료, 저녁 17:30 시작이 둘 다 합법이었다.
|
||||||
|
# 손님은 배가 안 꺼진 채 두 번째 상을 받는다.
|
||||||
|
walk = _minutes(day["startTime"])
|
||||||
|
last_meal = None
|
||||||
|
for sl, stop in zip(slots, day["stops"]):
|
||||||
|
walk += stop["moveMinutes"]
|
||||||
|
if sl in FOOD_SLOTS:
|
||||||
|
if last_meal is not None and walk - last_meal < MEAL_GAP:
|
||||||
|
flag(item, day, f"앞 끼니 끝나고 {walk - last_meal}분 만에 {sl}")
|
||||||
|
last_meal = walk + stop["minutes"]
|
||||||
|
walk += stop["minutes"]
|
||||||
|
|
||||||
|
# ⑯ 볼거리는 **가는 시간보다 오래 머문다**
|
||||||
|
# 끼니는 뺀다 — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리다.
|
||||||
|
# (섬에서 85분 달려와 55분 먹는 것은 이상하지 않다. 섬에 30분 있으면 이상하다.)
|
||||||
|
for sl, pl, stop in zip(slots, places, day["stops"]):
|
||||||
|
if pl == STAY or sl in FOOD_SLOTS:
|
||||||
|
continue
|
||||||
|
if stop["minutes"] < stop["moveMinutes"]:
|
||||||
|
flag(item, day, f"{pl}: 이동 {stop['moveMinutes']}분인데 체류 {stop['minutes']}분")
|
||||||
|
|
||||||
|
# ⑨ 같은 날 같은 식당 두 번
|
||||||
|
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)}개 · 규칙 16종 위반 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))
|
||||||
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)}")
|
||||||
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/mirror/07600bb67b91dd15.jpg
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
solution/site/scripts/mockup/img/mirror/0a1e0ff50ca7ee37.jpg
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
solution/site/scripts/mockup/img/mirror/12938240987c1f78.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
solution/site/scripts/mockup/img/mirror/149c18951bdcaff8.jpg
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
solution/site/scripts/mockup/img/mirror/1caf7fcac8ece19a.jpg
Normal file
|
After Width: | Height: | Size: 328 KiB |
BIN
solution/site/scripts/mockup/img/mirror/1f35ef26139c6cb9.jpg
Normal file
|
After Width: | Height: | Size: 371 KiB |
BIN
solution/site/scripts/mockup/img/mirror/1f6dbdd78a3da2de.png
Normal file
|
After Width: | Height: | Size: 692 KiB |
BIN
solution/site/scripts/mockup/img/mirror/22af0bff45e5fce8.jpg
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
solution/site/scripts/mockup/img/mirror/2af9caa23a08a3ec.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
solution/site/scripts/mockup/img/mirror/2d94158a48480fcd.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
solution/site/scripts/mockup/img/mirror/3420ff7e357ab7ef.jpg
Normal file
|
After Width: | Height: | Size: 481 KiB |
BIN
solution/site/scripts/mockup/img/mirror/37c90494d28478e2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
solution/site/scripts/mockup/img/mirror/3a780a4a34df9289.jpg
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
solution/site/scripts/mockup/img/mirror/3dc384187a900e95.png
Normal file
|
After Width: | Height: | Size: 548 KiB |
BIN
solution/site/scripts/mockup/img/mirror/3e9e574d3b987c8d.jpg
Normal file
|
After Width: | Height: | Size: 494 KiB |
BIN
solution/site/scripts/mockup/img/mirror/43461e391f83b9e8.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
solution/site/scripts/mockup/img/mirror/4389ded2dee1ad98.jpg
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
solution/site/scripts/mockup/img/mirror/487643cdcfd19fdb.jpg
Normal file
|
After Width: | Height: | Size: 479 KiB |
BIN
solution/site/scripts/mockup/img/mirror/49558512d8d52e1a.jpg
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
solution/site/scripts/mockup/img/mirror/4b4086345c20b968.jpg
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
solution/site/scripts/mockup/img/mirror/4d71067dc7e3f671.jpg
Normal file
|
After Width: | Height: | Size: 309 KiB |
BIN
solution/site/scripts/mockup/img/mirror/4db345bd2cb4f0d7.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
solution/site/scripts/mockup/img/mirror/5000f0a4feb45788.jpg
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
solution/site/scripts/mockup/img/mirror/566bc326e8a58042.jpg
Normal file
|
After Width: | Height: | Size: 467 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6194651114d1e740.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
solution/site/scripts/mockup/img/mirror/669abc2b08640d3e.jpg
Normal file
|
After Width: | Height: | Size: 374 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6a0c445e6dd940af.jpg
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6a15360430298700.jpg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
solution/site/scripts/mockup/img/mirror/6d0c447b6a0c32af.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6d59722cedddd566.jpg
Normal file
|
After Width: | Height: | Size: 648 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6ded14f21a17894b.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
solution/site/scripts/mockup/img/mirror/7095220ca156e25e.jpg
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
solution/site/scripts/mockup/img/mirror/7ba545fdbc8ad273.jpg
Normal file
|
After Width: | Height: | Size: 397 KiB |
BIN
solution/site/scripts/mockup/img/mirror/7ccd68de6b280bc2.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |