[feat] site/mockup: 시연본 갱신 — 주변 맛집 31곳·국가유산 야행·차 동선 분기·번들 최신화

/s/stay 시연본에 오늘 쌓인 변경을 한꺼번에 반영한다. 원본 8곳이 전부 467m 안이라
'걸어서 10분 이상' 탭의 맛집 칸이 비었던 것, xlsx 대조로 사이트에 없던 축제(국가유산
야행)가 빠져 있던 것, 차로 온 손님도 걸어온 손님과 같은 동선(짐 맡기고 저녁에 복귀)을
따르던 것, 번들이 2026-09-09 판이라 숙박 예약 섹션이 아예 안 그려지던 것 — 전부 발견한
순서대로 고쳤다.

- patch_stay.py: 날짜 값(UPDATED_AT) 한 곳으로 모음 · 날씨 문구 케이스당 1개→5개
  무작위 · '오늘의 엽서' 탭 비움(엽서 쓰기 섹션으로 대체) · 주변 맛집 8→31곳 +
  사진 없는 집에 시연 전용 사진 채움 · 군산 국가유산 야행 추가(날짜 미확정이라
  기간 값은 안 넣음) · 오늘의 한 장 탭 숨김 · 예약 섹션 켬 · head 메타의 날짜도
  같이 갱신 · 번들을 최신 빌드로 교체하고 `<body class="site">` 보정(빠지면 카로셀
  overflow 가 죽어 가로 스크롤이 생겼다) · 군산 읽기/엽서 카로셀에 쓸
  embla-carousel UMD 번들 도입
- build_itinerary.py: 차로 온 손님의 마지막 날은 복귀 칸 없이 마지막 정거장에서
  끝나도록 동선을 가름(도보 뼈대에서 복귀 칸만 떼어 파생 — 두 벌로 안 둔다)
