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