Geonode logo
Geonode Team

Geonode Team

Mis à jour : 16 octobre 2026

Publié : 16 octobre 2026

Détection des modifications sur les sites web avec Python : suivi des mises à jour de la documentation et des politiques

La détection des modifications sur un site web vous permet de savoir si un fournisseur a réécrit une clause de ses conditions générales d'utilisation, a déprécié un point de terminaison dans sa documentation ou a discrètement modifié sa politique de confidentialité avant que quiconque ne l'annonce. À la fin de ce guide, vous disposerez d’un script Python qui effectue un instantané d’une liste de pages au format Markdown via les URL Geonode et Scraper API, élimine les éléments superflus, stocke chaque version dans SQLite, génère un comparatif lisible et publie sur Slack uniquement lorsque le contenu a réellement changé. Il s’exécute via cron ou GitHub Actions et résiste aux récupérations instables. Il ne s’agit délibérément pas d’un outil de surveillance des prix. Les prix sont des chiffres occupant un emplacement connu ; les documents sont des textes dont l’emplacement et la nature des modifications ne sont pas connus à l’avance. Les choix de conception présentés ci-dessous découlent de ce principe.

La détection des modifications sur un site web vous permet de savoir si un fournisseur a réécrit une clause de ses conditions générales d'utilisation, a déprécié un point de terminaison dans sa documentation ou a discrètement modifié sa politique de confidentialité avant que quiconque ne l'annonce. À la fin de ce guide, vous disposerez d’un script Python qui effectue un instantané d’une liste de pages au format Markdown via les URL Geonode et Scraper API, élimine les éléments superflus, stocke chaque version dans SQLite, génère un comparatif lisible et publie sur Slack uniquement lorsque le contenu a réellement changé. Il s’exécute via cron ou GitHub Actions et résiste aux récupérations instables.

Il ne s’agit délibérément pas d’un outil de surveillance des prix. Les prix sont des chiffres occupant un emplacement connu ; les documents sont des textes dont l’emplacement et la nature des modifications ne sont pas connus à l’avance. Les choix de conception présentés ci-dessous découlent de ce principe.

Ce que vous surveillez et pourquoi Markdown

Les pages à surveiller présentent toutes les mêmes caractéristiques : elles sont publiques, contiennent beaucoup de texte, sont rarement mises à jour et ont un impact important lorsqu’elles le sont. Les pages de référence des API, les guides d’authentification, les tableaux de limitation de débit, les conditions générales, les accords de traitement des données, les politiques d’utilisation acceptable, les journaux de modifications et les historiques d’incidents des pages d’état en font tous partie.

Comparer le code HTML brut de ces pages est fastidieux. Les hachages intégrés aux URL des ressources, les jetons CSRF, les noms de classes de tests A/B et les éléments d’analyse injectés changent à chaque chargement. La conversion au format Markdown élimine d’emblée la quasi-totalité de ces éléments pour ne conserver que les phrases. Le point de terminaison /v1/extract renvoie directement du Markdown, de sorte que le script n’a jamais besoin de recourir à un analyseur HTML.

Installez la seule dépendance requise avec pip install requests. Tout le reste provient de la bibliothèque standard.

Prise d'un instantané

Le « fetcher » requiert le format Markdown, désactive par défaut le rendu JavaScript et l'active page par page pour les sites de documentation qui effectuent le rendu côté client. Le bloc wait_config n'est nécessaire que lorsqu'une page remplit son contenu après le chargement ; il active automatiquement le rendu lorsqu'il est présent.

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 ""

Le statut de l’origine est tout aussi important que le corps du message. Une page de maintenance 503 se convertit en Markdown parfaitement valide ; sans vérifier http_status, vous la stockeriez en tant que nouvelle version et déclencheriez une alerte à ce sujet.

Suppression du bruit

Deux mécanismes, appliqués dans l'ordre. Un passage par expression régulière supprime les lignes connues pour être redondantes : dates, bannières « dernière mise à jour », mentions relatives aux cookies, pieds de page de droits d'auteur, liens « modifier cette page ». Ensuite, une liste blanche par section ne conserve que les titres qui vous intéressent, ce qui supprime la navigation, les barres latérales et les blocs marketing que le convertisseur Markdown avait conservés car il s'agissait de texte réel.

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 est une liste de sous-chaînes comparées aux titres ; ainsi, ["rate limit", "authentication"] conserve les blocs H2/H3 et tout ce qui se trouve en dessous jusqu’au titre suivant. Laissez-le sur None pour les pages de politique où chaque clause a son importance.

Les expressions régulières relatives à la date sont délibérément strictes. La ligne « En vigueur à compter du 1er mars 2026 » d’une page de politique est exactement le type de modification que vous souhaitez voir apparaître ; si cela vous importe, supprimez la première entrée NOISE et laissez la comparaison afficher cette modification.

Comparaison entre le hachage et la comparaison de différences

