Le scraping par langage 12 min de lecture

Web scraping en PHP avec un navigateur

Piloter un vrai navigateur depuis PHP : Symfony Panther, chrome-php et Selenium — parser le contenu dynamique sans quitter la stack PHP.

ÉW
Équipe Web-Scraping.fr
Collecte de données pour votre activité
Publié le: 23 avril 2025

Le parsing PHP classique, c'est cURL + DOMDocument ou une combinaison du type Symfony DomCrawler. Il est rapide, économique et fonctionne parfaitement — exactement jusqu'au moment où le site sert un HTML tout prêt. Le frontend moderne ne le fait presque jamais : le contenu arrive par AJAX, est dessiné par React/Vue/Angular directement dans le navigateur, les prix et catalogues se cachent derrière un défilement infini, et à l'entrée vous accueille un anti-bot qui vérifie si le client sait exécuter du JavaScript.

À ce moment-là, le client HTTP ne suffit plus, et il faut lever un vrai navigateur — c'est-à-dire piloter Chrome ou Firefox headless directement depuis PHP. Cela résout le problème du rendu JS, mais en apporte en échange toute une classe de nouveaux : ressources, fuites de mémoire, processus zombies, onglets décrochés et débogage difficile. C'est de cela que nous allons parler.

Quand le navigateur est réellement nécessaire

Le navigateur est un outil lourd, à ne sortir qu'en cas de réelle nécessité. Les signes qu'on ne peut s'en passer :

  • le contenu n'apparaît dans le DOM qu'après l'exécution du JavaScript (SPA, chargement paresseux) ;
  • il faut des actions utilisateur : clics, défilement, remplissage de formulaires, parcours de scénarios multi-étapes ;
  • le site se défend activement contre les bots et vérifie l'exécution du JS, l'empreinte du navigateur, les mouvements de souris ;
  • on a besoin d'un rendu en capture d'écran ou en PDF, ou de relever des métriques de performance.

En revanche, si la page fournit les données voulues dans le HTML source ou en JSON via une API interne — le navigateur est inutile, et une requête HTTP honnête sera des dizaines de fois plus rapide et stable.

Les bibliothèques populaires

Symfony Panther

Panther est la bibliothèque officielle de l'équipe Symfony. Sous le capot, elle utilise le protocole WebDriver et de vrais drivers (ChromeDriver, GeckoDriver), et son API est compatible avec DomCrawler et BrowserKit. C'est son atout principal : vous écrivez le code habituel, mais dessous tourne un Chrome vivant au lieu d'un client HTTP.

php
use Symfony\Component\Panther\Client;

$client = Client::createChromeClient(null, [
    '--headless=new',
    '--no-sandbox',
    '--disable-dev-shm-usage',
    '--disable-gpu',
]);

$crawler = $client->request('GET', 'https://example.com');
$client->waitFor('.product-card'); // on attend l'apparition de l'élément

$titles = $crawler->filter('.product-card h2')->each(
    fn ($node) => $node->text()
);

$client->quit(); // OBLIGATOIRE — sinon le processus du navigateur reste suspendu

Panther est bon pour les tests d'intégration et un parsing modéré. Sa principale douleur : elle ne gère pas parfaitement le cycle de vie du navigateur ; en cas d'exception, d'erreur fatale ou de quit() oublié, les processus s'accumulent.

php-webdriver/webdriver + Selenium

php-webdriver (ex-Facebook WebDriver) est le client du protocole WebDriver le plus bas niveau, mais le plus souple. Il travaille en général avec Selenium Grid ou Standalone, ce qui se met à l'échelle commodément en cluster.

php
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\WebDriverBy;

$driver = RemoteWebDriver::create(
    'http://selenium-hub:4444/wd/hub',
    DesiredCapabilities::chrome()
);

try {
    $driver->get('https://example.com');
    $element = $driver->findElement(WebDriverBy::cssSelector('.price'));
    $price = $element->getText();
} finally {
    $driver->quit(); // dans finally — pour fermer la session même en cas d'erreur
}

