add csfloat api usage

This commit is contained in:
bob
2026-05-29 22:08:32 -05:00
parent b51f1d9f5f
commit d1752b1b07
37 changed files with 6095 additions and 22 deletions

View File

@@ -0,0 +1,556 @@
using BlueLaminate.EFCore.Data;
using BlueLaminate.EFCore.Entities;
using BlueLaminate.Scraper.CsFloat;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace BlueLaminate.Cli;
/// <param name="Pages">How many API pages were fetched.</param>
/// <param name="Seen">Total listings returned across those pages.</param>
/// <param name="Inserted">New listings inserted.</param>
/// <param name="Updated">Existing listings refreshed (price/last-seen/etc.).</param>
/// <param name="Removed">Listings flagged Removed (only on a complete pass).</param>
/// <param name="Linked">Listings resolved to a catalogue skin by def/paint.</param>
/// <param name="StoppedReason">Why the sweep ended.</param>
public sealed record ListingSweepResult(
int Pages,
int Seen,
int Inserted,
int Updated,
int Removed,
int Linked,
string StoppedReason);
/// <param name="SkinsCovered">Catalogue skins fully paged this run.</param>
/// <param name="SkinsSkipped">Skins left untouched (e.g. request budget ran out).</param>
public sealed record CatalogSweepResult(
int SkinsCovered,
int SkinsSkipped,
int Pages,
int Seen,
int Inserted,
int Updated,
int Removed,
string StoppedReason);
/// <summary>
/// Global incremental sweep of CSFloat active listings into the database. Pages
/// <c>sort_by=most_recent</c> with no item filter, so it captures every listing —
/// including items not in our catalogue. Each listing is upserted by its stable
/// CSFloat id; <see cref="Listing.FirstSeenAt"/>/<see cref="Listing.LastSeenAt"/>
/// bound the observation window.
///
/// Two things keep it safe against the 200-request rate limit and partial runs:
/// <list type="bullet">
/// <item><b>Pacing.</b> After each page it inspects the client's rate-limit
/// headers; when remaining is low it sleeps until the reset epoch rather than
/// risking a 429.</item>
/// <item><b>Removed-tracking only on a complete pass.</b> Marking unseen listings
/// as Removed is only valid when the whole market was covered. A capped or
/// incremental run that stops early must not do it, or it would falsely "sell"
/// everything it didn't reach.</item>
/// </list>
/// </summary>
public sealed class ListingSweepService
{
public const string Source = "listings";
public const string CatalogSource = "listings-catalog";
// Pace before the bucket is fully empty so a slightly-stale counter can't tip
// us into a 429.
private const int RateLimitSafetyMargin = 2;
private readonly SkinTrackerDbContext _db;
private readonly CsFloatListingsClient _client;
private readonly ILogger<ListingSweepService> _logger;
public ListingSweepService(
SkinTrackerDbContext db,
CsFloatListingsClient client,
ILogger<ListingSweepService> logger)
{
_db = db;
_client = client;
_logger = logger;
}
/// <param name="maxRequests">Hard cap on API pages this run (rate-limit budget).</param>
/// <param name="maxListings">Hard cap on listings ingested this run.</param>
/// <param name="incremental">
/// Stop once a whole page is already-known listings (cheap daily delta). When
/// false, keep paging until the cursor or a cap is exhausted (cold pass).
/// </param>
/// <param name="delayBetweenPages">Optional courtesy delay between pages.</param>
public async Task<ListingSweepResult> SweepAsync(
int maxRequests = 4,
int maxListings = 200,
bool incremental = true,
TimeSpan? delayBetweenPages = null,
CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var pages = 0;
var seen = 0;
var inserted = 0;
var updated = 0;
var linked = 0;
string? cursor = null;
string stoppedReason = "cursor exhausted";
var completePass = true;
// Catalogue lookup for best-effort skin linking, built once per run.
var skinByIndex = await _db.Skins
.Where(s => s.DefIndex != null && s.PaintIndex != null)
.Select(s => new { s.Id, s.DefIndex, s.PaintIndex })
.ToDictionaryAsync(s => (s.DefIndex!.Value, s.PaintIndex!.Value), s => s.Id, ct);
// Track which listing ids we touched this run, so a complete pass can flag
// the rest as Removed.
var touchedIds = new HashSet<string>();
var touchedInstanceIds = new HashSet<int>();
while (true)
{
if (pages >= maxRequests)
{
stoppedReason = $"hit max-requests cap ({maxRequests})";
completePass = false;
break;
}
if (seen >= maxListings)
{
stoppedReason = $"hit max-listings cap ({maxListings})";
completePass = false;
break;
}
ListingsPageResult page;
try
{
page = await _client.FetchPageAsync(
defIndex: null, paintIndex: null, sortBy: "most_recent",
limit: 50, cursor: cursor, ct: ct);
}
catch (CsFloatApiException ex)
{
_logger.LogError("Sweep aborted: {Message}", ex.Message);
stoppedReason = $"API error: {ex.Status}";
completePass = false;
break;
}
pages++;
seen += page.Listings.Count;
var (ins, upd, link, allKnown) = await IngestPageAsync(
page.Listings, skinByIndex, touchedIds, touchedInstanceIds, now, ct);
inserted += ins;
updated += upd;
linked += link;
_logger.LogInformation(
"Page {Page}: {Count} listings ({Ins} new, {Upd} updated); {Rate}",
pages, page.Listings.Count, ins, upd, _client.LastRateLimit);
cursor = page.Cursor;
// End of the market.
if (string.IsNullOrEmpty(cursor) || page.Listings.Count == 0)
{
stoppedReason = "cursor exhausted";
break;
}
// Incremental short-circuit: a full page we already knew means we've
// caught up to the previous sweep. This is a partial pass by design.
if (incremental && allKnown)
{
stoppedReason = "reached already-seen listings (incremental)";
completePass = false;
break;
}
await PaceAsync(delayBetweenPages, ct);
}
// Persist inserts/updates before computing Removed so the touched set is durable.
await _db.SaveChangesAsync(ct);
var removed = 0;
if (completePass)
removed = await MarkRemovedAsync(touchedIds, now, ct);
else
_logger.LogInformation("Partial pass — skipping Removed-tracking to avoid false sales.");
await FlagDupesAsync(touchedInstanceIds, now, ct);
await _db.ScrapeRuns.AddAsync(
new ScrapeRun { Source = Source, RanAt = now, ItemCount = seen }, ct);
await _db.SaveChangesAsync(ct);
return new ListingSweepResult(pages, seen, inserted, updated, removed, linked, stoppedReason);
}
/// <summary>
/// Catalogue-driven sweep: walk skins that have def/paint indexes and query
/// each one's listings with a server-side def_index+paint_index filter. The
/// API returns only that skin's listings, so no rate-limit budget is wasted on
/// stickers/cases/agents — every request is productive weapon data. Because
/// each skin is paged to completion, Removed-tracking is accurate per skin
/// even when the overall run is capped: a skin we fully covered but whose old
/// listing is now absent is genuinely gone.
/// </summary>
/// <param name="maxRequests">Hard cap on API pages across the whole run.</param>
/// <param name="maxListingsPerSkin">Safety cap on pages-worth per skin.</param>
/// <param name="delayBetweenPages">Optional courtesy delay between pages.</param>
public async Task<CatalogSweepResult> SweepCatalogAsync(
int maxRequests = 50,
int maxListingsPerSkin = 500,
TimeSpan? delayBetweenPages = null,
CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
// Least-recently-swept first (never-swept skins sort first because null
// orders before any timestamp ascending). This is the cross-run resume:
// a capped run always continues from where the previous one stopped, and
// the stalest data refreshes first.
var skins = await _db.Skins
.Where(s => s.DefIndex != null && s.PaintIndex != null)
.OrderBy(s => s.ListingsSweptAt)
.Select(s => new { s.Id, Def = s.DefIndex!.Value, Paint = s.PaintIndex!.Value })
.ToListAsync(ct);
var pages = 0;
var seen = 0;
var inserted = 0;
var updated = 0;
var removed = 0;
var covered = 0;
var stoppedReason = "all catalogue skins covered";
foreach (var skin in skins)
{
if (pages >= maxRequests)
{
stoppedReason = $"hit max-requests cap ({maxRequests})";
break;
}
// One-entry lookup so IngestPageAsync resolves SkinId to this skin.
var lookup = new Dictionary<(int, int), int> { [(skin.Def, skin.Paint)] = skin.Id };
var touchedIds = new HashSet<string>();
var touchedInstanceIds = new HashSet<int>();
string? cursor = null;
var skinComplete = true;
var skinSeen = 0;
while (true)
{
if (pages >= maxRequests)
{
stoppedReason = $"hit max-requests cap ({maxRequests})";
skinComplete = false;
break;
}
ListingsPageResult page;
try
{
page = await _client.FetchPageAsync(
defIndex: skin.Def, paintIndex: skin.Paint, sortBy: "lowest_price",
limit: 50, cursor: cursor, ct: ct);
}
catch (CsFloatApiException ex)
{
_logger.LogError("Catalogue sweep aborted on skin {SkinId}: {Message}", skin.Id, ex.Message);
await _db.SaveChangesAsync(ct);
return Finish($"API error: {ex.Status}");
}
pages++;
seen += page.Listings.Count;
skinSeen += page.Listings.Count;
var (ins, upd, _, _) = await IngestPageAsync(
page.Listings, lookup, touchedIds, touchedInstanceIds, now, ct);
inserted += ins;
updated += upd;
cursor = page.Cursor;
if (string.IsNullOrEmpty(cursor) || page.Listings.Count == 0)
break;
if (skinSeen >= maxListingsPerSkin)
{
skinComplete = false; // didn't reach the end; don't mark Removed
break;
}
await PaceAsync(delayBetweenPages, ct);
}
// Per-skin Removed-tracking + resume stamp: only when this skin was
// paged to the end. A partial skin (hit the per-skin cap) is left with
// its old ListingsSweptAt so the next run revisits it first.
if (skinComplete)
{
removed += await MarkRemovedForSkinAsync(skin.Id, touchedIds, now, ct);
await _db.Skins
.Where(s => s.Id == skin.Id)
.ExecuteUpdateAsync(
setters => setters.SetProperty(s => s.ListingsSweptAt, now), ct);
covered++;
}
// Persist this skin's listings/instances before dupe analysis so the
// asset-id grouping query sees them.
await _db.SaveChangesAsync(ct);
await FlagDupesAsync(touchedInstanceIds, now, ct);
await _db.SaveChangesAsync(ct);
await PaceAsync(delayBetweenPages, ct);
}
await _db.ScrapeRuns.AddAsync(
new ScrapeRun { Source = CatalogSource, RanAt = now, ItemCount = seen }, ct);
await _db.SaveChangesAsync(ct);
return Finish(stoppedReason);
CatalogSweepResult Finish(string reason) =>
new(covered, skins.Count - covered, pages, seen, inserted, updated, removed, reason);
}
// Flag this skin's once-Active listings that we didn't see this run as Removed.
private async Task<int> MarkRemovedForSkinAsync(
int skinId, HashSet<string> touchedIds, DateTimeOffset now, CancellationToken ct)
{
return await _db.Listings
.Where(l => l.SkinId == skinId
&& l.Status == ListingStatus.Active
&& !touchedIds.Contains(l.CsFloatListingId))
.ExecuteUpdateAsync(
setters => setters
.SetProperty(l => l.Status, ListingStatus.Removed)
.SetProperty(l => l.RemovedAt, now),
ct);
}
// Upsert a page of listings. Returns counts plus whether every listing on the
// page already existed (the incremental stop signal). Also resolves each
// listing to a SkinInstance (the physical item, by fingerprint) and records
// the touched instance ids so the caller can run dupe detection over them.
private async Task<(int Inserted, int Updated, int Linked, bool AllKnown)> IngestPageAsync(
IReadOnlyList<CsFloatListing> listings,
IReadOnlyDictionary<(int, int), int> skinByIndex,
HashSet<string> touchedIds,
HashSet<int> touchedInstanceIds,
DateTimeOffset now,
CancellationToken ct)
{
if (listings.Count == 0)
return (0, 0, 0, true);
var ids = listings.Select(l => l.ListingId).ToList();
var existing = await _db.Listings
.Where(l => ids.Contains(l.CsFloatListingId))
.ToDictionaryAsync(l => l.CsFloatListingId, ct);
var inserted = 0;
var updated = 0;
var linked = 0;
var allKnown = true;
foreach (var l in listings)
{
touchedIds.Add(l.ListingId);
int? skinId = skinByIndex.TryGetValue((l.DefIndex, l.PaintIndex), out var id) ? id : null;
if (skinId is not null)
linked++;
// Resolve the physical item only when we know the skin — the
// fingerprint is meaningless without it.
var instance = skinId is { } sid
? await ResolveInstanceAsync(sid, l, now, ct)
: null;
if (instance is not null)
touchedInstanceIds.Add(instance.Id);
if (existing.TryGetValue(l.ListingId, out var row))
{
// Refresh mutable fields. Price can change; a re-appeared listing
// returns to Active.
row.Price = l.Price;
row.LastSeenAt = now;
row.Status = ListingStatus.Active;
row.RemovedAt = null;
row.SkinId = skinId;
row.AssetId = l.AssetId;
row.SkinInstance = instance;
updated++;
}
else
{
allKnown = false;
var entity = MapToEntity(l, skinId, now);
entity.SkinInstance = instance;
_db.Listings.Add(entity);
inserted++;
}
}
return (inserted, updated, linked, allKnown);
}
// Find the SkinInstance matching this listing's fingerprint, or create one.
// The fingerprint is (skin, full-precision float, seed, stattrak, souvenir).
// It is deliberately NOT unique — duped copies share it — so a match may
// already represent more than one physical item; dupe detection runs later.
private async Task<SkinInstance> ResolveInstanceAsync(
int skinId, CsFloatListing l, DateTimeOffset now, CancellationToken ct)
{
var seed = l.PaintSeed.ToString();
// Check the change-tracker first (an instance just added earlier this page
// isn't queryable yet), then the database.
var tracked = _db.ChangeTracker.Entries<SkinInstance>()
.Select(e => e.Entity)
.FirstOrDefault(i => i.SkinId == skinId && i.FloatValue == l.FloatValue
&& i.PaintSeed == seed && i.StatTrak == l.IsStatTrak && i.Souvenir == l.IsSouvenir);
if (tracked is not null)
{
tracked.LastSeenAt = now;
return tracked;
}
var instance = await _db.SkinInstances.FirstOrDefaultAsync(
i => i.SkinId == skinId && i.FloatValue == l.FloatValue
&& i.PaintSeed == seed && i.StatTrak == l.IsStatTrak && i.Souvenir == l.IsSouvenir,
ct);
if (instance is not null)
{
instance.LastSeenAt = now;
return instance;
}
instance = new SkinInstance
{
SkinId = skinId,
FloatValue = l.FloatValue,
PaintSeed = seed,
StatTrak = l.IsStatTrak,
Souvenir = l.IsSouvenir,
FirstSeenAt = now,
LastSeenAt = now,
};
_db.SkinInstances.Add(instance);
return instance;
}
private static Listing MapToEntity(CsFloatListing l, int? skinId, DateTimeOffset now) => new()
{
CsFloatListingId = l.ListingId,
Type = l.Type,
Price = l.Price,
ListedAt = l.CreatedAt,
AssetId = l.AssetId,
DefIndex = l.DefIndex,
PaintIndex = l.PaintIndex,
MarketHashName = l.MarketHashName,
WearName = l.WearName,
FloatValue = l.FloatValue,
PaintSeed = l.PaintSeed,
IsStatTrak = l.IsStatTrak,
IsSouvenir = l.IsSouvenir,
StickerCount = l.StickerCount,
SellerSteamId = l.SellerSteamId,
InspectLink = l.InspectLink,
SkinId = skinId,
FirstSeenAt = now,
LastSeenAt = now,
Status = ListingStatus.Active,
};
// Flag every currently-Active listing we did NOT see this run as Removed.
// Only called after a complete pass. Done in a single set-based update to
// avoid loading the whole table.
private async Task<int> MarkRemovedAsync(
HashSet<string> touchedIds, DateTimeOffset now, CancellationToken ct)
{
return await _db.Listings
.Where(l => l.Status == ListingStatus.Active && !touchedIds.Contains(l.CsFloatListingId))
.ExecuteUpdateAsync(
setters => setters
.SetProperty(l => l.Status, ListingStatus.Removed)
.SetProperty(l => l.RemovedAt, now),
ct);
}
// Dupe detection. For each instance touched this run, count the DISTINCT
// asset ids among its currently-Active listings. Two or more means the same
// fingerprint (skin+float+seed+ST+souvenir) is live under multiple Steam
// assets at once — the signature of a duplicated item, as opposed to an
// ordinary trade (which retires the old listing before the new one appears,
// leaving a single active asset). Flags freshly-detected dupes and stamps
// when first seen, enabling "alert on fresh duping" downstream.
private async Task FlagDupesAsync(
HashSet<int> instanceIds, DateTimeOffset now, CancellationToken ct)
{
if (instanceIds.Count == 0)
return;
// Instances (among those touched) with 2+ distinct active asset ids.
var dupeInstanceIds = await _db.Listings
.Where(l => l.SkinInstanceId != null
&& instanceIds.Contains(l.SkinInstanceId!.Value)
&& l.Status == ListingStatus.Active
&& l.AssetId != null)
.GroupBy(l => l.SkinInstanceId!.Value)
.Where(g => g.Select(l => l.AssetId).Distinct().Count() >= 2)
.Select(g => g.Key)
.ToListAsync(ct);
if (dupeInstanceIds.Count == 0)
return;
// Flag only those not already flagged, stamping first-seen once. Instances
// already marked stay marked (they're excluded by the !SuspectedDupe filter).
var newlyFlagged = await _db.SkinInstances
.Where(i => dupeInstanceIds.Contains(i.Id) && !i.SuspectedDupe)
.ExecuteUpdateAsync(
setters => setters
.SetProperty(i => i.SuspectedDupe, true)
.SetProperty(i => i.DupeFirstSeenAt, now),
ct);
if (newlyFlagged > 0)
_logger.LogWarning(
"Dupe detection: {Count} instance(s) newly flagged as suspected dupes.", newlyFlagged);
}
// Pace requests against the rate limit: if the bucket is nearly empty, sleep
// until the reset epoch. Otherwise apply only the optional courtesy delay.
private async Task PaceAsync(TimeSpan? delay, CancellationToken ct)
{
var rate = _client.LastRateLimit;
if (rate.Remaining is { } remaining && remaining <= RateLimitSafetyMargin
&& long.TryParse(rate.Reset, out var resetEpoch))
{
var resetAt = DateTimeOffset.FromUnixTimeSeconds(resetEpoch);
var wait = resetAt - DateTimeOffset.UtcNow;
if (wait > TimeSpan.Zero)
{
_logger.LogWarning(
"Rate limit nearly exhausted ({Remaining} left); sleeping {Seconds:0}s until reset.",
remaining, wait.TotalSeconds);
await Task.Delay(wait, ct);
return;
}
}
if (delay is { } d && d > TimeSpan.Zero)
await Task.Delay(d, ct);
}
}