Le hachage permet de déterminer si « quelque chose a changé » en temps constant. La comparaison de différences permet de déterminer « ce qui a changé » sous une forme lisible par l'utilisateur dans un message Slack. La plupart des outils de détection des modifications de sites web se contentent du hachage ; utilisez les deux : le hachage pour le filtrage, la comparaison de différences pour le rapport.

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 fait partie de la bibliothèque standard et est documenté sur docs.python.org. L’argument n définit le nombre de lignes de contexte ; deux suffisent pour un texte en prose. Limitez la sortie afin qu’une réécriture complète d’une page ne génère pas une alerte de 5 000 lignes.

Stockage des instantanés

SQLite conserve l'historique complet, et pas seulement la dernière version. Cela permet de comparer avec les deux derniers instantanés (section suivante) et de répondre à la question « quand cette clause est-elle apparue pour la première fois ? » plusieurs mois plus tard.

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()

Les pages de politique sont de petite taille ; le stockage de l’intégralité du contenu à chaque exécution est donc peu coûteux. Si vous surveillez des centaines de documents volumineux toutes les heures, ne stockez le contenu que lorsque le hachage a changé et conservez uniquement la ligne de hachage dans le reste des cas.

Gestion des requêtes instables

Les requêtes instables constituent la principale source de faux positifs dans la détection des modifications sur les sites web ; il existe donc trois mesures de protection. Réessayer la requête en appliquant un délai d’attente avant d’abandonner. Considérer tout code d’état autre que 200 comme « aucune donnée », enregistrer ce code d’état dans l’historique et ne pas effectuer de comparaison. Comparez également le nouveau hachage aux deux derniers instantanés valides, et pas seulement au dernier : si la page a alterné entre A, B, A, le B était très probablement une variante transitoire (une page d’erreur renvoyant tout de même un code 200, un cache servant une version obsolète) et ne mérite pas de déclencher une alerte.

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 règle du deuxième instantané présente un inconvénient : une modification réelle qui est annulée au cours d’une même exécution n’est pas signalée. Pour la documentation et la surveillance des politiques, c’est presque toujours le bon compromis.

Notification

Les webhooks entrants de Slack acceptent un corps JSON comportant un champ text ; placez le diff dans un bloc de code afin qu'il reste en police à espacement fixe. L'envoi par e-mail via smtplib nécessite quelques lignes supplémentaires et s'avère utile lorsque Slack n'est pas disponible.

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 tronque les messages longs, d’où la limite de 3 500 caractères pour le diff. Pour les diffs volumineux, publiez la première page et joignez le texte complet ailleurs, ou indiquez dans le message un identifiant de snapshot enregistré.

La liste de surveillance et le processus

La configuration est stockée dans une liste, ce qui vous permet de la conserver dans le dépôt et d’en examiner les modifications comme s’il s’agissait de code. Chaque entrée contient l’URL, indique si un rendu est nécessaire, un sélecteur facultatif à surveiller et une liste blanche facultative.

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 première exécution initialise la base de données et ne génère aucun rapport. À partir de la deuxième exécution, chaque modification réelle génère un message Slack contenant le diff.

Détection des modifications sur un site web

Deux options ne nécessitant aucune infrastructure. Une tâche cron sur n’importe quelle machine disposant de Python et des variables d’environnement suivantes :

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

Ou un workflow GitHub Actions, ce qui est pratique car le fichier SQLite peut être validé dans le dépôt en tant que magasin d’état et les secrets sont stockés dans les paramètres du dépôt :

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

Une fréquence horaire convient pour les journaux de modifications ; pour les pages juridiques, une fréquence quotidienne est déjà plus que suffisante pour la plupart des équipes. Le tableau ci-dessous constitue un bon point de départ.

Type de pageFréquence suggéréerender_jskeep_sections
Conditions générales, confidentialité, conditions d’utilisationquotidiennonaucune (page entière)
Référence APItoutes les 6 hdépend du moteur de documentationoui, les points de terminaison que vous utilisez
Journal des modifications / notes de mise à jourtoutes les heuressouventaucune
Historique des incidents de la page d’étattoutes les heuresgénéralementaucune

Remarque concernant les conditions d'utilisation et les robots

Consulter une fois par jour une page de documentation publique ou de politique d'utilisation relève d'une utilisation normale du Web, et c'est exactement ce que fait un navigateur. Veillez toutefois à vérifier les conditions d'utilisation du site et le robots.txt avant de l'ajouter à votre liste de suivi, à maintenir une fréquence raisonnable et à ne jamais surveiller des pages protégées par une connexion à laquelle vous n'avez pas accès. Si un site publie un flux RSS ou une API de journal des modifications, privilégiez ces solutions : elles sont plus économiques pour les deux parties et ne seront pas affectées par d'éventuels changements de mise en page.

Erreurs courantes

Comparaison de code HTML brut

Chaque compilation d’un site de documentation moderne réécrit les hachages des ressources et les noms de classes. Les différences deviennent illisibles et chaque déploiement déclenche une alerte. Convertissez d’abord le contenu au format Markdown, comme le fait snapshot().

Déclencher une alerte dès le premier code d'état autre que 200

