Files
Mimante/Mimante/Utilities/AuctionDossier.cs
T
Alby96 7ca504a70a Add utility classes for theme management, waiting, watched products, and notifications
- Implement ThemeManager for dynamic light/dark theme switching in the application.
- Create Wait class for cancellable delays without exceptions for smoother user experience.
- Introduce WatchedProductsStore to manage and persist watched products in JSON format.
- Add WindowsNotifier for system notifications to inform users of important events.
- Develop ProductViewModel to encapsulate product data and manage UI interactions effectively.
2026-08-04 21:49:53 +02:00

494 lines
19 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using AutoBidder.Models;
namespace AutoBidder.Utilities
{
/// <summary>
/// Il dossier di una singola asta: un file per asta, scritto mentre l'asta è in corso.
///
/// <para><b>Formato JSON Lines.</b> La prima riga è l'intestazione (che asta è, con che
/// impostazioni la si sta seguendo, quanto vale il prodotto), poi una riga per ogni cosa
/// che succede — ogni interrogazione a Bidoo, ogni puntata di ogni utente con l'ora al
/// millisecondo, ogni mia puntata con anticipo pianificato ed effettivo, ogni riga di
/// registro — e l'ultima riga è il riepilogo. Si accoda senza rileggere, un'interruzione
/// perde al massimo l'ultima riga, e un'AI lo legge senza doverlo convertire.</para>
///
/// <para>Perché tanto dettaglio: questi dati <b>non esistono a posteriori</b>. Bidoo non
/// espone lo storico dei prezzi né i tempi delle puntate di un'asta conclusa. O li si
/// raccoglie mentre l'asta va avanti, o sono persi — e sono esattamente quelli su cui si
/// capisce se l'anticipo impostato è giusto e come si comportano gli avversari.</para>
///
/// <para>La scrittura passa da <see cref="FileLogWriter"/>, quindi non tocca mai il
/// disco sul thread che segue l'asta: si accoda una riga e si prosegue.</para>
/// </summary>
public sealed class AuctionDossier
{
/// <summary>Campioni di ping tenuti in memoria per il riepilogo finale.</summary>
private const int MaxPingSamples = 50_000;
private static readonly ConcurrentDictionary<string, AuctionDossier> Open =
new(StringComparer.Ordinal);
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = false,
// Senza, accenti e apostrofi dei nomi prodotto finiscono in \uXXXX e il file
// diventa illeggibile proprio per chi (o cosa) deve analizzarlo.
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
private readonly FileLogWriter _writer;
private readonly DateTime _startedAt;
private readonly List<int> _pings = new();
private readonly object _sync = new();
private long _polls;
private long _events;
private bool _closed;
private AuctionDossier(string path, DateTime startedAt)
{
_writer = FileLogWriter.For(path);
_startedAt = startedAt;
}
public string Path => _writer.Path;
/// <summary>Righe scritte finora, intestazione esclusa.</summary>
public long EventCount => _events;
// ── Apertura e chiusura ──────────────────────────────────────────
/// <summary>
/// Apre (o riapre) il dossier di quest'asta. Riaprendo l'applicazione mentre l'asta
/// è ancora viva si <b>riprende lo stesso file</b>: un'asta seguita a cavallo di un
/// riavvio deve restare una storia sola.
/// </summary>
public static AuctionDossier? OpenFor(AuctionInfo auction, AppSettings settings)
{
if (auction == null || !settings.WriteAuctionDossiers) return null;
return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings));
}
/// <summary>Il dossier già aperto per quest'asta, se c'è.</summary>
public static AuctionDossier? For(string auctionId) =>
auctionId != null && Open.TryGetValue(auctionId, out var dossier) ? dossier : null;
/// <summary>
/// Fa confluire nel dossier tutte le righe di registro delle aste. Da chiamare una
/// volta all'avvio: le righe nascono in decine di punti del motore, e agganciarsi
/// alla sorgente è l'unico modo per non doverle inseguire una per una.
/// </summary>
public static void CaptureAuctionLogs()
{
AuctionInfo.LogSink = (auction, entry) =>
For(auction.AuctionId)?.Log(
entry.Level.ToString().ToLowerInvariant(),
entry.Category.ToString(),
entry.Message);
}
/// <summary>
/// Chiude il dossier di un'asta tolta dal monitor prima della fine.
///
/// <para>Senza riepilogo, e detto esplicitamente: quel file racconta una storia
/// interrotta, e chi lo analizzerà deve saperlo — una serie di prezzi che si ferma a
/// metà sembra un'asta finita a quel prezzo.</para>
/// </summary>
public static void Abandon(string auctionId, string reason)
{
if (auctionId == null) return;
if (!Open.TryRemove(auctionId, out var dossier)) return;
dossier.Write(new
{
t = dossier.Elapsed(),
type = "abandoned",
at = Now(),
reason,
note = "asta tolta dal monitor prima della conclusione: dati incompleti"
});
FileLogWriter.FlushAll();
}
/// <summary>Svuota sul disco i dossier aperti. Alla chiusura, o prima di esportare.</summary>
public static void FlushAll() => FileLogWriter.FlushAll();
/// <summary>Quanti dossier sono aperti adesso.</summary>
public static int OpenCount => Open.Count;
private static AuctionDossier Create(AuctionInfo auction, AppSettings settings)
{
AppPaths.EnsureFolders();
var path = ResolvePath(auction);
var isNew = !File.Exists(path);
var dossier = new AuctionDossier(path, DateTime.Now);
if (isNew) dossier.WriteHeader(auction, settings);
else dossier.Write(new { type = "resumed", at = Now(), note = "applicazione riavviata, asta ancora in corso" });
return dossier;
}
/// <summary>
/// Percorso del file. Se ne esiste già uno per quest'asta si riusa quello, comunque
/// si chiami: il nome porta la data, e un'asta cominciata ieri non deve spezzarsi in
/// due file solo perché è mezzanotte.
/// </summary>
private static string ResolvePath(AuctionInfo auction)
{
try
{
var existing = Directory
.GetFiles(AppPaths.AuctionLogFolder, $"*_{auction.AuctionId}.jsonl")
.FirstOrDefault();
if (existing != null) return existing;
}
catch { /* cartella non ancora disponibile: si crea il file nuovo */ }
var slug = Slug(auction.Name);
var name = $"{DateTime.Now:yyyy-MM-dd_HHmm}_{slug}_{auction.AuctionId}.jsonl";
return System.IO.Path.Combine(AppPaths.AuctionLogFolder, name);
}
private void WriteHeader(AuctionInfo auction, AppSettings settings)
{
Write(new
{
type = "header",
schema = "autobidder.auction.v1",
app = AppInfo.Version,
auctionId = auction.AuctionId,
name = auction.Name,
productKey = ProductKeyHelper.GenerateProductKey(auction.Name),
url = auction.OriginalUrl,
openedAt = Now(),
addedAt = auction.AddedAt.ToLocalTime().ToString("o"),
// Valore del prodotto: senza questo, prezzo finale e convenienza non
// significano nulla quando si rileggeranno i dati.
product = new
{
buyNowPrice = auction.BuyNowPrice,
shippingCost = auction.ShippingCost,
bidCostEuro = auction.BidCost,
hasWinLimit = auction.HasWinLimit,
winLimit = auction.WinLimitDescription
},
// Con che regole la si sta seguendo: due aste identiche con anticipi
// diversi non sono confrontabili, e senza questo non si saprebbe.
config = new
{
state = auction.State.ToString(),
bidBeforeDeadlineMs = auction.BidBeforeDeadlineMs > 0
? auction.BidBeforeDeadlineMs
: settings.DefaultBidBeforeDeadlineMs,
minPrice = auction.MinPrice,
maxPrice = auction.MaxPrice,
maxBids = auction.MaxClicks,
minResets = auction.MinResets,
maxResets = auction.MaxResets,
pollCriticalMs = settings.PollIntervalCriticalMs,
criticalWindowMs = settings.CriticalWindowMs,
valueCheckEnabled = settings.ValueCheckEnabled,
minSavingsPercentage = settings.MinSavingsPercentage,
antiBotEnabled = settings.AntiBotDetectionEnabled,
competitionEnabled = settings.CompetitionDetectionEnabled,
rawPollsIncluded = settings.DossierIncludeRawPolls
}
});
}
/// <summary>
/// Chiude il dossier con il riepilogo. Da qui in avanti il file non cresce più:
/// è la riga che dice a chi analizza che la storia è completa.
/// </summary>
public static void Close(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
if (auction == null) return;
if (!Open.TryRemove(auction.AuctionId, out var dossier)) return;
dossier.WriteSummary(auction, detail, finalState);
}
private void WriteSummary(AuctionInfo auction, AuctionDetailRecord detail, AuctionState? finalState)
{
lock (_sync)
{
if (_closed) return;
_closed = true;
}
var pings = PingStats();
Write(new
{
type = "summary",
closedAt = Now(),
endedAt = detail.EndedAt.ToLocalTime().ToString("o"),
outcome = detail.Outcome,
wonByMe = detail.WonByMe,
winner = detail.Winner,
finalPrice = detail.FinalPrice,
// Le due bandiere che dicono quanto vale questo dossier per l'analisi.
coverage = new
{
observedFromStart = auction.ObservedFromStart,
observedToEnd = auction.ObservedToEnd,
complete = auction.ObservedFromStart && auction.ObservedToEnd,
firstSeenAt = detail.FirstSeenAt.ToLocalTime().ToString("o"),
observedMinutes = Math.Round(detail.ObservedMinutes, 2)
},
participation = new
{
myBids = detail.MyBids,
totalObservedBids = detail.TotalObservedBids,
distinctBidders = detail.DistinctBidders,
resets = detail.Resets,
topBidderShare = Math.Round(detail.TopBidderShare, 2),
bidsByUser = detail.BidsByUser
},
value = new
{
buyNowPrice = detail.BuyNowPrice,
shippingCost = detail.ShippingCost,
bidCostEuro = auction.BidCost,
totalCostIfWon = Math.Round(detail.TotalCostIfWon(auction.BidCost), 2),
finalPriceRatio = detail.FinalPriceRatio.HasValue
? Math.Round(detail.FinalPriceRatio.Value, 4)
: (double?)null
},
network = new
{
avgPingMs = pings.Avg,
minPingMs = pings.Min,
maxPingMs = pings.Max,
medianPingMs = pings.Median,
p95PingMs = pings.P95,
samples = pings.Count,
polls = _polls,
pollErrors = detail.PollErrors
},
engine = new
{
configuredLeadMs = detail.ConfiguredLeadMs,
timerExpiredCount = auction.TimerExpiredCount,
successfulBids = auction.SuccessfulBidCount,
failedBids = auction.FailedBidCount,
collisions = auction.CollisionCount
},
priceSeries = detail.PriceSeries.Select(p => new { t = Math.Round(p.T, 2), price = p.Price }),
priceVelocityPerMinute = Math.Round(detail.PriceVelocityPerMinute, 4),
finalStatus = finalState?.Status.ToString(),
eventsWritten = _events
}, isSummary: true);
FileLogWriter.FlushAll();
}
// ── Eventi ───────────────────────────────────────────────────────
/// <summary>
/// Una singola interrogazione a Bidoo. È l'evento più frequente — nella finestra
/// critica quattro al secondo — ed è quello che permette di ricostruire a posteriori
/// dove stava davvero il timer nell'istante in cui si è deciso di puntare.
/// </summary>
public void Poll(AuctionState state, bool includeRaw)
{
_polls++;
lock (_sync)
{
if (_pings.Count < MaxPingSamples) _pings.Add(state.PollingLatencyMs);
}
if (!includeRaw) return;
Write(new
{
t = Elapsed(),
type = "poll",
at = Now(),
price = state.Price,
timer = Math.Round(state.Timer, 2),
status = state.Status.ToString(),
lastBidder = string.IsNullOrEmpty(state.LastBidder) ? null : state.LastBidder,
mine = state.IsMyBid,
pingMs = state.PollingLatencyMs,
expiryUnix = state.ExpiryUnixSeconds,
serverUnix = state.ServerUnixSeconds
});
}
/// <summary>Una puntata altrui, come l'ha riportata Bidoo.</summary>
public void ForeignBid(BidHistoryEntry entry, double price)
{
Write(new
{
t = Elapsed(),
type = "bid",
at = Now(),
user = entry.Username,
// L'ora dichiarata da Bidoo, distinta da quella in cui l'abbiamo saputo:
// fra le due può passare un poll intero, e la differenza conta quando si
// ricostruisce chi ha puntato per primo.
bidAt = entry.Timestamp > 0
? DateTimeOffset.FromUnixTimeSeconds(entry.Timestamp).ToLocalTime().ToString("o")
: null,
bidType = entry.BidType,
mine = entry.IsMyBid,
price
});
}
/// <summary>
/// Una mia puntata, con tutto ciò che serve a giudicarla: quanto prima della
/// scadenza volevo essere, quanto prima ci sono arrivato davvero, e con che ping.
/// È la coppia pianificato/effettivo il dato che permette di tarare l'anticipo.
/// </summary>
public void MyBid(
double price,
int plannedLeadMs,
double actualLeadMs,
int pingMs,
bool success,
string? error,
int? bidsUsed,
int? remainingBids)
{
Write(new
{
t = Elapsed(),
type = "my_bid",
at = Now(),
price,
plannedLeadMs,
actualLeadMs = Math.Round(actualLeadMs, 1),
leadErrorMs = Math.Round(actualLeadMs - plannedLeadMs, 1),
pingMs,
avgPingMs = PingStats().Avg,
result = success ? "ok" : "fail",
error,
bidsUsed,
remainingBids
});
}
/// <summary>Il timer è stato azzerato da una puntata: l'asta continua.</summary>
public void Reset(int resetCount, double price, string? bidder)
{
Write(new
{
t = Elapsed(),
type = "reset",
at = Now(),
resetCount,
price,
bidder
});
}
/// <summary>Una riga del registro dell'asta (strategie, avvisi, errori).</summary>
public void Log(string level, string category, string message)
{
Write(new
{
t = Elapsed(),
type = "log",
at = Now(),
level,
category,
msg = message
});
}
/// <summary>Cambio di modo: Ferma, Osserva, Attiva.</summary>
public void StateChanged(string from, string to)
{
Write(new
{
t = Elapsed(),
type = "state",
at = Now(),
from,
to
});
}
// ── Interno ──────────────────────────────────────────────────────
private void Write(object payload, bool isSummary = false)
{
// Dopo il riepilogo il file è una storia chiusa: una riga in coda al riepilogo
// farebbe dubitare di tutto ciò che c'è sopra.
if (_closed && !isSummary) return;
try
{
_writer.Write(JsonSerializer.Serialize(payload, Json));
_events++;
}
catch { /* una riga persa non vale l'interruzione di un'asta */ }
}
private double Elapsed() => Math.Round((DateTime.Now - _startedAt).TotalSeconds, 3);
private static string Now() => DateTime.Now.ToString("HH:mm:ss.fff");
private (int Count, double Avg, int Min, int Max, int Median, int P95) PingStats()
{
int[] copy;
lock (_sync) copy = _pings.Where(p => p > 0).ToArray();
if (copy.Length == 0) return (0, 0, 0, 0, 0, 0);
Array.Sort(copy);
return (
copy.Length,
Math.Round(copy.Average(), 1),
copy[0],
copy[^1],
copy[copy.Length / 2],
copy[Math.Min(copy.Length - 1, (int)(copy.Length * 0.95))]);
}
/// <summary>Nome file leggibile: niente caratteri vietati, niente nomi chilometrici.</summary>
private static string Slug(string? name)
{
if (string.IsNullOrWhiteSpace(name)) return "asta";
var clean = Regex.Replace(name.Trim(), @"[^\p{L}\p{Nd}]+", "-").Trim('-');
foreach (var invalid in System.IO.Path.GetInvalidFileNameChars())
clean = clean.Replace(invalid, '-');
if (clean.Length == 0) return "asta";
return clean.Length <= 48 ? clean : clean[..48].TrimEnd('-');
}
}
}