Les sites modernes livrent rarement un HTML déjà prêt avec les données. Catalogue de produits, fil d'actualité, prix, avis — tout cela est le plus souvent chargé après le rendu de la page, via JavaScript. Pour un scraper, cela signifie qu'un simple GET de la page et l'analyse du HTML renverront une coquille vide, sans données.
Il existe deux manières fondamentalement différentes de résoudre ce problème :
- Interception des requêtes API — repérer les requêtes que le navigateur utilise lui-même pour récupérer les données, puis les rejouer directement, sans navigateur.
- Émulation complète d'un navigateur — lancer un vrai navigateur (ou en mode headless), lui laisser exécuter le JS et interagir avec la page comme un utilisateur : cliquer, faire défiler, déplacer des éléments.
La première méthode est plus rapide et moins gourmande en ressources ; la seconde est plus universelle et plus robuste face à une logique non standard. En pratique, on les combine souvent.
Approche 1. Interception et émulation des requêtes API
L'idée
Lorsqu'une page « complète » ses données, elle effectue presque toujours des requêtes HTTP en arrière-plan (XHR/fetch) vers une API interne qui renvoie du JSON. Si vous trouvez cet endpoint et reproduisez la requête avec les bons en-têtes, cookies et jetons, vous pouvez récupérer les données directement, sans passer par le rendu. C'est des dizaines de fois plus rapide et cela ne nécessite aucun navigateur.
Comment trouver l'API
- Ouvrez les DevTools (
F12) → onglet Network. - Filtrez par Fetch/XHR.
- Faites défiler la page, cliquez sur « Afficher plus », changez de catégorie — bref, provoquez le chargement des données.
- Repérez la requête dont la réponse contient le JSON recherché (produits, prix, etc.).
- Examinez-la : URL, méthode, paramètres de requête, en-têtes, corps, cookies.
- Clic droit → Copy → Copy as cURL — un excellent point de départ : vous pouvez l'importer dans Postman ou le convertir directement en code.
Les jetons : CSRF, sessions, authentification
La principale difficulté de cette approche : la requête n'est presque jamais « nue ». Le serveur attend un ensemble de données de validation, et sans elles il renverra une erreur 401, 403 ou 419.
Jeton CSRF (Cross-Site Request Forgery). Protection contre la falsification de requêtes intersites. Le serveur émet un jeton aléatoire que le client doit renvoyer lors des requêtes modifiantes (et parfois même en lecture). Où on le trouve habituellement :
- dans une balise
<meta name="csrf-token" content="...">du HTML de la page ; - dans un champ de formulaire caché
<input type="hidden" name="_token" value="...">; - dans un cookie (souvent
XSRF-TOKEN) qu'il faut ensuite recopier dans l'en-têteX-CSRF-TokenouX-XSRF-TOKEN.
Principe de fonctionnement : on charge d'abord la page classique, on en extrait le jeton et les cookies de session, puis on les injecte dans la requête API.
Cookies de session. Après la première visite, le serveur pose un Set-Cookie (par exemple sessionid, PHPSESSID, laravel_session). Il faut les conserver entre les requêtes — on utilise pour cela un objet-session (requests.Session, httpx.Client) qui s'en charge automatiquement.
Authentification (Bearer / JWT / clé API). Si les données sont derrière une connexion, l'en-tête contient généralement Authorization: Bearer <token>. Les jetons JWT s'obtiennent via l'endpoint de connexion, puis sont joints à chaque requête.
Autres champs de protection. X-Requested-With: XMLHttpRequest (souvent obligatoire pour les endpoints AJAX), Referer, Origin, et parfois des paramètres signés (signature, nonce, timestamp) générés par le JS du front-end.
Quand l'approche échoue. Si le jeton ou la signature de la requête sont générés par du JS obfusqué directement dans le navigateur (ou en WASM), les reproduire côté serveur est extrêmement difficile. C'est le signal qu'il vaut mieux passer à la seconde approche — l'émulation du navigateur, où le JS s'exécute de lui-même.
Exemple : Python + requests (avec CSRF et pagination)
import re
import requests
session = requests.Session()
BASE = "https://example-shop.com"
# 1. On charge la page pour récupérer le jeton CSRF et les cookies de session
resp = session.get(f"{BASE}/catalog", headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
})
# Le CSRF peut se trouver dans une balise meta...
m = re.search(r'name="csrf-token"\s+content="([^"]+)"', resp.text)
csrf = m.group(1) if m else session.cookies.get("XSRF-TOKEN")
headers = {
"X-CSRF-Token": csrf,
"X-Requested-With": "XMLHttpRequest",
"Referer": f"{BASE}/catalog",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
"Accept": "application/json",
}
# 2. On interroge l'API interne page par page
all_items = []
page = 1
while True:
r = session.get(
f"{BASE}/api/products",
params={"category": "phones", "page": page, "per_page": 48},
headers=headers,
)
r.raise_for_status()
payload = r.json()
items = payload.get("items", [])
if not items:
break
all_items.extend(items)
page += 1
for it in all_items:
print(it["title"], it["price"])
Exemple : Python + httpx (async, plus rapide sur de gros volumes)
import asyncio
import httpx
async def fetch_page(client, page):
r = await client.get("/api/products", params={"page": page, "per_page": 48})
return r.json().get("items", [])
async def main():
async with httpx.AsyncClient(base_url="https://example-shop.com",
headers={"X-Requested-With": "XMLHttpRequest"}) as client:
tasks = [fetch_page(client, p) for p in range(1, 11)]
results = await asyncio.gather(*tasks)
items = [x for chunk in results for x in chunk]
print(len(items))
asyncio.run(main())
Exemple : Node.js + fetch
const csrf = "..."; // extrait au préalable du HTML/des cookies
const res = await fetch("https://example-shop.com/api/products?page=1&per_page=48", {
headers: {
"X-CSRF-Token": csrf,
"X-Requested-With": "XMLHttpRequest",
"Accept": "application/json",
"Cookie": "sessionid=abc123; XSRF-TOKEN=" + csrf,
},
});
const data = await res.json();
data.items.forEach(item => console.log(item.title, item.price));
Approche 2. Émulation complète d'un navigateur
L'idée
On lance un vrai moteur (Chromium, Firefox, WebKit), il télécharge la page, exécute tout le JS et rend le DOM. On interagit ensuite avec la page comme un humain : on attend l'apparition des éléments, on clique, on fait défiler, on déplace des curseurs. Tous les jetons, signatures et scripts anti-bot s'exécutent d'eux-mêmes — pas besoin de les reproduire.
Inconvénients : c'est bien plus lent, gourmand en CPU/RAM, et plus facilement détecté par les systèmes anti-bot (mais on combat cela avec des modes « stealth » dédiés).
Avec quoi émuler
- Selenium — le standard le plus ancien, prend en charge Python, Java, C#, JavaScript, Ruby. Pilote de vrais navigateurs via WebDriver.
- Playwright — framework moderne de Microsoft. Python, JavaScript/TS, .NET, Java. Chromium, Firefox, WebKit « clés en main », attente automatique intelligente des éléments, interception pratique des requêtes réseau.
- Puppeteer — Node.js, à l'origine uniquement Chromium (support Firefox expérimental). Très rapide et mature pour Chrome.
Actions sur la page
Voici ci-dessous les mêmes quatre actions (clic, défilement, défilement jusqu'à un élément, maintien du bouton de la souris + déplacement) sur différentes stacks. Le maintien + déplacement est la base du glisser-déposer, des curseurs (sliders) et des captchas à glissière.
Playwright (Python)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto("https://example-shop.com/catalog")
# CLIC
page.click("button.load-more")
# DÉFILEMENT à la molette
page.mouse.wheel(0, 2000)
# DÉFILEMENT jusqu'à un élément précis
page.locator("footer").scroll_into_view_if_needed()
# MAINTIEN du bouton + DÉPLACEMENT (drag / slider)
box = page.locator(".slider-handle").bounding_box()
start_x = box["x"] + box["width"] / 2
start_y = box["y"] + box["height"] / 2
page.mouse.move(start_x, start_y)
page.mouse.down() # on maintient
page.mouse.move(start_x + 200, start_y, steps=25) # on déplace en douceur (25 pas)
page.mouse.up() # on relâche
browser.close()
Playwright (JavaScript/Node)
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example-shop.com/catalog');
// CLIC
await page.click('button.load-more');
// DÉFILEMENT
await page.mouse.wheel(0, 2000);
// DÉFILEMENT jusqu'à un élément
await page.locator('footer').scrollIntoViewIfNeeded();
// MAINTIEN + DÉPLACEMENT
const box = await page.locator('.slider-handle').boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + 200, box.y, { steps: 25 });
await page.mouse.up();
await browser.close();
})();
Selenium (Python) — via ActionChains
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
driver = webdriver.Chrome()
driver.get("https://example-shop.com/catalog")
wait = WebDriverWait(driver, 10)
# CLIC (avec attente de la cliquabilité)
btn = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button.load-more")))
btn.click()
# DÉFILEMENT
driver.execute_script("window.scrollBy(0, 2000)")
# DÉFILEMENT jusqu'à un élément
footer = driver.find_element(By.CSS_SELECTOR, "footer")
driver.execute_script("arguments[0].scrollIntoView({block:'center'})", footer)
# MAINTIEN + DÉPLACEMENT
handle = driver.find_element(By.CSS_SELECTOR, ".slider-handle")
(ActionChains(driver)
.click_and_hold(handle) # on maintient
.move_by_offset(200, 0) # on déplace de 200px vers la droite
.pause(0.3)
.release() # on relâche
.perform())
driver.quit()
Selenium (Java)
WebDriver driver = new ChromeDriver();
driver.get("https://example-shop.com/catalog");
// CLIC
driver.findElement(By.cssSelector("button.load-more")).click();
// DÉFILEMENT
((JavascriptExecutor) driver).executeScript("window.scrollBy(0, 2000)");
// MAINTIEN + DÉPLACEMENT
WebElement handle = driver.findElement(By.cssSelector(".slider-handle"));
new Actions(driver)
.clickAndHold(handle)
.moveByOffset(200, 0)
.release()
.perform();
Puppeteer (Node.js)
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
await page.goto('https://example-shop.com/catalog');
// CLIC
await page.click('button.load-more');
// DÉFILEMENT
await page.evaluate(() => window.scrollBy(0, 2000));
// DÉFILEMENT jusqu'à un élément
await page.$eval('footer', el => el.scrollIntoView());
// MAINTIEN + DÉPLACEMENT
const handle = await page.$('.slider-handle');
const box = await handle.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + 200, box.y, { steps: 25 });
await page.mouse.up();
await browser.close();
})();
Modes furtifs (« stealth ») : contourner la détection d'automatisation
Un navigateur headless lancé « tel quel » est facile à distinguer d'un vrai. Les systèmes anti-bot (Cloudflare, DataDome, PerimeterX/HUMAN, Akamai, etc.) vérifient des dizaines de signaux, et si ne serait-ce qu'une partie trahit l'automatisation, on obtient un captcha, un challenge ou un blocage. Le mode stealth est un ensemble de correctifs et d'astuces qui masquent ces indices.
Par quels indices on est repéré
navigator.webdriver === true— le drapeau le plus évident, positionné automatiquement par un navigateur piloté via WebDriver/CDP.- Artefacts headless. Absence de
window.chrome, liste de plugins vide (navigator.plugins),navigator.languagesnon standard, rendu WebGL de typeSwiftShader/Google Inc.au lieu d'une vraie carte graphique. - Fingerprinting. Canvas, WebGL, AudioContext et le jeu de polices donnent une « empreinte » stable de l'environnement ; celle d'un headless par défaut est étonnamment typique.
- Empreinte TLS/JA3. Au niveau même de la connexion HTTP, la « poignée de main » d'un client Python ou Node diffère de celle de Chrome — cela se détecte avant même l'exécution du JS (valable aussi pour l'approche 1).
- Comportement. Clics instantanés sans mouvement de souris, timing parfaitement régulier, accès direct à une page interne sans navigation — tout cela n'a rien d'humain.
- Réputation de l'IP. Les plages de datacenters (AWS, Hetzner, etc.) sont marquées ; les adresses résidentielles (residential) et mobiles éveillent moins de soupçons.
Outils prêts à l'emploi
puppeteer-extra+puppeteer-extra-plugin-stealth(Node) — l'ensemble le plus connu, masquenavigator.webdriver, corrige WebGL/plugins/languages et des dizaines d'autres « fuites ».playwright-extraavec le même plugin stealth — l'équivalent pour Playwright sous Node.undetected-chromedriver(Python, par-dessus Selenium) — un ChromeDriver patché qui passe de nombreux contrôles Cloudflare. Son évolution estnodriver(sans protocole webdriver du tout, uniquement via CDP).SeleniumBaseen mode UC (--uc) — une surcouche de Selenium avec anti-détection intégré.rebrowser-patches— des correctifs bas niveau du runtime de Puppeteer/Playwright, colmatant des fuites CDP plus subtiles.
Important : aucun plugin stealth ne garantit quoi que ce soit. Les systèmes anti-bot se mettent à jour en permanence, et ce qui passait hier peut être détecté demain. C'est une « course à l'armement », pas un réglage ponctuel.
Exemples
Python — undetected-chromedriver :
import undetected_chromedriver as uc
options = uc.ChromeOptions()
options.add_argument("--lang=fr-FR")
# pour l'anti-détection, on ne met généralement PAS headless, ou on utilise le nouveau mode :
# options.add_argument("--headless=new")
driver = uc.Chrome(options=options)
driver.get("https://example-shop.com/catalog")
print(driver.title)
driver.quit()
Node — puppeteer-extra + stealth :
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
(async () => {
const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();
// UA crédible et en-têtes cohérents
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
);
await page.goto('https://example-shop.com/catalog');
await browser.close();
})();
Node — playwright-extra + stealth :
const { chromium } = require('playwright-extra');
const stealth = require('puppeteer-extra-plugin-stealth')();
chromium.use(stealth);
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto('https://example-shop.com/catalog');
await browser.close();
})();
Astuces manuelles (en complément ou à la place des plugins)
- Masquer
webdriveret corriger l'environnement. Via CDP/init script, avant le chargement de la page :
# Playwright (Python) : exécuté dans chaque nouveau document AVANT les scripts du site
page.add_init_script(
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined});"
)
- Nouveau mode headless. Dans les versions récentes de Chrome, le drapeau
--headless=newest plus proche d'un navigateur classique que l'ancien headless ; il est parfois plus avantageux de lancer carrément en mode non-headless sous un affichage virtuel (Xvfb). - Profil persistant. Le lancement avec
user_data_dirconserve les cookies et la « chaleur » de la session entre les exécutions — cela ressemble moins à un bot fraîchement démarré. - Comportement humain. Pauses aléatoires, mouvement de souris le long d'une courbe (Bézier), défilement par à-coups plutôt qu'en un seul saut. Pour générer des trajectoires, il existe des bibliothèques comme
pyautogui/bezierou le paramètrestepsintégré demouse.move. - Proxys. Les proxys résidentiels et mobiles avec rotation réduisent nettement la part de challenges par rapport aux IP de datacenter.
La furtivité pour l'approche 1 (sans navigateur)
L'interception d'API est elle aussi détectable — par l'empreinte TLS du client HTTP. Pour que la requête paraisse, dès la « poignée de main », provenir d'un vrai Chrome, on utilise des clients qui falsifient l'empreinte TLS :
# curl_cffi sait imiter le TLS/JA3 d'un navigateur précis
from curl_cffi import requests
r = requests.get(
"https://example-shop.com/api/products?page=1",
impersonate="chrome124", # on falsifie la poignée de main de Chrome 124
)
print(r.json())
Équivalents : tls-client (Python/Go), curl-impersonate (binaire système). Cela résout souvent le problème lorsqu'un requests « nu » reçoit un 403 alors que la même page s'ouvre sans souci dans le navigateur.
Quelle bibliothèque fait quoi
La ligne de partage essentielle : l'outil exécute-t-il le JavaScript et sait-il imiter les actions de la souris ? Les clients HTTP et les parseurs HTML ne font ni l'un ni l'autre — ils ne conviennent qu'au contenu statique ou à la seconde approche (émulation de l'API).
| Bibliothèque | Langage | Rendu JS | Actions (clic/défilement/drag) | Usage |
|---|---|---|---|---|
| requests / httpx | Python | Non | Non | Client HTTP |
| aiohttp | Python | Non | Non | HTTP async |
| BeautifulSoup / lxml | Python | Non | Non | Analyse HTML |
| Scrapy | Python | Non (nécessite le plugin Splash/Playwright) | Non | Framework de crawling |
| Selenium | Python/Java/C#/JS/Ruby | Oui | Oui | Pilotage de navigateur |
| Playwright | Python/JS/.NET/Java | Oui | Oui | Pilotage de navigateur |
| Puppeteer | Node.js | Oui (Chromium) | Oui | Pilotage de navigateur |
| Cypress | JS | Oui | Oui (mais conçu pour les tests e2e) | Tests |
| axios / fetch / got | Node.js | Non | Non | Client HTTP |
| cheerio | Node.js | Non | Non | Analyse HTML (style jQuery) |
| Colly | Go | Non | Non | Framework de crawling |
| chromedp / rod | Go | Oui | Oui | Pilotage de navigateur |
| HtmlUnit | Java | Partiel/instable | Limité | Navigateur headless |
| jsoup | Java | Non | Non | Analyse HTML |
En résumé :
- Il suffit de rejouer une requête API →
requests/httpx(Python),fetch/got(Node),Colly(Go). - Analyser un HTML déjà récupéré →
BeautifulSoup/lxml,cheerio,jsoup. - Besoin de rendu JS et d'actions à la souris →
Playwright,Selenium,Puppeteer,chromedp/rod. - HtmlUnit gère un peu de JS, mais bute souvent sur les SPA modernes — pour un rendu sérieux, on choisit Playwright/Selenium.
Cas pratique : veille tarifaire des boutiques en ligne et défilement infini
C'est sans doute la tâche pratique la plus courante. Dans les catalogues, les produits ne s'affichent généralement pas tous d'un coup : on applique un lazy loading / infinite scroll — de nouvelles fiches se chargent au fur et à mesure du défilement (ou en cliquant sur « Afficher plus »). Une simple requête HTML ne renverra que la première « fournée ».
Il y a deux voies, toutes deux largement utilisées dans la veille tarifaire et la surveillance de l'assortiment :
Voie A (à privilégier) : intercepter l'API de pagination
Lors du défilement, la boutique appelle presque toujours quelque chose comme /api/catalog?page=2&offset=48. Si c'est le cas, oubliez le navigateur et collectez les données page par page directement (voir approche 1). C'est rapide, stable et cela s'étend à des milliers de produits. C'est ainsi que sont construites la plupart des veilles industrielles : le navigateur ne sert qu'une fois — pour reconnaître la structure de l'API et les jetons — tandis que la collecte elle-même passe par un client HTTP.
Voie B : rendu + défilement, quand l'API est fermée
Si l'endpoint est protégé par une signature non triviale ou si les données sont générées exclusivement côté client, il ne reste plus qu'à faire défiler avec le navigateur et à collecter les fiches depuis le DOM. Algorithme : on fait défiler vers le bas → on attend le chargement → on compte les fiches → on répète tant que le nombre augmente.
from playwright.sync_api import sync_playwright
def scrape_catalog(url):
products = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url)
prev_count = -1
stable_rounds = 0
while stable_rounds < 2: # 2 défilements « vides » d'affilée = on a atteint la fin
# on fait défiler tout en bas
page.mouse.wheel(0, 4000)
page.wait_for_timeout(1500) # on laisse le temps de charger
cards = page.locator(".product-card")
count = cards.count()
if count == prev_count:
stable_rounds += 1
else:
stable_rounds = 0
prev_count = count
# on collecte toutes les fiches après le défilement complet
cards = page.locator(".product-card")
for i in range(cards.count()):
card = cards.nth(i)
products.append({
"title": card.locator(".title").inner_text(),
"price": card.locator(".price").inner_text(),
"url": card.locator("a").get_attribute("href"),
})
browser.close()
return products
data = scrape_catalog("https://example-shop.com/catalog/phones")
print(f"Produits collectés : {len(data)}")
Le même procédé pour un bouton « Afficher plus » — on clique tant que le bouton existe :
while page.locator("button.load-more").count() > 0:
page.click("button.load-more")
page.wait_for_timeout(1200)
Hybride : le meilleur des deux mondes
Playwright et Puppeteer savent écouter le trafic réseau. On peut ouvrir la page dans le navigateur (pour que tous les jetons et contrôles anti-bot passent), mais récupérer les données non pas depuis le DOM, mais depuis les réponses de cette même API que le navigateur appelle lors du défilement :
def handle_response(response):
if "/api/products" in response.url:
data = response.json()
# on enregistre le JSON prêt à l'emploi — pas besoin d'analyser le HTML
save(data["items"])
page.on("response", handle_response)
page.goto("https://example-shop.com/catalog")
# ensuite on fait simplement défiler — les données « arrivent » d'elles-mêmes dans le gestionnaire
C'est souvent la solution optimale pour la veille : la robustesse de l'émulation de navigateur + un JSON propre et structuré au lieu d'une fragile analyse de la mise en page.
Conseils pratiques pour la veille
- Attendez les données, pas le temps. Au lieu de « dormir 1,5 seconde », utilisez l'attente de l'apparition d'un élément (
wait_for_selector) ou d'une accalmie réseau (wait_for_load_state("networkidle")) — plus fiable et souvent plus rapide. - Déduplication. Avec le défilement infini, certaines fiches peuvent être lues plusieurs fois — collectez par
id/urlunique. - Limitez la fréquence. Des requêtes trop agressives surchargent le site et mènent vite au blocage. Ajoutez des délais, utilisez des pools de proxys avec prudence et dans le cadre légal.
- Mettez la reconnaissance en cache. La structure de l'API et les jetons ne s'établissent qu'une fois ; en production, lancez une collecte HTTP légère et gardez le navigateur lourd en réserve.
Comment choisir l'approche
| Critère | Interception d'API | Émulation de navigateur |
|---|---|---|
| Vitesse | Très élevée | Faible |
| Consommation de ressources | Minimale | Élevée (CPU/RAM) |
| Complexité de mise en place | Plus élevée (reverse des jetons) | Plus faible (tout « comme un utilisateur ») |
| Robustesse aux changements de mise en page | Élevée (dépend de l'API) | Moyenne (dépend des sélecteurs) |
| Contournement des jetons/signatures côté client | Difficile | Automatique |
| Passage à l'échelle en volume | Excellent | Limité |
Règle pratique : vérifiez toujours d'abord si l'interception d'API suffit — c'est plus rapide, moins coûteux et plus stable. Ne passez à l'émulation de navigateur que lorsque l'API est cachée derrière de la cryptographie côté client, protégée par une logique anti-bot complexe, ou lorsqu'il faut reproduire une interaction non triviale (glisser-déposer, curseurs, formulaires par étapes).