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:
@@ -22,6 +22,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
|
||||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6584" />
|
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6584" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
23
Mimante/Data/StatisticsContext.cs
Normal file
23
Mimante/Data/StatisticsContext.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<Window x:Class="AutoBidder.Dialogs.AddAuctionSimpleDialog"
|
<Window x:Class="AutoBidder.Dialogs.AddAuctionSimpleDialog"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
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"
|
Background="#0a0a0a" Foreground="#FFFFFF"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
Icon="pack://application:,,,/Icon/favicon.ico"
|
Icon="pack://application:,,,/Icon/favicon.ico"
|
||||||
@@ -11,13 +12,19 @@
|
|||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
|
<RowDefinition Height="*" />
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
</Grid.RowDefinitions>
|
</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ù)" Foreground="#CCCCCC" FontSize="14" Margin="0,0,0,6" />
|
||||||
|
<TextBlock Grid.Row="1" Text="Puoi aggiungere più 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"
|
Background="#181818" Foreground="#00CCFF" BorderBrush="#444" BorderThickness="1"
|
||||||
Padding="8" FontSize="13" ToolTip="Inserisci l'URL completo dell'asta" />
|
Padding="8" FontSize="13" ToolTip="Inserisci uno o più URL/ID dell'asta. Separali con a capo, spazio o ';'"
|
||||||
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
|
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"
|
<Button x:Name="OkButton" Content="OK" Width="110" Margin="6" Padding="10,8"
|
||||||
Style="{StaticResource SmallButtonStyle}" Background="#00CC66" Foreground="White" Click="OkButton_Click" />
|
Style="{StaticResource SmallButtonStyle}" Background="#00CC66" Foreground="White" Click="OkButton_Click" />
|
||||||
<Button x:Name="CancelButton" Content="Annulla" Width="110" Margin="6" Padding="10,8"
|
<Button x:Name="CancelButton" Content="Annulla" Width="110" Margin="6" Padding="10,8"
|
||||||
|
|||||||
@@ -13,13 +13,15 @@ namespace AutoBidder.Dialogs
|
|||||||
|
|
||||||
private void OkButton_Click(object sender, RoutedEventArgs e)
|
private void OkButton_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var text = AuctionUrlBox.Text?.Trim() ?? string.Empty;
|
var text = AuctionUrlBox.Text ?? string.Empty;
|
||||||
if (string.IsNullOrWhiteSpace(text) || !text.StartsWith("http"))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
AuctionId = text;
|
|
||||||
|
// Return the raw text (may contain multiple entries); caller will parse it
|
||||||
|
AuctionId = text.Trim();
|
||||||
DialogResult = true;
|
DialogResult = true;
|
||||||
Close();
|
Close();
|
||||||
}
|
}
|
||||||
|
|||||||
89
Mimante/Dialogs/ClosedAuctionsWindow.xaml
Normal file
89
Mimante/Dialogs/ClosedAuctionsWindow.xaml
Normal 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>
|
||||||
140
Mimante/Dialogs/ClosedAuctionsWindow.xaml.cs
Normal file
140
Mimante/Dialogs/ClosedAuctionsWindow.xaml.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -185,6 +185,12 @@
|
|||||||
<Button x:Name="ConfigSessionButton" Content="Configura" Click="ConfigSessionButton_Click"
|
<Button x:Name="ConfigSessionButton" Content="Configura" Click="ConfigSessionButton_Click"
|
||||||
Style="{StaticResource SmallButtonStyle}"
|
Style="{StaticResource SmallButtonStyle}"
|
||||||
Background="#8B5CF6" Padding="20,10" Margin="0,0,6,0" Height="40" MinWidth="110" />
|
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"
|
<Button x:Name="StartButton" Content="Avvia Tutti" Command="{Binding StartAllCommand}" IsEnabled="False"
|
||||||
Style="{StaticResource SmallButtonStyle}"
|
Style="{StaticResource SmallButtonStyle}"
|
||||||
Background="#00CC66" Padding="20,10" Margin="0,0,6,0" Height="40" MinWidth="110" />
|
Background="#00CC66" Padding="20,10" Margin="0,0,6,0" Height="40" MinWidth="110" />
|
||||||
@@ -195,6 +201,20 @@
|
|||||||
Style="{StaticResource SmallButtonStyle}"
|
Style="{StaticResource SmallButtonStyle}"
|
||||||
Background="#CC0000" Padding="20,10" Opacity="0.5" Height="40" MinWidth="110" />
|
Background="#CC0000" Padding="20,10" Opacity="0.5" Height="40" MinWidth="110" />
|
||||||
</StackPanel>
|
</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>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
@@ -281,7 +301,7 @@
|
|||||||
</DataGrid.Resources>
|
</DataGrid.Resources>
|
||||||
<DataGrid.Columns>
|
<DataGrid.Columns>
|
||||||
<DataGridTextColumn Header="Asta" Binding="{Binding Name, Mode=OneWay}" Width="2*" IsReadOnly="True" />
|
<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="Stato" Binding="{Binding StatusDisplay, Mode=OneWay}" Width="90" IsReadOnly="True" />
|
||||||
<DataGridTextColumn Header="Timer" Binding="{Binding TimerDisplay, Mode=OneWay}" Width="70" 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" />
|
<DataGridTextColumn Header="Prezzo" Binding="{Binding PriceDisplay, Mode=OneWay}" Width="85" IsReadOnly="True" />
|
||||||
@@ -365,10 +385,12 @@
|
|||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
</Grid.ColumnDefinitions>
|
</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" />
|
<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="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="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>
|
</Grid>
|
||||||
|
|
||||||
<UniformGrid Columns="2" Margin="0,0,0,8">
|
<UniformGrid Columns="2" Margin="0,0,0,8">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ using System.Net;
|
|||||||
using System.Windows;
|
using System.Windows;
|
||||||
using System.Windows.Controls;
|
using System.Windows.Controls;
|
||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
|
using System.Xml.Linq;
|
||||||
|
using System.Globalization;
|
||||||
using AutoBidder.Models;
|
using AutoBidder.Models;
|
||||||
using AutoBidder.Services;
|
using AutoBidder.Services;
|
||||||
using AutoBidder.ViewModels;
|
using AutoBidder.ViewModels;
|
||||||
@@ -90,6 +92,21 @@ namespace AutoBidder
|
|||||||
_ = UpdateUserHtmlInfoAsync();
|
_ = 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
|
// Command implementations
|
||||||
private void ExecuteStartAll()
|
private void ExecuteStartAll()
|
||||||
{
|
{
|
||||||
@@ -349,7 +366,62 @@ namespace AutoBidder
|
|||||||
var dialog = new AddAuctionSimpleDialog();
|
var dialog = new AddAuctionSimpleDialog();
|
||||||
if (dialog.ShowDialog() == true)
|
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();
|
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("\"", "\"\"");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
Mimante/Models/ClosedAuctionRecord.cs
Normal file
15
Mimante/Models/ClosedAuctionRecord.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Mimante/Models/ProductStat.cs
Normal file
21
Mimante/Models/ProductStat.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
370
Mimante/Services/ClosedAuctionsScraper.cs
Normal file
370
Mimante/Services/ClosedAuctionsScraper.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
109
Mimante/Services/StatsService.cs
Normal file
109
Mimante/Services/StatsService.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
Mimante/Utilities/ExportPreferences.cs
Normal file
41
Mimante/Utilities/ExportPreferences.cs
Normal 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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user