Lorsqu'il faut télécharger des milliers, voire des dizaines de milliers de pages, le code synchrone se heurte à un mur : chaque requête attend sa réponse avant que la suivante ne démarre. Les threads aident, mais consomment de la mémoire et butent sur leurs coûts fixes. L'asynchrone résout le problème avec plus d'élégance : un seul thread maintient des milliers de connexions simultanées, en basculant de l'une à l'autre pendant qu'elles attendent le réseau.
Cet article est la suite avancée du guide généraliste « Le web scraping avec Python ». Les techniques de base (bibliothèques, encodages) y sont détaillées ; ici, nous voyons comment passer le scraping à l'échelle avec asyncio.
Sommaire
- Pourquoi l'async est plus rapide pour le scraping
- Récupérer une page : aiohttp
- Parser le contenu dans du code async
- Le cyrillique dans le scraping asynchrone
- Contrôler la concurrence : les sémaphores
- Proxys
- Scraping via TOR
- HTTPS/SSL
- Gestion des cookies
- Statut de réponse et en-têtes
- Files d'attente : asyncio.Queue
- httpx comme alternative
- Avantages et inconvénients
1. Pourquoi l'async est plus rapide pour le scraping
Le scraping est une tâche I/O-bound : 99 % du temps, le programme ne fait qu'attendre la réponse du serveur. En code synchrone, cette attente est du temps perdu. L'asynchrone permet, pendant qu'une requête attend, d'en lancer des centaines d'autres.
- Synchrone : 1000 pages à 0.5 s = ~500 secondes.
- Asynchrone (100 en parallèle) : les mêmes 1000 pages = ~5 secondes.
Contrairement aux threads, les coroutines ne coûtent presque rien en mémoire — des dizaines de milliers de tâches simultanées dans un seul thread sont tout à fait réalistes. Comparez avec l'approche multithread du guide central : l'async monte nettement plus haut en charge.
2. Récupérer une page : aiohttp
aiohttp est le client HTTP asynchrone de référence. Principe clé : un seul ClientSession pour tout le programme (il réutilise les connexions), et une multitude de requêtes simultanées via asyncio.gather.
import asyncio
import aiohttp
async def fetch(session, url):
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
return await resp.text()
async def main(urls):
async with aiohttp.ClientSession(headers={"User-Agent": "MyBot/1.0"}) as session:
tasks = [fetch(session, url) for url in urls]
pages = await asyncio.gather(*tasks, return_exceptions=True)
return pages
urls = [f"https://example.com/page/{i}" for i in range(1, 1001)]
results = asyncio.run(main(urls))
return_exceptions=True est important : une tâche qui échoue ne fait pas tomber tout le gather, mais est renvoyée comme objet d'exception que vous traiterez ensuite.
3. Parser le contenu dans du code async
Nuance importante : l'analyse du HTML elle-même (BeautifulSoup, lxml) est une opération CPU synchrone. Si le HTML est lourd, le parsing bloque l'event loop et annule le gain de l'async. Les pages légères peuvent être parsées directement dans la coroutine :
from bs4 import BeautifulSoup
async def fetch_and_parse(session, url):
async with session.get(url) as resp:
html = await resp.text()
soup = BeautifulSoup(html, "lxml") # ok pour les pages légères
return soup.find("h1").get_text(strip=True)
Si l'analyse est lourde, déportez-la dans un pool de processus pour ne pas bloquer la boucle :
import asyncio
from concurrent.futures import ProcessPoolExecutor
def heavy_parse(html):
soup = BeautifulSoup(html, "lxml")
return [a["href"] for a in soup.select("a")]
async def fetch_and_parse(session, url, pool):
async with session.get(url) as resp:
html = await resp.text()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(pool, heavy_parse, html)
Pour le détail des parseurs, voir le guide central et l'article dédié à lxml (l'option la plus rapide pour les charges async).
Souvent, ce ne sont pas des pages HTML que l'on interroge en asynchrone, mais des API : le corps de la réponse est alors déjà structuré, et un simple await resp.json() remplace le parseur. C'est à la fois plus rapide et plus fiable que d'analyser du balisage ; les techniques de traitement de ces réponses sont rassemblées dans « Parser du JSON en Python ».
4. Encodages et Unicode dans le scraping asynchrone
Lors d'un await resp.text(), aiohttp tente de déterminer l'encodage à partir des en-têtes. Sur les sites en cyrillique, cela échoue régulièrement. Les solutions sont les mêmes qu'en code synchrone :
# option 1 : encodage explicite
html = await resp.text(encoding="utf-8")
# option 2 : travailler sur les octets et les passer au parseur
raw = await resp.read()
soup = BeautifulSoup(raw, "lxml") # le parseur lira lui-même <meta charset>
# option 3 : décodage manuel
html = raw.decode("windows-1251", errors="replace")
La théorie complète du problème se trouve dans le guide central, section « Encodages ».
5. Contrôler la concurrence : les sémaphores
Lancer 10 000 requêtes d'un coup, c'est mettre à genoux le serveur cible comme votre propre réseau — et récolter un bannissement. On limite la concurrence avec un sémaphore :
import asyncio
import aiohttp
async def fetch(session, url, semaphore):
async with semaphore: # pas plus de N en même temps
async with session.get(url) as resp:
return await resp.text()
async def main(urls, concurrency=20):
semaphore = asyncio.Semaphore(concurrency)
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, url, semaphore) for url in urls]
return await asyncio.gather(*tasks, return_exceptions=True)
Semaphore(20) garantit au maximum 20 requêtes actives simultanément. C'est votre principal levier de « politesse » : choisissez la valeur de façon à ne pas surcharger le site cible. Ajoutez de petites pauses aléatoires (await asyncio.sleep(random.uniform(0.1, 0.5))) pour un comportement plus naturel.
6. Proxys
Dans aiohttp, le proxy se passe en paramètre de la requête :
async with session.get(url, proxy="http://user:pass@ip:port") as resp:
html = await resp.text()
La rotation consiste simplement à choisir un proxy au hasard pour chaque requête :
import random
PROXIES = ["http://ip1:port", "http://ip2:port", "http://ip3:port"]
async def fetch(session, url):
proxy = random.choice(PROXIES)
async with session.get(url, proxy=proxy) as resp:
return await resp.text()
La stratégie générale de gestion des proxys (types, élimination des proxys morts) est décrite dans le guide central, section « Proxys ».
7. Scraping via TOR
aiohttp ne gère pas SOCKS nativement — il faut le paquet aiohttp-socks :
# pip install aiohttp-socks
import aiohttp
from aiohttp_socks import ProxyConnector
async def main(urls):
connector = ProxyConnector.from_url("socks5://127.0.0.1:9050")
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get("https://httpbin.org/ip") as resp:
print(await resp.json()) # IP du nœud de sortie TOR
Le changement de nœud de sortie via le signal NEWNYM (bibliothèque stem) est décrit dans le guide central, section « TOR ». Attention : TOR est lent — à forte concurrence, il deviendra le goulot d'étranglement.
8. HTTPS/SSL
Par défaut, aiohttp vérifie les certificats. Pour désactiver la vérification (uniquement en débogage) ou fournir votre propre contexte :
import ssl
# désactivation de la vérification — PAS pour la production
async with session.get(url, ssl=False) as resp:
...
# contexte SSL personnalisé
ctx = ssl.create_default_context(cafile="/path/to/ca.crt")
async with session.get(url, ssl=ctx) as resp:
...
Les principes de sécurité des connexions sont détaillés dans le guide central, section « HTTPS/SSL ».
9. Gestion des cookies
ClientSession conserve automatiquement les cookies entre les requêtes — comme requests.Session :
async with aiohttp.ClientSession() as session:
# connexion — le serveur pose le cookie de session
await session.post("https://example.com/login",
data={"user": "u", "pass": "p"})
# les requêtes suivantes sont déjà authentifiées
async with session.get("https://example.com/profile") as resp:
html = await resp.text()
Vous pouvez passer des cookies manuellement via le paramètre cookies={...}. Plus de détails dans le guide central, section « Cookies ».
10. Statut de réponse et en-têtes
async with session.get(url) as resp:
print(resp.status) # 200, 404 ...
print(resp.headers.get("Content-Type"))
if resp.status == 429:
wait = int(resp.headers.get("Retry-After", 60))
await asyncio.sleep(wait) # ne bloque pas les autres tâches !
resp.raise_for_status()
Avantage décisif : lors du traitement d'un 429, await asyncio.sleep() n'endort que cette coroutine, les autres continuent de travailler. En code synchrone, time.sleep() aurait tout figé. La logique des codes de statut est expliquée dans le guide central.
11. Files d'attente : asyncio.Queue
Pour un crawling « au fil de la découverte des liens », on utilise asyncio.Queue et un pool de workers-coroutines :
import asyncio
import aiohttp
async def worker(name, queue, session, visited):
while True:
url = await queue.get()
if url not in visited:
visited.add(url)
try:
async with session.get(url) as resp:
html = await resp.text()
# ... trouver de nouveaux liens et les mettre dans la file :
# for link in extract_links(html):
# await queue.put(link)
except Exception as exc:
print(f"{name} erreur {url}: {exc}")
queue.task_done()
async def crawl(start_urls, num_workers=10):
queue = asyncio.Queue()
visited = set()
for url in start_urls:
queue.put_nowait(url)
async with aiohttp.ClientSession() as session:
workers = [asyncio.create_task(worker(f"w{i}", queue, session, visited))
for i in range(num_workers)]
await queue.join() # on attend que la file se vide
for w in workers:
w.cancel()
Un set pour la déduplication, une Queue pour coordonner les workers — l'équivalent asynchrone du frontier décrit dans le guide central. Pour un crawl distribué, on externalise la file dans Redis. L'implémentation industrielle de ce schéma est fournie par Scrapy (lui aussi asynchrone en interne).
12. httpx comme alternative
httpx est un client moderne avec une API identique en synchrone et en asynchrone, et la prise en charge de HTTP/2 :
import httpx
import asyncio
async def main(urls):
async with httpx.AsyncClient(http2=True, timeout=15) as client:
tasks = [client.get(url) for url in urls]
responses = await asyncio.gather(*tasks, return_exceptions=True)
return responses
Si vous voulez basculer le même code entre sync et async, ou s'il vous faut HTTP/2, httpx est plus pratique qu'aiohttp. En vitesse pure sur de gros volumes, les deux se valent.
13. Avantages et inconvénients du scraping asynchrone
Avantages :
- Concurrence massive pour une empreinte mémoire minimale.
- Accélération d'un ordre de grandeur sur les tâches I/O-bound.
- Des « pauses » bon marché :
asyncio.sleepne bloque pas les autres tâches. - Contrôle fin du débit grâce aux sémaphores.
Inconvénients :
- Plus difficile à écrire et à déboguer (
async/awaitpartout). - Le parsing CPU-bound bloque quand même la boucle — il faut un pool de processus.
- Impossible de mélanger avec des bibliothèques bloquantes sans
run_in_executor. - Facile de surcharger le site cible — la discipline des sémaphores est indispensable.
Quand le choisir : à partir de plusieurs milliers de pages, quand la vitesse compte. Pour quelques centaines de pages, requests + ThreadPoolExecutor est plus simple. Pour un crawling complet d'un site, préférez Scrapy, où l'asynchrone et les files d'attente sont déjà intégrés. Et si votre scraper vit à l'intérieur d'une application web, gardez à l'esprit que l'ORM de Django reste majoritairement synchrone et que le code async y demande des précautions (sync_to_async) — voir « Le scraping avec Django ».