Tienes un script que descarga archivos o páginas con requests. Funcionaba, el sitio cambió de servidor o de proveedor y ahora cada petición termina así, mientras que la misma URL se abre sin problemas en el navegador:
requests.exceptions.HTTPError: 403 Client Error: Forbidden for url: https://example.com/archivos/informe.pdf
El texto exacto varía un poco entre versiones de la biblioteca. Una causa frecuente es que el servidor rechaza el User-Agent que requests envía por defecto. Este artículo explica cómo comprobarlo, cómo corregirlo y cómo evitar que un 403 se interprete como "el archivo no existe" dentro de un bucle de descarga.
Por qué pasa
Cada petición de requests lleva una cabecera User-Agent con el formato python-requests/<versión>; la documentación lo muestra al inspeccionar r.request.headers. Algunos servidores, redes de distribución de contenido y cortafuegos de aplicaciones web bloquean esa firma conocida de automatización y responden 403, aunque con un navegador la misma URL funcione.
Según MDN, el código 403 significa que el servidor entendió la petición pero se niega a atenderla. No dice que el recurso no exista: de hecho, algunos servidores devuelven 404 en lugar de 403 justamente para no revelar si existe. Por eso conviene tratar el 403 como "bloqueado" y no como "ausente".
El User-Agent no es la única causa posible. También pueden influir las cookies, la cabecera Referer, un límite de peticiones o un bloqueo por dirección IP. Por eso lo primero es comprobarlo, no suponerlo.
Solución
-
Comprueba qué cabecera envías. Con la propia respuesta:
import requests r = requests.get("https://example.com/archivos/informe.pdf", timeout=15) print(r.status_code, r.request.headers["User-Agent"])También puedes ver el valor por defecto sin hacer ninguna petición con
requests.utils.default_user_agent(). Esa función existe en el código fuente de la biblioteca, aunque no figura en la referencia de la API. Devuelve algo comopython-requests/<versión instalada>. -
Descarta que sea cosa del cliente. Compara el mismo recurso con dos valores distintos de
User-Agentusandocurl:curl -s -o /dev/null -w "%{http_code}\n" -A "python-requests/VERSION" https://example.com/archivos/informe.pdf curl -s -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0" https://example.com/archivos/informe.pdfSustituye
VERSIONpor el valor que mostró el paso anterior. Si el primer comando devuelve 403 y el segundo 200, el bloqueo depende delUser-Agent. En PowerShell usacurl.exeyNULen lugar de/dev/null. -
Usa una sesión con cabeceras. La documentación de
requestsindica que los valores deSession.headersse envían en todas las peticiones de la sesión y que los de cada llamada se combinan con ellos y los sustituyen si coinciden:import requests CABECERAS = { # Ejemplo: sustitúyelo por el User-Agent de tu navegador "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "es-ES,es;q=0.9,en;q=0.8", } sesion = requests.Session() sesion.headers.update(CABECERAS)Copia el
User-Agentde tu propio navegador (herramientas de desarrollo, pestaña Red, cabeceras de la petición) o usa uno actual. No copiesAccept-Encodingdel navegador: la documentación indica querequestssolo decodifica la codificaciónbrsi hay una biblioteca Brotli instalada, y sin ella recibirías el contenido sin decodificar. -
Distingue "bloqueado" de "ausente" en los bucles. Un scraper que avanza con un cursor no debe contar un 403 como "este elemento no existe":
OK, AUSENTE, BLOQUEADO = "ok", "ausente", "bloqueado" def descargar(sesion, url, destino): r = sesion.get(url, timeout=(5, 30)) if r.status_code == 200: destino.write_bytes(r.content) return OK if r.status_code == 404: return AUSENTE if r.status_code in (401, 403, 429): return BLOQUEADO r.raise_for_status() raise RuntimeError(f"Respuesta inesperada: {r.status_code}") cursor = leer_cursor() # último elemento confirmado while cursor < MAXIMO: siguiente = cursor + 1 url = f"https://example.com/archivos/{siguiente}.pdf" estado = descargar(sesion, url, carpeta / f"{siguiente}.pdf") if estado == BLOQUEADO: print(f"Bloqueado en {siguiente}: se detiene sin avanzar el cursor") break cursor = siguiente guardar_cursor(cursor) time.sleep(retraso)El cursor solo avanza con descargas correctas o con 404 reales. Si una ejecución anterior contó los 403 como ausentes, el cursor quedó por delante de elementos que nunca se descargaron: retrocédelo al último elemento descargado con éxito y vuelve a ejecutar.
-
Respeta las reglas del sitio. Consulta
robots.txty los términos de uso antes de automatizar, y limita el ritmo:from urllib.robotparser import RobotFileParser rp = RobotFileParser() rp.set_url("https://example.com/robots.txt") rp.read() if not rp.can_fetch("MiScraper", url): raise SystemExit("robots.txt no permite esta ruta") retraso = rp.crawl_delay("MiScraper") or 1La documentación de Python describe
can_fetchycrawl_delay. Ten presente que, según el RFC 9309, las reglas derobots.txtno son una forma de autorización de acceso: el sitio puede seguir bloqueándote, y sus términos de uso mandan. Si el sitio ofrece una API o una descarga oficial, es mejor opción. Cuando sea posible, identifica tu cliente con unUser-Agentpropio que incluya un contacto (por ejemplo,MiScraper/1.0 (+https://example.com/contacto)) y recurre a uno de navegador solo si el servidor rechaza todo lo demás.
Cómo verificar que quedó resuelto
Compara la misma URL con las cabeceras por defecto y con las tuyas:
import requests
URL = "https://example.com/archivos/informe.pdf"
for nombre, cabeceras in (("por defecto", {}), ("con cabeceras", CABECERAS)):
r = requests.get(URL, headers=cabeceras, timeout=15)
print(nombre, r.status_code, r.request.headers["User-Agent"])
La primera línea debe mostrar 403 con python-requests/<versión> y la segunda 200 con tu User-Agent. Después ejecuta el script real sobre un lote pequeño y comprueba tres cosas: que se crean los archivos, que el contenido es el esperado (no una página HTML de error guardada con extensión .pdf) y que el cursor avanza solo cuando hay descarga.
Variantes y errores relacionados
Sigue el 403 con el User-Agent de un navegador
El servidor está usando otras señales. Abre la petición en las herramientas de desarrollo del navegador, cópiala como curl y compara cabeceras y cookies con las de tu script. Si hay un desafío de JavaScript o un bloqueo por IP, las cabeceras no lo resolverán; pide acceso o usa la vía oficial.
429 Too Many Requests
No es un bloqueo por firma sino por ritmo. Aumenta el retraso entre peticiones y trata el 429 como "pausa", igual que el 403 en el bucle anterior.
401 frente a 403
Según MDN, en un 403 volver a autenticarse no cambia el resultado, mientras que un 401 indica credenciales ausentes o inválidas. Si el sitio requiere inicio de sesión, el problema no es el User-Agent.
Errores al definir las cabeceras
Los valores de las cabeceras deben ser cadenas de texto. Y si quieres quitar una cabecera de la sesión en una llamada concreta, la documentación indica que basta con pasarla con valor None en esa llamada.
Resumen
- Un 403 en
requestscon la URL funcionando en el navegador suele indicar que el servidor rechaza elUser-Agentpython-requests/<versión>; compruébalo concurl -A. - Configura una
requests.SessionconUser-Agent,AcceptyAccept-Language, sin copiarAccept-Encoding. - En los bucles, trata 401, 403 y 429 como "bloqueado" y no avances el cursor; solo el 404 significa "ausente".
- Respeta
robots.txt, los términos de uso y un ritmo razonable; prefiere la API oficial si existe. - Verifica comparando el código de estado con y sin tus cabeceras, y revisando el contenido descargado.
Comentarios
Publicar un comentario