Retour au blog
Guides
Sorin-Gabriel MaricaLast updated on May 12, 202620 min read

Tutoriel BeautifulSoup : Construire un vrai scraper Python à partir de zéro

Tutoriel BeautifulSoup : Construire un vrai scraper Python à partir de zéro
En bref : ce tutoriel sur BeautifulSoup vous guide pas à pas dans la création d'un scraper Python complet, depuis pip install à un script robuste qui pagine Hacker News, exporte vers CSV et JSON, et reste suffisamment « poli » pour ne pas se faire bloquer. Chaque extrait de code est exécutable, et nous signalons les cas précis où BeautifulSoup n'est pas l'outil adéquat.

Si vous savez écrire une for boucle en Python et que vous vous êtes déjà retrouvé à fixer une page web en vous disant « Je veux ces données dans un tableur », ce tutoriel BeautifulSoup est fait pour vous. Beautiful Soup est une bibliothèque Python permettant de parser du HTML et du XML en une arborescence que vous pouvez interroger à l’aide de méthodes familières, de type jQuery. Elle ne récupère pas de pages, n’exécute pas de JavaScript et ne prétend pas être un navigateur. Elle se contente de prendre le balisage brut et vous fournit une API propre pour extraire les parties qui vous intéressent.

Le plan est clair. Nous allons configurer un nouvel environnement, récupérer une véritable page de liste à l’aide de la requests bibliothèque, l'analyser avec BeautifulSoup, cibler des éléments à l'aide de find_all et de sélecteurs CSS, suivre la pagination sur plusieurs pages, puis enregistrer les résultats au format CSV et JSON. Au cours de cette démarche, nous intégrerons la rotation d’agent utilisateur, les tentatives de reconnexion et la limitation de débit, car un tutoriel qui ignore les défenses anti-bot s’effondre dès que vous le dirigez vers un site réel. À la fin, vous disposerez d’un scraper prêt à l’emploi (à copier-coller) et d’une idée claire des situations où continuer à utiliser BeautifulSoup et de celles où passer à un outil plus puissant.

Qu'est-ce que BeautifulSoup et quand y avoir recours

BeautifulSoup (le bs4 paquet sur PyPI, actuellement dans la version 4.x) est une bibliothèque de parsing, ni un crawler ni un navigateur. Vous lui fournissez une chaîne HTML et elle renvoie un arbre de parsing que vous pouvez parcourir par balise, attribut, sélecteur CSS ou relation. C'est tout ce qu'elle fait. Tout ce qui concerne les requêtes HTTP, les cookies, les sessions, l'exécution de JavaScript ou les files d'attente relève d'un autre domaine, et c'est précisément cette séparation qui explique pourquoi BeautifulSoup reste le choix par défaut pour les pages statiques plus d'une décennie après sa première version.

Il est utile de la replacer dans son contexte. requests De plus, BeautifulSoup est la configuration la plus légère possible : il est idéal lorsque les données que vous recherchez se trouvent déjà dans le code HTML renvoyé par le serveur, et que vous explorez une poignée de pages plutôt qu’un million. Scrapy est l'outil qu'il vous faut lorsque vous avez besoin d'un framework de crawling complet avec pipelines, déduplication et concurrence. Selenium et Playwright sont les outils qu'il vous faut lorsque la page est une application monopage qui n'assemble son contenu qu'après l'exécution de JavaScript. Si vous pouvez envoyer une requête curl à l'URL et voir vos données dans le corps de la réponse, BeautifulSoup est presque toujours la solution la plus simple.

Configuration de l'environnement : Python, Requests et BeautifulSoup4

Utilisez un environnement virtuel afin que ce projet ne contamine pas vos paquets globaux. Toute version de Python 3.9 ou supérieure fonctionnera parfaitement pour ce tutoriel BeautifulSoup, et le verrouillage des versions garantit la reproductibilité des extraits de code présentés ici.

python -m venv .venv
source .venv/bin/activate   # on Windows: .venv\Scripts\activate
pip install requests==2.32.3 beautifulsoup4==4.12.3 lxml==5.2.2

requests gère la couche HTTP, beautifulsoup4 est l'API d'analyse syntaxique elle-même, et lxml est un parseur optionnel mais fortement recommandé, basé sur C. BeautifulSoup se rabat sur la bibliothèque standard html.parser si vous n'installez pas lxml, mais le parseur C est nettement plus rapide sur les documents volumineux et plus tolérant face à un balisage désordonné. Si vous devez prendre en charge des environnements Python où la compilation d'extensions C est compliquée, omettez lxml et vous perdrez un peu de vitesse mais aucune fonctionnalité.

