Geonode logo
Geonode Team

Geonode Team

Actualizado: 16 de octubre de 2026

Publicado: 16 de octubre de 2026

Detección de cambios en sitios web con Python: seguimiento de actualizaciones de documentación y políticas

La detección de cambios en sitios web te permite descubrir si un proveedor ha modificado una cláusula de las condiciones de servicio, ha dejado de ofrecer un punto final en su documentación o ha editado discretamente una política de privacidad antes de que nadie lo anuncie. Al final de esta guía, tendrás un script en Python que captura una lista de páginas en formato Markdown a través de Geonode y Scraper API, elimina el ruido, almacena cada versión en SQLite, genera un comparativo legible y publica en Slack solo cuando el contenido ha cambiado realmente. Se ejecuta desde cron o GitHub Actions y resiste las recuperaciones inestables. Se ha evitado deliberadamente que sea un monitor de precios. Los precios son números en un espacio conocido; los documentos son texto en el que la ubicación y la forma de un cambio se desconocen de antemano. Las decisiones de diseño que se exponen a continuación se derivan de ello.

La detección de cambios en sitios web te permite descubrir si un proveedor ha modificado una cláusula de las condiciones de servicio, ha dejado de ofrecer un punto final en su documentación o ha editado discretamente una política de privacidad antes de que nadie lo anuncie. Al final de esta guía, tendrás un script en Python que captura una lista de páginas en formato Markdown a través de Geonode y Scraper API, elimina el ruido, almacena cada versión en SQLite, genera un comparativo legible y publica en Slack solo cuando el contenido ha cambiado realmente. Se ejecuta desde cron o GitHub Actions y resiste las recuperaciones inestables.

Se ha evitado deliberadamente que sea un monitor de precios. Los precios son números en un espacio conocido; los documentos son texto en el que la ubicación y la forma de un cambio se desconocen de antemano. Las decisiones de diseño que se exponen a continuación se derivan de ello.

Qué se supervisa y por qué se utiliza Markdown

Las páginas que merece la pena vigilar comparten un perfil común: son públicas, contienen mucho texto, se actualizan con poca frecuencia y, cuando se actualizan, tienen un gran impacto. Las páginas de referencia de API, las guías de autenticación, las tablas de límites de rate, los términos y condiciones, los acuerdos de tratamiento de datos, las políticas de uso aceptable, los registros de cambios y los historiales de incidencias de las páginas de estado entran en esta categoría.

Comparar el código HTML sin procesar de estas páginas resulta tedioso. Los hash integrados en las URL de los recursos, los tokens CSRF, los nombres de las clases de pruebas A/B y los códigos de análisis inyectados cambian con cada carga. Al convertirlas primero a Markdown, se descarta casi todo eso y solo quedan las frases. El punto final /v1/extract devuelve Markdown directamente, por lo que el script nunca recurre a un analizador de HTML.

Instala la única dependencia con pip install requests. Todo lo demás forma parte de la biblioteca estándar.

Captura de pantalla

El fetcher solicita Markdown, mantiene desactivada la representación con JavaScript por defecto y la activa página por página en los sitios de documentación que se representan del lado del cliente. El bloque wait_config solo es necesario cuando una página rellena su contenido tras la carga; si está presente, activa automáticamente la representación.

import os
import requests

API = "https://scraper.geonode.io"
HEADERS = {"X-Api-Key": os.environ["GEONODE_API_KEY"]}

def snapshot(session: requests.Session, url: str, render_js: bool = False,
             wait_for: str | None = None) -> tuple[int, str]:
    """Return (origin_http_status, markdown). Raises on API-level errors."""
    body = {"url": url, "formats": ["markdown"], "render_js": render_js,
            "processing_mode": "sync", "timeout": 60000}
    if wait_for:
        body["wait_config"] = {"wait_until": "domcontentloaded", "wait_for": wait_for,
                               "wait_timeout": 2000}
        body.pop("render_js")          # wait_config implies rendering; sending false is rejected
    r = session.post(f"{API}/v1/extract", json=body, headers=HEADERS, timeout=90)
    if r.status_code == 429:
        err = r.json().get("error", r.json())
        raise RuntimeError(f"rate limited ({err.get('code')}): {err.get('message')}")
    r.raise_for_status()
    payload = r.json()
    return payload["metadata"]["http_status"], (payload.get("data") or {}).get("markdown") or ""