- build_story.py: 진희경은 익산 출생·군산에서 통학 — "군산 출신"으로 뭉뚱그리지 않게 정정
- fetch_restaurants.py · fill_restaurant_photos.py · restaurants.json ·
  restaurant-photos.json · img/mirror/*: TourAPI 로 받은 맛집 31곳과, 사진 없는
  집에 채운 시연 전용 사진(공공누리 아님 — 제품으로는 안 옮긴다)
- vendor/: embla-carousel.umd.js 추가, 번들을 index-C3fFNl3r.css·index-DDxwteyn.js
  로 교체하고 옛 파일은 vendor/retired/ 로 보관
- audit-all.mjs · audit_schedule.py · SCHEDULE.md: 위 변경에 맞춘 점검 항목·산출물 갱신
- AUTOPLAY.md: 새로고침 직후 배경음악 자동재생을 시도한 것과 실측값 — 사람 입력 없이는
  브라우저가 막아서 안 된다는 결론 기록

검증: `python3 patch_stay.py` 정상 종료, 킹서버 반영 후 md5 대조.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XYUb8ZfNiDd15tZRYrusCX
This commit is contained in:
Mina Choi 2026-09-14 17:07:01 +09:00
parent 0500ab3a3f
commit 0da9b92423
40 changed files with 1370 additions and 66 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

@ -1,14 +1,14 @@
# `/s/stay` 여행 일정 전수 검수표
생성 `python3 audit_schedule.py` · 테마 21개 · 53일 · 304정거장
생성 `python3 audit_schedule.py` · 테마 21개 · 53일 · 290정거장
표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).
규칙 14종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,
규칙 17종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,
이 표는 그 규칙이 **안 보는 논리**를 따로 센다.
## 결과 — 21/21 검수완료
**전부 통과.** 12개 항목 위반 0건.
**전부 통과.** 13개 항목 위반 0건.
| 코드 | 검수 항목 |
|---|---|
@ -20,10 +20,11 @@
| L5 | 바깥 정거장 체류 15분 이상 |
| L6 | 하루가 14시간을 넘지 않는다 |
| L7 | 입실 15:00 이후 · 퇴실 11:00 전 |
| L8 | 하루의 첫 칸·마지막 칸이 숙소 |
| L8 | 하루의 첫 칸·마지막 칸이 숙소 (**떠나는 날 차 테마는 복귀 없이 끝난다**) |
| L9 | 같은 자리가 연달아 서지 않는다 |
| L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 |
| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |
| L12 | 떠나는 날의 끝맺음이 퇴실 문구와 같다 — 짐을 차에 실었으면 안 돌아오고, 맡겼으면 찾으러 온다 |
---
@ -44,14 +45,13 @@
| 5 | 18:45–19:25 | 밤 산책 | 군산 내항 | 5분 | 40분 |
| 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분 |
| 2 | 10:59–13:29 | 오전 | 고군산군도 선유도 | 84분 | 150분 |
| 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분 |
| 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분 |
| 3 | 12:10–13:07 | 점심 | 일흥옥 | 8분 | 57분 |
| 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분 |
| 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분 |
| 3 | 11:30–12:18 | 점심 | 일흥옥 | 8분 | 48분 |
| 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분 |
| 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분 |
| 3 | 11:30–12:18 | 점심 | 일흥옥 | 11분 | 48분 |
| 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분 |
| 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분 |
| 3 | 12:03–12:58 | 점심 | 일흥옥 | 17분 | 55분 |
| 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분 |
| 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분 |
| 3 | 12:05–12:35 | 점심 | 이성당 | 11분 | 30분 |
| 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분 |
| 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분 |
| 4 | 12:36–13:21 | 오후 | 군산근대건축관 | 4분 | 45분 |
| 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분 |
| 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분 |
| 3 | 11:30–12:20 | 점심 | 일흥옥 | 4분 | 50분 |
| 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분 |
| 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분 |
| 3 | 11:30–12:33 | 점심 | 국제반점 | 10분 | 63분 |
| 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분 |
| 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분 |
| 3 | 11:33–12:19 | 점심 | 물밀소 | 3분 | 46분 |
| 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분 |
| 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분 |
| 3 | 11:30–12:10 | 점심 | 일흥옥 | 4분 | 40분 |
| 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분 |
| 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분 |
| 3 | 12:47–13:47 | 점심 | 한일옥 | 17분 | 60분 |
| 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분 |
| 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분 |
| 3 | 11:53–12:31 | 점심 | 만남스넥 | 9분 | 38분 |
| 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분 |
| 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분 |
| 3 | 11:30–12:15 | 점심 | 일흥옥 | 12분 | 45분 |
| 4 | 12:23–13:03 | 오후 | 군산근대미술관 | 8분 | 40분 |
| 5 | 13:12–13:48 | 마무리 | 스테이 머뭄 | 9분 | 36분 |

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),
themes: itin.length,
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,
hasNote: stops.every((s) => s.name.endsWith('스테이 머뭄') || s.note),
stopImages: stops.filter((s) => s.imageUrl).length,
restDays: days.filter((day) => day.stops.some((s) => s.name.startsWith('머뭄에서 쉬는 날'))).length,
endTimes: (() => { const t = days.map((day) => { let c = +day.startTime.slice(0, 2) * 60 + +day.startTime.slice(3); day.stops.forEach((s) => { c += s.moveMinutes + s.minutes; }); return c; }); return [Math.min(...t), Math.max(...t)]; })(),
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,
ownInSongs: sec('songs').items.filter((s) => s.own).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,
roomMirror: P.units.flatMap((u) => u.mediaIds).length,
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('캐치프레이즈 순환', 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('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.hasNote, d.hasNote ? '전 정거장' : '빠짐');
ok('일정 사진 없음', d.stopImages === 0 && d.domItinImg === 0, `payload ${d.stopImages} · 화면 ${d.domItinImg}`);
ok('숙소에서 쉬는 날', d.restDays >= 3, `${d.restDays}일`);
ok('종료 시각 분산', d.endTimes[1] - d.endTimes[0] > 240, `${(d.endTimes[0] / 60 | 0)}:${String(d.endTimes[0] % 60).padStart(2, '0')} ~ ${(d.endTimes[1] / 60 | 0)}:${String(d.endTimes[1] % 60).padStart(2, '0')}`);
ok('군산 읽기 — 한 화면에 다 편다',
d.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.ownInSongs === 0, `${d.ownInSongs}곡`);
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() 는 "만들 때" 지키는 규칙이다.
이 스크립트는 **만들어진 결과**를 사람이 읽는 표로 펴고, 비율·간격처럼
@ -95,10 +95,19 @@ def check(item):
if is_last and rows[0]["place"] == STAY and rows[0]["to"] > 11 * 60:
bad.append(("L7", f"{label}: 퇴실이 {hm(rows[0]['to'])}"))
# L8 시작·끝이 숙소인가
# ★ 떠나는 날은 갈린다 (2026-09-14 대표 의견). 차로 온 손님은 퇴실할 때 짐을 싣고
# 나서므로 복귀 칸이 없다 — 그 하루는 마지막 정거장에서 끝나는 것이 맞다.
# 판별은 **퇴실 칸의 문구**로 한다(payload 에 남는 값이라 표와 화면이 같은 것을 본다).
car_out = is_last and "차에 싣고" in (day["stops"][0].get("note") or "")
if rows[0]["place"] != STAY:
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']}"))
# 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 같은 자리 연속
for i in range(1, len(rows)):
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(d['stops']) for i in items for d in i['days'])}정거장", "",
"표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).",
"규칙 14종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,",
"규칙 17종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,",
"이 표는 그 규칙이 **안 보는 논리**를 따로 센다.", "",
"| 코드 | 검수 항목 |", "|---|---|",
"| L0 | 21시를 넘겨 화면에서 버려지는 칸이 없다 |",
@ -135,10 +144,11 @@ lines = ["# `/s/stay` 여행 일정 전수 검수표", "",
"| L5 | 바깥 정거장 체류 15분 이상 |",
"| L6 | 하루가 14시간을 넘지 않는다 |",
"| L7 | 입실 15:00 이후 · 퇴실 11:00 전 |",
"| L8 | 하루의 첫 칸·마지막 칸이 숙소 |",
"| L8 | 하루의 첫 칸·마지막 칸이 숙소 (**떠나는 날 차 테마는 복귀 없이 끝난다**) |",
"| L9 | 같은 자리가 연달아 서지 않는다 |",
"| L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 |",
"| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |", ""]
"| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |",
"| L12 | 떠나는 날의 끝맺음이 퇴실 문구와 같다 — 짐을 차에 실었으면 안 돌아오고, 맡겼으면 찾으러 온다 |", ""]
fails = []
for item in items:
@ -169,7 +179,7 @@ if fails:
head += ["| 테마 | 걸린 항목 |", "|---|---|"]
head += [f"| {n} | {', '.join(sorted({c for c, _ in b}))} |" for n, b in fails]
else:
head += ["**전부 통과.** 12개 항목 위반 0건."]
head += ["**전부 통과.** 13개 항목 위반 0건."]
head.append("")
lines = lines[:8] + head + lines[8:]

View File

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

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

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

@ -14,6 +14,15 @@ from pathlib import Path
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개 ────────────────────────────────────────────────────────
# 사실이 아니라 문구다(출처가 붙는 값이 아니다). 근거는 payload 의 시설·위치 값 —
# 적산가옥 두 동 · 히로쓰 가옥 옆 · 마당과 정원 · 창고형 카페 · 매일 세탁하는 침구 · 최대 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": "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]
@ -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["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")
# ── 머뭄이 만든 노래 5곡 ──────────────────────────────────────────────────────
@ -234,6 +394,35 @@ for unit in payload["units"]:
# 옛 사진은 남기지 않는다 — 등록본이 그 객실에 붙여 놓은 사진만 그 순서로 세운다.
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 조립 ──────────────────────────────────────────────────────────
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)
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 사장님 지시).
# 항목이 끝난 자리에 선이 한 줄 더 그어져 있어, 빈 칸이 하나 더 있는 것처럼 보인다.
# 항목(바비큐 이용) 자체는 그대로 둔다 — 지우라고 한 것은 선이다.
@ -252,9 +449,23 @@ assert n == 1, "payload 스크립트를 못 찾았다"
css = (SP / "inject.css").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
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 ")
# 치환문(block)에 JS 의 정규식(\s 등)이 섞여 있어 그대로 넘기면 파이썬이 이스케이프로 읽는다 — 람다로 넘긴다.
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/…` 로 바꿔 박는다.
# 이러면 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 = []
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():
@ -289,6 +522,7 @@ out.mkdir(exist_ok=True)
(out / "index.html").write_text(html, encoding="utf-8")
env = json.loads({s["id"]: s for s in payload["theme"]["sections"]}["itinerary"]["data"])
print(f"index.html {len(html):,}자 · 일정 {len(env['items'])}개 · 캐치프레이즈 {len(items)}개")
print(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)}"
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

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 다.