Scraping per linguaggio 19 min di lettura

Web scraping in PHP: guida completa, dal semplice al complesso

Guida completa al web scraping in PHP: cURL, DOMDocument, Simple HTML DOM, Guzzle e come organizzare una raccolta periodica sull’hosting.

TW
Team Web-Scraping.it
Raccolta dati per le esigenze del business
Pubblicato il 1 aprile 2025

Guida generale su come scrivere scraper (strumenti di web scraping) in puro PHP: dal semplice download di una pagina fino al parallelismo, ai proxy, a Tor e alle code. Tutti gli esempi sono funzionanti: puoi copiarli e adattarli alle tue esigenze.

Indice

  1. Che cos’è il web scraping e quando serve
  2. Etica e aspetti legali (robots.txt, carico)
  3. Come scarichiamo la pagina - file_get_contents - cURL - Guzzle
  4. Librerie per il parsing del contenuto - Perché non usare le regex - DOMDocument + DOMXPath - Symfony DomCrawler - Simple HTML DOM / phpQuery - Quando conviene fare il parsing del JSON / dell’API nascosta
  5. Risolvere i problemi di codifica (lettere accentate)
  6. multi_curl e parallelismo
  7. Uso dei proxy
  8. Scraping attraverso Tor
  9. Lavorare con HTTPS / SSL
  10. Lavorare con i cookie
  11. Codice di stato e altri header
  12. Camuffarsi da browser, pause, retry (integrazione)
  13. Pagine JavaScript e browser headless (integrazione)
  14. Salvataggio degli URL e code (panoramica)
  15. Principali pro e contro dell’implementazione in PHP
  16. Conclusioni

1. Che cos’è il web scraping e quando serve

Il web scraping è il recupero automatico delle pagine di un sito e l’estrazione di dati strutturati: prezzi, descrizioni, contatti, notizie. Il processo si compone quasi sempre di due passaggi:

  1. Scaricare la pagina HTML (richiesta HTTP).
  2. Analizzarla ed estrarne i frammenti necessari (parsing di HTML/DOM).

Conviene tenere questi due passaggi separati: il «downloader» e il «parser». Così potrai cambiare il metodo di download (cURL → proxy → Tor) senza toccare la logica di estrazione.

Prima di scrivere uno scraper, controlla sempre una cosa: se il sito ha un’API aperta o un endpoint JSON. Fare il parsing di un JSON già pronto è decine di volte più semplice e affidabile che estrarre i dati da un markup che cambia ogni settimana.


2. Etica e aspetti legali

Prima di mettere sotto carico un server altrui, tieni a mente alcune cose:

  • robots.txt — il file in cui il sito indica che cosa si può indicizzare. Dal punto di vista legale non vieta l’accesso, ma è un gesto di cortesia e, a volte, parte delle condizioni d’uso.
  • Carico. Non inviare centinaia di richieste al secondo: somiglia a un DDoS. Inserisci pause tra le richieste (vedi la sezione 12).
  • Diritto d’autore e dati personali. Raccogliere e ripubblicare contenuti può violare la legge. Massima prudenza con i dati personali.
  • Condizioni d’uso (ToS). Molti siti vietano espressamente la raccolta automatica. Non è materia penale, ma può finire in blocchi e contestazioni.

Un semplice lettore di robots.txt:

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; // robots.txt assente: formalmente non è vietato
    }
    // Controllo semplificato: cerchiamo un Disallow per il nostro percorso.
    $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;
}

Per i progetti seri usa un parser di robots.txt già pronto (per esempio spatie/robots-txt), non uno scritto in casa.


3. Come scarichiamo la pagina

3.1. file_get_contents — il modo più semplice

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

Funziona se in php.ini è attivo allow_url_fopen. Puoi passare un contesto con gli header:

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);

Contro: non gestisce bene cookie e proxy, non restituisce i codici di risposta «di serie» e il controllo degli errori è scarso. Va bene per script occasionali, non per uno scraper in produzione.

3.2. cURL — il cavallo di battaglia

cURL è un’estensione disponibile quasi ovunque, che dà pieno controllo sulla richiesta. È lo strumento principale per fare scraping in PHP.

php
function fetch(string $url): string
{
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,   // restituire il risultato come stringa, senza stamparlo
        CURLOPT_FOLLOWLOCATION => true,   // seguire i redirect
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_TIMEOUT        => 30,     // timeout complessivo
        CURLOPT_CONNECTTIMEOUT => 10,     // timeout per stabilire la connessione
        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       => '',     // accettare gzip/deflate e decomprimere
    ]);

    $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;
}

Opzioni chiave:

Opzione A cosa serve
CURLOPT_RETURNTRANSFER restituire la risposta come stringa
CURLOPT_FOLLOWLOCATION seguire i redirect 301/302
CURLOPT_TIMEOUT / CURLOPT_CONNECTTIMEOUT non restare appesi all’infinito
CURLOPT_ENCODING => '' decomprimere il gzip automaticamente
CURLOPT_HTTPHEADER header arbitrari (array di stringhe)
CURLOPT_POSTFIELDS corpo della richiesta POST

3.3. Guzzle — un client HTTP moderno

Se il progetto usa Composer, è più comodo lavorare con Guzzle. È un wrapper su cURL con un’API a misura d’uomo, supporto per l’asincronia, middleware, cookie jar e così via.

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();

Negli esempi che seguono useremo cURL «nudo» — per vederne la meccanica —, ma in un progetto reale Guzzle fa spesso risparmiare tempo.


4. Librerie per il parsing del contenuto

4.1. Perché non usare le espressioni regolari

La tentazione di analizzare l’HTML con un’espressione regolare è forte, ma l’HTML non è un linguaggio regolare. Basta un annidamento, un tag non chiuso o un a capo per rompere la regex. Le espressioni regolari hanno senso solo per frammenti molto semplici e piatti (per esempio tirare fuori un numero da una stringa), non per attraversare l’albero del documento.

4.2. DOMDocument + DOMXPath (integrati in PHP)

Il metodo integrato più affidabile. Carichiamo l’HTML nel DOM e lo attraversiamo con XPath.

php
$dom = new DOMDocument();
libxml_use_internal_errors(true);           // silenziamo i warning dell'HTML «rotto»
$dom->loadHTML($html);
libxml_clear_errors();

$xpath = new DOMXPath($dom);

// Tutti i titoli h2 dentro il blocco con classe article
$nodes = $xpath->query('//div[@class="article"]//h2');
foreach ($nodes as $node) {
    echo trim($node->textContent), PHP_EOL;
}

// Ottenere l'href dei link
$links = $xpath->query('//a/@href');
foreach ($links as $link) {
    echo $link->value, PHP_EOL;
}

Espressioni XPath utili:

XPath Che cosa seleziona
//a tutti i link
//div[@id="main"] il div con id="main"
//div[contains(@class,"item")] i div la cui classe contiene item
//table//tr/td[2] la seconda cella di ogni riga della tabella
//meta[@property="og:title"]/@content il valore dell’attributo content

4.3. Symfony DomCrawler (via Composer) — consigliato

Un comodo wrapper sul DOM che supporta sia i selettori CSS sia XPath.

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

$crawler = new Crawler($html);

// Selettori CSS (serve css-selector)
$crawler->filter('div.article h2')->each(function (Crawler $node) {
    echo $node->text(), PHP_EOL;
});

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

// Anche XPath è disponibile
$crawler->filterXPath('//a')->each(fn(Crawler $a) => print($a->attr('href') . "\n"));

4.4. Simple HTML DOM e phpQuery

  • Simple HTML DOM (simple_html_dom) — una libreria vecchia e molto semplice con sintassi in stile jQuery. Comoda, ma divora memoria e da anni è quasi ferma. Per compiti piccoli può bastare.
  • phpQuery — un port di jQuery in PHP. Anche questa datata, ma con la sintassi familiare pq('div.item')->find('a').

Per i nuovi progetti meglio DomCrawler o DOMXPath: sono più veloci e vengono mantenuti.