El estado del origen es tan importante como el cuerpo. Una página de mantenimiento 503 se convierte en Markdown perfectamente válido; sin comprobar http_status, la almacenarías como una nueva versión y generarías una alerta al respecto.

Eliminación de ruido

Dos mecanismos, aplicados en orden. Una pasada con expresiones regulares elimina las líneas que se sabe que generan ruido: fechas, banners de «última actualización», avisos sobre cookies, pies de página de derechos de autor y enlaces de «editar esta página». A continuación, una lista de permitidos conserva solo los encabezados que te interesan, lo que elimina la navegación, las barras laterales y los bloques de marketing que el conversor de Markdown conservó por tratarse de texto real.

import re

NOISE = [
    re.compile(r"(?im)^.*\b(last (updated|modified|reviewed)|effective date|updated on)\b.*$"),
    re.compile(r"(?im)^.*\b(cookie|cookies)\b.*\b(accept|consent|preferences)\b.*$"),
    re.compile(r"(?im)^.*(©|copyright|all rights reserved).*$"),
    re.compile(r"(?im)^.*\b(edit this page|was this page helpful|on this page)\b.*$"),
    re.compile(r"\b(?:\d{1,2}\s)?(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4}\b", re.I),
    re.compile(r"\b\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2})?\S*)?\b"),
    re.compile(r"!\[[^\]]*\]\([^)]*\)"),                 # images
]

def strip_noise(md: str, keep_sections: list[str] | None = None) -> str:
    for rx in NOISE:
        md = rx.sub("", md)
    if keep_sections:
        kept, keep = [], False
        for line in md.splitlines():
            if line.startswith("#"):
                title = line.lstrip("#").strip().lower()
                keep = any(s.lower() in title for s in keep_sections)
            if keep:
                kept.append(line)
        md = "\n".join(kept)
    return normalize_ws(md)

def normalize_ws(text: str) -> str:
    lines = (re.sub(r"[ \t]+", " ", ln).strip() for ln in text.splitlines())
    return "\n".join(ln for ln in lines if ln)

keep_sections es una lista de subcadenas que coinciden con los encabezados, por lo que ["rate limit", "authentication"] conserva esos bloques H2/H3 y todo lo que hay debajo de ellos hasta el siguiente encabezado. Déjalo en None para páginas de políticas en las que cada cláusula es importante.

Las expresiones regulares de fecha son deliberadamente agresivas. La línea «Vigente a partir del 1 de marzo de 2026» de una página de políticas es exactamente el tipo de cambio que quieres ver, así que, si eso te importa, elimina la primera entrada NOISE y deja que la comparación lo muestre.

Comparación entre el hash y la comparación de diferencias

El hash responde a la pregunta «¿ha cambiado algo?» en tiempo constante. La comparación de diferencias responde a la pregunta «¿qué ha cambiado?» de una forma legible para una persona en un mensaje de Slack. La mayoría de las herramientas de detección de cambios en sitios web se limitan al hash; utiliza ambas: el hash para filtrar y la comparación de diferencias para informar.

import difflib
import hashlib

def digest(text: str) -> str:
    return hashlib.sha256(text.encode("utf-8")).hexdigest()

def unified(old: str, new: str, url: str, context: int = 2, max_lines: int = 120) -> str:
    diff = difflib.unified_diff(old.splitlines(), new.splitlines(),
                                fromfile=f"{url} (previous)", tofile=f"{url} (current)",
                                lineterm="", n=context)
    lines = list(diff)
    if len(lines) > max_lines:
        lines = lines[:max_lines] + [f"... {len(lines) - max_lines} more lines"]
    return "\n".join(lines)

difflib.unified_diff se encuentra en la biblioteca estándar y está documentado en docs.python.org. El argumento n establece el número de líneas de contexto; dos son suficientes para un texto en prosa. Limita la salida para que una reescritura de página completa no genere una alerta de 5.000 líneas.

Almacenamiento de instantáneas

SQLite conserva el historial completo, no solo la última versión. Esto permite comparar con las dos últimas instantáneas (siguiente sección) y responder a la pregunta «¿cuándo apareció por primera vez esta cláusula?» meses después.

import sqlite3
import time

SCHEMA = """
CREATE TABLE IF NOT EXISTS snapshots (
  id          INTEGER PRIMARY KEY,
  url         TEXT NOT NULL,
  taken_at    REAL NOT NULL,
  http_status INTEGER,
  hash        TEXT,
  content     TEXT
);
CREATE INDEX IF NOT EXISTS idx_url_time ON snapshots(url, taken_at DESC);
"""

