fromis_9/backend/services/youtube-bot.js

361 lines
9 KiB
JavaScript
Raw Normal View History

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,
};