feat(festival-bot): 대학 축제 크롤러 봇 구현 (3단계)

검색 페이지(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 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-05-20 22:28:24 +09:00
parent 3827a23d75
commit d530822a68
16 changed files with 1000 additions and 51 deletions

3
.env
View file

@ -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

View file

@ -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) - 활동 로그 시스템
## 작업 시 주의사항

View file

@ -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로 참조 가능)

View file

@ -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',

View file

@ -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'],
});

View file

@ -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 };

View file

@ -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 파일들 추출
*/

View file

@ -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 };
});
}

View file

@ -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' });

View file

@ -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<number>} 생성된 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<Array>} 카카오 검색 결과 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,
};
}

View file

@ -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<Array>} [{ 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);
}

View file

@ -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'],
});

View file

@ -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<string[]>} 게시글 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;
}

View file

@ -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

View file

@ -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` }}
>
<GraduationCap size={48} style={{ color: categoryColor }} strokeWidth={1.5} />
<PartyPopper size={48} style={{ color: categoryColor }} strokeWidth={1.5} />
</div>
)}
@ -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' }}
>
<GraduationCap size={10} />
<PartyPopper size={10} />
{schedule.schoolName}
</span>
)}
@ -606,13 +606,13 @@ function MobileEventSection({ schedule }) {
</p>
<ul className="space-y-1">
{postUrls.map((url, idx) => (
<li key={idx} className="flex items-center gap-1.5 text-xs">
<li key={idx} className="flex items-start gap-1.5 text-xs">
<span className="text-gray-300 select-none">·</span>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-primary truncate"
className="text-primary break-all"
>
{url}
</a>

View file

@ -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` }}
>
<GraduationCap size={72} style={{ color: categoryColor }} strokeWidth={1.5} />
<PartyPopper size={72} style={{ color: categoryColor }} strokeWidth={1.5} />
</div>
)}
</div>
@ -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' }}
>
<GraduationCap size={15} />
<PartyPopper size={15} />
{schedule.schoolName}
</span>
)}
@ -185,13 +185,13 @@ function EventSection({ schedule }) {
</p>
<ul className="space-y-1">
{postUrls.map((url, idx) => (
<li key={idx} className="flex items-center gap-2 text-sm">
<li key={idx} className="flex items-start gap-2 text-sm">
<span className="text-gray-300 select-none">·</span>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline truncate"
className="text-primary hover:underline break-all"
>
{url}
</a>