feat: 스프 채널 다음 주 예정 일정 자동 생성 기능

- 새 영상(쇼츠 제외) 추가 시 다음 주 같은 요일 예정 일정 자동 생성
- 실제 영상 업로드 시 예정 일정을 실제 정보로 덮어씌움
- 금요일 00시까지 영상 없으면 예정 일정 삭제 + 다음 주 예정 일정 생성
- autoScheduleNext 설정: dayOfWeek, time, title, deadlineDayOfWeek, excludeShorts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-02-03 14:36:10 +09:00
parent 9735206da7
commit e759d14ed6
3 changed files with 256 additions and 14 deletions

View file

@ -21,6 +21,14 @@ export default [
channelName: '스프 : 스튜디오 프로미스나인', channelName: '스프 : 스튜디오 프로미스나인',
cron: '*/2 * * * *', cron: '*/2 * * * *',
enabled: true, enabled: true,
// 다음 주 예정 일정 자동 생성
autoScheduleNext: {
dayOfWeek: 4, // 목요일 (0=일요일)
time: '18:00:00',
titleTemplate: '{channelName} {episode}화', // {episode}는 자동 계산
deadlineDayOfWeek: 5, // 금요일 00시까지 영상 없으면 삭제
excludeShorts: true, // 쇼츠는 제외
},
}, },
{ {
id: 'youtube-musinsa', id: 'youtube-musinsa',

View file

@ -39,7 +39,8 @@ export function buildDatetime(date, time) {
export function buildSource(schedule) { export function buildSource(schedule) {
const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id } = schedule; const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id } = schedule;
if (category_id === CATEGORY_IDS.YOUTUBE && youtube_video_id) { if (category_id === CATEGORY_IDS.YOUTUBE) {
if (youtube_video_id) {
const url = youtube_video_type === 'shorts' const url = youtube_video_type === 'shorts'
? `https://www.youtube.com/shorts/${youtube_video_id}` ? `https://www.youtube.com/shorts/${youtube_video_id}`
: `https://www.youtube.com/watch?v=${youtube_video_id}`; : `https://www.youtube.com/watch?v=${youtube_video_id}`;
@ -47,6 +48,13 @@ export function buildSource(schedule) {
name: youtube_channel || 'YouTube', name: youtube_channel || 'YouTube',
url, url,
}; };
} else if (youtube_channel) {
// 예정 일정: video_id 없이 채널 이름만
return {
name: youtube_channel,
url: null,
};
}
} }
if (category_id === CATEGORY_IDS.X && x_post_id) { if (category_id === CATEGORY_IDS.X && x_post_id) {

View file

@ -3,7 +3,7 @@ import { fetchRecentVideos, fetchAllVideos, getUploadsPlaylistId } from './api.j
import bots from '../../config/bots.js'; import bots from '../../config/bots.js';
import { CATEGORY_IDS } from '../../config/index.js'; import { CATEGORY_IDS } from '../../config/index.js';
import { withTransaction } from '../../utils/transaction.js'; import { withTransaction } from '../../utils/transaction.js';
import { syncScheduleById } from '../meilisearch/index.js'; import { syncScheduleById, deleteSchedule } from '../meilisearch/index.js';
const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE; const YOUTUBE_CATEGORY_ID = CATEGORY_IDS.YOUTUBE;
const PLAYLIST_CACHE_PREFIX = 'yt_uploads:'; const PLAYLIST_CACHE_PREFIX = 'yt_uploads:';
@ -28,6 +28,203 @@ async function youtubeBotPlugin(fastify, opts) {
return playlistId; return playlistId;
} }
/**
* 다음 특정 요일 날짜 계산 (KST 기준)
* @param {number} targetDay - 목표 요일 (0=, 4=)
* @param {Date} fromDate - 기준 날짜 (기본: 오늘)
* @returns {string} YYYY-MM-DD 형식
*/
function getNextWeekday(targetDay, fromDate = new Date()) {
const kst = new Date(fromDate.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
const currentDay = kst.getDay();
// 다음 주 같은 요일까지 일수 계산
let daysUntil = targetDay - currentDay + 7;
if (daysUntil <= 0) daysUntil += 7;
const nextDate = new Date(kst);
nextDate.setDate(kst.getDate() + daysUntil);
const year = nextDate.getFullYear();
const month = String(nextDate.getMonth() + 1).padStart(2, '0');
const day = String(nextDate.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* 해당 날짜의 예정 일정 조회 (is_temp = 1 )
*/
async function findScheduledEntry(bot, date) {
const [rows] = await fastify.db.query(
`SELECT sy.schedule_id, s.title, s.date, s.time
FROM schedule_youtube sy
JOIN schedules s ON s.id = sy.schedule_id
WHERE s.is_temp = 1 AND sy.channel_id = ? AND s.date = ?`,
[bot.channelId, date]
);
return rows[0] || null;
}
/**
* 채널의 일반 영상 개수 조회 (쇼츠 제외)
*/
async function getVideoCount(channelId) {
const [rows] = await fastify.db.query(
`SELECT COUNT(*) as cnt FROM schedule_youtube
WHERE channel_id = ? AND video_type = 'video' AND video_id IS NOT NULL`,
[channelId]
);
return rows[0].cnt;
}
/**
* 예정 일정 제목 생성
*/
async function generateScheduledTitle(bot) {
const { autoScheduleNext } = bot;
if (autoScheduleNext.titleTemplate) {
const videoCount = await getVideoCount(bot.channelId);
const nextEpisode = videoCount + 1;
return autoScheduleNext.titleTemplate
.replace('{channelName}', bot.channelName)
.replace('{episode}', nextEpisode);
}
return autoScheduleNext.title || `${bot.channelName} (예정)`;
}
/**
* 다음 예정 일정 생성
*/
async function createScheduledEntry(bot) {
const { autoScheduleNext } = bot;
if (!autoScheduleNext) return null;
const nextDate = getNextWeekday(autoScheduleNext.dayOfWeek);
// 이미 존재하는지 확인 (같은 채널, 같은 날짜, is_temp = 1)
const [existing] = await fastify.db.query(
`SELECT sy.schedule_id FROM schedule_youtube sy
JOIN schedules s ON s.id = sy.schedule_id
WHERE s.is_temp = 1 AND sy.channel_id = ? AND s.date = ?`,
[bot.channelId, nextDate]
);
if (existing.length > 0) {
return null; // 이미 존재
}
// 제목 생성
const title = await generateScheduledTitle(bot);
// 트랜잭션으로 생성
const scheduleId = await withTransaction(fastify.db, async (conn) => {
const [result] = await conn.query(
'INSERT INTO schedules (category_id, title, date, time, is_temp) VALUES (?, ?, ?, ?, 1)',
[YOUTUBE_CATEGORY_ID, title, nextDate, autoScheduleNext.time]
);
const newScheduleId = result.insertId;
await conn.query(
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
[newScheduleId, null, 'video', bot.channelId, bot.channelName]
);
return newScheduleId;
});
// Meilisearch 동기화
if (scheduleId) {
await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId);
fastify.log.info(`[${bot.id}] 다음 주 예정 일정 생성: ${nextDate} - ${title}`);
}
return scheduleId;
}
/**
* 예정 일정을 실제 영상으로 덮어씌움
*/
async function updateScheduledEntry(scheduledEntry, video, bot) {
await withTransaction(fastify.db, async (conn) => {
// schedules 테이블 업데이트 (is_temp = 0으로 변경)
await conn.query(
'UPDATE schedules SET title = ?, date = ?, time = ?, is_temp = 0 WHERE id = ?',
[video.title, video.date, video.time, scheduledEntry.schedule_id]
);
// schedule_youtube 테이블 업데이트
await conn.query(
'UPDATE schedule_youtube SET video_id = ?, video_type = ? WHERE schedule_id = ?',
[video.videoId, video.videoType, scheduledEntry.schedule_id]
);
});
// Meilisearch 동기화
await syncScheduleById(fastify.meilisearch, fastify.db, scheduledEntry.schedule_id);
fastify.log.info(`[${bot.id}] 예정 일정 업데이트: ${video.title}`);
return scheduledEntry.schedule_id;
}
/**
* 예정 일정 삭제 + 다음 예정 일정 생성
*/
async function deleteScheduledAndCreateNext(bot, scheduleId) {
// 삭제
await withTransaction(fastify.db, async (conn) => {
await conn.query('DELETE FROM schedule_members WHERE schedule_id = ?', [scheduleId]);
await conn.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [scheduleId]);
await conn.query('DELETE FROM schedules WHERE id = ?', [scheduleId]);
});
// Meilisearch에서도 삭제
await deleteSchedule(fastify.meilisearch, scheduleId);
fastify.log.info(`[${bot.id}] 예정 일정 삭제 (영상 미업로드)`);
// 다음 주 예정 일정 생성
await createScheduledEntry(bot);
}
/**
* 예정 일정 deadline 체크 (금요일 00)
*/
async function checkScheduledDeadline(bot) {
const { autoScheduleNext } = bot;
if (!autoScheduleNext || !autoScheduleNext.deadlineDayOfWeek) return;
const now = new Date();
const kst = new Date(now.toLocaleString('en-US', { timeZone: 'Asia/Seoul' }));
const currentDay = kst.getDay();
// deadline 요일인지 확인 (금요일 = 5)
if (currentDay !== autoScheduleNext.deadlineDayOfWeek) {
return;
}
// 어제(목요일) 날짜 계산 - deadline 당일이면 전날이 목표 요일
const targetDate = new Date(kst);
targetDate.setDate(kst.getDate() - 1); // 어제
const year = targetDate.getFullYear();
const month = String(targetDate.getMonth() + 1).padStart(2, '0');
const day = String(targetDate.getDate()).padStart(2, '0');
const targetDateStr = `${year}-${month}-${day}`;
// 예정 일정이 아직 존재하는지 확인 (is_temp = 1인 것)
const [rows] = await fastify.db.query(
`SELECT sy.schedule_id FROM schedule_youtube sy
JOIN schedules s ON s.id = sy.schedule_id
WHERE s.is_temp = 1 AND sy.channel_id = ? AND s.date = ?`,
[bot.channelId, targetDateStr]
);
if (rows.length > 0) {
// 아직 예정 상태 → 삭제 + 다음 주 생성
await deleteScheduledAndCreateNext(bot, rows[0].schedule_id);
}
}
/** /**
* 멤버 이름 조회 * 멤버 이름 조회
*/ */
@ -72,6 +269,23 @@ async function youtubeBotPlugin(fastify, opts) {
return null; return null;
} }
const { autoScheduleNext } = bot;
const isVideoType = video.videoType === 'video'; // 쇼츠가 아닌 일반 영상
// 예정 일정 처리 (쇼츠 제외 옵션이 있으면 쇼츠는 무시)
if (autoScheduleNext && isVideoType) {
// 해당 날짜의 예정 일정이 있는지 확인
const scheduledEntry = await findScheduledEntry(bot, video.date);
if (scheduledEntry) {
// 예정 일정을 실제 영상으로 덮어씌움
await updateScheduledEntry(scheduledEntry, video, bot);
// 다음 주 예정 일정 생성
await createScheduledEntry(bot);
return scheduledEntry.schedule_id;
}
}
// 멤버 이름 맵 미리 조회 (트랜잭션 전에) // 멤버 이름 맵 미리 조회 (트랜잭션 전에)
let nameMap = null; let nameMap = null;
if (bot.extractMembersFromDesc) { if (bot.extractMembersFromDesc) {
@ -79,18 +293,18 @@ async function youtubeBotPlugin(fastify, opts) {
} }
// 트랜잭션으로 INSERT 작업 수행 // 트랜잭션으로 INSERT 작업 수행
return withTransaction(fastify.db, async (connection) => { const scheduleId = await withTransaction(fastify.db, async (connection) => {
// schedules 테이블에 저장 // schedules 테이블에 저장
const [result] = await connection.query( const [result] = await connection.query(
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
[YOUTUBE_CATEGORY_ID, video.title, video.date, video.time] [YOUTUBE_CATEGORY_ID, video.title, video.date, video.time]
); );
const scheduleId = result.insertId; const newScheduleId = result.insertId;
// schedule_youtube 테이블에 저장 // schedule_youtube 테이블에 저장
await connection.query( await connection.query(
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)', 'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
[scheduleId, video.videoId, video.videoType, video.channelId, bot.channelName] [newScheduleId, video.videoId, video.videoType, video.channelId, bot.channelName]
); );
// 멤버 연결 (커스텀 설정) // 멤버 연결 (커스텀 설정)
@ -104,7 +318,7 @@ async function youtubeBotPlugin(fastify, opts) {
} }
if (memberIds.length > 0) { if (memberIds.length > 0) {
const uniqueIds = [...new Set(memberIds)]; const uniqueIds = [...new Set(memberIds)];
const values = uniqueIds.map(id => [scheduleId, id]); const values = uniqueIds.map(id => [newScheduleId, id]);
await connection.query( await connection.query(
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', 'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
[values] [values]
@ -112,14 +326,26 @@ async function youtubeBotPlugin(fastify, opts) {
} }
} }
return scheduleId; return newScheduleId;
}); });
// 새 영상 추가 후 다음 주 예정 일정 생성 (쇼츠 제외)
if (autoScheduleNext && isVideoType && scheduleId) {
await createScheduledEntry(bot);
}
return scheduleId;
} }
/** /**
* 최근 영상 동기화 (정기 실행) * 최근 영상 동기화 (정기 실행)
*/ */
async function syncNewVideos(bot) { async function syncNewVideos(bot) {
// 예정 일정 deadline 체크 (금요일 00시)
if (bot.autoScheduleNext) {
await checkScheduledDeadline(bot);
}
const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId); const uploadsPlaylistId = await getCachedUploadsPlaylistId(bot.channelId);
const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId); const videos = await fetchRecentVideos(bot.channelId, 10, uploadsPlaylistId);
let addedCount = 0; let addedCount = 0;