UNPKG

epl-mcp-server

Version:

EPL 축구 소식을 위한 MCP Server

457 lines (396 loc) 15.4 kB
// web-api-server.ts import express from 'express'; import cors from 'cors'; import { createClient } from '@supabase/supabase-js'; import dotenv from 'dotenv'; dotenv.config(); const app = express(); const port = process.env.PORT || 3001; // 미들웨어 설정 app.use(cors()); app.use(express.json()); // Supabase 클라이언트 초기화 const supabase = createClient( process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY! ); // EPL MCP Server 로직을 클래스로 분리 class EPLDataService { private supabase: any; constructor(supabaseClient: any) { this.supabase = supabaseClient; } async getHotIssues(timeframe: string, limit: number = 10) { let timeFilter = ''; const now = new Date(); switch (timeframe) { case 'today': const today = now.toISOString().split('T')[0]; timeFilter = today; break; case 'week': const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); timeFilter = weekAgo.toISOString(); break; case 'month': const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); timeFilter = monthAgo.toISOString(); break; default: const defaultTime = new Date(now.getTime() - 24 * 60 * 60 * 1000); timeFilter = defaultTime.toISOString(); } const { data, error } = await this.supabase .from('tweets') .select('*') .gte('created_at', timeFilter) .order('engagement', { ascending: false }) .limit(limit); if (error) { throw new Error(`Database query error: ${error.message}`); } return { timeframe, hot_issues: data.map((tweet: any) => ({ id: tweet.id, content: tweet.content, author: tweet.author, engagement: (tweet.retweet_count || 0) + (tweet.like_count || 0), hashtags: tweet.hashtags || [], keywords: tweet.keywords || [], created_at: tweet.created_at, category: tweet.category })) }; } async getPlayerNews(playerName: string, timeframe: string) { const days = this.parseTimeframe(timeframe); const dateFilter = new Date(Date.now() - days * 24 * 60 * 60 * 1000); const { data, error } = await this.supabase .from('tweets') .select('*') .eq('category', 'player') .or(`content.ilike.%${playerName}%,player_mentions.cs.{${playerName}}`) .gte('created_at', dateFilter.toISOString()) .order('created_at', { ascending: false }) .limit(20); if (error) { throw new Error(`Database query error: ${error.message}`); } return { player_name: playerName, timeframe, player_news: data.map((tweet: any) => ({ id: tweet.id, content: tweet.content, author: tweet.author, created_at: tweet.created_at, engagement: (tweet.retweet_count || 0) + (tweet.like_count || 0), hashtags: tweet.hashtags || [], category: tweet.category })) }; } async getTransferNews(timeframe: string, playerName?: string) { const days = this.parseTimeframe(timeframe); const dateFilter = new Date(Date.now() - days * 24 * 60 * 60 * 1000); let query = this.supabase .from('tweets') .select('*') .eq('category', 'transfer') .gte('created_at', dateFilter.toISOString()) .order('created_at', { ascending: false }) .limit(20); if (playerName) { query = query.ilike('content', `%${playerName}%`); } const { data, error } = await query; if (error) { throw new Error(`Database query error: ${error.message}`); } return { timeframe, player_name: playerName, transfer_news: data }; } async getMatchIssues(dateRange: string, teams?: string[]) { const [startDate, endDate] = dateRange.split(':'); let query = this.supabase .from('tweets') .select('*') .eq('category', 'match') .gte('created_at', `${startDate}T00:00:00`) .lte('created_at', `${endDate}T23:59:59`) .order('created_at', { ascending: false }); if (teams && teams.length > 0) { const teamFilters = teams.map((team: string) => `content.ilike.%${team}%`).join(','); query = query.or(teamFilters); } const { data, error } = await query; if (error) { throw new Error(`Database query error: ${error.message}`); } return { date_range: dateRange, teams, match_issues: data }; } async searchTweets(keywords: string[], timeframe: string, limit: number = 20) { const days = this.parseTimeframe(timeframe); const dateFilter = new Date(Date.now() - days * 24 * 60 * 60 * 1000); const orConditions = keywords.map((keyword: string) => `content.ilike.%${keyword}%`).join(','); const { data, error } = await this.supabase .from('tweets') .select('*') .or(orConditions) .gte('created_at', dateFilter.toISOString()) .order('created_at', { ascending: false }) .limit(limit); if (error) { throw new Error(`Database query error: ${error.message}`); } return { keywords, timeframe, tweets: data }; } async getCategoryTrends(category: string, timeframe: string = '7days') { const days = this.parseTimeframe(timeframe); const dateFilter = new Date(Date.now() - days * 24 * 60 * 60 * 1000); // 카테고리별 최고 인기 트윗 const { data: topTweets, error: topError } = await this.supabase .from('tweets') .select('*') .eq('category', category) .gte('created_at', dateFilter.toISOString()) .order('engagement', { ascending: false }) .limit(5); if (topError) { throw new Error(`Database query error: ${topError.message}`); } // 일별 트윗 볼륨 (PostgreSQL의 date_trunc 사용) const { data: volumeData, error: volumeError } = await this.supabase .rpc('get_daily_tweet_volume', { category_filter: category, start_date: dateFilter.toISOString() }); return { category, timeframe, trends: { top_tweets: topTweets?.map((tweet: any) => ({ content: tweet.content.substring(0, 100) + '...', engagement: (tweet.retweet_count || 0) + (tweet.like_count || 0), created_at: tweet.created_at })) || [], daily_volume: volumeData || [] } }; } suggestHashtags(content: string, category: string) { const commonHashtags: Record<string, string[]> = { match: ['#EPL', '#PremierLeague', '#Football', '#Soccer'], player: ['#EPL', '#PremierLeague', '#Player', '#Football'], club: ['#EPL', '#PremierLeague', '#Club', '#Football'], director: ['#EPL', '#PremierLeague', '#Manager', '#Coach'], transfer: ['#TransferNews', '#EPL', '#PremierLeague', '#Transfers'] }; const suggested = [...(commonHashtags[category] || commonHashtags.match)]; // 팀명 추출하여 해시태그 추가 const teams = ['Arsenal', 'Chelsea', 'Liverpool', 'ManCity', 'ManUnited', 'Tottenham']; teams.forEach(team => { if (content.toLowerCase().includes(team.toLowerCase())) { suggested.push(`#${team}`); } }); return { content, category, suggested_hashtags: [...new Set(suggested)] }; } getPostTemplates(type: string) { const templates: Record<string, any> = { match_preview: { template: "🔥 {team1} vs {team2} 경기 미리보기!\n\n⚽️ 주요 포인트:\n- {key_point_1}\n- {key_point_2}\n\n🏆 예상 결과: {prediction}\n\n{hashtags}", variables: ['team1', 'team2', 'key_point_1', 'key_point_2', 'prediction', 'hashtags'] }, player_news: { template: "⭐️ {player_name} 선수 소식\n\n{news_content}\n\n📊 최근 폼:\n- {recent_performance}\n\n💭 분석: {analysis}\n\n{hashtags}", variables: ['player_name', 'news_content', 'recent_performance', 'analysis', 'hashtags'] }, club_news: { template: "🏟️ {club_name} 클럽 소식\n\n{news_title}\n\n{news_content}\n\n📈 클럽 현황: {club_status}\n\n{hashtags}", variables: ['club_name', 'news_title', 'news_content', 'club_status', 'hashtags'] }, director_news: { template: "👨‍💼 {director_name} 감독/코치 소식\n\n{news_content}\n\n🎯 전술/전략: {tactics}\n📊 최근 성과: {recent_results}\n\n{hashtags}", variables: ['director_name', 'news_content', 'tactics', 'recent_results', 'hashtags'] }, transfer_news: { template: "🚨 이적시장 속보!\n\n{player_name} ➡️ {destination_team}\n\n💰 이적료: {transfer_fee}\n📝 계약 기간: {contract_length}\n\n{analysis}\n\n{hashtags}", variables: ['player_name', 'destination_team', 'transfer_fee', 'contract_length', 'analysis', 'hashtags'] } }; return { type, template: templates[type] }; } private parseTimeframe(timeframe: string): number { if (timeframe.includes('day')) { return parseInt(timeframe.replace(/\D/g, '')) || 1; } else if (timeframe.includes('week')) { return (parseInt(timeframe.replace(/\D/g, '')) || 1) * 7; } else if (timeframe.includes('month')) { return (parseInt(timeframe.replace(/\D/g, '')) || 1) * 30; } return 7; // 기본값 } } // 서비스 인스턴스 생성 const eplService = new EPLDataService(supabase); // API 라우트 정의 // 1. 핫 이슈 조회 app.get('/api/hot-issues', async (req, res) => { try { const { timeframe = 'today', limit = 10 } = req.query; const result = await eplService.getHotIssues(timeframe as string, Number(limit)); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message }); } }); // 2. 선수 뉴스 조회 app.get('/api/player-news', async (req, res) => { try { const { player_name, timeframe = '7days' } = req.query; if (!player_name) { return res.status(400).json({ success: false, error: 'player_name is required' }); } const result = await eplService.getPlayerNews(player_name as string, timeframe as string); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message }); } }); // 3. 이적 뉴스 조회 app.get('/api/transfer-news', async (req, res) => { try { const { timeframe = '7days', player_name } = req.query; const result = await eplService.getTransferNews(timeframe as string, player_name as string); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message }); } }); // 4. 경기 이슈 조회 app.get('/api/match-issues', async (req, res) => { try { const { date_range, teams } = req.query; if (!date_range) { return res.status(400).json({ success: false, error: 'date_range is required (format: YYYY-MM-DD:YYYY-MM-DD)' }); } const teamList = teams ? (teams as string).split(',') : undefined; const result = await eplService.getMatchIssues(date_range as string, teamList); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message }); } }); // 5. 트윗 검색 app.post('/api/search-tweets', async (req, res) => { try { const { keywords, timeframe = '7days', limit = 20 } = req.body; if (!keywords || !Array.isArray(keywords)) { return res.status(400).json({ success: false, error: 'keywords array is required' }); } const result = await eplService.searchTweets(keywords, timeframe, Number(limit)); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message }); } }); // 6. 카테고리별 트렌드 app.get('/api/category-trends/:category', async (req, res) => { try { const { category } = req.params; const { timeframe = '7days' } = req.query; const validCategories = ['match', 'player', 'club', 'director', 'transfer']; if (!validCategories.includes(category)) { return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(', ')}` }); } const result = await eplService.getCategoryTrends(category, timeframe as string); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message }); } }); // 7. 해시태그 추천 app.post('/api/suggest-hashtags', async (req, res) => { try { const { content, category } = req.body; if (!content || !category) { return res.status(400).json({ success: false, error: 'content and category are required' }); } const result = eplService.suggestHashtags(content, category); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message }); } }); // 8. 포스트 템플릿 app.get('/api/post-templates/:type', async (req, res) => { try { const { type } = req.params; const result = eplService.getPostTemplates(type); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ success: false, error: (error as Error).message }); } }); // 상태 확인 엔드포인트 app.get('/api/health', (req, res) => { res.json({ success: true, message: 'EPL MCP Server API is running', timestamp: new Date().toISOString() }); }); // API 문서 (간단한 버전) app.get('/api/docs', (req, res) => { const docs = { title: 'EPL MCP Server API', version: '1.0.0', endpoints: { 'GET /api/hot-issues': 'Get hot issues with timeframe and limit params', 'GET /api/player-news': 'Get player news with player_name and timeframe params', 'GET /api/transfer-news': 'Get transfer news with timeframe and optional player_name params', 'GET /api/match-issues': 'Get match issues with date_range and optional teams params', 'POST /api/search-tweets': 'Search tweets with keywords, timeframe, and limit in body', 'GET /api/category-trends/:category': 'Get category trends with timeframe param', 'POST /api/suggest-hashtags': 'Suggest hashtags with content and category in body', 'GET /api/post-templates/:type': 'Get post templates by type', 'GET /api/health': 'Health check endpoint', 'GET /api/docs': 'This documentation' } }; res.json(docs); }); // 에러 핸들링 미들웨어 app.use((err: any, req: any, res: any, next: any) => { console.error('Unhandled error:', err); res.status(500).json({ success: false, error: 'Internal server error' }); }); // 404 핸들러 app.use('*', (req, res) => { res.status(404).json({ success: false, error: 'Endpoint not found' }); }); // 서버 시작 app.listen(port, () => { console.log(`🚀 EPL MCP Server API running on port ${port}`); console.log(`📖 API Documentation: http://localhost:${port}/api/docs`); console.log(`💗 Health Check: http://localhost:${port}/api/health`); });