How to Detect CAPTCHA Type on a Page and Solve It (Python)

detect captcha type - How to Detect CAPTCHA Type on a Page and Solve It (Python)

To detect CAPTCHA type on a page, read the page’s HTML for the widget’s script and container, pull the sitekey out of them, and map the family to the CapSkip method that solves it. A reCAPTCHA v2 box, a v3 script and a Turnstile widget need three different calls with different options, and a wrong guess wastes a whole solve and gives you a token you cannot use. This guide builds a detector of about 175 lines in Python that returns the type, the method and the arguments, or says plainly that it cannot tell. CapSkip runs on your own Windows PC with no per-solve fee, so a script that checks a hundred pages costs nothing extra.

What you need

  • CapSkip running on a Windows PC, with the solvers you need switched on. The setup guide covers the first start.
  • Python 3.10 or later. The code below ran on Python 3.14.0 and 3.11.7 with requests 2.33.0 and 2.34.2 and capskip 1.3.0.
  • A PowerShell prompt. The commands use PowerShell syntax.
  • A page that you own or may automate, with a CAPTCHA on it.
  • An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so a script on another PC, a VPS or a hosted platform can reach it over the API. Both are set under connection settings.

How this was tested: the detector ran against 66 saved pages and every page returned the expected result. Of those, 59 were built by hand (about 40 follow the vendors’ documentation, the rest are edge cases I made up) and 7 are snapshots of public pages taken on 29 October 2026. The solves ran through the real capskip package against a mock CapSkip that copies the app’s input checks. The real app was not running, so no number below is a solve time.

Why you detect CAPTCHA type before you solve

CapSkip has a different method for each family: recaptcha, turnstile, geetest, altcha, capy, captchafox, friendly_captcha and normal for images in the SDK, plus hcaptcha on the HTTP API, which the SDK does not wrap yet. Within reCAPTCHA the options matter too. Invisible, Enterprise and v3 are flags on the same call. Send the wrong ones and the best case is a failed job, the worst a token the site rejects with no hint why. I could not check which one the real app does for a wrong reCAPTCHA version. Either way the solve is spent.

Other mistakes fail fast and loudly. Reading the sitekey from a page that renders reCAPTCHA explicitly gives the word explicit, and CapSkip refuses it with ERROR_GOOGLEKEY. The ERROR_GOOGLEKEY and ERROR_PAGEURL guide covers that error from the other side. To detect CAPTCHA type reliably, a detector has to check the signals in a fixed order, and that order avoids both kinds of mistake. A detector is also the first thing to build before a scraper that meets more than one site. If you want the background on what each type is, what a CAPTCHA is explains the families without code.

Step 1: install and fetch the page

Create a virtual environment in an empty folder and install the two packages. The commands call the environment’s own Python by path, so you do not need to activate it.

py -m venv .venv
.\.venv\Scripts\python.exe -m pip install capskip requests

To detect CAPTCHA type from text, the detector needs only two inputs: the page’s HTML and its address. Fetch the page with a requests Session and keep that Session, because the image CAPTCHA step later downloads the picture with the same cookies. A plain requests fetch shows what the server sends before any JavaScript runs. Google’s reCAPTCHA demo pages, Cloudflare’s Turnstile demo and the hCaptcha demo all carry their widget in that HTML. Pages that add the widget with JavaScript do not, and the section “When the HTML says nothing” covers that case.

Step 2: write the helpers

Save everything from here to Step 9 in one file called detect.py, one block after another. The first block imports what is needed and writes four small readers. Each returns None when nothing matches, so a missing attribute never raises.

import base64, html as htmllib, re, time
from types import SimpleNamespace as NS
from urllib.parse import urljoin
import requests

SITEKEY_JS = r"sitekey['\"]?\s*:\s*['\"]([^'\"]+)"   # sitekey: '...' inside a render() call


def grab(pattern, text):  # first capture group that matched, or None
    m = re.search(pattern, text or "", re.I | re.S)
    return next(g for g in m.groups() if g is not None) if m else None

def tag(cls, h):  # opening tag with CSS class cls (and not g-recaptcha-response)
    pat = r'(<\w+[^>]*\sclass\s*=\s*["\'][^"\']*(?<![\w-])%s(?![\w-])[^"\']*["\'][^>]*>)'
    return grab(pat % cls, h)

def attr(t, name):  # attribute value from a tag string, either quote style, HTML-unescaped
    v = grab(r'\s%s\s*=\s*(?:"([^"]*)"|\'([^\']*)\')' % name, t)
    return None if v is None else htmllib.unescape(v)

def render_opts(call, js):  # first 400 characters of the options in call(el, {...})
    return grab(call + r"\([^,]+,\s*\{(.{0,400})", js)

The grab function returns the first capture group that matched. The tag function finds an opening tag by CSS class and refuses a class that only contains the name inside a longer one, so a class called cf-turnstile-response is not a Turnstile widget. The attr function reads an attribute written with either quote style and unescapes HTML entities, which matters for a challenge held in an attribute. Unquoted attributes are not read.

The second block turns a page into something the checks can share. It strips comments and style blocks, so a CSS rule that mentions a widget class cannot match, and it collects the script addresses and the inline JavaScript separately. The last two helpers build the two possible answers: a hit, or a needs_browser answer.

def parse(html, url):
    h = re.sub(r"<!--.*?-->|<style\b.*?</style>", "", html, flags=re.S | re.I)
    srcs = " ".join(re.findall(r'<script\b[^>]*?\ssrc\s*=\s*["\']([^"\']+)', h, re.I))
    js = "\n".join(re.findall(r"<script\b[^>]*>(.*?)</script>", h, re.S | re.I))
    return NS(h=h, srcs=htmllib.unescape(srcs), js=js, url=url)

def hit(kind, method, **args):
    return {"type": kind, "method": method, "args": args}

def browser(kind, method, p, why):  # the static HTML is not enough, so do not guess
    return {"type": kind, "method": method, "args": {"url": p.url}, "needs_browser": why}

Step 3: hCaptcha, and what CapSkip cannot solve

Start with the pages where the right answer is no. CapSkip does not solve FunCaptcha or Arkose, and GeeTest v4 is outside the GeeTest method, which is for v3. If the detector skipped those, a page with an Arkose box would fall through to the next check and might come back as something it is not. So this check runs first and returns a result with no method.

The same block handles the Cloudflare full-page check, the “Just a moment” interstitial. That page is not a Turnstile widget. The turnstile method needs two values from it, data and pagedata, and a plain fetch does not hold them. The detector looks for the page’s inline challenge options and answers needs_browser. The cf_clearance guide covers what comes after a challenge page is passed.

hCaptcha comes next, before reCAPTCHA. CapSkip solves it, whether the widget is a checkbox, invisible or Enterprise, but a site that moved from reCAPTCHA to hCaptcha often keeps the g-recaptcha class on its widget, and only the hCaptcha script tells the two apart. The check reads the sitekey from the widget or from an hcaptcha.render call, and adds invisible when the widget has data-size set to invisible. An Enterprise page that hands the widget an rqdata value through hcaptcha.setData gets needs_browser instead, because that value is issued for one challenge and expires quickly.

UNSUPPORTED = {"arkose": r"arkoselabs\.com/v2/|funcaptcha\.com/fc/", "geetest_v4": r"gt4\.js|initGeetest4"}

def unsupported(p):  # CapSkip cannot solve these: say so instead of misleading the reader
    for kind, pat in UNSUPPORTED.items():
        if re.search(pat, p.srcs + " " + p.js):
            return {"type": kind, "method": None, "args": {}, "unsupported": True}

def cf_challenge(p):  # the full-page "Just a moment..." interstitial, not a widget
    if re.search(r"_cf_chl_opt\s*=\s*\{", p.js):
        why = "data and pagedata (Cloudflare calls them cData and chlPageData) only exist at run time"
        return browser("turnstile_challenge", "turnstile", p, why)

def hcaptcha(p):  # before reCAPTCHA: a site that moved to hCaptcha may keep the g-recaptcha class
    if not re.search(r"hcaptcha\.com/1/api\.js", p.srcs + " " + p.js):
        return None
    if re.search(r"\.setData\s*\(|rqdata", p.js):  # Enterprise rqdata is issued per challenge
        return browser("hcaptcha_enterprise", "hcaptcha", p, "rqdata is issued for one challenge at run time")
    w = tag("h-captcha", p.h) or tag("g-recaptcha", p.h)
    key = attr(w, "data-sitekey") or grab(SITEKEY_JS, render_opts(r"hcaptcha\.render", p.js))
    if not key:
        return browser("hcaptcha", "hcaptcha", p, "no sitekey in the static HTML")
    args = {"sitekey": key, "url": p.url}
    if attr(w, "data-size") == "invisible":
        args["invisible"] = 1
    return hit("hcaptcha", "hcaptcha", **args)