Petit test rapide dans un REPL Python :

import requests, bs4
print(requests.__version__, bs4.__version__)

Si les deux versions s'affichent sans erreur, vous êtes prêt. Enregistrez le reste du code dans un fichier nommé hn_scraper.py et exécutez-le avec python hn_scraper.py.

Récupération de HTML avec Requests

BeautifulSoup a besoin d'octets pour analyser le code. La requests bibliothèque est le moyen le plus pratique de les obtenir. Choisissez une cible réelle que vous pouvez interroger poliment : Hacker News est le choix classique car la page d'accueil est du HTML brut rendu par le serveur, avec une structure prévisible et une protection anti-bot très légère, ce qui est idéal pour l'apprentissage.

import requests

URL = "https://news.ycombinator.com/news"
HEADERS = {
    "User-Agent": "Mozilla/5.0 (compatible; LearningScraper/1.0)",
    "Accept-Language": "en-US,en;q=0.9",
}

response = requests.get(URL, headers=HEADERS, timeout=15)
response.raise_for_status()        # blows up on 4xx/5xx
html_bytes = response.content      # bytes, not str

Deux points méritent qu'on s'y attarde. Premièrement, vérifiez toujours le code d'état. Un 403 silencieux renvoyant une page « Accès refusé » sera analysé proprement en un objet BeautifulSoup ne contenant aucune des données que vous souhaitez réellement, et vous passerez un après-midi à déboguer des sélecteurs sur la mauvaise page. raise_for_status() rend cette erreur évidente.

Deuxièmement, privilégiez response.content à response.text lorsque vous alimentez BeautifulSoup. .text force un décodage en utilisant l'encodage requests deviné à partir des en-têtes, ce qui est parfois erroné. .content correspond aux octets bruts, et BeautifulSoup est bien plus efficace pour détecter l'encodage réel à partir d'une <meta charset> balise ou du document lui-même. La différence a rarement de l’importance sur les sites en anglais uniquement, mais elle est cruciale dès que vous récupérez des données contenant des caractères accentués.

Créer un objet BeautifulSoup et choisir un analyseur

Une fois les octets en main, construisez l'arbre de parse en les passant au BeautifulSoup constructor avec un nom de parseur. La documentation officielle de Beautiful Soup répertorie trois parseurs qu’il est utile de connaître.

Analyseur

Vitesse

Tolérance vis-à-vis du code HTML incorrect

Remarques

html.parser

Correct

Bon

Bibliothèque standard, aucune installation requise.

lxml

Le plus rapide

Bon

Extension C ; pip install lxml.

html5lib

Le plus lent

Meilleur

Python pur ; imite la manière dont les navigateurs gèrent les balises incorrectes.

Pour ce tutoriel BeautifulSoup, nous utiliserons lxml car il est rapide et disponible partout de nos jours. Optez pour html5lib uniquement lorsqu'un site présente un code HTML véritablement mal formé que lxml déforme le code, et rabattez-vous sur html.parser si vous ne pouvez rien installer en dehors de la bibliothèque standard.

from bs4 import BeautifulSoup

soup = BeautifulSoup(html_bytes, "lxml")
print(soup.title.string)            # "Hacker News"
print(soup.prettify()[:300])        # peek at the formatted DOM

soup.title.string fonctionne car BeautifulSoup expose les balises de premier niveau sous forme d'attributs. get_text(strip=True) est l'alternative polyvalente la plus sûre lorsque vous ne savez pas si une balise contient du texte brut ou des éléments enfants imbriqués, et prettify() est inestimable lors de l'exploration car elle vous montre l'arborescence indentée que vous interrogez réellement.

Cibler des éléments : find, find_all et select

BeautifulSoup vous propose trois méthodes pour localiser des nœuds : find, find_all, et select. find renvoie la première correspondance (ou None). find_all renvoie une liste de toutes les correspondances. select et select_one utilisent des chaînes de sélection CSS, que nous aborderons dans la sous-section suivante.

Recherche par balise. La forme la plus simple. soup.find_all("a") renvoie toutes les ancres de la page.

links = soup.find_all("a")
print(len(links), "anchors found")

Recherche par classe. Utilisez le mot-clé class_ suivi d'un trait de soulignement, car class est un mot réservé en Python. Cela pose problème à presque tous les débutants.

rows = soup.find_all("tr", class_="athing")          # Hacker News story rows
titles = soup.find_all("span", class_="titleline")

Recherche par identifiant. Passez id= directement. Les identifiants sont censés être uniques, donc find c'est généralement ce que vous recherchez.

main = soup.find(id="hnmain")

