Le scraping par langage 19 min de lecture

Web scraping en PHP : le guide complet, du simple au complexe

Guide complet du web scraping en PHP : cURL, DOMDocument, Simple HTML DOM, Guzzle et la mise en place d'une collecte régulière sur votre hébergement.

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

Article de synthèse sur l'écriture de scrapers en PHP pur : du simple téléchargement d'une page à la parallélisation, aux proxys, à TOR et aux files d'attente. Tous les exemples sont fonctionnels — copiez-les et adaptez-les à votre besoin.

Sommaire

  1. Qu'est-ce que le web scraping et quand en a-t-on besoin
  2. Éthique et aspects juridiques (robots.txt, charge serveur)
  3. Comment récupérer la page - file_get_contents - cURL - Guzzle
  4. Bibliothèques pour parser le contenu - Pourquoi pas les expressions régulières - DOMDocument + DOMXPath - Symfony DomCrawler - Simple HTML DOM / phpQuery - Quand il vaut mieux parser le JSON / l'API cachée
  5. Résoudre les problèmes de parsing du cyrillique
  6. multi_curl et requêtes parallèles
  7. Utiliser des proxys
  8. Scraper via TOR
  9. Gérer HTTPS / SSL
  10. Gérer les cookies
  11. Code de réponse et autres en-têtes
  12. Se faire passer pour un navigateur, pauses, réessais (complément)
  13. Pages JavaScript et navigateurs headless (complément)
  14. Stockage des URL et files d'attente (survol)
  15. Principaux avantages et inconvénients d'une implémentation en PHP
  16. Conclusion

1. Qu'est-ce que le web scraping et quand en a-t-on besoin

Le web scraping (ou scraping) est la récupération automatique des pages d'un site et l'extraction de données structurées : prix, descriptions, contacts, actualités. Le processus comporte presque toujours deux étapes :

  1. Télécharger la page HTML (requête HTTP).
  2. Parser la page et en extraire les morceaux utiles (parsing HTML/DOM).

Mieux vaut garder ces deux étapes séparées : le « téléchargeur » et le « parseur ». Vous pourrez ainsi changer le mode de chargement (cURL → proxy → TOR) indépendamment de la logique d'extraction.

Avant d'écrire un scraper, vérifiez toujours un point : le site n'expose-t-il pas une API ouverte ou un endpoint JSON ? Parser un JSON tout prêt est dix fois plus simple et plus fiable que d'extraire les données d'un balisage qui change chaque semaine.


2. Éthique et aspects juridiques

Avant de mettre à contribution le serveur de quelqu'un d'autre, gardez quelques points en tête :

  • robots.txt — le fichier où le site indique ce qui peut être indexé. Juridiquement, il n'interdit pas l'accès, mais le respecter est une marque de politesse — et parfois une clause des conditions d'utilisation.
  • La charge. N'envoyez pas des centaines de requêtes par seconde — cela ressemble à un DDoS. Insérez des pauses entre les requêtes (voir la section 12).
  • Droits d'auteur et données personnelles. Collecter et republier du contenu peut enfreindre la loi. Soyez particulièrement prudent avec les données personnelles.
  • Conditions d'utilisation (ToS). Beaucoup de sites interdisent explicitement la collecte automatisée. Rien de pénal, mais cela peut se solder par un blocage et des réclamations.

Un lecteur robots.txt rudimentaire :

php
function isAllowed(string $url, string $userAgent = '*'): bool
{
    $parts = parse_url($url);
    $robotsUrl = $parts['scheme'] . '://' . $parts['host'] . '/robots.txt';
    $robots = @file_get_contents($robotsUrl);
    if ($robots === false) {
        return true; // pas de robots.txt — formellement, rien n'est interdit
    }
    // Vérification simplifiée : on cherche un Disallow pour notre chemin.
    $path = $parts['path'] ?? '/';
    foreach (preg_split('/\R/', $robots) as $line) {
        if (preg_match('/^\s*Disallow:\s*(\S+)/i', $line, $m)) {
            if ($m[1] !== '' && str_starts_with($path, $m[1])) {
                return false;
            }
        }
    }
    return true;
}

