Web Scraping
de Zéro à Expert
Parser du HTML
Complexe
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.
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.
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 :
pip install lxmlpip install requests beautifulsoup4 lxml pip install httpx playwright playwright install chromium # navigateur headless pour le JS
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.
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
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.
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]")
Utilitaire universel — tableau vers liste de dicts
Ce pattern revient dans presque tous les scrapers de catalogues. Gardez-le précieusement :
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'}, ...]
<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.Scrapy :
Crawlers Professionnels
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.
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 :
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
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.
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)
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.urljoin les convertit automatiquement en URL absolue ("https://site.fr/produit/123"). Toujours utiliser urljoin.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.
# 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}
Stocker
les Données Scrapées
Le choix dépend de deux questions : qui va lire les données ? et à quelle fréquence elles sont mises à jour ?
| Backend | Usage idéal | Limites |
|---|---|---|
| CSV | Export ponctuel, partage avec Excel, analyse one-shot | Pas de requêtes, pas de mises à jour partielles |
| SQLite | Dev local, prototype, script perso sans serveur | Fichier local uniquement, pas de multi-utilisateurs |
| PostgreSQL | Production, données critiques, requêtes complexes | Infrastructure à gérer |
| Supabase | Stack EtikPharma — PostgreSQL managé + API REST auto | Dépendance externe, coût à l'échelle |
| Firebase Firestore | Sync temps réel, apps mobiles, React | NoSQL = pas de JOINs, schéma implicite |
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.
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()
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.
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)
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.Extraire
de Documents
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 :
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.
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
extract_tables(). Le total TTC est dans le bloc texte en bas de page — une regex suffit. Zéro OCR nécessaire.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.
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")
Nettoyer
& Normaliser
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.
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)
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.
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
NLP
& Langage Naturel
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.
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"
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.
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)
json.loads() plante alors à cause du texte et des backticks. En insistant sur "UNIQUEMENT", on force une réponse directement parsable.Formulaires
& Authentification
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.
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.
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")
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.
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")
<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.JavaScript
& APIs
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 :
<!-- 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.
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.
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")
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.
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"]
OCR
Image vers 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.
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.
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)
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.
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)
Anti-bots
& Pièges
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.webdriverdans Chrome, c'estundefined. Dans Playwright non configuré, c'esttrue. 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.
| Mécanisme | Ce qu'il détecte | Contre-mesure |
|---|---|---|
| User-Agent check | UA de requests ("python-requests/2.x") ou Playwright par défaut | UA réaliste Chrome Mac + rotation |
| Rate limiting | Plus de N requêtes/minute depuis la même IP | DOWNLOAD_DELAY 1-3s + autothrottle |
| Honeypot links | Liens CSS hidden cliqués (aucun humain ne les voit) | Vérifier bounding_box() avant de cliquer |
| webdriver detection | navigator.webdriver === true en JS | playwright-stealth injecte le patch |
| Canvas/WebGL fingerprint | Rendu headless détectable | playwright-stealth + args Chrome |
| Cloudflare Bot Score | Score composite de 0 à 100 | Stealth + délais humains + IP résidentielle |
| IP blacklist | IP datacenter ou connue comme bot | Proxies résidentiels (Brightdata, Oxylabs) |
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")
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.
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
Web Scraping
de Zéro à Expert
Parser du HTML
Complexe
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.
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.
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 :
pip install lxmlpip install requests beautifulsoup4 lxml pip install httpx playwright playwright install chromium # navigateur headless pour le JS
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.
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
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.
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]")
Utilitaire universel — tableau vers liste de dicts
Ce pattern revient dans presque tous les scrapers de catalogues. Gardez-le précieusement :
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'}, ...]
<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.Scrapy :
Crawlers Professionnels
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.
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 :
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
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.
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)
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.urljoin les convertit automatiquement en URL absolue ("https://site.fr/produit/123"). Toujours utiliser urljoin.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.
# 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}
Stocker
les Données Scrapées
Le choix dépend de deux questions : qui va lire les données ? et à quelle fréquence elles sont mises à jour ?
| Backend | Usage idéal | Limites |
|---|---|---|
| CSV | Export ponctuel, partage avec Excel, analyse one-shot | Pas de requêtes, pas de mises à jour partielles |
| SQLite | Dev local, prototype, script perso sans serveur | Fichier local uniquement, pas de multi-utilisateurs |
| PostgreSQL | Production, données critiques, requêtes complexes | Infrastructure à gérer |
| Supabase | Stack EtikPharma — PostgreSQL managé + API REST auto | Dépendance externe, coût à l'échelle |
| Firebase Firestore | Sync temps réel, apps mobiles, React | NoSQL = pas de JOINs, schéma implicite |
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.
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()
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.
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)
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.Extraire
de Documents
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 :
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.
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
extract_tables(). Le total TTC est dans le bloc texte en bas de page — une regex suffit. Zéro OCR nécessaire.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.
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")
Nettoyer
& Normaliser
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.
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)
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.
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
NLP
& Langage Naturel
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.
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"
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.
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)
json.loads() plante alors à cause du texte et des backticks. En insistant sur "UNIQUEMENT", on force une réponse directement parsable.Formulaires
& Authentification
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.
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.
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")
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.
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")
<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.JavaScript
& APIs
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 :
<!-- 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.
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.
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")
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.
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"]
OCR
Image vers 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.
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.
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)
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.
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)
Anti-bots
& Pièges
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.webdriverdans Chrome, c'estundefined. Dans Playwright non configuré, c'esttrue. 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.
| Mécanisme | Ce qu'il détecte | Contre-mesure |
|---|---|---|
| User-Agent check | UA de requests ("python-requests/2.x") ou Playwright par défaut | UA réaliste Chrome Mac + rotation |
| Rate limiting | Plus de N requêtes/minute depuis la même IP | DOWNLOAD_DELAY 1-3s + autothrottle |
| Honeypot links | Liens CSS hidden cliqués (aucun humain ne les voit) | Vérifier bounding_box() avant de cliquer |
| webdriver detection | navigator.webdriver === true en JS | playwright-stealth injecte le patch |
| Canvas/WebGL fingerprint | Rendu headless détectable | playwright-stealth + args Chrome |
| Cloudflare Bot Score | Score composite de 0 à 100 | Stealth + délais humains + IP résidentielle |
| IP blacklist | IP datacenter ou connue comme bot | Proxies résidentiels (Brightdata, Oxylabs) |
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")
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.
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
Tester
son Site
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
# 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
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.| Situation | Outil | Pourquoi |
|---|---|---|
| HTML statique simple | requests + BeautifulSoup | Léger, rapide, pas de navigateur |
| Site entier / pagination | Scrapy | Async, gestion des erreurs, pipelines |
| SPA JavaScript | Playwright | Exécute le JS et rend le DOM |
| Login SSO / Keycloak | Playwright + stealth | Navigateur réel pour les flux OAuth |
| API REST / GraphQL détectée | httpx direct | Plus rapide, stable, indétectable |
| PDF natif (factures) | pdfplumber | Détecte les tableaux, rapide, gratuit |
| PDF scanné / photo BL | Claude Vision | Comprend la structure, ~0.01€/image |
| Excel / CSV mal formaté | pandas + regex | Lecture flexible des exports LGO |
| Texte libre → entités | spaCy + Claude API | spaCy local pour 95%, Claude pour le reste |
| Tests E2E webapp Netlify | pytest + Playwright | Même outil, même API que le scraping |