Skip to content

Commit 68896fe

Browse files
author
Andrii Bondarchuk
committed
Add ZwiftFRRRidersVELORatingHttpTriggerFunction function
1 parent eec7ce0 commit 68896fe

File tree

4 files changed

+260
-0
lines changed

4 files changed

+260
-0
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
using FitSyncHub.Common.Extensions;
2+
using FitSyncHub.Zwift.HttpClients;
3+
using FitSyncHub.Zwift.HttpClients.Models.Responses.ZwiftRacing;
4+
using Microsoft.AspNetCore.Http;
5+
using Microsoft.AspNetCore.Mvc;
6+
using Microsoft.Azure.Functions.Worker;
7+
8+
namespace FitSyncHub.Functions.Functions;
9+
10+
public sealed class ZwiftFRRRidersVELORatingHttpTriggerFunction
11+
{
12+
private readonly FlammeRougeRacingHttpClient _flammeRougeRacingHttpClient;
13+
private readonly ZwiftHttpClient _zwiftHttpClient;
14+
private readonly ZwiftRacingHttpClient _zwiftRacingHttpClient;
15+
16+
public ZwiftFRRRidersVELORatingHttpTriggerFunction(
17+
FlammeRougeRacingHttpClient flammeRougeRacingHttpClient,
18+
ZwiftHttpClient zwiftHttpClient,
19+
ZwiftRacingHttpClient zwiftRacingHttpClient)
20+
{
21+
_flammeRougeRacingHttpClient = flammeRougeRacingHttpClient;
22+
_zwiftHttpClient = zwiftHttpClient;
23+
_zwiftRacingHttpClient = zwiftRacingHttpClient;
24+
}
25+
26+
#if DEBUG
27+
[Function(nameof(ZwiftFRRRidersVELORatingHttpTriggerFunction))]
28+
#endif
29+
public async Task<IActionResult> Run(
30+
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "zwift-frr-tour-vELO-rating")] HttpRequest req,
31+
CancellationToken cancellationToken)
32+
{
33+
string? cookie = req.Query["cookie"];
34+
string? category = req.Query["category"];
35+
36+
if (string.IsNullOrWhiteSpace(cookie)
37+
|| string.IsNullOrWhiteSpace(category))
38+
{
39+
return new BadRequestObjectResult($"Specify params: {nameof(cookie)}, {nameof(category)}");
40+
}
41+
42+
if (!Enum.TryParse<FlammeRougeRacingCategory>(category, ignoreCase: true, out var parsedFRRCategory))
43+
{
44+
return new BadRequestObjectResult($"Cannot parse FRR category");
45+
}
46+
47+
var riders = await _flammeRougeRacingHttpClient
48+
.GetTourRegisteredRiders(parsedFRRCategory, cancellationToken);
49+
50+
var result = await GetRidersVELO(riders, cancellationToken);
51+
result = [.. result.OrderByDescending(x => x.MaxVELO)];
52+
53+
return new OkObjectResult(result);
54+
}
55+
56+
private async Task<List<ZwiftEventVELORatingResponseItem>> GetRidersVELO(
57+
IReadOnlyCollection<long> riderIds,
58+
CancellationToken cancellationToken)
59+
{
60+
var year = DateTime.UtcNow.Year;
61+
62+
List<ZwiftEventVELORatingResponseItem> items = [];
63+
foreach (var riderId in riderIds)
64+
{
65+
var historyTask = _zwiftRacingHttpClient
66+
.GetRiderHistory(riderId, year: year, cancellationToken);
67+
var riderTask = _zwiftHttpClient.GetProfile(riderId, cancellationToken);
68+
69+
await Task.WhenAll(historyTask, riderTask);
70+
71+
var history = await historyTask;
72+
var rider = await riderTask;
73+
74+
var maxVelo = history?.History.Max(x => x.Rating);
75+
var minVelo = history?.History.Min(x => x.Rating);
76+
var velo = history?.History
77+
.OrderByDescending(x => x.UpdatedAt)
78+
.FirstOrDefault()?.Rating;
79+
80+
var weigth = rider.WeightInGrams / 1000.0;
81+
var height = rider.HeightInMillimeters / 1000.0;
82+
83+
var ftpPerKg = rider.Ftp / weigth;
84+
85+
items.Add(new ZwiftEventVELORatingResponseItem
86+
{
87+
Id = rider.Id,
88+
FirstName = rider.FirstName,
89+
LastName = rider.LastName,
90+
Age = rider.Age,
91+
Weight = weigth,
92+
Height = height,
93+
FtpPerKg = ftpPerKg,
94+
Best5Sec = GetWkgValue(history, x => x.Wkg5),
95+
Best15Sec = GetWkgValue(history, x => x.Wkg15),
96+
Best30Sec = GetWkgValue(history, x => x.Wkg30),
97+
Best1Min = GetWkgValue(history, x => x.Wkg60),
98+
Best2Min = GetWkgValue(history, x => x.Wkg120),
99+
Best5Min = GetWkgValue(history, x => x.Wkg300),
100+
Best20Min = GetWkgValue(history, x => x.Wkg1200),
101+
MaxVELO = maxVelo,
102+
MinVELO = minVelo,
103+
VELO = velo,
104+
});
105+
}
106+
107+
return items;
108+
}
109+
110+
private static double? GetWkgValue(
111+
ZwiftRacingRiderResponse? history,
112+
Func<ZwiftRacingHistoryEntry, double?> wkgSelector)
113+
{
114+
if (history == null)
115+
{
116+
return default;
117+
}
118+
119+
return history.History
120+
.Select(wkgSelector)
121+
.WhereNotNull()
122+
.OrderByDescending(x => x)
123+
.FirstOrNull();
124+
}
125+
}

