This commit is contained in:
Executable
+746
@@ -0,0 +1,746 @@
|
||||
/**
|
||||
* Feeder Scraper Service - Senior Level Implementation
|
||||
* HTTP requests with exact headers from working curl commands
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import * as cheerio from 'cheerio';
|
||||
import {
|
||||
Sport,
|
||||
SPORTS_CONFIG,
|
||||
DEFAULT_HEADERS,
|
||||
DEFAULT_TIMEOUT,
|
||||
KeyEventsResponse,
|
||||
MatchStatsResponse,
|
||||
GameStatsResponse,
|
||||
ManagerResponse,
|
||||
IddaaMarketsHtmlResponse,
|
||||
BasketballBoxScoreResponse,
|
||||
ParsedMatchHeader,
|
||||
ParsedMarket,
|
||||
ParsedSelection,
|
||||
BasketballPlayerStats,
|
||||
LivescoresApiResponse,
|
||||
SidelinedResponse,
|
||||
SidelinedTeamData,
|
||||
SidelinedPlayer,
|
||||
} from './feeder.types';
|
||||
|
||||
@Injectable()
|
||||
export class FeederScraperService {
|
||||
private readonly logger = new Logger(FeederScraperService.name);
|
||||
private readonly axios: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
// Create axios instance with default config
|
||||
this.axios = axios.create({
|
||||
headers: DEFAULT_HEADERS,
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
});
|
||||
|
||||
// Add response interceptor for logging
|
||||
this.axios.interceptors.response.use(
|
||||
(response) => {
|
||||
this.logger.debug(
|
||||
`✅ [${response.config.url?.split('?')[0]}] Status: ${response.status}`,
|
||||
);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
const status = error.response?.status || 'N/A';
|
||||
const url = error.config?.url?.split('?')[0] || 'Unknown';
|
||||
this.logger.error(`❌ [${url}] Status: ${status} - ${error.message}`);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Historical source endpoint (match list)
|
||||
// ============================================
|
||||
async fetchLivescores(
|
||||
dateString: string,
|
||||
sport: Sport,
|
||||
): Promise<LivescoresApiResponse> {
|
||||
const { sportParam } = SPORTS_CONFIG[sport];
|
||||
const url = `https://www.mackolik.com/perform/p0/ajax/components/competition/livescores/json`;
|
||||
|
||||
this.logger.log(
|
||||
`📡 [${sport}] Fetching historical source snapshot for ${dateString}`,
|
||||
);
|
||||
|
||||
const response = await this.axios.get(url, {
|
||||
params: {
|
||||
'sports[]': sportParam,
|
||||
matchDate: dateString,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = response.data as unknown;
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== 'object' ||
|
||||
!('status' in payload) ||
|
||||
!('data' in payload)
|
||||
) {
|
||||
throw new Error('Historical source payload has invalid shape');
|
||||
}
|
||||
|
||||
return payload as LivescoresApiResponse;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MATCH HEADER (Score, Status, HT Score)
|
||||
// ============================================
|
||||
async fetchMatchHeader(matchId: string): Promise<ParsedMatchHeader> {
|
||||
const url = `https://www.mackolik.com/perform/p0/ajax/components/match/matchHeader`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching match header`);
|
||||
|
||||
const response = await this.axios.get(url, {
|
||||
params: {
|
||||
matchId,
|
||||
sdapiLanguageCode: 'tr-mk',
|
||||
ajaxViewName: 'match-details',
|
||||
ajaxPartialViewName: 'match-details-status',
|
||||
displayMode: 'all',
|
||||
},
|
||||
});
|
||||
|
||||
return this.parseMatchHeader(response.data.data?.html || '');
|
||||
}
|
||||
|
||||
private parseMatchHeader(html: string): ParsedMatchHeader {
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// Extract match-status from data attribute
|
||||
const matchStatus =
|
||||
($('[data-match-status]').attr('data-match-status') as any) || 'postGame';
|
||||
|
||||
// Extract scores
|
||||
const scoreHome = this.safeInt($('[data-slot="score-home"]').text().trim());
|
||||
const scoreAway = this.safeInt($('[data-slot="score-away"]').text().trim());
|
||||
|
||||
// Extract HT score from detailed score (İY X - X)
|
||||
let htScoreHome: number | null = null;
|
||||
let htScoreAway: number | null = null;
|
||||
|
||||
const detailedScore = $('.p0c-soccer-match-details-header__detailed-score')
|
||||
.text()
|
||||
.trim();
|
||||
const htMatch = detailedScore.match(/\(İY\s*(\d+)\s*-\s*(\d+)\)/);
|
||||
if (htMatch) {
|
||||
htScoreHome = parseInt(htMatch[1], 10);
|
||||
htScoreAway = parseInt(htMatch[2], 10);
|
||||
}
|
||||
|
||||
return { matchStatus, scoreHome, scoreAway, htScoreHome, htScoreAway };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// KEY EVENTS (Goals, Cards, Substitutes)
|
||||
// ============================================
|
||||
async fetchKeyEvents(
|
||||
matchId: string,
|
||||
): Promise<KeyEventsResponse['data'] | null> {
|
||||
const url = `https://www.mackolik.com/ajax/football/key-events`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching key events`);
|
||||
|
||||
try {
|
||||
const response = await this.axios.get<KeyEventsResponse>(url, {
|
||||
params: {
|
||||
ajaxViewName: 'events',
|
||||
matchId,
|
||||
seasonId: matchId, // Same as matchId
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
this.logger.warn(`[${matchId}] Key events not found (404)`);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MATCH STATS - STARTING FORMATION (İlk 11)
|
||||
// ============================================
|
||||
async fetchStartingFormation(
|
||||
matchId: string,
|
||||
): Promise<MatchStatsResponse['data'] | null> {
|
||||
const url = `https://www.mackolik.com/ajax/football/match-stats`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching starting formation`);
|
||||
|
||||
try {
|
||||
const response = await this.axios.get<MatchStatsResponse>(url, {
|
||||
params: {
|
||||
ajaxViewName: 'starting-formation',
|
||||
matchId,
|
||||
seasonId: matchId,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
this.logger.warn(`[${matchId}] Starting formation not found (404)`);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MATCH STATS - SUBSTITUTIONS (Yedekler)
|
||||
// ============================================
|
||||
async fetchSubstitutions(
|
||||
matchId: string,
|
||||
): Promise<MatchStatsResponse['data'] | null> {
|
||||
const url = `https://www.mackolik.com/ajax/football/match-stats`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching substitutions`);
|
||||
|
||||
try {
|
||||
const response = await this.axios.get<MatchStatsResponse>(url, {
|
||||
params: {
|
||||
ajaxViewName: 'substitutions',
|
||||
matchId,
|
||||
seasonId: matchId,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
this.logger.warn(`[${matchId}] Substitutions not found (404)`);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GAME STATS (Possession, Shots, Passes)
|
||||
// ============================================
|
||||
async fetchGameStats(
|
||||
matchId: string,
|
||||
): Promise<GameStatsResponse['data'] | null> {
|
||||
const url = `https://www.mackolik.com/ajax/soccer/match/gameStats`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching game stats`);
|
||||
|
||||
try {
|
||||
const response = await this.axios.get<GameStatsResponse>(url, {
|
||||
params: { matchId },
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
this.logger.warn(`[${matchId}] Game stats not found (404)`);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MANAGER
|
||||
// ============================================
|
||||
async fetchManager(matchId: string): Promise<ManagerResponse['data'] | null> {
|
||||
const url = `https://www.mackolik.com/ajax/football/match-stats`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching manager`);
|
||||
|
||||
try {
|
||||
const response = await this.axios.get<ManagerResponse>(url, {
|
||||
params: {
|
||||
ajaxViewName: 'manager',
|
||||
matchId,
|
||||
seasonId: matchId,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
this.logger.warn(`[${matchId}] Manager not found (404)`);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// IDDAA MARKETS (HTML with odds + names)
|
||||
// ============================================
|
||||
async fetchIddaaMarkets(matchId: string): Promise<ParsedMarket[]> {
|
||||
const url = `https://www.mackolik.com/ajax/iddaa/markets/soccer/all/${matchId}`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching iddaa markets`);
|
||||
|
||||
try {
|
||||
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
|
||||
params: { template: 'all' },
|
||||
});
|
||||
|
||||
return this.parseIddaaMarketsHtml(response.data.data?.html || '');
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
this.logger.warn(`[${matchId}] Iddaa markets not found (404)`);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseIddaaMarketsHtml(html: string): ParsedMarket[] {
|
||||
if (!html) return [];
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
const markets: ParsedMarket[] = [];
|
||||
|
||||
$('.widget-iddaa-markets__market-item').each((_, marketEl) => {
|
||||
const $market = $(marketEl);
|
||||
|
||||
const marketId = $market.attr('data-market') || '';
|
||||
const marketName = $market
|
||||
.find('.widget-iddaa-markets__header-text')
|
||||
.text()
|
||||
.trim();
|
||||
const iddaaCode = $market
|
||||
.find('.widget-iddaa-markets__iddaa-code')
|
||||
.text()
|
||||
.trim();
|
||||
const mbc = $market.find('.widget-iddaa-markets__mbc').text().trim();
|
||||
|
||||
const selections: ParsedSelection[] = [];
|
||||
|
||||
$market.find('.widget-iddaa-markets__option').each((_, optionEl) => {
|
||||
const $option = $(optionEl);
|
||||
|
||||
selections.push({
|
||||
shortcode: $option.attr('data-shortcode') || '',
|
||||
outcomeNo: $option.attr('data-outcome-no') || '',
|
||||
label: $option.find('.widget-iddaa-markets__label').text().trim(),
|
||||
value: $option.find('.widget-iddaa-markets__value').text().trim(),
|
||||
});
|
||||
});
|
||||
|
||||
if (marketId && marketName) {
|
||||
markets.push({ marketId, marketName, iddaaCode, mbc, selections });
|
||||
}
|
||||
});
|
||||
|
||||
this.logger.debug(`Parsed ${markets.length} iddaa markets`);
|
||||
return markets;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BASKETBALL BOX SCORE
|
||||
// ============================================
|
||||
async fetchBasketballBoxScore(
|
||||
matchId: string,
|
||||
): Promise<BasketballBoxScoreResponse['data'] | null> {
|
||||
// Updated URL based on user request
|
||||
const url = `https://www.mackolik.com/ajax/basketball/match/box-score`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching basketball box score`);
|
||||
|
||||
try {
|
||||
const response = await this.axios.get<BasketballBoxScoreResponse>(url, {
|
||||
params: { matchId },
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'User-Agent': DEFAULT_HEADERS['User-Agent'],
|
||||
},
|
||||
});
|
||||
|
||||
return response.data.data;
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
this.logger.warn(`[${matchId}] Basketball box score not found (404)`);
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
parseBasketballBoxScore(html: string): {
|
||||
teamTotals: any;
|
||||
players: Partial<BasketballPlayerStats>[];
|
||||
} {
|
||||
if (!html) return { teamTotals: {}, players: [] };
|
||||
|
||||
const $ = cheerio.load(html);
|
||||
const players: Partial<BasketballPlayerStats>[] = [];
|
||||
|
||||
// Parse individual players from widget rows
|
||||
$('.widget-basketball-match-box-score__row').each((_, elem) => {
|
||||
const row = $(elem);
|
||||
// Skip if no player name found
|
||||
const nameElem = row.find('.widget-basketball-match-box-score__player');
|
||||
if (!nameElem.length) return;
|
||||
|
||||
const name = nameElem.text().trim();
|
||||
// Indices based on User HTML:
|
||||
// 0: Name, 1: Min, 2: Pts, 3: Reb, 4: Ast, 5: 2FG, 6: 3FG, 7: FT, 8: Fouls, 9: Blk, 10: Stl, 11: TO
|
||||
const values = row.find('td');
|
||||
|
||||
// Check if it's a valid player row (should have enough columns)
|
||||
if (values.length < 10) return;
|
||||
|
||||
// Extract ID from link if possible
|
||||
let playerId = '';
|
||||
const link = nameElem.find('a').attr('href');
|
||||
if (link) {
|
||||
playerId = this.extractPlayerIdFromUrl(link) || '';
|
||||
}
|
||||
|
||||
players.push({
|
||||
id: playerId, // Will be generated if empty later
|
||||
name,
|
||||
minutes: values.eq(1).text().trim(),
|
||||
points: this.safeInt(values.eq(2).text().trim()) || 0,
|
||||
rebounds: this.safeInt(values.eq(3).text().trim()) || 0,
|
||||
assists: this.safeInt(values.eq(4).text().trim()) || 0,
|
||||
fgMade: this.safeInt(values.eq(5).text().trim().split('/')[0]) || 0,
|
||||
fgAttempted:
|
||||
this.safeInt(values.eq(5).text().trim().split('/')[1]) || 0,
|
||||
threePtMade:
|
||||
this.safeInt(values.eq(6).text().trim().split('/')[0]) || 0,
|
||||
threePtAttempted:
|
||||
this.safeInt(values.eq(6).text().trim().split('/')[1]) || 0,
|
||||
ftMade: this.safeInt(values.eq(7).text().trim().split('/')[0]) || 0,
|
||||
ftAttempted:
|
||||
this.safeInt(values.eq(7).text().trim().split('/')[1]) || 0,
|
||||
fouls: this.safeInt(values.eq(8).text().trim()) || 0,
|
||||
blocks: this.safeInt(values.eq(9).text().trim()) || 0,
|
||||
steals: this.safeInt(values.eq(10).text().trim()) || 0,
|
||||
turnovers: this.safeInt(values.eq(11).text().trim()) || 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Parse Team Totals from Footer
|
||||
const footerRow = $('.widget-basketball-match-box-score__footer td');
|
||||
let teamTotals: any = {};
|
||||
|
||||
if (footerRow.length > 5) {
|
||||
// Indices shift because first cells might be empty matchers
|
||||
// usually index 2 matches Points column
|
||||
teamTotals = {
|
||||
points: this.safeInt(footerRow.eq(2).text().trim()) || 0,
|
||||
rebounds: this.safeInt(footerRow.eq(3).text().trim()) || 0,
|
||||
assists: this.safeInt(footerRow.eq(4).text().trim()) || 0,
|
||||
fgMade: this.safeInt(footerRow.eq(5).text().trim().split('/')[0]) || 0,
|
||||
fgAttempted:
|
||||
this.safeInt(footerRow.eq(5).text().trim().split('/')[1]) || 0,
|
||||
threePtMade:
|
||||
this.safeInt(footerRow.eq(6).text().trim().split('/')[0]) || 0,
|
||||
threePtAttempted:
|
||||
this.safeInt(footerRow.eq(6).text().trim().split('/')[1]) || 0,
|
||||
ftMade: this.safeInt(footerRow.eq(7).text().trim().split('/')[0]) || 0,
|
||||
ftAttempted:
|
||||
this.safeInt(footerRow.eq(7).text().trim().split('/')[1]) || 0,
|
||||
fouls: this.safeInt(footerRow.eq(8).text().trim()) || 0,
|
||||
blocks: this.safeInt(footerRow.eq(9).text().trim()) || 0,
|
||||
steals: this.safeInt(footerRow.eq(10).text().trim()) || 0,
|
||||
turnovers: this.safeInt(footerRow.eq(11).text().trim()) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
return { teamTotals, players };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MATCH PAGE (Main page for officials parsing)
|
||||
// ============================================
|
||||
async fetchMatchPage(
|
||||
matchId: string,
|
||||
matchSlug: string,
|
||||
sport: Sport,
|
||||
): Promise<string> {
|
||||
const { iddaaUrlPath } = SPORTS_CONFIG[sport];
|
||||
const url = `https://www.mackolik.com/${iddaaUrlPath}/${matchSlug}/${matchId}`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching match page`);
|
||||
|
||||
// For HTML pages, we DON'T send X-Requested-With header
|
||||
const response = await this.axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': DEFAULT_HEADERS['User-Agent'],
|
||||
Referer: DEFAULT_HEADERS['Referer'],
|
||||
'Accept-Language': DEFAULT_HEADERS['Accept-Language'],
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
// NO X-Requested-With for HTML pages!
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPER FUNCTIONS
|
||||
// ============================================
|
||||
private safeInt(value: string | undefined): number | null {
|
||||
if (!value) return null;
|
||||
const num = parseInt(value, 10);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BASKETBALL DETAILS HEADER (Quarter Scores)
|
||||
// ============================================
|
||||
async fetchBasketballDetailsHeader(matchId: string): Promise<any> {
|
||||
const url = `https://www.mackolik.com/ajax/basketball/match/details-header`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching basketball details header`);
|
||||
|
||||
try {
|
||||
const response = await this.axios.get(url, {
|
||||
params: { matchId },
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'User-Agent': DEFAULT_HEADERS['User-Agent'],
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.data?.views?.scoreDetails?.html) {
|
||||
return this.parseBasketballDetailsHeader(
|
||||
response.data.data.views.scoreDetails.html,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
// 404 is acceptable
|
||||
if (error.response?.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseBasketballDetailsHeader(
|
||||
html: string,
|
||||
): { home: any; away: any } | null {
|
||||
if (!html) return null;
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const rows = $(
|
||||
'.widget-basketball-match-details-header__score-details tbody tr',
|
||||
);
|
||||
if (rows.length < 2) return null;
|
||||
|
||||
const parseRow = (row: any) => {
|
||||
const cols = $(row).find('td');
|
||||
// Format: TeamName, Q1, Q2, Q3, Q4, Final
|
||||
// Values are inside .widget-basketball-match-details-header__score-part (just the quarter score)
|
||||
// or direct text if simple table.
|
||||
// User HTML shows: <span class="...score-part"> 33 </span>
|
||||
const getScore = (index: number) => {
|
||||
const cell = cols.eq(index);
|
||||
const part = cell.find(
|
||||
'.widget-basketball-match-details-header__score-part',
|
||||
);
|
||||
const val = part.length ? part.text() : cell.text();
|
||||
return this.safeInt(val.trim());
|
||||
};
|
||||
|
||||
return {
|
||||
q1: getScore(1),
|
||||
q2: getScore(2),
|
||||
q3: getScore(3),
|
||||
q4: getScore(4),
|
||||
// If there's OT, it would be column 5, and Final column 6?
|
||||
// Standard 4 quarters: Col 1,2,3,4. Col 5 is Final.
|
||||
// If 5 cols (+name), logic holds.
|
||||
// Let's assume standard for now.
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
home: parseRow(rows[0]),
|
||||
away: parseRow(rows[1]),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// BASKETBALL MARKETS (Odds)
|
||||
// ============================================
|
||||
async fetchBasketballMarkets(matchId: string): Promise<ParsedMarket[]> {
|
||||
// User provided URL structure: /ajax/iddaa/markets/basketball/all/{matchId}?template=all
|
||||
const url = `https://www.mackolik.com/ajax/iddaa/markets/basketball/all/${matchId}`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching basketball markets`);
|
||||
|
||||
try {
|
||||
const response = await this.axios.get<IddaaMarketsHtmlResponse>(url, {
|
||||
params: { template: 'all' },
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'User-Agent': DEFAULT_HEADERS['User-Agent'],
|
||||
},
|
||||
});
|
||||
|
||||
if (response.data?.data?.html) {
|
||||
return this.parseIddaaMarketsHtml(response.data.data.html);
|
||||
}
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
this.logger.warn(`[${matchId}] Basketball markets not found (404)`);
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
extractPlayerIdFromUrl(url: string | undefined): string | null {
|
||||
if (!url) return null;
|
||||
const parts = url.split('/');
|
||||
return parts[parts.length - 1] || null;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SIDELINED PLAYERS (Injuries & Suspensions)
|
||||
// ============================================
|
||||
async fetchSidelinedPlayers(
|
||||
matchId: string,
|
||||
matchSlug: string,
|
||||
): Promise<SidelinedResponse | null> {
|
||||
const url = `https://www.mackolik.com/mac/${matchSlug}/${matchId}`;
|
||||
|
||||
this.logger.debug(`📡 [${matchId}] Fetching sidelined players`);
|
||||
|
||||
try {
|
||||
const response = await this.axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
Accept:
|
||||
'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
|
||||
'Accept-Language': 'tr-TR,tr;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
Referer: 'https://www.mackolik.com',
|
||||
},
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data);
|
||||
|
||||
return {
|
||||
homeTeam: this._parseSidelinedSection($, 0),
|
||||
awayTeam: this._parseSidelinedSection($, 1),
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.response?.status === 404) {
|
||||
this.logger.warn(`[${matchId}] Match page not found (404)`);
|
||||
return null;
|
||||
}
|
||||
this.logger.warn(
|
||||
`[${matchId}] Sidelined fetch warning: ${error.message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private _parseSidelinedSection(
|
||||
$: cheerio.CheerioAPI,
|
||||
teamIndex: number,
|
||||
): SidelinedTeamData {
|
||||
const sidelinedWidgets = $('.widget-sidelined-players');
|
||||
|
||||
if (sidelinedWidgets.length <= teamIndex) {
|
||||
return { teamName: '', teamId: '', totalSidelined: 0, players: [] };
|
||||
}
|
||||
|
||||
const widget = sidelinedWidgets.eq(teamIndex);
|
||||
|
||||
const teamCrest = widget.find('.widget-sidelined-players__header-crest');
|
||||
const teamCrestSrc = teamCrest.attr('src') || '';
|
||||
const teamId = teamCrestSrc.split('/').pop() || '';
|
||||
const teamName = widget
|
||||
.find('.widget-sidelined-players__header-text')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
const players: SidelinedPlayer[] = [];
|
||||
widget.find('.widget-sidelined-players__item').each((_, element) => {
|
||||
const playerData = this._parsePlayerItem($, $(element));
|
||||
if (playerData) {
|
||||
players.push(playerData);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
teamName,
|
||||
teamId,
|
||||
totalSidelined: players.length,
|
||||
players,
|
||||
};
|
||||
}
|
||||
|
||||
private _parsePlayerItem(
|
||||
$: cheerio.CheerioAPI,
|
||||
$item: cheerio.Cheerio<any>,
|
||||
): SidelinedPlayer | null {
|
||||
try {
|
||||
const nameElem = $item.find('.widget-sidelined-players__name');
|
||||
const playerName = nameElem.text().trim();
|
||||
const playerUrl = nameElem.attr('href') || '';
|
||||
const playerId = playerUrl.split('/').pop() || '';
|
||||
|
||||
const positionElem = $item.find('.widget-sidelined-players__position');
|
||||
const position = positionElem.attr('title') || '';
|
||||
const positionShort = positionElem.text().trim();
|
||||
|
||||
const reasonImg = $item.find('.widget-sidelined-players__reason img');
|
||||
const reasonIcon = reasonImg.attr('src') || '';
|
||||
|
||||
const numbers = $item.find('.widget-sidelined-players__number');
|
||||
// Use parseInt EXACTLY as in JS script (ignoring potential NaN for now, will handle via helper if needed but safer to stick to script logic first)
|
||||
const matchesMissedText =
|
||||
numbers.length > 0 ? numbers.eq(0).text().trim() : '';
|
||||
const matchesMissed = matchesMissedText
|
||||
? parseInt(matchesMissedText, 10)
|
||||
: null;
|
||||
|
||||
const averageText = numbers.length > 1 ? numbers.eq(1).text().trim() : '';
|
||||
const average = averageText ? parseInt(averageText, 10) : null;
|
||||
|
||||
const description = $item
|
||||
.find('.widget-sidelined-players__value')
|
||||
.text()
|
||||
.trim();
|
||||
|
||||
const type = reasonIcon.includes('shortage_1.png')
|
||||
? 'injury'
|
||||
: reasonIcon.includes('suspension')
|
||||
? 'suspension'
|
||||
: 'other';
|
||||
|
||||
return {
|
||||
playerId,
|
||||
playerName,
|
||||
playerUrl: playerUrl.startsWith('http')
|
||||
? playerUrl
|
||||
: `https://www.mackolik.com${playerUrl}`,
|
||||
position,
|
||||
positionShort,
|
||||
type,
|
||||
description,
|
||||
matchesMissed: isNaN(matchesMissed as number) ? null : matchesMissed,
|
||||
average: isNaN(average as number) ? null : average,
|
||||
reasonIcon: reasonIcon.startsWith('http')
|
||||
? reasonIcon
|
||||
: `https://www.mackolik.com${reasonIcon}`, // Keep safer URL construction but stick closer to logic
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user