/** * 축제 & 펜션 데이터 동기화 스크립트 * * 사용법: * 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();