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.
- 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. - A resposta é validada. O código de status é lido. Um
200não significa que você recebeu os dados; verifique se o elemento esperado está mesmo na página. - O HTML é analisado. O texto recebido é transformado em uma árvore e os campos desejados são extraídos com seletores (seletor CSS ou XPath).
- 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
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:
$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; // 2A 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:
$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:
$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?
| 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:
$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:
// 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:
$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
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çã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 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:
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.
// 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.
$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.
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
200sem olhar o conteúdo. Páginas de verificação e de erro também devolvem200; 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.77como 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 tagclass="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
407no 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 |
| Endereços de datacenter estão sendo restringidos | Proxies residenciais |
| Você precisa do mesmo endereço durante a sessão | Proxies de sessão fixa |
| 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.




