fromis_9/backend/services/x-bot.js
caadiq 2ebee0682c fix(backend/x-bot): 고정 트윗 다음 트윗이 무시되는 버그 수정
- 고정 트윗 체크 로직이 이전 컨테이너를 참조하여 다음 트윗도 고정으로 판단하는 버그 수정
- 현재 컨테이너 내에 'class="pinned"'가 있는지만 확인하도록 변경

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:02:53 +09:00

595 lines
16 KiB
JavaScript

/**
* X 봇 서비스
*
* - Nitter를 통해 @realfromis_9 트윗 수집
* - 트윗을 schedules 테이블에 저장
* - 유튜브 링크 감지 시 별도 일정 추가
*/
import pool from "../lib/db.js";
import { addOrUpdateSchedule } from "./meilisearch.js";
import {
toKST,
formatDate,
formatTime,
parseNitterDateTime,
} from "../lib/date.js";
// 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에서 트윗 수집 (첫 페이지만)
*/
async function fetchTweetsFromNitter(nitterUrl, username) {
const url = `${nitterUrl}/${username}`;
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"');
// 트윗 ID 추출
const linkMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/);
tweet.id = linkMatch ? linkMatch[1] : null;
// 시간 추출
const timeMatch = container.match(
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
);
tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
// 텍스트 내용 추출
const contentMatch = container.match(
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
);
if (contentMatch) {
tweet.text = contentMatch[1]
.replace(/<br\s*\/?>/g, "\n")
.replace(/<a[^>]*>([^<]*)<\/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(
/<span class="tweet-date"[^>]*><a[^>]*title="([^"]+)"/
);
tweet.time = timeMatch ? parseNitterDateTime(timeMatch[1]) : null;
const contentMatch = container.match(
/<div class="tweet-content[^"]*"[^>]*>([\s\S]*?)<\/div>/
);
if (contentMatch) {
tweet.text = contentMatch[1]
.replace(/<br\s*\/?>/g, "\n")
.replace(/<a[^>]*>([^<]*)<\/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*<a href="\?cursor=([^"]+)"/
);
if (!cursorMatch) {
console.log("\n다음 페이지 없음. 스크래핑 완료.");
break;
}
cursor = cursorMatch[1];
pageNum++;
await new Promise((r) => 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,
};