如何检测页面上的验证码类型并识别它(Python)

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

要检测页面上的验证码类型,先读取页面 HTML 中小组件的脚本和容器,从中取出 sitekey,再把这一类映射到能识别它的 CapSkip 方法。reCAPTCHA v2 方框、v3 脚本和 Turnstile 小组件需要三种不同的调用和不同的选项,猜错就会白白花掉一次识别,得到的结果还用不了。本指南用 Python 构建一个约 175 行的检测器,它会返回类型、方法和参数,或者明确说它无法判断。CapSkip 运行在你自己的 Windows PC 上,没有按次识别的费用,所以检查一百个页面的脚本不会产生额外开销。

你需要什么

  • 在 Windows PC 上运行的 CapSkip,并已开启你需要的识别功能。 设置指南 详细介绍了首次启动的步骤。
  • Python 3.10 或更高版本。下面的代码在 Python 3.14.0 和 3.11.7 上运行过,使用了 requests 2.33.0 和 2.34.2,以及 capskip 1.3.0。
  • 一个 PowerShell 提示符。这些命令使用 PowerShell 语法。
  • 一个你拥有或被允许自动化操作的页面,页面上有验证码。
  • 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台 PC、VPS 或托管平台上的脚本就能通过 API 访问它。两种模式的设置位置都是 连接设置.

测试方式:检测器在 66 个保存下来的页面上运行,每个页面都返回了预期结果。其中 59 个是手工构建的(约 40 个遵循各厂商的文档,其余是我自己编出来的边缘情况),7 个是 2026 年 10 月 29 日抓取的公开页面快照。识别通过真实的 capskip 包,发往一个照搬了应用输入检查逻辑的模拟 CapSkip。真实的应用没有运行,所以下面的任何数字都不是识别耗时。

为什么要先检测验证码类型再识别

CapSkip 为每一类验证码提供不同的方法:SDK 中有 recaptcha、turnstile、geetest、altcha、capy、captchafox、friendly_captcha,以及用于图片的 normal;HTTP API 上还有 hcaptcha,SDK 目前尚未封装它。在 reCAPTCHA 内部,选项也很重要。Invisible、Enterprise 和 v3 是同一个调用上的标志。传错了,最好的情况是任务失败,最坏的情况是得到一个被站点拒绝、却不给任何提示的 token。我无法确认,面对错误的 reCAPTCHA 版本,真实的应用到底属于哪一种情况。无论哪种,这次识别都白费了。

其他错误会很快、很明显地失败。从以显式方式渲染 reCAPTCHA 的页面读取 sitekey,得到的是单词 explicit,CapSkip 会用 ERROR_GOOGLEKEY 拒绝它。 ERROR_GOOGLEKEY 与 ERROR_PAGEURL 指南 从另一个角度讲了这个错误。要可靠地检测验证码类型,检测器必须按固定的顺序检查各种信号,而这个顺序能避开这两种错误。在构建要面对多个站点的爬虫之前,检测器也是首先要做的东西。如果你想了解每种类型的背景,可以看 什么是验证码 这一页,里面不写代码,只讲各个类别。

第 1 步:安装并抓取页面

在一个空文件夹中创建虚拟环境,并安装这两个包。这些命令通过路径调用该环境自己的 Python,所以不需要激活它。

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

要根据文本检测验证码类型,检测器只需要两个输入:页面的 HTML 和它的地址。用 requests 的 Session 抓取页面,并保留这个 Session,因为后面的图片验证码步骤会用同样的 cookie 下载图片。普通的 requests 抓取,看到的是服务器在任何 JavaScript 运行之前发来的内容。Google 的 reCAPTCHA 演示页面、Cloudflare 的 Turnstile 演示和 hCaptcha 演示,都把各自的小组件放在这样的 HTML 里。通过 JavaScript 添加小组件的页面则不是这样,“当 HTML 什么都没说时”一节会讲这种情况。

第 2 步:编写辅助函数

把从这里到第 9 步的所有内容,一个代码块接一个代码块,保存在一个名为 detect.py 的文件里。第一个代码块导入所需的模块,并写了四个小的读取函数。没有匹配时它们都返回 None,所以缺失的属性不会引发异常。

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)

grab 函数返回第一个匹配到的捕获组。tag 函数按 CSS 类查找开始标签,并拒绝那些只是在更长的类名里包含该名称的类,所以名为 cf-turnstile-response 的类不是 Turnstile 小组件。attr 函数读取用任一种引号写出的属性,并对 HTML 实体做反转义,这对放在属性里的挑战数据很重要。不带引号的属性不会被读取。

