Cada scraper termina deteniéndose a medias en algún momento. Un contenedor se reprograma, un despliegue reinicia el worker, una máquina se queda sin memoria, o alguien pulsa Ctrl+C. Lo que sucede después decide si tus datos siguen siendo fiables. Un scraper que simplemente vuelve a empezar desde el principio vuelve a obtener todo y, a menos que se haya diseñado para ello, lo escribe todo de nuevo.
Un scraper idempotente es aquel en el que ejecutar el mismo trabajo dos veces tiene el mismo efecto que ejecutarlo una sola vez. Se le puede matar en cualquier momento, reiniciarlo, y aun así termina con exactamente una copia correcta de cada registro. Esta guía muestra las cuatro decisiones de diseño que hacen esto posible, con código probado y los resultados de matarlo diez veces en mitad de un rastreo.
Conclusiones clave
- Asume que el proceso morirá entre dos líneas cualesquiera. Diséñalo de modo que un reinicio repita, como máximo, la única página que estaba en curso.
- Asigna a cada registro un ID derivado de lo que es, como la fuente y el SKU, nunca del momento en que se extrajo. Así, una escritura repetida es una actualización, no un duplicado.
- Mantén la frontera de rastreo en almacenamiento duradero, y marca una URL como terminada en la misma transacción en la que se guarda su resultado.
- Usa arrendamientos (leases) en lugar de bloqueos, de modo que el trabajo reclamado por un worker que ha fallado quede disponible de nuevo por sí solo.
- En nuestra prueba con 500 productos, matado diez veces por ejecución: el scraper ingenuo terminó con entre 1.286 y 2.165 filas y hasta 4,3 veces las peticiones; el idempotente terminó con exactamente 500 filas correctas y, como máximo, 3 peticiones repetidas.
Por qué los reinicios crean duplicados
La primera versión habitual de un scraper se parece a esto: empezar en la página uno, seguir la paginación, insertar una fila por cada producto, confirmar (commit) a medida que avanza. Funciona perfectamente hasta que se interrumpe. Al reiniciarse no tiene memoria de hasta dónde llegó, así que vuelve a empezar, y cada producto que ya había guardado se inserta una segunda vez con un nuevo ID autoincremental. Mátalo unas cuantas veces y la tabla contiene varias copias de la mayoría de los productos, sin nada que indique cuál es la vigente.
Deduplicar después es posible, y la resolución de entidades lo trata, pero eso ataca el síntoma. Los duplicados también cuestan dinero antes de afectar a la precisión: cada página repetida es ancho de banda pagado dos veces, lo que repercute directamente en el coste por registro limpio.
Cuatro decisiones que hacen idempotente a un scraper
1. Deriva los ID de los registros a partir de los datos
El ID de un registro debe provenir de lo que el registro es: la fuente más su clave natural, como un SKU, un ID de anuncio o una URL canónica. Combínalos con un hash y el mismo producto obtiene siempre el mismo ID, en cada ejecución, en cada máquina. Escribirlo de nuevo se convierte en un upsert: si el registro existe y no ha cambiado, no ocurre nada; si ha cambiado, se actualiza y se registra el momento del cambio.
2. Mantén la frontera duradera
La lista de URLs por visitar, y cuáles ya están terminadas, pertenece a la base de datos, no a la memoria. Añadir una URL que ya se conoce debe ser una operación sin efecto (no-op), de modo que redescubrir enlaces en una página de listado que ya has procesado sea inofensivo.
3. Confirma el resultado y el progreso juntos
El momento peligroso está entre guardar un resultado y registrar que la URL ha terminado. Si el proceso muere después de uno y antes del otro, un reinicio pierde el resultado o lo repite. Hacer ambas cosas en una sola transacción de base de datos elimina esa brecha: o bien el registro se guarda y la URL se marca como terminada, o bien no ocurre ninguna de las dos cosas y la URL simplemente se vuelve a obtener.
4. Arrienda el trabajo en lugar de bloquearlo
Un worker reclama una URL durante un tiempo limitado. Si termina, la URL se marca como terminada. Si falla, el arrendamiento expira y otro worker, o el mismo reiniciado, recoge la URL. No es necesario que nada detecte el fallo para que el trabajo se recupere.
El código
El módulo siguiente implementa las cuatro decisiones con SQLite y requests. SQLite mantiene el ejemplo autocontenido; el mismo diseño se traslada a PostgreSQL o a cualquier base de datos con transacciones y upserts.
import hashlib
import json
import re
import sqlite3
import time
import requests
SCHEMA = """
CREATE TABLE IF NOT EXISTS frontier (
url TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending', -- pending, leased, done
leased_until REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS records (
record_id TEXT PRIMARY KEY, -- derived from the source and its natural key
url TEXT NOT NULL,
content_hash TEXT NOT NULL,
data TEXT NOT NULL,
first_seen REAL NOT NULL,
last_changed REAL NOT NULL
);
"""
def open_db(path):
db = sqlite3.connect(path, isolation_level=None) # explicit transactions below
db.execute("PRAGMA journal_mode=WAL")
db.executescript(SCHEMA)
return db
def record_id(source, natural_key):
"""The same item always gets the same ID, however many times it is scraped."""
return hashlib.sha256(f"{source}:{natural_key}".encode()).hexdigest()[:16]
def enqueue(db, urls):
"""Adding a URL that is already known is a no-op, so re-discovering links is harmless."""
db.executemany("INSERT OR IGNORE INTO frontier (url) VALUES (?)", [(u,) for u in urls])
def lease(db, lease_seconds=60):
"""Claim one URL. A lease left behind by a crashed worker expires and the URL is retried."""
now = time.time()
row = db.execute(
"UPDATE frontier SET status = 'leased', leased_until = ? WHERE url = ("
" SELECT url FROM frontier WHERE status = 'pending' OR (status = 'leased' AND leased_until < ?) LIMIT 1"
") RETURNING url", (now + lease_seconds, now)).fetchone()
return row[0] if row else None
def complete(db, url, new_urls=(), record=None):
"""Write the result and mark the URL done in one transaction: either both happen or neither does."""
db.execute("BEGIN IMMEDIATE")
try:
enqueue(db, new_urls)
if record:
now = time.time()
payload = json.dumps(record["data"], sort_keys=True)
digest = hashlib.sha256(payload.encode()).hexdigest()
db.execute(
"INSERT INTO records (record_id, url, content_hash, data, first_seen, last_changed) VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(record_id) DO UPDATE SET data = excluded.data, content_hash = excluded.content_hash, "
"last_changed = excluded.last_changed WHERE records.content_hash != excluded.content_hash",
(record["id"], url, digest, payload, now, now))
db.execute("UPDATE frontier SET status = 'done' WHERE url = ?", (url,))
db.execute("COMMIT")
except Exception:
db.execute("ROLLBACK")
raise
def crawl(db, base, session=None, lease_seconds=60):
session = session or requests.Session()
enqueue(db, [f"{base}/list/0"])
while True:
url = lease(db, lease_seconds)
if url is None:
if db.execute("SELECT 1 FROM frontier WHERE status != 'done' LIMIT 1").fetchone():
time.sleep(1) # a lease is still held, perhaps by a worker that crashed; wait for it to expire
continue
return
html = session.get(url, timeout=30).text
if "/list/" in url:
links = [base + href for href in re.findall(r'href="(/(?:product|list)/\d+)"', html)]
complete(db, url, new_urls=links)
else:
sku = re.search(r'class="sku">([^<]+)<', html).group(1)
price = re.search(r'class="price">([^<]+)<', html).group(1)
complete(db, url, record={"id": record_id("example-shop", sku), "data": {"sku": sku, "price": price}})
La función crawl es específica de nuestro sitio de prueba, con 25 páginas de listado que enlazan a 500 páginas de producto. Todo lo que hay por encima es reutilizable. Hay dos detalles fáciles de pasar por alto:
- El upsert solo escribe cuando el contenido ha cambiado. La cláusula
WHEREen la actualización por conflicto deja intacto un registro sin cambios, de modo quelast_changedsignifica lo que dice y puede alimentar la detección de cambios. - El bucle no se detiene ante una cola vacía. Nuestra primera versión terminaba cuando no se podía arrendar ninguna URL, lo que dejaba una página sin terminar siempre que un fallo había dejado un arrendamiento pendiente. Ahora el bucle espera hasta que todas las URLs estén terminadas.
Matándolo diez veces
Ejecutamos un sitio de prueba local con 25 páginas de listado y 500 páginas de producto, de modo que un rastreo completo necesita exactamente 525 peticiones. Cada ejecución inició el scraper, lo mató con SIGKILL en un momento aleatorio entre 0,2 y 1 segundo, y repitió esto diez veces antes de dejarlo terminar. Ejecutamos el scraper idempotente de arriba y el ingenuo descrito antes, cinco veces cada uno, con los mismos tiempos de muerte, y un arrendamiento de 3 segundos para la versión idempotente.
| Ejecución | Ingenuo: filas guardadas | Ingenuo: peticiones | Idempotente: filas guardadas | Idempotente: peticiones |
|---|---|---|---|---|
| 1 | 2.165 | 2.283 | 500 | 525 |
| 2 | 1.875 | 1.977 | 500 | 526 |
| 3 | 1.862 | 1.967 | 500 | 526 |
| 4 | 1.455 | 1.538 | 500 | 526 |
| 5 | 1.286 | 1.361 | 500 | 528 |
Ambas versiones terminaron guardando los 500 productos, porque cada una completó su última ejecución. La diferencia está en todo lo demás. El scraper ingenuo guardó entre 2,6 y 4,3 filas por producto e hizo entre 2,6 y 4,3 veces las peticiones necesarias. El idempotente guardó exactamente una fila por producto, con todos los valores coincidiendo con la página de origen, y repitió como máximo tres peticiones a lo largo de diez fallos: una por cada muerte que coincidió con una página en curso. También terminó antes en cada ejecución, entre 6,8 y 8,3 segundos frente a 9,2 a 10,8, aunque en ocasiones esperó a que expirase el arrendamiento de un worker que había fallado.
Más allá de la base de datos
Los registros no son lo único que un scraper hace más de una vez. El mismo principio se aplica a cada efecto secundario:
- Archivos. Nombra las imágenes y documentos descargados mediante un hash de su contenido o del ID del registro, de modo que una descarga repetida sobrescriba en lugar de añadir una copia.
- Mensajes y webhooks. Envía una clave de idempotencia derivada del registro y su versión, de modo que un consumidor pueda ignorar un mensaje que ya ha procesado.
- Contadores y agregados. Recalcúlalos a partir de los registros guardados en lugar de incrementarlos en cada obtención, o un reinicio los inflará.
- Respuestas en bruto. Si archivas las respuestas en bruto, una obtención repetida añade una segunda captura, lo cual es inofensivo e incluso útil, siempre que los registros extraídos sigan siendo idempotentes.
Reglas prácticas
- Elige la clave natural con cuidado. Debe ser estable en la fuente: un SKU o un ID de anuncio, no una posición en la página ni una URL que lleve parámetros de seguimiento.
- Haz que los reintentos sean seguros primero, y frecuentes después. Una vez que las escrituras son idempotentes, el reintento y backoff puede ser generoso sin contaminar los datos.
- Dimensiona los arrendamientos según la página más lenta. Un arrendamiento más corto que una obtención lenta permite que dos workers procesen la misma URL; aquí es inofensivo, pero desperdicia ancho de banda.
- Vigila la tasa de repetición. Peticiones por registro guardado es una métrica de salud barata; un aumento indica fallos o problemas con los arrendamientos, y pertenece junto a las demás en la monitorización de una pipeline de scraping.
La conclusión
Un scraper que no se puede reiniciar de forma segura terminará corrompiendo sus propios datos, y lo hará silenciosamente. La solución no es una operación más cuidadosa, sino un diseño que haga que los reinicios sean triviales: ID derivados de los datos, una frontera duradera, resultados y progreso confirmados juntos, y arrendamientos que expiran.
En nuestra prueba, esas cuatro decisiones convirtieron diez fallos, que podían suponer hasta 4,3 veces las filas y peticiones, en exactamente los 500 registros correctos y tres peticiones repetidas.