Detectar cambios al hacer scraping: SQLite y hashes

Guarda precios en SQLite, detecta cambios reales con hashes de campos, haz upserts idempotentes y programa cron en UTC. Espacio y costo de commit medidos.

Serie: curso de web scraping 5/6 (avanzado). Esta clase es para ti si ya sabes descargar, parsear y paginar con cortesía, y ahora necesitas un scraper que corra todos los días y responda "¿qué cambió desde ayer?". Al terminar vas a guardar snapshots en SQLite, detectar cambios reales de precio y stock con hashes de contenido, lograr que correr el script dos veces no rompa nada, y programarlo con cron usando timestamps en UTC.

El caso típico: un cliente te contrata como freelancer y te dice "scrapeame esta tienda", y unos días después agrega "¿y lo puedes dejar corriendo todas las mañanas para ver cuándo baja el precio?". Seguir precios en tiendas en línea de Latinoamérica es un pedido muy común. Un script que imprime los precios de hoy es fácil; uno que recuerda ayer, avisa solo de cambios reales, aguanta correr dos veces y no llena el disco es otro trabajo, y casi todo ese trabajo es de base de datos.

Todo lo que sigue se ejecutó en una Mac con Apple Silicon (M1 Pro) con Python 3.9.6 (solo biblioteca estándar), SQLite 3.51.0 (la que trae ese Python y la CLI sqlite3) y PHP 8.5.6 sirviendo la tienda de práctica de la serie en 127.0.0.1:18172. Las rutas se acortaron a ~/demo/. Solo scrapeamos nuestro propio sitio de práctica.

Antes de guardar nada: ¿el scraping sigue siendo la herramienta correcta?

