El rastreo web incremental consiste en volver a recuperar únicamente las páginas que han cambiado, y no todo el sitio web. Al final de esta guía, dispondrás de un módulo de Python que descubre las URL a partir de un mapa del sitio o del punto final Geonode /v1/map, las normaliza, almacena el estado de cada URL en SQLite, utiliza ETag y hash de contenido para determinar qué ha cambiado, adapta los intervalos de revisión, detecta eliminaciones y extrae el conjunto de páginas modificadas a través de /v1/extract o /v1/batch. Geonode se encarga de recuperar las páginas; tu script gestiona el estado y la comparación.
Por qué los rastreos completos malgastan el presupuesto
Un sitio web con 50 000 páginas, en el que el 2 % cambia cada semana, necesita unas 1 000 consultas semanales para mantenerse actualizado. Un rastreo completo semanal consume 50 000. Eso supone una diferencia de 50 veces en cuanto a solicitudes de API, ancho de banda y el tiempo que tu canalización queda bloqueada en E/S.
El desperdicio se agrava en las páginas renderizadas con JavaScript, donde cada solicitud es más lenta y costosa, y en los sitios que aplican límites de frecuencia de forma agresiva. El rastreo web incremental da la vuelta al problema: mantienes una pequeña cantidad de estado por URL y dejas que ese estado te indique dónde dirigir la siguiente solicitud.
El módulo que aparece a continuación tiene unas 120 líneas. Cada sección añade una parte. Instala la dependencia con pip install requests (httpx funciona de forma idéntica si lo prefieres).
Descubrimiento de URL
Dos fuentes, por orden de preferencia. Un mapa del sitio te proporciona las URL, además de un lastmod
opcional, que es la señal de cambio más sencilla que existe. Cuando un sitio no tiene un mapa del sitio utilizable, POST /v1/map
descubre las URL combinando el análisis del mapa del sitio con la extracción de enlaces HTML y devuelve cada URL con su source
.
import os
import xml.etree.ElementTree as ET
import requests
API = "https://scraper.geonode.io"
HEADERS = {"X-Api-Key": os.environ["GEONODE_API_KEY"]}
SM_NS = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
def urls_from_sitemap(sitemap_url: str, session: requests.Session) -> list[tuple[str, str | None]]:
"""Return (loc, lastmod) pairs. Follows sitemap index files one level."""
root = ET.fromstring(session.get(sitemap_url, timeout=30).content)
out: list[tuple[str, str | None]] = []
for sm in root.findall("sm:sitemap", SM_NS): # sitemap index
out.extend(urls_from_sitemap(sm.findtext("sm:loc", namespaces=SM_NS), session))
for u in root.findall("sm:url", SM_NS):
out.append((u.findtext("sm:loc", namespaces=SM_NS),
u.findtext("sm:lastmod", namespaces=SM_NS)))
return out
def urls_from_map(site_url: str, session: requests.Session) -> list[tuple[str, None]]:
body = {"url": site_url, "include_subdomains": False, "ignore_query_parameters": True}
r = session.post(f"{API}/v1/map", json=body, headers=HEADERS, timeout=120)
r.raise_for_status()
return [(link["url"], None) for link in r.json()["links"]]
El
lastmod
de un mapa del sitio es una indicación, no una garantía. Algunos CMS marcan cada URL con la hora de implementación; otros nunca la actualizan. Tómalo como un motivo para comprobarlo antes, nunca como una razón para saltarte la comprobación por completo.
Normalización de URL
Sin normalización, https://Example.com/docs/?utm_source=x#top y https://example.com/docs se convierten en dos filas y dos consultas. Normaliza antes de que nada modifique el almacén de estado.
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
TRACKING = {"utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content",
"gclid", "fbclid", "ref", "mc_cid", "mc_eid"}
def normalize(url: str) -> str:
parts = urlsplit(url.strip())
scheme = parts.scheme.lower()
host = parts.netloc.lower()
if (scheme, host.rsplit(":", 1)[-1]) in {("http", "80"), ("https", "443")}:
host = host.rsplit(":", 1)[0]
query = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True)
if k.lower() not in TRACKING]
query.sort()
path = parts.path or "/"
if len(path) > 1 and path.endswith("/"):
path = path[:-1] # pick one convention and stick to it
return urlunsplit((scheme, host, path, urlencode(query), "")) # fragment dropped
La regla de la barra final es una elección de política. Algunos marcos de trabajo sirven contenido diferente en /docs y /docs/. Si el sitio de destino es uno de ellos, elimina esa línea y deja que las dos URL se mantengan como filas separadas.
El almacén de estado
Una tabla de SQLite contiene toda la información que necesita el rastreador para tomar decisiones. Las columnas se corresponden con las señales que se describen en el resto de esta guía.
import sqlite3
import time
SCHEMA = """
CREATE TABLE IF NOT EXISTS pages (
normalized_url TEXT PRIMARY KEY,
url TEXT NOT NULL,
last_seen REAL,
last_fetched REAL,
next_check REAL DEFAULT 0,
interval_s INTEGER DEFAULT 86400,
content_hash TEXT,
etag TEXT,
last_modified TEXT,
sitemap_lastmod TEXT,
missing_runs INTEGER DEFAULT 0,
gone INTEGER DEFAULT 0
);
"""
def open_db(path: str = "crawl_state.sqlite") -> sqlite3.Connection:
db = sqlite3.connect(path)
db.executescript(SCHEMA)
return db
def upsert_discovered(db: sqlite3.Connection, found: list[tuple[str, str | None]]) -> None:
now = time.time()
for raw, lastmod in found:
n = normalize(raw)
row = db.execute("SELECT sitemap_lastmod FROM pages WHERE normalized_url=?", (n,)).fetchone()
if row is None:
db.execute("INSERT INTO pages (normalized_url,url,last_seen,sitemap_lastmod) VALUES (?,?,?,?)",
(n, raw, now, lastmod))
else:
bump = lastmod is not None and lastmod != row[0]
db.execute("UPDATE pages SET last_seen=?, missing_runs=0, sitemap_lastmod=?, "
"next_check=CASE WHEN ? THEN 0 ELSE next_check END WHERE normalized_url=?",
(now, lastmod, bump, n))
db.commit()
last_seen
registra la última ejecución de descubrimiento en la que apareció la URL, lo cual es distinto de last_fetched
. Un cambio en sitemap_lastmod
restablece next_check
a cero, de modo que la página se incluye en la siguiente cola de cambios.
Solicitudes condicionales: ETag e If-Modified-Since
Cuando un sitio envía ETag o Last-Modified, una solicitud condicional permite que el origen responda con un 304 sin cuerpo. Debes enviar esos encabezados a través del campo headers de /v1/extract y leer el estado del origen en metadata.http_status. La solicitud sigue contabilizándose en tu plan, pero es más rápida que una representación completa.
No todos los sitios respetan los validadores, y las CDN a veces los eliminan. El código que aparece a continuación los envía cuando están presentes y, si la respuesta es 200, pasa directamente al proceso de hash.
Cálculo del hash del contenido tras eliminar los bloques volátiles
El hash sin procesar de Markdown cambia en cada ejecución en las páginas que muestran la hora actual, un token CSRF o un bloque de «artículos relacionados». Elimina esos elementos antes de calcular el hash. Mantén las reglas de eliminación en un único lugar para poder ajustarlas según cada sitio web.
import hashlib
import re
VOLATILE = [
re.compile(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?\S*"), # ISO timestamps
re.compile(r"(?i)(csrf|nonce|session)[_-]?token[^\s]*"),
re.compile(r"(?i)^#+\s*related (posts|articles).*?(?=^#|\Z)", re.M | re.S),
re.compile(r"[ \t]+"),
]
def content_fingerprint(markdown: str) -> str:
text = markdown
for rx in VOLATILE:
text = rx.sub(" ", text)
text = "\n".join(line.strip() for line in text.splitlines() if line.strip())
return hashlib.sha256(text.encode("utf-8")).hexdigest()
def fetch_one(session: requests.Session, row: sqlite3.Row | tuple) -> dict:
"""Sync extract with conditional headers. Returns a dict the caller can store."""
normalized_url, url, etag, last_modified = row
cond = {}
if etag:
cond["If-None-Match"] = etag
if last_modified:
cond["If-Modified-Since"] = last_modified
body = {"url": url, "formats": ["markdown"], "render_js": False,
"processing_mode": "sync", "headers": cond or None, "timeout": 60000}
r = session.post(f"{API}/v1/extract", json=body, headers=HEADERS, timeout=90)
if r.status_code == 429:
raise RuntimeError(f"rate limited: {r.json().get('error', r.json())}")
r.raise_for_status()
payload = r.json()
status = payload["metadata"].get("http_status")
md = (payload.get("data") or {}).get("markdown") or ""
return {"http_status": status, "markdown": md,
"hash": content_fingerprint(md) if status == 200 else None}
Que los encabezados de respuesta ETag y Last-Modified del origen aparezcan en metadata depende de la versión de la API que utilices; comprueba la respuesta de tu cuenta antes de basarte en ellos. Si no aparecen, el hash del contenido te seguirá dando una respuesta fiable sobre si ha habido cambios o no; simplemente perderás el atajo 304.
La cola de cambios y la revisita adaptativa
La cola es el núcleo del rastreo web incremental, y se trata de una consulta, no de una estructura de datos: cada fila cuyo next_check se encuentre en el pasado y que no esté marcada como gone. Tras cada recuperación, el intervalo se modifica. Si se detecta un cambio: dividirlo por la mitad y redondearlo a la baja a una hora. Si no hay cambios: se multiplica por 1,5, redondeando al alza a 30 días. A lo largo de unas semanas, el calendario converge hacia la frecuencia de cambios observada en cada página.
MIN_S, MAX_S = 3600, 30 * 86400
def change_queue(db: sqlite3.Connection, limit: int = 500) -> list[tuple]:
return db.execute(
"SELECT normalized_url, url, etag, last_modified FROM pages "
"WHERE gone=0 AND next_check<=? ORDER BY next_check LIMIT ?",
(time.time(), limit)).fetchall()
def record_result(db: sqlite3.Connection, normalized_url: str, res: dict) -> str:
now = time.time()
old_hash, interval = db.execute(
"SELECT content_hash, interval_s FROM pages WHERE normalized_url=?",
(normalized_url,)).fetchone()
if res["http_status"] in (404, 410):
db.execute("UPDATE pages SET gone=1, last_fetched=? WHERE normalized_url=?", (now, normalized_url))
db.commit()
return "gone"
changed = res["http_status"] == 200 and res["hash"] != old_hash
interval = max(MIN_S, interval // 2) if changed else min(MAX_S, int(interval * 1.5))
db.execute("UPDATE pages SET last_fetched=?, next_check=?, interval_s=?, "
"content_hash=COALESCE(?, content_hash) WHERE normalized_url=?",
(now, now + interval, interval, res["hash"], normalized_url))
db.commit()
return "changed" if changed else "unchanged"
Un código de estado 304 llega con http_status == 304 y sin hash, por lo que pasa a la rama de «sin cambios» y el intervalo aumenta. Cualquier error 5xx o de red debe dejar la fila sin modificar para que se vuelva a intentar en la siguiente ejecución; para ello, envuelve fetch_one en un try/except en tu programa de ejecución.
Detección de URL eliminadas
Dos señales, utilizadas conjuntamente. Un error directo 404 o 410 activa inmediatamente gone en record_result. Una URL que desaparece del mapa del sitio es una prueba menos concluyente, ya que los mapas del sitio tienen un retraso y, en ocasiones, omiten secciones. Cuenta las ejecuciones consecutivas de detección en las que la URL ha estado ausente y solo fuerza una recuperación de confirmación tras N de ellas.
def mark_missing(db: sqlite3.Connection, run_started: float, n_runs: int = 3) -> None:
"""Call after upsert_discovered. Rows not seen this run get missing_runs+1."""
db.execute("UPDATE pages SET missing_runs=missing_runs+1 WHERE last_seen<? AND gone=0", (run_started,))
db.execute("UPDATE pages SET next_check=0 WHERE missing_runs>=? AND gone=0", (n_runs,))
db.commit()
Las filas marcadas como gone permanecen en la tabla. Esto evita que un enlace entrante obsoleto vuelva a introducir la URL como nueva. Si la página vuelve a aparecer, una ejecución de detección posterior la incluirá de nuevo; restablece gone a 0 en upsert_discovered si deseas ese comportamiento.
Obtención del conjunto de cambios de forma masiva
Para unos pocos cientos de URL, el bucle de sincronización anterior es suficiente, y en una configuración madura de rastreo web incremental, el conjunto de cambios suele ser tan reducido. Para miles de ellas, envía la cola como un único trabajo /v1/batch y comprueba su estado. El cuerpo del lote admite los mismos campos formats, render_js, proxy, headers y wait_config que una extracción individual, aplicados a todas las URL, por lo que los encabezados condicionales no pueden variar por URL dentro de un lote. Utiliza el lote para la ruta de hash de contenido simple y mantén la ruta de sincronización para las URL en las que dispongas de un ETag.
def batch_fetch(session: requests.Session, urls: list[str], poll_s: int = 10) -> dict[str, dict]:
body = {"urls": urls, "formats": ["markdown"], "render_js": False, "ignore_invalid_urls": True}
job = session.post(f"{API}/v1/batch", json=body, headers=HEADERS, timeout=60).json()
while True:
st = session.get(f"{API}/v1/batch/{job['job_id']}", headers=HEADERS, timeout=60).json()
if st["status"] in ("completed", "failed", "cancelled"):
break
time.sleep(poll_s)
out = {}
for res in st.get("results", []):
if res["status"] != "completed":
continue
md = (res.get("data") or {}).get("markdown") or ""
out[res["url"]] = {"http_status": res["metadata"].get("http_status"),
"markdown": md, "hash": content_fingerprint(md)}
return out
La alternativa es utilizar processing_mode: "async" en llamadas individuales a /v1/extract, lo que devuelve un código de estado 202 con un job_id que se consulta mediante GET /v1/extract/{job_id}. De este modo se conservan los encabezados por URL, pero se realiza una solicitud por cada URL. En cualquier caso, envía los resultados a través de record_result exactamente igual que en la ruta de sincronización.
Configuración de la ejecución
Una ejecución consiste en: descubrir, actualizar o insertar, marcar como faltantes, vaciar la cola y registrar. Ejecútala desde cron o un programador con la periodicidad que requiera tu objetivo de actualización; los intervalos adaptativos mantienen bajo el número de consultas, independientemente de la frecuencia con la que se active el programa de ejecución.
def run(sitemap_url: str | None, site_url: str) -> None:
db = open_db()
session = requests.Session()
started = time.time()
found = urls_from_sitemap(sitemap_url, session) if sitemap_url else urls_from_map(site_url, session)
upsert_discovered(db, found)
mark_missing(db, started)
queue = change_queue(db)
with_etag = [r for r in queue if r[2] or r[3]]
plain = [r for r in queue if not (r[2] or r[3])]
for row in with_etag:
try:
record_result(db, row[0], fetch_one(session, row))
except Exception as exc: # leave row for next run
print("retry later:", row[1], exc)
if plain:
results = batch_fetch(session, [r[1] for r in plain])
for row in plain:
if row[1] in results:
record_result(db, row[0], results[row[1]])
Este es el módulo completo de rastreo web incremental. La tabla siguiente resume qué hace cada señal, para que puedas eliminar las partes que no necesites.
| Señal | Coste | Fiabilidad | Qué hace en este módulo |
|---|---|---|---|
Mapa del sitio lastmod | una recuperación del mapa del sitio | baja a media | restablece next_check a 0 |
| ETag / Last-Modified | una solicitud, sin renderización | alta donde sea compatible | 304 significa que no ha cambiado |
| Hash de contenido | una recuperación completa | alta | la comparación con la verdad de referencia |
| Intervalo adaptativo | ninguno | n/a | decide cuándo volver a comprobar |
| 404 / 410 | una solicitud | alta | marca gone |
| Ausente del mapa del sitio tras N ejecuciones | ninguno | baja | fuerza una recuperación de confirmación |
Errores comunes
Calcular el hash antes de eliminar el contenido volátil
Una página que muestra «Generado a las 14:02:11» cambia con cada consulta, por lo que tu rastreador reduce a la mitad su intervalo hasta llegar al mínimo y vuelve a realizar consultas cada hora. Examina las diferencias entre dos consultas consecutivas de cualquier página que registre cambios en cada ejecución y añade el patrón a VOLATILE.
Normalizar después de la primera inserción
Si en la primera ejecución se almacenan las URL sin procesar y la normalización se añade más tarde, cada fila es un duplicado en potencia. Normaliza desde el primer día y haz de normalized_url la clave principal, tal y como hace el esquema.
Confiar únicamente en la ausencia del mapa del sitio
Los mapas del sitio van con retraso respecto a las implementaciones, se dividen en varios archivos de índice y, a veces, omiten secciones enteras tras un cambio en el CMS. Marcar una página como eliminada solo porque ha desaparecido de una recuperación del mapa del sitio borrará contenido activo de tu índice. Exige un código de estado 404 o 410 que lo confirme.
Ejecutar el procesamiento por lotes con encabezados condicionales por URL
/v1/batch aplica un único objeto headers a todas las URL del trabajo. Enviar un If-None-Match destinado a una página a otras mil produce resultados sin sentido. Dirige las filas que contengan ETag a través de la ruta de sincronización y el resto a través del procesamiento por lotes, tal y como hace run().
Siguiente paso
El módulo anterior gestiona el estado, compara contenidos y programa nuevas visitas, y necesita una capa de recuperación que devuelva código Markdown limpio con un http_status veraz para cada URL, incluidos los códigos 304 y 404 de los que depende. La API de Scraper API/v1/extract proporciona esa capa: /v1/extract para páginas individuales con encabezados por solicitud, /v1/batch para el conjunto de cambios masivos y /v1/map cuando no hay mapa del sitio. El nivel gratuito cubre 1.500 solicitudes al mes, lo suficiente para probar el bucle incremental en un sitio real antes de elegir un plan. Consulta la página de la Scraper API para ver los planes y los precios para conocer los niveles de solicitudes.
Preguntas frecuentes
¿Qué es el rastreo web incremental?
El rastreo web incremental consiste en volver a visitar únicamente aquellas páginas que es probable que hayan cambiado desde la última ejecución, en lugar de volver a recabar todo el sitio web cada vez. El rastreador mantiene un almacén de estado con una huella digital por cada URL, identifica las páginas candidatas de forma económica (fecha de última modificación del mapa del sitio, ETag, Last-Modified) y destina el presupuesto de descarga al subconjunto que realmente lo necesita.
¿Cómo puedo saber qué páginas han cambiado sin descargarlas?
Normalmente no se puede saber con certeza sin realizar una solicitud, pero sí se pueden clasificar las páginas candidatas. La fecha de última modificación del mapa del sitio es la señal más económica. Una solicitud condicional con «If-None-Match» o «If-Modified-Since» devuelve un código de estado 304 en sitios que admiten validadores. Para todo lo demás, se debe recuperar y comparar un hash del contenido calculado tras eliminar bloques volátiles como marcas de tiempo y tokens de sesión.
¿La API de GeonodeScraper API se encarga de la gestión del estado y la detección de cambios por mí?
No. La Scraper API recupera páginas y devuelve Markdown o HTML, además de metadatos como el http_status. Tu script se encarga del almacenamiento de estado, la normalización de URL, el cálculo de hash y la programación. Esa separación es deliberada: la API se mantiene sin estado y es económica de invocar, mientras que tu lógica de rastreo web incremental reside en código que tú controlas y puedes probar.
¿Cómo debo programar las revisiones para el rastreo web incremental?
Empieza con un intervalo predeterminado y, a continuación, adáptalo a cada URL. Si una recuperación detecta un cambio, reduce el intervalo a la mitad hasta un mínimo; si no detecta ningún cambio, multiplícalo hasta un máximo. Las páginas con mucha actividad se comprueban con frecuencia, mientras que las páginas estáticas tienden hacia el límite máximo. Almacena «next_check» en la tabla de estado y consulta las filas cuyo «next_check» haya caducado.
¿Cómo detecto las páginas que se han eliminado?
Utiliza dos señales conjuntamente. Un código 404 o 410 en la recuperación es una señal clara. La ausencia del mapa del sitio en N ejecuciones consecutivas es una señal más débil, ya que los mapas del sitio suelen estar incompletos. Marca la fila como eliminada solo cuando una consulta directa lo confirme, y conserva la fila para no volver a descubrir y volver a consultar la URL a partir de enlaces obsoletos.
¿Cuáles son los límites y las normas de cumplimiento que debo respetar?
Respeta el archivo robots.txt y las condiciones de uso del sitio web, mantén unas tasas de solicitud moderadas y nunca eludas la autenticación. En cuanto a la Scraper API, el plan gratuito cubre 1.500 solicitudes al mes y los planes de pago van de 2 a 100 subprocesos, así que adapta el conjunto de cambios a tu plan. Las solicitudes condicionales también cuentan como solicitudes.
