---
title: "Web scraping con PHP: cURL, Guzzle y uso de proxy"
description: "El núcleo del scraping en PHP es la petición con cURL y el análisis con DOM y XPath. Explicamos Guzzle, proxy, robots.txt y reintentos con código probado."
url: https://proxynet.io/es/blog/php-web-scraping
date: 2026-09-19
author: "Acar Diveroli"
category: "Tutoriales, Web scraping"
lang: es
---

# Web scraping con PHP: cURL, Guzzle y uso de proxy

La lista de precios de un proveedor cambia dos veces por semana y tú introduces esa lista a mano en tu propio panel. El sitio no tiene API ni un archivo descargable; los datos solo existen dentro de la página HTML. Tu proyecto está escrito en PHP, así que buscas la solución también del lado de PHP: que se ejecute en el mismo servidor, que escriba en la misma base de datos y que corra una vez por la noche con cron.

En este artículo explicamos cómo extraer datos estructurados de una página con las herramientas propias de PHP. Por orden: enviar la petición con cURL, analizar el HTML con `DOMDocument` y XPath, el nuevo analizador HTML5 de PHP 8.4, el manejo de errores y las peticiones simultáneas con Guzzle, la configuración del proxy (`CURLOPT_PROXY`, SOCKS5 y la opción `proxy` de Guzzle), la lectura de robots.txt, la espera y el reintento. Al final hay un ejemplo completo que escribe los datos en una base de datos con PDO. Todo el código se ejecutó con PHP 8.4 sobre `books.toscrape.com` y a través de un proxy de prueba local.

> **Nota: Respuesta breve**
>
> El scraping en PHP se reduce a dos pasos: descargar la página con las funciones `curl_*` (o con Guzzle) y analizar el HTML recibido con `DOMDocument` + XPath o con Symfony DomCrawler. `file_get_contents` y las expresiones regulares sirven para una prueba rápida, pero no soportan el código de estado, el tiempo de espera ni las etiquetas anidadas. El proxy se indica en cURL con `CURLOPT_PROXY` y `CURLOPT_PROXYUSERPWD`, y en Guzzle con la opción `proxy` de una sola línea. Lo que de verdad decide el resultado no es el código: es respetar robots.txt, esperar entre peticiones y decidir en qué error reintentas y en cuál te detienes.

## Extraer datos no es lo mismo que copiar contenido

Este artículo no trata de republicar el contenido de otras personas. Tomar un artículo, una noticia o una ficha de película de otro sitio y publicarla en el tuyo es una infracción de derechos de autor, y da igual si se hace con PHP o con cualquier otro lenguaje.

Lo que describimos aquí es **la extracción de datos estructurados**: el precio de un producto, su cantidad en stock, su título, las filas de una tabla, los nombres de dominio de una lista. Casi siempre son hechos concretos y no forman una obra creativa. Llevar la lista de precios de tu propio proveedor a tu propio panel, leer una tabla pública de un organismo o seguir el stock de tus propios productos en un marketplace entran en este grupo.

En la práctica puedes trazar el límite con tres preguntas. ¿Lo que extraes es un bloque de texto o el valor de un campo? ¿Usas los datos dentro de tu propio negocio o los publicas como una página que sustituye a la fuente? ¿Qué dicen las condiciones de uso y el archivo robots.txt del sitio sobre este acceso? La parte legal la tratamos en [¿Es legal el web scraping?](/es/blog/is-data-web-scraping-legal) y la sintaxis de robots.txt en [¿Qué es robots.txt?](/es/blog/robots-txt).

Hay otro límite técnico: si la página no se ve sin iniciar sesión, si las condiciones prohíben expresamente el acceso automatizado o si los datos contienen información personal, ningún código de este artículo es adecuado. Comprueba primero si existe una API oficial.

## ¿Cómo funciona el scraping con PHP?

Hay cuatro pasos y no cambian según el lenguaje; solo cambia la biblioteca que usas.

