add csfloat api usage
This commit is contained in:
556
BlueLaminate/BlueLaminate.Cli/ListingSweepService.cs
Normal file
556
BlueLaminate/BlueLaminate.Cli/ListingSweepService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
86
BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs
Normal file
86
BlueLaminate/BlueLaminate.EFCore/Entities/Listing.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
829
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530014903_AddListingsAndSkinIndexes.Designer.cs
generated
Normal file
829
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530014903_AddListingsAndSkinIndexes.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
836
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530023217_AddSkinListingsSweptAt.Designer.cs
generated
Normal file
836
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530023217_AddSkinListingsSweptAt.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
868
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530030305_AddSkinInstanceDupeTrackingModelB.Designer.cs
generated
Normal file
868
BlueLaminate/BlueLaminate.EFCore/Migrations/20260530030305_AddSkinInstanceDupeTrackingModelB.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
47
BlueLaminate/BlueLaminate.Scraper/CsFloat/CsFloatListing.cs
Normal file
47
BlueLaminate/BlueLaminate.Scraper/CsFloat/CsFloatListing.cs
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}"));
|
||||
}
|
||||
21
BlueLaminate/BlueLaminate.Scraper/Proxies/IProxyProvider.cs
Normal file
21
BlueLaminate/BlueLaminate.Scraper/Proxies/IProxyProvider.cs
Normal 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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
29
BlueLaminate/BlueLaminate.Scraper/Proxies/ProxyLease.cs
Normal file
29
BlueLaminate/BlueLaminate.Scraper/Proxies/ProxyLease.cs
Normal 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}";
|
||||
}
|
||||
97
BlueLaminate/BlueLaminate.Scraper/Proxies/ProxyProbe.cs
Normal file
97
BlueLaminate/BlueLaminate.Scraper/Proxies/ProxyProbe.cs
Normal 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)];
|
||||
}
|
||||
}
|
||||
30
BlueLaminate/BlueLaminate.Scraper/Proxies/ProxyRequest.cs
Normal file
30
BlueLaminate/BlueLaminate.Scraper/Proxies/ProxyRequest.cs
Normal 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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
@@ -0,0 +1 @@
|
||||
{"inferred_location":{"short":"US","long":"United States","currency":"USD"}}
|
||||
1
BlueLaminate/captures/002_csfloat.com_api_v1_schema.json
Normal file
1
BlueLaminate/captures/002_csfloat.com_api_v1_schema.json
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"code":1,"message":"You need to be logged in to search listings"}
|
||||
@@ -0,0 +1 @@
|
||||
{"inferred_location":{"short":"US","long":"United States","currency":"USD"}}
|
||||
1
BlueLaminate/captures/004_csfloat.com_api_v1_schema.json
Normal file
1
BlueLaminate/captures/004_csfloat.com_api_v1_schema.json
Normal file
File diff suppressed because one or more lines are too long
852
BlueLaminate/listings.json
Normal file
852
BlueLaminate/listings.json
Normal 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"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user