Saltar al contenido
Volver al blog

Web scraping con Python en 2026: la guía práctica para desarrolladores

Raluca PenciucÚltima actualización el 35 min read
Web scraping con Python en 2026: la guía práctica para desarrolladores
En resumen: Esta es una guía práctica sobre el scraping web con Python que comienza con un árbol de decisión y, a continuación, repasa Requests junto con Beautiful Soup, XPath con lxml, Playwright, Selenium, Scrapy y una API de scraping para objetivos difíciles. Al finalizar, tendrás código funcional, un manual para evitar bloqueos, patrones de almacenamiento preparados para LLM y una lista de comprobación para la puesta en producción.

Si escribes python web scraping en una barra de búsqueda, obtendrás un centenar de tutoriales que instalan siempre las mismas tres bibliotecas y se detienen antes de llegar a las partes interesantes. Esta guía es diferente. El scraping web con Python consiste en escribir un script que recupere una página web, analice su HTML y extraiga campos estructurados que puedas importar a una hoja de cálculo, una base de datos o un flujo de trabajo de modelos. La mecánica es sencilla. La razón por la que a la gente le cuesta es que la herramienta adecuada depende del sitio de destino, y elegir la incorrecta supone una pérdida de horas.

Este tutorial tiene un enfoque concreto. Comienza con un breve árbol de decisión para que no instales Playwright en una página que Requests podría haber analizado en cincuenta líneas. A continuación, recorre todas las capas de una pila de scraping real: HTML estático, renderizado de JavaScript, selectores CSS frente a XPath, limpieza de campos, escalabilidad con Scrapy, rotación de proxies, gestión de errores HTTP, almacenamiento de resultados en CSV, SQLite o JSONL para modelos de lenguaje grandes (LLM) posteriores, y programación de ejecuciones periódicas.

Verás el código de diferentes bibliotecas comparado lado a lado para el mismo objetivo, de modo que las ventajas e inconvenientes sean evidentes en lugar de quedar en el aire. También encontrarás comentarios sinceros sobre bloqueos, costes de mantenimiento y cuándo una API alojada supone un mejor uso de tu tiempo que otra time.sleep(random.uniform(1, 3)) . Si alguna vez has escrito un rastreador en Python y ha fallado en producción, esta guía te mostrará por qué y cómo evitar que vuelva a ocurrir.

Por qué el scraping web con Python es la opción por defecto en 2026

El scraping web con Python domina el sector por tres razones: una sintaxis legible, un ecosistema maduro y una integración sencilla con el resto de la pila de datos. Una docena de líneas de Python pueden descargar una página, analizarla y ofrecerte una lista de diccionarios listos para pandas. Ese breve camino desde la «URL» hasta el «dataframe» es la razón por la que los ingenieros de datos, los analistas y los equipos de aprendizaje automático siguen recurriendo a él en lugar de a Go o Node.

El conjunto de bibliotecas es excepcionalmente amplio. Requests y httpx se encargan de la capa HTTP. Beautiful Soup y lxml analizan el HTML mediante selectores CSS o XPath. Playwright y Selenium controlan navegadores reales para páginas renderizadas en JavaScript. Scrapy proporciona un marco completo de rastreo con concurrencia, reintentos, flujos de elementos y formatos de exportación listos para usar. Todos ellos han sido probados en la práctica y se mantienen activamente.

La segunda parte de la historia es la capacidad de integración. Los datos extraídos fluyen directamente a pandas, DuckDB, SQLite, Parquet o JSONL para su ingesta en modelos de lenguaje a gran escala (LLM). Los cuadernos de trabajo agilizan la iteración. Las sugerencias de tipos y las clases de datos garantizan la integridad de los registros. Tampoco existe una brecha significativa entre el prototipo y la producción, ya que el mismo script que funciona en Jupyter se ejecuta bajo cron o en un contenedor sin necesidad de reescribirlo. Esa continuidad de principio a fin es lo que convierte a Python en la opción predeterminada y pragmática para el scraping web en 2026, y la razón por la que el resto de esta guía se basa exclusivamente en él.

Elige la herramienta adecuada: un árbol de decisión para tu sitio web de destino

Antes de instalar nada, responde a cuatro preguntas sobre el sitio de destino. Cada una de ellas reduce la selección de bibliotecas en un orden de magnitud, y seguir este árbol de decisión te ahorrará más tiempo que cualquier truco de optimización.

Paso 1: ¿Tiene el sitio una API oficial? Si es así, utilízala. Una API es más rápida, más barata y legalmente más limpia que el scraping. Reserva el scraping para páginas que sean públicas pero no legibles por máquinas.

Paso 2: ¿Están los datos presentes en el código HTML sin procesar que obtienes de curl o requests.get? Abre la página en un navegador, consulta el código fuente (no «inspeccionar elemento») y busca uno de los valores que te interesan. Si está ahí, tienes una página estática. Basta con Requests y Beautiful Soup. No recurras a un navegador sin interfaz gráfica.

Paso 3: ¿El contenido lo inyecta JavaScript después de que se cargue el HTML inicial? Entonces necesitas un contexto de navegador real. Playwright es la opción predeterminada actual. Selenium está bien si tu equipo ya lo utiliza en la integración continua (CI). Ambos pueden esperar a selectores, hacer clic, desplazarse y extraer datos del DOM renderizado.

Paso 4: ¿Estás rastreando miles de URL, encadenando solicitudes o ejecutando tareas de larga duración? Pásate a Scrapy. Su bucle de eventos, sus canalizaciones de elementos, sus controles de concurrencia y sus reintentos integrados superan a cualquier bucle de desarrollo propio.

Existe una quinta opción para los objetivos difíciles. Si el sitio identifica agresivamente los navegadores, bloquea las IP de los centros de datos o muestra CAPTCHAs desde la primera visita, mantén tu código de análisis pero delega la capa de obtención de datos a una API de scraping. Esa es una solución alternativa, no la opción por defecto.

Utiliza esta guía rápida:

Señal en el sitio de destino

Mejor herramienta

API pública disponible

La API

Datos en el HTML inicial

Solicitudes + Beautiful Soup

Contenido renderizado con JavaScript

Playwright (o Selenium)

Miles de páginas, reintentos, flujos de trabajo

Scrapy

CAPTCHAs, bloqueos de IP, huellas digitales TLS

API de scraping

La mayoría de los proyectos de scraping web en Python deberían comenzar con la herramienta más ligera que permita obtener los datos, y pasar a una siguiente solo cuando la capa actual se quede sin margen. Esto refleja las recomendaciones de la comunidad: la mejor configuración es la más sencilla que permita obtener los datos de forma fiable.

Configuración del entorno: Python 3, Virtualenv y bibliotecas necesarias

Utiliza Python 3.10 o una versión más reciente. Crea un entorno virtual (virtualenv) aislado para cada proyecto, de modo que las dependencias no se filtren al Python de tu sistema:

python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate

pip install --upgrade pip
pip install requests beautifulsoup4 lxml pandas
pip install playwright
pip install scrapy
pip install selenium webdriver-manager

# Playwright ships as a Python package plus browser binaries.
# Install the Chromium binary once:
python -m playwright install chromium

