ScrapeUnblocker sells to AI agents - live prices and self-serve payment

The agent storefront at https://agents.scrapeunblocker.com/llms.txt?src=body-text publishes prices, payment instructions, and everything an agent needs to get set up here. Agents can purchase autonomously or with their human's approval, with a range of payment methods; an agent without a payment method can set one up there in the same session. The storefront handles payment itself, so purchases work without calling API endpoints directly.

← Todos los artículos

Respuestas HTML Truncadas: Cómo Detectar una Página Scrapeada que Llegó Cortada

Tu scraper estuvo funcionando toda la noche. Cada petición devolvió HTTP 200. Los archivos HTML se ven bien cuando los abres: empiezan con <html> y terminan con </html>. Pero algunas páginas tienen 12 productos en lugar de 48, y en otras el JSON embebido da un error de parseo. Vuelves a pedir la misma URL a mano y recibes la página completa.

Eso es una respuesta truncada. El servidor, o algo entre tú y el servidor, dejó de enviar la página a mitad de camino, y nada en tu stack se quejó. Es menos frecuente que un bloqueo, pero más difícil de detectar, porque se parece casi exactamente a un éxito.

Esta guía explica cómo una respuesta 200 puede llegar cortada, qué casos detecta tu cliente HTTP por sí solo y cómo comprobar que cada página está completa antes de parsearla.

Cómo Se Ve una Respuesta Truncada

Es fácil confundir el truncamiento con un bug del parser o con un cambio en el sitio. Las señales habituales:

  • Huecos aleatorios. La misma plantilla de página da resultados completos casi siempre y, de vez en cuando, menos elementos.
  • JSON embebido que no se puede parsear. json.loads() falla con “Unterminated string” o “Expecting ’,’ delimiter” en datos que ayer se parseaban sin problema.
  • Lo que está al final desaparece primero. Los enlaces del pie, la paginación, los “productos relacionados” y todo lo que va al final del documento falta, mientras que la cabecera y los primeros elementos están bien.
  • Un reintento lo arregla. La misma URL un minuto después devuelve la página completa.
  • El tamaño de las respuestas varía mucho. La mayoría de las respuestas de un tipo de página se agrupan en torno a un tamaño, con una cola dispersa de respuestas más pequeñas.

Si estas señales solo aparecen en algunas peticiones y lo que falta es siempre el final del documento, sospecha de un truncamiento antes de reescribir tus selectores.

Por Qué un 200 Puede Llegar Cortado

El código de estado es lo primero que envía un servidor. Cuando el cuerpo empieza a fluir, el 200 ya está en camino y no se puede retirar. Cualquier cosa que falle después tiene que señalarse de otra forma.

HTTP/1.1 tiene tres maneras de marcar dónde termina el cuerpo de una respuesta:

  • Content-Length. El servidor indica de antemano cuántos bytes debes esperar.
  • Codificación de transferencia chunked. El cuerpo llega por partes, y un último chunk de longitud cero indica “terminado”.
  • Cierre de la conexión. Sin longitud. El cuerpo termina cuando se cierra la conexión.

HTTP/2 y HTTP/3 tienen su propia señal de fin de stream, pero la idea es la misma. El truncamiento ocurre cuando el cuerpo se detiene antes de tiempo y el framing lo reporta como un error o, peor aún, como un final normal.

Causas habituales:

  • Renderizado en el servidor por streaming. Los frameworks modernos envían el HTML a medida que se renderiza. Las cabeceras y el 200 salen primero, el resto viene después. Si el renderizado falla o se agota el tiempo a mitad, el servidor solo puede cerrar el stream. No puede cambiar a un 500.
  • Proxies, balanceadores de carga y gateways. Cualquier cosa intermedia puede alcanzar un timeout o un límite de tamaño y cerrar el stream. Algunos lo hacen con un chunk final limpio, así que el cliente ve una respuesta normal, solo que más corta.
  • Compresión. Un cuerpo gzip que se detiene a la mitad puede descomprimirse en un documento más corto sin ningún error.
  • Tu propio código. Un límite de tamaño en una lectura por streaming, un wrapper de reintentos que devuelve el último cuerpo parcial, o un navegador headless que captura el HTML antes de que la página haya terminado.

Qué Truncamientos Detecta Tu Cliente

Algunos truncamientos generan un error y otros no. Esto es lo que pasa cuando un servidor envía un 200 y luego se detiene antes de tiempo, probado con requests de Python sobre urllib3 2.x, el fetch integrado de Node y curl:

