diff --git a/HorseRacingPredictor/HorseRacingPredictor/Football/FootballDownloadOptions.cs b/HorseRacingPredictor/HorseRacingPredictor/Football/FootballDownloadOptions.cs
index f2f360d..6f69b50 100644
--- a/HorseRacingPredictor/HorseRacingPredictor/Football/FootballDownloadOptions.cs
+++ b/HorseRacingPredictor/HorseRacingPredictor/Football/FootballDownloadOptions.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
namespace HorseRacingPredictor.Football
@@ -101,6 +102,25 @@ namespace HorseRacingPredictor.Football
///
public int MinRemainingQuota { get; set; } = 10;
+ ///
+ /// Ora minima per filtrare le fixture (null = nessun limite inferiore).
+ /// Il filtro viene applicato lato client dopo il download.
+ ///
+ public TimeSpan? TimeFrom { get; set; }
+
+ ///
+ /// Ora massima per filtrare le fixture (null = nessun limite superiore).
+ /// Il filtro viene applicato lato client dopo il download.
+ ///
+ public TimeSpan? TimeTo { get; set; }
+
+ ///
+ /// Opzioni per l'arricchimento tramite ricerca web.
+ /// Quando abilitato, per ogni partita viene eseguita una ricerca e i risultati
+ /// vengono aggiunti in una colonna dedicata del CSV.
+ ///
+ public WebSearch.WebSearchOptions WebSearch { get; set; } = new();
+
///
/// Restituisce la stagione corrente calcolata (anno corrente o anno-1 se prima di luglio).
///
diff --git a/HorseRacingPredictor/HorseRacingPredictor/Football/Main.cs b/HorseRacingPredictor/HorseRacingPredictor/Football/Main.cs
index aa4eab6..00974fe 100644
--- a/HorseRacingPredictor/HorseRacingPredictor/Football/Main.cs
+++ b/HorseRacingPredictor/HorseRacingPredictor/Football/Main.cs
@@ -9,6 +9,7 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using Newtonsoft.Json.Linq;
using Microsoft.Data.SqlClient;
+using HorseRacingPredictor.Infrastructure;
namespace HorseRacingPredictor.Football
{
@@ -191,8 +192,10 @@ namespace HorseRacingPredictor.Football
if (options.DownloadFixtures)
{
ReportProgress("Scaricamento elenco partite...");
+ AppLogger.Info("Football", $"[Step 1] Download fixtures per {date:yyyy-MM-dd}");
fixturesResponse = GetFixtures(date);
table = CreateFixturesDataTable(fixturesResponse, options);
+ AppLogger.Info("Football", $"[Step 1] Fixture ottenute: {table.Rows.Count}");
}
else
{
@@ -205,6 +208,7 @@ namespace HorseRacingPredictor.Football
if (options.DownloadOdds)
{
ReportProgress($"Scaricamento quote (max {options.OddsMaxPages} pagine, bookmaker {options.BookmakerId})...");
+ AppLogger.Info("Football", $"[Step 2] Download quote per {fixtureCount} fixture");
var oddsResponses = GetOdds(date, options);
ParseOddsIntoTable(table, oddsResponses, options.BookmakerId);
}
@@ -216,6 +220,7 @@ namespace HorseRacingPredictor.Football
? Math.Min(fixtureCount, options.MaxFixturesForDetails)
: fixtureCount;
ReportProgress($"Scaricamento previsioni per {maxPred} partite...");
+ AppLogger.Info("Football", $"[Step 3] Download previsioni per {maxPred} partite");
EnrichWithPredictions(table, fixturesResponse, options, statusCallback, progressCallback, ref currentStep, totalSteps);
}
@@ -223,6 +228,7 @@ namespace HorseRacingPredictor.Football
if (options.DownloadStandings)
{
ReportProgress("Scaricamento classifiche...");
+ AppLogger.Info("Football", "[Step 4] Download classifiche");
EnrichWithStandings(table, options);
}
@@ -233,6 +239,7 @@ namespace HorseRacingPredictor.Football
? Math.Min(fixtureCount, options.MaxFixturesForDetails)
: fixtureCount;
ReportProgress($"Scaricamento scontri diretti per {maxH2H} partite...");
+ AppLogger.Info("Football", $"[Step 5] Download H2H per {maxH2H} partite");
EnrichWithH2H(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
}
@@ -240,6 +247,7 @@ namespace HorseRacingPredictor.Football
if (options.DownloadEvents)
{
ReportProgress("Scaricamento eventi partite...");
+ AppLogger.Info("Football", "[Step 6] Download eventi");
EnrichWithEvents(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
}
@@ -247,6 +255,7 @@ namespace HorseRacingPredictor.Football
if (options.DownloadLineups)
{
ReportProgress("Scaricamento formazioni...");
+ AppLogger.Info("Football", "[Step 7] Download formazioni");
EnrichWithLineups(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
}
@@ -254,6 +263,7 @@ namespace HorseRacingPredictor.Football
if (options.DownloadStatistics)
{
ReportProgress("Scaricamento statistiche partite...");
+ AppLogger.Info("Football", "[Step 8] Download statistiche");
EnrichWithStatistics(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
}
@@ -261,18 +271,33 @@ namespace HorseRacingPredictor.Football
if (options.DownloadInjuries)
{
ReportProgress("Scaricamento infortunati...");
+ AppLogger.Info("Football", "[Step 9] Download infortuni");
EnrichWithInjuries(table, date, options);
}
+ // ?? Step 10: Web Search AI ??
+ if (options.WebSearch?.Enabled == true)
+ {
+ ReportProgress($"Ricerca web per {table.Rows.Count} partite...");
+ AppLogger.Info("Football", $"[Step 10] Web Search avviato provider={options.WebSearch.Provider}, url={options.WebSearch.SearXNgUrl}, risultati_max={options.WebSearch.MaxResults}, lingua={options.WebSearch.Language}");
+ EnrichWithWebSearch(table, date, options, statusCallback, progressCallback, ref currentStep, totalSteps);
+ }
+ else if (options.WebSearch != null)
+ {
+ AppLogger.Debug("Football", $"[Step 10] Web Search disabilitato (Enabled={options.WebSearch.Enabled})");
+ }
+
// ?? Completamento ??
progressCallback?.Report(100);
string quotaMsg = _lastQuota.IsValid ? $" {_lastQuota}" : "";
statusCallback?.Report($"Trovate {table.Rows.Count} partite{quotaMsg}");
+ AppLogger.Info("Football", $"Download completato: {table.Rows.Count} partite, {table.Columns.Count} colonne");
return table;
}
catch (Exception ex)
{
_database.LogError("recupero partite del giorno", ex);
+ AppLogger.Error("Football", "Eccezione durante il download delle partite", ex);
statusCallback?.Report($"Errore: {ex.Message}");
return CreateEmptyResultTable();
}
@@ -294,6 +319,7 @@ namespace HorseRacingPredictor.Football
if (options.DownloadLineups) steps.Add("lineups");
if (options.DownloadStatistics) steps.Add("statistics");
if (options.DownloadInjuries) steps.Add("injuries");
+ if (options.WebSearch?.Enabled == true) steps.Add("websearch");
return steps;
}
@@ -885,12 +911,14 @@ namespace HorseRacingPredictor.Football
dataTable.Columns.Add("Infortunati Trasf.", typeof(int));
}
+ // Colonna web search (sempre in fondo, se abilitata)
+ if (options.WebSearch?.Enabled == true)
+ {
+ dataTable.Columns.Add("Info Web AI", typeof(string));
+ }
+
return dataTable;
}
-
- ///
- /// Crea un DataTable con le partite dalla risposta API
- ///
private DataTable CreateFixturesDataTable(RestResponse response, FootballDownloadOptions options = null)
{
options ??= new FootballDownloadOptions();
@@ -942,6 +970,12 @@ namespace HorseRacingPredictor.Football
if (fixtureEl.TryGetProperty("date", out var dateEl) && dateEl.ValueKind == JsonValueKind.String)
{
DateTime.TryParse(dateEl.GetString(), out var parsedDate);
+ // Filtra per intervallo orario se specificato
+ var tod = parsedDate.TimeOfDay;
+ if (options.TimeFrom.HasValue && tod < options.TimeFrom.Value)
+ continue;
+ if (options.TimeTo.HasValue && tod > options.TimeTo.Value)
+ continue;
row["Data / Ora"] = parsedDate;
}
else
@@ -1879,6 +1913,116 @@ namespace HorseRacingPredictor.Football
#endregion
+ #region Web Search Enrichment
+
+ ///
+ /// Arricchisce il DataTable con risultati di ricerca web per ogni partita.
+ /// I risultati vengono inseriti nella colonna "Info Web AI".
+ ///
+ private void EnrichWithWebSearch(
+ DataTable table, DateTime date,
+ FootballDownloadOptions options,
+ IProgress statusCallback,
+ IProgress progressCallback,
+ ref int currentStep, int totalSteps)
+ {
+ if (!table.Columns.Contains("Info Web AI"))
+ {
+ AppLogger.Warn("WebSearch", "Colonna 'Info Web AI' non trovata nel DataTable il download fixture potrebbe essere disabilitato");
+ return;
+ }
+ if (table.Rows.Count == 0)
+ {
+ AppLogger.Warn("WebSearch", "Nessuna riga nel DataTable web search saltata");
+ return;
+ }
+
+ var wsOptions = options.WebSearch;
+ if (wsOptions == null)
+ {
+ AppLogger.Warn("WebSearch", "WebSearchOptions θ null web search saltata");
+ return;
+ }
+
+ // SearXNG non richiede API key; per gli altri provider θ obbligatoria
+ bool needsApiKey = wsOptions.Provider != WebSearch.WebSearchProvider.SearXng;
+ if (needsApiKey && string.IsNullOrEmpty(wsOptions.ApiKey))
+ {
+ AppLogger.Warn("WebSearch", $"API key mancante per provider {wsOptions.Provider} web search saltata. Inserire la chiave nelle Impostazioni.");
+ statusCallback?.Report("Web Search: API key mancante configurare nelle Impostazioni");
+ return;
+ }
+
+ AppLogger.Info("WebSearch", $"Inizio arricchimento web {table.Rows.Count} partite, provider={wsOptions.Provider}, lingua={wsOptions.Language}, maxRisultati={wsOptions.MaxResults}, delay={wsOptions.DelayMs}ms");
+ if (wsOptions.Provider == WebSearch.WebSearchProvider.SearXng)
+ AppLogger.Info("WebSearch", $"SearXNG URL: {wsOptions.SearXNgUrl}");
+
+ var client = new WebSearch.WebSearchClient(wsOptions);
+
+ int done = 0;
+ int total = table.Rows.Count;
+ int ok = 0;
+ int empty = 0;
+ int errors = 0;
+
+ foreach (DataRow row in table.Rows)
+ {
+ try
+ {
+ string home = row["Casa"]?.ToString() ?? "";
+ string away = row["Trasferta"]?.ToString() ?? "";
+ string league = row["Campionato"]?.ToString() ?? "";
+ string country = row["Paese"]?.ToString() ?? "";
+
+ if (string.IsNullOrEmpty(home) || string.IsNullOrEmpty(away))
+ {
+ AppLogger.Debug("WebSearch", $"Riga {done + 1}: nome squadra vuoto saltata");
+ done++;
+ empty++;
+ continue;
+ }
+
+ string query = WebSearch.WebSearchClient.BuildQuery(home, away, league, country, date, wsOptions.Language);
+ AppLogger.Debug("WebSearch", $"[{done + 1}/{total}] Query: {query}");
+
+ statusCallback?.Report($"Ricerca web [{done + 1}/{total}]: {home} vs {away}...");
+
+ string snippet = Task.Run(() => client.SearchAsync(query)).GetAwaiter().GetResult();
+ row["Info Web AI"] = snippet;
+
+ if (string.IsNullOrEmpty(snippet))
+ {
+ AppLogger.Warn("WebSearch", $"[{done + 1}/{total}] {home} vs {away}: nessun risultato restituito");
+ empty++;
+ }
+ else
+ {
+ AppLogger.Debug("WebSearch", $"[{done + 1}/{total}] {home} vs {away}: {snippet.Length} caratteri ricevuti");
+ ok++;
+ }
+ }
+ catch (Exception ex)
+ {
+ row["Info Web AI"] = $"[Errore: {ex.Message[..Math.Min(60, ex.Message.Length)]}]";
+ AppLogger.Error("WebSearch", $"[{done + 1}/{total}] Eccezione durante la ricerca", ex);
+ errors++;
+ }
+
+ done++;
+ if (totalSteps > 0)
+ {
+ int baseProgress = (int)((double)currentStep / totalSteps * 100);
+ int stepProgress = (int)((double)done / total * (100.0 / totalSteps));
+ progressCallback?.Report(Math.Min(baseProgress + stepProgress, 99));
+ }
+ }
+
+ AppLogger.Info("WebSearch", $"Arricchimento completato ok={ok}, vuoti={empty}, errori={errors} su {total} partite");
+ currentStep++;
+ }
+
+ #endregion
+
#region Supplementary Data
///
diff --git a/HorseRacingPredictor/HorseRacingPredictor/Football/WebSearch/WebSearchClient.cs b/HorseRacingPredictor/HorseRacingPredictor/Football/WebSearch/WebSearchClient.cs
new file mode 100644
index 0000000..92ee120
--- /dev/null
+++ b/HorseRacingPredictor/HorseRacingPredictor/Football/WebSearch/WebSearchClient.cs
@@ -0,0 +1,367 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using HorseRacingPredictor.Infrastructure;
+
+namespace HorseRacingPredictor.Football.WebSearch
+{
+ ///
+ /// Provider di ricerca web supportato.
+ ///
+ public enum WebSearchProvider
+ {
+ /// Bing Web Search API v7 (richiede chiave Azure Cognitive Services).
+ Bing,
+ /// Google Custom Search JSON API (richiede chiave + CX id).
+ Google,
+ /// SerpAPI aggregatore multi-motore (richiede chiave).
+ SerpApi,
+ /// SearXNG istanza self-hosted (nessuna chiave richiesta).
+ SearXng,
+ }
+
+ ///
+ /// Opzioni per la ricerca web per singola partita.
+ ///
+ public class WebSearchOptions
+ {
+ /// Abilita l'arricchimento tramite ricerca web.
+ public bool Enabled { get; set; } = false;
+
+ /// Provider da usare.
+ public WebSearchProvider Provider { get; set; } = WebSearchProvider.SearXng;
+
+ /// API key del provider scelto (non richiesta per SearXNG).
+ public string ApiKey { get; set; } = string.Empty;
+
+ ///
+ /// Solo per Google Custom Search: identificatore del motore di ricerca (cx).
+ ///
+ public string GoogleCx { get; set; } = string.Empty;
+
+ ///
+ /// URL base dell'istanza SearXNG self-hosted (es. "http://192.168.30.23:8082").
+ ///
+ public string SearXNgUrl { get; set; } = "http://192.168.30.23:8082";
+
+ /// Numero massimo di risultati da includere per partita (120).
+ public int MaxResults { get; set; } = 10;
+
+ /// Ritardo in ms tra le ricerche per evitare rate-limit.
+ public int DelayMs { get; set; } = 300;
+
+ /// Lingua della ricerca (es. "it", "en").
+ public string Language { get; set; } = "it";
+ }
+
+ ///
+ /// Singolo risultato di una ricerca web.
+ ///
+ public class WebSearchResult
+ {
+ public string Title { get; init; } = string.Empty;
+ public string Snippet { get; init; } = string.Empty;
+ public string Url { get; init; } = string.Empty;
+ }
+
+ ///
+ /// Client per la ricerca web multi-provider.
+ /// Effettua query testuali e restituisce snippet pronti per essere inseriti nel CSV.
+ ///
+ public class WebSearchClient
+ {
+ private static readonly HttpClient _http = new()
+ {
+ Timeout = TimeSpan.FromSeconds(10)
+ };
+
+ private readonly WebSearchOptions _options;
+
+ public WebSearchClient(WebSearchOptions options)
+ {
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ // ?? query builder ????????????????????????????????????????????????????????
+
+ ///
+ /// Costruisce la query di ricerca per una singola partita.
+ /// La query viene pensata per raccogliere il massimo contesto utile all'IA:
+ /// meteo, forma recente, formazioni probabili, infortuni, notizie, quote e pronostici.
+ ///
+ public static string BuildQuery(
+ string homeTeam, string awayTeam,
+ string league, string country,
+ DateTime date, string language = "it")
+ {
+ string dateStr = date.ToString("dd/MM/yyyy");
+ string matchCore = $"{homeTeam} vs {awayTeam}";
+
+ // Termini specifici per il calcio, ottimizzati per l'analisi IA.
+ // Per "all" si usano termini inglesi (lingua franca del web calcistico).
+ string[] contextTerms = language == "it"
+ ? [
+ "pronostico", "formazioni", "infortuni", "squalifiche",
+ "forma recente", "statistiche", "meteo", "notizie",
+ "quote", "precedenti"
+ ]
+ : [
+ "prediction", "lineups", "injuries", "suspensions",
+ "recent form", "statistics", "weather", "news",
+ "odds", "head to head"
+ ];
+
+ // Combina tutto in un'unica query ottimizzata
+ return $"{matchCore} {league} {dateStr} {string.Join(" ", contextTerms)}";
+ }
+
+ // ?? public API ????????????????????????????????????????????????????????????
+
+ ///
+ /// Esegue la ricerca e restituisce uno snippet unico concatenato (max ~3000 caratteri),
+ /// pronto per essere inserito in una cella CSV.
+ ///
+ public async Task SearchAsync(string query, CancellationToken ct = default)
+ {
+ // SearXNG non richiede API key
+ bool needsKey = _options.Provider != WebSearchProvider.SearXng;
+ if (needsKey && string.IsNullOrWhiteSpace(_options.ApiKey))
+ {
+ AppLogger.Warn("WebSearchClient", $"API key vuota per provider {_options.Provider} ricerca saltata");
+ return string.Empty;
+ }
+
+ AppLogger.Debug("WebSearchClient", $"SearchAsync | provider={_options.Provider} | query='{query}'");
+
+ try
+ {
+ List results = _options.Provider switch
+ {
+ WebSearchProvider.Bing => await SearchBingAsync(query, ct),
+ WebSearchProvider.Google => await SearchGoogleAsync(query, ct),
+ WebSearchProvider.SerpApi => await SearchSerpApiAsync(query, ct),
+ WebSearchProvider.SearXng => await SearchSearXNgAsync(query, ct),
+ _ => []
+ };
+
+ AppLogger.Debug("WebSearchClient", $"Risultati raw: {results.Count}");
+
+ if (results.Count == 0)
+ {
+ AppLogger.Warn("WebSearchClient", $"Nessun risultato per: '{query}'");
+ return string.Empty;
+ }
+
+ // Concatena titolo + snippet per ogni risultato, separati da " || "
+ var sb = new StringBuilder();
+ foreach (var r in results.Take(_options.MaxResults))
+ {
+ if (sb.Length > 0) sb.Append(" || ");
+ sb.Append($"[{r.Title}] {r.Snippet}");
+ if (sb.Length > 3000) break;
+ }
+
+ AppLogger.Debug("WebSearchClient", $"Snippet finale: {sb.Length} caratteri");
+ return sb.ToString();
+ }
+ catch (Exception ex)
+ {
+ AppLogger.Error("WebSearchClient", $"Eccezione | provider={_options.Provider} | query='{query}'", ex);
+ return $"[Errore ricerca: {ex.Message[..Math.Min(80, ex.Message.Length)]}]";
+ }
+ finally
+ {
+ if (_options.DelayMs > 0)
+ await Task.Delay(_options.DelayMs, ct);
+ }
+ }
+
+ // ?? Bing ?????????????????????????????????????????????????????????????????
+
+ private async Task> SearchBingAsync(string query, CancellationToken ct)
+ {
+ int count = Math.Clamp(_options.MaxResults, 1, 10);
+ string mkt = _options.Language == "it" ? "it-IT" : "en-US";
+ string url = $"https://api.bing.microsoft.com/v7.0/search" +
+ $"?q={Uri.EscapeDataString(query)}&count={count}&mkt={mkt}";
+
+ using var req = new HttpRequestMessage(HttpMethod.Get, url);
+ req.Headers.Add("Ocp-Apim-Subscription-Key", _options.ApiKey);
+
+ using var resp = await _http.SendAsync(req, ct);
+ resp.EnsureSuccessStatusCode();
+
+ var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)).RootElement;
+
+ var results = new List();
+ if (json.TryGetProperty("webPages", out var pages) &&
+ pages.TryGetProperty("value", out var values))
+ {
+ foreach (var item in values.EnumerateArray())
+ {
+ results.Add(new WebSearchResult
+ {
+ Title = item.TryGetProperty("name", out var t) ? t.GetString() ?? "" : "",
+ Snippet = item.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : "",
+ Url = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : "",
+ });
+ }
+ }
+ return results;
+ }
+
+ // ?? Google Custom Search ??????????????????????????????????????????????????
+
+ private async Task> SearchGoogleAsync(string query, CancellationToken ct)
+ {
+ int count = Math.Clamp(_options.MaxResults, 1, 10);
+ string url = $"https://www.googleapis.com/customsearch/v1" +
+ $"?key={Uri.EscapeDataString(_options.ApiKey)}" +
+ $"&cx={Uri.EscapeDataString(_options.GoogleCx)}" +
+ $"&q={Uri.EscapeDataString(query)}&num={count}" +
+ $"&lr=lang_{_options.Language}";
+
+ using var resp = await _http.GetAsync(url, ct);
+ resp.EnsureSuccessStatusCode();
+
+ var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)).RootElement;
+
+ var results = new List();
+ if (json.TryGetProperty("items", out var items))
+ {
+ foreach (var item in items.EnumerateArray())
+ {
+ results.Add(new WebSearchResult
+ {
+ Title = item.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "",
+ Snippet = item.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : "",
+ Url = item.TryGetProperty("link", out var u) ? u.GetString() ?? "" : "",
+ });
+ }
+ }
+ return results;
+ }
+
+ // ?? SerpAPI ???????????????????????????????????????????????????????????????
+
+ private async Task> SearchSerpApiAsync(string query, CancellationToken ct)
+ {
+ int count = Math.Clamp(_options.MaxResults, 1, 10);
+ string url = $"https://serpapi.com/search.json" +
+ $"?q={Uri.EscapeDataString(query)}&num={count}&hl={_options.Language}" +
+ $"&api_key={Uri.EscapeDataString(_options.ApiKey)}";
+
+ using var resp = await _http.GetAsync(url, ct);
+ resp.EnsureSuccessStatusCode();
+
+ var json = JsonDocument.Parse(await resp.Content.ReadAsStringAsync(ct)).RootElement;
+
+ var results = new List();
+ if (json.TryGetProperty("organic_results", out var items))
+ {
+ foreach (var item in items.EnumerateArray())
+ {
+ results.Add(new WebSearchResult
+ {
+ Title = item.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "",
+ Snippet = item.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : "",
+ Url = item.TryGetProperty("link", out var u) ? u.GetString() ?? "" : "",
+ });
+ }
+ }
+ return results;
+ }
+
+ // ?? SearXNG ???????????????????????????????????????????????????????????????
+
+ ///
+ /// Ricerca su istanza SearXNG self-hosted via API JSON.
+ /// Usa le categorie "general" e "news" per massimizzare la copertura informativa.
+ /// Le query vengono eseguite in parallelo per le due categorie per ottenere
+ /// risultati diversificati (notizie recenti + contenuti generali).
+ ///
+ private async Task> SearchSearXNgAsync(string query, CancellationToken ct)
+ {
+ string baseUrl = _options.SearXNgUrl.TrimEnd('/');
+ string lang = _options.Language;
+ string encoded = Uri.EscapeDataString(query);
+
+ // Lancia le due categorie in parallelo per velocizzare e diversificare i risultati
+ var generalTask = FetchSearXNgCategoryAsync(baseUrl, encoded, "general", lang, ct);
+ var newsTask = FetchSearXNgCategoryAsync(baseUrl, encoded, "news", lang, ct);
+
+ await Task.WhenAll(generalTask, newsTask);
+
+ // Unisci e deduplicata per URL, privilegiando "news" (piω recente) come primo
+ var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var merged = new List();
+
+ foreach (var r in newsTask.Result.Concat(generalTask.Result))
+ {
+ if (seen.Add(r.Url))
+ merged.Add(r);
+ }
+
+ return merged;
+ }
+
+ private async Task> FetchSearXNgCategoryAsync(
+ string baseUrl, string encodedQuery, string category, string lang, CancellationToken ct)
+ {
+ // SearXNG JSON API: /search?q=...&format=json&categories=...
+ // Il parametro language viene omesso quando si vuole cercare in tutte le lingue
+ string url = $"{baseUrl}/search" +
+ $"?q={encodedQuery}" +
+ $"&format=json" +
+ $"&categories={Uri.EscapeDataString(category)}" +
+ (lang == "all" ? "" : $"&language={Uri.EscapeDataString(lang)}");
+
+ AppLogger.Debug("WebSearchClient", $"SearXNG [{category}] GET: {url}");
+
+ using var req = new HttpRequestMessage(HttpMethod.Get, url);
+ req.Headers.TryAddWithoutValidation("User-Agent", "BettingPredictor/1.0 (AI enrichment)");
+ req.Headers.TryAddWithoutValidation("Accept", "application/json");
+
+ using var resp = await _http.SendAsync(req, ct);
+ AppLogger.Debug("WebSearchClient", $"SearXNG [{category}] risposta HTTP: {(int)resp.StatusCode} {resp.StatusCode}");
+ resp.EnsureSuccessStatusCode();
+
+ var body = await resp.Content.ReadAsStringAsync(ct);
+ AppLogger.Debug("WebSearchClient", $"SearXNG [{category}] body ricevuto: {body.Length} caratteri");
+
+ var json = JsonDocument.Parse(body).RootElement;
+
+ var results = new List();
+ if (!json.TryGetProperty("results", out var items))
+ {
+ AppLogger.Warn("WebSearchClient", $"SearXNG [{category}]: proprietΰ 'results' assente nella risposta JSON");
+ return results;
+ }
+
+ foreach (var item in items.EnumerateArray())
+ {
+ string title = item.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "";
+ string snippet = item.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
+ string itemUrl = item.TryGetProperty("url", out var u) ? u.GetString() ?? "" : "";
+
+ if (item.TryGetProperty("publishedDate", out var pd) && pd.ValueKind == JsonValueKind.String)
+ {
+ string dateStr = pd.GetString();
+ if (!string.IsNullOrEmpty(dateStr))
+ snippet = $"[{dateStr[..Math.Min(10, dateStr.Length)]}] {snippet}";
+ }
+
+ if (!string.IsNullOrEmpty(title) || !string.IsNullOrEmpty(snippet))
+ results.Add(new WebSearchResult { Title = title, Snippet = snippet, Url = itemUrl });
+ }
+
+ AppLogger.Debug("WebSearchClient", $"SearXNG [{category}]: {results.Count} risultati parsati");
+ return results;
+ }
+ }
+}
diff --git a/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/AppConfig.cs b/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/AppConfig.cs
index 018827d..5a94215 100644
--- a/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/AppConfig.cs
+++ b/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/AppConfig.cs
@@ -13,6 +13,9 @@ namespace HorseRacingPredictor
{
private static IConfiguration _configuration;
+ // Runtime override for the Football API key (set from UI / UserSettings)
+ private static string _footballApiKeyOverride;
+
public static IConfiguration Configuration => _configuration ??= BuildConfiguration();
private static IConfiguration BuildConfiguration()
@@ -38,12 +41,51 @@ namespace HorseRacingPredictor
// ?? API settings ????????????????????????????????????????
public static string FootballApiKey =>
- Configuration["Api:FootballApiKey"] ?? string.Empty;
+ !string.IsNullOrEmpty(_footballApiKeyOverride)
+ ? _footballApiKeyOverride
+ : Configuration["Api:FootballApiKey"] ?? string.Empty;
public static string FootballApiKeyHeader =>
Configuration["Api:FootballApiKeyHeader"] ?? "x-rapidapi-key";
public static string FootballApiHost =>
Configuration["Api:FootballApiHost"] ?? "v3.football.api-sports.io";
+
+ ///
+ /// Imposta la API key di Football da codice (es. dalla UI).
+ /// Ha precedenza sul valore in appsettings.json.
+ ///
+ public static void SetFootballApiKey(string apiKey)
+ {
+ _footballApiKeyOverride = apiKey?.Trim();
+ }
+
+ // ?? WebSearch settings ?????????????????????????????????
+ public static string WebSearchProvider =>
+ Configuration["WebSearch:Provider"] ?? "Bing";
+
+ public static string WebSearchApiKey =>
+ !string.IsNullOrEmpty(_webSearchApiKeyOverride)
+ ? _webSearchApiKeyOverride
+ : Configuration["WebSearch:ApiKey"] ?? string.Empty;
+
+ public static string WebSearchGoogleCx =>
+ Configuration["WebSearch:GoogleCx"] ?? string.Empty;
+
+ public static int WebSearchMaxResults =>
+ int.TryParse(Configuration["WebSearch:MaxResults"], out var v) ? v : 5;
+
+ public static int WebSearchDelayMs =>
+ int.TryParse(Configuration["WebSearch:DelayMs"], out var v) ? v : 500;
+
+ public static string WebSearchLanguage =>
+ Configuration["WebSearch:Language"] ?? "it";
+
+ private static string _webSearchApiKeyOverride;
+
+ public static void SetWebSearchApiKey(string key)
+ {
+ _webSearchApiKeyOverride = key?.Trim();
+ }
}
}
diff --git a/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/UserSettings.cs b/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/UserSettings.cs
index eb822de..27243a7 100644
--- a/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/UserSettings.cs
+++ b/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/UserSettings.cs
@@ -61,6 +61,16 @@ namespace HorseRacingPredictor
public bool FbDownloadCoaches { get; set; } = false;
public bool FbDownloadTransfers { get; set; } = false;
+ // ?? Web Search (arricchimento IA) ???????????????????????????
+ public bool FbWebSearchEnabled { get; set; } = false;
+ public string FbWebSearchProvider { get; set; } = "SearXng";
+ public string FbWebSearchApiKey { get; set; } = string.Empty;
+ public string FbWebSearchGoogleCx { get; set; } = string.Empty;
+ public string FbWebSearchSearXNgUrl { get; set; } = "http://192.168.30.23:8082";
+ public int FbWebSearchMaxResults { get; set; } = 10;
+ public int FbWebSearchDelayMs { get; set; } = 300;
+ public string FbWebSearchLanguage { get; set; } = "it";
+
// ?? Racing ???????????????????????????????????????????????
public string RacingApiKey { get; set; } = string.Empty;
public string RcDataSource { get; set; } = "API - FormFav";
@@ -108,7 +118,20 @@ namespace HorseRacingPredictor
DownloadTopCards = FbDownloadTopCards,
DownloadSquads = FbDownloadSquads,
DownloadCoaches = FbDownloadCoaches,
- DownloadTransfers = FbDownloadTransfers
+ DownloadTransfers = FbDownloadTransfers,
+ // Web Search
+ WebSearch = new Football.WebSearch.WebSearchOptions
+ {
+ Enabled = FbWebSearchEnabled,
+ Provider = Enum.TryParse(FbWebSearchProvider, out var p)
+ ? p : Football.WebSearch.WebSearchProvider.SearXng,
+ ApiKey = FbWebSearchApiKey,
+ GoogleCx = FbWebSearchGoogleCx,
+ SearXNgUrl = FbWebSearchSearXNgUrl,
+ MaxResults = FbWebSearchMaxResults,
+ DelayMs = FbWebSearchDelayMs,
+ Language = FbWebSearchLanguage,
+ }
};
}
diff --git a/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/appsettings.template.json b/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/appsettings.template.json
index 232c3e3..8aba037 100644
--- a/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/appsettings.template.json
+++ b/HorseRacingPredictor/HorseRacingPredictor/HorseRacingPredictor/appsettings.template.json
@@ -7,5 +7,14 @@
"FootballApiKey": "",
"FootballApiKeyHeader": "x-rapidapi-key",
"FootballApiHost": "v3.football.api-sports.io"
+ },
+ "WebSearch": {
+ "Provider": "SearXng",
+ "ApiKey": "",
+ "GoogleCx": "",
+ "SearXNgUrl": "http://192.168.30.23:8082",
+ "MaxResults": 10,
+ "DelayMs": 300,
+ "Language": "it"
}
}
diff --git a/HorseRacingPredictor/HorseRacingPredictor/Infrastructure/AppLogger.cs b/HorseRacingPredictor/HorseRacingPredictor/Infrastructure/AppLogger.cs
new file mode 100644
index 0000000..14cfc23
--- /dev/null
+++ b/HorseRacingPredictor/HorseRacingPredictor/Infrastructure/AppLogger.cs
@@ -0,0 +1,179 @@
+using System;
+using System.IO;
+using System.Text;
+using System.Threading;
+
+namespace HorseRacingPredictor.Infrastructure
+{
+ ///
+ /// Log severity level.
+ ///
+ public enum LogLevel
+ {
+ Debug,
+ Info,
+ Warning,
+ Error,
+ }
+
+ ///
+ /// Thread-safe, file-based application logger.
+ /// Writes to %AppData%\HorseRacingPredictor\logs\app-YYYY-MM-DD.log.
+ /// A new file is created each day; old files are kept for 30 days.
+ ///
+ public sealed class AppLogger
+ {
+ // ?? Singleton ????????????????????????????????????????????????????????????
+
+ private static readonly Lazy _instance =
+ new(() => new AppLogger(), LazyThreadSafetyMode.ExecutionAndPublication);
+
+ public static AppLogger Instance => _instance.Value;
+
+ // ?? Configuration ????????????????????????????????????????????????????????
+
+ /// Minimum level written to file (default: Debug).
+ public LogLevel MinLevel { get; set; } = LogLevel.Debug;
+
+ /// Also write to Debug output (default: true in DEBUG builds).
+#if DEBUG
+ public bool WriteToDebugOutput { get; set; } = true;
+#else
+ public bool WriteToDebugOutput { get; set; } = false;
+#endif
+
+ // ?? State ????????????????????????????????????????????????????????????????
+
+ private readonly string _logDir;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+ private string _currentLogPath;
+ private DateTime _currentDate;
+
+ // ?? Constructor ??????????????????????????????????????????????????????????
+
+ private AppLogger()
+ {
+ _logDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "HorseRacingPredictor",
+ "logs");
+
+ try { Directory.CreateDirectory(_logDir); }
+ catch { /* if we can't create the dir, logging will silently fail */ }
+
+ RefreshLogPath(DateTime.Today);
+ PurgeOldLogs(30);
+
+ Log(LogLevel.Info, "Logger", "=== AppLogger avviato ===");
+ }
+
+ // ?? Public API ????????????????????????????????????????????????????????????
+
+ public static void Debug(string category, string message, Exception ex = null)
+ => Instance.Log(LogLevel.Debug, category, message, ex);
+
+ public static void Info(string category, string message, Exception ex = null)
+ => Instance.Log(LogLevel.Info, category, message, ex);
+
+ public static void Warn(string category, string message, Exception ex = null)
+ => Instance.Log(LogLevel.Warning, category, message, ex);
+
+ public static void Error(string category, string message, Exception ex = null)
+ => Instance.Log(LogLevel.Error, category, message, ex);
+
+ // ?? Core write ????????????????????????????????????????????????????????????
+
+ public void Log(LogLevel level, string category, string message, Exception ex = null)
+ {
+ if (level < MinLevel) return;
+
+ var now = DateTime.Now;
+ var date = now.Date;
+ var line = FormatLine(now, level, category, message, ex);
+
+ if (WriteToDebugOutput)
+ System.Diagnostics.Debug.WriteLine(line);
+
+ // Rotate file if date changed
+ if (date != _currentDate)
+ {
+ RefreshLogPath(date);
+ PurgeOldLogs(30);
+ }
+
+ WriteToFile(line);
+ }
+
+ /// Returns the path of the current log file.
+ public string CurrentLogPath => _currentLogPath;
+
+ // ?? Helpers ???????????????????????????????????????????????????????????????
+
+ private void RefreshLogPath(DateTime date)
+ {
+ _currentDate = date;
+ _currentLogPath = Path.Combine(_logDir, $"app-{date:yyyy-MM-dd}.log");
+ }
+
+ private void WriteToFile(string line)
+ {
+ if (string.IsNullOrEmpty(_currentLogPath)) return;
+
+ _lock.Wait();
+ try
+ {
+ File.AppendAllText(_currentLogPath, line + Environment.NewLine, Encoding.UTF8);
+ }
+ catch
+ {
+ // Non-fatal: logging should never crash the app
+ }
+ finally
+ {
+ _lock.Release();
+ }
+ }
+
+ private static string FormatLine(DateTime ts, LogLevel level, string category, string message, Exception ex)
+ {
+ string levelTag = level switch
+ {
+ LogLevel.Debug => "DBG",
+ LogLevel.Info => "INF",
+ LogLevel.Warning => "WRN",
+ LogLevel.Error => "ERR",
+ _ => "???"
+ };
+
+ var sb = new StringBuilder();
+ sb.Append($"[{ts:yyyy-MM-dd HH:mm:ss.fff}] [{levelTag}] [{category}] {message}");
+
+ if (ex != null)
+ {
+ sb.AppendLine();
+ sb.Append($" Exception: {ex.GetType().Name}: {ex.Message}");
+ if (ex.InnerException != null)
+ sb.Append($" | Inner: {ex.InnerException.Message}");
+ sb.AppendLine();
+ sb.Append($" StackTrace: {ex.StackTrace?.Replace(Environment.NewLine, " | ")}");
+ }
+
+ return sb.ToString();
+ }
+
+ private void PurgeOldLogs(int keepDays)
+ {
+ try
+ {
+ var cutoff = DateTime.Today.AddDays(-keepDays);
+ foreach (var file in Directory.GetFiles(_logDir, "app-*.log"))
+ {
+ var name = Path.GetFileNameWithoutExtension(file); // "app-2024-01-01"
+ if (DateTime.TryParse(name.Substring(4), out var fileDate) && fileDate < cutoff)
+ File.Delete(file);
+ }
+ }
+ catch { }
+ }
+ }
+}
diff --git a/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml b/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml
index c25fdc0..e5a7621 100644
--- a/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml
+++ b/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml
@@ -1136,6 +1136,107 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1173,8 +1274,8 @@
-
-
+
+
@@ -1188,11 +1289,81 @@
-
+
+
+
+
+
+
+
+
+
+ Abilita ricerca web per ogni partita
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml.cs b/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml.cs
index 78c3d9c..a112cce 100644
--- a/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml.cs
+++ b/HorseRacingPredictor/HorseRacingPredictor/MainWindow.xaml.cs
@@ -34,7 +34,9 @@ namespace HorseRacingPredictor
BuildCountryCheckboxes();
BuildFbEndpointCheckboxes();
BuildFbSupplementaryCheckboxes();
+ BuildFbLeagueFilter();
PopulateTimezoneComboBoxes();
+ PopulateTimeFilterComboBoxes();
// Wire preview update events
txtFbPrefix.TextChanged += (s, e) => UpdateFbPreview();
txtFbSuffix.TextChanged += (s, e) => UpdateFbPreview();
@@ -44,6 +46,9 @@ namespace HorseRacingPredictor
cmbFbFormat.SelectionChanged += (s, e) => UpdateFbPreview();
dpFootball.SelectedDateChanged += (s, e) => UpdateFbPreview();
+ chkFbWebSearch.Checked += (s, e) => UpdateWebSearchPanelVisibility();
+ chkFbWebSearch.Unchecked += (s, e) => UpdateWebSearchPanelVisibility();
+
txtRcPrefix.TextChanged += (s, e) => UpdateRcPreview();
txtRcSuffix.TextChanged += (s, e) => UpdateRcPreview();
chkRcIncludeDate.Checked += (s, e) => UpdateRcPreview();
@@ -208,6 +213,24 @@ namespace HorseRacingPredictor
combo.SelectedItem = ianaId;
}
+ ///
+ /// Popola i ComboBox per il filtro orario con slot ogni 30 minuti (00:00 23:30).
+ /// Il primo elemento "" indica nessun filtro.
+ ///
+ private void PopulateTimeFilterComboBoxes()
+ {
+ var items = new List { "--" };
+ for (int h = 0; h < 24; h++)
+ {
+ items.Add($"{h:D2}:00");
+ items.Add($"{h:D2}:30");
+ }
+ cmbFbTimeFrom.ItemsSource = items;
+ cmbFbTimeTo.ItemsSource = items;
+ cmbFbTimeFrom.SelectedIndex = 0;
+ cmbFbTimeTo.SelectedIndex = 0;
+ }
+
// ???????????????????? LIFECYCLE ????????????????????
private void Window_Loaded(object sender, RoutedEventArgs e)
@@ -324,6 +347,9 @@ namespace HorseRacingPredictor
dpFootball.IsEnabled = false;
btnExportFbCsv.IsEnabled = false;
+ // Applica la API key dalla UI alla configurazione runtime
+ AppConfig.SetFootballApiKey(txtApiKey.Text);
+
// Costruisci le opzioni di download dalla UI
var options = BuildFootballDownloadOptions();
@@ -342,7 +368,7 @@ namespace HorseRacingPredictor
dgFootball.ItemsSource = _footballData?.DefaultView;
dgFootball.AutoGeneratingColumn += (s, e) =>
{
- if (e.PropertyName is "LeagueId" or "HomeTeamId" or "AwayTeamId")
+ if (e.PropertyName is "LeagueId" or "HomeTeamId" or "AwayTeamId" or "_timeMinutes")
e.Cancel = true;
};
@@ -681,6 +707,195 @@ namespace HorseRacingPredictor
if (_fbSupplementaryCheckboxes.TryGetValue(key, out var cb)) cb.IsChecked = value;
}
+ // FOOTBALL COMPETITION FILTER (Country ? League)
+
+ /// Represents a league entry for the competition filter.
+ private record FbLeagueEntry(int Id, string Name, string Country);
+
+ private readonly Dictionary _fbLeagueCheckboxes = new();
+ private List _fbLeagues = new();
+
+ /// Default leagues: Big Five, European cups, and other notable leagues.
+ private static List GetDefaultLeagues() =>
+ [
+ // England
+ new(39, "Premier League", "England"),
+ new(40, "Championship", "England"),
+ // Spain
+ new(140, "La Liga", "Spain"),
+ // Italy
+ new(135, "Serie A", "Italy"),
+ // Germany
+ new(78, "Bundesliga", "Germany"),
+ // France
+ new(61, "Ligue 1", "France"),
+ // European cups
+ new(2, "Champions League", "Europe"),
+ new(3, "Europa League", "Europe"),
+ new(848, "Conference League", "Europe"),
+ // Netherlands
+ new(88, "Eredivisie", "Netherlands"),
+ // Portugal
+ new(94, "Primeira Liga", "Portugal"),
+ // Turkey
+ new(203, "Sόper Lig", "Turkey"),
+ ];
+
+ private void BuildFbLeagueFilter()
+ {
+ _fbLeagues = GetDefaultLeagues();
+ PopulateFbCountryComboBox();
+ PopulateFbLeagueCheckboxes();
+ }
+
+ private void PopulateFbCountryComboBox()
+ {
+ if (cmbFbCountry == null) return;
+ var countries = new List { "Tutte" };
+ countries.AddRange(_fbLeagues.Select(l => l.Country).Distinct().OrderBy(c => c));
+ cmbFbCountry.ItemsSource = countries;
+ cmbFbCountry.SelectedIndex = 0;
+ cmbFbCountry.SelectionChanged -= CmbFbCountry_SelectionChanged;
+ cmbFbCountry.SelectionChanged += CmbFbCountry_SelectionChanged;
+ }
+
+ private void CmbFbCountry_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ PopulateFbLeagueCheckboxes();
+ }
+
+ private void PopulateFbLeagueCheckboxes()
+ {
+ if (pnlFbLeagues == null) return;
+ pnlFbLeagues.Children.Clear();
+ _fbLeagueCheckboxes.Clear();
+
+ string selectedCountry = cmbFbCountry?.SelectedItem as string ?? "Tutte";
+ var filtered = selectedCountry == "Tutte"
+ ? _fbLeagues
+ : _fbLeagues.Where(l => l.Country == selectedCountry).ToList();
+
+ // Get currently selected IDs from txtFbLeagueIds
+ var currentIds = new HashSet(ParseIntList(txtFbLeagueIds?.Text));
+
+ foreach (var league in filtered)
+ {
+ var cb = new CheckBox
+ {
+ Content = $"{league.Name} ({league.Country})",
+ Tag = league.Id,
+ IsChecked = currentIds.Contains(league.Id),
+ Margin = new Thickness(4, 2, 4, 2),
+ FontSize = 12,
+ Foreground = FindResource("BrText") as System.Windows.Media.Brush,
+ };
+ cb.Checked += (s, ev) => UpdateFbLeagueSelection();
+ cb.Unchecked += (s, ev) => UpdateFbLeagueSelection();
+ _fbLeagueCheckboxes[league.Id.ToString()] = cb;
+ pnlFbLeagues.Children.Add(cb);
+ }
+ UpdateFbLeaguesSummary();
+ }
+
+ private void UpdateFbLeagueSelection()
+ {
+ // Collect all checked league IDs and update the hidden textbox
+ var ids = _fbLeagueCheckboxes
+ .Where(kv => kv.Value.IsChecked == true)
+ .Select(kv => kv.Key)
+ .ToList();
+ txtFbLeagueIds.Text = string.Join(",", ids);
+ UpdateFbLeaguesSummary();
+ }
+
+ private void UpdateFbLeaguesSummary()
+ {
+ var selected = _fbLeagueCheckboxes
+ .Where(kv => kv.Value.IsChecked == true)
+ .Select(kv => kv.Value.Content.ToString())
+ .ToList();
+ if (lblFbLeaguesSummary != null)
+ lblFbLeaguesSummary.Text = selected.Count > 0
+ ? string.Join(", ", selected) : "Tutte le leghe";
+ }
+
+ private void txtFbLeagueSearch_TextChanged(object sender, TextChangedEventArgs e)
+ {
+ string query = txtFbLeagueSearch?.Text?.Trim() ?? "";
+ foreach (var kv in _fbLeagueCheckboxes)
+ {
+ var cb = kv.Value;
+ cb.Visibility = string.IsNullOrEmpty(query) ||
+ cb.Content.ToString().Contains(query, StringComparison.OrdinalIgnoreCase)
+ ? Visibility.Visible : Visibility.Collapsed;
+ }
+ }
+
+ private async void btnFbRefreshLeagues_Click(object sender, RoutedEventArgs e)
+ {
+ try
+ {
+ btnFbRefreshLeagues.IsEnabled = false;
+ pbFbLeagueRefresh.Visibility = Visibility.Visible;
+ pbFbLeagueRefresh.IsIndeterminate = true;
+ lblFbLeagueRefreshStatus.Text = "Scaricamento leghe da API...";
+ AppConfig.SetFootballApiKey(txtApiKey.Text);
+
+ var leagueApi = new Football.API.League();
+ var response = await Task.Run(() => leagueApi.GetLeagues());
+
+ if (response == null || !response.IsSuccessful || string.IsNullOrEmpty(response.Content))
+ {
+ lblFbLeagueRefreshStatus.Text = "Errore: impossibile ottenere le leghe";
+ return;
+ }
+
+ var json = System.Text.Json.JsonDocument.Parse(response.Content).RootElement;
+ if (!json.TryGetProperty("response", out var responseArr))
+ {
+ lblFbLeagueRefreshStatus.Text = "Risposta API non valida";
+ return;
+ }
+
+ var newLeagues = new List();
+ foreach (var item in responseArr.EnumerateArray())
+ {
+ if (!item.TryGetProperty("league", out var leagueObj) ||
+ !item.TryGetProperty("country", out var countryObj))
+ continue;
+
+ int id = leagueObj.TryGetProperty("id", out var idProp) ? idProp.GetInt32() : 0;
+ string name = leagueObj.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : "";
+ string country = countryObj.TryGetProperty("name", out var cProp) ? cProp.GetString() : "";
+
+ if (id > 0 && !string.IsNullOrEmpty(name))
+ newLeagues.Add(new FbLeagueEntry(id, name, country));
+ }
+
+ if (newLeagues.Count > 0)
+ {
+ _fbLeagues = newLeagues.OrderBy(l => l.Country).ThenBy(l => l.Name).ToList();
+ PopulateFbCountryComboBox();
+ PopulateFbLeagueCheckboxes();
+ lblFbLeagueRefreshStatus.Text = $"? {_fbLeagues.Count} leghe da {_fbLeagues.Select(l => l.Country).Distinct().Count()} nazioni";
+ }
+ else
+ {
+ lblFbLeagueRefreshStatus.Text = "Nessuna lega trovata";
+ }
+ }
+ catch (Exception ex)
+ {
+ lblFbLeagueRefreshStatus.Text = $"Errore: {ex.Message}";
+ }
+ finally
+ {
+ btnFbRefreshLeagues.IsEnabled = true;
+ pbFbLeagueRefresh.IsIndeterminate = false;
+ pbFbLeagueRefresh.Visibility = Visibility.Collapsed;
+ }
+ }
+
private async void btnDownloadRc_Click(object sender, RoutedEventArgs e)
{
await DownloadRacecardsAsync();
@@ -1114,6 +1329,8 @@ namespace HorseRacingPredictor
var s = UserSettings.Load();
txtApiKey.Text = s.ApiKey;
+ // Applica la API key alla configurazione runtime
+ AppConfig.SetFootballApiKey(s.ApiKey);
SetComboBoxSelectionByContent(cmbFbDataSource, s.FbDataSource);
txtFbExportPath.Text = s.FbExportPath;
txtFbPrefix.Text = s.FbPrefix;
@@ -1150,10 +1367,23 @@ namespace HorseRacingPredictor
txtFbMaxFixtures.Text = s.FbMaxFixturesForDetails.ToString();
if (cmbFbTimezone != null) SetTimezoneSelection(cmbFbTimezone, s.FbTimezone);
txtFbLeagueIds.Text = s.FbLeagueIds.Count > 0 ? string.Join(",", s.FbLeagueIds) : "";
+ PopulateFbLeagueCheckboxes();
chkFbCheckQuota.IsChecked = s.FbCheckQuota;
txtFbMinQuota.Text = s.FbMinRemainingQuota.ToString();
txtFbApiDelay.Text = s.FbApiDelayMs.ToString();
+ // Web Search
+ chkFbWebSearch.IsChecked = s.FbWebSearchEnabled;
+ SetComboBoxSelectionByContent(cmbFbWebSearchProvider, ProviderToDisplayName(s.FbWebSearchProvider));
+ txtFbWebSearchApiKey.Text = s.FbWebSearchApiKey;
+ txtFbWebSearchGoogleCx.Text = s.FbWebSearchGoogleCx;
+ txtFbWebSearchSearXNgUrl.Text = string.IsNullOrEmpty(s.FbWebSearchSearXNgUrl)
+ ? "http://192.168.30.23:8082" : s.FbWebSearchSearXNgUrl;
+ txtFbWebSearchMaxResults.Text = s.FbWebSearchMaxResults.ToString();
+ txtFbWebSearchDelayMs.Text = s.FbWebSearchDelayMs.ToString();
+ SetComboBoxSelectionByContent(cmbFbWebSearchLanguage, s.FbWebSearchLanguage);
+ UpdateWebSearchPanelVisibility();
+
// Racing
txtRcExportPath.Text = s.RcExportPath;
txtRcPrefix.Text = s.RcPrefix;
@@ -1405,6 +1635,9 @@ namespace HorseRacingPredictor
// per validazione prima del salvataggio
var options = BuildFootballDownloadOptions();
+ // Applica la API key alla configurazione runtime
+ AppConfig.SetFootballApiKey(txtApiKey.Text);
+
var s = new UserSettings
{
ApiKey = txtApiKey.Text.Trim(),
@@ -1442,6 +1675,15 @@ namespace HorseRacingPredictor
FbCheckQuota = chkFbCheckQuota.IsChecked == true,
FbMinRemainingQuota = int.TryParse(txtFbMinQuota.Text.Trim(), out var mq) ? mq : 10,
FbApiDelayMs = int.TryParse(txtFbApiDelay.Text.Trim(), out var ad) ? ad : 300,
+ // Web Search
+ FbWebSearchEnabled = chkFbWebSearch.IsChecked == true,
+ FbWebSearchProvider = DisplayNameToProvider((cmbFbWebSearchProvider?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "SearXNG (self-hosted)"),
+ FbWebSearchApiKey = txtFbWebSearchApiKey.Text.Trim(),
+ FbWebSearchGoogleCx = txtFbWebSearchGoogleCx.Text.Trim(),
+ FbWebSearchSearXNgUrl = txtFbWebSearchSearXNgUrl.Text.Trim(),
+ FbWebSearchMaxResults = int.TryParse(txtFbWebSearchMaxResults.Text.Trim(), out var mr) ? Math.Clamp(mr, 1, 20) : 10,
+ FbWebSearchDelayMs = int.TryParse(txtFbWebSearchDelayMs.Text.Trim(), out var wd) ? wd : 300,
+ FbWebSearchLanguage = (cmbFbWebSearchLanguage?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "it",
// Racing
RcExportPath = txtRcExportPath.Text.Trim(),
RcPrefix = txtRcPrefix.Text.Trim(),
@@ -1478,8 +1720,10 @@ namespace HorseRacingPredictor
///
private void UpdateFootballStatCards()
{
- int rows = _footballData?.Rows.Count ?? 0;
+ int rows = _footballData?.DefaultView?.Count ?? _footballData?.Rows.Count ?? 0;
int cols = _footballData?.Columns.Count ?? 0;
+ // Escludi colonne helper interne dal conteggio
+ if (_footballData?.Columns.Contains("_timeMinutes") == true) cols--;
lblFbCardCount.Text = rows.ToString();
lblFbCardCols.Text = cols > 0 ? cols.ToString() : "--";
lblFbCardFormat.Text = (cmbFbFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "CSV";
@@ -1551,10 +1795,70 @@ namespace HorseRacingPredictor
LeagueIds = ParseIntList(txtFbLeagueIds?.Text),
CheckQuota = chkFbCheckQuota.IsChecked == true,
MinRemainingQuota = int.TryParse(txtFbMinQuota.Text.Trim(), out var mq) ? mq : 10,
- ApiDelayMs = int.TryParse(txtFbApiDelay.Text.Trim(), out var ad) ? ad : 300
+ ApiDelayMs = int.TryParse(txtFbApiDelay.Text.Trim(), out var ad) ? ad : 300,
+ TimeFrom = GetSelectedTimeFilter(cmbFbTimeFrom),
+ TimeTo = GetSelectedTimeFilter(cmbFbTimeTo),
+ WebSearch = new Football.WebSearch.WebSearchOptions
+ {
+ Enabled = chkFbWebSearch?.IsChecked == true,
+ Provider = Enum.TryParse(
+ DisplayNameToProvider((cmbFbWebSearchProvider?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? ""),
+ out var wsp) ? wsp : Football.WebSearch.WebSearchProvider.SearXng,
+ ApiKey = txtFbWebSearchApiKey?.Text.Trim() ?? "",
+ GoogleCx = txtFbWebSearchGoogleCx?.Text.Trim() ?? "",
+ SearXNgUrl = txtFbWebSearchSearXNgUrl?.Text.Trim() ?? "http://192.168.30.23:8082",
+ MaxResults = int.TryParse(txtFbWebSearchMaxResults?.Text.Trim(), out var wmr) ? Math.Clamp(wmr, 1, 20) : 10,
+ DelayMs = int.TryParse(txtFbWebSearchDelayMs?.Text.Trim(), out var wdms) ? wdms : 300,
+ Language = (cmbFbWebSearchLanguage?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "it",
+ }
};
}
+ // WEB SEARCH HELPERS
+
+ private static string ProviderToDisplayName(string provider) => provider switch
+ {
+ "SearXng" => "SearXNG (self-hosted)",
+ "Bing" => "Bing Web Search API",
+ "Google" => "Google Custom Search",
+ "SerpApi" => "SerpAPI",
+ _ => "SearXNG (self-hosted)"
+ };
+
+ private static string DisplayNameToProvider(string displayName) => displayName switch
+ {
+ "SearXNG (self-hosted)" => "SearXng",
+ "Bing Web Search API" => "Bing",
+ "Google Custom Search" => "Google",
+ "SerpAPI" => "SerpApi",
+ _ => "SearXng"
+ };
+
+ private void UpdateWebSearchPanelVisibility()
+ {
+ bool enabled = chkFbWebSearch?.IsChecked == true;
+ if (pnlFbWebSearch != null)
+ pnlFbWebSearch.Visibility = enabled ? Visibility.Visible : Visibility.Collapsed;
+
+ string provider = (cmbFbWebSearchProvider?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "";
+ bool isSearXng = provider == "SearXNG (self-hosted)";
+ bool isGoogle = provider == "Google Custom Search";
+
+ if (pnlFbWebSearchSearXNg != null)
+ pnlFbWebSearchSearXNg.Visibility = isSearXng ? Visibility.Visible : Visibility.Collapsed;
+ if (pnlFbWebSearchApiKey != null)
+ pnlFbWebSearchApiKey.Visibility = isSearXng ? Visibility.Collapsed : Visibility.Visible;
+ if (pnlFbWebSearchGoogleCx != null)
+ pnlFbWebSearchGoogleCx.Visibility = isGoogle ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ private void cmbFbWebSearchProvider_SelectionChanged(object sender, SelectionChangedEventArgs e)
+ {
+ UpdateWebSearchPanelVisibility();
+ }
+
+ //
+
///
/// Analizza una stringa di interi separati da virgola e restituisce una lista.
///
@@ -1569,5 +1873,12 @@ namespace HorseRacingPredictor
}
return result;
}
+
+ private static TimeSpan? GetSelectedTimeFilter(ComboBox cmb)
+ {
+ var text = cmb?.SelectedItem as string;
+ if (string.IsNullOrEmpty(text) || text == "--") return null;
+ return TimeSpan.TryParse(text, out var ts) ? ts : null;
+ }
}
}
diff --git a/HorseRacingPredictor/HorseRacingPredictor/Manager/API.cs b/HorseRacingPredictor/HorseRacingPredictor/Manager/API.cs
index c956c1c..c45e8c1 100644
--- a/HorseRacingPredictor/HorseRacingPredictor/Manager/API.cs
+++ b/HorseRacingPredictor/HorseRacingPredictor/Manager/API.cs
@@ -26,7 +26,11 @@ namespace HorseRacingPredictor.Manager
var response = clientApi.Execute(request);
if (!response.IsSuccessful)
{
- throw new Exception($"Errore nella richiesta API: {response.ErrorMessage}");
+ string errDetail = !string.IsNullOrEmpty(response.ErrorMessage)
+ ? response.ErrorMessage
+ : $"HTTP {(int)response.StatusCode} {response.StatusCode}" +
+ (!string.IsNullOrEmpty(response.Content) ? $" β {response.Content[..Math.Min(200, response.Content.Length)]}" : "");
+ throw new Exception($"Errore nella richiesta API: {errDetail}");
}
// Aggiungi una pausa tra una chiamata e l'altra