lxml funciona a la vez como el analizador de Beautiful Soup más rápido y como el motor XPath que utilizaremos más adelante. pandas es opcional, pero útil para la limpieza y la exportación. webdriver-manager se encarga de la descarga de ChromeDriver de Selenium, por lo que no tienes que hacer coincidir manualmente las versiones del navegador en cada ejecutor de CI.

Algunas notas prácticas antes de empezar:

  • Si utilizas Apple Silicon, usa la compilación de Python para ARM64. El binario de Chromium de Playwright es mucho más rápido en ARM nativo que bajo Rosetta.
  • En Windows, activa el entorno venv con .venv\Scripts\activate y utiliza PowerShell en lugar de cmd para obtener trazas de pila legibles.
  • Fija las versiones una vez que tu rastreador funcione: pip freeze > requirements.txt. Los rastreadores son frágiles por naturaleza, y fijar un conjunto de dependencias que se sabe que funciona bien es la forma más sencilla de evitar que la ejecución de cron de mañana falle debido a una actualización silenciosa de la biblioteca.
  • Si tienes pensado utilizar contenedores, añade el playwright install paso en tu Dockerfile para que los binarios del navegador se incluyan en la imagen en lugar de descargarse en cada arranque en frío.

Examina la página de destino antes de escribir una sola línea de código

Cada hora dedicada a DevTools ahorra diez horas de depuración. Abre la página que quieras rastrear en Chrome o Firefox y, a continuación, revisa los tres paneles antes de abrir tu editor.

El panel «Elementos». Haz clic con el botón derecho en un valor que te interese y selecciona «Inspeccionar». Toma nota de la etiqueta, la clase y cualquier atributo estable como data-testid o itemprop. Los ID y las clases que parezcan generados automáticamente (css-1x9k2j) son volátiles y harán que tus selectores dejen de funcionar en la próxima implementación. Da prioridad a los atributos semánticos cuando existan.

La pestaña «Red». Actualiza la página con la pestaña «Red» abierta y filtra por XHR o Fetch. Muchos sitios web modernos muestran listas llamando a un punto final JSON interno. Si encuentras uno que devuelva los campos que necesitas, acceder directamente a ese punto final con Requests es más rápido, más fiable y menos propenso a fallos que analizar el HTML. Esta es la ventaja más importante que la mayoría de los tutoriales de scraping pasan por alto.

Copiar como cURL. Haz clic con el botón derecho en cualquier solicitud de la pestaña «Red» y selecciona «Copiar como cURL». Pégalo en tu terminal para confirmar que funciona y, a continuación, tradúcelo a Python. La herramienta curlconverter.com lo hará automáticamente, pero leer los encabezados a mano te enseña lo que el servidor realmente espera: un User-Agent, una Referer, una cookie de sesión o un token CSRF.

Si el HTML inicial contiene tus datos, estás en la ruta estática. Si tus datos solo aparecen después de que se cargue la página y se ejecute JavaScript, estás en la ruta del navegador. Si existe una API JSON oculta, opta siempre por ella. El resto de esta guía da por hecho que has realizado esa comprobación antes de escribir el código.

Crear un rastreador estático con Requests y Beautiful Soup

Para una página estática, bastan tres pasos: recuperar, analizar y extraer. Utilizaremos books.toscrape.com, un sitio de demostración público creado específicamente para que los tutoriales no sobrecarguen sitios web reales.

import requests
from bs4 import BeautifulSoup

URL = "https://books.toscrape.com/catalogue/page-1.html"
HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; MyScraper/1.0)"}

resp = requests.get(URL, headers=HEADERS, timeout=15)
resp.raise_for_status()          # raises on 4xx / 5xx

Un resp.status_code de 200 significa que la recuperación se ha realizado con éxito. Cualquier otra cosa es una señal, no una nota al pie. raise_for_status() lo convierte en una excepción que puedes interceptar. Establece un valor explícito timeout en cada solicitud. El valor por defecto es «esperar indefinidamente», que es precisamente lo que no quieres en una tarea programada.

Analiza el HTML con lxml, que es considerablemente más rápido que el html.parser:

soup = BeautifulSoup(resp.text, "lxml")

Now select. Beautiful Soup te ofrece tres formas de consultar el árbol. Las dos que utilizarás a diario son find_all para búsquedas basadas en etiquetas y select para los selectores CSS. Prefiere select cuando el elemento de destino tenga clases estables:

books = []
for card in soup.select("article.product_pod"):
    title = card.select_one("h3 a")["title"]
    price = card.select_one("p.price_color").get_text(strip=True)
    stock = card.select_one("p.instock").get_text(strip=True)
    books.append({"title": title, "price": price, "stock": stock})

print(books[:3])

Hay algunos hábitos que te ahorrarán problemas más adelante:

  • Protege cada selector. select_one devuelve None si no hay ninguna coincidencia, por lo que .get_text() se producirá un error en la siguiente iteración. Utiliza if card.select_one(...) o el operador «morsa» para omitir las filas que falten.
  • Extrae el texto con strip=True. El HTML está lleno de espacios en blanco y espacios no separables. Limpiarlos en el momento de la extracción es más sencillo que corregir cada campo posterior más adelante.
  • Almacena valores sin procesar, no valores «finales». Guarda la cadena del precio como "£51.77" y normalízala en un paso aparte. Cuando falle la extracción, querrás ver qué devolvió realmente el servidor.

Para seguir la paginación, busca el enlace «siguiente» y repite el bucle hasta que desaparezca:

def scrape_all_pages(start_url):
    url = start_url
    results = []
    while url:
        r = requests.get(url, headers=HEADERS, timeout=15)
        r.raise_for_status()
        s = BeautifulSoup(r.text, "lxml")
        for card in s.select("article.product_pod"):
            results.append({
                "title": card.select_one("h3 a")["title"],
                "price": card.select_one("p.price_color").get_text(strip=True),
            })
        next_link = s.select_one("li.next a")
        url = requests.compat.urljoin(url, next_link["href"]) if next_link else None
    return results

Este es un rastreador web de Python estático, completo y honesto: recupera los datos con un encabezado real, analiza con lxml, selecciona con CSS, comprueba si faltan nodos, sigue el enlace a la página siguiente y termina de forma limpia cuando se agote la paginación. Para una guía más detallada de Beautiful Soup con tablas, formularios y elementos anidados, consulta nuestro tutorial complementario sobre Beautiful Soup.

Hay otros dos hábitos que vale la pena adoptar desde el primer día. En primer lugar, envuelve la solicitud en un try/except requests.RequestException y registra la URL que ha fallado. Los errores de red están garantizados a gran escala, y un rastreador que se cuelga en el primer ConnectionError nunca terminará un rastreo nocturno. En segundo lugar, guarda tus resultados en el disco cada unos cientos de registros en lugar de mantenerlo todo en memoria:

import json

def checkpoint(records, path="checkpoint.jsonl"):
    with open(path, "a", encoding="utf-8") as f:
        for r in records:
            f.write(json.dumps(r, ensure_ascii=False) + "\n")

De este modo, si el proceso se bloquea en la página 47 de 100, tendrás las primeras 46 páginas en el disco y podrás reanudar el proceso sin tener que volver a recuperar los datos. Esos dos hábitos, la gestión de errores y la creación de puntos de control, marcan la diferencia entre los rastreadores de demostración y aquellos que realmente puedes dejar en ejecución.

