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