Cuando tu automatización con Playwright llega a un reCAPTCHA v2 o a un Cloudflare Turnstile, el navegador se detiene y el flujo se rompe. La salida no es pelear con el widget dentro de la página: es delegar ese único paso a una API de resolución, recibir el token y reinyectarlo en el formulario para que el sitio lo dé por válido. Playwright encaja bien en este trabajo porque trae espera automática, control de Chromium, Firefox y WebKit, y una API de locator predecible.
En este tutorial vas a construir, pieza por pieza, un cliente reutilizable que cubre lo esencial:
- Un único cliente
solveCaptchaque envía la tarea y hace sondeo del resultado. - Resolución de reCAPTCHA v2, Cloudflare Turnstile y CAPTCHA de imagen.
- Detección automática del tipo de CAPTCHA en la página.
- Una clase
PlaywrightAutomationque encapsula el login completo con CAPTCHA.
Un caso habitual entre equipos hispanohablantes: una agencia que hace QA del checkout de una tienda regional o de un portal de trámites (cita previa, SAT) necesita correr la prueba completa sin frenarse en el CAPTCHA. Con un plan BASIC ($15/mes, 5 threads), el costo mensual en USD es predecible frente a facturar en monedas volátiles.
Requisitos previos
Instala Playwright y descarga el binario de Chromium con el que vas a trabajar:
npm install playwright
npx playwright install chromium
Configuración del navegador con Playwright
Arranca el navegador con un userAgent y un viewport realistas para que la página se comporte como en un equipo normal. Abre en modo visible (headless: false) mientras depuras; luego lo pasarás a headless.
const { chromium } = require("playwright");
async function createBrowser() {
const browser = await chromium.launch({
headless: false,
args: [],
});
const context = await browser.newContext({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
viewport: { width: 1920, height: 1080 },
locale: "en-US",
});
const page = await context.newPage();
return { browser, context, page };
}
El cliente de resolución con CaptchaAI
El corazón de la integración es una sola función que habla con la API. Envía la tarea al endpoint in.php, recibe un taskId y luego hace sondeo contra res.php cada cinco segundos hasta que el resultado está listo. Este patrón de envío y sondeo es común a todos los tipos: solo cambian el method y sus parámetros. La función corta con un error claro si el CAPTCHA no se puede resolver o si se agota el tiempo de espera.
const API_KEY = "YOUR_API_KEY";
async function solveCaptcha(method, params) {
// Submit
const submitResp = await fetch("https://ocr.captchaai.com/in.php", {
method: "POST",
body: new URLSearchParams({ key: API_KEY, method, json: "1", ...params }),
});
const submitData = await submitResp.json();
if (submitData.status !== 1) throw new Error(`Submit: ${submitData.request}`);
const taskId = submitData.request;
// Poll
for (let i = 0; i < 30; i++) {
await new Promise((r) => setTimeout(r, 5000));
const pollResp = await fetch(
`https://ocr.captchaai.com/res.php?${new URLSearchParams({
key: API_KEY,
action: "get",
id: taskId,
json: "1",
})}`
);
const data = await pollResp.json();
if (data.status === 1) return data.request;
if (data.request === "ERROR_CAPTCHA_UNSOLVABLE") throw new Error("Unsolvable");
}
throw new Error("Timed out");
}
Resolver reCAPTCHA v2 con Playwright
El flujo de reCAPTCHA v2 tiene tres movimientos: leer el sitekey del atributo data-sitekey, pedir el token a CaptchaAI con el método userrecaptcha, y escribirlo en el textarea oculto g-recaptcha-response. El paso que muchos olvidan es el último: varios formularios no envían nada hasta que se dispara el callback de reCAPTCHA, y el código lo invoca de forma explícita recorriendo la configuración interna del widget.
async function solveRecaptchaV2(page) {
// Extract sitekey
const sitekey = await page.evaluate(() => {
const el = document.querySelector("[data-sitekey]");
return el ? el.getAttribute("data-sitekey") : null;
});
if (!sitekey) throw new Error("Sitekey not found");
// Solve
const token = await solveCaptcha("userrecaptcha", {
googlekey: sitekey,
pageurl: page.url(),
});
// Inject
await page.evaluate((t) => {
const textarea = document.getElementById("g-recaptcha-response");
if (textarea) {
textarea.value = t;
textarea.style.display = "block";
}
// Trigger callback
if (typeof ___grecaptcha_cfg !== "undefined") {
const clients = ___grecaptcha_cfg.clients;
for (const key in clients) {
for (const prop in clients[key]) {
try {
const cb = clients[key][prop];
if (cb && typeof cb.callback === "function") cb.callback(t);
} catch {}
}
}
}
}, token);
return token;
}
Resolver Cloudflare Turnstile con Playwright
Turnstile sigue la misma lógica con dos matices. El sitekey a veces no está en el elemento .cf-turnstile, así que conviene un plan B que busque cualquier data-sitekey que empiece por 0x. Y el token vuelve en el campo cf-turnstile-response, que puede estar repetido; el código lo escribe en todas las coincidencias.
async function solveTurnstile(page) {
// Extract sitekey
const sitekey = await page.evaluate(() => {
const el = document.querySelector(".cf-turnstile[data-sitekey]");
if (el) return el.getAttribute("data-sitekey");
// Fallback: any data-sitekey starting with 0x
const all = document.querySelectorAll("[data-sitekey]");
for (const item of all) {
const key = item.getAttribute("data-sitekey");
if (key && key.startsWith("0x")) return key;
}
return null;
});
if (!sitekey) throw new Error("Turnstile sitekey not found");
// Solve
const token = await solveCaptcha("turnstile", {
sitekey,
pageurl: page.url(),
});
// Inject
await page.evaluate((t) => {
document
.querySelectorAll('[name="cf-turnstile-response"]')
.forEach((el) => (el.value = t));
}, token);
return token;
}
Detección y resolución automática
En scraping real no siempre sabes de antemano qué CAPTCHA vas a encontrar. Esta función inspecciona la página, decide si hay reCAPTCHA, Turnstile o un CAPTCHA de imagen, y encamina cada caso a su resolución. Así tu script funciona sobre varios sitios sin tocarlo para cada uno.
async function detectAndSolve(page) {
const captchaInfo = await page.evaluate(() => {
// Check reCAPTCHA
const recaptcha = document.querySelector("[data-sitekey]");
if (
recaptcha &&
(document.querySelector(".g-recaptcha") ||
document.querySelector('script[src*="recaptcha"]'))
) {
return { type: "recaptcha", sitekey: recaptcha.getAttribute("data-sitekey") };
}
// Check Turnstile
const turnstile = document.querySelector(".cf-turnstile[data-sitekey]");
if (turnstile) {
return { type: "turnstile", sitekey: turnstile.getAttribute("data-sitekey") };
}
// Check image CAPTCHA
const captchaImg = document.querySelector(
'img.captcha, img[alt*="captcha"], img[src*="captcha"]'
);
if (captchaImg) {
return { type: "image" };
}
return { type: null };
});
if (!captchaInfo.type) return null;
console.log(`Detected: ${captchaInfo.type}`);
switch (captchaInfo.type) {
case "recaptcha":
return await solveCaptcha("userrecaptcha", {
googlekey: captchaInfo.sitekey,
pageurl: page.url(),
});
case "turnstile":
return await solveCaptcha("turnstile", {
sitekey: captchaInfo.sitekey,
pageurl: page.url(),
});
case "image":
return await solveImageCaptcha(page);
default:
return null;
}
}
CAPTCHA de imagen con Playwright
Para los CAPTCHA de imagen clásicos (texto distorsionado, OCR), la vía más directa con Playwright es capturar una screenshot del elemento, mandarla en base64 con el método base64 y escribir la respuesta en el campo de entrada. Playwright fotografía solo el elemento localizado, sin recortes manuales.
async function solveImageCaptcha(page) {
const captchaImg = page.locator(
'img.captcha, img[alt*="captcha"], img[src*="captcha"]'
).first();
// Screenshot the CAPTCHA element
const imgBuffer = await captchaImg.screenshot();
const imgBase64 = imgBuffer.toString("base64");
// Solve via CaptchaAI
const answer = await solveCaptcha("base64", { body: imgBase64 });
// Type the answer
const input = page.locator(
'input[name="captcha"], input[name="code"], input.captcha-input'
).first();
await input.fill(answer);
return answer;
}
Interceptar rutas para capturar parámetros
Algunos CAPTCHA, como GeeTest v3, exponen sus parámetros en respuestas de red antes de renderizarse. Con page.on("response", ...) puedes leer esas respuestas al vuelo y quedarte con valores como gt y challenge, en lugar de rastrearlos en el DOM.
async function interceptCaptchaRoutes(page, url) {
const captchaParams = {};
// Intercept responses
page.on("response", async (response) => {
const respUrl = response.url();
// GeeTest parameters
if (respUrl.includes("geetest") || respUrl.includes("gt=")) {
try {
const data = await response.json();
if (data.gt) {
captchaParams.type = "geetest";
captchaParams.gt = data.gt;
captchaParams.challenge = data.challenge;
}
} catch {}
}
});
await page.goto(url, { waitUntil: "networkidle" });
return captchaParams;
}
Clase de automatización completa
Con las piezas anteriores, esta clase reúne el ciclo entero: abre el navegador, navega, rellena el formulario, detecta y resuelve el CAPTCHA, inyecta el token y envía. El método loginWithCaptcha es el que usarás a diario, y staging.example.com/qa-login deja claro que apunta a tu propio entorno de pruebas.
const { chromium } = require("playwright");
class PlaywrightAutomation {
#apiKey;
#browser;
#context;
#page;
constructor(apiKey) {
this.#apiKey = apiKey;
}
async start(headless = false) {
this.#browser = await chromium.launch({
headless,
args: [],
});
this.#context = await this.#browser.newContext({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36",
viewport: { width: 1920, height: 1080 },
});
this.#page = await this.#context.newPage();
}
async stop() {
await this.#browser?.close();
}
async navigate(url) {
await this.#page.goto(url, { waitUntil: "networkidle" });
}
async fillForm(fields) {
for (const [selector, value] of Object.entries(fields)) {
await this.#page.fill(selector, value);
}
}
async solveCaptcha() {
return await detectAndSolve(this.#page);
}
async submit(selector = 'button[type="submit"]') {
await this.#page.click(selector);
await this.#page.waitForLoadState("networkidle");
return this.#page.url();
}
async loginWithCaptcha(url, fields, submitSelector) {
await this.navigate(url);
await this.fillForm(fields);
const token = await this.solveCaptcha();
if (token) {
// Inject token
await this.#page.evaluate((t) => {
const re = document.getElementById("g-recaptcha-response");
if (re) re.value = t;
document
.querySelectorAll('[name="cf-turnstile-response"]')
.forEach((el) => (el.value = t));
}, token);
}
return await this.submit(submitSelector);
}
get page() {
return this.#page;
}
}
// Usage
const bot = new PlaywrightAutomation("YOUR_API_KEY");
await bot.start();
try {
const result = await bot.loginWithCaptcha(
"https://staging.example.com/qa-login",
{
"#email": "user@example.com",
"#password": "pass123",
},
"#login-btn"
);
console.log(`Redirected to: ${result}`);
} finally {
await bot.stop();
}
Playwright frente a Puppeteer
Ambos automatizan Chromium, pero Playwright trae más funciones de fábrica. Si arrancas un proyecto nuevo, esta comparación lo resume:
| Característica | Playwright | Puppeteer |
|---|---|---|
| Multinavegador | Chromium, Firefox, WebKit | Solo Chromium |
| Estilo de API | Basado en locator |
Basado en selectores |
| Espera automática | Integrada | Esperas manuales |
| Intercepción de red | Por rutas | Por solicitudes |
| Ajustes por defecto | Sensatos de fábrica | Requiere plugin adicional |
| TypeScript | Nativo | Tipos de la comunidad |
Solución de problemas
Los tropiezos más frecuentes al integrar CAPTCHA con Playwright:
| Síntoma | Causa | Solución |
|---|---|---|
page.evaluate devuelve null |
El elemento aún no ha cargado | Usa waitForSelector primero |
| No se detecta Turnstile | Se carga por JS tras la página | Espera al selector .cf-turnstile |
| El token se inyecta pero no se envía | Falta disparar el callback | Llama al callback de reCAPTCHA de forma explícita |
| El sitio detecta la automatización | Falta el script de inicialización | Añade un addInitScript al crear el contexto |
Se agota el tiempo con networkidle |
Scripts de long-polling | Usa domcontentloaded en su lugar |
Preguntas frecuentes
¿Cuánto cuesta resolver CAPTCHA en un proyecto con Playwright?
Depende del volumen concurrente, no de cuántos CAPTCHA resuelvas. CaptchaAI cobra por threads (resoluciones en paralelo) con solves ilimitados en cada plan. BASIC cuesta $15/mes con 5 threads; si un script de QA agota su cola, subes a STANDARD ($30/mes, 15 threads) sin tocar el código.
¿El modo headless de Playwright reduce la tasa de resolución?
No. CaptchaAI resuelve el CAPTCHA del lado del servidor a partir del sitekey y la URL, así que da igual si Playwright corre visible o con headless: true. El modo headless solo afecta al rendimiento de tu propio navegador, no al resultado de la resolución.
¿Qué hago cuando recibo ERROR_CAPTCHA_UNSOLVABLE?
Es una respuesta normal de la API, no un fallo de tu código. Captúrala, espera un momento y reintenta con retroceso exponencial; el ejemplo ya lanza un error que puedes envolver en un bucle de reintentos. Si se repite mucho, revisa que el sitekey y el pageurl que envías sean los reales de la página.
¿Puedo usar este mismo código con TypeScript?
Sí, sin adaptaciones. Playwright trae tipos nativos, por lo que el cliente solveCaptcha y la clase PlaywrightAutomation funcionan igual en un proyecto .ts; solo añade las anotaciones de tipos que quieras en tus firmas.
Resumen
Con Playwright en Node.js y CaptchaAI tienes una pila de automatización moderna: detección automática del tipo de CAPTCHA, interceptación por rutas y soporte para reCAPTCHA v2, Cloudflare Turnstile e imagen. La clase PlaywrightAutomation cierra el círculo: resuelve el login con CAPTCHA en una sola llamada, lista para tus pruebas de QA y tu scraping.