Creación de un agregador de feeds RSS impulsado por IA

· 39 min de lectura

Como MVP de Microsoft y entusiasta de la tecnología, constantemente me ahogo en el océano de contenido sorprendente publicado en los DevBlogs de Microsoft. Desde anuncios de .NET hasta actualizaciones de Visual Studio, desde innovaciones de Azure hasta análisis profundos del Kernel Semántico, siempre sucede algo nuevo y emocionante en el ecosistema de Microsoft.

¿El problema? Mantenerse al día con todo esto es casi imposible.

Quería estar al tanto de los últimos anuncios y compartirlos con mi red, pero revisar manualmente siete fuentes RSS diferentes, leer artículos, crear publicaciones atractivas en las redes sociales y realizar un seguimiento de lo que ya había compartido se estaba convirtiendo en un trabajo de tiempo completo en sí mismo. Todas las mañanas abría varias pestañas del navegador, examinaba docenas de artículos, intentaba recordar cuáles ya había compartido y luego dedicaba un tiempo precioso a escribir publicaciones sobre los que me llamaban la atención.

Así que hice lo que haría cualquier desarrollador: Lo automaticé.

En esta guía completa, le explicaré cómo construí un agregador de fuentes RSS con tecnología de inteligencia artificial que monitorea múltiples fuentes RSS de Microsoft DevBlogs en busca de contenido nuevo, usa Azure OpenAI y Semantic Kernel para analizar artículos y generar publicaciones interesantes, crea documentación de rebajas detallada para cada artículo analizado, envía notificaciones a través de Telegram para que pueda revisar y compartir el contenido, realiza un seguimiento de todo para evitar publicaciones duplicadas y se ejecuta automáticamente a través de GitHub Actions.

Profundicemos en cada aspecto de esta solución.

La historia detrás de este proyecto

Vivir con sobrecarga de información

Permítanme describirles una imagen de mi mañana típica antes de crear esta herramienta. Me despertaba, tomaba mi café y abría mi computadora portátil para ver qué había de nuevo en el ecosistema de desarrolladores de Microsoft. Primero, navegué al sitio principal de DevBlogs para ver si había algún anuncio importante. Luego consultaría el blog de .NET específicamente porque esa es mi pila de tecnología principal. Después de eso, pasaría al blog de Semantic Kernel, ya que la IA se está volviendo cada vez más importante. El blog de Visual Studio fue el siguiente en la lista porque las actualizaciones de IDE pueden afectar significativamente mi flujo de trabajo diario. Luego vino el blog DevOps para CI/CD y noticias relacionadas con GitHub, seguido por el blog All Things Azure para actualizaciones de infraestructura en la nube y, finalmente, el blog Azure SQL para innovaciones en bases de datos.

Son siete feeds diferentes para verificar. Cada uno de estos blogs publica varios artículos por semana, a veces varios por día durante los períodos de anuncios importantes, como .NET Conf o Build. Son potencialmente docenas de artículos para rastrear, leer y compartir. Y esta es la cuestión: como alguien que valora compartir conocimientos con la comunidad, no quería simplemente leer estos artículos. Quería compartir los más valiosos con mi red en LinkedIn, ayudando a otros desarrolladores a mantenerse informados también.Pero elaborar una buena publicación en LinkedIn lleva tiempo. Debe leer el artículo detenidamente, comprender los puntos clave, pensar por qué es importante para su audiencia, escribir un gancho atractivo y formatear todo correctamente. Multiplique eso por varios artículos por semana y obtendrá horas de trabajo.

Lo que realmente quería

Después de lidiar con esto durante meses, me senté y pensé en cómo sería una solución ideal. En primer lugar, no quería volver a perderme anuncios importantes. El sistema debería detectar automáticamente nuevos artículos tan pronto como se publiquen. También quería ahorrar tiempo en la creación de contenido permitiendo que la IA me ayudara a crear publicaciones atractivas, no para reemplazar mi voz por completo, sino para brindarme un punto de partida sólido que pudiera personalizar.

La coherencia fue otro factor importante. Quería compartir contenido con regularidad sin tener que acordarme de hacerlo manualmente todos los días. El aspecto de seguimiento también fue crucial: necesitaba una forma de saber lo que ya había compartido para evitar publicar duplicados y molestar a mis seguidores. Finalmente, quería mantenerme organizado con un registro permanente de todo lo que he procesado, para poder mirar hacia atrás y ver qué temas he cubierto.

La solución toma forma

La solución que imaginé se ejecutaría según un cronograma utilizando GitHub Actions, completamente con manos libres. Recuperaría los siete feeds automáticamente sin tener que abrir una sola pestaña del navegador. El componente de IA realmente leería y comprendería el contenido y luego lo resumiría de una manera que fuera útil para mi audiencia. En lugar de tener que escribir publicaciones desde cero, crearía contenido de redes sociales listo para compartir que podría modificar si fuera necesario. Todo se enviaría a mi Telegram para su revisión, de modo que pudiera mirar rápidamente mi teléfono y decidir qué compartir. Y por supuesto, mantendría un registro permanente de todo para referencia futura.

Antes de comenzar a construir

Lo que necesitará en su máquina

Para seguir este tutorial, necesitará algunas cosas instaladas en su máquina de desarrollo. El más importante es la versión 9.0 o posterior del SDK de .NET. Este es nuestro tiempo de ejecución y proporciona todas las herramientas de compilación que necesitamos. Si no lo tiene instalado, diríjase a dot.net y descargue la última versión. La instalación es sencilla en Windows, macOS o Linux.

También querrás tener instalado Git para el control de versiones. Enviaremos nuestro código a GitHub y usaremos GitHub Actions para la automatización, por lo que tener Git configurado localmente es esencial. Cualquier versión reciente funcionará bien.

Para su entorno de desarrollo, recomiendo Visual Studio o VS Code. Personalmente, uso VS Code para la mayor parte de mi trabajo estos días porque es liviano y tiene un excelente soporte para C# a través de la extensión C# Dev Kit. Pero si se siente más cómodo con Visual Studio completo, eso también funciona perfectamente.

