Scarnox · Blog

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

  1. Read the sitekey from the page.
  2. Ask Scarnox for a token with one blocking POST request.
  3. 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

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:

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.

Next steps