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
- Che cos’è il web scraping e quando serve
- Etica e aspetti legali (robots.txt, carico)
- Come scarichiamo la pagina - file_get_contents - cURL - Guzzle
- 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
- Risolvere i problemi di codifica (lettere accentate)
- multi_curl e parallelismo
- Uso dei proxy
- Scraping attraverso Tor
- Lavorare con HTTPS / SSL
- Lavorare con i cookie
- Codice di stato e altri header
- Camuffarsi da browser, pause, retry (integrazione)
- Pagine JavaScript e browser headless (integrazione)
- Salvataggio degli URL e code (panoramica)
- Principali pro e contro dell’implementazione in PHP
- 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:
- Scaricare la pagina HTML (richiesta HTTP).
- 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:
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
$html = file_get_contents('https://example.com');Funziona se in php.ini è attivo allow_url_fopen. Puoi passare un contesto con gli header:
$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.
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.
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.
$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.
composer require symfony/dom-crawler symfony/css-selectoruse 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:
$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>.
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
$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:
$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):
$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.
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.
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.
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
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:
ControlPort 9051
CookieAuthentication 0
HashedControlPassword 16:... # genera con: tor --hash-password "la_tua_password"Poi, da 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.
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:
curl.cainfo = "/path/to/cacert.pem"
openssl.cafile = "/path/to/cacert.pem"Non fare così in produzione:
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // disattiva la protezione contro i MITMQuesto 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:
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);10. Lavorare con i cookie
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.
$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
$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):
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
$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
$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 rispostaOttenere gli header della risposta separatamente
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:
$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
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
usleep(random_int(800_000, 2_500_000)); // pausa casuale di 0,8–2,5 sLe 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)
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-Aftersui 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:
- 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.
- 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.
// 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
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:
$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, insiemiSADDper 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.