chore(med): mapping culture-safe parse, dead-code, scope comparer, UA rotator, parser cache

Six MEDIUM-tier review items:

* Mapping.cs — DateTimeOffset.Parse now passes CultureInfo.InvariantCulture
  + DateTimeStyles.RoundtripKind so a non-en-US thread culture cannot
  corrupt round-tripped ScheduledAt / CapturedAt / DetectedAt / CompletedAt.
  Also replaces the magic 0/1 BetScope discriminator with named constants.

* Delete dead Placeholder.cs files in Marathon.Application and
  Marathon.Infrastructure — they were stubs from Phase 1 to satisfy
  "non-empty project" and have been dead since Phase 2/3.

* EventBrowsingService — drop the bespoke ScopeEqualityComparer; BetScope
  is a record hierarchy, .GroupBy uses value equality natively.

* UserAgentRotatorHandler — counter promoted to private static int with
  Interlocked.Increment so rotation is round-robin across the process.
  HttpClientFactory builds the handler Transient, so the previous instance
  field reset to zero on every new client and broke rotation.

* EventOddsParser — added a parallel "selection-key → IElement" index
  alongside the existing price index. Handicap extraction (6 call sites
  per event detail page) used to do a fresh document.QuerySelector("span[
  data-selection-key='...']") for every key — full-document CSS traversal.
  Now it's a dictionary lookup, with the pair-emit logic factored into a
  shared TryEmitHandicapPair helper.
This commit is contained in:
2026-05-09 15:45:18 +03:00
parent fed3a09695
commit c2934b2c8d
6 changed files with 98 additions and 118 deletions
@@ -204,8 +204,10 @@ public sealed class EventBrowsingService : IEventBrowsingService
private static IReadOnlyList<EventScopeBoard> BuildBoards(OddsSnapshot snapshot)
{
// Group by scope, preserve Match-first order then ascending Period numbers.
// BetScope is a record hierarchy so .GroupBy uses value equality natively —
// no custom comparer needed.
var groups = snapshot.Bets
.GroupBy(static b => b.Scope, ScopeEqualityComparer.Instance)
.GroupBy(static b => b.Scope)
.OrderBy(static g => OrderKey(g.Key));
var boards = new List<EventScopeBoard>();
@@ -241,23 +243,4 @@ public sealed class EventBrowsingService : IEventBrowsingService
PeriodScope p => p.Number,
_ => int.MaxValue,
};
private sealed class ScopeEqualityComparer : IEqualityComparer<BetScope>
{
public static readonly ScopeEqualityComparer Instance = new();
public bool Equals(BetScope? x, BetScope? y) => (x, y) switch
{
(null, null) => true,
(MatchScope, MatchScope) => true,
(PeriodScope a, PeriodScope b) => a.Number == b.Number,
_ => false,
};
public int GetHashCode(BetScope obj) => obj switch
{
MatchScope => 0,
PeriodScope p => p.Number,
_ => -1,
};
}
}