o2o-site-AEO/solution/site/scripts/mockup/audit_schedule.py
Mina Choi 0da9b92423 [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
2026-09-14 17:07:01 +09:00

192 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""구워진 payload 의 일정 21개를 표로 뽑고, 규칙 17종이 **안 보는** 논리까지 검수한다.
★ 왜 따로 있나 — build_itinerary.py 의 audit() 는 "만들 때" 지키는 규칙이다.
이 스크립트는 **만들어진 결과**를 사람이 읽는 표로 펴고, 비율·간격처럼
규칙에 없던 것을 센다. 사장님이 화면에서 찾아내신 종류가 전부 여기 있다.
"""
import json, re, sys
from pathlib import Path
SP = Path(__file__).resolve().parent
html = (SP / "build" / "index.html").read_text(encoding="utf-8")
payload = json.loads(re.search(r"window\.__SITE_PAYLOAD__\s*=\s*(\{.*?\});?\s*</script>", html, re.S).group(1))
sec = next(s for s in payload["theme"]["sections"] if s["id"] == "itinerary")
items = json.loads(sec["data"])["items"]
STAY = "스테이 머뭄"
MEAL_SLOTS = {"점심", "늦은 점심", "저녁"}
def hm(m): return f"{m // 60:02d}:{m % 60:02d}"
def plan(day):
"""렌더러(shared planDay)와 같은 계산. 21시 넘는 칸은 렌더러가 버린다."""
clock = int(day["startTime"][:2]) * 60 + int(day["startTime"][3:])
rows, dropped = [], 0
for s in day["stops"]:
move = max(0, s.get("moveMinutes", 0))
stay = max(1, s.get("minutes", 60))
arrive = clock + move
if arrive + stay > 21 * 60:
dropped += 1
continue
rows.append({"name": s["name"], "slot": s["name"].split(" · ")[0],
"place": s["name"].split(" · ")[-1], "move": move, "stay": stay,
"from": arrive, "to": arrive + stay})
clock = arrive + stay
return rows, dropped
def check(item):
"""규칙 14종이 안 보는 논리. (코드, 설명) 목록을 돌려준다."""
bad = []
name = item["name"]
seen_places = []
for day in item["days"]:
rows, dropped = plan(day)
label = day["label"]
if dropped:
bad.append(("L0", f"{label}: 21시를 넘겨 화면에서 버려지는 칸 {dropped}"))
if not rows:
bad.append(("L0", f"{label}: 칸이 없다"))
continue
outs = [r for r in rows if r["place"] != STAY]
# L1 체류 ≥ 편도 이동 — 가는 데 든 시간보다 짧게 머물면 다녀온 의미가 없다.
# ★ 끼니는 뺀다. 밥집은 목적지가 아니라 **돌아오는 길에 들르는 자리**다 —
# 섬에서 85분 달려와 55분 먹는 것은 이상하지 않고, 섬에 30분 있는 것이 이상하다.
# (2026-09-11 사장님 지적의 실제 대상은 선유도였고, 선유도는 150분이다.)
for r in outs:
if r["slot"] in MEAL_SLOTS:
continue
if r["stay"] < r["move"]:
bad.append(("L1", f"{label} {r['place']}: 이동 {r['move']}분인데 체류 {r['stay']}"))
# L2 끼니 — 둘째 날부터는 점심과 저녁이 다 있어야 한다(마지막 날은 저녁 없이 떠난다)
meals = [r for r in rows if r["slot"] in MEAL_SLOTS]
is_first = label == item["days"][0]["label"]
is_last = label == item["days"][-1]["label"]
if not meals:
bad.append(("L2", f"{label}: 끼니가 하나도 없다"))
elif not is_first and not is_last:
slots = {r["slot"] for r in meals}
if not (slots & {"점심", "늦은 점심"}) or "저녁" not in slots:
bad.append(("L2", f"{label}: 점심/저녁 중 하나가 없다 ({sorted(slots)})"))
# L3 끼니 간격 — 점심과 저녁이 3시간 미만이면 배가 안 꺼진다
lunch = next((r for r in meals if r["slot"] in {"점심", "늦은 점심"}), None)
dinner = next((r for r in meals if r["slot"] == "저녁"), None)
if lunch and dinner and dinner["from"] - lunch["to"] < 180:
bad.append(("L3", f"{label}: 점심 끝나고 저녁까지 {dinner['from'] - lunch['to']}"))
# L4 하루 이동 비율 — 절반을 길에서 쓰면 그건 관광이 아니라 운전이다
span = rows[-1]["to"] - rows[0]["from"]
move_sum = sum(r["move"] for r in rows)
if span and move_sum / span > 0.40:
bad.append(("L4", f"{label}: 하루 {span}분 중 이동 {move_sum}분 ({move_sum / span:.0%})"))
# L5 너무 짧은 정거장 — 15분은 들렀다 가는 것도 아니다
for r in outs:
if r["stay"] < 15:
bad.append(("L5", f"{label} {r['place']}: 체류 {r['stay']}"))
# L6 하루 길이 — 14시간을 넘으면 손님이 못 따라온다
if span > 14 * 60:
bad.append(("L6", f"{label}: 하루가 {span // 60}시간 {span % 60}"))
# L7 첫날 입실 15:00 이후 · 마지막 날 퇴실 11:00 전
if is_first and rows[0]["place"] == STAY and rows[0]["from"] < 15 * 60:
bad.append(("L7", f"{label}: 입실이 {hm(rows[0]['from'])}"))
if is_last and rows[0]["place"] == STAY and rows[0]["to"] > 11 * 60:
bad.append(("L7", f"{label}: 퇴실이 {hm(rows[0]['to'])}"))
# L8 시작·끝이 숙소인가
# ★ 떠나는 날은 갈린다 (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 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"]:
bad.append(("L9", f"{label}: {rows[i]['place']} 가 연달아"))
seen_places += [r["place"] for r in outs]
# L10 테마 안에서 같은 곳 두 번
dup = {p for p in seen_places if seen_places.count(p) > 1}
if dup:
bad.append(("L10", f"테마 안에서 반복: {', '.join(sorted(dup))}"))
# L11 테마 이름·대상과 내용 — '걸어서'/'차 없이' 인데 편도 25분 넘는 이동이 있나
walkish = any(k in name + item.get("audience", "") for k in ("걸어서", "차 없이", "뚜벅"))
if walkish:
for day in item["days"]:
rows, _ = plan(day)
far = [r for r in rows if r["move"] > 25]
if far:
bad.append(("L11", f"걷는 테마인데 {far[0]['place']} 이동 {far[0]['move']}"))
return bad
lines = ["# `/s/stay` 여행 일정 전수 검수표", "",
f"생성 `python3 audit_schedule.py` · 테마 {len(items)}개 · "
f"{sum(len(i['days']) for i in items)}일 · "
f"{sum(len(d['stops']) for i in items for d in i['days'])}정거장", "",
"표는 **구워진 payload 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).",
"규칙 17종(만들 때 거는 것)은 `build_itinerary.py` `audit()` 가 이미 통과시켰고,",
"이 표는 그 규칙이 **안 보는 논리**를 따로 센다.", "",
"| 코드 | 검수 항목 |", "|---|---|",
"| L0 | 21시를 넘겨 화면에서 버려지는 칸이 없다 |",
"| L1 | **체류 ≥ 편도 이동** — 볼거리는 가는 데 든 시간보다 오래 머문다 (끼니 제외 — 밥집은 돌아오는 길에 들르는 자리다) |",
"| L2 | 가운데 날은 점심과 저녁이 둘 다 있다 |",
"| L3 | 점심 끝나고 저녁까지 3시간 이상 |",
"| L4 | 하루의 40% 넘게 길에서 쓰지 않는다 |",
"| L5 | 바깥 정거장 체류 15분 이상 |",
"| L6 | 하루가 14시간을 넘지 않는다 |",
"| L7 | 입실 15:00 이후 · 퇴실 11:00 전 |",
"| L8 | 하루의 첫 칸·마지막 칸이 숙소 (**떠나는 날 차 테마는 복귀 없이 끝난다**) |",
"| L9 | 같은 자리가 연달아 서지 않는다 |",
"| L10 | 한 테마 안에서 같은 곳을 두 번 가지 않는다 |",
"| L11 | 걷는 테마에 편도 25분 넘는 이동이 없다 |",
"| L12 | 떠나는 날의 끝맺음이 퇴실 문구와 같다 — 짐을 차에 실었으면 안 돌아오고, 맡겼으면 찾으러 온다 |", ""]
fails = []
for item in items:
bad = check(item)
mark = "✅ **검수완료**" if not bad else "⚠️ **확인필요**"
lines += ["---", "", f"## {item['name']}", "",
f"{item['duration']} · {item.get('audience', '')}{mark}", "",
f"> {item.get('why', '')}", ""]
for day in item["days"]:
rows, _ = plan(day)
span = rows[-1]["to"] - rows[0]["from"]
move_sum = sum(r["move"] for r in rows)
lines += [f"**{day['label']}** {hm(rows[0]['from'])}{hm(rows[-1]['to'])} "
f"(총 {span // 60}시간 {span % 60}분 · 이동 {move_sum}분 · 정거장 {len(rows)})", "",
"| | 시각 | 자리 | 장소 | 이동 | 체류 |", "|---|---|---|---|---|---|"]
for n, r in enumerate(rows, 1):
lines.append(f"| {n} | {hm(r['from'])}{hm(r['to'])} | {r['slot']} | {r['place']} | "
f"{r['move'] or ''}분 | {r['stay']}분 |")
lines.append("")
if bad:
fails.append((item["name"], bad))
lines += ["**확인이 필요한 것**", ""]
lines += [f"- `{code}` {msg}" for code, msg in bad]
lines.append("")
head = [f"## 결과 — {len(items) - len(fails)}/{len(items)} 검수완료", ""]
if fails:
head += ["| 테마 | 걸린 항목 |", "|---|---|"]
head += [f"| {n} | {', '.join(sorted({c for c, _ in b}))} |" for n, b in fails]
else:
head += ["**전부 통과.** 13개 항목 위반 0건."]
head.append("")
lines = lines[:8] + head + lines[8:]
(SP / "SCHEDULE.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"SCHEDULE.md · 검수완료 {len(items) - len(fails)}/{len(items)}")
for n, b in fails:
print(f" ⚠️ {n}")
for code, msg in b:
print(f" {code} {msg}")