Web scraping com PHP: cURL, Guzzle e uso de proxy

Publicado:

20 min de leitura

Acar Diveroli
Autor: Acar Diveroli
Da janela de produtos, a caixa selecionada por XPath passa ao cartão azul de extraído; à esquerda, o cubo de saída

A lista de preços de um fornecedor muda duas vezes por semana e você digita essa lista à mão no seu próprio painel. O site não tem API nem arquivo para baixar; os dados só existem dentro da página HTML. Seu projeto foi escrito em PHP, então você procura a solução também do lado do PHP: que rode no mesmo servidor, grave no mesmo banco de dados e seja disparada uma vez por noite pelo cron.

Neste artigo explicamos como extrair dados estruturados de uma página com as ferramentas do próprio PHP. Na ordem: enviar a requisição com cURL, analisar o HTML com DOMDocument e XPath, o novo analisador HTML5 do PHP 8.4, o tratamento de erros e as requisições simultâneas com Guzzle, a configuração de proxy (CURLOPT_PROXY, SOCKS5 e a opção proxy do Guzzle), a leitura do robots.txt, a espera e a nova tentativa. No fim há um exemplo completo que grava os dados em um banco com PDO. Todo o código foi executado no PHP 8.4 sobre books.toscrape.com e através de um proxy de teste local.

Extrair dados não é o mesmo que copiar conteúdo

Este artigo não trata de republicar o conteúdo de outra pessoa. Pegar um artigo, uma notícia ou uma página de filme de outro site e publicá-la no seu é violação de direitos autorais, e não faz diferença se isso foi feito com PHP ou com qualquer outra linguagem.

O que descrevemos aqui é a extração de dados estruturados: o preço de um produto, a quantidade em estoque, o título, as linhas de uma tabela, os nomes de domínio de uma lista. Quase sempre são fatos isolados e não formam uma obra criativa. Levar a lista de preços do seu próprio fornecedor para o seu próprio painel, ler uma tabela pública de um órgão ou acompanhar o estoque dos seus próprios produtos em um marketplace entram nesse grupo.

Na prática você traça o limite com três perguntas. O que você extrai é um bloco de texto ou o valor de um campo? Você usa os dados dentro do seu próprio negócio ou os publica como uma página que substitui a fonte? O que os termos de uso e o arquivo robots.txt do site dizem sobre esse acesso? A parte jurídica tratamos em Web scraping é legal? e a sintaxe do robots.txt em O que é robots.txt?.

Há mais um limite técnico: se a página não aparece sem login, se os termos proíbem expressamente o acesso automatizado ou se os dados contêm informação pessoal, nenhum código deste artigo é adequado. Verifique primeiro se existe uma API oficial.

Como funciona o scraping com PHP?

São quatro passos e eles não mudam com a linguagem; muda apenas a biblioteca usada.

  1. A requisição é enviada. Uma requisição HTTP GET baixa o HTML da página. Aqui são definidos o cabeçalho User-Agent, o tempo limite, o acompanhamento de redirecionamentos e, se houver, o proxy.
  2. A resposta é validada. O código de status é lido. Um 200 não significa que você recebeu os dados; verifique se o elemento esperado está mesmo na página.
  3. O HTML é analisado. O texto recebido é transformado em uma árvore e os campos desejados são extraídos com seletores (seletor CSS ou XPath).
  4. Os dados são salvos. Os valores são convertidos para o tipo certo (o preço, de texto para número decimal) e gravados em um banco de dados ou em um arquivo.

Do lado da requisição você tem duas opções (funções curl_* e Guzzle) e do lado da análise outras duas (DOMDocument e Symfony DomCrawler). Abaixo você vê cada uma delas.

Como baixar uma página com cURL?

A extensão cURL do PHP abre para o PHP a libcurl, a biblioteca por trás da ferramenta curl da linha de comando. Os nomes das opções seguem a mesma lógica, então traduzir para código uma requisição testada no terminal é fácil. Para os equivalentes das flags na linha de comando, veja Como usar proxy com cURL.

php
<?php
declare(strict_types=1);

