"""한국관광공사 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