How to Solve Friendly Captcha in Python From the Script Tag

To solve Friendly Captcha in Python, read the data-sitekey off the frc-captcha element, find the script tag that loads the widget, tell v1 from v2 by that script, and pass the sitekey, the page URL, the version and the script’s full address to CapSkip’s friendly_captcha method. You get back a token to post in frc-captcha-solution on v1 or frc-captcha-response on v2. The script tag is the part that matters. Friendly Captcha ships two unrelated protocols under one name and one sitekey format, and solving the wrong one returns a token that looks valid and is quietly rejected. CapSkip added Friendly Captcha in version 1.4.0. This guide covers reading the page, the call, the submit, and running many solves at once with AsyncCapSkip.
What you need
- CapSkip 1.4.0 or later running on a Windows machine. That release added Friendly Captcha, alongside CaptchaFox and Capy Puzzle.
- Python 3.10 or later and version 1.3.0 or later of the capskip package, the first release with friendly_captcha. The samples also use requests to fetch the page and post the form.
- The URL of the page that shows the widget. The sitekey, the script address and the field name all come out of that page’s HTML, and Step 1 shows where.
- An address for the solver. Local mode answers on 127.0.0.1 for that device only; Server mode listens on your network address or public IP so a script on another box can call it over the API. Both are under connection settings, and Step 4 covers when to switch.
# pip install capskip requests pip install "capskip>=1.3.0" requests
Keep the quotes. In cmd an unquoted greater-than sign is a redirect: pip installs whatever capskip it finds, with no version check, and writes its output to a file named 1.3.0. PowerShell passes the argument through, but the quotes work in every shell.
Step 1: read the widget and its script tag
Both versions render the same element, a div with the class frc-captcha and a data-sitekey attribute. So the element gives you the sitekey and nothing about the protocol. The version is in the script tag that loads the widget, because v1 and v2 are different packages with different file names:
<!-- v2: the @friendlycaptcha/sdk package --> <div class="frc-captcha" data-sitekey="YOUR_SITEKEY"></div> <script type="module" src="https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk/site.min.js" async defer></script> <script nomodule src="https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk/site.compat.min.js" async defer></script> <!-- v1: the friendly-challenge package --> <div class="frc-captcha" data-sitekey="YOUR_SITEKEY"></div> <script type="module" src="https://cdn.jsdelivr.net/npm/friendly-challenge/widget.module.min.js" async defer></script> <script nomodule src="https://cdn.jsdelivr.net/npm/friendly-challenge/widget.min.js" async defer></script>
Most sites pin a version number inside that path, which changes nothing here. Python’s standard library is enough to read all of it. This parser keeps the widget element’s attributes and the address of every script that is not a nomodule fallback, resolved against the page URL:
# pip install capskip requests
from html.parser import HTMLParser
from urllib.parse import urljoin
import requests
class FriendlyPage(HTMLParser):
"""Collects the frc-captcha element and the page's script tags."""
def __init__(self, page_url):
super().__init__()
self.page_url = page_url
self.widget = {}
self.scripts = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if "frc-captcha" in (a.get("class") or "").split():
self.widget = a
# Skip nomodule fallbacks; keep full addresses, never relative.
if tag == "script" and a.get("src") and "nomodule" not in a:
self.scripts.append(urljoin(self.page_url, a["src"]))
session = requests.Session()
page = FriendlyPage("https://example.com/signup")
page.feed(session.get(page.page_url, timeout=30).text)The urljoin call earns its place. A self-hosted widget can have a relative src, such as /js/site.min.js, and CapSkip needs an address it can fetch: on v2 it loads that same script in its own browser to run the solve. Pass the bare path and the widget never loads.
Step 2: pick the version and call friendly_captcha
CapSkip decides the version in a fixed order and stops at the first answer: the version you pass, then the script address you pass as module_script, then a default of v1. That last step is the trap. A script CapSkip cannot read a version from, such as a bundled /assets/app.4f2a.js, is solved as v1 without a word, and a v2 site then rejects every token.
So decide the version in your own code, where you can refuse to guess. The package name in a CDN address is unambiguous. A self-hosted build often drops it but keeps the file name, as the official WordPress plugin does: site.min.js is v2, and widget.module.min.js or widget.min.js is v1. The widget’s own attributes are the last resort, because the two versions name their options differently:
from urllib.parse import urlparse
V2_FILES = {"site.min.js", "site.compat.min.js"}
V1_FILES = {"widget.module.min.js", "widget.min.js", "widget.polyfilled.min.js"}
def friendly_version(page):
# Package names are unambiguous, so check them first.
for src in page.scripts:
if "@friendlycaptcha/sdk" in src:
return "v2", src
if "friendly-challenge" in src:
return "v1", src
# Self-hosted builds usually keep the file name. Themes ship their own
# site.min.js too, so only trust a path that says friendly.
for src in [s for s in page.scripts if "friendly" in s.lower()]:
name = urlparse(src).path.rsplit("/", 1)[-1]
if name in V2_FILES:
return "v2", src
if name in V1_FILES:
return "v1", src
# Last resort: v2 and v1 name their widget options differently.
if {"data-api-endpoint", "data-form-field-name"} & page.widget.keys():
return "v2", None
if {"data-puzzle-endpoint", "data-solution-field-name"} & page.widget.keys():
return "v1", None
raise RuntimeError("v1 or v2? Read the page and set it by hand.")The file-name check only trusts paths that mention friendly, because a theme can load a site.min.js of its own that has nothing to do with the widget.
Then make the call. Send the version you found, plus the script address when you have one. The explicit version wins, and on v2 the address tells CapSkip to solve with the exact build the site loads. A None value is simply left out of the request.
from capskip import CapSkip
solver = CapSkip(host="127.0.0.1", port=8080)
version, script = friendly_version(page)
result = solver.friendly_captcha(
page.widget["data-sitekey"],
"https://example.com/signup",
version=version,
module_script=script,
# data-api-endpoint="eu" (v2) or data-puzzle-endpoint (v1).
api_server=page.widget.get("data-api-endpoint")
or page.widget.get("data-puzzle-endpoint"),
)
print(result["token"][:40]) # v2 tokens start with AQQA.The api_server line handles sites on Friendly Captcha’s EU endpoint. An EU site’s sitekey still gets a token from the global endpoint, so solving it there fails only at the site’s own verification, the same silent failure as the version. When neither attribute is present the value is None and CapSkip uses the global endpoint.
The result is a plain dict. result["token"] is the string to submit, result["code"] holds the same string for scripts ported from another solver, and result["captchaId"] is CapSkip’s reference for the job. A version other than v1, v2, 1 or 2, an empty sitekey, or an option the method does not take raises ValidationException before anything is sent.
Step 3: post the token in the field the widget uses
The field name is the second thing that differs between the versions, and a site can rename it on the widget element:
import requests
# A renamed field wins; otherwise the default for the version.
field = (
page.widget.get("data-form-field-name") # v2 rename
or page.widget.get("data-solution-field-name") # v1 rename
or ("frc-captcha-response" if version == "v2" else "frc-captcha-solution")
)
# session is the requests.Session that fetched the page.
resp = session.post(
"https://example.com/signup",
data={"email": "YOUR_EMAIL", field: result["token"]},
# Browsers send the page as Referer; some servers refuse a post without it.
headers={"Referer": page.page_url},
timeout=30,
)
print(resp.status_code)One v1 special case: a data-solution-field-name set to a single hyphen means the widget writes no hidden field at all, and the site’s own script sends the token some other way. Mirror that request, as described below.
Post to wherever the form’s action attribute points, and include the form’s other fields, hidden ones such as a CSRF token too. Fetching the page and posting the form through one requests.Session keeps the site’s cookies, which that CSRF token usually depends on. Send the page URL as Referer too: requests sends no Referer or Origin header, and some frameworks, Django among them, refuse an HTTPS form post that has neither, however right the cookie and token are.
Send the token in the body with data=, never in the URL with params=. A v2 token is roughly six kilobytes, enough to trip a server’s limit on URL length, while a v1 token is four dot-separated parts and a few hundred characters. Pass it through verbatim, with no stripping or re-encoding. It is good for one submit: Friendly Captcha’s verification rejects a response that has already been used or has expired, so solve again for every form.
Some sites send the form with JavaScript and a JSON body rather than a form post. When the submit fails with a correct field name, open DevTools, submit once by hand, and copy exactly what the page sends.
Step 4: many forms at once, and where the solver runs
AsyncCapSkip in Python is a genuine asynchronous client built on httpx, not an alias of the blocking one, so one event loop can keep many solves in flight. Cap them with a semaphore so you never have more running than CapSkip will work on at once. The Max. Threads setting in CapSkip’s Friendly Captcha section defaults to 10.
import asyncio
from capskip import AsyncCapSkip
solver = AsyncCapSkip(host="127.0.0.1", port=8080)
limit = asyncio.Semaphore(10) # match Friendly Captcha Max. Threads
async def solve(sitekey, url, version, script):
async with limit:
r = await solver.friendly_captcha(
sitekey, url, version=version, module_script=script)
return r["token"]
async def main(jobs):
# jobs: (sitekey, url, version, script) tuples from Step 2
return await asyncio.gather(*(solve(*j) for j in jobs))Expect solve times to vary. Friendly Captcha sets the amount of work per request and raises it for addresses it has already seen a lot of, and CapSkip solves every v2 widget in a real browser. That is why the method polls on recaptchaTimeout, 300 seconds by default, rather than the 120 second defaultTimeout. CapSkip keeps limits of its own as well. In its Friendly Captcha settings, a task may wait 250 seconds for a free thread and then spend 120 seconds solving before CapSkip fails it. So a slow solve needs a higher Row Timeout, and the SDK’s own timeout, 300 seconds by default or a per-call timeout=, has to cover that plus any time the task waits for a thread. The semaphore above keeps that wait near zero. If solves slow down over a long run, rising difficulty on one address is the usual cause. Pass a per-request proxy as a dict with type and uri keys, or configure a proxy pool in CapSkip so every solve does not come from the same address.
The samples use 127.0.0.1 because that is right when your script and the solver share a machine. Once the script runs anywhere else, such as a VPS, a container or a CI runner, loopback points at the wrong box and the first call raises NetworkException. Switch CapSkip to Server mode and it listens on your network address or public IP, so any of those can reach it over the same API. Use a static public IP if the route crosses the internet, with a firewall rule for the addresses you expect. It is still your own hardware and still unmetered. The client does not read environment variables by itself, so read CAPSKIP_HOST in your code and pass it in, as the full example below does.
Full working example
# pip install capskip requests
import os
from html.parser import HTMLParser
from urllib.parse import urljoin, urlparse
import requests
from capskip import CapSkip
from capskip.exceptions import CapSkipError, ValidationException
PAGE_URL = "https://example.com/signup"
V2_FILES = {"site.min.js", "site.compat.min.js"}
V1_FILES = {"widget.module.min.js", "widget.min.js", "widget.polyfilled.min.js"}
class FriendlyPage(HTMLParser):
def __init__(self, page_url):
super().__init__()
self.page_url, self.widget, self.scripts = page_url, {}, []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if "frc-captcha" in (a.get("class") or "").split():
self.widget = a
if tag == "script" and a.get("src") and "nomodule" not in a:
self.scripts.append(urljoin(self.page_url, a["src"]))
def friendly_version(page):
for src in page.scripts:
if "@friendlycaptcha/sdk" in src:
return "v2", src
if "friendly-challenge" in src:
return "v1", src
for src in [s for s in page.scripts if "friendly" in s.lower()]:
name = urlparse(src).path.rsplit("/", 1)[-1]
if name in V2_FILES or name in V1_FILES:
return ("v2" if name in V2_FILES else "v1"), src
keys = page.widget.keys()
if {"data-api-endpoint", "data-form-field-name"} & keys:
return "v2", None
if {"data-puzzle-endpoint", "data-solution-field-name"} & keys:
return "v1", None
raise SystemExit("v1 or v2? Read the page and set it by hand.")
solver = CapSkip(
apiKey=os.getenv("CAPSKIP_API_KEY", "capskip"),
host=os.getenv("CAPSKIP_HOST", "127.0.0.1"),
port=int(os.getenv("CAPSKIP_PORT", "8080")),
)
session = requests.Session()
page = FriendlyPage(PAGE_URL)
page.feed(session.get(PAGE_URL, timeout=30).text)
if not page.widget.get("data-sitekey"):
raise SystemExit("No frc-captcha widget in the HTML; it may be built by JS.")
version, script = friendly_version(page)
try:
result = solver.friendly_captcha(
page.widget["data-sitekey"], PAGE_URL,
version=version, module_script=script,
api_server=page.widget.get("data-api-endpoint")
or page.widget.get("data-puzzle-endpoint"),
)
except ValidationException as exc:
raise SystemExit(f"not sent: {exc}")
except CapSkipError as exc:
raise SystemExit(f"solve failed: {exc!r}")
field = (page.widget.get("data-form-field-name")
or page.widget.get("data-solution-field-name")
or ("frc-captcha-response" if version == "v2" else "frc-captcha-solution"))
# Post wherever the form's action points, with its other fields.
# Browsers send the page as Referer; some servers refuse a post without it.
resp = session.post(PAGE_URL, data={"email": "YOUR_EMAIL", field: result["token"]},
headers={"Referer": PAGE_URL}, timeout=30)
print(resp.status_code, version, field)The version is decided once and used twice, for the solve and for the field name, so the two can never disagree. When the parser finds no widget, the page usually builds it from JavaScript, and you need the sitekey from the rendered page or from the script that creates it. Every parameter the raw endpoint accepts is in the Friendly Captcha API reference.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| The site rejects a token that CapSkip returned without error | The wrong version was solved, often because v1 was the silent default | Decide the version in code as in Step 2 and pass it |
| Rejected on a site whose widget has data-api-endpoint (v2) or data-puzzle-endpoint (v1) | The token came from the global endpoint and the site uses the EU one | Pass the attribute’s value as api_server |
| The token is right but the submit still fails | It went into the wrong field, the post had no Referer, or the site posts JSON | Check the rename attributes and send the page URL as Referer, then mirror the request in DevTools |
| A v2 solve fails on a site that self-hosts the widget | module_script was a relative path, so the widget never loaded | Resolve the src with urljoin before passing it |
| ApiException saying ERROR_CAPTCHA_UNSOLVABLE within seconds, every time | Friendly Captcha refuses the sitekey or the page’s origin, or the key’s account has no v2 or EU endpoint enabled | Check the sitekey, the page URL and api_server; retrying will not help |
| ValidationException before anything is sent | An empty sitekey, a version other than v1, v2, 1 or 2, or an unknown option | Check the parser found the widget, and drop the option the message names |
| Solves get slower as a long run goes on | Friendly Captcha raises the work for a busy address | Add per-request proxies or a proxy pool in CapSkip |
| ApiException saying ERROR_CAPTCHA_UNSOLVABLE after two minutes or more | A solve ran past CapSkip’s Row Timeout of 120 seconds, or waited past its Wait Timeout of 250 seconds for a free thread | Keep the semaphore at Max. Threads, add proxies, or raise Row Timeout in the Friendly Captcha settings |
| TimeoutException after 300 seconds | A task waited for a free thread and then solved for longer than the SDK polls | Keep the semaphore at Max. Threads, or pass a longer timeout= |
| NetworkException on the first call | CapSkip is not running, or the host and port are wrong | Start CapSkip, then check Local mode against Server mode |
FAQ
Why not send only module_script and let CapSkip decide?
You can, and for a script loaded from a CDN it works, because the package name is in the address. The risk is the fallback. When the address names no build CapSkip recognises, it solves v1 rather than failing, and nothing in the result tells you. Checking in your own code turns that silent default into an exception you can see.
Does the solve need a browser on my side, such as Selenium or Playwright?
No. Your script only needs the HTML, which requests fetches. Any browser work happens inside CapSkip, which solves v2 widgets in its own browser and v1 puzzles directly. If you already drive a browser for other reasons, the same call works; read the sitekey and script address from the live page instead.
Can a script on a VPS or in the cloud use a solver on my Windows PC?
Yes. Put CapSkip in Server mode under connection settings so it listens on a network address instead of loopback, set CAPSKIP_HOST where the script runs, and pass it to the client as the full example does. A VPS, a container and a hosted runner all connect over the same HTTP API. Use a static public IP with a firewall rule when the route crosses the internet. The solver stays on hardware you own, so running more solves never changes what you pay.
How is this different from the C# guide?
The raw endpoint, the options and the result fields are the same in every CapSkip SDK; only the spelling changes. The real difference is the code around them: the standard library parser, the requests session that carries the site’s cookies, and AsyncCapSkip, which is a separate asynchronous client in Python, while in .NET it is another name for CapSkipClient, which is asynchronous already. The .NET version of this workflow is in the C# Friendly Captcha guide, and more on running solves concurrently in Python is in the parallel solving guide.
The short version
To solve Friendly Captcha in Python, parse the page for the frc-captcha element and its script tags, and resolve every src to a full address. Decide v1 or v2 from the package name, or from the widget’s own attributes, and raise if neither says. Call friendly_captcha with the sitekey, the page URL, that version and the script address, plus api_server for EU sites. Post the token once, in the body, in the field the version and the widget name.
- How the type works and what the solver covers: the Friendly Captcha solver page.
- Every other CAPTCHA type the Python package solves: the Python CAPTCHA solver page.
One more thing about volume. Friendly Captcha charges its difficulty in CPU time, not money, so the cost of a busy day is solve time on your own machine. Run an unlimited captcha solver locally and the only limits are that machine and the threads you give it, not a meter.
