441 lines
13 KiB
JavaScript
441 lines
13 KiB
JavaScript
/**
|
|
* 축제 & 펜션 데이터 동기화 스크립트
|
|
*
|
|
* 사용법:
|
|
* node server/scripts/syncData.js # 전체 동기화
|
|
* node server/scripts/syncData.js festivals # 축제만
|
|
* node server/scripts/syncData.js pensions # 펜션만
|
|
* node server/scripts/syncData.js --area=32 # 특정 지역만 (32=강원)
|
|
* node server/scripts/syncData.js festivals --startDate=20251201 --endDate=20261231
|
|
* # 특정 날짜 범위 축제 동기화
|
|
*/
|
|
|
|
const path = require('path');
|
|
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
|
|
const axios = require('axios');
|
|
const db = require('../db');
|
|
|
|
// 설정
|
|
const TOURAPI_KEY = process.env.TOURAPI_KEY;
|
|
const TOURAPI_ENDPOINT = process.env.TOURAPI_ENDPOINT || 'https://apis.data.go.kr/B551011/KorService2';
|
|
|
|
// 지역코드 매핑
|
|
const AREA_NAMES = {
|
|
'1': '서울', '2': '인천', '3': '대전', '4': '대구', '5': '광주',
|
|
'6': '부산', '7': '울산', '8': '세종', '31': '경기', '32': '강원',
|
|
'33': '충북', '34': '충남', '35': '경북', '36': '경남', '37': '전북',
|
|
'38': '전남', '39': '제주',
|
|
};
|
|
|
|
// 유틸리티 함수
|
|
function sleep(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function getTodayString() {
|
|
return new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
}
|
|
|
|
function getDateAfterMonths(months) {
|
|
const date = new Date();
|
|
date.setMonth(date.getMonth() + months);
|
|
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
|
}
|
|
|
|
function normalizeText(text) {
|
|
if (!text) return '';
|
|
return text.replace(/\s+/g, '').toLowerCase();
|
|
}
|
|
|
|
// DB 프로미스 래퍼
|
|
function dbRun(sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.run(sql, params, function(err) {
|
|
if (err) reject(err);
|
|
else resolve({ lastID: this.lastID, changes: this.changes });
|
|
});
|
|
});
|
|
}
|
|
|
|
function dbGet(sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.get(sql, params, (err, row) => {
|
|
if (err) reject(err);
|
|
else resolve(row);
|
|
});
|
|
});
|
|
}
|
|
|
|
function dbAll(sql, params = []) {
|
|
return new Promise((resolve, reject) => {
|
|
db.all(sql, params, (err, rows) => {
|
|
if (err) reject(err);
|
|
else resolve(rows);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// TourAPI 호출
|
|
// ============================================
|
|
|
|
async function callTourApi(operation, params = {}) {
|
|
try {
|
|
const url = `${TOURAPI_ENDPOINT}/${operation}`;
|
|
const response = await axios.get(url, {
|
|
params: {
|
|
serviceKey: TOURAPI_KEY,
|
|
MobileOS: 'ETC',
|
|
MobileApp: 'CastAD',
|
|
_type: 'json',
|
|
...params,
|
|
},
|
|
timeout: 30000,
|
|
});
|
|
|
|
const data = response.data;
|
|
if (data.response?.header?.resultCode !== '0000') {
|
|
throw new Error(data.response?.header?.resultMsg || 'API Error');
|
|
}
|
|
|
|
const body = data.response?.body;
|
|
let items = body?.items?.item || [];
|
|
|
|
// 단일 아이템일 경우 배열로 변환
|
|
if (items && !Array.isArray(items)) {
|
|
items = [items];
|
|
}
|
|
|
|
return {
|
|
items,
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 축제 동기화
|
|
// ============================================
|
|
|
|
async function syncFestivals(areaCode = null, customStartDate = null, customEndDate = null) {
|
|
console.log('\n========================================');
|
|
console.log('🎪 축제 데이터 동기화 시작');
|
|
console.log('========================================');
|
|
|
|
// 날짜 설정: 커스텀 날짜가 있으면 사용, 없으면 기본값 (오늘~1년 후)
|
|
const startDate = customStartDate || getTodayString();
|
|
const endDate = customEndDate || getDateAfterMonths(12);
|
|
|
|
console.log(` 기간: ${startDate} ~ ${endDate}`);
|
|
|
|
let totalSynced = 0;
|
|
let pageNo = 1;
|
|
const errors = [];
|
|
|
|
while (true) {
|
|
try {
|
|
const params = {
|
|
numOfRows: 100,
|
|
pageNo,
|
|
eventStartDate: startDate,
|
|
eventEndDate: endDate,
|
|
arrange: 'A',
|
|
};
|
|
|
|
if (areaCode) {
|
|
params.areaCode = areaCode;
|
|
}
|
|
|
|
console.log(`\n📄 페이지 ${pageNo} 조회 중...`);
|
|
const result = await callTourApi('searchFestival2', params);
|
|
|
|
if (!result.items || result.items.length === 0) {
|
|
console.log(' → 더 이상 데이터 없음');
|
|
break;
|
|
}
|
|
|
|
console.log(` → ${result.items.length}건 발견 (전체 ${result.totalCount}건)`);
|
|
|
|
for (const item of result.items) {
|
|
try {
|
|
await upsertFestival(item);
|
|
totalSynced++;
|
|
} catch (err) {
|
|
errors.push({ contentId: item.contentid, error: err.message });
|
|
}
|
|
}
|
|
|
|
if (result.items.length < 100) break;
|
|
pageNo++;
|
|
|
|
// API 호출 제한 대응
|
|
await sleep(200);
|
|
} catch (error) {
|
|
console.error(` ❌ 페이지 ${pageNo} 에러:`, error.message);
|
|
break;
|
|
}
|
|
}
|
|
|
|
console.log('\n========================================');
|
|
console.log(`✅ 축제 동기화 완료: ${totalSynced}건`);
|
|
if (errors.length > 0) {
|
|
console.log(`⚠️ 에러: ${errors.length}건`);
|
|
}
|
|
console.log('========================================\n');
|
|
|
|
return { synced: totalSynced, errors };
|
|
}
|
|
|
|
async function upsertFestival(item) {
|
|
const exists = await dbGet('SELECT id FROM festivals WHERE content_id = ?', [item.contentid]);
|
|
|
|
const sido = AREA_NAMES[item.areacode] || null;
|
|
|
|
if (exists) {
|
|
await dbRun(`
|
|
UPDATE festivals SET
|
|
title = ?, addr1 = ?, addr2 = ?, area_code = ?, sigungu_code = ?, sido = ?,
|
|
mapx = ?, mapy = ?, event_start_date = ?, event_end_date = ?,
|
|
first_image = ?, first_image2 = ?, tel = ?, zipcode = ?,
|
|
last_synced_at = ?, updated_at = CURRENT_TIMESTAMP
|
|
WHERE content_id = ?
|
|
`, [
|
|
item.title, item.addr1, item.addr2, item.areacode, item.sigungucode, sido,
|
|
item.mapx ? parseFloat(item.mapx) : null,
|
|
item.mapy ? parseFloat(item.mapy) : null,
|
|
item.eventstartdate, item.eventenddate,
|
|
item.firstimage, item.firstimage2, item.tel, item.zipcode,
|
|
new Date().toISOString(),
|
|
item.contentid
|
|
]);
|
|
} else {
|
|
await dbRun(`
|
|
INSERT INTO festivals (
|
|
content_id, content_type_id, title, addr1, addr2, area_code, sigungu_code, sido,
|
|
mapx, mapy, event_start_date, event_end_date, first_image, first_image2,
|
|
tel, zipcode, last_synced_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
item.contentid, item.contenttypeid || '15', item.title, item.addr1, item.addr2,
|
|
item.areacode, item.sigungucode, sido,
|
|
item.mapx ? parseFloat(item.mapx) : null,
|
|
item.mapy ? parseFloat(item.mapy) : null,
|
|
item.eventstartdate, item.eventenddate, item.firstimage, item.firstimage2,
|
|
item.tel, item.zipcode, new Date().toISOString()
|
|
]);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 펜션 동기화
|
|
// ============================================
|
|
|
|
async function syncPensions(areaCode = null) {
|
|
console.log('\n========================================');
|
|
console.log('🏡 펜션 데이터 동기화 시작');
|
|
console.log('========================================');
|
|
|
|
let totalSynced = 0;
|
|
const errors = [];
|
|
|
|
// 지역별로 순회 (전체 또는 특정 지역)
|
|
const areaCodes = areaCode ? [areaCode] : Object.keys(AREA_NAMES);
|
|
|
|
for (const area of areaCodes) {
|
|
console.log(`\n📍 ${AREA_NAMES[area]} (코드: ${area}) 조회 중...`);
|
|
|
|
let pageNo = 1;
|
|
let areaTotal = 0;
|
|
|
|
while (true) {
|
|
try {
|
|
const params = {
|
|
numOfRows: 100,
|
|
pageNo,
|
|
areaCode: area,
|
|
arrange: 'A',
|
|
};
|
|
|
|
const result = await callTourApi('searchStay2', params);
|
|
|
|
if (!result.items || result.items.length === 0) break;
|
|
|
|
// 펜션만 필터링 (이름에 '펜션' 포함 또는 cat3 코드)
|
|
const pensions = result.items.filter(item =>
|
|
item.title?.includes('펜션') ||
|
|
item.cat3 === 'B02010700' ||
|
|
item.title?.toLowerCase().includes('pension')
|
|
);
|
|
|
|
for (const item of pensions) {
|
|
try {
|
|
await upsertPension(item);
|
|
totalSynced++;
|
|
areaTotal++;
|
|
} catch (err) {
|
|
errors.push({ contentId: item.contentid, error: err.message });
|
|
}
|
|
}
|
|
|
|
if (result.items.length < 100) break;
|
|
pageNo++;
|
|
|
|
await sleep(200);
|
|
} catch (error) {
|
|
console.error(` ❌ ${AREA_NAMES[area]} 에러:`, error.message);
|
|
break;
|
|
}
|
|
}
|
|
|
|
console.log(` → ${AREA_NAMES[area]}: ${areaTotal}건`);
|
|
}
|
|
|
|
console.log('\n========================================');
|
|
console.log(`✅ 펜션 동기화 완료: ${totalSynced}건`);
|
|
if (errors.length > 0) {
|
|
console.log(`⚠️ 에러: ${errors.length}건`);
|
|
}
|
|
console.log('========================================\n');
|
|
|
|
return { synced: totalSynced, errors };
|
|
}
|
|
|
|
async function upsertPension(item) {
|
|
const exists = await dbGet(
|
|
'SELECT id FROM public_pensions WHERE source = ? AND content_id = ?',
|
|
['TOURAPI', item.contentid]
|
|
);
|
|
|
|
const sido = AREA_NAMES[item.areacode] || null;
|
|
const nameNormalized = normalizeText(item.title);
|
|
|
|
if (exists) {
|
|
await dbRun(`
|
|
UPDATE public_pensions SET
|
|
name = ?, name_normalized = ?, address = ?, area_code = ?, sigungu_code = ?, sido = ?,
|
|
mapx = ?, mapy = ?, tel = ?, thumbnail = ?, zipcode = ?,
|
|
last_synced_at = ?, updated_at = CURRENT_TIMESTAMP
|
|
WHERE source = 'TOURAPI' AND content_id = ?
|
|
`, [
|
|
item.title, nameNormalized, item.addr1, item.areacode, item.sigungucode, sido,
|
|
item.mapx ? parseFloat(item.mapx) : null,
|
|
item.mapy ? parseFloat(item.mapy) : null,
|
|
item.tel, item.firstimage, item.zipcode,
|
|
new Date().toISOString(),
|
|
item.contentid
|
|
]);
|
|
} else {
|
|
await dbRun(`
|
|
INSERT INTO public_pensions (
|
|
source, source_id, content_id, name, name_normalized, address,
|
|
area_code, sigungu_code, sido, mapx, mapy, tel, thumbnail, zipcode, last_synced_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
'TOURAPI', item.contentid, item.contentid, item.title, nameNormalized, item.addr1,
|
|
item.areacode, item.sigungucode, sido,
|
|
item.mapx ? parseFloat(item.mapx) : null,
|
|
item.mapy ? parseFloat(item.mapy) : null,
|
|
item.tel, item.firstimage, item.zipcode, new Date().toISOString()
|
|
]);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// 통계 출력
|
|
// ============================================
|
|
|
|
async function printStats() {
|
|
console.log('\n========================================');
|
|
console.log('📊 현재 데이터 현황');
|
|
console.log('========================================');
|
|
|
|
// 축제 통계
|
|
const festivalCount = await dbGet('SELECT COUNT(*) as count FROM festivals');
|
|
const festivalByArea = await dbAll(`
|
|
SELECT sido, COUNT(*) as count
|
|
FROM festivals
|
|
WHERE sido IS NOT NULL
|
|
GROUP BY sido
|
|
ORDER BY count DESC
|
|
`);
|
|
|
|
console.log(`\n🎪 축제: 총 ${festivalCount.count}건`);
|
|
festivalByArea.forEach(row => {
|
|
console.log(` - ${row.sido}: ${row.count}건`);
|
|
});
|
|
|
|
// 펜션 통계
|
|
const pensionCount = await dbGet('SELECT COUNT(*) as count FROM public_pensions');
|
|
const pensionByArea = await dbAll(`
|
|
SELECT sido, COUNT(*) as count
|
|
FROM public_pensions
|
|
WHERE sido IS NOT NULL
|
|
GROUP BY sido
|
|
ORDER BY count DESC
|
|
`);
|
|
|
|
console.log(`\n🏡 펜션: 총 ${pensionCount.count}건`);
|
|
pensionByArea.forEach(row => {
|
|
console.log(` - ${row.sido}: ${row.count}건`);
|
|
});
|
|
|
|
console.log('\n========================================\n');
|
|
}
|
|
|
|
// ============================================
|
|
// 메인 실행
|
|
// ============================================
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
|
|
// 옵션 파싱
|
|
let syncType = 'all'; // festivals, pensions, all
|
|
let areaCode = null;
|
|
let startDate = null;
|
|
let endDate = null;
|
|
|
|
args.forEach(arg => {
|
|
if (arg === 'festivals') syncType = 'festivals';
|
|
else if (arg === 'pensions') syncType = 'pensions';
|
|
else if (arg.startsWith('--area=')) areaCode = arg.split('=')[1];
|
|
else if (arg.startsWith('--startDate=')) startDate = arg.split('=')[1];
|
|
else if (arg.startsWith('--endDate=')) endDate = arg.split('=')[1];
|
|
});
|
|
|
|
console.log('\n🚀 TourAPI 데이터 동기화');
|
|
console.log(` - 유형: ${syncType}`);
|
|
console.log(` - 지역: ${areaCode ? AREA_NAMES[areaCode] : '전체'}`);
|
|
if (startDate || endDate) {
|
|
console.log(` - 날짜 범위: ${startDate || '오늘'} ~ ${endDate || '1년 후'}`);
|
|
}
|
|
|
|
try {
|
|
if (syncType === 'all' || syncType === 'festivals') {
|
|
await syncFestivals(areaCode, startDate, endDate);
|
|
}
|
|
|
|
if (syncType === 'all' || syncType === 'pensions') {
|
|
await syncPensions(areaCode);
|
|
}
|
|
|
|
await printStats();
|
|
|
|
console.log('✅ 동기화 완료!\n');
|
|
process.exit(0);
|
|
} catch (error) {
|
|
console.error('\n❌ 동기화 실패:', error.message);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
// 실행
|
|
main();
|