Step 4: reCAPTCHA, in the right order

reCAPTCHA needs the most care because several CapSkip variants share two script names. The order of questions is what keeps them apart. First the script name: enterprise.js means the Enterprise flag, api.js does not. Then the render parameter in the script address. A key after render= means v3, but the word explicit after render= means a v2 page that renders its widget from code, and it must never be used as a key. Then the markup. A button with the g-recaptcha class and a data-action attribute is Google’s v3 button binding. A button without data-action, or any element with data-size set to invisible, is the invisible v2 widget. Anything else with the class is the checkbox.

def recaptcha(p):  # Enterprise flag first, then v3 (render=KEY), then v2
    m = re.search(r"/recaptcha/(api|enterprise)\.js(\?\S*)?", p.srcs)
    if not m:
        return None
    ent = {"enterprise": 1} if m.group(1) == "enterprise" else {}
    render = grab(r"[?&]render=([^&\s]+)", m.group(2))  # a v3 sitekey, or "explicit" (a v2 page)
    t, o = tag("g-recaptcha", p.h), render_opts(r"\.render", p.js)
    button, action = (t or "").lower().startswith("<button"), attr(t, "data-action")
    v3 = bool(render and render != "explicit") or bool(button and action)
    key = render if render != "explicit" else None
    key = key or attr(t, "data-sitekey") or grab(SITEKEY_JS, o)
    if not key:
        return browser("recaptcha", "recaptcha", p, "no sitekey in the static HTML (set by JavaScript)")
    small = attr(t, "data-size") == "invisible" or grab(r"size['\"]?\s*:\s*['\"](invisible)", o)
    invisible = not v3 and bool(button or small)
    action = action or grab(r"execute\(\s*[^,)]+,\s*\{\s*['\"]?action['\"]?\s*:\s*['\"]([^'\"]+)", p.js)
    args = {"sitekey": key, "url": p.url, **ent}
    if v3:
        args.update(version="v3", **({"action": action} if action else {}))
    if invisible:
        args["invisible"] = 1
    kind = "recaptcha_" + ("v3" if v3 else "v2_invisible" if invisible else "v2")
    return hit(kind + ("_enterprise" if ent else ""), "recaptcha", **args)

Each outcome maps to a call you already know. A checkbox is solver.recaptcha with a sitekey and the page URL. Invisible adds invisible=1, Enterprise adds enterprise=1, and v3 adds version="v3" and the action. The action name comes from the data-action attribute or from the page’s execute call; if neither is in the HTML, the argument is left out and CapSkip uses its default of verify. See the reCAPTCHA solver page for every variant it handles.

Step 5: Turnstile

A Turnstile widget needs two things on the page: Cloudflare’s api.js and a sitekey. The key sits in a data-sitekey attribute on a div with the cf-turnstile class, or in a turnstile.render call. The check requires the script, so a stray class name on an unrelated page cannot trigger it. It also reads data-action and data-cdata when they are present and passes them on. The Turnstile solver page lists the options.

def turnstile(p):  # widget only: implicit (div.cf-turnstile) or explicit (turnstile.render)
    if "challenges.cloudflare.com/turnstile/v0/api.js" not in p.srcs:
        return None
    t, o = tag("cf-turnstile", p.h), render_opts(r"turnstile\.render", p.js)
    key = attr(t, "data-sitekey") or grab(SITEKEY_JS, o)
    if not key:
        return browser("turnstile", "turnstile", p, "no sitekey in the static HTML")
    action = attr(t, "data-action") or grab(r"\baction['\"]?\s*:\s*['\"]([^'\"]+)", o)
    cdata = attr(t, "data-cdata") or grab(r"\bcData['\"]?\s*:\s*['\"]([^'\"]+)", o)
    extra = {k: v for k, v in (("action", action), ("data", cdata)) if v}
    return hit("turnstile", "turnstile", sitekey=key, url=p.url, **extra)

Step 6: GeeTest and ALTCHA

GeeTest v3 gives you two values, and one of them is a trap. The gt value is fixed for the site and can sit in the page. The challenge value is single-use, and sites fetch it from a register endpoint at run time. If the page carries both, the detector returns them. If it carries only the initGeetest call, it answers needs_browser, because a challenge found in old HTML would already be dead.

