---
title: "Web scraping com PHP: cURL, Guzzle e uso de proxy"
description: "O núcleo do scraping em PHP é a requisição com cURL e a análise com DOM e XPath. Explicamos Guzzle, proxy, robots.txt e novas tentativas com código testado."
url: https://proxynet.io/pt-br/blog/php-web-scraping
date: 2026-09-19
author: "Acar Diveroli"
category: "Tutoriais, Web scraping"
lang: pt-BR
---

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

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.

> **Nota: Resposta rápida**
>
> O scraping em PHP se resume a dois passos: baixar a página com as funções `curl_*` (ou com o Guzzle) e analisar o HTML recebido com `DOMDocument` + XPath ou com o Symfony DomCrawler. `file_get_contents` e expressões regulares funcionam em um teste rápido, mas não dão conta do código de status, do tempo limite nem de tags aninhadas. O proxy é indicado no cURL com `CURLOPT_PROXY` e `CURLOPT_PROXYUSERPWD` e, no Guzzle, com a opção `proxy` de uma linha só. O que realmente decide o resultado não é o código: é respeitar o robots.txt, esperar entre as requisições e definir em qual erro você tenta de novo e em qual você para.

## 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?](/pt-br/blog/is-data-web-scraping-legal) e a sintaxe do robots.txt em [O que é robots.txt?](/pt-br/blog/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](/pt-br/blog/curl-proxy).

```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](https://www.php.net/manual/en/function.curl-setopt.php).

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](/pt-br/blog/http-status-codes-web-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](https://www.php.net/manual/en/migration84.new-features.php), 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](/pt-br/blog/css-selector-vs-xpath).

## Qual camada escolher?

| Camada | Para quê | A favor | Contra |
|---|---|---|---|
| `file_get_contents` | Um teste pontual | Zero instalação | Código de status e tempo limite ficam invisíveis |
| Funções `curl_*` | Uma página, poucas dependências | Está no núcleo, todas as opções na sua mão | Você monta cada requisição à mão |
| Guzzle | Trabalho regular de várias páginas | Novas tentativas, simultaneidade, exceções limpas | Dependência do Composer |
| Expressões regulares | Nenhum dos casos | Parece curto | Quebra com espaço e aspas diferentes |
| `DOMDocument` + XPath | Analisar com o núcleo | Sem dependência, XPath é poderoso | Dica de codificação e ruído do `libxml` |
| `Dom\HTMLDocument` | PHP 8.4 em diante | Compatível com HTML5, tem `querySelector` | Não existe em versões antigas |
| Symfony DomCrawler | Percorrer listas e links | Seletores CSS, `each()`, links absolutos | Você 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](/pt-br/blog/proxy-authentication-methods).

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](https://curl.se/libcurl/c/CURLOPT_PROXY.html) 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](https://proxynet.io/pt-br/datacenter-proxy) é a solução mais barata. Em sites que restringem endereços de datacenter você precisa de [Proxies residenciais](https://proxynet.io/pt-br/residential-proxy). Quando quiser trocar o endereço de saída a cada requisição use [Proxies rotativos](https://proxynet.io/pt-br/rotating-proxy), e quando precisar ficar no mesmo endereço durante toda a sessão use [Proxies de sessão fixa](https://proxynet.io/pt-br/sticky-proxy).

## 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](https://docs.guzzlephp.org/en/stable/request-options.html) 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](/pt-br/blog/pagination-web-scraping). Mais um aviso sobre `text()`: como diz [a documentação do DomCrawler](https://symfony.com/doc/current/components/dom_crawler.html), 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ção | Quando | Tem objeto de resposta |
|---|---|---|
| `ClientException` | Resposta `4xx` (`404`, `407` em alvo HTTP) | Sim |
| `ServerException` | Resposta `5xx` | Sim |
| `ConnectException` | Conexão falhou, porta fechada, tempo esgotado | Não |
| `TransferException` (classe-mãe) | Túnel não foi criado, `407` em alvo HTTPS | Nã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](https://www.rfc-editor.org/rfc/rfc9309.html) 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](/pt-br/blog/wget-proxy).

## 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](/pt-br/blog/static-vs-dynamic-pages).

## 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](/pt-br/data-scraping).
- **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](/pt-br/blog/competitor-price-tracking) e na nossa página de [monitoramento de preços](/pt-br/price-monitoring).
- **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](/pt-br/web-crawler).
- **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](/pt-br/e-commerce-proxy).
- **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](/pt-br/blog/extract-data-from-website).

## 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

| Necessidade | Recomendação |
|---|---|
| Alguns campos de uma única página | `curl_*` + `DOMDocument` e XPath |
| PHP 8.4 e costume de seletores CSS | `querySelectorAll` com `Dom\HTMLDocument` |
| Trabalho regular em dezenas de páginas | Guzzle + DomCrawler com middleware de nova tentativa |
| Navegar entre páginas de listagem | DomCrawler `link()->getUri()` para endereços absolutos |
| Muitas requisições de um IP e você recebe `429` | Reduza a velocidade e depois [Proxies rotativos](https://proxynet.io/pt-br/rotating-proxy) |
| Endereços de datacenter estão sendo restringidos | [Proxies residenciais](https://proxynet.io/pt-br/residential-proxy) |
| Você precisa do mesmo endereço durante a sessão | [Proxies de sessão fixa](https://proxynet.io/pt-br/sticky-proxy) |
| O conteúdo chega por JavaScript | Procure primeiro a requisição JSON em segundo plano |
| O site oferece uma API oficial | A 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](/pt-br/proxy).
