Guia abrangente sobre como escrever scrapers (ferramentas de web scraping) em PHP puro: do download simples de uma página ao paralelismo, aos proxies, ao Tor e às filas. Todos os exemplos são funcionais: copie-os e adapte-os à sua tarefa.
Sumário
- O que é o web scraping e quando ele é necessário
- Ética e aspectos legais (robots.txt, carga)
- Como baixamos a página - file_get_contents - cURL - Guzzle
- Bibliotecas para parsear o conteúdo - Por que não usar regex - DOMDocument + DOMXPath - Symfony DomCrawler - Simple HTML DOM / phpQuery - Quando é melhor parsear JSON / a API oculta
- Solução de problemas de codificação (acentos e cedilha)
- multi_curl e paralelismo
- Uso de proxies
- Scraping através do Tor
- Trabalho com HTTPS / SSL
- Trabalho com cookies
- Código de status e outros cabeçalhos
- Camuflar-se como navegador, pausas, novas tentativas (complemento)
- Páginas JavaScript e navegadores headless (complemento)
- Armazenamento de URLs e filas (visão geral)
- Principais prós e contras de implementar em PHP
- Conclusão
1. O que é o web scraping e quando ele é necessário
O web scraping é a obtenção automática de páginas de um site e a extração de dados estruturados a partir delas: preços, descrições, contatos, notícias. O processo quase sempre consiste em dois passos:
- Baixar a página HTML (requisição HTTP).
- Parseá-la e extrair os trechos necessários (parsing de HTML/DOM).
Convém manter esses dois passos separados: o «baixador» e o «parser». Assim é possível trocar o método de download (cURL → proxy → Tor) sem tocar na lógica de extração.
Antes de escrever um scraper, verifique sempre uma coisa: se o site tem uma API aberta ou um endpoint JSON. Parsear um JSON pronto é dezenas de vezes mais simples e confiável do que arrancar os dados de um layout que muda toda semana.
2. Ética e aspectos legais
Antes de gerar carga em um servidor alheio, tenha em mente alguns pontos:
- robots.txt — o arquivo em que o site indica o que pode ser indexado. Juridicamente não proíbe o acesso, mas é um gesto de cortesia e, às vezes, parte dos termos de uso.
- Carga. Não envie centenas de requisições por segundo: isso se parece com um DDoS. Adicione pausas entre as requisições (veja a seção 12).
- Direitos autorais e dados pessoais. Coletar e republicar conteúdo pode infringir a lei. Redobre a cautela com os dados pessoais.
- Termos de uso (ToS). Muitos sites proíbem expressamente a coleta automática. Não chega a ser caso de polícia, mas pode terminar em bloqueios e reclamações.
Um leitor simples de 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; // não há robots.txt: formalmente não está proibido
}
// Verificação simplificada: procuramos um Disallow para o nosso caminho.
$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;
}Para projetos sérios, use um parser de robots.txt pronto (por exemplo
spatie/robots-txt) em vez de um caseiro.
3. Como baixamos a página
3.1. file_get_contents — o caminho mais simples
$html = file_get_contents('https://example.com');Funciona se allow_url_fopen estiver habilitado no php.ini. Dá para passar um contexto com cabeçalhos:
$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);Contras: não gerencia bem cookies nem proxies, não entrega códigos de resposta «de fábrica», e o controle de erros é pobre. Serve para scripts pontuais, não para um scraper em produção.
3.2. cURL — o burro de carga
O cURL é uma extensão disponível em praticamente qualquer ambiente e que dá controle total sobre a requisição. É a principal ferramenta para fazer scraping em PHP.
function fetch(string $url): string
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true, // devolver o resultado como string, não imprimi-lo
CURLOPT_FOLLOWLOCATION => true, // seguir os redirecionamentos
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 30, // tempo máximo total
CURLOPT_CONNECTTIMEOUT => 10, // tempo máximo para estabelecer a conexão
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 => '', // aceitar gzip/deflate e descomprimir
]);
$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;
}Opções-chave:
| Opção | Para quê |
|---|---|
CURLOPT_RETURNTRANSFER |
devolver a resposta como string |
CURLOPT_FOLLOWLOCATION |
seguir os redirecionamentos 301/302 |
CURLOPT_TIMEOUT / CURLOPT_CONNECTTIMEOUT |
não ficar travado eternamente |
CURLOPT_ENCODING => '' |
descomprimir o gzip automaticamente |
CURLOPT_HTTPHEADER |
cabeçalhos arbitrários (array de strings) |
CURLOPT_POSTFIELDS |
corpo da requisição POST |
3.3. Guzzle — um cliente HTTP moderno
Se o projeto usa Composer, é mais confortável trabalhar com o Guzzle. É um wrapper sobre o cURL com uma API humana, suporte a assincronia, middleware, cookie jar etc.
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();Daqui em diante, os exemplos usam cURL «na unha» — para mostrar a mecânica —, mas em um projeto real o Guzzle costuma economizar tempo.
4. Bibliotecas para parsear o conteúdo
4.1. Por que não usar expressões regulares
A tentação de parsear o HTML com uma expressão regular é grande, mas HTML não é uma linguagem regular. Qualquer aninhamento, tag sem fechar ou quebra de linha derruba a regex. As expressões regulares só são adequadas para trechos muito simples e planos (por exemplo, tirar um número de uma string), não para percorrer a árvore do documento.
4.2. DOMDocument + DOMXPath (integrado no PHP)
O método integrado mais confiável. Carregamos o HTML no DOM e o percorremos com XPath.
$dom = new DOMDocument();
libxml_use_internal_errors(true); // silenciamos os avisos do HTML «quebrado»
$dom->loadHTML($html);
libxml_clear_errors();
$xpath = new DOMXPath($dom);
// Todos os cabeçalhos h2 dentro do bloco com a classe article
$nodes = $xpath->query('//div[@class="article"]//h2');
foreach ($nodes as $node) {
echo trim($node->textContent), PHP_EOL;
}
// Obter o href dos links
$links = $xpath->query('//a/@href');
foreach ($links as $link) {
echo $link->value, PHP_EOL;
}Expressões XPath úteis:
| XPath | O que seleciona |
|---|---|
//a |
todos os links |
//div[@id="main"] |
o div com id="main" |
//div[contains(@class,"item")] |
os divs cuja classe contém item |
//table//tr/td[2] |
a segunda célula de cada linha da tabela |
//meta[@property="og:title"]/@content |
o valor do atributo content |
4.3. Symfony DomCrawler (via Composer) — recomendado
Um wrapper confortável sobre o DOM com suporte tanto a seletores CSS quanto a XPath.
composer require symfony/dom-crawler symfony/css-selectoruse Symfony\Component\DomCrawler\Crawler;
$crawler = new Crawler($html);
// Seletores CSS (requer css-selector)
$crawler->filter('div.article h2')->each(function (Crawler $node) {
echo $node->text(), PHP_EOL;
});
// Atributos
$title = $crawler->filter('meta[property="og:title"]')->attr('content');
// XPath também está disponível
$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) — uma biblioteca antiga e muito simples com sintaxe no estilo jQuery. É prática, mas devora memória e há anos quase não evolui. Para tarefas pequenas, pode servir. - phpQuery — um port do jQuery para PHP. Também obsoleta, mas com a sintaxe familiar
pq('div.item')->find('a').
Para projetos novos, melhor DomCrawler ou DOMXPath: são mais rápidos e têm manutenção.
4.5. JSON oculto / API — o caminho mais limpo
Abra as DevTools → aba Network. Muitas vezes os dados são carregados por uma requisição XHR à parte que devolve um JSON pronto. Parseá-lo é uma linha:
$data = json_decode($jsonString, true);É mais confiável que qualquer parsing de HTML: a estrutura do JSON muda com menos frequência que o layout.
5. Solução de problemas de codificação (acentos e cedilha)
A dor mais frequente: caracteres corrompidos («mojibake») no resultado — café em vez de café. As causas são uma divergência de codificações (o site está em ISO-8859-1/Windows-1252 enquanto o código espera UTF-8) e o fato de o DOMDocument nem sempre detectar bem a codificação da entrada.
5.1. Detectar a codificação da página
O site comunica sua codificação no cabeçalho HTTP Content-Type ou no <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]);
}
// Heurística como último recurso
return mb_detect_encoding($html, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true) ?: 'UTF-8';
}5.2. Converter para UTF-8
$charset = detectCharset($html, $contentType);
if ($charset !== 'UTF-8') {
$html = mb_convert_encoding($html, 'UTF-8', $charset);
// e substituímos a declaração do meta para o DOM não se confundir
$html = preg_replace('/charset=[\w-]+/i', 'charset=UTF-8', $html, 1);
}5.3. O truque-chave para o DOMDocument
DOMDocument::loadHTML adivinha a codificação a partir do conteúdo e erra com frequência. O recurso mais confiável é adicionar uma «dica» antes de carregar:
$dom = new DOMDocument();
libxml_use_internal_errors(true);
// obrigamos o parser a tratar a entrada como UTF-8
$dom->loadHTML('<?xml encoding="UTF-8">' . $html);
libxml_clear_errors();ou a variante com flags (no PHP 8.1+ não acrescenta nada extra):
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);Regra: converta primeiro todo o HTML para UTF-8 na fase de download e só depois o entregue ao parser. Assim os acentos e as cedilhas não quebram.
6. multi_curl e paralelismo
O PHP é single-thread por natureza, mas o cURL sabe manter várias requisições em paralelo por meio de curl_multi_*. O ganho de velocidade é enorme: enquanto um servidor «pensa», os demais vão sendo baixados.
function fetchMany(array $urls, int $concurrency = 10): array
{
$multi = curl_multi_init();
$handles = [];
$results = [];
$queue = array_values($urls);
$active = [];
// função para adicionar uma requisição
$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;
};
// primeiro lote
for ($i = 0; $i < $concurrency && $queue; $i++) {
$addHandle(array_shift($queue));
}
do {
curl_multi_exec($multi, $running);
curl_multi_select($multi); // esperamos eventos sem queimar CPU à toa
// recolhemos as requisições concluídas
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]);
// colocamos a próxima da fila
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);A ideia-chave é a janela deslizante: manter em voo no máximo concurrency requisições por vez e adicionar novas à medida que terminam. Assim somos rápidos sem abrir mil conexões de uma vez.
Alternativas para o paralelismo «de verdade»:
- Guzzle Pool / Promises — requisições assíncronas com limite de concorrência, de nível mais alto que o
curl_multi. - ReactPHP / Amp / Swoole — runtimes assíncronos/com corrotinas, se precisar de grande escala.
- pcntl_fork / workers paralelos — vários processos, cada um pega seu lote de URLs da fila (veja a seção 14).
7. Uso de proxies
Os proxies servem para:
- driblar bloqueios por IP (o site bane por excesso de requisições),
- coletar dados a partir de regiões diferentes,
- repartir a carga entre vários endereços.
curl_setopt_array($ch, [
CURLOPT_PROXY => '123.45.67.89:8080',
CURLOPT_PROXYTYPE => CURLPROXY_HTTP, // ou CURLPROXY_SOCKS5
]);
// proxy com autenticação
curl_setopt($ch, CURLOPT_PROXYUSERPWD, 'login:password');Tipos de proxy:
| Tipo | Constante | Observações |
|---|---|---|
| HTTP | CURLPROXY_HTTP |
o mais comum |
| HTTPS | CURLPROXY_HTTPS |
proxy sobre TLS |
| SOCKS5 | CURLPROXY_SOCKS5 |
o Tor funciona por SOCKS5 |
| SOCKS5 + DNS no proxy | CURLPROXY_SOCKS5_HOSTNAME |
resolução de domínios do lado do proxy |
Rotação de proxies. Mantenha um pool de endereços e distribua-os em círculo; marque os «mortos» e exclua-os temporariamente.
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;
}
}Distinguem-se proxies de datacenter (baratos, fáceis de detectar) e residenciais/móveis (mais caros, mas parecem usuários reais). A escolha depende de quão agressiva é a proteção do site.
8. Scraping através do Tor
O Tor é uma rede gratuita que oferece um proxy SOCKS5 anônimo em 127.0.0.1:9050. É prático para rotacionar o IP sem custo, mas é lento, e muitos sites bloqueiam os nós de saída do Tor.
Conexão
curl_setopt_array($ch, [
CURLOPT_PROXY => '127.0.0.1:9050',
CURLOPT_PROXYTYPE => CURLPROXY_SOCKS5_HOSTNAME, // DNS através do Tor: importante para o anonimato
]);Troca de IP (circuito novo)
O Tor tem uma porta de controle (9051) pela qual se pode pedir um circuito novo com o sinal NEWNYM. Primeiro ative-a no torrc:
ControlPort 9051
CookieAuthentication 0
HashedControlPassword 16:... # gerar com: tor --hash-password "sua_senha"Depois, a partir do 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); // esperamos o 250 OK
fwrite($fp, "SIGNAL NEWNYM\r\n");
$signal = fgets($fp); // 250 OK
fclose($fp);
sleep(5); // o Tor não constrói o circuito novo na hora
return str_starts_with($auth, '250') && str_starts_with($signal, '250');
}Ciclo típico: fazer N requisições → torNewIdentity() → continuar com o IP novo.
Contras do Tor: velocidade baixa, uma parte dos sites devolve CAPTCHA ou 403 de cara, e o número de nós de saída é reduzido. Para volumes sérios, melhor proxies pagos.
9. Trabalho com HTTPS / SSL
Por padrão, o cURL verifica o certificado SSL, e assim deve ser. Os problemas surgem quando o servidor tem um pacote de certificados raiz (CA bundle) desatualizado.
curl_setopt_array($ch, [
CURLOPT_SSL_VERIFYPEER => true, // verificar o certificado (NÃO desative sem motivo)
CURLOPT_SSL_VERIFYHOST => 2, // conferir se o host coincide com o certificado
CURLOPT_CAINFO => '/path/to/cacert.pem', // CA bundle atualizado
]);O cacert.pem atualizado se baixa de curl.se/docs/caextract.html e se declara no php.ini:
curl.cainfo = "/path/to/cacert.pem"
openssl.cafile = "/path/to/cacert.pem"Não faça isto em produção:
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // desativa a proteção contra MITMIsso elimina a verificação do certificado. Só é admissível de forma temporária, para depurar localmente. A solução correta para o «erro de certificado» é atualizar o CA bundle, não desativar a verificação.
Também dá para forçar a versão do TLS:
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);10. Trabalho com cookies
Os cookies são necessários para as sessões, a autenticação e a superação das «verificações» que colocam um cookie e o esperam na requisição seguinte. O cURL sabe salvá-los e reenviá-los automaticamente por meio do cookie jar, um arquivo.
$cookieFile = __DIR__ . '/cookies.txt';
curl_setopt_array($ch, [
CURLOPT_COOKIEJAR => $cookieFile, // onde SALVAR os cookies recebidos
CURLOPT_COOKIEFILE => $cookieFile, // de onde LÊ-LOS em cada requisição
]);Se ambas as requisições (o login e a seguinte) usam o mesmo $cookieFile, a sessão se conserva entre elas.
Exemplo de autenticação
$cookieFile = tempnam(sys_get_temp_dir(), 'ck');
// 1) POST com usuário/senha: o servidor devolverá o cookie de sessão
$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) requisição à página protegida: o cookie é anexado 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);Para passar cookies manualmente (sem arquivo):
curl_setopt($ch, CURLOPT_COOKIE, 'sessionid=abc123; lang=pt');Em um scraping paralelo, dê a cada «worker»/proxy seu próprio arquivo de cookies; caso contrário, as sessões vão se misturar.
11. Código de status e outros cabeçalhos
O scraper é obrigado a reagir ao código de resposta: 200 — tudo bem, 404 — a página não existe, 403/429 — houve banimento ou pedem para reduzir o ritmo, 5xx — erro do servidor.
Código de resposta
$html = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($httpCode === 200) {
// processamos
} elseif ($httpCode === 429) {
// requisições demais: esperamos e tentamos de novo
} elseif ($httpCode >= 500) {
// erro do servidor: tentar de novo mais tarde
}Informações úteis de curl_getinfo
$info = curl_getinfo($ch);
// $info['http_code'] — código de resposta
// $info['content_type'] — Content-Type (é aqui que vem o charset!)
// $info['redirect_url'] — para onde redirecionou
// $info['total_time'] — quanto demorou
// $info['primary_ip'] — IP do servidor (útil ao conferir um proxy)
// $info['size_download'] — tamanho da respostaObter os cabeçalhos da resposta separadamente
curl_setopt($ch, CURLOPT_HEADER, true); // incluir os cabeçalhos na saída
$response = curl_exec($ch);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$rawHeaders = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);Com mais limpeza: por meio de um callback que acumula os cabeçalhos em um 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); // é obrigatório devolver o comprimento
});
curl_exec($ch);
// agora temos $headers['content-type'], $headers['set-cookie'] etc.Especialmente importantes: Content-Type (codificação), Set-Cookie, Location (redirecionamento), Retry-After (quanto esperar após um 429), Content-Length.
12. Camuflar-se como navegador, pausas, novas tentativas
Para o scraper não ser banido já na segunda requisição, ele precisa se comportar «como uma pessoa».
Cabeçalhos realistas
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: pt-BR,pt;q=0.9,en;q=0.8',
'Referer: https://example.com/',
'Connection: keep-alive',
]);Pausas entre requisições
usleep(random_int(800_000, 2_500_000)); // pausa aleatória de 0,8–2,5 sAs pausas aleatórias parecem mais naturais que as fixas. É uma cortesia com o servidor e reduz o risco de banimento.
Novas tentativas com espera exponencial (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; // não faz sentido tentar de novo
}
sleep(2 ** $attempt); // 2, 4, 8 segundos...
}
return null;
}O que mais ajuda a não esbarrar em um banimento
- Rotação de User-Agent e de proxies.
- Conservar os cookies entre as requisições (como um navegador real).
- Respeitar o
Retry-Afternos 429. - Paralelizar com moderação (nada de centenas de threads contra um mesmo domínio).
13. Páginas JavaScript e navegadores headless
O cURL obtém o HTML original, mas não executa JavaScript. Se o conteúdo é desenhado no cliente (uma SPA em React/Vue), ele não estará no HTML: restarão blocos vazios.
Opções:
- Encontrar a API oculta (seção 4.5) — quase sempre a melhor saída: a SPA pega os dados de um endpoint JSON que pode ser consultado diretamente.
- Navegador headless — subir um motor real que execute o JS: - Symfony Panther — wrapper PHP sobre ChromeDriver/Selenium. - php-webdriver + Selenium/Chrome. - A combinação com Puppeteer/Playwright (Node.js) — às vezes é mais simples extrair a renderização para um microsserviço à parte.
// Exemplo com Symfony Panther
use Symfony\Component\Panther\Client;
$client = Client::createChromeClient();
$crawler = $client->request('GET', 'https://spa-example.com');
$client->waitFor('.product'); // esperamos o JS desenhar o conteúdo
$titles = $crawler->filter('.product .title')->each(fn($n) => $n->text());Os navegadores headless são pesados e lentos: use-os apenas quando sem JS não houver saída.
14. Armazenamento de URLs e filas
Quando o scraper percorre centenas de milhares de páginas, são necessários uma fila de URLs e um registro do que já foi processado. Em linhas gerais, as principais abordagens:
O que armazenar
- a fila de URLs «a processar» (frontier);
- o conjunto de URLs já visitadas (para não passar duas vezes pela mesma); para a deduplicação, é prático guardar o hash da URL;
- o estado de cada URL: pendente / em processamento / concluída / erro / número de tentativas;
- os resultados em si (os dados já parseados).
A variante simples: um banco de dados
CREATE TABLE crawl_queue (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
url VARCHAR(2048) NOT NULL,
url_hash CHAR(40) NOT NULL, -- sha1(url), para a unicidade
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)
);O worker «pega» a tarefa de forma atômica, para que dois processos não peguem a mesma 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) é a chave para repartir tarefas com segurança entre vários workers.
Quando é preciso mais escala
- Redis (listas
LPUSH/BRPOP, conjuntosSADDpara a deduplicação) — uma fila rapidíssima, uma escolha muito popular. - RabbitMQ / Kafka / Beanstalkd — brokers de mensagens completos, se há muitos workers e é necessária uma entrega confiável.
- Filtro de Bloom — verificação compacta de «já vimos esta URL?» sobre bilhões de endereços sem armazenar todas as strings.
Princípio arquitetônico
Separe os papéis: o producer encontra links novos e os coloca na fila; os workers consomem a fila em paralelo e gravam o resultado. Assim o sistema escala horizontalmente sem esforço: basta adicionar workers.
15. Principais prós e contras de implementar em PHP
Prós
- Barreira de entrada baixa — o cURL e o DOM vêm integrados, e o ambiente está disponível em praticamente qualquer lugar.
- Um cURL excelente — manejo flexível de proxies, cookies, SSL e cabeçalhos.
- Bibliotecas maduras — Guzzle, Symfony DomCrawler/Panther, filas prontas para usar.
- Fácil de integrar a um projeto web PHP existente (CMS, painel administrativo) — o scraper grava diretamente no mesmo banco de dados.
- Implantação barata — hospedagem para PHP existe em toda parte e custa pouco.
Contras
- Sem multithreading real de fábrica. O paralelismo passa por
curl_multi, vários processos ou runtimes assíncronos (ReactPHP/Amp/Swoole). É mais complicado que as threads em Go ou o async em Python. - Não executa JS — para as SPAs é preciso um navegador headless, que é pesado e lento.
- Memória. As bibliotecas antigas (Simple HTML DOM) devoram memória; em grandes volumes, é preciso vigiar os vazamentos nos workers de vida longa.
- Velocidade. Para uma escala extrema, as stacks especializadas (Scrapy em Python, Colly em Go) costumam ser mais eficientes e trazem mais ferramentas prontas.
- Fragilidade. Como qualquer scraper, quebra quando o site muda o layout; não é algo específico do PHP, mas convém ter presente.
Conclusão: PHP é uma opção excelente para a maioria das tarefas de scraping de pequena e média escala, sobretudo quando os dados devem ir parar diretamente em um projeto PHP. Para crawlers muito grandes e renderização JS pesada, avalie stacks especializadas ou extraia a renderização para um serviço à parte.
16. Conclusão
Um scraper PHP mínimo pronto para produção inclui:
- download com cURL com timeouts, redirecionamentos e
CURLOPT_ENCODING => ''; - User-Agent e cabeçalhos realistas;
- conversão da codificação para UTF-8 antes do parsing (a cura do mojibake);
- parsing com DOMXPath ou Symfony DomCrawler (não com regex);
- verificação do código HTTP e tratamento de 404/403/429/5xx;
- pausas entre as requisições e novas tentativas com backoff;
- cookie jar, se há autenticação/sessões;
- proxies/rotação de IP se há bloqueios (ou o Tor como variante gratuita);
curl_multi/workers para ganhar velocidade em volumes grandes;- uma fila de URLs com deduplicação para um crawling sério.
As regras de ouro: separe o download do parsing, conserte sempre a codificação antes de parsear, respeite o servidor alheio (pausas, robots.txt, nada de DDoS) e procure primeiro um JSON/API pronto antes de brigar com o layout.