Привет, Хабр! Последнюю неделю ушедшего лета не мог найти себе места, так как со дня на день должен был выйти приказ о моем зачислении в университет в магистратуру, но приказ предательски все никак не выходит, списки поступивших не обновляются, а я ежедневно захожу на страницу универа, проверяя изменилось ли что-нибудь. И ладно бы, если один раз в день.
В какой-то момент мне это надоело, и я вспомнил, что я – разработчик, в конце концов, поэтому зачем что-то проверять руками, если это можно автоматизировать? Собственно, так и пришла в голову идея о простом разовом проекте, который будет регулярно стучать на сайт, проверять статус зачисления и, в случае изменения, присылать радостное сообщение в телеграм.

Что нам понадобится для этого? Пара свободных часов, чтобы развернуть простое .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.
Бенчмарк
Результат
Код: