---
title: "¿Qué es la paginación y cómo rastrear todas las páginas?"
description: "La paginación divide una lista larga en trozos. Vemos los cinco tipos, las condiciones de parada y cómo reanudar con SQLite un rastreo interrumpido."
url: https://proxynet.io/es/blog/pagination-web-scraping
date: 2026-09-19
author: "Acar Diveroli"
category: "Web scraping, Tutoriales"
lang: es
---

# ¿Qué es la paginación y cómo rastrear todas las páginas?

El script que escribiste para descargar un catálogo de mil productos lee sin problemas los veinte productos de la primera página. El trabajo de verdad empieza después: encontrar las direcciones de las 49 páginas restantes, saber que la lista terminó, no guardar dos veces el mismo producto y no empezar de cero cuando se corta la conexión en la página 31. La diferencia entre el código que lee una página y el que reúne la lista completa se llama paginación.

En este artículo definimos la paginación brevemente y luego la miramos desde el lado de quien rastrea: cómo se reconocen los cinco tipos en las herramientas de desarrollo, de dónde se saca la página siguiente, cuándo se detiene el rastreo, cómo se depuran los registros repetidos. En el centro hay una cola de URL sobre SQLite; un rastreo que matamos a mitad de proceso siguió, gracias a esa cola, desde la página en la que se había quedado. Ejecutamos el código en los sitios de práctica `books.toscrape.com` y `quotes.toscrape.com`.

> **Nota: Respuesta breve**
>
> La paginación consiste en que el servidor divide una lista larga en trozos y envía solo un trozo por petición. En scraping todo se reduce a tres decisiones: de dónde vas a sacar la página siguiente (un enlace «siguiente», un número de página, `offset`, `cursor` o la cabecera `Link`), cuándo vas a parar (no hay enlace, página vacía, no hay registros nuevos) y dónde vas a anotar por dónde ibas. La respuesta sólida a lo último es una tabla pequeña de SQLite que actualiza los registros y la cola en la misma transacción de base de datos.

## ¿Qué es la paginación?

Si una base de datos tiene diez mil registros, el servidor no los envía todos en una sola respuesta. La consulta se vuelve pesada y la respuesta se hincha con miles de filas que nadie va a mirar. En su lugar, la lista se parte en trozos de tamaño fijo y el cliente pide un trozo cada vez. Los enlaces «1 2 3 ... 50» al pie de una página de categoría, el parámetro `?page=2` de una API y los flujos que cargan a medida que bajas son caras distintas de la misma idea.

Quien diseña la paginación se pregunta qué método cansa menos a la base de datos. La pregunta de quien rastrea es otra: qué método eligió este sitio y cómo lo distingo desde fuera. El marco general de la recogida masiva de datos está en nuestra página de [extracción de datos](/es/data-scraping), y el diseño de los rastreadores que van de enlace en enlace, en la de [web crawler](/es/web-crawler).

## ¿Qué tipos de paginación hay y cómo se reconocen?

El camino para reconocerlos es el mismo en todos los casos: abre las herramientas de desarrollo del navegador, limpia la pestaña Red, pasa a la segunda página y mira qué cambió. ¿Cambió la barra de direcciones o salió una petición en segundo plano?

**1. URL con número de página.** La dirección cambia a `?page=2`, `/page/2/` o `page-2.html`. Es el tipo más fácil de reconocer y el bucle también es simple: incrementas el número. Su punto débil es que casi nunca sabes cuál es la última página.

**2. Enlace «siguiente».** Al pie de la página hay un elemento `<a>` que apunta a la página siguiente. Tú no generas la dirección, la lees de la página. Si no hay enlace, la lista terminó. Tu código no se rompe cuando el sitio cambia su estructura de direcciones; en listas HTML esta es la primera opción. La lógica de los selectores la explicamos en el artículo [Selector CSS y XPath](/es/blog/css-selector-vs-xpath).

**3. offset/limit.** En la petición a la API ves dos parámetros como `offset=40&limit=20`, o `skip` y `take`: «salta los primeros 40 registros y dame 20». Es el equivalente del número de página en la API.

**4. Cursor.** En la respuesta llega una cadena de aspecto absurdo como `next_cursor`, `after` o `next_page_token`, y en la petición siguiente la devuelves tal cual. El cursor lleva la información «el último registro que te di fue este»; no intentas descifrarlo, solo lo transportas.