4.5. JSON nascosto / API — la strada più pulita

Apri i DevTools → scheda Network. Spesso i dati vengono caricati da una richiesta XHR separata che restituisce un JSON già pronto. Farne il parsing è questione di una riga:

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

È più affidabile di qualsiasi parsing dell’HTML: la struttura del JSON cambia meno spesso del markup.


5. Risolvere i problemi di codifica (lettere accentate)

Il problema più frequente: caratteri corrotti («mojibake») nel risultato — caffè al posto di caffè. Le cause sono una discrepanza tra codifiche (il sito è in ISO-8859-1/Windows-1252 e tu ti aspetti UTF-8) e il fatto che DOMDocument non sempre rileva correttamente la codifica dell’input.

5.1. Rilevare la codifica della pagina

Il sito comunica la propria codifica nell’header HTTP Content-Type o nel <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]);
    }
    // Euristica come ultima risorsa
    return mb_detect_encoding($html, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true) ?: 'UTF-8';
}

5.2. Convertire in UTF-8

php
$charset = detectCharset($html, $contentType);
if ($charset !== 'UTF-8') {
    $html = mb_convert_encoding($html, 'UTF-8', $charset);
    // e sostituiamo la dichiarazione nel meta, così il DOM non si confonde
    $html = preg_replace('/charset=[\w-]+/i', 'charset=UTF-8', $html, 1);
}

5.3. Il trucco chiave per DOMDocument

DOMDocument::loadHTML prova a indovinare la codifica dal contenuto e sbaglia spesso. Il rimedio più affidabile è aggiungere un «suggerimento» prima del caricamento:

php
$dom = new DOMDocument();
libxml_use_internal_errors(true);
// obblighiamo il parser a trattare l'input come UTF-8
$dom->loadHTML('<?xml encoding="UTF-8">' . $html);
libxml_clear_errors();

oppure la variante con i flag (in PHP 8.1+ non aggiunge nulla di superfluo):

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

Regola: converti prima tutto l’HTML in UTF-8 nella fase di download e solo dopo passalo al parser. Così le lettere accentate non si rompono.


6. multi_curl e parallelismo

PHP è per natura a thread singolo, ma cURL sa portare avanti più richieste in parallelo tramite curl_multi_*. Il guadagno di velocità è enorme: mentre un server «ci pensa», gli altri continuano a scaricare.

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

    // funzione per aggiungere una richiesta
    $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;
    };

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

    do {
        curl_multi_exec($multi, $running);
        curl_multi_select($multi);   // aspettiamo gli eventi senza bruciare CPU a vuoto

        // raccogliamo le richieste completate
        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]);

            // aggiungiamo la prossima dalla coda
            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’idea chiave è la finestra scorrevole: tenere in volo al massimo concurrency richieste alla volta e aggiungerne di nuove man mano che finiscono. Così siamo veloci senza aprire mille connessioni in un colpo solo.

Alternative per il parallelismo «vero»:

  • Guzzle Pool / Promises — richieste asincrone con limite di concorrenza, a un livello più alto di curl_multi.
  • ReactPHP / Amp / Swoole — runtime asincroni/a coroutine, se punti a una scala davvero grande.
  • pcntl_fork / worker paralleli — più processi, ognuno prende il suo lotto di URL dalla coda (vedi la sezione 14).

7. Uso dei proxy

I proxy servono per:

  • aggirare i blocchi per IP (il sito banna per richieste troppo frequenti),
  • raccogliere dati da regioni diverse,
  • distribuire il carico tra più indirizzi.
php
curl_setopt_array($ch, [
    CURLOPT_PROXY     => '123.45.67.89:8080',
    CURLOPT_PROXYTYPE => CURLPROXY_HTTP,     // oppure CURLPROXY_SOCKS5
]);

// proxy con autenticazione
curl_setopt($ch, CURLOPT_PROXYUSERPWD, 'login:password');

Tipi di proxy:

