diff --git a/.gitignore b/.gitignore
index d9d409e..255583b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -101,6 +101,8 @@ env/
# cs.money discovery capture dumps (JSON responses)
csmoney-captures/
+# API response capture dumps (CSFloat schema/listing samples, worker page dumps)
+captures/
# Local compose secrets (DB connection string, tokens)
.env
diff --git a/BlueLaminate/BlueLaminate.C2/Contracts.cs b/BlueLaminate/BlueLaminate.C2/Contracts.cs
index 8dd799b..4467176 100644
--- a/BlueLaminate/BlueLaminate.C2/Contracts.cs
+++ b/BlueLaminate/BlueLaminate.C2/Contracts.cs
@@ -1,4 +1,5 @@
using BlueLaminate.Core.CsMoney;
+using BlueLaminate.Core.SkinLand;
namespace BlueLaminate.C2;
@@ -17,3 +18,20 @@ public sealed record ScrapeJobDto(string JobId, int SkinId, int? ConditionId, st
/// 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);
+
+/// A unit of skin.land scrape work: one skin+wear, as its market page URL.
+/// Opaque id the worker echoes back when posting results.
+/// Catalogue skin this job targets.
+/// Wear band (skin_conditions row).
+/// The skin.land market page, e.g.
+/// "https://skin.land/market/csgo/ak-47-redline-field-tested/". The worker resolves the
+/// internal skin_id from this page, then pages the obtained-skins API.
+/// Safety cap on offer-page fetches (Laravel paginator, ~26/page).
+public sealed record SkinLandJobDto(string JobId, int SkinId, int ConditionId, string Url, int MaxPages);
+
+/// A worker's results for a claimed skin.land job: the offers it scraped.
+/// All obtained-skins offers gathered across pages (raw skin.land shape).
+/// How many offer pages the worker fetched.
+/// Why it stopped. "completed" = full sweep (authoritative);
+/// anything else (fetch-cap / challenged / no-skin-id) is partial.
+public sealed record SkinLandResultDto(List Items, int Pages, string? StoppedReason);
diff --git a/BlueLaminate/BlueLaminate.C2/JobQueue.cs b/BlueLaminate/BlueLaminate.C2/JobQueue.cs
index 7406662..66de313 100644
--- a/BlueLaminate/BlueLaminate.C2/JobQueue.cs
+++ b/BlueLaminate/BlueLaminate.C2/JobQueue.cs
@@ -1,5 +1,4 @@
using System.Collections.Concurrent;
-using BlueLaminate.Core.CsMoney;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
@@ -7,42 +6,58 @@ 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.
+/// catalogue's per-band, per-site checkpoints (the rows in skin_condition_sweeps
+/// for this queue's ) 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 the work target. On completion the ingest stamps the band's
+/// checkpoint, so it drops to the back — the sweep loops the whole catalogue continuously
+/// and resumes cleanly after restarts. Because the checkpoint is per-site, a band one
+/// market just swept is still due on another.
+///
+/// The queue is source-agnostic: it's constructed with the checkpoint
+/// and a that turns a band into the
+/// thing a worker needs — a free-text search for cs.money, a market URL for skin.land — so
+/// one class drives every market. Register one instance per source.
+///
///
/// A floor keeps a band from being re-handed-out until
-/// its data is at least that stale. Without it the queue re-scrapes the whole catalogue
-/// as fast as the workers run, which on a metered residential proxy is the dominant cost;
-/// the floor trades a little price-freshness for a roughly linear bandwidth cut (a 6h
-/// floor vs. continuous ≈ 6× less, if a full pass takes ~1h). When every band is fresher
-/// than the floor the queue hands out nothing (workers idle) until one ages past it.
+/// its data is at least that stale. Without it the queue re-scrapes the whole catalogue as
+/// fast as the workers run, which on a metered residential proxy is the dominant cost; the
+/// floor trades a little price-freshness for a roughly linear bandwidth cut. When every
+/// band is fresher than the floor the queue hands out nothing (workers idle) until one ages.
///
///
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).
+ // 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 string _source;
private readonly TimeSpan _minResweepInterval;
+ private readonly Func _targetBuilder;
private readonly SemaphoreSlim _gate = new(1, 1);
private readonly ConcurrentDictionary _leases = new(); // conditionId -> leasedAt
private readonly ConcurrentDictionary _inFlight = new(); // jobId -> mapping
+ ///
+ /// The skin_condition_sweeps.Source this queue reads/leases on (a
+ /// SweepSource value, e.g. "csmoney" / "skinland").
+ ///
///
- /// How stale a band's ListingsSweptAt must be before it's eligible again.
+ /// How stale a band's checkpoint must be before it's eligible again.
/// disables the floor (continuous re-sweep).
///
- public JobQueue(TimeSpan minResweepInterval)
+ /// Turns a claimed band into the worker's target string.
+ public JobQueue(string source, TimeSpan minResweepInterval, Func targetBuilder)
{
+ _source = source;
_minResweepInterval = minResweepInterval;
+ _targetBuilder = targetBuilder;
}
- public async Task ClaimNextAsync(SkinTrackerDbContext db, int maxPages, CancellationToken ct)
+ public async Task ClaimNextAsync(SkinTrackerDbContext db, int maxPages, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
@@ -58,17 +73,26 @@ public sealed class JobQueue
}
// Only consider bands that are never-swept or stale past the re-sweep floor,
- // then stalest first (never-swept null sorts before any timestamp). With the
- // floor in place a fully-fresh catalogue yields no candidates, so workers idle
- // instead of needlessly re-pulling ~1MB pages on the metered proxy.
+ // then stalest first (never-swept null sorts before any timestamp). The
+ // checkpoint is read for THIS queue's source only (a correlated subquery over
+ // the per-site sweep rows), so a band another market just swept is still
+ // never-swept here. With the floor in place a fully-fresh catalogue yields no
+ // candidates, so workers idle instead of needlessly re-pulling on the proxy.
var freshCutoff = DateTimeOffset.UtcNow - _minResweepInterval;
var candidates = await db.SkinConditions
- .Where(c => c.ListingsSweptAt == null || c.ListingsSweptAt <= freshCutoff)
- .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))
+ .Select(c => new
+ {
+ Candidate = new Candidate(c.Id, c.SkinId, c.Skin.Weapon.Name, c.Skin.Name, c.Condition),
+ SweptAt = c.Sweeps
+ .Where(s => s.Source == _source)
+ .Select(s => (DateTimeOffset?)s.SweptAt)
+ .FirstOrDefault(),
+ })
+ .Where(x => x.SweptAt == null || x.SweptAt <= freshCutoff)
+ .OrderBy(x => x.SweptAt.HasValue)
+ .ThenBy(x => x.SweptAt)
.Take(CandidateBatch)
+ .Select(x => x.Candidate)
.ToListAsync(ct);
var pick = candidates.FirstOrDefault(c => !_leases.ContainsKey(c.ConditionId));
@@ -81,9 +105,7 @@ public sealed class JobQueue
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);
+ return new ClaimedJob(jobId, pick.SkinId, pick.ConditionId, _targetBuilder(pick), maxPages);
}
finally
{
@@ -107,5 +129,8 @@ public sealed class JobQueue
public sealed record JobMapping(int SkinId, int ConditionId);
- private sealed record Candidate(int ConditionId, int SkinId, string Weapon, string SkinName, string Condition);
+ /// A claimed band ready to hand to a worker: its ids + built target string.
+ public sealed record ClaimedJob(string JobId, int SkinId, int ConditionId, string Target, int MaxPages);
+
+ public 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
index 4eb4772..da955c2 100644
--- a/BlueLaminate/BlueLaminate.C2/Program.cs
+++ b/BlueLaminate/BlueLaminate.C2/Program.cs
@@ -1,13 +1,16 @@
using BlueLaminate.C2;
using BlueLaminate.Core.CsMoney;
using BlueLaminate.Core.DependencyInjection;
+using BlueLaminate.Core.SkinLand;
+using System.Text.Json.Serialization;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
-// 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).
+// The C2: hands cs.money and skin.land scrape jobs to Python workers and ingests their
+// results. Reuses the whole BlueLaminate stack (DB, ingest services) 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).
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = args,
@@ -15,17 +18,34 @@ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
});
builder.Services.AddBlueLaminateCore(builder.Configuration);
-// Re-sweep floor: don't re-hand-out a band whose listings were swept less than this
-// many hours ago. The dominant cost on the metered residential proxy is re-scraping
-// already-fresh bands, so this caps how often any band is re-pulled. 0 = continuous.
+// Worker result bodies carry some numbers as JSON strings (skin.land's item_float comes
+// through as "0.60…"); allow string-encoded numbers so they bind, parsed straight to
+// decimal (full precision preserved). Harmless to cs.money's numeric fields.
+builder.Services.ConfigureHttpJsonOptions(o =>
+ o.SerializerOptions.NumberHandling |= JsonNumberHandling.AllowReadingFromString);
+
+// Re-sweep floor: don't re-hand-out a band whose listings were swept less than this many
+// hours ago. The dominant cost on the metered residential proxy is re-scraping already-
+// fresh bands, so this caps how often any band is re-pulled. 0 = continuous. Shared by
+// both markets (each keeps its own per-site checkpoints, so the floors are independent).
var minResweepHours = builder.Configuration.GetValue("MinResweepHours", 6.0);
-builder.Services.AddSingleton(new JobQueue(TimeSpan.FromHours(minResweepHours)));
+var floor = TimeSpan.FromHours(minResweepHours);
+
+// One JobQueue per market source (same class, different checkpoint source + target). The
+// candidate query reads each band's checkpoint for that queue's source only, so the two
+// sweeps progress independently over the shared catalogue.
+builder.Services.AddKeyedSingleton(CsMoneyIngestService.Source, new JobQueue(
+ CsMoneyIngestService.Source, floor,
+ c => $"{c.Weapon} {c.SkinName} {Wear.ToCode(c.Condition) ?? c.Condition}".Trim()));
+builder.Services.AddKeyedSingleton(SkinLandIngestService.Source, new JobQueue(
+ SkinLandIngestService.Source, floor,
+ c => SkinLandSlug.MarketUrl(c.Weapon, c.SkinName, c.Condition)));
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.
+// 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();
@@ -33,8 +53,8 @@ if (app.Configuration.GetValue("AutoMigrate", true))
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.
+// 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);
@@ -49,30 +69,43 @@ 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) =>
+// The same X-Worker-Token gate applied to every worker-facing route group.
+Func withTokenGate = group =>
{
- if (!string.IsNullOrEmpty(workerToken)
- && ctx.HttpContext.Request.Headers["X-Worker-Token"].ToString() != workerToken)
+ group.AddEndpointFilter(async (ctx, next) =>
{
- return Results.Unauthorized();
- }
+ if (!string.IsNullOrEmpty(workerToken)
+ && ctx.HttpContext.Request.Headers["X-Worker-Token"].ToString() != workerToken)
+ {
+ return Results.Unauthorized();
+ }
- return await next(ctx);
-});
+ return await next(ctx);
+ });
+ return group;
+};
+
+// --- cs.money worker endpoints (unchanged behaviour) ------------------------------------
+var jobs = withTokenGate(app.MapGroup("/jobs"));
// 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) =>
+jobs.MapGet("/next", async (
+ [FromKeyedServices(CsMoneyIngestService.Source)] JobQueue queue,
+ SkinTrackerDbContext db, CancellationToken ct) =>
{
var job = await queue.ClaimNextAsync(db, maxPagesPerJob, ct);
- return job is null ? Results.NoContent() : Results.Ok(job);
+ return job is null
+ ? Results.NoContent()
+ : Results.Ok(new ScrapeJobDto(job.JobId, job.SkinId, job.ConditionId, job.Target, job.MaxPages));
});
-// 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.
+// 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) =>
+ string jobId, ScrapeResultDto result,
+ [FromKeyedServices(CsMoneyIngestService.Source)] JobQueue queue,
+ CsMoneyIngestService ingest, CancellationToken ct) =>
{
var mapping = queue.Complete(jobId);
if (mapping is null)
@@ -89,4 +122,33 @@ jobs.MapPost("/{jobId}/result", async (
return Results.Ok(r);
});
+// --- skin.land worker endpoints ---------------------------------------------------------
+var skinLandJobs = withTokenGate(app.MapGroup("/skinland/jobs"));
+
+skinLandJobs.MapGet("/next", async (
+ [FromKeyedServices(SkinLandIngestService.Source)] JobQueue queue,
+ SkinTrackerDbContext db, CancellationToken ct) =>
+{
+ var job = await queue.ClaimNextAsync(db, maxPagesPerJob, ct);
+ return job is null
+ ? Results.NoContent()
+ : Results.Ok(new SkinLandJobDto(job.JobId, job.SkinId, job.ConditionId, job.Target, job.MaxPages));
+});
+
+skinLandJobs.MapPost("/{jobId}/result", async (
+ string jobId, SkinLandResultDto result,
+ [FromKeyedServices(SkinLandIngestService.Source)] JobQueue queue,
+ SkinLandIngestService ingest, CancellationToken ct) =>
+{
+ var mapping = queue.Complete(jobId);
+ if (mapping is null)
+ {
+ return Results.NotFound(new { error = "unknown or expired jobId" });
+ }
+
+ 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.Cli/Commands/CaptureCsMoneyCommand.cs b/BlueLaminate/BlueLaminate.Cli/Commands/CaptureCsMoneyCommand.cs
deleted file mode 100644
index 179836b..0000000
--- a/BlueLaminate/BlueLaminate.Cli/Commands/CaptureCsMoneyCommand.cs
+++ /dev/null
@@ -1,122 +0,0 @@
-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/ProbeProxyCommand.cs b/BlueLaminate/BlueLaminate.Cli/Commands/ProbeProxyCommand.cs
deleted file mode 100644
index 30b70a3..0000000
--- a/BlueLaminate/BlueLaminate.Cli/Commands/ProbeProxyCommand.cs
+++ /dev/null
@@ -1,72 +0,0 @@
-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/Program.cs b/BlueLaminate/BlueLaminate.Cli/Program.cs
index f3f835b..41eafa6 100644
--- a/BlueLaminate/BlueLaminate.Cli/Program.cs
+++ b/BlueLaminate/BlueLaminate.Cli/Program.cs
@@ -72,8 +72,6 @@ var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker too
FetchListingsCommand.Build(host),
SweepListingsCommand.Build(host),
SweepCatalogCommand.Build(host),
- ProbeProxyCommand.Build(host),
- CaptureCsMoneyCommand.Build(host),
};
// Ctrl+C → cancel the action's token so long-running commands (e.g. sweep-catalog,
diff --git a/BlueLaminate/BlueLaminate.Cli/appsettings.json b/BlueLaminate/BlueLaminate.Cli/appsettings.json
index 31e2408..14076e4 100644
--- a/BlueLaminate/BlueLaminate.Cli/appsettings.json
+++ b/BlueLaminate/BlueLaminate.Cli/appsettings.json
@@ -10,14 +10,6 @@
"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",
diff --git a/BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyIngestService.cs b/BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyIngestService.cs
index a5ac290..0fa10ca 100644
--- a/BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyIngestService.cs
+++ b/BlueLaminate/BlueLaminate.Core/CsMoney/CsMoneyIngestService.cs
@@ -20,7 +20,7 @@ public sealed record CsMoneyIngestResult(
///
public sealed class CsMoneyIngestService
{
- public const string Source = "csmoney";
+ public const string Source = SweepSource.CsMoney;
private readonly SkinTrackerDbContext _db;
private readonly ILogger _logger;
@@ -192,7 +192,7 @@ public sealed class CsMoneyIngestService
return null;
}
- var seed = pattern.ToString();
+ var seed = pattern;
var st = it.Asset.IsStatTrak;
var sv = it.Asset.IsSouvenir;
@@ -280,13 +280,13 @@ public sealed class CsMoneyIngestService
}
}
+ // Stamp this band's cs.money checkpoint (upsert into skin_condition_sweeps under
+ // the csmoney source). Caller persists via SaveChangesAsync.
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);
+ await SweepCheckpoints.StampConditionAsync(_db, cid, Source, now, ct);
}
}
diff --git a/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs b/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs
index bf7859b..18a5db5 100644
--- a/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs
+++ b/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs
@@ -2,10 +2,7 @@ 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;
@@ -54,8 +51,6 @@ public static class ServiceCollectionExtensions
.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
@@ -72,42 +67,12 @@ public static class ServiceCollectionExtensions
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();
+ services.AddScoped();
return services;
}
diff --git a/BlueLaminate/BlueLaminate.Core/Listings/ListingSweepService.cs b/BlueLaminate/BlueLaminate.Core/Listings/ListingSweepService.cs
index 44f8e72..8687282 100644
--- a/BlueLaminate/BlueLaminate.Core/Listings/ListingSweepService.cs
+++ b/BlueLaminate/BlueLaminate.Core/Listings/ListingSweepService.cs
@@ -30,7 +30,7 @@ namespace BlueLaminate.Core.Listings;
public sealed class ListingSweepService
{
public const string Source = "listings";
- public const string CatalogSource = "listings-catalog";
+ public const string CatalogSource = SweepSource.CsFloatCatalog;
private readonly SkinTrackerDbContext _db;
private readonly CsFloatListingsClient _client;
@@ -79,6 +79,9 @@ public sealed class ListingSweepService
.Select(s => new { s.Id, s.DefIndex, s.PaintIndex })
.ToDictionaryAsync(s => (s.DefIndex!.Value, s.PaintIndex!.Value), s => s.Id, ct);
+ // (skin, wear) -> condition id, so each listing's wear band is set directly.
+ var conditionLookup = await BuildConditionLookupAsync(ct);
+
// Track which listing ids we touched this run, so a complete pass can flag
// the rest as Removed.
var touchedIds = new HashSet();
@@ -118,7 +121,7 @@ public sealed class ListingSweepService
seen += page.Listings.Count;
var (ins, upd, link, allKnown) = await IngestPageAsync(
- page.Listings, skinByIndex, touchedIds, touchedInstanceIds, now, ct);
+ page.Listings, skinByIndex, conditionLookup, touchedIds, touchedInstanceIds, now, ct);
inserted += ins;
updated += upd;
linked += link;
@@ -207,7 +210,7 @@ public sealed class ListingSweepService
try
{
// Repeat the whole catalogue until cancelled. Re-querying each pass picks
- // up newly-synced skins and re-orders by the latest ListingsSweptAt.
+ // up newly-synced skins and re-orders by this site's latest checkpoint.
while (!ct.IsCancellationRequested)
{
var now = DateTimeOffset.UtcNow;
@@ -219,6 +222,9 @@ public sealed class ListingSweepService
break;
}
+ // (skin, wear) -> condition id, refreshed each pass alongside the units.
+ var conditionLookup = await BuildConditionLookupAsync(ct);
+
var index = 0;
foreach (var unit in units)
{
@@ -258,7 +264,7 @@ public sealed class ListingSweepService
seen += page.Listings.Count;
var (ins, upd, _, _) = await IngestPageAsync(
- page.Listings, lookup, touchedIds, touchedInstanceIds, now, ct);
+ page.Listings, lookup, conditionLookup, touchedIds, touchedInstanceIds, now, ct);
inserted += ins;
updated += upd;
@@ -293,20 +299,19 @@ public sealed class ListingSweepService
{
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);
+ await SweepCheckpoints.StampConditionAsync(_db, conditionId, CatalogSource, 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);
+ await SweepCheckpoints.StampSkinAsync(_db, unit.SkinId, CatalogSource, now, ct);
}
+ // Persist the checkpoint upsert now so a cancellation between bands
+ // doesn't lose it (the stamp goes through the change tracker, not a
+ // set-based update).
+ await _db.SaveChangesAsync(ct);
+
covered++;
await PaceAsync(delayBetweenPages, ct);
@@ -352,8 +357,9 @@ public sealed class ListingSweepService
// 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.
+ // whole-skin case (checkpointed in skin_sweeps rather than skin_condition_sweeps).
+ // SweptAt is this site's checkpoint for the unit and drives the never-swept-first /
+ // stalest-first ordering.
private sealed record SweepUnit(
int SkinId,
int Def,
@@ -383,6 +389,9 @@ public sealed class ListingSweepService
// small (~2k skins) so this is negligible.
private async Task> BuildSweepUnitsAsync(CancellationToken ct)
{
+ // Read each unit's checkpoint for THIS site only (a correlated subquery over the
+ // per-source sweep rows), so a band swept on another site still sorts as
+ // never-swept here. No row for this source => null => front of the queue.
var skins = await _db.Skins
.Where(s => s.DefIndex != null && s.PaintIndex != null)
.Select(s => new
@@ -393,9 +402,22 @@ public sealed class ListingSweepService
s.Name,
Weapon = s.Weapon.Name,
s.Rarity,
- s.ListingsSweptAt,
+ SweptAt = s.Sweeps
+ .Where(x => x.Source == CatalogSource)
+ .Select(x => (DateTimeOffset?)x.SweptAt)
+ .FirstOrDefault(),
Conditions = s.Conditions
- .Select(c => new { c.Id, c.Condition, c.MinFloat, c.MaxFloat, c.ListingsSweptAt })
+ .Select(c => new
+ {
+ c.Id,
+ c.Condition,
+ c.FloatMin,
+ c.FloatMax,
+ SweptAt = c.Sweeps
+ .Where(x => x.Source == CatalogSource)
+ .Select(x => (DateTimeOffset?)x.SweptAt)
+ .FirstOrDefault(),
+ })
.ToList(),
})
.ToListAsync(ct);
@@ -408,7 +430,7 @@ public sealed class ListingSweepService
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));
+ SweptAt: s.SweptAt));
continue;
}
@@ -417,8 +439,8 @@ public sealed class ListingSweepService
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));
+ MinFloat: c.FloatMin, MaxFloat: c.FloatMax,
+ SweptAt: c.SweptAt));
}
}
@@ -431,6 +453,15 @@ public sealed class ListingSweepService
.ToList();
}
+ // (skinId, wear name) -> skin_conditions.id, built once per run so each listing's
+ // wear band resolves without a per-row query. The wear name equals
+ // skin_conditions.condition (CSFloat's authoritative tier name, e.g. "Factory New").
+ private async Task> BuildConditionLookupAsync(
+ CancellationToken ct) =>
+ await _db.SkinConditions
+ .Select(c => new { c.SkinId, c.Condition, c.Id })
+ .ToDictionaryAsync(c => (c.SkinId, c.Condition), c => c.Id, ct);
+
// Flag this skin's once-Active listings that we didn't see this run as Removed.
private async Task MarkRemovedForSkinAsync(
int skinId, HashSet touchedIds, DateTimeOffset now, CancellationToken ct)
@@ -472,6 +503,7 @@ public sealed class ListingSweepService
private async Task<(int Inserted, int Updated, int Linked, bool AllKnown)> IngestPageAsync(
IReadOnlyList listings,
IReadOnlyDictionary<(int, int), int> skinByIndex,
+ IReadOnlyDictionary<(int, string), int> conditionBySkinAndWear,
HashSet touchedIds,
HashSet touchedInstanceIds,
DateTimeOffset now,
@@ -501,6 +533,14 @@ public sealed class ListingSweepService
linked++;
}
+ // Wear band: resolve from (skin, wear name) so both the catalogue and the
+ // incremental sweep set the same condition_id. Null when the skin is
+ // unknown or the item has no wear (e.g. vanilla knives).
+ int? conditionId = skinId is { } skinForCond && l.WearName is { } wearForCond
+ && conditionBySkinAndWear.TryGetValue((skinForCond, wearForCond), out var resolvedCond)
+ ? resolvedCond
+ : null;
+
// Resolve the physical item only when we know the skin — the
// fingerprint is meaningless without it.
var instance = skinId is { } sid
@@ -520,6 +560,7 @@ public sealed class ListingSweepService
row.Status = ListingStatus.Active;
row.RemovedAt = null;
row.SkinId = skinId;
+ row.ConditionId = conditionId;
row.AssetId = l.AssetId;
row.SkinInstance = instance;
updated++;
@@ -527,7 +568,7 @@ public sealed class ListingSweepService
else
{
allKnown = false;
- var entity = MapToEntity(l, skinId, now);
+ var entity = MapToEntity(l, skinId, conditionId, now);
entity.SkinInstance = instance;
_db.Listings.Add(entity);
inserted++;
@@ -541,16 +582,23 @@ public sealed class ListingSweepService
// The fingerprint is (skin, full-precision float, seed, stattrak, souvenir).
// It is deliberately NOT unique — duped copies share it — so a match may
// already represent more than one physical item; dupe detection runs later.
- private async Task ResolveInstanceAsync(
+ private async Task ResolveInstanceAsync(
int skinId, CsFloatListing l, DateTimeOffset now, CancellationToken ct)
{
- var seed = l.PaintSeed.ToString();
+ // Floatless items (e.g. Vanilla knives) can't be fingerprinted; skip the
+ // instance and leave the listing's SkinInstanceId null, like the cs.money path.
+ if (l.FloatValue is not { } floatValue)
+ {
+ return null;
+ }
+
+ var seed = l.PaintSeed;
// Check the change-tracker first (an instance just added earlier this page
// isn't queryable yet), then the database.
var tracked = _db.ChangeTracker.Entries()
.Select(e => e.Entity)
- .FirstOrDefault(i => i.SkinId == skinId && i.FloatValue == l.FloatValue
+ .FirstOrDefault(i => i.SkinId == skinId && i.FloatValue == floatValue
&& i.PaintSeed == seed && i.StatTrak == l.IsStatTrak && i.Souvenir == l.IsSouvenir);
if (tracked is not null)
{
@@ -559,7 +607,7 @@ public sealed class ListingSweepService
}
var instance = await _db.SkinInstances.FirstOrDefaultAsync(
- i => i.SkinId == skinId && i.FloatValue == l.FloatValue
+ i => i.SkinId == skinId && i.FloatValue == floatValue
&& i.PaintSeed == seed && i.StatTrak == l.IsStatTrak && i.Souvenir == l.IsSouvenir,
ct);
@@ -572,7 +620,7 @@ public sealed class ListingSweepService
instance = new SkinInstance
{
SkinId = skinId,
- FloatValue = l.FloatValue,
+ FloatValue = floatValue,
PaintSeed = seed,
StatTrak = l.IsStatTrak,
Souvenir = l.IsSouvenir,
@@ -583,7 +631,7 @@ public sealed class ListingSweepService
return instance;
}
- private static Listing MapToEntity(CsFloatListing l, int? skinId, DateTimeOffset now) => new()
+ private static Listing MapToEntity(CsFloatListing l, int? skinId, int? conditionId, DateTimeOffset now) => new()
{
CsFloatListingId = l.ListingId,
Type = l.Type,
@@ -602,6 +650,7 @@ public sealed class ListingSweepService
SellerSteamId = l.SellerSteamId,
InspectLink = l.InspectLink,
SkinId = skinId,
+ ConditionId = conditionId,
FirstSeenAt = now,
LastSeenAt = now,
Status = ListingStatus.Active,
diff --git a/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandIngestService.cs b/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandIngestService.cs
new file mode 100644
index 0000000..2ed6d44
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandIngestService.cs
@@ -0,0 +1,205 @@
+using BlueLaminate.EFCore.Data;
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace BlueLaminate.Core.SkinLand;
+
+/// Outcome of ingesting one skin+wear scrape job's results.
+public sealed record SkinLandIngestResult(
+ int Matched, int Inserted, int Updated, int Removed, int Skipped);
+
+///
+/// Persists the offers the worker scraped for one targeted skin+wear job into the
+/// skin_land_listings table. Mirrors 's
+/// upsert-by-natural-key + soft-track-Removed + complete-vs-partial flow, but is thinner:
+/// skin.land exposes no paint seed, so there's no SkinInstance resolution and no
+/// dupe detection. The scraped page is already one exact skin+wear (the worker fetches it
+/// by slug), so instead of cs.money's fuzzy name filter we only validate defensively that
+/// each offer's slug matches the targeted band, skipping any that don't.
+///
+public sealed class SkinLandIngestService
+{
+ public const string Source = SweepSource.SkinLand;
+
+ private readonly SkinTrackerDbContext _db;
+ private readonly ILogger _logger;
+
+ public SkinLandIngestService(SkinTrackerDbContext db, ILogger logger)
+ {
+ _db = db;
+ _logger = logger;
+ }
+
+ ///
+ /// True only when the worker walked every page of the 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 offers 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 offers, 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 SkinLandIngestResult(0, 0, 0, 0, offers.Count);
+ }
+
+ string? conditionName = null;
+ if (conditionId is { } cid)
+ {
+ conditionName = await _db.SkinConditions
+ .Where(c => c.Id == cid).Select(c => c.Condition).FirstOrDefaultAsync(ct);
+ }
+
+ // Each offer carries its skin's slug; the targeted band has a known slug. When we
+ // can build the expected slug, keep only offers whose slug matches (a cheap guard
+ // against a wrong/redirected page); otherwise accept all (the worker targeted it).
+ var expectedSlug = conditionName is null
+ ? null
+ : SkinLandSlug.Slugify($"{skin.Weapon} {skin.Name} {conditionName}");
+ var matched = offers.Where(o =>
+ expectedSlug is null
+ || string.Equals(o.Skin?.Url, expectedSlug, StringComparison.OrdinalIgnoreCase)).ToList();
+
+ var skipped = offers.Count - matched.Count;
+ if (matched.Count == 0)
+ {
+ // Nothing for this skin+wear. If the sweep was complete this is genuine (none
+ // listed, or a slug mismatch) — stamp the checkpoint so it advances. If partial
+ // (e.g. challenged before any page), leave it un-stamped so the band is retried.
+ if (complete)
+ {
+ await StampCheckpointAsync(conditionId, now, ct);
+ await _db.SaveChangesAsync(ct);
+ }
+
+ return new SkinLandIngestResult(0, 0, 0, 0, skipped);
+ }
+
+ var listingIds = matched.Select(o => o.Id).ToList();
+ var existing = await _db.SkinLandListings
+ .Where(l => listingIds.Contains(l.ListingId))
+ .ToDictionaryAsync(l => l.ListingId, ct);
+
+ var inserted = 0;
+ var updated = 0;
+ var touched = new HashSet();
+
+ foreach (var o in matched)
+ {
+ touched.Add(o.Id);
+ if (existing.TryGetValue(o.Id, out var row))
+ {
+ row.Price = o.FinalWithdrawalPrice ?? row.Price;
+ row.FloatValue = o.ItemFloat;
+ row.NameTag = o.NameTag;
+ row.InspectLink = o.ItemLink;
+ row.StickerCount = o.Stickers?.Count(s => s is not null) ?? 0;
+ row.LastSeenAt = now;
+ row.Status = ListingStatus.Active;
+ row.RemovedAt = null;
+ row.ConditionId = conditionId;
+ updated++;
+ }
+ else
+ {
+ _db.SkinLandListings.Add(Map(o, skinId, conditionId, now));
+ inserted++;
+ }
+ }
+
+ // Persist inserts/updates before the set-based Removed query runs.
+ await _db.SaveChangesAsync(ct);
+
+ // The following only hold if we saw the FULL skin+wear set. On a partial sweep,
+ // offers we didn't fetch are not gone (so don't mark them Removed), the cheapest
+ // offer 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);
+
+ if (conditionId is { } condId)
+ {
+ var priced = matched.Where(m => m.FinalWithdrawalPrice is not null)
+ .Select(m => m.FinalWithdrawalPrice!.Value).ToList();
+ if (priced.Count > 0)
+ {
+ await _db.PriceHistories.AddAsync(new PriceHistory
+ {
+ SkinId = skinId,
+ ConditionId = condId,
+ Price = priced.Min(),
+ Currency = "USD",
+ RecordedAt = now,
+ Source = Source,
+ }, ct);
+ }
+ }
+
+ await StampCheckpointAsync(conditionId, now, ct);
+ }
+
+ await _db.SaveChangesAsync(ct);
+
+ _logger.LogInformation(
+ "skin.land 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 SkinLandIngestResult(matched.Count, inserted, updated, removed, skipped);
+ }
+
+ // Flag this skin+wear's once-Active offers 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.SkinLandListings
+ .Where(l => l.SkinId == skinId
+ && l.ConditionId == conditionId
+ && l.Status == ListingStatus.Active
+ && !touched.Contains(l.ListingId))
+ .ExecuteUpdateAsync(setters => setters
+ .SetProperty(l => l.Status, ListingStatus.Removed)
+ .SetProperty(l => l.RemovedAt, now), ct);
+ }
+
+ // Stamp this band's skin.land checkpoint (upsert into skin_condition_sweeps under the
+ // skinland source). Caller persists via SaveChangesAsync.
+ private async Task StampCheckpointAsync(int? conditionId, DateTimeOffset now, CancellationToken ct)
+ {
+ if (conditionId is { } cid)
+ {
+ await SweepCheckpoints.StampConditionAsync(_db, cid, Source, now, ct);
+ }
+ }
+
+ private static SkinLandListing Map(SkinLandOffer o, int skinId, int? conditionId, DateTimeOffset now) => new()
+ {
+ ListingId = o.Id,
+ SkinId = skinId,
+ ConditionId = conditionId,
+ MarketHashName = o.Skin?.Name ?? "",
+ FloatValue = o.ItemFloat,
+ IsStatTrak = o.Skin?.IsStatTrak ?? false,
+ IsSouvenir = o.Skin?.IsSouvenir ?? false,
+ NameTag = o.NameTag,
+ StickerCount = o.Stickers?.Count(s => s is not null) ?? 0,
+ Price = o.FinalWithdrawalPrice ?? 0m,
+ Currency = "USD",
+ InspectLink = o.ItemLink,
+ FirstSeenAt = now,
+ LastSeenAt = now,
+ Status = ListingStatus.Active,
+ };
+}
diff --git a/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandJson.cs b/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandJson.cs
new file mode 100644
index 0000000..50ba1b1
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandJson.cs
@@ -0,0 +1,35 @@
+using System.Text.Json.Serialization;
+
+namespace BlueLaminate.Core.SkinLand;
+
+///
+/// The subset of a skin.land obtained-skins offer we persist, parsed from the
+/// JSON the Python worker scrapes (the paginated data[] array). Decimals are
+/// parsed directly (not via double) so the full-precision float round-trips exactly into
+/// numeric(20,18). skin.land exposes no paint seed / def index, so there's nothing
+/// to fingerprint a SkinInstance with — the shape is intentionally thin.
+///
+public sealed class SkinLandOffer
+{
+ [JsonPropertyName("id")] public long Id { get; set; }
+ [JsonPropertyName("item_float")] public decimal? ItemFloat { get; set; }
+ [JsonPropertyName("final_withdrawal_price")] public decimal? FinalWithdrawalPrice { get; set; }
+ [JsonPropertyName("name_tag")] public string? NameTag { get; set; }
+ [JsonPropertyName("item_link")] public string? ItemLink { get; set; }
+ [JsonPropertyName("stickers")] public List? Stickers { get; set; }
+ [JsonPropertyName("skin")] public SkinLandSkin? Skin { get; set; }
+}
+
+public sealed class SkinLandSkin
+{
+ [JsonPropertyName("id")] public long? Id { get; set; }
+ [JsonPropertyName("name")] public string? Name { get; set; }
+ [JsonPropertyName("url")] public string? Url { get; set; } // the market slug
+ [JsonPropertyName("is_stattrak")] public bool IsStatTrak { get; set; }
+ [JsonPropertyName("is_souvenir")] public bool IsSouvenir { get; set; }
+}
+
+public sealed class SkinLandSticker
+{
+ [JsonPropertyName("name")] public string? Name { get; set; }
+}
diff --git a/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandSlug.cs b/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandSlug.cs
new file mode 100644
index 0000000..dc5e9db
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.Core/SkinLand/SkinLandSlug.cs
@@ -0,0 +1,55 @@
+using System.Text;
+
+namespace BlueLaminate.Core.SkinLand;
+
+///
+/// Builds a skin.land market URL from the catalogue's weapon + skin + wear. skin.land's
+/// market routes are /market/csgo/{slug}/ where the slug is simply
+/// {weapon}-{skin}-{wear} kebab-cased — verified against the live site (e.g.
+/// "M4A4" + "Global Offensive" + "Battle-Scarred" → m4a4-global-offensive-battle-scarred,
+/// "AK-47" + "Redline" + "Field-Tested" → ak-47-redline-field-tested). No discovery
+/// or stored mapping is needed.
+///
+/// StatTrak and Souvenir are separate pages on skin.land (stattrak-/
+/// souvenir- prefixed slugs); this builds the base (non-special) page, which is the
+/// unit v1 sweeps per SkinCondition.
+///
+///
+public static class SkinLandSlug
+{
+ private const string MarketBase = "https://skin.land/market/csgo/";
+
+ /// "M4A4", "Global Offensive", "Battle-Scarred" → the full market URL.
+ public static string MarketUrl(string weapon, string skinName, string condition) =>
+ $"{MarketBase}{Slugify($"{weapon} {skinName} {condition}")}/";
+
+ ///
+ /// Lowercase, collapse every run of non-alphanumeric characters to a single hyphen,
+ /// and trim leading/trailing hyphens. So "AK-47 | Redline (Field-Tested)" and the
+ /// catalogue's "AK-47 Redline Field-Tested" both reduce to "ak-47-redline-field-tested".
+ ///
+ public static string Slugify(string value)
+ {
+ var sb = new StringBuilder(value.Length);
+ var pendingHyphen = false;
+ foreach (var ch in value)
+ {
+ if (char.IsLetterOrDigit(ch))
+ {
+ if (pendingHyphen && sb.Length > 0)
+ {
+ sb.Append('-');
+ }
+
+ sb.Append(char.ToLowerInvariant(ch));
+ pendingHyphen = false;
+ }
+ else
+ {
+ pendingHyphen = true;
+ }
+ }
+
+ return sb.ToString();
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/InventoryItemConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/InventoryItemConfiguration.cs
index ee27762..7fb246e 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Configurations/InventoryItemConfiguration.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/InventoryItemConfiguration.cs
@@ -8,7 +8,8 @@ public class InventoryItemConfiguration : IEntityTypeConfiguration entity)
{
- entity.HasIndex(e => e.AssetId);
+ // A Steam asset id identifies one physical copy; never store it twice.
+ entity.HasIndex(e => e.AssetId).IsUnique();
entity.HasOne(e => e.User)
.WithMany(u => u.InventoryItems)
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/ListingConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/ListingConfiguration.cs
index c5b3e8c..cefdd13 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Configurations/ListingConfiguration.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/ListingConfiguration.cs
@@ -31,6 +31,14 @@ public class ListingConfiguration : IEntityTypeConfiguration
.HasForeignKey(e => e.SkinId)
.OnDelete(DeleteBehavior.SetNull);
+ // Wear band the sweep targeted (set directly from the sweep unit, not
+ // best-effort). Set null on delete so a condition row can change without
+ // blocking its listings — matching the cs.money/skin.land tables.
+ entity.HasOne(e => e.Condition)
+ .WithMany()
+ .HasForeignKey(e => e.ConditionId)
+ .OnDelete(DeleteBehavior.SetNull);
+
// Listings roll up to the physical item they represent.
entity.HasOne(e => e.SkinInstance)
.WithMany(i => i.Listings)
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionConfiguration.cs
index 901b550..a2c4bfc 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionConfiguration.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionConfiguration.cs
@@ -8,12 +8,11 @@ public class SkinConditionConfiguration : IEntityTypeConfiguration entity)
{
- entity.Property(e => e.MinFloat).HasColumnType("numeric(10,9)");
- entity.Property(e => e.MaxFloat).HasColumnType("numeric(10,9)");
+ entity.Property(e => e.FloatMin).HasColumnType("numeric(10,9)");
+ entity.Property(e => e.FloatMax).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);
+ // Per-site "last swept" checkpoints live in skin_condition_sweeps (one row per
+ // site); see SkinConditionSweepConfiguration for the indexes that order them.
entity.HasOne(e => e.Skin)
.WithMany(s => s.Conditions)
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionSweepConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionSweepConfiguration.cs
new file mode 100644
index 0000000..1d3c9e0
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConditionSweepConfiguration.cs
@@ -0,0 +1,24 @@
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BlueLaminate.EFCore.Configurations;
+
+public class SkinConditionSweepConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder entity)
+ {
+ // One checkpoint per band per site: the natural key, and what the upsert
+ // ("stamp") in SweepCheckpoints relies on.
+ entity.HasIndex(e => new { e.SkinConditionId, e.Source }).IsUnique();
+
+ // Each site's sweep orders its bands never-swept-first then stalest; index the
+ // ordering it scans (filter by source, sort by swept_at).
+ entity.HasIndex(e => new { e.Source, e.SweptAt });
+
+ entity.HasOne(e => e.SkinCondition)
+ .WithMany(c => c.Sweeps)
+ .HasForeignKey(e => e.SkinConditionId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs
index c9e32ad..70f80fb 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinConfiguration.cs
@@ -29,9 +29,8 @@ public class SkinConfiguration : IEntityTypeConfiguration
.IsUnique()
.HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
- // The catalogue sweep orders skins by when they were last swept (nulls
- // first) to resume across capped runs; index that ordering.
- entity.HasIndex(e => e.ListingsSweptAt);
+ // Per-site "last swept" checkpoints live in skin_sweeps (one row per site);
+ // see SkinSweepConfiguration for the indexes that order them.
entity.HasOne(e => e.Weapon)
.WithMany(w => w.Skins)
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinLandListingConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinLandListingConfiguration.cs
new file mode 100644
index 0000000..447bd1d
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinLandListingConfiguration.cs
@@ -0,0 +1,39 @@
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BlueLaminate.EFCore.Configurations;
+
+public class SkinLandListingConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder entity)
+ {
+ // skin.land's offer id is the natural key; ingest upserts against it and must
+ // never create duplicates.
+ entity.HasIndex(e => e.ListingId).IsUnique();
+
+ entity.Property(e => e.Price).HasPrecision(18, 2);
+ // Full precision (matches SkinInstance/cs.money) even though skin.land offers
+ // aren't fingerprinted — keep the float lossless for later analysis.
+ 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);
+
+ // 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);
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinSweepConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinSweepConfiguration.cs
new file mode 100644
index 0000000..b62ddd4
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/SkinSweepConfiguration.cs
@@ -0,0 +1,22 @@
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace BlueLaminate.EFCore.Configurations;
+
+public class SkinSweepConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder entity)
+ {
+ // One checkpoint per skin per site: the natural key the upsert relies on.
+ entity.HasIndex(e => new { e.SkinId, e.Source }).IsUnique();
+
+ // Mirror SkinConditionSweep: index the (source, swept_at) ordering each sweep scans.
+ entity.HasIndex(e => new { e.Source, e.SweptAt });
+
+ entity.HasOne(e => e.Skin)
+ .WithMany(s => s.Sweeps)
+ .HasForeignKey(e => e.SkinId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/TradeConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/TradeConfiguration.cs
index 92d4038..cf143b2 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Configurations/TradeConfiguration.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/TradeConfiguration.cs
@@ -8,6 +8,10 @@ public class TradeConfiguration : IEntityTypeConfiguration
{
public void Configure(EntityTypeBuilder entity)
{
+ // Steam's trade id is the natural key for an observed trade. Nullable (some
+ // trades are reconstructed without one); Postgres keeps multiple NULLs distinct.
+ entity.HasIndex(e => e.SteamTradeId).IsUnique();
+
entity.HasOne(e => e.FromUser)
.WithMany(u => u.TradesSent)
.HasForeignKey(e => e.FromUserId)
diff --git a/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs b/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
index 310e20e..7b2c453 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs
@@ -23,6 +23,8 @@ public class SkinTrackerDbContext : DbContext
public DbSet Collections => Set();
public DbSet Skins => Set();
public DbSet SkinConditions => Set();
+ public DbSet SkinSweeps => Set();
+ public DbSet SkinConditionSweeps => Set();
public DbSet SteamUsers => Set();
public DbSet SkinInstances => Set();
public DbSet InventoryItems => Set();
@@ -31,6 +33,7 @@ public class SkinTrackerDbContext : DbContext
public DbSet PriceHistories => Set();
public DbSet Listings => Set();
public DbSet CsMoneyListings => Set();
+ public DbSet SkinLandListings => Set();
/// Read-only cross-market view UNIONing the per-market listing tables.
public DbSet MarketListings => Set();
@@ -47,6 +50,8 @@ public class SkinTrackerDbContext : DbContext
modelBuilder.ApplyConfiguration(new CollectionConfiguration());
modelBuilder.ApplyConfiguration(new SkinConfiguration());
modelBuilder.ApplyConfiguration(new SkinConditionConfiguration());
+ modelBuilder.ApplyConfiguration(new SkinSweepConfiguration());
+ modelBuilder.ApplyConfiguration(new SkinConditionSweepConfiguration());
modelBuilder.ApplyConfiguration(new SteamUserConfiguration());
modelBuilder.ApplyConfiguration(new SkinInstanceConfiguration());
modelBuilder.ApplyConfiguration(new InventoryItemConfiguration());
@@ -55,6 +60,7 @@ public class SkinTrackerDbContext : DbContext
modelBuilder.ApplyConfiguration(new PriceHistoryConfiguration());
modelBuilder.ApplyConfiguration(new ListingConfiguration());
modelBuilder.ApplyConfiguration(new CsMoneyListingConfiguration());
+ modelBuilder.ApplyConfiguration(new SkinLandListingConfiguration());
modelBuilder.ApplyConfiguration(new MarketListingConfiguration());
}
}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Data/SweepCheckpoints.cs b/BlueLaminate/BlueLaminate.EFCore/Data/SweepCheckpoints.cs
new file mode 100644
index 0000000..b4fb772
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Data/SweepCheckpoints.cs
@@ -0,0 +1,64 @@
+using BlueLaminate.EFCore.Entities;
+using Microsoft.EntityFrameworkCore;
+
+namespace BlueLaminate.EFCore.Data;
+
+///
+/// Write helpers for the per-site sweep checkpoints ( /
+/// ). Each marketplace sweeper stamps its own row
+/// keyed by (entity, source), so a band swept on one site is still "never
+/// swept" on another. Adding a new site means a new
+/// constant — no schema changes.
+///
+/// Reads stay inline in the sweep queries (a correlated subquery over the navigation
+/// for the relevant Source) so EF can translate and order by them server-side.
+///
+///
+public static class SweepCheckpoints
+{
+ ///
+ /// Record that just swept this wear band. Upserts the
+ /// single (condition, source) row via the change tracker; the caller persists with
+ /// .
+ ///
+ public static async Task StampConditionAsync(
+ SkinTrackerDbContext db, int conditionId, string source, DateTimeOffset sweptAt, CancellationToken ct)
+ {
+ var existing = await db.SkinConditionSweeps
+ .FirstOrDefaultAsync(s => s.SkinConditionId == conditionId && s.Source == source, ct);
+ if (existing is null)
+ {
+ db.SkinConditionSweeps.Add(new SkinConditionSweep
+ {
+ SkinConditionId = conditionId,
+ Source = source,
+ SweptAt = sweptAt,
+ });
+ }
+ else
+ {
+ existing.SweptAt = sweptAt;
+ }
+ }
+
+ /// As , for a whole-skin unit (no wear bands).
+ public static async Task StampSkinAsync(
+ SkinTrackerDbContext db, int skinId, string source, DateTimeOffset sweptAt, CancellationToken ct)
+ {
+ var existing = await db.SkinSweeps
+ .FirstOrDefaultAsync(s => s.SkinId == skinId && s.Source == source, ct);
+ if (existing is null)
+ {
+ db.SkinSweeps.Add(new SkinSweep
+ {
+ SkinId = skinId,
+ Source = source,
+ SweptAt = sweptAt,
+ });
+ }
+ else
+ {
+ existing.SweptAt = sweptAt;
+ }
+ }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs
index 7265219..52831b0 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs
@@ -36,9 +36,12 @@ public class Listing
/// "buy_now" or "auction".
public string Type { get; set; } = null!;
- /// Asking price in USD.
+ /// Asking price.
public decimal Price { get; set; }
+ /// Currency of . CSFloat lists in USD.
+ public string Currency { get; set; } = "USD";
+
/// When CSFloat says the listing was created.
public DateTimeOffset ListedAt { get; set; }
@@ -48,7 +51,13 @@ public class Listing
public int PaintIndex { get; set; }
public string MarketHashName { get; set; } = null!;
public string? WearName { get; set; }
- public decimal FloatValue { get; set; }
+
+ ///
+ /// Exact float, or null for items with no float at all (e.g. Vanilla knives).
+ /// Null is deliberately distinct from a genuine 0.0 float; a floatless item
+ /// also can't be fingerprinted, so its stays null.
+ ///
+ public decimal? FloatValue { get; set; }
public int PaintSeed { get; set; }
public bool IsStatTrak { get; set; }
public bool IsSouvenir { get; set; }
@@ -68,6 +77,15 @@ public class Listing
public int? SkinId { get; set; }
public Skin? Skin { get; set; }
+ ///
+ /// The wear band this listing belongs to. Unlike this is NOT
+ /// best-effort: the catalogue sweep pages one skin+wear band at a time, so the band
+ /// is set directly from the sweep unit. Null for whole-skin sweeps (e.g. vanilla
+ /// knives with no wear bands).
+ ///
+ public int? ConditionId { get; set; }
+ public SkinCondition? Condition { get; set; }
+
///
/// The physical item (by fingerprint) this listing is for. Many listings over
/// time roll up to one instance, forming its market-movement history. Nullable
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs
index e7fe387..711ccb1 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/Skin.cs
@@ -16,12 +16,6 @@ public class Skin
public int? DefIndex { get; set; }
public int? PaintIndex { get; set; }
- // When the catalogue-driven listing sweep last fully covered this skin. The
- // sweep processes least-recently-swept skins first (nulls = never swept), so
- // capped runs chain across the whole catalogue and the stalest data refreshes
- // first. Null until the first sweep reaches this skin.
- public DateTimeOffset? ListingsSweptAt { get; set; }
-
public string Name { get; set; } = null!;
public string Rarity { get; set; } = null!;
public string? Description { get; set; }
@@ -44,6 +38,12 @@ public class Skin
public bool? TrueFloat { get; private set; }
public ICollection Conditions { get; set; } = new List();
+
+ // Per-site "last swept" checkpoints for the whole-skin sweep unit — only used for
+ // skins with no wear bands (the per-band checkpoint lives on SkinCondition.Sweeps).
+ // The sweep processes never-swept (no row) / stalest skins first. See SkinSweep.
+ public ICollection Sweeps { get; set; } = new List();
+
public ICollection Instances { get; set; } = new List();
public ICollection PriceHistories { get; set; } = new List();
}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinCondition.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinCondition.cs
index b918a82..546b66f 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinCondition.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinCondition.cs
@@ -7,14 +7,15 @@ public class SkinCondition
public Skin Skin { get; set; } = null!;
public string Condition { get; set; } = null!;
- public decimal MinFloat { get; set; }
- public decimal MaxFloat { get; set; }
+ public decimal FloatMin { get; set; }
+ public decimal FloatMax { get; set; }
- // When the catalogue-driven listing sweep last fully covered this skin's wear
- // band. The sweep splits each skin by wear and pages one band at a time, so this
- // is the per-band checkpoint: an interrupted run resumes from never-swept/stalest
- // bands rather than redoing a whole skin. Null until the first sweep reaches it.
- public DateTimeOffset? ListingsSweptAt { get; set; }
+ // Per-site "last swept" checkpoints for this wear band — one row per marketplace
+ // (Source). The sweep splits each skin by wear and pages one band at a time, so
+ // this is the per-band checkpoint: an interrupted run resumes from never-swept
+ // (no row) / stalest bands rather than redoing a whole skin. Tracked per site so a
+ // band swept on CSFloat is still never-swept on cs.money. See SkinConditionSweep.
+ public ICollection Sweeps { get; set; } = new List();
public ICollection Instances { get; set; } = new List();
public ICollection PriceHistories { get; set; } = new List();
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinConditionSweep.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinConditionSweep.cs
new file mode 100644
index 0000000..7bc5c5a
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinConditionSweep.cs
@@ -0,0 +1,21 @@
+namespace BlueLaminate.EFCore.Entities;
+
+///
+/// One site's "last swept" checkpoint for a single wear band. The catalogue sweep
+/// processes least-recently-swept bands first (no row = never swept), so capped/looping
+/// runs chain across the catalogue and refresh the stalest data first. Keyed by
+/// (SkinConditionId, Source) so each marketplace tracks its own progress
+/// independently — a band swept on one site stays never-swept on another.
+///
+public class SkinConditionSweep
+{
+ public int Id { get; set; }
+
+ public int SkinConditionId { get; set; }
+ public SkinCondition SkinCondition { get; set; } = null!;
+
+ /// Which site swept it — a value.
+ public string Source { get; set; } = null!;
+
+ public DateTimeOffset SweptAt { get; set; }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinInstance.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinInstance.cs
index 92b0491..87498b4 100644
--- a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinInstance.cs
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinInstance.cs
@@ -26,9 +26,11 @@ public class SkinInstance
public SkinCondition? Condition { get; set; }
// The fingerprint. FloatValue is stored at full precision (see config) so
- // that exact-match dupe detection isn't fooled by rounding.
+ // that exact-match dupe detection isn't fooled by rounding. An instance is
+ // only created for items that have a float + paint seed (skins), so both are
+ // non-null here even though some listings (e.g. vanilla knives) lack them.
public decimal FloatValue { get; set; }
- public string PaintSeed { get; set; } = null!;
+ public int PaintSeed { get; set; }
public bool StatTrak { get; set; }
public bool Souvenir { get; set; }
public DateTimeOffset FirstSeenAt { get; set; }
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinLandListing.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinLandListing.cs
new file mode 100644
index 0000000..4061184
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinLandListing.cs
@@ -0,0 +1,54 @@
+namespace BlueLaminate.EFCore.Entities;
+
+///
+/// One offer observed on skin.land via its internal
+/// GET /api/v2/obtained-skins?skin_id={id}&page={n} endpoint (scraped through
+/// the Python worker, since skin.land has no public API and sits behind Cloudflare).
+///
+/// Kept in its own table like , but deliberately thinner:
+/// skin.land exposes a full-precision float and price but no paint seed / def index,
+/// so an offer can't be fingerprinted to a market-agnostic and
+/// there is no cross-market roll-up or dupe detection here (revisit if pattern is ever
+/// exposed). StatTrak and Souvenir live on separate skin.land pages (their own
+/// stattrak-/souvenir- slugs); v1 sweeps the base page per skin+wear, so
+/// / are normally false.
+///
+/// Soft-tracked across sweeps exactly like :
+/// / bound the observation window and
+/// flips to when a once-seen
+/// offer stops appearing (sold/delisted).
+///
+public class SkinLandListing
+{
+ public int Id { get; set; }
+
+ /// skin.land's offer id (obtained-skin id). Natural key for dedup.
+ public long ListingId { get; set; }
+
+ // Catalogue links. Like cs.money (and unlike the CSFloat global sweep) these are NOT
+ // best-effort: each scrape job targets one skin+wear, so we set them directly.
+ public int SkinId { get; set; }
+ public Skin Skin { get; set; } = null!;
+ public int? ConditionId { get; set; }
+ public SkinCondition? Condition { get; set; }
+
+ // Item identity, from the offer's skin block.
+ public string MarketHashName { get; set; } = null!;
+ public decimal? FloatValue { get; set; } // item_float (string, full precision)
+ public bool IsStatTrak { get; set; }
+ public bool IsSouvenir { get; set; }
+ public string? NameTag { get; set; } // offer.name_tag (rare; affects value)
+ public int StickerCount { get; set; }
+
+ // Pricing. skin.land returns a single price (the amount to buy/withdraw the item).
+ public decimal Price { get; set; } // final_withdrawal_price
+ public string Currency { get; set; } = "USD"; // prices are read in USD
+
+ public string? InspectLink { get; set; } // item_link (steam:// inspect)
+
+ // Soft-tracking across sweeps.
+ public DateTimeOffset FirstSeenAt { get; set; }
+ public DateTimeOffset LastSeenAt { get; set; }
+ public ListingStatus Status { get; set; }
+ public DateTimeOffset? RemovedAt { get; set; }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/SkinSweep.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinSweep.cs
new file mode 100644
index 0000000..14b6a0f
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/SkinSweep.cs
@@ -0,0 +1,20 @@
+namespace BlueLaminate.EFCore.Entities;
+
+///
+/// One site's "last swept" checkpoint for a whole skin — used only for skins with no
+/// wear bands (e.g. vanilla knives), which are swept as a single unit. The per-band
+/// equivalent is . Keyed by (SkinId, Source) so
+/// each marketplace tracks its own progress independently.
+///
+public class SkinSweep
+{
+ public int Id { get; set; }
+
+ public int SkinId { get; set; }
+ public Skin Skin { get; set; } = null!;
+
+ /// Which site swept it — a value.
+ public string Source { get; set; } = null!;
+
+ public DateTimeOffset SweptAt { get; set; }
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/SweepSource.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/SweepSource.cs
new file mode 100644
index 0000000..9984705
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Entities/SweepSource.cs
@@ -0,0 +1,23 @@
+namespace BlueLaminate.EFCore.Entities;
+
+///
+/// Canonical site identifiers for per-site sweep checkpoints — the Source
+/// discriminator on and .
+/// Each marketplace sweeper stamps its own checkpoint under one of these, so a band
+/// swept on one site is still "never swept" on another.
+///
+/// To add sweeping for a new marketplace, add one constant here and have that
+/// sweeper read/stamp checkpoints with it — no schema or query changes needed.
+///
+///
+public static class SweepSource
+{
+ /// CSFloat catalogue-driven sweep (ListingSweepService.SweepCatalogAsync).
+ public const string CsFloatCatalog = "listings-catalog";
+
+ /// cs.money worker sweep (CsMoneyIngestService).
+ public const string CsMoney = "csmoney";
+
+ /// skin.land worker sweep (SkinLandIngestService).
+ public const string SkinLand = "skinland";
+}
diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260531203937_AddPerSiteSweepCheckpoints.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260531203937_AddPerSiteSweepCheckpoints.Designer.cs
new file mode 100644
index 0000000..c2e0200
--- /dev/null
+++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260531203937_AddPerSiteSweepCheckpoints.Designer.cs
@@ -0,0 +1,1207 @@
+//
+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("20260531203937_AddPerSiteSweepCheckpoints")]
+ partial class AddPerSiteSweepCheckpoints
+ {
+ ///
+ 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("Id"));
+
+ b.Property("AssetId")
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("ComputedPrice")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("computed_price");
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ 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("MarketHashName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("market_hash_name");
+
+ b.Property("PaintSeed")
+ .HasColumnType("integer")
+ .HasColumnName("paint_seed");
+
+ b.Property("Phase")
+ .HasColumnType("text")
+ .HasColumnName("phase");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("PriceBeforeDiscount")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price_before_discount");
+
+ b.Property("Quality")
+ .HasColumnType("text")
+ .HasColumnName("quality");
+
+ b.Property("RemovedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("removed_at");
+
+ b.Property("SellOrderId")
+ .HasColumnType("bigint")
+ .HasColumnName("sell_order_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.HasKey("Id")
+ .HasName("pk_cs_money_listings");
+
+ b.HasIndex("AssetId")
+ .HasDatabaseName("ix_cs_money_listings_asset_id");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_cs_money_listings_condition_id");
+
+ b.HasIndex("SellOrderId")
+ .IsUnique()
+ .HasDatabaseName("ix_cs_money_listings_sell_order_id");
+
+ b.HasIndex("SkinInstanceId")
+ .HasDatabaseName("ix_cs_money_listings_skin_instance_id");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_cs_money_listings_status");
+
+ b.HasIndex("SkinId", "ConditionId")
+ .HasDatabaseName("ix_cs_money_listings_skin_id_condition_id");
+
+ b.ToTable("cs_money_listings", "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.MarketListing", b =>
+ {
+ b.Property("AssetId")
+ .HasColumnType("text")
+ .HasColumnName("asset_id");
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("ExternalId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("external_id");
+
+ b.Property("FirstSeenAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("first_seen_at");
+
+ b.Property("FloatValue")
+ .HasColumnType("numeric")
+ .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("MarketHashName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("market_hash_name");
+
+ b.Property("Marketplace")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("marketplace");
+
+ b.Property("PaintSeed")
+ .HasColumnType("integer")
+ .HasColumnName("paint_seed");
+
+ b.Property("Price")
+ .HasColumnType("numeric")
+ .HasColumnName("price");
+
+ b.Property("RemovedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("removed_at");
+
+ 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("Wear")
+ .HasColumnType("text")
+ .HasColumnName("wear");
+
+ b.ToTable((string)null);
+
+ b.ToView("market_listings", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("condition_id");
+
+ b.Property("Currency")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("currency");
+
+ b.Property("Price")
+ .HasPrecision(18, 2)
+ .HasColumnType("numeric(18,2)")
+ .HasColumnName("price");
+
+ b.Property("RecordedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("recorded_at");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_price_histories");
+
+ b.HasIndex("ConditionId")
+ .HasDatabaseName("ix_price_histories_condition_id");
+
+ b.HasIndex("SkinId", "ConditionId", "RecordedAt")
+ .HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at");
+
+ b.ToTable("price_histories", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ItemCount")
+ .HasColumnType("integer")
+ .HasColumnName("item_count");
+
+ b.Property("RanAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("ran_at");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.HasKey("Id")
+ .HasName("pk_scrape_runs");
+
+ b.HasIndex("Source", "RanAt")
+ .HasDatabaseName("ix_scrape_runs_source_ran_at");
+
+ b.ToTable("scrape_runs", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("DefIndex")
+ .HasColumnType("integer")
+ .HasColumnName("def_index");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("FloatMax")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_max");
+
+ b.Property("FloatMin")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("float_min");
+
+ b.Property("ImageUrl")
+ .HasColumnType("text")
+ .HasColumnName("image_url");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("PaintIndex")
+ .HasColumnType("integer")
+ .HasColumnName("paint_index");
+
+ b.Property("Rarity")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("rarity");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("SouvenirAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("souvenir_available");
+
+ b.Property("StatTrakAvailable")
+ .HasColumnType("boolean")
+ .HasColumnName("stat_trak_available");
+
+ b.Property("TrueFloat")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("boolean")
+ .HasColumnName("true_float")
+ .HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
+
+ b.Property("WeaponId")
+ .HasColumnType("integer")
+ .HasColumnName("weapon_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skins");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_slug");
+
+ b.HasIndex("TrueFloat")
+ .HasDatabaseName("ix_skins_true_float");
+
+ b.HasIndex("WeaponId")
+ .HasDatabaseName("ix_skins_weapon_id");
+
+ b.HasIndex("DefIndex", "PaintIndex")
+ .IsUnique()
+ .HasDatabaseName("ix_skins_def_index_paint_index")
+ .HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
+
+ b.ToTable("skins", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Condition")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("condition");
+
+ b.Property("MaxFloat")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("max_float");
+
+ b.Property("MinFloat")
+ .HasColumnType("numeric(10,9)")
+ .HasColumnName("min_float");
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_conditions");
+
+ b.HasIndex("SkinId")
+ .HasDatabaseName("ix_skin_conditions_skin_id");
+
+ b.ToTable("skin_conditions", "skintracker");
+ });
+
+ modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinConditionSweep", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("SkinConditionId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_condition_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.Property("SweptAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("swept_at");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_condition_sweeps");
+
+ b.HasIndex("SkinConditionId", "Source")
+ .IsUnique()
+ .HasDatabaseName("ix_skin_condition_sweeps_skin_condition_id_source");
+
+ b.HasIndex("Source", "SweptAt")
+ .HasDatabaseName("ix_skin_condition_sweeps_source_swept_at");
+
+ b.ToTable("skin_condition_sweeps", "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.SkinSweep", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("SkinId")
+ .HasColumnType("integer")
+ .HasColumnName("skin_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("source");
+
+ b.Property("SweptAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("swept_at");
+
+ b.HasKey("Id")
+ .HasName("pk_skin_sweeps");
+
+ b.HasIndex("SkinId", "Source")
+ .IsUnique()
+ .HasDatabaseName("ix_skin_sweeps_skin_id_source");
+
+ b.HasIndex("Source", "SweptAt")
+ .HasDatabaseName("ix_skin_sweeps_source_swept_at");
+
+ b.ToTable("skin_sweeps", "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