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:
2026-04-14 23:48:53 +02:00
parent 0d3769db79
commit 923f4c761c
4 changed files with 762 additions and 12 deletions
@@ -18,6 +18,7 @@
<OutputPath>bin\x64\Release\</OutputPath> <OutputPath>bin\x64\Release\</OutputPath>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AngleSharp" Version="1.4.0" />
<PackageReference Include="CsvHelper" Version="33.1.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.AsyncInterfaces" Version="11.0.0-preview.2.26159.112" />
<PackageReference Include="Microsoft.Bcl.HashCode" Version="6.0.0" /> <PackageReference Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
@@ -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"> <ComboBox x:Name="cmbRcDataSource" Margin="0,0,0,12">
<ComboBoxItem Content="API - FormFav"/> <ComboBoxItem Content="API - FormFav"/>
<ComboBoxItem Content="API - RacingAPI"/> <ComboBoxItem Content="API - RacingAPI"/>
<ComboBoxItem Content="Scraping - Punters"/>
<ComboBoxItem Content="CSV - Punters"/> <ComboBoxItem Content="CSV - Punters"/>
<ComboBoxItem Content="CSV - Racing Post"/> <ComboBoxItem Content="CSV - Racing Post"/>
<ComboBoxItem Content="CSV - Timeform"/> <ComboBoxItem Content="CSV - Timeform"/>
@@ -532,13 +532,13 @@ namespace HorseRacingPredictor
if (pnlFbApiOptions != null) if (pnlFbApiOptions != null)
pnlFbApiOptions.Visibility = IsFbApiSource() ? Visibility.Visible : Visibility.Collapsed; 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) if (btnDownloadRc != null && btnBrowseCsvRc != null)
{ {
bool rcIsApi = IsRcApiSource(); bool rcIsDownload = IsRcDownloadSource();
dpRacing.Visibility = rcIsApi ? Visibility.Visible : Visibility.Collapsed; dpRacing.Visibility = rcIsDownload ? Visibility.Visible : Visibility.Collapsed;
btnDownloadRc.Visibility = rcIsApi ? Visibility.Visible : Visibility.Collapsed; btnDownloadRc.Visibility = rcIsDownload ? Visibility.Visible : Visibility.Collapsed;
btnBrowseCsvRc.Visibility = rcIsApi ? Visibility.Collapsed : Visibility.Visible; btnBrowseCsvRc.Visibility = rcIsDownload ? Visibility.Collapsed : Visibility.Visible;
} }
} }
@@ -554,6 +554,18 @@ namespace HorseRacingPredictor
return src.StartsWith("API", StringComparison.OrdinalIgnoreCase); 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 ———————————— // ———————————— FOOTBALL ENDPOINT / SUPPLEMENTARY POPUPS ————————————
private static readonly (string key, string label, bool defaultChecked)[] FbEndpointItems = private static readonly (string key, string label, bool defaultChecked)[] FbEndpointItems =
@@ -888,22 +900,38 @@ namespace HorseRacingPredictor
try try
{ {
pbRacing.Value = 0; pbRacing.Value = 0;
lblStatusRc.Text = "Scaricamento corse da FormFav...";
btnDownloadRcIcon.Text = "\u2718"; btnDownloadRcIcon.Text = "\u2718";
btnDownloadRcText.Text = "Annulla"; btnDownloadRcText.Text = "Annulla";
dpRacing.IsEnabled = false; dpRacing.IsEnabled = false;
btnExportRcCsv.IsEnabled = false; btnExportRcCsv.IsEnabled = false;
// Applica impostazioni correnti al manager
ApplyRacingSettings();
var progress = new Progress<int>(v => pbRacing.Value = v); var progress = new Progress<int>(v => pbRacing.Value = v);
var status = new Progress<string>(s => lblStatusRc.Text = s); var status = new Progress<string>(s => lblStatusRc.Text = s);
var date = dpRacing.SelectedDate ?? DateTime.Today; var date = dpRacing.SelectedDate ?? DateTime.Today;
var table = await Task.Run(() => DataTable table;
_racingManager.GetAllRacesForDate(date, progress, status, ct), ct);
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; _racingData = table;