diff --git a/backend/sql/bot_active_months.sql b/backend/sql/bot_active_months.sql index 1b44c37..469aebb 100644 --- a/backend/sql/bot_active_months.sql +++ b/backend/sql/bot_active_months.sql @@ -1,7 +1,6 @@ --- 봇 실행 활성 월 (시즌성 봇 대응) +-- 축제 봇 실행 활성 월 (시즌성 대응) -- active_months: JSON 정수 배열 (예: [4,5,8,9]) — 해당 월에만 동기화 실행 -- NULL 또는 12개 전체 = 모든 월 실행 (제한 없음) --- 대학 축제 봇처럼 특정 시즌에만 도는 봇의 불필요한 API 호출(특히 Gemini RPD)을 절약 +-- 대학 축제는 학기 중 한정 기간에만 열리므로, 비시즌에 불필요한 +-- 크롤링/Gemini 호출(RPD 제한)을 막기 위해 축제 봇에만 적용. ALTER TABLE bot_festival ADD COLUMN active_months JSON DEFAULT NULL AFTER cron_interval; -ALTER TABLE bot_x ADD COLUMN active_months JSON DEFAULT NULL AFTER cron_interval; -ALTER TABLE bot_youtube ADD COLUMN active_months JSON DEFAULT NULL AFTER cron_interval; diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js index 092733b..96e01cd 100644 --- a/backend/src/plugins/scheduler.js +++ b/backend/src/plugins/scheduler.js @@ -3,26 +3,13 @@ import cron from 'node-cron'; import staticBots from '../config/bots.js'; import { syncAllSchedules } from '../services/meilisearch/index.js'; import { nowKST, monthKST } from '../utils/date.js'; +import { parseActiveMonths } from '../utils/botMonths.js'; import { logActivity } from '../utils/log.js'; const REDIS_PREFIX = 'bot:status:'; const TIMEZONE = 'Asia/Seoul'; const MAX_CONSECUTIVE_ERRORS = 10; -/** - * active_months 컬럼 파싱 (JSON 정수 배열 또는 null) - */ -function parseActiveMonths(raw) { - if (!raw) return null; - try { - const arr = typeof raw === 'string' ? JSON.parse(raw) : raw; - if (!Array.isArray(arr) || arr.length === 0) return null; - return arr.map(Number).filter((m) => m >= 1 && m <= 12); - } catch { - return null; - } -} - /** * 현재 KST 월이 봇의 활성 월에 포함되는지 * - activeMonths가 null/빈배열/12개 전체 → 항상 실행 @@ -97,7 +84,6 @@ async function schedulerPlugin(fastify, opts) { : [], extractMembersFromDesc: row.extract_members_from_desc === 1, extractMembersFromTitle: row.extract_members_from_title === 1, - activeMonths: parseActiveMonths(row.active_months), autoScheduleNext: row.auto_schedule_config ? (typeof row.auto_schedule_config === 'string' ? JSON.parse(row.auto_schedule_config) @@ -133,7 +119,6 @@ async function schedulerPlugin(fastify, opts) { includeRetweets: row.include_retweets === 1, extractYoutube: row.extract_youtube === 1, excludeManagedChannels: row.exclude_managed_channels === 1, - activeMonths: parseActiveMonths(row.active_months), })); } diff --git a/backend/src/routes/admin/festival-bots.js b/backend/src/routes/admin/festival-bots.js index 671a4ca..10fbc16 100644 --- a/backend/src/routes/admin/festival-bots.js +++ b/backend/src/routes/admin/festival-bots.js @@ -1,6 +1,7 @@ import { errorResponse } from '../../schemas/index.js'; import { badRequest, notFound } from '../../utils/error.js'; import { logActivity } from '../../utils/log.js'; +import { parseActiveMonths, serializeActiveMonths } from '../../utils/botMonths.js'; /** * 축제 봇 응답 스키마 @@ -12,6 +13,7 @@ const festivalBotResponse = { name: { type: 'string' }, search_url: { type: 'string' }, cron_interval: { type: 'integer' }, + active_months: { type: ['array', 'null'], items: { type: 'integer' } }, enabled: { type: 'boolean' }, }, }; @@ -33,6 +35,7 @@ function formatBotResponse(row) { name: row.name, search_url: row.search_url, cron_interval: row.cron_interval, + active_months: parseActiveMonths(row.active_months), enabled: row.enabled === 1, }; } @@ -97,6 +100,7 @@ export default async function festivalBotsRoutes(fastify) { name: { type: 'string' }, search_url: { type: 'string' }, cron_interval: { type: 'integer', default: 360 }, + active_months: { type: ['array', 'null'], items: { type: 'integer' } }, }, required: ['name', 'search_url'], }, @@ -104,16 +108,16 @@ export default async function festivalBotsRoutes(fastify) { }, preHandler: [fastify.authenticate], }, async (request, reply) => { - const { name, search_url, cron_interval = 360 } = request.body; + const { name, search_url, cron_interval = 360, active_months } = 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] + `INSERT INTO bot_festival (name, search_url, cron_interval, active_months, enabled) + VALUES (?, ?, ?, ?, 1)`, + [name.trim(), search_url.trim(), cron_interval, serializeActiveMonths(active_months)] ); scheduler.invalidateCache(); @@ -152,6 +156,7 @@ export default async function festivalBotsRoutes(fastify) { name: { type: 'string' }, search_url: { type: 'string' }, cron_interval: { type: 'integer' }, + active_months: { type: ['array', 'null'], items: { type: 'integer' } }, enabled: { type: 'boolean' }, }, }, @@ -181,6 +186,10 @@ export default async function festivalBotsRoutes(fastify) { fields.push('cron_interval = ?'); values.push(updates.cron_interval); } + if (updates.active_months !== undefined) { + fields.push('active_months = ?'); + values.push(serializeActiveMonths(updates.active_months)); + } if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); diff --git a/backend/src/utils/botMonths.js b/backend/src/utils/botMonths.js new file mode 100644 index 0000000..cf09930 --- /dev/null +++ b/backend/src/utils/botMonths.js @@ -0,0 +1,32 @@ +/** + * 봇 활성 월(active_months) 파싱/직렬화 유틸 + * - 저장 형식: JSON 정수 배열 (예: [4,5,8,9]) 또는 NULL(전체 월 = 항상 실행) + */ + +/** + * DB 값(JSON 문자열/배열) → 정수 배열 또는 null + */ +export function parseActiveMonths(raw) { + if (!raw) return null; + try { + const arr = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!Array.isArray(arr) || arr.length === 0) return null; + const months = arr.map(Number).filter((m) => m >= 1 && m <= 12); + return months.length > 0 ? months : null; + } catch { + return null; + } +} + +/** + * 입력 배열 → DB 저장용 JSON 문자열 또는 null + * - 빈 배열 / 12개 전체 선택은 NULL(제한 없음)로 정규화 + */ +export function serializeActiveMonths(input) { + if (!Array.isArray(input)) return null; + const months = [...new Set(input.map(Number).filter((m) => m >= 1 && m <= 12))].sort( + (a, b) => a - b + ); + if (months.length === 0 || months.length >= 12) return null; + return JSON.stringify(months); +}