/** * X 봇 서비스 * * - Nitter를 통해 @realfromis_9 트윗 수집 * - 트윗을 schedules 테이블에 저장 * - 유튜브 링크 감지 시 별도 일정 추가 */ import pool from "../lib/db.js"; import redis from "../lib/redis.js"; import { addOrUpdateSchedule } from "./meilisearch.js"; import { toKST, formatDate, formatTime, parseNitterDateTime, } from "../lib/date.js"; // X 프로필 캐시 키 prefix const X_PROFILE_CACHE_PREFIX = "x_profile:"; // YouTube API 키 const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM"; // X 카테고리 ID const X_CATEGORY_ID = 3; // 유튜브 카테고리 ID const YOUTUBE_CATEGORY_ID = 2; /** * 트윗 텍스트에서 첫 문단 추출 (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 HTML에서 프로필 정보 추출 */ function extractProfileFromHtml(html) { const profile = { displayName: null, avatarUrl: null, }; // Display name 추출: 이름 const nameMatch = html.match( /]*class="profile-card-fullname"[^>]*>([^<]+)<\/a>/ ); if (nameMatch) { profile.displayName = nameMatch[1].trim(); } // Avatar URL 추출: const avatarMatch = html.match( /]*class="profile-card-avatar"[^>]*>[\s\S]*?]*src="([^"]+)"/ ); if (avatarMatch) { profile.avatarUrl = avatarMatch[1]; } return profile; } /** * X 프로필 정보 캐시에 저장 */ async function cacheXProfile(username, profile, nitterUrl) { try { // Nitter URL이 상대 경로인 경우 절대 경로로 변환 let avatarUrl = profile.avatarUrl; if (avatarUrl && avatarUrl.startsWith("/")) { avatarUrl = `${nitterUrl}${avatarUrl}`; } const data = { username, displayName: profile.displayName, avatarUrl, updatedAt: new Date().toISOString(), }; // 7일간 캐시 (604800초) await redis.setex( `${X_PROFILE_CACHE_PREFIX}${username}`, 604800, JSON.stringify(data) ); console.log(`[X 프로필] ${username} 캐시 저장 완료`); } catch (error) { console.error(`[X 프로필] 캐시 저장 실패:`, error.message); } } /** * X 프로필 정보 조회 */ export async function getXProfile(username) { try { const cached = await redis.get(`${X_PROFILE_CACHE_PREFIX}${username}`); if (cached) { return JSON.parse(cached); } return null; } catch (error) { console.error(`[X 프로필] 캐시 조회 실패:`, 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 profile = extractProfileFromHtml(html); if (profile.displayName || profile.avatarUrl) { await cacheXProfile(username, profile, nitterUrl); } const tweets = []; const tweetContainers = html.split('class="timeline-item '); for (let i = 1; i < tweetContainers.length; i++) { const container = tweetContainers[i]; const tweet = {}; // 고정 트윗 체크 - 현재 컨테이너 내에 pinned 클래스가 있는지 확인 tweet.isPinned = container.includes('class="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( /]*>]*title="([^"]+)"/ ); tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null; // 텍스트 내용 추출 const contentMatch = container.match( /
]*>([\s\S]*?)<\/div>/ ); if (contentMatch) { tweet.text = contentMatch[1] .replace(//g, "\n") .replace(/]*>([^<]*)<\/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 = {}; // 고정 트윗 체크 - 현재 컨테이너 내에 pinned 클래스가 있는지 확인 tweet.isPinned = container.includes('class="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( /]*>]*title="([^"]+)"/ ); tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null; const contentMatch = container.match( /
]*>([\s\S]*?)<\/div>/ ); if (contentMatch) { tweet.text = contentMatch[1] .replace(//g, "\n") .replace(/]*>([^<]*)<\/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* 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); // 일정 생성 (source_name에 채널명 저장) const [result] = await pool.query( `INSERT INTO schedules (title, date, time, category_id, source_url, source_name) VALUES (?, ?, ?, ?, ?, ?)`, [ video.title, date, time, YOUTUBE_CATEGORY_ID, video.videoUrl, video.channelTitle || null, ] ); 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: video.channelTitle || 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 = NOW(), 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 = NOW(), error_message = NULL WHERE id = ?`, [botId] ); } return { addedCount, ytAddedCount, total: tweets.length }; } catch (error) { // 오류 상태 업데이트 await pool.query( `UPDATE bots SET last_check_at = NOW(), 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 = NOW(), 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 = NOW(), 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, };