Recherche par attribut. N'importe quel attribut peut être passé dans un attrs dict. C'est ainsi que vous ciblez data-* les attributs, aria-* des attributs ou tout autre élément qui n'est pas une balise, un identifiant ou une classe.

rows = soup.find_all("tr", attrs={"data-row-type": "story"})

Filtrer par une fonction. Lorsque vous avez besoin d'une logique qu'aucun mot-clé ne couvre, passez une fonction lambda. La fonction reçoit chaque balise et renvoie True à la conserver.

def is_external_link(tag):
    return tag.name == "a" and tag.get("href", "").startswith("http")

external = soup.find_all(is_external_link)

Vous pouvez également passer un lambda à l' string argument pour filtrer en fonction du contenu textuel. La correspondance de sous-chaînes sans distinction de casse est un cas d'utilisation courant :

python_links = soup.find_all("a", string=lambda s: s and "python" in s.lower())

Une règle empirique pragmatique : utilisez find et find_all lorsque la recherche porte sur un ou deux attributs. Dès que vous devez combiner une classe, un parent et une position, passez aux sélecteurs CSS. Ils sont plus faciles à lire et à copier depuis les DevTools du navigateur.

Approfondissement des sélecteurs CSS avec select() et select_one()

select() accepte les mêmes chaînes de sélecteurs CSS que celles que vous utilisez dans document.querySelectorAll. Cela signifie que les combinateurs de descendants, les combinateurs d'enfants, les sélecteurs d'attributs, les pseudo-classes et les noms de classes enchaînés fonctionnent tous.

# Descendant: any .titleline inside a tr.athing, at any depth
titles = soup.select("tr.athing .titleline")

# Direct child: only immediate children
direct = soup.select("tr.athing > td.title > span.titleline")

# Attribute selector: links to PDFs
pdfs = soup.select("a[href$='.pdf']")

# Positional: every fifth story row
every_fifth = soup.select("tr.athing:nth-of-type(5n)")

# Multiple classes at once
emphasized = soup.select("span.titleline.featured")

Voici le mappage pratique entre les deux API.

find_all form

select form

find_all("a", class_="storylink")

select("a.storylink")

find_all("div", id="main")

select("div#main")

find_all("input", attrs={"type": "hidden"})

select("input[type='hidden']")

Les sélecteurs ne sont pas un simple détail dans ce tutoriel BeautifulSoup, ils constituent la principale stratégie de maintenance. L'astuce qui permet aux scrapers de continuer à fonctionner lorsque le balisage change consiste à définir vos sélecteurs sous forme de constantes nommées en haut du module. Lorsque le site renomme une classe, vous modifiez une seule ligne au lieu de parcourir l'ensemble du code.

STORY_ROW = "tr.athing"
TITLE_LINK = "span.titleline > a"
RANK = "span.rank"

Prenez l'habitude de copier un sélecteur fonctionnel depuis Chrome DevTools (clic droit sur un élément, Copier > Copier le sélecteur), puis raccourcissez la chaîne générée automatiquement jusqu'à la version la plus courte qui identifie encore de manière unique ce que vous voulez. Les longs sélecteurs sont les premiers à ne plus fonctionner lorsque le balisage change ; les sélecteurs courts et nommés survivent aux petites refontes.

Parcourir le DOM : parents, frères et sœurs, et enfants

Parfois, l'élément que vous pouvez identifier clairement n'est pas celui que vous recherchez réellement. Un cas de figure courant : vous pouvez cibler un élément unique <span class="rank"> facilement, mais le titre et le lien se trouvent dans un nœud frère. Plutôt que d'écrire un sélecteur composé fragile, parcourez l'arborescence.

Chaque balise BeautifulSoup expose des attributs de navigation :

  • .parent: la balise immédiatement englobante.
  • .parents: un générateur renvoyant tous les ancêtres jusqu’à la racine du document.
  • .next_sibling et .previous_sibling: les nœuds adjacents au même niveau (peut s'agir d'espaces).
  • .find_next("tag") et .find_previous("tag"): ignorer les nœuds d'espaces et trouver la balise réelle suivante.
  • .children et .descendants: enfants directs ou tous les nœuds imbriqués.

Un exemple concret. Supposons que vous ayez récupéré toutes les balises .titleline spans sur Hacker News et que vous souhaitiez, pour chacun d’entre eux, la ligne environnante ainsi que la ligne suivante (qui contient le score et l’auteur).

for title_span in soup.select("span.titleline"):
    row = title_span.find_parent("tr")               # the .athing row
    meta_row = row.find_next_sibling("tr")           # the subtext row
    score = meta_row.find("span", class_="score")
    print(title_span.get_text(strip=True), score.get_text() if score else "-")

Le compromis à faire est entre lisibilité et robustesse. Un sélecteur CSS en chaîne est plus court, mais parcourir l'arborescence est souvent plus résilient lorsque la page encapsule les mêmes données dans différents conteneurs selon le contexte. Optez pour la traversée lorsqu'une seule requête ne permet pas d'exprimer la relation dont vous avez besoin.

Projet de bout en bout : extraction du classement, du titre et de l'URL de Hacker News

Il est temps d'arrêter de montrer des extraits isolés et de construire le cœur du scraper. La page d'accueil de Hacker News affiche chaque article sous la forme d'une tr.athing ligne, où le classement se trouve dans span.rank, le titre et le lien externe se trouvent à l'intérieur de span.titleline > a, et une ligne sœur contient le score et l'auteur. Notre travail consiste à transformer chaque article en un dictionnaire.

Voici la première version de l'analyseur. Notez qu'il n'effectue aucune requête ; il accepte une chaîne HTML et renvoie des enregistrements structurés. Le fait de séparer la récupération et l'analyse vous permet de tester l'analyseur de manière unitaire sur un HTML de test sans solliciter le réseau.

from bs4 import BeautifulSoup

def parse_stories(html: bytes) -> list[dict]:
    soup = BeautifulSoup(html, "lxml")
    stories = []
    for row in soup.select("tr.athing"):
        rank_tag = row.select_one("span.rank")
        link_tag = row.select_one("span.titleline > a")
        if not (rank_tag and link_tag):
            continue                                # skip malformed rows
        stories.append({
            "rank": rank_tag.get_text(strip=True).rstrip("."),
            "title": link_tag.get_text(strip=True),
            "url": link_tag.get("href", ""),
            "id": row.get("id"),
        })
    return stories

Quelques détails qui ont plus d’importance qu’il n’y paraît. rank_tag.get_text(strip=True).rstrip(".") gère le point final que Hacker News affiche après chaque classement ("1." devient "1"). link_tag.get("href", "") renvoie la chaîne vide au lieu de lever une exception KeyError si l'attribut est manquant, ce qui est le genre de modification d'un seul caractère qui transforme un scraper fragile en un scraper robuste. Et le continue maintient la boucle active lorsque le site insère occasionnellement une ligne publicitaire ou un espace réservé sponsorisé qui ne correspond pas au schéma.

Associez l'analyseur au récupérateur :

import requests

def fetch(url: str) -> bytes:
    headers = {"User-Agent": "LearningScraper/1.0"}
    response = requests.get(url, headers=headers, timeout=15)
    response.raise_for_status()
    return response.content

if __name__ == "__main__":
    stories = parse_stories(fetch("https://news.ycombinator.com/news"))
    for story in stories[:5]:
        print(story["rank"], story["title"])

L'exécution de ce code devrait afficher les cinq premiers titres classés tels qu'ils apparaissent actuellement sur la page. Vous disposez d'un scraper d'une seule page fonctionnel en moins de trente lignes. Les sections restantes de ce tutoriel BeautifulSoup ajoutent la pagination, les exportations, les tentatives de reprise et les finitions qui permettent au script de résister à une exécution sur un site réel pendant une heure au lieu d'une minute.

Gestion de la pagination et des explorations multipages

Hacker News utilise un paramètre de requête pour la pagination : ?p=2, ?p=3, et ainsi de suite. Au bas de chaque page se trouve une <a class="morelink"> ancre qui pointe vers la page suivante. Détecter cette ancre est la condition d'arrêt la plus propre, car elle fonctionne que le site utilise des pages séquentielles, des jetons de curseur ou des paramètres de décalage.

import time
from urllib.parse import urljoin

BASE = "https://news.ycombinator.com/"

def scrape_all(start_url: str, max_pages: int = 5, delay: float = 1.5) -> list[dict]:
    url = start_url
    pages_done = 0
    all_stories: list[dict] = []

    while url and pages_done < max_pages:
        html = fetch(url)
        all_stories.extend(parse_stories(html))

        soup = BeautifulSoup(html, "lxml")
        more = soup.select_one("a.morelink")
        url = urljoin(BASE, more["href"]) if more else None

        pages_done += 1
        time.sleep(delay)
    return all_stories

Trois détails méritent d'être soulignés. urljoin(BASE, more["href"]) Il s'agit de la manière dont vous transformez des liens relatifs comme news?p=2 en une véritable URL absolue, ce que requests nécessite. La max_pages limite sert de filet de sécurité afin qu'une condition d'arrêt boguée ne puisse pas s'exécuter indéfiniment. Et time.sleep(delay) est le limiteur de débit le plus simple possible ; nous le remplacerons par quelque chose de plus intelligent lorsque nous aborderons l'anti-blocage.

Ce modèle de pagination s'applique bien au-delà de Hacker News. Partout où la page suivante est une véritable ancre dans le balisage, vous pouvez insérer un sélecteur différent dans select_one et le reste de la boucle reste identique. Pour les sites qui paginent avec un défilement infini, BeautifulSoup seul ne suffira pas, et nous aborderons cette limitation dans la section JavaScript plus loin dans ce tutoriel BeautifulSoup.

Exportation des données extraites au format CSV et JSON

Une fois que vous disposez d’une liste de dictionnaires, leur exportation vers le disque est une opération mécanique. Les deux formats attendus par tout analyste sont CSV et JSON, et il n’y a aucune raison de ne pas produire les deux dans le même flux de travail.

import csv, json
from pathlib import Path

def export(records: list[dict], out_dir: str = "out") -> None:
    out = Path(out_dir)
    out.mkdir(exist_ok=True)

    csv_path = out / "stories.csv"
    with csv_path.open("w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=["rank", "title", "url", "id"])
        writer.writeheader()
        writer.writerows(records)

    json_path = out / "stories.json"
    with json_path.open("w", encoding="utf-8") as f:
        json.dump(records, f, ensure_ascii=False, indent=2)

Quelques pièges d'encodage méritent d'être signalés. Utilisez encoding="utf-8-sig" pour le CSV si les données doivent être ouvertes dans Excel sous Windows, car la BOM indique à Excel que le fichier est en UTF-8 (sans elle, les caractères accentués s'affichent de manière incohérente). Passez newline="" à open lors de l'écriture du CSV pour éviter les lignes vides sous Windows. Pour le JSON, ensure_ascii=False conserve les caractères non ASCII tels quels plutôt que de les \uXXXX les échapper, ce qui rend le résultat lisible par l’utilisateur.

Pour les analystes qui travaillent sur un ordinateur portable, pandas.DataFrame(records).to_csv("stories.csv", index=False) est l'alternative en une seule ligne. Elle est plus lourde mais pratique lorsque vous vous apprêtez de toute façon à effectuer une analyse exploratoire sur ces mêmes données.

Pièges courants : éléments manquants, encodage et erreurs NoneType

Le bug le plus courant que vous rencontrerez dans n'importe quel code de tutoriel BeautifulSoup est AttributeError: 'NoneType' object has no attribute 'get_text'. Cela signifie toujours find ou select_one retourné None, puis vous avez essayé d'appeler une méthode dessus. La solution consiste à toujours vérifier avant d'enchaîner.

# Brittle
title = row.find("span", class_="titleline").a.get_text()

# Defensive
line = row.find("span", class_="titleline")
anchor = line.find("a") if line else None
title = anchor.get_text(strip=True) if anchor else None

Deux habitudes connexes vous feront gagner des heures :

  • Utilisez .get(attr, default) au lieu de tag[attr]. L'indexation lève une exception KeyError lorsque l'attribut est manquant, tandis que .get renvoie discrètement votre valeur par défaut et laisse la boucle se poursuivre.
  • Toujours .get_text(strip=True) plutôt que .string. .string est None lorsqu’une balise comporte plusieurs enfants, ce qui la rend étonnamment fragile.

L'encodage est le deuxième piège classique. Si vous passez à BeautifulSoup response.text et que le site ment sur son encodage dans l' Content-Type en-tête, vous obtenez des caractères illisibles. En lui fournissant response.content (en octets) permet à BeautifulSoup de détecter le véritable encodage du document.

Enfin, écrivez vos sélecteurs par rapport à un fichier HTML de référence enregistré pendant le développement. Enregistrez le response.content une seule fois, puis itérez localement. Votre scraper est alors facile à tester unitairement et vous cessez de surcharger le site cible à chaque fois que vous modifiez un sélecteur.

Contourner les défenses anti-scraping tout en restant courtois

Même une cible bienveillante bloquera un scraper qui la bombarde de milliers de requêtes identiques provenant d’une seule adresse IP. La courtoisie relève en partie de l’ingénierie et en partie du bon sens. Cinq techniques couvrent l’essentiel de ce dont vous aurez besoin.

1. Alternez les user agents. Une empreinte de navigateur authentique associée à un petit ensemble de chaînes User-Agent réalistes suffit pour que les filtres basiques vous ignorent. Choisissez-en une par requête.

import random
UAS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) Safari/17.0",
    "Mozilla/5.0 (X11; Linux x86_64) Firefox/124.0",
]
headers = {"User-Agent": random.choice(UAS)}

