Crawlee no incluye ningún resolutor de CAPTCHA: lo pones tú. La forma más corta es una función async que envíe el sitekey a la API de CaptchaAI, espere el token y lo devuelva al requestHandler a punto de fallar. Son unas cuarenta líneas de JavaScript, no un plugin, y sirven igual en CheerioCrawler que en PlaywrightCrawler.
Esta guía recorre la integración entera: la función solver, cómo llamarla desde un crawler estático, cómo inyectar el token con Playwright y cómo evitar que el session pool tire a la basura lo que acabas de pagar.
Qué aporta Crawlee y qué tienes que añadir tú
Crawlee resuelve la parte aburrida del scraping —cola, reintentos, sesiones, rotación de proxies— y deja libre el hueco donde entra el solver. Esa división de trabajo es lo que hace cómoda la integración:
| Pieza de Crawlee | Qué te ahorra al resolver CAPTCHA |
|---|---|
| Gestión de sesiones | Mantiene cookies y señales del navegador coherentes con el CAPTCHA resuelto |
| Reintento automático | Reencola la solicitud fallida en vez de perder la URL |
| Rotación de proxies | Cambia la salida de red sin tocar el solver |
| Cola de solicitudes | Otras páginas avanzan mientras un CAPTCHA está en cola |
Lo que Crawlee no hace es reconocer el desafío ni producir el token. Ahí entra CaptchaAI con dos endpoints: in.php para enviar la tarea y res.php para consultar el resultado. Si vienes de otro proveedor, el patrón te sonará: mismo contrato de envío y sondeo, así que migrar un crawler suele reducirse a cambiar el host.
Paso 1: la función solver reutilizable
Todo empieza con solveCaptcha(sitekey, pageurl), que encapsula el ciclo completo: envía la tarea, espera y sondea res.php hasta obtener el token o agotar el tiempo. Así el resto del crawler no sabe —ni le importa— qué servicio hay detrás.
En el ejemplo aparece también el CheerioCrawler que la consume. Fíjate en tres detalles: la espera de 15 segundos antes del primer sondeo, el intervalo de 5 segundos entre consultas y el corte por intentos, para que una URL problemática no bloquee un worker.
const { CheerioCrawler } = require('crawlee');
const https = require('https');
const API_KEY = process.env.CAPTCHAAI_API_KEY;
async function solveCaptcha(sitekey, pageurl) {
// Submit task
const submitData = new URLSearchParams({
key: API_KEY,
method: 'userrecaptcha',
googlekey: sitekey,
pageurl: pageurl,
json: '1',
});
const submitResp = await fetch('https://ocr.captchaai.com/in.php', {
method: 'POST',
body: submitData,
});
const submitResult = await submitResp.json();
if (submitResult.status !== 1) {
throw new Error(`Submit error: ${submitResult.request}`);
}
const taskId = submitResult.request;
// Poll for result
await new Promise(r => setTimeout(r, 15000));
for (let i = 0; i < 24; i++) {
const pollResp = await fetch(
`https://ocr.captchaai.com/res.php?key=${API_KEY}&action=get&id=${taskId}&json=1`
);
const pollResult = await pollResp.json();
if (pollResult.status === 1) return pollResult.request;
if (pollResult.request !== 'CAPCHA_NOT_READY') {
throw new Error(`Solve error: ${pollResult.request}`);
}
await new Promise(r => setTimeout(r, 5000));
}
throw new Error('Solve timeout');
}
// Crawlee spider with CAPTCHA handling
const crawler = new CheerioCrawler({
maxConcurrency: 5,
requestHandlerTimeoutSecs: 180,
async requestHandler({ request, $, log }) {
// Check if page has CAPTCHA
const captchaDiv = $('[data-sitekey]');
if (captchaDiv.length > 0) {
const sitekey = captchaDiv.attr('data-sitekey');
log.info(`CAPTCHA found on ${request.url}, solving...`);
const token = await solveCaptcha(sitekey, request.url);
log.info('CAPTCHA solved, submitting form');
// Submit form with token
const formData = new URLSearchParams({
'g-recaptcha-response': token,
});
const resp = await fetch(request.url, {
method: 'POST',
body: formData,
});
const html = await resp.text();
// Parse the result page...
}
// Extract data
const title = $('title').text();
const data = $('table tr').map((i, row) => ({
col1: $(row).find('td:eq(0)').text().trim(),
col2: $(row).find('td:eq(1)').text().trim(),
})).get();
log.info(`Scraped ${data.length} rows from ${request.url}`);
},
failedRequestHandler({ request, log }) {
log.error(`Failed: ${request.url}`);
},
});
// Run
(async () => {
await crawler.run([
'https://example.com/page1',
'https://example.com/page2',
]);
})();
El detector $('[data-sitekey]') es deliberadamente amplio. Ajústalo al marcado concreto del sitio: un selector demasiado genérico dispara resoluciones que nadie pidió, y cada una ocupa un thread de tu plan.
Paso 2: páginas con JavaScript y PlaywrightCrawler
Cuando el formulario se renderiza en el navegador, Cheerio se queda corto: hay que abrir la página, leer el sitekey del DOM montado, escribir el token en g-recaptcha-response y disparar el callback del widget. PlaywrightCrawler te da esa página; la función solver no cambia.
La secuencia importa: navega, deja que la red se calme, lee el sitekey, resuelve y solo entonces inyecta y envía. Si inyectas antes de que el widget se monte, el callback no existe todavía y el formulario se manda sin token.
const { PlaywrightCrawler } = require('crawlee');
const crawler = new PlaywrightCrawler({
maxConcurrency: 3,
requestHandlerTimeoutSecs: 180,
launchContext: {
launchOptions: {
headless: true,
args: [],
},
},
async requestHandler({ request, page, log }) {
await page.goto(request.url, { waitUntil: 'networkidle' });
// Check for reCAPTCHA
const sitekey = await page.evaluate(() => {
const el = document.querySelector('[data-sitekey]');
return el ? el.getAttribute('data-sitekey') : null;
});
if (sitekey) {
log.info(`CAPTCHA detected, solving for ${request.url}`);
const token = await solveCaptcha(sitekey, request.url);
// Inject token
await page.evaluate((t) => {
const ta = document.querySelector('[name="g-recaptcha-response"]');
if (ta) {
ta.style.display = 'block';
ta.value = t;
}
// Trigger callback
const widget = document.querySelector('.g-recaptcha');
if (widget) {
const cb = widget.getAttribute('data-callback');
if (cb && typeof window[cb] === 'function') {
window[cb](t);
}
}
}, token);
await page.click('button[type="submit"]');
await page.waitForNavigation({ waitUntil: 'networkidle' });
}
// Extract data
const title = await page.title();
const content = await page.textContent('body');
log.info(`Page: ${title}, length: ${content.length}`);
},
});
Baja la concurrencia respecto al crawler estático: cada instancia de Playwright consume mucha memoria y el cuello de botella pasa a ser el tiempo de resolución.
Paso 3: no desperdiciar las sesiones ya resueltas
Este es el paso que más dinero ahorra y el que más gente se salta. Si resuelves un CAPTCHA y en la siguiente solicitud Crawlee usa otra sesión con otra salida de red, has pagado un token para nada. Con el session pool activo y el token —más su marca de tiempo— en session.userData, las solicitudes posteriores de esa sesión aprovechan el estado validado.
const { CheerioCrawler, Session } = require('crawlee');
const crawler = new CheerioCrawler({
useSessionPool: true,
sessionPoolOptions: {
maxPoolSize: 10,
sessionOptions: {
maxUsageCount: 50,
},
},
async requestHandler({ request, $, session, log }) {
// If blocked, solve CAPTCHA and mark session as usable
if ($('.captcha-container').length > 0) {
const sitekey = $('[data-sitekey]').attr('data-sitekey');
const token = await solveCaptcha(sitekey, request.url);
// Store token in session for subsequent requests
session.userData = session.userData || {};
session.userData.captchaToken = token;
session.userData.tokenTime = Date.now();
log.info('CAPTCHA solved, session updated');
}
// Normal scraping
const items = $('div.item').map((i, el) => ({
name: $(el).find('.name').text().trim(),
price: $(el).find('.price').text().trim(),
})).get();
log.info(`Found ${items.length} items`);
},
});
Guarda siempre tokenTime. Los tokens de reCAPTCHA v2 caducan en unos dos minutos: uno vencido devuelve un rechazo silencioso que luego cuesta horas de depuración.
Un escenario realista: monitorización de precios en marketplaces
Un caso frecuente en equipos de España y Latinoamérica: una agencia que sigue precios públicos de catálogo en marketplaces regionales de clase MercadoLibre. Casi todo es HTML estático que CheerioCrawler cubre sin ver un desafío; el CAPTCHA aparece en un puñado de rutas, tras un pico de solicitudes.
Con esa carga no necesitas un plan grande, sino concurrencia suficiente para que los pocos CAPTCHA en vuelo no frenen la cola. CaptchaAI factura por thread —una resolución simultánea— con resoluciones ilimitadas dentro del mes: el cálculo es de capacidad, no de volumen. BASIC ($15/mes, 5 threads) absorbe una cola nocturna modesta y ADVANCE ($90/mes, 50 threads) es el escalón habitual con varios crawlers en paralelo. El coste mensual en USD es fijo, algo que agradece cualquier equipo que factura en moneda local volátil.
Y una nota que no es opcional: respeta los términos de servicio de cada sitio y la normativa de protección de datos aplicable (GDPR y LOPDGDD, LFPDPPP en México y equivalentes).
Ajustes que marcan la diferencia en producción
- Sube
requestHandlerTimeoutSecs. Una resolución puede tardar decenas de segundos y el valor por defecto corta antes de que llegue el token. Los 180 segundos del ejemplo son un punto de partida sensato. - Registra el
taskId. Es lo único que permite correlacionar un error del crawler con una tarea concreta en tu panel de control. - Trata los errores por familias. Los del envío (
in.php) suelen ser de configuración —clave, saldo, parámetros—; los del sondeo (res.php) vienen del desafío. La referencia de códigos de error indica cuál reintentar. - Comprueba el tipo. Aquí usamos reCAPTCHA v2 (
userrecaptcha), pero el mismo esqueleto vale para Cloudflare Turnstile o GeeTest v3 cambiando el método y el campo del token que inyectas. - Aísla la clave.
process.env.CAPTCHAAI_API_KEYen local y variable de entorno en el despliegue; nunca en el repositorio.
Preguntas frecuentes
¿Cuánto tarda en resolverse un reCAPTCHA v2 dentro del crawler?
Lo bastante como para que tengas que subir el tiempo de espera del requestHandler. Sondear más rápido no acelera la resolución: solo multiplica las llamadas a res.php.
¿Necesito un thread por cada página que rastreo?
No. El thread solo queda ocupado mientras hay una resolución en vuelo. Si de cada cien páginas tres muestran desafío, tu necesidad de threads la marca cuántas de esas tres coinciden en el tiempo.
¿Puedo reutilizar el mismo token en varias solicitudes?
Como token, no: cada envío necesita el suyo y caduca en un par de minutos. Lo que sí se reutiliza es la sesión validada al usarlo, y para eso está el session pool del paso 3.
¿Funciona igual si despliego el crawler como actor en Apify?
Sí. El actor hace llamadas HTTP normales, así que el código no cambia: define CAPTCHAAI_API_KEY como variable de entorno del actor y listo.
¿Y si el sitio usa hCaptcha en lugar de reCAPTCHA?
Entonces este patrón no te sirve: CaptchaAI no resuelve hCaptcha ni FunCaptcha (Arkose Labs). Sí cubre reCAPTCHA v2 y v3, Cloudflare Turnstile y Challenge, GeeTest v3 e imagen/OCR, más CaptchaFox (beta), Friendly Captcha (beta) y Lemin (beta).
Guías relacionadas
Crea tu cuenta en captchaai.com, copia la clave y deja que tu crawler siga rastreando cuando aparezca el desafío.