Files
iddaai-be/src/tasks/data-fetcher.task.ts
T
fahricansecer 145a8b336b
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s
fix(feeder): preserve pre-match odds when match goes live
Live odds have missing selections (e.g. '1' key removed from Maç Sonucu
after kickoff), causing the AI model to produce wildly incorrect predictions
(e.g. 3.5% home win for Bristol City). Two guards added:

1. fetchOddsForMatches: Exclude live/finished matches from odds fetch query
2. processMatchOdds: Skip odds/lineups/sidelined overwrite if match already
   has pre-match odds and is live/finished
2026-05-02 16:32:42 +03:00

1338 lines
54 KiB
TypeScript
Executable File

import { Injectable, Logger } from "@nestjs/common";
import { Cron } from "@nestjs/schedule";
import { HttpService } from "@nestjs/axios";
import { PrismaService } from "../database/prisma.service";
import { firstValueFrom } from "rxjs";
import { FeederScraperService } from "../modules/feeder/feeder-scraper.service";
import * as fs from "fs";
import * as path from "path";
import { Prisma } from "@prisma/client";
import { SidelinedResponse } from "../modules/feeder/feeder.types";
import {
FINISHED_STATE_VALUES_FOR_DB,
FINISHED_STATUS_VALUES_FOR_DB,
LIVE_STATE_VALUES_FOR_DB,
LIVE_STATUS_VALUES_FOR_DB,
} from "../common/utils/match-status.util";
import {
getDateStringInTimeZone,
getDayBoundsForTimeZone,
getShiftedDateStringInTimeZone,
} from "../common/utils/timezone.util";
import { TaskLockService } from "./task-lock.service";
// ────────────────────────────────────────────────────────────────
// Types
// ────────────────────────────────────────────────────────────────
interface LiveScoreTeamPayload {
id: string;
name: string;
slug: string | null;
}
interface LiveScoreCompetitionPayload {
id: string;
name: string;
slug: string | null;
country: {
id: string;
name: string;
} | null;
}
interface LiveScorePayloadMatch {
id: string;
matchName: string;
matchSlug: string;
competitionId: string | null;
mstUtc: number | null;
state: string | null;
substate: string | null;
status: string | null;
statusBoxContent: string | null; // ERT = Erteledendi
iddaaCode: string | null;
homeTeam: LiveScoreTeamPayload;
awayTeam: LiveScoreTeamPayload;
homeScore: number | null;
awayScore: number | null;
score: {
home: number | null;
away: number | null;
} | null;
}
type LiveMatchOddsTarget = Prisma.LiveMatchGetPayload<{
include: {
homeTeam: { select: { name: true } };
awayTeam: { select: { name: true } };
};
}>;
interface LiveLineupsJson {
home: { xi: unknown[]; subs: unknown[] };
away: { xi: unknown[]; subs: unknown[] };
}
type SportType = "football" | "basketball";
// ────────────────────────────────────────────────────────────────
// Service
// ────────────────────────────────────────────────────────────────
@Injectable()
export class DataFetcherTask {
private readonly logger = new Logger(DataFetcherTask.name);
private readonly timeZone = "Europe/Istanbul";
constructor(
private readonly httpService: HttpService,
private readonly prisma: PrismaService,
private readonly scraper: FeederScraperService,
private readonly taskLock: TaskLockService,
) {}
// ────────────────────────────────────────────────────────────
// CRON 1: Main sync — every 15 minutes
// Phases: match list → live scores → odds → lineups
// ────────────────────────────────────────────────────────────
@Cron("*/15 * * * *")
async syncLiveMatches(): Promise<void> {
if (this.shouldSkipInHistoricalMode("syncLiveMatches")) return;
await this.taskLock.runWithLease(
"syncLiveMatches",
30 * 60 * 1000,
async () => {
await this.runLiveSync();
},
this.logger,
);
}
// ────────────────────────────────────────────────────────────
// CRON 2: Daily cleanup + full sync — 07:00 Istanbul
// Preserve yesterday as a fallback until the 08:00 archive job completes.
// ────────────────────────────────────────────────────────────
@Cron("0 7 * * *", { timeZone: "Europe/Istanbul" })
async cleanAndFullSync(): Promise<void> {
if (this.shouldSkipInHistoricalMode("cleanAndFullSync")) return;
await this.taskLock.runWithLease(
"cleanAndFullSync",
2 * 60 * 60 * 1000,
async () => {
this.logger.log(
"cleanAndFullSync: Pruning stale live_matches while preserving yesterday for archive fallback...",
);
try {
const yesterdayDate = getShiftedDateStringInTimeZone(
-1,
this.timeZone,
);
const { startMs: yesterdayStartMs } = getDayBoundsForTimeZone(
yesterdayDate,
this.timeZone,
);
const cutoffDate = new Date(yesterdayStartMs);
const deleted = await this.prisma.liveMatch.deleteMany({
where: {
OR: [
{ mstUtc: { lt: BigInt(yesterdayStartMs) } },
{
AND: [
{ mstUtc: null },
{ updatedAt: { lt: cutoffDate } },
{
OR: [
{ status: { in: FINISHED_STATUS_VALUES_FOR_DB } },
{ state: { in: FINISHED_STATE_VALUES_FOR_DB } },
],
},
],
},
],
},
});
this.logger.log(
`Pruned ${deleted.count} stale live matches. Starting full sync...`,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Stale live_match cleanup failed: ${message}`);
return;
}
await this.runLiveSync();
},
this.logger,
);
}
// ────────────────────────────────────────────────────────────
// Phase 1: Fetch match list for all sports
// ────────────────────────────────────────────────────────────
private async runLiveSync(): Promise<void> {
if (this.shouldSkipInHistoricalMode("syncLiveMatches")) return;
this.logger.log("syncLiveMatches START");
const today = getDateStringInTimeZone(new Date(), this.timeZone);
const tomorrow = getShiftedDateStringInTimeZone(1, this.timeZone);
await this.syncMatchList(today);
await this.syncMatchList(tomorrow);
await this.updateLiveScores();
await this.fetchOddsForMatches();
await this.fillMissingLineups();
this.logger.log("syncLiveMatches END");
}
private async syncMatchList(date: string): Promise<void> {
// Football
const footballLeagues = this.loadLeagueFilterSet("top_leagues.json");
if (footballLeagues && footballLeagues.size > 0) {
await this.fetchMatchesForSport("football", date, footballLeagues);
} else {
this.logger.warn(
"top_leagues.json is missing/empty — writing ALL football matches",
);
await this.fetchMatchesForSport("football", date, new Set());
}
// Basketball
const basketballLeagues = this.loadLeagueFilterSet(
"basketball_top_leagues.json",
);
if (!basketballLeagues || basketballLeagues.size === 0) {
this.logger.error(
"basketball_top_leagues.json is missing/empty. Basketball ingest skipped to protect data quality.",
);
} else {
await this.fetchMatchesForSport("basketball", date, basketballLeagues);
// Clean up basketball matches that are NOT in configured leagues
await this.prisma.liveMatch.deleteMany({
where: {
sport: "basketball",
OR: [
{ leagueId: null },
{ leagueId: { notIn: Array.from(basketballLeagues) } },
],
},
});
}
}
// ────────────────────────────────────────────────────────────
// Phase 2: Live score updates (merged from live-updater.task)
// ────────────────────────────────────────────────────────────
private async updateLiveScores(): Promise<void> {
try {
const liveMatches = await this.prisma.liveMatch.findMany({
where: {
OR: [
{ state: { in: LIVE_STATE_VALUES_FOR_DB } },
{ status: { in: LIVE_STATUS_VALUES_FOR_DB } },
],
},
select: { id: true, matchSlug: true },
});
if (liveMatches.length === 0) {
this.logger.debug("No live matches to update scores for");
return;
}
this.logger.log(
`📡 Updating scores for ${liveMatches.length} live matches`,
);
for (const match of liveMatches) {
try {
const url = `https://www.mackolik.com/ajax/football/match-info?matchId=${match.id}`;
const response = await firstValueFrom(
this.httpService.get(url, { timeout: 5000 }),
);
if (response.data?.data) {
const matchData = response.data.data;
await this.prisma.liveMatch.update({
where: { id: match.id },
data: {
scoreHome: matchData.homeScore ?? null,
scoreAway: matchData.awayScore ?? null,
state: matchData.state || matchData.status,
status: matchData.status,
updatedAt: new Date(),
},
});
}
} catch {
// Individual match update failed, continue with others
}
}
this.logger.log("📡 Live score update complete");
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Live score update failed: ${message}`);
}
}
// ────────────────────────────────────────────────────────────
// Phase 3: Odds + referee + lineups + sidelined
// ────────────────────────────────────────────────────────────
private async fetchOddsForMatches(): Promise<void> {
this.logger.log("💰 Fetching odds for live matches...");
try {
// Load both league filters
const topLeagueIds: string[] = [];
const footballLeagues = this.loadLeagueFilterSet("top_leagues.json");
if (footballLeagues) topLeagueIds.push(...footballLeagues);
const basketballLeagues = this.loadLeagueFilterSet(
"basketball_top_leagues.json",
);
if (basketballLeagues) topLeagueIds.push(...basketballLeagues);
const allowedLeagueIds = Array.from(new Set(topLeagueIds));
// Get matches needing odds (from 12 hours ago onward)
// CRITICAL: Only fetch odds/lineups for NOT STARTED matches.
// Once a match goes live, odds selections disappear or change,
// corrupting the pre-match data the AI model relies on.
const twelveHoursAgo = new Date(Date.now() - 12 * 60 * 60 * 1000);
const matchesToFetch = await this.prisma.liveMatch.findMany({
where: {
mstUtc: { gte: BigInt(twelveHoursAgo.getTime()) },
...(allowedLeagueIds.length > 0
? { leagueId: { in: allowedLeagueIds } }
: {}),
// Exclude live and finished matches — preserve their pre-match odds
NOT: {
OR: [
{ status: { in: FINISHED_STATUS_VALUES_FOR_DB } },
{ state: { in: FINISHED_STATE_VALUES_FOR_DB } },
{ status: { in: LIVE_STATUS_VALUES_FOR_DB } },
{ state: { in: LIVE_STATE_VALUES_FOR_DB } },
],
},
},
include: {
homeTeam: { select: { name: true } },
awayTeam: { select: { name: true } },
},
orderBy: [{ oddsUpdatedAt: "asc" }, { mstUtc: "asc" }],
take: 1000,
});
if (matchesToFetch.length === 0) {
this.logger.log("💰 No matches to fetch odds for");
return;
}
this.logger.log(`💰 Fetching odds for ${matchesToFetch.length} matches`);
let successCount = 0;
let errorCount = 0;
const failedMatches: LiveMatchOddsTarget[] = [];
// Initial pass
for (const match of matchesToFetch) {
try {
await this.processMatchOdds(match);
successCount++;
await this.delay(500);
} catch (err: unknown) {
errorCount++;
const isRetryable = this.isRetryableError(err);
if (isRetryable) {
failedMatches.push(match);
} else {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Match ${match.id} odds fetch failed (Non-retryable): ${message}`,
);
}
}
}
// Retry failed matches (502/Timeout)
if (failedMatches.length > 0) {
this.logger.warn(
`⚠️ Retrying ${failedMatches.length} failed matches (502/Timeout)...`,
);
for (const match of failedMatches) {
await this.delay(2000);
try {
await this.processMatchOdds(match);
successCount++;
this.logger.log(`✅ Retry successful for match ${match.id}`);
} catch (retryErr: unknown) {
const message =
retryErr instanceof Error ? retryErr.message : String(retryErr);
this.logger.error(
`❌ Retry failed for match ${match.id}: ${message}`,
);
}
}
}
this.logger.log(
`💰 Odds complete: ${successCount} success, ${errorCount} errors (initially)`,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Odds fetch job failed: ${message}`);
}
}
// ────────────────────────────────────────────────────────────
// Phase 4: Fill missing lineups (backup)
// ────────────────────────────────────────────────────────────
private async fillMissingLineups(): Promise<void> {
try {
const matchesToUpdate = await this.prisma.liveMatch.findMany({
where: {
sport: "football",
NOT: {
OR: [
{ status: { in: FINISHED_STATUS_VALUES_FOR_DB } },
{ state: { in: FINISHED_STATE_VALUES_FOR_DB } },
{
AND: [
{ scoreHome: { not: null } },
{ scoreAway: { not: null } },
{
NOT: {
OR: [
{ status: { in: LIVE_STATUS_VALUES_FOR_DB } },
{ state: { in: LIVE_STATE_VALUES_FOR_DB } },
],
},
},
],
},
],
},
},
select: { id: true, matchSlug: true, lineups: true, sport: true },
take: 30,
});
// Only football matches without lineups
const toUpdate = matchesToUpdate.filter(
(m) => !m.lineups && m.sport === "football",
);
if (toUpdate.length === 0) {
this.logger.debug("👕 All lineups already filled");
return;
}
this.logger.log(`👕 Filling lineups for ${toUpdate.length} matches...`);
for (const match of toUpdate) {
try {
const [formation, substitutions] = await Promise.all([
this.scraper.fetchStartingFormation(match.id),
this.scraper.fetchSubstitutions(match.id),
]);
const sidelined = match.matchSlug
? await this.scraper.fetchSidelinedPlayers(
match.id,
match.matchSlug,
)
: null;
// Normalize to same home.xi/away.xi format used by processMatchOdds
let normalizedLineups: Record<string, unknown> | null = null;
if (formation || substitutions) {
normalizedLineups = {
home: {
xi: formation?.stats?.home || [],
subs: substitutions?.stats?.home || [],
},
away: {
xi: formation?.stats?.away || [],
subs: substitutions?.stats?.away || [],
},
};
}
await this.prisma.liveMatch.update({
where: { id: match.id },
data: {
lineups: normalizedLineups
? JSON.parse(JSON.stringify(normalizedLineups))
: Prisma.JsonNull,
sidelined: sidelined
? JSON.parse(JSON.stringify(sidelined))
: Prisma.JsonNull,
updatedAt: new Date(),
},
});
this.logger.log(`👕 Lineups filled for match ${match.id}`);
await this.delay(500);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(`Lineup fill failed for ${match.id}: ${message}`);
}
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`Lineup fill error: ${message}`);
}
}
// ────────────────────────────────────────────────────────────
// Unified match fetcher — DRY for football + basketball
// ────────────────────────────────────────────────────────────
private async fetchMatchesForSport(
sport: SportType,
date: string,
topLeagueIds: Set<string>,
): Promise<void> {
const sportParam = sport === "basketball" ? "Basketball" : "Soccer";
const url = `https://www.mackolik.com/perform/p0/ajax/components/competition/livescores/json?sports[]=${sportParam}&matchDate=${date}`;
try {
const response = await firstValueFrom(
this.httpService.get(url, { timeout: 20000 }),
);
const payload = this.parseLiveScoresPayload(response.data);
if (!payload) {
this.logger.warn(`[${sport}] No valid data from API for ${date}`);
return;
}
const allMatches = payload.matches;
const competitions = payload.competitions;
// Apply league filter
const targetMatches =
topLeagueIds.size > 0
? allMatches.filter(
(m) =>
!!m.competitionId && topLeagueIds.has(String(m.competitionId)),
)
: allMatches;
if (targetMatches.length === 0) {
this.logger.log(`[${sport}] No matches found for ${date}`);
return;
}
this.logger.log(
`[${sport}] Processing ${targetMatches.length}/${allMatches.length} matches for ${date}`,
);
// Local caches to avoid N+1 redundant upserts
const processedCountries = new Set<string>();
const processedLeagues = new Set<string>();
const processedTeams = new Set<string>();
const integrityChecked = new Map<string, boolean>();
let upsertCount = 0;
let skippedCount = 0;
for (const match of targetMatches) {
try {
const homeTeamId = match.homeTeam.id;
const awayTeamId = match.awayTeam.id;
const leagueId = match.competitionId;
// Sport integrity check (cached)
const ensureIntegrity = async (
entityType: "league" | "team" | "liveMatch",
entityId: string | null,
): Promise<boolean> => {
if (!entityId) return true;
const cacheKey = `${sport}:${entityType}:${entityId}`;
if (!integrityChecked.has(cacheKey)) {
integrityChecked.set(
cacheKey,
await this.ensureLiveEntitySportIntegrity(
entityType,
entityId,
sport,
),
);
}
return integrityChecked.get(cacheKey) === true;
};
if (
!(await ensureIntegrity("liveMatch", String(match.id))) ||
!(await ensureIntegrity("league", leagueId)) ||
!(await ensureIntegrity("team", homeTeamId)) ||
!(await ensureIntegrity("team", awayTeamId))
) {
skippedCount++;
continue;
}
// Resolve competition details
const compInfo = this.resolveCompetition(competitions, leagueId);
const countryId = compInfo?.country?.id || null;
// 1. Upsert Country (cached)
if (
countryId &&
compInfo?.country?.name &&
!processedCountries.has(countryId)
) {
await this.prisma.country
.upsert({
where: { id: countryId },
update: { name: compInfo.country.name },
create: { id: countryId, name: compInfo.country.name },
})
.catch((e: Error) =>
this.logger.warn(
`[${sport}] Country upsert failed: ${e.message}`,
),
);
processedCountries.add(countryId);
}
// 2. Upsert League (cached)
if (
leagueId &&
compInfo?.name &&
!processedLeagues.has(String(leagueId))
) {
await this.prisma.league
.upsert({
where: { id: leagueId },
update: {
name: compInfo.name,
countryId: countryId,
sport: sport,
},
create: {
id: leagueId,
name: compInfo.name,
countryId: countryId,
sport: sport,
competitionSlug: compInfo.slug || null,
},
})
.catch((e: Error) =>
this.logger.warn(
`[${sport}] League upsert failed: ${e.message}`,
),
);
processedLeagues.add(String(leagueId));
}
// 3. Upsert Home Team (cached)
if (
homeTeamId &&
match.homeTeam?.name &&
!processedTeams.has(homeTeamId)
) {
await this.prisma.team
.upsert({
where: { id: homeTeamId },
update: {
name: match.homeTeam.name,
slug: match.homeTeam.slug || null,
sport: sport,
},
create: {
id: homeTeamId,
name: match.homeTeam.name,
slug: match.homeTeam.slug || null,
sport: sport,
},
})
.catch((e: Error) =>
this.logger.warn(
`[${sport}] Home team upsert failed: ${e.message}`,
),
);
processedTeams.add(homeTeamId);
}
// 4. Upsert Away Team (cached)
if (
awayTeamId &&
match.awayTeam?.name &&
!processedTeams.has(awayTeamId)
) {
await this.prisma.team
.upsert({
where: { id: awayTeamId },
update: {
name: match.awayTeam.name,
slug: match.awayTeam.slug || null,
sport: sport,
},
create: {
id: awayTeamId,
name: match.awayTeam.name,
slug: match.awayTeam.slug || null,
sport: sport,
},
})
.catch((e: Error) =>
this.logger.warn(
`[${sport}] Away team upsert failed: ${e.message}`,
),
);
processedTeams.add(awayTeamId);
}
// Safe score parsing
const sHome = this.asInt(match.homeScore ?? match.score?.home);
const sAway = this.asInt(match.awayScore ?? match.score?.away);
// Handle postponed matches (ERT = Erteledendi)
if (match.statusBoxContent === "ERT") {
await this.prisma.liveMatch
.updateMany({
where: { id: String(match.id) },
data: {
status: "POSTPONED",
state: "postponed",
substate: "postponed",
updatedAt: new Date(),
},
})
.catch(() => {});
this.logger.debug(
`[${sport}] Marked as POSTPONED: ${match.matchName}`,
);
skippedCount++;
continue;
}
// 5. Upsert LiveMatch
await this.prisma.liveMatch.upsert({
where: { id: String(match.id) },
update: {
leagueId: leagueId,
state: match.state || null,
substate: match.substate || null,
status: match.status || match.state || "NS",
scoreHome: sHome,
scoreAway: sAway,
homeTeamId: homeTeamId,
awayTeamId: awayTeamId,
updatedAt: new Date(),
},
create: {
id: String(match.id),
matchName: match.matchName,
matchSlug: match.matchSlug,
sport: sport,
leagueId: leagueId,
state: match.state || null,
substate: match.substate || null,
status: match.status || match.state || "NS",
mstUtc: BigInt(match.mstUtc || Date.now()),
scoreHome: sHome,
scoreAway: sAway,
homeTeamId: homeTeamId,
awayTeamId: awayTeamId,
},
});
upsertCount++;
// Progress logging every 100 matches
if (
(upsertCount + skippedCount) % 100 === 0 ||
upsertCount + skippedCount === targetMatches.length
) {
this.logger.log(
`[${sport}] ⏳ Progress: ${upsertCount + skippedCount}/${targetMatches.length} (Saved: ${upsertCount}, Skipped: ${skippedCount})`,
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(`[${sport}] Match ${match.id} failed: ${message}`);
}
}
this.logger.log(
`[${sport}] [${date}] Done: ${upsertCount} saved, ${skippedCount} skipped`,
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.logger.error(`[${sport}] Fetch failed for ${date}: ${message}`);
}
}
// ────────────────────────────────────────────────────────────
// processMatchOdds — odds + referee + lineups + sidelined
// (Preserved from original — no logic changes)
// ────────────────────────────────────────────────────────────
private async processMatchOdds(match: LiveMatchOddsTarget): Promise<void> {
const matchSlug = match.matchSlug || "match";
const sport = String(match.sport || "football").toLowerCase();
const sportPath = sport === "basketball" ? "basketbol/mac" : "mac";
const httpHeaders = {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
};
let odds: Record<string, Record<string, number>> = {};
let refereeName: string | null = null;
let lineups: LiveLineupsJson | null = null;
let sidelined: SidelinedResponse | null = null;
// 1. Fetch Odds from İddaa page
const oddsUrl = `https://www.mackolik.com/${sportPath}/${matchSlug}/iddaa/${match.id}`;
try {
const response = await firstValueFrom(
this.httpService.get(oddsUrl, {
timeout: 10000,
headers: httpHeaders,
maxRedirects: 5,
}),
);
odds = this.extractOddsFromHtml(
typeof response.data === "string" ? response.data : "",
);
} catch (e: unknown) {
if (this.isRetryableError(e)) throw e;
const message = e instanceof Error ? e.message : String(e);
this.logger.warn(`Odds fetch failed for ${match.id}: ${message}`);
}
if (sport === "football") {
// 2. Fetch Referee from main match page
if (!refereeName) {
const mainUrl = `https://www.mackolik.com/mac/${matchSlug}/${match.id}`;
try {
const mainResp = await firstValueFrom(
this.httpService.get(mainUrl, {
timeout: 10000,
headers: httpHeaders,
maxRedirects: 5,
}),
);
refereeName = this.extractRefereeFromHtml(
typeof mainResp.data === "string" ? mainResp.data : "",
);
} catch {
// Non-critical — referee is optional
}
}
// 3. Fetch Lineups & Sidelined Players
const now = Date.now();
const matchTime = Number(match.mstUtc);
const diffHours = (matchTime - now) / (1000 * 60 * 60);
// Fetch if between -3 hours (started) and +24 hours (upcoming)
if (diffHours < 24 && diffHours > -3) {
// Lineups
try {
const [startingFormation, substitutions] = await Promise.all([
this.scraper.fetchStartingFormation(match.id),
this.scraper.fetchSubstitutions(match.id),
]);
if (startingFormation || substitutions) {
lineups = {
home: {
xi: startingFormation?.stats?.home || [],
subs: substitutions?.stats?.home || [],
},
away: {
xi: startingFormation?.stats?.away || [],
subs: substitutions?.stats?.away || [],
},
};
this.logger.log(`👥 Lineups found for ${match.matchName}`);
} else {
this.logger.debug(`No lineups (yet) for ${match.matchName}`);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(`Lineup fetch failed for ${match.id}: ${message}`);
}
// Sidelined Players (Injuries/Suspensions)
try {
sidelined = await this.scraper.fetchSidelinedPlayers(
match.id,
matchSlug,
);
if (sidelined) {
if (sidelined.homeTeam) {
sidelined.homeTeam.teamName = match.homeTeam?.name || "";
}
if (sidelined.awayTeam) {
sidelined.awayTeam.teamName = match.awayTeam?.name || "";
}
if (
sidelined.homeTeam?.totalSidelined > 0 ||
sidelined.awayTeam?.totalSidelined > 0
) {
this.logger.log(
`🚑 Sidelined: ${sidelined.homeTeam.totalSidelined}(H) - ${sidelined.awayTeam.totalSidelined}(A) for ${match.matchName}`,
);
}
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
this.logger.warn(
`Sidelined fetch failed for ${match.id}: ${message}`,
);
}
}
}
// Guard: If match already has pre-match odds and is now live/finished,
// do NOT overwrite odds/lineups/sidelined — the model needs stable pre-match data.
const matchState = match.state?.toLowerCase() ?? '';
const matchStatus = match.status?.toLowerCase() ?? '';
const liveStates = LIVE_STATE_VALUES_FOR_DB.map((s) => s.toLowerCase());
const liveStatuses = LIVE_STATUS_VALUES_FOR_DB.map((s) => s.toLowerCase());
const finishedStates = FINISHED_STATE_VALUES_FOR_DB.map((s) => s.toLowerCase());
const finishedStatuses = FINISHED_STATUS_VALUES_FOR_DB.map((s) => s.toLowerCase());
const isLiveOrFinished =
liveStates.includes(matchState) ||
liveStatuses.includes(matchStatus) ||
finishedStates.includes(matchState) ||
finishedStatuses.includes(matchStatus);
const existingOdds = match.odds as Record<string, unknown> | null;
const hasExistingOdds =
!!existingOdds &&
typeof existingOdds === 'object' &&
Object.keys(existingOdds).length > 0;
if (isLiveOrFinished && hasExistingOdds) {
// Match is live/finished and already has pre-match odds — skip data update
this.logger.debug(
`🛡️ Preserving pre-match data for ${match.matchName} (status: ${matchStatus}, state: ${matchState})`,
);
await this.prisma.liveMatch.update({
where: { id: match.id },
data: { oddsUpdatedAt: new Date() },
});
return;
}
// Update odds/lineups/sidelined for pre-match (NS) matches
await this.prisma.liveMatch.update({
where: { id: match.id },
data: {
odds: Object.keys(odds).length > 0 ? odds : undefined,
oddsUpdatedAt: new Date(),
refereeName: refereeName ?? undefined,
lineups: (lineups as unknown as Prisma.InputJsonValue) ?? undefined,
sidelined: (sidelined as unknown as Prisma.InputJsonValue) ?? undefined,
},
});
if (
Object.keys(odds).length > 0 ||
refereeName ||
lineups ||
(sidelined &&
(sidelined.homeTeam.totalSidelined > 0 ||
sidelined.awayTeam.totalSidelined > 0))
) {
this.logger.log(
`✅ Loop update: ${match.matchName} | Odds: ${Object.keys(odds).length} | Ref: ${refereeName || "N/A"} | Lineups: ${lineups ? "Yes" : "No"} | Sidelined: ${sidelined ? "Yes" : "No"}`,
);
} else {
this.logger.debug(
`❕ No detailed data for ${match.matchName}, marked check.`,
);
}
}
// ────────────────────────────────────────────────────────────
// HTML Extraction Helpers (preserved — no logic changes)
// ────────────────────────────────────────────────────────────
/**
* Extract odds from Mackolik HTML page
* Returns structured odds object: { "MS": {"1": 2.10, "X": 3.40}, "AU25": {"Alt": 2.05, "Üst": 1.75} }
*/
private extractOddsFromHtml(
html: string,
): Record<string, Record<string, number>> {
const odds: Record<string, Record<string, number>> = {};
if (!html) return odds;
try {
const settingsPattern = /data-settings="([^"]+)"/g;
let match;
while ((match = settingsPattern.exec(html)) !== null) {
try {
const decoded = match[1]
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
const parsed = JSON.parse(decoded) as unknown;
if (!this.isRecord(parsed)) continue;
const iddaaEventId = this.isRecord(parsed.iddaaEventId)
? parsed.iddaaEventId
: null;
const marketCollection =
iddaaEventId && this.isRecord(iddaaEventId.marketCollection)
? iddaaEventId.marketCollection
: null;
if (marketCollection) {
for (const marketValue of Object.values(marketCollection)) {
if (!this.isRecord(marketValue)) continue;
const marketName = this.asString(marketValue.name)?.trim();
if (!marketName) continue;
// First-come-first-served: Skip if already populated
if (
odds[marketName] &&
Object.keys(odds[marketName]).length > 0
) {
continue;
}
odds[marketName] = {};
const selectionCollection = this.isRecord(
marketValue.selectionCollection,
)
? marketValue.selectionCollection
: {};
for (const selectionValue of Object.values(selectionCollection)) {
if (!this.isRecord(selectionValue)) continue;
const selName =
this.asString(selectionValue.name) ||
this.asString(selectionValue.outcome);
const selOdd = Number.parseFloat(
this.asString(selectionValue.odd) || "",
);
if (selName && !isNaN(selOdd)) {
odds[marketName][selName] = selOdd;
}
}
}
}
} catch {
// JSON parse error, skip
}
}
} catch {
this.logger.warn("Failed to extract odds from HTML");
}
return odds;
}
/**
* Normalize odds category names to short codes
*/
private normalizeOddsCategory(name: string): string | null {
if (!name) return null;
const lower = name.toLowerCase();
// Specific & Compound names FIRST
if (lower.includes("ilk yarı/maç sonucu")) return "HTFT";
if (lower.includes("1. yarı sonucu")) return "HT";
if (lower.includes("çifte şans")) return "CS";
// General names LATER
if (lower.includes("maç sonucu") && !lower.includes("handikap"))
return "MS";
if (lower.includes("karşılıklı gol")) return "KG";
if (lower.includes("2,5 alt/üst") || lower.includes("2.5")) return "AU25";
if (lower.includes("1,5 alt/üst") || lower.includes("1.5")) return "AU15";
if (lower.includes("3,5 alt/üst") || lower.includes("3.5")) return "AU35";
return null;
}
/**
* Extract referee name from match page HTML
*/
private extractRefereeFromHtml(html: string): string | null {
try {
// Strategy 1: Mackolik officials section — head referee in '--main' list item
const mainOfficialPattern =
/official-list-item--main[^>]*>\s*(?:<[^>]*>\s*)*?<span[^>]*official-name[^>]*>\s*([^<]+)/i;
const mainMatch = mainOfficialPattern.exec(html);
if (mainMatch?.[1]) {
const name = mainMatch[1].trim();
if (name.length > 2 && name.length < 100) return name;
}
// Strategy 2: Any official-name followed by "Orta Hakem"
const ortaHakemPattern =
/official-name[^>]*>\s*([^<]+)<[\s\S]*?Orta\s*Hakem/i;
const ortaMatch = ortaHakemPattern.exec(html);
if (ortaMatch?.[1]) {
const name = ortaMatch[1].trim();
if (name.length > 2 && name.length < 100) return name;
}
// Strategy 3: Generic fallback patterns
const fallbackPatterns = [
/Hakem:\s*([^<]+)/i,
/"refereeName":"([^"]+)"/i,
];
for (const pattern of fallbackPatterns) {
const result = pattern.exec(html);
if (result?.[1]) {
const name = result[1].trim();
if (name.length > 2 && name.length < 100) return name;
}
}
} catch {
// Ignore extraction errors
}
return null;
}
// ────────────────────────────────────────────────────────────
// Low-level Helpers (preserved — no logic changes)
// ────────────────────────────────────────────────────────────
private shouldSkipInHistoricalMode(jobName: string): boolean {
if (process.env.FEEDER_MODE === "historical") {
this.logger.debug(`Skipping ${jobName} in historical feeder mode`);
return true;
}
return false;
}
private isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
private asString(value: unknown): string | null {
if (typeof value === "string") return value;
if (typeof value === "number" && Number.isFinite(value))
return String(value);
return null;
}
private asInt(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.trunc(value);
}
if (typeof value === "string" && value.trim().length > 0) {
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? null : parsed;
}
return null;
}
private parseTeam(value: unknown): LiveScoreTeamPayload | null {
if (!this.isRecord(value)) return null;
const id = this.asString(value.id);
if (!id) return null;
return {
id,
name: this.asString(value.name) || "Unknown",
slug: this.asString(value.slug),
};
}
private parseCompetition(value: unknown): LiveScoreCompetitionPayload | null {
if (!this.isRecord(value)) return null;
const id = this.asString(value.id);
const name = this.asString(value.name);
if (!id || !name) return null;
const rawCountry = this.isRecord(value.country) ? value.country : null;
const countryId = rawCountry ? this.asString(rawCountry.id) : null;
const countryName = rawCountry ? this.asString(rawCountry.name) : null;
return {
id,
name,
slug: this.asString(value.slug),
country:
countryId && countryName ? { id: countryId, name: countryName } : null,
};
}
private parseLiveScoreMatch(value: unknown): LiveScorePayloadMatch | null {
if (!this.isRecord(value)) return null;
const id = this.asString(value.id);
const homeTeam = this.parseTeam(value.homeTeam);
const awayTeam = this.parseTeam(value.awayTeam);
if (!id || !homeTeam || !awayTeam) return null;
const score = this.isRecord(value.score)
? {
home: this.asInt(value.score.home),
away: this.asInt(value.score.away),
}
: null;
return {
id,
matchName:
this.asString(value.matchName) || `${homeTeam.name} - ${awayTeam.name}`,
matchSlug: this.asString(value.matchSlug) || "",
competitionId: this.asString(value.competitionId),
mstUtc: this.asInt(value.mstUtc),
state: this.asString(value.state),
substate: this.asString(value.substate),
status: this.asString(value.status),
statusBoxContent: this.asString(value.statusBoxContent),
iddaaCode: this.asString(value.iddaaCode),
homeTeam,
awayTeam,
homeScore: this.asInt(value.homeScore),
awayScore: this.asInt(value.awayScore),
score,
};
}
private parseLiveScoresPayload(raw: unknown): {
matches: LiveScorePayloadMatch[];
competitions: Record<string, LiveScoreCompetitionPayload>;
} | null {
if (!this.isRecord(raw)) return null;
if (this.asString(raw.status) !== "success") return null;
const data = this.isRecord(raw.data) ? raw.data : null;
if (!data) return null;
const rawMatches = this.isRecord(data.matches) ? data.matches : {};
const rawCompetitions = this.isRecord(data.competitions)
? data.competitions
: {};
const matches: LiveScorePayloadMatch[] = [];
for (const rawMatch of Object.values(rawMatches)) {
const parsed = this.parseLiveScoreMatch(rawMatch);
if (parsed) matches.push(parsed);
}
const competitions: Record<string, LiveScoreCompetitionPayload> = {};
for (const [key, rawCompetition] of Object.entries(rawCompetitions)) {
const parsed = this.parseCompetition(rawCompetition);
if (parsed) {
competitions[key] = parsed;
}
}
return { matches, competitions };
}
private loadLeagueFilterSet(fileName: string): Set<string> | null {
try {
const filePath = path.join(process.cwd(), fileName);
const raw = JSON.parse(fs.readFileSync(filePath, "utf-8")) as unknown;
if (!Array.isArray(raw)) {
this.logger.error(`${fileName} is not a JSON array`);
return null;
}
const ids = raw
.map((value) => this.asString(value))
.filter((value): value is string => !!value);
return new Set(ids);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
this.logger.error(`Failed to load ${fileName}: ${message}`);
return null;
}
}
private resolveCompetition(
competitions: Record<string, LiveScoreCompetitionPayload>,
leagueId: string | null,
): LiveScoreCompetitionPayload | null {
if (!leagueId) return null;
if (competitions[leagueId]) return competitions[leagueId];
for (const comp of Object.values(competitions)) {
if (comp.id === leagueId) return comp;
}
return null;
}
private async ensureLiveEntitySportIntegrity(
entityType: "league" | "team" | "liveMatch",
id: string,
expectedSport: SportType,
): Promise<boolean> {
if (!id) return true;
let existingSport: string | null | undefined;
if (entityType === "league") {
const existing = await this.prisma.league.findUnique({
where: { id },
select: { sport: true },
});
existingSport = existing?.sport;
} else if (entityType === "team") {
const existing = await this.prisma.team.findUnique({
where: { id },
select: { sport: true },
});
existingSport = existing?.sport;
} else {
const existing = await this.prisma.liveMatch.findUnique({
where: { id },
select: { sport: true },
});
existingSport = existing?.sport;
}
if (existingSport && existingSport !== expectedSport) {
this.logger.error(
`Sport integrity violation on ${entityType}:${id}. Existing=${existingSport}, incoming=${expectedSport}. Skipping write to prevent cross-sport overwrite.`,
);
return false;
}
return true;
}
private isRetryableError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const errorObj = err as Record<string, unknown>;
const response = errorObj.response as Record<string, unknown> | undefined;
return (
response?.status === 502 ||
errorObj.code === "ECONNABORTED" ||
errorObj.code === "ETIMEDOUT"
);
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}