2026-01-10 17:06:23 +09:00
|
|
|
/**
|
|
|
|
|
* X 봇 서비스
|
|
|
|
|
*
|
|
|
|
|
* - Nitter를 통해 @realfromis_9 트윗 수집
|
|
|
|
|
* - 트윗을 schedules 테이블에 저장
|
|
|
|
|
* - 유튜브 링크 감지 시 별도 일정 추가
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import pool from "../lib/db.js";
|
|
|
|
|
import { addOrUpdateSchedule } from "./meilisearch.js";
|
|
|
|
|
|
|
|
|
|
// YouTube API 키
|
|
|
|
|
const YOUTUBE_API_KEY =
|
|
|
|
|
process.env.YOUTUBE_API_KEY || "AIzaSyBmn79egY0M_z5iUkqq9Ny0zVFP6PoYCzM";
|
|
|
|
|
|
|
|
|
|
// X 카테고리 ID
|
|
|
|
|
const X_CATEGORY_ID = 12;
|
|
|
|
|
|
|
|
|
|
// 유튜브 카테고리 ID
|
|
|
|
|
const YOUTUBE_CATEGORY_ID = 7;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* UTC → KST 변환
|
|
|
|
|
*/
|
|
|
|
|
export function toKST(utcDate) {
|
|
|
|
|
const date = new Date(utcDate);
|
|
|
|
|
return new Date(date.getTime() + 9 * 60 * 60 * 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 날짜를 YYYY-MM-DD 형식으로 변환
|
|
|
|
|
*/
|
|
|
|
|
export function formatDate(date) {
|
|
|
|
|
return date.toISOString().split("T")[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 시간을 HH:MM:SS 형식으로 변환
|
|
|
|
|
*/
|
|
|
|
|
export function formatTime(date) {
|
|
|
|
|
return date.toTimeString().split(" ")[0];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Nitter 날짜 파싱 ("Jan 7, 2026 · 12:00 PM UTC" → Date)
|
|
|
|
|
*/
|
|
|
|
|
function parseNitterDateTime(timeStr) {
|
|
|
|
|
if (!timeStr) return null;
|
|
|
|
|
try {
|
|
|
|
|
const cleaned = timeStr.replace(" · ", " ").replace(" UTC", "");
|
|
|
|
|
const date = new Date(cleaned + " UTC");
|
|
|
|
|
if (isNaN(date.getTime())) return null;
|
|
|
|
|
return date;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 트윗 텍스트에서 첫 문단 추출 (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 = {};
|
|
|
|
|
|
|
|
|
|
// 고정 트윗 체크
|
|
|
|
|
tweet.isPinned =
|
|
|
|
|
tweetContainers[i - 1].includes("pinned") || container.includes("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 = {};
|
|
|
|
|
|
|
|
|
|
tweet.isPinned =
|
|
|
|
|
tweetContainers[i - 1].includes("pinned") ||
|
|
|
|
|
container.includes("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);
|
|
|
|
|
|
2026-01-10 18:26:43 +09:00
|
|
|
// 일정 생성 (source_name에 채널명 저장)
|
2026-01-10 17:06:23 +09:00
|
|
|
const [result] = await pool.query(
|
|
|
|
|
`INSERT INTO schedules (title, date, time, category_id, source_url, source_name)
|
2026-01-10 18:26:43 +09:00
|
|
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
|
|
|
[
|
|
|
|
|
video.title,
|
|
|
|
|
date,
|
|
|
|
|
time,
|
|
|
|
|
YOUTUBE_CATEGORY_ID,
|
|
|
|
|
video.videoUrl,
|
|
|
|
|
video.channelTitle || null,
|
|
|
|
|
]
|
2026-01-10 17:06:23 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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 || "",
|
2026-01-10 18:26:43 +09:00
|
|
|
source_name: video.channelTitle || null,
|
2026-01-10 17:06:23 +09:00
|
|
|
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
|
2026-01-10 18:59:39 +09:00
|
|
|
last_check_at = NOW(),
|
2026-01-10 17:06:23 +09:00
|
|
|
schedules_added = schedules_added + ?,
|
|
|
|
|
last_added_count = ?,
|
|
|
|
|
error_message = NULL
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
[totalAdded, totalAdded, botId]
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE bots SET
|
2026-01-10 18:59:39 +09:00
|
|
|
last_check_at = NOW(),
|
2026-01-10 17:06:23 +09:00
|
|
|
error_message = NULL
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
[botId]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { addedCount, ytAddedCount, total: tweets.length };
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// 오류 상태 업데이트
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE bots SET
|
2026-01-10 18:59:39 +09:00
|
|
|
last_check_at = NOW(),
|
2026-01-10 17:06:23 +09:00
|
|
|
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
|
2026-01-10 18:59:39 +09:00
|
|
|
last_check_at = NOW(),
|
2026-01-10 17:06:23 +09:00
|
|
|
schedules_added = schedules_added + ?,
|
|
|
|
|
last_added_count = ?,
|
|
|
|
|
error_message = NULL
|
|
|
|
|
WHERE id = ?`,
|
|
|
|
|
[totalAdded, totalAdded, botId]
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
await pool.query(
|
|
|
|
|
`UPDATE bots SET
|
2026-01-10 18:59:39 +09:00
|
|
|
last_check_at = NOW(),
|
2026-01-10 17:06:23 +09:00
|
|
|
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,
|
|
|
|
|
toKST,
|
|
|
|
|
};
|