Supporto per aste chiuse e miglioramenti UI

- Aggiornamento alla versione Microsoft.EntityFrameworkCore.Sqlite 8.0.0.
- Aggiornamento alla versione Microsoft.Windows.SDK.BuildTools 10.0.26100.6584.
- Migliorata l'interfaccia per l'inserimento di più URL/ID di aste.
- Aggiunti pulsanti per "Aste Chiuse" e "Esporta" in MainWindow.
- Creata finestra "Aste Chiuse" per visualizzare e gestire aste chiuse.
- Implementato scraper per estrarre dati da aste chiuse.
- Aggiunto supporto per esportazione dati in CSV, JSON e XML.
- Introdotto contesto Entity Framework per statistiche delle aste.
- Aggiunto servizio per calcolo e gestione delle statistiche.
- Gestite preferenze di esportazione con salvataggio in file JSON.
This commit is contained in:
Alberto Balbo
2025-11-03 14:24:19 +01:00
parent 59d7e0c41f
commit 967005b96a
13 changed files with 1128 additions and 42 deletions

View File

@@ -22,6 +22,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6584" />
</ItemGroup>

View File

@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
using AutoBidder.Models;
namespace AutoBidder.Data
{
public class StatisticsContext : DbContext
{
public DbSet<ProductStat> ProductStats { get; set; }
public StatisticsContext(DbContextOptions<StatisticsContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ProductStat>()
.HasIndex(p => p.ProductKey)
.IsUnique(false);
base.OnModelCreating(modelBuilder);
}
}
}

View File

@@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<Window x:Class="AutoBidder.Dialogs.AddAuctionSimpleDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Aggiungi Asta" Height="220" Width="600"
Title="Aggiungi Asta" Height="320" Width="700"
Background="#0a0a0a" Foreground="#FFFFFF"
WindowStartupLocation="CenterOwner"
Icon="pack://application:,,,/Icon/favicon.ico"
@@ -11,13 +12,19 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Text="Inserire URL dell'asta" Foreground="#CCCCCC" FontSize="14" Margin="0,0,0,10" />
<TextBox x:Name="AuctionUrlBox" Grid.Row="1" MinWidth="320" Margin="0,0,0,8"
<TextBlock Text="Inserire URL dell'asta (uno o pi&#x00F9;)" Foreground="#CCCCCC" FontSize="14" Margin="0,0,0,6" />
<TextBlock Grid.Row="1" Text="Puoi aggiungere pi&#x00F9; link separandoli con 'a capo', 'spazio' o ';'" Foreground="#999999" FontSize="12" Margin="0,0,0,10" />
<TextBox x:Name="AuctionUrlBox" Grid.Row="2" MinWidth="560" Margin="0,0,0,8"
Background="#181818" Foreground="#00CCFF" BorderBrush="#444" BorderThickness="1"
Padding="8" FontSize="13" ToolTip="Inserisci l'URL completo dell'asta" />
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
Padding="8" FontSize="13" ToolTip="Inserisci uno o pi&#x00F9; URL/ID dell'asta. Separali con a capo, spazio o ';'"
AcceptsReturn="True" TextWrapping="Wrap" VerticalScrollBarVisibility="Auto" Height="160" />
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<Button x:Name="OkButton" Content="OK" Width="110" Margin="6" Padding="10,8"
Style="{StaticResource SmallButtonStyle}" Background="#00CC66" Foreground="White" Click="OkButton_Click" />
<Button x:Name="CancelButton" Content="Annulla" Width="110" Margin="6" Padding="10,8"

View File

@@ -13,13 +13,15 @@ namespace AutoBidder.Dialogs
private void OkButton_Click(object sender, RoutedEventArgs e)
{
var text = AuctionUrlBox.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(text) || !text.StartsWith("http"))
var text = AuctionUrlBox.Text ?? string.Empty;
if (string.IsNullOrWhiteSpace(text))
{
MessageBox.Show("Inserisci un URL valido dell'asta.", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
MessageBox.Show("Inserisci almeno un URL o ID dell'asta.", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
AuctionId = text;
// Return the raw text (may contain multiple entries); caller will parse it
AuctionId = text.Trim();
DialogResult = true;
Close();
}

View File

@@ -0,0 +1,89 @@
<Window x:Class="AutoBidder.Dialogs.ClosedAuctionsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Aste Chiuse - Estrazione" Height="600" Width="1000"
Background="#0a0a0a" Foreground="#FFFFFF" WindowStartupLocation="CenterOwner">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="8" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Background="#1a1a1a" Padding="10" CornerRadius="6" Grid.Row="0" BorderBrush="#333333" BorderThickness="1">
<DockPanel>
<TextBlock Text="Estrazione Aste Chiuse" FontSize="16" FontWeight="Bold" Foreground="#00CC66" VerticalAlignment="Center" DockPanel.Dock="Left" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" DockPanel.Dock="Right">
<Button x:Name="StartExtractButton" Content="Avvia Estrazione" Click="StartExtractButton_Click" Width="140" Height="36" Margin="8,0,0,0" Background="#00CC66" Style="{StaticResource SmallButtonStyle}"/>
<Button x:Name="ExportStatsButton" Content="Esporta Statistiche" Click="ExportStatsButton_Click" Width="160" Height="36" Margin="8,0,0,0" Background="#8B5CF6" Style="{StaticResource SmallButtonStyle}"/>
<Button x:Name="CloseButton" Content="Chiudi" Click="CloseButton_Click" Width="80" Height="36" Margin="8,0,0,0" Background="#666" Style="{StaticResource SmallButtonStyle}"/>
</StackPanel>
</DockPanel>
</Border>
<Border Grid.Row="2" Background="#1a1a1a" Padding="8" CornerRadius="6" BorderBrush="#333333" BorderThickness="1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2*" />
<ColumnDefinition Width="8" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Products grid: styled like MainWindow -->
<DataGrid x:Name="ProductsGrid" AutoGenerateColumns="False" Grid.Column="0" Background="#1a1a1a" Foreground="#FFFFFF" BorderBrush="#333333" BorderThickness="1"
RowBackground="#1a1a1a" AlternatingRowBackground="#222222" GridLinesVisibility="Horizontal" HorizontalGridLinesBrush="#333333"
IsReadOnly="True" SelectionUnit="CellOrRowHeader" SelectionMode="Extended" ClipboardCopyMode="IncludeHeader" CanUserAddRows="False" CanUserDeleteRows="False">
<DataGrid.Resources>
<Style TargetType="DataGridColumnHeader">
<Setter Property="Background" Value="#2a2a2a" />
<Setter Property="Foreground" Value="#FFFFFF" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Padding" Value="10,8" />
<Setter Property="BorderThickness" Value="0,0,0,2" />
<Setter Property="BorderBrush" Value="#00CC66" />
</Style>
<Style TargetType="DataGridRow">
<Setter Property="Height" Value="36" />
<Style.Triggers>
<Trigger Property="IsSelected" Value="True">
<Setter Property="Background" Value="#0099FF" />
<Setter Property="Foreground" Value="White" />
</Trigger>
</Style.Triggers>
</Style>
<Style TargetType="DataGridCell">
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="10,6" />
</Style>
</DataGrid.Resources>
<DataGrid.ContextMenu>
<ContextMenu>
<MenuItem Command="ApplicationCommands.Copy" Header="Copia" />
</ContextMenu>
</DataGrid.ContextMenu>
<DataGrid.Columns>
<DataGridTextColumn Header="Asta URL" Binding="{Binding AuctionUrl}" Width="2*" />
<DataGridTextColumn Header="Nome" Binding="{Binding ProductName}" Width="3*"/>
<DataGridTextColumn Header="Prezzo" Binding="{Binding FinalPrice}" Width="80"/>
<DataGridTextColumn Header="Vincitore" Binding="{Binding Winner}" Width="120"/>
<DataGridTextColumn Header="Puntate Usate" Binding="{Binding BidsUsed}" Width="100"/>
<DataGridTextColumn Header="Scraped At" Binding="{Binding ScrapedAt}" Width="140"/>
</DataGrid.Columns>
</DataGrid>
<GridSplitter Grid.Column="1" Width="8" />
<!-- Log area styled like main window log -->
<Border Grid.Column="2" Background="#0f0f0f" Padding="8" CornerRadius="6" BorderBrush="#333333" BorderThickness="1">
<DockPanel>
<TextBlock Text="Log Operazioni" FontWeight="Bold" Foreground="#00CC66" DockPanel.Dock="Top" Margin="0,0,0,8" />
<RichTextBox x:Name="ExtractLogBox" IsReadOnly="True" VerticalScrollBarVisibility="Auto" FontFamily="Consolas" FontSize="11" Background="#0f0f0f" Foreground="#CCC" BorderBrush="#333333" BorderThickness="1" />
</DockPanel>
</Border>
</Grid>
</Border>
</Grid>
</Window>

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Documents;
using Microsoft.Win32;
using AutoBidder.Models;
using AutoBidder.Services;
using AutoBidder.Data;
using Microsoft.EntityFrameworkCore;
namespace AutoBidder.Dialogs
{
public partial class ClosedAuctionsWindow : Window
{
private ObservableCollection<ClosedAuctionRecord> _products = new();
private bool _isRunning = false;
// StatsService using local DB. Create context with default sqlite file in app folder
private readonly StatsService _statsService;
public ClosedAuctionsWindow()
{
InitializeComponent();
ProductsGrid.ItemsSource = _products;
var optionsBuilder = new DbContextOptionsBuilder<StatisticsContext>();
var dbPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "stats.db");
optionsBuilder.UseSqlite($"Data Source={dbPath}");
var ctx = new StatisticsContext(optionsBuilder.Options);
_statsService = new StatsService(ctx);
Log("Finestra pronta");
}
private void Log(string message)
{
try
{
var para = new Paragraph(new Run($"{DateTime.Now:HH:mm} - {message}"));
ExtractLogBox.Document.Blocks.Add(para);
// keep size manageable
while (ExtractLogBox.Document.Blocks.Count > 500)
ExtractLogBox.Document.Blocks.Remove(ExtractLogBox.Document.Blocks.FirstBlock);
ExtractLogBox.ScrollToEnd();
}
catch { }
}
private async void StartExtractButton_Click(object sender, RoutedEventArgs e)
{
if (_isRunning)
{
Log("Estrazione già in corso");
return;
}
_isRunning = true;
StartExtractButton.IsEnabled = false;
Log("Avvio procedura di estrazione da closed_auctions.php...");
try
{
var scraper = new ClosedAuctionsScraper(null, _statsService, Log);
var closedUrl = "https://it.bidoo.com/closed_auctions.php";
Log($"Scarico: {closedUrl}");
int count = 0;
await foreach (var rec in scraper.ScrapeYieldAsync(closedUrl))
{
// Filter out records without bids info (user requested)
if (!rec.BidsUsed.HasValue)
{
Log($"Scartata asta (mancano puntate): {rec.AuctionUrl} - '{rec.ProductName ?? "?"}'");
continue;
}
// Add and log incrementally so user sees progress
_products.Add(rec);
count++;
Log($"[{count}] {rec.ProductName} | Prezzo: {(rec.FinalPrice.HasValue?rec.FinalPrice.Value.ToString("F2")+"":"--")} | Vincitore: {rec.Winner ?? "--"} | Puntate: {rec.BidsUsed.Value} | URL: {rec.AuctionUrl}");
}
Log($"Estrazione completata: {count} record aggiunti.");
}
catch (Exception ex)
{
Log($"[ERRORE] Estrattore: {ex.Message}");
}
finally
{
_isRunning = false;
StartExtractButton.IsEnabled = true;
}
}
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
this.Close();
}
private async void ExportStatsButton_Click(object sender, RoutedEventArgs e)
{
try
{
Log("Preparazione esportazione statistiche...");
var stats = await _statsService.GetAllStatsAsync();
if (stats == null || stats.Count == 0)
{
Log("Nessuna statistica disponibile da esportare.");
MessageBox.Show(this, "Nessuna statistica disponibile.", "Esporta Statistiche", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var dlg = new SaveFileDialog() { Filter = "CSV files|*.csv|All files|*.*", FileName = "auction_stats.csv" };
if (dlg.ShowDialog(this) != true) return;
using var sw = new StreamWriter(dlg.FileName, false, System.Text.Encoding.UTF8);
sw.WriteLine("ProductKey,ProductName,TotalAuctions,AverageBidsUsed,AverageFinalPrice,LastSeen");
foreach (var s in stats)
{
var line = $"\"{s.ProductKey}\",\"{s.ProductName}\",{s.TotalAuctions},{s.AverageBidsUsed:F2},{s.AverageFinalPrice:F2},{s.LastSeen:O}";
sw.WriteLine(line);
}
Log($"Statistiche esportate su: {dlg.FileName}");
MessageBox.Show(this, "Statistiche esportate con successo.", "Esporta Statistiche", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
Log($"[ERRORE] Esporta: {ex.Message}");
MessageBox.Show(this, "Errore durante esportazione: " + ex.Message, "Esporta Statistiche", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
}
}

View File

@@ -185,6 +185,12 @@
<Button x:Name="ConfigSessionButton" Content="Configura" Click="ConfigSessionButton_Click"
Style="{StaticResource SmallButtonStyle}"
Background="#8B5CF6" Padding="20,10" Margin="0,0,6,0" Height="40" MinWidth="110" />
<!-- separator between Config and action group -->
<Border Width="2" Height="36" Background="#333333" Margin="12,0" VerticalAlignment="Center" CornerRadius="2" />
<!-- Group: Start / Pause / Stop -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="StartButton" Content="Avvia Tutti" Command="{Binding StartAllCommand}" IsEnabled="False"
Style="{StaticResource SmallButtonStyle}"
Background="#00CC66" Padding="20,10" Margin="0,0,6,0" Height="40" MinWidth="110" />
@@ -195,6 +201,20 @@
Style="{StaticResource SmallButtonStyle}"
Background="#CC0000" Padding="20,10" Opacity="0.5" Height="40" MinWidth="110" />
</StackPanel>
<!-- separator graphic -->
<Border Width="2" Height="36" Background="#333333" Margin="12,0" VerticalAlignment="Center" CornerRadius="2" />
<!-- Group: Closed auctions + Free bids (free bids disabled for now) -->
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="ClosedAuctionsButton" Content="Aste Chiuse" Click="OpenClosedAuctionsButton_Click"
Style="{StaticResource SmallButtonStyle}"
Background="#0099FF" Padding="20,10" Margin="0,0,6,0" Height="40" MinWidth="120" />
<Button x:Name="FreeBidsButton" Content="Puntate Gratis" IsEnabled="False" ToolTip="Funzionalita in sviluppo"
Style="{StaticResource SmallButtonStyle}"
Background="#666" Padding="20,10" Height="40" MinWidth="140" />
</StackPanel>
</StackPanel>
</Grid>
</Border>
@@ -281,7 +301,7 @@
</DataGrid.Resources>
<DataGrid.Columns>
<DataGridTextColumn Header="Asta" Binding="{Binding Name, Mode=OneWay}" Width="2*" IsReadOnly="True" />
<DataGridTextColumn Header="Latenza (ms)" Binding="{Binding AuctionInfo.PollingLatencyMs, Mode=OneWay}" Width="80" IsReadOnly="True" />
<DataGridTextColumn Header="Latenza" Binding="{Binding AuctionInfo.PollingLatencyMs, Mode=OneWay}" Width="80" IsReadOnly="True" />
<DataGridTextColumn Header="Stato" Binding="{Binding StatusDisplay, Mode=OneWay}" Width="90" IsReadOnly="True" />
<DataGridTextColumn Header="Timer" Binding="{Binding TimerDisplay, Mode=OneWay}" Width="70" IsReadOnly="True" />
<DataGridTextColumn Header="Prezzo" Binding="{Binding PriceDisplay, Mode=OneWay}" Width="85" IsReadOnly="True" />
@@ -365,10 +385,12 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox x:Name="SelectedAuctionUrl" Text="" IsReadOnly="True" MinWidth="220" Margin="0,0,8,0" VerticalAlignment="Center" Background="#181818" Foreground="#00CCFF" BorderBrush="#333" BorderThickness="1" FontSize="11" Grid.Column="0" HorizontalAlignment="Stretch" />
<Button Content="Apri" x:Name="OpenAuctionButton" Click="GridOpenAuction_Click" Style="{StaticResource SmallButtonStyle}" Background="#0099FF" Padding="10,6" Margin="0,0,4,0" Height="28" MinWidth="60" FontSize="11" Grid.Column="1" />
<Button Content="Copia" x:Name="CopyAuctionUrlButton" Click="CopyAuctionUrlButton_Click" Style="{StaticResource SmallButtonStyle}" Background="#666" Padding="10,6" Height="28" MinWidth="60" FontSize="11" Grid.Column="2" />
<Button Content="Esporta" x:Name="ExportAuctionButton" Click="ExportSelectedAuction_Click" Style="{StaticResource SmallButtonStyle}" Background="#8B5CF6" Padding="10,6" Height="28" MinWidth="80" FontSize="11" Grid.Column="3" Margin="6,0,0,0" />
</Grid>
<UniformGrid Columns="2" Margin="0,0,0,8">

View File

@@ -3,6 +3,8 @@ using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Xml.Linq;
using System.Globalization;
using AutoBidder.Models;
using AutoBidder.Services;
using AutoBidder.ViewModels;
@@ -90,6 +92,21 @@ namespace AutoBidder
_ = UpdateUserHtmlInfoAsync();
}
// New button handler to open ClosedAuctionsWindow
private void OpenClosedAuctionsButton_Click(object sender, RoutedEventArgs e)
{
try
{
var win = new ClosedAuctionsWindow();
win.Owner = this;
win.ShowDialog();
}
catch (Exception ex)
{
Log($"[ERRORE] Apri Aste Chiuse: {ex.Message}");
}
}
// Command implementations
private void ExecuteStartAll()
{
@@ -349,7 +366,62 @@ namespace AutoBidder
var dialog = new AddAuctionSimpleDialog();
if (dialog.ShowDialog() == true)
{
await AddAuctionById(dialog.AuctionId);
var raw = dialog.AuctionId ?? string.Empty;
// Split by newline, semicolon or whitespace sequences
var parts = System.Text.RegularExpressions.Regex.Split(raw, "[\r\n;]+|\\s+")
.Select(p => p.Trim())
.Where(p => !string.IsNullOrWhiteSpace(p))
.ToList();
if (parts.Count ==0)
{
MessageBox.Show("Nessun URL/ID valido trovato.", "Errore", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
var added =0;
var skipped = new List<string>();
foreach (var part in parts)
{
try
{
// If part looks like a plain numeric ID, pass as-is; otherwise pass full URL
var input = part;
// Avoid duplicates: extract auction id if possible
string? aid = null;
if (input.Contains("bidoo.com") || input.StartsWith("http", StringComparison.OrdinalIgnoreCase))
{
aid = ExtractAuctionId(input);
}
else
{
// treat as ID
aid = input;
}
if (!string.IsNullOrEmpty(aid) && _auctionViewModels.Any(a => a.AuctionId == aid))
{
skipped.Add(part + " (duplicato)");
continue;
}
await AddAuctionById(input);
added++;
}
catch (Exception ex)
{
skipped.Add(part + " (errore: " + ex.Message + ")");
}
}
UpdateGlobalControlButtons();
var summary = $"Aggiunte: {added}. Skipped: {skipped.Count}.";
if (skipped.Count >0)
summary += "\nDettagli: " + string.Join("; ", skipped.Take(10));
MessageBox.Show(summary, "Aggiunta aste", MessageBoxButton.OK, MessageBoxImage.Information);
}
}
@@ -1292,5 +1364,179 @@ namespace AutoBidder
}
RemainingBidsText.Text = remainingBids.ToString();
}
private void ExportSelectedAuction_Click(object sender, RoutedEventArgs e)
{
if (_selectedAuction == null)
{
MessageBox.Show("Seleziona un'asta da esportare", "Nessuna selezione", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
try
{
var dlg = new Microsoft.Win32.SaveFileDialog()
{
Filter = "CSV files|*.csv|JSON files|*.json|XML files|*.xml|All files|*.*",
FileName = $"auction_{_selectedAuction.AuctionId}.csv"
};
// Preselect filter based on last saved preference
try
{
var last = ExportPreferences.LoadLastExportExtension();
if (!string.IsNullOrEmpty(last))
{
switch (last.ToLowerInvariant())
{
case ".csv":
dlg.FilterIndex =1; dlg.FileName = $"auction_{_selectedAuction.AuctionId}.csv"; break;
case ".json":
dlg.FilterIndex =2; dlg.FileName = $"auction_{_selectedAuction.AuctionId}.json"; break;
case ".xml":
dlg.FilterIndex =3; dlg.FileName = $"auction_{_selectedAuction.AuctionId}.xml"; break;
default:
dlg.FilterIndex =1; break;
}
}
}
catch { /* ignore preference load errors */ }
if (dlg.ShowDialog(this) != true) return;
var path = dlg.FileName;
if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
// Export as JSON
var obj = new
{
AuctionId = _selectedAuction.AuctionId,
Name = _selectedAuction.Name,
OriginalUrl = _selectedAuction.AuctionInfo.OriginalUrl,
MinPrice = _selectedAuction.MinPrice,
MaxPrice = _selectedAuction.MaxPrice,
TimerClick = _selectedAuction.TimerClick,
DelayMs = _selectedAuction.AuctionInfo.DelayMs,
IsActive = _selectedAuction.IsActive,
IsPaused = _selectedAuction.IsPaused,
BidHistory = _selectedAuction.AuctionInfo.BidHistory,
Bidders = _selectedAuction.AuctionInfo.BidderStats.Values.ToList(),
AuctionLog = _selectedAuction.AuctionInfo.AuctionLog.ToList()
};
var json = System.Text.Json.JsonSerializer.Serialize(obj, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
System.IO.File.WriteAllText(path, json, System.Text.Encoding.UTF8);
}
else if (path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
{
// Export as XML
var auctionVm = _selectedAuction;
var ai = auctionVm.AuctionInfo;
var doc = new XDocument(
new XElement("AuctionExport",
new XElement("Metadata",
new XElement("AuctionId", auctionVm.AuctionId),
new XElement("Name", auctionVm.Name),
new XElement("OriginalUrl", ai.OriginalUrl ?? string.Empty),
new XElement("MinPrice", auctionVm.MinPrice),
new XElement("MaxPrice", auctionVm.MaxPrice),
new XElement("TimerClick", auctionVm.TimerClick),
new XElement("DelayMs", ai.DelayMs),
new XElement("IsActive", auctionVm.IsActive),
new XElement("IsPaused", auctionVm.IsPaused)
),
new XElement("FinalPrice", auctionVm.PriceDisplay ?? string.Empty),
new XElement("TotalBids", ai.BidHistory?.Count ??0),
new XElement("Bidders",
from b in ai.BidderStats.Values.Where(x => x.BidCount >0)
select new XElement("Bidder",
new XAttribute("Username", b.Username ?? string.Empty),
new XAttribute("BidCount", b.BidCount),
new XElement("LastBidTime", b.LastBidTimeDisplay ?? string.Empty)
)
),
new XElement("AuctionLog",
from l in ai.AuctionLog
select new XElement("Entry", l)
),
new XElement("BidHistory",
from bh in ai.BidHistory
select new XElement("Entry",
new XElement("Timestamp", bh.Timestamp.ToString("o")),
new XElement("EventType", bh.EventType),
new XElement("Bidder", bh.Bidder),
new XElement("Price", bh.Price.ToString("F2", CultureInfo.InvariantCulture)),
new XElement("Timer", bh.Timer.ToString("F2", CultureInfo.InvariantCulture)),
new XElement("LatencyMs", bh.LatencyMs),
new XElement("Success", bh.Success),
new XElement("Notes", bh.Notes)
)
)
)
);
doc.Save(path);
}
else
{
// Export as CSV (simple flat CSV with key-value pairs and sections)
using var sw = new System.IO.StreamWriter(path, false, System.Text.Encoding.UTF8);
sw.WriteLine("Field,Value");
sw.WriteLine($"AuctionId,{_selectedAuction.AuctionId}");
sw.WriteLine($"Name,\"{EscapeCsv(_selectedAuction.Name)}\"");
sw.WriteLine($"OriginalUrl,\"{EscapeCsv(_selectedAuction.AuctionInfo.OriginalUrl)}\"");
sw.WriteLine($"MinPrice,{_selectedAuction.MinPrice}");
sw.WriteLine($"MaxPrice,{_selectedAuction.MaxPrice}");
sw.WriteLine($"TimerClick,{_selectedAuction.TimerClick}");
sw.WriteLine($"DelayMs,{_selectedAuction.AuctionInfo.DelayMs}");
sw.WriteLine($"IsActive,{_selectedAuction.IsActive}");
sw.WriteLine($"IsPaused,{_selectedAuction.IsPaused}");
sw.WriteLine();
sw.WriteLine("--Auction Log--");
sw.WriteLine("Timestamp,Message");
foreach (var l in _selectedAuction.AuctionInfo.AuctionLog)
{
sw.WriteLine($"\"\",\"{EscapeCsv(l)}\"");
}
sw.WriteLine();
sw.WriteLine("--Bidders--");
sw.WriteLine("Username,BidCount,LastBidTime");
foreach (var b in _selectedAuction.AuctionInfo.BidderStats.Values)
{
sw.WriteLine($"\"{EscapeCsv(b.Username)}\",{b.BidCount},\"{EscapeCsv(b.LastBidTimeDisplay)}\"");
}
sw.WriteLine();
sw.WriteLine("--BidHistory--");
sw.WriteLine("Timestamp,EventType,Bidder,Price,Timer,LatencyMs,Success,Notes");
foreach (var bh in _selectedAuction.AuctionInfo.BidHistory)
{
sw.WriteLine($"\"{EscapeCsv(bh.Timestamp.ToString("o"))}\",{bh.EventType},\"{EscapeCsv(bh.Bidder)}\",{bh.Price:F2},{bh.Timer:F2},{bh.LatencyMs},{bh.Success},\"{EscapeCsv(bh.Notes)}\"");
}
}
// Persist last chosen extension
try
{
var ext = System.IO.Path.GetExtension(path);
if (!string.IsNullOrEmpty(ext)) ExportPreferences.SaveLastExportExtension(ext);
}
catch { }
MessageBox.Show(this, "Esportazione completata.", "Esporta Asta", MessageBoxButton.OK, MessageBoxImage.Information);
Log($"[EXPORT] Asta esportata: {_selectedAuction.Name} -> {dlg.FileName}");
}
catch (Exception ex)
{
Log($"[ERRORE] Esporta asta: {ex.Message}");
MessageBox.Show(this, "Errore durante esportazione: " + ex.Message, "Esporta Asta", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private static string EscapeCsv(string? v)
{
if (v == null) return string.Empty;
return v.Replace("\"", "\"\"");
}
}
}

View File

@@ -0,0 +1,15 @@
using System;
namespace AutoBidder.Models
{
public class ClosedAuctionRecord
{
public string? AuctionUrl { get; set; }
public string? ProductName { get; set; }
public double? FinalPrice { get; set; }
public string? Winner { get; set; }
public int? BidsUsed { get; set; }
public DateTime ScrapedAt { get; set; }
public string? Notes { get; set; }
}
}

View File

@@ -0,0 +1,21 @@
using System;
namespace AutoBidder.Models
{
public class ProductStat
{
public int Id { get; set; }
public string ProductKey { get; set; } = string.Empty;
public string ProductName { get; set; } = string.Empty;
public int TotalAuctions { get; set; }
public long TotalBidsUsed { get; set; }
public long TotalFinalPriceCents { get; set; }
public DateTime LastSeen { get; set; }
// Helper properties (not mapped)
public double AverageBidsUsed => TotalAuctions > 0 ? (double)TotalBidsUsed / TotalAuctions : 0.0;
public double AverageFinalPrice => TotalAuctions > 0 ? (double)TotalFinalPriceCents / 100.0 / TotalAuctions : 0.0;
}
}

View File

@@ -0,0 +1,370 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Semplice scraper che scarica la pagina delle "closed auctions" di Bidoo,
/// estrae i link alle singole aste, visita ciascuna pagina e prova ad estrarre
/// informazioni utili (nome prodotto, prezzo finale, vincitore, puntate usate).
/// Risultato salvato in CSV per analisi statistiche esterne.
///
/// Nota: il parsing è basato su euristiche (regex) per resistere a vari formati HTML.
/// </summary>
public class ClosedAuctionsScraper
{
private readonly HttpClient _http;
private readonly StatsService? _statsService;
private readonly Action<string>? _log;
public ClosedAuctionsScraper(HttpMessageHandler? handler = null, StatsService? statsService = null, Action<string>? log = null)
{
var h = handler ?? new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli
};
_http = new HttpClient(h)
{
Timeout = TimeSpan.FromSeconds(10)
};
// Default headers user-like
_http.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36");
_http.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8");
_http.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Language", "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7");
_statsService = statsService;
_log = log;
}
/// <summary>
/// Scarica la pagina di aste chiuse, estrae i link ed esegue scraping per ogni asta.
/// Salva il risultato in CSV.
/// </summary>
public async Task ScrapeAndSaveCsvAsync(string closedAuctionsUrl, string outputCsvPath)
{
var results = await ScrapeAsync(closedAuctionsUrl);
SaveCsv(results, outputCsvPath);
}
/// <summary>
/// Scarica la pagina di aste chiuse, estrae i link ed esegue scraping per ogni asta.
/// Ritorna la lista dei record (non salva su disco).
/// </summary>
public async Task<List<ClosedAuctionRecord>> ScrapeAsync(string closedAuctionsUrl)
{
var list = new List<ClosedAuctionRecord>();
await foreach (var rec in ScrapeYieldAsync(closedAuctionsUrl))
{
list.Add(rec);
}
return list;
}
/// <summary>
/// Scarica la pagina di aste chiuse e produce i record uno per uno (yield) per permettere aggiornamenti UI incrementali.
/// </summary>
public async IAsyncEnumerable<ClosedAuctionRecord> ScrapeYieldAsync(string closedAuctionsUrl)
{
if (string.IsNullOrWhiteSpace(closedAuctionsUrl)) throw new ArgumentNullException(nameof(closedAuctionsUrl));
var baseUri = new Uri("https://it.bidoo.com/");
_log?.Invoke($"[scraper] Downloading closed auctions page: {closedAuctionsUrl}");
var html = await GetStringAsync(closedAuctionsUrl);
if (html == null)
{
_log?.Invoke("[scraper] ERROR: unable to download closed auctions page");
throw new InvalidOperationException("Impossibile scaricare la pagina delle aste chiuse.");
}
var auctionUrls = ExtractAuctionLinks(html, baseUri).Distinct().ToList();
_log?.Invoke($"[scraper] Found {auctionUrls.Count} auction links on closed auctions page.");
foreach (var auctionUrl in auctionUrls)
{
ClosedAuctionRecord record;
try
{
_log?.Invoke($"[scraper] Fetching auction page: {auctionUrl}");
var contextInfo = ExtractSummaryInfoForUrl(html, auctionUrl);
var auctionHtml = await GetStringAsync(auctionUrl);
if (auctionHtml == null)
{
_log?.Invoke($"[scraper] WARNING: failed to download auction page: {auctionUrl}");
throw new InvalidOperationException("Download auction page failed");
}
var productName = contextInfo?.ProductName ?? ExtractProductNameFromAuctionHtml(auctionHtml);
var finalPrice = contextInfo?.FinalPrice ?? ExtractFinalPriceFromAuctionHtml(auctionHtml);
var winner = contextInfo?.Winner ?? ExtractWinnerFromAuctionHtml(auctionHtml);
var bidsUsed = ExtractBidsUsedFromAuctionHtml(auctionHtml);
_log?.Invoke($"[scraper] Parsed: Name='{productName}', FinalPrice={(finalPrice.HasValue? finalPrice.Value.ToString("F2", CultureInfo.InvariantCulture):"null")}, Winner='{winner}', BidsUsed={(bidsUsed.HasValue?bidsUsed.Value.ToString():"null")}" );
// Ensure HTML entities decoded already by helper methods
record = new ClosedAuctionRecord
{
AuctionUrl = auctionUrl,
ProductName = productName,
FinalPrice = finalPrice,
Winner = winner,
BidsUsed = bidsUsed,
ScrapedAt = DateTime.UtcNow,
Notes = string.Empty
};
// Record stats if service provided (fire-and-forget)
if (_statsService != null)
{
#pragma warning disable CS4014
_statsService.RecordClosedAuctionAsync(record);
#pragma warning restore CS4014
}
}
catch (Exception ex)
{
_log?.Invoke($"[scraper] ERROR parsing auction {auctionUrl}: {ex.Message}");
record = new ClosedAuctionRecord
{
AuctionUrl = auctionUrl,
ProductName = "(parse error)",
FinalPrice = null,
Winner = null,
BidsUsed = null,
ScrapedAt = DateTime.UtcNow,
Notes = ex.Message
};
}
yield return record;
}
}
private async Task<string?> GetStringAsync(string url)
{
try
{
var uri = new Uri(url, UriKind.RelativeOrAbsolute);
if (!uri.IsAbsoluteUri)
{
uri = new Uri(new Uri("https://it.bidoo.com"), url);
}
var req = new HttpRequestMessage(HttpMethod.Get, uri);
req.Headers.TryAddWithoutValidation("Referer", "https://it.bidoo.com/");
var resp = await _http.SendAsync(req);
resp.EnsureSuccessStatusCode();
var txt = await resp.Content.ReadAsStringAsync();
_log?.Invoke($"[scraper] HTTP {resp.StatusCode} {uri}");
return txt;
}
catch (Exception ex)
{
_log?.Invoke($"[scraper] HTTP ERROR fetching {url}: {ex.Message}");
return null;
}
}
private IEnumerable<string> ExtractAuctionLinks(string closedHtml, Uri baseUri)
{
var urls = new List<string>();
// Cerca attributi data-href
var mh = Regex.Matches(closedHtml, "data-href\\s*=\\s*\\\"(?<u>[^\\\"]+)\\\"", RegexOptions.IgnoreCase);
foreach (Match m in mh)
{
var u = m.Groups["u"].Value.Trim();
if (!string.IsNullOrEmpty(u)) urls.Add(ToAbsolute(u, baseUri));
}
// fallback: cerca link a auction.php?a=
var mh2 = Regex.Matches(closedHtml, "href\\s*=\\s*\\\"(?<u>[^\\\"]*auction.php\\?a=[^\\\"]+)\\\"", RegexOptions.IgnoreCase);
foreach (Match m in mh2)
{
var u = m.Groups["u"].Value.Trim();
urls.Add(ToAbsolute(u, baseUri));
}
return urls.Where(u => !string.IsNullOrWhiteSpace(u));
}
private string ToAbsolute(string url, Uri baseUri)
{
try
{
if (url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
return url;
if (url.StartsWith("//"))
return "https:" + url;
if (url.StartsWith("/"))
return new Uri(baseUri, url).ToString();
return new Uri(baseUri, "/" + url).ToString();
}
catch
{
return url;
}
}
private (string? ProductName, double? FinalPrice, string? Winner)? ExtractSummaryInfoForUrl(string closedHtml, string auctionUrl)
{
try
{
var idx = closedHtml.IndexOf(auctionUrl, StringComparison.OrdinalIgnoreCase);
if (idx < 0) return null;
var start = Math.Max(0, idx - 800);
var len = Math.Min(2500, closedHtml.Length - start);
var seg = closedHtml.Substring(start, len);
var namePattern1 = "<b[^>]*class=\\\"media-heading\\\"[^>]*>\\s*<a[^>]*>(?<name>[^<]+)</a>";
var namePattern2 = "<span[^>]*class=\\\"media-heading[^\\\"]*\\\"[^>]*>\\s*<a[^>]*>(?<name>[^<]+)</a>";
var nameMatch = Regex.Match(seg, namePattern1, RegexOptions.IgnoreCase);
if (!nameMatch.Success)
{
nameMatch = Regex.Match(seg, namePattern2, RegexOptions.IgnoreCase);
}
var product = nameMatch.Success ? WebUtility.HtmlDecode(nameMatch.Groups["name"].Value).Trim() : null;
var priceMatch = Regex.Match(seg, "<span[^>]*class=\\\"price\\\"[^>]*>(?<p>[0-9.,]+)\\s*€", RegexOptions.IgnoreCase);
double? price = null;
if (priceMatch.Success) price = ParseEuro(priceMatch.Groups["p"].Value);
var winnerPattern1 = "<span[^>]*class=\\\"username\\\"[^>]*>.*?<span[^>]*class=\\\"offer\\\"[^>]*>(?<w>[^<]+)</span>";
var winnerPattern2 = "<span[^>]*class=\\\"mobile_offerer offer\\\"[^>]*>(?<w>[^<]+)</span>";
var winnerMatch = Regex.Match(seg, winnerPattern1, RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (!winnerMatch.Success)
{
winnerMatch = Regex.Match(seg, winnerPattern2, RegexOptions.IgnoreCase);
}
var winner = winnerMatch.Success ? WebUtility.HtmlDecode(winnerMatch.Groups["w"].Value).Trim() : null;
return (product, price, winner);
}
catch
{
return null;
}
}
private string? ExtractProductNameFromAuctionHtml(string? auctionHtml)
{
if (string.IsNullOrEmpty(auctionHtml)) return null;
var content = auctionHtml ?? string.Empty;
var m = Regex.Match(content, "<h1[^>]*>(?<n>.*?)</h1>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (m.Success) return WebUtility.HtmlDecode(StripTags(m.Groups["n"].Value)).Trim();
m = Regex.Match(content, "<title[^>]*>(?<t>.*?)</title>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (m.Success) return WebUtility.HtmlDecode(StripTags(m.Groups["t"].Value)).Trim();
m = Regex.Match(content, "<b[^>]*class=\\\"media-heading\\\"[^>]*>\\s*<a[^>]*>(?<name>[^<]+)</a>", RegexOptions.IgnoreCase);
if (m.Success) return WebUtility.HtmlDecode(m.Groups["name"].Value).Trim();
return null;
}
private double? ExtractFinalPriceFromAuctionHtml(string? auctionHtml)
{
if (string.IsNullOrEmpty(auctionHtml)) return null;
var content = auctionHtml ?? string.Empty;
var m = Regex.Match(content, "<span[^>]*class=\\\"price\\\"[^>]*>(?<p>[0-9.,]+)\\s*€", RegexOptions.IgnoreCase);
if (m.Success) return ParseEuro(m.Groups["p"].Value);
m = Regex.Match(content, "prez[zo]?[\\\"\\']?[^0-9]{0,30}(?<p>[0-9.,]+)\\s*€", RegexOptions.IgnoreCase);
if (m.Success) return ParseEuro(m.Groups["p"].Value);
m = Regex.Match(content, "([0-9]{1,3}(?:[.,][0-9]{2}))\\s*€", RegexOptions.IgnoreCase);
if (m.Success) return ParseEuro(m.Groups[1].Value);
return null;
}
private string? ExtractWinnerFromAuctionHtml(string? auctionHtml)
{
if (string.IsNullOrEmpty(auctionHtml)) return null;
var content = auctionHtml ?? string.Empty;
var m = Regex.Match(content, "Vincitore[:\\s\\\"]+<[^>]*>(?<w>[^<]+)</", RegexOptions.IgnoreCase);
if (m.Success) return WebUtility.HtmlDecode(m.Groups["w"].Value).Trim();
m = Regex.Match(content, "<span[^>]*class=\\\"username\\\"[^>]*>.*?<span[^>]*class=\\\"offer\\\"[^>]*>(?<w>[^<]+)</span>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (m.Success) return WebUtility.HtmlDecode(m.Groups["w"].Value).Trim();
m = Regex.Match(content, "mobile_offerer offer\\\"[^>]*>(?<w>[^<]+)<", RegexOptions.IgnoreCase);
if (m.Success) return WebUtility.HtmlDecode(m.Groups["w"].Value).Trim();
return null;
}
private int? ExtractBidsUsedFromAuctionHtml(string? auctionHtml)
{
if (string.IsNullOrEmpty(auctionHtml)) return null;
var content = auctionHtml ?? string.Empty;
// 1) Look for the explicit bids-used span: <p ...><span>628</span> Puntate utilizzate</p>
var m = Regex.Match(content, "class=\\\"bids-used\\\"[^>]*>[^<]*<span[^>]*>(?<n>[0-9]{1,7})</span>", RegexOptions.IgnoreCase | RegexOptions.Singleline);
if (m.Success && int.TryParse(m.Groups["n"].Value, out var val)) return val;
// 2) Look for numeric followed by 'Puntate utilizzate' or similar
m = Regex.Match(content, "(?<n>[0-9]{1,7})\\s*(?:Puntate utilizzate|Puntate usate|puntate utilizzate|puntate usate|puntate)\\b", RegexOptions.IgnoreCase);
if (m.Success && int.TryParse(m.Groups["n"].Value, out val)) return val;
// 3) Fallbacks used previously
m = Regex.Match(content, "(?<n>[0-9]+)\\s*(?:puntate|Puntate|puntate usate|puntate_usate|pt\\.?|pts)\\b", RegexOptions.IgnoreCase);
if (m.Success && int.TryParse(m.Groups["n"].Value, out val)) return val;
m = Regex.Match(content, "usato[sx]?\\s*(?<n>[0-9]{1,6})\\s*(?:puntate|pts|pt)\\b", RegexOptions.IgnoreCase);
if (m.Success && int.TryParse(m.Groups["n"].Value, out val)) return val;
m = Regex.Match(content, "(Puntate\\s*(?:usate|vinte)?)[^0-9]{0,10}(?<n>[0-9]{1,6})", RegexOptions.IgnoreCase);
if (m.Success && int.TryParse(m.Groups["n"].Value, out val)) return val;
m = Regex.Match(content, "<[^>]*>\\s*(?<n>[0-9]{1,6})\\s*(puntate)\\s*<", RegexOptions.IgnoreCase);
if (m.Success && int.TryParse(m.Groups["n"].Value, out val)) return val;
return null;
}
private double? ParseEuro(string s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
s = s.Trim();
s = s.Replace(".", "").Replace(',', '.');
if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return d;
return null;
}
private string StripTags(string input)
{
return Regex.Replace(input ?? string.Empty, "<.*?>", string.Empty);
}
private void SaveCsv(IEnumerable<Models.ClosedAuctionRecord> data, string filePath)
{
var sb = new StringBuilder();
sb.AppendLine("AuctionUrl,ProductName,FinalPrice,Winner,BidsUsed,ScrapedAt,Notes");
foreach (var r in data)
{
// Escape quotes
string Escape(string? v) => (v ?? string.Empty).Replace("\"", "\"\"");
var finalPrice = r.FinalPrice.HasValue ? r.FinalPrice.Value.ToString("F2", CultureInfo.InvariantCulture) : string.Empty;
var bidsUsed = r.BidsUsed.HasValue ? r.BidsUsed.Value.ToString() : string.Empty;
var line = $"\"{Escape(r.AuctionUrl)}\",\"{Escape(r.ProductName)}\",{finalPrice},\"{Escape(r.Winner)}\",{bidsUsed},\"{r.ScrapedAt:O}\",\"{Escape(r.Notes)}\"";
sb.AppendLine(line);
}
File.WriteAllText(filePath, sb.ToString(), Encoding.UTF8);
}
}
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using AutoBidder.Data;
using AutoBidder.Models;
using System.Collections.Generic;
namespace AutoBidder.Services
{
public class StatsService
{
private readonly StatisticsContext _ctx;
public StatsService(StatisticsContext ctx)
{
_ctx = ctx;
// Ensure DB created
_ctx.Database.Migrate();
}
private static string NormalizeKey(string? productName, string? auctionUrl)
{
// Prefer auctionUrl numeric id if present
try
{
if (!string.IsNullOrWhiteSpace(auctionUrl))
{
var uri = new Uri(auctionUrl);
// Try regex to find trailing numeric ID
var m = System.Text.RegularExpressions.Regex.Match(uri.AbsolutePath + uri.Query, @"(\d{6,})");
if (m.Success) return m.Groups[1].Value;
}
}
catch { }
if (!string.IsNullOrWhiteSpace(productName))
{
var key = productName.Trim().ToLowerInvariant();
key = System.Text.RegularExpressions.Regex.Replace(key, "[^a-z0-9]+", "_");
return key;
}
return string.Empty;
}
public async Task RecordClosedAuctionAsync(ClosedAuctionRecord rec)
{
if (rec == null) return;
var key = NormalizeKey(rec.ProductName, rec.AuctionUrl);
if (string.IsNullOrWhiteSpace(key)) return;
var stat = await _ctx.ProductStats.FirstOrDefaultAsync(p => p.ProductKey == key);
if (stat == null)
{
stat = new ProductStat
{
ProductKey = key,
ProductName = rec.ProductName ?? "",
TotalAuctions = 0,
TotalBidsUsed = 0,
TotalFinalPriceCents = 0,
LastSeen = DateTime.UtcNow
};
_ctx.ProductStats.Add(stat);
}
stat.TotalAuctions += 1;
stat.TotalBidsUsed += rec.BidsUsed ?? 0;
if (rec.FinalPrice.HasValue)
stat.TotalFinalPriceCents += (long)Math.Round(rec.FinalPrice.Value * 100.0);
stat.LastSeen = DateTime.UtcNow;
await _ctx.SaveChangesAsync();
}
public async Task<ProductStat?> GetStatsForKeyAsync(string productName, string auctionUrl)
{
var key = NormalizeKey(productName, auctionUrl);
if (string.IsNullOrWhiteSpace(key)) return null;
return await _ctx.ProductStats.FirstOrDefaultAsync(p => p.ProductKey == key);
}
public async Task<(int recommendedBids, double recommendedMaxPrice)> GetRecommendationAsync(string productName, string auctionUrl, double userRiskFactor = 1.0)
{
var stat = await GetStatsForKeyAsync(productName, auctionUrl);
if (stat == null || stat.TotalAuctions < 3)
{
return (1, 1.0); // conservative defaults
}
int recBids = (int)Math.Ceiling(stat.AverageBidsUsed * userRiskFactor);
if (recBids < 1) recBids = 1;
// recommended max price: avg * (1 + min(0.2, 1/sqrt(n)))
double factor = 1.0 + Math.Min(0.2, 1.0 / Math.Sqrt(Math.Max(1, stat.TotalAuctions)));
double recPrice = stat.AverageFinalPrice * factor;
return (recBids, recPrice);
}
// New: return all stats for export
public async Task<List<ProductStat>> GetAllStatsAsync()
{
return await _ctx.ProductStats
.OrderByDescending(p => p.LastSeen)
.ToListAsync();
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using System.IO;
using System.Text.Json;
namespace AutoBidder.Utilities
{
internal static class ExportPreferences
{
private class Prefs { public string? LastExportExt { get; set; } }
private static readonly string _folder = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AutoBidder");
private static readonly string _file = Path.Combine(_folder, "exportprefs.json");
public static string? LoadLastExportExtension()
{
try
{
if (!File.Exists(_file)) return null;
var txt = File.ReadAllText(_file);
var p = JsonSerializer.Deserialize<Prefs>(txt);
if (p == null || string.IsNullOrEmpty(p.LastExportExt)) return null;
return p.LastExportExt;
}
catch
{
return null;
}
}
public static void SaveLastExportExtension(string? ext)
{
try
{
if (!Directory.Exists(_folder)) Directory.CreateDirectory(_folder);
var p = new Prefs { LastExportExt = ext };
var txt = JsonSerializer.Serialize(p);
File.WriteAllText(_file, txt);
}
catch { }
}
}
}