def open_db(path: str = "changes.sqlite") -> sqlite3.Connection:
    db = sqlite3.connect(path)
    db.executescript(SCHEMA)
    return db

def last_snapshots(db: sqlite3.Connection, url: str, n: int = 2) -> list[tuple[str, str]]:
    """Most recent first: [(hash, content), ...] for successful (200) fetches only."""
    return db.execute("SELECT hash, content FROM snapshots WHERE url=? AND http_status=200 "
                      "ORDER BY taken_at DESC LIMIT ?", (url, n)).fetchall()

def save(db: sqlite3.Connection, url: str, status: int, h: str | None, content: str | None) -> None:
    db.execute("INSERT INTO snapshots (url, taken_at, http_status, hash, content) VALUES (?,?,?,?,?)",
               (url, time.time(), status, h, content))
    db.commit()

Las páginas de políticas son pequeñas, por lo que almacenar el contenido completo en cada ejecución resulta económico. Si supervisas cientos de documentos largos cada hora, almacena el contenido solo cuando cambie el hash y, en caso contrario, conserva únicamente la fila del hash.

Gestión de las recuperaciones inestables

Las recuperaciones inestables son la principal fuente de falsos positivos en la detección de cambios en sitios web, por lo que existen tres medidas de protección. Reintentar la recuperación con un tiempo de espera antes de darla por perdida. Considerar cualquier código de estado que no sea 200 como «sin datos», almacenar el estado en el registro y no realizar la comparación. Y comparar el nuevo hash con las dos últimas instantáneas válidas, no solo con la última: si la página ha pasado de A a B y luego de nuevo a A, lo más probable es que el B fuera una variante transitoria (una página de error que aún devolvía un 200, una caché que servía una versión obsoleta) y no merezca una alerta.

def fetch_with_retry(session: requests.Session, url: str, attempts: int = 3, **kw) -> tuple[int, str]:
    delay = 5
    for i in range(attempts):
        try:
            status, md = snapshot(session, url, **kw)
            if status < 500 and md.strip():
                return status, md
        except (requests.RequestException, RuntimeError) as exc:
            if i == attempts - 1:
                raise
        time.sleep(delay)
        delay *= 3
    return status, md

def check(db: sqlite3.Connection, session: requests.Session, url: str,
          keep_sections: list[str] | None = None, **kw) -> str | None:
    """Returns a unified diff if a real change was detected, else None."""
    status, raw = fetch_with_retry(session, url, **kw)
    if status != 200:
        save(db, url, status, None, None)
        return None
    clean = strip_noise(raw, keep_sections)
    h = digest(clean)
    previous = last_snapshots(db, url, n=2)
    save(db, url, status, h, clean)
    if not previous:
        return None                                   # first run, nothing to compare
    if any(h == old_hash for old_hash, _ in previous):
        return None                                   # unchanged, or a revert of a blip
    return unified(previous[0][1], clean, url)

La regla de la segunda instantánea tiene un inconveniente: no se informa de un cambio real que se revierta en una misma ejecución. Para la documentación y la supervisión de políticas, casi siempre es la decisión correcta.

Notificaciones

Los webhooks entrantes de Slack aceptan un cuerpo JSON con un campo text; envuelve el diff en un bloque de código para que se mantenga con espaciado fijo. El envío por correo electrónico a través de smtplib ocupa unas cuantas líneas más y resulta útil cuando no se dispone de Slack.

import json
import smtplib
from email.message import EmailMessage

SLACK_WEBHOOK = os.environ.get("SLACK_WEBHOOK_URL")

def notify_slack(url: str, diff: str) -> None:
    if not SLACK_WEBHOOK:
        return
    fence = "`" * 3                          # literal ``` would close the Markdown block
    text = f"*Change detected:* {url}\n{fence}\n{diff[:3500]}\n{fence}"
    requests.post(SLACK_WEBHOOK, data=json.dumps({"text": text}),
                  headers={"Content-Type": "application/json"}, timeout=15).raise_for_status()

def notify_email(url: str, diff: str, to: str) -> None:
    msg = EmailMessage()
    msg["Subject"] = f"Change detected: {url}"
    msg["From"] = os.environ["SMTP_FROM"]
    msg["To"] = to
    msg.set_content(diff)
    with smtplib.SMTP(os.environ["SMTP_HOST"], int(os.environ.get("SMTP_PORT", "587"))) as s:
        s.starttls()
        s.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
        s.send_message(msg)

