

Token bucket par source, user-agent identifié, respect du robots.txt, dédoublonnage métier, snapshots horodatés et droit à l'oubli : 14 mois de scraping continu sans incident.
Scraper Pages Jaunes, Pappers, GoAfrica et Google Maps en respectant la loi et les serveurs, à raison de milliers de requêtes par jour. Retour d'expérience sur Prospect-Pro AI.
Prospect-Pro AI, plateforme de prospection B2B, doit constituer en continu une base de contacts qualifiés depuis quatre sources publiques : Pages Jaunes (annuaire pro français), Pappers (open data société française), GoAfrica (annuaire business panafricain), Google Maps (fiches d'établissements). L'enjeu : alimenter le pipeline IA avec des données fraîches sans tomber dans l'illégal ni saturer les sources.
Cet article n'est pas un tutoriel "comment contourner un anti-bot". C'est un retour d'expérience sur comment scraper proprement : conformité légale, respect technique, robustesse en production.
Premier réflexe : connaître le terrain juridique. Trois critères clarifient 95 % des cas :
Cas concrets :
Toujours préférer l'API officielle quand elle existe. Le scraping est un dernier recours, pas un raccourci.
Sur Prospect-Pro, chaque source est encapsulée dans un connecteur typé. Le squelette :
| 1 | // src/scraping/Connector.ts |
| 2 | export interface ScrapingConnector { |
| 3 | readonly source: SourceName; |
| 4 | readonly rateLimit: { rpm: number; concurrent: number }; |
| 5 | readonly robotsTxtPolicy: "respect" | "ignore"; // toujours "respect" en prod |
| 6 | search(query: SearchQuery): AsyncIterable<RawRecord>; |
| 7 | hydrate(record: RawRecord): Promise<EnrichedRecord>; |
| 8 | } |
Chaque connecteur déclare :
robots.txtSaturer un serveur est un comportement de pirate, pas de pro. Trois techniques combinées :
Chaque source a son propre bucket, dimensionné conservativement (typiquement 60 requêtes / minute).
| 1 | // src/scraping/RateLimiter.ts |
| 2 | export class TokenBucket { |
| 3 | private tokens: number; |
| 4 | private readonly capacity: number; |
| 5 | private readonly refillRate: number; // tokens / sec |
| 6 | private lastRefill: number; |
| 7 | |
| 8 | constructor(capacity: number, refillRate: number) { |
| 9 | this.capacity = capacity; |
| 10 | this.tokens = capacity; |
| 11 | this.refillRate = refillRate; |
| 12 | this.lastRefill = Date.now(); |
| 13 | } |
| 14 | |
| 15 | async acquire(): Promise<void> { |
| 16 | this.refill(); |
| 17 | if (this.tokens >= 1) { |
| 18 | this.tokens -= 1; |
| 19 | return; |
| 20 | } |
| 21 | const waitMs = ((1 - this.tokens) / this.refillRate) * 1000; |
| 22 | await sleep(waitMs); |
| 23 | return this.acquire(); |
| 24 | } |
| 25 | |
| 26 | private refill(): void { |
| 27 | const now = Date.now(); |
| 28 | const elapsed = (now - this.lastRefill) / 1000; |
| 29 | this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillRate); |
| 30 | this.lastRefill = now; |
| 31 | } |
| 32 | } |
Chaque connecteur appelle await bucket.acquire() avant chaque requête HTTP.
Pas plus de N requêtes simultanées par source. Le plus simple : utiliser une queue avec p-limit :
| 1 | import pLimit from "p-limit"; |
| 2 | |
| 3 | const limit = pLimit(3); // max 3 concurrent sur cette source |
| 4 | |
| 5 | const results = await Promise.all( |
| 6 | queries.map((q) => limit(() => connector.search(q))) |
| 7 | ); |
429 Too Many Requests ou 503 Service Unavailable : on attend, on retente avec un délai croissant.
| 1 | async function withRetry<T>(fn: () => Promise<T>, maxAttempts = 5): Promise<T> { |
| 2 | let lastError: unknown; |
| 3 | for (let attempt = 0; attempt < maxAttempts; attempt += 1) { |
| 4 | try { |
| 5 | return await fn(); |
| 6 | } catch (error) { |
| 7 | lastError = error; |
| 8 | const delay = Math.min(30_000, 1000 * 2 ** attempt) + Math.random() * 1000; |
| 9 | await sleep(delay); |
| 10 | } |
| 11 | } |
| 12 | throw lastError; |
| 13 | } |
Le + Math.random() (jitter) évite que tous les workers retentent en même temps après une panne.
Cacher son identité derrière un user-agent de Chrome standard est dangereux : juridiquement, ça suggère une intention de tromper. La bonne pratique :
| 1 | User-Agent: ProspectProBot/1.0 (+https://prospect-pro.example.com/bot) |
L'URL dans le user-agent pointe vers une page expliquant le projet, à qui s'adresser pour des questions, comment se faire désindexer. Les sources sérieuses respectent cette transparence. Les sources qui blockent un bot identifié signalent qu'elles refusent le scraping — et on s'abstient.
Avant chaque session, on charge le robots.txt de la source et on respecte ses Disallow.
| 1 | import robotsParser from "robots-parser"; |
| 2 | |
| 3 | async function loadRobots(host: string): Promise<robotsParser.Robot> { |
| 4 | const text = await fetch(`https://${host}/robots.txt`).then((r) => r.text()); |
| 5 | return robotsParser(`https://${host}/robots.txt`, text); |
| 6 | } |
| 7 | |
| 8 | async function shouldFetch(url: string, robots: robotsParser.Robot): Promise<boolean> { |
| 9 | return robots.isAllowed(url, "ProspectProBot"); |
| 10 | } |
Si robots.txt interdit, on n'y va pas. Point. Même si techniquement on pourrait.
Une source peut retourner le même contact deux fois sous des libellés légèrement différents. Sans dédoublonnage, votre base se pollue rapidement.
Stratégie : clé de dédoublonnage métier combinant les champs stables.
| 1 | function leadKey(lead: RawLead): string { |
| 2 | // Combinaison : SIREN si présent, sinon hash(nom_normalisé + ville) |
| 3 | if (lead.siren) return `siren:${lead.siren}`; |
| 4 | const normalized = lead.name.toLowerCase().replace(/[^a-z0-9]/g, ""); |
| 5 | return `hash:${sha256(`${normalized}|${lead.city}`)}`; |
| 6 | } |
En base, contrainte unique sur cette clé. INSERT ... ON CONFLICT DO UPDATE met à jour les champs récents sans dupliquer.
Les données scrapées vieillissent. Une entreprise déménage, change de numéro, ferme. Stocker une snapshot horodatée plutôt qu'un seul état :
| 1 | CREATE TABLE leads ( |
| 2 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), |
| 3 | key VARCHAR(120) NOT NULL UNIQUE, |
| 4 | name TEXT NOT NULL, |
| 5 | siren VARCHAR(20) NULL, |
| 6 | city TEXT NULL, |
| 7 | -- ... |
| 8 | first_seen TIMESTAMP NOT NULL DEFAULT NOW(), |
| 9 | last_seen TIMESTAMP NOT NULL DEFAULT NOW() |
| 10 | ); |
| 11 | |
| 12 | CREATE TABLE lead_snapshots ( |
| 13 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), |
| 14 | lead_id UUID NOT NULL REFERENCES leads(id), |
| 15 | source VARCHAR(40) NOT NULL, |
| 16 | payload JSONB NOT NULL, |
| 17 | scraped_at TIMESTAMP NOT NULL DEFAULT NOW() |
| 18 | ); |
leads contient l'état courant, lead_snapshots l'historique brut. Ça permet de retracer un contact, de comprendre une évolution, et de purger les snapshots anciens sans perdre les leads.
Si vous scrapez du B2C ou des données qui touchent à du B2B mais avec PII (nom + prénom d'un dirigeant par exemple), le RGPD s'applique. Trois exigences minimales :
| 1 | // src/controllers/rightToErasure.ts |
| 2 | export async function requestErasure(req: Request, res: Response) { |
| 3 | const email = req.body.email; // identifiant fourni par la personne |
| 4 | const found = await Lead.findOne({ where: { contactEmail: email } }); |
| 5 | if (!found) return res.status(404).json({ ok: false }); |
| 6 | |
| 7 | await found.update({ |
| 8 | redactedAt: new Date(), |
| 9 | name: "[REDACTED]", |
| 10 | contactEmail: null, |
| 11 | contactPhone: null, |
| 12 | }); |
| 13 | await LeadSnapshot.destroy({ where: { leadId: found.id } }); |
| 14 | |
| 15 | return res.status(200).json({ ok: true, redactedAt: found.redactedAt }); |
| 16 | } |
Les scrapers en prod doivent émettre des métriques sur :
Un dashboard Grafana sur ces signaux détecte tôt :
| Piège | Symptôme | Correction |
|---|---|---|
| User-agent menteur | Risque juridique, ban éthique | UA identifié + URL projet |
| Pas de rate-limit | Saturation source, ban IP | Token bucket par source |
| Concurrence illimitée | Trop de connexions, OOM | p-limit ou pool borné |
| Ignorer robots.txt | Illégal selon CGU + éthique | Parser et respecter |
| Pas de dédoublonnage | Base polluée | Clé métier + UPSERT |
| Snapshot unique | Pas d'historique | Table snapshots horodatée |
| Pas de droit à l'oubli | Risque RGPD | Endpoint d'effacement |
| Pas de monitor | Source change → silence | Dashboard sur taux et codes |
Scraper à grande échelle, c'est jouer un rôle d'utilisateur intensif sans pénaliser la source. Trois mots-clés : identification, modération, respect.
L'identification (user-agent honnête, page projet) protège juridiquement. La modération (rate-limit, concurrence, backoff) protège techniquement la source. Le respect (robots.txt, RGPD, droit à l'oubli) protège votre marque et vos utilisateurs.
Sur Prospect-Pro, ce framework a permis de scraper en continu pendant 14 mois sans incident juridique, sans ban, et avec une qualité de données stable. Pas de magie noire, juste de la discipline.
Si ce sujet ressemble à un problème réel dans votre produit, je peux intervenir sur le diagnostic, l'architecture, le backend, l'interface et les automatisations qui rendent une plateforme exploitable.
Réactions des lecteurs
Aucun commentaire pour l'instant
Soyez le premier à réagir à cet article.