**5. Scroll infinito y «cargar más».** La barra de direcciones no cambia. Cuando llegas al final de la página o pulsas el botón, aparece una petición Fetch/XHR nueva en la pestaña Red. Al mirar esa petición, casi siempre ves uno de los tres tipos anteriores. El scroll infinito no es un método aparte, es una interfaz montada sobre la paginación de una API.

A esto hay que sumar la marca `rel="next"`. Aparece en dos sitios. El primero es el elemento `<link rel="next" href="...">` del `<head>` del HTML. [Google escribe claramente que ya no usa esta etiqueta](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading), pero muchos sitios la siguen imprimiendo y, cuando la encuentras, es una copia limpia del enlace «siguiente». El segundo es la cabecera `Link` de la respuesta HTTP; su formato lo define [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288), y servicios como GitHub dan en esa cabecera la dirección completa de la página siguiente.

| Tipo | Cómo se reconoce | De dónde sale la página siguiente | Condición de parada | Su trampa |
|---|---|---|---|---|
| Número de página | `page=2`, `/page/2/` en la dirección | Lo incrementas tú | 404, lista vacía o contenido repetido | Lo que pasa después de la última página cambia según el sitio |
| Enlace «siguiente» | `<a>` al pie, `rel="next"` en el `<head>` | Se lee de la página | No hay enlace | Olvidar convertir la dirección relativa en absoluta |
| offset/limit | `offset`, `limit`, `skip` en la petición a la API | `offset += limit` | Trozo incompleto o vacío | Los registros se desplazan mientras la lista cambia |
| Cursor | `next_cursor`, `after`, un token en la respuesta | Se traslada tal cual desde la respuesta | Cursor vacío o ausente | El cursor caduca y no puedes empezar por la mitad |
| Scroll infinito, «cargar más» | La dirección no cambia, sale una petición XHR | La regla de la API que hay debajo | La regla de la API o que dejen de llegar tarjetas nuevas | Desplazarse con el navegador sale caro sin necesidad |

## ¿De qué pasos se compone un bucle de paginación?

Sea cual sea el tipo, el bucle sigue los mismos seis pasos:

1. **Pon la dirección inicial en la cola.** La página de categoría o la primera petición de la API.
2. **Coge la dirección siguiente y comprueba si está permitida.** Si `robots.txt` prohíbe esa ruta, la petición ni siquiera sale.
3. **Espera y luego envía la petición.** Deja un intervalo fijo entre dos peticiones al mismo sitio.
4. **Extrae los registros.** Dale a cada registro una clave que lo identifique de forma única.
5. **Encuentra la página siguiente.** Enlace, número, offset o cursor.
6. **Escribe juntos los registros, la dirección siguiente y la marca «esta página terminó».** Después vuelve al paso dos.

La palabra «juntos» del sexto paso es el tema del resto del artículo. Primero, la condición de parada.

## ¿Cuándo debe detenerse el rastreo?

Saber que la lista terminó es más difícil de lo que parece, porque los sitios se comportan de forma distinta después de la última página. En los sitios de práctica vimos tres comportamientos distintos uno al lado del otro. `books.toscrape.com` tiene 50 páginas y la petición de `page-51.html` devuelve `404`. En cambio, `quotes.toscrape.com/page/11/` devuelve con código `200` una página sin ninguna cita dentro. La API JSON del mismo sitio escribe `"has_next": false` en la última página, y si pides la página 11 recibes una lista vacía. En sitios reales es más habitual un cuarto comportamiento: volver a mostrar en silencio la primera o la última página cuando el número queda fuera de rango.

Por eso no confíes en una sola condición, usa varias a la vez:

- **Señal explícita:** no hay enlace «siguiente», `has_next` es falso, el cursor está vacío, en la cabecera `Link` no hay `rel="next"`.
- **Trozo vacío o incompleto:** la página no tiene ningún registro, o llegan menos registros que el valor de `limit`.
- **Ningún registro nuevo:** todos los registros de la página ya se habían visto. Esta es la condición que atrapa a los sitios que muestran la última página una y otra vez.
- **Límite superior:** un número de páginas que no se supera pase lo que pase. Así, un enlace «siguiente» roto o un cursor que se repite no pueden meter tu script en un bucle infinito.
- **Número total:** si la API da `total` o `total_pages`, úsalo para verificar, no para parar.

Cuando avanzas por número de página, un `404` puede significar «la lista terminó» tanto como «la estructura de direcciones cambió». Si recibes `404` ya en la primera página, es lo segundo.

