如何在 Python 中用 requests 和 httpx 识别 CaptchaFox

solve captchafox in python - How to Solve CaptchaFox in Python With requests and httpx

要在 Python 中识别 CaptchaFox,就用 sitekey 和页面 URL 调用 capskip 包里的 solver.captchafox(),把它返回的 token 填入 cf-captcha-response 表单字段,并用随 token 一起返回的 user agent 发送这个请求。Python 代码通常就栽在最后这一步。requests 和 httpx 默认都会在 User-Agent 请求头里报出自己的身份,而站点期望 token 来自签发它的那个浏览器,于是会拒绝这个请求,所以一次完全成功的识别,照样可能在提交时失败。CapSkip 从 1.4.0 版开始支持 CaptchaFox。本指南讲这几件事:key、调用、提交、重试,以及并行运行多个识别。

你需要什么

  • 在 Windows 机器上运行的 CapSkip 1.4.0 或更高版本。CaptchaFox 支持就是在该版本中加入的,同时加入的还有 Friendly Captcha 和 Capy Puzzle。
  • Python 3.10 或更高版本,以及 capskip 包 1.3.0 或更高版本,这是第一个带有 captchafox() 方法的版本。示例还用到了 requests,讲并行识别的那一节用的是 httpx。
  • 目标页面上的两个值:sitekey,以及小组件所在页面的 URL。第 1 步会告诉你 key 在哪里,以及顺便该看哪个 script 标签。
  • 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用;在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的脚本就能通过 API 调用它。两种模式的设置位置都是 连接设置.
# Quoted, so cmd.exe does not read >= as a redirect
pip install "capskip>=1.3.0" requests httpx

第 1 步:读取 sitekey 和小组件来源

sitekey 是公开的,对每个访问者都一样,按惯例以 sk_ 开头。大多数页面把它放在小组件的容器上:一个带有 captchafox 类和 data-sitekey 属性的 div。如果页面用自己的脚本构建小组件,就会改为把 key 传给 captchafox.render 调用;如果你拿到的 HTML 里两者都没有,key 就在 Network 标签页里:它是发往 api.captchafox.com 的请求中 /captcha/ 之后的那段路径。

读页面的时候,顺便检查一下是哪个脚本加载了小组件。大多数站点从 cdn.captchafox.com 加载它,拿到的是普通 token。有些平台则嵌入 s.uicdn.com/mampkg/ 下的一个包,这些站点期望的是以 MAM_ 开头的 token。按错误的来源识别,照样会返回 token,只是格式会被站点拒绝,所以这一步检查能帮你省下一个让人摸不着头脑的下午。在 MAM 页面上,key 可能就在脚本自己的 src 里,位于 ?key= 之后;而且同一个 CDN 还提供其他不相干的包,所以要匹配 captchafox 包的路径,而不是主机名。

import re

import requests

PAGE_URL = "https://example.com/signup"
session = requests.Session()
html = session.get(PAGE_URL, timeout=30).text

# The container: <div class="captchafox" data-sitekey="sk_...">
tag = re.search(r'<[^>]*class="(?:[^"]*\s)?captchafox(?:\s[^"]*)?"[^>]*>', html)
key = tag and re.search(r'data-sitekey="([^"]+)"', tag.group(0))
# MAM pages may carry the key in the script src instead.
key = key or re.search(r'captchafox[^"]*/api\.js\?key=([^"&]+)', html)
sitekey = key.group(1) if key else None

# The MAM build expects a MAM_ token; everything else is the default.
uses_mam = "mampkg/@mamdev/core.frontend.libs.captchafox" in html
print(sitekey, uses_mam)

这个类名匹配模式以空白字符为边界,所以像 captchafox-container 这样带有类名的外层元素不会被误匹配。它要求属性值使用双引号,这与 CaptchaFox 自己的代码片段写法一致。

第 2 步:调用 captchafox()

在 Python 中识别 CaptchaFox 只需要一个方法,它接受 sitekey 和页面 URL,外加可选的关键字参数。对于标准小组件,有这两个就够了。

# pip install "capskip>=1.3.0"
from capskip import CapSkip

solver = CapSkip(host="127.0.0.1", port=8080)

result = solver.captchafox("YOUR_SITEKEY", "https://example.com/signup")

print(result["token"])           # goes in cf-captcha-response
print(result.get("userAgent"))   # send this as the User-Agent

返回结果是一个普通的 dict。token 和 code 里是同一个字符串,token 是按它要填入的字段命名的。userAgent 是签发该 token 的浏览器的身份,也就是 CapSkip 自己的浏览器,而不是你发送的任何内容。这个键只在识别报告了 user agent 时才存在,所以要用 result.get() 读取,而不是用方括号;否则,偶尔遇到一次没有它的识别,就会在本该提交表单的那一行抛出 KeyError。

页面 URL 必须是小组件真正所在的那个页面。CaptchaFox 会为每个 key 登记一份允许的域名列表,并在签发任何东西之前检查主机,所以配错页面的 key 每次都会被拒绝,而不是偶尔被拒。重定向地址、短链接或搜索结果都不行。

该方法接受的选项

如果页面加载的是 MAM 包,就把它 script 标签里的包路径作为 api_server 传入。请求发出之前,SDK 会丢弃所有值为 None 的关键字参数,所以你可以在每次调用时都传这个选项,让第 1 步的检查来决定它的值。

# Copied from the script tag on MAM pages.
MAM_PACKAGE = "https://s.uicdn.com/mampkg/@mamdev/core.frontend.libs.captchafox/"

result = solver.captchafox(
    sitekey,
    PAGE_URL,
    # None is dropped, so the standard widget sends nothing extra.
    api_server=MAM_PACKAGE if uses_mam else None,
)

除了 api_server,captchafox() 还接受 proxy、proxytype 和 useragent,另外还有以秒为单位的单次调用 timeout 和 polling_interval。useragent 选项只是为了与其他服务兼容而接受,并且有意不生效,因为 CapSkip 在真实浏览器中识别,用的是该浏览器自身一致的身份。任何其他关键字、为空的 sitekey 或 URL、缺少 type 和 uri 的 proxy 字典,或者 HTTP、HTTPS、SOCKS5、SOCKS5H 之外的代理类型,都会在发出请求之前抛出 ValidationException。

第 3 步:用签发 token 的那个 user agent 提交

在 Python 中识别 CaptchaFox 时,站点是否接受 token,就取决于这一步。默认情况下,requests 发送的 User-Agent 是 python-requests 加上它的版本号,httpx 发送的是 python-httpx 加上它自己的版本号。两者都不是生成这个 token 的浏览器。把返回的值复制到携带 token 的请求上。

# session is the one Step 1 fetched the page with, so it carries its cookies.
ua = result.get("userAgent")
resp = session.post(
    PAGE_URL,  # or wherever the form's action attribute points
    data={"email": "YOUR_EMAIL", "cf-captcha-response": result["token"]},
    # Per request, not on session.headers: the next token may differ.
    headers={"User-Agent": ua} if ua else {},
    timeout=30,
)
print(resp.status_code)

传给 post() 的请求头会合并覆盖 session 的默认值,但只对这一次请求生效,所以 session 会保留它的 cookie,之后的每个请求也照常发出。如果改为设置 session.headers,就会把某一次识别的 user agent 盖到之后的每个请求上,包括下一个 token 的提交。用 httpx 时,要通过同一个 httpx.Client 获取页面并提交,这样 cookie 才能沿用下来;同样把 data、headers 和 timeout 传给它的 post()。

另外两条规则,来自 CaptchaFox 在站点一侧对 token 的校验机制。 每个 token 只能验证一次,而且只在很短的时间内有效,所以要在准备好提交时再识别,识别完立刻提交。另外,要把 token 当作不透明的数据:不要裁剪、重新编码或改动它的格式。有些站点是把它放在 JSON 请求体里发送,而不是通过表单提交,所以请打开 DevTools,手动提交一次表单,然后原样照搬页面发送的内容。

