Merge branch 'feature/stay-mockup-postcard-reading'

This commit is contained in:
Mina Choi 2026-09-14 17:09:51 +09:00
commit bdc69a80f2
48 changed files with 3933 additions and 156 deletions

View File

@ -0,0 +1,146 @@
# 자동재생 — 안 된다. 왜 안 되는지와, 그래서 뭘 했는지
`/s/stay` 배경음악을 **새로고침 직후 바로** 나게 하려고 시도한 것 전부와 실측값이다.
같은 얘기가 다시 나오면 이 파일부터 본다.
측정: 2026-09-14 · 킹서버 `https://web4ai.o2osolution.ai/s/stay` · Chrome(정식 채널)
---
## 결론
**브라우저에서 첫 방문에 소리가 저절로 나게 하는 방법은 없다.**
사람 손 입력(`isTrusted: true`) 없이는 크롬이 `play()` 를 `NotAllowedError` 로 거절한다.
자바스크립트로 그 검사를 통과시키는 길은 없고, 그건 우회 대상이 아니라
**그 우회를 막으려고 만들어진 검사**다.
지금 동작: **손님이 화면에 처음 닿으면(클릭·탭·키) 켜진다.** 그 이상은 어떤 사이트도 못 한다.
### 해 본 것과 결과
| 시도 | 결과 |
|---|---|
| 로드 즉시 `audio.play()` | `NotAllowedError` |
| 무음(`muted=true`)으로 시작 → 나중에 unmute | `NotAllowedError` (무음도 거절된다) |
| `<audio muted playsinline>` 태그로 두고 `play()` | `NotAllowedError` |
| 가짜 이벤트 강제 발생 — `dispatchEvent` 로 `pointerdown`·`mousedown`·`pointerup`·`mouseup`·`click`·`keydown` 6종 | `navigator.userActivation` **false 그대로**, 거절 |
| 재생 단추를 코드로 누르기 — `btn.click()`, 0.7초 간격 3회 | `play=0` |
| `--app=URL` 앱 모드로 띄우기 | `play=0` |
| **Web Audio API** — `new AudioContext()` | `state: "suspended"` |
| `ctx.resume()` | **약속이 영원히 안 돌아온다** (제스처를 기다리며 매달린다) |
| mp3 를 `decodeAudioData` 로 풀어 `BufferSource` 재생 | `currentTime` **0.000 → 0.000**, 안 돈다 |
| **`speechSynthesis.speak()`** | `error: not-allowed` |
| 마우스만 움직임 / 스크롤만 | `play=0` (`act=false`) |
| **진짜 클릭·탭·키 입력 한 번** | **켜진다** (`act=true`, `play=1`) |
`dispatchEvent` 와 `el.click()` 이 만든 이벤트는 `isTrusted: false` 로 찍혀 나간다.
크롬의 사용자 활성화 카운터(`navigator.userActivation`)는 `isTrusted: true` 인 입력만 세고,
그게 올라야 소리가 풀린다.
브라우저에서 **소리를 낼 수 있는 API 는 셋뿐이고 셋 다 같은 게이트 뒤에 있다** —
HTMLMediaElement(`<audio>`) · Web Audio(`AudioContext`) · speechSynthesis. 우회로가 없는 이유다.
**활성화를 준다**: `mousedown` · `pointerdown` · `pointerup` · `touchend` · `keydown`(Esc 제외) · `click`
**안 준다**: `mousemove` · `pointermove` · `touchmove` · `wheel` · `scroll`
---
## 안 한 것과 그 이유
크롬 문서(developer.chrome.com/blog/autoplay)에 예외가 넷 있지만 **전부 손님 쪽 설정이라
제품에서는 쓸 수 없다.** 한 번 만들었다가 되돌렸다.
| 예외 | 왜 안 쓰나 |
|---|---|
| PWA 설치본 | **펜션 사이트 보러 온 손님이 앱을 설치할 리 없다.** 매니페스트까지 붙였다가 걷어냈다 |
| MEI (같은 출처에서 7초 이상 여러 번) | 손님이 쌓는 것이라 첫 방문에는 없다 |
| `--autoplay-policy=no-user-gesture-required` | 브라우저를 그 플래그로 띄워야 한다 |
| 기업정책 `AutoplayAllowlist` | 기계 관리자가 넣는 것이다 |
★ **강제 클릭 코드도 뺐다.** 넣어서 배포까지 해 봤지만 기본 크롬에서 `play=0` 이고,
동작하지 않는 코드가 남으면 다음 사람이 "이미 처리돼 있네" 로 읽는다.
---
## 지금 코드가 하는 일 (`inject.js` `startPlayer`)
1. 로드 즉시 `play()` 를 건다. (거절되지만, 자동재생이 허락된 환경이면 여기서 켜진다)
2. 거절되면 **첫 사람 손길**을 기다린다. 창 전체에 걸고, 플레이어 자기 단추만 빼고
어디를 눌러도 걸린다.
- STRONG(활성화를 주는 입력): 스로틀 없이 매번 시도
- WEAK(안 주는 입력): 1초에 한 번만 — 초당 수십 번 오는 mousemove 로 단추가 깜빡이는 것을 막는다
3. 탭이 다시 앞으로 나올 때(`visibilitychange`) 한 번 더 건다.
### 여기서 밟았던 함정 둘
- **가짜 pointerdown 이 대기를 태웠다.** `tameRails()` 가 레일 자동 넘김을 세우려고 레일마다
`pointerdown` 을 쏘는데(정상 동작), 그게 첫 손길 대기를 먼저 먹었다. 그 뒤로는 손님이
아무 데나 눌러도 안 켜졌다. → `onFirstTouch` 가 `event.isTrusted` 를 먼저 본다.
- **스로틀이 클릭을 걸렀다.** 입력을 안 가리고 1초 스로틀을 걸었더니 클릭 직전의 mousemove 가
그 1초를 먹어 **정작 활성화를 주는 mousedown 이 걸러졌다**(실측: `act=true` 인데 `play=0`).
→ STRONG/WEAK 로 갈라 STRONG 에는 스로틀을 안 건다.
---
## "트리플픽은 되던데?" — 실측 결과
**트리플픽도 안 된다. 똑같이 거절당한다.**
`https://triplepick.o2o.kr/match/MLB_BAL_TOR_20260914?song=BAL` (공유 링크 = 자동 로드 경로)에
깨끗한 크롬으로 들어가 `HTMLMediaElement.prototype.play` 를 가로채 찍은 값이다.
```
[T] play() 거절 NotAllowedError .kr/api/songs/audio/815/0?v=89a0fcd6
```
코드도 우리와 같은 한 줄이다 — `usePlayer.tsx:133` `if (shouldPlay) audio.play().catch(() => {})`.
게스처 감지 코드는 0줄이고, 평소 재생 경로는 `MatchupHUD.tsx:55` 의 **「♪ 응원가 듣기」 버튼**이다.
`playlist.ts:19` 의 `PATH_PLAYLISTS` 는 비어 있어서 경로 진입 자동 로드는 실을 곡도 없다.
### 그럼 왜 내 브라우저에서는 나나 — MEI
크롬은 **같은 출처에서 소리를 여러 번 들은 사람에게만** 그 출처의 자동재생을 풀어 준다
(Media Engagement Index). 조건: 7초 초과 재생 · 음소거 아님 · 탭 활성 · (영상이면) 200x140px 초과.
트리플픽을 자주 쓰면서 응원가를 여러 번 들었으면 그 브라우저에는 이미 허락이 쌓여 있다.
**그 브라우저에서만** 자동재생된다. 손님의 새 브라우저에서는 안 된다.
확인: `chrome://media-engagement/` 에서 출처별 점수와 `Has high score` 를 본다.
★ **같은 일이 이 사이트에도 일어난다.** 목업을 자주 열고 음악을 들으면 그 브라우저는
`web4ai.o2osolution.ai` 에 허락을 쌓고, 그 뒤로는 새로고침해도 바로 난다.
**대표님 노트북에서 시연하면 그 상태가 된다** — 다만 손님 브라우저는 아니다.
---
## 재보고 싶을 때 — 측정 주의
★ **Playwright 로 잴 때 `page.evaluate` 를 쓰면 안 된다.** CDP 가 `userGesture: true` 로 넣어서
**상태를 읽으려고 부른 그 호출이 브라우저를 "조작됨" 으로 만든다.** 그 뒤 측정은 전부 거짓
양성이다 — 실제로 이것 때문에 "마우스만 움직여도 켜진다" 는 틀린 결론을 한 번 냈다.
상태는 `addInitScript` 로 심고 `console` 로만 받는다.
```js
await page.addInitScript(() => {
setInterval(() => {
const el = document.getElementById('w4d-play');
console.log('[C] play=' + (el ? el.dataset.playing : '-')
+ ' act=' + navigator.userActivation.isActive);
}, 1000);
});
page.on('console', (m) => { /* 여기서만 읽는다 */ });
```
---
## 지금 무엇을 보고 있는지 가리는 법
`/s/` 는 `max-age=300, must-revalidate` 다. "고쳤는데 안 된다" 의 상당수가 **캐시된 옛 파일**이었다.
콘솔 첫 줄에 빌드 시각이 찍힌다.
```
[w4d] 주입분 로드 · 빌드 2026-09-14T07:12:00+00:00
```
이 시각이 방금 구운 것과 다르면 옛 파일을 보고 있는 것이다. ⌘⇧R.

View File

@ -59,7 +59,7 @@
| 파일 | 옮길 자리 | | 파일 | 옮길 자리 |
|---|---| |---|---|
| 이 문서 **1.4 프롬프트 전문** | `shared/src/lib/section-prompts.ts` | | 이 문서 **1.4 프롬프트 전문** | `shared/src/lib/section-prompts.ts` |
| `build_itinerary.py` (규칙 16종) | `backend/services/itinerary.py` | | `build_itinerary.py` (규칙 17종) | `backend/services/itinerary.py` |
| `build_story.py` (위키 사진 수집) | `backend/services/external/wikimedia.py` | | `build_story.py` (위키 사진 수집) | `backend/services/external/wikimedia.py` |
| `inject.js` (렌더러에 **없는** 기능 3개) | `site/src/sections/` | | `inject.js` (렌더러에 **없는** 기능 3개) | `site/src/sections/` |
@ -81,25 +81,26 @@
| `photos` | 사진 갤러리 | `media[]` | 사장님이 올림 | | | `photos` | 사진 갤러리 | `media[]` | 사장님이 올림 | |
| `map` | 오시는 길 | `place` + `routes[]` | 수집 | | | `map` | 오시는 길 | `place` + `routes[]` | 수집 | |
| `festival` | 계절별 축제 | `local.festivals` | TourAPI 수집 | | | `festival` | 계절별 축제 | `local.festivals` | TourAPI 수집 | |
| `local` | 지역 정보 | `local.attractions/restaurants` | TourAPI 수집 | | | `local` | 지역 정보 | `local.attractions/restaurants` | TourAPI 수집 (시연본 맛집은 `restaurants.json` 31곳) | |
| `itinerary` | **추천 일정** | `data(kind=itinerary)` | `services/itinerary.py` + 빌더 프롬프트 `dataSpec.ts:556` | **T2** | | `itinerary` | **추천 일정** | `data(kind=itinerary)` | `services/itinerary.py` + 빌더 프롬프트 `dataSpec.ts:556` | **T2** |
| `story` | 군산 이야기 | 아래 6탭 껍데기 | — | | | `story` | 군산 이야기 | 아래 6탭 껍데기 | — | |
| `songs` | 가요 다방 | `data(kind=songs)` | LLM `section-prompts.ts:67` | **T3 T5** | | `songs` | 가요 다방 | `data(kind=songs)` | LLM `section-prompts.ts:67` | **T3 T5** |
| `daily` | 오늘의 한 장 | `data(kind=daily)` | LLM `:91` | 섹션 자리가 없다 | | `daily` | 오늘의 한 장 | `data(kind=daily)` | LLM `:91` | 시연본은 노래·문학 39장(`build_daily.py`) |
| `people` | 인물 열전 | `data(kind=people)` | LLM `:113` | **T3 T5** | | `people` | 인물 열전 | `data(kind=people)` | LLM `:113` | **T3 T5** |
| `chronicle` | 시간의 골목 | `data(kind=chronicle)` | LLM `:135` | | | `chronicle` | 시간의 골목 | `data(kind=chronicle)` | LLM `:135` | |
| `postcard` | 오늘의 엽서 | `data(kind=postcard)` | LLM `:160` | | | `postcard` | 오늘의 엽서 | `data(kind=postcard)` | LLM `:160` | 시연본은 탭을 비우고 독립 "엽서 쓰기" 로 대체(**2.5 항목 9**) |
| `quiz` | 뒤집어 보는 질문 | `data(kind=quiz)` | LLM `:188` | | | `quiz` | 뒤집어 보는 질문 | `data(kind=quiz)` | LLM `:188` | |
| `faq` | 자주 묻는 질문 | `faqs[]` | LLM + 사장님 | | | `faq` | 자주 묻는 질문 | `faqs[]` | LLM + 사장님 | |
| `rules` | 이용 규정 | `facts[]` | 사장님이 적음 | | | `rules` | 이용 규정 | `facts[]` | 사장님이 적음 | |
| `weather` | 날씨 | `local.weather` | open-meteo | | | `weather` | 날씨 | `local.weather` | open-meteo | |
| `planner` | 계절별 추천 하루 | `data(kind=planner)` | **없음** | (시연본은 꺼 둠) | | `planner` | 계절별 추천 하루 | `data(kind=planner)` | **없음** | (시연본은 꺼 둠) |
섹션이 아닌 것 하나가 더 있다 — 헤더 음악 플레이어(**T4**). 섹션이 아닌 것이 셋 더 있다 — 헤더 음악 플레이어(**T4**) · **군산 읽기**(**T7**, '군산 이야기'
탭 묶음의 여섯 번째 탭) · **엽서 쓰기**(독립 섹션, `festival` 바로 앞).
--- ---
## 1.3 할 일 여섯 ## 1.3 할 일 여덟
### T1 — 히어로 캐치프레이즈 ### T1 — 히어로 캐치프레이즈
@ -118,7 +119,10 @@
weather?: '맑음'|'구름많음'|'흐림'|'비'|'눈'; weather?: '맑음'|'구름많음'|'흐림'|'비'|'눈';
}[] }; }[] };
``` ```
2. 히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다. 2. **대표 문구(`tagline`)는 고정하고, 순환은 그 아래 줄에서 돈다** (2026-09-11 사장님 지시).
대표 문구 자리를 갈아 끼우면 숙소의 한 줄이 첫 화면에서 사라진다. 아래 줄은 대표 문구보다
한 단 작고 옅게, 높이는 가장 긴 문구에 맞춰 잡는다(히어로가 아래 정렬이라 줄 수가 바뀌면 튄다).
히어로가 **지금 조건에 맞는 것만 후보**로 두고 돌린다.
순환 순서는 `일반 → 계절 → 일반 → 월 → 일반 → 날씨` 한 바퀴를 만들고 그 바퀴를 돈다. 순환 순서는 `일반 → 계절 → 일반 → 월 → 일반 → 날씨` 한 바퀴를 만들고 그 바퀴를 돈다.
일반은 자루에서 뽑아 쓰고 비면 다시 채운다(같은 문장이 연달아 안 나오게). 일반은 자루에서 뽑아 쓰고 비면 다시 채운다(같은 문장이 연달아 안 나오게).
계절은 3개월 단위(3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울). 계절은 3개월 단위(3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울).
@ -161,7 +165,7 @@
|---|---|---|---| |---|---|---|---|
| **① 장소 대장** | LLM | 장소 30곳 × `{stayMinutes:{min,base,max}, todo, indoor, needsCar, bestTime, searchQuery}` | **1.4 ②** | | **① 장소 대장** | LLM | 장소 30곳 × `{stayMinutes:{min,base,max}, todo, indoor, needsCar, bestTime, searchQuery}` | **1.4 ②** |
| **② 좌표** | 코드 | 카카오 로컬로 `searchQuery` → 위경도. **LLM 이 적은 좌표는 쓰지 않는다** | — | | **② 좌표** | 코드 | 카카오 로컬로 `searchQuery` → 위경도. **LLM 이 적은 좌표는 쓰지 않는다** | — |
| **③ 조립** | **코드** | 이동시간·도착시각·끼니 창 맞추기·규칙 16종 감사 | 없음 (`build_itinerary.py`) | | **③ 조립** | **코드** | 이동시간·도착시각·끼니 창 맞추기·규칙 17종 감사 | 없음 (`build_itinerary.py`) |
| **④ 테마** | LLM | 테마 20개 × `{name, duration, audience, why, days[{kind, places[]}]}` | **1.4 ③** | | **④ 테마** | LLM | 테마 20개 × `{name, duration, audience, why, days[{kind, places[]}]}` | **1.4 ③** |
**코드가 하는 일(③)에서 반드시 옮겨야 하는 것** — 근거는 `build_itinerary.py` 줄번호 **코드가 하는 일(③)에서 반드시 옮겨야 하는 것** — 근거는 `build_itinerary.py` 줄번호
@ -174,7 +178,7 @@
| 창 맞추기 | 어긋나면 **앞 칸 체류를 범위(min~max) 안에서** 늘이고 줄인다. 이동시간을 고치지 않는다. 그래도 안 되면 시작 시각을 10분씩 옮기고, 그래도 안 되면 **그 조합을 만들지 않는다** | `plan_day()` | | 창 맞추기 | 어긋나면 **앞 칸 체류를 범위(min~max) 안에서** 늘이고 줄인다. 이동시간을 고치지 않는다. 그래도 안 되면 시작 시각을 10분씩 옮기고, 그래도 안 되면 **그 조합을 만들지 않는다** | `plan_day()` |
| 테마별 완급 | 시작 시각·체류 배율이 테마마다 다르다(쉬러 오면 1.25배, 사진 찍으면 0.8배) → 끝나는 시각이 13:35~21:00 로 퍼진다 | `:470` | | 테마별 완급 | 시작 시각·체류 배율이 테마마다 다르다(쉬러 오면 1.25배, 사진 찍으면 0.8배) → 끝나는 시각이 13:35~21:00 로 퍼진다 | `:470` |
| 끼니 간격 | 앞 끼니가 끝나고 **3시간**이 지나야 다음 끼니 창이 열린다(`MEAL_GAP`). 창만 지키면 통과하던 자리다 | `_window_of()` | | 끼니 간격 | 앞 끼니가 끝나고 **3시간**이 지나야 다음 끼니 창이 열린다(`MEAL_GAP`). 창만 지키면 통과하던 자리다 | `_window_of()` |
| **감사 16종** | 하나라도 어기면 **payload 를 쓰지 않고 멈춘다** | `:526` · 목록은 **2.4** | | **감사 17종** | 하나라도 어기면 **payload 를 쓰지 않고 멈춘다** | `:575` · 목록은 **2.4** |
★ **감사는 생성기 안에 둔다.** 밖에 두면 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생긴다 — ★ **감사는 생성기 안에 둔다.** 밖에 두면 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생긴다 —
실제로 그래서 33건이 통과했다(**3.2**). 실제로 그래서 33건이 통과했다(**3.2**).
@ -185,9 +189,15 @@
첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@ 첫날 체크인@ · 오후 · 오후 · 저녁 · 밤산책 · 복귀@
가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@ 가운데날 아침@ · 오전 · 점심 · 오후 · 오후 · 쉼@ · 저녁 · 복귀@
쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@ 쉬는날 숙소에서 쉬는 날@ · 점심 · 쉼@ · 저녁 · 복귀@
마지막날 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@ 마지막날(도보) 아침(퇴실)@ · 오전 · 점심 · 오후 · 마무리@ ← 짐을 맡기고 저녁에 찾으러 온다
마지막날(차) 아침(퇴실·차)@ · 오전 · 점심 · 오후 ← 짐을 싣고 나섰다, 복귀 칸이 없다
``` ```
★ **떠나는 날은 둘로 갈린다** (2026-09-14 대표 의견). 차로 온 손님에게 "짐을 맡기고 저녁에
찾으러 오세요" 는 **한 번 더 들르라는 말**이다 — 퇴실할 때 실으면 끝날 일이다. 그래서 차 테마의
마지막 날은 마지막 정거장에서 끝난다. 짐 보관 동선은 걸어 다니는 손님 것이고 그 테마만 남는다.
뼈대를 두 벌로 적지 않고 **복귀 칸만 떼어** 만든다(`build_itinerary.py` `CAR_KINDS`).
**완료 기준** 새 업장에서 테마 20개가 나오고 `audit()` 위반 0건. **완료 기준** 새 업장에서 테마 20개가 나오고 `audit()` 위반 0건.
하루가 숙소로 시작해 숙소로 끝나고, 끼니가 제 시각에 있고, 21시를 안 넘는다. 하루가 숙소로 시작해 숙소로 끝나고, 끼니가 제 시각에 있고, 21시를 안 넘는다.
@ -220,9 +230,18 @@
**할 일** `MediaItem` 에 `kind:'audio'`(+`title` `artist` `durationSec`)를 넣고 발행 때 같이 올린다. **할 일** `MediaItem` 에 `kind:'audio'`(+`title` `artist` `durationSec`)를 넣고 발행 때 같이 올린다.
헤더에 붙는 작은 플레이어를 만든다 — 아이콘 · 현재 곡명 · 재생/멈춤 · 목록. 헤더에 붙는 작은 플레이어를 만든다 — 아이콘 · 현재 곡명 · 재생/멈춤 · 목록.
**자동재생은 넣지 않는다**(2026-09-11 지시). 순서는 **랜덤 시작 후 순차**. 순서는 **랜덤 시작 후 순차**.
목업 구현은 `inject.js` 의 `startPlayer()`, 모양은 `inject.css` 의 `#w4d-mini` · `#w4d-panel`. 목업 구현은 `inject.js` 의 `startPlayer()`, 모양은 `inject.css` 의 `#w4d-mini` · `#w4d-panel`.
★ **자동재생한다**(2026-09-14 대표 지시 — 2026-09-11 의 "자동 빼" 를 뒤집은 것이다).
다만 **브라우저가 막는다.** 크롬·사파리는 손님이 페이지를 한 번도 만지지 않았으면 소리 나는
재생을 거절한다(정책이고, 거절된 약속으로 온다). 실측 2026-09-14, 크롬: 불러온 직후 재생 0,
세로 스크롤 한 번에 0:02 부터 재생. 그래서 구현이 **두 단계**다 —
① 불러오자마자 걸어 본다 ② 막히면 손님이 **처음 만지는 순간**(스크롤·클릭·키·터치) 켠다.
→ 첫 손길 리스너는 `#w4d-mini`·`#w4d-panel` 안에서 난 이벤트를 **흘려보내야 한다.**
예전 판은 window 캡처라 재생 단추의 클릭을 먼저 먹었고, 단추 핸들러가 다시 꺼서
두 번 눌러야 켜졌다(2026-09-11). 검사는 `audit-all.mjs` 의 `자동재생`.
--- ---
### T5 — 상한(`maxItems`) ### T5 — 상한(`maxItems`)
@ -254,6 +273,68 @@
--- ---
### T7 — 도시를 펼쳐 보이는 자리
**증상** 도시 이야기를 담는 자리가 전부 한 번에 하나만 보인다. 하루에 한 장만 보여주는
일력이나, 넉 장짜리 엽서 레일 정도로는 군산이라는 도시를 소개하기엔 부족하다.
**할 일** 문학·역사·섬과 바다·장소·음식처럼 갈래로 나눈 도시 이야기를, 한 화면에서
옆으로 넘겨 가며 볼 수 있는 자리를 만든다.
1. 이 콘텐츠는 **사장님마다 새로 만드는 게 아니다.** 군산이라는 지역이 공유하는 고정
자료다(계절별 축제·주변 명소와 같은 성격) — 사이트를 만들 때마다 AI 가 새로 글을
지어내는 게 아니라, 군산에 있는 모든 사이트가 같은 이야기 목록을 나눠 쓴다.
2. 항목마다 출처 링크를 단다. 정확한 문서 주소를 일일이 확인하기 어려우니, 제목으로
검색되는 링크(예: 네이버 검색)를 걸어 둔다 — 없는 주소를 지어내 걸지 않는다.
3. 옆으로 넘겨 보는 방식은 **이 사이트가 이미 쓰고 있는 것**을 그대로 쓴다(사진 갤러리·
추천 일정에 쓰는 그 슬라이드). 새로 스크롤 상자를 만들지 않는다. 한 슬라이드에는
이야기 하나만 크게 보여준다 — 여러 개를 한 슬라이드에 욱여넣지 않는다.
4. 한 번 볼 때 전체 이야기를 다 보여주지 않는다. 그중 몇 개만 무작위로 뽑아 보여주고,
다시 오거나 새로고침하면 다른 조합이 뜬다. 화면 문구에 "전체 몇 개"라고 숫자를
박지 않는다 — 실제로 보이는 개수와 어긋나면 안 되기 때문이다.
5. 화면 안에서는 '군산 이야기'(가요 다방·인물 열전 등이 모여 있는 탭 묶음)의 탭
하나로 들어간다 — 독립된 섹션으로 따로 빼지 않는다. 다른 탭들처럼 탭 안에도
제목을 그대로 보여준다.
6. 이미 다른 자리에 나온 이야기는 다시 넣지 않는다 — 인물·가요·축제·연표·명소·맛집이
각자 자리를 갖고 있다. 같은 이야기를 두 번 보여주면 페이지만 길어진다.
**완료 기준** 갈래를 가리지 않고 훑어볼 수 있고, 이야기마다 출처가 붙어 있고, 넘기는
방식이 이 사이트에서 이미 쓰는 방식과 같다.
---
### T8 — 엽서 쓰기
**증상** '오늘의 엽서'는 미리 써 둔 문구를 보여주기만 했다. 손님이 직접 한마디를 써서
자기만의 엽서를 만들고, 그걸 저장하거나 공유할 수 있게 바꾼다.
**이건 AI 가 글을 짓는 기능이 아니다.** 새 데이터 형식이나 문구를 설계할 필요 없이,
이미 만들어 확인까지 끝낸 화면 기능 하나를 그대로 옮겨 붙이면 되는 일이다.
1. 숙소 사진(가진 사진 전체를 보여주고 손님이 그중 하나를 고르게 한다) + 손님이
직접 쓴 한마디 + 우표·소인 모양을 합쳐서 한 장의 카드 이미지로 만든다.
2. 문구는 최대 네 줄까지만 쓸 수 있게 막는다. 줄이 넘어가면 자동으로 다음 줄로
넘어가는데, 띄어쓰기 없이 길게 이어 쓴 글자도 카드 밖으로 삐져나오지 않고
글자 단위로 잘 접혀야 한다.
3. 완성한 카드는 사진으로 저장하거나 공유할 수 있다. 공유할 때는 카드 이미지와
함께, 지금 보고 있는 이 사이트 페이지로 가는 링크를 같이 보낸다. 손님이 쓴
문구는 이미 카드 그림 안에 박혀 있으니, 공유 문구 자리에 그 글을 따로 한 번 더
적지 않는다 — 같은 말이 두 번 보이면 안 된다. 서버에 새로 올리는 기능을 만들
필요 없이 휴대폰·브라우저가 원래 갖고 있는 공유 기능을 그대로 쓰고, 그래서 이
기능은 서버를 한 번도 부르지 않는다.
4. 공유 버튼이나 안내 문구에 특정 메신저 이름을 못 박지 않는다 — 기기에 따라
실제로 뜨는 공유 대상이 다르다(휴대폰은 설치된 메신저가 뜨지만, 컴퓨터 특히
맥은 카카오톡이 그 목록에 안 뜬다). 버튼 문구는 손끝으로 조작하는 기기에서만
"카카오톡 등으로 공유"라고 쓰고, 그 외에는 그냥 "공유하기"라고 쓴다.
5. 상호명을 코드 안에 고정으로 박아 넣지 않는다 — 이 기능은 다른 숙소나 카페에서도
그대로 가져다 써야 한다.
6. 화면 위치는 "계절별 축제" 섹션 바로 앞에 독립된 섹션으로 둔다.
**완료 기준** 손님이 사진 고르고 글 쓰고 저장·공유하는 것까지 한 화면에서 끝난다.
새 서버 기능이나 AI 프롬프트가 필요 없다.
---
## 1.4 프롬프트 전문 ## 1.4 프롬프트 전문
**프롬프트는 지금 두 파일에 나뉘어 있다. 이게 문제다.** **프롬프트는 지금 두 파일에 나뉘어 있다. 이게 문제다.**
@ -307,7 +388,7 @@ backend/services/prompts/section_prompts.json ← 산출물. 손으로 고치
창고형 카페 공간 · 침구 매일 세탁 · 히로쓰 가옥 옆 · 초원사진관 도보 3분} 창고형 카페 공간 · 침구 매일 세탁 · 히로쓰 가옥 옆 · 초원사진관 도보 3분}
[해야 할 일] [해야 할 일]
[업소] 의 히어로 문구를 100개 만든다. 상호 바로 아래에 한 줄로 뜨고 7초마다 바뀐다. [업소] 의 히어로 문구를 100개 만든다. 상호와 대표 문구 아래에 한 줄로 뜨고 7초마다 바뀐다.
네 종류로 나눠 만든다. 네 종류로 나눠 만든다.
· 일반 40개 — 언제 나와도 되는 문구 · 일반 40개 — 언제 나와도 되는 문구
@ -552,9 +633,11 @@ backend/services/prompts/section_prompts.json ← 산출물. 손으로 고치
stay-payload.json 원본 payload (건드리지 않는다 · 되돌릴 때 기준) stay-payload.json 원본 payload (건드리지 않는다 · 되돌릴 때 기준)
orig/index.html 원본 HTML (건드리지 않는다) orig/index.html 원본 HTML (건드리지 않는다)
│ │
├─ build_itinerary.py 여행 일정을 계산해 넣는다 (+ 규칙 16종 감사) ├─ build_itinerary.py 여행 일정을 계산해 넣는다 (+ 규칙 17종 감사)
├─ build_story.py 가요 다방 · 인물 열전을 넣는다 ├─ build_story.py 가요 다방 · 인물 열전을 넣는다
└─ patch_stay.py 캐치프레이즈 · 객실 사진 · 자작곡 · 주입 CSS/JS · 자산 경로 교정 ├─ build_reading.py 군산 읽기(카로셀 34꼭지, 화면엔 매번 5~6개 무작위)를 넣는다
├─ build_daily.py 오늘의 한 장(일력 39장)을 넣는다 — 가요·인물·문학을 읽어 쓰므로 맨 뒤
└─ patch_stay.py 캐치프레이즈 · 주변 맛집 · 객실 사진 · 자작곡 · 주입 CSS/JS · 자산 경로 교정
│ │
▼ ▼
build/index.html ← 배포하는 파일 (git 이그노어) build/index.html ← 배포하는 파일 (git 이그노어)
@ -599,19 +682,27 @@ node audit-all.mjs
### 내용을 바꾼 뒤 (전체 굽기) ### 내용을 바꾼 뒤 (전체 굽기)
```bash ```bash
python3 build_itinerary.py && python3 build_story.py && python3 patch_stay.py python3 build_itinerary.py && python3 build_story.py && python3 build_reading.py \
&& python3 build_daily.py && python3 patch_stay.py
``` ```
**순서를 지킨다.** `build_itinerary.py` 가 `stay-payload.json`(원본)을 읽어 **순서를 지킨다.** `build_itinerary.py` 가 `stay-payload.json`(원본)을 읽어
`stay-payload-new.json` 을 만들고, 뒤 둘이 그 파일을 이어서 고친다. `stay-payload-new.json` 을 만들고, 뒤 셋이 그 파일을 이어서 고친다.
순서를 바꾸면 앞 단계 결과가 지워진다. 순서를 바꾸면 앞 단계 결과가 지워진다.
`build_daily.py` 는 가요 다방·인물 열전·군산 읽기를 **읽어서** 일력을 만든다 — 맨 뒤여야 한다.
손으로 적는 자리가 한 곳이어야 해서다: 문학 8꼭지의 문장은 `build_reading.py` 에만 있다.
주변 맛집을 다시 받을 때만 `solution/backend` 에서 `.venv/bin/python ../site/scripts/mockup/fetch_restaurants.py` 를 먼저 돌린다(TourAPI 키가 필요하다).
굽기는 그 결과 파일만 읽는다 — 굽기가 키에 매이면 다른 기계에서 못 굽는다.
그다음 `python3 fill_restaurant_photos.py` 로 사진 없는 집의 사진을 받는다. ⚠ 이 11장은 블로그·네이버 플레이스·관광 사이트에 올라온 것이라 **권리가 게시자에게 있다** — 시연 뒤 내리거나 사장님 사진으로 바꾼다. 제품으로 옮기지 않는다.
로컬 반영 · 검사: 로컬 반영 · 검사:
```bash ```bash
docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html
node audit-all.mjs # 34개 항목 node audit-all.mjs # 47개 항목
node rails-test.mjs # 카로셀 13개 node rails-test.mjs # 카로셀 13개 (모바일은 +3)
python3 audit_schedule.py # 일정 전수 검수표(SCHEDULE.md)
``` ```
`/s/` 는 **5분 캐시**(`max-age=300`)다 — 확인할 때 ⌘⇧R. `/s/` 는 **5분 캐시**(`max-age=300`)다 — 확인할 때 ⌘⇧R.
@ -645,14 +736,32 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
| 자리 | 줄 | 무엇 | | 자리 | 줄 | 무엇 |
|---|---|---| |---|---|---|
| `GENERAL` | `:20` | 일반 40개 | | `GENERAL` | `:29` | 일반 40개 |
| `SEASON` | `:62` | 계절별 24개 — 봄/여름/가을/겨울 각 6 | | `SEASON` | `:71` | 계절별 24개 — 봄/여름/가을/겨울 각 6 |
| `MONTH` | `:72` | 월별 24개 — 1~12월 각 2 | | `MONTH` | `:81` | 월별 24개 — 1~12월 각 2 |
| `WEATHER` | `:87` | 날씨별 12개 — 맑음/구름많음/흐림/비/눈 | | `WEATHER` | `:96` | 날씨별 12개 — 맑음/구름많음/흐림/비/눈 |
문장만 더하거나 지우면 된다. 개수 제한은 없다. 문장만 더하거나 지우면 된다. 개수 제한은 없다.
순환 순서·계절 구분·날씨 연동은 **1.3 T1** 에 적어 둔 그대로다(`inject.js`). 순환 순서·계절 구분·날씨 연동은 **1.3 T1** 에 적어 둔 그대로다(`inject.js`).
### 오늘의 날씨 문구 50개 → `patch_stay.py`
| 자리 | 줄 | 무엇 |
|---|---|---|
| `SKY_NOTES` | `:114` | 하늘 25개 — 맑음/구름많음/그외/비/눈 각 5. **집 안**에서 뭘 할지 |
| `TEMP_NOTES` | `:152` | 기온대 25개 — 혹서/더움/선선/쌀쌀/추움 각 5. **밖에서** 어디를 갈지 |
케이스마다 여러 벌이고 20초마다 자루에서 다음 것이 나온다(`inject.js` ⑥).
굽는 판에는 케이스마다 **첫 문장**이 박히고, 나머지는 `local.weather.noteSets` ·
`tempNoteSets` 로 실려 주입분만 읽는다 — 렌더러 계약에 없는 칸이라 그대로 지나간다.
- 개수 제한은 없다. 다만 **문장이 겹치면 빌드가 멈춘다** — 주입분이 DOM 에서 바꿀 줄을
문장으로 찾기 때문에, 같은 문장이 둘이면 어느 줄인지 못 가른다.
- 이름을 대는 곳은 이 사이트에 **이미 있는 자리**만 쓴다(주변 안내 12곳 · 맛집 31곳).
화면에 없는 곳을 날씨 칸에서만 권하면 손님이 그걸 찾을 데가 없다.
- 기온대 경계(30·25·20·10)는 렌더러가 정한다(`site/src/lib/derive.ts weatherBand`).
화면의 배지는 렌더러가 붙이고 문장만 주입분이 갈아 끼우므로, 경계를 바꾸면 둘이 어긋난다.
### 여행 일정 → `build_itinerary.py` ### 여행 일정 → `build_itinerary.py`
**사람은 "어느 자리에 어느 장소" 만 고르고, 시각은 스크립트가 계산한다.** **사람은 "어느 자리에 어느 장소" 만 고르고, 시각은 스크립트가 계산한다.**
@ -660,17 +769,55 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
| 자리 | 줄 | 무엇 | | 자리 | 줄 | 무엇 |
|---|---|---| |---|---|---|
| `PLACES` | `:40` | **장소 대장** — 좌표 · 지도 검색어 · 체류시간(min/base/max) · **거기서 무엇을 하는지** | | `PLACES` | `:40` | **장소 대장** — 좌표 · 지도 검색어 · 체류시간(min/base/max) · **거기서 무엇을 하는지** |
| `STAY_ACTION` | `:108` | 머뭄 칸의 행위 — 체크인 · 아침 · 아침(퇴실) · 쉼 · 쉬는 날 · 머뭄으로 · 마무리 | | `STAY_ACTION` | `:108` | 머뭄 칸의 행위 — 체크인 · 아침 · 아침(퇴실) · **아침(퇴실·차)** · 쉼 · 쉬는 날 · 머뭄으로 · 마무리 |
| `WINDOW` | `:156` | 시간대가 정해진 자리 | | `WINDOW` | `:162` | 시간대가 정해진 자리 |
| `SKELETON` | `:182` | 하루 뼈대. `@` 가 붙은 것은 머뭄 | | `SKELETON` | `:194` | 하루 뼈대. `@` 가 붙은 것은 머뭄. **떠나는 날은 차·도보 두 벌**(`CAR_KINDS` 가 복귀 칸을 떼어 만든다) |
| `THEMES` | `:368` | 테마 21개 | | `THEMES` | `:414` | 테마 21개. 마지막 날 뼈대가 `last_car` 계열이면 **차로 떠나는 테마**다(지금 14개 — 1박 2일 7 · 2박 3일 7) |
| `PACING` | `:470` | 테마별 시작 시각과 체류 배율 | | `PACING` | `:516` | 테마별 시작 시각과 체류 배율 |
**새 장소** → `PLACES` 에 한 줄. 좌표는 카카오 로컬에서 받은 실제 값이어야 한다(지어내지 않는다). **새 장소** → `PLACES` 에 한 줄. 좌표는 카카오 로컬에서 받은 실제 값이어야 한다(지어내지 않는다).
그다음 `THEMES` 의 원하는 자리에 이름을 적는다. 그다음 `THEMES` 의 원하는 자리에 이름을 적는다.
**새 테마** → `THEMES` 에 한 덩이, `PACING` 에 한 줄. 뼈대의 `@` 없는 자리 수와 장소 개수가 **새 테마** → `THEMES` 에 한 덩이, `PACING` 에 한 줄. 뼈대의 `@` 없는 자리 수와 장소 개수가
같아야 한다 — 틀리면 빌드가 멈추고 알려 준다. 같아야 한다 — 틀리면 빌드가 멈추고 알려 준다.
### 군산 읽기 → `build_reading.py`
| 자리 | 무엇 |
|---|---|
| `GROUPS` | 갈래와 그 아래 한 줄. 화면에 이 순서로 선다 |
| `ITEMS` | 꼭지 34개 — (갈래, 제목, 글, 연도). **문학 문장의 단일 출처**다 |
| `naver(title)` | 항목마다 다는 출처 — 네이버 검색 링크 하나로 통일(아래 참고) |
- **이미 다른 섹션에 선 것은 넣지 않는다.** 인물 57 · 가요 25 · 축제 10 · 연표 7 · 명소 12 ·
맛집 31 이 각자 자리를 갖는다. 받은 엑셀 52주제에서 그걸 빼면 34꼭지가 남는다.
- 연도는 **확인된 것만** 적는다(연표에서 이미 확인한 값, 엑셀이 명시한 1944 등).
- 글은 **3~5문장**이다(2026-09-14 대표: "2줄있는애들 최소 3-5줄 이였으면 좋겠오") —
두 문장짜리 21꼭지를 네 문장으로 늘렸다. 새로 보탠 문장은 없는 사실을 지어낸 게 아니라
널리 알려진 배경 설명이다.
- **`verified`("확인"/"확인필요") 필드는 뺐다.** 대신 `source` 를 **네이버 검색 링크**로
통일했다(`naver(title)` — `https://search.naver.com/search.naver?query=...`). 기관·위키
링크를 항목마다 골라 달던 방식(`CHAE`·`STAMP`·`TOUR`·`WIKI`·`KCI`·`GUIDE` 상수)은 없앴다 —
개별 문서 URL 은 실제로 확인 안 하고 적으면 없는 문서로 이어질 위험이 있어서다(레포 절대
규칙: 없는 사실은 안 짓는다). 검색 링크는 제목을 그대로 넘기니 항상 관련 결과로 뜬다.
- 모양은 `inject.css` `.w4d-read-*` — 이 사이트의 실제 카로셀(embla)이다. 신문 조판(제호·면·단)
은 한 번 시도했다가 되돌렸다 — 경위는 **2.5 항목 3**.
- 화면은 **전체 중 5~6개만 매번 무작위로** 보여준다(갈래 안 가림) — payload 에는 34개가
전부 있고, `subtitle` 에도 전체 개수를 안 박는다(**2.5 항목 3**).
### 오늘의 한 장(일력) → `build_daily.py`
| 자리 | 무엇 |
|---|---|
| `POETS` | 시인 6명. 이름만 적으면 인물 열전에서 한 줄과 위키 출처를 가져온다 |
| (자동) | 가요 25장은 가요 다방을, 문학 8장은 군산 읽기를 **그대로 읽는다.** 목록을 두 벌로 두지 않는다 |
- 이 자리는 **도시를 소개하는 자리가 아니다** — 세 장만 화면에 선다. 소개는 '군산 읽기' 가 한다.
- 날짜는 **고르게 편다**(39장 → 9일 간격). 곡이 나온 날·작가의 생일에 맞추고 싶지만
그 날짜를 출처로 확인한 것이 없다 — 지어낸 날짜는 해마다 틀린다.
- 오늘 자리가 비면 렌더러가 **다음 장**을 편다(`DailySection.tsx`). 365장을 채우려면
같은 글을 여러 날짜에 복사해야 하는데, 인용되려고 만든 사이트에서 할 일이 아니다.
- 받은 자료(`gunsan_365_story_db.xlsx`)를 그대로 싣지 않는 이유는 `build_daily.py` 머리말에 있다.
### 가요 다방 · 인물 열전 → `build_story.py` ### 가요 다방 · 인물 열전 → `build_story.py`
| 자리 | 줄 | 무엇 | | 자리 | 줄 | 무엇 |
@ -685,13 +832,13 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
- ★ 이름만으로 커먼즈를 검색하지 않는다(**1.3 T3**). - ★ 이름만으로 커먼즈를 검색하지 않는다(**1.3 T3**).
- 숙소가 만든 곡은 이 섹션에 넣지 않는다. 도시의 노래를 모으는 자리다. - 숙소가 만든 곡은 이 섹션에 넣지 않는다. 도시의 노래를 모으는 자리다.
### 객실 사진 → `patch_stay.py` `ROOM_PHOTOS`(`:142`) · `rooms.json` ### 객실 사진 → `patch_stay.py` `ROOM_PHOTOS`(`:267`) · `rooms.json`
--- ---
## 2.4 규칙 16종 — 어기면 빌드가 멈춘다 ## 2.4 규칙 17종 — 어기면 빌드가 멈춘다
`build_itinerary.py` 의 `audit()`(`:526`)이 매 실행 검사한다. `build_itinerary.py` 의 `audit()` 가 매 실행 검사한다.
하나라도 어기면 **payload 파일을 쓰지 않고** 위반 목록을 찍고 멈춘다. 하나라도 어기면 **payload 파일을 쓰지 않고** 위반 목록을 찍고 멈춘다.
| | 규칙 | 이 규칙이 없어서 났던 일 | | | 규칙 | 이 규칙이 없어서 났던 일 |
@ -712,24 +859,91 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
| 14 | 아이 동반 테마에 술집 금지 | | | 14 | 아이 동반 테마에 술집 금지 | |
| **15** | **끼니 사이 3시간 이상** | 늦은 점심 15:47 종료 → 저녁 17:30. 창은 둘 다 합법인데 배가 안 꺼진다 | | **15** | **끼니 사이 3시간 이상** | 늦은 점심 15:47 종료 → 저녁 17:30. 창은 둘 다 합법인데 배가 안 꺼진다 |
| **16** | **볼거리는 편도 이동보다 오래 머문다**(끼니 제외) | 사장님 지적 — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리라 뺀다 | | **16** | **볼거리는 편도 이동보다 오래 머문다**(끼니 제외) | 사장님 지적 — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리라 뺀다 |
| **17** | **떠나는 날의 끝맺음이 테마와 맞는다** — 차 테마는 복귀 칸이 없고, 도보 테마는 짐을 찾는 `마무리` 칸으로 끝난다 | 2026-09-14. 한쪽만 고치면 차인데 숙소로 되돌아오거나(한 번 더 들르기) 맡긴 짐을 못 찾고 끝난다 — 둘 다 화면만 봐서는 멀쩡하다 |
★ **왜 빌드 안인가** — 전에는 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다. ★ **왜 빌드 안인가** — 전에는 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다.
그래서 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생겼고 사장님이 화면에서 먼저 찾으셨다. 그래서 뼈대를 고칠 때마다 "이번에 안 본 규칙"이 생겼고 사장님이 화면에서 먼저 찾으셨다.
**검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다.** **검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다.**
`audit-all.mjs` 는 그다음 단계다 — 구워진 HTML 을 **실제 브라우저로 열어** 34개 항목을 센다. `audit-all.mjs` 는 그다음 단계다 — 구워진 HTML 을 **실제 브라우저로 열어** 47개 항목을 센다.
--- ---
## 2.5 주입분(`inject.js` · `inject.css`) 다섯 ## 2.5 주입분(`inject.js` · `inject.css`) 아홉
렌더러에 없어서 이 목업에만 얹은 것이다. 제품으로 옮길 자리는 1부에 적었다. 렌더러에 없어서 이 목업에만 얹은 것이다. 제품으로 옮길 자리는 1부에 적었다.
1. **캐치프레이즈 순환** — React 가 그린 `<p>` 를 찾아 단어 단위로 갈아 끼운다 → T1 1. **캐치프레이즈 순환** — 대표 문구 `<p>` 를 찾아 **그 아래에 줄(`#w4d-sub`)을 붙이고** 단어 단위로 갈아 끼운다. 대표 문구는 안 건드린다 → T1
2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡 → T4 2. **헤더 미니 플레이어** — 카세트 아이콘 + 재생/멈춤 + 목록. 자작곡 5곡.
3. **링크 걸기** — 일정 정거장 187개 · 엽서 4개를 네이버 지도로 **자동재생**은 걸어 보고, 브라우저가 막으면 첫 손길에 켠다(**1.3 T4**) → T4
4. **사진 저작자 표시** — 이 목업이 물고 있는 번들이 옛 판이라 `imageCredit` 을 안 그린다 → T3 3. **군산 읽기** — 렌더러에 없는 섹션. '군산 이야기' 탭 묶음의 **여섯 번째 탭**으로 들어가
5. **카로셀 제어** — 아래 34꼭지 중 5~6개를 매번 무작위로 카로셀(embla, 한 슬라이드에 한 꼭지) 로 보여준다.
`startReading()` 이 story 탭 패널들의 마지막(postcard) 뒤에 자기 패널을 꽂고,
자기 탭 버튼은 진짜 탭들의 `[role="tablist"]` 끝에 붙인다 → **T7**
- 처음엔 "신문 조판"(제호·면·단)으로 시작했다가, 3~4개씩 갈래별로 뽑아 한 카로셀에
잇는 중간 단계를 거쳐, 지금은 갈래를 안 가리고 **전체에서 5~6개**로 정착했다
(2026-09-14 하루 안에서: "신문처럼" → "포스트 하나씩만…나랑 장난하자는거?" →
"문학면? 말이 안 되는거 같네" → "34개중 15~19개인데 왜 34개라고 써놔?" →
"그냥 5-6개 정도만…랜덤으로").
- ★ **탭 전환 버그** (2026-09-14 대표 제보: "시간의 골목 누르면 사라지기만 함,
탭활성화도 그렇고"). 진짜 탭 다섯 개의 `hidden`·`aria-selected`·style 은 전부
React 가 `active` state 로 매번 다시 계산해 주는 값인데, 예전 코드가 내 탭으로
넘어갈 때 그 값들을 **직접 덮어썼다.** 그 뒤 손님이 (내 탭에 있다가) **직전까지
활성이던 그 탭을 다시 누르면** `setActive(sameIndex)` 는 React 입장에서 상태가
안 바뀐 것이라 **리렌더를 생략한다**(bail-out) — 내가 덮은 값이 영영 안 돌아왔다.
→ **React 가 매 렌더 계산해 주는 속성은 아예 안 건드린다.** 대신 내가 전적으로
소유한 `style.display`(JSX 에 없는 속성이라 React 가 절대 안 건드린다)로만 가리고,
되돌아올 때는 그 오버라이드만 지운다 — `hidden` 은 그동안 한 번도 틀린 적이 없으니
지우자마자 바로 맞다. 방금 누른 진짜 탭의 스타일도 직접 켠다(React 리렌더가
생략돼도 항상 맞게).
4. **링크 걸기** — 일정 정거장 187개 · 엽서 4개를 네이버 지도로
5. **사진 저작자 표시** — 이 목업이 물고 있는 번들이 옛 판이라 `imageCredit` 을 안 그린다 → T3
6. **카로셀 제어** — 아래
7. **날씨 카드 문구 순환** (`startWeatherNote`) — 하늘 5 · 기온대 5, 케이스마다 다섯 벌을
자루에서 뽑아 20초마다 갈아 끼운다. 렌더러는 케이스마다 한 칸씩만 읽는다.
하늘이 다섯인 것도 여기서만 가능하다 — 카드는 넷으로만 가르므로 구름많음(1~3)과
그 외(안개·소나기·뇌우…)가 `notes.흐림` **한 칸**을 같이 본다 → **2.3**
8. **가요 다방 판꽂이** (`inject.css` + `inject.js` `startSongsRail()`) — 렌더러는 판을
`flex-wrap` 으로 편다. 4곡일 때 만든 모양이라 25곡이면 세 줄, 50곡이면 여섯 줄짜리
음반 진열대가 된다. 한 줄 가로 스크롤로 눕힌다(넘치면 옆으로 민다). **곡명은 항상
보인다** — 렌더러가 원래 판 아래에 그리는 그 글자를 그대로 두고 줄 폭만 맞춘다.
★ 한 번 이 자리를 hover 로만 곡명이 뜨게 고친 적이 있다(2026-09-14, 판을 겹쳐 꽂고
손을 얹거나 고른 판에만 이름표를 띄우는 식). 데스크톱 벽은 없앴지만 모바일엔 hover 가
없어 **곡명이 아예 안 보이는 화면**이 됐다 — 데스크톱만 보고 고친 값이었다(같은 날 대표
지적으로 되돌림).
★ **화살표 + 실제 카로셀**(같은 날: "옆으로 갈수있는 버튼도 주고 slider.js 적용해줘").
렌더러가 이 판 목록을 `Carousel.tsx` 로 안 감싸서(그냥 `flex flex-wrap` 상자다)
"군산 읽기" 처럼 기존 `.slider-viewport` 를 마크업에서 못 가져다 쓴다 — 그래서
`startSongsRail()` 이 그 상자(`:has()` 로 잡는 판 목록 div) 하나를 통째로 새 상자로
감싸 `.slider-viewport` 를 붙이고, 그 위에 embla-carousel 코어(`vendor/embla-carousel.umd.js`,
군산 읽기와 같은 라이브러리)를 얹는다. **판 버튼 25개를 옮기지 않고 그 부모 하나만
옮긴다** — React 는 그 노드를 그대로 갖고 있으니(`setPlaying` 이 속성만 고친다) 감싼
뒤에도 클릭이 그대로 먹는다.
★ **마크업을 고치지 않는다**(React 가 되돌린다) — `:has()` 로 판이 든 상자만 집는다.
→ 렌더러도 같은 문제다(**3.4**)
9. **엽서 쓰기** (`postcardMaker()`, `#w4d-pm-section`) — "오늘의 엽서" 탭을 비우고
(`patch_stay.py` 가 `postcard` 섹션 `data` 를 빈 `items:[]` 로 덮는다), 그 자리 대신
**손님이 직접 문구를 써서 자기 것으로 저장·공유하는 카드**를 `festival` 섹션 바로 앞에
독립 섹션으로 얹는다(2026-09-14: "오늘의 엽서 칸을 빼고 이 엽서 포맷으로…이건 축제소개
전에 섹션으로 빼줘"). 캔버스(1080×1080)에 숙소 사진(`P.media` 전체, 카로셀로 고른다) +
문구 + 우표·소인을 그려 PNG 로 만들고, `navigator.share({files:[...]})` (Web Share API)
로 공유하거나 다운로드한다 — 새 익명 업로드 백엔드를 만들지 않으려고 파일 공유 쪽을 골랐다
(`solution/backend` 미디어 라우터는 전부 로그인 필요, `IsValidAccessToken`).
- 공유 버튼 문구는 기기별로 다르다 — `matchMedia('(pointer: coarse)')` 로 터치 기기에서만
"카카오톡 등으로 공유", 나머지(주로 macOS 데스크톱)는 "공유하기". macOS 는 카카오톡이
시스템 공유 확장을 안 만들어 시트에 메시지·메모 정도만 뜨는 게 OS 사양이라 그렇다.
- 상호명(placeName)·소인 텍스트·플레이스홀더 전부 하드코딩 안 한다 — 다른 숙소·카페도
이 스크립트를 그대로 쓸 수 있어야 해서다("스테이머뭄뿐만 아니라 다른 숙박업소나 카페도
써야 하는데"). 소인은 `stampLines(name, maxWidth)` 가 콤마·공백 경계로, 안 되면
글자 단위로 접는다.
- ★ **줄바꿈이 폭을 안 넘던 버그** (2026-09-14 대표 제보 — 스크린샷: 공백 없이 이어 친
글자열이 우표 칸까지 넘쳐 나감). `wrapLines()` 가 **공백 단위로만** 접어서, 단어 하나가
이미 칸보다 넓으면 아예 안 잘랐다. → `stampLines()` 와 같은 글자 단위 폴백을 추가했다.
- ★ **4줄 제한** (2026-09-14 대표: "4줄이상 못쓰게 막으라고"). 글자 수로는 몇 줄이 될지
못 잰다(줄바꿈은 폭·서체에 달렸다) — `textarea` 의 `input` 이벤트마다 `wrapLines()` 로
**실제로 접히는 줄 수**를 재서, 4줄을 넘으면 방금 입력을 되돌린다(엔터를 여러 번 눌러도,
자음만 이어 쳐도 똑같이 막힌다). `draw()` 쪽에도 4줄로 자르고 말줄임표(…)를 붙이는
방어선을 하나 더 둔다 — 붙여넣기처럼 입력 쪽 검사를 안 거치는 경로까지 대비한다.
### 카로셀 제어 (`tameRails`) ### 카로셀 제어 (`tameRails`)
@ -765,7 +979,7 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
★ **React 가 다시 그리면 사라진다.** 그래서 ★ **React 가 다시 그리면 사라진다.** 그래서
- 플레이어 목록·패널은 `#root` **밖**(body 직속)에 둔다 - 플레이어 목록·패널은 `#root` **밖**(body 직속)에 둔다
- 헤더에 꽂는 단추는 `MutationObserver` 로 감시해 다시 꽂는다 - 헤더에 꽂는 단추는 `MutationObserver` 로 감시해 다시 꽂는다
- 캐치프레이즈는 노드를 들고 있지 않고 **매 순환마다 다시 찾는다** - 캐치프레이즈 줄은 노드를 들고 있지 않고 **매 순환마다 붙어 있는지 보고, 없으면 대표 문구를 다시 찾아 새로 붙인다**
★ **`window.load` 를 기다리지 않는다.** 경로 지도 때문에 OSM 타일 `<img>` 가 140장이라 ★ **`window.load` 를 기다리지 않는다.** 경로 지도 때문에 OSM 타일 `<img>` 가 140장이라
`load` 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. `load` 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다.
@ -787,6 +1001,15 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
바꾸는 것이라, SSR 마크업과 `llms.txt` 는 구운 날 그대로다. 시연에는 문제없지만 바꾸는 것이라, SSR 마크업과 `llms.txt` 는 구운 날 그대로다. 시연에는 문제없지만
검색·AI 에 인용되는 값은 아니다. 검색·AI 에 인용되는 값은 아니다.
- **`og:image`** 도 마찬가지다. 화면은 새 사진, 카톡 공유 썸네일만 옛것이다. - **`og:image`** 도 마찬가지다. 화면은 새 사진, 카톡 공유 썸네일만 옛것이다.
- **React 가 `hidden`·`aria-selected`·style 로 계산해 주는 속성은 주입분이 직접 안 건드린다.**
건드리면 그 값과 React 내부 상태(`useState`)가 어긋나고, 손님이 **같은 값으로 되돌리는
클릭**(예: 방금까지 활성이던 탭을 다시 누름)을 하면 `setState` 가 같은 값이라 **리렌더를
생략**해 버려(bail-out) 주입분이 덮은 값이 영영 안 돌아온다(2026-09-14, 군산 읽기 탭
버그 — **2.5 항목 3**). React 가 그 요소에 아예 안 쓰는 속성(`style.display` 등)으로만
가리고, 걷을 때는 그 오버라이드만 지운다.
- **캔버스 줄바꿈은 공백 기준만으로는 모자란다.** 단어 하나가 이미 칸보다 넓으면
(예: 공백 없이 이어 친 자·모음) 안 잘리고 그대로 넘친다 — 글자 단위 폴백을 같이 둬야
한다(`wrapLines()`·`stampLines()`, **2.5 항목 9**).
--- ---
--- ---
@ -799,7 +1022,7 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
1. **실행하지 않은 것을 됐다고 쓰지 않는다.** 코드에서 빠진 것을 보는 건 확인이 아니다. 1. **실행하지 않은 것을 됐다고 쓰지 않는다.** 코드에서 빠진 것을 보는 건 확인이 아니다.
화면을 열어 세거나 명령을 돌려서 **숫자·출력**을 얻은 뒤에 말한다. 화면을 열어 세거나 명령을 돌려서 **숫자·출력**을 얻은 뒤에 말한다.
2. **"했다" 옆에 무엇으로 확인했는지 같이 쓴다.** `audit-all.mjs 34/34` · `md5 일치` · 2. **"했다" 옆에 무엇으로 확인했는지 같이 쓴다.** `audit-all.mjs 38/38` · `md5 일치` ·
`20초 동안 0px` 처럼. 근거를 못 쓰면 아직 안 끝난 것이다. `20초 동안 0px` 처럼. 근거를 못 쓰면 아직 안 끝난 것이다.
3. **절차를 안내할 때는 그 절차를 먼저 밟아 본다.** 3. **절차를 안내할 때는 그 절차를 먼저 밟아 본다.**
4. **일부만 했으면 일부만 했다고 쓴다.** 못 한 것을 빼고 요약하지 않는다. 4. **일부만 했으면 일부만 했다고 쓴다.** 못 한 것을 빼고 요약하지 않는다.
@ -861,15 +1084,19 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
|---|---| |---|---|
| `README.md` | **이 문서.** 지침은 전부 여기 | | `README.md` | **이 문서.** 지침은 전부 여기 |
| `TEXT.md` | 화면에 나가는 텍스트 전문 (생성물 — `python3 dump_text.py`) | | `TEXT.md` | 화면에 나가는 텍스트 전문 (생성물 — `python3 dump_text.py`) |
| `build_itinerary.py` | 여행 일정 계산 + 규칙 16종 감사 | | `build_itinerary.py` | 여행 일정 계산 + 규칙 17종 감사 |
| `build_story.py` | 가요 다방 · 인물 열전 + 위키 사진 | | `build_story.py` | 가요 다방 · 인물 열전 + 위키 사진 |
| `patch_stay.py` | 캐치프레이즈 · 객실 · 자작곡 · HTML 조립 · 자산 경로 교정 | | `build_reading.py` | 군산 읽기 — 카로셀 34꼭지(문학·섬과 바다·역사·장소·음식), 화면엔 매번 5~6개 무작위 |
| `build_daily.py` | 오늘의 한 장 — 노래·문학 일력 39장 |
| `patch_stay.py` | 캐치프레이즈 · 주변 맛집 · 객실 · 자작곡 · HTML 조립 · 자산 경로 교정 |
| `fetch_restaurants.py` | 주변 맛집을 TourAPI 2km 로 다시 받아 `restaurants.json` · 사진은 `img/mirror/` (굽기와 따로, 한 번만) |
| `fill_restaurant_photos.py` | TourAPI 에 사진이 없는 맛집 11곳 사진을 받아 1600px 로 줄여 둔다 — ⚠ **공공누리 아님, 시연본 전용** |
| `dump_text.py` | `TEXT.md` 생성 | | `dump_text.py` | `TEXT.md` 생성 |
| `inject.css` · `inject.js` | 화면에 덧대는 다섯 가지 | | `inject.css` · `inject.js` | 화면에 덧대는 다섯 가지 |
| `audit-all.mjs` | 브라우저로 34개 항목 전수 점검 | | `audit-all.mjs` | 브라우저로 47개 항목 전수 점검 |
| `rails-test.mjs` | 카로셀 13개 × 데스크톱·모바일 | | `rails-test.mjs` | 카로셀 13개 × 데스크톱·모바일. 모바일에는 이름 없는 가로 상자 3개가 더 잡힌다 — 그중 하나가 가요 다방 판꽂이다(자동으로 안 넘어가므로 전 칸 0 이 맞다) |
| `kingcheck.mjs` | 킹서버 반영분 확인 | | `kingcheck.mjs` | 킹서버 반영분 확인 |
| `audit_schedule.py` | 일정 전수 검수표 생성 → `SCHEDULE.md` (규칙이 안 보는 논리 12가지) | | `audit_schedule.py` | 일정 전수 검수표 생성 → `SCHEDULE.md` (규칙이 안 보는 논리 13가지) |
| `SCHEDULE.md` | 그 결과물 (생성물) | | `SCHEDULE.md` | 그 결과물 (생성물) |
### 원본 · 원자료 (건드리지 않는다) ### 원본 · 원자료 (건드리지 않는다)
@ -878,6 +1105,8 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
|---|---| |---|---|
| `stay-payload.json` · `orig/index.html` | **원본.** 되돌릴 때 기준 | | `stay-payload.json` · `orig/index.html` | **원본.** 되돌릴 때 기준 |
| `rooms.json` | 야놀자 등록본에서 뽑은 객실 사진 목록 (A동 12 · B동 10) | | `rooms.json` | 야놀자 등록본에서 뽑은 객실 사진 목록 (A동 12 · B동 10) |
| `restaurants.json` | 주변 맛집 31곳 — 원본 8 + TourAPI 2km 23 (사진 20) |
| `restaurant-photos.json` | 사진 없던 맛집 11곳의 사진 출처 주소·게시물 제목. 굽기가 사진 없는 집에만 덧댄다 |
| `people-photos.json` · `people-images.json` · `people-images-meta.json` | 위키 문서 사진 + 라이선스 | | `people-photos.json` · `people-images.json` · `people-images-meta.json` | 위키 문서 사진 + 라이선스 |
| `people-raw.json` · `people-rows.json` | 인물 수집 원자료 | | `people-raw.json` · `people-rows.json` | 인물 수집 원자료 |
| `coords.json` | 카카오 로컬에서 받은 좌표 | | `coords.json` | 카카오 로컬에서 받은 좌표 |
@ -890,7 +1119,7 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
| `img/people/` | 17장 | 위키 사진. 저작자 표시가 라이선스 조건이라 같이 관리한다 | | `img/people/` | 17장 | 위키 사진. 저작자 표시가 라이선스 조건이라 같이 관리한다 |
| `img/mirror/` | 61장 | 히어로·소개·객실. 없으면 **다른 기계에서 흰 화면** | | `img/mirror/` | 61장 | 히어로·소개·객실. 없으면 **다른 기계에서 흰 화면** |
| `audio/` | 5곡 | 사장님 자작곡 | | `audio/` | 5곡 | 사장님 자작곡 |
| `vendor/` | 2개 | 번들 css/js. 해시가 달라도 뜨게 하려고 | | `vendor/` | 3개 | 번들 css/js(해시가 달라도 뜨게 하려고) + `embla-carousel.umd.js`(군산 읽기·가요 다방 카로셀이 쓰는 코어, `node_modules/embla-carousel` 그대로 복사) |
합쳐서 약 77M 이다. 전부 **한 번 넣고 안 바뀌는 파일**이라 git 이 싫어하는 경우가 아니다. 합쳐서 약 77M 이다. 전부 **한 번 넣고 안 바뀌는 파일**이라 git 이 싫어하는 경우가 아니다.
자산은 커밋을 따로 떼어 뒀다 — 히스토리에서 덩치를 걷어낼 일이 생기면 그 커밋만 건드린다. 자산은 커밋을 따로 떼어 뒀다 — 히스토리에서 덩치를 걷어낼 일이 생기면 그 커밋만 건드린다.
@ -935,4 +1164,7 @@ ssh King_admin "rm -rf ~/data2/o2o-site-AEO/tmp-stay-deploy"
| 인물 사진 18/57 | 위키 문서에 사진이 있는 사람이 18명뿐이다. 동명이인 함정 때문에 이름 검색으로 늘리지 않는다 | | 인물 사진 18/57 | 위키 문서에 사진이 있는 사람이 18명뿐이다. 동명이인 함정 때문에 이름 검색으로 늘리지 않는다 |
| 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 `use-rail-autoplay.ts` 를 고치고 **전체 재굽기 + `republish_all.py`** | | 렌더러의 카로셀 자동 넘김 | 운영 사이트에 그대로 있다. 고치려면 `use-rail-autoplay.ts` 를 고치고 **전체 재굽기 + `republish_all.py`** |
| ~~일정 정거장의 체류 대비 이동~~ | **해결(2026-09-11)** — 규칙 15·16 을 감사에 넣었다. 전수 검수 21/21 (`SCHEDULE.md`) | | ~~일정 정거장의 체류 대비 이동~~ | **해결(2026-09-11)** — 규칙 15·16 을 감사에 넣었다. 전수 검수 21/21 (`SCHEDULE.md`) |
| 렌더러의 가요 다방 판 배치 | 운영 사이트도 `flex-wrap` 이라 곡이 늘면 진열대가 된다(`SongsSection.tsx`). 목업은 CSS 로 덮었다(**2.5** 7). 제품에 옮기려면 **전체 재굽기 + `republish_all.py`** |
| 날씨 문구를 만드는 경로 | 제품에는 없다 — `site_payload.py` `_weather()` 는 기온·하늘·관측시각만 싣는다. `notes`·`tempNotes` 는 이 목업에서 손으로 적은 것이고, 사이트마다 자동으로 나오려면 생성 잡이 하나 필요하다 |
| 오늘의 한 장 39장 (365일 중 39일) | 확인되는 소재가 거기까지다. 받은 `gunsan_365_story_db.xlsx` 는 365행이지만 실제 주제가 52개(한 주제를 운영 슬롯 7가지로 복제)이고 231행이 `검증필요`, 시·수필은 빈 슬롯이다. 더 채우려면 **군산을 소재로 한 시·수필을 출처와 함께 찾아야** 한다 |
| 일정을 이미지/링크로 공유 | 이미지 저장은 가능(카드를 PNG 로). 링크는 지금 섹션까지만 간다 — 탭·코스가 URL 에 안 남는다 | | 일정을 이미지/링크로 공유 | 이미지 저장은 가능(카드를 PNG 로). 링크는 지금 섹션까지만 간다 — 탭·코스가 URL 에 안 남는다 |

View File

@ -1,14 +1,14 @@
# `/s/stay` 여행 일정 전수 검수표 # `/s/stay` 여행 일정 전수 검수표
생성 `python3 audit_schedule.py` · 테마 21개 · 53일 · 304정거장 생성 `python3 audit_schedule.py` · 테마 21개 · 53일 · 290정거장
표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다). 표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).
규칙 14종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고, 규칙 17종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,
이 표는 그 규칙이 **안 보는 논리**를 따로 센다. 이 표는 그 규칙이 **안 보는 논리**를 따로 센다.
## 결과 — 21/21 검수완료 ## 결과 — 21/21 검수완료
**전부 통과.** 12개 항목 위반 0건. **전부 통과.** 13개 항목 위반 0건.
| 코드 | 검수 항목 | | 코드 | 검수 항목 |
|---|---| |---|---|
@ -20,10 +20,11 @@
| L5 | 바깥 정거장 체류 15분 이상 | | L5 | 바깥 정거장 체류 15분 이상 |
| L6 | 하루가 14시간을 넘지 않는다 | | L6 | 하루가 14시간을 넘지 않는다 |
| L7 | 입실 15:00 이후 · 퇴실 11:00 전 | | L7 | 입실 15:00 이후 · 퇴실 11:00 전 |
| L8 | 하루의 첫 칸·마지막 칸이 숙소 | | L8 | 하루의 첫 칸·마지막 칸이 숙소 (**떠나는 날 차 테마는 복귀 없이 끝난다**) |
| L9 | 같은 자리가 연달아 서지 않는다 | | L9 | 같은 자리가 연달아 서지 않는다 |
| L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 | | L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 |
| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 | | L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |
| L12 | 떠나는 날의 끝맺음이 퇴실 문구와 같다 — 짐을 차에 실었으면 안 돌아오고, 맡겼으면 찾으러 온다 |
--- ---
@ -44,14 +45,13 @@
| 5 | 18:45–19:25 | 밤 산책 | 군산 내항 | 5분 | 40분 | | 5 | 18:45–19:25 | 밤 산책 | 군산 내항 | 5분 | 40분 |
| 6 | 19:41–20:11 | 머뭄으로 | 스테이 머뭄 | 16분 | 30분 | | 6 | 19:41–20:11 | 머뭄으로 | 스테이 머뭄 | 16분 | 30분 |
**둘째 날** 08:20–16:36 (총 8시간 16분 · 이동 176분 · 정거장 4) **둘째 날** 08:20–15:49 (총 7시간 29분 · 이동 169분 · 정거장 3)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 1 | 08:20–09:35 | 아침 | 스테이 머뭄 | –분 | 75분 | | 1 | 08:20–09:35 | 아침 | 스테이 머뭄 | –분 | 75분 |
| 2 | 10:59–13:29 | 오전 | 고군산군도 선유도 | 84분 | 150분 | | 2 | 10:59–13:29 | 오전 | 고군산군도 선유도 | 84분 | 150분 |
| 3 | 14:54–15:49 | 늦은 점심 | 국제반점 | 85분 | 55분 | | 3 | 14:54–15:49 | 늦은 점심 | 국제반점 | 85분 | 55분 |
| 4 | 15:56–16:36 | 마무리 | 스테이 머뭄 | 7분 | 40분 |
--- ---
@ -101,7 +101,7 @@
| 5 | 19:33–20:12 | 밤 산책 | 군산 내항 | 5분 | 39분 | | 5 | 19:33–20:12 | 밤 산책 | 군산 내항 | 5분 | 39분 |
| 6 | 20:28–20:57 | 머뭄으로 | 스테이 머뭄 | 16분 | 29분 | | 6 | 20:28–20:57 | 머뭄으로 | 스테이 머뭄 | 16분 | 29분 |
**둘째 날** 09:40–15:36 (총 5시간 56분 · 이동 52분 · 정거장 5) **둘째 날** 09:40–14:31 (총 4시간 51분 · 이동 33분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -109,7 +109,6 @@
| 2 | 11:10–12:02 | 오전 | 군산근대건축관 | 10분 | 52분 | | 2 | 11:10–12:02 | 오전 | 군산근대건축관 | 10분 | 52분 |
| 3 | 12:10–13:07 | 점심 | 일흥옥 | 8분 | 57분 | | 3 | 12:10–13:07 | 점심 | 일흥옥 | 8분 | 57분 |
| 4 | 13:22–14:31 | 오후 | 째보선창 근처 카페 | 15분 | 69분 | | 4 | 13:22–14:31 | 오후 | 째보선창 근처 카페 | 15분 | 69분 |
| 5 | 14:50–15:36 | 마무리 | 스테이 머뭄 | 19분 | 46분 |
--- ---
@ -130,7 +129,7 @@
| 5 | 18:52–19:30 | 밤 산책 | 군산 내항 | 11분 | 38분 | | 5 | 18:52–19:30 | 밤 산책 | 군산 내항 | 11분 | 38분 |
| 6 | 19:46–20:14 | 머뭄으로 | 스테이 머뭄 | 16분 | 28분 | | 6 | 19:46–20:14 | 머뭄으로 | 스테이 머뭄 | 16분 | 28분 |
**둘째 날** 08:50–13:35 (총 4시간 45분 · 이동 37분 · 정거장 5) **둘째 날** 08:50–12:49 (총 3시간 59분 · 이동 29분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -138,7 +137,6 @@
| 2 | 10:12–11:22 | 오전 | 군산근대미술관 | 9분 | 70분 | | 2 | 10:12–11:22 | 오전 | 군산근대미술관 | 9분 | 70분 |
| 3 | 11:30–12:18 | 점심 | 일흥옥 | 8분 | 48분 | | 3 | 11:30–12:18 | 점심 | 일흥옥 | 8분 | 48분 |
| 4 | 12:30–12:49 | 오후 | 해망굴 | 12분 | 19분 | | 4 | 12:30–12:49 | 오후 | 해망굴 | 12분 | 19분 |
| 5 | 12:57–13:35 | 마무리 | 스테이 머뭄 | 8분 | 38분 |
--- ---
@ -188,7 +186,7 @@
| 5 | 18:42–19:20 | 밤 산책 | 군산 내항 | 9분 | 38분 | | 5 | 18:42–19:20 | 밤 산책 | 군산 내항 | 9분 | 38분 |
| 6 | 19:36–20:04 | 머뭄으로 | 스테이 머뭄 | 16분 | 28분 | | 6 | 19:36–20:04 | 머뭄으로 | 스테이 머뭄 | 16분 | 28분 |
**둘째 날** 08:30–14:06 (총 5시간 36분 · 이동 36분 · 정거장 5) **둘째 날** 08:30–13:21 (총 4시간 51분 · 이동 29분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -196,7 +194,6 @@
| 2 | 09:59–11:19 | 오전 | 경암동 철길마을 | 12분 | 80분 | | 2 | 09:59–11:19 | 오전 | 경암동 철길마을 | 12분 | 80분 |
| 3 | 11:30–12:18 | 점심 | 일흥옥 | 11분 | 48분 | | 3 | 11:30–12:18 | 점심 | 일흥옥 | 11분 | 48분 |
| 4 | 12:24–13:21 | 오후 | 군산 시간여행마을 | 6분 | 57분 | | 4 | 12:24–13:21 | 오후 | 군산 시간여행마을 | 6분 | 57분 |
| 5 | 13:28–14:06 | 마무리 | 스테이 머뭄 | 7분 | 38분 |
--- ---
@ -246,7 +243,7 @@
| 5 | 18:46–19:30 | 밤 산책 | 군산 내항 | 11분 | 44분 | | 5 | 18:46–19:30 | 밤 산책 | 군산 내항 | 11분 | 44분 |
| 6 | 19:46–20:19 | 머뭄으로 | 스테이 머뭄 | 16분 | 33분 | | 6 | 19:46–20:19 | 머뭄으로 | 스테이 머뭄 | 16분 | 33분 |
**둘째 날** 09:00–14:40 (총 5시간 40분 · 이동 60분 · 정거장 5) **둘째 날** 09:00–13:43 (총 4시간 43분 · 이동 47분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -254,7 +251,6 @@
| 2 | 10:40–11:46 | 오전 | 채만식문학관 | 18분 | 66분 | | 2 | 10:40–11:46 | 오전 | 채만식문학관 | 18분 | 66분 |
| 3 | 12:03–12:58 | 점심 | 일흥옥 | 17분 | 55분 | | 3 | 12:03–12:58 | 점심 | 일흥옥 | 17분 | 55분 |
| 4 | 13:10–13:43 | 오후 | 구 군산세관 본관 | 12분 | 33분 | | 4 | 13:10–13:43 | 오후 | 구 군산세관 본관 | 12분 | 33분 |
| 5 | 13:56–14:40 | 마무리 | 스테이 머뭄 | 13분 | 44분 |
--- ---
@ -275,7 +271,7 @@
| 5 | 18:30–19:04 | 밤 산책 | 군산 내항 | 13분 | 34분 | | 5 | 18:30–19:04 | 밤 산책 | 군산 내항 | 13분 | 34분 |
| 6 | 19:20–19:46 | 머뭄으로 | 스테이 머뭄 | 16분 | 26분 | | 6 | 19:20–19:46 | 머뭄으로 | 스테이 머뭄 | 16분 | 26분 |
**둘째 날** 10:10–14:10 (총 4시간 0분 · 이동 42분 · 정거장 5) **둘째 날** 10:10–13:27 (총 3시간 17분 · 이동 33분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -283,7 +279,6 @@
| 2 | 11:12–11:54 | 오전 | 경암동 철길마을 | 12분 | 42분 | | 2 | 11:12–11:54 | 오전 | 경암동 철길마을 | 12분 | 42분 |
| 3 | 12:05–12:35 | 점심 | 이성당 | 11분 | 30분 | | 3 | 12:05–12:35 | 점심 | 이성당 | 11분 | 30분 |
| 4 | 12:45–13:27 | 오후 | 월명호수 | 10분 | 42분 | | 4 | 12:45–13:27 | 오후 | 월명호수 | 10분 | 42분 |
| 5 | 13:36–14:10 | 마무리 | 스테이 머뭄 | 9분 | 34분 |
--- ---
@ -301,7 +296,7 @@
| 2 | 19:05–20:20 | 저녁 | 명월갈비 | 5분 | 75분 | | 2 | 19:05–20:20 | 저녁 | 명월갈비 | 5분 | 75분 |
| 3 | 20:25–20:55 | 머뭄으로 | 스테이 머뭄 | 5분 | 30분 | | 3 | 20:25–20:55 | 머뭄으로 | 스테이 머뭄 | 5분 | 30분 |
**둘째 날** 08:40–14:39 (총 5시간 59분 · 이동 39분 · 정거장 6) **둘째 날** 08:40–13:51 (총 5시간 11분 · 이동 31분 · 정거장 5)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -310,7 +305,6 @@
| 3 | 11:42–12:32 | 점심 | 한일옥 | 6분 | 50분 | | 3 | 11:42–12:32 | 점심 | 한일옥 | 6분 | 50분 |
| 4 | 12:36–13:21 | 오후 | 군산근대건축관 | 4분 | 45분 | | 4 | 12:36–13:21 | 오후 | 군산근대건축관 | 4분 | 45분 |
| 5 | 13:31–13:51 | 오후 | 해망굴 | 10분 | 20분 | | 5 | 13:31–13:51 | 오후 | 해망굴 | 10분 | 20분 |
| 6 | 13:59–14:39 | 마무리 | 스테이 머뭄 | 8분 | 40분 |
--- ---
@ -382,7 +376,7 @@
| 6 | 18:47–20:02 | 저녁 | 명월갈비 | 5분 | 75분 | | 6 | 18:47–20:02 | 저녁 | 명월갈비 | 5분 | 75분 |
| 7 | 20:07–20:37 | 머뭄으로 | 스테이 머뭄 | 5분 | 30분 | | 7 | 20:07–20:37 | 머뭄으로 | 스테이 머뭄 | 5분 | 30분 |
**셋째 날** 09:20–13:46 (총 4시간 26분 · 이동 19분 · 정거장 5) **셋째 날** 09:20–12:59 (총 3시간 39분 · 이동 12분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -390,7 +384,6 @@
| 2 | 10:41–11:26 | 오전 | 초원사진관 | 4분 | 45분 | | 2 | 10:41–11:26 | 오전 | 초원사진관 | 4분 | 45분 |
| 3 | 11:30–12:20 | 점심 | 일흥옥 | 4분 | 50분 | | 3 | 11:30–12:20 | 점심 | 일흥옥 | 4분 | 50분 |
| 4 | 12:24–12:59 | 오후 | 이성당 | 4분 | 35분 | | 4 | 12:24–12:59 | 오후 | 이성당 | 4분 | 35분 |
| 5 | 13:06–13:46 | 마무리 | 스테이 머뭄 | 7분 | 40분 |
--- ---
@ -466,7 +459,7 @@
| 7 | 17:30–18:50 | 저녁 | 빈해원 | 11분 | 80분 | | 7 | 17:30–18:50 | 저녁 | 빈해원 | 11분 | 80분 |
| 8 | 19:01–19:35 | 머뭄으로 | 스테이 머뭄 | 11분 | 34분 | | 8 | 19:01–19:35 | 머뭄으로 | 스테이 머뭄 | 11분 | 34분 |
**셋째 날** 08:40–15:18 (총 6시간 38분 · 이동 32분 · 정거장 5) **셋째 날** 08:40–14:21 (총 5시간 41분 · 이동 21분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -474,7 +467,6 @@
| 2 | 10:30–11:20 | 오전 | 동국사 | 6분 | 50분 | | 2 | 10:30–11:20 | 오전 | 동국사 | 6분 | 50분 |
| 3 | 11:30–12:33 | 점심 | 국제반점 | 10분 | 63분 | | 3 | 11:30–12:33 | 점심 | 국제반점 | 10분 | 63분 |
| 4 | 12:38–14:21 | 오후 | 군산근대역사박물관 | 5분 | 103분 | | 4 | 12:38–14:21 | 오후 | 군산근대역사박물관 | 5분 | 103분 |
| 5 | 14:32–15:18 | 마무리 | 스테이 머뭄 | 11분 | 46분 |
--- ---
@ -505,7 +497,7 @@
| 4 | 17:30–18:25 | 저녁 | 일흥옥 | 5분 | 55분 | | 4 | 17:30–18:25 | 저녁 | 일흥옥 | 5분 | 55분 |
| 5 | 18:30–19:03 | 머뭄으로 | 스테이 머뭄 | 5분 | 33분 | | 5 | 18:30–19:03 | 머뭄으로 | 스테이 머뭄 | 5분 | 33분 |
**셋째 날** 09:00–14:34 (총 5시간 34분 · 이동 27분 · 정거장 5) **셋째 날** 09:00–13:33 (총 4시간 33분 · 이동 18분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -513,7 +505,6 @@
| 2 | 10:44–11:30 | 오전 | 동국사 | 6분 | 46분 | | 2 | 10:44–11:30 | 오전 | 동국사 | 6분 | 46분 |
| 3 | 11:33–12:19 | 점심 | 물밀소 | 3분 | 46분 | | 3 | 11:33–12:19 | 점심 | 물밀소 | 3분 | 46분 |
| 4 | 12:28–13:33 | 오후 | 월명호수 | 9분 | 65분 | | 4 | 12:28–13:33 | 오후 | 월명호수 | 9분 | 65분 |
| 5 | 13:42–14:34 | 마무리 | 스테이 머뭄 | 9분 | 52분 |
--- ---
@ -547,7 +538,7 @@
| 7 | 17:30–18:15 | 저녁 | 국제반점 | 7분 | 45분 | | 7 | 17:30–18:15 | 저녁 | 국제반점 | 7분 | 45분 |
| 8 | 18:22–18:46 | 머뭄으로 | 스테이 머뭄 | 7분 | 24분 | | 8 | 18:22–18:46 | 머뭄으로 | 스테이 머뭄 | 7분 | 24분 |
**셋째 날** 08:50–13:35 (총 4시간 45분 · 이동 29분 · 정거장 5) **셋째 날** 08:50–12:54 (총 4시간 4분 · 이동 20분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -555,7 +546,6 @@
| 2 | 10:46–11:26 | 오전 | 우체통거리 | 8분 | 40분 | | 2 | 10:46–11:26 | 오전 | 우체통거리 | 8분 | 40분 |
| 3 | 11:30–12:10 | 점심 | 일흥옥 | 4분 | 40분 | | 3 | 11:30–12:10 | 점심 | 일흥옥 | 4분 | 40분 |
| 4 | 12:18–12:54 | 오후 | 군산근대미술관 | 8분 | 36분 | | 4 | 12:18–12:54 | 오후 | 군산근대미술관 | 8분 | 36분 |
| 5 | 13:03–13:35 | 마무리 | 스테이 머뭄 | 9분 | 32분 |
--- ---
@ -589,7 +579,7 @@
| 7 | 17:37–19:07 | 저녁 | 명월갈비 | 5분 | 90분 | | 7 | 17:37–19:07 | 저녁 | 명월갈비 | 5분 | 90분 |
| 8 | 19:12–19:48 | 머뭄으로 | 스테이 머뭄 | 5분 | 36분 | | 8 | 19:12–19:48 | 머뭄으로 | 스테이 머뭄 | 5분 | 36분 |
**셋째 날** 09:40–15:27 (총 5시간 47분 · 이동 45분 · 정거장 5) **셋째 날** 09:40–14:32 (총 4시간 52분 · 이동 38분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -597,7 +587,6 @@
| 2 | 11:18–12:30 | 오전 | 채만식문학관 | 18분 | 72분 | | 2 | 11:18–12:30 | 오전 | 채만식문학관 | 18분 | 72분 |
| 3 | 12:47–13:47 | 점심 | 한일옥 | 17분 | 60분 | | 3 | 12:47–13:47 | 점심 | 한일옥 | 17분 | 60분 |
| 4 | 13:50–14:32 | 오후 | 이성당 | 3분 | 42분 | | 4 | 13:50–14:32 | 오후 | 이성당 | 3분 | 42분 |
| 5 | 14:39–15:27 | 마무리 | 스테이 머뭄 | 7분 | 48분 |
--- ---
@ -671,7 +660,7 @@
| 6 | 18:37–19:48 | 저녁 | 명월갈비 | 5분 | 71분 | | 6 | 18:37–19:48 | 저녁 | 명월갈비 | 5분 | 71분 |
| 7 | 19:53–20:21 | 머뭄으로 | 스테이 머뭄 | 5분 | 28분 | | 7 | 19:53–20:21 | 머뭄으로 | 스테이 머뭄 | 5분 | 28분 |
**셋째 날** 09:40–14:20 (총 4시간 40분 · 이동 42분 · 정거장 5) **셋째 날** 09:40–13:30 (총 3시간 50분 · 이동 30분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -679,7 +668,6 @@
| 2 | 11:01–11:44 | 오전 | 군산근대건축관 | 10분 | 43분 | | 2 | 11:01–11:44 | 오전 | 군산근대건축관 | 10분 | 43분 |
| 3 | 11:53–12:31 | 점심 | 만남스넥 | 9분 | 38분 | | 3 | 11:53–12:31 | 점심 | 만남스넥 | 9분 | 38분 |
| 4 | 12:42–13:30 | 오후 | 경암동 철길마을 | 11분 | 48분 | | 4 | 12:42–13:30 | 오후 | 경암동 철길마을 | 11분 | 48분 |
| 5 | 13:42–14:20 | 마무리 | 스테이 머뭄 | 12분 | 38분 |
--- ---
@ -752,7 +740,7 @@
| 7 | 17:30–18:38 | 저녁 | 명월갈비 | 5분 | 68분 | | 7 | 17:30–18:38 | 저녁 | 명월갈비 | 5분 | 68분 |
| 8 | 18:43–19:10 | 머뭄으로 | 스테이 머뭄 | 5분 | 27분 | | 8 | 18:43–19:10 | 머뭄으로 | 스테이 머뭄 | 5분 | 27분 |
**셋째 날** 09:30–13:48 (총 4시간 18분 · 이동 42분 · 정거장 5) **셋째 날** 09:30–13:03 (총 3시간 33분 · 이동 33분 · 정거장 4)
| | 시각 | 자리 | 장소 | 이동 | 체류 | | | 시각 | 자리 | 장소 | 이동 | 체류 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@ -760,5 +748,4 @@
| 2 | 10:51–11:18 | 오전 | 구 군산세관 본관 | 13분 | 27분 | | 2 | 10:51–11:18 | 오전 | 구 군산세관 본관 | 13분 | 27분 |
| 3 | 11:30–12:15 | 점심 | 일흥옥 | 12분 | 45분 | | 3 | 11:30–12:15 | 점심 | 일흥옥 | 12분 | 45분 |
| 4 | 12:23–13:03 | 오후 | 군산근대미술관 | 8분 | 40분 | | 4 | 12:23–13:03 | 오후 | 군산근대미술관 | 8분 | 40분 |
| 5 | 13:12–13:48 | 마무리 | 스테이 머뭄 | 9분 | 36분 |

View File

@ -218,7 +218,7 @@
| 56 | | 전미라 | 1978– | 테니스 선수 | 테니스 선수로 뛰었고 지금은 해설위원입니다. | | 56 | | 전미라 | 1978– | 테니스 선수 | 테니스 선수로 뛰었고 지금은 해설위원입니다. |
| 57 | | 한왕용 | 1966– | 산악인 | 히말라야 8000m 14좌를 세계 11번째로 완등했습니다. | | 57 | | 한왕용 | 1966– | 산악인 | 히말라야 8000m 14좌를 세계 11번째로 완등했습니다. |
## 4. 일정 — 정거장에서 할 일 36줄 — `build_itinerary.py` `PLACES`·`STAY_ACTION` ## 4. 일정 — 정거장에서 할 일 37줄 — `build_itinerary.py` `PLACES`·`STAY_ACTION`
| 장소 | 무엇을 하는지 | 머무는 시간 | | 장소 | 무엇을 하는지 | 머무는 시간 |
|---|---|---| |---|---|---|
@ -240,10 +240,11 @@
| 물밀소 | 저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다. | 40분 | | 물밀소 | 저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다. | 40분 |
| 빈해원 | 2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다. | 70분 | | 빈해원 | 2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다. | 70분 |
| 스테이 머뭄 | 돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다. | 30분 | | 스테이 머뭄 | 돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다. | 30분 |
| 스테이 머뭄 | 맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다. | 40분 | | 스테이 머뭄 | 맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다. | 42분 |
| 스테이 머뭄 | 부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다. | 60분 | | 스테이 머뭄 | 부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다. | 60분 |
| 스테이 머뭄 | 숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다. | 173분 | | 스테이 머뭄 | 숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다. | 173분 |
| 스테이 머뭄 | 아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요. | 75분 | | 스테이 머뭄 | 아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요. | 79분 |
| 스테이 머뭄 | 아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다. | 75분 |
| 스테이 머뭄 | 오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다. | 223분 | | 스테이 머뭄 | 오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다. | 223분 |
| 스테이 머뭄 | 짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다. | 45분 | | 스테이 머뭄 | 짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다. | 45분 |
| 우체통거리 | 주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다. | 26분 | | 우체통거리 | 주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다. | 26분 |
@ -285,17 +286,200 @@
| 소설 『탁류』를 따라 걷는 2박 3일 | 2박 3일 | 채만식을 읽어 본 손님 | 소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다. | | 소설 『탁류』를 따라 걷는 2박 3일 | 2박 3일 | 채만식을 읽어 본 손님 | 소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다. |
| 철길마을과 항구를 같이 보는 2박 3일 | 2박 3일 | 사진을 찍는 손님 | 기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다. | | 철길마을과 항구를 같이 보는 2박 3일 | 2박 3일 | 사진을 찍는 손님 | 기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다. |
## 6. 오늘의 엽서 4장 — 원본 payload (아직 다시 안 뽑음) ## 6. 군산 읽기 31가지 — `build_reading.py` `ITEMS`
한 화면에 다 펴 두는 카드. 일력(7번)과 달리 손님이 훑어 내려가며 읽는 자리다.
### 문학 8가지 — 이 도시를 쓴 사람과 그 자리
| 제목 | 글 | 연도 | 확인 | 출처 |
|---|---|---|---|---|
| 채만식 『탁류』 | 일제강점기 군산을 무대로 한 장편소설입니다. 금강 하구의 흙탕물처럼 시대에 휩쓸리는 사람들을 그렸습니다. | 1937 | 확인 | 군산시 채만식문학관 |
| 백릉 채만식 | 1902년 옥구(지금의 군산) 임피에서 태어난 소설가입니다. 풍자로 시대를 그렸고 1950년에 세상을 떠났습니다. | 1902 | 확인 | 군산시 채만식문학관 |
| 채만식문학관 | 금강 하구 옆에 선 문학관입니다. 작가의 생애와 『탁류』의 판본을 한자리에서 봅니다. | | 확인 | 군산시 채만식문학관 |
| 『탁류』라는 제목 | 금강 하류의 흙탕물을 가리키는 말입니다. 그 물살에 휩쓸려 가는 사람들을 함께 가리킵니다. | | 확인 | 군산시 채만식문학관 |
| 채만식의 풍자 | 웃기면서 찌르는 문장이 이 작가의 방식입니다. 개항장 군산에서 본 것이 그 바탕에 있습니다. | | 확인필요 | 군산시 채만식문학관 |
| 탁류길 | 소설의 배경을 따라 걷는 군산 스탬프투어 코스입니다. 원도심 구간이라 머뭄에서 걸어 닿습니다. | | 확인 | 군산 스탬프투어 |
| 조정래 『아리랑』과 아리랑길 | 군산과 김제 들녘이 무대인 대하소설입니다. 스탬프투어에 같은 이름의 코스가 있습니다. | | 확인필요 | 군산 스탬프투어 |
| 군산이 낳은 시인들 | 고은 · 이병훈 · 심호택 · 문효치 · 이연주 · 주하림. 아래 '인물 열전' 에서 한 사람씩 봅니다. | | 확인 | 한국어 위키백과 |
### 섬과 바다 6가지 — 다리를 건너면 나오는 것
| 제목 | 글 | 연도 | 확인 | 출처 |
|---|---|---|---|---|
| 고군산군도 | 예순 남짓한 섬이 모인 군도입니다. 새만금 방조제와 연결 다리로 이어져 차로 들어갑니다. | | 확인 | 군산시 문화관광 |
| 선유도 | 고군산군도의 대표 섬입니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다. 머뭄에서 차로 한 시간 남짓입니다. | | 확인 | 군산시 문화관광 |
| 장자도 | 선유도와 다리로 이어진 작은 섬입니다. 포구와 바다길을 봅니다. | | 확인필요 | 군산시 문화관광 |
| 무녀도 | 고군산군도의 섬 하나입니다. 이름의 유래가 전해집니다. | | 확인필요 | 군산시 문화관광 |
| 신시도 | 새만금과 고군산군도를 잇는 자리의 섬입니다. | | 확인필요 | 군산시 문화관광 |
| 비응항 | 새만금 방조제가 시작하는 항구입니다. 서해 관광의 관문으로 씁니다. | | 확인필요 | 군산시 문화관광 |
### 역사 5가지 — 연표에 안 들어간 이야기
| 제목 | 글 | 연도 | 확인 | 출처 |
|---|---|---|---|---|
| 미곡 수탈과 군산항 | 일제강점기 호남평야의 쌀이 이 항구로 모여 일본으로 나갔습니다. 부잔교와 창고가 그 통로였습니다. | | 확인필요 | KCI 학술자료 |
| 째보선창 | 내항 옆 선창입니다. 『탁류』의 인물들이 오가던 자리이고, 지금은 배 대신 낡은 창고가 남았습니다. | | 확인필요 | 군산시 문화관광 |
| 해망굴 | 시내와 항구를 잇는 굴입니다. 걸어서 통과해 반대편 항구 쪽으로 나옵니다. | | 확인필요 | 군산시 문화관광 |
| 경암동 철길 | 1944년에 놓인 철길입니다. 선로가 집 앞 골목을 그대로 지나갑니다. | 1944 | 확인 | 군산시 문화관광 |
| 군산항 | 만남과 이별, 물류와 근대사가 한자리에 겹치는 항구입니다. 도시의 성격이 여기서 정해졌습니다. | | 확인 | 군산시 문화관광 |
### 장소 9가지 — 걸어서 또는 차로 닿는 자리
| 제목 | 글 | 연도 | 확인 | 출처 |
|---|---|---|---|---|
| 군산근대역사박물관 | 항구도시의 근대사를 3층부터 내려오며 봅니다. 도시를 걷기 전에 여기서 한 번 잡고 나섭니다. | | 확인 | 군산시 문화관광 |
| 군산 내항과 부잔교 | 조수에 따라 오르내리는 뜬다리입니다. 다리 위를 끝까지 걸어 나갔다 돌아옵니다. | 1926 | 확인 | 군산시 문화관광 |
| 동국사 | 일본식 사찰 건축이 남은 절입니다. 대웅전과 마당을 둘러봅니다. | | 확인필요 | 군산시 문화관광 |
| 월명공원 | 시내와 바다를 함께 내려다보는 공원입니다. 계절마다 오르는 길이 다릅니다. | | 확인 | 군산시 문화관광 |
| 은파호수공원 | 호수를 두르는 산책길입니다. 저녁에 한 바퀴 돌기 좋습니다. | | 확인 | 군산시 문화관광 |
| 금강하굿둑 | 금강과 서해가 만나는 자리입니다. 강과 바다의 경계를 사람 손으로 그은 곳입니다. | 1990 | 확인필요 | 군산시 문화관광 |
| 말랭이마을 | 언덕과 골목이 남은 마을입니다. 도시재생으로 되살린 자리입니다. | | 확인필요 | 군산시 문화관광 |
| 장미갤러리 | 근대역사문화거리의 전시 공간입니다. 옛 창고 건물을 고쳐 씁니다. | | 확인필요 | 군산시 문화관광 |
| 진포해양테마공원 | 진포대첩의 기억을 항구 옆에 펼쳐 둔 공원입니다. 실제 군함과 항공기를 봅니다. | | 확인필요 | 군산시 문화관광 |
### 음식과 생활 3가지 — 이 도시가 먹는 것
| 제목 | 글 | 연도 | 확인 | 출처 |
|---|---|---|---|---|
| 군산 짬뽕 | 항구도시의 중국집 문화가 남았습니다. 빈해원과 국제반점이 그 자리입니다. | | 확인필요 | 군산시 문화관광 |
| 박대와 군산 밥상 | 서해에서 나는 박대를 말려 구워 냅니다. 백반집 상에 자주 오릅니다. | | 확인필요 | 군산시 문화관광 |
| 이성당 | 오래된 빵집입니다. 단팥빵과 야채빵이 나오는 시간에 맞추면 줄이 짧습니다. | | 확인 | 대한민국 구석구석 |
## 7. 오늘의 한 장 39장 — `build_daily.py`
날마다 한 장씩 넘어가는 일력. 가요는 가요 다방을 그대로 읽고, 시·소설은 손으로 적는다.
| 날짜 | 분류 | 제목 | 글 | 확인 | 출처 |
|---|---|---|---|---|---|
| 01-01 | 문학 | 채만식 『탁류』 | 일제강점기 군산을 무대로 한 장편소설입니다. 금강 하구의 흙탕물처럼 시대에 휩쓸리는 사람들을 그렸습니다. | 확인 | 군산시 채만식문학관 |
| 01-10 | 대중가요 | 「잘 있거라 군산항」 · 안다성 | 1963년 곡. 작사 손석우 · 작곡 손석우. 월명산 고갯길로 시작하는 이별가. 군산을 노래한 곡 가운데 가장 널리 알려졌습니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 |
| 01-20 | 문학 | 백릉 채만식 | 1902년 옥구(지금의 군산) 임피에서 태어난 소설가입니다. 풍자로 시대를 그렸고 1950년에 세상을 떠났습니다. | 확인 | 군산시 채만식문학관 |
| 01-29 | 대중가요 | 「헤어진 군산항」 · 박제연 | 1965년 곡. 작사 송운선 · 작곡 송운선. 비 내리는 밤 부두에서 헤어지는 노래입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 |
| 02-07 | 대중가요 | 「님 떠난 군산항」 · 이미자 | 1966년 곡. 작사 조형식 · 작곡 백영호. 떠나는 배를 보내는 항구의 정서를 담았습니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 |
| 02-17 | 문학 | 채만식문학관 | 금강 하구 옆에 선 문학관입니다. 작가의 생애와 『탁류』의 판본을 한자리에서 봅니다. | 확인 | 군산시 채만식문학관 |
| 02-26 | 대중가요 | 「금강 나그네」 · 이미자 | 1966년 곡. 작사 이병훈 · 작곡 박춘석. 금강 하구와 군산항 부두를 배경으로 한 곡입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 |
| 03-08 | 대중가요 | 「잘 있거라 군산항구」 · 최정자 | 1967년 곡. 작사 최치수 · 작곡 유금춘. 떠나는 사람의 말로 부르는 이별가입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 |
| 03-17 | 문학 | 『탁류』라는 제목 | 금강 하류의 흙탕물을 가리키는 말입니다. 그 물살에 휩쓸려 가는 사람들을 함께 가리킵니다. | 확인 | 군산시 채만식문학관 |
| 03-26 | 대중가요 | 「군산 마도로스」 · 이상열 | 1967년 곡. 항구를 드나들던 뱃사람의 노래입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 |
| 04-05 | 대중가요 | 「군산 부르스」 · 유주용 | 1969년 곡. 작사 반야월 · 작곡 강도미. 1960년대 말 군산 거리의 분위기를 담은 곡입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 |
| 04-14 | 문학 | 채만식의 풍자 | 웃기면서 찌르는 문장이 이 작가의 방식입니다. 개항장 군산에서 본 것이 그 바탕에 있습니다. | 확인필요 | 군산시 채만식문학관 |
| 04-23 | 대중가요 | 「이별의 군산항 부두」 · 최정자 | 1969년 곡. 작사 최치수 · 작곡 유금춘. 부두에서의 이별을 다시 한 번 부른 곡입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 |
| 05-03 | 문학 | 탁류길 | 소설의 배경을 따라 걷는 군산 스탬프투어 코스입니다. 원도심 구간이라 머뭄에서 걸어 닿습니다. | 확인 | 군산 스탬프투어 |
| 05-12 | 대중가요 | 「군산항 부르스」 · 최하우 | 1984년 곡. 작사 최하우 · 작곡 김성근. 1980년대에 나온 군산항 노래입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 |
| 05-21 | 대중가요 | 「나의 사랑 군산이여」 · 오단비 | 1989년 곡. 작사 이수 · 작곡 정종택. 군산을 향한 애정을 정면으로 부르는 곡입니다. | 확인 | 음악의 오솔길 — 군산 관련 노래 |
| 05-31 | 문학 | 조정래 『아리랑』과 아리랑길 | 군산과 김제 들녘이 무대인 대하소설입니다. 스탬프투어에 같은 이름의 코스가 있습니다. | 확인필요 | 군산 스탬프투어 |
| 06-09 | 대중가요 | 「군산항 엘레지」 · 오단비 | 작사 이수 · 작곡 정종택. 항구 도시의 비가입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 06-18 | 대중가요 | 「선유도 아가씨」 · 오단비 | 작사 이수 · 작곡 서승일. 고군산군도 선유도를 배경으로 한 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 06-28 | 문학 | 군산이 낳은 시인들 | 고은 · 이병훈 · 심호택 · 문효치 · 이연주 · 주하림. 아래 '인물 열전' 에서 한 사람씩 봅니다. | 확인 | 한국어 위키백과 |
| 07-07 | 대중가요 | 「군산찬가」 · 오단비 | 작사 이수 · 작곡 서승일. 은파와 새만금이 가사에 나오는 군산 찬가입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 07-17 | 대중가요 | 「오성산」 · 오단비 | 작사 이수 · 작곡 서승일. 금강 하구를 내려다보는 오성산을 노래했습니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 07-26 | 시 | 시인 고은 | 1933– · 군산에서 태어나 60년 넘게 시를 써 온 시인입니다. | 확인 | 한국어 위키백과 |
| 08-04 | 대중가요 | 「함께하는 군산」 · 오은주 | 작사 김동찬 · 작곡 김동찬. 군산시가 제작한 음반의 첫 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 08-14 | 대중가요 | 「새만금 연가」 · 채미영 | 작사 김동찬 · 작곡 김동찬 · 김수환. 새만금을 소재로 한 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 08-23 | 시 | 시인 이병훈 | 1925–2009 · 옥구(지금의 군산) 출신으로 1950년대에 등단한 현대 시인입니다. | 확인 | 한국어 위키백과 |
| 09-01 | 대중가요 | 「군산시민의 노래」 · 오단비 | 작사 군산문화원 · 작곡 군산예총. 금만경 들녘과 서해 바다로 시작하는 시민의 노래입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 09-11 | 시 | 시인 심호택 | 1947–2010 · 민중의 삶을 시로 옮긴 시인입니다. | 확인 | 한국어 위키백과 |
| 09-20 | 대중가요 | 「군산 아줌마」 · 박솔미 | 작사 송두호 · 작곡 송두호. 선창가 거리와 새만금이 함께 나오는 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 09-29 | 대중가요 | 「내 고향 군산항」 · 정애란 | 작사 정애란 · 작곡 정애란. 고향으로서의 군산항을 부른 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 10-09 | 시 | 시인 문효치 | 1943– · 군산에서 태어나 1966년 신춘문예로 등단했습니다. | 확인 | 한국어 위키백과 |
| 10-18 | 대중가요 | 「군산 가는 길」 · 황상준 | 군산으로 향하는 길을 제목으로 삼은 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 10-27 | 대중가요 | 「군산 추억」 · 임인건 | 재즈 피아니스트 임인건이 군산을 소재로 만든 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 11-06 | 시 | 시인 이연주 | 1953–1992 · 짧게 활동하고 떠난 시인입니다. | 확인 | 한국어 위키백과 |
| 11-15 | 대중가요 | 「군산에서」 · 임인건 | 같은 연주자가 군산에서 받은 인상을 담았습니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 11-25 | 대중가요 | 「헤어진 군산항」 · 김창환 | 작사 송운선 · 작곡 송운선. 군산시 제작 음반에 다시 실린 판입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 12-04 | 시 | 시인 주하림 | 군산에서 태어나 2009년 창비신인시인상으로 등단했습니다. | 확인 | 한국어 위키백과 |
| 12-13 | 대중가요 | 「금강 아가씨」 · 이미자 | 금강을 배경으로 한 이미자의 또 다른 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
| 12-23 | 대중가요 | 「금강의 비가」 · 전승애 | 금강을 제목에 올린 곡입니다. | 확인필요 | 음악의 오솔길 — 군산 관련 노래 |
## 8. 오늘의 엽서 4장 — 원본 payload (아직 다시 안 뽑음)
- 부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다. `#군산 #내항 #부잔교` - 부잔교 위에서는 물이 어디까지 올라왔었는지가 다리에 새겨져 있다. `#군산 #내항 #부잔교`
- 짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다. `#군산짬뽕 #항구도시` - 짬뽕 한 그릇이 항구도시의 이력서인 줄은 몰랐다. `#군산짬뽕 #항구도시`
- 단팥빵을 사러 줄을 서는 일이 여행이 되는 도시. `#이성당 #오래된가게` - 단팥빵을 사러 줄을 서는 일이 여행이 되는 도시. `#이성당 #오래된가게`
- 섬과 섬 사이를 다리로 건너면, 바다가 길이 된다. `#고군산군도 #선유도` - 섬과 섬 사이를 다리로 건너면, 바다가 길이 된다. `#고군산군도 #선유도`
## 7. 머뭄이 만든 노래 5곡 — `patch_stay.py` `OWN_SONGS` ## 9. 머뭄이 만든 노래 5곡 — `patch_stay.py` `OWN_SONGS`
- 머뭄의 아침 — `/s/stay/audio/01-mumum-morning.mp3` - 머뭄의 아침 — `/s/stay/audio/01-mumum-morning.mp3`
- CHECK IN · 머뭄 — `/s/stay/audio/02-check-in.mp3` - CHECK IN · 머뭄 — `/s/stay/audio/02-check-in.mp3`
- Stay in 머뭄 — `/s/stay/audio/03-stay-in.mp3` - Stay in 머뭄 — `/s/stay/audio/03-stay-in.mp3`
- 오늘은 안 갈란다 — `/s/stay/audio/04-not-going.mp3` - 오늘은 안 갈란다 — `/s/stay/audio/04-not-going.mp3`
- 군산에 두고 온 님 — `/s/stay/audio/05-gunsan-nim.mp3` - 군산에 두고 온 님 — `/s/stay/audio/05-gunsan-nim.mp3`
## 10. 오늘의 날씨 문구 50개 — `patch_stay.py` `SKY_NOTES`·`TEMP_NOTES`
하늘 줄은 **집 안**에서 뭘 할지, 기온 줄은 **밖에서** 어디를 갈지다. 두 줄이 나란히 선다.
### 하늘 25개 (`SKY_NOTES`)
**맑음**
- 마당 평상에 앉으면 새소리만 들립니다. 정원 쪽으로 그늘이 길게 눕는 시간입니다.
- 기와가 마르는 냄새가 나는 날입니다. 아침에 널어 두신 것은 오후면 다 마릅니다.
- 볕이 좋아 대문을 열어 둡니다. 마당 사진은 해가 기울기 시작할 때가 가장 곱습니다.
- 창을 다 열어도 좋은 날입니다. A동 장지문으로 드는 빛이 오전에 가장 깊습니다.
- 해가 길게 남는 날입니다. 짐을 풀고 골목부터 한 바퀴 돌고 오셔도 늦지 않습니다.
**구름많음**
- 빛이 부드러운 날입니다. 골목 사진이 제일 잘 나오는 빛입니다.
- 그늘이 옅어 마당이 눈부시지 않습니다. 평상에 오래 앉아 계시기 좋습니다.
- 해가 들었다 숨었다 합니다. 창고형 CAFÉ 공간과 마당을 오가며 쉬시면 됩니다.
- 구름이 얇은 날입니다. 우체통거리까지 걷기에 덥지도 춥지도 않습니다.
- 빛이 고른 날입니다. 초원사진관까지 걸어서 삼 분, 사진은 오늘 같은 하늘이 낫습니다.
**그외**
- 해가 숨은 날입니다. 기와와 담이 한 색으로 가라앉고, 마당 평상에 앉아도 눈이 부시지 않습니다.
- 하늘이 자주 바뀌는 날입니다. 나가실 때 우산을 챙기시는 편이 낫습니다.
- 안개가 낮게 깔리면 골목 끝이 흐려집니다. 서두르지 마시고 늦게 나가셔도 됩니다.
- 소나기가 지나갈 수 있는 하늘입니다. 창고형 CAFÉ 공간에서 한 차례 보내고 나가셔도 됩니다.
- 바람이 먼저 바뀌는 날입니다. 마당에 널어 두신 것은 들여놓으시는 편이 좋습니다.
**비**
- 백 년 된 기와를 타고 내리는 소리가 다릅니다. 창고형 CAFÉ 공간에서 빗소리를 들으실 수 있습니다.
- 비 오는 골목에는 사람이 없습니다. 히로쓰 가옥 담길은 오늘 같은 날이 가장 조용합니다.
- 마당 디딤돌이 미끄럽습니다. 밤에 나가실 때는 정원 석등 쪽으로 도세요.
- 실내로 도는 편이 낫습니다. 근대건축관과 군산근대미술관이 걸어서 십 분 안에 나란히 있습니다.
- 우산은 대문 안쪽에 두시면 됩니다. 젖은 것은 테라스 쪽이 잘 마릅니다.
**눈**
- 기와에 눈이 앉으면 골목이 통째로 조용해집니다. 정원 석등만 켜 두겠습니다.
- 눈 밟는 소리가 담 안에서 크게 들립니다. 마당은 아침에 한 번 쓸어 두겠습니다.
- 눈 오는 날의 원도심은 사람이 적습니다. 초원사진관 골목이 오늘 가장 한산합니다.
- 디딤돌에 눈이 얼 수 있습니다. 밤에 드나드실 때는 조심해서 딛으세요.
- 창을 닫아 두시는 편이 낫습니다. 창고형 CAFÉ 공간이 이런 날 가장 아늑합니다.
### 기온대 25개 (`TEMP_NOTES`)
**혹서**
- 낮에는 군산근대미술관·근대건축관처럼 실내로 도는 편이 낫습니다. 월명호수 한 바퀴는 해가 넘어간 뒤가 시원합니다.
- 한낮 골목에는 그늘이 없습니다. 초원사진관·영화의 거리는 이른 아침이나 해 질 무렵에 도세요.
- 옛 군산세관과 근대건축관은 걸어서 이어집니다. 실내를 징검다리 삼아 도시면 덜 지칩니다.
- 경암동 철길마을은 그늘이 적습니다. 오늘은 저녁에 가시는 편이 낫습니다.
- 물은 나가시기 전에 챙기세요. 이성당까지는 걸어서 오백 미터, 해가 기운 뒤가 낫습니다.
**더움**
- 선유도까지 다녀오기 좋은 기온입니다. 골목은 오후 늦게 도세요 — 경암동 철길마을은 해 질 때가 사람이 적습니다.
- 낮에는 월명호수 그늘길이 시원합니다. 물가를 따라 걷다 돌아오시면 됩니다.
- 우체통거리와 영화의 거리는 그늘이 이어집니다. 걸어서 도시기에 무리가 없습니다.
- 해가 길어 저녁이 넉넉합니다. 내항 쪽으로 나가 노을을 보고 오셔도 됩니다.
- 가장 더운 시간만 실내가 낫습니다. 군산근대미술관에서 한 차례 쉬어 가세요.
**선선**
- 걷기에 가장 좋은 날입니다. 히로쓰 가옥에서 초원사진관·영화의 거리까지 걸어서 그대로 이어집니다.
- 원도심은 오늘 하루면 다 걸립니다. 군산 시간여행마을까지 천천히 다녀오셔도 됩니다.
- 차를 두고 걷는 편이 낫습니다. 우체통거리에서 옛 군산세관까지 골목이 계속 이어집니다.
- 해가 부담스럽지 않습니다. 경암동 철길마을까지 다녀오기에 알맞은 기온입니다.
- 낮에는 월명호수를 한 바퀴 돌고, 밤에는 마당에 앉아 계셔도 좋습니다.
**쌀쌀**
- 월명호수를 한 바퀴 돌고 이성당이나 국제반점에서 따뜻한 것을 드시면 알맞습니다.
- 겉옷을 하나 더 챙기세요. 해가 지면 항구 쪽 바람이 먼저 찹니다.
- 실내와 골목을 섞어 도는 편이 낫습니다. 근대건축관에서 몸을 녹이고 다시 나오시면 됩니다.
- 따뜻한 국물이 도는 날입니다. 복성루·빈해원처럼 가까운 집부터 보세요.
- 오후 볕이 짧습니다. 초원사진관·영화의 거리를 먼저 돌고 저녁에 들어오세요.
**추움**
- 밖은 바람이 매섭습니다. 근대건축관·근대미술관과 시간여행마을 실내를 묶어 도는 편이 낫습니다.
- 항구 바람이 매운 날입니다. 골목은 짧게 돌고 실내를 길게 잡으세요.
- 장갑과 목도리를 챙기세요. 옛 군산세관 앞은 바람이 그대로 지나갑니다.
- 따뜻한 것을 먼저 드시는 편이 낫습니다. 이성당·국제반점은 걸어서 오백 미터입니다.
- 일찍 들어오셔도 됩니다. 벽난로가 있는 거실이 이런 날 가장 좋습니다.

View File

@ -25,12 +25,48 @@ const d = await page.evaluate(() => {
catchKinds: ['general', 'season', 'month', 'weather'].map((k) => (P.narrative.catchphrases?.items || []).filter((x) => x.kind === k).length), catchKinds: ['general', 'season', 'month', 'weather'].map((k) => (P.narrative.catchphrases?.items || []).filter((x) => x.kind === k).length),
themes: itin.length, themes: itin.length,
firstIsStay: days.every((day) => day.stops[0].name.endsWith('스테이 머뭄')), firstIsStay: days.every((day) => day.stops[0].name.endsWith('스테이 머뭄')),
lastIsStay: days.every((day) => day.stops[day.stops.length - 1].name.endsWith('스테이 머뭄')), // 떠나는 날은 갈린다 — 차로 온 손님은 짐을 싣고 나서므로 복귀 칸이 없다(2026-09-14 대표 의견).
lastIsStay: itin.every((i) => i.days.slice(0, -1)
.every((day) => day.stops[day.stops.length - 1].name.endsWith('스테이 머뭄'))),
depart: (() => {
const tail = (i) => i.days[i.days.length - 1];
const car = itin.filter((i) => !tail(i).stops[tail(i).stops.length - 1].name.endsWith('스테이 머뭄'));
const walk = itin.filter((i) => !car.includes(i));
return {
car: car.length, walk: walk.length,
car1: car.filter((i) => i.duration === '1박 2일').length,
car2: car.filter((i) => i.duration === '2박 3일').length,
// 퇴실 칸의 문구가 동선과 같은 말을 하는가 — 차인데 "맡겨 두고" 면 손님이 한 번 더 들른다
carNote: car.every((i) => (tail(i).stops[0].note || '').includes('차에 싣고')),
walkNote: walk.every((i) => (tail(i).stops[0].note || '').includes('맡겨 두고')),
walkEnd: walk.every((i) => tail(i).stops[tail(i).stops.length - 1].name.startsWith('마무리')),
};
})(),
consecutive: days.filter((day) => day.stops.some((s, i) => i && s.name.split(' · ').pop() === day.stops[i - 1].name.split(' · ').pop())).length, 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), hasNote: stops.every((s) => s.name.endsWith('스테이 머뭄') || s.note),
stopImages: stops.filter((s) => s.imageUrl).length, stopImages: stops.filter((s) => s.imageUrl).length,
restDays: days.filter((day) => day.stops.some((s) => s.name.startsWith('머뭄에서 쉬는 날'))).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)]; })(), 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)]; })(),
daily: sec('daily').items.length,
reading: (() => { const e = sec('reading'); return {
items: e.items.length,
groups: (e.groups || []).length,
sourced: e.items.filter((x) => x.source && x.source.url).length,
}; })(),
domReadCards: document.querySelectorAll('#w4d-reading .w4d-read-card').length,
domReadGroups: document.querySelectorAll('#w4d-reading .w4d-read-group').length,
domReadSrc: document.querySelectorAll('#w4d-reading .w4d-read-src a').length,
// 레일이 아니어야 한다 — 가로로 밀어야 보이면 '펼쳐 둔다' 가 아니다.
// 신문 조판이라 단(column)으로 흐른다(2026-09-14 대표: "신문처럼").
readCols: (() => { const el = document.querySelector('#w4d-reading .w4d-read-grid');
if (!el) return null;
return {cols: getComputedStyle(el).columnCount, overflow: el.scrollWidth - el.clientWidth}; })(),
readPaper: !!document.querySelector('#w4d-reading .w4d-read-head h2')
&& !!document.querySelector('#w4d-reading .w4d-read-dateline'),
dailyCats: [...new Set(sec('daily').items.map((x) => x.category))].sort(),
dailySourced: sec('daily').items.filter((x) => x.source && x.source.url).length,
domDailyPages: document.querySelectorAll('#daily article').length,
storyTabs: [...document.querySelectorAll('#story [role="tab"]')].map((t) => t.textContent.trim()),
songs: sec('songs').items.length, songs: sec('songs').items.length,
ownInSongs: sec('songs').items.filter((s) => s.own).length, ownInSongs: sec('songs').items.filter((s) => s.own).length,
songLinks: sec('songs').items.filter((s) => s.listenUrl).length, songLinks: sec('songs').items.filter((s) => s.listenUrl).length,
@ -55,19 +91,57 @@ const d = await page.evaluate(() => {
roomA: P.units[0].mediaIds.length, roomB: P.units[1].mediaIds.length, roomA: P.units[0].mediaIds.length, roomB: P.units[1].mediaIds.length,
roomMirror: P.units.flatMap((u) => u.mediaIds).length, roomMirror: P.units.flatMap((u) => u.mediaIds).length,
tagline: document.querySelector('#root h1')?.nextElementSibling?.textContent.trim(), tagline: document.querySelector('#root h1')?.nextElementSibling?.textContent.trim(),
sub: document.getElementById('w4d-sub')?.textContent.trim(),
subIsCatch: (() => { const t = document.getElementById('w4d-sub')?.textContent.trim(); return !!t && (P.narrative.catchphrases?.items || []).some((x) => x.text.replace(/\s+/g, ' ').trim() === t); })(),
restaurants: P.local.restaurants.length,
// 걸어서 10분(800m) 넘는 맛집 — 없으면 '10분 이상' 탭에서 맛집 칸이 빈다(2026-09-11 사장님 지적)
restPhotos: P.local.restaurants.filter((r) => r.imageUrl).length,
restFar: P.local.restaurants.filter((r) => { const m = /^([\d.]+)\s*(km|m)$/i.exec(r.distanceText || ''); return m && Number(m[1]) * (m[2].toLowerCase() === 'km' ? 1000 : 1) > 800; }).length,
}; };
}); });
ok('캐치프레이즈 100개', d.catch === 100, `${d.catch}개 · 일반${d.catchKinds[0]}/계절${d.catchKinds[1]}/월${d.catchKinds[2]}/날씨${d.catchKinds[3]}`); 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}"`); // 대표 문구는 고정, 순환은 그 아래 줄 (2026-09-11 사장님 지시)
ok('대표 문구 고정', d.tagline === '히로쓰 가옥 담 너머, 백 년 된 집 한 채', `"${d.tagline}"`);
ok('아래 줄에 캐치프레이즈', d.subIsCatch, `"${d.sub}"`);
const sub2 = await page.waitForTimeout(7500).then(() => page.evaluate(() => document.getElementById('w4d-sub')?.textContent.trim()));
ok('아래 줄 순환', !!sub2 && sub2 !== d.sub, `→ "${sub2}"`);
/* 자동재생 (2026-09-14 대표 지시). 브라우저는 조작 없는 소리 재생을 막는다 — 정책이라 못 넘는다.
그래서 **첫 손길에 켜지는가**를 센다. 막지 않는 브라우저면 그 전에 이미 켜져 있다. */
const autoAtLoad = await page.evaluate(() => document.getElementById('w4d-play')?.dataset.playing);
await page.mouse.wheel(0, 300);
await page.waitForTimeout(2500);
const autoAfter = await page.evaluate(() => ({
btn: document.getElementById('w4d-play')?.dataset.playing,
time: document.querySelector('.w4d-item[data-playing="1"] .w4d-time')?.textContent,
}));
ok('자동재생', autoAfter.btn === '1',
`불러온 직후 ${autoAtLoad === '1' ? '바로 재생' : '브라우저가 막음'} → 첫 손길에 ${autoAfter.time || '안 켜짐'}`);
ok('주변 맛집', d.restaurants >= 30 && d.restFar > 0, `${d.restaurants}곳 · 걸어서 10분 넘는 곳 ${d.restFar}`);
ok('맛집 사진', d.restPhotos === d.restaurants, `${d.restPhotos}/${d.restaurants}`);
ok('일정 테마', d.themes === 21, `${d.themes}개`); ok('일정 테마', d.themes === 21, `${d.themes}개`);
ok('1번 = 스테이 머뭄', d.firstIsStay, d.firstIsStay ? '전 일자' : '어긋남'); ok('1번 = 스테이 머뭄', d.firstIsStay, d.firstIsStay ? '전 일자' : '어긋남');
ok('마지막 = 스테이 머뭄', d.lastIsStay, d.lastIsStay ? '전 일자' : '어긋남'); ok('마지막 = 머뭄 (떠나는 날 빼고)', d.lastIsStay, d.lastIsStay ? '전 일자' : '어긋남');
ok('떠나는 날 복귀 없음 = 차 테마', d.depart.car === 14 && d.depart.car1 === 7 && d.depart.car2 === 7,
`차 ${d.depart.car}(1박${d.depart.car1}·2박${d.depart.car2}) · 도보 ${d.depart.walk}`);
ok('떠나는 날 퇴실 문구', d.depart.carNote && d.depart.walkNote && d.depart.walkEnd,
d.depart.carNote && d.depart.walkNote && d.depart.walkEnd ? '차=싣고 · 도보=맡기고 찾으러' : '어긋남');
ok('같은 자리 연속 없음', d.consecutive === 0, `${d.consecutive}건`); ok('같은 자리 연속 없음', d.consecutive === 0, `${d.consecutive}건`);
ok('정거장마다 할 일 문구', d.hasNote, d.hasNote ? '전 정거장' : '빠짐'); ok('정거장마다 할 일 문구', d.hasNote, d.hasNote ? '전 정거장' : '빠짐');
ok('일정 사진 없음', d.stopImages === 0 && d.domItinImg === 0, `payload ${d.stopImages} · 화면 ${d.domItinImg}`); ok('일정 사진 없음', d.stopImages === 0 && d.domItinImg === 0, `payload ${d.stopImages} · 화면 ${d.domItinImg}`);
ok('숙소에서 쉬는 날', d.restDays >= 3, `${d.restDays}일`); 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.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.reading.items >= 30 && d.domReadCards === d.reading.items
&& d.readCols && d.readCols.overflow <= 2 && Number(d.readCols.cols) >= 2,
`payload ${d.reading.items} · 화면 ${d.domReadCards}꼭지 · ${d.domReadGroups}면 · ${d.readCols?.cols}단 · 가로넘침 ${d.readCols?.overflow}px`);
ok('군산 읽기 신문 조판', d.readPaper, d.readPaper ? '제호 · 발행일 줄 있음' : '없음');
ok('군산 읽기 출처', d.reading.sourced === d.reading.items && d.domReadSrc === d.reading.items,
`payload ${d.reading.sourced}/${d.reading.items} · 화면 ${d.domReadSrc}`);
ok('오늘의 한 장', d.daily >= 39 && d.domDailyPages === d.daily,
`payload ${d.daily}장 · 화면 ${d.domDailyPages}장 · ${d.dailyCats.join('/')}`);
ok('오늘의 한 장 출처', d.dailySourced === d.daily, `${d.dailySourced}/${d.daily}`);
ok('이야기 탭에 일력', d.storyTabs.includes('오늘의 한 장'), d.storyTabs.join(','));
ok('가요 다방 곡', d.songs >= 25, `${d.songs}곡`); ok('가요 다방 곡', d.songs >= 25, `${d.songs}곡`);
ok('가요 다방에 자작곡 없음', d.ownInSongs === 0, `${d.ownInSongs}곡`); ok('가요 다방에 자작곡 없음', d.ownInSongs === 0, `${d.ownInSongs}곡`);
ok('노래마다 링크', d.songLinks === d.songs, `${d.songLinks}/${d.songs}`); ok('노래마다 링크', d.songLinks === d.songs, `${d.songLinks}/${d.songs}`);

