Volver al blog
Guías
Sorin-Gabriel MaricaLast updated on May 12, 202619 min read

Tutorial de BeautifulSoup: Construir un raspador real de Python desde cero

Tutorial de BeautifulSoup: Construir un raspador real de Python desde cero
En resumen: Este tutorial de BeautifulSoup te guía paso a paso en la creación de un rastreador completo en Python, desde pip install hasta un script optimizado que pagina Hacker News, exporta a CSV y JSON, y se comporta lo suficientemente bien como para no ser bloqueado. Todos los fragmentos de código son ejecutables, y señalamos los momentos exactos en los que BeautifulSoup no es la herramienta adecuada.

Si sabes escribir un for bucle en Python y alguna vez te has quedado mirando una página web pensando: «Quiero esos datos en una hoja de cálculo», este tutorial de BeautifulSoup está hecho para ti. Beautiful Soup es una biblioteca de Python para analizar HTML y XML en un árbol que puedes consultar con métodos familiares, al estilo de jQuery. No recurre páginas, no ejecuta JavaScript y no pretende ser un navegador. Simplemente toma el marcado sin procesar y te ofrece una API limpia para extraer las partes que te interesan.

El plan es concreto. Configuraremos un entorno nuevo, obtendremos una página de listados real con la requests biblioteca, la analizaremos con BeautifulSoup, seleccionaremos elementos con selectores tanto find_all selectores CSS, seguiremos la paginación a través de varias páginas y escribiremos los resultados en CSV y JSON. Por el camino incorporaremos la rotación de user-agent, los reintentos y la limitación de velocidad, porque un tutorial que ignora las defensas antibots se viene abajo en cuanto lo aplicas a un sitio web real. Al final tendrás un scraper ejecutable de copiar y pegar y una idea clara de cuándo seguir usando BeautifulSoup y cuándo dar el salto a una herramienta más potente.

Qué es BeautifulSoup y cuándo recurrir a él

BeautifulSoup (el bs4 paquete de PyPI, actualmente en la línea 4.x) es una biblioteca de análisis sintáctico, no un rastreador ni un navegador. Le pasas una cadena de HTML y te devuelve un árbol de análisis que puedes recorrer por etiqueta, atributo, selector CSS o relación. Esa es toda su función. Todo lo relacionado con las solicitudes HTTP, las cookies, las sesiones, la ejecución de JavaScript o las colas es problema de otros, y esa separación es precisamente la razón por la que BeautifulSoup sigue siendo la opción predeterminada para páginas estáticas más de una década después de su lanzamiento.

Ayuda situarlo en un contexto. requests Además, BeautifulSoup es la configuración más ligera posible: es ideal cuando los datos que quieres ya están en el HTML que devuelve el servidor y estás rastreando unas pocas páginas en lugar de un millón. Scrapy es la herramienta adecuada cuando necesitas un marco de rastreo completo con pipelines, deduplicación y concurrencia. Selenium y Playwright son las herramientas adecuadas cuando la página es una aplicación de una sola página que solo ensambla su contenido después de que se ejecute JavaScript. Si puedes hacer un curl a la URL y ver tus datos en el cuerpo de la respuesta, BeautifulSoup es casi siempre la respuesta más sencilla.

Configuración del entorno: Python, Requests y BeautifulSoup4

Utiliza un entorno virtual para que este proyecto no contamine tus paquetes globales del sitio. Cualquier versión de Python a partir de la 3.9 funcionará bien para este tutorial de BeautifulSoup, y fijar las versiones garantiza que los fragmentos de código aquí sean reproducibles.

python -m venv .venv
source .venv/bin/activate   # on Windows: .venv\Scripts\activate
pip install requests==2.32.3 beautifulsoup4==4.12.3 lxml==5.2.2

requests se encarga de la capa HTTP, beautifulsoup4 es la propia API del analizador, y lxml es un analizador opcional, pero muy recomendable, basado en C. BeautifulSoup recurre a la biblioteca estándar html.parser si no instalas lxml, pero el analizador en C es significativamente más rápido con documentos grandes y más tolerante con el marcado desordenado. Si necesitas dar soporte a entornos de Python en los que compilar extensiones en C resulta complicado, omite lxml y perderás algo de velocidad, pero no funcionalidad.

Prueba rápida en un REPL de Python:

