DATOS · 6 MIN DE LECTURA

Web scraping con Python: guía paso a paso con requests, BeautifulSoup y Playwright

Para hacer web scraping con Python, descargas la página con requests, buscas los datos con BeautifulSoup usando selectores CSS y los guardas en CSV. Si la página arma su contenido con JavaScript, la abres en un navegador real con Playwright. Antes de cualquiera de las dos, revisa si hay una API: muchas veces los datos ya vienen en JSON.

Respuesta corta: cómo hacer web scraping con Python

El web scraping tiene tres pasos: descargar la página, encontrar los datos dentro del HTML y guardarlos. En Python, requests descarga, BeautifulSoup busca y el módulo csv guarda. Cuando la página se arma con JavaScript en el navegador, requests recibe una página casi vacía y hace falta un navegador real controlado por código, como Playwright.

Todo el código de esta guía lo ejecuté el 19 de octubre de 2026 contra books.toscrape.com y quotes.toscrape.com, dos sitios creados para practicar scraping. Las salidas que ves son las reales.

Antes de escribir código: ¿deberías hacer scraping?

Cinco minutos de revisión te ahorran un proyecto que se cae o un problema legal:

  • ¿Hay una API oficial o un archivo descargable? Si existe, úsalo: es más estable y tiene reglas claras.
  • ¿Qué dicen los términos de uso del sitio? Algunos prohíben la extracción automática.
  • ¿Qué permite su robots.txt? No es un candado, pero respetarlo es la práctica profesional.
  • ¿Los datos son públicos y sin iniciar sesión? Si hay que entrar con usuario o saltar un captcha, detente.
  • ¿Hay datos personales? Nombres, correos o teléfonos cambian por completo el análisis legal. En la guía sobre la legalidad del web scraping en México explico el detalle.

Qué necesitas instalar

Python 3.10 o más reciente. Para el nivel 1 bastan requests y BeautifulSoup; Playwright solo hace falta para páginas con JavaScript y descarga su propio navegador.

pip install requests beautifulsoup4
# Solo para el nivel 2:
pip install playwright
python -m playwright install chromium

Nivel 1: páginas HTML con requests y BeautifulSoup

Primero abre la página en tu navegador, haz clic derecho sobre un dato y elige «Inspeccionar». Ahí ves qué etiqueta y qué clase tiene: en books.toscrape.com cada libro es un article con clase product_pod, el precio está en p.price_color y la calificación es una clase de p.star-rating. Esos selectores CSS son los que usa el script.

Este script recorre las primeras tres páginas del catálogo, saca título, precio, disponibilidad, estrellas y enlace de cada libro, y guarda todo en un CSV. Revisa robots.txt antes de empezar, se identifica con un User-Agent que dice quién eres y espera un segundo entre páginas.

"""Nivel 1: extrae título, precio, disponibilidad y calificación de los libros de books.toscrape.com."""
import csv
import time
from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser

import requests
from bs4 import BeautifulSoup

INICIO = "https://books.toscrape.com/catalogue/page-1.html"
AGENTE = "PruebaScraping/1.0 (+https://daniel-pereda.dev/contacto)"
PAUSA = 1.0  # segundos entre páginas: no hay prisa y el sitio no es tuyo
ESTRELLAS = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5}


def permitido(url):
    robots = RobotFileParser(urljoin(url, "/robots.txt"))
    robots.read()  # si no existe (404), todo está permitido
    return robots.can_fetch(AGENTE, url)


def leer_pagina(sesion, url):
    respuesta = sesion.get(url, timeout=15)
    respuesta.raise_for_status()
    respuesta.encoding = "utf-8"
    sopa = BeautifulSoup(respuesta.text, "html.parser")
    libros = []
    for tarjeta in sopa.select("article.product_pod"):
        libros.append({
            "titulo": tarjeta.h3.a["title"],
            "precio": tarjeta.select_one("p.price_color").get_text(strip=True).lstrip("£"),
            "disponible": "In stock" in tarjeta.select_one("p.availability").get_text(),
            "estrellas": ESTRELLAS[tarjeta.select_one("p.star-rating")["class"][1]],
            "url": urljoin(url, tarjeta.h3.a["href"]),
        })
    siguiente = sopa.select_one("li.next a")
    return libros, urljoin(url, siguiente["href"]) if siguiente else None


def main(max_paginas=3):
    if not permitido(INICIO):
        raise SystemExit("robots.txt no permite esta ruta")
    sesion = requests.Session()
    sesion.headers["User-Agent"] = AGENTE
    url, todos = INICIO, []
    for _ in range(max_paginas):
        libros, url = leer_pagina(sesion, url)
        todos.extend(libros)
        print(f"{len(libros)} libros leídos; total {len(todos)}")
        if url is None:
            break
        time.sleep(PAUSA)
    with open("libros.csv", "w", newline="", encoding="utf-8") as archivo:
        escritor = csv.DictWriter(archivo, fieldnames=todos[0].keys())
        escritor.writeheader()
        escritor.writerows(todos)
    print("Guardado en libros.csv")


if __name__ == "__main__":
    main()
  • urljoin convierte los enlaces relativos (page-2.html) en direcciones completas. Olvidarlo es el error más común al seguir la paginación.
  • raise_for_status() detiene el script si el sitio responde con error, en lugar de guardar páginas vacías sin que te des cuenta.
  • max_paginas pone un límite. Sin él, un error en la paginación puede dejar el script dando vueltas.
  • Los precios de este sitio de práctica están en libras esterlinas; por eso se quita el símbolo £.

Resultado del nivel 1

Salida real del script y las primeras filas del CSV:

python libros.py
20 libros leídos; total 20
20 libros leídos; total 40
20 libros leídos; total 60
Guardado en libros.csv

titulo,precio,disponible,estrellas,url
A Light in the Attic,51.77,True,3,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Tipping the Velvet,53.74,True,1,https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html

¿Cómo saber si la página necesita un navegador?

Compara lo que ves en pantalla con lo que llega al descargar la página. En quotes.toscrape.com/js/ el navegador muestra 10 frases, pero el HTML que descarga requests no trae ningún div.quote: lo comprobé y el conteo da cero. Las frases las inserta el JavaScript después de cargar.

Antes de pasar a Playwright, abre las herramientas de desarrollador en la pestaña «Red» (Network), filtra por Fetch/XHR y recarga. Muchas páginas piden sus datos a una API interna en JSON. Si la encuentras, leer ese JSON es más rápido y estable que controlar un navegador. La versión con scroll infinito de quotes.toscrape.com, por ejemplo, carga sus frases desde /api/quotes:

import requests

respuesta = requests.get("https://quotes.toscrape.com/api/quotes", params={"page": 1}, timeout=15)
datos = respuesta.json()
for frase in datos["quotes"][:3]:
    print(frase["author"]["name"], "-", frase["text"][:50])
print("¿Hay más páginas?", datos["has_next"])
  • Salida real: «Albert Einstein - “The world as we have created it is a process of o…», seguida de dos frases más y «¿Hay más páginas? True».
  • Una API interna no es una API pública con contrato: puede cambiar sin aviso y sus términos siguen aplicando.

Nivel 2: páginas con JavaScript usando Playwright

Cuando no hay API a la vista, Playwright abre un Chromium sin ventana, espera a que el JavaScript pinte el contenido y te deja leerlo con los mismos selectores CSS. Este script lee dos páginas de quotes.toscrape.com/js/ y guarda texto, autor y etiquetas en JSON.

"""Nivel 2: la página arma su contenido con JavaScript, así que se abre en un navegador real con Playwright."""
import json
import time

from playwright.sync_api import sync_playwright

INICIO = "https://quotes.toscrape.com/js/"
AGENTE = "PruebaScraping/1.0 (+https://daniel-pereda.dev/contacto)"


def main(max_paginas=2):
    frases = []
    with sync_playwright() as p:
        navegador = p.chromium.launch()
        pagina = navegador.new_page(user_agent=AGENTE)
        pagina.goto(INICIO)
        for _ in range(max_paginas):
            pagina.wait_for_selector("div.quote")  # espera a que el JavaScript pinte las frases
            for bloque in pagina.locator("div.quote").all():
                frases.append({
                    "texto": bloque.locator("span.text").inner_text(),
                    "autor": bloque.locator("small.author").inner_text(),
                    "etiquetas": bloque.locator("a.tag").all_inner_texts(),
                })
            siguiente = pagina.locator("li.next a")
            if siguiente.count() == 0:
                break
            siguiente.click()
            time.sleep(1)
        navegador.close()
    with open("frases.json", "w", encoding="utf-8") as archivo:
        json.dump(frases, archivo, ensure_ascii=False, indent=2)
    print(f"{len(frases)} frases guardadas en frases.json")


