string labelText = doc.QuerySelector("a#rasp-prev + div:nth-child(2) > span")!.TextContent;
+
+ // Content of the
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 ParseListResponse(string responseContent) =>
responseContent
.Split(';', StringSplitOptions.RemoveEmptyEntries)
@@ -55,7 +103,8 @@ public class ApiService
parts => parts[1]
);
- public async Task SendRequestAsync(Dictionary formData)
+ // Utility method for sending request to sut.ru API.
+ private static async Task SendRequestAsync(Dictionary 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}";
}
}
diff --git a/api/Services/IssueTrackingService.cs b/api/Services/IssueTrackingService.cs
index 60d80e4..b1e5e64 100644
--- a/api/Services/IssueTrackingService.cs
+++ b/api/Services/IssueTrackingService.cs
@@ -1,5 +1,8 @@
namespace BonchCalendar.Services;
+///
+/// Service that tracks results of the most recent requests.
+///
public class IssueTrackingService
{
private bool _isLastFacultyFetchSuccessful = true;
@@ -8,12 +11,22 @@ public class IssueTrackingService
private readonly List _unsuccessfulTimetableFetches = [];
+ ///
+ /// Record whether the last attempt to retrieve faculty list was successful.
+ ///
+ /// Set true if the attempt was successful. Otherwise, set false
public void TrackFacultyFetch(bool isSuccessful) =>
_isLastFacultyFetchSuccessful = isSuccessful;
- public void TrackGroupFetch(int facultyId, int course, bool isSuccessful)
+ ///
+ /// Record whether the last attempt to retrieve groups for provided faculty and term year was successful.
+ ///
+ /// ID of a faculty which was used to retrieve the group list.
+ /// Term year which was used to retrieve the group list.
+ /// Set true if the attempt was successful. Otherwise, set false
+ 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);
}
+ ///
+ /// Record whether the last attempt to retrieve timetable for provided group was successful.
+ ///
+ /// ID of a faculty the group belongs to.
+ /// ID of a group the timetable was retrieved for.
+ /// Set true if the attempt was successful. Otherwise, set false
public void TrackTimetableFetch(int facultyId, int groupId, bool isSuccessful)
{
string key = $"{facultyId}/{groupId}";
@@ -37,6 +56,13 @@ public class IssueTrackingService
_unsuccessfulTimetableFetches.Remove(key);
}
+ ///
+ /// Get report on the success of latest retrieval attempts.
+ ///
+ /// A dictionary for each of the tracked groups.
+ ///
+ /// If the dictionary is empty, that means that there're no known issues.
+ ///
public Dictionary GetReport()
{
Dictionary 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;
}
}
diff --git a/api/Services/ParsingService.cs b/api/Services/ParsingService.cs
index d72cd05..e658deb 100644
--- a/api/Services/ParsingService.cs
+++ b/api/Services/ParsingService.cs
@@ -11,6 +11,13 @@ namespace BonchCalendar.Services;
public partial class ParsingService
{
+ ///
+ /// Parse general timetable document.
+ ///
+ /// HTML document retrieved from the API.
+ /// that represents the first day of current semester.
+ /// Name of a group this timetable is for.
+ /// An array of s
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];
}
+ ///
+ /// Parse exam timetable document.
+ ///
+ /// HTML document, retrieved from the API.
+ /// Name of a group this timetable is for.
+ /// An array of s
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;
diff --git a/api/Services/TimetableService.cs b/api/Services/TimetableService.cs
index c589dfa..95866b4 100644
--- a/api/Services/TimetableService.cs
+++ b/api/Services/TimetableService.cs
@@ -4,6 +4,9 @@ using Ical.Net.Serialization;
namespace BonchCalendar.Services;
+///
+/// Service for retrieving timetable.
+///
public class TimetableService(
ApiService apiService,
ParsingService parsingService,
@@ -11,7 +14,12 @@ public class TimetableService(
IHostEnvironment environment
)
{
- public async Task TryServingFromCacheAsync(int groupId)
+ ///
+ /// Try to retrieve timetable from application's cache.
+ ///
+ /// ID of a group to retrieve timetable for.
+ /// null if cache for this timetable is not present, or is older than 6 hours. Otherwise, timetable content in iCal format.
+ public async Task 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);
}
+ ///
+ /// Retrieve timetable for specified group from sut.ru API.
+ ///
+ /// If set to true, result timetable will be wirtten to cache for that group.
+ /// Action delegate that can be used to manipulate the result object, before converting it to iCal.
+ /// A string that contains timetable in iCal format.
public async Task GetTimetableAsync(int facultyId, int groupId, bool saveToCache = true, Action? 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 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)
diff --git a/api/StatsResponse.cs b/api/StatsResponse.cs
index bf2d580..f3eb6dd 100644
--- a/api/StatsResponse.cs
+++ b/api/StatsResponse.cs
@@ -1,3 +1,7 @@
namespace BonchCalendar;
+///
+/// Response body object for /stats endpoint.
+///
+/// Number of active users.
public record StatsResponse(int ActiveUsers);
diff --git a/api/TimetableType.cs b/api/TimetableType.cs
index dc0d68e..61b3260 100644
--- a/api/TimetableType.cs
+++ b/api/TimetableType.cs
@@ -1,9 +1,27 @@
namespace BonchCalendar;
+///
+/// Types of timetable documents retrieved from sut.ru API.
+///
public enum TimetableType
{
+ ///
+ /// Regular timetable document (Занятия).
+ ///
Classes = 1,
+
+ ///
+ /// Exams timetable document (Экзаменационная сессия).
+ ///
Exams = 2,
+
+ ///
+ /// Exams timetable for extramural students document (Сессия для заочников).
+ ///
ExamsForExtramural = 4,
+
+ ///
+ /// Attestations timetable document (Зачеты).
+ ///
Attestations = 14
}
diff --git a/api/Utils/ParserUtils.cs b/api/Utils/ParserUtils.cs
index 1a4c8e9..deef33b 100644
--- a/api/Utils/ParserUtils.cs
+++ b/api/Utils/ParserUtils.cs
@@ -2,8 +2,20 @@ using System.Text.RegularExpressions;
namespace BonchCalendar.Utils;
+///
+/// Utility methods for timetable parser.
+///
public static partial class ParserUtils
{
+ ///
+ /// Get class start and end times from class' number label.
+ ///
+ /// Class' number label.
+ /// A tuple value of start and end times.
+ ///
+ /// This method is only supposed to be used as a fallback method of determening class' time
+ ///
+ /// Unknown label encountered.
public static (TimeSpan startTime, TimeSpan endTime) GetTimesFromLabel(string label)
{
(string startTime, string endTime) = label switch
diff --git a/api/appsettings.Development.json b/api/appsettings.Development.json
deleted file mode 100644
index 0c208ae..0000000
--- a/api/appsettings.Development.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "Logging": {
- "LogLevel": {
- "Default": "Information",
- "Microsoft.AspNetCore": "Warning"
- }
- }
-}
diff --git a/api/appsettings.json b/api/appsettings.json
index 74084f2..9c3a5de 100644
--- a/api/appsettings.json
+++ b/api/appsettings.json
@@ -3,9 +3,6 @@
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
- },
- "Console": {
- "IncludeScopes": true
}
},
"AllowedHosts": "*"
diff --git a/api/sut.ru.http b/api/sut.ru.http
new file mode 100644
index 0000000..2959db3
--- /dev/null
+++ b/api/sut.ru.http
@@ -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}}
diff --git a/app/.env b/app/.env
index 9fb1467..a75029b 100644
--- a/app/.env
+++ b/app/.env
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
index 806a58d..b8bccdd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,6 +3,8 @@ services:
image: xfox111/bonch-calendar-api:latest
build:
context: ./api
+ environment:
+ - ORIGIN_DOMAIN=localhost:8000
ports:
- 8080:8080