_logger;
+
+ // Matches handicap text like "(-1.0)" or "(+1.0)" or "(2.5)" in prefix text
+ [GeneratedRegex(@"\(([+-]?\d+(?:\.\d+)?)\)", RegexOptions.CultureInvariant)]
+ private static partial Regex HandicapValueRegex();
+
+ // Basketball "Normal_Time_Result" or "Match_Winner_Including_All_OT"
+ private static readonly string[] MatchResultMarkets =
+ [
+ "Match_Result",
+ "Normal_Time_Result",
+ "Match_Winner_Including_All_OT",
+ ];
+
+ private static readonly string[] HandicapMarkets =
+ [
+ "To_Win_Match_With_Handicap",
+ "Match_Handicap",
+ "To_Win_Match_With_Handicap_By_Games",
+ ];
+
+ private static readonly string[] TotalMarkets =
+ [
+ "Total_Goals",
+ "Total_Points",
+ "Total_Games",
+ ];
+
+ public EventOddsParser(
+ IServerTimeProvider serverTime,
+ PeriodScopeMapper periodMapper,
+ ILogger logger)
+ {
+ _serverTime = serverTime;
+ _periodMapper = periodMapper;
+ _logger = logger;
+ }
+
+ ///
+ public async Task ParseAsync(
+ string html,
+ OddsSource source,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(html);
+
+ var capturedAt = _serverTime.ExtractServerTime(html)
+ ?? new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset);
+
+ var config = AngleSharpConfig.Default;
+ using var context = BrowsingContext.New(config);
+ using var document = await context
+ .OpenAsync(req => req.Content(html), ct)
+ .ConfigureAwait(false);
+
+ // Extract event ID from the first coupon-row
+ var mainRow = document.QuerySelector("div.coupon-row[data-event-eventId]");
+ if (mainRow is null)
+ {
+ _logger.LogWarning("No coupon-row with eventId found. Page may not be an event detail.");
+ return null;
+ }
+
+ var eventIdRaw = mainRow.GetAttribute("data-event-eventId");
+ if (string.IsNullOrWhiteSpace(eventIdRaw))
+ return null;
+
+ var eventId = new DomainEventId(eventIdRaw);
+
+ // Determine sport code for period market token resolution
+ var sportCode = ExtractSportCode(document);
+
+ // Collect all selection spans with data-selection-key and data-selection-price
+ var selections = document
+ .QuerySelectorAll("span[data-selection-key][data-selection-price]")
+ .ToList();
+
+ if (selections.Count == 0)
+ {
+ _logger.LogWarning(
+ "No selections found on event detail page for eventId={EventId}.", eventIdRaw);
+ return null;
+ }
+
+ // Index selections by key for O(1) lookup
+ var selectionIndex = BuildSelectionIndex(selections);
+
+ var bets = new List();
+
+ // ── Match scope bets ───────────────────────────────────────────────
+ ExtractMatchWin(selectionIndex, eventIdRaw, bets);
+ ExtractMatchHandicap(selectionIndex, document, eventIdRaw, bets);
+ ExtractMatchTotal(selectionIndex, document, eventIdRaw, bets);
+
+ // ── Period scope bets ──────────────────────────────────────────────
+ if (sportCode is not null)
+ {
+ var maxPeriods = _periodMapper.MaxPeriods(sportCode);
+ for (var n = 1; n <= maxPeriods; n++)
+ {
+ ExtractPeriodWin(selectionIndex, document, sportCode, eventIdRaw, n, bets);
+ ExtractPeriodHandicap(selectionIndex, document, sportCode, eventIdRaw, n, bets);
+ ExtractPeriodTotal(selectionIndex, document, sportCode, eventIdRaw, n, bets);
+ }
+ }
+
+ if (bets.Count == 0)
+ {
+ _logger.LogWarning(
+ "No parseable bets extracted for eventId={EventId}. " +
+ "Markets may be suspended or the page structure has changed.",
+ eventIdRaw);
+ return null;
+ }
+
+ return new OddsSnapshot(eventId, capturedAt, source, bets);
+ }
+
+ // ── Selection indexing ─────────────────────────────────────────────────
+
+ private static Dictionary BuildSelectionIndex(List selections)
+ {
+ var index = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var sel in selections)
+ {
+ var key = sel.GetAttribute("data-selection-key");
+ var priceStr = sel.GetAttribute("data-selection-price");
+ if (!string.IsNullOrWhiteSpace(key) &&
+ decimal.TryParse(priceStr, NumberStyles.Number,
+ CultureInfo.InvariantCulture, out var price) &&
+ price > 1.0m)
+ {
+ // First occurrence wins (main line usually appears first)
+ index.TryAdd(key, price);
+ }
+ }
+ return index;
+ }
+
+ // ── Match Win / Draw ───────────────────────────────────────────────────
+
+ private void ExtractMatchWin(
+ Dictionary idx,
+ string eventId,
+ List bets)
+ {
+ // Try each market variant; first match wins
+ foreach (var market in MatchResultMarkets)
+ {
+ var win1Key = $"{eventId}@{market}.1";
+ var drawKey = $"{eventId}@{market}.draw";
+ var win2Key = $"{eventId}@{market}.3";
+
+ // Basketball 2-way OT market uses HB_H / HB_A
+ var hbhKey = $"{eventId}@{market}.HB_H";
+ var hbaKey = $"{eventId}@{market}.HB_A";
+
+ var hasWin1 = idx.TryGetValue(win1Key, out var rate1);
+ var hasDraw = idx.TryGetValue(drawKey, out var rateDraw);
+ var hasWin2 = idx.TryGetValue(win2Key, out var rate2);
+ var hasHbh = idx.TryGetValue(hbhKey, out var rateHbh);
+ var hasHba = idx.TryGetValue(hbaKey, out var rateHba);
+
+ if (hasWin1 || hasDraw || hasWin2 || hasHbh || hasHba)
+ {
+ if (hasWin1)
+ TryAddBet(bets, MatchScope.Instance, BetType.Win, Side.Side1, null, rate1);
+ else if (hasHbh)
+ TryAddBet(bets, MatchScope.Instance, BetType.Win, Side.Side1, null, rateHbh);
+
+ if (hasDraw)
+ TryAddBet(bets, MatchScope.Instance, BetType.Draw, Side.Draw, null, rateDraw);
+
+ if (hasWin2)
+ TryAddBet(bets, MatchScope.Instance, BetType.Win, Side.Side2, null, rate2);
+ else if (hasHba)
+ TryAddBet(bets, MatchScope.Instance, BetType.Win, Side.Side2, null, rateHba);
+
+ break; // Found a market — stop trying fallbacks
+ }
+ }
+ }
+
+ // ── Match Handicap ─────────────────────────────────────────────────────
+
+ private void ExtractMatchHandicap(
+ Dictionary idx,
+ IDocument document,
+ string eventId,
+ List bets)
+ {
+ foreach (var market in HandicapMarkets)
+ {
+ var hbhKey = $"{eventId}@{market}.HB_H";
+ var hbaKey = $"{eventId}@{market}.HB_A";
+
+ if (idx.TryGetValue(hbhKey, out var rateH) &&
+ idx.TryGetValue(hbaKey, out var rateA))
+ {
+ // Extract handicap value from the containing the HB_H selection
+ var hbhSpan = document
+ .QuerySelector($"span[data-selection-key='{hbhKey}']");
+ var hbhTd = hbhSpan?.Closest("td");
+ var valueH = ExtractHandicapFromTd(hbhTd);
+
+ var hbaSpan = document
+ .QuerySelector($"span[data-selection-key='{hbaKey}']");
+ var hbaTd = hbaSpan?.Closest("td");
+ var valueA = ExtractHandicapFromTd(hbaTd);
+
+ if (valueH.HasValue)
+ TryAddBet(bets, MatchScope.Instance, BetType.WinFora, Side.Side1,
+ valueH.Value, rateH);
+ if (valueA.HasValue)
+ TryAddBet(bets, MatchScope.Instance, BetType.WinFora, Side.Side2,
+ valueA.Value, rateA);
+
+ break;
+ }
+
+ // Also try no-suffix and suffix-0 fallback
+ var alt0HKey = $"{eventId}@{market}0.HB_H";
+ var alt0AKey = $"{eventId}@{market}0.HB_A";
+ if (idx.TryGetValue(alt0HKey, out rateH) &&
+ idx.TryGetValue(alt0AKey, out rateA))
+ {
+ var hbhSpan = document
+ .QuerySelector($"span[data-selection-key='{alt0HKey}']");
+ var hbhTd = hbhSpan?.Closest("td");
+ var valueH = ExtractHandicapFromTd(hbhTd);
+
+ var hbaSpan = document
+ .QuerySelector($"span[data-selection-key='{alt0AKey}']");
+ var hbaTd = hbaSpan?.Closest("td");
+ var valueA = ExtractHandicapFromTd(hbaTd);
+
+ if (valueH.HasValue)
+ TryAddBet(bets, MatchScope.Instance, BetType.WinFora, Side.Side1,
+ valueH.Value, rateH);
+ if (valueA.HasValue)
+ TryAddBet(bets, MatchScope.Instance, BetType.WinFora, Side.Side2,
+ valueA.Value, rateA);
+
+ break;
+ }
+ }
+ }
+
+ // ── Match Total ────────────────────────────────────────────────────────
+
+ private void ExtractMatchTotal(
+ Dictionary idx,
+ IDocument document,
+ string eventId,
+ List bets)
+ {
+ foreach (var market in TotalMarkets)
+ {
+ // Find main line — prefer no-suffix (@Total_Goals.Under_X.X)
+ var (underKey, overKey, threshold) = FindMainTotalLine(idx, eventId, market);
+
+ if (underKey is null || overKey is null || !threshold.HasValue)
+ continue;
+
+ if (idx.TryGetValue(underKey, out var underRate) &&
+ idx.TryGetValue(overKey, out var overRate))
+ {
+ TryAddBet(bets, MatchScope.Instance, BetType.Total, Side.Less,
+ threshold.Value, underRate);
+ TryAddBet(bets, MatchScope.Instance, BetType.Total, Side.More,
+ threshold.Value, overRate);
+ break;
+ }
+ }
+ }
+
+ // ── Period Win ─────────────────────────────────────────────────────────
+
+ private void ExtractPeriodWin(
+ Dictionary idx,
+ IDocument document,
+ SportCode sport,
+ string eventId,
+ int n,
+ List bets)
+ {
+ var marketToken = _periodMapper.TryGetResultToken(sport, n);
+ if (marketToken is null) return;
+
+ var scope = new PeriodScope(n);
+
+ var rnhKey = $"{eventId}@{marketToken}.RN_H";
+ var rndKey = $"{eventId}@{marketToken}.RN_D";
+ var rnaKey = $"{eventId}@{marketToken}.RN_A";
+
+ if (idx.TryGetValue(rnhKey, out var rateH))
+ TryAddBet(bets, scope, BetType.Win, Side.Side1, null, rateH);
+
+ if (idx.TryGetValue(rndKey, out var rateD))
+ TryAddBet(bets, scope, BetType.Draw, Side.Draw, null, rateD);
+
+ if (idx.TryGetValue(rnaKey, out var rateA))
+ TryAddBet(bets, scope, BetType.Win, Side.Side2, null, rateA);
+ }
+
+ // ── Period Handicap ────────────────────────────────────────────────────
+
+ private void ExtractPeriodHandicap(
+ Dictionary idx,
+ IDocument document,
+ SportCode sport,
+ string eventId,
+ int n,
+ List bets)
+ {
+ var marketToken = _periodMapper.TryGetHandicapToken(sport, n);
+ if (marketToken is null) return;
+
+ var scope = new PeriodScope(n);
+
+ var hbhKey = $"{eventId}@{marketToken}.HB_H";
+ var hbaKey = $"{eventId}@{marketToken}.HB_A";
+
+ if (!idx.TryGetValue(hbhKey, out var rateH) ||
+ !idx.TryGetValue(hbaKey, out var rateA))
+ {
+ // Try suffix-0 variant
+ hbhKey = $"{eventId}@{marketToken}0.HB_H";
+ hbaKey = $"{eventId}@{marketToken}0.HB_A";
+ if (!idx.TryGetValue(hbhKey, out rateH) ||
+ !idx.TryGetValue(hbaKey, out rateA))
+ return;
+ }
+
+ var hbhSpan = document.QuerySelector($"span[data-selection-key='{hbhKey}']");
+ var valueH = ExtractHandicapFromTd(hbhSpan?.Closest("td"));
+
+ var hbaSpan = document.QuerySelector($"span[data-selection-key='{hbaKey}']");
+ var valueA = ExtractHandicapFromTd(hbaSpan?.Closest("td"));
+
+ if (valueH.HasValue)
+ TryAddBet(bets, scope, BetType.WinFora, Side.Side1, valueH.Value, rateH);
+ if (valueA.HasValue)
+ TryAddBet(bets, scope, BetType.WinFora, Side.Side2, valueA.Value, rateA);
+ }
+
+ // ── Period Total ───────────────────────────────────────────────────────
+
+ private void ExtractPeriodTotal(
+ Dictionary idx,
+ IDocument document,
+ SportCode sport,
+ string eventId,
+ int n,
+ List bets)
+ {
+ var marketToken = _periodMapper.TryGetTotalToken(sport, n);
+ if (marketToken is null) return;
+
+ var scope = new PeriodScope(n);
+ var (underKey, overKey, threshold) = FindMainTotalLine(idx, eventId, marketToken);
+
+ if (underKey is null || overKey is null || !threshold.HasValue)
+ return;
+
+ if (idx.TryGetValue(underKey, out var underRate))
+ TryAddBet(bets, scope, BetType.Total, Side.Less, threshold.Value, underRate);
+
+ if (idx.TryGetValue(overKey, out var overRate))
+ TryAddBet(bets, scope, BetType.Total, Side.More, threshold.Value, overRate);
+ }
+
+ // ── Helpers ────────────────────────────────────────────────────────────
+
+ ///
+ /// Finds the "main" total line for a market prefix.
+ /// Prefers the no-suffix key (e.g., Total_Goals.Under_X);
+ /// falls back to suffix-0 (Total_Goals0.Under_X);
+ /// then picks the balanced line (Under+Over rates closest to 2.00).
+ ///
+ private static (string? underKey, string? overKey, decimal? threshold) FindMainTotalLine(
+ Dictionary idx,
+ string eventId,
+ string marketPrefix)
+ {
+ // First pass: collect all Under_* keys for this market
+ var candidates = idx.Keys
+ .Where(k => k.StartsWith($"{eventId}@{marketPrefix}", StringComparison.OrdinalIgnoreCase) &&
+ k.Contains(".Under_", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (candidates.Count == 0)
+ return (null, null, null);
+
+ // Prefer no-suffix: "{eventId}@{market}.Under_X" (no digit between market and dot)
+ var noSuffix = candidates.FirstOrDefault(k =>
+ {
+ var atPart = k[(k.LastIndexOf('@') + 1)..]; // "market.Under_X"
+ var dotIdx = atPart.IndexOf('.', StringComparison.Ordinal);
+ if (dotIdx < 0) return false;
+ var marketPart = atPart[..dotIdx]; // "Total_Goals" or "Total_Goals0"
+ return marketPart.Equals(marketPrefix, StringComparison.OrdinalIgnoreCase);
+ });
+
+ if (noSuffix is null)
+ {
+ // Try suffix-0
+ noSuffix = candidates.FirstOrDefault(k =>
+ k.Contains($"@{marketPrefix}0.", StringComparison.OrdinalIgnoreCase));
+ }
+
+ if (noSuffix is null)
+ {
+ // Fall back to the most balanced line (rates closest to 2.00)
+ noSuffix = candidates
+ .Where(uk =>
+ {
+ var overKey = uk.Replace(".Under_", ".Over_", StringComparison.OrdinalIgnoreCase);
+ return idx.ContainsKey(overKey);
+ })
+ .OrderBy(uk =>
+ {
+ var overKey = uk.Replace(".Under_", ".Over_", StringComparison.OrdinalIgnoreCase);
+ idx.TryGetValue(uk, out var u);
+ idx.TryGetValue(overKey, out var o);
+ return Math.Abs(u - 2.0m) + Math.Abs(o - 2.0m);
+ })
+ .FirstOrDefault();
+ }
+
+ if (noSuffix is null)
+ return (null, null, null);
+
+ var overCandidate = noSuffix.Replace(".Under_", ".Over_", StringComparison.OrdinalIgnoreCase);
+ var thresholdStr = noSuffix[(noSuffix.LastIndexOf(".Under_", StringComparison.OrdinalIgnoreCase) + 7)..];
+ decimal? threshold = decimal.TryParse(thresholdStr, NumberStyles.Number,
+ CultureInfo.InvariantCulture, out var t) ? t : null;
+
+ return (noSuffix, overCandidate, threshold);
+ }
+
+ private static decimal? ExtractHandicapFromTd(IElement? td)
+ {
+ if (td is null) return null;
+
+ // The begins with "(-1.0) " or "(+1.0) "
+ // We look at the raw text content of the | before the
+ var rawText = td.TextContent ?? string.Empty;
+ var match = HandicapValueRegex().Match(rawText);
+ if (!match.Success) return null;
+
+ return decimal.TryParse(
+ match.Groups[1].Value,
+ NumberStyles.Number | NumberStyles.AllowLeadingSign,
+ CultureInfo.InvariantCulture,
+ out var value) ? value : null;
+ }
+
+ private static SportCode? ExtractSportCode(IDocument document)
+ {
+ // Breadcrumb:
+ var crumbLink = document.QuerySelector("ol.breadcrumbs-list a[href*='/su/betting/']");
+ if (crumbLink is not null)
+ {
+ var href = crumbLink.GetAttribute("href") ?? string.Empty;
+ // e.g. "/su/betting/Basketball+-+6"
+ var lastSep = href.LastIndexOf("+-+", StringComparison.Ordinal);
+ if (lastSep >= 0)
+ {
+ var idStr = href[(lastSep + 3)..];
+ if (int.TryParse(idStr, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0)
+ return new SportCode(id);
+ }
+ }
+
+ // Fallback: check data-sport-treeId on outer containers
+ var container = document.QuerySelector("[data-sport-treeId]");
+ var attr = container?.GetAttribute("data-sport-treeId");
+ if (!string.IsNullOrWhiteSpace(attr) &&
+ int.TryParse(attr, NumberStyles.None, CultureInfo.InvariantCulture, out var sportId) &&
+ sportId > 0)
+ {
+ return new SportCode(sportId);
+ }
+
+ return null;
+ }
+
+ private void TryAddBet(
+ List bets,
+ BetScope scope,
+ BetType type,
+ Side side,
+ decimal? value,
+ decimal rate)
+ {
+ if (rate <= 1.0m) return; // OddsRate invariant
+
+ try
+ {
+ bets.Add(new Bet(
+ scope,
+ type,
+ side,
+ value.HasValue ? new OddsValue(value.Value) : null,
+ new OddsRate(rate)));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex,
+ "Skipping bet ({Type}, {Side}, value={Value}, rate={Rate}) — invariant violation.",
+ type, side, value, rate);
+ }
+ }
+}
diff --git a/src/Marathon.Infrastructure/Scraping/Parsers/IEventOddsParser.cs b/src/Marathon.Infrastructure/Scraping/Parsers/IEventOddsParser.cs
new file mode 100644
index 0000000..fb200c6
--- /dev/null
+++ b/src/Marathon.Infrastructure/Scraping/Parsers/IEventOddsParser.cs
@@ -0,0 +1,26 @@
+using Marathon.Domain.Entities;
+using Marathon.Domain.Enums;
+
+namespace Marathon.Infrastructure.Scraping.Parsers;
+
+///
+/// Parses an event detail page (/su/betting/{event-path}) into an
+/// containing all extractable bets.
+///
+public interface IEventOddsParser
+{
+ ///
+ /// Parses raw HTML from an event detail page.
+ ///
+ /// Full HTML body of the event detail page.
+ ///
+ /// Whether the snapshot is from the pre-match or live context.
+ /// Determines the stamped on the snapshot.
+ ///
+ /// Cancellation token.
+ ///
+ /// A populated , or null when
+ /// the page contains no parseable odds (e.g., event not found).
+ ///
+ Task ParseAsync(string html, OddsSource source, CancellationToken ct = default);
+}
diff --git a/src/Marathon.Infrastructure/Scraping/Parsers/ILiveEventsParser.cs b/src/Marathon.Infrastructure/Scraping/Parsers/ILiveEventsParser.cs
new file mode 100644
index 0000000..a77301c
--- /dev/null
+++ b/src/Marathon.Infrastructure/Scraping/Parsers/ILiveEventsParser.cs
@@ -0,0 +1,17 @@
+using Marathon.Domain.Entities;
+
+namespace Marathon.Infrastructure.Scraping.Parsers;
+
+///
+/// Parses the live-events listing page (/su/live) into a list of
+/// domain objects flagged as live.
+///
+public interface ILiveEventsParser
+{
+ ///
+ /// Parses raw HTML from the live listing page.
+ ///
+ /// Full HTML body of the live listing page.
+ /// Cancellation token.
+ Task> ParseAsync(string html, CancellationToken ct = default);
+}
diff --git a/src/Marathon.Infrastructure/Scraping/Parsers/IResultsParser.cs b/src/Marathon.Infrastructure/Scraping/Parsers/IResultsParser.cs
new file mode 100644
index 0000000..d8470ab
--- /dev/null
+++ b/src/Marathon.Infrastructure/Scraping/Parsers/IResultsParser.cs
@@ -0,0 +1,25 @@
+using Marathon.Domain.Entities;
+
+namespace Marathon.Infrastructure.Scraping.Parsers;
+
+///
+/// Parses a single event detail page to determine whether the match is complete
+/// and, if so, extracts the final score as an .
+///
+///
+/// Used by the Phase 8 watch-list poller — it re-fetches individual event
+/// detail pages until eventJsonInfo.matchIsComplete = true.
+///
+public interface IResultsParser
+{
+ ///
+ /// Parses raw HTML from an event detail page.
+ ///
+ /// Full HTML body of the event detail page.
+ /// Cancellation token.
+ ///
+ /// An when matchIsComplete=true and the
+ /// score is parseable; otherwise null.
+ ///
+ Task ParseAsync(string html, CancellationToken ct = default);
+}
diff --git a/src/Marathon.Infrastructure/Scraping/Parsers/IServerTimeProvider.cs b/src/Marathon.Infrastructure/Scraping/Parsers/IServerTimeProvider.cs
new file mode 100644
index 0000000..8dfea2a
--- /dev/null
+++ b/src/Marathon.Infrastructure/Scraping/Parsers/IServerTimeProvider.cs
@@ -0,0 +1,15 @@
+namespace Marathon.Infrastructure.Scraping.Parsers;
+
+///
+/// Extracts and caches the bookmaker's server time (Moscow TZ, UTC+3) from a
+/// page's embedded initData.serverTime script variable.
+///
+public interface IServerTimeProvider
+{
+ ///
+ /// Parses a page's HTML and returns the server time as a
+ /// with a +03:00 offset.
+ /// Returns null when the script variable cannot be found.
+ ///
+ DateTimeOffset? ExtractServerTime(string html);
+}
diff --git a/src/Marathon.Infrastructure/Scraping/Parsers/IUpcomingEventsParser.cs b/src/Marathon.Infrastructure/Scraping/Parsers/IUpcomingEventsParser.cs
new file mode 100644
index 0000000..ff2c85a
--- /dev/null
+++ b/src/Marathon.Infrastructure/Scraping/Parsers/IUpcomingEventsParser.cs
@@ -0,0 +1,21 @@
+using Marathon.Domain.Entities;
+
+namespace Marathon.Infrastructure.Scraping.Parsers;
+
+///
+/// Parses a pre-match listing page (/su/ or /su/betting/{Sport}+-+{id})
+/// into a list of domain objects.
+///
+public interface IUpcomingEventsParser
+{
+ ///
+ /// Parses raw HTML from a listing page.
+ ///
+ /// Full HTML body of the listing page.
+ /// Cancellation token.
+ ///
+ /// Events found on the page. An empty list is returned when the page
+ /// contains no events (e.g., sport filter returned no results).
+ ///
+ Task> ParseAsync(string html, CancellationToken ct = default);
+}
diff --git a/src/Marathon.Infrastructure/Scraping/Parsers/LiveEventsParser.cs b/src/Marathon.Infrastructure/Scraping/Parsers/LiveEventsParser.cs
new file mode 100644
index 0000000..e1321be
--- /dev/null
+++ b/src/Marathon.Infrastructure/Scraping/Parsers/LiveEventsParser.cs
@@ -0,0 +1,26 @@
+using Marathon.Domain.Entities;
+using Microsoft.Extensions.Logging;
+
+namespace Marathon.Infrastructure.Scraping.Parsers;
+
+///
+/// Parses a live-events listing page (/su/live) into
+/// objects flagged with data-live="true".
+///
+public sealed class LiveEventsParser : EventListingParserBase, ILiveEventsParser
+{
+ public LiveEventsParser(
+ IServerTimeProvider serverTimeProvider,
+ ILogger logger)
+ : base(serverTimeProvider, logger)
+ {
+ }
+
+ ///
+ public Task> ParseAsync(string html, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(html);
+ // liveOnly = true → only rows with data-live="true"
+ return ParseHtmlAsync(html, liveOnly: true, ct);
+ }
+}
diff --git a/src/Marathon.Infrastructure/Scraping/Parsers/MoscowDateParser.cs b/src/Marathon.Infrastructure/Scraping/Parsers/MoscowDateParser.cs
new file mode 100644
index 0000000..3626ebf
--- /dev/null
+++ b/src/Marathon.Infrastructure/Scraping/Parsers/MoscowDateParser.cs
@@ -0,0 +1,106 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace Marathon.Infrastructure.Scraping.Parsers;
+
+///
+/// Parses the two date string formats used on marathonbet.by listings:
+///
+/// - HH:MM — today's date is implied via .
+/// - DD <ru-month> HH:MM — e.g., 06 мая 22:00.
+///
+/// Always emits a with the Moscow UTC+3 offset.
+///
+public static partial class MoscowDateParser
+{
+ private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
+
+ // Matches "HH:MM"
+ [GeneratedRegex(@"^\s*(\d{1,2}):(\d{2})\s*$", RegexOptions.CultureInvariant)]
+ private static partial Regex TimeOnlyRegex();
+
+ // Matches "DD HH:MM", e.g. "06 мая 22:00"
+ [GeneratedRegex(
+ @"^\s*(\d{1,2})\s+([а-яё]+)\s+(\d{1,2}):(\d{2})\s*$",
+ RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
+ private static partial Regex FullDateRegex();
+
+ // Russian month abbreviations (nominative/genitive used by the site)
+ private static readonly Dictionary RuMonths = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["янв"] = 1, ["января"] = 1,
+ ["фев"] = 2, ["февраля"] = 2,
+ ["мар"] = 3, ["марта"] = 3,
+ ["апр"] = 4, ["апреля"] = 4,
+ ["май"] = 5, ["мая"] = 5,
+ ["июн"] = 6, ["июня"] = 6,
+ ["июл"] = 7, ["июля"] = 7,
+ ["авг"] = 8, ["августа"] = 8,
+ ["сен"] = 9, ["сентября"] = 9,
+ ["окт"] = 10, ["октября"] = 10,
+ ["ноя"] = 11, ["ноября"] = 11,
+ ["дек"] = 12, ["декабря"] = 12,
+ };
+
+ ///
+ /// Parses a date string from the event listing.
+ ///
+ /// Raw text from .date-wrapper element.
+ ///
+ /// Moscow-timezone server time from initData.serverTime.
+ /// Used as "today" anchor when contains only a time.
+ ///
+ ///
+ /// Parsed in UTC+3, or null if parsing fails.
+ ///
+ public static DateTimeOffset? TryParse(string? dateText, DateTimeOffset serverTimeAnchor)
+ {
+ if (string.IsNullOrWhiteSpace(dateText))
+ return null;
+
+ // Try time-only format first: "HH:MM"
+ var timeOnlyMatch = TimeOnlyRegex().Match(dateText);
+ if (timeOnlyMatch.Success)
+ {
+ var hour = int.Parse(timeOnlyMatch.Groups[1].Value, CultureInfo.InvariantCulture);
+ var minute = int.Parse(timeOnlyMatch.Groups[2].Value, CultureInfo.InvariantCulture);
+
+ // Anchor to server's "today" in Moscow time
+ var today = serverTimeAnchor.Date;
+ var scheduled = new DateTimeOffset(
+ today.Year, today.Month, today.Day,
+ hour, minute, 0,
+ MoscowOffset);
+
+ // If the computed time is already in the past (same day but earlier),
+ // that's fine — the event may have already started (live) or the listing
+ // is stale. Return as-is; the caller decides what to do.
+ return scheduled;
+ }
+
+ // Try full date format: "DD HH:MM"
+ var fullMatch = FullDateRegex().Match(dateText);
+ if (fullMatch.Success)
+ {
+ var day = int.Parse(fullMatch.Groups[1].Value, CultureInfo.InvariantCulture);
+ var monthToken = fullMatch.Groups[2].Value;
+ var hour = int.Parse(fullMatch.Groups[3].Value, CultureInfo.InvariantCulture);
+ var minute = int.Parse(fullMatch.Groups[4].Value, CultureInfo.InvariantCulture);
+
+ if (!RuMonths.TryGetValue(monthToken, out var month))
+ return null;
+
+ // Infer year: if month/day is before the server anchor's month/day,
+ // the event is in the next calendar year.
+ var anchorDate = serverTimeAnchor.Date;
+ var year = anchorDate.Year;
+ var candidate = new DateOnly(year, month, day);
+ if (candidate < DateOnly.FromDateTime(anchorDate))
+ year++; // e.g., anchor is Dec 2026 and event is in Jan 2027
+
+ return new DateTimeOffset(year, month, day, hour, minute, 0, MoscowOffset);
+ }
+
+ return null;
+ }
+}
diff --git a/src/Marathon.Infrastructure/Scraping/Parsers/OutcomeCodeMapper.cs b/src/Marathon.Infrastructure/Scraping/Parsers/OutcomeCodeMapper.cs
new file mode 100644
index 0000000..e24505d
--- /dev/null
+++ b/src/Marathon.Infrastructure/Scraping/Parsers/OutcomeCodeMapper.cs
@@ -0,0 +1,96 @@
+using Marathon.Domain.Enums;
+
+namespace Marathon.Infrastructure.Scraping.Parsers;
+
+///
+/// Translates bookmaker DOM outcome codes to the vocabulary-agnostic enum.
+///
+///
+/// Two vocabularies are in use on marathonbet.by:
+///
+/// -
+///
| | |