- 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 일정에서 유튜브 영상 추출 스크립트 추가
317 lines
8.3 KiB
JavaScript
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);
|
|
});
|