import requests, bs4
print(requests.__version__, bs4.__version__)

Si ambas versiones se imprimen sin errores, ya estás listo. Guarda el resto del código en un archivo llamado hn_scraper.py y ejecútalo con python hn_scraper.py.

Obtención de HTML con Requests

BeautifulSoup necesita bytes para analizar. La requests biblioteca es la forma más ergonómica de obtenerlos. Elige un objetivo real al que puedas acceder sin problemas: Hacker News es la opción clásica porque la página principal es HTML sin formato renderizado por el servidor, con una estructura predecible y una protección antibots muy ligera, lo cual es ideal para aprender.

import requests

URL = "https://news.ycombinator.com/news"
HEADERS = {
    "User-Agent": "Mozilla/5.0 (compatible; LearningScraper/1.0)",
    "Accept-Language": "en-US,en;q=0.9",
}

response = requests.get(URL, headers=HEADERS, timeout=15)
response.raise_for_status()        # blows up on 4xx/5xx
html_bytes = response.content      # bytes, not str

Hay dos cosas en las que vale la pena detenerse. En primer lugar, comprueba siempre el código de estado. Un 403 silencioso que devuelva una página de «Acceso denegado» se analizará correctamente en un objeto BeautifulSoup que no contiene ninguno de los datos que realmente quieres, y perderás una tarde depurando selectores en la página equivocada. raise_for_status() hace que ese fallo sea evidente.

Segundo, da preferencia a response.content a response.text al alimentar a BeautifulSoup. .text fuerza una decodificación utilizando la codificación requests deducida de los encabezados, que a veces es errónea. .content son los bytes sin procesar, y BeautifulSoup es mucho mejor a la hora de detectar la codificación real a partir de una <meta charset> o del propio documento. La diferencia rara vez importa en sitios web solo en inglés, pero es muy importante en cuanto se extrae cualquier contenido con caracteres acentuados.

Crear un objeto BeautifulSoup y elegir un analizador

Con los bytes en mano, construye el árbol de análisis pasándolos al BeautifulSoup constructor junto con un nombre de analizador. La documentación oficial de Beautiful Soup enumera tres analizadores que vale la pena conocer.

Analizador

Velocidad

Tolerancia con el HTML dañado

Notas

html.parser

Aceptable

Bueno

Biblioteca estándar, sin necesidad de instalación.

lxml

Más rápido

Bueno

Extensión C; pip install lxml.

html5lib

Más lento

Mejor

Python puro; imita cómo los navegadores se recuperan de un marcado dañado.

Para este tutorial de BeautifulSoup utilizaremos lxml porque es rápido y hoy en día está presente en todas partes. Recurre a html5lib solo cuando un sitio tenga un HTML realmente malformado que lxml se distorsiona, y recurre a html.parser si no puedes instalar nada más allá de la biblioteca estándar.

from bs4 import BeautifulSoup

soup = BeautifulSoup(html_bytes, "lxml")
print(soup.title.string)            # "Hacker News"
print(soup.prettify()[:300])        # peek at the formatted DOM

soup.title.string funciona porque BeautifulSoup expone las etiquetas de nivel superior como atributos. get_text(strip=True) es la alternativa más segura y de uso general cuando no sabes si una etiqueta contiene texto sin formato o elementos anidados, y prettify() es muy útil durante la exploración porque te muestra el árbol indentado que estás consultando realmente.

Selección de elementos: find, find_all y select

BeautifulSoup te ofrece tres formas de localizar nodos: find, find_all, y select. find devuelve la primera coincidencia (o None). find_all devuelve una lista con todas las coincidencias. select y select_one utiliza cadenas de selector CSS, que veremos en la siguiente subsección.

Buscar por etiqueta. La forma más sencilla. soup.find_all("a") Devuelve todos los enlaces de la página.

links = soup.find_all("a")
print(len(links), "anchors found")

Buscar por clase. Utiliza la palabra clave class_ con un guión bajo al final, ya que class es una palabra reservada en Python. Esto confunde a casi todos los principiantes.

rows = soup.find_all("tr", class_="athing")          # Hacker News story rows
titles = soup.find_all("span", class_="titleline")

Buscar por id. Pasa id= directamente. Se supone que los ID son únicos, por lo que find suele ser lo que quieres.

main = soup.find(id="hnmain")

Buscar por atributo. Se puede pasar cualquier atributo arbitrario dentro de un attrs dict. Así es como se seleccionan data-* atributos, aria-* o cualquier otro elemento que no sea una etiqueta, un ID o una clase.

rows = soup.find_all("tr", attrs={"data-row-type": "story"})

Filtrar por una función. Cuando necesites una lógica que ninguna palabra clave capture, pasa una lambda. La función recibe cada etiqueta y devuelve True para conservarla.

def is_external_link(tag):
    return tag.name == "a" and tag.get("href", "").startswith("http")

external = soup.find_all(is_external_link)

También puedes pasar una lambda al string argumento para filtrar por contenido de texto. La coincidencia de subcadenas sin distinción entre mayúsculas y minúsculas es un caso de uso habitual:

python_links = soup.find_all("a", string=lambda s: s and "python" in s.lower())

Una regla empírica pragmática: utiliza find y find_all cuando la búsqueda tenga una o dos profundidades de atributos. En cuanto necesites combinar una clase, un elemento padre y una posición, cambia a selectores CSS. Son más fáciles de leer y de copiar desde las herramientas de desarrollo del navegador.

Análisis en profundidad de los selectores CSS con select() y select_one()

select() acepta las mismas cadenas de selectores CSS que se utilizan en document.querySelectorAll. Eso significa que funcionan los combinadores de descendientes, los combinadores de hijos, los selectores de atributos, las pseudoclases y los nombres de clases encadenados.

# Descendant: any .titleline inside a tr.athing, at any depth
titles = soup.select("tr.athing .titleline")

# Direct child: only immediate children
direct = soup.select("tr.athing > td.title > span.titleline")

# Attribute selector: links to PDFs
pdfs = soup.select("a[href$='.pdf']")

# Positional: every fifth story row
every_fifth = soup.select("tr.athing:nth-of-type(5n)")

# Multiple classes at once
emphasized = soup.select("span.titleline.featured")

Aquí tienes la correspondencia práctica entre las dos API.

find_all form

select form

find_all("a", class_="storylink")

select("a.storylink")

find_all("div", id="main")

select("div#main")

find_all("input", attrs={"type": "hidden"})

select("input[type='hidden']")

Los selectores no son un elemento secundario en este tutorial de BeautifulSoup, sino la principal estrategia de mantenimiento. El truco que mantiene vivos los rastreadores cuando cambia el marcado es definir tus selectores como constantes con nombre en la parte superior del módulo. Cuando el sitio renombra una clase, solo tienes que corregir una línea en lugar de buscar en todo el código.

STORY_ROW = "tr.athing"
TITLE_LINK = "span.titleline > a"
RANK = "span.rank"

Como hábito, copia un selector que funcione desde Chrome DevTools (haz clic con el botón derecho en un elemento, Copiar > Copiar selector) y, a continuación, recorta la cadena generada automáticamente hasta la versión más corta que siga identificando de forma única lo que deseas. Los selectores largos son los primeros en fallar cuando cambia el marcado; los cortos y con nombre sobreviven a pequeños rediseños.

Recorrido por el DOM: padres, hermanos e hijos

A veces, el elemento que puedes identificar claramente no es el que realmente quieres. Un patrón común: puedes seleccionar un <span class="rank"> , pero el título y el enlace se encuentran en un nodo hermano. En lugar de escribir un selector compuesto frágil, recorre el árbol.

Cada etiqueta de BeautifulSoup expone atributos de navegación:

  • .parent: la etiqueta que lo envuelve directamente.
  • .parents: un generador que devuelve todos los antepasados hasta la raíz del documento.
  • .next_sibling y .previous_sibling: los nodos adyacentes a la misma profundidad (pueden ser espacios en blanco).
  • .find_next("tag") y .find_previous("tag"): omite los nodos de espacio en blanco y busca la siguiente etiqueta real.
  • .children y .descendants: hijos directos o todos los nodos anidados.

Un ejemplo práctico. Supongamos que has recopilado todos los .titleline spans de Hacker News y quieres, para cada uno, la fila circundante más la siguiente fila (que contiene la puntuación y el autor).

for title_span in soup.select("span.titleline"):
    row = title_span.find_parent("tr")               # the .athing row
    meta_row = row.find_next_sibling("tr")           # the subtext row
    score = meta_row.find("span", class_="score")
    print(title_span.get_text(strip=True), score.get_text() if score else "-")

La disyuntiva real es la legibilidad frente a la solidez. Un selector CSS encadenado es más corto, pero recorrer el árbol suele ser más resistente cuando la página envuelve los mismos datos en diferentes contenedores según el contexto. Opta por el recorrido cuando una sola consulta no pueda expresar la relación que necesitas.

Proyecto de principio a fin: extraer la clasificación, el título y la URL de Hacker News

Es hora de dejar de mostrar fragmentos aislados y construir el núcleo del scraper. La página principal de Hacker News muestra cada noticia como una tr.athing fila, donde la clasificación se encuentra en span.rank, el título y el enlace externo se encuentran dentro de span.titleline > a, y una fila adyacente contiene la puntuación y el autor. Nuestra tarea consiste en convertir cada noticia en un diccionario.

Aquí está la primera versión del analizador. Fíjate en que no realiza ninguna consulta; acepta una cadena HTML y devuelve registros estructurados. Mantener la consulta y el análisis por separado es lo que te permite realizar pruebas unitarias del analizador con HTML de prueba sin conectarte a la red.

from bs4 import BeautifulSoup

def parse_stories(html: bytes) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    stories = []
    for row in soup.select("tr.athing"):
        rank_tag = row.select_one("span.rank")
        link_tag = row.select_one("span.titleline > a")
        if not (rank_tag and link_tag):
            continue                                # skip malformed rows
        stories.append({
            "rank": rank_tag.get_text(strip=True).rstrip("."),
            "title": link_tag.get_text(strip=True),
            "url": link_tag.get("href", ""),
            "id": row.get("id"),
        })
    return stories

Algunos detalles que son más importantes de lo que parecen. rank_tag.get_text(strip=True).rstrip(".") gestiona el punto final que Hacker News muestra después de cada clasificación ("1." se convierte en "1"). link_tag.get("href", "") devuelve la cadena vacía en lugar de generar un error KeyError si falta el atributo, que es el tipo de cambio de un solo carácter que convierte un rastreador frágil en uno robusto. Y el continue mantiene el bucle activo cuando el sitio inserta ocasionalmente una fila de anuncios o un marcador de posición patrocinado que no coincide con el esquema.

Une el analizador al recuperador:

import requests

def fetch(url: str) -> bytes:
    headers = {"User-Agent": "LearningScraper/1.0"}
    response = requests.get(url, headers=headers, timeout=15)
    response.raise_for_status()
    return response.content

if __name__ == "__main__":
    stories = parse_stories(fetch("https://news.ycombinator.com/news"))
    for story in stories[:5]:
        print(story["rank"], story["title"])

Al ejecutar esto, deberían aparecer los cinco titulares mejor posicionados tal y como aparecen en la página en este momento. Ya tienes un rastreador de una sola página en menos de treinta líneas. Las secciones restantes de este tutorial de BeautifulSoup añaden paginación, exportaciones, reintentos y los retoques que hacen que el script aguante una hora de ejecución en un sitio web real en lugar de un minuto.

Gestión de la paginación y los rastreos de varias páginas

Hacker News pagina con un parámetro de consulta: ?p=2, ?p=3, y así sucesivamente. En la parte inferior de cada página hay un <a class="morelink"> ancla que apunta a la página siguiente. Detectar esa ancla es la condición de parada más limpia, ya que funciona tanto si el sitio utiliza páginas secuenciales, tokens de cursor o parámetros de desplazamiento.

import time
from urllib.parse import urljoin

BASE = "https://news.ycombinator.com/"

def scrape_all(start_url: str, max_pages: int = 5, delay: float = 1.5) -> list[dict]:
    url = start_url
    pages_done = 0
    all_stories: list[dict] = []

    while url and pages_done < max_pages:
        html = fetch(url)
        all_stories.extend(parse_stories(html))

        soup = BeautifulSoup(html, "lxml")
        more = soup.select_one("a.morelink")
        url = urljoin(BASE, more["href"]) if more else None

        pages_done += 1
        time.sleep(delay)
    return all_stories

Tres detalles que vale la pena destacar. urljoin(BASE, more["href"]) es cómo convertir enlaces relativos como news?p=2 en una URL absoluta real, lo que requests requiere. El max_pages límite es una red de seguridad para que una condición de parada defectuosa no se ejecute indefinidamente. Y time.sleep(delay) es el limitador de frecuencia más sencillo posible; lo sustituiremos por algo más inteligente cuando lleguemos al antibloqueo.

