📡 Hors-ligne — contenu servi depuis le cache
Modules
▸ Programme · 11 modules

Web Scraping
de Zéro à Expert

Une formation complète, rédigée comme si on travaillait ensemble. Du contexte, des explications, des pièges à éviter — et des exemples ancrés dans la réalité d'une pharmacie officinale.
11
modules
~40h
de pratique
Python
langage
Scrapy
framework clé
💊
Fil rouge officinal : chaque module est ancré dans un cas réel — extraction de prix Alliance Healthcare, parsers de factures pour Bunka NEV, contournement des bot-blockers sur HubPharma et DigiPharmacie, OCR de BL scannés.
MODULE 01 / 11

Parser du HTML
Complexe

Comprendre ce qu'est le DOM, pourquoi le HTML des sites "résiste", et comment extraire exactement les données qu'on veut avec BeautifulSoup4 et XPath.
beautifulsoup4lxmlCSS selectorsXPath
🌐C'est quoi le HTML que tu veux scraper ?

Quand tu ouvres un site comme HubPharma dans ton navigateur, ce que tu vois visuellement — le tableau de prix, les noms de produits, les CIP — est généré à partir d'un fichier texte structuré : le HTML. C'est ce fichier que le navigateur reçoit du serveur et "dessine" pour toi.

Le HTML est une arborescence de balises imbriquées : <html> contient <body> qui contient <div> qui contient <table> qui contient <tr>… C'est ce qu'on appelle le DOM — Document Object Model. C'est un arbre généalogique de balises.

Le scraping HTML, c'est l'art de naviguer dans cet arbre pour en extraire des données précises. Tu veux le 3ème <td> du 5ème <tr> du tableau dont l'ID est "catalogue-prix" ? BeautifulSoup te permet de l'écrire en Python en 2 lignes.

💡
Analogie DMP : le DOM, c'est comme le Dossier Médical Partagé. Tout y est structuré hiérarchiquement — patient → épisodes → prescriptions → médicaments. Le scraping, c'est interroger ce dossier pour en extraire un champ précis.

Le problème, c'est que le HTML "réel" des sites est rarement propre. Il y a des balises non fermées, des attributs mal formés, des caractères d'encodage bizarres, des espaces parasites. C'est pourquoi on a besoin d'un parser — un programme qui lit ce HTML imparfait et le transforme en un arbre propre qu'on peut interroger.

📦Choisir son parser : html.parser, lxml ou html5lib ?

BeautifulSoup n'est pas un parser en lui-même — c'est une bibliothèque qui utilise un parser externe pour lire le HTML, puis qui te fournit une API pour naviguer dans l'arbre. Tu as 3 options :

html.parser
Inclus dans Python. Aucune installation. Lent. Assez permissif. OK pour apprendre.
lxml
10x plus rapide. Très permissif avec le HTML malformé. Recommandé en production.
html5lib
Parse exactement comme Firefox. Lent. Utile uniquement si lxml rate des cas.
⚠️
Installe toujours lxml en production. Sur des catalogues de 5000 produits, la différence de vitesse est significative. pip install lxml
bash
pip install requests beautifulsoup4 lxml
pip install httpx playwright
playwright install chromium  # navigateur headless pour le JS
🔍CSS Selectors — la syntaxe du développeur front

Les CSS selectors, c'est le même langage que les développeurs utilisent pour styler un site. span.price signifie "une balise <span> qui a la classe CSS price". Si tu as déjà inspecté un élément dans Chrome (clic droit → Inspecter), tu as vu ce HTML — les sélecteurs CSS décrivent exactement ces patterns.

La méthode select_one() retourne le premier résultat, select() retourne une liste. C'est la distinction la plus importante à retenir.

python
from bs4 import BeautifulSoup
import requests

html = requests.get("https://example.com").text
soup = BeautifulSoup(html, "lxml")

# Sélecteurs CSS — de la plus simple à la plus précise
prix   = soup.select("span.price")            # tous les <span class="price">
titre  = soup.select_one("h1.product-title")  # le premier h1.product-title
liens  = soup.select("a[href^='/produit']")   # liens dont href commence par /produit
hidden = soup.select("input[type='hidden']")   # inputs cachés (tokens CSRF !)
trs    = soup.select("tr:not(:first-child)")   # toutes les lignes sauf l'en-tête

# Extraire le texte d'un élément
if titre:
    print(titre.get_text(strip=True))  # strip=True supprime les espaces
Pourquoi strip=True ?Les balises HTML contiennent souvent des retours à la ligne et espaces invisibles autour du texte. Sans strip, tu récupères "\n DOLIPRANE 500 mg\n " au lieu de "DOLIPRANE 500 mg".
🧭XPath — quand les CSS selectors ne suffisent pas

Les CSS selectors sont parfaits pour cibler des éléments par leur type, classe ou attribut. Mais ils ont une limitation majeure : tu ne peux pas remonter dans l'arbre. Tu ne peux pas dire "donne-moi le parent du TD qui contient 'Prix TTC'".

XPath est un langage plus puissant qui permet de naviguer dans toutes les directions : parents, frères, enfants conditionnels. La syntaxe est moins intuitive mais indispensable pour les cas complexes.

python
from lxml import etree

tree = etree.fromstring(html.encode())

# Cas 1 : texte du 2ème TD d'un tableau précis
val = tree.xpath("//table[@id='catalogue']//tr[2]/td[2]/text()")

# Cas 2 : remonter au parent (impossible en CSS)
# "Donne-moi la LIGNE qui contient un TD avec 'Prix TTC'"
row = tree.xpath("//td[contains(text(),'Prix TTC')]/..")

# Cas 3 : attribut contient une valeur partielle
elems = tree.xpath("//div[contains(@class,'product')]")

# Cas 4 : l'élément suivant (next sibling)
next_el = tree.xpath("//label[text()='CIP']/../following-sibling::td[1]")
💡
Comment trouver le bon XPath ? Dans Chrome DevTools → clic droit sur un élément → Copy → Copy XPath. C'est un excellent point de départ. Ensuite, généralize le pour qu'il capture tous les éléments similaires, pas juste cet occurrence précise.

Utilitaire universel — tableau vers liste de dicts

Ce pattern revient dans presque tous les scrapers de catalogues. Gardez-le précieusement :

python
def table_to_dicts(table_tag) -> list[dict]:
    """Convertit un <table> HTML en liste de dicts Python.
    Les en-têtes (<th>) deviennent les clés du dict."""
    headers = [th.get_text(strip=True) for th in table_tag.select("th")]
    rows = []
    for tr in table_tag.select("tr:not(:first-child)"):
        cells = [td.get_text(strip=True) for td in tr.select("td")]
        if cells:
            rows.append(dict(zip(headers, cells)))
    return rows

