---
title: "Cómo crear un web crawler en Python paso a paso"
description: "Un web crawler en Python encola los enlaces de una URL inicial y visita cada página una sola vez. Programamos cola, normalización, profundidad y alcance."
url: https://proxynet.io/es/blog/python-web-crawler
date: 2026-09-25
author: "Acar Diveroli"
category: "Web scraping, Tutoriales"
lang: es
---

# Cómo crear un web crawler en Python paso a paso

Necesitas la lista de todas las páginas de categoría y de producto de una librería online que no tiene sitemap. Nuestro objetivo de prueba es books.toscrape.com, un sitio de práctica creado para aprender scraping. Un crawler que sigue cada enlace sin recordar por dónde ha pasado nunca termina aquí: la mayoría de los enlaces apuntan a páginas que ya ha visto. En nuestra medición, 3.515 de los 4.098 enlaces de las primeras 60 páginas apuntaban a direcciones que el crawler ya conocía, y 524 direcciones nuevas seguían esperando en la cola.

A continuación construimos un crawler con Requests y BeautifulSoup, sin framework: cola, conjunto de URL vistas, normalización de URL, alcance, límite de profundidad, robots.txt, pausas y salida en JSON Lines. Qué es un crawler y en qué se diferencia de un scraper lo explicamos en nuestra página de [web crawler](/es/web-crawler) y en [Web scraping y web crawling](/es/blog/web-scraping-vs-web-crawling). Todas las cifras salen de ejecuciones del script final el 25 de septiembre de 2026.

> **Nota: Respuesta breve**
>
> Para crear un web crawler en Python, descarga cada página con Requests, toma los enlaces `a[href]` con BeautifulSoup, conviértelos en absolutos con `urljoin` y normalízalos. Los enlaces del mismo host entran en un `collections.deque` como pares `(url, depth)`. Marcar una dirección como vista en el momento de ponerla en la cola evita descargas dobles; un límite de profundidad y un tope de páginas hacen que el rastreo termine. Consulta robots.txt una vez por host, haz una pausa entre peticiones, detente ante un `429` y escribe cada página como una línea JSON.

## ¿Qué partes necesita un web crawler en Python?

Seis partes, cada una de unas pocas líneas de Python:

- **Frontera (frontier):** la cola de direcciones que quedan por visitar, un `collections.deque`.
- **Conjunto de URL vistas (`seen`):** todas las direcciones que el crawler ha encontrado hasta ahora.
- **Descargador (fetcher):** una `requests.Session` con timeout y un `User-Agent` honesto que da el nombre del bot y un contacto ([¿Qué es el User-Agent?](/es/blog/what-is-user-agent)).
- **Extractor de enlaces:** `select("a[href]")`. Leer el resto de la página es trabajo de un scraper ([Qué es BeautifulSoup](/es/blog/beautifulsoup-tutorial)).
- **Filtro de alcance:** el host, el esquema y el tipo de contenido deciden qué cuenta.
- **Salida:** un objeto JSON por página en un archivo `.jsonl`.

Usamos Requests 2.34.2 y Beautiful Soup 4.15.0, las versiones vigentes en PyPI el 25 de septiembre de 2026; Requests necesita Python 3.10 o posterior. Scrapy trae las seis partes ya hechas.

## ¿Cómo funciona el bucle de rastreo paso a paso?

1. **Semilla.** Normaliza la dirección inicial, pon `(url, 0)` en la cola y la dirección en el conjunto `seen`.
2. **Tomar.** Saca con `popleft()` la entrada más antigua.
3. **Consultar robots.txt.** Lee las reglas del host desde una caché; se descargan una sola vez.
4. **Esperar y luego descargar.** Controla el intervalo por host y después envía una petición GET.
5. **Revisar la respuesta.** Lee la dirección final tras las redirecciones y la cabecera `Content-Type`.
6. **Extraer y filtrar.** Resuelve cada `href` con `urljoin`, normalízalo y descarta lo ya visto, lo que está fuera del sitio y lo que queda demasiado profundo.
7. **Registrar y encolar.** Escribe una línea JSON y añade las direcciones nuevas con `depth + 1`.

El bucle termina cuando la cola se vacía o se alcanza el tope de páginas.

## ¿Qué parte evita qué problema?

| Parte | Qué evita | En Python | Sin ella |
|---|---|---|---|
| Conjunto `seen`, rellenado al encolar | Descargar la misma página dos veces | `set`, se añade al encolar | En nuestra prueba con books, 3.515 enlaces conocidos se descargarían otra vez |
| Límite de profundidad y tope de páginas | Cadenas sin fin, como calendarios y filtros | `(url, depth)`, `--depth`, `--max-pages` | Una cola que no deja de crecer (524 en espera tras 60 páginas) |
| Normalización de URL | Una misma página escrita de varias formas | `urldefrag`, `urlsplit`, `parse_qsl` | La misma página se descarga y se guarda más de una vez |
| Comprobación de redirección y tipo de contenido | Irse a otro sitio, analizar un PDF como HTML | `r.url`, `Content-Type`, `stream=True` | Los enlaces de páginas ajenas entran en la cola |
| Caché de robots.txt y pausa por host | Rutas prohibidas, ráfagas de peticiones | `RobotFileParser.parse()`, `time.monotonic()` | El sitio responde `429` |

## ¿Por qué la cola debe ser un deque y no una lista?

`deque.popleft()` saca la dirección más antigua, así que el rastreo es en anchura (BFS): primero la página inicial, luego las páginas que están a un clic, luego las que están a dos. Las páginas cercanas a la portada llegan antes, y un límite de profundidad tiene sentido porque la profundidad crece en orden. Con `pop()` desde el mismo extremo en el que añades, el rastreo es en profundidad (DFS): una rama hasta el fondo antes de pasar a la siguiente.

