Construya una tubería de análisis de datos extraídos con Scrapeless y DuckDB.
Advanced Data Extraction Specialist
Resumen:
- Un pipeline de scraping que termina en un archivo JSON es solo la mitad de un pipeline; las preguntas analíticas llegan después de que los datos aterrizan.
- DuckDB lee JSON Lines directamente, por lo que los registros extraídos se convierten en una tabla consultable sin servidor, sin migración de esquema y sin herramienta ETL.
- Este pipeline obtiene 3 páginas a través de la API de Scraping Universal de Scrapeless, extrae 30 registros, los carga en DuckDB y escribe en Parquet.
- Parquet con compresión ZSTD almacenó los mismos 30 registros en 4,674 bytes frente a los 7,690 bytes de JSON Lines, y DuckDB consulta el archivo directamente sin cargarlo nuevamente.
- Cada etapa a continuación se ejecuta en tu máquina sin necesidad de una cuenta de almacén en la nube y sin credenciales más allá de una clave de Scrapeless.
- Comienza en el plan gratuito de Scrapeless y apunta la etapa de obtención a tu propia fuente.
Pipeline a Simple Vista
El flujo consta de cinco etapas, y la decisión de diseño interesante es dónde los datos dejan de ser texto y comienzan a ser una tabla.
obtener (Scrapeless) → descubrir registros → extraer campos → transformar a una tabla tipada (DuckDB) → almacenar como Parquet
JSON Lines es el formato de transferencia entre la mitad de scraping y la mitad analítica. Es amigable para agregar, sobrevive a una ejecución fallida sin corromper lo que vino antes, y DuckDB lo lee de forma nativa, por lo que no hay cargador que escribir. El formato está especificado en la especificación de JSON Lines.
Nada aquí necesita una cuenta de almacén. DuckDB se ejecuta en proceso, lo que hace que esta sea la manera más económica de obtener SQL real sobre datos extraídos. Cuando el destino es un almacén gestionado, la guía de ingestión de Snowflake cubre la misma etapa de recolección con una zona de destino diferente.
Requisitos Previos
- Python 3.9 o posterior.
- Una clave de API de Scrapeless desde el panel de control.
duckdbinstalado:
bash
pip install "duckdb==1.5.4"
Configura la clave:
bash
export SCRAPELESS_API_KEY="tu_clave_api_aqui"
Etapa 1–3: Obtener, Descubrir, Extraer
Una llamada por página a través de la API de Scraping Universal de Scrapeless devuelve HTML renderizado, y un parser de la biblioteca estándar convierte cada bloque de cita en un registro. El extractor emite un objeto JSON por línea:
python
import json, os, urllib.request
from html.parser import HTMLParser
API = "https://api.scrapeless.com/api/v2/unlocker/request"
def obtener(url: str) -> str:
payload = json.dumps({
"actor": "unlocker.webunlocker",
"input": {"url": url, "js_render": True, "headless": True},
}).encode()
req = urllib.request.Request(
API, data=payload,
headers={"x-api-token": os.environ["SCRAPELESS_API_KEY"],
"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read())["data"]
class QuoteParser(HTMLParser):
def __init__(self):
super().__init__()
self.rows, self._cur, self._cap = [], None, None
def handle_starttag(self, tag, attrs):
a = dict(attrs); cls = a.get("class", "")
if tag == "div" and "quote" in cls:
self._cur = {"text": "", "author": "", "tags": []}
elif self._cur is not None and tag == "span" and "text" in cls:
self._cap = "text"
elif self._cur is not None and tag == "small" and "author" in cls:
self._cap = "author"
elif self._cur is not None and tag == "a" and "tag" in cls:
self._cap = "tag"
def handle_data(self, data):
if self._cap == "text": self._cur["text"] += data
elif self._cap == "author": self._cur["author"] += data
elif self._cap == "tag": self._cur["tags"].append(data.strip())
def handle_endtag(self, tag):
if self._cap in ("text", "author", "tag"): self._cap = None
if tag == "div" and self._cur and self._cur["text"]:
self.rows.append(self._cur); self._cur = None
rows = []
for page in range(1, 4):
p = QuoteParser(); p.feed(obtener(f"https://quotes.toscrape.com/page/{page}/"))
rows.extend({"page": page, **r} for r in p.rows)
print(f"páginas obtenidas: 3 | registros extraídos: {len(rows)}")
with open("quotes.jsonl", "w", encoding="utf-8") as f:
for r in rows: f.write(json.dumps(r, ensure_ascii=False) + "\n")
print(f"escribió quotes.jsonl ({os.path.getsize('quotes.jsonl')} bytes)")
text
páginas obtenidas: 3 | registros extraídos: 30
escribió quotes.jsonl (7690 bytes)
Vale la pena conservar dos decisiones aquí en tu propia versión.
El número de página se adjunta a cada registro en el momento de la extracción. La procedencia es casi gratuita para registrar en el momento de la recolección y costosa de reconstruir después, y es lo que te permite responder "¿de qué página proviene esto?" sin volver a ejecutar nada.
ensure_ascii=False mantiene los caracteres de comillas tipográficas intactos en lugar de escaparlos. Eso importa cuando el texto es los datos: la salida escapada sigue siendo JSON válido, pero infla el archivo y dificulta la inspección posterior.
Etapa 4: Transformar en una Tabla Tipada
DuckDB lee el archivo JSON Lines directamente. read_json_auto infiere el esquema, y el SELECT circundante es donde imponen los tipos y las columnas derivadas que realmente deseas:
python
import duckdb
con = duckdb.connect("quotes.duckdb")
con.execute("""
CREATE OR REPLACE TABLE quotes AS
SELECT
page::INTEGER AS page,
text AS quote_text,
author,
tags,
len(tags) AS tag_count
FROM read_json_auto('quotes.jsonl')
""")
total = con.sql("SELECT count(*) FROM quotes").fetchone()[0]
authors = con.sql("SELECT count(DISTINCT author) FROM quotes").fetchone()[0]
print(f"filas cargadas: {total} | autores distintos: {authors}")
print(con.sql("""
SELECT author, count(*) AS quotes, round(avg(tag_count), 2) AS avg_tags
FROM quotes
GROUP BY author
ORDER BY quotes DESC, author
LIMIT 5
""").to_df().to_string(index=False))
con.execute("COPY quotes TO 'quotes.parquet' (FORMAT PARQUET, COMPRESSION ZSTD)")
import os
print(f"bytes parquet: {os.path.getsize('quotes.parquet')} | bytes jsonl: {os.path.getsize('quotes.jsonl')}")
rt = duckdb.sql("SELECT count(*) AS n, count(DISTINCT author) AS a FROM 'quotes.parquet'").fetchone()
print(f"circuito completo de parquet -> filas: {rt[0]}, autores: {rt[1]}")
text
filas cargadas: 30 | autores distintos: 20
autor citas avg_tags
Albert Einstein 6 2.83
J.K. Rowling 3 1.33
Bob Marley 2 1.00
Dr. Seuss 2 2.00
Marilyn Monroe 2 4.00
bytes parquet: 4674 | bytes jsonl: 7690
circuito completo de parquet -> filas: 30, autores: 20
La columna tags se mantiene como una lista en lugar de aplanarse en una cadena delimitada. DuckDB mantiene los tipos anidados hasta Parquet, por lo que len(tags) funciona en SQL y el arreglo sobrevive el ciclo completo: aplanar a "a,b,c" en esta etapa es un hábito destructivo que vale la pena romper.
CREATE OR REPLACE TABLE hace que la carga sea idempotente. Volver a ejecutar la etapa reconstruye la tabla a partir del archivo actual en lugar de agregar duplicados, que es el comportamiento que deseas mientras sigues iterando sobre el extractor.
El agregado es el objetivo de todo el ejercicio: 30 registros, 20 autores distintos y un autor que representa 6 de ellos. Esa pregunta es una consulta de una línea contra una tabla y un molesto ciclo contra un archivo JSON.
¿Listo para ejecutar esto contra una fuente que te importe? Crea una cuenta gratuita en Scrapeless y cambia la URL en la etapa de obtención.
Etapa 5: Almacenar como Parquet
Los mismos 30 registros ocupan 4,674 bytes como Parquet comprimido en ZSTD frente a 7,690 bytes como JSON Lines. En esta muestra, el ahorro es modesto; la razón para preocuparse es lo que el formato hace a gran escala y lo que permite después.
Parquet es columnar y almacena los valores de cada columna juntos con su propia codificación, que se define en la especificación del formato de archivo Apache Parquet. Una consulta que toca dos de cinco columnas solo lee esas dos. El códec de compresión utilizado aquí está especificado en el estándar de compresión Zstandard.
La línea de circuito completo es la prueba de la propia etapa. Leer el archivo Parquet de vuelta devuelve 30 filas y 20 autores distintos, coincidiendo con la tabla de la que proviene: vale la pena afirmarlo en cualquier canalización que escriba un archivo que otro sistema leerá.
Nota que la consulta final lee 'quotes.parquet' directamente como una tabla, sin un paso de importación y sin una conexión abierta al archivo de base de datos. Esa es la razón práctica para terminar una canalización de raspado en Parquet: la salida es consultable por DuckDB, y por la mayoría de otros motores analíticos, justo donde se encuentra.
La Canalización Completa
Ejecutado como un solo script, las cinco etapas son lo suficientemente cortas como para leer en una sola pasada. Esta es la versión para copiar:
python
import json, os, urllib.request
from html.parser import HTMLParser
API = "https://api.scrapeless.com/api/v2/unlocker/request"
def fetch(url: str) -> str:
payload = json.dumps({
"actor": "unlocker.webunlocker",
"input": {"url": url, "js_render": True, "headless": True},
}).encode()
req = urllib.request.Request(
API, data=payload,
```text
páginas recogidas: 3 | registros extraídos: 30
filas cargadas: 30 | autores distintos: 20
bytes parquet: 4674 | bytes jsonl: 7690
vuelta de parquet -> filas: 30, autores: 20
Dónde va esta canalización a continuación
Particiona por fecha de colección una vez que lo ejecutes repetidamente. Escribir en un data/dt=<fecha-de-colección>/quotes.parquet permite que una consulta omita directorios enteros en lugar de escanear el historial.
Conserva los archivos de JSON Lines. Son el registro bruto de lo que se ha recogido; Parquet es el artefacto derivado y tipado. Cuando surge un error de extractores, puedes volver a derivar a partir de los archivos crudos en lugar de re-scrapear.
Agrega afirmaciones entre etapas. Una verificación de conteo entre la extracción y la carga captura un selector que ha dejado de coincidir silenciosamente — el modo de fallo que de otro modo aparecería como una caída tranquila en el conteo de filas semanas después.
Antes de apuntar esto a una fuente en vivo, verifica sus términos y sus directivas de /robots.txt, que siguen el estándar del Protocolo de Exclusión de Robots. Mantén la recolección a páginas públicas y a un rango de páginas limitado como el anterior.
Conclusión
La brecha entre un scraper y una canalización de análisis es más pequeña de lo que parece. JSON Lines como transferencia, DuckDB como motor de consulta y Parquet como artefacto almacenado lo cubren con una dependencia y sin infraestructura: 3 páginas recogidas, 30 filas tipadas producidas, consultables en el lugar.
El paso que más canalizaciones omiten es el último. Escribir Parquet y luego leerlo de nuevo para confirmar que los conteos coinciden convierte un paso de almacenamiento en uno verificado, lo cual es la diferencia entre un archivo que tienes y un archivo en el que puedes confiar.
Comienza con el plan gratuito de Scrapeless para ejecutar la etapa de recolección contra tus propios objetivos, y revisa los precios de Scrapeless cuando dimensionas un trabajo recurrente.
FAQ
P: ¿Por qué DuckDB en lugar de un almacén en la nube para datos recogidos?
DuckDB se ejecuta en el proceso sin servidor, sin cuenta y sin viajes de red, lo cual se ajusta a la escala en la que la mayoría de los proyectos de scraping realmente operan. Lee JSON y Parquet de manera nativa, por lo que no hay necesidad de escribir un cargador. Un almacén en la nube gana su lugar cuando varios equipos necesitan acceso concurrente a las mismas tablas, no cuando un pipeline necesita SQL sobre su propia salida.
P: ¿Necesito aplanar campos anidados como listas de etiquetas antes de cargar?
No, y aplanar hace que se pierda información. DuckDB admite tipos de lista de extremo a extremo, por lo que tags sigue siendo un array durante la carga, a través de len(tags) en SQL, y durante la escritura y lectura de Parquet. Colapsarlo en una cadena delimitada obliga a cada consulta posterior a volver a analizarlo.
P: ¿Por qué escribir JSON Lines entre el scraping y la carga?
Desacopla las dos mitades. El scraping es la parte lenta y propensa a fallos; una vez que los registros están en el disco uno por línea, puedes volver a ejecutar la carga y transformar tantas veces como desees sin volver a obtenerlos. Añadir línea por línea también significa que un proceso que falle a medio camino deja los registros anteriores intactos y legibles.
P: ¿Cuánto más pequeño es Parquet que JSON Lines?
En esta muestra de 30 registros, 4,674 bytes contra 7,690, aproximadamente un 40% más pequeño. No interpretes demasiado esa proporción a este tamaño, donde la sobrecarga del archivo domina. La verdadera ventaja de Parquet son las lecturas columnares: una consulta que toca dos de cinco columnas lee solo esas, lo que importa a volúmenes donde el archivo ya no encaja cómodamente en memoria.
P: ¿Puedo consultar el archivo Parquet sin cargarlo primero en una base de datos?
Sí, y la última línea de la etapa de carga hace exactamente eso: SELECT ... FROM 'quotes.parquet' sin conexión abierta a una base de datos y sin importación. Eso es lo que hace de Parquet un buen artefacto final para un pipeline de scraping: la salida se mantiene consultable donde está, por DuckDB y por otros motores analíticos.
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.