Pour un projet sérieux, prenez un parseur robots.txt existant (par exemple spatie/robots-txt) plutôt qu'une solution maison.


3. Comment récupérer la page

3.1. file_get_contents — la méthode la plus simple

php
$html = file_get_contents('https://example.com');

Fonctionne si allow_url_fopen est activé dans php.ini. On peut passer un contexte avec des en-têtes :

php
$context = stream_context_create([
    'http' => [
        'method'  => 'GET',
        'header'  => "User-Agent: Mozilla/5.0\r\n",
        'timeout' => 10,
    ],
]);
$html = file_get_contents('https://example.com', false, $context);

Inconvénients : pas de vraie gestion des cookies ni des proxys, pas de codes de réponse « prêts à l'emploi », un contrôle des erreurs limité. Suffisant pour un script ponctuel, pas pour un scraper de production.

3.2. cURL — le cheval de bataille

cURL est une extension disponible presque partout, qui donne un contrôle total sur la requête. C'est l'outil de base du scraping en PHP.

php
function fetch(string $url): string
{
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,   // renvoyer le résultat en chaîne au lieu de l'afficher
        CURLOPT_FOLLOWLOCATION => true,   // suivre les redirections
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_TIMEOUT        => 30,     // timeout global
        CURLOPT_CONNECTTIMEOUT => 10,     // timeout d'établissement de la connexion
        CURLOPT_USERAGENT      => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                                . 'AppleWebKit/537.36 (KHTML, like Gecko) '
                                . 'Chrome/124.0 Safari/537.36',
        CURLOPT_ENCODING       => '',     // accepter gzip/deflate et décompresser
    ]);

    $html = curl_exec($ch);

    if ($html === false) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException("cURL error: $error");
    }

    curl_close($ch);
    return $html;
}

Les options clés :

Option Pourquoi
CURLOPT_RETURNTRANSFER renvoyer la réponse sous forme de chaîne
CURLOPT_FOLLOWLOCATION suivre les redirections 301/302
CURLOPT_TIMEOUT / CURLOPT_CONNECTTIMEOUT ne pas rester bloqué indéfiniment
CURLOPT_ENCODING => '' décompresser le gzip automatiquement
CURLOPT_HTTPHEADER en-têtes personnalisés (tableau de chaînes)
CURLOPT_POSTFIELDS corps de la requête POST

3.3. Guzzle — un client HTTP moderne

Si le projet est sous Composer, le plus confortable est d'utiliser Guzzle. C'est une surcouche de cURL avec une API agréable, la prise en charge de l'asynchrone, des middlewares, du cookie jar, etc.

php
use GuzzleHttp\Client;

$client = new Client([
    'timeout' => 30,
    'headers' => ['User-Agent' => 'Mozilla/5.0 ...'],
]);

$response = $client->get('https://example.com');
$html = (string) $response->getBody();
$status = $response->getStatusCode();

Dans la suite de l'article, les exemples restent en cURL « nu » — pour bien voir la mécanique —, mais dans un projet réel, Guzzle fait souvent gagner du temps.


4. Bibliothèques pour parser le contenu

4.1. Pourquoi pas les expressions régulières

La tentation de parser le HTML à coups d'expressions régulières est grande, mais le HTML n'est pas un langage régulier. La moindre imbrication, une balise non fermée ou un simple retour à la ligne cassent la regex. Les regex ne conviennent qu'à des fragments très simples et plats (extraire un nombre d'une chaîne, par exemple), pas au parsing de l'arbre du document.

4.2. DOMDocument + DOMXPath (intégrés à PHP)

La méthode intégrée la plus fiable. On charge le HTML dans le DOM et on le parcourt en XPath.

php
$dom = new DOMDocument();
libxml_use_internal_errors(true);           // on étouffe les warnings du HTML « cassé »
$dom->loadHTML($html);
libxml_clear_errors();

$xpath = new DOMXPath($dom);

// Tous les titres h2 dans le bloc de classe article
$nodes = $xpath->query('//div[@class="article"]//h2');
foreach ($nodes as $node) {
    echo trim($node->textContent), PHP_EOL;
}

// Récupérer le href des liens
$links = $xpath->query('//a/@href');
foreach ($links as $link) {
    echo $link->value, PHP_EOL;
}

Des expressions XPath utiles :

XPath Ce que ça sélectionne
//a tous les liens
//div[@id="main"] le div avec id="main"
//div[contains(@class,"item")] les div dont la classe contient item
//table//tr/td[2] la deuxième cellule de chaque ligne du tableau
//meta[@property="og:title"]/@content la valeur de l'attribut content

4.3. Symfony DomCrawler (via Composer) — recommandé

Une surcouche pratique du DOM, qui prend en charge à la fois les sélecteurs CSS et XPath.

bash
composer require symfony/dom-crawler symfony/css-selector
php
use Symfony\Component\DomCrawler\Crawler;

$crawler = new Crawler($html);

// Sélecteurs CSS (nécessite css-selector)
$crawler->filter('div.article h2')->each(function (Crawler $node) {
    echo $node->text(), PHP_EOL;
});

// Attributs
$title = $crawler->filter('meta[property="og:title"]')->attr('content');

// XPath est disponible aussi
$crawler->filterXPath('//a')->each(fn(Crawler $a) => print($a->attr('href') . "\n"));

4.4. Simple HTML DOM et phpQuery

  • Simple HTML DOM (simple_html_dom) — une bibliothèque ancienne et très simple, à la syntaxe façon jQuery. Pratique, mais gourmande en mémoire et presque plus développée depuis longtemps. Acceptable pour de petites tâches.
  • phpQuery — un portage de jQuery en PHP. Vieillissant lui aussi, mais avec la syntaxe familière pq('div.item')->find('a').

Pour un nouveau projet, préférez DomCrawler ou DOMXPath — plus rapides et toujours maintenus.

4.5. JSON caché / API — la voie la plus propre

Ouvrez les DevTools → onglet Network. Souvent, les données arrivent par une requête XHR séparée qui renvoie un JSON tout prêt. Le parser tient en une ligne :

php
$data = json_decode($jsonString, true);

C'est plus fiable que n'importe quel parsing HTML : la structure d'un JSON change moins souvent que le balisage.


5. Encodages et Unicode : résoudre les problèmes de parsing

