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

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

Стартовое приветствие
Стартовое приветствие

Что нам понадобится для этого? Пара свободных часов, чтобы развернуть простое .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% от всех абитуриентов уже зачислены (к моменту написания статьи уже наступил сентябрь и прошли первые организационные встречи), но через год возможно для кого-то будет актуально. План таков.

  1. С сайта университета собрать ссылки на все списки с зачисленными студентами.

  2. Каждый такой список разобрать и сохранить в базу список зачисленных студентов, а также периодически обновлять его.

  3. Предоставить возможность через телегу узнать свой статус, а также подписаться на изменение информации.

Итак, сперва создадим наши модели, которые будем хранить в базе (в качестве базы подойдет самый простой 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

Далее начнется сборка и запуск. Если нигде не ошиблись, то увидим знакомые логи в консоли.

В принципе, этого для наших потребностей достаточно. Абитуриент может через бота проверить свой статус, а также подписаться на уведомление, если он окажется в списках зачисленных на обучение. Что еще нужно для студенческого счастья? Остается надеяться, что в следующем году структура списков не сильно изменится, а пока бот доступен для проверки. Полностью код доступен в репозитории.


Можете также подписаться на мой телеграм, чтобы быть в курсе планов выхода следующих статей.

Комментарии (2)


  1. SemenPetrov
    06.09.2024 17:57

    Всегда было интересно, зачем делать что-то типа:

    int counter = 0;
    foreach (Direction direction in directions)
    {
      ...
        ++counter;
    }

    Вместо простого цикла "for" который тебе этот counter автоматом предоставит, плюс ещё без потенциальных ошибок, когда несколько раз его заинкрементишь или наборот.


    1. Vasjen Автор
      06.09.2024 17:57

      Вместо простого цикла "for" который тебе этот counter автоматом предоставит,

      Конкретно мне инкремент и не сильно нужен, я это добавлял для отладки и не убрал.

      Всегда было интересно, зачем делать что-то типа:

      Ходят легенды, что foreach более производительный, чем for. Особенно если перебор идет по каким-то коллекциям классов, а не примитивным типам вроде int.

      Бенчмарк

      Результат

      Код:

      using BenchmarkDotNet.Attributes;
      using BenchmarkDotNet.Running;
      
      public record Direction(BudgetType BudgetType, string Name, string Code, EducationFormat Format);
      
      public enum BudgetType
      {
          Type1,
          Type2,
          Type3
      }
      
      public enum EducationFormat
      {
          Format1,
          Format2,
          Format3
      }
      [MemoryDiagnoser]
      public class Benchmark
      {
          private List<Direction> directions;
      
          [Params(1000, 10000, 100000,1000000)]
          public int N;
      
          [GlobalSetup]
          public void GlobalSetup()
          {
              directions = new List<Direction>();
              for (int i = 0; i < N; i++)
              {
                  directions.Add(new Direction(BudgetType.Type1, $"Name{i}", $"Code{i}", EducationFormat.Format1));
              }
          }
      
          [Benchmark]
          public void ForeachLoop()
          {
              foreach (var direction in directions)
              {
                  var some = direction;
              }
          }
      
          [Benchmark]
          public void ForLoop()
          {
              for (int i = 0; i < directions.Count; i++)
              {
                  var direction = directions[i];
              }
          }
      }
      
      class Program
      {
          static void Main(string[] args)
          {
              var summary = BenchmarkRunner.Run<Benchmark>();
          }
      }