Un seul code 503 ou une page de vérification anti-bot renvoyant un code 200 avec le message « Veuillez vérifier que vous êtes bien un humain » ne doit pas donner lieu à une version enregistrée ni à une alerte. Consultez http_status, exigez un contenu non vide et comparez avec les deux derniers instantanés valides.

Suppression insuffisante ou excessive

Si votre comparaison ne montre qu’une date, ajoutez-la à NOISE. Si une modification de clause a été manquée parce que keep_sections a supprimé l’en-tête sous lequel elle se trouvait, élargissez la liste blanche. Faites fonctionner le moniteur pendant une semaine et affinez les deux listes en fonction des différences qu’il produit avant de lui faire confiance.

Laisser la liste de surveillance s’éloigner de la réalité

Une URL qui redirige vers un nouveau chemin, ou une page de documentation déplacée sous un préfixe versionné, se transforme silencieusement en une erreur 404 ou en une autre page. Consignez clairement les statuts autres que 200 et passez régulièrement en revue les lignes « skip » dans la sortie.

Étape suivante

Tout ce qui précède repose sur la bibliothèque standard de Python, à laquelle s'ajoute un appel HTTP par page, ce qui suffit amplement pour la détection des modifications sur les sites web hébergeant des documents. La partie difficile à héberger soi-même est la récupération elle-même : une conversion Markdown propre qui gère les sites de documentation rendus en JavaScript, renvoie le véritable http_status de l’origine et ne se fait pas bloquer lorsque vous consultez quarante pages de fournisseurs chaque matin. Les services Geonode et Scraper API fournissent exactement cet appel, avec render_js et wait_config pour les pages rendues côté client, ainsi qu’un forfait gratuit de 1 500 requêtes par mois qui couvre largement une liste de surveillance quotidienne. Consultez la page du Scraper API pour les formules et le cas d’utilisation du web scraping pour les modèles associés.

FAQ

Qu'est-ce que la détection des modifications sur un site web et dans quels cas est-elle utile ?

La détection des modifications sur un site web consiste à récupérer une page à intervalles réguliers, à la comparer à l'instantané précédent et à signaler tout changement significatif du contenu. Elle est particulièrement utile pour les pages dont vous dépendez mais que vous ne contrôlez pas : documentation d’API, conditions d’utilisation, politiques de confidentialité, conditions tarifaires et journaux de modifications des fournisseurs, où une modification silencieuse peut compromettre une intégration ou une hypothèse de conformité.

Pourquoi comparer du Markdown plutôt que du HTML brut ?

Le HTML contient la mise en page, les scripts, les paramètres de suivi et les hachages de compilation qui changent sans que le texte ne soit modifié. La conversion en Markdown réduit d’abord la page à son contenu, de sorte qu’une comparaison affiche la phrase qui a été modifiée plutôt qu’une centaine de lignes d’attributs modifiés. La fonction Geonode Scraper API renvoie directement du Markdown, ce qui permet de garder un pipeline court.

Comment éviter les fausses alertes liées aux dates, aux bannières de cookies et à la navigation ?

Éliminez les bruits connus à l’aide d’un petit ensemble d’expressions régulières (dates, lignes « dernière mise à jour », notifications de cookies, pieds de page), puis ne conservez que les sections qui vous intéressent à l’aide d’une liste blanche de titres. Normalisez les espaces avant de calculer le hachage. Enfin, comparez avec les deux derniers instantanés afin qu’une page d’erreur temporaire ne déclenche pas d’alerte à elle seule.

Dois-je calculer le hachage de la page ou effectuer une comparaison de différences ?

Les deux. Un hachage permet une vérification rapide de l’égalité qui détermine s’il faut agir ou non. Un diff unifié issu de difflib correspond à ce qu’un humain lit dans l’alerte. Effectuez d’abord le hachage, ne calculez le diff que lorsque le hachage diffère, puis envoyez le diff avec la notification afin que le lecteur puisse évaluer le changement sans ouvrir la page.

À quelle fréquence la détection des modifications du site web doit-elle être exécutée ?

Adaptez la fréquence à l’importance de la page et à son taux de rotation. Les pages juridiques et de politique ne changent généralement que quelques fois par an ; une vérification quotidienne suffit donc amplement. Les documentations d’API et les journaux de modifications, qui évoluent fréquemment, justifient des vérifications toutes les heures. Exécutez la vérification via un cron ou un planning GitHub Actions, et prévoyez un intervalle suffisamment long pour qu’une requête instable puisse être réessayée avant d’être signalée.

Quelles sont les considérations en matière de conformité et de limites ?

La surveillance des pages de documentation publique et de politique à une fréquence modérée relève d’une utilisation normale du Web, mais lisez les conditions d’utilisation et le fichier robots.txt du site, respectez-les et ne surveillez pas les pages protégées par une authentification à laquelle vous n’avez pas droit. Du côté des API, le niveau gratuit Scraper API couvre 1 500 requêtes par mois ; une vérification quotidienne de 40 pages s’inscrit dans cette limite.