웹빌더가 세 자리에서 연달아 죽었다 — 가게 등록 · 수집 시작 · 수집 완료. 전부 같은 뿌리다: DB 구조 재편이 테이블 이름을 옮기면서 **참조 세 종류 중 일부만** 따라갔다. - **생성자 12군데** (`place_links(...)` → `place_channels(...)`) import 와 `DBType()` 은 고쳤는데 생성자를 빠뜨렸다. 클래스가 없어도 import 는 통과하므로 기동은 정상이고, 그 줄이 실제로 실행되는 순간에만 터진다. - **raw SQL 12군데** (`job.jobs` → `jobs`) 잡 큐만 raw SQL 이라 ORM 이름 변경에 안 딸려 왔다. 큐가 안 도니 수집·비전·소개문·빌드· 지역데이터가 하나도 못 들어간다. 화면에는 "버튼만 안 먹는" 것으로 보였다. - **같은 이름의 속성 5군데** (`source.place_facts` → `source.facts` 등) 이름만 보고 일괄 치환해 테이블과 무관한 자리까지 바뀌었다. `RawSource` 는 수집기 결과 객체지 테이블이 아니다. - **뗀 표를 계속 부르던 5군데** (`ai_check_results`) 한 번도 쓰지 않아 마이그레이션이 뗀 표다. 부르면 SEO 진단이 통째로 죽는다. ★ 하나씩 터질 때마다 고치다가 멈추고 정적 검사로 남은 것을 한 번에 셌다 — pyflakes 가 19건을 짚었다. 이 종류는 import 도 타입검사도 안 잡는다. 테이블 이름을 옮긴 뒤에는 `python -m pyflakes services/ crud/ router/ worker/ common/ | grep "undefined name"` 을 돌린다. 검증: 직접 수집 실행(스테이,머뭄) — 잡 DONE · 재시도 0 · fact 2 · 사진 10 · 채널 2 저장. 정의 안 된 이름 0건. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
312 lines
16 KiB
Python
312 lines
16 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일**로 고정해 부르고,
|
|
이미 끝난 행사(eventenddate < 오늘)만 우리가 한 번 더 거른다.
|
|
|
|
★ 이미지 저작권 — 수집 단계에서 끝낸다 (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 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 ~ …")로 미리
|
|
구워 두면 노출 기간 필터(`_festival_not_ended`·display_end_at)가 읽을 값이 없어진다.
|
|
날짜는 사실이고 문장은 표기다 — 사실만 저장한다.
|
|
"""
|
|
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
|
|
|
|
|
|
def _festival_not_ended(body: dict, today: date) -> bool:
|
|
"""종료일이 지났으면 끝난 축제 — 신지 않는다. 기간을 아예 모르면 못 믿으니 역시 뺀다.
|
|
종료일 없이 시작일만 있으면(무기한 진행) 유지한다 — 끝났다는 증거가 없다."""
|
|
end, start = body.get("eventenddate"), body.get("eventstartdate")
|
|
ymd = today.strftime("%Y%m%d")
|
|
if end:
|
|
return len(end) == 8 and end.isdigit() and end >= ymd
|
|
return bool(start)
|
|
|
|
|
|
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일로 **고정** — "오늘" 을 넣으면 그 이전에 시작해 아직 진행 중인
|
|
축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받고, 끝난 것만 여기서 거른다.
|
|
★ 좌표 없는 항목은 뺀다 — 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
|
|
if not _festival_not_ended(body, today):
|
|
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
|
|
|
|
|
|
def festival_is_current(period: Optional[tuple[str, str]], today: date) -> bool:
|
|
"""종료일이 지났으면 끝난 축제 — 싣지 않는다. 기간을 아예 모르면(None) 못 믿으니 역시 뺀다.
|
|
종료일 없이 시작일만 있으면(무기한 진행) 시작일이 지났어도 유지한다 — 끝났다는 증거가 없다."""
|
|
if not period:
|
|
return False
|
|
start, end = period
|
|
ymd = today.strftime("%Y%m%d")
|
|
if end:
|
|
return len(end) == 8 and end.isdigit() and end >= ymd
|
|
return bool(start)
|