Este patrón de paginación se generaliza mucho más allá de Hacker News. En cualquier lugar donde la página siguiente sea un ancla real en el marcado, puedes insertar un selector diferente en select_one y el resto del bucle permanece idéntico. Para sitios que paginan con desplazamiento infinito, BeautifulSoup por sí solo no servirá de ayuda, y trataremos esa limitación en la sección de JavaScript más adelante en este tutorial de BeautifulSoup.

Exportación de datos extraídos a CSV y JSON

Una vez que tengas una lista de diccionarios, guardarlos en el disco es una tarea mecánica. Los dos formatos que todo analista espera son CSV y JSON, y no hay razón para no generar ambos en el mismo flujo de trabajo.

import csv, json
from pathlib import Path

def export(records: list[dict], out_dir: str = "out") -> None:
    out = Path(out_dir)
    out.mkdir(exist_ok=True)

    csv_path = out / "stories.csv"
    with csv_path.open("w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["rank", "title", "url", "id"])
        writer.writeheader()
        writer.writerows(records)

    json_path = out / "stories.json"
    with json_path.open("w", encoding="utf-8") as f:
        json.dump(records, f, ensure_ascii=False, indent=2)

Hay algunas trampas de codificación que merecen una mención especial. Utiliza encoding="utf-8-sig" para el CSV si los datos se van a abrir en Excel en Windows, ya que la BOM es lo que le indica a Excel que el archivo está en UTF-8 (sin ella, los caracteres acentuados se muestran como caracteres sin sentido). Pasa newline="" a open al escribir CSV para evitar filas en blanco en Windows. Para JSON, ensure_ascii=False mantiene los caracteres no ASCII tal cual en lugar de \uXXXX escapes, lo que hace que la salida sea legible para el ser humano.

Para los analistas que trabajan con un portátil, pandas.DataFrame(records).to_csv("stories.csv", index=False) es la alternativa de una sola línea. Es más pesada, pero resulta agradable cuando de todos modos vas a realizar un análisis exploratorio sobre los mismos datos.

Errores comunes: elementos que faltan, codificación y errores de NoneType

El error más común con el que te encontrarás en cualquier código tutorial de BeautifulSoup es AttributeError: 'NoneType' object has no attribute 'get_text'. Eso siempre significa find o select_one devuelto None, y luego intentaste llamar a un método sobre él. La solución es comprobar siempre antes de encadenar.

# Brittle
title = row.find("span", class_="titleline").a.get_text()

# Defensive
line = row.find("span", class_="titleline")
anchor = line.find("a") if line else None
title = anchor.get_text(strip=True) if anchor else None

Dos hábitos relacionados te ahorrarán horas:

  • Usa .get(attr, default) en lugar de tag[attr]. La indexación genera un KeyError cuando falta el atributo, mientras que .get devuelve silenciosamente tu valor predeterminado y permite que el bucle continúe.
  • Utiliza siempre .get_text(strip=True) en lugar de .string. .string es None siempre que una etiqueta tenga varios hijos, lo que la hace sorprendentemente frágil.

La codificación es la segunda trampa clásica. Si le pasas a BeautifulSoup response.text y el sitio miente sobre su codificación en el Content-Type encabezado, obtienes caracteres garabateados. Al proporcionarle response.content (bytes) permite a BeautifulSoup detectar la codificación real del documento.

Por último, escribe tus selectores basándote en un archivo HTML guardado durante el desarrollo. Guarda el response.content una vez e itera localmente. Así, tu rastreador será fácil de someter a pruebas unitarias y dejarás de saturar el sitio de destino cada vez que cambies un selector.

Superar las defensas antiscraping sin dejar de ser educado

Incluso un sitio de destino «amigable» bloqueará un rastreador que lo sature con miles de solicitudes idénticas desde una misma IP. La cortesía es, en parte, una cuestión de ingeniería y, en parte, lo correcto. Cinco técnicas cubren la mayor parte de lo que necesitarás.

1. Alterna los user agents. Una huella de navegador real más un pequeño conjunto de cadenas de User-Agent realistas es suficiente para que los filtros básicos te ignoren. Elige uno por solicitud.

import random
UAS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) Safari/17.0",
    "Mozilla/5.0 (X11; Linux x86_64) Firefox/124.0",
]
headers = {"User-Agent": random.choice(UAS)}

