Scraping por lenguaje 19 min de lectura

Web scraping en PHP: guía completa de lo simple a lo complejo

Guía completa de web scraping en PHP: cURL, DOMDocument, Simple HTML DOM, Guzzle y la organización de una recolección periódica en el hosting.

EW
Equipo Web-Scraping.es
Recopilación de datos para las necesidades del negocio
Publicado: 1 abril 2025

Guía general sobre cómo escribir scrapers (herramientas de web scraping) en PHP puro: desde la descarga simple de una página hasta el paralelismo, los proxies, Tor y las colas. Todos los ejemplos son funcionales: puede copiarlos y adaptarlos a su tarea.

Índice

  1. Qué es el web scraping y cuándo se necesita
  2. Ética y aspectos legales (robots.txt, carga)
  3. Cómo descargamos la página - file_get_contents - cURL - Guzzle
  4. Bibliotecas para parsear el contenido - Por qué no usar regex - DOMDocument + DOMXPath - Symfony DomCrawler - Simple HTML DOM / phpQuery - Cuándo es mejor parsear JSON / la API oculta
  5. Solución de problemas de codificación (acentos y eñes)
  6. multi_curl y paralelismo
  7. Uso de proxies
  8. Scraping a través de Tor
  9. Trabajo con HTTPS / SSL
  10. Trabajo con cookies
  11. Código de estado y otras cabeceras
  12. Camuflarse como navegador, pausas, reintentos (complemento)
  13. Páginas JavaScript y navegadores headless (complemento)
  14. Almacenamiento de URL y colas (visión general)
  15. Principales pros y contras de implementarlo en PHP
  16. Conclusión

1. Qué es el web scraping y cuándo se necesita

El web scraping es la obtención automática de páginas de un sitio y la extracción de datos estructurados a partir de ellas: precios, descripciones, contactos, noticias. El proceso casi siempre consta de dos pasos:

  1. Descargar la página HTML (petición HTTP).
  2. Parsearla y extraer los fragmentos necesarios (parseo de HTML/DOM).

Conviene mantener estos dos pasos separados: el «descargador» y el «parser». Así podrá cambiar el método de descarga (cURL → proxy → Tor) sin tocar la lógica de extracción.

Antes de escribir un scraper, compruebe siempre una cosa: si el sitio tiene una API abierta o un endpoint JSON. Parsear un JSON ya listo es decenas de veces más simple y fiable que sacar los datos de una maquetación que cambia cada semana.


2. Ética y aspectos legales

Antes de cargar un servidor ajeno, tenga presentes varias cosas:

  • robots.txt — el archivo donde el sitio indica qué se puede indexar. Jurídicamente no prohíbe el acceso, pero es un gesto de cortesía y, a veces, parte de las condiciones de uso.
  • Carga. No envíe cientos de peticiones por segundo: eso se parece a un DDoS. Introduzca pausas entre peticiones (vea la sección 12).
  • Derechos de autor y datos personales. Recopilar y republicar contenido puede infringir la ley. Extreme la precaución con los datos personales.
  • Condiciones de uso (ToS). Muchos sitios prohíben expresamente la recolección automática. No es un asunto penal, pero puede terminar en bloqueos y reclamaciones.

Un lector simple de robots.txt:

php
function isAllowed(string $url, string $userAgent = '*'): bool
{
    $parts = parse_url($url);
    $robotsUrl = $parts['scheme'] . '://' . $parts['host'] . '/robots.txt';
    $robots = @file_get_contents($robotsUrl);
    if ($robots === false) {
        return true; // no hay robots.txt: formalmente no está prohibido
    }
    // Comprobación simplificada: buscamos un Disallow para nuestra ruta.
    $path = $parts['path'] ?? '/';
    foreach (preg_split('/\R/', $robots) as $line) {
        if (preg_match('/^\s*Disallow:\s*(\S+)/i', $line, $m)) {
            if ($m[1] !== '' && str_starts_with($path, $m[1])) {
                return false;
            }
        }
    }
    return true;
}

Para proyectos serios, use un parser de robots.txt ya hecho (por ejemplo spatie/robots-txt) en lugar de uno casero.


3. Cómo descargamos la página

3.1. file_get_contents — la vía más simple

php
$html = file_get_contents('https://example.com');

Funciona si allow_url_fopen está habilitado en php.ini. Puede pasarse un contexto con cabeceras:

