Scraping

Concevoir des scrapers idempotents : redémarrer sans dupliquer

Les scrapers plantent. Nous en avons tué un dix fois en plein crawl : une version naïve stockait jusqu'à 4,3 fois plus de lignes, une version idempotente exactement 500. Comment construire la seconde.

Chris Collins

Chris Collins

2 octobre 2026 · 11 min de lecture

Chaque scraper finit tôt ou tard par s’arrêter en plein milieu. Un conteneur est reprogrammé, un déploiement redémarre le worker, une machine manque de mémoire, ou quelqu’un appuie sur Ctrl+C. Ce qui se passe ensuite détermine si vos données restent fiables. Un scraper qui redémarre simplement depuis le début re-récupère tout et, sauf s’il a été conçu pour cela, réécrit tout une nouvelle fois.

Un scraper idempotent est un scraper où exécuter le même travail deux fois a le même effet que l’exécuter une seule fois. Il peut être interrompu à tout moment, redémarré, et se retrouver malgré tout avec exactement une copie correcte de chaque enregistrement. Ce guide présente les quatre décisions de conception qui rendent cela possible, avec du code testé et les résultats obtenus en le tuant dix fois en plein crawl.

Points clés

  • Partez du principe que le processus mourra entre deux lignes quelconques. Concevez le système pour qu’un redémarrage ne répète au maximum que la seule page en cours de traitement.
  • Donnez à chaque enregistrement un ID dérivé de ce qu’il est, par exemple la source et le SKU, jamais du moment où il a été scrapé. Ainsi, une réécriture devient une mise à jour, pas un doublon.
  • Conservez la frontière de crawl dans un stockage durable, et marquez une URL comme terminée dans la même transaction que celle qui stocke son résultat.
  • Utilisez des baux (leases) plutôt que des verrous, afin que le travail revendiqué par un worker planté redevienne disponible de lui-même.
  • Dans notre test sur 500 produits, tué dix fois par run : le scraper naïf a terminé avec 1 286 à 2 165 lignes et jusqu’à 4,3 fois plus de requêtes ; le scraper idempotent a terminé avec exactement 500 lignes correctes et au maximum 3 requêtes répétées.

Pourquoi les redémarrages créent des doublons

La première version courante d’un scraper ressemble à ceci : commencer à la page un, suivre la pagination, insérer une ligne pour chaque produit, valider au fur et à mesure. Cela fonctionne parfaitement jusqu’à ce qu’il soit interrompu. Au redémarrage, il n’a aucun souvenir de où il en était, donc il recommence, et chaque produit déjà stocké est inséré une seconde fois avec un nouvel ID auto-incrémenté. Tuez-le quelques fois et la table contient plusieurs copies de la plupart des produits, sans rien pour indiquer laquelle est actuelle.

Dédupliquer après coup est possible, et la résolution d’entités couvre ce sujet, mais cela traite le symptôme. Les doublons coûtent aussi de l’argent avant de coûter en précision : chaque page répétée est de la bande passante payée deux fois, ce qui alimente directement le coût par enregistrement propre.

Quatre décisions qui rendent un scraper idempotent

1. Dériver les ID des enregistrements à partir des données

L’ID d’un enregistrement doit provenir de ce qu’est l’enregistrement : la source plus sa clé naturelle, comme un SKU, un ID d’annonce ou une URL canonique. Hachez-les ensemble et le même produit obtient toujours le même ID, à chaque exécution, sur chaque machine. Le réécrire devient un upsert : si l’enregistrement existe et n’a pas changé, rien ne se passe ; s’il a changé, il est mis à jour et l’heure du changement enregistrée.

2. Garder la frontière durable

La liste des URL à visiter, et celles qui sont terminées, doit se trouver dans la base de données, pas en mémoire. Ajouter une URL déjà connue doit être une opération sans effet, de sorte que redécouvrir des liens sur une page de listing déjà traitée est inoffensif.

3. Valider le résultat et la progression ensemble

Le moment dangereux se situe entre le stockage d’un résultat et l’enregistrement du fait que l’URL est terminée. Si le processus meurt après l’un et avant l’autre, un redémarrage perd soit le résultat, soit le répète. Faire les deux dans une seule transaction de base de données supprime cet écart : soit l’enregistrement est stocké et l’URL marquée terminée, soit aucun des deux n’a eu lieu et l’URL est simplement récupérée à nouveau.

4. Louer le travail plutôt que le verrouiller

Un worker revendique une URL pour une durée limitée. S’il termine, l’URL est marquée terminée. S’il plante, le bail expire et un autre worker, ou celui redémarré, reprend l’URL. Rien n’a besoin de détecter le plantage pour que le travail soit récupéré.

Le code

Le module ci-dessous implémente les quatre points avec SQLite et requests. SQLite rend l’exemple autonome ; la même conception s’applique à PostgreSQL ou à toute base de données avec transactions et upserts.

import hashlib
import json
import re
import sqlite3
import time

import requests

SCHEMA = """
CREATE TABLE IF NOT EXISTS frontier (
    url TEXT PRIMARY KEY,
    status TEXT NOT NULL DEFAULT 'pending',      -- pending, leased, done
    leased_until REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS records (
    record_id TEXT PRIMARY KEY,                  -- derived from the source and its natural key
    url TEXT NOT NULL,
    content_hash TEXT NOT NULL,
    data TEXT NOT NULL,
    first_seen REAL NOT NULL,
    last_changed REAL NOT NULL
);
"""


def open_db(path):
    db = sqlite3.connect(path, isolation_level=None)  # explicit transactions below
    db.execute("PRAGMA journal_mode=WAL")
    db.executescript(SCHEMA)
    return db


def record_id(source, natural_key):
    """The same item always gets the same ID, however many times it is scraped."""
    return hashlib.sha256(f"{source}:{natural_key}".encode()).hexdigest()[:16]


def enqueue(db, urls):
    """Adding a URL that is already known is a no-op, so re-discovering links is harmless."""
    db.executemany("INSERT OR IGNORE INTO frontier (url) VALUES (?)", [(u,) for u in urls])


def lease(db, lease_seconds=60):
    """Claim one URL. A lease left behind by a crashed worker expires and the URL is retried."""
    now = time.time()
    row = db.execute(
        "UPDATE frontier SET status = 'leased', leased_until = ? WHERE url = ("
        "  SELECT url FROM frontier WHERE status = 'pending' OR (status = 'leased' AND leased_until < ?) LIMIT 1"
        ") RETURNING url", (now + lease_seconds, now)).fetchone()
    return row[0] if row else None


def complete(db, url, new_urls=(), record=None):
    """Write the result and mark the URL done in one transaction: either both happen or neither does."""
    db.execute("BEGIN IMMEDIATE")
    try:
        enqueue(db, new_urls)
        if record:
            now = time.time()
            payload = json.dumps(record["data"], sort_keys=True)
            digest = hashlib.sha256(payload.encode()).hexdigest()
            db.execute(
                "INSERT INTO records (record_id, url, content_hash, data, first_seen, last_changed) VALUES (?, ?, ?, ?, ?, ?) "
                "ON CONFLICT(record_id) DO UPDATE SET data = excluded.data, content_hash = excluded.content_hash, "
                "last_changed = excluded.last_changed WHERE records.content_hash != excluded.content_hash",
                (record["id"], url, digest, payload, now, now))
        db.execute("UPDATE frontier SET status = 'done' WHERE url = ?", (url,))
        db.execute("COMMIT")
    except Exception:
        db.execute("ROLLBACK")
        raise


def crawl(db, base, session=None, lease_seconds=60):
    session = session or requests.Session()
    enqueue(db, [f"{base}/list/0"])
    while True:
        url = lease(db, lease_seconds)
        if url is None:
            if db.execute("SELECT 1 FROM frontier WHERE status != 'done' LIMIT 1").fetchone():
                time.sleep(1)  # a lease is still held, perhaps by a worker that crashed; wait for it to expire
                continue
            return
        html = session.get(url, timeout=30).text
        if "/list/" in url:
            links = [base + href for href in re.findall(r'href="(/(?:product|list)/\d+)"', html)]
            complete(db, url, new_urls=links)
        else:
            sku = re.search(r'class="sku">([^<]+)<', html).group(1)
            price = re.search(r'class="price">([^<]+)<', html).group(1)
            complete(db, url, record={"id": record_id("example-shop", sku), "data": {"sku": sku, "price": price}})

La fonction crawl est spécifique à notre site de test, avec 25 pages de listing liant vers 500 pages produit. Tout ce qui précède est réutilisable. Deux détails sont faciles à manquer :

  • L’upsert n’écrit que lorsque le contenu a changé. La clause WHERE sur la mise à jour du conflit laisse un enregistrement inchangé tranquille, de sorte que last_changed signifie ce qu’il dit et peut alimenter la détection de changement.
  • La boucle ne s’arrête pas à une file vide. Notre première version se terminait lorsqu’aucune URL ne pouvait être louée, ce qui laissait une page inachevée chaque fois qu’un plantage avait laissé un bail en cours. La boucle attend désormais que chaque URL soit terminée.

