Как стать автором
Обновить

Собираем интересные .NET блоги в Telegram-канал с помощью GitHub Actions

Время на прочтение10 мин
Количество просмотров4.3K

Будучи .NET разработчиком, я стараюсь периодически просматривать различные ресурсы, связанные с .NET тематикой. Как правило, это различные блоги. Иногда то тут, то там появляются какие-нибудь интересные статьи, на которые стоит обратить внимание.

Недавно я поймал себя на мысли, что делать это вручную мне как-то поднадоело. Тем более, что просматриваю я обычно одни и те же сайты. А значит время заняться автоматизацией.

Идея была достаточно проста. Нужен скрипт, который сходит по разным ссылкам, разберёт содержимое и отправит обновление в какой-нибудь telegram-канал. Нужно было только придумать, как этот скрипт запускать и где сохранять результат его работы, чтобы знать, что уже было отправлено, а что нет.

Я решил использовать в качестве хранилища GitHub, а сам скрипт (написан на TypeScript) запускать по расписанию с помощью GitHub Actions. В этой статье я хочу рассказать про некоторые технические детали реализации и поделиться полученным результатом.

Основная логика GitHub Action

За основу проекта я взял минимальный GitHub Action, который описывал в этой статье, и исходный код которого можно найти тут.

Точкой входа в проект является функция main, которая (упрощённо) выглядит следующим образом:

import * as core from '@actions/core';
import * as github from '@actions/github';

// Other imports...

async function main() {
  try {

    const scrapers: Scraper[] = [
      new AndrewLockScraper(),
      new DevBlogsScraper('dotnet'),
      new DevBlogsScraper('nuget'),
      new DevBlogsScraper('visualstudio'),
      new HabrScraper(),
      new JetBrainsScraper('how-tos'),
      new JetBrainsScraper('releases'),
      new JetBrainsScraper('net-annotated'),
    ];

    const IS_PRODUCTION = github.context.ref === 'refs/heads/main';

    const TELEGRAM_TOKEN = getInput('TELEGRAM_TOKEN');
    const TELEGRAM_PUBLIC_CHAT_ID = getInput('TELEGRAM_PUBLIC_CHAT_ID');
    const TELEGRAM_PRIVATE_CHAT_ID = getInput('TELEGRAM_PRIVATE_CHAT_ID');

    const publicSender = new TelegramSender(TELEGRAM_TOKEN, TELEGRAM_PUBLIC_CHAT_ID);
    const privateSender = new TelegramSender(TELEGRAM_TOKEN, TELEGRAM_PRIVATE_CHAT_ID);

    for (const scraper of scrapers) {
      await core.group(scraper.name, async () => {

        const storage = new Storage(scraper.path);
        const sender = IS_PRODUCTION && storage.exists() ? publicSender : privateSender;

        try {
          await scraper.scrape(storage, sender);
        }
        catch (error: any) {
          core.error(error, { title: `The '${scraper.name}' scraper has failed.` });
        }
        finally {
          storage.save();
        }

      });
    }

  }
  catch (error: any) {
    core.setFailed(error);
  }
}

Здесь используются три типа сущностей:

  • Scraper - интерфейс, отвечающий за парсинг различных источников. Он имеет несколько реализаций для каждого конкретного сайта с соответствующим набором костылей (например для andrewlock.net или devblogs.microsoft.com).

  • Sender - класс, отвечающий за отправку сообщений в Telegram.

  • Storage - класс отвечающий за сохранение ссылок на отправленные сообщения в файлы, чтобы при каждом новом запуске отправлять в Telegram только новые сообщения.

Общая логика тут довольна проста. Все реализации Scraper перебираются в цикле и парсят соответствующий сайт. Каждый Scraper получает в качестве параметров ссылки на Storage, чтобы знать, куда сохранить результат своей работы и Sender, чтобы отправить новые посты (которых ещё нет в Storage) в Telegram.

Чтобы запускать скрипт из GitHub я добавил в репозиторий файл рабочего процесса .github/workflows/scrape.yml:

name: Scrape

on:
  push:
    paths:
      - dist/index.js
  schedule:
    # At minute 42 past every 2nd hour.
    - cron: 42 */2 * * *

