epl-mcp-server
Version:
EPL 축구 소식을 위한 MCP Server
457 lines (396 loc) • 15.4 kB
text/typescript
// 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`);
});