Le tuer dix fois

Nous avons exécuté un site de test local avec 25 pages de listing et 500 pages produit, de sorte qu’un crawl complet nécessite exactement 525 requêtes. Chaque run démarrait le scraper, le tuait avec SIGKILL à un moment aléatoire entre 0,2 et 1 seconde, et répétait cela dix fois avant de le laisser terminer. Nous avons exécuté le scraper idempotent ci-dessus et le naïf décrit plus haut, cinq fois chacun, avec les mêmes moments de mise à mort, et un bail de 3 secondes pour la version idempotente.

RunNaïf : lignes stockéesNaïf : requêtesIdempotent : lignes stockéesIdempotent : requêtes
12 1652 283500525
21 8751 977500526
31 8621 967500526
41 4551 538500526
51 2861 361500528

Les deux versions ont fini par stocker les 500 produits, car chacune a terminé son dernier run. La différence se situe partout ailleurs. Le scraper naïf a stocké entre 2,6 et 4,3 lignes par produit et effectué entre 2,6 et 4,3 fois le nombre de requêtes nécessaires. L’idempotent a stocké exactement une ligne par produit, chaque valeur correspondant à la page source, et répété au maximum trois requêtes sur dix plantages : une pour chaque mise à mort tombée pendant qu’une page était en cours de traitement. Il a aussi terminé plus tôt à chaque run, entre 6,8 et 8,3 secondes contre 9,2 à 10,8, même s’il a parfois attendu l’expiration du bail d’un worker planté.

Au-delà de la base de données

Les enregistrements ne sont pas la seule chose qu’un scraper fait plus d’une fois. Le même principe s’applique à chaque effet de bord :

  • Fichiers. Nommez les images et documents téléchargés d’après un hash de leur contenu ou de l’ID de l’enregistrement, pour qu’un téléchargement répété écrase plutôt que d’ajouter une copie.
  • Messages et webhooks. Envoyez une clé d’idempotence dérivée de l’enregistrement et de sa version, afin qu’un consommateur puisse ignorer un message déjà traité.
  • Compteurs et agrégats. Recalculez-les à partir des enregistrements stockés plutôt que de les incrémenter à chaque récupération, sinon un redémarrage les gonfle.
  • Réponses brutes. Si vous archivez les réponses brutes, une récupération répétée ajoute une seconde capture, ce qui est inoffensif et même utile, tant que les enregistrements extraits restent idempotents.

Règles pratiques

  • Choisissez la clé naturelle avec soin. Elle doit être stable sur la source : un SKU ou un ID d’annonce, pas une position sur la page ou une URL portant des paramètres de suivi.
  • Rendez d’abord les relances sûres, puis fréquentes. Une fois les écritures idempotentes, la relance et le backoff peuvent être généreux sans polluer les données.
  • Dimensionnez les baux selon la page la plus lente. Un bail plus court qu’une récupération lente permet à deux workers de traiter la même URL ; inoffensif ici, mais c’est de la bande passante gaspillée.
  • Surveillez le taux de répétition. Les requêtes par enregistrement stocké constituent une métrique de santé peu coûteuse ; une hausse signale des plantages ou des problèmes de bail, et a sa place à côté des autres dans la surveillance d’un pipeline de scraping.

En résumé

Un scraper qui ne peut pas être redémarré en toute sécurité finira par corrompre ses propres données, et il le fera silencieusement. La solution n’est pas une exploitation plus prudente, mais une conception qui rend les redémarrages sans conséquence : des ID dérivés des données, une frontière durable, des résultats et une progression validés ensemble, et des baux qui expirent.

Dans notre test, ces quatre décisions ont transformé dix plantages, qui auraient pu donner jusqu’à 4,3 fois plus de lignes et de requêtes, en exactement les 500 enregistrements corrects attendus et trois requêtes répétées.

Sources et références

  • Documentation SQLite, UPSERT et RETURNING.
  • Test de mise à mort exécuté par Shifter le 2 octobre 2026 contre un site de test local, en utilisant le code ci-dessus.

Prêt à commencer ?

Essayez les proxies résidentiels de Shifter, 205M+ IPs, 195+ pays, à partir de 0,75 $/GB.

Commencer