import Parser from "rss-parser"; import pool from "../lib/db.js"; import { addOrUpdateSchedule } from "./meilisearch.js"; // YouTube API 키 const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; // 봇별 커스텀 설정 (DB 대신 코드에서 관리) // botId를 키로 사용 const BOT_CUSTOM_CONFIG = { // MUSINSA TV: 제목에 '성수기' 포함된 영상만, 이채영 기본 멤버, description에서 멤버 추출 3: { titleFilter: "성수기", defaultMemberId: 7, // 이채영 extractMembersFromDesc: true, }, }; /** * 봇 커스텀 설정 조회 */ function getBotCustomConfig(botId) { return ( BOT_CUSTOM_CONFIG[botId] || { titleFilter: null, defaultMemberId: null, extractMembersFromDesc: false, } ); } // RSS 파서 설정 (media:description 포함) const rssParser = new Parser({ customFields: { item: [ ["yt:videoId", "videoId"], ["yt:channelId", "channelId"], ["media:group", "mediaGroup"], ], }, }); /** * UTC → KST 변환 */ export function toKST(utcDate) { const date = new Date(utcDate); return new Date(date.getTime() + 9 * 60 * 60 * 1000); } /** * 날짜를 YYYY-MM-DD 형식으로 변환 */ export function formatDate(date) { return date.toISOString().split("T")[0]; } /** * 시간을 HH:MM:SS 형식으로 변환 */ export function formatTime(date) { return date.toTimeString().split(" ")[0]; } /** * '유튜브' 카테고리 ID 조회 (없으면 생성) */ export async function getYoutubeCategory() { const [rows] = await pool.query( "SELECT id FROM schedule_categories WHERE name = '유튜브'" ); if (rows.length > 0) { return rows[0].id; } // 없으면 생성 const [result] = await pool.query( "INSERT INTO schedule_categories (name, color, sort_order) VALUES ('유튜브', '#ff0033', 99)" ); return result.insertId; } /** * 영상 URL에서 유형 판별 (video/shorts) */ export function getVideoType(url) { if (url.includes("/shorts/")) { return "shorts"; } return "video"; } /** * 영상 URL 생성 */ export function getVideoUrl(videoId, videoType) { if (videoType === "shorts") { return `https://www.youtube.com/shorts/${videoId}`; } return `https://www.youtube.com/watch?v=${videoId}`; } /** * RSS 피드 파싱하여 영상 목록 반환 */ export async function parseRSSFeed(rssUrl) { try { const feed = await rssParser.parseURL(rssUrl); return feed.items.map((item) => { const videoId = item.videoId; const link = item.link || ""; const videoType = getVideoType(link); const publishedAt = toKST(new Date(item.pubDate)); // media:group에서 description 추출 let description = ""; if (item.mediaGroup && item.mediaGroup["media:description"]) { description = item.mediaGroup["media:description"][0] || ""; } return { videoId, title: item.title, description, publishedAt, date: formatDate(publishedAt), time: formatTime(publishedAt), videoUrl: link || getVideoUrl(videoId, videoType), videoType, }; }); } catch (error) { console.error("RSS 파싱 오류:", error); throw error; } } /** * ISO 8601 duration (PT1M30S) → 초 변환 */ function parseDuration(duration) { const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (!match) return 0; const hours = parseInt(match[1] || 0); const minutes = parseInt(match[2] || 0); const seconds = parseInt(match[3] || 0); return hours * 3600 + minutes * 60 + seconds; } /** * YouTube API로 최근 N개 영상 수집 (정기 동기화용) * @param {string} channelId - 채널 ID * @param {number} maxResults - 조회할 영상 수 (기본 10) */ export async function fetchRecentVideosFromAPI(channelId, maxResults = 10) { const videos = []; try { // 채널의 업로드 플레이리스트 ID 조회 const channelResponse = await fetch( `https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}` ); const channelData = await channelResponse.json(); if (!channelData.items || channelData.items.length === 0) { throw new Error("채널을 찾을 수 없습니다."); } const uploadsPlaylistId = channelData.items[0].contentDetails.relatedPlaylists.uploads; // 플레이리스트 아이템 조회 (최근 N개만) const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=${maxResults}&key=${YOUTUBE_API_KEY}`; const response = await fetch(url); const data = await response.json(); if (data.error) { throw new Error(data.error.message); } // 영상 ID 목록 추출 const videoIds = data.items.map((item) => item.snippet.resourceId.videoId); // videos API로 duration 조회 (Shorts 판별용) const videosResponse = await fetch( `https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join( "," )}&key=${YOUTUBE_API_KEY}` ); const videosData = await videosResponse.json(); // duration으로 Shorts 판별 맵 생성 const durationMap = {}; if (videosData.items) { for (const v of videosData.items) { const duration = v.contentDetails.duration; const seconds = parseDuration(duration); durationMap[v.id] = seconds <= 60 ? "shorts" : "video"; } } for (const item of data.items) { const snippet = item.snippet; const videoId = snippet.resourceId.videoId; const publishedAt = toKST(new Date(snippet.publishedAt)); const videoType = durationMap[videoId] || "video"; videos.push({ videoId, title: snippet.title, description: snippet.description || "", publishedAt, date: formatDate(publishedAt), time: formatTime(publishedAt), videoUrl: getVideoUrl(videoId, videoType), videoType, }); } return videos; } catch (error) { console.error("YouTube API 오류:", error); throw error; } } /** * YouTube API로 전체 영상 수집 (초기 동기화용) * Shorts 판별: duration이 60초 이하이면 Shorts */ export async function fetchAllVideosFromAPI(channelId) { const videos = []; let pageToken = ""; try { // 채널의 업로드 플레이리스트 ID 조회 const channelResponse = await fetch( `https://www.googleapis.com/youtube/v3/channels?part=contentDetails&id=${channelId}&key=${YOUTUBE_API_KEY}` ); const channelData = await channelResponse.json(); if (!channelData.items || channelData.items.length === 0) { throw new Error("채널을 찾을 수 없습니다."); } const uploadsPlaylistId = channelData.items[0].contentDetails.relatedPlaylists.uploads; // 플레이리스트 아이템 조회 (페이징) do { const url = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet&playlistId=${uploadsPlaylistId}&maxResults=50&key=${YOUTUBE_API_KEY}${ pageToken ? `&pageToken=${pageToken}` : "" }`; const response = await fetch(url); const data = await response.json(); if (data.error) { throw new Error(data.error.message); } // 영상 ID 목록 추출 const videoIds = data.items.map( (item) => item.snippet.resourceId.videoId ); // videos API로 duration 조회 (50개씩 배치) const videosResponse = await fetch( `https://www.googleapis.com/youtube/v3/videos?part=contentDetails&id=${videoIds.join( "," )}&key=${YOUTUBE_API_KEY}` ); const videosData = await videosResponse.json(); // duration으로 Shorts 판별 맵 생성 const durationMap = {}; if (videosData.items) { for (const v of videosData.items) { const duration = v.contentDetails.duration; const seconds = parseDuration(duration); durationMap[v.id] = seconds <= 60 ? "shorts" : "video"; } } for (const item of data.items) { const snippet = item.snippet; const videoId = snippet.resourceId.videoId; const publishedAt = toKST(new Date(snippet.publishedAt)); const videoType = durationMap[videoId] || "video"; videos.push({ videoId, title: snippet.title, description: snippet.description || "", publishedAt, date: formatDate(publishedAt), time: formatTime(publishedAt), videoUrl: getVideoUrl(videoId, videoType), videoType, }); } pageToken = data.nextPageToken || ""; } while (pageToken); // 과거순 정렬 (오래된 영상부터 추가) videos.sort((a, b) => new Date(a.publishedAt) - new Date(b.publishedAt)); return videos; } catch (error) { console.error("YouTube API 오류:", error); throw error; } } /** * 영상을 일정으로 추가 (source_url로 중복 체크) * @param {Object} video - 영상 정보 * @param {number} categoryId - 카테고리 ID * @param {number[]} memberIds - 연결할 멤버 ID 배열 (선택) * @param {string} sourceName - 출처 이름 (선택) */ export async function createScheduleFromVideo( video, categoryId, memberIds = [], sourceName = null ) { try { // source_url로 중복 체크 const [existing] = await pool.query( "SELECT id FROM schedules WHERE source_url = ?", [video.videoUrl] ); if (existing.length > 0) { return null; // 이미 존재 } // 일정 생성 const [result] = await pool.query( `INSERT INTO schedules (title, date, time, category_id, source_url, source_name) VALUES (?, ?, ?, ?, ?, ?)`, [ video.title, video.date, video.time, categoryId, video.videoUrl, sourceName, ] ); const scheduleId = result.insertId; // 멤버 연결 if (memberIds.length > 0) { const uniqueMemberIds = [...new Set(memberIds)]; // 중복 제거 const memberValues = uniqueMemberIds.map((memberId) => [ scheduleId, memberId, ]); await pool.query( `INSERT INTO schedule_members (schedule_id, member_id) VALUES ?`, [memberValues] ); } // Meilisearch에 동기화 try { const [categoryInfo] = await pool.query( "SELECT name, color FROM schedule_categories WHERE id = ?", [categoryId] ); const [memberInfo] = await pool.query( "SELECT id, name FROM members WHERE id IN (?)", [memberIds.length > 0 ? [...new Set(memberIds)] : [0]] ); await addOrUpdateSchedule({ id: scheduleId, title: video.title, description: "", date: video.date, time: video.time, category_id: categoryId, category_name: categoryInfo[0]?.name || "", category_color: categoryInfo[0]?.color || "", source_name: sourceName, source_url: video.videoUrl, members: memberInfo, }); } catch (searchError) { console.error("Meilisearch 동기화 오류:", searchError.message); } return scheduleId; } catch (error) { console.error("일정 생성 오류:", error); throw error; } } /** * 멤버 이름 목록 조회 */ async function getMemberNameMap() { const [members] = await pool.query("SELECT id, name FROM members"); const nameMap = {}; for (const m of members) { nameMap[m.name] = m.id; } return nameMap; } /** * description에서 멤버 이름 추출 */ function extractMemberIdsFromDescription(description, memberNameMap) { if (!description) return []; const memberIds = []; for (const [name, id] of Object.entries(memberNameMap)) { if (description.includes(name)) { memberIds.push(id); } } return memberIds; } /** * 봇의 새 영상 동기화 (YouTube API 기반) */ export async function syncNewVideos(botId) { try { // 봇 정보 조회 (bot_youtube_config 조인) const [bots] = await pool.query( ` SELECT b.*, c.channel_id FROM bots b LEFT JOIN bot_youtube_config c ON b.id = c.bot_id WHERE b.id = ? `, [botId] ); if (bots.length === 0) { throw new Error("봇을 찾을 수 없습니다."); } const bot = bots[0]; if (!bot.channel_id) { throw new Error("Channel ID가 설정되지 않았습니다."); } // 봇별 커스텀 설정 조회 const customConfig = getBotCustomConfig(botId); const categoryId = await getYoutubeCategory(); // YouTube API로 최근 10개 영상 조회 const videos = await fetchRecentVideosFromAPI(bot.channel_id, 10); let addedCount = 0; // 멤버 추출을 위한 이름 맵 조회 (필요 시) let memberNameMap = null; if (customConfig.extractMembersFromDesc) { memberNameMap = await getMemberNameMap(); } for (const video of videos) { // 제목 필터 적용 (설정된 경우) if ( customConfig.titleFilter && !video.title.includes(customConfig.titleFilter) ) { continue; // 필터에 맞지 않으면 스킵 } // 멤버 ID 수집 const memberIds = []; // 기본 멤버 추가 if (customConfig.defaultMemberId) { memberIds.push(customConfig.defaultMemberId); } // description에서 멤버 추출 (설정된 경우) if (customConfig.extractMembersFromDesc && memberNameMap) { const extractedIds = extractMemberIdsFromDescription( video.description, memberNameMap ); memberIds.push(...extractedIds); } const scheduleId = await createScheduleFromVideo( video, categoryId, memberIds, bot.name ); if (scheduleId) { addedCount++; } } // 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수) // addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지) if (addedCount > 0) { await pool.query( `UPDATE bots SET last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), schedules_added = schedules_added + ?, last_added_count = ?, error_message = NULL WHERE id = ?`, [addedCount, addedCount, botId] ); } else { await pool.query( `UPDATE bots SET last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), error_message = NULL WHERE id = ?`, [botId] ); } return { addedCount, total: videos.length }; } catch (error) { // 오류 상태 업데이트 await pool.query( `UPDATE bots SET last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), status = 'error', error_message = ? WHERE id = ?`, [error.message, botId] ); throw error; } } /** * 전체 영상 동기화 (API 기반, 초기화용) */ export async function syncAllVideos(botId) { try { // 봇 정보 조회 (bot_youtube_config 조인) const [bots] = await pool.query( ` SELECT b.*, c.channel_id FROM bots b LEFT JOIN bot_youtube_config c ON b.id = c.bot_id WHERE b.id = ? `, [botId] ); if (bots.length === 0) { throw new Error("봇을 찾을 수 없습니다."); } const bot = bots[0]; if (!bot.channel_id) { throw new Error("Channel ID가 설정되지 않았습니다."); } // 봇별 커스텀 설정 조회 const customConfig = getBotCustomConfig(botId); const categoryId = await getYoutubeCategory(); // API로 전체 영상 수집 const videos = await fetchAllVideosFromAPI(bot.channel_id); let addedCount = 0; // 멤버 추출을 위한 이름 맵 조회 (필요 시) let memberNameMap = null; if (customConfig.extractMembersFromDesc) { memberNameMap = await getMemberNameMap(); } for (const video of videos) { // 제목 필터 적용 (설정된 경우) if ( customConfig.titleFilter && !video.title.includes(customConfig.titleFilter) ) { continue; // 필터에 맞지 않으면 스킵 } // 멤버 ID 수집 const memberIds = []; // 기본 멤버 추가 if (customConfig.defaultMemberId) { memberIds.push(customConfig.defaultMemberId); } // description에서 멤버 추출 (설정된 경우) if (customConfig.extractMembersFromDesc && memberNameMap) { const extractedIds = extractMemberIdsFromDescription( video.description, memberNameMap ); memberIds.push(...extractedIds); } const scheduleId = await createScheduleFromVideo( video, categoryId, memberIds, bot.name ); if (scheduleId) { addedCount++; } } // 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수) // addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지) if (addedCount > 0) { await pool.query( `UPDATE bots SET last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), schedules_added = schedules_added + ?, last_added_count = ?, error_message = NULL WHERE id = ?`, [addedCount, addedCount, botId] ); } else { await pool.query( `UPDATE bots SET last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), error_message = NULL WHERE id = ?`, [botId] ); } return { addedCount, total: videos.length }; } catch (error) { await pool.query( `UPDATE bots SET status = 'error', error_message = ? WHERE id = ?`, [error.message, botId] ); throw error; } } export default { parseRSSFeed, fetchAllVideosFromAPI, syncNewVideos, syncAllVideos, getYoutubeCategory, toKST, };