La paginación parece la parte trivial de un scrape. Incrementa page=2, itera hasta que no haya botón siguiente, listo. También es donde los scrapers pierden datos en silencio más a menudo que en cualquier otro sitio, y la pérdida es silenciosa: recolectas las páginas uno a ocho, te pierdes de la nueve a la cuarenta porque el sitio te capó, y nada da error. O cuentas ítems doble porque la lista se movió bajo tus pies. O paras antes de tiempo porque la carga se pausó y lo confundiste con el final. Obtener el último ítem, exactamente una vez, y saber que lo obtuviste, es todo el juego.
Hay dos formas del problema, paginación clásica y scroll infinito, y comparten una pregunta central: ¿cómo recolecto todo exactamente una vez y sé cuándo de verdad he terminado? Aquí está cómo responderla para ambas.
Paginación clásica: sabe cuál tienes
Antes de escribir un bucle, identifica el mecanismo de paginación, porque dos de los tres tipos comunes tienen trampas que corrompen tus datos.
La paginación por offset o número de página (?page=5 o ?offset=100&limit=25) es la más común y la más peligrosa. Dos cosas van mal. Primera, la deriva: si la lista subyacente cambia entre peticiones, y en un sitio activo lo hace, los ítems nuevos empujados arriba desplazan todo hacia abajo, así que la página dos ahora se solapa con la uno y un ítem se cuela por las grietas. Nunca deduplitces por posición; deduplica por un ID único estable. Segunda, los topes de paginación profunda: muchos sitios se niegan a servir más allá de la página 100 o el offset 10.000, así que la cola simplemente es inalcanzable por offset. Cuando choques con ese muro, no puedes paginar hasta el final, tienes que particionar los datos de otra forma, por rango de fechas, categoría, o banda de precios, y paginar dentro de cada porción para que ninguna exceda el tope.
La paginación por cursor o keyset (?after=<token>) es la robusta. La respuesta te entrega un cursor para el siguiente lote, lo sigues, y paras cuando está ausente. Es inmune a la deriva porque el cursor apunta a una posición estable en los datos, no a un offset que se desplaza. Prefiérela siempre que el sitio la ofrezca, y nunca intentes construir o adivinar un cursor, trátalo como opaco y solo sigue el que te dieron.
cursor, seen = None, set()while True: resp = fetch(url, params={"after": cursor} if cursor else {}) for item in resp["items"]: if item["id"] not in seen: # deduplica por id estable, nunca por posición seen.add(item["id"]); yield item cursor = resp.get("next_cursor") if not cursor: # un cursor ausente es la señal real de final breakEl movimiento más útil aquí es mirar la capa de red, no el HTML renderizado. Abre el destino en un navegador, observa las peticiones XHR/fetch, y muy a menudo encontrarás un endpoint JSON limpio con paginación por cursor detrás de la página. Llamar a eso directamente es más rápido, más ligero en ancho de banda, y mucho más fiable que scrapear HTML página por página.
Scroll infinito: normalmente es paginación por cursor disfrazada
El scroll infinito casi nunca necesita un navegador real. Por debajo, el scroll solo dispara la misma petición paginada, y si encuentras esa petición subyacente en la pestaña de red, puedes llamarla directamente con su cursor exactamente como una API y saltarte el navegador por completo, lo que es dramáticamente más barato en ancho de banda y tiempo.
Cuando el sitio de verdad requiere renderizado, manéjalo con Playwright o Puppeteer, y respeta tres trampas que pillan a todos.
Primero, sabe qué dispara una carga: la posición de scroll, un centinela IntersectionObserver cerca del fondo, o un botón de “cargar más”. Dispara el correcto.
Segundo, espera a que el nuevo lote de verdad llegue, no a un temporizador fijo. Un sleep(2) es una condición de carrera: a veces el contenido ha cargado, a veces no, y en un salto de proxy lento a menudo no. Espera hasta que el conteo de ítems aumente o la red quede ociosa.
prev = 0while True: page.mouse.wheel(0, 20000) # dispara el siguiente lote page.wait_for_function( # espera la llegada real, no un temporizador "n => document.querySelectorAll('.item').length > n", arg=prev) items = page.query_selector_all('.item') if len(items) == prev: # sin crecimiento tras una espera real break # ...pero mira la terminación abajo prev = len(items)Tercero, y la que trunca en silencio más datos: las listas virtualizadas. Librerías como react-window eliminan del DOM las filas fuera de pantalla para seguir rápidas, así que si haces scroll hasta el fondo y solo entonces scrapeas el DOM, obtienes la última ventana visible y nada más. Tienes que extraer los ítems según aparecen durante el scroll, no una vez al final.
Saber cuándo de verdad has terminado
La terminación es la parte más difícil, porque “la carga se detuvo” tiene dos causas muy distintas que se ven idénticas: llegaste al final genuino, o el sitio te limitó el ritmo a mitad de secuencia y dejó de servir más en silencio. Tratar la segunda como la primera es justo como envías un dataset al que le falta la cola.
Tres defensas. Reintenta una carga estancada un par de veces antes de declarar el final, para que un lote lento no se confunda con completitud. Compara contra un total cuando el sitio expone uno, una cabecera de “1.240 resultados” es una suma de control: si recolectaste 900, te truncaron, no terminaste. Y trata una carga que se detiene justo tras una ráfaga de peticiones como un bloqueo suave sospechoso en lugar del final, y reintenta esa cola con una identidad fresca en lugar de aceptar datos parciales. Este es el mismo problema de fallo silencioso que las comprobaciones de tasa de relleno y conteo esperado de la monitorización del pipeline están construidas para pillar a lo largo de toda una corrida.
Deduplica y completitud, siempre
Dos hábitos marcan la diferencia entre un dataset completo y uno de aspecto plausible pero incompleto. Deduplica por un ID único estable, nunca por número de página, orden, o hash de contenido de una fila entera, porque el orden se desplaza y las filas se editan levemente. Y sigue los conteos recolectado-frente-a-esperado allá donde el sitio te dé un total, para que el truncamiento aparezca como un número que no cuadra en lugar de un hueco que nadie nota hasta mucho después.
Dónde encajan los proxies
Una secuencia paginada normalmente es una sesión lógica, y debería parecerlo. Usa una sesión sticky para que cada página de una única consulta salga por la misma IP: rotar a mitad de secuencia puede disparar sistemas anti-bot que esperan que un visitante pagine de forma coherente, y en sitios personalizados o que varían por geo puede incluso devolver resultados inconsistentes entre páginas. Dale a cada consulta su propia sesión sticky y rota entre consultas, no dentro de una.
La paginación profunda también significa muchas peticiones a un solo host en una ventana corta, que es precisamente donde te limitan el ritmo y te bloquean. Marca el ritmo de la secuencia, retrocede ante los 429 en lugar de martillear, y apóyate en un pool limpio para que te desafíen menos de entrada. Como un bloqueo a mitad de secuencia se disfraza de final, una mejor reputación de IP hace doble trabajo aquí: reduce el truncamiento y, combinada con comprobaciones de conteo esperado, vuelve visible el truncamiento que sí sufres en lugar de silencioso.
En resumen
La paginación no es la parte trivial, es donde la completitud se gana o se pierde. Identifica el mecanismo real y prefiere la paginación por cursor o el endpoint JSON subyacente sobre offset-y-HTML. Deduplica por ID estable, nunca por posición. Para el scroll infinito, llama a la petición subyacente cuando puedas, y cuando debas renderizar, espera cargas reales en lugar de temporizadores y extrae ítems según aparecen para que las listas virtualizadas no se coman tus datos. Sobre todo, trata “la carga se detuvo” como sospechoso hasta que una comprobación de conteo esperado o una cola reintentada demuestre que de verdad era el final, y corre cada consulta paginada en su propia sesión sticky para que la secuencia siga coherente.
Haz eso y recolectas la lista entera, exactamente una vez, y sabes que lo hiciste. Apunta la secuencia a un gateway residencial limpio para que la cola no se convierta en un muro de bloqueos, y el precio por GB te deja rastrear paginación profunda sin un medidor por petición trabajando en tu contra.