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
- Qué es el web scraping y de qué se compone
- Panorama de bibliotecas y soluciones
- Cómo obtenemos la página
- Bibliotecas para parsear el contenido
- Solución de problemas con codificaciones y caracteres especiales
- Uso de multihilo
- Uso de proxies
- Scraping a través de TOR
- Trabajo con HTTPS/SSL
- Trabajo con cookies
- Estado de la respuesta y cabeceras
- Almacenamiento de URL y colas
- Ventajas y desventajas de la implementación
- 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:
- Obtención de la página — petición HTTP al servidor y recepción de la respuesta (HTML, XML, JSON).
- Parseo del contenido — extracción de los datos necesarios de la estructura mediante selectores (CSS, XPath) o expresiones regulares.
- Normalización y almacenamiento — conversión de los datos a un formato uniforme y escritura en un archivo o base de datos.
- 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:
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.textPuntos clave:
- User-Agent: defínalo siempre — muchos sitios bloquean las peticiones que llegan con el
python-requestspor 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:
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
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").textBeautifulSoup 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
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:
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
response = requests.get(url)
response.encoding = "utf-8" # o "windows-1252" en sitios antiguos
html = response.textSolución 2: detección automática
response = requests.get(url)
response.encoding = response.apparent_encoding # se detecta a partir del contenido
html = response.textapparent_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>:
from bs4 import BeautifulSoup
response = requests.get(url)
soup = BeautifulSoup(response.content, "lxml") # .content, no .textresponse.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
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
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
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
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:
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 suyaEl 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:
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)
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
response = requests.get(url, verify=False) # NO apto para producciónDesactivar 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
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:
pip install --upgrade certifi
10. Trabajo con cookies
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.
Session guarda las cookies automáticamente
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.
Pasar cookies a mano
cookies = {"sessionid": "abc123", "csrftoken": "xyz789"}
response = requests.get(url, cookies=cookies)Leer las cookies recibidas
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».
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 respuestaGestión correcta de los estados
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 IPLa 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
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_visitset 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.