castad-pre-v0.3/castad-data/server/scripts/syncData.js

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();