Привет, Хабр! Последнюю неделю ушедшего лета не мог найти себе места, так как со дня на день должен был выйти приказ о моем зачислении в университет в магистратуру, но приказ предательски все никак не выходит, списки поступивших не обновляются, а я ежедневно захожу на страницу универа, проверяя изменилось ли что-нибудь. И ладно бы, если один раз в день.
В какой-то момент мне это надоело, и я вспомнил, что я – разработчик, в конце концов, поэтому зачем что-то проверять руками, если это можно автоматизировать? Собственно, так и пришла в голову идея о простом разовом проекте, который будет регулярно стучать на сайт, проверять статус зачисления и, в случае изменения, присылать радостное сообщение в телеграм.
Что нам понадобится для этого? Пара свободных часов, чтобы развернуть простое .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
Далее начнется сборка и запуск. Если нигде не ошиблись, то увидим знакомые логи в консоли.
В принципе, этого для наших потребностей достаточно. Абитуриент может через бота проверить свой статус, а также подписаться на уведомление, если он окажется в списках зачисленных на обучение. Что еще нужно для студенческого счастья? Остается надеяться, что в следующем году структура списков не сильно изменится, а пока бот доступен для проверки. Полностью код доступен в репозитории.
Можете также подписаться на мой телеграм, чтобы быть в курсе планов выхода следующих статей.
ссылка на оригинал статьи https://habr.com/ru/articles/840978/
Добавить комментарий