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:
parent
9ceef6c656
commit
5d44434e36
7 changed files with 94 additions and 30 deletions
|
|
@ -69,6 +69,7 @@ async function schedulerPlugin(fastify, opts) {
|
|||
? JSON.parse(row.text_filters)
|
||||
: row.text_filters)
|
||||
: [],
|
||||
includeRetweets: row.include_retweets === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue