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

Publicado:

23 min de lectura

Acar Diveroli
Autor: Acar Diveroli
La rejilla se hunde en un embudo profundo: encima, el cubo azul de la cola; a la izquierda, la escala de la página final

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.

¿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, y el diseño de los rastreadores que van de enlace en enlace, en la de 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.

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, 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, y servicios como GitHub dan en esa cabecera la dirección completa de la página siguiente.

TipoCómo se reconoceDe dónde sale la página siguienteCondición de paradaSu trampa
Número de páginapage=2, /page/2/ en la direcciónLo incrementas tú404, lista vacía o contenido repetidoLo 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áginaNo hay enlaceOlvidar convertir la dirección relativa en absoluta
offset/limitoffset, limit, skip en la petición a la APIoffset += limitTrozo incompleto o vacíoLos registros se desplazan mientras la lista cambia
Cursornext_cursor, after, un token en la respuestaSe traslada tal cual desde la respuestaCursor vacío o ausenteEl cursor caduca y no puedes empezar por la mitad
Scroll infinito, «cargar más»La dirección no cambia, sale una petición XHRLa regla de la API que hay debajoLa regla de la API o que dejen de llegar tarjetas nuevasDesplazarse 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. 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.

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 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.

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. 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 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.
  • 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, 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. Al crecer el número de trabajadores, un marco de trabajo ya hecho da menos faena: Scrapy 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: 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.

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. Escribir un User-Agent que presente a tu bot también forma parte del trabajo: Qué es el 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 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 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. 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 y la parte de infraestructura, en la página de monitorización de precios.
  • 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.
  • 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.
  • 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.

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.

Guía de decisión

SituaciónRecomendació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 últimaIncrementa 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 JSONDeja el navegador y llama directamente a la petición
La API da un cursorTraslada el cursor tal cual y guarda en un conjunto los ya vistos
La API da una cabecera LinkSigue la dirección response.links["next"]
La petición no se puede repetir, hay scroll infinitoDesplá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 minutosMonta una cola SQLite y escribe registro y estado en la misma transacción
La lista cambia mientras la rastreasClave estable, INSERT OR IGNORE, orden fijo y una segunda vuelta si hace falta
Lista filtrada o ligada a sesión, con proxySesió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.