jobs:
  Scrape:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Scrape
        uses: ./
        with:
          TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
          TELEGRAM_PUBLIC_CHAT_ID: ${{ secrets.TELEGRAM_PUBLIC_CHAT_ID }}
          TELEGRAM_PRIVATE_CHAT_ID: ${{ secrets.TELEGRAM_PRIVATE_CHAT_ID }}

Тут я добавил два триггера:

  • 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.group('Some group name', async () => {
  // ...
  core.info('Some message in group.');
  // ...
});

У меня в логах это выглядит следующим образом:

Во-вторых, искать в логах все сообщения об ошибках может быть немного утомительно, однако если если для логирования используются функции core.error, core.warning или core.notice, то все такие сообщения будут выведены на главную страницу рабочего процесса в виде аннотаций и будут сразу бросаться в глаза.

Кроме этого вместе с сообщением, которое просто выводится в лог, можно также задать заголовок для аннотации (и даже ссылки на место в файле, где случилась ошибка, если это необходимо).

Выглядят аннотации в интерфейсе следующим образом:

Реализация Storage и сохранение результатов работы в файлы

Поскольку я планировал сохранять результаты работы в 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. Это третий шаг пайплайна, который используется следующим образом:

steps:
  - name: Checkout
    uses: actions/checkout@v2

  - name: Scrape
    uses: ./
    with:
      TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
      TELEGRAM_PUBLIC_CHAT_ID: ${{ secrets.TELEGRAM_PUBLIC_CHAT_ID }}
      TELEGRAM_PRIVATE_CHAT_ID: ${{ secrets.TELEGRAM_PRIVATE_CHAT_ID }}

  # https://github.com/marketplace/actions/add-commit
  - name: Commit
    uses: EndBug/add-and-commit@v9.0.0
    if: ${{ always() && github.ref == 'refs/heads/main' }}
    with:
      add: data
      message: Commit scrape results

Этот шаг просто проверяет, есть ли какие-нибудь незакомиченные изменения в указанной папке (в моём случае это папка data) и, если они есть, делает коммит с указанным сообщением.

Я также добавил дополнительное условие запуска на ветку main (т.к. запуски из других веток отправляют сообщения в приватный канал, который я использую для отладки, и их не нужно коммитить). А также условие always(), чтобы коммит происходил даже в том случае, если предыдущий шаг упал (т.к. он всё же мог что-то отправить и записать в файлы).

Реализация Sender и отправка сообщений в Telegram

Для отправки сообщений в 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 добавил логику поиска картинок на сайтах. В итоге код отправки сообщений у меня выглядит следующим образом:

async send(message: Message): Promise<void> {
  const messageHtml = getMessageHtml(message);

  if (!message.image || messageHtml.length > 1024) {
    await this.telegram.sendMessage(this.chatId, messageHtml, {
      parse_mode: 'HTML',
    });
  }
  else if (message.image.endsWith('.gif')) {
    await this.telegram.sendAnimation(this.chatId, message.image, {
      caption: messageHtml,
      parse_mode: 'HTML',
    });
  }
  else {
    await this.telegram.sendPhoto(this.chatId, message.image, {
      caption: messageHtml,
      parse_mode: 'HTML',
    });
  }
}

Тут нужно учесть, что если у сообщения есть картинка, то длина подписи к ней не может превышать 1024 символа, иначе Telegram вернёт ошибку. Поэтому длинные сообщения приходится отправлять просто через sendMessage.

При отправке gif, чтобы они нормально отображались, их следует отправлять как анимацию через sendAnimation. Все остальные картинки можно отправлять как фото через sendPhoto.

В итоге отформатированные сообщения в канале выглядят следующим образом:

Реализация Scraper и парсинг различных сайтов

Как я уже упоминал, я сделал несколько реализаций Scraper для парсинга разных сайтов, но все они работают примерно по одному и тому же алгоритму:

export default class AndrewLockScraper implements Scraper {
  async scrape(storage: Storage, sender: Sender): Promise<void> {
    for await (const post of this.readPosts()) {
      if (storage.has(post.href, post.date)) {
        core.info('Post already exists in storage. Break scraping.');
        break;
      }

      core.info('Sending post...');
      await sender.send(post);

      core.info('Storing post...');
      storage.add(post.href, post.date);
    }
  }

