"""구워진 payload 의 일정 21개를 표로 뽑고, 규칙 14종이 **안 보는** 논리까지 검수한다. ★ 왜 따로 있나 — 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*", 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 시작·끝이 숙소인가 if rows[0]["place"] != STAY: bad.append(("L8", f"{label}: 첫 칸이 {rows[0]['place']}")) if rows[-1]["place"] != STAY: bad.append(("L8", f"{label}: 마지막 칸이 {rows[-1]['place']}")) # 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 를 렌더러와 같은 방식으로 계산한 값**이다(화면에 뜨는 것과 같다).", "규칙 14종(만들 때 거는 것)은 `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분 넘는 이동이 없다 |", ""] 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 += ["**전부 통과.** 12개 항목 위반 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}")