Slack trunca los mensajes largos, de ahí el límite de 3.500 caracteres para el diff. En el caso de diffs largos, publica la primera pantalla y adjunta el texto completo en otro lugar, o bien incluye en el mensaje un enlace a un ID de instantánea almacenada.

La lista de seguimiento y el procesador

La configuración se almacena en una lista, por lo que puedes mantenerla en el repositorio y revisar los cambios que se le realicen como si fuera código. Cada entrada contiene la URL, si es necesario renderizarla, un selector opcional por el que esperar y una lista de secciones permitidas opcional.

WATCHLIST = [
    {"url": "https://example.com/legal/terms"},
    {"url": "https://example.com/legal/privacy"},
    {"url": "https://example.com/docs/api/authentication",
     "keep_sections": ["authentication", "rate limit"]},
    {"url": "https://example.com/changelog", "render_js": True, "wait_for": "main article"},
]

def main() -> None:
    db = open_db()
    session = requests.Session()
    for item in WATCHLIST:
        url = item["url"]
        try:
            diff = check(db, session, url, keep_sections=item.get("keep_sections"),
                         render_js=item.get("render_js", False), wait_for=item.get("wait_for"))
        except Exception as exc:
            print(f"[skip] {url}: {exc}")
            continue
        if diff:
            print(f"[changed] {url}")
            notify_slack(url, diff)
        else:
            print(f"[ok] {url}")

if __name__ == "__main__":
    main()

La primera ejecución inicializa la base de datos y no genera ningún informe. A partir de la segunda ejecución, cada cambio real genera un mensaje de Slack con las diferencias.

Detección de cambios en el sitio web mediante programación

Dos opciones que no requieren infraestructura. Una línea de cron en cualquier máquina que tenga Python y las variables de entorno:

# daily at 06:10, log to a file
10 6 * * * cd /opt/change-monitor && /usr/bin/python3 monitor.py >> monitor.log 2>&1

O un flujo de trabajo de GitHub Actions, que resulta muy práctico porque el archivo SQLite se puede volver a enviar al repositorio como almacén de estado y los secretos se guardan en la configuración del repositorio:

name: change-monitor
on:
  schedule:
    - cron: "10 6 * * *"
  workflow_dispatch:
jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install requests
      - run: python monitor.py
        env:
          GEONODE_API_KEY: ${{ secrets.GEONODE_API_KEY }}
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
      - run: |
          git config user.name "change-monitor"
          git config user.email "bot@users.noreply.github.com"
          git add changes.sqlite && git commit -m "snapshot $(date -u +%F)" || true
          git push

Las programaciones por horas son adecuadas para los registros de cambios; en el caso de las páginas legales, una frecuencia diaria ya es más de lo que la mayoría de los equipos necesitan. La tabla siguiente ofrece un punto de partida razonable.

Tipo de páginaFrecuencia sugeridarender_jskeep_sections
Condiciones, privacidad, PUAdiarianoninguna (página completa)
Referencia de la APIcada 6 hdepende del motor de documentaciónsí, los puntos finales que utilices
Registro de cambios / notas de lanzamientocada horaa menudoninguna
Historial de incidencias de la página de estadocada horanormalmenteninguna

Nota sobre las condiciones de uso y los robots

Leer una vez al día una página de documentación pública o de políticas es un uso habitual de la web, y es lo mismo que hace un navegador. Aun así, comprueba las condiciones del sitio y el robots.txt antes de añadirlo a la lista de seguimiento, mantén una cadencia razonable y nunca supervises páginas que requieran un inicio de sesión para las que no tengas autorización. Si un sitio web publica un canal RSS o una API de registro de cambios, da prioridad a estas opciones: resultan más económicas para ambas partes y no dejarán de funcionar cuando cambie el diseño de la página.

Errores comunes

Comparar código HTML sin procesar

Cada compilación de un sitio web de documentación moderno reescribe los hash de los recursos y los nombres de las clases. Las diferencias se vuelven ilegibles y cada implementación activa una alerta. Convierte el código a Markdown antes de nada, tal y como hace snapshot().

Alertas ante el primer código de estado distinto de 200

Un único código 503 o una página de verificación de bot que devuelva un 200 con el mensaje «Por favor, verifica que eres humano» no debería convertirse en una versión almacenada ni en una alerta. Comprueba http_status, exige que el contenido no esté vacío y compáralo con las dos últimas instantáneas válidas.

Eliminar demasiado poco o demasiado

