Compare commits

...

4 Commits

Author SHA1 Message Date
77eb9943d0 Gestione avanzata database e rimozione MaxClicks
Aggiunta sezione impostazioni per manutenzione database (auto-salvataggio, pulizia duplicati/incompleti, retention, ottimizzazione). Implementati metodi asincroni in DatabaseService per pulizia e statistiche. Pulizia automatica all’avvio secondo impostazioni. Rimossa la proprietà MaxClicks da modello, UI e logica. Migliorata la sicurezza thread-safe e la trasparenza nella gestione dati. Spostato il badge versione nelle info applicazione.
2026-01-24 01:30:49 +01:00
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
21a1d57cab Migliora badge stato aste: nuovi colori, icone, animazioni
Rivisti i metodi di calcolo e visualizzazione dello stato delle aste in Index.razor.cs, distinguendo tra stati di sistema e controllati dall’utente. Aggiunte nuove classi CSS e animazioni in modern-pages.css per badge più chiari, compatti e animati. Mantenuta compatibilità con classi Bootstrap legacy. Migliorata la leggibilità e l’usabilità della tabella aste.
2026-01-22 15:28:05 +01:00
2833cd0487 Aggiornamento live aste, azioni rapide e scroll infinito
- Aggiornamento automatico degli stati delle aste ogni 500ms, rimosso il bottone manuale "Aggiorna Prezzi"
- Aggiunti pulsanti per copiare il link e aprire l'asta in nuova scheda
- Possibilità di rimuovere aste dal monitor direttamente dalla lista
- Caricamento aste ottimizzato: scroll infinito senza duplicati tramite nuova API get_auction_updates.php
- Migliorato il parsing dei dati e la precisione del countdown usando il timestamp del server
- Refactoring vari per migliorare la reattività e l'esperienza utente
2026-01-22 11:43:59 +01:00
20 changed files with 31850 additions and 1032 deletions

View File

@@ -56,6 +56,10 @@ ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENV Kestrel__EnableHttps=false
# Database path - tutti i database SQLite e dati persistenti
# Può essere sovrascritto nel docker-compose per mappare un volume persistente
ENV DATA_PATH=/app/Data
# Autenticazione applicazione (OBBLIGATORIO)
ENV ADMIN_USERNAME=admin
ENV ADMIN_PASSWORD=

File diff suppressed because one or more lines are too long

View File

@@ -37,8 +37,14 @@ namespace AutoBidder.Models
public double MaxPrice { get; set; } = 0;
public int MinResets { get; set; } = 0; // Numero minimo reset prima di puntare
public int MaxResets { get; set; } = 0; // Numero massimo reset (0 = illimitati)
/// <summary>
/// [OBSOLETO] Numero massimo di puntate consentite - Non più utilizzato nell'UI
/// Mantenuto per retrocompatibilità con salvataggi JSON esistenti
/// </summary>
[Obsolete("MaxClicks non è più utilizzato. Usa invece la logica di limiti per prodotto.")]
[JsonPropertyName("MaxClicks")]
public int MaxClicks { get; set; } = 0; // Numero massimo di puntate consentite (0 = illimitato)
public int MaxClicks { get; set; } = 0;
// Stato asta
public bool IsActive { get; set; } = true;

View File

@@ -0,0 +1,120 @@
namespace AutoBidder.Models
{
/// <summary>
/// Record per le statistiche aggregate di un prodotto nel database
/// </summary>
public class ProductStatisticsRecord
{
public string ProductKey { get; set; } = string.Empty;
public string ProductName { get; set; } = string.Empty;
// Contatori
public int TotalAuctions { get; set; }
public int WonAuctions { get; set; }
public int LostAuctions { get; set; }
// Statistiche prezzo
public double AvgFinalPrice { get; set; }
public double? MinFinalPrice { get; set; }
public double? MaxFinalPrice { get; set; }
// Statistiche puntate
public double AvgBidsToWin { get; set; }
public int? MinBidsToWin { get; set; }
public int? MaxBidsToWin { get; set; }
// Statistiche reset
public double AvgResets { get; set; }
public int? MinResets { get; set; }
public int? MaxResets { get; set; }
// Limiti consigliati (calcolati dall'algoritmo)
public double? RecommendedMinPrice { get; set; }
public double? RecommendedMaxPrice { get; set; }
public int? RecommendedMinResets { get; set; }
public int? RecommendedMaxResets { get; set; }
public int? RecommendedMaxBids { get; set; }
// JSON con statistiche per fascia oraria
public string? HourlyStatsJson { get; set; }
// Metadata
public string? LastUpdated { get; set; }
/// <summary>
/// Calcola il win rate come percentuale
/// </summary>
public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0;
}
/// <summary>
/// Risultato asta esteso con tutti i campi per analytics
/// </summary>
public class AuctionResultExtended
{
public int Id { get; set; }
public string AuctionId { get; set; } = "";
public string AuctionName { get; set; } = "";
public double FinalPrice { get; set; }
public int BidsUsed { get; set; }
public bool Won { get; set; }
public string Timestamp { get; set; } = "";
public double? BuyNowPrice { get; set; }
public double? ShippingCost { get; set; }
public double? TotalCost { get; set; }
public double? Savings { get; set; }
// Campi estesi per analytics
public string? WinnerUsername { get; set; }
public int? ClosedAtHour { get; set; }
public string? ProductKey { get; set; }
public int? TotalResets { get; set; }
public int? WinnerBidsUsed { get; set; }
}
/// <summary>
/// Limiti consigliati per un'asta basati sulle statistiche storiche
/// </summary>
public class RecommendedLimits
{
public double MinPrice { get; set; }
public double MaxPrice { get; set; }
public int MinResets { get; set; }
public int MaxResets { get; set; }
public int MaxBids { get; set; }
/// <summary>
/// Confidence score (0-100) - quanto sono affidabili questi limiti
/// </summary>
public int ConfidenceScore { get; set; }
/// <summary>
/// Numero di aste usate per calcolare i limiti
/// </summary>
public int SampleSize { get; set; }
/// <summary>
/// Fascia oraria migliore per vincere (0-23)
/// </summary>
public int? BestHourToPlay { get; set; }
/// <summary>
/// Win rate medio per questo prodotto
/// </summary>
public double? AverageWinRate { get; set; }
}
/// <summary>
/// Statistiche per fascia oraria
/// </summary>
public class HourlyStats
{
public int Hour { get; set; }
public int TotalAuctions { get; set; }
public int WonAuctions { get; set; }
public double AvgFinalPrice { get; set; }
public double AvgBidsUsed { get; set; }
public double WinRate => TotalAuctions > 0 ? (double)WonAuctions / TotalAuctions * 100 : 0;
}
}

View File

