Scraping por lenguaje 12 min de lectura

Web scraping con Python: guía completa

Recorremos todo el proceso de web scraping con Python: requests y BeautifulSoup, contenido dinámico, cómo evitar bloqueos y la estructura de un scraper completo.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 1 enero 2025

El web scraping es la recopilación automática de datos de páginas web y su conversión a un formato estructurado: tablas, JSON o una base de datos. Python se ha convertido en el estándar de facto para esta tarea: tiene una sintaxis sencilla, un ecosistema enorme de bibliotecas y una comunidad muy activa.

Este material es un «hub» panorámico. Aquí repasaremos todo el proceso, desde la petición de la página hasta el almacenamiento de las colas de URL, daremos un mapa de todas las bibliotecas y soluciones clave y, para los temas más específicos, enlazaremos artículos detallados aparte.

Índice

  1. Qué es el web scraping y de qué se compone
  2. Panorama de bibliotecas y soluciones
  3. Cómo obtenemos la página
  4. Bibliotecas para parsear el contenido
  5. Solución de problemas con codificaciones y caracteres especiales
  6. Uso de multihilo
  7. Uso de proxies
  8. Scraping a través de TOR
  9. Trabajo con HTTPS/SSL
  10. Trabajo con cookies
  11. Estado de la respuesta y cabeceras
  12. Almacenamiento de URL y colas
  13. Ventajas y desventajas de la implementación
  14. Aspectos legales y éticos

1. Qué es el web scraping y de qué se compone

Cualquier scraper, sea cual sea su escala, consta de cuatro etapas:

  1. Obtención de la página — petición HTTP al servidor y recepción de la respuesta (HTML, XML, JSON).
  2. Parseo del contenido — extracción de los datos necesarios de la estructura mediante selectores (CSS, XPath) o expresiones regulares.
  3. Normalización y almacenamiento — conversión de los datos a un formato uniforme y escritura en un archivo o base de datos.
  4. Gestión del rastreo — cola de URL, deduplicación, límite de velocidad, reintentos.

Un script sencillo puede resolver todo esto en 10 líneas. Un crawler industrial separa cada etapa en una capa propia, con colas, proxies y workers distribuidos.


2. Panorama de bibliotecas y soluciones

Para no perderse, dividamos las herramientas según su función.

Descarga de páginas (clientes HTTP)

Biblioteca Tipo Cuándo usarla
requests síncrono el estándar para la mayoría de las tareas, API cómoda
urllib síncrono incluida en la biblioteca estándar, sin dependencias
httpx sínc./asínc. sustituto moderno de requests con soporte de async y HTTP/2
aiohttp asíncrono alta concurrencia, miles de peticiones
pycurl síncrono control fino de la petición, máxima velocidad

Parseo de HTML/XML

Biblioteca Motor Particularidades
BeautifulSoup (bs4) html.parser / lxml la API más amigable, tolera el HTML «sucio»
lxml libxml2 (C) máxima velocidad, XPath completo
parsel lxml CSS + XPath, la base de Scrapy
selectolax Modest/Lexbor (C) parser CSS muy rápido para grandes volúmenes
pyquery lxml sintaxis al estilo jQuery

Sitios dinámicos (JavaScript)

Herramienta Propósito
Selenium control de un navegador real, el clásico
Playwright alternativa moderna, más rápida y estable
Pyppeteer port de Puppeteer a Python

Frameworks y plataformas

Solución Propósito
Scrapy framework completo para crawlers: colas, pipelines, middleware
Scrapy + Splash/Playwright Scrapy con renderizado de JS
Django + Celery scraping como parte de una aplicación web con tareas en segundo plano

Cómo elegir

  • Página simple sin JS, tarea puntual → requests + BeautifulSoup.
  • Necesita velocidad con grandes volúmenes → httpx/aiohttp + lxml/selectolax.
  • Cientos de miles de páginas, rastreo del sitio completo → Scrapy. Más detalles en el artículo «Web scraping en Python con Scrapy».
  • El contenido se renderiza con JavaScript → Playwright/Selenium.
  • El scraping forma parte de un servicio web → Django, vea «Web scraping con Django».
  • Necesita la máxima concurrencia → enfoque asíncrono.

3. Cómo obtenemos la página

Petición básica con requests:

python
import requests

url = "https://example.com"
headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
    "Accept-Language": "es-ES,es;q=0.9",
}

response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()   # lanza una excepción ante un 4xx/5xx
html = response.text

Puntos clave:

  • User-Agent: defínalo siempre — muchos sitios bloquean las peticiones que llegan con el python-requests por defecto.
  • timeout: indíquelo siempre; de lo contrario, el script puede quedarse colgado para siempre.
  • raise_for_status() le ahorra la comprobación manual del código de respuesta.

Si la página se renderiza con JavaScript, requests devolverá un esqueleto vacío. En ese caso hace falta un motor de navegador:

python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://example.com")
    page.wait_for_selector(".content")   # esperamos a que aparezcan los datos
    html = page.content()
    browser.close()

Si lo que se espera a la salida no es HTML sino datos estructurados de una API, consulte el material aparte «Parseo de JSON».


4. Bibliotecas para parsear el contenido

BeautifulSoup — la puerta de entrada para principiantes

python
from bs4 import BeautifulSoup

soup = BeautifulSoup(html, "lxml")   # el parser lxml es más rápido que html.parser

title = soup.find("h1").get_text(strip=True)
links = [a["href"] for a in soup.select("a.product-link")]
price = soup.select_one(".price").text

BeautifulSoup tolera la estructura rota y se lee casi como lenguaje natural. Para la extracción de tablas hay un análisis aparte: «Extraer tablas HTML con Python y BeautifulSoup».

lxml — velocidad y XPath

python
from lxml import html as lxml_html

tree = lxml_html.fromstring(html)
titles = tree.xpath('//h2[@class="title"]/text()')
prices = tree.xpath('//span[@class="price"]/text()')

lxml está escrito en C y trabaja varias veces más rápido con grandes volúmenes. Su XPath completo aporta una flexibilidad que los selectores CSS no alcanzan. El análisis en profundidad está en el artículo «Web scraping en Python con lxml».

parsel y selectolax

parsel (el núcleo de Scrapy) combina CSS y XPath:

python
from parsel import Selector

sel = Selector(text=html)
sel.css("h1::text").get()
sel.xpath("//a/@href").getall()

selectolax es la opción adecuada cuando hay que parsear cientos de miles de documentos: es notablemente más rápido incluso que lxml en las selecciones CSS.

Expresiones regulares

re solo resulta apropiado para patrones simples y planos (teléfono, email, referencia de producto). No conviene parsear HTML anidado con regex: la estructura se rompe con demasiada facilidad.


5. Solución de problemas con codificaciones y caracteres especiales

La molestia más frecuente son los caracteres corruptos (mojibake) en lugar de las tildes y las eñes: información en vez de información, España en vez de España. La causa es una codificación de la respuesta mal detectada.

Por qué ocurre

requests intenta adivinar la codificación a partir de la cabecera Content-Type. Si el servidor no la envía o la envía mal, el texto se decodifica con la codificación equivocada (a menudo se asume ISO-8859-1 en lugar de windows-1252 o utf-8).

Solución 1: fijar la codificación a mano

python
response = requests.get(url)
response.encoding = "utf-8"      # o "windows-1252" en sitios antiguos
html = response.text

Solución 2: detección automática

python
response = requests.get(url)
response.encoding = response.apparent_encoding   # se detecta a partir del contenido
html = response.text

apparent_encoding usa la biblioteca charset-normalizer (o chardet), que analiza los bytes y deduce la codificación.

Solución 3: trabajar directamente con los bytes

El camino más fiable es entregar los bytes al parser y dejar que él mismo lea el <meta charset>:

python
from bs4 import BeautifulSoup

response = requests.get(url)
soup = BeautifulSoup(response.content, "lxml")   # .content, no .text

response.content son los bytes «en crudo»; lxml y BeautifulSoup encuentran por sí solos la declaración de codificación dentro del HTML.

Solución 4: decodificación manual

python
html = response.content.decode("windows-1252", errors="replace")

El parámetro errors="replace" sustituye los caracteres no convertibles por sin tumbar el script. Los matices del XML con caracteres especiales se describen en «Parseo de XML en Python».


6. Uso de multihilo

El scraping consiste sobre todo en esperar a la red (I/O-bound), por lo que los hilos aportan una aceleración notable pese al GIL: mientras un hilo espera la respuesta, otro trabaja.

ThreadPoolExecutor — la vía más sencilla

python
from concurrent.futures import ThreadPoolExecutor
import requests

urls = [f"https://example.com/page/{i}" for i in range(1, 101)]

