almost ready

This commit is contained in:
bob
2026-06-01 10:52:06 -05:00
parent 8b0eb0db78
commit 763305ca89
94 changed files with 8766 additions and 2674 deletions

View File

@@ -36,9 +36,12 @@ public class Listing
/// <summary>"buy_now" or "auction".</summary>
public string Type { get; set; } = null!;
/// <summary>Asking price in USD.</summary>
/// <summary>Asking price.</summary>
public decimal Price { get; set; }
/// <summary>Currency of <see cref="Price"/>. CSFloat lists in USD.</summary>
public string Currency { get; set; } = "USD";
/// <summary>When CSFloat says the listing was created.</summary>
public DateTimeOffset ListedAt { get; set; }
@@ -48,7 +51,13 @@ public class Listing
public int PaintIndex { get; set; }
public string MarketHashName { get; set; } = null!;
public string? WearName { get; set; }
public decimal FloatValue { get; set; }
/// <summary>
/// Exact float, or null for items with no float at all (e.g. Vanilla knives).
/// Null is deliberately distinct from a genuine 0.0 float; a floatless item
/// also can't be fingerprinted, so its <see cref="SkinInstanceId"/> stays null.
/// </summary>
public decimal? FloatValue { get; set; }
public int PaintSeed { get; set; }
public bool IsStatTrak { get; set; }
public bool IsSouvenir { get; set; }
@@ -68,6 +77,15 @@ public class Listing
public int? SkinId { get; set; }
public Skin? Skin { get; set; }
/// <summary>
/// The wear band this listing belongs to. Unlike <see cref="SkinId"/> this is NOT
/// best-effort: the catalogue sweep pages one skin+wear band at a time, so the band
/// is set directly from the sweep unit. Null for whole-skin sweeps (e.g. vanilla
/// knives with no wear bands).
/// </summary>
public int? ConditionId { get; set; }
public SkinCondition? Condition { get; set; }
/// <summary>
/// The physical item (by fingerprint) this listing is for. Many listings over
/// time roll up to one instance, forming its market-movement history. Nullable

View File

@@ -16,12 +16,6 @@ public class Skin
public int? DefIndex { get; set; }
public int? PaintIndex { get; set; }
// When the catalogue-driven listing sweep last fully covered this skin. The
// sweep processes least-recently-swept skins first (nulls = never swept), so
// capped runs chain across the whole catalogue and the stalest data refreshes
// first. Null until the first sweep reaches this skin.
public DateTimeOffset? ListingsSweptAt { get; set; }
public string Name { get; set; } = null!;
public string Rarity { get; set; } = null!;
public string? Description { get; set; }
@@ -44,6 +38,12 @@ public class Skin
public bool? TrueFloat { get; private set; }
public ICollection<SkinCondition> Conditions { get; set; } = new List<SkinCondition>();
// Per-site "last swept" checkpoints for the whole-skin sweep unit — only used for
// skins with no wear bands (the per-band checkpoint lives on SkinCondition.Sweeps).
// The sweep processes never-swept (no row) / stalest skins first. See SkinSweep.
public ICollection<SkinSweep> Sweeps { get; set; } = new List<SkinSweep>();
public ICollection<SkinInstance> Instances { get; set; } = new List<SkinInstance>();
public ICollection<PriceHistory> PriceHistories { get; set; } = new List<PriceHistory>();
}

View File

@@ -7,14 +7,15 @@ public class SkinCondition
public Skin Skin { get; set; } = null!;
public string Condition { get; set; } = null!;
public decimal MinFloat { get; set; }
public decimal MaxFloat { get; set; }
public decimal FloatMin { get; set; }
public decimal FloatMax { get; set; }
// When the catalogue-driven listing sweep last fully covered this skin's wear
// band. The sweep splits each skin by wear and pages one band at a time, so this
// is the per-band checkpoint: an interrupted run resumes from never-swept/stalest
// bands rather than redoing a whole skin. Null until the first sweep reaches it.
public DateTimeOffset? ListingsSweptAt { get; set; }
// Per-site "last swept" checkpoints for this wear band — one row per marketplace
// (Source). The sweep splits each skin by wear and pages one band at a time, so
// this is the per-band checkpoint: an interrupted run resumes from never-swept
// (no row) / stalest bands rather than redoing a whole skin. Tracked per site so a
// band swept on CSFloat is still never-swept on cs.money. See SkinConditionSweep.
public ICollection<SkinConditionSweep> Sweeps { get; set; } = new List<SkinConditionSweep>();
public ICollection<SkinInstance> Instances { get; set; } = new List<SkinInstance>();
public ICollection<PriceHistory> PriceHistories { get; set; } = new List<PriceHistory>();

View File

@@ -0,0 +1,21 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// One site's "last swept" checkpoint for a single wear band. The catalogue sweep
/// processes least-recently-swept bands first (no row = never swept), so capped/looping
/// runs chain across the catalogue and refresh the stalest data first. Keyed by
/// <c>(SkinConditionId, Source)</c> so each marketplace tracks its own progress
/// independently — a band swept on one site stays never-swept on another.
/// </summary>
public class SkinConditionSweep
{
public int Id { get; set; }
public int SkinConditionId { get; set; }
public SkinCondition SkinCondition { get; set; } = null!;
/// <summary>Which site swept it — a <see cref="SweepSource"/> value.</summary>
public string Source { get; set; } = null!;
public DateTimeOffset SweptAt { get; set; }
}

