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
- Qué es el web scraping y cuándo se necesita
- Ética y aspectos legales (robots.txt, carga)
- Cómo descargamos la página - file_get_contents - cURL - Guzzle
- 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
- Solución de problemas de codificación (acentos y eñes)
- multi_curl y paralelismo
- Uso de proxies
- Scraping a través de Tor
- Trabajo con HTTPS / SSL
- Trabajo con cookies
- Código de estado y otras cabeceras
- Camuflarse como navegador, pausas, reintentos (complemento)
- Páginas JavaScript y navegadores headless (complemento)
- Almacenamiento de URL y colas (visión general)
- Principales pros y contras de implementarlo en PHP
- 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:
- Descargar la página HTML (petición HTTP).
- 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:
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
$html = file_get_contents('https://example.com');Funciona si allow_url_fopen está habilitado en php.ini. Puede pasarse un contexto con cabeceras:
$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.
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.
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.
$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.
composer require symfony/dom-crawler symfony/css-selectoruse 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:
$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>.
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
$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:
$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):
$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.
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.
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.
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
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:
ControlPort 9051
CookieAuthentication 0
HashedControlPassword 16:... # generar con: tor --hash-password "su_contraseña"Después, desde 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.
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:
curl.cainfo = "/path/to/cacert.pem"
openssl.cafile = "/path/to/cacert.pem"No haga esto en producción:
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // desactiva la protección contra MITMEsto 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:
curl_setopt($ch, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2);10. Trabajo con cookies
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.
$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
$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):
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
$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
$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 respuestaObtener las cabeceras de la respuesta por separado
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:
$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
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
usleep(random_int(800_000, 2_500_000)); // pausa aleatoria de 0,8–2,5 sLas 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)
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-Afteren 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:
- 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.
- 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.
// 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
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:
$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, conjuntosSADDpara 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.