2. Limita la frecuencia con fluctuación. Una time.sleep(1) es una huella en sí misma. Añade un jitter aleatorio para que la cadencia parezca humana.

time.sleep(random.uniform(1.0, 2.5))

3. Vuelve a intentarlo con retroceso exponencial. Los fallos transitorios (5xx, reinicios de conexión, tiempos de espera) son la norma. Envuelve las solicitudes con retroceso para que un fallo puntual no arruine la ejecución.

def fetch_with_retry(url, headers, attempts=4):
    for i in range(attempts):
        try:
            r = requests.get(url, headers=headers, timeout=15)
            if r.status_code == 200:
                return r.content
            if r.status_code in (429, 503):
                time.sleep(2 ** i)
                continue
            r.raise_for_status()
        except requests.RequestException:
            time.sleep(2 ** i)
    raise RuntimeError(f"giving up on {url}")

4. Rotar proxies. Si tu IP doméstica se satura, redirige las solicitudes a través de un conjunto de proxies residenciales o de centros de datos. requests acepta un proxies={"http": ..., "https": ...} argumento; la lógica de rotación se encuentra un nivel por encima.

5. Lee robots.txt y los Términos de servicio. La documentación de robots.txt de Google es una introducción sólida al protocolo. Respetar Disallow las directivas no es legalmente vinculante en todas partes, pero marca la diferencia entre un rastreador educado y uno molesto, e ignorarlas es la forma en que los proyectos acaban en listas de bloqueo.

Cuando los sitios se apoyan en potentes sistemas anti-bot (el gestor de bots de Cloudflare, PerimeterX, DataDome), el coste de desarrollar todo esto por tu cuenta supera el coste de utilizar un desbloqueador gestionado. Nuestra API de scraper gestiona la rotación, los CAPTCHAs y los reintentos detrás de un único punto final, por lo que el código de análisis de BeautifulSoup de este tutorial permanece exactamente igual y solo cambia la capa de obtención de datos.

Cuando BeautifulSoup no es suficiente: páginas renderizadas con JavaScript

BeautifulSoup analiza lo que envía el servidor. Si el servidor envía un shell HTML casi vacío y la página solo ensambla su contenido después de que se ejecute JavaScript en el navegador, BeautifulSoup analizará felizmente el shell y no encontrará nada útil. Esta es la única limitación importante de lo que este tutorial de BeautifulSoup puede hacer por ti, y vale la pena reconocer los síntomas.

Señales reveladoras de que estás ante una aplicación de página única:

  • view-source: muestra un pequeño <div id="root"></div> y un montón de <script> , pero la página renderizada en el navegador está llena de contenido.
  • Tu scraper ve un DOM diferente al de DevTools. DevTools muestra el DOM en tiempo real, que incluye nodos inyectados por JS; requests solo ve la respuesta inicial.
  • La pestaña de red muestra una avalancha de XHR o fetch después de la carga de la página.

Tienes tres buenas opciones:

  • Busca la API. Observa la pestaña de red. Si la página está recuperando JSON desde un backend, accede directamente a ese punto final con requests y omite por completo la renderización. Esta suele ser la vía más rápida y estable.
  • Utiliza un navegador real. Usa Playwright o Selenium para cargar la página, espera a que lleguen los datos y, a continuación, pasa el HTML renderizado a BeautifulSoup para su análisis.
  • Utiliza una API de navegador gestionada. Para los casos en los que quieras el navegador sin gestionar la infraestructura, un punto final de navegador en la nube devuelve el HTML renderizado y tú continúas analizándolo con el mismo find_all/select código que ya has escrito.

Script final: combinando la obtención, el análisis, la paginación y la exportación

Aquí tienes la versión consolidada del código del tutorial de BeautifulSoup. Paginación, reintentos, limitación de velocidad con jitter, rotación de agentes de usuario y exportación tanto a CSV como a JSON.

import csv, json, random, time
from pathlib import Path
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

BASE = "https://news.ycombinator.com/"
START = urljoin(BASE, "news")
UAS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) Safari/17.0",
]

