/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
726 lines
47 KiB
Python
726 lines
47 KiB
Python
"""/s/stay 목업의 '추천 일정' 을 짠다 — 펜션에서 나서고 펜션으로 돌아오는 하루.
|
||
|
||
★ 왜 스크립트인가
|
||
20개 테마 × 하루 5~6칸을 손으로 적으면 시각이 어긋난다. 렌더러(shared `planDay`)는
|
||
**21시를 넘기는 칸을 조용히 버린다** — 적어 놓고 화면에 안 나오는 칸이 생긴다.
|
||
그래서 사람은 **어느 자리에 어느 장소**만 고르고, 시각은 여기서 계산한다.
|
||
|
||
★ 2026-09-10 전면 재작성. 앞선 판이 논리적으로 틀린 자리가 네 군데였다(전수 감사).
|
||
① 이동시간을 슬롯에 박아 뒀다 — 담 하나 건너인 히로쓰 가옥이 8분, 45분 걸리는 선유도가 10분.
|
||
→ **좌표에서 계산한다**(1.5km 까지 도보 4km/h, 그 위는 차 25km/h + 주차 5분).
|
||
② 체류시간도 슬롯이 정했다 — '오후' 칸이면 무엇이든 90~115분이라 우체통거리에 2시간 서 있었다.
|
||
→ **장소마다 (최소·기본·최대)** 를 준다.
|
||
③ 입실이 14:30 에 잡혔다(payload `check_in_time` 은 15:00), 퇴실은 11:35(규정 11:00).
|
||
④ 점심이 09:40, 저녁이 13:09 에 잡혔다.
|
||
→ ③④ 는 **창(WINDOW)** 으로 못 박고, 앞 칸 체류를 범위 안에서 늘이고 줄여 맞춘다.
|
||
맞추지 못하면 시작 시각을 당긴다. 그래도 안 되면 그 조합은 만들지 않는다(조용히 틀리게 두지 않는다).
|
||
|
||
★ 지어내지 않는다. 좌표는 payload(기존 정거장)와 카카오 로컬 검색(새 장소)에서 온 값이고,
|
||
한 줄 설명은 **거기서 무엇을 하는지**다 — 장소 소개는 이미 '주변 정보' 섹션이 한다.
|
||
"""
|
||
import json
|
||
import math
|
||
from pathlib import Path
|
||
|
||
SP = Path(__file__).parent
|
||
SRC = SP / "stay-payload.json"
|
||
DST = SP / "stay-payload-new.json"
|
||
|
||
payload = json.loads(SRC.read_text(encoding="utf-8"))
|
||
SECT = {s["id"]: s for s in payload["theme"]["sections"]}
|
||
|
||
BASE = (payload["place"]["latitude"], payload["place"]["longitude"])
|
||
STAY = "스테이 머뭄"
|
||
|
||
|
||
# ── 장소 대장 ─────────────────────────────────────────────────────────────────
|
||
# (위도, 경도, 지도 검색어, (최소·기본·최대 체류분), 거기서 무엇을 하는지)
|
||
# ★ 설명이 아니라 **할 일**이다 (2026-09-10 사장님: "가서 뭐 할지를 추천해 줘야지").
|
||
# "1899년에 지어진 건물입니다" 는 손님이 거기서 뭘 해야 하는지 안 알려 준다.
|
||
PLACES = {
|
||
STAY: (35.9864175, 126.7061254, None, (30, 40, 60), None),
|
||
|
||
"히로쓰 가옥": (35.9861696, 126.7060575, "군산 신흥동 일본식가옥", (20, 30, 45),
|
||
"담 하나 건너입니다. 마당과 다다미방을 둘러보고 2층 계단까지 올라가 봅니다."),
|
||
"초원사진관": (35.9877370, 126.7083826, "군산 초원사진관", (20, 30, 45),
|
||
"안에 들어가 사진관 세트에서 한 장 찍습니다. 걸어서 3분입니다."),
|
||
"군산근대역사박물관": (35.9908197, 126.7121232, "군산근대역사박물관", (60, 90, 120),
|
||
"3층부터 내려오며 봅니다. 항구가 어떻게 생겼는지 여기서 먼저 잡고 나갑니다."),
|
||
"군산근대건축관": (35.9894202, 126.7121232, "군산근대건축관", (30, 45, 70),
|
||
"옛 조선은행 금고와 은행 창구를 봅니다. 2층 전시까지 보면 45분입니다."),
|
||
"군산근대미술관": (35.9899368, 126.7108000, "군산근대미술관", (30, 45, 70),
|
||
"옛 18은행 건물 안 전시를 봅니다. 마당 벤치에서 한 번 쉬어 갑니다."),
|
||
"구 군산세관 본관": (35.9915088, 126.7135000, "구 군산세관", (20, 30, 45),
|
||
"빨간 벽돌 본관을 한 바퀴 돌고 앞마당에서 사진을 찍습니다."),
|
||
"해망굴": (35.9913181, 126.7050000, "군산 해망굴", (15, 20, 30),
|
||
"굴을 걸어서 통과해 반대편 항구 쪽으로 나옵니다."),
|
||
"경암동 철길마을": (35.9813520, 126.7362331, "군산 경암동 철길마을", (35, 50, 80),
|
||
"선로를 따라 걸으며 옛 간판을 봅니다. 교복을 빌려 입고 찍는 집이 여럿입니다."),
|
||
"군산 내항 부잔교": (35.9892997, 126.7174508, "군산 내항 부잔교", (25, 35, 55),
|
||
"뜬다리 위를 끝까지 걸어 나갔다 돌아옵니다. 조수에 따라 다리가 오르내립니다."),
|
||
"째보선창": (35.9875345, 126.7199875, "군산 째보선창", (20, 30, 45),
|
||
"선창을 따라 걷습니다. 아직 배가 드나드는 자리라 아침이 더 볼 게 많습니다."),
|
||
"군산 시간여행마을": (35.9883192, 126.7109377, "군산 시간여행마을", (40, 60, 90),
|
||
"골목을 정해 두지 말고 걷습니다. 1930년대 간판을 그대로 단 가게가 이어집니다."),
|
||
"월명호수": (35.9766391, 126.6913696, "군산 월명호수", (35, 50, 80),
|
||
"호수를 한 바퀴 돕니다. 천천히 걸어 40분 코스입니다."),
|
||
"은적사": (35.9748304, 126.6854726, "군산 은적사", (30, 40, 60),
|
||
"대웅전까지 올라갔다 내려옵니다. 절 마당에서 한 번 앉아 갑니다."),
|
||
"채만식문학관": (36.0085981, 126.7578853, "군산 채만식문학관", (40, 60, 90),
|
||
"배 모양 전시관을 돌고 금강 쪽 데크로 나가 강을 봅니다."),
|
||
"동국사": (35.9827663, 126.7074296, "군산 동국사", (25, 35, 50),
|
||
"대웅전 처마와 대나무 숲을 봅니다. 종각까지 돌면 35분입니다."),
|
||
"우체통거리": (35.9859965, 126.7119708, "군산 우체통거리", (15, 25, 40),
|
||
"주민들이 손질해 내놓은 우체통을 따라 걷습니다. 엽서를 부치고 가도 됩니다."),
|
||
"고군산군도 선유도": (35.8102050, 126.4124053, "군산 선유도", (120, 150, 210),
|
||
"다리를 건너 들어갑니다. 망주봉 아래 해변을 걷고 스카이라인을 탑니다."),
|
||
"장자도": (35.8101000, 126.3973000, "군산 장자도", (40, 50, 80),
|
||
"대장봉까지 올라갔다 내려옵니다. 섬과 섬 사이를 걸어 넘습니다."),
|
||
"군산 내항": (35.9892997, 126.7174508, "군산 내항", (30, 40, 60),
|
||
"조명이 들어온 부잔교를 따라 걷습니다. 낮과 아주 다른 자리입니다."),
|
||
|
||
# 끼니 — 무엇을 먹는지까지 적는다. "맛집입니다" 로는 무엇을 시킬지 모른다.
|
||
"빈해원": (35.9888841, 126.7139765, "군산 빈해원", (55, 70, 90),
|
||
"2층 홀에 앉아 짬뽕이나 잡채밥을 시킵니다. 건물 자체가 문화재입니다."),
|
||
"일흥옥": (35.9851498, 126.7091839, "군산 일흥옥", (40, 50, 65),
|
||
"소고기 국밥으로 저녁을 먹습니다. 백년가게로 지정된 집입니다."),
|
||
"한일옥": (35.9874081, 126.7100000, "군산 한일옥", (40, 50, 65),
|
||
"무국으로 아침 겸 점심을 엽니다. 줄이 있으면 이름을 적고 기다립니다."),
|
||
"명월갈비": (35.9863914, 126.7101663, "군산 명월갈비", (60, 75, 95),
|
||
"한우 양념갈비 한 상을 시킵니다. 이 집은 단품 하나로 버텨 왔습니다."),
|
||
"국제반점": (35.9881100, 126.7108985, "군산 국제반점", (45, 55, 70),
|
||
"영화 「타짜」에 나온 홀에서 짜장과 탕수육을 시킵니다."),
|
||
"만남스넥": (35.9841788, 126.7097763, "군산 만남스넥", (30, 40, 55),
|
||
"군산식 잡탕 한 냄비를 시킵니다. 떡·만두·라면이 같이 끓어 나옵니다."),
|
||
"도란": (35.9868220, 126.7075100, "군산 도란", (60, 80, 100),
|
||
"감바스와 부채살 스테이크에 와인 한 잔으로 저녁을 마무리합니다."),
|
||
"물밀소": (35.9845243, 126.7073211, "군산 물밀소", (25, 35, 50),
|
||
"저온숙성 빵을 골라 나옵니다. 마당에서 먹을 것으로 두어 개 더 삽니다."),
|
||
"이성당": (35.9870443, 126.7111681, "군산 이성당", (25, 35, 50),
|
||
"단팥빵과 야채빵을 사서 나옵니다. 갓 나오는 시간에 맞추면 줄이 짧습니다."),
|
||
"째보선창 근처 카페": (35.9875345, 126.7199875, "군산 째보선창 카페", (45, 60, 90),
|
||
"창가에 앉아 비가 그칠 때까지 기다립니다. 항구가 보이는 자리입니다."),
|
||
"동국사다온": (35.9860907, 126.7144400, "군산 동국사 다온", (30, 40, 55),
|
||
"대추차나 오미자차를 시켜 절 마당을 보며 마십니다."),
|
||
}
|
||
|
||
# 펜션 칸은 장소가 아니라 **행위**가 시간을 정한다.
|
||
STAY_ACTION = {
|
||
"체크인": ((30, 40, 50), "짐을 풀고 마당과 창고형 카페 공간을 한 바퀴 돕니다."),
|
||
"머뭄으로": ((20, 30, 45), "돌아와 마당 평상에 앉습니다. 항구 소리가 여기까지 옵니다."),
|
||
# ★ 오후에 한 번 숙소로 돌아온다. 넣은 이유가 둘이다.
|
||
# ① 점심(≤13:30)과 저녁(≥17:30) 사이 네 시간을 바깥 두 곳으로는 못 채운다 —
|
||
# 억지로 채우면 골목 하나에 두 시간 서 있는 일정이 된다(그래서 실제로 그랬다).
|
||
# ② 독채 펜션에 묵는 손님은 실제로 그렇게 한다. 이 집이 목적지인 일정이다.
|
||
"쉼": ((30, 70, 190), "숙소로 돌아와 마당 평상이나 창고형 카페 공간에서 쉽니다."),
|
||
# ★ 하루의 1번은 **언제나 머뭄**이다 (2026-09-10 사장님: "1은 스테이 머뭄이어야 한다고 말했지?").
|
||
# 한때 이 칸을 빼고 "시작 시각 = 나서는 시각" 으로 뒀다 — 출발지가 화면에서 사라졌다.
|
||
# 빈 칸으로 두지 않으려면 **실제로 하는 일**이 있어야 한다. 이 집은 부엌과 창고형 카페가
|
||
# 딸린 독채라 아침을 여기서 보낸다 — 그게 이 숙소를 고른 이유이기도 하다.
|
||
"아침": ((40, 60, 110), "부엌에서 아침을 차려 먹고 마당에서 커피를 내려 마신 뒤 나섭니다."),
|
||
# ★ 마지막 날 아침은 **퇴실까지 한 칸**이다 (2026-09-11 사장님: "체크인하고 바로 체크아웃하는
|
||
# 일정도 있어"). 아침(머뭄)과 체크아웃(머뭄)을 따로 세웠더니 **같은 자리에 두 칸이 연달아**
|
||
# 섰다 — 손님은 그 사이에 아무 데도 가지 않는다. 한 칸이 맞다.
|
||
# 마지막 날 아침은 체류 범위가 다르다 — 짐을 싸고 퇴실까지 한 칸에서 끝낸다.
|
||
# ★ 퇴실 뒤가 **차로 온 손님과 걸어 온 손님이 갈리는 자리**다 (2026-09-14 대표 의견).
|
||
# 차로 온 손님은 짐을 싣고 그대로 나선다 — 짐을 맡겨 두고 저녁에 숙소로 되돌아오는 동선은
|
||
# 차가 있는 사람에게는 한 번 더 들르라는 말이 된다. 그래서 그 하루는 **마지막 정거장에서 끝난다**.
|
||
# 짐 보관은 걸어 다니는 손님에게 필요한 것이고, 그 테마만 저녁에 찾으러 돌아온다.
|
||
"아침(퇴실)": ((45, 75, 115),
|
||
"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 맡겨 두고 나섰다가 저녁에 찾아 가세요."),
|
||
"아침(퇴실·차)": ((45, 75, 115),
|
||
"아침을 차려 먹고 11시 전에 퇴실합니다. 짐은 차에 싣고 그대로 나섭니다."),
|
||
"머뭄에서 쉬는 날": ((120, 210, 360),
|
||
"오늘은 나가지 않습니다. 마당 평상과 창고형 카페 공간에서 책을 읽고 낮잠을 잡니다."),
|
||
# ★ 10분짜리 칸을 시간표에 세우지 않는다 (2026-09-10 사장님: "10분 정도 왜 잡아 놓는 거야?").
|
||
# 짐만 찾는 데는 10분이면 되지만, 그건 한 칸이 될 만한 일이 아니다 — 손님이 시간표에서
|
||
# 읽는 것은 "여기서 무엇을 하다 가는가" 다. 마당에 한 번 더 앉았다 나서는 시간으로 잡는다.
|
||
"마무리": ((30, 40, 60), "맡겨 둔 짐을 찾고, 마당 평상에 한 번 더 앉았다 나섭니다."),
|
||
}
|
||
|
||
# ── 이동시간 — 좌표에서 계산한다 ────────────────────────────────────────────────
|
||
WALK_KMH, DRIVE_KMH, PARK_MIN, WALK_LIMIT_KM = 4.0, 25.0, 5, 1.5
|
||
|
||
|
||
def haversine_km(a: tuple, b: tuple) -> float:
|
||
r, rad = 6371.0, math.pi / 180
|
||
h = (math.sin((b[0] - a[0]) * rad / 2) ** 2
|
||
+ math.cos(a[0] * rad) * math.cos(b[0] * rad) * math.sin((b[1] - a[1]) * rad / 2) ** 2)
|
||
return 2 * r * math.asin(math.sqrt(h))
|
||
|
||
|
||
def move_minutes(a: tuple, b: tuple) -> int:
|
||
dist = haversine_km(a, b)
|
||
if dist < 0.05:
|
||
return 3 # 같은 자리(부잔교↔내항 야경)라도 옮겨 서는 시간은 있다
|
||
raw = dist / WALK_KMH * 60 if dist <= WALK_LIMIT_KM else dist / DRIVE_KMH * 60 + PARK_MIN
|
||
return max(3, int(round(raw)))
|
||
|
||
|
||
# ── 시간대가 정해진 약속 ───────────────────────────────────────────────────────
|
||
# 도착이 이 창 안에 들어와야 한다. 입·퇴실은 payload 의 check_in_time·check_out_time 과 같다.
|
||
WINDOW = {
|
||
"체크인": (15 * 60, 20 * 60),
|
||
"점심": (11 * 60 + 30, 13 * 60 + 30),
|
||
"저녁": (17 * 60 + 30, 20 * 60),
|
||
"늦은 점심": (13 * 60, 15 * 60),
|
||
"밤 산책": (18 * 60 + 30, 20 * 60),
|
||
}
|
||
DAY_ENDS_BY = 21 * 60
|
||
# ★ 끼니 사이 최소 간격 (2026-09-11 검수).
|
||
# 섬 코스에서 늦은 점심이 15:47 에 끝나고 저녁이 17:30 이었다 — 103분이다.
|
||
# 창(窓)만 지키면 통과한다: 늦은 점심은 15:00 까지, 저녁은 17:30 부터라 둘 다 합법인데
|
||
# 손님은 배가 안 꺼진 채 두 번째 상을 받는다. 창 밖에 이 규칙이 따로 필요하다.
|
||
MEAL_GAP = 180
|
||
MEAL_SLOTS = {"점심", "늦은 점심", "저녁"}
|
||
# 퇴실 시각. payload 의 check_out_time 과 같아야 한다 — 시간표가 규정을 어기면
|
||
# 사장님이 손님에게 설명할 수 없는 일정이 된다.
|
||
CHECK_OUT_BY = 11 * 60
|
||
|
||
|
||
# ── 하루 뼈대 ─────────────────────────────────────────────────────────────────
|
||
# 자리 이름만 정한다. 장소는 테마가 주고, 시간은 계산된다.
|
||
# ★ 펜션 칸은 **실제로 무슨 일이 일어나는 자리**만 남긴다 — 입실·퇴실·돌아옴.
|
||
# 한때 '아침 · 머뭄 20분' 을 세워 뒀는데, 이미 그 집에 있는 사람에게 아무 뜻이 없는 칸이었다
|
||
# (2026-09-10 사장님: "이건 무슨 의미야???"). 지금은 **하루의 시작 시각 = 머뭄에서 나서는 시각**이다.
|
||
# ★ 규칙 셋을 뼈대가 지킨다(아래 audit 이 매 실행 검사해서 어기면 빌드를 멈춘다).
|
||
# ① 하루의 처음과 끝은 머뭄이다 — 출발지와 복귀지.
|
||
# ② **같은 자리가 연달아 서지 않는다.** 머뭄 칸 사이에는 반드시 바깥 정거장이 있다.
|
||
# 한때 '아침 · 머뭄' 다음 칸이 '체크아웃 · 머뭄' 이었다 — 그 사이에 손님은 아무 데도
|
||
# 가지 않는데 시간표에는 두 줄이 섰다(2026-09-11 사장님 지적). 지금은 한 칸이다.
|
||
# ③ 가운데 머뭄 칸은 '쉼'·'쉬는 날' 뿐이다. 점심(≤13:30)과 저녁(≥17:30) 사이 네 시간을
|
||
# 바깥 두 곳으로는 못 채운다 — 억지로 채우면 골목 하나에 두 시간 세우게 된다(실측).
|
||
# 독채 펜션에 묵는 손님은 실제로 그때 들어와 쉰다.
|
||
SKELETON = {
|
||
"first": ["체크인@", "오후", "오후", "저녁", "밤 산책", "머뭄으로@"],
|
||
"first_late": ["체크인@", "저녁", "머뭄으로@"],
|
||
"mid": ["아침@", "오전", "점심", "오후", "오후", "쉼@", "저녁", "머뭄으로@"],
|
||
"mid_island": ["아침@", "오전", "오후", "늦은 점심", "쉼@", "저녁", "머뭄으로@"],
|
||
"mid_late": ["아침@", "점심", "오후", "오후", "쉼@", "저녁", "머뭄으로@"],
|
||
# ★ 숙소에서 노는 날. 오후에 머뭄으로 돌아와 쉬는 것이 이 테마의 내용이다
|
||
# (2026-09-10 사장님: "펜션에서 조용히 노는 날도 있어야지").
|
||
"mid_rest": ["머뭄에서 쉬는 날@", "점심", "쉼@", "저녁", "머뭄으로@"],
|
||
"last": ["아침@", "오전", "점심", "오후", "마무리@"],
|
||
"last_plus": ["아침@", "오전", "점심", "오후", "오후", "마무리@"],
|
||
# ★ 떠나는 날에 섬을 가는 코스. 왕복 두 시간이라 **바깥 정거장은 둘**이다
|
||
# (2026-09-11 감사: 섬을 오후에 넣었더니 저녁도 없이 19시에 돌아와 짐만 찾고 끝났다).
|
||
# 퇴실하고 바로 섬으로 가서, 돌아오는 길에 늦은 점심을 먹고 짐을 찾는다.
|
||
"last_island": ["아침@", "오전", "늦은 점심", "마무리@"],
|
||
}
|
||
# 이 뼈대의 첫 칸(아침 · 퇴실)은 **11시 전에 끝나야** 한다 — payload 의 check_out_time.
|
||
CHECKOUT_KINDS = {"last", "last_plus", "last_island"}
|
||
# ★ 차로 온 손님의 마지막 날은 **복귀 칸이 없다** (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",
|
||
"last_island": "08:00"}
|
||
# 시작을 앞당길 수 있는 한계. 첫날은 입실(15:00)이 있어 당길 이유가 없다.
|
||
FLOOR = {"first": "15:00", "first_late": "18:00", "mid": "07:20", "mid_island": "07:00",
|
||
"mid_late": "09:00", "mid_rest": "09:30", "last": "07:20", "last_plus": "07:20",
|
||
"last_island": "07:20"}
|
||
for _car in CAR_KINDS:
|
||
START[_car] = START[_car[:-4]]
|
||
FLOOR[_car] = FLOOR[_car[:-4]]
|
||
|
||
DAY_LABEL = ["첫째 날", "둘째 날", "셋째 날"]
|
||
DURATION = {2: "1박 2일", 3: "2박 3일"}
|
||
|
||
|
||
def _minutes(text: str) -> int:
|
||
hour, minute = text.split(":")
|
||
return int(hour) * 60 + int(minute)
|
||
|
||
|
||
def _action(slot: str, kind: str) -> tuple:
|
||
"""머뭄 칸의 (체류범위, 한 줄). 마지막 날 아침만 퇴실을 겸하고, 차 테마는 문구가 다르다."""
|
||
if slot == "아침" and kind in CHECKOUT_KINDS:
|
||
return STAY_ACTION["아침(퇴실·차)" if kind in CAR_KINDS else "아침(퇴실)"]
|
||
return STAY_ACTION[slot]
|
||
|
||
|
||
def _stay_spec(slot: str, place: str, kind: str) -> tuple:
|
||
if place == STAY:
|
||
return _action(slot, kind)[0]
|
||
return PLACES[place][3]
|
||
|
||
|
||
def _note(slot: str, place: str, kind: str) -> str | None:
|
||
if place == STAY:
|
||
return _action(slot, kind)[1]
|
||
return PLACES[place][4]
|
||
|
||
|
||
def _window_of(slot: str, last_meal_end: int | None):
|
||
"""그 자리의 도착 창. 끼니는 **앞 끼니가 끝난 뒤 MEAL_GAP** 이 지나야 연다."""
|
||
window = WINDOW.get(slot)
|
||
if not window:
|
||
return None
|
||
early, late = window
|
||
if slot in MEAL_SLOTS and last_meal_end is not None:
|
||
early = max(early, last_meal_end + MEAL_GAP)
|
||
return (early, late) if early <= late else None
|
||
|
||
|
||
def plan_day(kind: str, places: list[str], label: str, start: int, pace: float):
|
||
"""하루 하나를 시각까지 계산한다. 창을 못 맞추면 None.
|
||
|
||
★ 창을 맞추는 방법은 **앞 칸에 더 머무는 것**이다. 이동시간을 늘리면 거짓말이 되고,
|
||
빈 시간을 끼우면 시간표에 구멍이 난다. 사람은 실제로 그렇게 한다 — 밥 때가 이르면
|
||
박물관을 조금 더 본다. 범위(min~max)를 벗어나면 그 조합은 포기한다.
|
||
"""
|
||
slots = SKELETON[kind]
|
||
need = [s for s in slots if not s.endswith("@")]
|
||
if len(places) != len(need):
|
||
raise ValueError(f"{label}/{kind}: 자리 {len(need)}개인데 장소 {len(places)}개")
|
||
|
||
feed = iter(places)
|
||
rows = []
|
||
for slot in slots:
|
||
name = slot[:-1] if slot.endswith("@") else slot
|
||
place = STAY if slot.endswith("@") else next(feed)
|
||
low, base, high = _stay_spec(name, place, kind)
|
||
rows.append({"slot": name, "place": place, "kind": kind,
|
||
"stay": max(low, min(high, int(round(base * pace)))),
|
||
"low": low, "high": high})
|
||
|
||
# ★ 마지막 날 첫 칸(아침 겸 퇴실)은 11시 전에 **끝나야** 한다.
|
||
# 체크아웃을 따로 세우던 때는 이 칸이 머뭄에 두 번 서게 만들었다(2026-09-11 감사).
|
||
if kind in CHECKOUT_KINDS:
|
||
if start + rows[0]["stay"] > CHECK_OUT_BY:
|
||
rows[0]["stay"] = max(rows[0]["low"], CHECK_OUT_BY - start)
|
||
if start + rows[0]["stay"] > CHECK_OUT_BY or rows[0]["stay"] < rows[0]["low"]:
|
||
return None
|
||
|
||
clock = start
|
||
prev = BASE
|
||
last_meal_end = None
|
||
for index, row in enumerate(rows):
|
||
coords = (PLACES[row["place"]][0], PLACES[row["place"]][1])
|
||
# 하루의 1번은 머뭄이다 — 이미 그 자리에 있으니 이동시간이 없다.
|
||
row["move"] = 0 if index == 0 else move_minutes(prev, coords)
|
||
arrive = clock + row["move"]
|
||
|
||
window = _window_of(row["slot"], last_meal_end)
|
||
if window is None and row["slot"] in WINDOW:
|
||
return None # 앞 끼니와 겹쳐 창이 닫혔다 — 이 조합은 포기한다
|
||
if window:
|
||
early, late = window
|
||
if arrive < early:
|
||
# 앞 칸들에 더 머문다. 뒤에서부터 늘려야 방금 지나온 자리가 길어진다.
|
||
short = early - arrive
|
||
for back in reversed(rows[:index]):
|
||
room = back["high"] - back["stay"]
|
||
take = min(room, short)
|
||
back["stay"] += take
|
||
short -= take
|
||
if short == 0:
|
||
break
|
||
if short > 0:
|
||
return None # 늘려도 못 채운다 — 시작을 늦춰야 하는 조합
|
||
return plan_day_fixed(rows, start)
|
||
if arrive > late:
|
||
short = arrive - late
|
||
for back in reversed(rows[:index]):
|
||
room = back["stay"] - back["low"]
|
||
take = min(room, short)
|
||
back["stay"] -= take
|
||
short -= take
|
||
if short == 0:
|
||
break
|
||
if short > 0:
|
||
return None # 줄여도 못 당긴다 — 시작을 앞당겨야 하는 조합
|
||
return plan_day_fixed(rows, start)
|
||
|
||
clock = arrive + row["stay"]
|
||
if row["slot"] in MEAL_SLOTS:
|
||
last_meal_end = clock
|
||
prev = coords
|
||
|
||
if clock > DAY_ENDS_BY:
|
||
return None
|
||
return _render(rows, start, label)
|
||
|
||
|
||
def plan_day_fixed(rows: list[dict], start: int):
|
||
"""체류시간을 고친 뒤 처음부터 다시 훑는다(앞 칸을 바꿨으니 뒤 시각이 전부 달라진다)."""
|
||
clock = start
|
||
prev = BASE
|
||
last_meal_end = None
|
||
for index, row in enumerate(rows):
|
||
coords = (PLACES[row["place"]][0], PLACES[row["place"]][1])
|
||
row["move"] = 0 if index == 0 else move_minutes(prev, coords)
|
||
arrive = clock + row["move"]
|
||
window = _window_of(row["slot"], last_meal_end)
|
||
if row["slot"] in WINDOW and (window is None or not (window[0] <= arrive <= window[1])):
|
||
return None
|
||
clock = arrive + row["stay"]
|
||
if row["slot"] in MEAL_SLOTS:
|
||
last_meal_end = clock
|
||
prev = coords
|
||
if clock > DAY_ENDS_BY:
|
||
return None
|
||
return rows, start
|
||
|
||
|
||
def _render(rows: list[dict], start: int, label: str) -> dict:
|
||
stops = []
|
||
for row in rows:
|
||
place = row["place"]
|
||
stop = {"name": f"{row['slot']} · {place}", "minutes": row["stay"],
|
||
"moveMinutes": row["move"],
|
||
"latitude": PLACES[place][0], "longitude": PLACES[place][1]}
|
||
note = _note(row["slot"], place, row.get("kind", ""))
|
||
if note:
|
||
stop["note"] = note
|
||
query = PLACES[place][2]
|
||
if query:
|
||
stop["searchQuery"] = query
|
||
stops.append(stop)
|
||
return {"label": label, "startTime": f"{start // 60:02d}:{start % 60:02d}", "stops": stops}
|
||
|
||
|
||
def build_day(kind: str, places: list[str], label: str, start: str | None, pace: float) -> dict:
|
||
"""시작 시각을 옮겨 가며 모든 창을 맞추는 하루를 찾는다."""
|
||
begin = _minutes(start or START[kind])
|
||
floor = _minutes(FLOOR[kind])
|
||
candidates = [begin] + [begin + delta for delta in range(10, 190, 10)] \
|
||
+ [begin - delta for delta in range(10, 190, 10) if begin - delta >= floor]
|
||
for scale in (pace, pace * 0.92, pace * 0.85, 1.0, 0.8):
|
||
for clock in candidates:
|
||
if clock < floor or clock > 20 * 60:
|
||
continue
|
||
result = plan_day(kind, places, label, clock, scale)
|
||
if result is None:
|
||
continue
|
||
if isinstance(result, tuple): # 창을 맞추느라 체류를 고친 경우
|
||
rows, begin_at = result
|
||
return _render(rows, begin_at, label)
|
||
return result
|
||
raise ValueError(f"{label}/{kind}: 창(입·퇴실·끼니·21시)을 다 맞추는 시각이 없다")
|
||
|
||
|
||
# ── 테마 20개 ────────────────────────────────────────────────────────────────
|
||
# (이름, 기간, 누구에게, 왜, [(뼈대, [장소…])])
|
||
THEMES = [
|
||
("섬까지 다녀오는 1박 2일", "1박 2일", "차를 가져온 손님",
|
||
"첫날은 걸어서 도심, 이튿날은 다리를 건너 섬입니다. 두 날의 성격이 아주 다릅니다.",
|
||
[("first", ["히로쓰 가옥", "초원사진관", "빈해원", "군산 내항"]),
|
||
("last_island_car", ["고군산군도 선유도", "국제반점"])]),
|
||
("걸어서만 도는 1박 2일", "1박 2일", "차 없이 오는 손님",
|
||
"모든 정거장이 머뭄에서 걸어 닿습니다. 이틀 동안 차를 한 번도 타지 않습니다.",
|
||
[("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]),
|
||
("last", ["군산근대건축관", "한일옥", "우체통거리"])]),
|
||
("비가 와도 되는 1박 2일", "1박 2일", "비 예보를 보고 오는 손님",
|
||
"실내가 이어집니다. 비가 그치기를 기다리지 않아도 하루가 찹니다.",
|
||
[("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]),
|
||
("last_car", ["군산근대건축관", "일흥옥", "째보선창 근처 카페"])]),
|
||
("근대건축만 보는 1박 2일", "1박 2일", "건물을 보러 오는 손님",
|
||
"1900년대 건물만 골라 돕니다. 머뭄도 그 시기에 지은 집입니다.",
|
||
[("first", ["구 군산세관 본관", "군산근대건축관", "명월갈비", "군산 내항"]),
|
||
("last_car", ["군산근대미술관", "일흥옥", "해망굴"])]),
|
||
("이성당으로 시작하는 1박 2일", "1박 2일", "빵을 좋아하는 손님",
|
||
"이튿날 아침을 빵으로 엽니다. 문 여는 시간에 맞춰 걸어갑니다.",
|
||
[("first", ["초원사진관", "우체통거리", "도란", "군산 내항"]),
|
||
("last", ["이성당", "만남스넥", "군산근대역사박물관"])]),
|
||
("철길마을까지 가는 1박 2일", "1박 2일", "사진을 찍는 손님",
|
||
"빛이 좋은 자리만 이어 붙였습니다. 철길마을은 오전 빛이 낫습니다.",
|
||
[("first", ["초원사진관", "동국사", "국제반점", "군산 내항"]),
|
||
("last_car", ["경암동 철길마을", "일흥옥", "군산 시간여행마을"])]),
|
||
("항구만 도는 1박 2일", "1박 2일", "바다가 보고 싶은 손님",
|
||
"부잔교에서 째보선창까지, 이틀 동안 물가만 걷습니다.",
|
||
[("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]),
|
||
("last", ["째보선창", "국제반점", "구 군산세관 본관"])]),
|
||
("소설 『탁류』를 따라 걷는 1박 2일", "1박 2일", "채만식을 읽어 본 손님",
|
||
"소설이 지나간 자리를 순서대로 밟습니다. 세관·선창·문학관이 한 줄로 이어집니다.",
|
||
[("first", ["째보선창", "군산 내항 부잔교", "한일옥", "군산 내항"]),
|
||
("last_car", ["채만식문학관", "일흥옥", "구 군산세관 본관"])]),
|
||
("아이와 함께 1박 2일", "1박 2일", "아이를 데려온 손님",
|
||
"걷는 거리가 짧고, 아이가 지루해할 자리를 뺐습니다.",
|
||
[("first", ["초원사진관", "우체통거리", "만남스넥", "군산 내항"]),
|
||
("last_car", ["경암동 철길마을", "이성당", "월명호수"])]),
|
||
("늦게 도착하는 1박 2일", "1박 2일", "퇴근하고 오는 손님",
|
||
"첫날은 저녁 한 끼로 끝냅니다. 대신 이튿날을 아침부터 저녁까지 씁니다.",
|
||
[("first_late", ["명월갈비"]),
|
||
("last_plus_car", ["군산근대역사박물관", "한일옥", "군산근대건축관", "해망굴"])]),
|
||
|
||
("천천히 도는 2박 3일", "2박 3일", "쉬러 오는 손님",
|
||
"하루에 두어 곳만 봅니다. 마당에 앉아 있는 시간을 일정에 넣었습니다.",
|
||
[("first", ["히로쓰 가옥", "동국사", "도란", "군산 내항"]),
|
||
("mid_rest", ["한일옥", "빈해원"]),
|
||
("last", ["이성당", "만남스넥", "군산 시간여행마을"])]),
|
||
("섬과 도심을 반씩 도는 2박 3일", "2박 3일", "차를 가져온 손님",
|
||
"가운데 날을 섬에 다 씁니다. 첫날과 마지막 날은 걸어서 도심입니다.",
|
||
[("first", ["구 군산세관 본관", "군산근대건축관", "빈해원", "군산 내항"]),
|
||
("mid_island", ["고군산군도 선유도", "장자도", "국제반점", "명월갈비"]),
|
||
("last_car", ["초원사진관", "일흥옥", "이성당"])]),
|
||
("골목을 세 번 나눠 걷는 2박 3일", "2박 3일", "차 없이 오는 손님",
|
||
"원도심을 세 덩이로 나눠 하루에 하나씩 걷습니다. 같은 길을 두 번 걷지 않습니다.",
|
||
[("first", ["히로쓰 가옥", "초원사진관", "일흥옥", "군산 내항"]),
|
||
("mid", ["군산근대역사박물관", "한일옥", "군산근대건축관", "해망굴", "명월갈비"]),
|
||
("last", ["우체통거리", "만남스넥", "군산 시간여행마을"])]),
|
||
("먹으러 오는 2박 3일", "2박 3일", "맛집을 도는 손님",
|
||
"끼니가 중심입니다. 사이에 걸을 자리를 넣어 배를 비웁니다.",
|
||
[("first", ["물밀소", "초원사진관", "명월갈비", "군산 내항"]),
|
||
("mid", ["군산 시간여행마을", "일흥옥", "이성당", "우체통거리", "빈해원"]),
|
||
("last_car", ["동국사", "국제반점", "군산근대역사박물관"])]),
|
||
("아무 데도 안 가는 2박 3일", "2박 3일", "쉬려고만 오는 손님",
|
||
"가운데 날은 대문 밖으로 두 번만 나갑니다. 나머지는 마당과 카페 공간에서 보냅니다.",
|
||
[("first", ["히로쓰 가옥", "초원사진관", "도란", "군산 내항"]),
|
||
("mid_rest", ["만남스넥", "일흥옥"]),
|
||
("last_car", ["동국사", "물밀소", "월명호수"])]),
|
||
("사진만 찍는 2박 3일", "2박 3일", "사진을 찍는 손님",
|
||
"시간대에 맞춰 자리를 배치했습니다. 철길마을은 오전, 부잔교는 해 질 때입니다.",
|
||
[("first", ["초원사진관", "동국사", "도란", "군산 내항"]),
|
||
("mid", ["경암동 철길마을", "만남스넥", "째보선창", "해망굴", "국제반점"]),
|
||
("last_car", ["우체통거리", "일흥옥", "군산근대미술관"])]),
|
||
("비가 와도 되는 2박 3일", "2박 3일", "비 예보를 보고 오는 손님",
|
||
"사흘 내리 비가 와도 도는 순서가 바뀌지 않습니다. 실내와 처마 밑만 잇습니다.",
|
||
[("first", ["군산근대역사박물관", "군산근대미술관", "빈해원", "군산 내항"]),
|
||
("mid", ["군산근대건축관", "일흥옥", "째보선창 근처 카페", "동국사다온", "명월갈비"]),
|
||
("last_car", ["채만식문학관", "한일옥", "이성당"])]),
|
||
("늦게 시작하는 2박 3일", "2박 3일", "아침이 힘든 손님",
|
||
"가운데 날을 열한 시에 엽니다. 오전을 비우고 저녁을 늦게까지 씁니다.",
|
||
[("first", ["히로쓰 가옥", "우체통거리", "도란", "군산 내항"]),
|
||
("mid_late", ["일흥옥", "군산근대역사박물관", "해망굴", "빈해원"]),
|
||
("last", ["이성당", "만남스넥", "초원사진관"])]),
|
||
("섬에서 하루를 다 쓰는 2박 3일", "2박 3일", "차를 가져온 손님",
|
||
"가운데 날은 아침에 나가 저녁에 들어옵니다. 섬에서 하루를 통째로 씁니다.",
|
||
[("first", ["군산 내항 부잔교", "해망굴", "빈해원", "군산 내항"]),
|
||
("mid_island", ["고군산군도 선유도", "장자도", "한일옥", "명월갈비"]),
|
||
("last_car", ["군산근대건축관", "만남스넥", "경암동 철길마을"])]),
|
||
("소설 『탁류』를 따라 걷는 2박 3일", "2박 3일", "채만식을 읽어 본 손님",
|
||
"소설의 순서대로 사흘을 나눴습니다. 선창에서 시작해 문학관에서 끝냅니다.",
|
||
[("first", ["째보선창", "군산 내항 부잔교", "일흥옥", "군산 내항"]),
|
||
("mid_rest", ["한일옥", "빈해원"]),
|
||
("last", ["군산근대역사박물관", "국제반점", "초원사진관"])]),
|
||
("철길마을과 항구를 같이 보는 2박 3일", "2박 3일", "사진을 찍는 손님",
|
||
"기찻길과 물길을 하루씩 나눠 봅니다. 마지막 날은 건물만 봅니다.",
|
||
[("first", ["경암동 철길마을", "초원사진관", "국제반점", "군산 내항"]),
|
||
("mid", ["군산 내항 부잔교", "한일옥", "째보선창", "해망굴", "명월갈비"]),
|
||
("last_car", ["구 군산세관 본관", "일흥옥", "군산근대미술관"])]),
|
||
]
|
||
|
||
# 테마 이름 → (날짜별 시작 시각, 체류 배율). 시각과 배율이 그 테마의 성격이다 —
|
||
# 쉬러 오는 손님(1.25)과 사진만 찍는 손님(0.8)은 같은 곳을 봐도 하루 길이가 다르다.
|
||
# 창(끼니·입퇴실)에 걸리면 build_day 가 시작을 옮겨 맞춘다.
|
||
PACING = {
|
||
"섬까지 다녀오는 1박 2일": (["15:00", "08:20"], 1.0),
|
||
"걸어서만 도는 1박 2일": (["15:00", "09:20"], 1.05),
|
||
"비가 와도 되는 1박 2일": (["15:20", "09:40"], 1.15),
|
||
"근대건축만 보는 1박 2일": (["15:00", "08:50"], 0.95),
|
||
"이성당으로 시작하는 1박 2일": (["16:10", "07:30"], 0.9),
|
||
"철길마을까지 가는 1박 2일": (["15:40", "08:30"], 0.95),
|
||
"항구만 도는 1박 2일": (["16:30", "09:20"], 1.05),
|
||
"소설 『탁류』를 따라 걷는 1박 2일": (["15:10", "09:00"], 1.1),
|
||
"아이와 함께 1박 2일": (["15:30", "10:10"], 0.85),
|
||
"늦게 도착하는 1박 2일": (["19:00", "08:40"], 1.0),
|
||
"천천히 도는 2박 3일": (["16:20", "10:40", "09:50"], 1.25),
|
||
"섬과 도심을 반씩 도는 2박 3일": (["15:10", "07:40", "09:20"], 1.0),
|
||
"골목을 세 번 나눠 걷는 2박 3일": (["15:00", "09:10", "08:20"], 1.1),
|
||
"먹으러 오는 2박 3일": (["16:40", "10:20", "08:40"], 1.15),
|
||
"사진만 찍는 2박 3일": (["15:50", "07:20", "07:40"], 0.8),
|
||
"비가 와도 되는 2박 3일": (["15:20", "10:10", "09:40"], 1.2),
|
||
"아무 데도 안 가는 2박 3일": (["15:00", "10:00", "09:00"], 1.3),
|
||
"늦게 시작하는 2박 3일": (["17:20", "10:30", "10:40"], 1.0),
|
||
"섬에서 하루를 다 쓰는 2박 3일": (["15:00", "07:30", "09:40"], 0.95),
|
||
"소설 『탁류』를 따라 걷는 2박 3일": (["15:00", "09:50", "08:30"], 1.1),
|
||
"철길마을과 항구를 같이 보는 2박 3일": (["16:00", "07:50", "09:30"], 0.9),
|
||
}
|
||
|
||
items = []
|
||
for name, duration, audience, why, days in THEMES:
|
||
starts, pace = PACING.get(name, (None, 1.0))
|
||
built = [build_day(kind, places, DAY_LABEL[i],
|
||
(starts[i] if starts and i < len(starts) else None), pace)
|
||
for i, (kind, places) in enumerate(days)]
|
||
# ★ 차 필요 여부를 손님이 테마를 **고르기 전에** 보이게 한다 (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}
|
||
SECT["itinerary"]["data"] = json.dumps(env, ensure_ascii=False)
|
||
|
||
|
||
# ── 검증: 규칙을 어기면 **빌드를 멈춘다** ────────────────────────────────────
|
||
#
|
||
# ★ 왜 스크립트 안인가 (2026-09-11)
|
||
# 지금까지 이 검사를 매번 따로 쓴 일회용 스크립트로 돌렸다. 그래서 뼈대를 고칠 때마다
|
||
# "이번에 안 본 규칙" 이 생겼고, 사장님이 화면에서 먼저 찾았다 —
|
||
# '아침 · 머뭄' 다음 칸이 '체크아웃 · 머뭄' 인 일정이 그렇게 나갔다.
|
||
# 검사가 빌드의 일부가 아니면 검사한 적 없는 것과 같다. 이제 어기면 payload 를 안 만든다.
|
||
FOOD_SLOTS = {"점심", "저녁", "늦은 점심"}
|
||
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}
|
||
|
||
|
||
def audit(built: list[dict]) -> list[str]:
|
||
bad: list[str] = []
|
||
|
||
def flag(item, day, text):
|
||
bad.append(f"{item['name']} / {day['label'] if day else '-'} — {text}")
|
||
|
||
for item in built:
|
||
want = DAYS_OF.get(item["duration"])
|
||
if want and len(item["days"]) != want:
|
||
flag(item, None, f"{item['duration']}인데 {len(item['days'])}일")
|
||
seen_all: list[str] = []
|
||
|
||
for day in item["days"]:
|
||
slots = [s["name"].split(" · ")[0] for s in day["stops"]]
|
||
places = [s["name"].split(" · ")[-1] for s in day["stops"]]
|
||
seen_all += [p for p in places if p != STAY]
|
||
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 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]:
|
||
flag(item, day, f"같은 자리 연속: {slots[i]} → {slots[i + 1]} ({places[i]})")
|
||
|
||
# ③ 가운데 머뭄 칸은 쉼·쉬는 날만
|
||
for i in range(1, len(places) - 1):
|
||
if places[i] == STAY and slots[i] not in REST_SLOTS:
|
||
flag(item, day, f"가운데 머뭄 칸이 쉼이 아니다 ({slots[i]})")
|
||
|
||
clock = _minutes(day["startTime"])
|
||
prev = BASE
|
||
for i, stop in enumerate(day["stops"]):
|
||
# ④ 이동시간은 좌표 계산값과 같아야 한다
|
||
real = 0 if i == 0 else move_minutes(prev, (stop["latitude"], stop["longitude"]))
|
||
if stop["moveMinutes"] != real:
|
||
flag(item, day, f"이동시간 {stop['moveMinutes']}분 (실거리 {real}분) — {places[i]}")
|
||
arrive = clock + stop["moveMinutes"]
|
||
|
||
# ⑤ 시간대가 정해진 자리
|
||
window = WINDOW.get(slots[i])
|
||
if window and not (window[0] <= arrive <= window[1]):
|
||
flag(item, day, f"{slots[i]} 도착 {arrive // 60}:{arrive % 60:02d} — 창 밖")
|
||
|
||
# ⑥ 마지막 날 첫 칸(아침 겸 퇴실)은 11시 전에 끝난다
|
||
if i == 0 and slots[0] == "아침" and last_day:
|
||
if arrive + stop["minutes"] > CHECK_OUT_BY:
|
||
end = arrive + stop["minutes"]
|
||
flag(item, day, f"퇴실이 {end // 60}:{end % 60:02d} — 11시를 넘긴다")
|
||
|
||
# ⑧ 끼니 자리에는 먹는 곳만
|
||
if slots[i] in FOOD_SLOTS and places[i] not in FOOD:
|
||
flag(item, day, f"{slots[i]} 자리에 식당이 아닌 {places[i]}")
|
||
if slots[i] == "밤 산책" and places[i] not in NIGHT_ONLY:
|
||
flag(item, day, f"밤 산책 자리에 {places[i]}")
|
||
|
||
clock = arrive + stop["minutes"]
|
||
prev = (stop["latitude"], stop["longitude"])
|
||
|
||
# ⑦ 21시를 넘기지 않는다(넘기면 렌더러가 그 칸을 조용히 버린다)
|
||
if clock > DAY_ENDS_BY:
|
||
flag(item, day, f"{clock // 60}:{clock % 60:02d} 종료 — 21시 초과")
|
||
|
||
# ⑮ 끼니 사이 최소 간격 (2026-09-11 검수에서 걸린 것)
|
||
# 창만 지키면 통과한다 — 늦은 점심 15:47 종료, 저녁 17:30 시작이 둘 다 합법이었다.
|
||
# 손님은 배가 안 꺼진 채 두 번째 상을 받는다.
|
||
walk = _minutes(day["startTime"])
|
||
last_meal = None
|
||
for sl, stop in zip(slots, day["stops"]):
|
||
walk += stop["moveMinutes"]
|
||
if sl in FOOD_SLOTS:
|
||
if last_meal is not None and walk - last_meal < MEAL_GAP:
|
||
flag(item, day, f"앞 끼니 끝나고 {walk - last_meal}분 만에 {sl}")
|
||
last_meal = walk + stop["minutes"]
|
||
walk += stop["minutes"]
|
||
|
||
# ⑯ 볼거리는 **가는 시간보다 오래 머문다**
|
||
# 끼니는 뺀다 — 밥집은 목적지가 아니라 돌아오는 길에 들르는 자리다.
|
||
# (섬에서 85분 달려와 55분 먹는 것은 이상하지 않다. 섬에 30분 있으면 이상하다.)
|
||
for sl, pl, stop in zip(slots, places, day["stops"]):
|
||
if pl == STAY or sl in FOOD_SLOTS:
|
||
continue
|
||
if stop["minutes"] < stop["moveMinutes"]:
|
||
flag(item, day, f"{pl}: 이동 {stop['moveMinutes']}분인데 체류 {stop['minutes']}분")
|
||
|
||
# ⑨ 같은 날 같은 식당 두 번
|
||
meals = [p for sl, p in zip(slots, places) if sl in FOOD_SLOTS]
|
||
if len(meals) != len(set(meals)):
|
||
flag(item, day, f"같은 날 같은 식당 반복 {meals}")
|
||
|
||
# ⑪⑫⑭ 테마가 약속한 성격
|
||
if "비가 와도" in item["name"]:
|
||
for sl, pl in zip(slots, places):
|
||
if sl in ("오전", "오후") and pl not in INDOOR:
|
||
flag(item, day, f"비 테마인데 실외 {pl}")
|
||
if "걸어서만" in item["name"] or "차 없이" in item["audience"]:
|
||
for pl in places:
|
||
if pl in NEEDS_CAR:
|
||
flag(item, day, f"걷는 테마인데 차가 필요한 {pl}")
|
||
if "아이와" in item["name"]:
|
||
for pl in places:
|
||
if pl in BAR:
|
||
flag(item, day, f"아이 동반인데 {pl}")
|
||
|
||
# ⑩ 한 테마 안에서 같은 곳을 두 번 가지 않는다
|
||
repeat = sorted({p for p in seen_all if seen_all.count(p) > 1})
|
||
if repeat:
|
||
flag(item, None, f"테마 안에서 반복 {repeat}")
|
||
|
||
return bad
|
||
|
||
|
||
problems = audit(items)
|
||
for line in problems:
|
||
print(" ✗", line)
|
||
if problems:
|
||
raise SystemExit(f"\n규칙 위반 {len(problems)}건 — payload 를 쓰지 않았다.")
|
||
|
||
for item in items:
|
||
for day in item["days"]:
|
||
clock = _minutes(day["startTime"])
|
||
for stop in day["stops"]:
|
||
clock += stop["moveMinutes"] + stop["minutes"]
|
||
print(f"{item['name'][:24]:26} {day['label']} {day['startTime']}–"
|
||
f"{clock // 60:02d}:{clock % 60:02d} {len(day['stops'])}칸")
|
||
|
||
DST.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||
print(f"\n테마 {len(items)}개 · 규칙 17종 위반 0건 · payload {DST.stat().st_size:,}B")
|