feat(x-bot): 키워드 필터링 및 전체 동기화 기능 추가

Backend:
- bot_x 테이블에 text_filters 컬럼 추가
- syncNewTweets/syncAllTweets에 텍스트 필터링 로직 적용
- 봇 추가 시 전체 트윗 동기화 수행 (백그라운드)
- X 봇 API에 text_filters 필드 처리

Frontend:
- XBotDialog에 고급 설정 (키워드 필터) UI 추가
- BotTableRow에서 X 봇 수정/삭제 버튼 활성화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-02-08 09:23:45 +09:00
parent eeb5e7234c
commit 9ceef6c656
8 changed files with 265 additions and 11 deletions

View file

@ -64,6 +64,11 @@ async function schedulerPlugin(fastify, opts) {
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
cron: `*/${row.cron_interval} * * * *`,
enabled: row.enabled === 1,
textFilters: row.text_filters
? (typeof row.text_filters === 'string'
? JSON.parse(row.text_filters)
: row.text_filters)
: [],
}));
}

View file

@ -33,6 +33,7 @@ const botResponse = {
username: { type: 'string' },
display_name: { type: 'string' },
avatar_url: { type: 'string' },
text_filters: { type: 'array', items: { type: 'string' } },
},
};
@ -122,6 +123,7 @@ export default async function botsRoutes(fastify) {
botData.username = bot.username;
botData.display_name = bot.displayName;
botData.avatar_url = bot.avatarUrl;
botData.text_filters = bot.textFilters || [];
botData.cron_interval = checkInterval;
}

View file

