From 59e5a1d47b1d7e6e3cf55217eee89e107231f11f Mon Sep 17 00:00:00 2001 From: caadiq Date: Sat, 10 Jan 2026 17:06:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20X=20=EB=B4=87=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B4=87=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - X 봇 서비스 추가 (x-bot.js) - Nitter를 통한 @realfromis_9 트윗 수집 - 트윗을 일정으로 자동 저장 (카테고리 12) - 관리 채널 외 유튜브 링크 감지 시 별도 일정 추가 - 1분 간격 동기화 지원 - DB 스키마 변경 - bots.type enum 수정 (vlive, weverse 제거, x 추가) - bot_x_config 테이블 추가 - 봇 스케줄러 수정 (youtube-scheduler.js) - 봇 타입별 동기화 함수 분기 (syncBot) - X 봇 지원 추가 - 관리자 페이지 개선 (AdminScheduleBots.jsx) - 봇 타입별 아이콘 표시 (YouTube/X) - X 아이콘 SVG 컴포넌트 추가 - last_added_count 로직 수정 - 추가 항목 없으면 이전 값 유지 (0으로 초기화 방지) - 기존 X 일정에서 유튜브 영상 추출 스크립트 추가 --- backend/extract_youtube_from_x.js | 317 +++++++++ backend/routes/admin.js | 29 +- backend/services/x-bot.js | 622 ++++++++++++++++++ backend/services/youtube-bot.js | 58 +- backend/services/youtube-scheduler.js | 27 +- .../src/pages/pc/admin/AdminScheduleBots.jsx | 17 +- 6 files changed, 1044 insertions(+), 26 deletions(-) create mode 100644 backend/extract_youtube_from_x.js create mode 100644 backend/services/x-bot.js diff --git a/backend/extract_youtube_from_x.js b/backend/extract_youtube_from_x.js new file mode 100644 index 0000000..9a612b1 --- /dev/null +++ b/backend/extract_youtube_from_x.js @@ -0,0 +1,317 @@ +/** + * 기존 X 일정에서 유튜브 링크를 추출하여 일정으로 추가하는 스크립트 + * + * - category_id=12 (X) 인 일정 중 description에 유튜브 링크가 포함된 것을 찾음 + * - 유튜브 영상 정보를 YouTube API로 조회 + * - 기존 유튜브 봇이 관리하지 않는 채널의 영상만 일정으로 추가 + */ + +import pool from "./lib/db.js"; +import { addOrUpdateSchedule } from "./services/meilisearch.js"; + +// YouTube API 키 +const YOUTUBE_API_KEY = + process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; + +// 기존 유튜브 봇이 관리하는 채널 ID 목록 +const MANAGED_CHANNEL_IDS = [ + "UCXbRURMKT3H_w8dT-DWLIxA", // fromis_9 + "UCeUJ8B3krxw8zuDi19AlhaA", // 스프 : 스튜디오 프로미스나인 + "UCtfyAiqf095_0_ux8ruwGfA", // MUSINSA TV +]; + +// 유튜브 카테고리 ID +const YOUTUBE_CATEGORY_ID = 7; + +/** + * 텍스트에서 유튜브 videoId 추출 + */ +function extractYoutubeVideoIds(text) { + if (!text) return []; + + const videoIds = []; + + // youtu.be/{videoId} 형식 + const shortMatches = text.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/g); + if (shortMatches) { + shortMatches.forEach((m) => { + const id = m.replace("youtu.be/", ""); + if (id && id.length === 11) videoIds.push(id); + }); + } + + // youtube.com/watch?v={videoId} 형식 + const watchMatches = text.match( + /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/g + ); + if (watchMatches) { + watchMatches.forEach((m) => { + const id = m.match(/v=([a-zA-Z0-9_-]{11})/)?.[1]; + if (id) videoIds.push(id); + }); + } + + // youtube.com/shorts/{videoId} 형식 + const shortsMatches = text.match( + /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/g + ); + if (shortsMatches) { + shortsMatches.forEach((m) => { + const id = m.match(/shorts\/([a-zA-Z0-9_-]{11})/)?.[1]; + if (id) videoIds.push(id); + }); + } + + // 중복 제거 + return [...new Set(videoIds)]; +} + +/** + * YouTube API로 영상 정보 조회 + */ +async function fetchVideoInfo(videoId) { + try { + const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=${videoId}&key=${YOUTUBE_API_KEY}`; + const response = await fetch(url); + const data = await response.json(); + + if (!data.items || data.items.length === 0) { + return null; + } + + const video = data.items[0]; + const snippet = video.snippet; + const duration = video.contentDetails?.duration || ""; + + // duration 파싱 (PT1M30S → 초) + const durationMatch = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + let seconds = 0; + if (durationMatch) { + seconds = + parseInt(durationMatch[1] || 0) * 3600 + + parseInt(durationMatch[2] || 0) * 60 + + parseInt(durationMatch[3] || 0); + } + + const isShorts = seconds > 0 && seconds <= 60; + + return { + videoId, + title: snippet.title, + description: snippet.description || "", + channelId: snippet.channelId, + channelTitle: snippet.channelTitle, + publishedAt: new Date(snippet.publishedAt), + isShorts, + videoUrl: isShorts + ? `https://www.youtube.com/shorts/${videoId}` + : `https://www.youtube.com/watch?v=${videoId}`, + }; + } catch (error) { + console.error(`영상 정보 조회 오류 (${videoId}):`, error.message); + return null; + } +} + +/** + * UTC → KST 변환 + */ +function toKST(utcDate) { + const date = new Date(utcDate); + return new Date(date.getTime() + 9 * 60 * 60 * 1000); +} + +/** + * 날짜를 YYYY-MM-DD 형식으로 변환 + */ +function formatDate(date) { + return date.toISOString().split("T")[0]; +} + +/** + * 시간을 HH:MM:SS 형식으로 변환 + */ +function formatTime(date) { + return date.toTimeString().split(" ")[0]; +} + +/** + * description에서 멤버 ID 추출 + */ +async function extractMemberIds(text) { + if (!text) return []; + + const [members] = await pool.query("SELECT id, name FROM members"); + const memberIds = []; + + for (const member of members) { + if (text.includes(member.name)) { + memberIds.push(member.id); + } + } + + return memberIds; +} + +/** + * 유튜브 영상을 일정으로 추가 + */ +async function createScheduleFromVideo(video, memberIds = []) { + // source_url로 중복 체크 + const [existing] = await pool.query( + "SELECT id FROM schedules WHERE source_url = ?", + [video.videoUrl] + ); + + if (existing.length > 0) { + return null; // 이미 존재 + } + + const kstDate = toKST(video.publishedAt); + const date = formatDate(kstDate); + const time = formatTime(kstDate); + + // 일정 생성 + const [result] = await pool.query( + `INSERT INTO schedules (title, date, time, category_id, source_url, source_name) + VALUES (?, ?, ?, ?, ?, NULL)`, + [video.title, date, time, YOUTUBE_CATEGORY_ID, video.videoUrl] + ); + + 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] + ); + } + + // Meilisearch에 동기화 + try { + const [categoryInfo] = await pool.query( + "SELECT name, color FROM schedule_categories WHERE id = ?", + [YOUTUBE_CATEGORY_ID] + ); + 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, + time, + category_id: YOUTUBE_CATEGORY_ID, + category_name: categoryInfo[0]?.name || "", + category_color: categoryInfo[0]?.color || "", + source_name: null, + source_url: video.videoUrl, + members: memberInfo, + }); + } catch (searchError) { + console.error("Meilisearch 동기화 오류:", searchError.message); + } + + return scheduleId; +} + +/** + * 메인 함수 + */ +async function main() { + console.log("=".repeat(60)); + console.log("X 일정에서 유튜브 영상 추출 시작"); + console.log("=".repeat(60)); + console.log(`관리 중인 채널: ${MANAGED_CHANNEL_IDS.length}개`); + console.log(""); + + // X 카테고리(12) 중 유튜브 링크 포함된 일정 조회 + const [xSchedules] = await pool.query(` + SELECT id, title, description, source_url + FROM schedules + WHERE category_id = 12 + AND (description LIKE '%youtu.be%' + OR description LIKE '%youtube.com/watch%' + OR description LIKE '%youtube.com/shorts%') + `); + + console.log(`처리할 X 일정: ${xSchedules.length}개`); + console.log(""); + + let addedCount = 0; + let skippedManaged = 0; + let skippedDuplicate = 0; + let skippedError = 0; + + for (let i = 0; i < xSchedules.length; i++) { + const schedule = xSchedules[i]; + const videoIds = extractYoutubeVideoIds(schedule.description); + + if (videoIds.length === 0) continue; + + for (const videoId of videoIds) { + process.stdout.write( + `\r[${i + 1}/${xSchedules.length}] 영상 ${videoId} 처리 중...` + ); + + // 영상 정보 조회 + const video = await fetchVideoInfo(videoId); + + if (!video) { + skippedError++; + continue; + } + + // 관리 중인 채널인지 확인 + if (MANAGED_CHANNEL_IDS.includes(video.channelId)) { + skippedManaged++; + continue; + } + + // description에서 멤버 추출 + const memberIds = await extractMemberIds(schedule.description); + + // 일정 생성 + const scheduleId = await createScheduleFromVideo(video, memberIds); + + if (scheduleId) { + addedCount++; + console.log( + `\n ✓ 추가: ${video.title.substring(0, 40)}... (${ + video.channelTitle + })` + ); + } else { + skippedDuplicate++; + } + + // API 할당량 보호를 위한 딜레이 + await new Promise((r) => setTimeout(r, 100)); + } + } + + console.log("\n"); + console.log("=".repeat(60)); + console.log("추출 완료"); + console.log("=".repeat(60)); + console.log(`추가됨: ${addedCount}개`); + console.log(`관리 채널 스킵: ${skippedManaged}개`); + console.log(`중복 스킵: ${skippedDuplicate}개`); + console.log(`오류 스킵: ${skippedError}개`); + + await pool.end(); + process.exit(0); +} + +main().catch((err) => { + console.error("치명적 오류:", err); + process.exit(1); +}); diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 251c0a6..2908782 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -10,6 +10,7 @@ import { } from "@aws-sdk/client-s3"; import pool from "../lib/db.js"; import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.js"; +import { syncAllTweets } from "../services/x-bot.js"; import { startBot, stopBot } from "../services/youtube-scheduler.js"; import { addOrUpdateSchedule, @@ -1748,9 +1749,12 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => { router.get("/bots", authenticateToken, async (req, res) => { try { const [bots] = await pool.query(` - SELECT b.*, c.channel_id, c.channel_name + SELECT b.*, + yc.channel_id, yc.channel_name, + xc.username, xc.nitter_url FROM bots b - LEFT JOIN bot_youtube_config c ON b.id = c.bot_id + LEFT JOIN bot_youtube_config yc ON b.id = yc.bot_id + LEFT JOIN bot_x_config xc ON b.id = xc.bot_id ORDER BY b.id ASC `); res.json(bots); @@ -1792,7 +1796,26 @@ router.post("/bots/:id/stop", authenticateToken, async (req, res) => { router.post("/bots/:id/sync-all", authenticateToken, async (req, res) => { try { const { id } = req.params; - const result = await syncAllVideos(id); + + // 봇 타입 조회 + const [bots] = await pool.query("SELECT type FROM bots WHERE id = ?", [id]); + if (bots.length === 0) { + return res.status(404).json({ error: "봇을 찾을 수 없습니다." }); + } + + const botType = bots[0].type; + let result; + + if (botType === "youtube") { + result = await syncAllVideos(id); + } else if (botType === "x") { + result = await syncAllTweets(id); + } else { + return res + .status(400) + .json({ error: `지원하지 않는 봇 타입: ${botType}` }); + } + res.json({ message: `${result.addedCount}개 일정이 추가되었습니다.`, addedCount: result.addedCount, diff --git a/backend/services/x-bot.js b/backend/services/x-bot.js new file mode 100644 index 0000000..af6ed33 --- /dev/null +++ b/backend/services/x-bot.js @@ -0,0 +1,622 @@ +/** + * X 봇 서비스 + * + * - Nitter를 통해 @realfromis_9 트윗 수집 + * - 트윗을 schedules 테이블에 저장 + * - 유튜브 링크 감지 시 별도 일정 추가 + */ + +import pool from "../lib/db.js"; +import { addOrUpdateSchedule } from "./meilisearch.js"; + +// YouTube API 키 +const YOUTUBE_API_KEY = + process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; + +// X 카테고리 ID +const X_CATEGORY_ID = 12; + +// 유튜브 카테고리 ID +const YOUTUBE_CATEGORY_ID = 7; + +/** + * 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]; +} + +/** + * Nitter 날짜 파싱 ("Jan 7, 2026 · 12:00 PM UTC" → Date) + */ +function parseNitterDateTime(timeStr) { + if (!timeStr) return null; + try { + const cleaned = timeStr.replace(" · ", " ").replace(" UTC", ""); + const date = new Date(cleaned + " UTC"); + if (isNaN(date.getTime())) return null; + return date; + } catch (e) { + return null; + } +} + +/** + * 트윗 텍스트에서 첫 문단 추출 (title용) + */ +export function extractTitle(text) { + if (!text) return ""; + + // 빈 줄(\n\n)로 분리하여 첫 문단 추출 + const paragraphs = text.split(/\n\n+/); + const firstParagraph = paragraphs[0]?.trim() || ""; + + return firstParagraph; +} + +/** + * 텍스트에서 유튜브 videoId 추출 + */ +export function extractYoutubeVideoIds(text) { + if (!text) return []; + + const videoIds = []; + + // youtu.be/{videoId} 형식 + const shortMatches = text.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/g); + if (shortMatches) { + shortMatches.forEach((m) => { + const id = m.replace("youtu.be/", ""); + if (id && id.length === 11) videoIds.push(id); + }); + } + + // youtube.com/watch?v={videoId} 형식 + const watchMatches = text.match( + /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/g + ); + if (watchMatches) { + watchMatches.forEach((m) => { + const id = m.match(/v=([a-zA-Z0-9_-]{11})/)?.[1]; + if (id) videoIds.push(id); + }); + } + + // youtube.com/shorts/{videoId} 형식 + const shortsMatches = text.match( + /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/g + ); + if (shortsMatches) { + shortsMatches.forEach((m) => { + const id = m.match(/shorts\/([a-zA-Z0-9_-]{11})/)?.[1]; + if (id) videoIds.push(id); + }); + } + + return [...new Set(videoIds)]; +} + +/** + * 관리 중인 채널 ID 목록 조회 + */ +export async function getManagedChannelIds() { + const [configs] = await pool.query( + "SELECT channel_id FROM bot_youtube_config" + ); + return configs.map((c) => c.channel_id); +} + +/** + * YouTube API로 영상 정보 조회 + */ +async function fetchVideoInfo(videoId) { + try { + const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,contentDetails&id=${videoId}&key=${YOUTUBE_API_KEY}`; + const response = await fetch(url); + const data = await response.json(); + + if (!data.items || data.items.length === 0) { + return null; + } + + const video = data.items[0]; + const snippet = video.snippet; + const duration = video.contentDetails?.duration || ""; + + // duration 파싱 + const durationMatch = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); + let seconds = 0; + if (durationMatch) { + seconds = + parseInt(durationMatch[1] || 0) * 3600 + + parseInt(durationMatch[2] || 0) * 60 + + parseInt(durationMatch[3] || 0); + } + + const isShorts = seconds > 0 && seconds <= 60; + + return { + videoId, + title: snippet.title, + description: snippet.description || "", + channelId: snippet.channelId, + channelTitle: snippet.channelTitle, + publishedAt: new Date(snippet.publishedAt), + isShorts, + videoUrl: isShorts + ? `https://www.youtube.com/shorts/${videoId}` + : `https://www.youtube.com/watch?v=${videoId}`, + }; + } catch (error) { + console.error(`영상 정보 조회 오류 (${videoId}):`, error.message); + return null; + } +} + +/** + * Nitter에서 트윗 수집 (첫 페이지만) + */ +async function fetchTweetsFromNitter(nitterUrl, username) { + const url = `${nitterUrl}/${username}`; + + const response = await fetch(url); + const html = await response.text(); + + const tweets = []; + const tweetContainers = html.split('class="timeline-item '); + + for (let i = 1; i < tweetContainers.length; i++) { + const container = tweetContainers[i]; + const tweet = {}; + + // 고정 트윗 체크 + tweet.isPinned = + tweetContainers[i - 1].includes("pinned") || container.includes("Pinned"); + + // 리트윗 체크 + tweet.isRetweet = container.includes('class="retweet-header"'); + + // 트윗 ID 추출 + const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/); + tweet.id = linkMatch ? linkMatch[1] : null; + + // 시간 추출 + const timeMatch = container.match( + /]*>]*title="([^"]+)"/ + ); + tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null; + + // 텍스트 내용 추출 + const contentMatch = container.match( + /
]*>([\s\S]*?)<\/div>/ + ); + if (contentMatch) { + tweet.text = contentMatch[1] + .replace(//g, "\n") + .replace(/]*>([^<]*)<\/a>/g, "$1") + .replace(/<[^>]+>/g, "") + .trim(); + } + + // URL 생성 + tweet.url = tweet.id + ? `https://x.com/${username}/status/${tweet.id}` + : null; + + if (tweet.id && !tweet.isRetweet && !tweet.isPinned) { + tweets.push(tweet); + } + } + + return tweets; +} + +/** + * Nitter에서 전체 트윗 수집 (페이지네이션) + */ +async function fetchAllTweetsFromNitter(nitterUrl, username) { + const allTweets = []; + let cursor = null; + let pageNum = 1; + let consecutiveEmpty = 0; + const DELAY_MS = 1000; + + while (true) { + const url = cursor + ? `${nitterUrl}/${username}?cursor=${cursor}` + : `${nitterUrl}/${username}`; + + console.log(`[페이지 ${pageNum}] 스크래핑 중...`); + + try { + const response = await fetch(url); + const html = await response.text(); + + const tweets = []; + const tweetContainers = html.split('class="timeline-item '); + + for (let i = 1; i < tweetContainers.length; i++) { + const container = tweetContainers[i]; + const tweet = {}; + + tweet.isPinned = + tweetContainers[i - 1].includes("pinned") || + container.includes("Pinned"); + tweet.isRetweet = container.includes('class="retweet-header"'); + + const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/); + tweet.id = linkMatch ? linkMatch[1] : null; + + const timeMatch = container.match( + /]*>]*title="([^"]+)"/ + ); + tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null; + + const contentMatch = container.match( + /
]*>([\s\S]*?)<\/div>/ + ); + if (contentMatch) { + tweet.text = contentMatch[1] + .replace(//g, "\n") + .replace(/]*>([^<]*)<\/a>/g, "$1") + .replace(/<[^>]+>/g, "") + .trim(); + } + + tweet.url = tweet.id + ? `https://x.com/${username}/status/${tweet.id}` + : null; + + if (tweet.id && !tweet.isRetweet && !tweet.isPinned) { + tweets.push(tweet); + } + } + + if (tweets.length === 0) { + consecutiveEmpty++; + console.log(` -> 트윗 없음 (연속 ${consecutiveEmpty}회)`); + if (consecutiveEmpty >= 3) break; + } else { + consecutiveEmpty = 0; + allTweets.push(...tweets); + console.log(` -> ${tweets.length}개 추출 (누적: ${allTweets.length})`); + } + + // 다음 페이지 cursor 추출 + const cursorMatch = html.match( + /class="show-more"[^>]*>\s* setTimeout(r, DELAY_MS)); + } catch (error) { + console.error(` -> 오류: ${error.message}`); + consecutiveEmpty++; + if (consecutiveEmpty >= 5) break; + await new Promise((r) => setTimeout(r, DELAY_MS * 3)); + } + } + + return allTweets; +} + +/** + * 트윗을 일정으로 저장 + */ +async function createScheduleFromTweet(tweet) { + // source_url로 중복 체크 + const [existing] = await pool.query( + "SELECT id FROM schedules WHERE source_url = ?", + [tweet.url] + ); + + if (existing.length > 0) { + return null; // 이미 존재 + } + + const kstDate = toKST(tweet.time); + const date = formatDate(kstDate); + const time = formatTime(kstDate); + const title = extractTitle(tweet.text); + const description = tweet.text; + + // 일정 생성 + const [result] = await pool.query( + `INSERT INTO schedules (title, description, date, time, category_id, source_url, source_name) + VALUES (?, ?, ?, ?, ?, ?, NULL)`, + [title, description, date, time, X_CATEGORY_ID, tweet.url] + ); + + const scheduleId = result.insertId; + + // Meilisearch 동기화 + try { + const [categoryInfo] = await pool.query( + "SELECT name, color FROM schedule_categories WHERE id = ?", + [X_CATEGORY_ID] + ); + await addOrUpdateSchedule({ + id: scheduleId, + title, + description, + date, + time, + category_id: X_CATEGORY_ID, + category_name: categoryInfo[0]?.name || "", + category_color: categoryInfo[0]?.color || "", + source_name: null, + source_url: tweet.url, + members: [], + }); + } catch (searchError) { + console.error("Meilisearch 동기화 오류:", searchError.message); + } + + return scheduleId; +} + +/** + * 유튜브 영상을 일정으로 저장 + */ +async function createScheduleFromYoutube(video) { + // source_url로 중복 체크 + const [existing] = await pool.query( + "SELECT id FROM schedules WHERE source_url = ?", + [video.videoUrl] + ); + + if (existing.length > 0) { + return null; // 이미 존재 + } + + const kstDate = toKST(video.publishedAt); + const date = formatDate(kstDate); + const time = formatTime(kstDate); + + // 일정 생성 + const [result] = await pool.query( + `INSERT INTO schedules (title, date, time, category_id, source_url, source_name) + VALUES (?, ?, ?, ?, ?, NULL)`, + [video.title, date, time, YOUTUBE_CATEGORY_ID, video.videoUrl] + ); + + const scheduleId = result.insertId; + + // Meilisearch 동기화 + try { + const [categoryInfo] = await pool.query( + "SELECT name, color FROM schedule_categories WHERE id = ?", + [YOUTUBE_CATEGORY_ID] + ); + await addOrUpdateSchedule({ + id: scheduleId, + title: video.title, + description: "", + date, + time, + category_id: YOUTUBE_CATEGORY_ID, + category_name: categoryInfo[0]?.name || "", + category_color: categoryInfo[0]?.color || "", + source_name: null, + source_url: video.videoUrl, + members: [], + }); + } catch (searchError) { + console.error("Meilisearch 동기화 오류:", searchError.message); + } + + return scheduleId; +} + +/** + * 새 트윗 동기화 (첫 페이지만 - 1분 간격 실행용) + */ +export async function syncNewTweets(botId) { + try { + // 봇 정보 조회 + const [bots] = await pool.query( + `SELECT b.*, c.username, c.nitter_url + FROM bots b + LEFT JOIN bot_x_config c ON b.id = c.bot_id + WHERE b.id = ?`, + [botId] + ); + + if (bots.length === 0) { + throw new Error("봇을 찾을 수 없습니다."); + } + + const bot = bots[0]; + + if (!bot.username) { + throw new Error("Username이 설정되지 않았습니다."); + } + + const nitterUrl = bot.nitter_url || "http://nitter:8080"; + + // 관리 중인 채널 목록 조회 + const managedChannelIds = await getManagedChannelIds(); + + // Nitter에서 트윗 수집 (첫 페이지만) + const tweets = await fetchTweetsFromNitter(nitterUrl, bot.username); + + let addedCount = 0; + let ytAddedCount = 0; + + for (const tweet of tweets) { + // 트윗 저장 + const scheduleId = await createScheduleFromTweet(tweet); + if (scheduleId) addedCount++; + + // 유튜브 링크 처리 + const videoIds = extractYoutubeVideoIds(tweet.text); + for (const videoId of videoIds) { + const video = await fetchVideoInfo(videoId); + if (!video) continue; + + // 관리 중인 채널이면 스킵 + if (managedChannelIds.includes(video.channelId)) continue; + + // 유튜브 일정 저장 + const ytScheduleId = await createScheduleFromYoutube(video); + if (ytScheduleId) ytAddedCount++; + } + } + + // 봇 상태 업데이트 + // 추가된 항목이 있을 때만 last_added_count 업데이트 (0이면 이전 값 유지) + const totalAdded = addedCount + ytAddedCount; + if (totalAdded > 0) { + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + schedules_added = schedules_added + ?, + last_added_count = ?, + error_message = NULL + WHERE id = ?`, + [totalAdded, totalAdded, botId] + ); + } else { + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + error_message = NULL + WHERE id = ?`, + [botId] + ); + } + + return { addedCount, ytAddedCount, total: tweets.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; + } +} + +/** + * 전체 트윗 동기화 (전체 페이지 - 초기화용) + */ +export async function syncAllTweets(botId) { + try { + // 봇 정보 조회 + const [bots] = await pool.query( + `SELECT b.*, c.username, c.nitter_url + FROM bots b + LEFT JOIN bot_x_config c ON b.id = c.bot_id + WHERE b.id = ?`, + [botId] + ); + + if (bots.length === 0) { + throw new Error("봇을 찾을 수 없습니다."); + } + + const bot = bots[0]; + + if (!bot.username) { + throw new Error("Username이 설정되지 않았습니다."); + } + + const nitterUrl = bot.nitter_url || "http://nitter:8080"; + + // 관리 중인 채널 목록 조회 + const managedChannelIds = await getManagedChannelIds(); + + // Nitter에서 전체 트윗 수집 + const tweets = await fetchAllTweetsFromNitter(nitterUrl, bot.username); + + let addedCount = 0; + let ytAddedCount = 0; + + for (const tweet of tweets) { + // 트윗 저장 + const scheduleId = await createScheduleFromTweet(tweet); + if (scheduleId) addedCount++; + + // 유튜브 링크 처리 + const videoIds = extractYoutubeVideoIds(tweet.text); + for (const videoId of videoIds) { + const video = await fetchVideoInfo(videoId); + if (!video) continue; + + // 관리 중인 채널이면 스킵 + if (managedChannelIds.includes(video.channelId)) continue; + + // 유튜브 일정 저장 + const ytScheduleId = await createScheduleFromYoutube(video); + if (ytScheduleId) ytAddedCount++; + } + } + + // 봇 상태 업데이트 + // 추가된 항목이 있을 때만 last_added_count 업데이트 (0이면 이전 값 유지) + const totalAdded = addedCount + ytAddedCount; + if (totalAdded > 0) { + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + schedules_added = schedules_added + ?, + last_added_count = ?, + error_message = NULL + WHERE id = ?`, + [totalAdded, totalAdded, botId] + ); + } else { + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + error_message = NULL + WHERE id = ?`, + [botId] + ); + } + + return { addedCount, ytAddedCount, total: tweets.length }; + } catch (error) { + await pool.query( + `UPDATE bots SET + status = 'error', + error_message = ? + WHERE id = ?`, + [error.message, botId] + ); + throw error; + } +} + +export default { + syncNewTweets, + syncAllTweets, + extractTitle, + extractYoutubeVideoIds, + toKST, +}; diff --git a/backend/services/youtube-bot.js b/backend/services/youtube-bot.js index c76bb58..2d40fd9 100644 --- a/backend/services/youtube-bot.js +++ b/backend/services/youtube-bot.js @@ -511,15 +511,26 @@ export async function syncNewVideos(botId) { } // 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수) - await pool.query( - `UPDATE bots SET - last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), - schedules_added = schedules_added + ?, - last_added_count = ?, - error_message = NULL - WHERE id = ?`, - [addedCount, addedCount, botId] - ); + // addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지) + if (addedCount > 0) { + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + schedules_added = schedules_added + ?, + last_added_count = ?, + error_message = NULL + WHERE id = ?`, + [addedCount, addedCount, botId] + ); + } else { + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + error_message = NULL + WHERE id = ?`, + [botId] + ); + } return { addedCount, total: videos.length }; } catch (error) { @@ -615,15 +626,26 @@ export async function syncAllVideos(botId) { } // 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수) - await pool.query( - `UPDATE bots SET - last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), - schedules_added = schedules_added + ?, - last_added_count = ?, - error_message = NULL - WHERE id = ?`, - [addedCount, addedCount, botId] - ); + // addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지) + if (addedCount > 0) { + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + schedules_added = schedules_added + ?, + last_added_count = ?, + error_message = NULL + WHERE id = ?`, + [addedCount, addedCount, botId] + ); + } else { + await pool.query( + `UPDATE bots SET + last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), + error_message = NULL + WHERE id = ?`, + [botId] + ); + } return { addedCount, total: videos.length }; } catch (error) { diff --git a/backend/services/youtube-scheduler.js b/backend/services/youtube-scheduler.js index 1978a52..d92ed50 100644 --- a/backend/services/youtube-scheduler.js +++ b/backend/services/youtube-scheduler.js @@ -1,10 +1,31 @@ import cron from "node-cron"; import pool from "../lib/db.js"; import { syncNewVideos } from "./youtube-bot.js"; +import { syncNewTweets } from "./x-bot.js"; // 봇별 스케줄러 인스턴스 저장 const schedulers = new Map(); +/** + * 봇 타입에 따라 적절한 동기화 함수 호출 + */ +async function syncBot(botId) { + const [bots] = await pool.query("SELECT type FROM bots WHERE id = ?", [ + botId, + ]); + if (bots.length === 0) throw new Error("봇을 찾을 수 없습니다."); + + const botType = bots[0].type; + + if (botType === "youtube") { + return await syncNewVideos(botId); + } else if (botType === "x") { + return await syncNewTweets(botId); + } else { + throw new Error(`지원하지 않는 봇 타입: ${botType}`); + } +} + /** * 봇이 메모리에서 실행 중인지 확인 */ @@ -27,7 +48,7 @@ export function registerBot(botId, intervalMinutes = 2, cronExpression = null) { const task = cron.schedule(expression, async () => { console.log(`[Bot ${id}] 동기화 시작...`); try { - const result = await syncNewVideos(id); + const result = await syncBot(id); console.log(`[Bot ${id}] 동기화 완료: ${result.addedCount}개 추가`); } catch (error) { console.error(`[Bot ${id}] 동기화 오류:`, error.message); @@ -79,7 +100,7 @@ async function syncBotStatuses() { const task = cron.schedule(expression, async () => { console.log(`[Bot ${botId}] 동기화 시작...`); try { - const result = await syncNewVideos(botId); + const result = await syncBot(botId); console.log( `[Bot ${botId}] 동기화 완료: ${result.addedCount}개 추가` ); @@ -152,7 +173,7 @@ export async function startBot(botId) { // 즉시 1회 실행 try { - await syncNewVideos(botId); + await syncBot(botId); } catch (error) { console.error(`[Bot ${botId}] 초기 동기화 오류:`, error.message); } diff --git a/frontend/src/pages/pc/admin/AdminScheduleBots.jsx b/frontend/src/pages/pc/admin/AdminScheduleBots.jsx index 1a4c6f3..5b092ca 100644 --- a/frontend/src/pages/pc/admin/AdminScheduleBots.jsx +++ b/frontend/src/pages/pc/admin/AdminScheduleBots.jsx @@ -11,6 +11,13 @@ import AdminHeader from '../../../components/admin/AdminHeader'; import useToast from '../../../hooks/useToast'; import * as botsApi from '../../../api/admin/bots'; +// X 아이콘 컴포넌트 +const XIcon = ({ size = 20, fill = "currentColor" }) => ( + + + +); + function AdminScheduleBots() { const navigate = useNavigate(); const [user, setUser] = useState(null); @@ -303,8 +310,14 @@ function AdminScheduleBots() { {/* 상단 헤더 */}
-
- +
+ {bot.type === 'x' ? ( + + ) : ( + + )}

{bot.name}