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.
This commit is contained in:
2026-08-04 21:49:53 +02:00
parent 551697d98d
commit 7ca504a70a
212 changed files with 112225 additions and 26787 deletions
File diff suppressed because it is too large Load Diff
+567
View File
@@ -0,0 +1,567 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio per strategie avanzate di puntata.
/// Implementa: adaptive latency, jitter, dynamic offset, heat metric,
/// competition detection, soft retreat, probabilistic bidding, opponent profiling.
/// </summary>
public class BidStrategyService
{
private readonly Random _random = new();
private int _sessionTotalBids = 0;
private DateTime _sessionStartedAt = DateTime.UtcNow;
/// <summary>
/// Aggiorna heat metric per un'asta
/// </summary>
public void UpdateHeatMetric(AuctionInfo auction, AppSettings settings, string currentUsername = "")
{
if (!settings.CompetitionDetectionEnabled) return;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var windowStart = now - settings.CompetitionWindowSeconds;
// Conta bidder unici nella finestra temporale (escludo me stesso)
var recentBids = auction.RecentBids
.Where(b => b.Timestamp >= windowStart)
.Where(b => !b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
.ToList();
auction.ActiveBiddersCount = recentBids
.Select(b => b.Username)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Count();
// Conta collisioni (puntate nello stesso secondo)
var bidsBySecond = recentBids
.GroupBy(b => b.Timestamp)
.Where(g => g.Count() > 1)
.Count();
auction.CollisionCount = bidsBySecond;
// Calcola heat metric (0-100)
// Fattori: bidder attivi (40%), frequenza puntate (30%), collisioni (30%)
int bidderScore = Math.Min(auction.ActiveBiddersCount * 15, 40); // Max 40 punti
int frequencyScore = Math.Min(recentBids.Count * 3, 30); // Max 30 punti
int collisionScore = Math.Min(auction.CollisionCount * 10, 30); // Max 30 punti
auction.HeatMetric = bidderScore + frequencyScore + collisionScore;
// Identifica bidder aggressivi e situazioni di duello
if (settings.OpponentProfilingEnabled)
{
UpdateAggressiveBidders(auction, settings, currentUsername);
DetectDuelSituation(auction, settings, currentUsername);
}
}
/// <summary>
/// Identifica e tracca bidder aggressivi (basato su ultime N puntate, esclude utente corrente)
/// </summary>
private void UpdateAggressiveBidders(AuctionInfo auction, AppSettings settings, string currentUsername)
{
// ?? FIX: Usa finestra scorrevole di ultime N puntate
var windowSize = settings.AggressiveBidderWindowSize > 0 ? settings.AggressiveBidderWindowSize : 30;
var recentWindow = auction.RecentBids
.Take(windowSize)
.ToList();
var bidCounts = recentWindow
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
.ToList();
auction.AggressiveBidders.Clear();
foreach (var bidder in bidCounts)
{
// ?? FIX: NON aggiungere l'utente corrente come aggressivo!
if (bidder.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
continue;
// ?? FIX: Soglia pi permissiva - usa percentuale invece di conteggio assoluto
// Un bidder "aggressivo" se ha pi del 40% delle puntate nella finestra (configurabile)
var percentageThreshold = settings.AggressiveBidderPercentageThreshold > 0 ? settings.AggressiveBidderPercentageThreshold : 40.0;
if (bidder.Percentage >= percentageThreshold || bidder.Count >= settings.AggressiveBidderThreshold)
{
auction.AggressiveBidders.Add(bidder.Username);
}
}
}
/// <summary>
/// Rileva situazione di "duello" (solo 2 bidder attivi che si contendono l'asta)
/// In questa situazione bisogna essere pronti perch se uno si ritira l'altro vince
/// </summary>
private void DetectDuelSituation(AuctionInfo auction, AppSettings settings, string currentUsername)
{
var windowSize = settings.DuelDetectionWindowSize > 0 ? settings.DuelDetectionWindowSize : 20;
var recentWindow = auction.RecentBids.Take(windowSize).ToList();
if (recentWindow.Count < 6) // Serve un minimo di puntate per rilevare un pattern
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
return;
}
var bidders = recentWindow
.GroupBy(b => b.Username, StringComparer.OrdinalIgnoreCase)
.Select(g => new { Username = g.Key, Count = g.Count(), Percentage = (double)g.Count() / recentWindow.Count * 100 })
.OrderByDescending(b => b.Count)
.ToList();
// Duello: esattamente 2 bidder dominanti che coprono almeno l'80% delle puntate
if (bidders.Count >= 2)
{
var top2Percentage = bidders.Take(2).Sum(b => b.Percentage);
if (top2Percentage >= 80 && bidders.Count <= 3)
{
auction.IsDuelSituation = true;
// Trova l'avversario (chi NON sono io)
var opponent = bidders.FirstOrDefault(b =>
!b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
auction.DuelOpponent = opponent?.Username;
// Calcola chi sta dominando
var myStats = bidders.FirstOrDefault(b =>
b.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase));
auction.DuelAdvantage = myStats != null && opponent != null
? myStats.Percentage - opponent.Percentage
: 0;
}
else
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
auction.DuelAdvantage = 0;
}
}
else
{
auction.IsDuelSituation = false;
auction.DuelOpponent = null;
}
}
/// <summary>
/// Verifica se il caso di puntare considerando tutte le strategie
/// </summary>
public BidDecision ShouldPlaceBid(AuctionInfo auction, AuctionState state, AppSettings settings, string currentUsername)
{
var decision = new BidDecision { ShouldBid = true };
// Se le strategie avanzate sono disabilitate per questa asta, salta tutto
if (auction.AdvancedStrategiesEnabled == false)
{
return decision;
}
// ? RIMOSSO: Entry Point - Era sbagliato!
// I limiti MinPrice/MaxPrice impostati dall'utente sono RIGIDI.
// Se l'utente imposta MaxPrice=2, vuole puntare FINO A 2, non fino al 70%!
// I controlli MinPrice/MaxPrice sono gi gestiti in AuctionMonitor.ShouldBid()
// L'Entry Point pu essere usato SOLO per calcolare limiti CONSIGLIATI, non per bloccare.
// 1. ANTI-BOT — riconoscimento del puntatore a cadenza fissa.
//
// Spento di proposito (vedi AppSettings.AntiBotDetectionEnabled): rigiocando i
// dossier raccolti la regola rifiutava fra il 4% e il 9% delle puntate, a seconda
// dell'anticipo. Chi lo accende lo fa sapendo che e' una scelta di
// prudenza, non una difesa: un avversario a cadenza fissa è il più facile da
// battere, perché punta sempre con secondi di anticipo.
if (settings.AntiBotDetectionEnabled && !string.IsNullOrEmpty(state.LastBidder))
{
var botCheck = DetectBotPattern(auction, state.LastBidder, currentUsername);
if (botCheck.IsBot)
{
decision.ShouldBid = false;
decision.Reason = $"Anti-bot: {state.LastBidder} punta a cadenza fissa ({botCheck.GapSeconds:F0}s)";
return decision;
}
}
// ?? 2. USER EXHAUSTION - Sfrutta utenti stanchi (info solo, non blocca)
if (settings.UserExhaustionEnabled && !string.IsNullOrEmpty(state.LastBidder))
{
var exhaustionCheck = CheckUserExhaustion(auction, state.LastBidder, currentUsername);
// Non blocchiamo, ma potremmo loggare per info
}
// 3. Verifica soft retreat
if (settings.SoftRetreatEnabled || (auction.SoftRetreatEnabledOverride ?? settings.SoftRetreatEnabled))
{
if (auction.IsInSoftRetreat)
{
var retreatEnd = auction.LastSoftRetreatAt?.AddSeconds(settings.SoftRetreatDurationSeconds);
if (retreatEnd > DateTime.UtcNow)
{
decision.ShouldBid = false;
decision.Reason = $"Soft retreat attivo (termina tra {(retreatEnd.Value - DateTime.UtcNow).TotalSeconds:F0}s)";
return decision;
}
else
{
// Fine soft retreat
auction.IsInSoftRetreat = false;
auction.ConsecutiveCollisions = 0;
}
}
// Verifica se attivare soft retreat
if (auction.ConsecutiveCollisions >= settings.SoftRetreatAfterCollisions)
{
auction.IsInSoftRetreat = true;
auction.LastSoftRetreatAt = DateTime.UtcNow;
decision.ShouldBid = false;
decision.Reason = $"Soft retreat attivato dopo {auction.ConsecutiveCollisions} collisioni";
return decision;
}
}
// 2. Verifica competition threshold
if (settings.CompetitionDetectionEnabled)
{
if (auction.ActiveBiddersCount >= settings.CompetitionThreshold)
{
// Controlla se l'ultimo bidder sono io - se s, posso continuare
var lastBid = auction.RecentBids.OrderByDescending(b => b.Timestamp).FirstOrDefault();
if (lastBid != null && !lastBid.Username.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
{
if (settings.AutoPauseHotAuctions && auction.HeatMetric >= settings.HeatThresholdForPause)
{
decision.ShouldBid = false;
decision.Reason = $"Asta troppo calda (heat={auction.HeatMetric}%, bidder={auction.ActiveBiddersCount})";
return decision;
}
}
}
}
// 3. Verifica opponent profiling
if (settings.OpponentProfilingEnabled && auction.AggressiveBidders.Count > 0)
{
if (settings.AggressiveBidderAction == "Avoid")
{
decision.ShouldBid = false;
decision.Reason = $"Bidder aggressivi rilevati: {string.Join(", ", auction.AggressiveBidders.Take(3))}";
return decision;
}
}
// 4. Probabilistic bidding
if (settings.ProbabilisticBiddingEnabled)
{
var probability = CalculateBidProbability(auction, settings);
var roll = _random.NextDouble();
if (roll > probability)
{
decision.ShouldBid = false;
decision.Reason = $"Skip probabilistico (p={probability:P0}, roll={roll:P0})";
return decision;
}
}
// 5. Bankroll manager
if (settings.BankrollManagerEnabled)
{
var bankrollCheck = CheckBankrollLimits(auction, settings);
if (!bankrollCheck.CanBid)
{
decision.ShouldBid = false;
decision.Reason = bankrollCheck.Reason;
return decision;
}
}
// ? RIMOSSO: DetectLastSecondSniper - causava falsi positivi
// In un duello, TUTTI i bidder hanno pattern regolari (ogni reset del timer)
// Questa strategia bloccava puntate legittime e faceva perdere aste
// 7. VELOCITA' DEL PREZZO - l'asta sta salendo troppo in fretta.
//
// La soglia era fissa a 0,10 EUR/s, cioe' dieci puntate al secondo: su 40.000
// valutazioni riprese dai dossier non e' scattata mai una volta, e il massimo
// mai osservato e' 0,016 EUR/s. Ora e' un'impostazione, spenta di predefinito:
// un controllo che non puo' scattare da' una falsa sensazione di protezione.
if (settings.PriceVelocityBlockPerSecond > 0)
{
var priceVelocity = CalculatePriceVelocity(auction);
if (priceVelocity > settings.PriceVelocityBlockPerSecond)
{
decision.ShouldBid = false;
decision.Reason = $"Prezzo sale troppo in fretta ({priceVelocity:F3} EUR/s, soglia {settings.PriceVelocityBlockPerSecond:F3})";
return decision;
}
}
return decision;
}
/// <summary>
/// Calcola la velocit di crescita del prezzo (/secondo)
/// </summary>
private double CalculatePriceVelocity(AuctionInfo auction)
{
if (auction.RecentBids.Count < 5) return 0;
var recentBids = auction.RecentBids.Take(10).ToList();
if (recentBids.Count < 2) return 0;
var first = recentBids.Last();
var last = recentBids.First();
var timeDiffSeconds = last.Timestamp - first.Timestamp;
if (timeDiffSeconds <= 0) return 0;
var priceDiff = last.Price - first.Price;
return (double)priceDiff / timeDiffSeconds;
}
/// <summary>
/// Riconosce un avversario che punta a cadenza fissa.
///
/// <para><b>Limite di misura, da tenere presente.</b> Le marche temporali dello
/// storico di Bidoo sono in <i>secondi interi</i>: le pause fra due puntate sono
/// quindi numeri interi, e la loro deviazione standard vale 0 ms (pause tutte
/// uguali) oppure almeno 500 ms. La vecchia soglia "deviazione &lt; 50 ms" si
/// riduceva percio' a "le ultime pause sono identiche al secondo" - condizione
/// comunissima fra utenti normali: rigiocando i dossier raccolti rifiutava fra il
/// 4% e il 9% delle puntate, proprio negli istanti in cui il motore avrebbe
/// sparato.</para>
///
/// <para>Ora servono <b>quattro</b> pause tutte uguali e brevi (sotto i 15 s):
/// resta un indizio, non una prova, ed e' il motivo per cui l'impostazione che
/// la usa nasce spenta.</para>
/// </summary>
private (bool IsBot, double GapSeconds) DetectBotPattern(AuctionInfo auction, string? lastBidder, string currentUsername)
{
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
return (false, 0);
var userBids = auction.RecentBids
.Where(b => b.Username.Equals(lastBidder, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(b => b.Timestamp)
.Take(5)
.ToList();
if (userBids.Count < 5) return (false, 0);
var gaps = new List<long>();
for (var i = 0; i < userBids.Count - 1; i++)
gaps.Add(userBids[i].Timestamp - userBids[i + 1].Timestamp);
// Una pausa nulla vuol dire due puntate nello stesso secondo: e' rumore
// dello storico, non una cadenza.
if (gaps.Count < 4 || gaps.Any(g => g <= 0 || g > 15)) return (false, 0);
var isBot = gaps.All(g => g == gaps[0]);
return (isBot, gaps[0]);
}
/// <summary>
/// Verifica se un utente esausto (molte puntate, pu mollare)
/// </summary>
private (bool ShouldExploit, string Reason) CheckUserExhaustion(AuctionInfo auction, string? lastBidder, string currentUsername)
{
if (string.IsNullOrEmpty(lastBidder) || lastBidder.Equals(currentUsername, StringComparison.OrdinalIgnoreCase))
return (false, "");
// Verifica se l'utente un "heavy user" (>50 puntate totali)
if (auction.BidderStats.TryGetValue(lastBidder, out var stats))
{
if (stats.BidCount > 50)
{
// Se ci sono pochi altri bidder attivi, pu essere un buon momento
var activeBidders = auction.BidderStats.Values.Count(b => b.BidCount > 5);
if (activeBidders <= 3)
{
return (true, $"{lastBidder} ha {stats.BidCount} puntate, potrebbe mollare");
}
}
}
return (false, "");
}
/// <summary>
/// Calcola probabilit di puntata basata su competizione e ROI
/// </summary>
private double CalculateBidProbability(AuctionInfo auction, AppSettings settings)
{
var probability = settings.BaseBidProbability;
// Riduci probabilit per ogni bidder attivo oltre la soglia
var extraBidders = Math.Max(0, auction.ActiveBiddersCount - settings.CompetitionThreshold);
probability -= extraBidders * settings.ProbabilityReductionPerBidder;
// Riduci per heat metric alto
if (auction.HeatMetric > 70)
{
probability -= 0.1;
}
// Aumenta se abbiamo un buon ROI potenziale
if (auction.CalculatedValue?.Savings > 0)
{
probability += 0.1;
}
return Math.Clamp(probability, 0.1, 1.0);
}
/// <summary>
/// Verifica limiti bankroll
/// </summary>
private BankrollCheckResult CheckBankrollLimits(AuctionInfo auction, AppSettings settings)
{
var result = new BankrollCheckResult { CanBid = true };
// Limite puntate per asta
var maxPerAuction = auction.MaxBidsOverride ?? settings.MaxBidsPerAuction;
if (maxPerAuction > 0 && auction.SessionBidCount >= maxPerAuction)
{
result.CanBid = false;
result.Reason = $"Limite puntate per asta raggiunto ({auction.SessionBidCount}/{maxPerAuction})";
return result;
}
// Limite puntate per sessione
if (settings.MaxBidsPerSession > 0 && _sessionTotalBids >= settings.MaxBidsPerSession)
{
result.CanBid = false;
result.Reason = $"Limite puntate per sessione raggiunto ({_sessionTotalBids}/{settings.MaxBidsPerSession})";
return result;
}
// Budget giornaliero
if (settings.DailyBudgetEuro > 0)
{
var spent = _sessionTotalBids * settings.AverageBidCostEuro;
if (spent >= settings.DailyBudgetEuro)
{
result.CanBid = false;
result.Reason = $"Budget giornaliero esaurito ({spent:F2}/{settings.DailyBudgetEuro:F2})";
return result;
}
}
return result;
}
/// <summary>
/// Registra una puntata effettuata (per tracking)
/// </summary>
public void RecordBidAttempt(AuctionInfo auction, bool success, bool collision = false)
{
auction.SessionBidCount++;
_sessionTotalBids++;
if (success)
{
auction.SuccessfulBidCount++;
auction.ConsecutiveCollisions = 0;
}
else
{
auction.FailedBidCount++;
}
if (collision)
{
auction.CollisionCount++;
auction.ConsecutiveCollisions++;
}
}
/// <summary>
/// Registra un ciclo perso perche' la puntata e' arrivata a giochi chiusi.
///
/// <para>Qui <b>non</b> si tocca <c>ConsecutiveCollisions</c>: chi chiama questo
/// metodo ha gia' chiamato <see cref="RecordBidAttempt"/> con <c>collision: true</c>
/// sulla stessa puntata, e il contatore veniva percio' incrementato due volte.
/// Con la soglia predefinita di tre collisioni bastavano <i>due</i> puntate tardive
/// per far scattare il ritiro - e un ritiro di trenta secondi, su cicli da otto o
/// dieci, significa perdere l'asta.</para>
/// </summary>
public void RecordTimerExpired(AuctionInfo auction)
{
auction.TimerExpiredCount++;
}
/// <summary>
/// Reset contatori sessione
/// </summary>
public void ResetSession()
{
_sessionTotalBids = 0;
_sessionStartedAt = DateTime.UtcNow;
}
/// <summary>
/// Ottiene statistiche sessione corrente
/// </summary>
public SessionStats GetSessionStats()
{
return new SessionStats
{
TotalBids = _sessionTotalBids,
SessionDuration = DateTime.UtcNow - _sessionStartedAt
};
}
}
/// <summary>
/// Risultato calcolo timing puntata
/// </summary>
public class BidTimingResult
{
public int BaseOffsetMs { get; set; }
public int LatencyCompensationMs { get; set; }
public int DynamicAdjustmentMs { get; set; }
public int JitterMs { get; set; }
public int FinalOffsetMs { get; set; }
public bool ShouldBid { get; set; }
}
/// <summary>
/// Decisione se puntare
/// </summary>
public class BidDecision
{
public bool ShouldBid { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Risultato verifica bankroll
/// </summary>
public class BankrollCheckResult
{
public bool CanBid { get; set; }
public string? Reason { get; set; }
}
/// <summary>
/// Statistiche sessione
/// </summary>
public class SessionStats
{
public int TotalBids { get; set; }
public TimeSpan SessionDuration { get; set; }
}
}
+133 -426
View File
@@ -7,16 +7,20 @@ using System.Threading;
using System.Threading.Tasks;
using System.Text;
using AutoBidder.Models;
using AutoBidder.Net;
namespace AutoBidder.Services
{
/// <summary>
/// Servizio completo API Bidoo (polling, puntate, info utente)
/// Solo HTTP, nessuna modalità, browser o multi-click
/// Servizio completo API Bidoo (polling, puntate, info utente).
///
/// Tutte le richieste passano da <see cref="BidooHttpClient"/>, che tiene aperte le
/// connessioni e riserva una corsia preferenziale alle puntate: nel momento critico
/// non si paga né handshake né coda dietro al polling.
/// </summary>
public class BidooApiClient
{
private readonly HttpClient _httpClient;
private readonly BidooHttpClient _http;
private BidooSession _session;
// Event used to push detailed logs into per-auction log in the monitor
@@ -24,20 +28,13 @@ namespace AutoBidder.Services
public BidooApiClient()
{
var handler = new HttpClientHandler
{
UseCookies = false, // Gestiamo manualmente i cookie
AutomaticDecompression = System.Net.DecompressionMethods.All // Decomprimi GZIP/Deflate/Brotli
};
_httpClient = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(3)
};
_http = new BidooHttpClient();
_session = new BidooSession();
}
/// <summary>Trasporto condiviso: riscaldamento connessioni, contatori, limiti di ritmo.</summary>
public BidooHttpClient Transport => _http;
// Helper that writes to Console and, when auctionId provided, emits per-auction log event
private void Log(string message, string? auctionId = null)
{
@@ -57,18 +54,6 @@ namespace AutoBidder.Services
}
}
/// <summary>
/// Inizializza sessione con token di autenticazione
/// </summary>
public void InitializeSession(string authToken, string username)
{
_session.AuthToken = authToken;
_session.Username = username;
Log($"[SESSION] Token impostato ({authToken.Length} chars)");
Log($"[SESSION] Username: {username}");
}
/// <summary>
/// Inizializza sessione con cookie string (manuale)
/// </summary>
@@ -76,160 +61,37 @@ namespace AutoBidder.Services
{
_session.CookieString = cookieString;
_session.Username = username;
_http.SetCookie(cookieString);
Log($"[SESSION] Cookie impostato manualmente ({cookieString.Length} chars)");
Log($"[SESSION] Username: {username}");
}
/// <summary>
/// Aggiunge header di autenticazione e browser-like alla richiesta
/// Headers critici per evitare rilevamento come bot
/// </summary>
private void AddAuthHeaders(HttpRequestMessage request, string? referer = null, string? auctionId = null)
{
// 1. AUTENTICAZIONE (solo cookie manuale)
if (!string.IsNullOrWhiteSpace(_session.CookieString))
{
request.Headers.Add("Cookie", _session.CookieString);
// Log rimosso per ridurre verbosità
}
else
{
Log("[AUTH WARN] No authentication method available!", auctionId);
}
// 2. HEADERS BROWSER-LIKE (anti-detection)
// User-Agent realistico (Chrome su Windows)
request.Headers.Add("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");
// Accept headers
request.Headers.Add("Accept", "*/*");
request.Headers.Add("Accept-Language", "it-IT,it;q=0.9,en-US;q=0.8,en;q=0.7");
request.Headers.Add("Accept-Encoding", "gzip, deflate, br");
// Security headers (critici per CORS)
request.Headers.Add("Sec-Fetch-Dest", "empty");
request.Headers.Add("Sec-Fetch-Mode", "cors");
request.Headers.Add("Sec-Fetch-Site", "same-origin");
// Chrome-specific headers
request.Headers.Add("sec-ch-ua", "\"Google Chrome\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"");
request.Headers.Add("sec-ch-ua-mobile", "?0");
request.Headers.Add("sec-ch-ua-platform", "\"Windows\"");
// XMLHttpRequest identifier (FONDAMENTALE per API AJAX)
request.Headers.Add("X-Requested-With", "XMLHttpRequest");
// Referer (importante per validazione origin)
if (!string.IsNullOrEmpty(referer))
{
request.Headers.Add("Referer", referer);
}
else
{
request.Headers.Add("Referer", "https://it.bidoo.com/");
}
// Log rimosso per ridurre verbosità - headers sempre aggiunti
}
/// <summary>
/// Estrae CSRF/Bid token dalla pagina asta
/// PASSO 1: Ottenere la pagina HTML dell'asta per estrarre il token di sicurezza
/// Il token può essere chiamato: bid_token, csrf_token, _token, etc.
/// </summary>
private async Task<(string? tokenName, string? tokenValue)> ExtractBidTokenAsync(string auctionId, string? auctionUrl = null)
{
try
{
var url = !string.IsNullOrEmpty(auctionUrl) ? auctionUrl : $"https://it.bidoo.com/asta/nome-prodotto-{auctionId}";
Log($"[TOKEN] GET {url}", auctionId);
var request = new HttpRequestMessage(HttpMethod.Get, url);
AddAuthHeaders(request, url, auctionId);
var response = await _httpClient.SendAsync(request);
var html = await response.Content.ReadAsStringAsync();
Log($"[TOKEN] Response: {response.StatusCode}, HTML length: {html.Length}", auctionId);
var patterns = new System.Collections.Generic.List<(string pattern, string name)>
{
// double-quoted input attributes
("(?i)<input[^>]*name=\"bid_token\"[^>]*value=\"([^\"]+)\"", "bid_token"),
("(?i)<input[^>]*value=\"([^\"]+)\"[^>]*name=\"bid_token\"", "bid_token"),
("(?i)<input[^>]*name=\"csrf_token\"[^>]*value=\"([^\"]+)\"", "csrf_token"),
("(?i)<input[^>]*value=\"([^\"]+)\"[^>]*name=\"csrf_token\"", "csrf_token"),
("(?i)<input[^>]*name=\"_token\"[^>]*value=\"([^\"]+)\"", "_token"),
("(?i)<input[^>]*name=\"token\"[^>]*value=\"([^\"]+)\"", "token"),
// single-quoted input attributes
("(?i)<input[^>]*name='bid_token'[^>]*value='([^']+)'", "bid_token"),
("(?i)<input[^>]*value='([^']+)'[^>]*name='bid_token'", "bid_token"),
("(?i)<input[^>]*name='csrf_token'[^>]*value='([^']+)'", "csrf_token"),
("(?i)<input[^>]*value='([^']+)'[^>]*name='csrf_token'", "csrf_token"),
("(?i)<input[^>]*name='_token'[^>]*value='([^']+)'", "_token"),
("(?i)<input[^>]*name='token'[^>]*value='([^']+)'", "token"),
// JavaScript style assignments (double and single quotes)
("(?i)bid_token\\s*[:=]\\s*\"([^\\\"]+)\"", "bid_token"),
("(?i)bid_token\\s*[:=]\\s*'([^']+)'", "bid_token"),
("(?i)csrf_token\\s*[:=]\\s*\"([^\\\"]+)\"", "csrf_token"),
("(?i)csrf_token\\s*[:=]\\s*'([^']+)'", "csrf_token"),
// JSON style
("\"token\"\\s*:\\s*\"([^\\\"]+)\"", "token")
};
foreach (var pattern in patterns)
{
var match = System.Text.RegularExpressions.Regex.Match(html, pattern.pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
if (match.Success)
{
var tokenValue = match.Groups[1].Value;
Log($"[TOKEN] ✓ Token found: {pattern.name} = {tokenValue.Substring(0, Math.Min(20, tokenValue.Length))}...", auctionId);
return (pattern.name, tokenValue);
}
}
Log("[TOKEN] ⚠ No bid token found in HTML", auctionId);
return (null, null);
}
catch (Exception ex)
{
Log($"[TOKEN ERROR] {ex.Message}", auctionId);
return (null, null);
}
}
private Task<(string? tokenName, string? tokenValue)> ExtractBidTokenAsync(string auctionId)
{
return ExtractBidTokenAsync(auctionId, null);
}
public async Task<AuctionState?> PollAuctionStateAsync(string auctionId, string? auctionUrl, CancellationToken token)
{
try
{
var startTime = DateTime.UtcNow;
var url = $"https://it.bidoo.com/data.php?ALL={auctionId}&LISTID=0";
var request = new HttpRequestMessage(HttpMethod.Get, url);
var referer = !string.IsNullOrEmpty(auctionUrl)
? auctionUrl
: $"https://it.bidoo.com/auction.php?a=asta_{auctionId}";
AddAuthHeaders(request, referer, auctionId);
var response = await _httpClient.SendAsync(request, token);
var latency = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
var responseText = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
var outcome = await _http
.SendAsync(_http.BuildGet(url, referer), RequestPriority.Normal, token)
.ConfigureAwait(false);
if (!outcome.Success)
{
string reason = response.StatusCode == System.Net.HttpStatusCode.RequestTimeout ? "timeout" : "errore HTTP";
var reason = outcome.Error ?? $"HTTP {outcome.StatusCode}";
Log($"[ERRORE] [{auctionId}] API non ha risposto (motivo: {reason})", null); // globale
Log($"API non ha risposto: {response.StatusCode} ({reason})", auctionId); // asta
Log($"API non ha risposto: {reason}", auctionId); // asta
return null;
}
var state = ParsePollingResponse(auctionId, responseText, latency);
return state;
return ParsePollingResponse(auctionId, outcome.Body, outcome.LatencyMs, outcome.ReceivedTicks);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
@@ -243,164 +105,22 @@ namespace AutoBidder.Services
}
}
private AuctionState? ParsePollingResponse(string auctionId, string response, int latency)
{
try
{
string serverTimestamp;
string mainData;
var starIndex = response.IndexOf('*');
if (starIndex == -1)
{
Log("[PARSE ERROR] No '*' separator found in response", auctionId);
return null;
}
var timestampPart = response.Substring(0, starIndex);
mainData = response.Substring(starIndex + 1);
if (timestampPart.Contains('|'))
{
serverTimestamp = timestampPart.Split('|')[0];
}
else
{
serverTimestamp = timestampPart;
}
var bracketStart = mainData.IndexOf('[');
var bracketEnd = mainData.IndexOf(']');
if (bracketStart == -1 || bracketEnd == -1)
{
Log("[PARSE ERROR] Missing brackets in auction data", auctionId);
return null;
}
var auctionData = mainData.Substring(bracketStart + 1, bracketEnd - bracketStart - 1);
// Separa dati principali dalla storia puntate
var pipeIndex = auctionData.IndexOf('|');
var coreData = pipeIndex > 0 ? auctionData.Substring(0, pipeIndex) : auctionData;
var historyData = pipeIndex > 0 ? auctionData.Substring(pipeIndex + 1) : "";
var fields = coreData.Split(';');
if (fields.Length < 5)
{
Log($"[PARSE ERROR] Expected at least 5 core fields, got {fields.Length}", auctionId);
return null;
}
var state = new AuctionState
{
AuctionId = auctionId,
SnapshotTime = DateTime.UtcNow,
PollingLatencyMs = latency
};
var status = fields[1].Trim().ToUpperInvariant();
string lastBidder = fields[4].Trim();
bool hasWinner = !string.IsNullOrEmpty(lastBidder);
bool iAmWinner = hasWinner && lastBidder.Equals(_session.Username, StringComparison.OrdinalIgnoreCase);
state.Status = DetermineAuctionStatus(status, hasWinner, iAmWinner, ref state);
if (long.TryParse(serverTimestamp, out var serverTs) && long.TryParse(fields[2], out var expiryTs))
{
var timerSeconds = (double)(expiryTs - serverTs);
state.Timer = Math.Max(0, timerSeconds);
}
if (int.TryParse(fields[3], out var priceIndex))
{
state.Price = priceIndex * 0.01;
}
state.LastBidder = fields[4].Trim();
state.IsMyBid = !string.IsNullOrEmpty(_session.Username) &&
state.LastBidder.Equals(_session.Username, StringComparison.OrdinalIgnoreCase);
// ✅ NUOVO: Parse storia puntate
// Formato: 42;fedekikka2323;3,42;fedekikka2323;1764068204;3|41;chamorro1984;1764068194;3|...
if (!string.IsNullOrEmpty(historyData))
{
state.RecentBidsHistory = ParseBidHistory(historyData, fields[3]);
}
state.ParsingSuccess = true;
Log($"[PARSE SUCCESS] Timer: {state.Timer:F2}s, Price: €{state.Price:F2}, Bidder: {state.LastBidder}, Status: {state.Status}, History: {state.RecentBidsHistory?.Count ?? 0} bids", auctionId);
return state;
}
catch (Exception ex)
{
Log($"[PARSE EXCEPTION] {ex.GetType().Name}: {ex.Message}", auctionId);
return null;
}
}
/// <summary>
/// Parse la storia delle ultime puntate dalla risposta API
/// Formato: 41;chamorro1984;1764068194;3|40;fedekikka2323;1764068184;3|...
/// Interpreta la risposta di data.php delegando a <see cref="BidooResponseParser"/>,
/// che e' pura e verificata dai test. Qui resta solo il collegamento con sessione,
/// impostazioni e log.
/// </summary>
private List<BidHistoryEntry>? ParseBidHistory(string historyData, string currentPriceStr)
private AuctionState? ParsePollingResponse(string auctionId, string response, int latency, long receivedTicks)
{
try
{
var entries = new List<BidHistoryEntry>();
// Il primo record è spesso il prezzo corrente con dati duplicati, lo saltiamo
var records = historyData.Split('|');
// Parsing prezzo corrente per calcolare i prezzi precedenti
if (!int.TryParse(currentPriceStr, out var currentPriceIndex))
return null;
// 📊 NUOVO: Carica impostazione limite visualizzazione puntate
var settings = Utilities.SettingsManager.Load();
var maxEntries = settings?.MaxBidHistoryEntries ?? 20; // Default 20 se non impostato
foreach (var record in records)
{
if (string.IsNullOrWhiteSpace(record))
continue;
// 📊 Limita il numero di puntate basandosi sulle impostazioni
if (maxEntries > 0 && entries.Count >= maxEntries)
break;
var parts = record.Split(';');
if (parts.Length < 4)
continue;
// Formato: priceIndex;username;timestamp;bidType
// Es: 41;chamorro1984;1764068194;3
if (!int.TryParse(parts[0], out var priceIndex))
continue;
var username = parts[1].Trim();
if (!long.TryParse(parts[2], out var timestamp))
continue;
var bidTypeCode = parts.Length > 3 ? parts[3].Trim() : "0";
// Determina tipo puntata: 3 = Auto, 1 = Manuale
string bidType = bidTypeCode switch
{
"3" => "Auto",
"1" => "Manuale",
_ => "Auto"
};
var entry = new BidHistoryEntry
{
Price = priceIndex * 0.01m,
BidType = bidType,
Timestamp = timestamp,
Username = username,
IsMyBid = !string.IsNullOrEmpty(_session.Username) &&
username.Equals(_session.Username, StringComparison.OrdinalIgnoreCase)
};
entries.Add(entry);
}
return entries.Count > 0 ? entries : null;
}
catch
{
return null;
}
var result = BidooResponseParser.Parse(
auctionId, response, latency, receivedTicks,
_session.Username,
Utilities.SettingsManager.Load().MaxBidHistoryEntries);
if (result.Success) return result.State;
Log($"[PARSE ERROR] {result.Error}", auctionId);
return null;
}
public async Task<bool> UpdateUserInfoAsync()
@@ -412,22 +132,20 @@ namespace AutoBidder.Services
Log($"[USER INFO REQUEST] GET {url}");
var request = new HttpRequestMessage(HttpMethod.Get, url);
AddAuthHeaders(request, "https://it.bidoo.com/");
var outcome = await _http
.SendAsync(_http.BuildGet(url, "https://it.bidoo.com/", ajax: false),
RequestPriority.Background, CancellationToken.None)
.ConfigureAwait(false);
var startTime = DateTime.UtcNow;
var response = await _httpClient.SendAsync(request);
var latency = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
Log($"[USER INFO RESPONSE] Status: {outcome.StatusCode}, Latency: {outcome.LatencyMs}ms");
Log($"[USER INFO RESPONSE] Status: {(int)response.StatusCode} {response.StatusCode}, Latency: {latency}ms");
if (!response.IsSuccessStatusCode)
if (!outcome.Success)
{
Log($"[USER INFO ERROR] HTTP {response.StatusCode} - Cookie potrebbe essere scaduto o non valido");
Log($"[USER INFO ERROR] {outcome.Error ?? $"HTTP {outcome.StatusCode}"} - Cookie potrebbe essere scaduto o non valido");
return false;
}
var html = await response.Content.ReadAsStringAsync();
var html = outcome.Body;
Log($"[USER INFO RESPONSE] Body length: {html.Length} chars");
// Verifica se la risposta contiene HTML valido
@@ -523,7 +241,7 @@ namespace AutoBidder.Services
if (creditMatch.Success && double.TryParse(creditMatch.Groups[1].Value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out double credit))
{
_session.ShopCredit = credit;
Log($"[USER INFO PARSED] Shop credit: {credit:F2}");
Log($"[USER INFO PARSED] Shop credit: {credit:F2}");
foundData = true;
}
@@ -550,7 +268,7 @@ namespace AutoBidder.Services
}
}
public async Task<BidResult> PlaceBidAsync(string auctionId, string? auctionUrl = null)
public async Task<BidResult> PlaceBidAsync(string auctionId, string? auctionUrl = null, CancellationToken ct = default)
{
var result = new BidResult
{
@@ -562,21 +280,22 @@ namespace AutoBidder.Services
var url = "https://it.bidoo.com/bid.php";
var payload = $"AID={WebUtility.UrlEncode(auctionId)}&sup=0&shock=0";
var getUrl = url + "?" + payload;
var request = new HttpRequestMessage(HttpMethod.Get, getUrl);
var referer = !string.IsNullOrEmpty(auctionUrl) ? auctionUrl : $"https://it.bidoo.com/asta/nome-prodotto-{auctionId}";
AddAuthHeaders(request, referer, auctionId);
if (!request.Headers.Contains("Origin"))
{
request.Headers.Add("Origin", "https://it.bidoo.com");
}
var startTime = DateTime.UtcNow;
var response = await _httpClient.SendAsync(request);
result.LatencyMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
var responseText = await response.Content.ReadAsStringAsync();
var request = _http.BuildGet(getUrl, referer);
request.Headers.TryAddWithoutValidation("Origin", BidooHttpClient.Origin);
// Priorità critica: la puntata scavalca limitatore di ritmo e coda di
// concorrenza. È l'unica richiesta per cui il ritardo si paga in aste perse.
var outcome = await _http
.SendAsync(request, RequestPriority.Critical, ct)
.ConfigureAwait(false);
result.LatencyMs = outcome.LatencyMs;
var responseText = outcome.Body;
result.Response = responseText;
if (responseText.StartsWith("ok", StringComparison.OrdinalIgnoreCase))
{
result.Success = true;
@@ -635,8 +354,10 @@ namespace AutoBidder.Services
else
{
result.Success = false;
result.Error = string.IsNullOrEmpty(responseText) ? $"HTTP {(int)response.StatusCode}" : "Formato risposta inatteso";
Log($"[BID ERROR] Formato risposta inatteso: HTTP {(int)response.StatusCode}", auctionId);
result.Error = string.IsNullOrEmpty(responseText)
? (outcome.Error ?? $"HTTP {outcome.StatusCode}")
: "Formato risposta inatteso";
Log($"[BID ERROR] {result.Error} (HTTP {outcome.StatusCode})", auctionId);
}
return result;
@@ -650,68 +371,11 @@ namespace AutoBidder.Services
}
}
/// <summary>
/// Determina lo stato dell'asta basandosi su Status, LastBidder, Timer
///
/// STATI API BIDOO:
/// - ON: Asta attiva e in corso
/// - OFF: Asta terminata definitivamente
/// - STOP: Asta in pausa (tipicamente 00:00-10:00) - riprenderà più tardi
/// </summary>
private AuctionStatus DetermineAuctionStatus(string apiStatus, bool hasWinner, bool iAmWinner, ref AuctionState state)
{
// Gestione stato STOP (pausa notturna)
if (apiStatus == "STOP")
{
// L'asta è iniziata ma è in pausa
// Controlla se c'è già un vincitore temporaneo
if (hasWinner)
{
state.LastBidder = state.LastBidder; // Mantieni il last bidder
return AuctionStatus.Paused;
}
// Pausa senza puntate ancora
return AuctionStatus.Paused;
}
if (apiStatus == "OFF")
{
// Asta terminata definitivamente
if (hasWinner)
{
return iAmWinner ? AuctionStatus.EndedWon : AuctionStatus.EndedLost;
}
return AuctionStatus.Closed;
}
if (apiStatus == "ON")
{
// Asta attiva
if (hasWinner)
{
// Ci sono già puntate → Running
return AuctionStatus.Running;
}
// Nessuna puntata ancora → Pending o Scheduled
// Se timer molto alto (> 30 minuti), è programmata per più tardi
if (state.Timer > 1800) // 30 minuti
{
return AuctionStatus.Scheduled;
}
// Altrimenti sta per iniziare
return AuctionStatus.Pending;
}
return AuctionStatus.Unknown;
}
public BidooSession GetSession() => _session;
public void Dispose()
{
_httpClient?.Dispose();
_http?.Dispose();
}
/// <summary>
@@ -723,10 +387,11 @@ namespace AutoBidder.Services
{
var url = "https://it.bidoo.com/update_credits_status.php?submit=1";
Log($"[USER STATUS REQUEST] GET {url}");
var request = new HttpRequestMessage(HttpMethod.Get, url);
AddAuthHeaders(request, "https://it.bidoo.com/");
var response = await _httpClient.SendAsync(request);
var responseString = await response.Content.ReadAsStringAsync();
var outcome = await _http
.SendAsync(_http.BuildGet(url, "https://it.bidoo.com/"),
RequestPriority.Background, CancellationToken.None)
.ConfigureAwait(false);
var responseString = outcome.Body;
Log($"[USER STATUS RESPONSE] Body: {responseString}");
var userData = new UserData();
var trimmed = responseString.Trim();
@@ -781,6 +446,47 @@ namespace AutoBidder.Services
}
}
/// <summary>
/// Quante aste vinte aspettano di essere confermate (pagate) su Bidoo.
///
/// <para>È lo stesso indirizzo che il sito interroga per il pallino rosso in testata
/// (<c>UserNotifications.updateAuctionsWon</c> in <c>checkN_it.js</c>): risponde con
/// un solo numero in chiaro. Da non confondere con
/// <c>get_auction_bids_info_banner.php</c>, che conta le aste di puntate vinte
/// <i>nella giornata</i> ai fini del bonus e resta a zero anche quando ci sono
/// vincite da confermare.</para>
///
/// <para>Restituisce <c>null</c> se la risposta non è utilizzabile: chi chiama deve
/// poter distinguere "zero da confermare" da "non lo so", altrimenti un errore di
/// rete cancellerebbe dalla barra una vincita che invece esiste.</para>
/// </summary>
public async Task<int?> GetAuctionsWonToConfirmAsync(CancellationToken ct = default)
{
try
{
var url = "https://it.bidoo.com/check_auctions_won.php";
var outcome = await _http
.SendAsync(_http.BuildGet(url, "https://it.bidoo.com/"), RequestPriority.Background, ct)
.ConfigureAwait(false);
if (!outcome.Success) return null;
var body = outcome.Body?.Trim();
if (string.IsNullOrEmpty(body)) return null;
// Senza sessione valida Bidoo risponde con una pagina di login, non con
// un numero: il parsing fallisce ed è giusto che sia "non lo so".
return int.TryParse(body, out var count) && count >= 0 ? count : null;
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
Log($"[ASTE DA CONFERMARE] {ex.GetType().Name}: {ex.Message}");
return null;
}
}
/// <summary>
/// Ottiene info banner utente (aste vinte, bonus, ecc.) tramite chiamata AJAX
/// </summary>
@@ -790,12 +496,13 @@ namespace AutoBidder.Services
{
var url = "https://it.bidoo.com/ajax/get_auction_bids_info_banner.php";
Log($"[USER BANNER REQUEST] GET {url}");
var request = new HttpRequestMessage(HttpMethod.Get, url);
AddAuthHeaders(request, "https://it.bidoo.com/");
var response = await _httpClient.SendAsync(request);
var responseString = await response.Content.ReadAsStringAsync();
var outcome = await _http
.SendAsync(_http.BuildGet(url, "https://it.bidoo.com/"),
RequestPriority.Background, CancellationToken.None)
.ConfigureAwait(false);
var responseString = outcome.Body;
Log($"[USER BANNER RESPONSE] Body: {responseString}");
if (!response.IsSuccessStatusCode || string.IsNullOrWhiteSpace(responseString))
if (!outcome.Success || string.IsNullOrWhiteSpace(responseString))
return null;
var info = System.Text.Json.JsonSerializer.Deserialize<UserBannerInfo>(responseString);
return info;
@@ -817,20 +524,20 @@ namespace AutoBidder.Services
var url = "https://it.bidoo.com/bids_history.php";
Log($"[USER HTML REQUEST] GET {url}");
var request = new HttpRequestMessage(HttpMethod.Get, url);
AddAuthHeaders(request, "https://it.bidoo.com/");
var response = await _httpClient.SendAsync(request);
Log($"[USER HTML RESPONSE] Status: {(int)response.StatusCode} {response.StatusCode}");
if (!response.IsSuccessStatusCode)
var outcome = await _http
.SendAsync(_http.BuildGet(url, "https://it.bidoo.com/", ajax: false),
RequestPriority.Background, CancellationToken.None)
.ConfigureAwait(false);
Log($"[USER HTML RESPONSE] Status: {outcome.StatusCode}");
if (!outcome.Success)
{
Log($"[USER HTML ERROR] HTTP {response.StatusCode} - Cookie potrebbe essere scaduto");
Log($"[USER HTML ERROR] {outcome.Error ?? $"HTTP {outcome.StatusCode}"} - Cookie potrebbe essere scaduto");
return null;
}
var html = await response.Content.ReadAsStringAsync();
var html = outcome.Body;
Log($"[USER HTML RESPONSE] Body length: {html.Length} chars");
// Verifica se la risposta contiene HTML valido
@@ -899,7 +606,7 @@ namespace AutoBidder.Services
Log($"[USER HTML ERROR] Puntate residue NON trovate nell'HTML");
}
// Ritorna dati solo se almeno username è stato trovato
// Ritorna dati solo se almeno username stato trovato
if (foundUsername)
{
Log($"[USER HTML SUCCESS] Dati estratti: {userData.Username}, {userData.RemainingBids} puntate");
+393
View File
@@ -0,0 +1,393 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Net;
namespace AutoBidder.Services
{
/// <summary>
/// Sfoglia il catalogo pubblico di Bidoo: categorie, listato paginato a scorrimento e
/// aggiornamento massivo dei prezzi tramite <c>data.php?LISTID=...</c>. Non richiede
/// autenticazione.
///
/// Affianca il browser integrato: il browser resta la via comoda per navigare e per il
/// login (da cui si estrae il cookie), questa è la via veloce per confrontare molte aste
/// in una griglia ordinabile senza caricare pagine.
/// </summary>
public sealed partial class BidooCatalogClient
{
private readonly BidooHttpClient _http;
private readonly List<CatalogCategory> _categoryCache = new();
private DateTime _categoriesCachedAt = DateTime.MinValue;
public BidooCatalogClient(BidooHttpClient http) => _http = http;
/// <summary>
/// Diagnostica verso il log applicativo: senza, un catalogo vuoto è indistinguibile
/// da un errore di rete.
/// </summary>
public Action<string>? Diagnostic { get; set; }
private void Report(string message) => Diagnostic?.Invoke(message);
public async Task<List<CatalogCategory>> GetCategoriesAsync(bool forceRefresh, CancellationToken ct)
{
lock (_categoryCache)
{
if (!forceRefresh && _categoryCache.Count > 0 &&
DateTime.UtcNow - _categoriesCachedAt < TimeSpan.FromMinutes(30))
{
return _categoryCache.ToList();
}
}
var categories = new List<CatalogCategory>
{
new() { TabId = 3, DisplayName = "Tutte le aste", IsSpecial = true },
new() { TabId = 1, DisplayName = "Aste di puntate", IsSpecial = true },
new() { TabId = 5, DisplayName = "Aste manuali", IsSpecial = true }
};
try
{
var outcome = await _http
.SendAsync(_http.BuildGet(BidooHttpClient.Origin + "/", ajax: false),
RequestPriority.Background, ct)
.ConfigureAwait(false);
if (!outcome.Success)
{
Report($"[CATALOGO] Home non raggiungibile (HTTP {outcome.StatusCode}{FormatError(outcome)}): uso l'elenco categorie predefinito");
}
else
{
foreach (Match match in CategoryRegex().Matches(outcome.Body))
{
if (!int.TryParse(match.Groups[1].Value, out var tagId) || tagId <= 0) continue;
var name = CatalogPageParser.DecodeHtml(match.Groups[3].Value);
if (name.Length == 0) continue;
if (categories.Any(c => !c.IsSpecial && c.TagId == tagId)) continue;
categories.Add(new CatalogCategory
{
TabId = 4,
TagId = tagId,
Slug = match.Groups[2].Value.Trim(),
DisplayName = name
});
}
}
}
catch (OperationCanceledException) { throw; }
catch { /* si ricade sull'elenco predefinito */ }
if (categories.Count <= 3) categories.AddRange(DefaultCategories());
lock (_categoryCache)
{
_categoryCache.Clear();
_categoryCache.AddRange(categories);
_categoriesCachedAt = DateTime.UtcNow;
}
return categories;
}
private static IEnumerable<CatalogCategory> DefaultCategories() => new CatalogCategory[]
{
new() { TagId = 6, DisplayName = "Buoni", Slug = "buoni" },
new() { TagId = 5, DisplayName = "Smartphone", Slug = "smartphone" },
new() { TagId = 7, DisplayName = "Apple", Slug = "apple" },
new() { TagId = 13, DisplayName = "Bellezza", Slug = "bellezza" },
new() { TagId = 8, DisplayName = "Cucina", Slug = "cucina" },
new() { TagId = 18, DisplayName = "Casa e giardino", Slug = "casa_e_giardino" },
new() { TagId = 11, DisplayName = "Elettrodomestici", Slug = "elettrodomestici" },
new() { TagId = 9, DisplayName = "Videogame", Slug = "videogame" },
new() { TagId = 41, DisplayName = "Giocattoli", Slug = "giocattoli" },
new() { TagId = 14, DisplayName = "Tablet e PC", Slug = "tablet-e-pc" },
new() { TagId = 20, DisplayName = "Hobby", Slug = "hobby" },
new() { TagId = 22, DisplayName = "Smartwatch", Slug = "smartwatch" },
new() { TagId = 12, DisplayName = "Moda", Slug = "moda" },
new() { TagId = 10, DisplayName = "Smart TV", Slug = "smart-tv" },
new() { TagId = 21, DisplayName = "Fai da te", Slug = "fai_da_te" },
new() { TagId = 26, DisplayName = "Luxury", Slug = "luxury" },
new() { TagId = 19, DisplayName = "Cuffie e audio", Slug = "cuffie-e-audio" },
new() { TagId = 38, DisplayName = "Prima infanzia", Slug = "prima-infanzia" }
};
/// <summary>
/// Aste già scaricate di recente, per categoria. Un caricamento costa più richieste
/// in sequenza: senza cache, spostarsi avanti e indietro fra due categorie le rifà
/// tutte ogni volta, e la sorveglianza dei prodotti seguiti le ripete a ogni giro.
/// </summary>
private readonly Dictionary<string, (DateTime At, List<CatalogAuction> Auctions)> _auctionCache = new();
/// <summary>Svuota la cache: usato dal pulsante "Aggiorna", che deve fidarsi del server.</summary>
public void InvalidateCache()
{
lock (_auctionCache) _auctionCache.Clear();
}
/// <summary>
/// Raccoglie fino a <paramref name="maxAuctions"/> aste della categoria, scendendo
/// nel listato pagina per pagina come fa il sito quando si scorre verso il basso.
///
/// <para>Il listato è ordinato per scadenza crescente: le prime pagine sono le aste
/// che chiudono prima, cioè quelle su cui si decide. Fermarsi al tetto impostato
/// non taglia quindi via nulla di urgente.</para>
/// </summary>
public async Task<List<CatalogAuction>> GetAllAuctionsAsync(
CatalogCategory category, int maxAuctions, CancellationToken ct, int cacheSeconds = 0)
{
if (cacheSeconds > 0)
{
lock (_auctionCache)
{
if (_auctionCache.TryGetValue(category.Key, out var hit) &&
DateTime.UtcNow - hit.At < TimeSpan.FromSeconds(cacheSeconds) &&
hit.Auctions.Count > 0)
{
Report($"[CATALOGO] \"{category.DisplayName}\": {hit.Auctions.Count} aste (dalla cache)");
// Copia della lista, non degli elementi: i prezzi restano quelli
// reali e vengono comunque riaggiornati dal chiamante.
return hit.Auctions.ToList();
}
}
}
var all = new List<CatalogAuction>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var paged = await CollectPagesAsync(category, all, seen, maxAuctions, ct).ConfigureAwait(false);
if (!paged)
{
// Il sito ha cambiato interfaccia: meglio una griglia parziale che vuota.
Report($"[CATALOGO] \"{category.DisplayName}\": paginazione non disponibile, uso il listato classico");
await CollectLegacyAsync(category, all, seen, maxAuctions, ct).ConfigureAwait(false);
}
if (cacheSeconds > 0 && all.Count > 0)
{
lock (_auctionCache) _auctionCache[category.Key] = (DateTime.UtcNow, all.ToList());
}
Report($"[CATALOGO] \"{category.DisplayName}\": {all.Count} aste");
return all;
}
// ── Paginazione a scorrimento ────────────────────────────────────
//
// È il meccanismo del sito vero, ricavato dalle chiamate che fa il browser mentre
// si scorre la pagina:
//
// 1ª pagina GET /get_auctions.php?tab=T&tag=G
// seguenti POST /get_auction_updates.php
// prefetch=true&view=<id già mostrati>&tab=T&tag=G
//
// Non c'è un numero di pagina né un cursore: il client dice cosa ha già, e il
// server risponde con le cinquanta successive. Da qui due conseguenze pratiche —
// l'elenco `view` va portato avanti per intero a ogni richiesta, e la fine si
// riconosce solo dal fatto che una pagina torna vuota.
private const int PageSize = 50;
/// <summary>
/// Tetto assoluto di pagine, indipendente da quante ne servirebbero. Il server
/// segnala la fine restituendo una pagina vuota; se per un guasto smettesse di
/// farlo, questo impedisce un giro infinito.
/// </summary>
private const int MaxPages = 200;
/// <summary>
/// Scende nel listato finché ha raccolto abbastanza aste o finché il server smette
/// di darne. Restituisce <c>false</c> solo se la <i>prima</i> pagina non è
/// utilizzabile: è l'unico caso in cui vale la pena ripiegare sul listato classico.
/// </summary>
private async Task<bool> CollectPagesAsync(
CatalogCategory category, List<CatalogAuction> all, HashSet<string> seen,
int maxAuctions, CancellationToken ct)
{
var tab = category.IsSpecial ? category.TabId : 4;
var tag = category.IsSpecial ? 0 : category.TagId;
// L'elenco di ciò che si ha già: è l'unico "cursore" che il server accetta.
var view = new List<string>();
var pageLimit = Math.Min(MaxPages, (maxAuctions + PageSize - 1) / PageSize + 2);
for (var page = 0; page < pageLimit; page++)
{
ct.ThrowIfCancellationRequested();
var url = page == 0
? $"{BidooHttpClient.Origin}/get_auctions.php?tab={tab}&tag={tag}"
: $"{BidooHttpClient.Origin}/get_auction_updates.php";
var request = page == 0
? _http.BuildGet(url, BidooHttpClient.Origin + "/")
: _http.BuildPost(url, $"prefetch=true&view={string.Join(',', view)}&tab={tab}&tag={tag}",
BidooHttpClient.Origin + "/");
var outcome = await _http.SendAsync(request, RequestPriority.Background, ct).ConfigureAwait(false);
if (!outcome.Success)
{
if (page == 0)
{
Report($"[CATALOGO] \"{category.DisplayName}\": HTTP {outcome.StatusCode}{FormatError(outcome)}");
return false;
}
// A metà discesa un errore costa qualche asta, non la pagina.
break;
}
var parsed = CatalogPageParser.ParsePage(outcome.Body);
if (parsed is null)
{
if (page == 0) return false;
break;
}
// Nessun id restituito: si è arrivati in fondo al listato.
if (parsed.Ids.Count == 0) break;
var added = 0;
foreach (var auction in parsed.Auctions)
{
if (!seen.Add(auction.AuctionId)) continue;
all.Add(auction);
added++;
}
// Gli id vanno accumulati tutti, anche quelli di aste che non siamo
// riusciti a interpretare: sono ciò che dice al server dove eravamo.
view.AddRange(parsed.Ids);
if (all.Count >= maxAuctions) break;
// Una pagina che non porta nulla di nuovo significa che il server ha
// smesso di avanzare: insistere ripeterebbe le stesse aste all'infinito.
if (added == 0) break;
}
return all.Count > 0;
}
// ── Listato classico (ripiego) ───────────────────────────────────
/// <summary>
/// La vecchia pagina <c>index.php?selectBids=1</c>: non pagina, ma restituisce un
/// insieme diverso a ogni chiamata. Ripetendola si raccoglie qualcosa in più.
/// Serve solo se la paginazione a scorrimento smette di rispondere.
/// </summary>
private async Task CollectLegacyAsync(
CatalogCategory category, List<CatalogAuction> all, HashSet<string> seen,
int maxAuctions, CancellationToken ct)
{
var tab = category.IsSpecial ? category.TabId : 4;
var tag = category.IsSpecial ? 0 : category.TagId;
var url = $"{BidooHttpClient.Origin}/index.php?selectBids=1&tab={tab}&tag={tag}";
for (var round = 0; round < 4 && all.Count < maxAuctions; round++)
{
ct.ThrowIfCancellationRequested();
var outcome = await _http
.SendAsync(_http.BuildGet(url, BidooHttpClient.Origin + "/"), RequestPriority.Background, ct)
.ConfigureAwait(false);
if (!outcome.Success) return;
var added = 0;
foreach (var auction in CatalogPageParser.ParseAuctions(outcome.Body))
{
if (!seen.Add(auction.AuctionId)) continue;
all.Add(auction);
added++;
if (all.Count >= maxAuctions) return;
}
if (added == 0) return;
}
}
private static string FormatError(HttpOutcome outcome) =>
string.IsNullOrEmpty(outcome.Error) ? "" : $", {outcome.Error}";
/// <summary>
/// Aggiorna prezzo, ultimo puntatore e timer di molte aste con una sola chiamata.
/// Formato risposta: <c>serverTs*(id;STATO;scadenza;prezzoCent;utente#id2;...)</c>
/// </summary>
public async Task UpdateStatesAsync(IReadOnlyList<CatalogAuction> auctions, CancellationToken ct)
{
if (auctions.Count == 0) return;
// Blocchi da 60 id per non generare URL smisurati.
foreach (var chunk in auctions.Chunk(60))
{
var ids = string.Join(',', chunk.Select(a => a.AuctionId));
var url = $"{BidooHttpClient.Origin}/data.php?LISTID={ids}&chk={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
var outcome = await _http
.SendAsync(_http.BuildGet(url, BidooHttpClient.Origin + "/"), RequestPriority.Background, ct)
.ConfigureAwait(false);
if (!outcome.Success) continue;
ApplyListResponse(outcome.Body, chunk);
}
}
private static void ApplyListResponse(string response, IReadOnlyList<CatalogAuction> auctions)
{
var star = response.IndexOf('*');
if (star < 0) return;
if (!long.TryParse(response.AsSpan(0, star), out var serverSeconds)) return;
var data = response[(star + 1)..].Trim();
if (data.StartsWith('(') && data.EndsWith(')')) data = data[1..^1];
var byId = auctions.ToDictionary(a => a.AuctionId, StringComparer.Ordinal);
foreach (var entry in data.Split('#', StringSplitOptions.RemoveEmptyEntries))
{
var fields = entry.Split(';');
if (fields.Length < 4) continue;
if (!byId.TryGetValue(fields[0].Trim(), out var auction)) continue;
var status = fields[1].Trim();
if (int.TryParse(fields[3], out var cents)) auction.CurrentPrice = cents / 100m;
var bidder = fields.Length > 4 ? fields[4].Trim() : "";
if (bidder.Length > 0) auction.LastBidder = bidder;
if (long.TryParse(fields[2], out var expiry) && serverSeconds > 0)
{
auction.RemainingSeconds = (int)Math.Max(0, expiry - serverSeconds);
}
auction.IsActive = status.Equals("ON", StringComparison.OrdinalIgnoreCase);
auction.LastUpdated = DateTime.UtcNow;
}
}
[GeneratedRegex(@"javascript:selectBids\(4,\s*true,\s*false,\s*(\d+)\);\s*""\s+data-tab=""4""\s+data-slug=""([^""]*)""\s+data-tag=""\d+""><span[^>]*>([^<]+)</span>",
RegexOptions.IgnoreCase | RegexOptions.Singleline)]
private static partial Regex CategoryRegex();
}
}
+285
View File
@@ -0,0 +1,285 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Net;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Riscuote le ricompense di Bidoo passando dal trasporto HTTP condiviso.
///
/// <para>Non contiene alcun indirizzo, nome di campo o chiave di risposta: li prende
/// tutti da <see cref="FreeBidsSiteConfig"/>, cioè dal file <c>site-config.json</c>. È la
/// separazione che rende una modifica del sito una riga di JSON invece di una nuova
/// versione dell'applicazione. La configurazione viene <b>riletta a ogni giro</b>: si può
/// correggere un indirizzo mentre il monitor sta seguendo le aste.</para>
///
/// <para>Usa la priorità <see cref="RequestPriority.Background"/>: riscuotere un premio
/// da pochi centesimi non deve mai rubare la corsia a una puntata, che ha una scadenza
/// al millisecondo.</para>
/// </summary>
public sealed class BidooFreeBidsClaimer : IFreeBidsClaimer
{
private readonly BidooHttpClient _transport;
private readonly Func<FreeBidsSiteConfig> _config;
public BidooFreeBidsClaimer(BidooHttpClient transport, Func<FreeBidsSiteConfig>? config = null)
{
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_config = config ?? (() => FreeBidsConfigStore.Current);
}
/// <summary>Diagnostica verso il log applicativo. Facoltativa.</summary>
public Action<string>? Diagnostic { get; set; }
/// <summary>I riferimenti in vigore. Utile all'interfaccia per mostrarli o aprirli.</summary>
public FreeBidsSiteConfig Config => _config();
public Task<FreeBidsPageScan> ScanAsync(CancellationToken cancellationToken = default) =>
ScanAsync(_config(), cancellationToken);
/// <summary>
/// Legge la pagina con i riferimenti indicati. Il chiamante li passa perché lettura e
/// riscatto devono usare <b>gli stessi</b>: il file si può correggere mentre il giro
/// è in corso, e ritrovarsi con premi trovati su una pagina e riscossi con un altro
/// indirizzo sarebbe un guasto raro e incomprensibile.
/// </summary>
private async Task<FreeBidsPageScan> ScanAsync(
FreeBidsSiteConfig config,
CancellationToken cancellationToken)
{
if (!_transport.HasCookie)
return FreeBidsPageScan.Unrecognised("non connesso: manca il cookie di sessione");
try
{
var outcome = await SendAsync(
config,
new FreeBidsRequestPlan("GET", config.Url(config.Endpoints.Rewards), null, config.Url("/")),
// Come documento, non come chiamata di servizio: con gli header di una
// richiesta AJAX Bidoo risponde con un frammento invece della pagina.
asDocument: true,
cancellationToken).ConfigureAwait(false);
if (!outcome.Success)
return FreeBidsPageScan.Unrecognised($"il sito ha risposto {outcome.StatusCode}");
return FreeBidsPageParser.Scan(outcome.Body, config);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
return FreeBidsPageScan.Unrecognised($"lettura non riuscita: {ex.Message}");
}
}
public async Task<FreeBidsClaimResult> ClaimAllAsync(CancellationToken cancellationToken = default)
{
var config = _config();
FreeBidsPageScan scan;
try
{
scan = await ScanAsync(config, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
return FreeBidsClaimResult.Failure($"lettura non riuscita: {ex.Message}");
}
if (!scan.PageRecognised)
return FreeBidsClaimResult.Failure(scan.Diagnostic);
if (scan.Claimable.Count == 0)
return FreeBidsClaimResult.Nothing(scan.Diagnostic);
var claimed = 0;
var declared = 0;
foreach (var reward in scan.Claimable)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
// Il token esce dalla stessa pagina che ha mostrato i premi: è quello
// che il sito si aspetta indietro, e non è costata una richiesta in più.
var plan = FreeBidsRequestFactory.ForReward(config, reward, scan.CsrfToken);
var outcome = await SendAsync(config, plan, plan.AsDocument, cancellationToken)
.ConfigureAwait(false);
var verdict = FreeBidsResponseValidator.Validate(config, outcome.StatusCode, outcome.Body);
if (!verdict.Accepted)
{
Diagnostic?.Invoke($"ricompensa {reward.Id}: {verdict.Reason}");
continue;
}
claimed++;
declared += verdict.BidsDeclared ?? 0;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
// Un premio che non si lascia prendere non deve fermare gli altri.
Diagnostic?.Invoke($"ricompensa {reward.Id} non riscossa: {ex.Message}");
}
}
if (claimed == 0)
return FreeBidsClaimResult.Failure("nessuna delle ricompense trovate è stata accettata dal sito");
return new FreeBidsClaimResult(
IsSuccess: true,
ClaimedCount: claimed,
BidsGained: declared,
Message: $"{claimed} riscosse");
}
public async Task<FreeBidsClaimResult> ClaimPromoAsync(
string codeOrLink,
CancellationToken cancellationToken = default)
{
var config = _config();
if (!_transport.HasCookie)
return FreeBidsClaimResult.Failure("non connesso: manca il cookie di sessione");
string? token = null;
// Il token si va a prendere solo se la configurazione dice di mandarlo:
// altrimenti sarebbe una richiesta in più per un campo che nessuno legge.
if (!string.IsNullOrWhiteSpace(config.ClaimParameters.PromoCode.TokenField))
{
try
{
var scan = await ScanAsync(config, cancellationToken).ConfigureAwait(false);
token = scan.CsrfToken;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
// Senza token si prova lo stesso: il sito potrebbe non chiederlo affatto.
}
}
var plan = FreeBidsRequestFactory.TryForPromo(config, codeOrLink, token, out var problem);
if (plan == null) return FreeBidsClaimResult.Failure(problem);
try
{
var outcome = await SendAsync(config, plan, plan.AsDocument, cancellationToken)
.ConfigureAwait(false);
var verdict = FreeBidsResponseValidator.Validate(config, outcome.StatusCode, outcome.Body);
if (!verdict.Accepted)
return FreeBidsClaimResult.Failure(verdict.Message ?? verdict.Reason);
return new FreeBidsClaimResult(
IsSuccess: true,
ClaimedCount: 1,
BidsGained: verdict.BidsDeclared ?? 0,
Message: verdict.Message ?? "riscatto accettato");
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
return FreeBidsClaimResult.Failure($"riscatto non riuscito: {ex.Message}");
}
}
// ── Esecuzione ───────────────────────────────────────────────────
/// <summary>
/// Esegue un piano di richiesta applicando i riferimenti: intestazioni configurate e
/// attesa massima indicata nel file.
///
/// <para>L'attesa è un annullamento collegato a quello del chiamante, non una
/// proprietà del trasporto: il trasporto è condiviso con le puntate, e cambiargli il
/// timeout per un premio da riscuotere significherebbe cambiarlo anche a loro.</para>
/// </summary>
private async Task<HttpOutcome> SendAsync(
FreeBidsSiteConfig config,
FreeBidsRequestPlan plan,
bool asDocument,
CancellationToken cancellationToken)
{
var request = plan.IsPost
? _transport.BuildPost(plan.Url, plan.FormBody ?? "", referer: plan.Referer)
: _transport.BuildGet(plan.Url, referer: plan.Referer, ajax: !asDocument);
// Le intestazioni configurate valgono per le chiamate di riscatto; la lettura
// della pagina resta una richiesta da documento, con le sue.
if (!asDocument) ApplyConfiguredHeaders(request, config.DefaultHeaders);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(config.Timeout);
try
{
return await _transport
.SendAsync(request, RequestPriority.Background, timeout.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// Scaduta l'attesa configurata, non annullato dall'utente.
return new HttpOutcome
{
Success = false,
StatusCode = 0,
Body = "",
Error = $"nessuna risposta entro {config.Timeout.TotalSeconds:0.#} s"
};
}
}
/// <summary>
/// Sovrascrive sulla richiesta le intestazioni indicate nel file. Vanno rimosse prima
/// di aggiungerle: <see cref="BidooHttpClient"/> ne ha già messe alcune, e sommarle
/// darebbe un'intestazione con due valori invece di quello scelto.
/// </summary>
private static void ApplyConfiguredHeaders(
HttpRequestMessage request,
IReadOnlyDictionary<string, string>? headers)
{
if (headers == null) return;
foreach (var (name, value) in headers)
{
if (string.IsNullOrWhiteSpace(name)) continue;
request.Headers.Remove(name);
request.Content?.Headers.Remove(name);
if (request.Headers.TryAddWithoutValidation(name, value)) continue;
// Content-Type e simili vivono sul contenuto, non sulla richiesta.
request.Content?.Headers.TryAddWithoutValidation(name, value);
}
}
}
}
+219
View File
@@ -0,0 +1,219 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Net;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Raccoglie i collegamenti promozionali da una pagina che li pubblica e li apre con la
/// sessione Bidoo dell'utente.
///
/// <para>Il giro completo è: <b>leggere</b> la pagina di raccolta (senza cookie: è un
/// sito di terzi), <b>filtrare</b> i collegamenti che sono davvero riscatti firmati,
/// <b>scartare</b> quelli già presi, <b>aprire</b> i rimanenti uno alla volta con una
/// pausa fra l'uno e l'altro.</para>
///
/// <para>Ogni riferimento — indirizzo di origine, classe dei collegamenti, dominio e
/// percorso di destinazione, parametri obbligatori, pausa e tetto per giro — sta nella
/// sezione <c>PromoHarvest</c> di <c>site-config.json</c>. Qui non c'è nessun indirizzo
/// scritto a mano: il sito di raccolta è di terzi e può cambiare in qualsiasi momento.</para>
/// </summary>
public sealed class BidooPromoRedeemer : IPromoLinkRedeemer
{
private readonly BidooHttpClient _transport;
private readonly Func<FreeBidsSiteConfig> _config;
public BidooPromoRedeemer(BidooHttpClient transport, Func<FreeBidsSiteConfig>? config = null)
{
_transport = transport ?? throw new ArgumentNullException(nameof(transport));
_config = config ?? (() => FreeBidsConfigStore.Current);
}
/// <summary>Diagnostica verso il registro della scheda. Facoltativa.</summary>
public Action<string>? Diagnostic { get; set; }
public async Task<PromoHarvestReport> HarvestAndRedeemAsync(CancellationToken cancellationToken = default)
{
var config = _config();
var settings = config.PromoHarvest;
if (!settings.Enabled)
return PromoHarvestReport.Nothing("raccolta dei collegamenti spenta");
if (settings.SourceUrl.Length == 0)
return PromoHarvestReport.Unreadable("indirizzo della pagina dei collegamenti non configurato");
// Senza sessione i collegamenti si aprirebbero da sconosciuti: il premio
// andrebbe perso davvero, perché quei codici valgono una volta sola.
if (!_transport.HasCookie)
return PromoHarvestReport.Unreadable("non connesso: manca il cookie di sessione");
string html;
try
{
html = await DownloadSourceAsync(config, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
return PromoHarvestReport.Unreadable($"pagina dei collegamenti non raggiungibile: {ex.Message}");
}
var links = PromoLinkParser.Extract(html, settings, out var problem);
if (links.Count == 0)
return problem == null
? PromoHarvestReport.Nothing("nessun collegamento da riscuotere")
: PromoHarvestReport.Unreadable(problem);
// I doppioni sono il caso normale, non l'eccezione: la pagina tiene gli stessi
// collegamenti per giorni.
var fresh = links
.Where(l => ClaimedPromoStore.ShouldTry(l.Code, settings.MaxAttemptsPerCode))
.ToList();
var skipped = links.Count - fresh.Count;
if (fresh.Count == 0)
return new PromoHarvestReport(true, links.Count, skipped, 0, 0,
$"{links.Count} collegamenti, tutti già presi", Array.Empty<RedeemResult>());
var batch = fresh.Take(settings.MaxLinksPerRound).ToList();
var results = new List<RedeemResult>(batch.Count);
for (var i = 0; i < batch.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var result = await RedeemAsync(config, batch[i], cancellationToken).ConfigureAwait(false);
results.Add(result);
ClaimedPromoStore.RecordOutcome(result.PromoCode, result.IsSuccess, result.Details);
Diagnostic?.Invoke($"codice {result.PromoCode}: {result.Details}");
// Pausa fra un riscatto e il successivo, mai dopo l'ultimo: sono richieste
// tutte uguali in fila, cioè quello che un limite di frequenza riconosce.
if (i < batch.Count - 1 &&
!await Wait.DelayAsync(settings.DelayBetweenClaimsMs, cancellationToken).ConfigureAwait(false))
break;
}
var claimed = results.Count(r => r.IsSuccess);
var failed = results.Count - claimed;
var remaining = fresh.Count - batch.Count;
var message = $"{links.Count} trovati, {skipped} già presi, {claimed} riscossi" +
(failed > 0 ? $", {failed} non riusciti" : "") +
(remaining > 0 ? $" ({remaining} al prossimo giro)" : "");
return new PromoHarvestReport(true, links.Count, skipped, claimed, failed, message, results);
}
/// <summary>
/// Scarica la pagina di raccolta. Richiesta <b>senza</b> il cookie di Bidoo: il sito
/// è di terzi, quel cookie non gli serve, e mandarglielo consegnerebbe la sessione.
/// </summary>
private async Task<string> DownloadSourceAsync(FreeBidsSiteConfig config, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(config.Timeout);
var outcome = await _transport
.SendAsync(_transport.BuildExternalGet(config.PromoHarvest.SourceUrl),
RequestPriority.Background, timeout.Token)
.ConfigureAwait(false);
if (!outcome.Success)
throw new InvalidOperationException(outcome.Error ?? $"HTTP {outcome.StatusCode}");
return outcome.Body;
}
/// <summary>
/// Apre un collegamento di riscatto come lo aprirebbe il browser: richiesta da
/// documento, con il cookie di sessione e senza aggiungere parametri — l'indirizzo
/// porta una firma, e modificarlo la invaliderebbe.
/// </summary>
private async Task<RedeemResult> RedeemAsync(
FreeBidsSiteConfig config,
PromoLink link,
CancellationToken ct)
{
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(config.Timeout);
var request = _transport.BuildGet(link.Url, referer: config.Url("/"), ajax: false);
var outcome = await _transport
.SendAsync(request, RequestPriority.Background, timeout.Token)
.ConfigureAwait(false);
if (!outcome.Success)
return new RedeemResult(link.Code, false, outcome.Error ?? $"HTTP {outcome.StatusCode}");
return Judge(config, link.Code, outcome.Body);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
return new RedeemResult(link.Code, false, $"nessuna risposta entro {config.Timeout.TotalSeconds:0.#} s");
}
catch (Exception ex)
{
return new RedeemResult(link.Code, false, ex.Message);
}
}
/// <summary>
/// Giudica la pagina di risposta.
///
/// <para>Qui l'HTTP 200 non dice nulla: il riscatto risponde con la home di Bidoo sia
/// quando accredita le puntate sia quando il codice era già stato usato. Contano solo
/// i testi configurati — e quando non ce n'è nessuno l'esito resta <b>incerto</b>,
/// non "riuscito": il codice va comunque segnato come consumato (aprirlo di nuovo non
/// porterebbe nulla), ma il numero vero lo dà la differenza di saldo.</para>
/// </summary>
private static RedeemResult Judge(FreeBidsSiteConfig config, string code, string? body)
{
var settings = config.PromoHarvest;
var text = body ?? "";
if (FreeBidsPageParser.LooksLikeLogin(text, config))
return new RedeemResult(code, false, "sessione scaduta: risposta la pagina di accesso");
var success = settings.SuccessMarkers
.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m) &&
text.Contains(m, StringComparison.OrdinalIgnoreCase));
if (success != null)
{
var declared = FreeBidsPageParser.ReadGainedBids(text, config);
return new RedeemResult(code, true,
declared.HasValue ? $"riscosso (+{declared} puntate dichiarate)" : "riscosso");
}
var expired = settings.ExpiredMarkers
.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m) &&
text.Contains(m, StringComparison.OrdinalIgnoreCase));
if (expired != null)
return new RedeemResult(code, false, $"non più valido («{expired}»)");
return new RedeemResult(code, true, "aperto, esito non dichiarato dalla pagina");
}
}
}
+232
View File
@@ -0,0 +1,232 @@
using System;
using System.Collections.Generic;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Traduce le risposte di <c>data.php</c> nel modello dell'applicazione.
///
/// È una classe pura di proposito: nessuna rete, nessuna impostazione globale, nessun
/// log. Il formato di Bidoo è posizionale e senza documentazione, quindi è il punto in
/// cui un cambiamento del sito si manifesta per primo — ed è anche il punto in cui un
/// errore passa inosservato, perché una risposta malformata si limita a produrre uno
/// stato sbagliato. Isolarlo così lo rende verificabile con i test.
///
/// <para>Formato: <c>serverTs*[core|storico]</c> dove <c>core</c> è
/// <c>id;STATO;scadenza;prezzoCent;utente</c> e <c>storico</c> è una sequenza di
/// <c>prezzoCent;utente;istante;tipo</c> separata da <c>|</c>.</para>
/// </summary>
public static class BidooResponseParser
{
/// <summary>Esito del parsing: lo stato, oppure il motivo per cui non si è potuto leggere.</summary>
public readonly struct ParseResult
{
public AuctionState? State { get; init; }
public string? Error { get; init; }
public bool Success => State != null;
public static ParseResult Fail(string reason) => new() { Error = reason };
public static ParseResult Ok(AuctionState state) => new() { State = state };
}
/// <summary>
/// Interpreta una risposta di data.php.
/// </summary>
/// <param name="username">Utente corrente, per riconoscere le proprie puntate.</param>
/// <param name="maxHistoryEntries">Tetto alle puntate lette dallo storico (0 = tutte).</param>
public static ParseResult Parse(
string auctionId,
string response,
int latencyMs,
long receivedTicks,
string? username,
int maxHistoryEntries = 20)
{
if (string.IsNullOrWhiteSpace(response))
return ParseResult.Fail("risposta vuota");
var starIndex = response.IndexOf('*');
if (starIndex < 0)
return ParseResult.Fail("separatore '*' assente");
// La parte prima di '*' può contenere altri campi separati da '|': il timestamp
// del server è comunque il primo.
var timestampPart = response[..starIndex];
var pipeInTimestamp = timestampPart.IndexOf('|');
var serverTimestamp = pipeInTimestamp > 0 ? timestampPart[..pipeInTimestamp] : timestampPart;
var mainData = response[(starIndex + 1)..];
var bracketStart = mainData.IndexOf('[');
var bracketEnd = mainData.IndexOf(']');
if (bracketStart < 0 || bracketEnd < 0 || bracketEnd < bracketStart)
return ParseResult.Fail("parentesi quadre assenti o invertite");
var auctionData = mainData[(bracketStart + 1)..bracketEnd];
// La VIRGOLA separa i dati correnti dallo storico. Non il '|': quello divide i
// record dello storico fra loro, e nelle aste concluse divide anche il conteggio
// delle puntate del vincitore.
//
// Prima si tagliava sul primo '|', e su un'asta in corso quel '|' cade dentro lo
// storico: il pezzo prima finiva nel "core" e la puntata piu' recente — proprio
// quella che conta — spariva a ogni interrogazione.
var comma = auctionData.IndexOf(',');
var coreData = comma >= 0 ? auctionData[..comma] : auctionData;
var historyData = comma >= 0 ? auctionData[(comma + 1)..] : "";
// Nelle aste concluse il core porta in coda il conteggio delle puntate del
// vincitore: id;OFF;scadenza;prezzo;vincitore;tipo;durata;|pagate|gratis|...
var winnerDetails = "";
var pipeInCore = coreData.IndexOf('|');
if (pipeInCore >= 0)
{
winnerDetails = coreData[(pipeInCore + 1)..];
coreData = coreData[..pipeInCore];
}
var fields = coreData.Split(';');
if (fields.Length < 5)
return ParseResult.Fail($"attesi almeno 5 campi, trovati {fields.Length}");
var state = new AuctionState
{
AuctionId = auctionId,
SnapshotTime = DateTime.UtcNow,
PollingLatencyMs = latencyMs,
ReceivedTicks = receivedTicks,
LastBidder = fields[4].Trim()
};
var apiStatus = fields[1].Trim().ToUpperInvariant();
var hasWinner = state.LastBidder.Length > 0;
var iAmWinner = hasWinner && !string.IsNullOrEmpty(username) &&
state.LastBidder.Equals(username, StringComparison.OrdinalIgnoreCase);
state.IsMyBid = iAmWinner;
if (long.TryParse(serverTimestamp, out var serverTs) &&
long.TryParse(fields[2], out var expiryTs))
{
state.Timer = Math.Max(0, expiryTs - serverTs);
// Materia prima per ServerClock: i due istanti assoluti del server.
// Il solo Timer è già una differenza e perde l'aggancio all'orologio remoto.
state.ServerUnixSeconds = serverTs;
state.ExpiryUnixSeconds = expiryTs;
}
if (int.TryParse(fields[3], out var priceIndex))
state.Price = priceIndex * 0.01;
state.Status = DetermineStatus(apiStatus, hasWinner, iAmWinner, state.Timer);
if (winnerDetails.Length > 0)
{
var (paid, free) = ParseWinnerBids(winnerDetails);
state.WinnerBidsPaid = paid;
state.WinnerBidsFree = free;
}
if (historyData.Length > 0)
state.RecentBidsHistory = ParseBidHistory(historyData, username, maxHistoryEntries);
state.ParsingSuccess = true;
return ParseResult.Ok(state);
}
/// <summary>
/// Puntate spese dal vincitore, come le dichiara il server a fine asta.
///
/// <para>Formato: <c>pagate|gratis|super|shock|manuali|automatiche|</c>. Il sito usa
/// gli stessi due primi valori per la scritta "Puntate utilizzate" sulla pagina
/// dell'asta conclusa, sommandoli.</para>
///
/// <para>È l'unica fonte attendibile per questo numero: lo storico restituito dal
/// server si ferma alle ultime cinquanta puntate, quindi contarle per conto proprio
/// funziona solo per le aste cortissime e sbaglia in silenzio su tutte le altre.</para>
/// </summary>
public static (int? Paid, int? Free) ParseWinnerBids(string? details)
{
if (string.IsNullOrWhiteSpace(details)) return (null, null);
var parts = details.Split('|');
if (parts.Length < 2) return (null, null);
int? paid = int.TryParse(parts[0].Trim(), out var p) && p >= 0 ? p : null;
int? free = int.TryParse(parts[1].Trim(), out var f) && f >= 0 ? f : null;
return (paid, free);
}
/// <summary>
/// Stato dell'asta a partire dal codice del server.
///
/// ON = in corso, OFF = conclusa, STOP = sospesa (Bidoo ferma le aste di notte).
/// </summary>
public static AuctionStatus DetermineStatus(string apiStatus, bool hasWinner, bool iAmWinner, double timerSeconds)
{
switch (apiStatus)
{
case "STOP":
return AuctionStatus.Paused;
case "OFF":
if (!hasWinner) return AuctionStatus.Closed;
return iAmWinner ? AuctionStatus.EndedWon : AuctionStatus.EndedLost;
case "ON":
if (hasWinner) return AuctionStatus.Running;
// Nessuna puntata ancora: o sta per cominciare, o è programmata più in là.
return timerSeconds > 1800 ? AuctionStatus.Scheduled : AuctionStatus.Pending;
default:
return AuctionStatus.Unknown;
}
}
/// <summary>
/// Storico delle ultime puntate.
/// Formato di ogni record: <c>prezzoCent;utente;istanteUnix;tipo</c>,
/// dove tipo 3 = automatica e 1 = manuale.
/// </summary>
public static List<BidHistoryEntry>? ParseBidHistory(string historyData, string? username, int maxEntries = 20)
{
if (string.IsNullOrWhiteSpace(historyData)) return null;
var entries = new List<BidHistoryEntry>();
foreach (var record in historyData.Split('|'))
{
if (maxEntries > 0 && entries.Count >= maxEntries) break;
if (string.IsNullOrWhiteSpace(record)) continue;
var parts = record.Split(';');
if (parts.Length < 4) continue;
if (!int.TryParse(parts[0], out var priceIndex)) continue;
if (!long.TryParse(parts[2], out var timestamp)) continue;
var user = parts[1].Trim();
entries.Add(new BidHistoryEntry
{
Price = priceIndex * 0.01m,
Username = user,
Timestamp = timestamp,
BidType = parts[3].Trim() switch
{
"1" => "Manuale",
_ => "Auto"
},
IsMyBid = !string.IsNullOrEmpty(username) &&
user.Equals(username, StringComparison.OrdinalIgnoreCase)
});
}
return entries.Count > 0 ? entries : null;
}
}
}
+305
View File
@@ -0,0 +1,305 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using AutoBidder.Models;
using AutoBidder.Net;
namespace AutoBidder.Services
{
/// <summary>Una pagina di listato: le schede interpretate e gli id così come arrivano.</summary>
public sealed record CatalogPage(List<CatalogAuction> Auctions, List<string> Ids);
/// <summary>
/// Interpreta le risposte del listato di Bidoo. Puro e senza stato: sta a parte dal
/// client HTTP proprio per poter essere messo alla prova su risposte registrate, che è
/// l'unico modo di accorgersi che il sito ha cambiato forma senza aspettare una
/// griglia vuota.
///
/// <para>Le due chiamate del listato hanno la stessa struttura con nomi diversi: le
/// schede stanno in <c>content</c> alla prima pagina e in <c>items</c> in quelle
/// successive, gli attributi in <c>auctions_attributes</c> oppure in
/// <c>attributes</c>.</para>
/// </summary>
public static partial class CatalogPageParser
{
/// <summary>
/// Interpreta la risposta di una pagina di listato. Restituisce <c>null</c> se la
/// risposta non ha la forma attesa: al chiamante serve distinguere "pagina vuota,
/// siamo in fondo" da "non ho capito la risposta".
/// </summary>
public static CatalogPage? ParsePage(string? body)
{
if (string.IsNullOrWhiteSpace(body)) return null;
try
{
using var document = JsonDocument.Parse(body);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object) return null;
if (!root.TryGetProperty("list", out var list) || list.ValueKind != JsonValueKind.Array) return null;
var ids = new List<string>(list.GetArrayLength());
foreach (var element in list.EnumerateArray())
{
// Bidoo scrive gli id ora come numero, ora come stringa.
var id = element.ValueKind == JsonValueKind.String ? element.GetString() : element.GetRawText();
if (!string.IsNullOrWhiteSpace(id)) ids.Add(id!);
}
var html = new StringBuilder();
if (TryGetArray(root, "content", out var cards) || TryGetArray(root, "items", out cards))
{
foreach (var card in cards.EnumerateArray())
{
if (card.ValueKind != JsonValueKind.String) continue;
html.Append(card.GetString());
html.Append('\n');
}
}
var auctions = ParseAuctions(html.ToString());
if (TryGetObject(root, "auctions_attributes", out var attributes) ||
TryGetObject(root, "attributes", out attributes))
{
ApplyAttributes(auctions, attributes);
}
return new CatalogPage(auctions, ids);
}
catch (JsonException)
{
return null;
}
}
private static bool TryGetArray(JsonElement root, string name, out JsonElement value) =>
root.TryGetProperty(name, out value) && value.ValueKind == JsonValueKind.Array;
private static bool TryGetObject(JsonElement root, string name, out JsonElement value) =>
root.TryGetProperty(name, out value) && value.ValueKind == JsonValueKind.Object;
/// <summary>
/// Sovrascrive con i valori strutturati quelli ricavati dall'HTML: stessa
/// informazione, ma senza dipendere dalla forma del markup.
/// </summary>
private static void ApplyAttributes(List<CatalogAuction> auctions, JsonElement attributes)
{
foreach (var auction in auctions)
{
if (!attributes.TryGetProperty(auction.AuctionId, out var attribute)) continue;
if (attribute.ValueKind != JsonValueKind.Object) continue;
if (TryReadInt(attribute, "data-freq", out var frequency) && frequency > 0)
auction.TimerFrequency = frequency;
if (TryReadInt(attribute, "data-credit", out var credit))
auction.IsCreditAuction = credit == 1;
if (TryReadInt(attribute, "data-credit-value", out var creditValue))
auction.CreditValue = creditValue;
if (TryReadInt(attribute, "data-id-product", out var productId) && productId > 0)
auction.ProductId = productId;
if (attribute.TryGetProperty("data-url", out var slug) &&
slug.ValueKind == JsonValueKind.String &&
!string.IsNullOrWhiteSpace(slug.GetString()))
{
auction.Url = $"{BidooHttpClient.Origin}/auction.php?a={slug.GetString()}";
}
}
}
/// <summary>Bidoo scrive gli stessi attributi ora come numero, ora come stringa.</summary>
private static bool TryReadInt(JsonElement owner, string name, out int value)
{
value = 0;
if (!owner.TryGetProperty(name, out var property)) return false;
return property.ValueKind switch
{
JsonValueKind.Number => property.TryGetInt32(out value),
JsonValueKind.String => int.TryParse(property.GetString(), NumberStyles.Integer,
CultureInfo.InvariantCulture, out value),
_ => false
};
}
// ── Schede prodotto ──────────────────────────────────────────────
/// <summary>
/// Estrae le schede da un blocco di HTML. Vale sia per le pagine del listato sia
/// per la vecchia pagina intera: il markup della singola scheda è lo stesso.
/// </summary>
public static List<CatalogAuction> ParseAuctions(string html)
{
var auctions = new List<CatalogAuction>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var matches = AuctionDivRegex().Matches(html);
for (var i = 0; i < matches.Count; i++)
{
var match = matches[i];
var id = match.Groups[1].Value;
if (!seen.Add(id)) continue;
// Il blocco di un'asta finisce dove inizia quello successivo.
var start = match.Index;
var end = i + 1 < matches.Count ? matches[i + 1].Index : Math.Min(html.Length, start + 6000);
var block = html[start..end];
var auction = ParseSingle(id, block);
if (auction is not null) auctions.Add(auction);
}
return auctions;
}
private static CatalogAuction? ParseSingle(string id, string block)
{
try
{
var auction = new CatalogAuction { AuctionId = id };
var m = DataUrlRegex().Match(block);
auction.Url = m.Success
? $"{BidooHttpClient.Origin}/auction.php?a={m.Groups[1].Value}"
: $"{BidooHttpClient.Origin}/auction.php?a={id}";
m = DataFreqRegex().Match(block);
if (m.Success && int.TryParse(m.Groups[1].Value, out var freq)) auction.TimerFrequency = freq;
m = DataCreditRegex().Match(block);
auction.IsCreditAuction = m.Success && m.Groups[1].Value == "1";
m = DataCreditValueRegex().Match(block);
if (m.Success && int.TryParse(m.Groups[1].Value, out var creditValue)) auction.CreditValue = creditValue;
m = DataProductRegex().Match(block);
if (m.Success && int.TryParse(m.Groups[1].Value, out var productId)) auction.ProductId = productId;
m = ImageRegex().Match(block);
if (m.Success) auction.ImageUrl = m.Groups[1].Value;
m = NameRegex().Match(block);
if (m.Success) auction.Name = DecodeHtml(m.Groups[1].Value);
if (string.IsNullOrWhiteSpace(auction.Name)) auction.Name = $"Asta {id}";
m = BuyNowRegex().Match(block);
if (m.Success && TryParsePrice(m.Groups[1].Value, out var buyNow)) auction.BuyNowPrice = buyNow;
auction.IsManualOnly = block.Contains("bi-noauto", StringComparison.OrdinalIgnoreCase);
auction.CurrentPrice = 0.01m;
auction.RemainingSeconds = auction.TimerFrequency;
auction.LastUpdated = DateTime.UtcNow;
return auction;
}
catch
{
return null;
}
}
/// <summary>
/// Legge un prezzo scritto all'italiana: punto per le migliaia, virgola per i
/// decimali. Il punto si toglie solo quando è davvero un separatore di migliaia —
/// perché c'è anche una virgola, o perché raggruppa cifre a tre a tre — altrimenti
/// "14.00" diventerebbe millequattrocento.
/// </summary>
private static bool TryParsePrice(string raw, out decimal value)
{
value = 0;
if (string.IsNullOrWhiteSpace(raw)) return false;
var text = raw.Trim();
if (text.Contains(','))
{
text = text.Replace(".", "").Replace(',', '.');
}
else if (ThousandsRegex().IsMatch(text))
{
text = text.Replace(".", "");
}
return decimal.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out value);
}
// ── Testo ────────────────────────────────────────────────────────
/// <summary>
/// WebUtility.HtmlDecode conosce solo le entità HTML 4: nei nomi prodotto compaiono
/// però anche entità HTML5 (&amp;dash;, &amp;plus;, &amp;excl;…) che resterebbero
/// visibili così come sono.
/// </summary>
private static readonly (string Entity, string Replacement)[] Html5Entities =
{
("&dash;", "-"), ("&plus;", "+"), ("&excl;", "!"), ("&quest;", "?"),
("&period;", "."), ("&comma;", ","), ("&colon;", ":"), ("&semi;", ";"),
("&lpar;", "("), ("&rpar;", ")"), ("&sol;", "/"), ("&ast;", "*"),
("&commat;", "@"), ("&num;", "#"), ("&percnt;", "%")
};
public static string DecodeHtml(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return "";
// Prima passata: risolve &amp;dash; in &dash; oltre alle entità standard.
var text = WebUtility.HtmlDecode(raw);
if (text.IndexOf('&') >= 0)
{
foreach (var (entity, replacement) in Html5Entities)
{
if (text.Contains(entity, StringComparison.OrdinalIgnoreCase))
text = text.Replace(entity, replacement, StringComparison.OrdinalIgnoreCase);
}
// Eventuali entità rimaste da una doppia codifica.
if (text.IndexOf('&') >= 0) text = WebUtility.HtmlDecode(text);
}
return text.Trim();
}
[GeneratedRegex(@"<div[^>]+id=""divAsta(\d+)""", RegexOptions.IgnoreCase)]
private static partial Regex AuctionDivRegex();
[GeneratedRegex(@"data-url=""([^""]+)""")] private static partial Regex DataUrlRegex();
[GeneratedRegex(@"data-freq=""(\d+)""")] private static partial Regex DataFreqRegex();
[GeneratedRegex(@"data-credit=""(\d+)""")] private static partial Regex DataCreditRegex();
[GeneratedRegex(@"data-credit-value=""(\d+)""")] private static partial Regex DataCreditValueRegex();
[GeneratedRegex(@"data-id-product=""(\d+)""")] private static partial Regex DataProductRegex();
[GeneratedRegex(@"<img[^>]+src=""(https?://[^""]+/products/[^""]+)""", RegexOptions.IgnoreCase)]
private static partial Regex ImageRegex();
[GeneratedRegex(@"<a[^>]+class=""name[^""]*""[^>]*>([^<]+)</a>", RegexOptions.IgnoreCase)]
private static partial Regex NameRegex();
/// <summary>
/// Il prezzo del "Compra Subito", che nel markup è separato dall'etichetta da una
/// quantità variabile di spazi e da un'icona che a volte c'è e a volte no.
///
/// <para>Il salto è pigro ma <b>l'importo è ancorato al simbolo di valuta</b>: la
/// versione precedente lasciava che il salto si mangiasse le prime cifre, e su una
/// scheda senza icona "14,00 €" veniva letto come 0.</para>
/// </summary>
[GeneratedRegex(@"buy-rapid-now[^>]*>[\s\S]{0,400}?([0-9]{1,3}(?:\.[0-9]{3})+(?:,[0-9]{1,2})?|[0-9]+(?:[,.][0-9]{1,2})?)\s*(?:|&euro;|&#8364;)",
RegexOptions.IgnoreCase)]
private static partial Regex BuyNowRegex();
/// <summary>Cifre raggruppate a tre a tre col punto: 1.234, 12.345.678.</summary>
[GeneratedRegex(@"^[0-9]{1,3}(?:\.[0-9]{3})+$")]
private static partial Regex ThousandsRegex();
}
}
-370
View File
@@ -1,370 +0,0 @@
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);
}
}
}
@@ -0,0 +1,277 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Controlla a intervalli regolari se ci sono puntate gratis da prendere e le prende,
/// mentre il resto dell'applicazione continua a lavorare.
///
/// <para>Le fonti sono due e indipendenti: le <b>ricompense in attesa sul proprio
/// account</b> (<see cref="IFreeBidsClaimer"/>) e i <b>collegamenti promozionali
/// pubblicati</b> (<see cref="IPromoLinkRedeemer"/>). Vengono percorse entrambe a ogni
/// giro, e un guasto sull'una non ferma l'altra.</para>
///
/// <para>Segue lo stesso schema di <see cref="ProductWatchService"/>: un unico ciclo su
/// thread di lavoro, annullabile, che non tocca mai l'interfaccia — espone eventi e
/// lascia al chiamante il compito di portarli sul thread giusto.</para>
///
/// <para>Il conteggio delle puntate ottenute non si fida del messaggio di risposta ma
/// misura il <b>saldo prima e dopo</b>: è l'unico numero che resta vero anche se Bidoo
/// cambia il testo della conferma.</para>
/// </summary>
public sealed class FreeBidsAutoClaimService
{
private readonly IFreeBidsClaimer _claimer;
private readonly IPromoLinkRedeemer? _promoRedeemer;
private readonly Func<CancellationToken, Task<int?>> _readBalanceAsync;
private CancellationTokenSource? _cts;
private Task? _loop;
/// <summary>Evita che due giri si sovrappongano (timer + richiesta manuale).</summary>
private readonly SemaphoreSlim _oneAtATime = new(1, 1);
/// <summary>
/// Ultimo motivo di mancato riconoscimento già segnalato. Serve a non ripetere lo
/// stesso avviso a ogni giro: un log che si ripete ogni mezz'ora smette di essere letto.
/// </summary>
private string? _lastReportedProblem;
public FreeBidsAutoClaimService(
IFreeBidsClaimer claimer,
Func<CancellationToken, Task<int?>> readBalanceAsync,
IPromoLinkRedeemer? promoRedeemer = null)
{
_claimer = claimer ?? throw new ArgumentNullException(nameof(claimer));
_readBalanceAsync = readBalanceAsync ?? throw new ArgumentNullException(nameof(readBalanceAsync));
_promoRedeemer = promoRedeemer;
}
/// <summary>Diagnostica verso il log applicativo.</summary>
public event Action<string, bool>? OnLog;
/// <summary>Un giro si è concluso: l'interfaccia può riallineare contatori e stato.</summary>
public event Action<FreeBidsClaimResult>? OnCycleCompleted;
public bool IsRunning => _loop is { IsCompleted: false };
public DateTime? LastCheckAt { get; private set; }
public DateTime? NextCheckAt { get; private set; }
/// <summary>Esito dell'ultima raccolta di collegamenti, per la riga di stato.</summary>
public PromoHarvestReport? LastHarvest { get; private set; }
public void Start()
{
if (IsRunning) return;
_cts = new CancellationTokenSource();
_loop = Task.Run(() => LoopAsync(_cts.Token));
}
public void Stop()
{
try { _cts?.Cancel(); } catch { }
_cts?.Dispose();
_cts = null;
_loop = null;
NextCheckAt = null;
}
/// <summary>Esegue subito un giro, senza aspettare la scadenza del timer.</summary>
public Task<FreeBidsClaimResult> CheckNowAsync(CancellationToken cancellationToken = default) =>
RunCycleAsync(cancellationToken);
private async Task LoopAsync(CancellationToken ct)
{
// Un primo giro poco dopo l'avvio, ma non subito: la sessione va prima
// ripristinata e validata, altrimenti il primo controllo fallirebbe sempre.
if (!await Wait.DelayAsync(20_000, ct).ConfigureAwait(false)) return;
while (!ct.IsCancellationRequested)
{
var settings = SettingsManager.Load();
if (settings.FreeBidsAutoClaimEnabled)
{
try
{
await RunCycleAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception ex)
{
// Il ciclo non deve morire: è l'unico modo perché l'automazione
// resti tale anche dopo un errore isolato.
OnLog?.Invoke($"giro non riuscito: {ex.Message}", true);
}
}
var minutes = Math.Max(5, settings.FreeBidsCheckMinutes);
NextCheckAt = DateTime.Now.AddMinutes(minutes);
if (!await Wait.DelayAsync(minutes * 60_000, ct).ConfigureAwait(false)) return;
}
}
private async Task<FreeBidsClaimResult> RunCycleAsync(CancellationToken ct)
{
// Timer e pulsante "Controlla adesso" possono capitare insieme: due riscatti
// in parallelo conterebbero gli stessi premi due volte.
if (!await _oneAtATime.WaitAsync(0, ct).ConfigureAwait(false))
return FreeBidsClaimResult.Nothing("controllo già in corso");
try
{
LastCheckAt = DateTime.Now;
FreeBidsStats.RecordCheck();
var before = await SafeBalanceAsync(ct).ConfigureAwait(false);
// Le due fonti sono indipendenti: le ricompense sul proprio account e i
// collegamenti pubblicati. Un problema sulla prima non deve far saltare la
// seconda — sono i giorni in cui non c'è nulla da riscuotere sul conto che i
// collegamenti gratuiti valgono di più.
var onSite = await ClaimOnSiteAsync(ct).ConfigureAwait(false);
var harvest = await HarvestPromoLinksAsync(ct).ConfigureAwait(false);
var claimed = onSite.ClaimedCount + (harvest?.Claimed ?? 0);
var message = Describe(onSite, harvest);
if (claimed == 0)
{
var nothing = new FreeBidsClaimResult(true, 0, 0, message);
OnCycleCompleted?.Invoke(nothing);
return nothing;
}
// Il saldo è la fonte attendibile: il messaggio del sito è solo un indizio,
// e per i collegamenti promozionali spesso non c'è nemmeno quello.
var after = await SafeBalanceAsync(ct).ConfigureAwait(false);
var gained = before.HasValue && after.HasValue
? Math.Max(0, after.Value - before.Value)
: onSite.BidsGained;
var measured = new FreeBidsClaimResult(true, claimed, gained, message);
FreeBidsStats.RecordClaim(claimed, gained);
OnLog?.Invoke(
gained > 0
? $"{message}: +{gained} puntate"
: $"{message} (saldo invariato)",
false);
OnCycleCompleted?.Invoke(measured);
return measured;
}
finally
{
_oneAtATime.Release();
}
}
/// <summary>Ricompense in attesa sul proprio account.</summary>
private async Task<FreeBidsClaimResult> ClaimOnSiteAsync(CancellationToken ct)
{
try
{
var result = await _claimer.ClaimAllAsync(ct).ConfigureAwait(false);
if (!result.IsSuccess) ReportProblemOnce(result.Message);
else _lastReportedProblem = null;
return result;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
ReportProblemOnce($"ricompense non riscosse: {ex.Message}");
return FreeBidsClaimResult.Failure(ex.Message);
}
}
/// <summary>
/// Collegamenti promozionali pubblicati. Restituisce <c>null</c> quando la raccolta
/// non è configurata: è diverso da "raccolta eseguita senza risultati", e il
/// messaggio del giro non deve dire che ha guardato se non ha guardato.
/// </summary>
private async Task<PromoHarvestReport?> HarvestPromoLinksAsync(CancellationToken ct)
{
if (_promoRedeemer == null) return null;
try
{
var report = await _promoRedeemer.HarvestAndRedeemAsync(ct).ConfigureAwait(false);
LastHarvest = report;
if (!report.SourceReadable) ReportProblemOnce(report.Message);
else if (report.HasNews) OnLog?.Invoke(report.Message, false);
return report;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
ReportProblemOnce($"raccolta dei collegamenti non riuscita: {ex.Message}");
return null;
}
}
private static string Describe(FreeBidsClaimResult onSite, PromoHarvestReport? harvest)
{
var parts = new List<string>(2);
if (onSite.ClaimedCount > 0) parts.Add($"{onSite.ClaimedCount} ricompense riscosse");
if (harvest is { Claimed: > 0 }) parts.Add($"{harvest.Claimed} collegamenti riscossi");
if (parts.Count > 0) return string.Join(", ", parts);
// Niente di preso: vale di più dire perché.
if (harvest is { SourceReadable: true, Found: > 0 })
return harvest.Message;
return onSite.IsSuccess ? onSite.Message : "nulla da riscuotere";
}
/// <summary>Il saldo è un di più: se non arriva, il riscatto va avanti lo stesso.</summary>
private async Task<int?> SafeBalanceAsync(CancellationToken ct)
{
try
{
return await _readBalanceAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch
{
return null;
}
}
private void ReportProblemOnce(string problem)
{
if (_lastReportedProblem == problem) return;
_lastReportedProblem = problem;
OnLog?.Invoke(problem, true);
}
}
}
+267
View File
@@ -0,0 +1,267 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Riconosce, nella pagina delle ricompense di Bidoo, ciò che è ancora da riscuotere.
///
/// <para>È deliberatamente una funzione pura da testo a risultato: nessuna rete, nessuno
/// stato. È l'unico pezzo legato al formato del sito, ed è quello che va corretto quando
/// Bidoo cambia grafica — tenerlo separato significa poterlo verificare su una pagina
/// salvata, senza account e senza aspettare che ci sia davvero un premio.</para>
///
/// <para>Le parole da riconoscere non sono più scritte qui ma in
/// <see cref="FreeBidsSiteConfig"/>, cioè su file: la <i>forma</i> delle espressioni
/// resta nel codice (dove è verificata dai test), i <i>valori</i> stanno nel JSON (dove
/// si correggono in un minuto). Le espressioni composte vengono tenute in cache per
/// configurazione: ricompilarle a ogni pagina costerebbe più dell'analisi stessa.</para>
/// </summary>
public static class FreeBidsPageParser
{
/// <summary>Indirizzo predefinito della pagina delle ricompense.</summary>
public const string ChestsPath = "/my_chests.php";
private const RegexOptions Opts =
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled;
/// <summary>
/// Espressioni già composte per una certa configurazione. La tabella è debole:
/// quando il file cambia e nasce una configurazione nuova, quella vecchia — e le sue
/// regex compilate — se ne vanno da sole.
/// </summary>
private static readonly ConditionalWeakTable<FreeBidsSiteConfig, CompiledPatterns> PatternCache = new();
/// <summary>Analizza la pagina usando i riferimenti indicati.</summary>
public static FreeBidsPageScan Scan(string? html, FreeBidsSiteConfig config)
{
config = (config ?? new FreeBidsSiteConfig()).Normalised();
if (string.IsNullOrWhiteSpace(html))
return FreeBidsPageScan.Unrecognised("risposta vuota dal sito");
if (html.Length < 200)
return FreeBidsPageScan.Unrecognised($"risposta troppo corta ({html.Length} caratteri)");
if (LooksLikeLogin(html, config))
return FreeBidsPageScan.Unrecognised("sessione scaduta: Bidoo ha risposto con la pagina di accesso");
var markers = config.PageRecognition.PageMarkers;
if (markers.Count > 0 && !markers.Any(m => Contains(html, m)))
return FreeBidsPageScan.Unrecognised("pagina delle ricompense non riconosciuta (formato cambiato?)");
var patterns = PatternsFor(config);
var found = new List<ClaimableReward>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var (pattern, buildUrl) in patterns.Claimables)
{
foreach (Match match in pattern.Matches(html))
{
var raw = match.Groups["v"].Value;
if (string.IsNullOrWhiteSpace(raw)) continue;
var url = buildUrl(config, raw);
// Lo stesso premio compare spesso due volte (immagine e pulsante):
// riscuoterlo due volte non farebbe danni, ma falserebbe i contatori.
if (!seen.Add(url)) continue;
found.Add(new ClaimableReward(
Id: ExtractId(raw),
Label: "Ricompensa Bidoo",
ClaimUrl: url));
}
}
return FreeBidsPageScan.Recognised(
found,
found.Count == 0
? "nessuna ricompensa da riscuotere"
: $"{found.Count} da riscuotere",
ExtractCsrfToken(html, config));
}
/// <summary>
/// Analizza la pagina con i riferimenti predefiniti, sostituendo il solo dominio.
/// Comoda per le prove su una pagina salvata.
/// </summary>
public static FreeBidsPageScan Scan(string? html, string origin)
{
var config = new FreeBidsSiteConfig();
config.SiteSettings.BaseUrl = origin;
return Scan(html, config);
}
/// <summary>
/// Estrae il token anti-CSRF dai campi nascosti (o dal <c>meta</c>) della pagina.
///
/// <para>Va letto <b>dalla pagina delle ricompense</b>, non da una qualunque: molti
/// siti legano il token alla pagina che ha generato il modulo, e uno preso altrove
/// verrebbe rifiutato. Qui arriva gratis, perché quella pagina l'abbiamo già
/// scaricata per trovare i premi: nessuna richiesta in più.</para>
///
/// <para>Restituisce <c>null</c> se non ce n'è: Bidoo potrebbe non usarlo affatto, e
/// mandare un campo vuoto sarebbe peggio che non mandarlo.</para>
/// </summary>
public static string? ExtractCsrfToken(string? html, FreeBidsSiteConfig config)
{
if (string.IsNullOrWhiteSpace(html)) return null;
foreach (var pattern in PatternsFor((config ?? new FreeBidsSiteConfig()).Normalised()).CsrfPatterns)
{
var match = pattern.Match(html);
if (!match.Success) continue;
var value = match.Groups["v"].Value.Trim();
if (value.Length > 0) return value;
}
return null;
}
/// <summary>
/// Quante puntate dichiara di aver dato la risposta al riscatto. È solo un
/// indizio: il conteggio buono resta la differenza di saldo prima e dopo, che non
/// dipende da come è scritto il messaggio.
/// </summary>
public static int? ReadGainedBids(string? responseBody) =>
ReadGainedBids(responseBody, new FreeBidsSiteConfig());
/// <inheritdoc cref="ReadGainedBids(string?)"/>
public static int? ReadGainedBids(string? responseBody, FreeBidsSiteConfig config)
{
if (string.IsNullOrWhiteSpace(responseBody)) return null;
var pattern = PatternsFor((config ?? new FreeBidsSiteConfig()).Normalised()).BidCount;
if (pattern == null) return null;
var match = pattern.Match(responseBody);
return match.Success && int.TryParse(match.Groups["n"].Value, out var n) ? n : null;
}
/// <summary>True se la risposta è, in realtà, il modulo di accesso.</summary>
public static bool LooksLikeLogin(string? html, FreeBidsSiteConfig config)
{
if (string.IsNullOrWhiteSpace(html)) return false;
var markers = (config ?? new FreeBidsSiteConfig()).Normalised().PageRecognition.LoginMarkers;
return markers.Any(m => Contains(html, m));
}
// ── Composizione delle espressioni ───────────────────────────────
private static CompiledPatterns PatternsFor(FreeBidsSiteConfig config) =>
PatternCache.GetValue(config, CompiledPatterns.Build);
private static bool Contains(string haystack, string? needle) =>
!string.IsNullOrWhiteSpace(needle) &&
haystack.Contains(needle, StringComparison.OrdinalIgnoreCase);
private static string ExtractId(string raw)
{
var digits = Regex.Match(raw, @"\d+");
return digits.Success ? digits.Value : raw;
}
private static string Absolute(FreeBidsSiteConfig config, string value) => config.Url(value);
/// <summary>
/// Indirizzo di riscatto costruito dal solo identificativo, quando la pagina non
/// offre un collegamento completo.
/// </summary>
private static string FromId(FreeBidsSiteConfig config, string id)
{
var url = config.Url(config.Endpoints.ClaimDailyReward);
var separator = url.Contains('?') ? '&' : '?';
return $"{url}{separator}{config.ClaimParameters.Daily.QueryKey}={Uri.EscapeDataString(id)}";
}
/// <summary>Espressioni composte una volta sola a partire dalle parole di configurazione.</summary>
private sealed class CompiledPatterns
{
public required IReadOnlyList<(Regex Pattern, Func<FreeBidsSiteConfig, string, string> BuildUrl)> Claimables { get; init; }
public required IReadOnlyList<Regex> CsrfPatterns { get; init; }
public required Regex? BidCount { get; init; }
public static CompiledPatterns Build(FreeBidsSiteConfig config)
{
var recognition = config.PageRecognition;
var claimables = new List<(Regex, Func<FreeBidsSiteConfig, string, string>)>();
// href diretto verso un'azione di apertura/riscatto: la pagina dice già
// dove andare, e ha ragione lei — l'indirizzo di configurazione serve solo
// quando questo collegamento non c'è.
var linkAlternation = Alternation(recognition.ClaimLinkKeywords);
if (linkAlternation != null)
{
claimables.Add((
new Regex($@"href\s*=\s*[""'](?<v>[^""']*(?:{linkAlternation})[^""']*)[""']", Opts),
Absolute));
}
// pulsante con chiamata JavaScript: openChest(123) / apriBaule('123')
var scriptAlternation = Alternation(recognition.ClaimScriptFunctions);
if (scriptAlternation != null)
{
claimables.Add((
new Regex($@"(?:{scriptAlternation})\s*\(\s*[""']?(?<v>\d+)", Opts),
FromId));
}
// attributo dedicato sull'elemento cliccabile
var attributeAlternation = Alternation(recognition.ClaimIdAttributes);
if (attributeAlternation != null)
{
claimables.Add((
new Regex($@"(?:{attributeAlternation})\s*=\s*[""'](?<v>\d+)[""']", Opts),
FromId));
}
var csrf = new List<Regex>();
foreach (var field in recognition.CsrfFieldNames.Where(f => !string.IsNullOrWhiteSpace(f)))
{
var name = Regex.Escape(field.Trim());
// I due ordini possibili degli attributi: name prima di value e viceversa.
csrf.Add(new Regex($@"name\s*=\s*[""']{name}[""'][^>]*?value\s*=\s*[""'](?<v>[^""']*)[""']", Opts));
csrf.Add(new Regex($@"value\s*=\s*[""'](?<v>[^""']*)[""'][^>]*?name\s*=\s*[""']{name}[""']", Opts));
// <meta name="csrf-token" content="...">
csrf.Add(new Regex($@"<meta[^>]*name\s*=\s*[""']{name}[""'][^>]*content\s*=\s*[""'](?<v>[^""']*)[""']", Opts));
}
var bidWords = Alternation(config.ResponseValidation.BidWords);
return new CompiledPatterns
{
Claimables = claimables,
CsrfPatterns = csrf,
BidCount = bidWords == null
? null
: new Regex($@"(?<n>\d{{1,4}})\s*(?:{bidWords})", Opts)
};
}
/// <summary>
/// Alternanza regex a partire da parole di configurazione, ognuna protetta con
/// <see cref="Regex.Escape"/>: il file contiene parole, non espressioni, e un
/// carattere speciale scritto per sbaglio non deve poter cambiare il significato
/// del riconoscitore o farlo esplodere.
/// </summary>
private static string? Alternation(IEnumerable<string>? words)
{
var escaped = (words ?? Array.Empty<string>())
.Where(w => !string.IsNullOrWhiteSpace(w))
.Select(w => Regex.Escape(w.Trim()))
.ToArray();
return escaped.Length == 0 ? null : string.Join('|', escaped);
}
}
}
}
+200
View File
@@ -0,0 +1,200 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// La richiesta da mandare per riscuotere, descritta e basta: metodo, indirizzo, corpo
/// e pagina di provenienza. Non sa nulla di rete, quindi si può verificare per intero
/// senza un account e senza che esista davvero un premio da prendere.
/// </summary>
public sealed record FreeBidsRequestPlan(
string Method,
string Url,
string? FormBody,
string Referer)
{
public bool IsPost => string.Equals(Method, "POST", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// La richiesta va mandata come <b>apertura di pagina</b>, non come chiamata AJAX.
///
/// <para>I collegamenti promozionali di Bidoo si riscuotono aprendoli, e basta. Se si
/// aggiunge <c>X-Requested-With: XMLHttpRequest</c> il sito risponde a una richiesta
/// che non è quella che si aspetta e le puntate non arrivano — con un 200 tranquillo,
/// quindi senza che nulla segnali il problema. È il difetto per cui il riscatto
/// automatico non funzionava.</para>
/// </summary>
public bool AsDocument { get; init; }
}
/// <summary>
/// Compone le richieste di riscatto a partire dai riferimenti di configurazione.
///
/// <para>È qui che la configurazione diventa una richiesta vera, ed è deliberatamente
/// l'unico posto in cui succede: il servizio che parla con Bidoo esegue quello che
/// trova in un <see cref="FreeBidsRequestPlan"/> e non conosce né indirizzi né nomi di
/// campo. Cambiare il file JSON cambia la richiesta senza toccare una riga di codice.</para>
/// </summary>
public static class FreeBidsRequestFactory
{
/// <summary>
/// Richiesta per riscuotere una ricompensa trovata sulla pagina.
///
/// <para>L'indirizzo è quello che la pagina ha indicato (il parser lo costruisce dai
/// riferimenti solo quando la pagina espone il solo identificativo): fra ciò che dice
/// il sito adesso e ciò che dice il file, ha ragione il sito. Metodo, campi fissi e
/// token vengono invece dalla configurazione.</para>
/// </summary>
public static FreeBidsRequestPlan ForReward(
FreeBidsSiteConfig config,
ClaimableReward reward,
string? csrfToken)
{
config = (config ?? new FreeBidsSiteConfig()).Normalised();
var referer = config.Url(config.Endpoints.Rewards);
// La pagina ha già dettato il corpo esatto: non c'è niente da comporre.
if (reward.ClaimFormBody != null)
return new FreeBidsRequestPlan("POST", reward.ClaimUrl, reward.ClaimFormBody, referer);
var action = config.ClaimParameters.Daily;
// Un riscatto in GET è un collegamento da aprire, non una chiamata AJAX:
// stessa ragione spiegata su FreeBidsRequestPlan.AsDocument.
if (!action.IsPost)
return new FreeBidsRequestPlan("GET", WithCacheBuster(reward.ClaimUrl, action), null, referer)
{
AsDocument = true
};
var fields = new List<KeyValuePair<string, string>>();
AddFixedFields(fields, action);
fields.Add(new(action.QueryKey, reward.Id));
AddToken(fields, action, csrfToken);
return new FreeBidsRequestPlan("POST", reward.ClaimUrl, Encode(fields), referer);
}
/// <summary>
/// Richiesta per riscuotere un codice o un collegamento promozionale.
///
/// <para>Accetta le due forme in cui questi premi arrivano davvero: il collegamento
/// intero ricevuto per email — usato com'è, ma <b>solo</b> se punta al sito
/// configurato, perché seguirlo significa mandarci il cookie di sessione — oppure il
/// solo codice, che ha bisogno dell'indirizzo indicato in <c>ClaimPromoLink</c>.</para>
///
/// <para>Restituisce <c>null</c> con un motivo leggibile invece di tirare a indovinare
/// un indirizzo: una richiesta inventata produrrebbe un 404 che sembra un problema del
/// sito, mentre il problema è che quel riferimento non è stato configurato.</para>
/// </summary>
public static FreeBidsRequestPlan? TryForPromo(
FreeBidsSiteConfig config,
string? codeOrLink,
string? csrfToken,
out string problem)
{
config = (config ?? new FreeBidsSiteConfig()).Normalised();
problem = "";
var input = codeOrLink?.Trim() ?? "";
var referer = config.Url(config.Endpoints.Rewards);
if (input.Length == 0)
{
problem = "manca il codice o il collegamento da riscattare";
return null;
}
var action = config.ClaimParameters.PromoCode;
// Un collegamento intero: lo si segue solo se è di casa.
if (input.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
input.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
if (!config.IsSameOrigin(input))
{
problem = $"il collegamento non appartiene a {config.Origin}: non viene aperto";
return null;
}
// Usato esattamente com'è: un collegamento personale può portare una firma,
// e aggiungerci parametri è il modo più rapido per invalidarla. E si apre
// come si aprirebbe nel browser: è tutto ciò che serve.
return new FreeBidsRequestPlan("GET", input, null, referer) { AsDocument = true };
}
if (string.IsNullOrWhiteSpace(config.Endpoints.ClaimPromoLink))
{
problem = "indirizzo del riscatto promozionale non configurato: " +
"compila «Endpoints.ClaimPromoLink» nel file dei riferimenti, " +
"oppure incolla il collegamento intero";
return null;
}
var url = config.Url(config.Endpoints.ClaimPromoLink);
if (action.IsPost)
{
var fields = new List<KeyValuePair<string, string>>();
AddFixedFields(fields, action);
fields.Add(new(action.QueryKey, input));
AddToken(fields, action, csrfToken);
return new FreeBidsRequestPlan("POST", url, Encode(fields), referer);
}
var query = new List<KeyValuePair<string, string>> { new(action.QueryKey, input) };
AddToken(query, action, csrfToken);
return new FreeBidsRequestPlan("GET", WithCacheBuster(Append(url, Encode(query)), action), null, referer);
}
// ── Composizione ─────────────────────────────────────────────────
private static void AddFixedFields(
ICollection<KeyValuePair<string, string>> fields,
FreeBidsClaimAction action)
{
foreach (var field in action.FormFields.Where(f => !string.IsNullOrWhiteSpace(f.Key)))
fields.Add(new(field.Key, field.Value ?? ""));
}
/// <summary>
/// Aggiunge il token solo se serve <b>e</b> se c'è: un campo token vuoto è un modo
/// sicuro di farsi rifiutare la richiesta da un sito che quel token non lo usa.
/// </summary>
private static void AddToken(
ICollection<KeyValuePair<string, string>> fields,
FreeBidsClaimAction action,
string? csrfToken)
{
if (string.IsNullOrWhiteSpace(action.TokenField)) return;
if (string.IsNullOrWhiteSpace(csrfToken)) return;
fields.Add(new(action.TokenField, csrfToken));
}
/// <summary>Millisecondi correnti in coda all'indirizzo, per non farsi servire una risposta vecchia.</summary>
private static string WithCacheBuster(string url, FreeBidsClaimAction action)
{
if (string.IsNullOrWhiteSpace(action.CacheBusterKey)) return url;
return Append(url, $"{Uri.EscapeDataString(action.CacheBusterKey)}=" +
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
}
private static string Append(string url, string query)
{
if (query.Length == 0) return url;
return url + (url.Contains('?') ? '&' : '?') + query;
}
private static string Encode(IEnumerable<KeyValuePair<string, string>> fields) =>
string.Join('&', fields.Select(f =>
$"{Uri.EscapeDataString(f.Key)}={Uri.EscapeDataString(f.Value ?? "")}"));
}
}
@@ -0,0 +1,192 @@
using System;
using System.Linq;
using System.Text.Json;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>Come è andata una singola richiesta di riscatto.</summary>
/// <param name="Accepted">Il sito ha accettato il riscatto.</param>
/// <param name="BidsDeclared">Puntate dichiarate nella risposta, se lo dice. Resta un indizio.</param>
/// <param name="Message">Messaggio del sito, se ne ha mandato uno leggibile.</param>
/// <param name="Reason">Perché si è deciso così: è quello che finisce nel registro.</param>
public sealed record FreeBidsResponseVerdict(
bool Accepted,
int? BidsDeclared,
string? Message,
string Reason);
/// <summary>
/// Giudica la risposta a un riscatto usando le chiavi indicate nei riferimenti.
///
/// <para>Non basta l'HTTP 200: un sito che rifiuta un premio già preso risponde
/// tranquillamente 200 con <c>success: false</c>, e contarlo come riscosso gonfierebbe i
/// contatori con puntate mai arrivate. Ma non basta nemmeno guardare le parole nel testo:
/// dentro una pagina intera "errore" compare per mille motivi, e scambiarla per un
/// rifiuto nasconderebbe un riscatto riuscito.</para>
///
/// <para>Da qui i tre livelli, dal più affidabile al meno: <b>JSON</b> con la chiave
/// dell'esito, che è una risposta esplicita del sito; <b>pagina intera</b>, dove l'unica
/// cosa che si può leggere davvero è se siamo finiti sul modulo di accesso; <b>frammento
/// breve</b>, dove le parole di rifiuto hanno un senso perché sono tutto ciò che c'è.</para>
/// </summary>
public static class FreeBidsResponseValidator
{
/// <summary>Oltre questa lunghezza la risposta è trattata come pagina, non come messaggio.</summary>
private const int FragmentMaxLength = 2000;
public static FreeBidsResponseVerdict Validate(
FreeBidsSiteConfig config,
int statusCode,
string? body)
{
config = (config ?? new FreeBidsSiteConfig()).Normalised();
if (statusCode is < 200 or > 299)
return new FreeBidsResponseVerdict(false, null, null, $"il sito ha risposto {statusCode}");
var text = body?.Trim() ?? "";
// Diverse azioni di Bidoo rispondono con un corpo vuoto: è il saldo, misurato
// prima e dopo, a dire se è arrivato qualcosa.
if (text.Length == 0)
return new FreeBidsResponseVerdict(true, null, null, "risposta vuota, accettata");
var fromJson = TryJson(config, text);
if (fromJson != null) return fromJson;
// Una sessione scaduta rimanda al modulo di accesso con un onestissimo 200:
// senza questo controllo ogni giro conterebbe riscatti che non sono avvenuti.
if (FreeBidsPageParser.LooksLikeLogin(text, config))
return new FreeBidsResponseVerdict(false, null, null,
"sessione scaduta: il sito ha risposto con la pagina di accesso");
var declared = FreeBidsPageParser.ReadGainedBids(text, config);
if (LooksLikeFullPage(text))
return new FreeBidsResponseVerdict(true, declared, null, "pagina restituita, riscatto accettato");
var refusal = config.ResponseValidation.FailureMarkers
.FirstOrDefault(m => !string.IsNullOrWhiteSpace(m) &&
text.Contains(m, StringComparison.OrdinalIgnoreCase));
if (refusal != null)
return new FreeBidsResponseVerdict(false, null, Shorten(text), $"rifiutato dal sito («{refusal}»)");
return new FreeBidsResponseVerdict(true, declared, Shorten(text), "accettato");
}
/// <summary>
/// Legge l'esito dalla risposta JSON, se è JSON e se la chiave dell'esito c'è.
/// Restituisce <c>null</c> quando non può dire nulla, così chi chiama passa ai
/// criteri successivi invece di prendere un silenzio per un rifiuto.
/// </summary>
private static FreeBidsResponseVerdict? TryJson(FreeBidsSiteConfig config, string text)
{
if (text.Length == 0 || (text[0] != '{' && text[0] != '[')) return null;
var rules = config.ResponseValidation;
if (string.IsNullOrWhiteSpace(rules.SuccessKey)) return null;
try
{
using var document = JsonDocument.Parse(text);
if (document.RootElement.ValueKind != JsonValueKind.Object) return null;
if (!TryFind(document.RootElement, rules.SuccessKey, out var successValue)) return null;
var succeeded = IsSuccess(successValue, rules.SuccessValue);
var message = TryFind(document.RootElement, rules.MessageKey, out var messageValue)
? messageValue.ToString()
: null;
int? bids = TryFind(document.RootElement, rules.BidsCountKey, out var bidsValue)
? AsInt(bidsValue)
: null;
return new FreeBidsResponseVerdict(
succeeded,
succeeded ? bids : null,
string.IsNullOrWhiteSpace(message) ? null : message!.Trim(),
succeeded
? $"accettato ({rules.SuccessKey} = {rules.SuccessValue})"
: $"rifiutato dal sito ({rules.SuccessKey} negativo)");
}
catch (JsonException)
{
// Comincia per graffa ma non è JSON valido: lo giudicano le regole successive.
return null;
}
}
/// <summary>
/// Cerca la chiave al primo livello e, se non c'è, dentro gli oggetti annidati:
/// molte risposte incartano il risultato in <c>data</c> o <c>result</c>, e chiedere
/// all'utente di indovinare il percorso completo sarebbe un modo per sbagliarlo.
/// </summary>
private static bool TryFind(JsonElement element, string? key, out JsonElement value)
{
value = default;
if (string.IsNullOrWhiteSpace(key) || element.ValueKind != JsonValueKind.Object) return false;
foreach (var property in element.EnumerateObject())
{
if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase))
{
value = property.Value;
return true;
}
}
foreach (var property in element.EnumerateObject())
{
if (property.Value.ValueKind == JsonValueKind.Object &&
TryFind(property.Value, key, out value))
return true;
}
return false;
}
private static bool IsSuccess(JsonElement value, string expected) => value.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Number => value.TryGetDouble(out var number) && number != 0,
JsonValueKind.String => string.Equals(value.GetString()?.Trim(), expected, StringComparison.OrdinalIgnoreCase)
|| string.Equals(value.GetString()?.Trim(), "1", StringComparison.Ordinal),
_ => false
};
private static int? AsInt(JsonElement value) => value.ValueKind switch
{
JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null,
JsonValueKind.String => int.TryParse(value.GetString(), out var parsed) ? parsed : null,
_ => null
};
private static bool LooksLikeFullPage(string text) =>
text.Length > FragmentMaxLength ||
text.Contains("<html", StringComparison.OrdinalIgnoreCase) ||
text.Contains("<!doctype", StringComparison.OrdinalIgnoreCase);
/// <summary>Il messaggio finisce in una riga di registro: va tenuto corto.</summary>
private static string? Shorten(string text)
{
var clean = System.Text.RegularExpressions.Regex
.Replace(text, "<[^>]+>", " ")
.Replace('\n', ' ')
.Replace('\r', ' ')
.Trim();
while (clean.Contains(" ", StringComparison.Ordinal))
clean = clean.Replace(" ", " ", StringComparison.Ordinal);
if (clean.Length == 0) return null;
return clean.Length <= 160 ? clean : clean[..160] + "…";
}
}
}
+25 -16
View File
@@ -5,6 +5,7 @@ using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Net;
namespace AutoBidder.Services
{
@@ -18,7 +19,7 @@ namespace AutoBidder.Services
// Cache HTML con timestamp
private readonly ConcurrentDictionary<string, CachedHtml> _cache = new();
// Coda richieste con priorità
// Coda richieste con priorit
private readonly SemaphoreSlim _rateLimiter;
private readonly TimeSpan _minRequestDelay;
private DateTime _lastRequestTime = DateTime.MinValue;
@@ -28,6 +29,7 @@ namespace AutoBidder.Services
private readonly int _maxConcurrentRequests;
private readonly TimeSpan _cacheExpiration;
private readonly int _maxRetries;
private readonly int _maxCacheEntries;
// Logging callback
public Action<string>? OnLog { get; set; }
@@ -36,12 +38,14 @@ namespace AutoBidder.Services
int maxConcurrentRequests = 3,
int requestsPerSecond = 5,
TimeSpan? cacheExpiration = null,
int maxRetries = 2)
int maxRetries = 2,
int maxCacheEntries = 50)
{
_maxConcurrentRequests = maxConcurrentRequests;
_minRequestDelay = TimeSpan.FromMilliseconds(1000.0 / requestsPerSecond);
_cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(5);
_cacheExpiration = cacheExpiration ?? TimeSpan.FromMinutes(3); // Ridotto da 5 a 3 minuti
_maxRetries = maxRetries;
_maxCacheEntries = maxCacheEntries;
_rateLimiter = new SemaphoreSlim(maxConcurrentRequests, maxConcurrentRequests);
_httpClient.Timeout = TimeSpan.FromSeconds(15);
@@ -167,7 +171,7 @@ namespace AutoBidder.Services
}
/// <summary>
/// Controlla se HTML è in cache e ancora valido
/// Controlla se HTML in cache e ancora valido
/// </summary>
private bool TryGetFromCache(string url, out string html)
{
@@ -191,10 +195,26 @@ namespace AutoBidder.Services
}
/// <summary>
/// Salva HTML in cache
/// Salva HTML in cache con limite dimensione
/// </summary>
private void SaveToCache(string url, string html)
{
// Limita dimensione cache per evitare memory leak
if (_cache.Count >= _maxCacheEntries)
{
// Rimuovi le entry pi vecchie
var oldestEntries = _cache
.OrderBy(kvp => kvp.Value.Timestamp)
.Take(_cache.Count - _maxCacheEntries + 10) // Rimuovi 10 extra per evitare chiamate frequenti
.Select(kvp => kvp.Key)
.ToList();
foreach (var key in oldestEntries)
{
_cache.TryRemove(key, out _);
}
}
_cache[url] = new CachedHtml
{
Html = html,
@@ -284,17 +304,6 @@ namespace AutoBidder.Services
public string Url { get; set; } = string.Empty;
}
/// <summary>
/// Priorità richiesta (per future implementazioni)
/// </summary>
public enum RequestPriority
{
Low = 0,
Normal = 1,
High = 2,
Critical = 3
}
/// <summary>
/// Statistiche cache
/// </summary>
+34
View File
@@ -0,0 +1,34 @@
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Legge la pagina delle ricompense di Bidoo e riscuote ciò che è rimasto da prendere.
///
/// <para>È un'interfaccia perché il riscatto è la sola parte di questa funzionalità che
/// dipende dal formato del sito: potendola sostituire, la logica di temporizzazione e i
/// contatori restano verificabili senza toccare la rete.</para>
/// </summary>
public interface IFreeBidsClaimer
{
/// <summary>Guarda cosa c'è da riscuotere, senza riscuotere nulla.</summary>
Task<FreeBidsPageScan> ScanAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Riscuote tutte le ricompense disponibili. Non solleva per un premio fallito:
/// prosegue con gli altri e riporta quanti ne ha presi.
/// </summary>
Task<FreeBidsClaimResult> ClaimAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Riscuote un codice o un collegamento promozionale indicato dall'utente.
///
/// <para>Sta qui e non nel giro automatico perché è l'unica parte che ha bisogno di
/// un dato che l'applicazione non può scoprire da sola: quei premi arrivano per email
/// o per messaggio, e vanno incollati.</para>
/// </summary>
Task<FreeBidsClaimResult> ClaimPromoAsync(string codeOrLink, CancellationToken cancellationToken = default);
}
}
+25
View File
@@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Raccoglie i collegamenti promozionali pubblicati e li riscuote con la sessione
/// dell'utente.
///
/// <para>È separato da <see cref="IFreeBidsClaimer"/> perché sono due meccanismi
/// diversi, non due varianti dello stesso: le ricompense stanno sul proprio account e si
/// prendono dalla pagina dei premi, i collegamenti promozionali arrivano da fuori e
/// valgono per chiunque li apra per primo. Tenerli distinti permette di spegnerne uno
/// senza toccare l'altro.</para>
/// </summary>
public interface IPromoLinkRedeemer
{
/// <summary>
/// Legge la pagina di raccolta, scarta i collegamenti già presi e apre i nuovi.
/// Non solleva per un collegamento fallito: prosegue e lo riporta nell'esito.
/// </summary>
Task<PromoHarvestReport> HarvestAndRedeemAsync(CancellationToken cancellationToken = default);
}
}
+240
View File
@@ -0,0 +1,240 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Sorveglia il catalogo alla ricerca di aste nuove dei prodotti seguiti.
///
/// Bidoo rimette all'asta lo stesso articolo di continuo: seguire il prodotto invece
/// della singola asta evita di doverle aggiungere a mano una per una. Il servizio
/// scandisce "Tutte le aste" a intervalli regolari e segnala le corrispondenze nuove.
///
/// Non aggiunge nulla da sé: espone l'evento e lascia decidere al chiamante, che
/// conosce lo stato del monitor e i limiti impostati dall'utente.
/// </summary>
public sealed class ProductWatchService
{
/// <summary>La categoria speciale "Tutte le aste": una sola scansione le copre tutte.</summary>
private static readonly CatalogCategory AllAuctions = new()
{
TabId = 3,
DisplayName = "Tutte le aste",
IsSpecial = true
};
private readonly BidooCatalogClient _catalog;
private CancellationTokenSource? _cts;
private Task? _loop;
public ProductWatchService(BidooCatalogClient catalog) => _catalog = catalog;
/// <summary>Asta di un prodotto seguito trovata e non ancora trattata.</summary>
public event Action<CatalogAuction, WatchedProduct>? OnAuctionFound;
/// <summary>Diagnostica verso il log applicativo.</summary>
public event Action<string>? OnLog;
/// <summary>Quante aste sono già state aggiunte automaticamente e sono ancora nel monitor.</summary>
public Func<int>? CountAutoAdded { get; set; }
public bool IsRunning => _loop is { IsCompleted: false };
public DateTime? LastScanAt { get; private set; }
public void Start()
{
if (IsRunning) return;
_cts = new CancellationTokenSource();
_loop = Task.Run(() => ScanLoopAsync(_cts.Token));
}
public void Stop()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
_loop = null;
}
/// <summary>Esegue subito una scansione, senza aspettare il prossimo giro.</summary>
public Task ScanNowAsync(CancellationToken ct = default) => ScanOnceAsync(ct);
private async Task ScanLoopAsync(CancellationToken ct)
{
// Un primo giro poco dopo l'avvio: se l'utente segue già dei prodotti,
// le aste in corso devono comparire subito.
if (!await Wait.DelayAsync(5000, ct).ConfigureAwait(false)) return;
while (!ct.IsCancellationRequested)
{
var settings = SettingsManager.Load();
if (settings.AutoAddProductsEnabled)
{
await ScanOnceAsync(ct).ConfigureAwait(false);
}
var seconds = Math.Max(30, settings.AutoAddScanSeconds);
if (!await Wait.DelayAsync(seconds * 1000, ct).ConfigureAwait(false)) return;
}
}
private async Task ScanOnceAsync(CancellationToken ct)
{
// Solo i prodotti con la stellina accesa: in elenco possono essercene altri,
// tenuti lì unicamente per i loro limiti su misura.
var watched = WatchedProductsStore.GetWatched();
if (watched.Count == 0) return;
try
{
var settings = SettingsManager.Load();
// Il tetto è quello della sorveglianza, non quello del catalogo, e più alto
// per una ragione precisa: il listato è ordinato per scadenza, quindi le
// aste che devono ancora aprire stanno in fondo. Fermandosi al tetto del
// catalogo un'asta che parte fra tre ore resta invisibile finché non si
// avvicina — e a quel punto è già cominciata, che è esattamente ciò che si
// voleva evitare.
var depth = Math.Max(settings.CatalogMaxAuctions, settings.AutoAddScanMaxAuctions);
var auctions = await _catalog
// La sorveglianza gira spesso: senza cache rifarebbe tutte le
// richieste del catalogo a ogni giro.
.GetAllAuctionsAsync(AllAuctions, depth, ct, settings.CatalogCacheSeconds)
.ConfigureAwait(false);
if (ct.IsCancellationRequested) return;
LastScanAt = DateTime.Now;
// Corrispondenze prima, orizzonte poi. Le aste già trattate non tornano:
// se l'utente ne ha rimossa una a mano, rimetterla sarebbe una lotta
// contro l'utente.
var matches = new List<(CatalogAuction Auction, WatchedProduct Product)>();
foreach (var auction in auctions)
{
if (WatchedProductsStore.IsHandled(auction.AuctionId)) continue;
var product = watched.FirstOrDefault(p => p.Matches(auction));
if (product != null) matches.Add((auction, product));
}
if (matches.Count == 0) return;
// Il listato non dice quando comincia un'asta né se qualcuno ha già puntato:
// le schede portano solo la durata nominale del timer. Lo stato vero sta in
// data.php, quindi per decidere serve una chiamata — ma solo sulle
// corrispondenze, che sono poche, e in blocchi da sessanta.
var needStates = settings.AutoAddOnlyNotStarted ||
matches.Any(m => Horizon(m.Product, settings) > 0);
if (needStates)
{
await _catalog
.UpdateStatesAsync(matches.Select(m => m.Auction).ToList(), ct)
.ConfigureAwait(false);
if (ct.IsCancellationRequested) return;
}
var found = 0;
var postponed = 0;
var alreadyRunning = 0;
foreach (var (auction, product) in matches)
{
if (ct.IsCancellationRequested) return;
// Orizzonte: un'asta che comincia troppo avanti nel tempo non entra
// ancora. Non va marcata come trattata — deve tornare alla scansione
// successiva, quando sarà abbastanza vicina.
var horizon = Horizon(product, settings);
if (horizon > 0 && auction.RemainingSeconds > horizon)
{
postponed++;
continue;
}
// Solo aste non ancora cominciate: qui invece si marca come trattata,
// ed è la differenza che conta. Un'asta già in corso non tornerà mai
// "non cominciata", quindi rivederla a ogni giro sarebbe solo lavoro
// sprecato — al contrario di una che deve ancora aprire.
if (settings.AutoAddOnlyNotStarted && HasStarted(auction))
{
alreadyRunning++;
WatchedProductsStore.MarkHandled(auction.AuctionId);
continue;
}
// Il tetto si controlla a ogni corrispondenza: chi conta è il chiamante,
// perché solo lui sa quante sono davvero ancora nel monitor.
if (settings.AutoAddMaxAuctions > 0 &&
(CountAutoAdded?.Invoke() ?? 0) + found >= settings.AutoAddMaxAuctions)
{
OnLog?.Invoke($"[SEGUITI] Raggiunto il limite di {settings.AutoAddMaxAuctions} aste aggiunte automaticamente");
break;
}
found++;
// Un'asta non ancora partita è la più preziosa da prendere: entra nel
// monitor prima che qualcuno punti, e i dati raccolti raccontano tutta
// la storia invece che la sua seconda metà.
if (auction.RemainingSeconds > 120)
OnLog?.Invoke($"[SEGUITI] Trovata prima dell'inizio ({Describe(auction.RemainingSeconds)}): {auction.Name}");
OnAuctionFound?.Invoke(auction, product);
}
if (postponed > 0)
{
OnLog?.Invoke($"[SEGUITI] {postponed} aste rinviate: cominciano oltre l'orizzonte impostato " +
$"({settings.AutoAddMaxStartMinutes} min). Verranno riprese quando saranno più vicine.");
}
if (alreadyRunning > 0)
{
OnLog?.Invoke($"[SEGUITI] {alreadyRunning} aste saltate: erano già cominciate e " +
"l'opzione chiede di seguirle solo dall'inizio.");
}
}
catch (OperationCanceledException)
{
// Chiusura in corso.
}
catch (Exception ex)
{
OnLog?.Invoke($"[SEGUITI] Scansione non riuscita: {ex.Message}");
}
}
/// <summary>
/// L'asta è già cominciata, cioè qualcuno ha già puntato.
///
/// <para>Il criterio è il puntatore, non il timer: un'asta appena aperta e ancora
/// senza offerte si può seguire dal principio, ed è esattamente quella che si vuole
/// prendere. Il prezzo serve da riserva per le risposte in cui il nome del puntatore
/// non arriva: su Bidoo si parte da un centesimo, quindi qualunque valore più alto
/// vuol dire che qualcuno ha già puntato.</para>
/// </summary>
private static bool HasStarted(CatalogAuction auction) =>
!string.IsNullOrWhiteSpace(auction.LastBidder) || auction.CurrentPrice > 0.01m;
/// <summary>Orizzonte in secondi per questo prodotto (0 = nessun limite).</summary>
private static int Horizon(WatchedProduct product, AppSettings settings) =>
ProductRuleResolver.HorizonSeconds(product, settings);
private static string Describe(int seconds) =>
seconds >= 3600 ? $"fra {seconds / 3600}h {seconds % 3600 / 60}min"
: seconds >= 60 ? $"fra {seconds / 60} min"
: $"fra {seconds}s";
}
}
+191
View File
@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.RegularExpressions;
using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Estrae dalla pagina di raccolta i collegamenti di riscatto validi.
///
/// <para>Funzione pura da HTML a elenco: nessuna rete, nessuno stato. È l'unico pezzo
/// legato all'aspetto del sito sorgente — che è di terzi e può cambiare senza
/// preavviso — e tenerlo separato significa poterlo verificare su una pagina salvata.</para>
///
/// <para>Il filtro è deliberatamente severo: un collegamento vale solo se punta al
/// dominio giusto, al percorso giusto e porta <b>tutti</b> i parametri richiesti. Su una
/// pagina piena di banner e affiliazioni, prendere tutto quello che assomiglia a un
/// riscatto significherebbe aprire indirizzi altrui con la propria sessione attiva.</para>
///
/// <para>Niente parser HTML completo, in linea con il resto del progetto
/// (<see cref="CatalogPageParser"/>, <see cref="FreeBidsPageParser"/>): quello che serve
/// qui è leggere gli attributi di un tag <c>&lt;a&gt;</c>, e una dipendenza in più
/// andrebbe poi dentro l'eseguibile unico per fare esattamente questo.</para>
/// </summary>
public static class PromoLinkParser
{
private const RegexOptions Opts =
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled;
/// <summary>Tag di apertura di un collegamento, con tutti i suoi attributi.</summary>
private static readonly Regex AnchorTag = new(@"<a\b[^>]*>", Opts);
private static readonly Regex HrefAttribute =
new(@"href\s*=\s*(?:""(?<v>[^""]*)""|'(?<v>[^']*)')", Opts);
private static readonly Regex ClassAttribute =
new(@"class\s*=\s*(?:""(?<v>[^""]*)""|'(?<v>[^']*)')", Opts);
/// <summary>
/// Legge la pagina e restituisce i collegamenti riscuotibili, senza doppioni.
///
/// <para><paramref name="problem"/> vale <c>null</c> quando la pagina è stata capita,
/// anche se non conteneva collegamenti: distinguere "pagina letta, niente di nuovo"
/// da "pagina non più riconoscibile" è ciò che permette di accorgersi che il sito
/// sorgente è cambiato, invece di credere che i premi siano finiti.</para>
/// </summary>
public static IReadOnlyList<PromoLink> Extract(
string? html,
FreeBidsPromoHarvest settings,
out string? problem)
{
problem = null;
settings ??= new FreeBidsPromoHarvest();
if (string.IsNullOrWhiteSpace(html))
{
problem = "la pagina dei collegamenti è arrivata vuota";
return Array.Empty<PromoLink>();
}
var anchors = AnchorTag.Matches(html);
if (anchors.Count == 0)
{
problem = "nessun collegamento nella pagina: formato cambiato?";
return Array.Empty<PromoLink>();
}
// Le impostazioni possono arrivare da un file scritto a mano: qui si legge in
// sicurezza invece di pretendere che qualcuno le abbia già sistemate.
var wanted = settings.LinkClass?.Trim() ?? "";
var candidates = 0;
var found = new List<PromoLink>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (Match anchor in anchors)
{
var tag = anchor.Value;
if (wanted.Length > 0 && !HasClass(tag, wanted)) continue;
candidates++;
var href = HrefAttribute.Match(tag);
if (!href.Success) continue;
// Gli href nell'HTML arrivano con le entità: senza decodificarle il "&"
// resterebbe "&amp;" e il secondo parametro finirebbe nel nome del primo.
var url = WebUtility.HtmlDecode(href.Groups["v"].Value).Trim();
if (!TryAccept(url, settings, out var link)) continue;
if (!seen.Add(link!.Code)) continue;
found.Add(link);
}
if (wanted.Length > 0 && candidates == 0)
problem = $"nessun collegamento con classe «{wanted}»: la pagina è cambiata";
else if (found.Count == 0 && candidates > 0)
problem = $"trovati {candidates} collegamenti, nessuno riscuotibile su {settings.TargetDomain}";
return found;
}
/// <summary>
/// Un collegamento è accettabile solo se è quello che dice di essere: dominio,
/// percorso e parametri richiesti. Il resto della pagina non ci riguarda.
/// </summary>
public static bool TryAccept(string? url, FreeBidsPromoHarvest settings, out PromoLink? link)
{
link = null;
settings ??= new FreeBidsPromoHarvest();
if (string.IsNullOrWhiteSpace(url)) return false;
if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri)) return false;
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) return false;
var domain = settings.TargetDomain?.Trim() ?? "";
if (domain.Length > 0 && !string.Equals(uri.Host, domain, StringComparison.OrdinalIgnoreCase))
return false;
var path = settings.TargetPath?.Trim() ?? "";
if (path.Length > 0 &&
!string.Equals(uri.AbsolutePath.TrimEnd('/'), path.TrimEnd('/'), StringComparison.OrdinalIgnoreCase))
return false;
var query = ParseQuery(uri.Query);
foreach (var required in (settings.RequiredQueryParams ?? new List<string>())
.Where(p => !string.IsNullOrWhiteSpace(p)))
{
if (!query.TryGetValue(required.Trim(), out var value) || value.Length == 0)
return false;
}
// Senza codice non ci sarebbe modo di riconoscere un doppione, e lo stesso premio
// verrebbe riaperto a ogni giro.
var codeKey = string.IsNullOrWhiteSpace(settings.CodeQueryParam) ? "promocode" : settings.CodeQueryParam.Trim();
if (!query.TryGetValue(codeKey, out var code) || code.Length == 0)
return false;
link = new PromoLink(code, uri.AbsoluteUri);
return true;
}
/// <summary>
/// Parametri della query in un dizionario. Scritto a mano invece di
/// <c>HttpUtility.ParseQueryString</c>: quello vive in <c>System.Web</c>, che non è
/// referenziato qui, e servono solo chiavi e valori già decodificati.
/// </summary>
public static Dictionary<string, string> ParseQuery(string? query)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(query)) return result;
foreach (var pair in query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var separator = pair.IndexOf('=');
var key = separator < 0 ? pair : pair[..separator];
var value = separator < 0 ? "" : pair[(separator + 1)..];
key = Uri.UnescapeDataString(key).Trim();
if (key.Length == 0) continue;
result[key] = Uri.UnescapeDataString(value);
}
return result;
}
/// <summary>
/// True se il tag porta davvero quella classe. Il confronto è per parola intera:
/// <c>class="aste_links_old"</c> non è <c>aste_links</c>, e prenderlo per tale
/// significherebbe aprire collegamenti che il sito ha smesso di usare.
/// </summary>
private static bool HasClass(string tag, string wanted)
{
var attribute = ClassAttribute.Match(tag);
if (!attribute.Success) return false;
return attribute.Groups["v"].Value
.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
.Any(token => string.Equals(token, wanted, StringComparison.OrdinalIgnoreCase));
}
}
}
+107 -78
View File
@@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
@@ -8,137 +8,166 @@ using AutoBidder.Models;
namespace AutoBidder.Services
{
/// <summary>
/// Gestore persistenza sessione Bidoo
/// Salva in modo sicuro il cookie di autenticazione per riutilizzo
/// Il cookie deve essere inserito manualmente dall'utente (copiato dal browser)
/// Persistenza della sessione Bidoo.
///
/// Il cookie salvato qui è la credenziale piena del conto: chi lo legge entra come te.
/// Va quindi protetto con <b>DPAPI</b> (<see cref="ProtectedData"/>, ambito utente
/// corrente): Windows lega il testo cifrato al tuo account, e l'applicazione non deve
/// custodire nessuna chiave.
///
/// <para>La versione precedente usava AES con chiave derivata da una stringa compilata
/// nell'eseguibile e IV costante: chiunque avesse l'exe poteva decifrare qualunque
/// <c>session.dat</c>. Quel formato viene ancora <i>letto</i> per non perdere le
/// sessioni esistenti, e al primo salvataggio il file viene riscritto con DPAPI.</para>
/// </summary>
public class SessionManager
{
private static readonly string SessionFilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"AutoBidder",
"session.dat"
);
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("AutoBidder_V1_2025");
// Nella radice fissa: DPAPI lega comunque il file a questo utente Windows,
// quindi spostarlo su un disco condiviso non lo renderebbe piu' utilizzabile.
private static string SessionFilePath => Utilities.AppPaths.SessionFile;
/// <summary>Marcatore iniziale dei file protetti con DPAPI.</summary>
private static readonly byte[] DpapiMagic = "ABDP1\0"u8.ToArray();
/// <summary>
/// Salva la sessione in modo sicuro (crittografata con DPAPI)
/// Entropia aggiuntiva: senza, qualunque altro programma in esecuzione con il tuo
/// account potrebbe decifrare il file semplicemente chiamando DPAPI.
/// </summary>
private static readonly byte[] Entropy = SHA256.HashData(
Encoding.UTF8.GetBytes("AutoBidder.Session.v2"));
// ── Formato precedente (solo lettura, per non perdere le sessioni salvate) ──
private static readonly byte[] LegacyKey =
SHA256.HashData(Encoding.UTF8.GetBytes("AutoBidder_Session_Key_V1_2025"));
private static readonly byte[] LegacyIV =
MD5.HashData(Encoding.UTF8.GetBytes("AutoBidder_IV_V1"));
/// <summary>Salva la sessione protetta con DPAPI.</summary>
public static bool SaveSession(BidooSession session)
{
try
{
// Crea directory se non esiste
var directory = Path.GetDirectoryName(SessionFilePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
// Serializza sessione in JSON
var json = JsonSerializer.Serialize(session, new JsonSerializerOptions
{
WriteIndented = true
var json = JsonSerializer.Serialize(session, new JsonSerializerOptions
{
WriteIndented = true
});
// Cripta usando DPAPI (Windows Data Protection API)
var plainBytes = Encoding.UTF8.GetBytes(json);
var encryptedBytes = ProtectedData.Protect(plainBytes, Entropy, DataProtectionScope.CurrentUser);
// Salva su file
File.WriteAllBytes(SessionFilePath, encryptedBytes);
Console.WriteLine($"[SESSION] Saved to: {SessionFilePath}");
var plain = Encoding.UTF8.GetBytes(json);
var protectedBytes = ProtectedData.Protect(plain, Entropy, DataProtectionScope.CurrentUser);
var output = new byte[DpapiMagic.Length + protectedBytes.Length];
Buffer.BlockCopy(DpapiMagic, 0, output, 0, DpapiMagic.Length);
Buffer.BlockCopy(protectedBytes, 0, output, DpapiMagic.Length, protectedBytes.Length);
File.WriteAllBytes(SessionFilePath, output);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[SESSION ERROR] Failed to save: {ex.Message}");
Console.WriteLine($"[SESSION ERROR] Salvataggio non riuscito: {ex.Message}");
return false;
}
}
/// <summary>
/// Carica la sessione salvata (decripta con DPAPI)
/// Carica la sessione. Riconosce il formato dal marcatore iniziale e, se trova
/// ancora il vecchio AES, la riscrive subito con DPAPI.
/// </summary>
public static BidooSession? LoadSession()
{
try
{
if (!File.Exists(SessionFilePath))
if (!File.Exists(SessionFilePath)) return null;
var raw = File.ReadAllBytes(SessionFilePath);
if (raw.Length == 0) return null;
var isDpapi = HasDpapiMagic(raw);
byte[] plain;
if (isDpapi)
{
Console.WriteLine($"[SESSION] No saved session found");
return null;
var payload = new byte[raw.Length - DpapiMagic.Length];
Buffer.BlockCopy(raw, DpapiMagic.Length, payload, 0, payload.Length);
plain = ProtectedData.Unprotect(payload, Entropy, DataProtectionScope.CurrentUser);
}
// Leggi file crittografato
var encryptedBytes = File.ReadAllBytes(SessionFilePath);
// Decripta usando DPAPI
var plainBytes = ProtectedData.Unprotect(encryptedBytes, Entropy, DataProtectionScope.CurrentUser);
var json = Encoding.UTF8.GetString(plainBytes);
// Deserializza JSON
else
{
plain = DecryptLegacyAes(raw);
}
var json = Encoding.UTF8.GetString(plain);
var session = JsonSerializer.Deserialize<BidooSession>(json);
if (session != null && session.IsValid)
if (session == null || !session.IsValid) return null;
// Migrazione silenziosa: da qui in poi il file è legato all'account Windows.
if (!isDpapi)
{
Console.WriteLine($"[SESSION] Loaded from: {SessionFilePath}");
Console.WriteLine($"[SESSION] Username: {session.Username}");
return session;
SaveSession(session);
Console.WriteLine("[SESSION] Sessione migrata alla protezione DPAPI");
}
Console.WriteLine($"[SESSION] Loaded session is invalid");
return null;
return session;
}
catch (Exception ex)
{
Console.WriteLine($"[SESSION ERROR] Failed to load: {ex.Message}");
Console.WriteLine($"[SESSION ERROR] Caricamento non riuscito: {ex.Message}");
return null;
}
}
/// <summary>
/// Elimina la sessione salvata
/// </summary>
public static bool ClearSession()
{
try
{
if (File.Exists(SessionFilePath))
{
File.Delete(SessionFilePath);
Console.WriteLine($"[SESSION] Cleared: {SessionFilePath}");
}
if (File.Exists(SessionFilePath)) File.Delete(SessionFilePath);
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[SESSION ERROR] Failed to clear: {ex.Message}");
Console.WriteLine($"[SESSION ERROR] Cancellazione non riuscita: {ex.Message}");
return false;
}
}
/// <summary>
/// Verifica se esiste una sessione salvata
/// </summary>
public static bool HasSavedSession()
{
return File.Exists(SessionFilePath);
}
/// <summary>
/// Ottiene informazioni sulla sessione salvata senza caricarla
/// </summary>
public static bool HasSavedSession() => File.Exists(SessionFilePath);
public static (bool exists, DateTime? lastModified) GetSessionInfo()
{
if (File.Exists(SessionFilePath))
if (!File.Exists(SessionFilePath)) return (false, null);
return (true, new FileInfo(SessionFilePath).LastWriteTime);
}
private static bool HasDpapiMagic(byte[] raw)
{
if (raw.Length < DpapiMagic.Length) return false;
for (var i = 0; i < DpapiMagic.Length; i++)
{
var fileInfo = new FileInfo(SessionFilePath);
return (true, fileInfo.LastWriteTime);
if (raw[i] != DpapiMagic[i]) return false;
}
return (false, null);
return true;
}
/// <summary>Legge il vecchio formato AES. Solo lettura: non si scrive più così.</summary>
private static byte[] DecryptLegacyAes(byte[] encrypted)
{
using var aes = Aes.Create();
aes.Key = LegacyKey;
aes.IV = LegacyIV;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
using var decryptor = aes.CreateDecryptor();
return decryptor.TransformFinalBlock(encrypted, 0, encrypted.Length);
}
}
}
+11
View File
@@ -0,0 +1,11 @@
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Alias pubblico per AppSettings per uso in Blazor
/// </summary>
public class Settings : AppSettings
{
}
}
-109
View File
@@ -1,109 +0,0 @@
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();
}
}
}
+131
View File
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Net;
using AutoBidder.Utilities;
namespace AutoBidder.Services
{
/// <summary>
/// Recupera a posteriori le puntate spese dai vincitori delle aste già concluse.
///
/// <para>Il dato sta in <c>data.php</c> e <b>non scade</b>: si può richiedere anche mesi
/// dopo la chiusura. Le aste registrate prima che l'applicazione sapesse leggerlo non
/// sono quindi perse — basta richiederle. Senza questo passaggio le statistiche sulle
/// puntate partirebbero da zero e servirebbero mesi per averne abbastanza.</para>
///
/// <para>Va piano di proposito: una richiesta alla volta, in corsia di fondo, con una
/// pausa fra l'una e l'altra. Non c'è alcuna fretta — è un recupero storico — e non deve
/// mai rubare banda alle aste in corso, che invece hanno una scadenza da rispettare.</para>
/// </summary>
public sealed class WinnerBidsBackfill
{
/// <summary>Pausa fra due richieste. Un recupero storico non ha fretta.</summary>
private const int DelayBetweenRequestsMs = 250;
private readonly BidooHttpClient _http;
public WinnerBidsBackfill(BidooHttpClient http) => _http = http;
/// <summary>Avanzamento: (fatte, totali, aggiornate).</summary>
public event Action<int, int, int>? OnProgress;
public event Action<string>? OnLog;
public sealed record Result(int Examined, int Updated, int Failed, int Skipped);
/// <summary>Aste concluse a cui manca il conteggio, dalla più recente.</summary>
public static List<CompletedAuctionRecord> FindMissing() =>
CompletedAuctionsStore.LoadAll()
.Where(r => !r.WinnerBidsUsed.HasValue)
.Where(r => !string.IsNullOrWhiteSpace(r.AuctionId))
// Un'asta senza vincitore non ha puntate da contare: è scaduta senza offerte.
.Where(r => !string.IsNullOrWhiteSpace(r.Winner))
.ToList();
public async Task<Result> RunAsync(int maxAuctions, CancellationToken ct)
{
var missing = FindMissing();
if (maxAuctions > 0 && missing.Count > maxAuctions)
missing = missing.Take(maxAuctions).ToList();
var updated = 0;
var failed = 0;
var skipped = 0;
for (var i = 0; i < missing.Count; i++)
{
if (ct.IsCancellationRequested) break;
var record = missing[i];
try
{
var state = await FetchAsync(record.AuctionId, ct).ConfigureAwait(false);
if (state?.WinnerBidsTotal is null)
{
skipped++;
}
else
{
record.WinnerBidsPaid = state.WinnerBidsPaid;
record.WinnerBidsFree = state.WinnerBidsFree;
var check = AuctionIntegrity.CheckWinnerBids(record);
if (check.IsTrustworthy)
{
// Si riscrive solo il record toccato: Append sostituisce
// quello con lo stesso id.
CompletedAuctionsStore.Append(record);
updated++;
}
else
{
// Il valore c'è ma non regge il confronto col prezzo: non si
// salva, e si dice perché. Meglio un buco che una media storta.
record.WinnerBidsPaid = null;
record.WinnerBidsFree = null;
skipped++;
OnLog?.Invoke($"[RECUPERO] {record.Name}: scartato ({check.Reason})");
}
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
failed++;
OnLog?.Invoke($"[RECUPERO] {record.AuctionId}: {ex.Message}");
}
OnProgress?.Invoke(i + 1, missing.Count, updated);
if (!await Wait.DelayAsync(DelayBetweenRequestsMs, ct).ConfigureAwait(false)) break;
}
return new Result(missing.Count, updated, failed, skipped);
}
private async Task<AuctionState?> FetchAsync(string auctionId, CancellationToken ct)
{
var url = $"{BidooHttpClient.Origin}/data.php?ALL={auctionId}&LISTID=0";
var outcome = await _http
.SendAsync(_http.BuildGet(url, BidooHttpClient.Origin + "/"),
RequestPriority.Background, ct)
.ConfigureAwait(false);
if (!outcome.Success) return null;
// Lo storico non serve: interessa solo la coda del core.
var parsed = BidooResponseParser.Parse(auctionId, outcome.Body, 0, 0, null, maxHistoryEntries: 1);
return parsed.State;
}
}
}