diff --git a/.env b/.env index efe78d8..3842af0 100644 --- a/.env +++ b/.env @@ -23,6 +23,9 @@ KAKAO_REST_KEY=e7a5516bf6cb1b398857789ee2ea6eea # Google API GOOGLE_API_KEY=AIzaSyC6l3nFlcHgLc0d1Q9WPyYQjVKTv21ZqFs +# Gemini API (대학 축제 크롤러 봇용) +GEMINI_API_KEY=AIzaSyC7TZKmbklcdSsX1qcpt8sTE29LWv9i_co + # Meilisearch MEILI_MASTER_KEY=xMLNzlGX4xYji494JOb5IMlLHULcYw91 diff --git a/CLAUDE.md b/CLAUDE.md index 8e46814..cd66f05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,13 +22,15 @@ DB 및 외부 서비스 접근 정보는 `.env` 파일 참조: - RUSTFS_* (S3 호환 스토리지) - YOUTUBE_API_KEY - MEILI_MASTER_KEY (Meilisearch) +- KAKAO_REST_KEY (카카오맵 장소 검색) +- GEMINI_API_KEY (대학 축제 크롤러 봇) ## 문서 -- [docs/migration.md](docs/migration.md) - 마이그레이션 현황 및 남은 작업 - [docs/architecture.md](docs/architecture.md) - 프로젝트 구조 - [docs/api.md](docs/api.md) - API 명세 - [docs/development.md](docs/development.md) - 개발/배포 가이드 +- [docs/logs.md](docs/logs.md) - 활동 로그 시스템 ## 작업 시 주의사항 diff --git a/backend/src/app.js b/backend/src/app.js index ed97774..ce72c1a 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -18,6 +18,7 @@ import authPlugin from './plugins/auth.js'; import meilisearchPlugin from './plugins/meilisearch.js'; import youtubeBotPlugin from './services/youtube/index.js'; import xBotPlugin from './services/x/index.js'; +import festivalBotPlugin from './services/festival/index.js'; import schedulerPlugin from './plugins/scheduler.js'; // 라우트 @@ -64,6 +65,7 @@ export async function buildApp(opts = {}) { await fastify.register(meilisearchPlugin); await fastify.register(youtubeBotPlugin); await fastify.register(xBotPlugin); + await fastify.register(festivalBotPlugin); await fastify.register(schedulerPlugin); // 공유 스키마 등록 (라우트에서 $ref로 참조 가능) diff --git a/backend/src/config/index.js b/backend/src/config/index.js index 5955d13..f7f04e5 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -52,6 +52,9 @@ export default { google: { apiKey: process.env.GOOGLE_API_KEY, }, + gemini: { + apiKey: process.env.GEMINI_API_KEY, + }, jwt: { secret: process.env.JWT_SECRET, expiresIn: '30d', diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js index e2ec83d..c759c3a 100644 --- a/backend/src/plugins/scheduler.js +++ b/backend/src/plugins/scheduler.js @@ -97,6 +97,39 @@ async function schedulerPlugin(fastify, opts) { })); } + /** + * 동기화 간격(분)을 cron 표현식으로 변환 + * - 60분 미만: 분 단위 (*\/N * * * *) + * - 60분 이상: 시간 단위 (0 *\/H * * *) + */ + function intervalToCron(minutes) { + if (!minutes || minutes < 60) { + return `*/${minutes || 2} * * * *`; + } + const hours = Math.floor(minutes / 60); + if (hours >= 24) { + return '0 0 * * *'; + } + return `0 */${hours} * * *`; + } + + /** + * DB에서 축제 봇 목록 조회 + */ + async function getFestivalBotsFromDB() { + const [rows] = await fastify.db.query('SELECT * FROM bot_festival'); + return rows.map(row => ({ + id: `festival-${row.id}`, + dbId: row.id, + type: 'festival', + name: row.name, + searchUrl: row.search_url, + cron: intervalToCron(row.cron_interval), + cronInterval: row.cron_interval, + enabled: row.enabled === 1, + })); + } + /** * 모든 봇 목록 가져오기 (정적 + DB) */ @@ -106,7 +139,8 @@ async function schedulerPlugin(fastify, opts) { } const youtubeBots = await getYouTubeBotsFromDB(); const xBots = await getXBotsFromDB(); - cachedBots = [...staticBots, ...youtubeBots, ...xBots]; + const festivalBots = await getFestivalBotsFromDB(); + cachedBots = [...staticBots, ...youtubeBots, ...xBots, ...festivalBots]; return cachedBots; } @@ -155,6 +189,8 @@ async function schedulerPlugin(fastify, opts) { return fastify.youtubeBot.syncNewVideos; } else if (bot.type === 'x') { return fastify.xBot.syncNewTweets; + } else if (bot.type === 'festival') { + return fastify.festivalBot.syncNewFestivals; } else if (bot.type === 'meilisearch') { return async () => { const count = await syncAllSchedules(fastify.meilisearch, fastify.db); @@ -190,9 +226,10 @@ async function schedulerPlugin(fastify, opts) { * DB의 enabled 필드 업데이트 (정적 봇은 무시) */ async function setEnabled(botId, enabled) { - const match = botId.match(/^(youtube|x)-(\d+)$/); + const match = botId.match(/^(youtube|x|festival)-(\d+)$/); if (!match) return; // 정적 봇 (meilisearch 등) - const table = match[1] === 'x' ? 'bot_x' : 'bot_youtube'; + const tableMap = { x: 'bot_x', youtube: 'bot_youtube', festival: 'bot_festival' }; + const table = tableMap[match[1]]; const dbId = match[2]; await fastify.db.query(`UPDATE ${table} SET enabled = ? WHERE id = ?`, [enabled ? 1 : 0, dbId]); invalidateCache(); @@ -415,5 +452,5 @@ async function schedulerPlugin(fastify, opts) { export default fp(schedulerPlugin, { name: 'scheduler', - dependencies: ['db', 'redis', 'meilisearch', 'youtubeBot', 'xBot'], + dependencies: ['db', 'redis', 'meilisearch', 'youtubeBot', 'xBot', 'festivalBot'], }); diff --git a/backend/src/routes/admin/bots.js b/backend/src/routes/admin/bots.js index 8269aac..2e6bf6e 100644 --- a/backend/src/routes/admin/bots.js +++ b/backend/src/routes/admin/bots.js @@ -10,7 +10,7 @@ const botResponse = { properties: { id: { type: 'string' }, name: { type: 'string' }, - type: { type: 'string', enum: ['youtube', 'x', 'meilisearch'] }, + type: { type: 'string', enum: ['youtube', 'x', 'meilisearch', 'festival'] }, status: { type: 'string', enum: ['running', 'stopped', 'error'] }, last_check_at: { type: 'string', format: 'date-time' }, last_added_count: { type: 'integer' }, @@ -35,6 +35,8 @@ const botResponse = { display_name: { type: 'string' }, avatar_url: { type: 'string' }, text_filters: { type: 'array', items: { type: 'string' } }, + // 축제 봇 전용 필드 + search_url: { type: 'string' }, }, }; @@ -82,12 +84,21 @@ export default async function botsRoutes(fastify) { // cron 표현식에서 간격 추출 (분 단위, 일일 스케줄은 1440분) let checkInterval = 2; // 기본값 - const cronMatch = bot.cron.match(/^\*\/(\d+)/); - if (cronMatch) { - checkInterval = parseInt(cronMatch[1]); - } else if (/^0 \d+ \* \* \*$/.test(bot.cron)) { - // 매일 특정 시간 (예: 0 12 * * *) - checkInterval = 1440; // 24시간 = 1440분 + if (bot.cronInterval) { + // 축제 봇 등 분 단위 간격을 직접 가진 봇 + checkInterval = bot.cronInterval; + } else { + const cronMatch = bot.cron.match(/^\*\/(\d+)/); + const hourMatch = bot.cron.match(/^0 \*\/(\d+) \* \* \*$/); + if (cronMatch) { + checkInterval = parseInt(cronMatch[1]); + } else if (hourMatch) { + // 시간 단위 (예: 0 *\/6 * * *) + checkInterval = parseInt(hourMatch[1]) * 60; + } else if (/^0 \d+ \* \* \*$/.test(bot.cron)) { + // 매일 특정 시간 (예: 0 12 * * *) + checkInterval = 1440; + } } const botData = { @@ -128,6 +139,13 @@ export default async function botsRoutes(fastify) { botData.cron_interval = checkInterval; } + // 축제 봇인 경우 상세 정보 추가 + if (bot.type === 'festival') { + botData.db_id = bot.dbId; + botData.search_url = bot.searchUrl; + botData.cron_interval = checkInterval; + } + result.push(botData); } @@ -246,6 +264,8 @@ export default async function botsRoutes(fastify) { result = await fastify.youtubeBot.syncAllVideos(bot); } else if (bot.type === 'x') { result = await fastify.xBot.syncAllTweets(bot); + } else if (bot.type === 'festival') { + result = await fastify.festivalBot.syncNewFestivals(bot); } else if (bot.type === 'meilisearch') { const count = await syncAllSchedules(fastify.meilisearch, fastify.db); result = { addedCount: count, total: count }; diff --git a/backend/src/routes/admin/events.js b/backend/src/routes/admin/events.js index 1061c24..4aa741e 100644 --- a/backend/src/routes/admin/events.js +++ b/backend/src/routes/admin/events.js @@ -1,41 +1,13 @@ import { CATEGORY_IDS } from '../../config/index.js'; import { withTransaction } from '../../utils/transaction.js'; import { uploadEventPoster } from '../../services/image.js'; +import { upsertVenue } from '../../services/event.js'; import { logActivity } from '../../utils/log.js'; import { syncScheduleById } from '../../services/meilisearch/index.js'; const EVENT_CATEGORY_ID = CATEGORY_IDS.EVENT; const VALID_SUBTYPES = ['university']; -/** - * 장소를 upsert (kakao_id 기준) 후 venue_id 반환 - */ -async function upsertVenue(db, venue) { - if (!venue) return null; - if (venue.id) return venue.id; - if (!venue.name) return null; - - // kakao_id가 있으면 먼저 조회 - if (venue.kakao_id) { - const [rows] = await db.query('SELECT id FROM event_venues WHERE kakao_id = ?', [venue.kakao_id]); - if (rows.length > 0) return rows[0].id; - } - - const [result] = await db.query( - `INSERT INTO event_venues (name, address, road_address, lat, lng, kakao_id) - VALUES (?, ?, ?, ?, ?, ?)`, - [ - venue.name, - venue.address || null, - venue.road_address || venue.roadAddress || null, - venue.lat ?? null, - venue.lng ?? null, - venue.kakao_id || venue.kakaoId || null, - ] - ); - return result.insertId; -} - /** * multipart에서 payload(JSON 문자열) + poster 파일들 추출 */ diff --git a/backend/src/routes/admin/festival-bots.js b/backend/src/routes/admin/festival-bots.js new file mode 100644 index 0000000..671a4ca --- /dev/null +++ b/backend/src/routes/admin/festival-bots.js @@ -0,0 +1,259 @@ +import { errorResponse } from '../../schemas/index.js'; +import { badRequest, notFound } from '../../utils/error.js'; +import { logActivity } from '../../utils/log.js'; + +/** + * 축제 봇 응답 스키마 + */ +const festivalBotResponse = { + type: 'object', + properties: { + id: { type: 'integer' }, + name: { type: 'string' }, + search_url: { type: 'string' }, + cron_interval: { type: 'integer' }, + enabled: { type: 'boolean' }, + }, +}; + +const festivalBotIdParam = { + type: 'object', + properties: { + id: { type: 'integer', description: '축제 봇 DB ID' }, + }, + required: ['id'], +}; + +/** + * DB row → API 응답 형식 + */ +function formatBotResponse(row) { + return { + id: row.id, + name: row.name, + search_url: row.search_url, + cron_interval: row.cron_interval, + enabled: row.enabled === 1, + }; +} + +/** + * 축제 봇 관리 라우트 + */ +export default async function festivalBotsRoutes(fastify) { + const { db, scheduler } = fastify; + + /** + * GET /api/admin/festival-bots + * 축제 봇 목록 조회 + */ + fastify.get('/', { + schema: { + tags: ['admin/festival-bots'], + summary: '축제 봇 목록 조회', + security: [{ bearerAuth: [] }], + response: { 200: { type: 'array', items: festivalBotResponse } }, + }, + preHandler: [fastify.authenticate], + }, async () => { + const [rows] = await db.query('SELECT * FROM bot_festival ORDER BY id'); + return rows.map(formatBotResponse); + }); + + /** + * GET /api/admin/festival-bots/:id + * 축제 봇 상세 조회 + */ + fastify.get('/:id', { + schema: { + tags: ['admin/festival-bots'], + summary: '축제 봇 상세 조회', + security: [{ bearerAuth: [] }], + params: festivalBotIdParam, + response: { 200: festivalBotResponse, 404: errorResponse }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const [rows] = await db.query('SELECT * FROM bot_festival WHERE id = ?', [id]); + if (rows.length === 0) { + return notFound(reply, '축제 봇을 찾을 수 없습니다.'); + } + return formatBotResponse(rows[0]); + }); + + /** + * POST /api/admin/festival-bots + * 축제 봇 추가 + */ + fastify.post('/', { + schema: { + tags: ['admin/festival-bots'], + summary: '축제 봇 추가', + security: [{ bearerAuth: [] }], + body: { + type: 'object', + properties: { + name: { type: 'string' }, + search_url: { type: 'string' }, + cron_interval: { type: 'integer', default: 360 }, + }, + required: ['name', 'search_url'], + }, + response: { 201: festivalBotResponse, 400: errorResponse }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { name, search_url, cron_interval = 360 } = request.body; + + if (!name?.trim() || !search_url?.trim()) { + return badRequest(reply, '이름과 크롤링 URL은 필수입니다.'); + } + + const [result] = await db.query( + `INSERT INTO bot_festival (name, search_url, cron_interval, enabled) + VALUES (?, ?, ?, 1)`, + [name.trim(), search_url.trim(), cron_interval] + ); + + scheduler.invalidateCache(); + const botId = `festival-${result.insertId}`; + + // 봇 시작 (스케줄러 등록) + try { + await scheduler.startBot(botId); + } catch (err) { + fastify.log.error(`[${botId}] 봇 시작 실패: ${err.message}`); + } + + const [newBot] = await db.query('SELECT * FROM bot_festival WHERE id = ?', [result.insertId]); + logActivity(db, { + actor: 'admin', action: 'create', category: 'bot', + targetType: 'festival_bot', targetId: result.insertId, + summary: `축제 봇 생성: ${name.trim()}`, + }); + reply.code(201); + return formatBotResponse(newBot[0]); + }); + + /** + * PUT /api/admin/festival-bots/:id + * 축제 봇 수정 + */ + fastify.put('/:id', { + schema: { + tags: ['admin/festival-bots'], + summary: '축제 봇 수정', + security: [{ bearerAuth: [] }], + params: festivalBotIdParam, + body: { + type: 'object', + properties: { + name: { type: 'string' }, + search_url: { type: 'string' }, + cron_interval: { type: 'integer' }, + enabled: { type: 'boolean' }, + }, + }, + response: { 200: festivalBotResponse, 404: errorResponse }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const updates = request.body; + + const [existing] = await db.query('SELECT * FROM bot_festival WHERE id = ?', [id]); + if (existing.length === 0) { + return notFound(reply, '축제 봇을 찾을 수 없습니다.'); + } + + const fields = []; + const values = []; + if (updates.name !== undefined) { + fields.push('name = ?'); + values.push(updates.name); + } + if (updates.search_url !== undefined) { + fields.push('search_url = ?'); + values.push(updates.search_url); + } + if (updates.cron_interval !== undefined) { + fields.push('cron_interval = ?'); + values.push(updates.cron_interval); + } + if (updates.enabled !== undefined) { + fields.push('enabled = ?'); + values.push(updates.enabled ? 1 : 0); + } + + if (fields.length > 0) { + values.push(id); + await db.query(`UPDATE bot_festival SET ${fields.join(', ')} WHERE id = ?`, values); + + // 스케줄러 캐시 무효화 및 봇 재시작 + scheduler.invalidateCache(); + const botId = `festival-${id}`; + const shouldBeEnabled = updates.enabled !== undefined + ? updates.enabled + : existing[0].enabled === 1; + try { + await scheduler.stopBot(botId); + if (shouldBeEnabled) { + await scheduler.startBot(botId); + } + } catch (err) { + fastify.log.error(`[${botId}] 봇 재시작 실패: ${err.message}`); + } + } + + const [updated] = await db.query('SELECT * FROM bot_festival WHERE id = ?', [id]); + logActivity(db, { + actor: 'admin', action: 'update', category: 'bot', + targetType: 'festival_bot', targetId: parseInt(id), + summary: `축제 봇 수정: ${existing[0].name}`, + }); + return formatBotResponse(updated[0]); + }); + + /** + * DELETE /api/admin/festival-bots/:id + * 축제 봇 삭제 + */ + fastify.delete('/:id', { + schema: { + tags: ['admin/festival-bots'], + summary: '축제 봇 삭제', + security: [{ bearerAuth: [] }], + params: festivalBotIdParam, + response: { + 200: { type: 'object', properties: { success: { type: 'boolean' } } }, + 404: errorResponse, + }, + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + + const [existing] = await db.query('SELECT * FROM bot_festival WHERE id = ?', [id]); + if (existing.length === 0) { + return notFound(reply, '축제 봇을 찾을 수 없습니다.'); + } + + const botId = `festival-${id}`; + try { + await scheduler.stopBot(botId); + } catch (err) { + // 이미 정지된 경우 무시 + } + + await db.query('DELETE FROM bot_festival WHERE id = ?', [id]); + scheduler.invalidateCache(); + + logActivity(db, { + actor: 'admin', action: 'delete', category: 'bot', + targetType: 'festival_bot', targetId: parseInt(id), + summary: `축제 봇 삭제: ${existing[0].name}`, + }); + return { success: true }; + }); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index c9a84ba..6a90bb8 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -6,6 +6,7 @@ import statsRoutes from './stats/index.js'; import botsRoutes from './admin/bots.js'; import youtubeBotsRoutes from './admin/youtube-bots.js'; import xBotsRoutes from './admin/x-bots.js'; +import festivalBotsRoutes from './admin/festival-bots.js'; import youtubeAdminRoutes from './admin/youtube.js'; import xAdminRoutes from './admin/x.js'; import concertAdminRoutes from './admin/concert.js'; @@ -43,6 +44,9 @@ export default async function routes(fastify) { // 관리자 - X 봇 라우트 fastify.register(xBotsRoutes, { prefix: '/admin/x-bots' }); + // 관리자 - 축제 봇 라우트 + fastify.register(festivalBotsRoutes, { prefix: '/admin/festival-bots' }); + // 관리자 - YouTube 라우트 fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' }); diff --git a/backend/src/services/event.js b/backend/src/services/event.js new file mode 100644 index 0000000..33a3485 --- /dev/null +++ b/backend/src/services/event.js @@ -0,0 +1,134 @@ +/** + * 행사(이벤트) 공통 서비스 + * - 관리자 라우트(events.js)와 축제 크롤러 봇(festival)이 공유 + */ +import { CATEGORY_IDS } from '../config/index.js'; +import { withTransaction } from '../utils/transaction.js'; +import { syncScheduleById } from './meilisearch/index.js'; + +const EVENT_CATEGORY_ID = CATEGORY_IDS.EVENT; +const KAKAO_REST_KEY = process.env.KAKAO_REST_KEY; + +/** + * 장소를 upsert (kakao_id 기준) 후 venue_id 반환 + * @param {object} conn - DB 연결 (트랜잭션 conn 또는 pool) + * @param {object} venue - 장소 정보 + */ +export async function upsertVenue(conn, venue) { + if (!venue) return null; + if (venue.id) return venue.id; + if (!venue.name) return null; + + const kakaoId = venue.kakao_id || venue.kakaoId || null; + + // kakao_id가 있으면 먼저 조회 + if (kakaoId) { + const [rows] = await conn.query('SELECT id FROM event_venues WHERE kakao_id = ?', [kakaoId]); + if (rows.length > 0) return rows[0].id; + } + + const [result] = await conn.query( + `INSERT INTO event_venues (name, address, road_address, lat, lng, kakao_id) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + venue.name, + venue.address || null, + venue.road_address || venue.roadAddress || null, + venue.lat ?? null, + venue.lng ?? null, + kakaoId, + ] + ); + return result.insertId; +} + +/** + * 행사 일정 생성 (schedules + schedule_event + schedule_members) + * 포스터는 호출 측에서 별도 처리 (S3 업로드 후 poster_image_ids UPDATE) + * + * @param {object} db - DB pool + * @param {object} meilisearch - Meilisearch 클라이언트 + * @param {object} data - { title, date, time, subtype, schoolName, memberIds, venue, postUrls } + * @returns {Promise} 생성된 schedule_id + */ +export async function createEventSchedule(db, meilisearch, data) { + const { + title, date, time, subtype = 'university', schoolName, + memberIds = [], venue, postUrls = [], + } = data; + + const scheduleId = await withTransaction(db, async (conn) => { + // 1) venue upsert + const venueId = await upsertVenue(conn, venue); + + // 2) schedules INSERT + const [sResult] = await conn.query( + `INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)`, + [EVENT_CATEGORY_ID, title, date, time || null] + ); + const sid = sResult.insertId; + + // 3) schedule_event INSERT + await conn.query( + `INSERT INTO schedule_event (schedule_id, subtype, school_name, venue_id, post_urls) + VALUES (?, ?, ?, ?, ?)`, + [ + sid, + subtype, + schoolName, + venueId, + postUrls.length > 0 ? JSON.stringify(postUrls) : null, + ] + ); + + // 4) 멤버 연결 + if (memberIds.length > 0) { + const values = memberIds.map(mid => [sid, mid]); + await conn.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); + } + + return sid; + }); + + // Meilisearch 동기화 (트랜잭션 외부) + await syncScheduleById(meilisearch, db, scheduleId); + + return scheduleId; +} + +/** + * 카카오맵 키워드 검색 (국내 장소) + * @param {string} query - 검색어 + * @returns {Promise} 카카오 검색 결과 documents + */ +export async function searchKakaoPlace(query) { + if (!KAKAO_REST_KEY) { + throw new Error('카카오 API 키가 설정되지 않았습니다.'); + } + + const response = await fetch( + `https://dapi.kakao.com/v2/local/search/keyword.json?query=${encodeURIComponent(query)}&size=5`, + { headers: { Authorization: `KakaoAK ${KAKAO_REST_KEY}` } } + ); + + if (!response.ok) { + throw new Error(`카카오 API 호출 실패: ${response.status}`); + } + + const data = await response.json(); + return data.documents || []; +} + +/** + * 카카오 검색 결과(document)를 event_venues 형식으로 변환 + */ +export function kakaoToVenue(doc) { + return { + name: doc.place_name, + address: doc.address_name || null, + road_address: doc.road_address_name || null, + lat: doc.y ? parseFloat(doc.y) : null, + lng: doc.x ? parseFloat(doc.x) : null, + kakao_id: doc.id || null, + }; +} diff --git a/backend/src/services/festival/gemini.js b/backend/src/services/festival/gemini.js new file mode 100644 index 0000000..f204f7a --- /dev/null +++ b/backend/src/services/festival/gemini.js @@ -0,0 +1,145 @@ +/** + * 축제 크롤러 - Gemini API 클라이언트 + * url_context 도구로 게시글을 직접 분석하여 프로미스나인 출연 축제 정보 추출 + */ + +const GEMINI_MODEL = 'gemini-2.5-flash'; +const GEMINI_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`; +const REQUEST_TIMEOUT = 240000; // 4분 + +/** + * 프롬프트 생성 + */ +function buildPrompt(postUrls) { + const urlList = postUrls.map((u, i) => `${i + 1}. ${u}`).join('\n'); + return `다음 블로그 게시글들의 내용을 모두 가져와서 분석해주세요: + +${urlList} + +각 게시글에서 프로미스나인(fromis_9)이 출연하는 2026년 **대학 축제** 일정을 모두 추출하세요. + +규칙: +1. 게시글 내용에 "프로미스나인" 또는 "fromis_9"이 출연진으로 명시된 경우만 인정 +2. 추측, 예상, 가능성 같은 미확정 정보는 제외 +3. **대학 축제(대동제 등)만** 포함. 지역 축제, 가요제, 콘서트 등 대학 행사가 아닌 것은 제외 +4. 캠퍼스 구분이 필요한 학교(단국대·성균관대·경희대 등)는 반드시 캠퍼스명 포함 +5. 게시글에 출연 시간(공연 시작 시각)이 명시되어 있으면 time 필드에 "HH:MM" 형식으로, 없으면 빈 문자열 +6. 게시글에 적힌 정보만 사용 (추론 금지) + +출력: JSON 배열만 출력하세요. 설명, 코드블록 표시(\`\`\`) 없이. +[ + { + "date": "YYYY-MM-DD", + "time": "HH:MM 또는 빈 문자열", + "university": "정확한 학교명 (캠퍼스 포함)", + "festival_name": "축제 공식 명칭 (없으면 빈 문자열)", + "source_url": "정보가 있던 게시글 URL" + } +] + +해당하는 일정이 없으면 빈 배열 []을 반환하세요.`; +} + +/** + * Gemini 응답 텍스트에서 JSON 배열 파싱 + */ +function parseJsonArray(text) { + let clean = text.trim(); + // 코드블록 제거 + if (clean.startsWith('```')) { + clean = clean.replace(/^```(?:json)?\s*/, '').replace(/\s*```$/, '').trim(); + } + const parsed = JSON.parse(clean); + if (!Array.isArray(parsed)) { + throw new Error('Gemini 응답이 배열이 아닙니다'); + } + return parsed; +} + +// 일시적 오류로 재시도할 HTTP 상태 코드 +const RETRYABLE_STATUS = [500, 503, 429]; +const MAX_RETRIES = 3; +const RETRY_DELAY = 20000; // 20초 + +/** + * Gemini API 단일 호출 (재시도 없음) + */ +async function callGemini(postUrls, apiKey) { + const payload = { + contents: [{ parts: [{ text: buildPrompt(postUrls) }] }], + tools: [{ url_context: {} }], + generationConfig: { temperature: 0.05 }, + }; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); + + try { + const res = await fetch(`${GEMINI_ENDPOINT}?key=${apiKey}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!res.ok) { + const errText = await res.text(); + const err = new Error(`Gemini API 오류 ${res.status}: ${errText.slice(0, 200)}`); + err.status = res.status; + throw err; + } + return await res.json(); + } catch (err) { + clearTimeout(timeoutId); + if (err.name === 'AbortError') { + throw new Error('Gemini API 요청 타임아웃'); + } + throw err; + } +} + +/** + * URL 목록을 Gemini url_context로 분석하여 축제 일정 추출 + * @param {string[]} postUrls - 분석할 게시글 URL (최대 20개) + * @param {string} apiKey - Gemini API 키 + * @returns {Promise} [{ date, time, university, festival_name, source_url }] + */ +export async function extractFestivalsFromUrls(postUrls, apiKey) { + if (!apiKey) { + throw new Error('GEMINI_API_KEY가 설정되지 않았습니다'); + } + if (postUrls.length === 0) { + return []; + } + + // 일시적 오류(503/500/429)는 재시도 + let result; + let lastErr; + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + result = await callGemini(postUrls, apiKey); + break; + } catch (err) { + lastErr = err; + if (attempt < MAX_RETRIES && RETRYABLE_STATUS.includes(err.status)) { + await new Promise(r => setTimeout(r, RETRY_DELAY)); + continue; + } + throw err; + } + } + if (!result) throw lastErr; + + const candidate = result?.candidates?.[0]; + if (!candidate) { + throw new Error('Gemini 응답에 candidates가 없습니다'); + } + + const text = candidate.content?.parts?.map(p => p.text || '').join('') || ''; + if (!text.trim()) { + throw new Error('Gemini 응답이 비어 있습니다'); + } + + return parseJsonArray(text); +} diff --git a/backend/src/services/festival/index.js b/backend/src/services/festival/index.js new file mode 100644 index 0000000..b453b79 --- /dev/null +++ b/backend/src/services/festival/index.js @@ -0,0 +1,211 @@ +/** + * 대학 축제 크롤러 봇 + * memogipost 등 검색 페이지에서 프로미스나인 출연 대학 축제를 수집하여 행사 일정 자동 생성 + */ +import fp from 'fastify-plugin'; +import { fetchSearchPostUrls } from './scraper.js'; +import { extractFestivalsFromUrls } from './gemini.js'; +import { createEventSchedule, searchKakaoPlace, kakaoToVenue } from '../event.js'; +import { CATEGORY_IDS } from '../../config/index.js'; +import { logActivity } from '../../utils/log.js'; + +const MAX_URLS_PER_REQUEST = 20; // Gemini url_context 한 번에 처리할 URL 수 + +async function festivalBotPlugin(fastify, opts) { + /** + * 활동 중인 멤버 ID 목록 조회 (축제는 보통 완전체) + */ + async function getActiveMemberIds() { + const [rows] = await fastify.db.query( + 'SELECT id FROM members WHERE is_former = 0 ORDER BY id' + ); + return rows.map(r => r.id); + } + + /** + * 같은 학교 + 날짜의 행사 일정이 이미 있는지 확인 + * 학교명은 부분일치로 비교 (캠퍼스명 유무 차이 흡수) + * 예: "인천대학교" vs "인천대학교 송도캠퍼스" → 같은 학교로 간주 + */ + async function isDuplicate(date, university) { + const [rows] = await fastify.db.query( + `SELECT se.school_name FROM schedules s + JOIN schedule_event se ON s.id = se.schedule_id + WHERE s.category_id = ? AND s.date = ?`, + [CATEGORY_IDS.EVENT, date] + ); + for (const row of rows) { + const existing = (row.school_name || '').trim(); + const target = university.trim(); + if (!existing) continue; + // 동일하거나, 한쪽이 다른 쪽의 접두사이면 같은 학교 + if (existing === target + || existing.startsWith(target) + || target.startsWith(existing)) { + return true; + } + } + return false; + } + + /** + * 게시글 URL이 이미 처리됐는지 필터링 → 새 URL만 반환 + */ + async function filterNewUrls(postUrls) { + if (postUrls.length === 0) return []; + const [rows] = await fastify.db.query( + 'SELECT post_url FROM festival_crawl_log WHERE post_url IN (?)', + [postUrls] + ); + const processed = new Set(rows.map(r => r.post_url)); + return postUrls.filter(u => !processed.has(u)); + } + + /** + * 크롤 로그 기록 (중복 방지용 - 이벤트 없는 글도 기록) + */ + async function logCrawled(postUrl, status, resultCount = 0) { + await fastify.db.query( + `INSERT IGNORE INTO festival_crawl_log (post_url, status, result_count) + VALUES (?, ?, ?)`, + [postUrl, status, resultCount] + ); + } + + /** + * 단일 축제 정보로 행사 일정 생성 + * @returns {boolean} 생성 성공 여부 + */ + async function createFestivalSchedule(festival, memberIds) { + const { date, time, university, festival_name } = festival; + + if (!date || !university) { + fastify.log.warn(`[festival] 필수 정보 누락: ${JSON.stringify(festival)}`); + return false; + } + + // 중복 체크 + if (await isDuplicate(date, university)) { + fastify.log.info(`[festival] 중복 건너뜀: ${date} ${university}`); + return false; + } + + // 카카오맵으로 장소 검색 + let venue = { name: university }; + try { + const docs = await searchKakaoPlace(university); + if (docs.length > 0) { + venue = kakaoToVenue(docs[0]); + } + } catch (err) { + fastify.log.warn(`[festival] 장소 검색 실패 (${university}): ${err.message}`); + } + + // 제목: 축제명이 있으면 "학교명 축제명", 없으면 "학교명 대학 축제" + const title = festival_name + ? `${university} ${festival_name}` + : `${university} 대학 축제`; + + const scheduleId = await createEventSchedule(fastify.db, fastify.meilisearch, { + title, + date, + time: time || null, + subtype: 'university', + schoolName: university, + memberIds, + venue, + postUrls: [], // 관련 링크는 관리자가 수동으로 입력 (블로그 링크 자동 추가 안 함) + }); + + logActivity(fastify.db, { + actor: 'festival-crawler', + action: 'create', + category: 'schedule', + targetType: 'event_schedule', + targetId: scheduleId, + summary: `대학 축제 자동 생성: ${title}`, + }); + + fastify.log.info(`[festival] 행사 생성: ${title} (${date})`); + return true; + } + + /** + * 새 축제 일정 동기화 (스케줄러가 호출) + * @param {object} bot - { id, searchUrl } + */ + async function syncNewFestivals(bot) { + const apiKey = fastify.config.gemini?.apiKey; + if (!apiKey) { + fastify.log.warn('[festival] GEMINI_API_KEY 미설정 - 동기화 건너뜀'); + return { addedCount: 0, total: 0 }; + } + + // 1. 검색 페이지에서 게시글 URL 수집 + const postUrls = await fetchSearchPostUrls(bot.searchUrl, fastify.log); + if (postUrls.length === 0) { + return { addedCount: 0, total: 0 }; + } + + // 2. 이미 처리한 URL 제외 + const newUrls = await filterNewUrls(postUrls); + if (newUrls.length === 0) { + fastify.log.info('[festival] 새 게시글 없음 - Gemini 호출 건너뜀'); + return { addedCount: 0, total: postUrls.length }; + } + + fastify.log.info(`[festival] 새 게시글 ${newUrls.length}개 발견`); + + const memberIds = await getActiveMemberIds(); + let addedCount = 0; + + // 3. 20개씩 배치로 Gemini 분석 + for (let i = 0; i < newUrls.length; i += MAX_URLS_PER_REQUEST) { + const batch = newUrls.slice(i, i + MAX_URLS_PER_REQUEST); + let festivals; + try { + festivals = await extractFestivalsFromUrls(batch, apiKey); + } catch (err) { + // 배치 실패 - 해당 URL들은 로그하지 않음 (다음 실행에 재시도) + fastify.log.error(`[festival] Gemini 분석 실패: ${err.message}`); + throw err; // scheduler의 consecutiveErrors 처리로 전달 + } + + // 결과를 source_url별로 그룹화 + const bySource = new Map(); + for (const f of festivals) { + const src = f.source_url || ''; + if (!bySource.has(src)) bySource.set(src, []); + bySource.get(src).push(f); + } + + // 각 축제 일정 생성 + for (const festival of festivals) { + try { + const ok = await createFestivalSchedule(festival, memberIds); + if (ok) addedCount++; + } catch (err) { + fastify.log.error(`[festival] 일정 생성 실패: ${err.message}`); + } + } + + // 4. 배치의 모든 URL을 크롤 로그에 기록 + for (const url of batch) { + const matched = bySource.get(url) || []; + const status = matched.length > 0 ? 'processed' : 'no_event'; + await logCrawled(url, status, matched.length); + } + } + + return { addedCount, total: postUrls.length }; + } + + fastify.decorate('festivalBot', { + syncNewFestivals, + }); +} + +export default fp(festivalBotPlugin, { + name: 'festivalBot', + dependencies: ['db', 'meilisearch'], +}); diff --git a/backend/src/services/festival/scraper.js b/backend/src/services/festival/scraper.js new file mode 100644 index 0000000..74b7b3d --- /dev/null +++ b/backend/src/services/festival/scraper.js @@ -0,0 +1,101 @@ +/** + * 축제 크롤러 - 검색 페이지에서 게시글 URL 수집 + */ + +const FETCH_TIMEOUT = 15000; +const MAX_PAGES = 10; // 안전 상한 + +/** + * 타임아웃이 적용된 fetch + */ +async function fetchWithTimeout(url, timeout = FETCH_TIMEOUT) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + try { + const res = await fetch(url, { + signal: controller.signal, + headers: { 'User-Agent': 'Mozilla/5.0 (compatible; fromis9-bot)' }, + }); + clearTimeout(timeoutId); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + return res; + } catch (err) { + clearTimeout(timeoutId); + if (err.name === 'AbortError') { + throw new Error('요청 타임아웃'); + } + throw err; + } +} + +/** + * HTML에서 게시글(/entry/) URL 추출 + * @param {string} html - 검색 페이지 HTML + * @param {string} origin - 사이트 origin (예: https://memogipost.tistory.com) + * @returns {string[]} 절대 URL 배열 + */ +export function extractEntryUrls(html, origin) { + const urls = new Set(); + // href="/entry/..." 또는 href="https://.../entry/..." + const regex = /href="((?:https?:\/\/[^"]+)?\/entry\/[^"]+)"/g; + let match; + while ((match = regex.exec(html)) !== null) { + let url = match[1]; + if (url.startsWith('/')) { + url = origin + url; + } + // 쿼리스트링/해시 제거 + url = url.split('#')[0].split('?')[0]; + urls.add(url); + } + return [...urls]; +} + +/** + * 검색 페이지를 페이지네이션하며 모든 게시글 URL 수집 + * @param {string} searchUrl - 검색 페이지 기본 URL + * @param {object} log - 로거 (선택) + * @returns {Promise} 게시글 URL 배열 (중복 제거) + */ +export async function fetchSearchPostUrls(searchUrl, log = null) { + // origin 추출 + const origin = new URL(searchUrl).origin; + const allUrls = []; + const seen = new Set(); + + for (let page = 1; page <= MAX_PAGES; page++) { + const pageUrl = searchUrl.includes('?') + ? `${searchUrl}&page=${page}` + : `${searchUrl}?page=${page}`; + + let html; + try { + const res = await fetchWithTimeout(pageUrl); + html = await res.text(); + } catch (err) { + log?.warn?.(`[festival] 페이지 ${page} 조회 실패: ${err.message}`); + break; + } + + const urls = extractEntryUrls(html, origin); + const newUrls = urls.filter(u => !seen.has(u)); + + if (newUrls.length === 0) { + // 더 이상 새 게시글 없음 → 종료 + break; + } + + for (const u of newUrls) { + seen.add(u); + allUrls.push(u); + } + log?.info?.(`[festival] 페이지 ${page}: ${newUrls.length}개 게시글`); + + // 페이지 간 간격 + await new Promise(r => setTimeout(r, 500)); + } + + return allUrls; +} diff --git a/docs/api.md b/docs/api.md index a9c59fe..a9d6fec 100644 --- a/docs/api.md +++ b/docs/api.md @@ -251,6 +251,7 @@ Meilisearch 전체 동기화 (인증 필요) ``` **필드 설명:** +- `type`: `youtube` | `x` | `festival` | `meilisearch` - `last_check_at`: 마지막 동기화 시간 (KST, +09:00) - `last_sync_duration`: 마지막 동기화 소요 시간 (ms) - `version`: Meilisearch 버전 (meilisearch 타입만) @@ -438,6 +439,61 @@ X 봇 삭제 --- +## 관리자 - 축제 봇 (인증 필요) + +대학 축제 크롤러 봇. 검색 페이지(memogipost 등)를 크롤링하여 프로미스나인 출연 대학 축제를 +Gemini `url_context`로 추출, 행사(EVENT) 일정을 자동 생성한다. + +- 이미 처리한 게시글 URL은 `festival_crawl_log`에 기록되어 재요청하지 않음 +- 새 게시글이 없으면 Gemini를 호출하지 않음 (무료 티어 RPD 20 제한 대응) +- 장소는 카카오맵 검색으로 자동 매칭, 멤버는 활동 멤버 전체로 등록 +- 같은 학교(캠퍼스명 부분일치) + 날짜 일정이 있으면 중복으로 건너뜀 + +### GET /admin/festival-bots +축제 봇 목록 조회 + +**응답:** `FestivalBot[]` + +### GET /admin/festival-bots/:id +축제 봇 상세 조회 + +**응답:** +```json +{ + "id": 1, + "name": "대학 축제 봇", + "search_url": "https://memogipost.tistory.com/search/프로미스나인", + "cron_interval": 360, + "enabled": true +} +``` + +### POST /admin/festival-bots +축제 봇 추가 + +**Request Body:** +```json +{ + "name": "대학 축제 봇", + "search_url": "https://memogipost.tistory.com/search/프로미스나인", + "cron_interval": 360 +} +``` + +| 필드 | 타입 | 기본값 | 설명 | +|------|------|--------|------| +| `name` | string | (필수) | 봇 이름 | +| `search_url` | string | (필수) | 크롤링할 검색 페이지 URL | +| `cron_interval` | integer | 360 | 동기화 간격 (분). 60 이상은 시간 단위 cron으로 변환 | + +### PUT /admin/festival-bots/:id +축제 봇 수정 (부분 업데이트 가능) + +### DELETE /admin/festival-bots/:id +축제 봇 삭제 + +--- + ## 관리자 - YouTube (인증 필요) ### GET /admin/youtube/video-info diff --git a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx index 255aa3a..5818297 100644 --- a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx +++ b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx @@ -2,7 +2,7 @@ import { useParams, Link } from 'react-router-dom'; import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { useEffect, useState, useRef } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, Tv, ExternalLink, Play, MapPin, GraduationCap } from 'lucide-react'; +import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, Tv, ExternalLink, Play, MapPin, PartyPopper } from 'lucide-react'; import { getSchedule } from '@/api'; import { KakaoMap } from '@/components/common'; import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils'; @@ -523,7 +523,7 @@ function MobileEventSection({ schedule }) { className="w-full aspect-[3/4] rounded-xl flex items-center justify-center" style={{ backgroundColor: `${categoryColor}10` }} > - + )} @@ -535,7 +535,7 @@ function MobileEventSection({ schedule }) { className="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold rounded-md" style={{ backgroundColor: `${categoryColor}25`, color: '#92400e' }} > - + {schedule.schoolName} )} @@ -606,13 +606,13 @@ function MobileEventSection({ schedule }) {

    {postUrls.map((url, idx) => ( -
  • +
  • · {url} diff --git a/frontend/src/pages/pc/public/schedule/sections/EventSection.jsx b/frontend/src/pages/pc/public/schedule/sections/EventSection.jsx index 872ce62..96adc13 100644 --- a/frontend/src/pages/pc/public/schedule/sections/EventSection.jsx +++ b/frontend/src/pages/pc/public/schedule/sections/EventSection.jsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { Swiper, SwiperSlide } from 'swiper/react'; import { Navigation } from 'swiper/modules'; import { - Calendar, Clock, MapPin, Link2, GraduationCap, ExternalLink, + Calendar, Clock, MapPin, Link2, PartyPopper, ExternalLink, ChevronLeft, ChevronRight, } from 'lucide-react'; import 'swiper/css'; @@ -90,7 +90,7 @@ function EventSection({ schedule }) { className="w-full aspect-[3/4] bg-white rounded-2xl flex items-center justify-center border border-gray-100" style={{ backgroundColor: `${categoryColor}10` }} > - + )} @@ -104,7 +104,7 @@ function EventSection({ schedule }) { className="inline-flex items-center gap-1.5 px-3 py-1.5 text-base font-semibold rounded-md" style={{ backgroundColor: `${categoryColor}25`, color: '#92400e' }} > - + {schedule.schoolName} )} @@ -185,13 +185,13 @@ function EventSection({ schedule }) {

      {postUrls.map((url, idx) => ( -
    • +
    • · {url}