@@ -4,6 +4,8 @@
@using AutoBidder.Services
@inject BidooBrowserService BrowserService
@inject ApplicationStateService AppState
@inject AuctionMonitor AuctionMonitor
@inject IJSRuntime JSRuntime
@implements IDisposable
<PageTitle>Esplora Aste - AutoBidder</PageTitle>
@@ -26,9 +28,9 @@
</button>
@if (auctions.Count > 0)
{
<button class="btn btn-outline-primary" @onclick="UpdateAuctionStates" disabled="@isUpdatingStates">
<i class="bi @(isUpdatingStates ? "bi-broadcast spin" : "bi-broadcast")"></i>
Aggiorna Prezzi
<button class="btn btn-outline-danger" @onclick="ClearAllAuctions" title="Rimuove tutte le aste caricate e ferma il polling">
<i class="bi bi-trash"></i>
Pulisci Tutto
</button>
}
</div>
@@ -81,6 +83,41 @@
</div>
</div>
<!-- ? NUOVO: Search Bar -->
<div class="card mb-4 shadow-sm">
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col-md-8">
<div class="input-group input-group-lg">
<span class="input-group-text bg-primary text-white border-0">
<i class="bi bi-search"></i>
</span>
<input type="text"
class="form-control form-control-lg border-0"
placeholder="Cerca per nome asta, prezzo, vincitore..."
@bind="searchQuery"
@bind:event="oninput"
@bind:after="OnSearchChanged" />
@if (!string.IsNullOrEmpty(searchQuery))
{
<button class="btn btn-outline-secondary border-0"
@onclick="ClearSearch"
title="Cancella ricerca">
<i class="bi bi-x-lg"></i>
</button>
}
</div>
</div>
<div class="col-md-4">
<div class="stats-mini">
<span class="text-muted">Risultati filtrati:</span>
<span class="fw-bold text-info ms-2">@filteredAuctions.Count</span>
</div>
</div>
</div>
</div>
</div>
<!-- Loading State -->
@if (isLoading)
{
@@ -101,6 +138,16 @@
</div>
</div>
}
else if (filteredAuctions.Count == 0 && !string.IsNullOrEmpty(searchQuery))
{
<div class="text-center py-5 animate-fade-in">
<i class="bi bi-search text-muted" style="font-size: 4rem;"></i>
<p class="text-muted mt-3">Nessuna asta trovata per "<strong>@searchQuery</strong>"</p>
<button class="btn btn-outline-secondary" @onclick="ClearSearch">
<i class="bi bi-x-circle me-2"></i>Cancella Ricerca
</button>
</div>
}
else if (auctions.Count == 0)
{
<div class="text-center py-5 animate-fade-in">
@@ -115,7 +162,7 @@
{
<!-- Auctions Grid -->
<div class="auction-grid animate-fade-in">
@foreach (var auction in auctions)
@foreach (var auction in filteredAuctions)
{
<div class="auction-card @(auction.IsMonitored ? "monitored" : "") @(auction.IsSold ? "sold" : "")">
<!-- Image -->
@@ -195,10 +242,22 @@
<!-- Actions -->
<div class="auction-actions">
<div class="d-flex gap-1 mb-1">
<button class="btn btn-outline-secondary btn-sm flex-grow-1"
@onclick="() => CopyAuctionLink(auction)"
title="Copia link">
<i class="bi bi-clipboard"></i>
</button>
<button class="btn btn-outline-secondary btn-sm flex-grow-1"
@onclick="() => OpenAuctionInNewTab(auction)"
title="Apri in nuova scheda">
<i class="bi bi-box-arrow-up-right"></i>
</button>
</div>
@if (auction.IsMonitored)
{
<button class="btn btn-success btn-sm w-100" disabled>
<i class="bi bi-check-lg me-1"></i>Monitorata
<button class="btn btn-warning btn-sm w-100" @onclick="() => RemoveFromMonitor(auction)">
<i class="bi bi-dash-lg me-1"></i>Togli dal Monitor
</button>
}
else
@@ -233,19 +292,23 @@
</div>
@code {
private List<BidooCategoryInfo> categories = new();
private List<BidooBrowserAuction> auctions = new();
private int selectedCategoryIndex = 0;
private int currentPage = 0;
private List<BidooCategoryInfo> categories = new();
private List<BidooBrowserAuction> auctions = new();
private List<BidooBrowserAuction> filteredAuctions = new();
private int selectedCategoryIndex = 0;
private int currentPage = 0;
private bool isLoading = false;
private bool isLoadingMore = false;
private bool isUpdatingStates = false;
private bool canLoadMore = true;
private string? errorMessage = null;
private bool isLoading = false;
private bool isLoadingMore = false;
private bool canLoadMore = true;
private string? errorMessage = null;
private System.Threading.Timer? stateUpdateTimer;
private CancellationTokenSource? cts;
// ? NUOVO: Ricerca
private string searchQuery = "";
private System.Threading.Timer? stateUpdateTimer;
private CancellationTokenSource? cts;
private bool isUpdatingInBackground = false;
protected override async Task OnInitializedAsync()
{
@@ -256,14 +319,14 @@
await LoadAuctions();
}
// Auto-update states every 5 seconds
// Auto-update states every 500ms for real-time price updates
stateUpdateTimer = new System.Threading.Timer(async _ =>
{
if (auctions.Count > 0 && !isUpdatingStates)
if (auctions.Count > 0 && !isUpdatingInBackground)
{
await UpdateAuctionStatesBackground();
}
}, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}, null, TimeSpan.FromMilliseconds(500), TimeSpan.FromMilliseconds(500));
}
private async Task LoadCategories()
@@ -313,6 +376,9 @@
{
await BrowserService.UpdateAuctionStatesAsync(auctions, cts.Token);
}
// ? NUOVO: Applica filtro ricerca
ApplySearchFilter();
}
catch (OperationCanceledException)
{
@@ -330,20 +396,62 @@
}
}
// ? NUOVO: Metodo per applicare il filtro di ricerca
private void ApplySearchFilter()
{
if (string.IsNullOrWhiteSpace(searchQuery))
{
filteredAuctions = auctions.ToList();
return;
}
var query = searchQuery.ToLowerInvariant().Trim();
filteredAuctions = auctions.Where(a =>
// Cerca nel nome
a.Name.ToLowerInvariant().Contains(query) ||
// Cerca nel prezzo corrente
a.CurrentPrice.ToString("F2").Contains(query) ||
// Cerca nel prezzo buy-now
(a.BuyNowPrice > 0 && a.BuyNowPrice.ToString("F2").Contains(query)) ||
// Cerca nel nome dell'ultimo puntatore
(!string.IsNullOrEmpty(a.LastBidder) && a.LastBidder.ToLowerInvariant().Contains(query)) ||
// Cerca nell'ID asta
a.AuctionId.Contains(query)
).ToList();
}
// ? NUOVO: Callback quando cambia la ricerca
private void OnSearchChanged()
{
ApplySearchFilter();
StateHasChanged();
}
// ? NUOVO: Pulisce la ricerca
private void ClearSearch()
{
searchQuery = "";
ApplySearchFilter();
StateHasChanged();
}
private async Task LoadMoreAuctions()
{
if (categories.Count == 0 || selectedCategoryIndex < 0)
if (categories.Count == 0 || selectedCategoryIndex < 0 || auctions.Count == 0)
return;
isLoadingMore = true;
currentPage++;
cts?.Cancel();
cts = new CancellationTokenSource();
try
{
var category = categories[selectedCategoryIndex];
var newAuctions = await BrowserService.GetAuctionsAsync(category, currentPage, cts.Token);
var existingIds = auctions.Select(a => a.AuctionId).ToList();
// Usa GetMoreAuctionsAsync che evita duplicati
var newAuctions = await BrowserService.GetMoreAuctionsAsync(category, existingIds, cts.Token);
if (newAuctions.Count == 0)
{
@@ -353,13 +461,17 @@
{
auctions.AddRange(newAuctions);
UpdateMonitoredStatus();
// Aggiorna stati delle nuove aste
await BrowserService.UpdateAuctionStatesAsync(newAuctions, cts.Token);
// ? NUOVO: Riapplica filtro dopo caricamento
ApplySearchFilter();
}
}
catch (Exception ex)
{
Console.WriteLine($"[Browser] Error loading more auctions: {ex.Message}");
currentPage--; // Rollback
}
finally
{
@@ -368,25 +480,11 @@
}
}
private async Task UpdateAuctionStates()
{
if (auctions.Count == 0) return;
isUpdatingStates = true;
try
{
await BrowserService.UpdateAuctionStatesAsync(auctions);
UpdateMonitoredStatus();
}
finally
{
isUpdatingStates = false;
StateHasChanged();
}
}
private async Task UpdateAuctionStatesBackground()
{
if (isUpdatingInBackground) return;
isUpdatingInBackground = true;
try
{
await BrowserService.UpdateAuctionStatesAsync(auctions);
@@ -397,6 +495,10 @@
{
// Ignore background errors
}
finally
{
isUpdatingInBackground = false;
}
}
private async Task RefreshAll()
@@ -408,6 +510,15 @@
await LoadAuctions();
}
private void ClearAllAuctions()
{
// Cancella le aste e ferma il timer
cts?.Cancel();
auctions.Clear();
filteredAuctions.Clear();
StateHasChanged();
}
private void UpdateMonitoredStatus()
{
var monitoredIds = AppState.Auctions.Select(a => a.AuctionId).ToHashSet();
@@ -421,26 +532,92 @@
{
if (browserAuction.IsMonitored) return;
// ?? Carica impostazioni di default
var settings = AutoBidder.Utilities.SettingsManager.Load();
var auctionInfo = new AuctionInfo
{
AuctionId = browserAuction.AuctionId,
Name = browserAuction.Name,
OriginalUrl = browserAuction.Url,
BuyNowPrice = (double)browserAuction.BuyNowPrice,
// ?? FIX: Applica valori dalle impostazioni
BidBeforeDeadlineMs = settings.DefaultBidBeforeDeadlineMs,
CheckAuctionOpenBeforeBid = settings.DefaultCheckAuctionOpenBeforeBid,
MinPrice = settings.DefaultMinPrice,
MaxPrice = settings.DefaultMaxPrice,
MinResets = settings.DefaultMinResets,
MaxResets = settings.DefaultMaxResets,
IsActive = true,
IsPaused = true, // Start paused
AddedAt = DateTime.UtcNow
};
AppState.AddAuction(auctionInfo);
// ?? FIX CRITICO: Registra l'asta nel monitor!
AuctionMonitor.AddAuction(auctionInfo);
browserAuction.IsMonitored = true;
// Save to disk
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
// ?? FIX CRITICO: Avvia il monitor se non è già attivo
if (!AppState.IsMonitoringActive)
{
AuctionMonitor.Start();
AppState.IsMonitoringActive = true;
Console.WriteLine($"[AuctionBrowser] Monitor auto-started for paused auction: {auctionInfo.Name}");
}
StateHasChanged();
}
private void RemoveFromMonitor(BidooBrowserAuction browserAuction)
{
if (!browserAuction.IsMonitored) return;
// Trova l'asta nel monitor
var auctionToRemove = AppState.Auctions.FirstOrDefault(a => a.AuctionId == browserAuction.AuctionId);
if (auctionToRemove != null)
{
AppState.RemoveAuction(auctionToRemove);
browserAuction.IsMonitored = false;
// Save to disk
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
}
StateHasChanged();
}
private async Task CopyAuctionLink(BidooBrowserAuction auction)
{
try
{
await JSRuntime.InvokeVoidAsync("navigator.clipboard.writeText", auction.Url);
}
catch (Exception ex)
{
Console.WriteLine($"[Browser] Error copying link: {ex.Message}");
}
}
private async Task OpenAuctionInNewTab(BidooBrowserAuction auction)
{
try
{
await JSRuntime.InvokeVoidAsync("window.open", auction.Url, "_blank");
}
catch (Exception ex)
{
Console.WriteLine($"[Browser] Error opening link: {ex.Message}");
}
}
public void Dispose()
{
stateUpdateTimer?.Dispose();

View File

@@ -1,33 +0,0 @@
@page "/freebids"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
<PageTitle>Puntate Gratuite - AutoBidder</PageTitle>
<div class="freebids-container animate-fade-in p-4">
<div class="d-flex align-items-center mb-4 animate-fade-in-down">
<i class="bi bi-gift-fill text-warning me-3" style="font-size: 2.5rem;"></i>
<h2 class="mb-0 fw-bold">Puntate Gratuite</h2>
</div>
<!-- Feature Under Development Notice - Conciso -->
<div class="alert alert-info border-0 shadow-sm animate-scale-in">
<div class="d-flex align-items-center">
<i class="bi bi-tools me-3" style="font-size: 2rem;"></i>
<div class="flex-grow-1">
<h5 class="mb-2"><strong>Funzionalità in Sviluppo</strong></h5>
<p class="mb-0">
Sistema di rilevamento, raccolta e utilizzo automatico delle puntate gratuite di Bidoo.
<br />
<small class="text-muted">Disponibile in una prossima versione con statistiche dettagliate.</small>
</p>
</div>
</div>
</div>
</div>
<style>
.freebids-container {
max-width: 1200px;
margin: 0 auto;
}
</style>

View File

@@ -87,7 +87,7 @@
<th class="col-prezzo"><i class="bi bi-currency-euro"></i> Prezzo</th>
<th class="col-timer"><i class="bi bi-clock"></i> Timer</th>
<th class="col-ultimo"><i class="bi bi-person"></i> Ultimo</th>
<th class="col-click"><i class="bi bi-hand-index"></i> Click</th>
<th class="col-click"><i class="bi bi-hand-index" style="font-size: 0.85rem;"></i> Puntate</th>
<th class="col-ping"><i class="bi bi-speedometer"></i> Ping</th>
<th class="col-azioni"><i class="bi bi-gear"></i> Azioni</th>
</tr>
@@ -95,7 +95,7 @@
<tbody>
@foreach (var auction in auctions)
{
<tr class="@GetRowClass(auction) table-row-enter transition-all"
<tr class="@GetRowClass(auction) @(selectedAuction == auction ? "selected-row" : "") table-row-enter transition-all"
@onclick="() => SelectAuction(auction)"
style="cursor: pointer;">
<td class="col-stato">
@@ -107,7 +107,7 @@
<td class="col-prezzo @GetPriceClass(auction)">@GetPriceDisplay(auction)</td>
<td class="col-timer">@GetTimerDisplay(auction)</td>
<td class="col-ultimo">@GetLastBidder(auction)</td>
<td class="col-click"><span class="badge bg-info">@GetMyBidsCount(auction)</span></td>
<td class="col-click bids-column fw-bold">@GetMyBidsCount(auction)</td>
<td class="col-ping">@GetPingDisplay(auction)</td>
<td class="col-azioni">
<div class="btn-group btn-group-sm" @onclick:stopPropagation="true">
@@ -230,18 +230,17 @@
<button class="btn btn-outline-secondary" @onclick="() => CopyToClipboard(selectedAuction.OriginalUrl)" title="Copia">
<i class="bi bi-clipboard"></i>
</button>
<button class="btn btn-outline-primary" @onclick="() => OpenAuctionInNewTab(selectedAuction.OriginalUrl)" title="Apri in nuova scheda">
<i class="bi bi-box-arrow-up-right"></i>
</button>
</div>
</div>
<div class="row">
<div class="col-md-6 info-group">
<div class="col-md-12 info-group">
<label><i class="bi bi-speedometer2"></i> Anticipo (ms):</label>
<input type="number" class="form-control" @bind="selectedAuction.BidBeforeDeadlineMs" @bind:after="SaveAuctions" />
</div>
<div class="col-md-6 info-group">
<label><i class="bi bi-hand-index-thumb"></i> Max Click:</label>
<input type="number" class="form-control" @bind="selectedAuction.MaxClicks" @bind:after="SaveAuctions" />
</div>
</div>
<div class="row">
@@ -272,6 +271,31 @@
Verifica asta aperta prima di puntare
</label>
</div>
<!-- Pulsante Applica Limiti Consigliati -->
<div class="mt-3 pt-3 border-top">
<button class="btn btn-outline-primary w-100"
@onclick="ApplyRecommendedLimitsToSelected"
disabled="@isLoadingRecommendations">
@if (isLoadingRecommendations)
{
<span class="spinner-border spinner-border-sm me-2"></span>
<span>Caricamento...</span>
}
else
{
<i class="bi bi-magic me-2"></i>
<span>Applica Limiti Consigliati</span>
}
</button>
@if (!string.IsNullOrEmpty(recommendationMessage))
{
<div class="alert @(recommendationSuccess ? "alert-success" : "alert-warning") mt-2 mb-0 py-2 small">
<i class="bi @(recommendationSuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
@recommendationMessage
</div>
}
</div>
</div>
</div>
@@ -368,7 +392,10 @@
<!-- TAB STORIA PUNTATE -->
<div class="tab-pane fade" id="content-history" role="tabpanel">
<div class="tab-panel-content">
@if (selectedAuction.RecentBids != null && selectedAuction.RecentBids.Any())
@{
var recentBidsList = GetRecentBidsSafe(selectedAuction);
}
@if (recentBidsList.Any())
{
<div class="table-responsive">
<table class="table table-sm table-striped">
@@ -381,7 +408,7 @@
</tr>
</thead>
<tbody>
@foreach (var bid in selectedAuction.RecentBids.Take(50))
@foreach (var bid in recentBidsList.Take(50))
{
<tr class="@(bid.IsMyBid ? "table-success" : "")">
<td>
@@ -412,17 +439,20 @@
<!-- TAB PUNTATORI -->
<div class="tab-pane fade" id="content-bidders" role="tabpanel">
<div class="tab-panel-content">
@if (selectedAuction.RecentBids != null && selectedAuction.RecentBids.Any())
@{
// Crea una copia thread-safe per evitare modifiche durante l'enumerazione
var recentBidsCopy = GetRecentBidsSafe(selectedAuction);
}
@if (recentBidsCopy.Any())
{
// Crea una copia locale per evitare modifiche durante l'enumerazione
var recentBidsCopy = selectedAuction.RecentBids.ToList();
// Calcola statistiche puntatori
var bidderStats = recentBidsCopy
.GroupBy(b => b.Username)
.Select(g => new { Username = g.Key, Count = g.Count(), IsMe = g.First().IsMyBid })
.OrderByDescending(s => s.Count)
.ToList();
var totalBids = recentBidsCopy.Count;
<div class="table-responsive">
<table class="table table-sm table-striped">
@@ -438,9 +468,9 @@
@for (int i = 0; i < bidderStats.Count; i++)
{
var bidder = bidderStats[i];
var percentage = (bidder.Count * 100.0 / recentBidsCopy.Count);
var percentage = (bidder.Count * 100.0 / totalBids);
<tr class="@(bidder.IsMe ? "table-success" : "")">
<td><span class="badge bg-primary">#{i + 1}</span></td>
<td><span class="badge bg-primary">#@(i + 1)</span></td>
<td>
@bidder.Username
@if (bidder.IsMe)
@@ -544,8 +574,3 @@
</div>
</div>
}
<!-- Versione in basso a destra -->
<div class="version-badge">
<i class="bi bi-box-seam"></i> v1.0.0
</div>

View File

@@ -13,6 +13,7 @@ namespace AutoBidder.Pages
{
[Inject] private ApplicationStateService AppState { get; set; } = default!;
[Inject] private HtmlCacheService HtmlCache { get; set; } = default!;
[Inject] private StatsService StatsService { get; set; } = default!;
private List<AuctionInfo> auctions => AppState.Auctions.ToList();
private AuctionInfo? selectedAuction
@@ -41,6 +42,11 @@ namespace AutoBidder.Pages
private double sessionShopCredit;
private int sessionAuctionsWon;
// Recommended limits
private bool isLoadingRecommendations = false;
private string? recommendationMessage = null;
private bool recommendationSuccess = false;
protected override void OnInitialized()
{
// Sottoscrivi agli eventi del servizio di stato (ASYNC)
@@ -379,7 +385,6 @@ namespace AutoBidder.Pages
CheckAuctionOpenBeforeBid = settings.DefaultCheckAuctionOpenBeforeBid,
MinPrice = settings.DefaultMinPrice,
MaxPrice = settings.DefaultMaxPrice,
MaxClicks = settings.DefaultMaxClicks,
MinResets = settings.DefaultMinResets,
MaxResets = settings.DefaultMaxResets,
IsActive = isActive,
@@ -631,6 +636,19 @@ namespace AutoBidder.Pages
}
}
private async Task OpenAuctionInNewTab(string url)
{
try
{
await JSRuntime.InvokeVoidAsync("window.open", url, "_blank");
AddLog("Asta aperta in nuova scheda");
}
catch (Exception ex)
{
AddLog($"Errore apertura: {ex.Message}");
}
}
// Helper methods per stili e classi
private string GetRowClass(AuctionInfo auction)
{
@@ -639,31 +657,98 @@ namespace AutoBidder.Pages
private string GetStatusBadgeClass(AuctionInfo auction)
{
if (!auction.IsActive) return "bg-secondary";
if (auction.IsPaused) return "bg-warning text-dark";
return "bg-success";
// Prima controlla lo stato real-time dell'asta (da LastState)
if (auction.LastState != null)
{
return auction.LastState.Status switch
{
AuctionStatus.EndedWon => "status-won",
AuctionStatus.EndedLost => "status-lost",
AuctionStatus.Closed => "status-closed",
AuctionStatus.Paused => "status-system-paused",
AuctionStatus.Pending => "status-pending",
AuctionStatus.Scheduled => "status-scheduled",
AuctionStatus.NotStarted => "status-scheduled",
_ => GetUserControlStatusClass(auction)
};
}
return GetUserControlStatusClass(auction);
}
private string GetUserControlStatusClass(AuctionInfo auction)
{
// Stati controllati dall'utente
if (!auction.IsActive) return "status-stopped";
if (auction.IsPaused) return "status-paused";
if (auction.IsAttackInProgress) return "status-attacking";
return "status-active";
}
private string GetStatusText(AuctionInfo auction)
{
if (!auction.IsActive) return "Fermo";
// Prima controlla lo stato real-time dell'asta
if (auction.LastState != null)
{
switch (auction.LastState.Status)
{
case AuctionStatus.EndedWon:
return "Vinta!";
case AuctionStatus.EndedLost:
return "Persa";
case AuctionStatus.Closed:
return "Chiusa";
case AuctionStatus.Paused:
return "Sospesa";
case AuctionStatus.Pending:
return "In Attesa";
case AuctionStatus.Scheduled:
case AuctionStatus.NotStarted:
return "Programmata";
}
}
// Stati controllati dall'utente
if (!auction.IsActive) return "Fermata";
if (auction.IsPaused) return "Pausa";
return "Attivo";
if (auction.IsAttackInProgress) return "Puntando";
return "Attiva";
}
private string GetStatusIcon(AuctionInfo auction)
{
// Usa icone Bootstrap Icons invece di emoji
// Prima controlla lo stato real-time dell'asta
if (auction.LastState != null)
{
switch (auction.LastState.Status)
{
case AuctionStatus.EndedWon:
return "<i class='bi bi-trophy-fill'></i>";
case AuctionStatus.EndedLost:
return "<i class='bi bi-x-circle-fill'></i>";
case AuctionStatus.Closed:
return "<i class='bi bi-lock-fill'></i>";
case AuctionStatus.Paused:
return "<i class='bi bi-moon-fill'></i>";
case AuctionStatus.Pending:
return "<i class='bi bi-hourglass-split'></i>";
case AuctionStatus.Scheduled:
case AuctionStatus.NotStarted:
return "<i class='bi bi-calendar-event'></i>";
}
}
// Stati controllati dall'utente
if (!auction.IsActive) return "<i class='bi bi-stop-circle'></i>";
if (auction.IsPaused) return "<i class='bi bi-pause-circle'></i>";
if (auction.IsAttackInProgress) return "<i class='bi bi-lightning-charge-fill'></i>";
return "<i class='bi bi-play-circle-fill'></i>";
}
private string GetStatusAnimationClass(AuctionInfo auction)
{
if (!auction.IsActive) return "";
if (auction.IsPaused) return "status-paused";
return "status-active";
// Animazioni disabilitate - i colori sono sufficienti per identificare lo stato
return "";
}
private string GetPriceDisplay(AuctionInfo? auction)
@@ -890,6 +975,29 @@ namespace AutoBidder.Pages
return auction.AuctionLog.TakeLast(50);
}
/// <summary>
/// Ottiene una copia thread-safe della lista RecentBids
/// </summary>
private List<BidHistoryEntry> GetRecentBidsSafe(AuctionInfo? auction)
{
if (auction?.RecentBids == null)
return new List<BidHistoryEntry>();
try
{
// Lock per evitare modifiche durante la copia
lock (auction.RecentBids)
{
return auction.RecentBids.ToList();
}
}
catch
{
// Fallback in caso di errore
return new List<BidHistoryEntry>();
}
}
private string GetLogEntryClass(LogEntry logEntry)
{
try
@@ -1005,5 +1113,65 @@ namespace AutoBidder.Pages
await RemoveSelectedAuctionWithConfirm();
}
}
private async Task ApplyRecommendedLimitsToSelected()
{
if (selectedAuction == null) return;
isLoadingRecommendations = true;
recommendationMessage = null;
StateHasChanged();
try
{
var limits = await StatsService.GetRecommendedLimitsAsync(selectedAuction.Name);
if (limits == null || limits.SampleSize == 0)
{
recommendationMessage = "Nessun dato storico disponibile per questo prodotto. Completa alcune aste per generare raccomandazioni.";
recommendationSuccess = false;
}
else if (limits.ConfidenceScore < 30)
{
// Applica comunque ma con avviso
selectedAuction.MinPrice = limits.MinPrice;
selectedAuction.MaxPrice = limits.MaxPrice;
selectedAuction.MinResets = limits.MinResets;
selectedAuction.MaxResets = limits.MaxResets;
SaveAuctions();
recommendationMessage = $"Limiti applicati con bassa confidenza ({limits.ConfidenceScore}%) - basati su {limits.SampleSize} aste";
recommendationSuccess = true;
}
else
{
// Applica limiti con buona confidenza
selectedAuction.MinPrice = limits.MinPrice;
selectedAuction.MaxPrice = limits.MaxPrice;
selectedAuction.MinResets = limits.MinResets;
selectedAuction.MaxResets = limits.MaxResets;
SaveAuctions();
var hourInfo = limits.BestHourToPlay.HasValue
? $" | Ora migliore: {limits.BestHourToPlay}:00"
: "";
recommendationMessage = $"? Limiti applicati (confidenza {limits.ConfidenceScore}%, {limits.SampleSize} aste){hourInfo}";
recommendationSuccess = true;
}
}
catch (Exception ex)
{
recommendationMessage = $"Errore: {ex.Message}";
recommendationSuccess = false;
}
finally
{
isLoadingRecommendations = false;
StateHasChanged();
}
}
}
}

View File

@@ -12,7 +12,7 @@
<i class="bi bi-gear-fill text-primary" style="font-size: 2rem;"></i>
<div>
<h2 class="mb-0 fw-bold">Impostazioni</h2>
<small class="text-muted">Configura sessione, comportamento aste, limiti e database statistiche.</small>
<small class="text-muted">Configura sessione, comportamento aste e limiti.</small>
</div>
</div>
@@ -209,73 +209,182 @@
</div>
</div>
<!-- CONFIGURAZIONE DATABASE -->
<!-- DATABASE MANAGEMENT -->
<div class="accordion-item">
<h2 class="accordion-header" id="heading-db">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-db" aria-expanded="false" aria-controls="collapse-db">
<i class="bi bi-database-fill me-2"></i> Configurazione Database
<h2 class="accordion-header" id="heading-database">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-database" aria-expanded="false" aria-controls="collapse-database">
<i class="bi bi-database-fill me-2"></i> Gestione Database
</button>
</h2>
<div id="collapse-db" class="accordion-collapse collapse" aria-labelledby="heading-db" data-bs-parent="#settingsAccordion">
<div id="collapse-database" class="accordion-collapse collapse" aria-labelledby="heading-database" data-bs-parent="#settingsAccordion">
<div class="accordion-body">
<div class="alert alert-info border-0 shadow-sm">
<i class="bi bi-info-circle-fill me-2"></i>
PostgreSQL per statistiche avanzate; SQLite come fallback.
</div>
<div class="form-check form-switch mb-3">
<input type="checkbox" class="form-check-input" id="usePostgres" @bind="settings.UsePostgreSQL" />
<label class="form-check-label" for="usePostgres">Usa PostgreSQL per statistiche avanzate</label>
</div>
@if (settings.UsePostgreSQL)
{
<div class="mb-3">
<label class="form-label fw-bold"><i class="bi bi-link-45deg"></i> Connection string</label>
<input type="text" class="form-control font-monospace" @bind="settings.PostgresConnectionString" />
</div>
<div class="row g-2 mb-3">
<div class="col-12 col-md-6">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="autoCreateSchema" @bind="settings.AutoCreateDatabaseSchema" />
<label class="form-check-label" for="autoCreateSchema">Auto-crea schema se mancante</label>
<!-- Impostazioni Auto-Salvataggio -->
<h6 class="fw-bold mb-3"><i class="bi bi-floppy"></i> Salvataggio Automatico</h6>
<div class="row g-3 mb-4">
<div class="col-12">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="dbAutoSave" @bind="settings.DatabaseAutoSaveEnabled" />
<label class="form-check-label" for="dbAutoSave">
<strong>Salva aste completate automaticamente</strong>
<div class="form-text">Salva statistiche nel database quando un'asta termina (consigliato)</div>
</label>
</div>
</div>
<div class="col-12 col-md-6">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="fallbackSqlite" @bind="settings.FallbackToSQLite" />
<label class="form-check-label" for="fallbackSqlite">Fallback a SQLite se non disponibile</label>
<label class="form-label fw-bold"><i class="bi bi-calendar-range"></i> Durata conservazione (giorni)</label>
<input type="number" class="form-control" @bind="settings.DatabaseMaxRetentionDays" min="0" />
<div class="form-text">0 = mantieni tutto | 180 = 6 mesi (consigliato)</div>
</div>
</div>
<!-- Pulizia Automatica -->
<h6 class="fw-bold mb-3"><i class="bi bi-brush"></i> Pulizia Automatica all'Avvio</h6>
<div class="row g-3 mb-4">
<div class="col-12">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="dbAutoCleanDup" @bind="settings.DatabaseAutoCleanupDuplicates" />
<label class="form-check-label" for="dbAutoCleanDup">
<strong>Rimuovi duplicati automaticamente</strong>
<div class="form-text">Elimina record duplicati all'avvio (consigliato)</div>
</label>
</div>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="dbAutoCleanInc" @bind="settings.DatabaseAutoCleanupIncomplete" />
<label class="form-check-label" for="dbAutoCleanInc">
<strong>Rimuovi dati incompleti automaticamente</strong>
<div class="form-text">Elimina record con dati invalidi (prezzi negativi, ecc.)</div>
</label>
</div>
</div>
</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<button class="btn btn-primary" @onclick="TestDatabaseConnection" disabled="@isTestingConnection">
@if (isTestingConnection)
<!-- Statistiche e Manutenzione Manuale -->
<h6 class="fw-bold mb-3"><i class="bi bi-tools"></i> Manutenzione Manuale</h6>
<div class="alert alert-info border-0 shadow-sm mb-3">
<div class="row g-2">
<div class="col-6 col-md-3">
<strong>Duplicati:</strong>
<div class="fs-4 fw-bold text-warning">@dbDuplicatesCount</div>
</div>
<div class="col-6 col-md-3">
<strong>Incompleti:</strong>
<div class="fs-4 fw-bold text-danger">@dbIncompleteCount</div>
</div>
<div class="col-12 col-md-6 text-md-end">
<button class="btn btn-sm btn-outline-primary" @onclick="RefreshDbStats" disabled="@isLoadingDbStats">
@if (isLoadingDbStats)
{
<span class="spinner-border spinner-border-sm me-2"></span>
<span>Test...</span>
<span class="spinner-border spinner-border-sm me-1"></span>
}
else
{
<i class="bi bi-wifi"></i>
<span>Test connessione</span>
<i class="bi bi-arrow-clockwise me-1"></i>
}
Aggiorna
</button>
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(dbTestResult))
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-warning text-dark" @onclick="CleanupDuplicates" disabled="@(isCleaningDb || dbDuplicatesCount == 0)">
<i class="bi bi-trash"></i> Rimuovi Duplicati (@dbDuplicatesCount)
</button>
<button class="btn btn-danger" @onclick="CleanupIncomplete" disabled="@(isCleaningDb || dbIncompleteCount == 0)">
<i class="bi bi-trash"></i> Rimuovi Incompleti (@dbIncompleteCount)
</button>
<button class="btn btn-primary" @onclick="CleanupDatabase" disabled="@isCleaningDb">
<i class="bi bi-stars"></i> Pulizia Completa
</button>
<button class="btn btn-info text-white" @onclick="OptimizeDatabase" disabled="@isCleaningDb">
<i class="bi bi-lightning-charge"></i> Ottimizza (VACUUM)
</button>
</div>
@if (!string.IsNullOrEmpty(dbCleanupMessage))
{
<span class="@(dbTestSuccess ? "text-success" : "text-danger")">
<i class="bi bi-@(dbTestSuccess ? "check-circle-fill" : "x-circle-fill") me-1"></i>
@dbTestResult
</span>
}
<div class="alert @(dbCleanupSuccess ? "alert-success" : "alert-warning") border-0 shadow-sm mt-3">
<i class="bi @(dbCleanupSuccess ? "bi-check-circle-fill" : "bi-exclamation-triangle-fill") me-2"></i>
@dbCleanupMessage
</div>
}
<div class="mt-3">
<button class="btn btn-secondary text-white" @onclick="SaveSettings"><i class="bi bi-check-lg"></i> Salva</button>
<div class="mt-4">
<button class="btn btn-success" @onclick="SaveSettings"><i class="bi bi-check-lg"></i> Salva Impostazioni</button>
</div>
</div>
</div>
</div>
<!-- INFORMAZIONI APPLICAZIONE -->
<div class="accordion-item">
<h2 class="accordion-header" id="heading-info">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-info" aria-expanded="false" aria-controls="collapse-info">
<i class="bi bi-info-circle-fill me-2"></i> Informazioni Applicazione
</button>
</h2>
<div id="collapse-info" class="accordion-collapse collapse" aria-labelledby="heading-info" data-bs-parent="#settingsAccordion">
<div class="accordion-body">
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="card bg-light border-0">
<div class="card-body">
<h6 class="text-muted mb-2">Versione</h6>
<div class="d-flex align-items-center">
<i class="bi bi-box-seam text-primary me-2" style="font-size: 1.5rem;"></i>
<span class="fs-4 fw-bold">v1.0.0</span>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card bg-light border-0">
<div class="card-body">
<h6 class="text-muted mb-2">Ambiente</h6>
<div class="d-flex align-items-center">
<i class="bi bi-code-slash text-success me-2" style="font-size: 1.5rem;"></i>
<span class="fs-4 fw-bold">.NET 8</span>
</div>
</div>
</div>
</div>
<div class="col-12">
<div class="card bg-light border-0">
<div class="card-body">
<h6 class="text-muted mb-2">Informazioni Sistema</h6>
<div class="row g-2">
<div class="col-12 col-md-6">
<small class="text-muted">Database:</small>
<div class="fw-bold">
@if (DbService.IsAvailable)
{
<span class="text-success"><i class="bi bi-check-circle-fill"></i> Operativo</span>
}
else
{
<span class="text-danger"><i class="bi bi-x-circle-fill"></i> Non disponibile</span>
}
</div>
</div>
<div class="col-12 col-md-6">
<small class="text-muted">Sessione:</small>
<div class="fw-bold">
@if (!string.IsNullOrEmpty(currentUsername))
{
<span class="text-success"><i class="bi bi-check-circle-fill"></i> @currentUsername</span>
}
else
{
<span class="text-warning"><i class="bi bi-exclamation-circle-fill"></i> Non connesso</span>
}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -300,21 +409,17 @@
</style>
@code {
private string startupLoadMode = "Stopped";
private string startupLoadMode = "Stopped";
private string? currentUsername;
private int remainingBids;
private string cookieInput = "";
private string usernameInput = "";
private string? connectionError;
private bool isConnecting;
private string? currentUsername;
private int remainingBids;
private string cookieInput = "";
private string usernameInput = "";
private string? connectionError;
private bool isConnecting;
private bool isTestingConnection;
private string? dbTestResult;
private bool dbTestSuccess;
private AutoBidder.Utilities.AppSettings settings = new();
private System.Threading.Timer? updateTimer;
private AutoBidder.Utilities.AppSettings settings = new();
private System.Threading.Timer? updateTimer;
protected override void OnInitialized()
{
@@ -492,55 +597,6 @@
settings.DefaultNewAuctionState = state;
}
private async Task TestDatabaseConnection()
{
isTestingConnection = true;
dbTestResult = null;
dbTestSuccess = false;
StateHasChanged();
try
{
var connString = settings.PostgresConnectionString;
if (string.IsNullOrWhiteSpace(connString))
{
dbTestResult = "Connection string vuota";
dbTestSuccess = false;
return;
}
await using var conn = new Npgsql.NpgsqlConnection(connString);
await conn.OpenAsync();
await using var cmd = new Npgsql.NpgsqlCommand("SELECT version()", conn);
var version = await cmd.ExecuteScalarAsync();
var versionStr = version?.ToString() ?? "";
var versionNumber = versionStr.Contains("PostgreSQL") ? versionStr.Split(' ')[1] : "Unknown";
dbTestResult = $"Connessione OK (PostgreSQL {versionNumber})";
dbTestSuccess = true;
await conn.CloseAsync();
}
catch (Npgsql.NpgsqlException ex)
{
dbTestResult = $"Errore PostgreSQL: {ex.Message}";
dbTestSuccess = false;
}
catch (Exception ex)
{
dbTestResult = $"Errore: {ex.Message}";
dbTestSuccess = false;
}
finally
{
isTestingConnection = false;
StateHasChanged();
}
}
private string GetRemainingBidsClass()
{
if (remainingBids < 50) return "bg-danger";
@@ -548,6 +604,159 @@
return "bg-success";
}
// ???????????????????????????????????????????????????????????????
// DATABASE MANAGEMENT
// ???????????????????????????????????????????????????????????????
[Inject] private DatabaseService DbService { get; set; } = default!;
private int dbDuplicatesCount = 0;
private int dbIncompleteCount = 0;
private bool isLoadingDbStats = false;
private bool isCleaningDb = false;
private string? dbCleanupMessage = null;
private bool dbCleanupSuccess = false;
private async Task RefreshDbStats()
{
if (!DbService.IsAvailable) return;
isLoadingDbStats = true;
dbCleanupMessage = null;
StateHasChanged();
try
{
dbDuplicatesCount = await DbService.CountDuplicateAuctionResultsAsync();
dbIncompleteCount = await DbService.CountIncompleteAuctionResultsAsync();
}
catch (Exception ex)
{
dbCleanupMessage = $"Errore: {ex.Message}";
dbCleanupSuccess = false;
}
finally
{
isLoadingDbStats = false;
StateHasChanged();
}
}
private async Task CleanupDuplicates()
{
if (!DbService.IsAvailable) return;
isCleaningDb = true;
dbCleanupMessage = null;
StateHasChanged();
try
{
var removed = await DbService.RemoveDuplicateAuctionResultsAsync();
dbCleanupMessage = $"? Rimossi {removed} record duplicati";
dbCleanupSuccess = true;
await RefreshDbStats();
}
catch (Exception ex)
{
dbCleanupMessage = $"Errore: {ex.Message}";
dbCleanupSuccess = false;
}
finally
{
isCleaningDb = false;
StateHasChanged();
}
}
private async Task CleanupIncomplete()
{
if (!DbService.IsAvailable) return;
isCleaningDb = true;
dbCleanupMessage = null;
StateHasChanged();
try
{
var removed = await DbService.RemoveIncompleteAuctionResultsAsync();
dbCleanupMessage = $"? Rimossi {removed} record incompleti";
dbCleanupSuccess = true;
await RefreshDbStats();
}
catch (Exception ex)
{
dbCleanupMessage = $"Errore: {ex.Message}";
dbCleanupSuccess = false;
}
finally
{
isCleaningDb = false;
StateHasChanged();
}
}
private async Task CleanupDatabase()
{
if (!DbService.IsAvailable) return;
isCleaningDb = true;
dbCleanupMessage = null;
StateHasChanged();
try
{
var message = await DbService.CleanupDatabaseAsync();
dbCleanupMessage = $"? {message}";
dbCleanupSuccess = true;
await RefreshDbStats();
}
catch (Exception ex)
{
dbCleanupMessage = $"Errore: {ex.Message}";
dbCleanupSuccess = false;
}
finally
{
isCleaningDb = false;
StateHasChanged();
}
}
private async Task OptimizeDatabase()
{
if (!DbService.IsAvailable) return;
isCleaningDb = true;
dbCleanupMessage = null;
StateHasChanged();
try
{
await DbService.OptimizeDatabaseAsync();
dbCleanupMessage = "? Database ottimizzato (VACUUM eseguito)";
dbCleanupSuccess = true;
}
catch (Exception ex)
{
dbCleanupMessage = $"Errore: {ex.Message}";
dbCleanupSuccess = false;
}
finally
{
isCleaningDb = false;
StateHasChanged();
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && DbService.IsAvailable)
{
await RefreshDbStats();
}
}
public void Dispose()
{
updateTimer?.Dispose();

View File

@@ -1,17 +1,21 @@
@page "/statistics"
@attribute [Microsoft.AspNetCore.Authorization.Authorize]
@using AutoBidder.Models
@using AutoBidder.Services
@inject StatsService StatsService
@inject IJSRuntime JSRuntime
@inject DatabaseService DatabaseService
<PageTitle>Statistiche - AutoBidder</PageTitle>
<div class="statistics-container animate-fade-in p-4">
<div class="d-flex align-items-center justify-content-between mb-4 animate-fade-in-down">
<div class="statistics-container p-4">
<div class="d-flex align-items-center justify-content-between mb-4">
<div class="d-flex align-items-center">
<i class="bi bi-bar-chart-fill text-primary me-3" style="font-size: 2.5rem;"></i>
<h2 class="mb-0 fw-bold">Statistiche</h2>
</div>
<button class="btn btn-primary hover-lift" @onclick="RefreshStats" disabled="@isLoading">
@if (StatsService.IsAvailable)
{
<button class="btn btn-primary" @onclick="RefreshStats" disabled="@isLoading">
@if (isLoading)
{
<span class="spinner-border spinner-border-sm me-2"></span>
@@ -22,217 +26,211 @@
}
Aggiorna
</button>
}
</div>
@if (errorMessage != null)
@if (!StatsService.IsAvailable)
{
<div class="alert alert-danger border-0 shadow-sm animate-shake mb-4">
<i class="bi bi-exclamation-triangle-fill me-2"></i> @errorMessage
<div class="alert alert-warning shadow-sm">
<div class="d-flex align-items-start">
<i class="bi bi-database-x me-3" style="font-size: 2rem;"></i>
<div>
<h5 class="mb-2 fw-bold">Statistiche non disponibili</h5>
<p class="mb-0">Il database per le statistiche non è stato configurato o non è accessibile.</p>
</div>
</div>
</div>
}
@if (isLoading)
else if (isLoading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;"></div>
<p class="mt-3 text-muted">Caricamento statistiche...</p>
</div>
}
else if (totalStats != null)
else
{
<!-- CARD TOTALI -->
<div class="row g-3 mb-4 animate-fade-in-up">
<div class="col-md-3">
<div class="stats-card card border-0 shadow-sm hover-lift">
<div class="card-body text-center">
<i class="bi bi-hand-index-fill text-primary" style="font-size: 2.5rem;"></i>
<h3 class="mt-3 mb-1 fw-bold">@totalStats.TotalBidsUsed</h3>
<p class="text-muted mb-0">Puntate Usate</p>
<small class="text-muted">€@((totalStats.TotalBidsUsed * 0.20).ToString("F2"))</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card card border-0 shadow-sm hover-lift">
<div class="card-body text-center">
<i class="bi bi-trophy-fill text-warning" style="font-size: 2.5rem;"></i>
<h3 class="mt-3 mb-1 fw-bold">@totalStats.TotalAuctionsWon</h3>
<p class="text-muted mb-0">Aste Vinte</p>
<small class="text-muted">Win Rate: @totalStats.WinRate.ToString("F1")%</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card card border-0 shadow-sm hover-lift">
<div class="card-body text-center">
<i class="bi bi-piggy-bank-fill text-success" style="font-size: 2.5rem;"></i>
<h3 class="mt-3 mb-1 fw-bold">€@totalStats.TotalSavings.ToString("F2")</h3>
<p class="text-muted mb-0">Risparmio Totale</p>
<small class="text-muted">ROI: @roi.ToString("F1")%</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card card border-0 shadow-sm hover-lift">
<div class="card-body text-center">
<i class="bi bi-speedometer text-info" style="font-size: 2.5rem;"></i>
<h3 class="mt-3 mb-1 fw-bold">@totalStats.AverageBidsPerAuction.ToString("F1")</h3>
<p class="text-muted mb-0">Puntate/Asta Media</p>
<small class="text-muted">Latency: @totalStats.AverageLatency.ToString("F0")ms</small>
</div>
</div>
</div>
</div>
<!-- GRAFICI -->
<div class="row g-4 mb-4 animate-fade-in-up delay-100">
<div class="col-lg-8">
<div class="card border-0 shadow-sm">
<div class="row g-4">
<!-- COLONNA SINISTRA: Aste Recenti -->
<div class="col-lg-7">
<div class="card shadow-sm h-100">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-graph-up me-2"></i>Spesa Giornaliera (Ultimi 30 Giorni)</h5>
</div>
<div class="card-body">
<canvas id="moneyChart" height="80"></canvas>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card border-0 shadow-sm">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="bi bi-pie-chart me-2"></i>Aste Vinte vs Perse</h5>
</div>
<div class="card-body">
<canvas id="winsChart"></canvas>
</div>
</div>
</div>
</div>
<!-- ASTE RECENTI -->
@if (recentResults != null && recentResults.Any())
{
<div class="card border-0 shadow-sm animate-fade-in-up delay-200">
<div class="card-header bg-info text-white">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>Aste Recenti</h5>
<h5 class="mb-0">
<i class="bi bi-clock-history me-2"></i>
Aste Terminate Recenti
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead>
@if (recentAuctions == null || !recentAuctions.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
<p class="mt-3">Nessuna asta terminata salvata</p>
</div>
}
else
{
<div class="table-responsive" style="max-height: 600px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Asta</th>
<th>Prezzo Finale</th>
<th>Puntate</th>
<th>Risultato</th>
<th>Risparmio</th>
<th>Nome</th>
<th class="text-end">Prezzo</th>
<th class="text-end">Puntate</th>
<th>Vincitore</th>
<th class="text-center">Stato</th>
<th>Data</th>
</tr>
</thead>
<tbody>
@foreach (var result in recentResults.Take(10))
@foreach (var auction in recentAuctions)
{
<tr class="@(result.Won ? "table-success" : "")">
<td class="fw-semibold">@result.AuctionName</td>
<td>€@result.FinalPrice.ToString("F2")</td>
<td><span class="badge bg-info">@result.BidsUsed</span></td>
<td>
@if (result.Won)
<tr class="@(auction.Won ? "table-success-subtle" : "")">
<td><small>@auction.AuctionName</small></td>
<td class="text-end fw-bold">€@auction.FinalPrice.ToString("F2")</td>
<td class="text-end">@auction.BidsUsed</td>
<td><small class="text-muted">@(auction.WinnerUsername ?? "-")</small></td>
<td class="text-center">
@if (auction.Won)
{
<span class="badge bg-success"><i class="bi bi-trophy-fill"></i> Vinta</span>
<span class="badge bg-success">? Vinta</span>
}
else
{
<span class="badge bg-secondary"><i class="bi bi-x-circle"></i> Persa</span>
<span class="badge bg-secondary">? Persa</span>
}
</td>
<td class="@(result.Savings > 0 ? "text-success fw-bold" : "text-danger")">
@if (result.Savings.HasValue)
{
@((result.Savings.Value > 0 ? "+" : "") + "€" + result.Savings.Value.ToString("F2"))
}
else
{
<span class="text-muted">-</span>
}
</td>
<td class="text-muted small">@DateTime.Parse(result.Timestamp).ToString("dd/MM HH:mm")</td>
<td><small class="text-muted">@FormatTimestamp(auction.Timestamp)</small></td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- COLONNA DESTRA: Statistiche Prodotti -->
<div class="col-lg-5">
<div class="card shadow-sm h-100">
<div class="card-header bg-success text-white">
<h5 class="mb-0">
<i class="bi bi-box-seam me-2"></i>
Prodotti Salvati
</h5>
</div>
<div class="card-body p-0">
@if (products == null || !products.Any())
{
<div class="text-center py-5 text-muted">
<i class="bi bi-inbox" style="font-size: 3rem;"></i>
<p class="mt-3">Nessun prodotto salvato</p>
</div>
}
else
{
<div class="alert alert-info border-0 shadow-sm animate-scale-in">
<i class="bi bi-info-circle-fill me-2"></i>
Nessuna statistica disponibile. Completa alcune aste per vedere le statistiche.
<div class="table-responsive" style="max-height: 600px; overflow-y: auto;">
<table class="table table-hover table-sm mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Prodotto</th>
<th class="text-center">Aste</th>
<th class="text-center">Win%</th>
<th class="text-end">Limiti €</th>
<th class="text-center">Azioni</th>
</tr>
</thead>
<tbody>
@foreach (var product in products)
{
var winRate = product.TotalAuctions > 0
? (product.WonAuctions * 100.0 / product.TotalAuctions)
: 0;
<tr>
<td>
<small class="fw-bold">@product.ProductName</small>
<br/>
<small class="text-muted">@product.TotalAuctions totali (@product.WonAuctions vinte)</small>
</td>
<td class="text-center fw-bold">
@product.TotalAuctions
</td>
<td class="text-center fw-bold">
<span class="@(winRate >= 50 ? "text-success" : "text-danger")">
@winRate.ToString("F0")%
</span>
</td>
<td class="text-end">
@if (product.RecommendedMinPrice.HasValue && product.RecommendedMaxPrice.HasValue)
{
<small class="text-muted">
@product.RecommendedMinPrice.Value.ToString("F2") - @product.RecommendedMaxPrice.Value.ToString("F2")
</small>
}
else
{
<small class="text-muted">-</small>
}
</td>
<td class="text-center">
@if (product.RecommendedMinPrice.HasValue && product.RecommendedMaxPrice.HasValue)
{
<button class="btn btn-sm btn-primary"
@onclick="() => ApplyLimitsToProduct(product)"
title="Applica limiti a tutte le aste di questo prodotto">
<i class="bi bi-check2-circle"></i> Applica
</button>
}
else
{
<small class="text-muted">N/D</small>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
</div>
}
</div>
<style>
.statistics-container {
max-width: 1600px;
margin: 0 auto;
}
.stats-card {
transition: all 0.3s ease;
}
.stats-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2) !important;
}
</style>
@code {
private TotalStats? totalStats;
private List<AuctionResult>? recentResults;
private string? errorMessage;
private bool isLoading = false;
private double roi = 0;
private bool isLoading = true;
private List<AuctionResultExtended>? recentAuctions;
private List<ProductStatisticsRecord>? products;
[Inject] private AuctionMonitor AuctionMonitor { get; set; } = default!;
[Inject] private ApplicationStateService AppState { get; set; } = default!;
[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
await RefreshStats();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && totalStats != null)
{
await RenderCharts();
}
}
private async Task RefreshStats()
{
try
{
isLoading = true;
errorMessage = null;
StateHasChanged();
// Carica statistiche
totalStats = await StatsService.GetTotalStatsAsync();
roi = await StatsService.CalculateROIAsync();
recentResults = await StatsService.GetRecentAuctionResultsAsync(20);
// Render grafici dopo il caricamento
if (totalStats != null)
try
{
await RenderCharts();
}
// Carica aste recenti (ultime 50)
recentAuctions = await DatabaseService.GetRecentAuctionResultsAsync(50);
// Carica prodotti con statistiche
products = await DatabaseService.GetAllProductStatisticsAsync();
}
catch (Exception ex)
{
errorMessage = $"Errore caricamento statistiche: {ex.Message}";
Console.WriteLine($"[ERROR] Statistics: {ex}");
Console.WriteLine($"[Statistics] Error loading data: {ex.Message}");
}
finally
{
@@ -241,26 +239,52 @@
}
}
private async Task RenderCharts()
private string FormatTimestamp(string timestamp)
{
if (DateTime.TryParse(timestamp, out var dt))
{
return dt.ToString("dd/MM HH:mm");
}
return timestamp;
}
private async Task ApplyLimitsToProduct(ProductStatisticsRecord product)
{
try
{
var chartData = await StatsService.GetChartDataAsync(30);
// Trova tutte le aste con questo ProductKey nel monitor
var matchingAuctions = AppState.Auctions
.Where(a => ProductStatisticsService.GenerateProductKey(a.Name) == product.ProductKey)
.ToList();
// Render grafico spesa
await JSRuntime.InvokeVoidAsync("renderMoneyChart",
chartData.Labels,
chartData.MoneySpent,
chartData.Savings);
if (!matchingAuctions.Any())
{
await JSRuntime.InvokeVoidAsync("alert", $"Nessuna asta trovata per '{product.ProductName}'");
return;
}
// Render grafico wins
await JSRuntime.InvokeVoidAsync("renderWinsChart",
totalStats!.TotalAuctionsWon,
totalStats!.TotalAuctionsLost);
// Applica i limiti
foreach (var auction in matchingAuctions)
{
auction.MinPrice = product.RecommendedMinPrice ?? 0;
auction.MaxPrice = product.RecommendedMaxPrice ?? 0;
auction.MinResets = product.RecommendedMinResets ?? 0;
auction.MaxResets = product.RecommendedMaxResets ?? 0;
}
// Salva
AutoBidder.Utilities.PersistenceManager.SaveAuctions(AppState.Auctions.ToList());
await JSRuntime.InvokeVoidAsync("alert",
$"? Limiti applicati a {matchingAuctions.Count} aste di '{product.ProductName}'\n\n" +
$"Min: €{product.RecommendedMinPrice:F2}\n" +
$"Max: €{product.RecommendedMaxPrice:F2}\n" +
$"Min Reset: {product.RecommendedMinResets}\n" +
$"Max Reset: {product.RecommendedMaxResets}");
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Render charts: {ex.Message}");
await JSRuntime.InvokeVoidAsync("alert", $"Errore: {ex.Message}");
}
}
}

View File

@@ -1,13 +1,9 @@
using AutoBidder.Services;
using AutoBidder.Services;
using AutoBidder.Data;
using AutoBidder.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.DataProtection;
using System.Data.Common;
var builder = WebApplication.CreateBuilder(args);
@@ -23,7 +19,7 @@ else
}
// Configura Kestrel solo per HTTPS opzionale
// HTTP è gestito da ASPNETCORE_URLS (default: http://+:8080 nel Dockerfile)
// HTTP è gestito da ASPNETCORE_URLS (default: http://+:8080 nel Dockerfile)
var enableHttps = builder.Configuration.GetValue<bool>("Kestrel:EnableHttps", false);
if (enableHttps)
@@ -77,12 +73,29 @@ else
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
// ============================================
// CONFIGURAZIONE DATABASE - Path configurabile via DATA_PATH
// ============================================
// Determina il path base per tutti i database e dati persistenti
// DATA_PATH può essere impostato nel docker-compose per usare un volume persistente
var dataBasePath = Environment.GetEnvironmentVariable("DATA_PATH");
if (string.IsNullOrEmpty(dataBasePath))
{
// Fallback: usa directory relativa all'applicazione
dataBasePath = Path.Combine(AppContext.BaseDirectory, "Data");
}
// Crea directory se non esiste
if (!Directory.Exists(dataBasePath))
{
Directory.CreateDirectory(dataBasePath);
}
Console.WriteLine($"[Startup] Data path: {dataBasePath}");
// Configura Data Protection per evitare CryptographicException
var dataProtectionPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AutoBidder",
"DataProtection-Keys"
);
var dataProtectionPath = Path.Combine(dataBasePath, "DataProtection-Keys");
if (!Directory.Exists(dataProtectionPath))
{
@@ -93,16 +106,8 @@ builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo(dataProtectionPath))
.SetApplicationName("AutoBidder");
// ============================================
// CONFIGURAZIONE AUTENTICAZIONE E SICUREZZA
// ============================================
// Database per Identity (SQLite)
var identityDbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AutoBidder",
"identity.db"
);
var identityDbPath = Path.Combine(dataBasePath, "identity.db");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
@@ -163,71 +168,6 @@ if (!builder.Environment.IsDevelopment())
});
}
// Configura Database SQLite per statistiche (fallback locale)
builder.Services.AddDbContext<StatisticsContext>(options =>
{
var dbPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AutoBidder",
"statistics.db"
);
// Crea directory se non esiste
var directory = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
options.UseSqlite($"Data Source={dbPath}");
});
// Configura Database PostgreSQL per statistiche avanzate
var usePostgres = builder.Configuration.GetValue<bool>("Database:UsePostgres", false);
if (usePostgres)
{
try
{
var connString = builder.Environment.IsProduction()
? builder.Configuration.GetConnectionString("PostgresStatsProduction")
: builder.Configuration.GetConnectionString("PostgresStats");
// Sostituisci variabili ambiente in production
if (builder.Environment.IsProduction())
{
connString = connString?
.Replace("${POSTGRES_USER}", Environment.GetEnvironmentVariable("POSTGRES_USER") ?? "autobidder")
.Replace("${POSTGRES_PASSWORD}", Environment.GetEnvironmentVariable("POSTGRES_PASSWORD") ?? "");
}
if (!string.IsNullOrEmpty(connString))
{
builder.Services.AddDbContext<AutoBidder.Data.PostgresStatsContext>(options =>
{
options.UseNpgsql(connString, npgsqlOptions =>
{
npgsqlOptions.EnableRetryOnFailure(3);
npgsqlOptions.CommandTimeout(30);
});
});
Console.WriteLine("[Startup] PostgreSQL configured for statistics");
}
else
{
Console.WriteLine("[Startup] PostgreSQL connection string not found - using SQLite only");
}
}
catch (Exception ex)
{
Console.WriteLine($"[Startup] PostgreSQL configuration failed: {ex.Message} - using SQLite only");
}
}
else
{
Console.WriteLine("[Startup] PostgreSQL disabled in configuration - using SQLite only");
}
// Registra servizi applicazione come Singleton per condividere stato
var htmlCacheService = new HtmlCacheService(
maxConcurrentRequests: 3,
@@ -245,23 +185,7 @@ builder.Services.AddSingleton(sp => new SessionService(auctionMonitor.GetApiClie
builder.Services.AddSingleton<DatabaseService>();
builder.Services.AddSingleton<ApplicationStateService>();
builder.Services.AddSingleton<BidooBrowserService>();
builder.Services.AddScoped<StatsService>(sp =>
{
var db = sp.GetRequiredService<DatabaseService>();
// Prova a ottenere PostgreSQL context (potrebbe essere null)
AutoBidder.Data.PostgresStatsContext? postgresDb = null;
try
{
postgresDb = sp.GetService<AutoBidder.Data.PostgresStatsContext>();
}
catch
{
// PostgreSQL non disponibile, usa solo SQLite
}
return new StatsService(db, postgresDb);
});
builder.Services.AddScoped<StatsService>();
builder.Services.AddScoped<AuctionStateService>();
// Configura SignalR per real-time updates
@@ -352,139 +276,126 @@ using (var scope = app.Services.CreateScope())
// Verifica salute database
var isHealthy = await databaseService.CheckDatabaseHealthAsync();
Console.WriteLine($"[DB] Database health check: {(isHealthy ? "OK" : "FAILED")}");
// 🔥 MANUTENZIONE AUTOMATICA DATABASE
var settings = AutoBidder.Utilities.SettingsManager.Load();
if (settings.DatabaseAutoCleanupDuplicates)
{
Console.WriteLine("[DB] Checking for duplicate records...");
var duplicateCount = await databaseService.CountDuplicateAuctionResultsAsync();
if (duplicateCount > 0)
{
Console.WriteLine($"[DB] Found {duplicateCount} duplicates - removing...");
var removed = await databaseService.RemoveDuplicateAuctionResultsAsync();
Console.WriteLine($"[DB] ✓ Removed {removed} duplicate auction results");
}
else
{
Console.WriteLine("[DB] ✓ No duplicates found");
}
}
if (settings.DatabaseAutoCleanupIncomplete)
{
Console.WriteLine("[DB] Checking for incomplete records...");
var incompleteCount = await databaseService.CountIncompleteAuctionResultsAsync();
if (incompleteCount > 0)
{
Console.WriteLine($"[DB] Found {incompleteCount} incomplete records - removing...");
var removed = await databaseService.RemoveIncompleteAuctionResultsAsync();
Console.WriteLine($"[DB] ✓ Removed {removed} incomplete auction results");
}
else
{
Console.WriteLine("[DB] ✓ No incomplete records found");
}
}
if (settings.DatabaseMaxRetentionDays > 0)
{
Console.WriteLine($"[DB] Checking for records older than {settings.DatabaseMaxRetentionDays} days...");
var oldCount = await databaseService.RemoveOldAuctionResultsAsync(settings.DatabaseMaxRetentionDays);
if (oldCount > 0)
{
Console.WriteLine($"[DB] ✓ Removed {oldCount} old auction results");
}
else
{
Console.WriteLine($"[DB] ✓ No old records to remove");
}
}
// 🆕 Esegui diagnostica completa se ci sono problemi o se richiesto
var runDiagnostics = Environment.GetEnvironmentVariable("DB_DIAGNOSTICS")?.ToLower() == "true";
if (!isHealthy || runDiagnostics)
{
Console.WriteLine("[DB] Running full diagnostics...");
await databaseService.RunDatabaseDiagnosticsAsync();
}
}
catch (Exception ex)
{
Console.WriteLine($"[DB ERROR] Failed to initialize database: {ex.Message}");
Console.WriteLine($"[DB ERROR] Stack trace: {ex.StackTrace}");
}
}
// Crea database statistiche se non esiste (senza migrations)
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<StatisticsContext>();
// In caso di errore, esegui sempre la diagnostica
try
{
// Log percorso database
var connection = db.Database.GetDbConnection();
Console.WriteLine($"[STATS DB] Database path: {connection.DataSource}");
// Verifica se database esiste
var dbExists = db.Database.CanConnect();
Console.WriteLine($"[STATS DB] Database exists: {dbExists}");
// Forza creazione tabelle se non esistono
if (!dbExists || !db.ProductStats.Any())
{
Console.WriteLine("[STATS DB] Creating database schema...");
db.Database.EnsureDeleted(); // Elimina database vecchio
db.Database.EnsureCreated(); // Ricrea con schema aggiornato
Console.WriteLine("[STATS DB] Database schema created successfully");
}
else
{
Console.WriteLine($"[STATS DB] Database already exists with {db.ProductStats.Count()} records");
}
}
catch (Exception ex)
{
Console.WriteLine($"[STATS DB ERROR] Failed to initialize database: {ex.Message}");
Console.WriteLine($"[STATS DB ERROR] Stack trace: {ex.StackTrace}");
// Prova a ricreare forzatamente
try
{
Console.WriteLine("[STATS DB] Attempting forced recreation...");
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
Console.WriteLine("[STATS DB] Forced recreation successful");
}
catch (Exception ex2)
{
Console.WriteLine($"[STATS DB ERROR] Forced recreation failed: {ex2.Message}");
}
}
}
// Inizializza PostgreSQL per statistiche avanzate
using (var scope = app.Services.CreateScope())
{
try
{
var postgresDb = scope.ServiceProvider.GetService<AutoBidder.Data.PostgresStatsContext>();
if (postgresDb != null)
{
Console.WriteLine("[PostgreSQL] Initializing PostgreSQL statistics database...");
var autoCreateSchema = app.Configuration.GetValue<bool>("Database:AutoCreateSchema", true);
if (autoCreateSchema)
{
// Usa il metodo EnsureSchemaAsync che gestisce la creazione automatica
var schemaCreated = await postgresDb.EnsureSchemaAsync();
if (schemaCreated)
{
// Valida che tutte le tabelle siano state create
var schemaValid = await postgresDb.ValidateSchemaAsync();
if (schemaValid)
{
Console.WriteLine("[PostgreSQL] Statistics features ENABLED");
}
else
{
Console.WriteLine("[PostgreSQL] Schema validation failed");
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (missing tables)");
}
}
else
{
Console.WriteLine("[PostgreSQL] Cannot connect to database");
Console.WriteLine("[PostgreSQL] Statistics features will use SQLite fallback");
}
}
else
{
Console.WriteLine("[PostgreSQL] Auto-create schema disabled");
// Prova comunque a validare lo schema esistente
try
{
var schemaValid = await postgresDb.ValidateSchemaAsync();
if (schemaValid)
{
Console.WriteLine("[PostgreSQL] Existing schema validated successfully");
Console.WriteLine("[PostgreSQL] Statistics features ENABLED");
}
else
{
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)");
}
await databaseService.RunDatabaseDiagnosticsAsync();
}
catch
{
Console.WriteLine("[PostgreSQL] Statistics features DISABLED (schema not found)");
// Ignora errori nella diagnostica stessa
}
}
}
else
{
Console.WriteLine("[PostgreSQL] Not configured - Statistics will use SQLite only");
}
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Initialization failed: {ex.Message}");
Console.WriteLine($"[PostgreSQL ERROR] Stack trace: {ex.StackTrace}");
Console.WriteLine($"[PostgreSQL] Statistics features will use SQLite fallback");
}
}
// ??? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato
// ?? NUOVO: Collega evento OnAuctionCompleted per salvare statistiche
{
var dbService = app.Services.GetRequiredService<DatabaseService>();
auctionMonitor.OnAuctionCompleted += async (auction, state, won) =>
{
try
{
Console.WriteLine($"");
Console.WriteLine($"╔════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ [EVENTO] Asta Terminata - Salvataggio Statistiche");
Console.WriteLine($"╠════════════════════════════════════════════════════════════════");
Console.WriteLine($"║ Asta: {auction.Name}");
Console.WriteLine($"║ ID: {auction.AuctionId}");
Console.WriteLine($"║ Stato: {(won ? " VINTA" : " PERSA")}");
Console.WriteLine($"╚════════════════════════════════════════════════════════════════");
Console.WriteLine($"");
// Crea un nuovo scope per StatsService (è Scoped)
using var scope = app.Services.CreateScope();
var statsService = scope.ServiceProvider.GetRequiredService<StatsService>();
await statsService.RecordAuctionCompletedAsync(auction, state, won);
// ✅ CORRETTO: Log di successo SOLO se non ci sono eccezioni
Console.WriteLine($"");
Console.WriteLine($"[EVENTO] ✓ Asta salvata con successo nel database");
Console.WriteLine($"");
}
catch (Exception ex)
{
Console.WriteLine($"");
Console.WriteLine($"[EVENTO ERROR] ✗ Errore durante salvataggio statistiche:");
Console.WriteLine($"[EVENTO ERROR] {ex.Message}");
Console.WriteLine($"[EVENTO ERROR] Stack: {ex.StackTrace}");
Console.WriteLine($"");
}
};
Console.WriteLine("[STARTUP] OnAuctionCompleted event handler registered");
}
// ? NUOVO: Ripristina aste salvate e riprendi monitoraggio se configurato
using (var scope = app.Services.CreateScope())
{
try
@@ -519,15 +430,26 @@ using (var scope = app.Services.CreateScope())
// Gestisci comportamento di avvio
if (settings.RememberAuctionStates)
{
// Modalità "Ricorda Stato": mantiene lo stato salvato di ogni asta
var activeAuctions = savedAuctions.Where(a => a.IsActive && !a.IsPaused).ToList();
// Modalità "Ricorda Stato": mantiene lo stato salvato di ogni asta
// 🔥 FIX CRITICO: Avvia monitor anche per aste in pausa (IsActive=true)
var activeAuctions = savedAuctions.Where(a => a.IsActive).ToList();
var resumeAuctions = savedAuctions.Where(a => a.IsActive && !a.IsPaused).ToList();
var pausedAuctions = savedAuctions.Where(a => a.IsActive && a.IsPaused).ToList();
if (activeAuctions.Any())
{
Console.WriteLine($"[STARTUP] Resuming monitoring for {activeAuctions.Count} active auctions");
Console.WriteLine($"[STARTUP] Starting monitor for {activeAuctions.Count} active auctions ({resumeAuctions.Count} active, {pausedAuctions.Count} paused)");
monitor.Start();
appState.IsMonitoringActive = true;
appState.AddLog($"[STARTUP] Ripristinato stato salvato: {activeAuctions.Count} aste attive");
if (pausedAuctions.Any())
{
appState.AddLog($"[STARTUP] Ripristinate {resumeAuctions.Count} aste attive + {pausedAuctions.Count} in pausa (polling attivo)");
}
else
{
appState.AddLog($"[STARTUP] Ripristinato stato salvato: {resumeAuctions.Count} aste attive");
}
}
else
{
@@ -537,7 +459,7 @@ using (var scope = app.Services.CreateScope())
}
else
{
// Modalità "Default": applica DefaultStartAuctionsOnLoad a tutte le aste
// Modalità "Default": applica DefaultStartAuctionsOnLoad a tutte le aste
switch (settings.DefaultStartAuctionsOnLoad)
{
case "Active":
@@ -597,7 +519,7 @@ if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
// Abilita HSTS solo se HTTPS è attivo
// Abilita HSTS solo se HTTPS è attivo
if (enableHttps)
{
app.UseHsts();
@@ -608,7 +530,7 @@ else
app.UseDeveloperExceptionPage();
}
// Abilita HTTPS redirection solo se HTTPS è configurato
// Abilita HTTPS redirection solo se HTTPS è configurato
if (enableHttps)
{
app.UseHttpsRedirection();

View File

@@ -23,6 +23,12 @@ namespace AutoBidder.Services
public event Action<string>? OnLog;
public event Action<string>? OnResetCountChanged;
/// <summary>
/// Evento fired quando un'asta termina (vinta, persa o chiusa).
/// Parametri: AuctionInfo, AuctionState finale, bool won (true se vinta dall'utente)
/// </summary>
public event Action<AuctionInfo, AuctionState, bool>? OnAuctionCompleted;
public AuctionMonitor()
{
_apiClient = new BidooApiClient();
@@ -101,13 +107,53 @@ namespace AutoBidder.Services
var auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
if (auction != null)
{
// ?? Se l'asta è terminata, salva le statistiche prima di rimuoverla
if (!auction.IsActive && auction.LastState != null)
{
OnLog?.Invoke($"[STATS] Asta terminata rilevata: {auction.Name} - Salvataggio statistiche in corso...");
try
{
// Determina se è stata vinta dall'utente
var won = IsAuctionWonByUser(auction);
OnLog?.Invoke($"[STATS] Asta {auction.Name} - Stato: {(won ? "VINTA" : "PERSA")}");
// Emetti evento per salvare le statistiche
// Questo trigger sarà gestito in Program.cs con scraping HTML
OnAuctionCompleted?.Invoke(auction, auction.LastState, won);
}
catch (Exception ex)
{
OnLog?.Invoke($"[STATS ERROR] Errore durante salvataggio statistiche per {auction.Name}: {ex.Message}");
}
}
else
{
OnLog?.Invoke($"[REMOVE] Rimozione asta non terminata: {auction.Name} (non salvata nelle statistiche)");
}
_auctions.Remove(auction);
// ? RIMOSSO: Log ridondante - viene già loggato da MainWindow con più dettagli
// OnLog?.Invoke($"[-] Asta rimossa: {auction.Name}");
}
}
}
/// <summary>
/// Determina se l'asta è stata vinta dall'utente corrente
/// </summary>
private bool IsAuctionWonByUser(AuctionInfo auction)
{
if (auction.LastState == null) return false;
var session = _apiClient.GetSession();
var username = session?.Username;
if (string.IsNullOrEmpty(username)) return false;
// Controlla se l'ultimo puntatore è l'utente
return auction.LastState.LastBidder?.Equals(username, StringComparison.OrdinalIgnoreCase) == true;
}
public IReadOnlyList<AuctionInfo> GetAuctions()
{
lock (_auctions)
@@ -116,6 +162,60 @@ namespace AutoBidder.Services
}
}
/// <summary>
/// Applica i limiti consigliati a un'asta specifica
/// </summary>
public bool ApplyLimitsToAuction(string auctionId, double minPrice, double maxPrice, int minResets, int maxResets, int maxBids)
{
lock (_auctions)
{
var auction = _auctions.FirstOrDefault(a => a.AuctionId == auctionId);
if (auction == null) return false;
auction.MinPrice = minPrice;
auction.MaxPrice = maxPrice;
auction.MinResets = minResets;
auction.MaxResets = maxResets;
OnLog?.Invoke($"[LIMITS] Aggiornati limiti per {auction.Name}: MinPrice={minPrice:F2}, MaxPrice={maxPrice:F2}, MinResets={minResets}, MaxResets={maxResets}");
return true;
}
}
/// <summary>
/// Applica i limiti consigliati a tutte le aste con lo stesso ProductKey
/// </summary>
public int ApplyLimitsToProductAuctions(string productKey, double minPrice, double maxPrice, int minResets, int maxResets, int maxBids)
{
int count = 0;
lock (_auctions)
{
foreach (var auction in _auctions)
{
var auctionProductKey = ProductStatisticsService.GenerateProductKey(auction.Name);
if (auctionProductKey == productKey)
{
auction.MinPrice = minPrice;
auction.MaxPrice = maxPrice;
auction.MinResets = minResets;
auction.MaxResets = maxResets;
count++;
OnLog?.Invoke($"[LIMITS] Aggiornati limiti per {auction.Name}");
}
}
}
if (count > 0)
{
OnLog?.Invoke($"[LIMITS] Applicati limiti a {count} aste con productKey={productKey}");
}
return count;
}
public void Start()
{
if (_monitoringTask != null && !_monitoringTask.IsCompleted)
@@ -247,6 +347,7 @@ namespace AutoBidder.Services
return;
}
auction.PollingLatencyMs = state.PollingLatencyMs;
// ? AGGIORNATO: Aggiorna storia puntate mantenendo quelle vecchie
@@ -262,7 +363,10 @@ namespace AutoBidder.Services
string statusMsg = state.Status == AuctionStatus.EndedWon ? "VINTA" :
state.Status == AuctionStatus.EndedLost ? "Persa" : "Chiusa";
bool won = state.Status == AuctionStatus.EndedWon;
auction.IsActive = false;
auction.LastState = state; // Salva stato finale per statistiche
auction.AddLog($"[ASTA TERMINATA] {statusMsg}");
OnLog?.Invoke($"[FINE] [{auction.AuctionId}] Asta {statusMsg} - Polling fermato");
@@ -277,6 +381,18 @@ namespace AutoBidder.Services
});
OnAuctionUpdated?.Invoke(state);
// ?? Emetti evento per salvare statistiche
try
{
OnAuctionCompleted?.Invoke(auction, state, won);
OnLog?.Invoke($"[STATS] Evento OnAuctionCompleted emesso per {auction.Name}");
}
catch (Exception ex)
{
OnLog?.Invoke($"[STATS ERROR] Errore in OnAuctionCompleted: {ex.Message}");
}
return;
}
@@ -473,7 +589,11 @@ namespace AutoBidder.Services
private bool ShouldBid(AuctionInfo auction, AuctionState state)
{
// ?? CONTROLLO 0: Verifica convenienza (se dati disponibili)
if (auction.CalculatedValue != null &&
// ?? IMPORTANTE: Applica solo se BuyNowPrice è valido (> 0)
// Se BuyNowPrice == 0, significa errore scraping - non bloccare le puntate
if (auction.BuyNowPrice.HasValue &&
auction.BuyNowPrice.Value > 0 &&
auction.CalculatedValue != null &&
auction.CalculatedValue.Savings.HasValue &&
!auction.CalculatedValue.IsWorthIt)
{
@@ -531,14 +651,6 @@ namespace AutoBidder.Services
return false;
}
// ??? CONTROLLO 5: MaxClicks
int myBidsCount = auction.BidHistory.Count(b => b.EventType == BidEventType.MyBid);
if (auction.MaxClicks > 0 && myBidsCount >= auction.MaxClicks)
{
auction.AddLog($"[CLICKS] Click massimi raggiunti: {myBidsCount} >= Max {auction.MaxClicks}");
return false;
}
// ?? CONTROLLO 6: Cooldown (evita puntate multiple ravvicinate)
if (auction.LastClickAt.HasValue)
{

View File

@@ -220,6 +220,19 @@ namespace AutoBidder.Services
auctions = ParseAuctionsFromHtml(html);
Console.WriteLine($"[BidooBrowser] Trovate {auctions.Count} aste nella categoria {category.DisplayName}");
// ?? DEBUG: Verifica quante aste hanno IsCreditAuction = true
if (category.IsSpecialCategory && category.TabId == 1)
{
var creditCount = auctions.Count(a => a.IsCreditAuction);
Console.WriteLine($"[BidooBrowser] DEBUG Aste di Puntate: {creditCount}/{auctions.Count} hanno IsCreditAuction=true");
// Log primi 3 nomi per debug
foreach (var a in auctions.Take(3))
{
Console.WriteLine($"[BidooBrowser] - {a.Name} (ID: {a.AuctionId}, IsCreditAuction: {a.IsCreditAuction})");
}
}
}
catch (Exception ex)
{
@@ -440,8 +453,9 @@ namespace AutoBidder.Services
/// <summary>
/// Parsa la risposta di data.php formato LISTID
/// Formato: timestamp*(id;status;expiry;price;bidder;timer;countdown#id2;status2;...)
/// Esempio: 1769032850*(85583891;OFF;1769019191;62;sederafo30;3;7m#85582947;OFF;1769023093;680;pandaka;3;1h 16m)
/// Formato: serverTimestamp*(id;status;expiry;price;;#id2;status2;...)
/// Esempio: 1769073106*(85559629;ON;1769082240;1;;#85559630;ON;1769082240;1;;)
/// Il timestamp del server viene usato come riferimento per calcolare il tempo rimanente
/// </summary>
private void ParseListIdResponse(string response, List<BidooBrowserAuction> auctions)
{
@@ -455,6 +469,11 @@ namespace AutoBidder.Services
return;
}
// Estrai il timestamp del server (prima di *)
var serverTimestampStr = response.Substring(0, starIndex);
long serverTimestamp = 0;
long.TryParse(serverTimestampStr, out serverTimestamp);
var mainData = response.Substring(starIndex + 1);
// Rimuovi parentesi se presenti
@@ -465,20 +484,19 @@ namespace AutoBidder.Services
// Split per ogni asta (separatore #)
var auctionEntries = mainData.Split('#', StringSplitOptions.RemoveEmptyEntries);
int updatedCount = 0;
foreach (var entry in auctionEntries)
{
// Formato: id;status;expiry;price;bidder;timer;countdown
// Formato: id;status;expiry;price;; (bidder e timer possono essere vuoti)
var fields = entry.Split(';');
if (fields.Length < 5) continue;
if (fields.Length < 4) continue;
var id = fields[0].Trim();
var status = fields[1].Trim(); // ON/OFF
var expiry = fields[2].Trim(); // timestamp scadenza
var priceStr = fields[3].Trim(); // prezzo in centesimi
var bidder = fields[4].Trim(); // ultimo bidder
var timer = fields.Length > 5 ? fields[5].Trim() : ""; // frequenza timer
var countdown = fields.Length > 6 ? fields[6].Trim() : ""; // tempo rimanente (es: "7m", "1h 16m")
var expiryStr = fields[2].Trim(); // timestamp scadenza (stesso formato del server)
var priceStr = fields[3].Trim(); // prezzo (centesimi)
var bidder = fields.Length > 4 ? fields[4].Trim() : ""; // ultimo bidder (può essere vuoto)
var auction = auctions.FirstOrDefault(a => a.AuctionId == id);
if (auction == null) continue;
@@ -489,32 +507,36 @@ namespace AutoBidder.Services
auction.CurrentPrice = priceCents / 100m;
}
// Aggiorna bidder
// Aggiorna bidder solo se non vuoto
if (!string.IsNullOrEmpty(bidder))
{
auction.LastBidder = bidder;
}
// Aggiorna timer frequency
if (int.TryParse(timer, out int timerFreq) && timerFreq > 0)
// Calcola tempo rimanente usando il timestamp del server come riferimento
if (long.TryParse(expiryStr, out long expiryTimestamp) && serverTimestamp > 0)
{
auction.TimerFrequency = timerFreq;
// Il tempo rimanente è: expiry - serverTime (entrambi nello stesso formato)
var remainingSeconds = expiryTimestamp - serverTimestamp;
auction.RemainingSeconds = remainingSeconds > 0 ? (int)remainingSeconds : 0;
}
// Parse countdown per calcolare secondi rimanenti
auction.RemainingSeconds = ParseCountdown(countdown, auction.TimerFrequency);
// Status: ON = attiva, OFF = in countdown
auction.IsActive = true;
auction.IsSold = false;
// Se countdown contiene "Ha Vinto" o simile, è venduta
if (countdown.Contains("Vinto", StringComparison.OrdinalIgnoreCase) ||
countdown.Contains("Chiusa", StringComparison.OrdinalIgnoreCase))
else if (status == "ON")
{
auction.IsSold = true;
auction.IsActive = false;
// Se non riusciamo a calcolare, usa il timer frequency come fallback
if (auction.RemainingSeconds <= 0)
{
auction.RemainingSeconds = auction.TimerFrequency;
}
}
Console.WriteLine($"[BidooBrowser] Aggiornate {auctionEntries.Length} aste");
// Status: ON = attiva in countdown, OFF = terminata/in pausa
auction.IsActive = status == "ON";
auction.IsSold = status != "ON" && auction.RemainingSeconds <= 0;
updatedCount++;
}
Console.WriteLine($"[BidooBrowser] Aggiornate {updatedCount} aste su {auctionEntries.Length}");
}
catch (Exception ex)
{
@@ -578,5 +600,137 @@ namespace AutoBidder.Services
return defaultSeconds;
}
/// <summary>
/// Carica nuove aste usando get_auction_updates.php (simula scrolling infinito)
/// Questa API restituisce aste che non sono ancora state caricate
/// </summary>
public async Task<List<BidooBrowserAuction>> GetMoreAuctionsAsync(
BidooCategoryInfo category,
List<string> existingAuctionIds,
CancellationToken cancellationToken = default)
{
var newAuctions = new List<BidooBrowserAuction>();
try
{
var existingIdsSet = existingAuctionIds.ToHashSet();
// Prepara la chiamata POST a get_auction_updates.php
var url = "https://it.bidoo.com/get_auction_updates.php";
// Costruisci il body della richiesta
var viewIds = string.Join(",", existingAuctionIds);
var tabValue = category.IsSpecialCategory ? category.TabId : 4;
var tagValue = category.IsSpecialCategory ? 0 : category.TagId;
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("prefetch", "true"),
new KeyValuePair<string, string>("view", viewIds),
new KeyValuePair<string, string>("tab", tabValue.ToString()),
new KeyValuePair<string, string>("tag", tagValue.ToString())
});
var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = formContent
};
AddBrowserHeaders(request, "https://it.bidoo.com/");
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
Console.WriteLine($"[BidooBrowser] Fetching more auctions with {existingAuctionIds.Count} existing IDs...");
var response = await _httpClient.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
var responseText = await response.Content.ReadAsStringAsync(cancellationToken);
// Parse la risposta JSON
// Formato: {"gc":[],"int":[],"list":[id1,id2,...],"items":["<html>","<html>",...]}
newAuctions = ParseGetAuctionUpdatesResponse(responseText, existingIdsSet);
Console.WriteLine($"[BidooBrowser] Trovate {newAuctions.Count} nuove aste");
}
catch (Exception ex)
{
Console.WriteLine($"[BidooBrowser] Errore caricamento nuove aste: {ex.Message}");
}
return newAuctions;
}
/// <summary>
/// Parsa la risposta di get_auction_updates.php
/// </summary>
private List<BidooBrowserAuction> ParseGetAuctionUpdatesResponse(string json, HashSet<string> existingIds)
{
var auctions = new List<BidooBrowserAuction>();
try
{
// Parse JSON manuale per estrarre items[]
// Cerchiamo "items":["...","..."]
var itemsMatch = Regex.Match(json, @"""items"":\s*\[(.*?)\](?=,""|\})", RegexOptions.Singleline);
if (!itemsMatch.Success)
{
Console.WriteLine("[BidooBrowser] Nessun items trovato nella risposta");
return auctions;
}
var itemsContent = itemsMatch.Groups[1].Value;
// Gli items sono stringhe HTML escaped, dobbiamo parsarle
// Ogni item è una stringa JSON che contiene HTML
var htmlPattern = new Regex(@"""((?:[^""\\]|\\.)*?)""", RegexOptions.Singleline);
var htmlMatches = htmlPattern.Matches(itemsContent);
foreach (Match htmlMatch in htmlMatches)
{
if (!htmlMatch.Success) continue;
// Unescape la stringa JSON
var escapedHtml = htmlMatch.Groups[1].Value;
var html = UnescapeJsonString(escapedHtml);
// Estrai l'ID dell'asta
var idMatch = Regex.Match(html, @"id=""divAsta(\d+)""");
if (!idMatch.Success) continue;
var auctionId = idMatch.Groups[1].Value;
// Salta se già esiste
if (existingIds.Contains(auctionId)) continue;
// Parsa l'asta dall'HTML
var auction = ParseSingleAuction(auctionId, html);
if (auction != null)
{
auctions.Add(auction);
}
}
}
catch (Exception ex)
{
Console.WriteLine($"[BidooBrowser] Errore parsing get_auction_updates response: {ex.Message}");
}
return auctions;
}
/// <summary>
/// Unescape di una stringa JSON
/// </summary>
private static string UnescapeJsonString(string escaped)
{
return escaped
.Replace("\\/", "/")
.Replace("\\n", "\n")
.Replace("\\r", "\r")
.Replace("\\t", "\t")
.Replace("\\\"", "\"")
.Replace("\\\\", "\\");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,340 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per calcolare statistiche aggregate per prodotto e generare limiti consigliati.
/// L'algoritmo analizza le aste storiche per determinare i parametri ottimali.
/// </summary>
public class ProductStatisticsService
{
private readonly DatabaseService _db;
public ProductStatisticsService(DatabaseService db)
{
_db = db;
}
/// <summary>
/// Genera una chiave univoca normalizzata per raggruppare prodotti simili.
/// Rimuove varianti, numeri di serie, colori ecc.
/// </summary>
public static string GenerateProductKey(string productName)
{
if (string.IsNullOrWhiteSpace(productName))
return "unknown";
var normalized = productName.ToLowerInvariant().Trim();
// Rimuovi contenuto tra parentesi (varianti, colori, capacità)
normalized = Regex.Replace(normalized, @"\([^)]*\)", "");
normalized = Regex.Replace(normalized, @"\[[^\]]*\]", "");
// Rimuovi colori comuni
var colors = new[] { "nero", "bianco", "grigio", "rosso", "blu", "verde", "oro", "argento",
"black", "white", "gray", "red", "blue", "green", "gold", "silver",
"space gray", "midnight", "starlight" };
foreach (var color in colors)
{
normalized = Regex.Replace(normalized, $@"\b{color}\b", "", RegexOptions.IgnoreCase);
}
// Rimuovi capacità storage (64gb, 128gb, 256gb, ecc.)
normalized = Regex.Replace(normalized, @"\b\d+\s*(gb|tb|mb)\b", "", RegexOptions.IgnoreCase);
// Rimuovi numeri di serie e codici prodotto
normalized = Regex.Replace(normalized, @"\b[A-Z]{2,}\d{3,}\b", "", RegexOptions.IgnoreCase);
// Normalizza spazi e caratteri speciali
normalized = Regex.Replace(normalized, @"[^a-z0-9\s]", " ");
normalized = Regex.Replace(normalized, @"\s+", "_");
normalized = normalized.Trim('_');
// Limita lunghezza
if (normalized.Length > 50)
normalized = normalized.Substring(0, 50);
return string.IsNullOrEmpty(normalized) ? "unknown" : normalized;
}
/// <summary>
/// Aggiorna le statistiche aggregate per un prodotto dopo una nuova asta completata
/// </summary>
public async Task UpdateProductStatisticsAsync(string productKey, string productName)
{
try
{
// Ottieni tutti i risultati per questo prodotto
var results = await _db.GetAuctionResultsByProductAsync(productKey, 500);
if (results.Count == 0)
{
Console.WriteLine($"[ProductStats] No results found for product: {productKey}");
return;
}
// Calcola statistiche aggregate
var wonResults = results.Where(r => r.Won).ToList();
var lostResults = results.Where(r => !r.Won).ToList();
var stats = new ProductStatisticsRecord
{
ProductKey = productKey,
ProductName = productName,
TotalAuctions = results.Count,
WonAuctions = wonResults.Count,
LostAuctions = lostResults.Count
};
// Statistiche prezzo (usa aste vinte per calcolare i target)
if (wonResults.Any())
{
stats.AvgFinalPrice = wonResults.Average(r => r.FinalPrice);
stats.MinFinalPrice = wonResults.Min(r => r.FinalPrice);
stats.MaxFinalPrice = wonResults.Max(r => r.FinalPrice);
}
else if (results.Any())
{
stats.AvgFinalPrice = results.Average(r => r.FinalPrice);
stats.MinFinalPrice = results.Min(r => r.FinalPrice);
stats.MaxFinalPrice = results.Max(r => r.FinalPrice);
}
// Statistiche puntate (usa WinnerBidsUsed se disponibile, altrimenti BidsUsed)
var bidsData = wonResults
.Where(r => r.WinnerBidsUsed.HasValue || r.BidsUsed > 0)
.Select(r => r.WinnerBidsUsed ?? r.BidsUsed)
.ToList();
if (bidsData.Any())
{
stats.AvgBidsToWin = bidsData.Select(b => (double)b).Average();
stats.MinBidsToWin = bidsData.Min();
stats.MaxBidsToWin = bidsData.Max();
}
// Statistiche reset
var resetData = wonResults.Where(r => r.TotalResets.HasValue).Select(r => r.TotalResets!.Value).ToList();
if (resetData.Any())
{
stats.AvgResets = resetData.Select(r => (double)r).Average();
stats.MinResets = resetData.Min();
stats.MaxResets = resetData.Max();
}
// Calcola limiti consigliati
var limits = CalculateRecommendedLimits(results);
stats.RecommendedMinPrice = limits.MinPrice;
stats.RecommendedMaxPrice = limits.MaxPrice;
stats.RecommendedMinResets = limits.MinResets;
stats.RecommendedMaxResets = limits.MaxResets;
stats.RecommendedMaxBids = limits.MaxBids;
// Calcola statistiche per fascia oraria
var hourlyStats = CalculateHourlyStats(results);
stats.HourlyStatsJson = JsonSerializer.Serialize(hourlyStats);
// Salva nel database
await _db.UpsertProductStatisticsAsync(stats);
Console.WriteLine($"[ProductStats] Updated stats for {productKey}: {stats.TotalAuctions} auctions, WinRate={stats.WinRate:F1}%");
}
catch (Exception ex)
{
Console.WriteLine($"[ProductStats ERROR] Failed to update stats for {productKey}: {ex.Message}");
}
}
/// <summary>
/// Calcola i limiti consigliati basandosi sui dati storici
/// </summary>
public RecommendedLimits CalculateRecommendedLimits(List<AutoBidder.Models.AuctionResultExtended> results)
{
var limits = new RecommendedLimits
{
SampleSize = results.Count
};
if (results.Count < 3)
{
limits.ConfidenceScore = 0;
return limits;
}
var wonResults = results.Where(r => r.Won).ToList();
if (wonResults.Count == 0)
{
// Nessuna vittoria: usa tutti i risultati con margine conservativo
limits.ConfidenceScore = 10;
limits.MinPrice = results.Min(r => r.FinalPrice) * 0.8;
limits.MaxPrice = results.Max(r => r.FinalPrice) * 1.2;
return limits;
}
// Calcola percentili sui prezzi delle aste vinte
var prices = wonResults.Select(r => r.FinalPrice).OrderBy(p => p).ToList();
limits.MinPrice = CalculatePercentile(prices, 10); // 10° percentile - entrare presto
limits.MaxPrice = CalculatePercentile(prices, 90); // 90° percentile - limite sicuro
// Calcola limiti reset
var resets = wonResults.Where(r => r.TotalResets.HasValue).Select(r => r.TotalResets!.Value).ToList();
if (resets.Any())
{
var avgResets = resets.Average();
var stdDev = CalculateStandardDeviation(resets.Select(r => (double)r).ToList());
limits.MinResets = Math.Max(0, (int)(avgResets - stdDev)); // Media - 1 stddev
limits.MaxResets = (int)(avgResets + stdDev); // Media + 1 stddev
}
// Calcola limiti puntate
var bids = wonResults
.Where(r => r.WinnerBidsUsed.HasValue || r.BidsUsed > 0)
.Select(r => r.WinnerBidsUsed ?? r.BidsUsed)
.OrderBy(b => b)
.ToList();
if (bids.Any())
{
// 90° percentile + 10% buffer
limits.MaxBids = (int)(CalculatePercentile(bids.Select(b => (double)b).ToList(), 90) * 1.1);
}
// Trova la fascia oraria migliore
var hourlyWins = wonResults
.Where(r => r.ClosedAtHour.HasValue)
.GroupBy(r => r.ClosedAtHour!.Value)
.Select(g => new { Hour = g.Key, Wins = g.Count() })
.OrderByDescending(x => x.Wins)
.FirstOrDefault();
if (hourlyWins != null)
{
limits.BestHourToPlay = hourlyWins.Hour;
}
// Win rate
limits.AverageWinRate = results.Count > 0 ? (double)wonResults.Count / results.Count * 100 : 0;
// Confidence score basato sul sample size
limits.ConfidenceScore = results.Count switch
{
>= 50 => 95,
>= 30 => 85,
>= 20 => 70,
>= 10 => 50,
>= 5 => 30,
_ => 15
};
return limits;
}
/// <summary>
/// Calcola statistiche aggregate per ogni fascia oraria
/// </summary>
private List<HourlyStats> CalculateHourlyStats(List<AutoBidder.Models.AuctionResultExtended> results)
{
var stats = new List<HourlyStats>();
var grouped = results
.Where(r => r.ClosedAtHour.HasValue)
.GroupBy(r => r.ClosedAtHour!.Value);
foreach (var group in grouped)
{
var hourResults = group.ToList();
var wonInHour = hourResults.Where(r => r.Won).ToList();
stats.Add(new HourlyStats
{
Hour = group.Key,
TotalAuctions = hourResults.Count,
WonAuctions = wonInHour.Count,
AvgFinalPrice = hourResults.Any() ? hourResults.Average(r => r.FinalPrice) : 0,
AvgBidsUsed = hourResults.Any() ? hourResults.Average(r => r.BidsUsed) : 0
});
}
return stats.OrderBy(s => s.Hour).ToList();
}
/// <summary>
/// Ottiene le statistiche per un prodotto
/// </summary>
public async Task<ProductStatisticsRecord?> GetProductStatisticsAsync(string productKey)
{
return await _db.GetProductStatisticsAsync(productKey);
}
/// <summary>
/// Ottiene tutti i prodotti con statistiche
/// </summary>
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
{
return await _db.GetAllProductStatisticsAsync();
}
/// <summary>
/// Ottiene i limiti consigliati per un prodotto
/// </summary>
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productKey)
{
var stats = await _db.GetProductStatisticsAsync(productKey);
if (stats == null)
return null;
return new RecommendedLimits
{
MinPrice = stats.RecommendedMinPrice ?? 0,
MaxPrice = stats.RecommendedMaxPrice ?? 0,
MinResets = stats.RecommendedMinResets ?? 0,
MaxResets = stats.RecommendedMaxResets ?? 0,
MaxBids = stats.RecommendedMaxBids ?? 0,
ConfidenceScore = stats.TotalAuctions switch
{
>= 50 => 95,
>= 30 => 85,
>= 20 => 70,
>= 10 => 50,
>= 5 => 30,
_ => 15
},
SampleSize = stats.TotalAuctions,
AverageWinRate = stats.WinRate
};
}
// Helpers per calcoli statistici
private static double CalculatePercentile(List<double> sortedData, int percentile)
{
if (sortedData.Count == 0) return 0;
if (sortedData.Count == 1) return sortedData[0];
double index = (percentile / 100.0) * (sortedData.Count - 1);
int lower = (int)Math.Floor(index);
int upper = (int)Math.Ceiling(index);
if (lower == upper) return sortedData[lower];
return sortedData[lower] + (sortedData[upper] - sortedData[lower]) * (index - lower);
}
private static double CalculateStandardDeviation(List<double> data)
{
if (data.Count < 2) return 0;
double avg = data.Average();
double sumSquares = data.Sum(d => Math.Pow(d - avg, 2));
return Math.Sqrt(sumSquares / (data.Count - 1));
}
}
}

