공식채널 단일화, 한일옥 거리 반영 날씨 조건을 7종으로 세분화, 축제 종료 여부와 무관하게 상시 노출, '지역 읽기'갈래 축소, 야놀자(NOL) 브랜드명 제거.
330 lines
17 KiB
Python
330 lines
17 KiB
Python
"""한국관광공사 TourAPI(KorService2) — **업장 반경** 주변정보 수집 클라이언트.
|
|
|
|
collector/tour_api_adapter.py 가 '사업장 1곳'의 fact 를 캐는 쪽이라면, 여기는
|
|
업장 좌표 반경 안의 곁들이 정보(맛집·관광지·축제·여행코스)를 긁는 쪽이다.
|
|
결과는 place_contents(업장 단위 캐시)에 들어가 발행본·캔버스의 지역 정보 섹션이 된다.
|
|
|
|
★ 왜 행정구역이 아니라 좌표인가 (2026-09-04, specs/2026-09-04-tourapi-radius-spike.md)
|
|
처음엔 법정동 코드로 areaBasedList2 를 불렀다. 그건 '그 시군구에 있는 것'이지
|
|
'이 업장에서 가까운 것'이 아니다 — 양양군 업장 옆 5km 속초 관광지가 빠지고,
|
|
같은 시군구 반대편 30km 맛집이 붙는다. locationBasedList2 는 좌표+반경으로 묻고
|
|
거리(dist)까지 준다. 실측(군산 절골길 18): 10km 안 133건, 5km 안 100건.
|
|
|
|
★ 호출 수 — 업장당 종류별 1회 (맛집·관광지·축제)
|
|
2026-09-08 부터 종류마다 **따로** 부른다(맛집 5km · 관광지 10km · 축제는 시도 전체, 반경 없음) —
|
|
한 걸음에 갈 맛집과 차 타고 갈 축제를 같은 반경으로 재는 게 맞지 않았다.
|
|
여행코스(25)는 실측 결과 반경을 넓혀도 데이터가 거의 없어(전북 전체 3건) 뺐다.
|
|
|
|
★ 축제는 locationBasedList2 가 아니라 searchFestival2 를 쓴다 (2026-09-08 교체)
|
|
locationBasedList2(contentTypeId=15) 의 위치 색인은 못 믿는다 — 실측(군산 절골길 18):
|
|
반경 20km 를 아무리 넓혀도 2023년에 끝난 서천 전시 1건만 나오고, 코앞 500m 의
|
|
진행 예정 축제(군산시간여행축제 등)는 끝내 안 잡혔다. searchFestival2 는 법정동(시도)
|
|
단위로 묻지만 정확하고 기간까지 함께 준다 — 그래서 시도 전체를 받아 우리가 거리로 거른다.
|
|
eventStartDate 는 파라미터로 준 날짜 **이후 시작하는** 행사만 거른다(이전에 시작해 아직
|
|
진행 중인 행사는 잡히지 않는다 — 실측). 그래서 항상 **그 해 1월 1일**로 고정해 부른다.
|
|
종료된 행사도 그대로 싣는다(2026-09-17 결정) — 시작일을 2020년으로 당겨 실측해도 API 자체가
|
|
옛 행사를 추가로 주지 않아(최근~예정 위주) 여기서 더 거를 실익이 없고, 이미 끝난 축제를
|
|
보여줄지는 노출 단계(local_content_service)의 몫으로 넘긴다.
|
|
|
|
★ 이미지 저작권 — 수집 단계에서 끝낸다 (collector/tour_api_adapter.py 와 같은 규칙)
|
|
firstimage 는 공공누리 Type1(출처표시)·Type3(출처표시+변경금지)만 남긴다.
|
|
발행본은 상업적 이용이라 Type2·Type4 는 싣지 못한다. 유형을 모르면 버린다.
|
|
"""
|
|
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
|
|
# 반경 10km 도심은 300건을 넘지 않는다(실측 133건). 그 이상은 어차피 종류별 20건 상한에 안 든다.
|
|
MAX_PAGES = 4
|
|
|
|
# TourAPI contentTypeId ↔ 우리 종류 코드(locationBasedList2 용). 축제(15)는 여기 없다 —
|
|
# searchFestival2 로 따로 받는다(위 모듈 docstring 참고). 여행코스(25)도 뺐다(데이터 부족).
|
|
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). 결과 없음은 빈 목록 — 없는 것과 실패를 구분한다.
|
|
|
|
★ 포털의 'Encoding' 키를 그대로 넣어도 이중 인코딩되지 않도록 원문으로 되돌린다
|
|
(collector/tour_api_adapter._call 과 같은 처리).
|
|
"""
|
|
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. contentid·title·거리·종류 없으면 버린다.
|
|
|
|
★ 여기서 **렌더러가 읽는 이름**으로 바꾼다(`name`·`location`·`imageUrl`). 예전에는 TourAPI
|
|
원문 이름(`title`·`addr1`·`firstimage`)을 그대로 저장하고 빌드마다 바꿔 실었다 —
|
|
같은 변환을 발행할 때마다 다시 하는 셈이었고, 캔버스와 발행본이 각자 바꾸면 갈릴 자리였다.
|
|
★ 저장 자리가 갈리는 값은 여기서 **평평하게** 내보내기만 한다. 어느 컬럼·어느 테이블로
|
|
가는지는 부르는 쪽(local_content_service.sync_place)이 정한다:
|
|
distance_m → 사이트 개인화(site_sections) 좌표 → area_contents 컬럼
|
|
"""
|
|
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). searchQuery 는 이름 그대로다 — 우리가 URL 을 지어내지 않는다.
|
|
"name": title, "searchQuery": title,
|
|
}
|
|
address = str(item.get("addr1") or "").strip()
|
|
if address:
|
|
out["location"] = address
|
|
# 좌표는 컬럼으로 간다. mapX=경도 · mapY=위도 (뒤집으면 엉뚱한 지역이 붙는다).
|
|
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]:
|
|
"""이름으로 공공데이터 사진 한 장. 없으면 None.
|
|
|
|
지역 이야기(연표·엽서)의 항목에 사진을 붙이는 자리다. 이야기는 검색모델이 쓰지만
|
|
**사진은 모델에게 묻지 않는다** — 모델이 준 이미지 주소는 대개 존재하지 않거나
|
|
남의 저작물이다. 공공데이터가 그 장소의 사진으로 준 것만 쓴다.
|
|
|
|
★ 권리는 여기서 끝낸다. 이 파일의 규칙 그대로 공공누리 Type1·Type3 만 남긴다
|
|
(머리주석). 발행본은 상업적 이용이라 Type2·Type4 는 못 싣고, 유형을 모르면 버린다.
|
|
★ 지역을 대조한다. `searchKeyword2` 는 전국에서 이름만 맞으면 주므로, 주소에 지역
|
|
토막이 없는 결과는 버린다 — '군산항' 을 찾다가 다른 지역 동명 시설 사진이 붙으면
|
|
그 사진은 이 지역 이야기와 아무 관계가 없다.
|
|
★ 실패는 None 이다. 사진이 없으면 렌더러가 활자만으로 세운다(설계된 폴백) —
|
|
사진 한 장 때문에 이야기 생성을 실패시키지 않는다.
|
|
"""
|
|
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]:
|
|
"""업장 좌표 반경 안의 한 종류(정규화, 거리순). 종류마다 반경이 달라 호출도 따로 한다.
|
|
|
|
★ mapX=경도 · mapY=위도. 뒤집으면 엉뚱한 지역이 붙는다(카카오와 같은 함정).
|
|
"""
|
|
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. locationBasedList2 와 달리 `dist` 를 안 주므로
|
|
(호출측이 haversine 으로 잰 값을) 그대로 받는다.
|
|
|
|
★ `_normalize` 와 같은 규약이다 — 렌더러 이름으로 바꿔 내보내고, 저장 자리는 부르는 쪽이 정한다.
|
|
★ 기간(eventstartdate/enddate)은 **원값 그대로** 남긴다. 화면 문자열("2026.10.01 ~ …")로 미리
|
|
구워 두면 정렬·계절 산출(site_payload._festival)이 읽을 값이 없어진다.
|
|
날짜는 사실이고 문장은 표기다 — 사실만 저장한다.
|
|
"""
|
|
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]:
|
|
"""업장이 속한 시도의 축제 **전부**(정규화, 거리순, 종료 여부와 무관하게 전부). 반경으로 자르지 않는다.
|
|
|
|
★ 반경을 안 두는 이유(2026-09-08 결정): 축제는 차로 가는 행사라 20km 로 자르면 시도 안의
|
|
큰 축제가 빠진다. 시도 전체를 그대로 싣고, 거리는 정렬·표시용으로만 잰다.
|
|
(종류별 노출 상한은 스냅샷이 20건으로 자른다 — 사진 있는 것 우선 → 가까운 순.)
|
|
★ locationBasedList2 의 위치 색인은 못 믿어서 searchFestival2 를 쓴다(위 모듈 docstring).
|
|
eventStartDate 는 그 해 1월 1일로 **고정** — "오늘" 을 넣으면 그 이전에 시작해 아직 진행 중인
|
|
축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받는다.
|
|
★ 종료된 축제도 거르지 않고 그대로 싣는다(2026-09-17 결정) — 시작일을 2020년으로 당겨 실측해도
|
|
API 가 옛 행사를 추가로 주지 않아 더 거를 실익이 없었고, 실제로 보여줄지는 노출 단계
|
|
(local_content_service.py — area_contents.display_end_at)가 정한다.
|
|
★ 좌표 없는 항목은 뺀다 — distance_m 이 NOT NULL 이고, 거리 없는 카드는 도보 필터에 못 얹는다.
|
|
"""
|
|
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]]:
|
|
"""(경도, 위도). 좌표가 없거나 숫자가 아니면 None — 거리를 잴 수 없는 항목은 반경으로 못 거른다."""
|
|
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). 못 구하면 None.
|
|
|
|
업장 자신이 TourAPI 에 등록돼 있을 때(place_channels 의 tour:// 링크) 그 업장의 업태를 여기서 읽는다 —
|
|
주변 맛집에서 같은 중분류를 빼기 위해서다. 외부 분류 문자열 매핑보다 이 값이 우선이다(같은 체계라 오차가 없다).
|
|
"""
|
|
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
|