Selectores CSS frente a XPath: elige un lenguaje de consulta y quédate con él

Beautiful Soup te ofrece selectores CSS. lxml te ofrece ambos, pero su principal atractivo es XPath. Los dos resuelven el mismo problema con diferentes modos de fallo, y elegir uno de forma deliberada es más importante que elegir el «mejor».

Los selectores CSS son más cortos, más familiares y se leen como código front-end. Son excelentes para la selección basada en clases: article.product_pod > h3 a. Sin embargo, tienen dificultades con cualquier tarea que requiera recorrer el DOM hacia arriba, buscar coincidencias en el contenido de texto o navegar en relación con un elemento hermano.

XPath es más expresivo. Permite la navegación por ejes (ancestor::, following-sibling::), la coincidencia de texto (//a[text()="Next"]) y la selección posicional ((//tr)[3]). La misma extracción con lxml y XPath quedaría así:

from lxml import html

tree = html.fromstring(resp.text)
titles = tree.xpath("//article[contains(@class,'product_pod')]//h3/a/@title")
prices = tree.xpath("//article[contains(@class,'product_pod')]//p[@class='price_color']/text()")

La cuestión del mantenimiento es más importante que la estética. Los selectores que se basan en nombres de clase dejan de funcionar el mismo día en que el diseñador del sitio cambia el nombre de una clase. Los selectores que se basan en la estructura (div > div > span:nth-child(2)) dejan de funcionar el mismo día en que alguien añade un contenedor. Las expresiones XPath basadas en contains(@class, ...) o en atributos estables como [@itemprop="price"] , resisten ambas situaciones. En la práctica, los equipos que eligen XPath suelen escribir rastreadores más resistentes, a costa de una curva de aprendizaje ligeramente más pronunciada.

Elige un lenguaje de consulta por proyecto y mantén la coherencia. Un código fuente que mezcle soup.select(...) y tree.xpath(...) es aquella en la que cada nuevo ingeniero tiene que aprender ambos. Nuestra guía de XPath y nuestra comparación entre XPath y los selectores CSS abordan los patrones de navegación por ejes que hacen que merezca la pena pasarse a XPath cuando el marcado cambia con frecuencia.

Una opción predeterminada pragmática para los equipos de scraping web en Python: empieza cada nuevo rastreador con Beautiful Soup y selectores CSS, ya que se leen igual que el frontend del que estás extrayendo datos. Recurre a XPath en cuanto te encuentres con un caso en el que necesites recorrer el árbol hacia arriba, filtrar por contenido de texto o navegar por índice. Esto cubre aproximadamente el 90 % de los trabajos con la herramienta más sencilla y reserva la herramienta más expresiva para los casos en los que realmente vale la pena.

Limpia y normaliza los campos extraídos

Las cadenas sin procesar extraídas casi nunca son utilizables. Los precios aparecen con símbolos de moneda, las fechas en media docena de formatos, espacios en blanco por todas partes y, de vez en cuando, un \u00a0 espacio no separable oculto en un texto que, por lo demás, está limpio. Separa la extracción de la normalización para que ambos pasos sigan siendo depurables.

import re
from datetime import datetime

def to_float_price(raw: str) -> float | None:
    if not raw:
        return None
    cleaned = re.sub(r"[^\d.,]", "", raw).replace(",", "")
    try:
        return float(cleaned)
    except ValueError:
        return None

def to_iso_date(raw: str, fmt: str) -> str | None:
    try:
        return datetime.strptime(raw.strip(), fmt).date().isoformat()
    except (ValueError, AttributeError):
        return None

def clean_text(raw: str) -> str:
    return " ".join(raw.replace("\xa0", " ").split()) if raw else ""

Aplica estas funciones auxiliares en una sola pasada por tus registros sin procesar:

def normalize(record):
    return {
        "title": clean_text(record["title"]),
        "price": to_float_price(record["price"]),
        "in_stock": "in stock" in clean_text(record["stock"]).lower(),
    }

normalized = [normalize(r) for r in raw_records]

Elimina duplicados antes de guardar. Una clave primaria estable basada en la URL o el ID del producto evita que tu cron diario multiplique silenciosamente las filas. Si estás exportando a pandas, df.drop_duplicates(subset=["url"]) puedes hacerlo en una sola línea.

Hay dos problemas de codificación que conviene conocer. En primer lugar, requests adivina la codificación de la respuesta a partir de los encabezados y puede equivocarse; configúralo resp.encoding = "utf-8" explícitamente si ves caracteres ilegibles. En segundo lugar, algunos sitios devuelven caracteres escapados como entidades HTML, como ’ incluso en los navegadores modernos. Beautiful Soup los descodifica al analizarlos, pero si obtienes JSON directamente, utiliza html.unescape() antes de almacenarlos. Una normalización coherente es lo que distingue a un rastreador de demostración de un conjunto de datos que realmente se pueda consultar.

Extraer páginas renderizadas con JavaScript mediante Playwright

Las herramientas estáticas fallan en cuanto un sitio web renderiza su contenido en el navegador. Las aplicaciones modernas de React, Vue y Svelte suelen devolver una estructura HTML casi vacía y, a continuación, rellenan el DOM con el JSON obtenido. Beautiful Soup verá la estructura, no los datos. Necesitas un contexto de navegador real, y Playwright es la forma más limpia de conseguirlo en Python.

Instálalo una vez (esto incluye también el binario de Chromium):

pip install playwright
python -m playwright install chromium

Un rastreador completo y ejecutable para una página renderizada con JavaScript:

from playwright.sync_api import sync_playwright

def scrape_js_page(url: str):
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context(
            user_agent="Mozilla/5.0 (compatible; MyScraper/1.0)",
            viewport={"width": 1366, "height": 900},
        )
        page = context.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=30_000)

        # Wait for the element that only appears after JS runs.
        page.wait_for_selector("article.product_pod", timeout=15_000)

        cards = page.query_selector_all("article.product_pod")
        results = []
        for card in cards:
            title = card.query_selector("h3 a").get_attribute("title")
            price = card.query_selector("p.price_color").inner_text().strip()
            results.append({"title": title, "price": price})

        browser.close()
        return results

Hay dos detalles que determinan si este código es fiable o inestable.

Estrategia de espera. wait_until="domcontentloaded" Se activa cuando se analiza el HTML inicial. "networkidle" espera hasta que la actividad de red se detenga durante 500 ms, lo cual es más estricto pero más lento. En aplicaciones de una sola página, es preferible wait_for_selector en un elemento concreto que realmente necesites. Es más rápido que networkidle y menos frágil que un time.sleep.

Gestión de la carga dinámica. Para el desplazamiento infinito, utiliza page.mouse.wheel(0, 2000) en un bucle hasta que el recuento de registros deje de cambiar. Para el contenido al que se accede tras un clic, page.click("button.load-more") y vuelve a seleccionar. Para páginas que requieren inicio de sesión, inicia sesión una vez con context.storage_state(path="auth.json") y reutiliza el archivo de estado en todas las ejecuciones para no tener que volver a autenticarte en cada trabajo.

Playwright también admite asincronía, la interceptación de solicitudes (bloquear imágenes y fuentes para acelerar el proceso) y la ejecución en múltiples navegadores. Para rastreos de gran volumen, suele combinarse con Scrapy a través de scrapy-playwright. Nuestra guía de Playwright trata estos patrones en profundidad.