View File

@ -1,4 +1,4 @@
"""구워진 payload 의 일정 21개를 표로 뽑고, 규칙 14종이 **안 보는** 논리까지 검수한다. """구워진 payload 의 일정 21개를 표로 뽑고, 규칙 17종이 **안 보는** 논리까지 검수한다.
★ 왜 따로 있나 — build_itinerary.py 의 audit() 는 "만들 때" 지키는 규칙이다. ★ 왜 따로 있나 — build_itinerary.py 의 audit() 는 "만들 때" 지키는 규칙이다.
이 스크립트는 **만들어진 결과**를 사람이 읽는 표로 펴고, 비율·간격처럼 이 스크립트는 **만들어진 결과**를 사람이 읽는 표로 펴고, 비율·간격처럼
@ -95,10 +95,19 @@ def check(item):
if is_last and rows[0]["place"] == STAY and rows[0]["to"] > 11 * 60: if is_last and rows[0]["place"] == STAY and rows[0]["to"] > 11 * 60:
bad.append(("L7", f"{label}: 퇴실이 {hm(rows[0]['to'])}")) bad.append(("L7", f"{label}: 퇴실이 {hm(rows[0]['to'])}"))
# L8 시작·끝이 숙소인가 # L8 시작·끝이 숙소인가
# ★ 떠나는 날은 갈린다 (2026-09-14 대표 의견). 차로 온 손님은 퇴실할 때 짐을 싣고
# 나서므로 복귀 칸이 없다 — 그 하루는 마지막 정거장에서 끝나는 것이 맞다.
# 판별은 **퇴실 칸의 문구**로 한다(payload 에 남는 값이라 표와 화면이 같은 것을 본다).
car_out = is_last and "차에 싣고" in (day["stops"][0].get("note") or "")
if rows[0]["place"] != STAY: if rows[0]["place"] != STAY:
bad.append(("L8", f"{label}: 첫 칸이 {rows[0]['place']}")) bad.append(("L8", f"{label}: 첫 칸이 {rows[0]['place']}"))
if rows[-1]["place"] != STAY: if rows[-1]["place"] != STAY and not car_out:
bad.append(("L8", f"{label}: 마지막 칸이 {rows[-1]['place']}")) bad.append(("L8", f"{label}: 마지막 칸이 {rows[-1]['place']}"))
# L12 떠나는 날의 끝맺음이 퇴실 문구와 같은 말을 하는가
if car_out and rows[-1]["place"] == STAY:
bad.append(("L12", f"{label}: 짐을 차에 싣고 나섰는데 숙소로 돌아온다"))
if is_last and not car_out and rows[-1]["slot"] != "마무리":
bad.append(("L12", f"{label}: 짐을 맡겨 두고 나섰는데 찾으러 돌아오지 않는다"))
# L9 같은 자리 연속 # L9 같은 자리 연속
for i in range(1, len(rows)): for i in range(1, len(rows)):
if rows[i]["place"] == rows[i - 1]["place"]: if rows[i]["place"] == rows[i - 1]["place"]:
@ -124,7 +133,7 @@ lines = ["# `/s/stay` 여행 일정 전수 검수표", "",
f"{sum(len(i['days']) for i in items)}일 · " f"{sum(len(i['days']) for i in items)}일 · "
f"{sum(len(d['stops']) for i in items for d in i['days'])}정거장", "", f"{sum(len(d['stops']) for i in items for d in i['days'])}정거장", "",
"표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).", "표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).",
"규칙 14종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,", "규칙 17종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,",
"이 표는 그 규칙이 **안 보는 논리**를 따로 센다.", "", "이 표는 그 규칙이 **안 보는 논리**를 따로 센다.", "",
"| 코드 | 검수 항목 |", "|---|---|", "| 코드 | 검수 항목 |", "|---|---|",
"| L0 | 21시를 넘겨 화면에서 버려지는 칸이 없다 |", "| L0 | 21시를 넘겨 화면에서 버려지는 칸이 없다 |",
@ -135,10 +144,11 @@ lines = ["# `/s/stay` 여행 일정 전수 검수표", "",
"| L5 | 바깥 정거장 체류 15분 이상 |", "| L5 | 바깥 정거장 체류 15분 이상 |",
"| L6 | 하루가 14시간을 넘지 않는다 |", "| L6 | 하루가 14시간을 넘지 않는다 |",
"| L7 | 입실 15:00 이후 · 퇴실 11:00 전 |", "| L7 | 입실 15:00 이후 · 퇴실 11:00 전 |",
"| L8 | 하루의 첫 칸·마지막 칸이 숙소 |", "| L8 | 하루의 첫 칸·마지막 칸이 숙소 (**떠나는 날 차 테마는 복귀 없이 끝난다**) |",
"| L9 | 같은 자리가 연달아 서지 않는다 |", "| L9 | 같은 자리가 연달아 서지 않는다 |",
"| L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 |", "| L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 |",
"| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |", ""] "| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |",
"| L12 | 떠나는 날의 끝맺음이 퇴실 문구와 같다 — 짐을 차에 실었으면 안 돌아오고, 맡겼으면 찾으러 온다 |", ""]
fails = [] fails = []
for item in items: for item in items:
@ -169,7 +179,7 @@ if fails:
head += ["| 테마 | 걸린 항목 |", "|---|---|"] head += ["| 테마 | 걸린 항목 |", "|---|---|"]
head += [f"| {n} | {', '.join(sorted({c for c, _ in b}))} |" for n, b in fails] head += [f"| {n} | {', '.join(sorted({c for c, _ in b}))} |" for n, b in fails]
else: else:
head += ["**전부 통과.** 12개 항목 위반 0건."] head += ["**전부 통과.** 13개 항목 위반 0건."]
head.append("") head.append("")
lines = lines[:8] + head + lines[8:] lines = lines[:8] + head + lines[8:]

