< Summary

Information
Class: Orchestrator.Commands.Utility.Snapshots.SnapshotClient
Assembly: Orchestrator
File(s): /home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Utility/Snapshots/SnapshotClient.cs
Line coverage
89%
Covered lines: 95
Uncovered lines: 11
Coverable lines: 106
Total lines: 271
Line coverage: 89.6%
Branch coverage
91%
Covered branches: 31
Total branches: 34
Branch coverage: 91.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
FetchLoginPageAsync()100%11100%
FetchStandingsPageAsync()100%11100%
FetchTippabgabePageAsync()100%11100%
FetchBonusPageAsync()100%11100%
FetchAllSpielinfoAsync()100%11100%
FetchAllSpielinfoHomeAwayAsync()100%11100%
FetchAllSpielinfoHeadToHeadAsync()100%11100%
FetchAllSpielinfoVariantAsync()95%202089.58%
ApplyAnsichtParam(...)75%44100%
FetchPageAsync()100%2275%
FindNextMatchLink(...)87.5%8882.35%

File(s)

/home/runner/work/KicktippAi/KicktippAi/src/Orchestrator/Commands/Utility/Snapshots/SnapshotClient.cs

#LineLine coverage
 1using AngleSharp;
 2using AngleSharp.Dom;
 3using Microsoft.Extensions.Logging;
 4
 5namespace Orchestrator.Commands.Utility.Snapshots;
 6
 7/// <summary>
 8/// A simple HTTP client for fetching HTML snapshots from Kicktipp.
 9/// This client is specifically for snapshot generation and does not parse the HTML.
 10/// </summary>
 11public class SnapshotClient : ISnapshotClient
 12{
 13    private readonly HttpClient _httpClient;
 14    private readonly ILogger _logger;
 15    private readonly IBrowsingContext _browsingContext;
 16
 117    public SnapshotClient(HttpClient httpClient, ILogger logger)
 18    {
 119        _httpClient = httpClient;
 120        _logger = logger;
 121        var config = Configuration.Default.WithDefaultLoader();
 122        _browsingContext = BrowsingContext.New(config);
 123    }
 24
 25    /// <summary>
 26    /// Fetches the login page.
 27    /// Note: This is fetched without authentication to capture the login form structure.
 28    /// </summary>
 29    public async Task<string?> FetchLoginPageAsync()
 30    {
 131        var url = "info/profil/login";
 132        return await FetchPageAsync(url, "login");
 133    }
 34
 35    /// <summary>
 36    /// Fetches the standings page (tabellen).
 37    /// </summary>
 38    public async Task<string?> FetchStandingsPageAsync(string community)
 39    {
 140        var url = $"{community}/tabellen";
 141        return await FetchPageAsync(url, "tabellen");
 142    }
 43
 44    /// <summary>
 45    /// Fetches the main betting page (tippabgabe).
 46    /// </summary>
 47    public async Task<string?> FetchTippabgabePageAsync(string community)
 48    {
 149        var url = $"{community}/tippabgabe";
 150        return await FetchPageAsync(url, "tippabgabe");
 151    }
 52
 53    /// <summary>
 54    /// Fetches the bonus questions page (tippabgabe?bonus=true).
 55    /// </summary>
 56    public async Task<string?> FetchBonusPageAsync(string community)
 57    {
 158        var url = $"{community}/tippabgabe?bonus=true";
 159        return await FetchPageAsync(url, "tippabgabe-bonus");
 160    }
 61
 62    /// <summary>
 63    /// Fetches all spielinfo pages (default view) by traversing through them.
 64    /// Returns a list of (fileName, content) tuples.
 65    /// </summary>
 66    public async Task<List<(string fileName, string content)>> FetchAllSpielinfoAsync(string community)
 67    {
 168        return await FetchAllSpielinfoVariantAsync(community, fileNameSuffix: null, ansichtParam: null);
 169    }
 70
 71    /// <summary>
 72    /// Fetches all spielinfo pages with home/away history (ansicht=2) by traversing through them.
 73    /// Returns a list of (fileName, content) tuples with "-homeaway" suffix.
 74    /// </summary>
 75    public async Task<List<(string fileName, string content)>> FetchAllSpielinfoHomeAwayAsync(string community)
 76    {
 177        return await FetchAllSpielinfoVariantAsync(community, fileNameSuffix: "-homeaway", ansichtParam: "2");
 178    }
 79
 80    /// <summary>
 81    /// Fetches all spielinfo pages with head-to-head history (ansicht=3) by traversing through them.
 82    /// Returns a list of (fileName, content) tuples with "-h2h" suffix.
 83    /// </summary>
 84    public async Task<List<(string fileName, string content)>> FetchAllSpielinfoHeadToHeadAsync(string community)
 85    {
 186        return await FetchAllSpielinfoVariantAsync(community, fileNameSuffix: "-h2h", ansichtParam: "3");
 187    }
 88
 89    /// <summary>
 90    /// Fetches all spielinfo pages with an optional ansicht parameter.
 91    /// </summary>
 92    /// <param name="community">The community name.</param>
 93    /// <param name="fileNameSuffix">Optional suffix to append to file names (e.g., "-homeaway", "-h2h").</param>
 94    /// <param name="ansichtParam">Optional ansicht parameter value (e.g., "2" for home/away, "3" for head-to-head).</pa
 95    private async Task<List<(string fileName, string content)>> FetchAllSpielinfoVariantAsync(
 96        string community, string? fileNameSuffix, string? ansichtParam)
 97    {
 198        var results = new List<(string fileName, string content)>();
 99
 100        // First, get the tippabgabe page to find the link to spielinfos
 1101        var tippabgabeUrl = $"{community}/tippabgabe";
 1102        var response = await _httpClient.GetAsync(tippabgabeUrl);
 103
 1104        if (!response.IsSuccessStatusCode)
 105        {
 1106            _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 1107            return results;
 108        }
 109
 1110        var content = await response.Content.ReadAsStringAsync();
 1111        var document = await _browsingContext.OpenAsync(req => req.Content(content));
 112
 113        // Find the "Tippabgabe mit Spielinfos" link
 1114        var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 1115        if (spielinfoLink == null)
 116        {
 1117            _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 1118            return results;
 119        }
 120
 1121        var spielinfoUrl = spielinfoLink.GetAttribute("href");
 1122        if (string.IsNullOrEmpty(spielinfoUrl))
 123        {
 0124            _logger.LogWarning("Spielinfo link has no href attribute");
 0125            return results;
 126        }
 127
 128        // Make URL absolute if it's relative
 1129        if (spielinfoUrl.StartsWith("/"))
 130        {
 1131            spielinfoUrl = spielinfoUrl.Substring(1);
 132        }
 133
 1134        var variantDescription = ansichtParam != null ? $" (ansicht={ansichtParam})" : "";
 1135        _logger.LogInformation("Starting to fetch spielinfo pages{Variant}...", variantDescription);
 136
 137        // Navigate through all matches using the right arrow navigation
 1138        var currentUrl = spielinfoUrl;
 1139        var matchCount = 0;
 140
 1141        while (!string.IsNullOrEmpty(currentUrl))
 142        {
 143            try
 144            {
 145                // Apply ansicht parameter if specified
 1146                var fetchUrl = ApplyAnsichtParam(currentUrl, ansichtParam);
 147
 1148                var spielinfoResponse = await _httpClient.GetAsync(fetchUrl);
 1149                if (!spielinfoResponse.IsSuccessStatusCode)
 150                {
 1151                    _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}",
 1152                        fetchUrl, spielinfoResponse.StatusCode);
 1153                    break;
 154                }
 155
 1156                var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 1157                matchCount++;
 158
 159                // Generate filename from URL or index, with optional suffix
 1160                var fileName = $"spielinfo-{matchCount:D2}{fileNameSuffix ?? ""}";
 1161                results.Add((fileName, spielinfoContent));
 1162                _logger.LogDebug("Fetched spielinfo page {Count}: {Url}", matchCount, fetchUrl);
 163
 164                // Parse to find next link (use the content without ansicht param for navigation)
 1165                var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 1166                var nextLink = FindNextMatchLink(spielinfoDocument);
 167
 1168                if (nextLink != null)
 169                {
 1170                    currentUrl = nextLink;
 1171                    if (currentUrl.StartsWith("/"))
 172                    {
 1173                        currentUrl = currentUrl.Substring(1);
 174                    }
 175                }
 176                else
 177                {
 178                    // No more matches
 1179                    break;
 180                }
 1181            }
 0182            catch (Exception ex)
 183            {
 0184                _logger.LogError(ex, "Error fetching spielinfo page: {Url}", currentUrl);
 0185                break;
 186            }
 187        }
 188
 1189        _logger.LogInformation("Fetched {Count} spielinfo pages{Variant}", results.Count, variantDescription);
 1190        return results;
 1191    }
 192
 193    /// <summary>
 194    /// Applies the ansicht query parameter to a URL.
 195    /// </summary>
 196    private static string ApplyAnsichtParam(string url, string? ansichtParam)
 197    {
 1198        if (string.IsNullOrEmpty(ansichtParam))
 199        {
 1200            return url;
 201        }
 202
 1203        return url.Contains('?')
 1204            ? $"{url}&ansicht={ansichtParam}"
 1205            : $"{url}?ansicht={ansichtParam}";
 206    }
 207
 208    private async Task<string?> FetchPageAsync(string url, string pageName)
 209    {
 210        try
 211        {
 1212            _logger.LogDebug("Fetching {PageName} from {Url}", pageName, url);
 1213            var response = await _httpClient.GetAsync(url);
 214
 1215            if (!response.IsSuccessStatusCode)
 216            {
 1217                _logger.LogError("Failed to fetch {PageName}. Status: {StatusCode}", pageName, response.StatusCode);
 1218                return null;
 219            }
 220
 1221            var content = await response.Content.ReadAsStringAsync();
 1222            _logger.LogDebug("Successfully fetched {PageName} ({Length} bytes)", pageName, content.Length);
 1223            return content;
 224        }
 0225        catch (Exception ex)
 226        {
 0227            _logger.LogError(ex, "Exception fetching {PageName}", pageName);
 0228            return null;
 229        }
 1230    }
 231
 232    /// <summary>
 233    /// Finds the next match link (right arrow navigation) on a spielinfo page.
 234    /// This mirrors the logic in KicktippClient.FindNextMatchLink.
 235    /// </summary>
 236    private string? FindNextMatchLink(IDocument document)
 237    {
 238        try
 239        {
 240            // Look for the right arrow button in the match navigation
 1241            var nextButton = document.QuerySelector(".prevnextNext a");
 1242            if (nextButton == null)
 243            {
 1244                _logger.LogDebug("No next match button found");
 1245                return null;
 246            }
 247
 248            // Check if the button is disabled
 1249            var parentDiv = nextButton.ParentElement;
 1250            if (parentDiv?.ClassList.Contains("disabled") == true)
 251            {
 1252                _logger.LogDebug("Next match button is disabled - reached end of matches");
 1253                return null;
 254            }
 255
 1256            var href = nextButton.GetAttribute("href");
 1257            if (string.IsNullOrEmpty(href))
 258            {
 1259                _logger.LogDebug("Next match button has no href");
 1260                return null;
 261            }
 262
 1263            return href;
 264        }
 0265        catch (Exception ex)
 266        {
 0267            _logger.LogDebug(ex, "Error finding next match link");
 0268            return null;
 269        }
 1270    }
 271}