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
- Qu'est-ce que le web scraping et quand en a-t-on besoin
- Éthique et aspects juridiques (robots.txt, charge serveur)
- Comment récupérer la page - file_get_contents - cURL - Guzzle
- 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
- Résoudre les problèmes de parsing du cyrillique
- multi_curl et requêtes parallèles
- Utiliser des proxys
- Scraper via TOR
- Gérer HTTPS / SSL
- Gérer les cookies
- Code de réponse et autres en-têtes
- Se faire passer pour un navigateur, pauses, réessais (complément)
- Pages JavaScript et navigateurs headless (complément)
- Stockage des URL et files d'attente (survol)
- Principaux avantages et inconvénients d'une implémentation en PHP
- 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 :
- Télécharger la page HTML (requête HTTP).
- 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 :
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
$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 :
$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.
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.
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.
$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.
composer require symfony/dom-crawler symfony/css-selector
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 :
$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>.
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
$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 :
$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) :
$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.
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.
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.
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
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 :
ControlPort 9051
CookieAuthentication 0
HashedControlPassword 16:... # à générer avec : tor --hash-password "votre_mot_de_passe"
Puis, côté 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).
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 :
curl.cainfo = "/path/to/cacert.pem"
openssl.cafile = "/path/to/cacert.pem"
Ne faites pas ça en production :
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // désactive la protection contre les attaques MITMCela 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 :
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);
10. Gérer les cookies
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.
$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
$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) :
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
$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
$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
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 :
$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
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
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)
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-Aftersur 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 :
- 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.
- 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é.
// 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
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 :
$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, ensemblesSADDpour 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.