Una lista puede hacer de cola, pero `pop(0)` desplaza todos los elementos restantes (O(n)), mientras que un deque saca elementos por ambos extremos en un tiempo aproximadamente constante ([documentación de collections](https://docs.python.org/3/library/collections.html)). Los crawlers recursivos son DFS disfrazados, y pueden detenerse en el límite por defecto de Python de 1.000 llamadas anidadas con un `RecursionError`.

Además, BFS encuentra cada dirección por primera vez en su menor profundidad, así que un enlace que es demasiado profundo la primera vez que aparece puede marcarse como visto y olvidarse.

## ¿Cómo se escribe la normalización de URL en código?

El conjunto `seen` compara cadenas, así que `normalize()` da a cada página una sola forma escrita:

- `urldefrag()` elimina `#reviews`. Según la sección 3.5 de [RFC 3986](https://www.rfc-editor.org/rfc/rfc3986.html), el fragmento nunca llega al servidor.
- El esquema y el host pasan a minúsculas; `urlsplit().hostname` ya lo está.
- Se eliminan los puertos por defecto (`:80` para http, `:443` para https), y una ruta vacía pasa a ser `/`, algo que la sección 6.2.3 del mismo RFC considera equivalente.
- Se quitan los parámetros de seguimiento (`utm_*`, `gclid`, `fbclid`) y el resto se ordena, de modo que `?b=2&a=1` y `?a=1&b=2` coinciden.
- Los enlaces `mailto:`, `tel:` y `javascript:` y los puertos rotos devuelven `None`.

No borres nunca la cadena de consulta entera: `?page=2` es otra página. Las barras finales también se quedan, porque `/a` y `/a/` pueden ser distintas; una redirección te indica cuándo no lo son. En books.toscrape.com, `/` y `/index.html` servían la misma página, algo que solo una regla propia de ese sitio podría unificar.

El conjunto `seen` además ignora el esquema. En quotes.toscrape.com, las páginas de autor redirigen de https a http, y sus enlaces relativos apuntan entonces a `http://quotes.toscrape.com/`, así que nuestra primera prueba descargó dos veces la portada y la página de login. `page_key()` elimina el esquema, de modo que las dos formas cuentan como una sola página.

Los sitios de práctica no tienen fragmentos ni parámetros de seguimiento, así que estas comprobaciones usan entradas inventadas:

```python
from crawler import normalize

assert normalize("https://Example.COM:443/a#reviews") == "https://example.com/a"
assert normalize("http://example.com") == "http://example.com/"
assert normalize("https://example.com:8443/a") == "https://example.com:8443/a"
assert normalize("https://example.com/list?b=2&a=1") == "https://example.com/list?a=1&b=2"
assert normalize("https://example.com/list?utm_source=mail&page=2") == "https://example.com/list?page=2"
assert normalize("mailto:shop@example.com") is None
assert normalize("javascript:void(0)") is None
assert normalize("https://example.com:abc/") is None
assert normalize("https://example.com/a/") != normalize("https://example.com/a")
print("all normalize checks passed")
```

## Alcance: mismo host, redirecciones y tipo de contenido

`self.hosts` contiene solo el host inicial. Si un sitio usa a la vez `www.example.com` y `example.com`, añade los dos a mano; una comprobación como `endswith("example.com")` dejaría pasar también `notexample.com`.

Requests sigue las redirecciones en todos los métodos salvo HEAD y guarda la dirección final en `r.url` ([guía rápida de Requests](https://requests.readthedocs.io/en/latest/user/quickstart/)), así que comprueba el alcance sobre `r.url` y resuelve los enlaces relativos contra esa dirección. En quotes.toscrape.com, `/author/Jane-Austen` redirige a `http://quotes.toscrape.com/author/Jane-Austen/`; en nuestra ejecución con profundidad 2, 40 de las 149 páginas llegaron así. Una redirección fuera del sitio se descarta, pero Requests ya ha descargado el destino. Para evitar incluso esa petición, pasa `allow_redirects=False` y pon tú mismo en la cola la dirección de la cabecera `Location`.

Con `stream=True`, Requests devuelve el control en cuanto llegan las cabeceras. Si `Content-Type` no es `text/html`, la conexión se cierra sin leer el cuerpo.

## ¿Cómo se fija un límite de profundidad y un tope de páginas?

Las entradas de la cola son `(url, depth, attempts)`. Un enlace cuyo `depth + 1` superaría `--depth` cuenta como `too_deep` y se queda fuera; `--max-pages` termina la ejecución tenga lo que tenga la cola. En los sitios de práctica:

- **quotes.toscrape.com, profundidad 2, tope 500:** la cola se vació sola tras 149 páginas (1, 46 y 102 por profundidad); 30 direcciones eran demasiado profundas.
- **El mismo sitio, tope 60:** la ejecución se detuvo en 60 páginas con 89 todavía en la cola.
- **books.toscrape.com, profundidad 2, tope 60:** las 60 páginas vinieron de las profundidades 0 y 1, con 524 en espera. Aquí la ejecución la terminó el tope, no la profundidad.

Elige la profundidad según la estructura del sitio (portada, categoría y artículo son profundidad 2; cada página de listado adicional suma uno) y el tope según tu presupuesto. Los enlaces colocados para atrapar bots los tratamos en [Trampas honeypot](/es/blog/honeypot-traps).

## robots.txt y pausas: la versión corta

El crawler guarda un `RobotFileParser` por esquema y host, descarga robots.txt con su propia sesión (timeout, `User-Agent` del bot, proxy) y pasa las líneas a `parse()`. Evitamos `read()`: en Python 3.13.9 usa `urllib` sin timeout y con el `User-Agent` propio de urllib, y lanza una excepción ante errores de red. Un `5xx` o un archivo inaccesible significa no rastrear nada, como exige [RFC 9309](https://www.rfc-editor.org/rfc/rfc9309.html); un `4xx` significa que no hay reglas (los dos sitios de práctica devolvieron `404`). La sintaxis está en [Qué es robots.txt](/es/blog/robots-txt).

Antes de cada petición, el crawler se asegura de que haya pasado `--delay` (1 segundo por defecto) o el `Crawl-delay` del sitio, el que sea mayor, desde su última petición a ese host. Con `Crawl-delay: 2` en un sitio de prueba local y `--delay 0.2`, el servidor registró intervalos de 2 segundos. Ante un `429`, el crawler detiene ese host e imprime `Retry-After`. Cómo reintentar bien lo explicamos en [Códigos de estado HTTP en web scraping](/es/blog/http-status-codes-web-scraping), y estas mismas comprobaciones con una cola en SQLite, en [Paginación en web scraping](/es/blog/pagination-web-scraping).

## Código completo: un web crawler en Python sin framework

Instala las dos bibliotecas en un entorno virtual, guarda el script como `crawler.py` y pásale una dirección inicial:

```bash
pip install requests==2.34.2 beautifulsoup4==4.15.0
python crawler.py https://books.toscrape.com/ --depth 2 --max-pages 60
```

```python
"""Crawler en anchura para un solo sitio: Requests + BeautifulSoup, sin framework."""
import argparse
import json
import time
from collections import Counter, deque
from urllib.parse import parse_qsl, urldefrag, urlencode, urljoin, urlsplit, urlunsplit
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

BOT_NAME = "ExampleSiteMapper"
USER_AGENT = f"{BOT_NAME}/1.0 (+https://example.com/bot; bot@example.com)"
TIMEOUT = 15      # segundos; sin timeout, Requests puede esperar para siempre
MAX_RETRIES = 2   # una URL vuelve al final de la cola como máximo dos veces
TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "gclid", "fbclid"}
DEFAULT_PORTS = {"http": 80, "https": 443}

def normalize(url):
    """Una sola forma escrita por página, o None para los enlaces que un crawler no debe seguir."""
    url, _fragment = urldefrag(url.strip())
    parts = urlsplit(url)
    scheme = parts.scheme.lower()
    try:
        port = parts.port
    except ValueError:  # un puerto roto, como ":abc"
        return None
    if scheme not in DEFAULT_PORTS or not parts.hostname:
        return None  # mailto:, tel:, javascript:, ftp:, ...
    host = parts.hostname  # ya viene en minúsculas
    if port and port != DEFAULT_PORTS[scheme]:
        host = f"{host}:{port}"
    query = sorted((k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
                   if k not in TRACKING)
    return urlunsplit((scheme, host, parts.path or "/", urlencode(query), ""))

def page_key(url):
    """La clave del conjunto seen: http:// y https:// de una dirección cuentan como una sola página."""
    return url.split("://", 1)[1]

class Crawler:
    def __init__(self, start, max_depth, max_pages, delay, out, proxy=None):
        self.start = normalize(start)
        self.hosts = {urlsplit(self.start).hostname}  # añade aquí, a propósito, un gemelo "www."
        self.max_depth, self.max_pages, self.delay, self.out = max_depth, max_pages, delay, out
        self.queue = deque([(self.start, 0, 0)])  # (url, depth, attempts)
        self.seen = {page_key(self.start)}  # se marca cuando una URL aparece por primera vez, no al descargarla
        self.robots, self.last, self.stopped = {}, {}, {}
        self.stats, self.depths = Counter(), Counter()
        self.session = requests.Session()
        self.session.headers["User-Agent"] = USER_AGENT
        # por petición: session.proxies perdería frente a las variables de entorno HTTP(S)_PROXY
        self.proxies = {"http": proxy, "https": proxy} if proxy else None

    def in_scope(self, url):
        return urlsplit(url).hostname in self.hosts

    def fetch(self, url):
        """GET con pausa por host. stream=True lee las cabeceras antes que el cuerpo."""
        parts = urlsplit(url)
        rules = self.robots.get(f"{parts.scheme}://{parts.netloc}")
        gap = max(self.delay, (rules.crawl_delay(BOT_NAME) if rules else None) or 0)
        pause = self.last.get(parts.netloc, float("-inf")) + gap - time.monotonic()
        if pause > 0:
            time.sleep(pause)
        try:
            return self.session.get(url, timeout=TIMEOUT, stream=True, proxies=self.proxies)
        finally:
            self.last[parts.netloc] = time.monotonic()  # por host: http y https lo comparten

    def robots_ok(self, url):
        root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
        if root not in self.robots:
            rules = RobotFileParser()
            try:
                with self.fetch(root + "/robots.txt") as r:
                    status = r.status_code
                    rules.parse(r.text.splitlines() if status == 200 else [])
            except requests.RequestException:
                status = None
                rules.parse([])
            if status is None or status >= 500:
                rules.disallow_all = True  # robots.txt inaccesible: no rastrear nada en este host
            self.robots[root] = rules
        return self.robots[root].can_fetch(BOT_NAME, url)

    def extract_links(self, html, base):
        soup = BeautifulSoup(html, "html.parser")
        title = " ".join(soup.title.get_text().split()) if soup.title else ""
        return title, [urljoin(base, a["href"]) for a in soup.select("a[href]")]

    def enqueue(self, links, depth):
        for link in links:
            self.stats["raw_links"] += 1
            url = normalize(link)
            if url is None:
                self.stats["not_http"] += 1
                continue
            if page_key(url) in self.seen:
                self.stats["duplicate"] += 1
                continue
            self.seen.add(page_key(url))  # BFS encuentra cada URL primero en su menor profundidad
            if not self.in_scope(url):
                self.stats["offsite"] += 1
            elif depth + 1 > self.max_depth:
                self.stats["too_deep"] += 1
            else:
                self.queue.append((url, depth + 1, 0))
                self.stats["queued"] += 1

    def retry(self, url, depth, attempts, why):
        if attempts < MAX_RETRIES:
            self.queue.append((url, depth, attempts + 1))
            self.stats["retried"] += 1
        else:
            self.stats["failed"] += 1
            print(f"giving up on {url}: {why}")

    def run(self):
        started = time.monotonic()
        with open(self.out, "w", encoding="utf-8", newline="\n") as out:  # JSON Lines: \n, sin BOM
            while self.queue and self.stats["fetched"] < self.max_pages:
                url, depth, attempts = self.queue.popleft()
                host = urlsplit(url).netloc
                if host in self.stopped:
                    self.stats["skipped_stopped_host"] += 1
                    continue
                if not self.robots_ok(url):
                    self.stats["robots_disallowed"] += 1
                    continue
                try:
                    r = self.fetch(url)
                except requests.RequestException as exc:
                    self.retry(url, depth, attempts, type(exc).__name__)
                    continue
                with r:
                    if r.status_code == 429:
                        self.stopped[host] = r.headers.get("Retry-After", "not sent")
                        print(f"429 from {host}, host stopped (Retry-After: {self.stopped[host]})")
                        continue
                    if r.status_code >= 500:
                        self.retry(url, depth, attempts, f"HTTP {r.status_code}")
                        continue
                    final = normalize(r.url)
                    moved = page_key(final) != page_key(url)  # no solo http -> https
                    if not self.in_scope(final) or (moved and page_key(final) in self.seen):
                        self.stats["redirect_skipped"] += 1  # salió del sitio, o es una página conocida
                        continue
                    is_html = "text/html" in r.headers.get("Content-Type", "")
                    try:  # el cuerpo se descarga aquí, y solo si es HTML
                        html = r.content if r.status_code == 200 and is_html else b""
                    except requests.RequestException as exc:  # la conexión se cortó a mitad del cuerpo
                        self.retry(url, depth, attempts, type(exc).__name__)
                        continue
                    if final != url:
                        self.stats["redirected"] += 1
                        self.seen.add(page_key(final))
                    self.stats["fetched"] += 1
                    self.depths[depth] += 1
                    title, links = "", []
                    if html:
                        self.stats["html_bytes"] += len(html)  # para estimar el tráfico
                        title, links = self.extract_links(html, r.url)
                    elif not is_html:
                        self.stats["not_html"] += 1  # el cuerpo nunca se descargó
                    record = {"url": url, "final_url": final, "depth": depth,
                              "status": r.status_code, "title": title, "links_found": len(links)}
                    out.write(json.dumps(record, ensure_ascii=False) + "\n")
                    print(f"{r.status_code} d{depth} {url}")
                    self.enqueue(links, depth)
        print(f"\n{self.stats['fetched']} pages in {time.monotonic() - started:.1f} s,"
              f" by depth {dict(sorted(self.depths.items()))}, left in queue {len(self.queue)}")
        for key, value in sorted(self.stats.items()):
            print(f"  {key:<21}{value}")

if __name__ == "__main__":
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("start", help="start URL, e.g. https://books.toscrape.com/")
    ap.add_argument("--depth", type=int, default=2, help="link hops from the start URL")
    ap.add_argument("--max-pages", type=int, default=100, help="hard ceiling on fetched pages")
    ap.add_argument("--delay", type=float, default=1.0, help="minimum seconds between requests to one host")
    ap.add_argument("--out", default="pages.jsonl")
    ap.add_argument("--proxy", help="e.g. http://user:pass@pr.proxynet.io:8000")
    args = ap.parse_args()
    Crawler(args.start, args.depth, args.max_pages, args.delay, args.out, args.proxy).run()
```

Hay tres detalles que importan cuando cambies el código:

- **Los reintentos van al final de la cola,** como máximo dos veces, tras un error de conexión o un `5xx`. Una página de prueba local que respondió `500` dos veces devolvió `200` al tercer intento. Los reintentos dentro de Requests están en [Max Retries Exceeded With URL](/es/blog/max-retries-exceeded-with-url).
- **El archivo es JSON Lines sin más.** `encoding="utf-8"` no escribe marca de orden de bytes (BOM) y `newline="\n"` mantiene los finales de línea `\n` que pide [JSON Lines](https://jsonlines.org/); si no, Windows escribe `\r\n`. `ensure_ascii=False` mantiene legible el texto que no es ASCII. Un título con letras turcas hizo el viaje de ida y vuelta intacto ([Codificación en Python](/es/blog/python-unicode-encoding-errors)).
- **Solo GET.** El crawler abre `/login` como cualquier otra página y nunca envía un formulario.

### Lo que vimos al ejecutarlo

Usamos Python 3.13.9, la pausa por defecto de un segundo y profundidad 2. En quotes.toscrape.com, con un tope de 500:

```text
149 pages in 179.1 s, by depth {0: 1, 1: 46, 2: 102}, left in queue 0
  duplicate            2916
  fetched              149
  html_bytes           722227
  offsite              2
  queued               148
  raw_links            3096
  redirected           40
  too_deep             30
```

En books.toscrape.com, con un tope de 60:

```text
60 pages in 69.8 s, by depth {0: 1, 1: 59}, left in queue 524
  duplicate            3515
  fetched              60
  html_bytes           2047561
  queued               583
  raw_links            4098
```

En quotes, el 94 % de los enlaces apuntaba a direcciones conocidas, y solo 2 direcciones distintas quedaban fuera del sitio. Dos líneas de la salida, una normal y otra redirigida:

```json
{"url": "https://books.toscrape.com/", "final_url": "https://books.toscrape.com/", "depth": 0, "status": 200, "title": "All products | Books to Scrape - Sandbox", "links_found": 94}
{"url": "https://quotes.toscrape.com/author/Jane-Austen", "final_url": "http://quotes.toscrape.com/author/Jane-Austen/", "depth": 1, "status": 200, "title": "Quotes to Scrape", "links_found": 4}
```

Un pequeño sitio local cubrió las ramas de error. El crawler se saltó una ruta prohibida, tres enlaces que no eran web y una redirección a otro host, cerró un PDF sin leerlo y registró un `404`. Un `429` con `Retry-After: 120` detuvo el host, y un `503` en robots.txt hizo que no se descargara nada.

## ¿Dónde va el proxy en el crawler?

`--proxy http://user:pass@pr.proxynet.io:8000` se pasa con cada petición, robots.txt incluido. El código no usa `session.proxies`, porque la documentación de Requests advierte que las variables de entorno de proxy tienen prioridad sobre ese ajuste ([uso avanzado](https://requests.readthedocs.io/en/latest/user/advanced/#proxies)). A través de un proxy de prueba local con contraseña, las 10 primeras páginas de books dieron las mismas filas que sin él, incluso con un proxy caído en `HTTPS_PROXY`.

Un solo sitio a una petición por segundo rara vez necesita proxy. Ayuda cuando un rastreo abarca muchos hosts y el tráfico no debería salir todo desde una misma dirección: los [Proxies rotativos](https://proxynet.io/es/rotating-proxy) dan a cada petición o a cada host una IP de salida distinta. Para una lista corta y fija de destinos, los [Proxies de centro de datos](https://proxynet.io/es/datacenter-proxy) suelen bastar; sus IP vienen por defecto con restricción de sitio de destino, y el acceso a todos los sitios web es una opción que eliges al hacer el pedido. El código de rotación está en [Cómo rotar proxies en Python](/es/blog/how-to-rotate-proxies-in-python).

El tráfico residencial rotativo se factura por GB, así que mide primero `html_bytes` en una ejecución corta. Los dos sitios de práctica enviaron HTML sin comprimir, unos 34 KB por página de books y 5 KB por página de quotes: 10.000 páginas suman aproximadamente 0,34 GB o 0,05 GB, más las cabeceras. La pausa y robots.txt se aplican a cada IP de salida, y la rotación no es motivo para enviar a un sitio más peticiones de las que permite.

## Casos de uso

- **Enlaces internos rotos:** filtra la salida por el estado `404` ([web crawler](/es/web-crawler)).
- **Una lista de URL antes del scraping:** primero rastrea y luego extrae datos solo de las páginas de producto o de artículo ([extracción de datos](/es/data-scraping)).
- **Productos nuevos de la competencia:** compara la lista de URL de esta semana con la de la semana pasada ([Seguimiento de precios de la competencia](/es/blog/competitor-price-tracking)).
- **Huecos en el sitemap:** páginas que el crawler encuentra pero que faltan en el sitemap ([Cómo encontrar el sitemap de una web](/es/blog/find-website-sitemap)).
- **Imágenes de páginas conocidas:** primero lista las páginas y después descarga ([Cómo descargar todas las imágenes de una web](/es/blog/download-all-images-from-website)).
- **Campos de las páginas encontradas:** títulos y precios del mismo HTML ([Qué es BeautifulSoup](/es/blog/beautifulsoup-tutorial)).

## Errores comunes

- **`list.pop(0)` como cola.** Cada llamada desplaza toda la lista.
- **Recursión para cada enlace.** El rastreo pasa a ser en profundidad y puede acabar en `RecursionError`.
- **Marcar las URL como vistas solo después de descargarlas.** La misma dirección entra en la cola muchas veces.
- **Borrar la cadena de consulta entera.** Desaparecen `?page=2` y todas las páginas siguientes.
- **Resolver los enlaces contra la URL pedida.** Tras una redirección, la base es `r.url`.
- **Sin timeout.** Sin él, Requests puede quedarse colgado indefinidamente.
- **Analizar archivos PDF y ZIP como HTML.** Comprueba antes el `Content-Type`.
- **Sin tope de páginas.** En un sitio con filtros o un calendario, la ejecución no termina nunca.
- **Rellenar formularios.** Un crawler lee páginas con GET; enviar datos es otro trabajo ([POST con JSON en Python Requests](/es/blog/python-requests-post-json)).

## Guía de decisión

| Necesidad | Recomendación |
|---|---|
| Unos cientos de páginas de un sitio, viendo el mecanismo | El script de este artículo |
| Reanudar un rastreo largo después de que se detenga | Una cola en disco: la versión con SQLite de [Paginación en web scraping](/es/blog/pagination-web-scraping) |
| Muchas peticiones al mismo tiempo | Concurrencia, dimensionada como en [Concurrencia y paralelismo](/es/blog/concurrency-vs-parallelism) |
| Miles de páginas, con reintentos y exportaciones integrados | Scrapy ([CrawlSpider](/es/blog/web-scraping-vs-web-crawling), [configuración de proxy](/es/blog/scrapy-proxy)) |
| Enlaces que solo aparecen después de ejecutar JavaScript | Un crawler basado en navegador, como PlaywrightCrawler de Crawlee para Python, o [Crawl4AI](/es/blog/crawl4ai-proxy) |
| El sitio publica un sitemap | Léelo primero y rastrea solo lo que le falte ([Cómo encontrar el sitemap de una web](/es/blog/find-website-sitemap)) |
| Un rastreo repartido entre muchos hosts | [Proxies rotativos](https://proxynet.io/es/rotating-proxy) al mismo ritmo respetuoso por host |

## Preguntas frecuentes

### ¿Qué biblioteca de Python debo usar para un web crawler?

Para unos cientos de páginas bastan Requests para descargar y BeautifulSoup para leer los enlaces. Para miles de páginas con reintentos, exportaciones y programación de tareas, Scrapy te ahorra ese código. Los enlaces que genera JavaScript necesitan una herramienta basada en navegador.

### ¿Puedo crear un crawler con BeautifulSoup o necesito Scrapy?

Puedes. BeautifulSoup es un parser: lee los enlaces del HTML, pero no descarga páginas, no mantiene una cola ni espera entre peticiones. El script de arriba escribe esas partes en menos de 200 líneas. Scrapy las trae integradas, algo que compensa en trabajos más grandes.

### ¿Un crawler en Python ve los enlaces cargados con JavaScript?

No. Requests no ejecuta scripts, así que los enlaces que añade JavaScript nunca llegan a BeautifulSoup. Busca primero una petición JSON detrás de la página a la que puedas llamar directamente. Si no la hay, usa un crawler basado en navegador donde las condiciones del sitio permitan la automatización.

### ¿Debería hacer el crawler multihilo?

Solo cuando rastreas varios hosts. En un solo sitio, la pausa entre peticiones marca la velocidad, y los hilos adicionales solo esperarían o romperían el intervalo. Con muchos hosts, ayuda tener un worker por host que respete las pausas ([Concurrencia y paralelismo](/es/blog/concurrency-vs-parallelism)).

### ¿Cómo encuentro todos los enlaces de un sitio web con Python?

Empieza por el [sitemap](/es/blog/find-website-sitemap), que suele indicarse en robots.txt o estar en `/sitemap.xml`. Si no lo hay, ejecuta un crawler como este con un límite de profundidad y un tope de páginas, y toma el resultado como lo que alcanzan los enlaces, no como una lista completa.

### ¿Es legal escribir y ejecutar un web crawler?

Esto no es asesoramiento jurídico. La respuesta depende de tu país, de las condiciones del sitio, de si las páginas contienen datos personales y de lo que hagas con las copias. Respeta robots.txt, mantén un ritmo bajo y no entres en zonas que requieren inicio de sesión. País por país: [¿El web scraping es legal?](/es/blog/is-data-web-scraping-legal).

## En resumen

Un crawler que termina sin repetirse necesita un deque para el orden en anchura, un conjunto `seen` que se rellena al encolar, una sola forma escrita por URL, una comprobación de alcance tras las redirecciones y un límite de profundidad con un tope de páginas. robots.txt, una pausa por host y la parada ante un `429` forman parte ya de la primera versión. Cuando el trabajo crezca, pasa a una cola en disco, a la concurrencia entre hosts o a Scrapy. Para rastreos que abarcan muchos sitios o países, compara las opciones en nuestra página de [servicios de proxy](/es/proxy).