View File

@@ -2,64 +2,145 @@ using System;
using System.Linq;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Data;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per calcolo e gestione statistiche avanzate
/// Usa PostgreSQL per statistiche persistenti e SQLite locale come fallback
/// 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;
private readonly PostgresStatsContext? _postgresDb;
private readonly bool _postgresAvailable;
public StatsService(DatabaseService db, PostgresStatsContext? postgresDb = null)
/// <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;
_postgresDb = postgresDb;
_postgresAvailable = false;
_productStatsService = new ProductStatisticsService(db);
// Verifica disponibilità PostgreSQL
if (_postgresDb != null)
// Log stato database SQLite
Console.WriteLine($"[StatsService] Database available: {_db.IsAvailable}");
Console.WriteLine($"[StatsService] Database initialized: {_db.IsInitialized}");
if (!_db.IsAvailable)
{
try
{
_postgresAvailable = _postgresDb.Database.CanConnect();
var status = _postgresAvailable ? "AVAILABLE" : "UNAVAILABLE";
Console.WriteLine($"[StatsService] PostgreSQL status: {status}");
}
catch (Exception ex)
{
Console.WriteLine($"[StatsService] PostgreSQL connection failed: {ex.Message}");
}
}
else
{
Console.WriteLine("[StatsService] PostgreSQL not configured - using SQLite only");
Console.WriteLine($"[StatsService] Database error: {_db.InitializationError}");
}
}
/// <summary>
/// Registra il completamento di un'asta (sia su PostgreSQL che SQLite)
/// 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, bool won)
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 = auction.LastState?.Price ?? 0;
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;
@@ -67,9 +148,14 @@ namespace AutoBidder.Services
{
totalCost = finalPrice + moneySpent + shippingCost;
savings = (buyNowPrice.Value + shippingCost) - totalCost.Value;
Console.WriteLine($"[StatsService] Costo totale: €{totalCost:F2}");
Console.WriteLine($"[StatsService] Risparmio: €{savings:F2}");
}
// Salva su SQLite (sempre)
Console.WriteLine($"[StatsService] Salvataggio nel database...");
// Salva risultato asta con tutti i campi
await _db.SaveAuctionResultAsync(
auction.AuctionId,
auction.Name,
@@ -79,9 +165,16 @@ namespace AutoBidder.Services
buyNowPrice,
shippingCost,
totalCost,
savings
savings,
winnerUsername,
totalResets,
winnerBidsUsed,
productKey
);
Console.WriteLine($"[StatsService] ? Risultato asta salvato");
// Aggiorna statistiche giornaliere
await _db.SaveDailyStatAsync(
today,
bidsUsed,
@@ -89,229 +182,159 @@ namespace AutoBidder.Services
won ? 1 : 0,
won ? 0 : 1,
savings ?? 0,
auction.LastState?.PollingLatencyMs
state.PollingLatencyMs
);
// Salva su PostgreSQL se disponibile
if (_postgresAvailable && _postgresDb != null)
Console.WriteLine($"[StatsService] ? Statistiche giornaliere aggiornate");
// Aggiorna statistiche aggregate per prodotto
if (_productStatsService != null)
{
await SaveToPostgresAsync(auction, won, finalPrice, bidsUsed, totalCost, savings);
Console.WriteLine($"[StatsService] Aggiornamento statistiche prodotto (key: {productKey})...");
await _productStatsService.UpdateProductStatisticsAsync(productKey, auction.Name);
Console.WriteLine($"[StatsService] ? Statistiche prodotto aggiornate");
}
Console.WriteLine($"[StatsService] Recorded auction {auction.Name} - Won: {won}, Bids: {bidsUsed}, Savings: {savings:F2}€");
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>
/// Salva asta conclusa su PostgreSQL
/// Scarica l'HTML della pagina dell'asta e estrae le puntate del vincitore
/// </summary>
private async Task SaveToPostgresAsync(AuctionInfo auction, bool won, double finalPrice, int bidsUsed, double? totalCost, double? savings)
private async Task<int?> ScrapeWinnerBidsFromAuctionPageAsync(string auctionUrl)
{
if (_postgresDb == null) return;
try
{
var completedAuction = new CompletedAuction
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)
{
AuctionId = auction.AuctionId,
ProductName = auction.Name,
FinalPrice = (decimal)finalPrice,
BuyNowPrice = auction.BuyNowPrice.HasValue ? (decimal)auction.BuyNowPrice.Value : null,
ShippingCost = auction.ShippingCost.HasValue ? (decimal)auction.ShippingCost.Value : null,
TotalBids = auction.LastState?.MyBidsCount ?? bidsUsed, // Usa MyBidsCount se disponibile
MyBidsCount = bidsUsed,
ResetCount = auction.ResetCount,
Won = won,
WinnerUsername = won ? "ME" : auction.LastState?.LastBidder,
CompletedAt = DateTime.UtcNow,
AverageLatency = auction.LastState != null ? (decimal)auction.LastState.PollingLatencyMs : null, // PollingLatencyMs è int, non nullable
Savings = savings.HasValue ? (decimal)savings.Value : null,
TotalCost = totalCost.HasValue ? (decimal)totalCost.Value : null,
CreatedAt = DateTime.UtcNow
};
_postgresDb.CompletedAuctions.Add(completedAuction);
await _postgresDb.SaveChangesAsync();
// Aggiorna statistiche prodotto
await UpdateProductStatisticsAsync(auction, won, bidsUsed, finalPrice);
// Aggiorna metriche giornaliere
await UpdateDailyMetricsAsync(DateTime.UtcNow.Date, bidsUsed, auction.BidCost, won, savings ?? 0);
Console.WriteLine($"[PostgreSQL] Saved auction {auction.Name} to PostgreSQL");
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($"[PostgreSQL ERROR] Failed to save auction: {ex.Message}");
Console.WriteLine($"[StatsService] ? Errore generico durante scraping: {ex.Message}");
return null;
}
}
/// <summary>
/// Aggiorna statistiche prodotto in PostgreSQL
/// Estrae le puntate usate dall'HTML (stesso algoritmo di ClosedAuctionsScraper)
/// </summary>
private async Task UpdateProductStatisticsAsync(AuctionInfo auction, bool won, int bidsUsed, double finalPrice)
private int? ExtractBidsUsedFromHtml(string html)
{
if (_postgresDb == null) return;
if (string.IsNullOrEmpty(html)) return null;
try
{
var productKey = GenerateProductKey(auction.Name);
var stat = await _postgresDb.ProductStatistics.FirstOrDefaultAsync(p => p.ProductKey == productKey);
// 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 (stat == null)
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val1))
{
stat = new ProductStatistic
{
ProductKey = productKey,
ProductName = auction.Name,
TotalAuctions = 0,
MinBidsSeen = int.MaxValue,
MaxBidsSeen = 0,
CompetitionLevel = "Medium"
};
_postgresDb.ProductStatistics.Add(stat);
Console.WriteLine($"[StatsService] Puntate estratte (metodo 1 - bids-used class): {val1}");
return val1;
}
stat.TotalAuctions++;
stat.AverageFinalPrice = ((stat.AverageFinalPrice * (stat.TotalAuctions - 1)) + (decimal)finalPrice) / stat.TotalAuctions;
stat.AverageResets = ((stat.AverageResets * (stat.TotalAuctions - 1)) + auction.ResetCount) / stat.TotalAuctions;
// 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 (won)
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val2))
{
stat.AverageWinningBids = ((stat.AverageWinningBids * Math.Max(1, stat.TotalAuctions - 1)) + bidsUsed) / stat.TotalAuctions;
Console.WriteLine($"[StatsService] Puntate estratte (metodo 2 - pattern testo): {val2}");
return val2;
}
stat.MinBidsSeen = Math.Min(stat.MinBidsSeen, bidsUsed);
stat.MaxBidsSeen = Math.Max(stat.MaxBidsSeen, bidsUsed);
stat.RecommendedMaxBids = (int)(stat.AverageWinningBids * 1.5m); // 50% buffer
stat.RecommendedMaxPrice = stat.AverageFinalPrice * 1.2m; // 20% buffer
stat.LastUpdated = DateTime.UtcNow;
// 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);
// Determina livello competizione
if (stat.AverageWinningBids > 50) stat.CompetitionLevel = "High";
else if (stat.AverageWinningBids < 20) stat.CompetitionLevel = "Low";
else stat.CompetitionLevel = "Medium";
await _postgresDb.SaveChangesAsync();
}
catch (Exception ex)
if (match.Success && int.TryParse(match.Groups["n"].Value, out var val3))
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to update product statistics: {ex.Message}");
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>
/// Aggiorna metriche giornaliere in PostgreSQL
/// Ottiene i limiti consigliati per un prodotto
/// </summary>
private async Task UpdateDailyMetricsAsync(DateTime date, int bidsUsed, double bidCost, bool won, double savings)
public async Task<RecommendedLimits?> GetRecommendedLimitsAsync(string productName)
{
if (_postgresDb == null) return;
if (_productStatsService == null) return null;
try
{
var metric = await _postgresDb.DailyMetrics.FirstOrDefaultAsync(m => m.Date.Date == date.Date);
if (metric == null)
{
metric = new DailyMetric { Date = date.Date };
_postgresDb.DailyMetrics.Add(metric);
}
metric.TotalBidsUsed += bidsUsed;
metric.MoneySpent += (decimal)(bidsUsed * bidCost);
if (won) metric.AuctionsWon++; else metric.AuctionsLost++;
metric.TotalSavings += (decimal)savings;
var totalAuctions = metric.AuctionsWon + metric.AuctionsLost;
if (totalAuctions > 0)
{
metric.WinRate = ((decimal)metric.AuctionsWon / totalAuctions) * 100;
}
if (metric.MoneySpent > 0)
{
metric.ROI = (metric.TotalSavings / metric.MoneySpent) * 100;
}
await _postgresDb.SaveChangesAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to update daily metrics: {ex.Message}");
}
var productKey = ProductStatisticsService.GenerateProductKey(productName);
return await _productStatsService.GetRecommendedLimitsAsync(productKey);
}
/// <summary>
/// Genera chiave univoca per prodotto
/// Ottiene tutte le statistiche prodotto
/// </summary>
private string GenerateProductKey(string productName)
public async Task<List<ProductStatisticsRecord>> GetAllProductStatisticsAsync()
{
var normalized = productName.ToLowerInvariant()
.Replace(" ", "_")
.Replace("-", "_");
return System.Text.RegularExpressions.Regex.Replace(normalized, "[^a-z0-9_]", "");
if (_productStatsService == null) return new List<ProductStatisticsRecord>();
return await _productStatsService.GetAllProductStatisticsAsync();
}
/// <summary>
/// Ottiene raccomandazioni strategiche da PostgreSQL
/// </summary>
public async Task<List<StrategicInsight>> GetStrategicInsightsAsync(string? productKey = null)
{
if (!_postgresAvailable || _postgresDb == null)
{
return new List<StrategicInsight>();
}
try
{
var query = _postgresDb.StrategicInsights.Where(i => i.IsActive);
if (!string.IsNullOrEmpty(productKey))
{
query = query.Where(i => i.ProductKey == productKey);
}
return await query.OrderByDescending(i => i.ConfidenceLevel).ToListAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to get insights: {ex.Message}");
return new List<StrategicInsight>();
}
}
/// <summary>
/// Ottiene performance puntatori da PostgreSQL
/// </summary>
public async Task<List<BidderPerformance>> GetTopCompetitorsAsync(int limit = 10)
{
if (!_postgresAvailable || _postgresDb == null)
{
return new List<BidderPerformance>();
}
try
{
return await _postgresDb.BidderPerformances
.OrderByDescending(b => b.WinRate)
.Take(limit)
.ToListAsync();
}
catch (Exception ex)
{
Console.WriteLine($"[PostgreSQL ERROR] Failed to get competitors: {ex.Message}");
return new List<BidderPerformance>();
}
}
// Metodi esistenti per compatibilità SQLite
// 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);
@@ -319,6 +342,11 @@ namespace AutoBidder.Services
public async Task<TotalStats> GetTotalStatsAsync()
{
if (!IsAvailable)
{
return new TotalStats();
}
var stats = await GetDailyStatsAsync(365);
return new TotalStats
@@ -338,13 +366,23 @@ namespace AutoBidder.Services
};
}
public async Task<List<AuctionResult>> GetRecentAuctionResultsAsync(int limit = 50)
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)
@@ -355,11 +393,22 @@ namespace AutoBidder.Services
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");
@@ -387,11 +436,6 @@ namespace AutoBidder.Services
Savings = allDates.Select(s => s.TotalSavings).ToList()
};
}
/// <summary>
/// Indica se il database PostgreSQL è disponibile
/// </summary>
public bool IsPostgresAvailable => _postgresAvailable;
}
// Classi esistenti per compatibilità

View File

@@ -24,11 +24,6 @@
<span>Esplora Aste</span>
</NavLink>
<NavLink class="nav-menu-item" href="freebids">
<i class="bi bi-gift"></i>
<span>Puntate Gratuite</span>
</NavLink>
<NavLink class="nav-menu-item" href="statistics">
<i class="bi bi-bar-chart"></i>
<span>Statistiche</span>

View File

@@ -71,26 +71,34 @@ namespace AutoBidder.Utilities
/// </summary>
public string MinLogLevel { get; set; } = "Normal";
// CONFIGURAZIONE DATABASE POSTGRESQL
/// <summary>
/// Abilita l'uso di PostgreSQL per statistiche avanzate
/// </summary>
public bool UsePostgreSQL { get; set; } = true;
// ???????????????????????????????????????????????????????????????
// IMPOSTAZIONI DATABASE
// ???????????????????????????????????????????????????????????????
/// <summary>
/// Connection string PostgreSQL
/// Abilita il salvataggio automatico delle aste completate nel database.
/// Default: true (consigliato per statistiche)
/// </summary>
public string PostgresConnectionString { get; set; } = "Host=localhost;Port=5432;Database=autobidder_stats;Username=autobidder;Password=autobidder_password";
public bool DatabaseAutoSaveEnabled { get; set; } = true;
/// <summary>
/// Auto-crea schema database se mancante
/// Esegue pulizia automatica duplicati all'avvio dell'applicazione.
/// Default: true (consigliato per mantenere database pulito)
/// </summary>
public bool AutoCreateDatabaseSchema { get; set; } = true;
public bool DatabaseAutoCleanupDuplicates { get; set; } = true;
/// <summary>
/// Fallback automatico a SQLite se PostgreSQL non disponibile
/// Esegue pulizia automatica record incompleti all'avvio.
/// Default: false (può rimuovere dati utili in caso di errori temporanei)
/// </summary>
public bool FallbackToSQLite { get; set; } = true;
public bool DatabaseAutoCleanupIncomplete { get; set; } = false;
/// <summary>
/// Numero massimo di giorni da mantenere nei risultati aste.
/// Record più vecchi vengono eliminati automaticamente.
/// Default: 180 (6 mesi), 0 = disabilitato
/// </summary>
public int DatabaseMaxRetentionDays { get; set; } = 180;
}
public static class SettingsManager

View File

@@ -1,31 +1,6 @@
version: '3.8'
services:
# ================================================
# PostgreSQL Database (statistiche avanzate)
# ================================================
postgres:
image: postgres:16-alpine
container_name: autobidder-postgres
environment:
POSTGRES_DB: autobidder_stats
POSTGRES_USER: ${POSTGRES_USER:-autobidder}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-autobidder_password}
POSTGRES_INITDB_ARGS: --encoding=UTF8
volumes:
- postgres-data:/var/lib/postgresql/data
- ./postgres-backups:/backups
ports:
- "5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-autobidder} -d autobidder_stats"]
interval: 10s
timeout: 5s
retries: 5
networks:
- autobidder-network
# ================================================
# AutoBidder Application
# ================================================
@@ -37,37 +12,29 @@ services:
BUILD_CONFIGURATION: Release
image: gitea.encke-hake.ts.net/alby96/autobidder:latest
container_name: autobidder
depends_on:
postgres:
condition: service_healthy
ports:
- "${APP_PORT:-5000}:8080" # Host:Container (HTTP only)
volumes:
# Persistent data (SQLite, backups, logs)
# Persistent data (SQLite databases, backups, logs, keys)
# Tutti i dati persistenti sono salvati in questo volume
- ./Data:/app/Data
# PostgreSQL backups
- ./postgres-backups:/app/Data/backups
environment:
# ASP.NET Core
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:8080
# ============================================
# DATABASE PATH - Volume persistente Docker
# ============================================
# Tutti i database SQLite e dati persistenti usano questo path
- DATA_PATH=/app/Data
# Autenticazione applicazione (SICUREZZA)
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
# PostgreSQL connection
- ConnectionStrings__PostgreSQL=Host=postgres;Port=5432;Database=autobidder_stats;Username=${POSTGRES_USER:-autobidder};Password=${POSTGRES_PASSWORD:-autobidder_password}
# Database settings
- Database__UsePostgres=${USE_POSTGRES:-true}
- Database__AutoCreateSchema=true
- Database__FallbackToSQLite=true
# Logging
- Logging__LogLevel__Default=${LOG_LEVEL:-Information}
- Logging__LogLevel__Microsoft.EntityFrameworkCore=Warning
# Timezone
- TZ=Europe/Rome
@@ -81,10 +48,6 @@ services:
networks:
- autobidder-network
volumes:
postgres-data:
driver: local
networks:
autobidder-network:
driver: bridge

View File

@@ -1,4 +1,33 @@
/* === MODERN PAGE STYLES (append to app-wpf.css) === */
/* === MODERN PAGE STYLES (append to app-wpf.css) === */
/* ✅ NUOVO: Stili per selezione riga e colonna puntate */
.table-hover tbody tr {
cursor: pointer;
transition: all 0.2s ease;
}
.table-hover tbody tr.selected-row {
background-color: rgba(76, 175, 80, 0.15) !important;
border-left: 4px solid #4CAF50;
}
.table-hover tbody tr:hover:not(.selected-row) {
background-color: rgba(255, 255, 255, 0.05);
}
/* Colonna Puntate - testo grassetto e leggibile */
.bids-column {
font-weight: bold !important;
color: var(--text-primary) !important;
}
/* Larghezza colonna puntate leggermente maggiore */
.col-click {
min-width: 85px;
width: 85px;
white-space: nowrap;
}
.page-header {
display: flex;
align-items: center;
@@ -458,3 +487,174 @@
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* === AUCTION MONITOR STATUS BADGES === */
/* Base badge styling for auction status - COMPACT */
.col-stato .badge {
font-size: 0.65rem;
font-weight: 600;
padding: 0.25rem 0.45rem;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 0.25rem;
min-width: 65px;
justify-content: center;
text-transform: uppercase;
letter-spacing: 0.2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
white-space: nowrap;
}
/* Icon inside badge - smaller */
.col-stato .badge i {
font-size: 0.7rem;
}
/* === USER-CONTROLLED STATES === */
/* Active - Monitoring enabled, bright green */
.col-stato .badge.status-active {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: white;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4), 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* Paused by user - Orange */
.col-stato .badge.status-paused {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: #1a1a1a;
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* Stopped by user - Gray */
.col-stato .badge.status-stopped {
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);
color: #e5e5e5;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* Attacking/Bidding - Electric blue with glow */
.col-stato .badge.status-attacking {
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
color: white;
box-shadow: 0 0 12px rgba(59, 130, 246, 0.6), 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* === AUCTION LIFECYCLE STATES === */
/* Won - Gold/Yellow celebration */
.col-stato .badge.status-won {
background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%);
color: #1a1a1a;
box-shadow: 0 0 12px rgba(251, 191, 36, 0.6), 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* Lost - Red muted */
.col-stato .badge.status-lost {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
opacity: 0.85;
}
/* Closed - Dark gray */
.col-stato .badge.status-closed {
background: linear-gradient(135deg, #374151 0%, #1f2937 100%);
color: #9ca3af;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* System Paused (night pause) - Purple/Indigo */
.col-stato .badge.status-system-paused {
background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
color: white;
box-shadow: 0 0 6px rgba(139, 92, 246, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* Pending/Waiting - Cyan */
.col-stato .badge.status-pending {
background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%);
color: white;
box-shadow: 0 0 6px rgba(6, 182, 212, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* Scheduled - Teal */
.col-stato .badge.status-scheduled {
background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%);
color: white;
box-shadow: 0 0 6px rgba(20, 184, 166, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* === ANIMATIONS === */
/* Active pulse - subtle */
.col-stato .badge.status-anim-active {
animation: statusPulseActive 2.5s ease-in-out infinite;
}
@keyframes statusPulseActive {
0%, 100% {
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4), 0 1px 3px rgba(0, 0, 0, 0.2);
}
50% {
box-shadow: 0 0 14px rgba(34, 197, 94, 0.7), 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
/* Paused blink */
.col-stato .badge.status-anim-paused {
animation: statusBlink 1.5s ease-in-out infinite;
}
@keyframes statusBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0.65; }
}
/* Attacking - fast pulse */
.col-stato .badge.status-anim-attacking {
animation: statusAttacking 0.5s ease-in-out infinite;
}
@keyframes statusAttacking {
0%, 100% {
box-shadow: 0 0 12px rgba(59, 130, 246, 0.6), 0 1px 3px rgba(0, 0, 0, 0.2);
transform: scale(1);
}
50% {
box-shadow: 0 0 20px rgba(59, 130, 246, 0.9), 0 1px 3px rgba(0, 0, 0, 0.2);
transform: scale(1.02);
}
}
/* Won celebration */
.col-stato .badge.status-anim-won {
animation: statusWon 1s ease-in-out infinite;
}
@keyframes statusWon {
0%, 100% {
box-shadow: 0 0 12px rgba(251, 191, 36, 0.6), 0 1px 3px rgba(0, 0, 0, 0.2);
}
50% {
box-shadow: 0 0 20px rgba(251, 191, 36, 0.9), 0 1px 3px rgba(0, 0, 0, 0.2);
}
}
/* Legacy Bootstrap classes override for backward compatibility */
.col-stato .badge.bg-success {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%) !important;
color: white !important;
}
.col-stato .badge.bg-warning {
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%) !important;
color: #1a1a1a !important;
}
.col-stato .badge.bg-secondary {
background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%) !important;
color: #e5e5e5 !important;
}