Arricchimento calcio: ricerca web AI, filtri avanzati, log

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.
This commit is contained in:
2026-04-29 16:17:03 +02:00
parent 923f4c761c
commit cf69e3b2fd
10 changed files with 1283 additions and 13 deletions
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
namespace HorseRacingPredictor.Football
@@ -101,6 +102,25 @@ namespace HorseRacingPredictor.Football
/// </summary>
public int MinRemainingQuota { get; set; } = 10;
/// <summary>
/// Ora minima per filtrare le fixture (null = nessun limite inferiore).
/// Il filtro viene applicato lato client dopo il download.
/// </summary>
public TimeSpan? TimeFrom { get; set; }
/// <summary>
/// Ora massima per filtrare le fixture (null = nessun limite superiore).
/// Il filtro viene applicato lato client dopo il download.
/// </summary>
public TimeSpan? TimeTo { get; set; }
/// <summary>
/// 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.
/// </summary>
public WebSearch.WebSearchOptions WebSearch { get; set; } = new();
/// <summary>
/// Restituisce la stagione corrente calcolata (anno corrente o anno-1 se prima di luglio).
/// </summary>
@@ -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));
}
return dataTable;
// Colonna web search (sempre in fondo, se abilitata)
if (options.WebSearch?.Enabled == true)
{
dataTable.Columns.Add("Info Web AI", typeof(string));
}
/// <summary>
/// Crea un DataTable con le partite dalla risposta API
/// </summary>
return dataTable;
}
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
/// <summary>
/// Arricchisce il DataTable con risultati di ricerca web per ogni partita.
/// I risultati vengono inseriti nella colonna "Info Web AI".
/// </summary>
private void EnrichWithWebSearch(
DataTable table, DateTime date,
FootballDownloadOptions options,
IProgress<string> statusCallback,
IProgress<int> 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
/// <summary>
@@ -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
{
/// <summary>
/// Provider di ricerca web supportato.
/// </summary>
public enum WebSearchProvider
{
/// <summary>Bing Web Search API v7 (richiede chiave Azure Cognitive Services).</summary>
Bing,
/// <summary>Google Custom Search JSON API (richiede chiave + CX id).</summary>
Google,
/// <summary>SerpAPI aggregatore multi-motore (richiede chiave).</summary>
SerpApi,
/// <summary>SearXNG istanza self-hosted (nessuna chiave richiesta).</summary>
SearXng,
}
/// <summary>
/// Opzioni per la ricerca web per singola partita.
/// </summary>
public class WebSearchOptions
{
/// <summary>Abilita l'arricchimento tramite ricerca web.</summary>
public bool Enabled { get; set; } = false;
/// <summary>Provider da usare.</summary>
public WebSearchProvider Provider { get; set; } = WebSearchProvider.SearXng;
/// <summary>API key del provider scelto (non richiesta per SearXNG).</summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Solo per Google Custom Search: identificatore del motore di ricerca (cx).
/// </summary>
public string GoogleCx { get; set; } = string.Empty;
/// <summary>
/// URL base dell'istanza SearXNG self-hosted (es. "http://192.168.30.23:8082").
/// </summary>
public string SearXNgUrl { get; set; } = "http://192.168.30.23:8082";
/// <summary>Numero massimo di risultati da includere per partita (120).</summary>
public int MaxResults { get; set; } = 10;
/// <summary>Ritardo in ms tra le ricerche per evitare rate-limit.</summary>
public int DelayMs { get; set; } = 300;
/// <summary>Lingua della ricerca (es. "it", "en").</summary>
public string Language { get; set; } = "it";
}
/// <summary>
/// Singolo risultato di una ricerca web.
/// </summary>
public class WebSearchResult
{
public string Title { get; init; } = string.Empty;
public string Snippet { get; init; } = string.Empty;
public string Url { get; init; } = string.Empty;
}
/// <summary>
/// Client per la ricerca web multi-provider.
/// Effettua query testuali e restituisce snippet pronti per essere inseriti nel CSV.
/// </summary>
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 ????????????????????????????????????????????????????????
/// <summary>
/// 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.
/// </summary>
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 ????????????????????????????????????????????????????????????
/// <summary>
/// Esegue la ricerca e restituisce uno snippet unico concatenato (max ~3000 caratteri),
/// pronto per essere inserito in una cella CSV.
/// </summary>
public async Task<string> 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<WebSearchResult> 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<List<WebSearchResult>> 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<WebSearchResult>();
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<List<WebSearchResult>> 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<WebSearchResult>();
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<List<WebSearchResult>> 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<WebSearchResult>();
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 ???????????????????????????????????????????????????????????????
/// <summary>
/// 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).
/// </summary>
private async Task<List<WebSearchResult>> 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<string>(StringComparer.OrdinalIgnoreCase);
var merged = new List<WebSearchResult>();
foreach (var r in newsTask.Result.Concat(generalTask.Result))
{
if (seen.Add(r.Url))
merged.Add(r);
}
return merged;
}
private async Task<List<WebSearchResult>> 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<WebSearchResult>();
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;
}
}
}
@@ -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";
/// <summary>
/// Imposta la API key di Football da codice (es. dalla UI).
/// Ha precedenza sul valore in appsettings.json.
/// </summary>
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();
}
}
}
@@ -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<Football.WebSearch.WebSearchProvider>(FbWebSearchProvider, out var p)
? p : Football.WebSearch.WebSearchProvider.SearXng,
ApiKey = FbWebSearchApiKey,
GoogleCx = FbWebSearchGoogleCx,
SearXNgUrl = FbWebSearchSearXNgUrl,
MaxResults = FbWebSearchMaxResults,
DelayMs = FbWebSearchDelayMs,
Language = FbWebSearchLanguage,
}
};
}
@@ -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"
}
}
@@ -0,0 +1,179 @@
using System;
using System.IO;
using System.Text;
using System.Threading;
namespace HorseRacingPredictor.Infrastructure
{
/// <summary>
/// Log severity level.
/// </summary>
public enum LogLevel
{
Debug,
Info,
Warning,
Error,
}
/// <summary>
/// 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.
/// </summary>
public sealed class AppLogger
{
// ?? Singleton ????????????????????????????????????????????????????????????
private static readonly Lazy<AppLogger> _instance =
new(() => new AppLogger(), LazyThreadSafetyMode.ExecutionAndPublication);
public static AppLogger Instance => _instance.Value;
// ?? Configuration ????????????????????????????????????????????????????????
/// <summary>Minimum level written to file (default: Debug).</summary>
public LogLevel MinLevel { get; set; } = LogLevel.Debug;
/// <summary>Also write to Debug output (default: true in DEBUG builds).</summary>
#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);
}
/// <summary>Returns the path of the current log file.</summary>
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 { }
}
}
}
@@ -1136,6 +1136,107 @@
</Popup>
</Grid>
<!-- Avanzati -->
<Border Height="1" Background="{StaticResource BrBorder}" Margin="0,6,0,10"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="&#x26BD;" FontFamily="Segoe UI Symbol" FontSize="13"
Foreground="{StaticResource BrPeach}" VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="Competizioni" FontSize="12" FontFamily="Segoe UI Semibold" Foreground="{StaticResource BrText}"/>
</StackPanel>
<TextBlock Text="Filtra per nazione e lega (vuoto = tutte)" FontSize="9"
Foreground="{StaticResource BrOverlay0}" Margin="0,0,0,6"/>
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Nazione" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<ComboBox x:Name="cmbFbCountry" IsTextSearchEnabled="True"/>
</StackPanel>
<Button x:Name="btnFbRefreshLeagues" Grid.Column="2" Content="&#x21BB;" FontFamily="Segoe UI Symbol"
Style="{StaticResource ToolBtn}" VerticalAlignment="Bottom"
Height="34" Padding="10,0" FontSize="14"
ToolTip="Aggiorna leghe da API"
Click="btnFbRefreshLeagues_Click"/>
</Grid>
<ProgressBar x:Name="pbFbLeagueRefresh" Style="{StaticResource ModernPb}" Margin="0,2,0,2" Visibility="Collapsed"/>
<TextBlock x:Name="lblFbLeagueRefreshStatus" FontSize="9" Foreground="{StaticResource BrOverlay0}" Margin="0,0,0,4" Text=""/>
<Grid>
<ToggleButton x:Name="btnFbLeaguesToggle"
Height="34" HorizontalContentAlignment="Left"
Padding="10,0,28,0" Cursor="Hand">
<ToggleButton.Template>
<ControlTemplate TargetType="ToggleButton">
<Grid>
<Border x:Name="Bd" CornerRadius="6"
Background="{StaticResource BrSurface0}"
BorderBrush="{StaticResource BrSurface1}" BorderThickness="1"/>
<ContentPresenter Margin="10,0,28,0" VerticalAlignment="Center" HorizontalAlignment="Left"/>
<Path Data="M 0,0 L 4,4 L 8,0" Stroke="{StaticResource BrSubtext0}" StrokeThickness="1.5"
HorizontalAlignment="Right" VerticalAlignment="Center" Margin="0,0,10,0"/>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource BrOverlay0}"/>
</Trigger>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{StaticResource BrBlue}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</ToggleButton.Template>
<TextBlock x:Name="lblFbLeaguesSummary" Text="Tutte le leghe"
Foreground="{StaticResource BrText}" FontSize="13" TextTrimming="CharacterEllipsis"/>
</ToggleButton>
<Popup x:Name="popupFbLeagues" Placement="Bottom" StaysOpen="False" AllowsTransparency="True"
IsOpen="{Binding IsChecked, ElementName=btnFbLeaguesToggle, Mode=TwoWay}">
<Border Background="{StaticResource BrSurface1}" BorderBrush="{StaticResource BrSurface2}"
BorderThickness="1" CornerRadius="8" Padding="6,8" Margin="0,4,0,0"
MinWidth="280" MaxHeight="360">
<Border.Effect>
<DropShadowEffect BlurRadius="16" ShadowDepth="4" Color="#40000000"/>
</Border.Effect>
<DockPanel>
<TextBox x:Name="txtFbLeagueSearch" DockPanel.Dock="Top"
Style="{StaticResource FlatTb}" Height="28" FontSize="11"
Margin="2,0,2,6" Padding="6,0"
TextChanged="txtFbLeagueSearch_TextChanged"
Tag="Cerca lega..."/>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<StackPanel x:Name="pnlFbLeagues"/>
</ScrollViewer>
</DockPanel>
</Border>
</Popup>
</Grid>
<!-- Filtro orario -->
<Border Height="1" Background="{StaticResource BrBorder}" Margin="0,6,0,10"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
<TextBlock Text="&#x23F0;" FontFamily="Segoe UI Symbol" FontSize="13"
Foreground="{StaticResource BrBlue}" VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="Filtro orario" FontSize="12" FontFamily="Segoe UI Semibold" Foreground="{StaticResource BrText}"/>
</StackPanel>
<TextBlock Text="Scarica solo partite nell'intervallo (vuoto = tutte)" FontSize="9"
Foreground="{StaticResource BrOverlay0}" Margin="0,0,0,6"/>
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Da" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<ComboBox x:Name="cmbFbTimeFrom" ToolTip="Ora inizio (vuoto = tutte)"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="A" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<ComboBox x:Name="cmbFbTimeTo" ToolTip="Ora fine (vuoto = tutte)"/>
</StackPanel>
</Grid>
<!-- Avanzati -->
<Border Height="1" Background="{StaticResource BrBorder}" Margin="0,6,0,10"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
@@ -1173,8 +1274,8 @@
<ComboBox x:Name="cmbFbTimezone" IsTextSearchEnabled="True"/>
</StackPanel>
</Grid>
<TextBlock Text="ID Leghe (virgola, vuoto = tutte)" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<TextBox x:Name="txtFbLeagueIds" Style="{StaticResource FlatTb}" Margin="0,0,0,6"/>
<TextBlock Text="ID Leghe (virgola, vuoto = tutte)" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3" Visibility="Collapsed"/>
<TextBox x:Name="txtFbLeagueIds" Style="{StaticResource FlatTb}" Margin="0,0,0,6" Visibility="Collapsed"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -1193,6 +1294,76 @@
<TextBox x:Name="txtFbApiDelay" Style="{StaticResource FlatTb}" Text="300"/>
</StackPanel>
</Grid>
<!-- Ricerca Web AI -->
<Border Height="1" Background="{StaticResource BrBorder}" Margin="0,6,0,10"/>
<StackPanel Orientation="Horizontal" Margin="0,0,0,4">
<TextBlock Text="&#x1F50D;" FontFamily="Segoe UI Symbol" FontSize="13"
Foreground="{StaticResource BrGreen}" VerticalAlignment="Center" Margin="0,0,5,0"/>
<TextBlock Text="Ricerca Web AI" FontSize="12" FontFamily="Segoe UI Semibold" Foreground="{StaticResource BrText}"/>
</StackPanel>
<TextBlock Text="Per ogni partita esegue una ricerca web e inserisce i risultati in una colonna del CSV per arricchire le previsioni IA"
FontSize="9" Foreground="{StaticResource BrOverlay0}" TextWrapping="Wrap" Margin="0,0,0,6"/>
<CheckBox x:Name="chkFbWebSearch" Margin="0,0,0,8">Abilita ricerca web per ogni partita</CheckBox>
<StackPanel x:Name="pnlFbWebSearch">
<TextBlock Text="Provider" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<ComboBox x:Name="cmbFbWebSearchProvider" Margin="0,0,0,8" SelectionChanged="cmbFbWebSearchProvider_SelectionChanged">
<ComboBoxItem Content="SearXNG (self-hosted)"/>
<ComboBoxItem Content="Bing Web Search API"/>
<ComboBoxItem Content="Google Custom Search"/>
<ComboBoxItem Content="SerpAPI"/>
</ComboBox>
<!-- SearXNG URL (visibile solo per SearXNG) -->
<StackPanel x:Name="pnlFbWebSearchSearXNg">
<TextBlock Text="URL istanza SearXNG" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<TextBox x:Name="txtFbWebSearchSearXNgUrl" Style="{StaticResource FlatTb}" Margin="0,0,0,8"
Text="http://192.168.30.23:8082"/>
</StackPanel>
<!-- API Key (nascosta per SearXNG) -->
<StackPanel x:Name="pnlFbWebSearchApiKey">
<TextBlock Text="API Key" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<TextBox x:Name="txtFbWebSearchApiKey" Style="{StaticResource FlatTb}" Margin="0,0,0,8"/>
</StackPanel>
<!-- Google CX (visibile solo per Google) -->
<StackPanel x:Name="pnlFbWebSearchGoogleCx">
<TextBlock Text="Google CX (Search Engine ID)" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<TextBox x:Name="txtFbWebSearchGoogleCx" Style="{StaticResource FlatTb}" Margin="0,0,0,8"/>
</StackPanel>
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="8"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="Risultati max" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<TextBox x:Name="txtFbWebSearchMaxResults" Style="{StaticResource FlatTb}" Text="10"/>
</StackPanel>
<StackPanel Grid.Column="2">
<TextBlock Text="Delay (ms)" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<TextBox x:Name="txtFbWebSearchDelayMs" Style="{StaticResource FlatTb}" Text="300"/>
</StackPanel>
<StackPanel Grid.Column="4">
<TextBlock Text="Lingua" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
<ComboBox x:Name="cmbFbWebSearchLanguage" SelectedIndex="0">
<ComboBoxItem Content="all"/>
<ComboBoxItem Content="it"/>
<ComboBoxItem Content="en"/>
<ComboBoxItem Content="es"/>
<ComboBoxItem Content="de"/>
<ComboBoxItem Content="fr"/>
</ComboBox>
</StackPanel>
</Grid>
<Border Background="{StaticResource BrSurface1}" CornerRadius="5" Padding="8,5" Margin="0,0,0,2">
<TextBlock FontSize="9" Foreground="{StaticResource BrOverlay0}" TextWrapping="Wrap">
<Run Text="? "/>
<Run Text="SearXNG: nessuna API key - basta l'URL dell'istanza (raccoglie da piu' motori in parallelo). Bing: chiave Azure Cognitive Services. Google: chiave API + CX. SerpAPI: chiave da serpapi.com."/>
</TextBlock>
</Border>
</StackPanel>
</StackPanel><!-- end pnlFbApiOptions -->
</StackPanel>
</Grid>
@@ -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;
}
/// <summary>
/// Popola i ComboBox per il filtro orario con slot ogni 30 minuti (00:00 23:30).
/// Il primo elemento "—" indica nessun filtro.
/// </summary>
private void PopulateTimeFilterComboBoxes()
{
var items = new List<string> { "--" };
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) ————————————
/// <summary>Represents a league entry for the competition filter.</summary>
private record FbLeagueEntry(int Id, string Name, string Country);
private readonly Dictionary<string, CheckBox> _fbLeagueCheckboxes = new();
private List<FbLeagueEntry> _fbLeagues = new();
/// <summary>Default leagues: Big Five, European cups, and other notable leagues.</summary>
private static List<FbLeagueEntry> 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<string> { "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<int>(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<FbLeagueEntry>();
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
/// </summary>
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<Football.WebSearch.WebSearchProvider>(
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();
}
// ————————————————————————————————————————
/// <summary>
/// Analizza una stringa di interi separati da virgola e restituisce una lista.
/// </summary>
@@ -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;
}
}
}
@@ -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