  private async *readPosts(): AsyncGenerator<Message, void> {
    // ...
  }
}

Каждая реализация Scraper имеет метод с асинхронным генератором readPosts, который выполняет поиск новых постов и возвращает их по одному.

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

Если же пост оказался новым, он сначала отправляется в Telegram, а затем сохраняется в хранилище.

Парсинг RSS

Изначально для парсинга блогов я планировал по максимуму использовать RSS и, делая самую первую реализацию Scraper для сайта andrewlock.net, использовал именно его.

Я использовал npm пакет rss-parser, который неплохо справляется со своей задачей и даже умеет парсить кастомные поля, которые мне, кстати, понадобились. Работа с этим пакетом в общих чертах выглядит следующим образом:

private async *readPosts(): AsyncGenerator<Message, void> {
  const parser = new RssParser({
    customFields: {
      item: ['media:content', 'media:content', { keepArray: true }],
    },
  });

  const feed = await parser.parseURL('https://andrewlock.net/rss.xml');

  for (const item of feed.items) {

    const post: Message = {
      // ...
    };

    yield post;
  }
}

Парсинг HTML

К сожалению, на этом вся радость использования RSS закончилась. Оказалось, что другие сайты хоть и имеют RSS фиды, но в них нет всей нужной мне информации: картинок, тегов, информации об авторах и т.п.

Поэтому дальше пришлось вооружиться npm пакетами axios и cheerio. Первый является http клиентом и позволяет скачать любую страницу в виде текста, а второй умеет парсить html и позволяет извлекать из него данные, используя запросы, похожие на селекторы jQuery.

Парсинг html страниц с использованием этих двух пакетов выглядит следующим образом:

private async *readPosts(): AsyncGenerator<Message, void> {
  const response = await axios.get('https://devblogs.microsoft.com/dotnet/');
  const $ = cheerio.load(response.data);
  const entries = $('#content .entry-box').toArray();

  for (let index = 0; index < entries.length; index++) {
    const entry = $(entries[index]);
    const image = entry.find('.entry-image img').attr('data-src');
    const title = entry.find('.entry-title a');
    const author = entry.find('.entry-author-link a');
    const date = entry.find('.entry-post-date').text();
    const tags = entry.find('.card-tags-links .card-tags-linkbox a').toArray();

    const post: Message = {
      // ...
    };

    yield post;
  }
}

Локальная отладка

Последний, но важный момент на котором я хотел бы остановиться - это локальная отладка.

Как известно, для запуска кода на TypeScript его сначала нужно преобразовать в JavaScript, настроить Source Maps и возможно сделать ещё какую-то магию.

Я пользуюсь для разработки VS Code и у меня долго не получалось добиться простого запуска отладчика, который бы при этом нормально работал, по F5.

В итоге я наткнулся на npm пакет ts-node, который умеет прятать всю эту магию с преобразованием из TypeScript в JavaScript под капот. Вместе с правильно настроенной конфигурацией в файле launch.json отладка по F5 работала без каких-либо проблем:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "pwa-node",
      "request": "launch",
      "name": "Launch Scrape",
      "console": "integratedTerminal",
      "cwd": "${workspaceFolder}",
      "program": "src/main.ts",
      "envFile": "${env:USERPROFILE}/${workspaceFolderBasename}.env",
      "runtimeArgs": [
        "--nolazy",
        "-r",
        "./node_modules/ts-node/register"
      ],
      "sourceMaps": true,
      "protocol": "inspector",
      "resolveSourceMapLocations": [
        "${workspaceFolder}/**",
        "!**/node_modules/**"
      ],
      "skipFiles": [
        "<node_internals>/**"
      ]
    }
  ]
}

Что в итоге получилось

В результате у меня получился Telegram-канал, в который ежедневно сваливаются несколько интересных статей из мира .NET (без надоедливых рекламных постов).

Сам канал: Amazing .NET

Исходники на GitHub.

Сейчас мониторятся следующие сайты:

В ближайших планах есть идеи добавить мониторинг ещё пары блогов и, возможно, YouTube.

Теги:
Хабы:
Всего голосов 6: ↑6 и ↓0+6
Комментарии2

Публикации

Истории

Работа

Ближайшие события