# Usage :
table = soup.select_one("table#catalogue-produits")
produits = table_to_dicts(table)
# → [{'CIP13': '3400935418487', 'Désignation': 'DOLIPRANE 500MG', 'PA HT': '1,42'}, ...]
💊
HubPharma : le catalogue grossiste est rendu dans un <table id="catalogue-produits">. Cette fonction te donne directement une liste de dicts avec CIP13, désignation, prix achat HT, UCD — prête à pousser dans Supabase.
MODULE 02 / 11

Scrapy :
Crawlers Professionnels

Pourquoi un framework plutôt que requests en boucle ? Quand passer à Scrapy, comment l'architecurer, et les patterns essentiels.
ScrapySpiderPipelineMiddlewareautothrottle
🤔Pourquoi pas juste requests en boucle ?

Au début on écrit tous la même chose : une boucle for, un requests.get(), un BeautifulSoup, et on stocke les résultats. Ça marche. Jusqu'à ce que ça ne marche plus.

Les problèmes arrivent vite dès qu'on monte en volume : le scraping de 5000 pages en séquentiel prend des heures, les erreurs réseau font planter toute la boucle, on n'a pas de gestion de rate limiting, les données doublonnent, et on réécrit la même logique de pagination à chaque projet.

Scrapy résout tous ces problèmes d'un coup avec une architecture éprouvée en production depuis 2008. Il gère la concurrence asynchrone (plusieurs pages en même temps), les retries automatiques, le rate limiting intelligent, la déduplication des URLs, et le stockage via des pipelines configurables.

💡
Règle pratique : si tu scrapes moins de 100 pages statiques → requests + BeautifulSoup suffit. Dès que tu crawles un site entier, gères de la pagination, ou as besoin de fiabilité en production → Scrapy.
🏗️Architecture Scrapy expliquée simplement

Scrapy fonctionne comme une chaîne de production avec des rôles bien définis. Voici ce qui se passe quand tu lances un crawl :

1. Spider
Définit les URL de départ et sait comment parser chaque type de page. C'est le seul fichier que tu écris vraiment.
2. Scheduler
File d'attente des URLs à visiter. Évite les doublons automatiquement. Tu n'y touches jamais directement.
3. Downloader
Télécharge les pages en parallèle. Gère les timeouts, retries, rate limiting. Configurable via settings.py.
4. Pipeline
Reçoit chaque Item extrait → validation, nettoyage, stockage (CSV, DB, API). Tu écris un Pipeline par type de traitement.
bash
pip install scrapy
scrapy startproject pharma_scraper  # crée la structure du projet
cd pharma_scraper
scrapy genspider catalogue hubpharma.fr  # génère un squelette de spider
scrapy crawl catalogue  # lancer le crawl
🕷️Écrire sa première Spider — ligne par ligne

La Spider est le cœur du système. Elle hérite de scrapy.Spider et définit 3 choses : les URLs de départ, comment parser une page, et quelles pages suivre ensuite. C'est aussi simple que ça.

python
import scrapy

class CatalogueSpider(scrapy.Spider):
    name = "catalogue"          # nom utilisé dans "scrapy crawl catalogue"
    allowed_domains = ["exemple-pharma.fr"]  # garde le crawler dans le site
    start_urls = ["https://exemple-pharma.fr/catalogue"]

    # Ces settings s'appliquent uniquement à cette spider
    custom_settings = {
        "DOWNLOAD_DELAY": 1.5,      # pause entre chaque requête (en secondes)
        "AUTOTHROTTLE_ENABLED": True, # ajuste la vitesse selon la réponse du serveur
        "FEEDS": {"output.json": {"format": "json"}},  # export auto
    }

    def parse(self, response):
        # response.css() = équivalent soup.select() mais plus rapide
        for produit in response.css("div.product-card"):
            yield {  # yield envoie chaque résultat au Pipeline
                "cip13":      produit.css("span.cip::text").get(),
                "nom":        produit.css("h3.name::text").get("").strip(),
                "prix_achat": produit.css("span.price::text").get(),
                "url":        response.urljoin(produit.css("a::attr(href)").get()),
            }

        # Pagination : si un lien "page suivante" existe, on le suit
        next_page = response.css("a.next-page::attr(href)").get()
        if next_page:
            yield response.follow(next_page, callback=self.parse)
Pourquoi yield et pas return ?yield est un générateur Python — il produit des valeurs une par une sans attendre que tout soit terminé. Scrapy collecte ces Items au fur et à mesure et les envoie aux Pipelines en temps réel. C'est ce qui permet de traiter des millions de pages sans saturer la mémoire.
response.urljoin() vs href brutLes liens dans le HTML sont souvent relatifs ("/produit/123"). urljoin les convertit automatiquement en URL absolue ("https://site.fr/produit/123"). Toujours utiliser urljoin.
⚙️Pipelines de données et Middlewares

Les Pipelines reçoivent chaque Item produit par la Spider et peuvent le filtrer, le nettoyer, ou le stocker. On enchaîne plusieurs Pipelines dans l'ordre — c'est une chaîne de traitement.

python
# pipelines.py — Pipeline de déduplication
from scrapy.exceptions import DropItem

class DedupPipeline:
    """Supprime les doublons basés sur le CIP13."""
    def __init__(self):
        self.seen_cips = set()

    def process_item(self, item, spider):
        if item["cip13"] in self.seen_cips:
            raise DropItem(f"Doublon ignoré : {item['cip13']}")
        self.seen_cips.add(item["cip13"])
        return item  # passe au pipeline suivant

# middlewares.py — Rotation des User-Agents
import random

USER_AGENTS = [
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
]

class RotateUserAgentMiddleware:
    def process_request(self, request, spider):
        request.headers["User-Agent"] = random.choice(USER_AGENTS)

# settings.py — activer les deux
ITEM_PIPELINES = {"pharma_scraper.pipelines.DedupPipeline": 300}
DOWNLOADER_MIDDLEWARES = {"pharma_scraper.middlewares.RotateUserAgentMiddleware": 543}
Le chiffre 300 et 543 Ce sont des priorités : les composants s'exécutent du plus petit au plus grand. 300 pour le Pipeline = il s'exécute relativement tôt dans la chaîne. 543 pour le Middleware = ordre standard recommandé par Scrapy pour les middlewares custom.
MODULE 03 / 11

Stocker
les Données Scrapées

Le choix du backend de stockage n'est pas anodin. CSV, SQLite, PostgreSQL, Supabase — chacun répond à un besoin différent. On explique la logique.
CSV/JSONSQLitePostgreSQLSupabase
🤔Quel backend choisir — et pourquoi ?

Le choix dépend de deux questions : qui va lire les données ? et à quelle fréquence elles sont mises à jour ?

