[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
146
solution/site/scripts/mockup/AUTOPLAY.md
Normal 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.
|
||||
@ -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분 |
|
||||
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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:]
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -121,6 +121,9 @@ PEOPLE = [
|
||||
("김수미", "김수미", "1949–2024", "배우", "「전원일기」 일용 엄니로 오래 기억되는 배우입니다."),
|
||||
("송새벽", "송새벽", "1979–", "배우", "군산 출신 배우입니다."),
|
||||
("장신영", "장신영", "1984–", "배우", "군산 출신 배우입니다."),
|
||||
# ★ 익산 출생이지만 군산에서 학교를 다녔다 — 그대로 적는다. 다른 줄처럼 "군산 출신" 으로
|
||||
# 뭉뚱그리면 위키와 어긋나고, 이 목록의 규칙(문서에 적힌 관계만 담는다)도 깨진다.
|
||||
("진희경", "진희경", "1968–", "배우", "익산에서 태어나 군산영광여고를 나온 배우입니다."),
|
||||
("이서원", "이서원", "1997–", "배우", "군산 출신 배우입니다."),
|
||||
("장재호 (배우)", "장재호", "1986–", "배우", "군산 출신 배우입니다."),
|
||||
("강민지 (배우)", "강민지", "1996–", "배우 · 모델", "모델로 시작해 드라마와 영화로 활동 범위를 넓혔습니다."),
|
||||
|
||||
99
solution/site/scripts/mockup/fetch_restaurants.py
Normal 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())
|
||||
47
solution/site/scripts/mockup/fill_restaurant_photos.py
Normal 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)}곳")
|
||||
BIN
solution/site/scripts/mockup/img/mirror/00a8ac4ce017fb12.jpg
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
solution/site/scripts/mockup/img/mirror/067f6fe42c045a3a.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
solution/site/scripts/mockup/img/mirror/087900c82da3b2f5.jpg
Normal file
|
After Width: | Height: | Size: 619 KiB |
BIN
solution/site/scripts/mockup/img/mirror/1302460cb149975f.jpg
Normal file
|
After Width: | Height: | Size: 457 KiB |
BIN
solution/site/scripts/mockup/img/mirror/24043ae4f5854f96.jpg
Normal file
|
After Width: | Height: | Size: 452 KiB |
BIN
solution/site/scripts/mockup/img/mirror/3e5b3daae33ea51c.jpg
Normal file
|
After Width: | Height: | Size: 490 KiB |
BIN
solution/site/scripts/mockup/img/mirror/42e8d872198edaa9.jpg
Normal file
|
After Width: | Height: | Size: 520 KiB |
BIN
solution/site/scripts/mockup/img/mirror/483f52c2d2b7eebe.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
solution/site/scripts/mockup/img/mirror/55f57f45fcebb460.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
solution/site/scripts/mockup/img/mirror/5f92192c7b3e2cbf.jpg
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
solution/site/scripts/mockup/img/mirror/6102b164464dbfa4.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
solution/site/scripts/mockup/img/mirror/81438670a5485351.jpg
Normal file
|
After Width: | Height: | Size: 482 KiB |
BIN
solution/site/scripts/mockup/img/mirror/81bd76fa51ae9ad1.jpg
Normal file
|
After Width: | Height: | Size: 485 KiB |
BIN
solution/site/scripts/mockup/img/mirror/83766fa52745ef37.jpg
Normal file
|
After Width: | Height: | Size: 529 KiB |
BIN
solution/site/scripts/mockup/img/mirror/92cb907d07db9953.jpg
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
solution/site/scripts/mockup/img/mirror/93146c8ae53252ff.jpg
Normal file
|
After Width: | Height: | Size: 418 KiB |
BIN
solution/site/scripts/mockup/img/mirror/98539e6361e317b1.jpg
Normal file
|
After Width: | Height: | Size: 357 KiB |
BIN
solution/site/scripts/mockup/img/mirror/aebe437fd281f9ad.jpg
Normal file
|
After Width: | Height: | Size: 420 KiB |
BIN
solution/site/scripts/mockup/img/mirror/b3518ee8ef2b4aac.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
solution/site/scripts/mockup/img/mirror/bf6bb6183c6f6246.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
solution/site/scripts/mockup/img/mirror/de286714af7faaba.jpg
Normal file
|
After Width: | Height: | Size: 544 KiB |
BIN
solution/site/scripts/mockup/img/mirror/e13c8a2a1aa2762c.jpg
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
solution/site/scripts/mockup/img/mirror/f9cb7e88415edd06.jpg
Normal file
|
After Width: | Height: | Size: 359 KiB |
@ -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")))
|
||||
|
||||
57
solution/site/scripts/mockup/restaurant-photos.json
Normal 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"
|
||||
}
|
||||
}
|
||||
239
solution/site/scripts/mockup/restaurants.json
Normal 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 프로그램에도 소개된 한식집이다."
|
||||
}
|
||||
]
|
||||
1
solution/site/scripts/mockup/vendor/embla-carousel.umd.js
vendored
Normal file
1
solution/site/scripts/mockup/vendor/index-C3fFNl3r.css
vendored
Normal file
340
solution/site/scripts/mockup/vendor/index-DDxwteyn.js
vendored
Normal file
22
solution/site/scripts/mockup/vendor/retired/README.md
vendored
Normal 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 다.
|
||||