- 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.
494 lines
19 KiB
C#
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('-');
|
|
}
|
|
}
|
|
}
|