Merge branch 'main' into feature/scheduler

# Conflicts:
#	solution/backend/services/build_service.py
This commit is contained in:
김성경 2026-09-11 14:03:27 +09:00
commit 6760b38fec
167 changed files with 7753 additions and 5 deletions

View File

@ -36,6 +36,13 @@ KAKAO_REST_API_KEY=
GEMINI_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(웹 애플리케이션)

View File

@ -49,6 +49,9 @@ services:
SCHEDULER_ENABLED: "1"
volumes:
- ./solution/site/payloads:/app/out/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/out/songs
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
- ./postgres-init:/app/postgres-init:ro
@ -81,6 +84,9 @@ services:
disable: true
volumes:
- ./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
extra_hosts:
- "host.docker.internal:host-gateway"
@ -119,6 +125,9 @@ services:
retries: 3
volumes:
- ./solution/site/payloads:/app/out/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
- ./solution/site/songs:/app/out/songs
ports:
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"

View File

@ -13,8 +13,14 @@
```
backend (Python) ──쓴다──▶ out/payloads/<slug>.json ◀──읽는다── prerender (Node)
out/payloads/.status/<slug>.json ──보고──▶
──쓴다──▶ out/songs/<song_id>.mp3 ◀──복사──
```
★ 노래(mp3)도 **같은 약속**을 쓴다(2026-09-11). 백엔드는 발행물 디렉토리를 모른 채 파일을
`out/songs/` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다. 백엔드가 발행물
디렉토리에 직접 쓰기 시작하면 이 경계가 무너진다 — 그때부터 두 쪽이 out/ 의 모양을 함께
알아야 한다.
이 경계가 있어서 렌더링을 통째로 갈아엎어도 백엔드는 안 건드린다. 반대도 같다.
**둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가
React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.

View File

@ -13,7 +13,7 @@
---
## 0. 표 14개, 스키마는 `public` 한 벌
## 0. 표 15개, 스키마는 `public` 한 벌
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
@ -27,13 +27,14 @@ users 사장님 계정
│ ├ place_photos 사진
│ ├ place_facts ★ 사실. 이 제품의 심장
│ ├ place_faqs FAQ
│ ├ place_songs 이 숙소의 노래 — 발행할 때마다 한 곡(가사 Gemini → 작곡 Suno)
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
└ sites 발행 사이트 — 사업장당 1개
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
├ site_versions ★ 빌드 버전 — snapshot 박제
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래
```
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
@ -137,6 +138,22 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
`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` — 지역 콘텐츠
★ **키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.

View File

@ -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 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거)
**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`,

View File

@ -167,6 +167,27 @@ CREATE TABLE IF NOT EXISTS public.place_units (
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 (
media_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
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_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);
-- place_facts

View 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);

View File

@ -14,6 +14,7 @@ from common.enums import (
FactStatus,
SourceType,
MediaStatus,
SongStatus,
SiteStatus,
BuildStatus,
JobStatus,
@ -190,6 +191,30 @@ class place_photos(MainTableMixin, MAIN_BASE):
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
# ============================================================

View File

@ -283,6 +283,18 @@ class MediaStatus(CodeEnum):
REJECTED = 3
class SongStatus(CodeEnum):
"""place_songs.status 코드값.
★ fact·사진과 달리 검증 상태가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
"맞는가" 를 물을 대상이 아니다. 물을 것은 "만들어졌는가" 하나다.
★ 사이트에는 READY 만 나간다 — 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다."""
GENERATING = 1 # Suno 가 작곡 중(또는 파일을 아직 못 받았다)
READY = 2 # 파일까지 받아 뒀다 — 사이트에 나간다
FAILED = 3 # 생성 실패. 발행은 그대로 진행된다(노래만 없다)
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다.
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
@ -419,6 +431,7 @@ class JobType(CodeEnum):
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
AI_CHECK = 6 # AI 검색 노출 점검
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다
class JobStatus(CodeEnum):

View File

@ -131,6 +131,11 @@ class ExternalApiConfig(BaseSettings):
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
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_*) 로 주입받는다.

View 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),
)

View File

@ -33,6 +33,9 @@ def _content_type(path: Path) -> str:
".json": "application/json; charset=utf-8",
".xml": "application/xml; 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"
@ -46,6 +49,10 @@ def _cache_control(path: Path) -> str:
# 60초로 두면 방문자가 수 MB 짜리 폰트를 계속 다시 받는다.
if head == "fonts":
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 — 발행하면 곧바로 반영되어야 한다.
return "public, max-age=60, must-revalidate"

View File

@ -29,7 +29,7 @@ from common.logger import LOG
from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD
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.site_payload import emit_payload
from services.snapshot import build_snapshot
@ -126,6 +126,23 @@ async def run_build(job: dict) -> dict:
except Exception as ex: # noqa: BLE001 — 곁들이 정보 실패가 빌드를 죽이면 안 된다
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)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
# 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(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,
"site_version_id": str(version.site_version_id)}
# 잡 결과에 남긴다 — "노래가 왜 없나" 를 잡 하나만 열어 보면 알 수 있어야 한다.
if song_result is not None:
result["song"] = song_result
now = GTime.UTC()
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):

View File

@ -194,3 +194,77 @@ async def generate_copy(
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
)
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")

View 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

