Создание агрегатора RSS-каналов на базе искусственного интеллекта

· 33 мин чтения

Будучи MVP Microsoft и энтузиастом технологий, я постоянно тону в океане потрясающего контента, публикуемого в блогах разработчиков Microsoft. От анонсов .NET до обновлений Visual Studio, от инноваций Azure до глубокого изучения семантического ядра — в экосистеме Microsoft всегда происходит что-то новое и интересное.

Проблема? Успеть за всем этим практически невозможно.

Я хотел быть в курсе последних объявлений и делиться ими со своей сетью, но проверка вручную семи различных RSS-каналов, чтение статей, создание интересных постов в социальных сетях и отслеживание того, чем я уже поделился, само по себе становилось работой на полный рабочий день. Каждое утро я открывал несколько вкладок браузера, просматривал десятки статей, пытался вспомнить, какими из них я уже поделился, а затем тратил драгоценное время на написание сообщений о тех, которые привлекли мое внимание.

Поэтому я сделал то, что сделал бы любой разработчик: я автоматизировал это.

В этом подробном руководстве я расскажу вам, как я создал агрегатор RSS-каналов на базе искусственного интеллекта, который отслеживает несколько RSS-каналов Microsoft DevBlogs на наличие нового контента, использует Azure OpenAI и семантическое ядро для анализа статей и создания интересных публикаций, создает подробную документацию по уценке для каждой проанализированной статьи, отправляет уведомления через Telegram, чтобы я мог просматривать и делиться контентом, отслеживает все, чтобы избежать дублирования сообщений, и автоматически запускается через GitHub Actions.

Давайте углубимся в каждый аспект этого решения.

История этого проекта

Жизнь с информационной перегрузкой

Позвольте мне нарисовать вам картину моего типичного утра до того, как я создал этот инструмент. Я просыпался, брал кофе и открывал ноутбук, чтобы проверить, что нового в экосистеме разработчиков Microsoft. Сначала я зашел на главный сайт DevBlogs, чтобы посмотреть, есть ли какие-нибудь важные объявления. Затем я специально проверял блог .NET, потому что это мой основной технологический стек. После этого я бы перешел к блогу Semantic Kernel, поскольку ИИ становится все более важным. Блог Visual Studio был следующим в списке, поскольку обновления IDE могут существенно повлиять на мой ежедневный рабочий процесс. Затем последовал блог DevOps с новостями, связанными с CI/CD и GitHub, за ним последовал блог All Things Azure, посвященный обновлениям облачной инфраструктуры, и, наконец, блог Azure SQL, посвященный инновациям в области баз данных.

Это семь разных каналов, которые нужно проверить. Каждый из этих блогов публикует несколько статей в неделю, иногда несколько в день в периоды важных объявлений, таких как .NET Conf или Build. Это потенциально десятки статей, которые можно отслеживать, читать и делиться. И вот в чем дело: как человек, который ценит обмен знаниями с сообществом, я не хотел просто читать эти статьи. Я хотел поделиться наиболее ценными из них со своей сетью LinkedIn, помогая другим разработчикам тоже оставаться в курсе.Но создание хорошего поста в LinkedIn требует времени. Вам нужно внимательно прочитать статью, понять ключевые моменты, подумать, почему она важна для вашей аудитории, написать увлекательную зацепку и красиво все отформатировать. Умножьте это на несколько статей в неделю, и вы получите количество часов работы.

Чего я действительно хотел

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

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

Решение обретает форму

Решение, которое я предполагал, будет запускаться по расписанию с использованием GitHub Actions, полностью без помощи рук. Он будет автоматически получать все семь каналов, и мне не придется открывать ни одну вкладку браузера. Компонент ИИ фактически прочитает и поймет контент, а затем обобщит его так, чтобы это было полезно для моей аудитории. Вместо того, чтобы писать сообщения с нуля, он создавал готовый контент для социальных сетей, который я мог бы настроить при необходимости. Все будет отправлено в мой Telegram для проверки, чтобы я мог быстро взглянуть на свой телефон и решить, чем поделиться. И, конечно же, он будет постоянно записывать все для дальнейшего использования.

Прежде чем мы начнем строить

Что вам понадобится на вашей машине

Чтобы следовать этому руководству, вам понадобится несколько вещей, установленных на вашей машине разработки. Наиболее важным из них является .NET SDK версии 9.0 или более поздней. Это наша среда выполнения, которая предоставляет все необходимые нам инструменты сборки. Если он у вас не установлен, зайдите на dot.net и загрузите последнюю версию. Установка проста в Windows, macOS или Linux.

Вам также понадобится установить Git для контроля версий. Мы будем размещать наш код на GitHub и использовать GitHub Actions для автоматизации, поэтому крайне важно настроить Git локально. Любая последняя версия будет работать нормально.

Для вашей среды разработки я рекомендую Visual Studio или VS Code. Лично я сейчас использую VS Code для большей части своей работы, потому что он легкий и имеет отличную поддержку C# через расширение C# Dev Kit. Но если вам удобнее работать с полной версией Visual Studio, она тоже отлично работает.

Сервисы и учетные записи, которые вам понадобятсяПомимо локальных инструментов, вам понадобятся учетные записи с несколькими сервисами. Самым важным из них является Azure OpenAI, на котором основан наш анализ ИИ. Это услуга с оплатой по мере использования, но затраты для этого варианта использования минимальны — мы говорим о центах за проанализированную статью. Если у вас нет учетной записи Azure, вы можете подписаться на бесплатную пробную версию, которая включает в себя некоторые кредиты для начала работы.

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

Наконец, вам понадобится учетная запись GitHub для размещения вашего кода и запуска действий GitHub. Уровень бесплатного пользования более чем достаточен для этого проекта. GitHub предоставляет вам 2000 минут выполнения Actions в месяц в частных репозиториях и неограниченное количество минут в публичных репозиториях.

Библиотеки, которые делают это возможным

Наш проект опирается на три основных пакета NuGet, каждый из которых служит определенной цели.

Первый — HtmlAgilityPack, который является золотым стандартом анализа HTML в .NET. Когда мы извлекаем статью из блога, мы получаем полный HTML-код страницы, включая навигационные меню, нижние колонтитулы, рекламные объявления и всевозможные элементы, которые нас не интересуют. HtmlAgilityPack позволяет нам анализировать этот HTML и извлекать только необходимое нам содержимое статьи.

