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:
@@ -1,3 +1,4 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
|
||||||
namespace HorseRacingPredictor.Football
|
namespace HorseRacingPredictor.Football
|
||||||
@@ -101,6 +102,25 @@ namespace HorseRacingPredictor.Football
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int MinRemainingQuota { get; set; } = 10;
|
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>
|
/// <summary>
|
||||||
/// Restituisce la stagione corrente calcolata (anno corrente o anno-1 se prima di luglio).
|
/// Restituisce la stagione corrente calcolata (anno corrente o anno-1 se prima di luglio).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using System.Text.Json;
|
|||||||
using System.Text.Json.Nodes;
|
using System.Text.Json.Nodes;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
using HorseRacingPredictor.Infrastructure;
|
||||||
|
|
||||||
namespace HorseRacingPredictor.Football
|
namespace HorseRacingPredictor.Football
|
||||||
{
|
{
|
||||||
@@ -191,8 +192,10 @@ namespace HorseRacingPredictor.Football
|
|||||||
if (options.DownloadFixtures)
|
if (options.DownloadFixtures)
|
||||||
{
|
{
|
||||||
ReportProgress("Scaricamento elenco partite...");
|
ReportProgress("Scaricamento elenco partite...");
|
||||||
|
AppLogger.Info("Football", $"[Step 1] Download fixtures per {date:yyyy-MM-dd}");
|
||||||
fixturesResponse = GetFixtures(date);
|
fixturesResponse = GetFixtures(date);
|
||||||
table = CreateFixturesDataTable(fixturesResponse, options);
|
table = CreateFixturesDataTable(fixturesResponse, options);
|
||||||
|
AppLogger.Info("Football", $"[Step 1] Fixture ottenute: {table.Rows.Count}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -205,6 +208,7 @@ namespace HorseRacingPredictor.Football
|
|||||||
if (options.DownloadOdds)
|
if (options.DownloadOdds)
|
||||||
{
|
{
|
||||||
ReportProgress($"Scaricamento quote (max {options.OddsMaxPages} pagine, bookmaker {options.BookmakerId})...");
|
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);
|
var oddsResponses = GetOdds(date, options);
|
||||||
ParseOddsIntoTable(table, oddsResponses, options.BookmakerId);
|
ParseOddsIntoTable(table, oddsResponses, options.BookmakerId);
|
||||||
}
|
}
|
||||||
@@ -216,6 +220,7 @@ namespace HorseRacingPredictor.Football
|
|||||||
? Math.Min(fixtureCount, options.MaxFixturesForDetails)
|
? Math.Min(fixtureCount, options.MaxFixturesForDetails)
|
||||||
: fixtureCount;
|
: fixtureCount;
|
||||||
ReportProgress($"Scaricamento previsioni per {maxPred} partite...");
|
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);
|
EnrichWithPredictions(table, fixturesResponse, options, statusCallback, progressCallback, ref currentStep, totalSteps);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +228,7 @@ namespace HorseRacingPredictor.Football
|
|||||||
if (options.DownloadStandings)
|
if (options.DownloadStandings)
|
||||||
{
|
{
|
||||||
ReportProgress("Scaricamento classifiche...");
|
ReportProgress("Scaricamento classifiche...");
|
||||||
|
AppLogger.Info("Football", "[Step 4] Download classifiche");
|
||||||
EnrichWithStandings(table, options);
|
EnrichWithStandings(table, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +239,7 @@ namespace HorseRacingPredictor.Football
|
|||||||
? Math.Min(fixtureCount, options.MaxFixturesForDetails)
|
? Math.Min(fixtureCount, options.MaxFixturesForDetails)
|
||||||
: fixtureCount;
|
: fixtureCount;
|
||||||
ReportProgress($"Scaricamento scontri diretti per {maxH2H} partite...");
|
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);
|
EnrichWithH2H(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +247,7 @@ namespace HorseRacingPredictor.Football
|
|||||||
if (options.DownloadEvents)
|
if (options.DownloadEvents)
|
||||||
{
|
{
|
||||||
ReportProgress("Scaricamento eventi partite...");
|
ReportProgress("Scaricamento eventi partite...");
|
||||||
|
AppLogger.Info("Football", "[Step 6] Download eventi");
|
||||||
EnrichWithEvents(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
|
EnrichWithEvents(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +255,7 @@ namespace HorseRacingPredictor.Football
|
|||||||
if (options.DownloadLineups)
|
if (options.DownloadLineups)
|
||||||
{
|
{
|
||||||
ReportProgress("Scaricamento formazioni...");
|
ReportProgress("Scaricamento formazioni...");
|
||||||
|
AppLogger.Info("Football", "[Step 7] Download formazioni");
|
||||||
EnrichWithLineups(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
|
EnrichWithLineups(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +263,7 @@ namespace HorseRacingPredictor.Football
|
|||||||
if (options.DownloadStatistics)
|
if (options.DownloadStatistics)
|
||||||
{
|
{
|
||||||
ReportProgress("Scaricamento statistiche partite...");
|
ReportProgress("Scaricamento statistiche partite...");
|
||||||
|
AppLogger.Info("Football", "[Step 8] Download statistiche");
|
||||||
EnrichWithStatistics(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
|
EnrichWithStatistics(table, options, statusCallback, progressCallback, ref currentStep, totalSteps);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,18 +271,33 @@ namespace HorseRacingPredictor.Football
|
|||||||
if (options.DownloadInjuries)
|
if (options.DownloadInjuries)
|
||||||
{
|
{
|
||||||
ReportProgress("Scaricamento infortunati...");
|
ReportProgress("Scaricamento infortunati...");
|
||||||
|
AppLogger.Info("Football", "[Step 9] Download infortuni");
|
||||||
EnrichWithInjuries(table, date, options);
|
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 ??
|
// ?? Completamento ??
|
||||||
progressCallback?.Report(100);
|
progressCallback?.Report(100);
|
||||||
string quotaMsg = _lastQuota.IsValid ? $" — {_lastQuota}" : "";
|
string quotaMsg = _lastQuota.IsValid ? $" — {_lastQuota}" : "";
|
||||||
statusCallback?.Report($"Trovate {table.Rows.Count} partite{quotaMsg}");
|
statusCallback?.Report($"Trovate {table.Rows.Count} partite{quotaMsg}");
|
||||||
|
AppLogger.Info("Football", $"Download completato: {table.Rows.Count} partite, {table.Columns.Count} colonne");
|
||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_database.LogError("recupero partite del giorno", ex);
|
_database.LogError("recupero partite del giorno", ex);
|
||||||
|
AppLogger.Error("Football", "Eccezione durante il download delle partite", ex);
|
||||||
statusCallback?.Report($"Errore: {ex.Message}");
|
statusCallback?.Report($"Errore: {ex.Message}");
|
||||||
return CreateEmptyResultTable();
|
return CreateEmptyResultTable();
|
||||||
}
|
}
|
||||||
@@ -294,6 +319,7 @@ namespace HorseRacingPredictor.Football
|
|||||||
if (options.DownloadLineups) steps.Add("lineups");
|
if (options.DownloadLineups) steps.Add("lineups");
|
||||||
if (options.DownloadStatistics) steps.Add("statistics");
|
if (options.DownloadStatistics) steps.Add("statistics");
|
||||||
if (options.DownloadInjuries) steps.Add("injuries");
|
if (options.DownloadInjuries) steps.Add("injuries");
|
||||||
|
if (options.WebSearch?.Enabled == true) steps.Add("websearch");
|
||||||
return steps;
|
return steps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -885,12 +911,14 @@ namespace HorseRacingPredictor.Football
|
|||||||
dataTable.Columns.Add("Infortunati Trasf.", typeof(int));
|
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>
|
return dataTable;
|
||||||
/// Crea un DataTable con le partite dalla risposta API
|
}
|
||||||
/// </summary>
|
|
||||||
private DataTable CreateFixturesDataTable(RestResponse response, FootballDownloadOptions options = null)
|
private DataTable CreateFixturesDataTable(RestResponse response, FootballDownloadOptions options = null)
|
||||||
{
|
{
|
||||||
options ??= new FootballDownloadOptions();
|
options ??= new FootballDownloadOptions();
|
||||||
@@ -942,6 +970,12 @@ namespace HorseRacingPredictor.Football
|
|||||||
if (fixtureEl.TryGetProperty("date", out var dateEl) && dateEl.ValueKind == JsonValueKind.String)
|
if (fixtureEl.TryGetProperty("date", out var dateEl) && dateEl.ValueKind == JsonValueKind.String)
|
||||||
{
|
{
|
||||||
DateTime.TryParse(dateEl.GetString(), out var parsedDate);
|
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;
|
row["Data / Ora"] = parsedDate;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -1879,6 +1913,116 @@ namespace HorseRacingPredictor.Football
|
|||||||
|
|
||||||
#endregion
|
#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
|
#region Supplementary Data
|
||||||
|
|
||||||
/// <summary>
|
/// <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 (1–20).</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;
|
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();
|
public static IConfiguration Configuration => _configuration ??= BuildConfiguration();
|
||||||
|
|
||||||
private static IConfiguration BuildConfiguration()
|
private static IConfiguration BuildConfiguration()
|
||||||
@@ -38,12 +41,51 @@ namespace HorseRacingPredictor
|
|||||||
|
|
||||||
// ?? API settings ????????????????????????????????????????
|
// ?? API settings ????????????????????????????????????????
|
||||||
public static string FootballApiKey =>
|
public static string FootballApiKey =>
|
||||||
Configuration["Api:FootballApiKey"] ?? string.Empty;
|
!string.IsNullOrEmpty(_footballApiKeyOverride)
|
||||||
|
? _footballApiKeyOverride
|
||||||
|
: Configuration["Api:FootballApiKey"] ?? string.Empty;
|
||||||
|
|
||||||
public static string FootballApiKeyHeader =>
|
public static string FootballApiKeyHeader =>
|
||||||
Configuration["Api:FootballApiKeyHeader"] ?? "x-rapidapi-key";
|
Configuration["Api:FootballApiKeyHeader"] ?? "x-rapidapi-key";
|
||||||
|
|
||||||
public static string FootballApiHost =>
|
public static string FootballApiHost =>
|
||||||
Configuration["Api:FootballApiHost"] ?? "v3.football.api-sports.io";
|
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 FbDownloadCoaches { get; set; } = false;
|
||||||
public bool FbDownloadTransfers { 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 ???????????????????????????????????????????????
|
// ?? Racing ???????????????????????????????????????????????
|
||||||
public string RacingApiKey { get; set; } = string.Empty;
|
public string RacingApiKey { get; set; } = string.Empty;
|
||||||
public string RcDataSource { get; set; } = "API - FormFav";
|
public string RcDataSource { get; set; } = "API - FormFav";
|
||||||
@@ -108,7 +118,20 @@ namespace HorseRacingPredictor
|
|||||||
DownloadTopCards = FbDownloadTopCards,
|
DownloadTopCards = FbDownloadTopCards,
|
||||||
DownloadSquads = FbDownloadSquads,
|
DownloadSquads = FbDownloadSquads,
|
||||||
DownloadCoaches = FbDownloadCoaches,
|
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,
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
@@ -7,5 +7,14 @@
|
|||||||
"FootballApiKey": "",
|
"FootballApiKey": "",
|
||||||
"FootballApiKeyHeader": "x-rapidapi-key",
|
"FootballApiKeyHeader": "x-rapidapi-key",
|
||||||
"FootballApiHost": "v3.football.api-sports.io"
|
"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>
|
</Popup>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
<!-- Avanzati -->
|
||||||
|
<Border Height="1" Background="{StaticResource BrBorder}" Margin="0,6,0,10"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
|
||||||
|
<TextBlock Text="⚽" 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="↻" 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="⏰" 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 -->
|
<!-- Avanzati -->
|
||||||
<Border Height="1" Background="{StaticResource BrBorder}" Margin="0,6,0,10"/>
|
<Border Height="1" Background="{StaticResource BrBorder}" Margin="0,6,0,10"/>
|
||||||
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
|
<StackPanel Orientation="Horizontal" Margin="0,0,0,6">
|
||||||
@@ -1173,8 +1274,8 @@
|
|||||||
<ComboBox x:Name="cmbFbTimezone" IsTextSearchEnabled="True"/>
|
<ComboBox x:Name="cmbFbTimezone" IsTextSearchEnabled="True"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Text="ID Leghe (virgola, vuoto = tutte)" Foreground="{StaticResource BrSubtext0}" FontSize="10" Margin="0,0,0,3"/>
|
<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"/>
|
<TextBox x:Name="txtFbLeagueIds" Style="{StaticResource FlatTb}" Margin="0,0,0,6" Visibility="Collapsed"/>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
@@ -1193,6 +1294,76 @@
|
|||||||
<TextBox x:Name="txtFbApiDelay" Style="{StaticResource FlatTb}" Text="300"/>
|
<TextBox x:Name="txtFbApiDelay" Style="{StaticResource FlatTb}" Text="300"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</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="🔍" 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><!-- end pnlFbApiOptions -->
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ namespace HorseRacingPredictor
|
|||||||
BuildCountryCheckboxes();
|
BuildCountryCheckboxes();
|
||||||
BuildFbEndpointCheckboxes();
|
BuildFbEndpointCheckboxes();
|
||||||
BuildFbSupplementaryCheckboxes();
|
BuildFbSupplementaryCheckboxes();
|
||||||
|
BuildFbLeagueFilter();
|
||||||
PopulateTimezoneComboBoxes();
|
PopulateTimezoneComboBoxes();
|
||||||
|
PopulateTimeFilterComboBoxes();
|
||||||
// Wire preview update events
|
// Wire preview update events
|
||||||
txtFbPrefix.TextChanged += (s, e) => UpdateFbPreview();
|
txtFbPrefix.TextChanged += (s, e) => UpdateFbPreview();
|
||||||
txtFbSuffix.TextChanged += (s, e) => UpdateFbPreview();
|
txtFbSuffix.TextChanged += (s, e) => UpdateFbPreview();
|
||||||
@@ -44,6 +46,9 @@ namespace HorseRacingPredictor
|
|||||||
cmbFbFormat.SelectionChanged += (s, e) => UpdateFbPreview();
|
cmbFbFormat.SelectionChanged += (s, e) => UpdateFbPreview();
|
||||||
dpFootball.SelectedDateChanged += (s, e) => UpdateFbPreview();
|
dpFootball.SelectedDateChanged += (s, e) => UpdateFbPreview();
|
||||||
|
|
||||||
|
chkFbWebSearch.Checked += (s, e) => UpdateWebSearchPanelVisibility();
|
||||||
|
chkFbWebSearch.Unchecked += (s, e) => UpdateWebSearchPanelVisibility();
|
||||||
|
|
||||||
txtRcPrefix.TextChanged += (s, e) => UpdateRcPreview();
|
txtRcPrefix.TextChanged += (s, e) => UpdateRcPreview();
|
||||||
txtRcSuffix.TextChanged += (s, e) => UpdateRcPreview();
|
txtRcSuffix.TextChanged += (s, e) => UpdateRcPreview();
|
||||||
chkRcIncludeDate.Checked += (s, e) => UpdateRcPreview();
|
chkRcIncludeDate.Checked += (s, e) => UpdateRcPreview();
|
||||||
@@ -208,6 +213,24 @@ namespace HorseRacingPredictor
|
|||||||
combo.SelectedItem = ianaId;
|
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 ????????????????????
|
// ???????????????????? LIFECYCLE ????????????????????
|
||||||
|
|
||||||
private void Window_Loaded(object sender, RoutedEventArgs e)
|
private void Window_Loaded(object sender, RoutedEventArgs e)
|
||||||
@@ -324,6 +347,9 @@ namespace HorseRacingPredictor
|
|||||||
dpFootball.IsEnabled = false;
|
dpFootball.IsEnabled = false;
|
||||||
btnExportFbCsv.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
|
// Costruisci le opzioni di download dalla UI
|
||||||
var options = BuildFootballDownloadOptions();
|
var options = BuildFootballDownloadOptions();
|
||||||
|
|
||||||
@@ -342,7 +368,7 @@ namespace HorseRacingPredictor
|
|||||||
dgFootball.ItemsSource = _footballData?.DefaultView;
|
dgFootball.ItemsSource = _footballData?.DefaultView;
|
||||||
dgFootball.AutoGeneratingColumn += (s, e) =>
|
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;
|
e.Cancel = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -681,6 +707,195 @@ namespace HorseRacingPredictor
|
|||||||
if (_fbSupplementaryCheckboxes.TryGetValue(key, out var cb)) cb.IsChecked = value;
|
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)
|
private async void btnDownloadRc_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
await DownloadRacecardsAsync();
|
await DownloadRacecardsAsync();
|
||||||
@@ -1114,6 +1329,8 @@ namespace HorseRacingPredictor
|
|||||||
var s = UserSettings.Load();
|
var s = UserSettings.Load();
|
||||||
|
|
||||||
txtApiKey.Text = s.ApiKey;
|
txtApiKey.Text = s.ApiKey;
|
||||||
|
// Applica la API key alla configurazione runtime
|
||||||
|
AppConfig.SetFootballApiKey(s.ApiKey);
|
||||||
SetComboBoxSelectionByContent(cmbFbDataSource, s.FbDataSource);
|
SetComboBoxSelectionByContent(cmbFbDataSource, s.FbDataSource);
|
||||||
txtFbExportPath.Text = s.FbExportPath;
|
txtFbExportPath.Text = s.FbExportPath;
|
||||||
txtFbPrefix.Text = s.FbPrefix;
|
txtFbPrefix.Text = s.FbPrefix;
|
||||||
@@ -1150,10 +1367,23 @@ namespace HorseRacingPredictor
|
|||||||
txtFbMaxFixtures.Text = s.FbMaxFixturesForDetails.ToString();
|
txtFbMaxFixtures.Text = s.FbMaxFixturesForDetails.ToString();
|
||||||
if (cmbFbTimezone != null) SetTimezoneSelection(cmbFbTimezone, s.FbTimezone);
|
if (cmbFbTimezone != null) SetTimezoneSelection(cmbFbTimezone, s.FbTimezone);
|
||||||
txtFbLeagueIds.Text = s.FbLeagueIds.Count > 0 ? string.Join(",", s.FbLeagueIds) : "";
|
txtFbLeagueIds.Text = s.FbLeagueIds.Count > 0 ? string.Join(",", s.FbLeagueIds) : "";
|
||||||
|
PopulateFbLeagueCheckboxes();
|
||||||
chkFbCheckQuota.IsChecked = s.FbCheckQuota;
|
chkFbCheckQuota.IsChecked = s.FbCheckQuota;
|
||||||
txtFbMinQuota.Text = s.FbMinRemainingQuota.ToString();
|
txtFbMinQuota.Text = s.FbMinRemainingQuota.ToString();
|
||||||
txtFbApiDelay.Text = s.FbApiDelayMs.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
|
// Racing
|
||||||
txtRcExportPath.Text = s.RcExportPath;
|
txtRcExportPath.Text = s.RcExportPath;
|
||||||
txtRcPrefix.Text = s.RcPrefix;
|
txtRcPrefix.Text = s.RcPrefix;
|
||||||
@@ -1405,6 +1635,9 @@ namespace HorseRacingPredictor
|
|||||||
// per validazione prima del salvataggio
|
// per validazione prima del salvataggio
|
||||||
var options = BuildFootballDownloadOptions();
|
var options = BuildFootballDownloadOptions();
|
||||||
|
|
||||||
|
// Applica la API key alla configurazione runtime
|
||||||
|
AppConfig.SetFootballApiKey(txtApiKey.Text);
|
||||||
|
|
||||||
var s = new UserSettings
|
var s = new UserSettings
|
||||||
{
|
{
|
||||||
ApiKey = txtApiKey.Text.Trim(),
|
ApiKey = txtApiKey.Text.Trim(),
|
||||||
@@ -1442,6 +1675,15 @@ namespace HorseRacingPredictor
|
|||||||
FbCheckQuota = chkFbCheckQuota.IsChecked == true,
|
FbCheckQuota = chkFbCheckQuota.IsChecked == true,
|
||||||
FbMinRemainingQuota = int.TryParse(txtFbMinQuota.Text.Trim(), out var mq) ? mq : 10,
|
FbMinRemainingQuota = int.TryParse(txtFbMinQuota.Text.Trim(), out var mq) ? mq : 10,
|
||||||
FbApiDelayMs = int.TryParse(txtFbApiDelay.Text.Trim(), out var ad) ? ad : 300,
|
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
|
// Racing
|
||||||
RcExportPath = txtRcExportPath.Text.Trim(),
|
RcExportPath = txtRcExportPath.Text.Trim(),
|
||||||
RcPrefix = txtRcPrefix.Text.Trim(),
|
RcPrefix = txtRcPrefix.Text.Trim(),
|
||||||
@@ -1478,8 +1720,10 @@ namespace HorseRacingPredictor
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void UpdateFootballStatCards()
|
private void UpdateFootballStatCards()
|
||||||
{
|
{
|
||||||
int rows = _footballData?.Rows.Count ?? 0;
|
int rows = _footballData?.DefaultView?.Count ?? _footballData?.Rows.Count ?? 0;
|
||||||
int cols = _footballData?.Columns.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();
|
lblFbCardCount.Text = rows.ToString();
|
||||||
lblFbCardCols.Text = cols > 0 ? cols.ToString() : "--";
|
lblFbCardCols.Text = cols > 0 ? cols.ToString() : "--";
|
||||||
lblFbCardFormat.Text = (cmbFbFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "CSV";
|
lblFbCardFormat.Text = (cmbFbFormat?.SelectedItem as ComboBoxItem)?.Content?.ToString() ?? "CSV";
|
||||||
@@ -1551,10 +1795,70 @@ namespace HorseRacingPredictor
|
|||||||
LeagueIds = ParseIntList(txtFbLeagueIds?.Text),
|
LeagueIds = ParseIntList(txtFbLeagueIds?.Text),
|
||||||
CheckQuota = chkFbCheckQuota.IsChecked == true,
|
CheckQuota = chkFbCheckQuota.IsChecked == true,
|
||||||
MinRemainingQuota = int.TryParse(txtFbMinQuota.Text.Trim(), out var mq) ? mq : 10,
|
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>
|
/// <summary>
|
||||||
/// Analizza una stringa di interi separati da virgola e restituisce una lista.
|
/// Analizza una stringa di interi separati da virgola e restituisce una lista.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1569,5 +1873,12 @@ namespace HorseRacingPredictor
|
|||||||
}
|
}
|
||||||
return result;
|
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);
|
var response = clientApi.Execute(request);
|
||||||
if (!response.IsSuccessful)
|
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
|
// Aggiungi una pausa tra una chiamata e l'altra
|
||||||
|
|||||||
Reference in New Issue
Block a user