如何在 Python 中识别 ALTCHA,全程不用浏览器

solve altcha in python - How to Solve ALTCHA in Python Without Running a Browser

在 Python 里识别 ALTCHA,只需要一个 HTTP 客户端,别的什么都不用。ALTCHA 属于工作量证明,而不是图像识别:站点下发一个挑战,想通过的人就得不停做哈希,直到找出满足它的那个数字。没有任何东西需要去看,所以不涉及浏览器、WebDriver 和 user agent,答案是算出来的,不是猜出来的。这也意味着一次识别是确定性的。要么在几毫秒内找到那个数字,要么就是挑战本身格式有误或者已经过期。CapSkip 从 1.2.6 版开始支持这一类型,Python SDK 把它暴露为一个方法。

你需要什么

  • Windows 机器上的 CapSkip 1.2.6 或更高版本。ALTCHA 就是在该版本中加入的。
  • Python 3.10 或更高版本,以及 CapSkip 包。
  • 承载小组件的页面 URL,以及小组件获取挑战的接口地址。
  • 识别器的地址。Local 模式监听 127.0.0.1,只服务于本机;Server 模式监听你的网络地址或公网 IP,让另一台机器也能访问到它。两者都设置在 连接设置.
# pip install capskip
pip install capskip requests

第 1 步:识别调用,以及该把它指向哪个地址

传入页面 URL 和挑战接口地址。CapSkip 会自己去取挑战,不停哈希直到找出计数值,然后把表单需要的载荷交回给你。

# pip install capskip
from capskip import CapSkip

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

# CapSkip fetches the challenge, then brute-forces the counter.
result = solver.altcha(
    url="https://example.com/signup",
    challenge_url="https://example.com/altcha/challenge",
)

print(result["token"])    # base64 payload for the form field
print(result["number"])   # the counter that satisfied it

字典里有两个键只属于 ALTCHA。token 键保存 base64 载荷,number 键保存解出该挑战的计数器。code 键携带的字符串和 token 相同,所以用哪个都行,但 token 这个名字对应的正是它要填入的那个字段。

两代 ALTCHA 都会上报这个计数器,这比听上去更有用,因为两者的载荷对它该放在哪里并不一致。旧版载荷把它放在顶层,工作量证明 v2 的载荷则不放,而是把它留在 solution 对象里。CapSkip 会从自己 API 响应的 solution 对象中读出它,所以无论站点用的是哪一代,你拿到的都是同一个字段。

只有在共用同一台机器时,回环地址才是对的

脚本换地方跑时,需要改的就是那个 host 参数这一行。只要 Python 进程和识别工具在同一台机器上,127.0.0.1 就是正确的。而一旦脚本跑在容器里、跑在 VPS 上、跑在 CI runner 上,或者作为托管平台上的定时任务运行,回环地址指向的就变成那个环境本身,第一次调用就会抛出 NetworkException。

答案是 Server 模式。它让 CapSkip 监听你的内网地址或公网 IP,而不是回环地址,这样上面那些环境都能通过 API 访问到同一个识别工具。如果流量要跨公网,请使用静态公网 IP,并把防火墙规则限制在你预期的地址上。其他什么都不变:识别工具仍然跑在你自己的硬件上,仍然不计用量,改变的只有它的地址。这个值应该从环境变量里读,而不是写死在代码里,因为客户端本来就会去找 CAPSKIP_HOST、CAPSKIP_PORT 和 CAPSKIP_API_KEY。

第 2 步:挑战从哪里来,以及传入它的两种方式

打开 DevTools,切到 Network 标签页,重新加载页面。小组件会为获取挑战发出一个请求,路径中通常带有 altcha。这个 URL 就是你要传的值。它返回的 JSON 就是挑战文档,你也可以改传这份文档。

不要猜属性名,直接看页面源码,因为它在不同的小组件版本之间变过。

小组件版本指定挑战的属性
v1 和 v2接口地址用 challengeurl,挑战为内联时另有一个 challengejson 属性
v3 及以后challenge,这一个属性既接受 URL,也接受挑战数据

小组件的样式在这里无关紧要。native、checkbox 和 switch 三种形态提交的载荷完全相同,这点差异也从不会传到识别工具,所以没什么需要你去检测。想要挑战的逐字段说明,可以看 ALTCHA 自己维护的 小组件与服务端文档.

当你的爬虫已经拿到文档时,直接把它交过去,完全跳过获取这一步。这个参数接收字典并替你完成序列化;如果你手上是 JSON 字符串,也可以直接传字符串。

# The document is already here, so no request goes out.
result = solver.altcha(
    url="https://example.com/signup",
    challenge_json={
        "algorithm": "SHA-256",
        "challenge": "YOUR_CHALLENGE_HASH",
        "salt": "YOUR_SALT",
        "signature": "YOUR_SIGNATURE",
        "maxnumber": 1000000,
    },
)

两个一起传是允许的,此时内联文档优先,因为再请求一次只会重新拿到你刚刚给出的东西。两条路径有一个差别在任务排队时很关键:已经过期的内联挑战会被立刻拒掉,而不是白白去做哈希;而给接口地址则允许识别工具在第一个挑战于等待期间失效后,再取一个新的。

识别工具支持哪些算法

一个方法就能处理两代方案。旧版方案支持 SHA-1、SHA-256、SHA-384 和 SHA-512,工作量证明 v2 则支持 PBKDF2 和迭代式 SHA。PBKDF2 是 ALTCHA 官方推荐的默认算法,所以这就是线上绝大多数站点的情况。

Argon2id 和 scrypt 是例外。它们会被直接拒绝而不是尝试:要求其中任一算法的挑战,大约三分之一秒后就以 ERROR_CAPTCHA_UNSOLVABLE 返回,并且永不重试,因为内存密集型函数不是重试能解决的问题。

第 3 步:在 token 过期之前,原封不动地把它回传

小组件把载荷提交在一个名为 altcha 的表单字段里。请把字符串按拿到的样子原样发出去。

import requests

# No strip(), no re-encoding, no rebuilding the JSON.
r = requests.post("https://example.com/signup", data={
    "email": "someone@example.com",
    "altcha": result["token"],
})

那段载荷是一个 JSON 文档的 base64 编码,文档中的字段都被服务端的 HMAC 签名覆盖,所以任何改动都会让它失效。对它调用 strip、解码后再重新编码,或者按另一种键顺序重建字典,都会产出一个被站点拒绝的 token。有些集成方式是从 JSON 请求体的某个字段里读取它,而不是从表单字段读取,所以要看清页面自身的提交发送了什么,然后照着做。

如果站点拒绝了一个在你日志里显示已识别的 token,那么更可能是挑战过期,而不是 token 损坏。挑战窗口很短,有些不到两分钟就关闭;过期的挑战返回的只是一次普通的验证失败,看起来和答错完全一样。请把抓取、识别和提交放在同一个工作单元里,绝不要在等人填表的过程中一直握着一个 token。

真正限制你的并不是客户端自身的轮询超时,因为挑战窗口关闭的时间远早于其中任何一个。不过还是值得知道适用的是哪一个,因为 ALTCHA 落在较短的那一边。它属于 CPU 计算,而不是一次浏览器会话,所以用的是默认轮询超时,而不是更长的 reCAPTCHA 超时。

构造函数选项默认值它的适用范围
defaultTimeout120 秒ALTCHA 与图片验证码的轮询
recaptchaTimeout300 秒reCAPTCHA、Turnstile 与极验(GeeTest)的轮询
pollingInterval最长 5 秒轮询从 0.25 秒开始,逐步退避到这个值

第 4 步:批量识别,又不让每个挑战都失效

Python 是唯一一个异步客户端属于独立实现的 SDK:这里的 AsyncCapSkip 是真正的 asyncio。在 Node.js 和 .NET 包里,这个名字只是普通客户端的别名,而那些客户端的方法本来就是异步的;PHP 则自始至终都是同步的。

真正的约束不是并发,而是新鲜度。先取一百个挑战再逐个识别是错的形状,因为批次还没跑完,最早的那些就已经过期了。应该在同一个任务里完成取挑战和识别,每个页面一个任务,再由 gather 负责扇出。

# pip install capskip
import asyncio
from capskip import AsyncCapSkip

async def solve_one(solver, page_url, challenge_url):
    # One fresh challenge per page, fetched and solved together.
    result = await solver.altcha(url=page_url, challenge_url=challenge_url)
    return page_url, result["token"]

async def main():
    solver = AsyncCapSkip()
    pages = [
        ("https://example.com/signup", "https://example.com/altcha/challenge"),
        ("https://example.com/contact", "https://example.com/altcha/challenge"),
    ]
    for page_url, token in await asyncio.gather(
        *(solve_one(solver, p, c) for p, c in pages)
    ):
        print(page_url, token[:24])

asyncio.run(main())

工作量证明是 CPU 密集型的,所以上限取决于核心数,而不是并发连接数;这个上限值得在你真正要跑的那台机器上实测,而不是靠猜。批量识别的通用做法,包括那些等待时间来自浏览器会话而不是哈希运算的类型,都在 并行识别验证码的指南.

完整可运行示例

# pip install capskip
import os
import requests
from capskip import CapSkip, ApiException, NetworkException, TimeoutException

solver = CapSkip(host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"), port=8080)

try:
    result = solver.altcha(
        url="https://example.com/signup",
        challenge_url="https://example.com/altcha/challenge",
    )
    # Submit here, while the challenge is still fresh.
    r = requests.post("https://example.com/signup", data={
        "email": "someone@example.com",
        "altcha": result["token"],
    })
    print(r.status_code, "solved with counter", result["number"])
except ApiException as exc:
    # ERROR_CAPTCHA_UNSOLVABLE here means Argon2id or scrypt.
    print("refused:", exc)
except NetworkException:
    print("CapSkip is not answering on that host and port")
except TimeoutException:
    print("gave up waiting, which on this type means something is wrong")

其他类型的调用形状相同,只是换一个方法。reCAPTCHA v2、v3 和 Enterprise 共用一个调用,Turnstile 一个,极验(GeeTest)v3 一个,图片验证码一个,完整列表见 Python 验证码识别页面.

Node.js、PHP 和 .NET 的包暴露出来的是同样的方法名。每一个 CapSkip SDK 的说明都在 SDK 页面.

常见错误及其含义

你所看到的原因修复
站点只返回一次普通的验证失败,而这个 token 本身是干净识别出来的挑战在表单提交之前就过期了在同一个函数里完成获取、识别和提交
三分之一秒后在 ApiException 里收到 ERROR_CAPTCHA_UNSOLVABLE挑战要求使用 Argon2id 或 scrypt没什么可重试的。这两种是故意被拒绝的
调用时抛出 ValidationException两个挑战参数都没有传,或者传了一个 ALTCHA 不接受的参数传挑战接口地址或挑战文档,其余的都去掉
还没开始任何哈希就抛出 NetworkExceptionCapSkip 没有在运行,或者主机和端口不对启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式
读取 token 键时抛出 KeyError只有 ALTCHA 的结果才带有这个键调用 altcha 方法,或者读 code 键,它保存的是同一个字符串
一个批次里最先识别的失败了,最后的反而通过挑战是提前一次性取好的,在排队期间就过期了在每个任务内部取挑战,而不是在 gather 之前
每次都被站点拒绝的 token某个环节对载荷做了裁剪或重新编码把字符串原样传过去,不要改动

常见问题

处理 ALTCHA 页面需要 Selenium 或 Playwright 吗?

ALTCHA 这一部分不需要。挑战是一道哈希题,答案是你填进表单字段的一个字符串,所以 requests 或 httpx 就够了,识别在几毫秒内完成。如果页面的其余部分确实需要浏览器,你还是会想用浏览器,比如会话 cookie 是由 JavaScript 设置的,或者表单是在客户端渲染的。这种情况下就让浏览器负责导航,而 token 直接调用识别工具去拿,不要试图让小组件自己跑起来。

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

可以。在连接设置里打开 Server 模式,让 CapSkip 监听一个网络地址而不是回环地址,然后把 CAPSKIP_HOST 指向它。Docker 容器、VPS、托管平台上的定时任务和 CI runner 的连接方式完全一样,都是通过同一个 HTTP API。如果链路要跨公网,请使用静态公网 IP,并配一条只放行你预期地址的防火墙规则。无论哪种方式,识别工具都仍然跑在你自己的硬件上,所以授权和无限量识别都不受影响。

为什么 ALTCHA 比 reCAPTCHA 识别得快这么多?

因为两者要的东西不一样。reCAPTCHA 和 Turnstile 要的是有一个带着可信历史的浏览器在场的证据,这需要真实的会话和真实的时间。ALTCHA 只要 CPU 被消耗过的证明,所以这份工作就是一个带已知停止条件的哈希循环。这也是为什么答案是确定的,而不是一种判断:存在一个满足挑战的数字,要么它被找到,要么就是挑战本身坏了。代价是这个数字在一两分钟之后就一文不值。

同一个 token 能在多个请求里复用吗?

不能,而且值得把原因说清楚。载荷是针对某一个具体挑战签名的,那个挑战只下发一次,服务端还会跟踪它,所以同一个 token 的第二次提交,正是这套设计要阻止的重放。每提交一次就识别一次。在这里这样做是负担得起的,在按次计费的服务上则不然,因为这份工作只是你自己 CPU 的几毫秒,而不是一次计费调用,所以没有理由去缓存一个随手就能重新算出来的东西。

简短版结论

从小组件上读出挑战接口地址,把它和页面 URL 一起传给那个唯一的 ALTCHA 方法,然后把 token 不加改动地填进名为 altcha 的字段。把获取、识别和提交放在一起,因为挑战可能在两分钟内就失效,而过期的挑战只会被报成一句普通的验证失败。要批量处理,就用异步客户端扇出,并在每个任务内部各取各的挑战,而不是先把它们收集起来。只有 Argon2id 和 scrypt 会给你 ERROR_CAPTCHA_UNSOLVABLE。

有一个结论值得写进你的重试逻辑。本地运行时, 验证码识别工具 是在你本来就拥有的机器上做哈希的,所以一次白费的识别代价是几毫秒,而不是额度。因此面对失效的挑战,正确的反应是取一个新的再来一次。