第二个代码块把页面变成各项检查可以共用的东西。它会去掉注释和样式块,这样提到小组件类名的 CSS 规则就不会产生匹配,并且把脚本地址和内联 JavaScript 分别收集起来。最后两个辅助函数构建两种可能的答案:命中,或者 needs_browser 答案。

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}

第 3 步:hCaptcha,以及 CapSkip 无法识别的类型

先处理正确答案是“否”的页面。CapSkip 不识别 FunCaptcha 或 Arkose,GeeTest v4 也不在 GeeTest 方法的范围内,该方法针对的是 v3。如果检测器跳过这些,带有 Arkose 方框的页面就会落到下一项检查,还可能被判成它并不是的东西。所以这项检查最先运行,并返回一个没有方法的结果。

同一个代码块也处理 Cloudflare 的整页检查,也就是 “Just a moment” 中间页。这个页面不是 Turnstile 小组件。turnstile 方法需要从中取得两个值,data 和 pagedata,而普通的 fetch 拿不到它们。检测器会查找页面内联的挑战选项,并回答 needs_browser。 cf_clearance 指南 介绍了通过挑战页面之后要做什么。

接下来是 hCaptcha,排在 reCAPTCHA 之前。无论小组件是复选框、invisible 还是 Enterprise,CapSkip 都能识别,但从 reCAPTCHA 迁移到 hCaptcha 的站点常常在小组件上保留 g-recaptcha 类,只有 hCaptcha 脚本才能区分两者。这项检查从小组件或 hcaptcha.render 调用中读取 sitekey,当小组件的 data-size 设为 invisible 时加上 invisible。如果 Enterprise 页面通过 hcaptcha.setData 给小组件传入 rqdata 值,结果则会是 needs_browser,因为这个值只针对一次挑战签发,而且很快过期。

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)

第 4 步:reCAPTCHA,按正确的顺序

reCAPTCHA 需要最细致的处理,因为好几个 CapSkip 变体共用两个脚本名。问题的先后顺序,才能把它们区分开。先看脚本名:enterprise.js 表示 Enterprise 标志,api.js 则不是。然后看脚本地址里的 render 参数。render= 后面跟一个 key 表示 v3,但 render= 后面跟单词 explicit,表示这是一个用代码渲染小组件的 v2 页面,而且它绝不能当作 key 使用。然后看标记。带有 g-recaptcha 类和 data-action 属性的按钮,是 Google 的 v3 按钮绑定。没有 data-action 的按钮,或者 data-size 设为 invisible 的任何元素,是 invisible 的 v2 小组件。带有该类的其他任何元素,都是复选框。

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)

每种结果都对应一个你已经熟悉的调用。复选框是带 sitekey 和页面 URL 的 solver.recaptcha。Invisible 要加 invisible=1,Enterprise 要加 enterprise=1,v3 要加 version="v3" 和 action。action 的名称取自 data-action 属性或页面的 execute 调用;如果 HTML 中两者都没有,就不传这个参数,CapSkip 使用它的默认值 verify。请参阅 reCAPTCHA 识别工具页面 了解它处理的每一种变体。

第 5 步:Turnstile

Turnstile 小组件在页面上需要两样东西:Cloudflare 的 api.js 和一个 sitekey。key 位于带有 cf-turnstile 类的 div 上的 data-sitekey 属性中,或者位于 turnstile.render 调用中。检查要求必须有这个脚本,所以无关页面上偶然出现的类名不会触发它。当 data-action 和 data-cdata 存在时,它也会读取并传下去。 Turnstile 识别工具页面 列出了这些选项。

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)

第 6 步:GeeTest 和 ALTCHA

GeeTest v3 会给你两个值,其中一个是陷阱。gt 值对站点是固定的,可以放在页面里。challenge 值只能用一次,站点在运行时从一个 register 端点获取它。如果页面同时带有两者,检测器就把它们返回。如果页面只带有 initGeetest 调用,它就回答 needs_browser,因为在旧 HTML 里找到的 challenge 早已失效。

ALTCHA 在不同代之间改过属性名。版本 1 和 2 使用 challengeurl,内联挑战则用 challengejson。版本 3 及更高版本使用一个叫 challenge 的属性,它既可以是 URL,也可以是挑战数据。检查会读取这三个,并把相对 URL 与页面地址拼合。

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")

第 7 步:Capy、CaptchaFox 和 Friendly Captcha

这三种很容易找到,也很容易稍微弄错。Capy 在这项检查中不需要小组件 div。它的 key 以 PUZZLE_ 开头,位于小组件脚本地址的 k 参数中,而非默认的主机则作为 api_server 传入。CaptchaFox 有两种构建。普通版从 cdn.captchafox.com 加载。另一种由托管平台打包,放在共享的 CDN 路径下,所以检测器是在该路径中查找 CaptchaFox 的包名,而不是只看路径本身,因为其他小组件也用这个路径。