src/FitSyncHub.Zwift/FitSyncHub.Zwift.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<PrivateAssets>all</PrivateAssets>
1313
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1414
</PackageReference>
15+
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
1516
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.3.0" />
1617
<PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.1" />
1718
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.1" />
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using System.Text.Json;
2+
using HtmlAgilityPack;
3+
using Microsoft.AspNetCore.WebUtilities;
4+
using Microsoft.Extensions.Primitives;
5+
6+
namespace FitSyncHub.Zwift.HttpClients;
7+
8+
public enum FlammeRougeRacingCategory
9+
{
10+
CAP,
11+
DRA,
12+
CRP,
13+
GHT,
14+
HAB,
15+
BON,
16+
CAY,
17+
JLP,
18+
PEP,
19+
BEL,
20+
}
21+
22+
public class FlammeRougeRacingHttpClient
23+
{
24+
private readonly HttpClient _httpClient;
25+
26+
public FlammeRougeRacingHttpClient(HttpClient httpClient)
27+
{
28+
_httpClient = httpClient;
29+
}
30+
31+
public async Task<List<long>> GetTourRegisteredRiders(FlammeRougeRacingCategory flammeRougeRacingCategory, CancellationToken cancellationToken)
32+
{
33+
const string BaseUrl = "https://flammerougeracing.com/wp-admin/admin-ajax.php";
34+
const int TableId = 105;
35+
36+
var url = QueryHelpers.AddQueryString(BaseUrl, new Dictionary<string, StringValues>
37+
{
38+
{"action", "get_wdtable"},
39+
{"table_id", TableId.ToString() },
40+
});
41+
42+
var wdtNonce = await GetWdtNonce(TableId, cancellationToken);
43+
44+
var start = 0;
45+
const int Page = 25;
46+
List<long> riderIds = [];
47+
48+
while (true)
49+
{
50+
var formData = new Dictionary<string, string>()
51+
{
52+
{"draw", "3" },
53+
{"columns[4][data]", "4" },
54+
{"columns[4][name]", "RACINGFRHC" },
55+
{"columns[4][searchable]", "true" },
56+
{"columns[4][orderable]", "false" },
57+
{"columns[4][search][value]", flammeRougeRacingCategory.ToString() },
58+
{"columns[4][search][regex]", "true" },
59+
{"start", $"{start}" },
60+
{"length", $"{Page}" },
61+
{"wdtNonce", wdtNonce },
62+
};
63+
64+
var dataContent = new FormUrlEncodedContent(formData);
65+
var response = await _httpClient.PostAsync(url, dataContent, cancellationToken);
66+
67+
var content = await response.Content.ReadAsStringAsync(cancellationToken);
68+
var jsonDocument = JsonDocument.Parse(content);
69+
var ridersData = jsonDocument.RootElement.GetProperty("data");
70+
71+
foreach (var riderData in ridersData.EnumerateArray())
72+
{
73+
var zwiftPowerAnchorElement = riderData.EnumerateArray().ToArray()[2].GetString()!;
74+
75+
var doc = new HtmlDocument();
76+
doc.LoadHtml(zwiftPowerAnchorElement);
77+
78+
// select the <a> element
79+
var linkNode = doc.DocumentNode.SelectSingleNode("//a");
80+
81+
var href = linkNode.GetAttributeValue("href", "");
82+
83+
// parse the "z" parameter
84+
var uri = new Uri(href);
85+
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
86+
87+
var profileId = query["z"];
88+
if (string.IsNullOrWhiteSpace(profileId) || !long.TryParse(profileId, out var parserProfileId))
89+
{
90+
throw new InvalidOperationException("can't parse profileId");
91+
}
92+
93+
riderIds.Add(long.Parse(profileId));
94+
}
95+
96+
if (ridersData.GetArrayLength() % Page != 0)
97+
{
98+
break;
99+
}
100+
101+
start += Page;
102+
}
103+
104+
return riderIds;
105+
}
106+
107+
private async Task<string> GetWdtNonce(int tableId, CancellationToken cancellationToken)
108+
{
109+
const string Url = "https://flammerougeracing.com/tour-registered/";
110+
var response = await _httpClient.GetAsync(Url, cancellationToken);
111+
response.EnsureSuccessStatusCode();
112+
113+
var content = await response.Content.ReadAsStringAsync(cancellationToken);
114+
115+
var doc = new HtmlDocument();
116+
doc.LoadHtml(content);
117+
118+
// Build full id pattern: wdtNonceFrontendServerSide_SUFFIX
119+
var id = $"wdtNonceFrontendServerSide_{tableId}";
120+
121+
var node = doc.DocumentNode
122+
.SelectSingleNode($"//input[@id='{id}']");
123+
124+
var nonce = node?.GetAttributeValue("value", null!);
125+
if (string.IsNullOrWhiteSpace(nonce))
126+
{
127+
throw new InvalidOperationException("wdtNonce is null or empty");
128+
}
129+
130+
return nonce;
131+
}
132+
}

src/FitSyncHub.Zwift/ZwiftModule.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ public IServiceCollection ConfigureZwiftModule(string zwiftAuthOptionsPath)
6060
services.AddHttpClient<ZwiftHttpClientUnauthorized>();
6161
ConfigureZwiftHttpClient(services);
6262

63+
services.AddHttpClient<FlammeRougeRacingHttpClient>();
64+
6365
services.AddTransient<ZwiftRacingAuthDelegatingHandler>();
6466
services.AddHttpClient<ZwiftRacingHttpClient>(client => client.BaseAddress = new Uri("https://www.zwiftracing.app"))
6567
.AddHttpMessageHandler<ZwiftRacingAuthDelegatingHandler>();

0 commit comments

Comments
 (0)