## ¿Cómo se depuran los registros repetidos?

Que el mismo registro llegue dos veces durante la paginación no es un fallo, es lo esperable. La causa más frecuente es que la lista cambie mientras la rastreas. Si en una lista ordenada por «más recientes» se añaden cinco productos arriba justo cuando lees la tercera página, todos los registros bajan cinco posiciones y los cinco primeros registros de la cuarta página son los que acabas de ver. Si se borra un registro pasa lo contrario: uno sube y no llegas a verlo nunca. offset/limit y el número de página están expuestos a ese desplazamiento; el cursor no, porque dice «los que van después de este registro». Los productos patrocinados y los que aparecen en dos categorías también generan repeticiones.

La solución es depurar la repetición en la base de datos, no en el código:

- **Dale a cada registro una clave estable.** El identificador del producto, su dirección o el campo `id` de la API. Si no hay ninguno, genera un hash con los campos que no cambian del registro. El número de orden en la lista no sirve como clave.
- **Haz que la clave sea clave primaria e inserta con `INSERT OR IGNORE`.** Si la misma clave llega por segunda vez, SQLite salta la fila en silencio. Ese comportamiento está definido en la documentación de las [reglas de conflicto de SQLite](https://www.sqlite.org/lang_conflict.html). Si quieres actualizar un campo cambiante como el precio, usas `ON CONFLICT ... DO UPDATE`.
- **Fija el orden.** Si el sitio ofrece opciones de ordenación, elige un campo que no cambie (por nombre o identificador en lugar de por fecha de alta). El desplazamiento se reduce.

## Paginación de API: offset, cursor y la cabecera Link

Los bucles de los tres tipos de API se parecen mucho entre sí; la diferencia está en cómo se construye la petición siguiente. Las tres funciones de abajo producen los registros de uno en uno (`yield`), de modo que el código que las llama no necesita saber de qué tipo de paginación se trata. Los nombres de campo (`items`, `next_cursor`) cambian de una API a otra; consulta la documentación de tu objetivo.

```python
import time

import requests

def crawl_offset(session, url, limit=100, max_pages=500, delay=1.0):
    """offset/limit: se detiene cuando llega una pagina incompleta o vacia."""
    offset = 0
    for _ in range(max_pages):
        response = session.get(url, params={"offset": offset, "limit": limit}, timeout=20)
        response.raise_for_status()
        batch = response.json()["items"]
        yield from batch
        if len(batch) < limit:
            break
        offset += limit
        time.sleep(delay)

def crawl_cursor(session, url, max_pages=500, delay=1.0):
    """cursor: el cursor de la respuesta pasa tal cual a la peticion siguiente."""
    cursor, seen = None, set()
    for _ in range(max_pages):
        response = session.get(url, params={"cursor": cursor} if cursor else {}, timeout=20)
        response.raise_for_status()
        payload = response.json()
        yield from payload["items"]
        cursor = payload.get("next_cursor")
        if not cursor or cursor in seen:  # no hay cursor o se repite a si mismo
            break
        seen.add(cursor)
        time.sleep(delay)

def crawl_link_header(session, url, max_pages=500, delay=1.0):
    """Cabecera Link: la direccion rel="next" llega lista, no se calcula nada."""
    for _ in range(max_pages):
        response = session.get(url, timeout=20)
        response.raise_for_status()
        yield from response.json()
        url = response.links.get("next", {}).get("url")
        if not url:
            break
        time.sleep(delay)
```

Probamos las tres contra una API falsa local con 250 registros: cada una reunió los 250 registros en tres peticiones y sin repeticiones. Contra un extremo roto que devolvía siempre el mismo cursor, `crawl_cursor` se detuvo después de la segunda petición; sin el conjunto `seen` habría enviado 500 peticiones. La función `crawl_link_header` la ejecutamos además sobre la lista de etiquetas de un repositorio público de GitHub y obtuvimos 200 registros en dos páginas. Requests analiza la cabecera `Link` por su cuenta y la deja en el diccionario `response.links`. [La documentación de paginación de GitHub](https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api) recomienda lo mismo: no construyas la dirección a mano, sigue la dirección `rel="next"`.

## ¿Cómo se rastrean el scroll infinito y el botón «cargar más»?

El primer movimiento no es la automatización del navegador, es la pestaña Red. La página `quotes.toscrape.com/scroll` es un buen ejemplo: al bajar llegan citas nuevas y cada vez sale una petición a `/api/quotes?page=2`, `page=3`. La respuesta es JSON y tiene dentro un campo `has_next`. Llamar directamente a esa petición es más rápido que desplazarse y carga menos el sitio; las imágenes, las tipografías y los scripts no se descargan. El ejemplo de cola de más abajo reúne las citas por esta vía. El detalle de cómo encontrar la petición está en la sección «Encontrar primero la petición API/XHR» de nuestro artículo [Páginas estáticas y dinámicas](/es/blog/static-vs-dynamic-pages).

Si la petición no se puede repetir (lleva un parámetro firmado, o la respuesta llega como fragmento HTML y la procesa el script de la página), se pasa a la automatización del navegador. Allí la condición de parada es «el número de tarjetas dejó de crecer»:

```python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto("https://quotes.toscrape.com/scroll")
    page.wait_for_selector(".quote")

    count, idle = 0, 0
    while idle < 3 and count < 500:  # parar tras tres vueltas sin tarjetas nuevas o al llegar al limite
        page.mouse.wheel(0, 10000)
        page.wait_for_timeout(1000)
        current = page.locator(".quote").count()
        idle = idle + 1 if current == count else 0
        count = current

    print(count, "tarjetas cargadas")
    browser.close()
```

Este script cargó las 100 tarjetas del sitio de práctica. Con el botón «cargar más» el bucle es el mismo, solo que pulsas el botón en lugar de usar la rueda y también paras cuando el botón desaparece de la página. En Selenium el mismo trabajo se hace con la llamada `execute_script("window.scrollTo(0, document.body.scrollHeight)")` y volviendo a contar las tarjetas; las diferencias entre las dos herramientas están en el artículo [Playwright y Selenium](/es/blog/playwright-vs-selenium). El desplazamiento tiene un límite: en flujos largos la página mantiene miles de elementos en memoria y se vuelve lenta. Si necesitas más de unos cientos de tarjetas, vale la pena buscar la petición a la API.

## Cómo reanudar un rastreo interrumpido: la cola de URL en SQLite

En listas cortas basta con guardar la dirección siguiente en una variable; la función que recorre categorías en nuestro artículo [Seguimiento de precios de la competencia en e-commerce](/es/blog/competitor-price-tracking) funciona así, y para una categoría de dos páginas es la elección correcta. Cuando la lista llega a cientos de páginas, la variable ya no basta: la conexión se corta, el ordenador se suspende, el servidor se reinicia y la dirección guardada en la variable desaparece con el proceso. Tienes que escribir en disco por dónde ibas.

Para eso no hace falta un servidor de colas aparte. SQLite, que viene con Python, resuelve el asunto con dos tablas: `queue` guarda las direcciones por rastrear y su estado (`pending`, `done`, `failed`, `blocked`), e `items` guarda los registros recogidos. El truco es una sola regla: **los registros de una página, la dirección siguiente aprendida en esa página y la marca `done` de la página se escriben en la misma transacción de base de datos.** La transacción llega al disco entera o no llega. Si el proceso muere justo en ese momento, la página queda `pending` en la cola y se lee de nuevo en la siguiente ejecución. No puede existir una página que parezca «terminada» con los registros incompletos.

El script siguiente rastrea dos tipos de paginación distintos en la misma cola: el catálogo de libros siguiendo el enlace «siguiente» y las citas desde la API JSON que hay detrás del scroll infinito.

```python
import hashlib
import json
import sqlite3
import time
from urllib.parse import urljoin, urlsplit
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

DB_PATH = "crawl.db"
BOT_NAME = "ExampleCrawler"
USER_AGENT = f"{BOT_NAME}/1.0 (+https://example.com/bot)"
PROXY = None  # ejemplo: "http://user:pass@pr.proxynet.io:8000"
DELAY = 1.0  # intervalo minimo entre dos peticiones al mismo sitio (segundos)
MAX_PAGES = 200  # limite superior frente a una cadena "siguiente" rota
MAX_ATTEMPTS = 3

SEEDS = [
    ("https://books.toscrape.com/", "books"),
    ("https://quotes.toscrape.com/api/quotes?page=1", "quotes"),
]

SCHEMA = """
CREATE TABLE IF NOT EXISTS queue (
    url      TEXT PRIMARY KEY,
    kind     TEXT NOT NULL,
    status   TEXT NOT NULL DEFAULT 'pending',  -- pending | done | failed | blocked
    attempts INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS items (
    item_key TEXT PRIMARY KEY,
    kind     TEXT NOT NULL,
    data     TEXT NOT NULL,
    page_url TEXT NOT NULL
);
"""

def parse_books(response):
    """Lista HTML: los registros salen de las tarjetas y la pagina siguiente del enlace 'next'."""
    soup = BeautifulSoup(response.content, "html.parser")
    items = []
    for card in soup.select("article.product_pod"):
        link = card.select_one("h3 a")
        url = urljoin(response.url, link["href"])
        items.append((url, {"title": link["title"], "price": card.select_one(".price_color").text}))
    next_link = soup.select_one("li.next a")
    return items, urljoin(response.url, next_link["href"]) if next_link else None

def parse_quotes(response):
    """API JSON (la peticion que hay detras del scroll infinito): se para cuando has_next acaba."""
    payload = response.json()
    items = []
    for quote in payload["quotes"]:
        key = hashlib.sha1(quote["text"].encode("utf-8")).hexdigest()
        items.append((key, {"text": quote["text"], "author": quote["author"]["name"]}))
    next_url = None
    if payload["has_next"]:
        next_url = urljoin(response.url, f"?page={payload['page'] + 1}")
    return items, next_url

PARSERS = {"books": parse_books, "quotes": parse_quotes}
robots_cache = {}
last_request = {}

def allowed(session, url):
    """Lee robots.txt una vez por sitio. 4xx: sin restriccion, 5xx: no se rastrea ninguna ruta (RFC 9309)."""
    root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
    if root not in robots_cache:
        parser = RobotFileParser()
        response = session.get(root + "/robots.txt", timeout=20)
        if response.status_code >= 500:
            parser.disallow_all = True
        elif response.status_code >= 400:
            parser.allow_all = True
        else:
            parser.parse(response.text.splitlines())
        robots_cache[root] = parser
    return robots_cache[root].can_fetch(BOT_NAME, url)

def polite_get(session, url):
    """No pide al mismo sitio mas seguido que DELAY (o que el Crawl-delay de robots.txt)."""
    host = urlsplit(url).netloc
    root = "{0.scheme}://{0.netloc}".format(urlsplit(url))
    delay = max(DELAY, float(robots_cache[root].crawl_delay(BOT_NAME) or 0))
    wait = last_request.get(host, 0) + delay - time.monotonic()
    if wait > 0:
        time.sleep(wait)
    try:
        return session.get(url, timeout=20)
    finally:
        last_request[host] = time.monotonic()

def crawl():
    db = sqlite3.connect(DB_PATH)
    db.executescript(SCHEMA)
    with db:  # en la primera ejecucion anade las direcciones iniciales, despues no las toca
        db.executemany("INSERT OR IGNORE INTO queue (url, kind) VALUES (?, ?)", SEEDS)

    session = requests.Session()
    session.headers["User-Agent"] = USER_AGENT
    if PROXY:
        session.proxies = {"http": PROXY, "https": PROXY}

    for _ in range(MAX_PAGES):
        row = db.execute(
            "SELECT url, kind FROM queue WHERE status = 'pending' ORDER BY attempts, rowid LIMIT 1"
        ).fetchone()
        if row is None:
            break  # cola vacia: el rastreo termino
        url, kind = row

        if not allowed(session, url):
            with db:
                db.execute("UPDATE queue SET status = 'blocked' WHERE url = ?", (url,))
            continue

        try:
            response = polite_get(session, url)
            response.raise_for_status()
            items, next_url = PARSERS[kind](response)
        except (requests.RequestException, KeyError, ValueError) as exc:
            with db:
                db.execute(
                    "UPDATE queue SET attempts = attempts + 1,"
                    " status = CASE WHEN attempts + 1 >= ? THEN 'failed' ELSE 'pending' END"
                    " WHERE url = ?",
                    (MAX_ATTEMPTS, url),
                )
            print(f"ERROR {url}: {exc}")
            continue

        # Los registros, la direccion siguiente y la marca "pagina terminada" se escriben en una sola transaccion.
        # Si el proceso muere dentro de este bloque, no se escribe nada y la pagina queda 'pending'.
        with db:
            before = db.total_changes
            db.executemany(
                "INSERT OR IGNORE INTO items (item_key, kind, data, page_url) VALUES (?, ?, ?, ?)",
                [(key, kind, json.dumps(data, ensure_ascii=False), url) for key, data in items],
            )
            new_items = db.total_changes - before
            # Condicion de parada: pagina vacia o registros todos vistos antes
            if next_url and items and new_items:
                db.execute("INSERT OR IGNORE INTO queue (url, kind) VALUES (?, ?)", (next_url, kind))
            db.execute("UPDATE queue SET status = 'done' WHERE url = ?", (url,))
        print(f"OK    {url}: {len(items)} registros, {new_items} nuevos")

    for kind, status, count in db.execute(
        "SELECT kind, status, COUNT(*) FROM queue GROUP BY kind, status ORDER BY kind, status"
    ):
        print(f"cola     {kind:7} {status:8} {count}")
    for kind, count in db.execute("SELECT kind, COUNT(*) FROM items GROUP BY kind ORDER BY kind"):
        print(f"registro {kind:7} {count}")
    db.close()

if __name__ == "__main__":
    crawl()
```

Para ejecutar el script basta con `pip install requests beautifulsoup4`. La reanudación la probamos así: arrancamos el script y matamos el proceso directamente en el segundo 14 (no con Ctrl+C). En ese momento la base de datos tenía 20 páginas completadas, 200 libros, 100 citas y una sola dirección en estado `pending`: `page-11.html`. Volvimos a ejecutarlo sin cambiar nada; el rastreo siguió con `page-11.html` y terminó en menos de un minuto con 50 páginas de catálogo y 1.000 libros. A la API de citas no le llegó ni una petición, sus diez páginas ya estaban `done`. La tercera ejecución no encontró ninguna dirección pendiente y escribió solo el resumen.

Puntos del código a los que prestar atención:

- **El bloque `with db:` es el límite de la transacción.** En el módulo `sqlite3` de Python, cuando la conexión se usa como gestor de contexto, la transacción se confirma si el bloque termina sin error y se deshace si salta una excepción. El detalle está en [la documentación del módulo](https://docs.python.org/3/library/sqlite3.html#sqlite3-connection-context-manager).
- **La clave primaria de la cola es la propia dirección.** Si la misma dirección se descubre una segunda vez, `INSERT OR IGNORE` la salta; así se resuelven los enlaces circulares.
- **La condición «ningún registro nuevo» no choca con la reanudación.** Como los registros de la página interrumpida no llegaron a escribirse, al leerla otra vez todos son nuevos y la cadena no se rompe.
- **La página que falla pasa al final de la cola.** Gracias a `ORDER BY attempts` se toman primero las direcciones que nunca se han intentado; una página que no se puede leer en tres intentos pasa a `failed` y el rastreo sigue sin ella. El contador es deliberadamente sencillo: el código de reintento que decide en qué código de estado esperar y en cuál parar, y que procesa la cabecera `Retry-After`, ya está escrito en nuestro artículo [Códigos de estado HTTP en scraping](/es/blog/http-status-codes-web-scraping), y puedes ponerlo en lugar de `polite_get`.
- **Añadir un sitio nuevo consiste en escribir un analizador.** Escribes una función que devuelva la lista de registros y la dirección siguiente, y la añades al diccionario `PARSERS`; la cola, la espera y la lógica de reanudación no cambian.

Esta cola es para un solo proceso. Si varios trabajadores van a leer de la misma cola, el que toma una dirección tiene que llevarla a un estado intermedio como `claimed`, y ese estado debe volver a `pending` por tiempo de espera. Cuándo gana velocidad el rastreo simultáneo lo vemos en nuestro artículo [Concurrencia y paralelismo](/es/blog/concurrency-vs-parallelism). Al crecer el número de trabajadores, un marco de trabajo ya hecho da menos faena: [Scrapy](/es/blog/scrapy-proxy) lleva dentro la cola, el filtrado de repetidos y la reanudación.

## robots.txt, límite de peticiones y proxy

La paginación es el trabajo en el que más peticiones seguidas envías a un mismo sitio. La función `allowed` del script lee una vez el `robots.txt` de cada sitio y le pregunta por cada dirección. Los casos en que el archivo no se encuentra los regula [RFC 9309](https://www.rfc-editor.org/rfc/rfc9309): una respuesta `4xx` cuenta como «no hay archivo, no hay restricción», y ante una respuesta `5xx` el rastreador está obligado a considerar prohibidas todas las rutas. En los dos sitios de práctica la petición devolvió `404`. La razón de pedir el archivo con `session` es que así la petición sale con el User-Agent del script y, si lo hay, a través del proxy. `urllib.robotparser` no admite comodines (`*`, `$`); sus límites están en nuestro artículo [Qué es el archivo robots.txt y cómo se lee](/es/blog/robots-txt).

`polite_get` deja al menos `DELAY` segundos entre dos peticiones al mismo sitio y, si el sitio indica `Crawl-delay`, toma ese valor como base; la espera se lleva por sitio. Si empiezas a ver `429`, la reacción correcta no es cambiar de IP sino subir el valor de `DELAY`; los motivos están en nuestro artículo [429 Too Many Requests](/es/blog/http-429-too-many-requests). Escribir un User-Agent que presente a tu bot también forma parte del trabajo: [Qué es el User-Agent](/es/blog/what-is-user-agent).

El proxy entra en este cuadro en dos puntos. El primero es la ubicación: para ver el catálogo y los precios que se muestran a alguien que visita desde Türkiye, la petición tiene que salir desde Türkiye. El segundo es el reparto de carga: en rastreos largos repartidos por varios sitios se usan [Proxies rotativos](https://proxynet.io/es/rotating-proxy) para que el tráfico no se amontone en una sola dirección. Aquí hay una trampa. Los resultados de búsqueda y las listas filtradas suelen depender de una sesión en el servidor; si la IP cambia en mitad de la lista, el sitio puede devolverte a la primera página o darte otra vez los mismos registros. Para rastrear una lista de principio a fin con la misma dirección de salida, abre una sesión de [Proxies de sesión fija](https://proxynet.io/es/sticky-proxy) y cambia de identidad cuando la lista termine. El mecanismo está en nuestro artículo [Qué es la rotación de IP y cómo funciona](/es/blog/ip-rotation-explained). También rellenamos la línea `PROXY` y ejecutamos el script a través de un proxy de prueba local con autenticación; todo el tráfico, incluido el de `robots.txt`, pasó por el proxy y el resultado no cambió.

## Casos de uso

- **Rastreo de categorías y catálogos:** reunir las direcciones de producto de las categorías de la competencia es el primer paso del seguimiento de precios; el flujo completo está en nuestro artículo [Seguimiento de precios de la competencia en e-commerce](/es/blog/competitor-price-tracking) y la parte de infraestructura, en la página de [monitorización de precios](/es/price-monitoring).
- **Listados de marketplace:** las listas de vendedores y productos llegan a miles de páginas y ahí la cola reanudable es obligatoria. Para la configuración de ubicación y sesión, mira nuestra página de [proxy para e-commerce](/es/e-commerce-proxy).
- **Datos masivos de APIs oficiales:** el cursor y la cabecera `Link` aparecen sobre todo aquí. Cómo se transporta la sesión en APIs que exigen identificación está en nuestro artículo [Sesiones y cookies en Python](/es/blog/python-login-session-cookies).
- **Trabajos pequeños y puntuales:** no montes una cola para una tabla de cincuenta filas; las opciones sin código están en nuestro artículo [Cómo extraer datos de un sitio web](/es/blog/extract-data-from-website).

## Errores frecuentes

- **Escribir el número de la última página en el código.** El catálogo crece, 50 páginas pasan a 53 y las tres últimas se quedan fuera sin avisar. Escribe la condición de parada, no el número.
- **No poner un límite superior.** Un elemento «siguiente» que se enlaza a sí mismo o un cursor que se repite hará que tu script dé vueltas horas en la misma página.
- **Unir a mano un enlace relativo.** En el sitio de práctica el enlace «siguiente» es `catalogue/page-2.html` en la primera página y `page-3.html` en la segunda. Una dirección construida sumando cadenas se rompe en la segunda página; `urljoin` resuelve bien las dos.
- **Escribir por dónde ibas aparte de los registros.** El código que escribe primero «página terminada» y añade los registros después pierde esa página para siempre si muere en medio. Invertir el orden lleva a repeticiones. Las dos cosas tienen que ir en la misma transacción.
- **Depurar las repeticiones con un conjunto en memoria.** Cuando el proceso vuelve a arrancar, el conjunto está vacío; la unicidad es tarea de la base de datos.
- **Seguir enlaces ocultos.** El código que, buscando el enlace de paginación, mete en la cola todos los elementos `<a>` de la página también entra en enlaces trampa invisibles para las personas. Selecciona solo el elemento de paginación; el detalle está en nuestro artículo [Trampas honeypot](/es/blog/honeypot-traps).

## Guía de decisión

| Situación | Recomendación |
|---|---|
| La página tiene un enlace «siguiente» | Sigue el enlace, usa `urljoin` |
| Solo hay números de página y no se sabe cuál es la última | Incrementa el número; usa a la vez página vacía, `404` y «ningún registro nuevo» |
| En la pestaña Red se ve una petición JSON | Deja el navegador y llama directamente a la petición |
| La API da un cursor | Traslada el cursor tal cual y guarda en un conjunto los ya vistos |
| La API da una cabecera `Link` | Sigue la dirección `response.links["next"]` |
| La petición no se puede repetir, hay scroll infinito | Desplázate con Playwright o Selenium y para cuando el número de tarjetas deje de crecer |
| La lista pasa de 50 páginas o el rastreo dura minutos | Monta una cola SQLite y escribe registro y estado en la misma transacción |
| La lista cambia mientras la rastreas | Clave estable, `INSERT OR IGNORE`, orden fijo y una segunda vuelta si hace falta |
| Lista filtrada o ligada a sesión, con proxy | Sesión sticky durante toda la lista y nueva identidad al terminarla |

## Preguntas frecuentes

### ¿Qué es la paginación por cursor y en qué se diferencia de offset?

offset dice «salta tantos registros desde el principio»; el cursor dice «dame los que van después de este registro». Con offset puedes saltar a la página que quieras, pero si la lista cambia los registros se desplazan y aparecen repeticiones o huecos. Con cursor no hay saltos, solo avanzas en orden; a cambio, tu posición se mantiene fija aunque la lista cambie. La diferencia práctica: un rastreo con offset lo puedes reanudar desde la página 40, mientras que con un cursor caducado quizá tengas que empezar de cero.

### ¿Qué es el scroll infinito?

Es que JavaScript, al acercarse el final de la página, envía una petición nueva en segundo plano y añade los registros que llegan al final de la lista. Quien navega no ve números de página, pero la API que hay detrás funciona casi siempre con números de página, offset o cursor. En scraping, el objetivo es esa petición a la API.

### ¿Se puede saber de antemano cuál es la última página?

A veces. El texto «Página 1 de 50» al pie, el campo `total_pages` de la respuesta de la API o la dirección `rel="last"` de la cabecera `Link` te lo dicen. Usa esa información para mostrar el progreso y verificar el resultado. Aun así, termina el bucle con las condiciones de parada, porque el total puede cambiar durante el rastreo.

### ¿Se pueden rastrear páginas en paralelo?

En los tipos de número de página y offset sí, porque puedes generar las direcciones por adelantado. En los tipos de enlace «siguiente» y cursor cada página depende de la anterior y la cadena avanza en orden; el paralelismo solo se monta entre listas distintas (categorías). El paralelismo no elimina el límite de peticiones: sigue limitando la velocidad total de peticiones al mismo sitio.

### ¿Por qué SQLite y no un archivo CSV o JSON?

Añadir a un archivo es sencillo, pero no da tres cosas: control de unicidad, escritura de «todo o nada» y una cola consultable. SQLite ofrece las tres en un único archivo y sin instalar nada. Cuando el rastreo termina, volcar la tabla `items` a CSV son unas pocas líneas.

### ¿Qué pasa si el sitio cambia el número de registros por página durante el rastreo?

Con enlace «siguiente» y cursor no pasa nada, porque es el sitio el que dice cuál es la página siguiente. Con número de página y offset los límites se desplazan y algunos registros pueden llegar dos veces y otros no llegar nunca. La depuración por clave resuelve el primer problema; para el segundo hace falta comparar con el número total y, si es necesario, dar una segunda vuelta.

## En resumen

En el rastreo con paginación el código responde a tres preguntas: dónde está la página siguiente, cuándo terminó la lista y dónde queda anotada mi posición. Para la primera, sigue la señal que da el sitio (enlace «siguiente», cursor, cabecera `Link`) en lugar de inventar direcciones; con scroll infinito, busca primero la petición a la API que hay detrás. Para la segunda, no confíes en una sola condición y añade siempre «ningún registro nuevo» y un límite de páginas. Para la tercera, escribe los registros y el estado de la cola en la misma transacción de SQLite; muera donde muera el proceso, el rastreo sigue desde la página en la que se quedó. Mantén baja la velocidad de las peticiones y respeta las reglas de `robots.txt`. Las opciones de ubicación y reparto de carga están en nuestros [servicios de proxy](/es/proxy).
