Scraping por lenguaje 8 min de lectura

Web scraping con Django: recolección de datos dentro de una aplicación web

Cómo integrar el web scraping en una aplicación Django: comandos de management, tareas de Celery y almacenamiento de resultados en modelos y el admin.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 7 enero 2025

Cuando el scraping no se necesita como script puntual, sino como parte de un servicio web completo — con interfaz, calendario de ejecución, almacenamiento en base de datos y procesamiento en segundo plano —, Django pasa a primer plano. El framework aporta el ORM para el almacenamiento, los comandos de management para el lanzamiento y, en combinación con Celery — tareas en segundo plano y periódicas.

Este artículo es la continuación práctica de la guía panorámica «Web scraping con Python». Las técnicas básicas (requests, BeautifulSoup, codificaciones) están explicadas allí; aquí el acento recae en la integración con Django.

Índice

  1. Por qué scrapear con Django
  2. Cómo obtenemos la página
  3. Bibliotecas para parsear el contenido
  4. Almacenamiento del resultado en modelos
  5. Lanzar el scraping: comandos de management y Celery
  6. Codificaciones y caracteres especiales en Django
  7. Multihilo y tareas en segundo plano
  8. Proxies
  9. Scraping a través de TOR
  10. HTTPS/SSL
  11. Trabajo con cookies
  12. Estado de la respuesta y cabeceras
  13. Almacenamiento de URL y colas con el ORM
  14. Ventajas y desventajas

1. Por qué scrapear con Django

Django se justifica cuando el scraping es solo una parte del producto:

  • un agregador de productos, ofertas de empleo o noticias con escaparate web;
  • monitorización de precios con histórico en base de datos y dashboard;
  • actualización periódica del catálogo según un calendario;
  • un panel de administración para gestionar las fuentes y revisar los resultados.

Django ofrece «de serie»: el ORM (almacenamiento y deduplicación), el admin (gestión de fuentes), el sistema de migraciones y, con Celery — la ejecución en segundo plano y periódica, para que el scraping pesado no bloquee las peticiones web.


2. Cómo obtenemos la página

El cliente HTTP en Django no se diferencia en nada del Python habitual — tomamos requests o httpx. La lógica de scraping se mantiene fuera de las views: las views deben ser rápidas, y las peticiones de red se llevan a la capa de servicios o a tareas de Celery.

python
# parser/services.py
import requests

HEADERS = {
    "User-Agent": "Mozilla/5.0 (compatible; MyParserBot/1.0)",
    "Accept-Language": "es-ES,es;q=0.9",
}

def fetch_page(url: str) -> str | None:
    try:
        resp = requests.get(url, headers=HEADERS, timeout=15)
        resp.raise_for_status()
        resp.encoding = resp.apparent_encoding
        return resp.text
    except requests.RequestException as exc:
        # registramos el error y no tumbamos la aplicación
        import logging
        logging.getLogger("parser").warning("fetch failed %s: %s", url, exc)
        return None

La regla de oro: nunca lance un scraping largo directamente en una view — el usuario quedará esperando y el worker del servidor web se bloqueará. La petición se pone en cola y la respuesta se devuelve de inmediato.


3. Bibliotecas para parsear el contenido

Dentro del servicio se usan las mismas herramientas que en cualquier otro contexto — BeautifulSoup por comodidad, lxml por velocidad:

python
from bs4 import BeautifulSoup

def parse_products(html: str) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    items = []
    for card in soup.select(".product-card"):
        items.append({
            "title": card.select_one(".title").get_text(strip=True),
            "price": card.select_one(".price").get_text(strip=True),
            "url": card.select_one("a")["href"],
        })
    return items

El análisis detallado de los parsers está en «Web scraping con Python» y en «lxml». Si la fuente son tablas, vea «Extraer tablas HTML con Python y BeautifulSoup». Si la API devuelve JSON — «Parseo de JSON».


4. Almacenamiento del resultado en modelos

La fuerza de Django es el ORM. Se describe el modelo, y la deduplicación, el filtrado y el histórico de actualizaciones se vuelven triviales.

python
# parser/models.py
from django.db import models

class Product(models.Model):
    source_url = models.URLField(unique=True)   # unicidad = protección contra duplicados
    title = models.CharField(max_length=500)
    price = models.DecimalField(max_digits=10, decimal_places=2, null=True)
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.title

Guardado con protección contra duplicados mediante update_or_create:

python
from .models import Product

def save_products(items: list[dict]):
    for item in items:
        Product.objects.update_or_create(
            source_url=item["url"],
            defaults={"title": item["title"], "price": item["price"]},
        )

update_or_create actualiza el registro existente o crea uno nuevo — ideal para un scraping repetido.


5. Lanzar el scraping: comandos de management y Celery

Comando de management — para el lanzamiento manual y por cron

python
# parser/management/commands/run_parser.py
from django.core.management.base import BaseCommand
from parser.services import fetch_page, parse_products, save_products

class Command(BaseCommand):
    help = "Lanza el scraping del catálogo"

    def add_arguments(self, parser):
        parser.add_argument("--url", required=True)

    def handle(self, *args, **options):
        html = fetch_page(options["url"])
        if html:
            items = parse_products(html)
            save_products(items)
            self.stdout.write(self.style.SUCCESS(f"Guardados {len(items)} productos"))

Ejecución: python manage.py run_parser --url https://example.com/catalog. Un comando así resulta cómodo de colgar del cron del sistema.

Celery — para tareas en segundo plano y periódicas

python
# parser/tasks.py
from celery import shared_task
from .services import fetch_page, parse_products, save_products

@shared_task(bind=True, max_retries=3, default_retry_delay=60)
def parse_url_task(self, url):
    html = fetch_page(url)
    if html is None:
        raise self.retry()       # reintento en caso de fallo
    items = parse_products(html)
    save_products(items)
    return len(items)

El lanzamiento periódico se configura con django-celery-beat directamente en el admin — por ejemplo, actualizar el catálogo cada 6 horas. El retry de Celery da resistencia a los fallos transitorios de red sin gestión manual.


6. Codificaciones y caracteres especiales en Django

El problema es el mismo que en cualquier scraper en Python: una codificación de la respuesta mal detectada, que rompe tildes y eñes. Se cura igual — resp.apparent_encoding o trabajar con resp.content (vea en detalle «Web scraping con Python», la sección sobre codificaciones).

La especificidad de Django: asegúrese de que la base de datos usa UTF-8 (en PostgreSQL — codificación UTF8; en MySQL — utf8mb4); de lo contrario, los caracteres acentuados se romperán ya en la fase de guardado, no en la de scraping. En un proyecto nuevo es el valor por defecto, pero al trabajar con una base de datos antigua conviene comprobarlo.


7. Multihilo y tareas en segundo plano

En Django rara vez se usan hilos «a pelo» — para el paralelismo está Celery con su pool de workers. Al lanzar varios workers, obtiene el procesamiento en paralelo de la cola sin gestionar hilos a mano:

bash
celery -A myproject worker --concurrency=8 --loglevel=info

Si necesita recorrer rápido una lista de URL dentro de una misma tarea, sirve ThreadPoolExecutor (vea la sección sobre multihilo del hub). Para una concurrencia muy alta dentro de la tarea se puede aplicar async — pero en Django eso exige cuidado con el ORM (sync_to_async). El tema está tratado en «Scraping asíncrono en Python».


8. Proxies

Los proxies se conectan en la capa de servicios — Django no interviene aquí. La lista de proxies resulta cómodo guardarla en un modelo y marcar los «sanos»:

python
class Proxy(models.Model):
    address = models.CharField(max_length=200)   # http://user:pass@ip:port
    is_active = models.BooleanField(default=True)
    fail_count = models.IntegerField(default=0)

El servicio toma entonces un proxy activo al azar y, ante un error, incrementa fail_count y, superado el umbral, lo desactiva. Las técnicas generales de rotación — en el hub, sección «Proxies».


9. Scraping a través de TOR

TOR se conecta como proxy SOCKS5 (socks5h://127.0.0.1:9050) en el mismo fetch_page. Django no añade ninguna especificidad — vea la sección sobre TOR del hub. En el servidor con Django, el demonio de TOR se ejecuta como proceso aparte (servicio de systemd).


10. HTTPS/SSL

La verificación de certificados funciona a nivel de requests. En producción, mantenga verify=True. Si el servidor tiene certificados raíz obsoletos, actualice certifi en el entorno virtual del proyecto. Los detalles — en el hub.


Para el scraping con autenticación use requests.Session dentro de la tarea. Si necesita reutilizar la sesión entre tareas de Celery, guarde el cookie-jar (por ejemplo, serializado en Redis o en un modelo) y restáurelo antes de la petición. Las técnicas básicas — en el hub, sección «Cookies».


12. Estado de la respuesta y cabeceras

Registre el código de respuesta y decida si conviene repetir la tarea. En Celery esto encaja con elegancia en el mecanismo de retry:

python
@shared_task(bind=True, max_retries=5)
def fetch_task(self, url):
    resp = requests.get(url, timeout=15)
    if resp.status_code == 429:
        delay = int(resp.headers.get("Retry-After", 60))
        raise self.retry(countdown=delay)
    if resp.status_code in (403, 503):
        raise self.retry(countdown=120)   # probable bloqueo, esperamos más
    resp.raise_for_status()
    return resp.text

Así, los bloqueos anti-bot (429/403/503) se convierten en reintentos controlados con espera, y no en pérdida de datos.


13. Almacenamiento de URL y colas con el ORM

En Django, el frontier se implementa cómodamente como una tabla con estados — sobrevive a los reinicios y se ve en el admin.

python
class CrawlURL(models.Model):
    STATUS = [("new", "new"), ("in_progress", "in_progress"),
              ("done", "done"), ("failed", "failed")]
    url = models.URLField(unique=True)     # unique = deduplicación automática
    status = models.CharField(max_length=20, choices=STATUS, default="new")
    attempts = models.IntegerField(default=0)
    created_at = models.DateTimeField(auto_now_add=True)

El worker toma las URL «nuevas», las marca in_progress, las procesa y las pasa a done/failed. El índice único sobre url excluye los duplicados. En los proyectos de alta carga, la cola real conviene mantenerla en Redis/el broker de Celery, y en la base de datos guardar solo el resultado y el histórico. El panorama general de enfoques de colas — en el hub, sección «Colas».


14. Ventajas y desventajas del scraping con Django

Ventajas:

  • El ORM libera de la gestión del almacenamiento, la deduplicación y las migraciones.
  • Admin de serie — gestión de fuentes y revisión de los datos.
  • Celery + beat — tareas en segundo plano y periódicas fiables, con retry.
  • Una base de código única: el scraper y el escaparate web en el mismo proyecto.

Desventajas:

  • Excesivo para scripts puntuales — un .py normal es más sencillo.
  • Exige infraestructura: broker (Redis/RabbitMQ), workers de Celery.
  • El async en Django sigue estando lleno de matices (el ORM es mayoritariamente síncrono).
  • Para recorrer millones de páginas, el especializado Scrapy es más eficiente.