def fetch(url):
    r = requests.get(url, timeout=10)
    return url, r.status_code

with ThreadPoolExecutor(max_workers=10) as executor:
    for url, status in executor.map(fetch, urls):
        print(url, status)

Cuándo recurrir a multiprocessing

Si el cuello de botella no es la red, sino el parseo pesado del HTML y su procesamiento (CPU-bound), los hilos chocarán con el GIL. Ahí ayuda multiprocessing: varios procesos, cada uno con su propio intérprete.

La mejor alternativa: async

Para miles de peticiones simultáneas, los hilos consumen demasiada memoria. La asincronía (asyncio + aiohttp) mantiene decenas de miles de conexiones en un solo hilo. Es un gran tema aparte: vea «Scraping asíncrono en Python».

Importante: una concurrencia alta no da derecho a «tumbar» el servidor ajeno. Limite la velocidad y respete robots.txt.


7. Uso de proxies

Con un scraping intensivo, el sitio banea su IP por el número de peticiones. La solución: un pool de proxies con rotación.

Conexión simple de un proxy

python
proxies = {
    "http":  "http://user:pass@123.45.67.89:8080",
    "https": "http://user:pass@123.45.67.89:8080",
}
response = requests.get(url, proxies=proxies, timeout=15)

Rotación de proxies

python
import random
import requests

PROXIES = [
    "http://user:pass@ip1:port",
    "http://user:pass@ip2:port",
    "http://user:pass@ip3:port",
]

def fetch_with_rotation(url):
    proxy = random.choice(PROXIES)
    return requests.get(url, proxies={"http": proxy, "https": proxy}, timeout=15)

Tipos de proxies

  • Datacenter — baratos y rápidos, pero fáciles de detectar y banear.
  • Residenciales — IP de proveedores reales; más caros, pero menos visibles.
  • Móviles — IP de operadores móviles, los más «de confianza» y los más caros.

En producción conviene mantener una lista de proxies «sanos»: descartar los que devuelven timeout o 403 y verificarlos periódicamente.


8. Scraping a través de TOR

TOR es una forma gratuita de cambiar la IP de salida. Es más lento que los proxies de pago y sirve para volúmenes modestos, pero no exige inversión.

Conexión

Tras instalar TOR (el demonio o Tor Browser), este levanta un proxy SOCKS5 en 127.0.0.1:9050:

python
import requests

proxies = {
    "http":  "socks5h://127.0.0.1:9050",
    "https": "socks5h://127.0.0.1:9050",
}
# se necesita el paquete: pip install requests[socks]
r = requests.get("https://httpbin.org/ip", proxies=proxies)
print(r.json())   # verá la IP del nodo de salida de TOR, no la suya

El esquema socks5h (con la letra h) es importante: la resolución DNS pasa por TOR y no se hace en local; de lo contrario, su petición DNS real quedaría expuesta.

Cambio de identidad (nueva IP)

Para obtener un nuevo nodo de salida, envíe la señal NEWNYM por el puerto de control (9051) con la biblioteca stem:

python
from stem import Signal
from stem.control import Controller

def renew_tor_ip():
    with Controller.from_port(port=9051) as controller:
        controller.authenticate(password="su_contrasena")
        controller.signal(Signal.NEWNYM)

El puerto de control debe habilitarse en torrc, definiendo además el hash de la contraseña (tor --hash-password).

Tenga en cuenta: muchos sitios grandes conocen la lista de nodos de salida de TOR y los bloquean o muestran un captcha. TOR es adecuado para tareas sin exigencias de velocidad y con volúmenes bajos.


9. Trabajo con HTTPS/SSL

Por defecto, requests verifica los certificados SSL mediante el paquete certifi. La mayoría de las veces no hay que configurar nada. Los problemas surgen en sitios con certificados autofirmados o caducados.

Desactivar la verificación (solo para depurar)

python
import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
response = requests.get(url, verify=False)   # NO apto para producción

Desactivar la verificación es inseguro: abre la puerta a un ataque MITM. Solo es admisible en local, para depurar.

El camino correcto: indicar su propia CA

python
response = requests.get(url, verify="/path/to/custom-ca-bundle.crt")

Actualización de los certificados raíz

Si recibe SSLCertVerificationError en sitios normales, actualice certifi:

bash
pip install --upgrade certifi

Las cookies hacen falta para las sesiones, la autenticación y para superar las páginas «de protección» que colocan un token y redirigen.

