360 lines
10 KiB
TypeScript
Executable File
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()}`;
|
|
}
|
|
}
|