Friendly Captcha 是最棘手的。版本 1 和版本 2 使用同样的 div 和同一个 sitekey 命名空间,但协议不同,错误版本的 token 会被 CapSkip 接受,却被站点拒绝。可以通过脚本把两者区分开:版本 2 加载 @friendlycaptcha/sdk 包,版本 1 加载 friendly-challenge。当页面没有给出任何迹象时,检测器拒绝猜测,并回答 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)

其中每一种都有自己的识别页面,介绍该方法所接受的选项:

第 8 步:图片验证码,一次猜测

图片验证码没有可以查找的厂商脚本,所以这项检查是一种猜测,只在其他检查都没有匹配时才运行。这项检查要找一张标签里带有 captcha 的图片,以及页面上其他位置一个标签里带有 captcha 或 security code 的文本框。凡是标记里没有用到这些词的情况,它每次都漏掉,而且它看不见绘制出来的 canvas。请把命中视为可能,而不是确定。

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")))

第 9 步:检查、检测与识别

最后一个代码块按顺序列出各项检查,并提供 detect 和 solve,另有 detect_all 作为辅助函数。这个顺序就是逻辑:先是不支持的厂商,然后是 Cloudflare 页面,然后是 hCaptcha,再是其他各个类别,只有当列表一无所获时才用图片猜测。detect 函数返回第一个结果,当页面带有不止一类时(例如 v3 脚本加一个 Turnstile 方框),会附加一个 also 列表。这个列表列出的是类别,而不是带着另一个 key 的第二个 reCAPTCHA 小组件。solve 函数把结果分发给正确的 CapSkip 方法。对于 unsupported 或标记为 needs_browser 的结果,它会用 RuntimeError 拒绝,而图片则通过你自己的 Session 下载,这样 cookie 才能对上。

SDK 还没有 hCaptcha 调用,所以 hCaptcha 结果改为交给 solve_hcaptcha。它以 method=hcaptcha 把 sitekey 和页面地址提交到 in.php,然后每 5 秒轮询一次 res.php,直到 token 就绪,正如 API 文档的 hCaptcha 部分 所述。把识别工具的地址作为 api 传入;如果开启了 API key 验证,再把你的 API key 作为 key 传入。结果在 code 中携带 token,在 userAgent 中携带获取它时使用的 User-Agent。

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)

运行它

把它保存为 use.py,放在 detect.py 旁边,并用该环境自己的 Python 运行,如 .\.venv\Scripts\python.exe use.py。端口 8080 是默认值;如果你的不同,请使用 CapSkip 连接设置中显示的端口。这个脚本会抓取你的页面,打印检测器的发现,并且只有当有方法可调用、且没有缺失项时才调用 CapSkip。

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)

在带有 reCAPTCHA v2 复选框的页面上,第一次打印显示类型、方法和参数,第二次显示结果,token 放在它的 code 字段里。在模拟运行中,它是这样的:

页面detect 的返回值
reCAPTCHA v2 复选框类型 recaptcha_v2,方法 recaptcha,以及 sitekey 和 url
hCaptcha 小组件类型 hcaptcha,方法 hcaptcha,sitekey 和 url
Cloudflare “Just a moment” 页面类型 turnstile_challenge,已设置 needs_browser
v3 脚本加 Turnstile 小组件类型 recaptcha_v3,also 列出 turnstile

当 HTML 什么都没说时:浏览器控制台

有些页面会在加载之后用 JavaScript 添加小组件。这时普通的抓取根本找不到验证码,检测器会对确实有验证码的页面返回 None。已知有两种预期中的情况:由代码添加的 Google 或 Cloudflare 脚本,以及由代码设置的 sitekey。遇到这些,就在 Chrome 中打开页面,按 F12,把下面的代码粘贴到 Console 中。如果 Chrome 阻止粘贴,请先输入 allow pasting 并按 Enter。它从页面已加载的资源中读取 sitekey,所以能看到浏览器构建出来的内容。

(() => {
  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)';
})();

它在 reCAPTCHA v2 页面、invisible 页面、v3 页面、Turnstile 页面和 hCaptcha 演示页面上找到了 sitekey 和标志,并在没有验证码的页面上打印了一条消息。有三个局限:浏览器默认只保留大约 250 条资源记录,所以长时间运行的单页应用可能丢掉最早的记录;这段代码无法区分 Friendly Captcha 版本 1 和版本 2;它也不会查找 GeeTest、Capy 或 CaptchaFox 的脚本。在 Cloudflare 的整页检查上,它能从资源地址显示 sitekey,但显示不了 data 和 pagedata 的值,这需要在 render 调用上挂一个钩子。那个钩子没有在这里测试。

