Будучи .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
Сейчас мониторятся следующие сайты:
Коллективные блоги:
Microsoft Developer Blogs:
.NET Blog - https://devblogs.microsoft.com/dotnet/
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.