Por qué la tool de fetch de tu agente de IA devuelve una página vacía

Shell vacío, muro de cookies, redirección al login o una página cortada: cómo distinguirlos en 30 líneas de Python, con mediciones reales del 19 de octubre de 2026.

“No he podido leer esa página” es una sola frase del modelo y, por debajo, al menos cinco problemas distintos. La solución depende por completo de cuál te haya tocado, así que primero diagnostica.

¿Por qué la tool de fetch de un agente no ve nada útil?

La mayoría de las tools de fetch integradas hacen lo mismo: un GET HTTP simple, luego el HTML convertido a texto, luego un tope de longitud. No se ejecuta JavaScript, no se guardan cookies, nadie hace clic en “aceptar”. La propia documentación de Anthropic sobre su tool de web fetch dice, a octubre de 2026, que no admite sitios web renderizados dinámicamente con JavaScript y que el contenido que supera max_content_tokens se trunca.

Así que cinco situaciones distintas acaban en el mismo “nada útil” para el modelo:

Caso Qué envió el servidor Señal que puedes comprobar
App renderizada en el cliente Un shell HTML, con el contenido construido después por scripts Menos de 500 caracteres de texto visible, un <noscript> que pide JavaScript, o muchos más bytes de HTML que de texto
Muro de consentimiento Un intersticial de cookies o privacidad Redirección a un host consent., página corta llena de “cookie” y “consent”
Muro de login Un formulario de inicio de sesión Redirección a /login o /signin, un campo de contraseña
Bloqueada Un error o un challenge 401, 403, 429 o 503
Demasiado larga La página completa Texto visible mayor que el presupuesto de la tool

Para entender por qué renderizar cuesta más y cómo detectarlo a mano, mira el renderizado de JavaScript en web scraping. Este post trata de hacer la comprobación en código, dentro del agente.

Una función de diagnóstico que puedes ejecutar

requests y BeautifulSoup a secas, sin API key, sin coste:

import re
import requests
from bs4 import BeautifulSoup

HEADERS = {"User-Agent": "page-diagnostic/1.0", "Accept": "text/html"}
CONSENT = re.compile(r"cookie|consent|gdpr|privacy choices", re.I)
LOGIN = re.compile(r"/(login|log-in|signin|sign-in|auth)\b", re.I)

def diagnose(url, budget=12_000):
    r = requests.get(url, headers=HEADERS, timeout=30)
    soup = BeautifulSoup(r.text, "html.parser")
    noscript = " ".join(n.get_text(" ", strip=True) for n in soup.find_all("noscript"))
    for tag in soup(["script", "style", "noscript", "template"]):
        tag.decompose()
    text = soup.get_text(" ", strip=True)

    if r.status_code in (401, 403, 429, 503):
        verdict = "blocked"
    elif LOGIN.search(r.url) or soup.find("input", {"type": "password"}):
        verdict = "login_wall"
    elif "consent" in r.url or (len(text) < 3_000 and CONSENT.search(text)):
        verdict = "consent_wall"
    elif len(text) < 500 and ("javascript" in noscript.lower() or len(r.content) > 50 * len(text)):
        verdict = "client_rendered"
    elif len(text) > budget:
        verdict = "too_long"
    else:
        verdict = "ok"
    return {"status": r.status_code, "final_url": r.url, "html_bytes": len(r.content),
            "text_chars": len(text), "verdict": verdict, "text": text}

Los umbrales son heurísticos. Ajústalos con tu propio tráfico, pero el orden importa: comprueba primero las señales baratas e inequívocas (status, URL) y después las difusas (longitud del texto).

Lo que devolvió en cuatro páginas públicas

Ejecutado el 19 de octubre de 2026 desde una máquina en Italia:

URL Status Bytes de HTML Texto visible Veredicto
python.org/about/ 200 41.239 4.598 caracteres ok
crates.io/crates/serde 200 5.406 32 caracteres client_rendered
it.yahoo.com 200, redirigida a consent.yahoo.com 112.226 2.471 caracteres consent_wall
en.wikipedia.org/wiki/World_War_II 200 2.074.721 196.628 caracteres too_long

Todas devolvieron 200. Una comprobación basada solo en el código de estado habría dado las cuatro por buenas. crates.io envía un shell de 5 KB cuyo único texto es el título y una línea <noscript> que pide JavaScript. La portada italiana de Yahoo redirige a un visitante sin cookies a una página de consentimiento; uk.yahoo.com, descargada del mismo modo, no lo hizo.

¿Qué haces con cada veredicto?