@ -12,6 +12,7 @@ const xBotResponse = {
username: { type: 'string' },
display_name: { type: 'string' },
avatar_url: { type: 'string' },
text_filters: { type: 'array', items: { type: 'string' } },
cron_interval: { type: 'integer' },
enabled: { type: 'boolean' },
},
@ -34,6 +35,11 @@ function formatBotResponse(row) {
username: row.username,
display_name: row.display_name,
avatar_url: row.avatar_url,
text_filters: row.text_filters
? (typeof row.text_filters === 'string'
? JSON.parse(row.text_filters)
: row.text_filters)
: [],
cron_interval: row.cron_interval,
enabled: row.enabled === 1,
};
@ -150,6 +156,7 @@ export default async function xBotsRoutes(fastify) {
username: { type: 'string' },
display_name: { type: ['string', 'null'] },
avatar_url: { type: ['string', 'null'] },
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
cron_interval: { type: 'integer', default: 1 },
},
required: ['username'],
@ -165,6 +172,7 @@ export default async function xBotsRoutes(fastify) {
username,
display_name,
avatar_url,
text_filters,
cron_interval = 1,
} = request.body;
@ -178,14 +186,41 @@ export default async function xBotsRoutes(fastify) {
}
const [result] = await db.query(
`INSERT INTO bot_x (username, display_name, avatar_url, cron_interval, enabled)
VALUES (?, ?, ?, ?, 1)`,
[username, display_name || null, avatar_url || null, cron_interval]
`INSERT INTO bot_x (username, display_name, avatar_url, text_filters, cron_interval, enabled)
VALUES (?, ?, ?, ?, ?, 1)`,
[
username,
display_name || null,
avatar_url || null,
text_filters && text_filters.length > 0 ? JSON.stringify(text_filters) : null,
cron_interval,
]
);
// 스케줄러 캐시 무효화 및 봇 시작
// 스케줄러 캐시 무효화
scheduler.invalidateCache();
const botId = `x-${result.insertId}`;
// 전체 트윗 동기화 수행 (백그라운드)
const bot = {
id: botId,
dbId: result.insertId,
type: 'x',
username,
nitterUrl: process.env.NITTER_URL || 'http://nitter:8080',
textFilters: text_filters || [],
};
// 전체 동기화 (async, 응답 대기하지 않음)
fastify.xBot.syncAllTweets(bot)
.then((syncResult) => {
fastify.log.info(`[${botId}] 초기 전체 동기화 완료: ${syncResult.addedCount}개 추가`);
})
.catch((err) => {
fastify.log.error(`[${botId}] 초기 전체 동기화 실패:`, err);
});
// 봇 시작 (스케줄러 등록)
try {
await scheduler.startBot(botId);
} catch (err) {
@ -212,6 +247,7 @@ export default async function xBotsRoutes(fastify) {
properties: {
display_name: { type: ['string', 'null'] },
avatar_url: { type: ['string', 'null'] },
text_filters: { type: ['array', 'null'], items: { type: 'string' } },
cron_interval: { type: 'integer' },
enabled: { type: 'boolean' },
},
@ -244,6 +280,12 @@ export default async function xBotsRoutes(fastify) {
fields.push('avatar_url = ?');
values.push(updates.avatar_url);
}
if (updates.text_filters !== undefined) {
fields.push('text_filters = ?');
values.push(updates.text_filters && updates.text_filters.length > 0
? JSON.stringify(updates.text_filters)
: null);
}
if (updates.cron_interval !== undefined) {
fields.push('cron_interval = ?');
values.push(updates.cron_interval);

View file

@ -156,6 +156,15 @@ async function xBotPlugin(fastify, opts) {
return addedCount;
}
/**
* 텍스트 필터 적용 (키워드 하나라도 포함되면 true)
*/
function matchesFilter(text, filters) {
if (!filters || filters.length === 0) return true;
const lowerText = text.toLowerCase();
return filters.some(filter => lowerText.includes(filter.toLowerCase()));
}
/**
* 최근 트윗 동기화 (정기 실행)
*/
@ -169,6 +178,11 @@ async function xBotPlugin(fastify, opts) {
let ytAddedCount = 0;
for (const tweet of tweets) {
// 텍스트 필터 적용
if (!matchesFilter(tweet.text, bot.textFilters)) {
continue;
}
const scheduleId = await saveTweet(tweet);
if (scheduleId) {
// Meilisearch 동기화
@ -192,6 +206,11 @@ async function xBotPlugin(fastify, opts) {
let ytAddedCount = 0;
for (const tweet of tweets) {
// 텍스트 필터 적용
if (!matchesFilter(tweet.text, bot.textFilters)) {
continue;
}
const scheduleId = await saveTweet(tweet);
if (scheduleId) {
// Meilisearch 동기화

View file

@ -290,6 +290,112 @@ YouTube API 할당량 경고 조회
---
## 관리자 - YouTube 봇 (인증 필요)
### POST /admin/youtube-bots/lookup
채널 핸들로 채널 정보 조회
**Request Body:**
```json
{
"handle": "@studiofromis_9"
}
```
**응답:**
```json
{
"channelId": "UCxxx",
"title": "채널명",
"thumbnailUrl": "https://...",
"bannerUrl": "https://..."
}
```
### GET /admin/youtube-bots
YouTube 봇 목록 조회
### GET /admin/youtube-bots/:id
YouTube 봇 상세 조회
### POST /admin/youtube-bots
YouTube 봇 추가
**Request Body:**
```json
{
"channel_id": "UCxxx",
"channel_handle": "@studiofromis_9",
"channel_name": "채널명",
"cron_interval": 2,
"title_filters": ["fromis_9", "프로미스나인"],
"default_member_ids": [1, 2],
"extract_members_from_desc": true,
"auto_schedule_config": {
"dayOfWeek": 4,
"time": "18:00:00",
"titleTemplate": "{channelName} {episode}화",
"deadlineDayOfWeek": 5
}
}
```
### PUT /admin/youtube-bots/:id
YouTube 봇 수정
### DELETE /admin/youtube-bots/:id
YouTube 봇 삭제
---
## 관리자 - X 봇 (인증 필요)
### POST /admin/x-bots/lookup
X username으로 프로필 정보 조회 (Nitter 사용)
**Request Body:**
```json
{
"username": "realfromis_9"
}
```
**응답:**
```json
{
"username": "realfromis_9",
"displayName": "프로미스나인 (fromis_9)",
"avatarUrl": "https://..."
}
```
### GET /admin/x-bots
X 봇 목록 조회
### GET /admin/x-bots/:id
X 봇 상세 조회
### POST /admin/x-bots
X 봇 추가
**Request Body:**
```json
{
"username": "realfromis_9",
"display_name": "프로미스나인 (fromis_9)",
"avatar_url": "https://...",
"cron_interval": 1
}
```
### PUT /admin/x-bots/:id
X 봇 수정
### DELETE /admin/x-bots/:id
X 봇 삭제
---
## 관리자 - YouTube (인증 필요)
### GET /admin/youtube/video-info

View file

@ -18,6 +18,8 @@ fromis_9/
│ │ ├── routes/ # API 라우트
│ │ │ ├── admin/ # 관리자 API
│ │ │ │ ├── bots.js # 봇 관리
│ │ │ │ ├── youtube-bots.js # YouTube 봇 CRUD
│ │ │ │ ├── x-bots.js # X 봇 CRUD
│ │ │ │ ├── youtube.js # YouTube 일정 관리
│ │ │ │ └── x.js # X 일정 관리
│ │ │ ├── albums/
@ -150,7 +152,9 @@ fromis_9/
│ │ │ │ │ ├── PendingFileItem.jsx
│ │ │ │ │ └── BulkEditPanel.jsx
│ │ │ │ └── bot/
│ │ │ │ └── BotCard.jsx
│ │ │ │ ├── BotCard.jsx
│ │ │ │ ├── YouTubeBotDialog.jsx
│ │ │ │ └── XBotDialog.jsx
│ │ │ │
│ │ │ └── mobile/ # 모바일 컴포넌트
│ │ │ ├── layout/
@ -280,7 +284,7 @@ fromis_9/
## 데이터베이스
### 테이블 목록 (25개)
### 테이블 목록 (27개)
#### 사용자/인증
- `admin_users` - 관리자 계정
@ -315,6 +319,10 @@ fromis_9/
#### X(Twitter) 프로필
- `x_profiles` - X 프로필 캐시 (프로필 이미지, 이름 등)
#### 봇
- `bot_youtube` - YouTube 봇 설정 (채널 정보, 동기화 간격, 필터 등)
- `bot_x` - X 봇 설정 (username, 동기화 간격 등)
#### 이미지
- `images` - 이미지 메타데이터 (3개 해상도 URL)

View file

@ -270,8 +270,8 @@ export const BotTableRow = memo(function BotTableRow({
{bot.status === 'running' ? <Square size={16} /> : <Play size={16} />}
</button>
</Tooltip>
{/* 수정 (YouTube) */}
{bot.type === 'youtube' && onEdit && (
{/* 수정 (YouTube, X) */}
{(bot.type === 'youtube' || bot.type === 'x') && onEdit && (
<Tooltip text="수정">
<button
onClick={() => onEdit(bot)}
@ -281,8 +281,8 @@ export const BotTableRow = memo(function BotTableRow({
</button>
</Tooltip>
)}
{/* 삭제 (YouTube) */}
{bot.type === 'youtube' && onDelete && (
{/* 삭제 (YouTube, X) */}
{(bot.type === 'youtube' || bot.type === 'x') && onDelete && (
<Tooltip text="삭제">
<button
onClick={() => onDelete(bot)}

View file

@ -5,7 +5,7 @@ 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, Loader2 } from 'lucide-react';
import { Twitter, Search, X, ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import { getXBot, createXBot, updateXBot, lookupXProfile } from '@/api/admin/bots';
//
@ -127,6 +127,11 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
const [interval, setInterval] = useState(1);
const [submitting, setSubmitting] = useState(false);
//
const [showAdvanced, setShowAdvanced] = useState(false);
const [textFilters, setTextFilters] = useState([]);
const [filterInput, setFilterInput] = useState('');
// X ( )
const { data: bot, isLoading: botLoading } = useQuery({
queryKey: ['admin', 'x-bot', botId],
@ -148,11 +153,16 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
avatarUrl: bot.avatar_url,
});
setInterval(bot.cron_interval || 1);
setTextFilters(bot.text_filters || []);
setShowAdvanced((bot.text_filters && bot.text_filters.length > 0) || false);
} else if (!botId) {
//
setUsername('');
setProfileInfo(null);
setInterval(1);
setTextFilters([]);
setFilterInput('');
setShowAdvanced(false);
}
}, [isOpen, bot, botId]);
@ -186,6 +196,7 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
username: profileInfo.username,
display_name: profileInfo.displayName,
avatar_url: profileInfo.avatarUrl,
text_filters: textFilters.length > 0 ? textFilters : null,
cron_interval: interval,
};
@ -319,6 +330,67 @@ function XBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
placeholder="간격 선택"
/>
</div>
{/* 고급 설정 */}
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
onClick={() => setShowAdvanced(!showAdvanced)}
>
<span className="font-medium text-gray-700">고급 설정</span>
{showAdvanced ? (
<ChevronUp size={20} className="text-gray-400" />
) : (
<ChevronDown size={20} className="text-gray-400" />
)}
</button>
{showAdvanced && (
<div className="p-4 pt-0 space-y-4 border-t border-gray-100">
{/* 텍스트 필터 */}
<div>
<label className="block text-sm text-gray-600 mb-1">텍스트 필터</label>
<div className="flex flex-wrap gap-2 p-2 border border-gray-200 rounded-lg min-h-[42px]">
{textFilters.map((filter, idx) => (
<span
key={idx}
className="inline-flex items-center gap-1 px-2 py-1 bg-sky-50 text-sky-600 rounded-md text-sm"
>
{filter}
<button
type="button"
onClick={() => setTextFilters(textFilters.filter((_, i) => i !== idx))}
className="hover:text-sky-800"
>
<X size={14} />
</button>
</span>
))}
<input
type="text"
value={filterInput}
onChange={(e) => setFilterInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && filterInput.trim()) {
e.preventDefault();
if (!textFilters.includes(filterInput.trim())) {
setTextFilters([...textFilters, filterInput.trim()]);
}
setFilterInput('');
}
}}
placeholder={textFilters.length === 0 ? '키워드 입력 후 Enter' : ''}
className="flex-1 min-w-[120px] outline-none text-sm"
/>
</div>
<p className="text-xs text-gray-400 mt-1">
키워드 하나라도 포함된 트윗만 추가됩니다 (비어있으면 모든 트윗)
</p>
</div>
</div>
)}
</div>
</form>
)}