diff --git a/BlueLaminate/BlueLaminate.Cli/ListingSweepService.cs b/BlueLaminate/BlueLaminate.Cli/ListingSweepService.cs
new file mode 100644
index 0000000..064e6bd
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/ListingSweepService.cs
@@ -0,0 +1,556 @@
+using BlueLaminate.EFCore.Data;
+using BlueLaminate.EFCore.Entities;
+using BlueLaminate.Scraper.CsFloat;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace BlueLaminate.Cli;
+
+/// How many API pages were fetched.
+/// Total listings returned across those pages.
+/// New listings inserted.
+/// Existing listings refreshed (price/last-seen/etc.).
+/// Listings flagged Removed (only on a complete pass).
+/// Listings resolved to a catalogue skin by def/paint.
+/// Why the sweep ended.
+public sealed record ListingSweepResult(
+ int Pages,
+ int Seen,
+ int Inserted,
+ int Updated,
+ int Removed,
+ int Linked,
+ string StoppedReason);
+
+/// Catalogue skins fully paged this run.
+/// Skins left untouched (e.g. request budget ran out).
+public sealed record CatalogSweepResult(
+ int SkinsCovered,
+ int SkinsSkipped,
+ int Pages,
+ int Seen,
+ int Inserted,
+ int Updated,
+ int Removed,
+ string StoppedReason);
+
+///
+/// Global incremental sweep of CSFloat active listings into the database. Pages
+/// sort_by=most_recent with no item filter, so it captures every listing —
+/// including items not in our catalogue. Each listing is upserted by its stable
+/// CSFloat id; /
+/// bound the observation window.
+///
+/// Two things keep it safe against the 200-request rate limit and partial runs:
+///
+/// - Pacing. After each page it inspects the client's rate-limit
+/// headers; when remaining is low it sleeps until the reset epoch rather than
+/// risking a 429.
+/// - Removed-tracking only on a complete pass. Marking unseen listings
+/// as Removed is only valid when the whole market was covered. A capped or
+/// incremental run that stops early must not do it, or it would falsely "sell"
+/// everything it didn't reach.
+///
+///
+public sealed class ListingSweepService
+{
+ public const string Source = "listings";
+ public const string CatalogSource = "listings-catalog";
+
+ // Pace before the bucket is fully empty so a slightly-stale counter can't tip
+ // us into a 429.
+ private const int RateLimitSafetyMargin = 2;
+
+ private readonly SkinTrackerDbContext _db;
+ private readonly CsFloatListingsClient _client;
+ private readonly ILogger _logger;
+
+ public ListingSweepService(
+ SkinTrackerDbContext db,
+ CsFloatListingsClient client,
+ ILogger logger)
+ {
+ _db = db;
+ _client = client;
+ _logger = logger;
+ }
+
+ /// Hard cap on API pages this run (rate-limit budget).
+ /// Hard cap on listings ingested this run.
+ ///
+ /// Stop once a whole page is already-known listings (cheap daily delta). When
+ /// false, keep paging until the cursor or a cap is exhausted (cold pass).
+ ///
+ /// Optional courtesy delay between pages.
+ public async Task SweepAsync(
+ int maxRequests = 4,
+ int maxListings = 200,
+ bool incremental = true,
+ TimeSpan? delayBetweenPages = null,
+ CancellationToken ct = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var pages = 0;
+ var seen = 0;
+ var inserted = 0;
+ var updated = 0;
+ var linked = 0;
+ string? cursor = null;
+ string stoppedReason = "cursor exhausted";
+ var completePass = true;
+
+ // Catalogue lookup for best-effort skin linking, built once per run.
+ var skinByIndex = await _db.Skins
+ .Where(s => s.DefIndex != null && s.PaintIndex != null)
+ .Select(s => new { s.Id, s.DefIndex, s.PaintIndex })
+ .ToDictionaryAsync(s => (s.DefIndex!.Value, s.PaintIndex!.Value), s => s.Id, ct);
+
+ // Track which listing ids we touched this run, so a complete pass can flag
+ // the rest as Removed.
+ var touchedIds = new HashSet();
+ var touchedInstanceIds = new HashSet();
+
+ while (true)
+ {
+ if (pages >= maxRequests)
+ {
+ stoppedReason = $"hit max-requests cap ({maxRequests})";
+ completePass = false;
+ break;
+ }
+ if (seen >= maxListings)
+ {
+ stoppedReason = $"hit max-listings cap ({maxListings})";
+ completePass = false;
+ break;
+ }
+
+ ListingsPageResult page;
+ try
+ {
+ page = await _client.FetchPageAsync(
+ defIndex: null, paintIndex: null, sortBy: "most_recent",
+ limit: 50, cursor: cursor, ct: ct);
+ }
+ catch (CsFloatApiException ex)
+ {
+ _logger.LogError("Sweep aborted: {Message}", ex.Message);
+ stoppedReason = $"API error: {ex.Status}";
+ completePass = false;
+ break;
+ }
+
+ pages++;
+ seen += page.Listings.Count;
+
+ var (ins, upd, link, allKnown) = await IngestPageAsync(
+ page.Listings, skinByIndex, touchedIds, touchedInstanceIds, now, ct);
+ inserted += ins;
+ updated += upd;
+ linked += link;
+
+ _logger.LogInformation(
+ "Page {Page}: {Count} listings ({Ins} new, {Upd} updated); {Rate}",
+ pages, page.Listings.Count, ins, upd, _client.LastRateLimit);
+
+ cursor = page.Cursor;
+
+ // End of the market.
+ if (string.IsNullOrEmpty(cursor) || page.Listings.Count == 0)
+ {
+ stoppedReason = "cursor exhausted";
+ break;
+ }
+
+ // Incremental short-circuit: a full page we already knew means we've
+ // caught up to the previous sweep. This is a partial pass by design.
+ if (incremental && allKnown)
+ {
+ stoppedReason = "reached already-seen listings (incremental)";
+ completePass = false;
+ break;
+ }
+
+ await PaceAsync(delayBetweenPages, ct);
+ }
+
+ // Persist inserts/updates before computing Removed so the touched set is durable.
+ await _db.SaveChangesAsync(ct);
+
+ var removed = 0;
+ if (completePass)
+ removed = await MarkRemovedAsync(touchedIds, now, ct);
+ else
+ _logger.LogInformation("Partial pass — skipping Removed-tracking to avoid false sales.");
+
+ await FlagDupesAsync(touchedInstanceIds, now, ct);
+
+ await _db.ScrapeRuns.AddAsync(
+ new ScrapeRun { Source = Source, RanAt = now, ItemCount = seen }, ct);
+ await _db.SaveChangesAsync(ct);
+
+ return new ListingSweepResult(pages, seen, inserted, updated, removed, linked, stoppedReason);
+ }
+
+ ///
+ /// Catalogue-driven sweep: walk skins that have def/paint indexes and query
+ /// each one's listings with a server-side def_index+paint_index filter. The
+ /// API returns only that skin's listings, so no rate-limit budget is wasted on
+ /// stickers/cases/agents — every request is productive weapon data. Because
+ /// each skin is paged to completion, Removed-tracking is accurate per skin
+ /// even when the overall run is capped: a skin we fully covered but whose old
+ /// listing is now absent is genuinely gone.
+ ///
+ /// Hard cap on API pages across the whole run.
+ /// Safety cap on pages-worth per skin.
+ /// Optional courtesy delay between pages.
+ public async Task SweepCatalogAsync(
+ int maxRequests = 50,
+ int maxListingsPerSkin = 500,
+ TimeSpan? delayBetweenPages = null,
+ CancellationToken ct = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ // Least-recently-swept first (never-swept skins sort first because null
+ // orders before any timestamp ascending). This is the cross-run resume:
+ // a capped run always continues from where the previous one stopped, and
+ // the stalest data refreshes first.
+ var skins = await _db.Skins
+ .Where(s => s.DefIndex != null && s.PaintIndex != null)
+ .OrderBy(s => s.ListingsSweptAt)
+ .Select(s => new { s.Id, Def = s.DefIndex!.Value, Paint = s.PaintIndex!.Value })
+ .ToListAsync(ct);
+
+ var pages = 0;
+ var seen = 0;
+ var inserted = 0;
+ var updated = 0;
+ var removed = 0;
+ var covered = 0;
+ var stoppedReason = "all catalogue skins covered";
+
+ foreach (var skin in skins)
+ {
+ if (pages >= maxRequests)
+ {
+ stoppedReason = $"hit max-requests cap ({maxRequests})";
+ break;
+ }
+
+ // One-entry lookup so IngestPageAsync resolves SkinId to this skin.
+ var lookup = new Dictionary<(int, int), int> { [(skin.Def, skin.Paint)] = skin.Id };
+ var touchedIds = new HashSet();
+ var touchedInstanceIds = new HashSet();
+ string? cursor = null;
+ var skinComplete = true;
+ var skinSeen = 0;
+
+ while (true)
+ {
+ if (pages >= maxRequests)
+ {
+ stoppedReason = $"hit max-requests cap ({maxRequests})";
+ skinComplete = false;
+ break;
+ }
+
+ ListingsPageResult page;
+ try
+ {
+ page = await _client.FetchPageAsync(
+ defIndex: skin.Def, paintIndex: skin.Paint, sortBy: "lowest_price",
+ limit: 50, cursor: cursor, ct: ct);
+ }
+ catch (CsFloatApiException ex)
+ {
+ _logger.LogError("Catalogue sweep aborted on skin {SkinId}: {Message}", skin.Id, ex.Message);
+ await _db.SaveChangesAsync(ct);
+ return Finish($"API error: {ex.Status}");
+ }
+
+ pages++;
+ seen += page.Listings.Count;
+ skinSeen += page.Listings.Count;
+
+ var (ins, upd, _, _) = await IngestPageAsync(
+ page.Listings, lookup, touchedIds, touchedInstanceIds, now, ct);
+ inserted += ins;
+ updated += upd;
+
+ cursor = page.Cursor;
+ if (string.IsNullOrEmpty(cursor) || page.Listings.Count == 0)
+ break;
+ if (skinSeen >= maxListingsPerSkin)
+ {
+ skinComplete = false; // didn't reach the end; don't mark Removed
+ break;
+ }
+
+ await PaceAsync(delayBetweenPages, ct);
+ }
+
+ // Per-skin Removed-tracking + resume stamp: only when this skin was
+ // paged to the end. A partial skin (hit the per-skin cap) is left with
+ // its old ListingsSweptAt so the next run revisits it first.
+ if (skinComplete)
+ {
+ removed += await MarkRemovedForSkinAsync(skin.Id, touchedIds, now, ct);
+ await _db.Skins
+ .Where(s => s.Id == skin.Id)
+ .ExecuteUpdateAsync(
+ setters => setters.SetProperty(s => s.ListingsSweptAt, now), ct);
+ covered++;
+ }
+
+ // Persist this skin's listings/instances before dupe analysis so the
+ // asset-id grouping query sees them.
+ await _db.SaveChangesAsync(ct);
+ await FlagDupesAsync(touchedInstanceIds, now, ct);
+
+ await _db.SaveChangesAsync(ct);
+ await PaceAsync(delayBetweenPages, ct);
+ }
+
+ await _db.ScrapeRuns.AddAsync(
+ new ScrapeRun { Source = CatalogSource, RanAt = now, ItemCount = seen }, ct);
+ await _db.SaveChangesAsync(ct);
+
+ return Finish(stoppedReason);
+
+ CatalogSweepResult Finish(string reason) =>
+ new(covered, skins.Count - covered, pages, seen, inserted, updated, removed, reason);
+ }
+
+ // Flag this skin's once-Active listings that we didn't see this run as Removed.
+ private async Task MarkRemovedForSkinAsync(
+ int skinId, HashSet touchedIds, DateTimeOffset now, CancellationToken ct)
+ {
+ return await _db.Listings
+ .Where(l => l.SkinId == skinId
+ && l.Status == ListingStatus.Active
+ && !touchedIds.Contains(l.CsFloatListingId))
+ .ExecuteUpdateAsync(
+ setters => setters
+ .SetProperty(l => l.Status, ListingStatus.Removed)
+ .SetProperty(l => l.RemovedAt, now),
+ ct);
+ }
+
+ // Upsert a page of listings. Returns counts plus whether every listing on the
+ // page already existed (the incremental stop signal). Also resolves each
+ // listing to a SkinInstance (the physical item, by fingerprint) and records
+ // the touched instance ids so the caller can run dupe detection over them.
+ private async Task<(int Inserted, int Updated, int Linked, bool AllKnown)> IngestPageAsync(
+ IReadOnlyList listings,
+ IReadOnlyDictionary<(int, int), int> skinByIndex,
+ HashSet touchedIds,
+ HashSet touchedInstanceIds,
+ DateTimeOffset now,
+ CancellationToken ct)
+ {
+ if (listings.Count == 0)
+ return (0, 0, 0, true);
+
+ var ids = listings.Select(l => l.ListingId).ToList();
+ var existing = await _db.Listings
+ .Where(l => ids.Contains(l.CsFloatListingId))
+ .ToDictionaryAsync(l => l.CsFloatListingId, ct);
+
+ var inserted = 0;
+ var updated = 0;
+ var linked = 0;
+ var allKnown = true;
+
+ foreach (var l in listings)
+ {
+ touchedIds.Add(l.ListingId);
+ int? skinId = skinByIndex.TryGetValue((l.DefIndex, l.PaintIndex), out var id) ? id : null;
+ if (skinId is not null)
+ linked++;
+
+ // Resolve the physical item only when we know the skin — the
+ // fingerprint is meaningless without it.
+ var instance = skinId is { } sid
+ ? await ResolveInstanceAsync(sid, l, now, ct)
+ : null;
+ if (instance is not null)
+ touchedInstanceIds.Add(instance.Id);
+
+ if (existing.TryGetValue(l.ListingId, out var row))
+ {
+ // Refresh mutable fields. Price can change; a re-appeared listing
+ // returns to Active.
+ row.Price = l.Price;
+ row.LastSeenAt = now;
+ row.Status = ListingStatus.Active;
+ row.RemovedAt = null;
+ row.SkinId = skinId;
+ row.AssetId = l.AssetId;
+ row.SkinInstance = instance;
+ updated++;
+ }
+ else
+ {
+ allKnown = false;
+ var entity = MapToEntity(l, skinId, now);
+ entity.SkinInstance = instance;
+ _db.Listings.Add(entity);
+ inserted++;
+ }
+ }
+
+ return (inserted, updated, linked, allKnown);
+ }
+
+ // Find the SkinInstance matching this listing's fingerprint, or create one.
+ // The fingerprint is (skin, full-precision float, seed, stattrak, souvenir).
+ // It is deliberately NOT unique — duped copies share it — so a match may
+ // already represent more than one physical item; dupe detection runs later.
+ private async Task ResolveInstanceAsync(
+ int skinId, CsFloatListing l, DateTimeOffset now, CancellationToken ct)
+ {
+ var seed = l.PaintSeed.ToString();
+
+ // Check the change-tracker first (an instance just added earlier this page
+ // isn't queryable yet), then the database.
+ var tracked = _db.ChangeTracker.Entries()
+ .Select(e => e.Entity)
+ .FirstOrDefault(i => i.SkinId == skinId && i.FloatValue == l.FloatValue
+ && i.PaintSeed == seed && i.StatTrak == l.IsStatTrak && i.Souvenir == l.IsSouvenir);
+ if (tracked is not null)
+ {
+ tracked.LastSeenAt = now;
+ return tracked;
+ }
+
+ var instance = await _db.SkinInstances.FirstOrDefaultAsync(
+ i => i.SkinId == skinId && i.FloatValue == l.FloatValue
+ && i.PaintSeed == seed && i.StatTrak == l.IsStatTrak && i.Souvenir == l.IsSouvenir,
+ ct);
+
+ if (instance is not null)
+ {
+ instance.LastSeenAt = now;
+ return instance;
+ }
+
+ instance = new SkinInstance
+ {
+ SkinId = skinId,
+ FloatValue = l.FloatValue,
+ PaintSeed = seed,
+ StatTrak = l.IsStatTrak,
+ Souvenir = l.IsSouvenir,
+ FirstSeenAt = now,
+ LastSeenAt = now,
+ };
+ _db.SkinInstances.Add(instance);
+ return instance;
+ }
+
+ private static Listing MapToEntity(CsFloatListing l, int? skinId, DateTimeOffset now) => new()
+ {
+ CsFloatListingId = l.ListingId,
+ Type = l.Type,
+ Price = l.Price,
+ ListedAt = l.CreatedAt,
+ AssetId = l.AssetId,
+ DefIndex = l.DefIndex,
+ PaintIndex = l.PaintIndex,
+ MarketHashName = l.MarketHashName,
+ WearName = l.WearName,
+ FloatValue = l.FloatValue,
+ PaintSeed = l.PaintSeed,
+ IsStatTrak = l.IsStatTrak,
+ IsSouvenir = l.IsSouvenir,
+ StickerCount = l.StickerCount,
+ SellerSteamId = l.SellerSteamId,
+ InspectLink = l.InspectLink,
+ SkinId = skinId,
+ FirstSeenAt = now,
+ LastSeenAt = now,
+ Status = ListingStatus.Active,
+ };
+
+ // Flag every currently-Active listing we did NOT see this run as Removed.
+ // Only called after a complete pass. Done in a single set-based update to
+ // avoid loading the whole table.
+ private async Task MarkRemovedAsync(
+ HashSet touchedIds, DateTimeOffset now, CancellationToken ct)
+ {
+ return await _db.Listings
+ .Where(l => l.Status == ListingStatus.Active && !touchedIds.Contains(l.CsFloatListingId))
+ .ExecuteUpdateAsync(
+ setters => setters
+ .SetProperty(l => l.Status, ListingStatus.Removed)
+ .SetProperty(l => l.RemovedAt, now),
+ ct);
+ }
+
+ // Dupe detection. For each instance touched this run, count the DISTINCT
+ // asset ids among its currently-Active listings. Two or more means the same
+ // fingerprint (skin+float+seed+ST+souvenir) is live under multiple Steam
+ // assets at once — the signature of a duplicated item, as opposed to an
+ // ordinary trade (which retires the old listing before the new one appears,
+ // leaving a single active asset). Flags freshly-detected dupes and stamps
+ // when first seen, enabling "alert on fresh duping" downstream.
+ private async Task FlagDupesAsync(
+ HashSet instanceIds, DateTimeOffset now, CancellationToken ct)
+ {
+ if (instanceIds.Count == 0)
+ return;
+
+ // Instances (among those touched) with 2+ distinct active asset ids.
+ var dupeInstanceIds = await _db.Listings
+ .Where(l => l.SkinInstanceId != null
+ && instanceIds.Contains(l.SkinInstanceId!.Value)
+ && l.Status == ListingStatus.Active
+ && l.AssetId != null)
+ .GroupBy(l => l.SkinInstanceId!.Value)
+ .Where(g => g.Select(l => l.AssetId).Distinct().Count() >= 2)
+ .Select(g => g.Key)
+ .ToListAsync(ct);
+
+ if (dupeInstanceIds.Count == 0)
+ return;
+
+ // Flag only those not already flagged, stamping first-seen once. Instances
+ // already marked stay marked (they're excluded by the !SuspectedDupe filter).
+ var newlyFlagged = await _db.SkinInstances
+ .Where(i => dupeInstanceIds.Contains(i.Id) && !i.SuspectedDupe)
+ .ExecuteUpdateAsync(
+ setters => setters
+ .SetProperty(i => i.SuspectedDupe, true)
+ .SetProperty(i => i.DupeFirstSeenAt, now),
+ ct);
+
+ if (newlyFlagged > 0)
+ _logger.LogWarning(
+ "Dupe detection: {Count} instance(s) newly flagged as suspected dupes.", newlyFlagged);
+ }
+
+ // Pace requests against the rate limit: if the bucket is nearly empty, sleep
+ // until the reset epoch. Otherwise apply only the optional courtesy delay.
+ private async Task PaceAsync(TimeSpan? delay, CancellationToken ct)
+ {
+ var rate = _client.LastRateLimit;
+ if (rate.Remaining is { } remaining && remaining <= RateLimitSafetyMargin
+ && long.TryParse(rate.Reset, out var resetEpoch))
+ {
+ var resetAt = DateTimeOffset.FromUnixTimeSeconds(resetEpoch);
+ var wait = resetAt - DateTimeOffset.UtcNow;
+ if (wait > TimeSpan.Zero)
+ {
+ _logger.LogWarning(
+ "Rate limit nearly exhausted ({Remaining} left); sleeping {Seconds:0}s until reset.",
+ remaining, wait.TotalSeconds);
+ await Task.Delay(wait, ct);
+ return;
+ }
+ }
+
+ if (delay is { } d && d > TimeSpan.Zero)
+ await Task.Delay(d, ct);
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/Program.cs b/BlueLaminate/BlueLaminate.Cli/Program.cs
index df096d0..f3ae79d 100644
--- a/BlueLaminate/BlueLaminate.Cli/Program.cs
+++ b/BlueLaminate/BlueLaminate.Cli/Program.cs
@@ -1,6 +1,9 @@
using BlueLaminate.Cli;
using BlueLaminate.Cli.Logging;
using BlueLaminate.EFCore.Data;
+using BlueLaminate.Scraper.Browser;
+using BlueLaminate.Scraper.CsFloat;
+using BlueLaminate.Scraper.Proxies;
using BlueLaminate.Scraper.Skins;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
@@ -46,13 +49,482 @@ syncSkins.SetAction((parseResult, ct) =>
loggerFactory,
ct));
+var countryOption = new Option("--country")
+{
+ Description = "Optional ISO country code(s) for the exit IP, e.g. \"us\" or \"us,gb\". Default: random."
+};
+var rotatingOption = new Option("--rotating")
+{
+ Description = "Use a rotating exit IP instead of a pinned (sticky) session."
+};
+
+var probeProxy = new Command(
+ "probe-proxy",
+ "Launch non-headless Edge through the IPRoyal residential proxy and print the exit IP "
+ + "to confirm auth works and the IP is residential. Reads IPROYAL_USERNAME / IPROYAL_PASSWORD.")
+{
+ countryOption,
+ rotatingOption,
+};
+probeProxy.SetAction((parseResult, ct) =>
+ ProbeProxyAsync(
+ parseResult.GetValue(countryOption),
+ parseResult.GetValue(rotatingOption),
+ loggerFactory,
+ ct));
+
+var defIndexOption = new Option("--def-index")
+{
+ Description = "CSFloat weapon def_index (e.g. AK-47=7, M4A4=16)."
+};
+var paintIndexOption = new Option("--paint-index")
+{
+ Description = "CSFloat paint_index for a specific skin (e.g. M4A4 | Cyber Security=985)."
+};
+var urlOption = new Option("--url")
+{
+ Description = "Full CSFloat URL to open. Overrides --def-index/--paint-index when set."
+};
+var loadImagesOption = new Option("--load-images")
+{
+ Description = "Load images (uses more bandwidth). Default off to conserve the metered plan."
+};
+var diagnoseOption = new Option("--diagnose")
+{
+ Description = "Log every CSFloat-domain response (url + status + type) to reveal where a "
+ + "Steam-login wall appears, not just /api/ JSON."
+};
+var outOption = new Option("--out")
+{
+ Description = "Directory to write captured JSON to.",
+ DefaultValueFactory = _ => "captures",
+};
+
+var captureCsfloat = new Command(
+ "capture-csfloat",
+ "Open a CSFloat search page through the residential proxy and dump every CSFloat /api/ "
+ + "JSON response to disk while you browse (open a listing → 'Latest Sales'). "
+ + "Reads IPROYAL_USERNAME / IPROYAL_PASSWORD.")
+{
+ defIndexOption,
+ paintIndexOption,
+ urlOption,
+ countryOption,
+ loadImagesOption,
+ diagnoseOption,
+ outOption,
+};
+captureCsfloat.SetAction((parseResult, ct) =>
+ CaptureCsfloatAsync(
+ parseResult.GetValue(defIndexOption),
+ parseResult.GetValue(paintIndexOption),
+ parseResult.GetValue(urlOption),
+ parseResult.GetValue(countryOption),
+ parseResult.GetValue(loadImagesOption),
+ parseResult.GetValue(diagnoseOption),
+ parseResult.GetValue(outOption)!,
+ loggerFactory,
+ ct));
+
+var sortByOption = new Option("--sort-by")
+{
+ Description = "Listing sort order: lowest_price, highest_price, most_recent, "
+ + "lowest_float, highest_float, best_deal, etc.",
+ DefaultValueFactory = _ => "lowest_price",
+};
+var maxOption = new Option("--max")
+{
+ Description = "Maximum number of listings to fetch (paged 50 at a time).",
+ DefaultValueFactory = _ => 50,
+};
+var dumpOption = new Option("--dump")
+{
+ Description = "Optional file path to write the fetched listings as JSON."
+};
+
+var fetchListings = new Command(
+ "fetch-listings",
+ "Fetch active CSFloat listings for one skin via the official API and print them. "
+ + "Reads CSFLOAT_API_KEY. Fetch-and-print only — nothing is written to the database.")
+{
+ defIndexOption,
+ paintIndexOption,
+ sortByOption,
+ maxOption,
+ dumpOption,
+};
+fetchListings.SetAction((parseResult, ct) =>
+ FetchListingsAsync(
+ parseResult.GetValue(defIndexOption),
+ parseResult.GetValue(paintIndexOption),
+ parseResult.GetValue(sortByOption)!,
+ parseResult.GetValue(maxOption),
+ parseResult.GetValue(dumpOption),
+ loggerFactory,
+ ct));
+
+var maxRequestsOption = new Option("--max-requests")
+{
+ Description = "Hard cap on API pages this run (rate-limit budget; 200/window).",
+ DefaultValueFactory = _ => 4,
+};
+var maxIngestOption = new Option("--max-listings")
+{
+ Description = "Hard cap on listings ingested this run.",
+ DefaultValueFactory = _ => 200,
+};
+var fullOption = new Option("--full")
+{
+ Description = "Cold full pass: keep paging past already-seen listings (default is "
+ + "incremental — stop once caught up)."
+};
+
+var sweepListings = new Command(
+ "sweep-listings",
+ "Global incremental sweep of active CSFloat listings into the database. Pages most_recent, "
+ + "upserts by listing id, paces off rate-limit headers. Reads CSFLOAT_API_KEY.")
+{
+ maxRequestsOption,
+ maxIngestOption,
+ fullOption,
+};
+sweepListings.SetAction((parseResult, ct) =>
+ SweepListingsAsync(
+ parseResult.GetValue(maxRequestsOption),
+ parseResult.GetValue(maxIngestOption),
+ parseResult.GetValue(fullOption),
+ loggerFactory,
+ ct));
+
+var catalogMaxRequestsOption = new Option("--max-requests")
+{
+ Description = "Hard cap on API pages across the whole run (rate-limit budget; 200/window).",
+ DefaultValueFactory = _ => 50,
+};
+var perSkinCapOption = new Option("--max-per-skin")
+{
+ Description = "Safety cap on listings fetched per skin before moving on.",
+ DefaultValueFactory = _ => 500,
+};
+
+var sweepCatalog = new Command(
+ "sweep-catalog",
+ "Catalogue-driven sweep: query each catalogue skin's listings by def_index+paint_index so "
+ + "only weapons are fetched (no stickers/cases/agents). Per-skin Removed-tracking. "
+ + "Reads CSFLOAT_API_KEY.")
+{
+ catalogMaxRequestsOption,
+ perSkinCapOption,
+};
+sweepCatalog.SetAction((parseResult, ct) =>
+ SweepCatalogAsync(
+ parseResult.GetValue(catalogMaxRequestsOption),
+ parseResult.GetValue(perSkinCapOption),
+ loggerFactory,
+ ct));
+
var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker tools.")
{
syncSkins,
+ probeProxy,
+ captureCsfloat,
+ fetchListings,
+ sweepListings,
+ sweepCatalog,
};
return await root.Parse(args).InvokeAsync();
+// Acquire an IPRoyal residential lease, drive a real (non-headless) Edge browser
+// through it, and report the exit IP. This is the proxy/Selenium spike: it proves
+// authenticated residential routing end-to-end for a few KB before any CSFloat
+// scraping spends real bandwidth.
+static async Task ProbeProxyAsync(
+ string? country, bool rotating, ILoggerFactory loggerFactory, CancellationToken ct)
+{
+ var username = Environment.GetEnvironmentVariable("IPROYAL_USERNAME");
+ var password = Environment.GetEnvironmentVariable("IPROYAL_PASSWORD");
+ if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
+ {
+ Console.Error.WriteLine(
+ "Set IPROYAL_USERNAME and IPROYAL_PASSWORD environment variables first.");
+ return 1;
+ }
+
+ var provider = new IpRoyalProxyProvider(username, password);
+ var factory = new BrowserDriverFactory(loggerFactory.CreateLogger());
+ var probe = new ProxyProbe(provider, factory, loggerFactory.CreateLogger());
+
+ try
+ {
+ var info = await probe.RunAsync(new ProxyRequest(Country: country, Sticky: !rotating));
+ Console.WriteLine();
+ Console.WriteLine($" Exit IP : {info.Ip}");
+ Console.WriteLine($" Location: {info.City}, {info.Region}, {info.Country}");
+ Console.WriteLine($" Org/ASN : {info.Org}");
+ Console.WriteLine($" Hostname: {info.Hostname ?? "—"}");
+ Console.WriteLine();
+ Console.WriteLine(
+ "Check Org/ASN: a consumer ISP = residential; a hosting provider = datacenter.");
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Proxy probe failed: {ex.Message}");
+ return 1;
+ }
+}
+
+// Phase B: open a CSFloat search page through the residential proxy and dump
+// every CSFloat /api/ JSON response to disk while the operator browses. This is
+// how we discover the real endpoint/field shapes (active listings + Latest
+// Sales) before designing tables or automating navigation.
+static async Task CaptureCsfloatAsync(
+ int? defIndex, int? paintIndex, string? url, string? country,
+ bool loadImages, bool diagnose, string outDir, ILoggerFactory loggerFactory, CancellationToken ct)
+{
+ var username = Environment.GetEnvironmentVariable("IPROYAL_USERNAME");
+ var password = Environment.GetEnvironmentVariable("IPROYAL_PASSWORD");
+ if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
+ {
+ Console.Error.WriteLine(
+ "Set IPROYAL_USERNAME and IPROYAL_PASSWORD environment variables first.");
+ return 1;
+ }
+
+ var targetUrl = BuildCsfloatUrl(url, defIndex, paintIndex);
+ var provider = new IpRoyalProxyProvider(username, password);
+ var factory = new BrowserDriverFactory(loggerFactory.CreateLogger());
+ var capture = new CsFloatCaptureService(
+ provider, factory, loggerFactory.CreateLogger());
+
+ Console.WriteLine($"Opening {targetUrl}");
+ Console.WriteLine(
+ "When the page loads: click a listing, then the 'Latest Sales' tab. "
+ + "Capturing all CSFloat /api/ responses.");
+ Console.WriteLine("Press Enter here when you're done to close the browser.");
+
+ try
+ {
+ // Block until the operator presses Enter; the browser stays open and
+ // capturing the whole time. ReadLine is sync, so push it off-thread.
+ var count = await capture.RunAsync(
+ targetUrl,
+ outDir,
+ new ProxyRequest(Country: country, Sticky: true),
+ loadImages,
+ diagnose,
+ () => Task.Run(() => Console.ReadLine(), ct));
+
+ var full = Path.GetFullPath(outDir);
+ Console.WriteLine();
+ Console.WriteLine($"Captured {count} response(s) to {full}");
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"CSFloat capture failed: {ex.Message}");
+ return 1;
+ }
+}
+
+// Prefer an explicit --url; otherwise build a search URL from the indexes,
+// defaulting to the M4A4 | Cyber Security example so the command runs as-is.
+static string BuildCsfloatUrl(string? url, int? defIndex, int? paintIndex)
+{
+ if (!string.IsNullOrWhiteSpace(url))
+ return url;
+
+ var def = defIndex ?? 16;
+ var paint = paintIndex ?? 985;
+ return $"https://csfloat.com/search?def_index={def}&paint_index={paint}";
+}
+
+// Fetch active listings for one skin via CSFloat's official API and print them.
+// Fetch-and-print only — no DB — so we can verify the real field shapes against a
+// live key before designing the Listing schema. Defaults to the M4A4 | Cyber
+// Security sample so it runs with no args.
+static async Task FetchListingsAsync(
+ int? defIndex, int? paintIndex, string sortBy, int max, string? dumpPath,
+ ILoggerFactory loggerFactory, CancellationToken ct)
+{
+ var apiKey = Environment.GetEnvironmentVariable("CSFLOAT_API_KEY");
+ if (string.IsNullOrWhiteSpace(apiKey))
+ {
+ Console.Error.WriteLine("Set the CSFLOAT_API_KEY environment variable first.");
+ return 1;
+ }
+
+ var def = defIndex ?? 16;
+ var paint = paintIndex ?? 985;
+
+ using var http = CreateHttpClient();
+ var client = new CsFloatListingsClient(
+ http, apiKey, loggerFactory.CreateLogger());
+
+ try
+ {
+ Console.WriteLine(
+ $"Fetching up to {max} active listings for def_index={def}, paint_index={paint} "
+ + $"(sort: {sortBy})…");
+ var listings = await client.GetListingsAsync(def, paint, sortBy, max, ct: ct);
+
+ Console.WriteLine();
+ Console.WriteLine(client.LastRateLimit.ToString());
+ Console.WriteLine();
+ if (listings.Count == 0)
+ {
+ Console.WriteLine("No active listings found.");
+ return 0;
+ }
+
+ Console.WriteLine($"{listings.Count} listing(s):");
+ Console.WriteLine($" {"Price",10} {"Float",-10} {"Seed",-6} {"Wear",-16} {"Name"}");
+ foreach (var l in listings)
+ {
+ var st = (l.IsStatTrak ? " ST" : "") + (l.IsSouvenir ? " SV" : "")
+ + (l.StickerCount > 0 ? $" +{l.StickerCount}stk" : "");
+ Console.WriteLine(
+ $" {l.Price,10:C} {l.FloatValue,-10:0.000000} {l.PaintSeed,-6} "
+ + $"{l.WearName,-16} {l.MarketHashName}{st}");
+ }
+
+ if (!string.IsNullOrWhiteSpace(dumpPath))
+ {
+ var json = System.Text.Json.JsonSerializer.Serialize(
+ listings, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
+ await File.WriteAllTextAsync(dumpPath, json, ct);
+ Console.WriteLine();
+ Console.WriteLine($"Wrote {listings.Count} listing(s) to {Path.GetFullPath(dumpPath)}");
+ }
+
+ return 0;
+ }
+ catch (CsFloatApiException ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ Console.Error.WriteLine(client.LastRateLimit.ToString());
+ return 1;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Fetch failed: {ex.Message}");
+ return 1;
+ }
+}
+
+// Global incremental sweep of active CSFloat listings into the database. Paces
+// off rate-limit headers and only marks listings Removed on a complete pass.
+static async Task SweepListingsAsync(
+ int maxRequests, int maxListings, bool full, ILoggerFactory loggerFactory, CancellationToken ct)
+{
+ var apiKey = Environment.GetEnvironmentVariable("CSFLOAT_API_KEY");
+ if (string.IsNullOrWhiteSpace(apiKey))
+ {
+ Console.Error.WriteLine("Set the CSFLOAT_API_KEY environment variable first.");
+ return 1;
+ }
+
+ using var http = CreateHttpClient();
+ var client = new CsFloatListingsClient(
+ http, apiKey, loggerFactory.CreateLogger());
+
+ using var db = new SkinTrackerDbContextFactory().CreateDbContext([]);
+ var service = new ListingSweepService(
+ db, client, loggerFactory.CreateLogger());
+
+ try
+ {
+ Console.WriteLine(
+ $"Sweeping listings ({(full ? "full cold pass" : "incremental")}; "
+ + $"max {maxRequests} requests, {maxListings} listings)…");
+
+ var r = await service.SweepAsync(
+ maxRequests: maxRequests,
+ maxListings: maxListings,
+ incremental: !full,
+ ct: ct);
+
+ Console.WriteLine();
+ Console.WriteLine($"Sweep complete ({r.StoppedReason}):");
+ Console.WriteLine($" Pages fetched : {r.Pages}");
+ Console.WriteLine($" Listings seen : {r.Seen}");
+ Console.WriteLine($" Inserted : {r.Inserted}");
+ Console.WriteLine($" Updated : {r.Updated}");
+ Console.WriteLine($" Removed : {r.Removed}");
+ Console.WriteLine($" Catalog-linked: {r.Linked}");
+ Console.WriteLine();
+ Console.WriteLine(client.LastRateLimit.ToString());
+ return 0;
+ }
+ catch (CsFloatApiException ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ Console.Error.WriteLine(client.LastRateLimit.ToString());
+ return 1;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Sweep failed: {ex.Message}");
+ return 1;
+ }
+}
+
+// Catalogue-driven sweep: query each catalogue skin's listings by def/paint so
+// only weapons are fetched (no stickers/cases/agents) and Removed-tracking is
+// accurate per skin. Writes to the database.
+static async Task SweepCatalogAsync(
+ int maxRequests, int maxPerSkin, ILoggerFactory loggerFactory, CancellationToken ct)
+{
+ var apiKey = Environment.GetEnvironmentVariable("CSFLOAT_API_KEY");
+ if (string.IsNullOrWhiteSpace(apiKey))
+ {
+ Console.Error.WriteLine("Set the CSFLOAT_API_KEY environment variable first.");
+ return 1;
+ }
+
+ using var http = CreateHttpClient();
+ var client = new CsFloatListingsClient(
+ http, apiKey, loggerFactory.CreateLogger());
+
+ using var db = new SkinTrackerDbContextFactory().CreateDbContext([]);
+ var service = new ListingSweepService(
+ db, client, loggerFactory.CreateLogger());
+
+ try
+ {
+ Console.WriteLine(
+ $"Catalogue sweep (weapons only; max {maxRequests} requests, {maxPerSkin}/skin)…");
+
+ var r = await service.SweepCatalogAsync(
+ maxRequests: maxRequests, maxListingsPerSkin: maxPerSkin, ct: ct);
+
+ Console.WriteLine();
+ Console.WriteLine($"Catalogue sweep complete ({r.StoppedReason}):");
+ Console.WriteLine($" Skins covered : {r.SkinsCovered}");
+ Console.WriteLine($" Skins skipped : {r.SkinsSkipped}");
+ Console.WriteLine($" Pages fetched : {r.Pages}");
+ Console.WriteLine($" Listings seen : {r.Seen}");
+ Console.WriteLine($" Inserted : {r.Inserted}");
+ Console.WriteLine($" Updated : {r.Updated}");
+ Console.WriteLine($" Removed : {r.Removed}");
+ Console.WriteLine();
+ Console.WriteLine(client.LastRateLimit.ToString());
+ return 0;
+ }
+ catch (CsFloatApiException ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ Console.Error.WriteLine(client.LastRateLimit.ToString());
+ return 1;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Catalogue sweep failed: {ex.Message}");
+ return 1;
+ }
+}
+
// Load the CS2 skin catalogue from the CSGO-API dataset and upsert it. Weapons
// and collections are derived from the skins themselves. Throttled to once a
// month unless --force; --dry-run loads and prints without a DB.
@@ -73,8 +545,9 @@ static async Task SyncSkinsAsync(
var tags = (s.StatTrakAvailable ? " ST" : "") + (s.SouvenirAvailable ? " SV" : "");
var range = s.FloatMin is not null ? $"{s.FloatMin:0.00}-{s.FloatMax:0.00}" : "—";
var sources = s.Sources.Count > 0 ? string.Join(", ", s.Sources.Select(x => x.Name)) : "—";
+ var idx = $"{s.DefIndex?.ToString() ?? "—"}/{s.PaintIndex?.ToString() ?? "—"}";
Console.WriteLine(
- $" {s.WeaponName,-16} {s.Name,-24} {s.Rarity,-14} {range,-10} {sources}{tags}");
+ $" {idx,-10} {s.WeaponName,-16} {s.Name,-24} {s.Rarity,-14} {range,-10} {sources}{tags}");
}
return 0;
}
diff --git a/BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs b/BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs
index ca110be..4c4847f 100644
--- a/BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs
+++ b/BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs
@@ -161,6 +161,8 @@ public sealed class SkinSyncService
}
Set(() => skin.Name, v => skin.Name = v, s.Name);
+ Set(() => skin.DefIndex, v => skin.DefIndex = v, s.DefIndex);
+ Set(() => skin.PaintIndex, v => skin.PaintIndex = v, s.PaintIndex);
Set(() => skin.Rarity, v => skin.Rarity = v, s.Rarity);
Set(() => skin.Description, v => skin.Description = v, s.Description);
Set(() => skin.ImageUrl, v => skin.ImageUrl = v, s.ImageUrl);
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/ListingConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/ListingConfiguration.cs
new file mode 100644
index 0000000..c5b3e8c
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/ListingConfiguration.cs
@@ -0,0 +1,43 @@
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BlueLaminate.EFCore.Configurations;
+
+public class ListingConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder entity)
+ {
+ // CSFloat's listing id is the natural key; the incremental sweep upserts
+ // against it and must never create duplicates.
+ entity.HasIndex(e => e.CsFloatListingId).IsUnique();
+
+ entity.Property(e => e.Price).HasPrecision(18, 2);
+ // Full precision to match SkinInstance for exact fingerprint joins.
+ entity.Property(e => e.FloatValue).HasColumnType("numeric(20,18)");
+
+ // Store the enum as text so the DB is self-describing (matches the
+ // project's readable-data leaning over opaque ints).
+ entity.Property(e => e.Status).HasConversion();
+
+ // The sweep filters/sorts by item identity and by what's still active.
+ entity.HasIndex(e => new { e.DefIndex, e.PaintIndex });
+ entity.HasIndex(e => e.Status);
+
+ // Best-effort catalogue link: a global sweep sees items we may not have,
+ // so the FK is optional and set null if the skin is later removed.
+ entity.HasOne(e => e.Skin)
+ .WithMany()
+ .HasForeignKey(e => e.SkinId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ // Listings roll up to the physical item they represent.
+ entity.HasOne(e => e.SkinInstance)
+ .WithMany(i => i.Listings)
+ .HasForeignKey(e => e.SkinInstanceId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ // Dupe analysis groups a fingerprint's listings by asset id.
+ entity.HasIndex(e => e.AssetId);
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs
index 039fea3..c9e32ad 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs
@@ -21,6 +21,18 @@ public class SkinConfiguration : IEntityTypeConfiguration
// Slug is the natural key the sync upserts against.
entity.HasIndex(e => e.Slug).IsUnique();
+ // Market listings join back to a skin by (def_index, paint_index). Unique
+ // among populated rows; filtered so the many catalogue rows that predate
+ // these columns (null) don't collide. Postgres treats nulls as distinct
+ // anyway, but the filter makes the intent explicit and the index smaller.
+ entity.HasIndex(e => new { e.DefIndex, e.PaintIndex })
+ .IsUnique()
+ .HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
+
+ // The catalogue sweep orders skins by when they were last swept (nulls
+ // first) to resume across capped runs; index that ordering.
+ entity.HasIndex(e => e.ListingsSweptAt);
+
entity.HasOne(e => e.Weapon)
.WithMany(w => w.Skins)
.HasForeignKey(e => e.WeaponId);
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinInstanceConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinInstanceConfiguration.cs
index 3308d6f..e32f798 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinInstanceConfiguration.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinInstanceConfiguration.cs
@@ -8,19 +8,34 @@ public class SkinInstanceConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder entity)
{
- entity.Property(e => e.FloatValue).HasColumnType("numeric(10,9)");
+ // Full precision so exact-match dupe detection isn't defeated by rounding.
+ // CSFloat returns deterministic ~17-digit floats; numeric(20,18) holds them.
+ entity.Property(e => e.FloatValue).HasColumnType("numeric(20,18)");
- // Primary lookup key for trade fingerprinting.
- entity.HasIndex(e => e.FloatValue);
- entity.HasIndex(e => e.PaintSeed);
+ // The fingerprint that identifies a physical item. NOT unique: duped items
+ // legitimately share a fingerprint, and detecting that collision is the
+ // point. Indexed for fast fingerprint resolution during the sweep.
+ entity.HasIndex(e => new
+ {
+ e.SkinId,
+ e.FloatValue,
+ e.PaintSeed,
+ e.StatTrak,
+ e.Souvenir,
+ });
+
+ // Surfacing fresh dupes is a hot query.
+ entity.HasIndex(e => e.SuspectedDupe);
entity.HasOne(e => e.Skin)
.WithMany(s => s.Instances)
.HasForeignKey(e => e.SkinId);
+ // Condition is optional now (derived from float later); set null on delete
+ // rather than restrict so condition rows can change without blocking.
entity.HasOne(e => e.Condition)
.WithMany(c => c.Instances)
.HasForeignKey(e => e.ConditionId)
- .OnDelete(DeleteBehavior.Restrict);
+ .OnDelete(DeleteBehavior.SetNull);
}
}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs b/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
index 5ca1af2..eeda481 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
@@ -29,6 +29,7 @@ public class SkinTrackerDbContext : DbContext
public DbSet Trades => Set();
public DbSet TradeItems => Set();
public DbSet PriceHistories => Set();
+ public DbSet Listings => Set();
/// The PostgreSQL schema that owns all of this context's tables.
public const string Schema = "skintracker";
@@ -48,5 +49,6 @@ public class SkinTrackerDbContext : DbContext
modelBuilder.ApplyConfiguration(new TradeConfiguration());
modelBuilder.ApplyConfiguration(new TradeItemConfiguration());
modelBuilder.ApplyConfiguration(new PriceHistoryConfiguration());
+ modelBuilder.ApplyConfiguration(new ListingConfiguration());
}
}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs
new file mode 100644
index 0000000..7265219
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs
@@ -0,0 +1,86 @@
+namespace BlueLaminate.EFCore.Entities;
+
+/// Lifecycle of a CSFloat listing as observed across sweeps.
+public enum ListingStatus
+{
+ /// Seen in the most recent sweep that covered it.
+ Active = 0,
+
+ ///
+ /// Previously seen, then absent from a sweep that should have covered it —
+ /// i.e. sold or delisted. The disappearance is the signal; we can't tell sold
+ /// from delisted with certainty, but bounds when.
+ ///
+ Removed = 1,
+}
+
+///
+/// One active-market listing observed on CSFloat via the official
+/// GET /api/v1/listings endpoint. Rows are keyed by CSFloat's own listing
+/// id and soft-tracked across sweeps: /
+/// bound the observation window and flips to
+/// when a once-seen listing stops appearing,
+/// which approximates a sale/delisting.
+///
+/// A global sweep returns items that may not be in our catalogue, so
+/// is a best-effort nullable link (resolved by
+/// def_index + paint_index); the listing stands on its own without it.
+///
+public class Listing
+{
+ public int Id { get; set; }
+
+ /// CSFloat's listing id (a snowflake string). Natural key for dedup.
+ public string CsFloatListingId { get; set; } = null!;
+
+ /// "buy_now" or "auction".
+ public string Type { get; set; } = null!;
+
+ /// Asking price in USD.
+ public decimal Price { get; set; }
+
+ /// When CSFloat says the listing was created.
+ public DateTimeOffset ListedAt { get; set; }
+
+ // Item identity. Stored directly (not only via the Skin link) so listings for
+ // items outside our catalogue are still fully described.
+ public int DefIndex { get; set; }
+ public int PaintIndex { get; set; }
+ public string MarketHashName { get; set; } = null!;
+ public string? WearName { get; set; }
+ public decimal FloatValue { get; set; }
+ public int PaintSeed { get; set; }
+ public bool IsStatTrak { get; set; }
+ public bool IsSouvenir { get; set; }
+ public int StickerCount { get; set; }
+
+ public string? SellerSteamId { get; set; }
+ public string? InspectLink { get; set; }
+
+ ///
+ /// Steam asset id of the listed copy. Changes on trade, so not a stable
+ /// identity — but the discriminator that distinguishes duped copies which
+ /// otherwise share an identical fingerprint.
+ ///
+ public string? AssetId { get; set; }
+
+ /// Best-effort catalogue link, resolved by def_index + paint_index. Null if unmatched.
+ public int? SkinId { get; set; }
+ public Skin? Skin { get; set; }
+
+ ///
+ /// The physical item (by fingerprint) this listing is for. Many listings over
+ /// time roll up to one instance, forming its market-movement history. Nullable
+ /// because catalogue-less items can't be fingerprinted to a known skin.
+ ///
+ public int? SkinInstanceId { get; set; }
+ public SkinInstance? SkinInstance { get; set; }
+
+ // Soft-tracking across sweeps.
+ public DateTimeOffset FirstSeenAt { get; set; }
+ public DateTimeOffset LastSeenAt { get; set; }
+ public ListingStatus Status { get; set; }
+
+ /// When the listing was marked Removed (absent from a sweep). Null while Active.
+ public DateTimeOffset? RemovedAt { get; set; }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs
index f1049c5..e7fe387 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs
@@ -9,6 +9,19 @@ public class Skin
/// Stable id from the CSGO-API catalogue, e.g. "skin-e757fd7191f9". The natural key.
public string Slug { get; set; } = null!;
+ // CSFloat/CS item indexes, sourced from the static catalogue (weapon.weapon_id
+ // and paint_index). Together they identify a skin on CSFloat and let market
+ // listings join back to this catalogue row. Nullable until a sync populates
+ // them, since older catalogue rows predate these columns.
+ 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; }
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinInstance.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinInstance.cs
index 0a4492c..92b0491 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinInstance.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinInstance.cs
@@ -1,20 +1,50 @@
namespace BlueLaminate.EFCore.Entities;
+///
+/// One physical CS2 item, identified by its fingerprint
+/// (Skin + FloatValue + PaintSeed + StatTrak + Souvenir) rather than its Steam
+/// asset id, which changes on every trade. Decoupled from Steam inventories on
+/// purpose: an instance exists from market observation alone, and the optional
+/// bridge ties it to a SteamUser only once we
+/// crawl inventories.
+///
+/// Duping note: a duplicated item is a byte-for-byte copy with an identical
+/// fingerprint, so a fingerprint is NOT guaranteed unique to one physical item.
+/// We treat the fingerprint as the item, and flag
+/// when the same fingerprint is seen live under two or more different asset ids
+/// at once (see the sweep's dupe detection).
+///
public class SkinInstance
{
public int Id { get; set; }
public int SkinId { get; set; }
public Skin Skin { get; set; } = null!;
- public int ConditionId { get; set; }
- public SkinCondition Condition { get; set; } = null!;
- // FloatValue + PaintSeed form a stable fingerprint across trades; the Steam
- // asset_id changes on every trade but these do not.
+ // Nullable: market observation gives a float but not a derived wear bucket.
+ // Condition can be backfilled later from the float without blocking ingest.
+ public int? ConditionId { get; set; }
+ 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.
public decimal FloatValue { get; set; }
public string PaintSeed { get; set; } = null!;
public bool StatTrak { get; set; }
public bool Souvenir { get; set; }
public DateTimeOffset FirstSeenAt { get; set; }
+ public DateTimeOffset LastSeenAt { get; set; }
+
+ ///
+ /// True once this fingerprint was observed live under 2+ distinct asset ids
+ /// simultaneously — the signature of duplication.
+ ///
+ public bool SuspectedDupe { get; set; }
+
+ /// When the dupe condition was first detected. Null until then.
+ public DateTimeOffset? DupeFirstSeenAt { get; set; }
+
+ /// Every market listing observed for this physical item over time.
+ public ICollection Listings { get; set; } = new List();
public ICollection InventoryItems { get; set; } = new List();
}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530014903_AddListingsAndSkinIndexes.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530014903_AddListingsAndSkinIndexes.Designer.cs
new file mode 100644
index 0000000..4b475f1
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530014903_AddListingsAndSkinIndexes.Designer.cs
@@ -0,0 +1,829 @@
+//
+using System;
+using BlueLaminate.EFCore.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace BlueLaminate.EFCore.Migrations
+{
+ [DbContext(typeof(SkinTrackerDbContext))]
+ [Migration("20260530014903_AddListingsAndSkinIndexes")]
+ partial class AddListingsAndSkinIndexes
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("skintracker")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_collections");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_collections_slug");
+
+ b.ToTable("collections", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AcquiredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("acquired_at");
+
+ b.Property("AssetId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("SkinInstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_instance_id");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_inventory_items");
+
+ b.HasIndex("AssetId")
+ .HasDatabaseName("ix_inventory_items_asset_id");
+
+ b.HasIndex("SkinInstanceId")
+ .HasDatabaseName("ix_inventory_items_skin_instance_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_inventory_items_user_id");
+
+ b.ToTable("inventory_items", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CsFloatListingId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("cs_float_listing_id");
+
+ b.Property("DefIndex")
+ .HasColumnType("integer")
+ .HasColumnName("def_index");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_value");
+
+ b.Property("InspectLink")
+ .HasColumnType("text")
+ .HasColumnName("inspect_link");
+
+ b.Property("IsSouvenir")
+ .HasColumnType("boolean")
+ .HasColumnName("is_souvenir");
+
+ b.Property("IsStatTrak")
+ .HasColumnType("boolean")
+ .HasColumnName("is_stat_trak");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("ListedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("listed_at");
+
+ b.Property("MarketHashName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("market_hash_name");
+
+ b.Property("PaintIndex")
+ .HasColumnType("integer")
+ .HasColumnName("paint_index");
+
+ b.Property("PaintSeed")
+ .HasColumnType("integer")
+ .HasColumnName("paint_seed");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("RemovedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("removed_at");
+
+ b.Property("SellerSteamId")
+ .HasColumnType("text")
+ .HasColumnName("seller_steam_id");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("status");
+
+ b.Property("StickerCount")
+ .HasColumnType("integer")
+ .HasColumnName("sticker_count");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.Property("WearName")
+ .HasColumnType("text")
+ .HasColumnName("wear_name");
+
+ b.HasKey("Id")
+ .HasName("pk_listings");
+
+ b.HasIndex("CsFloatListingId")
+ .IsUnique()
+ .HasDatabaseName("ix_listings_cs_float_listing_id");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_listings_skin_id");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_listings_status");
+
+ b.HasIndex("DefIndex", "PaintIndex")
+ .HasDatabaseName("ix_listings_def_index_paint_index");
+
+ b.ToTable("listings", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_price_histories");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_price_histories_condition_id");
+
+ b.HasIndex("SkinId", "ConditionId", "RecordedAt")
+ .HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
+
+ b.ToTable("price_histories", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ItemCount")
+ .HasColumnType("integer")
+ .HasColumnName("item_count");
+
+ b.Property("RanAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("ran_at");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_scrape_runs");
+
+ b.HasIndex("Source", "RanAt")
+ .HasDatabaseName("ix_scrape_runs_source_ran_at");
+
+ b.ToTable("scrape_runs", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DefIndex")
+ .HasColumnType("integer")
+ .HasColumnName("def_index");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("FloatMax")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_max");
+
+ b.Property("FloatMin")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_min");
+
+ b.Property("ImageUrl")
+ .HasColumnType("text")
+ .HasColumnName("image_url");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("PaintIndex")
+ .HasColumnType("integer")
+ .HasColumnName("paint_index");
+
+ b.Property("Rarity")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("rarity");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("SouvenirAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir_available");
+
+ b.Property("StatTrakAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak_available");
+
+ b.Property("TrueFloat")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("boolean")
+ .HasColumnName("true_float")
+ .HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
+
+ b.Property("WeaponId")
+ .HasColumnType("integer")
+ .HasColumnName("weapon_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skins");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_slug");
+
+ b.HasIndex("TrueFloat")
+ .HasDatabaseName("ix_skins_true_float");
+
+ b.HasIndex("WeaponId")
+ .HasDatabaseName("ix_skins_weapon_id");
+
+ b.HasIndex("DefIndex", "PaintIndex")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_def_index_paint_index")
+ .HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
+
+ b.ToTable("skins", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Condition")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("condition");
+
+ b.Property("MaxFloat")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("max_float");
+
+ b.Property("MinFloat")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("min_float");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_conditions");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_skin_conditions_skin_id");
+
+ b.ToTable("skin_conditions", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_value");
+
+ b.Property("PaintSeed")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("paint_seed");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Souvenir")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir");
+
+ b.Property("StatTrak")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_instances");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_skin_instances_condition_id");
+
+ b.HasIndex("FloatValue")
+ .HasDatabaseName("ix_skin_instances_float_value");
+
+ b.HasIndex("PaintSeed")
+ .HasDatabaseName("ix_skin_instances_paint_seed");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_skin_instances_skin_id");
+
+ b.ToTable("skin_instances", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DisplayName")
+ .HasColumnType("text")
+ .HasColumnName("display_name");
+
+ b.Property("LastSyncedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_synced_at");
+
+ b.Property("SteamId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("steam_id");
+
+ b.HasKey("Id")
+ .HasName("pk_steam_users");
+
+ b.HasIndex("SteamId")
+ .IsUnique()
+ .HasDatabaseName("ix_steam_users_steam_id");
+
+ b.ToTable("steam_users", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FromUserId")
+ .HasColumnType("integer")
+ .HasColumnName("from_user_id");
+
+ b.Property("SteamTradeId")
+ .HasColumnType("text")
+ .HasColumnName("steam_trade_id");
+
+ b.Property("ToUserId")
+ .HasColumnType("integer")
+ .HasColumnName("to_user_id");
+
+ b.Property("TradedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("traded_at");
+
+ b.HasKey("Id")
+ .HasName("pk_trades");
+
+ b.HasIndex("FromUserId")
+ .HasDatabaseName("ix_trades_from_user_id");
+
+ b.HasIndex("ToUserId")
+ .HasDatabaseName("ix_trades_to_user_id");
+
+ b.ToTable("trades", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("InventoryItemId")
+ .HasColumnType("integer")
+ .HasColumnName("inventory_item_id");
+
+ b.Property("TradeId")
+ .HasColumnType("integer")
+ .HasColumnName("trade_id");
+
+ b.HasKey("Id")
+ .HasName("pk_trade_items");
+
+ b.HasIndex("InventoryItemId")
+ .HasDatabaseName("ix_trade_items_inventory_item_id");
+
+ b.HasIndex("TradeId")
+ .HasDatabaseName("ix_trade_items_trade_id");
+
+ b.ToTable("trade_items", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Team")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("team");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_weapons");
+
+ b.HasIndex("Name")
+ .IsUnique()
+ .HasDatabaseName("ix_weapons_name");
+
+ b.ToTable("weapons", "skintracker");
+ });
+
+ modelBuilder.Entity("CollectionSkin", b =>
+ {
+ b.Property("CollectionsId")
+ .HasColumnType("integer")
+ .HasColumnName("collections_id");
+
+ b.Property("SkinsId")
+ .HasColumnType("integer")
+ .HasColumnName("skins_id");
+
+ b.HasKey("CollectionsId", "SkinsId")
+ .HasName("pk_skin_collections");
+
+ b.HasIndex("SkinsId")
+ .HasDatabaseName("ix_skin_collections_skins_id");
+
+ b.ToTable("skin_collections", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
+ .WithMany("InventoryItems")
+ .HasForeignKey("SkinInstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
+ .WithMany("InventoryItems")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_inventory_items_steam_users_user_id");
+
+ b.Navigation("SkinInstance");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
+ .WithMany()
+ .HasForeignKey("SkinId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_listings_skins_skin_id");
+
+ b.Navigation("Skin");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
+ .WithMany("PriceHistories")
+ .HasForeignKey("ConditionId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_price_histories_skin_conditions_condition_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
+ .WithMany("PriceHistories")
+ .HasForeignKey("SkinId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_price_histories_skins_skin_id");
+
+ b.Navigation("Condition");
+
+ b.Navigation("Skin");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
+ .WithMany("Skins")
+ .HasForeignKey("WeaponId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skins_weapons_weapon_id");
+
+ b.Navigation("Weapon");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
+ .WithMany("Conditions")
+ .HasForeignKey("SkinId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skin_conditions_skins_skin_id");
+
+ b.Navigation("Skin");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
+ .WithMany("Instances")
+ .HasForeignKey("ConditionId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
+ .WithMany("Instances")
+ .HasForeignKey("SkinId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skin_instances_skins_skin_id");
+
+ b.Navigation("Condition");
+
+ b.Navigation("Skin");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
+ .WithMany("TradesSent")
+ .HasForeignKey("FromUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_trades_steam_users_from_user_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
+ .WithMany("TradesReceived")
+ .HasForeignKey("ToUserId")
+ .OnDelete(DeleteBehavior.Restrict)
+ .IsRequired()
+ .HasConstraintName("fk_trades_steam_users_to_user_id");
+
+ b.Navigation("FromUser");
+
+ b.Navigation("ToUser");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
+ .WithMany("TradeItems")
+ .HasForeignKey("InventoryItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
+ .WithMany("TradeItems")
+ .HasForeignKey("TradeId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_trade_items_trades_trade_id");
+
+ b.Navigation("InventoryItem");
+
+ b.Navigation("Trade");
+ });
+
+ modelBuilder.Entity("CollectionSkin", b =>
+ {
+ b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
+ .WithMany()
+ .HasForeignKey("CollectionsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skin_collections_collections_collections_id");
+
+ b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
+ .WithMany()
+ .HasForeignKey("SkinsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_skin_collections_skins_skins_id");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.Navigation("TradeItems");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.Navigation("Conditions");
+
+ b.Navigation("Instances");
+
+ b.Navigation("PriceHistories");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.Navigation("Instances");
+
+ b.Navigation("PriceHistories");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
+ {
+ b.Navigation("InventoryItems");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
+ {
+ b.Navigation("InventoryItems");
+
+ b.Navigation("TradesReceived");
+
+ b.Navigation("TradesSent");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
+ {
+ b.Navigation("TradeItems");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
+ {
+ b.Navigation("Skins");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530014903_AddListingsAndSkinIndexes.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530014903_AddListingsAndSkinIndexes.cs
new file mode 100644
index 0000000..1ee288f
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530014903_AddListingsAndSkinIndexes.cs
@@ -0,0 +1,126 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace BlueLaminate.EFCore.Migrations
+{
+ ///
+ public partial class AddListingsAndSkinIndexes : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "def_index",
+ schema: "skintracker",
+ table: "skins",
+ type: "integer",
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "paint_index",
+ schema: "skintracker",
+ table: "skins",
+ type: "integer",
+ nullable: true);
+
+ migrationBuilder.CreateTable(
+ name: "listings",
+ schema: "skintracker",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ cs_float_listing_id = table.Column(type: "text", nullable: false),
+ type = table.Column(type: "text", nullable: false),
+ price = table.Column(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
+ listed_at = table.Column(type: "timestamp with time zone", nullable: false),
+ def_index = table.Column(type: "integer", nullable: false),
+ paint_index = table.Column(type: "integer", nullable: false),
+ market_hash_name = table.Column(type: "text", nullable: false),
+ wear_name = table.Column(type: "text", nullable: true),
+ float_value = table.Column(type: "numeric(10,9)", nullable: false),
+ paint_seed = table.Column(type: "integer", nullable: false),
+ is_stat_trak = table.Column(type: "boolean", nullable: false),
+ is_souvenir = table.Column(type: "boolean", nullable: false),
+ sticker_count = table.Column(type: "integer", nullable: false),
+ seller_steam_id = table.Column(type: "text", nullable: true),
+ inspect_link = table.Column(type: "text", nullable: true),
+ skin_id = table.Column(type: "integer", nullable: true),
+ first_seen_at = table.Column(type: "timestamp with time zone", nullable: false),
+ last_seen_at = table.Column(type: "timestamp with time zone", nullable: false),
+ status = table.Column(type: "text", nullable: false),
+ removed_at = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_listings", x => x.id);
+ table.ForeignKey(
+ name: "fk_listings_skins_skin_id",
+ column: x => x.skin_id,
+ principalSchema: "skintracker",
+ principalTable: "skins",
+ principalColumn: "id",
+ onDelete: ReferentialAction.SetNull);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_skins_def_index_paint_index",
+ schema: "skintracker",
+ table: "skins",
+ columns: new[] { "def_index", "paint_index" },
+ unique: true,
+ filter: "def_index IS NOT NULL AND paint_index IS NOT NULL");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_listings_cs_float_listing_id",
+ schema: "skintracker",
+ table: "listings",
+ column: "cs_float_listing_id",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_listings_def_index_paint_index",
+ schema: "skintracker",
+ table: "listings",
+ columns: new[] { "def_index", "paint_index" });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_listings_skin_id",
+ schema: "skintracker",
+ table: "listings",
+ column: "skin_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_listings_status",
+ schema: "skintracker",
+ table: "listings",
+ column: "status");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "listings",
+ schema: "skintracker");
+
+ migrationBuilder.DropIndex(
+ name: "ix_skins_def_index_paint_index",
+ schema: "skintracker",
+ table: "skins");
+
+ migrationBuilder.DropColumn(
+ name: "def_index",
+ schema: "skintracker",
+ table: "skins");
+
+ migrationBuilder.DropColumn(
+ name: "paint_index",
+ schema: "skintracker",
+ table: "skins");
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530023217_AddSkinListingsSweptAt.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530023217_AddSkinListingsSweptAt.Designer.cs
new file mode 100644
index 0000000..ac78c4b
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530023217_AddSkinListingsSweptAt.Designer.cs
@@ -0,0 +1,836 @@
+//
+using System;
+using BlueLaminate.EFCore.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace BlueLaminate.EFCore.Migrations
+{
+ [DbContext(typeof(SkinTrackerDbContext))]
+ [Migration("20260530023217_AddSkinListingsSweptAt")]
+ partial class AddSkinListingsSweptAt
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("skintracker")
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Collection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("pk_collections");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_collections_slug");
+
+ b.ToTable("collections", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("AcquiredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("acquired_at");
+
+ b.Property("AssetId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("SkinInstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_instance_id");
+
+ b.Property("UserId")
+ .HasColumnType("integer")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_inventory_items");
+
+ b.HasIndex("AssetId")
+ .HasDatabaseName("ix_inventory_items_asset_id");
+
+ b.HasIndex("SkinInstanceId")
+ .HasDatabaseName("ix_inventory_items_skin_instance_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_inventory_items_user_id");
+
+ b.ToTable("inventory_items", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CsFloatListingId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("cs_float_listing_id");
+
+ b.Property("DefIndex")
+ .HasColumnType("integer")
+ .HasColumnName("def_index");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_value");
+
+ b.Property("InspectLink")
+ .HasColumnType("text")
+ .HasColumnName("inspect_link");
+
+ b.Property("IsSouvenir")
+ .HasColumnType("boolean")
+ .HasColumnName("is_souvenir");
+
+ b.Property("IsStatTrak")
+ .HasColumnType("boolean")
+ .HasColumnName("is_stat_trak");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ b.Property("ListedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("listed_at");
+
+ b.Property("MarketHashName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("market_hash_name");
+
+ b.Property("PaintIndex")
+ .HasColumnType("integer")
+ .HasColumnName("paint_index");
+
+ b.Property("PaintSeed")
+ .HasColumnType("integer")
+ .HasColumnName("paint_seed");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("RemovedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("removed_at");
+
+ b.Property("SellerSteamId")
+ .HasColumnType("text")
+ .HasColumnName("seller_steam_id");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("status");
+
+ b.Property("StickerCount")
+ .HasColumnType("integer")
+ .HasColumnName("sticker_count");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("type");
+
+ b.Property("WearName")
+ .HasColumnType("text")
+ .HasColumnName("wear_name");
+
+ b.HasKey("Id")
+ .HasName("pk_listings");
+
+ b.HasIndex("CsFloatListingId")
+ .IsUnique()
+ .HasDatabaseName("ix_listings_cs_float_listing_id");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_listings_skin_id");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_listings_status");
+
+ b.HasIndex("DefIndex", "PaintIndex")
+ .HasDatabaseName("ix_listings_def_index_paint_index");
+
+ b.ToTable("listings", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_price_histories");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_price_histories_condition_id");
+
+ b.HasIndex("SkinId", "ConditionId", "RecordedAt")
+ .HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
+
+ b.ToTable("price_histories", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ItemCount")
+ .HasColumnType("integer")
+ .HasColumnName("item_count");
+
+ b.Property("RanAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("ran_at");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_scrape_runs");
+
+ b.HasIndex("Source", "RanAt")
+ .HasDatabaseName("ix_scrape_runs_source_ran_at");
+
+ b.ToTable("scrape_runs", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DefIndex")
+ .HasColumnType("integer")
+ .HasColumnName("def_index");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("FloatMax")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_max");
+
+ b.Property("FloatMin")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_min");
+
+ b.Property("ImageUrl")
+ .HasColumnType("text")
+ .HasColumnName("image_url");
+
+ b.Property("ListingsSweptAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("listings_swept_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("PaintIndex")
+ .HasColumnType("integer")
+ .HasColumnName("paint_index");
+
+ b.Property("Rarity")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("rarity");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("SouvenirAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir_available");
+
+ b.Property("StatTrakAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak_available");
+
+ b.Property("TrueFloat")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("boolean")
+ .HasColumnName("true_float")
+ .HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
+
+ b.Property("WeaponId")
+ .HasColumnType("integer")
+ .HasColumnName("weapon_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skins");
+
+ b.HasIndex("ListingsSweptAt")
+ .HasDatabaseName("ix_skins_listings_swept_at");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_slug");
+
+ b.HasIndex("TrueFloat")
+ .HasDatabaseName("ix_skins_true_float");
+
+ b.HasIndex("WeaponId")
+ .HasDatabaseName("ix_skins_weapon_id");
+
+ b.HasIndex("DefIndex", "PaintIndex")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_def_index_paint_index")
+ .HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
+
+ b.ToTable("skins", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property