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:
+888
-321
File diff suppressed because it is too large
Load Diff
@@ -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 < 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
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (&dash;, &plus;, &excl;…) che resterebbero
|
||||
/// visibili così come sono.
|
||||
/// </summary>
|
||||
private static readonly (string Entity, string Replacement)[] Html5Entities =
|
||||
{
|
||||
("‐", "-"), ("+", "+"), ("!", "!"), ("?", "?"),
|
||||
(".", "."), (",", ","), (":", ":"), (";", ";"),
|
||||
("(", "("), (")", ")"), ("/", "/"), ("*", "*"),
|
||||
("@", "@"), ("#", "#"), ("%", "%")
|
||||
};
|
||||
|
||||
public static string DecodeHtml(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return "";
|
||||
|
||||
// Prima passata: risolve &dash; in ‐ 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*(?:€|€|€)",
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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] + "…";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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><a></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 "&" 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using AutoBidder.Utilities;
|
||||
|
||||
namespace AutoBidder.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Alias pubblico per AppSettings per uso in Blazor
|
||||
/// </summary>
|
||||
public class Settings : AppSettings
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user