< 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
0%
Covered lines: 0
Uncovered lines: 106
Coverable lines: 106
Total lines: 271
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 34
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

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
 12{
 13    private readonly HttpClient _httpClient;
 14    private readonly ILogger _logger;
 15    private readonly IBrowsingContext _browsingContext;
 16
 017    public SnapshotClient(HttpClient httpClient, ILogger logger)
 18    {
 019        _httpClient = httpClient;
 020        _logger = logger;
 021        var config = Configuration.Default.WithDefaultLoader();
 022        _browsingContext = BrowsingContext.New(config);
 023    }
 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    {
 031        var url = "info/profil/login";
 032        return await FetchPageAsync(url, "login");
 033    }
 34
 35    /// <summary>
 36    /// Fetches the standings page (tabellen).
 37    /// </summary>
 38    public async Task<string?> FetchStandingsPageAsync(string community)
 39    {
 040        var url = $"{community}/tabellen";
 041        return await FetchPageAsync(url, "tabellen");
 042    }
 43
 44    /// <summary>
 45    /// Fetches the main betting page (tippabgabe).
 46    /// </summary>
 47    public async Task<string?> FetchTippabgabePageAsync(string community)
 48    {
 049        var url = $"{community}/tippabgabe";
 050        return await FetchPageAsync(url, "tippabgabe");
 051    }
 52
 53    /// <summary>
 54    /// Fetches the bonus questions page (tippabgabe?bonus=true).
 55    /// </summary>
 56    public async Task<string?> FetchBonusPageAsync(string community)
 57    {
 058        var url = $"{community}/tippabgabe?bonus=true";
 059        return await FetchPageAsync(url, "tippabgabe-bonus");
 060    }
 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    {
 068        return await FetchAllSpielinfoVariantAsync(community, fileNameSuffix: null, ansichtParam: null);
 069    }
 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    {
 077        return await FetchAllSpielinfoVariantAsync(community, fileNameSuffix: "-homeaway", ansichtParam: "2");
 078    }
 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    {
 086        return await FetchAllSpielinfoVariantAsync(community, fileNameSuffix: "-h2h", ansichtParam: "3");
 087    }
 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    {
 098        var results = new List<(string fileName, string content)>();
 99
 100        // First, get the tippabgabe page to find the link to spielinfos
 0101        var tippabgabeUrl = $"{community}/tippabgabe";
 0102        var response = await _httpClient.GetAsync(tippabgabeUrl);
 103
 0104        if (!response.IsSuccessStatusCode)
 105        {
 0106            _logger.LogError("Failed to fetch tippabgabe page. Status: {StatusCode}", response.StatusCode);
 0107            return results;
 108        }
 109
 0110        var content = await response.Content.ReadAsStringAsync();
 0111        var document = await _browsingContext.OpenAsync(req => req.Content(content));
 112
 113        // Find the "Tippabgabe mit Spielinfos" link
 0114        var spielinfoLink = document.QuerySelector("a[href*='spielinfo']");
 0115        if (spielinfoLink == null)
 116        {
 0117            _logger.LogWarning("Could not find Spielinfo link on tippabgabe page");
 0118            return results;
 119        }
 120
 0121        var spielinfoUrl = spielinfoLink.GetAttribute("href");
 0122        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
 0129        if (spielinfoUrl.StartsWith("/"))
 130        {
 0131            spielinfoUrl = spielinfoUrl.Substring(1);
 132        }
 133
 0134        var variantDescription = ansichtParam != null ? $" (ansicht={ansichtParam})" : "";
 0135        _logger.LogInformation("Starting to fetch spielinfo pages{Variant}...", variantDescription);
 136
 137        // Navigate through all matches using the right arrow navigation
 0138        var currentUrl = spielinfoUrl;
 0139        var matchCount = 0;
 140
 0141        while (!string.IsNullOrEmpty(currentUrl))
 142        {
 143            try
 144            {
 145                // Apply ansicht parameter if specified
 0146                var fetchUrl = ApplyAnsichtParam(currentUrl, ansichtParam);
 147
 0148                var spielinfoResponse = await _httpClient.GetAsync(fetchUrl);
 0149                if (!spielinfoResponse.IsSuccessStatusCode)
 150                {
 0151                    _logger.LogWarning("Failed to fetch spielinfo page: {Url}. Status: {StatusCode}",
 0152                        fetchUrl, spielinfoResponse.StatusCode);
 0153                    break;
 154                }
 155
 0156                var spielinfoContent = await spielinfoResponse.Content.ReadAsStringAsync();
 0157                matchCount++;
 158
 159                // Generate filename from URL or index, with optional suffix
 0160                var fileName = $"spielinfo-{matchCount:D2}{fileNameSuffix ?? ""}";
 0161                results.Add((fileName, spielinfoContent));
 0162                _logger.LogDebug("Fetched spielinfo page {Count}: {Url}", matchCount, fetchUrl);
 163
 164                // Parse to find next link (use the content without ansicht param for navigation)
 0165                var spielinfoDocument = await _browsingContext.OpenAsync(req => req.Content(spielinfoContent));
 0166                var nextLink = FindNextMatchLink(spielinfoDocument);
 167
 0168                if (nextLink != null)
 169                {
 0170                    currentUrl = nextLink;
 0171                    if (currentUrl.StartsWith("/"))
 172                    {
 0173                        currentUrl = currentUrl.Substring(1);
 174                    }
 175                }
 176                else
 177                {
 178                    // No more matches
 0179                    break;
 180                }
 0181            }
 0182            catch (Exception ex)
 183            {
 0184                _logger.LogError(ex, "Error fetching spielinfo page: {Url}", currentUrl);
 0185                break;
 186            }
 187        }
 188
 0189        _logger.LogInformation("Fetched {Count} spielinfo pages{Variant}", results.Count, variantDescription);
 0190        return results;
 0191    }
 192
 193    /// <summary>
 194    /// Applies the ansicht query parameter to a URL.
 195    /// </summary>
 196    private static string ApplyAnsichtParam(string url, string? ansichtParam)
 197    {
 0198        if (string.IsNullOrEmpty(ansichtParam))
 199        {
 0200            return url;
 201        }
 202
 0203        return url.Contains('?')
 0204            ? $"{url}&ansicht={ansichtParam}"
 0205            : $"{url}?ansicht={ansichtParam}";
 206    }
 207
 208    private async Task<string?> FetchPageAsync(string url, string pageName)
 209    {
 210        try
 211        {
 0212            _logger.LogDebug("Fetching {PageName} from {Url}", pageName, url);
 0213            var response = await _httpClient.GetAsync(url);
 214
 0215            if (!response.IsSuccessStatusCode)
 216            {
 0217                _logger.LogError("Failed to fetch {PageName}. Status: {StatusCode}", pageName, response.StatusCode);
 0218                return null;
 219            }
 220
 0221            var content = await response.Content.ReadAsStringAsync();
 0222            _logger.LogDebug("Successfully fetched {PageName} ({Length} bytes)", pageName, content.Length);
 0223            return content;
 224        }
 0225        catch (Exception ex)
 226        {
 0227            _logger.LogError(ex, "Exception fetching {PageName}", pageName);
 0228            return null;
 229        }
 0230    }
 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
 0241            var nextButton = document.QuerySelector(".prevnextNext a");
 0242            if (nextButton == null)
 243            {
 0244                _logger.LogDebug("No next match button found");
 0245                return null;
 246            }
 247
 248            // Check if the button is disabled
 0249            var parentDiv = nextButton.ParentElement;
 0250            if (parentDiv?.ClassList.Contains("disabled") == true)
 251            {
 0252                _logger.LogDebug("Next match button is disabled - reached end of matches");
 0253                return null;
 254            }
 255
 0256            var href = nextButton.GetAttribute("href");
 0257            if (string.IsNullOrEmpty(href))
 258            {
 0259                _logger.LogDebug("Next match button has no href");
 0260                return null;
 261            }
 262
 0263            return href;
 264        }
 0265        catch (Exception ex)
 266        {
 0267            _logger.LogDebug(ex, "Error finding next match link");
 0268            return null;
 269        }
 0270    }
 271}