Files
Mimante/Mimante/Services/StatsService.cs
Alberto Balbo a0ec72f6c0 Refactor: solo SQLite, limiti auto, UI statistiche nuova
Rimosso completamente il supporto a PostgreSQL: ora tutte le statistiche e i dati persistenti usano solo SQLite, con percorso configurabile tramite DATA_PATH per Docker/volumi. Aggiunta gestione avanzata delle statistiche per prodotto, limiti consigliati calcolati automaticamente e applicabili dalla UI. Rinnovata la pagina Statistiche con tabelle aste recenti e prodotti, rimosso il supporto a grafici legacy e a "Puntate Gratuite". Migliorata la ricerca e la gestione delle aste nel browser, aggiunta diagnostica avanzata e logging dettagliato per il database. Aggiornati Dockerfile e docker-compose: l'app è ora self-contained e pronta per l'uso senza database esterni.
2026-01-23 16:56:03 +01:00

464 lines
20 KiB
C#

using System;
using System.Linq;
using System.Threading.Tasks;
using AutoBidder.Models;
using System.Collections.Generic;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per calcolo e gestione statistiche.
/// Usa esclusivamente il database SQLite interno gestito da DatabaseService.
/// Le statistiche sono disabilitate se il database non è disponibile.
/// </summary>
public class StatsService
{
private readonly DatabaseService _db;
/// <summary>
/// Indica se le statistiche sono disponibili (database SQLite funzionante)
/// </summary>
public bool IsAvailable => _db.IsAvailable && _db.IsInitialized;
/// <summary>
/// Messaggio di errore se le statistiche non sono disponibili
/// </summary>
public string? ErrorMessage => !IsAvailable ? _db.InitializationError ?? "Database non disponibile" : null;
/// <summary>
/// Path del database SQLite
/// </summary>
public string DatabasePath => _db.DatabasePath;
private ProductStatisticsService? _productStatsService;
public StatsService(DatabaseService db)
{
_db = db;
_productStatsService = new ProductStatisticsService(db);
// Log stato database SQLite
Console.WriteLine($"[StatsService] Database available: {_db.IsAvailable}");
Console.WriteLine($"[StatsService] Database initialized: {_db.IsInitialized}");
if (!_db.IsAvailable)
{
Console.WriteLine($"[StatsService] Database error: {_db.InitializationError}");
}
}
/// <summary>
/// Registra il completamento di un'asta con tutti i dati per analytics
/// Include scraping HTML per ottenere le puntate del vincitore
/// </summary>
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, AuctionState state, bool won)
{
// Skip se database non disponibile
if (!IsAvailable)
{
Console.WriteLine("[StatsService] Skipping record - database not available");
return;
}
try
{
Console.WriteLine($"[StatsService] ====== INIZIO SALVATAGGIO ASTA TERMINATA ======");
Console.WriteLine($"[StatsService] Asta: {auction.Name} (ID: {auction.AuctionId})");
Console.WriteLine($"[StatsService] Stato: {(won ? "VINTA" : "PERSA")}");
var today = DateTime.UtcNow.ToString("yyyy-MM-dd");
var bidsUsed = auction.BidsUsedOnThisAuction ?? 0;
var bidCost = auction.BidCost;
var moneySpent = bidsUsed * bidCost;
var finalPrice = state.Price;
var buyNowPrice = auction.BuyNowPrice;
var shippingCost = auction.ShippingCost ?? 0;
// Dati aggiuntivi per analytics
var winnerUsername = state.LastBidder;
var totalResets = auction.ResetCount;
var productKey = ProductStatisticsService.GenerateProductKey(auction.Name);
Console.WriteLine($"[StatsService] Prezzo finale: €{finalPrice:F2}");
Console.WriteLine($"[StatsService] Puntate usate (utente): {bidsUsed}");
Console.WriteLine($"[StatsService] Vincitore: {winnerUsername ?? "N/A"}");
Console.WriteLine($"[StatsService] Reset totali: {totalResets}");
// ?? SCRAPING HTML: Ottieni puntate del vincitore dalla pagina dell'asta
int? winnerBidsUsed = null;
if (!string.IsNullOrEmpty(winnerUsername))
{
Console.WriteLine($"[StatsService] Avvio scraping HTML per ottenere puntate del vincitore...");
winnerBidsUsed = await ScrapeWinnerBidsFromAuctionPageAsync(auction.OriginalUrl);
// ? VALIDAZIONE: Verifica che i dati estratti siano ragionevoli
if (winnerBidsUsed.HasValue)
{
if (winnerBidsUsed.Value < 0)
{
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: Puntate negative ({winnerBidsUsed.Value}) - Dato scartato");
winnerBidsUsed = null;
}
else if (winnerBidsUsed.Value > 50000)
{
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: Puntate sospette ({winnerBidsUsed.Value} > 50000) - Dato scartato");
winnerBidsUsed = null;
}
else if (winnerBidsUsed.Value == 0 && totalResets > 10)
{
Console.WriteLine($"[StatsService] ? VALIDAZIONE FALLITA: 0 puntate con {totalResets} reset - Dato sospetto");
winnerBidsUsed = null;
}
else
{
Console.WriteLine($"[StatsService] ? Puntate vincitore estratte da HTML: {winnerBidsUsed.Value} (validazione OK)");
}
}
// Fallback se validazione fallita o scraping non riuscito
if (!winnerBidsUsed.HasValue)
{
Console.WriteLine($"[StatsService] ? Impossibile estrarre puntate valide del vincitore da HTML");
// Fallback: conta da RecentBids (meno affidabile)
if (auction.RecentBids != null)
{
winnerBidsUsed = auction.RecentBids
.Count(b => b.Username?.Equals(winnerUsername, StringComparison.OrdinalIgnoreCase) == true);
if (winnerBidsUsed.Value > 0)
{
Console.WriteLine($"[StatsService] Fallback: puntate vincitore da RecentBids: {winnerBidsUsed.Value}");
}
else
{
Console.WriteLine($"[StatsService] ? Fallback fallito: nessuna puntata trovata in RecentBids");
winnerBidsUsed = null;
}
}
}
}
double? totalCost = null;
double? savings = null;
if (won && buyNowPrice.HasValue)
{
totalCost = finalPrice + moneySpent + shippingCost;
savings = (buyNowPrice.Value + shippingCost) - totalCost.Value;
Console.WriteLine($"[StatsService] Costo totale: €{totalCost:F2}");
Console.WriteLine($"[StatsService] Risparmio: €{savings:F2}");
}
Console.WriteLine($"[StatsService] Salvataggio nel database...");
// Salva risultato asta con tutti i campi
await _db.SaveAuctionResultAsync(
auction.AuctionId,
auction.Name,
finalPrice,
bidsUsed,
won,
buyNowPrice,
shippingCost,
totalCost,
savings,
winnerUsername,
totalResets,
winnerBidsUsed,
productKey
);
Console.WriteLine($"[StatsService] ? Risultato asta salvato");
// Aggiorna statistiche giornaliere
await _db.SaveDailyStatAsync(
today,
bidsUsed,
moneySpent,
won ? 1 : 0,
won ? 0 : 1,
savings ?? 0,
state.PollingLatencyMs
);
Console.WriteLine($"[StatsService] ? Statistiche giornaliere aggiornate");
// Aggiorna statistiche aggregate per prodotto
if (_productStatsService != null)
{
Console.WriteLine($"[StatsService] Aggiornamento statistiche prodotto (key: {productKey})...");
await _productStatsService.UpdateProductStatisticsAsync(productKey, auction.Name);
Console.WriteLine($"[StatsService] ? Statistiche prodotto aggiornate");
}
Console.WriteLine($"[StatsService] ====== SALVATAGGIO COMPLETATO ======");
}
catch (Exception ex)
{
Console.WriteLine($"[StatsService ERROR] Failed to record auction: {ex.Message}");
Console.WriteLine($"[StatsService ERROR] Stack: {ex.StackTrace}");
}
}
/// <summary>
/// Scarica l'HTML della pagina dell'asta e estrae le puntate del vincitore
/// </summary>
private async Task<int?> ScrapeWinnerBidsFromAuctionPageAsync(string auctionUrl)
{
try
{
using var httpClient = new HttpClient();
// ? RIDOTTO: Timeout da 10s ? 5s per evitare rallentamenti
httpClient.Timeout = TimeSpan.FromSeconds(5);
// Headers browser-like per evitare rilevamento come bot
httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
httpClient.DefaultRequestHeaders.Add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
httpClient.DefaultRequestHeaders.Add("Accept-Language", "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7");
Console.WriteLine($"[StatsService] Downloading HTML from: {auctionUrl} (timeout: 5s)");
var html = await httpClient.GetStringAsync(auctionUrl);
Console.WriteLine($"[StatsService] HTML scaricato ({html.Length} chars), parsing...");
// Usa il metodo esistente di ClosedAuctionsScraper per estrarre le puntate
var bidsUsed = ExtractBidsUsedFromHtml(html);
return bidsUsed;
}
catch (TaskCanceledException)
{
Console.WriteLine($"[StatsService] ? Timeout durante download HTML (>5s) - URL: {auctionUrl}");
return null;
}
catch (HttpRequestException ex)
{
Console.WriteLine($"[StatsService] ? Errore HTTP durante scraping: {ex.Message}");
return null;
}
catch (Exception ex)
{
Console.WriteLine($"[StatsService] ? Errore generico durante scraping: {ex.Message}");
return null;
}
}
/// <summary>
/// Estrae le puntate usate dall'HTML (stesso algoritmo di ClosedAuctionsScraper)
/// </summary>
private int? ExtractBidsUsedFromHtml(string html)
{
if (string.IsNullOrEmpty(html)) return null;
// 1) Look for the explicit bids-used span: <p ...><span>628</span> Puntate utilizzate</p>
var match = System.Text.RegularExpressions.Regex.Match(html,
"class=\\\"bids-used\\\"[^>]*>[^<]*<span[^>]*>(?<n>[0-9]{1,7})</span>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Singleline);
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val1))
{
Console.WriteLine($"[StatsService] Puntate estratte (metodo 1 - bids-used class): {val1}");
return val1;
}
// 2) Look for numeric followed by 'Puntate utilizzate' or similar
match = System.Text.RegularExpressions.Regex.Match(html,
"(?<n>[0-9]{1,7})\\s*(?:Puntate utilizzate|Puntate usate|puntate utilizzate|puntate usate|puntate)\\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val2))
{
Console.WriteLine($"[StatsService] Puntate estratte (metodo 2 - pattern testo): {val2}");
return val2;
}
// 3) Fallbacks
match = System.Text.RegularExpressions.Regex.Match(html,
"(?<n>[0-9]+)\\s*(?:puntate|Puntate|puntate usate|puntate_usate|pt\\.?|pts)\\b",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val3))
{
Console.WriteLine($"[StatsService] Puntate estratte (metodo 3 - fallback): {val3}");
return val3;
}
Console.WriteLine($"[StatsService] Nessun pattern trovato per le puntate nell'HTML");
return null;
}
/// <summary>
/// Registra il completamento di un'asta (overload semplificato per compatibilità)
/// </summary>
public async Task RecordAuctionCompletedAsync(AuctionInfo auction, bool won)
{
if (auction.LastState != null)
{
await RecordAuctionCompletedAsync(auction, auction.LastState, won);
}
else
{
Console.WriteLine("[StatsService] Cannot record auction - LastState is null");
}
}
/// <summary>
/// Ottiene i limiti consigliati per un prodotto
/// </summary>
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productName)
{
if (_productStatsService == null) return null;
var productKey = ProductStatisticsService.GenerateProductKey(productName);
return await _productStatsService.GetRecommendedLimitsAsync(productKey);
}
/// <summary>
/// Ottiene tutte le statistiche prodotto
/// </summary>
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
{
if (_productStatsService == null) return new List<ProductStatisticsRecord>();
return await _productStatsService.GetAllProductStatisticsAsync();
}
// Metodi per query statistiche
public async Task<List<DailyStat>> GetDailyStatsAsync(int days = 30)
{
if (!IsAvailable)
{
return new List<DailyStat>();
}
var to = DateTime.UtcNow;
var from = to.AddDays(-days);
return await _db.GetDailyStatsAsync(from, to);
}
public async Task<TotalStats> GetTotalStatsAsync()
{
if (!IsAvailable)
{
return new TotalStats();
}
var stats = await GetDailyStatsAsync(365);
return new TotalStats
{
TotalBidsUsed = stats.Sum(s => s.BidsUsed),
TotalMoneySpent = stats.Sum(s => s.MoneySpent),
TotalAuctionsWon = stats.Sum(s => s.AuctionsWon),
TotalAuctionsLost = stats.Sum(s => s.AuctionsLost),
TotalSavings = stats.Sum(s => s.TotalSavings),
AverageLatency = stats.Any() ? stats.Average(s => s.AverageLatency ?? 0) : 0,
WinRate = stats.Sum(s => s.AuctionsWon + s.AuctionsLost) > 0
? (double)stats.Sum(s => s.AuctionsWon) / (stats.Sum(s => s.AuctionsWon) + stats.Sum(s => s.AuctionsLost)) * 100
: 0,
AverageBidsPerAuction = stats.Sum(s => s.AuctionsWon + s.AuctionsLost) > 0
? (double)stats.Sum(s => s.BidsUsed) / (stats.Sum(s => s.AuctionsWon) + stats.Sum(s => s.AuctionsLost))
: 0
};
}
public async Task<List<AuctionResultExtended>> GetRecentAuctionResultsAsync(int limit = 50)
{
if (!IsAvailable)
{
return new List<AuctionResultExtended>();
}
return await _db.GetRecentAuctionResultsAsync(limit);
}
public async Task<double> CalculateROIAsync()
{
if (!IsAvailable)
{
return 0;
}
var stats = await GetTotalStatsAsync();
if (stats.TotalMoneySpent <= 0)
return 0;
return (stats.TotalSavings / stats.TotalMoneySpent) * 100;
}
public async Task<ChartData> GetChartDataAsync(int days = 30)
{
if (!IsAvailable)
{
return new ChartData
{
Labels = new List<string>(),
MoneySpent = new List<double>(),
Savings = new List<double>()
};
}
var stats = await GetDailyStatsAsync(days);
var allDates = new List<DailyStat>();
var startDate = DateTime.UtcNow.AddDays(-days);
for (int i = 0; i < days; i++)
{
var date = startDate.AddDays(i).ToString("yyyy-MM-dd");
var existingStat = stats.FirstOrDefault(s => s.Date == date);
allDates.Add(existingStat ?? new DailyStat
{
Date = date,
BidsUsed = 0,
MoneySpent = 0,
AuctionsWon = 0,
AuctionsLost = 0,
TotalSavings = 0,
AverageLatency = null
});
}
return new ChartData
{
Labels = allDates.Select(s => DateTime.Parse(s.Date).ToString("dd/MM")).ToList(),
BidsUsed = allDates.Select(s => s.BidsUsed).ToList(),
MoneySpent = allDates.Select(s => s.MoneySpent).ToList(),
AuctionsWon = allDates.Select(s => s.AuctionsWon).ToList(),
AuctionsLost = allDates.Select(s => s.AuctionsLost).ToList(),
Savings = allDates.Select(s => s.TotalSavings).ToList()
};
}
}
// Classi esistenti per compatibilità
public class TotalStats
{
public int TotalBidsUsed { get; set; }
public double TotalMoneySpent { get; set; }
public int TotalAuctionsWon { get; set; }
public int TotalAuctionsLost { get; set; }
public double TotalSavings { get; set; }
public double AverageLatency { get; set; }
public double WinRate { get; set; }
public double AverageBidsPerAuction { get; set; }
}
public class ChartData
{
public List<string> Labels { get; set; } = new();
public List<int> BidsUsed { get; set; } = new();
public List<double> MoneySpent { get; set; } = new();
public List<int> AuctionsWon { get; set; } = new();
public List<int> AuctionsLost { get; set; } = new();
public List<double> Savings { get; set; } = new();
}
}