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