feat: X 봇 구현 및 봇 관리 기능 개선

- 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 일정에서 유튜브 영상 추출 스크립트 추가
This commit is contained in:
caadiq 2026-01-10 17:06:23 +09:00
parent 0376c0ac73
commit 59e5a1d47b
6 changed files with 1044 additions and 26 deletions

View file

@ -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);
});

View file

@ -10,6 +10,7 @@ import {
} from "@aws-sdk/client-s3"; } from "@aws-sdk/client-s3";
import pool from "../lib/db.js"; import pool from "../lib/db.js";
import { syncNewVideos, syncAllVideos } from "../services/youtube-bot.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 { startBot, stopBot } from "../services/youtube-scheduler.js";
import { import {
addOrUpdateSchedule, addOrUpdateSchedule,
@ -1748,9 +1749,12 @@ router.delete("/schedules/:id", authenticateToken, async (req, res) => {
router.get("/bots", authenticateToken, async (req, res) => { router.get("/bots", authenticateToken, async (req, res) => {
try { try {
const [bots] = await pool.query(` 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 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 ORDER BY b.id ASC
`); `);
res.json(bots); 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) => { router.post("/bots/:id/sync-all", authenticateToken, async (req, res) => {
try { try {
const { id } = req.params; 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({ res.json({
message: `${result.addedCount}개 일정이 추가되었습니다.`, message: `${result.addedCount}개 일정이 추가되었습니다.`,
addedCount: result.addedCount, addedCount: result.addedCount,

622
backend/services/x-bot.js Normal file
View file

@ -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(
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
);
tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
// 텍스트 내용 추출
const contentMatch = container.match(
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
);
if (contentMatch) {
tweet.text = contentMatch[1]
.replace(/<br\s*\/?>/g, "\n")
.replace(/<a[^>]*>([^<]*)<\/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(
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
);
tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
const contentMatch = container.match(
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
);
if (contentMatch) {
tweet.text = contentMatch[1]
.replace(/<br\s*\/?>/g, "\n")
.replace(/<a[^>]*>([^<]*)<\/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*<a href="\?cursor=([^"]+)"/
);
if (!cursorMatch) {
console.log("\n다음 페이지 없음. 스크래핑 완료.");
break;
}
cursor = cursorMatch[1];
pageNum++;
await new Promise((r) => 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,
};

View file

@ -511,6 +511,8 @@ export async function syncNewVideos(botId) {
} }
// 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수) // 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수)
// addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지)
if (addedCount > 0) {
await pool.query( await pool.query(
`UPDATE bots SET `UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
@ -520,6 +522,15 @@ export async function syncNewVideos(botId) {
WHERE id = ?`, WHERE id = ?`,
[addedCount, addedCount, botId] [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 }; return { addedCount, total: videos.length };
} catch (error) { } catch (error) {
@ -615,6 +626,8 @@ export async function syncAllVideos(botId) {
} }
// 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수) // 봇 상태 업데이트 (전체 추가 수 + 마지막 추가 수)
// addedCount > 0일 때만 last_added_count 업데이트 (0이면 이전 값 유지)
if (addedCount > 0) {
await pool.query( await pool.query(
`UPDATE bots SET `UPDATE bots SET
last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR), last_check_at = DATE_ADD(NOW(), INTERVAL 9 HOUR),
@ -624,6 +637,15 @@ export async function syncAllVideos(botId) {
WHERE id = ?`, WHERE id = ?`,
[addedCount, addedCount, botId] [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 }; return { addedCount, total: videos.length };
} catch (error) { } catch (error) {

View file

@ -1,10 +1,31 @@
import cron from "node-cron"; import cron from "node-cron";
import pool from "../lib/db.js"; import pool from "../lib/db.js";
import { syncNewVideos } from "./youtube-bot.js"; import { syncNewVideos } from "./youtube-bot.js";
import { syncNewTweets } from "./x-bot.js";
// 봇별 스케줄러 인스턴스 저장 // 봇별 스케줄러 인스턴스 저장
const schedulers = new Map(); 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 () => { const task = cron.schedule(expression, async () => {
console.log(`[Bot ${id}] 동기화 시작...`); console.log(`[Bot ${id}] 동기화 시작...`);
try { try {
const result = await syncNewVideos(id); const result = await syncBot(id);
console.log(`[Bot ${id}] 동기화 완료: ${result.addedCount}개 추가`); console.log(`[Bot ${id}] 동기화 완료: ${result.addedCount}개 추가`);
} catch (error) { } catch (error) {
console.error(`[Bot ${id}] 동기화 오류:`, error.message); console.error(`[Bot ${id}] 동기화 오류:`, error.message);
@ -79,7 +100,7 @@ async function syncBotStatuses() {
const task = cron.schedule(expression, async () => { const task = cron.schedule(expression, async () => {
console.log(`[Bot ${botId}] 동기화 시작...`); console.log(`[Bot ${botId}] 동기화 시작...`);
try { try {
const result = await syncNewVideos(botId); const result = await syncBot(botId);
console.log( console.log(
`[Bot ${botId}] 동기화 완료: ${result.addedCount}개 추가` `[Bot ${botId}] 동기화 완료: ${result.addedCount}개 추가`
); );
@ -152,7 +173,7 @@ export async function startBot(botId) {
// 즉시 1회 실행 // 즉시 1회 실행
try { try {
await syncNewVideos(botId); await syncBot(botId);
} catch (error) { } catch (error) {
console.error(`[Bot ${botId}] 초기 동기화 오류:`, error.message); console.error(`[Bot ${botId}] 초기 동기화 오류:`, error.message);
} }

View file

@ -11,6 +11,13 @@ import AdminHeader from '../../../components/admin/AdminHeader';
import useToast from '../../../hooks/useToast'; import useToast from '../../../hooks/useToast';
import * as botsApi from '../../../api/admin/bots'; import * as botsApi from '../../../api/admin/bots';
// X
const XIcon = ({ size = 20, fill = "currentColor" }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
function AdminScheduleBots() { function AdminScheduleBots() {
const navigate = useNavigate(); const navigate = useNavigate();
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
@ -303,8 +310,14 @@ function AdminScheduleBots() {
{/* 상단 헤더 */} {/* 상단 헤더 */}
<div className="flex items-center justify-between p-4 border-b border-gray-100"> <div className="flex items-center justify-between p-4 border-b border-gray-100">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center"> <div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
bot.type === 'x' ? 'bg-black' : 'bg-red-50'
}`}>
{bot.type === 'x' ? (
<XIcon size={20} fill="white" />
) : (
<Youtube size={20} className="text-red-500" /> <Youtube size={20} className="text-red-500" />
)}
</div> </div>
<div> <div>
<h3 className="font-bold text-gray-900">{bot.name}</h3> <h3 className="font-bold text-gray-900">{bot.name}</h3>