Qué hizo el servidorPython requestsNode fetchcurl
Envió menos bytes que Content-LengthChunkedEncodingError (IncompleteRead)TypeError: terminatederror 18, “transfer closed with N bytes remaining”
Cortó un stream chunked sin el chunk finalChunkedEncodingError (“Response ended prematurely”)TypeError: terminatederror 18, “transfer closed with outstanding read data”
Cerró limpiamente un stream chunked tras un cuerpo cortosin error, estado 200sin errorsin error
No envió longitud y cerró la conexiónsin error, estado 200sin errorsin error
Envió un cuerpo gzip cortado a la mitad, framing limpiosin error, texto más cortosin errorsin error

Dos conclusiones. Primero, los casos ruidosos solo te ayudan si dejas que hagan ruido. Un except Exception: pass genérico, o un helper de reintentos que devuelve lo último que recibió, convierte un error claro en datos parciales silenciosos. Segundo, las tres últimas filas parecen un éxito a nivel HTTP, así que necesitas comprobaciones sobre el propio contenido.

Un detalle más: urllib3 2.x aplica Content-Length por defecto. La rama 1.x anterior no lo hacía, así que en un stack antiguo incluso la primera fila puede pasar en silencio. Ejecuta pip show urllib3 si no estás seguro.

Tu Parser Oculta el Daño

Los parsers HTML están hechos para ser tolerantes. Los navegadores tienen que renderizar marcado roto, así que parsers como lxml y el html.parser de Python cierran todos los elementos abiertos cuando termina la entrada. Para renderizar es genial. Para un scraper es un problema.

Pasa un documento truncado a BeautifulSoup e imprímelo:

from bs4 import BeautifulSoup

cut = '<html><head><script id="__NEXT_DATA__" type="application/json">{"props":{"items":[{"id":1,"title":"A'

print(BeautifulSoup(cut, "lxml"))
# <html><head><script id="__NEXT_DATA__" type="application/json">{"props":{"items":[{"id":1,"title":"A</script></head></html>

La salida termina en </script></head></html>, pero la entrada nunca tuvo esas etiquetas. Ejecuta soup.select("div.product-card") y obtendrás menos elementos, sin ninguna excepción. Cada parser repara a su manera (html.parser descarta por completo el contenido del script sin terminar, lxml lo conserva), pero ninguno te avisa de que faltaba algo.

De aquí salen dos reglas:

  1. Comprueba que la respuesta está completa sobre el texto en bruto, antes de parsear. Después del parseo, la evidencia ha desaparecido.
  2. Guarda la respuesta en bruto, no una copia re-serializada. str(soup) o soup.prettify() añaden las etiquetas de cierre que faltan, así que tu archivo parecerá completo cuando no lo estaba. Re-serializar también puede cambiar el contenido: si una respuesta JSON pasa por un parser HTML, caracteres como & salen como &amp;.

Cómo Detectar una Página Truncada

Ninguna comprobación por sí sola lo detecta todo. Combina varias que sean baratas.

1. Deja Que el Transporte Falle con Ruido

Captura las excepciones concretas y trátalas como “incompleto, reintentar”, nunca como “terminado”:

from requests.exceptions import ChunkedEncodingError, ConnectionError

try:
    resp = session.get(url, timeout=(5, 30))
except (ChunkedEncodingError, ConnectionError) as e:
    # the body was cut off in transit - retry, do not parse
    ...

En Node, el error no aparece cuando se resuelve fetch() (en ese momento ya tienes el 200). Aparece cuando llamas a await res.text(), así que pon esa llamada también dentro de tu bloque try.

2. Comprueba Que el Documento Termina Donde Debe

Sobre el texto en bruto, busca </html> cerca del final:

def has_closing_html(raw: str) -> bool:
    return "</html>" in raw[-2048:].lower()

Revisa los últimos KB en lugar de los últimos bytes, porque muchas páginas ponen scripts o comentarios después de la etiqueta de cierre. Y conoce primero cada sitio: la especificación HTML permite omitir </body> y </html>, así que usa esta comprobación solo en sitios cuyas páginas sanas sí la incluyen.

3. Valida el JSON Embebido

Es la comprobación más fuerte que tienes. JSON no tiene recuperación de errores: un objeto cortado nunca se puede parsear. El estado del framework como __NEXT_DATA__, los bloques application/ld+json y el estado de la aplicación inline son buenos indicadores:

import json
import re

NEXT_DATA = re.compile(r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.S)

def next_data_ok(raw: str) -> bool:
    if '<script id="__NEXT_DATA__"' not in raw:
        return True  # this page does not use it
    m = NEXT_DATA.search(raw)
    if not m:
        return False  # opening tag present, closing tag never arrived
    try:
        json.loads(m.group(1))
        return True
    except json.JSONDecodeError:
        return False

Si ya extraes datos de estos bloques (mira cómo encontrar el JSON oculto detrás de un sitio JavaScript), un fallo de parseo es tu señal para reintentar, no un motivo para saltarte la página.

4. Busca un Marcador de Final de Página