检测失败的情形

场景detect 的返回值该怎么做
由 JavaScript 添加的小组件脚本无在浏览器中加载页面,并运行控制台代码
由 JavaScript 设置的 sitekeyneeds_browser从实时页面读取 key
Cloudflare 整页检查needs_browser在浏览器中读取这些值,请参阅 cf_clearance 指南
HTML 中没有小组件的 CaptchaFox、Friendly Captcha 或 Capy 脚本无运行控制台代码,或查看页面源代码
站点自身返回的 403 或 503 页面无先打印状态码:封锁页面里没有验证码可找
Arkose、GeeTest v4unsupportedCapSkip 无法识别这些
设置了 rqdata 的 hCaptcha Enterpriseneeds_browser针对每个任务从实时页面读取新的 rqdata,并将其作为 data 传给 solve_hcaptcha
没有脚本线索的 Friendly Captchaneeds_browser检查页面加载的是哪个脚本
不含 captcha 这个词的图片验证码无为那个站点添加你自己的规则

常见错误

错误原因修复
ERROR_GOOGLEKEYkey 是单词 explicit,或者是不像 reCAPTCHA key 的 key从小组件读取 key,而不是从 render 参数读取
ERROR_CAPTCHA_UNSOLVABLE任何失败的任务都是这样的表现,包括 reCAPTCHA 版本错误,而且它不会指出原因先检查检测到的类型,然后参阅 API 文档
ValidationException方法不接受的选项,例如 v2 调用上的 action使用检测器返回的参数
NetworkExceptionCapSkip 没有在运行,或者主机或端口不对启动 CapSkip 并检查连接设置
solve 抛出的 RuntimeError结果是 unsupported 或 needs_browser,或者 CapSkip 拒绝了 hCaptcha 任务,消息就是它的错误代码调用 solve 之前先处理前两种情况,第三种情况请检查 sitekey 和页面地址

把识别工具跑在服务器上

检测器只需要 CapSkip 提供两样东西:主机和端口。在 Local 模式下,那就是 127.0.0.1 和你自己的 PC。在 Server 模式下,CapSkip 监听你的网络地址或公网 IP,所以另一台 PC、VPS 或托管平台上运行的检测器,可以通过同样的 API 调用它。在 use.py 顶部更改 HOST 和 PORT,代码里的其他部分都不用变。建议使用静态公网 IP,以获得稳定的远程访问。Server 模式仍然是你自己的硬件,仍然不计量。请注意,Server 模式会对任何地址开放这个端口,所以请为不应访问它的地址添加 Windows Firewall 阻止规则。请参阅 连接设置 了解如何切换模式。

常见问题

不写代码,能判断一个站点用的是哪种验证码吗?

可以。打开 DevTools,进入 Network 标签页,重新加载页面,查看脚本地址。google.com/recaptcha 或 recaptcha.net 的脚本是 reCAPTCHA,challenges.cloudflare.com 是 Turnstile,js.hcaptcha.com 是 hCaptcha。上面的 Console 代码做的是同样的查找。

为什么 detect 返回 needs_browser,而不是猜一个?

因为猜错会白白浪费一次完整的识别,还会得到一个被站点拒绝、却不给任何提示的 token。当静态 HTML 中没有 sitekey、GeeTest 的 challenge 或 Friendly Captcha 的版本时,直接说明,比让任务失败来得更快。

CapSkip 能识别 hCaptcha 吗?

可以。CapSkip 通过其 HTTP API(method=hcaptcha)识别 hCaptcha 的复选框、invisible 和 Enterprise 小组件,正如 API 文档 所述。Python SDK 还没有 hCaptcha 调用,所以上面的 solve_hcaptcha 会自己把任务提交到 in.php。FunCaptcha、Arkose 和 GeeTest v4 仍然不受支持,检测器会把它们报告为 unsupported,这样脚本就可以跳过该页面,而不是在上面失败。

托管平台能用我的 CapSkip 来运行检测器吗?

可以,通过 Server 模式。把 CapSkip 设置为监听你的网络 IP 或公网 IP,把 use.py 中的 HOST 和 PORT 设为那个地址,检测器和识别调用的工作方式就和在一台 PC 上一样。识别工具仍然在你自己的硬件上。

简短版结论

先检测验证码类型,再识别:按固定的顺序读取脚本和标记,把参数交给正确的方法,并把 None、needs_browser 和 unsupported 当作答案,而不是错误。CapSkip 运行在你自己的机器上,检查页面时没有任何计费表在走,这正是 无限量验证码识别工具 在每隔几分钟就遇到新站点的爬取中切实可用的原因。关于 Python SDK 本身,请参阅 Python 验证码识别页面.