La douleur la plus fréquente : des caractères illisibles (« mojibake ») à la place du texte en cyrillique. Les causes : des encodages qui ne concordent pas (le site est en windows-1251 alors que vous attendez de l'UTF-8) et le fait que DOMDocument considère par défaut l'entrée comme de l'UTF-8.

5.1. Détecter l'encodage de la page

Le site annonce son encodage dans l'en-tête HTTP Content-Type ou dans <meta charset>.

php
function detectCharset(string $html, ?string $contentTypeHeader = null): string
{
    if ($contentTypeHeader && preg_match('/charset=([\w-]+)/i', $contentTypeHeader, $m)) {
        return strtoupper($m[1]);
    }
    if (preg_match('/<meta[^>]+charset=["\']?([\w-]+)/i', $html, $m)) {
        return strtoupper($m[1]);
    }
    // Heuristique en solution de repli
    return mb_detect_encoding($html, ['UTF-8', 'Windows-1251', 'KOI8-R'], true) ?: 'UTF-8';
}

5.2. Convertir en UTF-8

php
$charset = detectCharset($html, $contentType);
if ($charset !== 'UTF-8') {
    $html = mb_convert_encoding($html, 'UTF-8', $charset);
    // et on corrige la déclaration dans le meta pour ne pas dérouter le DOM
    $html = preg_replace('/charset=[\w-]+/i', 'charset=UTF-8', $html, 1);
}

5.3. L'astuce clé pour DOMDocument

DOMDocument::loadHTML devine l'encodage d'après le contenu et se trompe souvent. Le procédé le plus fiable : ajouter un « indice » avant le chargement :

php
$dom = new DOMDocument();
libxml_use_internal_errors(true);
// on force le parseur à traiter l'entrée comme de l'UTF-8
$dom->loadHTML('<?xml encoding="UTF-8">' . $html);
libxml_clear_errors();

ou la variante avec des flags (PHP 8.1+ n'ajoute rien de superflu) :

php
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);

La règle : convertissez d'abord tout le HTML en UTF-8 dès le téléchargement, et passez-le au parseur seulement ensuite. Le cyrillique reste alors intact.


6. multi_curl et requêtes parallèles

PHP est monothread par nature, mais cURL sait mener plusieurs requêtes en parallèle via curl_multi_*. Le gain de vitesse est énorme : pendant qu'un serveur « réfléchit », les autres téléchargements avancent.

php
function fetchMany(array $urls, int $concurrency = 10): array
{
    $multi = curl_multi_init();
    $handles = [];
    $results = [];
    $queue = array_values($urls);
    $active = [];

    // fonction d'ajout d'une requête
    $addHandle = function (string $url) use ($multi, &$active) {
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_ENCODING       => '',
        ]);
        curl_multi_add_handle($multi, $ch);
        $active[(int) $ch] = $url;
        return $ch;
    };

    // premier lot
    for ($i = 0; $i < $concurrency && $queue; $i++) {
        $addHandle(array_shift($queue));
    }

    do {
        curl_multi_exec($multi, $running);
        curl_multi_select($multi);   // on attend les événements sans faire tourner le CPU à vide

        // on récupère les requêtes terminées
        while ($done = curl_multi_info_read($multi)) {
            $ch = $done['handle'];
            $url = $active[(int) $ch];
            $results[$url] = curl_multi_getcontent($ch);

            curl_multi_remove_handle($multi, $ch);
            curl_close($ch);
            unset($active[(int) $ch]);

            // on enchaîne avec la suivante de la file
            if ($queue) {
                $addHandle(array_shift($queue));
            }
        }
    } while ($running || $queue || $active);

    curl_multi_close($multi);
    return $results;
}

$pages = fetchMany([
    'https://example.com/1',
    'https://example.com/2',
    'https://example.com/3',
], concurrency: 5);

L'idée clé — la fenêtre glissante : on ne garde jamais plus de concurrency requêtes en cours et on en ajoute à mesure qu'elles se terminent. On va vite, sans ouvrir mille connexions d'un coup.

Les alternatives pour une « vraie » parallélisation :

  • Guzzle Pool / Promises — des requêtes asynchrones avec limite de concurrence, plus haut niveau que curl_multi.
  • ReactPHP / Amp / Swoole — des runtimes asynchrones/à coroutines, quand il faut une grande échelle.
  • pcntl_fork / workers parallèles — plusieurs processus, chacun prenant son lot d'URL dans la file (voir la section 14).

7. Utiliser des proxys

Les proxys servent à :

  • contourner les blocages par IP (le site bannit les requêtes trop fréquentes),
  • collecter des données depuis différentes régions,
  • répartir la charge entre plusieurs adresses.
php
curl_setopt_array($ch, [
    CURLOPT_PROXY     => '123.45.67.89:8080',
    CURLOPT_PROXYTYPE => CURLPROXY_HTTP,     // ou CURLPROXY_SOCKS5
]);

// proxy avec authentification
curl_setopt($ch, CURLOPT_PROXYUSERPWD, 'login:password');

Les types de proxys :

