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

286 lines
12 KiB
C#

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);
}
}
}
}