Lección 22 de 29Módulo 5: Web Scraping y APIs

22. Introducción a Web Scraping con BeautifulSoup

Parsea HTML, extrae datos y navega el DOM

25 minutos

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.