Второй пакет — Microsoft.SemanticKernel, который представляет собой SDK Microsoft для интеграции моделей искусственного интеллекта в приложения. Думайте об этом как о мосте между вашим кодом .NET и большими языковыми моделями, такими как GPT-4. Он берет на себя всю сложность вызовов API, управления токенами и анализа ответов, позволяя вам сосредоточиться на том, что на самом деле вы хотите от ИИ.

Третий пакет — System.ServiceModel.Syndicate, который обеспечивает встроенную поддержку анализа каналов RSS и Atom. RSS может показаться старой технологией, но это по-прежнему лучший способ получать структурированные обновления из блогов и новостных сайтов. Этот пакет превращает необработанные XML-каналы в строго типизированные объекты C#, с которыми легко работать.

Понимание архитектуры

Как части соединяются друг с другом

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

На самом высоком уровне у нас есть основной файл Program.cs, который действует как оркестратор. Это точка входа нашего приложения, и она координирует все остальные компоненты. Когда приложение запускается, оно сначала загружает конфигурацию из переменных среды — таких как ключи API и учетные данные Telegram. Затем он выходит и получает RSS-каналы из всех семи источников Microsoft DevBlogs. Обрабатывая эти каналы, он дедуплицирует статьи, чтобы обрабатывать случаи, когда одна и та же статья появляется в нескольких каналах. Он сверяет каждую статью с нашим файлом отслеживания, чтобы узнать, обрабатывали ли мы ее уже. Новые статьи передаются на обработку ИИ-анализатору.Класс ArticleAnalyzer — это место, где творится магия искусственного интеллекта. Этот компонент получает статью и выполняет с ней несколько действий. Сначала он извлекает полное HTML-содержимое из URL-адреса статьи. Затем он извлекает из этого HTML-кода чистый текст, удаляя все ненужные элементы навигации, скрипты и стили. Получив чистый текст, он отправляет его в Azure OpenAI через семантическое ядро ​​с тщательно составленным приглашением. ИИ анализирует статью и возвращает структурированный ответ, который включает в себя краткое изложение, ключевые темы, релевантное объяснение и, самое главное, готовую к использованию публикацию в LinkedIn. Анализатор анализирует этот ответ и возвращает объект ArticleAnaлиз, содержащий всю эту информацию.

Класс MarkdownGenerator берет этот объект ArticleAnaлиз и создает его постоянную запись. Он генерирует красиво отформатированный файл уценки, который включает в себя все метаданные статьи, анализ ИИ и сгенерированный пост. Эти файлы хранятся в каталоге сгенерированных сообщений, что дает вам доступный для поиска архив всего, что вы обработали.

Наконец, интеграция с Telegram отправляет сгенерированный контент сообщения на ваш телефон. Это момент, когда вы, как человек, можете просмотреть работу ИИ и решить, стоит ли ею делиться. Бот отправляет вам сообщение с содержимым публикации, и вы можете либо скопировать его непосредственно в LinkedIn, либо сначала изменить.

Поток данных

Позвольте мне рассказать вам, что происходит, когда в блоге .NET публикуется новая статья. Рабочий процесс начинается, когда GitHub Actions запускает наше приложение по расписанию — скажем, каждые шесть часов. Приложение просыпается и начинает получать все семь RSS-каналов. Каждый канал возвращает XML-документ, содержащий самые последние статьи из этого блога.

Анализируя каждый канал, мы извлекаем отдельные статьи и сохраняем их в списке. Но есть одна сложность: основная лента DevBlogs часто включает в себя статьи, которые также появляются в лентах отдельных категорий. Таким образом, статья о «.NET 10» может появиться как в основной ленте, так и в ленте, посвященной .NET. Мы справляемся с этим, отслеживая URL-адреса в HashSet, который автоматически предотвращает дублирование.

Получив дедуплицированный список статей, мы отфильтровываем его только до последних — обычно статей, опубликованных в последний день или около того. Мы не хотим обрабатывать старые статьи, которые уже обрабатывались в предыдущих запусках. Затем мы сверяем каждую недавнюю статью с нашим файлом отслеживания. Если мы уже обработали и опубликовали статью, мы ее пропускаем.

Для каждой новой статьи мы запускаем конвейер анализа ИИ. Анализатор извлекает полный HTML-код статьи, очищает его и отправляет в GPT-4 с нашей подсказкой. ИИ читает статью и генерирует всесторонний анализ вместе с публикацией в LinkedIn. Мы сохраняем этот анализ в файл уценки для наших записей.После завершения анализа форматируем сообщение и отправляем его в Telegram. Сообщение включает в себя сгенерированный контент публикации с добавленными URL-адресом и хэштегами. На свой телефон я получаю уведомление, просматриваю публикацию и, если она мне нравится, могу скопировать ее и поделиться в LinkedIn всего несколькими нажатиями.

Наконец, мы обновляем наш файл отслеживания, чтобы пометить эту статью как обработанную, чтобы больше не обрабатывать ее при будущих запусках. Если какие-либо файлы были созданы или изменены, GitHub Actions фиксирует эти изменения обратно в репозиторий, сохраняя все в синхронизации.

Настройка проекта с нуля

Создание структуры решения

Начнем строить. Откройте терминал и перейдите туда, где вы хотите создать проект. Мне нравится хранить свои проекты в папке «Разработка», но вы можете разместить их там, где вам удобно.

Сначала мы создадим новый файл решения. В .NET решение — это контейнер, в котором может храниться несколько проектов. Несмотря на то, что на данный момент у нас есть только один проект, начав с решения, при необходимости будет проще добавить больше проектов позже. Запустите команду dotnet new sln -n vs-feed-linkedin для создания решения с именем vs-feed-linkedin.

Далее нам нужно создать проект консольного приложения. Мы поместим это в подкаталог src, чтобы все было организовано. Запустите dotnet new console -n VsFeedLinkedin -o src для создания консольного проекта с именем VsFeedLinkedin в папке src. Затем добавьте этот проект в наше решение с помощью dotnet sln add src/VsFeedLinkedin.csproj.

Теперь перейдите в каталог src с помощью cd src. Здесь мы добавим пакеты NuGet и выполним большую часть разработки.

Добавление необходимых пакетов

После создания нашего проекта нам нужно добавить три пакета NuGet, о которых я упоминал ранее. Запустите каждую из этих команд последовательно:

[[[ТОК_4]]]

После выполнения этих команд файл вашего проекта должен выглядеть примерно так:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="HtmlAgilityPack" Version="1.11.72" />
    <PackageReference Include="Microsoft.SemanticKernel" Version="1.30.0" />
    <PackageReference Include="System.ServiceModel.Syndication" Version="9.0.9" />
  </ItemGroup>

</Project>

Файл проекта сообщает .NET, что мы создаем исполняемый файл (OutputType — Exe), ориентированный на .NET 9.0 и использующий современные функции C#, такие как неявное использование и ссылочные типы, допускающие значение NULL. В разделе ItemGroup перечислены три зависимости наших пакетов с указанием их точных версий.

Глубокое погружение в RSS-каналы

Что такое RSS?

Прежде чем мы начнем писать код для получения каналов, давайте удостоверимся, что мы понимаем, с чем работаем. RSS означает Really Simple Syndicate и представляет собой стандартизированный формат XML для распространения обновлений контента. Идея проста: вместо того, чтобы требовать от пользователей посещения вашего веб-сайта для проверки наличия нового контента, вы публикуете машиночитаемый файл, в котором перечислены ваши последние материалы. Приложения могут затем периодически опрашивать этот файл для обнаружения новых статей.

RSS существует с конца 1990-х — начала 2000-х годов. Вы можете подумать, что это устаревшая технология, но на самом деле она все еще широко используется – особенно в блогах, новостных сайтах и ​​подкастах. Прелесть RSS в его простоте. Это просто XML с определенной структурой, и любое приложение может его анализировать.

Структура ленты DevBlogsКогда вы получаете RSS-канал из Microsoft DevBlogs, вы получаете обратно XML-документ, имеющий определенную структуру. На верхнем уровне есть элемент rss, содержащий один элемент канала. Канал представляет сам блог и включает метаданные, такие как заголовок, URL-адрес и описание блога.

Внутри канала вы найдете несколько элементов item, каждый из которых представляет отдельную публикацию в блоге. Каждый элемент включает заголовок (заголовок статьи), ссылку (URL-адрес, по которому можно прочитать полную статью), дату публикации (когда статья была опубликована), элемент dc:creator (имя автора), один или несколько элементов категории (теги для статьи) и описание (обычно краткое изложение или отрывок статьи).

Вот упрощенный пример того, как это выглядит:

[[[ТОК_6]]]

Самое замечательное в пакете System.ServiceModel.Syndicate .NET заключается в том, что он анализирует все это за нас. Нам не нужно вручную перемещаться по узлам XML или беспокоиться о разных версиях RSS. Мы просто загружаем фид и получаем обратно строго типизированные объекты.

Семь каналов, которые мы отслеживаем

В своей реализации я отслеживаю семь различных каналов Microsoft DevBlogs. Основной канал DevBlogs по адресу devblogs.microsoft.com/feed дает нам общее представление обо всем, что Microsoft публикует во всех своих блогах разработчиков. Лента, посвященная .NET, по адресу devblogs.microsoft.com/dotnet/feed посвящена выпускам, функциям и передовым практикам .NET. Лента Semantic Kernel на сайте devblogs.microsoft.com/semantic-kernel/feed посвящена оркестровке и интеграции ИИ, что становится все более важным, поскольку ИИ становится центральным элементом современного развития.

Лента Visual Studio по адресу devblogs.microsoft.com/visualstudio/feed позволяет мне быть в курсе улучшений IDE и функций повышения производительности. Канал DevOps по адресу devblogs.microsoft.com/devops/feed охватывает темы Azure DevOps, GitHub и CI/CD. Канал All Things Azure по адресу devblogs.microsoft.com/all-things-azure/feed посвящен облачным сервисам и шаблонам архитектуры. Наконец, канал Azure SQL по адресу devblogs.microsoft.com/azure-sql/feed посвящен инновациям и функциям баз данных.

Вы можете задаться вопросом, почему я проверяю как основной канал, так и каналы отдельных категорий. Основной канал дает мне простор: я вижу статьи из любого блога разработчиков Microsoft, включая те, о которых я, возможно, не знаю. Ленты категорий дают мне глубину — они гарантируют, что я не пропущу ничего важного в моих основных областях интересов, даже если эти статьи будут вытеснены из основного канала новым контентом.

Построение логики получения RSS

Основная функция выборки

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

[[[ТОК_7]]]

Позвольте мне рассказать, что делает этот код. Начнем с создания HttpClient — встроенного класса .NET для выполнения HTTP-запросов. Мы устанавливаем заголовок User-Agent, потому что некоторые серверы блокируют запросы, которые не идентифицируют себя. Рекомендуется устанавливать это значение, даже если серверы этого не требуют.Затем мы делаем запрос GET к URL-адресу канала и получаем ответ в виде строки. Эта строка содержит необработанный XML-файл RSS-канала.

Чтобы проанализировать этот XML, мы создаем StringReader для переноса нашей строки ответа, а затем настраиваем некоторые XmlReaderSettings. Параметр DtdProcessing важен: RSS-каналы иногда включают объявления DTD (определение типа документа), которые необходимо обработать. Параметр MaxCharactersFromEntities — это мера безопасности, которая предотвращает атаки XML-бомбы, ограничивая степень расширения сущности.

Наконец, мы создаем XmlReader с этими настройками и используем SyndictionFeed.Load для анализа XML в строго типизированный объект SyndictionFeed. Это дает нам доступ к метаданным канала и всем его элементам через удобные свойства C# вместо простой навигации по XML.

Получение нескольких каналов с обработкой ошибок

В реальном мире сетевые запросы не выполняются. Серверы выходят из строя, время соединения истекает, а XML может быть искажен. Нам нужно обращаться с такими случаями изящно. Вот как мы получаем все наши каналы, сохраняя при этом устойчивость к сбоям:

[[[ТОК_8]]]

Мы храним здесь две коллекции. Список allArticles будет содержать все найденные нами статьи, а также из какого канала они взяты. Набор хэшей «seenUrls» отслеживает URL-адреса статей, которые мы уже видели, что помогает нам избежать дублирования.

Мы перебираем каждый URL-адрес канала и заключаем операцию выборки в блок try-catch. Если получение определенного канала не удается (возможно, сервер временно не работает), мы регистрируем предупреждение и продолжаем работу со следующим каналом. Таким образом, проблема с одним фидом не мешает нам обрабатывать остальные.

Для каждого успешно полученного канала мы перебираем его элементы. Мы извлекаем URL-адрес статьи из коллекции ссылок элемента. Метод HashSet.Add возвращает false, если URL-адрес уже находится в наборе, что идеально подходит для нашей логики дедупликации. Мы добавляем статью в наш список только в том случае, если она новая.

Мы сохраняем URL-адрес канала рядом с каждой статьей, поскольку эта информация может пригодиться позже — например, нам может потребоваться узнать, из какого конкретного канала пришла статья, в целях отладки или регистрации.

Обработка дубликатов и отслеживание состояния

Проблема дедупликации

Как я упоминал ранее, Microsoft DevBlogs имеет иерархическую структуру каналов, что создает интересную проблему. Когда член команды .NET публикует статью, скажем, об улучшении производительности в .NET 10, эта статья, скорее всего, появится как в основной ленте DevBlogs, так и в ленте, посвященной .NET. Иногда оно может даже появиться в ленте Visual Studio, если оно относится к функциям IDE.

Если бы мы наивно обрабатывали каждую статью из каждого канала, нам пришлось бы анализировать и публиковать одну и ту же статью несколько раз. Это приведет к пустой трате вызовов API к Azure OpenAI, спаму в Telegram дубликатами уведомлений и потенциально раздражению наших подписчиков, если мы опубликуем дубликаты.Решением является дедупликация на основе URL-адресов. Каждая статья имеет уникальный URL-адрес, поэтому мы можем использовать его в качестве идентификатора. Структура данных HashSet идеально подходит для этого, поскольку обеспечивает время поиска O(1) и автоматически предотвращает дублирование. Когда мы пытаемся добавить URL-адрес, который уже есть в наборе, метод Add просто возвращает false, давая нам понять, что эту статью следует пропустить.

Постоянное состояние с Markdown

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

Я решил сохранить это состояние в файле уценки под названием Posted-articles.md. Почему уценка? Несколько причин. Во-первых, он удобен для чтения человеком. Я могу открыть файл и сразу увидеть, какими статьями я поделился. Во-вторых, он контролируется версиями. Поскольку этот файл находится в нашем репозитории Git, у меня есть полная история обработки статей. В-третьих, он служит документацией. Любой, кто заглянет в репозиторий, сможет увидеть, что сделало приложение.

Формат этого файла простой. Он имеет заголовок, временную метку, показывающую дату последнего запуска приложения, а затем список статей в формате ссылки уценки:

[[[ТОК_9]]]

Загрузка и анализ файла отслеживания

Чтобы проверить, обрабатывали ли мы уже статью, нам нужно загрузить этот файл и извлечь URL-адреса. Вот функция, которая это делает:

[[[ТОК_10]]]

Эта функция возвращает HashSet, содержащий все URL-адреса, которые мы уже обработали. Начнем с проверки существования файла — при первом запуске его не будет, поэтому мы возвращаем пустой набор.

Для каждой строки файла мы используем регулярное выражение для извлечения URL-адреса из формата ссылки уценки. Регулярное выражение \(([^)]+)\) соответствует всему, что находится внутри круглых скобок, где ссылки уценки хранят свои URL-адреса.

Затем следует важный шаг: нормализация URL-адресов. URL-адреса одной и той же статьи могут различаться по формату. RSS-канал может дать нам https://devblogs.microsoft.com/dotnet/article, но к нашей сохраненной версии добавлен параметр отслеживания: https://devblogs.microsoft.com/dotnet/article?wt.mc_id=DT-MVP-5004972. Некоторые URL-адреса имеют косую черту в конце, другие — нет.

Чтобы справиться с этим, мы удаляем все параметры запроса (все после ?) и удаляем конечные косые черты. Эта нормализация гарантирует, что мы распознаем статьи как дубликаты, даже если их URL-адреса различаются такими поверхностными способами.

Сохранение новых статей

Когда мы успешно обработаем статью, нам нужно добавить ее в наш файл отслеживания:

static void SavePostedArticle(string filePath, string url, string title, DateTimeOffset publishDate)
{
    var markdownEntry = $"- [{title}]({url}) - Posted on {DateTime.Now:yyyy-MM-dd HH:mm:ss} (Published: {publishDate:yyyy-MM-dd})\n";
    
    if (!File.Exists(filePath))
    {
        File.WriteAllText(filePath, "# Posted Articles\n\n*Last run: {DateTime.Now:yyyy-MM-dd HH:mm:ss}*\n\nList of articles posted:\n\n");
    }
    
    File.AppendAllText(filePath, markdownEntry);
}

Эта функция создает запись в формате уценки с заголовком статьи в виде ссылки, за которой следуют временные метки, показывающие, когда мы ее опубликовали и когда она была первоначально опубликована. Если файл еще не существует, мы сначала создаем его с заголовком.

Механизм анализа ИИ

Понимание семантического ядраТеперь мы переходим к самой интересной части нашего приложения — анализу ИИ. Семантическое ядро ​​— это пакет SDK Microsoft с открытым исходным кодом для интеграции больших языковых моделей в приложения. Это больше, чем просто оболочка вызовов API. Он обеспечивает основу для создания сложных приложений искусственного интеллекта с такими функциями, как плагины, планировщики и память.

В нашем случае мы используем возможности завершения чата семантического ядра. Мы отправим запрос в Azure OpenAI, и модель проанализирует нашу статью и сгенерирует ответ. Семантическое ядро ​​берет на себя всю сложность аутентификации API, форматирования запросов и анализа ответов.

Настройка анализатора статей

Давайте посмотрим, как мы настроили наш класс анализатора:

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using HtmlAgilityPack;

namespace VsFeedLinkedin.Services;

public class ArticleAnalyzer
{
    private readonly Kernel _kernel;
    private readonly IChatCompletionService _chatService;

    public ArticleAnalyzer(string endpoint, string apiKey, string deploymentName)
    {
        var builder = Kernel.CreateBuilder();
        
        builder.AddAzureOpenAIChatCompletion(
            deploymentName: deploymentName,
            endpoint: endpoint,
            apiKey: apiKey
        );
        
        _kernel = builder.Build();
        _chatService = _kernel.GetRequiredService<IChatCompletionService>();
    }

Семантическое ядро использует шаблон компоновщика для настройки. Мы создаем KernelBuilder, добавляем нашу службу завершения чата Azure OpenAI с необходимыми учетными данными, а затем собираем ядро. Из построенного ядра мы получаем интерфейс IChatCompletionService, который будем использовать для отправки подсказок и получения ответов.

Конструктор принимает три параметра: конечную точку Azure OpenAI (что-то вроде https://your-resource.openai.azure.com/), ваш ключ API и имя развертывания (например, gpt-4o). Они передаются из переменных среды, обеспечивая безопасность наших учетных данных.

Создание идеальной подсказки

Подсказка, которую мы отправляем ИИ, имеет решающее значение. Хорошо продуманная подсказка обеспечивает стабильные и высококачественные результаты. Расплывчатая или плохо структурированная подсказка приводит к непоследовательным и посредственным результатам. Я потратил много времени на повторение этого запроса, чтобы получить результаты, которые меня устраивают:

var prompt = $"""
    You are a professional tech content analyst and LinkedIn content creator. 
    Analyze the following Microsoft DevBlogs article and create an engaging LinkedIn post.

    Article Title: {title}
    Author: {author}
    URL: {url}
    Tags: {string.Join(", ", tags)}
    
    Article Content:
    {cleanContent}

    Please provide:
    1. A brief summary (2-3 sentences) of the key points
    2. The main technologies or topics covered
    3. Why this is relevant for developers/tech professionals
    4. An engaging LinkedIn post (max 1300 characters) that:
       - Starts with a hook or attention-grabbing statement
       - Highlights the key value for readers
       - Includes a call to action
       - Uses appropriate emojis (but not too many)
       - Maintains a professional yet approachable tone
       - DO NOT include hashtags in the post (they will be added separately)
       - DO NOT include the URL in the post (it will be added separately)

    Format your response as follows:
    ## Summary
    [Your summary here]

    ## Key Topics
    [List of main topics/technologies]

    ## Relevance
    [Why this matters]

    ## LinkedIn Post
    [Your engaging LinkedIn post here]
    """;

Позвольте мне объяснить здесь дизайнерские решения. Начнем с того, что дадим ИИ четкую роль: «Вы — профессиональный аналитик технического контента и создатель контента LinkedIn». Это побуждает модель реагировать соответствующим стилем и голосом.

Мы предоставляем весь контекст, необходимый ИИ: название статьи, автора, URL-адрес, теги из RSS-канала и полное содержание статьи. Чем больше контекста мы дадим, тем лучше будет анализ.

Затем мы точно указываем, что мы хотим получить обратно. Я прошу четыре вещи: краткое изложение, ключевые темы, релевантное объяснение и публикацию в LinkedIn. В частности, для публикации в LinkedIn я даю подробные инструкции о том, что делает публикацию хорошей: она должна иметь зацепку, подчеркивать ценность, включать призыв к действию, правильно использовать смайлы и поддерживать профессиональный тон.

Негативные инструкции не менее важны. Я прямо говорю ИИ НЕ включать хэштеги или URL-адрес в сообщение. Почему? Потому что я добавляю их отдельно, и если бы ИИ включил их, у меня были бы дубликаты. Подобные явные инструкции предотвращают распространенные ошибки.

Наконец, я указываю точный формат вывода. Запрашивая разделы, отмеченные заголовками ##, я упрощаю программный анализ ответа. ИИ очень хорошо выполняет инструкции по форматированию, и такая согласованность делает наш код синтаксического анализа более простым и надежным.

Выполнение анализа

Вот как мы все это собрали:

public async Task<ArticleAnalysis> AnalyzeArticleAsync(
    string title, 
    string url, 
    string htmlContent, 
    string author, 
    List<string> tags)
{
    var cleanContent = ExtractTextFromHtml(htmlContent);
    
    if (cleanContent.Length > 8000)
    {
        cleanContent = cleanContent.Substring(0, 8000) + "...";
    }

    var chatHistory = new ChatHistory();
    chatHistory.AddUserMessage(prompt);

    var response = await _chatService.GetChatMessageContentAsync(chatHistory);
    var responseText = response.Content ?? "";

    return ParseAnalysisResponse(responseText, title, url, author, tags);
}
```Сначала мы извлекаем чистый текст из содержимого HTML (я объясню это в следующем разделе). Затем мы обрезаем контент, если он слишком длинный. Большие языковые модели имеют ограничения по токенам, а очень длинные статьи могут их превышать. Ограничивая текст до 8000 символов, мы гарантируем, что не выйдем за рамки ограничений, сохраняя при этом существенный контекст.

Мы создаем объект ChatHistory и добавляем наше приглашение в качестве сообщения пользователя. Это абстракция семантического ядра для взаимодействия в чате. Мы отправляем это в службу завершения чата и получаем ответ. Наконец, мы анализируем ответ, чтобы извлечь отдельные разделы.

### Анализ ответа ИИ

ИИ возвращает свой ответ в виде текста, отформатированного в соответствии с запрошенной нами структурой. Нам нужно разобрать это на отдельные поля:

```csharp
private static ArticleAnalysis ParseAnalysisResponse(
    string response, 
    string title, 
    string url, 
    string author, 
    List<string> tags)
{
    var analysis = new ArticleAnalysis
    {
        Title = title,
        Url = url,
        Author = author,
        Tags = tags,
        RawAnalysis = response
    };

    var sections = response.Split("##", StringSplitOptions.RemoveEmptyEntries);
    
    foreach (var section in sections)
    {
        var lines = section.Trim().Split('\n', 2);
        if (lines.Length < 2) continue;
        
        var sectionTitle = lines[0].Trim().ToLower();
        var sectionContent = lines[1].Trim();

        switch (sectionTitle)
        {
            case "summary":
                analysis.Summary = sectionContent;
                break;
            case "key topics":
                analysis.KeyTopics = sectionContent;
                break;
            case "relevance":
                analysis.Relevance = sectionContent;
                break;
            case "linkedin post":
                analysis.LinkedInPost = sectionContent;
                break;
        }
    }

    return analysis;
}

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

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

Извлечение контента из HTML

Почему нам нужно чистить HTML

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

Если бы мы отправили все это нашему ИИ, произошло бы несколько плохих вещей. ИИ пришлось бы обрабатывать много ненужного текста, тратя токены и потенциально запутывая анализ. Текст навигации и нижнего колонтитула может быть включен в сводку. Скрипты и CSS будут рассматриваться как контент, что еще больше затруднит анализ.

Нам нужно извлечь только содержание статьи — ту часть, которую на самом деле прочитает читатель.

Использование HtmlAgilityPack

HtmlAgilityPack — это надежная библиотека анализа HTML для .NET. В отличие от XML, HTML часто имеет неправильный формат: теги могут быть неправильно закрыты, атрибуты могут быть заключены в кавычки неправильно. HtmlAgilityPack прекрасно справляется со всем этим, предоставляя нам структуру, подобную DOM, которую мы можем запрашивать и манипулировать ею.

Вот наша функция извлечения:

private static string ExtractTextFromHtml(string html)
{
    if (string.IsNullOrWhiteSpace(html))
        return string.Empty;

    var doc = new HtmlDocument();
    doc.LoadHtml(html);

    var nodesToRemove = doc.DocumentNode.SelectNodes("//script|//style|//nav|//footer|//header");
    if (nodesToRemove != null)
    {
        foreach (var node in nodesToRemove)
        {
            node.Remove();
        }
    }

    var text = doc.DocumentNode.InnerText;
    
    text = System.Text.RegularExpressions.Regex.Replace(text, @"\s+", " ");
    return text.Trim();
}

Мы загружаем HTML в HtmlDocument, который анализирует его в древовидную структуру. Затем мы используем XPath, чтобы выбрать все узлы, которые хотим удалить. Выражение XPath //script|//style|//nav|//footer|//header выбирает все элементы скрипта (код JavaScript нам не нужен), элементы стиля (CSS нам не нужен), элементы навигации (меню навигации), элементы нижнего колонтитула и элементы заголовка.

После удаления этих узлов мы получаем свойство InnerText, которое извлекает весь текстовый контент, удаляя при этом HTML-теги. Это дает нам простой текст статьи.Наконец, мы очищаем пробелы. HTML часто имеет много дополнительных пробелов для форматирования — несколько пробелов, табуляции, новой строки. Мы используем регулярное выражение для замены любой последовательности пробельных символов одним пробелом, а затем обрезаем результат.

Получение полной статьи

RSS-канал предоставляет нам только краткое изложение, а не полное содержание статьи. Чтобы получить полный текст, нам нужно получить веб-страницу статьи:

public static async Task<string> FetchArticleContentAsync(string url)
{
    using var httpClient = new HttpClient();
    httpClient.DefaultRequestHeaders.Add("User-Agent", "VsFeedLinkedin/1.0");
    
    try
    {
        return await httpClient.GetStringAsync(url);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"⚠️ Failed to fetch article content: {ex.Message}");
        return string.Empty;
    }
}

Это просто: мы делаем HTTP-запрос GET к URL-адресу статьи и возвращаем ответ в формате HTML. Мы обертываем его в try-catch, потому что сетевые запросы могут завершиться неудачно, и мы предпочитаем вернуть пустую строку, чем привести к сбою всего приложения.

Создание постоянной документации

Зачем создавать файлы Markdown

Каждый раз, когда мы анализируем статью, мы создаем подробный файл уценки, документирующий этот анализ. Это служит нескольким целям.

Во-первых, он создает архив с возможностью поиска. Со временем вы создадите коллекцию проанализированных статей. Вы можете выполнить поиск по этим файлам, чтобы найти прошлый контент по определенным темам.

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

В-третьих, это полезно для отладки. Если с публикацией что-то пойдет не так, вы можете посмотреть файл уценки, чтобы понять, что произошло.

Класс генератора Markdown

public class MarkdownGenerator
{
    private readonly string _outputDirectory;

    public MarkdownGenerator(string outputDirectory)
    {
        _outputDirectory = outputDirectory;
        
        if (!Directory.Exists(_outputDirectory))
        {
            Directory.CreateDirectory(_outputDirectory);
        }
    }

    public string GenerateMarkdownFile(ArticleAnalysis analysis)
    {
        var sb = new StringBuilder();
        
        var safeTitle = GenerateSafeFileName(analysis.Title);
        var fileName = $"{analysis.AnalyzedAt:yyyy-MM-dd}_{safeTitle}.md";
        var filePath = Path.Combine(_outputDirectory, fileName);

        sb.AppendLine($"# {analysis.Title}");
        sb.AppendLine();
        sb.AppendLine("## Article Information");
        sb.AppendLine();
        sb.AppendLine($"- **Author:** {analysis.Author}");
        sb.AppendLine($"- **URL:** [{analysis.Url}]({analysis.Url})");
        sb.AppendLine($"- **Published:** {analysis.PublishDate:yyyy-MM-dd}");
        sb.AppendLine($"- **Analyzed:** {analysis.AnalyzedAt:yyyy-MM-dd HH:mm:ss}");
        sb.AppendLine($"- **Tags:** {string.Join(", ", analysis.Tags)}");
        sb.AppendLine();
        sb.AppendLine("");
        sb.AppendLine();
        sb.AppendLine("## AI Analysis");
        sb.AppendLine();
        sb.AppendLine("### Summary");
        sb.AppendLine();
        sb.AppendLine(analysis.Summary);
        sb.AppendLine();
        sb.AppendLine("### Key Topics");
        sb.AppendLine();
        sb.AppendLine(analysis.KeyTopics);
        sb.AppendLine();
        sb.AppendLine("### Relevance for Developers");
        sb.AppendLine();
        sb.AppendLine(analysis.Relevance);
        sb.AppendLine();
        sb.AppendLine("");
        sb.AppendLine();
        sb.AppendLine("## Generated LinkedIn Post");
        sb.AppendLine();
        sb.AppendLine("```");
        sb.AppendLine(analysis.LinkedInPost);
        sb.AppendLine("```");
        sb.AppendLine();
        sb.AppendLine("");
        sb.AppendLine();
        sb.AppendLine("*This analysis was generated using AI (Semantic Kernel with Azure OpenAI)*");

        File.WriteAllText(filePath, sb.ToString());
        return filePath;
    }

Конструктор принимает путь к выходному каталогу и создает его, если он не существует. Метод GenerateMarkdownFile принимает объект ArticleAnaлиз и создает красиво отформатированный документ с уценкой.

Имя файла включает дату и исправленную версию названия. Это позволяет легко сортировать файлы в хронологическом порядке и сразу идентифицировать их.

Обработка небезопасных имен файлов

Заголовки статей могут содержать символы, которые не разрешены в именах файлов, например двоеточия, косую черту, вопросительные знаки и кавычки. Нам нужно продезинфицировать следующее:

private static string GenerateSafeFileName(string title)
{
    var invalidChars = Path.GetInvalidFileNameChars();
    var safeTitle = new string(title
        .Where(c => !invalidChars.Contains(c))
        .ToArray());
    
    safeTitle = safeTitle.Replace(" ", "-").Replace("--", "-");
    
    if (safeTitle.Length > 50)
    {
        safeTitle = safeTitle.Substring(0, 50);
    }
    
    return safeTitle.TrimEnd('-').ToLowerInvariant();
}

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

Настройка уведомлений Telegram

Почему я выбрал Telegram

Для компонента уведомлений я рассматривал несколько вариантов — электронная почта, SMS, Slack, Discord и Telegram. В конечном итоге я выбрал Telegram по нескольким причинам.

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

API бота невероятно прост. Это просто HTTP-запросы с полезной нагрузкой JSON. Никаких сложных потоков аутентификации и веб-перехватчиков, необходимых для базовой функциональности.Telegram работает везде – на моем телефоне, на моем рабочем столе, в моем веб-браузере. Я могу получать уведомления, где бы я ни находился, и немедленно отвечать на них.

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

Создание бота в Telegram

Настроить бота Telegram на удивление просто. Откройте Telegram и найдите @BotFather — это официальный бот Telegram для создания ботов и управления ими. Начните разговор с BotFather и отправьте команду /newbot. BotFather запросит у вас имя для вашего бота (это отображаемое имя) и имя пользователя (оно должно быть уникальным и заканчиваться на «bot»). Как только вы их предоставите, BotFather создаст вашего бота и предоставит вам токен API. Этот токен подобен паролю — храните его в секрете и не передавайте в публичные репозитории.

Чтобы найти свой идентификатор чата, чтобы бот знал, куда отправлять сообщения, начните разговор с новым ботом, выполнив поиск и нажав «Старт». Затем откройте URL-адрес https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates в браузере или с помощью Curl. Найдите в ответе объект chat — поле id — это ваш идентификатор чата.

Отправка сообщений через API

Вот наша функция для отправки сообщений Telegram:

static async Task SendToTelegramAsync(string botToken, string chatId, string message)
{
    using var httpClient = new HttpClient();
    
    var telegramApiUrl = $"https://api.telegram.org/bot{botToken}/sendMessage";
    
    var payload = new
    {
        chat_id = chatId,
        text = message,
        parse_mode = "HTML"
    };
    
    var jsonContent = JsonSerializer.Serialize(payload);
    var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
    
    var response = await httpClient.PostAsync(telegramApiUrl, content);
    
    if (!response.IsSuccessStatusCode)
    {
        var errorContent = await response.Content.ReadAsStringAsync();
        throw new Exception($"Telegram API error: {response.StatusCode} - {errorContent}");
    }
}

API Telegram Bot основан на REST. Мы делаем POST-запрос к конечной точке sendMessage с телом JSON, содержащим идентификатор чата (куда отправлять), текст сообщения (что отправлять) и, при необходимости, режим анализа (для форматирования).

Установка parse_mode на «HTML» позволяет нам использовать в наших сообщениях базовые HTML-теги – такие, как <b>bold</b> и <i>italic</i>. Это может сделать уведомления более читабельными, хотя в нашем текущем случае мы отправляем простой текст.

Если запрос завершается неудачей, мы выдаем исключение с подробной информацией о том, что пошло не так. Это помогает при отладке, если что-то не работает.

Настройка приложения

Переменные среды

Нашему приложению требуется несколько частей конфиденциальной информации — ключи API, токены ботов и URL-адреса конечных точек. Мы никогда не должны жестко запрограммировать их или передать их под контроль версий. Вместо этого мы используем переменные среды, которые можно безопасно установить в каждой среде, в которой работает приложение.

Для Telegram нам нужен TELEGRAM_BOT_TOKEN (токен, который вам дал BotFather) и TELEGRAM_CHAT_ID (ваш идентификатор чата, куда следует отправлять сообщения).

Для Azure OpenAI нам нужны AZURE_OPENAI_ENDPOINT (URL-адрес вашего ресурса), AZURE_OPENAI_API_KEY (ваш ключ API) и AZURE_OPENAI_DEPLOYMENT (имя вашей развернутой модели, например «gpt-4o»).

Загрузка конфигурации в код

Вот как мы загружаем эти значения при запуске приложения:

var telegramBotToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN");
var telegramChatId = Environment.GetEnvironmentVariable("TELEGRAM_CHAT_ID");

var azureOpenAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT");
var azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
var azureOpenAiDeployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT") ?? "gpt-4o";

var aiAnalysisEnabled = !string.IsNullOrWhiteSpace(azureOpenAiEndpoint) && 
                        !string.IsNullOrWhiteSpace(azureOpenAiKey);

Мы используем Environment.GetEnvironmentVariable для чтения каждого значения. В качестве имени развертывания мы предоставляем значение по умолчанию «gpt-4o», если значение не задано.

Затем мы проверяем, следует ли включать анализ ИИ, проверяя, что у нас есть как конечная точка, так и ключ API. Это позволяет приложению работать в ухудшенном режиме, если Azure OpenAI не настроен — оно по-прежнему будет получать каналы и отслеживать статьи, только без анализа ИИ.### Грациозная деградация

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

ArticleAnalyzer? articleAnalyzer = null;
MarkdownGenerator? markdownGenerator = null;

if (aiAnalysisEnabled)
{
    Console.WriteLine("🤖 AI Analysis enabled - Using Azure OpenAI with Semantic Kernel");
    articleAnalyzer = new ArticleAnalyzer(azureOpenAiEndpoint!, azureOpenAiKey!, azureOpenAiDeployment);
    markdownGenerator = new MarkdownGenerator(articlesOutputDir);
}
else
{
    Console.WriteLine("ℹ️  AI Analysis disabled - Set AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_API_KEY to enable");
}

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

Автоматизация с помощью действий GitHub

Почему действия GitHub

Настоящая сила этого решения заключается в автоматизации. Мы не хотим вручную запускать приложение каждые несколько часов — мы хотим, чтобы оно запускалось автоматически в фоновом режиме.

GitHub Actions идеально подходит для этого. Он встроен в GitHub, поэтому не требуется настраивать дополнительный сервис. Он бесплатен для общедоступных репозиториев и включает щедрые бесплатные минуты для частных репозиториев. Он может работать по расписанию, запуская наше приложение через определенные промежутки времени. Он имеет встроенное управление секретами для безопасного хранения наших ключей API. И он может фиксировать изменения обратно в репозиторий, поддерживая актуальность нашего файла отслеживания.

Файл рабочего процесса

Создайте файл .github/workflows/fetch-and-notify.yml со следующим содержимым:

name: Fetch DevBlogs and Notify

on:
  schedule:
    - cron: '0 */6 * * *'
  workflow_dispatch:

jobs:
  fetch-and-notify:
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '9.0.x'
      