Servicios y cuentas que necesitarásMás allá de las herramientas locales, necesitará cuentas con algunos servicios. El más importante es Azure OpenAI, que impulsa nuestro análisis de IA. Este es un servicio de pago por uso, pero los costos son mínimos para este caso de uso: estamos hablando de centavos por artículo analizado. Si no tiene una cuenta de Azure, puede registrarse para una prueba gratuita que incluye algunos créditos para comenzar.

Para las notificaciones, usaremos un Telegram Bot. Lo mejor de Telegram es que su API de bot es de uso completamente gratuito. Puedes crear tantos bots como quieras y enviar mensajes ilimitados. Lo guiaré a través del proceso de configuración más adelante en esta guía.

Finalmente, necesitará una cuenta de GitHub para alojar su código y ejecutar GitHub Actions. El nivel gratuito es más que suficiente para este proyecto. GitHub le ofrece 2000 minutos de tiempo de ejecución de Acciones por mes en repositorios privados y minutos ilimitados en repositorios públicos.

Las bibliotecas que hacen esto posible

Nuestro proyecto se basa en tres paquetes principales de NuGet, cada uno de los cuales tiene un propósito específico.

El primero es HtmlAgilityPack, que es el estándar de oro para el análisis de HTML en .NET. Cuando recuperamos un artículo de un blog, recuperamos el HTML completo de la página, incluidos menús de navegación, pies de página, anuncios y todo tipo de elementos que no nos interesan. HtmlAgilityPack nos permite analizar ese HTML y extraer solo el contenido del artículo que necesitamos.

El segundo paquete es Microsoft.SemanticKernel, que es el SDK de Microsoft para integrar modelos de IA en aplicaciones. Piense en ello como el puente entre su código .NET y modelos de lenguaje grandes como GPT-4. Maneja toda la complejidad de las llamadas API, la administración de tokens y el análisis de respuestas, lo que le permite concentrarse en lo que realmente desea que haga la IA.

El tercer paquete es System.ServiceModel.Syndication, que proporciona soporte integrado para analizar fuentes RSS y Atom. RSS puede parecer una tecnología antigua, pero sigue siendo la mejor manera de obtener actualizaciones estructuradas de blogs y sitios de noticias. Este paquete convierte fuentes XML sin formato en objetos C# fuertemente tipados con los que es fácil trabajar.

Comprender la arquitectura

Cómo encajan las piezas

Antes de profundizar en el código, permítanme explicarles cómo funcionan todos los componentes juntos. Comprender el panorama general hará que los detalles de la implementación sean mucho más claros.

En el nivel más alto, tenemos nuestro archivo principal Program.cs que actúa como orquestador. Este es el punto de entrada de nuestra aplicación y coordina todos los demás componentes. Cuando se ejecuta la aplicación, primero carga la configuración de las variables de entorno, como claves API y credenciales de Telegram. Luego sale y recupera canales RSS de las siete fuentes de Microsoft DevBlogs. A medida que procesa estos feeds, deduplica artículos para manejar casos en los que el mismo artículo aparece en varios feeds. Compara cada artículo con nuestro archivo de seguimiento para ver si ya lo hemos procesado. Para artículos nuevos, los entrega al analizador de IA para su procesamiento.La clase ArticleAnalyzer es donde ocurre la magia de la IA. Este componente recibe un artículo y hace varias cosas con él. Primero, recupera el contenido HTML completo de la URL del artículo. Luego extrae texto limpio de ese HTML, eliminando todos los elementos de navegación, scripts y estilos que no necesitamos. Una vez que tiene texto limpio, lo envía a Azure OpenAI a través del Semantic Kernel con un mensaje cuidadosamente elaborado. La IA analiza el artículo y devuelve una respuesta estructurada que incluye un resumen, temas clave, explicación de relevancia y, lo más importante, una publicación de LinkedIn lista para usar. El analizador analiza esta respuesta y devuelve un objeto ArticleAnalysis que contiene toda esta información.

La clase MarkdownGenerator toma ese objeto ArticleAnalysis y crea un registro permanente del mismo. Genera un archivo de rebajas con un formato agradable que incluye todos los metadatos del artículo, el análisis de la IA y la publicación generada. Estos archivos se almacenan en un directorio de publicaciones generadas, lo que le brinda un archivo con capacidad de búsqueda de todo lo que ha procesado.

Finalmente, la integración de Telegram envía el contenido de la publicación generada a su teléfono. Este es el punto en el que usted, como ser humano, puede revisar el trabajo de la IA y decidir si lo comparte. El bot te envía un mensaje con el contenido de la publicación y puedes copiarlo directamente a LinkedIn o modificarlo primero.

El flujo de datos

Permítame explicarle lo que sucede cuando se publica un nuevo artículo en el blog de .NET. El flujo de trabajo comienza cuando GitHub Actions activa nuestra aplicación según lo programado, digamos cada seis horas. La aplicación se activa y comienza a buscar los siete canales RSS. Cada feed devuelve un documento XML que contiene los artículos más recientes de ese blog.

A medida que analizamos cada feed, extraemos artículos individuales y los almacenamos en una lista. Pero aquí hay una parte complicada: el feed principal de DevBlogs a menudo incluye artículos que también aparecen en los feeds de categorías individuales. Por lo tanto, es posible que aparezca un artículo sobre “.NET 10” tanto en la fuente principal como en la fuente específica de .NET. Manejamos esto rastreando las URL en un HashSet, lo que evita automáticamente los duplicados.

Una vez que tenemos nuestra lista de artículos deduplicados, la filtramos solo a los recientes (generalmente artículos publicados en el último día). No queremos procesar artículos antiguos que ya hemos manejado en ejecuciones anteriores. Luego comparamos cada artículo reciente con nuestro archivo de seguimiento. Si ya procesamos y publicamos sobre un artículo, lo omitimos.

Para cada artículo nuevo, iniciamos el proceso de análisis de IA. El analizador recupera el HTML del artículo completo, lo limpia y lo envía a GPT-4 con nuestro mensaje. La IA lee el artículo y genera un análisis completo junto con una publicación de LinkedIn. Guardamos este análisis en un archivo de rebajas para nuestros registros.Con el análisis completo, formateamos un mensaje y lo enviamos vía Telegram. El mensaje incluye el contenido de la publicación generada con la URL y los hashtags adjuntos. En mi teléfono recibo una notificación, reviso la publicación y, si me gusta, puedo copiarla y compartirla en LinkedIn con solo unos pocos toques.

Finalmente, actualizamos nuestro archivo de seguimiento para marcar este artículo como procesado, de modo que no lo volvamos a manejar en ejecuciones futuras. Si se creó o modificó algún archivo, GitHub Actions envía estos cambios nuevamente al repositorio, manteniendo todo sincronizado.

Configurar el proyecto desde cero

Creando la estructura de la solución

Empecemos a construir. Abra su terminal y navegue hasta donde desea crear el proyecto. Me gusta mantener mis proyectos organizados en una carpeta de Desarrollo, pero puedes colocarlos donde tenga sentido para ti.

Primero, crearemos un nuevo archivo de solución. En .NET, una solución es un contenedor que puede contener múltiples proyectos. Aunque por ahora solo tenemos un proyecto, comenzar con una solución hace que sea más fácil agregar más proyectos más adelante si es necesario. Ejecute el comando dotnet new sln -n vs-feed-linkedin para crear una solución llamada vs-feed-linkedin.

A continuación, necesitamos crear nuestro proyecto de aplicación de consola. Pondremos esto en un subdirectorio src para mantener todo organizado. Ejecute dotnet new console -n VsFeedLinkedin -o src para crear un proyecto de consola llamado VsFeedLinkedin en la carpeta src. Luego agregue este proyecto a nuestra solución con dotnet sln add src/VsFeedLinkedin.csproj.

Ahora navega al directorio src con cd src. Aquí es donde agregaremos nuestros paquetes NuGet y realizaremos la mayor parte de nuestro desarrollo.

Agregar los paquetes necesarios

Con nuestro proyecto creado, necesitamos agregar los tres paquetes NuGet que mencioné anteriormente. Ejecute cada uno de estos comandos en secuencia:

dotnet add package System.ServiceModel.Syndication --version 9.0.9
dotnet add package Microsoft.SemanticKernel --version 1.30.0
dotnet add package HtmlAgilityPack --version 1.11.72

Después de ejecutar estos comandos, el archivo de su proyecto debería verse así:

<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>

El archivo del proyecto le dice a .NET que estamos creando un ejecutable (OutputType es Exe), apuntando a .NET 9.0 y usando características modernas de C# como usos implícitos y tipos de referencia que aceptan valores NULL. La sección ItemGroup enumera nuestras tres dependencias de paquetes con sus versiones exactas.

Profundice en las fuentes RSS

¿Qué es exactamente RSS?

Antes de comenzar a escribir código para recuperar feeds, asegurémonos de comprender con qué estamos trabajando. RSS significa Really Simple Syndication y es un formato XML estandarizado para distribuir actualizaciones de contenido. La idea es simple: en lugar de requerir que los usuarios visiten su sitio web para ver si hay contenido nuevo, publica un archivo legible por máquina que enumera su contenido reciente. Luego, las aplicaciones pueden sondear este archivo periódicamente para descubrir nuevos artículos.

RSS existe desde finales de los años 1990 y principios de los 2000. Podría pensar que es una tecnología obsoleta, pero en realidad todavía se usa ampliamente, especialmente en blogs, sitios de noticias y podcasts. La belleza de RSS es su simplicidad. Es simplemente XML con una estructura definida y cualquier aplicación puede analizarlo.

La estructura de un feed de DevBlogsCuando recupera una fuente RSS de Microsoft DevBlogs, obtiene un documento XML que sigue una estructura específica. En el nivel superior, hay un elemento rss que contiene un elemento de canal único. El canal representa el blog en sí e incluye metadatos como el título, la URL y la descripción del blog.

Dentro del canal, encontrará varios elementos de elementos, cada uno de los cuales representa una publicación de blog individual. Cada elemento incluye un título (el título del artículo), un enlace (la URL donde puede leer el artículo completo), una fecha de publicación (cuando se publicó el artículo), un elemento dc:creator (el nombre del autor), uno o más elementos de categoría (etiquetas para el artículo) y una descripción (generalmente un resumen o extracto del artículo).

Aquí hay un ejemplo simplificado de cómo se ve esto:

<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>.NET Blog</title>
    <link>https://devblogs.microsoft.com/dotnet</link>
    <description>The latest news about .NET</description>
    <item>
      <title>Announcing .NET 10</title>
      <link>https://devblogs.microsoft.com/dotnet/announcing-dotnet-10</link>
      <pubDate>Mon, 10 Dec 2025 12:00:00 GMT</pubDate>
      <dc:creator>Microsoft</dc:creator>
      <category>Announcements</category>
      <category>.NET</category>
      <description>Article summary...</description>
    </item>
  </channel>
</rss>

Lo mejor del paquete System.ServiceModel.Syndication de .NET es que analiza todo esto por nosotros. No tenemos que navegar manualmente por nodos XML ni preocuparnos por diferentes versiones de RSS. Simplemente cargamos el feed y recuperamos objetos fuertemente tipados.

Los siete feeds que monitoreamos

En mi implementación, superviso siete fuentes diferentes de Microsoft DevBlogs. La fuente principal de DevBlogs en devblogs.microsoft.com/feed nos brinda una visión amplia de todo lo que Microsoft publica en todos sus blogs de desarrolladores. La fuente específica de .NET en devblogs.microsoft.com/dotnet/feed se centra específicamente en las versiones, características y mejores prácticas de .NET. El feed Semantic Kernel en devblogs.microsoft.com/semantic-kernel/feed cubre la orquestación e integración de la IA, algo cada vez más importante a medida que la IA se vuelve central para el desarrollo moderno.

La fuente de Visual Studio en devblogs.microsoft.com/visualstudio/feed me mantiene actualizado sobre las mejoras de IDE y las características de productividad. La fuente de DevOps en devblogs.microsoft.com/devops/feed cubre temas de Azure DevOps, GitHub y CI/CD. La fuente All Things Azure en devblogs.microsoft.com/all-things-azure/feed se centra en los servicios de nube y los patrones de arquitectura. Finalmente, la fuente de Azure SQL en devblogs.microsoft.com/azure-sql/feed cubre las innovaciones y características de las bases de datos.

Quizás se pregunte por qué reviso tanto el feed principal como el feed de categorías individuales. La fuente principal me da amplitud: veré artículos de cualquier blog de desarrolladores de Microsoft, incluidos aquellos que quizás no conozca. Las fuentes de categorías me dan profundidad: garantizan que no me pierda nada importante en mis áreas principales de interés, incluso si esos artículos son eliminados de la fuente principal por contenido más nuevo.

Construyendo la lógica de recuperación de RSS

La función de búsqueda principal

Ahora escribamos algo de código. La base de nuestra aplicación es la capacidad de buscar y analizar fuentes RSS. Aquí está la función que maneja esto:

static async Task<SyndicationFeed?> FetchRssFeedAsync(string url)
{
    using var httpClient = new HttpClient();
    httpClient.DefaultRequestHeaders.Add("User-Agent", "VsFeedLinkedin/1.0");
    
    var response = await httpClient.GetStringAsync(url);
    
    using var stringReader = new StringReader(response);
    
    var settings = new XmlReaderSettings
    {
        DtdProcessing = DtdProcessing.Parse,
        MaxCharactersFromEntities = 1024
    };
    
    using var xmlReader = XmlReader.Create(stringReader, settings);
    
    return SyndicationFeed.Load(xmlReader);
}

Déjame explicarte lo que hace este código. Comenzamos creando un HttpClient, que es la clase integrada de .NET para realizar solicitudes HTTP. Configuramos un encabezado User-Agent porque algunos servidores bloquean solicitudes que no se identifican. Es una buena práctica configurar esto incluso cuando los servidores no lo requieran.Luego realizamos una solicitud GET a la URL del feed y recibimos la respuesta como una cadena. Esta cadena contiene el XML sin formato de la fuente RSS.

Para analizar este XML, creamos un StringReader para envolver nuestra cadena de respuesta y luego configuramos algunos XmlReaderSettings. La configuración de DtdProcessing es importante: las fuentes RSS a veces incluyen declaraciones DTD (Definición de tipo de documento) que deben procesarse. La configuración MaxCharactersFromEntities es una medida de seguridad que previene ataques de bombas XML al limitar la expansión de entidades que puede ocurrir.

Finalmente, creamos un XmlReader con esta configuración y usamos SyndicationFeed.Load para analizar el XML en un objeto SyndicationFeed fuertemente tipado. Esto nos da acceso a los metadatos del feed y a todos sus elementos a través de agradables propiedades de C# en lugar de navegación XML sin formato.

Obteniendo múltiples feeds con manejo de errores

En el mundo real, las solicitudes de red fallan. Los servidores caen, las conexiones se agotan y el formato XML puede tener un formato incorrecto. Necesitamos manejar estos casos con gracia. Así es como recuperamos todos nuestros feeds y al mismo tiempo somos resistentes a las fallas:

var allArticles = new List<(SyndicationItem item, string feedUrl)>();
var seenUrls = new HashSet<string>();

foreach (var feedUrl in feedUrls)
{
    try
    {
        Console.WriteLine($"  📡 Fetching {feedUrl}...");
        var feed = await FetchRssFeedAsync(feedUrl);
        
        if (feed?.Items != null && feed.Items.Any())
        {
            foreach (var item in feed.Items)
            {
                var itemUrl = item.Links.FirstOrDefault()?.Uri.ToString() ?? "";
                if (!string.IsNullOrEmpty(itemUrl) && seenUrls.Add(itemUrl))
                {
                    allArticles.Add((item, feedUrl));
                }
            }
        }
    }
    catch (Exception ex)
    {
        Console.WriteLine($"  ⚠️  Failed to fetch {feedUrl}: {ex.Message}");
    }
}

Mantenemos dos colecciones aquí. La lista de todos los artículos contendrá todos los artículos que encontremos, junto con el feed de donde provienen. SeenUrls HashSet rastrea las URL de artículos que ya hemos visto, lo que nos ayuda a evitar duplicados.

Recorremos cada URL del feed y envolvemos la operación de recuperación en un bloque try-catch. Si falla la recuperación de un feed en particular (tal vez el servidor esté inactivo temporalmente), registramos una advertencia y continuamos con el siguiente feed. De esta manera, un problema con un feed no nos impide procesar los demás.

Para cada feed obtenido con éxito, iteramos a través de sus elementos. Extraemos la URL del artículo de la colección de enlaces del artículo. El método HashSet.Add devuelve false si la URL ya está en el conjunto, lo cual es perfecto para nuestra lógica de deduplicación. Solo agregamos el artículo a nuestra lista si es nuevo.

Almacenamos la URL del feed junto con cada artículo porque esta información puede ser útil más adelante; por ejemplo, es posible que queramos saber de qué feed específico proviene un artículo para fines de depuración o registro.

Manejo de duplicados y seguimiento del estado

El desafío de la deduplicación

Como mencioné anteriormente, Microsoft DevBlogs tiene una estructura de alimentación jerárquica que crea un desafío interesante. Cuando un miembro del equipo .NET publica un artículo sobre, por ejemplo, mejoras de rendimiento en .NET 10, ese artículo probablemente aparecerá tanto en la fuente principal de DevBlogs como en la fuente específica de .NET. A veces, incluso puede aparecer en el feed de Visual Studio si se relaciona con funciones del IDE.

Si procesáramos ingenuamente cada artículo de cada feed, terminaríamos analizando y publicando sobre el mismo artículo varias veces. Eso desperdiciaría llamadas API a Azure OpenAI, enviaría spam a nuestro Telegram con notificaciones duplicadas y potencialmente molestaría a nuestros seguidores si publicáramos duplicados.La solución es la deduplicación basada en URL. Cada artículo tiene una URL única, por lo que podemos usarla como identificador. La estructura de datos HashSet es perfecta para esto porque proporciona un tiempo de búsqueda O(1) y evita automáticamente duplicados. Cuando intentamos agregar una URL que ya está en el conjunto, el método Agregar simplemente devuelve falso, haciéndonos saber que debemos omitir ese artículo.