Tipo Costante Note
HTTP CURLPROXY_HTTP il più comune
HTTPS CURLPROXY_HTTPS proxy su TLS
SOCKS5 CURLPROXY_SOCKS5 Tor funziona via SOCKS5
SOCKS5 + DNS sul proxy CURLPROXY_SOCKS5_HOSTNAME risoluzione dei domini lato proxy

Rotazione dei proxy. Tieni un pool di indirizzi e usali a rotazione; marca quelli «morti» ed escludili temporaneamente.

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;
    }
}

Si distingue tra proxy datacenter (economici, facili da individuare) e residenziali/mobili (più cari, ma sembrano utenti reali). La scelta dipende da quanto è aggressiva la protezione del sito.


8. Scraping attraverso Tor

Tor è una rete gratuita che mette a disposizione un proxy SOCKS5 anonimo su 127.0.0.1:9050. È comodo per ruotare gli IP a costo zero, ma è lento e molti siti bloccano i nodi di uscita di Tor.

Connessione

php
curl_setopt_array($ch, [
    CURLOPT_PROXY     => '127.0.0.1:9050',
    CURLOPT_PROXYTYPE => CURLPROXY_SOCKS5_HOSTNAME, // DNS attraverso Tor: importante per l'anonimato
]);

Cambio di IP (nuovo circuito)

Tor ha una porta di controllo (9051) attraverso cui puoi richiedere un nuovo circuito con il segnale NEWNYM. Prima attivala nel torrc:

code
ControlPort 9051
CookieAuthentication 0
HashedControlPassword 16:...   # genera con: tor --hash-password "la_tua_password"

Poi, da 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);                 // aspettiamo il 250 OK
    fwrite($fp, "SIGNAL NEWNYM\r\n");
    $signal = fgets($fp);               // 250 OK
    fclose($fp);

    sleep(5); // Tor non costruisce il nuovo circuito all'istante
    return str_starts_with($auth, '250') && str_starts_with($signal, '250');
}

Ciclo tipico: fare N richieste → torNewIdentity() → continuare con il nuovo IP.

Contro di Tor: velocità bassa, una parte dei siti restituisce subito CAPTCHA o 403, e i nodi di uscita sono pochi. Per volumi seri meglio i proxy a pagamento.


9. Lavorare con HTTPS / SSL

Per impostazione predefinita cURL verifica il certificato SSL — ed è giusto così. I problemi nascono quando sul server c’è un pacchetto di certificati radice (CA bundle) obsoleto.

php
curl_setopt_array($ch, [
    CURLOPT_SSL_VERIFYPEER => true,   // verificare il certificato (NON disattivarlo senza motivo)
    CURLOPT_SSL_VERIFYHOST => 2,      // controllare che l'host corrisponda al certificato
    CURLOPT_CAINFO         => '/path/to/cacert.pem', // CA bundle aggiornato
]);

Il cacert.pem aggiornato si scarica da curl.se/docs/caextract.html e si dichiara nel php.ini:

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

Non fare così in produzione:

php
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // disattiva la protezione contro i MITM

Questo elimina la verifica del certificato. È ammissibile solo temporaneamente, per il debug in locale. La soluzione corretta all’«errore di certificato» è aggiornare il CA bundle, non disattivare la verifica.

Puoi anche forzare la versione di TLS:

php
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);

I cookie servono per le sessioni, l’autenticazione e per superare i «controlli» che impostano un cookie e lo aspettano alla richiesta successiva. cURL sa salvarli e rispedirli automaticamente tramite il cookie jar, un file.

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

curl_setopt_array($ch, [
    CURLOPT_COOKIEJAR  => $cookieFile,  // dove SALVARE i cookie ricevuti
    CURLOPT_COOKIEFILE => $cookieFile,  // da dove LEGGERLI a ogni richiesta
]);

Se entrambe le richieste (il login e la successiva) usano lo stesso $cookieFile, la sessione si conserva tra l’una e l’altra.

Esempio di autenticazione

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

// 1) POST con utente/password: il server restituirà il cookie di sessione
$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) richiesta alla pagina protetta: il cookie viene allegato automaticamente
$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);

Per passare i cookie a mano (senza file):

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