ALTCHA changed its attribute names between generations. Versions 1 and 2 use challengeurl, or challengejson for an inline challenge. Version 3 and later use one attribute called challenge, which takes either a URL or the challenge data. The check reads all three and joins a relative URL to the page address.

def geetest(p):  # v3 only: gt is static, challenge comes from the register endpoint
    if not (re.search(r"initGeetest\s*\(", p.js) or re.search(r"/gt(\.[\d.]+)?\.js", p.srcs)):
        return None
    gt = grab(r"\bgt['\"]?\s*:\s*['\"]([0-9a-f]{32})", p.js)
    challenge = grab(r"\bchallenge['\"]?\s*:\s*['\"]([0-9a-f]{32,34})", p.js)
    if not (gt and challenge):
        return browser("geetest", "geetest", p, "gt and challenge come from the register endpoint")
    return hit("geetest", "geetest", gt=gt, challenge=challenge, url=p.url)

def altcha(p):  # challengeurl / challengejson (v1, v2) or challenge (v3+)
    t = grab(r"(<altcha-widget\b[^>]*>)", p.h)
    if not t:
        return None
    c = attr(t, "challenge")
    as_json = attr(t, "challengejson") or (c if (c or "").lstrip().startswith("{") else None)
    as_url = attr(t, "challengeurl") or (None if as_json else c)
    if as_json:
        return hit("altcha", "altcha", url=p.url, challenge_json=as_json)
    if as_url:
        return hit("altcha", "altcha", url=p.url, challenge_url=urljoin(p.url, as_url))
    return browser("altcha", "altcha", p, "no challenge attribute")

Step 7: Capy, CaptchaFox and Friendly Captcha

These three are easy to find and easy to get slightly wrong. Capy needs no widget div for this check. Its key starts with PUZZLE_ and sits in the k parameter of the widget script’s address, and a host other than the default is passed as api_server. CaptchaFox comes in two builds. The plain one loads from cdn.captchafox.com. The other is bundled by a hosting platform under a shared CDN path, so the detector looks for the CaptchaFox package name in that path and not for the path alone, which other widgets also use.

Friendly Captcha is the trickiest. Version 1 and version 2 use the same div and the same sitekey namespace, but different protocols, and a token for the wrong one is accepted by CapSkip and rejected by the site. The scripts tell them apart: version 2 loads the @friendlycaptcha/sdk package, version 1 loads friendly-challenge. When the page gives no sign, the detector refuses to guess and says needs_browser.

def capy(p):  # the widget script is .../puzzle/get_js/?k=PUZZLE_...
    m = re.search(r"((?:https?:)?//[^\s\"']+?)/puzzle/get_js/\S*?(PUZZLE_[\w-]+)", p.srcs)
    if m:
        host = m.group(1) if m.group(1).startswith("http") else "https:" + m.group(1)
        more = {} if host == "https://jp.api.capy.me" else {"api_server": host}
        return hit("capy", "capy", sitekey=m.group(2), url=p.url, **more)

MAM = "mampkg/@mamdev/core.frontend.libs.captchafox"

def captchafox(p):  # plain build from cdn.captchafox.com, or the bundled build from s.uicdn.com
    t, key = tag("captchafox", p.h), grab(r"captchafox[^\"']*/api\.js\?key=([^\"'&]+)", p.srcs)
    if t or key:
        more = {"api_server": "https://s.uicdn.com/" + MAM + "/"} if MAM in p.srcs else {}
        sitekey = attr(t, "data-sitekey") or key
        if not sitekey:
            return browser("captchafox", "captchafox", p, "no sitekey in the static HTML")
        return hit("captchafox", "captchafox", sitekey=sitekey, url=p.url, **more)

def friendly(p):  # v2 loads @friendlycaptcha/sdk (site.min.js), v1 loads friendly-challenge
    t = tag("frc-captcha", p.h)
    if not t:
        return None
    v2_end, v1_end = attr(t, "data-api-endpoint"), attr(t, "data-puzzle-endpoint")
    server = v2_end or v1_end
    v2 = v2_end is not None or re.search(r"friendlycaptcha/sdk|site(\.compat)?\.min\.js", p.srcs)
    v1 = v1_end is not None or re.search(r"friendly-challenge|widget(\.module)?\.min\.js", p.srcs)
    if bool(v2) == bool(v1):
        return browser("friendly_captcha", "friendly_captcha", p, "cannot tell v1 from v2 in the HTML")
    if not attr(t, "data-sitekey"):
        return browser("friendly_captcha", "friendly_captcha", p, "no sitekey in the static HTML")
    more = {"api_server": server} if server else {}
    return hit("friendly_captcha_" + ("v2" if v2 else "v1"), "friendly_captcha",
               sitekey=attr(t, "data-sitekey"), url=p.url, version="v2" if v2 else "v1", **more)

Each of these has its own solver page with the options the method takes:

Step 8: the image CAPTCHA, a guess

An image CAPTCHA has no vendor script to find, so this check is a guess and runs only when nothing else matched. The check wants an image whose tag says captcha, and a text box elsewhere on the page whose tag says captcha or security code. It missed in every case where the markup did not use those words, and a drawn canvas is invisible to it. Treat a hit as likely, not certain.

def image(p):  # a guess: an <img> that says captcha, next to a text box that says captcha
    box = r"<input\b(?![^>]*type\s*=\s*[\"']?(?:hidden|checkbox|submit))[^>]*(?:captcha|security.?code)"
    for img in re.findall(r"<img\b[^>]*>", p.h, re.I):
        if re.search(box, p.h, re.I) and re.search(r"captcha", img, re.I) and attr(img, "src"):
            return hit("image", "normal", image=urljoin(p.url, attr(img, "src")))

Step 9: the checks, detect and solve

The last block lists the checks in order and exposes detect and solve, with detect_all as a helper. The order is the logic: unsupported vendors first, then the Cloudflare page, then hCaptcha, then each other family, and the image guess only when the list found nothing. The detect function returns the first result and adds an also list when the page carries more than one family, for example a v3 script plus a Turnstile box. The list names families, not a second reCAPTCHA widget with another key. The solve function dispatches the result to the right CapSkip method. It refuses anything unsupported or marked needs_browser with a RuntimeError, and it downloads an image through your own Session so the cookies match.

The SDK has no hCaptcha call yet, so an hCaptcha result goes to solve_hcaptcha instead. It posts the sitekey and the page address to in.php with method=hcaptcha, then polls res.php every 5 seconds until the token is ready, as the hCaptcha section of the API docs describes. Pass the solver’s address as api, and your API key as key if API key validation is switched on. The result carries the token in code and the User-Agent it was earned with in userAgent.

CHECKS = [unsupported, cf_challenge, hcaptcha, recaptcha, turnstile, geetest, altcha, capy, captchafox, friendly]

def detect_all(html, page_url):
    p = parse(html, page_url)
    return [r for r in (check(p) for check in CHECKS) if r] or [r for r in [image(p)] if r]

def detect(html, page_url):
    found = detect_all(html, page_url)
    if len(found) > 1:
        found[0]["also"] = [r["type"] for r in found[1:]]
    return found[0] if found else None

def solve_hcaptcha(args, api, key=None):  # no SDK call yet: post to in.php, poll res.php
    form = {"method": "hcaptcha", "sitekey": args["sitekey"], "pageurl": args["url"],
            "invisible": args.get("invisible", 0), "data": args.get("data"), "key": key, "json": 1}
    job = requests.post(api + "/in.php", data=form, timeout=30).json()
    if job["status"] != 1:
        raise RuntimeError(job["request"])
    for _ in range(36):  # a solve takes 10 to 30 seconds, so give up after 3 minutes
        time.sleep(5)
        q = {"key": key, "action": "get", "id": job["request"], "json": 1}
        r = requests.get(api + "/res.php", params=q, timeout=30).json()
        if r["request"] != "CAPCHA_NOT_READY":
            if r["status"] != 1:
                raise RuntimeError(r["request"])
            return {"code": r["request"], "userAgent": r.get("useragent")}
    raise TimeoutError("hCaptcha was not solved within 3 minutes")

def solve(info, solver, session=None, api="http://127.0.0.1:8080", key=None):
    if info.get("unsupported") or info.get("needs_browser"):
        raise RuntimeError(info.get("needs_browser") or info["type"] + " is not supported by CapSkip")
    args = dict(info["args"])
    if info["method"] == "hcaptcha":
        return solve_hcaptcha(args, api, key)
    if info["method"] == "normal":  # fetch the image with YOUR session so the cookie matches it
        src = args.pop("image")
        if not src.startswith("data:"):
            r = (session or requests).get(src, timeout=30)
            r.raise_for_status()
            src = "data:image/png;base64," + base64.b64encode(r.content).decode()
        args["file"] = src
    return getattr(solver, info["method"])(**args)

