Cómo construir un pipeline de web scraping con Pandas y Scrapeless
Advanced Data Extraction Specialist
TL;DR:
- La API de raspado universal Scrapeless devuelve HTML procesado para una página pública, y
read_htmlde pandas convierte las tablas de esa página en DataFrames en una única llamada. - Esta guía construye el trabajo en cinco etapas explícitas: obtener, descubrir, extraer, transformar y almacenar, cada una con unas pocas líneas de Python.
- En pandas 3.x,
read_htmlnecesita un envoltorioio.StringIOalrededor de una cadena HTML, porque una cadena desnuda ahora se lee como una ruta de archivo y genera un error. - La limpieza pertenece después de la extracción: renombrar las columnas, coercer tipos numéricos con
pd.to_numeric, y agregar columnas derivadas antes de que se escriba cualquier cosa en el disco. - Parquet preserva los tipos de columna pero no es automáticamente más pequeño que CSV; en una muestra de 75 filas, su sobrecarga de pie de página lo convierte en el archivo más grande, y la ventaja de tamaño llega con el volumen.
- Comienza con el plan gratuito de Scrapeless y apunta la etapa de obtención a tu propia fuente.
La mayoría de los tutoriales de pandas comienzan con un CSV que ya existe. El trabajo real rara vez comienza ahí. Los números que deseas están dentro de una página HTML, envueltos en navegación, estilo y marcado que un simple requests.get a menudo ni siquiera puede recuperar de manera limpia. La brecha entre "hay una tabla en esa página" y "hay un DataFrame tipado en memoria" es donde vive una tubería de raspado.
Esta publicación cierra esa brecha con dos herramientas. La API de raspado universal Scrapeless maneja la recuperación y devuelve la página como HTML. pandas maneja la estructura: lee las tablas, las limpia y escribe archivos tipados que puedes analizar. El objetivo del ejemplo es un espacio de raspado público con una tabla paginada de temporadas de equipos de la NHL, así que cada número a continuación proviene de una ejecución real contra una página real.
Tubería a Primera Vista
La tubería tiene cinco etapas, y cada una entrega un único objeto bien definido a la siguiente:
obtener (cadena HTML) → descubrir (qué tabla) → extraer (DataFrame) → transformar (DataFrame limpio y tipado) → almacenar (CSV + Parquet)
Mantener las etapas separadas compensa la primera vez que una página cambia. Cuando el diseño cambia, solo la etapa de descubrimiento se mueve. Cuando una columna comienza a llegar como texto, solo cambia la etapa de transformación. Las etapas de obtención y almacenamiento permanecen intactas.
Etapa 1: Obtener la Página como HTML Limpio
La etapa de obtención envía una solicitud a Scrapeless y devuelve la página como una cadena HTML. La solicitud toma un actor y un objeto input; el actor unlocker.webunlocker recupera la página y la clave de API se envía en el encabezado x-api-token.
Esta tubería necesita tres paquetes: pandas en sí, un analizador para read_html y un motor Parquet para la etapa de almacenamiento. Instala los tres a la vez.
bash
pip install pandas pyarrow lxml
python
import io
import json
import os
import urllib.request
import pandas as pd
API_URL = "https://api.scrapeless.com/api/v2/unlocker/request"
def fetch_html(url: str) -> str:
payload = json.dumps(
{"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "headless": False}}
).encode()
request = urllib.request.Request(
API_URL,
data=payload,
headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"], "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=90) as response:
return json.loads(response.read())["data"]
html = fetch_html("https://www.scrapethissite.com/pages/forms/")
tables = pd.read_html(io.StringIO(html))
df = tables[0]
print(f"tablas en la página: {len(tables)} | forma: {df.shape}")
print("columnas crudas:", list(df.columns))
El cuerpo de la respuesta es un sobre JSON con el marcado procesado bajo la clave data, así que json.loads(response.read())["data"] es toda la página como una cadena. js_render se establece en False aquí a propósito, lo que la sección Cuando Necesitas una Página Procesada explica.
Establece la clave una vez en tu terminal antes de ejecutar cualquier cosa. Usa la clave real en tiempo de ejecución y mantén el marcador de posición fuera de tu código fuente.
bash
export SCRAPELESS_API_KEY="sk_your_key_here"
Etapa 2: Descubrir la Tabla
El descubrimiento responde a una pregunta: qué tabla en la página contiene los datos. Ejecutar el bloque anterior imprime la respuesta para este objetivo.
text
tablas en la página: 1 | forma: (25, 9)
columnas crudas: ['Nombre del equipo', 'Año', 'Victorias', 'Derrotas', 'Derrotas en OT', 'Porc. de victorias', 'Goles a favor (GF)', 'Goles en contra (GA)', '+ / -']
read_html escanea el HTML y devuelve una lista con un DataFrame por cada elemento <table> que encuentra, siguiendo el mismo modelo de tabla que la especificación de datos tabulares HTML. Esta página tiene una sola tabla, así que tables[0] es la que deseas. En una página con varias tablas, imprime la forma y las primeras filas de cada una, escoge el índice que coincida con tus columnas y codifica ese índice. Los nombres de las columnas en bruto provienen directamente de las celdas <th>, que es por lo que todavía contienen espacios y puntuación.
Etapa 3: Leerlo en un DataFrame
La extracción ya está hecha. Ese es el propósito de read_html: convierte toda la tabla en un DataFrame sin un bucle manual sobre filas y celdas. La referencia de pandas read_html documenta el requisito de io.StringIO que obstaculiza la mayoría de los primeros intentos en pandas 3.x. Una cadena HTML desnuda se interpreta como un nombre de archivo; envolverla en io.StringIO(html) le dice a pandas que analice la cadena en sí.
Con 25 filas y 9 columnas en mano, el marco en bruto es utilizable pero aún no está limpio. Los nombres de las columnas son torpes, y cada valor todavía es del tipo que el analizador HTML inferió. Ambos problemas pertenecen a la siguiente etapa.
Etapa 4: Transformar y Tipar los Datos
La etapa de transformación realiza tres tareas por orden: renombrar las columnas a algo que puedas escribir, forzar los valores a números y agregar las columnas derivadas que tu análisis necesita.
python
raw.columns = ["equipo", "año", "victorias", "derrotas", "derrotas_ot", "pct_victorias", "goles_a_favor", "goles_en_contra", "diferencia_goles"]
numeric = ["año", "victorias", "derrotas", "derrotas_ot", "pct_victorias", "goles_a_favor", "goles_en_contra", "diferencia_goles"]
raw[numeric] = raw[numeric].apply(pd.to_numeric, errors="coerce")
raw["partidos"] = raw["victorias"] + raw["derrotas"] + raw["derrotas_ot"].fillna(0)
raw["temporada_ganadora"] = raw["pct_victorias"] >= 0.5
clean = raw.dropna(subset=["equipo", "victorias"]).reset_index(drop=True)
pd.to_numeric con errors="coerce" es el caballo de batalla. Convierte números limpios y convierte cualquier cosa inanalizable en NaN en lugar de fallar toda la columna, lo cual es importante aquí porque las temporadas anteriores dejan la celda de derrotas en tiempo extra en blanco. fillna(0) trata esos espacios en blanco como cero al calcular los partidos jugados, y dropna elimina cualquier fila que falte un nombre de equipo o un conteo de victorias. El resultado es un marco donde cada columna numérica realmente es numérica y las columnas derivadas partidos y temporada_ganadora están listas para agrupar y filtrar.
Etapa 5: Almacenar Como CSV y Parquet
El almacenamiento escribe el marco limpio dos veces, porque los dos formatos responden a diferentes necesidades.
python
clean.to_csv("equipos.csv", index=False)
clean.to_parquet("equipos.parquet", index=False)
CSV es portátil y legible por cualquier cosa, desde una hoja de cálculo hasta una línea de comando de shell, y sigue el ampliamente implementado formato de valores separados por comas. Su costo es que los tipos desaparecen en el momento que escribes; cada columna es texto al volver a entrar. Parquet mantiene el esquema. Cuando lees equipos.parquet de nuevo, victorias sigue siendo un entero y pct_victorias sigue siendo un flotante, sin recasteo, porque el formato de archivo Apache Parquet almacena el tipo de cada columna junto a sus valores.
Parquet no siempre es el archivo más pequeño, sea lo que sea lo que diga la tradición. En esta muestra de 75 filas, el CSV es de 4,379 bytes y el archivo Parquet es de 8,999 bytes, porque los metadatos y el pie de página por columna de Parquet son costos fijos que un conjunto de datos pequeño no puede amortizar. Almacena resultados pequeños como CSV si el tamaño es lo único que te importa. Usa Parquet cuando el conteo de filas crezca hasta decenas de miles y las lecturas tipadas y columnadas empiecen a importar más que el conteo de bytes.
La Canalización Completa
Coloca las cinco etapas en un solo script y añade paginación, y la canalización obtiene tres páginas, construye un marco de 75 filas, lo limpia y escribe ambos archivos.
python
import io
import json
import os
import urllib.request
import pandas as pd
API_URL = "https://api.scrapeless.com/api/v2/unlocker/request"
BASE = "https://www.scrapethissite.com/pages/forms/"
PAGES = 3 # limitado: tres páginas de la tabla de sandbox público
def fetch_html(url: str) -> str:
"""Etapa 1 - obtener HTML renderizado a través de la API de scraping universal de Scrapeless."""
payload = json.dumps(
{"actor": "unlocker.webunlocker", "input": {"url": url, "js_render": False, "headless": False}}
).encode()
request = urllib.request.Request(
API_URL,
data=payload,
headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"], "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=90) as response:
return json.loads(response.read())["data"]
# Etapas 1-3 - obtener cada página, descubrir la tabla única, leerla directamente en un DataFrame
frames = []
for page in range(1, PAGES + 1):
html = fetch_html(f"{BASE}?page_num={page}")
frames.append(pd.read_html(io.StringIO(html))[0])
raw = pd.concat(frames, ignore_index=True)
print(f"páginas obtenidas: {PAGES} | filas parseadas: {len(raw)}")
# Etapa 4 - transformar: limpiar nombres de columnas, forzar tipos numéricos, añadir columnas derivadas
raw.columns = ["equipo", "año", "victorias", "derrotas", "derrotas_ot", "pct_victorias", "goles_a_favor", "goles_en_contra", "diferencia_de_goles"]
numeric = ["año", "victorias", "derrotas", "derrotas_ot", "pct_victorias", "goles_a_favor", "goles_en_contra", "diferencia_de_goles"]
raw[numeric] = raw[numeric].apply(pd.to_numeric, errors="coerce")
raw["juegos"] = raw["victorias"] + raw["derrotas"] + raw["derrotas_ot"].fillna(0)
raw["temporada_ganadora"] = raw["pct_victorias"] >= 0.5
clean = raw.dropna(subset=["equipo", "victorias"]).reset_index(drop=True)
print(f"filas después de la limpieza: {len(clean)} | temporadas ganadoras: {int(clean['temporada_ganadora'].sum())}")
mejor = clean.sort_values("victorias", ascending=False).iloc[0]
print(f"mejor temporada: {mejor['equipo']} {int(mejor['año'])} ({int(mejor['victorias'])} victorias)")
# Etapa 5 - almacenar el marco como CSV para portabilidad y Parquet para lecturas columnar tipadas
clean.to_csv("equipos.csv", index=False)
clean.to_parquet("equipos.parquet", index=False)
print(f"tamaño csv: {os.path.getsize('equipos.csv')} | tamaño parquet: {os.path.getsize('equipos.parquet')}")
La ejecución imprime un resumen compacto de cada etapa:
text
páginas obtenidas: 3 | filas parseadas: 75
filas después de la limpieza: 75 | temporadas ganadoras: 26
mejor temporada: Pittsburgh Penguins 1992 (56 victorias)
tamaño csv: 4379 | tamaño parquet: 8999
El parámetro de consulta page_num controla la paginación, y PAGES limita la ejecución a tres páginas para que el ejemplo se mantenga pequeño y educado. Aumenta esa constante para ampliar la cobertura, y mantenla en un solo lugar para que el límite sea una decisión, no un accidente.
Apunta este pipeline a una fuente que te interese cambiando la URL de BASE y los nombres de las columnas, y la forma de cinco etapas se mantiene sin cambios. Si deseas el contexto más amplio sobre dónde encaja cada una de estas etapas, la guía sobre qué es un pipeline ETL repasa extracción, transformación y carga como un patrón general.
Cuando Necesitas una Página Renderizada
Este pipeline establece js_render en False, y esa es una elección deliberada, no un valor predeterminado que dejar solo. La tabla de sandbox está presente en el HTML que envía el servidor, por lo que no hay nada que un navegador necesite renderizar, y evitar la renderización hace que cada obtención sea más rápida. Muchas páginas son diferentes: la tabla que deseas se inyecta mediante JavaScript después de que se cargue el HTML inicial, y una obtención no renderizada devuelve una estructura vacía. Cuando read_html no encuentra tablas en una página que puedes ver en un navegador, establece js_render en True para que Scrapeless devuelva la página después de que se ejecuten sus scripts. Decide por fuente en lugar de activar la renderización en todas partes, porque renderizar una página que no la necesita solo añade latencia.
Antes de aumentar cualquiera de esto, lee el robots.txt y los términos del destino. El Protocolo de Exclusión de Robots te dice qué rutas un sitio pide que los clientes automatizados dejen en paz, y honrarlas mantiene un pipeline de datos en el lado correcto de los sitios de los que depende. Mantén el volumen limitado y el destino público, tal como lo hace este ejemplo.
¿Listo para ejecutar esto contra una fuente real? Crea una cuenta gratuita en Scrapeless y cambia la URL en la etapa de obtención.
Conclusión
Un pipeline de raspado es cinco pequeñas etapas, cada una realizando una tarea. Scrapeless obtiene la página, read_html extrae la tabla, to_numeric y algunas asignaciones la limpian, y dos llamadas to_ la almacenan. Debido a que las etapas son separadas, el pipeline sobrevive al cambio: un nuevo diseño afecta el descubrimiento, un nuevo tipo de columna afecta la transformación, y el resto se mantiene. Comienza desde el script funcional anterior, intercambia tu propio objetivo y crece la etapa de transformación a medida que tus datos lo exigen.
Comienza con el plan gratuito de Scrapeless para ejecutar la etapa de obtención contra tus propias páginas, y consulta los precios de Scrapeless cuando dimensionas un trabajo recurrente.
Preguntas Frecuentes
P: ¿Por qué falla pandas.read_html en una cadena HTML en pandas 3.x?
Un argumento de cadena desnudo se trata como una ruta de archivo o URL, por lo que pandas intenta abrirlo y genera un error. Envuelve el marcado en io.StringIO(html) y pásalo en su lugar; read_html luego analiza la cadena en memoria y devuelve una lista de DataFrames.
P: ¿Necesito BeautifulSoup o lxml para usar read_html?
read_html necesita un analizador HTML instalado, y utiliza lxml o html5lib en su funcionamiento interno, así que instala uno de ellos junto con pandas. No escribes el código del analizador tú mismo para la extracción de tablas; read_html controla el analizador y devuelve DataFrames.
P: ¿Cuándo debo establecer js_render en True?
Establece js_render en True cuando los datos se añaden a la página mediante JavaScript después de que se cargue el HTML inicial, lo que se muestra como que read_html no encuentra tablas en una página que claramente tiene una en el navegador. Déjalo en False cuando la tabla ya esté en el HTML del servidor, porque renderizar una página innecesaria solo añade latencia.
P: ¿Debería almacenar los datos extraídos como CSV o Parquet?
Elige CSV cuando quieras un archivo portátil y legible por humanos y el número de filas sea pequeño; elige Parquet cuando quieras preservar los tipos de columnas y el conjunto de datos sea lo suficientemente grande como para que las lecturas tipadas y columnadas sean importantes. En muestras pequeñas, Parquet puede ser el archivo más grande debido a su sobrecarga de pie fijo, así que solo el tamaño favorece a CSV hasta que los datos crezcan.
P: ¿Cómo manejo una página con varias tablas?
read_html devuelve cada tabla como un DataFrame separado en una lista, por lo que imprime la forma y las primeras filas de cada elemento para identificar el que deseas, luego indexa directamente. Una vez que sepas que la posición es estable, codifica esa índice en la etapa de extracción.
P: ¿Cómo mantengo la recolección educada cuando añado más páginas?
Mantén la página limitada en una constante única, como PAGES lo hace aquí, para que ampliar la cobertura sea una edición deliberada en lugar de un bucle sin límites. Lee primero el robots.txt del sitio y los términos, y solo recopila datos públicos a un volumen que el objetivo pueda absorber.
En Scrapeless, solo accedemos a datos disponibles públicamente y cumplimos estrictamente con las leyes, regulaciones y políticas de privacidad del sitio web aplicables. El contenido de este blog es sólo para fines de demostración y no implica ninguna actividad ilegal o infractora. No ofrecemos garantías y renunciamos a toda responsabilidad por el uso de la información de este blog o enlaces de terceros. Antes de realizar cualquier actividad de scraping, consulte a su asesor legal y revise los términos de servicio del sitio web de destino u obtenga los permisos necesarios.



