From cf69e3b2fd190a6414401a772b802c0466c2dd1e Mon Sep 17 00:00:00 2001 From: Alberto Balbo Date: Wed, 29 Apr 2026 16:17:03 +0200 Subject: [PATCH] Arricchimento calcio: ricerca web AI, filtri avanzati, log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggiunto arricchimento automatico delle partite calcio tramite ricerca web AI (SearXNG, Bing, Google, SerpAPI) con risultati inseriti nel CSV. Introdotta la classe WebSearchClient multi-provider e le relative opzioni configurabili (provider, chiave, lingua, max risultati, delay). Migliorata la UI con filtri orari, filtro avanzato per nazione/lega e pannello di configurazione ricerca web. Estese le impostazioni (AppConfig, UserSettings) per supportare i nuovi parametri. Introdotto logging avanzato file-based (AppLogger) con livelli di severitΓ  e rotazione giornaliera. Migliorata la gestione errori API e la UX nella selezione filtri e provider. --- .../Football/FootballDownloadOptions.cs | 20 + .../HorseRacingPredictor/Football/Main.cs | 152 +++++++- .../Football/WebSearch/WebSearchClient.cs | 367 ++++++++++++++++++ .../HorseRacingPredictor/AppConfig.cs | 44 ++- .../HorseRacingPredictor/UserSettings.cs | 25 +- .../appsettings.template.json | 9 + .../Infrastructure/AppLogger.cs | 179 +++++++++ .../HorseRacingPredictor/MainWindow.xaml | 177 ++++++++- .../HorseRacingPredictor/MainWindow.xaml.cs | 317 ++++++++++++++- .../HorseRacingPredictor/Manager/API.cs | 6 +- 10 files changed, 1283 insertions(+), 13 deletions(-) create mode 100644 HorseRacingPredictor/HorseRacingPredictor/Football/WebSearch/WebSearchClient.cs create mode 100644 HorseRacingPredictor/HorseRacingPredictor/Infrastructure/AppLogger.cs 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 (1–20). + 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 @@ + + + + + + + + + + + + + + + + + + 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