2. Limitez le débit avec une variation aléatoire. Un débit constant time.sleep(1) est une empreinte en soi. Ajoutez une variation aléatoire pour que le rythme semble humain.

time.sleep(random.uniform(1.0, 2.5))

3. Réessayez avec un backoff exponentiel. Les échecs transitoires (5xx, réinitialisations de connexion, délais d'attente) sont la norme. Enveloppez les requêtes d'un backoff afin qu'un incident ponctuel ne mette pas fin à l'opération.

def fetch_with_retry(url, headers, attempts=4):
    for i in range(attempts):
        try:
            r = requests.get(url, headers=headers, timeout=15)
            if r.status_code == 200:
                return r.content
            if r.status_code in (429, 503):
                time.sleep(2 ** i)
                continue
            r.raise_for_status()
        except requests.RequestException:
            time.sleep(2 ** i)
    raise RuntimeError(f"giving up on {url}")

4. Alternez les proxys. Si votre adresse IP personnelle ne suffit plus, acheminez les requêtes via un ensemble de proxys résidentiels ou de centre de données. requests accepte un proxies={"http": ..., "https": ...} argument ; la logique de rotation se trouve un niveau plus haut.

5. Lisez robots.txt et les conditions d'utilisation. La documentation robots.txt de Google constitue une excellente introduction au protocole. Le respect des Disallow directives n'est pas juridiquement contraignant partout, mais c'est la frontière entre un scraper courtois et un scraper importun, et c'est en les ignorant que les projets finissent sur des listes de blocage.

Lorsque les sites s'appuient sur des solutions anti-bot sophistiquées (Cloudflare Bot Manager, PerimeterX, DataDome), le coût de la mise en place de tout cela par vos propres moyens dépasse celui de l'utilisation d'un déblocage géré. Notre API Scraper gère la rotation, les CAPTCHA et les tentatives de récupération derrière un seul point de terminaison ; ainsi, le code d'analyse BeautifulSoup de ce tutoriel reste exactement le même et seule la couche de récupération change.

Quand BeautifulSoup ne suffit pas : les pages rendues par JavaScript

BeautifulSoup analyse ce que le serveur a envoyé. Si le serveur a envoyé une coquille HTML presque vide et que la page n'assemble son contenu qu'après l'exécution de JavaScript dans le navigateur, BeautifulSoup analysera joyeusement la coquille sans trouver quoi que ce soit d'utile. C'est la seule limite majeure à ce que ce tutoriel BeautifulSoup peut faire pour vous, et il vaut la peine d'en reconnaître les symptômes.

Signes révélateurs indiquant que vous avez affaire à une application monopage :

  • view-source: affiche un minuscule <div id="root"></div> et un mur de <script> , mais la page affichée dans le navigateur est pleine de contenu.
  • Votre scraper voit un DOM différent de celui de DevTools. DevTools affiche le DOM en temps réel, qui inclut les nœuds injectés par JS ; requests ne voit que la réponse initiale.
  • L'onglet Réseau affiche une multitude de XHR ou fetch après le chargement de la page.

Vous disposez de trois bonnes options :

  • Trouvez l'API. Surveillez l'onglet Réseau. Si la page récupère du JSON depuis un backend, accédez directement à ce point de terminaison avec requests et ignorez complètement le rendu. C'est généralement la méthode la plus rapide et la plus stable.
  • Utilisez un vrai navigateur. Utilisez Playwright ou Selenium pour charger la page, attendez les données, puis transmettez le code HTML rendu à BeautifulSoup pour l'analyse.
  • Utilisez une API de navigateur gérée. Si vous souhaitez utiliser un navigateur sans gérer l'infrastructure, un point de terminaison de navigateur cloud renvoie le code HTML rendu et vous pouvez continuer à l'analyser avec le même find_all/select code que vous avez déjà écrit.

Script final : assembler la récupération, l'analyse, la pagination et l'exportation

Voici la version consolidée du code du tutoriel BeautifulSoup. Elle effectue la pagination, les réessais, la limitation de débit avec jitter, la rotation des agents utilisateurs et l'exportation au format CSV et JSON.

import csv, json, random, time
from pathlib import Path
from urllib.parse import urljoin

import requests
from bs4 import BeautifulSoup

BASE = "https://news.ycombinator.com/"
START = urljoin(BASE, "news")
UAS = [
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/124.0",
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 13_5) Safari/17.0",
]