      - name: Restore dependencies
        run: dotnet restore src/VsFeedLinkedin.csproj
      
      - name: Build
        run: dotnet build src/VsFeedLinkedin.csproj --no-restore
      
      - name: Run application
        env:
          TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
          TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
          AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
          AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
          AZURE_OPENAI_DEPLOYMENT: ${{ secrets.AZURE_OPENAI_DEPLOYMENT }}
        run: dotnet run --project src/VsFeedLinkedin.csproj
      
      - name: Commit and push changes
        run: |
          git config user.name "GitHub Actions Bot"
          git config user.email "[email protected]"
          
          if [[ -n $(git status --porcelain posted-articles.md generated-posts/) ]]; then
            TIMESTAMP=$(date +%Y%m%d_%H%M%S)
            git add posted-articles.md generated-posts/
            git commit -m "chore($TIMESTAMP): processed new articles"
            git push
          else
            echo "No changes to commit"
          fi

Позвольте мне объяснить каждую часть. Раздел on определяет, когда запускается рабочий процесс. Триггер расписания использует синтаксис cron — 0 */6 * * * означает «в 0-ю минуту каждого 6-го часа». Таким образом, рабочий процесс выполняется в полночь, 6 утра, полдень и 18:00 по всемирному координированному времени. Триггер workflow_dispatch позволяет запускать вручную из пользовательского интерфейса GitHub, что полезно для тестирования.

Задание выполняется на Ubuntu-latest, виртуальной машине Linux. Мы проверяем наш репозиторий, настраиваем .NET 9, восстанавливаем пакеты NuGet и собираем проект.

На этапе «Запуск приложения» происходит волшебство. Мы передаем наши секреты как переменные среды, используя синтаксис ${{ secrets.SECRET_NAME }}. Эти секреты надежно хранятся в GitHub и никогда не раскрываются в журналах.

Наконец, мы фиксируем все изменения обратно в репозиторий. Мы настраиваем Git с идентификатором бота, проверяем, есть ли какие-либо изменения в нашем файле отслеживания или каталоге сгенерированных сообщений, и если да, создаем коммит и отправляем его.

Настройка секретов

Чтобы добавить секреты в свой репозиторий GitHub, перейдите в «Настройки» вашего репозитория, затем «Секреты и переменные», затем «Действия». Нажмите «Новый секрет репозитория» и добавьте каждую переменную среды. Имена должны точно соответствовать тем, на которые мы ссылаемся в файле рабочего процесса.

Подведение итогов

Что мы создали

Оглядываясь назад на все, что мы рассмотрели, мы создали комплексный агрегатор RSS-каналов на базе искусственного интеллекта, который автоматизирует то, что раньше было утомительным ручным процессом. Приложение автоматически отслеживает семь каналов Microsoft DevBlogs, отслеживая каждую новую статью сразу после ее публикации. Он справляется со сложностями дедупликации, распознавая, когда одна и та же статья появляется в нескольких каналах.Анализ ИИ на основе семантического ядра и Azure OpenAI считывает и понимает содержание статей, создает резюме, определяет ключевые темы и объясняет актуальность — и все это автоматически. Самое главное, он создает интересные публикации в LinkedIn, которыми я могу поделиться с минимальным редактированием.

Интеграция Telegram означает, что я получаю уведомления на свой телефон, когда появляется новый контент для просмотра. Я могу взглянуть на сообщение, решить, хочу ли я им поделиться, и действовать немедленно.

А поскольку он запускается в GitHub Actions по расписанию, мне не нужно ничего не забывать делать. Система работает в фоновом режиме, и я подключаюсь только тогда, когда есть что-то, чем стоит поделиться.

Технологии, которые сделали это возможным

Этот проект объединил несколько технологий, каждая из которых сыграла решающую роль. .NET 9 обеспечил прочную основу благодаря своим современным языковым функциям и превосходной производительности. Семантическое ядро ​​упростило интеграцию ИИ, справившись со всей сложностью вызовов API и управлением ответами. Azure OpenAI предоставил интеллект — возможность действительно понимать и анализировать технический контент. HtmlAgilityPack решил сложную проблему извлечения чистого текста с веб-страниц. System.ServiceModel.Syndicate упростил анализ RSS. API Telegram Bot предоставил нам бесплатные и надежные уведомления. А GitHub Actions связал все это вместе с автоматическим запланированным выполнением.

Думая о затратах

У вас может возникнуть один вопрос: сколько стоит его эксплуатация? Ответ: совсем немного.

Telegram полностью бесплатен — за отправку сообщений через вашего бота плата не взимается.

GitHub Actions бесплатен для общедоступных репозиториев. Для частных репозиториев вы получаете 2000 минут в месяц на бесплатном уровне, чего более чем достаточно для нашего варианта использования.

Azure OpenAI — единственный платный компонент, а затраты минимальны. Используя GPT-4o, анализ типичной статьи в блоге стоит от одного до трех центов. Даже если вы обрабатываете десятки статей в месяц, затраты на ИИ составляют менее доллара.

Где вы могли бы взять это дальше

Хотя это решение отлично подходит для моих нужд, существует множество способов его расширения. Вы можете добавить поддержку нескольких социальных платформ — например, публиковать сообщения в Twitter/X, Mastodon или Bluesky в дополнение к LinkedIn. Вы можете реализовать анализ настроений, чтобы отслеживать тон статей с течением времени и выявлять тенденции. Вы можете разрешить разные шаблоны подсказок для разных каналов, создавая разные стили сообщений для разных тем. Вы можете создать веб-панель для просмотра и управления публикациями вместо использования Telegram. Вы можете отслеживать показатели вовлеченности публикуемого контента, чтобы увидеть, какие темы больше всего резонируют с вашей аудиторией.

Заключительные мыслиЧто мне больше всего нравится в этом проекте, так это то, что он воплощает философию, в которую я твердо верю: автоматизация должна выполнять утомительные части, оставляя творческую часть и принятие решений людям. Система выполняет всю тяжелую работу — выборку, анализ, анализ, генерацию — но я все равно проверяю все, прежде чем поделиться. Публикации, созданные с помощью ИИ, — это отправная точка, которую я могу настроить и персонализировать.

Объединив возможности .NET, семантического ядра и Azure OpenAI, мы создали инструмент, который каждую неделю экономит часы ручной работы, сохраняя при этом качество и согласованность. Это своего рода практическая автоматизация, которая действительно меняет повседневную жизнь.

Если вы создадите что-то подобное или у вас есть идеи по улучшению, я буду рад об этом услышать. Не стесняйтесь обращаться к LinkedIn!

Приятного кодирования и счастливого Рождества! 🎄