feat(x-bot): 리트윗 옵션 및 고정 트윗 제외 기능 추가

- include_retweets 옵션으로 리트윗 포함 여부 설정 가능
- 고정된 트윗(pinned)은 기본적으로 파싱에서 제외
- XBotDialog에서 Twitter 아이콘을 X 아이콘으로 변경
- schedule_x의 username을 source_name으로 활용

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-02-08 09:32:45 +09:00
parent 9ceef6c656
commit 5d44434e36
7 changed files with 94 additions and 30 deletions

View file

@ -69,6 +69,7 @@ async function schedulerPlugin(fastify, opts) {
? JSON.parse(row.text_filters)
: row.text_filters)
: [],
includeRetweets: row.include_retweets === 1,
}));
}

View file

@ -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);

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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++;

View file

@ -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 }) {
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-sky-50 flex items-center justify-center">
<Twitter size={20} className="text-sky-500" />
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center">
<XIcon size={20} fill="#000" />
</div>
<h2 className="text-lg font-bold text-gray-900">
{isEdit ? 'X 봇 수정' : 'X 봇 추가'}
@ -304,8 +309,8 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 bg-sky-100 rounded-full flex items-center justify-center">
<Twitter size={24} className="text-sky-500" />
<div className="w-12 h-12 bg-gray-200 rounded-full flex items-center justify-center">
<XIcon size={24} fill="#374151" />
</div>
)}
<div className="flex-1 min-w-0">
@ -347,7 +352,28 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
</button>
{showAdvanced && (
<div className="p-4 pt-0 space-y-4 border-t border-gray-100">
<div className="p-4 space-y-4 border-t border-gray-100">
{/* 리트윗 포함 */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">리트윗 포함</label>
<p className="text-xs text-gray-400">리트윗도 일정에 추가합니다</p>
</div>
<button
type="button"
onClick={() => setIncludeRetweets(!includeRetweets)}
className={`relative w-11 h-6 rounded-full transition-colors ${
includeRetweets ? 'bg-sky-500' : 'bg-gray-300'
}`}
>
<span
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
includeRetweets ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
{/* 텍스트 필터 */}
<div>
<label className="block text-sm text-gray-600 mb-1">텍스트 필터</label>