def fetch(url, attempts=4):
    for i in range(attempts):
        try:
            r = requests.get(url, headers={"User-Agent": random.choice(UAS)}, timeout=15)
            if r.status_code == 200:
                return r.content
            if r.status_code in (429, 503):
                time.sleep(2 ** i); continue
            r.raise_for_status()
        except requests.RequestException:
            time.sleep(2 ** i)
    raise RuntimeError(f"failed: {url}")

def parse_stories(html):
    soup = BeautifulSoup(html, "lxml")
    out = []
    for row in soup.select("tr.athing"):
        rank = row.select_one("span.rank")
        link = row.select_one("span.titleline > a")
        if not (rank and link):
            continue
        out.append({
            "rank": rank.get_text(strip=True).rstrip("."),
            "title": link.get_text(strip=True),
            "url": link.get("href", ""),
            "id": row.get("id"),
        })
    return out

def next_page(html):
    soup = BeautifulSoup(html, "lxml")
    more = soup.select_one("a.morelink")
    return urljoin(BASE, more["href"]) if more else None

def crawl(start, max_pages=3):
    url, pages, rows = start, 0, []
    while url and pages < max_pages:
        html = fetch(url)
        rows.extend(parse_stories(html))
        url = next_page(html)
        pages += 1
        time.sleep(random.uniform(1.0, 2.5))
    return rows

def export(rows, out_dir="out"):
    out = Path(out_dir); out.mkdir(exist_ok=True)
    with (out / "stories.csv").open("w", newline="", encoding="utf-8-sig") as f:
        w = csv.DictWriter(f, fieldnames=["rank", "title", "url", "id"])
        w.writeheader(); w.writerows(rows)
    with (out / "stories.json").open("w", encoding="utf-8") as f:
        json.dump(rows, f, ensure_ascii=False, indent=2)

if __name__ == "__main__":
    rows = crawl(START)
    export(rows)
    print(f"saved {len(rows)} stories")

Placez-le dans hn_scraper.py, exécutez python hn_scraper.py, et vous devriez voir trois pages d'articles écrites dans out/stories.csv et out/stories.json.

Prochaines étapes de ce tutoriel BeautifulSoup

Vous disposez désormais d’un scraper complet pour sites statiques, mais ce même parseur s’intègre dans des workflows bien plus vastes. Voici trois étapes logiques à suivre :

  • Passez à Scrapy lorsque vous devez explorer des milliers de pages, dédupliquer des URL, gérer la concurrence et exécuter des tâches planifiées. Scrapy utilise des idiomes de sélection similaires, de sorte que le modèle mental que vous avez construit dans ce tutoriel BeautifulSoup se transpose sans difficulté.
  • Ajoutez un navigateur sans interface graphique lorsque les données sont protégées par JavaScript. Playwright et Selenium vous permettent tous deux d'afficher d'abord la page, puis d'analyser le code HTML affiché avec BeautifulSoup, ce qui vous permet de conserver votre code d'analyse existant et vos sélecteurs CSS.
  • Externalisez la couche de récupération lorsque les blocs deviennent un goulot d'étranglement. Une API de scraping gérée gère les proxys, les en-têtes et la résolution des CAPTCHA, ce qui vous permet de continuer à itérer sur les sélecteurs plutôt que sur l'empreinte digitale.

Quelle que soit la direction que vous prenez, conservez la séparation entre l'analyse et la récupération que vous avez mise en place ici. C'est le seul choix de conception qui permet à un scraper de survivre à l'inévitable refonte du site, et c'est ce qui rend le code de ce guide réutilisable à mesure que vos besoins évoluent.

Points clés

  • BeautifulSoup analyse le HTML, rien de plus. Associez-le à requests pour les pages statiques et d'un véritable navigateur pour celles rendues par JavaScript.
  • Les sélecteurs CSS s'adaptent mieux que les appels find_all . Définissez-les comme des constantes nommées en haut de votre module afin qu’une modification du balisage ne nécessite qu’une ligne de code.
  • Protégez-vous toujours contre None. Utilisez find_parent avec précaution, privilégiez .get("attr", "") à l'indexation, et vérifiez avant de chaîner les appels de méthode.
  • La pagination est une condition d'arrêt. Détectez l'ancre de la page suivante, construisez des URL absolues avec urljoin, et limitez la boucle avec max_pages afin qu'un bug ne puisse pas s'exécuter indéfiniment.
  • La courtoisie, c'est de l'ingénierie. La rotation des agents utilisateurs, le délai aléatoire, le recul exponentiel et le respect robots.txt sont des pratiques de base, et non des fioritures facultatives, pour tout tutoriel BeautifulSoup que vous comptez exécuter plus d’une fois.

FAQ

Quelle est la différence entre html.parser de BeautifulSoup, lxml et html5lib ?