第 4 步:重试、挑战类型和超时

CaptchaFox 弹出哪种挑战,不由你选择。大多数识别根本不会弹出任何挑战,因为浏览器层面的证据本身就足以过关;出现滑块拼图时,CapSkip 通常也能完成它。图片选择和音频挑战很少见,也不予识别:它们会以 ApiException 的形式返回,消息里包含 ERROR_CAPTCHA_UNSOLVABLE,而且几秒内就会返回,不会等到超时结束。重新尝试一次通常会换成别的挑战,所以你有两种重试方式。

  • 在 CapSkip 中。 设置中的 CaptchaFox 部分有 Retries (0-3),默认为 0。调高之后,遇到不支持的挑战,它会自动让 CaptchaFox 换一个挑战;超时的尝试和浏览器错误,它也会自动重试;但被站点拒绝的 key,它从不重试。每多一次重试,单次调用最多会多出一个 Row Timeout 的时长(默认 150 秒),所以调高它时,要给 captchafox() 传一个更长的 timeout。
  • 在你的代码中。 像下面这样捕获异常,然后再调用一次。唯一的问题是,被拒绝的 key 通过 API 看起来也是无法识别,所以用错域名的 key 也会被重试。它每次尝试都在几秒内失败,而且一次也不会成功,这正是辨认它的线索。
from capskip.exceptions import ApiException


def solve_captchafox(sitekey, url, attempts=3, **options):
    for attempt in range(1, attempts + 1):
        try:
            return solver.captchafox(sitekey, url, **options)
        except ApiException as exc:
            # Select, audio and unreadable slide draws come back
            # unsolvable; redraw. Anything else, such as a wrong
            # API key, is final.
            if "UNSOLVABLE" not in str(exc) or attempt == attempts:
                raise

captchafox() 按客户端的 recaptchaTimeout 轮询,默认 300 秒,因为它是一个真实的浏览器会话。CapSkip 自己也有一套计时。一个识别任务最多可以等待 250 秒(Wait Timeout),等 10 个 CaptchaFox 线程(Max. Threads)中的一个空出来,单次尝试则有 150 秒(Row Timeout)。在 Retries 为 0 且有空闲线程时,失败的尝试会以 CapSkip 的 ApiException 形式到达你这里,远早于 SDK 的 300 秒用完;如果识别任务排队很久,SDK 就可能先到达它的时限,改为抛出 TimeoutException。

同时识别多个 token

AsyncCapSkip 是 Python 中真正的 asyncio 客户端,而不是别名,所以 asyncio.gather 可以让多个识别并行执行。把它和 httpx 的异步客户端搭配使用,并让每个 token 在提交之前始终和它自己的 user agent 放在一起。

import asyncio

import httpx
from capskip import AsyncCapSkip

solver = AsyncCapSkip(host="127.0.0.1", port=8080)


async def solve_and_submit(client, sitekey, url, form):
    await client.get(url)  # the form's cookies land in client
    result = await solver.captchafox(sitekey, url)
    ua = result.get("userAgent")
    return await client.post(
        url,
        data={**form, "cf-captcha-response": result["token"]},
        headers={"User-Agent": ua} if ua else {},
    )


async def main(jobs):
    async with httpx.AsyncClient(timeout=30) as client:
        return await asyncio.gather(
            *(solve_and_submit(client, *job) for job in jobs),
            return_exceptions=True,
        )

有两个限制。CapSkip 默认同时运行 10 个 CaptchaFox 识别,其余的会等待空闲线程,最多等到 250 秒的 Wait Timeout,所以在正常识别耗时下,对五十个任务做一次 gather 没有问题;批量更大时,就调高 Max. Threads,或者分成更小的组来 gather。另外,CaptchaFox 不仅给浏览器打分,也给网络打分,所以从同一个地址大量识别,会推高挑战率。在 CapSkip 的 CaptchaFox 设置部分配置代理,或者在每次调用时传入一个带 type 和 uri 的 proxy 字典,并让提交也走同一个出口。更多 asyncio 用法,见 在 Python 中并行识别验证码的指南.