if __name__ == "__main__":
    main()
  • wait_for_selector es la clave: espera a que existan las frases antes de leerlas. Sin esa línea, el script lee la página antes de tiempo y no encuentra nada.
  • Salida real: «20 frases guardadas en frases.json». La primera es de Albert Einstein, con las etiquetas change, deep-thoughts, thinking y world.
  • Un navegador consume mucho más que una petición con requests. Úsalo solo cuando de verdad haga falta.

Dónde guardar los datos

Para empezar, CSV o JSON son suficientes y se abren en cualquier lado. Cuando el scraper corre todos los días, conviene una base de datos: SQLite viene con Python (import sqlite3) y te permite guardar el historial y consultar cambios, por ejemplo, cómo varió un precio a lo largo del mes. Si después vas a analizar los datos, pandas lee el CSV con una línea.

Ritmo, reintentos y buena educación

Un scraper mal hecho se parece mucho a un ataque. Estas reglas evitan bloqueos y problemas:

  • Una petición a la vez y una pausa entre páginas. No pongas a correr diez hilos contra el sitio de una pyme.
  • Un User-Agent que diga quién eres y cómo contactarte.
  • Si el sitio responde 429 (demasiadas solicitudes) o 503, espera y reintenta con pausas cada vez más largas; si persiste, detente.
  • Guarda en caché lo que ya descargaste para no pedirlo de nuevo en cada prueba.
  • Corre los procesos grandes en horarios de poco tráfico.
  • No saltes inicios de sesión, captchas ni bloqueos. Si el sitio te bloquea, es su respuesta.

Errores comunes al hacer web scraping con Python

  • Selectores que se rompen cuando el sitio cambia su diseño. Prefiere atributos estables y deja pruebas que avisen cuando un campo llega vacío.
  • Acentos mal leídos: si ves «Ã©» en lugar de «é», fija la codificación con respuesta.encoding = "utf-8".
  • Enlaces relativos que no se resuelven: usa urljoin.
  • Leer la página antes de que cargue el JavaScript: usa wait_for_selector en Playwright.
  • Guardar datos que no necesitas, sobre todo personales. Extrae solo lo que vas a usar.

Cuándo conviene contratar a alguien

Un script como el de esta guía resuelve una extracción puntual. Cuando el proceso tiene que correr todos los días, con varios sitios, alertas si algo cambia, historial y un reporte al final, el trabajo ya no es el scraper sino mantenerlo vivo. Ese es el tipo de proyecto que hago: empiezo revisando cada fuente (si hay API, qué permiten sus términos y su robots.txt) y descarto las que prohíben la extracción. Mi proyecto NLP Pipeline, que extrae y normaliza contenido web por lotes, nació de ese mismo proceso.

Preguntas frecuentes

¿Qué librería de Python es mejor para web scraping?

Para páginas HTML normales, requests con BeautifulSoup es lo más simple. Para páginas que se arman con JavaScript, Playwright. Para proyectos grandes con muchas páginas, colas y reintentos, Scrapy. Empieza por la más sencilla que resuelva tu caso.

¿Es legal hacer web scraping?

Depende de qué datos extraes, cómo accedes y qué haces con ellos. Extraer datos públicos sin iniciar sesión y sin datos personales es lo de menor riesgo; saltar bloqueos o recolectar datos personales no. Lo explico en la guía sobre la legalidad del web scraping en México.

¿Por qué mi scraper no encuentra nada si en el navegador sí veo los datos?

Casi siempre es porque la página carga los datos con JavaScript. Revisa la pestaña Red de las herramientas de desarrollador: si los datos vienen de una API en JSON, léela directo; si no, usa Playwright con wait_for_selector.

¿Cómo evito que me bloqueen?

No intentes esconderte: haz pocas peticiones, con pausas, un User-Agent honesto y respetando robots.txt. Si aun así el sitio te bloquea, la respuesta correcta es buscar otra fuente o pedir permiso, no saltar el bloqueo.

FUENTES
SIGUIENTE PASO

Si quieres llevarlo a la práctica

¿Lo platicamos?

Si esta guía te dejó dudas sobre tu caso concreto, escríbeme. Te respondo personalmente.