L'exploration incrémentielle du Web consiste à ne récupérer que les pages qui ont été modifiées, et non l'intégralité du site. À la fin de ce guide, vous disposerez d’un module Python capable de détecter les URL à partir d’un plan du site ou du point de terminaison Geonode /v1/map, de les normaliser, de conserver l’état de chaque URL dans SQLite, d’utiliser les ETag et les hachages de contenu pour déterminer ce qui a changé, d’adapter les intervalles de revisite, de détecter les suppressions et d’extraire l’ensemble des pages modifiées via /v1/extract ou /v1/batch. Geonode se charge de récupérer les pages ; votre script gère l’état et la comparaison.
Pourquoi les réindexations complètes gaspillent le budget
Un site comptant 50 000 pages, dont 2 % changent chaque semaine, nécessite environ 1 000 requêtes par semaine pour rester à jour. Une exploration complète hebdomadaire en consomme 50 000. Cela représente une différence de 50 fois en termes de requêtes API, de bande passante et de temps pendant lequel votre pipeline est bloqué en E/S.
Ce gaspillage s'accentue sur les pages rendues en JavaScript, où chaque requête est plus lente et plus coûteuse, ainsi que sur les sites qui appliquent une limitation de débit très stricte. L'exploration incrémentielle du Web résout ce problème : vous conservez une petite quantité d’état par URL et laissez cet état vous indiquer où diriger la prochaine requête.
Le module ci-dessous compte environ 120 lignes. Chaque section ajoute un élément. Installez la dépendance avec pip install requests (httpx fonctionne de manière identique si vous le préférez).
Découverte des URL
Deux sources, par ordre de préférence. Un plan du site vous fournit des URL ainsi qu’un champ lastmod
facultatif, qui constitue le signal de modification le plus simple qui soit. Lorsqu’un site ne dispose pas de plan du site exploitable, la requête POST /v1/map
permet de découvrir les URL en combinant l’analyse du plan du site et l’extraction des liens HTML, puis renvoie chaque URL avec sa 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"]]
Le champ
lastmod
d’un plan du site est une indication, pas une garantie. Certains CMS apposent la date de déploiement sur chaque URL ; d’autres ne la mettent jamais à jour. Considérez cela comme une raison de vérifier plus tôt, jamais comme une raison de ne pas effectuer de vérification du tout.
Normalisation des URL
Sans normalisation, https://Example.com/docs/?utm_source=x#top et https://example.com/docs donnent lieu à deux lignes et deux requêtes. Effectuez la normalisation avant que quoi que ce soit n'accède au magasin d'état.
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 règle du « / » final relève d’un choix de politique. Certains frameworks servent un contenu différent sur /docs et /docs/. Si le site cible fait partie de ceux-là, supprimez cette ligne et laissez les deux URL exister en tant que lignes distinctes.
Le magasin d'état
Une seule table SQLite contient toutes les informations dont le robot d'indexation a besoin pour prendre ses décisions. Les colonnes correspondent aux signaux abordés dans la suite de ce guide.
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
enregistre la dernière session de découverte au cours de laquelle l'URL a été répertoriée ; cette information est distincte de last_fetched
. Une modification de sitemap_lastmod
remet next_check
à zéro, afin que la page soit prise en compte dans la prochaine file d'attente de modification.
Requêtes conditionnelles : ETag et If-Modified-Since
Lorsqu’un site envoie un en-tête ETag ou Last-Modified, une requête conditionnelle permet à la source de renvoyer un code d’état 304 sans corps. Vous transmettez ces en-têtes via le champ headers de /v1/extract et récupérez le code d’état de la source dans metadata.http_status. La requête est toujours comptabilisée dans votre forfait, mais elle est plus rapide qu’un rendu complet.
Tous les sites ne prennent pas en charge les validateurs, et les CDN les suppriment parfois. Le code ci-dessous les envoie lorsqu’ils sont présents et passe au hachage lorsque la réponse est 200.
Calcul du hachage du contenu après suppression des blocs volatils
Le hachage brut du Markdown change à chaque exécution sur les pages affichant l'heure actuelle, un jeton CSRF ou un bloc « articles connexes ». Supprimez ces éléments avant de calculer le hachage. Regroupez les règles de suppression au même endroit afin de pouvoir les ajuster pour chaque site.
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}
La présence des en-têtes de réponse ETag et Last-Modified de l’origine dans les metadata dépend de la version de l’API que vous utilisez ; vérifiez la réponse de votre compte avant de vous y fier. S’ils ne sont pas présents, le hachage du contenu vous fournit tout de même une réponse fiable indiquant si le contenu a été modifié ou non ; vous perdez simplement le raccourci 304.
La file d’attente des modifications et la revisite adaptative
La file d’attente est au cœur de l’exploration incrémentielle du Web ; il s’agit d’une requête, et non d’une structure de données : chaque ligne dont la valeur next_check est antérieure à la date actuelle et qui n’est pas marquée gone. Après chaque récupération, l’intervalle est ajusté. Si une modification est détectée : diviser par deux, en arrondissant à l’heure inférieure. Aucun changement : multipliez-le par 1,5, en arrondissant au plafond à 30 jours. En l’espace de quelques semaines, le calendrier converge vers la fréquence de changement observée pour chaque page.
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 code 304 arrive avec http_status == 304 et sans hachage ; il est donc classé dans la branche « inchangé » et l’intervalle s’allonge. Toute erreur 5xx ou d’fetch_one devrait laisser la ligne inchangée afin qu’elle soit réessayée lors de la prochaine exécution ; pour cela, encapsulez fetch_one dans un bloc try/except dans votre programme d’exécution.
Détection des URL supprimées
Deux signaux, utilisés conjointement. Une erreur 404 ou 410 directe déclenche immédiatement la valeur gone dans record_result. Une URL qui disparaît du plan du site constitue un indice moins fiable, car les plans de site sont mis à jour avec un certain décalage et omettent parfois certaines sections. Comptez le nombre de cycles de découverte consécutifs au cours desquels l’URL était absente et ne forcez une récupération de confirmation qu’après N cycles.
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()
Les lignes marquées gone restent dans la table. Cela empêche un lien entrant obsolète de réintroduire l’URL comme si elle était nouvelle. Si la page réapparaît, un cycle de découverte ultérieur la répertoriera à nouveau ; réinitialisez gone à 0 dans upsert_discovered si vous souhaitez ce comportement.
Récupération en masse de l'ensemble des pages modifiées
Pour quelques centaines d'URL, la boucle de synchronisation ci-dessus convient parfaitement, et dans une configuration de crawling incrémental bien rodée, l'ensemble des pages modifiées est généralement aussi restreint. Pour des milliers d’URL, soumettez la file d’attente sous la forme d’une seule tâche /v1/batch et interrogez-la. Le corps de la tâche par lots accepte les mêmes champs formats, render_js, proxy, headers et wait_config qu’une extraction unique, appliqués à chaque URL ; les en-têtes conditionnels ne peuvent donc pas varier d’une URL à l’autre au sein d’un lot. Utilisez le traitement par lots pour le chemin « content-hash » simple et conservez le chemin de synchronisation pour les URL pour lesquelles vous disposez d’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
L’alternative consiste à utiliser processing_mode: "async" sur des appels /v1/extract individuels, ce qui renvoie un code 202 avec un job_id que vous interrogez via GET /v1/extract/{job_id}. Cela permet de conserver les en-têtes par URL, mais nécessite une requête par URL. Dans les deux cas, transmettez les résultats via record_result exactement comme le fait le chemin de synchronisation.
Mise en place de l'exécution
Une exécution comprend les étapes suivantes : découverte, insertion ou mise à jour, signalement des éléments manquants, vidage de la file d'attente, enregistrement. Lancez-la via cron ou un planificateur à la fréquence requise par votre objectif d'actualité ; les intervalles adaptatifs permettent de maintenir un faible nombre de requêtes, quelle que soit la fréquence de déclenchement du programme d'exécution.
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]])
Voici l’intégralité du module de crawl Web incrémental. Le tableau ci-dessous résume la fonction de chaque signal, afin que vous puissiez supprimer les éléments dont vous n’avez pas besoin.
| Signal | Coût | Fiabilité | Ce qu’il fait dans ce module |
|---|---|---|---|
Sitemap lastmod | une récupération du sitemap | faible à moyenne | réinitialise next_check à 0 |
| ETag / Last-Modified | une requête, pas de rendu | élevée là où pris en charge | 304 signifie inchangé |
| Hachage du contenu | une récupération complète | élevée | comparaison avec la vérité de référence |
| Intervalle adaptatif | Aucune | N/A | Détermine quand effectuer une nouvelle vérification |
| 404 / 410 | Une requête | Élevée | Marque la page comme gone |
| Absent du plan du site pendant N itérations | Aucune | Faible | Force une récupération de confirmation |
Erreurs courantes
Calcul du hachage avant de supprimer le contenu volatile
Une page affichant « Généré à 14 h 02 min 11 s » change à chaque récupération ; votre robot d'indexation réduit donc de moitié son intervalle jusqu'à atteindre la limite inférieure et effectue alors une nouvelle récupération toutes les heures. Examinez la comparaison de deux récupérations consécutives pour toute page signalant un changement à chaque exécution, puis ajoutez ce modèle à VOLATILE.
Normalisation après la première insertion
Si la première exécution stocke des URL brutes et que la normalisation est ajoutée ultérieurement, chaque ligne est un doublon en puissance. Normalisez dès le premier jour et faites d’normalized_url la clé primaire, comme le prévoit le schéma.
Ne pas se fier uniquement à l’absence de plan du site
Les plans de site sont décalés par rapport aux déploiements, répartis entre plusieurs fichiers d’index et omettent parfois des sections entières après une modification du CMS. Marquer une page comme supprimée simplement parce qu’elle a disparu d’une récupération du plan du site supprimera du contenu actif de votre index. Exigez une confirmation par un code 404 ou 410.
Exécution d’un traitement par lots avec des en-têtes conditionnels par URL
/v1/batch applique un seul objet headers à chaque URL du traitement. Envoyer un If-None-Match destiné à une seule page à un millier d’autres produit des résultats dénués de sens. Acheminez les lignes comportant un ETag via le chemin de synchronisation et les autres via le traitement par lots, comme le fait run().
Étape suivante
Le module ci-dessus gère l'état, compare le contenu et planifie les nouvelles visites ; il a besoin d'une couche de récupération qui renvoie du Markdown « propre » avec un code http_status valide pour chaque URL, y compris les codes 304 et 404 sur lesquels il s'appuie. L’Scraper API de Geonode fournit cette couche : /v1/extract pour les pages individuelles avec des en-têtes par requête, /v1/batch pour les ensembles de modifications en masse, et /v1/map en l’absence de plan du site. Le niveau gratuit couvre 1 500 requêtes par mois, ce qui est suffisant pour tester la boucle incrémentale sur un site réel avant de choisir un forfait. Consultez la page de l’Scraper API pour les formules, et la tarification pour les paliers de requêtes.
FAQ
Qu'est-ce que l'exploration incrémentielle du Web ?
L'exploration incrémentielle du Web consiste à ne revisiter que les pages susceptibles d'avoir changé depuis votre dernière session, au lieu de récupérer l'intégralité d'un site à chaque fois. Le robot d'indexation conserve un historique avec une empreinte pour chaque URL, identifie les pages candidates à moindre coût (date de dernière modification du plan du site, ETag, Last-Modified) et consacre son budget de récupération au sous-ensemble qui en a réellement besoin.
Comment savoir quelles pages ont changé sans les télécharger ?
En général, il est impossible de le savoir avec certitude sans envoyer de requête, mais vous pouvez classer les pages candidates par ordre de priorité. La date de dernière modification du plan du site est l’indicateur le moins coûteux. Une requête conditionnelle avec If-None-Match ou If-Modified-Since renvoie un code 304 sur les sites prenant en charge les validateurs. Pour tous les autres cas, récupérez et comparez un hachage de contenu calculé après avoir supprimé les blocs volatils tels que les horodatages et les jetons de session.
L’GeonodeScraper API gère-t-elle pour moi la gestion de l’état et la détection des modifications ?
Non. L’Scraper API récupère les pages et renvoie du Markdown ou du HTML ainsi que des métadonnées telles que http_status. C’est votre script qui gère le stockage de l’état, la normalisation des URL, le hachage et la planification. Cette séparation est délibérée : l’API reste sans état et peu coûteuse à appeler, tandis que votre logique de crawling web incrémental réside dans du code que vous contrôlez et pouvez tester.
Comment dois-je planifier les nouvelles visites pour le crawling incrémental ?
Commencez par un intervalle par défaut, puis adaptez-le en fonction de chaque URL. Si une requête détecte un changement, divisez l’intervalle par deux jusqu’à une valeur minimale ; si elle ne détecte aucun changement, multipliez-le jusqu’à une valeur maximale. Les pages qui évoluent fréquemment sont vérifiées souvent, tandis que les pages statiques tendent vers le plafond. Enregistrez la valeur next_check dans la table d’état et interrogez les lignes dont la date next_check est dépassée.
Comment détecter les pages qui ont été supprimées ?
Utilisez deux signaux conjointement. Un code 404 ou 410 lors de la récupération est un signal fort. L’absence du plan du site lors de N exécutions consécutives est un signal plus faible, car les plans de site sont souvent incomplets. Ne marquez la ligne comme supprimée que lorsqu’une requête directe le confirme, et conservez la ligne afin de ne pas redécouvrir et récupérer à nouveau l’URL à partir de liens obsolètes.
Quelles sont les limites et les règles de conformité que je dois respecter ?
Respectez le fichier robots.txt et les conditions d’utilisation du site, maintenez un taux de requêtes modéré et ne contournez jamais l’authentification. Du côté de l’Scraper API, le forfait gratuit couvre 1 500 requêtes par mois et les forfaits payants vont de 2 à 100 threads ; adaptez donc la taille de votre ensemble de modifications à votre forfait. Les requêtes conditionnelles comptent toujours comme des requêtes.