Nello scraping in parallelo assegna a ogni «worker»/proxy il suo file di cookie, altrimenti le sessioni si mescolano.


11. Codice di stato e altri header

Lo scraper deve reagire al codice di risposta: 200 — tutto ok, 404 — la pagina non esiste, 403/429 — sei stato bannato o ti chiedono di rallentare, 5xx — errore del server.

Codice di risposta

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

if ($httpCode === 200) {
    // elaboriamo
} elseif ($httpCode === 429) {
    // troppe richieste: aspettiamo e riproviamo
} elseif ($httpCode >= 500) {
    // errore del server: riprovare più tardi
}

Informazioni utili da curl_getinfo

php
$info = curl_getinfo($ch);
// $info['http_code']       — codice di risposta
// $info['content_type']    — Content-Type (qui c'è anche il charset!)
// $info['redirect_url']    — dove ha rediretto
// $info['total_time']      — quanto tempo ha richiesto
// $info['primary_ip']      — IP del server (utile per verificare un proxy)
// $info['size_download']   — dimensione della risposta

Ottenere gli header della risposta separatamente

php
curl_setopt($ch, CURLOPT_HEADER, true); // includere gli header nell'output
$response = curl_exec($ch);

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

Più pulito: con un callback che accumula gli header in un array:

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); // è obbligatorio restituire la lunghezza
});
curl_exec($ch);
// ora abbiamo $headers['content-type'], $headers['set-cookie'] ecc.

Particolarmente importanti: Content-Type (codifica), Set-Cookie, Location (redirect), Retry-After (quanto aspettare dopo un 429), Content-Length.


12. Camuffarsi da browser, pause, retry

Per evitare il ban alla seconda richiesta, lo scraper deve comportarsi «come una persona».

Header realistici

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: it-IT,it;q=0.9,en;q=0.8',
    'Referer: https://example.com/',
    'Connection: keep-alive',
]);

Pause tra le richieste

php
usleep(random_int(800_000, 2_500_000)); // pausa casuale di 0,8–2,5 s

Le pause casuali sembrano più naturali di quelle fisse. È una cortesia verso il server e riduce il rischio di ban.

Retry con attesa esponenziale (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; // non ha senso riprovare
        }
        sleep(2 ** $attempt); // 2, 4, 8 secondi...
    }
    return null;
}

Cos’altro aiuta a evitare il ban

  • Rotazione di User-Agent e proxy.
  • Conservare i cookie tra le richieste (come un browser vero).
  • Rispettare il Retry-After sui 429.
  • Parallelizzare con moderazione (non centinaia di thread su un solo dominio).

13. Pagine JavaScript e browser headless

cURL ottiene l’HTML originale, ma non esegue JavaScript. Se il contenuto viene disegnato lato client (una SPA in React/Vue), nell’HTML non ci sarà: vedrai blocchi vuoti.

Le opzioni:

  1. Trovare l’API nascosta (sezione 4.5) — quasi sempre la via migliore: la SPA prende i dati da un endpoint JSON che puoi interrogare direttamente.
  2. Browser headless — avviare un motore vero che esegua il JS: - Symfony Panther — wrapper PHP su ChromeDriver/Selenium. - php-webdriver + Selenium/Chrome. - L’accoppiata con Puppeteer/Playwright (Node.js) — a volte è più semplice spostare il rendering in un microservizio a parte.
php
// Esempio con Symfony Panther
use Symfony\Component\Panther\Client;

$client = Client::createChromeClient();
$crawler = $client->request('GET', 'https://spa-example.com');
$client->waitFor('.product');         // aspettiamo che il JS disegni il contenuto
$titles = $crawler->filter('.product .title')->each(fn($n) => $n->text());

I browser headless sono pesanti e lenti: usali solo quando senza JS non se ne esce.


14. Salvataggio degli URL e code

Quando lo scraper percorre centinaia di migliaia di pagine, servono una coda di URL e un registro di ciò che è già stato elaborato. A grandi linee, gli approcci principali:

Cosa memorizzare

  • la coda degli URL «da elaborare» (frontier);
  • l’insieme degli URL già visitati (per non passare due volte dallo stesso); per la deduplicazione è comodo salvare l’hash dell’URL;
  • lo stato di ogni URL: in attesa / in elaborazione / completato / errore / numero di tentativi;
  • i risultati veri e propri (i dati già estratti).

La variante semplice: un database

sql
CREATE TABLE crawl_queue (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    url         VARCHAR(2048) NOT NULL,
    url_hash    CHAR(40) NOT NULL,           -- sha1(url), per 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)
);

Il worker «prende» il task in modo atomico, così due processi non prendono lo stesso 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) è la chiave per distribuire i task in sicurezza tra più worker.

Quando serve più scala

  • Redis (liste LPUSH/BRPOP, insiemi SADD per la deduplicazione) — una coda velocissima, una scelta molto diffusa.
  • RabbitMQ / Kafka / Beanstalkd — broker di messaggi completi, se i worker sono tanti e serve una consegna affidabile.
  • Filtro di Bloom — verifica compatta del «questo URL l’abbiamo già visto?» su miliardi di indirizzi senza salvare tutte le stringhe.

Principio architetturale

Separa i ruoli: il producer trova i nuovi link e li mette in coda; i worker consumano la coda in parallelo e scrivono il risultato. Così il sistema scala orizzontalmente senza sforzo: basta aggiungere worker.


15. Principali pro e contro dell’implementazione in PHP

Pro

  • Barriera d’ingresso bassa — cURL e il DOM sono integrati e l’ambiente è disponibile quasi ovunque.
  • Un cURL eccellente — gestione flessibile di proxy, cookie, SSL e header.
  • Librerie mature — Guzzle, Symfony DomCrawler/Panther, code pronte all’uso.
  • Facile da integrare in un progetto web PHP esistente (CMS, pannello di amministrazione) — lo scraper scrive direttamente nello stesso database.
  • Deployment economico — l’hosting per PHP è diffusissimo e costa poco.

Contro

  • Niente multithreading vero di serie. Il parallelismo passa per curl_multi, più processi o runtime asincroni (ReactPHP/Amp/Swoole). Più complicato dei thread in Go o dell’async in Python.
  • Il JS non viene eseguito — per le SPA serve un browser headless, pesante e lento.
  • Memoria. Le librerie vecchie (Simple HTML DOM) divorano memoria; su grandi volumi bisogna sorvegliare le perdite nei worker di lunga durata.
  • Velocità. Su scala estrema gli stack specializzati (Scrapy in Python, Colly in Go) sono spesso più efficienti e offrono più strumenti pronti.
  • Fragilità. Come ogni scraper, si rompe quando il sito cambia il markup: non è una specificità di PHP, ma va tenuto presente.

In sintesi: PHP è una scelta eccellente per la maggior parte dei progetti di scraping di piccola e media scala, soprattutto quando i dati devono finire direttamente in un progetto PHP. Per crawler molto grandi e rendering JS pesante conviene valutare stack specializzati o spostare il rendering in un servizio a parte.


16. Conclusioni

Uno scraper PHP minimo, pronto per la produzione, comprende:

  • il download via cURL con timeout, redirect e CURLOPT_ENCODING => '';
  • User-Agent e header realistici;
  • la conversione della codifica in UTF-8 prima del parsing (la cura del mojibake);
  • il parsing con DOMXPath o Symfony DomCrawler (niente regex);
  • il controllo del codice HTTP e la gestione di 404/403/429/5xx;
  • le pause tra le richieste e i retry con backoff;
  • il cookie jar, se servono autenticazione/sessioni;
  • i proxy/la rotazione degli IP, se ci sono blocchi (o Tor come variante gratuita);
  • curl_multi/i worker per la velocità sui grandi volumi;
  • una coda di URL con deduplicazione per un crawling serio.

Le regole d’oro: separa il download dal parsing, sistema sempre la codifica prima del parsing, rispetta il server altrui (pause, robots.txt, niente DDoS) e cerca prima un JSON/un’API già pronti anziché combattere con il markup.