Lienzo Abierto: una galería de imágenes para aprender, adaptar y hacer tuya
Una galería de imágenes con búsqueda, favoritos e importación local. Código abierto, ilustraciones de ejemplo y un recorrido para adaptarla.
Crea un sistema de Web Scraping en C# con Selenium y Kafka. Extrae datos, gestiona colas de eventos y escala tus apps. ¡Incluye código gratis en GitHub!

¿Quieres aprender a crear un scraper eficiente y escalable en C#? En este tutorial, te guiaré paso a paso para construir desde cero un sistema de Web Scraping utilizando Selenium junto a Apache Kafka para la gestión de eventos.
He diseñado este template con un enfoque completamente didáctico, pensado para que comprendas cada parte del proceso: desde cómo enviar URLs a una cola de eventos con Kafka hasta cómo consumirlas y procesarlas de forma asíncrona.
🎁 Y lo mejor de todo… ¡es totalmente gratis!
Aquí aprenderás a escalar aplicaciones de scraping usando colas de eventos y a procesar datos de forma distribuida, todo con ejemplos claros y un enfoque práctico.
Apache Kafka es una plataforma distribuida de mensajería orientada a flujos de datos. Funciona como un sistema de colas altamente escalable y es ideal para procesar grandes volúmenes de eventos en tiempo real.
He estructurado el proyecto para centrarme en los aspectos más importantes y didácticos, simplificando el código y eliminando cualquier complejidad innecesaria. El objetivo es que puedas entender fácilmente cómo funciona cada parte del sistema y cómo se integran entre sí.
InmoCopilotoScrapper/
├── Application/
│ └── Events/
│ └── ScrapperConsumerService.cs # Servicio que consume los eventos desde Kafka
├── Domain/
│ ├── Controllers/
│ │ └── ScrappingController.cs # Controlador que envía eventos a Kafka
│ ├── Entities/
│ │ └── WebPageContent.cs # Entidad que representa el contenido extraído
│ └── Interfaces/
│ └── IEventRepository.cs # Interfaz para manejar eventos
├── Infrastructure/
│ └── EventRepository.cs # Implementación de la interfaz para Kafka
├── Services/
│ └── WebScrapingService.cs # Servicio que realiza el scraping
├── appsettings.json # Configuración general
├── appsettings.Development.json # Configuración para entorno de desarrollo
└── Program.cs # Entrada principal del programa
Este enfoque modular te permitirá entender claramente cada parte del flujo y cómo Kafka se integra en el proceso.
ScrappingController.ScrapperConsumerService actúa como consumidor, escuchando eventos desde el topic de Kafka.WebScrapingService para realizar el scraping de la URL proporcionada.Primero, asegúrate de tener las dependencias necesarias en tu proyecto. Para conectarte a Kafka desde C#, puedes usar la librería Confluent.Kafka.
dotnet add package Confluent.Kafka
ScrappingController.csEl controlador será el encargado de recibir peticiones HTTP y enviar los datos a Kafka.
using InmoCopilotoScrapper.Domain.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace InmoCopilotoScrapper.Controllers;
[ApiController]
[Route("[controller]")]
public class ScrapingController : ControllerBase
{
private readonly IEventRepository _eventRepository;
public ScrapingController(IEventRepository eventRepository)
{
_eventRepository = eventRepository;
}
[HttpGet("scrape")]
public async Task<IActionResult> ScrapeWebsite([FromQuery] string url)
{
if (string.IsNullOrEmpty(url))
{
return BadRequest("URL is required.");
}
await _eventRepository.CreateScrappingEventAsync(url);
return Ok();
}
}
✅ ¿Qué hace este código?
scraping-topic.ScrapperConsumerService.csEl consumidor escuchará el topic y procesará las URLs.
using Confluent.Kafka;
using InmoCopilotoScrapper.Services;
namespace InmoCopilotoScrapper.Application.Events;
public class ScrapperConsumerService : BackgroundService
{
private readonly IConsumer<Ignore, string> _consumer;
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<ScrapperConsumerService> _logger;
private readonly IConfiguration _configuration;
public ScrapperConsumerService(IConfiguration configuration, ILogger<ScrapperConsumerService> logger, IServiceProvider serviceProvider)
{
_logger = logger;
var consumerConfig = new ConsumerConfig
{
BootstrapServers = configuration["Kafka:BootstrapServers"],
GroupId = "csharp-group-1",
AutoOffsetReset = AutoOffsetReset.Latest,
SecurityProtocol = SecurityProtocol.SaslSsl,
SaslMechanism = SaslMechanism.Plain,
SaslUsername = configuration["Kafka:SaslUsername"],
SaslPassword = configuration["Kafka:SaslPassword"],
SessionTimeoutMs = 3600000,
EnableAutoCommit = false, // Deshabilitar commit automático
StatisticsIntervalMs = 60000,
EnablePartitionEof = true,
MaxPollIntervalMs = 3600000,
SocketKeepaliveEnable = true,
MetadataMaxAgeMs = 300000,
ReconnectBackoffMs = 1000,
ReconnectBackoffMaxMs = 10000,
};
_consumer = new ConsumerBuilder<Ignore, string>(consumerConfig)
.SetLogHandler((_, logMessage) => _logger.LogDebug(logMessage.Message))
.SetErrorHandler((_, e) => _logger.LogError($"Error: {e.Reason}"))
.Build();
_serviceProvider = serviceProvider;
_configuration = configuration;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_consumer.Subscribe(_configuration["KAFKA_TOPIC_GENERATE_SCRAPPING"]);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessKafkaMessage(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError($"Error in ExecuteAsync: {ex.Message}");
}
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); // Asegúrate de no hacer delays largos aquí.
}
_consumer.Close();
}
public async Task ProcessKafkaMessage(CancellationToken stoppingToken)
{
string website = string.Empty;
try
{
var consumeResult = _consumer.Consume(stoppingToken);
if (consumeResult != null && consumeResult.Message != null)
{
website = consumeResult.Message.Value;
using (var scope = _serviceProvider.CreateScope())
{
var scrappingService = scope.ServiceProvider.GetRequiredService<WebScrapingService>();
var result = await scrappingService.ScrapePage(website);
//TODO: Haz aquí lo que necesites con el resultado del scrapping
_logger.LogInformation("Scrapping has been processed: {website}", website);
try
{
_consumer.Commit(consumeResult);
_logger.LogInformation("Committed offset for processed scrapping: {website}", website);
}
catch (KafkaException ex)
{
_logger.LogError($"Error committing offset: {ex.Message}");
}
}
}
else
{
_logger.LogInformation("No message received in the last 10 seconds");
}
}
catch (ConsumeException ex)
{
_logger.LogError($"Consume error: {ex.Error.Reason}");
// Opción de manejar errores de consumo específicos aquí
}
catch (KafkaException ex)
{
_logger.LogError($"Kafka error: {ex.Error.Reason}");
// Opción de manejar errores de Kafka específicos aquí
}
catch (Exception ex)
{
_logger.LogError($"Error processing Kafka message: {website} {ex.Message}");
// Asegurarse de que se manejen todos los errores y se liberen recursos si es necesario
}
}
}
✅ ¿Qué hace este código?
WebScrapingService.csEste servicio se mantiene como antes, usando Selenium para realizar el scraping. Aquí solo ejecutará el proceso para cada URL que llega desde Kafka.
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using System.Net;
using System.Text.RegularExpressions;
using System.Xml;
namespace InmoCopilotoScrapper.Services
{
public class WebScrapingService
{
private readonly ILogger<WebScrapingService> _logger;
private readonly ChromeDriver _driver;
public WebScrapingService(ILogger<WebScrapingService> logger)
{
_logger = logger;
var options = new ChromeOptions();
options.AddArgument("--headless"); // Puedes desactivar temporalmente para depuración
options.AddArgument("--disable-gpu");
options.AddArgument("--no-sandbox");
options.AddArgument("--disable-dev-shm-usage");
// Establecer el idioma preferido del navegador
options.AddArgument("--lang=es-ES");
options.AddArgument("–-lang=es-ES");
options.AddArgument("--accept-lang=es-ES");
options.AddUserProfilePreference("intl.accept_languages", "es-ES,es");
options.AddArgument("user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36");
// Emular un dispositivo móvil específico
//options.EnableMobileEmulation("iPhone X");
// Configuración de geolocalización (opcional)
var locationContext = new Dictionary<string, object>
{
{ "latitude", 40.416775 }, // Coordenadas de Madrid, España
{ "longitude", -3.703790 },
{ "accuracy", 1 }
};
_driver = new ChromeDriver(options);
_driver.Manage().Cookies.DeleteAllCookies();
_driver.ExecuteCdpCommand("Emulation.setGeolocationOverride", locationContext);
}
public async Task<List<PropertyData>> ScrapePage(string url)
{
var allUrls = new HashSet<string>();
var propertyDataList = new List<PropertyData>();
await Task.Run(() => GetAllLinks(url, allUrls));
foreach (var link in allUrls)
{
_logger.LogInformation($"Processing link: {link}");
var propertyData = await Task.Run(() => GetPropertyDataFromPage(link));
if (propertyData != null)
{
propertyDataList.Add(propertyData);
}
}
//SAVE FIREBASE
return propertyDataList;
}
private void GetAllLinks(string url, HashSet<string> allUrls)
{
_driver.Navigate().GoToUrl(url);
// Extraer enlaces de la página
var links = _driver.FindElements(By.CssSelector("a[href]"))
.Select(element => element.GetAttribute("href"))
.Where(href => !string.IsNullOrEmpty(href) && Uri.IsWellFormedUriString(href, UriKind.RelativeOrAbsolute))
.Select(href => new Uri(new Uri(url), href).ToString())
.Distinct()
.ToList();
_logger.LogInformation($"Found {links.Count} links on {url}");
// Agregar links encontrados a la lista final
foreach (var link in links)
{
_logger.LogInformation($"Adding link: {link}");
allUrls.Add(link);
}
// Buscar el archivo sitemap.xml y extraer URLs adicionales
var sitemapUrl = new Uri(new Uri(url), "/sitemap.xml").ToString();
var newUrlsFromSitemap = GetUrlsFromSitemap(sitemapUrl);
foreach (var sitemapLink in newUrlsFromSitemap)
{
if (allUrls.Add(sitemapLink))
{
_logger.LogInformation($"Adding new link from sitemap: {sitemapLink}");
}
}
}
private List<string> GetUrlsFromSitemap(string sitemapUrl)
{
var newUrls = new List<string>();
try
{
using (var webClient = new WebClient())
{
var sitemapContent = webClient.DownloadString(sitemapUrl);
var xmlDoc = new XmlDocument();
xmlDoc.LoadXml(sitemapContent);
XmlNamespaceManager nsmgr = new XmlNamespaceManager(xmlDoc.NameTable);
nsmgr.AddNamespace("sm", "http://www.sitemaps.org/schemas/sitemap/0.9");
var locNodes = xmlDoc.SelectNodes("//sm:url/sm:loc", nsmgr);
if (locNodes != null)
{
foreach (XmlNode locNode in locNodes)
{
if (Uri.IsWellFormedUriString(locNode.InnerText, UriKind.Absolute))
{
newUrls.Add(locNode.InnerText.Trim());
}
}
}
_logger.LogInformation($"Found {newUrls.Count} links in sitemap");
}
}
catch (WebException ex)
{
_logger.LogError($"Error accessing sitemap at {sitemapUrl}: {ex.Message}");
}
catch (XmlException ex)
{
_logger.LogError($"Error parsing sitemap XML from {sitemapUrl}: {ex.Message}");
}
return newUrls;
}
private string GetPrice()
{
try
{
// Intenta encontrar el precio utilizando XPath para clases o IDs que contienen 'precio' o 'price'
IWebElement priceElement = _driver.FindElement(By.XPath("//*[contains(@class, 'precio') or contains(@id, 'precio') or contains(@class, 'price') or contains(@id, 'price')]"));
// Obtén el texto del elemento
string price = priceElement.Text?.Trim();
// Verifica si se ha encontrado un precio visible
if (!string.IsNullOrEmpty(price))
{
return CleanPrice(price);
}
}
catch (NoSuchElementException)
{
// No se encontró el elemento con FindElement
}
// Intenta buscar el precio utilizando un selector CSS como fallback
var priceElements = _driver.FindElements(By.CssSelector("*[id*='price'], *[id*='precio'], *[class*='price'], *[class*='precio']"));
foreach (var element in priceElements)
{
string? price = element.Text?.Trim();
// Verifica si el texto del elemento no está vacío
if (!string.IsNullOrEmpty(price))
{
return CleanPrice(price);
}
// Intenta obtener el precio desde el atributo "content" si existe
price = element.GetAttribute("content")?.Trim();
if (!string.IsNullOrEmpty(price))
{
return CleanPrice(price);
}
}
// Si no se encontró ningún precio, devolver un mensaje indicando que no se encontró
return "Precio no encontrado";
}
private string CleanPrice(string rawPrice)
{
// Expresión regular para encontrar números y símbolos de monedas comunes (€, $, etc.)
var regex = new Regex(@"[\d.,]+(?:\s*[€$])?");
var match = regex.Match(rawPrice);
if (match.Success)
{
return match.Value.Trim();
}
return "Precio no encontrado";
}
private PropertyData GetPropertyDataFromPage(string url)
{
try
{
_driver.Navigate().GoToUrl(url);
// Capturar la URL de la imagen desde la metaetiqueta og:image
var imageUrl = _driver.FindElement(By.CssSelector("meta[property='og:image']"))?.GetAttribute("content")?.Trim();
// Buscar la descripción utilizando varias estrategias
var shortDescription = GetShortDescription();
var longDescription = GetLongDescription();
var price = GetPrice();
var characteristics = _driver.FindElements(By.CssSelector(".caracteristicas, .features, .specs"))
.Select(element => element.Text.Trim())
.ToList();
return new PropertyData
{
Url = url,
ImageUrl = imageUrl,
ShortDescription = shortDescription,
LongDescription = longDescription,
Price = price,
Characteristics = characteristics
};
}
catch (NoSuchElementException ex)
{
_logger.LogError($"Element not found on URL {url}: {ex.Message}");
}
catch (Exception ex)
{
_logger.LogError($"Error processing URL {url}: {ex.Message}");
}
return null;
}
private string GetShortDescription()
{
IWebElement metaDescription = _driver.FindElement(By.XPath("//meta[@name='description']"));
string descriptionContent = metaDescription.GetAttribute("content");
// Intentar encontrar la descripción usando diferentes métodos
var description = _driver.FindElements(By.CssSelector("meta[name='description'], meta[property='og:description'], meta[name='twitter:description']"))
.Select(meta => meta.GetAttribute("content"))
.FirstOrDefault(content => !string.IsNullOrEmpty(content))?.Trim();
return description ?? "Descripción no encontrada";
}
private string GetLongDescription()
{
// Intentar encontrar la descripción usando diferentes métodos
var description = string.Empty;
description = _driver.FindElements(By.CssSelector("section[id*='descripcion'], section[id*='description'], section[id*='desc']"))
.Select(section => section.Text)
.FirstOrDefault(text => !string.IsNullOrEmpty(text))?.Trim();
if (string.IsNullOrEmpty(description))
{
// Buscar en secciones con clases relacionadas con descripciones
description = _driver.FindElements(By.CssSelector("*[class*='descripcion'], *[class*='description'], *[class*='desc']"))
.Select(div => div.Text)
.FirstOrDefault(text => !string.IsNullOrEmpty(text))?.Trim();
}
return description ?? "Descripción no encontrada";
}
~WebScrapingService()
{
_driver.Quit();
}
}
public class PropertyData
{
public string Url { get; set; }
public string ImageUrl { get; set; } // Nueva propiedad para almacenar la URL de la imagen
public string ShortDescription { get; set; }
public string LongDescription { get; set; }
public string Price { get; set; }
public List<string> Characteristics { get; set; }
}
}
appsettings.jsonAquí defines los parámetros para conectar con Kafka.
{
"Kafka": {
"BootstrapServers": "localhost:9092",
"ScrapingTopic": "scraping-topic"
}
}
docker-compose up -d
dotnet run
curl -X POST http://localhost:5000/api/scrapping/scrape -H "Content-Type: application/json" -d "\"https://www.ejemplo.com\""
Verás en la consola del consumidor algo como:
[Kafka] URL recibida: https://www.ejemplo.com
Iniciando scraping para: https://www.ejemplo.com
Scraping completado para https://www.ejemplo.com con 1 elementos encontrados
Puedes añadir el siguiente párrafo al README.md o al post:
Todo el código fuente de InmoCopilotoScrapper está disponible en mi repositorio de GitHub. Puedes clonarlo, revisarlo y modificarlo según tus necesidades. ¡No olvides dejar una estrella ⭐ si te resulta útil!
¿Te gustaría que añadiera alguna instrucción específica sobre cómo clonar el repositorio o contribuir al proyecto? 🚀
¡Déjame un comentario! ¿Quieres que haga una guía paso a paso para desplegar esto en Kubernetes? ¿O prefieres un tutorial para implementar almacenamiento en tiempo real en Firebase?
👉 Sígueme para más contenido técnico:
¡Nos vemos en el próximo post! 🚀🚀
¿Te gustaría que agregue algún fragmento adicional o amplíe algún punto?
Comentarios anteriores (1)
You are a very clever person!