Scraping por linguagem 19 min de leitura

Web scraping em PHP: guia completo do simples ao complexo

Guia completo de web scraping em PHP: cURL, DOMDocument, Simple HTML DOM, Guzzle e a organização de uma coleta periódica na hospedagem.

EW
Equipe Web-Scraping.biz
Coleta de dados para as demandas do negócio
Publicado: 1 abril 2025

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

  1. O que é o web scraping e quando ele é necessário
  2. Ética e aspectos legais (robots.txt, carga)
  3. Como baixamos a página - file_get_contents - cURL - Guzzle
  4. 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
  5. Solução de problemas de codificação (acentos e cedilha)
  6. multi_curl e paralelismo
  7. Uso de proxies
  8. Scraping através do Tor
  9. Trabalho com HTTPS / SSL
  10. Trabalho com cookies
  11. Código de status e outros cabeçalhos
  12. Camuflar-se como navegador, pausas, novas tentativas (complemento)
  13. Páginas JavaScript e navegadores headless (complemento)
  14. Armazenamento de URLs e filas (visão geral)
  15. Principais prós e contras de implementar em PHP
  16. 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:

  1. Baixar a página HTML (requisição HTTP).
  2. 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:

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

php
$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:

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

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.

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.

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

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.

php
$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.

bash
composer require symfony/dom-crawler symfony/css-selector
php
use 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:

php
$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>.

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]);
    }
    // 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

php
$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:

php
$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):

php
$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.

php
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.
php
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.

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

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

php
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:

code
ControlPort 9051
CookieAuthentication 0
HashedControlPassword 16:...   # gerar com: tor --hash-password "sua_senha"

Depois, a partir do 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);                 // 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.

php
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:

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

Não faça isto em produção:

php
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // desativa a proteção contra MITM

Isso 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:

php
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);

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.

php
$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

php
$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):

php
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

php
$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

php
$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 resposta

Obter os cabeçalhos da resposta separadamente

php
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:

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

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

Pausas entre requisições

php
usleep(random_int(800_000, 2_500_000)); // pausa aleatória de 0,8–2,5 s

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

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; // 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-After nos 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:

  1. 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.
  2. 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.
php
// 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

sql
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:

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) é a chave para repartir tarefas com segurança entre vários workers.

Quando é preciso mais escala

  • Redis (listas LPUSH/BRPOP, conjuntos SADD para 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.