La Raya · backend (3): que se llene solo — el sistema de scraping

Publicado el 12/06/2026

Una guía local sin contenido fresco está muerta. Como conté en posts anteriores, esa fue la razón de replantear La Raya como agregador. Este post va del motor que la llena: el sistema de scraping.

Estrategias por tipo de fuente

Las fuentes de la Raya son un zoo: webs municipales, agendas culturales, RSS de medios, calendarios .ics de plugins de WordPress, cines con su propia web… Intentar un scraper "a medida" por cada una sería inmantenible. La clave fue separar estrategia (cómo se extrae) de fuente (de dónde):

  • FeedScraper para RSS/Atom (vía feedparser): la mayoría de medios y muchos ayuntamientos.
  • ICalScraper para calendarios .ics: sorprendentemente común en agendas con plugins de eventos.
  • Scrapers HTML para los sitios que no dan feed.
  • WP API cuando el WordPress expone su API.

Cada fuente concreta es una subclase pequeña que solo dice "mi URL es esta y mi localidad es esta". La estrategia hace el trabajo pesado y devuelve un ScrapedItem normalizado (título, fecha, descripción, URL, imagen, localidad…). Un decorador @register mete cada fuente en un registro central, y un sistema de fases (1 a 5) ordena qué se scrapea y cuándo: las fuentes rápidas (feeds) en una fase, las lentas (HTML) en otra.

Normalizar, clasificar y no duplicar

Extraer es la parte fácil. Lo difícil es convertir ese caos en datos limpios:

  • Recintos (VenuePage): un ítem puede traer un venue_slug/venue_info, y el sistema crea o reutiliza el recinto. Así "Teatro López de Ayala" no se repite en cada evento.
  • Clasificador: cada evento necesita una categoría (concierto, teatro, feria, deportes…). Lo resolví con un clasificador de palabras clave ponderadas, con la opción de que la fuente dé una pista (hint) que tiene prioridad, y un desempate. No es IA; es un diccionario bien pensado, y para esto funciona mejor que cualquier cosa más lista.
  • Deduplicación: lo mismo aparece en varias fuentes. Hay dos capas: un modelo de enlace (source_id + external_id) para no recrear lo que ya scrapeaste, y una deduplicación por URL normalizada (quitando parámetros de tracking) para detectar el mismo artículo llegado por dos vías. Una dedupe_task limpia duplicados antes de traducir.

Ejecutar a escala: Celery y un monitor que avisa

Scrapear docenas de fuentes a mano no escala, así que todo corre en Celery: una tarea por fuente, un comando scrape con --async que las despacha, y un planificador (Celery Beat) que ejecuta cada fase en su horario.

Lo que de verdad cambió mi tranquilidad fue el monitor: cada ejecución se registra (encontrados/creados/actualizados/omitidos/errores), y si una fuente falla varias veces seguidas se desactiva automáticamente y me llega un aviso (email/Telegram), además de un digest diario. Cuando dependes de webs ajenas, no es si una fuente se romperá, es cuándo; mejor enterarte por una alerta que por un usuario.

Lo que aprendí peleándome con datos sucios

  • Sepa estrategia de fuente. El día que añadir un municipio nuevo es "crear una subclase de cinco líneas", has ganado. El día que es "escribir otro scraper entero", has perdido.
  • El dato sucio es la norma, no la excepción. Fechas en mil formatos, títulos en mayúsculas, slugs que colisionan entre workers concurrentes, URLs con tracking. Cada uno de esos fue un fix real (incluido un bonito error de colisión de slugs al scrapear en paralelo).
  • Un scraper sin monitor es una bomba de relojería. Si no te avisa cuando se rompe, te enteras semanas después con la guía a medias.
  • La deduplicación nunca está "terminada". Es una capa que ajustas para siempre según aparecen casos nuevos (spoiler: vuelve, y con ganas, en el capítulo de gastronomía).

Con el motor llenando la guía, apareció el siguiente reto, muy propio de una zona fronteriza: el contenido llega en español y en portugués, y los usuarios pueden quererlo en cualquiera de los dos (o en inglés). De eso va la próxima entrada: i18n y traducción automática.