Files
Operation-Blue-Laminate-v2/BlueLaminate/BlueLaminate.Core/DependencyInjection/ServiceCollectionExtensions.cs
bob dc7c3f99ae Add cs.money worker stack with per-worker IPRoyal residential proxy
Brings up the pull-model scraper: the .NET C2 hands skin+wear jobs to Python nodriver workers that scrape cs.money and post results back, plus the supporting Core/EFCore data model, migrations, and docker-compose orchestration.

IPRoyal proxying lets workers scale horizontally with a distinct residential exit IP each: every worker process mints its own sticky session at startup, and an in-process forwarding proxy injects the gateway auth so Chromium talks only to an auth-free localhost endpoint (zero CDP). On a Cloudflare challenge a worker rotates to a fresh session/IP and re-warms. Verified end-to-end against live IPRoyal: distinct US residential exits per worker and IP rotation on demand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:03:53 -05:00

121 lines
5.6 KiB
C#

using BlueLaminate.Core.Listings;
using BlueLaminate.Core.Options;
using BlueLaminate.Core.Skins;
using BlueLaminate.EFCore.DependencyInjection;
using BlueLaminate.Scraper.Browser;
using BlueLaminate.Scraper.CsFloat;
using BlueLaminate.Scraper.CsMoney;
using BlueLaminate.Scraper.Proxies;
using BlueLaminate.Scraper.Skins;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace BlueLaminate.Core.DependencyInjection;
/// <summary>
/// The single composition root for BlueLaminate's application logic. Any frontend
/// — the CLI today, a web UI later — wires up the whole stack with one call:
/// <c>services.AddBlueLaminateCore(configuration)</c>. Nothing about the database,
/// the CSFloat client, or the sweep/sync services is duplicated per host.
/// </summary>
public static class ServiceCollectionExtensions
{
private const string CsFloatHttpClient = "csfloat";
private const string CatalogHttpClient = "catalog";
public static IServiceCollection AddBlueLaminateCore(
this IServiceCollection services,
IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("SkinTracker")
?? throw new InvalidOperationException(
"Connection string 'SkinTracker' is not configured. Set it via user secrets (dev) "
+ "or the ConnectionStrings__SkinTracker environment variable (prod).");
// Database (DbContext is registered scoped by the EFCore extension).
services.AddSkinTrackerData(connectionString);
// Options bound from configuration. The CsFloat API key falls back to the
// legacy CSFLOAT_API_KEY environment variable so existing setups keep working.
services.AddOptions<CsFloatOptions>()
.Bind(configuration.GetSection(CsFloatOptions.SectionName))
.Configure(o =>
{
if (string.IsNullOrWhiteSpace(o.ApiKey))
{
o.ApiKey = configuration["CSFLOAT_API_KEY"];
}
})
.ValidateDataAnnotations()
.ValidateOnStart();
services.AddOptions<SkinCatalogOptions>()
.Bind(configuration.GetSection(SkinCatalogOptions.SectionName));
services.AddOptions<SweepOptions>()
.Bind(configuration.GetSection(SweepOptions.SectionName));
services.AddOptions<CsMoneyOptions>()
.Bind(configuration.GetSection(CsMoneyOptions.SectionName));
// Typed-handler pooling via IHttpClientFactory; clients are scoped so a
// command's handler and the service it drives share one instance (and thus
// the same LastRateLimit) within a single request scope.
services.AddHttpClient(CsFloatHttpClient, ConfigureHttpClient);
services.AddHttpClient(CatalogHttpClient, ConfigureHttpClient);
services.AddScoped(sp => new CsFloatListingsClient(
sp.GetRequiredService<IHttpClientFactory>().CreateClient(CsFloatHttpClient),
sp.GetRequiredService<IOptions<CsFloatOptions>>().Value,
sp.GetRequiredService<ILogger<CsFloatListingsClient>>()));
services.AddScoped(sp => new SkinCatalogClient(
sp.GetRequiredService<IHttpClientFactory>().CreateClient(CatalogHttpClient),
sp.GetRequiredService<IOptions<SkinCatalogOptions>>().Value));
// Residential proxy provider (IPRoyal). Credentials come from configuration
// — IPROYAL_USERNAME / IPROYAL_PASSWORD env vars in practice. Resolution
// throws a clear error only when a proxy-using command actually needs it, so
// API-only commands (sync, fetch) run without proxy creds configured.
services.AddSingleton<IProxyProvider>(sp =>
{
var username = configuration["IPROYAL_USERNAME"];
var password = configuration["IPROYAL_PASSWORD"];
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
throw new InvalidOperationException(
"IPRoyal credentials are not configured. Set IPROYAL_USERNAME and "
+ "IPROYAL_PASSWORD (env vars or user secrets) before running a proxy command.");
}
return new IpRoyalProxyProvider(username, password);
});
// cs.money is driven through a real, non-headless browser (Selenium/Edge,
// zero CDP) routed through a local forwarding proxy that chains to the
// residential gateway, not an HttpClient.
services.AddSingleton<LocalForwardingProxyFactory>();
services.AddScoped<BrowserDriverFactory>();
services.AddScoped<ProxyProbe>();
services.AddScoped(sp => new CsMoneyCaptureService(
sp.GetRequiredService<IProxyProvider>(),
sp.GetRequiredService<LocalForwardingProxyFactory>(),
sp.GetRequiredService<BrowserDriverFactory>(),
sp.GetRequiredService<IOptions<CsMoneyOptions>>().Value,
sp.GetRequiredService<ILogger<CsMoneyCaptureService>>()));
// Application services (constructor injection; DbContext keeps them scoped).
services.AddScoped<ListingSweepService>();
services.AddScoped<SkinSyncService>();
services.AddScoped<CsMoney.CsMoneyIngestService>();
services.AddScoped<CsMoney.MarketPresenceService>();
return services;
}
private static void ConfigureHttpClient(HttpClient http)
{
http.Timeout = TimeSpan.FromMinutes(2);
http.DefaultRequestHeaders.UserAgent.ParseAdd("BlueLaminate");
}
}