BeautifulSoup es la librería estándar de Python para el parseo de HTML y para extraer datos de páginas web. No descarga páginas — eso lo hace requests — y no ejecuta JavaScript. Lo que hace, mejor que cualquier otra cosa de su tamaño, es convertir markup desordenado del mundo real en un árbol que puedes buscar con dos líneas de código. Este tutorial te lleva desde pip install hasta un scraper completo con paginación que escribe CSV, usando un mismo sitio de ejemplo de principio a fin, y al final es honesto sobre dónde se acaba BeautifulSoup y a qué recurrir después. Es un capítulo de nuestra guía más amplia de web scraping con Python — empieza ahí si quieres el mapa completo.
Setup: instalar bs4, lxml y requests
Todo en este tutorial necesita tres paquetes. Fíjate en que el nombre en pip es beautifulsoup4, no bs4 — el import es bs4, el paquete es beautifulsoup4:
python -m pip install beautifulsoup4 lxml requests
BeautifulSoup en sí no parsea HTML — delega en un backend de parser y te da una API amigable sobre el resultado. Eliges el backend cuando construyes la soup:
| Parser | Argumento del constructor | Cuándo usarlo |
|---|---|---|
| html.parser | "html.parser" | Viene integrado en Python, cero dependencias. Bien para trabajos pequeños y scripts rápidos. |
| lxml | "lxml" | Basado en C y mucho más rápido. La opción por defecto en cuanto scrapeas más de un puñado de páginas. |
| html5lib | "html5lib" | El más lento, pero parsea markup roto exactamente como lo hace un navegador. Un último recurso para HTML patológico. |
Dos reglas prácticas: usa "lxml" salvo que tengas un motivo para no hacerlo, y pasa siempre el parser explícitamente. Si lo omites, BeautifulSoup elige el mejor parser instalado en esa máquina — lo que significa que el mismo script puede parsear markup muy roto de forma ligeramente distinta en tu laptop y en tu servidor.
Tu primera soup
Usaremos books.toscrape.com para cada ejemplo — una librería sandbox construida específicamente para practicar scraping, con 1.000 libros repartidos en 50 páginas paginadas. Obtén la página de inicio y parséala:
import requests
from bs4 import BeautifulSoup
resp = requests.get("https://books.toscrape.com/")
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "lxml")
print(soup.title.get_text(strip=True))
# All products | Books to Scrape - Sandbox
Ahí hay una decisión deliberada: pasamos resp.content (bytes crudos), no resp.text. Este sitio — como muchos reales — omite el charset en su header Content-Type, así que resp.text decodifica la página mal y cada £ se convierte en £. Dado bytes, BeautifulSoup olfatea la codificación a partir del propio documento y la acierta. Más sobre esto en la tabla de errores más abajo.
El objeto soup es el documento entero como un árbol buscable. Cada libro de la página vive en un tag article con la clase product_pod — ese único hecho es la base de todo lo que sigue.
find y find_all
find_all devuelve una lista de todos los elementos que coinciden; find devuelve el primer match o None:
books = soup.find_all("article", class_="product_pod")
print(len(books)) # 20 — one per book on the page
first = soup.find("article", class_="product_pod")
Fíjate en class_ con guion bajo al final, porque class es una palabra reservada en Python. Puedes filtrar por cualquier atributo de la misma forma (soup.find_all("a", href=True)), pasar un dict para nombres de atributo incómodos (attrs={"data-id": "42"}), limitar resultados con limit=5, o pasar recursive=False para buscar solo entre los hijos directos. Si ves findAll en algún tutorial viejo, es la forma anterior a la 4.0 y ya deprecada de find_all — el mismo método.
Selectores CSS: select y select_one
Desde bs4 4.7, el soporte completo de selectores CSS viene incluido a través de la librería soupsieve. select devuelve una lista, select_one devuelve el primer match o None:
books = soup.select("article.product_pod")
first_price = soup.select_one("article.product_pod p.price_color")
print(first_price.get_text(strip=True)) # £51.77
Todo lo que puedas escribir en el buscador de las dev tools del navegador funciona aquí: combinadores de descendencia (section ol li), selectores de atributo (a[href^="catalogue/"]), pseudo-clases (li:nth-of-type(3)). find_all y select se solapan casi por completo — los selectores son más concisos para rutas anidadas, find_all es más fácil cuando filtras por función o regex. Elige un estilo por proyecto para que el código se mantenga legible.
Atributos y texto
Los tags se comportan como diccionarios para los atributos. Los corchetes lanzan KeyError si falta el atributo; .get() devuelve None en su lugar:
link = first.h3.a # dot access walks to the first matching child
print(link["href"]) # catalogue/a-light-in-the-attic_1000/index.html
print(link["title"]) # A Light in the Attic
print(link.get("rel")) # None — no such attribute, no crash
Fíjate en que la página trunca el texto visible del link ("A Light in the ...") pero guarda el título completo en el atributo title — los sitios reales esconden datos en atributos constantemente, así que revísalos antes de pelearte con el texto visible. Para texto, get_text(strip=True) colapsa los espacios en blanco y casi siempre es lo que quieres; el .get_text() sin más preserva el espacio en blanco crudo entre tags anidados.
Navegar hacia arriba y hacia los lados
A veces el elemento que puedes seleccionar no es el elemento que necesitas — encuentras un título, pero quieres el precio que está justo al lado. Cada tag tiene .parent, .children, y ganchos para hermanos:
title_link = soup.find("a", title="A Light in the Attic")
pod = title_link.find_parent("article") # climb to the container
price = pod.select_one("p.price_color") # search back down
availability = pod.select_one("p.instock.availability")
print(price.get_text(strip=True), "|", availability.get_text(strip=True))
# £51.77 | In stock
find_parent sube hasta encontrar un match; find_next_sibling / find_previous_sibling se mueven a los lados. El patrón fiable para páginas de listado es exactamente lo que hace este fragmento: localiza el contenedor que se repite, y luego busca dentro de él — nunca scrapees precios y títulos en dos consultas globales separadas esperando que las listas coincidan.
Tarea real: listado de productos a CSV
Aquí tienes un script completo y ejecutable que scrapea una página de listado en una lista de dicts y escribe CSV. El único campo incómodo es la valoración en estrellas, que el sitio codifica como nombre de clase (star-rating Three):
import csv
import requests
from bs4 import BeautifulSoup
RATINGS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}
def parse_book(pod):
"""Turn one article.product_pod into a dict."""
link = pod.h3.a
rating_classes = pod.select_one("p.star-rating")["class"] # ["star-rating", "Three"]
return {
"title": link["title"],
"price": pod.select_one("p.price_color").get_text(strip=True),
"rating": RATINGS.get(rating_classes[-1]),
"in_stock": "In stock" in pod.select_one("p.availability").get_text(),
"url": link["href"],
}
resp = requests.get("https://books.toscrape.com/")
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "lxml")
books = [parse_book(pod) for pod in soup.select("article.product_pod")]
with open("books.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=books[0].keys())
writer.writeheader()
writer.writerows(books)
print(f"Wrote {len(books)} books to books.csv")
Dos hábitos de este script vale la pena robar: una función parse_book que se hace cargo de un único contenedor (así un cambio de layout rompe una función, no todo tu script), y construir dicts antes de tocar el CSV writer, para que la estructura de datos sea testeable por sí sola. Si tu destino es una hoja de cálculo en vez de un archivo CSV, esa misma lista de dicts entra directamente en el workflow de nuestra guía scrapear un sitio web a Excel.
Paginación y cortesía
El sandbox tiene 50 páginas, cada una enlaza a la siguiente vía li.next a. Sigue ese link hasta que desaparezca — y sé educado al hacerlo: identifícate con un header User-Agent en vez del python-requests/x.y por defecto, y duerme entre requests para no bombardear el servidor. La mayoría de los sitios imponen rate limits, y los sitios que no lo hacen igualmente merecen esa cortesía:
import csv
import time
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
HEADERS = {"User-Agent": "books-tutorial/1.0 (learning BeautifulSoup)"}
def scrape_all(start_url):
url, books = start_url, []
while url:
resp = requests.get(url, headers=HEADERS, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.content, "lxml")
books += [parse_book(pod) for pod in soup.select("article.product_pod")]
next_link = soup.select_one("li.next a") # None on the last page
url = urljoin(url, next_link["href"]) if next_link else None
time.sleep(1) # be polite
return books
books = scrape_all("https://books.toscrape.com/")
print(f"Scraped {len(books)} books") # 1000
urljoin importa más de lo que parece: el href de la página siguiente es relativo (catalogue/page-2.html), y — una rareza real de este sitio — el prefijo de ruta cambia entre la página 1 y la página 2. Resolver cada link contra la URL que realmente obtuviste maneja eso automáticamente; la concatenación de strings devolvería 404 en la página 3.
Errores comunes y cómo arreglarlos
Cuatro fallos explican la mayor parte del tiempo de debugging con BeautifulSoup:
| Error | Causa | Solución |
|---|---|---|
AttributeError: 'NoneType' object has no attribute 'get_text' | find / select_one no encontró nada, y llamaste a un método sobre el None que devolvió | Revisa si es None antes de dereferenciar, o haz explícitos los campos ausentes: el.get_text(strip=True) if el else None |
El selector devuelve [] en una página que claramente tiene los datos | Nombre de clase incorrecto, el contenido está dentro de un iframe, o se renderiza con JavaScript y no está en el HTML crudo | Imprime resp.text (o soup.prettify()) y búscalo — debuguea siempre contra lo que Python obtuvo, no contra lo que muestra tu navegador |
Mojibake: £ o ’ donde debería haber un £ o un apóstrofo | El servidor omitió (o mintió sobre) el charset, así que resp.text decodificó los bytes mal — el propio books.toscrape.com dispara esto | Pasa bytes crudos y deja que bs4 olfatee la codificación: BeautifulSoup(resp.content, "lxml"), como hace cada script de este post — o fija primero resp.encoding = resp.apparent_encoding |
| El script funciona en local, falla en el servidor | No se especificó parser, así que cada máquina usó el mejor que tenía instalado — y cada uno repara el markup roto de forma distinta | Pasa siempre el parser explícitamente: BeautifulSoup(html, "lxml") |
La segunda fila merece énfasis porque es la que tarde o temprano atrapa a todo el mundo: tu navegador no es tu scraper. Las dev tools muestran el DOM después de que el JavaScript se ha ejecutado; requests solo ve la respuesta HTML inicial. Cuando discrepan, ningún selector te salva — lo que nos lleva a la parte honesta.
Cuando BeautifulSoup no basta
BeautifulSoup tiene dos límites duros, y conocerlos te ahorra días.
Páginas renderizadas con JavaScript. Si un sitio construye su contenido en el lado del cliente — feeds de scroll infinito, storefronts en React, dashboards — el HTML que descarga requests es una cáscara casi vacía, y BeautifulSoup solo puede parsear lo que realmente está ahí. Necesitas un navegador headless que ejecute el JavaScript primero. Nuestro tutorial de web scraping con Playwright cubre la forma moderna de hacerlo (y Selenium la más antigua); un híbrido común es dejar que el navegador renderice y luego pasarle page.content() a BeautifulSoup para el parseo que ya conoces.
Sistemas anti-bot a gran escala. El sandbox nunca te bloquea. Amazon, Google y la mayoría de los sitios comerciales sí lo harán — con CAPTCHAs, fingerprinting TLS y bloqueos de IP — en cuanto tu tráfico deje de parecerse al de una persona. Resolver eso por tu cuenta implica pools de proxies, rotación de fingerprints y lógica de reintentos: un segundo proyecto de ingeniería atornillado a tu parser. La alternativa es una scraping API estructurada que se encarga del bloqueo y devuelve JSON, así que directamente no hay HTML que parsear:
import requests
resp = requests.get(
"https://api.crawlora.net/api/v1/amazon/search",
params={"k": "mechanical keyboard"},
headers={"x-api-key": "YOUR_API_KEY"},
)
for item in resp.json()["data"]:
print(item["asin"], item["price"], item["title"])
Ese es el endpoint de búsqueda de Amazon de Crawlora — uno de un catálogo de endpoints por plataforma documentados en los docs de la API. La facturación es pay-on-success: solo pagas por respuestas 2xx exitosas, y el plan gratuito incluye 2.000 créditos/mes, sin tarjeta. Para un marco más completo sobre cuándo parsear HTML tú mismo frente a cuándo llamar a una API, mira web scraping vs API; pricing tiene las cifras.
Nada de esto resta valor a lo que construiste en este tutorial. Para sitios estáticos, públicos y de tamaño razonable — que es buena parte de la web — requests más BeautifulSoup sigue siendo la herramienta más simple que funciona, y cada habilidad de arriba se transfiere directamente a los setups renderizados por navegador y asistidos por API a los que graduarás.
Sáltate el parseo en los sitios difíciles
Endpoints por plataforma documentados, JSON normalizado, proxies y reintentos gestionados. Paga solo por respuestas 2xx exitosas — 2.000 créditos/mes, sin tarjeta.
Lectura relacionada: la guía pilar web scraping con Python · web scraping con Playwright para sitios cargados de JavaScript · Selenium web scraping · scrapear un sitio web a Excel · qué es el parseo de HTML.
Preguntas frecuentes
¿Para qué se usa BeautifulSoup?
BeautifulSoup es una librería de Python para parsear HTML y XML y extraer datos de ellos. Convierte el markup en un árbol buscable que consultas con find, find_all o selectores CSS. No descarga páginas (combínala con requests) y no ejecuta JavaScript.
¿Cuál es la diferencia entre find_all y select en BeautifulSoup?
find_all busca por nombre de tag, clase y atributos usando argumentos de Python, mientras que select usa strings de selectores CSS a través del motor soupsieve incluido. Se solapan casi por completo: los selectores son más concisos para rutas anidadas, find_all es más fácil para filtrar por función o regex. find_all devuelve una lista; find y select_one devuelven el primer match o None.
¿Es BeautifulSoup más rápido que lxml?
No — lxml es la librería más rápida, y BeautifulSoup incluso puede usar lxml como backend de parser. BeautifulSoup(html, 'lxml') te da la velocidad de lxml basada en C con la API más amigable de BeautifulSoup. El html.parser integrado de Python no necesita instalación adicional, pero es notablemente más lento en trabajos grandes.
¿Puede BeautifulSoup scrapear sitios renderizados con JavaScript?
No. BeautifulSoup solo parsea el HTML que tu cliente HTTP descargó; nunca ejecuta JavaScript. Para páginas renderizadas del lado del cliente, usa un navegador headless como Playwright para renderizar primero y luego pasa ese HTML renderizado a BeautifulSoup — o usa una scraping API que devuelva JSON estructurado directamente.
¿Por qué BeautifulSoup devuelve None o una lista vacía?
O el selector está mal, o los datos no están en el HTML que obtuvo tu script — a menudo porque se renderizan con JavaScript o están dentro de un iframe. Debuguea contra resp.text (lo que Python realmente descargó), no contra las dev tools del navegador, que muestran el DOM después de que se ejecutó el JavaScript.
¿Qué parser debería usar con BeautifulSoup?
Usa lxml por velocidad en cualquier carga de trabajo real, html.parser si no puedes instalar dependencias, y html5lib solo cuando necesites un comportamiento idéntico al del navegador con markup muy roto. Pasa siempre el parser explícitamente — si lo omites, cada máquina elige un backend distinto que puede parsear el HTML roto de forma diferente.
¿Sigo necesitando BeautifulSoup si uso una scraping API?
Para plataformas soportadas, casi nunca — una scraping API estructurada como Crawlora devuelve JSON normalizado, así que no hay HTML que parsear. BeautifulSoup sigue siendo útil para sitios estáticos que scrapeas directamente y para parsear el HTML renderizado por un navegador headless.