// Baixa uma única página com cURL; verifica erros, código de status e tempo limite.
function fetchPage(string $url): string
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,   // não imprimir a resposta, devolvê-la
        CURLOPT_FOLLOWLOCATION => true,   // seguir os redirecionamentos 301/302
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_CONNECTTIMEOUT => 10,     // tempo para estabelecer a conexão (segundos)
        CURLOPT_TIMEOUT        => 30,     // tempo total da requisição (segundos)
        CURLOPT_ENCODING       => '',     // descompactar sozinho as respostas gzip/deflate
        CURLOPT_USERAGENT      => 'price-sync/1.0 (+https://example.com/bot)',
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        // Erro de rede: DNS, conexão recusada, tempo esgotado
        throw new RuntimeException('cURL error ' . curl_errno($ch) . ': ' . curl_error($ch));
    }

    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    if ($status !== 200) {
        throw new RuntimeException("HTTP $status: $url");
    }

    return $body;
}

Algumas opções importam mais que as outras. Sem CURLOPT_RETURNTRANSFER, o cURL imprime a resposta direto na saída e curl_exec devolve apenas true. Quando CURLOPT_ENCODING recebe uma string vazia, o cURL descompacta a resposta sozinho; se você pular isso, alguns sites enviam dados binários ilegíveis. Os dois tempos limite separados também não são acaso: CURLOPT_CONNECTTIMEOUT limita o estabelecimento da conexão e CURLOPT_TIMEOUT limita a requisição inteira. A lista completa das opções está na página do curl_setopt no php.net.

Repare que capturamos dois tipos de erro separadamente. Se curl_exec devolve false, nenhuma resposta chegou; esse é um erro da camada de rede. Se a resposta chegou e o código não é 200, o problema está no servidor e a reação depende do código. Reunimos em uma tabela em quais códigos parar e em quais tentar de novo em Códigos de status HTTP no scraping.

Por que file_get_contents e expressões regulares não bastam?

A maioria dos tutoriais começa com file_get_contents. Parece tentador por ser uma única linha, mas esconde três coisas.

A primeira é o código de status. Quando buscamos uma página inexistente, a função gerou um aviso e devolveu false; o único jeito de descobrir o código era ler a primeira linha do array $http_response_header, que surge como num passe de mágica depois da chamada. A segunda é o tempo limite: o valor padrão de default_socket_timeout é 60 segundos, ou seja, uma única página que não responde deixa seu script esperando um minuto. A terceira é a configuração de proxy e de cabeçalhos: as duas só são possíveis se você escrever à mão um bloco stream_context_create. O cURL já faz tudo isso.

O segundo clássico é analisar HTML com expressão regular. Um único exemplo basta para mostrar por que isso quebra. Das duas tags de preço abaixo, uma contém uma quebra de linha e a outra usa aspas simples em vez de duplas:

php
$fragment = "<p class=\"price_color\">\n  £51.77\n</p><p class='price_color'>£53.74</p>";

preg_match_all('/<p class="price_color">(.*?)<\/p>/', $fragment, $m);
echo count($m[1]);   // 0

$dom = Dom\HTMLDocument::createFromString('<div>' . $fragment . '</div>', LIBXML_NOERROR);
echo $dom->querySelectorAll('.price_color')->length;   // 2

A expressão regular não achou nenhuma; o analisador achou as duas. Em páginas reais essas duas diferenças são a regra, não a exceção, e ainda se somam a ordem das classes, atributos extras e tags aninhadas. Em vez de complicar o padrão a cada vez, use um analisador desde o início.

Como analisar o HTML: DOMDocument, XPath e PHP 8.4

O núcleo do PHP traz dois analisadores. O antigo é o DOMDocument e o novo é o namespace Dom, que chegou com o PHP 8.4. Segundo a página de novidades do PHP 8.4, as classes novas são compatíveis com HTML5 e seguem a especificação WHATWG; as classes antigas continuam por compatibilidade retroativa.

Na prática há três diferenças. DOMDocument::loadHTML gera avisos diante dos erros de HTML das páginas reais, por isso é preciso chamar libxml_use_internal_errors(true) antes; a classe nova não produz esse ruído. A segunda é o suporte a seletores: Dom\HTMLDocument traz os métodos querySelector e querySelectorAll que você conhece do navegador.

A terceira diferença interessa a todo mundo que extrai páginas com caracteres acentuados: a codificação de caracteres. Quando entregamos ao analisador antigo um trecho UTF-8 sem a tag <meta charset>, as letras acentuadas vieram corrompidas; o analisador novo leu o mesmo trecho corretamente. Se você precisa trabalhar com a classe antiga, é necessário declarar a codificação de forma explícita:

php
$fragment = '<p class="price">Preço: 1.250 reais (Türkiye, Izmir, ãçéõ)</p>';

