Change to static skin catalog population

This commit is contained in:
bob
2026-05-29 18:36:17 -05:00
parent 6f3c0175cd
commit b51f1d9f5f
26 changed files with 3063 additions and 370 deletions

View File

@@ -0,0 +1,36 @@
namespace BlueLaminate.Scraper.Skins;
/// <summary>A single CS2 skin from the CSGO-API static catalogue (skins.json).</summary>
/// <param name="Id">Stable catalogue id, e.g. "skin-e757fd7191f9". Globally unique natural key.</param>
/// <param name="WeaponName">Owning weapon, e.g. "AK-47", "Hand Wraps", "Bayonet".</param>
/// <param name="Category">Weapon category, e.g. "Rifles", "Knives", "Gloves". Becomes the weapon type.</param>
/// <param name="Team">"CT", "T", or "Both".</param>
/// <param name="Name">Skin/pattern name, e.g. "Dragon Lore"; "Vanilla" for knives with no finish.</param>
/// <param name="Rarity">Rarity tier, e.g. "Covert", "Classified", "Extraordinary".</param>
/// <param name="Description">Flavour/description text, or null.</param>
/// <param name="ImageUrl">Catalogue image URL, or null.</param>
/// <param name="StatTrakAvailable">True if a StatTrak variant exists.</param>
/// <param name="SouvenirAvailable">True if a Souvenir variant exists.</param>
/// <param name="FloatMin">Minimum wear value, or null when the catalogue gives none (e.g. vanilla knives).</param>
/// <param name="FloatMax">Maximum wear value, or null.</param>
/// <param name="Sources">Collections and containers this skin belongs to.</param>
public sealed record CatalogSkin(
string Id,
string WeaponName,
string Category,
string Team,
string Name,
string Rarity,
string? Description,
string? ImageUrl,
bool StatTrakAvailable,
bool SouvenirAvailable,
decimal? FloatMin,
decimal? FloatMax,
IReadOnlyList<CatalogSource> Sources);
/// <summary>A collection or container a skin originates from.</summary>
/// <param name="Id">Stable catalogue id, e.g. "collection-set-community-37" or "crate-4288". Natural key.</param>
/// <param name="Name">Display name, e.g. "The Dead Hand Collection", "Glove Case".</param>
/// <param name="Type">"Collection" or "Container".</param>
public sealed record CatalogSource(string Id, string Name, string Type);

View File

@@ -0,0 +1,105 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace BlueLaminate.Scraper.Skins;
/// <summary>
/// Loads the CS2 skin catalogue from the ByMykel/CSGO-API static dataset
/// (skins.json) and maps it to <see cref="CatalogSkin"/> records. This replaces
/// the old HTML scraper: one JSON file carries every skin with its weapon,
/// category, rarity, wear range, and the collections/containers it comes from.
/// </summary>
public sealed class SkinCatalogClient
{
public const string DefaultUrl =
"https://raw.githubusercontent.com/ByMykel/CSGO-API/refs/heads/main/public/api/en/skins.json";
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
private readonly HttpClient _http;
private readonly string _url;
public SkinCatalogClient(HttpClient http, string? url = null)
{
_http = http;
_url = url ?? DefaultUrl;
}
public async Task<IReadOnlyList<CatalogSkin>> FetchAsync(CancellationToken ct = default)
{
await using var stream = await _http.GetStreamAsync(_url, ct);
var dtos = await JsonSerializer.DeserializeAsync<List<SkinDto>>(stream, Options, ct)
?? throw new InvalidOperationException("skins.json deserialized to null.");
return dtos.Select(Map).ToList();
}
private static CatalogSkin Map(SkinDto dto)
{
var sources = new List<CatalogSource>();
AddSources(sources, dto.Collections, "Collection");
AddSources(sources, dto.Crates, "Container");
return new CatalogSkin(
Id: dto.Id,
WeaponName: dto.Weapon?.Name ?? "Unknown",
Category: dto.Category?.Name ?? "Unknown",
Team: MapTeam(dto.Team?.Id),
// Knives with no finish carry a null pattern; "Vanilla" is the community term.
Name: dto.Pattern?.Name ?? "Vanilla",
Rarity: dto.Rarity?.Name ?? "Unknown",
Description: dto.Description,
ImageUrl: dto.Image,
StatTrakAvailable: dto.Stattrak,
SouvenirAvailable: dto.Souvenir,
FloatMin: dto.MinFloat,
FloatMax: dto.MaxFloat,
Sources: sources);
}
private static void AddSources(List<CatalogSource> into, List<NamedDto>? items, string type)
{
if (items is null)
return;
foreach (var item in items)
{
if (string.IsNullOrEmpty(item.Id) || string.IsNullOrEmpty(item.Name))
continue;
if (into.Any(s => s.Id == item.Id))
continue;
into.Add(new CatalogSource(item.Id, item.Name, type));
}
}
private static string MapTeam(string? teamId) => teamId switch
{
"terrorists" => "T",
"counter-terrorists" => "CT",
_ => "Both",
};
private sealed record SkinDto(
string Id,
string? Name,
string? Description,
NamedDto? Weapon,
NamedDto? Category,
NamedDto? Pattern,
decimal? MinFloat,
decimal? MaxFloat,
NamedDto? Rarity,
bool Stattrak,
bool Souvenir,
string? Image,
NamedDto? Team,
List<NamedDto>? Collections,
List<NamedDto>? Crates);
private sealed record NamedDto(string? Id, string? Name);
}