Solving a PopularCaptcha in Playwright and Puppeteer (Node.js)
Browser automation stalls the moment a PopularCaptcha appears. Clicking through an image grid from a script is slow and brittle. Getting a token from a solving API and putting it into the page takes one request, and the rest of your script carries on as normal.
This guide covers Playwright and Puppeteer with Node.js. The flow is the same in both, and the examples use proxyless solves, which Scarnox runs on its own network. PopularCaptcha is the type Scarnox solves today. Other captcha types are coming soon, so the code below is for PopularCaptcha only.
The flow
- Read the sitekey from the page.
- Ask Scarnox for a token with one blocking POST request.
- Put the token into the response field, or hand it to the site's callback, then submit the form.
Set up the key
Create an account and start with free trial credit, no card needed. Keep the key in an environment variable, never in the test code:
export SCARNOX_KEY="scarnox_YOURAPIKEY"
Node 18 and later include fetch, so the API call needs no extra packages.
Playwright
import { chromium } from "playwright";
const API_KEY = process.env.SCARNOX_KEY;
async function solve(sitekey, url) {
const r = await fetch("https://api.scarnox.com/api/tasks/create", {
method: "POST",
headers: { Authorization: `Bearer ${API_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ captcha_type: "PopularCaptchaTaskProxyless", site_key: sitekey, site_url: url }),
});
const data = await r.json();
if (data.errorId !== 0) throw new Error(`${data.errorCode}: ${data.errorDescription}`);
return data.solution.token;
}
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto("https://example.com/signup");
const sitekey = await page.getAttribute("[data-sitekey]", "data-sitekey");
const token = await solve(sitekey, page.url());
// Put the token into both response fields the site may read.
await page.evaluate((t) => {
for (const name of ["h-captcha-response", "g-recaptcha-response"]) {
document.querySelectorAll(`[name="${name}"]`).forEach((el) => { el.value = t; });
}
}, token);
await page.fill("#email", "you@example.com");
await page.click("button[type=submit]");
Notice that site_url comes from page.url(). That is the page the widget lives on, which is what the solver needs. The solve happens after the page has loaded, so the sitekey is already in the DOM.
Puppeteer
Puppeteer differs only in the selector API. Reuse the solve function above and replace the page calls:
const sitekey = await page.$eval("[data-sitekey]", (el) => el.dataset.sitekey);
const token = await solve(sitekey, page.url());
await page.evaluate((t) => {
document.querySelectorAll('[name="h-captcha-response"],[name="g-recaptcha-response"]')
.forEach((el) => { el.value = t; });
}, token);
When the site uses a callback
Some sites do not read the hidden field. They wait for the widget's JavaScript callback instead. Find the function named in the widget's data-callback attribute and call it with the token:
await page.evaluate((t) => {
const name = document.querySelector("[data-sitekey]")?.dataset.callback;
if (name && typeof window[name] === "function") window[name](t);
}, token);
If the site uses both, set the field and call the callback. Doing both is harmless.
Invisible widgets
An invisible widget has no checkbox. The site starts it from its own code, usually when the visitor presses submit. Send the same request with the optional is_invisible field set to true:
body: JSON.stringify({
captcha_type: "PopularCaptchaTaskProxyless",
site_key: sitekey,
site_url: url,
is_invisible: true,
}),
The invisible captcha guide explains how to spot these widgets and how to hand the token back.
Timing tips
- Fetch the token right before you submit. Tokens expire within about two minutes.
- Give the solve request a long timeout, about 185 seconds. It blocks until the token is ready.
- Failed solves are free, so a retry costs time only. Retry transient failures with backoff, as described in error handling and backoff.
- Running many browsers at once? Cap the number of concurrent solves so one slow batch does not stall the rest of the run.
- Close browsers in a
finallyblock. A crashed test that leaves Chromium running will slow every later solve on the same machine.
Testing your own flow
Run the example against a form you own before you point it at anything larger. Check three things: the token is accepted, the form submits, and the next page appears. When the flow works end to end, keep it green in CI with a single real solve per run.
Try it free
Create an account and start with free trial credit, no card needed. That is enough to wire up and check the whole flow. For what solving costs at volume, see current pricing.
Debugging a rejected token
When the site rejects a token, reproduce the flow with one request and one page load before you change anything else. Check these in order:
- The sitekey you sent matches the widget on the page you loaded.
site_urlis the page the widget lives on, not a redirect target.- The token went into the field the page reads, or the callback was called.
- The submit happened within about two minutes of the solve.
Save the page HTML at the moment of the failure with await page.content(). Comparing that HTML with a working run usually shows the difference at a glance: a different sitekey, a missing field, or a widget that rendered in a different mode.