This commit is contained in:
+359
@@ -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()}`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user