html.parser est fourni avec Python et ne nécessite aucune installation, mais c’est le plus lent des trois. lxml est une extension C qui est la plus rapide en pratique et gère bien la plupart des HTML mal formés ; installez-la avec pip install lxml. html5lib est en Python pur et le plus indulgent, imitant la façon dont un vrai navigateur se remet d'un balisage corrompu, au prix d'une lenteur notable.

Quand dois-je utiliser BeautifulSoup plutôt que Scrapy, Selenium ou Playwright ?

Utilisez BeautifulSoup pour les scripts ponctuels et les pages statiques où vous pouvez récupérer le code HTML avec requests. Utilisez Scrapy lorsque vous avez besoin d'un véritable crawler avec concurrence, pipelines et planification sur des milliers d'URL. Utilisez Selenium ou Playwright lorsque la page dépend de JavaScript pour afficher le contenu, puis renvoyez éventuellement le code HTML affiché à BeautifulSoup pour analyse.

BeautifulSoup peut-il extraire seul des pages rendues par JavaScript ?

Non. BeautifulSoup analyse uniquement le code HTML qu'il reçoit, et requests renvoie la réponse initiale du serveur sans exécuter de JavaScript. Pour les applications monopages ou le contenu injecté après le chargement de la page, vous avez besoin d’un navigateur sans interface graphique (Playwright, Selenium ou un point de terminaison de navigateur cloud) pour afficher le DOM en premier lieu. Une fois affiché, vous pouvez toujours transmettre ce code HTML à BeautifulSoup pour qu’il l’analyse.

Comment éviter que mon adresse IP soit bloquée lors du scraping avec BeautifulSoup ?

Faites tourner les chaînes User-Agent, ajoutez des délais aléatoires entre les requêtes et réessayez en cas d'erreurs transitoires avec un backoff exponentiel. Pour les volumes plus importants, acheminez le trafic via des proxys résidentiels ou de centre de données en rotation. Respectez robots.txt et évitez de scraper du contenu protégé par une connexion. Les piles anti-bot agressives comme Cloudflare nécessitent souvent un déverrouilleur géré plutôt que des modifications d'en-tête à faire soi-même.

Est-il légal de scraper un site web avec BeautifulSoup ?

La bibliothèque en elle-même ne fait qu'analyser du texte et ne pose pas de problème juridique. La légalité d'un scraping spécifique dépend généralement des conditions d'utilisation du site cible, des lois applicables en matière de droits d'auteur et d'utilisation abusive des ordinateurs dans votre juridiction, et du fait que les données soient ou non personnelles au regard de réglementations telles que le RGPD ou le CCPA. Il s'agit d'informations générales et non de conseils juridiques ; consultez un avocat pour toute question relative aux données personnelles, aux paywalls ou à la redistribution commerciale.

Conclusion

Vous avez commencé ce tutoriel BeautifulSoup avec pip install et l'avez terminé avec un scraper capable de paginer, de réessayer, de faire tourner les user agents et d'exporter des fichiers CSV et JSON propres. La structure de ce script est plus importante que n'importe quel extrait de code : séparez la récupération (fetch) de l'analyse (parse), ciblez les éléments avec des sélecteurs CSS nommés, protégez chaque accès aux attributs en chaîne contre None, et intégrez les pratiques anti-blocage dès la conception plutôt que de les ajouter après coup. Les sites continueront à évoluer, les parseurs continueront à être bloqués, et les bases de code qui résistent bien au temps sont celles qui respectent cette séparation dès le premier jour.

Si la couche de récupération commence à vous prendre plus de temps que la couche d'analyse, c'est le signe qu'il faut la décharger. WebScrapingAPI gère la rotation des proxys, l'empreinte des en-têtes et la résolution des CAPTCHA derrière un seul point de terminaison, ce qui vous permet de conserver le code BeautifulSoup que vous avez écrit ici et de ne remplacer que la requête qui lui fournit le HTML. Bonne chance, et que vos sélecteurs restent verts.

À propos de l'auteur
Sorin-Gabriel Marica, Développeur full-stack @ WebScrapingAPI
Sorin-Gabriel MaricaDéveloppeur full-stack

Sorin Marica est ingénieur Full Stack et DevOps chez WebScrapingAPI ; il développe des fonctionnalités pour les produits et assure la maintenance de l'infrastructure qui garantit le bon fonctionnement de la plateforme.

Commencez à créer

Prêt à faire évoluer votre système de collecte de données ?

Rejoignez plus de 2 000 entreprises qui utilisent WebScrapingAPI pour extraire des données Web à l'échelle de l'entreprise, sans aucun coût d'infrastructure.