Estado persistente con Markdown

La deduplicación maneja duplicados dentro de una sola ejecución, pero ¿qué pasa entre ejecuciones? Cuando nuestra aplicación se ejecuta cada seis horas, debemos recordar qué artículos ya hemos procesado para no volver a procesarlos.

Elegí almacenar este estado en un archivo de rebajas llamado post-articles.md. ¿Por qué rebajas? Algunas razones. Primero, es legible por humanos. Puedo abrir el archivo y ver inmediatamente qué artículos he compartido. En segundo lugar, está controlado por versiones. Dado que este archivo se encuentra en nuestro repositorio de Git, tengo un historial completo de cuándo se procesaron los artículos. En tercer lugar, sirve como documentación. Cualquiera que mire el repositorio puede ver lo que ha hecho la aplicación.

El formato de este archivo es simple. Tiene un encabezado, una marca de tiempo que muestra cuándo se ejecutó la aplicación por última vez y luego una lista de artículos en formato de enlace de rebajas:

# Posted Articles

*Last run: 2025-12-10 15:30:00*

List of articles posted to LinkedIn:

- [Announcing .NET 10](https://devblogs.microsoft.com/dotnet/announcing-dotnet-10?wt.mc_id=DT-MVP-5004972) - Posted on 2025-12-10 15:30:00 (Published: 2025-12-10)
- [Visual Studio 2026 Preview](https://devblogs.microsoft.com/visualstudio/vs-2026-preview?wt.mc_id=DT-MVP-5004972) - Posted on 2025-12-09 10:15:00 (Published: 2025-12-09)

Cargando y analizando el archivo de seguimiento

Para comprobar si ya hemos procesado un artículo, debemos cargar este archivo y extraer las URL. Aquí está la función que hace esto:

static HashSet<string> LoadPostedArticles(string filePath)
{
    var postedUrls = new HashSet<string>();
    
    if (!File.Exists(filePath))
    {
        return postedUrls;
    }

    var lines = File.ReadAllLines(filePath);
    foreach (var line in lines)
    {
        var match = System.Text.RegularExpressions.Regex.Match(line, @"\(([^)]+)\)");
        if (match.Success)
        {
            var url = match.Groups[1].Value;
            
            if (url.Contains("?wt.mc_id="))
            {
                url = url.Substring(0, url.IndexOf("?wt.mc_id="));
            }
            else if (url.Contains("?"))
            {
                url = url.Substring(0, url.IndexOf("?"));
            }
            
            url = url.TrimEnd('/');
            postedUrls.Add(url);
        }
    }

    return postedUrls;
}

Esta función devuelve un HashSet que contiene todas las URL que ya hemos procesado. Comenzamos comprobando si el archivo existe; en la primera ejecución, no existe, por lo que devolvemos un conjunto vacío.

Para cada línea del archivo, utilizamos una expresión regular para extraer la URL del formato de enlace de rebajas. La expresión regular \(([^)]+)\) coincide con todo lo que esté entre paréntesis, que es donde los enlaces de rebajas almacenan sus URL.

Luego viene un paso importante: la normalización de URL. Las URL del mismo artículo pueden variar en formato. La fuente RSS puede proporcionarnos https://devblogs.microsoft.com/dotnet/article, pero nuestra versión guardada tiene un parámetro de seguimiento adjunto: https://devblogs.microsoft.com/dotnet/article?wt.mc_id=DT-MVP-5004972. Algunas URL tienen barras al final, otras no.

Para manejar esto, eliminamos todos los parámetros de consulta (todo lo que está después de ?) y eliminamos las barras diagonales. Esta normalización garantiza que reconozcamos los artículos como duplicados incluso si sus URL difieren de manera superficial.

Guardando nuevos artículos

Cuando procesamos exitosamente un artículo, debemos agregarlo a nuestro archivo de seguimiento:

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);
}

Esta función crea una entrada con formato de rebajas con el título del artículo como enlace, seguido de marcas de tiempo que muestran cuándo lo publicamos y cuándo se publicó originalmente. Si el archivo aún no existe, primero lo creamos con un encabezado.

El motor de análisis de IA

Comprender el núcleo semánticoAhora llegamos a la parte más interesante de nuestra aplicación: el análisis de IA. Semantic Kernel es el SDK de código abierto de Microsoft para integrar grandes modelos de lenguaje en aplicaciones. Es más que una simple envoltura de llamadas API. Proporciona un marco para crear aplicaciones de IA sofisticadas con funciones como complementos, planificadores y memoria.

Para nuestro caso de uso, utilizamos las capacidades de finalización de chat de Semantic Kernel. Enviaremos un mensaje a Azure OpenAI y el modelo analizará nuestro artículo y generará una respuesta. Semantic Kernel maneja toda la complejidad de la autenticación de API, el formato de solicitudes y el análisis de respuestas.

Configuración del analizador de artículos

Veamos cómo configuramos nuestra clase de analizador:

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>();
    }

Semantic Kernel utiliza un patrón de creación para la configuración. Creamos un KernelBuilder, agregamos nuestro servicio de finalización de chat Azure OpenAI con las credenciales necesarias y luego compilamos el kernel. Del kernel construido, recuperamos la interfaz IChatCompletionService, que usaremos para enviar mensajes y recibir respuestas.