View File

@@ -1,6 +1,9 @@
using BlueLaminate.Cli;
using BlueLaminate.Cli.Logging;
using BlueLaminate.EFCore.Data;
using BlueLaminate.Scraper.Browser;
using BlueLaminate.Scraper.CsFloat;
using BlueLaminate.Scraper.Proxies;
using BlueLaminate.Scraper.Skins;
using Microsoft.Extensions.Logging;
using OpenTelemetry;
@@ -46,13 +49,482 @@ syncSkins.SetAction((parseResult, ct) =>
loggerFactory,
ct));
var countryOption = new Option<string?>("--country")
{
Description = "Optional ISO country code(s) for the exit IP, e.g. \"us\" or \"us,gb\". Default: random."
};
var rotatingOption = new Option<bool>("--rotating")
{
Description = "Use a rotating exit IP instead of a pinned (sticky) session."
};
var probeProxy = new Command(
"probe-proxy",
"Launch non-headless Edge through the IPRoyal residential proxy and print the exit IP "
+ "to confirm auth works and the IP is residential. Reads IPROYAL_USERNAME / IPROYAL_PASSWORD.")
{
countryOption,
rotatingOption,
};
probeProxy.SetAction((parseResult, ct) =>
ProbeProxyAsync(
parseResult.GetValue(countryOption),
parseResult.GetValue(rotatingOption),
loggerFactory,
ct));
var defIndexOption = new Option<int?>("--def-index")
{
Description = "CSFloat weapon def_index (e.g. AK-47=7, M4A4=16)."
};
var paintIndexOption = new Option<int?>("--paint-index")
{
Description = "CSFloat paint_index for a specific skin (e.g. M4A4 | Cyber Security=985)."
};
var urlOption = new Option<string?>("--url")
{
Description = "Full CSFloat URL to open. Overrides --def-index/--paint-index when set."
};
var loadImagesOption = new Option<bool>("--load-images")
{
Description = "Load images (uses more bandwidth). Default off to conserve the metered plan."
};
var diagnoseOption = new Option<bool>("--diagnose")
{
Description = "Log every CSFloat-domain response (url + status + type) to reveal where a "
+ "Steam-login wall appears, not just /api/ JSON."
};
var outOption = new Option<string>("--out")
{
Description = "Directory to write captured JSON to.",
DefaultValueFactory = _ => "captures",
};
var captureCsfloat = new Command(
"capture-csfloat",
"Open a CSFloat search page through the residential proxy and dump every CSFloat /api/ "
+ "JSON response to disk while you browse (open a listing → 'Latest Sales'). "
+ "Reads IPROYAL_USERNAME / IPROYAL_PASSWORD.")
{
defIndexOption,
paintIndexOption,
urlOption,
countryOption,
loadImagesOption,
diagnoseOption,
outOption,
};
captureCsfloat.SetAction((parseResult, ct) =>
CaptureCsfloatAsync(
parseResult.GetValue(defIndexOption),
parseResult.GetValue(paintIndexOption),
parseResult.GetValue(urlOption),
parseResult.GetValue(countryOption),
parseResult.GetValue(loadImagesOption),
parseResult.GetValue(diagnoseOption),
parseResult.GetValue(outOption)!,
loggerFactory,
ct));
var sortByOption = new Option<string>("--sort-by")
{
Description = "Listing sort order: lowest_price, highest_price, most_recent, "
+ "lowest_float, highest_float, best_deal, etc.",
DefaultValueFactory = _ => "lowest_price",
};
var maxOption = new Option<int>("--max")
{
Description = "Maximum number of listings to fetch (paged 50 at a time).",
DefaultValueFactory = _ => 50,
};
var dumpOption = new Option<string?>("--dump")
{
Description = "Optional file path to write the fetched listings as JSON."
};
var fetchListings = new Command(
"fetch-listings",
"Fetch active CSFloat listings for one skin via the official API and print them. "
+ "Reads CSFLOAT_API_KEY. Fetch-and-print only — nothing is written to the database.")
{
defIndexOption,
paintIndexOption,
sortByOption,
maxOption,
dumpOption,
};
fetchListings.SetAction((parseResult, ct) =>
FetchListingsAsync(
parseResult.GetValue(defIndexOption),
parseResult.GetValue(paintIndexOption),
parseResult.GetValue(sortByOption)!,
parseResult.GetValue(maxOption),
parseResult.GetValue(dumpOption),
loggerFactory,
ct));
var maxRequestsOption = new Option<int>("--max-requests")
{
Description = "Hard cap on API pages this run (rate-limit budget; 200/window).",
DefaultValueFactory = _ => 4,
};
var maxIngestOption = new Option<int>("--max-listings")
{
Description = "Hard cap on listings ingested this run.",
DefaultValueFactory = _ => 200,
};
var fullOption = new Option<bool>("--full")
{
Description = "Cold full pass: keep paging past already-seen listings (default is "
+ "incremental — stop once caught up)."
};
var sweepListings = new Command(
"sweep-listings",
"Global incremental sweep of active CSFloat listings into the database. Pages most_recent, "
+ "upserts by listing id, paces off rate-limit headers. Reads CSFLOAT_API_KEY.")
{
maxRequestsOption,
maxIngestOption,
fullOption,
};
sweepListings.SetAction((parseResult, ct) =>
SweepListingsAsync(
parseResult.GetValue(maxRequestsOption),
parseResult.GetValue(maxIngestOption),
parseResult.GetValue(fullOption),
loggerFactory,
ct));
var catalogMaxRequestsOption = new Option<int>("--max-requests")
{
Description = "Hard cap on API pages across the whole run (rate-limit budget; 200/window).",
DefaultValueFactory = _ => 50,
};
var perSkinCapOption = new Option<int>("--max-per-skin")
{
Description = "Safety cap on listings fetched per skin before moving on.",
DefaultValueFactory = _ => 500,
};
var sweepCatalog = new Command(
"sweep-catalog",
"Catalogue-driven sweep: query each catalogue skin's listings by def_index+paint_index so "
+ "only weapons are fetched (no stickers/cases/agents). Per-skin Removed-tracking. "
+ "Reads CSFLOAT_API_KEY.")
{
catalogMaxRequestsOption,
perSkinCapOption,
};
sweepCatalog.SetAction((parseResult, ct) =>
SweepCatalogAsync(
parseResult.GetValue(catalogMaxRequestsOption),
parseResult.GetValue(perSkinCapOption),
loggerFactory,
ct));
var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker tools.")
{
syncSkins,
probeProxy,
captureCsfloat,
fetchListings,
sweepListings,
sweepCatalog,
};
return await root.Parse(args).InvokeAsync();
// Acquire an IPRoyal residential lease, drive a real (non-headless) Edge browser
// through it, and report the exit IP. This is the proxy/Selenium spike: it proves
// authenticated residential routing end-to-end for a few KB before any CSFloat
// scraping spends real bandwidth.
static async Task<int> ProbeProxyAsync(
string? country, bool rotating, ILoggerFactory loggerFactory, CancellationToken ct)
{
var username = Environment.GetEnvironmentVariable("IPROYAL_USERNAME");
var password = Environment.GetEnvironmentVariable("IPROYAL_PASSWORD");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
Console.Error.WriteLine(
"Set IPROYAL_USERNAME and IPROYAL_PASSWORD environment variables first.");
return 1;
}
var provider = new IpRoyalProxyProvider(username, password);
var factory = new BrowserDriverFactory(loggerFactory.CreateLogger<BrowserDriverFactory>());
var probe = new ProxyProbe(provider, factory, loggerFactory.CreateLogger<ProxyProbe>());
try
{
var info = await probe.RunAsync(new ProxyRequest(Country: country, Sticky: !rotating));
Console.WriteLine();
Console.WriteLine($" Exit IP : {info.Ip}");
Console.WriteLine($" Location: {info.City}, {info.Region}, {info.Country}");
Console.WriteLine($" Org/ASN : {info.Org}");
Console.WriteLine($" Hostname: {info.Hostname ?? ""}");
Console.WriteLine();
Console.WriteLine(
"Check Org/ASN: a consumer ISP = residential; a hosting provider = datacenter.");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Proxy probe failed: {ex.Message}");
return 1;
}
}
// Phase B: open a CSFloat search page through the residential proxy and dump
// every CSFloat /api/ JSON response to disk while the operator browses. This is
// how we discover the real endpoint/field shapes (active listings + Latest
// Sales) before designing tables or automating navigation.
static async Task<int> CaptureCsfloatAsync(
int? defIndex, int? paintIndex, string? url, string? country,
bool loadImages, bool diagnose, string outDir, ILoggerFactory loggerFactory, CancellationToken ct)
{
var username = Environment.GetEnvironmentVariable("IPROYAL_USERNAME");
var password = Environment.GetEnvironmentVariable("IPROYAL_PASSWORD");
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
Console.Error.WriteLine(
"Set IPROYAL_USERNAME and IPROYAL_PASSWORD environment variables first.");
return 1;
}
var targetUrl = BuildCsfloatUrl(url, defIndex, paintIndex);
var provider = new IpRoyalProxyProvider(username, password);
var factory = new BrowserDriverFactory(loggerFactory.CreateLogger<BrowserDriverFactory>());
var capture = new CsFloatCaptureService(
provider, factory, loggerFactory.CreateLogger<CsFloatCaptureService>());
Console.WriteLine($"Opening {targetUrl}");
Console.WriteLine(
"When the page loads: click a listing, then the 'Latest Sales' tab. "
+ "Capturing all CSFloat /api/ responses.");
Console.WriteLine("Press Enter here when you're done to close the browser.");
try
{
// Block until the operator presses Enter; the browser stays open and
// capturing the whole time. ReadLine is sync, so push it off-thread.
var count = await capture.RunAsync(
targetUrl,
outDir,
new ProxyRequest(Country: country, Sticky: true),
loadImages,
diagnose,
() => Task.Run(() => Console.ReadLine(), ct));
var full = Path.GetFullPath(outDir);
Console.WriteLine();
Console.WriteLine($"Captured {count} response(s) to {full}");
return 0;
}
catch (Exception ex)
{
Console.Error.WriteLine($"CSFloat capture failed: {ex.Message}");
return 1;
}
}
// Prefer an explicit --url; otherwise build a search URL from the indexes,
// defaulting to the M4A4 | Cyber Security example so the command runs as-is.
static string BuildCsfloatUrl(string? url, int? defIndex, int? paintIndex)
{
if (!string.IsNullOrWhiteSpace(url))
return url;
var def = defIndex ?? 16;
var paint = paintIndex ?? 985;
return $"https://csfloat.com/search?def_index={def}&paint_index={paint}";
}
// Fetch active listings for one skin via CSFloat's official API and print them.
// Fetch-and-print only — no DB — so we can verify the real field shapes against a
// live key before designing the Listing schema. Defaults to the M4A4 | Cyber
// Security sample so it runs with no args.
static async Task<int> FetchListingsAsync(
int? defIndex, int? paintIndex, string sortBy, int max, string? dumpPath,
ILoggerFactory loggerFactory, CancellationToken ct)
{
var apiKey = Environment.GetEnvironmentVariable("CSFLOAT_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.Error.WriteLine("Set the CSFLOAT_API_KEY environment variable first.");
return 1;
}
var def = defIndex ?? 16;
var paint = paintIndex ?? 985;
using var http = CreateHttpClient();
var client = new CsFloatListingsClient(
http, apiKey, loggerFactory.CreateLogger<CsFloatListingsClient>());
try
{
Console.WriteLine(
$"Fetching up to {max} active listings for def_index={def}, paint_index={paint} "
+ $"(sort: {sortBy})…");
var listings = await client.GetListingsAsync(def, paint, sortBy, max, ct: ct);
Console.WriteLine();
Console.WriteLine(client.LastRateLimit.ToString());
Console.WriteLine();
if (listings.Count == 0)
{
Console.WriteLine("No active listings found.");
return 0;
}
Console.WriteLine($"{listings.Count} listing(s):");
Console.WriteLine($" {"Price",10} {"Float",-10} {"Seed",-6} {"Wear",-16} {"Name"}");
foreach (var l in listings)
{
var st = (l.IsStatTrak ? " ST" : "") + (l.IsSouvenir ? " SV" : "")
+ (l.StickerCount > 0 ? $" +{l.StickerCount}stk" : "");
Console.WriteLine(
$" {l.Price,10:C} {l.FloatValue,-10:0.000000} {l.PaintSeed,-6} "
+ $"{l.WearName,-16} {l.MarketHashName}{st}");
}
if (!string.IsNullOrWhiteSpace(dumpPath))
{
var json = System.Text.Json.JsonSerializer.Serialize(
listings, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(dumpPath, json, ct);
Console.WriteLine();
Console.WriteLine($"Wrote {listings.Count} listing(s) to {Path.GetFullPath(dumpPath)}");
}
return 0;
}
catch (CsFloatApiException ex)
{
Console.Error.WriteLine(ex.Message);
Console.Error.WriteLine(client.LastRateLimit.ToString());
return 1;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Fetch failed: {ex.Message}");
return 1;
}
}
// Global incremental sweep of active CSFloat listings into the database. Paces
// off rate-limit headers and only marks listings Removed on a complete pass.
static async Task<int> SweepListingsAsync(
int maxRequests, int maxListings, bool full, ILoggerFactory loggerFactory, CancellationToken ct)
{
var apiKey = Environment.GetEnvironmentVariable("CSFLOAT_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.Error.WriteLine("Set the CSFLOAT_API_KEY environment variable first.");
return 1;
}
using var http = CreateHttpClient();
var client = new CsFloatListingsClient(
http, apiKey, loggerFactory.CreateLogger<CsFloatListingsClient>());
using var db = new SkinTrackerDbContextFactory().CreateDbContext([]);
var service = new ListingSweepService(
db, client, loggerFactory.CreateLogger<ListingSweepService>());
try
{
Console.WriteLine(
$"Sweeping listings ({(full ? "full cold pass" : "incremental")}; "
+ $"max {maxRequests} requests, {maxListings} listings)…");
var r = await service.SweepAsync(
maxRequests: maxRequests,
maxListings: maxListings,
incremental: !full,
ct: ct);
Console.WriteLine();
Console.WriteLine($"Sweep complete ({r.StoppedReason}):");
Console.WriteLine($" Pages fetched : {r.Pages}");
Console.WriteLine($" Listings seen : {r.Seen}");
Console.WriteLine($" Inserted : {r.Inserted}");
Console.WriteLine($" Updated : {r.Updated}");
Console.WriteLine($" Removed : {r.Removed}");
Console.WriteLine($" Catalog-linked: {r.Linked}");
Console.WriteLine();
Console.WriteLine(client.LastRateLimit.ToString());
return 0;
}
catch (CsFloatApiException ex)
{
Console.Error.WriteLine(ex.Message);
Console.Error.WriteLine(client.LastRateLimit.ToString());
return 1;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Sweep failed: {ex.Message}");
return 1;
}
}
// Catalogue-driven sweep: query each catalogue skin's listings by def/paint so
// only weapons are fetched (no stickers/cases/agents) and Removed-tracking is
// accurate per skin. Writes to the database.
static async Task<int> SweepCatalogAsync(
int maxRequests, int maxPerSkin, ILoggerFactory loggerFactory, CancellationToken ct)
{
var apiKey = Environment.GetEnvironmentVariable("CSFLOAT_API_KEY");
if (string.IsNullOrWhiteSpace(apiKey))
{
Console.Error.WriteLine("Set the CSFLOAT_API_KEY environment variable first.");
return 1;
}
using var http = CreateHttpClient();
var client = new CsFloatListingsClient(
http, apiKey, loggerFactory.CreateLogger<CsFloatListingsClient>());
using var db = new SkinTrackerDbContextFactory().CreateDbContext([]);
var service = new ListingSweepService(
db, client, loggerFactory.CreateLogger<ListingSweepService>());
try
{
Console.WriteLine(
$"Catalogue sweep (weapons only; max {maxRequests} requests, {maxPerSkin}/skin)…");
var r = await service.SweepCatalogAsync(
maxRequests: maxRequests, maxListingsPerSkin: maxPerSkin, ct: ct);
Console.WriteLine();
Console.WriteLine($"Catalogue sweep complete ({r.StoppedReason}):");
Console.WriteLine($" Skins covered : {r.SkinsCovered}");
Console.WriteLine($" Skins skipped : {r.SkinsSkipped}");
Console.WriteLine($" Pages fetched : {r.Pages}");
Console.WriteLine($" Listings seen : {r.Seen}");
Console.WriteLine($" Inserted : {r.Inserted}");
Console.WriteLine($" Updated : {r.Updated}");
Console.WriteLine($" Removed : {r.Removed}");
Console.WriteLine();
Console.WriteLine(client.LastRateLimit.ToString());
return 0;
}
catch (CsFloatApiException ex)
{
Console.Error.WriteLine(ex.Message);
Console.Error.WriteLine(client.LastRateLimit.ToString());
return 1;
}
catch (Exception ex)
{
Console.Error.WriteLine($"Catalogue sweep failed: {ex.Message}");
return 1;
}
}
// Load the CS2 skin catalogue from the CSGO-API dataset and upsert it. Weapons
// and collections are derived from the skins themselves. Throttled to once a
// month unless --force; --dry-run loads and prints without a DB.
@@ -73,8 +545,9 @@ static async Task<int> SyncSkinsAsync(
var tags = (s.StatTrakAvailable ? " ST" : "") + (s.SouvenirAvailable ? " SV" : "");
var range = s.FloatMin is not null ? $"{s.FloatMin:0.00}-{s.FloatMax:0.00}" : "—";
var sources = s.Sources.Count > 0 ? string.Join(", ", s.Sources.Select(x => x.Name)) : "—";
var idx = $"{s.DefIndex?.ToString() ?? ""}/{s.PaintIndex?.ToString() ?? ""}";
Console.WriteLine(
$" {s.WeaponName,-16} {s.Name,-24} {s.Rarity,-14} {range,-10} {sources}{tags}");
$" {idx,-10} {s.WeaponName,-16} {s.Name,-24} {s.Rarity,-14} {range,-10} {sources}{tags}");
}
return 0;
}

