Aggiunto scraping Punters.com.au per corse cavalli
- Aggiunto nuovo scraper HTML (PuntersScraper.cs) che estrae dati delle corse da Punters.com.au tramite AngleSharp. - Aggiornata UI: nuova opzione "Scraping - Punters" tra le sorgenti dati. - Migliorata la logica di gestione dei controlli per distinguere tra download API/Scraping e CSV. - Ora è possibile scaricare e visualizzare dati delle corse direttamente dal sito senza file CSV.
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
<OutputPath>bin\x64\Release\</OutputPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AngleSharp" Version="1.4.0" />
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="11.0.0-preview.2.26159.112" />
|
||||
<PackageReference Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
|
||||
+720
@@ -0,0 +1,720 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AngleSharp;
|
||||
using AngleSharp.Dom;
|
||||
using AngleSharp.Html.Parser;
|
||||
|
||||
namespace HorseRacingPredictor.HorseRacing.Scraping
|
||||
{
|
||||
/// <summary>
|
||||
/// Scraper per Punters.com.au – estrae dati delle corse dei cavalli
|
||||
/// direttamente dal sito web (SSR HTML) senza scaricare file CSV.
|
||||
///
|
||||
/// Flusso:
|
||||
/// 1. Discovery: accede a /form-guide/ e scopre le corse del giorno
|
||||
/// per le nazioni richieste (es. GB, IE).
|
||||
/// 2. Extraction: per ogni corsa, accede alla pagina dettaglio ed estrae
|
||||
/// i dati dei corridori dalla tabella HTML renderizzata lato server.
|
||||
/// 3. Il risultato è un DataTable pronto per DataGrid ed esportazione.
|
||||
/// </summary>
|
||||
internal sealed class PuntersScraper
|
||||
{
|
||||
private const string BaseUrl = "https://www.punters.com.au";
|
||||
private const string FormGuideUrl = BaseUrl + "/form-guide/";
|
||||
|
||||
// Delay tra richieste per sembrare un utente reale (ms)
|
||||
private const int MinDelayMs = 1500;
|
||||
private const int MaxDelayMs = 3500;
|
||||
|
||||
private static readonly Random Rng = new();
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly HtmlParser _parser = new();
|
||||
|
||||
/// <summary>Codici paese da scaricare (es. "GB", "IE").</summary>
|
||||
public List<string> Countries { get; set; } = new() { "GB", "IE" };
|
||||
|
||||
public PuntersScraper()
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
AutomaticDecompression = DecompressionMethods.GZip
|
||||
| DecompressionMethods.Deflate
|
||||
| DecompressionMethods.Brotli,
|
||||
UseCookies = true,
|
||||
CookieContainer = new CookieContainer()
|
||||
};
|
||||
|
||||
_http = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
|
||||
// Headers che mimano un browser reale (dal file HAR)
|
||||
_http.DefaultRequestHeaders.Add("User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36");
|
||||
_http.DefaultRequestHeaders.Add("Accept",
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
|
||||
_http.DefaultRequestHeaders.Add("Accept-Language", "en-US,en;q=0.9");
|
||||
_http.DefaultRequestHeaders.Add("Accept-Encoding", "gzip, deflate, br");
|
||||
_http.DefaultRequestHeaders.Add("sec-ch-ua",
|
||||
"\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"");
|
||||
_http.DefaultRequestHeaders.Add("sec-ch-ua-mobile", "?0");
|
||||
_http.DefaultRequestHeaders.Add("sec-ch-ua-platform", "\"Windows\"");
|
||||
_http.DefaultRequestHeaders.Add("sec-fetch-dest", "document");
|
||||
_http.DefaultRequestHeaders.Add("sec-fetch-mode", "navigate");
|
||||
_http.DefaultRequestHeaders.Add("sec-fetch-site", "none");
|
||||
_http.DefaultRequestHeaders.Add("sec-fetch-user", "?1");
|
||||
_http.DefaultRequestHeaders.Add("upgrade-insecure-requests", "1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scarica tutti i dati delle corse per una data specifica.
|
||||
/// </summary>
|
||||
public async Task<DataTable> ScrapeAsync(DateTime date,
|
||||
IProgress<int> progress = null,
|
||||
IProgress<string> status = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var dt = CreateTable();
|
||||
|
||||
try
|
||||
{
|
||||
// === FASE 1: Discovery – scarica la pagina form-guide ===
|
||||
status?.Report("Punters: Accesso al palinsesto...");
|
||||
progress?.Report(2);
|
||||
|
||||
var meetings = await DiscoverMeetingsAsync(date, ct);
|
||||
|
||||
if (meetings.Count == 0)
|
||||
{
|
||||
status?.Report("Punters: Nessun meeting trovato per le nazioni selezionate");
|
||||
progress?.Report(100);
|
||||
return dt;
|
||||
}
|
||||
|
||||
// Conta totale corse per progresso
|
||||
int totalRaces = meetings.Sum(m => m.Races.Count);
|
||||
status?.Report($"Punters: {meetings.Count} meeting, {totalRaces} corse trovate");
|
||||
progress?.Report(10);
|
||||
|
||||
// === FASE 2: Extraction – scarica ogni pagina corsa ===
|
||||
int completed = 0;
|
||||
int errors = 0;
|
||||
|
||||
foreach (var meeting in meetings)
|
||||
{
|
||||
foreach (var race in meeting.Races)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
status?.Report($"Punters: {meeting.Track} R{race.Number}/{meeting.Races.Count} " +
|
||||
$"({completed + 1}/{totalRaces})");
|
||||
|
||||
try
|
||||
{
|
||||
await RandomDelayAsync(ct);
|
||||
await FetchRaceRunners(dt, race.Url, meeting, race, ct);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors++;
|
||||
System.Diagnostics.Debug.WriteLine(
|
||||
$"Errore Punters {meeting.Track} R{race.Number}: {ex.Message}");
|
||||
}
|
||||
|
||||
completed++;
|
||||
int pct = 10 + (int)((double)completed / Math.Max(totalRaces, 1) * 88);
|
||||
progress?.Report(Math.Min(pct, 98));
|
||||
}
|
||||
}
|
||||
|
||||
progress?.Report(100);
|
||||
string errMsg = errors > 0 ? $" ({errors} errori)" : "";
|
||||
string countriesStr = string.Join("+", Countries.Select(c => c.ToUpper()));
|
||||
status?.Report($"Punters [{countriesStr}]: {dt.Rows.Count} corridori in " +
|
||||
$"{meetings.Count} meeting{errMsg}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
status?.Report("Scraping annullato");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
status?.Report($"Errore Punters: {ex.Message}");
|
||||
}
|
||||
|
||||
return dt;
|
||||
}
|
||||
|
||||
#region Discovery
|
||||
|
||||
/// <summary>
|
||||
/// Accede alla pagina form-guide e scopre i meeting per le nazioni selezionate.
|
||||
/// La pagina Punters ha sezioni raggruppate per paese con id="country-XX".
|
||||
/// </summary>
|
||||
private async Task<List<MeetingInfo>> DiscoverMeetingsAsync(DateTime date, CancellationToken ct)
|
||||
{
|
||||
var meetings = new List<MeetingInfo>();
|
||||
|
||||
// Punters mostra "Today" di default; la data è basata sul timezone australiano.
|
||||
// Per UK/IE con date specifiche, proviamo il percorso con data.
|
||||
string html = await FetchPageAsync(FormGuideUrl, ct);
|
||||
if (string.IsNullOrEmpty(html)) return meetings;
|
||||
|
||||
var doc = await ParseHtmlAsync(html);
|
||||
|
||||
// Cerca le sezioni per ogni nazione richiesta
|
||||
foreach (string country in Countries)
|
||||
{
|
||||
string countryUpper = country.ToUpper();
|
||||
string selectorId = $"country-{countryUpper}";
|
||||
|
||||
// Cerca la tabella con id="country-XX"
|
||||
var countrySection = doc.QuerySelector($"#{selectorId}");
|
||||
|
||||
if (countrySection == null)
|
||||
{
|
||||
// Prova a cercare in tutto il documento per sezioni paese
|
||||
// che contengono il codice nel testo
|
||||
var allMeetingStates = doc.QuerySelectorAll(
|
||||
".race-meetings-desktop__meeting-state");
|
||||
foreach (var stateEl in allMeetingStates)
|
||||
{
|
||||
if (string.Equals(stateEl.TextContent.Trim(), countryUpper,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Risali alla riga del meeting
|
||||
var row = stateEl.Closest("tr");
|
||||
if (row != null)
|
||||
{
|
||||
var meetingInfo = ParseMeetingRow(row, countryUpper);
|
||||
if (meetingInfo != null)
|
||||
meetings.Add(meetingInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parsa le righe della tabella meeting
|
||||
var rows = countrySection.QuerySelectorAll(
|
||||
"tbody tr.race-meetings-desktop__races-row");
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var meetingInfo = ParseMeetingRow(row, countryUpper);
|
||||
if (meetingInfo != null)
|
||||
meetings.Add(meetingInfo);
|
||||
}
|
||||
}
|
||||
|
||||
return meetings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsifica una riga di meeting nella tabella form-guide.
|
||||
/// Estrae nome track e URL di ogni singola corsa.
|
||||
/// </summary>
|
||||
private MeetingInfo ParseMeetingRow(IElement row, string country)
|
||||
{
|
||||
// Nome track
|
||||
var nameLink = row.QuerySelector(
|
||||
".race-meetings-desktop__meeting-name a");
|
||||
if (nameLink == null) return null;
|
||||
|
||||
string trackName = nameLink.TextContent.Trim();
|
||||
string trackUrl = nameLink.GetAttribute("href") ?? "";
|
||||
|
||||
// Condizioni pista
|
||||
string condition = "";
|
||||
var condEl = row.QuerySelector("[class*='track-condition']");
|
||||
if (condEl != null)
|
||||
condition = condEl.TextContent.Trim();
|
||||
|
||||
// Meteo
|
||||
string weather = "";
|
||||
var weatherImg = row.QuerySelector(".weather__icon img");
|
||||
if (weatherImg != null)
|
||||
weather = weatherImg.GetAttribute("alt")?.Replace("Icon ", "") ?? "";
|
||||
|
||||
// Raccogli i link delle corse individuali
|
||||
var raceCards = row.QuerySelectorAll("td.race-meetings-desktop__event");
|
||||
var races = new List<RaceRef>();
|
||||
int raceNum = 0;
|
||||
|
||||
foreach (var td in raceCards)
|
||||
{
|
||||
raceNum++;
|
||||
var link = td.QuerySelector("a.event-card");
|
||||
if (link == null) continue;
|
||||
|
||||
string href = link.GetAttribute("href");
|
||||
if (string.IsNullOrEmpty(href)) continue;
|
||||
|
||||
// Controlla se la corsa ha dati (non è "no-data")
|
||||
if (link.ClassList.Contains("event-card--no-data")) continue;
|
||||
|
||||
races.Add(new RaceRef
|
||||
{
|
||||
Number = raceNum,
|
||||
Url = href.StartsWith("http") ? href : BaseUrl + href
|
||||
});
|
||||
}
|
||||
|
||||
if (races.Count == 0) return null;
|
||||
|
||||
return new MeetingInfo
|
||||
{
|
||||
Track = trackName,
|
||||
TrackUrl = trackUrl.StartsWith("http") ? trackUrl : BaseUrl + trackUrl,
|
||||
Country = country,
|
||||
Condition = condition,
|
||||
Weather = weather,
|
||||
Races = races
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Race Page Extraction
|
||||
|
||||
/// <summary>
|
||||
/// Scarica la pagina di una singola corsa ed estrae i dati dei corridori.
|
||||
/// La pagina Punters SSR contiene tabelle/liste con i dettagli di ogni cavallo.
|
||||
/// </summary>
|
||||
private async Task FetchRaceRunners(DataTable dt, string raceUrl,
|
||||
MeetingInfo meeting, RaceRef race, CancellationToken ct)
|
||||
{
|
||||
string html = await FetchPageAsync(raceUrl, ct);
|
||||
if (string.IsNullOrEmpty(html)) return;
|
||||
|
||||
var doc = await ParseHtmlAsync(html);
|
||||
|
||||
// Estrai nome corsa dall'header della pagina
|
||||
string raceName = ExtractRaceName(doc);
|
||||
string distance = ExtractMetadata(doc, "distance", "dist");
|
||||
string raceClass = ExtractMetadata(doc, "class", "grade");
|
||||
string prize = ExtractMetadata(doc, "prize", "prizemoney", "purse");
|
||||
string startTime = ExtractMetadata(doc, "time", "start");
|
||||
|
||||
// Cerca la tabella/lista dei corridori.
|
||||
// Punters usa diverse strutture: tabelle, card, liste.
|
||||
// Proviamo diverse strategie di parsing.
|
||||
var runners = ExtractRunners(doc);
|
||||
|
||||
if (runners.Count == 0)
|
||||
{
|
||||
// Fallback: cerca qualsiasi tabella con dati di cavalli
|
||||
runners = ExtractRunnersFromTable(doc);
|
||||
}
|
||||
|
||||
foreach (var runner in runners)
|
||||
{
|
||||
var row = dt.NewRow();
|
||||
row["Meeting"] = meeting.Track;
|
||||
row["Paese"] = meeting.Country;
|
||||
row["Corsa N."] = race.Number;
|
||||
row["Nome Corsa"] = raceName;
|
||||
row["Orario"] = startTime;
|
||||
row["Distanza"] = distance;
|
||||
row["Terreno"] = meeting.Condition;
|
||||
row["Classe"] = raceClass;
|
||||
row["Meteo"] = meeting.Weather;
|
||||
row["Premio"] = prize;
|
||||
row["N. Corridori"] = runners.Count;
|
||||
row["Num"] = runner.Number;
|
||||
row["Cavallo"] = runner.Name;
|
||||
row["Fantino"] = runner.Jockey;
|
||||
row["Allenatore"] = runner.Trainer;
|
||||
row["Peso"] = runner.Weight;
|
||||
row["Box"] = runner.Barrier;
|
||||
row["Forma"] = runner.Form;
|
||||
row["Eta'"] = runner.Age;
|
||||
row["Ritirato"] = runner.Scratched ? "Si" : "";
|
||||
dt.Rows.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
private string ExtractRaceName(IDocument doc)
|
||||
{
|
||||
// Prova vari selettori per il nome della corsa
|
||||
var candidates = new[]
|
||||
{
|
||||
"h1", ".race-header__name", ".race-name",
|
||||
"[data-test='race-name']", ".event-header__title"
|
||||
};
|
||||
|
||||
foreach (var sel in candidates)
|
||||
{
|
||||
var el = doc.QuerySelector(sel);
|
||||
if (el != null)
|
||||
{
|
||||
string text = el.TextContent.Trim();
|
||||
if (!string.IsNullOrEmpty(text) && text.Length < 200)
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estrae un metadato della corsa cercando tra diversi selettori e pattern.
|
||||
/// </summary>
|
||||
private string ExtractMetadata(IDocument doc, params string[] keywords)
|
||||
{
|
||||
// Cerca nelle sezioni di info/dettaglio della corsa
|
||||
var infoElements = doc.QuerySelectorAll(
|
||||
".race-info dt, .race-info dd, .race-details span, " +
|
||||
".race-header__info span, .event-info span, " +
|
||||
"[class*='race-detail'], [class*='event-detail']");
|
||||
|
||||
foreach (var el in infoElements)
|
||||
{
|
||||
string cls = el.ClassName ?? "";
|
||||
string text = el.TextContent.Trim();
|
||||
|
||||
foreach (string kw in keywords)
|
||||
{
|
||||
if (cls.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
|
||||
text.StartsWith(kw, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Se è un dt, cerca il successivo dd
|
||||
if (el.TagName.Equals("DT", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var dd = el.NextElementSibling;
|
||||
return dd?.TextContent.Trim() ?? "";
|
||||
}
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Estrae i corridori cercando strutture note del sito Punters.
|
||||
/// Il sito usa card/righe per ogni corridore con classi CSS riconoscibili.
|
||||
/// </summary>
|
||||
private List<RunnerData> ExtractRunners(IDocument doc)
|
||||
{
|
||||
var runners = new List<RunnerData>();
|
||||
|
||||
// Strategia 1: cerca elementi con classi runner-like
|
||||
var runnerSelectors = new[]
|
||||
{
|
||||
".runner-row", ".runner-card", ".runner",
|
||||
"[data-test*='runner']", ".form-guide-runner",
|
||||
".runner-list__item", "tr[class*='runner']",
|
||||
".horse-row", ".selection-row"
|
||||
};
|
||||
|
||||
IEnumerable<IElement> runnerElements = null;
|
||||
|
||||
foreach (var sel in runnerSelectors)
|
||||
{
|
||||
var elements = doc.QuerySelectorAll(sel);
|
||||
if (elements.Length > 0)
|
||||
{
|
||||
runnerElements = elements;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (runnerElements == null) return runners;
|
||||
|
||||
foreach (var el in runnerElements)
|
||||
{
|
||||
var runner = ParseRunnerElement(el);
|
||||
if (runner != null)
|
||||
runners.Add(runner);
|
||||
}
|
||||
|
||||
return runners;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback: estrae corridori da una tabella HTML generica.
|
||||
/// </summary>
|
||||
private List<RunnerData> ExtractRunnersFromTable(IDocument doc)
|
||||
{
|
||||
var runners = new List<RunnerData>();
|
||||
|
||||
// Cerca tabelle che sembrano contenere dati di corse
|
||||
var tables = doc.QuerySelectorAll("table");
|
||||
|
||||
foreach (var table in tables)
|
||||
{
|
||||
var headers = table.QuerySelectorAll("th")
|
||||
.Select(th => th.TextContent.Trim().ToLower())
|
||||
.ToList();
|
||||
|
||||
// Identifica se la tabella contiene dati di corridori
|
||||
bool hasHorseColumn = headers.Any(h =>
|
||||
h.Contains("horse") || h.Contains("runner") ||
|
||||
h.Contains("name") || h.Contains("cavallo"));
|
||||
|
||||
if (!hasHorseColumn) continue;
|
||||
|
||||
int nameIdx = headers.FindIndex(h =>
|
||||
h.Contains("horse") || h.Contains("runner") || h.Contains("name"));
|
||||
int numIdx = headers.FindIndex(h =>
|
||||
h == "no" || h == "#" || h.Contains("number") || h.Contains("tab"));
|
||||
int jockeyIdx = headers.FindIndex(h =>
|
||||
h.Contains("jockey") || h.Contains("rider"));
|
||||
int trainerIdx = headers.FindIndex(h =>
|
||||
h.Contains("trainer"));
|
||||
int weightIdx = headers.FindIndex(h =>
|
||||
h.Contains("weight") || h.Contains("wgt"));
|
||||
int barrierIdx = headers.FindIndex(h =>
|
||||
h.Contains("barrier") || h.Contains("gate") || h.Contains("draw"));
|
||||
int formIdx = headers.FindIndex(h =>
|
||||
h.Contains("form") || h.Contains("last"));
|
||||
|
||||
var rows = table.QuerySelectorAll("tbody tr");
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var cells = row.QuerySelectorAll("td").ToList();
|
||||
if (cells.Count < 2) continue;
|
||||
|
||||
var runner = new RunnerData();
|
||||
|
||||
if (nameIdx >= 0 && nameIdx < cells.Count)
|
||||
runner.Name = CleanText(cells[nameIdx].TextContent);
|
||||
if (numIdx >= 0 && numIdx < cells.Count)
|
||||
runner.Number = ParseInt(cells[numIdx].TextContent);
|
||||
if (jockeyIdx >= 0 && jockeyIdx < cells.Count)
|
||||
runner.Jockey = CleanText(cells[jockeyIdx].TextContent);
|
||||
if (trainerIdx >= 0 && trainerIdx < cells.Count)
|
||||
runner.Trainer = CleanText(cells[trainerIdx].TextContent);
|
||||
if (weightIdx >= 0 && weightIdx < cells.Count)
|
||||
runner.Weight = CleanText(cells[weightIdx].TextContent);
|
||||
if (barrierIdx >= 0 && barrierIdx < cells.Count)
|
||||
runner.Barrier = CleanText(cells[barrierIdx].TextContent);
|
||||
if (formIdx >= 0 && formIdx < cells.Count)
|
||||
runner.Form = CleanText(cells[formIdx].TextContent);
|
||||
|
||||
if (!string.IsNullOrEmpty(runner.Name))
|
||||
runners.Add(runner);
|
||||
}
|
||||
|
||||
if (runners.Count > 0) break; // Usa la prima tabella valida
|
||||
}
|
||||
|
||||
return runners;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsifica un singolo elemento corridore (card, riga, div) estraendo
|
||||
/// i dati con selettori e pattern euristici.
|
||||
/// </summary>
|
||||
private RunnerData ParseRunnerElement(IElement el)
|
||||
{
|
||||
var runner = new RunnerData();
|
||||
|
||||
// Numero
|
||||
var numEl = el.QuerySelector(
|
||||
"[class*='number'], [class*='tab-no'], .runner-number, " +
|
||||
"[data-test*='number'], .saddle-cloth");
|
||||
if (numEl != null)
|
||||
runner.Number = ParseInt(numEl.TextContent);
|
||||
|
||||
// Nome cavallo
|
||||
var nameEl = el.QuerySelector(
|
||||
"[class*='horse-name'], [class*='runner-name'], .horse a, " +
|
||||
"[data-test*='horse-name'], [class*='selection-name'], h3 a, h4 a");
|
||||
if (nameEl != null)
|
||||
runner.Name = CleanText(nameEl.TextContent);
|
||||
else
|
||||
{
|
||||
// Fallback: il primo link o header
|
||||
var link = el.QuerySelector("a[href*='/horses/']");
|
||||
if (link != null)
|
||||
runner.Name = CleanText(link.TextContent);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(runner.Name)) return null;
|
||||
|
||||
// Fantino
|
||||
var jockeyEl = el.QuerySelector(
|
||||
"[class*='jockey'], a[href*='/jockeys/'], [data-test*='jockey']");
|
||||
if (jockeyEl != null)
|
||||
runner.Jockey = CleanText(jockeyEl.TextContent);
|
||||
|
||||
// Allenatore
|
||||
var trainerEl = el.QuerySelector(
|
||||
"[class*='trainer'], a[href*='/trainers/'], [data-test*='trainer']");
|
||||
if (trainerEl != null)
|
||||
runner.Trainer = CleanText(trainerEl.TextContent);
|
||||
|
||||
// Peso
|
||||
var weightEl = el.QuerySelector(
|
||||
"[class*='weight'], [data-test*='weight']");
|
||||
if (weightEl != null)
|
||||
runner.Weight = CleanText(weightEl.TextContent);
|
||||
|
||||
// Barrier/Gate
|
||||
var barrierEl = el.QuerySelector(
|
||||
"[class*='barrier'], [class*='gate'], [class*='draw'], " +
|
||||
"[data-test*='barrier']");
|
||||
if (barrierEl != null)
|
||||
runner.Barrier = CleanText(barrierEl.TextContent);
|
||||
|
||||
// Forma
|
||||
var formEl = el.QuerySelector(
|
||||
"[class*='form'], [class*='last-starts'], [data-test*='form']");
|
||||
if (formEl != null)
|
||||
runner.Form = CleanText(formEl.TextContent);
|
||||
|
||||
// Età
|
||||
var ageEl = el.QuerySelector("[class*='age']");
|
||||
if (ageEl != null)
|
||||
runner.Age = CleanText(ageEl.TextContent);
|
||||
|
||||
// Ritirato (scratched)
|
||||
runner.Scratched = el.ClassList.Any(c =>
|
||||
c.Contains("scratched", StringComparison.OrdinalIgnoreCase)) ||
|
||||
el.QuerySelector("[class*='scratched']") != null;
|
||||
|
||||
return runner;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region HTTP & Parsing
|
||||
|
||||
private async Task<string> FetchPageAsync(string url, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Add("Referer", BaseUrl + "/form-guide/");
|
||||
|
||||
using var response = await _http.SendAsync(request, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine(
|
||||
$"[Punters] HTTP {(int)response.StatusCode} per {url}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadAsStringAsync(ct);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"[Punters] Errore fetch {url}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IDocument> ParseHtmlAsync(string html)
|
||||
{
|
||||
var config = Configuration.Default;
|
||||
var context = BrowsingContext.New(config);
|
||||
return await context.OpenAsync(req => req.Content(html));
|
||||
}
|
||||
|
||||
private static async Task RandomDelayAsync(CancellationToken ct)
|
||||
{
|
||||
int delay = Rng.Next(MinDelayMs, MaxDelayMs);
|
||||
await Task.Delay(delay, ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region DataTable
|
||||
|
||||
private DataTable CreateTable()
|
||||
{
|
||||
var dt = new DataTable();
|
||||
dt.Columns.Add("Meeting", typeof(string));
|
||||
dt.Columns.Add("Paese", typeof(string));
|
||||
dt.Columns.Add("Corsa N.", typeof(int));
|
||||
dt.Columns.Add("Nome Corsa", typeof(string));
|
||||
dt.Columns.Add("Orario", typeof(string));
|
||||
dt.Columns.Add("Distanza", typeof(string));
|
||||
dt.Columns.Add("Terreno", typeof(string));
|
||||
dt.Columns.Add("Classe", typeof(string));
|
||||
dt.Columns.Add("Meteo", typeof(string));
|
||||
dt.Columns.Add("Premio", typeof(string));
|
||||
dt.Columns.Add("N. Corridori", typeof(int));
|
||||
dt.Columns.Add("Num", typeof(int));
|
||||
dt.Columns.Add("Cavallo", typeof(string));
|
||||
dt.Columns.Add("Fantino", typeof(string));
|
||||
dt.Columns.Add("Allenatore", typeof(string));
|
||||
dt.Columns.Add("Peso", typeof(string));
|
||||
dt.Columns.Add("Box", typeof(string));
|
||||
dt.Columns.Add("Forma", typeof(string));
|
||||
dt.Columns.Add("Eta'", typeof(string));
|
||||
dt.Columns.Add("Ritirato", typeof(string));
|
||||
return dt;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Models
|
||||
|
||||
private class MeetingInfo
|
||||
{
|
||||
public string Track { get; set; }
|
||||
public string TrackUrl { get; set; }
|
||||
public string Country { get; set; }
|
||||
public string Condition { get; set; }
|
||||
public string Weather { get; set; }
|
||||
public List<RaceRef> Races { get; set; } = new();
|
||||
}
|
||||
|
||||
private class RaceRef
|
||||
{
|
||||
public int Number { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
|
||||
private class RunnerData
|
||||
{
|
||||
public int Number { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Jockey { get; set; }
|
||||
public string Trainer { get; set; }
|
||||
public string Weight { get; set; }
|
||||
public string Barrier { get; set; }
|
||||
public string Form { get; set; }
|
||||
public string Age { get; set; }
|
||||
public bool Scratched { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helpers
|
||||
|
||||
private static string CleanText(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return "";
|
||||
return text.Trim().Replace("\n", " ").Replace("\r", "")
|
||||
.Replace(" ", " ").Trim();
|
||||
}
|
||||
|
||||
private static int ParseInt(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return 0;
|
||||
var cleaned = new string(text.Where(c => char.IsDigit(c)).ToArray());
|
||||
return int.TryParse(cleaned, out int val) ? val : 0;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1238,6 +1238,7 @@
|
||||
<ComboBox x:Name="cmbRcDataSource" Margin="0,0,0,12">
|
||||
<ComboBoxItem Content="API - FormFav"/>
|
||||
<ComboBoxItem Content="API - RacingAPI"/>
|
||||
<ComboBoxItem Content="Scraping - Punters"/>
|
||||
<ComboBoxItem Content="CSV - Punters"/>
|
||||
<ComboBoxItem Content="CSV - Racing Post"/>
|
||||
<ComboBoxItem Content="CSV - Timeform"/>
|
||||
|
||||
@@ -532,13 +532,13 @@ namespace HorseRacingPredictor
|
||||
if (pnlFbApiOptions != null)
|
||||
pnlFbApiOptions.Visibility = IsFbApiSource() ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
// Racing: toggle API vs CSV controls
|
||||
// Racing: toggle API/Scraping vs CSV controls
|
||||
if (btnDownloadRc != null && btnBrowseCsvRc != null)
|
||||
{
|
||||
bool rcIsApi = IsRcApiSource();
|
||||
dpRacing.Visibility = rcIsApi ? Visibility.Visible : Visibility.Collapsed;
|
||||
btnDownloadRc.Visibility = rcIsApi ? Visibility.Visible : Visibility.Collapsed;
|
||||
btnBrowseCsvRc.Visibility = rcIsApi ? Visibility.Collapsed : Visibility.Visible;
|
||||
bool rcIsDownload = IsRcDownloadSource();
|
||||
dpRacing.Visibility = rcIsDownload ? Visibility.Visible : Visibility.Collapsed;
|
||||
btnDownloadRc.Visibility = rcIsDownload ? Visibility.Visible : Visibility.Collapsed;
|
||||
btnBrowseCsvRc.Visibility = rcIsDownload ? Visibility.Collapsed : Visibility.Visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +554,18 @@ namespace HorseRacingPredictor
|
||||
return src.StartsWith("API", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private bool IsRcScrapingSource()
|
||||
{
|
||||
var src = (cmbRcDataSource?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "";
|
||||
return src.StartsWith("Scraping", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>True per sorgenti che usano il pulsante Scarica (API + Scraping).</summary>
|
||||
private bool IsRcDownloadSource()
|
||||
{
|
||||
return IsRcApiSource() || IsRcScrapingSource();
|
||||
}
|
||||
|
||||
// ———————————— FOOTBALL ENDPOINT / SUPPLEMENTARY POPUPS ————————————
|
||||
|
||||
private static readonly (string key, string label, bool defaultChecked)[] FbEndpointItems =
|
||||
@@ -888,22 +900,38 @@ namespace HorseRacingPredictor
|
||||
try
|
||||
{
|
||||
pbRacing.Value = 0;
|
||||
lblStatusRc.Text = "Scaricamento corse da FormFav...";
|
||||
btnDownloadRcIcon.Text = "\u2718";
|
||||
btnDownloadRcText.Text = "Annulla";
|
||||
dpRacing.IsEnabled = false;
|
||||
btnExportRcCsv.IsEnabled = false;
|
||||
|
||||
// Applica impostazioni correnti al manager
|
||||
ApplyRacingSettings();
|
||||
|
||||
var progress = new Progress<int>(v => pbRacing.Value = v);
|
||||
var status = new Progress<string>(s => lblStatusRc.Text = s);
|
||||
|
||||
var date = dpRacing.SelectedDate ?? DateTime.Today;
|
||||
|
||||
var table = await Task.Run(() =>
|
||||
_racingManager.GetAllRacesForDate(date, progress, status, ct), ct);
|
||||
DataTable table;
|
||||
|
||||
if (IsRcScrapingSource())
|
||||
{
|
||||
// === SCRAPING PUNTERS ===
|
||||
lblStatusRc.Text = "Scraping dati da Punters.com.au...";
|
||||
var scraper = new HorseRacing.Scraping.PuntersScraper();
|
||||
scraper.Countries = GetSelectedCountries()
|
||||
.Select(c => c.ToUpper())
|
||||
.ToList();
|
||||
if (scraper.Countries.Count == 0)
|
||||
scraper.Countries = new List<string> { "GB", "IE" };
|
||||
|
||||
table = await scraper.ScrapeAsync(date, progress, status, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// === API (FormFav / RacingAPI) ===
|
||||
lblStatusRc.Text = "Scaricamento corse da FormFav...";
|
||||
ApplyRacingSettings();
|
||||
table = await Task.Run(() =>
|
||||
_racingManager.GetAllRacesForDate(date, progress, status, ct), ct);
|
||||
}
|
||||
|
||||
_racingData = table;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user