ok: usa el fetch simple. Python.org no costó nada ni necesitó nada más.

client_rendered: renderízala. La API de web scraping de FastAF lo hace con dynamic=true por 5 créditos en lugar de 1, o con render_js: true en la tool scrape_page de MCP. En crates.io, una llamada estática devolvió 0 caracteres de markdown; una renderizada devolvió 4.939. Eso sí, sé honesto contigo mismo sobre la variabilidad: el 19 de octubre hicimos seis llamadas renderizadas a esa página, cuatro volvieron con entre 2.313 y 4.939 caracteres y dos volvieron vacías, porque la app no había terminado de cargar. Esas dos se cobraron en su momento; después de medirlo para este post cambiamos la regla, y ahora un resultado vacío devuelve 422 target_unavailable y no cuesta nada. Un render todavía puede devolver un shell casi vacío, así que comprueba la longitud del texto y usa wait (de 0 a 35.000 ms) para las apps lentas.

consent_wall: renderizar no ayuda. El fetch de FastAF sobre it.yahoo.com también obtuvo la página de consentimiento (3.697 caracteres, todo aviso de privacidad). Nadie debería aceptar condiciones en nombre de un sitio en una ejecución automatizada. Infórmalo, o saca la información de una fuente pensada para ello, como una búsqueda de noticias.

login_wall y blocked: detente y dilo. El contenido tras un login no es público, y la protección contra bots es decisión del dueño del sitio. FastAF solo sirve páginas públicas: una solicitud a https://example.com/login devuelve 400 blocked_target en 64 ms y no cuesta nada. Las reglas están en la política de uso aceptable.

too_long: pide markdown con un presupuesto. La tool MCP scrape_page sobre la página de Wikipedia devolvió los primeros 12.000 de 597.765 caracteres de markdown con truncated: true, por 1 crédito en 1,3 segundos. El flag es lo importante: el modelo sabe que ha leído un trozo, no la página entera.

Una estrategia de fetch para agentes

Primero la vía barata, el renderizado solo cuando el diagnóstico lo pide, y un tope duro por ejecución:

import os, time, requests

def read_page(url, state, max_chars=12_000):
    d = diagnose(url, budget=max_chars)
    if d["verdict"] in ("blocked", "login_wall", "consent_wall"):
        return {"error": f"{d['verdict']}: page not readable, tell the user"}
    if d["verdict"] in ("ok", "too_long"):
        t = d["text"]
        return {"text": t[:max_chars], "truncated": len(t) > max_chars}
    if state["renders_left"] <= 0:
        return {"error": "page needs rendering, render budget for this run is spent"}
    for _ in range(3):
        r = requests.get("https://api.fastaf.co/v1/scrape",
            params={"url": url, "formats": "markdown", "dynamic": "true", "wait": 3000},
            headers={"Authorization": f"Bearer {os.environ['FASTAF_API_KEY']}"}, timeout=90)
        if r.status_code != 429:
            break
        time.sleep(int(r.headers.get("Retry-After", 3)))
    if r.status_code != 200:
        return {"error": r.json().get("error", {}).get("code", "scrape_failed")}
    state["renders_left"] -= 1
    if len(r.text) < 500:
        return {"error": "rendered page still empty, tell the user"}
    return {"text": r.text[:max_chars], "truncated": len(r.text) > max_chars}

Nuestra ejecución final del 19 de octubre, con renders_left en 1, costó 5 créditos en total. Python.org y Wikipedia salieron del fetch simple gratuito (Wikipedia recortada a 12.000 de 196.628 caracteres y marcada), crates.io/crates/serde se renderizó en 2.508 caracteres en 6,3 segundos, crates.io/crates/tokio recibió “budget spent” y Yahoo recibió consent_wall. Los errores vuelven como resultados de la tool para que el modelo pueda contarle al usuario lo que ha pasado en lugar de adivinar. Los parámetros completos están en la documentación.

En resumen

Un fetch vacío es un síntoma, no un diagnóstico. Comprueba el status, el destino de la redirección, la longitud del texto visible y el <noscript> antes de gastar nada. Fetch simple para las páginas estáticas, renderizado a 5 créditos solo para las páginas públicas renderizadas en el cliente, un tope de renders por ejecución, y un “no puedo leer esto” honesto ante muros de consentimiento, logins y bloqueos. Las páginas truncadas necesitan un presupuesto y un flag, no un contexto más grande. Consigue 500 créditos gratis y prueba el diagnóstico con las páginas en las que tu agente sigue fallando.