View File

@ -0,0 +1,152 @@
"""오늘의 한 장 — 군산의 노래와 문학을 하루에 한 장씩 넘기는 일력.
★ 왜 이 자리인가 (2026-09-14 대표 의견: "엽서 형식보다 대중가요·시·소설이 더 풍부했으면")
렌더러에는 `daily`(오늘의 한 장) 탭이 이미 있는데 **payload 에 그 섹션 자체가 없었다** —
자리는 있고 값이 없어 탭이 서지 않던 자리다(README 1.2). 엽서를 갈아 끼우는 대신
빈 자리를 채운다. 엽서는 그대로 둔다.
★ **이 자리는 도시를 소개하는 자리가 아니다** (같은 날 대표 의견: "오늘의 한 장으로 군산을
소개하기는 무리다 — 하루에 하나씩밖에 안 알려 주니까"). 일력은 오늘·어제·내일 세 장만
화면에 선다. 군산을 펼쳐 보이는 자리는 `build_reading.py`(군산 읽기)가 맡는다.
여기는 **날마다 바뀌는 한 장**이고, 그래서 곡·시인·문학을 한 장씩 돌린다.
★ 받은 자료(`gunsan_365_story_db.xlsx`)를 그대로 싣지 않는다.
365행이지만 **실제 주제는 52개**고, 한 주제를 '작품 배경 / 역사 맥락 / 계절 연결 /
SNS 한 문장 / 교육용 질문 …' 일곱 가지 운영 슬롯으로 복제해 둔 시드다. 그대로 실으면
같은 문장이 일주일에 일곱 번 돈다. 게다가 231행이 `검증필요`, 14행이 `후보`이고
시·수필 카테고리는 전부 빈 슬롯이다("…를 찾아 소개하기 위한 **운영용 슬롯**이다") —
손님 화면에 나갈 문장이 아니라 우리끼리 쓰는 메모다.
→ **소재 목록으로만 쓴다.** 화면에 나가는 문장은 여기서 다시 쓰고, 출처는 그 주제를
실제로 설명하는 기관 페이지를 단다.
★ 365장을 억지로 채우지 않는다. 확인되는 소재가 39개면 39장이다 —
렌더러는 오늘 자리가 비면 **다음 장**을 편다(`DailySection.tsx`). 채우려면 같은 글을
여러 날짜에 복사해야 하는데, 그건 인용되려고 만든 사이트에서 할 일이 아니다.
입력: stay-payload-new.json (build_story.py · build_reading.py 까지 끝난 것 —
가요 25곡 · 인물 57명 · 문학 8가지를 **전부 거기서 읽는다.** 여기서 손으로 적는 것은 없다)
출력: 같은 파일의 theme.sections 에 `daily` 섹션을 넣는다.
"""
import json
from pathlib import Path
SP = Path(__file__).parent
SRC = SP / "stay-payload-new.json"
payload = json.loads(SRC.read_text(encoding="utf-8"))
SECTIONS = payload["theme"]["sections"]
SECT = {s["id"]: s for s in SECTIONS}
# ── 문학은 '군산 읽기' 표에서 가져온다 ─────────────────────────────────────
# ★ 한 벌만 둔다 (2026-09-14). 문학 카드를 여기에도 적어 두면 한쪽만 고치는 날이 온다 —
# 이 레포에서 프롬프트가 두 곳으로 갈려 서버가 세 종류를 못 만들던 것과 같은 종류의 사고다.
# 손으로 적는 자리는 `build_reading.py` `ITEMS` 하나다.
LIT_GROUP = "문학"
# ── 시 — 군산이 낳은 시인 ────────────────────────────────────────────────────
# ★ 시 작품을 싣지 않는다. 확인되는 것은 '이 도시에서 난 시인' 까지다 —
# 받은 자료의 시 카테고리도 전부 빈 슬롯이었고(위 머리말), 군산을 노래한 시를
# 출처와 함께 확인한 것이 아직 없다. 없는 것을 지어내느니 시인을 세운다.
POETS = ["고은", "이병훈", "심호택", "문효치", "이연주", "주하림"]
def _items(section_id: str) -> list[dict]:
section = SECT.get(section_id)
if not section or not section.get("data"):
raise SystemExit(f"{section_id} 섹션이 비었다 — build_story.py · build_reading.py 를 먼저 돌린다")
return json.loads(section["data"])["items"]
# ── 가요 25곡 → 25장 ─────────────────────────────────────────────────────────
# 가요 다방(`songs`)에 이미 선 곡을 그대로 읽는다. 목록을 두 벌로 두면 한쪽만 고치게 된다.
song_pages = []
for song in _items("songs"):
facts = []
if song.get("year"):
facts.append(f"{song['year']}년 곡")
credit = " · ".join(
part for part in (
f"작사 {song['lyricist']}" if song.get("lyricist") else "",
f"작곡 {song['composer']}" if song.get("composer") else "",
) if part)
if credit:
facts.append(credit)
body = ". ".join(facts)
body = f"{body}. {song['story']}" if body else song["story"]
song_pages.append({"category": "대중가요",
"title": f"「{song['title']}」 · {song['artist']}",
"body": body,
"verified": song.get("verified", "확인필요"),
"source": song["source"]})
# ── 시인 6명 → 6장 ───────────────────────────────────────────────────────────
people = {p["name"]: p for p in _items("people")}
poet_pages = []
for name in POETS:
person = people.get(name)
if not person:
raise SystemExit(f"인물 열전에 {name} 이 없다 — build_story.py 의 PEOPLE 을 본다")
years = f"{person['years']} · " if person.get("years") else ""
poet_pages.append({"category": "시", "title": f"시인 {name}",
"body": f"{years}{person['oneLine']}",
"verified": person.get("verified", "확인필요"),
"source": person["source"]})
lit_pages = [{"category": LIT_GROUP, "title": x["title"], "body": x["body"],
# 2026-09-14 부로 build_reading.py 의 ITEMS 에서 verified 필드를 뺐다
# (대표: "확인필요 없애고 링크는 네이버 링크로") — 여기 기본값은 그 결정을 따른다.
"verified": x.get("verified", "확인"), "source": x["source"]}
for x in _items("reading") if x["group"] == LIT_GROUP]
# ── 섞고, 날짜를 준다 ────────────────────────────────────────────────────────
# ★ 같은 분류를 연달아 세우지 않는다 — 일력은 어제·오늘·내일 세 장이 한 화면에 걸린다
# (`DailySection.tsx`). 가요만 석 장 나란히 서면 일력이 아니라 음반 목록으로 보인다.
others = lit_pages + poet_pages
pages, si, oi = [], 0, 0
total = len(song_pages) + len(others)
for step in range(total):
take_other = oi < len(others) and (si >= len(song_pages)
or (step + 1) * len(others) / total > oi)
if take_other:
pages.append(others[oi])
oi += 1
else:
pages.append(song_pages[si])
si += 1
# ★ 날짜는 **고르게 편다**. 곡이 나온 날·작가의 생일에 맞추고 싶지만 그 날짜를 출처로
# 확인한 것이 없다 — 지어낸 날짜를 일력에 박으면 틀린 줄 모르고 해마다 틀린다.
for index, page in enumerate(pages):
day = round(index * 365 / len(pages)) + 1
month, rest = 1, day
for length in (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31):
if rest <= length:
break
rest -= length
month += 1
page["monthDay"] = f"{month:02d}-{rest:02d}"
env = {"kind": "daily", "version": 1, "title": "오늘의 한 장",
"subtitle": f"군산의 노래와 문학 {len(pages)}장 — 날마다 한 장씩 넘어갑니다",
"items": [{"monthDay": p["monthDay"], "category": p["category"], "title": p["title"],
"body": p["body"], "verified": p["verified"], "source": p["source"]}
for p in pages]}
# 없으면 만든다 — 원본 payload 에는 `daily` 섹션 자체가 없다(그래서 탭이 안 섰다).
if "daily" not in SECT:
# 형제(가요 다방) 옆에 둔다. 탭 순서는 렌더러가 정하므로 배열 순서는 보기 좋으라고 맞추는 것이다.
SECTIONS.insert(next(i for i, s in enumerate(SECTIONS) if s["id"] == "songs") + 1,
{"id": "daily", "name": "오늘의 한 장", "enabled": False, "locked": False})
SECT = {s["id"]: s for s in SECTIONS}
SECT["daily"]["data"] = json.dumps(env, ensure_ascii=False)
SRC.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
counts: dict[str, int] = {}
for page in pages:
counts[page["category"]] = counts.get(page["category"], 0) + 1
unverified = sum(1 for p in pages if p["verified"] != "확인")
print(f"오늘의 한 장 {len(pages)}장 — "
+ " · ".join(f"{k} {v}" for k, v in counts.items())
+ f" (확인필요 {unverified}장)")
print(f" 날짜 {pages[0]['monthDay']} … {pages[-1]['monthDay']}")

