여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
246 lines
9.3 KiB
Python
246 lines
9.3 KiB
Python
"""한국관광공사 TourAPI(KorService2) — **업장 반경** 주변정보 수집 클라이언트."""
|
|
from datetime import date
|
|
from typing import Optional
|
|
from urllib.parse import unquote, urlencode
|
|
|
|
import httpx
|
|
|
|
from common.enums import LocalContentType
|
|
from common.utils.geo import haversine_m
|
|
from config.server_configs import external_api_config
|
|
|
|
BASE_URL = "https://apis.data.go.kr/B551011/KorService2"
|
|
REQUEST_TIMEOUT = 25
|
|
PAGE_SIZE = 100
|
|
MAX_PAGES = 4
|
|
|
|
# TourAPI contentTypeId ↔ 우리 종류 코드(locationBasedList2 용).
|
|
CONTENT_TYPE_MAP = {
|
|
"39": LocalContentType.RESTAURANT.value,
|
|
"12": LocalContentType.ATTRACTION.value,
|
|
}
|
|
|
|
# 상업적 이용이 허용된 공공누리 유형(tour_api_adapter 와 동일 규칙·동일 이유).
|
|
_COMMERCIAL_OK_LICENSES = frozenset({"type1", "type3"})
|
|
|
|
|
|
class TourApiNotConfigured(RuntimeError):
|
|
pass
|
|
|
|
|
|
class TourApiRequestFailed(RuntimeError):
|
|
pass
|
|
|
|
|
|
# ── HTTP ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _service_key() -> str:
|
|
key = (external_api_config.tour_api_key or "").strip()
|
|
if not key:
|
|
raise TourApiNotConfigured("TOUR_API_KEY is not configured")
|
|
return key
|
|
|
|
|
|
async def _call(client: httpx.AsyncClient, op: str, **params) -> tuple[list[dict], int]:
|
|
"""오퍼레이션 1회 → (항목, totalCount)."""
|
|
query = urlencode(
|
|
{"serviceKey": unquote(_service_key()), "MobileOS": "ETC", "MobileApp": "o2o-web4ai",
|
|
"_type": "json", "numOfRows": str(PAGE_SIZE), "pageNo": "1", **params},
|
|
safe="",
|
|
)
|
|
res = await client.get(f"{BASE_URL}/{op}?{query}")
|
|
if res.status_code != 200:
|
|
raise TourApiRequestFailed(f"{op} HTTP {res.status_code}")
|
|
try:
|
|
payload = res.json()
|
|
except ValueError:
|
|
# 인증 실패·쿼터 초과는 XML 로 온다.
|
|
raise TourApiRequestFailed(f"{op} 응답이 JSON 이 아니다: {res.text[:160]}")
|
|
|
|
# 게이트웨이 오류(미등록 키 등)는 200 + JSON 이지만 response 가 없다.
|
|
if "response" not in payload:
|
|
raise TourApiRequestFailed(f"{op} 게이트웨이 오류: {str(payload)[:160]}")
|
|
header = payload["response"].get("header", {})
|
|
code = str(header.get("resultCode") or "")
|
|
if code not in ("0000", "00"):
|
|
raise TourApiRequestFailed(f"{op} 실패 [{code}] {header.get('resultMsg')}")
|
|
|
|
body = payload["response"].get("body", {}) or {}
|
|
items = (body.get("items") or {}).get("item") if isinstance(body.get("items"), dict) else None
|
|
if isinstance(items, dict):
|
|
items = [items]
|
|
total = int(body.get("totalCount") or 0)
|
|
return items or [], total
|
|
|
|
|
|
# ── 정규화 ──────────────────────────────────────────────────────────────
|
|
|
|
def _int(value) -> Optional[int]:
|
|
try:
|
|
return int(float(str(value).strip()))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _normalize(item: dict) -> Optional[dict]:
|
|
"""locationBasedList2 항목 1건 → 정규화 dict."""
|
|
content_id = str(item.get("contentid") or "").strip()
|
|
title = str(item.get("title") or "").strip()
|
|
kind = CONTENT_TYPE_MAP.get(str(item.get("contenttypeid") or "").strip())
|
|
distance = _int(item.get("dist"))
|
|
if not content_id or not title or kind is None or distance is None:
|
|
return None
|
|
|
|
out = {
|
|
"contentid": content_id, "content_type": kind, "distance_m": distance,
|
|
# 렌더러 계약(LocalPlace).
|
|
"name": title, "searchQuery": title,
|
|
}
|
|
address = str(item.get("addr1") or "").strip()
|
|
if address:
|
|
out["location"] = address
|
|
# 좌표는 컬럼으로 간다.
|
|
for src, dst in (("mapx", "longitude"), ("mapy", "latitude")):
|
|
value = str(item.get(src) or "").strip()
|
|
if value:
|
|
out[dst] = value
|
|
# 중분류만 남긴다.
|
|
cls = str(item.get("lclsSystm2") or "").strip()
|
|
if cls:
|
|
out["lclsSystm2"] = cls
|
|
|
|
# 사진은 상업적 이용이 허용된 공공누리 유형일 때만 싣는다.
|
|
image = str(item.get("firstimage") or "").strip()
|
|
license_code = str(item.get("cpyrhtDivCd") or "").strip().lower()
|
|
if image and license_code in _COMMERCIAL_OK_LICENSES:
|
|
out["imageUrl"] = image
|
|
return out
|
|
|
|
|
|
# ── 공개 API ────────────────────────────────────────────────────────────
|
|
|
|
def make_client() -> httpx.AsyncClient:
|
|
return httpx.AsyncClient(timeout=REQUEST_TIMEOUT)
|
|
|
|
|
|
async def find_image(client: httpx.AsyncClient, keyword: str, region_token: str) -> Optional[str]:
|
|
"""이름으로 공공데이터 사진 한 장."""
|
|
word = (keyword or "").strip()
|
|
token = (region_token or "").strip()
|
|
if not word:
|
|
return None
|
|
try:
|
|
items, _ = await _call(client, "searchKeyword2", keyword=word, arrange="O")
|
|
except Exception: # noqa: BLE001 — 사진은 있으면 좋은 것이지 없으면 안 되는 것이 아니다
|
|
return None
|
|
|
|
for item in items:
|
|
image = str(item.get("firstimage") or "").strip()
|
|
if not image:
|
|
continue
|
|
if str(item.get("cpyrhtDivCd") or "").strip().lower() not in _COMMERCIAL_OK_LICENSES:
|
|
continue
|
|
addr = f"{item.get('addr1') or ''} {item.get('addr2') or ''}"
|
|
if token and token not in addr:
|
|
continue
|
|
return image
|
|
return None
|
|
|
|
|
|
async def fetch_nearby(client: httpx.AsyncClient, latitude: float, longitude: float,
|
|
*, radius_m: int, content_type_id: str) -> list[dict]:
|
|
"""업장 좌표 반경 안의 한 종류(정규화, 거리순)."""
|
|
out: list[dict] = []
|
|
seen: set[str] = set()
|
|
for page in range(1, MAX_PAGES + 1):
|
|
items, total = await _call(
|
|
client, "locationBasedList2",
|
|
mapX=str(longitude), mapY=str(latitude), radius=str(radius_m),
|
|
contentTypeId=content_type_id, arrange="E", pageNo=str(page),
|
|
)
|
|
for item in items:
|
|
body = _normalize(item)
|
|
if body and body["contentid"] not in seen:
|
|
seen.add(body["contentid"])
|
|
out.append(body)
|
|
if not items or page * PAGE_SIZE >= total:
|
|
break
|
|
out.sort(key=lambda b: b["distance_m"])
|
|
return out
|
|
|
|
|
|
def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]:
|
|
"""searchFestival2 항목 1건 → 정규화 dict."""
|
|
content_id = str(item.get("contentid") or "").strip()
|
|
title = str(item.get("title") or "").strip()
|
|
if not content_id or not title:
|
|
return None
|
|
|
|
out = {
|
|
"contentid": content_id, "content_type": LocalContentType.FESTIVAL.value,
|
|
"distance_m": distance_m, "name": title, "searchQuery": title,
|
|
}
|
|
address = str(item.get("addr1") or "").strip()
|
|
if address:
|
|
out["location"] = address
|
|
for src, dst in (("mapx", "longitude"), ("mapy", "latitude")):
|
|
value = str(item.get(src) or "").strip()
|
|
if value:
|
|
out[dst] = value
|
|
for key in ("eventstartdate", "eventenddate", "homepage", "overview", "lclsSystm2"):
|
|
value = str(item.get(key) or "").strip()
|
|
if value:
|
|
out[key] = value
|
|
|
|
image = str(item.get("firstimage") or "").strip()
|
|
license_code = str(item.get("cpyrhtDivCd") or "").strip().lower()
|
|
if image and license_code in _COMMERCIAL_OK_LICENSES:
|
|
out["imageUrl"] = image
|
|
return out
|
|
|
|
|
|
async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, longitude: float,
|
|
*, sido_code: str, today: date) -> list[dict]:
|
|
"""업장이 속한 시도의 축제 **전부**(정규화, 거리순, 종료 여부와 무관하게 전부)."""
|
|
start_date = date(today.year, 1, 1).strftime("%Y%m%d")
|
|
out: list[dict] = []
|
|
seen: set[str] = set()
|
|
for page in range(1, MAX_PAGES + 1):
|
|
items, total = await _call(
|
|
client, "searchFestival2",
|
|
eventStartDate=start_date, lDongRegnCd=sido_code, pageNo=str(page),
|
|
)
|
|
for item in items:
|
|
lnglat = _mapxy(item) # (경도, 위도) — 좌표가 없으면 거리를 잴 수 없다
|
|
if lnglat is None:
|
|
continue
|
|
lng, lat = lnglat
|
|
distance = haversine_m(latitude, longitude, lat, lng) # 자르지 않는다 — 정렬·표시용
|
|
body = _normalize_festival(item, round(distance))
|
|
if not body or body["contentid"] in seen:
|
|
continue
|
|
seen.add(body["contentid"])
|
|
out.append(body)
|
|
if not items or page * PAGE_SIZE >= total:
|
|
break
|
|
out.sort(key=lambda b: b["distance_m"])
|
|
return out
|
|
|
|
|
|
def _mapxy(item: dict) -> Optional[tuple[float, float]]:
|
|
"""(경도, 위도)."""
|
|
try:
|
|
return float(item.get("mapx")), float(item.get("mapy"))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
async def fetch_content_class(client: httpx.AsyncClient, content_id: str) -> Optional[str]:
|
|
"""콘텐츠 1건의 중분류 코드(lclsSystm2)."""
|
|
items, _ = await _call(client, "detailCommon2", contentId=content_id)
|
|
if not items:
|
|
return None
|
|
code = str(items[0].get("lclsSystm2") or "").strip()
|
|
return code or None
|