View File

@@ -26,9 +26,11 @@ public class SkinInstance
public SkinCondition? Condition { get; set; }
// The fingerprint. FloatValue is stored at full precision (see config) so
// that exact-match dupe detection isn't fooled by rounding.
// that exact-match dupe detection isn't fooled by rounding. An instance is
// only created for items that have a float + paint seed (skins), so both are
// non-null here even though some listings (e.g. vanilla knives) lack them.
public decimal FloatValue { get; set; }
public string PaintSeed { get; set; } = null!;
public int PaintSeed { get; set; }
public bool StatTrak { get; set; }
public bool Souvenir { get; set; }
public DateTimeOffset FirstSeenAt { get; set; }

View File

@@ -0,0 +1,54 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// One offer observed on skin.land via its internal
/// <c>GET /api/v2/obtained-skins?skin_id={id}&amp;page={n}</c> endpoint (scraped through
/// the Python worker, since skin.land has no public API and sits behind Cloudflare).
/// <para>
/// Kept in its own table like <see cref="CsMoneyListing"/>, but deliberately thinner:
/// skin.land exposes a full-precision float and price but <b>no paint seed / def index</b>,
/// so an offer can't be fingerprinted to a market-agnostic <see cref="SkinInstance"/> and
/// there is no cross-market roll-up or dupe detection here (revisit if pattern is ever
/// exposed). StatTrak and Souvenir live on <em>separate</em> skin.land pages (their own
/// <c>stattrak-</c>/<c>souvenir-</c> slugs); v1 sweeps the base page per skin+wear, so
/// <see cref="IsStatTrak"/>/<see cref="IsSouvenir"/> are normally false.
/// </para>
/// Soft-tracked across sweeps exactly like <see cref="CsMoneyListing"/>:
/// <see cref="FirstSeenAt"/>/<see cref="LastSeenAt"/> bound the observation window and
/// <see cref="Status"/> flips to <see cref="ListingStatus.Removed"/> when a once-seen
/// offer stops appearing (sold/delisted).
/// </summary>
public class SkinLandListing
{
public int Id { get; set; }
/// <summary>skin.land's offer id (obtained-skin <c>id</c>). Natural key for dedup.</summary>
public long ListingId { get; set; }
// Catalogue links. Like cs.money (and unlike the CSFloat global sweep) these are NOT
// best-effort: each scrape job targets one skin+wear, so we set them directly.
public int SkinId { get; set; }
public Skin Skin { get; set; } = null!;
public int? ConditionId { get; set; }
public SkinCondition? Condition { get; set; }
// Item identity, from the offer's skin block.
public string MarketHashName { get; set; } = null!;
public decimal? FloatValue { get; set; } // item_float (string, full precision)
public bool IsStatTrak { get; set; }
public bool IsSouvenir { get; set; }
public string? NameTag { get; set; } // offer.name_tag (rare; affects value)
public int StickerCount { get; set; }
// Pricing. skin.land returns a single price (the amount to buy/withdraw the item).
public decimal Price { get; set; } // final_withdrawal_price
public string Currency { get; set; } = "USD"; // prices are read in USD
public string? InspectLink { get; set; } // item_link (steam:// inspect)
// Soft-tracking across sweeps.
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
public ListingStatus Status { get; set; }
public DateTimeOffset? RemovedAt { get; set; }
}

View File

@@ -0,0 +1,20 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// One site's "last swept" checkpoint for a whole skin — used only for skins with no
/// wear bands (e.g. vanilla knives), which are swept as a single unit. The per-band
/// equivalent is <see cref="SkinConditionSweep"/>. Keyed by <c>(SkinId, Source)</c> so
/// each marketplace tracks its own progress independently.
/// </summary>
public class SkinSweep
{
public int Id { get; set; }
public int SkinId { get; set; }
public Skin Skin { get; set; } = null!;
/// <summary>Which site swept it — a <see cref="SweepSource"/> value.</summary>
public string Source { get; set; } = null!;
public DateTimeOffset SweptAt { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// Canonical site identifiers for per-site sweep checkpoints — the <c>Source</c>
/// discriminator on <see cref="SkinSweep"/> and <see cref="SkinConditionSweep"/>.
/// Each marketplace sweeper stamps its own checkpoint under one of these, so a band
/// swept on one site is still "never swept" on another.
/// <para>
/// To add sweeping for a new marketplace, add one constant here and have that
/// sweeper read/stamp checkpoints with it — no schema or query changes needed.
/// </para>
/// </summary>
public static class SweepSource
{
/// <summary>CSFloat catalogue-driven sweep (<c>ListingSweepService.SweepCatalogAsync</c>).</summary>
public const string CsFloatCatalog = "listings-catalog";
/// <summary>cs.money worker sweep (<c>CsMoneyIngestService</c>).</summary>
public const string CsMoney = "csmoney";
/// <summary>skin.land worker sweep (<c>SkinLandIngestService</c>).</summary>
public const string SkinLand = "skinland";
}