Due difetti che i registri di tre giorni mostravano chiaramente. Il riscatto dei buoni giudicava la risposta del sito, e la giudicava male: ogni singolo tentativo risultava "sessione scaduta: risposta la pagina di accesso" — 6 trovati, 0 riscossi, 6 non riusciti, giro dopo giro per tre giorni — mentre le puntate arrivavano lo stesso. Il riconoscimento della pagina di accesso scattava su pagine che di accesso non erano, e il servizio dichiarava di non funzionare mentre funzionava. Adesso aprire il collegamento e' il riscatto: si apre, si scrive che e' stato aperto, e il numero vero lo da' la differenza di saldo, che e' l'unica misura che non mente. Resta un solo fallimento possibile, l'errore di trasporto, dove il collegamento non e' stato aperto affatto. Un premio si riscuote una volta sola: fra i due errori, insistere su un premio gia' preso non costa niente, mentre dichiarare falliti dei riscatti riusciti riempie il registro di allarmi inutili. Il giudizio vecchio non e' stato cancellato, e' in GiudicaRisposta. Il conto alla rovescia in griglia restava fermo fra un poll e l'altro. Il valore era gia' giusto — si ricava dalla scadenza ancorata all'orologio del server, non dall'ultimo poll — ma nessuno lo richiedeva finche' non arrivava una risposta, e nelle prove reali fra un poll e l'altro passava anche un secondo e mezzo: sembrava un'applicazione bloccata. Un battito da cinque volte al secondo notifica ora le tre sole proprieta' che dipendono dal tempo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
222 lines
9.7 KiB
C#
222 lines
9.7 KiB
C#
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 ?? "";
|
|
|
|
// Il riconoscimento della pagina di accesso scattava anche su pagine che di
|
|
// accesso non erano: nei registri ogni riscatto risultava fallito mentre le
|
|
// puntate arrivavano. Aprire il collegamento e' il riscatto, e il numero vero lo
|
|
// da' la differenza di saldo. Vedi FreeBidsResponseValidator.Validate.
|
|
|
|
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");
|
|
}
|
|
}
|
|
}
|