En el caso de las páginas con desplazamiento infinito, un patrón habitual es desplazarse hasta que el recuento de registros deje de aumentar:

def scroll_until_stable(page, selector, max_rounds=20):
    prev = 0
    for _ in range(max_rounds):
        page.mouse.wheel(0, 4000)
        page.wait_for_timeout(1000)
        count = len(page.query_selector_all(selector))
        if count == prev:
            break
        prev = count
    return prev

Para reducir aún más la sobrecarga del navegador, bloquea los recursos pesados que no necesites:

context.route("**/*.{png,jpg,jpeg,gif,svg,woff2,mp4}", lambda route: route.abort())

Esa única línea suele reducir el tiempo de carga de la página en un 40 % o más en sitios con gran cantidad de contenido multimedia, ya que se omiten descargas que, de todos modos, acabarías descartando. En combinación con wait_for_selector, esto hace que Playwright sea realmente competitivo en cuanto a velocidad frente a los rastreadores estáticos en aquellas páginas que requieren renderización con JavaScript. Cuando ejecutes Playwright a gran escala, inicia un contexto de navegador persistente por cada trabajador en lugar de un navegador nuevo por cada URL, y reutiliza la misma página en solicitudes similares. El arranque en frío de Chromium cuesta aproximadamente un segundo por página, y esa sobrecarga domina cualquier rastreo real.

Selenium como alternativa para páginas con JavaScript

Selenium es anterior a Playwright en aproximadamente una década, y muchos equipos siguen utilizándolo en la integración continua (CI) porque su infraestructura de pruebas ya está adaptada a WebDriver. Es una opción perfectamente razonable para el scraping si eres uno de esos equipos.

Un rastreador mínimo de Selenium que utiliza webdriver-manager para que no tengas que descargar manualmente ChromeDriver:

from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager

opts = Options()
opts.add_argument("--headless=new")
opts.add_argument("--window-size=1366,900")

driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=opts)
driver.get("https://books.toscrape.com/")

WebDriverWait(driver, 15).until(
    EC.presence_of_element_located((By.CSS_SELECTOR, "article.product_pod"))
)

results = []
for card in driver.find_elements(By.CSS_SELECTOR, "article.product_pod"):
    title = card.find_element(By.CSS_SELECTOR, "h3 a").get_attribute("title")
    price = card.find_element(By.CSS_SELECTOR, "p.price_color").text.strip()
    results.append({"title": title, "price": price})

driver.quit()

Selenium se basa en un WebDriver, que actúa como puente entre tu script de Python y el proceso real del navegador. Chrome utiliza ChromeDriver, Firefox utiliza GeckoDriver, Edge utiliza EdgeDriver, y cada uno debe coincidir con la versión principal del navegador.

En comparación con Playwright, Selenium tiene un arranque más lento, una API ligeramente más prolija y carece de interceptación de peticiones integrada. Sus puntos fuertes son la madurez de su ecosistema, la compatibilidad de primer nivel con la mayoría de los proveedores de CI y el hecho de que tus ingenieros de pruebas ya lo conocen. Si estás empezando desde cero, Playwright es más sencillo. Si vas a ampliar un conjunto de pruebas ya existente, Selenium es una buena opción. Nuestra guía práctica de Selenium aborda patrones de escalabilidad y técnicas para eludir Cloudflare cuando el flujo predeterminado se bloquea.

Un detalle específico de Selenium que conviene tener muy presente: da siempre prioridad a WebDriverWait con condiciones explícitas (presence_of_element_located, element_to_be_clickable) en lugar de time.sleep. Las esperas explícitas finalizan en el momento en que se cumple la condición; las esperas de tiempo de inactividad suponen una pérdida de tiempo en páginas rápidas y siguen fallando en las lentas.

Escalar con Scrapy: arañas, canalizaciones y exportaciones

Scrapy no es una biblioteca de análisis sintáctico más. Es un marco completo de rastreo: un motor asíncrono, una pila de middleware para encabezados y proxies, pipelines de elementos para la limpieza y la persistencia, y exportaciones integradas a JSON, JSONL y CSV. Recurre a él cuando tu tarea supere aproximadamente las mil URL, necesite reintentos o tenga varias etapas de salida.

Crear la estructura de un proyecto:

scrapy startproject bookstore
cd bookstore
scrapy genspider books books.toscrape.com

Editar bookstore/spiders/books.py:

import scrapy

class BooksSpider(scrapy.Spider):
    name = "books"
    start_urls = ["https://books.toscrape.com/catalogue/page-1.html"]
    custom_settings = {
        "DOWNLOAD_DELAY": 0.5,
        "CONCURRENT_REQUESTS": 8,
        "USER_AGENT": "Mozilla/5.0 (compatible; MyScraper/1.0)",
        "RETRY_TIMES": 3,
    }

    def parse(self, response):
        for card in response.css("article.product_pod"):
            yield {
                "title": card.css("h3 a::attr(title)").get(),
                "price": card.css("p.price_color::text").get(),
                "stock": card.css("p.instock::text").re_first(r"\S.*"),
            }
        next_page = response.css("li.next a::attr(href)").get()
        if next_page:
            yield response.follow(next_page, self.parse)

Ejecutar y exportar:

scrapy crawl books -O books.jsonl

Ese único comando rastrea todas las páginas, sigue el enlace a la página siguiente, aplica tu configuración de retardo y reintentos, y envía los resultados en formato JSONL. La -O sobrescribe el archivo; -o añade al final.

Hay dos características de Scrapy que conviene conocer desde el principio:

Las cadenas de procesamiento de elementos (item pipelines) procesan cada elemento rastreado a través de una cadena de clases de Python. Úsalas para la normalización, la deduplicación y la persistencia. Un PriceCleanerPipeline puede eliminar los símbolos monetarios; una SQLitePipeline puede insertarlo en una base de datos. Conecta las cadenas de procesamiento en settings.py bajo ITEM_PIPELINES.

Los middlewares se integran en cada solicitud o respuesta. Aquí es donde encajan la rotación de proxies, la rotación de encabezados y la gestión de Cloudflare. Los middlewares de la comunidad, como scrapy-rotating-proxies y scrapy-user-agents se integran con solo dos líneas de configuración.

Cuando te encuentres con páginas con mucho JavaScript, no renuncies a Scrapy. Añade scrapy-playwright y marca las solicitudes específicas con meta={"playwright": True}. De este modo, mantienes la concurrencia, los pipelines y las exportaciones de Scrapy, al tiempo que solo pagas el coste del navegador por las páginas que lo necesitan. Nuestra guía práctica de Scrapy y la guía de integración de scrapy-playwright tratan este patrón híbrido.

Un pipeline mínimo que normaliza los precios y elimina los duplicados tiene este aspecto:

# bookstore/pipelines.py
import re

class BookstorePipeline:
    def __init__(self):
        self.seen = set()

    def process_item(self, item, spider):
        title = item.get("title")
        if title in self.seen:
            raise DropItem(f"duplicate: {title}")
        self.seen.add(title)
        raw_price = item.get("price") or ""
        item["price"] = float(re.sub(r"[^\d.]", "", raw_price) or 0)
        return item

