El web scraping clásico en PHP es cURL + DOMDocument o un tándem como Symfony DomCrawler. Es rápido, barato y funciona de maravilla exactamente mientras el sitio entregue el HTML ya listo. El front-end moderno casi nunca lo hace: el contenido llega por AJAX, React/Vue/Angular lo pintan ya en el navegador, los precios y los catálogos se esconden tras un scroll infinito y en la puerta espera un sistema anti-bots que comprueba si el cliente sabe ejecutar JavaScript.
En ese momento el cliente HTTP deja de dar la talla y toca levantar un navegador de verdad — es decir, controlar un Chrome o Firefox headless directamente desde PHP. Eso resuelve el problema del renderizado de JS, pero a cambio trae toda una clase de problemas nuevos: recursos, fugas de memoria, procesos zombi, pestañas caídas y una depuración complicada. De eso hablaremos.
Cuándo el navegador es realmente necesario
El navegador es una herramienta pesada, y solo merece la pena cargar con él cuando hay una necesidad real. Señales de que no podrá evitarlo:
- el contenido aparece en el DOM solo tras la ejecución de JavaScript (SPA, carga diferida);
- se necesitan acciones de usuario: clics, scroll, relleno de formularios, escenarios de varios pasos;
- el sitio se defiende activamente de los bots y comprueba la ejecución de JS, la huella del navegador, el comportamiento del ratón;
- se requiere renderizar a captura de pantalla o PDF, o tomar métricas de rendimiento.
Si, en cambio, la página entrega los datos necesarios en el HTML original o como JSON a través de una API interna, el navegador sobra: una petición HTTP honesta será decenas de veces más rápida y estable.
Bibliotecas populares
Symfony Panther
Panther es la biblioteca oficial del equipo de Symfony. Por debajo utiliza el protocolo WebDriver y drivers reales (ChromeDriver, GeckoDriver), y su API es compatible con DomCrawler y BrowserKit. Esa es su principal ventaja: usted escribe el código de siempre y, en lugar de un cliente HTTP, por debajo trabaja un Chrome vivo.
use Symfony\Component\Panther\Client;
$client = Client::createChromeClient(null, [
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
]);
$crawler = $client->request('GET', 'https://example.com');
$client->waitFor('.product-card'); // esperamos a que aparezca el elemento
$titles = $crawler->filter('.product-card h2')->each(
fn ($node) => $node->text()
);
$client->quit(); // OBLIGATORIO: si no, el proceso del navegador quedará colgadoPanther va bien para tests de integración y scraping moderado. Su mayor dolor: no gestiona a la perfección el ciclo de vida del navegador — con excepciones, errores fatales o un quit() olvidado, los procesos se acumulan.
php-webdriver/webdriver + Selenium
php-webdriver (el antiguo Facebook WebDriver) es el cliente del protocolo WebDriver de más bajo nivel, pero también el más flexible. Suele trabajar en pareja con Selenium Grid o Standalone, lo que escala cómodamente a un clúster.
use Facebook\WebDriver\Remote\RemoteWebDriver;
use Facebook\WebDriver\Remote\DesiredCapabilities;
use Facebook\WebDriver\WebDriverBy;
$driver = RemoteWebDriver::create(
'http://selenium-hub:4444/wd/hub',
DesiredCapabilities::chrome()
);
try {
$driver->get('https://example.com');
$element = $driver->findElement(WebDriverBy::cssSelector('.price'));
$price = $element->getText();
} finally {
$driver->quit(); // en finally, para cerrar la sesión incluso si hay un error
}El tándem PHP + Selenium resulta especialmente cómodo cuando la infraestructura de navegadores debe extraerse a un servicio aparte (Docker Compose, Kubernetes) y PHP debe quedar como un cliente ligero.
chrome-php/chrome (headless Chrome vía DevTools Protocol)
Esta biblioteca habla con Chrome directamente por el Chrome DevTools Protocol (CDP), sin pasar por WebDriver. Es más rápida, está más cerca del «metal» y da un control fino sobre las páginas, la red y las pestañas.
use HeadlessChromium\BrowserFactory;
$factory = new BrowserFactory('chromium-browser');
$browser = $factory->createBrowser([
'headless' => true,
'noSandbox' => true,
'windowSize' => [1920, 1080],
'keepAlive' => false,
]);
try {
$page = $browser->createPage();
$page->navigate('https://example.com')->waitForNavigation();
$html = $page->getHtml();
} finally {
$browser->close(); // cerramos el navegador y todas sus páginas
}El enfoque CDP ofrece el acceso más directo a la gestión de pestañas (targets) y a la interceptación de peticiones de red — algo que nos será útil más adelante.
Goutte / DomCrawler — para comparar
Conviene recordarlo: Goutte (hoy, simplemente BrowserKit sobre HttpClient) y el DomCrawler puro no son navegadores — no ejecutan JavaScript. A menudo se confunden con soluciones de navegador, pero son clientes HTTP. Si la página necesita JS, resultan inútiles; en cambio, con la estática son incomparablemente más ligeros.
Problemas del scraping con navegador
Recursos y rendimiento
Cada instancia de Chrome son decenas, a veces cientos, de megabytes de RAM, con procesos separados para el renderizado, la GPU y la red. Una decena de navegadores en paralelo se come con facilidad varios gigabytes. Por eso el scraping con navegador casi siempre se construye como un pool con un límite duro de paralelismo, una cola de tareas y timeouts en cada operación.
Un conjunto típico de flags de arranque para un entorno de servidor, que ahorra recursos y elimina caídas frecuentes:
--headless=new
--no-sandbox
--disable-dev-shm-usage # crítico en Docker: si no, /dev/shm se desborda y Chrome se cae
--disable-gpu
--disable-extensions
--blink-settings=imagesEnabled=false # no cargar imágenes si no hacen falta
--js-flags=--max-old-space-size=512 # limitar el apetito de V8Fugas de memoria
Esta es, quizá, la principal dolencia oculta de los scrapers de larga vida. Las fugas llegan por dos frentes.
Del lado del propio navegador. Chrome no está pensado para abrir miles de páginas en una misma sesión. El consumo de memoria crece de página en página: se acumula la caché, no se liberan los árboles DOM, se amontonan los listeners de eventos del lado del sitio. Un mismo proceso de navegador que atiende una cola larga puede ocupar, al cabo de unas horas, varias veces más memoria que al principio.
Del lado de PHP. El propio proceso worker de PHP también gotea: objetos que envuelven páginas, respuestas, árboles DOM acumulados en memoria, variables sin limpiar. Especialmente peligrosos son los workers long-running (Symfony Messenger, RoadRunner, Swoole, un demonio corriente en un bucle while (true)), donde el proceso vive días enteros.
Medidas prácticas:
- Reiniciar el worker por contador de tareas. El recurso más fiable: el worker procesa N tareas (por ejemplo, 50--200) y termina; el supervisor levanta uno nuevo. La memoria acumulada la libera por completo el sistema operativo, no el recolector de basura de PHP.
- Reiniciar también el propio navegador periódicamente. No mantener un mismo Chrome durante miles de páginas: cerrarlo y crearlo de nuevo cada M páginas.
- Cerrar las páginas (pestañas) justo después de usarlas, y no solo al final de todo el lote.
- Vigilar las métricas. Registrar el
memory_get_usage(true)del worker y el RSS de los procesos del navegador, y construir gráficos. Un crecimiento lineal de la memoria en el tiempo es señal inequívoca de fuga. - Usar
gc_collect_cycles()con cuidado. La recolección forzada de referencias cíclicas a veces ayuda, pero no sustituye al reinicio.
Ventanas sin cerrar y procesos zombi
Es un problema específico de los navegadores y fácil de pasar por alto. Si el script PHP cayó con una excepción, fue eliminado por timeout o simplemente olvidó llamar a quit()/close(), el proceso del navegador no muere con él. Se queda colgado en el sistema — con todas sus pestañas abiertas y su memoria ocupada. En un scraper activo, esos procesos «huérfanos» se acumulan por decenas, y al cabo de un día el servidor está lleno de Chrome zombis aunque no quede ni una sola tarea activa.
Esto hay que monitorizarlo y limpiarlo sin falta. Varios niveles de defensa:
1. Cierre en finally. Todo código que abra un navegador debe cerrarlo en finally, para que una excepción no deje el proceso colgado:
$browser = $factory->createBrowser([...]);
try {
// ... trabajo
} finally {
$browser->close();
}2. Registro de una función de shutdown. Para el caso de los errores fatales:
register_shutdown_function(function () use ($browser) {
try { $browser->close(); } catch (\Throwable $e) {}
});3. Monitorización externa de procesos. Comprobar con regularidad (por cron o con un script watchdog) cuántos procesos de navegador viven en el sistema y si hay entre ellos «viejos» que hayan sobrevivido a su padre:
# Cuántos procesos de chrome/chromium hay colgados ahora mismo
pgrep -c -f 'chrome|chromium'
# Encontrar procesos de más de 30 minutos: candidatos a eliminar
ps -eo pid,etimes,comm | awk '$2 > 1800 && $3 ~ /chrome/ {print $1}'
# Limpieza dura de navegadores zombi de más de media hora
ps -eo pid,etimes,comm | awk '$2 > 1800 && $3 ~ /chrome/ {print $1}' | xargs -r kill -9Conviene programar este watchdog como una tarea cron aparte cada pocos minutos. No cura la causa, pero evita que el servidor se venga abajo por las ventanas acumuladas mientras usted busca la fuga. En paralelo, es útil crear una alerta del tipo «hay más de X procesos de navegador» o «existe un proceso de navegador de más de Y minutos» — es la primera señal de que en algún sitio se está perdiendo un quit().
4. Ejecución en un contenedor desechable. La variante radical pero limpia: cada tarea de scraping arranca en un contenedor Docker nuevo con su navegador, que se destruye de forma garantizada al terminar la tarea. Los zombis no tienen físicamente dónde acumularse.
Gestión de pestañas
Abrir un navegador nuevo por cada página sale caro. A menudo compensa mantener un solo navegador y trabajar con pestañas (en términos de CDP, targets; en términos de WebDriver, windows). Pero aquí hay trampas propias.
La regla principal: las pestañas hay que contarlas y cerrarlas con la misma disciplina que los propios navegadores. Una pestaña abierta y olvidada es la misma fuga, solo que dentro de un proceso vivo. El propio sitio puede abrir pestañas nuevas (target="_blank", popups, publicidad), y si no se les sigue la pista, se acumulan en silencio.
// chrome-php: control explícito de las pestañas
$page = $browser->createPage();
$page->navigate('https://example.com')->waitForNavigation();
$data = $page->getHtml();
$page->close(); // cerramos la pestaña de inmediato, sin esperar al final del lote// php-webdriver: vigilamos las ventanas y cerramos las que sobran
$handles = $driver->getWindowHandles();
foreach ($handles as $handle) {
if ($handle !== $mainWindow) {
$driver->switchTo()->window($handle)->close();
}
}
$driver->switchTo()->window($mainWindow);Prácticas útiles:
- limitar el número de pestañas abiertas a la vez (por ejemplo, no más de 5--10 por navegador);
- cerrar la pestaña justo después de procesar la página, en lugar de acumularlas «para luego»;
- cotejar periódicamente el número real de pestañas con el esperado — una discrepancia avisa de que el sitio abrió algo de más o de que el código no cierra en algún punto;
- al reutilizar una pestaña, limpiar su estado (cookies, localStorage); de lo contrario, las sesiones se mezclan entre tareas.
PHP como orquestador: cuando en realidad lanza Python o Node
Merece un apartado propio un escenario arquitectónico honesto que se da más a menudo de lo que se suele admitir. PHP desempeña en el proyecto el papel de orquestador: acepta las tareas, las pone en cola, gobierna la lógica del proceso de negocio, escribe los resultados en la base de datos. Pero el scraping con navegador no lo hace él — porque el ecosistema de automatización de navegadores es mucho más rico en Node.js (Puppeteer, Playwright) y en Python (Playwright, Selenium, undetected-chromedriver, envolturas anti-detección).
Al final, el «scraper en PHP» luce en realidad así: PHP prepara la tarea y lanza un proceso externo en otro lenguaje, que es quien hace todo el trabajo real con el navegador, mientras PHP se limita a procesar su salida.
use Symfony\Component\Process\Process;
$process = new Process([
'python3',
'/app/scrapers/playwright_scraper.py',
'--url', $url,
'--timeout', '30',
]);
$process->setTimeout(60); // timeout para el proceso completo: obligatorio
try {
$process->mustRun();
$payload = json_decode($process->getOutput(), true, 512, JSON_THROW_ON_ERROR);
} catch (\Throwable $e) {
// importante: si PHP eliminó el proceso por timeout, al navegador hijo
// también hay que rematarlo; si no, zombis otra vez
$process->stop(5, SIGKILL);
throw $e;
}Por qué se hace así:
- Madurez de las herramientas. Playwright y Puppeteer superan con claridad a los análogos de PHP en interceptación de red, emulación, anti-detección y estabilidad.
- Aislamiento. El navegador vive en un proceso hijo. Si se cae o tiene fugas, no tumba ni infla el worker de PHP. PHP se mantiene ligero y estable.
- Escalado por roles. PHP responde de la lógica de negocio, las colas y el almacenamiento — lo que sabe hacer bien. El navegador, del renderizado.
El matiz principal de este esquema es la frontera de responsabilidad sobre los procesos. Cuando PHP elimina al proceso hijo por timeout, es importante asegurarse de que con él murió también el navegador que ese proceso lanzó. De lo contrario, los Chrome zombis se mudan un nivel más abajo y resultan aún más difíciles de detectar: PHP está limpio, pero la memoria se fuga «no se sabe de dónde». Por eso el script hijo debe cerrar él mismo su navegador correctamente en su finally/atexit, y PHP debe enviar el SIGKILL a todo el grupo de procesos, no a un único PID. Lanzar el proceso hijo en su propio process group y matar el grupo entero es la manera fiable de no dejar cabos sueltos.
Es una arquitectura normal y extendida. Llamar al proyecto «scraper en PHP» no es ningún engaño: PHP orquesta de verdad el proceso; simplemente delega el trabajo pesado del navegador allí donde existen mejores herramientas para hacerlo.
VNC para la depuración inicial
El modo headless es cómodo en producción, pero un suplicio al depurar: usted no ve qué ocurre en la página. El scraper «no encuentra el elemento» y el porqué es un misterio: si apareció el banner de cookies, si saltó un CAPTCHA, si la maquetación es distinta para el bot o si simplemente no se esperó a que cargara. Las capturas de pantalla ayudan, pero son fotogramas sueltos, no una imagen viva.
La solución para la depuración inicial es levantar el navegador no en headless, sino en modo normal dentro de un display virtual, y observarlo por VNC. Usted ve literalmente la pantalla del navegador en tiempo real: cómo hace clic, qué se carga, dónde se atasca.
El esquema en Docker es simple:
- dentro del contenedor se lanza un servidor X virtual (
Xvfb) — el navegador necesita «algún sitio» donde dibujar; - encima, un servidor VNC (
x11vnc) que retransmite ese display hacia fuera; - el navegador arranca sin el flag
--headless, para dibujar de verdad en la pantalla; - usted se conecta con cualquier cliente VNC (o vía noVNC directamente en el navegador) y observa.
# Fragmento: kit para depurar por VNC
RUN apt-get update && apt-get install -y \
chromium xvfb x11vnc fluxbox
ENV DISPLAY=:99
# arranque (simplificado; en la práctica, vía supervisor/entrypoint):
# Xvfb :99 -screen 0 1920x1080x24 &
# fluxbox & # gestor de ventanas ligero
# x11vnc -display :99 -forever -nopw & # VNC hacia fuera, puerto 5900
# después PHP/Python lanza Chrome SIN --headlessLas imágenes ya preparadas tipo selenium/standalone-chrome-debug traen el VNC de serie — para el tándem PHP + Selenium es el arranque más rápido: se conecta al puerto 5900, lanza la tarea desde PHP y mira qué hace el navegador.
Cuándo resulta el VNC especialmente valioso:
- está cazando un CAPTCHA intermitente o un anti-bot que no salta siempre;
- el selector «a veces no aparece» — a simple vista se ve qué estorba (una ventana modal, un popup, otra configuración regional);
- está desentrañando un escenario de varios pasos (login, carrito, pago) donde importa la secuencia;
- comprueba cómo reacciona el sitio precisamente a su navegador, con sus flags y su fingerprint.
Importante: el VNC es una herramienta de depuración inicial y desarrollo del escenario. En producción no hay que mantenerlo: un puerto abierto es superficie de ataque, y un navegador con interfaz gasta recursos de más. Depurado el escenario en vivo y comprobado que todo funciona, vuelva al headless.
Checklist final
Si construye un scraper con navegador sobre PHP, tenga presente como mínimo:
- Elección de la herramienta. Panther, por sencillez y compatibilidad con Symfony; php-webdriver + Selenium, para una infraestructura escalable; chrome-php/chrome, para el control fino vía CDP. Para el trabajo serio con navegador, no dude en delegar en Playwright/Puppeteer mediante un proceso hijo.
- Ciclo de vida. Cierre siempre el navegador y las pestañas en
finally; registre unaregister_shutdown_function; reinicie los workers y los propios navegadores por contador. - Memoria. Registre el consumo del worker y el RSS de los navegadores, busque crecimientos lineales, trátelos con reinicios.
- Ventanas zombi. Monitorice sin falta el número y la edad de los procesos del navegador, instale un watchdog que remate a los procesos huérfanos y alertas sobre su acumulación.
- Pestañas. Cuéntelas y ciérrelas con el mismo rigor que los navegadores; vigile que el sitio no haya abierto ninguna de más.
- Orquestación. Si PHP lanza Python/Node, elimine el grupo de procesos completo, no un único PID, y que el script hijo cierre él mismo su navegador.
- Depuración. Levante VNC + Xvfb para ver el navegador en vivo durante el desarrollo; no lo lleve a producción.
El scraping con navegador en PHP no consiste en «encontrar la biblioteca mágica», sino en la disciplina de gestionar los recursos. Extraer los datos de un DOM ya listo es la parte fácil. Lo difícil es conseguir que, tras una semana de funcionamiento ininterrumpido, el servidor no esté sembrado de pestañas zombis y de workers hinchados.