1. **Se envía la petición.** Una petición HTTP GET descarga el HTML de la página. Aquí se configuran la cabecera `User-Agent`, el tiempo de espera, el seguimiento de redirecciones y, si lo hay, el proxy.
2. **Se valida la respuesta.** Se lee el código de estado. Un `200` no significa que tengas los datos; comprueba que el elemento que esperas está realmente en la página.
3. **Se analiza el HTML.** El texto recibido se convierte en un árbol y los campos que quieres se extraen con selectores (selector CSS o XPath).
4. **Se guardan los datos.** Los valores se convierten a su tipo (el precio, de texto a número decimal) y se escriben en una base de datos o en un archivo.

Del lado de la petición tienes dos opciones (las funciones `curl_*` y Guzzle) y del lado del análisis otras dos (`DOMDocument` y Symfony DomCrawler). A continuación las ves una por una.

## ¿Cómo se descarga una página con cURL?

La extensión cURL de PHP abre a PHP libcurl, la biblioteca que hay detrás de la herramienta `curl` de la línea de comandos. Los nombres de las opciones siguen la misma lógica, así que traducir a código una petición que probaste en el terminal es fácil. Para los equivalentes de las banderas en la línea de comandos, mira [Cómo usar un proxy con cURL](/es/blog/curl-proxy).

```php
<?php
declare(strict_types=1);

// Descarga una sola página con cURL; comprueba errores, código de estado y tiempo de espera.
function fetchPage(string $url): string
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,   // no imprimir la respuesta, devolverla
        CURLOPT_FOLLOWLOCATION => true,   // seguir las redirecciones 301/302
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_CONNECTTIMEOUT => 10,     // tiempo para establecer la conexión (segundos)
        CURLOPT_TIMEOUT        => 30,     // tiempo total de la petición (segundos)
        CURLOPT_ENCODING       => '',     // descomprimir por sí mismo las respuestas gzip/deflate
        CURLOPT_USERAGENT      => 'price-sync/1.0 (+https://example.com/bot)',
    ]);

    $body = curl_exec($ch);
    if ($body === false) {
        // Error de red: DNS, conexión rechazada, tiempo agotado
        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;
}
```

Algunas opciones importan especialmente. Sin `CURLOPT_RETURNTRANSFER`, cURL imprime la respuesta directamente en la salida y `curl_exec` solo te devuelve `true`. Cuando a `CURLOPT_ENCODING` se le pasa una cadena vacía, cURL descomprime la respuesta por sí mismo; si lo omites, algunos sitios te envían datos binarios ilegibles. Que haya dos tiempos de espera separados tampoco es casual: `CURLOPT_CONNECTTIMEOUT` limita el establecimiento de la conexión y `CURLOPT_TIMEOUT` limita la petición entera. La lista completa de opciones está en [la página de `curl_setopt` en php.net](https://www.php.net/manual/en/function.curl-setopt.php).

Fíjate en que capturamos dos tipos de error por separado. Si `curl_exec` devuelve `false`, no llegó ninguna respuesta; ese es un fallo de la capa de red. Si llegó una respuesta y el código no es `200`, el problema está en el servidor y la reacción depende del código. Reunimos en una tabla en qué códigos hay que detenerse y en cuáles reintentar en [Códigos de estado HTTP en el scraping](/es/blog/http-status-codes-web-scraping).

## ¿Por qué no bastan file_get_contents y las expresiones regulares?

La mayoría de los tutoriales empiezan con `file_get_contents`. Parece tentador porque es una sola línea, pero oculta tres cosas.

La primera es el código de estado. Cuando pedimos una página que no existe, la función generó un aviso y devolvió `false`; la única forma de conocer el código era leer la primera línea del array `$http_response_header`, que aparece por arte de magia después de la llamada. La segunda es el tiempo de espera: el valor predeterminado de `default_socket_timeout` es de 60 segundos, así que una sola página que no responde deja tu script esperando un minuto. La tercera es la configuración del proxy y de las cabeceras: ambas solo son posibles si escribes a mano un bloque `stream_context_create`. cURL ya hace todo eso.

El segundo clásico es analizar HTML con una expresión regular. Basta un ejemplo para mostrar por qué se rompe. De las dos etiquetas de precio de abajo, una contiene un salto de línea y la otra usa comillas simples en lugar de dobles:

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

La expresión regular no encontró ninguna; el analizador encontró las dos. En páginas reales esas dos diferencias son la regla y no la excepción, y encima se añaden el orden de las clases, atributos de más y etiquetas anidadas. En lugar de complicar el patrón cada vez, usa un analizador desde el principio.

## Cómo analizar el HTML: DOMDocument, XPath y PHP 8.4

El núcleo de PHP trae dos analizadores. El antiguo es `DOMDocument` y el nuevo es el espacio de nombres `Dom` que llegó con PHP 8.4. [Según la página de novedades de PHP 8.4](https://www.php.net/manual/en/migration84.new-features.php), las clases nuevas son compatibles con HTML5 y siguen la especificación WHATWG; las clases antiguas se mantienen por compatibilidad hacia atrás.

En la práctica hay tres diferencias. `DOMDocument::loadHTML` genera avisos con los errores de HTML de las páginas reales, así que antes de la llamada hace falta `libxml_use_internal_errors(true)`; la clase nueva no produce ese ruido. La segunda es el soporte de selectores: `Dom\HTMLDocument` trae los métodos `querySelector` y `querySelectorAll` que conoces del navegador.

La tercera diferencia afecta a cualquiera que extraiga páginas con caracteres acentuados: la codificación de caracteres. Cuando entregamos al analizador antiguo un fragmento UTF-8 sin la etiqueta `<meta charset>`, las letras acentuadas llegaron rotas; el analizador nuevo leyó el mismo fragmento correctamente. Si tienes que trabajar con la clase antigua, hay que declarar la codificación de forma explícita:

```php
$fragment = '<p class="price">Precio: 1.250 euros (Türkiye, Izmir, áéíñü)</p>';

$old = new DOMDocument();
libxml_use_internal_errors(true);
$old->loadHTML($fragment);
echo $old->getElementsByTagName('p')->item(0)->textContent;
// Precio: 1.250 euros (TÃ¼rkiye, Izmir, Ã¡Ã©Ã­Ã±Ã¼)

$old2 = new DOMDocument();
$old2->loadHTML('<?xml encoding="UTF-8">' . $fragment);   // declara la codificación
echo $old2->getElementsByTagName('p')->item(0)->textContent;
// Precio: 1.250 euros (Türkiye, Izmir, áéíñü)

// PHP 8.4: no hace falta ninguna pista extra
$new = Dom\HTMLDocument::createFromString($fragment, LIBXML_NOERROR);
echo $new->querySelector('p.price')->textContent;
// Precio: 1.250 euros (Türkiye, Izmir, áéíñü)
```

Recorrer con XPath todas las tarjetas de producto de una página de listado sigue la misma lógica. El bucle de abajo extrae el título, el precio y el estado de stock de las 20 tarjetas del sitio de prueba:

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

Escribimos `contains(@class, ...)` porque el atributo `class` casi siempre lleva más de un nombre de clase; la igualdad `@class="product_pod"` se salta una etiqueta con `class="product_pod col-xs-6"`. Y `string(...)` devuelve una cadena vacía en lugar de lanzar una excepción cuando la selección está vacía. Puedes encontrar la comparación de los dos lenguajes de selectores en [Selector CSS y XPath](/es/blog/css-selector-vs-xpath).

## ¿Qué capa elegir?

| Capa | Para qué | A favor | En contra |
|---|---|---|---|
| `file_get_contents` | Una prueba puntual | Cero instalación | El código de estado y el tiempo de espera no se ven |
| Funciones `curl_*` | Una página, pocas dependencias | Está en el núcleo, todas las opciones en tu mano | Montas cada petición a mano |
| Guzzle | Trabajo regular de varias páginas | Reintentos, simultaneidad, excepciones limpias | Dependencia de Composer |
| Expresiones regulares | Ninguno de los casos | Parece corto | Se rompe con un espacio o una comilla distinta |
| `DOMDocument` + XPath | Analizar con el núcleo | Sin dependencias, XPath es potente | Pista de codificación y ruido de `libxml` |
| `Dom\HTMLDocument` | PHP 8.4 y superior | Compatible con HTML5, tiene `querySelector` | No existe en versiones anteriores |
| Symfony DomCrawler | Recorrer listas y enlaces | Selectores CSS, `each()`, enlaces absolutos | Instalas dos paquetes más |

## ¿Cómo se usa un proxy con cURL?

Un trabajo que descarga muchas páginas de forma regular choca tarde o temprano con el límite de depender de una única dirección de salida: un sitio que ve cientos de peticiones por minuto desde la misma IP devuelve `429`, o reconoce los bloques de centro de datos y devuelve `403`. El proxy cambia el punto de salida de esas peticiones.

En cURL bastan dos opciones:

```php
$ch = curl_init('https://example.com/producto/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);
```

También puedes escribir las credenciales dentro de la dirección (`CURLOPT_PROXY => 'http://user:pass@pr.proxynet.io:8000'`). Si la contraseña contiene `@`, `:` o `/`, la opción separada es más segura, porque esos caracteres tienen que ir codificados dentro de una dirección. Explicamos los dos métodos de autenticación y la alternativa de la lista blanca de IP en [Autenticación de proxy](/es/blog/proxy-authentication-methods).

Para SOCKS5 también tienes que indicar el tipo de proxy. La distinción crítica aquí es quién resuelve el nombre de dominio:

```php
// El dominio lo resuelve el proxy (socks5h): tu consulta DNS también pasa por el 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');

// Lo mismo escrito en una sola línea
curl_setopt($ch, CURLOPT_PROXY, 'socks5h://user:pass@pr.proxynet.io:1080');

// El dominio lo resuelve la máquina local
curl_setopt($ch, CURLOPT_PROXY, 'socks5://user:pass@pr.proxynet.io:1080');
```

En nuestras pruebas aparecieron dos trampas. La primera: si no escribes el puerto en la dirección, libcurl intenta el puerto 1080 de forma predeterminada, porque [la definición de `CURLOPT_PROXY` en la documentación de libcurl](https://curl.se/libcurl/c/CURLOPT_PROXY.html) lo dice así. De ahí viene el error "could not connect" cuando escribes tu proxy HTTP sin puerto.

La segunda es que una contraseña incorrecta se manifiesta de dos maneras distintas. Camino de una dirección HTTPS, el proxy levanta un túnel; si la contraseña es incorrecta, el túnel no llega a levantarse y `curl_exec` devuelve `false`. `CURLINFO_RESPONSE_CODE` te muestra `0`, y el `407` real solo está dentro de `CURLINFO_HTTP_CONNECTCODE`. Cuando la misma petición va a una dirección HTTP, llega una respuesta normal y el código de estado es simplemente `407`. Es decir, el código que comprueba la contraseña del proxy tiene que leer los dos campos:

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

if ($status === 407 || $tunnel === 407) {
    throw new RuntimeException('Credenciales de proxy rechazadas');
}
```

Qué tipo de proxy elegir depende del destino. Para tráfico intenso hacia tu propio servidor o hacia una fuente sin restricciones, [Proxies de centro de datos](https://proxynet.io/es/datacenter-proxy) es la solución más barata. En sitios que restringen las direcciones de centro de datos hacen falta [Proxies residenciales](https://proxynet.io/es/residential-proxy). Cuando quieres cambiar la dirección de salida en cada petición usas [Proxies rotativos](https://proxynet.io/es/rotating-proxy), y cuando necesitas mantener la misma dirección durante toda la sesión usas [Proxies de sesión fija](https://proxynet.io/es/sticky-proxy).

## Enviar peticiones y capturar errores con Guzzle

Para trabajos de una sola página, cURL basta. Si escribes un trabajo que recorre decenas de páginas de forma regular, Guzzle te da hechas las trescientas o cuatrocientas líneas que escribirías a mano: middleware de reintentos, pool de peticiones simultáneas y excepciones que se separan según el código de estado. La configuración del proxy también se reduce a una línea, porque [según la documentación de opciones de petición de Guzzle](https://docs.guzzlephp.org/en/stable/request-options.html) la opción `proxy` acepta o bien una única cadena o bien un 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) {
    // El servidor devolvió 4xx o 5xx; el objeto de respuesta está dentro de la excepción
    exit('HTTP ' . $e->getResponse()->getStatusCode() . PHP_EOL);
} catch (TransferException $e) {
    // No llegó ninguna respuesta: DNS, tiempo agotado, túnel del proxy (incluido el 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(),
]);
```

Pasarle al objeto `Crawler` la dirección de la página como segundo parámetro es un detalle pequeño pero crítico: sin eso, `link()->getUri()` no puede convertir una dirección relativa en absoluta y lanza una excepción. Toda la lógica de la paginación la tratamos en [La paginación en el web scraping](/es/blog/pagination-web-scraping). Un aviso más sobre `text()`: como indica [la documentación de DomCrawler](https://symfony.com/doc/current/components/dom_crawler.html), lanza una excepción cuando el selector no encuentra nada, así que pasa un valor por defecto (`->text('')`) para que un campo ausente no detenga el trabajo.

Las clases de excepción tienen dos ramas principales y las dos descienden de `TransferException`:

| Excepción | Cuándo | ¿Hay objeto de respuesta? |
|---|---|---|
| `ClientException` | Respuesta `4xx` (`404`, `407` con destino HTTP) | Sí |
| `ServerException` | Respuesta `5xx` | Sí |
| `ConnectException` | No se pudo conectar, puerto cerrado, tiempo agotado | No |
| `TransferException` (clase padre) | No se pudo levantar el túnel, `407` con destino HTTPS | No |

Guzzle 8 añadió clases más detalladas para los errores de conexión, como `NetworkException` y `ConnectTimeoutException`. Para que el código funcione en las dos versiones, ordena las capturas como arriba: primero `BadResponseException` y después `TransferException`.

Por defecto Guzzle lanza una excepción en las respuestas `4xx` y `5xx`. En un trabajo que recorre cientos de direcciones es más cómodo leer el código de estado como un valor: con `'http_errors' => false`, una petición que recibe un `404` devuelve en silencio un objeto de respuesta con el código `404`.

## robots.txt, espera y reintento

No basta con que el código funcione: tiene que comportarse bien. Hay tres reglas.

**Se lee robots.txt.** El estándar está definido en la [RFC 9309](https://www.rfc-editor.org/rfc/rfc9309.html) y enuncia cuatro comportamientos con claridad: gana la regla coincidente más larga, si un `allow` y un `disallow` son equivalentes gana el `allow`, si el archivo devuelve `4xx` no se aplica ninguna restricción y si devuelve `5xx` se considera prohibido todo camino. Las dos funciones de abajo aplican esas cuatro reglas y unen en un solo grupo las líneas `User-agent` escritas de forma consecutiva:

```php
const BOT_TOKEN = 'price-sync';   // el nombre de nuestra cabecera User-Agent

// Extrae de robots.txt las líneas Allow/Disallow del grupo que nos corresponde.
function loadRobotsRules(Client $client): array
{
    $response = $client->get('/robots.txt');
    $status = $response->getStatusCode();
    if ($status >= 500) {
        return [['disallow', '/']];   // inaccesible: todo camino prohibido
    }
    if ($status >= 400) {
        return [];                    // no hay archivo: sin restricciones
    }

    $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];   // empieza un grupo nuevo
            }
            $agents[] = strtolower($value);
            continue;
        }
        $inRules = true;
        foreach ($agents as $agent) {
            $groups[$agent][] = [$field, $value];
        }
    }

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

// Gana la regla coincidente más larga; en caso de empate gana Allow (RFC 9309).
function isAllowed(string $path, array $rules): bool
{
    [$bestLength, $allowed] = [-1, true];
    foreach ($rules as [$field, $pattern]) {
        if ($pattern === '') {
            continue;   // Disallow vacío: sin restricción
        }
        $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;
}
```

**Se espera entre peticiones.** La opción `delay` de Guzzle pone delante de cada petición una espera en milisegundos. Un segundo es un punto de partida razonable para la mayoría de los trabajos, y la simultaneidad se mantiene baja. La regla es simple: sé lo bastante lento como para pasar inadvertido junto al tráfico normal de visitantes del sitio.

**Se reacciona a los errores según el código.** El middleware `Middleware::retry` de Guzzle recibe dos funciones de retorno: una que decide en qué caso se reintenta y otra que dice cuánto hay que esperar. En nuestro servidor de pruebas, una dirección que devolvió dos veces `503` con `Retry-After: 1` respondió `200` al tercer intento en menos de dos segundos; una dirección que devolvió `404` no se reintentó nunca.

```php
// Reintenta como máximo 3 veces en errores temporales; no lo hace en códigos como 403, 404 o 407.
function retryMiddleware(): callable
{
    $decider = function (int $retries, $request, ?ResponseInterface $response = null): bool {
        if ($retries >= 3) {
            return false;
        }
        if ($response === null) {
            return true;   // no llegó respuesta: se cortó la conexión o se agotó el tiempo
        }
        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;   // el tiempo indicado por el servidor
        }
        return (2 ** $retries) * 1000 + random_int(0, 500);
    };

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

El componente aleatorio que se añade a la espera no es casual: evita que las peticiones que fallaron a la vez se reintenten a la vez y provoquen un nuevo atasco. En la lista no están `403`, `404` ni `407`, porque esos códigos no cambian por esperar; ahí el trabajo debe detenerse y registrar el motivo.

## Ejemplo completo: sincronización de precio y stock

Juntemos las piezas. El flujo de abajo lee robots.txt, recorre las páginas de listado y reúne las direcciones de los productos (`collectProductUrls`, un bucle sencillo que sigue el enlace de "página siguiente"), descarga las páginas de producto de dos en dos, extrae precio y stock de la tabla y los escribe en SQLite. Al ejecutarlo a través del proxy de prueba local, guardó los 40 productos de 40 en unos 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
)');
// Cuando el mismo producto llega por segunda vez no se añade una fila: se actualizan precio y stock
$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,   // leemos el código como valor en vez de como excepción
    '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 peticiones abiertas a la vez
    'options'     => ['delay' => 1000],       // espera antes de cada petición
    '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));
```

La función `parseProduct`, que lee la página de producto, empieza con una pequeña comprobación para no aceptar un `200` a ciegas: si falta el elemento de encabezado esperado devuelve `null` y el flujo principal registra esa dirección como error.

```php
function parseProduct(string $html, string $url): ?array
{
    $crawler = new Crawler($html, $url);
    if ($crawler->filter('.product_main h1')->count() === 0) {
        return null;   // llegó un 200 pero falta el elemento esperado
    }

    // Convierte las filas de <table> en un array "encabezado => 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'),
    ];
}
```

Dos notas para cron: ejecuta el script desde la línea de comandos y no a través del servidor web (`php /ruta/sync.php`), porque el límite de tiempo de ejecución predeterminado del lado web corta un trabajo largo por la mitad. Y pon las credenciales del proxy en una variable de entorno, no en el código; el detalle está en [Cómo usar un proxy con wget](/es/blog/wget-proxy).

## Dónde se detiene PHP en una página cargada con JavaScript

Todo lo explicado hasta aquí se apoya en una suposición: los datos que quieres están dentro del primer HTML que envía el servidor. En buena parte de los sitios modernos eso no es cierto. El servidor envía un esqueleto vacío y la lista de productos la trae después el JavaScript que se ejecuta en el navegador. Cuando descargas esa página con cURL tus selectores no encuentran nada, porque las etiquetas que buscas nunca se escribieron en el HTML.

Aquí se detiene PHP, y no tiene nada que ver con la elección de la biblioteca. Tanto Guzzle como DomCrawler analizan el texto que llega; ninguno ejecuta JavaScript. Si quieres controlar un navegador desde PHP, tienes que lanzar Chrome desde fuera con un paquete como Panther, es decir, el trabajo ya no está en PHP sino en el navegador.

La buena noticia es que en la mayoría de los casos no hace falta ningún navegador. Puedes encontrar en el panel Red de las herramientas de desarrollo la petición JSON que la página hace en segundo plano y llamar a esa misma dirección directamente con Guzzle; como el resultado ya son datos estructurados, el paso del análisis también desaparece. Mostramos paso a paso cómo saber si una página es dinámica en [Páginas estáticas y dinámicas](/es/blog/static-vs-dynamic-pages).

## Casos de uso

- **Llevar el precio del proveedor a tu propio panel:** una tarea cron nocturna recorre la lista de productos y actualiza los campos de precio y stock; el montaje está en nuestra página de [extracción de datos](/es/data-scraping).
- **Seguir los precios de la competencia:** registrar a diario el precio del mismo producto en varios sitios; lo vemos en [Seguimiento de precios de la competencia](/es/blog/competitor-price-tracking) y en nuestra página de [monitorización de precios](/es/price-monitoring).
- **Rastrear tu propio sitio:** recorrer tu propio dominio para buscar enlaces rotos y títulos ausentes; mira nuestra página de [rastreador web](/es/web-crawler).
- **Verificar tus anuncios en un marketplace:** comprobar que los campos de stock y título coinciden con tu panel; mira nuestra página de [soluciones de comercio electrónico](/es/e-commerce-proxy).
- **Leer tablas públicas:** tomar tablas de tipo de cambio, tarifas o anuncios de sitios institucionales; el resumen del método, independiente del lenguaje, está en [Cómo extraer datos de un sitio web](/es/blog/extract-data-from-website).

## Errores frecuentes

- **Aceptar un `200` sin mirar el contenido.** Las páginas de verificación y de error también devuelven `200`; antes de cada análisis comprueba que existe un elemento que esperas.
- **No poner tiempo de espera.** Una sola página que no responde deja el script esperando un minuto por culpa de `default_socket_timeout`. Indica los dos tiempos de forma explícita.
- **Guardar el precio como texto.** Si conservas la cadena `£51.77` tal cual, no puedes comparar ni sumar. Conviértela en número y pon la moneda en otra columna.
- **Buscar el nombre de clase con igualdad.** Un XPath que dice `@class="product_pod"` no encuentra la etiqueta `class="product_pod col-xs-6"`.
- **No escribir el puerto en la dirección del proxy.** libcurl prueba 1080 de forma predeterminada y el mensaje de error te despista.
- **Buscar el error `407` en el sitio de destino.** Ese código viene del proxy; revisa el usuario, la contraseña y la lista blanca.
- **Ajustar la simultaneidad con avidez.** Veinte peticiones en paralelo no aceleran el trabajo, te estrellan contra el límite de velocidad.
- **Incrustar las credenciales en el código.** El usuario y la contraseña del proxy no deben entrar en el control de versiones.

## Guía de decisión

| Necesidad | Recomendación |
|---|---|
| Unos pocos campos de una sola página | `curl_*` + `DOMDocument` y XPath |
| PHP 8.4 y costumbre de selectores CSS | `querySelectorAll` con `Dom\HTMLDocument` |
| Trabajo regular de decenas de páginas | Guzzle + DomCrawler con middleware de reintentos |
| Moverse entre páginas de listado | DomCrawler `link()->getUri()` para direcciones absolutas |
| Muchas peticiones desde una IP y recibes `429` | Baja la velocidad y luego [Proxies rotativos](https://proxynet.io/es/rotating-proxy) |
| Las direcciones de centro de datos están restringidas | [Proxies residenciales](https://proxynet.io/es/residential-proxy) |
| Necesitas la misma dirección durante toda la sesión | [Proxies de sesión fija](https://proxynet.io/es/sticky-proxy) |
| El contenido llega con JavaScript | Busca primero la petición JSON en segundo plano |
| El sitio ofrece una API oficial | La API en lugar del scraping |

## Preguntas frecuentes

### ¿Es PHP un lenguaje adecuado para el web scraping?

Sí, siempre que conozcas su límite. En la petición HTTP y en el análisis de HTML las herramientas son maduras: la extensión cURL abre libcurl por completo, Guzzle aporta simultaneidad y reintentos, y DomCrawler con XPath te da selectores potentes. Donde flojea es en la automatización de navegador. Si vas a alimentar un sistema que ya está escrito en PHP, mantener el trabajo en PHP es más simple que traer los datos desde un segundo lenguaje.

### ¿Debería usar la biblioteca Simple HTML DOM?

Esta biblioteca, que aparece en muchos tutoriales antiguos, lleva mucho tiempo sin mantenimiento y funciona de forma notablemente lenta en páginas grandes. El mismo trabajo lo hace `DOMDocument`, que está en el núcleo de PHP, sin ninguna dependencia; en PHP 8.4 pasa a ser compatible con HTML5 gracias a `Dom\HTMLDocument`, y si quieres una interfaz parecida a jQuery tienes Symfony DomCrawler. Para un proyecto nuevo, elige uno de esos tres.

### ¿Qué diferencia hay entre cURL y Guzzle?

Guzzle ya usa cURL por debajo; la diferencia está en el nivel de abstracción. Si descargas una sola página, las funciones `curl_*` bastan y no necesitas instalar ningún paquete. Si quieres reintentos, un pool de peticiones, middleware y excepciones separadas por código de estado, usa Guzzle. La configuración del proxy son unas pocas líneas en ambos casos.

### Recibo un error 407 al usar el proxy, ¿qué debo hacer?

El `407` no viene del sitio de destino sino del proxy, y dice que la autenticación ha fallado. Comprueba primero el usuario y la contraseña. Si la contraseña contiene `@` o `:`, tiene que ir codificada dentro de la dirección; usar por separado la opción `CURLOPT_PROXYUSERPWD` elimina ese problema. Si estás con lista blanca de IP, confirma que la dirección de salida de tu servidor está en la lista. Y recuerda que en las peticiones HTTPS el `407` aparece en `CURLINFO_HTTP_CONNECTCODE` y no en `CURLINFO_RESPONSE_CODE`.

### Los caracteres acentuados de la página que descargo salen rotos, ¿por qué?

Lo más probable es que estés usando `DOMDocument::loadHTML` y que la página no tenga la etiqueta `<meta charset>`. En ese caso el analizador no trata el contenido como UTF-8. La solución es añadir `<?xml encoding="UTF-8">` al principio del HTML o pasarte al método `Dom\HTMLDocument::createFromString` de PHP 8.4, que detecta la codificación por sí mismo. Que la respuesta llegue comprimida puede causar un daño parecido; para eso pásale a `CURLOPT_ENCODING` una cadena vacía.

### ¿Cada cuántos segundos debo enviar una petición?

No hay un número fijo, pero sirven dos medidas: el tamaño del destino (un pequeño sitio institucional y un gran marketplace no soportan la misma carga) y la reacción del propio sitio (si empiezas a recibir `429`, vas demasiado rápido y tienes que respetar el valor de `Retry-After`). Un punto de partida práctico es esperar un segundo entre peticiones y limitar la simultaneidad a dos.

## En resumen

Hacer scraping con PHP consiste en enviar la petición con `curl_*` y analizar el HTML recibido con `DOMDocument` + XPath o con DomCrawler. `file_get_contents` y las expresiones regulares se rompen en la primera página real porque no ven el código de estado ni las variaciones de las etiquetas. Cuando el trabajo pasa de unas pocas páginas entran en juego el middleware de reintentos y el pool de peticiones de Guzzle. Del lado del proxy bastan `CURLOPT_PROXY` y `CURLOPT_PROXYUSERPWD`; no olvides escribir el puerto ni buscar el error `407` del lado del proxy. Lo que de verdad decide, sin embargo, son las tres decisiones previas al código: respetar robots.txt, esperar entre peticiones y extraer solo datos con carácter de hecho. Puedes encontrar los tipos de proxy adecuados en nuestros [servicios de proxy](/es/proxy).
