import Parser from "rss-parser"; import pool from "../lib/db.js"; // YouTube API 키 const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; // RSS 파서 설정 const rssParser = new Parser({ customFields: { item: [ ["yt:videoId", "videoId"], ["yt:channelId", "channelId"], ], }, }); /** * 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)); return { videoId, title: item.title, 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로 전체 영상 수집 (초기 동기화용) * 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, 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로 중복 체크) */ export async function createScheduleFromVideo(video, categoryId) { 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) VALUES (?, ?, ?, ?, ?)`, [video.title, video.date, video.time, categoryId, video.videoUrl] ); return result.insertId; } catch (error) { console.error("일정 생성 오류:", error); throw error; } } /** * 봇의 새 영상 동기화 (RSS 기반) */ export async function syncNewVideos(botId) { try { // 봇 정보 조회 (bots 테이블에서 직접) const [bots] = await pool.query(`SELECT * FROM bots WHERE id = ?`, [botId]); if (bots.length === 0) { throw new Error("봇을 찾을 수 없습니다."); } const bot = bots[0]; if (!bot.rss_url) { throw new Error("RSS URL이 설정되지 않았습니다."); } const categoryId = await getYoutubeCategory(); // RSS 피드 파싱 const videos = await parseRSSFeed(bot.rss_url); let addedCount = 0; for (const video of videos) { // Shorts도 포함하여 일정으로 추가 const scheduleId = await createScheduleFromVideo(video, categoryId); if (scheduleId) { addedCount++; } } // 봇 상태 업데이트 await pool.query( `UPDATE bots SET last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), schedules_added = schedules_added + ?, error_message = NULL WHERE id = ?`, [addedCount, 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 { // 봇 정보 조회 (bots 테이블에서 직접) const [bots] = await pool.query(`SELECT * FROM bots WHERE id = ?`, [botId]); if (bots.length === 0) { throw new Error("봇을 찾을 수 없습니다."); } const bot = bots[0]; if (!bot.channel_id) { throw new Error("Channel ID가 설정되지 않았습니다."); } const categoryId = await getYoutubeCategory(); // API로 전체 영상 수집 const videos = await fetchAllVideosFromAPI(bot.channel_id); let addedCount = 0; for (const video of videos) { // Shorts도 포함하여 일정으로 추가 const scheduleId = await createScheduleFromVideo(video, categoryId); if (scheduleId) { addedCount++; } } // 봇 상태 업데이트 await pool.query( `UPDATE bots SET last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), schedules_added = schedules_added + ?, error_message = NULL WHERE id = ?`, [addedCount, 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, };