From d530822a68a75b83aac056d8d53547524574d204 Mon Sep 17 00:00:00 2001
From: caadiq
Date: Wed, 20 May 2026 22:28:24 +0900
Subject: [PATCH] =?UTF-8?q?feat(festival-bot):=20=EB=8C=80=ED=95=99=20?=
=?UTF-8?q?=EC=B6=95=EC=A0=9C=20=ED=81=AC=EB=A1=A4=EB=9F=AC=20=EB=B4=87=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84=20(3=EB=8B=A8=EA=B3=84)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
검색 페이지(memogipost)를 크롤링하여 프로미스나인 출연 대학 축제를
Gemini url_context로 추출, 행사 일정을 자동 생성하는 봇.
백엔드:
- services/event.js: 이벤트 생성 로직 공유화 (upsertVenue, createEventSchedule, 카카오 검색)
- services/festival/: scraper(검색 페이지 크롤) + gemini(추출) + index(봇 플러그인)
- routes/admin/festival-bots.js: 축제 봇 CRUD API
- scheduler.js: festival 타입 지원, 시간 단위 cron(0 */H * * *) 변환
- 처리한 글 URL은 festival_crawl_log에 기록, 새 글 없으면 Gemini 미호출
- 학교명 부분일치 중복 감지, 활동 멤버 전체 자동 등록
- Gemini 503/500/429 재시도 로직
기타 수정:
- 행사 상세 페이지 관련 링크 줄바꿈 (truncate → break-all)
- 대학 축제 아이콘 변경 (GraduationCap → PartyPopper)
- docs/api.md, CLAUDE.md 환경변수 문서화
Co-Authored-By: Claude Opus 4.7
---
.env | 3 +
CLAUDE.md | 4 +-
backend/src/app.js | 2 +
backend/src/config/index.js | 3 +
backend/src/plugins/scheduler.js | 45 ++-
backend/src/routes/admin/bots.js | 34 ++-
backend/src/routes/admin/events.js | 30 +-
backend/src/routes/admin/festival-bots.js | 259 ++++++++++++++++++
backend/src/routes/index.js | 4 +
backend/src/services/event.js | 134 +++++++++
backend/src/services/festival/gemini.js | 145 ++++++++++
backend/src/services/festival/index.js | 211 ++++++++++++++
backend/src/services/festival/scraper.js | 101 +++++++
docs/api.md | 56 ++++
.../pages/mobile/schedule/ScheduleDetail.jsx | 10 +-
.../public/schedule/sections/EventSection.jsx | 10 +-
16 files changed, 1000 insertions(+), 51 deletions(-)
create mode 100644 backend/src/routes/admin/festival-bots.js
create mode 100644 backend/src/services/event.js
create mode 100644 backend/src/services/festival/gemini.js
create mode 100644 backend/src/services/festival/index.js
create mode 100644 backend/src/services/festival/scraper.js
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}