/** * TourAPI 4.0 클라이언트 * 한국관광공사 국문 관광정보 서비스 (KorService2) * * 문서: https://www.data.go.kr/data/15101578/openapi.do * Base URL: https://apis.data.go.kr/B551011/KorService2 */ const axios = require('axios'); // 관광타입 ID const CONTENT_TYPE = { TOURIST_SPOT: '12', // 관광지 CULTURAL: '14', // 문화시설 FESTIVAL: '15', // 행사/공연/축제 TRAVEL_COURSE: '25', // 여행코스 LEISURE: '28', // 레포츠 STAY: '32', // 숙박 (펜션) SHOPPING: '38', // 쇼핑 RESTAURANT: '39', // 음식점 }; // 지역코드 const AREA_CODE = { SEOUL: '1', INCHEON: '2', DAEJEON: '3', DAEGU: '4', GWANGJU: '5', BUSAN: '6', ULSAN: '7', SEJONG: '8', GYEONGGI: '31', GANGWON: '32', CHUNGBUK: '33', CHUNGNAM: '34', GYEONGBUK: '35', GYEONGNAM: '36', JEONBUK: '37', JEONNAM: '38', JEJU: '39', }; // 지역코드 이름 매핑 const AREA_NAME = { '1': '서울', '2': '인천', '3': '대전', '4': '대구', '5': '광주', '6': '부산', '7': '울산', '8': '세종', '31': '경기', '32': '강원', '33': '충북', '34': '충남', '35': '경북', '36': '경남', '37': '전북', '38': '전남', '39': '제주', }; class TourApiClient { constructor() { this.baseUrl = process.env.TOURAPI_ENDPOINT || 'https://apis.data.go.kr/B551011/KorService2'; this.serviceKey = process.env.TOURAPI_KEY; this.client = axios.create({ baseURL: this.baseUrl, timeout: 30000, }); } /** * API 요청 공통 메서드 */ async request(operation, params = {}) { try { const response = await this.client.get(`/${operation}`, { params: { serviceKey: this.serviceKey, MobileOS: 'ETC', MobileApp: 'CastAD', _type: 'json', ...params, }, }); const data = response.data; // 에러 체크 if (data.response?.header?.resultCode !== '0000') { const errorMsg = data.response?.header?.resultMsg || 'Unknown error'; throw new Error(`TourAPI Error: ${errorMsg}`); } const body = data.response?.body; return { items: body?.items?.item || [], totalCount: body?.totalCount || 0, pageNo: body?.pageNo || 1, numOfRows: body?.numOfRows || 10, }; } catch (error) { if (error.response?.status === 401) { throw new Error('TourAPI 인증 실패: 서비스키를 확인하세요'); } throw error; } } // ============================================ // 지역/분류 코드 조회 // ============================================ /** * 지역코드 조회 * @param {string} areaCode - 시도 코드 (없으면 전체 시도 조회) */ async getAreaCodes(areaCode = null) { const params = { numOfRows: 100, pageNo: 1 }; if (areaCode) params.areaCode = areaCode; return this.request('areaCode2', params); } /** * 서비스 분류코드 조회 */ async getCategoryCodes(contentTypeId = null, cat1 = null, cat2 = null) { const params = { numOfRows: 100, pageNo: 1 }; if (contentTypeId) params.contentTypeId = contentTypeId; if (cat1) params.cat1 = cat1; if (cat2) params.cat2 = cat2; return this.request('categoryCode2', params); } // ============================================ // 축제/행사 조회 (핵심!) // ============================================ /** * 행사정보 조회 (축제) * @param {Object} options * @param {string} options.eventStartDate - 행사 시작일 (YYYYMMDD) - 필수! * @param {string} options.eventEndDate - 행사 종료일 (YYYYMMDD) * @param {string} options.areaCode - 지역코드 * @param {string} options.sigunguCode - 시군구코드 * @param {number} options.numOfRows - 한 페이지 결과 수 * @param {number} options.pageNo - 페이지 번호 * @param {string} options.arrange - 정렬 (A=제목순, C=수정일순, D=생성일순) */ async searchFestival(options = {}) { const { eventStartDate = this.getTodayString(), eventEndDate, areaCode, sigunguCode, numOfRows = 100, pageNo = 1, arrange = 'A', } = options; const params = { numOfRows, pageNo, arrange, eventStartDate, }; if (eventEndDate) params.eventEndDate = eventEndDate; if (areaCode) params.areaCode = areaCode; if (sigunguCode) params.sigunguCode = sigunguCode; return this.request('searchFestival2', params); } /** * 진행중인 축제 조회 */ async getOngoingFestivals(areaCode = null) { const today = this.getTodayString(); return this.searchFestival({ eventStartDate: '20240101', // 과거부터 eventEndDate: today, // 오늘까지 종료되지 않은 areaCode, arrange: 'C', // 수정일순 }); } /** * 예정 축제 조회 (향후 6개월) */ async getUpcomingFestivals(areaCode = null, months = 6) { const today = this.getTodayString(); const endDate = this.getDateAfterMonths(months); return this.searchFestival({ eventStartDate: today, eventEndDate: endDate, areaCode, arrange: 'A', // 제목순 }); } // ============================================ // 숙박 조회 (펜션) // ============================================ /** * 숙박정보 조회 * @param {Object} options * @param {string} options.areaCode - 지역코드 * @param {string} options.sigunguCode - 시군구코드 * @param {number} options.numOfRows - 한 페이지 결과 수 * @param {number} options.pageNo - 페이지 번호 * @param {string} options.arrange - 정렬 */ async searchStay(options = {}) { const { areaCode, sigunguCode, numOfRows = 100, pageNo = 1, arrange = 'A', } = options; const params = { numOfRows, pageNo, arrange }; if (areaCode) params.areaCode = areaCode; if (sigunguCode) params.sigunguCode = sigunguCode; return this.request('searchStay2', params); } // ============================================ // 지역/위치 기반 조회 // ============================================ /** * 지역기반 관광정보 조회 */ async getAreaBasedList(options = {}) { const { contentTypeId, areaCode, sigunguCode, cat1, cat2, cat3, numOfRows = 100, pageNo = 1, arrange = 'C', } = options; const params = { numOfRows, pageNo, arrange }; if (contentTypeId) params.contentTypeId = contentTypeId; if (areaCode) params.areaCode = areaCode; if (sigunguCode) params.sigunguCode = sigunguCode; if (cat1) params.cat1 = cat1; if (cat2) params.cat2 = cat2; if (cat3) params.cat3 = cat3; return this.request('areaBasedList2', params); } /** * 위치기반 관광정보 조회 (근처 검색) * @param {number} mapX - 경도 (longitude) * @param {number} mapY - 위도 (latitude) * @param {number} radius - 반경 (미터, 최대 20000) * @param {string} contentTypeId - 관광타입 */ async getLocationBasedList(mapX, mapY, radius = 10000, contentTypeId = null) { const params = { mapX: mapX.toString(), mapY: mapY.toString(), radius: Math.min(radius, 20000), // 최대 20km numOfRows: 100, pageNo: 1, arrange: 'E', // 거리순 }; if (contentTypeId) params.contentTypeId = contentTypeId; return this.request('locationBasedList2', params); } /** * 근처 축제 검색 */ async getNearbyFestivals(mapX, mapY, radius = 20000) { return this.getLocationBasedList(mapX, mapY, radius, CONTENT_TYPE.FESTIVAL); } /** * 근처 숙박시설 검색 */ async getNearbyStay(mapX, mapY, radius = 20000) { return this.getLocationBasedList(mapX, mapY, radius, CONTENT_TYPE.STAY); } // ============================================ // 상세 정보 조회 // ============================================ /** * 공통정보 조회 (상세정보1) */ async getDetailCommon(contentId) { return this.request('detailCommon2', { contentId }); } /** * 소개정보 조회 (상세정보2) - 타입별 상세 */ async getDetailIntro(contentId, contentTypeId) { return this.request('detailIntro2', { contentId, contentTypeId }); } /** * 반복정보 조회 (상세정보3) - 객실정보 등 */ async getDetailInfo(contentId, contentTypeId) { return this.request('detailInfo2', { contentId, contentTypeId }); } /** * 이미지정보 조회 (상세정보4) */ async getDetailImage(contentId) { return this.request('detailImage2', { contentId }); } /** * 축제 전체 상세정보 조회 */ async getFestivalDetail(contentId) { const [common, intro, images] = await Promise.all([ this.getDetailCommon(contentId), this.getDetailIntro(contentId, CONTENT_TYPE.FESTIVAL), this.getDetailImage(contentId), ]); return { common: common.items[0] || {}, intro: intro.items[0] || {}, images: images.items || [], }; } /** * 숙박 전체 상세정보 조회 */ async getStayDetail(contentId) { const [common, intro, rooms, images] = await Promise.all([ this.getDetailCommon(contentId), this.getDetailIntro(contentId, CONTENT_TYPE.STAY), this.getDetailInfo(contentId, CONTENT_TYPE.STAY), this.getDetailImage(contentId), ]); return { common: common.items[0] || {}, intro: intro.items[0] || {}, rooms: rooms.items || [], images: images.items || [], }; } // ============================================ // 동기화 // ============================================ /** * 관광정보 동기화 목록 조회 (변경된 데이터) */ async getSyncList(options = {}) { const { contentTypeId, modifiedTime, // YYYYMMDD 형식 numOfRows = 100, pageNo = 1, } = options; const params = { numOfRows, pageNo }; if (contentTypeId) params.contentTypeId = contentTypeId; if (modifiedTime) params.modifiedtime = modifiedTime; return this.request('areaBasedSyncList2', params); } // ============================================ // 유틸리티 // ============================================ /** * 오늘 날짜 문자열 (YYYYMMDD) */ getTodayString() { return new Date().toISOString().slice(0, 10).replace(/-/g, ''); } /** * N개월 후 날짜 문자열 */ getDateAfterMonths(months) { const date = new Date(); date.setMonth(date.getMonth() + months); return date.toISOString().slice(0, 10).replace(/-/g, ''); } /** * 날짜 파싱 (YYYYMMDD -> Date) */ parseDate(dateStr) { if (!dateStr || dateStr.length < 8) return null; const year = dateStr.slice(0, 4); const month = dateStr.slice(4, 6); const day = dateStr.slice(6, 8); return new Date(`${year}-${month}-${day}`); } /** * 날짜 포맷팅 (YYYYMMDD -> YYYY.MM.DD) */ formatDate(dateStr) { if (!dateStr || dateStr.length < 8) return ''; return `${dateStr.slice(0, 4)}.${dateStr.slice(4, 6)}.${dateStr.slice(6, 8)}`; } } // 싱글톤 인스턴스 const tourApiClient = new TourApiClient(); module.exports = { tourApiClient, TourApiClient, CONTENT_TYPE, AREA_CODE, AREA_NAME, };