BackendUsage idéalLimites
CSVExport ponctuel, partage avec Excel, analyse one-shotPas de requêtes, pas de mises à jour partielles
SQLiteDev local, prototype, script perso sans serveurFichier local uniquement, pas de multi-utilisateurs
PostgreSQLProduction, données critiques, requêtes complexesInfrastructure à gérer
SupabaseStack EtikPharma — PostgreSQL managé + API REST autoDépendance externe, coût à l'échelle
Firebase FirestoreSync temps réel, apps mobiles, ReactNoSQL = pas de JOINs, schéma implicite
💡
Règle simple : si c'est pour Bunka NEV → Supabase (déjà là). Si c'est pour un export ponctuel → CSV. Si c'est pour du dev/test → SQLite sans hésiter. Ne surdimensionne jamais le storage d'un prototype.
🗄️SQLite + SQLAlchemy — le bon réflexe en dev

SQLite, c'est une base de données entière dans un fichier. Pas de serveur, pas de config, ça marche partout. SQLAlchemy est l'ORM Python de référence — il te permet d'écrire des requêtes en Python pur sans taper de SQL brut.

python
from sqlalchemy import create_engine, Column, String, Float, DateTime
from sqlalchemy.orm import declarative_base, sessionmaker
from datetime import datetime

Base = declarative_base()

class Produit(Base):
    __tablename__ = "produits"
    cip13      = Column(String, primary_key=True)  # clé unique
    nom        = Column(String)
    prix_achat = Column(Float)
    scraped_at = Column(DateTime, default=datetime.utcnow)

engine = create_engine("sqlite:///catalogue.db")
Base.metadata.create_all(engine)  # crée le fichier catalogue.db et la table
Session = sessionmaker(bind=engine)

def upsert_produit(data: dict):
    """Insère ou met à jour un produit (upsert = insert OR update)."""
    with Session() as session:
        # get() cherche par clé primaire, renvoie None si absent
        p = session.get(Produit, data["cip13"]) or Produit()
        for k, v in data.items():
            setattr(p, k, v)
        session.merge(p)  # merge = insert si nouveau, update si existant
        session.commit()
🔥Push vers Supabase — le pattern EtikPharma

Supabase expose automatiquement une API REST sur ta base PostgreSQL. Pour le robot Bunka NEV, c'est le backend naturel : les données scrapées alimentent directement la table catalogue_prix que FaceAuCommercial affiche.

Le pattern clé est l'upsert par batch : on envoie 100 produits en une seule requête au lieu de 100 requêtes individuelles. Ça divise le temps d'insertion par 50.

python
from supabase import create_client
import os

supa = create_client(
    os.getenv("SUPABASE_URL"),   # ne jamais hardcoder les credentials
    os.getenv("SUPABASE_KEY")
)

def push_catalogue(produits: list[dict]):
    """Upsert batch — 100 produits en une requête.
    on_conflict='cip13' : si le CIP existe déjà, update. Sinon, insert."""
    (supa.table("catalogue_prix")
         .upsert(produits, on_conflict="cip13")
         .execute())

def log_prix(cip13: str, prix: float, source: str):
    """Enregistre chaque variation de prix avec timestamp.
    Permet de tracer l'historique et détecter les anomalies."""
    supa.table("historique_prix").insert({
        "cip13": cip13, "prix": prix,
        "source": source, "ts": "now()"
    }).execute()

# Usage dans le scraper :
batch = []
for produit in scraped_products:
    batch.append(produit)
    if len(batch) >= 100:
        push_catalogue(batch)
        batch = []
if batch:                 # ne pas oublier le dernier batch incomplet
    push_catalogue(batch)
💊
Architecture Bunka NEV : le robot scrape les prix NEV → log_prix() → table historique_prix. FaceAuCommercial compare avec catalogue_prix (prix marché scrapé Alliance/OCP). La table d'historique permet de voir si un prix a bougé entre deux scrapes.
MODULE 04 / 11

Extraire
de Documents

PDF natifs, PDF scannés, Excel mal formatés. Comprendre pourquoi certains PDF résistent, et comment les lire avec les bons outils.
pdfplumberpandasopenpyxlPyMuPDF
🤔Pourquoi les PDF sont si difficiles à lire ?

Le PDF n'est pas un format de données — c'est un format de présentation visuelle. Quand tu génères une facture Alliance Healthcare en PDF, le logiciel ne stocke pas "ligne 1 : DOLIPRANE, qté 3, prix 1.42€". Il stocke "dessin le texte DOLIPRANE aux coordonnées x=45, y=230, en Arial 10pt noir".

Il n'y a pas de notion de tableau, de colonne, de ligne dans un PDF brut. C'est une série de commandes graphiques. C'est pour ça qu'un copier-coller depuis un PDF donne souvent un résultat désastreux.

Il existe deux types de PDF, et ils se lisent très différemment :

PDF natif (texte)
Généré par un logiciel (Word, LGO, ERP). Le texte est encodé. pdfplumber peut l'extraire directement et détecter les tableaux par les coordonnées.
PDF scanné (image)
Photo d'un document papier. C'est une image bitmap. Aucun texte encodé → il faut de l'OCR (reconnaissance optique). Voir Module 9.
⚠️
Comment savoir ? Essaie de sélectionner du texte dans le PDF avec ta souris. Si tu peux sélectionner → natif → pdfplumber. Si le curseur devient une croix de déplacement → scanné → OCR.
📄pdfplumber — extraction texte et tableaux

pdfplumber est la bibliothèque Python la plus fiable pour les PDF natifs. Elle détecte les tableaux en analysant l'alignement des éléments texte dans la page — exactement comme le ferait un humain en regardant les colonnes.

python
import pdfplumber, re

with pdfplumber.open("facture_alliance.pdf") as pdf:
    page1 = pdf.pages[0]

    # --- Méthode 1 : texte brut de la page entière ---
    texte = page1.extract_text()
    # Utile pour récupérer un numéro de facture, une date, un total

    # --- Méthode 2 : tableaux structurés ---
    tables = page1.extract_tables()
    # tables est une liste de tableaux, chaque tableau est une liste de lignes
    if tables:
        header = tables[0][0]  # première ligne = en-têtes
        for row in tables[0][1:]:
            print(dict(zip(header, row)))

    # --- Méthode 3 : zone précise (crop) ---
    # Utile si le tableau est toujours à la même position
    bbox = (0, 180, 595, 750)   # (x0, top, x1, bottom) en points PDF
    zone = page1.crop(bbox).extract_table()

    # --- Extraire un montant avec regex ---
    m = re.search(r"Total TTC[^\d]*([\d\s,\.]+)", texte or "")
    total = m.group(1).strip() if m else None
Les coordonnées crop en "points PDF"Un point PDF = 1/72 de pouce. Une page A4 standard fait 595 × 842 points. Pour trouver les coordonnées d'une zone : ouvre le PDF dans Adobe Reader → Édition → Préférences → Unités = Points.
💊
Factures Alliance Healthcare : toujours des PDF natifs. Le tableau des lignes de commande est détecté automatiquement par extract_tables(). Le total TTC est dans le bloc texte en bas de page — une regex suffit. Zéro OCR nécessaire.
📊pandas — Excel et CSV mal formatés

