/** * 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, 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> { const odds: Record> = {}; 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, ): 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()}`; } }