mirror of
https://github.com/XFox111/bonch-calendar.git
synced 2026-06-30 10:52:41 +03:00
docs: more code comments and minor style refactoring
This commit is contained in:
@@ -3,6 +3,9 @@ using BonchCalendar.Health;
|
|||||||
|
|
||||||
namespace BonchCalendar;
|
namespace BonchCalendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Custom JSON serializer context for static serialization/deserialization of requests and responses
|
||||||
|
/// </summary>
|
||||||
[JsonSerializable(typeof(string))]
|
[JsonSerializable(typeof(string))]
|
||||||
[JsonSerializable(typeof(string[]))]
|
[JsonSerializable(typeof(string[]))]
|
||||||
[JsonSerializable(typeof(int))]
|
[JsonSerializable(typeof(int))]
|
||||||
|
|||||||
@@ -24,13 +24,11 @@ Accept: application/json
|
|||||||
|
|
||||||
@groupId = 56606
|
@groupId = 56606
|
||||||
@facultyId = 50029
|
@facultyId = 50029
|
||||||
# See remark for id
|
|
||||||
@id = download
|
|
||||||
GET {{Host}}/timetable/{{facultyId}}/{{groupId}}
|
GET {{Host}}/timetable/{{facultyId}}/{{groupId}}
|
||||||
?id={{id}}
|
?id=download
|
||||||
Accept: text/calendar
|
Accept: text/calendar
|
||||||
|
|
||||||
# id parameter changes behavior for the calendar:
|
# id parameter changes behavior for the calendar:
|
||||||
|
# - If set to "download", nothing changes
|
||||||
# - If not present, an additional event will be appended to the calendar (see Program.cs)
|
# - If not present, an additional event will be appended to the calendar (see Program.cs)
|
||||||
# - If set to "download", nothing will happen
|
|
||||||
# - If set to any other value, this request will be counted in active users stats (only once per ID until the next restart)
|
# - If set to any other value, this request will be counted in active users stats (only once per ID until the next restart)
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ ADD --link . .
|
|||||||
RUN --mount=type=cache,target=/root/.nuget \
|
RUN --mount=type=cache,target=/root/.nuget \
|
||||||
--mount=type=cache,target=/source/bin \
|
--mount=type=cache,target=/source/bin \
|
||||||
--mount=type=cache,target=/source/obj \
|
--mount=type=cache,target=/source/obj \
|
||||||
dotnet publish --output /out /p:PublishAot=true BonchCalendar.csproj \
|
dotnet publish --output /out BonchCalendar.csproj \
|
||||||
&& rm /out/*.dbg /out/*.Development.json
|
&& rm /out/*.dbg /out/*.Development.json
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS prod
|
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-alpine AS prod
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|||||||
|
|
||||||
namespace BonchCalendar.Health;
|
namespace BonchCalendar.Health;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Healthcheck service for sut.ru API.
|
||||||
|
/// </summary>
|
||||||
public class ApiHealthCheck(IssueTrackingService trackingService) : IHealthCheck
|
public class ApiHealthCheck(IssueTrackingService trackingService) : IHealthCheck
|
||||||
{
|
{
|
||||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
@@ -11,8 +14,12 @@ public class ApiHealthCheck(IssueTrackingService trackingService) : IHealthCheck
|
|||||||
{
|
{
|
||||||
Dictionary<string, object> report = trackingService.GetReport();
|
Dictionary<string, object> report = trackingService.GetReport();
|
||||||
|
|
||||||
|
// We deem service "unhealthy" if any of the last requests to the API were unsuccessful.
|
||||||
if (report.Count > 0)
|
if (report.Count > 0)
|
||||||
return HealthCheckResult.Unhealthy(description: "We're having issues with fetching data from the timetable website.", data: report);
|
return HealthCheckResult.Unhealthy(
|
||||||
|
description: "We're having issues with fetching data from the timetable website.",
|
||||||
|
data: report
|
||||||
|
);
|
||||||
|
|
||||||
return HealthCheckResult.Healthy();
|
return HealthCheckResult.Healthy();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,18 +7,18 @@ namespace BonchCalendar.Health;
|
|||||||
public static class HealthCheckWriter
|
public static class HealthCheckWriter
|
||||||
{
|
{
|
||||||
private static readonly byte[] _emptyResponse = [ (byte)'{', (byte)'}' ];
|
private static readonly byte[] _emptyResponse = [ (byte)'{', (byte)'}' ];
|
||||||
private static JsonSerializerContext? _jsonContext = null;
|
private static readonly JsonSerializerContext _jsonContext = CreateSerializerContext();
|
||||||
|
|
||||||
public static JsonSerializerContext JsonContext => _jsonContext ??= CreateSerializerContext();
|
|
||||||
|
|
||||||
public static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
|
public static async Task WriteHealthCheckResponse(HttpContext context, HealthReport report)
|
||||||
{
|
{
|
||||||
if (report is null)
|
if (report is null)
|
||||||
{
|
{
|
||||||
|
// Just in case, but this should not ever happen.
|
||||||
await context.Response.BodyWriter.WriteAsync(_emptyResponse).ConfigureAwait(false);
|
await context.Response.BodyWriter.WriteAsync(_emptyResponse).ConfigureAwait(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create DTO from the report.
|
||||||
HealthResponse response = new(
|
HealthResponse response = new(
|
||||||
Status: report.Status,
|
Status: report.Status,
|
||||||
TotalDuration: report.TotalDuration,
|
TotalDuration: report.TotalDuration,
|
||||||
@@ -33,9 +33,9 @@ public static class HealthCheckWriter
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Write DTO to the response body.
|
||||||
context.Response.ContentType = "application/json; charset=utf-8";
|
context.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
await JsonSerializer.SerializeAsync(context.Response.Body, response, typeof(HealthResponse), _jsonContext).ConfigureAwait(false);
|
||||||
await JsonSerializer.SerializeAsync(context.Response.Body, response, typeof(HealthResponse), JsonContext).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AppJsonSerializerContext CreateSerializerContext()
|
private static AppJsonSerializerContext CreateSerializerContext()
|
||||||
@@ -53,12 +53,25 @@ public static class HealthCheckWriter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response body for /health endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Status">Overall status of the application.</param>
|
||||||
|
/// <param name="TotalDuration">Total time it took to complete the health check.</param>
|
||||||
|
/// <param name="Entries">List of subcomponent healthcheck reports.</param>
|
||||||
public record HealthResponse(
|
public record HealthResponse(
|
||||||
HealthStatus Status,
|
HealthStatus Status,
|
||||||
TimeSpan TotalDuration,
|
TimeSpan TotalDuration,
|
||||||
IReadOnlyDictionary<string, HealthResponseEntry> Entries
|
IReadOnlyDictionary<string, HealthResponseEntry> Entries
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Healthcheck report for a subcomponent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Status">Status of the subcomponent.</param>
|
||||||
|
/// <param name="Description">Report remarks.</param>
|
||||||
|
/// <param name="Duration">Time it took to complete health check for this subcomponent.</param>
|
||||||
|
/// <param name="Data">Addtional report data.</param>
|
||||||
public record HealthResponseEntry(
|
public record HealthResponseEntry(
|
||||||
HealthStatus Status,
|
HealthStatus Status,
|
||||||
string? Description,
|
string? Description,
|
||||||
|
|||||||
+59
-30
@@ -10,14 +10,17 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
|
||||||
|
|
||||||
|
// Adding static JSON serializer since we're running in Native AOT
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||||
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default)
|
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add services to the container.
|
// Add services to the container.
|
||||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi(); // OpenAPI specification generator
|
||||||
builder.Services.AddValidation();
|
builder.Services.AddValidation(); // Request validation
|
||||||
|
|
||||||
|
// Customizing non-200 responses to include trace identifier and a no-as-a-service reason
|
||||||
builder.Services.AddProblemDetails(configure =>
|
builder.Services.AddProblemDetails(configure =>
|
||||||
{
|
{
|
||||||
configure.CustomizeProblemDetails = context =>
|
configure.CustomizeProblemDetails = context =>
|
||||||
@@ -28,57 +31,76 @@ builder.Services.AddProblemDetails(configure =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddSingleton<IssueTrackingService>()
|
.AddSingleton<IssueTrackingService>() // Service for tracking latest API request results
|
||||||
.AddScoped<TimetableService>()
|
.AddScoped<TimetableService>() // Service for generating a timetable
|
||||||
.AddScoped<ApiService>()
|
.AddScoped<ApiService>() // Service for making API calls to sut.ru API
|
||||||
.AddScoped<ParsingService>();
|
.AddScoped<ParsingService>(); // Service for parsing timetable from sut.ru
|
||||||
|
|
||||||
builder.Services.AddHealthChecks()
|
builder.Services.AddHealthChecks()
|
||||||
.AddCheck<ApiHealthCheck>("timetable_website");
|
.AddCheck<ApiHealthCheck>("timetable_website"); // Healthcheck service
|
||||||
|
|
||||||
|
// Get ORIGIN_DOMAIN environmental variable
|
||||||
|
string? corsDomain = Environment.GetEnvironmentVariable("ORIGIN_DOMAIN");
|
||||||
|
|
||||||
|
// Configure defautl CORS policy
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
options.AddDefaultPolicy(policy =>
|
options.AddDefaultPolicy(policy =>
|
||||||
|
{
|
||||||
|
// Allow only GET requests with any headers
|
||||||
policy
|
policy
|
||||||
.WithMethods(["GET"])
|
.WithMethods(["GET"])
|
||||||
.AllowAnyOrigin()
|
.AllowAnyHeader();
|
||||||
.AllowAnyHeader()
|
|
||||||
)
|
// If ORIGIN_DOMAIN environmental variable is set, allow request only from this domain,
|
||||||
|
// otherwise allow request from any domains.
|
||||||
|
if (string.IsNullOrWhiteSpace(corsDomain))
|
||||||
|
policy.AllowAnyOrigin();
|
||||||
|
else
|
||||||
|
policy.WithOrigins(corsDomain);
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
WebApplication app = builder.Build();
|
WebApplication app = builder.Build();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline
|
||||||
app.UseCors();
|
app.UseCors(); // Enable CORS
|
||||||
app.UseStatusCodePages();
|
app.UseStatusCodePages(); // Enable default JSON response body for non-200 responses.
|
||||||
app.MapOpenApi();
|
app.MapOpenApi(); // Map OpenAPI sepcification. Available at /openapi/v1.json
|
||||||
|
|
||||||
|
// Map healthcheck endpoint with custom response writer
|
||||||
|
// Remark: /health and /openapi/v1.json endpoints are not present in OpenAPI specification.
|
||||||
app.MapHealthChecks("/health", new HealthCheckOptions
|
app.MapHealthChecks("/health", new HealthCheckOptions
|
||||||
{
|
{
|
||||||
ResponseWriter = HealthCheckWriter.WriteHealthCheckResponse
|
ResponseWriter = HealthCheckWriter.WriteHealthCheckResponse
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Request singleton services which will be used in subsequent endpoints
|
||||||
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
|
ILogger<Program> logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||||
IssueTrackingService tracker = app.Services.GetRequiredService<IssueTrackingService>();
|
IssueTrackingService tracker = app.Services.GetRequiredService<IssueTrackingService>();
|
||||||
|
|
||||||
|
// List of identifiers for tracking unique visits to /timetable endpoint (essentially, for unique user counting).
|
||||||
List<string> ids = [];
|
List<string> ids = [];
|
||||||
|
|
||||||
|
// Statistics endpoint. Shows number of active users.
|
||||||
app.MapGet("/stats", () => Results.Ok(new StatsResponse(ids.Count)))
|
app.MapGet("/stats", () => Results.Ok(new StatsResponse(ids.Count)))
|
||||||
.WithName("GetStats")
|
.WithName("GetStats")
|
||||||
.WithDescription("Get basic usage statistics.")
|
.WithDescription("Get basic usage statistics.")
|
||||||
.Produces<StatsResponse>(StatusCodes.Status200OK);
|
.Produces<StatsResponse>(StatusCodes.Status200OK);
|
||||||
|
|
||||||
|
// Retrieve list of faculties and their IDs from sut.ru API
|
||||||
app.MapGet("/faculties", async ([FromServices] ApiService apiService) =>
|
app.MapGet("/faculties", async ([FromServices] ApiService apiService) =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Dictionary<int, string> faculties = await apiService.GetFacultiesListAsync();
|
Dictionary<int, string> faculties = await apiService.GetFacultiesListAsync();
|
||||||
logger.LogInformation("Fetched {Count} faculties.", faculties.Count);
|
logger.LogInformation("Fetched {Count} faculties.", faculties.Count);
|
||||||
tracker.TrackFacultyFetch(true);
|
tracker.TrackFacultyFetch(true); // Record that last attempt to retrieve faculties was successful
|
||||||
return Results.Ok(faculties);
|
return Results.Ok(faculties);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to fetch faculties list.");
|
logger.LogError(ex, "Failed to fetch faculties list.");
|
||||||
tracker.TrackFacultyFetch(false);
|
tracker.TrackFacultyFetch(false); // Record that last attempt to retrieve faculties was unsuccessful
|
||||||
return Results.Problem("Failed to fetch faculties list.", statusCode: StatusCodes.Status500InternalServerError);
|
return Results.Problem("Failed to fetch faculties list.", statusCode: StatusCodes.Status500InternalServerError);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -87,19 +109,24 @@ app.MapGet("/faculties", async ([FromServices] ApiService apiService) =>
|
|||||||
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
||||||
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK);
|
.Produces<Dictionary<int, string>>(StatusCodes.Status200OK);
|
||||||
|
|
||||||
app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(0, 5)] int year) =>
|
// Retrieve list of groups for a chosen faculty and year.
|
||||||
|
// Year can be in range from 1 to 5.
|
||||||
|
// If year is not specified, all groups for chosen faculty will be retrieved.
|
||||||
|
app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId, [Range(1, 5)] int? year) =>
|
||||||
{
|
{
|
||||||
|
year ??= 0; // Setting year to 0 (show all groups) if not specified in the request.
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Dictionary<int, string> groups = await apiService.GetGroupsListAsync(facultyId, year);
|
Dictionary<int, string> groups = await apiService.GetGroupsListAsync(facultyId, year.Value);
|
||||||
logger.LogInformation("Fetched {Count} groups (facultyId: {FacultyId}, year: {Year}).", groups.Count, facultyId, year);
|
logger.LogInformation("Fetched {Count} groups (facultyId: {FacultyId}, year: {Year}).", groups.Count, facultyId, year);
|
||||||
tracker.TrackGroupFetch(facultyId, year, true);
|
tracker.TrackGroupFetch(facultyId, year.Value, true); // Track whether retrieving groups for this specific faculty and year was successul or not.
|
||||||
return Results.Ok(groups);
|
return Results.Ok(groups);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to fetch groups list (facultyId: {FacultyId}, year: {Year}).", facultyId, year);
|
logger.LogError(ex, "Failed to fetch groups list (facultyId: {FacultyId}, year: {Year}).", facultyId, year);
|
||||||
tracker.TrackGroupFetch(facultyId, year, false);
|
tracker.TrackGroupFetch(facultyId, year.Value, false); // Track whether retrieving groups for this specific faculty and year was successul or not.
|
||||||
return Results.Problem(
|
return Results.Problem(
|
||||||
"Failed to fetch groups list.",
|
"Failed to fetch groups list.",
|
||||||
statusCode: StatusCodes.Status500InternalServerError,
|
statusCode: StatusCodes.Status500InternalServerError,
|
||||||
@@ -117,18 +144,22 @@ app.MapGet("/groups", async ([FromServices] ApiService apiService, int facultyId
|
|||||||
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
||||||
.ProducesValidationProblem();
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
|
// Retrieve timetable for specified group in form of iCal.
|
||||||
app.MapGet("/timetable/{facultyId}/{groupId}", async (
|
app.MapGet("/timetable/{facultyId}/{groupId}", async (
|
||||||
int facultyId, int groupId, string? id,
|
int facultyId, int groupId, string? id,
|
||||||
[FromServices] TimetableService timetableService
|
[FromServices] TimetableService timetableService
|
||||||
) =>
|
) =>
|
||||||
{
|
{
|
||||||
string cacheFile = Path.Combine(Path.GetTempPath(), $"bonch_cal_{groupId}.ics");
|
string? content = await timetableService.TryGetTimetableFromCacheAsync(groupId);
|
||||||
string? content = await timetableService.TryServingFromCacheAsync(groupId);
|
|
||||||
bool hasId = !string.IsNullOrEmpty(id);
|
bool hasId = !string.IsNullOrEmpty(id);
|
||||||
|
|
||||||
|
// If this is the first request with given id, we record it.
|
||||||
|
// "download" is a special "ID" that is used solely for downloading timetable as file.
|
||||||
if (hasId && id is not "download" && !ids.Contains(id!))
|
if (hasId && id is not "download" && !ids.Contains(id!))
|
||||||
ids.Add(id!);
|
ids.Add(id!);
|
||||||
|
|
||||||
|
// If we have a valid cache, we serve it, instead of retrieving new timetable from sut.ru API
|
||||||
|
// We don't serve cache for requests with no ID, since they have a special behavior.
|
||||||
if (content is not null && hasId)
|
if (content is not null && hasId)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Serving timetable for {FacultyId}/{GroupId} from cache.", facultyId, groupId);
|
logger.LogInformation("Serving timetable for {FacultyId}/{GroupId} from cache.", facultyId, groupId);
|
||||||
@@ -143,6 +174,9 @@ app.MapGet("/timetable/{facultyId}/{groupId}", async (
|
|||||||
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: true);
|
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: true);
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// For requests with no ID we append an event at 7pm that asks users to update their calendar URL,
|
||||||
|
// since now all /timetable requests must have one.
|
||||||
|
// This is a temporary behavior that will be changed to just sending 4xx response instead later.
|
||||||
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: false, transform: calendar =>
|
content = await timetableService.GetTimetableAsync(facultyId, groupId, saveToCache: false, transform: calendar =>
|
||||||
calendar.Events.Add(new()
|
calendar.Events.Add(new()
|
||||||
{
|
{
|
||||||
@@ -172,19 +206,13 @@ app.MapGet("/timetable/{facultyId}/{groupId}", async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.LogInformation("Fetched timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
|
logger.LogInformation("Fetched timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
|
||||||
tracker.TrackTimetableFetch(facultyId, groupId, true);
|
tracker.TrackTimetableFetch(facultyId, groupId, true); // Track whether retrieving timetable for this specific group was successul or not.
|
||||||
return Results.Text(content, contentType: "text/calendar");
|
return Results.Text(content, contentType: "text/calendar");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Failed to generate timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
|
logger.LogError(ex, "Failed to generate timetable for {FacultyId}/{GroupId}.", facultyId, groupId);
|
||||||
tracker.TrackTimetableFetch(facultyId, groupId, false);
|
tracker.TrackTimetableFetch(facultyId, groupId, false); // Track whether retrieving timetable for this specific group was successul or not.
|
||||||
|
|
||||||
if (content is not null)
|
|
||||||
{
|
|
||||||
logger.LogWarning("[Fallback] Serving timetable from cache ({CacheFile}).", cacheFile);
|
|
||||||
return Results.Text(content, contentType: "text/calendar");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Problem(
|
return Results.Problem(
|
||||||
"Failed to fetch timetable",
|
"Failed to fetch timetable",
|
||||||
@@ -203,4 +231,5 @@ app.MapGet("/timetable/{facultyId}/{groupId}", async (
|
|||||||
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
.ProducesProblem(StatusCodes.Status500InternalServerError)
|
||||||
.ProducesValidationProblem();
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
|
// Start the application.
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -4,8 +4,19 @@ using BonchCalendar.Utils;
|
|||||||
|
|
||||||
namespace BonchCalendar.Services;
|
namespace BonchCalendar.Services;
|
||||||
|
|
||||||
|
// For better understanding of what is happening here,
|
||||||
|
// I recommend visiting https://cabinet.sut.ru/raspisanie_all_new.php
|
||||||
|
// and trying to send requests yourself.
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for calling sut.ru API.
|
||||||
|
/// </summary>
|
||||||
public class ApiService
|
public class ApiService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve list of faculties and their IDs.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A dictionary, where the key is faculty's ID and the value is faculty's name.</returns>
|
||||||
public async Task<Dictionary<int, string>> GetFacultiesListAsync() =>
|
public async Task<Dictionary<int, string>> GetFacultiesListAsync() =>
|
||||||
ParseListResponse(await SendRequestAsync(new()
|
ParseListResponse(await SendRequestAsync(new()
|
||||||
{
|
{
|
||||||
@@ -13,15 +24,30 @@ public class ApiService
|
|||||||
["schet"] = GetCurrentSemesterId()
|
["schet"] = GetCurrentSemesterId()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
public async Task<Dictionary<int, string>> GetGroupsListAsync(int facultyId, int course) =>
|
/// <summary>
|
||||||
|
/// Retrieve list of groups for specified faculty and year.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="facultyId">ID of selected faculty.</param>
|
||||||
|
/// <param name="year">An academic year. Should be from 0 to 5.</param>
|
||||||
|
/// <returns>A dictionary, where the key is group's ID and the value is group's name.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// If <paramref name="year"/> is set to 0, all groups for the specified faculty will be retrieved instead.
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<Dictionary<int, string>> GetGroupsListAsync(int facultyId, int year) =>
|
||||||
ParseListResponse(await SendRequestAsync(new()
|
ParseListResponse(await SendRequestAsync(new()
|
||||||
{
|
{
|
||||||
["choice"] = "1",
|
["choice"] = "1",
|
||||||
["schet"] = GetCurrentSemesterId(),
|
["schet"] = GetCurrentSemesterId(),
|
||||||
["faculty"] = facultyId.ToString(), // Specifying faculty ID returns a list of groups
|
["faculty"] = facultyId.ToString(), // Specifying faculty ID returns a list of groups
|
||||||
["kurs"] = course.ToString() // Course number is actually optional, but filters out other groups. Can be set 0 to get all groups of the faculty.
|
["kurs"] = year.ToString() // Course number is actually optional, but filters out other groups. Can be set 0 to get all groups of the faculty.
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve timetable document for the specified group.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="groupId">ID of selected group.</param>
|
||||||
|
/// <param name="timetableType">Type of a timetable to retrieve.</param>
|
||||||
|
/// <returns>A string, represeting raw HTML document, that contains the timetable.</returns>
|
||||||
public async Task<string> GetScheduleDocumentAsync(int groupId, TimetableType timetableType) =>
|
public async Task<string> GetScheduleDocumentAsync(int groupId, TimetableType timetableType) =>
|
||||||
await SendRequestAsync(new()
|
await SendRequestAsync(new()
|
||||||
{
|
{
|
||||||
@@ -30,14 +56,34 @@ public class ApiService
|
|||||||
["group"] = groupId.ToString()
|
["group"] = groupId.ToString()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve current semester start date.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="groupId">ID of a group.</param>
|
||||||
|
/// <returns>A <see cref="DateTime"/> object, representing the first day of current semester.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <paramref name="groupId"/> can be any valid group ID. We only need it for retrieving a correct HTML document.
|
||||||
|
/// </remarks>
|
||||||
public async Task<DateTime> GetSemesterStartDateAsync(int groupId)
|
public async Task<DateTime> GetSemesterStartDateAsync(int groupId)
|
||||||
{
|
{
|
||||||
using HttpClient client = new();
|
using HttpClient client = new();
|
||||||
|
// We go to this URL, since it has to contain current week number,
|
||||||
|
// which we can use to calculate the first day of the semester.
|
||||||
|
// If we don't specify group, we'll get a page listing all available groups,
|
||||||
|
// which doesn't contain current week number, thus, rendering it useless for us.
|
||||||
string content = await client.GetStringAsync($"https://www.sut.ru/studentu/raspisanie/raspisanie-zanyatiy-studentov-ochnoy-i-vecherney-form-obucheniya?group={groupId}");
|
string content = await client.GetStringAsync($"https://www.sut.ru/studentu/raspisanie/raspisanie-zanyatiy-studentov-ochnoy-i-vecherney-form-obucheniya?group={groupId}");
|
||||||
|
|
||||||
using IHtmlDocument doc = new HtmlParser().ParseDocument(content);
|
using IHtmlDocument doc = new HtmlParser().ParseDocument(content);
|
||||||
|
|
||||||
|
// 1. Get <a> tag with id "rasp-prev"
|
||||||
|
// 2. Get it's neighbor <div> tag that is the second child of their parent tag
|
||||||
|
// 3. Get <span> tag inside the <div>
|
||||||
string labelText = doc.QuerySelector("a#rasp-prev + div:nth-child(2) > span")!.TextContent;
|
string labelText = doc.QuerySelector("a#rasp-prev + div:nth-child(2) > span")!.TextContent;
|
||||||
|
|
||||||
|
// Content of the <span> tag is supposed to be something like "Нечетная неделя (15)"
|
||||||
|
// So, we can use regular expressions to get the "15" part and parse it to an integer.
|
||||||
int weekNumber = int.Parse(ParserUtils.NumberRegex().Match(labelText).Value);
|
int weekNumber = int.Parse(ParserUtils.NumberRegex().Match(labelText).Value);
|
||||||
|
|
||||||
DateTime currentDate = DateTime.Today;
|
DateTime currentDate = DateTime.Today;
|
||||||
currentDate = currentDate
|
currentDate = currentDate
|
||||||
.AddDays(-(int)currentDate.DayOfWeek + 1) // Move to Monday
|
.AddDays(-(int)currentDate.DayOfWeek + 1) // Move to Monday
|
||||||
@@ -46,6 +92,8 @@ public class ApiService
|
|||||||
return currentDate;
|
return currentDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utility method that converts faculty or group list response into a dictionary.
|
||||||
|
// It expected the reponse to be in format: "1,Group 1;2,Group2;..."
|
||||||
private static Dictionary<int, string> ParseListResponse(string responseContent) =>
|
private static Dictionary<int, string> ParseListResponse(string responseContent) =>
|
||||||
responseContent
|
responseContent
|
||||||
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||||
@@ -55,7 +103,8 @@ public class ApiService
|
|||||||
parts => parts[1]
|
parts => parts[1]
|
||||||
);
|
);
|
||||||
|
|
||||||
public async Task<string> SendRequestAsync(Dictionary<string, string> formData)
|
// Utility method for sending request to sut.ru API.
|
||||||
|
private static async Task<string> SendRequestAsync(Dictionary<string, string> formData)
|
||||||
{
|
{
|
||||||
HttpRequestMessage request = new(HttpMethod.Post, "https://cabinet.sut.ru/raspisanie_all_new.php")
|
HttpRequestMessage request = new(HttpMethod.Post, "https://cabinet.sut.ru/raspisanie_all_new.php")
|
||||||
{
|
{
|
||||||
@@ -64,7 +113,8 @@ public class ApiService
|
|||||||
|
|
||||||
using HttpClient client = new(new HttpClientHandler
|
using HttpClient client = new(new HttpClientHandler
|
||||||
{
|
{
|
||||||
// Sometimes Bonch being Bonch just doesn't renew its SSL certificates properly
|
// Sometimes Bonch being Bonch just doesn't renew its SSL certificates properly,
|
||||||
|
// so we just assume that we're in the right place.
|
||||||
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
|
ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,11 +131,12 @@ public class ApiService
|
|||||||
? 1 // August through January - first semester
|
? 1 // August through January - first semester
|
||||||
: 2; // Everything else - second
|
: 2; // Everything else - second
|
||||||
|
|
||||||
int termStartYear = now.Year - 2000; // We need only last two digits (e.g. 25 for 2025)
|
int academicYearStartYear = now.Year - 2000; // We need only last two digits (e.g. 25 for 2025)
|
||||||
|
// P.S. I am not a fun of this variable name either.
|
||||||
|
|
||||||
if (now.Month < 8) // Before August means we are in the second semester of the previous academic year
|
if (now.Month < 8) // Before August means we are in the second semester of the previous academic year
|
||||||
termStartYear--;
|
academicYearStartYear--;
|
||||||
|
|
||||||
return $"205.{termStartYear}{termStartYear + 1}/{currentSemester}";
|
return $"205.{academicYearStartYear}{academicYearStartYear + 1}/{currentSemester}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
namespace BonchCalendar.Services;
|
namespace BonchCalendar.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service that tracks results of the most recent requests.
|
||||||
|
/// </summary>
|
||||||
public class IssueTrackingService
|
public class IssueTrackingService
|
||||||
{
|
{
|
||||||
private bool _isLastFacultyFetchSuccessful = true;
|
private bool _isLastFacultyFetchSuccessful = true;
|
||||||
@@ -8,12 +11,22 @@ public class IssueTrackingService
|
|||||||
|
|
||||||
private readonly List<string> _unsuccessfulTimetableFetches = [];
|
private readonly List<string> _unsuccessfulTimetableFetches = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Record whether the last attempt to retrieve faculty list was successful.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isSuccessful">Set <c>true</c> if the attempt was successful. Otherwise, set <c>false</c></param>
|
||||||
public void TrackFacultyFetch(bool isSuccessful) =>
|
public void TrackFacultyFetch(bool isSuccessful) =>
|
||||||
_isLastFacultyFetchSuccessful = isSuccessful;
|
_isLastFacultyFetchSuccessful = isSuccessful;
|
||||||
|
|
||||||
public void TrackGroupFetch(int facultyId, int course, bool isSuccessful)
|
/// <summary>
|
||||||
|
/// Record whether the last attempt to retrieve groups for provided faculty and term year was successful.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="facultyId">ID of a faculty which was used to retrieve the group list.</param>
|
||||||
|
/// <param name="termYear">Term year which was used to retrieve the group list.</param>
|
||||||
|
/// <param name="isSuccessful">Set <c>true</c> if the attempt was successful. Otherwise, set <c>false</c></param>
|
||||||
|
public void TrackGroupFetch(int facultyId, int termYear, bool isSuccessful)
|
||||||
{
|
{
|
||||||
string key = $"{facultyId}/{course}";
|
string key = $"{facultyId}/{termYear}";
|
||||||
|
|
||||||
if (!isSuccessful)
|
if (!isSuccessful)
|
||||||
{
|
{
|
||||||
@@ -24,6 +37,12 @@ public class IssueTrackingService
|
|||||||
_unsuccessfulGroupFetches.Remove(key);
|
_unsuccessfulGroupFetches.Remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Record whether the last attempt to retrieve timetable for provided group was successful.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="facultyId">ID of a faculty the group belongs to.</param>
|
||||||
|
/// <param name="groupId">ID of a group the timetable was retrieved for.</param>
|
||||||
|
/// <param name="isSuccessful">Set <c>true</c> if the attempt was successful. Otherwise, set <c>false</c></param>
|
||||||
public void TrackTimetableFetch(int facultyId, int groupId, bool isSuccessful)
|
public void TrackTimetableFetch(int facultyId, int groupId, bool isSuccessful)
|
||||||
{
|
{
|
||||||
string key = $"{facultyId}/{groupId}";
|
string key = $"{facultyId}/{groupId}";
|
||||||
@@ -37,6 +56,13 @@ public class IssueTrackingService
|
|||||||
_unsuccessfulTimetableFetches.Remove(key);
|
_unsuccessfulTimetableFetches.Remove(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get report on the success of latest retrieval attempts.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A dictionary for each of the tracked groups.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// If the dictionary is empty, that means that there're no known issues.
|
||||||
|
/// </remarks>
|
||||||
public Dictionary<string, object> GetReport()
|
public Dictionary<string, object> GetReport()
|
||||||
{
|
{
|
||||||
Dictionary<string, object> report = [];
|
Dictionary<string, object> report = [];
|
||||||
@@ -50,6 +76,26 @@ public class IssueTrackingService
|
|||||||
if (_unsuccessfulTimetableFetches.Count > 0)
|
if (_unsuccessfulTimetableFetches.Count > 0)
|
||||||
report.Add("/timetable", _unsuccessfulTimetableFetches.ToArray());
|
report.Add("/timetable", _unsuccessfulTimetableFetches.ToArray());
|
||||||
|
|
||||||
|
// No issues example:
|
||||||
|
/*
|
||||||
|
* { }
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Report example with issues:
|
||||||
|
/*
|
||||||
|
* {
|
||||||
|
* "/faculties": false,
|
||||||
|
* "/groups": [
|
||||||
|
* "123/1",
|
||||||
|
* "321/3"
|
||||||
|
* ],
|
||||||
|
* "/timetable": [
|
||||||
|
* "123/321",
|
||||||
|
* "456/654"
|
||||||
|
* ],
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ namespace BonchCalendar.Services;
|
|||||||
|
|
||||||
public partial class ParsingService
|
public partial class ParsingService
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Parse general timetable document.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rawHtml">HTML document retrieved from the API.</param>
|
||||||
|
/// <param name="semesterStartDate"><see cref="DateTime"/> that represents the first day of current semester.</param>
|
||||||
|
/// <param name="groupName">Name of a group this timetable is for.</param>
|
||||||
|
/// <returns>An array of <see cref="CalendarEvent"/>s</returns>
|
||||||
public CalendarEvent[] ParseGeneralTimetable(string rawHtml, DateTime semesterStartDate, string groupName)
|
public CalendarEvent[] ParseGeneralTimetable(string rawHtml, DateTime semesterStartDate, string groupName)
|
||||||
{
|
{
|
||||||
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
|
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
|
||||||
@@ -27,7 +34,7 @@ public partial class ParsingService
|
|||||||
Match timeMatch = ParserUtils.TimeLabelRegex().Match(timeLabelText);
|
Match timeMatch = ParserUtils.TimeLabelRegex().Match(timeLabelText);
|
||||||
string number = timeMatch.Success ? timeMatch.Groups["number"].Value : timeLabelText;
|
string number = timeMatch.Success ? timeMatch.Groups["number"].Value : timeLabelText;
|
||||||
(TimeSpan startTime, TimeSpan endTime) = !timeMatch.Success ?
|
(TimeSpan startTime, TimeSpan endTime) = !timeMatch.Success ?
|
||||||
ParserUtils.GetTimesFromLabel(timeLabelText) :
|
ParserUtils.GetTimesFromLabel(timeLabelText) : // If the label for some reason doesn't contain start and end time, we can infer it from class' number
|
||||||
(
|
(
|
||||||
TimeSpan.Parse(timeMatch.Groups["start"].Value),
|
TimeSpan.Parse(timeMatch.Groups["start"].Value),
|
||||||
TimeSpan.Parse(timeMatch.Groups["end"].Value)
|
TimeSpan.Parse(timeMatch.Groups["end"].Value)
|
||||||
@@ -44,16 +51,26 @@ public partial class ParsingService
|
|||||||
.AddDays((week - 1) * 7) // Move to the correct week
|
.AddDays((week - 1) * 7) // Move to the correct week
|
||||||
.AddDays(weekday - 1); // Move to the correct weekday
|
.AddDays(weekday - 1); // Move to the correct weekday
|
||||||
|
|
||||||
classes.Add(GetEvent(
|
classes.Add(CreateEvent(
|
||||||
$"{number}. {className} ({classType})", auditorium,
|
title: $"{number}. {className} ({classType})",
|
||||||
GetDescription(groupName, professors, auditorium, weeks),
|
location: auditorium,
|
||||||
classDate, startTime, endTime));
|
description: CreateDescription(groupName, professors, auditorium, weeks),
|
||||||
|
date: classDate,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. classes];
|
return [.. classes];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parse exam timetable document.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="rawHtml">HTML document, retrieved from the API.</param>
|
||||||
|
/// <param name="groupName">Name of a group this timetable is for.</param>
|
||||||
|
/// <returns>An array of <see cref="CalendarEvent"/>s</returns>
|
||||||
public CalendarEvent[] ParseExamTimetable(string rawHtml, string groupName)
|
public CalendarEvent[] ParseExamTimetable(string rawHtml, string groupName)
|
||||||
{
|
{
|
||||||
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
|
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
|
||||||
@@ -77,26 +94,32 @@ public partial class ParsingService
|
|||||||
TimeSpan startTime = TimeSpan.Parse(timeMatch.Groups["start"].Value.Replace('.', ':'));
|
TimeSpan startTime = TimeSpan.Parse(timeMatch.Groups["start"].Value.Replace('.', ':'));
|
||||||
TimeSpan endTime = TimeSpan.Parse(timeMatch.Groups["end"].Value.Replace('.', ':'));
|
TimeSpan endTime = TimeSpan.Parse(timeMatch.Groups["end"].Value.Replace('.', ':'));
|
||||||
|
|
||||||
classes.Add(GetEvent(
|
classes.Add(CreateEvent(
|
||||||
$"{number}{className} ({classType})", auditorium,
|
title: $"{number}{className} ({classType})",
|
||||||
GetDescription(groupName, professors, auditorium),
|
location: auditorium,
|
||||||
classDate, startTime, endTime));
|
description: CreateDescription(groupName, professors, auditorium),
|
||||||
|
date: classDate,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. classes];
|
return [.. classes];
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CalendarEvent GetEvent(string title, string auditorium, string description, DateTime date, TimeSpan startTime, TimeSpan endTime) =>
|
// Create a calendar event
|
||||||
|
private static CalendarEvent CreateEvent(string title, string location, string description, DateTime date, TimeSpan startTime, TimeSpan endTime) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Summary = title,
|
Summary = title,
|
||||||
Description = description,
|
Description = description,
|
||||||
Start = new CalDateTime(date.Add(startTime - TimeSpan.FromHours(3)).ToUniversalTime()),
|
Start = new CalDateTime(date.Add(startTime - TimeSpan.FromHours(3)).ToUniversalTime()),
|
||||||
End = new CalDateTime(date.Add(endTime - TimeSpan.FromHours(3)).ToUniversalTime()),
|
End = new CalDateTime(date.Add(endTime - TimeSpan.FromHours(3)).ToUniversalTime()),
|
||||||
Location = auditorium
|
Location = location
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string GetDescription(string groupName, string[] professors, string auditorium, int[]? weeks = null)
|
// Create event description
|
||||||
|
private static string CreateDescription(string groupName, string[] professors, string auditorium, int[]? weeks = null)
|
||||||
{
|
{
|
||||||
string str = $"""
|
string str = $"""
|
||||||
Группа: {groupName}
|
Группа: {groupName}
|
||||||
@@ -107,22 +130,26 @@ public partial class ParsingService
|
|||||||
if (weeks is not null && weeks.Length > 0)
|
if (weeks is not null && weeks.Length > 0)
|
||||||
str += $"\nНедели: {string.Join(", ", weeks)}";
|
str += $"\nНедели: {string.Join(", ", weeks)}";
|
||||||
|
|
||||||
|
// Attempt to recognize wing and room number
|
||||||
Match auditoriumMatch = ParserUtils.AuditoriumRegex().Match(auditorium);
|
Match auditoriumMatch = ParserUtils.AuditoriumRegex().Match(auditorium);
|
||||||
|
|
||||||
if (!auditoriumMatch.Success)
|
if (!auditoriumMatch.Success)
|
||||||
auditoriumMatch = ParserUtils.AuditoriumAltRegex().Match(auditorium);
|
auditoriumMatch = ParserUtils.AuditoriumAltRegex().Match(auditorium);
|
||||||
|
|
||||||
|
// If successful, we can add a nav.sut.ru map link
|
||||||
if (auditoriumMatch.Success)
|
if (auditoriumMatch.Success)
|
||||||
str += "\n\n" + $"""
|
str += "\n\n" + $"""
|
||||||
ГУТ.Навигатор:
|
ГУТ.Навигатор:
|
||||||
https://nav.sut.ru/?cab=k{auditoriumMatch.Groups["wing"].Value}-{auditoriumMatch.Groups["room"].Value}
|
https://nav.sut.ru/?cab=k{auditoriumMatch.Groups["wing"].Value}-{auditoriumMatch.Groups["room"].Value}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
// Some shameless self-promotion
|
||||||
str += "\n\n" + "Создано при помощи сервиса Бонч.Календарь: https://bonch.xfox111.net";
|
str += "\n\n" + "Создано при помощи сервиса Бонч.Календарь: https://bonch.xfox111.net";
|
||||||
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse basic info for a class
|
||||||
private static (string className, string classType, string[] professors, string auditorium) ParseBaseInfo(IElement classElement)
|
private static (string className, string classType, string[] professors, string auditorium) ParseBaseInfo(IElement classElement)
|
||||||
{
|
{
|
||||||
string className = classElement.QuerySelector(".subect")?.TextContent ?? string.Empty;
|
string className = classElement.QuerySelector(".subect")?.TextContent ?? string.Empty;
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ using Ical.Net.Serialization;
|
|||||||
|
|
||||||
namespace BonchCalendar.Services;
|
namespace BonchCalendar.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for retrieving timetable.
|
||||||
|
/// </summary>
|
||||||
public class TimetableService(
|
public class TimetableService(
|
||||||
ApiService apiService,
|
ApiService apiService,
|
||||||
ParsingService parsingService,
|
ParsingService parsingService,
|
||||||
@@ -11,7 +14,12 @@ public class TimetableService(
|
|||||||
IHostEnvironment environment
|
IHostEnvironment environment
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public async Task<string?> TryServingFromCacheAsync(int groupId)
|
/// <summary>
|
||||||
|
/// Try to retrieve timetable from application's cache.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="groupId">ID of a group to retrieve timetable for.</param>
|
||||||
|
/// <returns><c>null</c> if cache for this timetable is not present, or is older than 6 hours. Otherwise, timetable content in iCal format.</returns>
|
||||||
|
public async Task<string?> TryGetTimetableFromCacheAsync(int groupId)
|
||||||
{
|
{
|
||||||
string cacheFile = GetCachePath(groupId);
|
string cacheFile = GetCachePath(groupId);
|
||||||
|
|
||||||
@@ -24,17 +32,28 @@ public class TimetableService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Calendar for group {GroupId} is present in cache ({CacheFile}).", groupId, cacheFile);
|
||||||
|
|
||||||
return await File.ReadAllTextAsync(cacheFile);
|
return await File.ReadAllTextAsync(cacheFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieve timetable for specified group from sut.ru API.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="saveToCache">If set to <c>true</c>, result timetable will be wirtten to cache for that group.</param>
|
||||||
|
/// <param name="transform">Action delegate that can be used to manipulate the result <see cref="Calendar"/> object, before converting it to iCal.</param>
|
||||||
|
/// <returns>A string that contains timetable in iCal format.</returns>
|
||||||
public async Task<string> GetTimetableAsync(int facultyId, int groupId, bool saveToCache = true, Action<Calendar>? transform = null)
|
public async Task<string> GetTimetableAsync(int facultyId, int groupId, bool saveToCache = true, Action<Calendar>? transform = null)
|
||||||
{
|
{
|
||||||
|
// We need semester start date, since the regular timetable is represented on sut.ru semester week numbers.
|
||||||
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId);
|
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(groupId);
|
||||||
string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId];
|
string groupName = (await apiService.GetGroupsListAsync(facultyId, 0))[groupId];
|
||||||
|
|
||||||
|
// Retrieve and parse regular timetable first, since it's the only timetable that has different structure.
|
||||||
string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes);
|
string classesRaw = await apiService.GetScheduleDocumentAsync(groupId, TimetableType.Classes);
|
||||||
List<CalendarEvent> timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)];
|
List<CalendarEvent> timetable = [.. parsingService.ParseGeneralTimetable(classesRaw, semesterStartDate, groupName)];
|
||||||
|
|
||||||
|
// Retrieve and parse other timetables using a loop, since they all can be parsed using the same parser.
|
||||||
TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural];
|
TimetableType[] types = [TimetableType.Attestations, TimetableType.Exams, TimetableType.ExamsForExtramural];
|
||||||
foreach (TimetableType type in types)
|
foreach (TimetableType type in types)
|
||||||
{
|
{
|
||||||
@@ -42,13 +61,17 @@ public class TimetableService(
|
|||||||
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
|
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create and configure the calendar.
|
||||||
Calendar calendar = new();
|
Calendar calendar = new();
|
||||||
calendar.AddTimeZone(new VTimeZone("Europe/Moscow"));
|
calendar.AddTimeZone(new VTimeZone("Europe/Moscow"));
|
||||||
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
|
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
|
||||||
calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow"));
|
calendar.Properties.Add(new CalendarProperty("X-WR-TIMEZONE", "Europe/Moscow"));
|
||||||
calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H"));
|
calendar.Properties.Add(new CalendarProperty("REFRESH-INTERVAL;VALUE=DURATION", "PT6H")); // Specifies how often calendar client should poll for new timetable.
|
||||||
calendar.Events.AddRange(timetable);
|
calendar.Events.AddRange(timetable);
|
||||||
transform?.Invoke(calendar);
|
|
||||||
|
transform?.Invoke(calendar); // If transform delegate is not null, invoke it.
|
||||||
|
|
||||||
|
// Serialize calendar to iCal format.
|
||||||
string content = new CalendarSerializer().SerializeToString(calendar)!;
|
string content = new CalendarSerializer().SerializeToString(calendar)!;
|
||||||
|
|
||||||
if (saveToCache)
|
if (saveToCache)
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
namespace BonchCalendar;
|
namespace BonchCalendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Response body object for /stats endpoint.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ActiveUsers">Number of active users.</param>
|
||||||
public record StatsResponse(int ActiveUsers);
|
public record StatsResponse(int ActiveUsers);
|
||||||
|
|||||||
@@ -1,9 +1,27 @@
|
|||||||
namespace BonchCalendar;
|
namespace BonchCalendar;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Types of timetable documents retrieved from sut.ru API.
|
||||||
|
/// </summary>
|
||||||
public enum TimetableType
|
public enum TimetableType
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Regular timetable document (Занятия).
|
||||||
|
/// </summary>
|
||||||
Classes = 1,
|
Classes = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exams timetable document (Экзаменационная сессия).
|
||||||
|
/// </summary>
|
||||||
Exams = 2,
|
Exams = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exams timetable for extramural students document (Сессия для заочников).
|
||||||
|
/// </summary>
|
||||||
ExamsForExtramural = 4,
|
ExamsForExtramural = 4,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attestations timetable document (Зачеты).
|
||||||
|
/// </summary>
|
||||||
Attestations = 14
|
Attestations = 14
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,20 @@ using System.Text.RegularExpressions;
|
|||||||
|
|
||||||
namespace BonchCalendar.Utils;
|
namespace BonchCalendar.Utils;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utility methods for timetable parser.
|
||||||
|
/// </summary>
|
||||||
public static partial class ParserUtils
|
public static partial class ParserUtils
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Get class start and end times from class' number label.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="label">Class' number label.</param>
|
||||||
|
/// <returns>A tuple value of start and end times.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// This method is only supposed to be used as a fallback method of determening class' time
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="NotImplementedException">Unknown label encountered.</exception>
|
||||||
public static (TimeSpan startTime, TimeSpan endTime) GetTimesFromLabel(string label)
|
public static (TimeSpan startTime, TimeSpan endTime) GetTimesFromLabel(string label)
|
||||||
{
|
{
|
||||||
(string startTime, string endTime) = label switch
|
(string startTime, string endTime) = label switch
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,6 @@
|
|||||||
"LogLevel": {
|
"LogLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
},
|
|
||||||
"Console": {
|
|
||||||
"IncludeScopes": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
### This file contains exampels of HTTP requests to sut.ru API.
|
||||||
|
# You can use them for reference and better understanding of what I have to deal with.
|
||||||
|
# You can use a vscode extension (like REST Client) to make this file interactive.
|
||||||
|
|
||||||
|
# Current semester "ID" (must be updated before sending requests)
|
||||||
|
@schet=205.2526/2
|
||||||
|
|
||||||
|
# Breakdown:
|
||||||
|
# "205." part is static
|
||||||
|
# "2526" represents current academic year (2025-2026 in this case)
|
||||||
|
# "/2" represents current semester. Can be either "/1" (first semester) or "/2" (second semester)
|
||||||
|
# When making requests this ID must always point to current semester, otherwise you may get a broken response.
|
||||||
|
|
||||||
|
# Tip:
|
||||||
|
# From August through January is considered to be the first semester
|
||||||
|
# Other months (February-July) are considered to be the second semester
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
# Get list of faculties
|
||||||
|
POST https://cabinet.sut.ru/raspisanie_all_new.php
|
||||||
|
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||||
|
|
||||||
|
choice=1&schet={{schet}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
# Get list of groups for faculty
|
||||||
|
|
||||||
|
# Year filters out groups by the term year they are at.
|
||||||
|
# Year should be an integer from 0 to 5, inclusive.
|
||||||
|
# If year is set to 0, all groups for the chosen faculty will be received instead.
|
||||||
|
|
||||||
|
@facultyId=50029
|
||||||
|
@year=0
|
||||||
|
POST https://cabinet.sut.ru/raspisanie_all_new.php
|
||||||
|
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||||
|
|
||||||
|
choice=1&schet={{schet}}&faculty={{facultyId}}&kurs={{year}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
# Get timetable for selected group
|
||||||
|
|
||||||
|
# Type here can be on of the following:
|
||||||
|
# 1 - for regular timetable (Занятия)
|
||||||
|
# 2 - for exams timetable (Экзаменационная сессия)
|
||||||
|
# 4 - for exams timetable for extramural students (Сессия для заочников)
|
||||||
|
# 14 - for attestations timetable (Зачеты)
|
||||||
|
|
||||||
|
@type=1
|
||||||
|
@groupId=55512
|
||||||
|
POST https://cabinet.sut.ru/raspisanie_all_new.php
|
||||||
|
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||||
|
|
||||||
|
schet={{schet}}&type_z={{type}}&group={{groupId}}
|
||||||
|
|
||||||
|
###
|
||||||
|
|
||||||
|
# Get page that contains current week number
|
||||||
|
|
||||||
|
# We use this page because it's the only known page that contains publicly accessible semester week number.
|
||||||
|
# Since regular timetable doesn't show normal dates for classes, and instead uses week numbers,
|
||||||
|
# we need to know a date of the first day of current semester to calculate dates for them
|
||||||
|
# (e.g. 3 week tuesday is = first day + 3 * 7 + 1)
|
||||||
|
|
||||||
|
# Since we know current date and weekday, by knowing week number we can calculate date for the first day.
|
||||||
|
|
||||||
|
GET https://www.sut.ru/studentu/raspisanie/raspisanie-zanyatiy-studentov-ochnoy-i-vecherney-form-obucheniya?group={{groupId}}
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
# VITE_BACKEND_HOST=https://api.bonch.xfox111.net
|
VITE_BACKEND_HOST=https://api.bonch.xfox111.net
|
||||||
VITE_BACKEND_HOST=http://localhost:8080
|
VITE_BACKEND_HOST=http://localhost:8080
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ services:
|
|||||||
image: xfox111/bonch-calendar-api:latest
|
image: xfox111/bonch-calendar-api:latest
|
||||||
build:
|
build:
|
||||||
context: ./api
|
context: ./api
|
||||||
|
environment:
|
||||||
|
- ORIGIN_DOMAIN=localhost:8000
|
||||||
ports:
|
ports:
|
||||||
- 8080:8080
|
- 8080:8080
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user