2026-01-05 22:16:02 +09:00
|
|
|
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 {
|
2026-01-05 22:26:44 +09:00
|
|
|
// 봇 정보 조회 (bots 테이블에서 직접)
|
|
|
|
|
const [bots] = await pool.query(`SELECT * FROM bots WHERE id = ?`, [botId]);
|
2026-01-05 22:16:02 +09:00
|
|
|
|
|
|
|
|
if (bots.length === 0) {
|
|
|
|
|
throw new Error("봇을 찾을 수 없습니다.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bot = bots[0];
|
2026-01-05 22:26:44 +09:00
|
|
|
|
|
|
|
|
if (!bot.rss_url) {
|
|
|
|
|
throw new Error("RSS URL이 설정되지 않았습니다.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 22:16:02 +09:00
|
|
|
const categoryId = await getYoutubeCategory();
|
|
|
|
|
|
|
|
|
|
// RSS 피드 파싱
|
|
|
|
|
const videos = await parseRSSFeed(bot.rss_url);
|
|
|
|
|
let addedCount = 0;
|
|
|
|
|
|
|
|
|
|
for (const video of videos) {
|
2026-01-05 22:26:44 +09:00
|
|
|
// Shorts도 포함하여 일정으로 추가
|
2026-01-05 22:16:02 +09:00
|
|
|
|
|
|
|
|
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 {
|
2026-01-05 22:26:44 +09:00
|
|
|
// 봇 정보 조회 (bots 테이블에서 직접)
|
|
|
|
|
const [bots] = await pool.query(`SELECT * FROM bots WHERE id = ?`, [botId]);
|
2026-01-05 22:16:02 +09:00
|
|
|
|
|
|
|
|
if (bots.length === 0) {
|
|
|
|
|
throw new Error("봇을 찾을 수 없습니다.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const bot = bots[0];
|
2026-01-05 22:26:44 +09:00
|
|
|
|
|
|
|
|
if (!bot.channel_id) {
|
|
|
|
|
throw new Error("Channel ID가 설정되지 않았습니다.");
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 22:16:02 +09:00
|
|
|
const categoryId = await getYoutubeCategory();
|
|
|
|
|
|
|
|
|
|
// API로 전체 영상 수집
|
|
|
|
|
const videos = await fetchAllVideosFromAPI(bot.channel_id);
|
|
|
|
|
let addedCount = 0;
|
|
|
|
|
|
|
|
|
|
for (const video of videos) {
|
2026-01-05 22:26:44 +09:00
|
|
|
// Shorts도 포함하여 일정으로 추가
|
2026-01-05 22:16:02 +09:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
};
|