From 6f3c0175cd5a612cfff48d4a1c878af652da720b Mon Sep 17 00:00:00 2001 From: bob Date: Fri, 29 May 2026 14:00:58 -0500 Subject: [PATCH] Add init weapon scraper --- .../BlueLaminate.Cli/BlueLaminate.Cli.csproj | 23 + BlueLaminate/BlueLaminate.Cli/Program.cs | 79 +++ .../BlueLaminate.Cli/WeaponSyncService.cs | 77 +++ .../BlueLaminate.Cli/appsettings.json | 5 + .../BlueLaminate.EFCore.csproj | 5 +- .../Configurations/ScrapeRunConfiguration.cs | 14 + .../Configurations/WeaponConfiguration.cs | 14 + .../Data/SkinTrackerDbContext.cs | 3 + .../BlueLaminate.EFCore/Entities/ScrapeRun.cs | 19 + ...AddScrapeRunAndWeaponNameIndex.Designer.cs | 609 ++++++++++++++++++ ...29182827_AddScrapeRunAndWeaponNameIndex.cs | 58 ++ .../SkinTrackerDbContextModelSnapshot.cs | 35 + BlueLaminate/BlueLaminate.EFCore/Program.cs | 7 - .../BlueLaminate.Scraper.csproj | 13 + .../Weapons/ScrapedWeapon.cs | 7 + .../Weapons/WeaponWikiScraper.cs | 172 +++++ .../Wiki/WikiPageFetcher.cs | 51 ++ .../BlueLaminate.Scraper/Wiki/WikiText.cs | 14 + BlueLaminate/BlueLaminate.slnx | 2 + WeaponGrabber/WeaponScraper.py | 54 -- 20 files changed, 1199 insertions(+), 62 deletions(-) create mode 100644 BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj create mode 100644 BlueLaminate/BlueLaminate.Cli/Program.cs create mode 100644 BlueLaminate/BlueLaminate.Cli/WeaponSyncService.cs create mode 100644 BlueLaminate/BlueLaminate.Cli/appsettings.json create mode 100644 BlueLaminate/BlueLaminate.EFCore/Configurations/ScrapeRunConfiguration.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Configurations/WeaponConfiguration.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Entities/ScrapeRun.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260529182827_AddScrapeRunAndWeaponNameIndex.Designer.cs create mode 100644 BlueLaminate/BlueLaminate.EFCore/Migrations/20260529182827_AddScrapeRunAndWeaponNameIndex.cs delete mode 100644 BlueLaminate/BlueLaminate.EFCore/Program.cs create mode 100644 BlueLaminate/BlueLaminate.Scraper/BlueLaminate.Scraper.csproj create mode 100644 BlueLaminate/BlueLaminate.Scraper/Weapons/ScrapedWeapon.cs create mode 100644 BlueLaminate/BlueLaminate.Scraper/Weapons/WeaponWikiScraper.cs create mode 100644 BlueLaminate/BlueLaminate.Scraper/Wiki/WikiPageFetcher.cs create mode 100644 BlueLaminate/BlueLaminate.Scraper/Wiki/WikiText.cs delete mode 100644 WeaponGrabber/WeaponScraper.py diff --git a/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj b/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj new file mode 100644 index 0000000..4d74b43 --- /dev/null +++ b/BlueLaminate/BlueLaminate.Cli/BlueLaminate.Cli.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/BlueLaminate/BlueLaminate.Cli/Program.cs b/BlueLaminate/BlueLaminate.Cli/Program.cs new file mode 100644 index 0000000..a5f68c3 --- /dev/null +++ b/BlueLaminate/BlueLaminate.Cli/Program.cs @@ -0,0 +1,79 @@ +using System.CommandLine; +using BlueLaminate.Cli; +using BlueLaminate.EFCore.Data; +using BlueLaminate.Scraper.Weapons; +using BlueLaminate.Scraper.Wiki; + +// Entry point: System.CommandLine builds the command tree, parsing, and help. +// New features are added as additional commands here as they're implemented. +var forceOption = new Option("--force") +{ + Description = "Ignore the once-a-month throttle and sync now." +}; +var dryRunOption = new Option("--dry-run") +{ + Description = "Scrape and print the weapons without writing to the database." +}; + +var syncWeapons = new Command( + "sync-weapons", + "Scrape the CS2 weapon catalogue from the wiki and upsert it (throttled to once a month).") +{ + forceOption, + dryRunOption, +}; +syncWeapons.SetAction((parseResult, ct) => + SyncWeaponsAsync(parseResult.GetValue(forceOption), parseResult.GetValue(dryRunOption), ct)); + +var root = new RootCommand("BlueLaminate CLI — Counter-Strike skin tracker tools.") +{ + syncWeapons, +}; + +return await root.Parse(args).InvokeAsync(); + +// Fetch the CS2 weapon catalogue from the wiki and upsert it. Throttled to once +// a month unless --force is passed; --dry-run scrapes and prints without a DB. +static async Task SyncWeaponsAsync(bool force, bool dryRun, CancellationToken ct) +{ + var scraper = new WeaponWikiScraper(new WikiPageFetcher(CreateHttpClient())); + + if (dryRun) + { + var weapons = await scraper.ScrapeAsync(ct); + Console.WriteLine($"Scraped {weapons.Count} weapons (dry run, nothing written):"); + foreach (var w in weapons) + Console.WriteLine($" {w.Name,-20} {w.Type,-16} {w.Team}"); + return 0; + } + + using var db = new SkinTrackerDbContextFactory().CreateDbContext([]); + var result = await new WeaponSyncService(db, scraper).SyncAsync(force, ct); + + if (result.Skipped) + { + Console.WriteLine( + $"Skipped: weapons were last synced {result.LastRanAt:u}. " + + "Next run allowed one month later — pass --force to override."); + } + else + { + Console.WriteLine( + $"Synced {result.Scraped} weapons: {result.Inserted} inserted, " + + $"{result.Updated} updated, " + + $"{result.Scraped - result.Inserted - result.Updated} unchanged."); + } + + return 0; +} + +static HttpClient CreateHttpClient() +{ + var http = new HttpClient(); + // The wiki is fronted by Cloudflare; a browser-like User-Agent is accepted + // on the MediaWiki API endpoint the scraper uses. + http.DefaultRequestHeaders.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"); + return http; +} diff --git a/BlueLaminate/BlueLaminate.Cli/WeaponSyncService.cs b/BlueLaminate/BlueLaminate.Cli/WeaponSyncService.cs new file mode 100644 index 0000000..a42da1f --- /dev/null +++ b/BlueLaminate/BlueLaminate.Cli/WeaponSyncService.cs @@ -0,0 +1,77 @@ +using BlueLaminate.EFCore.Data; +using BlueLaminate.EFCore.Entities; +using BlueLaminate.Scraper.Weapons; +using Microsoft.EntityFrameworkCore; + +namespace BlueLaminate.Cli; + +/// True when the monthly throttle suppressed the run. +/// When the previous successful run happened, if any. +public sealed record WeaponSyncResult( + bool Skipped, + DateTimeOffset? LastRanAt, + int Scraped, + int Inserted, + int Updated); + +/// +/// Fetches the CS2 weapon catalogue and upserts it into the database. The +/// catalogue changes rarely, so a run is throttled to at most once a month +/// unless explicitly forced. +/// +public sealed class WeaponSyncService +{ + public const string Source = "weapons"; + + private readonly SkinTrackerDbContext _db; + private readonly WeaponWikiScraper _scraper; + + public WeaponSyncService(SkinTrackerDbContext db, WeaponWikiScraper scraper) + { + _db = db; + _scraper = scraper; + } + + public async Task SyncAsync(bool force = false, CancellationToken ct = default) + { + var now = DateTimeOffset.UtcNow; + + var lastRanAt = await _db.ScrapeRuns + .Where(r => r.Source == Source) + .OrderByDescending(r => r.RanAt) + .Select(r => (DateTimeOffset?)r.RanAt) + .FirstOrDefaultAsync(ct); + + if (!force && lastRanAt is { } last && last.AddMonths(1) > now) + return new WeaponSyncResult(Skipped: true, last, Scraped: 0, Inserted: 0, Updated: 0); + + var scraped = await _scraper.ScrapeAsync(ct); + + var existing = await _db.Weapons.ToDictionaryAsync(w => w.Name, ct); + var inserted = 0; + var updated = 0; + + foreach (var s in scraped) + { + if (existing.TryGetValue(s.Name, out var weapon)) + { + if (weapon.Type != s.Type || weapon.Team != s.Team) + { + weapon.Type = s.Type; + weapon.Team = s.Team; + updated++; + } + } + else + { + _db.Weapons.Add(new Weapon { Name = s.Name, Type = s.Type, Team = s.Team }); + inserted++; + } + } + + _db.ScrapeRuns.Add(new ScrapeRun { Source = Source, RanAt = now, ItemCount = scraped.Count }); + await _db.SaveChangesAsync(ct); + + return new WeaponSyncResult(Skipped: false, lastRanAt, scraped.Count, inserted, updated); + } +} diff --git a/BlueLaminate/BlueLaminate.Cli/appsettings.json b/BlueLaminate/BlueLaminate.Cli/appsettings.json new file mode 100644 index 0000000..ef45fd6 --- /dev/null +++ b/BlueLaminate/BlueLaminate.Cli/appsettings.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "SkinTracker": "Host=localhost;Port=5432;Database=skintracker;Username=postgres" + } +} diff --git a/BlueLaminate/BlueLaminate.EFCore/BlueLaminate.EFCore.csproj b/BlueLaminate/BlueLaminate.EFCore/BlueLaminate.EFCore.csproj index 2c78733..59b7ed7 100644 --- a/BlueLaminate/BlueLaminate.EFCore/BlueLaminate.EFCore.csproj +++ b/BlueLaminate/BlueLaminate.EFCore/BlueLaminate.EFCore.csproj @@ -1,7 +1,6 @@  - Exe net10.0 enable enable @@ -14,6 +13,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/ScrapeRunConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/ScrapeRunConfiguration.cs new file mode 100644 index 0000000..67bc4d9 --- /dev/null +++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/ScrapeRunConfiguration.cs @@ -0,0 +1,14 @@ +using BlueLaminate.EFCore.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BlueLaminate.EFCore.Configurations; + +public class ScrapeRunConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder entity) + { + // The throttle check looks up the most recent run for a given source. + entity.HasIndex(e => new { e.Source, e.RanAt }); + } +} diff --git a/BlueLaminate/BlueLaminate.EFCore/Configurations/WeaponConfiguration.cs b/BlueLaminate/BlueLaminate.EFCore/Configurations/WeaponConfiguration.cs new file mode 100644 index 0000000..8ba1b78 --- /dev/null +++ b/BlueLaminate/BlueLaminate.EFCore/Configurations/WeaponConfiguration.cs @@ -0,0 +1,14 @@ +using BlueLaminate.EFCore.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace BlueLaminate.EFCore.Configurations; + +public class WeaponConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder entity) + { + // Name is the natural key the scraper upserts against. + entity.HasIndex(e => e.Name).IsUnique(); + } +} diff --git a/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs b/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs index abd788e..aaf5f2d 100644 --- a/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs +++ b/BlueLaminate/BlueLaminate.EFCore/Data/SkinTrackerDbContext.cs @@ -19,6 +19,7 @@ public class SkinTrackerDbContext : DbContext } public DbSet Weapons => Set(); + public DbSet ScrapeRuns => Set(); public DbSet Skins => Set(); public DbSet SkinConditions => Set(); public DbSet SteamUsers => Set(); @@ -35,6 +36,8 @@ public class SkinTrackerDbContext : DbContext { modelBuilder.HasDefaultSchema(Schema); + modelBuilder.ApplyConfiguration(new WeaponConfiguration()); + modelBuilder.ApplyConfiguration(new ScrapeRunConfiguration()); modelBuilder.ApplyConfiguration(new SkinConfiguration()); modelBuilder.ApplyConfiguration(new SkinConditionConfiguration()); modelBuilder.ApplyConfiguration(new SteamUserConfiguration()); diff --git a/BlueLaminate/BlueLaminate.EFCore/Entities/ScrapeRun.cs b/BlueLaminate/BlueLaminate.EFCore/Entities/ScrapeRun.cs new file mode 100644 index 0000000..72219b6 --- /dev/null +++ b/BlueLaminate/BlueLaminate.EFCore/Entities/ScrapeRun.cs @@ -0,0 +1,19 @@ +namespace BlueLaminate.EFCore.Entities; + +/// +/// One successful run of a data-scraping job. Used to throttle jobs that +/// should run infrequently (e.g. the weapon catalogue, which changes rarely). +/// Only successful runs are recorded, so a failed run never blocks a retry. +/// +public class ScrapeRun +{ + public int Id { get; set; } + + /// Identifies which job this run belongs to, e.g. "weapons". + public string Source { get; set; } = null!; + + public DateTimeOffset RanAt { get; set; } + + /// How many records the run inserted or updated. + public int ItemCount { get; set; } +} diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529182827_AddScrapeRunAndWeaponNameIndex.Designer.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529182827_AddScrapeRunAndWeaponNameIndex.Designer.cs new file mode 100644 index 0000000..67a67c8 --- /dev/null +++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529182827_AddScrapeRunAndWeaponNameIndex.Designer.cs @@ -0,0 +1,609 @@ +// +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("20260529182827_AddScrapeRunAndWeaponNameIndex")] + partial class AddScrapeRunAndWeaponNameIndex + { + /// + 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.InventoryItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcquiredAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("acquired_at"); + + b.Property("AssetId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("asset_id"); + + b.Property("SkinInstanceId") + .HasColumnType("integer") + .HasColumnName("skin_instance_id"); + + b.Property("UserId") + .HasColumnType("integer") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_inventory_items"); + + b.HasIndex("AssetId") + .HasDatabaseName("ix_inventory_items_asset_id"); + + b.HasIndex("SkinInstanceId") + .HasDatabaseName("ix_inventory_items_skin_instance_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_inventory_items_user_id"); + + b.ToTable("inventory_items", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.PriceHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConditionId") + .HasColumnType("integer") + .HasColumnName("condition_id"); + + b.Property("Currency") + .IsRequired() + .HasColumnType("text") + .HasColumnName("currency"); + + b.Property("Price") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)") + .HasColumnName("price"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("recorded_at"); + + b.Property("SkinId") + .HasColumnType("integer") + .HasColumnName("skin_id"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.HasKey("Id") + .HasName("pk_price_histories"); + + b.HasIndex("ConditionId") + .HasDatabaseName("ix_price_histories_condition_id"); + + b.HasIndex("SkinId", "ConditionId", "RecordedAt") + .HasDatabaseName("ix_price_histories_skin_id_condition_id_recorded_at"); + + b.ToTable("price_histories", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ItemCount") + .HasColumnType("integer") + .HasColumnName("item_count"); + + b.Property("RanAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ran_at"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.HasKey("Id") + .HasName("pk_scrape_runs"); + + b.HasIndex("Source", "RanAt") + .HasDatabaseName("ix_scrape_runs_source_ran_at"); + + b.ToTable("scrape_runs", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("FloatMax") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(10,9)") + .HasDefaultValue(1.0m) + .HasColumnName("float_max"); + + b.Property("FloatMin") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(10,9)") + .HasDefaultValue(0.0m) + .HasColumnName("float_min"); + + b.Property("ImageUrl") + .HasColumnType("text") + .HasColumnName("image_url"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Rarity") + .IsRequired() + .HasColumnType("text") + .HasColumnName("rarity"); + + b.Property("TrueFloat") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("boolean") + .HasColumnName("true_float") + .HasComputedColumnSql("float_min = 0.0 AND float_max = 1.0", true); + + b.Property("WeaponId") + .HasColumnType("integer") + .HasColumnName("weapon_id"); + + b.HasKey("Id") + .HasName("pk_skins"); + + b.HasIndex("TrueFloat") + .HasDatabaseName("ix_skins_true_float"); + + b.HasIndex("WeaponId") + .HasDatabaseName("ix_skins_weapon_id"); + + b.ToTable("skins", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Condition") + .IsRequired() + .HasColumnType("text") + .HasColumnName("condition"); + + b.Property("MaxFloat") + .HasColumnType("numeric(10,9)") + .HasColumnName("max_float"); + + b.Property("MinFloat") + .HasColumnType("numeric(10,9)") + .HasColumnName("min_float"); + + b.Property("SkinId") + .HasColumnType("integer") + .HasColumnName("skin_id"); + + b.HasKey("Id") + .HasName("pk_skin_conditions"); + + b.HasIndex("SkinId") + .HasDatabaseName("ix_skin_conditions_skin_id"); + + b.ToTable("skin_conditions", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConditionId") + .HasColumnType("integer") + .HasColumnName("condition_id"); + + b.Property("FirstSeenAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_seen_at"); + + b.Property("FloatValue") + .HasColumnType("numeric(10,9)") + .HasColumnName("float_value"); + + b.Property("PaintSeed") + .IsRequired() + .HasColumnType("text") + .HasColumnName("paint_seed"); + + b.Property("SkinId") + .HasColumnType("integer") + .HasColumnName("skin_id"); + + b.Property("Souvenir") + .HasColumnType("boolean") + .HasColumnName("souvenir"); + + b.Property("StatTrak") + .HasColumnType("boolean") + .HasColumnName("stat_trak"); + + b.HasKey("Id") + .HasName("pk_skin_instances"); + + b.HasIndex("ConditionId") + .HasDatabaseName("ix_skin_instances_condition_id"); + + b.HasIndex("FloatValue") + .HasDatabaseName("ix_skin_instances_float_value"); + + b.HasIndex("PaintSeed") + .HasDatabaseName("ix_skin_instances_paint_seed"); + + b.HasIndex("SkinId") + .HasDatabaseName("ix_skin_instances_skin_id"); + + b.ToTable("skin_instances", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("LastSyncedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_synced_at"); + + b.Property("SteamId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("steam_id"); + + b.HasKey("Id") + .HasName("pk_steam_users"); + + b.HasIndex("SteamId") + .IsUnique() + .HasDatabaseName("ix_steam_users_steam_id"); + + b.ToTable("steam_users", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FromUserId") + .HasColumnType("integer") + .HasColumnName("from_user_id"); + + b.Property("SteamTradeId") + .HasColumnType("text") + .HasColumnName("steam_trade_id"); + + b.Property("ToUserId") + .HasColumnType("integer") + .HasColumnName("to_user_id"); + + b.Property("TradedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("traded_at"); + + b.HasKey("Id") + .HasName("pk_trades"); + + b.HasIndex("FromUserId") + .HasDatabaseName("ix_trades_from_user_id"); + + b.HasIndex("ToUserId") + .HasDatabaseName("ix_trades_to_user_id"); + + b.ToTable("trades", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.TradeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("InventoryItemId") + .HasColumnType("integer") + .HasColumnName("inventory_item_id"); + + b.Property("TradeId") + .HasColumnType("integer") + .HasColumnName("trade_id"); + + b.HasKey("Id") + .HasName("pk_trade_items"); + + b.HasIndex("InventoryItemId") + .HasDatabaseName("ix_trade_items_inventory_item_id"); + + b.HasIndex("TradeId") + .HasDatabaseName("ix_trade_items_trade_id"); + + b.ToTable("trade_items", "skintracker"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Team") + .IsRequired() + .HasColumnType("text") + .HasColumnName("team"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("pk_weapons"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_weapons_name"); + + b.ToTable("weapons", "skintracker"); + }); + + modelBuilder.Entity("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.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("BlueLaminate.EFCore.Entities.InventoryItem", b => + { + b.Navigation("TradeItems"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b => + { + b.Navigation("Conditions"); + + b.Navigation("Instances"); + + b.Navigation("PriceHistories"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinCondition", b => + { + b.Navigation("Instances"); + + b.Navigation("PriceHistories"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SkinInstance", b => + { + b.Navigation("InventoryItems"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.SteamUser", b => + { + b.Navigation("InventoryItems"); + + b.Navigation("TradesReceived"); + + b.Navigation("TradesSent"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Trade", b => + { + b.Navigation("TradeItems"); + }); + + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Weapon", b => + { + b.Navigation("Skins"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529182827_AddScrapeRunAndWeaponNameIndex.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529182827_AddScrapeRunAndWeaponNameIndex.cs new file mode 100644 index 0000000..21a6bab --- /dev/null +++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/20260529182827_AddScrapeRunAndWeaponNameIndex.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BlueLaminate.EFCore.Migrations +{ + /// + public partial class AddScrapeRunAndWeaponNameIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "scrape_runs", + schema: "skintracker", + columns: table => new + { + id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + source = table.Column(type: "text", nullable: false), + ran_at = table.Column(type: "timestamp with time zone", nullable: false), + item_count = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_scrape_runs", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "ix_weapons_name", + schema: "skintracker", + table: "weapons", + column: "name", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_scrape_runs_source_ran_at", + schema: "skintracker", + table: "scrape_runs", + columns: new[] { "source", "ran_at" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "scrape_runs", + schema: "skintracker"); + + migrationBuilder.DropIndex( + name: "ix_weapons_name", + schema: "skintracker", + table: "weapons"); + } + } +} diff --git a/BlueLaminate/BlueLaminate.EFCore/Migrations/SkinTrackerDbContextModelSnapshot.cs b/BlueLaminate/BlueLaminate.EFCore/Migrations/SkinTrackerDbContextModelSnapshot.cs index 4c62ecc..99c0979 100644 --- a/BlueLaminate/BlueLaminate.EFCore/Migrations/SkinTrackerDbContextModelSnapshot.cs +++ b/BlueLaminate/BlueLaminate.EFCore/Migrations/SkinTrackerDbContextModelSnapshot.cs @@ -112,6 +112,37 @@ namespace BlueLaminate.EFCore.Migrations b.ToTable("price_histories", "skintracker"); }); + modelBuilder.Entity("BlueLaminate.EFCore.Entities.ScrapeRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ItemCount") + .HasColumnType("integer") + .HasColumnName("item_count"); + + b.Property("RanAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("ran_at"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text") + .HasColumnName("source"); + + b.HasKey("Id") + .HasName("pk_scrape_runs"); + + b.HasIndex("Source", "RanAt") + .HasDatabaseName("ix_scrape_runs_source_ran_at"); + + b.ToTable("scrape_runs", "skintracker"); + }); + modelBuilder.Entity("BlueLaminate.EFCore.Entities.Skin", b => { b.Property("Id") @@ -389,6 +420,10 @@ namespace BlueLaminate.EFCore.Migrations b.HasKey("Id") .HasName("pk_weapons"); + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_weapons_name"); + b.ToTable("weapons", "skintracker"); }); diff --git a/BlueLaminate/BlueLaminate.EFCore/Program.cs b/BlueLaminate/BlueLaminate.EFCore/Program.cs deleted file mode 100644 index 54bfd60..0000000 --- a/BlueLaminate/BlueLaminate.EFCore/Program.cs +++ /dev/null @@ -1,7 +0,0 @@ -using BlueLaminate.EFCore.Data; - -// Build the context the same way the design-time factory does. Run -// `dotnet ef database update` to apply migrations to the configured database. -using var db = new SkinTrackerDbContextFactory().CreateDbContext(args); - -Console.WriteLine($"CS2 Skin Tracker — {db.Model.GetEntityTypes().Count()} entities mapped."); diff --git a/BlueLaminate/BlueLaminate.Scraper/BlueLaminate.Scraper.csproj b/BlueLaminate/BlueLaminate.Scraper/BlueLaminate.Scraper.csproj new file mode 100644 index 0000000..0a81ac4 --- /dev/null +++ b/BlueLaminate/BlueLaminate.Scraper/BlueLaminate.Scraper.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/BlueLaminate/BlueLaminate.Scraper/Weapons/ScrapedWeapon.cs b/BlueLaminate/BlueLaminate.Scraper/Weapons/ScrapedWeapon.cs new file mode 100644 index 0000000..e890a85 --- /dev/null +++ b/BlueLaminate/BlueLaminate.Scraper/Weapons/ScrapedWeapon.cs @@ -0,0 +1,7 @@ +namespace BlueLaminate.Scraper.Weapons; + +/// A single CS2 weapon parsed from the Counter-Strike wiki. +/// Display name, e.g. "AK-47". +/// Category from the wiki heading, e.g. "Pistols", "Assault Rifles". +/// "CT", "T", or "Both". +public sealed record ScrapedWeapon(string Name, string Type, string Team); diff --git a/BlueLaminate/BlueLaminate.Scraper/Weapons/WeaponWikiScraper.cs b/BlueLaminate/BlueLaminate.Scraper/Weapons/WeaponWikiScraper.cs new file mode 100644 index 0000000..15e3eef --- /dev/null +++ b/BlueLaminate/BlueLaminate.Scraper/Weapons/WeaponWikiScraper.cs @@ -0,0 +1,172 @@ +using System.Text.RegularExpressions; +using BlueLaminate.Scraper.Wiki; +using HtmlAgilityPack; + +namespace BlueLaminate.Scraper.Weapons; + +/// +/// Scrapes the CS2 weapon catalogue from the wiki's "Weapons" page. +/// +/// Layout: the page has one "tabber" per weapon category, each immediately +/// preceded by a section heading (the category / Type). Inside each tabber the +/// "Global Offensive & Counter-Strike 2" tab holds a gallery of captions — +/// one per weapon, optionally suffixed with "(CT)" or "(T)" for team-locked +/// weapons. +/// +public sealed class WeaponWikiScraper +{ + private const string Page = "Weapons"; + private const string Cs2TabHash = "Global_Offensive_&_Counter-Strike_2"; + + // Matches a trailing "(CT)" / "(T)" team annotation, capturing the team. + private static readonly Regex TeamAnnotation = + new(@"\s*\((CT|T)\)\s*$", RegexOptions.Compiled); + + // The wiki labels the default knife "Stock Knife"; drop the prefix. + private static readonly Regex StockPrefix = + new(@"^Stock\s+", RegexOptions.Compiled); + + private readonly WikiPageFetcher _fetcher; + + public WeaponWikiScraper(WikiPageFetcher fetcher) => _fetcher = fetcher; + + public async Task> ScrapeAsync(CancellationToken ct = default) + { + var doc = await _fetcher.LoadAsync(Page, ct); + + // Headings and tabbers in document order so each tabber inherits the + // most recent heading as its category. + var nodes = doc.DocumentNode.SelectNodes( + "//h2 | //h3 | //h4 | " + + "//div[contains(concat(' ', normalize-space(@class), ' '), ' tabber ')]"); + + var aggregator = new WeaponAggregator(); + string? currentType = null; + + if (nodes is not null) + { + foreach (var node in nodes) + { + if (node.Name is "h2" or "h3" or "h4") + { + currentType = HeadingText(node); + continue; + } + + if (currentType is null) + continue; + + foreach (var caption in Cs2Captions(node)) + aggregator.Add(caption, currentType); + } + } + + return aggregator.Build(); + } + + /// Caption texts from the CS2 tab of a single tabber, if present. + private static IEnumerable Cs2Captions(HtmlNode tabber) + { + var tabs = tabber.SelectNodes( + ".//li[contains(concat(' ', normalize-space(@class), ' '), ' wds-tabs__tab ')]"); + if (tabs is null) + yield break; + + var index = -1; + for (var i = 0; i < tabs.Count; i++) + { + // HtmlAgilityPack returns attribute values un-decoded, and the wiki + // entity-encodes the "&" in this hash (&). + var hash = HtmlEntity.DeEntitize(tabs[i].GetAttributeValue("data-hash", string.Empty)); + if (hash == Cs2TabHash) + { + index = i; + break; + } + } + + if (index < 0) + yield break; + + var contents = tabber.SelectNodes( + ".//div[contains(concat(' ', normalize-space(@class), ' '), ' wds-tab__content ')]"); + if (contents is null || index >= contents.Count) + yield break; + + var captions = contents[index].SelectNodes( + ".//div[contains(concat(' ', normalize-space(@class), ' '), ' lightbox-caption ')]"); + if (captions is null) + yield break; + + foreach (var caption in captions) + yield return WikiText.Normalize(caption.InnerText); + } + + private static string HeadingText(HtmlNode heading) + { + var headline = heading.SelectSingleNode( + ".//span[contains(concat(' ', normalize-space(@class), ' '), ' mw-headline ')]"); + return WikiText.Normalize((headline ?? heading).InnerText); + } + + /// + /// Collapses the per-caption rows into one weapon per name, tracking which + /// teams it appeared for so a weapon shown as both "(CT)" and "(T)" (or with + /// no annotation) resolves to "Both". + /// + private sealed class WeaponAggregator + { + private sealed class Entry + { + public required string Type { get; init; } + public bool SawCt; + public bool SawT; + public bool SawUnannotated; + } + + private readonly Dictionary _byName = new(); + private readonly List _order = new(); + + public void Add(string caption, string type) + { + if (string.IsNullOrEmpty(caption)) + return; + + var match = TeamAnnotation.Match(caption); + var name = TeamAnnotation.Replace(caption, string.Empty); + name = StockPrefix.Replace(name, string.Empty).Trim(); + if (name.Length == 0) + return; + + if (!_byName.TryGetValue(name, out var entry)) + { + entry = new Entry { Type = type }; + _byName[name] = entry; + _order.Add(name); + } + + if (!match.Success) + entry.SawUnannotated = true; + else if (match.Groups[1].Value == "CT") + entry.SawCt = true; + else + entry.SawT = true; + } + + public IReadOnlyList Build() + { + var result = new List(_order.Count); + foreach (var name in _order) + { + var e = _byName[name]; + var team = + e.SawUnannotated || (e.SawCt && e.SawT) ? "Both" + : e.SawCt ? "CT" + : e.SawT ? "T" + : "Both"; + result.Add(new ScrapedWeapon(name, e.Type, team)); + } + return result; + } + } +} diff --git a/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiPageFetcher.cs b/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiPageFetcher.cs new file mode 100644 index 0000000..de5a41b --- /dev/null +++ b/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiPageFetcher.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using HtmlAgilityPack; + +namespace BlueLaminate.Scraper.Wiki; + +/// +/// Fetches a rendered page from the Counter-Strike Fandom wiki, shared by all +/// wiki scrapers. +/// +/// The rendered HTML pages sit behind Cloudflare, which 403s .NET's TLS +/// fingerprint regardless of headers. The MediaWiki action=parse API is +/// not challenged, so we fetch the same content as JSON from there and return +/// the embedded HTML as a parsed document. +/// +public sealed class WikiPageFetcher +{ + private const string ApiBase = "https://counterstrike.fandom.com/api.php"; + + private readonly HttpClient _http; + + public WikiPageFetcher(HttpClient http) => _http = http; + + /// Loads a wiki page (e.g. "Weapons") as a parsed HTML document. + public async Task LoadAsync(string page, CancellationToken ct = default) + { + var url = $"{ApiBase}?action=parse&page={Uri.EscapeDataString(page)}&prop=text&format=json"; + + using var resp = await _http.GetAsync(url, ct); + resp.EnsureSuccessStatusCode(); + + await using var stream = await resp.Content.ReadAsStreamAsync(ct); + using var json = await JsonDocument.ParseAsync(stream, cancellationToken: ct); + + if (json.RootElement.TryGetProperty("error", out var error)) + { + var info = error.TryGetProperty("info", out var i) ? i.GetString() : "unknown error"; + throw new InvalidOperationException($"Wiki API returned an error for page '{page}': {info}"); + } + + var html = json.RootElement + .GetProperty("parse") + .GetProperty("text") + .GetProperty("*") + .GetString() + ?? throw new InvalidOperationException($"Wiki API response for page '{page}' had no parsed text."); + + var doc = new HtmlDocument(); + doc.LoadHtml(html); + return doc; + } +} diff --git a/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiText.cs b/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiText.cs new file mode 100644 index 0000000..7c72cfc --- /dev/null +++ b/BlueLaminate/BlueLaminate.Scraper/Wiki/WikiText.cs @@ -0,0 +1,14 @@ +using System.Text.RegularExpressions; +using HtmlAgilityPack; + +namespace BlueLaminate.Scraper.Wiki; + +/// Text helpers shared by wiki scrapers. +public static class WikiText +{ + private static readonly Regex Whitespace = new(@"\s+", RegexOptions.Compiled); + + /// Decodes HTML entities and collapses whitespace runs to single spaces. + public static string Normalize(string raw) => + Whitespace.Replace(HtmlEntity.DeEntitize(raw) ?? string.Empty, " ").Trim(); +} diff --git a/BlueLaminate/BlueLaminate.slnx b/BlueLaminate/BlueLaminate.slnx index 24dc98c..62fa1ef 100644 --- a/BlueLaminate/BlueLaminate.slnx +++ b/BlueLaminate/BlueLaminate.slnx @@ -1,3 +1,5 @@ + + diff --git a/WeaponGrabber/WeaponScraper.py b/WeaponGrabber/WeaponScraper.py deleted file mode 100644 index cb3c9f2..0000000 --- a/WeaponGrabber/WeaponScraper.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Print every CS2 weapon listed on the Counter-Strike wiki. - -Requires: pip install curl_cffi beautifulsoup4 - -Uses curl_cffi instead of requests because the wiki sits behind Cloudflare, -which blocks Python's default TLS fingerprint with a 403 even when the -User-Agent header looks like a browser. -""" -import re - -from bs4 import BeautifulSoup -from curl_cffi import requests - -URL = "https://counterstrike.fandom.com/wiki/Weapons" -TAB_HASH = "Global_Offensive_&_Counter-Strike_2" -ANNOTATION_RE = re.compile(r"\s*\((?:CT|T)\)\s*$") -STOCK_PREFIX_RE = re.compile(r"^Stock\s+") - - -def cs2_weapons(): - resp = requests.get(URL, impersonate="chrome", timeout=30) - resp.raise_for_status() - soup = BeautifulSoup(resp.text, "html.parser") - - weapons, seen = [], set() - for tabber in soup.select("div.tabber"): - tabs = tabber.select("li.wds-tabs__tab") - idx = next( - (i for i, t in enumerate(tabs) if t.get("data-hash") == TAB_HASH), - None, - ) - if idx is None: - continue - contents = tabber.find_all("div", class_="wds-tab__content") - if idx >= len(contents): - continue - for cap in contents[idx].select("div.lightbox-caption"): - name = cap.get_text(" ", strip=True) - name = ANNOTATION_RE.sub("", name) - name = STOCK_PREFIX_RE.sub("", name).strip() - if not name: - continue - if name not in seen: - seen.add(name) - weapons.append(name) - return weapons - - -if __name__ == "__main__": - weaps = cs2_weapons() - for w in weaps: - print(w) - - print(len(weaps)) \ No newline at end of file