View 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)}.
"""

View File

@ -954,6 +954,25 @@ def to_site_payload(place, snapshot: dict, site, version, links) -> dict:
# ★ 없는 것을 지어내지 않는다 — 틀린 경로 안내는 방문자에게 헛걸음을 만든다(모델 주석).
# 렌더러는 비면 해당 섹션을 그리지 않는다.
"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,
"theme": theme,
}

View File

@ -560,6 +560,11 @@ class SiteService:
if job_id is None:
res.result.SetResult(ErrorType.COLLECT_ALREADY_RUNNING)
return res
# ★ 노래는 여기서 따로 걸지 않는다. BUILD 잡이 스냅샷을 뜨기 **전에** 직접 만든다
# (`build_service.run_build` → `song_service.ensure_song`) — 발행이 노래를 기다린다.
# 따로 걸면 먼저 구워지고 노래가 몇 분 뒤 붙는데, 그러면 사장님이 [사이트 열기] 로
# 보는 첫 화면에 그 기능이 빠져 있다(2026-09-11 결정).
res.job_id = uuid.UUID(job_id)
res.status = JobStatus.PENDING
res.created = created

View File

@ -23,7 +23,7 @@ from sqlalchemy import or_, select
from common.category_schema import get_schema
from common.database.db_session_manager import DB_SESSION_MNG
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,
)
from common.enums import (
@ -35,6 +35,7 @@ from common.enums import (
LocalSource,
MediaStatus,
PlaceCategory,
SongStatus,
)
from common.logger import LOG
from services.external.naver import region_key
@ -89,6 +90,16 @@ async def build_snapshot(place) -> dict:
).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)
snapshot = {
@ -161,11 +172,26 @@ async def build_snapshot(place) -> dict:
# ★ 원문(body)을 거의 그대로 싣는다. 렌더러 타입으로의 변환은 site_payload 가 한다 —
# fact·사진과 같은 분업이다(여기는 '무엇이 나갈 수 있는가', 거기는 '어떤 모양으로 나가는가').
"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(
f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · "
f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · "
f"지역 {len(snapshot['local']['contents'])}"
f"지역 {len(snapshot['local']['contents'])} · 노래 {len(snapshot['songs'])}"
)
return snapshot

View 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,
}

View File

@ -15,6 +15,7 @@
JobType.COPY ✓ services/copy_service.run_copy — 소개문·FAQ (확보된 fact 만 근거)
JobType.BUILD ✓ services/build_service.run_build — 정적 빌드 + 발행 검수 게이트
JobType.LOCAL_SYNC ✓ services/story_service.run_local_sync — 지역 이야기 생성(지역당 1회)
JobType.SONG ✓ services/song_service.run_song — 이 숙소의 노래 한 곡(가사 Gemini → 작곡 Suno)
JobType.AI_CHECK → reports 모듈이 붙을 때
"""
@ -80,6 +81,7 @@ def _register_builtin():
from services.build_service import run_build
from services.copy_service import run_copy
from services.story_service import run_local_sync
from services.song_service import run_song
from services.vision_service import run_vision
if JobType.COLLECT.value not in HANDLERS:
@ -92,6 +94,8 @@ def _register_builtin():
HANDLERS[JobType.BUILD.value] = run_build
if JobType.LOCAL_SYNC.value not in HANDLERS:
HANDLERS[JobType.LOCAL_SYNC.value] = run_local_sync
if JobType.SONG.value not in HANDLERS:
HANDLERS[JobType.SONG.value] = run_song
_register_builtin()

View File

@ -118,5 +118,7 @@ export function sanitizePayloadForPublish(payload: SitePayload): SitePayload {
links: payload.links.filter((link) => link.confirmed),
// 대체 텍스트 없는 이미지는 어차피 렌더하지 않는다 — 목록에서도 뺀다.
media: payload.media.filter((item) => item.alt?.trim()),
// 재생 주소가 없는 곡은 버튼만 있고 소리가 없다. 목록에서 뺀다.
songs: (payload.songs ?? []).filter((song) => song.audioUrl?.trim()),
};
}

View File

@ -41,6 +41,15 @@ export interface SitePayload {
/** 주요 거점까지의 이동 시간. */
routes: RouteEntry[];
/**
* 이 숙소의 노래. 발행할 때마다 한 곡 만든다(가사 Gemini → 작곡 Suno).
*
* ★ 발행이 노래를 기다리므로 발행본에는 보통 한 곡이 실려 있다. 그래도 빌 수 있다 —
* 키가 없거나(SUNO_API_KEY) 작곡이 실패하면 **노래 없이** 발행한다(발행을 막지는 않는다).
* 렌더러는 비면 플레이어를 아예 그리지 않는다.
*/
songs: SongTrack[];
/** LLM 이 쓴 문장(allow_llm=true 필드). 사실이 아니라 문장이라 fact 와 분리한다. */
narrative: Narrative;
@ -323,6 +332,25 @@ export interface Narrative {
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 {
templateId: string;
colors: {

View File

@ -0,0 +1,2 @@
build/
stay-payload-new.json

View 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 에 안 남는다 |

View 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분 |

View 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`

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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);

View 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}")

View 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")

View 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))

View 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"
}
}

View 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)}")

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 648 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Some files were not shown because too many files have changed in this diff Show More