Por qué tu crawler con Puppeteer puro siempre es detectado
Si alguna vez lanzaste un script de Puppeteer contra un sitio protegido y recibiste un CAPTCHA o un bloqueo instantáneo, no estás solo. Los sistemas anti-bot modernos como Cloudflare, Datadome, PerimeterX y Akamai Bot Manager detectan navegadores automatizados en cuestión de milisegundos.
El problema no es que Puppeteer sea malo — es que fue diseñado para testing, no para evasión. Cuando Chromium se ejecuta bajo protocolo DevTools, deja huellas por todas partes. Estas son las señales más explotadas:
- navigator.webdriver = true — Chrome expone esta propiedad cuando es controlado por automation. Es la señal más obvia y la primera que verifican los WAF.
- Plugins array vacío o inconsistente — Un navegador real tiene plugins (PDF Viewer, Chrome PDF Viewer, etc.). Puppeteer puro reporta un array vacío o con diferencias sutiles.
- Artefactos de ChromeDriver en iframes — El iframe
cd_frame_id_o propiedades comowindow.cdc_adoQpoasnfa76pfcZLmcfl_Arrayson residuos del WebDriver que Cloudflare detecta directamente. - navigator.languages inconsistente — Un navegador real envía Accept-Language headers que coinciden con
navigator.languages. Puppeteer frecuentemente desincroniza ambos. - Permisos de notificación y permisos automation —
navigator.permissions.query({name: 'notifications'})devuelve resultados diferentes en navegadores automatizados.
El resultado: antes de que tu página siquiera cargue, el script anti-bot ya te clasificó como bot. Y si tu IP viene de un datacenter conocido, el bloqueo es doble.
puppeteer-extra con el plugin Stealth: qué parcha y qué no
puppeteer-extra es un wrapper alrededor de Puppeteer que añade un sistema de plugins. El plugin puppeteer-extra-plugin-stealth aplica una colección de parches que neutralizan las señales más comunes de automatización.
Los 10 subsistemas de stealth
El plugin stealth aplica parches en estas áreas clave:
| Subsistema | Qué parcha | Eficacia |
|---|---|---|
| webdriver | navigator.webdriver → undefined | Alta |
| chrome.runtime | Simula chrome.runtime si falta | Media |
| chrome.csi / chrome.loadTimes | Añade funciones stub | Alta |
| Navigator.languages | Sincroniza con Accept-Language | Alta |
| Navigator.plugins | Inyecta plugins realistas | Alta |
| Navigator.permissions | Corrige query de permisos | Media |
| iframe.contentWindow | Oculta artefactos ChromeDriver | Alta |
| Media codecs | Corrige navigator.mediaCodecs | Baja |
| WebGL vendor | Puede randomizar vendor/renderer | Media |
| SourceURL | Elimina //# sourceURL de scripts inyectados | Media |
Estos parches cubren la mayoría de las señales básicas, pero no son suficientes. Los sistemas anti-bot avanzados usan fingerprinting de canvas, WebGL, AudioContext y TLS fingerprinting (JA3/JA4), que stealth no aborda completamente por defecto.
Configuración base: Puppeteer-Extra Stealth
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
async function launchStealthBrowser(proxyUrl = null) {
const args = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-blink-features=AutomationControlled',
'--window-size=1920,1080',
];
if (proxyUrl) {
args.push(`--proxy-server=${proxyUrl}`);
}
return puppeteer.launch({
headless: 'new',
args,
});
}
(async () => {
const browser = await launchStealthBrowser();
const page = await browser.newPage();
await page.setViewport({ width: 1920, height: 1080 });
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' +
'AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/125.0.0.0 Safari/537.36'
);
await page.goto('https://bot.sannysoft.com/');
await new Promise(r => setTimeout(r, 5000));
await browser.close();
})();
Esto ya supera la mayoría de tests básicos. Pero para sitios con protección avanzada, necesitas combinarlo con proxies residenciales y fingerprinting personalizado.
Combinando Stealth con proxies residenciales: la pila definitiva
El error más común es pensar que el stealth del navegador es independiente del proxy. No lo es. Los sistemas anti-bot correlacionan la IP con el comportamiento del navegador. Si tu IP es de un datacenter OVH o DigitalOcean, pero tu navegador parece un usuario de Comcast en Nueva York, la inconsistencia te delata.
Los proxies residenciales resuelven esto porque usan IPs de dispositivos reales conectados a ISPs legítimas. Cuando combinas un fingerprint de navegador realista con una IP residencial coherente, el tráfico es prácticamente indistinguible del orgánico.
Configuración con ProxyHat: HTTP residencial
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
puppeteer.use(StealthPlugin());
// Credenciales ProxyHat con geo-targeting
const PROXY_USER = 'user-country-US';
const PROXY_PASS = 'your_password';
const PROXY_HOST = 'gate.proxyhat.com';
const PROXY_PORT = 8080;
function buildProxyUrl(country = 'US', city = null) {
let user = `user-country-${country}`;
if (city) user += `-city-${city.toLowerCase()}`;
return `http://${user}:${PROXY_PASS}@${PROXY_HOST}:${PROXY_PORT}`;
}
async function createStealthSession(country = 'US', city = null) {
const proxyUrl = buildProxyUrl(country, city);
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-blink-features=AutomationControlled',
`--proxy-server=http://${PROXY_HOST}:${PROXY_PORT}`,
'--window-size=1920,1080',
],
});
const page = await browser.newPage();
// Autenticación del proxy via header
await page.setExtraHTTPHeaders({
'Proxy-Authorization': 'Basic ' +
Buffer.from(`${buildProxyUrl(country, city).split('//')[1].split('@')[0]}`).toString('base64'),
});
await page.setViewport({ width: 1920, height: 1080 });
// User Agent consistente con la geolocalización
const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' +
'AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/125.0.0.0 Safari/537.36';
await page.setUserAgent(ua);
await page.setExtraHTTPHeaders({
'Accept-Language': country === 'DE' ? 'de-DE,de;q=0.9' : 'en-US,en;q=0.9',
});
return { browser, page };
}
(async () => {
const { browser, page } = await createStealthSession('US', 'new-york');
await page.goto('https://ipinfo.io/json');
const ipData = await page.evaluate(() =>
JSON.parse(document.body.innerText)
);
console.log('IP residencial:', ipData.ip, '| Ciudad:', ipData.city);
await browser.close();
})();
Clave: Siempre alinea el Accept-Language header, el User Agent y la geolocalización del proxy. Una IP de Berlín con Accept-Language en-US es una señal de inconsistencia que los anti-bot detectan.
Evaluadores personalizados: randomización de canvas y WebGL por sesión
El plugin stealth parcha señales de automatización, pero no randomiza tu huella digital de canvas o WebGL. Si lanzas 100 sesiones desde la misma máquina, todas comparten el mismo canvas fingerprint. Los anti-bot avanzados detectan esto: muchas sesiones con la misma huella pero IPs diferentes = bot.
La solución es inyectar ruido controlado en canvas y WebGL por sesión, usando page.evaluateOnNewDocument.
Middleware de fingerprinting personalizado
const crypto = require('crypto');
function generateSessionSeed(sessionId) {
return crypto.createHash('sha256')
.update(sessionId + Date.now().toString())
.digest('hex');
}
function createFingerprintInjector(seed) {
// Genera valores deterministas pero únicos por sesión
const noise = parseInt(seed.slice(0, 8), 16) / 0xFFFFFFFF;
const webglVendor = seed.slice(8, 16) % 2 === 0
? 'Google Inc. (NVIDIA)'
: 'Google Inc. (Intel)';
const webglRenderer = seed.slice(8, 16) % 2 === 0
? 'ANGLE (NVIDIA, NVIDIA GeForce GTX 1660 DIRECT3D11 vs_5_0 ps_5_0)'
: 'ANGLE (Intel, Intel(R) UHD Graphics 630 DIRECT3D11 vs_5_0 ps_5_0)';
return `
// Canvas fingerprint noise
const _toDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(type) {
const ctx = this.getContext('2d');
if (ctx) {
const imageData = ctx.getImageData(0, 0, this.width, this.height);
for (let i = 0; i < imageData.data.length; i += 4) {
// Añade ruido sub-pixel imperceptible
imageData.data[i] = Math.min(255, Math.max(0,
imageData.data[i] + Math.round((${noise} - 0.5) * 2)
));
}
ctx.putImageData(imageData, 0, 0);
}
return _toDataURL.apply(this, arguments);
};
const _toBlob = HTMLCanvasElement.prototype.toBlob;
HTMLCanvasElement.prototype.toBlob = function(callback, type, quality) {
const ctx = this.getContext('2d');
if (ctx) {
const imageData = ctx.getImageData(0, 0, this.width, this.height);
for (let i = 0; i < imageData.data.length; i += 4) {
imageData.data[i] = Math.min(255, Math.max(0,
imageData.data[i] + Math.round((${noise} - 0.5) * 2)
));
}
ctx.putImageData(imageData, 0, 0);
}
return _toBlob.apply(this, arguments);
};
// WebGL fingerprint override
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(param) {
if (param === 0x1F00) return '${webglVendor}'; // VENDOR
if (param === 0x1F01) return '${webglRenderer}'; // RENDERER
return getParameter.call(this, param);
};
const getParameter2 = WebGL2RenderingContext.prototype.getParameter;
WebGL2RenderingContext.prototype.getParameter = function(param) {
if (param === 0x1F00) return '${webglVendor}';
if (param === 0x1F01) return '${webglRenderer}';
return getParameter2.call(this, param);
};
// AudioContext fingerprint noise
const _getChannelData = AudioBuffer.prototype.getChannelData;
AudioBuffer.prototype.getChannelData = function(channel) {
const data = _getChannelData.call(this, channel);
if (data.length > 0) {
data[0] += (${noise} - 0.5) * 0.0001;
}
return data;
};
`;
}
// Uso: inyectar antes de cada navegación
async function injectFingerprint(page, sessionId) {
const seed = generateSessionSeed(sessionId);
const script = createFingerprintInjector(seed);
await page.evaluateOnNewDocument(script);
}
Con esto, cada sesión tiene un canvas fingerprint diferente pero estable dentro de la misma sesión. El ruido es imperceptible visualmente pero suficiente para generar hashes diferentes.
Rotación de proxies por contexto de navegador
Para scraping a escala, necesitas múltiples contextos de navegador, cada uno con su propia IP y fingerprint. Puppeteer permite crear IncognitoBrowserContexts, pero el proxy se configura a nivel de navegador, no por contexto. La solución es un pool de navegadores con proxy asignado.
BrowserPool con proxy rotativo
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const { EventEmitter } = require('events');
puppeteer.use(StealthPlugin());
const PROXY_HOST = 'gate.proxyhat.com';
const PROXY_PORT = 8080;
const PROXY_PASS = 'your_password';
class BrowserPool extends EventEmitter {
constructor({ maxBrowsers = 5, maxPagesPerBrowser = 3, countries = ['US'] }) {
super();
this.maxBrowsers = maxBrowsers;
this.maxPagesPerBrowser = maxPagesPerBrowser;
this.countries = countries;
this.pool = []; // { browser, activePages, proxyCountry, sessionId }
this.waitQueue = [];
}
_buildProxyAuth(country) {
const user = `user-country-${country}`;
return { user, pass: PROXY_PASS };
}
async _createBrowserSlot(country) {
const { user, pass } = this._buildProxyAuth(country);
const sessionId = `sess_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const browser = await puppeteer.launch({
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-blink-features=AutomationControlled',
`--proxy-server=http://${PROXY_HOST}:${PROXY_PORT}`,
'--window-size=1920,1080',
],
});
const slot = {
browser,
activePages: 0,
proxyCountry: country,
sessionId,
createdAt: Date.now(),
};
this.pool.push(slot);
this.emit('browser:created', { sessionId, country });
return slot;
}
async getPage(country = null) {
const targetCountry = country || this.countries[
Math.floor(Math.random() * this.countries.length)
];
// Buscar slot disponible
let slot = this.pool.find(s =>
s.proxyCountry === targetCountry &&
s.activePages < this.maxPagesPerBrowser &&
Date.now() - s.createdAt < 10 * 60 * 1000 // Max 10 min de vida
);
if (!slot && this.pool.length < this.maxBrowsers) {
slot = await this._createBrowserSlot(targetCountry);
}
if (!slot) {
// Esperar a que se libere un slot
return new Promise((resolve) => {
this.waitQueue.push({ country: targetCountry, resolve });
});
}
const page = await slot.browser.newPage();
slot.activePages++;
// Autenticar proxy
const { user, pass } = this._buildProxyAuth(targetCountry);
await page.authenticate({ username: user, password: pass });
// Inyectar fingerprint único por sesión
await injectFingerprint(page, slot.sessionId);
// Configurar viewport y UA consistentes
await page.setViewport({ width: 1920, height: 1080 });
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' +
'AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/125.0.0.0 Safari/537.36'
);
// Cleanup al cerrar la página
page.on('close', () => {
slot.activePages--;
this._processWaitQueue();
});
return { page, slot };
}
_processWaitQueue() {
while (this.waitQueue.length > 0) {
const slot = this.pool.find(s =>
s.proxyCountry === this.waitQueue[0]?.country &&
s.activePages < this.maxPagesPerBrowser
);
if (!slot) break;
const { resolve } = this.waitQueue.shift();
// Reintentar asignación
this.getPage(slot.proxyCountry).then(resolve);
}
}
async close() {
for (const slot of this.pool) {
await slot.browser.close().catch(() => {});
}
this.pool = [];
}
}
// Uso del pool
(async () => {
const pool = new BrowserPool({
maxBrowsers: 5,
maxPagesPerBrowser: 3,
countries: ['US', 'DE', 'GB'],
});
const tasks = ['https://example.com/page1', 'https://example.com/page2']
.map(async (url) => {
const { page, slot } = await pool.getPage('US');
try {
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
const title = await page.title();
console.log(`[${slot.sessionId}] ${url} → ${title}`);
} finally {
await page.close();
}
});
await Promise.all(tasks);
await pool.close();
})();
Patrón clave: Cada navegador del pool tiene su propia IP residencial y su propio fingerprint. Las páginas dentro del mismo navegador comparten IP (sticky session), lo cual es natural — un usuario real navega varias páginas desde la misma IP.
Escalado: flotas containerizadas y gestión de recursos
Un pool de navegadores en un solo proceso no escala. Para procesar miles de páginas por hora, necesitas contenedores con navegadores aislados, un orquestador y gestión inteligente de recursos.
Arquitectura de flota con Docker
La arquitectura más efectiva separa el orquestador de los workers:
- Orquestador — Distribuye URLs, gestiona colas, reintentos y deduplicación. Puede ser BullMQ, RabbitMQ o un servicio custom.
- Workers — Contenedores Docker que ejecutan 2-3 instancias de Chromium cada uno. Cada worker tiene su propio proxy residencial.
- Proxy pool — ProxyHat con rotación automática, geo-targeting y sesiones sticky por worker.
# Dockerfile.worker
FROM node:20-slim
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
libappindicator3-1 \
libasound2 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libxss1 \
xdg-utils \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .
# Limitar recursos por worker
ENV MAX_BROWSERS=2
ENV MAX_PAGES_PER_BROWSER=3
ENV NODE_OPTIONS="--max-old-space-size=1024"
CMD ["node", "worker.js"]
Worker con health checks y graceful shutdown
// worker.js — Procesa URLs desde una cola Redis
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const Redis = require('ioredis');
const os = require('os');
puppeteer.use(StealthPlugin());
const PROXY_HOST = 'gate.proxyhat.com';
const PROXY_PORT = 8080;
const MAX_BROWSERS = parseInt(process.env.MAX_BROWSERS || '2');
const MAX_PAGES = parseInt(process.env.MAX_PAGES_PER_BROWSER || '3');
const redis = new Redis(process.env.REDIS_URL);
const QUEUE_KEY = 'scrape:pending';
const RESULT_KEY = 'scrape:results';
let activeBrowsers = 0;
let isShuttingDown = false;
async function processUrl(url, workerId) {
if (isShuttingDown) return;
const country = 'US'; // O extraer del job
const user = `user-country-${country}`;
const pass = process.env.PROXY_PASS;
const browser = await puppeteer.launch({
headless: 'new',
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-blink-features=AutomationControlled',
`--proxy-server=http://${PROXY_HOST}:${PROXY_PORT}`,
`--window-size=1920,1080`,
],
});
activeBrowsers++;
try {
const page = await browser.newPage();
await page.authenticate({ username: user, password: pass });
await injectFingerprint(page, `w${workerId}_${Date.now()}`);
await page.setViewport({ width: 1920, height: 1080 });
await page.setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' +
'AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/125.0.0.0 Safari/537.36'
);
const response = await page.goto(url, {
waitUntil: 'networkidle2',
timeout: 30000,
});
const html = await page.content();
const status = response?.status();
await redis.hset(RESULT_KEY, url, JSON.stringify({
status,
htmlLength: html.length,
workerId,
timestamp: Date.now(),
}));
console.log(`[Worker ${workerId}] ${url} → ${status}`);
} catch (err) {
console.error(`[Worker ${workerId}] Error: ${err.message}`);
// Reencolar con backoff
await redis.lpush(QUEUE_KEY, JSON.stringify({
url, retries: 1, nextAt: Date.now() + 5000,
}));
} finally {
await browser.close().catch(() => {});
activeBrowsers--;
}
}
async function main() {
const workerId = os.hostname();
console.log(`Worker ${workerId} iniciado. Max browsers: ${MAX_BROWSERS}`);
while (!isShuttingDown) {
if (activeBrowsers >= MAX_BROWSERS) {
await new Promise(r => setTimeout(r, 1000));
continue;
}
const job = await redis.brpop(QUEUE_KEY, 5);
if (!job) continue;
try {
const { url } = JSON.parse(job[1]);
await processUrl(url, workerId);
} catch (e) {
console.error('Job parse error:', e.message);
}
}
}
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('Shutting down gracefully...');
isShuttingDown = true;
await redis.quit();
process.exit(0);
});
main();
Patrones de escalado por escenario
| Escenario | Navegadores/contenedor | Contenedores | Proxy strategy |
|---|---|---|---|
| MONITORING de precios (baja frecuencia) | 1-2 | 1-3 | Sticky sessions (1 IP/sitio) |
| SERP scraping (media frecuencia) | 2-3 | 3-10 | Rotación por request |
| E-commerce masivo (alta frecuencia) | 2-3 | 10-50 | Rotación + geo-targeting por mercado |
| QA / Testing automatizado | 1 | 1-2 | IP fija por entorno |
Optimización de recursos
- Limitar --disable-dev-shm-usage — Evita que Chromium use /dev/shm, que en Docker es limitado (64MB por defecto).
- Compartir /dev/shm entre contenedores — Usa
--shm-size=2gen docker run para dar más memoria compartida. - Reutilizar navegadores — No lances un navegador por URL. Usa el pool con 3-5 páginas por navegador.
- Timeout agresivo — Configura
page.setDefaultTimeout(15000)ypage.setDefaultNavigationTimeout(20000). - Monitoreo — Emite métricas de uso de memoria, navegadores activos y tasa de éxito por proxy.
Nota ética: stealth es para scraping legítimo
Las técnicas descritas en esta guía existen para resolver un problema real: muchos sitios bloquean el scraping automatizado incluso de datos públicos. Pero hay una línea clara entre scraping legítimo y fraude.
Uso legítimo:
- Monitorización de precios públicos en e-commerce.
- Recopilación de datos de SERP para SEO y análisis de mercado.
- Testing automatizado de tus propias aplicaciones.
- Investigación académica sobre datos públicamente accesibles.
- Verificación de listings y contenido propio distribuido.
Uso indebido:
- Crear cuentas falsas o realizar compras con identidades fraudulentas.
- Evadir bans impuestos por un servicio por violar sus ToS.
- Scraping de datos personales sin consentimiento (violación de GDPR/CCPA).
- Ataques de credential stuffing o fuerza bruta.
Siempre respeta robots.txt, implementa rate limiting razonable, y consulta los términos de servicio del sitio. El scraping ético beneficia a todo el ecosistema.
Puntos clave
- Puppeteer puro es trivialmente detectable —
navigator.webdriver, plugins vacíos y artefactos de ChromeDriver son señales inmediatas. puppeteer-extra-stealth parcha la mayoría. - Stealth solo no basta — Sin una IP residencial coherente con tu fingerprint, los anti-bot te detectan por inconsistencia IP/comportamiento.
- Los proxies residenciales son esenciales — Proveen IPs de ISPs reales que coinciden con el fingerprint del navegador. ProxyHat ofrece geo-targeting a nivel de país y ciudad.
- Randomiza fingerprints por sesión — Canvas, WebGL y AudioContext deben variar entre sesiones. Usa
evaluateOnNewDocumentpara inyectar ruido. - Rota proxies por navegador, no por página — Un usuario real navega varias páginas desde la misma IP. Usa sticky sessions dentro de un navegador.
- Escala con contenedores y pools — Limita 2-3 navegadores por contenedor, usa colas para distribución, y monitorea recursos activamente.
- La ética importa — Usa estas técnicas para scraping legítimo de datos públicos, nunca para fraude.
Conclusión
Construir un crawler de Puppeteer que no sea detectado requiere una pila completa: puppeteer-extra-stealth para parchar señales del navegador, proxies residenciales para IPs creíbles, y fingerprinting personalizado para que cada sesión sea única. Ninguno de estos componentes funciona bien por sí solo.
La buena noticia es que la implementación es modular. Empieza con stealth + un proxy residencial básico, y ve añadiendo fingerprinting y escalado según lo necesites. Para empezar con proxies residenciales con geo-targeting en +190 países, consulta las opciones de ProxyHat o explora las ubicaciones disponibles.
Si quieres profundizar en patrones de scraping con proxies, revisa nuestra guía de web scraping con proxies y la guía de SERP tracking.






