This commit is contained in:
Executable
+319
@@ -0,0 +1,319 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface AIPredictionResult {
|
||||
matchId: string;
|
||||
matchName: string;
|
||||
predictions: {
|
||||
betType: string;
|
||||
prediction: string;
|
||||
confidence: number;
|
||||
probabilities?: Record<string, number>;
|
||||
reasoning?: string;
|
||||
odd?: number;
|
||||
valueBet?: { isValue: boolean; edge: number };
|
||||
}[];
|
||||
recommendedBets: string[];
|
||||
homeAnalysis?: {
|
||||
teamName: string;
|
||||
formText: string;
|
||||
goalsAvg: number;
|
||||
formRating: string;
|
||||
squadStrength?: number;
|
||||
};
|
||||
awayAnalysis?: {
|
||||
teamName: string;
|
||||
formText: string;
|
||||
goalsAvg: number;
|
||||
formRating: string;
|
||||
squadStrength?: number;
|
||||
};
|
||||
expertComment: string;
|
||||
modelVersion: string;
|
||||
confidenceScore: number;
|
||||
expectedGoals?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
private readonly logger = new Logger(AiService.name);
|
||||
private readonly pythonEngineUrl: string;
|
||||
|
||||
constructor(
|
||||
private readonly httpService: HttpService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.pythonEngineUrl =
|
||||
this.configService.get('AI_ENGINE_URL') || 'http://127.0.0.1:8000';
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Python match analysis engine and map the result to stable frontend contract.
|
||||
*/
|
||||
async callPythonEngine(
|
||||
matchDetails: any,
|
||||
_odds: any[],
|
||||
_lineups: { home: any[]; away: any[] },
|
||||
_substitutes: { home: any[]; away: any[] } | null,
|
||||
_stats: any,
|
||||
_eventData: any[],
|
||||
): Promise<AIPredictionResult | null> {
|
||||
try {
|
||||
const matchId = String(matchDetails?.matchId || '').trim();
|
||||
if (!matchId) {
|
||||
this.logger.warn('Skipping AI call: missing matchId');
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Calling Python V25 Engine for ${matchDetails.homeTeam} vs ${matchDetails.awayTeam}`,
|
||||
);
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${this.pythonEngineUrl}/v20plus/analyze/${matchId}`,
|
||||
{},
|
||||
{
|
||||
timeout: 30000,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (response.data) {
|
||||
return this.mapPythonResponse(response.data, matchDetails);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Python Engine error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Python response to our interface
|
||||
*/
|
||||
private mapPythonResponse(data: any, matchDetails: any): AIPredictionResult {
|
||||
const picks = Array.isArray(data?.bet_summary) ? data.bet_summary : [];
|
||||
const recommendedBets = picks
|
||||
.filter((p: any) => p?.playable)
|
||||
.map((p: any) => `${p.market}: ${p.pick}`);
|
||||
|
||||
const mappedPredictions = picks.map((p: any) => ({
|
||||
betType: String(p.market || ''),
|
||||
prediction: String(p.pick || ''),
|
||||
confidence: Number(p.calibrated_confidence ?? p.confidence ?? 0),
|
||||
probabilities: {},
|
||||
reasoning: Array.isArray(p.reasons)
|
||||
? p.reasons.join(' | ')
|
||||
: Array.isArray(p.decision_reasons)
|
||||
? p.decision_reasons.join(' | ')
|
||||
: '',
|
||||
odd: typeof p.odds === 'number' ? p.odds : undefined,
|
||||
valueBet:
|
||||
typeof p.edge === 'number'
|
||||
? {
|
||||
isValue: p.edge > 0,
|
||||
edge: p.edge,
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
const matchInfo = data?.match_info || {};
|
||||
const confidenceScore = Number(
|
||||
data?.main_pick?.calibrated_confidence ??
|
||||
data?.main_pick?.confidence ??
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
matchId: matchDetails.matchId || matchInfo.match_id || data.match_id,
|
||||
matchName: `${matchDetails.homeTeam} vs ${matchDetails.awayTeam}`,
|
||||
predictions: mappedPredictions,
|
||||
recommendedBets:
|
||||
data.recommended_bets && Array.isArray(data.recommended_bets)
|
||||
? data.recommended_bets
|
||||
: recommendedBets,
|
||||
homeAnalysis: undefined,
|
||||
awayAnalysis: undefined,
|
||||
expertComment: data.ai_commentary || data.expert_comment || '',
|
||||
modelVersion: data.model_version || 'v25.main',
|
||||
confidenceScore:
|
||||
confidenceScore > 1 ? confidenceScore : confidenceScore * 100,
|
||||
expectedGoals: data?.score_prediction?.xg_total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mapped prediction response from the AI package.
|
||||
*/
|
||||
getPredictionForMatch(analysisData: any): any {
|
||||
const pyData = analysisData.liveMatchData?.pythonEnginePrediction;
|
||||
|
||||
if (!pyData || !Array.isArray(pyData.bet_summary)) {
|
||||
return this.getEmptyPrediction();
|
||||
}
|
||||
|
||||
const allPredictions = pyData.bet_summary.map((p: any) => ({
|
||||
betType: p.market,
|
||||
prediction: p.pick,
|
||||
confidence: p.calibrated_confidence ?? p.confidence ?? 0,
|
||||
probabilities: {},
|
||||
reasoning: Array.isArray(p.reasons) ? p.reasons.join(' | ') : '',
|
||||
odd: p.odds || 0,
|
||||
valueBet: {
|
||||
is_value: typeof p.edge === 'number' ? p.edge > 0 : false,
|
||||
edge: p.edge || 0,
|
||||
},
|
||||
}));
|
||||
const firstPick = allPredictions[0];
|
||||
|
||||
return {
|
||||
predictions: allPredictions,
|
||||
recommendedBets: pyData.recommended_bets || [],
|
||||
valueBets: allPredictions.filter((p: any) => p.valueBet?.is_value),
|
||||
homeAnalysis: null,
|
||||
awayAnalysis: null,
|
||||
expertComment: pyData.ai_commentary || '',
|
||||
winnerPrediction: firstPick?.prediction || 'N/A',
|
||||
scorePrediction: pyData.score_prediction?.ft || '-',
|
||||
confidenceScore:
|
||||
typeof firstPick?.confidence === 'number' ? firstPick.confidence : 0,
|
||||
modelVersion: pyData.model_version || 'v25.main',
|
||||
expectedGoals: pyData.score_prediction?.xg_total || 0,
|
||||
keyInsights: [
|
||||
`Model: ${pyData.model_version || 'v25.main'}`,
|
||||
`Risk: ${pyData.risk?.level || 'N/A'} (${pyData.risk?.score ?? 0})`,
|
||||
`Data Quality: ${pyData.data_quality?.label || 'N/A'}`,
|
||||
`xG Beklentisi: ${
|
||||
typeof pyData.score_prediction?.xg_total === 'number'
|
||||
? pyData.score_prediction.xg_total.toFixed(2)
|
||||
: 'N/A'
|
||||
}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate analysis strategy (replaces Gemini)
|
||||
*/
|
||||
getAnalysisStrategy(matchDNA: any): { analysisTactics: any[] } {
|
||||
const tactics: any[] = [];
|
||||
const odds = matchDNA?.odds || [];
|
||||
|
||||
// MS 1 oranını bul
|
||||
const ms1 = odds.find(
|
||||
(o: any) =>
|
||||
o.category?.toLowerCase().includes('maç sonucu') && o.selection === '1',
|
||||
);
|
||||
|
||||
// KG Var oranını bul
|
||||
const kgVar = odds.find(
|
||||
(o: any) =>
|
||||
o.category?.toLowerCase().includes('karşılıklı gol') &&
|
||||
o.selection?.toLowerCase() === 'var',
|
||||
);
|
||||
|
||||
// Alt 2.5 oranını bul
|
||||
const alt25 = odds.find(
|
||||
(o: any) =>
|
||||
o.category?.toLowerCase().includes('alt/üst') &&
|
||||
o.selection?.toLowerCase() === 'alt',
|
||||
);
|
||||
|
||||
// Tactic 1: Benzer MS oranları
|
||||
if (ms1?.odd_value) {
|
||||
tactics.push({
|
||||
tacticName: 'Benzer Maç Sonucu Oranları',
|
||||
description:
|
||||
'Ev sahibi galibiyeti için benzer oran aralığındaki maçlar',
|
||||
odds: [
|
||||
{
|
||||
categoryName: 'Maç Sonucu',
|
||||
selectionName: '1',
|
||||
value: parseFloat(ms1.odd_value),
|
||||
tolerance: 0.3,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Tactic 2: Benzer KG + AU oranları
|
||||
if (kgVar?.odd_value && alt25?.odd_value) {
|
||||
tactics.push({
|
||||
tacticName: 'Benzer Gol Beklentisi',
|
||||
description: 'Karşılıklı gol ve toplam gol benzerliği',
|
||||
odds: [
|
||||
{
|
||||
categoryName: 'Karşılıklı Gol',
|
||||
selectionName: 'Var',
|
||||
value: parseFloat(kgVar.odd_value),
|
||||
tolerance: 0.4,
|
||||
},
|
||||
{
|
||||
categoryName: '2,5 Alt/Üst',
|
||||
selectionName: 'Alt',
|
||||
value: parseFloat(alt25.odd_value),
|
||||
tolerance: 0.3,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Tactic 3: Favori analizi
|
||||
if (ms1?.odd_value && parseFloat(ms1.odd_value) < 1.8) {
|
||||
tactics.push({
|
||||
tacticName: 'Favori Takım Analizi',
|
||||
description: 'Benzer şekilde favori olan ev sahibi takımların maçları',
|
||||
odds: [
|
||||
{
|
||||
categoryName: 'Maç Sonucu',
|
||||
selectionName: '1',
|
||||
value: parseFloat(ms1.odd_value),
|
||||
tolerance: 0.2,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return { analysisTactics: tactics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check Python engine health
|
||||
*/
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get(`${this.pythonEngineUrl}/health`, {
|
||||
timeout: 5000,
|
||||
}),
|
||||
);
|
||||
return response.data?.status === 'healthy';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty prediction fallback
|
||||
*/
|
||||
private getEmptyPrediction() {
|
||||
return {
|
||||
predictions: [],
|
||||
recommendedBets: [],
|
||||
valueBets: [],
|
||||
homeAnalysis: null,
|
||||
awayAnalysis: null,
|
||||
expertComment: 'Analiz verisi alınamadı (Python Servis Hatası).',
|
||||
winnerPrediction: 'N/A',
|
||||
scorePrediction: '-',
|
||||
confidenceScore: 0,
|
||||
modelVersion: 'v25.main',
|
||||
expectedGoals: 0,
|
||||
keyInsights: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
Executable
+318
@@ -0,0 +1,318 @@
|
||||
import { Injectable, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { ScraperService, ScrapedData } from './scraper.service';
|
||||
import { AiService, AIPredictionResult } from './ai.service';
|
||||
|
||||
export interface AnalysisResult {
|
||||
aiAnalysis: AIPredictionResult;
|
||||
strategyUsed: { analysisTactics: any[] };
|
||||
similarMatches: any[];
|
||||
matchDetails: any;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MatchAnalysisService {
|
||||
private readonly logger = new Logger(MatchAnalysisService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly scraperService: ScraperService,
|
||||
private readonly aiService: AiService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Main entry point for match analysis
|
||||
*/
|
||||
async analyzeMatch(url: string, userId?: string): Promise<AnalysisResult> {
|
||||
this.logger.log(`Starting analysis for: ${url}`);
|
||||
|
||||
// Phase 1: Parse URL
|
||||
const { matchId, matchSlug, sport } = this.parseUrl(url);
|
||||
if (!matchId) {
|
||||
throw new BadRequestException('Invalid match URL');
|
||||
}
|
||||
|
||||
// Phase 2: Scrape data with retry
|
||||
let scrapedData: ScrapedData;
|
||||
const retryDelays = [3000, 5000, 7000];
|
||||
|
||||
for (let attempt = 0; attempt <= retryDelays.length; attempt++) {
|
||||
try {
|
||||
scrapedData = await this.scraperService.scrapeMatchData(
|
||||
matchId,
|
||||
matchSlug,
|
||||
sport,
|
||||
);
|
||||
break;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 502 && attempt < retryDelays.length) {
|
||||
this.logger.warn(
|
||||
`Scraper returned 502. Retrying in ${retryDelays[attempt] / 1000}s...`,
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, retryDelays[attempt]),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (attempt === retryDelays.length) {
|
||||
throw new BadRequestException(
|
||||
'Maç bilgileri çekilemedi. Lütfen sonra tekrar deneyiniz.',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('Phase 1 Complete: Data Scraped');
|
||||
|
||||
// Phase 3: Call Python AI Engine
|
||||
let pythonAnalysis: AIPredictionResult | null = null;
|
||||
try {
|
||||
const flatOdds = this.scraperService.flattenOdds(scrapedData!.odds);
|
||||
pythonAnalysis = await this.aiService.callPythonEngine(
|
||||
{ ...scrapedData!.matchDetails, matchId },
|
||||
flatOdds,
|
||||
scrapedData!.lineups,
|
||||
scrapedData!.substitutes,
|
||||
scrapedData!.stats,
|
||||
scrapedData!.eventData,
|
||||
);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Python Engine error: ${err.message}`);
|
||||
}
|
||||
|
||||
this.logger.log('Phase 2 Complete: Python Engine Consulted');
|
||||
|
||||
// Phase 4: Generate strategy
|
||||
const matchDNA = await this.aggregateDataForAI(
|
||||
scrapedData!,
|
||||
pythonAnalysis,
|
||||
);
|
||||
const strategy = this.aiService.getAnalysisStrategy(matchDNA);
|
||||
|
||||
this.logger.log(
|
||||
`Phase 3 Complete: Strategy Formulated (${strategy.analysisTactics.length} tactics)`,
|
||||
);
|
||||
|
||||
// Phase 5: Find similar matches
|
||||
const similarMatches = await this.findSimilarMatches(
|
||||
strategy.analysisTactics,
|
||||
matchId,
|
||||
sport,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Phase 4 Complete: Found ${similarMatches.length} similar matches`,
|
||||
);
|
||||
|
||||
// Phase 6: Get final AI prediction
|
||||
const aiPayload = {
|
||||
liveMatchData: {
|
||||
matchDetails: scrapedData!.matchDetails,
|
||||
odds: this.scraperService.flattenOdds(scrapedData!.odds),
|
||||
stats: scrapedData!.stats,
|
||||
eventData: scrapedData!.eventData,
|
||||
lineups: scrapedData!.lineups,
|
||||
pythonEnginePrediction: pythonAnalysis,
|
||||
},
|
||||
historicalEvidence: similarMatches,
|
||||
strategy,
|
||||
};
|
||||
|
||||
const aiAnalysis = await this.aiService.getPredictionForMatch(aiPayload);
|
||||
|
||||
this.logger.log('Phase 5 Complete: Analysis Generated');
|
||||
|
||||
// Phase 7: Save to DB if user provided
|
||||
if (userId) {
|
||||
await this.saveAnalysisResult(userId, matchId, {
|
||||
aiAnalysis,
|
||||
strategyUsed: strategy,
|
||||
similarMatches,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
aiAnalysis,
|
||||
strategyUsed: strategy,
|
||||
similarMatches,
|
||||
matchDetails: scrapedData!.matchDetails,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Mackolik URL
|
||||
*/
|
||||
private parseUrl(url: string): {
|
||||
matchId: string;
|
||||
matchSlug: string;
|
||||
sport: 'football' | 'basketball';
|
||||
} {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathParts = urlObj.pathname.split('/').filter((p) => p.length > 0);
|
||||
let sport: 'football' | 'basketball' = 'football';
|
||||
if (pathParts.includes('basketbol')) sport = 'basketball';
|
||||
const lastPart = pathParts[pathParts.length - 1];
|
||||
const slugPart = pathParts[pathParts.length - 2];
|
||||
return { matchId: lastPart, matchSlug: slugPart, sport };
|
||||
} catch {
|
||||
return { matchId: '', matchSlug: '', sport: 'football' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate data for AI
|
||||
*/
|
||||
private async aggregateDataForAI(
|
||||
scrapedData: ScrapedData,
|
||||
pythonAnalysis: any,
|
||||
): Promise<any> {
|
||||
const flattenedOdds = this.scraperService.flattenOdds(scrapedData.odds);
|
||||
|
||||
// Get comparison data from DB
|
||||
const comparisonData = await this.getComparisonData(
|
||||
scrapedData.matchDetails.homeTeamId,
|
||||
scrapedData.matchDetails.awayTeamId,
|
||||
);
|
||||
|
||||
return {
|
||||
liveMatchData: {
|
||||
matchDetails: scrapedData.matchDetails,
|
||||
odds: flattenedOdds,
|
||||
stats: scrapedData.stats,
|
||||
eventData: scrapedData.eventData,
|
||||
lineups: scrapedData.lineups,
|
||||
comparisonData,
|
||||
pythonEnginePrediction: pythonAnalysis,
|
||||
},
|
||||
tacticalAnalysis: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get head-to-head and form data
|
||||
*/
|
||||
private async getComparisonData(
|
||||
homeTeamId: string,
|
||||
awayTeamId: string,
|
||||
): Promise<any> {
|
||||
try {
|
||||
// H2H matches
|
||||
const h2h = await this.prisma.match.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ homeTeamId, awayTeamId },
|
||||
{ homeTeamId: awayTeamId, awayTeamId: homeTeamId },
|
||||
],
|
||||
state: 'Finished',
|
||||
},
|
||||
orderBy: { mstUtc: 'desc' },
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
matchName: true,
|
||||
scoreHome: true,
|
||||
scoreAway: true,
|
||||
mstUtc: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Home team recent form
|
||||
const homeForm = await this.prisma.match.findMany({
|
||||
where: {
|
||||
OR: [{ homeTeamId }, { awayTeamId: homeTeamId }],
|
||||
state: 'Finished',
|
||||
},
|
||||
orderBy: { mstUtc: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
matchName: true,
|
||||
scoreHome: true,
|
||||
scoreAway: true,
|
||||
homeTeamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Away team recent form
|
||||
const awayForm = await this.prisma.match.findMany({
|
||||
where: {
|
||||
OR: [{ homeTeamId: awayTeamId }, { awayTeamId }],
|
||||
state: 'Finished',
|
||||
},
|
||||
orderBy: { mstUtc: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
matchName: true,
|
||||
scoreHome: true,
|
||||
scoreAway: true,
|
||||
homeTeamId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { h2h, homeForm, awayForm };
|
||||
} catch {
|
||||
return { h2h: [], homeForm: [], awayForm: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar matches based on tactics
|
||||
*/
|
||||
private async findSimilarMatches(
|
||||
tactics: any[],
|
||||
currentMatchId: string,
|
||||
sport: string,
|
||||
): Promise<any[]> {
|
||||
if (!tactics.length) return [];
|
||||
|
||||
try {
|
||||
// Get finished matches with similar odds
|
||||
const matches = await this.prisma.match.findMany({
|
||||
where: {
|
||||
sport: sport as any,
|
||||
id: { not: currentMatchId },
|
||||
scoreHome: { not: null },
|
||||
},
|
||||
take: 50,
|
||||
orderBy: { mstUtc: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
matchName: true,
|
||||
scoreHome: true,
|
||||
scoreAway: true,
|
||||
mstUtc: true,
|
||||
homeTeam: { select: { name: true } },
|
||||
awayTeam: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return matches;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save analysis result
|
||||
*/
|
||||
private async saveAnalysisResult(
|
||||
userId: string,
|
||||
matchId: string,
|
||||
result: any,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.prisma.analysis.create({
|
||||
data: {
|
||||
userId,
|
||||
matchIds: matchId,
|
||||
analysisResultJson: result,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`Failed to save analysis: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+273
@@ -0,0 +1,273 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
export interface ScrapedMatchDetails {
|
||||
homeTeam: string;
|
||||
awayTeam: string;
|
||||
homeTeamId: string;
|
||||
awayTeamId: string;
|
||||
league: string;
|
||||
scoreHome: number | null;
|
||||
scoreAway: number | null;
|
||||
status: string;
|
||||
date: string;
|
||||
mstUtc: number;
|
||||
}
|
||||
|
||||
export interface ScrapedOdds {
|
||||
id: string;
|
||||
name: string;
|
||||
mbc: string;
|
||||
selectionCollection: any[];
|
||||
}
|
||||
|
||||
export interface ScrapedData {
|
||||
matchDetails: ScrapedMatchDetails;
|
||||
odds: ScrapedOdds[];
|
||||
eventData: any[];
|
||||
stats: { home: any; away: any };
|
||||
lineups: { home: any[]; away: any[] };
|
||||
substitutes: { home: any[]; away: any[] } | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ScraperService {
|
||||
private readonly logger = new Logger(ScraperService.name);
|
||||
|
||||
constructor(private readonly httpService: HttpService) {}
|
||||
|
||||
/**
|
||||
* Main scrape method - fetches all match data from Mackolik
|
||||
*/
|
||||
async scrapeMatchData(
|
||||
matchId: string,
|
||||
matchSlug: string,
|
||||
sport: 'football' | 'basketball',
|
||||
): Promise<ScrapedData> {
|
||||
const sportPath = sport === 'basketball' ? 'basketbol/mac' : 'mac';
|
||||
|
||||
const oddsUrl = `https://www.mackolik.com/${sportPath}/${matchSlug}/iddaa/${matchId}`;
|
||||
const infoUrl = `https://www.mackolik.com/${sportPath}/${matchSlug}/${matchId}`;
|
||||
|
||||
try {
|
||||
this.logger.log(`Scraping match data for ${matchId}`);
|
||||
|
||||
// Parallel fetch
|
||||
const [oddsResponse, infoResponse] = await Promise.all([
|
||||
firstValueFrom(this.httpService.get(oddsUrl)),
|
||||
firstValueFrom(this.httpService.get(infoUrl)),
|
||||
]);
|
||||
|
||||
// Parse odds page
|
||||
const oddsSettings = this.extractDataSettings(oddsResponse.data);
|
||||
const odds = this.transformOdds(oddsSettings);
|
||||
|
||||
// Parse info page
|
||||
const infoSettings = this.extractDataSettings(infoResponse.data);
|
||||
const dataLayer = this.extractDataLayer(infoResponse.data);
|
||||
|
||||
// Extract match details
|
||||
const matchDetails = this.extractMatchDetails(infoSettings, dataLayer);
|
||||
|
||||
// Extract events
|
||||
const eventData = this.extractKeyEvents(infoSettings);
|
||||
|
||||
// Extract stats
|
||||
const stats = this.extractGameStats(infoSettings);
|
||||
|
||||
// Extract lineups
|
||||
const lineups = await this.fetchLineups(matchId);
|
||||
|
||||
// Fetch substitutes
|
||||
const substitutes = await this.fetchSubstitutes(matchId);
|
||||
|
||||
return {
|
||||
matchDetails,
|
||||
odds,
|
||||
eventData,
|
||||
stats,
|
||||
lineups,
|
||||
substitutes,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Scrape failed for ${matchId}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all [data-settings] attributes from HTML
|
||||
*/
|
||||
extractDataSettings(html: string): any[] {
|
||||
const $ = cheerio.load(html);
|
||||
const settings: any[] = [];
|
||||
|
||||
$('[data-settings]').each((i, elem) => {
|
||||
const settingsJson = $(elem).attr('data-settings');
|
||||
if (settingsJson) {
|
||||
try {
|
||||
settings.push(JSON.parse(settingsJson));
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract window.dataLayer from HTML
|
||||
*/
|
||||
extractDataLayer(html: string): any {
|
||||
const match = html.match(/window\.dataLayer\s*=\s*(\[[\s\S]*?\]);/);
|
||||
if (match && match[1]) {
|
||||
try {
|
||||
return JSON.parse(match[1])[0];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform odds from data-settings
|
||||
*/
|
||||
private transformOdds(settings: any[]): ScrapedOdds[] {
|
||||
const oddsData = settings.find((s) => s.iddaaEventId?.marketCollection);
|
||||
if (!oddsData) return [];
|
||||
|
||||
const markets = Object.values(oddsData.iddaaEventId.marketCollection);
|
||||
return markets.map((market: any) => ({
|
||||
id: market.id,
|
||||
name: market.name,
|
||||
mbc: market.mbc,
|
||||
selectionCollection: Object.values(market.selectionCollection || {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract match details from dataLayer
|
||||
*/
|
||||
private extractMatchDetails(
|
||||
settings: any[],
|
||||
dataLayer: any,
|
||||
): ScrapedMatchDetails {
|
||||
const matchInfo = settings.find((s) => s.homeTeamName && s.matchId);
|
||||
const matchHeader = settings.find((s) => s.match?.startTime);
|
||||
|
||||
const prf = dataLayer?.prf || {};
|
||||
|
||||
return {
|
||||
homeTeam: matchInfo?.homeTeamName || prf.team1Name || 'Unknown',
|
||||
awayTeam: matchInfo?.awayTeamName || prf.team2Name || 'Unknown',
|
||||
homeTeamId: prf.team1Id?.toString() || '0',
|
||||
awayTeamId: prf.team2Id?.toString() || '0',
|
||||
league: prf.competitionName || 'Unknown',
|
||||
scoreHome: prf.team1Score != null ? parseInt(prf.team1Score) : null,
|
||||
scoreAway: prf.team2Score != null ? parseInt(prf.team2Score) : null,
|
||||
status: this.extractMatchStatus(settings) || 'NS',
|
||||
date: matchHeader?.match?.startTime?.utc
|
||||
? new Date(matchHeader.match.startTime.utc).toISOString()
|
||||
: new Date().toISOString(),
|
||||
mstUtc: matchHeader?.match?.startTime?.utc
|
||||
? new Date(matchHeader.match.startTime.utc).getTime()
|
||||
: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract match status
|
||||
*/
|
||||
private extractMatchStatus(settings: any[]): string {
|
||||
const matchState = settings.find((s) => s.matchState || s.status);
|
||||
return matchState?.matchState || matchState?.status || 'NS';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract key events (goals, cards, etc)
|
||||
*/
|
||||
private extractKeyEvents(settings: any[]): any[] {
|
||||
const keyEventsContainer = settings.find(
|
||||
(s) => s.keyEvents && Array.isArray(s.keyEvents),
|
||||
);
|
||||
return keyEventsContainer?.keyEvents || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract game stats
|
||||
*/
|
||||
private extractGameStats(settings: any[]): { home: any; away: any } {
|
||||
const gameStats = settings.find((s) => s.home?.shotsOnTarget !== undefined);
|
||||
if (gameStats) {
|
||||
return { home: gameStats.home, away: gameStats.away };
|
||||
}
|
||||
return { home: {}, away: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch lineups from AJAX endpoint (İlk 11)
|
||||
*/
|
||||
async fetchLineups(matchId: string): Promise<{ home: any[]; away: any[] }> {
|
||||
try {
|
||||
const url = `https://www.mackolik.com/ajax/football/match-stats?matchId=${matchId}&ajaxViewName=starting-formation&seasonId=${matchId}`;
|
||||
const response = await firstValueFrom(this.httpService.get(url));
|
||||
|
||||
if (response.data?.data?.home && response.data?.data?.away) {
|
||||
return {
|
||||
home: response.data.data.home || [],
|
||||
away: response.data.data.away || [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn(`Could not fetch lineups for ${matchId}`);
|
||||
}
|
||||
|
||||
return { home: [], away: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch substitutes
|
||||
*/
|
||||
async fetchSubstitutes(
|
||||
matchId: string,
|
||||
): Promise<{ home: any[]; away: any[] } | null> {
|
||||
try {
|
||||
const url = `https://www.mackolik.com/ajax/football/match-stats?matchId=${matchId}&ajaxViewName=substitutions`;
|
||||
const response = await firstValueFrom(this.httpService.get(url));
|
||||
|
||||
if (response.data?.data?.stats) {
|
||||
return {
|
||||
home: response.data.data.stats.home || [],
|
||||
away: response.data.data.stats.away || [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn(`Could not fetch substitutes for ${matchId}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten odds for Python engine
|
||||
*/
|
||||
flattenOdds(odds: ScrapedOdds[]): any[] {
|
||||
const result: any[] = [];
|
||||
|
||||
for (const market of odds) {
|
||||
for (const selection of market.selectionCollection) {
|
||||
result.push({
|
||||
category: market.name,
|
||||
selection: selection.name,
|
||||
odd_value: selection.odd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { ScraperService } from './scraper.service';
|
||||
import { AiService } from './ai.service';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { MatchAnalysisService } from './match-analysis.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
HttpModule.register({
|
||||
timeout: 35000,
|
||||
maxRedirects: 5,
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8',
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [ScraperService, MatchAnalysisService, AiService],
|
||||
exports: [ScraperService, MatchAnalysisService, AiService],
|
||||
})
|
||||
export class ServicesModule {}
|
||||
Reference in New Issue
Block a user