$old = new DOMDocument();
libxml_use_internal_errors(true);
$old->loadHTML($fragment);
echo $old->getElementsByTagName('p')->item(0)->textContent;
// Preço: 1.250 reais (Türkiye, Izmir, ãçéõ)

$old2 = new DOMDocument();
$old2->loadHTML('<?xml encoding="UTF-8">' . $fragment);   // declare a codificação
echo $old2->getElementsByTagName('p')->item(0)->textContent;
// Preço: 1.250 reais (Türkiye, Izmir, ãçéõ)

// PHP 8.4: nenhuma dica extra é necessária
$new = Dom\HTMLDocument::createFromString($fragment, LIBXML_NOERROR);
echo $new->querySelector('p.price')->textContent;
// Preço: 1.250 reais (Türkiye, Izmir, ãçéõ)

Percorrer com XPath todos os cartões de produto de uma página de listagem segue a mesma lógica. O laço abaixo extrai título, preço e situação de estoque dos 20 cartões do site de teste:

php
$doc = new DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML($html);
libxml_clear_errors();

$xpath = new DOMXPath($doc);
$books = [];

foreach ($xpath->query('//article[contains(@class, "product_pod")]') as $card) {
    $books[] = [
        'title' => $xpath->evaluate('string(.//h3/a/@title)', $card),
        'price' => $xpath->evaluate('string(.//p[contains(@class, "price_color")])', $card),
        'stock' => trim($xpath->evaluate('string(.//p[contains(@class, "availability")])', $card)),
    ];
}

Escrevemos contains(@class, ...) porque o atributo class costuma carregar mais de um nome de classe; a igualdade @class="product_pod" deixa passar uma tag com class="product_pod col-xs-6". E string(...) devolve uma string vazia em vez de lançar exceção quando a seleção está vazia. Você encontra a comparação das duas linguagens de seletor em Seletor CSS e XPath.

Qual camada escolher?

CamadaPara quêA favorContra
file_get_contentsUm teste pontualZero instalaçãoCódigo de status e tempo limite ficam invisíveis
Funções curl_*Uma página, poucas dependênciasEstá no núcleo, todas as opções na sua mãoVocê monta cada requisição à mão
GuzzleTrabalho regular de várias páginasNovas tentativas, simultaneidade, exceções limpasDependência do Composer
Expressões regularesNenhum dos casosParece curtoQuebra com espaço e aspas diferentes
DOMDocument + XPathAnalisar com o núcleoSem dependência, XPath é poderosoDica de codificação e ruído do libxml
Dom\HTMLDocumentPHP 8.4 em dianteCompatível com HTML5, tem querySelectorNão existe em versões antigas
Symfony DomCrawlerPercorrer listas e linksSeletores CSS, each(), links absolutosVocê instala mais dois pacotes

Como usar um proxy com cURL?

Um trabalho que baixa muitas páginas com regularidade cedo ou tarde esbarra no limite de depender de um único endereço de saída: um site que vê centenas de requisições por minuto vindas do mesmo IP devolve 429 ou reconhece as faixas de datacenter e devolve 403. O proxy muda o ponto de saída dessas requisições.

No cURL bastam duas opções:

php
$ch = curl_init('https://example.com/produto/123');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_PROXY          => 'pr.proxynet.io:8000',
    CURLOPT_PROXYUSERPWD   => 'user:pass',
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT        => 30,
]);
$body = curl_exec($ch);

Você também pode escrever as credenciais dentro do endereço (CURLOPT_PROXY => 'http://user:pass@pr.proxynet.io:8000'). Se a senha tiver @, : ou /, a opção separada é mais segura, porque esses caracteres precisam ser codificados dentro de um endereço. Explicamos os dois métodos de autenticação e a alternativa da lista branca de IP em Autenticação de proxy.

Para SOCKS5 você também precisa informar o tipo de proxy. A distinção crítica aqui é quem resolve o nome de domínio:

php
// O domínio é resolvido pelo proxy (socks5h): sua consulta DNS também passa pelo proxy
curl_setopt($ch, CURLOPT_PROXY, 'pr.proxynet.io:1080');
curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME);
curl_setopt($ch, CURLOPT_PROXYUSERPWD, 'user:pass');

// O mesmo escrito em uma única linha
curl_setopt($ch, CURLOPT_PROXY, 'socks5h://user:pass@pr.proxynet.io:1080');

