Files
Mimante/Mimante/Services/PromoLinkParser.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

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>&lt;a&gt;</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 "&amp;" 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));
}
}
}