Les exports Excel du LGO Smart RX ou des grossistes ne sont jamais propres. En-têtes sur la 3ème ligne, cellules fusionnées, colonnes sans nom, lignes vides parasites. pandas gère tout ça avec des paramètres de lecture.

python
import pandas as pd

# Export LGO avec les en-têtes sur la ligne 3 (index 2)
df = pd.read_excel(
    "export_lgo.xlsx",
    header=2,           # ligne 3 = en-têtes (0-indexed)
    skiprows=[0, 1],     # sauter les 2 premières lignes (logo, titre)
    usecols="A:F",       # ne lire que les colonnes A à F
)

# Renommer les colonnes avec des noms propres
df = df.rename(columns={
    "Code Article": "cip13",
    "Désignation": "nom",
    "Prix Achat HT": "pa"
})

# Nettoyer : supprimer lignes sans CIP, prix à 0 ou négatifs
df = df.dropna(subset=["cip13"])
df = df[df["pa"] > 0]

# Convertir en liste de dicts pour Supabase
records = df[["cip13", "nom", "pa"]].to_dict("records")

# Lire un fichier avec plusieurs onglets
xl = pd.ExcelFile("rapport_mensuel.xlsx")
print(xl.sheet_names)  # ['Janvier', 'Février', 'Mars']
df_jan = xl.parse("Janvier")
MODULE 05 / 11

Nettoyer
& Normaliser

Les données scrapées sont toujours sales. Comprendre pourquoi, et construire un pipeline de nettoyage robuste avec regex, pandas et ftfy.
regexpandasftfyrapidfuzz
🤔Pourquoi les données sont-elles toujours sales ?

Le web n'a pas été conçu pour être scraped — il a été conçu pour être affiché. Les données que tu extrais sont souvent le reflet de décisions d'affichage, pas de rigueur de données.

  • Encodage cassé : un nom comme "Générique" peut devenir "Générique" si le site mélange UTF-8 et Latin-1. C'est le problème le plus fréquent.
  • Espaces invisibles : espaces insécables (U+00A0), tabulations, retours à la ligne masqués dans le HTML.
  • Prix en chaîne : "12,50 €" ou "12.50€" ou "12 50" selon le site. Il faut normaliser en float Python.
  • CIP mal formatés : "34009-354-18-4" ou "3400935418" ou "3 400 935 418 487" — c'est le même produit.
  • Doublons mous : "DOLIPRANE 500MG" et "Doliprane 500 mg" — même produit, orthographe différente.
💡
Principe : nettoyer tôt, nettoyer systématiquement. Un pipeline de nettoyage qu'on applique à chaque donnée avant de la stocker vaut mieux que de nettoyer après coup dans la base de données.
🧹Le pipeline de nettoyage universel
python
import re, unicodedata, ftfy

def clean_text(s) -> str:
    if not isinstance(s, str): return ""
    s = ftfy.fix_text(s)              # 1. répare l'encodage cassé
    s = unicodedata.normalize("NFC", s) # 2. normalise Unicode (é = é)
    s = re.sub(r"[\u00a0\u200b\u2009]", " ", s)  # 3. espaces spéciaux → espace
    s = re.sub(r"\s+", " ", s)         # 4. espaces multiples → un seul
    return s.strip()

def parse_prix(s) -> float | None:
    """Convertit '12,50 €' ou '12.50' ou '12 50' en 12.5"""
    s = re.sub(r"[^\d,\.]", "", str(s))  # garde uniquement chiffres, virgule, point
    s = s.replace(",", ".")              # virgule décimale → point
    try: return float(s)
    except: return None                   # si ça échoue, on retourne None

def parse_cip(s) -> str | None:
    """Extrait un CIP7 (7 chiffres) ou CIP13 (commence par 34009)"""
    s_clean = re.sub(r"[\s\-\.]", "", str(s))  # supprimer séparateurs
    m = re.search(r"(34009\d{8}|\d{7})", s_clean)
    return m.group(1) if m else None

# Application sur un DataFrame pandas en une passe
df["nom_clean"]  = df["nom"].map(clean_text)
df["pa_float"]   = df["prix_achat"].map(parse_prix)
df["cip_clean"]  = df["ref"].map(parse_cip)
Pourquoi ftfy en premier ?ftfy (Fix Text For You) est une bibliothèque spécialisée dans la réparation des encodages cassés. Elle reconnaît les patterns comme "Générique" et les reconvertit en "Générique". Il faut le faire AVANT la normalisation Unicode pour ne pas opérer sur du texte déjà cassé.
🔗Déduplication floue — rapidfuzz

Parfois deux sources disent "DOLIPRANE 500MG" et "Doliprane 500 mg" — c'est le même produit mais la comparaison stricte (==) les considère différents. La déduplication floue (fuzzy matching) mesure la similarité entre deux chaînes.

python
from rapidfuzz import fuzz, process

# Comparer deux chaînes (0 = aucune similarité, 100 = identique)
score = fuzz.ratio("DOLIPRANE 500MG", "Doliprane 500 mg")
# → 89 : très similaire

# Trouver le meilleur match dans un catalogue
catalogue = ["DOLIPRANE 500MG", "PARACETAMOL BIOGARAN", "EFFERALGAN 500MG"]
match, score, idx = process.extractOne("doliprane 500 mg", catalogue)
# → ("DOLIPRANE 500MG", 93, 0)

# Fonction utilitaire avec seuil
def find_match(query: str, catalogue: list[str], seuil=85) -> str | None:
    result = process.extractOne(query, catalogue)
    return result[0] if result and result[1] >= seuil else None
⚠️
Attention au seuil : trop bas (60) → faux positifs ("DOLIPRANE" matchera "DOLI"). Trop haut (95) → tu rates les vrais doublons. 85 est généralement un bon compromis. Ajuste selon la longueur moyenne de tes libellés.
MODULE 06 / 11

NLP
& Langage Naturel

Extraire des entités nommées (médicaments, prix, laboratoires) depuis du texte libre. spaCy pour le local, Claude API pour les cas complexes.
spaCyNERClaude APIlangdetect
🤔Qu'est-ce que le NLP appliqué au scraping ?

Jusqu'ici on a supposé que les données étaient dans des champs identifiables — un TD, un span, une colonne Excel. Mais parfois la donnée est noyée dans du texte libre. Exemple : une description produit sur un site fournisseur peut contenir "Boîte de 30 comprimés de Lévothyrox 50 µg, fabriqué par Merck, remboursable SS à 65%".

Le NLP (Natural Language Processing) te permet d'extraire automatiquement les entités structurées depuis ce texte : le médicament, le fabricant, le dosage, le taux de remboursement. C'est de la NER — Named Entity Recognition.

💡
Analogie OTC : quand un patient te décrit ses symptômes "j'ai mal à la tête depuis 2 jours, j'ai un peu de fièvre, j'ai pris du doliprane ce matin", tu extrais automatiquement : symptôme = céphalée + fièvre, durée = 2 jours, médicament pris = paracétamol. C'est de la NER humaine. spaCy automatise ça.
🔤spaCy — extraction d'entités en français
python
import spacy
# Installation : pip install spacy && python -m spacy download fr_core_news_lg

nlp = spacy.load("fr_core_news_lg")  # modèle large = meilleure précision

texte = """Lévothyrox 50 µg de Merck disponible en boîte de 30 comprimés
pour 2,69 € remboursés à 65% par l'Assurance Maladie."""

doc = nlp(texte)
for ent in doc.ents:
    print(f"{ent.text:30} → {ent.label_}")

# Résultat :
# Lévothyrox 50 µg              → PRODUCT
# Merck                         → ORG
# 2,69 €                        → MONEY
# 65%                           → PERCENT
# l'Assurance Maladie           → ORG

# Patterns custom — pour détecter les médicaments par leur structure
from spacy.matcher import Matcher
matcher = Matcher(nlp.vocab)

# Pattern : NOM_MAJUSCULES + CHIFFRE + UNITÉ (ex: "DOLIPRANE 500 mg")
pattern = [
    {"IS_UPPER": True},                          # DOLIPRANE
    {"LIKE_NUM": True},                          # 500
    {"TEXT": {"IN": ["mg", "µg", "g", "ml"]}}  # mg
]
matcher.add("MEDICAMENT", [pattern])

matches = matcher(doc)
for match_id, start, end in matches:
    print(doc[start:end])  # "DOLIPRANE 500 mg"
🤖Claude API — structuration de texte complexe

spaCy est rapide et local, mais il ne comprend pas le sens. Pour les textes complexes ou ambigus, Claude API donne des résultats incomparables — il comprend le contexte, les acronymes métier, les abréviations officinales.

Le pattern clé : demander une réponse en JSON strict pour pouvoir parser le résultat directement.

python
import anthropic, json

client = anthropic.Anthropic()

def structurer_fiche_produit(texte_brut: str) -> dict:
    """Extrait une fiche produit structurée depuis du texte libre."""
    resp = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=600,
        system="""Tu es un expert en données pharmaceutiques.
Extrais les informations du texte et réponds UNIQUEMENT en JSON valide.
Aucun texte avant ou après le JSON. Aucun code block markdown.""",
        messages=[{
            "role": "user",
            "content": f"""Extrais depuis ce texte :
cip13, nom_commercial, dci, laboratoire, dosage, forme,
conditionnement, prix_ht, tva_pct, remboursement_pct.
Utilise null si l'information est absente.

Texte : {texte_brut}"""
        }]
    )
    return json.loads(resp.content[0].text)
Pourquoi "UNIQUEMENT en JSON" dans le system prompt ?Par défaut, Claude peut répondre "Voici ce que j'ai extrait : ```json...```". Le json.loads() plante alors à cause du texte et des backticks. En insistant sur "UNIQUEMENT", on force une réponse directement parsable.
🎯
Combo optimal : spaCy local pour 95% des cas (rapide, gratuit, hors-ligne) → Claude API uniquement pour les cas ambigus ou les textes non structurés complexes. Budget API réduit par 20.
MODULE 07 / 11

Formulaires
& Authentification

Comprendre le flux d'authentification HTTP pour automatiser les logins, gérer les sessions, et contourner les protections CSRF.
requests.SessionCSRFcookiesPlaywright
🤔Comment fonctionne un login HTTP ?

Quand tu te connectes à HubPharma dans ton navigateur, voici ce qui se passe exactement :

  • GET /login → le serveur te renvoie la page de login avec un champ caché : le token CSRF
  • Tu remplis email + password + le token CSRF est inclus automatiquement
  • POST /login → le serveur vérifie les credentials ET le token CSRF
  • Si correct → le serveur renvoie un cookie de session dans l'en-tête HTTP
  • Toutes les requêtes suivantes incluent ce cookie → le serveur sait que c'est toi

Le token CSRF (Cross-Site Request Forgery) est un token aléatoire généré à chaque chargement de la page de login. Il empêche une autre page de soumettre le formulaire à ta place. Si tu fais un POST direct sans d'abord charger la page et récupérer le token, la requête sera rejetée.

⚠️
Erreur classique : faire directement un POST /login sans d'abord faire GET /login pour récupérer le token CSRF → réponse 403 Forbidden. Toujours charger la page de login avant de poster.
🔐Session HTTP avec gestion CSRF

requests.Session maintient automatiquement les cookies entre les requêtes — exactement comme un navigateur. C'est l'objet central pour scraper des sites authentifiés.

python
import requests
from bs4 import BeautifulSoup

# Session = même que ton navigateur : garde les cookies entre les requêtes
session = requests.Session()

# Headers réalistes — sans ça, certains sites bloquent
session.headers.update({
    "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Safari/537.36",
    "Accept-Language": "fr-FR,fr;q=0.9",
    "Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
})

# ÉTAPE 1 — Charger la page de login pour récupérer le token CSRF
login_page = session.get("https://portail.grossiste.fr/login")
soup = BeautifulSoup(login_page.text, "lxml")

# Le token CSRF est dans un champ input caché
csrf = soup.select_one("input[name='_token']")
if not csrf:
    # Parfois il s'appelle différemment selon le framework
    csrf = soup.select_one("input[name='csrf_token'], input[name='authenticity_token']")
csrf_value = csrf["value"]

# ÉTAPE 2 — POST avec les credentials + le token CSRF
resp = session.post("https://portail.grossiste.fr/login", data={
    "_token": csrf_value,
    "email": "pharmacie@theatres.fr",
    "password": "***",
    "remember": "1",
})

# ÉTAPE 3 — Vérifier le succès du login
if "tableau de bord" not in resp.text.lower():
    raise Exception("Login échoué — vérifier les credentials ou le token CSRF")

# ÉTAPE 4 — Scraper les pages protégées (cookies gérés automatiquement)
factures = session.get("https://portail.grossiste.fr/factures")
commandes = session.get("https://portail.grossiste.fr/commandes?page=2")
🎭Playwright — pour les logins JavaScript (SSO, Keycloak)

La méthode requests fonctionne pour les logins simples. Mais HubPharma et DigiPharmacie utilisent Keycloak — un système SSO (Single Sign-On) où la page de login est entièrement en JavaScript. Il n'y a aucun token CSRF dans le HTML, pas de formulaire HTML classique — le tout est géré par des callbacks JavaScript.

Dans ce cas, la seule approche fiable est de piloter un vrai navigateur avec Playwright. Playwright contrôle Chrome de façon programmatique — exactement comme si tu cliquais toi-même.

