o2o-site-AEO/solution/site/scripts/mockup/fetch_restaurants.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

100 lines
4.7 KiB
Python

"""주변 맛집을 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())