diff --git a/backend/src/plugins/scheduler.js b/backend/src/plugins/scheduler.js index 811fbdc..d3a77ef 100644 --- a/backend/src/plugins/scheduler.js +++ b/backend/src/plugins/scheduler.js @@ -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) + : [], })); } diff --git a/backend/src/routes/admin/bots.js b/backend/src/routes/admin/bots.js index fae08d7..e9929a4 100644 --- a/backend/src/routes/admin/bots.js +++ b/backend/src/routes/admin/bots.js @@ -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; } diff --git a/backend/src/routes/admin/x-bots.js b/backend/src/routes/admin/x-bots.js index 4e1c668..29cc9c5 100644 --- a/backend/src/routes/admin/x-bots.js +++ b/backend/src/routes/admin/x-bots.js @@ -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); diff --git a/backend/src/services/x/index.js b/backend/src/services/x/index.js index 1904e17..e3f5535 100644 --- a/backend/src/services/x/index.js +++ b/backend/src/services/x/index.js @@ -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 동기화 diff --git a/docs/api.md b/docs/api.md index 30a0118..c4673d9 100644 --- a/docs/api.md +++ b/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 diff --git a/docs/architecture.md b/docs/architecture.md index 70629b8..8788a67 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) diff --git a/frontend/src/components/pc/admin/bot/BotCard.jsx b/frontend/src/components/pc/admin/bot/BotCard.jsx index 1602ac0..39fa658 100644 --- a/frontend/src/components/pc/admin/bot/BotCard.jsx +++ b/frontend/src/components/pc/admin/bot/BotCard.jsx @@ -270,8 +270,8 @@ export const BotTableRow = memo(function BotTableRow({ {bot.status === 'running' ? : } - {/* 수정 (YouTube만) */} - {bot.type === 'youtube' && onEdit && ( + {/* 수정 (YouTube, X) */} + {(bot.type === 'youtube' || bot.type === 'x') && onEdit && ( )} - {/* 삭제 (YouTube만) */} - {bot.type === 'youtube' && onDelete && ( + {/* 삭제 (YouTube, X) */} + {(bot.type === 'youtube' || bot.type === 'x') && onDelete && ( + + {showAdvanced && ( +
+ {/* 텍스트 필터 */} +
+ +
+ {textFilters.map((filter, idx) => ( + + {filter} + + + ))} + 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" + /> +
+

+ 키워드 중 하나라도 포함된 트윗만 추가됩니다 (비어있으면 모든 트윗) +

+
+
+ )} + )}