Skip to main content
Glama
api_client.py54.4 kB
import httpx import logging import asyncio import urllib.parse import json import codecs from typing import Dict, Optional, Any, Literal, ClassVar from cachetools import TTLCache from tenacity import ( retry, stop_after_attempt, wait_exponential, retry_if_exception_type, ) from ratelimit import limits, sleep_and_retry # Map of content type IDs to their human-readable names CONTENTTYPE_ID_MAP = { "76": "Tourist Attraction", "78": "Cultural Facility", "85": "Festival Event", "75": "Leisure Activity", "80": "Accommodation", "79": "Shopping", "82": "Restaurant", "77": "Transportation", } # Map of supported languages to their service endpoints LANGUAGE_SERVICE_MAP = { "en": "EngService2", # English "jp": "JpnService2", # Japanese "zh-cn": "ChsService2", # Simplified Chinese "zh-tw": "ChtService2", # Traditional Chinese "de": "GerService2", # German "fr": "FreService2", # French "es": "SpnService2", # Spanish "ru": "RusService2", # Russian } def decode_unicode_escapes(obj: Any) -> Any: """ Recursively decode unicode escape sequences in strings within data structures. This helps resolve Korean character encoding issues like \\uc11c\\uc6b8 -> 서울. Only processes strings that actually contain unicode escape sequences. """ if isinstance(obj, str): # Only attempt decoding if the string contains unicode escape sequences if "\\u" in obj: try: # Try to decode unicode escape sequences (e.g., \\uc11c\\uc6b8 -> 서울) return codecs.decode(obj, "unicode_escape") except (UnicodeDecodeError, ValueError): # If decoding fails, return original string return obj else: # If no escape sequences, return as-is return obj elif isinstance(obj, dict): return {key: decode_unicode_escapes(value) for key, value in obj.items()} elif isinstance(obj, list): return [decode_unicode_escapes(item) for item in obj] else: return obj class TourismApiError(Exception): """Base exception for Tourism API errors""" def __init__( self, message: str, response: Optional[httpx.Response] = None, request: Optional[httpx.Request] = None, ): super().__init__(message) self.message = message self.response = response self.request = request def __str__(self) -> str: base_str = super().__str__() if self.response: base_str += f" (Status Code: {self.response.status_code})" if self.request: base_str += f" (Request URL: {self.request.url})" return base_str class TourismApiConnectionError(TourismApiError): """Connection error with Tourism API""" # Usually no response, so focus on request def __init__(self, message: str, request: Optional[httpx.Request] = None): super().__init__(message, response=None, request=request) class TourismApiClientError(TourismApiError): """Client-side error with Tourism API requests (4xx)""" pass class TourismApiServerError(TourismApiError): """Server-side error with Tourism API operations (5xx)""" pass class KoreaTourismApiClient: """ Client for the Korea Tourism Organization API with caching and rate limiting. Features: - Multi-language support - Response caching with TTL - Rate limiting to respect API quotas - Automatic retries for transient errors - Connection pooling """ # Base URL for all services BASE_URL = "http://apis.data.go.kr/B551011" # --- Constants for API Parameters --- DEFAULT_NUM_OF_ROWS = 100 DEFAULT_PAGE_NO = 1 MOBILE_OS = "ETC" MOBILE_APP = "MobileApp" RESPONSE_FORMAT = "json" ARRANGE_MODIFIED_WITH_IMAGE = "Q" # Sort by modified date with image # --- End Constants --- # Common endpoints (will be prefixed with service name) AREA_BASED_LIST_ENDPOINT = "/areaBasedList2" LOCATION_BASED_LIST_ENDPOINT = "/locationBasedList2" SEARCH_KEYWORD_ENDPOINT = "/searchKeyword2" SEARCH_FESTIVAL_ENDPOINT = "/searchFestival2" SEARCH_STAY_ENDPOINT = "/searchStay2" DETAIL_COMMON_ENDPOINT = "/detailCommon2" DETAIL_INTRO_ENDPOINT = "/detailIntro2" DETAIL_INFO_ENDPOINT = "/detailInfo2" DETAIL_IMAGE_ENDPOINT = "/detailImage2" AREA_BASED_SYNC_LIST_ENDPOINT = "/areaBasedSyncList2" AREA_CODE_LIST_ENDPOINT = "/areaCode2" CATEGORY_CODE_LIST_ENDPOINT = "/categoryCode2" # Class-level connection pool and semaphore for concurrency control _shared_client: ClassVar[Optional[httpx.AsyncClient]] = None _client_lock: ClassVar[asyncio.Lock] = asyncio.Lock() # Make concurrency limit configurable # _request_semaphore: ClassVar[asyncio.Semaphore] = asyncio.Semaphore(10) def __init__( self, api_key: str, language: str = "en", cache_ttl: int = 86400, rate_limit_calls: int = 5, rate_limit_period: int = 1, concurrency_limit: int = 10, ): """ Initialize with API key and optional configurations. Args: api_key: The API key for the Korea Tourism Organization. language: Default language code for content (en, ko, jp, etc.). cache_ttl: Time-to-live for cached responses in seconds. rate_limit_calls: Maximum number of API calls allowed. rate_limit_period: Time period (in seconds) for the rate limit. concurrency_limit: Maximum number of concurrent API requests. """ self.api_key = api_key if ( not self.api_key or self.api_key == "missing_api_key" ): # Check for placeholder too # Log warning here instead of raising immediately, allows lazy init check logging.warning("Korean Tourism API key is missing or invalid.") # We don't raise here because get_api_client in server.py handles this self._logger_name = "tourism_api_client" # Store configuration parameters self.default_language = language.lower() self.language: Optional[str] = None # Initialize language attribute self._cache_ttl = cache_ttl self._rate_limit_calls = rate_limit_calls self._rate_limit_period = rate_limit_period self._concurrency_limit = concurrency_limit # Lazy initialization flags/placeholders self.service_name: Optional[str] = None self.full_base_url: Optional[str] = None self._is_fully_initialized = False self._cache: Optional[TTLCache] = None self._request_semaphore: Optional[asyncio.Semaphore] = None self.logger: Optional[logging.Logger] = None # Add logger type hint def _ensure_full_initialization(self): """Ensure all initialization tasks are completed before first API request""" if self._is_fully_initialized: return # Get logger first self.logger = logging.getLogger(self._logger_name) if ( not self.logger.hasHandlers() ): # Basic check to prevent duplicate handlers if called multiple times logging.basicConfig(level=logging.INFO) # Basic config if not already set # Validate API key presence here before proceeding if not self.api_key or self.api_key == "missing_api_key": self.logger.error("Cannot proceed without a valid KOREA_TOURISM_API_KEY.") # Raising here prevents further operations if key is truly missing raise ValueError("Korean Tourism API key must be provided and valid.") # Initialize language service using the stored default self.language = self.default_language if self.language not in LANGUAGE_SERVICE_MAP: self.logger.warning( f"Unsupported language: {self.language}. Falling back to English." ) self.language = "en" self.service_name = LANGUAGE_SERVICE_MAP[self.language] self.full_base_url = f"{self.BASE_URL}/{self.service_name}" # Initialize cache with configured TTL self._cache = TTLCache(maxsize=1000, ttl=self._cache_ttl) # Initialize semaphore with configured concurrency limit self._request_semaphore = asyncio.Semaphore(self._concurrency_limit) self._is_fully_initialized = True @property def cache(self) -> TTLCache: """Lazy cache initialization""" self._ensure_full_initialization() return self._cache # type: ignore # We know it's initialized after _ensure_full_initialization @classmethod async def get_shared_client(cls) -> httpx.AsyncClient: """Get or create the shared HTTP client with connection pooling""" async with cls._client_lock: if cls._shared_client is None: cls._shared_client = httpx.AsyncClient( limits=httpx.Limits( max_connections=100, max_keepalive_connections=20 ), timeout=httpx.Timeout(30.0), ) return cls._shared_client @classmethod async def close_all_connections(cls): """Close the shared client connection - call this when your application is shutting down""" async with cls._client_lock: if cls._shared_client is not None: await cls._shared_client.aclose() cls._shared_client = None def _process_response_error(self, response: httpx.Response): """Process HTTP errors and raise appropriate exceptions""" # Check status code first for efficiency if response.status_code >= 400: status_code = response.status_code request = response.request # Get the request object # Try to extract error message error_msg = f"Tourism API error: HTTP {status_code}" try: error_data = response.json() if isinstance(error_data, dict) and "error" in error_data: error_msg = f"Tourism API error: {error_data['error']}" except Exception: pass # Map status code to exception type if status_code >= 400 and status_code < 500: # Pass response and request to the exception raise TourismApiClientError( f"Client error: {error_msg}", response=response, request=request ) else: # Pass response and request to the exception raise TourismApiServerError( f"Server error: {error_msg}", response=response, request=request ) def _get_cache_key( self, endpoint: str, params: Dict[str, Any], language: str ) -> str: """Generate a unique cache key for an API request, including language.""" # Sort params to ensure consistent keys, exclude common/dynamic ones sorted_params = sorted( [ (k, v) for k, v in params.items() if k not in ("MobileOS", "MobileApp", "serviceKey", "_type") ] ) # Include language in cache key param_str = f"lang={language}&" + "&".join( [f"{k}={v}" for k, v in sorted_params] ) return f"{endpoint}?{param_str}" @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), retry=retry_if_exception_type( (httpx.ConnectTimeout, httpx.ConnectError, TourismApiServerError) ), reraise=True, ) async def _make_request( self, endpoint: str, params: Dict[str, Any], use_cache: bool = True, language_override: Optional[str] = None, ) -> Dict[str, Any]: """Make a request to the API with caching, rate limiting, and language override.""" # Ensure all initialization is completed self._ensure_full_initialization() # After initialization, these are guaranteed to be not None assert self._request_semaphore is not None # Determine the language and base URL for this specific request request_language = self.language or "en" # Fallback to English if None if language_override: lang_lower = language_override.lower() if lang_lower in LANGUAGE_SERVICE_MAP: request_language = lang_lower request_service_name = LANGUAGE_SERVICE_MAP[request_language] request_full_base_url = f"{self.BASE_URL}/{request_service_name}" # Check cache first if caching is enabled, using the request-specific language if use_cache: cache_key = self._get_cache_key(endpoint, params, request_language) cached_response = self.cache.get(cache_key) if cached_response: return cached_response # Apply rate limiting dynamically here using instance attributes @sleep_and_retry @limits(calls=self._rate_limit_calls, period=self._rate_limit_period) async def rate_limited_request() -> Dict[str, Any]: # Use concurrency semaphore to limit simultaneous requests semaphore = self._request_semaphore assert semaphore is not None, "Semaphore should be initialized" async with semaphore: # Add common parameters using constants full_params = { "MobileOS": self.MOBILE_OS, "MobileApp": self.MOBILE_APP, # Defaults like numOfRows/pageNo are better set by calling methods or API defaults "_type": self.RESPONSE_FORMAT, **params, } # API key handling remains the same for now serviceKey = ( self.api_key ) # Already validated in _ensure_full_initialization # Build the full URL with the determined service for this request url = f"{request_full_base_url}{endpoint}" client = await self.get_shared_client() # First, encode the parameters encoded_params = urllib.parse.urlencode(full_params) # Then append the already-encoded service key full_url = f"{url}?serviceKey={serviceKey}&{encoded_params}" response = await client.get(full_url) self._process_response_error(response) # Parse the response with better error handling try: # Check if the response has content if not response.content or len(response.content.strip()) == 0: raise TourismApiError( "Empty response received from tourism API" ) result = response.json() except json.JSONDecodeError as e: raise TourismApiError(f"Invalid JSON response: {str(e)}") # Extract the items from the nested response structure try: response_header = result["response"]["header"] response_body = result["response"]["body"] result_code = response_header.get("resultCode") if result_code != "0000": raise TourismApiError( f"API error: {response_header.get('resultMsg', 'Unknown error')}" ) total_count = response_body.get("totalCount", 0) items = [] if total_count > 0: items_container = response_body.get("items", {}) if "item" in items_container: items = items_container["item"] if not isinstance(items, list): items = [ items ] # Ensure items is a list even if there's only one result # Structure the results result_data = { "total_count": total_count, "num_of_rows": response_body.get("numOfRows", 0), "page_no": response_body.get("pageNo", 1), "items": items, } # Apply unicode decoding to handle Korean character encoding issues result_data = decode_unicode_escapes(result_data) # Cache the response if caching is enabled, using the request-specific language if use_cache: cache_key = self._get_cache_key( endpoint, params, request_language ) self.cache[cache_key] = result_data return result_data except (KeyError, TypeError) as e: raise TourismApiError(f"Failed to parse API response: {e}") # Call the inner function that has the decorators applied result = rate_limited_request() return await result # type: ignore[misc] async def search_by_keyword( self, keyword: str, content_type_id: Optional[str] = None, area_code: Optional[str] = None, sigungu_code: Optional[str] = None, cat1: Optional[str] = None, cat2: Optional[str] = None, cat3: Optional[str] = None, language: Optional[str] = None, page: int = 1, rows: int = 20, ) -> Dict[str, Any]: """ Search tourism information by keyword. /searchKeyword1 Args: keyword: Search keyword (required) content_type_id: Content type ID to filter results area_code: Area code to filter results sigungu_code: Sigungu code to filter results, areaCode is required cat1: Major category code cat2: Middle category code (requires cat1) cat3: Minor category code (requires cat1 and cat2) language: Override the client's default language page: Page number for pagination rows: Number of items per page Returns: Dictionary containing search results with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List of tourism items { "title": str, # Name of the attraction/place "addr1": str, # Primary address "addr2": str, # Secondary address "areacode": str, # Area code "sigungucode": str, # Sigungu code "cat1": str, # Category 1 code "cat2": str, # Category 2 code "cat3": str, # Category 3 code "contentid": str, # Unique content ID "contenttypeid": str, # Content type ID "createdtime": str, # Creation timestamp "modifiedtime": str, # Last modified timestamp "tel": str, # Phone number "firstimage": str, # URL of main image "firstimage2": str, # URL of thumbnail image "mapx": str, # Longitude "mapy": str, # Latitude "mlevel": str, # Map level "cpyrhtDivCd": str # Copyright division code }, # ... more items ] } """ if not keyword: raise ValueError("Keyword must be provided for search_by_keyword") params: Dict[str, Any] = { "keyword": keyword, "arrange": self.ARRANGE_MODIFIED_WITH_IMAGE, "pageNo": str(page), "numOfRows": str(rows), } if content_type_id: params["contentTypeId"] = content_type_id if area_code: params["areaCode"] = area_code if sigungu_code: params["sigunguCode"] = sigungu_code if cat1: params["cat1"] = cat1 if cat2: params["cat2"] = cat2 if cat3: params["cat3"] = cat3 # Pass language override directly to _make_request return await self._make_request( self.SEARCH_KEYWORD_ENDPOINT, params, language_override=language ) async def get_area_based_list( self, area_code: Optional[str] = None, content_type_id: Optional[str] = None, sigunguCode: Optional[str] = None, cat1: Optional[str] = None, cat2: Optional[str] = None, cat3: Optional[str] = None, language: Optional[str] = None, page: int = 1, rows: int = 20, ) -> Dict[str, Any]: """ Get a list of tourism information by area. Args: area_code: Area code to filter results content_type_id: Content type ID to filter results cat1: Major category code cat2: Middle category code (requires cat1) cat3: Minor category code (requires cat1 and cat2) language: Override the client's default language page: Page number for pagination rows: Number of items per page sigunguCode: Sigungu code to filter results, areaCode is required Returns: Dictionary containing area-based tourism information with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List of tourism items { "title": str, # Name of the attraction/place "addr1": str, # Primary address "addr2": str, # Secondary address "areacode": str, # Area code "sigungucode": str, # Sigungu code "cat1": str, # Category 1 code "cat2": str, # Category 2 code "cat3": str, # Category 3 code "contentid": str, # Unique content ID "contenttypeid": str, # Content type ID "createdtime": str, # Creation timestamp "modifiedtime": str, # Last modified timestamp "tel": str, # Phone number "firstimage": str, # URL of main image "firstimage2": str, # URL of thumbnail image "mapx": str, # Longitude "mapy": str, # Latitude "zipcode": str, # Postal code "mlevel": str # Map level }, # ... more items ] } """ params: Dict[str, Any] = { "arrange": self.ARRANGE_MODIFIED_WITH_IMAGE, "pageNo": str(page), "numOfRows": str(rows), # "_type": self.RESPONSE_FORMAT, # Added in _make_request } # Add optional filters if area_code: params["areaCode"] = area_code if sigunguCode: params["sigunguCode"] = sigunguCode if content_type_id: params["contentTypeId"] = content_type_id # Add category filters if provided if cat1: params["cat1"] = cat1 if cat2: params["cat2"] = cat2 if cat3: params["cat3"] = cat3 # Pass language override directly to _make_request return await self._make_request( self.AREA_BASED_LIST_ENDPOINT, params, language_override=language ) async def get_location_based_list( self, mapx: float, mapy: float, radius: int, content_type_id: Optional[str] = None, language: Optional[str] = None, page: int = 1, rows: int = 20, ) -> Dict[str, Any]: """ Get a list of tourism information by location. Args: mapx: Map X coordinate mapy: Map Y coordinate radius: Radius in meters content_type_id: Content type ID from the tourism API language: Override the client's default language page: Page number for pagination rows: Number of items per page Returns: Dictionary containing location-based tourism information with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List of tourism items { "title": str, # Name of the attraction/place "addr1": str, # Primary address "addr2": str, # Secondary address "areacode": str, # Area code "sigungucode": str, # Sigungu code "cat1": str, # Category 1 code "cat2": str, # Category 2 code "cat3": str, # Category 3 code "contentid": str, # Unique content ID "contenttypeid": str, # Content type ID "createdtime": str, # Creation timestamp "modifiedtime": str, # Last modified timestamp "tel": str, # Phone number "firstimage": str, # URL of main image "firstimage2": str, # URL of thumbnail image "mapx": str, # Longitude "mapy": str, # Latitude "mlevel": str, # Map level "dist": str # Distance from the specified coordinates }, # ... more items ] } """ # Parameter validation if mapx is None or mapy is None or radius is None: raise ValueError( "mapx, mapy, and radius are required for location-based search" ) try: # Ensure radius is a positive integer radius_int = int(radius) if radius_int <= 0: raise ValueError("radius must be a positive integer") except ValueError: raise ValueError("radius must be a valid integer") params: Dict[str, Any] = { "pageNo": str(page), "numOfRows": str(rows), "arrange": self.ARRANGE_MODIFIED_WITH_IMAGE, "mapX": str(mapx), "mapY": str(mapy), "radius": str(radius_int), } if content_type_id: params["contentTypeId"] = content_type_id # Pass language override directly to _make_request return await self._make_request( self.LOCATION_BASED_LIST_ENDPOINT, params, language_override=language ) async def search_festival( self, event_start_date: str, event_end_date: Optional[str] = None, area_code: Optional[str] = None, sigungu_code: Optional[str] = None, language: Optional[str] = None, page: int = 1, rows: int = 20, ) -> Dict[str, Any]: """ Search for festivals by date and location. Args: event_start_date: Start date of the event (YYYYMMDD) event_end_date: End date of the event (YYYYMMDD) area_code: Area code to filter results sigungu_code: Sigungu code to filter results, areaCode is required language: Override the client's default language page: Page number for pagination rows: Number of items per page Returns: Dictionary containing festival information with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List of festival items { "title": str, # Name of the festival "addr1": str, # Primary address "addr2": str, # Secondary address "areacode": str, # Area code "contentid": str, # Unique content ID "contenttypeid": str, # Content type ID "createdtime": str, # Creation timestamp "eventstartdate": str, # Festival start date "eventenddate": str, # Festival end date "firstimage": str, # URL of main image "firstimage2": str, # URL of thumbnail image "mapx": str, # Longitude "mapy": str, # Latitude "mlevel": str, # Map level "tel": str, # Phone number "cat1": str, # Category 1 code "cat2": str, # Category 2 code "cat3": str # Category 3 code }, # ... more items ] } """ if not event_start_date: raise ValueError("event_start_date (YYYYMMDD) is required") # Basic format check (can be improved with regex) if not ( isinstance(event_start_date, str) and len(event_start_date) == 8 and event_start_date.isdigit() ): raise ValueError("event_start_date must be in YYYYMMDD format") if event_end_date and not ( isinstance(event_end_date, str) and len(event_end_date) == 8 and event_end_date.isdigit() ): raise ValueError("event_end_date must be in YYYYMMDD format") params: Dict[str, Any] = { "eventStartDate": event_start_date, "pageNo": str(page), "numOfRows": str(rows), "arrange": self.ARRANGE_MODIFIED_WITH_IMAGE, # Default arrange added } if event_end_date: params["eventEndDate"] = event_end_date if area_code: params["areaCode"] = area_code if sigungu_code: params["sigunguCode"] = sigungu_code # Pass language override directly to _make_request return await self._make_request( self.SEARCH_FESTIVAL_ENDPOINT, params, language_override=language ) async def search_stay( self, area_code: Optional[str] = None, sigungu_code: Optional[str] = None, rows: int = 20, page: int = 1, language: Optional[str] = None, ) -> Dict[str, Any]: """ Search for stays by area and sigungu. Args: area_code: Area code to filter results sigungu_code: Sigungu code to filter results, areaCode is required rows: Number of items per page page: Page number for pagination language: Override the client's default language Returns: Dictionary containing accommodation information with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List of accommodation items { "title": str, # Name of the accommodation "addr1": str, # Primary address "addr2": str, # Secondary address "areacode": str, # Area code "sigungucode": str, # Sigungu code "contentid": str, # Unique content ID "contenttypeid": str, # Content type ID "createdtime": str, # Creation timestamp "firstimage": str, # URL of main image "firstimage2": str, # URL of thumbnail image "mapx": str, # Longitude "mapy": str, # Latitude "mlevel": str, # Map level "tel": str, # Phone number "cat1": str, # Category 1 code "cat2": str, # Category 2 code "cat3": str, # Category 3 code "hanok": str, # Korean traditional house flag "benikia": str, # Benikia hotel flag "goodstay": str # Goodstay accommodation flag }, # ... more items ] } """ params: Dict[str, Any] = { "pageNo": str(page), "numOfRows": str(rows), "arrange": self.ARRANGE_MODIFIED_WITH_IMAGE, # Default arrange added } if area_code: params["areaCode"] = area_code if sigungu_code: params["sigunguCode"] = sigungu_code # Pass language override directly to _make_request return await self._make_request( self.SEARCH_STAY_ENDPOINT, params, language_override=language ) async def get_detail_common( self, content_id: str, language: Optional[str] = None, rows: int = 20, page: int = 1, ) -> Dict[str, Any]: """ Get common information by type basic information, schematic image, representative image, classification information, regional information, address information, coordinate information, outline information, road guidance information, image information, linked tourism information list Args: content_id: Content ID from the tourism API language: Override the client's default language rows: Number of items per page page: Page number for pagination Returns: Dictionary containing common details about a tourism item with structure: { "total_count": int, # Total number of matching items (typically 1) "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List containing a single item's details { "title": str, # Name of the item "contentid": str, # Unique content ID "contenttypeid": str, # Content type ID "addr1": str, # Primary address "addr2": str, # Secondary address "areacode": str, # Area code "sigungucode": str, # Sigungu code "cat1": str, # Category 1 code "cat2": str, # Category 2 code "cat3": str, # Category 3 code "mapx": str, # Longitude "mapy": str, # Latitude "mlevel": str, # Map level "overview": str, # Detailed description "tel": str, # Phone number "telname": str, # Contact name "homepage": str, # Website URL "firstimage": str, # URL of main image "firstimage2": str, # URL of thumbnail image "createdtime": str, # Creation timestamp "modifiedtime": str, # Last modified timestamp "zipcode": str # Postal code } ] } """ if not content_id: raise ValueError("content_id is required") params: Dict[str, Any] = { "contentId": content_id, "pageNo": str(page), "numOfRows": str(rows), } # Pass language override directly to _make_request return await self._make_request( self.DETAIL_COMMON_ENDPOINT, params, language_override=language ) async def get_detail_images( self, content_id: str, language: Optional[str] = None, rows: int = 20, page: int = 1, ) -> Dict[str, Any]: """ Get images for a tourism item. Args: content_id: Content ID from the tourism API language: Override the client's default language rows: Number of items per page page: Page number for pagination Returns: Dictionary containing images for a tourism item with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List of image items { "contentid": str, # Content ID this image belongs to "imgname": str, # Image name "originimgurl": str, # URL of original image "smallimageurl": str, # URL of small/thumbnail image "serialnum": str, # Serial number "cpyrhtDivCd": str # Copyright division code }, # ... more items ] } """ if not content_id: raise ValueError("content_id is required") params: Dict[str, Any] = { "contentId": content_id, "numOfRows": str(rows), "pageNo": str(page), } # Pass language override directly to _make_request return await self._make_request( self.DETAIL_IMAGE_ENDPOINT, params, language_override=language ) async def get_detail_intro( self, content_id: str, content_type_id: str, language: Optional[str] = None, rows: int = 20, page: int = 1, ) -> Dict[str, Any]: """ Function to check detailed introduction (off day, opening period, etc.) Args: content_id: Content ID from the tourism API content_type_id: Content type ID from the tourism API language: Override the client's default language rows: Number of items per page page: Page number for pagination Returns: Dictionary containing detailed introduction information with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List containing a single item's intro details { "contentid": str, # Content ID "contenttypeid": str, # Content type ID # The following fields vary based on content_type_id: # Attraction specific fields may include: "infocenter": str, # Information center "restdate": str, # Rest/closing days "usetime": str, # Hours of operation "parking": str, # Parking information "chkbabycarriage": str, # Baby carriage accessibility "chkpet": str, # Pet allowance "chkcreditcard": str, # Credit card acceptance # Festival specific fields may include: "eventstartdate": str, # Festival start date "eventenddate": str, # Festival end date "eventplace": str, # Festival venue "usetimefestival": str, # Festival hours "sponsor1": str, # Primary sponsor "sponsor2": str, # Secondary sponsor # Restaurant specific fields may include: "firstmenu": str, # Main menu items "treatmenu": str, # Specialty dishes "opentimefood": str, # Opening hours "restdatefood": str, # Closing days "reservationfood": str, # Reservation information # Accommodation specific fields may include: "checkintime": str, # Check-in time "checkouttime": str, # Check-out time "roomcount": str, # Number of rooms "reservationurl": str, # Reservation website "benikia": str, # Benikia certification "goodstay": str # Goodstay certification } ] } Note: The actual fields returned depend on the content_type_id and will vary between different types of tourism items. """ if not content_id: raise ValueError("content_id is required") if not content_type_id: raise ValueError("content_type_id is required") params: Dict[str, Any] = { "contentId": content_id, "contentTypeId": content_type_id, "numOfRows": str(rows), "pageNo": str(page), } # Pass language override directly to _make_request return await self._make_request( self.DETAIL_INTRO_ENDPOINT, params, language_override=language ) async def get_detail_info( self, content_id: str, content_type_id: str, language: Optional[str] = None, rows: int = 20, page: int = 1, ) -> Dict[str, Any]: """ Check the details of additional tourism information. Args: content_id: Content ID from the tourism API content_type_id: Content type ID from the tourism API language: Override the client's default language rows: Number of items per page page: Page number for pagination Returns: Dictionary containing additional detailed information with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List of additional info items { "contentid": str, # Content ID this info belongs to "contenttypeid": str, # Content type ID "infoname": str, # Name of the information "infotext": str, # Detailed text information "fldgubun": str, # Field division code "serialnum": str # Serial number }, # ... more items ] } Note: Each item in the 'items' list represents a specific piece of additional information about the tourism item. """ if not content_id: raise ValueError("content_id is required") if not content_type_id: raise ValueError("content_type_id is required") params: Dict[str, Any] = { "contentId": content_id, "contentTypeId": content_type_id, "numOfRows": str(rows), "pageNo": str(page), } # Pass language override directly to _make_request return await self._make_request( self.DETAIL_INFO_ENDPOINT, params, language_override=language ) async def get_area_based_sync_list( self, content_type_id: Optional[str] = None, area_code: Optional[str] = None, sigungu_code: Optional[str] = None, cat1: Optional[str] = None, cat2: Optional[str] = None, cat3: Optional[str] = None, language: Optional[str] = None, show_flag: Optional[Literal["0", "1"]] = None, rows: int = 20, page: int = 1, ) -> Dict[str, Any]: """ Detailed function of inquiring about the tourism information synchronization list (provided whether the contents are displayed or not) Args: area_code: Area code from the tourism API content_type_id: Content type ID from the tourism API sigungu_code: Sigungu code from the tourism API cat1: Category 1 from the tourism API cat2: Category 2 from the tourism API cat3: Category 3 from the tourism API language: Override the client's default language show_flag: Show flag from the tourism API rows: Number of items per page page: Page number for pagination Returns: Dictionary containing synchronized tourism information with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List of tourism items with sync status { "title": str, # Name of the attraction/place "addr1": str, # Primary address "addr2": str, # Secondary address "areacode": str, # Area code "sigungucode": str, # Sigungu code "cat1": str, # Category 1 code "cat2": str, # Category 2 code "cat3": str, # Category 3 code "contentid": str, # Unique content ID "contenttypeid": str, # Content type ID "createdtime": str, # Creation timestamp "modifiedtime": str, # Last modified timestamp "tel": str, # Phone number "firstimage": str, # URL of main image "firstimage2": str, # URL of thumbnail image "mapx": str, # Longitude "mapy": str, # Latitude "mlevel": str, # Map level "showflag": str # Display status flag }, # ... more items ] } """ params: Dict[str, Any] = { "numOfRows": str(rows), # Use str() "pageNo": str(page), # Use str() "arrange": self.ARRANGE_MODIFIED_WITH_IMAGE, } if show_flag: params["showFlag"] = show_flag if area_code: params["areaCode"] = area_code if sigungu_code: params["sigunguCode"] = sigungu_code if cat1: params["cat1"] = cat1 if cat2: params["cat2"] = cat2 if cat3: params["cat3"] = cat3 if content_type_id: params["contentTypeId"] = content_type_id # Pass language override directly to _make_request return await self._make_request( self.AREA_BASED_SYNC_LIST_ENDPOINT, params, language_override=language ) async def get_area_code_list( self, area_code: Optional[str] = None, language: Optional[str] = None, rows: int = 20, page: int = 1, ) -> Dict[str, Any]: """ Get the list of area codes. Args: area_code: Area code from the tourism API language: Override the client's default language rows: Number of items per page page: Page number for pagination Returns: Dictionary containing area code information with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List of area code items { "code": str, # Area code value "name": str, # Area name "rnum": str # Row number }, # ... more items ] } If area_code is provided, returns sigungu codes for that area. If area_code is not provided, returns top-level area codes. """ params: Dict[str, Any] = { "numOfRows": str(rows), # Use str() "pageNo": str(page), # Use str() } if area_code: params["areaCode"] = area_code # Pass language override directly to _make_request return await self._make_request( self.AREA_CODE_LIST_ENDPOINT, params, language_override=language ) async def get_category_code_list( self, content_type_id: Optional[str] = None, language: Optional[str] = None, cat1: Optional[str] = None, cat2: Optional[str] = None, cat3: Optional[str] = None, rows: int = 20, page: int = 1, ) -> Dict[str, Any]: """ Get the list of category codes. Args: content_type_id: Content type ID from the tourism API language: Override the client's default language cat1: Category 1 from the tourism API cat2: Category 2 from the tourism API cat3: Category 3 from the tourism API rows: Number of items per page page: Page number for pagination Returns: Dictionary containing category code information with structure: { "total_count": int, # Total number of matching items "num_of_rows": int, # Number of items per page "page_no": int, # Current page number "items": [ # List of category code items { "code": str, # Category code value "name": str, # Category name "rnum": str # Row number }, # ... more items ] } The categories returned depend on the parameters provided: - Without any parameters: Returns top-level categories (cat1) - With cat1: Returns subcategories (cat2) under that cat1 - With cat1 and cat2: Returns subcategories (cat3) under that cat2 """ params: Dict[str, Any] = { "numOfRows": str(rows), # Use str() "pageNo": str(page), # Use str() } if content_type_id: params["contentTypeId"] = content_type_id if cat1: params["cat1"] = cat1 if cat2: params["cat2"] = cat2 if cat3: params["cat3"] = cat3 # Pass language override directly to _make_request return await self._make_request( self.CATEGORY_CODE_LIST_ENDPOINT, params, language_override=language ) if __name__ == "__main__": import os import asyncio api_key = os.environ.get("KOREA_TOURISM_API_KEY") if not api_key: raise ValueError("KOREA_TOURISM_API_KEY environment variable is not set") client = KoreaTourismApiClient(api_key=api_key) print(asyncio.run(client.search_by_keyword(keyword="Gyeongbokgung")))

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/harimkang/mcp-korea-tourism-api'

If you have feedback or need assistance with the MCP directory API, please join our Discord server