python
from playwright.sync_api import sync_playwright
import json

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)  # False = voir le navigateur
    ctx = browser.new_context(
        viewport={"width": 1280, "height": 800},
        locale="fr-FR",
    )
    page = ctx.new_page()

    page.goto("https://portail.fr/login")
    page.wait_for_selector("#email")  # attendre que le form JS soit chargé

    page.fill("#email", "pharmacie@theatres.fr")
    page.fill("#password", "mon_mot_de_passe")
    page.click("button[type='submit']")

    # Attendre la redirection post-login
    page.wait_for_url("**/dashboard", timeout=10000)

    # Sauvegarder la session (cookies + localStorage) pour réutilisation
    storage = ctx.storage_state()
    open("session_hubpharma.json", "w").write(json.dumps(storage))

    # Scraper le contenu authentifié
    page.goto("https://portail.fr/catalogue")
    html = page.content()  # le HTML complet après rendu JS

# Relancer sans re-login grâce à la session sauvegardée
ctx2 = browser.new_context(storage_state="session_hubpharma.json")
storage_state() — c'est quoi exactement ?C'est un fichier JSON qui contient tous les cookies et données de session du navigateur — exactement ce que ton Chrome stocke sur ton disque pour que tu restes connecté. En le réutilisant dans un nouveau contexte Playwright, tu sautes l'étape de login. La session dure généralement plusieurs heures.
💊
HubPharma & DigiPharmacie : ces portails grossistes utilisent Keycloak SSO. La page de login ne contient aucun <form> HTML classique — tout est géré par du JavaScript. Playwright est obligatoire. Le skill scraping-pharma-platforms contient les sélecteurs précis pour chaque portail.
MODULE 08 / 11

JavaScript
& APIs

Comprendre pourquoi les sites JavaScript sont vides sans un vrai navigateur, et comment intercepter les appels API directement — la technique la plus puissante du scraping moderne.
PlaywrightXHR interceptRESTGraphQL
🤔Pourquoi requests ne voit pas les données d'une SPA ?

Les applications web modernes (React, Vue, Angular) fonctionnent différemment des sites classiques. Quand tu fais requests.get("https://app-moderne.fr/catalogue"), tu reçois un fichier HTML qui ressemble à ça :

html
<!-- Ce que requests voit d'une SPA React -->
<html>
<body>
  <div id="root"></div>  <!-- vide ! les données arrivent après -->
  <script src="/bundle.js"></script>  <!-- le JS qui va tout charger -->
</body>
</html>

Dès que le navigateur reçoit ce HTML, il exécute bundle.js qui fait lui-même des appels XHR/fetch vers une API pour récupérer les données, puis "peint" le contenu dans le <div id="root">. requests, lui, s'arrête à la réception du HTML initial — il ne voit jamais les données.

💡
Le réflexe DevTools : avant d'écrire une seule ligne de code, ouvre la page dans Chrome → F12 → onglet Network → filtre XHR/Fetch → recharge la page. Tu vois exactement quelles APIs sont appelées, avec quels paramètres. C'est souvent bien plus simple que de scraper le HTML rendu.
📡Intercepter les appels réseau avec Playwright

Playwright peut s'abonner à tous les événements réseau de la page — comme DevTools mais en Python. Tu récupères les réponses JSON directement, avant même que le JavaScript ne les affiche.

python
from playwright.sync_api import sync_playwright

api_data = []  # stocke toutes les réponses API interceptées

def on_response(response):
    # Filtrer : on veut uniquement les appels /api/ qui retournent du JSON
    if "/api/" in response.url and response.status == 200:
        try:
            data = response.json()
            print(f"API interceptée : {response.url}")
            api_data.append({"url": response.url, "data": data})
        except: pass  # ignorer les réponses non-JSON

with sync_playwright() as p:
    page = p.chromium.launch().new_page()
    page.on("response", on_response)  # s'abonner aux réponses
    page.goto("https://app.exemple.fr/catalogue")
    page.wait_for_load_state("networkidle")  # attendre qu'il n'y ait plus de requêtes

# api_data contient maintenant les JSON bruts — sans avoir eu besoin de parser le HTML
print(f"{len(api_data)} appels API interceptés")
🔌Appel API direct — REST et GraphQL

Une fois que tu as identifié l'API (via DevTools ou l'interception Playwright), tu peux souvent l'appeler directement sans passer par le navigateur. C'est 100x plus rapide et fiable que le scraping HTML.

python
import httpx  # pip install httpx — comme requests mais async-capable

# Le token Bearer vient du localStorage du navigateur (DevTools → Application → Local Storage)
headers = {
    "Authorization": f"Bearer {token}",
    "Accept": "application/json",
    "X-Requested-With": "XMLHttpRequest",  # certaines APIs le vérifient
}

# Pagination REST — récupère toutes les pages
with httpx.Client(headers=headers, timeout=30) as client:
    all_products = []
    page = 1
    while True:
        r = client.get(f"https://api.grossiste.fr/v1/products?page={page}&per_page=100")
        r.raise_for_status()
        data = r.json()
        if not data.get("items"): break
        all_products.extend(data["items"])
        page += 1

# GraphQL — une seule requête pour des données complexes
query = """
query GetProduits($labo: String!) {
  produits(laboratoire: $labo, limit: 100) {
    cip13 nom dci { libelle }
    prix { ht tva ttc }
    disponibilite stock
  }
}
"""
r = httpx.post(
    "https://api.grossiste.fr/graphql",
    json={"query": query, "variables": {"labo": "SANOFI"}},
    headers=headers
)
produits = r.json()["data"]["produits"]
🎯
Règle d'or du scraping : toujours chercher l'API avant de scraper le HTML. Une API retourne des données propres et structurées, sans parsing ni nettoyage. Elle change moins souvent que le HTML. Et elle est beaucoup plus difficile à détecter comme du scraping.
MODULE 09 / 11

OCR
Image vers Texte

Comprendre le pipeline complet : pourquoi l'image brute donne de mauvais résultats, comment la préparer, et quand choisir Tesseract vs Claude Vision.
pytesseractOpenCVClaude VisionPillow
🤔Comment fonctionne la reconnaissance de texte ?

L'OCR (Optical Character Recognition) transforme une image de texte en texte machine. Mais contrairement à ce qu'on pourrait croire, ce n'est pas magique. La précision dépend énormément de la qualité de l'image fournie.

Tesseract, le moteur OCR de référence (développé par Google), fonctionne en plusieurs passes : il détecte les zones de texte, segmente les lignes, puis reconnaît chaque caractère. Chaque étape peut échouer si l'image est floue, tordue, ou a des fonds trop chargés.

Image idéale
Fond blanc uni, texte noir, résolution ≥ 300 DPI, alignement droit, bonne luminosité. Précision ≥ 98%.
Image problématique
Photo prise à la main (flou, biais), fond coloré, timbre sur le texte, résolution < 150 DPI. Précision peut tomber à 60%.