def fetch(url, attempts=4):
    for i in range(attempts):
        try:
            r = requests.get(url, headers={"User-Agent": random.choice(UAS)}, timeout=15)
            if r.status_code == 200:
                return r.content
            if r.status_code in (429, 503):
                time.sleep(2 ** i); continue
            r.raise_for_status()
        except requests.RequestException:
            time.sleep(2 ** i)
    raise RuntimeError(f"failed: {url}")

def parse_stories(html):
    soup = BeautifulSoup(html, "lxml")
    out = []
    for row in soup.select("tr.athing"):
        rank = row.select_one("span.rank")
        link = row.select_one("span.titleline > a")
        if not (rank and link):
            continue
        out.append({
            "rank": rank.get_text(strip=True).rstrip("."),
            "title": link.get_text(strip=True),
            "url": link.get("href", ""),
            "id": row.get("id"),
        })
    return out

def next_page(html):
    soup = BeautifulSoup(html, "lxml")
    more = soup.select_one("a.morelink")
    return urljoin(BASE, more["href"]) if more else None

def crawl(start, max_pages=3):
    url, pages, rows = start, 0, []
    while url and pages < max_pages:
        html = fetch(url)
        rows.extend(parse_stories(html))
        url = next_page(html)
        pages += 1
        time.sleep(random.uniform(1.0, 2.5))
    return rows

def export(rows, out_dir="out"):
    out = Path(out_dir); out.mkdir(exist_ok=True)
    with (out / "stories.csv").open("w", newline="", encoding="utf-8-sig") as f:
        w = csv.DictWriter(f, fieldnames=["rank", "title", "url", "id"])
        w.writeheader(); w.writerows(rows)
    with (out / "stories.json").open("w", encoding="utf-8") as f:
        json.dump(rows, f, ensure_ascii=False, indent=2)

if __name__ == "__main__":
    rows = crawl(START)
    export(rows)
    print(f"saved {len(rows)} stories")

Colócalo en hn_scraper.py, ejecuta python hn_scraper.pyy deberías ver tres páginas de historias escritas en out/stories.csv y out/stories.json.

Hacia dónde llevar este tutorial de BeautifulSoup

Ahora tienes un rastreador de sitios estáticos completo, pero el mismo analizador se adapta a flujos de trabajo mucho más amplios. Tres pasos lógicos a seguir:

  • Pasa a Scrapy cuando necesites rastrear miles de páginas, deduplicar URL, gestionar la concurrencia y ejecutar tareas programadas. Scrapy utiliza expresiones de selección similares, por lo que el modelo mental que has construido en este tutorial de BeautifulSoup se transfiere sin problemas.
  • Añade un navegador sin interfaz gráfica cuando los datos se encuentren detrás de JavaScript. Tanto Playwright como Selenium te permiten renderizar primero la página y analizar después el HTML renderizado con BeautifulSoup, lo que conserva tu código de análisis existente y tus selectores CSS.
  • Externaliza la capa de obtención de datos cuando los bloques se conviertan en el cuello de botella. Una API de scraping gestionada se encarga de los proxies, los encabezados y la resolución de CAPTCHAs, para que puedas seguir iterando sobre los selectores en lugar de sobre la identificación de huellas digitales.

Sea cual sea la dirección que elijas, mantén la separación entre análisis y obtención de datos que has creado aquí. Es la única decisión de diseño que permite que un scraper sobreviva al inevitable rediseño del sitio, y es lo que hace que el código de esta guía sea reutilizable a medida que crecen tus necesidades.

Conclusiones clave

  • BeautifulSoup analiza HTML, nada más. Combínalo con requests para páginas estáticas y un navegador real para las renderizadas con JavaScript.
  • Los selectores CSS se adaptan mejor que las llamadas encadenadas find_all . Defínelos como constantes con nombre en la parte superior de su módulo para que un cambio en el marcado se solucione con una sola línea.
  • Protégete siempre contra None. Usa find_parent con cuidado, da preferencia a .get("attr", "") a la indexación, y comprueba antes de encadenar llamadas a métodos.
  • La paginación es una condición de parada. Detecta el ancla de la página siguiente, crea URL absolutas con urljoin, y limita el bucle con max_pages para que un error no se ejecute indefinidamente.
  • La cortesía es ingeniería. La rotación de agentes de usuario, el sueño con fluctuación, el retroceso exponencial y respetar robots.txt son prácticas básicas, no un toque opcional, para cualquier tutorial de BeautifulSoup que pretendas ejecutar más de una vez.

