/**
* 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"[^>]*title="([^"]+)"/
);
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에서 원본 Twitter 이미지 URL 추출
let avatarUrl = profile.avatarUrl;
if (avatarUrl) {
// /pic/https%3A%2F%2Fpbs.twimg.com%2F... 형식에서 원본 URL 추출
const encodedMatch = avatarUrl.match(/\/pic\/(.+)/);
if (encodedMatch) {
avatarUrl = decodeURIComponent(encodedMatch[1]);
} else if (avatarUrl.startsWith("/")) {
// 상대 경로인 경우 Nitter URL 추가
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(
/