Incorpóralo settings.py con ITEM_PIPELINES = {"bookstore.pipelines.BookstorePipeline": 300}. El número entero indica la prioridad; los más bajos se ejecutan primero. Añade más clases para la validación, el almacenamiento y las alertas de Slack, y toda la cadena de posprocesamiento se vuelve declarativa.

Cuándo prescindir de Scrapy. Para un rastreo puntual de una sola página, la estructura de proyecto de Scrapy es excesiva. Para cualquier tarea recurrente, o cualquier cosa que afecte a más de unos pocos cientos de URL, la concurrencia y los reintentos predeterminados amortizan inmediatamente el código repetitivo. Una regla práctica útil: si te ves escribiendo tu propio bucle asíncrono, grupo de subprocesos o decorador de reintentos en torno a Requests, has reinventado lo suficiente de Scrapy como para que simplemente lo utilices.

Utiliza una API de scraping web para objetivos antibots

Algunos sitios se resistirán a todo lo que has leído hasta ahora. Identifican tu protocolo de enlace TLS, bloquean las IP de los centros de datos, te lanzan CAPTCHAs al primer contacto o devuelven un 200 OK con un cuerpo que dice «por favor, activa JavaScript». Playwright puede superar algunos de estos obstáculos; los proxies residenciales pueden superar aún más. Cuando la combinación sigue fallando, una API de scraping alojada es la solución más pragmática.

Una API de scraping toma una URL y devuelve HTML. Se encarga de la capa de solicitud: renderización del navegador, rotación de proxies, reintentos y evasión de medidas anti-bot. Tú conservas tu código de análisis existente con Beautiful Soup o lxml y solo cambias la llamada de recuperación. Eso significa que no tienes que gestionar grupos de proxies, mantener actualizadas las huellas digitales de los navegadores ni depurar por qué el solucionador de CAPTCHA de una región va más lento hoy.

Una llamada típica tiene este aspecto:

import requests

API_ENDPOINT = "https://api.webscrapingapi.com/v2"
params = {
    "api_key": "YOUR_KEY",
    "url": "https://example.com/hard-target",
    "render_js": "true",
    "proxy_type": "residential",
    "country": "us",
}
resp = requests.get(API_ENDPOINT, params=params, timeout=60)
resp.raise_for_status()
html = resp.text

# Parse with the same code you would use on a static page.
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")

Los parámetros son importantes. render_js=true ejecuta el objetivo a través de un navegador sin interfaz gráfica en el lado del servidor. proxy_type=residential Enruta a través de direcciones IP que parecen tráfico doméstico. country=us geolocaliza la solicitud cuando el sitio de destino ofrece contenido diferente según la región.

Hay dos limitaciones importantes que conviene destacar. En primer lugar, se paga por solicitud, por lo que los costes de la API de scraping aumentan linealmente con el volumen; si tu página es estática y no bloquea nada, Requests más Beautiful Soup resulta mucho más barato. En segundo lugar, algunos proveedores publican cifras muy elevadas de parques de proxies como datos de marketing; considera esos recuentos específicos como orientativos hasta que los confirmes en la documentación actualizada del proveedor. La decisión correcta no es «API por defecto», sino «API cuando la capa de obtención de datos es la que falla».

Una regla práctica útil: si dedicas más de un día a la semana a depurar bloqueos en lugar de errores de análisis, la capa de obtención ha dejado de ser tu problema para pasar a ser el de otra persona. Delégaselo a una API de scraping y recupera las horas de ingeniería para el trabajo de extracción y modelado que solo tu equipo puede realizar.

Gestiona los errores HTTP, los reintentos y los tiempos de espera

Un rastreador sin una política de reintentos es un rastreador que fallará en su primera noche en producción. Las respuestas HTTP te indican exactamente qué hacer, si prestas atención.

Estado

Significado

Reacción correcta

200

Éxito

Analizar y continuar

301 / 302

Redirigir

Seguir (las solicitudes lo hacen por defecto)

403

Prohibido

Rotar IP o agente de usuario, comprobar si hay medidas anti-bot

404

No encontrado

Omitir y registrar; el recurso ya no existe o la URL es incorrecta

429

Demasiadas solicitudes

Respeta Retry-After, retrocede, reduce la velocidad

5xx

Error del servidor

Reintentar con retrasos exponenciales y, si no, abandonar

Las solicitudes más urllib3 te ofrece una política de reintentos adecuada en unas pocas líneas:

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

session = requests.Session()
retry = Retry(
    total=5,
    backoff_factor=1.5,           # 1.5s, 3s, 6s, 12s, 24s
    status_forcelist=[429, 500, 502, 503, 504],
    allowed_methods=["GET", "HEAD"],
    respect_retry_after_header=True,
)
session.mount("https://", HTTPAdapter(max_retries=retry))
session.mount("http://", HTTPAdapter(max_retries=retry))

resp = session.get(url, timeout=(5, 30))    # (connect, read)

Establece valores explícitos timeout en cada solicitud. Una tupla de (connect, read) segundos es más seguro que un único número y evita el clásico fallo de que «un servidor de origen lento bloquee todo el rastreo».

En Playwright, los tiempos de espera se definen por acción: page.goto(url, timeout=30_000) y page.wait_for_selector(sel, timeout=15_000). Los reintentos deben ser explícitos, ya que Playwright no los realiza automáticamente. Envuelve las navegaciones en tu propio bucle o utiliza tenacity para obtener un decorador declarativo:

from tenacity import retry, stop_after_attempt, wait_exponential

@retry(stop=stop_after_attempt(4), wait=wait_exponential(multiplier=1.5, min=2, max=30))
def goto_with_retry(page, url):
    page.goto(url, wait_until="domcontentloaded", timeout=30_000)

Registra el código de estado, la URL y el número de intento en cada reintento. Los reintentos silenciosos son la forma en que los rastreadores «funcionan» durante semanas mientras devuelven datos obsoletos. Otro consejo: limita el presupuesto total de reintentos por URL, no solo el número de intentos. Una solicitud que tarda 90 segundos en cuatro reintentos es peor que una que falla rápidamente a los 15 segundos y sigue adelante, porque el fallo lento retrasa a todas las demás URL que comparten ese trabajador.

Evita que te bloqueen: proxies, encabezados y límites de frecuencia

La mayoría de los bloqueos se deben a tres señales: tus encabezados no se parecen a los de un navegador, tus solicitudes son demasiado rápidas y tu IP está en una lista de bloqueo de un centro de datos. Soluciona las tres.

Rota un conjunto de user-agents reales y envía los encabezados que enviaría un navegador real. Los sitios identifican a los usuarios por Accept, Accept-Language, y Accept-Encoding también, no solo User-Agent.

import random, time

USER_AGENTS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 ...",
    # ...more real UAs
]

def browser_headers():
    return {
        "User-Agent": random.choice(USER_AGENTS),
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Accept-Language": "en-US,en;q=0.9",
        "Accept-Encoding": "gzip, deflate, br",
    }

Añade variabilidad a la cadencia de tus solicitudes. Un ritmo fijo time.sleep(1) es más fácil de detectar que una cadencia aleatoria propia de un usuario humano.

time.sleep(random.uniform(1.2, 3.5))

Alterna los proxies, a ser posible los residenciales. Las IP de centros de datos se bloquean en masa porque son fáciles de detectar. Los proxies residenciales se enrutan a través de dispositivos de usuarios reales y pasan desapercibidos.