Le prétraitement est 80% du travail OCR. Avant de donner l'image à Tesseract, on la prépare : conversion en niveaux de gris, augmentation du contraste, seuillage (binarisation), défloutage.

👁️Pipeline Tesseract avec prétraitement OpenCV
python
import pytesseract, cv2
from PIL import Image
import numpy as np

def preprocess_for_ocr(image_path: str) -> Image:
    """Pipeline de prétraitement pour améliorer la précision OCR."""

    # Lire l'image (BGR = format OpenCV)
    img = cv2.imread(image_path)

    # 1. Niveaux de gris — simplifie le problème
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

    # 2. Redimensionner si trop petite (OCR aime 300+ DPI)
    h, w = gray.shape
    if h < 1000:
        gray = cv2.resize(gray, (w*2, h*2), interpolation=cv2.INTER_CUBIC)

    # 3. Léger flou gaussien pour réduire le bruit de l'image
    blur = cv2.GaussianBlur(gray, (3, 3), 0)

    # 4. Seuillage adaptatif = noir ou blanc selon le contexte local
    # Plus robuste qu'un seuil global pour les irrégularités de lumière
    thresh = cv2.adaptiveThreshold(
        blur, 255,
        cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
        cv2.THRESH_BINARY,
        11, 2  # blockSize=11, constante=2
    )

    return Image.fromarray(thresh)


def ocr_document(image_path: str) -> str:
    img = preprocess_for_ocr(image_path)
    # --oem 3 : moteur LSTM (meilleur, plus lent)
    # --psm 6 : supposer un bloc de texte uniforme
    # -l fra  : modèle de langue français
    config = "--oem 3 --psm 6 -l fra"
    return pytesseract.image_to_string(img, config=config)
Les modes PSM de TesseractPSM = Page Segmentation Mode. PSM 6 (bloc de texte) est le bon choix pour une facture. PSM 11 (texte épars) marche mieux pour des étiquettes. PSM 3 (auto) laisse Tesseract décider — à utiliser si tu ne sais pas.
🚀Claude Vision — quand Tesseract ne suffit pas

Tesseract est gratuit et local mais a ses limites : il rate souvent les tableaux complexes, les textes avec fond coloré, les polices inhabituelles, et le mélange de langues. Pour les BL (Bons de Livraison) scannés ou les photos de factures prises à la main, Claude Vision donne des résultats incomparablement supérieurs.

L'avantage majeur : Claude ne fait pas que lire le texte — il comprend la structure. Il sait que le tableau a des colonnes "CIP / Désignation / Quantité / Prix", même si les bordures sont floues.

python
import anthropic, base64, json
from pathlib import Path

def ocr_facture_claude(image_path: str) -> dict:
    """Extraction structurée d'une facture via Claude Vision.
    Retourne un dict avec les données clés de la facture."""

    # Lire et encoder l'image en base64
    img_bytes = Path(image_path).read_bytes()
    img_b64 = base64.b64encode(img_bytes).decode()

    # Détecter le type MIME (jpeg ou png)
    mime = "image/jpeg" if image_path.lower().endswith((".jpg", ".jpeg")) else "image/png"

    client = anthropic.Anthropic()
    resp = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2000,
        system="Tu extrais les données de documents pharmaceutiques. Réponds UNIQUEMENT en JSON valide.",
        messages=[{"role": "user", "content": [
            {
                "type": "image",
                "source": {"type": "base64", "media_type": mime, "data": img_b64}
            },
            {
                "type": "text",
                "text": """Extrais toutes les données de cette facture pharmacie.
Structure attendue :
{
  "numero": "FAC-2024-001234",
  "date": "2024-01-15",
  "fournisseur": "Alliance Healthcare",
  "total_ht": 1234.56,
  "total_ttc": 1320.45,
  "lignes": [
    {"cip13": "3400935418487", "designation": "DOLIPRANE 500MG", "quantite": 10, "pu_ht": 1.42, "total_ht": 14.20}
  ]
}"""
            }
        ]}]
    )
    return json.loads(resp.content[0].text)
💊
Stratégie Bunka NEV : pdfplumber pour toutes les factures Alliance/OCP en PDF natif (gratuit, instantané, précis). Claude Vision uniquement pour les BL scannés ou photos (~0.01€/image). Sur 500 factures par mois, le coût Vision reste sous 5€.
MODULE 10 / 11

Anti-bots
& Pièges

Comment Cloudflare et les systèmes anti-bot détectent les scrapers. Comprendre la détection pour mieux s'y adapter.
CloudflareFingerprintProxiesStealth
🤔Comment Cloudflare sait que tu es un bot ?

Les systèmes anti-bot modernes ne regardent pas juste le User-Agent. Ils construisent une empreinte comportementale et technique qui combine des dizaines de signaux :

  • TLS Fingerprint : la façon dont Python's requests négocie une connexion HTTPS est différente de Chrome. Cloudflare reconnaît la bibliothèque SSL utilisée.
  • JavaScript Fingerprint : quand tu exécutes navigator.webdriver dans Chrome, c'est undefined. Dans Playwright non configuré, c'est true. C'est le premier test.
  • Canvas Fingerprint : le rendu GPU d'un vrai navigateur est légèrement différent de celui d'un headless. Les systèmes avancés le mesurent.
  • Timing des requêtes : un humain attend 2-5 secondes entre les pages. Un script attend 0.1ms. Trop régulier ou trop rapide = bot.
  • Mouse movements : un vrai utilisateur bouge la souris avant de cliquer. Un Playwright standard clique directement à la coordonnée.
  • IP Reputation : les IPs des datacenters AWS/GCP/Azure sont connues. Elles déclenche immédiatement Cloudflare. Les IPs résidentielles passent mieux.
💡
Principe clé : l'objectif n'est pas d'être indétectable — c'est de ne pas être le plus facilement détectable. Si ton scraper ressemble à 99% des bots qu'ils voient, tu seras bloqué. Si tu ressembles à un utilisateur légèrement maladroit, tu passes.
🛡️Tableau des mécanismes et contre-mesures
MécanismeCe qu'il détecteContre-mesure
User-Agent checkUA de requests ("python-requests/2.x") ou Playwright par défautUA réaliste Chrome Mac + rotation
Rate limitingPlus de N requêtes/minute depuis la même IPDOWNLOAD_DELAY 1-3s + autothrottle
Honeypot linksLiens CSS hidden cliqués (aucun humain ne les voit)Vérifier bounding_box() avant de cliquer
webdriver detectionnavigator.webdriver === true en JSplaywright-stealth injecte le patch
Canvas/WebGL fingerprintRendu headless détectableplaywright-stealth + args Chrome
Cloudflare Bot ScoreScore composite de 0 à 100Stealth + délais humains + IP résidentielle
IP blacklistIP datacenter ou connue comme botProxies résidentiels (Brightdata, Oxylabs)
🥷Playwright Stealth + comportement humain
python
from playwright.sync_api import sync_playwright
from playwright_stealth import stealth_sync  # pip install playwright-stealth
import time, random

