o2o-site-AEO/solution/backend/services/collector/tour_api_adapter.py
Mina Choi 11d30bb3d1 [chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제
여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- 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>
2026-09-28 16:05:19 +09:00

340 lines
15 KiB
Python

"""한국관광공사 TourAPI(KorService2) 어댑터 — 숙박·음식점."""
import re
from typing import Optional
from urllib.parse import unquote, urlencode
import httpx
from common.enums import LinkChannel, PlaceCategory
from common.logger import LOG
from config.server_configs import external_api_config
from services.collector.base import CollectedFact, CollectedMedia, RawSource
BASE_URL = "https://apis.data.go.kr/B551011/KorService2"
# fact 의 출처로 남기는 주소.
SOURCE_URL = (
"https://apis.data.go.kr/B551011/KorService2/detailCommon2"
"?contentId={content_id}&MobileOS=ETC&MobileApp=o2o-web4ai&_type=json"
)
# 화면에 적을 출처 이름.
SOURCE_LABEL = "한국관광공사 TourAPI"
REQUEST_TIMEOUT = 25
MAX_MEDIA = 30
CONTENT_TYPE_LODGING = "32"
CONTENT_TYPE_RESTAURANT = "39"
_SUPPORTED = (CONTENT_TYPE_LODGING, CONTENT_TYPE_RESTAURANT)
# 상업적 이용이 허용된 공공누리 유형만 담는다.
COMMERCIAL_OK_LICENSES = frozenset({"type1", "type3"})
# 변형(크롭·리사이즈)까지 금지된 유형.
NO_DERIVATIVE_LICENSES = frozenset({"type3", "type4"})
# 이 어댑터가 받는 주소.
_COTID = re.compile(r"[?&]cotid=([0-9a-zA-Z\-]+)", re.I)
_TOUR_SCHEME = re.compile(r"^tour://(\d+)/(\d+)$", re.I)
_HOSTS = ("visitkorea.or.kr",)
_BR = re.compile(r"<br\s*/?>", re.I)
_TAGS = re.compile(r"<[^>]+>")
_HREF = re.compile(r'href=["\']([^"\']+)["\']', re.I)
# "13실", "16명", "대지 면적 11,570㎡" 처럼 숫자에 단위가 붙어 온다.
_LEADING_NUM = re.compile(r"^\s*([\d,]+(?:\.\d+)?)")
# "가능 / 불가" 로 시작하는 필드들.
_NEG_HEAD = ("불가", "없음", "미제공", "불가능", "없슴")
_POS_HEAD = ("가능", "있음", "제공", "무료", "부분")
class TourApiAdapter:
"""TourAPI 콘텐츠 → fact·사진 후보."""
id = "tour_api"
# 계약
def can_handle(self, url: str) -> bool:
u = (url or "").strip().lower()
if _TOUR_SCHEME.match(u):
return True
return any(h in u for h in _HOSTS) and bool(_COTID.search(u))
async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
channel = LinkChannel.ETC
key = (external_api_config.tour_api_key or "").strip()
if not key:
return RawSource.failure(url, self.id, "TOUR_API_KEY 가 설정되지 않았다", channel)
parsed = self._parse_url(url)
if not parsed:
return RawSource.failure(url, self.id, f"콘텐츠 id 를 찾지 못했다: {url}", channel)
content_id, content_type = parsed
try:
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
common = await self._call(client, key, "detailCommon2", contentId=content_id)
if not common:
return RawSource.failure(url, self.id, f"detailCommon2 결과 없음(contentId={content_id})", channel)
common = common[0]
# URL 에 타입이 없으면 공통정보가 알려준다.
content_type = content_type or str(common.get("contenttypeid") or "")
if content_type not in _SUPPORTED:
return RawSource.failure(
url, self.id,
f"지원하지 않는 콘텐츠 타입: {content_type} (숙박 32 · 음식점 39 만 받는다)",
channel,
)
intro = await self._call(client, key, "detailIntro2", contentId=content_id, contentTypeId=content_type)
info = await self._call(client, key, "detailInfo2", contentId=content_id, contentTypeId=content_type)
images = await self._call(client, key, "detailImage2", contentId=content_id, imageYN="Y")
except httpx.TimeoutException:
return RawSource.failure(url, self.id, f"{REQUEST_TIMEOUT}s 안에 응답이 없다", channel)
except httpx.HTTPError as ex:
return RawSource.failure(url, self.id, f"네트워크 오류: {ex}", channel)
except RuntimeError as ex:
return RawSource.failure(url, self.id, str(ex), channel)
source_url = SOURCE_URL.format(content_id=content_id)
intro_row = intro[0] if intro else {}
facts = self._common_facts(common)
if content_type == CONTENT_TYPE_LODGING:
facts += self._lodging_facts(intro_row)
facts += self._room_facts(info)
else:
facts += self._restaurant_facts(intro_row)
media = self._media(common, images, info if content_type == CONTENT_TYPE_LODGING else [])
LOG.i(
f"[tour_api] {common.get('title')} — fact {len(facts)}건 · 사진 {len(media)}장 "
f"(contentId={content_id} type={content_type})"
)
return RawSource(
url=source_url,
adapter_id=self.id,
channel=channel,
text=self._plain(common.get("overview")),
facts=facts,
media=media,
)
# HTTP
async def _call(self, client: httpx.AsyncClient, key: str, op: str, **params) -> list[dict]:
"""오퍼레이션 1회."""
query = urlencode(
{"serviceKey": unquote(key), "MobileOS": "ETC", "MobileApp": "o2o-web4ai",
"_type": "json", "numOfRows": "50", "pageNo": "1", **params},
safe="",
)
res = await client.get(f"{BASE_URL}/{op}?{query}")
if res.status_code != 200:
raise RuntimeError(f"{op} HTTP {res.status_code}")
try:
payload = res.json()
except ValueError:
# 인증 실패·쿼터 초과는 XML 로 온다.
raise RuntimeError(f"{op} 응답이 JSON 이 아니다: {res.text[:160]}")
header = payload.get("response", {}).get("header", {})
code = str(header.get("resultCode") or "")
if code not in ("0000", "00"):
raise RuntimeError(f"{op} 실패 [{code}] {header.get('resultMsg')}")
items = (payload.get("response", {}).get("body", {}).get("items") or {}).get("item")
if isinstance(items, dict):
return [items]
return items or []
@staticmethod
def _parse_url(url: str) -> Optional[tuple[str, str]]:
"""(contentId, contentTypeId) — 타입을 모르면 빈 문자열."""
m = _TOUR_SCHEME.match((url or "").strip())
if m:
return m.group(2), m.group(1)
m = _COTID.search(url or "")
return (m.group(1), "") if m else None
# 값 다듬기
@classmethod
def _plain(cls, value) -> str:
"""<br> 은 줄바꿈으로, 나머지 태그는 제거."""
if value is None:
return ""
text = _TAGS.sub("", _BR.sub("\n", str(value)))
return re.sub(r"[ \t]+", " ", text).strip()
@classmethod
def _yn(cls, value) -> Optional[bool]:
"""'Y'/'N' 또는 '1'/'0'."""
v = str(value or "").strip().upper()
if v in ("Y", "1"):
return True
if v in ("N", "0"):
return False
return None
@classmethod
def _head_bool(cls, value) -> Optional[bool]:
"""'가능 (…)' / '불가 (…)' 처럼 **맨 앞 토큰**이 부호인 필드."""
text = cls._plain(value)
if not text:
return None
head = text[:6]
if any(neg in head for neg in _NEG_HEAD):
return False
if any(pos in head for pos in _POS_HEAD):
return True
return None
@classmethod
def _number(cls, value) -> Optional[str]:
"""'13실' · '11,570㎡' → '13' · '11570'."""
m = _LEADING_NUM.match(cls._plain(value))
if not m:
return None
num = m.group(1).replace(",", "")
return num if num not in ("", "0") else None
@staticmethod
def _fact(key: str, value: Optional[str], *, unit: Optional[str] = None) -> Optional[CollectedFact]:
if value is None or not str(value).strip():
return None
return CollectedFact(
key=key, value=str(value).strip(),
scope="unit" if unit else "place", unit_name=unit,
)
@classmethod
def _collect(cls, pairs) -> list[CollectedFact]:
return [f for f in (cls._fact(*p[:2], unit=p[2] if len(p) > 2 else None) for p in pairs) if f]
# 업종별 매핑
def _common_facts(self, common: dict) -> list[CollectedFact]:
"""모든 업종 공통."""
return []
def _lodging_facts(self, intro: dict) -> list[CollectedFact]:
"""숙박 detailIntro2 → 사업장 단위 fact."""
return self._collect([
("check_in_time", self._plain(intro.get("checkintime"))[:40]),
("check_out_time", self._plain(intro.get("checkouttime"))[:40]),
("cancel_policy", self._plain(intro.get("refundregulation"))[:1000]),
("cooking_allowed", self._bool_str(self._head_bool(intro.get("chkcooking")))),
("parking", self._bool_str(self._head_bool(intro.get("parkinglodging")))),
("pickup_service", self._bool_str(self._head_bool(intro.get("pickup")))),
("bbq_available", self._bool_str(self._yn(intro.get("barbecue")))),
("facilities", self._plain(intro.get("subfacility"))[:500]),
("total_rooms", self._number(intro.get("roomcount"))),
("accommodation_capacity", self._number(intro.get("accomcountlodging"))),
# "약 23평" · "대지 면적 11,570㎡" 처럼 단위가 제각각이라 숫자로 뽑지 않고 원문을 싣는다.
("building_scale", self._plain(intro.get("scalelodging"))[:200]),
])
def _restaurant_facts(self, intro: dict) -> list[CollectedFact]:
"""음식점 detailIntro2 → 사업장 단위 fact."""
facts = self._collect([
("business_hours", self._plain(intro.get("opentimefood"))[:500]),
("closed_days", self._plain(intro.get("restdatefood"))[:200]),
("signature_menu", self._plain(intro.get("firstmenu"))[:200]),
("parking", self._bool_str(self._head_bool(intro.get("parkingfood")))),
("takeout", self._bool_str(self._head_bool(intro.get("packing")))),
("reservation_required", self._bool_str(self._head_bool(intro.get("reservationfood")))),
])
# treatmenu 는 "돔베고기 / 멸치국수 / 물만두 등" 처럼 취급 메뉴를 이어 붙여 준다.
for name in self._split_menu(intro.get("treatmenu")):
facts.append(CollectedFact(key="menu_name", value=name, scope="unit", unit_name=name))
return facts
@classmethod
def _split_menu(cls, value) -> list[str]:
"""'게살해물요리 / 난자완스 / 특색냉채 등' → 3건."""
text = cls._plain(value)
if not text:
return []
out: list[str] = []
for token in re.split(r"[/,·]", text):
name = re.sub(r"\s*등\s*$", "", token).strip()
if len(name) >= 2 and name not in out:
out.append(name)
return out[:20]
def _room_facts(self, rooms: list[dict]) -> list[CollectedFact]:
"""숙박 detailInfo2 → **객실 단위** fact."""
facts: list[CollectedFact] = []
seen: list[str] = []
for row in rooms:
name = self._plain(row.get("roomtitle"))
if not name or name in seen:
continue
seen.append(name)
facts += self._collect([
("room_type", name, name),
("standard_capacity", self._number(row.get("roombasecount")), name),
("max_capacity", self._number(row.get("roommaxcount")), name),
("room_size", self._number(row.get("roomsize2")), name),
("weekday_price", self._number(row.get("roomoffseasonminfee1")), name),
("weekend_price", self._number(row.get("roomoffseasonminfee2")), name),
("peak_price", self._number(row.get("roompeakseasonminfee1")), name),
("has_kitchen", self._bool_str(self._yn(row.get("roomcook"))), name),
("has_aircon", self._bool_str(self._yn(row.get("roomaircondition"))), name),
# 객실 편의시설 Y/N.
("has_bathroom", self._bool_str(self._yn(row.get("roombathfacility"))), name),
("has_tv", self._bool_str(self._yn(row.get("roomtv"))), name),
("has_internet", self._bool_str(self._yn(row.get("roominternet"))), name),
("has_refrigerator", self._bool_str(self._yn(row.get("roomrefrigerator"))), name),
("has_hairdryer", self._bool_str(self._yn(row.get("roomhairdryer"))), name),
("has_toiletries", self._bool_str(self._yn(row.get("roomtoiletries"))), name),
("has_table", self._bool_str(self._yn(row.get("roomtable"))), name),
("has_sofa", self._bool_str(self._yn(row.get("roomsofa"))), name),
("has_home_theater", self._bool_str(self._yn(row.get("roomhometheater"))), name),
])
return facts
@staticmethod
def _bool_str(value: Optional[bool]) -> Optional[str]:
"""None(모름)과 False(아니오)를 구분한다."""
return None if value is None else ("true" if value else "false")
# 사진
def _media(self, common: dict, images: list[dict], rooms: list[dict]) -> list[CollectedMedia]:
"""대표사진 + 추가사진 + 객실사진."""
out: list[CollectedMedia] = []
seen: set[str] = set()
dropped = 0
def add(url, license_code, label=None, unit=None) -> None:
nonlocal dropped
url = (url or "").strip()
if not url or url in seen:
return
code = str(license_code or "").strip().lower()
# 라이선스를 모르면 담지 않는다.
if code not in COMMERCIAL_OK_LICENSES:
dropped += 1
return
seen.add(url)
out.append(CollectedMedia(
origin_url=url, label=label or None, unit_name=unit,
license=code,
))
add(common.get("firstimage"), common.get("cpyrhtDivCd"), label=self._plain(common.get("title")))
for img in images:
add(img.get("originimgurl"), img.get("cpyrhtDivCd"), label=self._plain(img.get("imgname")))
for row in rooms:
unit = self._plain(row.get("roomtitle")) or None
for i in range(1, 6):
add(row.get(f"roomimg{i}"), row.get(f"cpyrhtDivCd{i}"),
label=self._plain(row.get(f"roomimg{i}alt")), unit=unit)
if len(out) >= MAX_MEDIA:
break
if dropped:
LOG.i(f"[tour_api] 상업적 이용이 허용되지 않은 사진 {dropped}장 제외(공공누리 유형)")
return out[:MAX_MEDIA]