- 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.
394 lines
17 KiB
C#
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();
|
|
|
|
}
|
|
}
|