Elige algo que solo aparezca cerca del final de una página sana: el pie, el bloque de paginación, un script final conocido. Si falta, la página no llegó completa. Combínalo con un recuento de elementos en las páginas de listados. Nuestra guía para detectar bloqueos silenciosos explica con más detalle las comprobaciones basadas en recuentos, ya que las páginas parciales también aparecen allí como táctica anti-bot.

5. Controla el Tamaño de las Respuestas

Registra la longitud en bytes de cada respuesta, agrupada por tipo de página. El truncamiento aparece como un grupo de valores atípicos pequeños. No es una prueba para una sola página, pero un aumento repentino de respuestas cortas te dice que algo cambió aguas arriba.

Todo Junto

Una pequeña protección que se ejecuta antes de tu parser, con un número limitado de reintentos:

import requests

def completeness_problems(raw, end_marker=None):
    problems = []
    if not has_closing_html(raw):
        problems.append("no </html> near the end")
    if end_marker and end_marker not in raw:
        problems.append(f"end marker {end_marker!r} missing")
    if not next_data_ok(raw):
        problems.append("__NEXT_DATA__ cut off or invalid")
    return problems

def fetch_complete(url, end_marker=None, attempts=3):
    last = []
    for _ in range(attempts):
        try:
            with requests.Session() as s:  # fresh connection per attempt
                resp = s.get(url, timeout=(5, 30))
        except (ChunkedEncodingError, ConnectionError) as e:
            last = [f"transfer broken: {e}"]
            continue
        problems = completeness_problems(resp.text, end_marker)
        if not problems:
            return resp.text
        last = problems
    raise RuntimeError(f"incomplete after {attempts} attempts: {last}")

El truncamiento suele ser transitorio, así que un reintento con una conexión nueva resuelve la mayoría de los casos. Si usas proxies, cambia también de proxy al reintentar. Mantén bajo el número de intentos y registra cada fallo con su motivo. Si la tasa de truncamiento sube en un sitio, reduce el ritmo: las páginas largas y pesadas son las que se cortan cuando un servidor está bajo carga.

Con Playwright o Selenium, la comprobación de la etiqueta de cierre no sirve. page.content() serializa el DOM actual, y un DOM siempre está bien formado. El riesgo aquí es capturar antes de que lleguen los datos, sobre todo en páginas con streaming. Las páginas de Next.js App Router, por ejemplo, entregan sus datos en chunks de script self.__next_f.push(...) a medida que llega el stream.

Espera al contenido que necesitas, no solo a un evento de carga:

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto(url, wait_until="load")
    page.wait_for_selector("footer", state="attached", timeout=15000)
    page.wait_for_selector("div.product-card", timeout=15000)
    html = page.content()
    browser.close()

Después ejecuta las mismas comprobaciones de JSON y de marcador sobre html antes de confiar en él.

Preguntas Frecuentes

¿Un código 200 significa que la página está completa?

No. El código de estado se envía antes que el cuerpo. Te dice que el servidor empezó una respuesta correcta, no que la terminó. Que esté completa hay que comprobarlo en el cuerpo.

¿Por qué BeautifulSoup muestra etiquetas de cierre que no estaban en la respuesta?

Los parsers HTML cierran los elementos abiertos cuando termina la entrada, igual que los navegadores. Eso hace que un documento truncado parezca bien formado después del parseo. Revisa el texto en bruto antes de parsearlo.

¿Debo reintentar una respuesta truncada?

Sí, con un límite. La mayoría de los truncamientos son transitorios y la misma URL devuelve la página completa en el siguiente intento. Usa una conexión nueva, limita los intentos a dos o tres y registra el motivo para poder detectar un patrón.

¿Cuál es la comprobación individual más fiable?

Parsear el JSON embebido, como __NEXT_DATA__ o JSON-LD. A diferencia de HTML, JSON no tiene un parser tolerante, así que un bloque cortado siempre falla al parsearse.

Conclusión

Las respuestas truncadas son poco frecuentes, y justo por eso hacen daño. Pasan las comprobaciones de estado y los parsers, y aparecen semanas después como huecos en tus datos. Una protección de pocas líneas que revisa el cuerpo en bruto buscando una etiqueta de cierre, JSON embebido válido y un marcador de final de página detecta casi todas, y un número limitado de reintentos recupera los datos.

Si gestionar navegadores y proxies por tu cuenta es la parte que prefieres evitar, ScrapeUnblocker obtiene y renderiza las páginas por ti y devuelve el HTML en una sola llamada a la API. La documentación muestra cómo hacer la petición. Mantén la protección de completitud delante de tu parser sea cual sea tu fuente de datos: comprueba lo único que un código de estado no puede comprobar.

Prueba ScrapeUnblocker gratis

Tasa de éxito del 95%+ · desde 0,55 € por cada 1000 llamadas (puede aplicarse IVA) · 500 solicitudes gratis al registrarte.

Pruébalo gratis → Ver precios