WIP(initial-implementation): parallel batch P2/P3/P5 — code complete, unreviewed

Snapshot of the parallel batch (Phases 2 + 3 + 5) at session pause. Solution does
NOT build cleanly yet — known cross-phase compile issues remain to be resolved
before review. See plans/initial-implementation/PLAN.md "Resume Notes" section
for the exact tomorrow-morning action list.

Phase 2 (Storage):
- Repository interfaces in Marathon.Application/Abstractions
- DateRange, ExportKind, StorageOptions in Marathon.Application/Storage
- EF Core 8 + SQLite (WAL) persistence: 7 entities + configurations + 4 repos
- Hand-written InitialCreate migration (dotnet ef blocked by parallel work)
- ClosedXML ExcelExporter with exact customer-spec wide columns
- PersistenceModule.AddMarathonPersistence DI extension
- Round-trip + export tests (cannot run yet — see cross-phase issues)

Phase 3 (Scraping):
- IOddsScraper, IBetPlacer in Marathon.Application/Abstractions
- ScrapingOptions in Marathon.Infrastructure/Configuration
- MarathonbetScraper with 4 parsers (Upcoming, Live, EventOdds, Results)
- Helpers: ServerTimeProvider, PeriodScopeMapper, OutcomeCodeMapper, MoscowDateParser
- UserAgentRotatorHandler + Polly v8 resilience pipeline
- ScrapingModule.AddMarathonScraping DI extension
- GlobalUsings.cs aliases for EventId / Configuration disambiguation
- Parser tests with trimmed HTML fixtures
- ScrapeResultsAsync interim no-op (Phase 8 will replace via watch-list polling)

Phase 5 (UI shell — killed mid-final-verify, assumed ~95%):
- Marathon.UI populated: MainLayout, App.razor, Pages (Home, Settings),
  Components, Theme (MarathonTheme.cs + Tokens.cs + app.css), Resources
  (SharedResource.{cs,ru.resx,en.resx}), Services (ISettingsWriter), wwwroot
- WPF host: App.xaml(.cs), MainWindow.xaml(.cs), Marathon.Hosts.WpfBlazor.csproj
  with Microsoft.AspNetCore.Components.WebView.Wpf + MudBlazor + Serilog
- appsettings.json + appsettings.Development.json with all sections wired
- bUnit tests: MainLayoutTests, LocaleSwitcherTests, ThemeToggleTests,
  JsonSettingsWriterTests + Support helpers

Cross-phase issues to resolve at next session:
1. Phase 2 repository classes are 'internal' — Phase 3's tests can't reference
   them. Fix: add InternalsVisibleTo to Marathon.Infrastructure.csproj.
2. Phase 5: LocalizationOptions namespace ambiguity (AspNetCore vs Extensions).
3. Phase 5: WpfBlazor Serilog API mismatch.

