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.