Preguntas frecuentes

¿Cuál es la diferencia entre html.parser de BeautifulSoup, lxml y html5lib?

html.parser Viene incluido con Python y no necesita instalación, pero es el más lento de los tres. lxml es una extensión en C que es la más rápida en la práctica y maneja bien la mayoría del HTML malformado; instálala con pip install lxml. html5lib es Python puro y el más tolerante, imitando cómo un navegador real se recupera de un marcado defectuoso, a costa de ser notablemente más lento.

¿Cuándo debo usar BeautifulSoup frente a Scrapy, Selenium o Playwright?

Utiliza BeautifulSoup para scripts puntuales y páginas estáticas en las que puedas obtener el HTML con requests. Usa Scrapy cuando necesites un rastreador real con concurrencia, flujos de trabajo y programación de miles de URL. Usa Selenium o Playwright cuando la página dependa de JavaScript para renderizar el contenido y, opcionalmente, devuelve el HTML renderizado a BeautifulSoup para su análisis.

¿Puede BeautifulSoup extraer páginas renderizadas con JavaScript por sí solo?

No. BeautifulSoup solo analiza el HTML que recibe, y requests devuelve la respuesta inicial del servidor sin ejecutar JavaScript. Para aplicaciones de una sola página o contenido inyectado tras la carga de la página, necesitas un navegador sin interfaz gráfica (Playwright, Selenium o un punto final de navegador en la nube) para renderizar primero el DOM. Una vez renderizado, puedes pasar ese HTML a BeautifulSoup para su análisis.

¿Cómo evito que me bloqueen la IP mientras extraigo datos con BeautifulSoup?

Alterna las cadenas de User-Agent, añade retrasos aleatorios entre las solicitudes y vuelve a intentar los errores transitorios con retroceso exponencial. Para grandes volúmenes, redirige el tráfico a través de proxies residenciales o de centros de datos rotativos. Respeta robots.txt y evita extraer contenido protegido por inicio de sesión. Las pilas anti-bot agresivas como Cloudflare suelen requerir un desbloqueador gestionado en lugar de ajustes de encabezados por cuenta propia.

La biblioteca en sí solo analiza texto y no es objeto de la cuestión legal. Que un rastreo específico sea legal depende generalmente de los Términos de servicio del sitio de destino, las leyes de derechos de autor y de uso indebido de ordenadores aplicables en tu jurisdicción, y de si los datos son personales según normativas como el RGPD o la CCPA. Esta es información general y no constituye asesoramiento legal; consulta a un abogado para cualquier asunto relacionado con datos personales, muros de pago o redistribución comercial.

Conclusión

Has comenzado este tutorial de BeautifulSoup con pip install y has terminado con un rastreador que pagina, reintenta, alterna los agentes de usuario y exporta archivos CSV y JSON limpios. La estructura de ese script es más importante que cualquier fragmento concreto: mantén la recuperación separada del análisis, selecciona los elementos con selectores CSS con nombre, protege cada acceso a atributos encadenados contra None, y trata las prácticas anti-bloqueo como parte de la construcción en lugar de como una idea de último momento. Los sitios seguirán rediseñándose, los analizadores seguirán siendo bloqueados, y los códigos base que envejecen bien son aquellos que respetan esa separación desde el primer día.

Si la capa de obtención empieza a consumir más tiempo que la de análisis, esa es la señal para externalizarla. WebScrapingAPI se encarga de la rotación de proxies, la identificación de cabeceras y la resolución de CAPTCHAs detrás de un único punto final, por lo que puedes mantener el código de BeautifulSoup que escribiste aquí y solo cambiar la solicitud que le proporciona el HTML. Buena suerte, y que tus selectores sigan en verde.

Acerca del autor
Sorin-Gabriel Marica, Desarrollador full-stack @ WebScrapingAPI
Sorin-Gabriel MaricaDesarrollador full-stack

Sorin Marica es ingeniero Full Stack y DevOps en WebScrapingAPI, donde se encarga de desarrollar funciones para los productos y de mantener la infraestructura que garantiza el buen funcionamiento de la plataforma.

Empieza a crear

¿Estás listo para ampliar tu recopilación de datos?

Únete a más de 2000 empresas que utilizan WebScrapingAPI para extraer datos de la web a escala empresarial sin ningún gasto de infraestructura.