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.
464 lines
20 KiB
C#
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();
|
|
}
|
|
}
|