View File

@ -122,8 +122,14 @@ STAY_ACTION = {
# 일정도 있어"). 아침(머뭄)과 체크아웃(머뭄)을 따로 세웠더니 **같은 자리에 두 칸이 연달아** # 일정도 있어"). 아침(머뭄)과 체크아웃(머뭄)을 따로 세웠더니 **같은 자리에 두 칸이 연달아**
# 섰다 — 손님은 그 사이에 아무 데도 가지 않는다. 한 칸이 맞다. # 섰다 — 손님은 그 사이에 아무 데도 가지 않는다. 한 칸이 맞다.
# 마지막 날 아침은 체류 범위가 다르다 — 짐을 싸고 퇴실까지 한 칸에서 끝낸다. # 마지막 날 아침은 체류 범위가 다르다 — 짐을 싸고 퇴실까지 한 칸에서 끝낸다.
# ★ 퇴실 뒤가 **차로 온 손님과 걸어 온 손님이 갈리는 자리**다 (2026-09-14 대표 의견).
# 차로 온 손님은 짐을 싣고 그대로 나선다 — 짐을 맡겨 두고 저녁에 숙소로 되돌아오는 동선은
# 차가 있는 사람에게는 한 번 더 들르라는 말이 된다. 그래서 그 하루는 **마지막 정거장에서 끝난다**.
# 짐 보관은 걸어 다니는 손님에게 필요한 것이고, 그 테마만 저녁에 찾으러 돌아온다.
"아침(퇴실)": ((45, 75, 115), "아침(퇴실)": ((45, 75, 115),
"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요."), "아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요."),
"아침(퇴실·차)": ((45, 75, 115),
"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다."),
"머뭄에서 쉬는 날": ((120, 210, 360), "머뭄에서 쉬는 날": ((120, 210, 360),
"오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다."), "오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다."),
# ★ 10분짜리 칸을 시간표에 세우지 않는다 (2026-09-10 사장님: "10분 정도 왜 잡아 놓는 거야?"). # ★ 10분짜리 칸을 시간표에 세우지 않는다 (2026-09-10 사장님: "10분 정도 왜 잡아 놓는 거야?").
@ -203,6 +209,17 @@ SKELETON = {
} }
# 이 뼈대의 첫 칸(아침 · 퇴실)은 **11시 전에 끝나야** 한다 — payload 의 check_out_time. # 이 뼈대의 첫 칸(아침 · 퇴실)은 **11시 전에 끝나야** 한다 — payload 의 check_out_time.
CHECKOUT_KINDS = {"last", "last_plus", "last_island"} CHECKOUT_KINDS = {"last", "last_plus", "last_island"}
# ★ 차로 온 손님의 마지막 날은 **복귀 칸이 없다** (2026-09-14 대표 의견).
# 퇴실할 때 짐을 차에 싣고 나서므로 숙소로 되돌아올 일이 없다 — 마지막 정거장이 하루의 끝이다.
# 뼈대를 따로 적지 않고 **복귀 칸만 떼어** 만든다. 그래야 도보 뼈대를 고칠 때 차 뼈대가 같이 따라온다
# (두 벌로 두면 한쪽만 고치고 넘어가는 자리가 생긴다 — 이 파일이 실제로 그래서 틀린 적이 있다).
CAR_KINDS = set()
for _base in ("last", "last_plus", "last_island"):
_car = _base + "_car"
assert SKELETON[_base][-1] == "마무리@"
SKELETON[_car] = SKELETON[_base][:-1]
CHECKOUT_KINDS.add(_car)
CAR_KINDS.add(_car)
# 하루의 시작 = **머뭄에서 아침을 여는 시각**이다(첫날만 입실 시각). # 하루의 시작 = **머뭄에서 아침을 여는 시각**이다(첫날만 입실 시각).
START = {"first": "15:00", "first_late": "19:00", "mid": "08:30", "mid_island": "07:40", 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", "mid_late": "09:40", "mid_rest": "10:00", "last": "08:10", "last_plus": "07:50",
@ -211,6 +228,10 @@ START = {"first": "15:00", "first_late": "19:00", "mid": "08:30", "mid_island":
FLOOR = {"first": "15:00", "first_late": "18:00", "mid": "07:20", "mid_island": "07: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", "mid_late": "09:00", "mid_rest": "09:30", "last": "07:20", "last_plus": "07:20",
"last_island": "07:20"} "last_island": "07:20"}
for _car in CAR_KINDS:
START[_car] = START[_car[:-4]]
FLOOR[_car] = FLOOR[_car[:-4]]
DAY_LABEL = ["첫째 날", "둘째 날", "셋째 날"] DAY_LABEL = ["첫째 날", "둘째 날", "셋째 날"]
DURATION = {2: "1박 2일", 3: "2박 3일"} DURATION = {2: "1박 2일", 3: "2박 3일"}
@ -221,9 +242,9 @@ def _minutes(text: str) -> int:
def _action(slot: str, kind: str) -> tuple: def _action(slot: str, kind: str) -> tuple:
"""머뭄 칸의 (체류범위, 한 줄). 마지막 날 아침만 퇴실을 겸한다.""" """머뭄 칸의 (체류범위, 한 줄). 마지막 날 아침만 퇴실을 겸하고, 차 테마는 문구가 다르다."""
if slot == "아침" and kind in CHECKOUT_KINDS: if slot == "아침" and kind in CHECKOUT_KINDS:
return STAY_ACTION["아침(퇴실)"] return STAY_ACTION["아침(퇴실·차)" if kind in CAR_KINDS else "아침(퇴실)"]
return STAY_ACTION[slot] return STAY_ACTION[slot]
@ -394,7 +415,7 @@ THEMES = [
("섬까지 다녀오는 1박 2일", "1박 2일", "차를 가져온 손님", ("섬까지 다녀오는 1박 2일", "1박 2일", "차를 가져온 손님",
"첫날은 걸어서 도심, 이튿날은 다리를 건너 섬입니다. 두 날의 성격이 아주 다릅니다.", "첫날은 걸어서 도심, 이튿날은 다리를 건너 섬입니다. 두 날의 성격이 아주 다릅니다.",
[("first", ["히로쓰 가옥", "초원사진관", "빈해원", "군산 내항"]), [("first", ["히로쓰 가옥", "초원사진관", "빈해원", "군산 내항"]),
("last_island", ["고군산군도 선유도", "국제반점"])]), ("last_island_car", ["고군산군도 선유도", "국제반점"])]),
("걸어서만 도는 1박 2일", "1박 2일", "차 없이 오는 손님", ("걸어서만 도는 1박 2일", "1박 2일", "차 없이 오는 손님",
"모든 정거장이 머뭄에서 걸어 닿습니다. 이틀 동안 차를 한 번도 타지 않습니다.", "모든 정거장이 머뭄에서 걸어 닿습니다. 이틀 동안 차를 한 번도 타지 않습니다.",
[("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]), [("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]),
@ -402,11 +423,11 @@ THEMES = [
("비가 와도 되는 1박 2일", "1박 2일", "비 예보를 보고 오는 손님", ("비가 와도 되는 1박 2일", "1박 2일", "비 예보를 보고 오는 손님",
"실내가 이어집니다. 비가 그치기를 기다리지 않아도 하루가 찹니다.", "실내가 이어집니다. 비가 그치기를 기다리지 않아도 하루가 찹니다.",
[("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]), [("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]),
("last", ["군산근대건축관", "일흥옥", "째보선창 근처 카페"])]), ("last_car", ["군산근대건축관", "일흥옥", "째보선창 근처 카페"])]),
("근대건축만 보는 1박 2일", "1박 2일", "건물을 보러 오는 손님", ("근대건축만 보는 1박 2일", "1박 2일", "건물을 보러 오는 손님",
"1900년대 건물만 골라 돕니다. 머뭄도 그 시기에 지은 집입니다.", "1900년대 건물만 골라 돕니다. 머뭄도 그 시기에 지은 집입니다.",
[("first", ["구 군산세관 본관", "군산근대건축관", "명월갈비", "군산 내항"]), [("first", ["구 군산세관 본관", "군산근대건축관", "명월갈비", "군산 내항"]),
("last", ["군산근대미술관", "일흥옥", "해망굴"])]), ("last_car", ["군산근대미술관", "일흥옥", "해망굴"])]),
("이성당으로 시작하는 1박 2일", "1박 2일", "빵을 좋아하는 손님", ("이성당으로 시작하는 1박 2일", "1박 2일", "빵을 좋아하는 손님",
"이튿날 아침을 빵으로 엽니다. 문 여는 시간에 맞춰 걸어갑니다.", "이튿날 아침을 빵으로 엽니다. 문 여는 시간에 맞춰 걸어갑니다.",
[("first", ["초원사진관", "우체통거리", "도란", "군산 내항"]), [("first", ["초원사진관", "우체통거리", "도란", "군산 내항"]),
@ -414,7 +435,7 @@ THEMES = [
("철길마을까지 가는 1박 2일", "1박 2일", "사진을 찍는 손님", ("철길마을까지 가는 1박 2일", "1박 2일", "사진을 찍는 손님",
"빛이 좋은 자리만 이어 붙였습니다. 철길마을은 오전 빛이 낫습니다.", "빛이 좋은 자리만 이어 붙였습니다. 철길마을은 오전 빛이 낫습니다.",
[("first", ["초원사진관", "동국사", "국제반점", "군산 내항"]), [("first", ["초원사진관", "동국사", "국제반점", "군산 내항"]),
("last", ["경암동 철길마을", "일흥옥", "군산 시간여행마을"])]), ("last_car", ["경암동 철길마을", "일흥옥", "군산 시간여행마을"])]),
("항구만 도는 1박 2일", "1박 2일", "바다가 보고 싶은 손님", ("항구만 도는 1박 2일", "1박 2일", "바다가 보고 싶은 손님",
"부잔교에서 째보선창까지, 이틀 동안 물가만 걷습니다.", "부잔교에서 째보선창까지, 이틀 동안 물가만 걷습니다.",
[("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]), [("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]),
@ -422,15 +443,15 @@ THEMES = [
("소설 『탁류』를 따라 걷는 1박 2일", "1박 2일", "채만식을 읽어 본 손님", ("소설 『탁류』를 따라 걷는 1박 2일", "1박 2일", "채만식을 읽어 본 손님",
"소설이 지나간 자리를 순서대로 밟습니다. 세관·선창·문학관이 한 줄로 이어집니다.", "소설이 지나간 자리를 순서대로 밟습니다. 세관·선창·문학관이 한 줄로 이어집니다.",
[("first", ["째보선창", "군산 내항 부잔교", "한일옥", "군산 내항"]), [("first", ["째보선창", "군산 내항 부잔교", "한일옥", "군산 내항"]),
("last", ["채만식문학관", "일흥옥", "구 군산세관 본관"])]), ("last_car", ["채만식문학관", "일흥옥", "구 군산세관 본관"])]),
("아이와 함께 1박 2일", "1박 2일", "아이를 데려온 손님", ("아이와 함께 1박 2일", "1박 2일", "아이를 데려온 손님",
"걷는 거리가 짧고, 아이가 지루해할 자리를 뺐습니다.", "걷는 거리가 짧고, 아이가 지루해할 자리를 뺐습니다.",
[("first", ["초원사진관", "우체통거리", "만남스넥", "군산 내항"]), [("first", ["초원사진관", "우체통거리", "만남스넥", "군산 내항"]),
("last", ["경암동 철길마을", "이성당", "월명호수"])]), ("last_car", ["경암동 철길마을", "이성당", "월명호수"])]),
("늦게 도착하는 1박 2일", "1박 2일", "퇴근하고 오는 손님", ("늦게 도착하는 1박 2일", "1박 2일", "퇴근하고 오는 손님",
"첫날은 저녁 한 끼로 끝냅니다. 대신 이튿날을 아침부터 저녁까지 씁니다.", "첫날은 저녁 한 끼로 끝냅니다. 대신 이튿날을 아침부터 저녁까지 씁니다.",
[("first_late", ["명월갈비"]), [("first_late", ["명월갈비"]),
("last_plus", ["군산근대역사박물관", "한일옥", "군산근대건축관", "해망굴"])]), ("last_plus_car", ["군산근대역사박물관", "한일옥", "군산근대건축관", "해망굴"])]),
("천천히 도는 2박 3일", "2박 3일", "쉬러 오는 손님", ("천천히 도는 2박 3일", "2박 3일", "쉬러 오는 손님",
"하루에 두어 곳만 봅니다. 마당에 앉아 있는 시간을 일정에 넣었습니다.", "하루에 두어 곳만 봅니다. 마당에 앉아 있는 시간을 일정에 넣었습니다.",
@ -441,7 +462,7 @@ THEMES = [
"가운데 날을 섬에 다 씁니다. 첫날과 마지막 날은 걸어서 도심입니다.", "가운데 날을 섬에 다 씁니다. 첫날과 마지막 날은 걸어서 도심입니다.",
[("first", ["구 군산세관 본관", "군산근대건축관", "빈해원", "군산 내항"]), [("first", ["구 군산세관 본관", "군산근대건축관", "빈해원", "군산 내항"]),
("mid_island", ["고군산군도 선유도", "장자도", "국제반점", "명월갈비"]), ("mid_island", ["고군산군도 선유도", "장자도", "국제반점", "명월갈비"]),
("last", ["초원사진관", "일흥옥", "이성당"])]), ("last_car", ["초원사진관", "일흥옥", "이성당"])]),
("골목을 세 번 나눠 걷는 2박 3일", "2박 3일", "차 없이 오는 손님", ("골목을 세 번 나눠 걷는 2박 3일", "2박 3일", "차 없이 오는 손님",
"원도심을 세 덩이로 나눠 하루에 하나씩 걷습니다. 같은 길을 두 번 걷지 않습니다.", "원도심을 세 덩이로 나눠 하루에 하나씩 걷습니다. 같은 길을 두 번 걷지 않습니다.",
[("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]), [("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]),
@ -451,22 +472,22 @@ THEMES = [
"끼니가 중심입니다. 사이에 걸을 자리를 넣어 배를 비웁니다.", "끼니가 중심입니다. 사이에 걸을 자리를 넣어 배를 비웁니다.",
[("first", ["물밀소", "초원사진관", "명월갈비", "군산 내항"]), [("first", ["물밀소", "초원사진관", "명월갈비", "군산 내항"]),
("mid", ["군산 시간여행마을", "일흥옥", "이성당", "우체통거리", "빈해원"]), ("mid", ["군산 시간여행마을", "일흥옥", "이성당", "우체통거리", "빈해원"]),
("last", ["동국사", "국제반점", "군산근대역사박물관"])]), ("last_car", ["동국사", "국제반점", "군산근대역사박물관"])]),
("아무 데도 안 가는 2박 3일", "2박 3일", "쉬려고만 오는 손님", ("아무 데도 안 가는 2박 3일", "2박 3일", "쉬려고만 오는 손님",
"가운데 날은 대문 밖으로 두 번만 나갑니다. 나머지는 마당과 카페 공간에서 보냅니다.", "가운데 날은 대문 밖으로 두 번만 나갑니다. 나머지는 마당과 카페 공간에서 보냅니다.",
[("first", ["히로쓰 가옥", "초원사진관", "도란", "군산 내항"]), [("first", ["히로쓰 가옥", "초원사진관", "도란", "군산 내항"]),
("mid_rest", ["만남스넥", "일흥옥"]), ("mid_rest", ["만남스넥", "일흥옥"]),
("last", ["동국사", "물밀소", "월명호수"])]), ("last_car", ["동국사", "물밀소", "월명호수"])]),
("사진만 찍는 2박 3일", "2박 3일", "사진을 찍는 손님", ("사진만 찍는 2박 3일", "2박 3일", "사진을 찍는 손님",
"시간대에 맞춰 자리를 배치했습니다. 철길마을은 오전, 부잔교는 해 질 때입니다.", "시간대에 맞춰 자리를 배치했습니다. 철길마을은 오전, 부잔교는 해 질 때입니다.",
[("first", ["초원사진관", "동국사", "도란", "군산 내항"]), [("first", ["초원사진관", "동국사", "도란", "군산 내항"]),
("mid", ["경암동 철길마을", "만남스넥", "째보선창", "해망굴", "국제반점"]), ("mid", ["경암동 철길마을", "만남스넥", "째보선창", "해망굴", "국제반점"]),
("last", ["우체통거리", "일흥옥", "군산근대미술관"])]), ("last_car", ["우체통거리", "일흥옥", "군산근대미술관"])]),
("비가 와도 되는 2박 3일", "2박 3일", "비 예보를 보고 오는 손님", ("비가 와도 되는 2박 3일", "2박 3일", "비 예보를 보고 오는 손님",
"사흘 내리 비가 와도 도는 순서가 바뀌지 않습니다. 실내와 처마 밑만 잇습니다.", "사흘 내리 비가 와도 도는 순서가 바뀌지 않습니다. 실내와 처마 밑만 잇습니다.",
[("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]), [("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]),
("mid", ["군산근대건축관", "일흥옥", "째보선창 근처 카페", "동국사다온", "명월갈비"]), ("mid", ["군산근대건축관", "일흥옥", "째보선창 근처 카페", "동국사다온", "명월갈비"]),
("last", ["채만식문학관", "한일옥", "이성당"])]), ("last_car", ["채만식문학관", "한일옥", "이성당"])]),
("늦게 시작하는 2박 3일", "2박 3일", "아침이 힘든 손님", ("늦게 시작하는 2박 3일", "2박 3일", "아침이 힘든 손님",
"가운데 날을 열한 시에 엽니다. 오전을 비우고 저녁을 늦게까지 씁니다.", "가운데 날을 열한 시에 엽니다. 오전을 비우고 저녁을 늦게까지 씁니다.",
[("first", ["히로쓰 가옥", "우체통거리", "도란", "군산 내항"]), [("first", ["히로쓰 가옥", "우체통거리", "도란", "군산 내항"]),
@ -476,7 +497,7 @@ THEMES = [
"가운데 날은 아침에 나가 저녁에 들어옵니다. 섬에서 하루를 통째로 씁니다.", "가운데 날은 아침에 나가 저녁에 들어옵니다. 섬에서 하루를 통째로 씁니다.",
[("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]), [("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]),
("mid_island", ["고군산군도 선유도", "장자도", "한일옥", "명월갈비"]), ("mid_island", ["고군산군도 선유도", "장자도", "한일옥", "명월갈비"]),
("last", ["군산근대건축관", "만남스넥", "경암동 철길마을"])]), ("last_car", ["군산근대건축관", "만남스넥", "경암동 철길마을"])]),
("소설 『탁류』를 따라 걷는 2박 3일", "2박 3일", "채만식을 읽어 본 손님", ("소설 『탁류』를 따라 걷는 2박 3일", "2박 3일", "채만식을 읽어 본 손님",
"소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다.", "소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다.",
[("first", ["째보선창", "군산 내항 부잔교", "일흥옥", "군산 내항"]), [("first", ["째보선창", "군산 내항 부잔교", "일흥옥", "군산 내항"]),
@ -486,7 +507,7 @@ THEMES = [
"기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다.", "기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다.",
[("first", ["경암동 철길마을", "초원사진관", "국제반점", "군산 내항"]), [("first", ["경암동 철길마을", "초원사진관", "국제반점", "군산 내항"]),
("mid", ["군산 내항 부잔교", "한일옥", "째보선창", "해망굴", "명월갈비"]), ("mid", ["군산 내항 부잔교", "한일옥", "째보선창", "해망굴", "명월갈비"]),
("last", ["구 군산세관 본관", "일흥옥", "군산근대미술관"])]), ("last_car", ["구 군산세관 본관", "일흥옥", "군산근대미술관"])]),
] ]
# 테마 이름 → (날짜별 시작 시각, 체류 배율). 시각과 배율이 그 테마의 성격이다 — # 테마 이름 → (날짜별 시작 시각, 체류 배율). 시각과 배율이 그 테마의 성격이다 —
@ -522,11 +543,21 @@ for name, duration, audience, why, days in THEMES:
built = [build_day(kind, places, DAY_LABEL[i], built = [build_day(kind, places, DAY_LABEL[i],
(starts[i] if starts and i < len(starts) else None), pace) (starts[i] if starts and i < len(starts) else None), pace)
for i, (kind, places) in enumerate(days)] for i, (kind, places) in enumerate(days)]
items.append({"name": name, "duration": duration, "audience": audience, "why": why, # ★ 차 필요 여부를 손님이 테마를 **고르기 전에** 보이게 한다 (2026-09-14 대표: "차로
"days": built}) # 가는거 아닌거는 대중교통 이용이라고 따로 표시해두는게 어때"). 이 신호는 원래
# 마지막 날 체크아웃 칸의 note 에만 있었다 — 카드를 펼쳐 마지막 정거장까지 봐야
# 나오는 자리라, 테마 이름만 보고 고르는 손님에게는 안 보였다.
# ★ '대중교통'이라고는 쓰지 않는다. 이 7개 테마는 버스·택시가 필요한 게 아니라
# 전 정거장이 머뭄에서 1.5km 안(WALK_LIMIT_KM)이라 걸어서 다 된다 — 실제로
# 안 쓰는 교통수단을 이름에 올리면 손님이 없는 버스편을 찾아보게 만든다.
needs_car = days[-1][0] in CAR_KINDS
tag = "차량 필요" if needs_car else "도보로 충분 (차 없이 가능)"
items.append({"name": name, "duration": duration, "audience": f"{audience} · {tag}",
"why": why, "days": built})
env = {"kind": "itinerary", "version": 1, "title": "추천 일정", env = {"kind": "itinerary", "version": 1, "title": "추천 일정",
"subtitle": "머뭄에서 나서고 머뭄으로 돌아옵니다", "items": items} "subtitle": "머뭄에서 나서고 머뭄으로 돌아옵니다 — 떠나는 날만 그대로 길을 나섭니다",
"items": items}
SECT["itinerary"]["data"] = json.dumps(env, ensure_ascii=False) SECT["itinerary"]["data"] = json.dumps(env, ensure_ascii=False)
@ -543,6 +574,8 @@ FOOD = {"빈해원", "일흥옥", "한일옥", "명월갈비", "국제반점", "
INDOOR = {"군산근대역사박물관", "군산근대건축관", "군산근대미술관", "채만식문학관", STAY} | FOOD INDOOR = {"군산근대역사박물관", "군산근대건축관", "군산근대미술관", "채만식문학관", STAY} | FOOD
NEEDS_CAR = {"고군산군도 선유도", "장자도", "채만식문학관", "경암동 철길마을", "월명호수", "은적사"} NEEDS_CAR = {"고군산군도 선유도", "장자도", "채만식문학관", "경암동 철길마을", "월명호수", "은적사"}
REST_SLOTS = {"쉼", "머뭄에서 쉬는 날"} REST_SLOTS = {"쉼", "머뭄에서 쉬는 날"}
# 마지막 날을 **복귀 없이** 끝내는 테마 — 차로 온 손님이다. 뼈대에서 곧바로 뽑는다(손으로 적지 않는다).
CAR_LAST_THEMES = {name for name, _dur, _aud, _why, days in THEMES if days[-1][0] in CAR_KINDS}
NIGHT_ONLY = {"군산 내항"} NIGHT_ONLY = {"군산 내항"}
BAR = {"도란"} BAR = {"도란"}
DAYS_OF = {"1박 2일": 2, "2박 3일": 3} DAYS_OF = {"1박 2일": 2, "2박 3일": 3}
@ -564,13 +597,24 @@ def audit(built: list[dict]) -> list[str]:
slots = [s["name"].split(" · ")[0] for s in day["stops"]] slots = [s["name"].split(" · ")[0] for s in day["stops"]]
places = [s["name"].split(" · ")[-1] 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] seen_all += [p for p in places if p != STAY]
last_day = day is item["days"][-1]
car_end = last_day and item["name"] in CAR_LAST_THEMES
# ① 하루의 처음과 끝은 머뭄 # ① 하루의 처음과 끝은 머뭄 — 단, 차로 온 손님의 마지막 날은 짐을 싣고 떠난 뒤라 복귀가 없다
if places[0] != STAY: if places[0] != STAY:
flag(item, day, f"1번이 머뭄이 아니다 ({places[0]})") flag(item, day, f"1번이 머뭄이 아니다 ({places[0]})")
if places[-1] != STAY: if places[-1] != STAY and not car_end:
flag(item, day, f"마지막이 머뭄이 아니다 ({places[-1]})") flag(item, day, f"마지막이 머뭄이 아니다 ({places[-1]})")
# ⑰ 떠나는 날의 끝맺음이 테마와 맞는가 (2026-09-14)
# 차 테마인데 복귀 칸이 남아 있으면 짐을 차에 싣고 숙소로 다시 오는 일정이 되고,
# 도보 테마인데 복귀 칸이 없으면 맡긴 짐을 못 찾고 하루가 끝난다. 둘 다 화면만 봐서는
# 멀쩡해 보인다 — 뼈대를 바꿀 때 한쪽만 고치고 넘어가는 자리라 검사에 둔다.
if car_end and (places[-1] == STAY or slots[-1] == "마무리"):
flag(item, day, f"차 테마인데 복귀 칸이 남았다 ({slots[-1]})")
if last_day and not car_end and slots[-1] != "마무리":
flag(item, day, f"도보 테마인데 짐을 찾는 마무리 칸이 없다 ({slots[-1]})")
# ② 같은 자리가 연달아 서지 않는다 # ② 같은 자리가 연달아 서지 않는다
for i in range(len(places) - 1): for i in range(len(places) - 1):
if places[i] == places[i + 1]: if places[i] == places[i + 1]:
@ -596,7 +640,7 @@ def audit(built: list[dict]) -> list[str]:
flag(item, day, f"{slots[i]} 도착 {arrive // 60}:{arrive % 60:02d} — 창 밖") flag(item, day, f"{slots[i]} 도착 {arrive // 60}:{arrive % 60:02d} — 창 밖")
# ⑥ 마지막 날 첫 칸(아침 겸 퇴실)은 11시 전에 끝난다 # ⑥ 마지막 날 첫 칸(아침 겸 퇴실)은 11시 전에 끝난다
if i == 0 and slots[0] == "아침" and len(item["days"]) == want and day is item["days"][-1]: if i == 0 and slots[0] == "아침" and last_day:
if arrive + stop["minutes"] > CHECK_OUT_BY: if arrive + stop["minutes"] > CHECK_OUT_BY:
end = arrive + stop["minutes"] end = arrive + stop["minutes"]
flag(item, day, f"퇴실이 {end // 60}:{end % 60:02d} — 11시를 넘긴다") flag(item, day, f"퇴실이 {end // 60}:{end % 60:02d} — 11시를 넘긴다")
@ -678,4 +722,4 @@ for item in items:
f"{clock // 60:02d}:{clock % 60:02d} {len(day['stops'])}칸") f"{clock // 60:02d}:{clock % 60:02d} {len(day['stops'])}칸")
DST.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") 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") print(f"\n테마 {len(items)}개 · 규칙 17종 위반 0건 · payload {DST.stat().st_size:,}B")

View File

@ -0,0 +1,301 @@
"""군산 읽기 — 이 도시를 갈래로 묶어 카로셀로 보여주는 소재 더미.
★ 왜 만들었나 (2026-09-14 대표 의견: "오늘의 한 장으로 군산을 소개하기는 무리다,
하루에 하나씩밖에 안 알려 주니까 — 엑셀을 보고 다시 짜라")
일력(`build_daily.py`)은 오늘·어제·내일 **세 장만** 화면에 선다. 나머지는 문서에만 남아
검색·AI 는 읽어도 손님은 못 본다. 도시를 소개하는 자리로는 그릇이 작다.
그래서 받은 자료(`gunsan_365_story_db.xlsx`)의 주제를 **갈래로 묶는다.**
일력은 그대로 두되, 이제 '매일 바뀌는 한 장' 이지 소개 자리가 아니다.
★ 화면 구성은 이 파일이 정하지 않는다 — **payload 는 갈래 전체를 그대로 담고**,
'한 화면에 몇 개를 어떻게 보여줄지' 는 `inject.js` `startReading()` 이 정한다.
처음엔 31개를 전부 세로로 펼쳤는데 "스크롤 압박" 이 됐다(같은 날 대표 의견) —
갈래마다 3~4개를 무작위로 뽑아 카로셀 하나로 붙이는 방식을 거쳐, 지금은
**갈래를 가리지 않고 전체에서 5~6개만 무작위로** 뽑는다(2026-09-14 대표: "34개중
15~19개인데 왜 34개라고 써놔?" → "그냥 5-6개 정도만 보여주는거 어때?" → "랜덤으로").
이 파일은 후보 전체를 만들 뿐이고, 추리고 섞고 세우는 일은 화면 쪽 책임이다.
★ **꼭지 수는 확인되는 사실만큼이다** — 채우려고 지어내지 않는다(레포 절대 규칙).
인물 57 · 가요 25 처럼 이 목록도 출처를 댈 수 있는 것까지가 한계다. 2026-09-14 기준 34가지.
★ 엑셀 52주제를 그대로 옮기지 않는다 — **이미 이 페이지에 선 것은 뺀다.**
· 인물 35주제 → `people` 57명이 이미 레일로 다 보인다
· 대중가요 21주제 → `songs` 25곡
· 축제 14주제 → `local.festivals` 10개
· 진포대첩·개항·세관·부잔교·『탁류』 연재·하굿둑·새만금 → `chronicle` 연표 7건
· 히로쓰 가옥·초원사진관·근대미술관·철길마을·우체통거리 등 → `local.attractions` 12곳
· 이성당·짬뽕집 → `local.restaurants` 31곳
같은 것을 두 번 세우면 페이지만 길어지고 손님은 같은 말을 두 번 읽는다.
**남은 것**이 여기 선다 — 문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활, 2026-09-14 기준 34가지.
★ 엑셀의 문장을 그대로 쓰지 않는다. 한 주제를 운영 슬롯 7가지로 복제해 둔 시드라
"…를 소개한다" 로 끝나는 **우리끼리 쓰는 메모**다. 여기 문장은 손님이 읽는 말로 다시 썼다.
★ 2026-09-14 대표: "내용이 너무 적다, 특히 2줄인 것들은 최소 3~5줄로." 두 문장짜리
21꼭지를 네 문장으로 늘렸다 — 새로 보탠 문장은 널리 알려진 배경 설명이고, 없는 숫자를
지어내지는 않는다.
★ 2026-09-14 대표: "확인필요 없애고 링크는 네이버 링크로." 기관·위키 링크를 항목마다
따로 골라 달던 방식을 접고, **네이버 검색 링크 하나**로 통일했다(`naver()`).
개별 문서 URL(`terms.naver.com/entry.naver?docId=...`)은 짐작해서 적으면 없는 문서로
이어질 위험이 있어(레포 절대 규칙: 없는 사실은 안 짓는다) 쓰지 않는다 — 검색 링크는
제목을 그대로 넘기니 항상 관련 결과로 뜬다. 이 결정과 함께 '확인/확인필요' 구분도
화면·데이터 양쪽에서 뺐다.
★ 렌더러에는 이 섹션이 없다. 목업이라 **주입분(`inject.js` `startReading()`)이 그린다** —
렌더러를 고치면 전 사이트 재굽기가 따라온다(README 2.1). 제품으로 옮길 자리는 README 1.3.
입력: stay-payload-new.json (build_story.py 뒤)
출력: 같은 파일에 `reading` 섹션을 넣는다. 화면은 `inject.js` 가 그린다.
"""
import json
from pathlib import Path
from urllib.parse import quote
SP = Path(__file__).parent
SRC = SP / "stay-payload-new.json"
def naver(title):
"""개별 항목마다 실제 문서를 골라 링크하지 않는다 — 검색 링크 하나로 통일해서
'없는 문서를 짐작해 적는' 위험을 아예 없앤다(위 파일 머리 설명)."""
query = title if "군산" in title else f"{title} 군산"
return {"name": "네이버에서 찾아보기",
"url": "https://search.naver.com/search.naver?query=" + quote(query)}
# 갈래 — 화면에 이 순서로 선다.
GROUPS = [
("문학", "이 도시를 쓴 사람과 그 자리"),
("섬과 바다", "다리를 건너면 나오는 것"),
("역사", "연표에 안 들어간 이야기"),
("장소", "걸어서 또는 차로 닿는 자리"),
("음식과 생활", "이 도시가 먹는 것"),
]
# (갈래, 제목, 글, 연도 또는 None)
# ★ 원문을 옮기지 않는다 — 시·소설·가사의 본문은 한 줄도 싣지 않고 제목과 배경만 쓴다.
ITEMS = [
# ── 문학 8 ────────────────────────────────────────────────────────────
("문학", "채만식 『탁류』",
"일제강점기 군산을 무대로 한 장편소설입니다. 쌀을 사고팔던 미두장을 중심으로, "
"가족을 지키려던 초봉이 시대의 소용돌이에 휩쓸려 무너지는 과정을 그렸습니다. "
"금강 하구의 흙탕물이라는 제목처럼, 개항장 군산의 실제 지명과 풍경 위에 이야기가 "
"펼쳐집니다.", 1937),
("문학", "백릉 채만식",
"1902년 옥구(지금의 군산) 임피에서 태어난 소설가입니다. 호는 백릉이고, 잡지에 "
"작품을 발표하며 문단에 나서 세태를 꼬집는 풍자 소설로 이름을 알렸습니다. "
"광복 뒤에는 고향 인근에 머물다 1950년 세상을 떠났습니다.", 1902),
("문학", "채만식문학관",
"금강 하구 옆, 작가가 나고 자란 임피 인근에 선 문학관입니다. 생애를 따라가는 "
"전시실과 『탁류』를 비롯한 작품의 여러 판본을 한자리에서 볼 수 있습니다. "
"군산이 낳은 다른 작가·시인을 소개하는 자리도 함께 있습니다.", None),
("문학", "『탁류』라는 제목",
"금강 하류의 흙탕물을 가리키는 말이자, 그 물살에 휩쓸려 가는 사람들을 함께 "
"가리키는 이중의 제목입니다. 맑았던 강물이 하구에 이르러 흐려지듯, 소설 속 "
"인물들도 도시의 욕망 앞에서 하나씩 무너져 갑니다. 신문에 회를 나눠 연재된 "
"소설이라, 그 시절 독자들은 다음 이야기를 기다리며 읽었습니다. 제목 하나에 "
"배경과 줄거리를 함께 담은 셈이라, 채만식문학관에서도 이 제목의 뜻부터 설명을 "
"시작합니다.", None),
("문학", "채만식의 풍자",
"웃기면서 찌르는 문장이 이 작가의 방식입니다. 『레디메이드 인생』·『태평천하』 같은 "
"다른 대표작에서도 이어지는 태도로, 식민지 도시의 부조리를 인물의 우스꽝스러운 "
"말과 행동으로 드러냅니다. 개항장 군산에서 본 것이 그 풍자의 바탕에 있습니다.",
None),
("문학", "탁류길",
"소설 속 공간을 실제 걷는 길로 이은 군산 스탬프투어 코스입니다. 미두장이 있던 "
"자리와 째보선창, 부잔교를 차례로 지나며 소설의 배경을 몸으로 확인합니다. "
"원도심 구간이라 머뭄에서 걸어 닿습니다.", None),
("문학", "조정래 『아리랑』과 아리랑길",
"구한말부터 광복까지, 만경강 들녘 농민들이 수탈에 맞서는 이야기를 그린 대하소설입니다. "
"군산·김제 일대가 무대라 스탬프투어에도 같은 이름의 코스가 있습니다. 여러 권에 "
"걸친 긴 이야기라, 한 도시가 아니라 호남평야 들녘 전체를 무대로 다룹니다. 아리랑길은 "
"실제 그 들녘과 마을 길을 따라 걷도록 짜여 있어, 소설이 그린 풍경을 걸음으로 "
"확인할 수 있습니다.", None),
("문학", "군산이 낳은 시인들",
"고은 · 이병훈 · 심호택 · 문효치 · 이연주 · 주하림. 일제강점기부터 지금까지 세대를 "
# ★ "아래 카드" 가 아니다 (2026-09-14 대표 지적). '인물 열전' 은 이 꼭지와 **형제 탭**이라
# 같은 줄 위쪽에 서 있다 — 아래로 내려가면 없다. 위치를 가리키는 말을 아예 뺀다:
# 탭 차림은 화면 폭과 탭 수에 따라 줄이 바뀌어서, 어느 방향으로도 단정하면 틀린다.
"이어 시인이 나온 도시입니다. 이름만으로는 알기 어려워, '인물 열전' 탭에서 "
"한 사람씩 생애와 활동을 봅니다.", None),
# ── 섬과 바다 6 ───────────────────────────────────────────────────────
("섬과 바다", "고군산군도",
"예순 남짓한 섬이 모인 군도입니다. 새만금 방조제가 육지와 신시도를 잇고, 그 뒤로 "
"무녀도 · 선유도 · 장자도가 다리로 차례로 이어져 차로 들어갈 수 있습니다. "
"섬마다 성격이 달라 하루로는 다 못 봅니다.", None),
("섬과 바다", "선유도",
"고군산군도의 대표 섬입니다. 명사십리라 불리는 백사장을 걷고, 망주봉 아래 해변에서 "
"스카이라인(짚라인)을 탑니다. 대장봉에 오르면 다도해로 지는 해를 한눈에 봅니다. "
"머뭄에서 차로 한 시간 남짓입니다.", None),
("섬과 바다", "장자도",
"선유도와 다리로 이어진 작은 섬입니다. 어선이 드나드는 포구를 끼고 있고, "
"마을 뒤 대장봉으로 오르는 등산로가 여기서 시작합니다. 산길이 가팔라도 길지 "
"않아 왕복 한두 시간이면 오르내릴 수 있습니다. 정상에 서면 고군산군도의 섬들이 "
"바다 위에 늘어선 모습이 한눈에 들어옵니다.", None),
("섬과 바다", "무녀도",
"고군산군도의 섬 하나입니다. 춤추는 무녀의 형상을 닮았다는 이야기가 이름의 유래로 "
"전해지고, 작은 해수욕장이 딸려 있습니다. 신시도에서 선유도로 넘어가는 다리 길목에 "
"있어, 고군산군도를 도는 길에 자연히 거쳐 가게 됩니다. 관광객이 몰리는 선유도보다 "
"한적해, 잠시 차를 세우고 바다를 보기에 좋습니다.", None),
("섬과 바다", "신시도",
"새만금 방조제가 육지와 처음 맞닿는 섬입니다. 고군산군도에서 몸집이 큰 섬 가운데 "
"하나로, 대각산에 오르면 방조제와 인근 섬들이 한눈에 들어옵니다. 방조제 도로를 타고 "
"들어오면 가장 먼저 만나는 섬이라, 고군산군도 여행이 시작되는 자리이기도 합니다. "
"마을과 해변이 함께 있어, 다른 섬으로 건너가기 전에 잠깐 들러 쉬어 가기도 합니다.",
None),
("섬과 바다", "비응항",
"새만금 방조제가 시작하는 항구입니다. 젓갈과 수산물을 파는 상가가 늘어서 있고, "
"서해 관광의 관문으로 씁니다. 어선이 실어 온 수산물이 그날그날 상가로 올라와, "
"싱싱한 해산물을 바로 사거나 맛볼 수 있습니다. 항구 너머로 방조제가 곧게 뻗어 "
"있어, 바다 쪽으로 나가기 전 들르기 좋은 자리입니다.", None),
# ── 역사 7 (연표에 없는 것만) ─────────────────────────────────────────
("역사", "미곡 수탈과 군산항",
"일제강점기 호남평야에서 거둔 쌀이 이 항구로 모여 일본으로 나갔습니다. 부잔교와 "
"그 옆의 대형 창고들이 그 통로였고, 쌀값을 매기던 미두장이 도시 경제의 중심에 "
"있었습니다. 『탁류』가 그 시절 풍경을 그대로 배경 삼았습니다.", None),
("역사", "째보선창",
"내항 옆 선창입니다. 언청이 뱃사공의 사연이 이름의 유래로 전해지고, 『탁류』의 "
"인물들이 오가던 자리이기도 합니다. 지금은 배 대신 낡은 창고가 남았습니다.",
None),
("역사", "해망굴",
"일제강점기에 뚫린 굴로, 시내와 항구를 잇습니다. 차 한 대가 겨우 지날 만큼 좁아 "
"지금은 사람이 걸어서 통과해 반대편 항구 쪽으로 나옵니다. 굴 안은 서늘하고 어둑해 "
"걸어 지나가는 동안 도시의 소리가 잠깐 끊깁니다. 반대편으로 나오면 바로 내항이 "
"보여, 원도심과 항구를 잇는 지름길로 지금도 쓰입니다.", None),
("역사", "경암동 철길",
"1944년에 놓인 철길입니다. 인근 제지공장의 화물을 나르던 산업철도였고, 지금은 "
"열차가 다니지 않지만 선로가 집 앞 골목을 그대로 지나가는 풍경만 남았습니다. "
"선로 양옆으로 집과 가게가 바짝 붙어 있어, 열차가 다니던 시절에는 문을 열면 바로 "
"철길이었습니다. 지금은 옛 교복을 빌려 입고 사진을 찍거나 선로를 따라 걷는 자리로 "
"쓰입니다.", 1944),
("역사", "군산항",
"1899년 개항 이후 근대 산업항으로 자란 항구입니다. 만남과 이별, 물류와 근대사가 "
"한자리에 겹치며 도시의 성격이 여기서 정해졌습니다. 개항 뒤로는 쌀과 물자가 이 "
"항구를 거쳐 오갔고, 그만큼 많은 사람이 들고 났습니다. 지금 원도심에 남은 근대 "
"건축물 대부분이 이 항구를 중심으로 들어선 것들입니다.", 1899),
("역사", "군산 3·5 만세운동",
"1919년 3월 5일, 영명학교 학생들이 중심이 되어 원도심에서 벌인 만세운동입니다. "
"전국 3·1운동이 퍼져 가던 무렵 이 도시가 어떻게 호응했는지를 보여주는 사건입니다. "
"학생들은 미리 태극기와 독립선언서를 준비해 장날에 맞춰 거리로 나섰습니다. 지금도 "
"원도심을 걷다 보면 이 사건을 알리는 표지판과 기록을 곳곳에서 만납니다.", 1919),
("역사", "임피역",
"옛 모습을 그대로 간직한 목조 간이역입니다. 근대 철도 건축의 흔적으로 등록문화재가 "
"됐고, 지금은 열차가 서지 않고 자리만 지킵니다. 작은 대합실과 승강장이 지어질 "
"당시 모습 그대로 남아 있어, 옛 기차역의 분위기를 눈으로 확인할 수 있습니다. "
"원도심에서 조금 떨어져 있어, 차로 이동하는 여정에 잠시 들르기 좋은 자리입니다.",
None),
# ── 장소 10 (주변 명소 12곳에 없는 것만) ──────────────────────────────
("장소", "군산근대역사박물관",
"해양물류의 역사와 근대 생활 풍경을 층별로 나눠 보여주는 박물관입니다. 3층부터 "
"내려오며 항구도시의 근대사를 훑고, 도시를 걷기 전에 여기서 한 번 배경을 "
"잡고 나섭니다. 전시실 안에는 당시 거리와 상점을 재현한 공간도 있어, 사진으로만 "
"보던 풍경을 실제 크기로 걸어 볼 수 있습니다. 원도심 답사의 시작점으로 삼기 좋은 "
"위치라, 관람을 마치고 바로 근처 근대문화유산으로 이어 걷습니다.", None),
("장소", "군산 내항과 부잔교",
"조수 간만의 차가 큰 서해에서 배를 대기 위해 만든 뜬다리(부잔교)입니다. "
"물이 오르내려도 다리 자체가 함께 움직여 배가 늘 수평으로 닿습니다. "
"다리 위를 끝까지 걸어 나갔다 돌아옵니다.", 1926),
("장소", "동국사",
"국내에 남은 몇 안 되는 일본식 사찰 건축입니다. 대웅전 지붕과 마당의 배치에서 "
"일본 사찰 양식이 그대로 드러나, 개항기 흔적을 건물로 보여주는 자리입니다. "
"지금도 실제로 예불을 올리는 절이라, 관람 중에는 조용히 둘러보는 것이 예의입니다. "
"마당의 소철과 종루까지 그 시절 배치가 크게 바뀌지 않아, 사진에 담기 좋은 자리로도 "
"알려져 있습니다.", None),
("장소", "월명공원",
"시내와 바다를 함께 내려다보는 공원입니다. 수시탑과 문인들의 시비가 산책로를 "
"따라 서 있고, 계절마다 오르는 길의 풍경이 달라집니다. 정상 부근까지 차로 오를 "
"수 있어, 가볍게 걷고 싶은 사람과 전망만 보고 싶은 사람 모두에게 맞습니다. 밤에는 "
"시내 불빛이 내려다보여, 낮과는 다른 풍경을 봅니다.", None),
("장소", "은파호수공원",
"호수를 두르는 산책길입니다. 겨울이면 조명을 켠 벚나무 길이 볼거리가 되고, "
"봄에는 같은 길이 벚꽃길로 바뀝니다. 저녁에 한 바퀴 돌기 좋습니다.",
None),
("장소", "금강하굿둑",
"금강과 서해가 만나는 자리에 놓은 둑입니다. 강과 바다의 경계를 사람 손으로 그은 "
"곳이고, 둑 위로 충남 서천과 전북 군산을 잇는 길이 나 있습니다. 둑을 따라 걷거나 "
"차로 건너면서 한쪽은 강물, 다른 한쪽은 바닷물이 나뉘는 모습을 눈으로 확인할 수 "
"있습니다. 철새가 많이 모이는 자리로도 알려져 있어, 계절에 따라 다른 새를 "
"봅니다.", 1990),
("장소", "말랭이마을",
"경사진 언덕을 따라 계단식으로 집이 들어선 마을입니다. 골목 담장마다 그림이 "
"그려져 있고, 도시재생 사업으로 되살아난 자리입니다. 계단과 골목이 좁고 가팔라, "
"걷는 속도를 늦추고 천천히 둘러보게 됩니다. 언덕 위에서는 원도심과 항구 쪽 풍경이 "
"함께 내려다보입니다.", None),
("장소", "장미갤러리",
"근대역사문화거리 안의 전시 공간입니다. 쌀을 쌓아 두던 옛 창고 건물을 고쳐 써서, "
"건물 자체가 그 시절 물류의 흔적입니다. 높은 천장과 두꺼운 벽 같은 창고의 구조를 "
"그대로 살려, 전시 공간이면서 건물 자체도 볼거리입니다. 인근의 다른 근대 건축물과 "
"나란히 있어, 걸어서 함께 둘러보기 좋습니다.", None),
("장소", "진포해양테마공원",
"진포대첩의 기억을 항구 옆에 펼쳐 둔 공원입니다. 퇴역한 군함과 항공기를 실제로 "
"가져다 놓아, 전시장이 아니라 야외에서 크기를 몸으로 느낄 수 있습니다. 고려 말 "
"왜구를 화포로 물리친 진포대첩의 무대가 바로 이 앞바다입니다. 아이와 함께라면 "
"직접 올라타 볼 수 있는 전시물도 있어, 역사 이야기를 몸으로 체험하게 됩니다.",
None),
("장소", "군산예술의전당",
"지역 공연을 올리는 문화 시설입니다. 원도심 답사에서 벗어나 공연 한 편을 함께 "
"일정에 넣고 싶을 때 확인해 볼 자리입니다. 크고 작은 공연장이 함께 있어, 음악회부터 "
"연극까지 다양한 공연이 열립니다. 원도심에서 차로 이동해야 하는 거리라, 일정에 "
"여유가 있을 때 넣기 좋습니다.", None),
# ── 음식과 생활 3 ─────────────────────────────────────────────────────
("음식과 생활", "군산 짬뽕",
"개항 이후 이 도시에 자리 잡은 화교 공동체가 남긴 음식 문화입니다. 빈해원 · "
"국제반점처럼 오래된 중식당이 아직 그 자리를 지키고, 짬뽕 한 그릇에 항구도시의 "
"내력이 담겨 있다고들 말합니다. 항구를 오가던 뱃사람과 노동자들이 빠르고 든든하게 "
"먹을 수 있는 음식으로 자리 잡았다고 전해집니다. 가게마다 국물 색과 맛이 조금씩 "
"달라, 여러 곳을 비교해 먹어 보는 재미도 있습니다.", None),
("음식과 생활", "박대와 군산 밥상",
"서해에서 나는 박대라는 생선을 말려 구워 냅니다. 살이 도톰하고 기름기가 적어 "
"짭조름하게 구우면 밥반찬으로 잘 맞고, 이 지역 백반집 상에 자주 오릅니다. 예전에는 "
"흔해 서민 밥상에 자주 오르던 생선이었다고 전해집니다. 말려서 굽는 조리법이라 "
"오래 두고 먹을 수 있어, 지금도 반찬이나 선물용으로 찾는 사람이 많습니다.", None),
("음식과 생활", "이성당",
"국내에서 손꼽히게 오래된 빵집 가운데 하나입니다. 단팥빵과 야채빵이 대표 메뉴로, "
"갓 나오는 시간에 맞추지 않으면 줄을 서야 할 만큼 찾는 사람이 많습니다. 일제강점기 "
"부터 이어진 자리에서 지금까지 영업을 이어 오고 있어, 군산을 대표하는 오래된 "
"가게로 꼽힙니다. 이른 아침부터 줄이 늘어서는 날이 많아, 여유 있게 들르려면 시간을 "
"넉넉히 잡는 편이 낫습니다.", None),
]
payload = json.loads(SRC.read_text(encoding="utf-8"))
SECTIONS = payload["theme"]["sections"]
seen = [name for name, _note in GROUPS]
for group, *_rest in ITEMS:
assert group in seen, f"갈래에 없는 이름: {group}"
items = []
for group, title, body, year in ITEMS:
item = {"group": group, "title": title, "body": body, "source": naver(title)}
if year:
item["year"] = year
items.append(item)
# 부제에 전체 개수를 내걸지 않는다 — 화면은 이 중 5~6개만 매번 무작위로 보여준다
# (2026-09-14 대표: "34개중 15~19개인데 왜 34개라고 써놔?"). 숫자 대신 "올 때마다
# 다른"으로 적어 실제 화면과 문구가 어긋나지 않게 한다.
env = {"kind": "reading", "version": 1, "title": "군산 읽기",
"subtitle": "문학 · 섬과 바다 · 역사 · 장소 · 음식과 생활 — 올 때마다 다른 몇 가지",
"groups": [{"name": name, "note": note} for name, note in GROUPS],
"items": items}
# 렌더러가 모르는 id 다 — payload 자체에는 이런 섹션이 없다. `#story` 탭 하나로 화면에
# 세우는 일은 inject.js `startReading()` 이 한다(2026-09-14부터, 독립 섹션이 아니라 탭이다).
# 여기 payload 로 두는 이유는 ① 고치는 자리가 스크립트 하나로 모이고 ② 문서(HTML)에 값이 남기 때문이다.
existing = next((s for s in SECTIONS if s["id"] == "reading"), None)
if existing is None:
existing = {"id": "reading", "name": "군산 읽기", "enabled": False, "locked": False}
SECTIONS.insert(next(i for i, s in enumerate(SECTIONS) if s["id"] == "story") + 1, existing)
existing["data"] = json.dumps(env, ensure_ascii=False)
SRC.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
counts = {name: sum(1 for i in items if i["group"] == name) for name, _ in GROUPS}
print("군산 읽기 " + str(len(items)) + "가지 — "
+ " · ".join(f"{k} {v}" for k, v in counts.items())
+ " (화면엔 매번 5~6개만 무작위)")

View File

@ -121,6 +121,9 @@ PEOPLE = [
("김수미", "김수미", "1949–2024", "배우", "「전원일기」 일용 엄니로 오래 기억되는 배우입니다."), ("김수미", "김수미", "1949–2024", "배우", "「전원일기」 일용 엄니로 오래 기억되는 배우입니다."),
("송새벽", "송새벽", "1979–", "배우", "군산 출신 배우입니다."), ("송새벽", "송새벽", "1979–", "배우", "군산 출신 배우입니다."),
("장신영", "장신영", "1984–", "배우", "군산 출신 배우입니다."), ("장신영", "장신영", "1984–", "배우", "군산 출신 배우입니다."),
# ★ 익산 출생이지만 군산에서 학교를 다녔다 — 그대로 적는다. 다른 줄처럼 "군산 출신" 으로
# 뭉뚱그리면 위키와 어긋나고, 이 목록의 규칙(문서에 적힌 관계만 담는다)도 깨진다.
("진희경", "진희경", "1968–", "배우", "익산에서 태어나 군산영광여고를 나온 배우입니다."),
("이서원", "이서원", "1997–", "배우", "군산 출신 배우입니다."), ("이서원", "이서원", "1997–", "배우", "군산 출신 배우입니다."),
("장재호 (배우)", "장재호", "1986–", "배우", "군산 출신 배우입니다."), ("장재호 (배우)", "장재호", "1986–", "배우", "군산 출신 배우입니다."),
("강민지 (배우)", "강민지", "1996–", "배우 · 모델", "모델로 시작해 드라마와 영화로 활동 범위를 넓혔습니다."), ("강민지 (배우)", "강민지", "1996–", "배우 · 모델", "모델로 시작해 드라마와 영화로 활동 범위를 넓혔습니다."),

View File

@ -89,17 +89,58 @@ for item in itin:
out.append(f"| {item['name']} | {item['duration']} | {item['audience']} | {item['why']} |") out.append(f"| {item['name']} | {item['duration']} | {item['audience']} | {item['why']} |")
out += [""] out += [""]
# ── 군산 읽기 ───────────────────────────────────────────────────────────────
reading = env("reading")
out += [f"## 6. 군산 읽기 {len(reading['items'])}가지 — `build_reading.py` `ITEMS`", "",
"한 화면에 다 펴 두는 카드. 일력(7번)과 달리 손님이 훑어 내려가며 읽는 자리다.", ""]
for group in reading["groups"]:
rows = [x for x in reading["items"] if x["group"] == group["name"]]
out += [f"### {group['name']} {len(rows)}가지 — {group['note']}", "",
"| 제목 | 글 | 연도 | 출처 |", "|---|---|---|---|"]
for x in rows:
out.append(f"| {x['title']} | {x['body']} | {x.get('year', '')} | "
f"{x['source']['name']} |")
out += [""]
# ── 오늘의 한 장 ────────────────────────────────────────────────────────────
daily = env("daily")["items"]
out += [f"## 7. 오늘의 한 장 {len(daily)}장 — `build_daily.py`", "",
"날마다 한 장씩 넘어가는 일력. 가요는 가요 다방을 그대로 읽고, 시·소설은 손으로 적는다.", "",
"| 날짜 | 분류 | 제목 | 글 | 확인 | 출처 |", "|---|---|---|---|---|---|"]
for x in daily:
out.append(f"| {x['monthDay']} | {x['category']} | {x['title']} | {x['body']} | "
f"{x['verified']} | {x['source']['name']} |")
out += [""]
# ── 오늘의 엽서 ────────────────────────────────────────────────────────────── # ── 오늘의 엽서 ──────────────────────────────────────────────────────────────
cards = env("postcard")["items"] cards = env("postcard")["items"]
out += [f"## 6. 오늘의 엽서 {len(cards)}장 — 원본 payload (아직 다시 안 뽑음)", ""] out += [f"## 8. 오늘의 엽서 {len(cards)}장 — 원본 payload (아직 다시 안 뽑음)", ""]
out += [f"- {c['line']} `{' '.join(c.get('hashtags', []))}`" for c in cards] + [""] out += [f"- {c['line']} `{' '.join(c.get('hashtags', []))}`" for c in cards] + [""]
# ── 머뭄이 만든 노래 ───────────────────────────────────────────────────────── # ── 머뭄이 만든 노래 ─────────────────────────────────────────────────────────
own = payload["narrative"]["ownSongs"]["items"] own = payload["narrative"]["ownSongs"]["items"]
out += [f"## 7. 머뭄이 만든 노래 {len(own)}곡 — `patch_stay.py` `OWN_SONGS`", ""] out += [f"## 9. 머뭄이 만든 노래 {len(own)}곡 — `patch_stay.py` `OWN_SONGS`", ""]
out += [f"- {s['title']} — `{s['audioUrl']}`" for s in own] + [""] out += [f"- {s['title']} — `{s['audioUrl']}`" for s in own] + [""]
# ── 오늘의 날씨 ──────────────────────────────────────────────────────────────
# 하늘 다섯 · 기온대 다섯. 화면에는 케이스마다 한 줄만 서고 20초마다 자루에서 다음 것이 나온다.
wx = payload["local"]["weather"]
sky, temp = wx.get("noteSets", {}), wx.get("tempNoteSets", {})
nsky, ntemp = sum(map(len, sky.values())), sum(map(len, temp.values()))
out += [f"## 10. 오늘의 날씨 문구 {nsky + ntemp}개 — `patch_stay.py` `SKY_NOTES`·`TEMP_NOTES`", "",
"하늘 줄은 **집 안**에서 뭘 할지, 기온 줄은 **밖에서** 어디를 갈지다. 두 줄이 나란히 선다.",
f"### 하늘 {nsky}개 (`SKY_NOTES`)", ""]
for case, rows in sky.items():
out += [f"**{case}**"] + [f"- {t}" for t in rows] + [""]
out += [f"### 기온대 {ntemp}개 (`TEMP_NOTES`)", ""]
for band, rows in temp.items():
out += [f"**{band}**"] + [f"- {t}" for t in rows] + [""]
path = SP / "TEXT.md" path = SP / "TEXT.md"
path.write_text("\n".join(out), encoding="utf-8") path.write_text("\n".join(out), encoding="utf-8")
print(f"{path.name} 캐치프레이즈 {len(box)} · 노래 {len(songs)} · 인물 {len(people)} · " print(f"{path.name} 캐치프레이즈 {len(box)} · 노래 {len(songs)} · 인물 {len(people)} · "
f"정거장 문구 {len(seen)} · 테마 {len(itin)} · 엽서 {len(cards)} · 자작곡 {len(own)}") f"정거장 문구 {len(seen)} · 테마 {len(itin)} · 군산 읽기 {len(reading['items'])} · "
f"일력 {len(daily)} · 엽서 {len(cards)} · "
f"자작곡 {len(own)} · "
f"날씨 {nsky + ntemp}")

View File

@ -0,0 +1,99 @@
"""주변 맛집을 TourAPI 에서 다시 받아 `restaurants.json` 으로 떨어뜨린다. patch_stay.py 가 이 파일을 싣는다.
★ 왜 (2026-09-11, 사장님: "주변맛집 데이터가 너무 적은거같아")
원본 사이트의 맛집은 8곳이었고 전부 467m 안이었다. 그래서 '걸어서 10분 이상' 탭을 누르면
맛집 칸이 "이 거리 안에는 없습니다" 로 비었다. 같은 좌표 TourAPI 5km 반경에는 59곳이 있다
(제품은 5km 를 싣는다 — local_content_service.RESTAURANT_RADIUS_M).
★ 2km(걸어서 25분)까지만 싣는다. 그 밖은 차로 갈 거리라 '주변' 이라 부르기 어렵고,
레일에 59장이 서면 가까운 집이 묻힌다.
★ 원래 8곳은 그대로 둔다 — 소개문과 사진이 원본 사이트 것이다. 새로 붙는 곳만 TourAPI 에서 받는다.
★ 사진은 공공누리 상업 이용 가능분만 온다(tour_api._normalize 가 거른다). 받아서 img/mirror/ 에
둔다 — 시연본은 레포 안에서 자급자족한다(README 2.2). 사진 없는 집은 렌더러가 이름 활자로 세운다.
실행 (solution/backend 에서 — 백엔드 설정이 .env 를 거기서 읽는다):
.venv/bin/python ../site/scripts/mockup/fetch_restaurants.py
"""
import asyncio
import hashlib
import json
import re
import sys
from pathlib import Path
import httpx
SP = Path(__file__).resolve().parent
sys.path.insert(0, str(SP.parents[2] / "backend"))
from services.external.tour_api import _call, fetch_nearby # noqa: E402
LAT, LNG = 35.9864175, 126.7061254 # payload.place — 스테이 머뭄
RADIUS_M = 2_000
def meters(text: str) -> float:
m = re.match(r"^([\d.]+)\s*(km|m)$", (text or "").strip(), re.I)
return float(m[1]) * (1000 if m[2].lower() == "km" else 1) if m else float("inf")
def distance_text(m: int) -> str:
"""site_payload 와 같은 표기 — 렌더러가 이 문자열을 다시 미터로 읽어 거리 탭을 가른다."""
return f"{m}m" if m < 1000 else f"{(m + 50) // 100 / 10:.1f}km"
def clean_name(title: str) -> str:
# "[백년가게]운정식당" · "[착한가게] 국일식당" — 인증 꼬리표는 이름이 아니다. 검색어에 붙으면 가게가 안 잡힌다.
return re.sub(r"^\[[^\]]*\]\s*", "", title).strip()
def blurb(overview: str) -> str:
"""overview 앞 두 문장까지. 카드는 세 줄에서 자른다(line-clamp-3) — 원래 8곳도 두 문장 안팎이다."""
text = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", overview or "")).strip()
sentences = re.split(r"(?<=[.!?])\s+", text)
out = sentences[0] if sentences else ""
if len(sentences) > 1 and len(out) + len(sentences[1]) < 110:
out += " " + sentences[1]
return out
async def mirror(client: httpx.AsyncClient, url: str) -> str:
ext = Path(url.split("?")[0]).suffix.lower() or ".jpg"
name = hashlib.sha1(url.encode()).hexdigest()[:16] + ext
path = SP / "img" / "mirror" / name
if not path.exists():
res = await client.get(url)
res.raise_for_status()
path.write_bytes(res.content)
# patch_stay.py 가 /assets/mirror/ → /s/stay/img/mirror/ 로 바꿔 박는다(원래 8곳과 같은 길).
return f"/assets/mirror/{name}"
async def main() -> None:
original = json.loads((SP / "stay-payload.json").read_text(encoding="utf-8"))["local"]["restaurants"]
have = {r["name"] for r in original}
out = list(original)
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
rows = await fetch_nearby(client, LAT, LNG, radius_m=RADIUS_M, content_type_id="39")
for row in rows:
name = clean_name(row["name"])
if name in have:
continue
items, _ = await _call(client, "detailCommon2", contentId=row["contentid"])
entry = {"name": name, "category": "맛집", "searchQuery": name,
"distanceText": distance_text(row["distance_m"])}
if row.get("imageUrl"):
entry["imageUrl"] = await mirror(client, row["imageUrl"])
desc = blurb(items[0].get("overview") if items else "")
if desc:
entry["description"] = desc
out.append(entry)
have.add(name)
# 거리순 — 레일 앞머리가 가장 가까운 집이어야 '주변' 이다.
out.sort(key=lambda r: meters(r.get("distanceText")))
(SP / "restaurants.json").write_text(json.dumps(out, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
far = sum(1 for r in out if meters(r.get("distanceText")) > 800)
photo = sum(1 for r in out if r.get("imageUrl"))
print(f"restaurants.json · {len(out)}곳 (원본 {len(original)} + TourAPI {len(out) - len(original)}) "
f"· 사진 {photo} · 800m 밖 {far}")
asyncio.run(main())

View File

@ -0,0 +1,47 @@
"""TourAPI 에 사진이 없는 주변 맛집에 네이버 이미지 검색으로 찾은 사진을 받아 둔다.
★ 왜 (2026-09-11, 사장님: "이미지가 없어?? 좀 가져와봐" · "다운로드 하던가")
fetch_restaurants.py 가 붙인 23곳 중 11곳은 TourAPI(위치 목록·추가 이미지·키워드 검색)와
위키미디어 어디에도 사진이 없었다. 레일에 이름 활자 카드가 11장 섞였다.
★ ⚠ 이 사진들은 공공누리가 아니다. 대한민국 구석구석·군산시청·블로그·네이버 플레이스에 올라온 것이고
권리는 각 게시자에게 있다. **시연본에만 쓴다 — 제품 수집 파이프라인으로 옮기지 않는다.**
제품의 규칙은 그대로 공공누리 Type1·Type3 뿐이다(tour_api._COMMERCIAL_OK_LICENSES).
★ 한 장씩 눈으로 보고 골랐다 — 간판이 보이거나 게시물 제목에 그 가게 이름이 있는 것만.
이름만 같은 다른 집(거목아리랑 · 국일복아구)은 버렸다. 출처 주소는 restaurant-photos.json 에 남긴다.
★ 긴 변 1600px 로 줄인다 — 원본이 10MB 인 것도 있다. 작은 사진은 키우지 않는다.
실행: python3 fill_restaurant_photos.py (macOS `sips` 로 줄인다)
굽기(patch_stay.py)는 여기서 받아 둔 파일만 읽는다 — 사진이 없는 집에만 덧대고, TourAPI 사진이 있으면 그쪽이 먼저다.
"""
import hashlib
import json
import re
import subprocess
import urllib.request
from pathlib import Path
SP = Path(__file__).resolve().parent
LIST = SP / "restaurant-photos.json"
MAX_SIDE = 1600
photos = json.loads(LIST.read_text(encoding="utf-8"))
for name, ph in photos.items():
file = hashlib.sha1(ph["url"].encode()).hexdigest()[:16] + ".jpg"
path = SP / "img" / "mirror" / file
if not path.exists():
src = path.with_suffix(".src")
req = urllib.request.Request(ph["url"], headers={"User-Agent": "Mozilla/5.0", "Referer": "https://search.naver.com/"})
src.write_bytes(urllib.request.urlopen(req, timeout=30).read())
dims = subprocess.run(["sips", "-g", "pixelWidth", "-g", "pixelHeight", str(src)],
capture_output=True, text=True, check=True).stdout
side = max(int(v) for v in re.findall(r"pixel(?:Width|Height): (\d+)", dims))
cmd = ["sips", "-s", "format", "jpeg", "-s", "formatOptions", "80"]
if side > MAX_SIDE:
cmd += ["-Z", str(MAX_SIDE)]
subprocess.run(cmd + [str(src), "--out", str(path)], capture_output=True, check=True)
src.unlink()
ph["file"] = file
print(f" {name:<8} {path.stat().st_size // 1024:>5}KB {file}")
LIST.write_text(json.dumps(photos, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print(f"restaurant-photos.json · {len(photos)}곳")

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 418 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

View File

@ -256,6 +256,9 @@
두 문장이 같은 자리에서 겹쳐 보인다. 두 문장이 같은 자리에서 겹쳐 보인다.
★ 자리 흔들림: 처음 구워진 줄 높이를 min-height 로 박아 둔다(스크립트가 잰다). ★ 자리 흔들림: 처음 구워진 줄 높이를 min-height 로 박아 둔다(스크립트가 잰다).
문장 길이가 달라 한 줄↔두 줄이 오갈 때 히어로 아래가 튀는 것을 막는다. */ 문장 길이가 달라 한 줄↔두 줄이 오갈 때 히어로 아래가 튀는 것을 막는다. */
/* 대표 문구 아래에서 도는 줄 (2026-09-11). 대표 문구보다 한 단 작고 옅게 둔다 —
둘이 같은 크기·같은 농도면 어느 쪽이 숙소의 한 줄인지 안 읽힌다. */
.w4d-sub { margin-top: 0.5rem; font-size: var(--fs-sm, 0.9375rem); line-height: 1.6; opacity: 0.75; }
.w4d-line { display: block; } .w4d-line { display: block; }
.w4d-line--out { .w4d-line--out {
opacity: 0; opacity: 0;
@ -327,3 +330,295 @@
animation: none !important; animation: none !important;
} }
/* #weather .w4-sky { display: none; } */ /* #weather .w4-sky { display: none; } */
/*
* ── 가요 다방 판꽂이 ──────────────────────────────────────────────────────
*
* ★ 되짚음 (2026-09-14 대표: "모바일 대응이 안 되어있는듯? 무슨 노래인지 모르잖아,
* 모바일에서는 hover가 없으니까"). 맞는 지적이고, **아래는 내가 만든 회귀였다.**
* 원래 렌더러(`SongsSection.tsx`)는 판 아래 곡명을 **항상** 보여준다 —
* `<span className="mt-2 block truncate text-[10px] ...">{song.title}</span>`,
* 숨기는 코드가 없다. 그런데 같은 날 앞선 지적("LP판이 많아져서 보기가 이상하잖아",
* 25곡이 `flex-wrap` 으로 3~9줄이 되던 문제)에 답하며 판을 겹쳐 꽂고 **곡명을
* hover 로만 띄우게** 고쳤다 — 데스크톱 벽은 없앴지만 손가락엔 hover 가 없으니
* 곡명이 아예 안 보이는 화면이 됐다. 데스크톱만 보고 고친 값이다.
* ★ 다시 짠다 — **곡명은 항상 보인다. 줄은 접지 않고 가로 한 줄로만 편다.**
* 판이 25곡이든 50곡이든 줄 수는 하나고, 안 보이는 나머지는 옆으로 스크롤한다.
* 이 페이지의 다른 레일(주변 명소·인물 열전)이 이미 쓰는 것과 같은 해법이라 —
* "hover 로 감추기" 는 애초에 필요 없던 처방이었다.
* ★ 마크업을 고치지 않는다(React 가 되돌린다). `:has()` 로 판이 든 상자만 집는다.
* 곡명 `<span>` 은 원래도 절대배치가 아니라 `block` 이라 손대지 않는다 — 화면에
* 보이려고 위치를 옮길 필요가 없다, 그냥 숨기지만 않으면 된다.
* ★ **화살표 + 실제 카로셀 라이브러리**(2026-09-14 대표: "옆으로 갈수있는 버튼도 주고
* slider.js 적용해줘"). `inject.js` `startSongsRail()` 이 이 판 목록을 감싸는 상자를
* 하나 만들어 `.slider-viewport` 로 표시하고(embla-carousel 코어를 그 위에 붙인다 —
* "군산 읽기" 탭과 같은 라이브러리, `vendor/embla-carousel.umd.js`), 그 안에 원래
* 판 목록(:has() 로 잡는 이 상자)을 그대로 넣는다. **가로 스크롤·snap 은 이제
* `.slider-viewport` 쪽 몫이다** — 그 클래스가 이미 그 CSS 를 갖고 있어(전역 번들)
* 여기서 다시 안 만든다. 이 규칙은 **안쪽 판 줄의 레이아웃(줄바꿈 금지·간격·판 크기)만** 잡는다.
*/
#songs div:has(> button > .w4-disc-mini) {
flex-wrap: nowrap;
justify-content: flex-start;
gap: 14px;
padding: 6px 4px 14px;
/* 렌더러가 이 div 자체에 준 mt-4 를 지운다 — 지금은 이 div 가 `.slider-viewport` 안에
들어가 있어, 그 여백이 화살표 줄(위)이 아니라 스크롤 상자 안쪽 빈 공간이 됐다.
간격은 화살표 줄의 mt-4(inject.js `startSongsRail`)가 대신 준다. */
margin-top: 0;
}
#songs button:has(> .w4-disc-mini) {
flex: 0 0 auto;
width: 68px; /* 원래 96px(`w-24`) — 한 줄로만 펴니 그만큼은 줄여도 된다 */
transition: transform 0.16s ease;
}
#songs button:has(> .w4-disc-mini) > .w4-disc-mini {
width: 56px;
height: 56px;
}
/* 곡명 — 렌더러가 원래 그리는 그 글자다. 여기서는 줄어든 판 폭에 맞춰 여백만 좁힌다. */
#songs button:has(> .w4-disc-mini) > .w4-disc-mini + span {
margin-top: 5px;
font-size: 10px;
}
/* 고른 판만 살짝 들어 올린다 — "뽑아 든 판" 표시. 선택 자체(테두리)는 React 가 인라인
스타일로 이미 그린다(`boxShadow`); 여기선 그 위에 얹는 동작만 더한다. */
#songs button:has(> .w4-disc-mini):hover { transform: translateY(-4px); }
#songs button[aria-pressed='true']:has(> .w4-disc-mini) { transform: translateY(-6px); }
@media (prefers-reduced-motion: reduce) {
#songs button:has(> .w4-disc-mini) { transition: none; }
}
/* `#songs .slider-viewport` 로 스코프한다 — `.slider-viewport` 를 전부 건드리면
"군산 읽기" 카로셀과 이 페이지 다른 실제 레일까지 같이 바뀐다. */
#songs .slider-viewport {
-webkit-mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent 100%);
mask-image: linear-gradient(90deg, #000 0, #000 calc(100% - 28px), transparent 100%);
}
/*
* ── 날씨 문구 전환 ────────────────────────────────────────────────────────
* 문장이 여러 벌이라 바뀌는 자리다(inject.js ⑦). 글자가 툭 갈리지 않게 한 번 접었다 편다.
*/
#weather [data-w4d-fade] { transition: opacity 0.3s ease; }
@media (prefers-reduced-motion: reduce) {
#weather [data-w4d-fade] { transition: none; }
}
/*
* ── 엽서 쓰기 (inject.js ⑥) ───────────────────────────────────────────────
* `#location` 과 `#festival` 사이의 **독립 섹션**이다(2026-09-14 대표 지시로 '오늘의
* 엽서' 탭에서 빠져나왔다). 다른 실제 섹션과 같은 껍데기(`.shell` · `--section-space`
* 세로 여백)를 쓰고, 제목·부제도 `ItemSection` 이 쓰는 것과 같은 전역 클래스(`.h2`
* `.measure`)라 이 섹션만 따로 CSS 를 두지 않는다 — 안쪽 위젯(캔버스·폼)만 여기서 잡는다.
*/
#w4d-pm-section {
width: 100%;
padding-block: var(--section-space, 64px);
background: var(--tpl-surface, #fafafa);
color: var(--tpl-text, #1b1a15);
}
.w4d-pm-layout {
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: flex-start;
}
.w4d-pm-canvas {
/* ★ 300px 이던 것을 키웠다(2026-09-14 대표: "엽서 작아서 깨지는데?"). 실제로 그리는
건 1080×1080 이라 화질 문제는 아니지만, 미리보기가 작으면 안의 문구·소인이 안 읽혀
"제대로 안 나온 것" 처럼 보인다 — 미리보기 크기를 실물 크기에 맞춘다. */
width: 420px;
max-width: 100%;
aspect-ratio: 1 / 1;
border: 1px solid var(--tpl-border, #bcb49e);
background: var(--tpl-card, #efe7d3);
}
.w4d-pm-form { flex: 1 1 320px; min-width: 260px; }
/* 썸네일 줄 — `.slider-viewport`(뷰포트) 안에 `.slider-track`(트랙, 이 클래스 자체가
display:flex 를 준다)이 있다. 여기서는 간격·안쪽 여백만 얹는다(2026-09-14 대표:
"이미지는 다 가져와서 그냥 카로셀로 보여주든가 하셈" — 9장 전체를 후보로 두면서
한 줄에 다 못 앉으니 카로셀이 됐다). */
.w4d-pm-thumbs-viewport { margin-bottom: 12px; }
.w4d-pm-thumbs { gap: 8px; padding: 2px; }
.w4d-pm-thumb {
width: 52px;
height: 52px;
flex: 0 0 auto;
padding: 0;
overflow: hidden;
border: 2px solid transparent;
border-radius: 4px;
cursor: pointer;
}
.w4d-pm-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.w4d-pm-thumb[aria-pressed='true'] { border-color: var(--tpl-accent, #bf2f1b); }
.w4d-pm-text {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
font: inherit;
font-family: var(--tpl-font-body, serif);
font-size: 14px;
line-height: 1.5;
color: var(--tpl-text, #1b1a15);
background: var(--tpl-surface, #fafafa);
border: 1px solid var(--tpl-border, #bcb49e);
resize: none; /* 2026-09-14 대표: "textarea 크기 못바꾸게" — 늘리면 옆 캔버스와 높이가 어긋난다 */
}
.w4d-pm-count { margin: 4px 0 14px; font-size: 11px; text-align: right; opacity: 0.55; }
.w4d-pm-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.w4d-pm-actions button {
padding: 9px 16px;
font-size: 13px;
font-weight: 700;
border: 1px solid var(--tpl-primary, #1b1a15);
cursor: pointer;
}
.w4d-pm-share { background: var(--tpl-primary, #1b1a15); color: var(--tpl-bg, #fff); }
.w4d-pm-save { background: transparent; color: var(--tpl-text, #1b1a15); }
.w4d-pm-hint { margin: 10px 0 0; font-size: 11.5px; opacity: 0.6; }
@media (max-width: 480px) {
.w4d-pm-canvas { width: 100%; }
}
/*
* ── 군산 읽기 (inject.js ③) ───────────────────────────────────────────────
* 렌더러에 없는 섹션이라 카드 내용 모양은 여기서 만든다. 색·서체는 전부 템플릿 변수에서
* 가져온다 — 값을 박으면 템플릿을 바꿀 때 이 섹션만 남의 집처럼 보인다(이 파일의 규약).
*
* ★ **카로셀 껍데기는 새로 만들지 않았다** — `.slider-viewport` · `.slider-track` 은
* 이 사이트가 이미 쓰는 클래스다(`site/src/lib/ui/Carousel.tsx`, 컴파일된 전역 CSS에
* 이미 있다). 여기서 정의하는 CSS 는 **카드 안 내용물**뿐이다. 점(dots)·간격도 그 컴포넌트가
* 쓰는 Tailwind 유틸리티(`h-1.5 rounded-full bg-current opacity-70` 등)를 그대로 쓴다.
* ★ **포스트 하나씩** (2026-09-14 대표). 슬라이드 하나 = 꼭지 하나, 전체 폭을 다 쓴다 —
* 3~4개를 욱여넣었더니 카드가 좁아져 본문이 한 줄로 접혔었다.
*/
/* '군산 이야기' 탭 하나로 접혀 들어왔다(2026-09-14) — 독립 섹션의 배경·제호는 없다.
나머지 다섯 탭 패널과 같은 폭(`.shell`)에, 위아래 여백만 얹는다. */
#w4d-reading-panel {
padding: 26px 0 4px;
color: var(--tpl-text, #1b1a15);
font-family: var(--tpl-font-body, serif);
}
/* .w4d-read-kicker 삭제 — 2026-09-14 대표: "군산읽기만 타이틀이 없서 통일 시켜",
커스텀 캡션 대신 다른 네 탭과 같은 h2.h2 + p.measure 로 바꿨다(inject.js startReading()). */
/* ── 슬라이드 폭 ── `CarouselSlide` 의 관례와 같다: 모바일은 다음 장이 살짝 걸치게(86%),
데스크톱은 한 장이 크게 온다(560px 안팎). "다음 장이 걸쳐 보이는 것" 이 옆에 더 있다는
유일한 시각 신호라는 그 컴포넌트의 원칙을 그대로 따른다. */
.w4d-read-basis { width: 86%; }
@media (min-width: 640px) { .w4d-read-basis { width: min(600px, 56%); } }
/* ── 포스트 카드 — 슬라이드 하나 = 꼭지 하나. */
.w4d-read-card {
height: 100%;
padding: 18px 20px 16px;
background: var(--tpl-card, #efe7d3);
border: 1px solid var(--tpl-border, #bcb49e);
border-radius: var(--tpl-radius, 0);
}
.w4d-read-page {
margin: 0 0 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
color: var(--tpl-accent, #bf2f1b);
}
.w4d-read-year { font-weight: 400; letter-spacing: 0.06em; opacity: 0.7; }
.w4d-read-card h3 {
margin: 0 0 10px;
padding-bottom: 10px;
border-bottom: 2px solid var(--tpl-primary, #1b1a15);
font-family: var(--tpl-font-heading, var(--tpl-font-body, serif));
font-size: 21px;
font-weight: 700;
line-height: 1.3;
}
.w4d-read-body {
margin: 0;
font-size: 14.5px;
line-height: 1.8;
text-align: justify;
word-break: keep-all;
opacity: 0.88;
}
.w4d-read-src {
margin-top: 14px;
padding-top: 10px;
border-top: 1px dashed var(--tpl-border, #bcb49e);
font-size: 11.5px;
font-style: italic;
opacity: 0.6;
}
.w4d-read-src a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
/* .w4d-read-flag(확인필요 배지) 삭제 — 2026-09-14 대표: "확인필요 없애고 링크는
네이버 링크로", inject.js startReading() 도 같이 뺐다. */
@media (max-width: 640px) {
.w4d-read-card h3 { font-size: 18px; }
}
/* ══ 사진 갤러리 쪽 넘김 (데스크톱만) ═══════════════════════════════════════
* ★ 왜 (2026-09-14 사장님: "사진갤러리 전체 렌더링 말고 페이징 좀, 너무 많아 데스크톱에서는")
* 갤러리는 좁은 화면에서 한 장씩 미는 캐러셀이지만 넓은 화면에서는 4열 격자로 **전부**
* 깔린다(`GallerySection.tsx` — `lg:grid lg:grid-cols-4`). 사진 37장이면 열 줄이 되어
* 페이지가 사진으로 끊긴다.
* ★ 좁은 화면은 그대로 둔다 — 거기서는 한 장이 폭을 다 쓰는 캐러셀이라 이미 한 번에 하나다.
* 그래서 숨김 규칙 전부가 `min-width: 1024px`(Tailwind `lg`) 안에 있다.
* ★ 마크업을 지우지 않고 **속성으로만** 가린다. React 가 payload 로 다시 그리면 잘라낸
* <li> 는 돌아오지만, 그때는 주입 JS 가 속성을 다시 찍는다(inject.js ⑧).
* 그리고 사진이 DOM 에 남아 있어야 alt 문장이 문서에 남는다 — 이미지 검색이 그걸 읽는다
* (같은 이유가 `GallerySection.tsx` 머리주석에도 있다).
*/
.w4d-gal-pager { display: none; }
@media (min-width: 1024px) {
#gallery ul[data-w4d-gal] > li[data-w4d-gal-on='0'] { display: none; }
.w4d-gal-pager {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 18px;
}
.w4d-gal-pager button {
min-width: 30px;
height: 30px;
padding: 0 8px;
border: 1px solid var(--tpl-border, #bcb49e);
border-radius: 8px;
background: transparent;
color: inherit;
font-size: 12.5px;
font-variant-numeric: tabular-nums;
line-height: 1;
cursor: pointer;
transition: background-color 0.15s ease, opacity 0.15s ease;
}
.w4d-gal-pager button:hover:not(:disabled) { background: color-mix(in srgb, currentColor 8%, transparent); }
.w4d-gal-pager button:disabled { opacity: 0.3; cursor: default; }
.w4d-gal-pager button[aria-current='true'] {
background: var(--tpl-primary, #1b1a15);
border-color: var(--tpl-primary, #1b1a15);
color: var(--tpl-bg, #fff);
font-weight: 700;
}
.w4d-gal-total { margin-left: 8px; font-size: 11.5px; opacity: 0.55; }
}
/* ── 768~1023px 에서 하단 탭바가 푸터 마지막 줄을 덮는 것 막기 ─────────────────
★ 실측(2026-09-14 대표 스크린샷, 태블릿 폭 Edge): 푸터(SiteFooter.tsx)는 768px
부터 아래 여백을 줄이는데(pb-28 → md:pb-14), 하단 고정 탭바(MobileTabBar.tsx)는
1024px 이 되기 전엔 그대로 떠 있다(lg:hidden). 그래서 768~1023px 사이에서만
푸터 마지막 줄이 탭바에 깔린다 — 두 컴포넌트가 서로 다른 기준(md·lg)으로
"이제 모바일 아니다"를 판단해 생기는 폭이다.
★ 진짜 수정은 렌더러 쪽(`solution/site/src/sections/SiteFooter.tsx`)에서
`md:pb-14` 를 탭바와 같은 `lg:pb-14` 로 맞추는 것인데, 그건 발행 사이트 전부에
영향을 주는 변경이라 다시 굽고(`docker compose restart solution-prerender`)
전부 재발행(`republish_all.py`)해야 한다 — 이 목업만 먼저 막는다. */
@media (min-width: 768px) and (max-width: 1023.98px) {
footer { padding-bottom: 7rem !important; }
}

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,15 @@ from pathlib import Path
SP = Path(__file__).parent SP = Path(__file__).parent
# ── 최종 업데이트 시각 ────────────────────────────────────────────────────────
# 화면 아래 "최종 업데이트", head 의 dateModified, JSON-LD, payload 의 site.updatedAt ·
# local.syncedAt 이 전부 이 한 값을 쓴다. 구운 원본에는 2026-09-09 가 박혀 있어, 문구·맛집을
# 고친 뒤에도 화면에는 옛 날짜가 남았다 (2026-09-11 사장님: "이거 업뎃해야지").
# ★ now() 를 쓰지 않는다 — 내용을 안 고치고 다시 구워도 날짜가 올라가 버린다. 구글은 lastmod 를
# 페이지의 실제 수정과 대조해 맞을 때만 쓰고 어긋나면 그 필드를 무시한다(seo/directory.ts).
# 내용을 고친 날에 이 값을 손으로 올린다.
UPDATED_AT = "2026-09-14T07:42:00+00:00"
# ── 캐치프레이즈 100개 ──────────────────────────────────────────────────────── # ── 캐치프레이즈 100개 ────────────────────────────────────────────────────────
# 사실이 아니라 문구다(출처가 붙는 값이 아니다). 근거는 payload 의 시설·위치 값 — # 사실이 아니라 문구다(출처가 붙는 값이 아니다). 근거는 payload 의 시설·위치 값 —
# 적산가옥 두 동 · 히로쓰 가옥 옆 · 마당과 정원 · 창고형 카페 · 매일 세탁하는 침구 · 최대 4인. # 적산가옥 두 동 · 히로쓰 가옥 옆 · 마당과 정원 · 창고형 카페 · 매일 세탁하는 침구 · 최대 4인.
@ -92,6 +101,92 @@ WEATHER = {
"눈": ["기와에 눈이 앉으면 골목이 통째로 조용해집니다", "눈 밟는 소리가 담 안에서 크게 들립니다"], "눈": ["기와에 눈이 앉으면 골목이 통째로 조용해집니다", "눈 밟는 소리가 담 안에서 크게 들립니다"],
} }
# ── 오늘의 날씨 문구 ──────────────────────────────────────────────────────────
# ★ 케이스마다 한 줄뿐이었다 (2026-09-14 사장님: "케이스당 여러개로 만들어서 랜덤으로")
# 같은 하늘·같은 기온이면 늘 같은 문장이 떴다. 시연에서 새로고침해도 안 바뀐다.
# ★ 축을 둘로 갈라 둔 것은 그대로다(site-payload.ts WeatherSnapshot 주석) —
# 하늘은 **집 안에서** 뭘 할지, 기온은 **밖에서** 어디를 갈지다. 한 칸에 담으면
# 5×5 스물다섯 벌을 적어야 한다.
# ★ 이름을 대는 곳은 이 사이트에 **이미 있는 자리**만 쓴다(주변 안내 12곳 · 맛집 31곳).
# 화면에 없는 곳을 날씨 칸에서만 권하면 손님이 그걸 찾을 데가 없다.
# ★ 하늘은 다섯이다. 렌더러는 넷(맑음·흐림·비·눈)으로만 가르므로 구름많음과 그 외
# (안개·소나기·뇌우)가 `notes.흐림` 한 칸을 같이 본다 — 그 둘을 가르는 것은 inject.js ⑥ 다.
SKY_NOTES = {
"맑음": [
"마당 평상에 앉으면 새소리만 들립니다. 정원 쪽으로 그늘이 길게 눕는 시간입니다.",
"기와가 마르는 냄새가 나는 날입니다. 아침에 널어 두신 것은 오후면 다 마릅니다.",
"볕이 좋아 대문을 열어 둡니다. 마당 사진은 해가 기울기 시작할 때가 가장 곱습니다.",
"창을 다 열어도 좋은 날입니다. A동 장지문으로 드는 빛이 오전에 가장 깊습니다.",
"해가 길게 남는 날입니다. 짐을 풀고 골목부터 한 바퀴 돌고 오셔도 늦지 않습니다.",
],
"구름많음": [
"빛이 부드러운 날입니다. 골목 사진이 제일 잘 나오는 빛입니다.",
"그늘이 옅어 마당이 눈부시지 않습니다. 평상에 오래 앉아 계시기 좋습니다.",
"해가 들었다 숨었다 합니다. 창고형 CAFÉ 공간과 마당을 오가며 쉬시면 됩니다.",
"구름이 얇은 날입니다. 우체통거리까지 걷기에 덥지도 춥지도 않습니다.",
"빛이 고른 날입니다. 초원사진관까지 걸어서 삼 분, 사진은 오늘 같은 하늘이 낫습니다.",
],
"그외": [
"해가 숨은 날입니다. 기와와 담이 한 색으로 가라앉고, 마당 평상에 앉아도 눈이 부시지 않습니다.",
"하늘이 자주 바뀌는 날입니다. 나가실 때 우산을 챙기시는 편이 낫습니다.",
"안개가 낮게 깔리면 골목 끝이 흐려집니다. 서두르지 마시고 늦게 나가셔도 됩니다.",
"소나기가 지나갈 수 있는 하늘입니다. 창고형 CAFÉ 공간에서 한 차례 보내고 나가셔도 됩니다.",
"바람이 먼저 바뀌는 날입니다. 마당에 널어 두신 것은 들여놓으시는 편이 좋습니다.",
],
"비": [
"백 년 된 기와를 타고 내리는 소리가 다릅니다. 창고형 CAFÉ 공간에서 빗소리를 들으실 수 있습니다.",
"비 오는 골목에는 사람이 없습니다. 히로쓰 가옥 담길은 오늘 같은 날이 가장 조용합니다.",
"마당 디딤돌이 미끄럽습니다. 밤에 나가실 때는 정원 석등 쪽으로 도세요.",
"실내로 도는 편이 낫습니다. 근대건축관과 군산근대미술관이 걸어서 십 분 안에 나란히 있습니다.",
"우산은 대문 안쪽에 두시면 됩니다. 젖은 것은 테라스 쪽이 잘 마릅니다.",
],
"눈": [
"기와에 눈이 앉으면 골목이 통째로 조용해집니다. 정원 석등만 켜 두겠습니다.",
"눈 밟는 소리가 담 안에서 크게 들립니다. 마당은 아침에 한 번 쓸어 두겠습니다.",
"눈 오는 날의 원도심은 사람이 적습니다. 초원사진관 골목이 오늘 가장 한산합니다.",
"디딤돌에 눈이 얼 수 있습니다. 밤에 드나드실 때는 조심해서 딛으세요.",
"창을 닫아 두시는 편이 낫습니다. 창고형 CAFÉ 공간이 이런 날 가장 아늑합니다.",
],
}
# 기온대 경계는 렌더러가 정한다(`site/src/lib/derive.ts weatherBand`) — 30·25·20·10.
TEMP_NOTES = {
"혹서": [
"낮에는 군산근대미술관·근대건축관처럼 실내로 도는 편이 낫습니다. 월명호수 한 바퀴는 해가 넘어간 뒤가 시원합니다.",
"한낮 골목에는 그늘이 없습니다. 초원사진관·영화의 거리는 이른 아침이나 해 질 무렵에 도세요.",
"옛 군산세관과 근대건축관은 걸어서 이어집니다. 실내를 징검다리 삼아 도시면 덜 지칩니다.",
"경암동 철길마을은 그늘이 적습니다. 오늘은 저녁에 가시는 편이 낫습니다.",
"물은 나가시기 전에 챙기세요. 이성당까지는 걸어서 오백 미터, 해가 기운 뒤가 낫습니다.",
],
"더움": [
"선유도까지 다녀오기 좋은 기온입니다. 골목은 오후 늦게 도세요 — 경암동 철길마을은 해 질 때가 사람이 적습니다.",
"낮에는 월명호수 그늘길이 시원합니다. 물가를 따라 걷다 돌아오시면 됩니다.",
"우체통거리와 영화의 거리는 그늘이 이어집니다. 걸어서 도시기에 무리가 없습니다.",
"해가 길어 저녁이 넉넉합니다. 내항 쪽으로 나가 노을을 보고 오셔도 됩니다.",
"가장 더운 시간만 실내가 낫습니다. 군산근대미술관에서 한 차례 쉬어 가세요.",
],
"선선": [
"걷기에 가장 좋은 날입니다. 히로쓰 가옥에서 초원사진관·영화의 거리까지 걸어서 그대로 이어집니다.",
"원도심은 오늘 하루면 다 걸립니다. 군산 시간여행마을까지 천천히 다녀오셔도 됩니다.",
"차를 두고 걷는 편이 낫습니다. 우체통거리에서 옛 군산세관까지 골목이 계속 이어집니다.",
"해가 부담스럽지 않습니다. 경암동 철길마을까지 다녀오기에 알맞은 기온입니다.",
"낮에는 월명호수를 한 바퀴 돌고, 밤에는 마당에 앉아 계셔도 좋습니다.",
],
"쌀쌀": [
"월명호수를 한 바퀴 돌고 이성당이나 국제반점에서 따뜻한 것을 드시면 알맞습니다.",
"겉옷을 하나 더 챙기세요. 해가 지면 항구 쪽 바람이 먼저 찹니다.",
"실내와 골목을 섞어 도는 편이 낫습니다. 근대건축관에서 몸을 녹이고 다시 나오시면 됩니다.",
"따뜻한 국물이 도는 날입니다. 복성루·빈해원처럼 가까운 집부터 보세요.",
"오후 볕이 짧습니다. 초원사진관·영화의 거리를 먼저 돌고 저녁에 들어오세요.",
],
"추움": [
"밖은 바람이 매섭습니다. 근대건축관·근대미술관과 시간여행마을 실내를 묶어 도는 편이 낫습니다.",
"항구 바람이 매운 날입니다. 골목은 짧게 돌고 실내를 길게 잡으세요.",
"장갑과 목도리를 챙기세요. 옛 군산세관 앞은 바람이 그대로 지나갑니다.",
"따뜻한 것을 먼저 드시는 편이 낫습니다. 이성당·국제반점은 걸어서 오백 미터입니다.",
"일찍 들어오셔도 됩니다. 벽난로가 있는 거실이 이런 날 가장 좋습니다.",
],
}
items = [{"text": t, "kind": "general"} for t in GENERAL] items = [{"text": t, "kind": "general"} for t in GENERAL]
items += [{"text": t, "kind": "season", "season": s} for s, lst in SEASON.items() for t in lst] items += [{"text": t, "kind": "season", "season": s} for s, lst in SEASON.items() for t in lst]
items += [{"text": t, "kind": "month", "month": m} for m, lst in MONTH.items() for t in lst] items += [{"text": t, "kind": "month", "month": m} for m, lst in MONTH.items() for t in lst]
@ -99,6 +194,71 @@ items += [{"text": t, "kind": "weather", "weather": w} for w, lst in WEATHER.ite
payload = json.loads((SP / "stay-payload-new.json").read_text(encoding="utf-8")) payload = json.loads((SP / "stay-payload-new.json").read_text(encoding="utf-8"))
payload["narrative"]["catchphrases"] = {"version": 1, "items": items} payload["narrative"]["catchphrases"] = {"version": 1, "items": items}
# ── '오늘의 엽서' 탭을 없앤다 ─────────────────────────────────────────────────
# ★ 왜 (2026-09-14 대표: "오늘의 엽서 칸을 빼고 이 엽서 포맷으로 스테이 머뭄 관련
# 엽서를 쓰는 걸로 하자") — 도시 엽서 4장은 이제 안 쓴다. 대신 손님이 직접 쓰는
# 엽서 섹션을 새로 만들고(inject.js `postcardMaker`), 그건 '군산 이야기' 탭이
# 아니라 `#location`–`#festival` 사이의 **독립 섹션**이다.
# ★ 마크업을 안 건드리고 payload 만 비운다 — `StorySection.tsx` 는
# `sectionItems(payload,'postcard').items.length > 0` 인 탭만 세우므로, data 를
# 비우면 렌더러 코드는 그대로 두고도 탭이 저절로 사라진다(README 2.6 의 원칙과 같다:
# "마크업을 고쳐 지우지 않는다").
for _sec in payload["theme"]["sections"]:
if _sec["id"] == "postcard":
_sec["data"] = json.dumps({"kind": "postcard", "version": 1, "items": []}, ensure_ascii=False)
break
# 굽는 판에는 케이스마다 **첫 문장**을 박는다 — 크롤러와 첫 화면이 보는 값이고,
# inject.js 가 DOM 에서 갈아 끼울 때 찾는 닻이기도 하다. 문장이 겹치면 엉뚱한 줄을 바꾼다.
_all_notes = [t for lst in SKY_NOTES.values() for t in lst] + \
[t for lst in TEMP_NOTES.values() for t in lst]
assert len(_all_notes) == len(set(_all_notes)), "날씨 문구가 겹친다 — DOM 에서 어느 줄인지 못 가른다"
payload["local"]["weather"].update({
# 렌더러가 아는 넷. 구름많음은 '흐림' 칸을 쓴다(그 외와 가르는 것은 inject.js ⑦).
"notes": {"맑음": SKY_NOTES["맑음"][0], "흐림": SKY_NOTES["구름많음"][0],
"비": SKY_NOTES["비"][0], "눈": SKY_NOTES["눈"][0]},
"tempNotes": {band: lines[0] for band, lines in TEMP_NOTES.items()},
# 렌더러가 모르는 칸이다(계약에 없어 그대로 지나간다). 주입분만 읽는다.
"noteSets": SKY_NOTES,
"tempNoteSets": TEMP_NOTES,
})
# ── 주변 맛집 8곳 → 2km 안 31곳 ──────────────────────────────────────────────
# ★ 원본 8곳이 전부 467m 안이라 '걸어서 10분 이상' 탭에서 맛집 칸이 비었다 (2026-09-11 사장님:
# "주변맛집 데이터가 너무 적은거같아"). 목록은 fetch_restaurants.py 가 TourAPI 에서 받아 둔 것이다 —
# 여기서 API 를 부르지 않는다. 굽기가 네트워크·키에 매이면 다른 기계에서 못 굽는다.
payload["local"]["restaurants"] = json.loads((SP / "restaurants.json").read_text(encoding="utf-8"))
# 사진 없는 집에 시연본 전용 사진을 덧댄다(fill_restaurant_photos.py — 공공누리 아님, 제품으로 옮기지 않는다).
# TourAPI 사진이 있는 집은 건드리지 않는다.
_photos = json.loads((SP / "restaurant-photos.json").read_text(encoding="utf-8"))
for _r in payload["local"]["restaurants"]:
if not _r.get("imageUrl") and _photos.get(_r["name"], {}).get("file"):
_r["imageUrl"] = f"/assets/mirror/{_photos[_r['name']]['file']}"
# ── 군산 국가유산 야행 ───────────────────────────────────────────────────────
# ★ 왜 (2026-09-14 대표: 받은 `gunsan_365_story_db.xlsx` 52주제 대조 — 사이트에 없던 둘 중 하나)
# 나머지 축제는 TourAPI 가 준 것이라 이 목록에 없었다. 원도심 국가유산을 밤에 여는
# 행사라 머뭄(신흥동)에서 걸어 닿는 범위이고, 여름 저녁 일정과 맞는다.
# ★ **이름은 '문화재야행' 이 아니다.** 엑셀에는 옛 이름으로 적혀 있는데, 2024년 문화재청이
# 국가유산청으로 바뀌면서 행사명도 '국가유산 야행' 으로 갈렸다(군산시 문화예술과 공지 확인).
# 옛 이름으로 두면 검색 링크가 엉뚱한 해의 문서로 간다.
# ★ 날짜(period·startDate)는 **적지 않는다.** 확인한 공지는 2025년분이고 2026년 일정은
# 아직 공고 전이다 — 지어내지 않는다(레포 절대 규칙). 축제 10개 중 5개가 이미 날짜 없이
# 서 있어 화면도 그 경우를 감당한다.
payload["local"]["festivals"].append({
"name": "군산 국가유산 야행",
"month": "8월",
"season": "여름",
"location": "구 조선식량영단 등 원도심 국가유산 일원 · 걸어서 20분",
"description": "원도심의 국가유산을 밤에 여는 행사다. 야경·야로·야사 등 여덟 갈래로 나뉘어 "
"신흥동 일본식 가옥의 정원 조명, 스탬프 투어, 옛 건물에서 여는 공연과 야시장이 "
"저녁부터 밤까지 이어진다.",
"searchQuery": "군산 국가유산 야행",
})
payload["site"]["updatedAt"] = UPDATED_AT
# 주변 정보(맛집·명소)를 갈아 끼운 시각도 같다 — 이 스크립트가 그 둘을 같이 바꾼다.
payload["local"]["syncedAt"] = UPDATED_AT
(SP / "stay-payload-new.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") (SP / "stay-payload-new.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
# ── 머뭄이 만든 노래 5곡 ────────────────────────────────────────────────────── # ── 머뭄이 만든 노래 5곡 ──────────────────────────────────────────────────────
@ -234,6 +394,35 @@ for unit in payload["units"]:
# 옛 사진은 남기지 않는다 — 등록본이 그 객실에 붙여 놓은 사진만 그 순서로 세운다. # 옛 사진은 남기지 않는다 — 등록본이 그 객실에 붙여 놓은 사진만 그 순서로 세운다.
unit["mediaIds"] = per_unit.get(room, []) unit["mediaIds"] = per_unit.get(room, [])
# ── 화면에서 뺄 탭 ───────────────────────────────────────────────────────────
# ★ `enabled = False` 로는 안 꺼진다 (2026-09-14 사장님: "오늘의 한장은 주석처리해주쇼")
# 이 다섯은 '군산 이야기' 안의 탭이고, 탭은 **항목이 있는 것만** 선다(StorySection.tsx:41).
# enabled 는 아예 보지 않는다 — 실측: daily 가 enabled=False 인데도 탭이 서 있었다.
# ★ 그래서 항목을 비운다. 원값은 stay-payload-new.json 에 그대로 있으므로(위에서 이미 썼다)
# 되살리려면 아래 집합에서 id 만 빼면 된다. 다시 만들 필요 없다.
HIDE_TABS = {"daily"} # 오늘의 한 장
for _section in payload["theme"]["sections"]:
if _section["id"] not in HIDE_TABS:
continue
_env = json.loads(_section["data"])
_env["items"] = []
_section["data"] = json.dumps(_env, ensure_ascii=False)
_section["enabled"] = False
# ── 예약 안내를 켠다 ─────────────────────────────────────────────────────────
# ★ 왜 (2026-09-14 사장님: "예약 섹션을 안 가져다가 렌더링하네, 그거 구현되었으니 가져오셈")
# 원본 payload 의 booking 은 enabled=False 였다. 숙박이면 이 자리는 요금·인원·창구에
# 날짜/시간 목업까지 붙은 `StayBookingSection` 이 그린다(HomePage.tsx:69) — 회의록의
# "예약 페이지는 연동이 어려워 우선 목업 형태로" 가 이미 구현돼 있던 것이다.
# ★ 기본값(항목이 없으면 알아서 나가는 HomePage.tsx:122)에 기대지 못한다 — 항목이
# **있는데 꺼진** 상태라 `hasSection()` 이 참이고, 그 분기는 "사장님이 끈 것" 으로 존중한다.
# ★ 지어낸 값이 없다. 요금·인원·취소 규정은 payload 의 확인된 fact 뿐이고
# (`derive.ts stayBookingView`), 근거가 하나도 없으면 섹션째 안 그린다.
for _section in payload["theme"]["sections"]:
if _section["id"] == "booking":
_section["enabled"] = True
# ── index.html 조립 ────────────────────────────────────────────────────────── # ── index.html 조립 ──────────────────────────────────────────────────────────
html = (SP / "orig" / "index.html").read_text(encoding="utf-8") html = (SP / "orig" / "index.html").read_text(encoding="utf-8")
@ -244,6 +433,14 @@ html, n = re.subn(r"<script>window\.__SITE_PAYLOAD__=.*?</script>",
html, count=1, flags=re.S) html, count=1, flags=re.S)
assert n == 1, "payload 스크립트를 못 찾았다" assert n == 1, "payload 스크립트를 못 찾았다"
# ★ 날짜는 payload 밖에도 박혀 있다 — head 의 dateModified · article:modified_time ·
# JSON-LD · 화면의 <time>. payload 만 고치면 **크롤러와 첫 화면은 옛 날짜를 본다**
# (React 가 다시 그리기 전까지, 그리고 JS 를 안 돌리는 크롤러에게는 영영).
OLD_UPDATED_AT = "2026-09-09T13:40:00+00:00"
html = html.replace(OLD_UPDATED_AT, UPDATED_AT)
html = html.replace(f">{OLD_UPDATED_AT[:10]}</time>", f">{UPDATED_AT[:10]}</time>")
assert OLD_UPDATED_AT not in html and OLD_UPDATED_AT[:10] not in html, "옛 날짜가 남았다"
# ★ 이용 정보 **맨 아래 구분선**을 지운다 (2026-09-10 사장님 지시). # ★ 이용 정보 **맨 아래 구분선**을 지운다 (2026-09-10 사장님 지시).
# 항목이 끝난 자리에 선이 한 줄 더 그어져 있어, 빈 칸이 하나 더 있는 것처럼 보인다. # 항목이 끝난 자리에 선이 한 줄 더 그어져 있어, 빈 칸이 하나 더 있는 것처럼 보인다.
# 항목(바비큐 이용) 자체는 그대로 둔다 — 지우라고 한 것은 선이다. # 항목(바비큐 이용) 자체는 그대로 둔다 — 지우라고 한 것은 선이다.
@ -252,9 +449,23 @@ assert n == 1, "payload 스크립트를 못 찾았다"
css = (SP / "inject.css").read_text(encoding="utf-8") css = (SP / "inject.css").read_text(encoding="utf-8")
js = (SP / "inject.js").read_text(encoding="utf-8") js = (SP / "inject.js").read_text(encoding="utf-8")
# 빌드 도장 — 콘솔에서 "지금 보는 게 언제 구운 것인가"를 바로 읽게 한다(위 ★ 주석, inject.js).
assert "__W4D_BUILD__" in js, "inject.js 에서 빌드 도장 자리가 사라졌다"
js = js.replace("__W4D_BUILD__", UPDATED_AT)
assert "</style>" not in css and "</script>" not in js assert "</style>" not in css and "</script>" not in js
block = ("\n <style id=\"w4d-css\">\n" + css + "\n </style>\n" # ★ 군산 읽기 카로셀은 이 사이트가 실제 쓰는 그 라이브러리(embla-carousel)로 움직인다
# (2026-09-14 대표: "기존 카로셀 쓰라고, 니가 뭔데 니맘대로 그지같이 구현해" — 맞는 말이다).
# `inject.js` 는 React 번들 밖의 순정 스크립트라 앱이 물고 있는 embla 인스턴스를 가져다
# 쓸 수 없다 — 그래서 **코어(라이브러리 자체, React 래퍼 없이)의 UMD 빌드**를 이 목업
# 전용으로 하나 더 들여왔다. 의존성 0 · 17.9KB(비압축, 사이트 자체 주석의 "~5KB gzip"과
# 같은 값) — `node_modules/embla-carousel/embla-carousel.umd.js` 를 그대로 복사한 것이고
# 손으로 고치지 않는다(라이선스 MIT, 버전은 site/package.json 의 embla-carousel-react 와 맞춘다).
assert (SP / "vendor" / "embla-carousel.umd.js").exists(), \
"vendor/embla-carousel.umd.js 가 없다 — node_modules/embla-carousel/embla-carousel.umd.js 를 복사한다"
block = ("\n <script src=\"/s/stay/vendor/embla-carousel.umd.js\"></script>\n"
" <style id=\"w4d-css\">\n" + css + "\n </style>\n"
" <script id=\"w4d-js\">\n" + js + "\n </script>\n ") " <script id=\"w4d-js\">\n" + js + "\n </script>\n ")
# 치환문(block)에 JS 의 정규식(\s 등)이 섞여 있어 그대로 넘기면 파이썬이 이스케이프로 읽는다 — 람다로 넘긴다. # 치환문(block)에 JS 의 정규식(\s 등)이 섞여 있어 그대로 넘기면 파이썬이 이스케이프로 읽는다 — 람다로 넘긴다.
html, n = re.subn(r"\n?\s*</body>", lambda _m: block + "</body>", html, count=1) html, n = re.subn(r"\n?\s*</body>", lambda _m: block + "</body>", html, count=1)
@ -270,6 +481,28 @@ assert n == 1, "</body> 를 못 찾았다"
# 그래서 둘 다 `img/mirror/` · `vendor/` 로 복사해 두고, 경로를 `/s/stay/…` 로 바꿔 박는다. # 그래서 둘 다 `img/mirror/` · `vendor/` 로 복사해 두고, 경로를 `/s/stay/…` 로 바꿔 박는다.
# 이러면 nginx 가 `/s/` 를 파일로 주기만 하면 되고 번들 해시와 무관해진다. # 이러면 nginx 가 `/s/` 를 파일로 주기만 하면 되고 번들 해시와 무관해진다.
# (대신 렌더러를 고쳐도 이 목업은 안 따라간다 — 목업은 원래 재굽기 대상이 아니라 그게 맞다.) # (대신 렌더러를 고쳐도 이 목업은 안 따라간다 — 목업은 원래 재굽기 대상이 아니라 그게 맞다.)
# ★ 번들은 **레포에서 지금 구운 것**으로 갈아 끼운다 — `orig/index.html` 에 박힌 해시는
# 2026-09-09 판이라 그 뒤에 들어온 화면이 없다(실측: 숙박 예약 섹션 `StayBookingSection`
# 이 그 번들에 아예 없어서, payload 로 켜도 화면에 아무것도 안 그려졌다).
# 목업은 자기 번들을 `vendor/` 에 들고 다니므로(아래 주석) 해시를 맞출 상대가 없다 —
# `vendor/` 에 있는 한 벌이 정답이고, 원본의 옛 이름을 그 이름으로 바꾼다.
# 갱신 절차: `solution/site` 에서 `npm run build:client` → `dist/client/assets/index-*.{js,css}`
# 를 `vendor/` 로 복사하고 옛 파일을 지운다. (여기는 이름만 맞추므로 손댈 것이 없다.)
# ★ `<body class="site">` — 새 번들에서는 이게 없으면 35줄이 조용히 죽는다.
# 2026-09-09 의 `b1386dd`(에디터와 발행본을 한 렌더러로) 이후 발행본 공용 스타일이
# `:where(.site,.site-canvas) …` 로 스코프됐다. `orig/index.html` 은 그 전에 구운 것이라
# body 에 클래스가 없다 — 실측: 카로셀 상자(`.slider-viewport`)의 `overflow-x` 가 통째로
# 빠져 모바일에서 판 25장이 화면 밖으로 흘러 **가로 스크롤이 생겼다.**
# 프리렌더가 지금 내보내는 것과 같은 마크업으로 맞춘다(`scripts/prerender.ts:384`).
html, n = re.subn(r"<body(?![^>]*\bclass=)", '<body class="site"', html, count=1) if True else (html,1)
assert n == 1 or 'class="site"' in html, "body 에 site 클래스를 못 붙였다"
_vendor = {ext: sorted((SP / "vendor").glob(f"index-*.{ext}")) for ext in ("js", "css")}
for _ext, _found in _vendor.items():
assert len(_found) == 1, f"vendor/ 의 {_ext} 번들이 {len(_found)}개다 — 한 벌만 둔다: {_found}"
html = re.sub(r"/assets/index-[\w.-]+\.js", "/assets/" + _vendor["js"][0].name, html)
html = re.sub(r"/assets/index-[\w.-]+\.css", "/assets/" + _vendor["css"][0].name, html)
missing = [] missing = []
for name in sorted(set(re.findall(r"/assets/mirror/[0-9a-f]+\.\w+", html))): for name in sorted(set(re.findall(r"/assets/mirror/[0-9a-f]+\.\w+", html))):
if not (SP / "img" / "mirror" / name.rsplit("/", 1)[1]).exists(): if not (SP / "img" / "mirror" / name.rsplit("/", 1)[1]).exists():
@ -289,6 +522,7 @@ out.mkdir(exist_ok=True)
(out / "index.html").write_text(html, encoding="utf-8") (out / "index.html").write_text(html, encoding="utf-8")
env = json.loads({s["id"]: s for s in payload["theme"]["sections"]}["itinerary"]["data"]) env = json.loads({s["id"]: s for s in payload["theme"]["sections"]}["itinerary"]["data"])
print(f"index.html {len(html):,}자 · 일정 {len(env['items'])}개 · 캐치프레이즈 {len(items)}개") print(f"index.html {len(html):,}자 · 일정 {len(env['items'])}개 · 캐치프레이즈 {len(items)}개 "
f"· 맛집 {len(payload['local']['restaurants'])}곳")
print(" " + " · ".join(f"{k} {sum(1 for i in items if i['kind'] == k)}" print(" " + " · ".join(f"{k} {sum(1 for i in items if i['kind'] == k)}"
for k in ("general", "season", "month", "weather"))) for k in ("general", "season", "month", "weather")))

View File

@ -0,0 +1,57 @@
{
"군산복집": {
"url": "https://cdn.visitkorea.or.kr/img/call?cmd=VIEW&id=07337813-0fb6-4e60-a6c7-e130feff5a63",
"title": "(인쇄용) 군산복집 | 대한민국 구석구석",
"file": "067f6fe42c045a3a.jpg"
},
"진갈비": {
"url": "http://www.gunsan.go.kr/upload_data/board_data/BBS_0000054/140229349431468.jpg",
"title": "음식,숙박,쇼핑 > 음식점 > 군산맛집",
"file": "92cb907d07db9953.jpg"
},
"한주옥": {
"url": "https://d12zq4w4guyljn.cloudfront.net/20220816093444324_photo_3w9NMDVGF1oN.webp",
"title": "한주옥 - 군산 간장게장, 꽃게장정식 맛집 - 다이닝코드",
"file": "f9cb7e88415edd06.jpg"
},
"사골뚝배기": {
"url": "https://www.gunsan.go.kr/upload_data/board_data/FOOD/150838840634244.jpg",
"title": "게시글 목록 < 탕류 < 음식 < 음식/숙박/쇼핑 < 문화관광",
"file": "24043ae4f5854f96.jpg"
},
"스위트인디아": {
"url": "https://cdn.visitkorea.or.kr/img/call?cmd=VIEW&id=bd1e6e61-70f2-462d-a8f7-f7fc31d98443",
"title": "스위트인디아> 여행지 :대한민국 구석구석 스위트인디아> 여행지 :대한민국 구석구석 ",
"file": "bf6bb6183c6f6246.jpg"
},
"홍영장": {
"url": "https://pup-post-phinf.pstatic.net/MjAyNjA1MjNfMTYg/MDAxNzc5NTE2OTMxNDkz.SRqGW5cr3uqRcOaulY6pgfkM_b8UPyxL1QwZ_WN-bfUg.tDWdfAwPxIUmrComXVMgo-hSslTYtbmI3x3iQyG0XKAg.JPEG/PostEncodingTask.30343AE4-7E4E-47B8-A9C0-978AD991D374.jpg",
"title": "물짜장 맛집입니더!!! #가정의달 #오늘클립챌린지 #군산 #홍영장 #맛집",
"file": "087900c82da3b2f5.jpg"
},
"명동소바": {
"url": "https://cdn.visitkorea.or.kr/img/call?cmd=VIEW&id=26a5546a-6966-4391-b0eb-46703f24adb9",
"title": "명동소바> 여행지 :대한민국 구석구석 명동소바> 여행지 :대한민국 구석구석 ",
"file": "55f57f45fcebb460.jpg"
},
"유락식당": {
"url": "https://cdn.visitkorea.or.kr/img/call?cmd=VIEW&id=911b5e60-01e0-4eab-8579-4cd447b94dca",
"title": "(인쇄용) [백년가게]유락식당 | 대한민국 구석구석",
"file": "5f92192c7b3e2cbf.jpg"
},
"일풍식당": {
"url": "https://ldb-phinf.pstatic.net/20200103_164/1578048423036vc5I1_JPEG/lIOOzZx3sZZK9JUJgMsD3dHg.jpg",
"title": "일풍식당",
"file": "e13c8a2a1aa2762c.jpg"
},
"아리랑": {
"url": "https://pup-post-phinf.pstatic.net/MjAyNjA2MDhfNCAg/MDAxNzgwODg2MDI5NTY1.6vNfC_2hbbyXSjtxSEDSTzkn0f3j8rrEjU15_2DL2Kkg.dbYhyKh4ozdjjWXUUS-oYnCJp7-oFfh_2bU0oc1OBAog.JPEG/POST_IMAGE_ENC_20260608_113317_278.jpg",
"title": "#오늘클립챌린지#아리랑식당#비빔밥정식#군산맛집",
"file": "1302460cb149975f.jpg"
},
"국일식당": {
"url": "https://ldb-phinf.pstatic.net/20240406_119/1712415338890jfJDL_JPEG/IMG_3315.jpeg",
"title": "국일복아구",
"file": "6102b164464dbfa4.jpg"
}
}

View File

@ -0,0 +1,239 @@
[
{
"name": "도란",
"category": "맛집",
"searchQuery": "도란",
"distanceText": "135m",
"imageUrl": "/assets/mirror/12938240987c1f78.jpg",
"description": "전라북도 군산시 월명동에 있는 요리주점이다. 와인과 함께 카페와 양식을 메뉴를 주로 다루고 있다. 대표 메뉴로는 감바스, 해산물 크림 스튜, 부채살 스테이크 등이 있다."
},
{
"name": "물밀소",
"category": "맛집",
"searchQuery": "물밀소",
"distanceText": "297m",
"imageUrl": "/assets/mirror/07600bb67b91dd15.jpg",
"description": "월명동에 자리한 베이커리 물밀소는 신생 빵집으로 입소문이 자자하다. 붉은 벽돌의 아담한 외관을 가졌고 매장 내부에는 저온숙성한 반죽으로 빚어낸 건강한 빵들이 진열돼 있다."
},
{
"name": "일흥옥",
"category": "맛집",
"searchQuery": "일흥옥",
"distanceText": "300m",
"imageUrl": "/assets/mirror/3420ff7e357ab7ef.jpg",
"description": "일흥옥은 군산 테디베어뮤지엄 인근에 위치한 국밥 전문점이다. 중소벤처기업부로부터 백년가게 인증을 받은 곳이다."
},
{
"name": "군산복집",
"category": "맛집",
"searchQuery": "군산복집",
"distanceText": "305m",
"description": "군산복집은 싱싱한 재료만을 사용하는 복요리 전문점으로 복탕과 아구탕은 해장하기에 좋으며, 오래된 전통과 경력의 주방장이 내는 회 맛이 일품인 곳이다."
},
{
"name": "명월갈비",
"category": "맛집",
"searchQuery": "명월갈비",
"distanceText": "351m",
"imageUrl": "/assets/mirror/881d1f7ff820942f.jpg",
"description": "군산 신창동에 있는 명월갈비는 군산 지역의 대표 양념 소갈비 전문 맛집이다. 플러스 등급의 한우 갈비를 비법 양념장으로 숙성한 단품 메뉴로 오랜 기간 동안 인기를 유지해오고 있다."
},
{
"name": "만남스넥",
"category": "맛집",
"searchQuery": "만남스넥",
"distanceText": "405m",
"imageUrl": "/assets/mirror/d2be5a053a944cfe.jpg",
"description": "만남스넥은 군산 현지인들이 좋아하는 메뉴인 잡탕을 파는 분식집이다. 잡탕은 떡, 만두, 라면, 어묵 등을 넣고 같이 끓인 메뉴인데, 떡볶이나 라볶이보다 국물이 많고 부재료가 풍부해 잡탕으로 불리게…"
},
{
"name": "동국사다온",
"category": "맛집",
"searchQuery": "동국사다온",
"distanceText": "418m",
"imageUrl": "/assets/mirror/4db345bd2cb4f0d7.jpg",
"description": "전라북도 군산시 일본식 사찰인 동국사 사찰 내에 있는 테이크아웃 커피 전문점이다. 불교 용품과 함께 대추차, 오미자차, 오룡차 등 다양한 차 종류를 판매하고 있다."
},
{
"name": "진갈비",
"category": "맛집",
"searchQuery": "진갈비",
"distanceText": "426m",
"description": "소박해보이지만 오랜 세월의 내공으로 묵직하고 깊은 떡갈비를 선사하는 맛집이다. 군산 현지인들이 추천하는 유명 맛집이기도 하다."
},
{
"name": "한주옥",
"category": "맛집",
"searchQuery": "한주옥",
"distanceText": "430m",
"description": "‘한주옥’은 전북 군산시 영화동에 위치한 꽃게장 전문 음식점이다. 꽃게장 백반/정식, 대하장 백반/정식을 맛볼 수 있다."
},
{
"name": "이성당",
"category": "맛집",
"searchQuery": "이성당",
"distanceText": "455m",
"imageUrl": "/assets/mirror/37c90494d28478e2.png",
"description": "일본 시마네현 이즈모시에 살다가 1906년 조선으로 건너온 히로세 야스타로라는 일본인이 ‘이즈모야’라는 이름으로 문을 열어 영업한 것이 시초이다."
},
{
"name": "국제반점",
"category": "맛집",
"searchQuery": "국제반점",
"distanceText": "467m",
"imageUrl": "/assets/mirror/149c18951bdcaff8.jpg",
"description": "군산 영화동에 있는 국제반점은 1960년대 초 진흥반점으로 개업해 현재는 상호를 국제 반점으로 변경하여 성업 중인 중식당이다. 영화 <타짜>에 등장해 군산의 관광 명소가 되었다."
},
{
"name": "사골뚝배기",
"category": "맛집",
"searchQuery": "사골뚝배기",
"distanceText": "479m",
"description": "사골 뚝배기는 전북 군산시에 위치한 한식 전문점이다. 한우고기와 한우사골만을 써서 아주 진하고 맛깔스러운 국물을 우려내어 요리를 만든다."
},
{
"name": "영화원",
"category": "맛집",
"searchQuery": "영화원",
"distanceText": "495m",
"imageUrl": "/assets/mirror/42e8d872198edaa9.jpg",
"description": "군산 영화동에 있는 영화원은 1976년부터 지금까지 4대째 운영하는 오래된 중국 음식점이다. 대를 이어오면서 그 맛의 비결을 간직하고 있다."
},
{
"name": "스위트인디아",
"category": "맛집",
"searchQuery": "스위트인디아",
"distanceText": "539m",
"description": "전라북도 군산시에 위치한 스위트인디아는 인도 카레 맛집으로, 양고기, 소고기, 닭고기, 새우, 야채 총 5가지의 맛의 카레를 판매하고 있다."
},
{
"name": "틈(TEUM)",
"category": "맛집",
"searchQuery": "틈(TEUM)",
"distanceText": "573m",
"imageUrl": "/assets/mirror/81bd76fa51ae9ad1.jpg",
"description": "군산항 근처에 있는 옛 미곡 창고를 그대로 활용한 카페다. ‘틈’이란 이름처럼 입구가 좁은 골목길 틈에 위치해 있어 간판을 눈여겨보지 않으면 찾기 어렵다."
},
{
"name": "미즈커피",
"category": "맛집",
"searchQuery": "미즈커피",
"distanceText": "696m",
"imageUrl": "/assets/mirror/3e5b3daae33ea51c.jpg",
"description": "군산 근대문화 역사관 바로 옆에 자리한 카페로 일제강점기 무역회사였던 ‘미즈 상사’의 옛 사옥을 그대로 활용했다. 당시 일본인이 운영했던 미즈 상사는 식료품과 잡화 등을 수입해 판매하던 회사였다."
},
{
"name": "홍영장",
"category": "맛집",
"searchQuery": "홍영장",
"distanceText": "725m",
"description": "홍영장은 군산의 짬뽕거리에 위치한 중식당으로 방송에서 인정받은 맛집이다. 평소에 흔히 맛볼 수 없는 메뉴인 물짜장이 유명하다."
},
{
"name": "아리랑",
"category": "맛집",
"searchQuery": "아리랑",
"distanceText": "732m",
"description": "군산에 위치한 향토음식점으로 군산 지역의 특산물을 맛볼 수 있는 음식점이다."
},
{
"name": "운정식당",
"category": "맛집",
"searchQuery": "운정식당",
"distanceText": "748m",
"imageUrl": "/assets/mirror/00a8ac4ce017fb12.jpg",
"description": "군산 개복동 골목의 터줏대감으로 40년 가까이 한 자리를 지키고 있는 식당이다."
},
{
"name": "빈해원",
"category": "맛집",
"searchQuery": "빈해원",
"distanceText": "749m",
"imageUrl": "/assets/mirror/483f52c2d2b7eebe.jpg",
"description": "군산 빈해원은 1965년 콘크리트와 벽돌을 사용하여 지은 2층 건물이다. 이 건물은 1~2층이 개방된 내부공간과 각층에 여러 개의 방이 있는 독특한 구조이다."
},
{
"name": "국일식당",
"category": "맛집",
"searchQuery": "국일식당",
"distanceText": "935m",
"description": "전라북도 군산시에 위치한 국일식당은 콩나물의 아삭함과 매콤한 양념이 어우러진 아귀찜 전문점이다. 군산의 전통명가 답게 모범 착한업소로 선정된 곳이다."
},
{
"name": "명동소바",
"category": "맛집",
"searchQuery": "명동소바",
"distanceText": "961m",
"description": "‘군산명동소바’는 30년 이상된 군산 최초 원조 소바집이다. 메밀면발도 직접 연구 개발하고 매장 자체적으로 기계로 면을 빼서 쫄깃하고 탱탱한 맛으로 현지인뿐만 아니라 여행객의 많은 발길이 이어진다."
},
{
"name": "복성루",
"category": "맛집",
"searchQuery": "복성루",
"distanceText": "1.3km",
"imageUrl": "/assets/mirror/b3518ee8ef2b4aac.png",
"description": "복성루는 빈해원 지린성과 함께 군산 3대 짬뽕 맛집으로 유명하며 싱싱한 오징어, 홍합, 꼬막 등 다양한 해산물과 야채를 넣고 우려낸 진한 짬뽕 국물이 일품인 가게로 줄 서서 맛보는 지역의 대표 짬뽕집으로 자리 잡고 있다."
},
{
"name": "유락식당",
"category": "맛집",
"searchQuery": "유락식당",
"distanceText": "1.3km",
"description": "김정례 대표가 1981년 문을 열어 40여 년 동안 지역을 대표하는 음식점으로 손님에게 최선의 맛과 서비스를 제공하고자 하는 곳이다."
},
{
"name": "왕산반점",
"category": "맛집",
"searchQuery": "왕산반점",
"distanceText": "1.5km",
"imageUrl": "/assets/mirror/aebe437fd281f9ad.jpg",
"description": "특허받은 콩나물 짬뽕으로 유명한 중화요리 집이다. 짬뽕면 위에 푸짐하게 올라간 콩나물, 홍합, 그 위에 신선한 낙지 한 마리까지 먹기 전부터 시각을 자극한다."
},
{
"name": "리투스카페",
"category": "맛집",
"searchQuery": "리투스카페",
"distanceText": "1.6km",
"imageUrl": "/assets/mirror/81438670a5485351.jpg",
"description": "리투스카페는 군산 내항 부두 근처에 있는 오션 뷰 카페다. 특히 황금빛 일몰이 아름다워 일몰 카페로도 유명하다."
},
{
"name": "일풍식당",
"category": "맛집",
"searchQuery": "일풍식당",
"distanceText": "1.6km",
"description": "‘일풍식당’은 각종 TV매체에서 맛집으로 다룬 곳이다. 골목 사이에 위치해 있어 찾을 때 약간 주의를 해야 한다."
},
{
"name": "수송반점",
"category": "맛집",
"searchQuery": "수송반점",
"distanceText": "1.7km",
"imageUrl": "/assets/mirror/98539e6361e317b1.jpg",
"description": "수송 반점은 군산시 서흥남동, 서흥 중학교 근처에 있는 중식당이다."
},
{
"name": "군산활어회센타",
"category": "맛집",
"searchQuery": "군산활어회센타",
"distanceText": "1.9km",
"imageUrl": "/assets/mirror/93146c8ae53252ff.jpg",
"description": "군산 문화동에 있는 '군산 활어회센터'는 가까이 있는 군산항 활어회 직판장에서 가져오는 활어회로 신선도와 식감 면에서 뛰어나다."
},
{
"name": "진성원",
"category": "맛집",
"searchQuery": "진성원",
"distanceText": "1.9km",
"imageUrl": "/assets/mirror/83766fa52745ef37.jpg",
"description": "진성원은 전라북도 군산시 경암동에 있는 중식당이다."
},
{
"name": "서우식당",
"category": "맛집",
"searchQuery": "서우식당",
"distanceText": "2.0km",
"imageUrl": "/assets/mirror/de286714af7faaba.jpg",
"description": "서우식당은 저렴한 가격에 푸짐한 한상차림으로 허영만의 백반 기행 등 TV 프로그램에도 소개된 한식집이다."
}
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,22 @@
# 물러난 번들 — 지우지 않는다
여기 있는 파일은 **더 이상 아무 HTML 도 가리키지 않는** 옛 번들이다. 그래도 서버에는 올린다.
## 왜
`/s/` 는 5분 캐시(`max-age=300`)다. 새 `index.html` 을 올려도 그동안 손님 브라우저에는
**옛 HTML** 이 남아 있고, 그 HTML 은 옛 해시 이름으로 css·js 를 부른다.
그 파일을 서버에서 지우면 **그 손님에게는 스타일도 스크립트도 404 다** — 화면은 뜨는데
헤더가 안 붙고 푸터 색이 빠진 민짜 문서가 된다.
실측(2026-09-14): 번들을 갈면서 옛 두 개를 `docker exec rm` 으로 지웠더니 바로 그 증상이
났다. "헤더가 스티키가 아니고 푸터가 투명해졌다" — 파일이 사라진 것이지 CSS 가 바뀐 게 아니다.
제품 쪽 프리렌더가 옛 자산을 30일 보관하는 것도 같은 이유다(`prerender.ts ASSET_RETENTION_DAYS`).
## 규칙
- 번들을 갈면 옛 파일을 **지우지 말고 이리로 옮긴다.** `vendor/` 뿌리에는 현재 한 벌만 둔다
(`patch_stay.py` 가 뿌리에 js·css 각 하나임을 확인한다).
- 배포할 때 뿌리와 이 폴더를 **둘 다** 서버 `vendor/` 에 넣는다:
`docker cp vendor/retired/. $C:/app/solution/site/out/s/stay/vendor/`
- 30일쯤 지나 캐시가 다 빠지면 지워도 된다. 급할 것 없다 — 합쳐서 450KB 다.