2026-01-05 22:16:02 +09:00
|
|
|
import Parser from "rss-parser";
|
|
|
|
|
import pool from "../lib/db.js";
|
2026-01-06 08:22:43 +09:00
|
|
|
import { addOrUpdateSchedule } from "./meilisearch.js";
|
2026-01-05 22:16:02 +09:00
|
|
|
|
|
|
|
|
// YouTube API 키
|
|
|
|
|
const YOUTUBE_API_KEY =
|
|
|
|
|
process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM";
|
|
|
|
|
|
2026-01-06 00:27:35 +09:00
|
|
|
// 봇별 커스텀 설정 (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 포함)
|
2026-01-05 22:16:02 +09:00
|
|
|
const rssParser = new Parser({
|
|
|
|
|
customFields: {
|
|
|
|
|
item: [
|
|
|
|
|
["yt:videoId", "videoId"],
|
|
|
|
|
["yt:channelId", "channelId"],
|
2026-01-06 00:27:35 +09:00
|
|
|
["media:group", "mediaGroup"],
|
2026-01-05 22:16:02 +09:00
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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));
|
|
|
|
|
|
2026-01-06 00:27:35 +09:00
|
|
|
// media:group에서 description 추출
|
|
|
|
|
let description = "";
|
|
|
|
|
if (item.mediaGroup && item.mediaGroup["media:description"]) {
|
|
|
|
|
description = item.mediaGroup["media:description"][0] || "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 22:16:02 +09:00
|
|
|
return {
|
|
|
|
|
videoId,
|
|
|
|
|
title: item.title,
|
2026-01-06 00:27:35 +09:00
|
|
|
description,
|
2026-01-05 22:16:02 +09:00
|
|
|
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,
|
2026-01-06 00:27:35 +09:00
|
|
|
description: snippet.description || "",
|
2026-01-05 22:16:02 +09:00
|
|
|
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로 중복 체크)
|
2026-01-06 00:27:35 +09:00
|
|
|
* @param {Object} video - 영상 정보
|
|
|
|
|
* @param {number} categoryId - 카테고리 ID
|
|
|
|
|
* @param {number[]} memberIds - 연결할 멤버 ID 배열 (선택)
|
|
|
|
|
* @param {string} sourceName - 출처 이름 (선택)
|
2026-01-05 22:16:02 +09:00
|
|
|
*/
|
2026-01-06 00:27:35 +09:00
|
|
|
export async function createScheduleFromVideo(
|
|
|
|
|
video,
|
|
|
|
|
categoryId,
|
|
|
|
|
memberIds = [],
|
|
|
|
|
sourceName = null
|
|
|
|
|
) {
|
2026-01-05 22:16:02 +09:00
|
|
|
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(
|
2026-01-06 00:27:35 +09:00
|
|
|
`INSERT INTO schedules (title, date, time, category_id, source_url, source_name)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
[
|
|
|
|
|
video.title,
|
|
|
|
|
video.date,
|
|
|
|
|
video.time,
|
|
|
|
|
categoryId,
|
|
|
|
|
video.videoUrl,
|
|
|
|
|
sourceName,
|
|
|
|
|
]
|
2026-01-05 22:16:02 +09:00
|
|
|
);
|
|
|
|
|
|
2026-01-06 00:27:35 +09:00
|
|
|
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]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 08:22:43 +09:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 00:27:35 +09:00
|
|
|
return scheduleId;
|
2026-01-05 22:16:02 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
console.error("일정 생성 오류:", error);
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-06 00:27:35 +09:00
|
|
|
/**
|
|
|
|
|
* 멤버 이름 목록 조회
|
|
|
|
|
*/
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 22:16:02 +09:00
|
|
|
/**
|
|
|
|
|
* 봇의 새 영상 동기화 (RSS 기반)
|
|
|
|
|
*/
|
|
|
|
|
export async function syncNewVideos(botId) {
|
|
|
|
|
try {
|
2026-01-06 00:27:35 +09:00
|
|
|
// 봇 정보 조회 (bot_youtube_config 조인)
|
|
|
|
|
const [bots] = await pool.query(
|
|
|
|
|
`
|
|
|
|
|
SELECT b.*, c.channel_id, c.rss_url
|
|
|
|
|
FROM bots b
|
|
|
|
|
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
|
|
|
|
WHERE b.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-06 00:27:35 +09:00
|
|
|
// 봇별 커스텀 설정 조회
|
|
|
|
|
const customConfig = getBotCustomConfig(botId);
|
|
|
|
|
|
2026-01-05 22:16:02 +09:00
|
|
|
const categoryId = await getYoutubeCategory();
|
|
|
|
|
|
|
|
|
|
// RSS 피드 파싱
|
|
|
|
|
const videos = await parseRSSFeed(bot.rss_url);
|
|
|
|
|
let addedCount = 0;
|
|
|
|
|
|
2026-01-06 00:27:35 +09:00
|
|
|
// 멤버 추출을 위한 이름 맵 조회 (필요 시)
|
|
|
|
|
let memberNameMap = null;
|
|
|
|
|
if (customConfig.extractMembersFromDesc) {
|
|
|
|
|
memberNameMap = await getMemberNameMap();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 22:16:02 +09:00
|
|
|
for (const video of videos) {
|
2026-01-06 00:27:35 +09:00
|
|
|
// 제목 필터 적용 (설정된 경우)
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-01-05 22:16:02 +09:00
|
|
|
|
2026-01-06 00:27:35 +09:00
|
|
|
const scheduleId = await createScheduleFromVideo(
|
|
|
|
|
video,
|
|
|
|
|
categoryId,
|
|
|
|
|
memberIds,
|
|
|
|
|
bot.name
|
|
|
|
|
);
|
2026-01-05 22:16:02 +09:00
|
|
|
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-06 00:27:35 +09:00
|
|
|
// 봇 정보 조회 (bot_youtube_config 조인)
|
|
|
|
|
const [bots] = await pool.query(
|
|
|
|
|
`
|
|
|
|
|
SELECT b.*, c.channel_id, c.rss_url
|
|
|
|
|
FROM bots b
|
|
|
|
|
LEFT JOIN bot_youtube_config c ON b.id = c.bot_id
|
|
|
|
|
WHERE b.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-06 00:27:35 +09:00
|
|
|
// 봇별 커스텀 설정 조회
|
|
|
|
|
const customConfig = getBotCustomConfig(botId);
|
|
|
|
|
|
2026-01-05 22:16:02 +09:00
|
|
|
const categoryId = await getYoutubeCategory();
|
|
|
|
|
|
|
|
|
|
// API로 전체 영상 수집
|
|
|
|
|
const videos = await fetchAllVideosFromAPI(bot.channel_id);
|
|
|
|
|
let addedCount = 0;
|
|
|
|
|
|
2026-01-06 00:27:35 +09:00
|
|
|
// 멤버 추출을 위한 이름 맵 조회 (필요 시)
|
|
|
|
|
let memberNameMap = null;
|
|
|
|
|
if (customConfig.extractMembersFromDesc) {
|
|
|
|
|
memberNameMap = await getMemberNameMap();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 22:16:02 +09:00
|
|
|
for (const video of videos) {
|
2026-01-06 00:27:35 +09:00
|
|
|
// 제목 필터 적용 (설정된 경우)
|
|
|
|
|
if (
|
|
|
|
|
customConfig.titleFilter &&
|
|
|
|
|
!video.title.includes(customConfig.titleFilter)
|
|
|
|
|
) {
|
|
|
|
|
continue; // 필터에 맞지 않으면 스킵
|
|
|
|
|
}
|
2026-01-05 22:16:02 +09:00
|
|
|
|
2026-01-06 00:27:35 +09:00
|
|
|
// 멤버 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
|
|
|
|
|
);
|
2026-01-05 22:16:02 +09:00
|
|
|
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,
|
|
|
|
|
};
|