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:
parent
eeb5e7234c
commit
9ceef6c656
8 changed files with 265 additions and 11 deletions
|
|
@ -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)
|
||||
: [],
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 동기화
|
||||
|
|
|
|||
106
docs/api.md
106
docs/api.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue