fromis_9/backend/extract_youtube_from_x.js
caadiq 59e5a1d47b 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 일정에서 유튜브 영상 추출 스크립트 추가
2026-01-10 17:06:23 +09:00

317 lines
8.3 KiB
JavaScript

/**
* 기존 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);
});