O web scraping clássico em PHP é cURL + DOMDocument ou uma dupla como o Symfony DomCrawler. É rápido, barato e funciona às mil maravilhas exatamente enquanto o site entregar o HTML pronto. O front-end moderno quase nunca entrega: o conteúdo chega via AJAX, React/Vue/Angular o desenham já no navegador, os preços e os catálogos se escondem atrás de uma rolagem infinita e, na porta, espera um sistema anti-bots que verifica se o cliente sabe executar JavaScript.
Nesse momento o cliente HTTP deixa de dar conta e é preciso levantar um navegador de verdade — ou seja, controlar um Chrome ou Firefox headless diretamente do PHP. Isso resolve o problema da renderização de JS, mas em troca traz uma classe inteira de problemas novos: recursos, vazamentos de memória, processos zumbis, abas travadas e uma depuração complicada. É disso que vamos falar.
Quando o navegador é realmente necessário
O navegador é uma ferramenta pesada, e só vale a pena carregá-lo quando há necessidade real. Sinais de que não dará para evitá-lo:
- o conteúdo aparece no DOM somente após a execução de JavaScript (SPA, carregamento adiado);
- são necessárias ações de usuário: cliques, rolagem, preenchimento de formulários, cenários de várias etapas;
- o site se defende ativamente dos bots e verifica a execução de JS, a impressão digital do navegador, o comportamento do mouse;
- é preciso renderizar para captura de tela ou PDF, ou medir métricas de desempenho.
Se, ao contrário, a página entrega os dados necessários no HTML original ou como JSON por meio de uma API interna, o navegador é dispensável: uma requisição HTTP honesta será dezenas de vezes mais rápida e estável.
Bibliotecas populares
Symfony Panther
O Panther é a biblioteca oficial da equipe do Symfony. Por baixo, utiliza o protocolo WebDriver e drivers reais (ChromeDriver, GeckoDriver), e sua API é compatível com DomCrawler e BrowserKit. Essa é sua principal vantagem: você escreve o código de sempre e, no lugar de um cliente HTTP, por baixo trabalha um 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'); // esperamos o elemento aparecer
$titles = $crawler->filter('.product-card h2')->each(
fn ($node) => $node->text()
);
$client->quit(); // OBRIGATÓRIO: senão o processo do navegador fica penduradoO Panther vai bem para testes de integração e scraping moderado. Sua maior dor: ele não gerencia com perfeição o ciclo de vida do navegador — com exceções, erros fatais ou um quit() esquecido, os processos se acumulam.
php-webdriver/webdriver + Selenium
O php-webdriver (o antigo Facebook WebDriver) é o cliente do protocolo WebDriver de mais baixo nível, mas também o mais flexível. Costuma trabalhar em par com o Selenium Grid ou Standalone, o que escala confortavelmente para um 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(); // no finally, para encerrar a sessão mesmo em caso de erro
}A dupla PHP + Selenium é especialmente confortável quando a infraestrutura de navegadores precisa ser extraída para um serviço à parte (Docker Compose, Kubernetes) e o PHP deve permanecer como um cliente leve.
chrome-php/chrome (headless Chrome via DevTools Protocol)
Esta biblioteca conversa com o Chrome diretamente pelo Chrome DevTools Protocol (CDP), sem passar pelo WebDriver. É mais rápida, está mais perto do «metal» e dá controle fino sobre páginas, rede e abas.
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(); // fechamos o navegador e todas as suas páginas
}A abordagem CDP oferece o acesso mais direto ao gerenciamento de abas (targets) e à interceptação de requisições de rede — algo que nos será útil mais adiante.
Goutte / DomCrawler — para comparar
Vale lembrar: o Goutte (hoje, simplesmente o BrowserKit sobre o HttpClient) e o DomCrawler puro não são navegadores — não executam JavaScript. Muitas vezes são confundidos com soluções de navegador, mas são clientes HTTP. Se a página precisa de JS, eles são inúteis; em compensação, com conteúdo estático são incomparavelmente mais leves.
Problemas do scraping com navegador
Recursos e desempenho
Cada instância do Chrome são dezenas, às vezes centenas, de megabytes de RAM, com processos separados para renderização, GPU e rede. Uma dezena de navegadores em paralelo devora com facilidade vários gigabytes. Por isso o scraping com navegador quase sempre é construído como um pool com limite rígido de paralelismo, uma fila de tarefas e timeouts em cada operação.
Um conjunto típico de flags de inicialização para ambiente de servidor, que economiza recursos e elimina travamentos frequentes:
--headless=new
--no-sandbox
--disable-dev-shm-usage # crítico no Docker: senão /dev/shm transborda e o Chrome cai
--disable-gpu
--disable-extensions
--blink-settings=imagesEnabled=false # não carregar imagens quando não são necessárias
--js-flags=--max-old-space-size=512 # limitar o apetite do V8Vazamentos de memória
Esta é, talvez, a principal doença oculta dos scrapers de vida longa. Os vazamentos chegam por duas frentes.
Do lado do próprio navegador. O Chrome não foi pensado para abrir milhares de páginas em uma mesma sessão. O consumo de memória cresce de página em página: o cache se acumula, as árvores DOM não são liberadas, os listeners de eventos do lado do site se amontoam. Um mesmo processo de navegador atendendo a uma fila longa pode ocupar, depois de algumas horas, várias vezes mais memória do que no início.
Do lado do PHP. O próprio processo worker de PHP também goteja: objetos que envolvem páginas, respostas, árvores DOM acumuladas em memória, variáveis não limpas. Especialmente perigosos são os workers long-running (Symfony Messenger, RoadRunner, Swoole, um daemon comum em um laço while (true)), em que o processo vive dias inteiros.
Medidas práticas:
- Reiniciar o worker por contador de tarefas. O recurso mais confiável: o worker processa N tarefas (por exemplo, 50--200) e encerra; o supervisor levanta um novo. A memória acumulada é liberada por completo pelo sistema operacional, não pelo coletor de lixo do PHP.
- Reiniciar também o próprio navegador periodicamente. Não manter um mesmo Chrome por milhares de páginas: fechá-lo e criá-lo de novo a cada M páginas.
- Fechar as páginas (abas) logo depois de usá-las, e não apenas ao final de todo o lote.
- Vigiar as métricas. Registrar o
memory_get_usage(true)do worker e o RSS dos processos do navegador, e montar gráficos. Um crescimento linear da memória ao longo do tempo é sinal inequívoco de vazamento. - Usar
gc_collect_cycles()com cuidado. A coleta forçada de referências cíclicas às vezes ajuda, mas não substitui o reinício.
Janelas sem fechar e processos zumbis
É um problema específico dos navegadores e fácil de deixar passar. Se o script PHP caiu com uma exceção, foi morto por timeout ou simplesmente esqueceu de chamar quit()/close(), o processo do navegador não morre junto. Ele fica pendurado no sistema — com todas as suas abas abertas e sua memória ocupada. Em um scraper ativo, esses processos «órfãos» se acumulam às dezenas e, depois de um dia, o servidor está cheio de Chromes zumbis mesmo sem restar uma única tarefa ativa.
Isso precisa ser monitorado e limpo sem falta. Vários níveis de defesa:
1. Fechamento no finally. Todo código que abre um navegador deve fechá-lo no finally, para que uma exceção não deixe o processo pendurado:
$browser = $factory->createBrowser([...]);
try {
// ... trabalho
} finally {
$browser->close();
}2. Registro de uma função de shutdown. Para o caso dos erros fatais:
register_shutdown_function(function () use ($browser) {
try { $browser->close(); } catch (\Throwable $e) {}
});3. Monitoramento externo de processos. Verificar com regularidade (por cron ou com um script watchdog) quantos processos de navegador vivem no sistema e se há entre eles «velhos» que sobreviveram ao pai:
# Quantos processos de chrome/chromium estão pendurados agora
pgrep -c -f 'chrome|chromium'
# Encontrar processos com mais de 30 minutos: candidatos a eliminação
ps -eo pid,etimes,comm | awk '$2 > 1800 && $3 ~ /chrome/ {print $1}'
# Limpeza dura de navegadores zumbis com mais de meia hora
ps -eo pid,etimes,comm | awk '$2 > 1800 && $3 ~ /chrome/ {print $1}' | xargs -r kill -9Convém agendar esse watchdog como uma tarefa cron à parte a cada poucos minutos. Ele não cura a causa, mas evita que o servidor desabe pelas janelas acumuladas enquanto você procura o vazamento. Em paralelo, é útil criar um alerta do tipo «há mais de X processos de navegador» ou «existe um processo de navegador com mais de Y minutos» — é o primeiro sinal de que em algum lugar um quit() está se perdendo.
4. Execução em um contêiner descartável. A variante radical porém limpa: cada tarefa de scraping inicia em um contêiner Docker novo com seu navegador, que é destruído de forma garantida ao fim da tarefa. Os zumbis não têm fisicamente onde se acumular.
Gerenciamento de abas
Abrir um navegador novo para cada página sai caro. Muitas vezes compensa manter um único navegador e trabalhar com abas (em termos de CDP, targets; em termos de WebDriver, windows). Mas aqui há armadilhas próprias.
A regra principal: as abas precisam ser contadas e fechadas com a mesma disciplina que os próprios navegadores. Uma aba aberta e esquecida é o mesmo vazamento, só que dentro de um processo vivo. O próprio site pode abrir abas novas (target="_blank", popups, publicidade) e, se ninguém as rastreia, elas se acumulam em silêncio.
// chrome-php: controle explícito das abas
$page = $browser->createPage();
$page->navigate('https://example.com')->waitForNavigation();
$data = $page->getHtml();
$page->close(); // fechamos a aba imediatamente, sem esperar o fim do lote// php-webdriver: vigiamos as janelas e fechamos as que sobram
$handles = $driver->getWindowHandles();
foreach ($handles as $handle) {
if ($handle !== $mainWindow) {
$driver->switchTo()->window($handle)->close();
}
}
$driver->switchTo()->window($mainWindow);Práticas úteis:
- limitar o número de abas abertas ao mesmo tempo (por exemplo, não mais que 5--10 por navegador);
- fechar a aba logo depois de processar a página, em vez de acumulá-las «para depois»;
- conferir periodicamente o número real de abas com o esperado — uma divergência avisa que o site abriu algo a mais ou que o código não está fechando em algum ponto;
- ao reutilizar uma aba, limpar seu estado (cookies, localStorage); caso contrário, as sessões se misturam entre tarefas.
PHP como orquestrador: quando na verdade ele executa Python ou Node
Merece uma seção própria um cenário arquitetural honesto que acontece com mais frequência do que se costuma admitir. O PHP desempenha no projeto o papel de orquestrador: aceita as tarefas, coloca-as na fila, governa a lógica do processo de negócio, grava os resultados no banco de dados. Mas o scraping com navegador não é ele que faz — porque o ecossistema de automação de navegadores é muito mais rico no Node.js (Puppeteer, Playwright) e no Python (Playwright, Selenium, undetected-chromedriver, camadas anti-detecção).
No fim, o «scraper em PHP» na verdade tem esta cara: o PHP prepara a tarefa e dispara um processo externo em outra linguagem, que é quem faz todo o trabalho real com o navegador, enquanto o PHP se limita a processar sua saída.
use Symfony\Component\Process\Process;
$process = new Process([
'python3',
'/app/scrapers/playwright_scraper.py',
'--url', $url,
'--timeout', '30',
]);
$process->setTimeout(60); // timeout para o processo inteiro: obrigatório
try {
$process->mustRun();
$payload = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
// importante: se o PHP matou o processo por timeout, o navegador filho
// também precisa ser finalizado; senão, zumbis de novo
$process->stop(5, SIGKILL);
throw $e;
}Por que se faz assim:
- Maturidade das ferramentas. Playwright e Puppeteer superam com clareza os análogos de PHP em interceptação de rede, emulação, anti-detecção e estabilidade.
- Isolamento. O navegador vive em um processo filho. Se cai ou vaza, não derruba nem incha o worker de PHP. O PHP se mantém leve e estável.
- Escala por papéis. O PHP responde pela lógica de negócio, pelas filas e pelo armazenamento — o que sabe fazer bem. O navegador, pela renderização.
A principal sutileza desse esquema é a fronteira de responsabilidade sobre os processos. Quando o PHP mata o processo filho por timeout, é importante garantir que com ele morreu também o navegador que esse processo iniciou. Caso contrário, os Chromes zumbis se mudam um nível para baixo e ficam ainda mais difíceis de detectar: o PHP está limpo, mas a memória vaza «não se sabe de onde». Por isso o script filho deve fechar ele mesmo seu navegador corretamente no seu finally/atexit, e o PHP deve enviar o SIGKILL para o grupo de processos inteiro, não para um único PID. Iniciar o processo filho no seu próprio process group e matar o grupo todo é a maneira confiável de não deixar pontas soltas.
É uma arquitetura normal e difundida. Chamar o projeto de «scraper em PHP» não é engano nenhum: o PHP orquestra de verdade o processo; apenas delega o trabalho pesado do navegador para onde existem ferramentas melhores para isso.
VNC para a depuração inicial
O modo headless é confortável em produção, mas um suplício na hora de depurar: você não vê o que acontece na página. O scraper «não encontra o elemento» e o porquê é um mistério: se apareceu o banner de cookies, se saltou um CAPTCHA, se o layout é diferente para o bot ou se simplesmente não se esperou o carregamento. As capturas de tela ajudam, mas são quadros soltos, não uma imagem viva.
A solução para a depuração inicial é levantar o navegador não em headless, mas em modo normal dentro de um display virtual, e observá-lo por VNC. Você vê literalmente a tela do navegador em tempo real: como ele clica, o que carrega, onde trava.
O esquema no Docker é simples:
- dentro do contêiner é iniciado um servidor X virtual (
Xvfb) — o navegador precisa de «algum lugar» onde desenhar; - por cima, um servidor VNC (
x11vnc) que retransmite esse display para fora; - o navegador inicia sem a flag
--headless, para desenhar de verdade na tela; - você se conecta com qualquer cliente VNC (ou via noVNC direto no navegador) e observa.
# Trecho: kit para depurar por VNC
RUN apt-get update && apt-get install -y \
chromium xvfb x11vnc fluxbox
ENV DISPLAY=:99
# inicialização (simplificada; na prática, via supervisor/entrypoint):
# Xvfb :99 -screen 0 1920x1080x24 &
# fluxbox & # gerenciador de janelas leve
# x11vnc -display :99 -forever -nopw & # VNC para fora, porta 5900
# depois o PHP/Python inicia o Chrome SEM --headlessAs imagens prontas do tipo selenium/standalone-chrome-debug trazem o VNC de fábrica — para a dupla PHP + Selenium é a partida mais rápida: conecte-se à porta 5900, dispare a tarefa do PHP e veja o que o navegador faz.
Quando o VNC é especialmente valioso:
- você está caçando um CAPTCHA intermitente ou um anti-bot que não dispara sempre;
- o seletor «às vezes não aparece» — a olho nu se vê o que atrapalha (uma janela modal, um popup, outra configuração regional);
- você está destrinchando um cenário de várias etapas (login, carrinho, pagamento) em que a sequência importa;
- você verifica como o site reage exatamente ao seu navegador, com suas flags e seu fingerprint.
Importante: o VNC é uma ferramenta de depuração inicial e desenvolvimento do cenário. Em produção não se deve mantê-lo: uma porta aberta é superfície de ataque, e um navegador com interface gasta recursos a mais. Depurado o cenário ao vivo e confirmado que tudo funciona, volte ao headless.
Checklist final
Se você constrói um scraper com navegador sobre PHP, tenha em mente no mínimo:
- Escolha da ferramenta. Panther, pela simplicidade e compatibilidade com o Symfony; php-webdriver + Selenium, para uma infraestrutura escalável; chrome-php/chrome, para o controle fino via CDP. Para o trabalho sério com navegador, não hesite em delegar ao Playwright/Puppeteer por meio de um processo filho.
- Ciclo de vida. Feche sempre o navegador e as abas no
finally; registre umaregister_shutdown_function; reinicie os workers e os próprios navegadores por contador. - Memória. Registre o consumo do worker e o RSS dos navegadores, procure crescimentos lineares, trate-os com reinícios.
- Janelas zumbis. Monitore sem falta o número e a idade dos processos do navegador, instale um watchdog que finalize os processos órfãos e alertas sobre seu acúmulo.
- Abas. Conte-as e feche-as com o mesmo rigor que os navegadores; vigie para que o site não tenha aberto nenhuma a mais.
- Orquestração. Se o PHP dispara Python/Node, mate o grupo de processos completo, não um único PID, e que o script filho feche ele mesmo o seu navegador.
- Depuração. Levante VNC + Xvfb para ver o navegador ao vivo durante o desenvolvimento; não o leve para produção.
O scraping com navegador em PHP não consiste em «encontrar a biblioteca mágica», e sim na disciplina de gerenciar recursos. Extrair os dados de um DOM pronto é a parte fácil. O difícil é conseguir que, depois de uma semana de operação ininterrupta, o servidor não esteja coalhado de abas zumbis e workers inchados.