Привет, Хабр! Последнюю неделю ушедшего лета не мог найти себе места, так как со дня на день должен был выйти приказ о моем зачислении в университет в магистратуру, но приказ предательски все никак не выходит, списки поступивших не обновляются, а я ежедневно захожу на страницу универа, проверяя изменилось ли что-нибудь. И ладно бы, если один раз в день.
В какой-то момент мне это надоело, и я вспомнил, что я – разработчик, в конце концов, поэтому зачем что-то проверять руками, если это можно автоматизировать? Собственно, так и пришла в голову идея о простом разовом проекте, который будет регулярно стучать на сайт, проверять статус зачисления и, в случае изменения, присылать радостное сообщение в телеграм.
Что нам понадобится для этого? Пара свободных часов, чтобы развернуть простое .NET приложение и хостинг, где это можно будет запустить. В качестве приложения я создам ASP.NET приложение, в качестве размещения решил не заморачиваться с VPS, а прямо из репозитория запушить в облако Amvera Cloud.
Так родился маленький проект, который можно было уместить в одном Program.cs файле. Мы знаем страницу, где публикуется информация о зачислении. К сожалению, на сайт она с бэка приходит в виде готовой HTML страницы, поэтому придется скачивать ее и искать нужную информацию. В общем виде таблица по каждой программе подготовке выглядит следующим образом.
Соответственно, нам надо время от времени заходить HttpClient
’ом на страницу, искать нужную строчку в таблице с нашим номером и проверять последний столбец. Логика простая и топорная, но главное, что работает. Были у меня тщетные попытки предотвратить загрузку, как-то поняв заранее, изменилась ли информация на странице. Я пробовал использовать ETag
и If-None-Match
, Last-Modified
и If-Modified-Since
, но при каждом запросе 304 так и не вернулось. Поэтому через HtmlAgilityPack
стал просто искать нужную строку по номер и проверять последнее значение.
using HttpClient client = new HttpClient();
string pageContent = await client.GetStringAsync(url);
HtmlDocument document = new HtmlDocument();
document.LoadHtml(pageContent);
string xpath = $"//tr[td[contains(text(), '{targetNumber}')]]";
HtmlNode? targetRow = document.DocumentNode.SelectSingleNode(xpath);
if (targetRow != null)
{
HtmlNode? statusNode = targetRow.SelectSingleNode("td[last()]");
if (statusNode != null)
{
string status = statusNode.InnerText.Trim();
Console.WriteLine($"Статус для номера {targetNumber}: {status}");
}
else
{
Console.WriteLine($"Не удалось найти статус для номера {targetNumber}");
}
}
else
{
Console.WriteLine($"Не удалось найти строку для номера {targetNumber}");
}
На этом вроде бы можно было и закончить, через крон просто ходи и проверяй изменения статуса, и отправляй в тг информацию о статусе. Но, к моменту, когда я сделал бота я выяснил две неприятные вещи. Во-первых, я не там искал информацию. Оказывается, есть конкурсные списки (где я изначально и искал), где есть информация по зачислению, а есть отдельные списки зачисленных. Выглядят одинаково, суть такая же, только во втором варианте нет информации по вакантным местам, только информация по зачисленным, и обновляется он чаще. А во-вторых, когда я понял это, то узнал, что я зачислен, и надобность в боте отпала само собой.
Однако я понял, что давненько не писал код вне рабочих задач, да и идея показалась интересной – сделать бота, который будет хранить информацию о всех имеющихся программах ВУЗа, хранить информацию по зачислению в рамках приемной кампании и предоставлять возможность проверить статус зачисления, а также подписаться на изменение статуса. И хоть в текущем году уже не актуально, и, наверное, процентов 90% от всех абитуриентов уже зачислены (к моменту написания статьи уже наступил сентябрь и прошли первые организационные встречи), но через год возможно для кого-то будет актуально. План таков.
С сайта университета собрать ссылки на все списки с зачисленными студентами.
Каждый такой список разобрать и сохранить в базу список зачисленных студентов, а также периодически обновлять его.
Предоставить возможность через телегу узнать свой статус, а также подписаться на изменение информации.
Итак, сперва создадим наши модели, которые будем хранить в базе (в качестве базы подойдет самый простой SqLite).
// Образовательное направление
public class Education
{
public int Id { get; set; }
public string Name { get; set; }
public EducationFormat Format { get; set; }
public string Code { get; set; }
public BudgetType BudgetType { get; set; }
//Navigation properties
public List<Student> Students { get; set; } = [];
}
//Зачисленные счастливчики
public class Student
{
public int Id { get; set; }
public string? Snils { get; set; }
public int RegistrationNumber { get; set; }
//Navigation properties
public int EducationId { get; set; }
public Education Education { get; set; }
//Наши подписчики
public class Subscriber
{
public int Id { get; set; }
public long ChatId { get; set; }
public int RegistrationNumber { get; set; }
public bool IsNotified { get; set; }
}
Настраиваем контекст, индексы, хотя с учетом количества записей они возможны и не нужны.
public DbSet<Education> Educations { get; set; }
public DbSet<Student> Students { get; set; }
public DbSet<Subscriber> Subscribers { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Education>().HasIndex(p => p.Code).IsUnique();
modelBuilder.Entity<Student>().HasIndex(p => p.RegistrationNumber).IsUnique();
modelBuilder.Entity<Subscriber>().HasIndex(p => p.RegistrationNumber);
}
Нашу базу нужно инициализировать начальными данными, нужно взять с сайта все возможные программы обучения со ссылками на списки зачисления. На словах это просто, но реализация усложнилась тем, что три вида образования (бакалавриат, магистратура и аспирантура), имеют разную структуру, а значит вместо одного универсального парсера, придется делать три, со своими своеобразными правилами.
Для понимания приведу пример того, насколько визуально отличаются направления образовательных программ.
Направления обучения
Из-за таких особенностей сервис по парсингу вышел довольно увесистым, ибо унифицировать это не вышло, так как хотел помимо имени и ссылки хранить информацию о формате обучения (бюджет, целевое, платное).
Добавим билдер для компактной инициализации.
public async Task InitAsync()
{
int counter = 0;
foreach (Direction direction in directions)
{
Education education = new();
education.Name = direction.Name;
education.Format = direction.Format;
education.Code = direction.Code;
education.BudgetType = direction.BudgetType;
if (await db.Educations.AnyAsync(e => e.Code == education.Code))
{
logger.LogInformation($"{++counter} Already in db => {education.Name} {education.Code} {education.BudgetType} {education.Format}.");
continue;
}
await db.Educations.AddAsync(education);
logger.LogInformation($"{++counter} Added to db => {education.Name} {education.Code} {education.BudgetType} {education.Format}.");
}
await db.SaveChangesAsync();
}
public EducationBuilder WithBachelor()
{
directions.AddRange(parserService.ParseBachelorAsync().GetAwaiter().GetResult());
return this;
}
public EducationBuilder WithMaster()
{
directions.AddRange(parserService.ParseMasterAsync().GetAwaiter().GetResult());
return this;
}
public EducationBuilder WithPostgraduate()
{
directions.AddRange(parserService.ParsePostgraduateAsync().GetAwaiter().GetResult());
return this;
}
В итоге получился довольно аккуратный сервис инициализации.
public async Task InitDb()
{
await db.Database.MigrateAsync();
if (!db.Educations.Any())
{
await educationBuilder
.WithBachelor()
.WithMaster()
.WithPostgraduate()
.InitAsync();
}
}
Делаем сервис, проверяющий зачислен ли человек с указанным номером (есть ли такой студент в нашей бд), в котором будет метод, работающий в фоне.
public class EnrolledService(AppDbContext db, ParserService parserService, UrlsConfig urls)
{
public async Task UpdateEnrolledAsync()
{
List<Education> educations = await db.Educations.ToListAsync();
foreach (Education education in educations)
{
string url = GetEducationLink(education);
List<EstimationResult?> students = await parserService.ParseAsync(url);
await AddToDb(students, education);
}
}
public async Task<EstimationResponseDto?> CheckStudentAsync(int registrationNumber)
{
var student = await db.Students
.Include(s => s.Education)
.FirstOrDefaultAsync(s => s.RegistrationNumber == registrationNumber);
return student?.StudentInfoToDto();
}
}
Далее нам нужен сервис, который будет обрабатывать наши подписки
public async Task SubscribeAsync(long chatId, int registrationNumber)
{
if (await db.Subscribers.AnyAsync(s => s.ChatId == chatId))
{
throw new Exception("Already subscribed");
}
await db.Subscribers.AddAsync(new Subscriber
{
ChatId = chatId,
RegistrationNumber = registrationNumber
});
await db.SaveChangesAsync();
}
//При отправке уведомления подписчику вызываем метод, чтобы его не спамить.
public async Task MakeSubscriberNotifiedAsync(Subscriber subscriber)
{
subscriber.IsNotified = true;
await db.SaveChangesAsync();
}
public async Task UnsubscribeAsync(long chatId)
{
var subscriber = await db.Subscribers.FirstOrDefaultAsync(s => s.ChatId == chatId);
if (subscriber == null)
{
throw new Exception("Not subscribed");
}
db.Subscribers.Remove(subscriber);
await db.SaveChangesAsync();
}
public async Task<bool> IsSubscribedAsync(long chatId)
=> await db.Subscribers.AnyAsync(s => s.ChatId == chatId);
public async Task<List<Subscriber>> GetAllActiveSubscribers()
=> await db.Subscribers.Where(p => !p.IsNotified).ToListAsync();
И добавим сервис уведомлений. Мы будем отправлять сообщения и перехватывать исключения (например, если пользователь подписался и заблокировал бота). По сути, это небольшая обертка над обычным SendTextMessageAsync()
public async Task NotifyAsync(long chatId, string message, ReplyKeyboardMarkup? replyMarkup = null, CancellationToken cancellationToken = default)
{
try
{
await botClient.SendTextMessageAsync(chatId, message, replyMarkup: replyMarkup, parseMode: ParseMode.Html, cancellationToken: cancellationToken);
}
catch (ApiRequestException e)
{
// Проверяем, не заблокировал ли нас подписчик. И если да, то удаляем его.
if (e.ErrorCode == 403)
{
Subscriber? subscriber = await db.Subscribers.FirstOrDefaultAsync(s => s.ChatId == chatId, cancellationToken: cancellationToken);
if (subscriber is not null)
{
logger.LogWarning($"Chat with id {chatId} blocked the bot and will be unsubscribed forcefully");
db.Subscribers.Remove(subscriber);
await db.SaveChangesAsync(cancellationToken);
}
logger.LogError(e, $"Error while sending message to chat {chatId}.\n Details: {e.Message}");
}
}
catch (Exception e)
{
logger.LogError(e, $"Error while sending message to chat {chatId}.\n Details: {e.Message}");
}
}
И настраиваем работу по расписанию. Думаю достаточно, ходить раз в полчаса и добавлять новых зачисленных студентов, если есть такие. Ну и сразу проверяем, есть ли у нас подписчики среди студентов, чтобы отправить уведомление о зачислении, а также исключить автоматом из дальнейшей рассылки. Для этого я использую WorkerService
public abstract class BackgroundWorkerService : BackgroundService
{
private TaskStatus DoWorkStatus { get; set; } = TaskStatus.Created;
protected abstract int ExecutionInterval { get; }
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
protected abstract Task DoWork(CancellationToken cancellationToken);
}
И уже его наследует конкретная реализация.
public class UpdaterWorkerService(IServiceScopeFactory scopeFactory) : BackgroundWorkerService
{
protected override int ExecutionInterval { get; } = (int)TimeSpan.FromMinutes(30).TotalMilliseconds;
protected override async Task DoWork(CancellationToken cancellationToken)
{
...
}
}
Ну и осталась самая базовая вещь, которая есть в любом телеграм боте – обработка входящих запросов от пользователя. Я не стал сильно заморачиваться на эту тему, за основу взял пример из документации библиотеки.
Также сделал HostedService, который обрабатывает вводимые данные, а пользователю сделал кнопки для удобства ввода. Все управлению свелось к switch с сохранением контекста. Поскольку у нас нет разветвленных диалогов, то этого достаточно, но для более сложных целей из этого будет очень долгая портянка условий.
Осталось дело за малым – развернуть наше приложение. Создаем новый проект в Amvera, выбираем тарифный план и придумываем название.
Начальное окно с настройками можно пропустить и настроить все вручную. В первую очередь, надо настроить конфигурацию запуска. Я использую Dockerfile, поэтому его и конфигурировал. Все что нужно – указать параметры запуска контейнера, сам docker run указывать не нужно, нужны только аргументы и параметры и указать путь к Dockerfile. Я в качестве параметров передаю запуск из под рута и подключение секретов: -d -u root -v /data/4bb1be19-6baf-40ce-9bf9-784d4afcf59a/:/root/.microsoft/usersecrets/4bb1be19-6baf-40ce-9bf9-784d4afcf59a/:ro
Сами же секреты можно положить в папку Data, там же можно разместить и базу, чтобы она оставалась после удаления контейнера.
Теперь нужно поправить пути в проекте, чтобы все ссылки сходились. В appsettings.json
"ConnectionStrings": {
"SqliteConnection": "Data Source=/data/app.db" //указали /data
},
И подключить смонтированный файл с секретами
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
// добавляем файл, который добавили в /data
.AddJsonFile("/data/usersecrets/4bb1be19-6baf-40ce-9bf9-784d4afcf59a/secrets.json")
.Build();
Ну и все, финальный шаг – синхронизировать репозиторий проекта. Просто добавляем внешний репозиторий командой
git remote add amvera https://git.amvera.ru/<ваша учетная запись>/<имя проекта>
И затем залить изменения, заодно вызвав тригер начала сборки
git push amvera master
Далее начнется сборка и запуск. Если нигде не ошиблись, то увидим знакомые логи в консоли.
В принципе, этого для наших потребностей достаточно. Абитуриент может через бота проверить свой статус, а также подписаться на уведомление, если он окажется в списках зачисленных на обучение. Что еще нужно для студенческого счастья? Остается надеяться, что в следующем году структура списков не сильно изменится, а пока бот доступен для проверки. Полностью код доступен в репозитории.
Можете также подписаться на мой телеграм, чтобы быть в курсе планов выхода следующих статей.
SemenPetrov
Всегда было интересно, зачем делать что-то типа:
Вместо простого цикла "for" который тебе этот counter автоматом предоставит, плюс ещё без потенциальных ошибок, когда несколько раз его заинкрементишь или наборот.
Vasjen Автор
Конкретно мне инкремент и не сильно нужен, я это добавлял для отладки и не убрал.
Ходят легенды, что foreach более производительный, чем for. Особенно если перебор идет по каким-то коллекциям классов, а не примитивным типам вроде int.
Бенчмарк
Результат
Код: