Files
iddaai-be/src/modules/feeder/feeder-transformer.service.ts
T
2026-04-16 17:21:48 +03:00

360 lines
10 KiB
TypeScript
Executable File

/**
* 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()}`;
}
}