php
$context = stream_context_create([
    'http' => [
        'method'  => 'GET',
        'header'  => "User-Agent: Mozilla/5.0\r\n",
        'timeout' => 10,
    ],
]);
$html = file_get_contents('https://example.com', false, $context);

Contras: no gestiona bien las cookies ni los proxies, no da códigos de respuesta «de serie» y el control de errores es pobre. Sirve para scripts puntuales, no para un scraper en producción.

3.2. cURL — el caballo de batalla

cURL es una extensión disponible casi en cualquier entorno y que da control total sobre la petición. Es la herramienta principal para hacer scraping en PHP.

php
function fetch(string $url): string
{
    $ch = curl_init();
    curl_setopt_array($ch, [
        CURLOPT_URL            => $url,
        CURLOPT_RETURNTRANSFER => true,   // devolver el resultado como cadena, no imprimirlo
        CURLOPT_FOLLOWLOCATION => true,   // seguir las redirecciones
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_TIMEOUT        => 30,     // tiempo máximo total
        CURLOPT_CONNECTTIMEOUT => 10,     // tiempo máximo para establecer la conexión
        CURLOPT_USERAGENT      => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
                                . 'AppleWebKit/537.36 (KHTML, like Gecko) '
                                . 'Chrome/124.0 Safari/537.36',
        CURLOPT_ENCODING       => '',     // aceptar gzip/deflate y descomprimir
    ]);

    $html = curl_exec($ch);

    if ($html === false) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException("cURL error: $error");
    }

    curl_close($ch);
    return $html;
}

Opciones clave:

Opción Para qué
CURLOPT_RETURNTRANSFER devolver la respuesta como cadena
CURLOPT_FOLLOWLOCATION seguir las redirecciones 301/302
CURLOPT_TIMEOUT / CURLOPT_CONNECTTIMEOUT no quedarse colgado eternamente
CURLOPT_ENCODING => '' descomprimir el gzip automáticamente
CURLOPT_HTTPHEADER cabeceras arbitrarias (array de cadenas)
CURLOPT_POSTFIELDS cuerpo de la petición POST

3.3. Guzzle — un cliente HTTP moderno

Si el proyecto usa Composer, resulta más cómodo trabajar con Guzzle. Es un envoltorio sobre cURL con una API humana, soporte de asincronía, middleware, cookie jar, etc.

php
use GuzzleHttp\Client;

$client = new Client([
    'timeout' => 30,
    'headers' => ['User-Agent' => 'Mozilla/5.0 ...'],
]);

$response = $client->get('https://example.com');
$html = (string) $response->getBody();
$status = $response->getStatusCode();

En lo que sigue, los ejemplos usan cURL «a pelo» — para ver la mecánica —, pero en un proyecto real Guzzle suele ahorrar tiempo.


4. Bibliotecas para parsear el contenido

4.1. Por qué no usar expresiones regulares

La tentación de parsear el HTML con una expresión regular es grande, pero HTML no es un lenguaje regular. Cualquier anidamiento, etiqueta sin cerrar o salto de línea rompe la regex. Las expresiones regulares solo son adecuadas para fragmentos muy simples y planos (por ejemplo, sacar un número de una cadena), no para recorrer el árbol del documento.

4.2. DOMDocument + DOMXPath (integrado en PHP)

El método integrado más fiable. Cargamos el HTML en el DOM y lo recorremos con XPath.

php
$dom = new DOMDocument();
libxml_use_internal_errors(true);           // silenciamos los avisos del HTML «roto»
$dom->loadHTML($html);
libxml_clear_errors();

$xpath = new DOMXPath($dom);

// Todos los encabezados h2 dentro del bloque con la clase article
$nodes = $xpath->query('//div[@class="article"]//h2');
foreach ($nodes as $node) {
    echo trim($node->textContent), PHP_EOL;
}

// Obtener el href de los enlaces
$links = $xpath->query('//a/@href');
foreach ($links as $link) {
    echo $link->value, PHP_EOL;
}

Expresiones XPath útiles:

XPath Qué selecciona
//a todos los enlaces
//div[@id="main"] el div con id="main"
//div[contains(@class,"item")] los div cuya clase contiene item
//table//tr/td[2] la segunda celda de cada fila de la tabla
//meta[@property="og:title"]/@content el valor del atributo content

4.3. Symfony DomCrawler (vía Composer) — recomendado