El constructor toma tres parámetros: el punto final de Azure OpenAI (algo así como https://your-resource.openai.azure.com/), su clave API y el nombre de la implementación (como gpt-4o). Estos se pasan desde variables de entorno, manteniendo nuestras credenciales seguras.

Elaboración del mensaje perfecto

El mensaje que enviamos a la IA es crucial. Un mensaje bien elaborado produce resultados consistentes y de alta calidad. Una indicación vaga o mal estructurada produce resultados mediocres e inconsistentes. Pasé un tiempo considerable iterando este mensaje para obtener resultados con los que estoy satisfecho:

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]
    """;

Permítanme explicar las decisiones de diseño aquí. Comenzamos dándole a la IA un papel claro: “Usted es un analista de contenido tecnológico profesional y un creador de contenido de LinkedIn”. Esto prepara al modelo para responder con el estilo y la voz adecuados.

Proporcionamos todo el contexto que la IA necesita: el título del artículo, el autor, la URL, las etiquetas del canal RSS y el contenido completo del artículo. Cuanto más contexto demos, mejor será el análisis.

Luego especificamos exactamente lo que queremos de vuelta. Pido cuatro cosas: un resumen, temas clave, explicación de relevancia y una publicación en LinkedIn. Específicamente para la publicación de LinkedIn, doy instrucciones detalladas sobre lo que hace que una publicación sea buena: debe tener un gancho, resaltar el valor, incluir un llamado a la acción, usar emojis de manera adecuada y mantener un tono profesional.

Las instrucciones negativas son igualmente importantes. Le digo explícitamente a la IA que NO incluya hashtags ni la URL en la publicación. ¿Por qué? Porque los agrego por separado, y si la IA los incluyera, tendría duplicados. Este tipo de instrucción explícita previene errores comunes.

Finalmente, especifico el formato de salida exacto. Al solicitar secciones marcadas con encabezados ##, hago que la respuesta sea fácil de analizar mediante programación. La IA es muy buena para seguir instrucciones de formato y esta coherencia hace que nuestro código de análisis sea más simple y confiable.

Ejecutando el análisis

Así es como lo armamos todo:

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);
}
```Primero extraemos texto limpio del contenido HTML (lo explicaré en la siguiente sección). Luego truncamos el contenido si es demasiado largo. Los modelos de lenguaje grandes tienen límites simbólicos y los artículos muy largos pueden excederlos. Al limitar a 8000 caracteres, nos aseguramos de mantenernos dentro de los límites y al mismo tiempo proporcionar un contexto sustancial.

Creamos un objeto ChatHistory y agregamos nuestro mensaje como mensaje de usuario. Esta es la abstracción de Semantic Kernel para interacciones basadas en chat. Enviamos esto al servicio de finalización del chat y recibimos una respuesta. Finalmente, analizamos la respuesta para extraer las secciones individuales.

### Analizando la respuesta de la IA

La IA devuelve su respuesta como texto formateado con nuestra estructura solicitada. Necesitamos analizar esto en campos individuales:

```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;
}

Dividimos la respuesta por los marcadores ##, lo que nos da cada sección. Para cada sección, dividimos por nueva línea para separar el encabezado del contenido. Luego usamos una declaración de cambio para asignar el contenido de cada sección a la propiedad apropiada.

También almacenamos la respuesta sin procesar y sin analizar. Esto es útil para la depuración: si algo sale mal con el análisis, podemos ver lo que realmente devolvió la IA.

Extracción de contenido de HTML

Por qué necesitamos limpiar HTML

Cuando recuperamos un artículo de un blog, obtenemos el HTML completo de la página. Esto incluye mucho más que solo el contenido del artículo: hay menús de navegación, encabezados, pies de página, barras laterales, widgets de artículos relacionados, secciones de comentarios, secuencias de comandos para análisis y seguimiento, hojas de estilo y todo tipo de otros elementos.

Si enviáramos todo esto a nuestra IA, sucederían varias cosas malas. La IA tendría que procesar una gran cantidad de texto irrelevante, desperdiciando tokens y potencialmente confundiendo el análisis. El texto de navegación y pie de página puede incluirse en el resumen. Los scripts y CSS serían tratados como contenido, contaminando aún más el análisis.

Necesitamos extraer sólo el contenido del artículo, la parte que un lector humano realmente leería.

Usando HtmlAgilityPack

HtmlAgilityPack es una sólida biblioteca de análisis HTML para .NET. A diferencia de XML, HTML a menudo tiene un formato incorrecto: es posible que las etiquetas no estén cerradas correctamente y que los atributos no estén entrecomillados correctamente. HtmlAgilityPack maneja todo esto con gracia, brindándonos una estructura similar a DOM que podemos consultar y manipular.

Aquí está nuestra función de extracción:

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();
}

Cargamos el HTML en un HtmlDocument, que lo analiza en una estructura de árbol. Luego usamos XPath para seleccionar todos los nodos que queremos eliminar. La expresión XPath //script|//style|//nav|//footer|//header selecciona todos los elementos de script (código JavaScript que no necesitamos), elementos de estilo (CSS que no necesitamos), elementos de navegación (menús de navegación), elementos de pie de página y elementos de encabezado.

Después de eliminar estos nodos, obtenemos la propiedad InnerText, que extrae todo el contenido del texto mientras elimina las etiquetas HTML. Esto nos da el texto plano del artículo.Finalmente, limpiamos los espacios en blanco. HTML a menudo tiene muchos espacios en blanco adicionales para fines de formato: múltiples espacios, tabulaciones, nuevas líneas. Usamos una expresión regular para reemplazar cualquier secuencia de espacios en blanco con un solo espacio y luego recortamos el resultado.

Obteniendo el artículo completo

La fuente RSS sólo nos proporciona resúmenes, no el contenido completo del artículo. Para obtener el texto completo, debemos buscar la página web del artículo:

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;
    }
}

Esto es sencillo: realizamos una solicitud HTTP GET a la URL del artículo y devolvemos la respuesta HTML. Lo envolvemos en un try-catch porque las solicitudes de red pueden fallar y preferimos devolver una cadena vacía que bloquear toda la aplicación.

Creando documentación permanente

¿Por qué generar archivos Markdown?

Cada vez que analizamos un artículo, generamos un archivo de rebajas detallado que documenta ese análisis. Esto tiene varios propósitos.

Primero, crea un archivo con capacidad de búsqueda. Con el tiempo, creará una colección de artículos analizados. Puede buscar en estos archivos para encontrar contenido anterior sobre temas específicos.

En segundo lugar, proporciona transparencia. Puede ver exactamente lo que generó la IA para cada artículo, incluido el análisis completo y la publicación de LinkedIn.

En tercer lugar, es útil para depurar. Si algo sale mal con una publicación, puede consultar el archivo de rebajas para comprender qué sucedió.

La clase generadora de rebajas

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(análisis.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;
    }

El constructor toma una ruta del directorio de salida y la crea si no existe. El método GenerateMarkdownFile toma un objeto ArticleAnalysis y produce un documento de rebajas con un formato agradable.

El nombre del archivo incluye la fecha y una versión desinfectada del título. Esto hace que los archivos sean fáciles de ordenar cronológicamente e identificar de un vistazo.

Manejo de nombres de archivos inseguros

Los títulos de los artículos pueden contener caracteres que no están permitidos en los nombres de archivos, como dos puntos, barras diagonales, signos de interrogación y comillas. Necesitamos desinfectar estos:

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();
}

Usamos Path.GetInvalidFileNameChars() para obtener una lista de caracteres que no pueden aparecer en los nombres de archivos en el sistema operativo actual. Los filtramos, reemplazamos los espacios con guiones para facilitar la lectura, limitamos la longitud a 50 caracteres y los convertimos a minúsculas para mantener la coherencia.

Configuración de notificaciones de Telegram

Por qué elegí Telegram

Para el componente de notificación, consideré varias opciones: correo electrónico, SMS, Slack, Discord y Telegram. Finalmente elegí Telegram por varias razones.

La API es completamente gratuita y no tiene límites de velocidad para un uso razonable. Muchos servicios de notificación tienen límites sobre la cantidad de mensajes que puedes enviar de forma gratuita, pero Telegram no restringe los mensajes de bot a usuarios individuales.

La API del bot es increíblemente simple. Son solo solicitudes HTTP con cargas JSON. No se requieren flujos de autenticación complejos ni webhooks para la funcionalidad básica.Telegram funciona en todas partes: en mi teléfono, en mi escritorio, en mi navegador web. Puedo recibir notificaciones donde quiera que esté y responder de inmediato.

Los mensajes admiten formato enriquecido. Puedo usar texto en negrita, cursiva e incluso bloques de código para que mis notificaciones sean más legibles.

Creando tu bot de Telegram

Configurar un bot de Telegram es sorprendentemente fácil. Abra Telegram y busque @BotFather; este es el bot oficial de Telegram para crear y administrar bots. Inicie una conversación con BotFather y envíe el comando /newbot. BotFather le pedirá un nombre para su bot (este es el nombre para mostrar) y un nombre de usuario (debe ser único y terminar en “bot”). Una vez que los haya proporcionado, BotFather creará su bot y le dará un token API. Este token es como una contraseña: manténgalo en secreto y no lo envíe a repositorios públicos.

Para encontrar su ID de chat para que el bot sepa dónde enviar mensajes, inicie una conversación con su nuevo bot buscándolo y presionando Iniciar. Luego accede a la URL https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates en tu navegador o usando curl. Busque el objeto chat en la respuesta; el campo id es su ID de chat.

Envío de mensajes a través de la API

Aquí está nuestra función para enviar mensajes de 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}");
    }
}

La API de Telegram Bot está basada en REST. Realizamos una solicitud POST al punto final de sendMessage con un cuerpo JSON que contiene el ID del chat (dónde enviar), el texto del mensaje (qué enviar) y, opcionalmente, un modo de análisis (para formatear).

Configurar parse_mode en “HTML” nos permite usar etiquetas HTML básicas en nuestros mensajes, cosas como <b>bold</b> y <i>italic</i>. Esto puede hacer que las notificaciones sean más legibles, aunque para nuestro caso de uso actual enviamos texto sin formato.

Si la solicitud falla, lanzamos una excepción con detalles sobre lo que salió mal. Esto ayuda con la depuración si algo no funciona.

Configurando la aplicación

Variables de entorno

Nuestra aplicación necesita varios datos confidenciales: claves API, tokens de bot y URL de puntos finales. Nunca deberíamos codificarlos ni comprometerlos con el control de versiones. En su lugar, utilizamos variables de entorno, que se pueden configurar de forma segura en cada entorno donde se ejecuta la aplicación.

Para Telegram, necesitamos TELEGRAM_BOT_TOKEN (el token que te dio BotFather) y TELEGRAM_CHAT_ID (tu ID de chat donde se deben enviar los mensajes).

Para Azure OpenAI, necesitamos AZURE_OPENAI_ENDPOINT (la URL de su recurso), AZURE_OPENAI_API_KEY (su clave API) y AZURE_OPENAI_DEPLOYMENT (el nombre de su modelo implementado, como “gpt-4o”).

Cargando configuración en código

Así es como cargamos estos valores al iniciar la aplicación:

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);

Usamos Environment.GetEnvironmentVariable para leer cada valor. Para el nombre de la implementación, proporcionamos un valor predeterminado de “gpt-4o” si no se establece ningún valor.

Luego verificamos si el análisis de IA debe habilitarse verificando que tengamos un punto final y una clave API. Esto permite que la aplicación se ejecute en modo degradado si Azure OpenAI no está configurado; seguirá obteniendo feeds y rastreando artículos, pero sin el análisis de IA.### Degradación elegante

Este concepto de degradación elegante es importante. No queremos que la aplicación falle solo porque una característica opcional no está configurada:

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");
}

Si la IA está habilitada, creamos el analizador y el generador de rebajas. De lo contrario, los dejamos nulos y nos saltamos los pasos relacionados con la IA durante el procesamiento. La aplicación sigue aportando valor al obtener feeds y enviar notificaciones básicas, incluso sin la mejora de la IA.

Automatización con acciones de GitHub

Por qué las acciones de GitHub

El verdadero poder de esta solución proviene de la automatización. No queremos ejecutar la aplicación manualmente cada pocas horas; queremos que se ejecute automáticamente en segundo plano.

GitHub Actions es perfecto para esto. Está integrado en GitHub, por lo que no es necesario configurar ningún servicio adicional. Es gratuito para repositorios públicos e incluye generosos minutos gratuitos para repositorios privados. Puede ejecutarse según una programación, activando nuestra aplicación a intervalos regulares. Tiene gestión de secretos incorporada para almacenar nuestras claves API de forma segura. Y puede enviar los cambios al repositorio, manteniendo nuestro archivo de seguimiento actualizado.

El archivo de flujo de trabajo

Cree un archivo en .github/workflows/fetch-and-notify.yml con el siguiente contenido:

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

Déjame explicarte cada parte. La sección on define cuándo se ejecuta el flujo de trabajo. El activador de programación utiliza la sintaxis cron: 0 */6 * * * significa “en el minuto 0 de cada sexta hora”. Por lo tanto, el flujo de trabajo se ejecuta a medianoche, a las 6 a. m., al mediodía y a las 6 p. m. UTC. El activador flujo de trabajo_dispatch permite ejecuciones manuales desde la interfaz de usuario de GitHub, lo cual es útil para realizar pruebas.

El trabajo se ejecuta en ubuntu-latest, que es una máquina virtual Linux. Revisamos nuestro repositorio, configuramos .NET 9, restauramos paquetes NuGet y compilamos el proyecto.

El paso Ejecutar aplicación es donde ocurre la magia. Pasamos nuestros secretos como variables de entorno usando la sintaxis ${{ secrets.SECRET_NAME }}. Estos secretos se almacenan de forma segura en GitHub y nunca se exponen en los registros.

Finalmente, confirmamos cualquier cambio en el repositorio. Configuramos Git con una identidad de bot, verificamos si hay algún cambio en nuestro archivo de seguimiento o directorio de publicaciones generadas y, de ser así, creamos una confirmación y la enviamos.

Configuración de secretos

Para agregar secretos a su repositorio de GitHub, vaya a Configuración de su repositorio, luego Secretos y variables, luego Acciones. Haga clic en “Nuevo secreto del repositorio” y agregue cada una de sus variables de entorno. Los nombres deben coincidir exactamente con lo que hacemos referencia en el archivo de flujo de trabajo.

Concluyendo

Lo que hemos construido

Mirando hacia atrás en todo lo que hemos cubierto, hemos creado un agregador de feeds RSS integral impulsado por IA que automatiza lo que solía ser un proceso manual tedioso. La aplicación monitorea automáticamente siete feeds de Microsoft DevBlogs y detecta cada artículo nuevo tan pronto como se publica. Maneja las complejidades de la deduplicación y reconoce cuando el mismo artículo aparece en varios feeds.El análisis de IA impulsado por Semantic Kernel y Azure OpenAI lee y comprende el contenido de los artículos, genera resúmenes, identifica temas clave y explica la relevancia, todo de forma automática. Lo más importante es que crea publicaciones atractivas en LinkedIn que puedo compartir con una edición mínima.

La integración de Telegram significa que recibo notificaciones en mi teléfono cada vez que hay contenido nuevo para revisar. Puedo echar un vistazo al mensaje, decidir si quiero compartirlo y actuar de inmediato.

Y debido a que se ejecuta en GitHub Actions según un cronograma, no tengo que acordarme de hacer nada. El sistema funciona en segundo plano y solo participo cuando hay algo que vale la pena compartir.

Las tecnologías que lo hicieron posible

Este proyecto reunió varias tecnologías y cada una de ellas desempeñó un papel crucial. .NET 9 proporcionó una base sólida con sus características de lenguaje moderno y su excelente rendimiento. Semantic Kernel simplificó la integración de la IA, manejando toda la complejidad de las llamadas API y la gestión de respuestas. Azure OpenAI proporcionó la inteligencia: la capacidad de comprender y analizar contenido técnico. HtmlAgilityPack resolvió el complicado problema de extraer texto limpio de páginas web. System.ServiceModel.Syndication hizo que el análisis de RSS fuera muy sencillo. La API de Telegram Bot nos brindó notificaciones confiables y gratuitas. Y GitHub Actions lo vinculó todo con una ejecución automatizada y programada.

Pensando en los costos

Una pregunta que podría tener es: ¿cuánto cuesta ejecutar esto? La respuesta es: no mucho en absoluto.

Telegram es completamente gratuito: no hay cargos por enviar mensajes a través de tu bot.

GitHub Actions es gratuito para repositorios públicos. Para los repositorios privados, obtienes 2000 minutos por mes en el nivel gratuito, lo cual es más que suficiente para nuestro caso de uso.

Azure OpenAI es el único componente pago y los costos son mínimos. Con GPT-4o, analizar un artículo de blog típico cuesta entre uno y tres centavos. Incluso si procesa docenas de artículos por mes, obtendrá menos de un dólar en costos de IA.

¿Dónde podrías llevar esto a continuación?

Si bien esta solución funciona muy bien para mis necesidades, hay muchas formas de ampliarla. Podrías agregar soporte para múltiples plataformas sociales, tal vez publicar en Twitter/X, Mastodon o Bluesky además de LinkedIn. Podría implementar un análisis de sentimiento para rastrear el tono de los artículos a lo largo del tiempo y detectar tendencias. Podrías permitir diferentes plantillas de mensajes para diferentes feeds, generando diferentes estilos de publicaciones para diferentes temas. Podrías crear un panel web para revisar y administrar publicaciones en lugar de usar Telegram. Puede realizar un seguimiento de las métricas de participación del contenido publicado para ver qué temas resuenan más en su audiencia.

Pensamientos finalesLo que más me gusta de este proyecto es que encarna una filosofía en la que creo firmemente: la automatización debe encargarse de las partes tediosas y dejar las partes creativas y de toma de decisiones a los humanos. El sistema hace todo el trabajo duro (buscar, analizar, generar), pero aún así reviso todo antes de compartirlo. Las publicaciones generadas por IA son puntos de partida que puedo personalizar y personalizar.

Al combinar el poder de .NET, Semantic Kernel y Azure OpenAI, hemos creado una herramienta que ahorra horas de trabajo manual cada semana mientras mantiene la calidad y la coherencia. Es el tipo de automatización práctica que marca una diferencia real en la vida diaria.

Si construyes algo similar o tienes ideas para mejorar, me encantaría saberlo. ¡No dudes en comunicarte con LinkedIn!

¡Feliz codificación y feliz Navidad! 🎄