如何检测页面上的验证码类型并识别它(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)其中每一种都有自己的识别页面,介绍该方法所接受的选项:
- Capy Puzzle 识别工具页面:Capy 答案返回的三个值。
- CaptchaFox 识别工具页面:CaptchaFox 方法所接受的选项。
- Friendly Captcha 识别工具页面:Friendly Captcha 方法。
第 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 设置的 sitekey | needs_browser | 从实时页面读取 key |
| Cloudflare 整页检查 | needs_browser | 在浏览器中读取这些值,请参阅 cf_clearance 指南 |
| HTML 中没有小组件的 CaptchaFox、Friendly Captcha 或 Capy 脚本 | 无 | 运行控制台代码,或查看页面源代码 |
| 站点自身返回的 403 或 503 页面 | 无 | 先打印状态码:封锁页面里没有验证码可找 |
| Arkose、GeeTest v4 | unsupported | CapSkip 无法识别这些 |
| 设置了 rqdata 的 hCaptcha Enterprise | needs_browser | 针对每个任务从实时页面读取新的 rqdata,并将其作为 data 传给 solve_hcaptcha |
| 没有脚本线索的 Friendly Captcha | needs_browser | 检查页面加载的是哪个脚本 |
| 不含 captcha 这个词的图片验证码 | 无 | 为那个站点添加你自己的规则 |
常见错误
| 错误 | 原因 | 修复 |
|---|---|---|
| ERROR_GOOGLEKEY | key 是单词 explicit,或者是不像 reCAPTCHA key 的 key | 从小组件读取 key,而不是从 render 参数读取 |
| ERROR_CAPTCHA_UNSOLVABLE | 任何失败的任务都是这样的表现,包括 reCAPTCHA 版本错误,而且它不会指出原因 | 先检查检测到的类型,然后参阅 API 文档 |
| ValidationException | 方法不接受的选项,例如 v2 调用上的 action | 使用检测器返回的参数 |
| NetworkException | CapSkip 没有在运行,或者主机或端口不对 | 启动 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 验证码识别页面.
