440 lines
11 KiB
JavaScript
440 lines
11 KiB
JavaScript
/**
|
|
* 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,
|
|
};
|