把识别工具跑在另一台机器上

只要脚本和 CapSkip 在同一台 Windows 电脑上,127.0.0.1 就是对的。一旦 Python 代码挪到 VPS、容器、CI runner 或托管 notebook 上,回环地址就指向了错误的机器,第一次识别就会抛出 NetworkException。把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP,上面这些环境都能通过同一套 API 调用它。如果链路要经过公网,请使用静态公网 IP,打开 API 密钥校验,并用一条 Windows Firewall 规则把端口限制在你预期的地址上。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。

SDK 不会自己读取环境变量。像完整示例那样,在你的代码里读取 CAPSKIP_HOST、CAPSKIP_PORT 和 CAPSKIP_API_KEY 并传给构造函数,这样同一个脚本在你的桌面机和服务器上都能运行。

完整可运行示例

# pip install "capskip>=1.3.0" requests
import os
import re

import requests
from capskip import CapSkip
from capskip.exceptions import (ApiException, CapSkipError,
                                TimeoutException, ValidationException)

PAGE_URL = "https://example.com/signup"
# Copied from the script tag on MAM pages.
MAM_PACKAGE = "https://s.uicdn.com/mampkg/@mamdev/core.frontend.libs.captchafox/"

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

html = session.get(PAGE_URL, timeout=30).text
tag = re.search(r'<[^>]*class="(?:[^"]*\s)?captchafox(?:\s[^"]*)?"[^>]*>', html)
key = tag and re.search(r'data-sitekey="([^"]+)"', tag.group(0))
key = key or re.search(r'captchafox[^"]*/api\.js\?key=([^"&]+)', html)
if not key:
    raise SystemExit("No sitekey in the HTML; find it in DevTools.")
api_server = MAM_PACKAGE if "mampkg/@mamdev/core.frontend.libs.captchafox" in html else None

try:
    for attempt in range(1, 4):
        try:
            result = solver.captchafox(key.group(1), PAGE_URL,
                                       api_server=api_server)
            break
        except ApiException as exc:
            # Select, audio and unreadable slide draws come back unsolvable.
            if "UNSOLVABLE" not in str(exc) or attempt == 3:
                raise
            print(f"attempt {attempt}: {exc}")
except ValidationException as exc:
    raise SystemExit(f"not sent: {exc}")
except TimeoutException:
    raise SystemExit("gave up waiting; recaptchaTimeout is 300 seconds")
except CapSkipError as exc:
    # A third unsolvable draw, a refused key, or CapSkip unreachable.
    raise SystemExit(f"solve failed: {exc!r}")

ua = result.get("userAgent")
resp = session.post(
    PAGE_URL,  # or wherever the form's action attribute points
    data={"email": "YOUR_EMAIL", "cf-captcha-response": result["token"]},
    headers={"User-Agent": ua} if ua else {},
    timeout=30,
)
print(resp.status_code, "UA sent:", bool(ua))

如果每次运行时识别都立刻失败,先检查页面 URL,因为在登记域名之外使用的 key,每次都会以同样的方式失败。如果正则什么都没匹配到,说明页面是用脚本构建小组件的,key 在 render 调用里或 Network 标签页里,具体见第 1 步。这个方法背后的原始接口,详见 CaptchaFox API 参考文档.

常见错误及其含义

