first (part 3: src directory)
Deploy Iddaai Backend / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-04-16 15:12:27 +03:00
parent 2f0b85a0c7
commit 182f4aae16
125 changed files with 22552 additions and 0 deletions
+319
View File
@@ -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: [],
};
}
}
+318
View File
@@ -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}`);
}
}
}
+273
View File
@@ -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;
}
}
+25
View File
@@ -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 {}