Будучи .NET разработчиком, я стараюсь периодически просматривать различные ресурсы, связанные с .NET тематикой. Как правило, это различные блоги. Иногда то тут, то там появляются какие-нибудь интересные статьи, на которые стоит обратить внимание. Недавно я поймал себя на мысли, что делать это вручную мне как-то поднадоело. Тем более, что просматриваю я обычно одни и те же сайты. А значит время заняться автоматизацией. Идея была достаточно проста. Нужен скрипт, который сходит по разным ссылкам, разберёт содержимое и отправит обновление в какой-нибудь telegram-канал. Нужно было только придумать, как этот скрипт запускать и где сохранять результат его работы, чтобы знать, что уже было отправлено, а что нет. Я решил использовать в качестве хранилища GitHub, а сам скрипт (написан на TypeScript) запускать по расписанию с помощью GitHub Actions. В этой статье я хочу рассказать про некоторые технические детали реализации и поделиться полученным результатом. За основу проекта я взял минимальный GitHub Action, который описывал в этой статье, и исходный код которого можно найти тут. Точкой входа в проект является функция main, которая (упрощённо) выглядит следующим образом: Здесь используются три типа сущностей: Scraper - интерфейс, отвечающий за парсинг различных источников. Он имеет несколько реализаций для каждого конкретного сайта с соответствующим набором костылей (например для andrewlock.net или devblogs.microsoft.com). Sender - класс, отвечающий за отправку сообщений в Telegram. Storage - класс отвечающий за сохранение ссылок на отправленные сообщения в файлы, чтобы при каждом новом запуске отправлять в Telegram только новые сообщения. Общая логика тут довольна проста. Все реализации Scraper перебираются в цикле и парсят соответствующий сайт. Каждый Scraper получает в качестве параметров ссылки на Storage, чтобы знать, куда сохранить результат своей работы и Sender, чтобы отправить новые посты (которых ещё нет в Storage) в Telegram. Чтобы запускать скрипт из GitHub я добавил в репозиторий файл рабочего процесса .github/workflows/scrape.yml: Тут я добавил два триггера: push - перезапускает скрипт при каждом пуше, который изменяет файл dist/index.js. (Это файл скрипта, собранный в githook через @vercel/ncc, который фактически запускается в GitHub. Для чего это нужно можно подробнее прочитать в этой статье) schedule - запускает скрипт по расписанию в 42 минуту каждого второго часа. Тут стоит отметить, что GitHub не строго гарантирует запуск в это время. Он может опаздывать, и иногда задержка может составлять до 20-30 минут. Сам рабочий процесс состоит из трёх шагов: Checkout - выкачивает исходники репозитория в рабочую директорию. Scrape - собственно сам процесс запуска GitHub Action, который находится в корне этого же репозитория (ссылка uses: ./). Про третий шаг я расскажу чуть позже. Здесь я хочу немного отклониться и поговорить про логирование. В целом, никто не мешает писать логи обычным console.log, но для GitHub Actions есть более удобный способ. Существует набор полезных npm пакетов для разработки, который называется GitHub Actions Toolkit. В него в частности входит пакет @actions/core, в котором есть набор полезных функций для логирования. Во-первых, для улучшения читабельности длинных логов можно группировать их при помощи функции core.group, достаточно просто обернуть кусок кода в вызов этой функции: У меня в логах это выглядит следующим образом: Во-вторых, искать в логах все сообщения об ошибках может быть немного утомительно, однако если если для логирования используются функции core.error, core.warning или core.notice, то все такие сообщения будут выведены на главную страницу рабочего процесса в виде аннотаций и будут сразу бросаться в глаза. Кроме этого вместе с сообщением, которое просто выводится в лог, можно также задать заголовок для аннотации (и даже ссылки на место в файле, где случилась ошибка, если это необходимо).  Выглядят аннотации в интерфейсе следующим образом: Поскольку я планировал сохранять результаты работы в git репозиторий, проще всего было хранить их в виде обычных текстовых файлов. Я решил, что каждая реализация Scraper будет иметь собственную папку в репозитории, и дополнительно сделал разбивку по месяцам (чтобы в перспективе можно было автоматически удалять старые файлы). В итоге у меня получилась такая структура файлов: Каждый раз, когда какой либо Scraper отправляет сообщение в Telegram, он дописывает ссылку в соответствующий файл. После того, как все Scraper отработали, остаётся только сохранить изменённые файлы в репозиторий. Сделать это можно несколькими способами. Можно воспользоваться пакетом @actions/exec (ссылка) и просто вызвать из кода команды git add, git commit и git push. Этот способ также потребует сначала вызвать git config, чтобы указать имя пользователя и эл. почту (можно использовать специальную почту <UserName>@users.noreply.github.com, которая есть у всех пользователей GitHub). Я же решил воспользоваться готовым решением и использовать GitHub Action из Marketplace: Add & Commit. Это третий шаг пайплайна, который используется следующим образом: Этот шаг просто проверяет, есть ли какие-нибудь незакомиченные изменения в указанной папке (в моём случае это папка data) и, если они есть, делает коммит с указанным сообщением. Я также добавил дополнительное условие запуска на ветку main (т.к. запуски из других веток отправляют сообщения в приватный канал, который я использую для отладки, и их не нужно коммитить). А также условие always(), чтобы коммит происходил даже в том случае, если предыдущий шаг упал (т.к. он всё же мог что-то отправить и записать в файлы). Для отправки сообщений в Telegram я решил воспользоваться готовым клиентом из пакета telegraf. Пользоваться им достаточно просто. Достаточно создать экземпляр класса Telegram и можно отправлять сообщения. Но для этого сначала необходимо получить два параметра: token и chatId. Все сообщения отправляются в Telegram от имени бота. Чтобы его создать необходимо воспользоваться другим ботом BotFather, который задаст несколько вопросов и в конце выдаст токен. Параметр chatId определяет в какой чат бот будет отправлять сообщения. С этим параметром всё несколько сложнее: Для публичных каналов можно просто использовать имя канала. Если канал приватный, то chatId - это некоторый отрицательный числовой идентификатор. Его можно узнать через бота IDBot. Для этого ему нужно отправить инвайт в этот приватный канал и в ответ получить идентификатор чата. Также можно отправлять сообщения от имени бота самому себе (в чат с ботом). Чтобы узнать идентификатор этого чата нужно сначала отправить какое-нибудь сообщение боту, а затем перейти по ссылке: https://api.telegram.org/bot<BotToken>/getUpdates. В полученном json можно будет найти параметр chatId. Последний способ я использовал, чтобы отправлять себе сообщения об ошибках, чтобы иметь возможность быстро на них реагировать. Все переменные я указал в настройках проекта на GitHub в разделе Secrets, и они передаются в GitHub Action через параметры в файле рабочего процесса: Мне также хотелось, чтобы сообщения в канале выглядели более или менее симпатично, поэтому для форматирования я использовал html, который Telegram поддерживает в очень ограниченном виде, а также при реализации Scraper добавил логику поиска картинок на сайтах. В итоге код отправки сообщений у меня выглядит следующим образом: Тут нужно учесть, что если у сообщения есть картинка, то длина подписи к ней не может превышать 1024 символа, иначе Telegram вернёт ошибку. Поэтому длинные сообщения приходится отправлять просто через sendMessage. При отправке gif, чтобы они нормально отображались, их следует отправлять как анимацию через sendAnimation. Все остальные картинки можно отправлять как фото через sendPhoto. В итоге отформатированные сообщения в канале выглядят следующим образом: Как я уже упоминал, я сделал несколько реализаций Scraper для парсинга разных сайтов, но все они работают примерно по одному и тому же алгоритму: Каждая реализация Scraper имеет метод с асинхронным генератором readPosts, который выполняет поиск новых постов и возвращает их по одному. Ссылка каждого поста проверяется в хранилище и если пост уже там присутствует, то парсинг прекращается. Асинхронный генератор в данном случае позволяет не парсить все посты, учитывая, что обычно нужно проверить только один, самый последний и убедиться, что на сайте нет обновлений. Если же пост оказался новым, он сначала отправляется в Telegram, а затем сохраняется в хранилище. Изначально для парсинга блогов я планировал по максимуму использовать RSS и, делая самую первую реализацию Scraper для сайта andrewlock.net, использовал именно его. Я использовал npm пакет rss-parser, который неплохо справляется со своей задачей и даже умеет парсить кастомные поля, которые мне, кстати, понадобились. Работа с этим пакетом в общих чертах выглядит следующим образом: К сожалению, на этом вся радость использования RSS закончилась. Оказалось, что другие сайты хоть и имеют RSS фиды, но в них нет всей нужной мне информации: картинок, тегов, информации об авторах и т.п. Поэтому дальше пришлось вооружиться npm пакетами axios и cheerio. Первый является http клиентом и позволяет скачать любую страницу в виде текста, а второй умеет парсить html и позволяет извлекать из него данные, используя запросы, похожие на селекторы jQuery. Парсинг html страниц с использованием этих двух пакетов выглядит следующим образом: Последний, но важный момент на котором я хотел бы остановиться - это локальная отладка. Как известно, для запуска кода на TypeScript его сначала нужно преобразовать в JavaScript, настроить Source Maps и возможно сделать ещё какую-то магию. Я пользуюсь для разработки VS Code и у меня долго не получалось добиться простого запуска отладчика, который бы при этом нормально работал, по F5. В итоге я наткнулся на npm пакет ts-node, который умеет прятать всю эту магию с преобразованием из TypeScript в JavaScript под капот. Вместе с правильно настроенной конфигурацией в файле launch.json отладка по F5 работала без каких-либо проблем: В результате у меня получился Telegram-канал, в который ежедневно сваливаются несколько интересных статей из мира .NET (без надоедливых рекламных постов). Сам канал: Amazing .NET Исходники на GitHub. Сейчас мониторятся следующие сайты: Коллективные блоги: Microsoft Developer Blogs: .NET Blog - https://devblogs.microsoft.com/dotnet/ OData - https://devblogs.microsoft.com/odata/ The NuGet Blog - https://devblogs.microsoft.com/nuget/ TypeScript - https://devblogs.microsoft.com/typescript/ Visual Studio Blog - https://devblogs.microsoft.com/visualstudio/ Windows Command Line - https://devblogs.microsoft.com/commandline/ The JetBrains Blog / The .NET Tools Blog How-To's - https://blog.jetbrains.com/dotnet/category/how-tos/ Releases - https://blog.jetbrains.com/dotnet/category/releases/ .NET Annotated - https://blog.jetbrains.com/dotnet/tag/net-annotated/ Хабр - https://habr.com/ru/hub/net/ (Только статьи с рейтингом 10 и выше) Code Maze - https://code-maze.com/ Персональные блоги: Andrew Lock - .NET Escapades - https://andrewlock.net/ Derek Comartin - CodeOpinion - https://codeopinion.com/ Wade Gausden - .NET Core Tutorials - https://dotnetcoretutorials.com/ Khalid Abuhakmeh - https://khalidabuhakmeh.com/ В ближайших планах есть идеи добавить мониторинг ещё пары блогов и, возможно, YouTube.  .NET Developer