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:
parent
3827a23d75
commit
d530822a68
16 changed files with 1000 additions and 51 deletions
3
.env
3
.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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) - 활동 로그 시스템
|
||||
|
||||
## 작업 시 주의사항
|
||||
|
||||
|
|
|
|||
|
|
@ -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로 참조 가능)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 파일들 추출
|
||||
*/
|
||||
|
|
|
|||
259
backend/src/routes/admin/festival-bots.js
Normal file
259
backend/src/routes/admin/festival-bots.js
Normal 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 };
|
||||
});
|
||||
}
|
||||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
134
backend/src/services/event.js
Normal file
134
backend/src/services/event.js
Normal 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,
|
||||
};
|
||||
}
|
||||
145
backend/src/services/festival/gemini.js
Normal file
145
backend/src/services/festival/gemini.js
Normal 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);
|
||||
}
|
||||
211
backend/src/services/festival/index.js
Normal file
211
backend/src/services/festival/index.js
Normal 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'],
|
||||
});
|
||||
101
backend/src/services/festival/scraper.js
Normal file
101
backend/src/services/festival/scraper.js
Normal 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;
|
||||
}
|
||||
56
docs/api.md
56
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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue