Solve hCaptcha in Scrapling.
Scrapling fetches the page; the NoneCap hCaptcha solver returns the
token. Read the data-sitekey in a page_action, get a real token
with the NoneCap Python SDK, write it into the form's h-captcha-response
field, then submit. The same flow works without a browser through Scrapling's
Fetcher.
The four steps
The flow is the same on every page that gates a form with hCaptcha:
- 1. Read the
data-sitekeyand the pageurl. - 2. Solve with
nc.solve(), which submits the job and long-polls until the token is ready. - 3. Inject the token into
textarea[name="h-captcha-response"](in a browser) or the form body (without one), and fire the widget callback if there is one. - 4. Submit the form the way the page does. Tokens are single-use and expire in about two minutes, so get one right before you submit.
Install
Scrapling's browser fetchers live in its fetchers extra, and
scrapling install downloads the browser they drive. The official
nonecap SDK is fully typed,
its only dependency is httpx, and it runs on Python 3.9 or newer:
pip install "scrapling[fetchers]" nonecap
scrapling install Full example: StealthyFetcher with page_action
page_action takes a function that receives the live browser page after
navigation. That is where the four steps run. The function also sends the browser's own
user agent with the solve, which some sites need before they accept the token.
import os
from nonecap import NoneCap
from scrapling.fetchers import StealthyFetcher
nc = NoneCap(api_key=os.environ["NONECAP_KEY"])
# Writes the token into the hCaptcha response fields. hCaptcha's drop-in mode
# reuses reCAPTCHA's old g-recaptcha-response field name, so it is set too when
# present; the value is still the hCaptcha token. Invisible widgets submit
# from data-callback, so the callback is fired if the page has one.
INJECT_JS = """(token) => {
for (const name of ["h-captcha-response", "g-recaptcha-response"]) {
const el = document.querySelector('textarea[name="' + name + '"]');
if (el) el.value = token;
}
const cb = document.querySelector("[data-callback]")
?.getAttribute("data-callback");
if (cb && typeof window[cb] === "function") window[cb](token);
}"""
result = {}
def solve_hcaptcha(page):
try:
widget = page.locator("[data-sitekey]")
if widget.count() == 0:
return # no hCaptcha on this page
# 1. Read the sitekey off the rendered widget.
sitekey = widget.first.get_attribute("data-sitekey")
# 2. Get a real token. nc.solve() submits the job and long-polls
# until it reaches a terminal state.
solve = nc.solve(
type="hcaptcha",
sitekey=sitekey,
url=page.url,
user_agent=page.evaluate("navigator.userAgent"),
)
# 3. Inject it into the page.
page.evaluate(INJECT_JS, solve.token)
# 4. Submit and wait for the next page to load.
with page.expect_navigation():
page.click("button[type=submit]")
result["solve"] = solve
except Exception as exc:
result["error"] = exc
response = StealthyFetcher.fetch(
"https://target.example/login",
page_action=solve_hcaptcha,
network_idle=True,
)
if "error" in result:
raise result["error"]
# response is the page after the form went through.
print(response.status, response.url)
print(response.css("h1::text").get())
Scrapling logs an exception raised inside page_action and still returns the
page, so the example keeps the exception and raises it after fetch(). When a
solve fails, nc.solve() raises SolveFailedError with the full
solve object on .solve, and no credits are charged. The other terminal cases
get their own types too: InsufficientCreditsError,
RateLimitError, and SolveTimeoutError, so you can catch exactly
what you want to retry.
Many pages in one browser: StealthySession
StealthyFetcher.fetch() opens and closes a browser per call. For a crawl,
keep one open with StealthySession and pass the same
page_action to each fetch():
from scrapling.fetchers import StealthySession
# One browser for the whole run; solve_hcaptcha only acts on pages
# that render a widget.
with StealthySession(network_idle=True) as session:
for url in ["https://target.example/login", "https://target.example/signup"]:
result.clear()
response = session.fetch(url, page_action=solve_hcaptcha)
if "error" in result:
raise result["error"]
print(url, response.status) Without a browser: FetcherSession
When the form is a plain HTML POST, you don't need a browser at all. Scrapling's
FetcherSession keeps cookies between the GET and the POST, its selectors read
the sitekey and the form's hidden fields, and the token goes into the form body:
import os
from nonecap import NoneCap
from scrapling.fetchers import FetcherSession
nc = NoneCap(api_key=os.environ["NONECAP_KEY"])
with FetcherSession(impersonate="chrome") as session:
page = session.get("https://target.example/signup")
# 1. Read the sitekey, plus the form's own fields (CSRF token and so on).
sitekey = page.css("[data-sitekey]::attr(data-sitekey)").get()
fields = {
field.attrib["name"]: field.attrib.get("value", "")
for field in page.css("form input[name]")
}
fields["email"] = "you@example.com"
# 2. Get a real token.
solve = nc.solve(type="hcaptcha", sitekey=sitekey, url=page.url)
# 3. The token goes in the form body under both field names.
fields["h-captcha-response"] = solve.token
fields["g-recaptcha-response"] = solve.token
# 4. Submit with the solve's user agent: some sites refuse a token
# sent from a different browser version.
action = page.urljoin(page.css("form::attr(action)").get(""))
headers = {"User-Agent": solve.user_agent} if solve.user_agent else {}
submitted = session.post(action, data=fields, headers=headers)
print(submitted.status, submitted.css("h1::text").get()) Reading the sitekey when it isn't a DOM attribute
The examples assume the widget renders a [data-sitekey] element, which is
the common case. Some pages pass the key straight to hcaptcha.render()
instead. On any Scrapling response, fall back to the sitekey query param on
the hcaptcha.com/1/api.js script tag:
from urllib.parse import parse_qs, urlparse
# Some pages never put data-sitekey in the DOM and pass the key straight
# to hcaptcha.render(). Read it off the api.js script tag instead:
sitekey = page.css("[data-sitekey]::attr(data-sitekey)").get()
if sitekey is None:
src = page.css("script[src*='hcaptcha.com/1/api.js']::attr(src)").get()
sitekey = parse_qs(urlparse(src).query)["sitekey"][0] Don't hard-code the sitekey: sites rotate them, so read it from the live page each time. The sitekey explainer covers the other places it can hide.
Enterprise rqdata
Enterprise sitekeys bind each challenge to a fresh rqdata blob. Capture the
rqdata the page passes into hcaptcha.render() /
execute() and forward it with the enterprise type; the injection and submit
steps stay the same:
# Enterprise sitekeys also need the per-challenge rqdata blob. Capture
# the value the page passes into hcaptcha.render() / execute(), then
# forward it with the enterprise type:
solve = nc.solve(
type="hcaptcha_enterprise",
sitekey=sitekey,
url=page.url,
rqdata=rqdata, # the fresh, IP-bound challenge blob
) Async: AsyncStealthySession
For concurrent pages, pair Scrapling's AsyncStealthySession with the SDK's
AsyncNoneCap. The page_action becomes a coroutine, and
max_pages sets how many tabs the session runs at once. Your NoneCap plan's
concurrency cap still applies to the solves.
import asyncio
import os
from nonecap import AsyncNoneCap
from scrapling.fetchers import AsyncStealthySession
nc = AsyncNoneCap(api_key=os.environ["NONECAP_KEY"])
errors = []
async def solve_hcaptcha(page):
try:
widget = page.locator("[data-sitekey]")
if await widget.count() == 0:
return
sitekey = await widget.first.get_attribute("data-sitekey")
solve = await nc.solve(
type="hcaptcha",
sitekey=sitekey,
url=page.url,
user_agent=await page.evaluate("navigator.userAgent"),
)
await page.evaluate(INJECT_JS, solve.token) # INJECT_JS from the full example
async with page.expect_navigation():
await page.click("button[type=submit]")
except Exception as exc:
errors.append(exc) # Scrapling only logs it, as in the sync example
async def main(urls):
async with AsyncStealthySession(network_idle=True, max_pages=5) as session:
pages = await asyncio.gather(
*(session.fetch(url, page_action=solve_hcaptcha) for url in urls)
)
if errors:
raise errors[0]
return pages Scrapling's MCP server and AI agents
If an AI agent drives Scrapling through its MCP server, give the agent NoneCap as one
more tool. When the agent lands on an hCaptcha gate, it reads the sitekey from the page,
calls the tool, writes the returned token into h-captcha-response and
submits, the same four steps as above. The
AI agents guide has a ready tool schema and a Python
handler for it.
When this isn't the right approach
NoneCap solves hCaptcha only: regular, invisible, and enterprise
rqdata. If the page is protected by reCAPTCHA or Cloudflare Turnstile, this technique doesn't apply; those are on the roadmap, not live. And if the page has no captcha at all, you don't need a solver: let Scrapling fetch it and skip the token step.
Using Scrapy, Playwright or Selenium elsewhere in the same project? The flow is the same. See the Scrapy middleware, Playwright guide or Selenium guide. Request and response shapes for every call are in the API reference.
Last updated November 2026.
Frequently asked
Does Scrapling solve hCaptcha on its own?
solve_cloudflare option is for Cloudflare's Turnstile and interstitial challenges, not hCaptcha. Scrapling's browser gets you a page that loads like a real visitor's, but an hCaptcha-gated form still wants a valid token in h-captcha-response before it goes through. NoneCap returns that token; your Scrapling code writes it into the page and submits.Do I need StealthyFetcher, or is Fetcher enough?
FetcherSession is enough when the form is a normal HTML POST: read the sitekey with page.css(), put the token in the form body as h-captcha-response, and post it. Use StealthyFetcher or StealthySession when the page builds the form with JavaScript, submits through an invisible widget's callback, or needs a real browser for the rest of the job.Why did my page_action fail without an error?
page_action and still returns the page. That is why the example stores the exception and raises it after fetch() returns: a SolveFailedError or InsufficientCreditsError from the NoneCap SDK then reaches your code instead of only the log.