"""한국관광공사 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"", 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: """
은 줄바꿈으로, 나머지 태그는 제거.""" 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]