/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
100 lines
4.7 KiB
Python
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())
|