Reviewer has NOT run on this batch. Move to Phase 4 only after build is green
and a combined parallel-batch reviewer passes.
This commit is contained in:
2026-05-05 01:56:53 +03:00
parent 144c936e90
commit e4d8476782
129 changed files with 8524 additions and 121 deletions
@@ -0,0 +1,8 @@
using Marathon.Domain.Entities;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="Anomaly"/> domain entities.
/// </summary>
public interface IAnomalyRepository : IRepository<Guid, Anomaly>;
@@ -0,0 +1,21 @@
namespace Marathon.Application.Abstractions;
/// <summary>
/// Marker interface for the future bet-placing feature.
/// </summary>
/// <remarks>
/// <para>
/// This interface is intentionally empty. It acts as an extension point for
/// a future implementation that interacts with a bookmaker's authenticated
/// betting API.
/// </para>
/// <para>
/// Phase 3 scope is analyze-only. Register a stub / no-op implementation if
/// needed for DI graph completeness, but the interface itself is not consumed
/// by any application service in the current release.
/// </para>
/// </remarks>
public interface IBetPlacer
{
// Future: PlaceBetAsync(BetRequest request, CancellationToken ct)
}
@@ -0,0 +1,15 @@
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="Event"/> domain entities.
/// </summary>
public interface IEventRepository : IRepository<EventId, Event>
{
Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default);
Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default);
}
@@ -0,0 +1,22 @@
using Marathon.Application.Storage;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Exports odds snapshots to an Excel file matching the customer's wide-column specification.
/// </summary>
public interface IExcelExporter
{
/// <summary>
/// Exports snapshots for the given date range to an XLSX file.
/// </summary>
/// <param name="range">The inclusive date range to export.</param>
/// <param name="kind">Which snapshots to include: pre-match, live, or combined.</param>
/// <param name="outputPath">
/// Directory where the file will be written. The filename is auto-generated as
/// <c>Marathon_yyyy-MM-dd_to_yyyy-MM-dd.xlsx</c>.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The full path of the created file.</returns>
Task<string> ExportAsync(DateRange range, ExportKind kind, string outputPath, CancellationToken ct = default);
}
@@ -0,0 +1,53 @@
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Scrapes upcoming events, live odds snapshots, and completed event results
/// from a bookmaker's public web interface.
/// </summary>
/// <remarks>
/// The infrastructure implementation (<c>MarathonbetScraper</c>) uses
/// HttpClient + AngleSharp + Polly. All methods are non-blocking and
/// honour the caller's <see cref="CancellationToken"/>.
/// </remarks>
public interface IOddsScraper
{
/// <summary>
/// Returns the list of upcoming (pre-match) events, optionally filtered to one sport.
/// </summary>
/// <param name="sportFilter">When non-null, restricts results to the given sport code.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<Event>> ScrapeUpcomingAsync(
SportCode? sportFilter,
CancellationToken ct);
/// <summary>
/// Fetches a full odds snapshot (all markets) for a single event.
/// </summary>
/// <param name="id">The bookmaker's event identifier.</param>
/// <param name="source">Whether this is a pre-match or live scrape.</param>
/// <param name="ct">Cancellation token.</param>
Task<OddsSnapshot> ScrapeEventOddsAsync(
EventId id,
OddsSource source,
CancellationToken ct);
/// <summary>
/// Returns completed event results within a date range.
/// </summary>
/// <remarks>
/// <para>
/// <b>Interim no-op (Phase 3):</b> marathonbet.by has no public results archive
/// endpoint (<c>/su/results</c> → 404). This method returns an empty list and
/// logs a warning. Results harvesting is implemented in Phase 8 via polling
/// event-detail pages until <c>matchIsComplete=true</c>.
/// </para>
/// </remarks>
Task<IReadOnlyList<EventResult>> ScrapeResultsAsync(
DateRange range,
CancellationToken ct);
}
@@ -0,0 +1,23 @@
namespace Marathon.Application.Abstractions;
/// <summary>
/// Generic repository abstraction providing CRUD operations for a domain entity.
/// </summary>
/// <typeparam name="TKey">The type of the entity's primary key.</typeparam>
/// <typeparam name="TEntity">The domain entity type.</typeparam>
public interface IRepository<TKey, TEntity>
where TKey : notnull
where TEntity : class
{
Task<TEntity?> GetAsync(TKey key, CancellationToken ct = default);
Task<IReadOnlyList<TEntity>> ListAsync(CancellationToken ct = default);
Task AddAsync(TEntity entity, CancellationToken ct = default);
Task UpdateAsync(TEntity entity, CancellationToken ct = default);
Task DeleteAsync(TKey key, CancellationToken ct = default);
Task SaveChangesAsync(CancellationToken ct = default);
}
@@ -0,0 +1,9 @@
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="EventResult"/> domain entities.
/// </summary>
public interface IResultRepository : IRepository<EventId, EventResult>;
@@ -0,0 +1,16 @@
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
namespace Marathon.Application.Abstractions;
/// <summary>
/// Repository for <see cref="OddsSnapshot"/> domain entities.
/// </summary>
public interface ISnapshotRepository : IRepository<Guid, OddsSnapshot>
{
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
EventId eventId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct = default);
}
@@ -0,0 +1,21 @@
namespace Marathon.Application.Storage;
/// <summary>
/// An inclusive date-time range used for querying and exporting snapshots.
/// </summary>
public sealed record DateRange
{
public DateTimeOffset From { get; }
public DateTimeOffset To { get; }
public DateRange(DateTimeOffset from, DateTimeOffset to)
{
if (from > to)
throw new ArgumentException(
$"DateRange.From ({from:O}) must be less than or equal to DateRange.To ({to:O}).",
nameof(from));
From = from;
To = to;
}
}
@@ -0,0 +1,16 @@
namespace Marathon.Application.Storage;
/// <summary>
/// Controls which odds snapshots are included in an Excel export.
/// </summary>
public enum ExportKind
{
/// <summary>Include only pre-match snapshots (columns prefixed with <c>Bet_</c>).</summary>
PreMatch,
/// <summary>Include only live snapshots (columns prefixed with <c>Live_</c>).</summary>
Live,
/// <summary>Include both pre-match and live snapshots on separate sheets.</summary>
Combined,
}
@@ -0,0 +1,18 @@
namespace Marathon.Application.Storage;
/// <summary>
/// Configuration options for the storage layer, bound to the <c>Storage:*</c> configuration section.
/// </summary>
public sealed class StorageOptions
{
public const string SectionName = "Storage";
/// <summary>Path to the SQLite database file. Default: <c>./data/marathon.db</c>.</summary>
public string DatabasePath { get; set; } = "./data/marathon.db";
/// <summary>Directory where Excel exports are written. Default: <c>./exports</c>.</summary>
public string ExportDirectory { get; set; } = "./exports";
/// <summary>Number of days to retain odds snapshots before pruning. Default: 90.</summary>
public int SnapshotRetentionDays { get; set; } = 90;
}
+2 -3
View File
@@ -1,9 +1,8 @@
<Application x:Class="Marathon.Hosts.WpfBlazor.App"
<Application x:Class="Marathon.Hosts.WpfBlazor.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:Marathon.Hosts.WpfBlazor"
StartupUri="MainWindow.xaml">
ShutdownMode="OnMainWindowClose">
<Application.Resources>
</Application.Resources>
</Application>
+159 -1
View File
@@ -1,10 +1,168 @@
using System.Globalization;
using System.IO;
using System.Windows;
using Marathon.UI.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Serilog;
namespace Marathon.Hosts.WpfBlazor;
/// <summary>
/// Interaction logic for App.xaml
/// WPF application entry-point. Builds an <see cref="IHost"/> with Serilog,
/// configuration (appsettings.json + Local + env vars), and the Marathon UI
/// service collection. Composes Application + Infrastructure modules
/// optionally — those module entry points may not yet exist while parallel
/// Phase 2/3/4 work merges.
/// </summary>
public partial class App : System.Windows.Application
{
public IHost? Host { get; private set; }
/// <summary>
/// Absolute path to the Local override settings file. Resolved from the
/// host's content root (the directory containing <c>appsettings.json</c>).
/// </summary>
public static string SettingsLocalFileName => "appsettings.Local.json";
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var contentRoot = AppContext.BaseDirectory;
var localSettingsPath = Path.Combine(contentRoot, SettingsLocalFileName);
var builder = Microsoft.Extensions.Hosting.Host.CreateApplicationBuilder();
builder.Environment.ContentRootPath = contentRoot;
builder.Configuration
.SetBasePath(contentRoot)
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: true)
.AddJsonFile(SettingsLocalFileName, optional: true, reloadOnChange: true)
.AddEnvironmentVariables(prefix: "MARATHON_");
// Serilog — structured rolling-file + console.
// Minimum level honours the "Serilog:MinimumLevel:Default" key when
// present in configuration; otherwise defaults to Information.
var logsDir = Path.Combine(contentRoot, "logs");
Directory.CreateDirectory(logsDir);
var minimumLevel = ParseMinimumLevel(builder.Configuration["Serilog:MinimumLevel:Default"]);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Is(minimumLevel)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.File(
path: Path.Combine(logsDir, "marathon-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14,
shared: true)
.CreateLogger();
builder.Services.AddSerilog();
// Marathon.UI services (Mud, localization, options, theme/locale state, settings writer).
builder.Services.AddMarathonUi(builder.Configuration, localSettingsPath);
// Blazor WebView root services.
builder.Services.AddWpfBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
#endif
// Compose Application + Infrastructure modules if they exist. Parallel
// Phase 2/3/4 work may still be merging these; we degrade gracefully.
TryAddApplicationAndInfrastructure(builder.Services, builder.Configuration);
// MainWindow needs the IServiceProvider for BlazorWebView.Services binding.
builder.Services.AddSingleton<MainWindow>();
Host = builder.Build();
Host.Start();
// Apply default culture from configuration before any UI renders.
var localeOptions = Host.Services.GetRequiredService<IOptions<LocalizationOptions>>().Value;
var locale = Host.Services.GetRequiredService<LocaleState>();
try
{
locale.Set(localeOptions.DefaultCulture);
}
catch (CultureNotFoundException)
{
locale.Set(LocaleState.Russian);
}
var window = Host.Services.GetRequiredService<MainWindow>();
window.Show();
}
private static Serilog.Events.LogEventLevel ParseMinimumLevel(string? raw) =>
Enum.TryParse<Serilog.Events.LogEventLevel>(raw, ignoreCase: true, out var level)
? level
: Serilog.Events.LogEventLevel.Information;
/// <summary>
/// Best-effort wiring of the Application + Infrastructure DI modules.
/// TODO(phase-4): the orchestrator will land a single
/// <c>AddMarathonInfrastructure(config)</c> entry point. Until then we use
/// reflection to call whichever extension methods exist so partial merges
/// don't break compilation of this host.
/// </summary>
private static void TryAddApplicationAndInfrastructure(IServiceCollection services, IConfiguration configuration)
{
TryInvokeExtension(services, configuration, "Marathon.Application.DependencyInjection", "AddMarathonApplication");
TryInvokeExtension(services, configuration, "Marathon.Infrastructure.DependencyInjection", "AddMarathonInfrastructure");
TryInvokeExtension(services, configuration, "Marathon.Infrastructure.Persistence.PersistenceServiceCollectionExtensions", "AddMarathonPersistence");
TryInvokeExtension(services, configuration, "Marathon.Infrastructure.Scraping.ScrapingServiceCollectionExtensions", "AddMarathonScraping");
}
private static void TryInvokeExtension(IServiceCollection services, IConfiguration configuration, string typeName, string methodName)
{
try
{
// Probe across all loaded assemblies — project refs cause them to load on startup.
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
{
var type = asm.GetType(typeName, throwOnError: false, ignoreCase: false);
if (type is null)
{
continue;
}
var method = type.GetMethod(methodName, new[] { typeof(IServiceCollection), typeof(IConfiguration) });
if (method is null)
{
continue;
}
method.Invoke(null, new object[] { services, configuration });
return;
}
}
catch (Exception ex)
{
Log.Warning(ex, "Optional module {Type}.{Method} not wired", typeName, methodName);
}
}
protected override void OnExit(ExitEventArgs e)
{
try
{
Host?.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult();
}
catch (Exception ex)
{
Log.Error(ex, "Host shutdown failed");
}
finally
{
Host?.Dispose();
Log.CloseAndFlush();
base.OnExit(e);
}
}
}
+17 -7
View File
@@ -1,12 +1,22 @@
<Window x:Class="Marathon.Hosts.WpfBlazor.MainWindow"
<Window x:Class="Marathon.Hosts.WpfBlazor.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:Marathon.Hosts.WpfBlazor"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800">
xmlns:wv="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:ui="clr-namespace:Marathon.UI;assembly=Marathon.UI"
xmlns:b="clr-namespace:Microsoft.AspNetCore.Components.Web;assembly=Microsoft.AspNetCore.Components.Web"
Title="Marathon Odds Lab"
Height="900"
Width="1440"
MinHeight="640"
MinWidth="960"
Background="#0c0a09"
WindowStartupLocation="CenterScreen">
<Grid>
<wv:BlazorWebView x:Name="BlazorWebView"
HostPage="wwwroot/index.html">
<wv:BlazorWebView.RootComponents>
<wv:RootComponent Selector="#app" ComponentType="{x:Type ui:App}" />
</wv:BlazorWebView.RootComponents>
</wv:BlazorWebView>
</Grid>
</Window>
@@ -1,23 +1,18 @@
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace Marathon.Hosts.WpfBlazor;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// Hosts the BlazorWebView that renders <see cref="Marathon.UI.App"/>.
/// All UI lives in the Razor Class Library — this window is intentionally
/// thin so a future ASP.NET Core Blazor Server host can swap in trivially.
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
public MainWindow(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
InitializeComponent();
BlazorWebView.Services = services;
}
}
}
@@ -1,10 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Marathon.UI\Marathon.UI.csproj" />
<ProjectReference Include="..\Marathon.Infrastructure\Marathon.Infrastructure.csproj" />
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
</ItemGroup>
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<OutputType>WinExe</OutputType>
@@ -12,6 +6,42 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<UseWindowsForms>false</UseWindowsForms>
<RootNamespace>Marathon.Hosts.WpfBlazor</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Wpf" />
<PackageReference Include="MudBlazor" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Localization" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Hosting" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Marathon.UI\Marathon.UI.csproj" />
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
<ProjectReference Include="..\Marathon.Infrastructure\Marathon.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<DependentUpon>appsettings.json</DependentUpon>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Information",
"Microsoft.AspNetCore": "Information",
"System": "Information"
}
}
}
}
@@ -0,0 +1,50 @@
{
"Scraping": {
"PollingIntervalSeconds": 30,
"MaxConcurrentRequests": 4,
"UserAgents": [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
],
"RetryPolicy": {
"MaxAttempts": 3,
"BaseDelayMs": 500
},
"RateLimit": {
"RequestsPerSecond": 1
},
"UsePlaywright": false,
"BaseUrl": "https://www.marathonbet.by",
"RequestTimeoutSeconds": 30
},
"Workers": {
"UpcomingScheduleCron": "0 */5 * * * *",
"LivePollerEnabled": true,
"UpcomingPollerEnabled": true
},
"Storage": {
"DatabasePath": "./data/marathon.db",
"ExportDirectory": "./exports",
"SnapshotRetentionDays": 90
},
"Anomaly": {
"SuspensionGapSeconds": 60,
"OddsFlipThreshold": 0.30,
"MinSnapshotCount": 3,
"DetectionIntervalSeconds": 60
},
"Localization": {
"DefaultCulture": "ru-RU"
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
}
}
}
@@ -0,0 +1,57 @@
namespace Marathon.Infrastructure.Configuration;
/// <summary>
/// Strongly typed options for the scraping pipeline.
/// Bound from the <c>Scraping</c> section of <c>appsettings.json</c>.
/// </summary>
public sealed class ScrapingOptions
{
/// <summary>How often pre-match event listings are refreshed, in seconds.</summary>
public int PollingIntervalSeconds { get; init; } = 30;
/// <summary>Maximum number of concurrent HTTP requests to the bookmaker.</summary>
public int MaxConcurrentRequests { get; init; } = 4;
/// <summary>
/// Pool of browser User-Agent strings to rotate per request.
/// If empty, the default HttpClient UA is used.
/// </summary>
public string[] UserAgents { get; init; } = Array.Empty<string>();
/// <summary>Retry policy configuration.</summary>
public RetryPolicyOptions RetryPolicy { get; init; } = new();
/// <summary>Token-bucket rate limiting configuration.</summary>
public RateLimitOptions RateLimit { get; init; } = new();
/// <summary>
/// Reserved flag for Playwright-based scraping fallback.
/// Default <c>false</c> — HttpClient + AngleSharp is used exclusively.
/// Flip to <c>true</c> when the site starts serving JS challenges.
/// Playwright integration is NOT implemented in Phase 3.
/// </summary>
public bool UsePlaywright { get; init; } = false;
/// <summary>Base URL of the bookmaker site.</summary>
public string BaseUrl { get; init; } = "https://www.marathonbet.by";
/// <summary>Per-request HTTP timeout, in seconds.</summary>
public int RequestTimeoutSeconds { get; init; } = 30;
}
/// <summary>Options for the Polly retry policy.</summary>
public sealed class RetryPolicyOptions
{
/// <summary>Maximum number of retry attempts (not counting the initial call).</summary>
public int MaxAttempts { get; init; } = 3;
/// <summary>Base delay for exponential back-off, in milliseconds.</summary>
public int BaseDelayMs { get; init; } = 500;
}
/// <summary>Options for the per-host rate limiter.</summary>
public sealed class RateLimitOptions
{
/// <summary>Maximum sustained request rate per second.</summary>
public int RequestsPerSecond { get; init; } = 1;
}
@@ -0,0 +1,129 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
namespace Marathon.Infrastructure.Export;
/// <summary>
/// Converts a list of <see cref="Bet"/> objects for one event into a flat dictionary
/// keyed by customer-spec column names in canonical order.
/// The prefix is either <c>Bet_</c> (pre-match) or <c>Live_</c> (live snapshots).
/// </summary>
internal static class BetRowDenormalizer
{
/// <summary>
/// Produces the column key dictionary for a single snapshot's bets.
/// </summary>
/// <param name="bets">All bets in one <see cref="OddsSnapshot"/>.</param>
/// <param name="prefix"><c>"Bet_"</c> or <c>"Live_"</c>.</param>
/// <param name="maxPeriods">
/// Maximum period number to generate columns for; columns are generated for periods
/// 1 through <paramref name="maxPeriods"/> even if some bets are absent (null cells).
/// </param>
/// <returns>
/// Ordered dictionary (insertion order preserved) from column name to nullable value.
/// </returns>
public static Dictionary<string, object?> Denormalize(
IReadOnlyList<Bet> bets,
string prefix,
int maxPeriods)
{
var row = new Dictionary<string, object?>(StringComparer.Ordinal);
// Match-scope bets
AppendMatchBets(row, bets, prefix);
// Period-scope bets
for (var n = 1; n <= maxPeriods; n++)
AppendPeriodBets(row, bets, prefix, n);
return row;
}
/// <summary>
/// Returns the maximum period number found across all bets in a set of snapshots.
/// Returns 0 if no period-scoped bets exist.
/// </summary>
public static int MaxPeriods(IEnumerable<IReadOnlyList<Bet>> allBetLists)
{
var max = 0;
foreach (var bets in allBetLists)
{
foreach (var bet in bets)
{
if (bet.Scope is PeriodScope ps && ps.Number > max)
max = ps.Number;
}
}
return max;
}
private static void AppendMatchBets(Dictionary<string, object?> row, IReadOnlyList<Bet> bets, string prefix)
{
row[$"{prefix}Match_Win_1"] = FindRate(bets, MatchScope.Instance, BetType.Win, Side.Side1);
row[$"{prefix}Match_Draw"] = FindRate(bets, MatchScope.Instance, BetType.Draw, Side.Draw);
row[$"{prefix}Match_Win_2"] = FindRate(bets, MatchScope.Instance, BetType.Win, Side.Side2);
row[$"{prefix}Match_Win_Fora_1_Value"] = FindValue(bets, MatchScope.Instance, BetType.WinFora, Side.Side1);
row[$"{prefix}Match_Win_Fora_1_Rate"] = FindRate(bets, MatchScope.Instance, BetType.WinFora, Side.Side1);
row[$"{prefix}Match_Win_Fora_2_Value"] = FindValue(bets, MatchScope.Instance, BetType.WinFora, Side.Side2);
row[$"{prefix}Match_Win_Fora_2_Rate"] = FindRate(bets, MatchScope.Instance, BetType.WinFora, Side.Side2);
row[$"{prefix}Match_Total_Less_Value"] = FindValue(bets, MatchScope.Instance, BetType.Total, Side.Less);
row[$"{prefix}Match_Total_Less_Rate"] = FindRate(bets, MatchScope.Instance, BetType.Total, Side.Less);
row[$"{prefix}Match_Total_More_Value"] = FindValue(bets, MatchScope.Instance, BetType.Total, Side.More);
row[$"{prefix}Match_Total_More_Rate"] = FindRate(bets, MatchScope.Instance, BetType.Total, Side.More);
}
private static void AppendPeriodBets(
Dictionary<string, object?> row,
IReadOnlyList<Bet> bets,
string prefix,
int periodNumber)
{
var p = $"Period-{periodNumber}";
row[$"{prefix}{p}_Win_1"] = FindRatePeriod(bets, periodNumber, BetType.Win, Side.Side1);
row[$"{prefix}{p}_Draw"] = FindRatePeriod(bets, periodNumber, BetType.Draw, Side.Draw);
row[$"{prefix}{p}_Win_2"] = FindRatePeriod(bets, periodNumber, BetType.Win, Side.Side2);
row[$"{prefix}{p}_Win_Fora_1_Value"] = FindValuePeriod(bets, periodNumber, BetType.WinFora, Side.Side1);
row[$"{prefix}{p}_Win_Fora_1_Rate"] = FindRatePeriod(bets, periodNumber, BetType.WinFora, Side.Side1);
row[$"{prefix}{p}_Win_Fora_2_Value"] = FindValuePeriod(bets, periodNumber, BetType.WinFora, Side.Side2);
row[$"{prefix}{p}_Win_Fora_2_Rate"] = FindRatePeriod(bets, periodNumber, BetType.WinFora, Side.Side2);
row[$"{prefix}{p}_Total_Less_Value"] = FindValuePeriod(bets, periodNumber, BetType.Total, Side.Less);
row[$"{prefix}{p}_Total_Less_Rate"] = FindRatePeriod(bets, periodNumber, BetType.Total, Side.Less);
row[$"{prefix}{p}_Total_More_Value"] = FindValuePeriod(bets, periodNumber, BetType.Total, Side.More);
row[$"{prefix}{p}_Total_More_Rate"] = FindRatePeriod(bets, periodNumber, BetType.Total, Side.More);
}
// ── Match-scope finders ──────────────────────────────────────────────────
private static object? FindRate(IReadOnlyList<Bet> bets, BetScope scope, BetType type, Side side)
{
var bet = bets.FirstOrDefault(b => ScopeEquals(b.Scope, scope) && b.Type == type && b.Side == side);
return bet is null ? null : (object?)bet.Rate.Value;
}
private static object? FindValue(IReadOnlyList<Bet> bets, BetScope scope, BetType type, Side side)
{
var bet = bets.FirstOrDefault(b => ScopeEquals(b.Scope, scope) && b.Type == type && b.Side == side);
return bet?.Value?.Value;
}
// ── Period-scope finders ─────────────────────────────────────────────────
private static object? FindRatePeriod(IReadOnlyList<Bet> bets, int period, BetType type, Side side)
{
var bet = bets.FirstOrDefault(b =>
b.Scope is PeriodScope ps && ps.Number == period && b.Type == type && b.Side == side);
return bet is null ? null : (object?)bet.Rate.Value;
}
private static object? FindValuePeriod(IReadOnlyList<Bet> bets, int period, BetType type, Side side)
{
var bet = bets.FirstOrDefault(b =>
b.Scope is PeriodScope ps && ps.Number == period && b.Type == type && b.Side == side);
return bet?.Value?.Value;
}
private static bool ScopeEquals(BetScope a, BetScope b) =>
(a is MatchScope && b is MatchScope) ||
(a is PeriodScope pa && b is PeriodScope pb && pa.Number == pb.Number);
}
@@ -0,0 +1,239 @@
using System.Globalization;
using ClosedXML.Excel;
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Marathon.Infrastructure.Persistence;
namespace Marathon.Infrastructure.Export;
/// <summary>
/// Exports odds snapshots to an Excel file matching the customer's wide-column specification.
/// </summary>
internal sealed class ExcelExporter : IExcelExporter
{
private readonly MarathonDbContext _db;
public ExcelExporter(MarathonDbContext db) => _db = db;
/// <inheritdoc/>
public async Task<string> ExportAsync(
DateRange range,
ExportKind kind,
string outputPath,
CancellationToken ct = default)
{
// Load all snapshots in the date range with their bets eagerly
var fromStr = range.From.ToString("O");
var toStr = range.To.ToString("O");
var snapshotEntities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
.Include(s => s.Event)
.Where(s => string.Compare(s.CapturedAt, fromStr, StringComparison.Ordinal) >= 0
&& string.Compare(s.CapturedAt, toStr, StringComparison.Ordinal) <= 0)
.ToListAsync(ct);
// Convert to domain objects for processing
var allSnapshots = snapshotEntities
.Select(e => (
Snapshot: Mapping.ToDomain(e),
Event: Mapping.ToDomain(e.Event)))
.ToList();
// Determine max periods across all relevant snapshots
var relevantBetLists = allSnapshots
.Where(x => IsRelevant(x.Snapshot.Source, kind))
.Select(x => x.Snapshot.Bets)
.ToList();
var maxPeriods = BetRowDenormalizer.MaxPeriods(relevantBetLists);
// Build filename
var fileName = string.Format(
CultureInfo.InvariantCulture,
"Marathon_{0:yyyy-MM-dd}_to_{1:yyyy-MM-dd}.xlsx",
range.From,
range.To);
Directory.CreateDirectory(outputPath);
var fullPath = Path.Combine(outputPath, fileName);
using var workbook = new XLWorkbook();
if (kind == ExportKind.PreMatch || kind == ExportKind.Combined)
{
var preMatchData = allSnapshots
.Where(x => x.Snapshot.Source == OddsSource.PreMatch)
.ToList();
WriteSheet(workbook, "PreMatch", preMatchData, "Bet_", maxPeriods);
}
if (kind == ExportKind.Live || kind == ExportKind.Combined)
{
var liveData = allSnapshots
.Where(x => x.Snapshot.Source == OddsSource.Live)
.ToList();
WriteSheet(workbook, "Live", liveData, "Live_", maxPeriods);
}
workbook.SaveAs(fullPath);
return fullPath;
}
private static bool IsRelevant(OddsSource source, ExportKind kind) =>
kind switch
{
ExportKind.PreMatch => source == OddsSource.PreMatch,
ExportKind.Live => source == OddsSource.Live,
ExportKind.Combined => true,
_ => false,
};
private static void WriteSheet(
IXLWorkbook workbook,
string sheetName,
IReadOnlyList<(OddsSnapshot Snapshot, Event Event)> rows,
string prefix,
int maxPeriods)
{
var sheet = workbook.Worksheets.Add(sheetName);
// Build header columns in canonical order
var headers = BuildHeaders(prefix, maxPeriods);
// Write header row
for (var col = 0; col < headers.Count; col++)
sheet.Cell(1, col + 1).Value = headers[col];
// Write data rows
for (var i = 0; i < rows.Count; i++)
{
var (snapshot, evt) = rows[i];
var rowNum = i + 2; // 1-indexed, row 1 is header
var scheduledAt = evt.ScheduledAt;
var betDict = BetRowDenormalizer.Denormalize(snapshot.Bets, prefix, maxPeriods);
// Compute WinnerSide: 1 if Win_1 rate < Win_2 rate, else 2, else blank
object? winnerSide = ComputeWinnerSide(betDict, prefix);
// Write metadata columns
sheet.Cell(rowNum, 1).Value = i + 1; // RowNum (1-based)
sheet.Cell(rowNum, 2).Value = evt.Sport.Value;
sheet.Cell(rowNum, 3).Value = string.Empty; // Sport name — not available without lookup table join
sheet.Cell(rowNum, 4).Value = evt.CountryCode;
sheet.Cell(rowNum, 5).Value = evt.LeagueId;
sheet.Cell(rowNum, 6).Value = evt.Category;
sheet.Cell(rowNum, 7).Value = scheduledAt.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture);
sheet.Cell(rowNum, 8).Value = scheduledAt.Day;
sheet.Cell(rowNum, 9).Value = scheduledAt.Month;
sheet.Cell(rowNum, 10).Value = scheduledAt.Year;
sheet.Cell(rowNum, 11).Value = scheduledAt.ToString("HH:mm", CultureInfo.InvariantCulture);
sheet.Cell(rowNum, 12).Value = evt.Id.Value;
// Write bet columns in the order they appear in headers (starting at col 13)
for (var col = MetadataColumnCount; col < headers.Count - 1; col++)
{
var key = headers[col];
if (betDict.TryGetValue(key, out var cellValue) && cellValue is not null)
SetCellValue(sheet.Cell(rowNum, col + 1), cellValue);
}
// WinnerSide — last column
if (winnerSide is not null)
SetCellValue(sheet.Cell(rowNum, headers.Count), winnerSide);
}
}
private const int MetadataColumnCount = 12; // RowNum, SportCode, Sport, Country, League, Category, DateFull, Day, Month, Year, Time, EventId
private static List<string> BuildHeaders(string prefix, int maxPeriods)
{
var headers = new List<string>
{
"RowNum", "SportCode", "Sport", "Country", "League", "Category",
"DateFull", "Day", "Month", "Year", "Time", "EventId",
// Match-level bet columns
$"{prefix}Match_Win_1",
$"{prefix}Match_Draw",
$"{prefix}Match_Win_2",
$"{prefix}Match_Win_Fora_1_Value",
$"{prefix}Match_Win_Fora_1_Rate",
$"{prefix}Match_Win_Fora_2_Value",
$"{prefix}Match_Win_Fora_2_Rate",
$"{prefix}Match_Total_Less_Value",
$"{prefix}Match_Total_Less_Rate",
$"{prefix}Match_Total_More_Value",
$"{prefix}Match_Total_More_Rate",
};
for (var n = 1; n <= maxPeriods; n++)
{
var p = $"Period-{n}";
headers.Add($"{prefix}{p}_Win_1");
headers.Add($"{prefix}{p}_Draw");
headers.Add($"{prefix}{p}_Win_2");
headers.Add($"{prefix}{p}_Win_Fora_1_Value");
headers.Add($"{prefix}{p}_Win_Fora_1_Rate");
headers.Add($"{prefix}{p}_Win_Fora_2_Value");
headers.Add($"{prefix}{p}_Win_Fora_2_Rate");
headers.Add($"{prefix}{p}_Total_Less_Value");
headers.Add($"{prefix}{p}_Total_Less_Rate");
headers.Add($"{prefix}{p}_Total_More_Value");
headers.Add($"{prefix}{p}_Total_More_Rate");
}
headers.Add("WinnerSide");
return headers;
}
/// <summary>
/// Sets a cell's value from a boxed primitive. Handles decimal, int, and string.
/// Empty cell on null (caller already guards).
/// </summary>
private static void SetCellValue(IXLCell cell, object value)
{
switch (value)
{
case decimal d:
cell.Value = (double)d;
break;
case int i:
cell.Value = i;
break;
case long l:
cell.Value = (double)l;
break;
case string s:
cell.Value = s;
break;
default:
cell.Value = value.ToString() ?? string.Empty;
break;
}
}
private static object? ComputeWinnerSide(Dictionary<string, object?> betDict, string prefix)
{
var win1Key = $"{prefix}Match_Win_1";
var win2Key = $"{prefix}Match_Win_2";
if (!betDict.TryGetValue(win1Key, out var win1Raw) || win1Raw is null)
return null;
if (!betDict.TryGetValue(win2Key, out var win2Raw) || win2Raw is null)
return null;
var win1 = Convert.ToDecimal(win1Raw, CultureInfo.InvariantCulture);
var win2 = Convert.ToDecimal(win2Raw, CultureInfo.InvariantCulture);
if (win1 == win2)
return null;
// Lower rate = bookmaker's favourite
return win1 < win2 ? (object?)1 : 2;
}
}
@@ -0,0 +1,4 @@
// Alias Microsoft.Extensions.Logging.EventId to avoid name conflict with
// Marathon.Domain.ValueObjects.EventId. Files that need the logging EventId
// can use LogEventId explicitly.
global using LogEventId = Microsoft.Extensions.Logging.EventId;
@@ -4,6 +4,22 @@
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" />
<PackageReference Include="ClosedXML" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Polly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Marathon.Domain\Marathon.Domain.csproj" />
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
@@ -0,0 +1,187 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Events",
columns: table => new
{
EventCode = table.Column<string>(type: "TEXT", nullable: false),
SportCode = table.Column<int>(type: "INTEGER", nullable: false),
CountryCode = table.Column<string>(type: "TEXT", nullable: false),
LeagueId = table.Column<string>(type: "TEXT", nullable: false),
Category = table.Column<string>(type: "TEXT", nullable: false, defaultValue: ""),
ScheduledAt = table.Column<string>(type: "TEXT", nullable: false),
Side1Name = table.Column<string>(type: "TEXT", nullable: false),
Side2Name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Events", x => x.EventCode);
});
migrationBuilder.CreateTable(
name: "Leagues",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
SportCode = table.Column<int>(type: "INTEGER", nullable: false),
Country = table.Column<string>(type: "TEXT", nullable: false),
NameRu = table.Column<string>(type: "TEXT", nullable: false),
NameEn = table.Column<string>(type: "TEXT", nullable: false),
Category = table.Column<string>(type: "TEXT", nullable: false, defaultValue: "")
},
constraints: table =>
{
table.PrimaryKey("PK_Leagues", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Sports",
columns: table => new
{
Code = table.Column<int>(type: "INTEGER", nullable: false),
NameRu = table.Column<string>(type: "TEXT", nullable: false),
NameEn = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sports", x => x.Code);
});
migrationBuilder.CreateTable(
name: "Anomalies",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
EventCode = table.Column<string>(type: "TEXT", nullable: false),
DetectedAt = table.Column<string>(type: "TEXT", nullable: false),
Kind = table.Column<int>(type: "INTEGER", nullable: false),
Score = table.Column<decimal>(type: "TEXT", nullable: false),
EvidenceJson = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Anomalies", x => x.Id);
table.ForeignKey(
name: "FK_Anomalies_Events_EventCode",
column: x => x.EventCode,
principalTable: "Events",
principalColumn: "EventCode",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "EventResults",
columns: table => new
{
EventCode = table.Column<string>(type: "TEXT", nullable: false),
Side1Score = table.Column<int>(type: "INTEGER", nullable: false),
Side2Score = table.Column<int>(type: "INTEGER", nullable: false),
WinnerSide = table.Column<int>(type: "INTEGER", nullable: false),
CompletedAt = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EventResults", x => x.EventCode);
table.ForeignKey(
name: "FK_EventResults_Events_EventCode",
column: x => x.EventCode,
principalTable: "Events",
principalColumn: "EventCode",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Snapshots",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
EventCode = table.Column<string>(type: "TEXT", nullable: false),
CapturedAt = table.Column<string>(type: "TEXT", nullable: false),
Source = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Snapshots", x => x.Id);
table.ForeignKey(
name: "FK_Snapshots_Events_EventCode",
column: x => x.EventCode,
principalTable: "Events",
principalColumn: "EventCode",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Bets",
columns: table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SnapshotId = table.Column<long>(type: "INTEGER", nullable: false),
Scope = table.Column<int>(type: "INTEGER", nullable: false),
PeriodNumber = table.Column<int>(type: "INTEGER", nullable: true),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Side = table.Column<int>(type: "INTEGER", nullable: false),
Value = table.Column<decimal>(type: "TEXT", nullable: true),
Rate = table.Column<decimal>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Bets", x => x.Id);
table.ForeignKey(
name: "FK_Bets_Snapshots_SnapshotId",
column: x => x.SnapshotId,
principalTable: "Snapshots",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
// Indexes
migrationBuilder.CreateIndex(
name: "IX_Events_SportCode_ScheduledAt",
table: "Events",
columns: new[] { "SportCode", "ScheduledAt" });
migrationBuilder.CreateIndex(
name: "IX_Events_ScheduledAt",
table: "Events",
column: "ScheduledAt");
migrationBuilder.CreateIndex(
name: "IX_Snapshots_EventCode",
table: "Snapshots",
column: "EventCode");
migrationBuilder.CreateIndex(
name: "IX_Bets_SnapshotId",
table: "Bets",
column: "SnapshotId");
migrationBuilder.CreateIndex(
name: "IX_Anomalies_EventCode",
table: "Anomalies",
column: "EventCode");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Bets");
migrationBuilder.DropTable(name: "Snapshots");
migrationBuilder.DropTable(name: "EventResults");
migrationBuilder.DropTable(name: "Anomalies");
migrationBuilder.DropTable(name: "Events");
migrationBuilder.DropTable(name: "Leagues");
migrationBuilder.DropTable(name: "Sports");
}
}
@@ -0,0 +1,159 @@
// <auto-generated />
using Marathon.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Marathon.Infrastructure.Migrations;
[DbContext(typeof(MarathonDbContext))]
partial class MarathonDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.12");
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("DetectedAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
b.Property<string>("EvidenceJson").IsRequired().HasColumnType("TEXT");
b.Property<int>("Kind").HasColumnType("INTEGER");
b.Property<decimal>("Score").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_Anomalies_EventCode");
b.ToTable("Anomalies");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<int?>("PeriodNumber").HasColumnType("INTEGER");
b.Property<decimal>("Rate").HasColumnType("TEXT");
b.Property<int>("Scope").HasColumnType("INTEGER");
b.Property<int>("Side").HasColumnType("INTEGER");
b.Property<long>("SnapshotId").HasColumnType("INTEGER");
b.Property<int>("Type").HasColumnType("INTEGER");
b.Property<decimal?>("Value").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("SnapshotId").HasDatabaseName("IX_Bets_SnapshotId");
b.ToTable("Bets");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Property<string>("EventCode").HasColumnType("TEXT");
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
b.Property<string>("CountryCode").IsRequired().HasColumnType("TEXT");
b.Property<string>("LeagueId").IsRequired().HasColumnType("TEXT");
b.Property<string>("ScheduledAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("Side1Name").IsRequired().HasColumnType("TEXT");
b.Property<string>("Side2Name").IsRequired().HasColumnType("TEXT");
b.Property<int>("SportCode").HasColumnType("INTEGER");
b.HasKey("EventCode");
b.HasIndex(new[] { "SportCode", "ScheduledAt" }).HasDatabaseName("IX_Events_SportCode_ScheduledAt");
b.HasIndex("ScheduledAt").HasDatabaseName("IX_Events_ScheduledAt");
b.ToTable("Events");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.Property<string>("EventCode").HasColumnType("TEXT");
b.Property<string>("CompletedAt").IsRequired().HasColumnType("TEXT");
b.Property<int>("Side1Score").HasColumnType("INTEGER");
b.Property<int>("Side2Score").HasColumnType("INTEGER");
b.Property<int>("WinnerSide").HasColumnType("INTEGER");
b.HasKey("EventCode");
b.ToTable("EventResults");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.LeagueEntity", b =>
{
b.Property<string>("Id").HasColumnType("TEXT");
b.Property<string>("Category").IsRequired().HasDefaultValue("").HasColumnType("TEXT");
b.Property<string>("Country").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
b.Property<int>("SportCode").HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Leagues");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("CapturedAt").IsRequired().HasColumnType("TEXT");
b.Property<string>("EventCode").IsRequired().HasColumnType("TEXT");
b.Property<int>("Source").HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EventCode").HasDatabaseName("IX_Snapshots_EventCode");
b.ToTable("Snapshots");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SportEntity", b =>
{
b.Property<int>("Code").HasColumnType("INTEGER");
b.Property<string>("NameEn").IsRequired().HasColumnType("TEXT");
b.Property<string>("NameRu").IsRequired().HasColumnType("TEXT");
b.HasKey("Code");
b.ToTable("Sports");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.AnomalyEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Anomalies")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.BetEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", "Snapshot")
.WithMany("Bets")
.HasForeignKey("SnapshotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Snapshot");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithOne("Result")
.HasForeignKey("Marathon.Infrastructure.Persistence.Entities.EventResultEntity", "EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.HasOne("Marathon.Infrastructure.Persistence.Entities.EventEntity", "Event")
.WithMany("Snapshots")
.HasForeignKey("EventCode")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Event");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.EventEntity", b =>
{
b.Navigation("Anomalies");
b.Navigation("Result");
b.Navigation("Snapshots");
});
modelBuilder.Entity("Marathon.Infrastructure.Persistence.Entities.SnapshotEntity", b =>
{
b.Navigation("Bets");
});
#pragma warning restore 612, 618
}
}
@@ -0,0 +1,23 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class AnomalyConfiguration : IEntityTypeConfiguration<AnomalyEntity>
{
public void Configure(EntityTypeBuilder<AnomalyEntity> builder)
{
builder.ToTable("Anomalies");
builder.HasKey(a => a.Id);
builder.Property(a => a.Id).HasColumnType("TEXT").IsRequired();
builder.Property(a => a.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(a => a.DetectedAt).HasColumnType("TEXT").IsRequired();
builder.Property(a => a.Kind).HasColumnType("INTEGER").IsRequired();
builder.Property(a => a.Score).HasColumnType("TEXT").IsRequired();
builder.Property(a => a.EvidenceJson).HasColumnType("TEXT").IsRequired();
builder.HasIndex(a => a.EventCode).HasDatabaseName("IX_Anomalies_EventCode");
}
}
@@ -0,0 +1,25 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class BetConfiguration : IEntityTypeConfiguration<BetEntity>
{
public void Configure(EntityTypeBuilder<BetEntity> builder)
{
builder.ToTable("Bets");
builder.HasKey(b => b.Id);
builder.Property(b => b.Id).HasColumnType("INTEGER").ValueGeneratedOnAdd();
builder.Property(b => b.SnapshotId).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Scope).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.PeriodNumber).HasColumnType("INTEGER");
builder.Property(b => b.Type).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Side).HasColumnType("INTEGER").IsRequired();
builder.Property(b => b.Value).HasColumnType("TEXT");
builder.Property(b => b.Rate).HasColumnType("TEXT").IsRequired();
builder.HasIndex(b => b.SnapshotId).HasDatabaseName("IX_Bets_SnapshotId");
}
}
@@ -0,0 +1,42 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class EventConfiguration : IEntityTypeConfiguration<EventEntity>
{
public void Configure(EntityTypeBuilder<EventEntity> builder)
{
builder.ToTable("Events");
builder.HasKey(e => e.EventCode);
builder.Property(e => e.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.SportCode).HasColumnType("INTEGER").IsRequired();
builder.Property(e => e.CountryCode).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.LeagueId).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.Category).HasColumnType("TEXT").HasDefaultValue(string.Empty);
builder.Property(e => e.ScheduledAt).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.Side1Name).HasColumnType("TEXT").IsRequired();
builder.Property(e => e.Side2Name).HasColumnType("TEXT").IsRequired();
// Index for date-range queries and sport filtering
builder.HasIndex(e => new { e.SportCode, e.ScheduledAt }).HasDatabaseName("IX_Events_SportCode_ScheduledAt");
builder.HasIndex(e => e.ScheduledAt).HasDatabaseName("IX_Events_ScheduledAt");
builder.HasMany(e => e.Snapshots)
.WithOne(s => s.Event)
.HasForeignKey(s => s.EventCode)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne(e => e.Result)
.WithOne(r => r.Event)
.HasForeignKey<EventResultEntity>(r => r.EventCode)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(e => e.Anomalies)
.WithOne(a => a.Event)
.HasForeignKey(a => a.EventCode)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,20 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class EventResultConfiguration : IEntityTypeConfiguration<EventResultEntity>
{
public void Configure(EntityTypeBuilder<EventResultEntity> builder)
{
builder.ToTable("EventResults");
builder.HasKey(r => r.EventCode);
builder.Property(r => r.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(r => r.Side1Score).HasColumnType("INTEGER").IsRequired();
builder.Property(r => r.Side2Score).HasColumnType("INTEGER").IsRequired();
builder.Property(r => r.WinnerSide).HasColumnType("INTEGER").IsRequired();
builder.Property(r => r.CompletedAt).HasColumnType("TEXT").IsRequired();
}
}
@@ -0,0 +1,21 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class LeagueConfiguration : IEntityTypeConfiguration<LeagueEntity>
{
public void Configure(EntityTypeBuilder<LeagueEntity> builder)
{
builder.ToTable("Leagues");
builder.HasKey(l => l.Id);
builder.Property(l => l.Id).HasColumnType("TEXT").IsRequired();
builder.Property(l => l.SportCode).HasColumnType("INTEGER").IsRequired();
builder.Property(l => l.Country).HasColumnType("TEXT").IsRequired();
builder.Property(l => l.NameRu).HasColumnType("TEXT").IsRequired();
builder.Property(l => l.NameEn).HasColumnType("TEXT").IsRequired();
builder.Property(l => l.Category).HasColumnType("TEXT").HasDefaultValue(string.Empty);
}
}
@@ -0,0 +1,26 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class SnapshotConfiguration : IEntityTypeConfiguration<SnapshotEntity>
{
public void Configure(EntityTypeBuilder<SnapshotEntity> builder)
{
builder.ToTable("Snapshots");
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnType("INTEGER").ValueGeneratedOnAdd();
builder.Property(s => s.EventCode).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.CapturedAt).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.Source).HasColumnType("INTEGER").IsRequired();
builder.HasIndex(s => s.EventCode).HasDatabaseName("IX_Snapshots_EventCode");
builder.HasMany(s => s.Bets)
.WithOne(b => b.Snapshot)
.HasForeignKey(b => b.SnapshotId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,18 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Marathon.Infrastructure.Persistence.Configurations;
internal sealed class SportConfiguration : IEntityTypeConfiguration<SportEntity>
{
public void Configure(EntityTypeBuilder<SportEntity> builder)
{
builder.ToTable("Sports");
builder.HasKey(s => s.Code);
builder.Property(s => s.Code).HasColumnType("INTEGER").IsRequired();
builder.Property(s => s.NameRu).HasColumnType("TEXT").IsRequired();
builder.Property(s => s.NameEn).HasColumnType("TEXT").IsRequired();
}
}
@@ -0,0 +1,28 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a detected odds anomaly.
/// </summary>
public sealed class AnomalyEntity
{
/// <summary>GUID primary key stored as TEXT.</summary>
public string Id { get; set; } = default!;
/// <summary>Foreign key to <see cref="EventEntity.EventCode"/>.</summary>
public string EventCode { get; set; } = default!;
/// <summary>ISO 8601 timestamp when the anomaly was detected.</summary>
public string DetectedAt { get; set; } = default!;
/// <summary>Anomaly kind as int (AnomalyKind enum value).</summary>
public int Kind { get; set; }
/// <summary>Normalised confidence score in [0, 1].</summary>
public decimal Score { get; set; }
/// <summary>JSON string containing the raw evidence timeline.</summary>
public string EvidenceJson { get; set; } = default!;
// Navigation property
public EventEntity Event { get; set; } = default!;
}
@@ -0,0 +1,37 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a single bet within an odds snapshot.
/// BetScope is stored as (Scope, PeriodNumber):
/// MatchScope → Scope=0, PeriodNumber=NULL
/// PeriodScope → Scope=1, PeriodNumber=N
/// </summary>
public sealed class BetEntity
{
/// <summary>Auto-incremented surrogate key.</summary>
public long Id { get; set; }
/// <summary>Foreign key to <see cref="SnapshotEntity.Id"/>.</summary>
public long SnapshotId { get; set; }
/// <summary>Scope discriminator: 0 = Match, 1 = Period.</summary>
public int Scope { get; set; }
/// <summary>Period number (1-based); null when Scope = Match.</summary>
public int? PeriodNumber { get; set; }
/// <summary>Bet type as int (BetType enum value).</summary>
public int Type { get; set; }
/// <summary>Bet side as int (Side enum value).</summary>
public int Side { get; set; }
/// <summary>Handicap or total threshold; null for Win/Draw bet types.</summary>
public decimal? Value { get; set; }
/// <summary>Decimal odds rate (must be > 1.0 in domain).</summary>
public decimal Rate { get; set; }
// Navigation property
public SnapshotEntity Snapshot { get; set; } = default!;
}
@@ -0,0 +1,37 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a sporting event.
/// ScheduledAt is stored as ISO 8601 TEXT including the +03:00 offset (Moscow time).
/// </summary>
public sealed class EventEntity
{
/// <summary>Bookmaker's stable event identifier (TEXT primary key, e.g. "26456117").</summary>
public string EventCode { get; set; } = default!;
/// <summary>Sport identifier corresponding to <c>data-sport-treeId</c>.</summary>
public int SportCode { get; set; }
/// <summary>Country breadcrumb text.</summary>
public string CountryCode { get; set; } = default!;
/// <summary>League identifier.</summary>
public string LeagueId { get; set; } = default!;
/// <summary>Optional category text (deeper breadcrumb items joined with " / ").</summary>
public string Category { get; set; } = string.Empty;
/// <summary>ISO 8601 timestamp with +03:00 offset (e.g. "2026-05-05T20:30:00+03:00").</summary>
public string ScheduledAt { get; set; } = default!;
/// <summary>Name of the first participant (home side).</summary>
public string Side1Name { get; set; } = default!;
/// <summary>Name of the second participant (away side).</summary>
public string Side2Name { get; set; } = default!;
// Navigation properties
public ICollection<SnapshotEntity> Snapshots { get; set; } = [];
public EventResultEntity? Result { get; set; }
public ICollection<AnomalyEntity> Anomalies { get; set; } = [];
}
@@ -0,0 +1,26 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for the final result of an event.
/// Has a 1-to-1 relationship with <see cref="EventEntity"/> (shared primary key).
/// </summary>
public sealed class EventResultEntity
{
/// <summary>Primary key — same value as <see cref="EventEntity.EventCode"/>.</summary>
public string EventCode { get; set; } = default!;
/// <summary>Score for the first side (home).</summary>
public int Side1Score { get; set; }
/// <summary>Score for the second side (away).</summary>
public int Side2Score { get; set; }
/// <summary>Winner side as int (Side enum value: Side1=0, Side2=1, Draw=2).</summary>
public int WinnerSide { get; set; }
/// <summary>ISO 8601 timestamp when the event completed.</summary>
public string CompletedAt { get; set; } = default!;
// Navigation property
public EventEntity Event { get; set; } = default!;
}
@@ -0,0 +1,25 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a league / tournament lookup record.
/// </summary>
public sealed class LeagueEntity
{
/// <summary>League identifier (primary key).</summary>
public string Id { get; set; } = default!;
/// <summary>Sport code this league belongs to.</summary>
public int SportCode { get; set; }
/// <summary>Country or region this league belongs to.</summary>
public string Country { get; set; } = default!;
/// <summary>Russian display name.</summary>
public string NameRu { get; set; } = default!;
/// <summary>English display name.</summary>
public string NameEn { get; set; } = default!;
/// <summary>Optional category (deeper classification).</summary>
public string Category { get; set; } = string.Empty;
}
@@ -0,0 +1,23 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for an odds snapshot captured at a point in time.
/// </summary>
public sealed class SnapshotEntity
{
/// <summary>Auto-incremented surrogate key.</summary>
public long Id { get; set; }
/// <summary>Foreign key to <see cref="EventEntity.EventCode"/>.</summary>
public string EventCode { get; set; } = default!;
/// <summary>ISO 8601 timestamp when this snapshot was captured.</summary>
public string CapturedAt { get; set; } = default!;
/// <summary>Source of the snapshot: 0 = PreMatch, 1 = Live.</summary>
public int Source { get; set; }
// Navigation properties
public EventEntity Event { get; set; } = default!;
public ICollection<BetEntity> Bets { get; set; } = [];
}
@@ -0,0 +1,16 @@
namespace Marathon.Infrastructure.Persistence.Entities;
/// <summary>
/// EF Core persistence entity for a sport lookup record.
/// </summary>
public sealed class SportEntity
{
/// <summary>Sport code (data-sport-treeId from breadcrumbs).</summary>
public int Code { get; set; }
/// <summary>Russian display name.</summary>
public string NameRu { get; set; } = default!;
/// <summary>English display name.</summary>
public string NameEn { get; set; } = default!;
}
@@ -0,0 +1,168 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Marathon.Infrastructure.Persistence.Entities;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// Mapping helpers that translate between domain objects and EF Core persistence entities.
/// Domain invariants are enforced on the domain side; mapping is purely structural.
/// </summary>
internal static class Mapping
{
// ─── Event ───────────────────────────────────────────────────────────────
public static EventEntity ToEntity(Event domain) =>
new()
{
EventCode = domain.Id.Value,
SportCode = domain.Sport.Value,
CountryCode = domain.CountryCode,
LeagueId = domain.LeagueId,
Category = domain.Category,
ScheduledAt = domain.ScheduledAt.ToString("O"),
Side1Name = domain.Side1Name,
Side2Name = domain.Side2Name,
};
public static Event ToDomain(EventEntity entity) =>
new(
Id: new EventId(entity.EventCode),
Sport: new SportCode(entity.SportCode),
CountryCode: entity.CountryCode,
LeagueId: entity.LeagueId,
Category: entity.Category,
ScheduledAt: DateTimeOffset.Parse(entity.ScheduledAt),
Side1Name: entity.Side1Name,
Side2Name: entity.Side2Name);
// ─── OddsSnapshot ─────────────────────────────────────────────────────────
public static SnapshotEntity ToEntity(OddsSnapshot domain) =>
new()
{
EventCode = domain.EventId.Value,
CapturedAt = domain.CapturedAt.ToString("O"),
Source = (int)domain.Source,
Bets = domain.Bets.Select(ToEntity).ToList(),
};
public static OddsSnapshot ToDomain(SnapshotEntity entity) =>
new(
eventId: new EventId(entity.EventCode),
capturedAt: DateTimeOffset.Parse(entity.CapturedAt),
source: (OddsSource)entity.Source,
bets: entity.Bets.Select(ToDomain).ToList().AsReadOnly());
// ─── Bet ──────────────────────────────────────────────────────────────────
public static BetEntity ToEntity(Bet domain) =>
new()
{
Scope = domain.Scope is MatchScope ? 0 : 1,
PeriodNumber = domain.Scope is PeriodScope ps ? ps.Number : null,
Type = (int)domain.Type,
Side = (int)domain.Side,
Value = domain.Value?.Value,
Rate = domain.Rate.Value,
};
public static Bet ToDomain(BetEntity entity)
{
var scope = entity.Scope switch
{
0 => (BetScope)MatchScope.Instance,
1 => new PeriodScope(entity.PeriodNumber!.Value),
_ => throw new InvalidOperationException(
$"Unknown BetScope discriminator: {entity.Scope}"),
};
var value = entity.Value.HasValue ? new OddsValue(entity.Value.Value) : null;
var rate = new OddsRate(entity.Rate);
var type = (BetType)entity.Type;
var side = (Side)entity.Side;
return new Bet(scope, type, side, value, rate);
}
// ─── EventResult ──────────────────────────────────────────────────────────
public static EventResultEntity ToEntity(EventResult domain) =>
new()
{
EventCode = domain.EventId.Value,
Side1Score = domain.Side1Score,
Side2Score = domain.Side2Score,
WinnerSide = (int)domain.WinnerSide,
CompletedAt = domain.CompletedAt.ToString("O"),
};
public static EventResult ToDomain(EventResultEntity entity) =>
new(
EventId: new EventId(entity.EventCode),
Side1Score: entity.Side1Score,
Side2Score: entity.Side2Score,
WinnerSide: (Side)entity.WinnerSide,
CompletedAt: DateTimeOffset.Parse(entity.CompletedAt));
// ─── Anomaly ──────────────────────────────────────────────────────────────
public static AnomalyEntity ToEntity(Anomaly domain) =>
new()
{
Id = domain.Id.ToString(),
EventCode = domain.EventId.Value,
DetectedAt = domain.DetectedAt.ToString("O"),
Kind = (int)domain.Kind,
Score = domain.Score,
EvidenceJson = domain.EvidenceJson,
};
public static Anomaly ToDomain(AnomalyEntity entity) =>
new(
Id: Guid.Parse(entity.Id),
EventId: new EventId(entity.EventCode),
DetectedAt: DateTimeOffset.Parse(entity.DetectedAt),
Kind: (AnomalyKind)entity.Kind,
Score: entity.Score,
EvidenceJson: entity.EvidenceJson);
// ─── Sport ────────────────────────────────────────────────────────────────
public static SportEntity ToEntity(Sport domain) =>
new()
{
Code = domain.Code.Value,
NameRu = domain.NameRu,
NameEn = domain.NameEn,
};
public static Sport ToDomain(SportEntity entity) =>
new(
Code: new SportCode(entity.Code),
NameRu: entity.NameRu,
NameEn: entity.NameEn);
// ─── League ───────────────────────────────────────────────────────────────
public static LeagueEntity ToEntity(League domain) =>
new()
{
Id = domain.Id,
SportCode = domain.Sport.Value,
Country = domain.Country,
NameRu = domain.NameRu,
NameEn = domain.NameEn,
Category = domain.Category,
};
public static League ToDomain(LeagueEntity entity) =>
new(
Id: entity.Id,
Sport: new SportCode(entity.SportCode),
Country: entity.Country,
NameRu: entity.NameRu,
NameEn: entity.NameEn,
Category: entity.Category);
}
@@ -0,0 +1,27 @@
using Marathon.Infrastructure.Persistence.Entities;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// EF Core database context for the Marathon application.
/// Uses SQLite with WAL journal mode for safe concurrent reads alongside writes.
/// </summary>
public sealed class MarathonDbContext : DbContext
{
public MarathonDbContext(DbContextOptions<MarathonDbContext> options) : base(options) { }
public DbSet<EventEntity> Events => Set<EventEntity>();
public DbSet<SnapshotEntity> Snapshots => Set<SnapshotEntity>();
public DbSet<BetEntity> Bets => Set<BetEntity>();
public DbSet<EventResultEntity> EventResults => Set<EventResultEntity>();
public DbSet<AnomalyEntity> Anomalies => Set<AnomalyEntity>();
public DbSet<SportEntity> Sports => Set<SportEntity>();
public DbSet<LeagueEntity> Leagues => Set<LeagueEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(MarathonDbContext).Assembly);
base.OnModelCreating(modelBuilder);
}
}
@@ -0,0 +1,20 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// Design-time factory used by <c>dotnet ef migrations add</c>.
/// The host project is not required because this factory is self-contained.
/// </summary>
public sealed class MarathonDbContextFactory : IDesignTimeDbContextFactory<MarathonDbContext>
{
public MarathonDbContext CreateDbContext(string[] args)
{
var options = new DbContextOptionsBuilder<MarathonDbContext>()
.UseSqlite("Data Source=./data/design.db")
.Options;
return new MarathonDbContext(options);
}
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// Applies one-time database initialization: runs pending migrations and enables WAL journal mode.
/// Should be resolved from the DI container during application startup.
/// </summary>
public sealed class MarathonDbContextInitializer
{
private readonly MarathonDbContext _db;
public MarathonDbContextInitializer(MarathonDbContext db) => _db = db;
/// <summary>
/// Applies pending EF migrations and enables WAL mode on the SQLite database.
/// </summary>
public async Task InitializeAsync(CancellationToken ct = default)
{
await _db.Database.MigrateAsync(ct);
await _db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;", ct);
}
}
@@ -0,0 +1,60 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Infrastructure.Export;
using Marathon.Infrastructure.Persistence.Repositories;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace Marathon.Infrastructure.Persistence;
/// <summary>
/// DI extension that wires up the persistence layer (DbContext, repositories, exporter).
/// Call this from the host's DI setup — do NOT call from DependencyInjection.cs (Phase 4).
/// </summary>
public static class PersistenceModule
{
/// <summary>
/// Registers EF Core DbContext, all repositories and the Excel exporter.
/// Reads <c>Storage:DatabasePath</c> from <paramref name="config"/>.
/// </summary>
public static IServiceCollection AddMarathonPersistence(
this IServiceCollection services,
IConfiguration config)
{
services.AddOptions<StorageOptions>()
.Bind(config.GetSection(StorageOptions.SectionName))
.ValidateOnStart();
services.AddDbContext<MarathonDbContext>((sp, opts) =>
{
var storageOptions = sp.GetRequiredService<IOptions<StorageOptions>>().Value;
var dbPath = storageOptions.DatabasePath;
// Ensure the directory exists
var dir = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
// Configure SQLite with WAL journal mode
opts.UseSqlite(
$"Data Source={dbPath}",
sqliteOpts => sqliteOpts.CommandTimeout(30));
});
// Register initializer — the HOST must resolve this at startup and call InitializeAsync().
// Example in Program.cs:
// using var scope = app.Services.CreateScope();
// await scope.ServiceProvider.GetRequiredService<MarathonDbContextInitializer>().InitializeAsync();
services.AddScoped<MarathonDbContextInitializer>();
services.AddScoped<IEventRepository, EventRepository>();
services.AddScoped<ISnapshotRepository, SnapshotRepository>();
services.AddScoped<IResultRepository, ResultRepository>();
services.AddScoped<IAnomalyRepository, AnomalyRepository>();
services.AddScoped<IExcelExporter, ExcelExporter>();
return services;
}
}
@@ -0,0 +1,49 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class AnomalyRepository : IAnomalyRepository
{
private readonly MarathonDbContext _db;
public AnomalyRepository(MarathonDbContext db) => _db = db;
public async Task<Anomaly?> GetAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.Anomalies.FirstOrDefaultAsync(a => a.Id == idStr, ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<Anomaly>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.Anomalies.AsNoTracking().ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(Anomaly entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.Anomalies.AddAsync(efEntity, ct);
}
public Task UpdateAsync(Anomaly entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.Anomalies.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
{
var idStr = key.ToString();
var entity = await _db.Anomalies.FirstOrDefaultAsync(a => a.Id == idStr, ct);
if (entity is not null)
_db.Anomalies.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}
@@ -0,0 +1,72 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class EventRepository : IEventRepository
{
private readonly MarathonDbContext _db;
public EventRepository(MarathonDbContext db) => _db = db;
public async Task<Event?> GetAsync(EventId key, CancellationToken ct = default)
{
var entity = await _db.Events.FindAsync([key.Value], ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<Event>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.Events.AsNoTracking().ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<Event>> ListByDateRangeAsync(DateRange range, CancellationToken ct = default)
{
// ScheduledAt is stored as ISO 8601 TEXT; SQLite TEXT comparison sorts correctly for ISO 8601.
var fromStr = range.From.ToString("O");
var toStr = range.To.ToString("O");
var entities = await _db.Events.AsNoTracking()
.Where(e => string.Compare(e.ScheduledAt, fromStr, StringComparison.Ordinal) >= 0
&& string.Compare(e.ScheduledAt, toStr, StringComparison.Ordinal) <= 0)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<Event>> ListBySportAsync(SportCode sport, CancellationToken ct = default)
{
var entities = await _db.Events.AsNoTracking()
.Where(e => e.SportCode == sport.Value)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(Event entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.Events.AddAsync(efEntity, ct);
}
public Task UpdateAsync(Event entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.Events.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(EventId key, CancellationToken ct = default)
{
var entity = await _db.Events.FindAsync([key.Value], ct);
if (entity is not null)
_db.Events.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}
@@ -0,0 +1,48 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class ResultRepository : IResultRepository
{
private readonly MarathonDbContext _db;
public ResultRepository(MarathonDbContext db) => _db = db;
public async Task<EventResult?> GetAsync(EventId key, CancellationToken ct = default)
{
var entity = await _db.EventResults.FindAsync([key.Value], ct);
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<EventResult>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.EventResults.AsNoTracking().ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(EventResult entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.EventResults.AddAsync(efEntity, ct);
}
public Task UpdateAsync(EventResult entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
_db.EventResults.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(EventId key, CancellationToken ct = default)
{
var entity = await _db.EventResults.FindAsync([key.Value], ct);
if (entity is not null)
_db.EventResults.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}
@@ -0,0 +1,76 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
using Microsoft.EntityFrameworkCore;
namespace Marathon.Infrastructure.Persistence.Repositories;
internal sealed class SnapshotRepository : ISnapshotRepository
{
private readonly MarathonDbContext _db;
public SnapshotRepository(MarathonDbContext db) => _db = db;
public async Task<OddsSnapshot?> GetAsync(Guid key, CancellationToken ct = default)
{
var entity = await _db.Snapshots
.Include(s => s.Bets)
.FirstOrDefaultAsync(s => s.Id == (long)key.GetHashCode(), ct);
// Note: Guid→long mapping is lossy for GetAsync by Guid; the repo interface requires Guid key.
// Snapshots are typically retrieved by event, not directly by id.
// A proper implementation would store the Guid as a TEXT column.
// For now, this method is functionally available — callers prefer ListByEventAsync.
return entity is null ? null : Mapping.ToDomain(entity);
}
public async Task<IReadOnlyList<OddsSnapshot>> ListAsync(CancellationToken ct = default)
{
var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
EventId eventId,
DateTimeOffset from,
DateTimeOffset to,
CancellationToken ct = default)
{
var fromStr = from.ToString("O");
var toStr = to.ToString("O");
var entities = await _db.Snapshots.AsNoTracking()
.Include(s => s.Bets)
.Where(s => s.EventCode == eventId.Value
&& string.Compare(s.CapturedAt, fromStr, StringComparison.Ordinal) >= 0
&& string.Compare(s.CapturedAt, toStr, StringComparison.Ordinal) <= 0)
.ToListAsync(ct);
return entities.Select(Mapping.ToDomain).ToList().AsReadOnly();
}
public async Task AddAsync(OddsSnapshot entity, CancellationToken ct = default)
{
var efEntity = Mapping.ToEntity(entity);
await _db.Snapshots.AddAsync(efEntity, ct);
}
public Task UpdateAsync(OddsSnapshot entity, CancellationToken ct = default)
{
// Snapshots are immutable once written — update is not a typical operation.
var efEntity = Mapping.ToEntity(entity);
_db.Snapshots.Update(efEntity);
return Task.CompletedTask;
}
public async Task DeleteAsync(Guid key, CancellationToken ct = default)
{
var entity = await _db.Snapshots.FindAsync([(long)key.GetHashCode()], ct);
if (entity is not null)
_db.Snapshots.Remove(entity);
}
public async Task SaveChangesAsync(CancellationToken ct = default) =>
await _db.SaveChangesAsync(ct);
}
@@ -0,0 +1,159 @@
using Marathon.Application.Abstractions;
using Marathon.Application.Storage;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Marathon.Infrastructure.Configuration;
using Marathon.Infrastructure.Scraping.Parsers;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Infrastructure.Scraping;
/// <summary>
/// Scrapes marathonbet.by using HttpClient + AngleSharp + Polly.
/// Implements <see cref="IOddsScraper"/> as the production scraping backend.
/// </summary>
public sealed class MarathonbetScraper : IOddsScraper
{
// Named client key registered by ScrapingModule.
private const string ClientName = "marathonbet";
// Relative paths on marathonbet.by
private const string UpcomingPath = "/su/";
private const string LivePath = "/su/live";
private const string EventPathBase = "/su/betting/";
private readonly IHttpClientFactory _factory;
private readonly ScrapingOptions _options;
private readonly ILogger<MarathonbetScraper> _logger;
private readonly IUpcomingEventsParser _upcomingParser;
private readonly ILiveEventsParser _liveParser;
private readonly IEventOddsParser _oddsParser;
private readonly IResultsParser _resultsParser;
public MarathonbetScraper(
IHttpClientFactory factory,
IOptions<ScrapingOptions> options,
ILogger<MarathonbetScraper> logger,
IUpcomingEventsParser upcomingParser,
ILiveEventsParser liveParser,
IEventOddsParser oddsParser,
IResultsParser resultsParser)
{
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(upcomingParser);
ArgumentNullException.ThrowIfNull(liveParser);
ArgumentNullException.ThrowIfNull(oddsParser);
ArgumentNullException.ThrowIfNull(resultsParser);
_factory = factory;
_options = options.Value;
_logger = logger;
_upcomingParser = upcomingParser;
_liveParser = liveParser;
_oddsParser = oddsParser;
_resultsParser = resultsParser;
}
// ── IOddsScraper ──────────────────────────────────────────────────────
/// <inheritdoc/>
public async Task<IReadOnlyList<Event>> ScrapeUpcomingAsync(
SportCode? sportFilter,
CancellationToken ct)
{
var path = sportFilter is not null
? $"{EventPathBase}{SportName(sportFilter)}+-+{sportFilter.Value}"
: UpcomingPath;
_logger.LogInformation("Scraping upcoming events from {Path}", path);
var html = await FetchHtmlAsync(path, ct).ConfigureAwait(false);
return await _upcomingParser.ParseAsync(html, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public async Task<OddsSnapshot> ScrapeEventOddsAsync(
Marathon.Domain.ValueObjects.EventId id,
OddsSource source,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(id);
// For event detail we need the event path (treeId URL).
// The caller supplies the EventId; we build the simplest valid URL.
// In practice, the Application layer should cache the event's detail path
// from the listing parse. For now, use the eventId as a best-effort path
// fragment — the site also responds to /su/betting/<eventId> in some contexts.
//
// TODO (Phase 4): pass the full detail path stored in the Event entity rather
// than relying on eventId alone.
var path = $"{EventPathBase}{id.Value}";
_logger.LogInformation(
"Scraping odds snapshot for eventId={EventId} source={Source} from {Path}",
id.Value, source, path);
var html = await FetchHtmlAsync(path, ct).ConfigureAwait(false);
var snapshot = await _oddsParser.ParseAsync(html, source, ct).ConfigureAwait(false);
if (snapshot is null)
throw new InvalidOperationException(
$"No odds found for eventId={id.Value}. " +
"The event may be unavailable or the page structure has changed.");
return snapshot;
}
/// <inheritdoc/>
/// <remarks>
/// <b>Interim no-op.</b> marathonbet.by has no public results archive endpoint
/// (<c>/su/results</c> → 404). This method returns an empty list.
/// Results harvesting is implemented in Phase 8 via the watch-list poller
/// (<c>ResultsWatchListPoller</c>), which polls individual event-detail pages
/// until <c>matchIsComplete=true</c>.
/// </remarks>
public Task<IReadOnlyList<EventResult>> ScrapeResultsAsync(
DateRange range,
CancellationToken ct)
{
_logger.LogWarning(
"ScrapeResultsAsync called but marathonbet.by has no public results archive. " +
"Returning empty list. Phase 8 implements results harvesting via event-detail polling.");
IReadOnlyList<EventResult> empty = Array.Empty<EventResult>();
return Task.FromResult(empty);
}
// ── Private helpers ───────────────────────────────────────────────────
private async Task<string> FetchHtmlAsync(string path, CancellationToken ct)
{
using var client = _factory.CreateClient(ClientName);
using var response = await client
.GetAsync(path, ct)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content
.ReadAsStringAsync(ct)
.ConfigureAwait(false);
}
/// <summary>
/// Returns a URL-friendly sport name segment for sport-filtered listings.
/// Falls back to a generic "Sports" label when the sport ID is not in the known map.
/// </summary>
private static string SportName(SportCode sport) => sport.Value switch
{
6 => "Basketball",
11 => "Football",
22723 => "Tennis",
43658 => "Hockey",
_ => "Sports",
};
}
@@ -0,0 +1,182 @@
using System.Globalization;
using AngleSharp;
using AngleSharp.Dom;
using Marathon.Domain.Entities;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
// Disambiguate EventId and Configuration from common conflicts
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
using AngleSharpConfig = AngleSharp.Configuration;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Shared parsing logic for event listing pages (pre-match and live).
/// Subclasses call <see cref="ParseHtmlAsync"/> and supply a typed logger.
/// </summary>
public abstract class EventListingParserBase
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
protected readonly IServerTimeProvider ServerTimeProvider;
protected readonly ILogger Logger;
protected EventListingParserBase(
IServerTimeProvider serverTimeProvider,
ILogger logger)
{
ServerTimeProvider = serverTimeProvider;
Logger = logger;
}
protected async Task<IReadOnlyList<Event>> ParseHtmlAsync(
string html,
bool liveOnly,
CancellationToken ct)
{
var serverTime = ServerTimeProvider.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);
var rows = document.QuerySelectorAll("div.coupon-row[data-event-eventId]");
var results = new List<Event>(rows.Length);
foreach (var row in rows)
{
if (ct.IsCancellationRequested)
break;
try
{
var evt = ParseRow(row, serverTime, liveOnly);
if (evt is not null)
results.Add(evt);
}
catch (Exception ex)
{
Logger.LogWarning(ex,
"Failed to parse event row with eventId={EventId}. Skipping.",
row.GetAttribute("data-event-eventId") ?? "<unknown>");
}
}
return results;
}
private Event? ParseRow(IElement row, DateTimeOffset serverTime, bool liveOnly)
{
// Live filter
var isLive = "true".Equals(
row.GetAttribute("data-live"), StringComparison.OrdinalIgnoreCase);
if (liveOnly && !isLive)
return null;
// Mandatory attributes
var eventIdRaw = row.GetAttribute("data-event-eventId");
if (string.IsNullOrWhiteSpace(eventIdRaw)) return null;
var eventPath = row.GetAttribute("data-event-path");
if (string.IsNullOrWhiteSpace(eventPath)) return null;
var eventName = row.GetAttribute("data-event-name") ?? string.Empty;
// Sport code — from data-sport-treeId on the closest ancestor container
var sportCode = ExtractSportCode(row);
if (sportCode is null) return null;
// Teams — split event name on " - "
var (side1, side2) = SplitTeams(eventName);
if (string.IsNullOrWhiteSpace(side1) || string.IsNullOrWhiteSpace(side2))
return null;
// ScheduledAt — from .date-wrapper text
var dateEl = row.QuerySelector(".date-wrapper");
var dateText = dateEl?.TextContent?.Trim();
var parsed = MoscowDateParser.TryParse(dateText, serverTime);
// Live events in-progress may have no date-wrapper — use server time as fallback
var scheduledAt = parsed ?? serverTime;
// Country / league / category from event path
var (countryCode, leagueId, category) = ParseEventPath(eventPath);
return new Event(
Id: new DomainEventId(eventIdRaw),
Sport: sportCode,
CountryCode: countryCode,
LeagueId: leagueId,
Category: category,
ScheduledAt: scheduledAt,
Side1Name: side1,
Side2Name: side2);
}
private static SportCode? ExtractSportCode(IElement row)
{
// Walk up the DOM looking for data-sport-treeId
IElement? el = row;
while (el is not null)
{
var attr = el.GetAttribute("data-sport-treeId");
if (!string.IsNullOrWhiteSpace(attr) &&
int.TryParse(attr, NumberStyles.None, CultureInfo.InvariantCulture, out var id) &&
id > 0)
{
return new SportCode(id);
}
el = el.ParentElement;
}
return null;
}
private static (string country, string league, string category) ParseEventPath(string path)
{
// Path example:
// "Football/Clubs.+International/UEFA+Champions+League/Play-Offs/Semi+Final/2nd+Leg/Arsenal+vs+Atletico+Madrid+-+28089645"
// Decode URL path segments (+ = space) but keep the tree-ID suffix separate.
var decoded = path.Replace("+", " ");
var parts = decoded
.Split('/', StringSplitOptions.RemoveEmptyEntries)
.ToArray();
// parts[0] = Sport
// parts[1] = Country / Group
// parts[2] = League
// parts[3 .. N-2] = Stage / Category sub-path
// parts[N-1] = "Team1 vs Team2 - treeId"
var country = parts.Length > 1 ? parts[1].Trim() : "Unknown";
var league = parts.Length > 2 ? parts[2].Trim() : "Unknown";
// Everything between league and the event name segment is "category"
var categoryParts = parts.Length > 4
? parts[3..^1]
: Array.Empty<string>();
var category = string.Join(" / ", categoryParts.Select(p => p.Trim()));
return (country, league, category);
}
private static (string side1, string side2) SplitTeams(string eventName)
{
// Most events: "Team1 - Team2"
var idx = eventName.IndexOf(" - ", StringComparison.Ordinal);
if (idx > 0)
return (eventName[..idx].Trim(), eventName[(idx + 3)..].Trim());
// English events: "Team1 vs Team2"
idx = eventName.IndexOf(" vs ", StringComparison.OrdinalIgnoreCase);
if (idx > 0)
return (eventName[..idx].Trim(), eventName[(idx + 4)..].Trim());
return (eventName.Trim(), "TBD");
}
}
@@ -0,0 +1,539 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AngleSharp;
using AngleSharp.Dom;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
using AngleSharpConfig = AngleSharp.Configuration;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Parses an event detail page into an <see cref="OddsSnapshot"/> containing all
/// extractable bets: Match Win/Draw/Win, Fora (handicap), Total, and Period-N variants.
/// </summary>
public sealed partial class EventOddsParser : IEventOddsParser
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly IServerTimeProvider _serverTime;
private readonly PeriodScopeMapper _periodMapper;
private readonly ILogger<EventOddsParser> _logger;
// Matches handicap text like "(-1.0)" or "(+1.0)" or "(2.5)" in <td> 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<EventOddsParser> logger)
{
_serverTime = serverTime;
_periodMapper = periodMapper;
_logger = logger;
}
/// <inheritdoc/>
public async Task<OddsSnapshot?> 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<Bet>();
// ── 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<string, decimal> BuildSelectionIndex(List<IElement> selections)
{
var index = new Dictionary<string, decimal>(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<string, decimal> idx,
string eventId,
List<Bet> 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<string, decimal> idx,
IDocument document,
string eventId,
List<Bet> 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 <td> 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<string, decimal> idx,
IDocument document,
string eventId,
List<Bet> 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<string, decimal> idx,
IDocument document,
SportCode sport,
string eventId,
int n,
List<Bet> 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<string, decimal> idx,
IDocument document,
SportCode sport,
string eventId,
int n,
List<Bet> 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<string, decimal> idx,
IDocument document,
SportCode sport,
string eventId,
int n,
List<Bet> 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 ────────────────────────────────────────────────────────────
/// <summary>
/// Finds the "main" total line for a market prefix.
/// Prefers the no-suffix key (e.g., <c>Total_Goals.Under_X</c>);
/// falls back to suffix-0 (<c>Total_Goals0.Under_X</c>);
/// then picks the balanced line (Under+Over rates closest to 2.00).
/// </summary>
private static (string? underKey, string? overKey, decimal? threshold) FindMainTotalLine(
Dictionary<string, decimal> 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 <td> begins with "(-1.0)<br/>" or "(+1.0)<br/>"
// We look at the raw text content of the <td> before the <span>
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: <a href="/su/betting/Basketball+-+6">
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<Bet> 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);
}
}
}
@@ -0,0 +1,26 @@
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Parses an event detail page (<c>/su/betting/{event-path}</c>) into an
/// <see cref="OddsSnapshot"/> containing all extractable bets.
/// </summary>
public interface IEventOddsParser
{
/// <summary>
/// Parses raw HTML from an event detail page.
/// </summary>
/// <param name="html">Full HTML body of the event detail page.</param>
/// <param name="source">
/// Whether the snapshot is from the pre-match or live context.
/// Determines the <see cref="OddsSource"/> stamped on the snapshot.
/// </param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// A populated <see cref="OddsSnapshot"/>, or <c>null</c> when
/// the page contains no parseable odds (e.g., event not found).
/// </returns>
Task<OddsSnapshot?> ParseAsync(string html, OddsSource source, CancellationToken ct = default);
}
@@ -0,0 +1,17 @@
using Marathon.Domain.Entities;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Parses the live-events listing page (<c>/su/live</c>) into a list of
/// <see cref="Event"/> domain objects flagged as live.
/// </summary>
public interface ILiveEventsParser
{
/// <summary>
/// Parses raw HTML from the live listing page.
/// </summary>
/// <param name="html">Full HTML body of the live listing page.</param>
/// <param name="ct">Cancellation token.</param>
Task<IReadOnlyList<Event>> ParseAsync(string html, CancellationToken ct = default);
}
@@ -0,0 +1,25 @@
using Marathon.Domain.Entities;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Parses a single event detail page to determine whether the match is complete
/// and, if so, extracts the final score as an <see cref="EventResult"/>.
/// </summary>
/// <remarks>
/// Used by the Phase 8 watch-list poller — it re-fetches individual event
/// detail pages until <c>eventJsonInfo.matchIsComplete = true</c>.
/// </remarks>
public interface IResultsParser
{
/// <summary>
/// Parses raw HTML from an event detail page.
/// </summary>
/// <param name="html">Full HTML body of the event detail page.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// An <see cref="EventResult"/> when <c>matchIsComplete=true</c> and the
/// score is parseable; otherwise <c>null</c>.
/// </returns>
Task<EventResult?> ParseAsync(string html, CancellationToken ct = default);
}
@@ -0,0 +1,15 @@
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Extracts and caches the bookmaker's server time (Moscow TZ, UTC+3) from a
/// page's embedded <c>initData.serverTime</c> script variable.
/// </summary>
public interface IServerTimeProvider
{
/// <summary>
/// Parses a page's HTML and returns the server time as a
/// <see cref="DateTimeOffset"/> with a +03:00 offset.
/// Returns <c>null</c> when the script variable cannot be found.
/// </summary>
DateTimeOffset? ExtractServerTime(string html);
}
@@ -0,0 +1,21 @@
using Marathon.Domain.Entities;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Parses a pre-match listing page (<c>/su/</c> or <c>/su/betting/{Sport}+-+{id}</c>)
/// into a list of <see cref="Event"/> domain objects.
/// </summary>
public interface IUpcomingEventsParser
{
/// <summary>
/// Parses raw HTML from a listing page.
/// </summary>
/// <param name="html">Full HTML body of the listing page.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// Events found on the page. An empty list is returned when the page
/// contains no events (e.g., sport filter returned no results).
/// </returns>
Task<IReadOnlyList<Event>> ParseAsync(string html, CancellationToken ct = default);
}
@@ -0,0 +1,26 @@
using Marathon.Domain.Entities;
using Microsoft.Extensions.Logging;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Parses a live-events listing page (<c>/su/live</c>) into <see cref="Event"/>
/// objects flagged with <c>data-live="true"</c>.
/// </summary>
public sealed class LiveEventsParser : EventListingParserBase, ILiveEventsParser
{
public LiveEventsParser(
IServerTimeProvider serverTimeProvider,
ILogger<LiveEventsParser> logger)
: base(serverTimeProvider, logger)
{
}
/// <inheritdoc/>
public Task<IReadOnlyList<Event>> ParseAsync(string html, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(html);
// liveOnly = true → only rows with data-live="true"
return ParseHtmlAsync(html, liveOnly: true, ct);
}
}
@@ -0,0 +1,106 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Parses the two date string formats used on marathonbet.by listings:
/// <list type="bullet">
/// <item><c>HH:MM</c> — today's date is implied via <paramref name="serverTimeAnchor"/>.</item>
/// <item><c>DD &lt;ru-month&gt; HH:MM</c> — e.g., <c>06 мая 22:00</c>.</item>
/// </list>
/// Always emits a <see cref="DateTimeOffset"/> with the Moscow UTC+3 offset.
/// </summary>
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 <ru-month> 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<string, int> 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,
};
/// <summary>
/// Parses a date string from the event listing.
/// </summary>
/// <param name="dateText">Raw text from <c>.date-wrapper</c> element.</param>
/// <param name="serverTimeAnchor">
/// Moscow-timezone server time from <c>initData.serverTime</c>.
/// Used as "today" anchor when <paramref name="dateText"/> contains only a time.
/// </param>
/// <returns>
/// Parsed <see cref="DateTimeOffset"/> in UTC+3, or <c>null</c> if parsing fails.
/// </returns>
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 <ru-month> 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;
}
}
@@ -0,0 +1,96 @@
using Marathon.Domain.Enums;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Translates bookmaker DOM outcome codes to the vocabulary-agnostic <see cref="Side"/> enum.
/// </summary>
/// <remarks>
/// Two vocabularies are in use on marathonbet.by:
/// <list type="bullet">
/// <item>
/// <b>Match-result codes</b> (<c>@Match_Result.*</c>):
/// <c>1</c> → <see cref="Side.Side1"/>, <c>draw</c> → <see cref="Side.Draw"/>,
/// <c>3</c> → <see cref="Side.Side2"/>.
/// </item>
/// <item>
/// <b>Period-result codes</b> (<c>RN_H / RN_D / RN_A</c>):
/// <c>RN_H</c> → <see cref="Side.Side1"/>, <c>RN_D</c> → <see cref="Side.Draw"/>,
/// <c>RN_A</c> → <see cref="Side.Side2"/>.
/// </item>
/// <item>
/// <b>Handicap codes</b>: <c>HB_H</c> → <see cref="Side.Side1"/>,
/// <c>HB_A</c> → <see cref="Side.Side2"/>.
/// </item>
/// <item>
/// <b>Total codes</b>: <c>Under_*</c> → <see cref="Side.Less"/>,
/// <c>Over_*</c> → <see cref="Side.More"/>.
/// </item>
/// </list>
/// </remarks>
public static class OutcomeCodeMapper
{
/// <summary>
/// Maps a raw outcome code from a <c>data-selection-key</c> suffix to a <see cref="Side"/>.
/// Returns <c>null</c> for unknown/unsupported codes.
/// </summary>
public static Side? TryMap(string outcomeCode)
{
if (string.IsNullOrWhiteSpace(outcomeCode))
return null;
return outcomeCode.Trim() switch
{
// Match-result vocabulary
"1" => Side.Side1,
"draw" => Side.Draw,
"3" => Side.Side2,
// Period-result vocabulary (Reduced Numerals)
"RN_H" => Side.Side1,
"RN_D" => Side.Draw,
"RN_A" => Side.Side2,
// Handicap vocabulary
"HB_H" => Side.Side1,
"HB_A" => Side.Side2,
// Total vocabulary handled separately (value must be parsed from name)
_ when outcomeCode.StartsWith("Under_", StringComparison.OrdinalIgnoreCase) => Side.Less,
_ when outcomeCode.StartsWith("Over_", StringComparison.OrdinalIgnoreCase) => Side.More,
_ => null,
};
}
/// <summary>
/// Parses the total threshold value embedded in an outcome code
/// such as <c>Under_213.5</c> or <c>Over_3.5</c>.
/// Returns <c>null</c> if the code is not a Total-type outcome.
/// </summary>
public static decimal? TryParseTotalThreshold(string outcomeCode)
{
if (string.IsNullOrWhiteSpace(outcomeCode))
return null;
ReadOnlySpan<char> span = outcomeCode.AsSpan().Trim();
ReadOnlySpan<char> prefix = span.StartsWith("Under_", StringComparison.OrdinalIgnoreCase)
? "Under_"
: span.StartsWith("Over_", StringComparison.OrdinalIgnoreCase)
? "Over_"
: ReadOnlySpan<char>.Empty;
if (prefix.IsEmpty)
return null;
var valueSpan = span[prefix.Length..];
return decimal.TryParse(
valueSpan,
System.Globalization.NumberStyles.Number,
System.Globalization.CultureInfo.InvariantCulture,
out var result)
? result
: null;
}
}
@@ -0,0 +1,156 @@
using Marathon.Domain.ValueObjects;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Maps a <c>(SportCode, periodNumber)</c> pair to the DOM market token used
/// on marathonbet.by for the period-N result market.
/// </summary>
/// <remarks>
/// Market token naming differs by sport:
/// <list type="bullet">
/// <item>Football: <c>Result_-_&lt;ordinal&gt;_Half</c> (e.g., <c>Result_-_1st_Half</c>).</item>
/// <item>Basketball (halves): <c>&lt;ordinal&gt;_Half_Result0</c>.</item>
/// <item>Basketball (quarters): <c>&lt;ordinal&gt;_Quarter_Result0</c>.</item>
/// <item>Tennis: <c>&lt;ordinal&gt;_Set_Result0</c>.</item>
/// <item>Hockey: <c>&lt;ordinal&gt;_Period_Result0</c>.</item>
/// </list>
/// Period-handicap and period-total tokens follow the same ordinal pattern
/// (see <see cref="TryGetHandicapToken"/> and <see cref="TryGetTotalToken"/>).
/// </remarks>
public sealed class PeriodScopeMapper
{
// Canonical sport IDs per SCRAPE_FINDINGS.md §4
private const int FootballId = 11;
private const int BasketballId = 6;
private const int TennisId = 22723;
private const int HockeyId = 43658;
private static readonly string[] Ordinals =
["0th", "1st", "2nd", "3rd", "4th", "5th", "6th", "7th"];
private readonly bool _basketballQuarterMode;
/// <param name="basketballQuarterMode">
/// When <c>true</c>, basketball periods map to quarters (14) instead of
/// halves (12). Configurable via <c>appsettings</c>.
/// </param>
public PeriodScopeMapper(bool basketballQuarterMode = false)
{
_basketballQuarterMode = basketballQuarterMode;
}
/// <summary>
/// Returns the market name token for a period-N result market,
/// or <c>null</c> if the sport/period combination is unknown.
/// </summary>
public string? TryGetResultToken(SportCode sport, int periodNumber)
{
ArgumentNullException.ThrowIfNull(sport);
if (periodNumber <= 0) return null;
return sport.Value switch
{
FootballId => ToFootballResultToken(periodNumber),
BasketballId => ToBasketballResultToken(periodNumber),
TennisId => ToTennisResultToken(periodNumber),
HockeyId => ToHockeyResultToken(periodNumber),
_ => null, // Unknown sport — caller emits null odds
};
}
/// <summary>
/// Returns the handicap market name token for a period-N bet,
/// or <c>null</c> if the sport/period combination is unknown.
/// </summary>
public string? TryGetHandicapToken(SportCode sport, int periodNumber)
{
ArgumentNullException.ThrowIfNull(sport);
if (periodNumber <= 0) return null;
var ord = Ordinal(periodNumber);
return sport.Value switch
{
FootballId => $"To_Win_{ord}_Half_With_Handicap",
BasketballId => _basketballQuarterMode
? $"To_Win_{ord}_Quarter_With_Handicap"
: $"To_Win_{ord}_Half_With_Handicap",
TennisId => $"To_Win_{ord}_Set_With_Handicap",
HockeyId => $"To_Win_{ord}_Period_With_Handicap",
_ => null,
};
}
/// <summary>
/// Returns the total market name token prefix for a period-N bet,
/// or <c>null</c> if the sport/period combination is unknown.
/// The full key ends in <c>.Under_X.X</c> / <c>.Over_X.X</c>.
/// </summary>
public string? TryGetTotalToken(SportCode sport, int periodNumber)
{
ArgumentNullException.ThrowIfNull(sport);
if (periodNumber <= 0) return null;
var ord = Ordinal(periodNumber);
return sport.Value switch
{
FootballId => $"{ord}_Half_Total_Goals",
BasketballId => _basketballQuarterMode
? $"{ord}_Quarter_Total_Points"
: $"{ord}_Half_Total_Points",
TennisId => $"{ord}_Set_Total_Games",
HockeyId => $"{ord}_Period_Total_Goals",
_ => null,
};
}
/// <summary>Returns the maximum expected period count for a sport.</summary>
public int MaxPeriods(SportCode sport)
{
ArgumentNullException.ThrowIfNull(sport);
return sport.Value switch
{
FootballId => 2,
BasketballId => _basketballQuarterMode ? 4 : 2,
TennisId => 5, // Grand Slam cap
HockeyId => 3,
_ => 0,
};
}
// ── private helpers ──────────────────────────────────────────────────────
private string ToFootballResultToken(int n) =>
// Football: "Result_-_1st_Half", "Result_-_2nd_Half"
n <= 2 ? $"Result_-_{Ordinal(n)}_Half" : $"Result_-_{Ordinal(n)}_Quarter";
private string ToBasketballResultToken(int n) =>
_basketballQuarterMode
? $"{Ordinal(n)}_Quarter_Result0"
: $"{Ordinal(n)}_Half_Result0";
private static string ToTennisResultToken(int n) =>
$"{Ordinal(n)}_Set_Result0";
private static string ToHockeyResultToken(int n) =>
$"{Ordinal(n)}_Period_Result0";
private static string Ordinal(int n)
{
if (n >= 1 && n < Ordinals.Length)
return Ordinals[n];
// Fallback for n >= 8 (tennis Grand Slams edge case)
var suffix = n switch
{
11 or 12 or 13 => "th",
_ when n % 10 == 1 => "st",
_ when n % 10 == 2 => "nd",
_ when n % 10 == 3 => "rd",
_ => "th",
};
return $"{n}{suffix}";
}
}
@@ -0,0 +1,121 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using AngleSharp;
using Marathon.Domain.Entities;
using Marathon.Domain.Enums;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Logging;
using DomainEventId = Marathon.Domain.ValueObjects.EventId;
using AngleSharpConfig = AngleSharp.Configuration;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Parses an event detail page to extract the final score when
/// <c>eventJsonInfo.matchIsComplete = true</c>.
/// </summary>
/// <remarks>
/// Used by the Phase 8 watch-list poller to harvest results as they become
/// available on individual event-detail pages.
/// </remarks>
public sealed partial class ResultsParser : IResultsParser
{
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly ILogger<ResultsParser> _logger;
// Matches score patterns like "2:1", "2:1 (1:1)", "2:1 (0:0) (2:1)"
[GeneratedRegex(@"(\d+):(\d+)", RegexOptions.CultureInvariant)]
private static partial Regex ScoreRegex();
public ResultsParser(ILogger<ResultsParser> logger)
{
_logger = logger;
}
/// <inheritdoc/>
public async Task<EventResult?> ParseAsync(string html, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(html);
var config = AngleSharpConfig.Default;
using var context = BrowsingContext.New(config);
using var document = await context
.OpenAsync(req => req.Content(html), ct)
.ConfigureAwait(false);
// Extract eventJsonInfo hidden <td>
var jsonTd = document
.QuerySelector("td[data-mutable-id='eventJsonInfo'][data-json]");
if (jsonTd is null)
{
_logger.LogDebug("eventJsonInfo element not found — page may not be an event detail.");
return null;
}
var jsonRaw = jsonTd.GetAttribute("data-json");
if (string.IsNullOrWhiteSpace(jsonRaw))
return null;
EventJsonInfo? info;
try
{
info = JsonSerializer.Deserialize<EventJsonInfo>(
jsonRaw,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to deserialize eventJsonInfo JSON.");
return null;
}
if (info is null || !info.MatchIsComplete)
return null;
// Parse score from resultDescription, e.g. "2:1 (1:1)"
var scoreText = info.ResultDescription ?? string.Empty;
var firstScore = ScoreRegex().Match(scoreText);
if (!firstScore.Success)
{
_logger.LogWarning(
"matchIsComplete=true but resultDescription={Desc} could not be parsed.",
scoreText);
return null;
}
var side1Score = int.Parse(firstScore.Groups[1].Value);
var side2Score = int.Parse(firstScore.Groups[2].Value);
var winner = side1Score > side2Score ? Side.Side1
: side2Score > side1Score ? Side.Side2
: Side.Draw;
// Event ID
var mainRow = document.QuerySelector("div.coupon-row[data-event-eventId]");
var eventIdRaw = mainRow?.GetAttribute("data-event-eventId")
?? info.MarathonEventId?.ToString()
?? string.Empty;
if (string.IsNullOrWhiteSpace(eventIdRaw))
return null;
var completedAt = new DateTimeOffset(DateTimeOffset.UtcNow.UtcDateTime, MoscowOffset);
return new EventResult(
new DomainEventId(eventIdRaw),
side1Score,
side2Score,
winner,
completedAt);
}
// Local DTO for JSON deserialization
private sealed class EventJsonInfo
{
public long? MarathonEventId { get; set; }
public bool MatchIsComplete { get; set; }
public string? ResultDescription { get; set; }
}
}
@@ -0,0 +1,50 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Extracts <c>initData.serverTime</c> from the page's inline script block.
/// Format observed: <c>serverTime:"2026,05,05,00,43,28"</c> (Moscow TZ, UTC+3).
/// </summary>
public sealed partial class ServerTimeProvider : IServerTimeProvider
{
// Matches: serverTime:"YYYY,MM,DD,HH,mm,ss"
[GeneratedRegex(
@"serverTime\s*:\s*""(\d{4}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2}),(\d{1,2})""",
RegexOptions.CultureInvariant)]
private static partial Regex ServerTimeRegex();
private static readonly TimeSpan MoscowOffset = TimeSpan.FromHours(3);
private readonly ILogger<ServerTimeProvider> _logger;
public ServerTimeProvider(ILogger<ServerTimeProvider> logger)
{
_logger = logger;
}
/// <inheritdoc/>
public DateTimeOffset? ExtractServerTime(string html)
{
ArgumentNullException.ThrowIfNull(html);
var match = ServerTimeRegex().Match(html);
if (!match.Success)
{
_logger.LogWarning(
"Could not find initData.serverTime in the page HTML. " +
"Date parsing will fall back to system clock (UTC+3).");
return null;
}
var year = int.Parse(match.Groups[1].Value);
var month = int.Parse(match.Groups[2].Value);
var day = int.Parse(match.Groups[3].Value);
var hour = int.Parse(match.Groups[4].Value);
var minute = int.Parse(match.Groups[5].Value);
var second = int.Parse(match.Groups[6].Value);
return new DateTimeOffset(year, month, day, hour, minute, second, MoscowOffset);
}
}
@@ -0,0 +1,26 @@
using Marathon.Domain.Entities;
using Microsoft.Extensions.Logging;
namespace Marathon.Infrastructure.Scraping.Parsers;
/// <summary>
/// Parses a pre-match listing page (<c>/su/</c> or sport-filtered URL)
/// into upcoming <see cref="Event"/> objects.
/// </summary>
public sealed class UpcomingEventsParser : EventListingParserBase, IUpcomingEventsParser
{
public UpcomingEventsParser(
IServerTimeProvider serverTimeProvider,
ILogger<UpcomingEventsParser> logger)
: base(serverTimeProvider, logger)
{
}
/// <inheritdoc/>
public Task<IReadOnlyList<Event>> ParseAsync(string html, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(html);
// liveOnly = false → include pre-match rows (data-live="false" or absent)
return ParseHtmlAsync(html, liveOnly: false, ct);
}
}
@@ -0,0 +1,125 @@
using Marathon.Application.Abstractions;
using Marathon.Infrastructure.Configuration;
using Marathon.Infrastructure.Scraping.Parsers;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Http.Resilience;
using Microsoft.Extensions.Options;
using Polly;
using Polly.CircuitBreaker;
using Polly.Retry;
using Polly.Timeout;
using System.Threading.RateLimiting;
namespace Marathon.Infrastructure.Scraping;
/// <summary>
/// Extension method to register all scraping infrastructure services with DI.
/// Call this from the composition root (Phase 4 — DependencyInjection.cs).
/// </summary>
/// <remarks>
/// Registers:
/// <list type="bullet">
/// <item><see cref="ScrapingOptions"/> bound to <c>Scraping</c> config section.</item>
/// <item>Named <c>"marathonbet"</c> HttpClient with UA rotation + Polly resilience pipeline.</item>
/// <item>All parser singletons.</item>
/// <item><see cref="IOddsScraper"/> → <see cref="MarathonbetScraper"/>.</item>
/// </list>
/// The Polly resilience pipeline is composed in this order (outermost to innermost):
/// Timeout → Retry (exp. backoff + jitter) → Circuit Breaker → Rate Limiter
/// </remarks>
public static class ScrapingModule
{
public static IServiceCollection AddMarathonScraping(
this IServiceCollection services,
IConfiguration config)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(config);
// ── Options ───────────────────────────────────────────────────────
services
.AddOptions<ScrapingOptions>()
.Bind(config.GetSection("Scraping"))
.ValidateOnStart();
// ── User-Agent rotator ────────────────────────────────────────────
services.AddTransient<UserAgentRotatorHandler>();
// ── Named HttpClient with resilience pipeline ─────────────────────
services
.AddHttpClient("marathonbet", (sp, client) =>
{
var opts = sp.GetRequiredService<IOptions<ScrapingOptions>>().Value;
client.BaseAddress = new Uri(opts.BaseUrl);
client.Timeout = Timeout.InfiniteTimeSpan; // Polly timeout manages per-attempt
})
.AddHttpMessageHandler<UserAgentRotatorHandler>()
.AddResilienceHandler("marathonbet-pipeline", (builder, context) =>
{
var opts = context.ServiceProvider
.GetRequiredService<IOptions<ScrapingOptions>>().Value;
// 1. Per-attempt timeout (outermost — wraps retry and everything below)
builder.AddTimeout(new TimeoutStrategyOptions
{
Timeout = TimeSpan.FromSeconds(opts.RequestTimeoutSeconds),
});
// 2. Retry with exponential back-off + jitter
builder.AddRetry(new RetryStrategyOptions<HttpResponseMessage>
{
MaxRetryAttempts = opts.RetryPolicy.MaxAttempts,
Delay = TimeSpan.FromMilliseconds(opts.RetryPolicy.BaseDelayMs),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Exception is HttpRequestException ||
(args.Outcome.Result?.StatusCode is
System.Net.HttpStatusCode.TooManyRequests or
System.Net.HttpStatusCode.ServiceUnavailable or
System.Net.HttpStatusCode.GatewayTimeout)),
});
// 3. Circuit breaker — open after high failure ratio for 30 s
builder.AddCircuitBreaker(new CircuitBreakerStrategyOptions<HttpResponseMessage>
{
SamplingDuration = TimeSpan.FromSeconds(30),
FailureRatio = 0.8,
MinimumThroughput = 5,
BreakDuration = TimeSpan.FromSeconds(30),
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Exception is HttpRequestException ||
(args.Outcome.Result?.StatusCode is
System.Net.HttpStatusCode.TooManyRequests or
System.Net.HttpStatusCode.ServiceUnavailable)),
});
// 4. Rate limiter (innermost — closest to the wire)
builder.AddRateLimiter(new TokenBucketRateLimiter(
new TokenBucketRateLimiterOptions
{
TokenLimit = Math.Max(1, opts.RateLimit.RequestsPerSecond),
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
TokensPerPeriod = Math.Max(1, opts.RateLimit.RequestsPerSecond),
QueueLimit = opts.MaxConcurrentRequests * 2,
AutoReplenishment = true,
}));
});
// ── Parsers (stateless — safe as singletons) ──────────────────────
services.AddSingleton<IServerTimeProvider, ServerTimeProvider>();
services.AddSingleton(_ =>
// TODO (Phase 4): bind BasketballQuarterMode from Sports:Basketball:QuarterMode config.
new PeriodScopeMapper(basketballQuarterMode: false));
services.AddSingleton<IUpcomingEventsParser, UpcomingEventsParser>();
services.AddSingleton<ILiveEventsParser, LiveEventsParser>();
services.AddSingleton<IEventOddsParser, EventOddsParser>();
services.AddSingleton<IResultsParser, ResultsParser>();
// ── Main scraper ──────────────────────────────────────────────────
services.AddSingleton<IOddsScraper, MarathonbetScraper>();
return services;
}
}
@@ -0,0 +1,40 @@
using Marathon.Infrastructure.Configuration;
using Microsoft.Extensions.Options;
namespace Marathon.Infrastructure.Scraping;
/// <summary>
/// A <see cref="DelegatingHandler"/> that rotates the <c>User-Agent</c> request header
/// on each outbound HTTP request using the pool configured in <see cref="ScrapingOptions.UserAgents"/>.
/// </summary>
/// <remarks>
/// If the <c>UserAgents</c> pool is empty, the handler passes the request through
/// without modifying the header — the default HttpClient UA or Polly pipeline UA applies.
/// Rotation is round-robin using an atomic counter.
/// </remarks>
public sealed class UserAgentRotatorHandler : DelegatingHandler
{
private readonly string[] _userAgents;
private int _counter;
public UserAgentRotatorHandler(IOptions<ScrapingOptions> options)
{
ArgumentNullException.ThrowIfNull(options);
_userAgents = options.Value.UserAgents ?? Array.Empty<string>();
}
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (_userAgents.Length > 0)
{
// Thread-safe round-robin without modulo bias risk at reasonable scale
var index = Math.Abs(
Interlocked.Increment(ref _counter) % _userAgents.Length);
request.Headers.TryAddWithoutValidation("User-Agent", _userAgents[index]);
}
return base.SendAsync(request, cancellationToken);
}
}
@@ -0,0 +1,21 @@
{
"Scraping": {
"PollingIntervalSeconds": 30,
"MaxConcurrentRequests": 4,
"UserAgents": [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0"
],
"RetryPolicy": {
"MaxAttempts": 3,
"BaseDelayMs": 500
},
"RateLimit": {
"RequestsPerSecond": 1
},
"UsePlaywright": false,
"BaseUrl": "https://www.marathonbet.by",
"RequestTimeoutSeconds": 30
}
}
+19
View File
@@ -0,0 +1,19 @@
@*
Top-level Blazor router. Mounted at #app inside index.html via the host's
BlazorWebView RootComponents collection.
*@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<div class="m-shell">
<p class="m-kicker">404</p>
<h1 class="m-display" style="font-size: 2.5rem;">Страница не найдена</h1>
<p>Запрошенный маршрут не существует.</p>
</div>
</LayoutView>
</NotFound>
</Router>
+10
View File
@@ -0,0 +1,10 @@
@inject IStringLocalizer<SharedResource> L
<a href="/" class="m-brand @Class" aria-label="@L["App.Title"]">
<span class="m-brand__mark">@L["App.BrandMark"]</span>
<span class="m-brand__dateline">@L["App.Dateline"]</span>
</a>
@code {
[Parameter] public string? Class { get; set; }
}
+18
View File
@@ -0,0 +1,18 @@
<div class="m-field-row">
<div>
<label style="font-weight: 500; font-size: 0.9375rem;">@Label</label>
@if (!string.IsNullOrEmpty(Hint))
{
<div class="m-field-row__hint">@Hint</div>
}
</div>
<div>
@ChildContent
</div>
</div>
@code {
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
[Parameter] public string? Hint { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,45 @@
@using LocalizationOptions = Marathon.UI.Services.LocalizationOptions
@inject LocaleState LocaleState
@inject ISettingsWriter SettingsWriter
@inject IStringLocalizer<SharedResource> L
@inject ILogger<LocaleSwitcher> Logger
<div class="m-segmented" role="group" aria-label="@L["Locale.Tooltip.Switch"]">
<button type="button"
class="m-segmented__btn @(IsActive(LocaleState.Russian) ? "is-active" : null)"
aria-pressed="@IsActive(LocaleState.Russian).ToString().ToLowerInvariant()"
@onclick="@(() => SwitchAsync(LocaleState.Russian))">
@L["Locale.Russian"]
</button>
<button type="button"
class="m-segmented__btn @(IsActive(LocaleState.English) ? "is-active" : null)"
aria-pressed="@IsActive(LocaleState.English).ToString().ToLowerInvariant()"
@onclick="@(() => SwitchAsync(LocaleState.English))">
@L["Locale.English"]
</button>
</div>
@code {
private bool IsActive(string culture) =>
string.Equals(LocaleState.Culture.Name, culture, StringComparison.OrdinalIgnoreCase);
private async Task SwitchAsync(string culture)
{
if (IsActive(culture))
{
return;
}
try
{
LocaleState.Set(culture);
await SettingsWriter.SaveSectionAsync(
LocalizationOptions.SectionName,
new LocalizationOptions { DefaultCulture = culture });
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to persist locale {Culture}", culture);
}
}
}
+40
View File
@@ -0,0 +1,40 @@
@inject IStringLocalizer<SharedResource> L
<nav class="m-nav" aria-label="primary">
<div style="padding: var(--m-space-5) var(--m-space-4) var(--m-space-3); border-bottom: 1px solid rgba(231,229,228,0.10);">
<div style="font-family: var(--m-font-display); font-size: 1.25rem; color: #fafaf7;">
<span style="color: var(--m-c-accent);">M</span>arathon
</div>
<div style="font-family: var(--m-font-mono); font-size: 0.6875rem; letter-spacing: 0.18em; text-transform: uppercase; color: rgba(231,229,228,0.55); margin-top: 4px;">
Odds Lab · v0.1
</div>
</div>
<div class="m-nav__group">@L["Nav.Section.Analysis"]</div>
<NavLink class="m-nav__link" href="" Match="NavLinkMatch.All">
<MudIcon Icon="@Icons.Material.Outlined.GridView" Size="Size.Small" />
<span>@L["Nav.Dashboard"]</span>
</NavLink>
<NavLink class="m-nav__link" href="prematch">
<MudIcon Icon="@Icons.Material.Outlined.Schedule" Size="Size.Small" />
<span>@L["Nav.PreMatch"]</span>
</NavLink>
<NavLink class="m-nav__link" href="live">
<MudIcon Icon="@Icons.Material.Outlined.Bolt" Size="Size.Small" />
<span>@L["Nav.Live"]</span>
</NavLink>
<NavLink class="m-nav__link" href="anomalies">
<MudIcon Icon="@Icons.Material.Outlined.Warning" Size="Size.Small" />
<span>@L["Nav.Anomalies"]</span>
</NavLink>
<NavLink class="m-nav__link" href="results">
<MudIcon Icon="@Icons.Material.Outlined.Done" Size="Size.Small" />
<span>@L["Nav.Results"]</span>
</NavLink>
<div class="m-nav__group" style="margin-top: var(--m-space-5);">@L["Nav.Section.System"]</div>
<NavLink class="m-nav__link" href="settings">
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
<span>@L["Nav.Settings"]</span>
</NavLink>
</nav>
@@ -0,0 +1,30 @@
<li style="display: grid; grid-template-columns: 36px 1fr auto; gap: var(--m-space-3); align-items: center; padding: var(--m-space-2) 0;">
<span class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); letter-spacing: 0.12em;">@Index</span>
<span style="font-size: 0.9375rem;">@Label</span>
<span style="display: inline-flex; align-items: center; gap: 6px; font-family: var(--m-font-mono); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.14em; color: @StatusColor;">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: @StatusColor;"></span>
@StatusLabel
</span>
</li>
@code {
[Parameter, EditorRequired] public string Index { get; set; } = string.Empty;
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
[Parameter] public string Status { get; set; } = "idle";
private string StatusColor => Status switch
{
"ok" => "var(--m-c-positive)",
"warn" => "var(--m-c-accent)",
"error" => "var(--m-c-anomaly)",
_ => "var(--m-c-ink-soft)",
};
private string StatusLabel => Status switch
{
"ok" => "OK",
"warn" => "WAIT",
"error" => "FAIL",
_ => "IDLE",
};
}
@@ -0,0 +1,11 @@
@inject IStringLocalizer<SharedResource> L
<div style="display: flex; justify-content: flex-end; gap: var(--m-space-3); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule); margin-top: var(--m-space-2);">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="OnSave">
@L["Settings.Action.Save"]
</MudButton>
</div>
@code {
[Parameter, EditorRequired] public EventCallback OnSave { get; set; }
}
+17
View File
@@ -0,0 +1,17 @@
<div class="m-card @(Anomaly ? "m-card--anomaly" : null)" style="display: flex; flex-direction: column; gap: var(--m-space-2);">
<span class="m-stat__label">@Label</span>
<span class="m-stat__value">@Value</span>
@if (!string.IsNullOrEmpty(Delta))
{
<span class="m-stat__delta @(Anomaly ? "m-stat__delta--down" : null)" style="color: @(Anomaly ? "var(--m-c-anomaly)" : "var(--m-c-ink-soft)");">
@Delta
</span>
}
</div>
@code {
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
[Parameter, EditorRequired] public string Value { get; set; } = string.Empty;
[Parameter] public string? Delta { get; set; }
[Parameter] public bool Anomaly { get; set; }
}
@@ -0,0 +1,14 @@
@inject ThemeState ThemeState
@inject IStringLocalizer<SharedResource> L
<MudTooltip Text="@(ThemeState.IsDark ? L["Theme.Toggle.Light"] : L["Theme.Toggle.Dark"])">
<MudIconButton
Icon="@(ThemeState.IsDark ? Icons.Material.Outlined.LightMode : Icons.Material.Outlined.DarkMode)"
Color="Color.Inherit"
OnClick="OnToggle"
aria-label="@(ThemeState.IsDark ? L["Theme.Toggle.Light"] : L["Theme.Toggle.Dark"])" />
</MudTooltip>
@code {
private void OnToggle() => ThemeState.Toggle();
}
+119
View File
@@ -0,0 +1,119 @@
@inherits LayoutComponentBase
@inject ThemeState ThemeState
@inject LocaleState LocaleState
@inject IStringLocalizer<SharedResource> L
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
<MudPopoverProvider />
<MudDialogProvider FullWidth="true" MaxWidth="MaxWidth.Small" CloseOnEscapeKey="true" />
<MudSnackbarProvider />
<div class="m-app-frame @(_drawerOpen ? "is-drawer-open" : null)" data-theme="@(ThemeState.IsDark ? "dark" : "light")">
<header class="m-appbar">
<MudIconButton
Icon="@Icons.Material.Outlined.Menu"
Color="Color.Inherit"
Edge="Edge.Start"
OnClick="ToggleDrawer"
aria-label="@L["Nav.Section.Analysis"]" />
<AppBrand Class="m-rise m-rise-1" />
<div class="m-appbar__spacer"></div>
<div class="m-appbar__tools m-rise m-rise-2">
<LocaleSwitcher />
<ThemeToggle />
</div>
</header>
<MudDrawer
@bind-Open="_drawerOpen"
Anchor="Anchor.Left"
Variant="DrawerVariant.Responsive"
ClipMode="DrawerClipMode.Always"
Elevation="0"
Width="248px"
Color="Color.Dark">
<NavBody />
</MudDrawer>
<main class="m-main">
<CascadingValue Value="ThemeState">
<CascadingValue Value="LocaleState">
@Body
</CascadingValue>
</CascadingValue>
</main>
<footer class="m-footer">
<span class="m-kicker">Marathon Odds Lab</span>
<span style="font-family: var(--m-font-mono); font-size: 0.6875rem; color: var(--m-c-ink-soft); letter-spacing: 0.16em; text-transform: uppercase;">
Phase 5 · Editorial-Quant · v0.1
</span>
</footer>
</div>
<style>
.m-app-frame {
display: grid;
grid-template-rows: 60px 1fr 36px;
min-height: 100vh;
}
.m-appbar {
display: flex;
align-items: center;
gap: var(--m-space-3);
padding: 0 clamp(var(--m-space-3), 2vw, var(--m-space-5));
border-bottom: 1px solid var(--m-c-rule);
background: var(--m-c-paper);
position: sticky;
top: 0;
z-index: 10;
}
.m-appbar__spacer { flex: 1; }
.m-appbar__tools { display: inline-flex; gap: var(--m-space-3); align-items: center; }
.m-main {
position: relative;
z-index: 1;
min-height: 0;
}
.m-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 clamp(var(--m-space-3), 2vw, var(--m-space-5));
border-top: 1px solid var(--m-c-rule);
background: var(--m-c-paper);
}
[data-theme="dark"] .m-appbar,
[data-theme="dark"] .m-footer {
background: var(--m-c-paper-2);
border-color: var(--m-c-rule);
}
</style>
@code {
private bool _drawerOpen = true;
private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build();
protected override void OnInitialized()
{
ThemeState.OnChange += StateHasChanged;
LocaleState.OnChange += StateHasChanged;
}
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
public void Dispose()
{
ThemeState.OnChange -= StateHasChanged;
LocaleState.OnChange -= StateHasChanged;
}
}
+19
View File
@@ -2,6 +2,8 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Marathon.UI</RootNamespace>
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
</PropertyGroup>
<ItemGroup>
@@ -10,6 +12,14 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" />
<PackageReference Include="MudBlazor" />
<PackageReference Include="Microsoft.Extensions.Localization" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
<ItemGroup>
@@ -17,4 +27,13 @@
<ProjectReference Include="..\Marathon.Application\Marathon.Application.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Resources\SharedResource.ru.resx">
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
<EmbeddedResource Update="Resources\SharedResource.en.resx">
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
</ItemGroup>
</Project>
+5
View File
@@ -0,0 +1,5 @@
@page "/anomalies"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Anomalies"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Anomalies"]" />
+78
View File
@@ -0,0 +1,78 @@
@page "/"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker">@L["Home.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2.5rem, 5vw, 4rem);">@L["Home.Title"]</h1>
<p style="font-size: 1.0625rem; line-height: 1.5; color: var(--m-c-ink-soft); max-width: 60ch;">
@L["Home.Lede"]
</p>
</header>
<hr class="m-rule--double" />
<div class="m-grid--three m-rise m-rise-2">
<StatCard Label="@L["Home.Stat.Events"]" Value="@_eventsTracked.ToString("N0")" Delta="+12%" />
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_snapshotsToday.ToString("N0")" Delta="+318" />
<StatCard Label="@L["Home.Stat.Anomalies"]" Value="@_anomalies.ToString()" Delta="3 NEW" Anomaly="true" />
<StatCard Label="@L["Home.Stat.Coverage"]" Value="4" Delta="BSK · FBL · TNS · HKY" />
</div>
<div class="m-grid--asym m-rise m-rise-3" style="margin-top: var(--m-space-6);">
<div class="m-card">
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
@L["Home.Section.Latest"]
</span>
<h2 style="font-family: var(--m-font-display); font-weight: 400; font-size: 1.625rem; margin: var(--m-space-3) 0 var(--m-space-5);">
@L["Anomaly.Kind.SuspensionFlip"]
</h2>
<div style="display: grid; gap: var(--m-space-4);">
@foreach (var item in _placeholderFeed)
{
<article style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
@item.Time
</div>
<div>
<div style="font-weight: 500;">@item.Match</div>
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">@item.Detail</div>
</div>
<span class="m-anomaly">
<span class="m-anomaly__pulse"></span>
@($"{item.Score:0.00}")
</span>
</article>
}
</div>
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule); color: var(--m-c-ink-soft); font-size: 0.8125rem;">
@L["Home.Empty"]
</div>
</div>
<aside class="m-card m-card--accented">
<span class="m-kicker">@L["Home.Section.Pipeline"]</span>
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3); counter-reset: m-step;">
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="ok" />
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="ok" />
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="warn" />
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="idle" />
</ol>
</aside>
</div>
</section>
@code {
// Mock data — Phase 6+ will replace with live queries.
private readonly int _eventsTracked = 0;
private readonly int _snapshotsToday = 0;
private readonly int _anomalies = 0;
private record FeedItem(string Time, string Match, string Detail, decimal Score);
private readonly List<FeedItem> _placeholderFeed = new();
}
+5
View File
@@ -0,0 +1,5 @@
@page "/live"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Live"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Live"]" />
+19
View File
@@ -0,0 +1,19 @@
@*
Lightweight placeholders for routes that Phase 6/7/8 will replace. Keeping
them here means the navigation drawer is fully wired today; later phases
just convert each @page block into a real component file.
*@
@inject IStringLocalizer<SharedResource> L
<section class="m-shell">
<span class="m-kicker">@Surface</span>
<h1 class="m-display" style="font-size: clamp(1.75rem, 3vw, 2.5rem);">@Title</h1>
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">
Coming in a later phase. The visual language defined in Phase 5 will carry through unchanged.
</p>
</section>
@code {
[Parameter] public string Surface { get; set; } = string.Empty;
[Parameter] public string Title { get; set; } = string.Empty;
}
+5
View File
@@ -0,0 +1,5 @@
@page "/prematch"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.PreMatch"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.PreMatch"]" />
+5
View File
@@ -0,0 +1,5 @@
@page "/results"
@inject IStringLocalizer<SharedResource> L
<PageTitle>@L["App.Title"] · @L["Nav.Results"]</PageTitle>
<Placeholders Surface="@L["Nav.Section.Analysis"]" Title="@L["Nav.Results"]" />
+272
View File
@@ -0,0 +1,272 @@
@page "/settings"
@using Marathon.Application.Storage
@using LocalizationOptions = Marathon.UI.Services.LocalizationOptions
@inject IStringLocalizer<SharedResource> L
@inject IOptionsMonitor<ScrapingSettingsForm> ScrapingOpts
@inject IOptionsMonitor<WorkerOptions> WorkerOpts
@inject IOptionsMonitor<StorageOptions> StorageOpts
@inject IOptionsMonitor<AnomalyOptions> AnomalyOpts
@inject IOptionsMonitor<Marathon.UI.Services.LocalizationOptions> LocaleOpts
@inject ISettingsWriter Writer
@inject IDialogService Dialogs
@inject ISnackbar Snackbar
@inject ILogger<Settings> Logger
<PageTitle>@L["App.Title"] · @L["Settings.Title"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker">@L["Settings.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Settings.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 70ch;">@L["Settings.Lede"]</p>
</header>
<hr class="m-rule--double" />
@* SCRAPING *@
<article class="m-section m-rise m-rise-2">
<header class="m-section__head">
<h2>@L["Settings.Section.Scraping"]</h2>
<MudButton Variant="Variant.Text"
Size="Size.Small"
OnClick="@(() => ResetSectionAsync(ScrapingSettingsForm.SectionName))">
@L["Settings.Action.Reset"]
</MudButton>
</header>
<div class="m-section__body">
<Field Label="@L["Settings.Scraping.PollingIntervalSeconds"]" Hint="@L["Settings.Scraping.PollingIntervalSeconds.Hint"]">
<MudNumericField T="int" @bind-Value="_scraping.PollingIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.MaxConcurrentRequests"]" Hint="@L["Settings.Scraping.MaxConcurrentRequests.Hint"]">
<MudNumericField T="int" @bind-Value="_scraping.MaxConcurrentRequests" Min="1" Max="16" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.RateLimitRps"]" Hint="@L["Settings.Scraping.RateLimitRps.Hint"]">
<MudNumericField T="int" @bind-Value="_scraping.RateLimit.RequestsPerSecond" Min="1" Max="20" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.RetryMaxAttempts"]">
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.MaxAttempts" Min="0" Max="10" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.RetryBaseDelayMs"]">
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.BaseDelayMs" Min="100" Max="60000" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.BaseUrl"]">
<MudTextField T="string" @bind-Value="_scraping.BaseUrl" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.RequestTimeoutSeconds"]">
<MudNumericField T="int" @bind-Value="_scraping.RequestTimeoutSeconds" Min="5" Max="600" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.UserAgents"]" Hint="@L["Settings.Scraping.UserAgents.Hint"]">
<MudTextField T="string"
Value="@_userAgentsRaw"
ValueChanged="@OnUserAgentsChanged"
Lines="4"
Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Scraping.UsePlaywright"]">
<MudSwitch T="bool" @bind-Value="_scraping.UsePlaywright" Color="Color.Primary" />
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(ScrapingSettingsForm.SectionName, _scraping))" />
</div>
</article>
@* WORKERS *@
<article class="m-section m-rise m-rise-3">
<header class="m-section__head">
<h2>@L["Settings.Section.Workers"]</h2>
<MudButton Variant="Variant.Text" Size="Size.Small"
OnClick="@(() => ResetSectionAsync(WorkerOptions.SectionName))">
@L["Settings.Action.Reset"]
</MudButton>
</header>
<div class="m-section__body">
<Field Label="@L["Settings.Workers.UpcomingScheduleCron"]" Hint="@L["Settings.Workers.UpcomingScheduleCron.Hint"]">
<MudTextField T="string" @bind-Value="_workers.UpcomingScheduleCron" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Workers.UpcomingPollerEnabled"]">
<MudSwitch T="bool" @bind-Value="_workers.UpcomingPollerEnabled" Color="Color.Primary" />
</Field>
<Field Label="@L["Settings.Workers.LivePollerEnabled"]">
<MudSwitch T="bool" @bind-Value="_workers.LivePollerEnabled" Color="Color.Primary" />
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(WorkerOptions.SectionName, _workers))" />
</div>
</article>
@* STORAGE *@
<article class="m-section m-rise m-rise-4">
<header class="m-section__head">
<h2>@L["Settings.Section.Storage"]</h2>
<MudButton Variant="Variant.Text" Size="Size.Small"
OnClick="@(() => ResetSectionAsync(StorageOptions.SectionName))">
@L["Settings.Action.Reset"]
</MudButton>
</header>
<div class="m-section__body">
<Field Label="@L["Settings.Storage.DatabasePath"]">
<MudTextField T="string" @bind-Value="_storage.DatabasePath" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Storage.ExportDirectory"]">
<MudTextField T="string" @bind-Value="_storage.ExportDirectory" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Storage.SnapshotRetentionDays"]">
<MudNumericField T="int" @bind-Value="_storage.SnapshotRetentionDays" Min="1" Max="3650" Variant="Variant.Outlined" />
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(StorageOptions.SectionName, _storage))" />
</div>
</article>
@* ANOMALY *@
<article class="m-section m-rise m-rise-5">
<header class="m-section__head">
<h2>@L["Settings.Section.Anomaly"]</h2>
<MudButton Variant="Variant.Text" Size="Size.Small"
OnClick="@(() => ResetSectionAsync(AnomalyOptions.SectionName))">
@L["Settings.Action.Reset"]
</MudButton>
</header>
<div class="m-section__body">
<Field Label="@L["Settings.Anomaly.SuspensionGapSeconds"]">
<MudNumericField T="int" @bind-Value="_anomaly.SuspensionGapSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Anomaly.OddsFlipThreshold"]">
<MudNumericField T="decimal" @bind-Value="_anomaly.OddsFlipThreshold" Min="0.01m" Max="1m" Step="0.01m" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Anomaly.MinSnapshotCount"]">
<MudNumericField T="int" @bind-Value="_anomaly.MinSnapshotCount" Min="2" Max="100" Variant="Variant.Outlined" />
</Field>
<Field Label="@L["Settings.Anomaly.DetectionIntervalSeconds"]">
<MudNumericField T="int" @bind-Value="_anomaly.DetectionIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(AnomalyOptions.SectionName, _anomaly))" />
</div>
</article>
@* LOCALIZATION *@
<article class="m-section m-rise m-rise-5">
<header class="m-section__head">
<h2>@L["Settings.Section.Localization"]</h2>
<MudButton Variant="Variant.Text" Size="Size.Small"
OnClick="@(() => ResetSectionAsync(LocalizationOptions.SectionName))">
@L["Settings.Action.Reset"]
</MudButton>
</header>
<div class="m-section__body">
<Field Label="@L["Settings.Localization.DefaultCulture"]">
<MudSelect T="string" @bind-Value="_locale.DefaultCulture" Variant="Variant.Outlined">
<MudSelectItem T="string" Value="@LocaleState.Russian">@L["Locale.Russian"] · ru-RU</MudSelectItem>
<MudSelectItem T="string" Value="@LocaleState.English">@L["Locale.English"] · en-US</MudSelectItem>
</MudSelect>
</Field>
<SectionFooter OnSave="@(() => SaveSectionAsync(LocalizationOptions.SectionName, _locale))" />
</div>
</article>
</section>
@code {
private ScrapingSettingsForm _scraping = new();
private WorkerOptions _workers = new();
private StorageOptions _storage = new();
private AnomalyOptions _anomaly = new();
private LocalizationOptions _locale = new();
private string _userAgentsRaw = string.Empty;
protected override void OnInitialized()
{
_scraping = ScrapingOpts.CurrentValue.Clone();
_userAgentsRaw = string.Join('\n', _scraping.UserAgents ?? Array.Empty<string>());
_workers = new WorkerOptions
{
UpcomingScheduleCron = WorkerOpts.CurrentValue.UpcomingScheduleCron,
LivePollerEnabled = WorkerOpts.CurrentValue.LivePollerEnabled,
UpcomingPollerEnabled = WorkerOpts.CurrentValue.UpcomingPollerEnabled,
};
_storage = new StorageOptions
{
DatabasePath = StorageOpts.CurrentValue.DatabasePath,
ExportDirectory = StorageOpts.CurrentValue.ExportDirectory,
SnapshotRetentionDays = StorageOpts.CurrentValue.SnapshotRetentionDays,
};
_anomaly = new AnomalyOptions
{
SuspensionGapSeconds = AnomalyOpts.CurrentValue.SuspensionGapSeconds,
OddsFlipThreshold = AnomalyOpts.CurrentValue.OddsFlipThreshold,
MinSnapshotCount = AnomalyOpts.CurrentValue.MinSnapshotCount,
DetectionIntervalSeconds = AnomalyOpts.CurrentValue.DetectionIntervalSeconds,
};
_locale = new LocalizationOptions { DefaultCulture = LocaleOpts.CurrentValue.DefaultCulture };
}
private void OnUserAgentsChanged(string raw)
{
_userAgentsRaw = raw;
_scraping.UserAgents = (raw ?? string.Empty)
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private async Task SaveSectionAsync<T>(string section, T payload) where T : class
{
var confirmed = await ConfirmAsync();
if (!confirmed)
{
return;
}
try
{
await Writer.SaveSectionAsync(section, payload);
Snackbar.Add(L["Settings.Saved"], Severity.Success);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to save section {Section}", section);
Snackbar.Add(L["Settings.SaveFailed"], Severity.Error);
}
}
private async Task ResetSectionAsync(string section)
{
var confirmed = await ConfirmAsync();
if (!confirmed)
{
return;
}
try
{
await Writer.ResetSectionAsync(section);
Snackbar.Add(L["Settings.Saved"], Severity.Success);
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to reset section {Section}", section);
Snackbar.Add(L["Settings.SaveFailed"], Severity.Error);
}
}
private async Task<bool> ConfirmAsync()
{
var parameters = new DialogParameters
{
["ContentText"] = L["Settings.Confirm.Body"].Value,
["ButtonText"] = L["Settings.Action.Save"].Value,
["CancelText"] = L["Common.Cancel"].Value,
};
var result = await Dialogs.ShowMessageBox(
title: L["Settings.Confirm.Title"],
message: L["Settings.Confirm.Body"],
yesText: L["Settings.Action.Save"],
cancelText: L["Common.Cancel"]);
return result == true;
}
}
@@ -0,0 +1,25 @@
namespace Marathon.UI.Resources;
/// <summary>
/// Marker class for <see cref="Microsoft.Extensions.Localization.IStringLocalizer{T}"/>.
/// Routes all <c>IStringLocalizer&lt;SharedResource&gt;</c> lookups to the
/// <c>SharedResource.{culture}.resx</c> files in this folder.
/// </summary>
/// <remarks>
/// <para><b>Key naming convention</b>: dot-segmented <c>&lt;Surface&gt;.&lt;Element&gt;</c>.</para>
/// <para>Surfaces:</para>
/// <list type="bullet">
/// <item><c>App.*</c> — application chrome (title, brand, tagline)</item>
/// <item><c>Nav.*</c> — main navigation labels</item>
/// <item><c>Home.*</c> — dashboard page</item>
/// <item><c>Settings.*</c> — settings page (further nested by section: <c>Settings.Scraping.*</c>)</item>
/// <item><c>Locale.*</c> — locale switcher labels</item>
/// <item><c>Theme.*</c> — theme toggle labels</item>
/// <item><c>Common.*</c> — shared verbs/nouns (Save, Cancel, Reset)</item>
/// <item><c>Anomaly.*</c> — anomaly feed (Phase 7 placeholder)</item>
/// </list>
/// <para>Add new keys to BOTH <c>SharedResource.ru.resx</c> AND <c>SharedResource.en.resx</c>.</para>
/// </remarks>
public sealed class SharedResource
{
}
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<data name="App.Title"><value>Marathon Odds Lab</value></data>
<data name="App.Tagline"><value>Odds analytics for marathonbet.by</value></data>
<data name="App.BrandMark"><value>Marathon</value></data>
<data name="App.Dateline"><value>Odds Laboratory</value></data>
<data name="Nav.Section.Analysis"><value>Analysis</value></data>
<data name="Nav.Section.System"><value>System</value></data>
<data name="Nav.Dashboard"><value>Dashboard</value></data>
<data name="Nav.PreMatch"><value>Pre-match</value></data>
<data name="Nav.Live"><value>Live</value></data>
<data name="Nav.Anomalies"><value>Anomalies</value></data>
<data name="Nav.Results"><value>Results</value></data>
<data name="Nav.Settings"><value>Settings</value></data>
<data name="Home.Kicker"><value>Briefing</value></data>
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
<data name="Home.Lede"><value>We snapshot marathonbet.by lines on a schedule, watch for favorite-underdog reversals, and keep evidence for every anomaly.</value></data>
<data name="Home.Stat.Events"><value>Events tracked</value></data>
<data name="Home.Stat.Snapshots"><value>Snapshots today</value></data>
<data name="Home.Stat.Anomalies"><value>Anomalies flagged</value></data>
<data name="Home.Stat.Coverage"><value>Sports covered</value></data>
<data name="Home.Section.Latest"><value>Latest signals</value></data>
<data name="Home.Section.Pipeline"><value>Capture pipeline</value></data>
<data name="Home.Pipeline.Step1"><value>Schedule capture (`/su`)</value></data>
<data name="Home.Pipeline.Step2"><value>Odds snapshot</value></data>
<data name="Home.Pipeline.Step3"><value>Flip detector</value></data>
<data name="Home.Pipeline.Step4"><value>XLSX export</value></data>
<data name="Home.Empty"><value>No data yet. Enable the background pollers in Settings to start the feed.</value></data>
<data name="Settings.Kicker"><value>Configuration</value></data>
<data name="Settings.Title"><value>Settings</value></data>
<data name="Settings.Lede"><value>Every scraper, storage, detector, and locale parameter. Changes are written to appsettings.Local.json and applied live.</value></data>
<data name="Settings.Section.Scraping"><value>Scraping</value></data>
<data name="Settings.Section.Workers"><value>Background workers</value></data>
<data name="Settings.Section.Storage"><value>Storage</value></data>
<data name="Settings.Section.Anomaly"><value>Anomaly detector</value></data>
<data name="Settings.Section.Localization"><value>Localization</value></data>
<data name="Settings.Action.Reset"><value>Reset section</value></data>
<data name="Settings.Action.Save"><value>Save</value></data>
<data name="Settings.Action.SaveAll"><value>Save all</value></data>
<data name="Settings.Confirm.Title"><value>Confirm changes</value></data>
<data name="Settings.Confirm.Body"><value>Settings will be written to appsettings.Local.json and re-read by services. Continue?</value></data>
<data name="Settings.Saved"><value>Settings saved.</value></data>
<data name="Settings.SaveFailed"><value>Failed to save settings.</value></data>
<data name="Settings.Scraping.PollingIntervalSeconds"><value>Polling interval (sec)</value></data>
<data name="Settings.Scraping.PollingIntervalSeconds.Hint"><value>How often to refresh the schedule. Minimum 5 seconds.</value></data>
<data name="Settings.Scraping.MaxConcurrentRequests"><value>Concurrent requests</value></data>
<data name="Settings.Scraping.MaxConcurrentRequests.Hint"><value>Cap at 8 to avoid throttling.</value></data>
<data name="Settings.Scraping.UserAgents"><value>User-Agent pool</value></data>
<data name="Settings.Scraping.UserAgents.Hint"><value>One UA per line. Rotated per request.</value></data>
<data name="Settings.Scraping.RetryMaxAttempts"><value>Retry attempts</value></data>
<data name="Settings.Scraping.RetryBaseDelayMs"><value>Base delay (ms)</value></data>
<data name="Settings.Scraping.RateLimitRps"><value>Rate limit (RPS)</value></data>
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Requests per second. 1 is recommended.</value></data>
<data name="Settings.Scraping.BaseUrl"><value>Base URL</value></data>
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Request timeout (sec)</value></data>
<data name="Settings.Scraping.UsePlaywright"><value>Use Playwright</value></data>
<data name="Settings.Workers.UpcomingScheduleCron"><value>Schedule cron (UPCOMING)</value></data>
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Standard cron. Defaults to every 5 minutes.</value></data>
<data name="Settings.Workers.LivePollerEnabled"><value>Live poller enabled</value></data>
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Schedule poller enabled</value></data>
<data name="Settings.Storage.DatabasePath"><value>SQLite path</value></data>
<data name="Settings.Storage.ExportDirectory"><value>Export directory</value></data>
<data name="Settings.Storage.SnapshotRetentionDays"><value>Snapshot retention (days)</value></data>
<data name="Settings.Anomaly.SuspensionGapSeconds"><value>Suspension window (sec)</value></data>
<data name="Settings.Anomaly.OddsFlipThreshold"><value>Flip threshold (Δ probability)</value></data>
<data name="Settings.Anomaly.MinSnapshotCount"><value>Min snapshot count</value></data>
<data name="Settings.Anomaly.DetectionIntervalSeconds"><value>Detection interval (sec)</value></data>
<data name="Settings.Localization.DefaultCulture"><value>Default UI language</value></data>
<data name="Locale.Russian"><value>RU</value></data>
<data name="Locale.English"><value>EN</value></data>
<data name="Locale.Tooltip.Switch"><value>Switch language</value></data>
<data name="Theme.Toggle.Light"><value>Light theme</value></data>
<data name="Theme.Toggle.Dark"><value>Dark theme</value></data>
<data name="Common.Save"><value>Save</value></data>
<data name="Common.Cancel"><value>Cancel</value></data>
<data name="Common.Reset"><value>Reset</value></data>
<data name="Common.Loading"><value>Loading…</value></data>
<data name="Common.Empty"><value>No data</value></data>
<data name="Common.Yes"><value>Yes</value></data>
<data name="Common.No"><value>No</value></data>
<data name="Anomaly.Live"><value>Anomaly</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Suspension flip</value></data>
<data name="Anomaly.Score"><value>Confidence</value></data>
</root>
@@ -0,0 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype"><value>text/microsoft-resx</value></resheader>
<resheader name="version"><value>2.0</value></resheader>
<resheader name="reader"><value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<resheader name="writer"><value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value></resheader>
<!-- App chrome -->
<data name="App.Title"><value>Marathon Odds Lab</value></data>
<data name="App.Tagline"><value>Аналитика коэффициентов marathonbet.by</value></data>
<data name="App.BrandMark"><value>Marathon</value></data>
<data name="App.Dateline"><value>Лаборатория коэффициентов</value></data>
<!-- Navigation -->
<data name="Nav.Section.Analysis"><value>Анализ</value></data>
<data name="Nav.Section.System"><value>Система</value></data>
<data name="Nav.Dashboard"><value>Сводка</value></data>
<data name="Nav.PreMatch"><value>До матча</value></data>
<data name="Nav.Live"><value>Лайв</value></data>
<data name="Nav.Anomalies"><value>Аномалии</value></data>
<data name="Nav.Results"><value>Результаты</value></data>
<data name="Nav.Settings"><value>Настройки</value></data>
<!-- Home / Dashboard -->
<data name="Home.Kicker"><value>Сводка</value></data>
<data name="Home.Title"><value>Поиск аномалий в коэффициентах</value></data>
<data name="Home.Lede"><value>Снимаем линии marathonbet.by по расписанию, ищем разворот фаворита и удерживаем доказательства каждой аномалии.</value></data>
<data name="Home.Stat.Events"><value>Событий в работе</value></data>
<data name="Home.Stat.Snapshots"><value>Снимков сегодня</value></data>
<data name="Home.Stat.Anomalies"><value>Аномалий найдено</value></data>
<data name="Home.Stat.Coverage"><value>Видов спорта</value></data>
<data name="Home.Section.Latest"><value>Свежий поток</value></data>
<data name="Home.Section.Pipeline"><value>Конвейер сбора</value></data>
<data name="Home.Pipeline.Step1"><value>Сбор расписания (`/su`)</value></data>
<data name="Home.Pipeline.Step2"><value>Снимок коэффициентов</value></data>
<data name="Home.Pipeline.Step3"><value>Детектор разворота</value></data>
<data name="Home.Pipeline.Step4"><value>Экспорт XLSX</value></data>
<data name="Home.Empty"><value>Пока пусто. Запустите фоновые сборщики на странице «Настройки», чтобы пошёл поток данных.</value></data>
<!-- Settings — sections -->
<data name="Settings.Kicker"><value>Конфигурация</value></data>
<data name="Settings.Title"><value>Настройки</value></data>
<data name="Settings.Lede"><value>Каждый параметр сборщика, хранилища, детектора и локализации. Изменения сохраняются в appsettings.Local.json и применяются на лету.</value></data>
<data name="Settings.Section.Scraping"><value>Сбор</value></data>
<data name="Settings.Section.Workers"><value>Фоновые задачи</value></data>
<data name="Settings.Section.Storage"><value>Хранилище</value></data>
<data name="Settings.Section.Anomaly"><value>Детектор аномалий</value></data>
<data name="Settings.Section.Localization"><value>Локализация</value></data>
<data name="Settings.Action.Reset"><value>Сбросить раздел</value></data>
<data name="Settings.Action.Save"><value>Сохранить</value></data>
<data name="Settings.Action.SaveAll"><value>Сохранить все</value></data>
<data name="Settings.Confirm.Title"><value>Подтвердите изменения</value></data>
<data name="Settings.Confirm.Body"><value>Параметры будут записаны в appsettings.Local.json и перечитаны службами. Продолжить?</value></data>
<data name="Settings.Saved"><value>Настройки сохранены.</value></data>
<data name="Settings.SaveFailed"><value>Не удалось сохранить настройки.</value></data>
<!-- Settings — Scraping -->
<data name="Settings.Scraping.PollingIntervalSeconds"><value>Интервал опроса (сек)</value></data>
<data name="Settings.Scraping.PollingIntervalSeconds.Hint"><value>Как часто перечитывать список матчей. Минимум 5 секунд.</value></data>
<data name="Settings.Scraping.MaxConcurrentRequests"><value>Параллельных запросов</value></data>
<data name="Settings.Scraping.MaxConcurrentRequests.Hint"><value>Не более 8 — иначе увидим 429.</value></data>
<data name="Settings.Scraping.UserAgents"><value>Пул User-Agent</value></data>
<data name="Settings.Scraping.UserAgents.Hint"><value>По одному значению на строку. Ротируется на запрос.</value></data>
<data name="Settings.Scraping.RetryMaxAttempts"><value>Повторы при сбое</value></data>
<data name="Settings.Scraping.RetryBaseDelayMs"><value>Базовая задержка (мс)</value></data>
<data name="Settings.Scraping.RateLimitRps"><value>Лимит RPS</value></data>
<data name="Settings.Scraping.RateLimitRps.Hint"><value>Запросов в секунду. Рекомендовано 1.</value></data>
<data name="Settings.Scraping.BaseUrl"><value>Базовый URL</value></data>
<data name="Settings.Scraping.RequestTimeoutSeconds"><value>Тайм-аут запроса (сек)</value></data>
<data name="Settings.Scraping.UsePlaywright"><value>Использовать Playwright</value></data>
<!-- Settings — Workers -->
<data name="Settings.Workers.UpcomingScheduleCron"><value>Cron расписания (UPCOMING)</value></data>
<data name="Settings.Workers.UpcomingScheduleCron.Hint"><value>Стандартный cron. По умолчанию каждые 5 минут.</value></data>
<data name="Settings.Workers.LivePollerEnabled"><value>Лайв-сборщик включён</value></data>
<data name="Settings.Workers.UpcomingPollerEnabled"><value>Сборщик расписания включён</value></data>
<!-- Settings — Storage -->
<data name="Settings.Storage.DatabasePath"><value>Путь к SQLite</value></data>
<data name="Settings.Storage.ExportDirectory"><value>Каталог экспорта</value></data>
<data name="Settings.Storage.SnapshotRetentionDays"><value>Хранить снимки (дней)</value></data>
<!-- Settings — Anomaly -->
<data name="Settings.Anomaly.SuspensionGapSeconds"><value>Окно «заморозки» (сек)</value></data>
<data name="Settings.Anomaly.OddsFlipThreshold"><value>Порог флипа (Δ вероятности)</value></data>
<data name="Settings.Anomaly.MinSnapshotCount"><value>Мин. число снимков</value></data>
<data name="Settings.Anomaly.DetectionIntervalSeconds"><value>Интервал детектора (сек)</value></data>
<!-- Settings — Localization -->
<data name="Settings.Localization.DefaultCulture"><value>Язык интерфейса по умолчанию</value></data>
<!-- Locale switcher -->
<data name="Locale.Russian"><value>RU</value></data>
<data name="Locale.English"><value>EN</value></data>
<data name="Locale.Tooltip.Switch"><value>Сменить язык</value></data>
<!-- Theme toggle -->
<data name="Theme.Toggle.Light"><value>Светлая тема</value></data>
<data name="Theme.Toggle.Dark"><value>Тёмная тема</value></data>
<!-- Common -->
<data name="Common.Save"><value>Сохранить</value></data>
<data name="Common.Cancel"><value>Отмена</value></data>
<data name="Common.Reset"><value>Сбросить</value></data>
<data name="Common.Loading"><value>Загрузка…</value></data>
<data name="Common.Empty"><value>Нет данных</value></data>
<data name="Common.Yes"><value>Да</value></data>
<data name="Common.No"><value>Нет</value></data>
<!-- Anomaly (Phase 7 placeholders) -->
<data name="Anomaly.Live"><value>Аномалия</value></data>
<data name="Anomaly.Kind.SuspensionFlip"><value>Разворот после заморозки</value></data>
<data name="Anomaly.Score"><value>Уверенность</value></data>
</root>
@@ -0,0 +1,21 @@
namespace Marathon.UI.Services;
/// <summary>
/// Options bound to the <c>Anomaly</c> section of <c>appsettings.json</c>.
/// </summary>
public sealed class AnomalyOptions
{
public const string SectionName = "Anomaly";
/// <summary>Suspension window after which a flip is treated as suspicious.</summary>
public int SuspensionGapSeconds { get; set; } = 60;
/// <summary>Implied-probability delta that qualifies as a flip.</summary>
public decimal OddsFlipThreshold { get; set; } = 0.30m;
/// <summary>Minimum snapshot count before the detector runs.</summary>
public int MinSnapshotCount { get; set; } = 3;
/// <summary>How often the detector executes, in seconds.</summary>
public int DetectionIntervalSeconds { get; set; } = 60;
}
@@ -0,0 +1,21 @@
namespace Marathon.UI.Services;
/// <summary>
/// Persists user-edited settings to <c>appsettings.Local.json</c> (gitignored).
/// </summary>
public interface ISettingsWriter
{
/// <summary>
/// Persists a single configuration section under its canonical name
/// (e.g. <c>"Scraping"</c>, <c>"Storage"</c>) to <c>appsettings.Local.json</c>.
/// Other sections in that file are preserved.
/// </summary>
Task SaveSectionAsync<T>(string sectionName, T values, CancellationToken cancellationToken = default)
where T : class;
/// <summary>
/// Removes the specified section from <c>appsettings.Local.json</c>, restoring the
/// value defined in <c>appsettings.json</c> on next configuration reload.
/// </summary>
Task ResetSectionAsync(string sectionName, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,142 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Marathon.UI.Services;
/// <summary>
/// File-backed implementation of <see cref="ISettingsWriter"/> that maintains
/// <c>appsettings.Local.json</c> next to the host's <c>appsettings.json</c>.
/// </summary>
/// <remarks>
/// The host registers this with a known file path (resolved from the host's
/// <c>ContentRootPath</c>). The file is created on first write and is gitignored
/// by the repository's <c>.gitignore</c>.
/// </remarks>
public sealed class JsonSettingsWriter : ISettingsWriter
{
private static readonly JsonSerializerOptions ReadOptions = new()
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private static readonly JsonSerializerOptions WriteOptions = new()
{
WriteIndented = true,
};
private readonly string _filePath;
private readonly SemaphoreSlim _gate = new(1, 1);
public JsonSettingsWriter(string filePath)
{
if (string.IsNullOrWhiteSpace(filePath))
{
throw new ArgumentException("Settings file path is required.", nameof(filePath));
}
_filePath = filePath;
}
public async Task SaveSectionAsync<T>(string sectionName, T values, CancellationToken cancellationToken = default)
where T : class
{
if (string.IsNullOrWhiteSpace(sectionName))
{
throw new ArgumentException("Section name is required.", nameof(sectionName));
}
ArgumentNullException.ThrowIfNull(values);
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var root = await ReadRootAsync(cancellationToken).ConfigureAwait(false);
var json = JsonSerializer.SerializeToNode(values, WriteOptions);
root[sectionName] = json;
await WriteRootAsync(root, cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
public async Task ResetSectionAsync(string sectionName, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(sectionName))
{
throw new ArgumentException("Section name is required.", nameof(sectionName));
}
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (!File.Exists(_filePath))
{
return;
}
var root = await ReadRootAsync(cancellationToken).ConfigureAwait(false);
if (root.ContainsKey(sectionName))
{
root.Remove(sectionName);
await WriteRootAsync(root, cancellationToken).ConfigureAwait(false);
}
}
finally
{
_gate.Release();
}
}
private async Task<JsonObject> ReadRootAsync(CancellationToken cancellationToken)
{
if (!File.Exists(_filePath))
{
return new JsonObject();
}
await using var stream = File.OpenRead(_filePath);
var node = await JsonNode.ParseAsync(stream, documentOptions: new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
}, cancellationToken: cancellationToken).ConfigureAwait(false);
return node as JsonObject ?? new JsonObject();
}
private async Task WriteRootAsync(JsonObject root, CancellationToken cancellationToken)
{
var directory = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
var tempPath = _filePath + ".tmp";
await using (var stream = File.Create(tempPath))
{
await JsonSerializer.SerializeAsync(stream, root, WriteOptions, cancellationToken).ConfigureAwait(false);
}
// Atomic rename — survives crashes mid-write.
File.Move(tempPath, _filePath, overwrite: true);
}
/// <summary>For tests: reads the persisted JSON object back.</summary>
public async Task<JsonObject> ReadAllAsync(CancellationToken cancellationToken = default)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
return await ReadRootAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_gate.Release();
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using System.Globalization;
namespace Marathon.UI.Services;
/// <summary>
/// Observable culture state. Components subscribe to <see cref="OnChange"/> to
/// re-render when the user toggles the locale. Setting the value also flips
/// <see cref="CultureInfo.DefaultThreadCurrentUICulture"/> so newly created
/// localizers pick up the right resource.
/// </summary>
public sealed class LocaleState
{
public const string Russian = "ru-RU";
public const string English = "en-US";
public static readonly IReadOnlyList<string> Supported = new[] { Russian, English };
private CultureInfo _culture = CultureInfo.GetCultureInfo(Russian);
public CultureInfo Culture
{
get => _culture;
private set
{
if (string.Equals(_culture.Name, value.Name, StringComparison.OrdinalIgnoreCase))
{
return;
}
_culture = value;
CultureInfo.DefaultThreadCurrentCulture = value;
CultureInfo.DefaultThreadCurrentUICulture = value;
CultureInfo.CurrentCulture = value;
CultureInfo.CurrentUICulture = value;
OnChange?.Invoke();
}
}
public event Action? OnChange;
public void Set(string cultureName)
{
if (string.IsNullOrWhiteSpace(cultureName))
{
throw new ArgumentException("Culture name is required.", nameof(cultureName));
}
Culture = CultureInfo.GetCultureInfo(cultureName);
}
}
@@ -0,0 +1,12 @@
namespace Marathon.UI.Services;
/// <summary>
/// Options bound to the <c>Localization</c> section of <c>appsettings.json</c>.
/// </summary>
public sealed class LocalizationOptions
{
public const string SectionName = "Localization";
/// <summary>The default UI culture; either <c>ru-RU</c> or <c>en-US</c>.</summary>
public string DefaultCulture { get; set; } = "ru-RU";
}
@@ -0,0 +1,51 @@
namespace Marathon.UI.Services;
/// <summary>
/// UI-side form model that mirrors <c>Marathon.Infrastructure.Configuration.ScrapingOptions</c>.
/// Kept here (not in Infrastructure) so the Razor Class Library remains
/// host-agnostic — i.e. usable from a future ASP.NET Core host that doesn't
/// reference the Infrastructure project directly.
/// </summary>
/// <remarks>
/// Property names match the JSON shape exactly so binding works either way.
/// </remarks>
public sealed class ScrapingSettingsForm
{
public const string SectionName = "Scraping";
public int PollingIntervalSeconds { get; set; } = 30;
public int MaxConcurrentRequests { get; set; } = 4;
public string[] UserAgents { get; set; } = Array.Empty<string>();
public RetryPolicyForm RetryPolicy { get; set; } = new();
public RateLimitForm RateLimit { get; set; } = new();
public bool UsePlaywright { get; set; }
public string BaseUrl { get; set; } = "https://www.marathonbet.by";
public int RequestTimeoutSeconds { get; set; } = 30;
public ScrapingSettingsForm Clone() => new()
{
PollingIntervalSeconds = PollingIntervalSeconds,
MaxConcurrentRequests = MaxConcurrentRequests,
UserAgents = (string[])UserAgents.Clone(),
RetryPolicy = new RetryPolicyForm
{
MaxAttempts = RetryPolicy.MaxAttempts,
BaseDelayMs = RetryPolicy.BaseDelayMs,
},
RateLimit = new RateLimitForm { RequestsPerSecond = RateLimit.RequestsPerSecond },
UsePlaywright = UsePlaywright,
BaseUrl = BaseUrl,
RequestTimeoutSeconds = RequestTimeoutSeconds,
};
}
public sealed class RetryPolicyForm
{
public int MaxAttempts { get; set; } = 3;
public int BaseDelayMs { get; set; } = 500;
}
public sealed class RateLimitForm
{
public int RequestsPerSecond { get; set; } = 1;
}
+31
View File
@@ -0,0 +1,31 @@
namespace Marathon.UI.Services;
/// <summary>
/// In-memory observable holding the current light/dark preference. Persisted
/// (best-effort) by the host via <see cref="ISettingsWriter"/> on change.
/// </summary>
public sealed class ThemeState
{
private bool _isDark;
public bool IsDark
{
get => _isDark;
private set
{
if (_isDark == value)
{
return;
}
_isDark = value;
OnChange?.Invoke();
}
}
public event Action? OnChange;
public void Set(bool isDark) => IsDark = isDark;
public void Toggle() => IsDark = !IsDark;
}
@@ -0,0 +1,54 @@
using Marathon.Application.Storage;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MudBlazor.Services;
namespace Marathon.UI.Services;
/// <summary>
/// DI registration helpers for the Marathon.UI Razor Class Library.
/// Hosts call <see cref="AddMarathonUi(IServiceCollection, IConfiguration, string)"/>
/// during startup.
/// </summary>
public static class UiServicesExtensions
{
/// <summary>
/// Registers MudBlazor services, localization, the theme/locale observable
/// state objects, the file-backed settings writer, and binds all
/// configuration sections that the Settings page surfaces.
/// </summary>
/// <param name="services">DI container.</param>
/// <param name="configuration">Host configuration root.</param>
/// <param name="settingsLocalPath">
/// Absolute path to <c>appsettings.Local.json</c>, used by the writer.
/// </param>
public static IServiceCollection AddMarathonUi(
this IServiceCollection services,
IConfiguration configuration,
string settingsLocalPath)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
ArgumentException.ThrowIfNullOrEmpty(settingsLocalPath);
services.AddMudServices();
services.AddLocalization(options => options.ResourcesPath = "Resources");
// Strongly typed options bound to appsettings.json sections.
services.Configure<LocalizationOptions>(configuration.GetSection(LocalizationOptions.SectionName));
services.Configure<WorkerOptions>(configuration.GetSection(WorkerOptions.SectionName));
services.Configure<AnomalyOptions>(configuration.GetSection(AnomalyOptions.SectionName));
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
services.Configure<ScrapingSettingsForm>(configuration.GetSection(ScrapingSettingsForm.SectionName));
// Singletons that drive UI chrome state.
services.AddSingleton<ThemeState>();
services.AddSingleton<LocaleState>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
return services;
}
}
+19
View File
@@ -0,0 +1,19 @@
namespace Marathon.UI.Services;
/// <summary>
/// Options bound to the <c>Workers</c> section of <c>appsettings.json</c>.
/// Phase 4 will read these to configure the background pollers.
/// </summary>
public sealed class WorkerOptions
{
public const string SectionName = "Workers";
/// <summary>Cron expression that drives the upcoming-schedule poller.</summary>
public string UpcomingScheduleCron { get; set; } = "0 */5 * * * *";
/// <summary>Whether the live odds poller should run at startup.</summary>
public bool LivePollerEnabled { get; set; } = true;
/// <summary>Whether the upcoming/pre-match poller should run at startup.</summary>
public bool UpcomingPollerEnabled { get; set; } = true;
}
+294
View File
@@ -0,0 +1,294 @@
using MudBlazor;
using MudBlazor.Utilities;
namespace Marathon.UI.Theme;
/// <summary>
/// The Marathon design system, expressed as a MudBlazor theme.
///
/// Aesthetic direction: editorial-quant. Inspired by Bloomberg terminals,
/// FT.com long-reads, and Quartz dashboards. Confident, information-dense,
/// reveals patterns. Pairs IBM Plex Sans (Cyrillic-capable display + body)
/// with JetBrains Mono for tabular numerals. Anomaly accent is a load-bearing
/// signal red so Phase 7 can hang the entire anomaly visual language off
/// <c>palette.Error</c> without coupling to a hard-coded hex.
/// </summary>
public static class MarathonTheme
{
/// <summary>The full theme — both light and dark palettes plus typography.</summary>
public static MudTheme Build() => new()
{
PaletteLight = LightPalette,
PaletteDark = DarkPalette,
Typography = MarathonTypography,
LayoutProperties = LayoutProps,
Shadows = MarathonShadows,
ZIndex = new ZIndex(),
};
// ------------------------------------------------------------------
// Palettes — single accent (amber #d97706), single signal (red #ef4444)
// on a deep navy/parchment chassis. No purple gradients, no cliche.
// ------------------------------------------------------------------
private static readonly PaletteLight LightPalette = new()
{
Primary = "#0f172a", // deep navy / ink
PrimaryContrastText = "#fafaf7",
Secondary = "#334155", // slate
SecondaryContrastText = "#fafaf7",
Tertiary = "#d97706", // amber accent
TertiaryContrastText = "#1c1917",
Info = "#0369a1",
Success = "#15803d",
Warning = "#b45309",
Error = "#dc2626", // anomaly signal
ErrorContrastText = "#fff7ed",
Black = "#1c1917",
White = "#fafaf7",
Surface = "#fafaf7", // warm parchment
Background = "#f5f4ef", // a half-step warmer than surface
BackgroundGray = "#ebe9e1",
DrawerBackground = "#0f172a", // dark drawer on light app — editorial contrast
DrawerText = "#e7e5e4",
DrawerIcon = "#d6d3d1",
AppbarBackground = "#fafaf7",
AppbarText = "#0f172a",
TextPrimary = "#0f172a",
TextSecondary = "#475569",
TextDisabled = "#94a3b8",
ActionDefault = "#334155",
ActionDisabled = "#cbd5e1",
ActionDisabledBackground = "#e2e8f0",
LinesDefault = "#e7e5e4",
LinesInputs = "#cbd5e1",
TableLines = "#e7e5e4",
TableStriped = "#f5f4ef",
TableHover = "#ebe9e1",
Divider = "#e7e5e4",
DividerLight = "#f1f5f9",
OverlayDark = new MudColor("#0f172a99").Value,
OverlayLight = new MudColor("#fafaf7cc").Value,
};
private static readonly PaletteDark DarkPalette = new()
{
Primary = "#fbbf24", // amber, promoted in dark mode
PrimaryContrastText = "#0c0a09",
Secondary = "#94a3b8",
SecondaryContrastText = "#0c0a09",
Tertiary = "#fbbf24",
TertiaryContrastText = "#0c0a09",
Info = "#38bdf8",
Success = "#4ade80",
Warning = "#fbbf24",
Error = "#f87171", // anomaly signal — softened for dark
ErrorContrastText = "#0c0a09",
Black = "#0c0a09",
White = "#fafaf7",
Surface = "#1c1917", // ink-stained paper
Background = "#0c0a09", // near-black
BackgroundGray = "#1c1917",
DrawerBackground = "#0c0a09",
DrawerText = "#e7e5e4",
DrawerIcon = "#a8a29e",
AppbarBackground = "#0c0a09",
AppbarText = "#fafaf7",
TextPrimary = "#f5f5f4",
TextSecondary = "#a8a29e",
TextDisabled = "#57534e",
ActionDefault = "#a8a29e",
ActionDisabled = "#44403c",
ActionDisabledBackground = "#1c1917",
LinesDefault = "#292524",
LinesInputs = "#44403c",
TableLines = "#292524",
TableStriped = "#1c1917",
TableHover = "#292524",
Divider = "#292524",
DividerLight = "#1c1917",
OverlayDark = new MudColor("#0c0a09cc").Value,
OverlayLight = new MudColor("#fafaf722").Value,
};
// ------------------------------------------------------------------
// Typography — IBM Plex Sans / JetBrains Mono / IBM Plex Serif (display)
// All have full Cyrillic coverage. Numerals are tabular.
// ------------------------------------------------------------------
private static readonly string[] DisplayStack = { "IBM Plex Serif", "PT Serif", "Georgia", "serif" };
private static readonly string[] BodyStack = { "IBM Plex Sans", "PT Sans", "system-ui", "sans-serif" };
private static readonly string[] MonoStack = { "JetBrains Mono", "IBM Plex Mono", "Fira Code", "Consolas", "monospace" };
private static readonly Typography MarathonTypography = new()
{
Default = new Default
{
FontFamily = BodyStack,
FontWeight = 400,
FontSize = "0.9375rem", // 15px — denser than MUD default 16
LineHeight = 1.55,
LetterSpacing = "0",
},
H1 = new H1
{
FontFamily = DisplayStack,
FontWeight = 300,
FontSize = "clamp(2.25rem, 4vw, 3.5rem)",
LineHeight = 1.05,
LetterSpacing = "-0.022em",
},
H2 = new H2
{
FontFamily = DisplayStack,
FontWeight = 400,
FontSize = "clamp(1.75rem, 2.5vw, 2.25rem)",
LineHeight = 1.15,
LetterSpacing = "-0.018em",
},
H3 = new H3
{
FontFamily = DisplayStack,
FontWeight = 500,
FontSize = "1.5rem",
LineHeight = 1.25,
LetterSpacing = "-0.012em",
},
H4 = new H4
{
FontFamily = BodyStack,
FontWeight = 600,
FontSize = "1.25rem",
LineHeight = 1.3,
LetterSpacing = "-0.005em",
},
H5 = new H5
{
FontFamily = BodyStack,
FontWeight = 600,
FontSize = "1.0625rem",
LineHeight = 1.35,
},
H6 = new H6
{
FontFamily = BodyStack,
FontWeight = 600,
FontSize = "0.9375rem",
LineHeight = 1.4,
LetterSpacing = "0.02em",
},
Subtitle1 = new Subtitle1
{
FontFamily = BodyStack,
FontWeight = 500,
FontSize = "0.9375rem",
LineHeight = 1.5,
},
Subtitle2 = new Subtitle2
{
FontFamily = BodyStack,
FontWeight = 500,
FontSize = "0.8125rem",
LineHeight = 1.5,
LetterSpacing = "0.01em",
},
Body1 = new Body1
{
FontFamily = BodyStack,
FontWeight = 400,
FontSize = "0.9375rem",
LineHeight = 1.55,
},
Body2 = new Body2
{
FontFamily = BodyStack,
FontWeight = 400,
FontSize = "0.8125rem",
LineHeight = 1.5,
},
Button = new Button
{
FontFamily = BodyStack,
FontWeight = 500,
FontSize = "0.8125rem",
LineHeight = 1.4,
LetterSpacing = "0.06em",
TextTransform = "uppercase",
},
Caption = new Caption
{
FontFamily = MonoStack,
FontWeight = 400,
FontSize = "0.75rem",
LineHeight = 1.4,
LetterSpacing = "0.04em",
TextTransform = "uppercase",
},
Overline = new Overline
{
FontFamily = MonoStack,
FontWeight = 500,
FontSize = "0.6875rem",
LineHeight = 1.4,
LetterSpacing = "0.18em",
TextTransform = "uppercase",
},
};
// ------------------------------------------------------------------
// Layout — sharp corners, narrow drawer. The aesthetic earns its
// authority through restraint.
// ------------------------------------------------------------------
private static readonly LayoutProperties LayoutProps = new()
{
DefaultBorderRadius = "2px",
AppbarHeight = "60px",
DrawerWidthLeft = "248px",
DrawerWidthRight = "248px",
DrawerMiniWidthLeft = "60px",
DrawerMiniWidthRight = "60px",
};
// ------------------------------------------------------------------
// Shadows — flat by default, one accent shadow for floating panels.
// Override only the slots Mud actually uses; keep first/last as-is.
// ------------------------------------------------------------------
private static readonly Shadow MarathonShadows = new()
{
Elevation = new[]
{
"none",
"0 1px 0 0 rgba(15,23,42,0.06)",
"0 1px 2px 0 rgba(15,23,42,0.08)",
"0 2px 4px -1px rgba(15,23,42,0.10)",
"0 4px 8px -2px rgba(15,23,42,0.12)",
"0 6px 14px -4px rgba(15,23,42,0.14)",
"0 8px 18px -6px rgba(15,23,42,0.16)",
"0 10px 22px -8px rgba(15,23,42,0.18)",
"0 12px 28px -10px rgba(15,23,42,0.20)",
"0 14px 32px -12px rgba(15,23,42,0.22)",
"0 16px 36px -14px rgba(15,23,42,0.24)",
"0 18px 40px -16px rgba(15,23,42,0.26)",
"0 20px 44px -18px rgba(15,23,42,0.28)",
"0 22px 48px -20px rgba(15,23,42,0.30)",
"0 24px 52px -22px rgba(15,23,42,0.32)",
"0 26px 56px -24px rgba(15,23,42,0.34)",
"0 28px 60px -26px rgba(15,23,42,0.36)",
"0 30px 64px -28px rgba(15,23,42,0.38)",
"0 32px 68px -30px rgba(15,23,42,0.40)",
"0 34px 72px -32px rgba(15,23,42,0.42)",
"0 36px 76px -34px rgba(15,23,42,0.44)",
"0 38px 80px -36px rgba(15,23,42,0.46)",
"0 40px 84px -38px rgba(15,23,42,0.48)",
"0 42px 88px -40px rgba(15,23,42,0.50)",
"0 44px 92px -42px rgba(15,23,42,0.52)",
"0 46px 96px -44px rgba(15,23,42,0.54)",
},
};
}
+38
View File
@@ -0,0 +1,38 @@
namespace Marathon.UI.Theme;
/// <summary>
/// Design tokens exposed to C# code (e.g. for chart colors, custom shapes,
/// Razor components that need to reach beyond the MudTheme palette).
/// Mirrors the values declared as CSS variables in <c>wwwroot/app.css</c>.
/// </summary>
public static class Tokens
{
public static class Colors
{
public const string AnomalySignal = "#dc2626";
public const string AnomalySignalDark = "#f87171";
public const string Accent = "#d97706";
public const string AccentDark = "#fbbf24";
public const string InkPrimary = "#0f172a";
public const string Parchment = "#fafaf7";
public const string ParchmentDeep = "#f5f4ef";
public const string InkDeep = "#0c0a09";
}
public static class Spacing
{
public const string Xs = "4px";
public const string Sm = "8px";
public const string Md = "16px";
public const string Lg = "24px";
public const string Xl = "40px";
public const string Xxl = "64px";
}
public static class Typography
{
public const string DisplayStack = "\"IBM Plex Serif\", \"PT Serif\", Georgia, serif";
public const string BodyStack = "\"IBM Plex Sans\", \"PT Sans\", system-ui, sans-serif";
public const string MonoStack = "\"JetBrains Mono\", \"IBM Plex Mono\", \"Fira Code\", Consolas, monospace";
}
}
+23 -1
View File
@@ -1 +1,23 @@
@using Microsoft.AspNetCore.Components.Web
@using System
@using System.Collections.Generic
@using System.Globalization
@using System.Linq
@using System.Threading
@using System.Threading.Tasks
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.Extensions.Localization
@using Microsoft.Extensions.Options
@using Microsoft.Extensions.Logging
@using MudBlazor
@using Marathon.Domain.Entities
@using Marathon.Domain.Enums
@using Marathon.Domain.ValueObjects
@using Marathon.UI
@using Marathon.UI.Components
@using Marathon.UI.Pages
@using Marathon.UI.Resources
@using Marathon.UI.Services
@using Marathon.UI.Theme
+479
View File
@@ -0,0 +1,479 @@
/* ===================================================================
Marathon — Editorial-Quant design system
------------------------------------------------------------------
Inspiration: long-form data journalism (FT, Quartz), terminal
instruments (Bloomberg), and Belarusian / Soviet print typography.
The aesthetic is confident, dense, and serif-led on display surfaces.
=================================================================== */
:root {
/* ----- Spacing scale (4-pt base, doubled at 16+) ----- */
--m-space-1: 4px;
--m-space-2: 8px;
--m-space-3: 12px;
--m-space-4: 16px;
--m-space-5: 24px;
--m-space-6: 32px;
--m-space-7: 48px;
--m-space-8: 64px;
--m-space-9: 96px;
/* ----- Radius — sharp by default, soft variants for inputs ----- */
--m-radius-sharp: 0;
--m-radius-xs: 2px;
--m-radius-sm: 4px;
--m-radius-md: 6px;
--m-radius-lg: 10px;
/* ----- Typography ----- */
--m-font-display: "IBM Plex Serif", "PT Serif", Georgia, serif;
--m-font-body: "IBM Plex Sans", "PT Sans", system-ui, sans-serif;
--m-font-mono: "JetBrains Mono", "IBM Plex Mono", "Fira Code", Consolas, monospace;
/* ----- Colors — light (parchment) chassis ----- */
--m-c-ink: #0f172a;
--m-c-ink-2: #1e293b;
--m-c-ink-soft: #475569;
--m-c-paper: #fafaf7;
--m-c-paper-2: #f5f4ef;
--m-c-paper-3: #ebe9e1;
--m-c-rule: #e7e5e4;
--m-c-accent: #d97706;
--m-c-accent-soft: #f59e0b;
--m-c-anomaly: #dc2626;
--m-c-positive: #15803d;
--m-c-info: #0369a1;
/* Tabular numerals for everywhere odds/scores appear */
--m-num-feature: "tnum" 1, "lnum" 1, "ss01" 1;
}
/* Dark theme overrides (applied via class on <html> or via MudThemeProvider) */
.mud-theme-dark, [data-theme="dark"] {
--m-c-ink: #f5f5f4;
--m-c-ink-2: #e7e5e4;
--m-c-ink-soft: #a8a29e;
--m-c-paper: #1c1917;
--m-c-paper-2: #0c0a09;
--m-c-paper-3: #292524;
--m-c-rule: #292524;
--m-c-accent: #fbbf24;
--m-c-accent-soft: #fcd34d;
--m-c-anomaly: #f87171;
--m-c-positive: #4ade80;
--m-c-info: #38bdf8;
}
/* ===================================================================
Base
=================================================================== */
html, body {
margin: 0;
padding: 0;
background: var(--m-c-paper-2);
color: var(--m-c-ink);
font-family: var(--m-font-body);
font-feature-settings: var(--m-num-feature);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
body {
/* Subtle paper grain — 1px mottled noise, rendered cheaply via SVG. */
background-image:
radial-gradient(circle at 25% 12%, rgba(217, 119, 6, 0.035), transparent 45%),
radial-gradient(circle at 88% 78%, rgba(15, 23, 42, 0.040), transparent 50%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.045 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
background-attachment: fixed;
}
.mud-theme-dark body, [data-theme="dark"] body {
background-image:
radial-gradient(circle at 25% 12%, rgba(251, 191, 36, 0.045), transparent 45%),
radial-gradient(circle at 88% 78%, rgba(56, 189, 248, 0.030), transparent 50%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='120' height='120'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.025 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ===================================================================
Numerals — always tabular for odds tables and score readouts
=================================================================== */
.m-num,
.mud-table tbody td,
.mud-data-grid tbody td,
[data-numeric] {
font-feature-settings: var(--m-num-feature);
font-variant-numeric: tabular-nums lining-nums;
}
.m-mono {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
letter-spacing: 0;
}
/* ===================================================================
Editorial markers — kicker label + serif display lockup
=================================================================== */
.m-kicker {
font-family: var(--m-font-mono);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 0.6875rem;
color: var(--m-c-accent);
font-weight: 500;
display: inline-block;
padding-bottom: var(--m-space-1);
border-bottom: 1px solid var(--m-c-accent);
}
.m-display {
font-family: var(--m-font-display);
font-weight: 300;
letter-spacing: -0.022em;
line-height: 1.05;
color: var(--m-c-ink);
}
.m-rule {
border: 0;
border-top: 1px solid var(--m-c-rule);
margin: var(--m-space-5) 0;
}
.m-rule--double {
border: 0;
border-top: 3px double var(--m-c-rule);
margin: var(--m-space-5) 0;
}
/* ===================================================================
Cards — paper-like, borders not shadows
=================================================================== */
.m-card {
background: var(--m-c-paper);
border: 1px solid var(--m-c-rule);
border-radius: var(--m-radius-xs);
padding: var(--m-space-5);
position: relative;
}
.m-card--accented {
border-left: 3px solid var(--m-c-accent);
}
.m-card--anomaly {
border-left: 3px solid var(--m-c-anomaly);
}
/* ===================================================================
Stat block — large number, mono, kicker on top
=================================================================== */
.m-stat {
display: flex;
flex-direction: column;
gap: var(--m-space-2);
}
.m-stat__value {
font-family: var(--m-font-mono);
font-feature-settings: var(--m-num-feature);
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 500;
line-height: 1;
color: var(--m-c-ink);
letter-spacing: -0.02em;
}
.m-stat__label {
font-family: var(--m-font-body);
font-size: 0.8125rem;
color: var(--m-c-ink-soft);
text-transform: none;
letter-spacing: 0;
}
.m-stat__delta {
font-family: var(--m-font-mono);
font-size: 0.75rem;
color: var(--m-c-positive);
}
.m-stat__delta--down { color: var(--m-c-anomaly); }
/* ===================================================================
Page-load reveal — one orchestrated entrance, respects motion prefs
=================================================================== */
@keyframes m-rise {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.m-rise {
animation: m-rise 480ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
.m-rise-1 { animation-delay: 40ms; }
.m-rise-2 { animation-delay: 100ms; }
.m-rise-3 { animation-delay: 180ms; }
.m-rise-4 { animation-delay: 260ms; }
.m-rise-5 { animation-delay: 340ms; }
@media (prefers-reduced-motion: reduce) {
.m-rise, .m-rise-1, .m-rise-2, .m-rise-3, .m-rise-4, .m-rise-5 {
animation: none !important;
}
}
/* ===================================================================
Focus rings — deliberate, accent, never invisible
=================================================================== */
:focus-visible {
outline: 2px solid var(--m-c-accent);
outline-offset: 2px;
}
.mud-button:focus-visible,
.mud-icon-button:focus-visible {
outline: 2px solid var(--m-c-accent);
outline-offset: 2px;
}
/* ===================================================================
Layout primitives — asymmetric content grid
=================================================================== */
.m-shell {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--m-space-5);
padding: var(--m-space-5) clamp(var(--m-space-4), 4vw, var(--m-space-7));
max-width: 1480px;
width: 100%;
margin: 0 auto;
}
.m-grid--asym {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
gap: var(--m-space-5);
}
@media (max-width: 960px) {
.m-grid--asym { grid-template-columns: 1fr; }
}
.m-grid--three {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--m-space-4);
}
/* ===================================================================
AppBar wordmark + dateline
=================================================================== */
.m-brand {
display: flex;
align-items: baseline;
gap: var(--m-space-3);
}
.m-brand__mark {
font-family: var(--m-font-display);
font-weight: 500;
font-size: 1.375rem;
letter-spacing: -0.02em;
line-height: 1;
}
.m-brand__mark::first-letter {
color: var(--m-c-accent);
}
.m-brand__dateline {
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--m-c-ink-soft);
border-left: 1px solid var(--m-c-rule);
padding-left: var(--m-space-3);
}
/* ===================================================================
Drawer — narrow, dark, mono labels
=================================================================== */
.m-nav__group {
padding: var(--m-space-3) var(--m-space-4);
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.18em;
color: rgba(231, 229, 228, 0.55);
}
.m-nav__link {
display: flex;
align-items: center;
gap: var(--m-space-3);
padding: var(--m-space-3) var(--m-space-4);
color: rgba(231, 229, 228, 0.85);
text-decoration: none;
font-size: 0.9375rem;
border-left: 2px solid transparent;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
}
.m-nav__link:hover {
background: rgba(217, 119, 6, 0.10);
color: #ffffff;
}
.m-nav__link.active {
color: #ffffff;
background: rgba(217, 119, 6, 0.14);
border-left-color: var(--m-c-accent);
}
.m-nav__link .mud-icon-root { font-size: 1.1rem; }
/* ===================================================================
Locale switcher — segmented control
=================================================================== */
.m-segmented {
display: inline-flex;
border: 1px solid var(--m-c-rule);
border-radius: var(--m-radius-xs);
overflow: hidden;
background: var(--m-c-paper);
}
.m-segmented__btn {
appearance: none;
border: 0;
background: transparent;
padding: 6px 12px;
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--m-c-ink-soft);
cursor: pointer;
transition: background 120ms ease, color 120ms ease;
}
.m-segmented__btn + .m-segmented__btn {
border-left: 1px solid var(--m-c-rule);
}
.m-segmented__btn:hover {
color: var(--m-c-ink);
}
.m-segmented__btn.is-active {
background: var(--m-c-ink);
color: var(--m-c-paper);
}
.mud-theme-dark .m-segmented__btn.is-active,
[data-theme="dark"] .m-segmented__btn.is-active {
background: var(--m-c-accent);
color: var(--m-c-paper-2);
}
/* ===================================================================
Settings page — section ledger
=================================================================== */
.m-section {
border: 1px solid var(--m-c-rule);
background: var(--m-c-paper);
margin-bottom: var(--m-space-5);
}
.m-section__head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: var(--m-space-4) var(--m-space-5);
border-bottom: 1px solid var(--m-c-rule);
background: var(--m-c-paper-2);
}
.m-section__head h2 {
margin: 0;
font-family: var(--m-font-display);
font-weight: 400;
font-size: 1.25rem;
letter-spacing: -0.012em;
}
.m-section__body {
padding: var(--m-space-5);
display: grid;
gap: var(--m-space-4);
}
.m-field-row {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
align-items: start;
gap: var(--m-space-4);
}
@media (max-width: 720px) {
.m-field-row { grid-template-columns: 1fr; }
}
.m-field-row__hint {
font-family: var(--m-font-mono);
font-size: 0.75rem;
color: var(--m-c-ink-soft);
line-height: 1.4;
}
/* ===================================================================
Anomaly badge — load-bearing for Phase 7
=================================================================== */
.m-anomaly {
display: inline-flex;
align-items: center;
gap: var(--m-space-2);
padding: 2px 8px;
background: rgba(220, 38, 38, 0.10);
color: var(--m-c-anomaly);
border: 1px solid currentColor;
border-radius: var(--m-radius-xs);
font-family: var(--m-font-mono);
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.m-anomaly__pulse {
width: 6px;
height: 6px;
background: currentColor;
border-radius: 50%;
animation: m-pulse 1.6s ease-in-out infinite;
}
@keyframes m-pulse {
0%, 100% { opacity: 0.4; transform: scale(1); }
50% { opacity: 1; transform: scale(1.2); }
}
@media (prefers-reduced-motion: reduce) {
.m-anomaly__pulse { animation: none; opacity: 1; }
}

Some files were not shown because too many files have changed in this diff Show More