// O domínio é resolvido pela máquina local
curl_setopt($ch, CURLOPT_PROXY, 'socks5://user:pass@pr.proxynet.io:1080');

Nos nossos testes apareceram duas armadilhas. A primeira: se você não escrever a porta no endereço, a libcurl tenta a porta 1080 por padrão, porque a definição de CURLOPT_PROXY na documentação da libcurl diz isso. É daí que vem o erro "could not connect" quando você escreve seu proxy HTTP sem porta.

A segunda é que uma senha errada aparece de duas formas diferentes. A caminho de um endereço HTTPS o proxy monta um túnel; se a senha estiver errada, o túnel nem chega a ser criado e curl_exec devolve false. CURLINFO_RESPONSE_CODE mostra 0 e o 407 real fica apenas dentro de CURLINFO_HTTP_CONNECTCODE. Quando a mesma requisição vai para um endereço HTTP, chega uma resposta normal e o código de status é simplesmente 407. Ou seja, o código que confere a senha do proxy precisa ler os dois campos:

php
$body    = curl_exec($ch);
$status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);       // 0 no HTTPS
$tunnel  = curl_getinfo($ch, CURLINFO_HTTP_CONNECTCODE);    // 407 no HTTPS

if ($status === 407 || $tunnel === 407) {
    throw new RuntimeException('Credenciais de proxy recusadas');
}

Qual tipo de proxy escolher depende do alvo. Em tráfego intenso para o seu próprio servidor ou para uma fonte sem restrições, Proxies de datacenter é a solução mais barata. Em sites que restringem endereços de datacenter você precisa de Proxies residenciais. Quando quiser trocar o endereço de saída a cada requisição use Proxies rotativos, e quando precisar ficar no mesmo endereço durante toda a sessão use Proxies de sessão fixa.

Enviar requisições e capturar erros com o Guzzle

Em trabalhos de uma página só, o cURL basta. Se você está escrevendo um trabalho que percorre dezenas de páginas com regularidade, o Guzzle entrega prontas as trezentas ou quatrocentas linhas que você escreveria à mão: middleware de nova tentativa, pool de requisições simultâneas e exceções que se separam por código de status. A configuração do proxy também cabe em uma linha, porque segundo a documentação das opções de requisição do Guzzle a opção proxy aceita uma única string ou um array separado por protocolo.

php
<?php
require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\BadResponseException;
use GuzzleHttp\Exception\TransferException;
use Symfony\Component\DomCrawler\Crawler;

$client = new Client([
    'base_uri'        => 'https://example.com/',
    'proxy'           => 'http://user:pass@pr.proxynet.io:8000',
    'connect_timeout' => 10,
    'timeout'         => 30,
    'headers'         => ['User-Agent' => 'price-sync/1.0 (+https://example.com/bot)'],
]);

try {
    $response = $client->get('catalogo/pagina-1.html');
} catch (BadResponseException $e) {
    // O servidor devolveu 4xx ou 5xx; o objeto de resposta está dentro da exceção
    exit('HTTP ' . $e->getResponse()->getStatusCode() . PHP_EOL);
} catch (TransferException $e) {
    // Nenhuma resposta chegou: DNS, tempo esgotado, túnel do proxy (incluindo o 407)
    exit('Network error: ' . $e->getMessage() . PHP_EOL);
}

$crawler = new Crawler((string) $response->getBody(), 'https://example.com/catalogo/pagina-1.html');

$books = $crawler->filter('article.product_pod')->each(fn (Crawler $card) => [
    'title' => $card->filter('h3 a')->attr('title'),
    'price' => (float) preg_replace('/[^0-9.]/', '', $card->filter('.price_color')->text()),
    'stock' => str_contains($card->filter('.availability')->text(), 'In stock'),
    'url'   => $card->filter('h3 a')->link()->getUri(),
]);

Passar o endereço da página como segundo parâmetro do objeto Crawler é um detalhe pequeno mas crítico: sem ele, link()->getUri() não consegue transformar um endereço relativo em absoluto e lança uma exceção. Toda a lógica de paginação tratamos em Paginação no web scraping. Mais um aviso sobre text(): como diz a documentação do DomCrawler, o método lança exceção quando o seletor não encontra nada, então passe um valor padrão (->text('')) para que um campo ausente não interrompa o trabalho.

As classes de exceção têm dois ramos principais e os dois descendem de TransferException:

ExceçãoQuandoTem objeto de resposta
ClientExceptionResposta 4xx (404, 407 em alvo HTTP)Sim
ServerExceptionResposta 5xxSim
ConnectExceptionConexão falhou, porta fechada, tempo esgotadoNão
TransferException (classe-mãe)Túnel não foi criado, 407 em alvo HTTPSNão

O Guzzle 8 acrescentou classes mais detalhadas para erros de conexão, como NetworkException e ConnectTimeoutException. Para um código que funcione nas duas versões, ordene as capturas como acima: primeiro BadResponseException, depois TransferException.

Por padrão o Guzzle lança exceção nas respostas 4xx e 5xx. Em um trabalho que percorre centenas de endereços é mais confortável ler o código de status como valor: com 'http_errors' => false, uma requisição que recebe 404 devolve silenciosamente um objeto de resposta com o código 404.

robots.txt, espera e nova tentativa

Não basta que o código funcione: ele precisa se comportar bem. Há três regras.

O robots.txt é lido. O padrão está definido na RFC 9309 e enuncia quatro comportamentos com clareza: vence a regra correspondente mais longa; se um allow e um disallow forem equivalentes, vence o allow; se o arquivo devolver 4xx, nenhuma restrição vale; se devolver 5xx, todo caminho é considerado proibido. As duas funções abaixo aplicam essas quatro regras e juntam em um único grupo as linhas User-agent escritas em sequência:

php
const BOT_TOKEN = 'price-sync';   // o nome no nosso cabeçalho User-Agent

// Extrai do robots.txt as linhas Allow/Disallow do grupo que se aplica a nós.
function loadRobotsRules(Client $client): array
{
    $response = $client->get('/robots.txt');
    $status = $response->getStatusCode();
    if ($status >= 500) {
        return [['disallow', '/']];   // inacessível: todo caminho proibido
    }
    if ($status >= 400) {
        return [];                    // não há arquivo: sem restrição
    }

    $groups = [];
    $agents = [];
    $inRules = false;
    foreach (preg_split('/\R/', (string) $response->getBody()) as $line) {
        $line = trim(preg_replace('/#.*/', '', $line));
        if (!preg_match('/^(user-agent|allow|disallow)\s*:\s*(.*)$/i', $line, $m)) {
            continue;
        }
        [$field, $value] = [strtolower($m[1]), $m[2]];
        if ($field === 'user-agent') {
            if ($inRules) {
                [$agents, $inRules] = [[], false];   // um grupo novo começa aqui
            }
            $agents[] = strtolower($value);
            continue;
        }
        $inRules = true;
        foreach ($agents as $agent) {
            $groups[$agent][] = [$field, $value];
        }
    }

    return $groups[BOT_TOKEN] ?? $groups['*'] ?? [];
}

// Vence a regra correspondente mais longa; no empate vence Allow (RFC 9309).
function isAllowed(string $path, array $rules): bool
{
    [$bestLength, $allowed] = [-1, true];
    foreach ($rules as [$field, $pattern]) {
        if ($pattern === '') {
            continue;   // Disallow vazio: sem restrição
        }
        $regex = '#^' . str_replace(['\*', '\$'], ['.*', '$'], preg_quote($pattern, '#')) . '#';
        if (!preg_match($regex, $path)) {
            continue;
        }
        $length = strlen($pattern);
        if ($length > $bestLength || ($length === $bestLength && $field === 'allow')) {
            [$bestLength, $allowed] = [$length, $field === 'allow'];
        }
    }
    return $allowed;
}

Espera-se entre as requisições. A opção delay do Guzzle coloca antes de cada requisição uma espera em milissegundos. Um segundo é um ponto de partida razoável para a maioria dos trabalhos, e a simultaneidade fica baixa. A regra é simples: seja lento o bastante para passar despercebido ao lado do tráfego normal de visitantes do site.

Reage-se aos erros conforme o código. O middleware Middleware::retry do Guzzle recebe duas funções de retorno: uma que decide em que caso haverá nova tentativa e outra que diz quanto esperar. No nosso servidor de teste, um endereço que devolveu duas vezes 503 com Retry-After: 1 respondeu 200 na terceira tentativa em menos de dois segundos; um endereço que devolveu 404 nunca foi tentado de novo.