View File

@@ -161,6 +161,8 @@ public sealed class SkinSyncService
}
Set(() => skin.Name, v => skin.Name = v, s.Name);
Set<int?>(() => skin.DefIndex, v => skin.DefIndex = v, s.DefIndex);
Set<int?>(() => skin.PaintIndex, v => skin.PaintIndex = v, s.PaintIndex);
Set(() => skin.Rarity, v => skin.Rarity = v, s.Rarity);
Set(() => skin.Description, v => skin.Description = v, s.Description);
Set(() => skin.ImageUrl, v => skin.ImageUrl = v, s.ImageUrl);

View File

@@ -0,0 +1,43 @@
using BlueLaminate.EFCore.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace BlueLaminate.EFCore.Configurations;
public class ListingConfiguration : IEntityTypeConfiguration<Listing>
{
public void Configure(EntityTypeBuilder<Listing> entity)
{
// CSFloat's listing id is the natural key; the incremental sweep upserts
// against it and must never create duplicates.
entity.HasIndex(e => e.CsFloatListingId).IsUnique();
entity.Property(e => e.Price).HasPrecision(18, 2);
// Full precision to match SkinInstance for exact fingerprint joins.
entity.Property(e => e.FloatValue).HasColumnType("numeric(20,18)");
// Store the enum as text so the DB is self-describing (matches the
// project's readable-data leaning over opaque ints).
entity.Property(e => e.Status).HasConversion<string>();
// The sweep filters/sorts by item identity and by what's still active.
entity.HasIndex(e => new { e.DefIndex, e.PaintIndex });
entity.HasIndex(e => e.Status);
// Best-effort catalogue link: a global sweep sees items we may not have,
// so the FK is optional and set null if the skin is later removed.
entity.HasOne(e => e.Skin)
.WithMany()
.HasForeignKey(e => e.SkinId)
.OnDelete(DeleteBehavior.SetNull);
// Listings roll up to the physical item they represent.
entity.HasOne(e => e.SkinInstance)
.WithMany(i => i.Listings)
.HasForeignKey(e => e.SkinInstanceId)
.OnDelete(DeleteBehavior.SetNull);
// Dupe analysis groups a fingerprint's listings by asset id.
entity.HasIndex(e => e.AssetId);
}
}

View File

@@ -21,6 +21,18 @@ public class SkinConfiguration : IEntityTypeConfiguration<Skin>
// Slug is the natural key the sync upserts against.
entity.HasIndex(e => e.Slug).IsUnique();
// Market listings join back to a skin by (def_index, paint_index). Unique
// among populated rows; filtered so the many catalogue rows that predate
// these columns (null) don't collide. Postgres treats nulls as distinct
// anyway, but the filter makes the intent explicit and the index smaller.
entity.HasIndex(e => new { e.DefIndex, e.PaintIndex })
.IsUnique()
.HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
// The catalogue sweep orders skins by when they were last swept (nulls
// first) to resume across capped runs; index that ordering.
entity.HasIndex(e => e.ListingsSweptAt);
entity.HasOne(e => e.Weapon)
.WithMany(w => w.Skins)
.HasForeignKey(e => e.WeaponId);

View File

@@ -8,19 +8,34 @@ public class SkinInstanceConfiguration : IEntityTypeConfiguration<SkinInstance>
{
public void Configure(EntityTypeBuilder<SkinInstance> entity)
{
entity.Property(e => e.FloatValue).HasColumnType("numeric(10,9)");
// Full precision so exact-match dupe detection isn't defeated by rounding.
// CSFloat returns deterministic ~17-digit floats; numeric(20,18) holds them.
entity.Property(e => e.FloatValue).HasColumnType("numeric(20,18)");
// Primary lookup key for trade fingerprinting.
entity.HasIndex(e => e.FloatValue);
entity.HasIndex(e => e.PaintSeed);
// The fingerprint that identifies a physical item. NOT unique: duped items
// legitimately share a fingerprint, and detecting that collision is the
// point. Indexed for fast fingerprint resolution during the sweep.
entity.HasIndex(e => new
{
e.SkinId,
e.FloatValue,
e.PaintSeed,
e.StatTrak,
e.Souvenir,
});
// Surfacing fresh dupes is a hot query.
entity.HasIndex(e => e.SuspectedDupe);
entity.HasOne(e => e.Skin)
.WithMany(s => s.Instances)
.HasForeignKey(e => e.SkinId);
// Condition is optional now (derived from float later); set null on delete
// rather than restrict so condition rows can change without blocking.
entity.HasOne(e => e.Condition)
.WithMany(c => c.Instances)
.HasForeignKey(e => e.ConditionId)
.OnDelete(DeleteBehavior.Restrict);
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -29,6 +29,7 @@ public class SkinTrackerDbContext : DbContext
public DbSet<Trade> Trades => Set<Trade>();
public DbSet<TradeItem> TradeItems => Set<TradeItem>();
public DbSet<PriceHistory> PriceHistories => Set<PriceHistory>();
public DbSet<Listing> Listings => Set<Listing>();
/// <summary>The PostgreSQL schema that owns all of this context's tables.</summary>
public const string Schema = "skintracker";
@@ -48,5 +49,6 @@ public class SkinTrackerDbContext : DbContext
modelBuilder.ApplyConfiguration(new TradeConfiguration());
modelBuilder.ApplyConfiguration(new TradeItemConfiguration());
modelBuilder.ApplyConfiguration(new PriceHistoryConfiguration());
modelBuilder.ApplyConfiguration(new ListingConfiguration());
}
}

View File

@@ -0,0 +1,86 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>Lifecycle of a CSFloat listing as observed across sweeps.</summary>
public enum ListingStatus
{
/// <summary>Seen in the most recent sweep that covered it.</summary>
Active = 0,
/// <summary>
/// Previously seen, then absent from a sweep that should have covered it —
/// i.e. sold or delisted. The disappearance is the signal; we can't tell sold
/// from delisted with certainty, but <see cref="Listing.LastSeenAt"/> bounds when.
/// </summary>
Removed = 1,
}
/// <summary>
/// One active-market listing observed on CSFloat via the official
/// <c>GET /api/v1/listings</c> endpoint. Rows are keyed by CSFloat's own listing
/// id and soft-tracked across sweeps: <see cref="FirstSeenAt"/>/<see cref="LastSeenAt"/>
/// bound the observation window and <see cref="Status"/> flips to
/// <see cref="ListingStatus.Removed"/> when a once-seen listing stops appearing,
/// which approximates a sale/delisting.
///
/// A global sweep returns items that may not be in our catalogue, so
/// <see cref="SkinId"/> is a best-effort nullable link (resolved by
/// def_index + paint_index); the listing stands on its own without it.
/// </summary>
public class Listing
{
public int Id { get; set; }
/// <summary>CSFloat's listing id (a snowflake string). Natural key for dedup.</summary>
public string CsFloatListingId { get; set; } = null!;
/// <summary>"buy_now" or "auction".</summary>
public string Type { get; set; } = null!;
/// <summary>Asking price in USD.</summary>
public decimal Price { get; set; }
/// <summary>When CSFloat says the listing was created.</summary>
public DateTimeOffset ListedAt { get; set; }
// Item identity. Stored directly (not only via the Skin link) so listings for
// items outside our catalogue are still fully described.
public int DefIndex { get; set; }
public int PaintIndex { get; set; }
public string MarketHashName { get; set; } = null!;
public string? WearName { get; set; }
public decimal FloatValue { get; set; }
public int PaintSeed { get; set; }
public bool IsStatTrak { get; set; }
public bool IsSouvenir { get; set; }
public int StickerCount { get; set; }
public string? SellerSteamId { get; set; }
public string? InspectLink { get; set; }
/// <summary>
/// Steam asset id of the listed copy. Changes on trade, so not a stable
/// identity — but the discriminator that distinguishes duped copies which
/// otherwise share an identical fingerprint.
/// </summary>
public string? AssetId { get; set; }
/// <summary>Best-effort catalogue link, resolved by def_index + paint_index. Null if unmatched.</summary>
public int? SkinId { get; set; }
public Skin? Skin { get; set; }
/// <summary>
/// The physical item (by fingerprint) this listing is for. Many listings over
/// time roll up to one instance, forming its market-movement history. Nullable
/// because catalogue-less items can't be fingerprinted to a known skin.
/// </summary>
public int? SkinInstanceId { get; set; }
public SkinInstance? SkinInstance { get; set; }
// Soft-tracking across sweeps.
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
public ListingStatus Status { get; set; }
/// <summary>When the listing was marked Removed (absent from a sweep). Null while Active.</summary>
public DateTimeOffset? RemovedAt { get; set; }
}

View File

@@ -9,6 +9,19 @@ public class Skin
/// <summary>Stable id from the CSGO-API catalogue, e.g. "skin-e757fd7191f9". The natural key.</summary>
public string Slug { get; set; } = null!;
// CSFloat/CS item indexes, sourced from the static catalogue (weapon.weapon_id
// and paint_index). Together they identify a skin on CSFloat and let market
// listings join back to this catalogue row. Nullable until a sync populates
// them, since older catalogue rows predate these columns.
public int? DefIndex { get; set; }
public int? PaintIndex { get; set; }
// When the catalogue-driven listing sweep last fully covered this skin. The
// sweep processes least-recently-swept skins first (nulls = never swept), so
// capped runs chain across the whole catalogue and the stalest data refreshes
// first. Null until the first sweep reaches this skin.
public DateTimeOffset? ListingsSweptAt { get; set; }
public string Name { get; set; } = null!;
public string Rarity { get; set; } = null!;
public string? Description { get; set; }

View File

@@ -1,20 +1,50 @@
namespace BlueLaminate.EFCore.Entities;
/// <summary>
/// One physical CS2 item, identified by its fingerprint
/// (Skin + FloatValue + PaintSeed + StatTrak + Souvenir) rather than its Steam
/// asset id, which changes on every trade. Decoupled from Steam inventories on
/// purpose: an instance exists from market observation alone, and the optional
/// <see cref="InventoryItem"/> bridge ties it to a <c>SteamUser</c> only once we
/// crawl inventories.
///
/// Duping note: a duplicated item is a byte-for-byte copy with an identical
/// fingerprint, so a fingerprint is NOT guaranteed unique to one physical item.
/// We treat the fingerprint as the item, and flag <see cref="SuspectedDupe"/>
/// when the same fingerprint is seen live under two or more different asset ids
/// at once (see the sweep's dupe detection).
/// </summary>
public class SkinInstance
{
public int Id { get; set; }
public int SkinId { get; set; }
public Skin Skin { get; set; } = null!;
public int ConditionId { get; set; }
public SkinCondition Condition { get; set; } = null!;
// FloatValue + PaintSeed form a stable fingerprint across trades; the Steam
// asset_id changes on every trade but these do not.
// Nullable: market observation gives a float but not a derived wear bucket.
// Condition can be backfilled later from the float without blocking ingest.
public int? ConditionId { get; set; }
public SkinCondition? Condition { get; set; }
// The fingerprint. FloatValue is stored at full precision (see config) so
// that exact-match dupe detection isn't fooled by rounding.
public decimal FloatValue { get; set; }
public string PaintSeed { get; set; } = null!;
public bool StatTrak { get; set; }
public bool Souvenir { get; set; }
public DateTimeOffset FirstSeenAt { get; set; }
public DateTimeOffset LastSeenAt { get; set; }
/// <summary>
/// True once this fingerprint was observed live under 2+ distinct asset ids
/// simultaneously — the signature of duplication.
/// </summary>
public bool SuspectedDupe { get; set; }
/// <summary>When the dupe condition was first detected. Null until then.</summary>
public DateTimeOffset? DupeFirstSeenAt { get; set; }
/// <summary>Every market listing observed for this physical item over time.</summary>
public ICollection<Listing> Listings { get; set; } = new List<Listing>();
public ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
}

