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

394 lines
17 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutoBidder.Models;
using AutoBidder.Net;
namespace AutoBidder.Services
{
/// <summary>
/// Sfoglia il catalogo pubblico di Bidoo: categorie, listato paginato a scorrimento e
/// aggiornamento massivo dei prezzi tramite <c>data.php?LISTID=...</c>. Non richiede
/// autenticazione.
///
/// Affianca il browser integrato: il browser resta la via comoda per navigare e per il
/// login (da cui si estrae il cookie), questa è la via veloce per confrontare molte aste
/// in una griglia ordinabile senza caricare pagine.
/// </summary>
public sealed partial class BidooCatalogClient
{
private readonly BidooHttpClient _http;
private readonly List<CatalogCategory> _categoryCache = new();
private DateTime _categoriesCachedAt = DateTime.MinValue;
public BidooCatalogClient(BidooHttpClient http) => _http = http;
/// <summary>
/// Diagnostica verso il log applicativo: senza, un catalogo vuoto è indistinguibile
/// da un errore di rete.
/// </summary>
public Action<string>? Diagnostic { get; set; }
private void Report(string message) => Diagnostic?.Invoke(message);
public async Task<List<CatalogCategory>> GetCategoriesAsync(bool forceRefresh, CancellationToken ct)
{
lock (_categoryCache)
{
if (!forceRefresh && _categoryCache.Count > 0 &&
DateTime.UtcNow - _categoriesCachedAt < TimeSpan.FromMinutes(30))
{
return _categoryCache.ToList();
}
}
var categories = new List<CatalogCategory>
{
new() { TabId = 3, DisplayName = "Tutte le aste", IsSpecial = true },
new() { TabId = 1, DisplayName = "Aste di puntate", IsSpecial = true },
new() { TabId = 5, DisplayName = "Aste manuali", IsSpecial = true }
};
try
{
var outcome = await _http
.SendAsync(_http.BuildGet(BidooHttpClient.Origin + "/", ajax: false),
RequestPriority.Background, ct)
.ConfigureAwait(false);
if (!outcome.Success)
{
Report($"[CATALOGO] Home non raggiungibile (HTTP {outcome.StatusCode}{FormatError(outcome)}): uso l'elenco categorie predefinito");
}
else
{
foreach (Match match in CategoryRegex().Matches(outcome.Body))
{
if (!int.TryParse(match.Groups[1].Value, out var tagId) || tagId <= 0) continue;
var name = CatalogPageParser.DecodeHtml(match.Groups[3].Value);
if (name.Length == 0) continue;
if (categories.Any(c => !c.IsSpecial && c.TagId == tagId)) continue;
categories.Add(new CatalogCategory
{
TabId = 4,
TagId = tagId,
Slug = match.Groups[2].Value.Trim(),
DisplayName = name
});
}
}
}
catch (OperationCanceledException) { throw; }
catch { /* si ricade sull'elenco predefinito */ }
if (categories.Count <= 3) categories.AddRange(DefaultCategories());
lock (_categoryCache)
{
_categoryCache.Clear();
_categoryCache.AddRange(categories);
_categoriesCachedAt = DateTime.UtcNow;
}
return categories;
}
private static IEnumerable<CatalogCategory> DefaultCategories() => new CatalogCategory[]
{
new() { TagId = 6, DisplayName = "Buoni", Slug = "buoni" },
new() { TagId = 5, DisplayName = "Smartphone", Slug = "smartphone" },
new() { TagId = 7, DisplayName = "Apple", Slug = "apple" },
new() { TagId = 13, DisplayName = "Bellezza", Slug = "bellezza" },
new() { TagId = 8, DisplayName = "Cucina", Slug = "cucina" },
new() { TagId = 18, DisplayName = "Casa e giardino", Slug = "casa_e_giardino" },
new() { TagId = 11, DisplayName = "Elettrodomestici", Slug = "elettrodomestici" },
new() { TagId = 9, DisplayName = "Videogame", Slug = "videogame" },
new() { TagId = 41, DisplayName = "Giocattoli", Slug = "giocattoli" },
new() { TagId = 14, DisplayName = "Tablet e PC", Slug = "tablet-e-pc" },
new() { TagId = 20, DisplayName = "Hobby", Slug = "hobby" },
new() { TagId = 22, DisplayName = "Smartwatch", Slug = "smartwatch" },
new() { TagId = 12, DisplayName = "Moda", Slug = "moda" },
new() { TagId = 10, DisplayName = "Smart TV", Slug = "smart-tv" },
new() { TagId = 21, DisplayName = "Fai da te", Slug = "fai_da_te" },
new() { TagId = 26, DisplayName = "Luxury", Slug = "luxury" },
new() { TagId = 19, DisplayName = "Cuffie e audio", Slug = "cuffie-e-audio" },
new() { TagId = 38, DisplayName = "Prima infanzia", Slug = "prima-infanzia" }
};
/// <summary>
/// Aste già scaricate di recente, per categoria. Un caricamento costa più richieste
/// in sequenza: senza cache, spostarsi avanti e indietro fra due categorie le rifà
/// tutte ogni volta, e la sorveglianza dei prodotti seguiti le ripete a ogni giro.
/// </summary>
private readonly Dictionary<string, (DateTime At, List<CatalogAuction> Auctions)> _auctionCache = new();
/// <summary>Svuota la cache: usato dal pulsante "Aggiorna", che deve fidarsi del server.</summary>
public void InvalidateCache()
{
lock (_auctionCache) _auctionCache.Clear();
}
/// <summary>
/// Raccoglie fino a <paramref name="maxAuctions"/> aste della categoria, scendendo
/// nel listato pagina per pagina come fa il sito quando si scorre verso il basso.
///
/// <para>Il listato è ordinato per scadenza crescente: le prime pagine sono le aste
/// che chiudono prima, cioè quelle su cui si decide. Fermarsi al tetto impostato
/// non taglia quindi via nulla di urgente.</para>
/// </summary>
public async Task<List<CatalogAuction>> GetAllAuctionsAsync(
CatalogCategory category, int maxAuctions, CancellationToken ct, int cacheSeconds = 0)
{
if (cacheSeconds > 0)
{
lock (_auctionCache)
{
if (_auctionCache.TryGetValue(category.Key, out var hit) &&
DateTime.UtcNow - hit.At < TimeSpan.FromSeconds(cacheSeconds) &&
hit.Auctions.Count > 0)
{
Report($"[CATALOGO] \"{category.DisplayName}\": {hit.Auctions.Count} aste (dalla cache)");
// Copia della lista, non degli elementi: i prezzi restano quelli
// reali e vengono comunque riaggiornati dal chiamante.
return hit.Auctions.ToList();
}
}
}
var all = new List<CatalogAuction>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var paged = await CollectPagesAsync(category, all, seen, maxAuctions, ct).ConfigureAwait(false);
if (!paged)
{
// Il sito ha cambiato interfaccia: meglio una griglia parziale che vuota.
Report($"[CATALOGO] \"{category.DisplayName}\": paginazione non disponibile, uso il listato classico");
await CollectLegacyAsync(category, all, seen, maxAuctions, ct).ConfigureAwait(false);
}
if (cacheSeconds > 0 && all.Count > 0)
{
lock (_auctionCache) _auctionCache[category.Key] = (DateTime.UtcNow, all.ToList());
}
Report($"[CATALOGO] \"{category.DisplayName}\": {all.Count} aste");
return all;
}
// ── Paginazione a scorrimento ────────────────────────────────────
//
// È il meccanismo del sito vero, ricavato dalle chiamate che fa il browser mentre
// si scorre la pagina:
//
// 1ª pagina GET /get_auctions.php?tab=T&tag=G
// seguenti POST /get_auction_updates.php
// prefetch=true&view=<id già mostrati>&tab=T&tag=G
//
// Non c'è un numero di pagina né un cursore: il client dice cosa ha già, e il
// server risponde con le cinquanta successive. Da qui due conseguenze pratiche —
// l'elenco `view` va portato avanti per intero a ogni richiesta, e la fine si
// riconosce solo dal fatto che una pagina torna vuota.
private const int PageSize = 50;
/// <summary>
/// Tetto assoluto di pagine, indipendente da quante ne servirebbero. Il server
/// segnala la fine restituendo una pagina vuota; se per un guasto smettesse di
/// farlo, questo impedisce un giro infinito.
/// </summary>
private const int MaxPages = 200;
/// <summary>
/// Scende nel listato finché ha raccolto abbastanza aste o finché il server smette
/// di darne. Restituisce <c>false</c> solo se la <i>prima</i> pagina non è
/// utilizzabile: è l'unico caso in cui vale la pena ripiegare sul listato classico.
/// </summary>
private async Task<bool> CollectPagesAsync(
CatalogCategory category, List<CatalogAuction> all, HashSet<string> seen,
int maxAuctions, CancellationToken ct)
{
var tab = category.IsSpecial ? category.TabId : 4;
var tag = category.IsSpecial ? 0 : category.TagId;
// L'elenco di ciò che si ha già: è l'unico "cursore" che il server accetta.
var view = new List<string>();
var pageLimit = Math.Min(MaxPages, (maxAuctions + PageSize - 1) / PageSize + 2);
for (var page = 0; page < pageLimit; page++)
{
ct.ThrowIfCancellationRequested();
var url = page == 0
? $"{BidooHttpClient.Origin}/get_auctions.php?tab={tab}&tag={tag}"
: $"{BidooHttpClient.Origin}/get_auction_updates.php";
var request = page == 0
? _http.BuildGet(url, BidooHttpClient.Origin + "/")
: _http.BuildPost(url, $"prefetch=true&view={string.Join(',', view)}&tab={tab}&tag={tag}",
BidooHttpClient.Origin + "/");
var outcome = await _http.SendAsync(request, RequestPriority.Background, ct).ConfigureAwait(false);
if (!outcome.Success)
{
if (page == 0)
{
Report($"[CATALOGO] \"{category.DisplayName}\": HTTP {outcome.StatusCode}{FormatError(outcome)}");
return false;
}
// A metà discesa un errore costa qualche asta, non la pagina.
break;
}
var parsed = CatalogPageParser.ParsePage(outcome.Body);
if (parsed is null)
{
if (page == 0) return false;
break;
}
// Nessun id restituito: si è arrivati in fondo al listato.
if (parsed.Ids.Count == 0) break;
var added = 0;
foreach (var auction in parsed.Auctions)
{
if (!seen.Add(auction.AuctionId)) continue;
all.Add(auction);
added++;
}
// Gli id vanno accumulati tutti, anche quelli di aste che non siamo
// riusciti a interpretare: sono ciò che dice al server dove eravamo.
view.AddRange(parsed.Ids);
if (all.Count >= maxAuctions) break;
// Una pagina che non porta nulla di nuovo significa che il server ha
// smesso di avanzare: insistere ripeterebbe le stesse aste all'infinito.
if (added == 0) break;
}
return all.Count > 0;
}
// ── Listato classico (ripiego) ───────────────────────────────────
/// <summary>
/// La vecchia pagina <c>index.php?selectBids=1</c>: non pagina, ma restituisce un
/// insieme diverso a ogni chiamata. Ripetendola si raccoglie qualcosa in più.
/// Serve solo se la paginazione a scorrimento smette di rispondere.
/// </summary>
private async Task CollectLegacyAsync(
CatalogCategory category, List<CatalogAuction> all, HashSet<string> seen,
int maxAuctions, CancellationToken ct)
{
var tab = category.IsSpecial ? category.TabId : 4;
var tag = category.IsSpecial ? 0 : category.TagId;
var url = $"{BidooHttpClient.Origin}/index.php?selectBids=1&tab={tab}&tag={tag}";
for (var round = 0; round < 4 && all.Count < maxAuctions; round++)
{
ct.ThrowIfCancellationRequested();
var outcome = await _http
.SendAsync(_http.BuildGet(url, BidooHttpClient.Origin + "/"), RequestPriority.Background, ct)
.ConfigureAwait(false);
if (!outcome.Success) return;
var added = 0;
foreach (var auction in CatalogPageParser.ParseAuctions(outcome.Body))
{
if (!seen.Add(auction.AuctionId)) continue;
all.Add(auction);
added++;
if (all.Count >= maxAuctions) return;
}
if (added == 0) return;
}
}
private static string FormatError(HttpOutcome outcome) =>
string.IsNullOrEmpty(outcome.Error) ? "" : $", {outcome.Error}";
/// <summary>
/// Aggiorna prezzo, ultimo puntatore e timer di molte aste con una sola chiamata.
/// Formato risposta: <c>serverTs*(id;STATO;scadenza;prezzoCent;utente#id2;...)</c>
/// </summary>
public async Task UpdateStatesAsync(IReadOnlyList<CatalogAuction> auctions, CancellationToken ct)
{
if (auctions.Count == 0) return;
// Blocchi da 60 id per non generare URL smisurati.
foreach (var chunk in auctions.Chunk(60))
{
var ids = string.Join(',', chunk.Select(a => a.AuctionId));
var url = $"{BidooHttpClient.Origin}/data.php?LISTID={ids}&chk={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
var outcome = await _http
.SendAsync(_http.BuildGet(url, BidooHttpClient.Origin + "/"), RequestPriority.Background, ct)
.ConfigureAwait(false);
if (!outcome.Success) continue;
ApplyListResponse(outcome.Body, chunk);
}
}
private static void ApplyListResponse(string response, IReadOnlyList<CatalogAuction> auctions)
{
var star = response.IndexOf('*');
if (star < 0) return;
if (!long.TryParse(response.AsSpan(0, star), out var serverSeconds)) return;
var data = response[(star + 1)..].Trim();
if (data.StartsWith('(') && data.EndsWith(')')) data = data[1..^1];
var byId = auctions.ToDictionary(a => a.AuctionId, StringComparer.Ordinal);
foreach (var entry in data.Split('#', StringSplitOptions.RemoveEmptyEntries))
{
var fields = entry.Split(';');
if (fields.Length < 4) continue;
if (!byId.TryGetValue(fields[0].Trim(), out var auction)) continue;
var status = fields[1].Trim();
if (int.TryParse(fields[3], out var cents)) auction.CurrentPrice = cents / 100m;
var bidder = fields.Length > 4 ? fields[4].Trim() : "";
if (bidder.Length > 0) auction.LastBidder = bidder;
if (long.TryParse(fields[2], out var expiry) && serverSeconds > 0)
{
auction.RemainingSeconds = (int)Math.Max(0, expiry - serverSeconds);
}
auction.IsActive = status.Equals("ON", StringComparison.OrdinalIgnoreCase);
auction.LastUpdated = DateTime.UtcNow;
}
}
[GeneratedRegex(@"javascript:selectBids\(4,\s*true,\s*false,\s*(\d+)\);\s*""\s+data-tab=""4""\s+data-slug=""([^""]*)""\s+data-tag=""\d+""><span[^>]*>([^<]+)</span>",
RegexOptions.IgnoreCase | RegexOptions.Singleline)]
private static partial Regex CategoryRegex();
}
}