369 lines
9.2 KiB
JavaScript
369 lines
9.2 KiB
JavaScript
|
|
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 {
|
||
|
|
// 봇 정보 조회
|
||
|
|
const [bots] = await pool.query(
|
||
|
|
`SELECT b.*, c.channel_id, c.rss_url, c.include_shorts
|
||
|
|
FROM bots b
|
||
|
|
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];
|
||
|
|
const categoryId = await getYoutubeCategory();
|
||
|
|
|
||
|
|
// RSS 피드 파싱
|
||
|
|
const videos = await parseRSSFeed(bot.rss_url);
|
||
|
|
let addedCount = 0;
|
||
|
|
|
||
|
|
for (const video of videos) {
|
||
|
|
// Shorts 필터링
|
||
|
|
if (!bot.include_shorts && video.videoType === "shorts") {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
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 {
|
||
|
|
// 봇 정보 조회
|
||
|
|
const [bots] = await pool.query(
|
||
|
|
`SELECT b.*, c.channel_id, c.include_shorts
|
||
|
|
FROM bots b
|
||
|
|
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];
|
||
|
|
const categoryId = await getYoutubeCategory();
|
||
|
|
|
||
|
|
// API로 전체 영상 수집
|
||
|
|
const videos = await fetchAllVideosFromAPI(bot.channel_id);
|
||
|
|
let addedCount = 0;
|
||
|
|
|
||
|
|
for (const video of videos) {
|
||
|
|
// Shorts 필터링
|
||
|
|
if (!bot.include_shorts && video.videoType === "shorts") {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
};
|