Playwright comme outil de scraping métier : au-delà des tests E2E

La plupart des articles sur Playwright parlent de tests E2E. C'est son usage le plus visible, mais pas le seul. Quand un portail métier n'expose pas d'API — ou que celle-ci est incomplète, mal documentée, ou réservée à des partenaires — Playwright devient un outil d'automatisation de premier ordre. Voici comment l'utiliser sérieusement, hors contexte de test.

Le cas concret : un portail sans API exploitable

Certains portails métier exposent une interface web riche mais une API limitée ou absente. L'extraction de données, les exports, la soumission de formulaires — tout passe par le navigateur. BeautifulSoup et requests s'arrêtent là : ils ne gèrent pas JavaScript, les SPAs, ni les flux d'authentification complexes (MFA, redirections OAuth2).

Playwright gère tout ça nativement.

Architecture du scraper

L'objectif est un scraper qui s'authentifie de façon fiable, navigue et extrait des données structurées, est relançable sans intervention humaine, et s'exécute en environnement conteneurisé.

from playwright.async_api import async_playwright, Browser, Page
from dataclasses import dataclass

@dataclass
class ScraperConfig:
    base_url: str
    username: str
    password: str
    headless: bool = True
    timeout: int = 30_000  # ms

class MetierScraper:
    def __init__(self, config: ScraperConfig):
        self.config = config
        self._browser: Browser | None = None
        self._page: Page | None = None

    async def __aenter__(self):
        self._playwright = await async_playwright().start()
        self._browser = await self._playwright.chromium.launch(
            headless=self.config.headless,
            args=["--no-sandbox", "--disable-dev-shm-usage"]  # Requis en Docker
        )
        context = await self._browser.new_context(
            viewport={"width": 1280, "height": 800},
            locale="fr-FR"
        )
        self._page = await context.new_page()
        return self

    async def __aexit__(self, *args):
        await self._browser.close()
        await self._playwright.stop()

Le context manager garantit la fermeture propre du navigateur même en cas d'exception — indispensable en production.

Authentification robuste

L'authentification est la partie la plus fragile d'un scraper. Les portails changent leur UI, ajoutent des étapes de sécurité, ou introduisent des délais. Quelques principes pour la rendre solide :

async def login(self) -> bool:
    page = self._page
    await page.goto(f"{self.config.base_url}/login", wait_until="networkidle")

    # Attendre l'élément précis, pas juste le chargement de la page
    await page.wait_for_selector("#username", state="visible", timeout=10_000)
    await page.fill("#username", self.config.username)
    await page.fill("#password", self.config.password)

    # Intercepter la réponse de login pour détecter les erreurs d'auth
    async with page.expect_response(
        lambda r: "/api/auth" in r.url and r.status in (200, 401, 403)
    ) as response_info:
        await page.click('[type="submit"]')

    response = await response_info.value
    if response.status != 200:
        raise AuthenticationError(f"Login échoué : HTTP {response.status}")

    await page.wait_for_url(f"{self.config.base_url}/dashboard", timeout=15_000)
    return True

L'interception de réponse réseau (expect_response) est plus fiable qu'attendre un sélecteur CSS après le clic — elle détecte les erreurs d'authentification sans dépendre de la mise en forme du message d'erreur affiché.

Extraction de données structurées

Une fois authentifié, l'extraction doit être déterministe. Playwright permet de combiner navigation DOM et interception réseau selon ce qui est le plus stable :

async def extract_certificates(self, period: str) -> list[dict]:
    page = self._page
    await page.goto(
        f"{self.config.base_url}/certificates?period={period}",
        wait_until="networkidle"
    )

    # Stratégie 1 : intercepter l'appel API sous-jacent si disponible
    async with page.expect_response(
        lambda r: "/api/certificates" in r.url
    ) as api_response:
        await page.click("#load-certificates")

    data = await (await api_response.value).json()
    return data.get("items", [])

async def extract_table_data(self) -> list[dict]:
    """Stratégie 2 : extraire directement depuis le DOM."""
    rows = await self._page.query_selector_all("table.data-grid tbody tr")
    results = []

    for row in rows:
        cells = await row.query_selector_all("td")
        values = [await cell.inner_text() for cell in cells]
        results.append({
            "id": values[0].strip(),
            "date": values[1].strip(),
            "volume": float(values[2].replace(" ", "").replace(",", ".")),
            "status": values[3].strip(),
        })

    return results

La stratégie 1 (interception réseau) est préférable quand elle est disponible : les données JSON brutes sont plus propres et moins sensibles aux changements de mise en page. La stratégie 2 (extraction DOM) est le fallback universel.

Gestion des exports fichiers

Beaucoup de portails proposent des exports Excel ou CSV via un bouton de téléchargement. Playwright gère ça nativement :

async def download_export(self, output_path: str) -> str:
    async with self._page.expect_download() as download_info:
        await self._page.click("#export-button")

    download = await download_info.value

    if download.failure():
        raise ExportError(f"Téléchargement échoué : {download.failure()}")

    await download.save_as(output_path)
    return output_path

Exécution en Docker / OpenShift

Playwright en conteneur nécessite les dépendances système de Chromium :

FROM python:3.12-slim

RUN apt-get update && apt-get install -y \
    libnss3 libatk1.0-0 libatk-bridge2.0-0 \
    libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
    libxdamage1 libxfixes3 libxrandr2 libgbm1 \
    libasound2 libpango-1.0-0 libcairo2 \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

RUN chown -R 1001:0 /app && chmod -R g=u /app

COPY --chown=1001:0 requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt && playwright install chromium

COPY --chown=1001:0 . .

USER 1001

CMD ["python", "scraper.py"]

Sur OpenShift, --no-sandbox est obligatoire : les conteneurs ne disposent pas des privilèges nécessaires au sandbox Chromium. Ce n'est pas un risque dans ce contexte — le sandbox protège contre du contenu web malveillant, ce qui ne s'applique pas à un scraper ciblant un portail interne connu.

Orchestration avec un CronJob Kubernetes

apiVersion: batch/v1
kind: CronJob
metadata:
  name: metier-scraper
spec:
  schedule: "0 6 * * 1-5"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: scraper
              image: registry.internal/metier-scraper:latest
              env:
                - name: SCRAPER_USERNAME
                  valueFrom:
                    secretKeyRef:
                      name: scraper-credentials
                      key: username
                - name: SCRAPER_PASSWORD
                  valueFrom:
                    secretKeyRef:
                      name: scraper-credentials
                      key: password
          restartPolicy: OnFailure

concurrencyPolicy: Forbid est critique : si une exécution prend plus longtemps que prévu, on ne veut pas deux scrapers qui s'authentifient simultanément avec le même compte.

Ce que Playwright apporte par rapport aux alternatives

Critèrerequests + BS4SeleniumPlaywright
SPAs / JavaScriptNonOuiOui
Interception réseauNonPartielNatif
Async natifNonNonOui
Stabilité en CI/CDBonneFragileBonne
Support DockerSimpleComplexeRaisonnable
API moderneNonNonOui

Sur des sites statiques simples, requests + BeautifulSoup reste plus rapide à mettre en place. Mais dès qu'il y a de l'authentification complexe, du JavaScript dynamique, ou des interactions utilisateur à reproduire — Playwright est le choix le plus solide disponible en open-source aujourd'hui.