Une grande partie du web tourne encore sur PHP : boutiques, CMS, outils internes et portails clients. Quand une de ces applications a besoin de données provenant d’autres sites, comme des prix concurrents, des détails produits ou des annonces, la solution tentante est de récupérer les pages avec file_get_contents et de les analyser avec des expressions régulières. Ça fonctionne le premier jour et casse le dixième, quand le site bloque l’IP du serveur ou modifie son balisage.
Une API de web scraping élimine la plupart de cette fragilité. Elle récupère les pages via ses propres proxies et navigateurs, relance les échecs et peut retourner uniquement les champs demandés au format JSON. Ce tutoriel construit un petit client PHP pour une telle API, sans dépendance autre que l’extension curl, et montre comment l’utiliser dans une application.
Ce que le client doit faire
Quatre points couvrent la plupart des cas d’usage réels :
- Envoyer une requête avec la clé API et l’URL cible.
- Demander des données structurées avec des règles d’extraction, pour que l’application n’ait jamais à analyser du HTML.
- Relancer les bonnes erreurs et s’arrêter sur celles qui ne réussiront jamais.
- Récupérer plusieurs pages en parallèle sans dépasser la limite de concurrence de votre plan.
Si vous n’avez jamais utilisé d’API de scraping auparavant, notre guide pour débutants détaille davantage le format des requêtes et les codes de statut.
Le client
<?php
final class ShifterScraper
{
private const RETRY = [408, 422, 429, 500];
public function __construct(
private string $apiKey,
private string $endpoint = 'https://scrape.shifter.io/v1',
private int $attempts = 3,
private float $backoffSeconds = 2.0,
) {}
/** Fetch one page; with $extractRules, returns the decoded JSON instead of HTML. */
public function fetch(string $url, array $extractRules = [], bool $renderJs = false): string|array
{
$params = ['api_key' => $this->apiKey, 'url' => $url];
if ($renderJs) {
$params['render_js'] = 1;
}
if ($extractRules) {
$params['extract_rules'] = json_encode($extractRules);
}
for ($attempt = 1; ; $attempt++) {
[$status, $body] = $this->get($this->endpoint . '?' . http_build_query($params));
if ($status === 200) {
return $extractRules ? json_decode($body, true, flags: JSON_THROW_ON_ERROR) : $body;
}
if (!in_array($status, self::RETRY, true) || $attempt >= $this->attempts) {
throw new RuntimeException("HTTP {$status} for {$url}: " . substr($body, 0, 200));
}
usleep((int) ($this->backoffSeconds * $attempt * 1_000_000));
}
}
/** Fetch many pages at once with curl_multi, at most $concurrency in flight. */
public function fetchMany(array $urls, array $extractRules = [], int $concurrency = 10): array
{
$results = [];
foreach (array_chunk($urls, $concurrency) as $batch) {
$multi = curl_multi_init();
$handles = [];
foreach ($batch as $url) {
$params = ['api_key' => $this->apiKey, 'url' => $url];
if ($extractRules) {
$params['extract_rules'] = json_encode($extractRules);
}
$handles[$url] = $this->handle($this->endpoint . '?' . http_build_query($params));
curl_multi_add_handle($multi, $handles[$url]);
}
do {
curl_multi_exec($multi, $running);
curl_multi_select($multi);
} while ($running > 0);
foreach ($handles as $url => $h) {
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
$body = curl_multi_getcontent($h);
$results[$url] = $status === 200
? ($extractRules ? json_decode($body, true) : $body)
: $this->fetchOrNull($url, $extractRules); // retry failures one by one
curl_multi_remove_handle($multi, $h);
}
curl_multi_close($multi);
}
return $results;
}
private function fetchOrNull(string $url, array $extractRules): string|array|null
{
try {
return $this->fetch($url, $extractRules);
} catch (RuntimeException) {
return null;
}
}
private function handle(string $requestUrl): CurlHandle
{
$h = curl_init($requestUrl);
curl_setopt_array($h, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120]);
return $h;
}
private function get(string $requestUrl): array
{
$h = $this->handle($requestUrl);
$body = curl_exec($h);
$status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
return [$status, $body === false ? curl_error($h) : $body];
}
}
Quelques choix de conception à relever :
- Relances uniquement là où elles aident. Les délais d’attente dépassés (
408), les rendus échoués (422), les limitations de débit (429) et les erreurs serveur (500) sont relancés avec une pause croissante. Tout le reste, comme une clé invalide, provoque une exception immédiate. - Les règles d’extraction deviennent un tableau. Avec des règles,
fetch()retourne du JSON décodé, donc le code appelant travaille avec$product['price'], pas avec du balisage. - Requêtes parallèles avec
curl_multi.fetchMany()envoie un lot de requêtes en une fois et attend qu’elles se terminent toutes. Les lots sont dimensionnés pour rester sous la limite de concurrence de votre plan. Les pages qui échouent dans le lot obtiennent une seconde chance viafetch()avec ses relances, et reviennent en tant quenullsi elles échouent encore.
Utilisation
<?php
require __DIR__ . '/ShifterScraper.php';
$scraper = new ShifterScraper(getenv('SHIFTER_SCRAPER_KEY'));
// One page, as structured data instead of HTML
$product = $scraper->fetch('https://shop.example.com/p/123', [
'name' => ['selector' => 'h1', 'output' => 'text'],
'price' => ['selector' => '.price', 'output' => 'text'],
]);
// A batch, ten requests in flight at a time; failed pages come back as null
$pages = $scraper->fetchMany($productUrls, ['name' => ['selector' => 'h1', 'output' => 'text']], 10);
Conservez la clé API dans une variable d’environnement ou dans la configuration de secrets de votre framework, jamais dans le code.
Comment nous l’avons testé
Nous avons exécuté le client contre un serveur PHP local simulant l’API. Il servait des pages normales, une page qui échouait deux fois avec 429 avant de réussir, une qui retournait toujours 401 et une qui dépassait toujours le délai avec 408. Les tests vérifiaient que :
- le HTML et le JSON extrait revenaient correctement ;
- la page instable réussissait à sa troisième tentative ;
- le
401était tenté une seule fois et le408était relancé jusqu’à la limite avant d’échouer ; - un lot de 13 pages retournait 12 résultats et un
nullpour la page en échec.
Les 11 vérifications sont passées sur PHP 8.5.
Intégration dans une application
Ne scrapez pas pendant l’affichage d’une page. Un utilisateur ne devrait jamais attendre qu’un site distant se charge. Exécutez la collecte dans un worker de file d’attente ou une tâche planifiée, et servez les résultats stockés.
Stockez les résultats avec un horodatage. Enregistrez chaque donnée avec son moment de collecte, pour que l’application puisse montrer sa fraîcheur et ne rafraîchir que ce qui est périmé.
Mettez en cache de manière agressive. La plupart des données ne changent pas à la minute. Un rafraîchissement horaire ou quotidien suffit pour les prix et les annonces sur la plupart des sites, et chaque requête évitée fait économiser de l’argent.
Journalisez les échecs. Enregistrez quelles URL reviennent en null et pourquoi. Une hausse soudaine signifie généralement qu’un site a changé sa mise en page et que vos règles d’extraction doivent être mises à jour.
Respectez les limites de votre plan. Réglez la taille des lots sur la limite de concurrence de votre plan ou en dessous. Sur Shifter, cette limite se situe entre 20 et 500 requêtes simultanées, selon le plan.
Quand utiliser des proxies directement à la place
Une API de scraping convient à la plupart des collectes de données : elle gère le rendu, les relances et le choix des IP pour vous. Certaines tâches se prêtent encore mieux à une connexion proxy directe, par exemple quand vous avez besoin d’un contrôle total du client HTTP, ou de sessions longues sur une seule IP. Notre guide sur l’utilisation de proxies résidentiels en PHP avec cURL et Guzzle couvre cette approche.
FAQ
PHP peut-il être utilisé pour le web scraping ?
Oui. L’extension curl de PHP gère bien le HTTP, y compris les requêtes parallèles avec curl_multi. L’associer à une API de web scraping signifie que l’application n’a pas à gérer elle-même les proxies, les navigateurs ou les blocages.
Ai-je besoin de Guzzle pour appeler une API de web scraping depuis PHP ?
Non. Le client de ce guide utilise uniquement l’extension curl intégrée. Guzzle fonctionne aussi si votre application l’utilise déjà.
Comment scraper des pages rendues en JavaScript depuis PHP ?
Demandez à l’API de rendre la page en passant render_js, que le client expose comme paramètre. L’API exécute un navigateur et retourne la page rendue, donc PHP n’a jamais besoin d’un navigateur headless.
Combien de requêtes puis-je envoyer à la fois ?
Autant que votre plan d’API de scraping le permet. Dimensionnez les lots de fetchMany() à cette limite ou en dessous.
En résumé
Une application PHP peut utiliser des données web sans construire d’infrastructure de scraping : un petit client basé sur curl, des règles d’extraction au lieu de l’analyse HTML, des relances uniquement pour les erreurs qui peuvent réussir, et des lots parallèles dimensionnés selon votre plan. Exécutez-le depuis une file d’attente, stockez les résultats avec des horodatages, et l’application obtient des données fraîches sans jamais faire attendre un utilisateur pour le site web de quelqu’un d’autre.