php
// Tenta no máximo 3 vezes em erros temporários; não tenta em códigos como 403, 404 ou 407.
function retryMiddleware(): callable
{
    $decider = function (int $retries, $request, ?ResponseInterface $response = null): bool {
        if ($retries >= 3) {
            return false;
        }
        if ($response === null) {
            return true;   // nenhuma resposta: a conexão caiu ou o tempo esgotou
        }
        return in_array($response->getStatusCode(), [408, 429, 500, 502, 503, 504], true);
    };

    $delay = function (int $retries, ?ResponseInterface $response = null): int {
        $retryAfter = $response?->getHeaderLine('Retry-After') ?? '';
        if (ctype_digit($retryAfter)) {
            return min((int) $retryAfter, 60) * 1000;   // o tempo indicado pelo servidor
        }
        return (2 ** $retries) * 1000 + random_int(0, 500);
    };

    return Middleware::retry($decider, $delay);
}

O componente aleatório somado à espera não é acaso: impede que requisições que falharam ao mesmo tempo sejam repetidas ao mesmo tempo e criem um novo congestionamento. 403, 404 e 407 não estão na lista porque esses códigos não mudam com a espera; neles o trabalho deve parar e o motivo deve ir para o log.

Exemplo completo: sincronização de preço e estoque

Vamos juntar as peças. O fluxo abaixo lê o robots.txt, percorre as páginas de listagem e reúne os endereços dos produtos (collectProductUrls, um laço simples que segue o link de "próxima página"), baixa as páginas de produto de duas em duas, extrai preço e estoque da tabela e grava no SQLite. Executado através do proxy de teste local, gravou os 40 produtos de 40 em cerca de 32 segundos.

