diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..6120ebe
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,22 @@
+# Keep build contexts small/clean (both images use the repo root as context).
+**/bin/
+**/obj/
+**/.vs/
+.git/
+.gitignore
+*.user
+
+# Python worker local artifacts
+worker/.venv/
+worker/__pycache__/
+worker/captures/
+
+# Discovery dumps
+csmoney-probe/
+csmoney-captures/
+
+# Docs/markdown aren't needed in images
+**/*.md
+
+# Secrets: compose reads .env for variable substitution; never bake it into an image
+.env
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ceb7909
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,31 @@
+# EditorConfig — https://editorconfig.org
+root = true
+
+[*.cs]
+
+#### Brace style ####
+# Require braces on all control-flow blocks (if/else/for/foreach/while/...),
+# even single statements. Enforced as a build error so this:
+# if (x == false)
+# return -1;
+# must instead be written:
+# if (x == false)
+# {
+# return -1;
+# }
+csharp_prefer_braces = true
+dotnet_diagnostic.IDE0011.severity = error
+
+#### Explicit constructors ####
+# Prefer explicit constructors over primary constructors; don't suggest the
+# "use primary constructor" refactor.
+csharp_style_prefer_primary_constructors = false
+dotnet_diagnostic.IDE0290.severity = none
+
+#### Logging analyzer ####
+# CA1873: "Avoid potentially expensive logging" — suppressed.
+dotnet_diagnostic.CA1873.severity = none
+
+# EF Core migrations are generated; don't enforce code style on them.
+[**/Migrations/*.cs]
+generated_code = true
diff --git a/.gitignore b/.gitignore
index 9c0a26a..d9d409e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -98,3 +98,9 @@ venv/
env/
*.egg-info/
.pytest_cache/
+
+# cs.money discovery capture dumps (JSON responses)
+csmoney-captures/
+
+# Local compose secrets (DB connection string, tokens)
+.env
diff --git a/BlueLaminate/BlueLaminate.C2/BlueLaminate.C2.csproj b/BlueLaminate/BlueLaminate.C2/BlueLaminate.C2.csproj
new file mode 100644
index 0000000..1d6e54c
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.C2/BlueLaminate.C2.csproj
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
diff --git a/BlueLaminate/BlueLaminate.C2/Contracts.cs b/BlueLaminate/BlueLaminate.C2/Contracts.cs
new file mode 100644
index 0000000..8dd799b
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.C2/Contracts.cs
@@ -0,0 +1,19 @@
+using BlueLaminate.Core.CsMoney;
+
+namespace BlueLaminate.C2;
+
+/// A unit of scrape work handed to a worker: one skin+wear, as a search.
+/// Opaque id the worker echoes back when posting results.
+/// Catalogue skin this job targets.
+/// Wear band (skin_conditions row), or null for a whole skin.
+/// Free-text market search, e.g. "M4A4 Cyber Security ft".
+/// Safety cap on page fetches (60 items each). The worker
+/// paginates by walking the float axis, so a skin+wear needs ceil(listings/60) pages.
+public sealed record ScrapeJobDto(string JobId, int SkinId, int? ConditionId, string Search, int MaxPages);
+
+/// A worker's results for a claimed job: the listings it scraped.
+/// All sell-order items gathered across pages (raw cs.money shape).
+/// How many pages the worker fetched.
+/// Why it stopped. "completed" = full sweep (authoritative);
+/// anything else (fetch-cap / challenged / stuck-float-tie) is partial.
+public sealed record ScrapeResultDto(List Items, int Pages, string? StoppedReason);
diff --git a/BlueLaminate/BlueLaminate.C2/Dockerfile b/BlueLaminate/BlueLaminate.C2/Dockerfile
new file mode 100644
index 0000000..28e070c
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.C2/Dockerfile
@@ -0,0 +1,24 @@
+# Build context is the REPO ROOT (so Central Package Management's Directory.*.props
+# at the root are available). Build with:
+# docker compose build (compose sets the context)
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+
+# Restore against the full solution sources the C2 transitively needs.
+COPY Directory.Build.props Directory.Packages.props ./
+COPY BlueLaminate/ BlueLaminate/
+RUN dotnet restore BlueLaminate/BlueLaminate.C2/BlueLaminate.C2.csproj
+RUN dotnet publish BlueLaminate/BlueLaminate.C2/BlueLaminate.C2.csproj \
+ -c Release -o /app --no-restore
+
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
+# NOTE: deliberately do NOT install libgssapi-krb5-2. Without it Npgsql logs a
+# harmless "cannot load libgssapi_krb5.so.2" line and falls back to password auth;
+# WITH it, a failed/misconfigured connection attempt segfaults during GSS negotiation
+# (observed: container exit 139 / crash-loop). Graceful failure beats the segfault.
+WORKDIR /app
+COPY --from=build /app ./
+# Bind all interfaces inside the container (overrides appsettings' localhost binding).
+ENV ASPNETCORE_URLS=http://+:5080
+EXPOSE 5080
+ENTRYPOINT ["dotnet", "BlueLaminate.C2.dll"]
diff --git a/BlueLaminate/BlueLaminate.C2/JobQueue.cs b/BlueLaminate/BlueLaminate.C2/JobQueue.cs
new file mode 100644
index 0000000..bd8a358
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.C2/JobQueue.cs
@@ -0,0 +1,88 @@
+using System.Collections.Concurrent;
+using BlueLaminate.Core.CsMoney;
+using BlueLaminate.EFCore.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace BlueLaminate.C2;
+
+///
+/// Hands out scrape jobs to workers, one skin+wear at a time, driven directly by the
+/// catalogue's per-band checkpoints (SkinCondition.ListingsSweptAt) rather than
+/// a pre-built queue. Each claim picks the stalest band (never-swept first), leases it
+/// in memory so two workers can't get the same one, and builds a free-text search. On
+/// completion the ingest stamps ListingsSweptAt, so the band drops to the back —
+/// the sweep loops the whole catalogue continuously and resumes cleanly after restarts.
+///
+public sealed class JobQueue
+{
+ // A leased condition can't be re-handed-out until released or the lease expires
+ // (so a crashed worker's band returns to the pool instead of stalling forever).
+ private static readonly TimeSpan LeaseTtl = TimeSpan.FromMinutes(15);
+ private const int CandidateBatch = 100;
+
+ private readonly SemaphoreSlim _gate = new(1, 1);
+ private readonly ConcurrentDictionary _leases = new(); // conditionId -> leasedAt
+ private readonly ConcurrentDictionary _inFlight = new(); // jobId -> mapping
+
+ public async Task ClaimNextAsync(SkinTrackerDbContext db, int maxPages, CancellationToken ct)
+ {
+ await _gate.WaitAsync(ct);
+ try
+ {
+ // Reclaim expired leases first.
+ var cutoff = DateTimeOffset.UtcNow - LeaseTtl;
+ foreach (var (cid, at) in _leases)
+ {
+ if (at < cutoff)
+ {
+ _leases.TryRemove(cid, out _);
+ }
+ }
+
+ // Stalest bands first (never-swept null sorts before any timestamp).
+ var candidates = await db.SkinConditions
+ .OrderBy(c => c.ListingsSweptAt.HasValue)
+ .ThenBy(c => c.ListingsSweptAt)
+ .Select(c => new Candidate(
+ c.Id, c.SkinId, c.Skin.Weapon.Name, c.Skin.Name, c.Condition))
+ .Take(CandidateBatch)
+ .ToListAsync(ct);
+
+ var pick = candidates.FirstOrDefault(c => !_leases.ContainsKey(c.ConditionId));
+ if (pick is null)
+ {
+ return null; // everything in the stalest batch is already in flight
+ }
+
+ _leases[pick.ConditionId] = DateTimeOffset.UtcNow;
+ var jobId = Guid.NewGuid().ToString("N");
+ _inFlight[jobId] = new JobMapping(pick.SkinId, pick.ConditionId);
+
+ var code = Wear.ToCode(pick.Condition) ?? pick.Condition;
+ var search = $"{pick.Weapon} {pick.SkinName} {code}".Trim();
+ return new ScrapeJobDto(jobId, pick.SkinId, pick.ConditionId, search, maxPages);
+ }
+ finally
+ {
+ _gate.Release();
+ }
+ }
+
+ /// Resolve a posted job to its skin+condition and release its lease.
+ public JobMapping? Complete(string jobId)
+ {
+ if (_inFlight.TryRemove(jobId, out var mapping))
+ {
+ _leases.TryRemove(mapping.ConditionId, out _);
+ return mapping;
+ }
+
+ return null;
+ }
+
+ public int InFlight => _inFlight.Count;
+
+ public sealed record JobMapping(int SkinId, int ConditionId);
+
+ private sealed record Candidate(int ConditionId, int SkinId, string Weapon, string SkinName, string Condition);
+}
diff --git a/BlueLaminate/BlueLaminate.C2/Program.cs b/BlueLaminate/BlueLaminate.C2/Program.cs
new file mode 100644
index 0000000..0e67b29
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.C2/Program.cs
@@ -0,0 +1,87 @@
+using BlueLaminate.C2;
+using BlueLaminate.Core.CsMoney;
+using BlueLaminate.Core.DependencyInjection;
+using BlueLaminate.EFCore.Data;
+using Microsoft.EntityFrameworkCore;
+
+// The C2: hands cs.money scrape jobs to Python workers and ingests their results.
+// Reuses the whole BlueLaminate stack (DB, ingest service) via the one composition root.
+// Content root = the binary directory so appsettings.json is found regardless of the
+// working directory the process is launched from (matches the CLI's approach).
+var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+{
+ Args = args,
+ ContentRootPath = AppContext.BaseDirectory,
+});
+builder.Services.AddBlueLaminateCore(builder.Configuration);
+builder.Services.AddSingleton();
+
+var app = builder.Build();
+
+// Apply pending EF migrations at startup (incl. the market_listings view) so a fresh
+// container is ready with one command. Disable with AutoMigrate=false if you'd rather
+// run `dotnet ef database update` yourself.
+if (app.Configuration.GetValue("AutoMigrate", true))
+{
+ using var scope = app.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Database.Migrate();
+}
+
+// Shared-secret gate. Workers send it as X-Worker-Token; if no token is configured
+// the gate is open (local dev). Set WorkerToken (config) / WORKER_TOKEN (env) in prod.
+var workerToken = builder.Configuration["WorkerToken"];
+var maxPagesPerJob = builder.Configuration.GetValue("MaxPagesPerJob", 60);
+
+app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
+
+// Operator read endpoints: "where is this listed?" across markets. Open (read-only).
+app.MapGet("/market/skin/{skinId:int}", async (
+ int skinId, MarketPresenceService presence, CancellationToken ct) =>
+ Results.Ok(await presence.ForSkinAsync(skinId, ct)));
+
+app.MapGet("/market/instance/{instanceId:int}", async (
+ int instanceId, MarketPresenceService presence, CancellationToken ct) =>
+ Results.Ok(await presence.ForInstanceAsync(instanceId, ct)));
+
+var jobs = app.MapGroup("/jobs");
+jobs.AddEndpointFilter(async (ctx, next) =>
+{
+ if (!string.IsNullOrEmpty(workerToken)
+ && ctx.HttpContext.Request.Headers["X-Worker-Token"].ToString() != workerToken)
+ {
+ return Results.Unauthorized();
+ }
+
+ return await next(ctx);
+});
+
+// Claim the next stalest skin+wear to scrape. 204 when nothing is currently available
+// (everything in the stalest batch is already leased to other workers).
+jobs.MapGet("/next", async (JobQueue queue, SkinTrackerDbContext db, CancellationToken ct) =>
+{
+ var job = await queue.ClaimNextAsync(db, maxPagesPerJob, ct);
+ return job is null ? Results.NoContent() : Results.Ok(job);
+});
+
+// Post a claimed job's scraped listings. The C2 owns parsing/persistence so the
+// worker stays dumb: it just forwards the raw cs.money items it gathered.
+jobs.MapPost("/{jobId}/result", async (
+ string jobId, ScrapeResultDto result, JobQueue queue, CsMoneyIngestService ingest, CancellationToken ct) =>
+{
+ var mapping = queue.Complete(jobId);
+ if (mapping is null)
+ {
+ return Results.NotFound(new { error = "unknown or expired jobId" });
+ }
+
+ // Only a fully-walked sweep ("completed") is authoritative. On a partial result
+ // (fetch-cap / challenged / float tie) we still upsert what we saw, but we must NOT
+ // mark unseen listings Removed or stamp the swept-checkpoint — the unseen ones may
+ // simply be unfetched, and the band must be re-queued and retried.
+ var complete = string.Equals(result.StoppedReason, "completed", StringComparison.OrdinalIgnoreCase);
+ var r = await ingest.IngestAsync(mapping.SkinId, mapping.ConditionId, result.Items ?? [], complete, ct);
+ return Results.Ok(r);
+});
+
+app.Run();
diff --git a/BlueLaminate/BlueLaminate.C2/Properties/launchSettings.json b/BlueLaminate/BlueLaminate.C2/Properties/launchSettings.json
new file mode 100644
index 0000000..c22ec4a
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.C2/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5103",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7111;http://localhost:5103",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.C2/appsettings.Development.json b/BlueLaminate/BlueLaminate.C2/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.C2/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.C2/appsettings.json b/BlueLaminate/BlueLaminate.C2/appsettings.json
new file mode 100644
index 0000000..ab0d67e
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.C2/appsettings.json
@@ -0,0 +1,16 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "Urls": "http://0.0.0.0:5080",
+ "ConnectionStrings": {
+ "SkinTracker": "Host=localhost;Port=5432;Database=skintracker;Username=postgres"
+ },
+ "WorkerToken": "dev-worker-token",
+ "MaxPagesPerJob": 60
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj b/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
index 2d0470b..6eeb7fb 100644
--- a/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
+++ b/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj
@@ -12,13 +12,14 @@
-
+
-
-
+
+
+
diff --git a/BlueLaminate/BlueLaminate.Cli/Commands/CaptureCsMoneyCommand.cs b/BlueLaminate/BlueLaminate.Cli/Commands/CaptureCsMoneyCommand.cs
new file mode 100644
index 0000000..179836b
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/Commands/CaptureCsMoneyCommand.cs
@@ -0,0 +1,122 @@
+using BlueLaminate.Scraper.CsMoney;
+using BlueLaminate.Scraper.Proxies;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using System.CommandLine;
+
+namespace BlueLaminate.Cli.Commands;
+
+///
+/// capture-csmoney: open the cs.money market through the IPRoyal residential
+/// proxy (local forwarding hop, no CDP) in a real, non-headless browser. You clear
+/// the Cloudflare challenge once; the tool then pages the listings API from inside
+/// the cleared page with human-like pacing, dumping each page's JSON and reporting
+/// how many pages survive before a re-challenge. Discovery/measurement tool — writes
+/// nothing to the database. Reads IPROYAL_USERNAME / IPROYAL_PASSWORD.
+///
+internal static class CaptureCsMoneyCommand
+{
+ public static Command Build(IHost host)
+ {
+ var countryOption = new Option("--country")
+ {
+ Description = "ISO country code(s) for the exit IP, e.g. \"us\". Default: configured/random.",
+ };
+ var loadImagesOption = new Option("--load-images")
+ {
+ Description = "Load images (uses more bandwidth). Default off to conserve the metered plan.",
+ };
+ var pagesOption = new Option("--pages")
+ {
+ Description = "Maximum offset pages (60 items each) to fetch before stopping.",
+ DefaultValueFactory = _ => 50,
+ };
+ var noProxyOption = new Option("--no-proxy")
+ {
+ Description = "Diagnostic: drive the browser on this machine's own IP (no IPRoyal proxy), "
+ + "to isolate whether re-challenges are IP reputation vs. the webdriver fingerprint.",
+ };
+ var outOption = new Option("--out")
+ {
+ Description = "Directory to write captured JSON pages to.",
+ DefaultValueFactory = _ => "csmoney-captures",
+ };
+
+ var command = new Command(
+ "capture-csmoney",
+ "Open the cs.money market through the residential proxy, clear Cloudflare once, then page "
+ + "the listings API with pacing and report how many pages survive. Discovery/measurement "
+ + "tool — writes nothing to the database. Reads IPROYAL_USERNAME / IPROYAL_PASSWORD.")
+ {
+ countryOption,
+ loadImagesOption,
+ pagesOption,
+ outOption,
+ noProxyOption,
+ };
+
+ command.SetAction((parseResult, ct) => RunAsync(
+ host,
+ parseResult.GetValue(countryOption),
+ parseResult.GetValue(loadImagesOption),
+ parseResult.GetValue(pagesOption),
+ parseResult.GetValue(outOption)!,
+ parseResult.GetValue(noProxyOption),
+ ct));
+
+ return command;
+ }
+
+ private static async Task RunAsync(
+ IHost host, string? country, bool loadImages, int pages, string outDir, bool noProxy,
+ CancellationToken ct)
+ {
+ using var scope = host.Services.CreateScope();
+ var options = scope.ServiceProvider.GetRequiredService>().Value;
+
+ var exitCountry = string.IsNullOrWhiteSpace(country) ? options.Country : country;
+ var images = loadImages || options.LoadImages;
+
+ Console.WriteLine($"Opening {options.MarketUrl}{(noProxy ? " (DIRECT — no proxy)" : "")}");
+ Console.WriteLine(
+ "Solve any Cloudflare challenge in the window and wait until the market grid "
+ + "(items + prices) is actually visible — that means the session is cleared.");
+ Console.WriteLine(
+ $"Press Enter here once it's visible. The tool then pages up to {pages} page(s) of "
+ + "listings from inside the cleared page and reports how far it gets.");
+
+ try
+ {
+ var capture = scope.ServiceProvider.GetRequiredService();
+
+ // Block until the operator presses Enter; the browser stays open the whole
+ // time. ReadLine is sync, so push it off-thread.
+ var result = await capture.RunAsync(
+ outDir,
+ new ProxyRequest(Country: exitCountry, Sticky: true),
+ images,
+ useProxy: !noProxy,
+ pages,
+ () => Task.Run(() => Console.ReadLine(), ct),
+ ct);
+
+ var full = Path.GetFullPath(outDir);
+ Console.WriteLine();
+ Console.WriteLine(
+ $"Stopped: {result.StoppedReason}. {result.PagesSucceeded} page(s), "
+ + $"{result.ItemsTotal} item(s) → {full}");
+ return result.PagesSucceeded > 0 ? 0 : 1;
+ }
+ catch (OperationCanceledException)
+ {
+ Console.Error.WriteLine("Capture cancelled.");
+ return 130;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"cs.money capture failed: {ex.Message}");
+ return 1;
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/Commands/FetchListingsCommand.cs b/BlueLaminate/BlueLaminate.Cli/Commands/FetchListingsCommand.cs
new file mode 100644
index 0000000..afd2eae
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/Commands/FetchListingsCommand.cs
@@ -0,0 +1,132 @@
+using BlueLaminate.Scraper.CsFloat;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using System.CommandLine;
+
+namespace BlueLaminate.Cli.Commands;
+
+///
+/// fetch-listings: fetch active CSFloat listings for one skin via the
+/// official API and print them. Fetch-and-print only — nothing is written to the
+/// database. Pure presentation over .
+///
+internal static class FetchListingsCommand
+{
+ public static Command Build(IHost host)
+ {
+ 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 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 command = 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,
+ };
+
+ command.SetAction((parseResult, ct) => RunAsync(
+ host,
+ parseResult.GetValue(defIndexOption),
+ parseResult.GetValue(paintIndexOption),
+ parseResult.GetValue(sortByOption)!,
+ parseResult.GetValue(maxOption),
+ parseResult.GetValue(dumpOption),
+ ct));
+
+ return command;
+ }
+
+ // Defaults to the M4A4 | Cyber Security sample so it runs with no args.
+ private static async Task RunAsync(
+ IHost host, int? defIndex, int? paintIndex, string sortBy, int max, string? dumpPath,
+ CancellationToken ct)
+ {
+ var def = defIndex ?? 16;
+ var paint = paintIndex ?? 985;
+
+ using var scope = host.Services.CreateScope();
+ CsFloatListingsClient? client = null;
+
+ try
+ {
+ client = scope.ServiceProvider.GetRequiredService();
+
+ 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);
+ if (client is not null)
+ {
+ Console.Error.WriteLine(client.LastRateLimit.ToString());
+ }
+
+ return 1;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Fetch failed: {ex.Message}");
+ return 1;
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/Commands/ProbeProxyCommand.cs b/BlueLaminate/BlueLaminate.Cli/Commands/ProbeProxyCommand.cs
new file mode 100644
index 0000000..30b70a3
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/Commands/ProbeProxyCommand.cs
@@ -0,0 +1,72 @@
+using BlueLaminate.Scraper.Proxies;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using System.CommandLine;
+
+namespace BlueLaminate.Cli.Commands;
+
+///
+/// probe-proxy: launch a non-headless Edge browser through the IPRoyal
+/// residential proxy and print the exit IP, to confirm authentication works and
+/// the IP is genuinely residential. Reads IPROYAL_USERNAME / IPROYAL_PASSWORD.
+/// Costs a few KB, so it's the right first check against a metered plan.
+///
+internal static class ProbeProxyCommand
+{
+ public static Command Build(IHost host)
+ {
+ 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 command = 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,
+ };
+
+ command.SetAction((parseResult, ct) => RunAsync(
+ host,
+ parseResult.GetValue(countryOption),
+ parseResult.GetValue(rotatingOption),
+ ct));
+
+ return command;
+ }
+
+ private static async Task RunAsync(
+ IHost host, string? country, bool rotating, CancellationToken ct)
+ {
+ using var scope = host.Services.CreateScope();
+
+ try
+ {
+ var probe = scope.ServiceProvider.GetRequiredService();
+ 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;
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/Commands/SweepCatalogCommand.cs b/BlueLaminate/BlueLaminate.Cli/Commands/SweepCatalogCommand.cs
new file mode 100644
index 0000000..2368ad2
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/Commands/SweepCatalogCommand.cs
@@ -0,0 +1,74 @@
+using BlueLaminate.Core.Listings;
+using BlueLaminate.Scraper.CsFloat;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using System.CommandLine;
+
+namespace BlueLaminate.Cli.Commands;
+
+///
+/// sweep-catalog: catalogue-driven sweep querying each catalogue skin's
+/// listings by def_index+paint_index. Presentation over
+/// .
+///
+internal static class SweepCatalogCommand
+{
+ public static Command Build(IHost host)
+ {
+ var command = new Command(
+ "sweep-catalog",
+ "Catalogue-driven sweep: query each catalogue skin's listings by def_index+paint_index, "
+ + "split by wear band (min_float/max_float), so only weapons are fetched (no "
+ + "stickers/cases/agents) and each wear band is an independent checkpoint. Each band is "
+ + "paged to completion, so Removed-tracking is accurate. Runs continuously (looping the "
+ + "catalogue, never-swept/stalest bands first) until Ctrl+C; paces off rate-limit headers. "
+ + "Reads CSFLOAT_API_KEY.");
+
+ command.SetAction((parseResult, ct) => RunAsync(host, ct));
+
+ return command;
+ }
+
+ private static async Task RunAsync(IHost host, CancellationToken ct)
+ {
+ using var scope = host.Services.CreateScope();
+ CsFloatListingsClient? client = null;
+
+ try
+ {
+ var service = scope.ServiceProvider.GetRequiredService();
+ client = scope.ServiceProvider.GetRequiredService();
+
+ Console.WriteLine("Catalogue sweep (weapons only). Running until Ctrl+C…");
+
+ var r = await service.SweepCatalogAsync(ct: ct);
+
+ Console.WriteLine();
+ Console.WriteLine($"Catalogue sweep {r.StoppedReason}:");
+ Console.WriteLine($" Wear-bands : {r.SkinsCovered}");
+ 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);
+ if (client is not null)
+ {
+ Console.Error.WriteLine(client.LastRateLimit.ToString());
+ }
+
+ return 1;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Catalogue sweep failed: {ex.Message}");
+ return 1;
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/Commands/SweepListingsCommand.cs b/BlueLaminate/BlueLaminate.Cli/Commands/SweepListingsCommand.cs
new file mode 100644
index 0000000..f0ea05e
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/Commands/SweepListingsCommand.cs
@@ -0,0 +1,102 @@
+using BlueLaminate.Core.Listings;
+using BlueLaminate.Scraper.CsFloat;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using System.CommandLine;
+
+namespace BlueLaminate.Cli.Commands;
+
+///
+/// sweep-listings: global incremental sweep of active CSFloat listings into
+/// the database. Presentation over .
+///
+internal static class SweepListingsCommand
+{
+ public static Command Build(IHost host)
+ {
+ 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 command = 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,
+ };
+
+ command.SetAction((parseResult, ct) => RunAsync(
+ host,
+ parseResult.GetValue(maxRequestsOption),
+ parseResult.GetValue(maxIngestOption),
+ parseResult.GetValue(fullOption),
+ ct));
+
+ return command;
+ }
+
+ private static async Task RunAsync(
+ IHost host, int maxRequests, int maxListings, bool full, CancellationToken ct)
+ {
+ using var scope = host.Services.CreateScope();
+ CsFloatListingsClient? client = null;
+
+ try
+ {
+ var service = scope.ServiceProvider.GetRequiredService();
+ client = scope.ServiceProvider.GetRequiredService();
+
+ 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);
+ if (client is not null)
+ {
+ Console.Error.WriteLine(client.LastRateLimit.ToString());
+ }
+
+ return 1;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Sweep failed: {ex.Message}");
+ return 1;
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/Commands/SyncSkinsCommand.cs b/BlueLaminate/BlueLaminate.Cli/Commands/SyncSkinsCommand.cs
new file mode 100644
index 0000000..799bc80
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Cli/Commands/SyncSkinsCommand.cs
@@ -0,0 +1,98 @@
+using BlueLaminate.Core.Skins;
+using BlueLaminate.Scraper.Skins;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using System.CommandLine;
+
+namespace BlueLaminate.Cli.Commands;
+
+///
+/// sync-skins: load the CS2 skin catalogue and upsert it (throttled monthly).
+/// Presentation over ; --dry-run
+/// loads and prints via without touching the DB.
+///
+internal static class SyncSkinsCommand
+{
+ public static Command Build(IHost host)
+ {
+ var forceOption = new Option("--force")
+ {
+ Description = "Ignore the once-a-month throttle and sync now."
+ };
+ var dryRunOption = new Option("--dry-run")
+ {
+ Description = "Load and print the skins without writing to the database."
+ };
+
+ var command = new Command(
+ "sync-skins",
+ "Load the CS2 skin catalogue from the CSGO-API dataset and upsert it (throttled to once a month).")
+ {
+ forceOption,
+ dryRunOption,
+ };
+
+ command.SetAction((parseResult, ct) => RunAsync(
+ host,
+ parseResult.GetValue(forceOption),
+ parseResult.GetValue(dryRunOption),
+ ct));
+
+ return command;
+ }
+
+ private static async Task RunAsync(IHost host, bool force, bool dryRun, CancellationToken ct)
+ {
+ using var scope = host.Services.CreateScope();
+
+ if (dryRun)
+ {
+ return await DryRunAsync(scope.ServiceProvider, ct);
+ }
+
+ var service = scope.ServiceProvider.GetRequiredService();
+ var result = await service.SyncAsync(force, ct);
+
+ if (result.Skipped)
+ {
+ Console.WriteLine(
+ $"Skipped: skins were last synced {result.LastRanAt:u}. "
+ + "Next run allowed one month later — pass --force to override.");
+ }
+ else
+ {
+ Console.WriteLine(
+ $"Synced {result.Loaded} skins: {result.Inserted} inserted, "
+ + $"{result.Updated} updated, "
+ + $"{result.Loaded - result.Inserted - result.Updated} unchanged "
+ + $"({result.WeaponsCreated} weapons, {result.CollectionsCreated} collections created).");
+ }
+
+ return 0;
+ }
+
+ // Loads the catalogue and prints it without a database — no service involved.
+ private static async Task DryRunAsync(IServiceProvider sp, CancellationToken ct)
+ {
+ var logger = sp.GetRequiredService().CreateLogger("BlueLaminate.Cli.SyncSkins");
+ var client = sp.GetRequiredService();
+
+ logger.LogInformation("Loading skin catalogue (dry run — nothing will be written).");
+ var skins = await client.FetchAsync(ct);
+ logger.LogInformation("Loaded {Count} skins.", skins.Count);
+
+ Console.WriteLine($"Loaded {skins.Count} skins (dry run, nothing written):");
+ foreach (var s in skins)
+ {
+ 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(
+ $" {idx,-10} {s.WeaponName,-16} {s.Name,-24} {s.Rarity,-14} {range,-10} {sources}{tags}");
+ }
+
+ return 0;
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Cli/Logging/CompactConsoleLogExporter.cs b/BlueLaminate/BlueLaminate.Cli/Logging/CompactConsoleLogExporter.cs
index 3d74f73..58dbcce 100644
--- a/BlueLaminate/BlueLaminate.Cli/Logging/CompactConsoleLogExporter.cs
+++ b/BlueLaminate/BlueLaminate.Cli/Logging/CompactConsoleLogExporter.cs
@@ -15,7 +15,7 @@ public sealed class CompactConsoleLogExporter : BaseExporter
foreach (var record in batch)
{
var message = record.FormattedMessage ?? record.Body ?? string.Empty;
- Console.WriteLine($"{record.Timestamp:yyyy-MM-dd HH:mm:ss.fff'Z'} {message}");
+ Console.WriteLine($"[{record.Timestamp:yyyy-MM-dd HH:mm:ss.fff'Z'}] {message}");
}
return ExportResult.Success;
diff --git a/BlueLaminate/BlueLaminate.Cli/Program.cs b/BlueLaminate/BlueLaminate.Cli/Program.cs
index 0cc5b4d..f3f835b 100644
--- a/BlueLaminate/BlueLaminate.Cli/Program.cs
+++ b/BlueLaminate/BlueLaminate.Cli/Program.cs
@@ -1,406 +1,89 @@
-using BlueLaminate.Cli;
+using BlueLaminate.Cli.Commands;
using BlueLaminate.Cli.Logging;
+using BlueLaminate.Core.DependencyInjection;
using BlueLaminate.EFCore.Data;
-using BlueLaminate.Scraper.CsFloat;
-using BlueLaminate.Scraper.Skins;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
using OpenTelemetry;
using OpenTelemetry.Resources;
using System.CommandLine;
-// OpenTelemetry logging through a compact console sink that prints one
-// "{utc timestamp} {message}" line per record. Swapping in an OTLP exporter
-// later is a change here. Disposed at process exit so buffered records flush.
-using var loggerFactory = LoggerFactory.Create(logging =>
+// Generic Host = composition root. The exact same wiring a web frontend would use:
+// configuration → AddBlueLaminateCore → resolve services per command scope. Args are
+// deliberately NOT handed to the host (System.CommandLine owns parsing; the host's
+// command-line config provider would reject bare verbs like "sync-skins"). The
+// content root is the binary directory so appsettings.json is found regardless of CWD.
+var builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
{
- logging.AddOpenTelemetry(otel =>
- {
- otel.SetResourceBuilder(
- ResourceBuilder.CreateDefault().AddService("BlueLaminate.Cli"));
- otel.IncludeFormattedMessage = true;
- otel.AddProcessor(new SimpleLogRecordExportProcessor(new CompactConsoleLogExporter()));
- });
+ ContentRootPath = AppContext.BaseDirectory,
});
-// Entry point: System.CommandLine builds the command tree, parsing, and help.
-// New features are added as additional commands here as they're implemented.
-var forceOption = new Option("--force")
-{
- Description = "Ignore the once-a-month throttle and sync now."
-};
-var dryRunOption = new Option("--dry-run")
-{
- Description = "Load and print the skins without writing to the database."
-};
+// Reuse the connection string stored in the EFCore project's user secrets (dev).
+builder.Configuration.AddUserSecrets(optional: true);
-var syncSkins = new Command(
- "sync-skins",
- "Load the CS2 skin catalogue from the CSGO-API dataset and upsert it (throttled to once a month).")
+// OpenTelemetry logging through a compact console sink that prints one
+// "{utc timestamp} {message}" line per record. Swapping in an OTLP exporter later
+// is a change here. ClearProviders drops the default console logger so we don't
+// double-print.
+builder.Logging.ClearProviders();
+// IHttpClientFactory logs each request at Information under these categories; mute
+// to Warning so the compact console stays one line per app message.
+builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
+// EF Core logs every SQL command at Information; we only care about failures, so
+// raise its floor to Warning (failed commands still log at Error).
+builder.Logging.AddFilter("Microsoft.EntityFrameworkCore", LogLevel.Warning);
+builder.Logging.AddOpenTelemetry(otel =>
{
- forceOption,
- dryRunOption,
-};
-syncSkins.SetAction((parseResult, ct) =>
- SyncSkinsAsync(
- parseResult.GetValue(forceOption),
- parseResult.GetValue(dryRunOption),
- loggerFactory,
- ct));
+ otel.SetResourceBuilder(
+ ResourceBuilder.CreateDefault().AddService("BlueLaminate.Cli"));
+ otel.IncludeFormattedMessage = true;
+ otel.AddProcessor(new SimpleLogRecordExportProcessor(new CompactConsoleLogExporter()));
+});
-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)."
-};
+builder.Services.AddBlueLaminateCore(builder.Configuration);
-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."
-};
+using var host = builder.Build();
-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.")
+// This CLI builds the host but doesn't run it, so ValidateOnStart won't fire on its
+// own — trigger it explicitly. Invalid configuration (e.g. CsFloat:MaxLimit out of
+// range) fails fast here with a clear message instead of being silently clamped.
+try
{
- 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));
+ host.Services.GetRequiredService().Validate();
+}
+catch (OptionsValidationException ex)
+{
+ Console.Error.WriteLine("Invalid configuration:");
+ foreach (var failure in ex.Failures)
+ {
+ Console.Error.WriteLine($" - {failure}");
+ }
+ return 1;
+}
+// System.CommandLine builds the command tree, parsing, and help. Each command lives
+// in its own file under Commands/ and resolves its service from a DI scope.
var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker tools.")
{
- syncSkins,
- fetchListings,
- sweepListings,
- sweepCatalog,
+ SyncSkinsCommand.Build(host),
+ FetchListingsCommand.Build(host),
+ SweepListingsCommand.Build(host),
+ SweepCatalogCommand.Build(host),
+ ProbeProxyCommand.Build(host),
+ CaptureCsMoneyCommand.Build(host),
};
-return await root.Parse(args).InvokeAsync();
-
-// 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)
+// Ctrl+C → cancel the action's token so long-running commands (e.g. sweep-catalog,
+// which loops until stopped) unwind gracefully instead of hard-killing the process
+// mid-write.
+using var cts = new CancellationTokenSource();
+Console.CancelKeyPress += (_, e) =>
{
- var apiKey = Environment.GetEnvironmentVariable("CSFLOAT_API_KEY");
- if (string.IsNullOrWhiteSpace(apiKey))
- {
- Console.Error.WriteLine("Set the CSFLOAT_API_KEY environment variable first.");
- return 1;
- }
+ e.Cancel = true; // prevent immediate termination; let the token cancel cleanly
+ cts.Cancel();
+};
- 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.
-static async Task SyncSkinsAsync(
- bool force, bool dryRun, ILoggerFactory loggerFactory, CancellationToken ct)
-{
- var logger = loggerFactory.CreateLogger("BlueLaminate.Cli.SyncSkins");
- var client = new SkinCatalogClient(CreateHttpClient());
-
- if (dryRun)
- {
- logger.LogInformation("Loading skin catalogue (dry run — nothing will be written).");
- var skins = await client.FetchAsync(ct);
- logger.LogInformation("Loaded {Count} skins.", skins.Count);
- Console.WriteLine($"Loaded {skins.Count} skins (dry run, nothing written):");
- foreach (var s in skins)
- {
- 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(
- $" {idx,-10} {s.WeaponName,-16} {s.Name,-24} {s.Rarity,-14} {range,-10} {sources}{tags}");
- }
- return 0;
- }
-
- using var db = new SkinTrackerDbContextFactory().CreateDbContext([]);
- var service = new SkinSyncService(db, client, loggerFactory.CreateLogger());
- var result = await service.SyncAsync(force, ct);
-
- if (result.Skipped)
- {
- Console.WriteLine(
- $"Skipped: skins were last synced {result.LastRanAt:u}. "
- + "Next run allowed one month later — pass --force to override.");
- }
- else
- {
- Console.WriteLine(
- $"Synced {result.Loaded} skins: {result.Inserted} inserted, "
- + $"{result.Updated} updated, "
- + $"{result.Loaded - result.Inserted - result.Updated} unchanged "
- + $"({result.WeaponsCreated} weapons, {result.CollectionsCreated} collections created).");
- }
-
- return 0;
-}
-
-static HttpClient CreateHttpClient()
-{
- var http = new HttpClient();
- http.Timeout = TimeSpan.FromMinutes(2);
- http.DefaultRequestHeaders.UserAgent.ParseAdd("BlueLaminate.Cli");
- return http;
-}
+return await root.Parse(args).InvokeAsync(cancellationToken: cts.Token);
diff --git a/BlueLaminate/BlueLaminate.Cli/appsettings.json b/BlueLaminate/BlueLaminate.Cli/appsettings.json
index ef45fd6..31e2408 100644
--- a/BlueLaminate/BlueLaminate.Cli/appsettings.json
+++ b/BlueLaminate/BlueLaminate.Cli/appsettings.json
@@ -1,5 +1,27 @@
{
"ConnectionStrings": {
"SkinTracker": "Host=localhost;Port=5432;Database=skintracker;Username=postgres"
+ },
+ "CsFloat": {
+ "ApiKey": "",
+ "BaseUrl": "https://csfloat.com/api/v1/listings",
+ "MaxLimit": 50
+ },
+ "SkinCatalog": {
+ "Url": "https://raw.githubusercontent.com/ByMykel/CSGO-API/refs/heads/main/public/api/en/skins.json"
+ },
+ "CsMoney": {
+ "MarketUrl": "https://cs.money/market/buy/",
+ "ApiUrlTemplate": "https://cs.money/2.0/market/sell-orders?limit=60&offset={0}",
+ "Country": "",
+ "LoadImages": false,
+ "PageDelaySeconds": 2.5,
+ "PageJitterSeconds": 2.0
+ },
+ "Sweep": {
+ "PageDelay": "00:00:05",
+ "MaxJitter": "00:00:03",
+ "RateLimitSafetyMargin": 2,
+ "RateLimitCooldown": "00:01:00"
}
}
diff --git a/BlueLaminate/BlueLaminate.Core/BlueLaminate.Core.csproj b/BlueLaminate/BlueLaminate.Core/BlueLaminate.Core.csproj
new file mode 100644
index 0000000..d1c6076
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/BlueLaminate.Core.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyIngestService.cs b/BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyIngestService.cs
new file mode 100644
index 0000000..a5ac290
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyIngestService.cs
@@ -0,0 +1,329 @@
+using BlueLaminate.EFCore.Data;
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace BlueLaminate.Core.CsMoney;
+
+/// Outcome of ingesting one skin+wear scrape job's results.
+public sealed record CsMoneyIngestResult(
+ int Matched, int Inserted, int Updated, int Removed, int Skipped);
+
+///
+/// Persists the listings the worker scraped for one targeted skin+wear job into the
+/// cs_money_listings table. Mirrors the CSFloat ListingSweepService
+/// patterns — upsert by natural key, resolve each listing to a market-agnostic
+/// by fingerprint, soft-track Removed, flag dupes — but
+/// scoped to the one skin+condition the job targeted (so it's the per-band unit, and
+/// Removed-tracking is exact). cs.money's free-text search is fuzzy, so results are
+/// filtered to the intended skin (by name) and wear (by quality) before persisting.
+///
+public sealed class CsMoneyIngestService
+{
+ public const string Source = "csmoney";
+
+ private readonly SkinTrackerDbContext _db;
+ private readonly ILogger _logger;
+
+ public CsMoneyIngestService(SkinTrackerDbContext db, ILogger logger)
+ {
+ _db = db;
+ _logger = logger;
+ }
+
+ ///
+ /// True only when the worker walked the whole skin+wear (stoppedReason "completed").
+ /// On a partial sweep we upsert what we saw but skip Removed-marking, the price
+ /// point, and the swept-checkpoint — unseen listings may just be unfetched, so the
+ /// band stays un-stamped and gets re-queued rather than being wrongly pruned.
+ ///
+ public async Task IngestAsync(
+ int skinId, int? conditionId, IReadOnlyList items, bool complete, CancellationToken ct = default)
+ {
+ var now = DateTimeOffset.UtcNow;
+
+ var skin = await _db.Skins
+ .Where(s => s.Id == skinId)
+ .Select(s => new { s.Id, s.Name, Weapon = s.Weapon.Name })
+ .FirstOrDefaultAsync(ct);
+ if (skin is null)
+ {
+ _logger.LogWarning("Ingest skipped: skin {SkinId} not found.", skinId);
+ return new CsMoneyIngestResult(0, 0, 0, 0, items.Count);
+ }
+
+ string? conditionName = null;
+ if (conditionId is { } cid)
+ {
+ conditionName = await _db.SkinConditions
+ .Where(c => c.Id == cid).Select(c => c.Condition).FirstOrDefaultAsync(ct);
+ }
+
+ var expectedShort = Normalize($"{skin.Weapon} | {skin.Name}");
+ var expectedQuality = Wear.ToCode(conditionName);
+
+ // cs.money search is fuzzy — keep only items that are actually this skin (by
+ // name) and, when the job targets a wear band, this wear (by quality).
+ var matched = items.Where(it =>
+ {
+ var a = it.Asset;
+ if (a?.Names?.Short is null)
+ {
+ return false;
+ }
+
+ if (Normalize(a.Names.Short) != expectedShort)
+ {
+ return false;
+ }
+
+ return expectedQuality is null
+ || string.Equals(a.Quality, expectedQuality, StringComparison.OrdinalIgnoreCase);
+ }).ToList();
+
+ var skipped = items.Count - matched.Count;
+ if (matched.Count == 0)
+ {
+ // Nothing for this skin+wear. If the sweep was complete this is genuine
+ // (none listed, or a name mismatch) — stamp the checkpoint so it advances.
+ // If it was partial (e.g. challenged before any item), leave it un-stamped
+ // so the band is retried.
+ if (complete)
+ {
+ await StampCheckpointAsync(conditionId, now, ct);
+ await _db.SaveChangesAsync(ct);
+ }
+
+ return new CsMoneyIngestResult(0, 0, 0, 0, skipped);
+ }
+
+ var sellOrderIds = matched.Select(it => it.Id).ToList();
+ var existing = await _db.CsMoneyListings
+ .Where(l => sellOrderIds.Contains(l.SellOrderId))
+ .ToDictionaryAsync(l => l.SellOrderId, ct);
+
+ var inserted = 0;
+ var updated = 0;
+ var touched = new HashSet();
+ var touchedInstanceIds = new HashSet();
+
+ foreach (var it in matched)
+ {
+ touched.Add(it.Id);
+ var instance = await ResolveInstanceAsync(skinId, conditionId, it, now, ct);
+ if (instance is not null)
+ {
+ touchedInstanceIds.Add(instance.Id);
+ }
+
+ if (existing.TryGetValue(it.Id, out var row))
+ {
+ row.Price = it.Pricing?.Default ?? row.Price;
+ row.PriceBeforeDiscount = it.Pricing?.PriceBeforeDiscount;
+ row.ComputedPrice = it.Pricing?.Computed;
+ row.AssetId = it.Asset?.Id?.ToString();
+ row.LastSeenAt = now;
+ row.Status = ListingStatus.Active;
+ row.RemovedAt = null;
+ row.ConditionId = conditionId;
+ row.SkinInstance = instance;
+ updated++;
+ }
+ else
+ {
+ var entity = Map(it, skinId, conditionId, now);
+ entity.SkinInstance = instance;
+ _db.CsMoneyListings.Add(entity);
+ inserted++;
+ }
+ }
+
+ // Persist inserts/updates before the set-based Removed/dupe queries run.
+ await _db.SaveChangesAsync(ct);
+
+ await FlagDupesAsync(touchedInstanceIds, now, ct);
+
+ // The following only hold if we saw the FULL skin+wear set. On a partial sweep,
+ // listings we didn't fetch are not gone (so don't mark them Removed), the
+ // cheapest item may be among the unfetched (so don't record a price point), and
+ // the band isn't fully swept (so don't stamp the checkpoint — let it re-queue).
+ var removed = 0;
+ if (complete)
+ {
+ removed = await MarkRemovedAsync(skinId, conditionId, touched, now, ct);
+
+ // Record a price point (the cheapest live listing) for this skin+wear.
+ if (conditionId is { } condId)
+ {
+ var minPrice = matched.Where(m => m.Pricing is not null).Select(m => m.Pricing!.Default).Min();
+ await _db.PriceHistories.AddAsync(new PriceHistory
+ {
+ SkinId = skinId,
+ ConditionId = condId,
+ Price = minPrice,
+ Currency = "USD",
+ RecordedAt = now,
+ Source = Source,
+ }, ct);
+ }
+
+ await StampCheckpointAsync(conditionId, now, ct);
+ }
+
+ await _db.SaveChangesAsync(ct);
+
+ _logger.LogInformation(
+ "cs.money ingest {Weapon} | {Skin} ({Wear}): {Matched} matched ({Ins} new, {Upd} upd, "
+ + "{Rem} removed), {Skipped} skipped by filter{Partial}.",
+ skin.Weapon, skin.Name, conditionName ?? "all", matched.Count, inserted, updated, removed, skipped,
+ complete ? "" : " [PARTIAL — not pruned/checkpointed]");
+
+ return new CsMoneyIngestResult(matched.Count, inserted, updated, removed, skipped);
+ }
+
+ // Find the physical item matching this listing's fingerprint, or create one.
+ // Shared with CSFloat listings, so a copy seen on both markets is one instance.
+ // Skipped for non-skin items (no float/pattern) — the fingerprint is meaningless.
+ private async Task ResolveInstanceAsync(
+ int skinId, int? conditionId, CsMoneyItem it, DateTimeOffset now, CancellationToken ct)
+ {
+ if (it.Asset?.Float is not { } floatValue || it.Asset.Pattern is not { } pattern)
+ {
+ return null;
+ }
+
+ var seed = pattern.ToString();
+ var st = it.Asset.IsStatTrak;
+ var sv = it.Asset.IsSouvenir;
+
+ var tracked = _db.ChangeTracker.Entries()
+ .Select(e => e.Entity)
+ .FirstOrDefault(i => i.SkinId == skinId && i.FloatValue == floatValue
+ && i.PaintSeed == seed && i.StatTrak == st && i.Souvenir == sv);
+ if (tracked is not null)
+ {
+ tracked.LastSeenAt = now;
+ return tracked;
+ }
+
+ var instance = await _db.SkinInstances.FirstOrDefaultAsync(
+ i => i.SkinId == skinId && i.FloatValue == floatValue
+ && i.PaintSeed == seed && i.StatTrak == st && i.Souvenir == sv, ct);
+ if (instance is not null)
+ {
+ instance.LastSeenAt = now;
+ return instance;
+ }
+
+ instance = new SkinInstance
+ {
+ SkinId = skinId,
+ ConditionId = conditionId,
+ FloatValue = floatValue,
+ PaintSeed = seed,
+ StatTrak = st,
+ Souvenir = sv,
+ FirstSeenAt = now,
+ LastSeenAt = now,
+ };
+ _db.SkinInstances.Add(instance);
+ return instance;
+ }
+
+ // Flag this skin+wear's once-Active listings we didn't see this run as Removed.
+ private async Task MarkRemovedAsync(
+ int skinId, int? conditionId, HashSet touched, DateTimeOffset now, CancellationToken ct)
+ {
+ return await _db.CsMoneyListings
+ .Where(l => l.SkinId == skinId
+ && l.ConditionId == conditionId
+ && l.Status == ListingStatus.Active
+ && !touched.Contains(l.SellOrderId))
+ .ExecuteUpdateAsync(setters => setters
+ .SetProperty(l => l.Status, ListingStatus.Removed)
+ .SetProperty(l => l.RemovedAt, now), ct);
+ }
+
+ // Same dupe signal as CSFloat: a fingerprint live under 2+ distinct asset ids at
+ // once. Considers cs.money listings only (cross-market dupe analysis is later).
+ private async Task FlagDupesAsync(HashSet instanceIds, DateTimeOffset now, CancellationToken ct)
+ {
+ if (instanceIds.Count == 0)
+ {
+ return;
+ }
+
+ var dupeInstanceIds = await _db.CsMoneyListings
+ .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;
+ }
+
+ 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("cs.money dupe detection: {Count} instance(s) newly flagged.", newlyFlagged);
+ }
+ }
+
+ private async Task StampCheckpointAsync(int? conditionId, DateTimeOffset now, CancellationToken ct)
+ {
+ if (conditionId is { } cid)
+ {
+ await _db.SkinConditions
+ .Where(c => c.Id == cid)
+ .ExecuteUpdateAsync(s => s.SetProperty(c => c.ListingsSweptAt, now), ct);
+ }
+ }
+
+ private static CsMoneyListing Map(CsMoneyItem it, int skinId, int? conditionId, DateTimeOffset now) => new()
+ {
+ SellOrderId = it.Id,
+ AssetId = it.Asset?.Id?.ToString(),
+ SkinId = skinId,
+ ConditionId = conditionId,
+ MarketHashName = it.Asset?.Names?.Full ?? it.Asset?.Names?.Short ?? "",
+ Quality = it.Asset?.Quality,
+ FloatValue = it.Asset?.Float,
+ PaintSeed = it.Asset?.Pattern,
+ Phase = it.Asset?.Phase,
+ IsStatTrak = it.Asset?.IsStatTrak ?? false,
+ IsSouvenir = it.Asset?.IsSouvenir ?? false,
+ StickerCount = it.Stickers?.Count(s => s is not null) ?? 0,
+ Price = it.Pricing?.Default ?? 0m,
+ PriceBeforeDiscount = it.Pricing?.PriceBeforeDiscount,
+ ComputedPrice = it.Pricing?.Computed,
+ Currency = "USD",
+ InspectLink = it.Links?.InspectLink,
+ FirstSeenAt = now,
+ LastSeenAt = now,
+ Status = ListingStatus.Active,
+ };
+
+ // Normalize a market name for matching: drop the StatTrak/Souvenir/★ adornments,
+ // collapse whitespace, lowercase. So "StatTrak™ M4A4 | Cyber Security" and the
+ // catalogue's "M4A4 | Cyber Security" compare equal.
+ private static string Normalize(string name)
+ {
+ var s = name
+ .Replace("★", " ", StringComparison.Ordinal)
+ .Replace("StatTrak™", " ", StringComparison.OrdinalIgnoreCase)
+ .Replace("Souvenir", " ", StringComparison.OrdinalIgnoreCase);
+ return string.Join(' ', s.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ .ToLowerInvariant();
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyJson.cs b/BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyJson.cs
new file mode 100644
index 0000000..2953940
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyJson.cs
@@ -0,0 +1,52 @@
+using System.Text.Json.Serialization;
+
+namespace BlueLaminate.Core.CsMoney;
+
+///
+/// The subset of a cs.money sell-orders item we persist, parsed from the
+/// JSON the Python worker scrapes. Decimals are parsed directly (not via double) so
+/// the full-precision float round-trips exactly into numeric(20,18).
+///
+public sealed class CsMoneyItem
+{
+ [JsonPropertyName("id")] public long Id { get; set; }
+ [JsonPropertyName("asset")] public CsMoneyAsset? Asset { get; set; }
+ [JsonPropertyName("pricing")] public CsMoneyPricing? Pricing { get; set; }
+ [JsonPropertyName("stickers")] public List? Stickers { get; set; }
+ [JsonPropertyName("links")] public CsMoneyLinks? Links { get; set; }
+}
+
+public sealed class CsMoneyAsset
+{
+ [JsonPropertyName("id")] public long? Id { get; set; }
+ [JsonPropertyName("names")] public CsMoneyNames? Names { get; set; }
+ [JsonPropertyName("isStatTrak")] public bool IsStatTrak { get; set; }
+ [JsonPropertyName("isSouvenir")] public bool IsSouvenir { get; set; }
+ [JsonPropertyName("quality")] public string? Quality { get; set; }
+ [JsonPropertyName("pattern")] public int? Pattern { get; set; }
+ [JsonPropertyName("phase")] public string? Phase { get; set; }
+ [JsonPropertyName("float")] public decimal? Float { get; set; }
+}
+
+public sealed class CsMoneyNames
+{
+ [JsonPropertyName("short")] public string? Short { get; set; }
+ [JsonPropertyName("full")] public string? Full { get; set; }
+}
+
+public sealed class CsMoneyPricing
+{
+ [JsonPropertyName("default")] public decimal Default { get; set; }
+ [JsonPropertyName("priceBeforeDiscount")] public decimal? PriceBeforeDiscount { get; set; }
+ [JsonPropertyName("computed")] public decimal? Computed { get; set; }
+}
+
+public sealed class CsMoneyLinks
+{
+ [JsonPropertyName("inspectLink")] public string? InspectLink { get; set; }
+}
+
+public sealed class CsMoneySticker
+{
+ [JsonPropertyName("name")] public string? Name { get; set; }
+}
diff --git a/BlueLaminate/BlueLaminate.Core/CsMoney/MarketPresenceService.cs b/BlueLaminate/BlueLaminate.Core/CsMoney/MarketPresenceService.cs
new file mode 100644
index 0000000..38bf295
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/CsMoney/MarketPresenceService.cs
@@ -0,0 +1,46 @@
+using BlueLaminate.EFCore.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace BlueLaminate.Core.CsMoney;
+
+/// One marketplace's current presence for a skin or a physical item.
+/// "csfloat", "csmoney", …
+/// Active listings on this market.
+/// Cheapest active listing (the comparable price).
+/// Dearest active listing.
+/// When this market was last observed to have it.
+public sealed record MarketPresence(
+ string Marketplace, int ActiveCount, decimal MinPrice, decimal MaxPrice, DateTimeOffset LastSeenAt);
+
+///
+/// Answers "where is this listed?" over the cross-market market_listings view.
+/// Per physical item () for the exact-copy / arbitrage /
+/// dupe view, or per catalogue skin () for "which markets
+/// carry this skin, and cheapest where".
+///
+public sealed class MarketPresenceService
+{
+ private const string Active = "Active";
+
+ private readonly SkinTrackerDbContext _db;
+
+ public MarketPresenceService(SkinTrackerDbContext db) => _db = db;
+
+ /// Markets currently listing this exact physical copy.
+ public Task> ForInstanceAsync(int skinInstanceId, CancellationToken ct = default) =>
+ _db.MarketListings
+ .Where(m => m.SkinInstanceId == skinInstanceId && m.Status == Active)
+ .GroupBy(m => m.Marketplace)
+ .Select(g => new MarketPresence(
+ g.Key, g.Count(), g.Min(x => x.Price), g.Max(x => x.Price), g.Max(x => x.LastSeenAt)))
+ .ToListAsync(ct);
+
+ /// Markets currently listing this skin (any wear), cheapest per market.
+ public Task> ForSkinAsync(int skinId, CancellationToken ct = default) =>
+ _db.MarketListings
+ .Where(m => m.SkinId == skinId && m.Status == Active)
+ .GroupBy(m => m.Marketplace)
+ .Select(g => new MarketPresence(
+ g.Key, g.Count(), g.Min(x => x.Price), g.Max(x => x.Price), g.Max(x => x.LastSeenAt)))
+ .ToListAsync(ct);
+}
diff --git a/BlueLaminate/BlueLaminate.Core/CsMoney/Wear.cs b/BlueLaminate/BlueLaminate.Core/CsMoney/Wear.cs
new file mode 100644
index 0000000..d2aabfb
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/CsMoney/Wear.cs
@@ -0,0 +1,21 @@
+namespace BlueLaminate.Core.CsMoney;
+
+///
+/// Maps between the catalogue's full wear names (SkinCondition.Condition) and
+/// cs.money's short wear codes (the quality field, also used in market search).
+///
+public static class Wear
+{
+ private static readonly Dictionary NameToCode = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["Factory New"] = "fn",
+ ["Minimal Wear"] = "mw",
+ ["Field-Tested"] = "ft",
+ ["Well-Worn"] = "ww",
+ ["Battle-Scarred"] = "bs",
+ };
+
+ /// "Field-Tested" → "ft". Null/unknown → null.
+ public static string? ToCode(string? conditionName) =>
+ conditionName is not null && NameToCode.TryGetValue(conditionName, out var code) ? code : null;
+}
diff --git a/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs b/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..bf7859b
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs
@@ -0,0 +1,120 @@
+using BlueLaminate.Core.Listings;
+using BlueLaminate.Core.Options;
+using BlueLaminate.Core.Skins;
+using BlueLaminate.EFCore.DependencyInjection;
+using BlueLaminate.Scraper.Browser;
+using BlueLaminate.Scraper.CsFloat;
+using BlueLaminate.Scraper.CsMoney;
+using BlueLaminate.Scraper.Proxies;
+using BlueLaminate.Scraper.Skins;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace BlueLaminate.Core.DependencyInjection;
+
+///
+/// The single composition root for BlueLaminate's application logic. Any frontend
+/// — the CLI today, a web UI later — wires up the whole stack with one call:
+/// services.AddBlueLaminateCore(configuration). Nothing about the database,
+/// the CSFloat client, or the sweep/sync services is duplicated per host.
+///
+public static class ServiceCollectionExtensions
+{
+ private const string CsFloatHttpClient = "csfloat";
+ private const string CatalogHttpClient = "catalog";
+
+ public static IServiceCollection AddBlueLaminateCore(
+ this IServiceCollection services,
+ IConfiguration configuration)
+ {
+ var connectionString = configuration.GetConnectionString("SkinTracker")
+ ?? throw new InvalidOperationException(
+ "Connection string 'SkinTracker' is not configured. Set it via user secrets (dev) "
+ + "or the ConnectionStrings__SkinTracker environment variable (prod).");
+
+ // Database (DbContext is registered scoped by the EFCore extension).
+ services.AddSkinTrackerData(connectionString);
+
+ // Options bound from configuration. The CsFloat API key falls back to the
+ // legacy CSFLOAT_API_KEY environment variable so existing setups keep working.
+ services.AddOptions()
+ .Bind(configuration.GetSection(CsFloatOptions.SectionName))
+ .Configure(o =>
+ {
+ if (string.IsNullOrWhiteSpace(o.ApiKey))
+ {
+ o.ApiKey = configuration["CSFLOAT_API_KEY"];
+ }
+ })
+ .ValidateDataAnnotations()
+ .ValidateOnStart();
+ services.AddOptions()
+ .Bind(configuration.GetSection(SkinCatalogOptions.SectionName));
+ services.AddOptions()
+ .Bind(configuration.GetSection(SweepOptions.SectionName));
+ services.AddOptions()
+ .Bind(configuration.GetSection(CsMoneyOptions.SectionName));
+
+ // Typed-handler pooling via IHttpClientFactory; clients are scoped so a
+ // command's handler and the service it drives share one instance (and thus
+ // the same LastRateLimit) within a single request scope.
+ services.AddHttpClient(CsFloatHttpClient, ConfigureHttpClient);
+ services.AddHttpClient(CatalogHttpClient, ConfigureHttpClient);
+
+ services.AddScoped(sp => new CsFloatListingsClient(
+ sp.GetRequiredService().CreateClient(CsFloatHttpClient),
+ sp.GetRequiredService>().Value,
+ sp.GetRequiredService>()));
+
+ services.AddScoped(sp => new SkinCatalogClient(
+ sp.GetRequiredService().CreateClient(CatalogHttpClient),
+ sp.GetRequiredService>().Value));
+
+ // Residential proxy provider (IPRoyal). Credentials come from configuration
+ // — IPROYAL_USERNAME / IPROYAL_PASSWORD env vars in practice. Resolution
+ // throws a clear error only when a proxy-using command actually needs it, so
+ // API-only commands (sync, fetch) run without proxy creds configured.
+ services.AddSingleton(sp =>
+ {
+ var username = configuration["IPROYAL_USERNAME"];
+ var password = configuration["IPROYAL_PASSWORD"];
+ if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
+ {
+ throw new InvalidOperationException(
+ "IPRoyal credentials are not configured. Set IPROYAL_USERNAME and "
+ + "IPROYAL_PASSWORD (env vars or user secrets) before running a proxy command.");
+ }
+
+ return new IpRoyalProxyProvider(username, password);
+ });
+
+ // cs.money is driven through a real, non-headless browser (Selenium/Edge,
+ // zero CDP) routed through a local forwarding proxy that chains to the
+ // residential gateway, not an HttpClient.
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped(sp => new CsMoneyCaptureService(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService>().Value,
+ sp.GetRequiredService>()));
+
+ // Application services (constructor injection; DbContext keeps them scoped).
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+
+ return services;
+ }
+
+ private static void ConfigureHttpClient(HttpClient http)
+ {
+ http.Timeout = TimeSpan.FromMinutes(2);
+ http.DefaultRequestHeaders.UserAgent.ParseAdd("BlueLaminate");
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.Core/Listings/CatalogSweepResult.cs b/BlueLaminate/BlueLaminate.Core/Listings/CatalogSweepResult.cs
new file mode 100644
index 0000000..776dd4a
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Listings/CatalogSweepResult.cs
@@ -0,0 +1,14 @@
+namespace BlueLaminate.Core.Listings;
+
+/// Wear-band sweeps fully paged this run (a skin contributes
+/// one per wear band, or one whole-skin sweep if it has no bands).
+/// Units 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);
diff --git a/BlueLaminate/BlueLaminate.Core/Listings/ListingSweepResult.cs b/BlueLaminate/BlueLaminate.Core/Listings/ListingSweepResult.cs
new file mode 100644
index 0000000..eea4c3a
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Listings/ListingSweepResult.cs
@@ -0,0 +1,17 @@
+namespace BlueLaminate.Core.Listings;
+
+/// 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);
diff --git a/BlueLaminate/BlueLaminate.Cli/ListingSweepService.cs b/BlueLaminate/BlueLaminate.Core/Listings/ListingSweepService.cs
similarity index 50%
rename from BlueLaminate/BlueLaminate.Cli/ListingSweepService.cs
rename to BlueLaminate/BlueLaminate.Core/Listings/ListingSweepService.cs
index 064e6bd..44f8e72 100644
--- a/BlueLaminate/BlueLaminate.Cli/ListingSweepService.cs
+++ b/BlueLaminate/BlueLaminate.Core/Listings/ListingSweepService.cs
@@ -1,38 +1,12 @@
+using BlueLaminate.Core.Options;
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using BlueLaminate.Scraper.CsFloat;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
-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);
+namespace BlueLaminate.Core.Listings;
///
/// Global incremental sweep of CSFloat active listings into the database. Pages
@@ -43,9 +17,10 @@ public sealed record CatalogSweepResult(
///
/// 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.
+/// - Pacing. After each page it waits a base courtesy delay plus
+/// random jitter so requests stay well under the limit and aren't perfectly
+/// regular; and it inspects the client's rate-limit headers, sleeping until the
+/// reset epoch when remaining is low 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"
@@ -57,22 +32,21 @@ 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;
+ private readonly SweepOptions _options;
public ListingSweepService(
SkinTrackerDbContext db,
CsFloatListingsClient client,
- ILogger logger)
+ ILogger logger,
+ IOptions options)
{
_db = db;
_client = client;
_logger = logger;
+ _options = options.Value;
}
/// Hard cap on API pages this run (rate-limit budget).
@@ -130,7 +104,7 @@ public sealed class ListingSweepService
{
page = await _client.FetchPageAsync(
defIndex: null, paintIndex: null, sortBy: "most_recent",
- limit: 50, cursor: cursor, ct: ct);
+ limit: _client.MaxLimit, cursor: cursor, ct: ct);
}
catch (CsFloatApiException ex)
{
@@ -155,8 +129,10 @@ public sealed class ListingSweepService
cursor = page.Cursor;
- // End of the market.
- if (string.IsNullOrEmpty(cursor) || page.Listings.Count == 0)
+ // End of the market. A short page (fewer than a full page) is the last
+ // one — the cursor points past the end, so fetching again would only burn
+ // a request on an empty response.
+ if (string.IsNullOrEmpty(cursor) || page.Listings.Count < _client.MaxLimit)
{
stoppedReason = "cursor exhausted";
break;
@@ -179,9 +155,13 @@ public sealed class ListingSweepService
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);
@@ -194,132 +174,261 @@ public sealed class ListingSweepService
///
/// 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.
+ /// their listings with a server-side def_index+paint_index filter, split by
+ /// wear band. Each skin_conditions row (one per overlapping wear tier,
+ /// with clamped float bounds) becomes its own unit, queried with the API's
+ /// min_float/max_float filter; skins with no wear bands (e.g. vanilla knives) are
+ /// swept whole. Splitting keeps even high-volume Covert skins to small,
+ /// independently-checkpointable units — an interrupted run resumes at wear-band
+ /// granularity rather than redoing a whole skin. Because each band is paged to
+ /// completion, Removed-tracking is accurate per band (scoped by wear name).
+ ///
+ /// Runs continuously until is cancelled (Ctrl+C):
+ /// it sweeps the whole catalogue, then loops and starts over. The unit list is
+ /// re-queried each pass, so newly-synced skins/bands are picked up and the
+ /// ordering (never-swept first, rarest first, then least-recently-swept) keeps
+ /// refreshing the stalest data. There is no request cap — request rate is bounded
+ /// only by , which sleeps when the rate-limit bucket runs
+ /// low so we never fire a request at zero remaining.
///
- /// 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";
+ var stoppedReason = "stopped";
- foreach (var skin in skins)
+ try
{
- if (pages >= maxRequests)
+ // Repeat the whole catalogue until cancelled. Re-querying each pass picks
+ // up newly-synced skins and re-orders by the latest ListingsSweptAt.
+ while (!ct.IsCancellationRequested)
{
- stoppedReason = $"hit max-requests cap ({maxRequests})";
- break;
- }
+ var now = DateTimeOffset.UtcNow;
- // 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)
+ var units = await BuildSweepUnitsAsync(ct);
+ if (units.Count == 0)
{
- stoppedReason = $"hit max-requests cap ({maxRequests})";
- skinComplete = false;
+ stoppedReason = "no catalogue skins to sweep";
break;
}
- ListingsPageResult page;
- try
+ var index = 0;
+ foreach (var unit in units)
{
- 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);
+ ct.ThrowIfCancellationRequested();
+ index++;
+
+ var wear = unit.Condition ?? "all wears";
+
+ // One-entry lookup so IngestPageAsync resolves SkinId to this skin.
+ var lookup = new Dictionary<(int, int), int> { [(unit.Def, unit.Paint)] = unit.SkinId };
+ var touchedIds = new HashSet();
+ var touchedInstanceIds = new HashSet();
+ string? cursor = null;
+
+ while (true)
+ {
+ ListingsPageResult page;
+ try
+ {
+ // min_float/max_float are null for whole-skin units (no wear
+ // bands); set, they restrict the page to this wear band.
+ page = await _client.FetchPageAsync(
+ defIndex: unit.Def, paintIndex: unit.Paint, sortBy: "lowest_price",
+ limit: _client.MaxLimit, cursor: cursor,
+ minFloat: unit.MinFloat, maxFloat: unit.MaxFloat, ct: ct);
+ }
+ catch (CsFloatApiException ex)
+ {
+ _logger.LogError(
+ "Catalogue sweep aborted on {Weapon} | {Skin} ({Wear}): {Message}",
+ unit.Weapon, unit.SkinName, wear, ex.Message);
+ await _db.SaveChangesAsync(CancellationToken.None);
+ return Finish($"API error: {ex.Status}");
+ }
+
+ pages++;
+ seen += page.Listings.Count;
+
+ var (ins, upd, _, _) = await IngestPageAsync(
+ page.Listings, lookup, touchedIds, touchedInstanceIds, now, ct);
+ inserted += ins;
+ updated += upd;
+
+ _logger.LogInformation(
+ "[{Index}/{Total}] {Weapon} | {Skin} ({Wear}): {Count} listings; {Remaining} requests remaining",
+ index, units.Count, unit.Weapon, unit.SkinName, wear, page.Listings.Count,
+ _client.LastRateLimit.Remaining);
+
+ cursor = page.Cursor;
+ // A short page (fewer than a full page of listings) is the last
+ // page: CSFloat still returns a cursor pointing past the end, so
+ // fetching again would only burn a request on an empty response.
+ if (string.IsNullOrEmpty(cursor) || page.Listings.Count < _client.MaxLimit)
+ {
+ break;
+ }
+
+ await PaceAsync(delayBetweenPages, ct);
+ }
+
+ // Persist this band's listings/instances before dupe analysis so the
+ // asset-id grouping query sees them.
await _db.SaveChangesAsync(ct);
- return Finish($"API error: {ex.Status}");
+ await FlagDupesAsync(touchedInstanceIds, now, ct);
+ await _db.SaveChangesAsync(ct);
+
+ // Each unit is paged to completion, so Removed-tracking is accurate.
+ // Scope it to the wear band (by wear name) so sweeping one band never
+ // false-removes another band's listings of the same skin. Then stamp
+ // the band's checkpoint so it leaves the never-swept queue.
+ if (unit.ConditionId is { } conditionId)
+ {
+ removed += await MarkRemovedForSkinConditionAsync(
+ unit.SkinId, unit.Condition!, touchedIds, now, ct);
+ await _db.SkinConditions
+ .Where(c => c.Id == conditionId)
+ .ExecuteUpdateAsync(
+ setters => setters.SetProperty(c => c.ListingsSweptAt, now), ct);
+ }
+ else
+ {
+ removed += await MarkRemovedForSkinAsync(unit.SkinId, touchedIds, now, ct);
+ await _db.Skins
+ .Where(s => s.Id == unit.SkinId)
+ .ExecuteUpdateAsync(
+ setters => setters.SetProperty(s => s.ListingsSweptAt, now), ct);
+ }
+
+ covered++;
+
+ await PaceAsync(delayBetweenPages, ct);
}
- 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);
+ _logger.LogInformation(
+ "Completed a full catalogue pass ({Covered} wear-band sweeps so far); restarting from the stalest.",
+ covered);
}
-
- // 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);
+ }
+ catch (OperationCanceledException)
+ {
+ stoppedReason = "stopped (cancellation requested)";
}
+ // Final bookkeeping with a non-cancellable token so the run is always recorded.
await _db.ScrapeRuns.AddAsync(
- new ScrapeRun { Source = CatalogSource, RanAt = now, ItemCount = seen }, ct);
- await _db.SaveChangesAsync(ct);
+ new ScrapeRun { Source = CatalogSource, RanAt = DateTimeOffset.UtcNow, ItemCount = seen },
+ CancellationToken.None);
+ await _db.SaveChangesAsync(CancellationToken.None);
return Finish(stoppedReason);
CatalogSweepResult Finish(string reason) =>
- new(covered, skins.Count - covered, pages, seen, inserted, updated, removed, reason);
+ new(covered, 0, pages, seen, inserted, updated, removed, reason);
+ }
+
+ // Rank a skin's rarity tier high→low so sweeps process the rarest (and least
+ // abundant) skins first. Names come from the CSGO-API catalogue; an unknown
+ // value ranks lowest so it's swept last rather than jumping the queue.
+ private static int RarityRank(string rarity) => rarity switch
+ {
+ "Extraordinary" => 8, // knives & gloves
+ "Contraband" => 7, // e.g. M4A4 | Howl
+ "Covert" => 6,
+ "Classified" => 5,
+ "Restricted" => 4,
+ "Mil-Spec Grade" => 3,
+ "Industrial Grade" => 2,
+ "Consumer Grade" => 1,
+ _ => 0,
+ };
+
+ // One unit of catalogue-sweep work: a skin filtered to a single wear band, or a
+ // whole skin when it has no bands. Float bounds + ConditionId are null for the
+ // whole-skin case (tracked by Skin.ListingsSweptAt instead). SweptAt drives the
+ // never-swept-first / stalest-first ordering.
+ private sealed record SweepUnit(
+ int SkinId,
+ int Def,
+ int Paint,
+ string SkinName,
+ string Weapon,
+ string Rarity,
+ int? ConditionId,
+ string? Condition,
+ decimal? MinFloat,
+ decimal? MaxFloat,
+ DateTimeOffset? SweptAt);
+
+ // Build and order this pass's sweep units. Each skin with def/paint indexes
+ // contributes one unit per wear band (skin_conditions row), or a single
+ // whole-skin unit if it has no bands (e.g. vanilla knives with no float range) —
+ // so those skins keep being swept rather than silently dropping out.
+ //
+ // Ordering, in priority:
+ // 1. never-swept first — so a restart resumes rather than redoing swept bands;
+ // 2. highest rarity first — rare skins (Covert/knives/gloves) have few listings,
+ // so capture them before the mass-quantity low grades;
+ // 3. least-recently-swept — refresh the stalest data first;
+ // 4. then by skin and ascending float — keeps a skin's bands contiguous and in
+ // FN→BS order ("wear within skin").
+ // Sorted in memory because rarity rank isn't a database column; the catalogue is
+ // small (~2k skins) so this is negligible.
+ private async Task
> BuildSweepUnitsAsync(CancellationToken ct)
+ {
+ var skins = await _db.Skins
+ .Where(s => s.DefIndex != null && s.PaintIndex != null)
+ .Select(s => new
+ {
+ s.Id,
+ Def = s.DefIndex!.Value,
+ Paint = s.PaintIndex!.Value,
+ s.Name,
+ Weapon = s.Weapon.Name,
+ s.Rarity,
+ s.ListingsSweptAt,
+ Conditions = s.Conditions
+ .Select(c => new { c.Id, c.Condition, c.MinFloat, c.MaxFloat, c.ListingsSweptAt })
+ .ToList(),
+ })
+ .ToListAsync(ct);
+
+ var units = new List();
+ foreach (var s in skins)
+ {
+ if (s.Conditions.Count == 0)
+ {
+ units.Add(new SweepUnit(
+ s.Id, s.Def, s.Paint, s.Name, s.Weapon, s.Rarity,
+ ConditionId: null, Condition: null, MinFloat: null, MaxFloat: null,
+ SweptAt: s.ListingsSweptAt));
+ continue;
+ }
+
+ foreach (var c in s.Conditions)
+ {
+ units.Add(new SweepUnit(
+ s.Id, s.Def, s.Paint, s.Name, s.Weapon, s.Rarity,
+ ConditionId: c.Id, Condition: c.Condition,
+ MinFloat: c.MinFloat, MaxFloat: c.MaxFloat,
+ SweptAt: c.ListingsSweptAt));
+ }
+ }
+
+ return units
+ .OrderBy(u => u.SweptAt != null)
+ .ThenByDescending(u => RarityRank(u.Rarity))
+ .ThenBy(u => u.SweptAt)
+ .ThenBy(u => u.SkinId)
+ .ThenBy(u => u.MinFloat)
+ .ToList();
}
// Flag this skin's once-Active listings that we didn't see this run as Removed.
@@ -337,6 +446,25 @@ public sealed class ListingSweepService
ct);
}
+ // Wear-band-scoped Removed-tracking: flag only this skin's once-Active listings in
+ // the given wear band that we didn't see this run. Scoping by wear name (CSFloat's
+ // authoritative tier, identical to skin_conditions.condition) means sweeping one
+ // band can't false-remove listings from the skin's other bands.
+ private async Task MarkRemovedForSkinConditionAsync(
+ int skinId, string wearName, HashSet touchedIds, DateTimeOffset now, CancellationToken ct)
+ {
+ return await _db.Listings
+ .Where(l => l.SkinId == skinId
+ && l.WearName == wearName
+ && 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
@@ -350,7 +478,9 @@ public sealed class ListingSweepService
CancellationToken ct)
{
if (listings.Count == 0)
+ {
return (0, 0, 0, true);
+ }
var ids = listings.Select(l => l.ListingId).ToList();
var existing = await _db.Listings
@@ -367,7 +497,9 @@ public sealed class ListingSweepService
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.
@@ -375,7 +507,9 @@ public sealed class ListingSweepService
? await ResolveInstanceAsync(sid, l, now, ct)
: null;
if (instance is not null)
+ {
touchedInstanceIds.Add(instance.Id);
+ }
if (existing.TryGetValue(l.ListingId, out var row))
{
@@ -499,7 +633,9 @@ public sealed class ListingSweepService
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
@@ -513,7 +649,9 @@ public sealed class ListingSweepService
.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).
@@ -526,31 +664,68 @@ public sealed class ListingSweepService
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.
+ // until the window resets (or a fallback cooldown) so we never fire a request
+ // at zero remaining. Otherwise apply a base courtesy delay plus random jitter so
+ // we stay well under the limit and never poll at a fixed cadence.
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))
+ if (rate.Remaining is { } remaining && remaining <= _options.RateLimitSafetyMargin)
{
- var resetAt = DateTimeOffset.FromUnixTimeSeconds(resetEpoch);
- var wait = resetAt - DateTimeOffset.UtcNow;
- if (wait > TimeSpan.Zero)
+ var wait = ResetWait(rate) ?? _options.RateLimitCooldown;
+ _logger.LogWarning(
+ "Rate limit nearly exhausted ({Remaining} left); sleeping {Seconds:0}s before next request.",
+ remaining, wait.TotalSeconds);
+ await Task.Delay(wait, ct);
+ return;
+ }
+
+ var courtesy = (delay ?? _options.PageDelay) + RandomJitter();
+ if (courtesy > TimeSpan.Zero)
+ {
+ _logger.LogDebug("Pacing {Seconds:0.0}s before next page.", courtesy.TotalSeconds);
+ await Task.Delay(courtesy, ct);
+ }
+ }
+
+ // Time until the rate-limit window resets, if the API reported a usable value.
+ // Reset is documented as unverified (epoch seconds vs seconds-until), so try the
+ // epoch interpretation first, then seconds-until, then Retry-After. Returns null
+ // when nothing usable was reported, so the caller applies a fallback cooldown.
+ private static TimeSpan? ResetWait(CsFloatRateLimit rate)
+ {
+ if (long.TryParse(rate.Reset, out var reset) && reset > 0)
+ {
+ var asEpoch = DateTimeOffset.FromUnixTimeSeconds(reset) - DateTimeOffset.UtcNow;
+ if (asEpoch > TimeSpan.Zero && asEpoch < TimeSpan.FromHours(1))
{
- _logger.LogWarning(
- "Rate limit nearly exhausted ({Remaining} left); sleeping {Seconds:0}s until reset.",
- remaining, wait.TotalSeconds);
- await Task.Delay(wait, ct);
- return;
+ return asEpoch;
+ }
+
+ var asDelta = TimeSpan.FromSeconds(reset);
+ if (asDelta > TimeSpan.Zero && asDelta < TimeSpan.FromHours(1))
+ {
+ return asDelta;
}
}
- if (delay is { } d && d > TimeSpan.Zero)
- await Task.Delay(d, ct);
+ if (rate.RetryAfter is { } retry && retry > 0)
+ {
+ return TimeSpan.FromSeconds(retry);
+ }
+
+ return null;
}
+
+ // A random delay in [0, MaxJitter] added to the base courtesy delay. Random.Shared
+ // is thread-safe; the spread keeps our request timing from being perfectly regular.
+ private TimeSpan RandomJitter() =>
+ _options.MaxJitter * Random.Shared.NextDouble();
}
diff --git a/BlueLaminate/BlueLaminate.Core/Options/SweepOptions.cs b/BlueLaminate/BlueLaminate.Core/Options/SweepOptions.cs
new file mode 100644
index 0000000..561db93
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Options/SweepOptions.cs
@@ -0,0 +1,36 @@
+namespace BlueLaminate.Core.Options;
+
+///
+/// Pacing configuration for the listing sweeps, bound from the Sweep
+/// configuration section. Controls how the sweep throttles itself between API
+/// pages so it stays under CSFloat's rate limit. Defaults preserve the original
+/// hard-coded behaviour.
+///
+public sealed class SweepOptions
+{
+ public const string SectionName = "Sweep";
+
+ ///
+ /// Base courtesy delay between pages, applied even when the rate-limit bucket
+ /// looks healthy so we never hammer the API at a fixed cadence.
+ ///
+ public TimeSpan PageDelay { get; set; } = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Upper bound on the random jitter added to ; the
+ /// spread keeps request timing from being perfectly regular.
+ ///
+ public TimeSpan MaxJitter { get; set; } = TimeSpan.FromSeconds(3);
+
+ ///
+ /// Pace before the rate-limit bucket is fully empty by this many requests, so
+ /// a slightly-stale counter can't tip us into a 429.
+ ///
+ public int RateLimitSafetyMargin { get; set; } = 2;
+
+ ///
+ /// Fallback wait when the bucket is exhausted but the API didn't report a usable
+ /// reset time. Guarantees we never fire a request at zero remaining.
+ ///
+ public TimeSpan RateLimitCooldown { get; set; } = TimeSpan.FromSeconds(60);
+}
diff --git a/BlueLaminate/BlueLaminate.Core/Skins/SkinSyncResult.cs b/BlueLaminate/BlueLaminate.Core/Skins/SkinSyncResult.cs
new file mode 100644
index 0000000..d2b577a
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/Skins/SkinSyncResult.cs
@@ -0,0 +1,12 @@
+namespace BlueLaminate.Core.Skins;
+
+/// True when the monthly throttle suppressed the run.
+/// When the previous successful run happened, if any.
+public sealed record SkinSyncResult(
+ bool Skipped,
+ DateTimeOffset? LastRanAt,
+ int Loaded,
+ int Inserted,
+ int Updated,
+ int WeaponsCreated,
+ int CollectionsCreated);
diff --git a/BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs b/BlueLaminate/BlueLaminate.Core/Skins/SkinSyncService.cs
similarity index 94%
rename from BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs
rename to BlueLaminate/BlueLaminate.Core/Skins/SkinSyncService.cs
index 4c4847f..ed1b730 100644
--- a/BlueLaminate/BlueLaminate.Cli/SkinSyncService.cs
+++ b/BlueLaminate/BlueLaminate.Core/Skins/SkinSyncService.cs
@@ -4,18 +4,7 @@ using BlueLaminate.Scraper.Skins;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
-namespace BlueLaminate.Cli;
-
-/// True when the monthly throttle suppressed the run.
-/// When the previous successful run happened, if any.
-public sealed record SkinSyncResult(
- bool Skipped,
- DateTimeOffset? LastRanAt,
- int Loaded,
- int Inserted,
- int Updated,
- int WeaponsCreated,
- int CollectionsCreated);
+namespace BlueLaminate.Core.Skins;
///
/// Loads the CS2 skin catalogue from the CSGO-API dataset and upserts it. The
@@ -82,7 +71,9 @@ public sealed class SkinSyncService
if (existing.TryGetValue(s.Id, out var skin))
{
if (Apply(skin, s, weapon, sources))
+ {
updated++;
+ }
}
else
{
@@ -172,7 +163,9 @@ public sealed class SkinSyncService
Set(() => skin.FloatMax, v => skin.FloatMax = v, s.FloatMax);
if (ReconcileCollections(skin.Collections, sources))
+ {
changed = true;
+ }
return changed;
}
diff --git a/BlueLaminate/BlueLaminate.EFCore/BlueLaminate.EFCore.csproj b/BlueLaminate/BlueLaminate.EFCore/BlueLaminate.EFCore.csproj
index 59b7ed7..ae6b74f 100644
--- a/BlueLaminate/BlueLaminate.EFCore/BlueLaminate.EFCore.csproj
+++ b/BlueLaminate/BlueLaminate.EFCore/BlueLaminate.EFCore.csproj
@@ -8,23 +8,29 @@
-
+
+
-
+
-
-
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
-
-
-
-
+
+
+
+
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/CsMoneyListingConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/CsMoneyListingConfiguration.cs
new file mode 100644
index 0000000..d11b365
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/CsMoneyListingConfiguration.cs
@@ -0,0 +1,47 @@
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BlueLaminate.EFCore.Configurations;
+
+public class CsMoneyListingConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder entity)
+ {
+ // cs.money's sell-order id is the natural key; ingest upserts against it and
+ // must never create duplicates.
+ entity.HasIndex(e => e.SellOrderId).IsUnique();
+
+ entity.Property(e => e.Price).HasPrecision(18, 2);
+ entity.Property(e => e.PriceBeforeDiscount).HasPrecision(18, 2);
+ entity.Property(e => e.ComputedPrice).HasPrecision(18, 2);
+ // Full precision to match SkinInstance for exact fingerprint joins.
+ entity.Property(e => e.FloatValue).HasColumnType("numeric(20,18)");
+
+ // Enum as text so the DB is self-describing (matches the project's leaning).
+ entity.Property(e => e.Status).HasConversion();
+
+ // Targeted scrape: results are filtered/sorted by skin+wear and by activity.
+ entity.HasIndex(e => new { e.SkinId, e.ConditionId });
+ entity.HasIndex(e => e.Status);
+ entity.HasIndex(e => e.AssetId);
+
+ // Each job targets a known skin, so this link is required (Restrict: a skin
+ // with live listings shouldn't be deleted out from under them).
+ entity.HasOne(e => e.Skin)
+ .WithMany()
+ .HasForeignKey(e => e.SkinId)
+ .OnDelete(DeleteBehavior.Restrict);
+
+ entity.HasOne(e => e.Condition)
+ .WithMany()
+ .HasForeignKey(e => e.ConditionId)
+ .OnDelete(DeleteBehavior.SetNull);
+
+ // Listings roll up to the physical item they represent (shared with CSFloat).
+ entity.HasOne(e => e.SkinInstance)
+ .WithMany()
+ .HasForeignKey(e => e.SkinInstanceId)
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/MarketListingConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/MarketListingConfiguration.cs
new file mode 100644
index 0000000..d500736
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/MarketListingConfiguration.cs
@@ -0,0 +1,17 @@
+using BlueLaminate.EFCore.Data;
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BlueLaminate.EFCore.Configurations;
+
+public class MarketListingConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder entity)
+ {
+ // Backed by the market_listings SQL view (created in a migration), not a
+ // table — so it's keyless and read-only through EF.
+ entity.HasNoKey();
+ entity.ToView("market_listings", SkinTrackerDbContext.Schema);
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionConfiguration.cs
index b262006..901b550 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionConfiguration.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionConfiguration.cs
@@ -11,6 +11,10 @@ public class SkinConditionConfiguration : IEntityTypeConfiguration e.MinFloat).HasColumnType("numeric(10,9)");
entity.Property(e => e.MaxFloat).HasColumnType("numeric(10,9)");
+ // The catalogue sweep orders bands by this (never-swept first, then stalest),
+ // so index it like the equivalent column on skins.
+ entity.HasIndex(e => e.ListingsSweptAt);
+
entity.HasOne(e => e.Skin)
.WithMany(s => s.Conditions)
.HasForeignKey(e => e.SkinId);
diff --git a/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs b/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
index eeda481..310e20e 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
@@ -30,6 +30,10 @@ public class SkinTrackerDbContext : DbContext
public DbSet TradeItems => Set();
public DbSet PriceHistories => Set();
public DbSet Listings => Set();
+ public DbSet CsMoneyListings => Set();
+
+ /// Read-only cross-market view UNIONing the per-market listing tables.
+ public DbSet MarketListings => Set();
/// The PostgreSQL schema that owns all of this context's tables.
public const string Schema = "skintracker";
@@ -50,5 +54,7 @@ public class SkinTrackerDbContext : DbContext
modelBuilder.ApplyConfiguration(new TradeItemConfiguration());
modelBuilder.ApplyConfiguration(new PriceHistoryConfiguration());
modelBuilder.ApplyConfiguration(new ListingConfiguration());
+ modelBuilder.ApplyConfiguration(new CsMoneyListingConfiguration());
+ modelBuilder.ApplyConfiguration(new MarketListingConfiguration());
}
}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/CsMoneyListing.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/CsMoneyListing.cs
new file mode 100644
index 0000000..62dd142
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/CsMoneyListing.cs
@@ -0,0 +1,67 @@
+namespace BlueLaminate.EFCore.Entities;
+
+///
+/// One sell-order observed on cs.money via its internal
+/// GET /2.0/market/sell-orders endpoint (scraped through the Python worker,
+/// since cs.money has no public API and sits behind Cloudflare).
+///
+/// Kept in its own table rather than shared with the CSFloat :
+/// cs.money exposes a different shape (its own sell-order id, a pricing breakdown,
+/// quality/phase, and no def/paint index). It still links to the
+/// market-agnostic by fingerprint, so the same physical
+/// item seen on both markets rolls up to one instance for cross-market analysis.
+///
+/// Soft-tracked across sweeps exactly like :
+/// / bound the observation window
+/// and flips to when a
+/// once-seen order stops appearing (sold/delisted).
+///
+public class CsMoneyListing
+{
+ public int Id { get; set; }
+
+ /// cs.money's sell-order id (item.id). Natural key for dedup.
+ public long SellOrderId { get; set; }
+
+ ///
+ /// cs.money's asset id for the listed copy. Not a stable identity, but the
+ /// discriminator that distinguishes duped copies sharing one fingerprint.
+ ///
+ public string? AssetId { get; set; }
+
+ // Catalogue links. Unlike the CSFloat global sweep these are NOT best-effort:
+ // each scrape job targets one skin+wear, so the worker reports which Skin/
+ // Condition the results belong to and 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; }
+
+ /// The physical item (by fingerprint), shared with CSFloat listings.
+ public int? SkinInstanceId { get; set; }
+ public SkinInstance? SkinInstance { get; set; }
+
+ // Item identity, from the listing's asset block.
+ public string MarketHashName { get; set; } = null!;
+ public string? Quality { get; set; } // cs.money wear short code: fn/mw/ft/ww/bs
+ public decimal? FloatValue { get; set; } // null for non-skin items
+ public int? PaintSeed { get; set; } // asset.pattern
+ public string? Phase { get; set; } // doppler phase (sapphire/ruby/…)
+ public bool IsStatTrak { get; set; }
+ public bool IsSouvenir { get; set; }
+ public int StickerCount { get; set; }
+
+ // Pricing. cs.money returns a breakdown; Price is the actual asking price.
+ public decimal Price { get; set; } // pricing.default
+ public decimal? PriceBeforeDiscount { get; set; }
+ public decimal? ComputedPrice { get; set; } // pricing.computed (reference price)
+ public string Currency { get; set; } = "USD"; // cs.money returns no currency field
+
+ public string? InspectLink { get; set; }
+
+ // Soft-tracking across sweeps.
+ public DateTimeOffset FirstSeenAt { get; set; }
+ public DateTimeOffset LastSeenAt { get; set; }
+ public ListingStatus Status { get; set; }
+ public DateTimeOffset? RemovedAt { get; set; }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/MarketListing.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/MarketListing.cs
new file mode 100644
index 0000000..4432345
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/MarketListing.cs
@@ -0,0 +1,45 @@
+namespace BlueLaminate.EFCore.Entities;
+
+///
+/// Read-model over the market_listings SQL view, which UNIONs every per-market
+/// listing table (CSFloat , , and any
+/// future market) tagged with its . This is how we answer
+/// "where is this listed?" — by for one physical copy,
+/// or by for a skin — without merging the source tables.
+/// Keyless: it's a view, never inserted/updated through EF.
+///
+public class MarketListing
+{
+ /// Which market this row came from: "csfloat", "csmoney", …
+ public string Marketplace { get; set; } = null!;
+
+ /// The source market's own listing id (as text), for traceability.
+ public string ExternalId { get; set; } = null!;
+
+ public int? SkinId { get; set; }
+ public int? ConditionId { get; set; }
+
+ /// The market-agnostic physical item — the key that bridges markets.
+ public int? SkinInstanceId { get; set; }
+
+ public string MarketHashName { get; set; } = null!;
+ public string? Wear { 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 decimal Price { get; set; }
+ public string Currency { get; set; } = null!;
+
+ public string? InspectLink { get; set; }
+ public string? AssetId { get; set; }
+
+ /// "Active" or "Removed" (text, from each source's status).
+ public string Status { get; set; } = null!;
+
+ public DateTimeOffset FirstSeenAt { get; set; }
+ public DateTimeOffset LastSeenAt { get; set; }
+ public DateTimeOffset? RemovedAt { get; set; }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinCondition.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinCondition.cs
index e3525fb..b918a82 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinCondition.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinCondition.cs
@@ -10,6 +10,12 @@ public class SkinCondition
public decimal MinFloat { get; set; }
public decimal MaxFloat { 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; }
+
public ICollection Instances { get; set; } = new List();
public ICollection PriceHistories { get; set; } = new List();
}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530222302_AddSkinConditionListingsSweptAt.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530222302_AddSkinConditionListingsSweptAt.Designer.cs
new file mode 100644
index 0000000..d418349
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530222302_AddSkinConditionListingsSweptAt.Designer.cs
@@ -0,0 +1,875 @@
+//
+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("20260530222302_AddSkinConditionListingsSweptAt")]
+ partial class AddSkinConditionListingsSweptAt
+ {
+ ///
+ 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("AssetId")
+ .HasColumnType("text")
+ .HasColumnName("asset_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(20,18)")
+ .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("SkinInstanceId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_instance_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("AssetId")
+ .HasDatabaseName("ix_listings_asset_id");
+
+ b.HasIndex("CsFloatListingId")
+ .IsUnique()
+ .HasDatabaseName("ix_listings_cs_float_listing_id");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_listings_skin_id");
+
+ b.HasIndex("SkinInstanceId")
+ .HasDatabaseName("ix_listings_skin_instance_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("Condition")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("condition");
+
+ b.Property("ListingsSweptAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("listings_swept_at");
+
+ 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("ListingsSweptAt")
+ .HasDatabaseName("ix_skin_conditions_listings_swept_at");
+
+ 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("DupeFirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("dupe_first_seen_at");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric(20,18)")
+ .HasColumnName("float_value");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at");
+
+ 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.Property("SuspectedDupe")
+ .HasColumnType("boolean")
+ .HasColumnName("suspected_dupe");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_instances");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_skin_instances_condition_id");
+
+ b.HasIndex("SuspectedDupe")
+ .HasDatabaseName("ix_skin_instances_suspected_dupe");
+
+ b.HasIndex("SkinId", "FloatValue", "PaintSeed", "StatTrak", "Souvenir")
+ .HasDatabaseName("ix_skin_instances_skin_id_float_value_paint_seed_stat_trak_sou");
+
+ 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.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
+ .WithMany("Listings")
+ .HasForeignKey("SkinInstanceId")
+ .OnDelete(DeleteBehavior.SetNull)
+ .HasConstraintName("fk_listings_skin_instances_skin_instance_id");
+
+ b.Navigation("Skin");
+
+ b.Navigation("SkinInstance");
+ });
+
+ 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.SetNull)
+ .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");
+
+ b.Navigation("Listings");
+ });
+
+ 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/20260530222302_AddSkinConditionListingsSweptAt.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530222302_AddSkinConditionListingsSweptAt.cs
new file mode 100644
index 0000000..3d25a22
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260530222302_AddSkinConditionListingsSweptAt.cs
@@ -0,0 +1,42 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace BlueLaminate.EFCore.Migrations
+{
+ ///
+ public partial class AddSkinConditionListingsSweptAt : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "listings_swept_at",
+ schema: "skintracker",
+ table: "skin_conditions",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_skin_conditions_listings_swept_at",
+ schema: "skintracker",
+ table: "skin_conditions",
+ column: "listings_swept_at");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropIndex(
+ name: "ix_skin_conditions_listings_swept_at",
+ schema: "skintracker",
+ table: "skin_conditions");
+
+ migrationBuilder.DropColumn(
+ name: "listings_swept_at",
+ schema: "skintracker",
+ table: "skin_conditions");
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260531022448_AddCsMoneyListing.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260531022448_AddCsMoneyListing.Designer.cs
new file mode 100644
index 0000000..a1d0e0b
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260531022448_AddCsMoneyListing.Designer.cs
@@ -0,0 +1,1031 @@
+//
+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("20260531022448_AddCsMoneyListing")]
+ partial class AddCsMoneyListing
+ {
+ ///
+ 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.CsMoneyListing", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property