Files
iddaai-be/src/tasks/data-fetcher.task.ts
T
fahricansecer 27e96da31d
Deploy Iddaai Backend / build-and-deploy (push) Successful in 29s
main
2026-05-04 18:00:40 +03:00

1344 lines
53 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("qualified_leagues.json");
if (footballLeagues && footballLeagues.size > 0) {
await this.fetchMatchesForSport("football", date, footballLeagues);
} else {
this.logger.warn(
"qualified_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(
`LIVE 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 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("MONEY Fetching odds for live matches...");
try {
// Load both league filters (data-driven qualified leagues)
const topLeagueIds: string[] = [];
const footballLeagues = this.loadLeagueFilterSet(
"qualified_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("MONEY No matches to fetch odds for");
return;
}
this.logger.log(
`MONEY 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(`SUCCESS 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(
`MONEY 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(
`SUCCESS 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));
}
}