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 { /// /// Il dossier di una singola asta: un file per asta, scritto mentre l'asta è in corso. /// /// Formato JSON Lines. 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. /// /// Perché tanto dettaglio: questi dati non esistono a posteriori. 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. /// /// La scrittura passa da , quindi non tocca mai il /// disco sul thread che segue l'asta: si accoda una riga e si prosegue. /// public sealed class AuctionDossier { /// Campioni di ping tenuti in memoria per il riepilogo finale. private const int MaxPingSamples = 50_000; private static readonly ConcurrentDictionary 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 _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; /// Righe scritte finora, intestazione esclusa. public long EventCount => _events; // ── Apertura e chiusura ────────────────────────────────────────── /// /// Apre (o riapre) il dossier di quest'asta. Riaprendo l'applicazione mentre l'asta /// è ancora viva si riprende lo stesso file: un'asta seguita a cavallo di un /// riavvio deve restare una storia sola. /// public static AuctionDossier? OpenFor(AuctionInfo auction, AppSettings settings) { if (auction == null || !settings.WriteAuctionDossiers) return null; return Open.GetOrAdd(auction.AuctionId, _ => Create(auction, settings)); } /// Il dossier già aperto per quest'asta, se c'è. public static AuctionDossier? For(string auctionId) => auctionId != null && Open.TryGetValue(auctionId, out var dossier) ? dossier : null; /// /// 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. /// public static void CaptureAuctionLogs() { AuctionInfo.LogSink = (auction, entry) => For(auction.AuctionId)?.Log( entry.Level.ToString().ToLowerInvariant(), entry.Category.ToString(), entry.Message); } /// /// Chiude il dossier di un'asta tolta dal monitor prima della fine. /// /// 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. /// 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(); } /// Svuota sul disco i dossier aperti. Alla chiusura, o prima di esportare. public static void FlushAll() => FileLogWriter.FlushAll(); /// Quanti dossier sono aperti adesso. 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; } /// /// 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. /// 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 } }); } /// /// 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. /// 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 ─────────────────────────────────────────────────────── /// /// 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. /// 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 }); } /// Una puntata altrui, come l'ha riportata Bidoo. 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 }); } /// /// 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. /// 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 }); } /// Il timer è stato azzerato da una puntata: l'asta continua. public void Reset(int resetCount, double price, string? bidder) { Write(new { t = Elapsed(), type = "reset", at = Now(), resetCount, price, bidder }); } /// Una riga del registro dell'asta (strategie, avvisi, errori). public void Log(string level, string category, string message) { Write(new { t = Elapsed(), type = "log", at = Now(), level, category, msg = message }); } /// Cambio di modo: Ferma, Osserva, Attiva. 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))]); } /// Nome file leggibile: niente caratteri vietati, niente nomi chilometrici. 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('-'); } } }