Type Constante Remarques
HTTP CURLPROXY_HTTP le plus courant
HTTPS CURLPROXY_HTTPS proxy via TLS
SOCKS5 CURLPROXY_SOCKS5 TOR fonctionne en SOCKS5
SOCKS5 + DNS sur le proxy CURLPROXY_SOCKS5_HOSTNAME résolution des domaines côté proxy

La rotation des proxys. Maintenez un pool d'adresses et servez-les à tour de rôle ; marquez les proxys « morts » et écartez-les temporairement.

php
class ProxyPool
{
    private array $proxies;
    private int $i = 0;

    public function __construct(array $proxies)
    {
        $this->proxies = array_values($proxies);
    }

    public function next(): string
    {
        $proxy = $this->proxies[$this->i % count($this->proxies)];
        $this->i++;
        return $proxy;
    }
}

On distingue les proxys datacenter (bon marché, faciles à détecter) et les proxys résidentiels/mobiles (plus chers, mais perçus comme de vrais utilisateurs). Le choix dépend de l'agressivité des protections du site.


8. Scraper via TOR

TOR est un réseau gratuit qui fournit un proxy SOCKS5 anonyme sur 127.0.0.1:9050. Pratique pour une rotation d'IP gratuite, mais lent, et beaucoup de sites bloquent les nœuds de sortie TOR.

Connexion

php
curl_setopt_array($ch, [
    CURLOPT_PROXY     => '127.0.0.1:9050',
    CURLOPT_PROXYTYPE => CURLPROXY_SOCKS5_HOSTNAME, // DNS via TOR — important pour l'anonymat
]);

Changer d'IP (nouveau circuit)

TOR dispose d'un port de contrôle (9051) par lequel on peut demander un nouveau circuit avec le signal NEWNYM. Activez-le d'abord dans torrc :

code
ControlPort 9051
CookieAuthentication 0
HashedControlPassword 16:...   # à générer avec : tor --hash-password "votre_mot_de_passe"

Puis, côté PHP :

php
function torNewIdentity(string $password, string $host = '127.0.0.1', int $port = 9051): bool
{
    $fp = @fsockopen($host, $port, $errno, $errstr, 10);
    if (!$fp) {
        return false;
    }
    fwrite($fp, "AUTHENTICATE \"$password\"\r\n");
    $auth = fgets($fp);                 // on attend 250 OK
    fwrite($fp, "SIGNAL NEWNYM\r\n");
    $signal = fgets($fp);               // 250 OK
    fclose($fp);

    sleep(5); // TOR ne construit pas le nouveau circuit instantanément
    return str_starts_with($auth, '250') && str_starts_with($signal, '250');
}

Cycle typique : effectuer N requêtes → torNewIdentity() → continuer avec la nouvelle IP.

Les inconvénients de TOR : une vitesse faible, certains sites renvoient d'emblée un CAPTCHA ou un 403, peu de nœuds de sortie. Pour un volume sérieux, mieux vaut des proxys payants.


9. Gérer HTTPS / SSL

Par défaut, cURL vérifie le certificat SSL — et c'est une bonne chose. Les problèmes surviennent quand le serveur embarque un jeu de certificats racines obsolète (CA bundle).

php
curl_setopt_array($ch, [
    CURLOPT_SSL_VERIFYPEER => true,   // vérifier le certificat (ne le désactivez PAS sans raison)
    CURLOPT_SSL_VERIFYHOST => 2,      // vérifier que l'hôte correspond au certificat
    CURLOPT_CAINFO         => '/path/to/cacert.pem', // CA bundle à jour
]);

On récupère un cacert.pem à jour sur curl.se/docs/caextract.html et on le déclare dans php.ini :

ini
curl.cainfo = "/path/to/cacert.pem"
openssl.cafile = "/path/to/cacert.pem"

Ne faites pas ça en production :

php
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // désactive la protection contre les attaques MITM