with sync_playwright() as p:
    browser = p.chromium.launch(
        headless=True,
        args=[
            "--disable-blink-features=AutomationControlled",
            "--no-sandbox",
        ]
    )
    ctx = browser.new_context(
        viewport={"width": 1366, "height": 768},   # résolution commune
        locale="fr-FR",
        timezone_id="Europe/Paris",
        user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
    )
    page = ctx.new_page()
    stealth_sync(page)  # patch les 17 détections JS connues

    # Comportement humain — INDISPENSABLE
    def human_pause(min_s=1.0, max_s=3.5):
        time.sleep(random.uniform(min_s, max_s))

    def human_scroll(page):
        # Scroll progressif avec vitesse variable
        for _ in range(random.randint(2, 5)):
            page.mouse.wheel(0, random.randint(150, 500))
            time.sleep(random.uniform(0.3, 1.2))

    def safe_click(page, selector):
        # Vérifier que l'élément est visible avant de cliquer
        # Évite de cliquer sur des honeypots invisibles
        el = page.locator(selector).first
        box = el.bounding_box()
        if box and box["width"] > 0 and box["height"] > 0:
            el.click()

    # Usage
    page.goto("https://portail.fr/catalogue")
    human_pause()
    human_scroll(page)
    human_pause(0.5, 1.5)
    safe_click(page, "button.load-more")
🔄Retry robuste avec backoff exponentiel

Même avec toutes ces précautions, des erreurs arrivent : timeouts, erreurs 429 (trop de requêtes), ou Cloudflare challenge. Le retry avec backoff exponentiel relance automatiquement en attendant de plus en plus longtemps entre les tentatives.

python
import tenacity  # pip install tenacity

@tenacity.retry(
    wait=tenacity.wait_exponential(multiplier=2, min=2, max=60),
    # attente : 2s, 4s, 8s, 16s, 32s, 60s (plafond)
    stop=tenacity.stop_after_attempt(5),
    retry=tenacity.retry_if_exception_type(Exception),
    before_sleep=lambda retry_state: print(f"Retry #{retry_state.attempt_number}...")
)
def fetch_page(session, url: str) -> str:
    r = session.get(url, timeout=15)
    r.raise_for_status()
    # Détecter un Cloudflare challenge (page vide avec challenge JS)
    if "cf-browser-verification" in r.text or "Just a moment" in r.text:
        raise Exception("Cloudflare challenge détecté → retry avec délai")
    return r.text
MODULE 11 / 11

Tester
son Site

Le scraping et les tests E2E utilisent exactement les mêmes outils. Comment utiliser Playwright pour vérifier que ton app fonctionne comme prévu — automatiquement.
pytestPlaywright TestE2Eassertions
🤔Pourquoi tester son site avec les mêmes outils que le scraping ?

Il y a une convergence naturelle : scraper un site et tester un site, c'est fondamentalement la même chose — naviguer dans une page et vérifier ce qu'elle contient. La différence, c'est l'intention : on scrape pour extraire des données d'un site tiers, on teste pour vérifier que notre propre site se comporte correctement.

Pour les apps EtikPharma déployées sur Netlify (RelaisbyEtikPharma, FaceAuCommercial, PlanningByEtikPharma), une suite de tests Playwright permet de vérifier automatiquement après chaque déploiement que :

  • La page se charge sans erreur
  • Les données Supabase/Firebase s'affichent correctement
  • Les formulaires fonctionnent
  • Les recherches CIP retournent les bons produits
  • Les fonctions Netlify (proxy API Claude) répondent correctement
Tests E2E avec pytest-playwright
python
# pip install pytest pytest-playwright
# pytest --headed tests/  (--headed = voir le navigateur)

from playwright.sync_api import Page, expect
import pytest

BASE_URL = "https://faceaucommercial-etikpharma.netlify.app"

def test_page_charge(page: Page):
    """Vérifie que la page principale se charge sans erreur."""
    page.goto(BASE_URL)
    # expect() = assertion Playwright, meilleur que assert car il attend
    expect(page).to_have_title("FaceAuCommercial")
    expect(page.locator(".product-grid")).to_be_visible()

def test_recherche_cip13(page: Page):
    """Vérifie que la recherche CIP retourne le bon produit."""
    page.goto(BASE_URL)
    page.fill("#search-input", "3400935418487")
    page.press("#search-input", "Enter")
    # Attendre que le résultat soit visible (requête Supabase asynchrone)
    expect(page.locator(".result-name")).to_contain_text("DOLIPRANE", timeout=5000)

def test_tous_les_prix_positifs(page: Page):
    """Vérifie qu'aucun prix n'est à 0 ou négatif dans le catalogue."""
    page.goto(f"{BASE_URL}/catalogue")
    expect(page.locator(".price-cell")).to_have_count(min=1)

    for cell in page.locator(".price-cell").all():
        text = cell.text_content().replace("€", "").replace(",", ".").strip()
        try:
            assert float(text) > 0, f"Prix invalide : {text}"
        except ValueError:
            pass  # ignorer les cellules non-numériques

def test_proxy_claude_repond(page: Page):
    """Vérifie que la Netlify Function proxy-claude répond."""
    resp = page.request.post(
        f"{BASE_URL}/.netlify/functions/claude-proxy",
        data=json.dumps({"prompt": "test"})
    )
    assert resp.status == 200
expect() vs assertLa différence est cruciale. assert page.locator(".result").text_content() == "DOLIPRANE" échoue immédiatement si l'élément n'est pas encore là (requête asynchrone en cours). expect(...).to_contain_text() attend automatiquement jusqu'à 30 secondes que la condition soit vraie.
📋Cheatsheet — quel outil pour quel cas ?
SituationOutilPourquoi
HTML statique simplerequests + BeautifulSoupLéger, rapide, pas de navigateur
Site entier / paginationScrapyAsync, gestion des erreurs, pipelines
SPA JavaScriptPlaywrightExécute le JS et rend le DOM
Login SSO / KeycloakPlaywright + stealthNavigateur réel pour les flux OAuth
API REST / GraphQL détectéehttpx directPlus rapide, stable, indétectable
PDF natif (factures)pdfplumberDétecte les tableaux, rapide, gratuit
PDF scanné / photo BLClaude VisionComprend la structure, ~0.01€/image
Excel / CSV mal formatépandas + regexLecture flexible des exports LGO
Texte libre → entitésspaCy + Claude APIspaCy local pour 95%, Claude pour le reste
Tests E2E webapp Netlifypytest + PlaywrightMême outil, même API que le scraping
🎯
Règle d'or : avant d'écrire une ligne de code, ouvre DevTools → Network → cherche une requête XHR/Fetch JSON. Si tu en trouves une, tu n'as probablement pas besoin de scraper le HTML — appelle l'API directement. C'est 10x plus simple.