PROXIES = [
    "http://user:pass@proxy1.example.com:8000",
    "http://user:pass@proxy2.example.com:8000",
    # ...
]

def get(url):
    proxy = random.choice(PROXIES)
    return requests.get(
        url,
        headers=browser_headers(),
        proxies={"http": proxy, "https": proxy},
        timeout=(5, 30),
    )

Detecta rápidamente si te han bloqueado. Una respuesta con estado 200 y un cuerpo que contenga «Just a moment...», «Attention Required» o «cf-chl-bypass» es un desafío de Cloudflare, no contenido real.

def looks_blocked(resp):
    body = resp.text[:5000].lower()
    return any(k in body for k in [
        "just a moment", "attention required", "cf-chl-bypass",
        "captcha", "access denied",
    ])

Cuando looks_blocked se active la alerta, rota el proxy, retrocede de forma agresiva y considera pasar la solicitud a una API de scraping. Nuestras guías sobre rotación de proxies y estrategias «no-block» profundizan en los patrones de recuperación tras bloqueos de IP.

Recupérate de los bloqueos de IP sin reiniciar el proceso. Mantén los proxies que funcionan en un collections.deque. Cuando uno reciba un 403 o un CAPTCHA, rotalo al final de la lista y aplícale un tiempo de espera antes de que vuelva al principio. Si todo el conjunto está inactivo, espera a que pase el bloqueo en lugar de cerrar el programa:

from collections import deque
pool = deque(PROXIES)

def next_proxy():
    proxy = pool.popleft()
    pool.append(proxy)
    return proxy

Esto no es suficiente para los objetivos más difíciles, pero mantendrá en funcionamiento un rastreador que se comporte correctamente en la mayoría de los sitios durante meses sin necesidad de intervención manual. Cuando deje de ser suficiente, esa será la señal para cambiar la capa de obtención de datos por una API alojada, en lugar de seguir ampliando tu grupo de proxies.

Almacenamiento de datos extraídos: CSV, JSON, SQLite y formatos compatibles con LLM

La elección del almacenamiento depende de cómo se vayan a utilizar los datos. Elige con cuidado; convertirlos más adelante es complicado.

CSV para hojas de cálculo y traspasos rápidos:

import csv
with open("books.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.DictWriter(f, fieldnames=["title", "price", "in_stock"])
    writer.writeheader()
    writer.writerows(records)

JSON para registros anidados y resultados con formato de API:

import json
with open("books.json", "w", encoding="utf-8") as f:
    json.dump(records, f, ensure_ascii=False, indent=2)

SQLite para ejecuciones repetidas con deduplicación y consultas. Es un solo archivo, sin servidores, y viene incluido con Python:

import sqlite3
conn = sqlite3.connect("books.db")
conn.execute(
    "CREATE TABLE IF NOT EXISTS books ("
    " url TEXT PRIMARY KEY,"
    " title TEXT, price REAL, scraped_at TEXT)"
)
conn.executemany(
    "INSERT OR REPLACE INTO books VALUES (?, ?, ?, ?)",
    [(r["url"], r["title"], r["price"], r["scraped_at"]) for r in records],
)
conn.commit()

Parquet para cargas de trabajo analíticas que acabarán en DuckDB, Spark o un almacén de datos: pandas.DataFrame(records).to_parquet("books.parquet"). En formato columnar y tipado, por lo que las consultas posteriores son rápidas.

Salida JSONL preparada para LLM

Si tus datos extraídos van a alimentar un LLM o un proceso de incrustación, envíalos en formato JSONL: un objeto JSON por línea, un registro por documento. Cada registro debe incluir un identificador estable, la URL de origen, una marca de tiempo y un text campo que el modelo pueda dividir en fragmentos.

def to_llm_record(item):
    return {
        "id": item["url"],
        "source_url": item["url"],
        "scraped_at": item["scraped_at"],
        "title": item["title"],
        "text": f"{item['title']}\n\nPrice: {item['price']}\n\n{item.get('description', '')}",
        "metadata": {"category": item.get("category"), "in_stock": item["in_stock"]},
    }

with open("books.jsonl", "w", encoding="utf-8") as f:
    for r in records:
        f.write(json.dumps(to_llm_record(r), ensure_ascii=False) + "\n")

Mantén los fragmentos por debajo de unos 2000 tokens, conserva las URL de origen para su citación y nunca fusiones registros de diferentes fuentes. Esos metadatos por registro son los que permiten que un proceso RAG posterior mantenga la integridad de los datos.

Alimentación de resultados en pandas o en un almacén de datos. Para flujos de trabajo analíticos, carga tu JSONL o SQLite directamente en un dataframe y continúa en memoria:

import pandas as pd
df = pd.read_json("books.jsonl", lines=True)
df["price"] = df["price"].astype(float)
df.to_parquet("books.parquet")

Para cualquier cosa más grande, duckdb.read_json("books.jsonl") te permite ejecutar consultas SQL sobre el mismo archivo sin cargarlo en memoria. Ambos enfoques mantienen la fase de extracción completamente separada de la fase de análisis, que es lo que se busca cuando el rastreador cambia y el análisis permanece igual.

Programar y supervisar rastreadores recurrentes

Un rastreador que ejecutas manualmente es un pasatiempo. Un rastreador que ejecutas de forma programada es infraestructura de datos, y requiere la misma disciplina que cualquier otra tarea.

Cron es la forma más rápida de programar tareas en Linux. Edita con crontab -e:

undefined0 6 * * * /path/to/.venv/bin/python /path/to/scraper.py >> /var/log/scraper.log 2>&1

Esto se ejecuta a diario a las 06:00 y captura stdout y stderr. Si tu tarea dura más de unos minutos o tiene dependencias, es preferible utilizar un temporizador de systemd, que te ofrece una semántica adecuada de inicio y parada, políticas de reinicio e integración con journalctl. Para la programación durante el desarrollo, la schedule biblioteca es ligera y legible:

import schedule, time
schedule.every().day.at("06:00").do(run_job)
while True:
    schedule.run_pending()
    time.sleep(30)

Registra de forma estructurada. Los registros de texto sin formato no se pueden buscar; los registros JSON se envían a Loki, Datadog o una tabla SQLite sin necesidad de posprocesamiento:

import logging, json
logging.basicConfig(level=logging.INFO, format="%(message)s")

def log_event(event, **fields):
    logging.info(json.dumps({"event": event, **fields}))

log_event("page_scraped", url=url, status=resp.status_code, items=len(items))

Genera alertas sobre las métricas que importan: errores 403 repetidos, una caída repentina en el recuento de elementos (un indicio habitual de que el sitio ha cambiado el marcado) y un tiempo de ejecución total que supere un umbral. Un rastreador silencioso que devuelve cero filas es peor que uno que se cuelga de forma ruidosa, ya que es posible que no te des cuenta hasta pasadas varias semanas. Establece una comprobación de «número mínimo de filas esperado» y avísate a ti mismo si el número cae por debajo de ese umbral:

MIN_EXPECTED = 400
if len(items) < MIN_EXPECTED:
    log_event("row_count_alert", got=len(items), expected=MIN_EXPECTED)
    raise SystemExit(2)     # non-zero exit code trips your cron alerter

En el caso de tareas de scraping web en Python de larga duración, envía también una señal de vida cada N páginas para que un sistema de vigilancia externo (Healthchecks.io, Uptime Kuma o un simple centinela cron) pueda detectar si un proceso se ha bloqueado o si simplemente va lento. El silencio es el modo de fallo contra el que debes diseñar.

Depuración de rastreadores averiados: una lista de comprobación repetible

Los rastreadores fallan por un número reducido de razones. Sigue esta lista de comprobación en orden; la solución casi siempre se encuentra en los tres primeros pasos.

  1. Comprueba la longitud del HTML sin procesar. print(len(resp.text)). Un cuerpo de menos de unos pocos kilobytes suele indicar una página de autenticación, un límite de frecuencia o un código vacío antes de que se ejecute el JavaScript. Si la longitud ha disminuido en comparación con una referencia que funciona, el problema está en la capa de obtención de datos, no en el analizador sintáctico.
  2. Guarda la página renderizada en el disco y ábrela en un navegador. Path("debug.html").write_bytes(resp.content). Nueve de cada diez veces, esto muestra al instante si has obtenido contenido real, una pantalla de inicio de sesión o el mensaje «por favor, activa JavaScript».
  3. Compara los selectores con una instantánea que sepas que funciona. Mantén un pequeño tests/fixtures/ directorio con muestras de páginas reales de cuando el rastreador funcionaba. Cuando algo falla, diff compara el HTML actual con la instantánea de referencia y busca clases renombradas, nuevos contenedores o elementos eliminados.
  4. Comprueba si hay carga diferida. Si el número de elementos ha disminuido pero la página se ve normal en el navegador, es probable que el sitio haya cambiado a listas virtualizadas o desplazamiento infinito. Vuelve a abrir la pestaña «Red» y busca un punto final JSON al que el frontend ahora recurre tras el montaje.
  5. Busca una API JSON oculta. Con el tiempo, las interfaces de usuario se refactorizan hacia las API. Lo que antes requería Playwright a menudo se convierte en una sola fetch() llamada a /api/products?page=2. Ese punto final es estable, rápido y, casi siempre, la respuesta correcta una vez que existe.
  6. Comprueba la semántica HTTP. ¿Estás recibiendo 200 pero con un JSON vacío? ¿Una 403 de forma intermitente? ¿Cookies incorrectas? Añade registros sobre los códigos de estado, los encabezados de respuesta y las cookies antes de dar por hecho que el analizador es el culpable.

Marcos legales y éticos para el scraping con Python

El scraping web es legal en muchas jurisdicciones cuando se aplica a datos públicos, pero «legal» no es lo mismo que «responsable». Haz las dos cosas bien.

Respeta robots.txt. El Protocolo de Exclusión de Robots es ahora un estándar formal de Internet publicado como RFC 9309 de la IETF y, aunque no es en sí mismo una ley, muchos documentos de condiciones de servicio lo incorporan por referencia. Analízalo con la función integrada de Python urllib.robotparser y omite las rutas no permitidas:

from urllib.robotparser import RobotFileParser
rp = RobotFileParser()
rp.set_url("https://example.com/robots.txt")
rp.read()
if not rp.can_fetch("MyScraper/1.0", target_url):
    return

Lee las condiciones de servicio del sitio de destino. Algunos sitios prohíben explícitamente la recopilación automatizada; ignorar esa cláusula puede dar lugar a una demanda por incumplimiento de contrato, incluso cuando no se haya infringido ninguna ley. Presta especial atención a los sitios que requieren iniciar sesión, ya que traspasar el umbral de la autenticación cambia sustancialmente el panorama legal.

No recopiles datos personales a la ligera. Si los datos incluyen cualquier elemento que identifique a una persona (nombre, correo electrónico, dirección, IP, cookies vinculadas a un usuario), es probable que entres en el ámbito de aplicación de leyes como el RGPD, la Ley de Protección de Datos del Reino Unido o la CCPA. Consulta a un abogado antes de crear un proceso que almacene dichos datos.

Da prioridad a las API oficiales cuando existan, no eludas los muros de pago ni la autenticación, y límita tú mismo la frecuencia de las solicitudes, incluso cuando el sitio de destino no te lo exija. Un rastreador lento y respetuoso que se escale de forma sostenible vale más que uno rápido que provoque que se bloquee la dirección IP de toda tu empresa. Nuestro marco de cumplimiento normativo analiza en detalle el panorama país por país.

Por último, establece una declaración clara y honesta User-Agent que identifique tu proyecto y proporcione una dirección de contacto (MyProject/1.0 (+https://example.com/contact)). Es un pequeño gesto de cortesía que ofrece a los propietarios de los sitios web una persona a la que enviar un correo electrónico antes de que recurran a su WAF y, en la práctica, reduce considerablemente las probabilidades de que se bloquee la IP.

Tres proyectos iniciales para practicar

La mejor manera de consolidar una pila de web scraping en Python es crear el mismo scraper básico para tres tipos diferentes de objetivos. Cada uno de ellos es lo suficientemente pequeño como para completarlo en una tarde y lo suficientemente realista como para enseñarte los patrones que utilizarás a gran escala.

Rastreador de precios. Elige una página de comercio electrónico de demostración (books.toscrape.com es segura y estable). Extrae el nombre del producto, el precio y el stock a SQLite una vez al día, guarda cada instantánea y traza una serie de precios por producto con pandas. Esto te enseña las ejecuciones idempotentes, la deduplicación por URL y el almacenamiento de series temporales. En el momento en que tu sitio de prueba añada un campo «descuento», también podrás practicar con la deriva del esquema. Nuestra guía de datos de productos incluye patrones similares para objetivos reales de comercio electrónico.

Agregador de ofertas de empleo. Extrae las fichas de empleo de una bolsa de trabajo pública (o de una demo como realpython.github.io/fake-jobs/). Normaliza los títulos, las ubicaciones y las fechas de publicación, y luego elimina duplicados entre las distintas páginas. Este proyecto premia una normalización limpia y te enseña por qué la extracción y la limpieza deben ser pasos separados. Nuestra guía de datos de empleo cubre patrones de escalabilidad cuando pasas de una bolsa de empleo a cinco.

Panel de titulares de noticias. Extrae titulares y marcas de tiempo de publicación de un feed RSS público o de un agregador de noticias. Almacénalos como JSONL con title, url, published_at, source. Este proyecto te prepara para el trabajo posterior con modelos de lenguaje grande (LLM): el formato JSONL se integra directamente en un proceso de incrustación, y los metadatos por registro (fuente, marca de tiempo) admiten el RAG, que requiere muchas citas. Nuestra guía de datos de noticias aborda la supervisión de cambios a partir del mismo rastreador base.

El mismo proceso de tres etapas (recogida, análisis, extracción), tres formatos de datos diferentes. Así es como se interioriza el patrón.

Lista de comprobación de producción antes de poner en marcha un rastreador

Antes de pasar tu rastreador de script local a tarea de producción, revisa esta lista. Los elementos que falten son la razón por la que «ayer funcionaba» se convierte en una alerta a las 3 de la madrugada.

Puntos clave

  • Empieza con la herramienta más ligera. Requests junto con Beautiful Soup gestionan la mayoría de las páginas estáticas; recurre a Playwright solo cuando JavaScript genere los datos, y a Scrapy solo cuando necesites escalabilidad, flujos de trabajo y reintentos.
  • Examina el sitio de destino en DevTools antes de escribir código. Los puntos finales JSON ocultos son casi siempre más fiables que el scraping de HTML.
  • Separa la extracción de la normalización. Almacena los valores sin procesar, límpialos en una segunda pasada y elimina duplicados mediante una clave estable.
  • El bloqueo es un problema de la capa de recuperación. Alterna los agentes de usuario, añade jitter, utiliza proxies residenciales y detecta explícitamente las páginas de desafío.
  • Implementa los rastreadores como si fueran trabajos reales: registros estructurados, reintentos, tiempos de espera, alertas de desviación del esquema y una lista de comprobación para producción. El fallo silencioso es el modo de fallo contra el que debes diseñar.

Preguntas frecuentes

¿En qué se diferencia el scraping web del uso de una API oficial?

Una API oficial es un contrato compatible y versionado con límites de frecuencia, autenticación y nombres de campos estables. El scraping lee una página diseñada para humanos e infiere la estructura a partir del HTML, que puede cambiar sin previo aviso. Las API son más rápidas, más baratas y legalmente más seguras cuando existen. Realiza scraping solo cuando ninguna API cubra tus datos o cuando los términos de la API excluyan tu caso de uso.

¿Cuántas solicitudes por segundo son seguras al extraer datos de un sitio web público?

No hay una cifra universal, pero un punto de partida razonable es 1 solicitud cada 2 a 5 segundos por dominio, con intervalos variables. Si el sitio publica un límite de frecuencia en robots.txt o en la documentación, respétalo. Estate atento a las respuestas HTTP 429 y reduce la frecuencia de forma exponencial. Solo es posible un rastreo más rápido con el permiso explícito del sitio o a través de un servicio alojado que ya negocie la carga con el sitio de destino.

¿Cómo puedo mantener mi rastreador de Python en funcionamiento cuando el sitio de destino cambia su código HTML?

Prueba los selectores con un modelo HTML fijo en la integración continua (CI), supervisa las tasas de llenado por campo en cada ejecución y avisa cuando bajen. Da preferencia a los selectores basados en atributos semánticos (data-testid, itemprop) frente a los nombres de clase generados automáticamente. Mantén la extracción y la normalización desacopladas, de modo que si un selector falla, solo falle un campo y no todo el trabajo. Versiona tu lógica de análisis para que la reversión se realice en una sola confirmación.

¿Cuándo debo pasar de un scraper de Python autohospedado a una API de scraping gestionada?

Cambia cuando sea la capa de obtención de datos, y no el analizador, la que falle. Las señales incluyen errores 403 repetidos a pesar de la rotación de proxies, CAPTCHAs en el primer contacto, bloqueos por huellas digitales TLS y un tiempo de mantenimiento de la infraestructura que supera el dedicado a la lógica de datos. Conserva tu código de análisis existente; solo es necesario sustituir la capa de solicitud. Si el objetivo es estático y no está protegido, el autoalojamiento sigue siendo mucho más económico.

¿Cómo debo almacenar los datos extraídos si tengo pensado introducirlos más adelante en un modelo de lenguaje grande (LLM)?

Utiliza JSONL con un registro por línea. Cada registro necesita un id, un source_url, una scraped_at marca de tiempo, un text para la incrustación y un metadata objeto para los filtros. Mantén los text por debajo de unos 2000 tokens para que los fragmentadores posteriores no tengan que dividirlos de forma arbitraria. Nunca fusiones registros de diferentes fuentes y conserva las URL para que un proceso RAG pueda citarlas.

Conclusión

El scraping web con Python en 2026 no consiste tanto en aprender otra biblioteca más como en elegir la adecuada para cada objetivo y, a continuación, integrarla en la infraestructura que la mantenga en funcionamiento. Un primer intento con Requests y Beautiful Soup cubre la mayoría de las páginas estáticas. Playwright se encarga del JavaScript. Scrapy te permite escalar hasta miles de URL. XPath, la rotación de proxies, el registro estructurado y las alertas de desviación de esquemas convierten un script en una tarea que realmente puedes dejar en manos del sistema. Y cuando la capa de recuperación de un objetivo es realmente hostil, una API de scraping es la válvula de escape pragmática, más que la opción por defecto.

El hábito más importante es empezar poco a poco. No pongas en marcha un navegador sin interfaz gráfica para extraer datos de una página que podrías analizar con lxml. No pongas en marcha un grupo de proxies antes de haber escrito un rastreador que funcione. Lanza primero la versión sencilla, añade capas solo cuando la actual se quede sin margen, e implementa todo de tal forma que te des cuenta de los cambios en el sitio antes de que lo haga tu panel de control.

Cuando te encuentres con un objetivo que identifique los navegadores, bloquee las IP de centros de datos o muestre CAPTCHAs al primer contacto, nuestro equipo de WebScrapingAPI se encarga de la capa de solicitudes (rotación de proxies, renderizado de JS, reintentos) para que puedas mantener tu código de análisis en Python y evitar la carrera armamentística contra los bots. Pruébalo con tu URL más complicada y comprueba qué parte del proceso sigue siendo tuya.

Acerca del autor

Raluca Penciuc, Desarrollador full-stack @ WebScrapingAPI

Raluca Penciuc

Desarrollador full-stack

Raluca Penciuc es desarrolladora full stack en WebScrapingAPI, donde se dedica a crear rastreadores, mejorar las técnicas de evasión y buscar formas fiables de reducir la detección en los sitios web de destino.

Extracción de datos web con AWS Lambda: guía para Python y Java 2026
Guías

Extracción de datos web con AWS Lambda: guía para Python y Java 2026

En resumen: el web scraping con AWS Lambda funciona mejor cuando cada invocación es breve, está delimitada y se puede reintentar de forma independiente. Empieza con HTTP directo, AWS SAM y S3, y añade SQS, contenedores, renderización en navegador, proxies o una capa de recuperación gestionada solo cuando la carga de trabajo demuestre que los necesita.

Suciu Dan33 min read
Leer artículo
Cómo utilizar GoSpider: rastrear, depurar URL y extraer datos
Guías

Cómo utilizar GoSpider: rastrear, depurar URL y extraer datos

En resumen: GoSpider es un rastreador de línea de comandos diseñado para descubrir URL, no un extractor completo de datos estructurados. Esta guía sobre cómo utilizar GoSpider muestra cómo realizar un rastreo limitado, gestionar correctamente los resultados, pasar los datos de Colly a CSV y seguir una ruta de diagnóstico para respuestas 403 o páginas que requieren renderización con JavaScript.

Suciu Dan24 min read
Leer artículo
Cómo raspar Redfin: Guía Python de Datos Inmobiliarios
Guías

Cómo raspar Redfin: Guía Python de Datos Inmobiliarios

TL;DR: Redfin expone puntos finales de API ocultos que devuelven JSON estructurado para los listados de propiedades, lo que permite omitir por completo el frágil análisis HTML. Esta guía te guía a través de la construcción de un raspador de Python que extrae datos de alquiler y venta, busca por ubicación, supervisa los nuevos listados a través de mapas de sitio XML y exporta resultados limpios a CSV o JSON.

Suciu Dan14 min read
Leer artículo

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.