Cela supprime la vérification du certificat. Tolérable seulement à titre temporaire, pour du débogage local. La bonne réponse à une « erreur de certificat », c'est de mettre à jour le CA bundle, pas de désactiver la vérification.

On peut aussi forcer la version de TLS :

php
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);

Les cookies servent aux sessions, à l'authentification et au passage des « contrôles » qui déposent un cookie puis l'attendent à la requête suivante. cURL sait les stocker et les renvoyer automatiquement via un cookie jar — un fichier.

php
$cookieFile = __DIR__ . '/cookies.txt';

curl_setopt_array($ch, [
    CURLOPT_COOKIEJAR  => $cookieFile,  // où ENREGISTRER les cookies reçus
    CURLOPT_COOKIEFILE => $cookieFile,  // où les LIRE lors de la requête
]);

Si les deux requêtes (la connexion et la suivante) utilisent le même $cookieFile, la session est conservée de l'une à l'autre.

Exemple d'authentification

php
$cookieFile = tempnam(sys_get_temp_dir(), 'ck');

// 1) POST avec identifiant/mot de passe — le serveur renverra le cookie de session
$ch = curl_init('https://example.com/login');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query(['user' => 'me', 'pass' => 'secret']),
    CURLOPT_COOKIEJAR      => $cookieFile,
    CURLOPT_COOKIEFILE     => $cookieFile,
    CURLOPT_FOLLOWLOCATION => true,
]);
curl_exec($ch);
curl_close($ch);

// 2) requête vers la page protégée — le cookie sera envoyé automatiquement
$ch = curl_init('https://example.com/account');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEFILE     => $cookieFile,
    CURLOPT_COOKIEJAR      => $cookieFile,
]);
$account = curl_exec($ch);
curl_close($ch);

Pour passer un cookie à la main (sans fichier) :

php
curl_setopt($ch, CURLOPT_COOKIE, 'sessionid=abc123; lang=ru');

En scraping parallèle, donnez à chaque « worker »/proxy son propre fichier de cookies, sinon les sessions vont se mélanger.


11. Code de réponse et autres en-têtes

Un scraper doit réagir au code de réponse : 200 — tout va bien, 404 — la page n'existe pas, 403/429 — vous êtes banni ou invité à ralentir, 5xx — erreur serveur.

Le code de réponse

php
$html = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if ($httpCode === 200) {
    // on traite
} elseif ($httpCode === 429) {
    // trop de requêtes — on attend et on réessaie
} elseif ($httpCode >= 500) {
    // erreur serveur — réessayer plus tard
}

Les infos utiles de curl_getinfo

php
$info = curl_getinfo($ch);
// $info['http_code']       — code de réponse
// $info['content_type']    — Content-Type (le charset s'y trouve !)
// $info['redirect_url']    — où la redirection a mené
// $info['total_time']      — durée totale
// $info['primary_ip']      — IP du serveur (utile pour vérifier le proxy)
// $info['size_download']   — taille de la réponse

Récupérer les en-têtes de réponse séparément

php
curl_setopt($ch, CURLOPT_HEADER, true); // inclure les en-têtes dans la sortie
$response = curl_exec($ch);

$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$rawHeaders = substr($response, 0, $headerSize);
$body       = substr($response, $headerSize);

Plus propre — un callback qui rassemble les en-têtes dans un tableau :

php
$headers = [];
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $line) use (&$headers) {
    $parts = explode(':', $line, 2);
    if (count($parts) === 2) {
        $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
    }
    return strlen($line); // il faut impérativement renvoyer la longueur
});
curl_exec($ch);
// désormais $headers['content-type'], $headers['set-cookie'], etc.

