Web scraping con Python — `requests` + `BeautifulSoup` paso a paso

Web scraping = extraer datos de páginas web automáticamente. Sirve para mil cosas: monitorizar precios de productos, recolectar ofertas de empleo, agregar noticias, alimentar datasets para ML, recuperar datos de webs que no tienen API.
En Python lo haces con dos librerías que llevan años siendo el estándar: requests para descargar HTML y BeautifulSoup para parsearlo. Ambas son sencillas, bien documentadas y suficientes para el 90% de los casos.
En esta entrada te enseño los patrones que de verdad usas: descargar páginas, extraer datos por etiquetas/clases/CSS selectors, paginación, errores típicos y los temas legales que tienes que conocer antes de scrapear nada que no sea tuyo.
Contenido
- 1 Antes de scrapear: lee robots.txt y los Terms of Service
- 2 Setup mínimo
- 3 Descargar una página con requests
- 4 Parsear con BeautifulSoup
- 5 Las 4 formas de seleccionar elementos
- 6 Extraer texto y atributos
- 7 Caso real: extraer todos los posts del blog
- 8 Paginación
- 9 Scraping concurrente con asyncio
- 10 Cuando el HTML no tiene los datos (JavaScript-rendered)
- 11 Buenas prácticas
- 12 Errores típicos al empezar
- 13 Resumen
- 14 Tu siguiente paso
Antes de scrapear: lee robots.txt y los Terms of Service
Esto va primero por una razón. Scraping no es ilegal por defecto, pero según qué scrapees y cómo, te puedes meter en problemas:
robots.txtdel sitio (https://sitio.com/robots.txt) lista qué rutas se pueden y no se pueden indexar/scrapear. No tiene fuerza legal, pero es mala práctica ignorarlo.- Terms of Service del sitio. Algunas webs los prohíben explícitamente. Saltárselos puede ser causa de demanda.
- Datos personales: el RGPD aplica. No descargues y proceses datos personales sin base legal.
- Frecuencia: bombardear con 1000 peticiones por segundo es DDoS técnico. Te baneará la IP, el provider, y posiblemente acaba en denuncia.
⚡ Tip-friki: mira si la web tiene API pública antes de scrapear HTML. Es más rápido, más estable, y respeta a los dueños del contenido.
Setup mínimo
pip install requests beautifulsoup4 lxml
lxml es un parser más rápido que el nativo de Python. Recomendado.
💡 ¿Sin venv todavía?
venvypipsin liarte. Empieza por ahí.
Descargar una página con requests
import requests
r = requests.get("https://elpythonista.com/blog")
r.raise_for_status() # lanza si la respuesta es 4xx/5xx
print(r.status_code) # 200
print(r.text[:500]) # HTML como string
r.raise_for_status() es importante: si la web devuelve 404 o 500, lo notas inmediatamente. Sin esto, sigues procesando “HTML” que en realidad es una página de error.
Headers — ser un cliente honesto
Muchas webs bloquean peticiones sin User-Agent o con uno sospechoso. Pasa por un navegador real:
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Safari/605.1.15",
"Accept-Language": "es-ES,es;q=0.9",
}
r = requests.get(url, headers=headers, timeout=10)
Y siempre timeout. Sin él, una request puede colgarse para siempre.
📥 Llévate el cheatsheet de Python (gratis)
PDF de 6 páginas con lo esencial: tipos, condicionales, bucles, estructuras de datos, funciones y los errores que más vas a cometer. Para tener al lado mientras programas.
Sin spam. Te apuntas a la lista, descargas el cheatsheet y recibes contenido de Python cada semana.
Parsear con BeautifulSoup
from bs4 import BeautifulSoup
soup = BeautifulSoup(r.text, "lxml")
print(soup.title.string) # contenido de <title>
print(soup.h1.string) # primer <h1>
soup es una representación navegable del HTML. A partir de aquí, extraes lo que necesites.
Las 4 formas de seleccionar elementos
1. Por etiqueta
soup.find("a") # primer <a>
soup.find_all("a") # lista de todos los <a>
soup.find_all("a", limit=10) # los primeros 10
2. Por etiqueta + atributo
soup.find_all("a", class_="post-title") # ojo: class_ con underscore
soup.find_all("img", src=True) # imágenes con atributo src
soup.find("meta", attrs={"name": "description"})
💡
class_con guion bajo porqueclasses palabra reservada en Python.
3. Por CSS selector (la opción más cómoda)
soup.select("a.post-title") # todos los a con class="post-title"
soup.select("h2 > a") # a hijos directos de h2
soup.select("article.post h3") # h3 dentro de article.post
soup.select_one("title") # el primero
Si sabes CSS, esto es lo más natural. Si no, las dos formas anteriores te valen.
soup.body # <body>
soup.body.find_all("p") # todos los <p> dentro del body
elemento.parent # padre
elemento.children # hijos
elemento.next_sibling # hermano siguiente
Extraer texto y atributos
enlace = soup.find("a", class_="post-title")
print(enlace.string) # texto directo (None si tiene tags hijos)
print(enlace.get_text()) # texto recursivo (concatena todos los descendientes)
print(enlace["href"]) # atributo href
print(enlace.get("title")) # atributo title (None si no existe, sin error)
get() es más seguro que [] cuando el atributo puede no existir.
Caso real: extraer todos los posts del blog
import requests
from bs4 import BeautifulSoup
r = requests.get(
"https://elpythonista.com/blog",
headers={"User-Agent": "Mozilla/5.0 ..."},
timeout=10,
)
r.raise_for_status()
soup = BeautifulSoup(r.text, "lxml")
posts = []
for articulo in soup.select("article.post"):
titulo = articulo.select_one("h2 a")
fecha = articulo.select_one(".post-date")
posts.append({
"titulo": titulo.get_text(strip=True),
"url": titulo["href"],
"fecha": fecha.get_text(strip=True) if fecha else None,
})
print(f"Encontrados {len(posts)} posts")
for p in posts[:5]:
print(p)
get_text(strip=True) quita espacios y saltos de línea. Útil cuando el HTML está formateado con indentación.
Paginación
Patrón típico cuando los resultados están repartidos en varias páginas:
import requests
from bs4 import BeautifulSoup
todos_los_posts = []
url = "https://elpythonista.com/blog"
while url:
r = requests.get(url, headers={"User-Agent": "Mozilla/5.0 ..."}, timeout=10)
r.raise_for_status()
soup = BeautifulSoup(r.text, "lxml")
for articulo in soup.select("article.post"):
titulo = articulo.select_one("h2 a")
todos_los_posts.append(titulo.get_text(strip=True))
siguiente = soup.select_one("a.next-page")
url = siguiente["href"] if siguiente else None
El bucle continúa mientras encuentre el enlace “siguiente”. Cuando no hay más páginas, siguiente es None y sale.
Scraping concurrente con asyncio
Si tienes 1000 URLs que scrapear, hacerlas en serie tarda mucho. Con asyncio + aiohttp las haces en paralelo:
import asyncio
import aiohttp
from bs4 import BeautifulSoup
async def extraer_titulo(session, url, sem):
async with sem:
async with session.get(url) as r:
html = await r.text()
soup = BeautifulSoup(html, "lxml")
return soup.title.string if soup.title else None
async def main(urls, max_concurrentes=10):
sem = asyncio.Semaphore(max_concurrentes)
async with aiohttp.ClientSession() as session:
return await asyncio.gather(*[extraer_titulo(session, u, sem) for u in urls])
titulos = asyncio.run(main(urls, max_concurrentes=20))
💡 ¿Asyncio te suena raro? Mira asyncio en Python desde cero.
Semaphore(20) evita machacar el servidor con 1000 peticiones simultáneas. Importante para no acabar baneado.
Cuando el HTML no tiene los datos (JavaScript-rendered)
Hay webs que cargan el contenido con JavaScript después del HTML inicial. Si haces requests.get() y los datos no aparecen, esto es lo que pasa.
Soluciones:
- Buscar la API que llama el JS. Abre DevTools → Network → recarga. Verás llamadas a JSON. Llama tú directamente esa API. La forma mejor.
PlaywrightoSelenium. Renderizadores de navegador completo. Más lento, más complejo, pero funciona con cualquier web.requests-htmly similar. Renderizadores más ligeros para casos sencillos.
pip install playwright
playwright install
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://web-con-js.com")
html = page.content()
browser.close()
# Ahora HTML sí tiene el contenido renderizado por JS
Buenas prácticas
- Respeta
robots.txty los TOS. No es opcional. - Identifícate con un User-Agent honesto. Algunos sitios pueden incluir su email para que les contactes en caso de problema.
- Pon delays entre peticiones.
time.sleep(1)entre requests es un mínimo razonable. - Usa caché en disco. Si scrapeas la misma página varias veces durante el desarrollo, cachea el HTML. Le ahorras tráfico al servidor y aceleras tus tests.
- Maneja errores robustamente. Páginas pueden cambiar HTML, dar 503 temporalmente, o redirigir.
try/excepty reintentos con backoff exponencial. - Limita la concurrencia.
Semaphorecon valor sensato (5-20). Más es DDoS.
Errores típicos al empezar
# 1. No comprobar status code
r = requests.get(url)
soup = BeautifulSoup(r.text, "lxml") # ❌ si fue 404, parseas página de error
# Mejor:
r.raise_for_status()
# 2. Sin User-Agent
requests.get(url) # algunos sitios devuelven 403
# Mejor:
requests.get(url, headers={"User-Agent": "Mozilla/5.0 ..."})
# 3. Sin timeout
requests.get(url) # puede colgarse infinito
# Mejor:
requests.get(url, timeout=10)
# 4. find().get_text() cuando find() devuelve None
elemento = soup.find("a", class_="no-existe")
elemento.get_text() # ❌ AttributeError: 'NoneType'
# Mejor:
if elemento:
elemento.get_text()
# 5. find_all sin verificar si está vacío
posts = soup.find_all("article")
for p in posts:
titulo = p.find("h2").get_text() # ❌ si algún <article> no tiene h2
# 6. No esperar entre peticiones
for url in mil_urls:
requests.get(url) # ❌ DDoS no intencional
# Mejor:
import time
for url in mil_urls:
requests.get(url)
time.sleep(0.5)
# 7. Confiar en `string` cuando hay tags hijos
soup.body.string # devuelve None si el body tiene varios hijos
# Usa get_text() siempre
# 8. Asumir que el HTML no cambia
# Las webs cambian su HTML cada cierto tiempo. Tus scrapers se romperán.
# Defensa: tests + alertas cuando la cantidad de resultados baje drásticamente.
Resumen
| Operación | Sintaxis |
|---|---|
| Descargar HTML | requests.get(url, headers=..., timeout=10) |
| Verificar respuesta | r.raise_for_status() |
| Parsear | BeautifulSoup(r.text, "lxml") |
| Por etiqueta | soup.find_all("a") |
| Por clase | soup.find_all("a", class_="post-title") |
| Por CSS selector | soup.select("article.post h2 a") |
| Texto | elemento.get_text(strip=True) |
| Atributo | elemento["href"] o elemento.get("href") |
| Concurrencia | asyncio + aiohttp + Semaphore |
| JS-rendered | Playwright o llamar la API real del backend |
¿Te ha valido esto?
Si te ha resultado útil, llévate el cheatsheet de Python en PDF — 6 páginas con tipos, condicionales, bucles, estructuras de datos, funciones y los errores típicos. Para tener al lado mientras programas. Gratis.
Sin spam. Email + cheatsheet + un correo por semana con tutoriales nuevos.
Tu siguiente paso
Si has llegado hasta aquí, ya tienes lo necesario para escribir tu primer scraper útil. Empieza por algo sencillo (extraer titulares de una web pública), respeta los TOS, no machaques al servidor y guarda los datos en CSV/JSON para usarlos.
Si quieres aprender Python desde la base hasta proyectos reales con scraping, automatización y deploy, en El Pythonista lo enseño paso a paso.
Un abrazo,
Oscar
¿Quieres aprender Python en orden, no a saltos?
Esto que has leído es solo una pieza. En El Pythonista lo verás todo encadenado: 11 módulos, 37+ horas de vídeo, 734 actividades y un proyecto real (MovieTracker) que crece contigo desde la primera variable hasta el deploy a producción.
37+ horas · 734 actividades · Proyecto real · Acceso de por vida · 14 días de garantía