python
import requests

session = requests.Session()

# iniciamos sesión: el servidor devolverá la cookie de sesión
session.post("https://example.com/login", data={"user": "u", "pass": "p"})

# las peticiones siguientes ya van autenticadas
profile = session.get("https://example.com/profile")

Session guarda y reenvía por sí sola las cookies entre peticiones y, de paso, reutiliza las conexiones TCP (más rápido) y las cabeceras comunes.

python
cookies = {"sessionid": "abc123", "csrftoken": "xyz789"}
response = requests.get(url, cookies=cookies)
python
response = requests.get(url)
for name, value in response.cookies.items():
    print(name, value)

11. Estado de la respuesta y cabeceras

Controlar la respuesta del servidor es obligatorio; de lo contrario, acabará parseando una página de error como si fueran «datos».

python
response = requests.get(url)

print(response.status_code)      # 200, 404, 403, 500 ...
print(response.reason)           # 'OK', 'Not Found'
print(response.headers["Content-Type"])
print(response.headers.get("Server"))
print(response.url)              # URL final tras las redirecciones
print(response.elapsed)          # tiempo de respuesta

Gestión correcta de los estados

python
if response.status_code == 200:
    parse(response.text)
elif response.status_code == 404:
    log("Página no encontrada")
elif response.status_code == 429:
    # Too Many Requests: nos están frenando
    wait = int(response.headers.get("Retry-After", 60))
    time.sleep(wait)
elif response.status_code in (403, 503):
    rotate_proxy()   # probablemente un ban: cambiamos de IP

La cabecera Retry-After indica cuánto esperar antes de reintentar. Los estados 403/503 suelen delatar una protección anti-bots: ayudan el cambio de proxy, otro User-Agent y una pausa.


12. Almacenamiento de URL y colas (panorama)

Cuando el scraper recorre un sitio completo, hay que guardar en algún lugar las URL «pendientes de visitar» y las «ya visitadas». Es lo que se denomina frontier (frontera de rastreo).

La variante más simple: estructuras en memoria

python
from collections import deque

to_visit = deque(["https://example.com"])
visited = set()

while to_visit:
    url = to_visit.popleft()
    if url in visited:
        continue
    visited.add(url)
    # ... descargar, parsear, añadir los enlaces nuevos a to_visit

set garantiza una deduplicación instantánea; deque funciona como cola FIFO.

Cuando los datos crecen

  • Redis — cola compartida para varios workers, sobrevive a los reinicios. Las listas y conjuntos de Redis son ideales para el rastreo distribuido.
  • Base de datos (PostgreSQL/SQLite) — tabla de URL con estados new / in_progress / done / failed; cómoda para la resiliencia y la analítica.
  • Colas de tareas (Celery + broker, RabbitMQ) — cuando el scraping está integrado en una aplicación; vea «Web scraping con Django».
  • Filtro de Bloom — ahorra memoria con millones de URL: comprobación probabilística de si «ya hemos visto esta URL».

En Scrapy, la gestión de la cola, la deduplicación y las prioridades vienen integradas de serie: es una de las razones principales para elegir este framework en los proyectos grandes. Más detalles en «Web scraping en Python con Scrapy».


13. Ventajas y desventajas de la implementación en Python

Ventajas:

  • Barrera de entrada baja, código legible, prototipo rápido.
  • Un ecosistema riquísimo: de requests a Scrapy y Playwright.
  • Una comunidad enorme: casi cualquier problema ya está resuelto.
  • Integración sencilla con el análisis de datos (pandas, numpy) y las bases de datos.

Desventajas:

  • El GIL limita el procesamiento CPU-bound (se sortea con async y multiprocessing).
  • El Python puro es más lento que los lenguajes compilados en el parseo pesado (lo salvan lxml/selectolax, escritos en C).
  • Los sitios dinámicos con JS exigen motores de navegador pesados.
  • Fragilidad: cuando el sitio cambia su maquetación, los selectores se rompen y hace falta mantenimiento.

14. Aspectos legales y éticos

El web scraping es una herramienta potente, y conviene usarla con responsabilidad:

  • Respete robots.txt y las condiciones de uso del sitio.
  • No genere una carga excesiva: añada pausas y limite la concurrencia.
  • No recopile datos personales sin una base legal (recuerde el RGPD).
  • Indique un User-Agent honesto cuando proceda y cachee las respuestas para no castigar el servidor más de la cuenta.