Les plus importants : Content-Type (l'encodage), Set-Cookie, Location (la redirection), Retry-After (le délai à respecter sur un 429), Content-Length.


12. Se faire passer pour un navigateur, pauses, réessais

Pour que le scraper ne soit pas banni dès la deuxième requête, il doit se comporter « comme un humain ».

Des en-têtes réalistes

php
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
        . '(KHTML, like Gecko) Chrome/124.0 Safari/537.36',
    'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language: ru-RU,ru;q=0.9,en;q=0.8',
    'Referer: https://example.com/',
    'Connection: keep-alive',
]);

Des pauses entre les requêtes

php
usleep(random_int(800_000, 2_500_000)); // pause aléatoire de 0,8 à 2,5 s

Des pauses aléatoires paraissent plus naturelles que des pauses fixes. C'est poli envers le serveur, et cela réduit le risque de bannissement.

Réessais avec délai exponentiel (retry/backoff)

php
function fetchWithRetry(string $url, int $maxAttempts = 3): ?string
{
    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_FOLLOWLOCATION => true,
        ]);
        $html = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($html !== false && $code === 200) {
            return $html;
        }
        if ($code === 404) {
            return null; // inutile de réessayer
        }
        sleep(2 ** $attempt); // 2, 4, 8 secondes...
    }
    return null;
}

Ce qui aide encore à éviter le bannissement

  • La rotation des User-Agents et des proxys.
  • La conservation des cookies entre les requêtes (comme un vrai navigateur).
  • Le respect de Retry-After sur un 429.
  • Une parallélisation modérée (pas des centaines de threads sur un même domaine).

13. Pages JavaScript et navigateurs headless

cURL récupère le HTML source, mais n'exécute pas le JavaScript. Si le contenu est dessiné côté client (SPA en React/Vue), il sera absent du HTML — vous verrez des blocs vides.

Les options :

  1. Trouver l'API cachée (section 4.5) — presque toujours la meilleure issue : la SPA tire ses données d'un endpoint JSON que l'on peut appeler directement.
  2. Un navigateur headless — lancer un vrai moteur qui exécutera le JS : - Symfony Panther — une surcouche PHP de ChromeDriver/Selenium. - php-webdriver + Selenium/Chrome. - Le couplage avec Puppeteer/Playwright (Node.js) — il est parfois plus simple de déporter le rendu dans un microservice séparé.
php
// Exemple avec Symfony Panther
use Symfony\Component\Panther\Client;

$client = Client::createChromeClient();
$crawler = $client->request('GET', 'https://spa-example.com');
$client->waitFor('.product');         // on attend que le JS fasse le rendu
$titles = $crawler->filter('.product .title')->each(fn($n) => $n->text());

Les navigateurs headless sont lourds et lents — ne les utilisez que lorsque rien ne fonctionne sans JS.


14. Stockage des URL et files d'attente

Quand un scraper parcourt des centaines de milliers de pages, il faut une file d'URL et un suivi de ce qui a déjà été traité. En survol, les principales approches :

Quoi stocker

  • la file des URL « à traiter » (frontier) ;
  • l'ensemble des URL déjà visitées (pour ne pas repasser deux fois) — pour la déduplication, il est pratique de stocker un hash de l'URL ;
  • le statut de chaque URL : en attente / en cours / terminé / en erreur / nombre de tentatives ;
  • les résultats eux-mêmes (les données parsées).

L'option simple — une base de données

sql
CREATE TABLE crawl_queue (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    url         VARCHAR(2048) NOT NULL,
    url_hash    CHAR(40) NOT NULL,           -- sha1(url), pour l'unicité
    status      ENUM('pending','processing','done','failed') DEFAULT 'pending',
    attempts    INT DEFAULT 0,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uniq_hash (url_hash),
    KEY idx_status (status)
);

Le worker « réserve » la tâche de façon atomique, pour que deux processus ne prennent pas la même URL :

php
$pdo->beginTransaction();
$row = $pdo->query(
    "SELECT id, url FROM crawl_queue
     WHERE status='pending' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED"
)->fetch();

if ($row) {
    $pdo->prepare("UPDATE crawl_queue SET status='processing', attempts=attempts+1 WHERE id=?")
        ->execute([$row['id']]);
}
$pdo->commit();

