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:
parent
0376c0ac73
commit
59e5a1d47b
6 changed files with 1044 additions and 26 deletions
317
backend/extract_youtube_from_x.js
Normal file
317
backend/extract_youtube_from_x.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
622
backend/services/x-bot.js
Normal file
622
backend/services/x-bot.js
Normal 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,
|
||||
};
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }) => (
|
||||
<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() {
|
||||
const navigate = useNavigate();
|
||||
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 gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center">
|
||||
<Youtube size={20} className="text-red-500" />
|
||||
<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" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900">{bot.name}</h3>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue