A fecha de 7 de octubre de 2026, Shifter cuenta con el mayor pool de IPs estadounidenses activas, más que Oxylabs y Bright Data, a una fracción del coste.Shifter ahora tiene el mayor conjunto de IPs de EE. UU. activas

Ver comparativas

Scraping

Mejora de aplicaciones PHP con la integración de una API de web scraping

Añade datos web a una aplicación PHP con una API de web scraping: un cliente sin dependencias con reintentos, extracción estructurada y solicitudes paralelas mediante curl_multi.

Chris Collins

19 de octubre de 2026 · 8 min de lectura

Gran parte de la web sigue funcionando con PHP: tiendas, CMS, herramientas internas y portales de clientes. Cuando una de esas aplicaciones necesita datos de otros sitios, como precios de la competencia, detalles de productos o listados, la vía tentadora es obtener las páginas con file_get_contents y analizarlas con expresiones regulares. Funciona el primer día y se rompe al décimo, cuando el sitio bloquea la IP del servidor o cambia su marcado.

Una API de web scraping elimina la mayor parte de esa fragilidad. Obtiene las páginas a través de sus propios proxies y navegadores, reintenta los fallos y puede devolver solo los campos que pidas en JSON. Este tutorial construye un pequeño cliente PHP para una de ellas, sin más dependencias que la extensión curl, y muestra cómo usarlo en una aplicación.

Lo que el cliente necesita hacer

Cuatro cosas cubren la mayoría de los usos reales:

  1. Enviar una solicitud con la clave de API y la URL de destino.
  2. Pedir datos estructurados con reglas de extracción, para que la aplicación nunca tenga que analizar HTML.
  3. Reintentar los errores adecuados y detenerse en los que nunca tendrán éxito.
  4. Obtener muchas páginas en paralelo sin superar el límite de concurrencia del plan.

Si no has usado antes una API de web scraping, nuestra guía para principiantes cubre con más detalle el formato de las solicitudes y los códigos de estado.

El cliente

<?php

final class ShifterScraper
{
    private const RETRY = [408, 422, 429, 500];

    public function __construct(
        private string $apiKey,
        private string $endpoint = 'https://scrape.shifter.io/v1',
        private int $attempts = 3,
        private float $backoffSeconds = 2.0,
    ) {}

    /** Fetch one page; with $extractRules, returns the decoded JSON instead of HTML. */
    public function fetch(string $url, array $extractRules = [], bool $renderJs = false): string|array
    {
        $params = ['api_key' => $this->apiKey, 'url' => $url];
        if ($renderJs) {
            $params['render_js'] = 1;
        }
        if ($extractRules) {
            $params['extract_rules'] = json_encode($extractRules);
        }

        for ($attempt = 1; ; $attempt++) {
            [$status, $body] = $this->get($this->endpoint . '?' . http_build_query($params));
            if ($status === 200) {
                return $extractRules ? json_decode($body, true, flags: JSON_THROW_ON_ERROR) : $body;
            }
            if (!in_array($status, self::RETRY, true) || $attempt >= $this->attempts) {
                throw new RuntimeException("HTTP {$status} for {$url}: " . substr($body, 0, 200));
            }
            usleep((int) ($this->backoffSeconds * $attempt * 1_000_000));
        }
    }

    /** Fetch many pages at once with curl_multi, at most $concurrency in flight. */
    public function fetchMany(array $urls, array $extractRules = [], int $concurrency = 10): array
    {
        $results = [];
        foreach (array_chunk($urls, $concurrency) as $batch) {
            $multi = curl_multi_init();
            $handles = [];
            foreach ($batch as $url) {
                $params = ['api_key' => $this->apiKey, 'url' => $url];
                if ($extractRules) {
                    $params['extract_rules'] = json_encode($extractRules);
                }
                $handles[$url] = $this->handle($this->endpoint . '?' . http_build_query($params));
                curl_multi_add_handle($multi, $handles[$url]);
            }
            do {
                curl_multi_exec($multi, $running);
                curl_multi_select($multi);
            } while ($running > 0);
            foreach ($handles as $url => $h) {
                $status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
                $body = curl_multi_getcontent($h);
                $results[$url] = $status === 200
                    ? ($extractRules ? json_decode($body, true) : $body)
                    : $this->fetchOrNull($url, $extractRules);   // retry failures one by one
                curl_multi_remove_handle($multi, $h);
            }
            curl_multi_close($multi);
        }
        return $results;
    }

    private function fetchOrNull(string $url, array $extractRules): string|array|null
    {
        try {
            return $this->fetch($url, $extractRules);
        } catch (RuntimeException) {
            return null;
        }
    }

    private function handle(string $requestUrl): CurlHandle
    {
        $h = curl_init($requestUrl);
        curl_setopt_array($h, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 120]);
        return $h;
    }

    private function get(string $requestUrl): array
    {
        $h = $this->handle($requestUrl);
        $body = curl_exec($h);
        $status = curl_getinfo($h, CURLINFO_RESPONSE_CODE);
        return [$status, $body === false ? curl_error($h) : $body];
    }
}

Hay varias decisiones de diseño que merece la pena señalar:

  • Reintentos solo donde ayudan. Los tiempos de espera agotados (408), los renderizados fallidos (422), los límites de frecuencia (429) y los errores de servidor (500) se reintentan con una pausa creciente. Cualquier otro caso, como una clave inválida, lanza una excepción de inmediato.
  • Las reglas de extracción se convierten en un array. Con reglas, fetch() devuelve JSON decodificado, de modo que el código que la llama trabaja con $product['price'], no con marcado.
  • Solicitudes en paralelo con curl_multi. fetchMany() envía un lote de solicitudes a la vez y espera a que todas terminen. Los lotes tienen un tamaño pensado para mantenerse por debajo del límite de concurrencia de tu plan. Las páginas que fallan dentro del lote reciben una segunda oportunidad a través de fetch() con sus reintentos, y vuelven como null si siguen fallando.

Cómo usarlo

<?php
require __DIR__ . '/ShifterScraper.php';

$scraper = new ShifterScraper(getenv('SHIFTER_SCRAPER_KEY'));

// One page, as structured data instead of HTML
$product = $scraper->fetch('https://shop.example.com/p/123', [
    'name'  => ['selector' => 'h1', 'output' => 'text'],
    'price' => ['selector' => '.price', 'output' => 'text'],
]);

// A batch, ten requests in flight at a time; failed pages come back as null
$pages = $scraper->fetchMany($productUrls, ['name' => ['selector' => 'h1', 'output' => 'text']], 10);

Guarda la clave de API en una variable de entorno o en la configuración de secretos de tu framework, nunca en el código.

Cómo lo probamos

Ejecutamos el cliente contra un servidor PHP local que simulaba la API. Servía páginas normales, una página que fallaba dos veces con 429 antes de tener éxito, otra que siempre devolvía 401 y otra que siempre agotaba el tiempo de espera con 408. Las pruebas comprobaron que:

  • el HTML y el JSON extraído volvían correctamente;
  • la página inestable tenía éxito en su tercer intento;
  • el 401 se intentaba una sola vez y el 408 se reintentaba hasta el límite antes de fallar;
  • un lote de 13 páginas devolvía 12 resultados y un null para la página fallida.

Las 11 comprobaciones pasaron en PHP 8.5.

Cómo encaja en una aplicación

No hagas scraping durante una visualización de página. Un usuario nunca debería esperar a que se cargue un sitio remoto. Ejecuta la recolección en un worker de cola o en una tarea programada, y sirve los resultados almacenados.

Guarda los resultados con una marca de tiempo. Guarda cada registro con la hora en que se recopiló, para que la aplicación pueda mostrar su antigüedad y actualizar solo lo que esté desactualizado.

Usa caché de forma agresiva. La mayoría de los datos no cambian minuto a minuto. Actualizar cada hora o cada día es suficiente para precios y listados en la mayoría de los sitios, y cada solicitud que evitas ahorra dinero.

Registra los fallos. Anota qué URL devuelven null y por qué. Un aumento repentino normalmente significa que un sitio ha cambiado su diseño y tus reglas de extracción necesitan actualizarse.

Respeta los límites del plan. Ajusta el tamaño del lote al límite de concurrencia de tu plan o por debajo de él. En Shifter ese límite va de 20 a 500 solicitudes simultáneas, según el plan.

Cuándo usar proxies directamente en su lugar

Una API de web scraping se adapta a la mayoría de los casos de recolección de datos: se encarga del renderizado, los reintentos y la elección de IP por ti. Algunos trabajos aún encajan mejor con una conexión directa a un proxy, por ejemplo cuando necesitas control total del cliente HTTP, o sesiones largas en una sola IP. Nuestra guía sobre el uso de proxies residenciales en PHP con cURL y Guzzle cubre esa opción.

Preguntas frecuentes

¿Se puede usar PHP para hacer web scraping?

Sí. La extensión curl de PHP gestiona bien el HTTP, incluidas las solicitudes en paralelo con curl_multi. Combinarlo con una API de web scraping significa que la aplicación no tiene que gestionar por sí misma los proxies, los navegadores ni los bloqueos.

¿Necesito Guzzle para llamar a una API de web scraping desde PHP?

No. El cliente de esta guía usa solo la extensión curl integrada. Guzzle también funciona si tu aplicación ya lo utiliza.

¿Cómo hago scraping de páginas renderizadas con JavaScript desde PHP?

Pide a la API que renderice la página pasando render_js, que el cliente expone como parámetro. La API ejecuta un navegador y devuelve la página renderizada, de modo que PHP nunca necesita un navegador headless.

¿Cuántas solicitudes puedo enviar a la vez?

Tantas como permita tu plan de la API de web scraping. Ajusta el tamaño de los lotes de fetchMany() a ese límite o por debajo.

En resumen

Una aplicación PHP puede usar datos web sin construir infraestructura de scraping: un pequeño cliente basado en curl, reglas de extracción en lugar de análisis de HTML, reintentos solo para los errores que pueden resolverse, y lotes en paralelo ajustados a tu plan. Ejecútalo desde una cola, guarda los resultados con marcas de tiempo, y la aplicación obtendrá datos actualizados sin que un usuario tenga que esperar nunca al sitio web de otro.

¿Listo para empezar?

Prueba los proxies residenciales de Shifter, más de 205M IPs, más de 195 países, desde 0,75 $/GB.

Comenzar