FOR UPDATE SKIP LOCKED (MySQL 8+/PostgreSQL) est la clé d'une distribution sûre des tâches entre plusieurs workers.

Quand il faut voir plus grand

  • Redis (listes LPUSH/BRPOP, ensembles SADD pour la déduplication) — une file très rapide, un choix populaire.
  • RabbitMQ / Kafka / Beanstalkd — de vrais brokers de messages, quand les workers sont nombreux et que la livraison doit être fiable.
  • Le filtre de Bloom — une vérification compacte du « cette URL a-t-elle déjà été vue » sur des milliards d'adresses, sans stocker toutes les chaînes.

Le principe d'architecture

Séparez les rôles : le producer découvre les nouveaux liens et les met en file, les workers dépilent la file en parallèle et écrivent le résultat. Le système se met alors facilement à l'échelle horizontale — il suffit d'ajouter des workers.


15. Principaux avantages et inconvénients d'une implémentation en PHP

Les avantages

  • Un ticket d'entrée bas — cURL et le DOM sont intégrés, l'environnement existe presque partout.
  • Un excellent cURL — une gestion souple des proxys, cookies, SSL et en-têtes.
  • Des bibliothèques matures — Guzzle, Symfony DomCrawler/Panther, des files d'attente prêtes à l'emploi.
  • Facile à intégrer à un projet web PHP existant (CMS, back-office) — le scraper écrit directement dans la même base de données.
  • Peu coûteux à déployer — l'hébergement PHP est répandu et bon marché.

Les inconvénients

  • Pas de vrai multithreading d'origine. La parallélisation passe par curl_multi, plusieurs processus ou des runtimes asynchrones (ReactPHP/Amp/Swoole). C'est plus lourd que les threads en Go ou l'async en Python.
  • Le JS n'est pas exécuté — pour les SPA, il faut un navigateur headless, lourd et lent.
  • La mémoire. Les vieilles bibliothèques (Simple HTML DOM) sont gourmandes ; sur de gros volumes, surveillez les fuites dans les workers de longue durée.
  • La vitesse. À très grande échelle, les stacks spécialisées (Scrapy en Python, Colly en Go) sont souvent plus efficaces et mieux outillées.
  • La fragilité. Comme tout scraper, il casse quand le site change son balisage — ce n'est pas propre à PHP, mais il faut l'avoir en tête.

Bilan : PHP est un excellent choix pour la plupart des projets de scraping de petite et moyenne envergure, surtout quand les données doivent atterrir directement dans un projet PHP. Pour de très gros crawlers et un rendu JS intensif, regardez du côté des stacks spécialisées ou déportez le rendu dans un service séparé.


16. Conclusion

Un scraper PHP minimal digne de la production comprend :

  • le téléchargement via cURL avec timeouts, redirections et CURLOPT_ENCODING => '' ;
  • un User-Agent et des en-têtes réalistes ;
  • la conversion de l'encodage en UTF-8 avant le parsing (le remède au cyrillique cassé) ;
  • le parsing via DOMXPath ou Symfony DomCrawler (pas de regex) ;
  • la vérification du code HTTP et le traitement des 404/403/429/5xx ;
  • des pauses entre les requêtes et des réessais avec backoff ;
  • un cookie jar si l'authentification ou les sessions sont nécessaires ;
  • des proxys/une rotation d'IP en cas de blocages (ou TOR en version gratuite) ;
  • curl_multi/des workers pour tenir la cadence sur les gros volumes ;
  • une file d'URL avec déduplication pour le crawling sérieux.

Les règles d'or : séparez le téléchargement du parsing, corrigez toujours l'encodage avant de parser, respectez le serveur d'en face (pauses, robots.txt, pas de DDoS) et cherchez d'abord un JSON/une API tout prêts avant de vous battre avec le balisage.