Il web scraping classico in PHP è cURL + DOMDocument o una coppia come Symfony DomCrawler. È veloce, economico e funziona benissimo esattamente finché il sito consegna l’HTML già pronto. Il front-end moderno non lo fa quasi mai: i contenuti arrivano via AJAX, React/Vue/Angular li disegnano direttamente nel browser, prezzi e cataloghi si nascondono dietro uno scroll infinito e all’ingresso ti accoglie un sistema anti-bot che controlla se il client sa eseguire JavaScript.
A quel punto il client HTTP smette di bastare e tocca tirare su un browser vero — cioè controllare un Chrome o Firefox headless direttamente da PHP. Questo risolve il problema del rendering JS, ma in cambio porta un’intera classe di problemi nuovi: risorse, perdite di memoria, processi zombie, schede andate in crash e un debugging complicato. È di questo che parleremo.
Quando il browser serve davvero
Il browser è uno strumento pesante, e vale la pena caricarselo solo in caso di reale necessità. I segnali che non se ne può fare a meno:
- i contenuti compaiono nel DOM solo dopo l’esecuzione di JavaScript (SPA, lazy loading);
- servono azioni utente: clic, scroll, compilazione di form, scenari in più passaggi;
- il sito si difende attivamente dai bot e controlla l’esecuzione di JS, l’impronta del browser, il comportamento del mouse;
- serve il rendering in screenshot o PDF, o la raccolta di metriche di prestazioni.
Se invece la pagina consegna i dati necessari nell’HTML di partenza o come JSON tramite una API interna, il browser è superfluo: una richiesta HTTP onesta sarà decine di volte più veloce e stabile.
Librerie popolari
Symfony Panther
Panther è la libreria ufficiale del team di Symfony. Sotto il cofano usa il protocollo WebDriver e driver reali (ChromeDriver, GeckoDriver), e la sua API è compatibile con DomCrawler e BrowserKit. È questo il suo principale vantaggio: scrivi il codice di sempre e, al posto del client HTTP, sotto lavora un Chrome vivo.
use Symfony\Component\Panther\Client;
$client = Client::createChromeClient(null, [
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
]);
$crawler = $client->request('GET', 'https://example.com');
$client->waitFor('.product-card'); // aspettiamo la comparsa dell'elemento
$titles = $crawler->filter('.product-card h2')->each(
fn ($node) => $node->text()
);
$client->quit(); // OBBLIGATORIO: altrimenti il processo del browser resta appesoPanther va bene per i test di integrazione e per uno scraping moderato. Il suo dolore principale: non gestisce alla perfezione il ciclo di vita del browser — con eccezioni, errori fatali o un quit() dimenticato i processi si accumulano.
php-webdriver/webdriver + Selenium
php-webdriver (l’ex Facebook WebDriver) è il client del protocollo WebDriver più a basso livello, ma anche il più flessibile. Di solito lavora in coppia con Selenium Grid o Standalone, il che scala comodamente in cluster.
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\WebDriverBy;
$driver = RemoteWebDriver::create(
'http://selenium-hub:4444/wd/hub',
DesiredCapabilities::chrome()
);
try {
$driver->get('https://example.com');
$element = $driver->findElement(WebDriverBy::cssSelector('.price'));
$price = $element->getText();
} finally {
$driver->quit(); // in finally, per chiudere la sessione anche in caso di errore
}La coppia PHP + Selenium è particolarmente comoda quando l’infrastruttura dei browser va spostata in un servizio a parte (Docker Compose, Kubernetes), lasciando PHP come client leggero.
chrome-php/chrome (headless Chrome via DevTools Protocol)
Questa libreria parla con Chrome direttamente tramite il Chrome DevTools Protocol (CDP), scavalcando WebDriver. È più veloce, più vicina al «metallo» e offre un controllo fine su pagine, rete e schede.
use HeadlessChromium\BrowserFactory;
$factory = new BrowserFactory('chromium-browser');
$browser = $factory->createBrowser([
'headless' => true,
'noSandbox' => true,
'windowSize' => [1920, 1080],
'keepAlive' => false,
]);
try {
$page = $browser->createPage();
$page->navigate('https://example.com')->waitForNavigation();
$html = $page->getHtml();
} finally {
$browser->close(); // chiudiamo il browser e tutte le sue pagine
}L’approccio CDP dà l’accesso più diretto alla gestione delle schede (targets) e all’intercettazione delle richieste di rete — ci tornerà utile più avanti.
Goutte / DomCrawler — per confronto
Va ricordato: Goutte (oggi semplicemente BrowserKit sopra HttpClient) e il puro DomCrawler non sono browser — non eseguono JavaScript. Spesso vengono confusi con le soluzioni basate su browser, ma sono client HTTP. Se la pagina ha bisogno di JS sono inutili; in compenso, sulla statica sono incomparabilmente più leggeri.
I problemi dello scraping con browser
Risorse e prestazioni
Ogni istanza di Chrome significa decine, a volte centinaia di megabyte di RAM, con processi separati per renderer, GPU e rete. Una decina di browser in parallelo si mangia con facilità diversi gigabyte. Per questo lo scraping con browser si costruisce quasi sempre come un pool con un limite rigido di parallelismo, una coda di task e timeout su ogni operazione.
Un set tipico di flag di avvio per un ambiente server, che risparmia risorse ed elimina i crash più frequenti:
--headless=new
--no-sandbox
--disable-dev-shm-usage # critico in Docker: altrimenti /dev/shm si riempie e Chrome va in crash
--disable-gpu
--disable-extensions
--blink-settings=imagesEnabled=false # non caricare le immagini se non servono
--js-flags=--max-old-space-size=512 # limitare l'appetito di V8Perdite di memoria
È questo, forse, il principale dolore nascosto degli scraper longevi. Le perdite arrivano da due fronti.
Dal lato del browser stesso. Chrome non è pensato per aprire migliaia di pagine in un’unica sessione. Il consumo di memoria cresce di pagina in pagina: la cache si accumula, gli alberi DOM non vengono liberati, i listener di eventi lato sito si ammucchiano. Un unico processo browser che smaltisce una coda lunga può occupare, dopo qualche ora, parecchie volte più memoria che all’inizio.
Dal lato di PHP. Anche il processo worker di PHP, di suo, perde: oggetti wrapper delle pagine, risposte, alberi DOM accumulati in memoria, variabili mai ripulite. Particolarmente a rischio sono i worker long-running (Symfony Messenger, RoadRunner, Swoole, un normale demone in un ciclo while (true)), dove il processo vive per giorni interi.
Misure pratiche:
- Riavviare il worker in base a un contatore di task. L’accorgimento più affidabile: il worker elabora N task (per esempio 50–200) e termina; il supervisor ne avvia uno nuovo. La memoria accumulata viene liberata per intero dal sistema operativo, non dal garbage collector di PHP.
- Riavviare periodicamente anche il browser. Non tenere lo stesso Chrome per migliaia di pagine: chiuderlo e ricrearlo ogni M pagine.
- Chiudere le pagine (schede) subito dopo l’uso, e non solo alla fine dell’intero lotto.
- Tenere d’occhio le metriche. Loggare il
memory_get_usage(true)del worker e l’RSS dei processi del browser, costruire grafici. Una crescita lineare della memoria nel tempo è il segno inequivocabile di una perdita. - Usare
gc_collect_cycles()con prudenza. La raccolta forzata dei riferimenti circolari a volte aiuta, ma non sostituisce il riavvio.
Finestre non chiuse e processi zombie
È un problema specifico dei browser e facile da trascurare. Se lo script PHP è caduto con un’eccezione, è stato ucciso per timeout o semplicemente ha dimenticato di chiamare quit()/close(), il processo del browser non muore con lui. Resta appeso nel sistema — con tutte le schede aperte e la memoria occupata. Su uno scraper attivo questi processi «orfani» si accumulano a decine, e nel giro di un giorno il server è intasato di Chrome zombie anche se non c’è più nemmeno un task attivo.
Questo va assolutamente monitorato e ripulito. Diversi livelli di difesa:
1. Chiusura in finally. Qualsiasi codice che apre un browser deve chiuderlo in finally, perché un’eccezione non lasci il processo appeso:
$browser = $factory->createBrowser([...]);
try {
// ... lavoro
} finally {
$browser->close();
}2. Registrazione di una funzione di shutdown. Per il caso degli errori fatali:
register_shutdown_function(function () use ($browser) {
try { $browser->close(); } catch (\Throwable $e) {}
});3. Monitoraggio esterno dei processi. Verificare con regolarità (via cron o con uno script watchdog) quanti processi browser vivono nel sistema e se tra loro ci sono «vecchi» sopravvissuti al proprio padre:
# Quanti processi chrome/chromium sono appesi in questo momento
pgrep -c -f 'chrome|chromium'
# Trovare i processi più vecchi di 30 minuti: candidati all'eliminazione
ps -eo pid,etimes,comm | awk '$2 > 1800 && $3 ~ /chrome/ {print $1}'
# Pulizia dura dei browser zombie più vecchi di mezz'ora
ps -eo pid,etimes,comm | awk '$2 > 1800 && $3 ~ /chrome/ {print $1}' | xargs -r kill -9Questo watchdog conviene impostarlo come job cron a parte, ogni pochi minuti. Non cura la causa, ma evita che il server crolli sotto le finestre accumulate mentre cerchi la perdita. In parallelo è utile creare un alert: «i processi browser sono più di X» oppure «esiste un processo browser più vecchio di Y minuti» — è il primo segnale che da qualche parte si sta perdendo un quit().
4. Esecuzione in un container usa e getta. La variante radicale ma pulita: ogni task di scraping parte in un container Docker nuovo con il suo browser, che viene distrutto in modo garantito a fine task. Gli zombie non hanno fisicamente dove accumularsi.
Gestione delle schede
Aprire un browser nuovo per ogni pagina costa caro. Spesso conviene tenere un solo browser e lavorare con le schede (in termini CDP, targets; in termini WebDriver, windows). Ma anche qui ci sono insidie.
La regola principale: le schede vanno contate e chiuse con la stessa disciplina dei browser. Una scheda aperta e dimenticata è la stessa perdita, solo dentro un processo vivo. Il sito può aprire da sé nuove schede (target="_blank", popup, pubblicità), e se non le tieni d’occhio si accumulano in silenzio.
// chrome-php: controllo esplicito delle schede
$page = $browser->createPage();
$page->navigate('https://example.com')->waitForNavigation();
$data = $page->getHtml();
$page->close(); // scheda chiusa subito, senza aspettare la fine del lotto// php-webdriver: teniamo d'occhio le finestre e chiudiamo quelle in più
$handles = $driver->getWindowHandles();
foreach ($handles as $handle) {
if ($handle !== $mainWindow) {
$driver->switchTo()->window($handle)->close();
}
}
$driver->switchTo()->window($mainWindow);Pratiche utili:
- limitare il numero di schede aperte contemporaneamente (per esempio non più di 5–10 per browser);
- chiudere la scheda subito dopo aver elaborato la pagina, invece di accumularle «per dopo»;
- confrontare periodicamente il numero reale di schede con quello atteso — una discrepanza segnala che il sito ha aperto qualcosa di troppo o che il codice da qualche parte non chiude;
- quando riutilizzi una scheda, ripulirne lo stato (cookie, localStorage), altrimenti le sessioni sconfinano da un task all’altro.
PHP come orchestratore: quando in realtà avvia Python o Node
Merita un discorso a parte uno scenario architetturale onesto, più frequente di quanto si sia soliti ammettere. Nel progetto PHP gioca il ruolo di orchestratore: accetta i task, li mette in coda, governa la logica del processo di business, scrive i risultati nel database. Ma lo scraping con browser non lo fa lui — perché l’ecosistema dell’automazione dei browser è molto più ricco in Node.js (Puppeteer, Playwright) e in Python (Playwright, Selenium, undetected-chromedriver, soluzioni anti-detection).
Alla fine lo «scraper in PHP» in realtà funziona così: PHP prepara il task e avvia un processo esterno in un altro linguaggio, che fa tutto il lavoro reale con il browser, mentre PHP si limita a interpretarne l’output.
use Symfony\Component\Process\Process;
$process = new Process([
'python3',
'/app/scrapers/playwright_scraper.py',
'--url', $url,
'--timeout', '30',
]);
$process->setTimeout(60); // timeout sull'intero processo: obbligatorio
try {
$process->mustRun();
$payload = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
// importante: se PHP ha ucciso il processo per timeout, anche il browser
// figlio va terminato, altrimenti di nuovo zombie
$process->stop(5, SIGKILL);
throw $e;
}Perché si fa così:
- Maturità degli strumenti. Playwright e Puppeteer superano nettamente gli analoghi PHP per intercettazione della rete, emulazione, anti-detection e stabilità.
- Isolamento. Il browser vive in un processo figlio. Se cade o perde memoria, non abbatte né gonfia il worker PHP. PHP resta leggero e stabile.
- Scalabilità per ruoli. PHP risponde della logica di business, delle code e dello storage — ciò che sa fare bene. Il browser, del rendering.
La sfumatura principale di questo schema è il confine di responsabilità sui processi. Quando PHP uccide il processo figlio per timeout, è importante assicurarsi che con lui sia morto anche il browser che quel processo aveva avviato. Altrimenti i Chrome zombie traslocano un livello più in basso e diventano ancora più difficili da notare: PHP è pulito, ma la memoria cola «non si sa da dove». Per questo lo script figlio deve chiudere da sé, correttamente, il proprio browser nel suo finally/atexit, e PHP deve mandare il SIGKILL all’intero gruppo di processi, non a un singolo PID. Avviare il processo figlio in un process group separato e uccidere tutto il gruppo è il modo affidabile per non lasciare residui.
È un’architettura normale e diffusa. Chiamare il progetto «scraper in PHP» non è un inganno: PHP orchestra davvero il processo; semplicemente delega il lavoro pesante del browser là dove gli strumenti per farlo sono migliori.
VNC per il primo debugging
La modalità headless è comoda in produzione, ma un supplizio in fase di debugging: non vedi cosa succede sulla pagina. Lo scraper «non trova l’elemento», e il perché è un mistero: è comparso il banner dei cookie, è scattato un CAPTCHA, il layout per il bot è diverso o semplicemente non si è aspettato il caricamento. Gli screenshot aiutano, ma sono fotogrammi sparsi, non un quadro dal vivo.
La soluzione per il primo debugging è avviare il browser non in headless, ma in modalità normale dentro un display virtuale, e osservarlo via VNC. Vedi letteralmente lo schermo del browser in tempo reale: come clicca, cosa si carica, dove si incaglia.
Lo schema in Docker è semplice:
- dentro il container si avvia un server X virtuale (
Xvfb) — al browser serve «un posto» dove disegnare; - sopra di lui, un server VNC (
x11vnc) che trasmette quel display verso l’esterno; - il browser parte senza il flag
--headless, per disegnare davvero sullo schermo; - ti colleghi con un qualsiasi client VNC (o via noVNC direttamente nel browser) e osservi.
# Frammento: kit per il debugging via VNC
RUN apt-get update && apt-get install -y \
chromium xvfb x11vnc fluxbox
ENV DISPLAY=:99
# avvio (semplificato; in pratica via supervisor/entrypoint):
# Xvfb :99 -screen 0 1920x1080x24 &
# fluxbox & # window manager leggero
# x11vnc -display :99 -forever -nopw & # VNC verso l'esterno, porta 5900
# poi PHP/Python avvia Chrome SENZA --headlessLe immagini già pronte come selenium/standalone-chrome-debug includono il VNC di serie — per la coppia PHP + Selenium è la partenza più rapida: ti colleghi alla porta 5900, lanci il task da PHP e guardi cosa combina il browser.
Quando il VNC è particolarmente prezioso:
- stai dando la caccia a un CAPTCHA intermittente o a un anti-bot che non scatta sempre;
- il selettore «a volte non si trova» — a occhio si vede subito cosa intralcia (una modale, un popup, un’altra lingua);
- stai sbrogliando uno scenario in più passaggi (login, carrello, pagamento) dove conta la sequenza;
- verifichi come il sito reagisce proprio al tuo browser, con i tuoi flag e il tuo fingerprint.
Importante: il VNC è uno strumento di primo debugging e sviluppo dello scenario. In produzione non va tenuto: una porta aperta è superficie d’attacco, e un browser con interfaccia consuma risorse in più. Una volta messo a punto lo scenario dal vivo e verificato che tutto funziona, torna all’headless.
Checklist finale
Se costruisci uno scraper con browser in PHP, tieni a mente come minimo:
- Scelta dello strumento. Panther per la semplicità e la compatibilità con Symfony; php-webdriver + Selenium per un’infrastruttura scalabile; chrome-php/chrome per il controllo fine via CDP. Per il lavoro serio con il browser, non esitare a delegare a Playwright/Puppeteer tramite un processo figlio.
- Ciclo di vita. Chiudi sempre browser e schede in
finally; registra unaregister_shutdown_function; riavvia i worker e i browser stessi in base a un contatore. - Memoria. Logga il consumo del worker e l’RSS dei browser, cerca le crescite lineari, curale con i riavvii.
- Finestre zombie. Monitora senza eccezioni numero ed età dei processi del browser, installa un watchdog che finisca i processi orfani e alert sul loro accumulo.
- Schede. Contale e chiudile con lo stesso rigore dei browser; controlla che il sito non ne abbia aperte di troppo.
- Orchestrazione. Se PHP avvia Python/Node, uccidi l’intero gruppo di processi, non un solo PID, e fai in modo che lo script figlio chiuda da sé il proprio browser.
- Debugging. Tira su VNC + Xvfb per osservare il browser dal vivo in fase di sviluppo; non portarlo in produzione.
Lo scraping con browser in PHP non consiste nel «trovare la libreria magica», ma nella disciplina della gestione delle risorse. Estrarre i dati da un DOM già pronto è la parte facile. Il difficile è fare in modo che, dopo una settimana di lavoro ininterrotto, il server non sia intasato di schede zombie e worker gonfi.