View File

@@ -0,0 +1,829 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260530014903_AddListingsAndSkinIndexes")]
partial class AddListingsAndSkinIndexes
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CsFloatListingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cs_float_listing_id");
b.Property<int>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("InspectLink")
.HasColumnType("text")
.HasColumnName("inspect_link");
b.Property<bool>("IsSouvenir")
.HasColumnType("boolean")
.HasColumnName("is_souvenir");
b.Property<bool>("IsStatTrak")
.HasColumnType("boolean")
.HasColumnName("is_stat_trak");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<DateTimeOffset>("ListedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listed_at");
b.Property<string>("MarketHashName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("market_hash_name");
b.Property<int>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<int>("PaintSeed")
.HasColumnType("integer")
.HasColumnName("paint_seed");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("removed_at");
b.Property<string>("SellerSteamId")
.HasColumnType("text")
.HasColumnName("seller_steam_id");
b.Property<int?>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<int>("StickerCount")
.HasColumnType("integer")
.HasColumnName("sticker_count");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.Property<string>("WearName")
.HasColumnType("text")
.HasColumnName("wear_name");
b.HasKey("Id")
.HasName("pk_listings");
b.HasIndex("CsFloatListingId")
.IsUnique()
.HasDatabaseName("ix_listings_cs_float_listing_id");
b.HasIndex("SkinId")
.HasDatabaseName("ix_listings_skin_id");
b.HasIndex("Status")
.HasDatabaseName("ix_listings_status");
b.HasIndex("DefIndex", "PaintIndex")
.HasDatabaseName("ix_listings_def_index_paint_index");
b.ToTable("listings", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int?>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("integer")
.HasColumnName("collections_id");
b.Property<int>("SkinsId")
.HasColumnType("integer")
.HasColumnName("skins_id");
b.HasKey("CollectionsId", "SkinsId")
.HasName("pk_skin_collections");
b.HasIndex("SkinsId")
.HasDatabaseName("ix_skin_collections_skins_id");
b.ToTable("skin_collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany()
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_collections_collections_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
.WithMany()
.HasForeignKey("SkinsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_skins_skins_id");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,126 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddListingsAndSkinIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "def_index",
schema: "skintracker",
table: "skins",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "paint_index",
schema: "skintracker",
table: "skins",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "listings",
schema: "skintracker",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
cs_float_listing_id = table.Column<string>(type: "text", nullable: false),
type = table.Column<string>(type: "text", nullable: false),
price = table.Column<decimal>(type: "numeric(18,2)", precision: 18, scale: 2, nullable: false),
listed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
def_index = table.Column<int>(type: "integer", nullable: false),
paint_index = table.Column<int>(type: "integer", nullable: false),
market_hash_name = table.Column<string>(type: "text", nullable: false),
wear_name = table.Column<string>(type: "text", nullable: true),
float_value = table.Column<decimal>(type: "numeric(10,9)", nullable: false),
paint_seed = table.Column<int>(type: "integer", nullable: false),
is_stat_trak = table.Column<bool>(type: "boolean", nullable: false),
is_souvenir = table.Column<bool>(type: "boolean", nullable: false),
sticker_count = table.Column<int>(type: "integer", nullable: false),
seller_steam_id = table.Column<string>(type: "text", nullable: true),
inspect_link = table.Column<string>(type: "text", nullable: true),
skin_id = table.Column<int>(type: "integer", nullable: true),
first_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
last_seen_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
removed_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_listings", x => x.id);
table.ForeignKey(
name: "fk_listings_skins_skin_id",
column: x => x.skin_id,
principalSchema: "skintracker",
principalTable: "skins",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "ix_skins_def_index_paint_index",
schema: "skintracker",
table: "skins",
columns: new[] { "def_index", "paint_index" },
unique: true,
filter: "def_index IS NOT NULL AND paint_index IS NOT NULL");
migrationBuilder.CreateIndex(
name: "ix_listings_cs_float_listing_id",
schema: "skintracker",
table: "listings",
column: "cs_float_listing_id",
unique: true);
migrationBuilder.CreateIndex(
name: "ix_listings_def_index_paint_index",
schema: "skintracker",
table: "listings",
columns: new[] { "def_index", "paint_index" });
migrationBuilder.CreateIndex(
name: "ix_listings_skin_id",
schema: "skintracker",
table: "listings",
column: "skin_id");
migrationBuilder.CreateIndex(
name: "ix_listings_status",
schema: "skintracker",
table: "listings",
column: "status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "listings",
schema: "skintracker");
migrationBuilder.DropIndex(
name: "ix_skins_def_index_paint_index",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "def_index",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "paint_index",
schema: "skintracker",
table: "skins");
}
}
}

View File

@@ -0,0 +1,836 @@
// <auto-generated />
using System;
using BlueLaminate.EFCore.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
[DbContext(typeof(SkinTrackerDbContext))]
[Migration("20260530023217_AddSkinListingsSweptAt")]
partial class AddSkinListingsSweptAt
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("CsFloatListingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cs_float_listing_id");
b.Property<int>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("InspectLink")
.HasColumnType("text")
.HasColumnName("inspect_link");
b.Property<bool>("IsSouvenir")
.HasColumnType("boolean")
.HasColumnName("is_souvenir");
b.Property<bool>("IsStatTrak")
.HasColumnType("boolean")
.HasColumnName("is_stat_trak");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<DateTimeOffset>("ListedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listed_at");
b.Property<string>("MarketHashName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("market_hash_name");
b.Property<int>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<int>("PaintSeed")
.HasColumnType("integer")
.HasColumnName("paint_seed");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("removed_at");
b.Property<string>("SellerSteamId")
.HasColumnType("text")
.HasColumnName("seller_steam_id");
b.Property<int?>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<int>("StickerCount")
.HasColumnType("integer")
.HasColumnName("sticker_count");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.Property<string>("WearName")
.HasColumnType("text")
.HasColumnName("wear_name");
b.HasKey("Id")
.HasName("pk_listings");
b.HasIndex("CsFloatListingId")
.IsUnique()
.HasDatabaseName("ix_listings_cs_float_listing_id");
b.HasIndex("SkinId")
.HasDatabaseName("ix_listings_skin_id");
b.HasIndex("Status")
.HasDatabaseName("ix_listings_status");
b.HasIndex("DefIndex", "PaintIndex")
.HasDatabaseName("ix_listings_def_index_paint_index");
b.ToTable("listings", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<DateTimeOffset?>("ListingsSweptAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listings_swept_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int?>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("ListingsSweptAt")
.HasDatabaseName("ix_skins_listings_swept_at");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.HasIndex("DefIndex", "PaintIndex")
.IsUnique()
.HasDatabaseName("ix_skins_def_index_paint_index")
.HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_value");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("integer")
.HasColumnName("collections_id");
b.Property<int>("SkinsId")
.HasColumnType("integer")
.HasColumnName("skins_id");
b.HasKey("CollectionsId", "SkinsId")
.HasName("pk_skin_collections");
b.HasIndex("SkinsId")
.HasDatabaseName("ix_skin_collections_skins_id");
b.ToTable("skin_collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany()
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_collections_collections_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
.WithMany()
.HasForeignKey("SkinsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_skins_skins_id");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddSkinListingsSweptAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTimeOffset>(
name: "listings_swept_at",
schema: "skintracker",
table: "skins",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_skins_listings_swept_at",
schema: "skintracker",
table: "skins",
column: "listings_swept_at");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_skins_listings_swept_at",
schema: "skintracker",
table: "skins");
migrationBuilder.DropColumn(
name: "listings_swept_at",
schema: "skintracker",
table: "skins");
}
}
}

View File

