feat(supporters): 회복 일정 플래너에 한국관광공사 TourAPI 데이터 합침 + 옛 주소 리다이렉트 + 404 페이지
- src/lib/tour.ts: 사전 수집 JSON(medicalTourismKo/medicalTourism)을 추천 후보로 변환. 관광지·문화시설→관광, 쇼핑, 음식점(일반식 단계에서만), 숙박→숙소. 사우나·스파는 회복기 금지라 제외. 거리로 외출 등급(600m·3km) - 큐레이션(places.json) 1순위, TourAPI 2순위 가점. 타임라인 추천에 이동 시간 라벨과 '관광공사' 표시 - 체류 기간과 겹치는 행사 섹션(가까운 순 6곳, 자유 외출 단계 도달일 표시), 자유 외출(L3) 날에 행사 1건 추가 - 숙소 그리드에 TourAPI 숙박 가까운 순 6곳 추가(예약 검색은 영문 이름으로) - scripts/fetch_medical_tourism.py: Productize 브랜치의 수집 스크립트 가져옴(화면 경로 주석 갱신) - vercel.json: /recovery·/stay·/en/recovery·/en/stay → /plan·/en/plan 영구 리다이렉트. src/pages/404.astro 사이트 스타일 404 - 테스트 14건(어댑터·우선순위·유동식 제외·축제) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
f9e91d3305
commit
83b8343262
585
scripts/fetch_medical_tourism.py
Normal file
585
scripts/fetch_medical_tourism.py
Normal file
@ -0,0 +1,585 @@
|
||||
#!/usr/bin/env python3
|
||||
"""의료관광 연결 데이터 수집 (한국관광공사 TourAPI 다국어). 병원별로 돈다.
|
||||
|
||||
병원 좌표를 중심으로 숙박·음식점·관광지·문화시설·쇼핑을 모으고, 진행 중인 축제를
|
||||
거리와 함께 붙여 프론트가 쓸 JSON 으로 낸다. 영문(EngService2)과 국문(KorService2)을
|
||||
한 번에 만들 수 있다.
|
||||
|
||||
왜 사전 수집인가
|
||||
영문 서비스는 도심 커버리지가 얇고 관광지 항목의 대부분이 의료기관이라 필터링이 많이
|
||||
필요하다. 그 처리를 수집 시점에 끝내고 프론트는 정제된 결과만 그린다. 미팅 데모 중
|
||||
외부 API 상태에 화면이 종속되지도 않는다.
|
||||
|
||||
무엇을 만들지 않는가
|
||||
없는 값을 채우지 않는다. 평점·리뷰·영업시간은 TourAPI 가 주지 않으므로 비워 두고,
|
||||
Google Places 승인 후 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서
|
||||
판정하지 않는다. 좌표도 지어내지 않는다. 주소를 좌표로 바꾸지 못하면 종료 코드 1 로 멈춘다.
|
||||
|
||||
기준 좌표를 정하는 순서
|
||||
1. --lat --lng 를 직접 주면 그대로 쓴다 (coordSource: 명령 인자).
|
||||
2. --address 와 --name 을 주면 네이버 지역검색(NAVER_CLIENT_ID/SECRET)으로 상호를 찾고,
|
||||
결과의 도로명 주소가 준 주소의 도로명·번지와 같을 때만 그 좌표를 쓴다.
|
||||
네이버가 못 찾으면 TourAPI 키워드 검색(국문·영문)으로 한 번 더 찾는다.
|
||||
3. 아무 인자도 없으면 .env 의 TOUR_API_ORIGIN_LAT/LNG (초기 샘플 사이트용).
|
||||
어느 경우든 meta.origin.coordSource 에 출처와 날짜를 적는다.
|
||||
|
||||
출력은 서포터즈 사이트(Astro)의 데이터다. 화면은 회복 일정 플래너 src/components/Planner.astro(/plan 국문, /en/plan 영문)이며
|
||||
src/lib/tour.ts 가 이 JSON 을 추천 후보·숙소·축제로 바꾼다. 외국인 환자와 보호자가 보는 곳이다.
|
||||
|
||||
# 워커가 부르는 형태 (팩트 시트 주소 기준, 영문·국문 동시)
|
||||
python3 scripts/fetch_medical_tourism.py --clinic oracle --name "오라클피부과" \
|
||||
--address "서울 강남구 선릉로 612 한일빌딩" --label "오라클피부과 (선릉로 612)" \
|
||||
--out-en ~/supporters-builds/oracle/tourism/medicalTourism.json \
|
||||
--out-ko ~/supporters-builds/oracle/tourism/medicalTourismKo.json
|
||||
# 좌표를 직접 줄 때
|
||||
python3 scripts/fetch_medical_tourism.py --lat 37.5049823 --lng 127.0254182 --label "..." --out-en a.json --out-ko b.json
|
||||
# 이전 방식 (.env 좌표, 언어 하나)
|
||||
python3 scripts/fetch_medical_tourism.py --lang ko --out supporters/src/data/medicalTourismKo.json
|
||||
"""
|
||||
import argparse, io, json, math, os, re, ssl, sys, time, urllib.parse, urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# 영문 관광지(76)의 대부분은 관광지가 아니라 의료관광 등록업소다. 반경 5km 100건 중 92건이
|
||||
# 분류코드 A02020500 = "Medical Tourism Sites"(categoryCode2 로 확인)이며 병원과 유치 에이전시가
|
||||
# 여기 들어 있다. 제목 키워드로는 "하이안과", "닥파인더코리아" 같은 것을 놓치므로 코드로 거른다.
|
||||
CAT_MEDICAL_TOURISM = "A02020500"
|
||||
# 스파·찜질방은 수술 후 회복 여정에 맞으므로 관광이 아니라 별도 범주로 뺀다.
|
||||
CAT_WELLNESS = ("A02020300", "A02020400") # Hot Springs & Spa, Jjimjilbang
|
||||
|
||||
# 다국어 ContentTypeId. 국문과 코드가 다르다.
|
||||
# 반경은 목적에 따라 다르다. 회복기에 매일 가는 곳(숙박·식사·스파)은 가깝게, 관광은 넓게 본다.
|
||||
SEASON = {3: "spring", 4: "spring", 5: "spring", 6: "summer", 7: "summer", 8: "summer",
|
||||
9: "autumn", 10: "autumn", 11: "autumn", 12: "winter", 1: "winter", 2: "winter"}
|
||||
|
||||
# ContentTypeId 는 다국어와 국문이 다르다. 같은 코드로 부르면 엉뚱한 것이 온다.
|
||||
TYPES_EN = {
|
||||
"76": ("attraction", "10000"),
|
||||
"78": ("culture", "10000"),
|
||||
"79": ("shopping", "10000"),
|
||||
"80": ("stay", "5000"),
|
||||
"82": ("restaurant", "5000"),
|
||||
}
|
||||
TYPES_KO = {
|
||||
"12": ("attraction", "10000"),
|
||||
"14": ("culture", "10000"),
|
||||
"38": ("shopping", "10000"),
|
||||
"32": ("stay", "5000"),
|
||||
"39": ("restaurant", "5000"),
|
||||
}
|
||||
# 언어별 타입 표는 collect() 가 lang 에 맞게 고른다.
|
||||
|
||||
|
||||
def ssl_ctx():
|
||||
try:
|
||||
import certifi; return ssl.create_default_context(cafile=certifi.where())
|
||||
except Exception:
|
||||
return ssl.create_default_context()
|
||||
|
||||
|
||||
def load_env(path):
|
||||
""".env 파일을 읽고, 같은 이름의 환경변수가 있으면 그 값을 우선한다.
|
||||
워커(run.mjs)나 클라우드 실행은 파일 없이 환경변수만 줄 수 있다."""
|
||||
d = {}
|
||||
if os.path.exists(path):
|
||||
for line in io.open(path, encoding="utf-8"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
k, v = line.split("=", 1); d[k.strip()] = v.strip().strip('"').strip("'")
|
||||
for k, v in os.environ.items():
|
||||
if k.startswith(("TOUR_API_", "NAVER_CLIENT_")) and v:
|
||||
d[k] = v
|
||||
return d
|
||||
|
||||
|
||||
def haversine_km(lat1, lng1, lat2, lng2):
|
||||
r = 6371.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
|
||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def call(env, op, extra, rows=100, retries=3, page=1):
|
||||
base, svc, key = env["TOUR_API_BASE"], env["TOUR_API_SERVICE"], env["TOUR_API_KEY"]
|
||||
p = {"MobileOS": "ETC", "MobileApp": "INFINITH", "_type": "json",
|
||||
"numOfRows": str(rows), "pageNo": str(page), **extra}
|
||||
url = f"{base}/{svc}/{op}?serviceKey={key}&" + urllib.parse.urlencode(p)
|
||||
for i in range(retries):
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30, context=ssl_ctx()) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
if body.lstrip().startswith("<"):
|
||||
raise RuntimeError(f"XML 응답(인증·한도 확인): {body[:160]}")
|
||||
j = json.loads(body)["response"]
|
||||
if j["header"].get("resultCode") != "0000":
|
||||
raise RuntimeError(f"{j['header'].get('resultCode')} {j['header'].get('resultMsg')}")
|
||||
b = j.get("body", {}); items = b.get("items")
|
||||
return b.get("totalCount", 0), ([] if not items or items == "" else items["item"])
|
||||
except Exception as e:
|
||||
if i == retries - 1: raise
|
||||
time.sleep(1.5 * (i + 1))
|
||||
|
||||
|
||||
def call_all(env, op, extra, max_pages=6, rows=100):
|
||||
"""전 페이지를 모은다. 관광지는 거리순 상위 100건이 거의 다 의료관광업소라 한 페이지로는 부족하다."""
|
||||
got, total = [], None
|
||||
for page in range(1, max_pages + 1):
|
||||
total, items = call(env, op, extra, rows=rows, page=page)
|
||||
got += items
|
||||
if not items or len(got) >= (total or 0): break
|
||||
time.sleep(0.2)
|
||||
return total, got
|
||||
|
||||
|
||||
def walk_min(m):
|
||||
"""도보 분. 80m/분(4.8km/h) 기준. 참고 사이트의 표기를 역산해 맞춘 값이다
|
||||
(135m→2분, 248m→3분, 297m→4분, 566m→7분)."""
|
||||
return max(1, round(m / 80))
|
||||
|
||||
|
||||
def drive_min(km):
|
||||
"""차 이동 분. 직선거리 km x 1.2. 역시 역산값이다(25km→30분, 33km→40분)."""
|
||||
return max(1, round(km * 1.2))
|
||||
|
||||
|
||||
def travel(m):
|
||||
"""이동 수단과 소요 시간.
|
||||
2km 까지는 걸을 수 있는 거리로 본다(약 25분). 그 위는 차·지하철이다.
|
||||
짧은 거리의 차 시간은 주차와 신호 때문에 의미가 없으므로 최소 5분으로 둔다."""
|
||||
if m is None: return None
|
||||
if m <= 2000: return {"mode": "walk", "minutes": walk_min(m), "label": f"{walk_min(m)} min walk"}
|
||||
mins = max(5, drive_min(m / 1000))
|
||||
return {"mode": "drive", "minutes": mins, "label": f"{mins} min by car"}
|
||||
|
||||
|
||||
def fetch_overview(env, content_id, cache):
|
||||
"""detailCommon2 의 overview(설명문)와 homepage. locationBasedList2 는 주지 않는다.
|
||||
설명문이 없으면 카드가 이름과 주소뿐이라 사람이 고를 수 없다."""
|
||||
if content_id in cache: return cache[content_id]
|
||||
try:
|
||||
_, rows = call(env, "detailCommon2", {"contentId": content_id}, rows=1)
|
||||
except Exception:
|
||||
cache[content_id] = ("", ""); return cache[content_id]
|
||||
if not rows: cache[content_id] = ("", ""); return cache[content_id]
|
||||
r = rows[0]
|
||||
ov = re.sub(r"<[^>]+>", " ", r.get("overview") or "")
|
||||
ov = re.sub(r"\s+", " ", ov).strip()
|
||||
hp = re.sub(r"<[^>]+>", " ", r.get("homepage") or "").strip()
|
||||
hp = (re.search(r"https?://\S+", hp) or [None])[0] if hp else ""
|
||||
cache[content_id] = (ov, hp or "")
|
||||
return cache[content_id]
|
||||
|
||||
|
||||
def fetch_image(env, content_id, cache):
|
||||
"""locationBasedList2 의 firstimage 가 비어 있을 때 detailImage2 로 한 번 더 찾는다.
|
||||
이미지 전용 오퍼레이션이라 목록 API 에 없는 사진이 여기 있다(도산공원 7장, 몽마르뜨공원 5장).
|
||||
실측: 사진 없던 38건 중 15건을 여기서 살렸다. 나머지 23건은 TourAPI 에 사진이 없다."""
|
||||
key = ("img", content_id)
|
||||
if key in cache: return cache[key]
|
||||
try:
|
||||
_, rows = call(env, "detailImage2", {"contentId": content_id, "imageYN": "Y"}, rows=5)
|
||||
except Exception:
|
||||
cache[key] = ""; return ""
|
||||
url = ""
|
||||
for r in rows or []:
|
||||
u = (r.get("originimgurl") or r.get("smallimageurl") or "").strip()
|
||||
if u: url = u.replace("http://", "https://", 1); break
|
||||
cache[key] = url
|
||||
return url
|
||||
|
||||
|
||||
def call_kor(env, op, extra, rows=10):
|
||||
"""국문 서비스(KorService2). 영문 항목에 사진이 없어도 국문 항목에는 있는 경우가 있다.
|
||||
같은 한국관광공사 사진이라 출처가 달라지지 않는다."""
|
||||
p = {"MobileOS": "ETC", "MobileApp": "INFINITH", "_type": "json",
|
||||
"numOfRows": str(rows), "pageNo": "1", **extra}
|
||||
url = f"{env['TOUR_API_BASE']}/KorService2/{op}?serviceKey={env['TOUR_API_KEY']}&" + urllib.parse.urlencode(p)
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=25, context=ssl_ctx()) as r:
|
||||
body = r.read().decode("utf-8", "replace")
|
||||
if body.lstrip().startswith("<"): return []
|
||||
j = json.loads(body)["response"]
|
||||
if j["header"].get("resultCode") != "0000": return []
|
||||
it = j["body"].get("items")
|
||||
return [] if not it or it == "" else it["item"]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def korean_name(title):
|
||||
m = re.search(r"\(([^)]+)\)\s*$", title or "")
|
||||
return (m.group(1) if m else (title or "")).strip()
|
||||
|
||||
|
||||
def fetch_image_kor(env, p):
|
||||
"""국문 서비스에서 같은 이름의 항목을 찾아 사진을 가져온다. 실측 23건 중 10건이 여기서 나왔다."""
|
||||
ko = korean_name(p.get("title"))
|
||||
if not re.search(r"[가-힣]", ko): return ""
|
||||
rows = []
|
||||
if p.get("lat"):
|
||||
rows = call_kor(env, "locationBasedList2",
|
||||
{"mapX": str(p["lng"]), "mapY": str(p["lat"]), "radius": "300", "keyword": ko})
|
||||
if not rows: rows = call_kor(env, "searchKeyword2", {"keyword": ko})
|
||||
key = ko.replace(" ", "")
|
||||
for r in rows:
|
||||
if key in (r.get("title") or "").replace(" ", ""):
|
||||
u = (r.get("firstimage") or r.get("firstimage2") or "").strip()
|
||||
if u: return u.replace("http://", "https://", 1)
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_image_og(url):
|
||||
"""업체 공식 홈페이지의 대표 이미지(og:image). 업체가 스스로 올린 사진이라 목록에 쓰기에 무리가 없다.
|
||||
검색 결과 이미지를 긁어 쓰면 식당·블로그·언론사의 저작물이라 환자용 페이지에 올릴 수 없다."""
|
||||
if not url or not url.startswith("http"): return ""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=12, context=ssl_ctx()) as r:
|
||||
head = r.read(120000).decode("utf-8", "replace")
|
||||
base = r.geturl()
|
||||
except Exception:
|
||||
return ""
|
||||
m = re.search(r'<meta[^>]+(?:property|name)=["\']og:image["\'][^>]*content=["\']([^"\']+)', head, re.I) \
|
||||
or re.search(r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+(?:property|name)=["\']og:image', head, re.I)
|
||||
if not m: return ""
|
||||
u = m.group(1).strip()
|
||||
if u.startswith("//"): u = "https:" + u
|
||||
elif u.startswith("/"):
|
||||
from urllib.parse import urlparse
|
||||
pr = urlparse(base); u = f"{pr.scheme}://{pr.netloc}{u}"
|
||||
return u if u.startswith("https://") else ""
|
||||
|
||||
|
||||
def usable_image(url):
|
||||
"""실제로 이미지가 오는지 열어 본다. og:image 가 이미지가 아니거나(빈 content-type)
|
||||
인스타 CDN 처럼 핫링크를 403 으로 막는 경우가 있어 화면에서 빈 칸이 된다."""
|
||||
if not url: return False
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||
with urllib.request.urlopen(req, timeout=10, context=ssl_ctx()) as r:
|
||||
return r.status == 200 and (r.headers.get("content-type") or "").startswith("image")
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def fetch_image_naver(env, p):
|
||||
"""마지막 폴백. Naver 이미지 검색 API.
|
||||
주의: 결과는 제3자 저작물이다. 내부 시연 목적으로만 쓰고, 공개 배포 전에는
|
||||
imageSource 가 'naver' 인 항목을 걷어내거나 라이선스가 있는 사진으로 교체한다.
|
||||
구글 HTML 크롤링 대신 이걸 쓰는 이유는 공식 API 라 차단되지 않고 결과가 일정해서다."""
|
||||
cid, sec = env.get("NAVER_CLIENT_ID"), env.get("NAVER_CLIENT_SECRET")
|
||||
if not (cid and sec): return ""
|
||||
ko = korean_name(p.get("title"))
|
||||
if not ko: return ""
|
||||
# 지역을 붙이면 정확도가 오르지만 결과가 아예 없는 경우가 있다. 넓혀 가며 시도한다.
|
||||
plain = re.sub(r"\s*\(.*\)\s*", "", p.get("title") or "").strip()
|
||||
for q in (f"{ko} 서울", ko, plain):
|
||||
if not q: continue
|
||||
qs = urllib.parse.urlencode({"query": q, "display": "3", "sort": "sim"})
|
||||
req = urllib.request.Request("https://openapi.naver.com/v1/search/image.json?" + qs,
|
||||
headers={"X-Naver-Client-Id": cid, "X-Naver-Client-Secret": sec})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15, context=ssl_ctx()) as r:
|
||||
items = json.loads(r.read().decode()).get("items", [])
|
||||
except Exception:
|
||||
continue
|
||||
for it in items:
|
||||
u = (it.get("link") or "").strip()
|
||||
if u.startswith("https://"): return u
|
||||
return ""
|
||||
|
||||
|
||||
def bucket(it):
|
||||
"""분류코드로 범주를 정한다. None 이면 버린다."""
|
||||
cat3 = (it.get("cat3") or "").strip()
|
||||
if cat3 == CAT_MEDICAL_TOURISM: return None
|
||||
if cat3 in CAT_WELLNESS: return "wellness"
|
||||
return "keep"
|
||||
|
||||
|
||||
def clean(it, lat, lng):
|
||||
"""TourAPI 원본에서 화면이 쓸 필드만 남긴다. mapx=경도, mapy=위도 (이름과 반대라 자주 틀린다)."""
|
||||
try:
|
||||
ilng, ilat = float(it.get("mapx") or 0), float(it.get("mapy") or 0)
|
||||
except ValueError:
|
||||
ilng = ilat = 0.0
|
||||
dist = it.get("dist")
|
||||
return {
|
||||
"id": it.get("contentid"),
|
||||
"title": (it.get("title") or "").strip(),
|
||||
"address": (it.get("addr1") or "").strip(),
|
||||
# TourAPI 는 이미지 URL 을 http 로 준다. https 사이트에서 혼합 콘텐츠로 차단되므로 올린다.
|
||||
# tong.visitkorea.or.kr 은 https 로도 같은 파일을 준다(실측 확인).
|
||||
"image": (lambda u: u.replace("http://", "https://", 1) if u else None)(
|
||||
it.get("firstimage") or it.get("firstimage2") or None),
|
||||
"imageSource": "tourapi" if (it.get("firstimage") or it.get("firstimage2")) else "",
|
||||
"tel": (it.get("tel") or "").strip() or None,
|
||||
"lat": ilat or None, "lng": ilng or None,
|
||||
"distanceM": round(float(dist)) if dist else (
|
||||
round(haversine_km(lat, lng, ilat, ilng) * 1000) if ilat and ilng else None),
|
||||
"travel": travel(round(float(dist)) if dist else (
|
||||
round(haversine_km(lat, lng, ilat, ilng) * 1000) if ilat and ilng else None)),
|
||||
# detailCommon2 로 따로 채운다(locationBasedList2 는 설명문을 주지 않는다).
|
||||
"overview": "", "homepage": "",
|
||||
# TourAPI 가 주지 않는 값. Google Places 승인 후 채운다. 지금은 비워 둔다.
|
||||
"rating": None, "reviewCount": None, "openingHours": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------- 기준 좌표 ----------
|
||||
|
||||
def road_key(address):
|
||||
"""주소에서 도로명과 건물번호를 뽑는다. "서울 강남구 선릉로 612 한일빌딩" → ("선릉로", "612").
|
||||
비교에만 쓴다. 없으면 (None, None)."""
|
||||
m = re.search(r"([가-힣A-Za-z0-9]*?(?:대로|로|길))\s*(\d+(?:-\d+)?)(?!\s*길)", address or "")
|
||||
return (m.group(1), m.group(2)) if m else (None, None)
|
||||
|
||||
|
||||
def addr_match(want, got):
|
||||
"""검색 결과 주소가 준 주소와 같은 도로명·번지인지. 도로명과 번지 사이 공백은 무시하고,
|
||||
번지 바로 뒤에 숫자나 '-' 가 붙으면(612 와 6120, 612-1) 다른 곳으로 본다.
|
||||
"봉은사로 107 1, 3-5층" 처럼 공백 뒤에 층이 오는 것은 같은 곳이다."""
|
||||
road, num = road_key(want)
|
||||
if not road: return False
|
||||
return re.search(re.escape(road) + r"\s*" + re.escape(num) + r"(?![\d-])", got or "") is not None
|
||||
|
||||
|
||||
def strip_tags(s):
|
||||
return re.sub(r"<[^>]+>", "", s or "").strip()
|
||||
|
||||
|
||||
def geocode_naver(env, name, address):
|
||||
"""네이버 지역검색으로 상호를 찾아 좌표를 얻는다. mapx/mapy 는 WGS84 경위도 x 10^7 이다.
|
||||
주소만으로는 결과가 없다(실측). 상호를 넣고, 결과의 도로명 주소가 준 주소와 같을 때만 받는다."""
|
||||
cid, sec = env.get("NAVER_CLIENT_ID"), env.get("NAVER_CLIENT_SECRET")
|
||||
if not (cid and sec): return None, "NAVER_CLIENT_ID/SECRET 없음"
|
||||
road, num = road_key(address)
|
||||
queries = []
|
||||
if road: queries.append(f"{name} {road} {num}")
|
||||
queries += [f"{name} {address}", name]
|
||||
tried = []
|
||||
for q in queries:
|
||||
qs = urllib.parse.urlencode({"query": q, "display": "5", "sort": "random"})
|
||||
req = urllib.request.Request("https://openapi.naver.com/v1/search/local.json?" + qs,
|
||||
headers={"X-Naver-Client-Id": cid, "X-Naver-Client-Secret": sec})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15, context=ssl_ctx()) as r:
|
||||
items = json.loads(r.read().decode()).get("items", [])
|
||||
except Exception as e:
|
||||
tried.append(f"'{q}': 요청 실패 {e}"); continue
|
||||
for it in items:
|
||||
got = it.get("roadAddress") or it.get("address") or ""
|
||||
if not addr_match(address, got): continue
|
||||
try:
|
||||
lng, lat = int(it["mapx"]) / 1e7, int(it["mapy"]) / 1e7
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
if not (33 <= lat <= 39 and 124 <= lng <= 132): continue
|
||||
return {"lat": lat, "lng": lng, "matched": strip_tags(it.get("title")), "matchedAddress": got,
|
||||
"coordSource": f"네이버 지역검색 '{strip_tags(it.get('title'))}' ({got}) {time.strftime('%Y-%m-%d')}"}, ""
|
||||
tried.append(f"'{q}': {len(items)}건, 주소 일치 없음")
|
||||
return None, "; ".join(tried)
|
||||
|
||||
|
||||
def geocode_tourapi(env, name, address):
|
||||
"""TourAPI 키워드 검색(국문·영문)으로 병원 항목을 찾는다. 등재된 병원만 나오므로 보조 수단이다."""
|
||||
tried = []
|
||||
for svc in ("KorService2", "EngService2"):
|
||||
e = dict(env, TOUR_API_SERVICE=svc)
|
||||
try:
|
||||
_, rows = call(e, "searchKeyword2", {"keyword": name}, rows=20, retries=1)
|
||||
except Exception as ex:
|
||||
tried.append(f"{svc}: 요청 실패 {ex}"); continue
|
||||
for it in rows:
|
||||
got = it.get("addr1") or ""
|
||||
if not addr_match(address, got): continue
|
||||
try:
|
||||
lng, lat = float(it.get("mapx") or 0), float(it.get("mapy") or 0)
|
||||
except ValueError:
|
||||
continue
|
||||
if not (33 <= lat <= 39 and 124 <= lng <= 132): continue
|
||||
return {"lat": lat, "lng": lng, "matched": (it.get("title") or "").strip(), "matchedAddress": got,
|
||||
"coordSource": f"한국관광공사 TourAPI {svc} contentid {it.get('contentid')} ({got}) {time.strftime('%Y-%m-%d')}"}, ""
|
||||
tried.append(f"{svc}: {len(rows)}건, 주소 일치 없음")
|
||||
return None, "; ".join(tried)
|
||||
|
||||
|
||||
def resolve_origin(env, a):
|
||||
"""기준 좌표와 출처. 못 정하면 (None, 이유)."""
|
||||
if a.lat is not None or a.lng is not None:
|
||||
if a.lat is None or a.lng is None: return None, "--lat 와 --lng 는 함께 줘야 합니다"
|
||||
return {"lat": a.lat, "lng": a.lng, "address": a.address or "",
|
||||
"coordSource": f"명령 인자 --lat/--lng {time.strftime('%Y-%m-%d')}"}, ""
|
||||
if a.address:
|
||||
if not a.name: return None, "--address 에는 --name(검색할 상호)이 필요합니다. 주소만으로는 지역검색 결과가 없습니다"
|
||||
road, num = road_key(a.address)
|
||||
if not road: return None, f"주소에서 도로명·번지를 찾지 못했습니다: {a.address}"
|
||||
found, why1 = geocode_naver(env, a.name, a.address)
|
||||
if found: return dict(found, address=a.address), ""
|
||||
found, why2 = geocode_tourapi(env, a.name, a.address)
|
||||
if found: return dict(found, address=a.address), ""
|
||||
return None, f"주소를 좌표로 바꾸지 못했습니다. 네이버 지역검색: {why1} / TourAPI: {why2}"
|
||||
lat, lng = env.get("TOUR_API_ORIGIN_LAT"), env.get("TOUR_API_ORIGIN_LNG")
|
||||
if not (lat and lng): return None, "기준 좌표가 없습니다. --address --name 또는 --lat --lng 를 주거나 .env 의 TOUR_API_ORIGIN_LAT/LNG 를 채우세요"
|
||||
return {"lat": float(lat), "lng": float(lng), "address": "",
|
||||
"coordSource": f".env TOUR_API_ORIGIN_LAT/LNG {time.strftime('%Y-%m-%d')}"}, ""
|
||||
|
||||
|
||||
# ---------- 수집 ----------
|
||||
|
||||
def collect(env, lang, origin, a):
|
||||
"""한 언어의 데이터를 모아 dict 로 돌려준다. 파일은 쓰지 않는다."""
|
||||
types = TYPES_KO if lang == "ko" else TYPES_EN
|
||||
env = dict(env, TOUR_API_SERVICE="KorService2" if lang == "ko" else "EngService2")
|
||||
lat, lng = origin["lat"], origin["lng"]
|
||||
print(f"\n[{lang}] {env['TOUR_API_SERVICE']} · 기준 {lat}, {lng}")
|
||||
|
||||
out = {"places": {}, "festivals": [], "filters": {}, "meta": {}}
|
||||
filtered_log = {}
|
||||
|
||||
wellness = []
|
||||
for ct, (name, radius) in types.items():
|
||||
total, rows = call_all(env, "locationBasedList2",
|
||||
{"mapX": str(lng), "mapY": str(lat), "radius": radius,
|
||||
"contentTypeId": ct, "arrange": "E"})
|
||||
kept, dropped = [], []
|
||||
for it in rows:
|
||||
b = bucket(it)
|
||||
if b is None:
|
||||
dropped.append((it.get("title") or "").strip()); continue
|
||||
(wellness if b == "wellness" else kept).append(clean(it, lat, lng))
|
||||
kept.sort(key=lambda x: x["distanceM"] if x["distanceM"] is not None else 10 ** 9)
|
||||
out["places"][name] = kept[:a.per_type]
|
||||
filtered_log[name] = {"apiTotal": total, "fetched": len(rows), "radiusM": int(radius),
|
||||
"droppedMedicalTourism": len(dropped), "kept": len(kept),
|
||||
"shown": len(out["places"][name]), "droppedSample": dropped[:5]}
|
||||
print(f" {name:<11} r={radius:>5}m · API {total:>4} · 받음 {len(rows):>3} · 의료관광업소 제외 {len(dropped):>3} · 담음 {len(out['places'][name])}")
|
||||
wellness.sort(key=lambda x: x["distanceM"] if x["distanceM"] is not None else 10 ** 9)
|
||||
out["places"]["wellness"] = wellness[:a.per_type]
|
||||
filtered_log["wellness"] = {"note": "관광지에서 스파·찜질방 분류만 분리", "shown": len(out["places"]["wellness"])}
|
||||
print(f" {'wellness':<11} 스파·찜질방 분리 · 담음 {len(out['places']['wellness'])}")
|
||||
|
||||
# 영문 축제는 areacode 필드가 비어 있어 지역 필터를 쓸 수 없다. 좌표로 거리를 계산해 거른다.
|
||||
today = time.strftime("%Y%m%d")
|
||||
total, rows = call(env, "searchFestival2", {"eventStartDate": today, "arrange": "A"}, rows=300)
|
||||
near, nocoord = [], 0
|
||||
for it in rows:
|
||||
c = clean(it, lat, lng)
|
||||
if c["lat"] is None: nocoord += 1; continue
|
||||
km = haversine_km(lat, lng, c["lat"], c["lng"])
|
||||
if km > a.festival_km: continue
|
||||
sd = it.get("eventstartdate") or ""
|
||||
c.update(startDate=sd, endDate=it.get("eventenddate"),
|
||||
distanceKm=round(km, 1), month=(int(sd[4:6]) if len(sd) == 8 else None),
|
||||
season=SEASON.get(int(sd[4:6]), "") if len(sd) == 8 else "")
|
||||
near.append(c)
|
||||
near.sort(key=lambda x: x["startDate"] or "")
|
||||
out["festivals"] = near[:a.per_type]
|
||||
print(f" festival API {total:>4}건 · 받음 {len(rows):>3} · 좌표없음 {nocoord} · {a.festival_km:.0f}km 이내 {len(near)}")
|
||||
|
||||
# 설명문은 항목마다 detailCommon2 를 한 번씩 부른다. 카드에 이름과 주소만 있으면 고를 수 없다.
|
||||
cache: dict = {}
|
||||
targets = [p for v in out["places"].values() for p in v] + out["festivals"]
|
||||
print(f" 설명문 수집 {len(targets)}건", end="", flush=True)
|
||||
for i, p in enumerate(targets):
|
||||
if not p.get("id"): continue
|
||||
ov, hp = fetch_overview(env, p["id"], cache)
|
||||
p["overview"] = ov[:600]
|
||||
p["homepage"] = hp
|
||||
if not p.get("image"):
|
||||
# 1) 같은 언어 detailImage2 → 2) 국문 서비스 → 3) 업체 홈페이지 og:image → 4) Naver 이미지 검색
|
||||
src = ""
|
||||
img = fetch_image(env, p["id"], cache)
|
||||
if img: src = "tourapi"
|
||||
if not img and lang == "en":
|
||||
img = fetch_image_kor(env, p); src = "tourapi-kor" if img else src
|
||||
if not img:
|
||||
cand = fetch_image_og(p.get("homepage") or "")
|
||||
if cand and usable_image(cand): img, src = cand, "homepage"
|
||||
if not img and not a.no_naver_images:
|
||||
img = fetch_image_naver(env, p); src = "naver" if img else src
|
||||
p["image"] = img or None
|
||||
p["imageSource"] = src
|
||||
if i % 20 == 0: print(".", end="", flush=True)
|
||||
time.sleep(0.12)
|
||||
got = sum(1 for p in targets if p["overview"])
|
||||
pic = sum(1 for p in targets if p.get("image"))
|
||||
print(f" 완료 · 설명문 {got}/{len(targets)} · 사진 {pic}/{len(targets)}")
|
||||
|
||||
# 도보 시간 구간별 건수. 화면의 필터 칩이 이 수를 쓴다.
|
||||
allp = [p for v in out["places"].values() for p in v]
|
||||
# 누적 기준이다. "15분 이내"는 5분 이내도 포함한다. 사람이 고르는 방식에 맞춘다.
|
||||
def walk_upto(mins):
|
||||
return sum(1 for p in allp if p.get("travel") and p["travel"]["mode"] == "walk"
|
||||
and p["travel"]["minutes"] <= mins)
|
||||
out["filters"] = {
|
||||
"total": len(allp),
|
||||
"walk5": walk_upto(5),
|
||||
"walk15": walk_upto(15),
|
||||
"walkAll": sum(1 for p in allp if p.get("travel") and p["travel"]["mode"] == "walk"),
|
||||
"drive": sum(1 for p in allp if not p.get("travel") or p["travel"]["mode"] != "walk"),
|
||||
}
|
||||
|
||||
out["meta"] = {
|
||||
"lang": lang,
|
||||
"clinic": a.clinic or "",
|
||||
"source": "한국관광공사 TourAPI " + env["TOUR_API_SERVICE"],
|
||||
"fetchedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"origin": {"lat": lat, "lng": lng, "label": a.label or origin.get("matched") or origin.get("address") or "",
|
||||
"address": origin.get("address", ""), "coordSource": origin["coordSource"]},
|
||||
"radiusByType": {n: int(r) for n, r in types.values()}, "festivalRadiusKm": a.festival_km,
|
||||
"counts": filtered_log,
|
||||
# 화면이 한계를 그대로 표시할 수 있게 데이터에 적어 둔다. 없는 값을 채우지 않는다.
|
||||
"limits": [
|
||||
"영문 서비스는 도심 숙박·음식점 커버리지가 국문보다 얇다.",
|
||||
"관광지 항목 중 분류코드 A02020500 \"Medical Tourism Sites\"(병원·유치업체)는 제외했다.",
|
||||
"평점·리뷰·영업시간은 TourAPI 가 제공하지 않는다. Google Places 승인 후 채운다.",
|
||||
"영문 축제는 areacode 필드가 비어 있어 좌표 거리로 걸렀다.",
|
||||
"수술 전후 식이 적합성은 병원이 입력한 가이드로만 표시한다. 이 파일에는 없다.",
|
||||
],
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="병원 주변 관광 데이터 수집 (TourAPI)")
|
||||
ap.add_argument("--clinic", default="", help="병원 id (meta.clinic)")
|
||||
ap.add_argument("--name", default="", help="지역검색에 쓸 상호. --address 와 함께 준다")
|
||||
ap.add_argument("--address", default="", help="병원 주소. 네이버 지역검색으로 좌표를 찾는다")
|
||||
ap.add_argument("--lat", type=float, default=None); ap.add_argument("--lng", type=float, default=None)
|
||||
ap.add_argument("--label", default="", help="화면에 보일 기준점 이름 (meta.origin.label)")
|
||||
ap.add_argument("--out-en", default="", help="영문 JSON 경로 (절대 또는 현재 폴더 기준)")
|
||||
ap.add_argument("--out-ko", default="", help="국문 JSON 경로")
|
||||
ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다")
|
||||
ap.add_argument("--per-type", type=int, default=24, help="타입별 최대 건수")
|
||||
ap.add_argument("--no-naver-images", action="store_true", help="네이버 이미지 검색 폴백을 끈다 (공개 배포용)")
|
||||
ap.add_argument("--lang", choices=["en", "ko"], default="en", help="(이전 방식) --out 하나만 낼 때의 언어")
|
||||
ap.add_argument("--out", default="", help="(이전 방식) 저장소 루트 기준 경로")
|
||||
a = ap.parse_args()
|
||||
|
||||
env = load_env(os.path.join(ROOT, ".env"))
|
||||
for k in ("TOUR_API_KEY", "TOUR_API_BASE"):
|
||||
if not env.get(k): sys.exit(f"{k} 가 비어 있습니다 (.env 또는 환경변수)")
|
||||
|
||||
origin, why = resolve_origin(env, a)
|
||||
if not origin:
|
||||
print(f"기준 좌표 실패: {why}", file=sys.stderr); sys.exit(1)
|
||||
print(f"기준 좌표 {origin['lat']}, {origin['lng']} · {origin['coordSource']}")
|
||||
|
||||
jobs = []
|
||||
if a.out_en: jobs.append(("en", os.path.abspath(a.out_en)))
|
||||
if a.out_ko: jobs.append(("ko", os.path.abspath(a.out_ko)))
|
||||
if not jobs:
|
||||
rel = a.out or ("supporters/src/data/medicalTourism.json" if a.lang == "en" else "supporters/src/data/medicalTourismKo.json")
|
||||
jobs.append((a.lang, os.path.join(ROOT, rel)))
|
||||
|
||||
for lang, path in jobs:
|
||||
out = collect(env, lang, origin, a)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
io.open(path, "w", encoding="utf-8").write(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
print(f"→ {path} · 장소 {out['filters']['total']} · 축제 {len(out['festivals'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -2,10 +2,12 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildPlan, addDays, decodeHash, encodeHash, bookingUrl, googleFlightsUrl, buildIcs } from '../../src/lib/plan.ts';
|
||||
import { tourToPlaces, festivalsDuring, levelByDistance, bookingName } from '../../src/lib/tour.ts';
|
||||
|
||||
const here = (p) => new URL(p, import.meta.url);
|
||||
const P = JSON.parse(readFileSync(here('../../src/data/procedures.json'), 'utf8'));
|
||||
const PL = JSON.parse(readFileSync(here('../../src/data/places.json'), 'utf8'));
|
||||
const TKO = JSON.parse(readFileSync(here('../../src/data/medicalTourismKo.json'), 'utf8'));
|
||||
const byId = (id) => P.procedures.find((x) => x.id === id);
|
||||
const nat = (code) => P.nationalities.list.find((x) => x.code === code);
|
||||
|
||||
@ -95,4 +97,30 @@ ok('규칙표 정합성: 숫자마다 status, provisional 은 source 없어도
|
||||
for (const pl of PL.places) { assert.ok(PL.categories[pl.category], `${pl.id} 카테고리`); assert.ok(pl.name.ko && pl.name.en, `${pl.id} 이름`); }
|
||||
});
|
||||
|
||||
ok('TourAPI 어댑터: 카테고리 매핑, 사우나 제외, 음식점은 일반식만, 거리 등급', () => {
|
||||
const tp = tourToPlaces(TKO, 'ko');
|
||||
assert.ok(tp.length > 100, `변환 ${tp.length}건`);
|
||||
assert.ok(tp.every((p) => p.source === 'tourapi'));
|
||||
assert.ok(!tp.some((p) => p.id.startsWith('tour-wellness-')), '사우나·스파가 후보에 들어감');
|
||||
assert.ok(tp.filter((p) => p.category === 'food').every((p) => p.dietOk.length === 1 && p.dietOk[0] === 'normal'));
|
||||
assert.equal(levelByDistance(400), 1); assert.equal(levelByDistance(2500), 2); assert.equal(levelByDistance(9000), 3);
|
||||
assert.equal(bookingName('K-그랜드 호스텔 강남 1(K-GRAND HOSTEL GANGNAM1)'), 'K-GRAND HOSTEL GANGNAM1');
|
||||
});
|
||||
|
||||
ok('큐레이션이 TourAPI 보다 먼저 나오고 유동식 단계에는 TourAPI 음식점이 없음', () => {
|
||||
const tp = tourToPlaces(TKO, 'ko');
|
||||
const all = [...PL.places.map((x) => ({ ...x, source: 'curated' })), ...tp];
|
||||
const plan = buildPlan({ procedure: byId('contour'), surgeryDate: '2026-11-03', nationality: null, prefs: ['food'] }, all);
|
||||
for (const d of plan.days.filter((d) => d.diet === 'liquid')) for (const p of d.picks) assert.ok(!(p.source === 'tourapi' && p.category === 'food'), `${d.date} ${p.id}`);
|
||||
const first = plan.days.find((d) => d.picks.length);
|
||||
assert.equal(first.picks[0].source, 'curated');
|
||||
});
|
||||
|
||||
ok('축제: 체류 기간과 겹치는 것만, L3 날에만 붙음', () => {
|
||||
const fest = festivalsDuring(TKO, '2026-11-01', '2026-11-17');
|
||||
for (const f of fest) assert.ok(f.start <= '2026-11-17' && f.end >= '2026-11-01');
|
||||
const plan = buildPlan({ procedure: byId('skin'), surgeryDate: '2026-11-03', nationality: null, prefs: [], festivals: fest }, PL.places);
|
||||
for (const d of plan.days) if (d.festivals.length) assert.ok(d.level >= 3, `${d.date} L${d.level}`);
|
||||
});
|
||||
|
||||
console.log(`plan.test: ${n}건 통과`);
|
||||
|
||||
@ -6,12 +6,16 @@ import '../styles/plan.css';
|
||||
import strings from '../data/planStrings.json';
|
||||
import P from '../data/procedures.json';
|
||||
import PL from '../data/places.json';
|
||||
import TKO from '../data/medicalTourismKo.json';
|
||||
import TEN from '../data/medicalTourism.json';
|
||||
import { tourToPlaces } from '../lib/tour';
|
||||
interface Props { lang: 'ko' | 'en' }
|
||||
const { lang } = Astro.props;
|
||||
const t = strings[lang];
|
||||
const tour = tourToPlaces((lang === 'en' ? TEN : TKO) as any, lang);
|
||||
const nProcs = P.procedures.length;
|
||||
const nPlaces = PL.places.filter((x) => x.category !== 'hotel').length;
|
||||
const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
|
||||
const nPlaces = PL.places.filter((x) => x.category !== 'hotel').length + tour.filter((x) => x.category !== 'hotel').length;
|
||||
const nHotels = PL.places.filter((x) => x.category === 'hotel').length + tour.filter((x) => x.category === 'hotel').length;
|
||||
---
|
||||
<section class="section dark plan-hero">
|
||||
<div class="wrap-wide">
|
||||
@ -39,12 +43,19 @@ const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
|
||||
import PL from '../data/places.json';
|
||||
import STR from '../data/planStrings.json';
|
||||
import factSheet from '../data/factSheet.json';
|
||||
import TKO from '../data/medicalTourismKo.json';
|
||||
import TEN from '../data/medicalTourism.json';
|
||||
import { tourToPlaces, festivalsDuring } from '../lib/tour';
|
||||
import { buildPlan, encodeHash, decodeHash, bookingUrl, googleFlightsUrl, mapUrl, buildIcs, summaryText, isIsoDate, addDays } from '../lib/plan';
|
||||
import type { Lang, Procedure, Place, Nationality, Plan, PlanDay } from '../lib/plan';
|
||||
|
||||
type Strings = Record<string, string>;
|
||||
const procedures = P.procedures as unknown as Procedure[];
|
||||
const places = PL.places as unknown as Place[];
|
||||
const curated = (PL.places as unknown as Place[]).map((x) => ({ ...x, source: 'curated' as const }));
|
||||
const root0 = document.getElementById('planner')!;
|
||||
const tourData = ((root0.dataset.lang === 'en' ? TEN : TKO) as any);
|
||||
const places: Place[] = [...curated, ...tourToPlaces(tourData, root0.dataset.lang === 'en' ? 'en' : 'ko')];
|
||||
const tourDate: string = String(tourData?.meta?.fetchedAt ?? '').slice(0, 10);
|
||||
const nationalities = P.nationalities.list as Nationality[];
|
||||
const PREFS = ['sightseeing', 'shopping', 'food', 'kpop', 'care'];
|
||||
const root = document.getElementById('planner')!;
|
||||
@ -134,7 +145,8 @@ const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
|
||||
}
|
||||
function pickHtml(p: Place): string {
|
||||
const t = T();
|
||||
const meta = `<small>${p.distanceKm <= 0.6 ? esc(t.walk) : esc(t.taxi)} ${esc(fill(t.km, { km: p.distanceKm }))}${p.indoor ? ' · ' + esc(t.indoor) : ''}</small>`;
|
||||
const dist = p.travelLabel ? esc(p.travelLabel) : `${p.distanceKm <= 0.6 ? esc(t.walk) : esc(t.taxi)} ${esc(fill(t.km, { km: p.distanceKm }))}`;
|
||||
const meta = `<small>${dist}${p.indoor ? ' · ' + esc(t.indoor) : ''}${p.source === 'tourapi' ? ' · ' + esc(t.tour_badge) : ''}</small>`;
|
||||
return p.mapQuery ? `<a href="${mapUrl(p.mapQuery)}" rel="noopener" target="_blank" title="${L(p.note)}">${L(p.name)} ${meta}</a>` : `<span class="pick" title="${L(p.note)}">${L(p.name)} ${meta}</span>`;
|
||||
}
|
||||
|
||||
@ -198,7 +210,8 @@ const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
|
||||
const days = plan.days.map((d) => {
|
||||
const ev = eventLabel(d);
|
||||
const lvl = lv(d.level);
|
||||
const picks = d.picks.length ? `<div class="picks">${d.picks.map(pickHtml).join('')}</div>` : d.level === 0 || d.isSurgery ? `<div class="rest">${esc(t.noPicks)}</div>` : '';
|
||||
const festPicks = d.festivals.map((f) => `<a href="#fest-${esc(f.id)}" class="fest"><span class="tag l3">${esc(t.festival_pick)}</span> ${esc(f.title)}</a>`).join('');
|
||||
const picks = d.picks.length || festPicks ? `<div class="picks">${festPicks}${d.picks.map(pickHtml).join('')}</div>` : d.level === 0 || d.isSurgery ? `<div class="rest">${esc(t.noPicks)}</div>` : '';
|
||||
const dietTag = d.offset >= 0 && d.diet !== 'normal' ? `<span class="tag diet" title="${L(diets[d.diet].desc)}">${esc(t.diet)}: ${L(diets[d.diet].label)}</span>` : '';
|
||||
return `<div class="day ${ev ? 'mark' : ''}"><div class="d"><b>${esc(fmtDate(d.date))}</b><span>${esc(dayLabel(d))}</span>${ev ? `<span class="ev">${esc(ev)}</span>` : ''}</div><div class="body"><div class="tags"><span class="tag l${d.level}">${esc(t.level)}: ${L(lvl.label)}</span>${dietTag}<span class="desc">${L(lvl.desc)}</span></div>${picks}</div></div>`;
|
||||
}).join('');
|
||||
@ -208,10 +221,19 @@ const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
|
||||
<div class="timeline">${days}</div>
|
||||
</div></section>`;
|
||||
|
||||
// 다크 섹션: 예약과 저장
|
||||
// 라이트 섹션: 체류 기간의 행사 (TourAPI)
|
||||
const firstL3 = plan.days.find((d) => d.level >= 3)?.date ?? null;
|
||||
const festCards = lastFest.map((f) => {
|
||||
const from = firstL3 && firstL3 <= f.end ? fill(t.fest_from, { d: fmtDate(firstL3 < f.start ? f.start : firstL3) }) : t.fest_notYet;
|
||||
return `<div class="card fest-card" id="fest-${esc(f.id)}">${f.image ? `<img src="${esc(f.image)}" alt="${esc(f.title)}" loading="lazy" onerror="this.remove()" />` : ''}<div class="cat">${esc(fill(t.fest_when, { a: f.start.slice(5).replace('-', '.'), b: f.end.slice(5).replace('-', '.') }))} · ${esc(f.travelLabel ?? fill(t.km, { km: f.distanceKm }))}</div><h3>${esc(f.title)}</h3><p>${esc(f.blurb)}</p><p class="fine"><span class="tag ${firstL3 && firstL3 <= f.end ? 'l3' : 'l0'}">${esc(from)}</span> ${f.url ? `<a href="${esc(f.url)}" rel="noopener" target="_blank">${esc(t.readMoreShort)}</a> · ` : ''}<a href="${mapUrl(f.title)}" rel="noopener" target="_blank">${esc(t.map)}</a></p></div>`;
|
||||
}).join('');
|
||||
const festSec = `<section class="section light"><div class="wrap-wide">${secHead('Events', t.h_fest, t.sub_fest)}${festCards ? `<div class="grid">${festCards}</div>` : `<p class="sub" style="color:var(--slate-500)">${esc(t.fest_none)}</p>`}<p class="fine" style="margin-top:1rem">${esc(fill(t.source_tour, { date: tourDate }))}</p></div></section>`;
|
||||
|
||||
// 다크 섹션: 예약과 저장 (큐레이션 숙소 + TourAPI 숙박 가까운 순 6곳)
|
||||
const checkin = plan.arriveBy, checkout = plan.earliestDeparture ?? addDays(plan.surgeryDate, 14);
|
||||
const hotels = places.filter((x) => x.category === 'hotel').sort((a, b) => a.distanceKm - b.distanceKm).map((h) => `
|
||||
<div class="card"><div class="cat">${L(h.area)} · ${esc(fill(t.km, { km: h.distanceKm }))}</div><h3>${L(h.name)}</h3><p>${L(h.note)}</p>
|
||||
const hotelList = [...places.filter((x) => x.category === 'hotel' && x.source !== 'tourapi'), ...places.filter((x) => x.category === 'hotel' && x.source === 'tourapi').sort((a, b) => a.distanceKm - b.distanceKm).slice(0, 6)];
|
||||
const hotels = hotelList.map((h) => `
|
||||
<div class="card"><div class="cat">${L(h.area)} · ${esc(h.travelLabel ?? fill(t.km, { km: h.distanceKm }))}${h.source === 'tourapi' ? ' · ' + esc(t.tour_badge) : ''}</div><h3>${L(h.name)}</h3><p>${L(h.note)}</p>
|
||||
<div class="links"><a href="${bookingUrl(h.bookingQuery ?? (h.name as any).en, checkin, checkout, state.lang)}" rel="noopener sponsored" target="_blank">${esc(t.hotelSearch)}</a>${h.mapQuery ? `<a href="${mapUrl(h.mapQuery)}" rel="noopener" target="_blank">${esc(t.map)}</a>` : ''}</div></div>`).join('');
|
||||
const srcs = p.sources.map((id) => (P as any).clinicSources.find((s: any) => s.id === id)).filter(Boolean).map((s: any) => `<li><a href="${esc(s.url)}" rel="noopener" target="_blank">${L(s.label)}</a></li>`).join('');
|
||||
const bookSec = `<section class="section dark"><div class="wrap-wide">
|
||||
@ -224,19 +246,22 @@ const nHotels = PL.places.filter((x) => x.category === 'hotel').length;
|
||||
<div class="card"><div class="cat">Save</div><h3>${esc(t.ics)}</h3><p>${esc(t.hotelHint)}</p><div class="plan-actions"><button type="button" class="btn ghost small" data-action="ics">${esc(t.ics)}</button><button type="button" class="btn ghost small" data-action="share">${esc(t.shareLink)}</button><button type="button" class="btn ghost small" data-action="print">${esc(t.print)}</button></div></div>
|
||||
</div>
|
||||
<p class="disclosure">${esc(t.affiliate)} ${esc(t.medical)}</p>
|
||||
${srcs ? `<p class="note" style="margin-top:0.6rem">${esc(t.sourceLabel)}</p><ul class="sources-list">${srcs}</ul>` : ''}
|
||||
<p class="note" style="margin-top:0.6rem">${esc(t.sourceLabel)}</p><ul class="sources-list">${srcs}<li>${esc(fill(t.source_tour, { date: tourDate }))}</li></ul>
|
||||
</div></section>`;
|
||||
|
||||
return scheduleSec + rulesSec + daysSec + bookSec;
|
||||
return scheduleSec + rulesSec + daysSec + festSec + bookSec;
|
||||
}
|
||||
|
||||
let lastPlan: Plan | null = null;
|
||||
let lastFest: ReturnType<typeof festivalsDuring> = [];
|
||||
function render() {
|
||||
const p = currentProc();
|
||||
lastPlan = null;
|
||||
let result = renderEmpty();
|
||||
if (p && isIsoDate(state.date)) {
|
||||
lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs }, places);
|
||||
const pre = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs }, places);
|
||||
lastFest = festivalsDuring(tourData, pre.arriveBy, pre.earliestDeparture ?? addDays(pre.surgeryDate, 14));
|
||||
lastPlan = buildPlan({ procedure: p, surgeryDate: state.date, nationality: currentNat(), prefs: state.prefs, festivals: lastFest }, places);
|
||||
result = renderResult(lastPlan, p);
|
||||
}
|
||||
root.innerHTML = renderInput() + result;
|
||||
|
||||
2852
supporters/src/data/medicalTourism.json
Normal file
2852
supporters/src/data/medicalTourism.json
Normal file
File diff suppressed because it is too large
Load Diff
3311
supporters/src/data/medicalTourismKo.json
Normal file
3311
supporters/src/data/medicalTourismKo.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -92,7 +92,7 @@
|
||||
"stat_procs": "시술",
|
||||
"stat_procs_d": "가슴·윤곽·눈·코·지방·리프팅·피부·모발",
|
||||
"stat_places": "추천 장소",
|
||||
"stat_places_d": "신논현역 기준 도보·택시 거리와 실내 여부를 함께 표시",
|
||||
"stat_places_d": "큐레이션과 한국관광공사 TourAPI. 신논현역 기준 거리·이동 시간 표시",
|
||||
"stat_hotels": "근처 숙소",
|
||||
"stat_hotels_d": "병원 도보 10분 안, 엘리베이터·룸서비스·24시간 프런트",
|
||||
"stat_privacy": "서버 저장",
|
||||
@ -105,7 +105,17 @@
|
||||
"empty_sub": "수술일은 예상일이어도 됩니다.",
|
||||
"stickyView": "일정표 보기",
|
||||
"stickyStay": "{a} → {b} · {n}박",
|
||||
"h_step1_hint": "국적을 고르면 시차와 무비자 체류 한도를 함께 봅니다."
|
||||
"h_step1_hint": "국적을 고르면 시차와 무비자 체류 한도를 함께 봅니다.",
|
||||
"h_fest": "Festivals During Your Stay",
|
||||
"sub_fest": "체류 기간과 겹치는 행사입니다. 자유 외출 단계가 되는 날부터 갈 수 있습니다.",
|
||||
"fest_none": "체류 기간과 겹치는 행사가 없습니다.",
|
||||
"fest_when": "{a} ~ {b}",
|
||||
"fest_from": "{d}부터 가능",
|
||||
"fest_notYet": "이번 체류에서는 자유 외출 단계에 닿지 않습니다",
|
||||
"source_tour": "장소·행사 데이터: 한국관광공사 TourAPI, {date} 기준. 영업 여부와 행사 일정은 방문 전에 확인하세요.",
|
||||
"tour_badge": "관광공사",
|
||||
"festival_pick": "행사",
|
||||
"readMoreShort": "홈페이지"
|
||||
},
|
||||
"en": {
|
||||
"eyebrow": "Recovery Planner",
|
||||
@ -199,7 +209,7 @@
|
||||
"stat_procs": "procedures",
|
||||
"stat_procs_d": "Breast, contouring, eyes, nose, fat, lifting, skin, hair",
|
||||
"stat_places": "curated places",
|
||||
"stat_places_d": "With walk or taxi distance from Sinnonhyeon Station and indoor flags",
|
||||
"stat_places_d": "Curated picks plus Korea Tourism Organization data, with distance and travel time from Sinnonhyeon Station",
|
||||
"stat_hotels": "hotels nearby",
|
||||
"stat_hotels_d": "Within a 10-minute walk, with elevators, room service and 24-hour desks",
|
||||
"stat_privacy": "stored on servers",
|
||||
@ -212,6 +222,16 @@
|
||||
"empty_sub": "An estimated date is fine.",
|
||||
"stickyView": "See schedule",
|
||||
"stickyStay": "{a} → {b} · {n} nights",
|
||||
"h_step1_hint": "Your nationality sets the jet-lag buffer and the visa-free stay check."
|
||||
"h_step1_hint": "Your nationality sets the jet-lag buffer and the visa-free stay check.",
|
||||
"h_fest": "Festivals During Your Stay",
|
||||
"sub_fest": "Events that overlap your stay. You can go from the first full-day-out date.",
|
||||
"fest_none": "No events overlap your stay.",
|
||||
"fest_when": "{a} to {b}",
|
||||
"fest_from": "From {d}",
|
||||
"fest_notYet": "Your stay does not reach the full-day-out stage",
|
||||
"source_tour": "Places and events: Korea Tourism Organization TourAPI, as of {date}. Check opening status and event dates before you go.",
|
||||
"tour_badge": "KTO",
|
||||
"festival_pick": "Event",
|
||||
"readMoreShort": "Website"
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,11 +16,13 @@ export interface Procedure {
|
||||
export interface Place {
|
||||
id: string; name: L10n; category: string; minLevel: number; dietOk?: string[]; distanceKm: number; indoor: boolean;
|
||||
area: L10n; note: L10n; mapQuery: string | null; bookingQuery?: string; verify: boolean;
|
||||
source?: 'curated' | 'tourapi'; travelLabel?: string; image?: string; url?: string;
|
||||
}
|
||||
export interface Nationality { code: string; label: L10n; visaFreeDays: number | null; longHaul: boolean }
|
||||
|
||||
export interface PlanInput { procedure: Procedure; surgeryDate: string; nationality?: Nationality | null; prefs: string[] }
|
||||
export interface PlanDay { date: string; offset: number; level: number; diet: DietStep['type']; picks: Place[]; isSurgery: boolean; isHospital: boolean; isStitch: boolean; isArrival: boolean; isDeparture: boolean }
|
||||
export interface PlanFestival { id: string; title: string; start: string; end: string }
|
||||
export interface PlanInput { procedure: Procedure; surgeryDate: string; nationality?: Nationality | null; prefs: string[]; festivals?: PlanFestival[] }
|
||||
export interface PlanDay { date: string; offset: number; level: number; diet: DietStep['type']; picks: Place[]; festivals: PlanFestival[]; isSurgery: boolean; isHospital: boolean; isStitch: boolean; isArrival: boolean; isDeparture: boolean }
|
||||
export interface Plan {
|
||||
arriveBy: string; surgeryDate: string; dischargeDate: string | null; stitchDate: string | null; earliestDeparture: string | null;
|
||||
stayNights: number | null; preOpDays: number; days: PlanDay[];
|
||||
@ -57,6 +59,7 @@ export function scorePlace(place: Place, level: number, diet: DietStep['type'],
|
||||
if (place.category === 'food' && place.dietOk && !place.dietOk.includes(diet)) return -1;
|
||||
let s = 0;
|
||||
if (prefs.length === 0 || prefs.includes(place.category)) s += 3;
|
||||
if (place.source !== 'tourapi') s += 0.75; // 큐레이션(1순위)이 같은 조건이면 먼저 나온다
|
||||
if (place.category === 'essentials' || place.category === 'care') s += 1; // 회복에 직접 필요한 것은 기본 가점
|
||||
s += 2 - Math.min(2, level - place.minLevel); // 오늘 등급에 맞는 곳을 우선
|
||||
s += Math.max(0, 1.5 - place.distanceKm / 4); // 가까울수록 가점(최대 1.5)
|
||||
@ -74,7 +77,7 @@ export function pickForDay(places: Place[], level: number, diet: DietStep['type'
|
||||
}
|
||||
|
||||
export function buildPlan(input: PlanInput, places: Place[]): Plan {
|
||||
const { procedure: p, surgeryDate, nationality, prefs } = input;
|
||||
const { procedure: p, surgeryDate, nationality, prefs, festivals = [] } = input;
|
||||
const preOp = effectivePreOpDays(p, nationality);
|
||||
const arriveBy = addDays(surgeryDate, -preOp);
|
||||
const nights = p.hospitalNights.value;
|
||||
@ -94,7 +97,8 @@ export function buildPlan(input: PlanInput, places: Place[]): Plan {
|
||||
const recentSet = new Set(recent.slice(-6));
|
||||
const picks = level === 0 || off === 0 ? [] : pickForDay(places, level, diet, prefs, recentSet);
|
||||
recent.push(...picks.map((x) => x.id));
|
||||
days.push({ date, offset: off, level, diet, picks, isSurgery: off === 0, isHospital, isStitch: stitchDate === date, isArrival: off === -preOp, isDeparture: earliestDeparture === date });
|
||||
const dayFest = level >= 3 ? festivals.filter((f) => f.start <= date && f.end >= date).slice(0, 1) : []; // 행사는 자유 외출(L3) 날에만
|
||||
days.push({ date, offset: off, level, diet, picks, festivals: dayFest, isSurgery: off === 0, isHospital, isStitch: stitchDate === date, isArrival: off === -preOp, isDeparture: earliestDeparture === date });
|
||||
}
|
||||
return { arriveBy, surgeryDate, dischargeDate, stitchDate, earliestDeparture, stayNights: dep === null ? null : preOp + dep, preOpDays: preOp, days };
|
||||
}
|
||||
|
||||
89
supporters/src/lib/tour.ts
Normal file
89
supporters/src/lib/tour.ts
Normal file
@ -0,0 +1,89 @@
|
||||
// 한국관광공사 TourAPI 사전 수집 데이터(src/data/medicalTourism*.json)를 회복 일정 플래너의 장소·축제 후보로 바꾼다 (설계 v0.2 §13.5, 2순위 소스).
|
||||
// 수집은 scripts/fetch_medical_tourism.py 가 병원 좌표 기준으로 미리 해 두고, 화면은 이 파일의 결과만 쓴다. 외부 호출 없음.
|
||||
// 식이 적합성은 TourAPI 가 주지 않으므로 판정하지 않는다: 음식점은 일반식 단계에서만 후보가 되고, 죽·스무디 같은 회복 초기 식사는 큐레이션(places.json)이 맡는다.
|
||||
import type { Place, L10n } from './plan';
|
||||
|
||||
export interface TourItem {
|
||||
id: string; title: string; address: string; image: string | null; tel: string | null; lat: number; lng: number;
|
||||
distanceM: number; travel: { mode: string; minutes: number; label: string } | null; overview: string; homepage: string;
|
||||
startDate?: string; endDate?: string; season?: string;
|
||||
}
|
||||
export interface TourData {
|
||||
places: Record<string, TourItem[]>;
|
||||
festivals: TourItem[];
|
||||
meta: { lang?: string; source: string; fetchedAt: string; origin?: { lat: number; lng: number; label: string } };
|
||||
}
|
||||
|
||||
/** TourAPI 유형 → 플래너 카테고리. wellness(사우나·스파)는 회복기 금지 항목이라 후보에서 뺀다. */
|
||||
const CATEGORY: Record<string, string | null> = { attraction: 'sightseeing', culture: 'sightseeing', shopping: 'shopping', restaurant: 'food', stay: 'hotel', wellness: null };
|
||||
|
||||
/** 거리로 외출 등급을 정한다: 600m 안 근거리(L1), 3km 안 반나절(L2), 그 밖은 자유 외출(L3). */
|
||||
export const levelByDistance = (m: number): number => (m <= 600 ? 1 : m <= 3000 ? 2 : 3);
|
||||
|
||||
const both = (s: string): L10n => ({ ko: s, en: s });
|
||||
/** 주소를 짧은 동네 표기로. 국문은 '구 도로명', 영문은 첫 쉼표 앞 도로명. */
|
||||
const areaLabel = (address: string, lang: 'ko' | 'en'): string => {
|
||||
const a = (address || '').trim();
|
||||
if (lang === 'en') return a.split(',').map((x) => x.trim()).filter((x) => x && !/^\d+$/.test(x))[0] ?? a;
|
||||
return a.replace(/^서울특별시\s*/, '').split(/\s+/).slice(0, 2).join(' ');
|
||||
};
|
||||
const firstSentence = (s: string, n = 140): string => {
|
||||
const t = (s || '').replace(/\d+\s*m?\s*NAVER\s*©\s*NAVER Corp\.?/gi, '').replace(/\s+/g, ' ').trim();
|
||||
if (t.length <= n) return t;
|
||||
const cut = t.slice(0, n);
|
||||
const stop = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('다. '), cut.lastIndexOf('。'));
|
||||
return (stop > 40 ? cut.slice(0, stop + 1) : cut).trim() + (stop > 40 ? '' : '…');
|
||||
};
|
||||
/** "K-그랜드 호스텔 강남 1(K-GRAND HOSTEL GANGNAM1)" → 예약 검색용 영문 이름이 괄호 안에 있으면 그것을 쓴다. */
|
||||
export const bookingName = (title: string): string => {
|
||||
const m = title.match(/\(([^)]*[A-Za-z][^)]*)\)\s*$/);
|
||||
return (m ? m[1] : title).trim();
|
||||
};
|
||||
|
||||
export function tourToPlaces(data: TourData, lang: 'ko' | 'en'): Place[] {
|
||||
const out: Place[] = [];
|
||||
for (const [type, items] of Object.entries(data.places ?? {})) {
|
||||
const category = CATEGORY[type];
|
||||
if (!category) continue;
|
||||
for (const it of items) {
|
||||
if (!it.lat || !it.lng) continue;
|
||||
const km = Math.round((it.distanceM / 1000) * 10) / 10;
|
||||
out.push({
|
||||
id: `tour-${type}-${it.id}`,
|
||||
name: both(it.title),
|
||||
category,
|
||||
minLevel: category === 'hotel' ? 0 : levelByDistance(it.distanceM),
|
||||
dietOk: category === 'food' ? ['normal'] : undefined,
|
||||
distanceKm: km,
|
||||
indoor: type === 'shopping' || type === 'culture' || type === 'restaurant' || type === 'stay',
|
||||
area: both(areaLabel(it.address, lang)),
|
||||
note: both(firstSentence(it.overview)),
|
||||
mapQuery: it.title,
|
||||
bookingQuery: category === 'hotel' ? bookingName(it.title) : undefined,
|
||||
verify: true,
|
||||
source: 'tourapi',
|
||||
travelLabel: it.travel?.label ?? undefined,
|
||||
image: it.image ?? undefined,
|
||||
url: it.homepage || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Festival { id: string; title: string; start: string; end: string; distanceKm: number; travelLabel?: string; blurb: string; image?: string; url?: string; address: string }
|
||||
const iso = (d?: string) => (d && d.length === 8 ? `${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}` : '');
|
||||
|
||||
/** 체류 기간 [from, to] 와 겹치는 행사만, 가까운 순. */
|
||||
export function festivalsDuring(data: TourData, from: string, to: string, max = 6): Festival[] {
|
||||
return (data.festivals ?? [])
|
||||
.map((f) => ({ f, start: iso(f.startDate), end: iso(f.endDate) }))
|
||||
.filter((x) => x.start && x.end && x.start <= to && x.end >= from)
|
||||
.sort((a, b) => a.f.distanceM - b.f.distanceM)
|
||||
.slice(0, max)
|
||||
.map(({ f, start, end }) => ({
|
||||
id: `fest-${f.id}`, title: f.title, start, end, distanceKm: Math.round((f.distanceM / 1000) * 10) / 10,
|
||||
travelLabel: f.travel?.label ?? undefined, blurb: firstSentence(f.overview), image: f.image ?? undefined,
|
||||
url: f.homepage || undefined, address: f.address,
|
||||
}));
|
||||
}
|
||||
17
supporters/src/pages/404.astro
Normal file
17
supporters/src/pages/404.astro
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
// 없는 주소. Vercel 이 dist/404.html 을 자동으로 쓴다. 옛 주소(/recovery, /stay)는 vercel.json 리다이렉트가 /plan 으로 먼저 보낸다.
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { SITE_NAME } from '../lib';
|
||||
const site = Astro.site!.toString().replace(/\/$/, '');
|
||||
const ld = { '@type': 'WebPage', '@id': `${site}/404#page`, name: '페이지를 찾을 수 없음', url: `${site}/404`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } };
|
||||
---
|
||||
<Base title="페이지를 찾을 수 없음" description="주소가 바뀌었거나 없는 페이지입니다. 홈, 회복 일정, 방문 안내로 이동할 수 있습니다." jsonLd={ld} noindex={true}>
|
||||
<section class="section dark" style="padding:5rem 0 4.5rem"><div class="wrap-wide">
|
||||
<div class="sec-head"><div class="eyebrow" style="color:var(--purple-300)">404</div><h1 class="serif" style="font-size:2rem;display:inline-block;background:linear-gradient(to right,var(--purple-300),var(--blue-300));-webkit-background-clip:text;background-clip:text;color:transparent">Page Not Found</h1><p class="sub">주소가 바뀌었거나 없는 페이지입니다. 아래에서 이어서 보실 수 있습니다.</p></div>
|
||||
<div class="grid">
|
||||
<a class="card" href="/" style="text-decoration:none"><div class="cat">Home</div><h3>홈</h3><p>뷰성형외과를 알아보는 분들의 질문과 답</p></a>
|
||||
<a class="card" href="/plan" style="text-decoration:none"><div class="cat">Recovery Planner</div><h3>회복 일정</h3><p>수술일 기준 입국·출국 계산과 회복기 외출 계획. 영어 화면은 /en/plan</p></a>
|
||||
<a class="card" href="/visit" style="text-decoration:none"><div class="cat">Visit</div><h3>방문 안내</h3><p>예약, 가는 길, 주차, 도착 후 동선</p></a>
|
||||
</div>
|
||||
</div></section>
|
||||
</Base>
|
||||
@ -100,6 +100,14 @@ button.btn { font: inherit; cursor: pointer; border: 0; }
|
||||
.day .rest { font-size: 0.88rem; color: var(--slate-500); }
|
||||
@media (max-width: 640px) { .day { grid-template-columns: 1fr; } }
|
||||
|
||||
/* 체류 기간의 행사 카드 (TourAPI 이미지) */
|
||||
.fest-card { padding: 0; overflow: hidden; }
|
||||
.fest-card img { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; display: block; }
|
||||
.fest-card .cat, .fest-card h3, .fest-card p { margin-left: 1.5rem; margin-right: 1.5rem; }
|
||||
.fest-card .cat { margin-top: 1.2rem; }
|
||||
.fest-card > p:last-child { margin-bottom: 1.3rem; }
|
||||
.day .picks a.fest { border-color: var(--st-good-border); background: var(--st-good-bg); }
|
||||
|
||||
/* 예약 (다크 섹션 위 흰 카드) */
|
||||
.hotel-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 1.25rem; }
|
||||
.hotel-grid .card small { color: var(--slate-500); font-size: 0.78rem; }
|
||||
|
||||
@ -21,5 +21,27 @@
|
||||
}
|
||||
],
|
||||
"cleanUrls": true,
|
||||
"trailingSlash": false
|
||||
}
|
||||
"trailingSlash": false,
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/recovery",
|
||||
"destination": "/plan",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/stay",
|
||||
"destination": "/plan",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/en/recovery",
|
||||
"destination": "/en/plan",
|
||||
"permanent": true
|
||||
},
|
||||
{
|
||||
"source": "/en/stay",
|
||||
"destination": "/en/plan",
|
||||
"permanent": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user