Si tu comparación solo muestra una fecha, añádela a NOISE. Si se ha pasado por alto un cambio en una cláusula porque keep_sections ha eliminado el encabezado bajo el que se encontraba, amplía la lista de permitidos. Ejecuta el monitor durante una semana y ajusta las dos listas en función de las comparaciones que genere antes de confiar en él.

Dejar que la lista de vigilancia se aleje de la realidad

Una URL que redirige a una nueva ruta, o una página de documentación que se ha trasladado bajo un prefijo versionado, se convierte silenciosamente en un error 404 o en una página diferente. Registra de forma clara los estados distintos de 200 y revisa las líneas «omitidas» en la salida de vez en cuando.

Siguiente paso

Todo lo anterior se basa en la biblioteca estándar de Python más una llamada HTTP por página, que es todo lo que se necesita para la detección de cambios en los documentos de un sitio web. La parte que resulta difícil de alojar por cuenta propia es la propia recuperación de datos: una conversión a Markdown limpia que gestione sitios de documentos renderizados con JavaScript, devuelva el http_status real del origen y no se bloquee cuando se comprueben cuarenta páginas de proveedores cada mañana. Geonode y Scraper API proporcionan exactamente esa llamada, junto con render_js y wait_config para páginas renderizadas por el cliente y un nivel gratuito de 1.500 solicitudes al mes que cubre holgadamente una lista de seguimiento diaria. Consulta la página del Scraper API para ver los planes y el caso de uso de web scraping para conocer patrones relacionados.

Preguntas frecuentes

¿Qué es la detección de cambios en sitios web y cuándo resulta útil?

La detección de cambios en sitios web consiste en recuperar una página según una periodicidad determinada, compararla con la instantánea anterior y avisar cuando el contenido relevante difiere. Resulta especialmente útil para páginas de las que dependes pero sobre las que no tienes control: documentación de API, condiciones de servicio, políticas de privacidad, condiciones de precios y registros de cambios de proveedores, donde una modificación silenciosa puede romper una integración o un supuesto de cumplimiento normativo.

¿Por qué comparar Markdown en lugar de HTML sin procesar?

El HTML incluye el diseño, los scripts, los parámetros de seguimiento y los hash de compilación, que cambian sin que se modifique el texto. Al convertirlo primero a Markdown, la página se reduce a su contenido, de modo que una comparación muestra la frase que se ha editado en lugar de un centenar de líneas de cambios en los atributos. El Geonode Scraper API devuelve Markdown directamente, lo que mantiene el proceso breve.

¿Cómo evito las falsas alertas provocadas por fechas, avisos de cookies y navegación?

Elimina el ruido conocido con un pequeño conjunto de expresiones regulares (fechas, líneas de «última actualización», avisos de cookies, pies de página) y, a continuación, conserva solo las secciones que te interesan utilizando una lista de encabezados permitidos. Normaliza los espacios en blanco antes de aplicar el hash. Por último, compara con las dos últimas instantáneas para que una página de error transitoria no active una alerta por sí sola.

¿Debo aplicar un hash a la página o comparar las diferencias?

Ambas cosas. Un hash es una comprobación rápida de igualdad que decide si hay que hacer algo o no. Un diff unificado de difflib es lo que un usuario lee en la alerta. Calcula primero el hash, compara solo cuando el hash difiera y envía el diff junto con la notificación para que el lector pueda evaluar el cambio sin necesidad de abrir la página.

¿Con qué frecuencia debe ejecutarse la detección de cambios en el sitio web?

Adapta la frecuencia a la importancia y la rotación de la página. Las páginas legales y de políticas rara vez cambian más de unas pocas veces al año, por lo que una vez al día es suficiente. La documentación activa de las API y los registros de cambios justifican comprobaciones cada hora. Ejecútalo mediante cron o una programación de GitHub Actions, y mantén el intervalo lo suficientemente largo como para que se vuelva a intentar una recuperación fallida antes de que se notifique.

¿Qué aspectos hay que tener en cuenta en materia de cumplimiento y límites?

Supervisar la documentación pública y las páginas de políticas con una frecuencia moderada es un uso normal de la web, pero lee las condiciones de servicio y el archivo robots.txt del sitio y respétalas; además, no supervises páginas protegidas por autenticación a las que no tengas acceso. En cuanto a la API, el nivel gratuito Scraper API cubre 1.500 solicitudes al mes; una comprobación diaria de 40 páginas entra dentro de ese límite.