diff --git a/HorseRacingPredictor/HorseRacingPredictor/BettingPredictor.csproj b/HorseRacingPredictor/HorseRacingPredictor/BettingPredictor.csproj index 86ee796..509b273 100644 --- a/HorseRacingPredictor/HorseRacingPredictor/BettingPredictor.csproj +++ b/HorseRacingPredictor/HorseRacingPredictor/BettingPredictor.csproj @@ -18,6 +18,7 @@ bin\x64\Release\ + diff --git a/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/HorseRacing/Scraping/PuntersScraper.cs b/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/HorseRacing/Scraping/PuntersScraper.cs new file mode 100644 index 0000000..c2c371d --- /dev/null +++ b/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/HorseRacing/Scraping/PuntersScraper.cs @@ -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 +{ + /// + /// 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. + /// + 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(); + + /// Codici paese da scaricare (es. "GB", "IE"). + public List 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"); + } + + /// + /// Scarica tutti i dati delle corse per una data specifica. + /// + public async Task ScrapeAsync(DateTime date, + IProgress progress = null, + IProgress 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 + + /// + /// 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". + /// + private async Task> DiscoverMeetingsAsync(DateTime date, CancellationToken ct) + { + var meetings = new List(); + + // 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; + } + + /// + /// Parsifica una riga di meeting nella tabella form-guide. + /// Estrae nome track e URL di ogni singola corsa. + /// + 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(); + 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 + + /// + /// 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. + /// + 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 ""; + } + + /// + /// Estrae un metadato della corsa cercando tra diversi selettori e pattern. + /// + 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 ""; + } + + /// + /// Estrae i corridori cercando strutture note del sito Punters. + /// Il sito usa card/righe per ogni corridore con classi CSS riconoscibili. + /// + private List ExtractRunners(IDocument doc) + { + var runners = new List(); + + // 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 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; + } + + /// + /// Fallback: estrae corridori da una tabella HTML generica. + /// + private List ExtractRunnersFromTable(IDocument doc) + { + var runners = new List(); + + // 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; + } + + /// + /// Parsifica un singolo elemento corridore (card, riga, div) estraendo + /// i dati con selettori e pattern euristici. + /// + 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 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 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 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 + } +} diff --git a/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml b/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml index 89c1db1..c25fdc0 100644 --- a/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml +++ b/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml @@ -1238,6 +1238,7 @@ + diff --git a/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml.cs b/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml.cs index 201765b..78c3d9c 100644 --- a/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml.cs +++ b/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml.cs @@ -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); + } + + /// True per sorgenti che usano il pulsante Scarica (API + Scraping). + 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(v => pbRacing.Value = v); var status = new Progress(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 { "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;