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:
@@ -4,8 +4,19 @@ using BonchCalendar.Utils;
|
||||
|
||||
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
|
||||
{
|
||||
/// <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() =>
|
||||
ParseListResponse(await SendRequestAsync(new()
|
||||
{
|
||||
@@ -13,15 +24,30 @@ public class ApiService
|
||||
["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()
|
||||
{
|
||||
["choice"] = "1",
|
||||
["schet"] = GetCurrentSemesterId(),
|
||||
["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) =>
|
||||
await SendRequestAsync(new()
|
||||
{
|
||||
@@ -30,14 +56,34 @@ public class ApiService
|
||||
["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)
|
||||
{
|
||||
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}");
|
||||
|
||||
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;
|
||||
|
||||
// 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);
|
||||
|
||||
DateTime currentDate = DateTime.Today;
|
||||
currentDate = currentDate
|
||||
.AddDays(-(int)currentDate.DayOfWeek + 1) // Move to Monday
|
||||
@@ -46,6 +92,8 @@ public class ApiService
|
||||
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) =>
|
||||
responseContent
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries)
|
||||
@@ -55,7 +103,8 @@ public class ApiService
|
||||
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")
|
||||
{
|
||||
@@ -64,7 +113,8 @@ public class ApiService
|
||||
|
||||
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
|
||||
});
|
||||
|
||||
@@ -81,11 +131,12 @@ public class ApiService
|
||||
? 1 // August through January - first semester
|
||||
: 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
|
||||
termStartYear--;
|
||||
academicYearStartYear--;
|
||||
|
||||
return $"205.{termStartYear}{termStartYear + 1}/{currentSemester}";
|
||||
return $"205.{academicYearStartYear}{academicYearStartYear + 1}/{currentSemester}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
namespace BonchCalendar.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service that tracks results of the most recent requests.
|
||||
/// </summary>
|
||||
public class IssueTrackingService
|
||||
{
|
||||
private bool _isLastFacultyFetchSuccessful = true;
|
||||
@@ -8,12 +11,22 @@ public class IssueTrackingService
|
||||
|
||||
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) =>
|
||||
_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)
|
||||
{
|
||||
@@ -24,6 +37,12 @@ public class IssueTrackingService
|
||||
_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)
|
||||
{
|
||||
string key = $"{facultyId}/{groupId}";
|
||||
@@ -37,6 +56,13 @@ public class IssueTrackingService
|
||||
_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()
|
||||
{
|
||||
Dictionary<string, object> report = [];
|
||||
@@ -50,6 +76,26 @@ public class IssueTrackingService
|
||||
if (_unsuccessfulTimetableFetches.Count > 0)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@ namespace BonchCalendar.Services;
|
||||
|
||||
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)
|
||||
{
|
||||
using IHtmlDocument doc = new HtmlParser().ParseDocument(rawHtml);
|
||||
@@ -27,7 +34,7 @@ public partial class ParsingService
|
||||
Match timeMatch = ParserUtils.TimeLabelRegex().Match(timeLabelText);
|
||||
string number = timeMatch.Success ? timeMatch.Groups["number"].Value : timeLabelText;
|
||||
(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["end"].Value)
|
||||
@@ -44,16 +51,26 @@ public partial class ParsingService
|
||||
.AddDays((week - 1) * 7) // Move to the correct week
|
||||
.AddDays(weekday - 1); // Move to the correct weekday
|
||||
|
||||
classes.Add(GetEvent(
|
||||
$"{number}. {className} ({classType})", auditorium,
|
||||
GetDescription(groupName, professors, auditorium, weeks),
|
||||
classDate, startTime, endTime));
|
||||
classes.Add(CreateEvent(
|
||||
title: $"{number}. {className} ({classType})",
|
||||
location: auditorium,
|
||||
description: CreateDescription(groupName, professors, auditorium, weeks),
|
||||
date: classDate,
|
||||
startTime,
|
||||
endTime
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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 endTime = TimeSpan.Parse(timeMatch.Groups["end"].Value.Replace('.', ':'));
|
||||
|
||||
classes.Add(GetEvent(
|
||||
$"{number}{className} ({classType})", auditorium,
|
||||
GetDescription(groupName, professors, auditorium),
|
||||
classDate, startTime, endTime));
|
||||
classes.Add(CreateEvent(
|
||||
title: $"{number}{className} ({classType})",
|
||||
location: auditorium,
|
||||
description: CreateDescription(groupName, professors, auditorium),
|
||||
date: classDate,
|
||||
startTime,
|
||||
endTime
|
||||
));
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
Summary = title,
|
||||
Description = description,
|
||||
Start = new CalDateTime(date.Add(startTime - 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 = $"""
|
||||
Группа: {groupName}
|
||||
@@ -107,22 +130,26 @@ public partial class ParsingService
|
||||
if (weeks is not null && weeks.Length > 0)
|
||||
str += $"\nНедели: {string.Join(", ", weeks)}";
|
||||
|
||||
// Attempt to recognize wing and room number
|
||||
Match auditoriumMatch = ParserUtils.AuditoriumRegex().Match(auditorium);
|
||||
|
||||
if (!auditoriumMatch.Success)
|
||||
auditoriumMatch = ParserUtils.AuditoriumAltRegex().Match(auditorium);
|
||||
|
||||
// If successful, we can add a nav.sut.ru map link
|
||||
if (auditoriumMatch.Success)
|
||||
str += "\n\n" + $"""
|
||||
ГУТ.Навигатор:
|
||||
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";
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
// Parse basic info for a class
|
||||
private static (string className, string classType, string[] professors, string auditorium) ParseBaseInfo(IElement classElement)
|
||||
{
|
||||
string className = classElement.QuerySelector(".subect")?.TextContent ?? string.Empty;
|
||||
|
||||
@@ -4,6 +4,9 @@ using Ical.Net.Serialization;
|
||||
|
||||
namespace BonchCalendar.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for retrieving timetable.
|
||||
/// </summary>
|
||||
public class TimetableService(
|
||||
ApiService apiService,
|
||||
ParsingService parsingService,
|
||||
@@ -11,7 +14,12 @@ public class TimetableService(
|
||||
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);
|
||||
|
||||
@@ -24,17 +32,28 @@ public class TimetableService(
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogInformation("Calendar for group {GroupId} is present in cache ({CacheFile}).", groupId, 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)
|
||||
{
|
||||
// We need semester start date, since the regular timetable is represented on sut.ru semester week numbers.
|
||||
DateTime semesterStartDate = await apiService.GetSemesterStartDateAsync(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);
|
||||
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];
|
||||
foreach (TimetableType type in types)
|
||||
{
|
||||
@@ -42,13 +61,17 @@ public class TimetableService(
|
||||
timetable.AddRange(parsingService.ParseExamTimetable(classesRaw, groupName));
|
||||
}
|
||||
|
||||
// Create and configure the calendar.
|
||||
Calendar calendar = new();
|
||||
calendar.AddTimeZone(new VTimeZone("Europe/Moscow"));
|
||||
calendar.Properties.Add(new CalendarProperty("X-WR-CALNAME", groupName));
|
||||
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);
|
||||
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)!;
|
||||
|
||||
if (saveToCache)
|
||||
|
||||
Reference in New Issue
Block a user