你所看到的原因修复
CapSkip 返回识别成功的 token 被站点拒绝提交请求是以 python-requests 或 python-httpx 的身份发出的,而不是签发 token 的那个浏览器在该请求上把 result.get("userAgent") 作为 User-Agent 请求头发送
user agent 一致,却仍被拒绝页面加载的是 MAM 包,识别用的却是默认小组件,或者反过来读取 script 标签,并把 api_server 设成与之匹配
读取 userAgent 时抛出 KeyError本次识别没有报告 user agent,而这个键只在报告了时才存在用 result.get() 读取,只在有值时才发送这个请求头
某个 key 每次都立刻抛出 ApiException页面 URL 不在该 key 登记的域名之内,或者 key 本身就不对发送小组件所在的页面,而不是重定向地址或搜索结果,并重新核对 key
偶尔出现包含 ERROR_CAPTCHA_UNSOLVABLE 的 ApiExceptionCaptchaFox 弹出了图片选择或音频挑战,或者 CapSkip 读不懂的滑块变体在代码里重试,或者在 CapSkip 的 CaptchaFox 设置中调高 Retries
第一次能用的 token,第二次失败每个 token 只能验证一次,而且很快过期每次提交都识别一个新的 token,并且识别完立刻提交
随着运行持续,先是挑战增多,然后开始被拒每次识别都来自同一个地址,而 CaptchaFox 会给网络打分通过代理池分散识别请求,并从同一个出口提交
还没发送任何内容就抛出 ValidationExceptionsitekey 或 URL 为空、传了该方法不接受的关键字参数,或者代理类型不被 CapSkip 接受确认 sitekey 已经找到,并修正或去掉错误信息中指出的那个参数
第一次调用就抛出 NetworkExceptionCapSkip 没有在运行,或者主机和端口不对启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式

常见问题

result["code"] 和 result["token"] 有区别吗?

没有,对 CaptchaFox 来说它们是同一个字符串。code 是每个 CapSkip 方法都会填的字段,这能让旧代码不做改动继续工作;token 这个名字则与表单期望的字段对应。新代码里请用 token,这样读起来就和它要填的字段一致。

我可以在 Playwright 或 Selenium 会话中使用这个 token 吗?

可以,只要提交时用的是 CapSkip 返回的 user agent。Playwright 在创建 context 时就固定了 user agent,所以要先识别,再用 browser.new_context(user_agent=ua) 打开 context,加载表单,把 token 写入 cf-captcha-response 字段并提交。在 Selenium 中使用 Chrome 时,要在启动 driver 之前,把带这个值的 user-agent 开关加到 Chrome 选项里,或者用 driver.execute_cdp_cmd("Network.setUserAgentOverride", {"userAgent": ua}) 切换正在运行的会话。如果你的自动化流程在其他方面并不需要浏览器,上面的 requests 方案更简单。

托管平台上的 Python 脚本能连到识别工具吗?

可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址,然后在你的脚本里从 CAPSKIP_HOST 读取这个地址,并传给 CapSkip()。VPS、容器宿主机、CI runner 和托管 notebook 都通过同一套 HTTP API 连接。如果链路要经过公网,请使用静态公网 IP 并配上防火墙规则。识别工具始终运行在你自己的硬件上,因此识别的计数方式不会有任何变化。

这和 C# 版本有什么不同?

调用和规则都一样,不同的是陷阱。在 C# 中,除非你自己添加,否则 HttpClient 根本不发送 User-Agent。在 Python 中,两个常用客户端都会改为发送自己的名字,也就是 python-requests 或 python-httpx,站点同样可以轻易拒绝它们。.NET 这一侧的内容见 C# CaptchaFox 指南.

简短版结论

要在 Python 中识别 CaptchaFox,先从 captchafox 容器、render 调用或 Network 标签页读取 sitekey,并留意页面是否加载了 MAM 包。用 sitekey 和真实的页面 URL 调用 solver.captchafox(),只有 MAM 页面才加上 api_server。把 result["token"] 放进 cf-captcha-response 提交,并以 result.get("userAgent") 作为 User-Agent 请求头,只提交一次,并且立刻提交。遇到无法识别的挑战就重试,用量增长时加上代理,一旦脚本离开识别工具所在的机器,就切换到 Server 模式。

遇到图片选择挑战就重试,这是这个类型唯一需要养成的习惯;而当 本地验证码识别工具 就运行在你自己的机器上时,每次重试只花几秒钟,而不是又一次计费的识别。