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>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6584" />
|
||||
</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,28 +1,35 @@
|
||||
<?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"
|
||||
Background="#0a0a0a" Foreground="#FFFFFF"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="pack://application:,,,/Icon/favicon.ico"
|
||||
ResizeMode="NoResize">
|
||||
<Border Background="#1a1a1a" CornerRadius="8" Padding="16" Margin="8">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<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"
|
||||
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">
|
||||
<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"
|
||||
Style="{StaticResource SmallButtonStyle}" Background="#666" Foreground="White" Click="CancelButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Aggiungi Asta" Height="320" Width="700"
|
||||
Background="#0a0a0a" Foreground="#FFFFFF"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="pack://application:,,,/Icon/favicon.ico"
|
||||
ResizeMode="NoResize">
|
||||
<Border Background="#1a1a1a" CornerRadius="8" Padding="16" Margin="8">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<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"
|
||||
Padding="8" FontSize="13" ToolTip="Inserisci uno o più 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"
|
||||
Style="{StaticResource SmallButtonStyle}" Background="#666" Foreground="White" Click="CancelButton_Click" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
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,15 +185,35 @@
|
||||
<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" />
|
||||
<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" />
|
||||
<Button x:Name="PauseAllButton" Content="Pausa Tutti" Command="{Binding PauseAllCommand}" IsEnabled="True"
|
||||
Style="{StaticResource SmallButtonStyle}"
|
||||
Background="#FF9933" Padding="20,10" Margin="0,0,6,0" Height="40" MinWidth="110" />
|
||||
<Button x:Name="StopButton" Content="Ferma Tutti" Command="{Binding StopAllCommand}" IsEnabled="False"
|
||||
Style="{StaticResource SmallButtonStyle}"
|
||||
Background="#CC0000" Padding="20,10" Opacity="0.5" 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" />
|
||||
<Button x:Name="PauseAllButton" Content="Pausa Tutti" Command="{Binding PauseAllCommand}" IsEnabled="True"
|
||||
Style="{StaticResource SmallButtonStyle}"
|
||||
Background="#FF9933" Padding="20,10" Margin="0,0,6,0" Height="40" MinWidth="110" />
|
||||
<Button x:Name="StopButton" Content="Ferma Tutti" Command="{Binding StopAllCommand}" IsEnabled="False"
|
||||
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">
|
||||
|
||||
@@ -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("\"", "\"\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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