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
+987
View File
@@ -0,0 +1,987 @@
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
/* eslint-disable @typescript-eslint/no-unused-vars */
/**
* Feeder Persistence Service - Senior Level Implementation
* Database operations using Prisma
*/
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../database/prisma.service';
import {
Sport,
MatchSummary,
Competition,
TransformedPlayer,
MatchParticipation,
TransformedMatchStats,
MatchOfficial,
ParsedMatchHeader,
BasketballPlayerStats,
DbEventPayload,
DbMarketPayload,
BasketballTeamStats,
} from './feeder.types';
import { ImageUtils } from '../../common/utils/image.util';
@Injectable()
export class FeederPersistenceService {
private readonly logger = new Logger(FeederPersistenceService.name);
constructor(private readonly prisma: PrismaService) {}
// ============================================
// HELPER FUNCTIONS
// ============================================
private safeString(value: any): string | null {
return value === null || value === undefined || value === ''
? null
: String(value);
}
private safeInt(value: any): number | null {
const num = parseInt(String(value), 10);
return isNaN(num) ? null : num;
}
private safeFloat(value: any): number | null {
const num = parseFloat(String(value));
return isNaN(num) ? null : num;
}
private mapPositionToEnum(position: string | null): any {
if (!position) return null;
const pos = position.toLowerCase();
if (pos.includes('kaleci') || pos.includes('goalkeeper'))
return 'goalkeeper';
if (pos.includes('defans') || pos.includes('defender')) return 'defender';
if (pos.includes('orta saha') || pos.includes('midfielder'))
return 'midfielder';
if (pos.includes('forvet') || pos.includes('striker')) return 'striker';
return null;
}
// ============================================
// ODDS HELPER (TRANSACTION SAFE)
// ============================================
private async saveOddsInTransaction(
tx: any,
matchId: string,
oddsArray: DbMarketPayload[],
): Promise<void> {
if (oddsArray.length === 0) return;
const existingCategories = await tx.oddCategory.findMany({
where: { matchId },
include: { selections: true },
});
for (const market of oddsArray) {
if (!market || !market.name || !market.selectionCollection) continue;
let category = existingCategories.find((c) => c.name === market.name);
if (!category) {
category = await tx.oddCategory.create({
data: {
matchId,
categoryJsonId: this.safeInt(market.id),
name: market.name,
},
include: { selections: true },
});
existingCategories.push(category);
}
for (const s of market.selectionCollection) {
if (!s || s.odd === '-' || s.odd === '') continue;
const sName = this.safeString(s.name);
const sValue = this.safeString(s.odd);
const sPos = this.safeString(s.position);
if (!sName || !sValue) continue;
const existingSel = category.selections.find(
(sel) => sel.name === sName,
);
if (existingSel) {
if (existingSel.oddValue !== sValue) {
const oldVal = parseFloat(existingSel.oddValue || '0');
const newVal = parseFloat(sValue);
if (!isNaN(oldVal) && !isNaN(newVal)) {
await tx.oddsHistory.create({
data: {
selectionId: existingSel.dbId,
matchId: matchId,
previousValue: oldVal,
newValue: newVal,
},
});
}
await tx.oddSelection.update({
where: { dbId: existingSel.dbId },
data: { oddValue: sValue, position: sPos },
});
}
} else {
const newSel = await tx.oddSelection.create({
data: {
categoryId: category.dbId,
name: sName,
oddValue: sValue,
position: sPos,
},
});
category.selections.push(newSel);
}
}
}
}
// ============================================
// MAIN SAVE FUNCTION
// ============================================
async saveMatch(
sport: Sport,
matchId: string,
matchSummary: MatchSummary,
league: Competition,
homeTeamId: string,
awayTeamId: string,
headerData: ParsedMatchHeader | null,
playersMap: Map<string, TransformedPlayer>,
participationData: MatchParticipation[],
eventData: DbEventPayload[],
stats: TransformedMatchStats | null,
basketballTeamStats: BasketballTeamStats | null,
basketballPlayerStats: Partial<BasketballPlayerStats>[],
oddsArray: DbMarketPayload[],
officialsData: MatchOfficial[],
): Promise<boolean> {
// START IMAGE DOWNLOADS (NON-BLOCKING)
const imageDownloads: Promise<void>[] = [];
const leagueId = this.safeString(league.id);
if (leagueId) {
const logoUrl = `https://file.mackolikfeeds.com/areas/${leagueId}`;
const localPath = `public/uploads/competitions/${leagueId}.png`;
imageDownloads.push(
ImageUtils.downloadImage(logoUrl, localPath)
.then(() => void 0)
.catch((err) => {
this.logger.error(
`Failed to download league logo ${leagueId}: ${err}`,
);
}),
);
}
const teamsToUpsert = [
{
id: homeTeamId,
name: matchSummary.homeTeam?.name || 'Unknown',
slug: matchSummary.homeTeam?.slug || homeTeamId,
sport: sport,
},
{
id: awayTeamId,
name: matchSummary.awayTeam?.name || 'Unknown',
slug: matchSummary.awayTeam?.slug || awayTeamId,
sport: sport,
},
];
for (const team of teamsToUpsert) {
const teamLogoUrl = `https://file.mackolikfeeds.com/teams/${team.id}`;
const teamLocalPath = `public/uploads/teams/${team.id}.png`;
imageDownloads.push(
ImageUtils.downloadImage(teamLogoUrl, teamLocalPath)
.then(() => void 0)
.catch((err) => {
this.logger.error(
`Failed to download team logo ${team.id}: ${err}`,
);
}),
);
}
// DATABASE TRANSACTION
try {
await this.prisma.$transaction(
async (tx) => {
// 1. Save Country
const countryId = this.safeString(league.country?.id);
if (countryId) {
try {
await tx.country.upsert({
where: { id: countryId },
update: {},
create: {
id: countryId,
name: league.country.name || 'Unknown',
},
});
} catch (error: any) {
if (error.code !== 'P2002') throw error;
}
}
// 2. Save League (Handle ID changes by checking unique constraint)
let finalLeagueId = this.safeString(league.id);
if (finalLeagueId && countryId) {
const leagueName = league.name || 'Unknown';
// Check if league exists by unique constraint (name + country + sport)
const existingLeague = await tx.league.findUnique({
where: {
name_countryId_sport: {
name: leagueName,
countryId: countryId,
sport: sport,
},
},
});
if (existingLeague) {
// If exists with different ID, use existing ID to prevent constraint errors
finalLeagueId = existingLeague.id;
} else {
// Create new league
await tx.league.create({
data: {
id: finalLeagueId,
name: leagueName,
countryId: countryId,
sport: sport,
competitionSlug: league.competitionSlug,
logoUrl: `/uploads/competitions/${finalLeagueId}.png`,
},
});
}
}
// 3. Save Teams (BULK OPTIMIZED)
const existingTeams = await tx.team.findMany({
where: {
id: { in: [homeTeamId, awayTeamId] },
},
select: { id: true },
});
const existingTeamIds = new Set(existingTeams.map((t) => t.id));
const teamsToCreate = teamsToUpsert.filter(
(t) => !existingTeamIds.has(t.id),
);
const teamsToUpdate = teamsToUpsert.filter((t) =>
existingTeamIds.has(t.id),
);
if (teamsToCreate.length > 0) {
await tx.team.createMany({
data: teamsToCreate.map((t) => ({
...t,
logoUrl: `/uploads/teams/${t.id}.png`,
})),
skipDuplicates: true,
});
}
for (const team of teamsToUpdate) {
await tx.team.update({
where: { id: team.id },
data: {
name: team.name,
logoUrl: `/uploads/teams/${team.id}.png`,
},
});
}
// 4. Save Match
const finalScoreHome =
headerData?.scoreHome ?? this.safeInt(matchSummary.score?.home);
const finalScoreAway =
headerData?.scoreAway ?? this.safeInt(matchSummary.score?.away);
const htScoreHome =
headerData?.htScoreHome ??
this.safeInt(matchSummary.score?.ht?.home);
const htScoreAway =
headerData?.htScoreAway ??
this.safeInt(matchSummary.score?.ht?.away);
let status = 'NS';
if (headerData?.matchStatus) {
if (
headerData.matchStatus === 'postGame' ||
headerData.matchStatus === 'post'
) {
status = 'FT';
} else if (
headerData.matchStatus === 'live' ||
headerData.matchStatus === 'liveGame'
) {
status = 'LIVE';
}
}
// Handle Postponed Matches (ERT)
if (matchSummary.statusBoxContent === 'ERT') {
status = 'POSTPONED';
}
if (
status === 'NS' &&
finalScoreHome !== null &&
finalScoreAway !== null
) {
status = 'FT';
}
await tx.match.upsert({
where: { id: matchId },
update: {
scoreHome: finalScoreHome,
scoreAway: finalScoreAway,
htScoreHome: htScoreHome,
htScoreAway: htScoreAway,
status: status,
state: headerData?.matchStatus || null,
},
create: {
id: matchId,
leagueId: finalLeagueId || undefined,
homeTeamId: homeTeamId,
awayTeamId: awayTeamId,
sport: sport,
matchName: matchSummary.matchName,
matchSlug: matchSummary.matchSlug,
mstUtc: BigInt(matchSummary.mstUtc || 0),
status: status,
state: headerData?.matchStatus || null,
scoreHome: finalScoreHome,
scoreAway: finalScoreAway,
htScoreHome: htScoreHome,
htScoreAway: htScoreAway,
winner: matchSummary.winner || null,
iddaaCode: this.safeString(matchSummary.iddaaCode),
},
});
// 5. Save Players (BULK OPTIMIZED)
const playersArray = Array.from(playersMap.values());
if (playersArray.length > 0) {
const existingPlayers = await tx.player.findMany({
where: {
id: { in: playersArray.map((p) => p.id) },
},
select: { id: true },
});
const existingPlayerIds = new Set(existingPlayers.map((p) => p.id));
const playersToCreate = playersArray.filter(
(p) => !existingPlayerIds.has(p.id),
);
const playersToUpdate = playersArray.filter((p) =>
existingPlayerIds.has(p.id),
);
if (playersToCreate.length > 0) {
await tx.player.createMany({
data: playersToCreate.map((p) => ({
id: p.id,
name: p.name,
slug: p.slug,
})),
skipDuplicates: true,
});
}
if (playersToUpdate.length > 0) {
await Promise.all(
playersToUpdate.map((p) =>
tx.player.update({
where: { id: p.id },
data: { name: p.name },
}),
),
);
}
}
// 6. Save Participation
if (participationData.length > 0) {
await tx.matchPlayerParticipation.deleteMany({
where: { matchId: matchId },
});
await tx.matchPlayerParticipation.createMany({
data: participationData.map((p) => ({
matchId: p.matchId,
playerId: p.playerId,
teamId: p.teamId,
position: this.mapPositionToEnum(p.position),
shirtNumber: p.shirtNumber,
isStarting: p.isStarting,
})),
skipDuplicates: true,
});
}
// 7. Save Events
if (eventData.length > 0) {
await tx.matchPlayerEvents.deleteMany({
where: { matchId: matchId },
});
await tx.matchPlayerEvents.createMany({
data: eventData.map((e) => ({
matchId: e.match_id,
playerId: e.player_id,
teamId: e.team_id,
eventType: e.event_type,
eventSubtype: e.event_subtype,
timeMinute: e.time_minute,
timeSeconds: e.time_seconds,
periodId: e.period_id,
assistPlayerId: e.assist_player_id,
scoreAfter: e.score_after,
playerOutId: e.player_out_id,
position: e.position,
})),
skipDuplicates: true,
});
}
// 8. Save Team Stats (Football)
if (stats && sport === 'football') {
const statsRows = [
{
matchId,
teamId: homeTeamId,
possessionPercentage: stats.home.possesionPercentage,
shotsOnTarget: stats.home.shotsOnTarget,
shotsOffTarget: stats.home.shotsOffTarget,
totalShots:
(stats.home.shotsOnTarget || 0) +
(stats.home.shotsOffTarget || 0) || null,
totalPasses: stats.home.totalPasses,
corners: stats.home.corners,
fouls: stats.home.fouls,
offsides: stats.home.offsides,
},
{
matchId,
teamId: awayTeamId,
possessionPercentage: stats.away.possesionPercentage,
shotsOnTarget: stats.away.shotsOnTarget,
shotsOffTarget: stats.away.shotsOffTarget,
totalShots:
(stats.away.shotsOnTarget || 0) +
(stats.away.shotsOffTarget || 0) || null,
totalPasses: stats.away.totalPasses,
corners: stats.away.corners,
fouls: stats.away.fouls,
offsides: stats.away.offsides,
},
];
for (const row of statsRows) {
await tx.footballTeamStats.upsert({
where: {
matchId_teamId: { matchId: row.matchId, teamId: row.teamId },
},
update: row,
create: row,
});
}
}
// 8b. Save Team Stats (Basketball)
if (basketballTeamStats && sport === 'basketball') {
const teams = [
{ id: homeTeamId, data: basketballTeamStats.home },
{ id: awayTeamId, data: basketballTeamStats.away },
];
for (const t of teams) {
if (!t.data) continue;
await tx.basketballTeamStats.upsert({
where: {
matchId_teamId: { matchId, teamId: t.id },
},
update: {
points: t.data.points,
rebounds: t.data.rebounds,
assists: t.data.assists,
fgMade: t.data.fgMade,
fgAttempted: t.data.fgAttempted,
threePtMade: t.data.threePtMade,
threePtAttempted: t.data.threePtAttempted,
ftMade: t.data.ftMade,
ftAttempted: t.data.ftAttempted,
steals: t.data.steals,
blocks: t.data.blocks,
turnovers: t.data.turnovers,
fouls: t.data.fouls,
q1Score: t.data.q1,
q2Score: t.data.q2,
q3Score: t.data.q3,
q4Score: t.data.q4,
otScore: t.data.ot,
},
create: {
matchId,
teamId: t.id,
points: t.data.points,
rebounds: t.data.rebounds,
assists: t.data.assists,
fgMade: t.data.fgMade,
fgAttempted: t.data.fgAttempted,
threePtMade: t.data.threePtMade,
threePtAttempted: t.data.threePtAttempted,
ftMade: t.data.ftMade,
ftAttempted: t.data.ftAttempted,
steals: t.data.steals,
blocks: t.data.blocks,
turnovers: t.data.turnovers,
fouls: t.data.fouls,
q1Score: t.data.q1,
q2Score: t.data.q2,
q3Score: t.data.q3,
q4Score: t.data.q4,
otScore: t.data.ot,
},
});
}
}
// 8c. Save Player Stats (Basketball)
if (basketballPlayerStats.length > 0 && sport === 'basketball') {
await tx.basketballPlayerStats.deleteMany({ where: { matchId } });
for (const p of basketballPlayerStats) {
if (!p.id || !p.teamId) continue;
await tx.basketballPlayerStats.create({
data: {
matchId,
playerId: p.id,
teamId: p.teamId,
minutes: p.minutes,
points: p.points,
rebounds: p.rebounds,
assists: p.assists,
fgMade: p.fgMade,
fgAttempted: p.fgAttempted,
threePtMade: p.threePtMade,
threePtAttempted: p.threePtAttempted,
ftMade: p.ftMade,
ftAttempted: p.ftAttempted,
steals: p.steals,
blocks: p.blocks,
turnovers: p.turnovers,
fouls: p.fouls,
},
});
}
}
// 9. Save Odds (USING HELPER)
await this.saveOddsInTransaction(tx, matchId, oddsArray);
// 10. Save Officials
if (sport === 'football' && officialsData.length > 0) {
await tx.matchOfficial.deleteMany({ where: { matchId } });
const processedOfficials = new Set<string>();
for (const o of officialsData) {
const roleName = o.role || 'Referee';
const uniqueKey = `${o.name}_${roleName}`;
if (processedOfficials.has(uniqueKey)) continue;
processedOfficials.add(uniqueKey);
const role = await tx.officialRole.upsert({
where: { name: roleName },
update: {},
create: { name: roleName },
});
await tx.matchOfficial.create({
data: {
matchId,
name: o.name,
roleId: role.id,
},
});
}
}
},
{ maxWait: 40000, timeout: 40000 },
);
// WAIT FOR IMAGES AFTER TRANSACTION
await Promise.allSettled(imageDownloads);
this.logger.log(`✅ SAVED: [${matchId}] ${matchSummary.matchName}`);
return true;
} catch (error: any) {
this.logger.error(`❌ SAVE FAILED [${matchId}]: ${error.message}`);
return false;
}
}
// ============================================
// SELECTIVE UPDATE: LINEUPS ONLY
// ============================================
async saveLineups(
matchId: string,
playersMap: Map<string, TransformedPlayer>,
participationData: MatchParticipation[],
homeTeamId: string,
awayTeamId: string,
): Promise<boolean> {
try {
await this.prisma.$transaction(
async (tx) => {
const matchInMainDb = await tx.match.findUnique({
where: { id: matchId },
select: { id: true },
});
if (matchInMainDb) {
const playersArray = Array.from(playersMap.values());
if (playersArray.length > 0) {
const existingPlayers = await tx.player.findMany({
where: {
id: { in: playersArray.map((p) => p.id) },
},
select: { id: true },
});
const existingPlayerIds = new Set(
existingPlayers.map((p) => p.id),
);
const playersToCreate = playersArray.filter(
(p) => !existingPlayerIds.has(p.id),
);
if (playersToCreate.length > 0) {
await tx.player.createMany({
data: playersToCreate.map((p) => ({
id: p.id,
name: p.name,
slug: p.slug,
})),
skipDuplicates: true,
});
}
}
if (participationData.length > 0) {
await tx.matchPlayerParticipation.deleteMany({
where: { matchId: matchId },
});
await tx.matchPlayerParticipation.createMany({
data: participationData.map((p) => ({
matchId: p.matchId,
playerId: p.playerId,
teamId: p.teamId,
position: this.mapPositionToEnum(p.position),
shirtNumber: p.shirtNumber,
isStarting: p.isStarting,
})),
skipDuplicates: true,
});
}
}
},
{ maxWait: 15000, timeout: 15000 },
);
this.logger.log(`✅ LINEUPS REFRESHED & SYNCED: [${matchId}]`);
return true;
} catch (error: any) {
this.logger.error(`❌ LINEUP SAVE FAILED [${matchId}]: ${error.message}`);
return false;
}
}
// ============================================
// SELECTIVE UPDATE: ODDS ONLY (HISTORY-AWARE)
// ============================================
async saveOdds(
matchId: string,
oddsArray: DbMarketPayload[],
): Promise<boolean> {
try {
await this.prisma.$transaction(
async (tx) => {
// 1. MAIN DB LOGIC
const matchInMainDb = await tx.match.findUnique({
where: { id: matchId },
select: { id: true },
});
if (matchInMainDb && oddsArray.length > 0) {
await this.saveOddsInTransaction(tx, matchId, oddsArray);
}
// 2. LIVE MATCH DB LOGIC
const liveMatch = await tx.liveMatch.findUnique({
where: { id: matchId },
select: { id: true },
});
if (liveMatch && oddsArray.length > 0) {
const oddsJson: Record<string, Record<string, number>> = {};
for (const m of oddsArray) {
oddsJson[m.name] = {};
for (const s of m.selectionCollection) {
const val = parseFloat(s.odd);
if (!isNaN(val)) oddsJson[m.name][s.name] = val;
}
}
await tx.liveMatch.update({
where: { id: matchId },
data: {
odds: oddsJson as any,
oddsUpdatedAt: new Date(),
},
});
}
},
{ maxWait: 15000, timeout: 15000 },
);
this.logger.log(`✅ ODDS REFRESHED: [${matchId}]`);
return true;
} catch (error: any) {
this.logger.error(`❌ ODDS SAVE FAILED [${matchId}]: ${error.message}`);
return false;
}
}
// ============================================
// FULL DATA FETCH FOR AI
// ============================================
async getMatchFullDetails(matchId: string) {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
league: true,
oddCategories: {
include: { selections: true },
},
playerParticipations: {
select: { playerId: true, teamId: true, isStarting: true },
},
},
});
if (!match) return null;
const homeLineup = match.playerParticipations
.filter((p) => p.teamId === match.homeTeamId)
.map((p) => p.playerId);
const awayLineup = match.playerParticipations
.filter((p) => p.teamId === match.awayTeamId)
.map((p) => p.playerId);
const getForm = async (teamId: string) => {
const history = await this.prisma.match.findMany({
where: {
OR: [{ homeTeamId: teamId }, { awayTeamId: teamId }],
status: 'FT',
mstUtc: { lt: match.mstUtc },
},
orderBy: { mstUtc: 'desc' },
take: 5,
});
if (history.length === 0) return { avg_gf: 1.2, avg_ga: 1.2 };
let totalGF = 0;
let totalGA = 0;
for (const m of history) {
if (m.homeTeamId === teamId) {
totalGF += m.scoreHome ?? 0;
totalGA += m.scoreAway ?? 0;
} else {
totalGF += m.scoreAway ?? 0;
totalGA += m.scoreHome ?? 0;
}
}
return {
avg_gf: totalGF / history.length,
avg_ga: totalGA / history.length,
};
};
const homeForm = await getForm(match.homeTeamId!);
const awayForm = await getForm(match.awayTeamId!);
const odds: any[] = [];
for (const cat of match.oddCategories) {
for (const sel of cat.selections) {
odds.push({
category: cat.name,
selection: sel.name,
odd_value: this.safeFloat(sel.oddValue),
});
}
}
return {
match_id: match.id,
home_team: match.homeTeam?.name || 'Unknown',
away_team: match.awayTeam?.name || 'Unknown',
home_team_id: match.homeTeamId,
away_team_id: match.awayTeamId,
league_id: match.leagueId,
league_name: match.league?.name,
date: match.mstUtc.toString(),
score_home: match.scoreHome,
score_away: match.scoreAway,
status: match.status,
odds: odds,
home_form: homeForm,
away_form: awayForm,
home_lineup: homeLineup,
away_lineup: awayLineup,
};
}
// ============================================
// CHECKERS
// ============================================
async matchExists(matchId: string): Promise<boolean> {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
select: { id: true },
});
return !!match;
}
async getExistingMatchIds(matchIds: string[]): Promise<string[]> {
// Only consider matches "existing" if they have ALL key data points
// This allows re-fetching matches that exist but have missing data
const matches = await this.prisma.match.findMany({
where: {
id: { in: matchIds },
AND: [
{ oddCategories: { some: {} } },
{ playerEvents: { some: {} } },
{ officials: { some: {} } },
{
OR: [
{ footballTeamStats: { some: {} } },
{ basketballTeamStats: { some: {} } },
],
},
],
},
select: { id: true },
});
return matches.map((m) => m.id);
}
async hasOdds(matchId: string): Promise<boolean> {
const category = await this.prisma.oddCategory.findFirst({
where: { matchId },
});
if (category) return true;
const live = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: { odds: true },
});
return !!(live?.odds && Object.keys(live.odds as any).length > 0);
}
async getMatch(matchId: string): Promise<any | null> {
const match = await this.prisma.match.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
},
});
if (match) return match;
const liveMatch = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
include: {
homeTeam: true,
awayTeam: true,
league: true,
},
});
if (liveMatch) {
return {
...liveMatch,
leagueId: liveMatch.leagueId,
homeTeamId: liveMatch.homeTeamId,
awayTeamId: liveMatch.awayTeamId,
scoreHome: liveMatch.scoreHome,
scoreAway: liveMatch.scoreAway,
mstUtc: liveMatch.mstUtc,
sport: liveMatch.sport || 'football',
};
}
return null;
}
async getPlayerCount(matchId: string): Promise<number> {
const relationalCount = await this.prisma.matchPlayerParticipation.count({
where: { matchId },
});
if (relationalCount > 0) return relationalCount;
const liveMatch = await this.prisma.liveMatch.findUnique({
where: { id: matchId },
select: { lineups: true },
});
if (liveMatch?.lineups) {
try {
const lineups = liveMatch.lineups as any;
const homeXi = lineups.home?.xi?.length || 0;
const awayXi = lineups.away?.xi?.length || 0;
return homeXi + awayXi;
} catch (e) {
return 0;
}
}
return 0;
}
// ============================================
// STATE MANAGEMENT
// ============================================
async getState(key: string): Promise<string | null> {
const setting = await this.prisma.appSetting.findUnique({
where: { key },
});
return setting?.value || null;
}
async setState(key: string, value: string): Promise<void> {
await this.prisma.appSetting.upsert({
where: { key },
update: { value, updatedAt: new Date() },
create: { key, value },
});
}
}
+746
View File
@@ -0,0 +1,746 @@
/**
* Feeder Scraper Service - Senior Level Implementation
* HTTP requests with exact headers from working curl commands
*/
import { Injectable, Logger } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
import * as cheerio from 'cheerio';
import {
Sport,
SPORTS_CONFIG,
DEFAULT_HEADERS,
DEFAULT_TIMEOUT,
KeyEventsResponse,
MatchStatsResponse,
GameStatsResponse,
ManagerResponse,
IddaaMarketsHtmlResponse,
BasketballBoxScoreResponse,
ParsedMatchHeader,
ParsedMarket,
ParsedSelection,
BasketballPlayerStats,
LivescoresApiResponse,
SidelinedResponse,
SidelinedTeamData,
SidelinedPlayer,
} from './feeder.types';
@Injectable()
export class FeederScraperService {
private readonly logger = new Logger(FeederScraperService.name);
private readonly axios: AxiosInstance;
constructor() {
// Create axios instance with default config
this.axios = axios.create({
headers: DEFAULT_HEADERS,
timeout: DEFAULT_TIMEOUT,
});
// Add response interceptor for logging
this.axios.interceptors.response.use(
(response) => {
this.logger.debug(
`✅ [${response.config.url?.split('?')[0]}] Status: ${response.status}`,
);
return response;
},
(error) => {
const status = error.response?.status || 'N/A';
const url = error.config?.url?.split('?')[0] || 'Unknown';
this.logger.error(`❌ [${url}] Status: ${status} - ${error.message}`);
throw error;
},
);
}
// ============================================
// Historical source endpoint (match list)
// ============================================
async fetchLivescores(
dateString: string,
sport: Sport,
): Promise<LivescoresApiResponse> {
const { sportParam } = SPORTS_CONFIG[sport];
const url = `https://www.mackolik.com/perform/p0/ajax/components/competition/livescores/json`;
this.logger.log(
`📡 [${sport}] Fetching historical source snapshot for ${dateString}`,
);
const response = await this.axios.get(url, {
params: {
'sports[]': sportParam,
matchDate: dateString,
},
});
const payload = response.data as unknown;
if (
!payload ||
typeof payload !== 'object' ||
!('status' in payload) ||
!('data' in payload)
) {
throw new Error('Historical source payload has invalid shape');
}
return payload as LivescoresApiResponse;
}
// ============================================
// MATCH HEADER (Score, Status, HT Score)
// ============================================
async fetchMatchHeader(matchId: string): Promise<ParsedMatchHeader> {
const url = `https://www.mackolik.com/perform/p0/ajax/components/match/matchHeader`;
this.logger.debug(`📡 [${matchId}] Fetching match header`);
const response = await this.axios.get(url, {
params: {
matchId,
sdapiLanguageCode: 'tr-mk',
ajaxViewName: 'match-details',
ajaxPartialViewName: 'match-details-status',
displayMode: 'all',
},
});
return this.parseMatchHeader(response.data.data?.html || '');
}
private parseMatchHeader(html: string): ParsedMatchHeader {
const $ = cheerio.load(html);
// Extract match-status from data attribute
const matchStatus =
($('[data-match-status]').attr('data-match-status') as any) || 'postGame';
// Extract scores
const scoreHome = this.safeInt($('[data-slot="score-home"]').text().trim());
const scoreAway = this.safeInt($('[data-slot="score-away"]').text().trim());
// Extract HT score from detailed score (İY X - X)
let htScoreHome: number | null = null;
let htScoreAway: number | null = null;
const detailedScore = $('.p0c-soccer-match-details-header__detailed-score')
.text()
.trim();
const htMatch = detailedScore.match(/\(İY\s*(\d+)\s*-\s*(\d+)\)/);
if (htMatch) {
htScoreHome = parseInt(htMatch[1], 10);
htScoreAway = parseInt(htMatch[2], 10);
}
return { matchStatus, scoreHome, scoreAway, htScoreHome, htScoreAway };
}
// ============================================
// KEY EVENTS (Goals, Cards, Substitutes)
// ============================================
async fetchKeyEvents(
matchId: string,
): Promise<KeyEventsResponse['data'] | null> {
const url = `https://www.mackolik.com/ajax/football/key-events`;
this.logger.debug(`📡 [${matchId}] Fetching key events`);
try {
const response = await this.axios.get<KeyEventsResponse>(url, {
params: {
ajaxViewName: 'events',
matchId,
seasonId: matchId, // Same as matchId
},
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Key events not found (404)`);
return null;
}
throw error;
}
}
// ============================================
// MATCH STATS - STARTING FORMATION (İlk 11)
// ============================================
async fetchStartingFormation(
matchId: string,
): Promise<MatchStatsResponse['data'] | null> {
const url = `https://www.mackolik.com/ajax/football/match-stats`;
this.logger.debug(`📡 [${matchId}] Fetching starting formation`);
try {
const response = await this.axios.get<MatchStatsResponse>(url, {
params: {
ajaxViewName: 'starting-formation',
matchId,
seasonId: matchId,
},
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Starting formation not found (404)`);
return null;
}
throw error;
}
}
// ============================================
// MATCH STATS - SUBSTITUTIONS (Yedekler)
// ============================================
async fetchSubstitutions(
matchId: string,
): Promise<MatchStatsResponse['data'] | null> {
const url = `https://www.mackolik.com/ajax/football/match-stats`;
this.logger.debug(`📡 [${matchId}] Fetching substitutions`);
try {
const response = await this.axios.get<MatchStatsResponse>(url, {
params: {
ajaxViewName: 'substitutions',
matchId,
seasonId: matchId,
},
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Substitutions not found (404)`);
return null;
}
throw error;
}
}
// ============================================
// GAME STATS (Possession, Shots, Passes)
// ============================================
async fetchGameStats(
matchId: string,
): Promise<GameStatsResponse['data'] | null> {
const url = `https://www.mackolik.com/ajax/soccer/match/gameStats`;
this.logger.debug(`📡 [${matchId}] Fetching game stats`);
try {
const response = await this.axios.get<GameStatsResponse>(url, {
params: { matchId },
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Game stats not found (404)`);
return null;
}
throw error;
}
}
// ============================================
// MANAGER
// ============================================
async fetchManager(matchId: string): Promise<ManagerResponse['data'] | null> {
const url = `https://www.mackolik.com/ajax/football/match-stats`;
this.logger.debug(`📡 [${matchId}] Fetching manager`);
try {
const response = await this.axios.get<ManagerResponse>(url, {
params: {
ajaxViewName: 'manager',
matchId,
seasonId: matchId,
},
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Manager not found (404)`);
return null;
}
throw error;
}
}
// ============================================
// IDDAA MARKETS (HTML with odds + names)
// ============================================
async fetchIddaaMarkets(matchId: string): Promise<ParsedMarket[]> {
const url = `https://www.mackolik.com/ajax/iddaa/markets/soccer/all/${matchId}`;
this.logger.debug(`📡 [${matchId}] Fetching iddaa markets`);
try {
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
params: { template: 'all' },
});
return this.parseIddaaMarketsHtml(response.data.data?.html || '');
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Iddaa markets not found (404)`);
return [];
}
throw error;
}
}
private parseIddaaMarketsHtml(html: string): ParsedMarket[] {
if (!html) return [];
const $ = cheerio.load(html);
const markets: ParsedMarket[] = [];
$('.widget-iddaa-markets__market-item').each((_, marketEl) => {
const $market = $(marketEl);
const marketId = $market.attr('data-market') || '';
const marketName = $market
.find('.widget-iddaa-markets__header-text')
.text()
.trim();
const iddaaCode = $market
.find('.widget-iddaa-markets__iddaa-code')
.text()
.trim();
const mbc = $market.find('.widget-iddaa-markets__mbc').text().trim();
const selections: ParsedSelection[] = [];
$market.find('.widget-iddaa-markets__option').each((_, optionEl) => {
const $option = $(optionEl);
selections.push({
shortcode: $option.attr('data-shortcode') || '',
outcomeNo: $option.attr('data-outcome-no') || '',
label: $option.find('.widget-iddaa-markets__label').text().trim(),
value: $option.find('.widget-iddaa-markets__value').text().trim(),
});
});
if (marketId && marketName) {
markets.push({ marketId, marketName, iddaaCode, mbc, selections });
}
});
this.logger.debug(`Parsed ${markets.length} iddaa markets`);
return markets;
}
// ============================================
// BASKETBALL BOX SCORE
// ============================================
async fetchBasketballBoxScore(
matchId: string,
): Promise<BasketballBoxScoreResponse['data'] | null> {
// Updated URL based on user request
const url = `https://www.mackolik.com/ajax/basketball/match/box-score`;
this.logger.debug(`📡 [${matchId}] Fetching basketball box score`);
try {
const response = await this.axios.get<BasketballBoxScoreResponse>(url, {
params: { matchId },
headers: {
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': DEFAULT_HEADERS['User-Agent'],
},
});
return response.data.data;
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Basketball box score not found (404)`);
return null;
}
throw error;
}
}
parseBasketballBoxScore(html: string): {
teamTotals: any;
players: Partial<BasketballPlayerStats>[];
} {
if (!html) return { teamTotals: {}, players: [] };
const $ = cheerio.load(html);
const players: Partial<BasketballPlayerStats>[] = [];
// Parse individual players from widget rows
$('.widget-basketball-match-box-score__row').each((_, elem) => {
const row = $(elem);
// Skip if no player name found
const nameElem = row.find('.widget-basketball-match-box-score__player');
if (!nameElem.length) return;
const name = nameElem.text().trim();
// Indices based on User HTML:
// 0: Name, 1: Min, 2: Pts, 3: Reb, 4: Ast, 5: 2FG, 6: 3FG, 7: FT, 8: Fouls, 9: Blk, 10: Stl, 11: TO
const values = row.find('td');
// Check if it's a valid player row (should have enough columns)
if (values.length < 10) return;
// Extract ID from link if possible
let playerId = '';
const link = nameElem.find('a').attr('href');
if (link) {
playerId = this.extractPlayerIdFromUrl(link) || '';
}
players.push({
id: playerId, // Will be generated if empty later
name,
minutes: values.eq(1).text().trim(),
points: this.safeInt(values.eq(2).text().trim()) || 0,
rebounds: this.safeInt(values.eq(3).text().trim()) || 0,
assists: this.safeInt(values.eq(4).text().trim()) || 0,
fgMade: this.safeInt(values.eq(5).text().trim().split('/')[0]) || 0,
fgAttempted:
this.safeInt(values.eq(5).text().trim().split('/')[1]) || 0,
threePtMade:
this.safeInt(values.eq(6).text().trim().split('/')[0]) || 0,
threePtAttempted:
this.safeInt(values.eq(6).text().trim().split('/')[1]) || 0,
ftMade: this.safeInt(values.eq(7).text().trim().split('/')[0]) || 0,
ftAttempted:
this.safeInt(values.eq(7).text().trim().split('/')[1]) || 0,
fouls: this.safeInt(values.eq(8).text().trim()) || 0,
blocks: this.safeInt(values.eq(9).text().trim()) || 0,
steals: this.safeInt(values.eq(10).text().trim()) || 0,
turnovers: this.safeInt(values.eq(11).text().trim()) || 0,
});
});
// Parse Team Totals from Footer
const footerRow = $('.widget-basketball-match-box-score__footer td');
let teamTotals: any = {};
if (footerRow.length > 5) {
// Indices shift because first cells might be empty matchers
// usually index 2 matches Points column
teamTotals = {
points: this.safeInt(footerRow.eq(2).text().trim()) || 0,
rebounds: this.safeInt(footerRow.eq(3).text().trim()) || 0,
assists: this.safeInt(footerRow.eq(4).text().trim()) || 0,
fgMade: this.safeInt(footerRow.eq(5).text().trim().split('/')[0]) || 0,
fgAttempted:
this.safeInt(footerRow.eq(5).text().trim().split('/')[1]) || 0,
threePtMade:
this.safeInt(footerRow.eq(6).text().trim().split('/')[0]) || 0,
threePtAttempted:
this.safeInt(footerRow.eq(6).text().trim().split('/')[1]) || 0,
ftMade: this.safeInt(footerRow.eq(7).text().trim().split('/')[0]) || 0,
ftAttempted:
this.safeInt(footerRow.eq(7).text().trim().split('/')[1]) || 0,
fouls: this.safeInt(footerRow.eq(8).text().trim()) || 0,
blocks: this.safeInt(footerRow.eq(9).text().trim()) || 0,
steals: this.safeInt(footerRow.eq(10).text().trim()) || 0,
turnovers: this.safeInt(footerRow.eq(11).text().trim()) || 0,
};
}
return { teamTotals, players };
}
// ============================================
// MATCH PAGE (Main page for officials parsing)
// ============================================
async fetchMatchPage(
matchId: string,
matchSlug: string,
sport: Sport,
): Promise<string> {
const { iddaaUrlPath } = SPORTS_CONFIG[sport];
const url = `https://www.mackolik.com/${iddaaUrlPath}/${matchSlug}/${matchId}`;
this.logger.debug(`📡 [${matchId}] Fetching match page`);
// For HTML pages, we DON'T send X-Requested-With header
const response = await this.axios.get(url, {
headers: {
'User-Agent': DEFAULT_HEADERS['User-Agent'],
Referer: DEFAULT_HEADERS['Referer'],
'Accept-Language': DEFAULT_HEADERS['Accept-Language'],
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
// NO X-Requested-With for HTML pages!
},
});
return response.data;
}
// ============================================
// HELPER FUNCTIONS
// ============================================
private safeInt(value: string | undefined): number | null {
if (!value) return null;
const num = parseInt(value, 10);
return isNaN(num) ? null : num;
}
// ============================================
// BASKETBALL DETAILS HEADER (Quarter Scores)
// ============================================
async fetchBasketballDetailsHeader(matchId: string): Promise<any> {
const url = `https://www.mackolik.com/ajax/basketball/match/details-header`;
this.logger.debug(`📡 [${matchId}] Fetching basketball details header`);
try {
const response = await this.axios.get(url, {
params: { matchId },
headers: {
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': DEFAULT_HEADERS['User-Agent'],
},
});
if (response.data?.data?.views?.scoreDetails?.html) {
return this.parseBasketballDetailsHeader(
response.data.data.views.scoreDetails.html,
);
}
return null;
} catch (error: any) {
// 404 is acceptable
if (error.response?.status === 404) return null;
throw error;
}
}
private parseBasketballDetailsHeader(
html: string,
): { home: any; away: any } | null {
if (!html) return null;
const $ = cheerio.load(html);
const rows = $(
'.widget-basketball-match-details-header__score-details tbody tr',
);
if (rows.length < 2) return null;
const parseRow = (row: any) => {
const cols = $(row).find('td');
// Format: TeamName, Q1, Q2, Q3, Q4, Final
// Values are inside .widget-basketball-match-details-header__score-part (just the quarter score)
// or direct text if simple table.
// User HTML shows: <span class="...score-part"> 33 </span>
const getScore = (index: number) => {
const cell = cols.eq(index);
const part = cell.find(
'.widget-basketball-match-details-header__score-part',
);
const val = part.length ? part.text() : cell.text();
return this.safeInt(val.trim());
};
return {
q1: getScore(1),
q2: getScore(2),
q3: getScore(3),
q4: getScore(4),
// If there's OT, it would be column 5, and Final column 6?
// Standard 4 quarters: Col 1,2,3,4. Col 5 is Final.
// If 5 cols (+name), logic holds.
// Let's assume standard for now.
};
};
return {
home: parseRow(rows[0]),
away: parseRow(rows[1]),
};
}
// ============================================
// BASKETBALL MARKETS (Odds)
// ============================================
async fetchBasketballMarkets(matchId: string): Promise<ParsedMarket[]> {
// User provided URL structure: /ajax/iddaa/markets/basketball/all/{matchId}?template=all
const url = `https://www.mackolik.com/ajax/iddaa/markets/basketball/all/${matchId}`;
this.logger.debug(`📡 [${matchId}] Fetching basketball markets`);
try {
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
params: { template: 'all' },
headers: {
'X-Requested-With': 'XMLHttpRequest',
'User-Agent': DEFAULT_HEADERS['User-Agent'],
},
});
if (response.data?.data?.html) {
return this.parseIddaaMarketsHtml(response.data.data.html);
}
return [];
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Basketball markets not found (404)`);
return [];
}
throw error;
}
}
extractPlayerIdFromUrl(url: string | undefined): string | null {
if (!url) return null;
const parts = url.split('/');
return parts[parts.length - 1] || null;
}
// ============================================
// SIDELINED PLAYERS (Injuries & Suspensions)
// ============================================
async fetchSidelinedPlayers(
matchId: string,
matchSlug: string,
): Promise<SidelinedResponse | null> {
const url = `https://www.mackolik.com/mac/${matchSlug}/${matchId}`;
this.logger.debug(`📡 [${matchId}] Fetching sidelined players`);
try {
const response = await this.axios.get(url, {
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7',
Referer: 'https://www.mackolik.com',
},
timeout: 10000,
});
const $ = cheerio.load(response.data);
return {
homeTeam: this._parseSidelinedSection($, 0),
awayTeam: this._parseSidelinedSection($, 1),
};
} catch (error: any) {
if (error.response?.status === 404) {
this.logger.warn(`[${matchId}] Match page not found (404)`);
return null;
}
this.logger.warn(
`[${matchId}] Sidelined fetch warning: ${error.message}`,
);
return null;
}
}
private _parseSidelinedSection(
$: cheerio.CheerioAPI,
teamIndex: number,
): SidelinedTeamData {
const sidelinedWidgets = $('.widget-sidelined-players');
if (sidelinedWidgets.length <= teamIndex) {
return { teamName: '', teamId: '', totalSidelined: 0, players: [] };
}
const widget = sidelinedWidgets.eq(teamIndex);
const teamCrest = widget.find('.widget-sidelined-players__header-crest');
const teamCrestSrc = teamCrest.attr('src') || '';
const teamId = teamCrestSrc.split('/').pop() || '';
const teamName = widget
.find('.widget-sidelined-players__header-text')
.text()
.trim();
const players: SidelinedPlayer[] = [];
widget.find('.widget-sidelined-players__item').each((_, element) => {
const playerData = this._parsePlayerItem($, $(element));
if (playerData) {
players.push(playerData);
}
});
return {
teamName,
teamId,
totalSidelined: players.length,
players,
};
}
private _parsePlayerItem(
$: cheerio.CheerioAPI,
$item: cheerio.Cheerio<any>,
): SidelinedPlayer | null {
try {
const nameElem = $item.find('.widget-sidelined-players__name');
const playerName = nameElem.text().trim();
const playerUrl = nameElem.attr('href') || '';
const playerId = playerUrl.split('/').pop() || '';
const positionElem = $item.find('.widget-sidelined-players__position');
const position = positionElem.attr('title') || '';
const positionShort = positionElem.text().trim();
const reasonImg = $item.find('.widget-sidelined-players__reason img');
const reasonIcon = reasonImg.attr('src') || '';
const numbers = $item.find('.widget-sidelined-players__number');
// Use parseInt EXACTLY as in JS script (ignoring potential NaN for now, will handle via helper if needed but safer to stick to script logic first)
const matchesMissedText =
numbers.length > 0 ? numbers.eq(0).text().trim() : '';
const matchesMissed = matchesMissedText
? parseInt(matchesMissedText, 10)
: null;
const averageText = numbers.length > 1 ? numbers.eq(1).text().trim() : '';
const average = averageText ? parseInt(averageText, 10) : null;
const description = $item
.find('.widget-sidelined-players__value')
.text()
.trim();
const type = reasonIcon.includes('shortage_1.png')
? 'injury'
: reasonIcon.includes('suspension')
? 'suspension'
: 'other';
return {
playerId,
playerName,
playerUrl: playerUrl.startsWith('http')
? playerUrl
: `https://www.mackolik.com${playerUrl}`,
position,
positionShort,
type,
description,
matchesMissed: isNaN(matchesMissed as number) ? null : matchesMissed,
average: isNaN(average as number) ? null : average,
reasonIcon: reasonIcon.startsWith('http')
? reasonIcon
: `https://www.mackolik.com${reasonIcon}`, // Keep safer URL construction but stick closer to logic
};
} catch {
return null;
}
}
}
+359
View File
@@ -0,0 +1,359 @@
/**
* Feeder Transformer Service - Senior Level Implementation
* Transforms raw API data into database-ready formats
*/
import { Injectable, Logger } from '@nestjs/common';
import * as cheerio from 'cheerio';
import {
RawKeyEvent,
TransformedEvent,
RawPlayerStats,
TransformedPlayer,
MatchParticipation,
TransformedMatchStats,
ParsedMarket,
MatchOfficial,
MatchState,
GameStatsResponse,
DbEventPayload,
DbMarketPayload,
} from './feeder.types';
@Injectable()
export class FeederTransformerService {
private readonly logger = new Logger(FeederTransformerService.name);
// ============================================
// HELPER FUNCTIONS
// ============================================
private safeString(value: any): string | null {
return value === null || value === undefined || value === ''
? null
: String(value);
}
private safeInt(value: any): number | null {
const num = parseInt(String(value), 10);
return isNaN(num) ? null : num;
}
private safeFloat(value: any): number | null {
const num = parseFloat(String(value));
return isNaN(num) ? null : num;
}
private extractPlayerIdFromUrl(url: string | undefined): string | null {
if (!url) return null;
const parts = url.split('/');
return parts[parts.length - 1] || null;
}
// ============================================
// KEY EVENTS TRANSFORMER
// ============================================
transformKeyEvents(
rawEvents: RawKeyEvent[],
homeTeamId: string,
awayTeamId: string,
matchId: string,
): TransformedEvent[] {
return rawEvents.map((e) => {
const playerId = this.extractPlayerIdFromUrl(e.playerUrl) || '';
const assistPlayerId = e.assistPlayerUrl
? this.extractPlayerIdFromUrl(e.assistPlayerUrl)
: null;
const playerOutId = e.playerOutUrl
? this.extractPlayerIdFromUrl(e.playerOutUrl)
: null;
// Determine event type
let eventType: 'goal' | 'card' | 'substitute' | 'other' = 'other';
if (e.type === 'goal') eventType = 'goal';
else if (e.type === 'card') eventType = 'card';
else if (e.type === 'substitute') eventType = 'substitute';
return {
matchId,
playerId,
playerName: e.playerName,
teamId: e.position === 'home' ? homeTeamId : awayTeamId,
eventType,
eventSubtype: e.subType || null,
timeMinute: e.timeMin,
timeSeconds: e.seconds,
periodId: e.periodId,
assistPlayerId,
assistPlayerName: e.assistPlayerName || null,
scoreAfter: e.score || null,
playerOutId,
playerOutName: e.playerOutName || null,
position: e.position,
};
});
}
// ============================================
// LINEUP PROCESSOR
// ============================================
processLineup(
players: RawPlayerStats[],
teamId: string,
isStarting: boolean,
matchId: string,
playersMap: Map<string, TransformedPlayer>,
participationData: MatchParticipation[],
): void {
if (!players || !Array.isArray(players)) return;
players.forEach((p) => {
const playerId = this.safeString(p.personId);
const playerName = this.safeString(p.matchName);
if (playerId && playerName) {
// Add to players map (for players table insert)
playersMap.set(playerId, {
id: playerId,
name: playerName,
slug: playerId,
teamId,
});
// Add participation record
participationData.push({
matchId,
playerId,
teamId,
position: this.safeString(p.position),
shirtNumber: this.safeInt(p.shirtNumber),
isStarting,
});
}
});
}
// ============================================
// GAME STATS TRANSFORMER
// ============================================
transformGameStats(
data: GameStatsResponse['data'] | null,
): TransformedMatchStats | null {
if (!data || !data.home) return null;
// Away possession can be calculated if not provided
const awayPossession: number | undefined =
data.away.possesionPercentage ??
(data.home.possesionPercentage
? 100 - data.home.possesionPercentage
: undefined);
return {
home: {
possesionPercentage: data.home.possesionPercentage,
shotsOnTarget: data.home.shotsOnTarget,
shotsOffTarget: data.home.shotsOffTarget,
totalPasses: data.home.totalPasses,
corners: data.home.corners,
fouls: data.home.fouls,
offsides: data.home.offsides,
},
away: {
possesionPercentage: awayPossession,
shotsOnTarget: data.away.shotsOnTarget,
shotsOffTarget: data.away.shotsOffTarget,
totalPasses: data.away.totalPasses,
corners: data.away.corners,
fouls: data.away.fouls,
offsides: data.away.offsides,
},
};
}
// ============================================
// MATCH STATE TO STATUS MAPPER
// ============================================
mapMatchStateToStatus(state: MatchState | undefined): string {
if (!state) return 'NS';
switch (state) {
case 'postGame':
case 'post':
return 'FT';
case 'preGame':
case 'pre':
return 'NS';
case 'live':
case 'liveGame':
return 'LIVE';
default:
return 'NS';
}
}
// ============================================
// OFFICIALS PARSER (from match page HTML)
// ============================================
parseOfficials(html: string): MatchOfficial[] {
if (!html) return [];
const $ = cheerio.load(html);
const officials: MatchOfficial[] = [];
// Try standard officials component
$('.p0c-match-officials__official-list-item').each((_, elem) => {
const name = $(elem)
.find('.p0c-match-officials__official-name')
.text()
.trim();
const role = $(elem)
.find('.p0c-match-officials__official-group-title')
.text()
.trim();
if (name) {
officials.push({ name, role: role || 'Referee' });
}
});
// Fallback: look for referee info in match info section
if (officials.length === 0) {
// Try alternative selectors
$('.widget-match-info__referee-name, .referee-name').each((_, elem) => {
const name = $(elem).text().trim();
if (name) {
officials.push({ name, role: 'Referee' });
}
});
}
return officials;
}
// ============================================
// IDDAA MARKETS TRANSFORMER
// For converting ParsedMarket[] to database format
// ============================================
transformIddaaMarkets(markets: ParsedMarket[]): DbMarketPayload[] {
return markets.map((market) => ({
id: market.marketId,
name: market.marketName,
iddaaCode: market.iddaaCode,
mbc: market.mbc,
selectionCollection: market.selections.map((s) => ({
shortcode: s.shortcode,
name: s.label,
odd: s.value,
position: s.outcomeNo,
})),
}));
}
/**
* Helper to convert ParsedMarket[] to LiveMatch.odds structure
* Useful for quick JSON storage
*/
transformToOddsJson(
markets: DbMarketPayload[],
): Record<string, Record<string, number>> {
const odds: Record<string, Record<string, number>> = {};
for (const market of markets) {
if (!market.name || !market.selectionCollection) continue;
const marketName = market.name;
odds[marketName] = {};
for (const sel of market.selectionCollection) {
const val = parseFloat(sel.odd);
if (sel.name && !isNaN(val)) {
odds[marketName][sel.name] = val;
}
}
}
return odds;
}
// ============================================
// EXTRACT PLAYERS FROM EVENTS
// (for adding to players map)
// ============================================
extractPlayersFromEvents(
events: TransformedEvent[],
playersMap: Map<string, TransformedPlayer>,
): void {
events.forEach((event) => {
// Main player
if (
event.playerId &&
event.playerName &&
!playersMap.has(event.playerId)
) {
playersMap.set(event.playerId, {
id: event.playerId,
name: event.playerName,
slug: event.playerId,
});
}
// Assist player
if (
event.assistPlayerId &&
event.assistPlayerName &&
!playersMap.has(event.assistPlayerId)
) {
playersMap.set(event.assistPlayerId, {
id: event.assistPlayerId,
name: event.assistPlayerName,
slug: event.assistPlayerId,
});
}
// Player out (substitution)
if (
event.playerOutId &&
event.playerOutName &&
!playersMap.has(event.playerOutId)
) {
playersMap.set(event.playerOutId, {
id: event.playerOutId,
name: event.playerOutName,
slug: event.playerOutId,
});
}
});
}
// ============================================
// PREPARE EVENT DATA FOR DATABASE
// ============================================
prepareEventDataForDb(events: TransformedEvent[]): DbEventPayload[] {
return events
.filter(
(
e,
): e is TransformedEvent & {
eventType: 'goal' | 'card' | 'substitute';
} => e.eventType !== 'other' && !!e.playerId,
)
.map((e) => ({
match_id: e.matchId,
player_id: e.playerId,
team_id: e.teamId,
event_type: e.eventType,
event_subtype: e.eventSubtype,
time_minute: e.timeMinute,
time_seconds: e.timeSeconds,
period_id: e.periodId,
assist_player_id: e.assistPlayerId,
score_after: e.scoreAfter,
player_out_id: e.playerOutId,
position: e.position,
}));
}
// ============================================
// BASKETBALL PLAYER ID GENERATOR
// ============================================
generateBasketballPlayerId(teamId: string, playerName: string): string {
return `${teamId}-${playerName.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase()}`;
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Feeder Module - Senior Level Implementation
*/
import { Module } from '@nestjs/common';
import { FeederService } from './feeder.service';
import { FeederScraperService } from './feeder-scraper.service';
import { FeederTransformerService } from './feeder-transformer.service';
import { FeederPersistenceService } from './feeder-persistence.service';
import { DatabaseModule } from '../../database/database.module';
@Module({
imports: [DatabaseModule],
providers: [
FeederService,
FeederScraperService,
FeederTransformerService,
FeederPersistenceService,
],
exports: [FeederService, FeederScraperService, FeederPersistenceService],
})
export class FeederModule {}
+994
View File
@@ -0,0 +1,994 @@
/**
* Feeder Service - Senior Level Implementation
* Main orchestration service for historical data scanning
*/
import { Injectable, Logger } from '@nestjs/common';
import { FeederScraperService } from './feeder-scraper.service';
import { FeederTransformerService } from './feeder-transformer.service';
import { FeederPersistenceService } from './feeder-persistence.service';
import {
Sport,
MatchSummary,
Competition,
LivescoresApiResponse,
TransformedPlayer,
MatchParticipation,
ProcessResult,
BasketballPlayerStats,
BasketballTeamStats,
TransformedMatchStats,
MatchOfficial,
ParsedMatchHeader,
ParsedMarket,
DbEventPayload,
DbMarketPayload,
} from './feeder.types';
interface ProcessDateOptions {
onlyCompletedMatches?: boolean;
refreshExistingMatches?: boolean;
}
@Injectable()
export class FeederService {
private readonly logger = new Logger(FeederService.name);
// Configuration - Adjust these based on rate limiting behavior
private readonly CONCURRENCY_LIMIT = 20; // Increased for maximum speed on EC2
private readonly REQUEST_DELAY_MS = 50; // Minimal delay to respect basics
private readonly HISTORICAL_START_DATE = '2023-06-01'; // 2 years of data
private readonly SPORTS: Sport[] = ['football', 'basketball'];
private readonly MAX_RETRIES = 50;
private readonly DAILY_SYNC_TIME_ZONE = 'Europe/Istanbul';
constructor(
private readonly scraperService: FeederScraperService,
private readonly transformerService: FeederTransformerService,
private readonly persistenceService: FeederPersistenceService,
) {}
// ============================================
// DELAY HELPER
// ============================================
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private getYesterdayDateString(timeZone: string): string {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = formatter.formatToParts(new Date());
const year = Number(parts.find((part) => part.type === 'year')?.value);
const month = Number(parts.find((part) => part.type === 'month')?.value);
const day = Number(parts.find((part) => part.type === 'day')?.value);
const tzMidnightUtc = new Date(Date.UTC(year, month - 1, day));
tzMidnightUtc.setUTCDate(tzMidnightUtc.getUTCDate() - 1);
return tzMidnightUtc.toISOString().split('T')[0];
}
private getTimeZoneOffsetMs(date: Date, timeZone: string): number {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
timeZoneName: 'shortOffset',
});
const offsetLabel =
formatter.formatToParts(date).find((part) => part.type === 'timeZoneName')
?.value || 'GMT+0';
const match = offsetLabel.match(/GMT([+-])(\d{1,2})(?::?(\d{2}))?/);
if (!match) return 0;
const sign = match[1] === '-' ? -1 : 1;
const hours = Number(match[2] || '0');
const minutes = Number(match[3] || '0');
return sign * (hours * 60 + minutes) * 60 * 1000;
}
private getDayBoundsForTimeZone(
dateString: string,
timeZone: string,
): { startTs: number; endTs: number } {
const [year, month, day] = dateString.split('-').map(Number);
const startGuess = new Date(Date.UTC(year, month - 1, day, 0, 0, 0));
const nextDayGuess = new Date(
Date.UTC(year, month - 1, day + 1, 0, 0, 0),
);
const startOffsetMs = this.getTimeZoneOffsetMs(startGuess, timeZone);
const nextDayOffsetMs = this.getTimeZoneOffsetMs(nextDayGuess, timeZone);
const startMs =
Date.UTC(year, month - 1, day, 0, 0, 0) - startOffsetMs;
const nextDayStartMs =
Date.UTC(year, month - 1, day + 1, 0, 0, 0) - nextDayOffsetMs;
return {
startTs: Math.floor(startMs / 1000),
endTs: Math.floor((nextDayStartMs - 1) / 1000),
};
}
private parseScoreValue(value: unknown): number | null {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
private isCompletedMatchSummary(match: MatchSummary): boolean {
if (match.statusBoxContent === 'ERT') return false;
const normalizedState = String(match.state || '')
.trim()
.toLowerCase();
const normalizedStatus = String(match.status || '')
.trim()
.toLowerCase();
const normalizedSubstate = String(match.substate || '')
.trim()
.toLowerCase();
if (['postgame', 'post'].includes(normalizedState)) return true;
if (
['played', 'finished', 'ft', 'afterpenalties', 'penalties'].includes(
normalizedStatus,
)
) {
return true;
}
if (['postgame', 'post', 'played', 'finished', 'ft'].includes(normalizedSubstate)) {
return true;
}
const homeScore = this.parseScoreValue(
match.score?.home ?? match.homeScore,
);
const awayScore = this.parseScoreValue(
match.score?.away ?? match.awayScore,
);
return homeScore !== null && awayScore !== null;
}
async runPreviousDayCompletedMatchesScan(
sports: Sport[] = this.SPORTS,
targetDateStr: string = this.getYesterdayDateString(
this.DAILY_SYNC_TIME_ZONE,
),
targetLeagueIds: string[] = [],
): Promise<void> {
this.logger.log(
`🗓️ STARTING DAILY COMPLETED MATCH SYNC [Date: ${targetDateStr}] [Sports: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`,
);
for (const sport of sports) {
await this.processDate(targetDateStr, sport, targetLeagueIds, {
onlyCompletedMatches: true,
refreshExistingMatches: true,
});
}
this.logger.log(
`✅ DAILY COMPLETED MATCH SYNC FINISHED [Date: ${targetDateStr}]`,
);
}
// ============================================
// MAIN HISTORICAL SCAN
// ============================================
async runHistoricalScan(
sports: Sport[] = this.SPORTS,
startDateStr: string = this.HISTORICAL_START_DATE,
targetLeagueIds: string[] = [], // NEW: Optional league filter
): Promise<void> {
this.logger.log(
`🚀 STARTING HISTORICAL SCAN [Target: ${sports.join(', ')}] ${targetLeagueIds.length > 0 ? `[Filter: ${targetLeagueIds.length} leagues]` : ''}`,
);
const startDate = new Date(startDateStr);
const endDate = new Date();
// Start from 2 days ago to avoid overlap with live_matches table.
// Cron jobs (data-fetcher.task.ts) handle today and yesterday,
// writing to live_matches. Historical scan should only fill matches table.
endDate.setDate(endDate.getDate() - 2);
const stateKey = `historical_scan_state_${sports.join('_')}${targetLeagueIds.length > 0 ? '_filtered' : ''}_desc`;
let currentDate: Date | null = null;
// Resume from saved state
try {
const savedState = await this.persistenceService.getState(stateKey);
if (savedState) {
const resumeDate = new Date(savedState);
// Ensure resumeDate is valid for reverse scan (<= endDate and >= startDate)
if (resumeDate <= endDate && resumeDate >= startDate) {
currentDate = new Date(resumeDate);
// For reverse scan, we resume from the *next* day backwards, i.e., resumeDate - 1 day
currentDate.setDate(currentDate.getDate() - 1);
this.logger.log(
`📍 Resuming from: ${currentDate.toISOString().split('T')[0]}`,
);
}
}
} catch {
this.logger.warn('Could not read state, starting from beginning');
}
// Initialize currentDate to endDate if not resuming (or if resume failed)
// Note: If resuming, currentDate is already set above.
// If not resuming, we start from endDate (Today) and go backwards.
if (!currentDate) {
currentDate = new Date(endDate);
}
this.logger.log(
`📊 Scanning (Reverse): ${currentDate.toISOString().split('T')[0]}${startDate.toISOString().split('T')[0]}`,
);
let processedDays = 0;
const scanStartTime = Date.now();
// REVERSE LOOP: Iterate while currentDate is greater than or equal to startDate
while (currentDate >= startDate) {
const dateString = currentDate.toISOString().split('T')[0];
for (const sport of sports) {
await this.processDate(dateString, sport, targetLeagueIds);
}
// Save state
await this.persistenceService.setState(stateKey, dateString);
// --- ETA CALCULATION ---
processedDays++;
const now = Date.now();
const totalElapsed = now - scanStartTime;
const avgTimePerDay = totalElapsed / processedDays;
// Calculate remaining days based on current position for REVERSE scan
// Days left = (currentDate - startDate)
const daysLeft = Math.ceil(
(currentDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24),
);
const estimatedRemainingMs = avgTimePerDay * daysLeft;
// Format time helper
const formatDuration = (ms: number) => {
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (1000 * 60)) % 60);
const hours = Math.floor(ms / (1000 * 60 * 60));
return `${hours}h ${minutes}m ${seconds}s`;
};
this.logger.log(
`⏱️ PROGRESS: [${processedDays} days done] | Avg/Day: ${(avgTimePerDay / 1000).toFixed(1)}s | Remaining: ${daysLeft} days | 🏁 ETA: ${formatDuration(estimatedRemainingMs)}`,
);
// Decrement date for reverse scan
currentDate.setDate(currentDate.getDate() - 1);
}
this.logger.log('🎉 HISTORICAL SCAN COMPLETED');
}
// ============================================
// PROCESS SINGLE DATE
// ============================================
private async processDate(
dateString: string,
sport: Sport,
targetLeagueIds: string[] = [],
options: ProcessDateOptions = {},
): Promise<void> {
const { onlyCompletedMatches = false, refreshExistingMatches = false } =
options;
this.logger.log(`[${sport}] 📅 Processing: ${dateString}`);
try {
// Fetch historical source snapshot for the date with retry.
// The upstream endpoint is named "livescores", but this path is used
// strictly as a historical source and filtered by mstUtc below.
let response: LivescoresApiResponse | null = null;
for (let i = 0; i < 3; i++) {
try {
response = await this.scraperService.fetchLivescores(
dateString,
sport,
);
break; // Success, exit loop
} catch (e: any) {
const is502 =
e.message?.includes('502') ||
e.response?.status === 502 ||
e.message?.includes('Bad Gateway');
if (is502 && i < 2) {
this.logger.warn(
`[${sport}] [${dateString}] Historical source fetch returned 502. Retrying in 5s...`,
);
await this.delay(5000);
continue;
}
throw e; // Rethrow if not 502 or retries exhausted
}
}
const data = response?.data;
if (!data?.matches || !data?.competitions) {
this.logger.warn(`[${sport}] [${dateString}] No data from API`);
return;
}
// Filter matches with iddaa code and deduplicate
const rawMatches = Object.values(
data.matches,
) as unknown as MatchSummary[];
const allMatches = rawMatches.filter((m) => m.iddaaCode);
// CRITICAL FIX: Filter matches by actual match date (mstUtc).
// Mackolik's historical source endpoint can still return current live/upcoming matches
// regardless of the matchDate query parameter. We must filter by mstUtc
// to ensure we only process matches that actually belong to the target date.
const { startTs: targetDateStartTs, endTs: targetDateEndTs } =
this.getDayBoundsForTimeZone(
dateString,
this.DAILY_SYNC_TIME_ZONE,
);
const dateFilteredMatches = allMatches.filter((m) => {
const matchTs = m.mstUtc;
return matchTs >= targetDateStartTs && matchTs <= targetDateEndTs;
});
const apiReturnedCount = allMatches.length;
const afterDateFilterCount = dateFilteredMatches.length;
if (apiReturnedCount > 0 && afterDateFilterCount === 0) {
this.logger.log(
`[${sport}] [${dateString}] Historical source returned ${apiReturnedCount} matches, but none belong to the target date after mstUtc filtering. Skipping.`,
);
return;
}
if (afterDateFilterCount < apiReturnedCount) {
this.logger.log(
`[${sport}] [${dateString}] Filtered out ${apiReturnedCount - afterDateFilterCount} off-date rows from historical source payload before processing.`,
);
}
let matchesToProcess = Array.from(
new Map(dateFilteredMatches.map((m) => [m.id, m])).values(),
);
if (targetLeagueIds.length > 0) {
matchesToProcess = matchesToProcess.filter((m) =>
targetLeagueIds.includes(m.competitionId),
);
}
if (onlyCompletedMatches) {
const beforeCompletedFilter = matchesToProcess.length;
matchesToProcess = matchesToProcess.filter((m) =>
this.isCompletedMatchSummary(m),
);
if (
beforeCompletedFilter > 0 &&
matchesToProcess.length < beforeCompletedFilter
) {
this.logger.log(
`[${sport}] [${dateString}] Filtered out ${beforeCompletedFilter - matchesToProcess.length} non-completed matches from daily sync payload.`,
);
}
}
// 1. Check if any matches came from source
if (matchesToProcess.length === 0) {
this.logger.log(
`[${sport}] [${dateString}] No iddaa matches found in source`,
);
return;
}
// 2. Filter out already existing matches to skip processing
const allIds = matchesToProcess.map((m) => m.id);
const existingIds =
await this.persistenceService.getExistingMatchIds(allIds);
const totalCount = matchesToProcess.length;
if (!refreshExistingMatches && existingIds.length > 0) {
matchesToProcess = matchesToProcess.filter(
(m) => !existingIds.includes(m.id),
);
}
if (matchesToProcess.length === 0) {
this.logger.log(
`[${sport}] [${dateString}] All ${totalCount} matches already exist. Skipping...`,
);
return;
}
if (refreshExistingMatches) {
this.logger.log(
`[${sport}] [${dateString}] Refreshing ${matchesToProcess.length} completed matches (${existingIds.length} already existed in matches)`,
);
} else {
this.logger.log(
`[${sport}] [${dateString}] Processing ${matchesToProcess.length}/${totalCount} matches (Skipped ${existingIds.length} existing)`,
);
}
let successCount = 0;
const failedMatches: MatchSummary[] = [];
// 1. SEQUENTIAL PROCESSING (Robust Mode)
// Processes matches one by one to avoid 502 errors
let sequentialCount = 0;
for (const match of matchesToProcess) {
sequentialCount++;
// Batch pause: Wait for ~5 matches worth of time every 10 matches
if (sequentialCount > 1 && sequentialCount % 10 === 0) {
this.logger.log(
`[${sport}] ⏸️ Processed 10 matches, pausing for cooldown...`,
);
await this.delay(4000); // Wait 2s (approx 5 * 400ms)
}
await this.delay(300); // 300ms delay between individual matches
try {
const result = await this.processSingleMatch(
match,
data.competitions,
sport,
refreshExistingMatches,
);
if (result.success) {
this.logger.log(
`[${sport}] ✅ successful for ${match.id} ${match.homeTeam.name} vs ${match.awayTeam.name}`,
);
successCount++;
} else if (result.retryable) {
this.logger.log(
`[${sport}] ⚠️ retryable for ${match.id} ${match.homeTeam.name} vs ${match.awayTeam.name}`,
);
failedMatches.push(match);
}
} catch (e: any) {
this.logger.warn(
`[${sport}] Sequential error for ${match.id}: ${e.message}`,
);
failedMatches.push(match);
}
}
// 2. SEQUENTIAL RETRY FOR FAILED (502) MATCHES
if (failedMatches.length > 0) {
this.logger.log(
`[${sport}] ⚠️ Retrying ${failedMatches.length} failed matches sequentially...`,
);
for (const match of failedMatches) {
await this.delay(2000); // Longer delay for retries
try {
const result = await this.processSingleMatch(
match,
data.competitions,
sport,
refreshExistingMatches,
);
if (result.success) {
successCount++;
this.logger.log(`[${sport}] ✅ Retry successful for ${match.id}`);
} else {
this.logger.warn(`[${sport}] ❌ Retry failed for ${match.id}`);
}
} catch (e: any) {
this.logger.warn(
`[${sport}] ❌ Retry exception for ${match.id}: ${e.message}`,
);
}
}
}
this.logger.log(
`[${sport}] [${dateString}] ✓ Saved ${successCount} matches`,
);
} catch (error: any) {
this.logger.error(
`[${sport}] [${dateString}] ❌ Failed: ${error.message}`,
);
}
}
// ============================================
// REFRESH SINGLE MATCH (On-demand)
// ============================================
async refreshMatch(
matchId: string,
scope: 'all' | 'lineups' | 'odds' = 'all',
): Promise<ProcessResult> {
this.logger.log(`🔄 Refreshing match (${scope}) for ${matchId}`);
const matchRecord = await this.persistenceService.getMatch(matchId);
if (!matchRecord) {
this.logger.warn(`[${matchId}] Refresh failed: Match not in DB`);
return { success: false, retryable: false, error: 'Match not found' };
}
// Construct MatchSummary from DB record
const summary: MatchSummary = {
id: matchId,
matchName: matchRecord.matchName,
matchSlug: matchRecord.matchSlug,
competitionId: matchRecord.leagueId,
mstUtc: Number(matchRecord.mstUtc),
iddaaCode: matchRecord.iddaaCode,
homeTeam: {
id: matchRecord.homeTeamId,
name: matchRecord.homeTeam?.name || '',
slug: matchRecord.homeTeam?.slug || '',
},
awayTeam: {
id: matchRecord.awayTeamId,
name: matchRecord.awayTeam?.name || '',
slug: matchRecord.awayTeam?.slug || '',
},
score: {
home: matchRecord.scoreHome,
away: matchRecord.scoreAway,
},
};
const dummyCompetitions: Record<string, Competition> = {
[summary.competitionId]: {
id: summary.competitionId,
name: 'Unknown',
competitionSlug: '',
country: { id: '', name: '' },
},
};
try {
return await this.processSingleMatch(
summary,
dummyCompetitions,
matchRecord.sport as Sport,
true, // FORCE UPDATE
scope,
);
} catch (error: any) {
this.logger.error(`[${matchId}] Refresh exception: ${error.message}`);
return { success: false, retryable: true, error: error.message };
}
}
// ============================================
// PROCESS SINGLE MATCH
// ============================================
private async processSingleMatch(
matchSummary: MatchSummary,
competitions: Record<string, Competition>,
sport: Sport,
force: boolean = false,
scope: 'all' | 'lineups' | 'odds' = 'all', // Add scope flag
): Promise<ProcessResult> {
const matchId = matchSummary.id;
const homeTeamId = matchSummary.homeTeam?.id;
const awayTeamId = matchSummary.awayTeam?.id;
if (!matchId || !homeTeamId || !awayTeamId) {
this.logger.warn(`[${matchId}] Skipped: Missing IDs`);
return { success: false, retryable: false };
}
// Skip postponed matches (ERT = Erteledendi)
if (matchSummary.statusBoxContent === 'ERT') {
this.logger.debug(`[${matchId}] Skipped: Postponed match (ERT)`);
return { success: false, retryable: false };
}
// Track critical errors (502) to trigger retry even if save succeeds
let hasCriticalError = false;
// Helper for resilient fetching with internal retry
const fetchResilient = async <T>(
label: string,
fn: () => Promise<T>,
retries = 3,
baseDelayMs = 1000,
): Promise<T | null> => {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (e: any) {
const is502 =
e.message?.includes('502') ||
e.response?.status === 502 ||
e.message?.includes('Bad Gateway');
if (i === retries - 1) throw e; // Last attempt failed
if (is502) {
// Exponential backoff: 1s, 2s, 3s
const waitTime = baseDelayMs * (i + 1);
// this.logger.debug(
// `[${matchId}] ${label} failed (502). Retrying in ${waitTime}ms...`,
// );
await this.delay(waitTime);
continue;
}
throw e; // Non-502 error, fail immediately
}
}
return null;
};
try {
// Check if exists
if (!force) {
// Skip exist check if force is true
const exists = await this.persistenceService.matchExists(matchId);
if (exists) {
return { success: true, retryable: false };
}
}
this.logger.debug(
`[${matchId}] Processing (${scope}): ${matchSummary.matchName}`,
);
const league = competitions[matchSummary.competitionId];
const playersMap = new Map<string, TransformedPlayer>();
const participationData: MatchParticipation[] = [];
let eventData: DbEventPayload[] = [];
let stats: TransformedMatchStats | null = null;
let basketballTeamStats: BasketballTeamStats | null = null;
const basketballPlayerStats: Partial<BasketballPlayerStats>[] = [];
let officialsData: MatchOfficial[] = [];
// 1. Fetch Match Header (score, status)
let headerData: ParsedMatchHeader | null = null;
if (scope === 'all') {
try {
headerData = await fetchResilient('Header', () =>
this.scraperService.fetchMatchHeader(matchId),
);
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Header fetch failed: ${e.message}`);
}
}
// 2. Sport-specific data fetching
if (sport === 'basketball') {
// Basketball: Box Score (Always if all or lineups)
if (scope === 'all' || scope === 'lineups') {
try {
const boxData = await fetchResilient('BoxScore', () =>
this.scraperService.fetchBasketballBoxScore(matchId),
);
if (boxData) {
const homeParsed = this.scraperService.parseBasketballBoxScore(
boxData.views?.home?.html || '',
);
const awayParsed = this.scraperService.parseBasketballBoxScore(
boxData.views?.away?.html || '',
);
basketballTeamStats =
scope === 'all'
? {
home: homeParsed.teamTotals,
away: awayParsed.teamTotals,
}
: null;
if (scope === 'all') {
try {
const details = await fetchResilient('QuarterScores', () =>
this.scraperService.fetchBasketballDetailsHeader(matchId),
);
if (details && basketballTeamStats) {
basketballTeamStats.home = {
...basketballTeamStats.home,
...details.home,
};
basketballTeamStats.away = {
...basketballTeamStats.away,
...details.away,
};
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(
`[${matchId}] Quarter scores fetch failed: ${e.message}`,
);
}
}
// Process players (always do if lineups or all)
const processPlayers = (
parsed: typeof homeParsed,
teamId: string,
) => {
parsed.players.forEach((p) => {
if (p.name) {
// Use extracted ID if available, otherwise generate one
const id =
p.id ||
this.transformerService.generateBasketballPlayerId(
teamId,
p.name,
);
basketballPlayerStats.push({ ...p, id, teamId });
playersMap.set(id, {
id,
name: p.name,
slug: id,
teamId,
});
}
});
};
processPlayers(homeParsed, homeTeamId);
processPlayers(awayParsed, awayTeamId);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Box score failed: ${e.message}`);
}
}
} else {
// Football: Events, Lineups, Stats, Officials
// Key Events
if (scope === 'all') {
try {
const eventsData = await fetchResilient('Events', () =>
this.scraperService.fetchKeyEvents(matchId),
);
if (eventsData?.keyEvents) {
const transformedEvents =
this.transformerService.transformKeyEvents(
eventsData.keyEvents,
homeTeamId,
awayTeamId,
matchId,
);
this.transformerService.extractPlayersFromEvents(
transformedEvents,
playersMap,
);
eventData =
this.transformerService.prepareEventDataForDb(
transformedEvents,
);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Events failed: ${e.message}`);
}
await this.delay(300);
}
// Starting Formation & Substitutes (Always for lineups or all)
// V20 OPTIMIZATION: Disabled to speed up feeder and reduce 502 errors.
// We only use Team Stats for V20 model.
/*
if (scope === 'all' || scope === 'lineups') {
// Starting Formation
try {
const formationData =
await this.scraperService.fetchStartingFormation(matchId);
if (formationData?.stats) {
this.transformerService.processLineup(
formationData.stats.home || [],
homeTeamId,
true,
matchId,
playersMap,
participationData,
);
this.transformerService.processLineup(
formationData.stats.away || [],
awayTeamId,
true,
matchId,
playersMap,
participationData,
);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Formation failed: ${e.message}`);
}
// Substitutes
try {
const subsData =
await this.scraperService.fetchSubstitutions(matchId);
if (subsData?.stats) {
this.transformerService.processLineup(
subsData.stats.home || [],
homeTeamId,
false,
matchId,
playersMap,
participationData,
);
this.transformerService.processLineup(
subsData.stats.away || [],
awayTeamId,
false,
matchId,
playersMap,
participationData,
);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Subs failed: ${e.message}`);
}
}
*/
// Game Stats & Officials
if (scope === 'all') {
try {
const gameStats = await fetchResilient('Stats', () =>
this.scraperService.fetchGameStats(matchId),
);
stats = this.transformerService.transformGameStats(gameStats);
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Stats failed: ${e.message}`);
}
// Officials (from match page)
try {
const matchPageHtml = await fetchResilient('Officials', () =>
this.scraperService.fetchMatchPage(
matchId,
matchSummary.matchSlug,
sport,
),
);
if (matchPageHtml) {
officialsData =
this.transformerService.parseOfficials(matchPageHtml);
}
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Officials failed: ${e.message}`);
}
}
}
// 3. Fetch Iddaa Odds (Always if all or odds)
let oddsArray: DbMarketPayload[] = [];
if (scope === 'all' || scope === 'odds') {
try {
let markets: ParsedMarket[] = [];
if (sport === 'basketball') {
markets =
((await fetchResilient('BucketOdds', () =>
this.scraperService.fetchBasketballMarkets(matchId),
)) as ParsedMarket[]) || [];
} else {
markets =
((await fetchResilient('IddaaOdds', () =>
this.scraperService.fetchIddaaMarkets(matchId),
)) as ParsedMarket[]) || [];
}
// Logic is same since structure is ParsedMarket[]
oddsArray = this.transformerService.transformIddaaMarkets(markets);
} catch (e: any) {
if (e.message?.includes('502')) hasCriticalError = true;
this.logger.warn(`[${matchId}] Odds failed: ${e.message}`);
}
}
// 4. Persist to Database
let saved = false;
if (scope === 'lineups') {
saved = await this.persistenceService.saveLineups(
matchId,
playersMap,
participationData,
homeTeamId,
awayTeamId,
);
} else if (scope === 'odds') {
saved = await this.persistenceService.saveOdds(matchId, oddsArray);
} else {
// Full Update
saved = await this.persistenceService.saveMatch(
sport,
matchId,
matchSummary,
league,
homeTeamId,
awayTeamId,
headerData,
playersMap,
participationData,
eventData,
stats,
basketballTeamStats,
basketballPlayerStats,
oddsArray,
officialsData,
);
}
// === AI FEATURE CALCULATION (V17 - DEPRECATED) ===
// Bu servis V17 modeli içindi. V20 Modeli tamamen Python (ai-engine) tarafında çalışmaktadır.
// Gereksiz kaynak tüketmemesi için devre dışı bırakıldı.
/*
if (saved) {
try {
// Fire and forget - don't block the feeder
this.aiFeatureStoreService
.calculateAndSaveFeatures(matchId)
.catch((err) => {
this.logger.warn(
`[${matchId}] AI Feature calculation failed: ${err.message}`,
);
});
} catch (e) {
// Safety catch
}
}
*/
// ==========================================
if (saved && hasCriticalError) {
// Collect missing components
const missingParts: string[] = [];
if (!stats) missingParts.push('Stats');
if (oddsArray.length === 0) missingParts.push('Odds');
if (officialsData.length === 0) missingParts.push('Officials');
this.logger.warn(
`[${matchId}] Saved with MISSING DATA (502). Missing: [${missingParts.join(', ')}]. Scheduled for retry.`,
);
return { success: false, retryable: true };
}
return { success: saved, retryable: !saved };
} catch (error: any) {
const isRetryable =
error.message.includes('502') ||
error.message.includes('504') ||
error.message.includes('ECONNABORTED') ||
error.message.includes('timeout') ||
error.message.includes('ETIMEDOUT') ||
error.message.includes('Unique constraint'); // Concurrency retry
if (isRetryable) {
this.logger.warn(`[${matchId}] ${error.message} - Will retry`);
} else {
this.logger.error(`[${matchId}] ${error.message} - Not retryable`);
}
return { success: false, retryable: isRetryable };
}
}
}
+533
View File
@@ -0,0 +1,533 @@
/**
* Feeder Types - Senior Level Implementation
* Based on actual Mackolik API responses
*/
// ============================================
// SPORT TYPES
// ============================================
export type Sport = 'football' | 'basketball';
export const SPORTS_CONFIG: Record<
Sport,
{ sportParam: string; iddaaUrlPath: string }
> = {
football: { sportParam: 'Soccer', iddaaUrlPath: 'mac' },
basketball: { sportParam: 'Basketball', iddaaUrlPath: 'basketbol/mac' },
};
// ============================================
// MATCH STATUS TYPES
// ============================================
export type MatchStatus = 'Cancelled' | 'Played' | 'Playing' | 'Scheduled';
export type MatchState =
| 'preGame'
| 'postGame'
| 'live'
| 'liveGame'
| 'pre'
| 'post';
// ============================================
// LIVESCORES API RESPONSE
// ============================================
export interface LivescoresApiResponse {
status: string;
data: {
matches: Record<string, MatchSummary>;
competitions: Record<string, Competition>;
};
}
export interface MatchSummary {
id: string;
matchName: string;
matchSlug: string;
competitionId: string;
mstUtc: number;
iddaaCode: string | number | null;
statusBoxContent?: string | null; // ERT = Erteledendi
substate?: string | null;
homeTeam: {
id: string;
name: string;
slug: string;
};
awayTeam: {
id: string;
name: string;
slug: string;
};
score?: {
home: number | string | null;
away: number | string | null;
ht?: {
home: number | string | null;
away: number | string | null;
};
};
homeScore?: number | string | null;
awayScore?: number | string | null;
state?: string | null;
status?: string | null;
winner?: string;
}
export interface Competition {
id: string;
name: string;
competitionSlug: string;
country: {
id: string;
name: string;
};
}
// ============================================
// MATCH HEADER API RESPONSE
// ============================================
export interface MatchHeaderResponse {
status: string;
data: {
html: string; // Contains score, status, HT score
};
}
export interface ParsedMatchHeader {
matchStatus: MatchState;
scoreHome: number | null;
scoreAway: number | null;
htScoreHome: number | null;
htScoreAway: number | null;
}
// ============================================
// KEY EVENTS API RESPONSE
// ============================================
export interface KeyEventsResponse {
status: string;
data: {
keyEvents: RawKeyEvent[];
matchState: MatchState;
matchStartTime: number;
finishedPeriodIds: number[];
};
}
export interface RawKeyEvent {
type: 'goal' | 'card' | 'substitute' | 'penalty-missed';
subType: 'goal' | 'penalty-goal' | 'yc' | 'rc' | 'pm' | 'ps' | null;
position: 'home' | 'away';
periodId: number; // 1 = 1st half, 2 = 2nd half
timeMin: string;
seconds: number | null;
playerName: string;
playerUrl: string;
assistPlayerName?: string | null;
assistPlayerUrl?: string | null;
playerOutName?: string | null;
playerOutUrl?: string | null;
score?: string; // "1-0" format
}
export interface TransformedEvent {
matchId: string;
playerId: string;
playerName: string;
teamId: string;
eventType: 'goal' | 'card' | 'substitute' | 'other';
eventSubtype: string | null;
timeMinute: string;
timeSeconds: number | null;
periodId: number;
assistPlayerId: string | null;
assistPlayerName: string | null;
scoreAfter: string | null;
playerOutId: string | null;
playerOutName: string | null;
position: 'home' | 'away';
}
// ============================================
// MATCH STATS (LINEUPS) API RESPONSE
// ============================================
export interface MatchStatsResponse {
status: string;
data: {
status: MatchState;
stats: {
home: RawPlayerStats[];
away: RawPlayerStats[];
homeBench?: RawPlayerStats[];
awayBench?: RawPlayerStats[];
homeSubstitutes?: RawPlayerStats[];
awaySubstitutes?: RawPlayerStats[];
};
};
}
export interface RawPlayerStats {
personId: string;
matchName: string;
shirtNumber: number | null;
position: 'goalkeeper' | 'defender' | 'midfielder' | 'striker' | 'Coach' | '';
events: PlayerEvent[] | null;
}
export interface PlayerEvent {
name:
| 'goal'
| 'yellow-card'
| 'red-card'
| 'sub-off'
| 'sub-on'
| 'penalty-missed';
timeMin: string;
count: number;
}
export interface TransformedPlayer {
id: string;
name: string;
slug: string;
teamId?: string;
}
export interface MatchParticipation {
matchId: string;
playerId: string;
teamId: string;
position: string | null;
shirtNumber: number | null;
isStarting: boolean;
}
// ============================================
// GAME STATS API RESPONSE
// ============================================
export interface GameStatsResponse {
status: string;
data: {
status: MatchStatus;
startTime: number;
home: TeamGameStats;
away: Partial<TeamGameStats>;
};
}
export interface TeamGameStats {
possesionPercentage?: number;
shotsOnTarget?: number;
shotsOffTarget?: number;
totalPasses?: number;
corners?: number;
fouls?: number;
offsides?: number;
}
export interface TransformedMatchStats {
home: TeamGameStats;
away: TeamGameStats;
}
// ============================================
// MANAGER API RESPONSE
// ============================================
export interface ManagerResponse {
status: string;
data: {
status: MatchState;
stats: {
home: RawPlayerStats;
away: RawPlayerStats;
};
};
}
export interface TransformedManager {
id: string;
name: string;
role: string;
}
// ============================================
// IDDAA ODDS API RESPONSE (JSON Endpoint)
// ============================================
export interface IddaaOddsResponse {
status: string;
data: {
matchStatus: MatchStatus;
markets: Record<string, IddaaMarket>;
};
}
export interface IddaaMarket {
outcomes: Record<string, IddaaOutcome>;
code: string;
mbc: string;
}
export interface IddaaOutcome {
outcome: string; // The odds value (e.g., "1.78")
handicap: string | null;
state: 'active' | 'suspended';
label: string; // "1", "X", "2", "Alt", "Üst", etc.
}
// ============================================
// IDDAA MARKETS HTML RESPONSE
// ============================================
export interface IddaaMarketsHtmlResponse {
status: string;
data: {
html: string;
matchStatus: MatchStatus;
};
}
export interface ParsedMarket {
marketId: string;
marketName: string;
iddaaCode: string;
mbc: string;
selections: ParsedSelection[];
}
export interface ParsedSelection {
shortcode: string;
outcomeNo: string;
label: string;
value: string; // The odds value
}
// ============================================
// BASKETBALL BOX SCORE
// ============================================
export interface BasketballBoxScoreResponse {
status: string;
data: {
views: {
home: { html: string };
away: { html: string };
};
};
}
export interface BasketballPlayerStats {
id: string;
name: string;
teamId: string;
minutes: string;
points: number;
rebounds: number;
assists: number;
steals: number;
blocks: number;
turnovers: number;
fouls: number;
fgMade: number;
fgAttempted: number;
threePtMade: number;
threePtAttempted: number;
ftMade: number;
ftAttempted: number;
}
export interface BasketballTeamTotals {
points?: number;
rebounds?: number;
assists?: number;
steals?: number;
blocks?: number;
turnovers?: number;
fouls?: number;
fgMade?: number;
fgAttempted?: number;
threePtMade?: number;
threePtAttempted?: number;
ftMade?: number;
ftAttempted?: number;
q1?: number | null;
q2?: number | null;
q3?: number | null;
q4?: number | null;
ot?: number | null;
}
export interface BasketballTeamStats {
home: BasketballTeamTotals;
away: BasketballTeamTotals;
}
// ============================================
// MATCH OFFICIALS
// ============================================
export interface MatchOfficial {
name: string;
role: string;
}
export interface DbEventPayload {
match_id: string;
player_id: string;
team_id: string;
event_type: 'goal' | 'card' | 'substitute';
event_subtype: string | null;
time_minute: string;
time_seconds: number | null;
period_id: number;
assist_player_id: string | null;
score_after: string | null;
player_out_id: string | null;
position: 'home' | 'away';
}
export interface DbMarketSelectionPayload {
shortcode: string;
name: string;
odd: string;
position: string;
}
export interface DbMarketPayload {
id: string;
name: string;
iddaaCode: string;
mbc: string;
selectionCollection: DbMarketSelectionPayload[];
}
// ============================================
// MARKET MAPPING (Static)
// ============================================
export const MARKET_MAPPING: Record<string, string> = {
// Ana Bahisler
'1': 'Maç Sonucu',
'3': 'Çifte Şans',
'6-11': 'Handikaplı MS (0:1)',
'6-22': 'Handikaplı MS (0:2)',
'611': 'Handikaplı MS (1:0)',
'622': 'Handikaplı MS (2:0)',
'14': 'İlk Yarı / Maç Sonucu',
'15': 'Maç Skoru',
// Gol Alt/Üst
'180.5': '0.5 Alt/Üst',
'181.5': '1.5 Alt/Üst',
'182.5': '2.5 Alt/Üst',
'183.5': '3.5 Alt/Üst',
'184.5': '4.5 Alt/Üst',
'185.5': '5.5 Alt/Üst',
// Diğer Gol Bahisleri
'11': 'Karşılıklı Gol',
'12': 'Tek / Çift',
'24': 'İlk Golü Kim Atar',
'26': 'Toplam Gol Aralığı',
'32': 'En Çok Gol Olacak Yarı',
// Yarı Bahisleri
'4': '1. Yarı Sonucu',
'5': '1. Yarı Çifte Şans',
'54': '2. Yarı Sonucu',
'190.5': '1. Yarı 0.5 Alt/Üst',
'191.5': '1. Yarı 1.5 Alt/Üst',
'192.5': '1. Yarı 2.5 Alt/Üst',
'39': '1. Yarı Karşılıklı Gol',
// Takım Bahisleri
'280.5': 'Ev Sahibi 0.5 Alt/Üst',
'281.5': 'Ev Sahibi 1.5 Alt/Üst',
'282.5': 'Ev Sahibi 2.5 Alt/Üst',
'283.5': 'Ev Sahibi 3.5 Alt/Üst',
'290.5': 'Deplasman 0.5 Alt/Üst',
'291.5': 'Deplasman 1.5 Alt/Üst',
'292.5': 'Deplasman 2.5 Alt/Üst',
'400.5': '1. Yarı Ev Sahibi 0.5 Alt/Üst',
'430.5': '1. Yarı Deplasman 0.5 Alt/Üst',
'37': 'Ev Sahibi Gol Yemeden Kazanır',
'38': 'Deplasman Gol Yemeden Kazanır',
// Korner & Kart
'47': 'En Çok Korner',
'48': '1. Yarı En Çok Korner',
'49': 'İlk Korner',
'43': 'Toplam Korner Aralığı',
'44': '1. Yarı Korner Aralığı',
'463.5': '1. Yarı 3.5 Korner Alt/Üst',
'464.5': '1. Yarı 4.5 Korner Alt/Üst',
'465.5': '1. Yarı 5.5 Korner Alt/Üst',
'53': 'Kırmızı Kart Olur mu?',
'384.5': '4.5 Kart Puanı Alt/Üst',
'385.5': '5.5 Kart Puanı Alt/Üst',
'386.5': '6.5 Kart Puanı Alt/Üst',
// Kombine
'301.5': 'MS ve 1.5 Alt/Üst',
'302.5': 'MS ve 2.5 Alt/Üst',
'303.5': 'MS ve 3.5 Alt/Üst',
'304.5': 'MS ve 4.5 Alt/Üst',
// İki Yarıyı da Kazanır (39 conflicts with 1. Yarı Karşılıklı Gol, keep that one)
'40': 'Deplasman İki Yarıyı da Kazanır',
};
// ============================================
// AXIOS CONFIG
// ============================================
export interface AxiosRequestConfig {
headers: {
'User-Agent': string;
Referer: string;
'X-Requested-With': string;
'Accept-Language'?: string;
};
timeout: number;
}
export const DEFAULT_HEADERS = {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
Referer: 'https://www.mackolik.com/',
'X-Requested-With': 'XMLHttpRequest',
'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7',
};
export const DEFAULT_TIMEOUT = 30000;
// ============================================
// SIDELINED PLAYERS API RESPONSE
// ============================================
export interface SidelinedResponse {
homeTeam: SidelinedTeamData;
awayTeam: SidelinedTeamData;
}
export interface SidelinedTeamData {
teamName: string;
teamId: string;
totalSidelined: number;
players: SidelinedPlayer[];
}
export interface SidelinedPlayer {
playerId: string;
playerName: string;
playerUrl: string;
position: string;
positionShort: string;
type: 'injury' | 'suspension' | 'other';
description: string;
matchesMissed: number | null;
average: number | null;
reasonIcon: string;
}
// ============================================
// PROCESSING RESULT
// ============================================
export interface ProcessResult {
success: boolean;
retryable: boolean;
error?: string;
}