22. Introducción a Web Scraping con BeautifulSoup
Parsea HTML, extrae datos y navega el DOM
Aprende a extraer datos de sitios web usando Python. El web scraping te permite recolectar información de miles de páginas automáticamente para análisis de competidores, monitoreo de precios, sentiment analysis y más.
¿Por qué web scraping?
En análisis de datos, muchas veces los datos que necesitas están en sitios web, no en bases de datos o APIs:
- Monitoreo de competidores: Precios, productos, promociones
- Social listening: Menciones de marca en foros y blogs
- Lead generation: Datos de contacto de prospectos
- Research de mercado: Reseñas de productos, tendencias
- Agregación de contenido: Noticias, artículos, eventos
Ejemplo real: Una startup de e-commerce usa scraping para monitorear precios de 50 competidores diariamente, ajustando sus propios precios automáticamente.
Instalación y setup
# Instalar librerías necesarias
# pip install requests beautifulsoup4 lxml pandas
import requests
from bs4 import BeautifulSoup
import pandas as pd
import time
print("Librerías importadas exitosamente")
Fundamentos de HTML
Antes de scrap ear, necesitas entender HTML básico:
<!-- Estructura HTML básica -->
<html>
<head><title>Mi Página</title></head>
<body>
<h1 class="titulo-principal">Bienvenido</h1>
<div class="producto" id="prod-001">
<h2>Laptop HP</h2>
<span class="precio">$799</span>
<p class="descripcion">Laptop profesional</p>
</div>
</body>
</html>
Conceptos clave:
- Tags (etiquetas):
<div>,<h1>,<span> - Atributos:
class="precio",id="prod-001" - Jerarquía: Tags dentro de otros tags (DOM tree)
Tu primer web scraper
Ejemplo 1: Scraping básico
# URL de prueba (página estática de ejemplo)
url = "https://books.toscrape.com/" # Sitio educativo para practicar scraping
# 1. Hacer request HTTP
response = requests.get(url)
print(f"Status code: {response.status_code}") # 200 = éxito
# 2. Parsear HTML con BeautifulSoup
soup = BeautifulSoup(response.content, 'html.parser')
# 3. Explorar el contenido
print(f"Título de la página: {soup.title.text}")
# 4. Encontrar todos los libros (están en tags <article>)
libros = soup.find_all('article', class_='product_pod')
print(f"\nLibros encontrados: {len(libros)}")
# 5. Extraer información del primer libro
primer_libro = libros[0]
titulo = primer_libro.h3.a['title']
precio = primer_libro.find('p', class_='price_color').text
print(f"\nPrimer libro:")
print(f" Título: {titulo}")
print(f" Precio: {precio}")
Ejemplo 2: Scraping de lista completa
def scrape_books(url, num_paginas=1):
"""
Extrae información de libros de books.toscrape.com
"""
all_books = []
for page in range(1, num_paginas + 1):
# Construir URL de la página
page_url = f"{url}catalogue/page-{page}.html" if page > 1 else url
print(f"Scrapeando página {page}...")
response = requests.get(page_url)
if response.status_code != 200:
print(f"Error en página {page}: {response.status_code}")
continue
soup = BeautifulSoup(response.content, 'html.parser')
libros = soup.find_all('article', class_='product_pod')
for libro in libros:
# Extraer datos
titulo = libro.h3.a['title']
precio_texto = libro.find('p', class_='price_color').text
precio = float(precio_texto.replace('£', '').strip())
# Rating (convertir "Three" a 3)
rating_class = libro.find('p', class_='star-rating')['class'][1]
rating_map = {'One': 1, 'Two': 2, 'Three': 3, 'Four': 4, 'Five': 5}
rating = rating_map.get(rating_class, 0)
# Disponibilidad
disponibilidad = libro.find('p', class_='instock availability').text.strip()
all_books.append({
'titulo': titulo,
'precio': precio,
'rating': rating,
'disponibilidad': disponibilidad
})
# Ser respetuoso: esperar entre requests
time.sleep(1)
return pd.DataFrame(all_books)
# Ejecutar scraper
df_books = scrape_books("https://books.toscrape.com/", num_paginas=3)
print(f"\nLibros scrapeados: {len(df_books)}")
print(df_books.head(10))
# Análisis rápido
print(f"\nPrecio promedio: £{df_books['precio'].mean():.2f}")
print(f"Libro más caro: {df_books.loc[df_books['precio'].idxmax(), 'titulo']} (£{df_books['precio'].max():.2f})")
print(f"\nDistribución de ratings:")
print(df_books['rating'].value_counts().sort_index())
Navegando el DOM tree
find() vs. find_all()
# find() - encuentra el PRIMER elemento
primer_titulo = soup.find('h1')
print(primer_titulo.text)
# find_all() - encuentra TODOS los elementos
todos_los_parrafos = soup.find_all('p')
print(f"Total de párrafos: {len(todos_los_parrafos)}")
# Con clase específica
precios = soup.find_all('p', class_='price_color')
for precio in precios[:5]: # Primeros 5
print(precio.text)
# Con atributos múltiples
div_especifico = soup.find('div', {'class': 'producto', 'id': 'prod-001'})
Selectores CSS (más potentes)
# select() usa selectores CSS (como jQuery)
# Por clase
productos = soup.select('.product_pod')
# Por ID
elemento_id = soup.select('#prod-001')
# Descendientes
titulos_en_article = soup.select('article h3')
# Combinaciones
precio_en_producto = soup.select('article.product_pod .price_color')
# Selectores complejos
libros_con_rating_alto = soup.select('article.product_pod .star-rating.Four, article.product_pod .star-rating.Five')
print(f"Libros con rating 4-5: {len(libros_con_rating_alto)}")
Navegación relacional
# Dado un elemento, navegar a parientes/hermanos/hijos
# Ejemplo: encontrar precio del libro conociendo solo el título
titulo = soup.find('h3', text=lambda t: 'Sapiens' in t if t else False)
if titulo:
# Padre (parent)
article = titulo.parent.parent # h3 -> a -> article
# Hermanos (siblings)
precio = article.find('p', class_='price_color')
print(f"Precio de Sapiens: {precio.text if precio else 'No encontrado'}")
# Hijos directos (children)
article = soup.find('article')
for child in article.children:
if child.name: # Ignorar texto blanco
print(child.name)
Extracción de datos específicos
Extraer atributos
# Extraer href de links
links = soup.find_all('a', href=True)
for link in links[:5]:
print(f"Texto: {link.text.strip()}, URL: {link['href']}")
# Extraer src de imágenes
imagenes = soup.find_all('img')
for img in imagenes[:5]:
print(f"Alt: {img.get('alt', 'Sin alt')}, Src: {img['src']}")
# Extraer data attributes
elemento = soup.find('div', {'data-id': '123'})
if elemento:
data_id = elemento['data-id']
print(f"Data ID: {data_id}")
Limpieza de texto
# Texto sucio con espacios/saltos de línea
texto_sucio = soup.find('p').text
# Limpiar
texto_limpio = ' '.join(texto_sucio.split()) # Elimina espacios extras
texto_limpio = texto_limpio.strip() # Elimina espacios al inicio/final
print(f"Original: '{texto_sucio}'")
print(f"Limpio: '{texto_limpio}'")
# Función de limpieza reutilizable
def limpiar_texto(texto):
"""Limpia texto extraído de HTML"""
if not texto:
return ""
return ' '.join(texto.strip().split())
# Uso
titulo = limpiar_texto(soup.find('h1').text)
Manejo de errores
def scrape_seguro(url):
"""Scraper con manejo robusto de errores"""
try:
# 1. Request con timeout
response = requests.get(url, timeout=10)
response.raise_for_status() # Lanza excepción si status != 200
# 2. Parsear
soup = BeautifulSoup(response.content, 'html.parser')
# 3. Extraer con verificaciones
titulo = soup.find('h1')
if titulo:
titulo_texto = limpiar_texto(titulo.text)
else:
titulo_texto = "Sin título"
precios = soup.find_all('span', class_='precio')
if precios:
lista_precios = [float(p.text.replace('$', '')) for p in precios]
else:
lista_precios = []
return {
'status': 'success',
'titulo': titulo_texto,
'precios': lista_precios
}
except requests.exceptions.Timeout:
return {'status': 'error', 'mensaje': 'Timeout - sitio muy lento'}
except requests.exceptions.HTTPError as e:
return {'status': 'error', 'mensaje': f'Error HTTP: {e}'}
except Exception as e:
return {'status': 'error', 'mensaje': f'Error inesperado: {e}'}
# Uso
resultado = scrape_seguro("https://books.toscrape.com/")
if resultado['status'] == 'success':
print(f"Título: {resultado['titulo']}")
else:
print(f"Error: {resultado['mensaje']}")
Proyecto práctico: Scraper de noticias
def scrape_noticias_hacker_news():
"""
Scraper de Hacker News (sitio permitido para scraping educativo)
"""
url = "https://news.ycombinator.com/"
response = requests.get(url)
soup = BeautifulSoup(response.content, 'html.parser')
noticias = []
# Hacker News tiene estructura: <tr class="athing">
stories = soup.find_all('tr', class_='athing')
for story in stories[:30]: # Top 30
# Título y link
title_cell = story.find('span', class_='titleline')
if not title_cell:
continue
link_tag = title_cell.find('a')
titulo = link_tag.text
url_noticia = link_tag['href']
# Puntos y comentarios están en el siguiente <tr>
subtext = story.find_next_sibling('tr')
if subtext:
score_tag = subtext.find('span', class_='score')
puntos = int(score_tag.text.split()[0]) if score_tag else 0
comments_tag = subtext.find_all('a')[-1]
comentarios_texto = comments_tag.text
comentarios = int(comentarios_texto.split()[0]) if comentarios_texto[0].isdigit() else 0
else:
puntos = 0
comentarios = 0
noticias.append({
'titulo': titulo,
'url': url_noticia,
'puntos': puntos,
'comentarios': comentarios
})
return pd.DataFrame(noticias)
# Ejecutar
df_noticias = scrape_noticias_hacker_news()
print(df_noticias.head(10))
# Análisis
print(f"\nNoticia más popular:")
top = df_noticias.loc[df_noticias['puntos'].idxmax()]
print(f" {top['titulo']}")
print(f" Puntos: {top['puntos']}, Comentarios: {top['comentarios']}")
# Exportar
df_noticias.to_csv('hacker_news_top_stories.csv', index=False)
print("\nDatos guardados en hacker_news_top_stories.csv")
Buenas prácticas y ética
1. Respetar robots.txt
# Verificar robots.txt antes de scrapear
import urllib.robotparser
def puede_scrapear(url, user_agent='*'):
"""Verifica si está permitido scrapear según robots.txt"""
rp = urllib.robotparser.RobotFileParser()
robots_url = url.rstrip('/') + '/robots.txt'
rp.set_url(robots_url)
rp.read()
return rp.can_fetch(user_agent, url)
# Ejemplo
url_test = "https://books.toscrape.com/"
if puede_scrapear(url_test):
print("✅ Scraping permitido")
else:
print("❌ Scraping NO permitido por robots.txt")
2. Rate limiting (no sobrecargar servidores)
import time
from datetime import datetime
class RateLimiter:
"""Limita requests por segundo"""
def __init__(self, max_per_second=1):
self.max_per_second = max_per_second
self.last_request = None
def wait_if_needed(self):
if self.last_request:
elapsed = (datetime.now() - self.last_request).total_seconds()
wait_time = (1.0 / self.max_per_second) - elapsed
if wait_time > 0:
time.sleep(wait_time)
self.last_request = datetime.now()
# Uso
limiter = RateLimiter(max_per_second=2) # Máx 2 requests/segundo
urls = ["https://example.com/page1", "https://example.com/page2"]
for url in urls:
limiter.wait_if_needed()
# response = requests.get(url)
print(f"Request a {url}")
3. User-Agent (identificarte correctamente)
# Usar User-Agent descriptivo
headers = {
'User-Agent': 'MiBot/1.0 (contact@example.com; Educational purposes)',
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'es-ES,es;q=0.9'
}
response = requests.get(url, headers=headers, timeout=10)
Checklist ético del web scraping
- Verificar robots.txt
- Usar rate limiting (máx 1-2 requests/seg)
- Identificarte con User-Agent descriptivo
- Preferir APIs oficiales si existen
- No scrapear datos personales sensibles
- Respetar copyright y términos de servicio
- Cachear resultados (no re-scrapear innecesariamente)
- Manejar errores gracefully (sin reintentos infinitos)
Resumen de la lección
✅ BeautifulSoup parsea HTML y permite navegar el DOM tree ✅ find()/find_all() buscan elementos por tag, clase, atributos ✅ select() usa selectores CSS para búsquedas complejas ✅ Limpieza de texto es esencial para datos utilizables ✅ Manejo de errores robusto previene crashes ✅ Ética: robots.txt, rate limiting, User-Agent ✅ Web scraping es legal si respetas términos de servicio
🎯 Próxima lección: 23. Consumo de APIs REST
En la siguiente lección aprenderás a consumir APIs REST profesionalmente: autenticación, paginación, manejo de errores y rate limits. ¡Nos vemos! 🚀
¿Completaste esta lección?
Marca esta lección como completada. Tu progreso se guardará en tu navegador.