

Per-source token bucket, identified user-agent, robots.txt respect, business dedup keys, timestamped snapshots and right to erasure: 14 months of continuous scraping without incident.
Scraping Pages Jaunes, Pappers, GoAfrica and Google Maps lawfully and respectfully, at thousands of requests per day. Field report from Prospect-Pro AI.
Prospect-Pro AI, a B2B prospecting platform, must continuously build a qualified contact base from four public sources: Pages Jaunes (French professional directory), Pappers (French company open data), GoAfrica (pan-African business directory), Google Maps (establishment listings). The challenge: feed the AI pipeline with fresh data without crossing into illegality nor saturating sources.
This article isn't a "how to bypass an anti-bot" tutorial. It's a field report on how to scrape properly: legal compliance, technical respect, production robustness.
First reflex: know the legal terrain. Three criteria clarify 95% of cases:
Concrete cases:
Always prefer the official API when it exists. Scraping is a last resort, not a shortcut.
On Prospect-Pro, each source is wrapped in a typed connector. Skeleton:
| 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"; // always "respect" in prod |
| 6 | search(query: SearchQuery): AsyncIterable<RawRecord>; |
| 7 | hydrate(record: RawRecord): Promise<EnrichedRecord>; |
| 8 | } |
Each connector declares:
robots.txt policySaturating a server is pirate behavior, not pro behavior. Three combined techniques:
Each source has its own bucket, sized conservatively (typically 60 req/min).
| 1 | // src/scraping/RateLimiter.ts |
| 2 | export class TokenBucket { |
| 3 | private tokens: number; |
| 4 | private readonly capacity: number; |
| 5 | private readonly refillRate: number; |
| 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 | } |
Each connector calls await bucket.acquire() before each HTTP request.
No more than N concurrent requests per source. Simplest: a queue with p-limit:
| 1 | import pLimit from "p-limit"; |
| 2 | |
| 3 | const limit = pLimit(3); |
| 4 | const results = await Promise.all( |
| 5 | queries.map((q) => limit(() => connector.search(q))) |
| 6 | ); |
429 Too Many Requests or 503 Service Unavailable: wait, retry with growing delay.
| 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 | } |
The + Math.random() (jitter) prevents all workers from retrying at the same time after an outage.
Hiding behind a standard Chrome user-agent is dangerous: legally, it suggests intent to deceive. Best practice:
| 1 | User-Agent: ProspectProBot/1.0 (+https://prospect-pro.example.com/bot) |
The URL points to a page explaining the project, contact for questions, how to opt out. Serious sources respect this transparency. Sources that block an identified bot signal they refuse scraping — and we abstain.
Before each session, load the source's robots.txt and respect its 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 | } |
If robots.txt forbids it, we don't go. Period. Even if technically we could.
A source may return the same contact twice under slightly different labels. Without dedup, your base pollutes fast.
Strategy: business dedup key combining stable fields.
| 1 | function leadKey(lead: RawLead): string { |
| 2 | if (lead.registrationNumber) return `reg:${lead.registrationNumber}`; |
| 3 | const normalized = lead.name.toLowerCase().replace(/[^a-z0-9]/g, ""); |
| 4 | return `hash:${sha256(`${normalized}|${lead.city}`)}`; |
| 5 | } |
In DB, unique constraint on this key. INSERT ... ON CONFLICT DO UPDATE updates recent fields without duplication.
Scraped data ages. A company moves, changes phone, closes. Store timestamped snapshots rather than a single state:
| 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 | registration_number VARCHAR(20) NULL, |
| 6 | city TEXT NULL, |
| 7 | first_seen TIMESTAMP NOT NULL DEFAULT NOW(), |
| 8 | last_seen TIMESTAMP NOT NULL DEFAULT NOW() |
| 9 | ); |
| 10 | |
| 11 | CREATE TABLE lead_snapshots ( |
| 12 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), |
| 13 | lead_id UUID NOT NULL REFERENCES leads(id), |
| 14 | source VARCHAR(40) NOT NULL, |
| 15 | payload JSONB NOT NULL, |
| 16 | scraped_at TIMESTAMP NOT NULL DEFAULT NOW() |
| 17 | ); |
leads holds the current state, lead_snapshots the raw history. Lets you trace a contact, understand evolution, and purge old snapshots without losing leads.
If you scrape B2C or B2B data touching PII (a director's first + last name for example), GDPR applies. Three minimum requirements:
| 1 | // src/controllers/rightToErasure.ts |
| 2 | export async function requestErasure(req: Request, res: Response) { |
| 3 | const email = req.body.email; |
| 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 | } |
Production scrapers must emit metrics on:
A Grafana dashboard on these signals catches early:
| Pitfall | Symptom | Fix |
|---|---|---|
| Lying user-agent | Legal risk, ethical ban | Identified UA + project URL |
| No rate-limit | Source saturation, IP ban | Per-source token bucket |
| Unlimited concurrency | Too many connections, OOM | p-limit or bounded pool |
| Ignoring robots.txt | Illegal per ToS + unethical | Parse and respect |
| No dedup | Polluted base | Business key + UPSERT |
| Single snapshot | No history | Timestamped snapshots table |
| No right to erasure | GDPR risk | Erasure endpoint |
| No monitor | Source changes → silence | Dashboard on rates and codes |
Scraping at scale means playing the role of an intensive user without penalizing the source. Three keywords: identification, moderation, respect.
Identification (honest user-agent, project page) protects legally. Moderation (rate-limit, concurrency, backoff) protects the source technically. Respect (robots.txt, GDPR, right to erasure) protects your brand and your users.
On Prospect-Pro, this framework allowed continuous scraping for 14 months with no legal incident, no ban, and stable data quality. No black magic, just discipline.
If this topic feels close to a real product problem, I can help on diagnosis, architecture, backend, interface and automations that make a platform usable in production.
Reader reactions
No comment yet
Be the first to share your reaction.