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.
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? y la sintaxis de robots.txt en ¿Qué es 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.
- 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. - Se valida la respuesta. Se lee el código de estado. Un
200no significa que tengas los datos; comprueba que el elemento que esperas está realmente en la página. - 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).
- 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.
<?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.
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.
¿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:
$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; // 2La 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, 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:
$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:
$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.
¿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:
$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.
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:
// 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 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:
$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 es la solución más barata. En sitios que restringen las direcciones de centro de datos hacen falta Proxies residenciales. Cuando quieres cambiar la dirección de salida en cada petición usas Proxies rotativos, y cuando necesitas mantener la misma dirección durante toda la sesión usas Proxies de sesión fija.
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 la opción proxy acepta o bien una única cadena o bien un 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) {
// 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. Un aviso más sobre text(): como indica la documentación de DomCrawler, 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 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:
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.
// 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.
$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.
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.
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.
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.
- 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 y en nuestra página de monitorización de precios.
- Rastrear tu propio sitio: recorrer tu propio dominio para buscar enlaces rotos y títulos ausentes; mira nuestra página de rastreador web.
- 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.
- 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.
Errores frecuentes
- Aceptar un
200sin mirar el contenido. Las páginas de verificación y de error también devuelven200; 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.77tal 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 etiquetaclass="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
407en 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 |
| Las direcciones de centro de datos están restringidas | Proxies residenciales |
| Necesitas la misma dirección durante toda la sesión | Proxies de sesión fija |
| 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.