La combinaison PHP + Selenium est particulièrement commode quand l'infrastructure des navigateurs doit être déportée dans un service séparé (Docker Compose, Kubernetes) et que PHP reste un client léger.

chrome-php/chrome (Chrome headless via DevTools Protocol)

Cette bibliothèque dialogue avec Chrome directement par le Chrome DevTools Protocol (CDP), en contournant WebDriver. Elle est plus rapide, plus proche du « métal » et offre un contrôle fin des pages, du réseau et des onglets.

php
use HeadlessChromium\BrowserFactory;

$factory = new BrowserFactory('chromium-browser');
$browser = $factory->createBrowser([
    'headless'        => true,
    'noSandbox'       => true,
    'windowSize'      => [1920, 1080],
    'keepAlive'       => false,
]);

try {
    $page = $browser->createPage();
    $page->navigate('https://example.com')->waitForNavigation();
    $html = $page->getHtml();
} finally {
    $browser->close(); // on ferme le navigateur et toutes ses pages
}

L'approche CDP donne l'accès le plus direct au pilotage des onglets (targets) et à l'interception des requêtes réseau — cela servira plus loin.

Goutte / DomCrawler — pour comparaison

À garder en tête : Goutte (aujourd'hui simplement BrowserKit par-dessus HttpClient) et le DomCrawler pur ne sont pas des navigateurs — ils n'exécutent pas de JavaScript. On les confond souvent avec des solutions navigateur, mais ce sont des clients HTTP. Si la page a besoin de JS, ils sont inutiles ; sur du statique, en revanche, ils sont incomparablement plus légers.

Les problèmes du scraping par navigateur

Ressources et performance

Chaque instance de Chrome, ce sont des dizaines, voire des centaines de mégaoctets de RAM, des processus séparés de rendu, GPU, réseau. Une dizaine de navigateurs en parallèle dévore facilement plusieurs gigaoctets. C'est pourquoi le parsing par navigateur se construit presque toujours comme un pool à limite de parallélisme stricte, avec file de tâches et timeouts sur chaque opération.

Jeu de flags de lancement typique pour un environnement serveur, qui économise les ressources et évite les plantages fréquents :

code
--headless=new
--no-sandbox
--disable-dev-shm-usage      # critique en Docker : sinon /dev/shm sature et Chrome plante
--disable-gpu
--disable-extensions
--blink-settings=imagesEnabled=false   # ne pas charger les images si elles sont inutiles
--js-flags=--max-old-space-size=512    # brider l'appétit de V8

Fuites de mémoire

C'est sans doute la principale douleur cachée des parseurs à longue durée de vie. Les fuites viennent de deux côtés.

Du côté du navigateur lui-même. Chrome n'est pas prévu pour ouvrir des milliers de pages dans une seule session. La consommation mémoire croît de page en page : le cache s'accumule, les arbres DOM ne sont pas libérés, les écouteurs d'événements côté site s'entassent. Un processus de navigateur unique traitant une longue file peut, après quelques heures, occuper plusieurs fois plus de mémoire qu'au départ.

Du côté de PHP. Le processus worker PHP fuit aussi : objets-wrappers de pages, réponses, arbres DOM accumulés en mémoire, variables non nettoyées. Particulièrement dangereux, les workers long-running (Symfony Messenger, RoadRunner, Swoole, ou un simple démon en boucle while (true)), où le processus vit des jours entiers.

Mesures pratiques :

  • Redémarrer le worker selon un compteur de tâches. La technique la plus fiable : le worker traite N tâches (par exemple 50–200) et se termine, le superviseur en relève un frais. La mémoire accumulée est entièrement libérée par le système d'exploitation, et non par le ramasse-miettes de PHP.
  • Redémarrer le navigateur lui-même périodiquement. Ne pas garder un seul Chrome pour des milliers de pages — le fermer et le recréer toutes les M pages.
  • Fermer les pages (onglets) juste après usage, et non seulement à la fin de tout le lot.
  • Surveiller les métriques. Journaliser le memory_get_usage(true) du worker et le RSS des processus navigateur, tracer des courbes. Une croissance linéaire de la mémoire dans le temps est un signe sans équivoque de fuite.
  • Manier gc_collect_cycles() avec soin. Le ramassage forcé des références cycliques aide parfois, mais ne remplace pas le redémarrage.

Fenêtres non fermées et processus zombies

Voilà un problème spécifique aux navigateurs, facile à négliger. Si le script PHP a planté sur une exception, a été tué par timeout ou a simplement oublié d'appeler quit()/close(), le processus du navigateur ne meurt pas avec lui. Il reste suspendu dans le système — avec tous ses onglets ouverts et sa mémoire occupée. Sur un parseur actif, ces processus « orphelins » s'accumulent par dizaines, et en une journée le serveur est saturé de Chrome zombies alors qu'aucune tâche n'est active.

Il faut impérativement le surveiller et le nettoyer. Plusieurs niveaux de protection :

1. Fermeture dans finally. Tout code ayant ouvert un navigateur doit le fermer dans finally, pour qu'une exception ne laisse pas le processus suspendu :

php
$browser = $factory->createBrowser([...]);
try {
    // ... travail
} finally {
    $browser->close();
}

2. Enregistrement d'une fonction de shutdown. Pour les erreurs fatales :

php
register_shutdown_function(function () use ($browser) {
    try { $browser->close(); } catch (\Throwable $e) {}
});

3. Surveillance externe des processus. Régulièrement (par cron ou script watchdog), vérifier combien de processus navigateur vivent dans le système et s'il y en a de « vieux », ayant survécu à leur parent :

bash
# Combien de processus chrome/chromium sont suspendus à l'instant
pgrep -c -f 'chrome|chromium'

# Trouver les processus de plus de 30 minutes — candidats au kill
ps -eo pid,etimes,comm | awk '$2 > 1800 && $3 ~ /chrome/ {print $1}'

# Nettoyage brutal des navigateurs zombies de plus d'une demi-heure
ps -eo pid,etimes,comm | awk '$2 > 1800 && $3 ~ /chrome/ {print $1}' | xargs -r kill -9

Ce watchdog se met en tâche cron distincte, toutes les quelques minutes. Il ne soigne pas la cause, mais empêche le serveur de s'effondrer sous les fenêtres accumulées pendant que vous cherchez la fuite. En parallèle, il est utile de poser une alerte : « plus de X processus navigateur » ou « un processus navigateur de plus de Y minutes » — c'est le premier signal qu'un quit() se perd quelque part.

4. Lancement dans un conteneur jetable. Radical, mais propre : chaque tâche de parsing démarre dans un conteneur Docker frais avec navigateur, garanti détruit après la tâche. Les zombies n'ont physiquement nulle part où s'accumuler.

Gestion des onglets

Ouvrir un navigateur par page coûte cher. Il est souvent plus avantageux de garder un seul navigateur et de travailler par onglets (targets en termes CDP, windows en termes WebDriver). Mais là aussi, il y a des pièges.

Règle d'or : les onglets doivent être comptés et fermés aussi rigoureusement que les navigateurs eux-mêmes. Un onglet ouvert et oublié, c'est la même fuite, mais à l'intérieur d'un processus vivant. Le site peut ouvrir lui-même de nouveaux onglets (target="_blank", popup, publicité), et sans surveillance ils s'entassent en silence.

php
// chrome-php : contrôle explicite des onglets
$page = $browser->createPage();
$page->navigate('https://example.com')->waitForNavigation();
$data = $page->getHtml();
$page->close();   // onglet fermé aussitôt, sans attendre la fin du lot
php
// php-webdriver : on surveille les fenêtres et on ferme les superflues
$handles = $driver->getWindowHandles();
foreach ($handles as $handle) {
    if ($handle !== $mainWindow) {
        $driver->switchTo()->window($handle)->close();
    }
}
$driver->switchTo()->window($mainWindow);

Bonnes pratiques :

  • limiter le nombre d'onglets ouverts simultanément (par exemple, pas plus de 5–10 par navigateur) ;
  • après le traitement d'une page, fermer l'onglet aussitôt, sans accumuler « pour plus tard » ;
  • vérifier périodiquement le nombre réel d'onglets par rapport à l'attendu — un écart signale que le site a ouvert quelque chose de superflu ou que le code ne ferme pas quelque part ;
  • en réutilisant un onglet, nettoyer l'état (cookies, localStorage), sinon les sessions débordent d'une tâche à l'autre.

PHP comme orchestrateur : quand il lance en réalité Python ou Node

Mentionnons à part un scénario architectural honnête, plus fréquent qu'on ne l'admet. Dans le projet, PHP joue le rôle d'orchestrateur : il reçoit les tâches, les met en file, gère la logique métier, écrit les résultats en base. Mais le parsing par navigateur, il ne le fait pas lui-même — parce que l'écosystème de l'automatisation de navigateur est bien plus riche sous Node.js (Puppeteer, Playwright) et Python (Playwright, Selenium, undetected-chromedriver, surcouches anti-détection).

Au final, un « parseur PHP » ressemble en réalité à ceci : PHP compose la tâche et lance un processus externe dans un autre langage, qui fait tout le vrai travail avec le navigateur, PHP se contentant d'analyser sa sortie.

php
use Symfony\Component\Process\Process;

$process = new Process([
    'python3',
    '/app/scrapers/playwright_scraper.py',
    '--url', $url,
    '--timeout', '30',
]);
$process->setTimeout(60); // timeout sur tout le processus — obligatoire

try {
    $process->mustRun();
    $payload = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
    // important : si PHP a tué le processus par timeout, le navigateur enfant
    // doit être achevé aussi — sinon, de nouveau des zombies
    $process->stop(5, SIGKILL);
    throw $e;
}

Pourquoi procède-t-on ainsi :

  • Maturité des outils. Playwright et Puppeteer devancent nettement les équivalents PHP pour l'interception réseau, l'émulation, l'anti-détection et la stabilité.
  • Isolation. Le navigateur vit dans un processus enfant. S'il plante ou fuit, cela ne fait pas tomber ni gonfler le worker PHP. PHP reste léger et stable.
  • Mise à l'échelle par rôles. PHP gère la logique métier, les files et le stockage — ce qu'il fait bien. Le navigateur gère le rendu.

Le point délicat de ce schéma, c'est la frontière de responsabilité sur les processus. Quand PHP tue le processus enfant par timeout, il faut s'assurer que le navigateur qu'il a lancé est mort avec lui. Sinon, les Chrome zombies déménagent d'un niveau plus bas et sont encore plus durs à repérer : PHP est propre, mais la mémoire fuit « on ne sait d'où ». C'est pourquoi le script enfant doit lui-même fermer proprement le navigateur dans son finally/atexit, et PHP doit envoyer SIGKILL à tout le groupe de processus, pas à un seul PID. Lancer le processus enfant dans un groupe de processus séparé et tuer tout le groupe est un moyen sûr de ne pas laisser de traîne.

C'est une architecture normale et répandue. Appeler le projet « parseur PHP » n'est pas malhonnête pour autant — PHP orchestre bien le processus, il délègue simplement le lourd travail navigateur là où les outils sont meilleurs.

Le VNC pour le débogage initial

Le mode headless est commode en production, mais pénible au débogage : vous ne voyez pas ce qui se passe sur la page. Le parseur « ne trouve pas l'élément », et pourquoi — mystère : bannière cookies apparue, captcha, balisage différent pour le bot, ou simplement chargement non attendu ? Les captures aident, mais ce sont des images éparses, pas une vue vivante.

La solution pour le débogage initial — lever le navigateur non pas en headless, mais en mode normal dans un affichage virtuel, et le regarder via VNC. Vous voyez littéralement l'écran du navigateur en temps réel : comment il clique, ce qui se charge, où il se bloque.

Le schéma en Docker est simple :

  • dans le conteneur, on lance un serveur X virtuel (Xvfb) — le navigateur doit « dessiner quelque part » ;
  • par-dessus, un serveur VNC (x11vnc), qui diffuse cet affichage vers l'extérieur ;
  • le navigateur démarre sans le flag --headless, pour dessiner réellement à l'écran ;
  • vous vous connectez avec n'importe quel client VNC (ou via noVNC directement dans le navigateur) et observez.
dockerfile
# Fragment : outillage de débogage par VNC
RUN apt-get update && apt-get install -y \
    chromium xvfb x11vnc fluxbox

ENV DISPLAY=:99

# lancement (simplifié, en vrai — via supervisor/entrypoint) :
# Xvfb :99 -screen 0 1920x1080x24 &
# fluxbox &                       # gestionnaire de fenêtres léger
# x11vnc -display :99 -forever -nopw &   # VNC vers l'extérieur, port 5900
# ensuite PHP/Python lance Chrome SANS --headless

Des images prêtes comme selenium/standalone-chrome-debug contiennent déjà le VNC d'origine — pour la combinaison PHP + Selenium, c'est le démarrage le plus rapide : connectez-vous au port 5900, envoyez la tâche depuis PHP et regardez ce que fait le navigateur.

Quand le VNC dépanne particulièrement :

  • vous traquez un captcha flottant ou un anti-bot qui ne se déclenche pas toujours ;
  • un sélecteur « parfois ne trouve pas » — à l'œil, on voit ce qui gêne (modale, popup, autre locale) ;
  • vous démêlez un scénario multi-étapes (connexion, panier, paiement) où l'ordre compte ;
  • vous vérifiez comment le site réagit précisément à votre navigateur, avec vos flags et votre empreinte.

Important : le VNC est un outil de débogage initial et de mise au point du scénario. Inutile de le garder en production : un port ouvert est une surface d'attaque, et un navigateur « headed » gaspille des ressources. Une fois le scénario débogué en direct et validé — repassez en headless.

Check-list finale

Si vous construisez un parseur par navigateur en PHP, gardez au minimum en tête :

  • Choix de l'outil. Panther — pour la simplicité et la compatibilité Symfony ; php-webdriver + Selenium — pour une infrastructure évolutive ; chrome-php/chrome — pour un contrôle fin via CDP. Pour du travail navigateur sérieux — n'hésitez pas à déléguer à Playwright/Puppeteer via un processus enfant.
  • Cycle de vie. Fermez toujours navigateur et onglets dans finally ; posez un register_shutdown_function ; redémarrez workers et navigateurs selon un compteur.
  • Mémoire. Journalisez la consommation du worker et le RSS des navigateurs, cherchez la croissance linéaire, soignez par redémarrage.
  • Fenêtres zombies. Surveillez impérativement le nombre et l'âge des processus navigateur, posez un watchdog qui achève les orphelins, et des alertes sur leur accumulation.
  • Onglets. Comptez-les et fermez-les aussi strictement que les navigateurs ; veillez à ce que le site n'en ouvre pas de superflus.
  • Orchestration. Si PHP lance Python/Node — tuez tout le groupe de processus, pas un seul PID, et laissez le script enfant fermer lui-même son navigateur.
  • Débogage. Levez VNC + Xvfb pour observer le navigateur en direct pendant le développement ; ne le traînez pas en production.

Le parsing par navigateur en PHP, ce n'est pas « trouver la bibliothèque magique », mais la discipline de gestion des ressources. Extraire les données d'un DOM déjà prêt est la partie facile. Le difficile, c'est faire en sorte qu'après une semaine de fonctionnement continu, le serveur ne se retrouve pas saturé d'onglets zombies et de workers boursouflés.