@@ -0,0 +1,868 @@
// <auto-generated />
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("20260530030305_AddSkinInstanceDupeTrackingModelB")]
partial class AddSkinInstanceDupeTrackingModelB
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_collections");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_collections_slug");
b.ToTable("collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("AcquiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acquired_at");
b.Property<string>("AssetId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<int>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AssetId")
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<string>("CsFloatListingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cs_float_listing_id");
b.Property<int>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(20,18)")
.HasColumnName("float_value");
b.Property<string>("InspectLink")
.HasColumnType("text")
.HasColumnName("inspect_link");
b.Property<bool>("IsSouvenir")
.HasColumnType("boolean")
.HasColumnName("is_souvenir");
b.Property<bool>("IsStatTrak")
.HasColumnType("boolean")
.HasColumnName("is_stat_trak");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<DateTimeOffset>("ListedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listed_at");
b.Property<string>("MarketHashName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("market_hash_name");
b.Property<int>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<int>("PaintSeed")
.HasColumnType("integer")
.HasColumnName("paint_seed");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("removed_at");
b.Property<string>("SellerSteamId")
.HasColumnType("text")
.HasColumnName("seller_steam_id");
b.Property<int?>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<int?>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<int>("StickerCount")
.HasColumnType("integer")
.HasColumnName("sticker_count");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.Property<string>("WearName")
.HasColumnType("text")
.HasColumnName("wear_name");
b.HasKey("Id")
.HasName("pk_listings");
b.HasIndex("AssetId")
.HasDatabaseName("ix_listings_asset_id");
b.HasIndex("CsFloatListingId")
.IsUnique()
.HasDatabaseName("ix_listings_cs_float_listing_id");
b.HasIndex("SkinId")
.HasDatabaseName("ix_listings_skin_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_listings_skin_instance_id");
b.HasIndex("Status")
.HasDatabaseName("ix_listings_status");
b.HasIndex("DefIndex", "PaintIndex")
.HasDatabaseName("ix_listings_def_index_paint_index");
b.ToTable("listings", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset>("RecordedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("recorded_at");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ItemCount")
.HasColumnType("integer")
.HasColumnName("item_count");
b.Property<DateTimeOffset>("RanAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ran_at");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<decimal?>("FloatMax")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_max");
b.Property<decimal?>("FloatMin")
.HasColumnType("numeric(10,9)")
.HasColumnName("float_min");
b.Property<string>("ImageUrl")
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<DateTimeOffset?>("ListingsSweptAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listings_swept_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int?>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
.HasColumnName("rarity");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("text")
.HasColumnName("slug");
b.Property<bool>("SouvenirAvailable")
.HasColumnType("boolean")
.HasColumnName("souvenir_available");
b.Property<bool>("StatTrakAvailable")
.HasColumnType("boolean")
.HasColumnName("stat_trak_available");
b.Property<bool?>("TrueFloat")
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("boolean")
.HasColumnName("true_float")
.HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true);
b.Property<int>("WeaponId")
.HasColumnType("integer")
.HasColumnName("weapon_id");
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("ListingsSweptAt")
.HasDatabaseName("ix_skins_listings_swept_at");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
b.HasIndex("TrueFloat")
.HasDatabaseName("ix_skins_true_float");
b.HasIndex("WeaponId")
.HasDatabaseName("ix_skins_weapon_id");
b.HasIndex("DefIndex", "PaintIndex")
.IsUnique()
.HasDatabaseName("ix_skins_def_index_paint_index")
.HasFilter("def_index IS NOT NULL AND paint_index IS NOT NULL");
b.ToTable("skins", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Condition")
.IsRequired()
.HasColumnType("text")
.HasColumnName("condition");
b.Property<decimal>("MaxFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("max_float");
b.Property<decimal>("MinFloat")
.HasColumnType("numeric(10,9)")
.HasColumnName("min_float");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.HasKey("Id")
.HasName("pk_skin_conditions");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_conditions_skin_id");
b.ToTable("skin_conditions", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset?>("DupeFirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("dupe_first_seen_at");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(20,18)")
.HasColumnName("float_value");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
.HasColumnName("paint_seed");
b.Property<int>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<bool>("Souvenir")
.HasColumnType("boolean")
.HasColumnName("souvenir");
b.Property<bool>("StatTrak")
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.Property<bool>("SuspectedDupe")
.HasColumnType("boolean")
.HasColumnName("suspected_dupe");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("SuspectedDupe")
.HasDatabaseName("ix_skin_instances_suspected_dupe");
b.HasIndex("SkinId", "FloatValue", "PaintSeed", "StatTrak", "Souvenir")
.HasDatabaseName("ix_skin_instances_skin_id_float_value_paint_seed_stat_trak_sou");
b.ToTable("skin_instances", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<DateTimeOffset>("LastSyncedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_synced_at");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FromUserId")
.HasColumnType("integer")
.HasColumnName("from_user_id");
b.Property<string>("SteamTradeId")
.HasColumnType("text")
.HasColumnName("steam_trade_id");
b.Property<int>("ToUserId")
.HasColumnType("integer")
.HasColumnName("to_user_id");
b.Property<DateTimeOffset>("TradedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("traded_at");
b.HasKey("Id")
.HasName("pk_trades");
b.HasIndex("FromUserId")
.HasDatabaseName("ix_trades_from_user_id");
b.HasIndex("ToUserId")
.HasDatabaseName("ix_trades_to_user_id");
b.ToTable("trades", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("InventoryItemId")
.HasColumnType("integer")
.HasColumnName("inventory_item_id");
b.Property<int>("TradeId")
.HasColumnType("integer")
.HasColumnName("trade_id");
b.HasKey("Id")
.HasName("pk_trade_items");
b.HasIndex("InventoryItemId")
.HasDatabaseName("ix_trade_items_inventory_item_id");
b.HasIndex("TradeId")
.HasDatabaseName("ix_trade_items_trade_id");
b.ToTable("trade_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("Team")
.IsRequired()
.HasColumnType("text")
.HasColumnName("team");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_weapons");
b.HasIndex("Name")
.IsUnique()
.HasDatabaseName("ix_weapons_name");
b.ToTable("weapons", "skintracker");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.Property<int>("CollectionsId")
.HasColumnType("integer")
.HasColumnName("collections_id");
b.Property<int>("SkinsId")
.HasColumnType("integer")
.HasColumnName("skins_id");
b.HasKey("CollectionsId", "SkinsId")
.HasName("pk_skin_collections");
b.HasIndex("SkinsId")
.HasDatabaseName("ix_skin_collections_skins_id");
b.ToTable("skin_collections", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("InventoryItems")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_skin_instances_skin_instance_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "User")
.WithMany("InventoryItems")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_inventory_items_steam_users_user_id");
b.Navigation("SkinInstance");
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany()
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skins_skin_id");
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("Listings")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skin_instances_skin_instance_id");
b.Navigation("Skin");
b.Navigation("SkinInstance");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("PriceHistories")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_price_histories_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("PriceHistories")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_price_histories_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Weapon", "Weapon")
.WithMany("Skins")
.HasForeignKey("WeaponId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skins_weapons_weapon_id");
b.Navigation("Weapon");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Conditions")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_conditions_skins_skin_id");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany("Instances")
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_instances_skins_skin_id");
b.Navigation("Condition");
b.Navigation("Skin");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "FromUser")
.WithMany("TradesSent")
.HasForeignKey("FromUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_from_user_id");
b.HasOne("BlueLaminate.EFCore.Entities.SteamUser", "ToUser")
.WithMany("TradesReceived")
.HasForeignKey("ToUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.HasConstraintName("fk_trades_steam_users_to_user_id");
b.Navigation("FromUser");
b.Navigation("ToUser");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.InventoryItem", "InventoryItem")
.WithMany("TradeItems")
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_inventory_items_inventory_item_id");
b.HasOne("BlueLaminate.EFCore.Entities.Trade", "Trade")
.WithMany("TradeItems")
.HasForeignKey("TradeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_trade_items_trades_trade_id");
b.Navigation("InventoryItem");
b.Navigation("Trade");
});
modelBuilder.Entity("CollectionSkin", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Collection", null)
.WithMany()
.HasForeignKey("CollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_collections_collections_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", null)
.WithMany()
.HasForeignKey("SkinsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_skin_collections_skins_skins_id");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.InventoryItem", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b =>
{
b.Navigation("Conditions");
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b =>
{
b.Navigation("Instances");
b.Navigation("PriceHistories");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
b.Navigation("Listings");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>
{
b.Navigation("InventoryItems");
b.Navigation("TradesReceived");
b.Navigation("TradesSent");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b =>
{
b.Navigation("TradeItems");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b =>
{
b.Navigation("Skins");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,259 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BlueLaminate.EFCore.Migrations
{
/// <inheritdoc />
public partial class AddSkinInstanceDupeTrackingModelB : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_skin_instances_skin_conditions_condition_id",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_skin_instances_float_value",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_skin_instances_paint_seed",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_skin_instances_skin_id",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.AlterColumn<decimal>(
name: "float_value",
schema: "skintracker",
table: "skin_instances",
type: "numeric(20,18)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)");
migrationBuilder.AlterColumn<int>(
name: "condition_id",
schema: "skintracker",
table: "skin_instances",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer");
migrationBuilder.AddColumn<DateTimeOffset>(
name: "dupe_first_seen_at",
schema: "skintracker",
table: "skin_instances",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "last_seen_at",
schema: "skintracker",
table: "skin_instances",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)));
migrationBuilder.AddColumn<bool>(
name: "suspected_dupe",
schema: "skintracker",
table: "skin_instances",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AlterColumn<decimal>(
name: "float_value",
schema: "skintracker",
table: "listings",
type: "numeric(20,18)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "numeric(10,9)");
migrationBuilder.AddColumn<string>(
name: "asset_id",
schema: "skintracker",
table: "listings",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "skin_instance_id",
schema: "skintracker",
table: "listings",
type: "integer",
nullable: true);
migrationBuilder.CreateIndex(
name: "ix_skin_instances_skin_id_float_value_paint_seed_stat_trak_sou",
schema: "skintracker",
table: "skin_instances",
columns: new[] { "skin_id", "float_value", "paint_seed", "stat_trak", "souvenir" });
migrationBuilder.CreateIndex(
name: "ix_skin_instances_suspected_dupe",
schema: "skintracker",
table: "skin_instances",
column: "suspected_dupe");
migrationBuilder.CreateIndex(
name: "ix_listings_asset_id",
schema: "skintracker",
table: "listings",
column: "asset_id");
migrationBuilder.CreateIndex(
name: "ix_listings_skin_instance_id",
schema: "skintracker",
table: "listings",
column: "skin_instance_id");
migrationBuilder.AddForeignKey(
name: "fk_listings_skin_instances_skin_instance_id",
schema: "skintracker",
table: "listings",
column: "skin_instance_id",
principalSchema: "skintracker",
principalTable: "skin_instances",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
migrationBuilder.AddForeignKey(
name: "fk_skin_instances_skin_conditions_condition_id",
schema: "skintracker",
table: "skin_instances",
column: "condition_id",
principalSchema: "skintracker",
principalTable: "skin_conditions",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_listings_skin_instances_skin_instance_id",
schema: "skintracker",
table: "listings");
migrationBuilder.DropForeignKey(
name: "fk_skin_instances_skin_conditions_condition_id",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_skin_instances_skin_id_float_value_paint_seed_stat_trak_sou",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_skin_instances_suspected_dupe",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropIndex(
name: "ix_listings_asset_id",
schema: "skintracker",
table: "listings");
migrationBuilder.DropIndex(
name: "ix_listings_skin_instance_id",
schema: "skintracker",
table: "listings");
migrationBuilder.DropColumn(
name: "dupe_first_seen_at",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropColumn(
name: "last_seen_at",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropColumn(
name: "suspected_dupe",
schema: "skintracker",
table: "skin_instances");
migrationBuilder.DropColumn(
name: "asset_id",
schema: "skintracker",
table: "listings");
migrationBuilder.DropColumn(
name: "skin_instance_id",
schema: "skintracker",
table: "listings");
migrationBuilder.AlterColumn<decimal>(
name: "float_value",
schema: "skintracker",
table: "skin_instances",
type: "numeric(10,9)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "numeric(20,18)");
migrationBuilder.AlterColumn<int>(
name: "condition_id",
schema: "skintracker",
table: "skin_instances",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true);
migrationBuilder.AlterColumn<decimal>(
name: "float_value",
schema: "skintracker",
table: "listings",
type: "numeric(10,9)",
nullable: false,
oldClrType: typeof(decimal),
oldType: "numeric(20,18)");
migrationBuilder.CreateIndex(
name: "ix_skin_instances_float_value",
schema: "skintracker",
table: "skin_instances",
column: "float_value");
migrationBuilder.CreateIndex(
name: "ix_skin_instances_paint_seed",
schema: "skintracker",
table: "skin_instances",
column: "paint_seed");
migrationBuilder.CreateIndex(
name: "ix_skin_instances_skin_id",
schema: "skintracker",
table: "skin_instances",
column: "skin_id");
migrationBuilder.AddForeignKey(
name: "fk_skin_instances_skin_conditions_condition_id",
schema: "skintracker",
table: "skin_instances",
column: "condition_id",
principalSchema: "skintracker",
principalTable: "skin_conditions",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
}
}
}

View File

@@ -98,6 +98,133 @@ namespace BlueLaminate.EFCore.Migrations
b.ToTable("inventory_items", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AssetId")
.HasColumnType("text")
.HasColumnName("asset_id");
b.Property<string>("CsFloatListingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("cs_float_listing_id");
b.Property<int>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(20,18)")
.HasColumnName("float_value");
b.Property<string>("InspectLink")
.HasColumnType("text")
.HasColumnName("inspect_link");
b.Property<bool>("IsSouvenir")
.HasColumnType("boolean")
.HasColumnName("is_souvenir");
b.Property<bool>("IsStatTrak")
.HasColumnType("boolean")
.HasColumnName("is_stat_trak");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<DateTimeOffset>("ListedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listed_at");
b.Property<string>("MarketHashName")
.IsRequired()
.HasColumnType("text")
.HasColumnName("market_hash_name");
b.Property<int>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<int>("PaintSeed")
.HasColumnType("integer")
.HasColumnName("paint_seed");
b.Property<decimal>("Price")
.HasPrecision(18, 2)
.HasColumnType("numeric(18,2)")
.HasColumnName("price");
b.Property<DateTimeOffset?>("RemovedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("removed_at");
b.Property<string>("SellerSteamId")
.HasColumnType("text")
.HasColumnName("seller_steam_id");
b.Property<int?>("SkinId")
.HasColumnType("integer")
.HasColumnName("skin_id");
b.Property<int?>("SkinInstanceId")
.HasColumnType("integer")
.HasColumnName("skin_instance_id");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<int>("StickerCount")
.HasColumnType("integer")
.HasColumnName("sticker_count");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.Property<string>("WearName")
.HasColumnType("text")
.HasColumnName("wear_name");
b.HasKey("Id")
.HasName("pk_listings");
b.HasIndex("AssetId")
.HasDatabaseName("ix_listings_asset_id");
b.HasIndex("CsFloatListingId")
.IsUnique()
.HasDatabaseName("ix_listings_cs_float_listing_id");
b.HasIndex("SkinId")
.HasDatabaseName("ix_listings_skin_id");
b.HasIndex("SkinInstanceId")
.HasDatabaseName("ix_listings_skin_instance_id");
b.HasIndex("Status")
.HasDatabaseName("ix_listings_status");
b.HasIndex("DefIndex", "PaintIndex")
.HasDatabaseName("ix_listings_def_index_paint_index");
b.ToTable("listings", "skintracker");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.Property<int>("Id")
@@ -186,6 +313,10 @@ namespace BlueLaminate.EFCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("DefIndex")
.HasColumnType("integer")
.HasColumnName("def_index");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
@@ -202,11 +333,19 @@ namespace BlueLaminate.EFCore.Migrations
.HasColumnType("text")
.HasColumnName("image_url");
b.Property<DateTimeOffset?>("ListingsSweptAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("listings_swept_at");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<int?>("PaintIndex")
.HasColumnType("integer")
.HasColumnName("paint_index");
b.Property<string>("Rarity")
.IsRequired()
.HasColumnType("text")
@@ -238,6 +377,9 @@ namespace BlueLaminate.EFCore.Migrations
b.HasKey("Id")
.HasName("pk_skins");
b.HasIndex("ListingsSweptAt")
.HasDatabaseName("ix_skins_listings_swept_at");
b.HasIndex("Slug")
.IsUnique()
.HasDatabaseName("ix_skins_slug");
@@ -248,6 +390,11 @@ namespace BlueLaminate.EFCore.Migrations
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");
});
@@ -295,18 +442,26 @@ namespace BlueLaminate.EFCore.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("ConditionId")
b.Property<int?>("ConditionId")
.HasColumnType("integer")
.HasColumnName("condition_id");
b.Property<DateTimeOffset?>("DupeFirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("dupe_first_seen_at");
b.Property<DateTimeOffset>("FirstSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("first_seen_at");
b.Property<decimal>("FloatValue")
.HasColumnType("numeric(10,9)")
.HasColumnType("numeric(20,18)")
.HasColumnName("float_value");
b.Property<DateTimeOffset>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_seen_at");
b.Property<string>("PaintSeed")
.IsRequired()
.HasColumnType("text")
@@ -324,20 +479,21 @@ namespace BlueLaminate.EFCore.Migrations
.HasColumnType("boolean")
.HasColumnName("stat_trak");
b.Property<bool>("SuspectedDupe")
.HasColumnType("boolean")
.HasColumnName("suspected_dupe");
b.HasKey("Id")
.HasName("pk_skin_instances");
b.HasIndex("ConditionId")
.HasDatabaseName("ix_skin_instances_condition_id");
b.HasIndex("FloatValue")
.HasDatabaseName("ix_skin_instances_float_value");
b.HasIndex("SuspectedDupe")
.HasDatabaseName("ix_skin_instances_suspected_dupe");
b.HasIndex("PaintSeed")
.HasDatabaseName("ix_skin_instances_paint_seed");
b.HasIndex("SkinId")
.HasDatabaseName("ix_skin_instances_skin_id");
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");
});
@@ -514,6 +670,25 @@ namespace BlueLaminate.EFCore.Migrations
b.Navigation("User");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.Listing", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
.WithMany()
.HasForeignKey("SkinId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skins_skin_id");
b.HasOne("BlueLaminate.EFCore.Entities.SkinInstance", "SkinInstance")
.WithMany("Listings")
.HasForeignKey("SkinInstanceId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_listings_skin_instances_skin_instance_id");
b.Navigation("Skin");
b.Navigation("SkinInstance");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b =>
{
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
@@ -564,8 +739,7 @@ namespace BlueLaminate.EFCore.Migrations
b.HasOne("BlueLaminate.EFCore.Entities.SkinCondition", "Condition")
.WithMany("Instances")
.HasForeignKey("ConditionId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired()
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_skin_instances_skin_conditions_condition_id");
b.HasOne("BlueLaminate.EFCore.Entities.Skin", "Skin")
@@ -663,6 +837,8 @@ namespace BlueLaminate.EFCore.Migrations
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b =>
{
b.Navigation("InventoryItems");
b.Navigation("Listings");
});
modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b =>

View File

@@ -6,4 +6,9 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageReference Include="Selenium.WebDriver" Version="4.44.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,88 @@
using BlueLaminate.Scraper.Proxies;
using Microsoft.Extensions.Logging;
using OpenQA.Selenium;
using OpenQA.Selenium.Edge;
namespace BlueLaminate.Scraper.Browser;
/// <summary>
/// Builds a non-headless Edge (Chromium) WebDriver routed through a
/// <see cref="ProxyLease"/>. Two things make this non-trivial:
/// <list type="bullet">
/// <item>Proxy authentication. Chromium can't auto-fill the gateway's auth
/// dialog under automation, and the classic extension trick relies on
/// Manifest V2 which current Chromium disables. Instead we answer the proxy's
/// 407 challenge through the DevTools (CDP) auth handler, which works
/// non-headless and needs no extension.</item>
/// <item>Bandwidth. The residential plan is metered per GB, so images are
/// disabled at the content-settings level. Cloudflare gates on JS execution and
/// TLS/behaviour, not whether pictures render, so this stays realistic.</item>
/// </list>
/// Each driver gets a throwaway user-data dir so runs never share cookies and
/// never touch the user's real Edge profile.
/// </summary>
public sealed class BrowserDriverFactory
{
private readonly ILogger<BrowserDriverFactory> _logger;
public BrowserDriverFactory(ILogger<BrowserDriverFactory> logger)
{
_logger = logger;
}
public async Task<IWebDriver> CreateAsync(ProxyLease lease, bool blockImages = true)
{
var options = new EdgeOptions();
// Route browser traffic through the gateway via the launch argument
// rather than EdgeOptions.Proxy. Setting Proxy makes Selenium hand the
// gateway to Selenium Manager for the driver *download* too, which fails
// because that step can't authenticate. The arg scopes the proxy to the
// browser only; credentials are answered below via CDP. No scheme = all
// protocols use the gateway.
options.AddArgument($"--proxy-server={lease.Endpoint}");
// Reduce the most obvious automation tells; residential exit + a real
// (non-headless) browser do the rest.
options.AddArgument("--disable-blink-features=AutomationControlled");
options.AddExcludedArgument("enable-automation");
options.AddArgument("--no-first-run");
options.AddArgument("--no-default-browser-check");
options.AddArgument("--start-maximized");
// Isolated, disposable profile per launch.
var profileDir = Path.Combine(Path.GetTempPath(), "bluelaminate-edge", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(profileDir);
options.AddArgument($"--user-data-dir={profileDir}");
if (blockImages)
options.AddUserProfilePreference("profile.managed_default_content_settings.images", 2);
_logger.LogInformation(
"Launching Edge via proxy {Endpoint} (provider {Provider}, session {Session}).",
lease.Endpoint, lease.Provider, lease.SessionId ?? "rotating");
var driver = new EdgeDriver(options);
try
{
// Answer the gateway's proxy-auth (407) challenge with the lease
// credentials. UriMatcher returns true so it applies to every
// request, since the challenge originates from the proxy itself.
var network = driver.Manage().Network;
network.AddAuthenticationHandler(new NetworkAuthenticationHandler
{
UriMatcher = _ => true,
Credentials = new PasswordCredentials(lease.Username, lease.Password),
});
await network.StartMonitoring();
}
catch
{
driver.Quit();
throw;
}
return driver;
}
}

View File

@@ -0,0 +1,139 @@
using System.Text;
using BlueLaminate.Scraper.Browser;
using BlueLaminate.Scraper.Proxies;
using Microsoft.Extensions.Logging;
using OpenQA.Selenium;
namespace BlueLaminate.Scraper.CsFloat;
/// <summary>
/// Phase-B discovery tool. Drives a real Edge browser through a residential
/// lease to a CSFloat search page, then records every CSFloat <c>/api/</c> JSON
/// response to disk while a human clicks around (open a listing → "Latest
/// Sales"). We don't yet know CSFloat's exact endpoints or DOM selectors, so a
/// human-in-the-loop is the cheapest way to surface the real traffic: the tool
/// just listens and dumps, the operator drives the UI in the visible window.
/// Once we can see the captured shapes we can automate navigation and design the
/// tables.
/// </summary>
public sealed class CsFloatCaptureService
{
private readonly IProxyProvider _provider;
private readonly BrowserDriverFactory _factory;
private readonly ILogger<CsFloatCaptureService> _logger;
public CsFloatCaptureService(
IProxyProvider provider,
BrowserDriverFactory factory,
ILogger<CsFloatCaptureService> logger)
{
_provider = provider;
_factory = factory;
_logger = logger;
}
/// <summary>
/// Opens <paramref name="url"/> through the proxy and captures CSFloat API
/// responses to <paramref name="outputDir"/> until <paramref name="browseUntilDone"/>
/// completes (the CLI ties that to the operator pressing Enter). When
/// <paramref name="diagnose"/> is true, every CSFloat-domain response is
/// logged (url + status + type) to reveal where a login wall appears.
/// Returns the number of responses written.
/// </summary>
public async Task<int> RunAsync(
string url,
string outputDir,
ProxyRequest request,
bool loadImages,
bool diagnose,
Func<Task> browseUntilDone)
{
Directory.CreateDirectory(outputDir);
var lease = _provider.Acquire(request);
var driver = await _factory.CreateAsync(lease, blockImages: !loadImages);
var captured = 0;
void OnResponse(object? sender, NetworkResponseReceivedEventArgs e)
{
var responseUrl = e.ResponseUrl;
if (string.IsNullOrEmpty(responseUrl)
|| !responseUrl.Contains("csfloat", StringComparison.OrdinalIgnoreCase))
{
return;
}
// Diagnose mode logs every CSFloat-domain response — including the
// SPA shell, redirects and any 401/403 — so we can see exactly where
// a Steam-login wall appears even before any /api/ call fires.
if (diagnose)
{
_logger.LogInformation("[{Status}] {Type} {Url}",
e.ResponseStatusCode, e.ResponseResourceType, responseUrl);
}
// Only JSON API calls get written to disk; skip the shell, images,
// fonts, analytics, etc. Matches both api.csfloat.com and csfloat.com/api.
if (!responseUrl.Contains("/api/", StringComparison.OrdinalIgnoreCase))
return;
var body = e.ResponseBody;
if (string.IsNullOrWhiteSpace(body))
{
// Body wasn't buffered (e.g. the known Fetch interception race).
// Log the endpoint so we still learn it exists even if empty.
_logger.LogWarning("No body captured for {Url} (status {Status}).",
responseUrl, e.ResponseStatusCode);
return;
}
try
{
var n = Interlocked.Increment(ref captured);
var fileName = $"{n:D3}_{Sanitize(responseUrl)}.json";
File.WriteAllText(Path.Combine(outputDir, fileName), body, Encoding.UTF8);
_logger.LogInformation(
"Captured #{N} [{Status}] {Url} → {File} ({Bytes} bytes).",
n, e.ResponseStatusCode, responseUrl, fileName, body.Length);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to write capture for {Url}.", responseUrl);
}
}
var network = driver.Manage().Network;
network.NetworkResponseReceived += OnResponse;
try
{
_logger.LogInformation("Navigating to {Url}", url);
driver.Navigate().GoToUrl(url);
await browseUntilDone();
}
finally
{
network.NetworkResponseReceived -= OnResponse;
driver.Quit();
}
return captured;
}
// Turn a URL into a filesystem-safe, readable, length-capped file stem so the
// captures are self-describing (the endpoint is visible in the filename).
private static string Sanitize(string url)
{
var trimmed = url
.Replace("https://", "", StringComparison.OrdinalIgnoreCase)
.Replace("http://", "", StringComparison.OrdinalIgnoreCase);
var sb = new StringBuilder(trimmed.Length);
foreach (var c in trimmed)
sb.Append(char.IsLetterOrDigit(c) || c is '-' or '.' ? c : '_');
var stem = sb.ToString();
return stem.Length > 120 ? stem[..120] : stem;
}
}

View File

@@ -0,0 +1,47 @@
namespace BlueLaminate.Scraper.CsFloat;
/// <summary>
/// A single active CSFloat listing, flattened from the API's listing+item shape
/// to the fields this project cares about. Prices arrive from CSFloat as integer
/// cents and are converted to whole-dollar <see cref="decimal"/> here so callers
/// never deal in cents. This is a read model for the official, documented
/// <c>GET /api/v1/listings</c> endpoint — active listings only, not sales.
/// </summary>
/// <param name="ListingId">CSFloat listing id (stable while the listing is live).</param>
/// <param name="CreatedAt">When the listing was created.</param>
/// <param name="Type">"buy_now" or "auction".</param>
/// <param name="Price">Asking price in USD (converted from cents).</param>
/// <param name="MarketHashName">Canonical item name, e.g. "M4A4 | Cyber Security (Field-Tested)".</param>
/// <param name="DefIndex">Weapon definition index (maps to catalog weapon_id).</param>
/// <param name="PaintIndex">Paint index (maps to catalog paint_index).</param>
/// <param name="PaintSeed">Pattern seed.</param>
/// <param name="FloatValue">Exact float/wear value.</param>
/// <param name="WearName">Wear bucket name, e.g. "Field-Tested".</param>
/// <param name="IsStatTrak">StatTrak™ variant.</param>
/// <param name="IsSouvenir">Souvenir variant.</param>
/// <param name="StickerCount">Number of stickers applied.</param>
/// <param name="SellerSteamId">Seller's SteamID64.</param>
/// <param name="InspectLink">In-game inspect link.</param>
/// <param name="AssetId">
/// Steam asset id of this specific copy. Changes when the item trades, so it is
/// NOT a stable item identity — but two live listings sharing a fingerprint
/// (skin+float+seed+ST/souvenir) yet showing different asset ids are the
/// signature of a duplicated ("duped") item.
/// </param>
public sealed record CsFloatListing(
string ListingId,
DateTimeOffset CreatedAt,
string Type,
decimal Price,
string MarketHashName,
int DefIndex,
int PaintIndex,
int PaintSeed,
decimal FloatValue,
string? WearName,
bool IsStatTrak,
bool IsSouvenir,
int StickerCount,
string? SellerSteamId,
string? InspectLink,
string? AssetId);

View File

@@ -0,0 +1,277 @@
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
namespace BlueLaminate.Scraper.CsFloat;
/// <summary>
/// Thrown when CSFloat rejects a request (bad/missing key, rate limit, etc.) so
/// the CLI can surface a clear message instead of a raw HTTP failure.
/// </summary>
public sealed class CsFloatApiException(HttpStatusCode status, string body)
: Exception($"CSFloat API returned {(int)status} {status}: {body}")
{
public HttpStatusCode Status { get; } = status;
}
/// <summary>One page of listings plus the opaque cursor for the next page (null at the end).</summary>
public sealed record ListingsPageResult(IReadOnlyList<CsFloatListing> Listings, string? Cursor);
/// <summary>
/// Client for CSFloat's official, documented <c>GET /api/v1/listings</c> endpoint
/// (active listings). Authenticates with a developer API key via the
/// <c>Authorization</c> header, filters by def_index/paint_index, and walks the
/// cursor-based pagination. This is the supported path the user opted into — no
/// proxy or browser involved. Docs: https://docs.csfloat.com/
/// </summary>
public sealed class CsFloatListingsClient
{
private const string BaseUrl = "https://csfloat.com/api/v1/listings";
private const int MaxLimit = 50; // API hard cap per page.
private static readonly JsonSerializerOptions Options = new()
{
// CSFloat uses snake_case for item fields (market_hash_name, float_value,
// def_index, …). Without this policy, multi-word fields silently
// deserialize to defaults while single-word ones slip through on
// case-insensitivity — exactly the "prices but no floats/names" symptom.
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
};
private readonly HttpClient _http;
private readonly string _apiKey;
private readonly ILogger<CsFloatListingsClient> _logger;
public CsFloatListingsClient(HttpClient http, string apiKey, ILogger<CsFloatListingsClient> logger)
{
if (string.IsNullOrWhiteSpace(apiKey))
throw new ArgumentException("CSFloat API key is required.", nameof(apiKey));
_http = http;
_apiKey = apiKey;
_logger = logger;
}
/// <summary>
/// Rate-limit state from the most recent response (success or failure).
/// <see cref="CsFloatRateLimit.None"/> until the first request completes.
/// </summary>
public CsFloatRateLimit LastRateLimit { get; private set; } = CsFloatRateLimit.None;
/// <summary>
/// Fetches active listings for one skin (by def_index/paint_index), following
/// the cursor until there are no more pages or <paramref name="maxListings"/>
/// is reached. <paramref name="maxListings"/> guards against pulling an
/// unbounded result set during the spike.
/// </summary>
public async Task<IReadOnlyList<CsFloatListing>> GetListingsAsync(
int defIndex,
int paintIndex,
string sortBy = "lowest_price",
int maxListings = 50,
string? type = "buy_now",
CancellationToken ct = default)
{
var results = new List<CsFloatListing>();
string? cursor = null;
do
{
var remaining = maxListings - results.Count;
var limit = Math.Min(MaxLimit, remaining);
var page = await FetchPageAsync(defIndex, paintIndex, sortBy, limit, cursor, type, ct);
results.AddRange(page.Listings);
_logger.LogInformation(
"Fetched {PageCount} listings (total {Total}); cursor {Cursor}.",
page.Listings.Count, results.Count, page.Cursor is null ? "—" : "present");
cursor = page.Cursor;
// Stop when the API signals the end (no cursor) or returns an empty page.
if (string.IsNullOrEmpty(cursor) || page.Listings.Count == 0)
break;
}
while (results.Count < maxListings);
return results;
}
/// <summary>
/// Fetches a single page of listings and the cursor for the next page. The
/// sweep runner drives this directly so it can decide — between pages — when
/// to stop (already-seen listings) or pace (rate-limit headers). Filters are
/// optional: omit def_index/paint_index for a global sweep across all items.
/// </summary>
public Task<ListingsPageResult> FetchPageAsync(
int? defIndex,
int? paintIndex,
string sortBy,
int limit,
string? cursor,
string? type = "buy_now",
CancellationToken ct = default)
{
var query = new List<string>
{
$"sort_by={Uri.EscapeDataString(sortBy)}",
$"limit={Math.Clamp(limit, 1, MaxLimit)}",
};
// Default to fixed-price listings only; auctions have no firm sale price
// and aren't wanted. Pass type=null to include everything.
if (!string.IsNullOrEmpty(type))
query.Add($"type={Uri.EscapeDataString(type)}");
if (defIndex is { } def)
query.Add($"def_index={def}");
if (paintIndex is { } paint)
query.Add($"paint_index={paint}");
if (!string.IsNullOrEmpty(cursor))
query.Add($"cursor={Uri.EscapeDataString(cursor)}");
return SendPageAsync(query, ct);
}
private async Task<ListingsPageResult> SendPageAsync(List<string> query, CancellationToken ct)
{
var url = $"{BaseUrl}?{string.Join('&', query)}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
// CSFloat expects the raw key in the Authorization header (no scheme).
request.Headers.TryAddWithoutValidation("Authorization", _apiKey);
using var response = await _http.SendAsync(request, ct);
var body = await response.Content.ReadAsStringAsync(ct);
// Always record rate-limit state, even on failure — a 429 is exactly when
// these headers (and Retry-After) matter most.
LastRateLimit = ParseRateLimit(response);
_logger.LogInformation("{RateLimit}", LastRateLimit);
if (!response.IsSuccessStatusCode)
throw new CsFloatApiException(response.StatusCode, Truncate(body));
var page = Parse(body);
return new ListingsPageResult(page.Data.Select(Map).ToList(), page.Cursor);
}
// Pull rate-limit info from response headers without assuming exact names:
// collect every header containing "ratelimit"/"rate-limit" (case-insensitive)
// plus Retry-After, then best-effort map the common remaining/limit/reset
// fields. The full set is kept in Raw so the spike reveals the real names.
private static CsFloatRateLimit ParseRateLimit(HttpResponseMessage response)
{
var raw = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Scan both response and content headers — servers split them either way.
var all = response.Headers.AsEnumerable();
if (response.Content is not null)
all = all.Concat(response.Content.Headers);
foreach (var header in all)
{
var name = header.Key;
var isRateLimit = name.Contains("ratelimit", StringComparison.OrdinalIgnoreCase)
|| name.Contains("rate-limit", StringComparison.OrdinalIgnoreCase)
|| name.Equals("Retry-After", StringComparison.OrdinalIgnoreCase);
if (isRateLimit)
raw[name] = string.Join(",", header.Value);
}
if (raw.Count == 0)
return CsFloatRateLimit.None;
return new CsFloatRateLimit(
Limit: FindInt(raw, "limit"),
Remaining: FindInt(raw, "remaining"),
Reset: Find(raw, "reset"),
RetryAfter: FindInt(raw, "retry-after"),
Raw: raw);
}
// Matches a header whose name contains the token but is NOT a different
// metric (e.g. "remaining" must not match when looking for "limit").
private static string? Find(IReadOnlyDictionary<string, string> raw, string token) =>
raw.FirstOrDefault(kv =>
kv.Key.Contains(token, StringComparison.OrdinalIgnoreCase)
&& !(token == "limit" && kv.Key.Contains("remaining", StringComparison.OrdinalIgnoreCase)))
.Value;
private static int? FindInt(IReadOnlyDictionary<string, string> raw, string token) =>
int.TryParse(Find(raw, token), out var v) ? v : null;
// The endpoint may return either a bare array of listings or an object with
// { data, cursor }. Detect which by the first non-whitespace character so the
// spike works regardless of which shape the live API uses.
private static ListingsPage Parse(string body)
{
var trimmed = body.TrimStart();
if (trimmed.StartsWith('['))
{
var array = JsonSerializer.Deserialize<List<ListingDto>>(body, Options) ?? [];
return new ListingsPage(array, null);
}
return JsonSerializer.Deserialize<ListingsPage>(body, Options)
?? new ListingsPage([], null);
}
private static CsFloatListing Map(ListingDto dto)
{
var item = dto.Item ?? new ItemDto();
return new CsFloatListing(
ListingId: dto.Id ?? "",
CreatedAt: dto.CreatedAt ?? default,
Type: dto.Type ?? "buy_now",
// CSFloat prices are integer cents.
Price: dto.Price / 100m,
MarketHashName: item.MarketHashName ?? "Unknown",
DefIndex: item.DefIndex,
PaintIndex: item.PaintIndex,
PaintSeed: item.PaintSeed,
FloatValue: item.FloatValue,
WearName: item.WearName,
IsStatTrak: item.IsStatTrak,
IsSouvenir: item.IsSouvenir,
StickerCount: item.Stickers?.Count ?? 0,
SellerSteamId: dto.Seller?.SteamId,
InspectLink: item.InspectLink,
AssetId: item.AssetId);
}
private static string Truncate(string s) => s.Length <= 500 ? s : s[..500];
private sealed record ListingsPage(
[property: JsonPropertyName("data")] List<ListingDto> Data,
[property: JsonPropertyName("cursor")] string? Cursor);
private sealed record ListingDto(
string? Id,
DateTimeOffset? CreatedAt,
string? Type,
long Price,
SellerDto? Seller,
ItemDto? Item);
private sealed record SellerDto(string? SteamId);
private sealed record ItemDto
{
public string? MarketHashName { get; init; }
public int DefIndex { get; init; }
public int PaintIndex { get; init; }
public int PaintSeed { get; init; }
public decimal FloatValue { get; init; }
public string? WearName { get; init; }
public bool IsStatTrak { get; init; }
public bool IsSouvenir { get; init; }
public string? InspectLink { get; init; }
public string? AssetId { get; init; }
public List<StickerDto>? Stickers { get; init; }
}
private sealed record StickerDto(int StickerId, int Slot, string? Name);
}

View File

@@ -0,0 +1,33 @@
namespace BlueLaminate.Scraper.CsFloat;
/// <summary>
/// Rate-limit state parsed from a CSFloat API response's headers. The official
/// docs don't pin down the exact header names, so this is populated generically
/// (any header whose name contains "ratelimit"/"rate-limit", plus "retry-after")
/// and keeps the <see cref="Raw"/> map so the real names surface during the
/// spike. A future catalog sweep uses <see cref="Remaining"/>/<see cref="Reset"/>
/// to pace requests and avoid 429s.
/// </summary>
/// <param name="Limit">Max requests allowed in the current window, if reported.</param>
/// <param name="Remaining">Requests left in the current window, if reported.</param>
/// <param name="Reset">Raw reset value as sent (epoch seconds or seconds-until — unverified).</param>
/// <param name="RetryAfter">Seconds to wait, from a Retry-After header (typically on 429).</param>
/// <param name="Raw">Every rate-limit-related header, verbatim, for inspection.</param>
public sealed record CsFloatRateLimit(
int? Limit,
int? Remaining,
string? Reset,
int? RetryAfter,
IReadOnlyDictionary<string, string> Raw)
{
public static readonly CsFloatRateLimit None =
new(null, null, null, null, new Dictionary<string, string>());
/// <summary>True when the API reports zero requests remaining.</summary>
public bool IsExhausted => Remaining is <= 0;
public override string ToString() =>
Raw.Count == 0
? "rate-limit: (no headers)"
: "rate-limit: " + string.Join(", ", Raw.Select(kv => $"{kv.Key}={kv.Value}"));
}

View File

@@ -0,0 +1,21 @@
namespace BlueLaminate.Scraper.Proxies;
/// <summary>
/// Source of proxy endpoints. The whole point of this seam is that the rest of
/// the scraper depends only on this interface and <see cref="ProxyLease"/>, so a
/// different residential provider — or the future C2 that allocates IPs to
/// containers, or a composite "grab-bag" over several providers — drops in
/// without changing any browser or scraping code.
/// </summary>
public interface IProxyProvider
{
/// <summary>Identifier recorded on issued leases, e.g. "iproyal".</summary>
string Name { get; }
/// <summary>
/// Produce a usable endpoint for the given request. For gateway providers
/// this is pure string composition (no network call); the C2 implementation
/// can override that later with real allocation.
/// </summary>
ProxyLease Acquire(ProxyRequest request);
}

View File

@@ -0,0 +1,70 @@
namespace BlueLaminate.Scraper.Proxies;
/// <summary>
/// <see cref="IProxyProvider"/> for IPRoyal's residential gateway. IPRoyal keeps
/// one fixed host/port (geo.iproyal.com:12321) and encodes everything else —
/// country, sticky-session id, session lifetime — as underscore-delimited
/// parameters appended to the account password. Example password:
/// "secret_country-us_session-ab12cd_lifetime-30m". The account username is sent
/// unchanged. Docs: https://docs.iproyal.com/proxies/residential/proxy
/// </summary>
public sealed class IpRoyalProxyProvider : IProxyProvider
{
public const string GatewayHost = "geo.iproyal.com";
public const int GatewayPort = 12321;
// IPRoyal caps sticky sessions; 30 minutes is a safe default that comfortably
// covers a single scrape pass without forcing an early IP rotation.
private static readonly TimeSpan DefaultLifetime = TimeSpan.FromMinutes(30);
private readonly string _username;
private readonly string _password;
public IpRoyalProxyProvider(string username, string password)
{
if (string.IsNullOrWhiteSpace(username))
throw new ArgumentException("IPRoyal username is required.", nameof(username));
if (string.IsNullOrWhiteSpace(password))
throw new ArgumentException("IPRoyal password is required.", nameof(password));
_username = username;
_password = password;
}
public string Name => "iproyal";
public ProxyLease Acquire(ProxyRequest request)
{
var password = _password;
string? sessionId = null;
DateTimeOffset? expiresAt = null;
// Country first; the router picks one at random when several are listed.
if (!string.IsNullOrWhiteSpace(request.Country))
password += $"_country-{request.Country.Trim().ToLowerInvariant()}";
if (request.Sticky)
{
sessionId = request.SessionId ?? NewSessionId();
var lifetime = request.Lifetime ?? DefaultLifetime;
// IPRoyal expresses lifetime as whole minutes (e.g. "_lifetime-30m").
var minutes = Math.Max(1, (int)Math.Round(lifetime.TotalMinutes));
password += $"_session-{sessionId}_lifetime-{minutes}m";
expiresAt = DateTimeOffset.UtcNow.AddMinutes(minutes);
}
return new ProxyLease(
Host: GatewayHost,
Port: GatewayPort,
Username: _username,
Password: password,
Provider: Name,
SessionId: sessionId,
ExpiresAt: expiresAt);
}
// Short, URL/param-safe token. IPRoyal treats the session value opaquely;
// it only needs to be stable for the duration of a sticky lease.
private static string NewSessionId() =>
Guid.NewGuid().ToString("N")[..10];
}

View File

@@ -0,0 +1,29 @@
namespace BlueLaminate.Scraper.Proxies;
/// <summary>
/// A concrete, ready-to-use proxy endpoint handed back by an
/// <see cref="IProxyProvider"/>. This is the only proxy type the browser layer
/// ever sees, so swapping providers (or mixing several in a grab-bag) never
/// touches the Selenium code. <see cref="Username"/> and <see cref="Password"/>
/// are the literal credentials to present to the gateway — for providers like
/// IPRoyal the targeting/session parameters are already baked into them.
/// </summary>
/// <param name="Host">Gateway host, e.g. "geo.iproyal.com".</param>
/// <param name="Port">Gateway port, e.g. 12321.</param>
/// <param name="Username">Credential username for the gateway.</param>
/// <param name="Password">Credential password (may carry encoded session/geo params).</param>
/// <param name="Provider">Name of the provider that issued this lease.</param>
/// <param name="SessionId">The sticky session key, if this is a pinned IP.</param>
/// <param name="ExpiresAt">When a sticky IP may be recycled; null if rotating/unbounded.</param>
public sealed record ProxyLease(
string Host,
int Port,
string Username,
string Password,
string Provider,
string? SessionId = null,
DateTimeOffset? ExpiresAt = null)
{
/// <summary>"host:port" form used by browser proxy settings.</summary>
public string Endpoint => $"{Host}:{Port}";
}

View File

@@ -0,0 +1,97 @@
using System.Text.Json;
using BlueLaminate.Scraper.Browser;
using Microsoft.Extensions.Logging;
using OpenQA.Selenium;
namespace BlueLaminate.Scraper.Proxies;
/// <summary>The exit IP a proxy lease actually resolves to, per ipinfo.io.</summary>
/// <param name="Org">
/// ASN + organisation, e.g. "AS7922 Comcast Cable". This is the tell for
/// residential vs. datacenter: a consumer ISP here means a real residential
/// exit; a hosting provider (OVH, Hetzner, AWS…) means datacenter dressed up.
/// </param>
public sealed record ProxyExitInfo(
string? Ip,
string? City,
string? Region,
string? Country,
string? Org,
string? Hostname,
string? Timezone);
/// <summary>
/// Smallest possible end-to-end check of the proxy plumbing: acquire a lease,
/// launch the real browser through it, and read back the exit IP from an
/// IP-echo endpoint. Costs a few KB, so it's the right first thing to run
/// against a metered residential plan — it proves auth works and shows whether
/// the IP is genuinely residential before we spend bandwidth on CSFloat.
/// </summary>
public sealed class ProxyProbe
{
private const string IpEchoUrl = "https://ipinfo.io/json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly IProxyProvider _provider;
private readonly BrowserDriverFactory _factory;
private readonly ILogger<ProxyProbe> _logger;
public ProxyProbe(
IProxyProvider provider,
BrowserDriverFactory factory,
ILogger<ProxyProbe> logger)
{
_provider = provider;
_factory = factory;
_logger = logger;
}
public async Task<ProxyExitInfo> RunAsync(ProxyRequest request)
{
var lease = _provider.Acquire(request);
_logger.LogInformation(
"Acquired {Provider} lease (exit {Mode}).",
lease.Provider, lease.SessionId is null ? "rotating" : $"sticky:{lease.SessionId}");
var driver = await _factory.CreateAsync(lease, blockImages: true);
try
{
driver.Manage().Timeouts().PageLoad = TimeSpan.FromSeconds(60);
driver.Navigate().GoToUrl(IpEchoUrl);
// Read the document's text rather than the DOM so the browser's
// built-in JSON viewer doesn't get in the way, then carve out the
// JSON object it rendered.
var rendered = ((IJavaScriptExecutor)driver)
.ExecuteScript("return document.documentElement.innerText;") as string
?? throw new InvalidOperationException("Browser returned no page text.");
var info = JsonSerializer.Deserialize<ProxyExitInfo>(ExtractJson(rendered), JsonOptions)
?? throw new InvalidOperationException("IP-echo response was empty.");
_logger.LogInformation(
"Exit IP {Ip} — {City}, {Region}, {Country} — {Org}",
info.Ip, info.City, info.Region, info.Country, info.Org);
return info;
}
finally
{
driver.Quit();
}
}
private static string ExtractJson(string text)
{
var start = text.IndexOf('{');
var end = text.LastIndexOf('}');
if (start < 0 || end <= start)
throw new InvalidOperationException($"No JSON found in IP-echo response: {text}");
return text[start..(end + 1)];
}
}

View File

@@ -0,0 +1,30 @@
namespace BlueLaminate.Scraper.Proxies;
/// <summary>
/// What kind of exit IP the caller wants. Provider-agnostic: each
/// <see cref="IProxyProvider"/> translates these knobs into its own gateway
/// syntax. A sticky request asks the provider to pin one residential IP for the
/// session's lifetime; a non-sticky request lets the IP rotate per connection.
/// </summary>
/// <param name="Country">
/// Optional ISO 3166-1 alpha-2 code, or a comma-separated list to let the
/// provider pick one at random (e.g. "us" or "us,gb,de"). Null means no
/// geo constraint.
/// </param>
/// <param name="Sticky">
/// True to keep the same exit IP for the whole session; false to rotate.
/// </param>
/// <param name="SessionId">
/// Optional caller-supplied session key for a sticky lease. When null and
/// <paramref name="Sticky"/> is true the provider generates one.
/// </param>
/// <param name="Lifetime">
/// How long a sticky IP should be held before the provider may recycle it.
/// Ignored when <paramref name="Sticky"/> is false. Null lets the provider
/// apply its own default.
/// </param>
public sealed record ProxyRequest(
string? Country = null,
bool Sticky = true,
string? SessionId = null,
TimeSpan? Lifetime = null);

View File

@@ -3,6 +3,8 @@ namespace BlueLaminate.Scraper.Skins;
/// <summary>A single CS2 skin from the CSGO-API static catalogue (skins.json).</summary>
/// <param name="Id">Stable catalogue id, e.g. "skin-e757fd7191f9". Globally unique natural key.</param>
/// <param name="WeaponName">Owning weapon, e.g. "AK-47", "Hand Wraps", "Bayonet".</param>
/// <param name="DefIndex">CS weapon definition index (weapon.weapon_id), e.g. AK-47=7. Null if absent.</param>
/// <param name="PaintIndex">Paint index identifying the finish, e.g. 985. Null if absent.</param>
/// <param name="Category">Weapon category, e.g. "Rifles", "Knives", "Gloves". Becomes the weapon type.</param>
/// <param name="Team">"CT", "T", or "Both".</param>
/// <param name="Name">Skin/pattern name, e.g. "Dragon Lore"; "Vanilla" for knives with no finish.</param>
@@ -17,6 +19,8 @@ namespace BlueLaminate.Scraper.Skins;
public sealed record CatalogSkin(
string Id,
string WeaponName,
int? DefIndex,
int? PaintIndex,
string Category,
string Team,
string Name,

View File

@@ -48,6 +48,8 @@ public sealed class SkinCatalogClient
return new CatalogSkin(
Id: dto.Id,
WeaponName: dto.Weapon?.Name ?? "Unknown",
DefIndex: dto.Weapon?.WeaponId,
PaintIndex: dto.PaintIndex,
Category: dto.Category?.Name ?? "Unknown",
Team: MapTeam(dto.Team?.Id),
// Knives with no finish carry a null pattern; "Vanilla" is the community term.
@@ -88,9 +90,11 @@ public sealed class SkinCatalogClient
string Id,
string? Name,
string? Description,
NamedDto? Weapon,
WeaponDto? Weapon,
NamedDto? Category,
NamedDto? Pattern,
// Top-level paint index. AllowReadingFromString handles its string form.
int? PaintIndex,
decimal? MinFloat,
decimal? MaxFloat,
NamedDto? Rarity,
@@ -102,4 +106,7 @@ public sealed class SkinCatalogClient
List<NamedDto>? Crates);
private sealed record NamedDto(string? Id, string? Name);
// Weapon carries a numeric weapon_id (the def_index) alongside id/name.
private sealed record WeaponDto(string? Id, int? WeaponId, string? Name);
}

View File

@@ -0,0 +1 @@
{"data":{"aed":3.67308,"afn":63.8101,"all":81.9632,"amd":368.143,"ang":1.80234,"aoa":918.907,"ars":1408.71,"aud":1.39151,"awg":1.79,"azn":1.69966,"bam":1.68079,"bbd":1.99,"bdt":122.756,"bgn":1.67724,"bhd":0.377063,"bif":2977.25,"bmd":1,"bnd":1.27739,"bob":6.93362,"brl":5.03662,"bsd":1,"btn":94.9823,"bwp":13.4051,"byn":2.76,"bzd":2,"cad":1.38011,"cdf":2303.13,"chf":0.781072,"clp":889.925,"cny":6.76633,"cop":3658.64,"crc":456.323,"cve":94.8541,"czk":20.8256,"djf":177.6,"dkk":6.41027,"dop":58.34,"dzd":132.483,"eek":11.7036,"egp":52.2449,"etb":158.478,"eur":0.85756,"eurc":0.85756,"fjd":2.22183,"fkp":0.743205,"gbp":0.743163,"gel":2.6635,"ghs":11.738,"gip":0.743205,"gmd":71.7,"gnf":8733.01,"gtq":7.62826,"gyd":209.218,"hkd":7.83683,"hnl":26.5919,"hrk":6.46045,"htg":131.051,"huf":303.494,"idr":17846.4,"ils":2.81558,"inr":94.9244,"isk":122.978,"jmd":157.512,"jod":0.709142,"jpy":159.298,"kes":129.43,"kgs":87.4636,"khr":4026.38,"kmf":422.97,"krw":1507.45,"kwd":0.306761,"kyd":0.831626,"kzt":485.776,"lak":21934.5,"lbp":89500,"lkr":330.556,"lrd":182.518,"lsl":16.2382,"ltl":2.85333,"lvl":0.666172,"mad":9.18233,"mdl":17.2495,"mga":4197.32,"mkd":52.9711,"mmk":3658.01,"mnt":3578.79,"mop":8.07515,"mro":357.429,"mur":47.3605,"mvr":15.4615,"mwk":1734.01,"mxn":17.3547,"myr":3.96506,"mzn":63.7022,"nad":16.2435,"ngn":1407.3,"nio":36.6243,"nok":9.25345,"npr":152.04,"nzd":1.67028,"omr":0.385044,"pab":1,"pen":3.4017,"pgk":4.36134,"php":61.5484,"pkr":278.578,"pln":3.62897,"pyg":6017.9,"qar":3.64153,"ron":4.5042,"rsd":100.688,"rub":71.0734,"rwf":1463.11,"sar":3.75298,"sbd":8.0556,"scr":14.4837,"sek":9.24372,"sgd":1.27675,"shp":0.743619,"sle":22.7529,"sll":22791.4,"sos":571.375,"srd":37.1698,"std":20979.6,"svc":8.75278,"szl":16.2358,"thb":32.5267,"tjs":9.25184,"tnd":2.92,"top":2.35974,"try":45.8529,"ttd":6.74984,"twd":31.4269,"tzs":2629.69,"uah":44.2847,"ugx":3771.6,"usd":1,"usdc":1,"usdt":1.0013,"uyu":40.1504,"uzs":12004,"vef":50.1656,"vnd":26311,"vuv":118.053,"wst":2.70421,"xaf":562.45,"xcd":2.6882,"xcg":1.80234,"xof":562.975,"xpf":102.465,"yer":1566.65,"zar":16.2289,"zmw":18.3213}}

View File

@@ -0,0 +1 @@
{"inferred_location":{"short":"US","long":"United States","currency":"USD"}}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"code":1,"message":"You need to be logged in to search listings"}

View File

@@ -0,0 +1 @@
{"inferred_location":{"short":"US","long":"United States","currency":"USD"}}

File diff suppressed because one or more lines are too long

852
BlueLaminate/listings.json Normal file
View File

@@ -0,0 +1,852 @@
[
{
"ListingId": "980620581575721585",
"CreatedAt": "2026-05-29T23:56:05.697492+00:00",
"Type": "auction",
"Price": 0.13,
"MarketHashName": "M4A4 | Cyber Security (Factory New)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 36,
"FloatValue": 0.012077982537448406,
"WearName": "Factory New",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 5,
"SellerSteamId": "76561198246557064",
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20S76561198246557064A46986755147D14033931285201373732"
},
{
"ListingId": "977983687889126586",
"CreatedAt": "2026-05-22T17:18:01.251983+00:00",
"Type": "buy_now",
"Price": 20.64,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 196,
"FloatValue": 0.37027570605278015,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198445217245",
"InspectLink": null
},
{
"ListingId": "980107904751372995",
"CreatedAt": "2026-05-28T13:58:54.017206+00:00",
"Type": "buy_now",
"Price": 21.12,
"MarketHashName": "M4A4 | Cyber Security (Well-Worn)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 197,
"FloatValue": 0.3876524567604065,
"WearName": "Well-Worn",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 4,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010B1C1BAD054181020D9072805300438E2F499F60340C501620A08001082251D00000000620A08011082251D00000000620A080210CB251D00000000620A080310D3241D00000000439A82B3"
},
{
"ListingId": "977475221521041770",
"CreatedAt": "2026-05-21T07:37:33.42261+00:00",
"Type": "buy_now",
"Price": 21.49,
"MarketHashName": "M4A4 | Cyber Security (Well-Worn)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 462,
"FloatValue": 0.41430607438087463,
"WearName": "Well-Worn",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 4,
"SellerSteamId": "76561198849546787",
"InspectLink": null
},
{
"ListingId": "944058434985265416",
"CreatedAt": "2026-02-18T02:31:10.65901+00:00",
"Type": "buy_now",
"Price": 21.69,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 458,
"FloatValue": 0.887004554271698,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198203007011",
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20S76561198203007011A49346222089D9398114021129036864"
},
{
"ListingId": "944058435647965449",
"CreatedAt": "2026-02-18T02:31:10.816658+00:00",
"Type": "buy_now",
"Price": 21.69,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 355,
"FloatValue": 0.8154000639915466,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198203007011",
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20S76561198203007011A49346222080D9398114021129036864"
},
{
"ListingId": "944058436260333836",
"CreatedAt": "2026-02-18T02:31:10.962109+00:00",
"Type": "buy_now",
"Price": 21.69,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 559,
"FloatValue": 0.861517071723938,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198203007011",
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20S76561198203007011A49346222045D9398114021129036864"
},
{
"ListingId": "978012477956687353",
"CreatedAt": "2026-05-22T19:12:25.338168+00:00",
"Type": "buy_now",
"Price": 21.7,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 26,
"FloatValue": 0.8027687668800354,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010E7F8AAF4B401181020D9072805300438C184B6FA03401A71625C02"
},
{
"ListingId": "972014931006327206",
"CreatedAt": "2026-05-06T06:00:18.716109+00:00",
"Type": "buy_now",
"Price": 22.5,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 394,
"FloatValue": 0.7475578784942627,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 4,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010F7FBE79FBF01181020D9072805300438F4BFFDF903408A03620A080010E6241D00000000620A0801108D251D00000000620A080210E5241D00000000620A080310A1251D000000007E31A390"
},
{
"ListingId": "977854395519732894",
"CreatedAt": "2026-05-22T08:44:15.548448+00:00",
"Type": "buy_now",
"Price": 22.99,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 956,
"FloatValue": 0.36133942008018494,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 2,
"SellerSteamId": "76561199211120731",
"InspectLink": null
},
{
"ListingId": "972313009710042328",
"CreatedAt": "2026-05-07T01:44:46.21802+00:00",
"Type": "buy_now",
"Price": 23,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 496,
"FloatValue": 0.720237672328949,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010D0D6FD9A6E181020D9072805300438FFC2E1F90340F003D8FDC657"
},
{
"ListingId": "975882545038232440",
"CreatedAt": "2026-05-16T22:08:49.758479+00:00",
"Type": "buy_now",
"Price": 23.01,
"MarketHashName": "M4A4 | Cyber Security (Well-Worn)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 230,
"FloatValue": 0.4235461354255676,
"WearName": "Well-Worn",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010DDEBA089C001181020D90728053004388AB6E3F60340E6019F941BC8"
},
{
"ListingId": "969844001735838920",
"CreatedAt": "2026-04-30T06:13:48.844979+00:00",
"Type": "buy_now",
"Price": 23.5,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 624,
"FloatValue": 0.8909536004066467,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010CDB2DBC1B701181020D907280530043889AB90FB0340F004D096EEF0"
},
{
"ListingId": "978946860024727369",
"CreatedAt": "2026-05-25T09:05:19.384057+00:00",
"Type": "buy_now",
"Price": 23.85,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 7,
"FloatValue": 0.2825245261192322,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 2,
"SellerSteamId": "76561198117461653",
"InspectLink": null
},
{
"ListingId": "978119890676353307",
"CreatedAt": "2026-05-23T02:19:14.52707+00:00",
"Type": "buy_now",
"Price": 23.94,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 412,
"FloatValue": 0.8696560859680176,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%2000108A8C84AC4B181020D9072805300438C8C3FAFA03409C03F7A3BA99"
},
{
"ListingId": "958112366166413168",
"CreatedAt": "2026-03-28T21:16:28.961251+00:00",
"Type": "buy_now",
"Price": 24.26,
"MarketHashName": "M4A4 | Cyber Security (Well-Worn)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 852,
"FloatValue": 0.39425063133239746,
"WearName": "Well-Worn",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010B395D9AAA001181020D9072805300438B8B6A7F60340D4069EC48C18"
},
{
"ListingId": "958276292820730284",
"CreatedAt": "2026-03-29T08:07:52.121365+00:00",
"Type": "buy_now",
"Price": 24.51,
"MarketHashName": "M4A4 | Cyber Security (Well-Worn)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 977,
"FloatValue": 0.41828399896621704,
"WearName": "Well-Worn",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010EBD4D59BBC01181020D9072805300438D2D2D8F60340D1078C025B18"
},
{
"ListingId": "974716602790578480",
"CreatedAt": "2026-05-13T16:55:47.464117+00:00",
"Type": "buy_now",
"Price": 24.59,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 731,
"FloatValue": 0.3624654710292816,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198074263117",
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20S76561198074263117A48837358290D10137251750820954148"
},
{
"ListingId": "973840546919481846",
"CreatedAt": "2026-05-11T06:54:39.468871+00:00",
"Type": "buy_now",
"Price": 25,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 67,
"FloatValue": 0.3120187222957611,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 3,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010B29AC599BF01181020D9072805300438EB81FFF4034043620A0800108D251D00000000620A080210C2231D00000000620A080310D1241D00000000FB2978DD"
},
{
"ListingId": "977676600889969709",
"CreatedAt": "2026-05-21T20:57:46.005071+00:00",
"Type": "buy_now",
"Price": 25,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 486,
"FloatValue": 0.29553860425949097,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561199700274343",
"InspectLink": null
},
{
"ListingId": "979960519056296713",
"CreatedAt": "2026-05-28T04:13:14.529164+00:00",
"Type": "buy_now",
"Price": 25.2,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 859,
"FloatValue": 0.24595648050308228,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010F6BBDBF1C001181020D907280530043884B8EFF30340DB06F1B9C120"
},
{
"ListingId": "975825575518275718",
"CreatedAt": "2026-05-16T18:22:27.166545+00:00",
"Type": "buy_now",
"Price": 25.41,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 989,
"FloatValue": 0.3423748314380646,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198348938457",
"InspectLink": null
},
{
"ListingId": "920071737041882772",
"CreatedAt": "2025-12-13T21:56:36.217081+00:00",
"Type": "buy_now",
"Price": 25.43,
"MarketHashName": "M4A4 | Cyber Security (Well-Worn)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 359,
"FloatValue": 0.4306662678718567,
"WearName": "Well-Worn",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010E196CCBAAA01181020D9072805300438CA80F2F60340E702E2EDB400"
},
{
"ListingId": "975848389856067624",
"CreatedAt": "2026-05-16T19:53:06.528564+00:00",
"Type": "buy_now",
"Price": 25.92,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 325,
"FloatValue": 0.28776511549949646,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561199163369506",
"InspectLink": null
},
{
"ListingId": "974864772635951769",
"CreatedAt": "2026-05-14T02:44:33.908679+00:00",
"Type": "buy_now",
"Price": 25.99,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 625,
"FloatValue": 0.9708523750305176,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561199225324148",
"InspectLink": null
},
{
"ListingId": "973499186190355214",
"CreatedAt": "2026-05-10T08:18:12.728927+00:00",
"Type": "buy_now",
"Price": 26.22,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 423,
"FloatValue": 0.2560030221939087,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20001098FB99EE6D181020D9072805300438D4A58CF40340A70325312E85"
},
{
"ListingId": "968055437461160578",
"CreatedAt": "2026-04-25T07:46:41.891409+00:00",
"Type": "buy_now",
"Price": 26.96,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 496,
"FloatValue": 0.48168882727622986,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 2,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010A0A1E581BB01181020D9072805300438EBBFDAF70340F003620A080210B4291D00000000620A080310A0291D00000000F40D9810"
},
{
"ListingId": "973760484195043977",
"CreatedAt": "2026-05-11T01:36:31.027586+00:00",
"Type": "buy_now",
"Price": 27.35,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 416,
"FloatValue": 0.26007798314094543,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198333202197",
"InspectLink": null
},
{
"ListingId": "957239800581194436",
"CreatedAt": "2026-03-26T11:29:13.114121+00:00",
"Type": "buy_now",
"Price": 27.67,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 453,
"FloatValue": 0.23538990318775177,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20001095B093F4BB01181020D90728053004388D94C4F30340C50375186720"
},
{
"ListingId": "978296828510473917",
"CreatedAt": "2026-05-23T14:02:19.793484+00:00",
"Type": "buy_now",
"Price": 27.93,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 443,
"FloatValue": 0.19115914404392242,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 2,
"SellerSteamId": "76561198234288280",
"InspectLink": null
},
{
"ListingId": "977992867920348649",
"CreatedAt": "2026-05-22T17:54:29.941117+00:00",
"Type": "buy_now",
"Price": 27.95,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 183,
"FloatValue": 0.23368020355701447,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 2,
"SellerSteamId": "76561198928748351",
"InspectLink": null
},
{
"ListingId": "977992871292569138",
"CreatedAt": "2026-05-22T17:54:30.745285+00:00",
"Type": "buy_now",
"Price": 27.95,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 401,
"FloatValue": 0.23699642717838287,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 1,
"SellerSteamId": "76561198928748351",
"InspectLink": null
},
{
"ListingId": "977992872978679410",
"CreatedAt": "2026-05-22T17:54:31.147159+00:00",
"Type": "buy_now",
"Price": 27.95,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 542,
"FloatValue": 0.23752112686634064,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198928748351",
"InspectLink": null
},
{
"ListingId": "971827159691822159",
"CreatedAt": "2026-05-05T17:34:10.546539+00:00",
"Type": "buy_now",
"Price": 28,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 219,
"FloatValue": 0.3420577645301819,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 2,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20001094E4B895BF01181020D9072805300438B2C4BCF50340DB01620A080210C1011D00000000620A080310CE241D00000000F5543270"
},
{
"ListingId": "970213268918503456",
"CreatedAt": "2026-05-01T06:41:09.001766+00:00",
"Type": "buy_now",
"Price": 28.19,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 574,
"FloatValue": 0.2504541277885437,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%2000109BF7BCF7BE01181020D907280530043886F780F40340BE041DB34318"
},
{
"ListingId": "885871847005095070",
"CreatedAt": "2025-09-10T12:58:27.029977+00:00",
"Type": "buy_now",
"Price": 28.22,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 214,
"FloatValue": 0.48499584197998047,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010DED3FFC851181020D9072805300438E0A2E1F70340D6017672504A"
},
{
"ListingId": "963337021257026727",
"CreatedAt": "2026-04-12T07:17:23.80413+00:00",
"Type": "buy_now",
"Price": 28.7,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 55,
"FloatValue": 0.3647545874118805,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 4,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010AAE3B1989001181020D90728053004389D82EBF50340376214080610D4331D000000003DCC084EBE45606D33BD6214080310E4331D000000003D32FC14BF458087E3BB6214080310E8331D000000003D0069E03C4500DA513B6214080510D4331D000000003DBA99393E450020D739A3A3A5B7"
},
{
"ListingId": "949438677182975431",
"CreatedAt": "2026-03-04T22:50:20.358844+00:00",
"Type": "buy_now",
"Price": 29,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 67,
"FloatValue": 0.20387957990169525,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198203007011",
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20S76561198203007011A49346221991D9578638446340935276"
},
{
"ListingId": "956840395688513433",
"CreatedAt": "2026-03-25T09:02:07.567089+00:00",
"Type": "buy_now",
"Price": 29,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 923,
"FloatValue": 0.2994285821914673,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010FDB4D5DBBB01181020D9072805300438B49DE5F403409B076AE8C2F0"
},
{
"ListingId": "979943982505265136",
"CreatedAt": "2026-05-28T03:07:31.908263+00:00",
"Type": "buy_now",
"Price": 29.03,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 636,
"FloatValue": 0.17294414341449738,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 2,
"SellerSteamId": "76561198869215251",
"InspectLink": null
},
{
"ListingId": "978080948111413150",
"CreatedAt": "2026-05-22T23:44:29.895135+00:00",
"Type": "buy_now",
"Price": 29.25,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 810,
"FloatValue": 0.20900020003318787,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561199526200114",
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20S76561199526200114A47321956134D14909415746496227396"
},
{
"ListingId": "977992866376844700",
"CreatedAt": "2026-05-22T17:54:29.574111+00:00",
"Type": "buy_now",
"Price": 29.43,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 70,
"FloatValue": 0.21028947830200195,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198928748351",
"InspectLink": null
},
{
"ListingId": "925666576131296227",
"CreatedAt": "2025-12-29T08:28:29.803995+00:00",
"Type": "buy_now",
"Price": 29.99,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 517,
"FloatValue": 0.285702109336853,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 1,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010D6F8A0EAB401181020D90728053004388C8FC9F403408504620A080310A6251D00000000F047402C"
},
{
"ListingId": "973396856010835170",
"CreatedAt": "2026-05-10T01:31:35.31225+00:00",
"Type": "buy_now",
"Price": 30,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 498,
"FloatValue": 0.20943382382392883,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010D0FFD1C7B201181020D9072805300438D2EBD9F20340F203BADA9120"
},
{
"ListingId": "971602967448913895",
"CreatedAt": "2026-05-05T02:43:18.950863+00:00",
"Type": "buy_now",
"Price": 30.28,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 197,
"FloatValue": 0.22420163452625275,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010C7D1A79EA601181020D90728053004389DAA96F30340C50144C36048"
},
{
"ListingId": "962983922025762562",
"CreatedAt": "2026-04-11T07:54:18.387897+00:00",
"Type": "buy_now",
"Price": 30.58,
"MarketHashName": "StatTrak\u2122 M4A4 | Cyber Security (Well-Worn)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 51,
"FloatValue": 0.4273785948753357,
"WearName": "Well-Worn",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 4,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010A5C2B7E9B501181020D9072805300938DEA2EBF6034033480050B90A620A080010F41A1D00000000620A080110D4241D00000000620A080210D6241D00000000620A080310CB241D0000000088F8E5FC"
},
{
"ListingId": "980048914193450883",
"CreatedAt": "2026-05-28T10:04:29.57228+00:00",
"Type": "buy_now",
"Price": 30.58,
"MarketHashName": "StatTrak\u2122 M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 290,
"FloatValue": 0.6706797480583191,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 5,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%200010F0C1F8DFC001181020D9072805300938ABE3AEF90340A202480050B90A620A080010EA251D0000803F620A080110A12E1D0000803F620A0802108A251D00000000620A080310E41F1D000000006214080310E9331D000000003D067E43BE45EC8DBF3DF4698B16"
},
{
"ListingId": "949751343617278099",
"CreatedAt": "2026-03-05T19:32:45.845412+00:00",
"Type": "buy_now",
"Price": 31.45,
"MarketHashName": "M4A4 | Cyber Security (Battle-Scarred)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 506,
"FloatValue": 0.8670915961265564,
"WearName": "Battle-Scarred",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 0,
"SellerSteamId": "76561198061656655",
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20S76561198061656655A31489990229D7812875425780713576"
},
{
"ListingId": "980191300609511002",
"CreatedAt": "2026-05-28T19:30:17.139705+00:00",
"Type": "buy_now",
"Price": 31.45,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 708,
"FloatValue": 0.17284630239009857,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 4,
"SellerSteamId": "76561198967873407",
"InspectLink": null
},
{
"ListingId": "977881196535089755",
"CreatedAt": "2026-05-22T10:30:45.40833+00:00",
"Type": "buy_now",
"Price": 32.5,
"MarketHashName": "M4A4 | Cyber Security (Field-Tested)",
"DefIndex": 16,
"PaintIndex": 985,
"PaintSeed": 240,
"FloatValue": 0.16542492806911469,
"WearName": "Field-Tested",
"IsStatTrak": false,
"IsSouvenir": false,
"StickerCount": 2,
"SellerSteamId": null,
"InspectLink": "steam://rungame/730/76561202255233023/\u002Bcsgo_econ_action_preview%20001084A3D5FABF01181020D9072805300438A7CAA5F10340F0016214080310FD341D000000003D9057D1BD45806C92BB621908061093461D0000803F2D00001C423D7F681C3E45400930BCC01DEC83"
}
]