diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js index d3a77ef..dc14e25 100644 --- a/backend/src/plugins/scheduler.js +++ b/backend/src/plugins/scheduler.js @@ -69,6 +69,7 @@ async function schedulerPlugin(fastify, opts) { ? JSON.parse(row.text_filters) : row.text_filters) : [], + includeRetweets: row.include_retweets === 1, })); } diff --git a/backend/src/routes/admin/x-bots.js b/backend/src/routes/admin/x-bots.js index 29cc9c5..d2f873a 100644 --- a/backend/src/routes/admin/x-bots.js +++ b/backend/src/routes/admin/x-bots.js @@ -13,6 +13,7 @@ const xBotResponse = { display_name: { type: 'string' }, avatar_url: { type: 'string' }, text_filters: { type: 'array', items: { type: 'string' } }, + include_retweets: { type: 'boolean' }, cron_interval: { type: 'integer' }, enabled: { type: 'boolean' }, }, @@ -40,6 +41,7 @@ function formatBotResponse(row) { ? JSON.parse(row.text_filters) : row.text_filters) : [], + include_retweets: row.include_retweets === 1, cron_interval: row.cron_interval, enabled: row.enabled === 1, }; @@ -157,6 +159,7 @@ export default async function xBotsRoutes(fastify) { display_name: { type: ['string', 'null'] }, avatar_url: { type: ['string', 'null'] }, text_filters: { type: ['array', 'null'], items: { type: 'string' } }, + include_retweets: { type: 'boolean', default: false }, cron_interval: { type: 'integer', default: 1 }, }, required: ['username'], @@ -173,6 +176,7 @@ export default async function xBotsRoutes(fastify) { display_name, avatar_url, text_filters, + include_retweets = false, cron_interval = 1, } = request.body; @@ -186,13 +190,14 @@ export default async function xBotsRoutes(fastify) { } const [result] = await db.query( - `INSERT INTO bot_x (username, display_name, avatar_url, text_filters, cron_interval, enabled) - VALUES (?, ?, ?, ?, ?, 1)`, + `INSERT INTO bot_x (username, display_name, avatar_url, text_filters, include_retweets, cron_interval, enabled) + VALUES (?, ?, ?, ?, ?, ?, 1)`, [ username, display_name || null, avatar_url || null, text_filters && text_filters.length > 0 ? JSON.stringify(text_filters) : null, + include_retweets ? 1 : 0, cron_interval, ] ); @@ -209,6 +214,7 @@ export default async function xBotsRoutes(fastify) { username, nitterUrl: process.env.NITTER_URL || 'http://nitter:8080', textFilters: text_filters || [], + includeRetweets: include_retweets, }; // 전체 동기화 (async, 응답 대기하지 않음) @@ -248,6 +254,7 @@ export default async function xBotsRoutes(fastify) { display_name: { type: ['string', 'null'] }, avatar_url: { type: ['string', 'null'] }, text_filters: { type: ['array', 'null'], items: { type: 'string' } }, + include_retweets: { type: 'boolean' }, cron_interval: { type: 'integer' }, enabled: { type: 'boolean' }, }, @@ -286,6 +293,10 @@ export default async function xBotsRoutes(fastify) { ? JSON.stringify(updates.text_filters) : null); } + if (updates.include_retweets !== undefined) { + fields.push('include_retweets = ?'); + values.push(updates.include_retweets ? 1 : 0); + } if (updates.cron_interval !== undefined) { fields.push('cron_interval = ?'); values.push(updates.cron_interval); diff --git a/backend/src/services/meilisearch/index.js b/backend/src/services/meilisearch/index.js index f3ad3ca..f0552a7 100644 --- a/backend/src/services/meilisearch/index.js +++ b/backend/src/services/meilisearch/index.js @@ -159,7 +159,7 @@ function formatScheduleResponse(hit) { if (hit.category_id === CATEGORY_IDS.YOUTUBE && hit.source_name) { source = { name: hit.source_name, url: null }; } else if (hit.category_id === CATEGORY_IDS.X) { - source = { name: '', url: null }; + source = { name: hit.source_name || '', url: null }; } return { @@ -217,11 +217,12 @@ export async function syncScheduleById(meilisearch, db, scheduleId) { s.category_id, c.name as category_name, c.color as category_color, - sy.channel_name as source_name, + COALESCE(sy.channel_name, sx.username) as source_name, GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id + LEFT JOIN schedule_x sx ON s.id = sx.schedule_id LEFT JOIN schedule_members sm ON s.id = sm.schedule_id LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0 WHERE s.id = ? @@ -290,11 +291,12 @@ export async function syncAllSchedules(meilisearch, db) { s.category_id, c.name as category_name, c.color as category_color, - sy.channel_name as source_name, + COALESCE(sy.channel_name, sx.username) as source_name, GROUP_CONCAT(DISTINCT m.name ORDER BY m.id SEPARATOR ',') as member_names FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id + LEFT JOIN schedule_x sx ON s.id = sx.schedule_id LEFT JOIN schedule_members sm ON s.id = sm.schedule_id LEFT JOIN members m ON sm.member_id = m.id AND m.is_former = 0 GROUP BY s.id diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 5dfbd61..1e0a795 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -37,7 +37,7 @@ export function buildDatetime(date, time) { * @returns {object|null} { name, url } 또는 null */ export function buildSource(schedule) { - const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id } = schedule; + const { category_id, youtube_video_id, youtube_video_type, youtube_channel, x_post_id, x_username } = schedule; if (category_id === CATEGORY_IDS.YOUTUBE) { if (youtube_video_id) { @@ -58,9 +58,10 @@ export function buildSource(schedule) { } if (category_id === CATEGORY_IDS.X && x_post_id) { + const username = x_username || config.x.defaultUsername; return { - name: '', - url: `https://x.com/${config.x.defaultUsername}/status/${x_post_id}`, + name: username, + url: `https://x.com/${username}/status/${x_post_id}`, }; } @@ -193,6 +194,7 @@ export async function getScheduleDetail(db, id, getXProfile = null) { sy.video_id as youtube_video_id, sy.video_type as youtube_video_type, sx.post_id as x_post_id, + sx.username as x_username, sx.content as x_content, sx.image_urls as x_image_urls FROM schedules s @@ -256,8 +258,9 @@ export async function getScheduleDetail(db, id, getXProfile = null) { : `https://www.youtube.com/watch?v=${s.youtube_video_id}`; } } else if (s.category_id === CATEGORY_IDS.X && s.x_post_id) { - const username = config.x.defaultUsername; + const username = s.x_username || config.x.defaultUsername; result.postId = s.x_post_id; + result.username = username; result.content = s.x_content || null; result.imageUrls = s.x_image_urls ? JSON.parse(s.x_image_urls) : []; result.postUrl = `https://x.com/${username}/status/${s.x_post_id}`; @@ -292,7 +295,8 @@ const SCHEDULE_LIST_SQL = ` sy.channel_name as youtube_channel, sy.video_id as youtube_video_id, sy.video_type as youtube_video_type, - sx.post_id as x_post_id + sx.post_id as x_post_id, + sx.username as x_username FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index e3f5535..15a2cb5 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -54,7 +54,7 @@ async function xBotPlugin(fastify, opts) { /** * 트윗을 DB에 저장 */ - async function saveTweet(tweet) { + async function saveTweet(tweet, username) { // 중복 체크 (post_id로) - 트랜잭션 전에 수행 const [existing] = await fastify.db.query( 'SELECT id FROM schedule_x WHERE post_id = ?', @@ -79,10 +79,11 @@ async function xBotPlugin(fastify, opts) { // schedule_x 테이블에 저장 await connection.query( - 'INSERT INTO schedule_x (schedule_id, post_id, content, image_urls) VALUES (?, ?, ?, ?)', + 'INSERT INTO schedule_x (schedule_id, post_id, username, content, image_urls) VALUES (?, ?, ?, ?, ?)', [ scheduleId, tweet.id, + username, tweet.text, tweet.imageUrls.length > 0 ? JSON.stringify(tweet.imageUrls) : null, ] @@ -169,7 +170,8 @@ async function xBotPlugin(fastify, opts) { * 최근 트윗 동기화 (정기 실행) */ async function syncNewTweets(bot) { - const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username); + const options = { includeRetweets: bot.includeRetweets || false }; + const { tweets, profile } = await fetchTweets(bot.nitterUrl, bot.username, options); // 프로필 저장 (DB + 캐시) await saveProfile(bot.username, profile); @@ -183,7 +185,7 @@ async function xBotPlugin(fastify, opts) { continue; } - const scheduleId = await saveTweet(tweet); + const scheduleId = await saveTweet(tweet, bot.username); if (scheduleId) { // Meilisearch 동기화 await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); @@ -200,7 +202,8 @@ async function xBotPlugin(fastify, opts) { * 전체 트윗 동기화 (초기화) */ async function syncAllTweets(bot) { - const tweets = await fetchAllTweets(bot.nitterUrl, bot.username, fastify.log); + const options = { includeRetweets: bot.includeRetweets || false }; + const tweets = await fetchAllTweets(bot.nitterUrl, bot.username, fastify.log, options); let addedCount = 0; let ytAddedCount = 0; @@ -211,7 +214,7 @@ async function xBotPlugin(fastify, opts) { continue; } - const scheduleId = await saveTweet(tweet); + const scheduleId = await saveTweet(tweet, bot.username); if (scheduleId) { // Meilisearch 동기화 await syncScheduleById(fastify.meilisearch, fastify.db, scheduleId); diff --git a/backend/src/services/x/scraper.js b/backend/src/services/x/scraper.js index 390d035..e37c410 100644 --- a/backend/src/services/x/scraper.js +++ b/backend/src/services/x/scraper.js @@ -125,18 +125,26 @@ function extractTextFromHtml(html) { /** * HTML에서 트윗 목록 파싱 + * @param {string} html - HTML 문자열 + * @param {string} username - 사용자명 + * @param {object} options - 옵션 + * @param {boolean} options.includeRetweets - 리트윗 포함 여부 (기본: false) */ -export function parseTweets(html, username) { +export function parseTweets(html, username, options = {}) { + const { includeRetweets = false } = options; const tweets = []; const containers = html.split('class="timeline-item '); for (let i = 1; i < containers.length; i++) { const container = containers[i]; - // 고정/리트윗 제외 + // 고정 트윗 제외 const isPinned = container.includes('class="pinned"'); + if (isPinned) continue; + + // 리트윗 필터링 (옵션에 따라) const isRetweet = container.includes('class="retweet-header"'); - if (isPinned || isRetweet) continue; + if (isRetweet && !includeRetweets) continue; // 트윗 ID const idMatch = container.match(/href="\/[^\/]+\/status\/(\d+)/); @@ -246,8 +254,12 @@ export async function fetchProfile(nitterUrl, username) { /** * Nitter에서 트윗 수집 (첫 페이지만) + * @param {string} nitterUrl - Nitter URL + * @param {string} username - 사용자명 + * @param {object} options - 옵션 + * @param {boolean} options.includeRetweets - 리트윗 포함 여부 */ -export async function fetchTweets(nitterUrl, username) { +export async function fetchTweets(nitterUrl, username, options = {}) { const url = `${nitterUrl}/${username}`; const res = await fetchWithTimeout(url); const html = await res.text(); @@ -256,15 +268,20 @@ export async function fetchTweets(nitterUrl, username) { const profile = extractProfile(html); // 트윗 파싱 - const tweets = parseTweets(html, username); + const tweets = parseTweets(html, username, options); return { tweets, profile }; } /** * Nitter에서 전체 트윗 수집 (페이지네이션) + * @param {string} nitterUrl - Nitter URL + * @param {string} username - 사용자명 + * @param {object} log - 로거 + * @param {object} options - 옵션 + * @param {boolean} options.includeRetweets - 리트윗 포함 여부 */ -export async function fetchAllTweets(nitterUrl, username, log) { +export async function fetchAllTweets(nitterUrl, username, log, options = {}) { const allTweets = []; let cursor = null; let pageNum = 1; @@ -280,7 +297,7 @@ export async function fetchAllTweets(nitterUrl, username, log) { try { const res = await fetchWithTimeout(url); const html = await res.text(); - const tweets = parseTweets(html, username); + const tweets = parseTweets(html, username, options); if (tweets.length === 0) { emptyCount++; diff --git a/frontend/src/components/pc/admin/bot/XBotDialog.jsx b/frontend/src/components/pc/admin/bot/XBotDialog.jsx index 3bcfa1c..a74e52c 100644 --- a/frontend/src/components/pc/admin/bot/XBotDialog.jsx +++ b/frontend/src/components/pc/admin/bot/XBotDialog.jsx @@ -5,8 +5,9 @@ import { useState, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { motion, AnimatePresence } from 'framer-motion'; -import { Twitter, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; +import { Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; import { getXBot, createXBot, updateXBot, lookupXProfile } from '@/api/admin/bots'; +import { XIcon } from './BotCard'; // 동기화 간격 옵션 const INTERVAL_OPTIONS = [ @@ -131,6 +132,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) { const [showAdvanced, setShowAdvanced] = useState(false); const [textFilters, setTextFilters] = useState([]); const [filterInput, setFilterInput] = useState(''); + const [includeRetweets, setIncludeRetweets] = useState(false); // X 봇 상세 조회 (수정 모드) const { data: bot, isLoading: botLoading } = useQuery({ @@ -154,7 +156,8 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) { }); setInterval(bot.cron_interval || 1); setTextFilters(bot.text_filters || []); - setShowAdvanced((bot.text_filters && bot.text_filters.length > 0) || false); + setIncludeRetweets(bot.include_retweets || false); + setShowAdvanced((bot.text_filters && bot.text_filters.length > 0) || bot.include_retweets || false); } else if (!botId) { // 추가 모드 setUsername(''); @@ -162,6 +165,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) { setInterval(1); setTextFilters([]); setFilterInput(''); + setIncludeRetweets(false); setShowAdvanced(false); } }, [isOpen, bot, botId]); @@ -197,6 +201,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) { display_name: profileInfo.displayName, avatar_url: profileInfo.avatarUrl, text_filters: textFilters.length > 0 ? textFilters : null, + include_retweets: includeRetweets, cron_interval: interval, }; @@ -239,8 +244,8 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) { {/* 헤더 */}
리트윗도 일정에 추가합니다
+