Un envoltorio cómodo sobre el DOM con soporte tanto de selectores CSS como de XPath.

bash
composer require symfony/dom-crawler symfony/css-selector
php
use Symfony\Component\DomCrawler\Crawler;

$crawler = new Crawler($html);

// Selectores CSS (requiere css-selector)
$crawler->filter('div.article h2')->each(function (Crawler $node) {
    echo $node->text(), PHP_EOL;
});

// Atributos
$title = $crawler->filter('meta[property="og:title"]')->attr('content');

// XPath también está disponible
$crawler->filterXPath('//a')->each(fn(Crawler $a) => print($a->attr('href') . "\n"));

4.4. Simple HTML DOM y phpQuery

  • Simple HTML DOM (simple_html_dom) — una biblioteca antigua y muy simple con sintaxis al estilo jQuery. Es cómoda, pero devora memoria y lleva años casi sin evolucionar. Para tareas pequeñas puede valer.
  • phpQuery — un port de jQuery a PHP. También obsoleta, pero con la sintaxis familiar pq('div.item')->find('a').

Para proyectos nuevos, mejor DomCrawler o DOMXPath: son más rápidos y tienen mantenimiento.

4.5. JSON oculto / API — el camino más limpio

Abra DevTools → pestaña Network. A menudo los datos se cargan con una petición XHR aparte que devuelve un JSON ya listo. Parsearlo es una línea:

php
$data = json_decode($jsonString, true);

Es más fiable que cualquier parseo de HTML: la estructura del JSON cambia con menos frecuencia que la maquetación.


5. Solución de problemas de codificación (acentos y eñes)

El dolor más frecuente: caracteres corruptos («mojibake») en el resultado — café en lugar de café. Las causas son una discrepancia de codificaciones (el sitio está en ISO-8859-1/Windows-1252 y usted espera UTF-8) y el hecho de que DOMDocument no siempre detecta bien la codificación de la entrada.

5.1. Detectar la codificación de la página

El sitio comunica su codificación en la cabecera HTTP Content-Type o en el <meta charset>.

php
function detectCharset(string $html, ?string $contentTypeHeader = null): string
{
    if ($contentTypeHeader && preg_match('/charset=([\w-]+)/i', $contentTypeHeader, $m)) {
        return strtoupper($m[1]);
    }
    if (preg_match('/<meta[^>]+charset=["\']?([\w-]+)/i', $html, $m)) {
        return strtoupper($m[1]);
    }
    // Heurística como último recurso
    return mb_detect_encoding($html, ['UTF-8', 'ISO-8859-1', 'Windows-1252'], true) ?: 'UTF-8';
}

5.2. Convertir a UTF-8

php
$charset = detectCharset($html, $contentType);
if ($charset !== 'UTF-8') {
    $html = mb_convert_encoding($html, 'UTF-8', $charset);
    // y sustituimos la declaración del meta para que el DOM no se confunda
    $html = preg_replace('/charset=[\w-]+/i', 'charset=UTF-8', $html, 1);
}

5.3. El truco clave para DOMDocument

DOMDocument::loadHTML adivina la codificación a partir del contenido y se equivoca a menudo. El recurso más fiable es añadir una «pista» antes de cargar:

php
$dom = new DOMDocument();
libxml_use_internal_errors(true);
// obligamos al parser a tratar la entrada como UTF-8
$dom->loadHTML('<?xml encoding="UTF-8">' . $html);
libxml_clear_errors();

o la variante con flags (en PHP 8.1+ no añade nada extra):

php
$dom->loadHTML($html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);

Regla: convierta primero todo el HTML a UTF-8 en la fase de descarga y solo después entréguelo al parser. Así los acentos y las eñes no se rompen.


6. multi_curl y paralelismo

PHP es monohilo por naturaleza, pero cURL sabe mantener varias peticiones en paralelo mediante curl_multi_*. La ganancia de velocidad es enorme: mientras un servidor «piensa», los demás se van descargando.

php
function fetchMany(array $urls, int $concurrency = 10): array
{
    $multi = curl_multi_init();
    $handles = [];
    $results = [];
    $queue = array_values($urls);
    $active = [];

    // función para añadir una petición
    $addHandle = function (string $url) use ($multi, &$active) {
        $ch = curl_init();
        curl_setopt_array($ch, [
            CURLOPT_URL            => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_ENCODING       => '',
        ]);
        curl_multi_add_handle($multi, $ch);
        $active[(int) $ch] = $url;
        return $ch;
    };

    // primer lote
    for ($i = 0; $i < $concurrency && $queue; $i++) {
        $addHandle(array_shift($queue));
    }

    do {
        curl_multi_exec($multi, $running);
        curl_multi_select($multi);   // esperamos eventos sin quemar CPU en vacío

        // recogemos las peticiones terminadas
        while ($done = curl_multi_info_read($multi)) {
            $ch = $done['handle'];
            $url = $active[(int) $ch];
            $results[$url] = curl_multi_getcontent($ch);

            curl_multi_remove_handle($multi, $ch);
            curl_close($ch);
            unset($active[(int) $ch]);

            // metemos la siguiente de la cola
            if ($queue) {
                $addHandle(array_shift($queue));
            }
        }
    } while ($running || $queue || $active);

    curl_multi_close($multi);
    return $results;
}

$pages = fetchMany([
    'https://example.com/1',
    'https://example.com/2',
    'https://example.com/3',
], concurrency: 5);

La idea clave es la ventana deslizante: mantener en vuelo como máximo concurrency peticiones a la vez y añadir nuevas a medida que terminan. Así somos rápidos sin abrir mil conexiones de golpe.

Alternativas para el paralelismo «de verdad»:

  • Guzzle Pool / Promises — peticiones asíncronas con límite de concurrencia, de más alto nivel que curl_multi.
  • ReactPHP / Amp / Swoole — runtimes asíncronos/con corrutinas, si necesita gran escala.
  • pcntl_fork / workers paralelos — varios procesos, cada uno toma su lote de URL de la cola (vea la sección 14).

7. Uso de proxies

Los proxies sirven para:

  • eludir bloqueos por IP (el sitio banea por exceso de peticiones),
  • recopilar datos desde distintas regiones,
  • repartir la carga entre varias direcciones.
php
curl_setopt_array($ch, [
    CURLOPT_PROXY     => '123.45.67.89:8080',
    CURLOPT_PROXYTYPE => CURLPROXY_HTTP,     // o CURLPROXY_SOCKS5
]);

// proxy con autenticación
curl_setopt($ch, CURLOPT_PROXYUSERPWD, 'login:password');

Tipos de proxy:

Tipo Constante Notas
HTTP CURLPROXY_HTTP el más habitual
HTTPS CURLPROXY_HTTPS proxy sobre TLS
SOCKS5 CURLPROXY_SOCKS5 Tor funciona por SOCKS5
SOCKS5 + DNS en el proxy CURLPROXY_SOCKS5_HOSTNAME resolución de dominios en el lado del proxy

Rotación de proxies. Mantenga un pool de direcciones y repártalas en círculo; marque las «muertas» y exclúyalas temporalmente.

php
class ProxyPool
{
    private array $proxies;
    private int $i = 0;

    public function __construct(array $proxies)
    {
        $this->proxies = array_values($proxies);
    }

    public function next(): string
    {
        $proxy = $this->proxies[$this->i % count($this->proxies)];
        $this->i++;
        return $proxy;
    }
}

Se distingue entre proxies de datacenter (baratos, fáciles de detectar) y residenciales/móviles (más caros, pero parecen usuarios reales). La elección depende de lo agresiva que sea la protección del sitio.


8. Scraping a través de Tor

Tor es una red gratuita que ofrece un proxy SOCKS5 anónimo en 127.0.0.1:9050. Resulta cómodo para rotar la IP sin coste, pero es lento y muchos sitios bloquean los nodos de salida de Tor.

Conexión

php
curl_setopt_array($ch, [
    CURLOPT_PROXY     => '127.0.0.1:9050',
    CURLOPT_PROXYTYPE => CURLPROXY_SOCKS5_HOSTNAME, // DNS a través de Tor: importante para el anonimato
]);

Cambio de IP (circuito nuevo)

Tor tiene un puerto de control (9051) por el que se puede pedir un circuito nuevo con la señal NEWNYM. Primero actívelo en torrc:

code
ControlPort 9051
CookieAuthentication 0
HashedControlPassword 16:...   # generar con: tor --hash-password "su_contraseña"

Después, desde PHP:

php
function torNewIdentity(string $password, string $host = '127.0.0.1', int $port = 9051): bool
{
    $fp = @fsockopen($host, $port, $errno, $errstr, 10);
    if (!$fp) {
        return false;
    }
    fwrite($fp, "AUTHENTICATE \"$password\"\r\n");
    $auth = fgets($fp);                 // esperamos el 250 OK
    fwrite($fp, "SIGNAL NEWNYM\r\n");
    $signal = fgets($fp);               // 250 OK
    fclose($fp);

    sleep(5); // Tor no construye el circuito nuevo al instante
    return str_starts_with($auth, '250') && str_starts_with($signal, '250');
}

Ciclo típico: hacer N peticiones → torNewIdentity() → continuar con la IP nueva.

Contras de Tor: velocidad baja, una parte de los sitios devuelve CAPTCHA o 403 de entrada, y el número de nodos de salida es reducido. Para volúmenes serios, mejor proxies de pago.


9. Trabajo con HTTPS / SSL

Por defecto, cURL verifica el certificado SSL, y así debe ser. Los problemas surgen cuando el servidor tiene un paquete de certificados raíz (CA bundle) desactualizado.

php
curl_setopt_array($ch, [
    CURLOPT_SSL_VERIFYPEER => true,   // verificar el certificado (NO lo desactive sin motivo)
    CURLOPT_SSL_VERIFYHOST => 2,      // comprobar que el host coincide con el certificado
    CURLOPT_CAINFO         => '/path/to/cacert.pem', // CA bundle actualizado
]);

El cacert.pem actualizado se descarga de curl.se/docs/caextract.html y se declara en php.ini:

ini
curl.cainfo = "/path/to/cacert.pem"
openssl.cafile = "/path/to/cacert.pem"

No haga esto en producción:

php
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // desactiva la protección contra MITM

Esto elimina la verificación del certificado. Solo es admisible de forma temporal, para depurar en local. La solución correcta al «error de certificado» es actualizar el CA bundle, no desactivar la verificación.

También puede forzar la versión de TLS:

php
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);

Las cookies se necesitan para las sesiones, la autenticación y para superar las «verificaciones» que colocan una cookie y la esperan en la petición siguiente. cURL sabe guardarlas y reenviarlas automáticamente mediante el cookie jar, un archivo.

php
$cookieFile = __DIR__ . '/cookies.txt';

curl_setopt_array($ch, [
    CURLOPT_COOKIEJAR  => $cookieFile,  // dónde GUARDAR las cookies recibidas
    CURLOPT_COOKIEFILE => $cookieFile,  // de dónde LEERLAS en cada petición
]);

Si ambas peticiones (el login y la siguiente) usan el mismo $cookieFile, la sesión se conserva entre ellas.

Ejemplo de autenticación

php
$cookieFile = tempnam(sys_get_temp_dir(), 'ck');

// 1) POST con usuario/contraseña: el servidor devolverá la cookie de sesión
$ch = curl_init('https://example.com/login');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query(['user' => 'me', 'pass' => 'secret']),
    CURLOPT_COOKIEJAR      => $cookieFile,
    CURLOPT_COOKIEFILE     => $cookieFile,
    CURLOPT_FOLLOWLOCATION => true,
]);
curl_exec($ch);
curl_close($ch);

// 2) petición a la página protegida: la cookie se adjunta automáticamente
$ch = curl_init('https://example.com/account');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_COOKIEFILE     => $cookieFile,
    CURLOPT_COOKIEJAR      => $cookieFile,
]);
$account = curl_exec($ch);
curl_close($ch);

Para pasar cookies a mano (sin archivo):

php
curl_setopt($ch, CURLOPT_COOKIE, 'sessionid=abc123; lang=es');

En un scraping paralelo, dé a cada «worker»/proxy su propio archivo de cookies; de lo contrario, las sesiones se mezclarán.


11. Código de estado y otras cabeceras

El scraper está obligado a reaccionar al código de respuesta: 200 — todo bien, 404 — la página no existe, 403/429 — lo han baneado o le piden bajar el ritmo, 5xx — error del servidor.

Código de respuesta

php
$html = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

if ($httpCode === 200) {
    // procesamos
} elseif ($httpCode === 429) {
    // demasiadas peticiones: esperamos y reintentamos
} elseif ($httpCode >= 500) {
    // error del servidor: reintentar más tarde
}

Información útil de curl_getinfo

php
$info = curl_getinfo($ch);
// $info['http_code']       — código de respuesta
// $info['content_type']    — Content-Type (¡aquí viene el charset!)
// $info['redirect_url']    — adónde redirigió
// $info['total_time']      — cuánto tardó
// $info['primary_ip']      — IP del servidor (útil al comprobar un proxy)
// $info['size_download']   — tamaño de la respuesta

Obtener las cabeceras de la respuesta por separado

php
curl_setopt($ch, CURLOPT_HEADER, true); // incluir las cabeceras en la salida
$response = curl_exec($ch);

$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$rawHeaders = substr($response, 0, $headerSize);
$body       = substr($response, $headerSize);

Con más limpieza: mediante un callback que acumula las cabeceras en un array:

php
$headers = [];
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $line) use (&$headers) {
    $parts = explode(':', $line, 2);
    if (count($parts) === 2) {
        $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
    }
    return strlen($line); // es obligatorio devolver la longitud
});
curl_exec($ch);
// ahora tenemos $headers['content-type'], $headers['set-cookie'], etc.

Especialmente importantes: Content-Type (codificación), Set-Cookie, Location (redirección), Retry-After (cuánto esperar tras un 429), Content-Length.


12. Camuflarse como navegador, pausas, reintentos

Para que no baneen el scraper a la segunda petición, este debe comportarse «como una persona».

Cabeceras realistas

php
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
        . '(KHTML, like Gecko) Chrome/124.0 Safari/537.36',
    'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language: es-ES,es;q=0.9,en;q=0.8',
    'Referer: https://example.com/',
    'Connection: keep-alive',
]);

Pausas entre peticiones

php
usleep(random_int(800_000, 2_500_000)); // pausa aleatoria de 0,8–2,5 s

Las pausas aleatorias parecen más naturales que las fijas. Es una cortesía con el servidor y reduce el riesgo de baneo.

Reintentos con espera exponencial (retry/backoff)

php
function fetchWithRetry(string $url, int $maxAttempts = 3): ?string
{
    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT        => 30,
            CURLOPT_FOLLOWLOCATION => true,
        ]);
        $html = curl_exec($ch);
        $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($html !== false && $code === 200) {
            return $html;
        }
        if ($code === 404) {
            return null; // no tiene sentido reintentar
        }
        sleep(2 ** $attempt); // 2, 4, 8 segundos...
    }
    return null;
}

Qué más ayuda a no toparse con un baneo

  • Rotación de User-Agent y de proxies.
  • Conservar las cookies entre peticiones (como un navegador real).
  • Respetar el Retry-After en los 429.
  • Paralelizar con moderación (no cientos de hilos contra un mismo dominio).

13. Páginas JavaScript y navegadores headless

cURL obtiene el HTML original, pero no ejecuta JavaScript. Si el contenido se dibuja en el cliente (una SPA en React/Vue), no estará en el HTML: verá bloques vacíos.

Opciones:

  1. Encontrar la API oculta (sección 4.5) — casi siempre la mejor salida: la SPA toma los datos de un endpoint JSON que puede consultarse directamente.
  2. Navegador headless — arrancar un motor real que ejecute el JS: - Symfony Panther — envoltorio PHP sobre ChromeDriver/Selenium. - php-webdriver + Selenium/Chrome. - La combinación con Puppeteer/Playwright (Node.js) — a veces es más simple sacar el renderizado a un microservicio aparte.
php
// Ejemplo con Symfony Panther
use Symfony\Component\Panther\Client;

$client = Client::createChromeClient();
$crawler = $client->request('GET', 'https://spa-example.com');
$client->waitFor('.product');         // esperamos a que el JS pinte el contenido
$titles = $crawler->filter('.product .title')->each(fn($n) => $n->text());

Los navegadores headless son pesados y lentos: úselos solo cuando sin JS no haya manera.


14. Almacenamiento de URL y colas

Cuando el scraper recorre cientos de miles de páginas, hace falta una cola de URL y un registro de lo ya procesado. A grandes rasgos, los enfoques principales:

Qué almacenar

  • la cola de URL «por procesar» (frontier);
  • el conjunto de URL ya visitadas (para no pasar dos veces por la misma); para la deduplicación es cómodo guardar el hash de la URL;
  • el estado de cada URL: pendiente / en proceso / hecha / error / número de intentos;
  • los resultados en sí (los datos ya parseados).

La variante simple: una base de datos

sql
CREATE TABLE crawl_queue (
    id          BIGINT AUTO_INCREMENT PRIMARY KEY,
    url         VARCHAR(2048) NOT NULL,
    url_hash    CHAR(40) NOT NULL,           -- sha1(url), para la unicidad
    status      ENUM('pending','processing','done','failed') DEFAULT 'pending',
    attempts    INT DEFAULT 0,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uniq_hash (url_hash),
    KEY idx_status (status)
);

El worker «toma» la tarea de forma atómica, para que dos procesos no cojan la misma URL:

php
$pdo->beginTransaction();
$row = $pdo->query(
    "SELECT id, url FROM crawl_queue
     WHERE status='pending' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED"
)->fetch();

if ($row) {
    $pdo->prepare("UPDATE crawl_queue SET status='processing', attempts=attempts+1 WHERE id=?")
        ->execute([$row['id']]);
}
$pdo->commit();

FOR UPDATE SKIP LOCKED (MySQL 8+/PostgreSQL) es la clave para repartir tareas con seguridad entre varios workers.

Cuando hace falta más escala

  • Redis (listas LPUSH/BRPOP, conjuntos SADD para la deduplicación) — una cola rapidísima, una elección muy popular.
  • RabbitMQ / Kafka / Beanstalkd — brokers de mensajes completos, si hay muchos workers y se necesita una entrega fiable.
  • Filtro de Bloom — comprobación compacta de «¿ya vimos esta URL?» sobre miles de millones de direcciones sin almacenar todas las cadenas.

Principio arquitectónico

Separe los roles: el producer encuentra enlaces nuevos y los pone en la cola; los workers consumen la cola en paralelo y escriben el resultado. Así el sistema escala horizontalmente sin esfuerzo: basta con añadir workers.


15. Principales pros y contras de implementarlo en PHP

Pros

  • Barrera de entrada baja — cURL y el DOM vienen integrados, y el entorno está disponible casi en cualquier parte.
  • Un cURL excelente — manejo flexible de proxies, cookies, SSL y cabeceras.
  • Bibliotecas maduras — Guzzle, Symfony DomCrawler/Panther, colas listas para usar.
  • Fácil de integrar en un proyecto web PHP existente (CMS, panel de administración) — el scraper escribe directamente en la misma base de datos.
  • Despliegue barato — el hosting para PHP es masivo y económico.

Contras

  • Sin multihilo real de serie. El paralelismo pasa por curl_multi, varios procesos o runtimes asíncronos (ReactPHP/Amp/Swoole). Es más complicado que los hilos en Go o el async en Python.
  • No ejecuta JS — para las SPA hace falta un navegador headless, que es pesado y lento.
  • Memoria. Las bibliotecas antiguas (Simple HTML DOM) devoran memoria; con grandes volúmenes hay que vigilar las fugas en los workers de larga vida.
  • Velocidad. Para una escala extrema, los stacks especializados (Scrapy en Python, Colly en Go) suelen ser más eficientes y traen más herramientas listas.
  • Fragilidad. Como cualquier scraper, se rompe cuando el sitio cambia su maquetación; no es algo específico de PHP, pero conviene tenerlo presente.

Conclusión: PHP es una opción excelente para la mayoría de las tareas de scraping de pequeña y mediana escala, sobre todo cuando los datos deben ir a parar directamente a un proyecto PHP. Para crawlers muy grandes y renderizado JS pesado, valore stacks especializados o saque el renderizado a un servicio aparte.


16. Conclusión

Un scraper PHP mínimo listo para producción incluye:

  • descarga con cURL con timeouts, redirecciones y CURLOPT_ENCODING => '';
  • User-Agent y cabeceras realistas;
  • conversión de la codificación a UTF-8 antes del parseo (la cura del mojibake);
  • parseo con DOMXPath o Symfony DomCrawler (no con regex);
  • comprobación del código HTTP y manejo de 404/403/429/5xx;
  • pausas entre peticiones y reintentos con backoff;
  • cookie jar, si hay autenticación/sesiones;
  • proxies/rotación de IP si hay bloqueos (o Tor como variante gratuita);
  • curl_multi/workers para ganar velocidad en volúmenes grandes;
  • una cola de URL con deduplicación para un crawling serio.

Las reglas de oro: separe la descarga del parseo, arregle siempre la codificación antes de parsear, respete el servidor ajeno (pausas, robots.txt, nada de DDoS) y busque primero un JSON/API ya listo antes de pelearse con la maquetación.