- 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.
286 lines
12 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|