o2o-infinith-demo/scripts/fetch_medical_tourism.py
Haewon Kam 5d3ce40f77 feat(discovery): 의료관광 연결 섹션 — TourAPI 다국어 주변 정보 + 회복 동선
/discovery/:id 에 외국인 환자용 주변 정보를 붙였다. 숙박·음식점·웰니스·관광·문화·
쇼핑·축제 125건과 이동 반경 기준 회복 동선. 다음 뷰성형외과 미팅용 프로토타입이다.

데이터 (scripts/fetch_medical_tourism.py → src/data/medicalTourism.json)
한국관광공사 TourAPI 영문(EngService2) 사전 수집. 실시간 호출이 아닌 이유는 영문
커버리지가 얇아 필터링이 많이 필요하고, 시연 중 외부 API 상태에 화면이 종속되지
않게 하기 위해서다. 좌표는 Naver 지역검색 실측(뷰성형외과의원, 봉은사로 107).

실측으로 드러난 것과 대응
- 영문 관광지(76)의 대부분이 관광지가 아니다. 반경 5km 100건 중 92건이 분류코드
  A02020500 "Medical Tourism Sites"(categoryCode2 로 확인)이며 병원과 유치
  에이전시다. 제목 키워드로는 "하이안과", "닥파인더코리아" 를 놓쳐 코드로 걸렀다.
  거리순 상위 100건이 거의 다 여기라 페이징을 넣어야 실제 관광지가 나온다.
- 스파·찜질방(A02020300/400)은 회복 여정에 맞아 wellness 로 분리했다.
- 영문 축제는 areacode 필드가 비어 있어 areaCode 필터가 0건을 반환한다. 좌표
  거리로 걸렀다.
- TourAPI 이미지 URL 이 http 라 https 사이트에서 혼합 콘텐츠로 차단된다. 수집
  단계에서 https 로 올린다.
- 반경을 목적별로 나눴다. 회복기에 자주 가는 숙박·식사는 5km, 관광은 10km.

만들지 않은 것
- 평점·리뷰·영업시간: TourAPI 미제공이라 null 로 두고 화면에 "미연동" 으로 적었다.
  Google Places 키가 아직 비어 있다(신청 중).
- 수술 전후 식이 적합성: 의학 판단이라 병원 입력만 쓴다. ClinicInputsPanel 에
  diet_guide 항목을 더해 supporter_inputs 로 받고, 입력 전에는 "병원 확인 대기"
  로 둔다. 개별 식당의 적합 여부를 추론하지 않는다.
- 회복 단계가 며칠째인지: 병원 입력(recovery). 화면의 단계는 이동 반경으로만
  정의해 의학적 함의를 담지 않는다.
- 한계 4가지를 meta.limits 에 담아 섹션 하단 "측정 가능 범위" 에 그대로 띄운다.

FilledIcons 에 Bed·Fork·Spa·MapPin·Ticket·Bag·Theater 7종 추가(라인 아이콘·이모지
금지 규칙에 따라 채움 SVG).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 11:31:55 +09:00

207 lines
10 KiB
Python

#!/usr/bin/env python3
"""의료관광 연결 데이터 수집 (한국관광공사 TourAPI 다국어).
병원 좌표를 중심으로 숙박·음식점·관광지·문화시설·쇼핑을 모으고, 진행 중인 축제를
거리와 함께 붙여 프론트가 쓸 JSON 하나로 낸다.
왜 사전 수집인가
영문 서비스는 강남 일대 커버리지가 얇고(5km 음식점 18건, 국문은 338건) 관광지의
79%가 의료기관이라 필터링이 많이 필요하다. 그 처리를 수집 시점에 끝내고 프론트는
정제된 결과만 그린다. 미팅 데모 중 외부 API 상태에 화면이 종속되지도 않는다.
무엇을 만들지 않는가
없는 값을 채우지 않는다. 평점·리뷰·영업시간은 TourAPI 가 주지 않으므로 비워 두고,
Google Places 승인 후 별도 단계에서 붙인다. 식이 적합성은 병원 입력이며 여기서
판정하지 않는다.
python3 scripts/fetch_medical_tourism.py
python3 scripts/fetch_medical_tourism.py --radius 5000 --out src/data/medicalTourism.json
"""
import argparse, io, json, math, os, 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. 국문과 코드가 다르다.
# 반경은 목적에 따라 다르다. 회복기에 매일 가는 곳(숙박·식사·스파)은 가깝게, 관광은 넓게 본다.
TYPES = {
"76": ("attraction", "10000"),
"78": ("culture", "10000"),
"79": ("shopping", "10000"),
"80": ("stay", "5000"),
"82": ("restaurant", "5000"),
}
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):
d = {}
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()
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 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),
"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),
# TourAPI 가 주지 않는 값. Google Places 승인 후 채운다. 지금은 비워 둔다.
"rating": None, "reviewCount": None, "openingHours": None,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다")
ap.add_argument("--per-type", type=int, default=24, help="타입별 최대 건수")
ap.add_argument("--out", default="src/data/medicalTourism.json")
a = ap.parse_args()
env = load_env(os.path.join(ROOT, ".env"))
for k in ("TOUR_API_KEY", "TOUR_API_BASE", "TOUR_API_SERVICE", "TOUR_API_ORIGIN_LAT", "TOUR_API_ORIGIN_LNG"):
if not env.get(k): sys.exit(f".env 에 {k} 가 비어 있습니다")
lat, lng = float(env["TOUR_API_ORIGIN_LAT"]), float(env["TOUR_API_ORIGIN_LNG"])
out = {"places": {}, "festivals": [], "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": "관광지(76)에서 스파·찜질방 분류만 분리", "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
c.update(startDate=it.get("eventstartdate"), endDate=it.get("eventenddate"),
distanceKm=round(km, 1))
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)}")
out["meta"] = {
"source": "한국관광공사 TourAPI " + env["TOUR_API_SERVICE"],
"fetchedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
"origin": {"lat": lat, "lng": lng, "label": "뷰성형외과의원 (봉은사로 107)",
"coordSource": "Naver 지역검색 실측 2026-09-09"},
"radiusByType": {n: int(r) for n, r in TYPES.values()}, "festivalRadiusKm": a.festival_km,
"counts": filtered_log,
# 화면이 한계를 그대로 표시할 수 있게 데이터에 적어 둔다. 없는 값을 채우지 않는다.
"limits": [
"영문 서비스는 강남 일대 숙박·음식점 커버리지가 얇다(반경 5km 숙박 4건·음식점 18건, 국문은 43건·338건).",
"관광지 항목의 대부분(반경 5km 100건 중 92건)이 분류코드 A02020500 \"Medical Tourism Sites\" 였고 제외했다.",
"평점·리뷰·영업시간은 TourAPI 가 제공하지 않는다. Google Places 승인 후 채운다.",
"영문 축제는 areacode 필드가 비어 있어 좌표 거리로 걸렀다.",
"수술 전후 식이 적합성은 병원이 입력한 가이드로만 표시한다. 이 파일에는 없다.",
],
}
p = os.path.join(ROOT, a.out)
os.makedirs(os.path.dirname(p), exist_ok=True)
io.open(p, "w", encoding="utf-8").write(json.dumps(out, ensure_ascii=False, indent=2))
print(f"\n→ {a.out}")
if __name__ == "__main__":
main()