php
$db = new PDO('sqlite:' . __DIR__ . '/prices.sqlite');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('CREATE TABLE IF NOT EXISTS products (
    upc TEXT PRIMARY KEY, title TEXT, price REAL, stock INTEGER, url TEXT, checked_at TEXT
)');
// Quando o mesmo produto chega pela segunda vez nenhuma linha é criada: preço e estoque são atualizados
$save = $db->prepare('INSERT INTO products (upc, title, price, stock, url, checked_at)
    VALUES (:upc, :title, :price, :stock, :url, :checked_at)
    ON CONFLICT(upc) DO UPDATE SET
        price = excluded.price, stock = excluded.stock, checked_at = excluded.checked_at');

$stack = HandlerStack::create();
$stack->push(retryMiddleware());

$client = new Client([
    'handler'         => $stack,
    'base_uri'        => 'https://example.com/',
    'proxy'           => getenv('PROXY_URL') ?: null,   // http://user:pass@pr.proxynet.io:8000
    'connect_timeout' => 10,
    'timeout'         => 30,
    'http_errors'     => false,   // lemos o código como valor em vez de exceção
    'headers'         => ['User-Agent' => 'price-sync/1.0 (+https://example.com/bot)'],
]);

$rules = loadRobotsRules($client);
$urls  = array_values(array_filter(
    collectProductUrls($client, $rules),
    fn (string $url) => isAllowed(parse_url($url, PHP_URL_PATH), $rules)
));

$requests = function () use ($urls) {
    foreach ($urls as $url) {
        yield new Request('GET', $url);
    }
};

$saved = 0;
$pool = new Pool($client, $requests(), [
    'concurrency' => 2,                       // número de requisições abertas ao mesmo tempo
    'options'     => ['delay' => 1000],       // espera antes de cada requisição
    'fulfilled'   => function (ResponseInterface $response, int $index) use ($urls, $save, &$saved) {
        if ($response->getStatusCode() !== 200) {
            fwrite(STDERR, "HTTP {$response->getStatusCode()}: {$urls[$index]}\n");
            return;
        }
        $product = parseProduct((string) $response->getBody(), $urls[$index]);
        if ($product === null) {
            fwrite(STDERR, "Unexpected page: {$urls[$index]}\n");
            return;
        }
        $save->execute($product);
        $saved++;
    },
    'rejected'    => function (Throwable $reason, int $index) use ($urls) {
        fwrite(STDERR, "Failed: {$urls[$index]} ({$reason->getMessage()})\n");
    },
]);
$pool->promise()->wait();

printf("%d of %d products saved\n", $saved, count($urls));

A função parseProduct, que lê a página do produto, começa com uma pequena verificação para não aceitar um 200 às cegas: se o elemento de título esperado não existe, ela devolve null e o fluxo principal registra aquele endereço como falha.

php
function parseProduct(string $html, string $url): ?array
{
    $crawler = new Crawler($html, $url);
    if ($crawler->filter('.product_main h1')->count() === 0) {
        return null;   // o 200 chegou, mas o elemento esperado não existe
    }

    // Transforma as linhas da <table> em um array "título => valor"
    $table = [];
    $crawler->filter('table.table-striped tr')->each(function (Crawler $row) use (&$table) {
        $table[$row->filter('th')->text()] = $row->filter('td')->text();
    });

    preg_match('/\((\d+) available\)/', $table['Availability'] ?? '', $stock);

    return [
        'upc'        => $table['UPC'],
        'title'      => $crawler->filter('.product_main h1')->text(),
        'price'      => (float) preg_replace('/[^0-9.]/', '', $table['Price (excl. tax)']),
        'stock'      => (int) ($stock[1] ?? 0),
        'url'        => $url,
        'checked_at' => date('c'),
    ];
}

Duas observações para o cron: rode o script pela linha de comando e não pelo servidor web (php /caminho/sync.php), porque o limite padrão de tempo de execução do lado web corta um trabalho longo pela metade. E coloque as credenciais do proxy em uma variável de ambiente, não no código; o detalhe está em Como usar proxy com wget.

Onde o PHP para diante de uma página carregada por JavaScript

Tudo o que foi descrito até aqui se apoia em uma suposição: os dados que você quer estão dentro do primeiro HTML enviado pelo servidor. Em boa parte dos sites modernos isso não é verdade. O servidor envia um esqueleto vazio e a lista de produtos é buscada depois pelo JavaScript que roda no navegador. Quando você baixa essa página com cURL, seus seletores não encontram nada, porque as tags procuradas nunca foram escritas no HTML.

É aqui que o PHP para, e isso não tem relação com a escolha da biblioteca. Guzzle e DomCrawler analisam o texto que chega; nenhum dos dois executa JavaScript. Se você quiser controlar um navegador a partir do PHP, precisa iniciar o Chrome por fora com um pacote como o Panther, ou seja, o trabalho deixa de estar no PHP e passa para o navegador.

A boa notícia é que na maioria dos casos nenhum navegador é necessário. Você pode achar no painel Rede das ferramentas de desenvolvedor a requisição JSON que a página faz em segundo plano e chamar o mesmo endereço direto com o Guzzle; como o resultado já é um dado estruturado, o passo da análise também desaparece. Mostramos passo a passo como saber se uma página é dinâmica em Páginas estáticas e dinâmicas.

Casos de uso

  • Levar o preço do fornecedor para o seu painel: uma tarefa cron noturna percorre a lista de produtos e atualiza os campos de preço e estoque; a montagem está na nossa página de extração de dados.
  • Acompanhar preços da concorrência: registrar diariamente o preço do mesmo produto em vários sites; tratamos disso em Monitoramento de preços da concorrência e na nossa página de monitoramento de preços.
  • Rastrear o próprio site: percorrer o seu domínio em busca de links quebrados e títulos ausentes; veja nossa página de rastreador web.
  • Conferir seus anúncios em marketplace: verificar se os campos de estoque e título batem com o seu painel; veja nossa página de soluções para e-commerce.
  • Ler tabelas públicas: pegar tabelas de câmbio, tarifas ou editais em sites de órgãos públicos; o resumo do método, independente de linguagem, está em Como extrair dados de um site.

Erros comuns

  • Aceitar um 200 sem olhar o conteúdo. Páginas de verificação e de erro também devolvem 200; antes de cada análise, confira se um elemento esperado existe.
  • Não definir tempo limite. Uma única página que não responde deixa o script esperando um minuto por causa do default_socket_timeout. Informe os dois tempos de forma explícita.
  • Guardar o preço como texto. Se você mantiver a string £51.77 como está, não consegue comparar nem somar. Converta para número e ponha a moeda em outra coluna.
  • Procurar o nome da classe por igualdade. Um XPath que diz @class="product_pod" não encontra a tag class="product_pod col-xs-6".
  • Não escrever a porta no endereço do proxy. A libcurl tenta 1080 por padrão e a mensagem de erro engana você.
  • Procurar o erro 407 no site de destino. Esse código vem do proxy; confira usuário, senha e lista branca.
  • Configurar a simultaneidade com ganância. Vinte requisições em paralelo não aceleram o trabalho, jogam você contra o limite de taxa.
  • Embutir credenciais no código. Usuário e senha do proxy não devem entrar no controle de versão.

Guia de decisão

NecessidadeRecomendação
Alguns campos de uma única páginacurl_* + DOMDocument e XPath
PHP 8.4 e costume de seletores CSSquerySelectorAll com Dom\HTMLDocument
Trabalho regular em dezenas de páginasGuzzle + DomCrawler com middleware de nova tentativa
Navegar entre páginas de listagemDomCrawler link()->getUri() para endereços absolutos
Muitas requisições de um IP e você recebe 429Reduza a velocidade e depois Proxies rotativos
Endereços de datacenter estão sendo restringidosProxies residenciais
Você precisa do mesmo endereço durante a sessãoProxies de sessão fixa
O conteúdo chega por JavaScriptProcure primeiro a requisição JSON em segundo plano
O site oferece uma API oficialA API em vez do scraping

Perguntas frequentes

O PHP é uma linguagem adequada para web scraping?

Sim, desde que você conheça o limite dele. Na requisição HTTP e na análise de HTML as ferramentas são maduras: a extensão cURL abre a libcurl por completo, o Guzzle oferece simultaneidade e novas tentativas, e o DomCrawler com XPath dá seletores potentes. O ponto fraco é a automação de navegador. Se você vai alimentar um sistema já escrito em PHP, manter o trabalho em PHP é mais simples que trazer os dados de uma segunda linguagem.

Devo usar a biblioteca Simple HTML DOM?

Essa biblioteca, que aparece em muitos tutoriais antigos, está há bastante tempo sem manutenção e funciona de forma visivelmente lenta em páginas grandes. O mesmo trabalho é feito pelo DOMDocument do núcleo do PHP, sem dependência; no PHP 8.4 ele passa a ser compatível com HTML5 através do Dom\HTMLDocument, e se você quiser uma interface parecida com a do jQuery existe o Symfony DomCrawler. Para um projeto novo, escolha um desses três.

Qual é a diferença entre cURL e Guzzle?

O Guzzle já usa o cURL por baixo; a diferença está no nível de abstração. Se você baixa uma única página, as funções curl_* bastam e não é preciso instalar pacote. Se quiser novas tentativas, pool de requisições, middleware e exceções separadas por código de status, use o Guzzle. A configuração do proxy são poucas linhas nos dois casos.

Estou recebendo erro 407 ao usar proxy, o que devo fazer?

O 407 não vem do site de destino e sim do proxy, e diz que a autenticação falhou. Confira primeiro usuário e senha. Se a senha tiver @ ou :, ela precisa estar codificada dentro do endereço; usar a opção CURLOPT_PROXYUSERPWD separadamente elimina esse problema. Se você está na lista branca de IP, confirme que o endereço de saída do seu servidor está na lista. E lembre que, em requisições HTTPS, o 407 aparece em CURLINFO_HTTP_CONNECTCODE e não em CURLINFO_RESPONSE_CODE.

Os caracteres acentuados da página que baixei vêm corrompidos, por quê?

O mais provável é que você esteja usando DOMDocument::loadHTML e a página não tenha a tag <meta charset>. Nesse caso o analisador não trata o conteúdo como UTF-8. A solução é acrescentar <?xml encoding="UTF-8"> no começo do HTML ou passar para o método Dom\HTMLDocument::createFromString do PHP 8.4, que detecta a codificação sozinho e de forma correta. A resposta chegar compactada também pode causar dano parecido; para isso passe uma string vazia em CURLOPT_ENCODING.

De quantos em quantos segundos devo enviar uma requisição?

Não há um número fixo, mas duas medidas ajudam: o tamanho do alvo (um pequeno site institucional e um grande marketplace não aguentam a mesma carga) e a reação do próprio site (se você começou a receber 429, está rápido demais e precisa respeitar o valor de Retry-After). Um começo prático é esperar um segundo entre as requisições e limitar a simultaneidade a duas.

Em resumo

Fazer scraping com PHP se resume a enviar a requisição com curl_* e analisar o HTML recebido com DOMDocument + XPath ou com o DomCrawler. file_get_contents e expressões regulares quebram na primeira página real porque não enxergam o código de status nem as variações das tags. Quando o trabalho passa de algumas páginas, entram em cena o middleware de nova tentativa e o pool de requisições do Guzzle. Do lado do proxy bastam CURLOPT_PROXY e CURLOPT_PROXYUSERPWD; lembre de escrever a porta e de procurar o erro 407 do lado do proxy. O que de fato decide, porém, são as três escolhas anteriores ao código: respeitar o robots.txt, esperar entre as requisições e extrair apenas dados com caráter de fato. Você encontra os tipos de proxy adequados nos nossos serviços de proxy.