Un trabajo diario lo multiplica todo: cada petición que haces la haces 365 veces al año. Por eso lo que vimos en la clase 1 pesa más ahora, no menos.

  • Primero la API oficial o el feed. Muchas tiendas y marketplaces ofrecen un feed de productos o una API de afiliados. Es más estable que el HTML y su uso suele estar permitido de forma explícita. Pregúntale al cliente si la tienda es suya o de un socio: a veces basta con pedir un export.
  • robots.txt y Crawl-delay. El robots.txt de la tienda de práctica dice Crawl-delay: 1, así que el monitor espera un segundo entre páginas. Diez páginas tardan unos 9 segundos. Una vez al día es perfectamente razonable; es el precio de ser un buen invitado.
  • Términos y datos personales. Precios y stock normalmente no son datos personales, pero las reseñas con nombre sí lo son. Si guardas datos de personas en Europa aplica el RGPD (GDPR), y muchos países de la región tienen leyes de protección de datos parecidas. Revisa los términos del sitio y la ley local antes de guardar nada, y guarda solo los campos que necesitas. Esto no es asesoría legal.
  • Un User-Agent honesto. Nuestro monitor envía shopwatch/1.0 (+https://example.com/bot-contact): un nombre y un lugar donde el dueño del sitio puede contactarte.

El modelo de datos: products y snapshots

La tienda de práctica tiene 200 productos en 10 páginas de listado. Cada producto se ve así en el HTML:

<li class="product" data-sku="SKU-0020"><a href="/product/SKU-0020">Cable clip 2</a>
  <span class="price">$66.79</span> <span class="stock">In stock</span></li>

Un archivo de estado en el servidor simula el paso de los días: cada "día" cambia cerca del 4% de los precios y cerca del 3% de los productos entra o sale de stock (generado una vez con random.seed(42)).

Usamos dos tablas. products tiene una fila por producto con lo último que vimos. snapshots tiene una fila solo cuando algo cambió: es el historial de precios.

CREATE TABLE IF NOT EXISTS products (
  sku TEXT PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL,
  first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, last_hash TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS snapshots (
  sku TEXT NOT NULL REFERENCES products(sku), seen_at TEXT NOT NULL,
  price_cents INTEGER NOT NULL, in_stock INTEGER NOT NULL, hash TEXT NOT NULL,
  PRIMARY KEY (sku, seen_at));

Ahí hay tres decisiones escondidas:

  1. Dinero en centavos enteros. $66.79 se guarda como 6679. Los floats y el dinero no se llevan; abajo vas a ver el bug. Si la tienda muestra $ 1.299,90 con punto de miles y coma decimal, normaliza ese formato antes de convertir.
  2. Timestamps como texto UTC en ISO 8601 con Z, por ejemplo 2026-10-02T03:00:00Z. En este formato el texto se ordena y se compara bien, y se lee fácil en la CLI de sqlite3.
  3. Una clave natural (sku) en lugar de un id inventado. Es lo que hace posible el upsert. Si el sitio no tiene un id estable, la URL del producto es la siguiente mejor opción.

El monitor: descargar, normalizar, hash, upsert

Diagrama de flujo. La corrida descarga las páginas de listado después de revisar robots.txt y esperar el crawl delay, parsea cada producto y lo normaliza a centavos enteros y un booleano de stock, y calcula un hash SHA-256 de nombre, precio y stock. Si el hash es igual al last_hash guardado, un upsert solo actualiza last_seen. Si es distinto o el producto es nuevo, se inserta una fila de snapshot y se reporta el cambio.
Una corrida del monitor: descargar con cortesía, normalizar, calcular el hash solo de los campos que importan y escribir un snapshot solo cuando el hash cambió.

Este es el script completo. Reutiliza el enfoque de html.parser de la clase 2 y el bucle de paginación cortés de la clase 3.

"""shopwatch.py - snapshot a practice shop into SQLite and report price/stock changes.
Python 3.9+ stdlib only. Point it ONLY at a site you run yourself (php -S 127.0.0.1:18172).
"""
import hashlib, json, os, sqlite3, sys, time
import urllib.request, urllib.robotparser
from datetime import datetime, timezone
from html.parser import HTMLParser
from urllib.parse import urljoin

BASE = os.environ.get("SHOP", "http://127.0.0.1:18172")
UA = "shopwatch/1.0 (+https://example.com/bot-contact)"   # honest name + contact page

SCHEMA = """
CREATE TABLE IF NOT EXISTS products (
  sku TEXT PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL,
  first_seen TEXT NOT NULL, last_seen TEXT NOT NULL, last_hash TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS snapshots (
  sku TEXT NOT NULL REFERENCES products(sku), seen_at TEXT NOT NULL,
  price_cents INTEGER NOT NULL, in_stock INTEGER NOT NULL, hash TEXT NOT NULL,
  PRIMARY KEY (sku, seen_at));
"""

class ListParser(HTMLParser):
    def __init__(self):
        super().__init__(); self.items, self.next, self.cur, self.field = [], None, None, None
    def handle_starttag(self, tag, attrs):
        a = dict(attrs)
        if tag == "li" and a.get("class") == "product":
            self.cur = {"sku": a.get("data-sku"), "name": "", "price": None, "stock": None, "url": None}
        elif self.cur is not None and tag == "a":
            self.cur["url"], self.field = a.get("href"), "name"
        elif self.cur is not None and tag == "span":
            self.field = a.get("class")
        elif tag == "a" and a.get("rel") == "next":
            self.next = a.get("href")
    def handle_data(self, data):
        if self.cur is not None and self.field in ("name", "price", "stock"):
            self.cur[self.field] = (self.cur[self.field] or "") + data
    def handle_endtag(self, tag):
        if tag in ("a", "span"): self.field = None
        if tag == "li" and self.cur is not None:
            self.items.append(self.cur); self.cur = None

def normalize(raw, page_url):
    price = raw["price"].strip().lstrip("$").replace(",", "")
    return {"sku": raw["sku"], "name": " ".join(raw["name"].split()),
            "url": urljoin(page_url, raw["url"]),
            "price_cents": round(float(price) * 100),          # money as integer cents
            "in_stock": raw["stock"].strip() == "In stock"}

def content_hash(item):
    # hash only the fields we care about, in a fixed order - never the raw HTML
    key = json.dumps([item["name"], item["price_cents"], item["in_stock"]], separators=(",", ":"))
    return hashlib.sha256(key.encode()).hexdigest()[:16]

def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=10) as r:
        return r.read().decode(r.headers.get_content_charset() or "utf-8")

def crawl(delay=None):
    rp = urllib.robotparser.RobotFileParser(BASE + "/robots.txt"); rp.read()
    delay = (rp.crawl_delay(UA) or 1) if delay is None else delay
    url, items, pages = BASE + "/products?page=1", [], 0
    while url:
        if not rp.can_fetch(UA, url):
            print("robots.txt disallows", url, file=sys.stderr); break
        p = ListParser(); p.feed(fetch(url)); pages += 1
        items += [normalize(i, url) for i in p.items]
        url = urljoin(url, p.next) if p.next else None
        if url: time.sleep(delay)
    return items, pages

def utc_now():
    fake = os.environ.get("SHOPWATCH_NOW")       # for tests and the 30-day simulation
    return fake or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")

def store(db, items, now):
    changes = []
    with db:                                      # one transaction per run
        for it in items:
            h = content_hash(it)
            old = db.execute("SELECT last_hash FROM products WHERE sku=?", (it["sku"],)).fetchone()
            db.execute("""INSERT INTO products (sku, name, url, first_seen, last_seen, last_hash)
                          VALUES (?, ?, ?, ?, ?, ?)
                          ON CONFLICT(sku) DO UPDATE SET name=excluded.name, url=excluded.url,
                            last_seen=excluded.last_seen, last_hash=excluded.last_hash""",
                       (it["sku"], it["name"], it["url"], now, now, h))
            if old is None or old[0] != h:        # new product or something changed
                prev = db.execute("""SELECT price_cents, in_stock FROM snapshots WHERE sku=?
                                     ORDER BY seen_at DESC LIMIT 1""", (it["sku"],)).fetchone()
                db.execute("INSERT OR IGNORE INTO snapshots VALUES (?, ?, ?, ?, ?)",
                           (it["sku"], now, it["price_cents"], int(it["in_stock"]), h))
                if prev: changes.append((it["sku"], prev, (it["price_cents"], int(it["in_stock"]))))
    return changes

if __name__ == "__main__":
    db = sqlite3.connect(os.environ.get("SHOPWATCH_DB", "shop.db"), timeout=5)
    db.executescript(SCHEMA)
    t0 = time.perf_counter()
    items, pages = crawl()
    changes = store(db, items, utc_now())
    for sku, (p0, s0), (p1, s1) in changes:
        what = f"price {p0/100:.2f} -> {p1/100:.2f}" if p0 != p1 else f"stock {s0} -> {s1}"
        print(f"CHANGE {sku} {what}")
    print(f"{utc_now()} pages={pages} items={len(items)} changes={len(changes)} "
          f"secs={time.perf_counter() - t0:.1f}")

Lo corrimos dos veces seguidas para el "día 0" y luego para el "día 1":

$ SHOPWATCH_NOW=2026-11-01T03:00:00Z python3 shopwatch.py
2026-11-01T03:00:00Z pages=10 items=200 changes=0 secs=9.2
$ SHOPWATCH_NOW=2026-11-01T03:00:00Z python3 shopwatch.py
2026-11-01T03:00:00Z pages=10 items=200 changes=0 secs=9.1
$ echo 1 > ~/demo/shop/state/day
$ SHOPWATCH_NOW=2026-11-02T03:00:00Z python3 shopwatch.py
CHANGE SKU-0020 price 66.79 -> 63.49
CHANGE SKU-0029 price 84.09 -> 88.29
CHANGE SKU-0036 price 74.89 -> 67.39
CHANGE SKU-0039 stock 1 -> 0
...
CHANGE SKU-0170 price 56.79 -> 53.99
2026-11-02T03:00:00Z pages=10 items=200 changes=10 secs=9.1
$ sqlite3 shop.db "select count(*) from products; select count(*) from snapshots;"
200
210

La segunda corrida del día 0 no cambió nada: ni filas duplicadas ni falsas alertas. Esa propiedad se llama idempotencia: correr el mismo trabajo dos veces tiene el mismo efecto que correrlo una vez. cron reintenta, las laptops se despiertan y se ponen al día, la gente vuelve a apretar el botón; con un trabajo idempotente nada de eso hace daño.

Errores comunes y lo que pasa de verdad

Error 1: sacar el hash del HTML completo. Parece astuto: un hash por página, alerta cuando cambia. Pero nuestra tienda, como la mayoría de los sitios reales, imprime una hora de generación y un id de petición en cada página. En la corrida de 30 días de más abajo, el hash del HTML completo cambió en 290 de 290 descargas. Solo 217 de esas páginas tenían un producto con precio o stock realmente distinto. Solución: hash de los campos normalizados, como hace content_hash(). Un orden fijo de campos (una lista JSON, no un dict armado en cualquier orden) mantiene el hash estable.

Error 2: un INSERT simple para la fila del producto. La segunda corrida se cae:

IntegrityError: UNIQUE constraint failed: products.sku

Error 3: "arreglarlo" con INSERT OR REPLACE. Corre, pero REPLACE significa borra la fila vieja e inserta una nueva. En nuestra prueba insertamos SKU-0001 el 1 de octubre y lo reemplazamos el 2:

after OR REPLACE: (2, 'SKU-0001', 'Desk lamp 1', '2026-10-02T03:00:00Z', '2026-10-02T03:00:00Z')
after upsert:     (2, 'SKU-0001', 'Desk lamp 1', '2026-10-02T03:00:00Z', '2026-10-03T03:00:00Z')

El rowid saltó de 1 a 2 y first_seen ahora dice 2 de octubre: el historial quedó mal sin ningún aviso. Con claves foráneas activas, el borrado además puede propagarse o fallar. Solución: INSERT ... ON CONFLICT(sku) DO UPDATE SET ... (SQLite 3.24 o posterior), nombrando solo las columnas que deben cambiar. first_seen no se toca.

Error 4: dinero con int(float(...)). int(float("4.35") * 100) da 434, porque 4.35 * 100 es 434.99999999999994 e int() trunca. Probamos todos los precios de 1.00 a 99.99: 570 de 9,900 quedaron un centavo abajo. Solución: round(), como arriba, o decimal.Decimal si además haces cuentas.

Error 5: hora local en la base. Con la zona horaria del proceso fijada en un desfase de 5 horas (TZ=EST5), tres relojes no coinciden:

python datetime.now()          : 2026-11-14 18:16:36 (naive local)
python datetime.now(utc)       : 2026-11-14T23:16:36Z
sqlite datetime('now')         : 2026-11-14 23:16:36

El datetime('now') de SQLite está en UTC. Mézclalo con horas locales sin zona de Python y "cambió en las últimas 24 horas" se corre varias horas, y en servidores con horario de verano, dos veces al año un poco más. Solución: escribe UTC con Z en todas partes y convierte a hora local solo para mostrar.

Consultar el historial

Un historial de solo cambios responde cualquier pregunta, porque el estado de un producto en el momento T es su último snapshot en T o antes:

SELECT price_cents, in_stock, seen_at FROM snapshots
WHERE sku='SKU-0020' AND seen_at <= '2026-10-20T23:59:59Z'
ORDER BY seen_at DESC LIMIT 1;
-- 4589 | 1 | 2026-10-17T03:00:00Z

Y "las mayores bajas de precio de la corrida de hoy" es una función de ventana (SQLite 3.25+):

WITH ordered AS (
  SELECT sku, seen_at, price_cents,
         LAG(price_cents) OVER (PARTITION BY sku ORDER BY seen_at) AS before
  FROM snapshots)
SELECT sku, before, price_cents AS now, ROUND(100.0*(price_cents-before)/before,1) AS pct
FROM ordered WHERE seen_at = '2026-10-30T03:00:00Z' AND price_cents < before
ORDER BY pct LIMIT 3;
sku       before  now   pct
--------  ------  ----  -----
SKU-0036  5439    4619  -15.1
SKU-0162  8629    7329  -15.1
SKU-0111  5469    4649  -15.0

Esto es justo lo que el cliente quiere ver en su reporte semanal. Si WHERE, ORDER BY y LIMIT son nuevos para ti, empieza por SQL desde cero: SELECT, WHERE y ORDER BY.

Medido: crecimiento del archivo, deduplicación y costo del commit

30 días contra la tienda de práctica. Corrimos el monitor una vez por día simulado durante 30 días (crawl delay de 1 segundo, unos 9 segundos por corrida) y, con los mismos datos parseados, escribimos además una segunda base que guarda todos los productos en cada corrida. Tras 30 corridas:

Almacenamiento filas de snapshot tamaño del archivo
solo cambios (el monitor) 594 110,592 bytes
todos los productos, cada corrida 6,000 577,536 bytes

Las 594 filas son los 200 primeros avistamientos más 394 cambios reales.

Un año, sin HTTP. Para ver el crecimiento a escala insertamos directo 2,000 productos × 365 corridas diarias (7% de cambio por día, random.seed(7)):

después de cada corrida: filas / MB solo cambios: filas / MB
30 días 60,000 / 6.13 6,095 / 0.62
90 días 180,000 / 18.50 14,544 / 1.48
365 días 730,000 / 77.05 52,940 / 5.44

La vista dbstat mostró que 33.6 MB de los 77 MB eran el índice automático detrás de PRIMARY KEY (sku, seen_at), porque una tabla normal guarda la clave dos veces (en el índice y en la tabla). Declarar la tabla WITHOUT ROWID guarda las filas directamente en el orden de la clave:

Gráfico de barras. Guardar todos los productos en cada corrida ocupó 77.05 MB para 730,000 filas, o 48.67 MB con una tabla WITHOUT ROWID. Guardar solo las filas que cambiaron ocupó 5.44 MB para 52,940 filas, o 3.31 MB con WITHOUT ROWID.
Tamaño del archivo SQLite después de 365 corridas diarias sobre 2,000 productos con 7% de cambios por día (seed 7, insertado directo sin HTTP). Mac con Apple Silicon (M1 Pro), SQLite 3.51.0.

Costo del commit. El otro número que sorprende: 10,000 upserts en una base en archivo, mediana de 5 corridas.

Gráfico de barras. Con el journal de rollback por defecto, hacer commit por cada fila tardó una mediana de 3,143.2 ms; con WAL, 258.4 ms. Meter los 10,000 upserts en una sola transacción tardó 7.4 ms con rollback journal y 7.2 ms con WAL.
10,000 upserts en una base en archivo, mediana de 5 corridas. Lo lento no es SQLite sino hacer commit después de cada fila. Mac con Apple Silicon (M1 Pro), Python 3.9.6, SQLite 3.51.0.

Hacer commit después de cada fila tardó 3,143.2 ms con el journal de rollback por defecto y 258.4 ms en modo WAL; una sola transacción tardó 7.4 ms y 7.2 ms. Cada commit tiene que llegar al disco; uno por corrida alcanza. Por eso store() envuelve toda la corrida en with db:, lo que además significa que si el proceso se cae a la mitad quedan intactos los datos de ayer, no la mitad de los de hoy.

Programar con cron, en UTC

No instalamos nada en la máquina de prueba; esta es la línea que agregarías con crontab -e en un servidor Linux o una Mac que administres:

15 3 * * * cd "$HOME/demo/shopwatch" && /usr/bin/python3 shopwatch.py >> logs/shopwatch.log 2>&1
  • Ruta absoluta al intérprete. cron corre con un PATH mínimo; python3 a secas puede no encontrarse.
  • Registra todo. >> ... 2>&1 guarda también los tracebacks. Scripts de shell robustos, cron y logs (en inglés) cubre la rotación y los códigos de salida.
  • cron usa la hora local de la máquina para el horario. Algunas implementaciones (cronie en muchas distribuciones Linux) aceptan CRON_TZ=UTC; aquí no lo corrimos. De cualquier forma, los datos quedan en UTC porque los escribe el script.
  • Corridas que se pisan. Si una corrida se alarga y arranca la siguiente, se encuentran dos escritores. Lo reprodujimos: una conexión con una transacción de escritura abierta y otra intentando insertar.
timeout=0: OperationalError: database is locked after 0.00 s
timeout=2: OperationalError: database is locked after 2.11 s

El argumento timeout hace que SQLite espere el lock; no elimina el conflicto. Programa el trabajo con un intervalo bastante mayor que lo que tarda, agrega un lock (por ejemplo mkdir "$HOME/demo/shopwatch/.lock" al inicio y rmdir al final, saltando la corrida si mkdir falla) y mantén el trabajo idempotente para que una corrida saltada o doble no haga daño.

Qué opción de almacenamiento y cuándo

Situación Elección
Necesitas historial de precios y alertas products + snapshots de solo cambios, como arriba
Debes demostrar cómo se veía la página en una fecha guarda también el HTML comprimido, pero nunca lo uses para el hash
Pocos productos, prototipo rápido una tabla, todo en cada corrida; cámbiala antes de que crezca
Muchos lectores, un escritor SQLite en modo WAL alcanza
Varias máquinas escriben a la vez una base de servidor (PostgreSQL, MySQL); SQLite es una base de un solo archivo
El sitio envía ETag o Last-Modified peticiones condicionales para saltar páginas sin cambios; ver caché HTTP

Límites honestos y notas de versión

  • Verificado con Python 3.9.6 y SQLite 3.51.0. ON CONFLICT ... DO UPDATE requiere SQLite 3.24 (2018) y LAG() requiere 3.25; revisa sqlite3.sqlite_version en tu servidor, que puede ser más viejo que tu laptop.
  • La tienda de práctica tiene SKUs estables. Los sitios reales cambian ids, juntan variantes y muestran precios distintos según región o sesión. Elegir la clave es la parte más difícil.
  • requests estaba instalado por casualidad en esta máquina, pero el script usa solo la biblioteca estándar. Con requests la descarga sería requests.get(url, headers=..., timeout=10).text; no se ejecutó aquí.
  • Los tamaños dependen del tamaño de página, el largo de la clave y la versión de SQLite. Vuelve a medir con tus propios datos antes de planificar el disco.
  • Un SHA-256 truncado a 16 dígitos hex (64 bits) sobra para detectar cambios en miles de filas; no es una medida de seguridad.

Ejercicios

  1. Agrega un reporte de removed: un producto que estaba en products pero no apareció en la corrida de hoy. ¿Qué columna ya te lo dice?
  2. Escribe el SQL que lista los productos que se quedaron sin stock en la corrida de 2026-10-23T03:00:00Z.

Respuestas

  1. last_seen. Después de store(), SELECT sku FROM products WHERE last_seen < ? con el timestamp de hoy lista los productos que no se vieron en esta corrida. No los borres: un producto que falta un día suele ser una página que falló (clase 6).
  2. Usa LAG(in_stock) igual que en la consulta de precios: WITH o AS (SELECT sku, seen_at, in_stock, LAG(in_stock) OVER (PARTITION BY sku ORDER BY seen_at) AS before FROM snapshots) SELECT sku FROM o WHERE seen_at='2026-10-23T03:00:00Z' AND before=1 AND in_stock=0;. En nuestros datos devuelve SKU-0020 y SKU-0136.

Preguntas frecuentes

¿Conviene sacar el hash del HTML completo para detectar cambios?

No. Casi toda página trae algo que cambia en cada petición, como la hora de generación, un id de petición o un token CSRF. En nuestra corrida de 30 días el hash del HTML completo cambió en las 290 descargas de página, mientras que solo 217 de esas páginas tenían un producto que de verdad cambió. Calcula el hash solo de los campos normalizados que te importan: nombre, precio en centavos y stock.

¿INSERT OR REPLACE es lo mismo que un upsert en SQLite?

No. INSERT OR REPLACE borra la fila vieja e inserta una nueva, así que toda columna que no pases se reinicia y el rowid cambia. En nuestra prueba first_seen se sobrescribió y el rowid pasó de 1 a 2. INSERT ... ON CONFLICT(sku) DO UPDATE actualiza solo las columnas que nombras y conserva la fila.

¿Cuánto espacio ocupan los snapshots diarios de precios en SQLite?

Depende de si guardas todas las filas o solo los cambios. Con 2,000 productos durante 365 corridas diarias y 7% de cambios por día, guardar todo ocupó 77.05 MB y guardar solo cambios 5.44 MB; una tabla WITHOUT ROWID los bajó a 48.67 MB y 3.31 MB.

  • #scraping
  • #avanzado
  • #python
  • #sqlite
  • #detección de cambios
  • #hash
  • #cron

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

Acerca de · Apps

↑