Jedes CAPTCHA-Handling in Puppeteer folgt derselben Kette: Parameter aus dem DOM oder aus dem Netzwerkverkehr lesen, die Aufgabe an CaptchaAI übermitteln, das Ergebnis abfragen, das Token in das passende Formularfeld eintragen und erst dann absenden. Alles andere ist Ablaufsteuerung.
Unangenehm wird es dort, wo echte Projekte von der Beispielseite abweichen: Das Widget steckt in einem Iframe einer fremden Domain, der Sitekey taucht erst in einer nachgeladenen Anfrage auf, die Abfrage erscheint im letzten Schritt einer Registrierungsstrecke. Dieser Leitfaden zeigt die Muster für genau diese Fälle.
Voraussetzungen und Projektaufbau
Sie brauchen Node.js 18 oder neuer (dort ist fetch ohne Zusatzpaket verfügbar), einen API-Schlüssel aus Ihrem CaptchaAI-Dashboard und Puppeteer:
npm install puppeteer
Ersetzen Sie in allen Beispielen YOUR_API_KEY durch Ihren eigenen Schlüssel – produktiv als Umgebungsvariable, in GitLab CI als maskierte Variable, nie im Repository.
Browser starten: die Flags, die in Containern zählen
In Docker-Containern und CI-Runnern startet Chromium ohne --no-sandbox und --disable-setuid-sandbox in der Regel gar nicht. Der neue Headless-Modus (headless: "new") rendert näher am sichtbaren Browser als die alte Variante:
const puppeteer = require("puppeteer");
const API_KEY = "YOUR_API_KEY";
async function createBrowser() {
const browser = await puppeteer.launch({
headless: "new",
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
],
});
return browser;
}
Für die lokale Fehlersuche hilft headless: false: Sie sehen sofort, ob ein Feld sichtbar wird oder die Seite umleitet.
Der Solver-Helper: übermitteln und Status abfragen
Der Helper kapselt beide API-Schritte. Ein POST an in.php übergibt method und die typspezifischen Parameter, danach fragt eine Schleife res.php ab, bis ein Token zurückkommt:
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
async function solveCaptcha(method, params) {
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;
for (let i = 0; i < 30; i++) {
await sleep(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");
}
Drei Details entscheiden über die Stabilität: json: "1" liefert auswertbare Antworten, ERROR_CAPTCHA_UNSOLVABLE bricht sofort ab, und nach 30 Durchläufen à 5 Sekunden endet das Polling – rund 150 Sekunden pro Aufgabe.
reCAPTCHA v2 im Iframe: Sitekey lesen, Token eintragen
Das Widget rendert in einem Iframe einer fremden Domain, in den Sie nicht hineinklicken können. Sie müssen es auch nicht: Der Sitekey steht im data-sitekey-Attribut der Hauptseite oder im k=-Parameter der Iframe-URL, und das Token gehört in das Feld g-recaptcha-response der Hauptseite – nicht in den Iframe.
async function solveRecaptchaInIframe(page) {
// Wait for the reCAPTCHA iframe to load
await page.waitForSelector('iframe[src*="recaptcha"]', { timeout: 10000 });
// Get the sitekey from the main page
const sitekey = await page.evaluate(() => {
// From data-sitekey attribute
const el = document.querySelector("[data-sitekey]");
if (el) return el.getAttribute("data-sitekey");
// From iframe src
const iframe = document.querySelector('iframe[src*="recaptcha"]');
if (iframe) {
const match = iframe.src.match(/k=([A-Za-z0-9_-]{40})/);
if (match) return match[1];
}
return null;
});
if (!sitekey) throw new Error("Sitekey not found");
// Solve via CaptchaAI
const token = await solveCaptcha("userrecaptcha", {
googlekey: sitekey,
pageurl: page.url(),
});
// Inject token into the main page (not the iframe)
await page.evaluate((t) => {
document.getElementById("g-recaptcha-response").value = t;
document.getElementById("g-recaptcha-response").style.display = "block";
// Trigger the callback
if (typeof ___grecaptcha_cfg !== "undefined") {
const clients = ___grecaptcha_cfg.clients;
for (const key in clients) {
const client = clients[key];
for (const prop in client) {
try {
if (client[prop] && typeof client[prop].callback === "function") {
client[prop].callback(t);
}
} catch {}
}
}
}
}, token);
return token;
}
Der zweite Teil des Beispiels wird am häufigsten übersehen: Viele Formulare werden erst aktiv, wenn die von grecaptcha registrierte Callback-Funktion gelaufen ist. Ein korrekt eingetragenes Token allein reicht dann nicht – mehr dazu im Leitfaden zu Fehlermustern bei CaptchaAI-Callbacks.
CAPTCHA-Typ über Request Interception erkennen
Wer hunderte unterschiedliche Seiten abarbeitet, will den Typ nicht fest verdrahten. Puppeteer kann den Netzwerkverkehr mitlesen: reCAPTCHA meldet sich über recaptcha/api2, Turnstile über challenges.cloudflare.com/turnstile, GeeTest v3 liefert gt und challenge in einer JSON-Antwort.
async function interceptAndSolve(page, url) {
const captchaData = {};
// Intercept requests to detect CAPTCHA type
await page.setRequestInterception(true);
page.on("request", (request) => {
const requestUrl = request.url();
if (requestUrl.includes("recaptcha/api2")) {
captchaData.type = "recaptcha";
const match = requestUrl.match(/k=([A-Za-z0-9_-]{40})/);
if (match) captchaData.sitekey = match[1];
}
if (requestUrl.includes("challenges.cloudflare.com/turnstile")) {
captchaData.type = "turnstile";
}
if (requestUrl.includes("geetest") || requestUrl.includes("gt=")) {
captchaData.type = "geetest";
}
request.continue();
});
// Intercept responses for CAPTCHA parameters
page.on("response", async (response) => {
if (response.url().includes("geetest") || response.url().includes("register")) {
try {
const data = await response.json();
if (data.gt && data.challenge) {
captchaData.gt = data.gt;
captchaData.challenge = data.challenge;
}
} catch {}
}
});
await page.goto(url, { waitUntil: "networkidle2" });
// Now solve based on detected type
if (captchaData.type === "recaptcha" && captchaData.sitekey) {
return await solveCaptcha("userrecaptcha", {
googlekey: captchaData.sitekey,
pageurl: url,
});
}
if (captchaData.type === "turnstile") {
const sitekey = await page.evaluate(() => {
const el = document.querySelector("[data-sitekey]");
return el ? el.getAttribute("data-sitekey") : null;
});
if (sitekey) {
return await solveCaptcha("turnstile", { sitekey, pageurl: url });
}
}
if (captchaData.type === "geetest" && captchaData.gt) {
return await solveCaptcha("geetest", {
gt: captchaData.gt,
challenge: captchaData.challenge,
pageurl: url,
});
}
return null;
}
Nach setRequestInterception(true) muss jede Anfrage mit request.continue() weitergereicht werden – fehlt der Aufruf in einem Zweig, bleibt der Seitenaufbau hängen. Beim Erkennungsbaum lohnt der Blick auf die Abdeckung: CaptchaAI löst reCAPTCHA v2 und v3 (inklusive Enterprise), Cloudflare Turnstile und Challenge, GeeTest v3, BLS sowie Bild- und Rasterbild-CAPTCHAs; CaptchaFox, Friendly Captcha und Lemin sind in der Beta. hCaptcha und FunCaptcha werden nicht unterstützt, GeeTest v4 ist als bald verfügbar angekündigt.
Mehrstufige Formulare: die Abfrage kommt zuletzt
Registrierungs- und Antragsstrecken verteilen ihre Felder über drei bis fünf Seiten und zeigen die CAPTCHA-Abfrage erst kurz vor dem Absenden. Pro Schritt bleibt der Ablauf identisch: Felder füllen, Auswahllisten setzen, auf ein Widget prüfen, gegebenenfalls lösen, weiterklicken.
async function multiPageFormFlow(browser, startUrl, formSteps) {
const page = await browser.newPage();
for (let i = 0; i < formSteps.length; i++) {
const step = formSteps[i];
console.log(`Step ${i + 1}: ${step.description}`);
if (i === 0) {
await page.goto(startUrl, { waitUntil: "networkidle2" });
}
// Fill form fields
for (const [selector, value] of Object.entries(step.fields || {})) {
await page.waitForSelector(selector, { visible: true });
await page.click(selector, { clickCount: 3 }); // Select all
await page.type(selector, value, { delay: 50 }); // Human-like typing
}
// Handle dropdowns
for (const [selector, value] of Object.entries(step.selects || {})) {
await page.select(selector, value);
}
// Check for CAPTCHA
const hasCaptcha = await page.evaluate(() => {
return !!(
document.querySelector("[data-sitekey]") ||
document.querySelector(".cf-turnstile") ||
document.querySelector(".geetest_holder")
);
});
if (hasCaptcha) {
console.log("CAPTCHA detected, solving...");
const token = await interceptAndSolve(page, page.url());
if (token) {
await page.evaluate((t) => {
const el = document.getElementById("g-recaptcha-response");
if (el) el.value = t;
const cf = document.querySelector('[name="cf-turnstile-response"]');
if (cf) cf.value = t;
}, token);
}
}
// Click next/submit
if (step.submit) {
await page.click(step.submit);
await page.waitForNavigation({ waitUntil: "networkidle2" });
}
}
return page;
}
// Usage
const browser = await createBrowser();
const page = await multiPageFormFlow(browser, "https://example.com/register", [
{
description: "Personal info",
fields: { "#name": "John Doe", "#email": "[email protected]" },
submit: "#next-btn",
},
{
description: "Address",
fields: { "#address": "123 Main St", "#city": "New York" },
selects: { "#state": "NY" },
submit: "#next-btn",
},
{
description: "Verification (has CAPTCHA)",
fields: {},
submit: "#submit-btn",
},
]);
Die Tippverzögerung von 50 ms ist dabei kein Selbstzweck: Sie löst Feldvalidierungen und onChange-Handler aus, die bei sofort gesetzten Werten stumm bleiben. Und jeder Klick auf „Weiter“ braucht ein waitForNavigation, sonst füllt der nächste Durchlauf Felder einer Seite, die gerade ausgetauscht wird.
Parallel lösen – und das Thread-Kontingent kalkulieren
Parallelität hat zwei Grenzen: den Arbeitsspeicher Ihres Runners und Ihr Thread-Kontingent. Abgerechnet wird pro gleichzeitigem Thread, nicht pro Lösung: BASIC (15 $/Monat) enthält 5 Threads, STANDARD (30 $/Monat) 15 und ADVANCE (90 $/Monat) 50 – jeweils mit unbegrenzt vielen Lösungen pro Thread im Abrechnungsmonat. Preise in US-Dollar.
async function parallelSolve(urls, maxConcurrent = 3) {
const browser = await createBrowser();
const results = [];
let running = 0;
const processUrl = async (url) => {
const page = await browser.newPage();
try {
await page.goto(url, { waitUntil: "networkidle2" });
// Detect and extract sitekey
const sitekey = await page.evaluate(() => {
const el = document.querySelector("[data-sitekey]");
return el ? el.getAttribute("data-sitekey") : null;
});
if (sitekey) {
const token = await solveCaptcha("userrecaptcha", {
googlekey: sitekey,
pageurl: url,
});
results.push({ url, status: "solved", token });
} else {
results.push({ url, status: "no-captcha" });
}
} catch (error) {
results.push({ url, status: "error", error: error.message });
} finally {
await page.close();
}
};
// Process with concurrency limit
const queue = [...urls];
const workers = [];
for (let i = 0; i < Math.min(maxConcurrent, urls.length); i++) {
workers.push(
(async () => {
while (queue.length > 0) {
const url = queue.shift();
if (url) await processUrl(url);
}
})()
);
}
await Promise.all(workers);
await browser.close();
return results;
}
Setzen Sie maxConcurrent nie höher als Ihre Threadzahl: Zusätzliche Seiten warten ohnehin und binden nur Arbeitsspeicher. Startwert: drei bis fünf gleichzeitige Seiten pro 8 GB RAM.
Bild-CAPTCHAs per Element-Screenshot lösen
Klassische Bild-CAPTCHAs haben weder Sitekey noch Iframe – hier ist das Bild selbst die Aufgabe. Puppeteer erzeugt einen Screenshot eines einzelnen Elements als Base64 und übermittelt ihn direkt:
async function solveScreenshotCaptcha(page, captchaSelector) {
const element = await page.$(captchaSelector);
if (!element) throw new Error("CAPTCHA element not found");
// Take a screenshot of just the CAPTCHA element
const screenshot = await element.screenshot({ encoding: "base64" });
// Solve via CaptchaAI
const answer = await solveCaptcha("base64", { body: screenshot });
// Type the answer
const input = await page.$(
'input[name="captcha"], input[name="code"], input.captcha-input'
);
if (input) {
await input.click({ clickCount: 3 });
await input.type(answer, { delay: 30 });
}
return answer;
}
Warten Sie, bis das Bild geladen ist, und achten Sie auf den Ausschnitt: Ränder oder ein halb sichtbarer Aktualisieren-Button senken die Trefferquote spürbar.
Praxisbeispiel: nächtlicher Smoke-Test in der GitLab-CI
Ein typischer Aufbau im DACH-Raum: Ein Team betreibt eine Registrierungsstrecke unter https://staging.example-app.test, GitLab CI startet jede Nacht einen Smoke-Test, ausgeführt auf einem kleinen Hetzner-Server. Der Test legt ein Testkonto an; im letzten Schritt steht eine Turnstile-Abfrage.
Der Ablauf besteht aus den Bausteinen oben: multiPageFormFlow füllt die drei Schritte, der Typ wird über die abgefangenen Anfragen erkannt, das gelöste Token landet im Feld cf-turnstile-response, danach prüft der Test die Bestätigungsseite. Drei parallele Läufe brauchen drei Threads – STANDARD deckt das ab.
Zwei Punkte gehören in jedes solche Setup: Testen Sie nur Umgebungen, die Ihnen gehören oder für die eine Freigabe vorliegt. Und wo eine Automatisierung Formular- oder Trefferdaten speichert, prüfen Sie vorab Ihre Rechtsgrundlage nach DSGVO – IP-Adressen und Formulareingaben gelten regelmäßig als personenbezogene Daten.
Fehlerbehebung
| Symptom | Ursache | Lösung |
|---|---|---|
| Seitenaufbau bleibt stehen | request.continue() fehlt in einem Zweig |
jede Anfrage weiterreichen |
| Token eingetragen, Formular abgelehnt | Callback nie ausgelöst | Callback wie im Iframe-Beispiel aufrufen |
| Falsches Feld befüllt | mehrere Widgets auf der Seite | g-recaptcha-response über den Index wählen |
page.goto hängt |
Seite lädt endlos nach | timeout setzen, auf einen Selektor warten |
| Parallele Seiten stürzen ab | Arbeitsspeicher erschöpft | Parallelität senken, --disable-dev-shm-usage |
| Abfragen häufiger als im manuellen Test | hastiger Ablauf | Wartezeiten realistisch wählen |
Häufige Fragen
Wie viele Threads brauche ich für 20 parallele Seiten?
Mindestens 20 – ein Thread entspricht einer gleichzeitig laufenden Lösung. BASIC (15 $/Monat) deckt 5 Threads ab, STANDARD (30 $/Monat) 15 und ADVANCE (90 $/Monat) 50. Sobald eine Aufgabe fertig ist, nimmt derselbe Thread die nächste an.
Löst CaptchaAI auch hCaptcha oder FunCaptcha?
Nein. Unterstützt sind reCAPTCHA v2 und v3 samt Enterprise, Cloudflare Turnstile und Challenge, GeeTest v3, BLS sowie Bild- und Rasterbild-CAPTCHAs; dazu CaptchaFox (Beta), Friendly Captcha (Beta) und Lemin (Beta). hCaptcha und FunCaptcha (Arkose Labs) stehen nicht zur Verfügung, GeeTest v4 ist als „bald verfügbar“ angekündigt.
Wie lange bleibt ein gelöstes Token gültig?
Bei reCAPTCHA rund zwei Minuten. Lösen Sie deshalb erst, wenn das Formular vollständig ausgefüllt ist, und senden Sie es unmittelbar danach ab. Token auf Vorrat zu halten bringt nichts – sie laufen vorher ab.
Was tun bei ERROR_CAPTCHA_UNSOLVABLE?
Abbrechen und die Aufgabe neu übermitteln, statt weiter abzufragen. Meist passen Sitekey und pageurl nicht zur geladenen Seite: Nach einer Weiterleitung übergeben Sie page.url().
Läuft der Ablauf auch in Docker und in einer CI-Pipeline?
Ja. Nutzen Sie ein Image mit den Chromium-Systembibliotheken, starten Sie mit --no-sandbox, geben Sie dem Container genug /dev/shm – und hinterlegen Sie den Schlüssel als CI-Variable.
Fazit
Fünf Muster tragen die meisten Puppeteer-Projekte: Anfragen abfangen statt den Typ zu raten, Sitekey aus Hauptseite oder Iframe-URL lesen, das Token über CaptchaAI lösen und eintragen, mehrstufige Strecken Schritt für Schritt durchlaufen, Parallelität am Thread-Kontingent ausrichten. Messen Sie zuerst Lösungszeit und Erfolgsquote, dann skalieren Sie.