- Schedule.jsx에서 useState 대신 useScheduleStore 사용 - 상세 페이지 이동 후에도 선택한 날짜/카테고리/검색어 유지 - X 상세 페이지 UI 개선 (X 아이콘 제거, 날짜 형식 변경) - X 프로필 URL 디코딩 로직 수정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
687 lines
18 KiB
JavaScript
687 lines
18 KiB
JavaScript
/**
|
|
* 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 추출: <a class="profile-card-fullname" ... title="이름">이름</a>
|
|
const nameMatch = html.match(
|
|
/class="profile-card-fullname"[^>]*title="([^"]+)"/
|
|
);
|
|
if (nameMatch) {
|
|
profile.displayName = nameMatch[1].trim();
|
|
}
|
|
|
|
// Avatar URL 추출: <a class="profile-card-avatar" ...><img src="/pic/...">
|
|
const avatarMatch = html.match(
|
|
/class="profile-card-avatar"[^>]*>[\s\S]*?<img[^>]*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(
|
|
/<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,
|
|
};
|