Run it

Save this as use.py next to detect.py and run it with the environment’s own Python, as in .\.venv\Scripts\python.exe use.py. The port 8080 is the default; use the port shown in CapSkip’s connection settings if yours differs. The script fetches your page, prints what the detector found, and only calls CapSkip when there is a method to call and nothing is missing.

import requests
from capskip import CapSkip
from detect import detect, solve

URL = "https://example.com/login"   # a page of yours with a CAPTCHA
HOST, PORT = "127.0.0.1", 8080      # from CapSkip's connection settings
session = requests.Session()
html = session.get(URL, timeout=30).text

info = detect(html, URL)
print(info)

if info and info["method"] and not info.get("needs_browser"):
    solver = CapSkip(host=HOST, port=PORT)
    result = solve(info, solver, session, api=f"http://{HOST}:{PORT}")
    print(result)

On a page with a reCAPTCHA v2 checkbox the first print shows the type, the method and the arguments, and the second shows the result with the token in its code field. In the mock run it looked like this:

Pagedetect returns
reCAPTCHA v2 checkboxtype recaptcha_v2, method recaptcha, sitekey and url
hCaptcha widgettype hcaptcha, method hcaptcha, sitekey and url
Cloudflare “Just a moment” pagetype turnstile_challenge, needs_browser set
v3 script plus a Turnstile widgettype recaptcha_v3, also lists turnstile

When the HTML says nothing: the browser console

Some pages add their widget with JavaScript after loading. A plain fetch then finds no CAPTCHA at all, and the detector returns None for a page that has one. Two cases are known and expected: a Google or Cloudflare script added by code, and a sitekey set by code. For those, open the page in Chrome, press F12 and paste this into the Console. If Chrome blocks the paste, type allow pasting and press Enter first. It reads the sitekeys from the resources the page has already loaded, so it sees what the browser built.

(() => {
  const iframes = [...document.querySelectorAll('iframe')].map((f) => f.src);
  const urls = performance.getEntriesByType('resource').map((r) => r.name).concat(iframes);
  const found = new Map(), add = (o) => found.set(JSON.stringify(o), o);
  for (const u of urls) {
    let m;
    if ((m = u.match(/recaptcha\/(?:api2|(enterprise))\/anchor\?.*?\bk=([\w-]+)/)))
      add({ type: 'recaptcha', sitekey: m[2], enterprise: !!m[1], invisible: /size=invisible/.test(u) });
    else if ((m = u.match(/recaptcha\/(?:api|enterprise)\.js\?.*?\brender=([\w-]+)/)) && m[1] !== 'explicit')
      add({ type: 'recaptcha v3', sitekey: m[1], enterprise: /enterprise\.js/.test(u) });
    else if ((m = u.match(/challenges\.cloudflare\.com\/.*turnstile\/.*\/(0x4[\w-]+|[123]x0+[A-Za-z]+)\//)))
      add({ type: 'turnstile', sitekey: m[1] });
    else if ((m = u.match(/hcaptcha\.com\/captcha\/.*[#&?]sitekey=([\w-]+)/))) add({ type: 'hcaptcha', sitekey: m[1] });
    else if (/arkoselabs\.com\/fc\//.test(u)) add({ type: 'arkose: CapSkip cannot solve' });
  }
  if (window._cf_chl_opt) add({ type: 'cloudflare challenge page' });
  document.querySelectorAll('altcha-widget').forEach((e) => add({ type: 'altcha', challenge: e.getAttribute('challenge') || e.getAttribute('challengeurl') }));
  document.querySelectorAll('.h-captcha[data-sitekey]').forEach((e) => add({ type: 'hcaptcha', sitekey: e.dataset.sitekey }));
  document.querySelectorAll('.frc-captcha, .captchafox').forEach((e) => add({ type: e.classList.contains('captchafox') ? 'captchafox' : 'frc-captcha', sitekey: e.dataset.sitekey }));
  const v3 = [...found.values()].filter((o) => o.type === 'recaptcha v3').map((o) => o.sitekey);  // a v3 badge also adds an invisible anchor frame
  const res = [...found.values()].filter((o) => !(o.type === 'recaptcha' && v3.includes(o.sitekey)));
  return res.length ? res : 'no known CAPTCHA found (GeeTest, Capy and CaptchaFox scripts are not checked)';
})();

It found the sitekey and flags on a reCAPTCHA v2 page, an invisible one, a v3 page, a Turnstile page and the hCaptcha demo, and it printed a message on a page with no CAPTCHA. Three limits: the browser keeps only about 250 resource entries by default, so a long-running single page app can lose early ones, and the snippet cannot tell Friendly Captcha version 1 from version 2, and it does not look for GeeTest, Capy or CaptchaFox scripts. On Cloudflare’s full-page check it can show the sitekey from a resource address, but not the data and pagedata values, which need a hook on the render call. That hook was not tested here.

Where detection fails

SituationWhat detect returnsWhat to do
Widget script added by JavaScriptNoneLoad the page in a browser and run the console snippet
Sitekey set by JavaScriptneeds_browserRead the key from the live page
Cloudflare full-page checkneeds_browserRead the values in a browser, see the cf_clearance guide
CaptchaFox, Friendly Captcha or Capy script with no widget in the HTMLNoneRun the console snippet or read the page source
A 403 or 503 page from the site itselfNonePrint the status code first: a block page holds no CAPTCHA to find
Arkose, GeeTest v4unsupportedCapSkip cannot solve these
hCaptcha Enterprise that sets rqdataneeds_browserRead a fresh rqdata from the live page for each task and pass it to solve_hcaptcha as data
Friendly Captcha with no script hintneeds_browserCheck which script the page loads
Image CAPTCHA without the word captchaNoneAdd your own rule for that site

Common errors

ErrorCauseFix
ERROR_GOOGLEKEYThe key was the word explicit, or a key that is not a reCAPTCHA keyRead the key from the widget, not from the render parameter
ERROR_CAPTCHA_UNSOLVABLEAny failed job reads this way, including a wrong reCAPTCHA version, and it does not name the causeCheck the detected type first, then see the API docs
ValidationExceptionAn option the method does not take, such as an action on a v2 callUse the arguments the detector returned
NetworkExceptionCapSkip is not running, or the host or port is wrongStart CapSkip and check the connection settings
RuntimeError from solveThe result is unsupported or needs_browser, or CapSkip refused an hCaptcha job and the message is its error codeHandle the first two cases before calling solve, and check the sitekey and the page address for the third

Running the solver on a server

The detector only needs two things from CapSkip: a host and a port. In Local mode that is 127.0.0.1 and your own PC. In Server mode CapSkip listens on your network address or a public IP, so a detector running on another PC, a VPS or a hosted platform can call it over the same API. Change HOST and PORT at the top of use.py, and nothing else in the code changes. A static public IP is recommended for stable remote access. Server mode is still your own hardware and still unmetered. Note that Server mode opens the port to any address, so add a Windows Firewall block rule for the addresses that should not reach it. See the connection settings for how to switch modes.

FAQ

Can I tell which CAPTCHA a site uses without code?

Yes. Open DevTools, go to the Network tab, reload the page and look at the script addresses. A google.com/recaptcha or recaptcha.net script is reCAPTCHA, challenges.cloudflare.com is Turnstile, and js.hcaptcha.com is hCaptcha. The Console snippet above does the same lookup for you.

Why does detect return needs_browser instead of a guess?

Because a wrong guess wastes a full solve and returns a token the site rejects with no hint why. When the static HTML does not hold the sitekey, a GeeTest challenge or a Friendly Captcha version, saying so is quicker than a failed job.

Does CapSkip solve hCaptcha?

Yes. CapSkip solves hCaptcha checkbox, invisible and Enterprise widgets through its HTTP API with method=hcaptcha, as the API docs describe. The Python SDK has no hCaptcha call yet, so solve_hcaptcha above posts the job to in.php itself. FunCaptcha, Arkose and GeeTest v4 are still not supported, and the detector reports those as unsupported so a script can skip the page instead of failing on it.

Can a hosted platform use the detector with my CapSkip?

Yes, through Server mode. Set CapSkip to listen on your network or public IP, set HOST and PORT in use.py to that address, and the detector and the solve call work the same as on one PC. The solver stays on your hardware.

The short version

Detect CAPTCHA type first, then solve: read the script and the markup in a fixed order, hand the arguments to the right method, and treat None, needs_browser and unsupported as answers, not errors. With CapSkip on your own machine there is no meter running while you check a page, which is what makes an unlimited captcha solver practical for a crawl that meets a new site every few minutes. For the Python SDK itself, see the Python CAPTCHA solver page.