feat(youtube-bot): 제목에서 멤버 추출 옵션 추가

- bot_youtube에 extract_members_from_title 컬럼 추가 (기본값 0)
- services/youtube/index.js: 설명과 제목에서 각각 멤버 이름 검색, 합집합으로 중복 제거
- YouTubeBotDialog 고급 설정에 토글 추가 (설명 추출 토글 아래)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-23 18:50:02 +09:00
parent 678e228bc5
commit bcc8555193
6 changed files with 49 additions and 6 deletions

View file

@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS bot_youtube (
-- 멤버 설정 (선택)
default_member_ids JSON,
extract_members_from_desc TINYINT(1) DEFAULT 0,
extract_members_from_title TINYINT(1) DEFAULT 0,
-- 다음 주 예정 일정 설정 (JSON)
auto_schedule_config JSON,

View file

@ -58,6 +58,7 @@ async function schedulerPlugin(fastify, opts) {
: row.default_member_ids)
: [],
extractMembersFromDesc: row.extract_members_from_desc === 1,
extractMembersFromTitle: row.extract_members_from_title === 1,
autoScheduleNext: row.auto_schedule_config
? (typeof row.auto_schedule_config === 'string'
? JSON.parse(row.auto_schedule_config)

View file

@ -19,6 +19,7 @@ const youtubeBotResponse = {
title_filters: { type: 'array', items: { type: 'string' } },
default_member_ids: { type: 'array', items: { type: 'integer' } },
extract_members_from_desc: { type: 'boolean' },
extract_members_from_title: { type: 'boolean' },
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
},
@ -55,6 +56,7 @@ function formatBotResponse(row) {
: row.default_member_ids)
: [],
extract_members_from_desc: row.extract_members_from_desc === 1,
extract_members_from_title: row.extract_members_from_title === 1,
auto_schedule_config: row.auto_schedule_config
? (typeof row.auto_schedule_config === 'string'
? JSON.parse(row.auto_schedule_config)
@ -186,6 +188,7 @@ export default async function youtubeBotsRoutes(fastify) {
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
extract_members_from_desc: { type: 'boolean', default: false },
extract_members_from_title: { type: 'boolean', default: false },
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
},
@ -207,6 +210,7 @@ export default async function youtubeBotsRoutes(fastify) {
title_filters,
default_member_ids,
extract_members_from_desc = false,
extract_members_from_title = false,
auto_schedule_config,
weekly_schedule_config,
} = request.body;
@ -226,9 +230,9 @@ export default async function youtubeBotsRoutes(fastify) {
const [result] = await db.query(
`INSERT INTO bot_youtube
(channel_id, channel_handle, channel_name, banner_url, cron_interval,
title_filters, default_member_ids, extract_members_from_desc,
title_filters, default_member_ids, extract_members_from_desc, extract_members_from_title,
auto_schedule_config, weekly_schedule_config, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
[
channel_id,
channel_handle || null,
@ -238,6 +242,7 @@ export default async function youtubeBotsRoutes(fastify) {
title_filters ? JSON.stringify(title_filters) : null,
default_member_ids ? JSON.stringify(default_member_ids) : null,
extract_members_from_desc ? 1 : 0,
extract_members_from_title ? 1 : 0,
auto_schedule_config ? JSON.stringify(auto_schedule_config) : null,
weekly_schedule_config ? JSON.stringify(weekly_schedule_config) : null,
]
@ -278,6 +283,7 @@ export default async function youtubeBotsRoutes(fastify) {
title_filters: { type: ['array', 'null'], items: { type: 'string' } },
default_member_ids: { type: ['array', 'null'], items: { type: 'integer' } },
extract_members_from_desc: { type: 'boolean' },
extract_members_from_title: { type: 'boolean' },
auto_schedule_config: { type: ['object', 'null'], additionalProperties: true },
weekly_schedule_config: { type: ['object', 'null'], additionalProperties: true },
enabled: { type: 'boolean' },
@ -331,6 +337,10 @@ export default async function youtubeBotsRoutes(fastify) {
fields.push('extract_members_from_desc = ?');
values.push(updates.extract_members_from_desc ? 1 : 0);
}
if (updates.extract_members_from_title !== undefined) {
fields.push('extract_members_from_title = ?');
values.push(updates.extract_members_from_title ? 1 : 0);
}
if (updates.auto_schedule_config !== undefined) {
fields.push('auto_schedule_config = ?');
values.push(updates.auto_schedule_config ? JSON.stringify(updates.auto_schedule_config) : null);

View file

@ -272,7 +272,7 @@ async function youtubeBotPlugin(fastify) {
// 멤버 이름 맵 미리 조회 (트랜잭션 전에)
let nameMap = null;
if (bot.extractMembersFromDesc) {
if (bot.extractMembersFromDesc || bot.extractMembersFromTitle) {
nameMap = await getMemberNameMap();
}
@ -295,14 +295,17 @@ async function youtubeBotPlugin(fastify) {
// 멤버 연결 (커스텀 설정)
const hasDefaultMembers = bot.defaultMemberIds && bot.defaultMemberIds.length > 0;
if (hasDefaultMembers || bot.extractMembersFromDesc) {
if (hasDefaultMembers || bot.extractMembersFromDesc || bot.extractMembersFromTitle) {
const memberIds = [];
if (hasDefaultMembers) {
memberIds.push(...bot.defaultMemberIds);
}
if (nameMap) {
if (nameMap && bot.extractMembersFromDesc) {
memberIds.push(...extractMemberIds(video.description, nameMap));
}
if (nameMap && bot.extractMembersFromTitle) {
memberIds.push(...extractMemberIds(video.title, nameMap));
}
if (memberIds.length > 0) {
const uniqueIds = [...new Set(memberIds)];
const values = uniqueIds.map(id => [newScheduleId, id]);

View file

@ -331,6 +331,7 @@ YouTube 봇 추가
"title_filters": ["fromis_9", "프로미스나인"],
"default_member_ids": [1, 2],
"extract_members_from_desc": true,
"extract_members_from_title": false,
"auto_schedule_config": {
"dayOfWeek": 4,
"time": "18:00:00",

View file

@ -300,6 +300,7 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
const [filterInput, setFilterInput] = useState('');
const [defaultMemberIds, setDefaultMemberIds] = useState([]);
const [extractMembers, setExtractMembers] = useState(false);
const [extractMembersFromTitle, setExtractMembersFromTitle] = useState(false);
// ( )
const [members, setMembers] = useState([]);
@ -381,11 +382,13 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
setTitleFilters(bot.title_filters || []);
setDefaultMemberIds(bot.default_member_ids || []);
setExtractMembers(bot.extract_members_from_desc || false);
setExtractMembersFromTitle(bot.extract_members_from_title || false);
//
if ((bot.title_filters && bot.title_filters.length > 0) ||
(bot.default_member_ids && bot.default_member_ids.length > 0) ||
bot.extract_members_from_desc) {
bot.extract_members_from_desc ||
bot.extract_members_from_title) {
setShowAdvanced(true);
} else {
setShowAdvanced(false);
@ -410,6 +413,7 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
setFilterInput('');
setDefaultMemberIds([]);
setExtractMembers(false);
setExtractMembersFromTitle(false);
}
}, [isOpen, bot, botId]);
@ -447,6 +451,7 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
title_filters: titleFilters.length > 0 ? titleFilters : null,
default_member_ids: defaultMemberIds.length > 0 ? defaultMemberIds : null,
extract_members_from_desc: extractMembers,
extract_members_from_title: extractMembersFromTitle,
auto_schedule_config: autoScheduleEnabled
? {
dayOfWeek: scheduleDayOfWeek,
@ -848,6 +853,28 @@ function YouTubeBotDialog({ isOpen, onClose, botId = null, onSuccess }) {
/>
</div>
</div>
{/* 제목에서 멤버 추출 */}
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => setExtractMembersFromTitle(!extractMembersFromTitle)}
>
<div>
<p className="text-sm font-medium text-gray-700">제목에서 멤버 추출</p>
<p className="text-xs text-gray-500">영상 제목에서 멤버 이름을 찾아 자동 연결</p>
</div>
<div
className={`w-10 h-5 rounded-full transition-colors ${
extractMembersFromTitle ? 'bg-red-500' : 'bg-gray-200'
}`}
>
<div
className={`w-4 h-4 bg-white rounded-full shadow-sm transform transition-transform mt-0.5 ${
extractMembersFromTitle ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</div>
</div>
</div>
)}
</div>