如何在 Playwright 中识别 ALTCHA 并填写隐藏字段

solve altcha in playwright - How to Solve ALTCHA in Playwright and Fill the Hidden Field

在 Playwright 中识别 ALTCHA,你永远不要让小组件自己去做这件事。ALTCHA 是工作量证明,而不是图像识别:站点发出一道哈希题,谁能正确作答就放谁通过,代价是 CPU 时间。这里没有东西可看,也没有东西可点,所以浏览器是为流程的其余部分准备的,不是为验证码准备的。抓取页面已经请求到的挑战,在你自己的机器上用几毫秒完成哈希,然后把答案写进表单提交的那个字段。本指南用 Playwright for Python 来完成这件事。

你需要什么

  • CapSkip 1.2.6 或更高版本,运行在一台 Windows 机器上。ALTCHA 支持是在该版本中加入的,更早的版本没有对应的方法可以调用。
  • Python 3.10 或更高版本,已安装 Playwright 和 CapSkip 包,并且至少下载了一个浏览器。
  • 小组件所在页面的 URL。你不需要 sitekey,因为 ALTCHA 没有这个东西。
  • 识别工具的地址。本地模式只在 127.0.0.1 上应答,仅限该设备;服务器模式则监听你的网络地址或公网 IP,这样容器、CI 运行器或另一台机器都能通过同一套 API 访问它。第 4 步会说明该用哪一种,两者都位于 连接设置.
# pip install playwright capskip
pip install playwright capskip
playwright install chromium

第 1 步:在页面打开时抓取挑战

其余一切都取决于这一个值。挑战是一份很小的 JSON 文档,里面包含算法、挑战哈希、盐、签名和一个最大数值,并且由站点签名。在 Playwright 运行过程中有两种方式拿到它,用哪一种取决于页面是怎么搭的。

从小组件上读取端点

小组件元素会写明它将要请求的端点。不要去猜属性名,因为它在不同世代的小组件之间发生过变化。

小组件版本指定挑战的属性
v1 和 v2接口地址用 challengeurl,挑战为内联时另有一个 challengejson 属性
v3 及以后challenge,而这一个属性既可以放 URL,也可以放挑战数据
# pip install playwright capskip
from playwright.sync_api import sync_playwright

PAGE_URL = "https://example.com/signup"

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto(PAGE_URL)

    # v1 and v2 use challengeurl; v3 and later use challenge.
    widget = page.locator("altcha-widget")
    endpoint = widget.get_attribute("challengeurl")
    if not endpoint:
        endpoint = widget.get_attribute("challenge")

小组件 type 属性上的三种交互样式 native、checkbox 和 switch 纯粹是视觉差异。它们提交同样的载荷,差异永远不会传到识别工具那里,所以你不必费心分辨自己看到的是哪一种。单独的 display 属性同样只是视觉层面的。ALTCHA 对它们都做了说明,见 它自己的小组件指南.

或者捕获页面已经发出的响应

光把属性读出来并不总是够用。v3 小组件可能把挑战数据直接放在那个属性里,而不是放一个 URL,所以当值以花括号开头时,要把它按 JSON 解析;而完全由 JavaScript 配置的小组件,在标记里根本没留下任何可读的东西。捕获网络响应能覆盖第二种情况,而且它交给你的是挑战文档本身,不是指向它的指针。请在触发这次请求的动作之前就把等待挂上去,否则请求发生时根本没有人在听。

# Filter out the widget's own script: its URL also contains
# altcha, and it loads before the challenge is ever requested.
is_challenge = lambda r: ("altcha" in r.url
    and "json" in r.headers.get("content-type", ""))

# This fires during navigation only when the widget carries
# auto="onload". Otherwise wrap the click that triggers it.
with page.expect_response(is_challenge) as caught:
    page.goto(PAGE_URL)

challenge = caught.value.json()
print(challenge["algorithm"], challenge["maxnumber"])

在相信那种形态之前,先检查小组件的 auto 属性。它决定验证何时开始,只有 onload 这个值会在导航期间发出请求。如果没有设置,或者设为 onfocus 或 onsubmit,那么在有人碰表单之前什么都不会被抓取,这时要把等待布置在对小组件的点击周围,而不是 goto 周围。

用子串匹配,而不是整个 URL,但绝不要只匹配 altcha 这个词。路径因站点而异,而且常常带着防缓存的查询串,所以精确比较是这一步永远不触发的一个原因,匹配得太松则是另一个原因:小组件脚本通常就是从带 altcha 的路径提供的,它先加载,于是等待会在一段任何 JSON 解析器都不会接受的 JavaScript 上返回。Playwright 对这一模式的说明见 其网络指南.

第 2 步:把挑战交给唯一的 ALTCHA 方法

只有一个方法,完整说明见 ALTCHA 验证码识别页面,两种形式的挑战它都接受。传入端点,识别工具会自己去抓挑战;传入文档,则完全不会发出任何请求。

from capskip import CapSkip

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

# The document from step 1, so nothing is fetched twice.
result = solver.altcha(url=PAGE_URL, challenge_json=challenge)

# Or hand over the endpoint and let CapSkip fetch it.
# result = solver.altcha(url=PAGE_URL, challenge_url=endpoint)

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

浏览器已经见过挑战时,优先传内联文档。这是驱动浏览器唯一会改变建议的地方。发给你浏览器会话的那个挑战,才是站点用来判定你的那一个,所以把这份文档原样交给识别工具,可以让两边保持一致。从端点再取一个挑战本身没有错,但那意味着页面手里拿着一个挑战,而你回答的是另一个,在把挑战绑定到会话的站点上,答案就对不上。这个选项接受一个字典,会替你完成序列化;如果你已经有 JSON 字符串,也可以直接传字符串。

结果里有两个键只属于 ALTCHA。token 键放的是表单需要的 base64 载荷,number 键放的是解开这道挑战的计数值。code 键携带的字符串与 token 相同,所以用哪个都行,但 token 键的命名对应它要填进去的那个字段。极验(GeeTest)的那些键和 Turnstile 的 user agent 在这里都不存在。

识别工具支持哪些算法

旧方案覆盖 SHA-1、SHA-256、SHA-384 和 SHA-512,工作量证明 v2 覆盖 PBKDF2 和迭代 SHA。PBKDF2 是 ALTCHA 自己推荐的默认值,所以线上站点绝大多数都是它。

Argon2id 和 scrypt 是例外,而且它们会被直接拒绝,而不是尝试:要求这两者之一的挑战大约三分之一秒就返回 ERROR_CAPTCHA_UNSOLVABLE,并且永远不会重试,因为内存密集型函数不是重试能解决的问题。在这个类型上,这个结果指向的是算法,而不是一张读不出来的图片,而该错误码另有 一篇专门的指南.

第 3 步:把 token 写进小组件的隐藏字段

小组件把它的载荷提交在一个隐藏 input 里,这个 input 的名字来自小组件自己的 name 属性,默认值是 altcha。和读 challenge 属性一样,要去读这个属性,而不是想当然。在浏览器运行中要由你自己填这个字段,因为没有任何东西验证过小组件,它不会填进任何内容。

# Walk up from the submit button so the field lands in the
# form that actually posts, not in the first form on the page.
SET_ALTCHA_FIELD = """({name, token}) => {
    const button = document.querySelector('button[type=submit]');
    const form = button ? button.form : document.querySelector('form');
    let field = form.querySelector('[name=' + name + ']');
    if (!field) {
        field = document.createElement('input');
        field.type = 'hidden';
        field.name = name;
        form.appendChild(field);
    }
    field.value = token;
}"""

field_name = widget.get_attribute("name") or "altcha"
page.evaluate(SET_ALTCHA_FIELD, {"name": field_name, "token": result["token"]})
page.click("button[type=submit]")

选对表单。注册页上常常有好几个表单,如果提交按钮属于另一个表单,你却把字段追加到页面上的第一个表单,服务器就永远看不到这个值。示例代码从提交按钮向上找,正是为了这个原因。

把字符串原样传过去。token 是一份 JSON 文档的 base64,其中的字段都在服务器的 HMAC 签名覆盖范围内,所以任何看起来像是在收拾整理的动作都会破坏它:去掉空白、解码后重新编码,或者按另一种键顺序重建 JSON。有些集成方式是从 JSON body 的字段里读取载荷,而不是表单字段,小组件也可以配置成用 cookie 传递,所以要看清页面自己的提交发的是什么,然后照做。

浏览器会多出一件普通 HTTP 客户端没有的事:页面可能会对表单跑它自己的脚本。如果提交按钮一直是禁用的,说明页面在等着听到小组件成功的消息,而不是在读那个字段。这里有两个诚实的答案,你选哪一个取决于你想保留页面的多少东西。你可以找出页面在监听什么并满足它,也可以跳过按钮,带着浏览器的 cookie 直接把表单字段 POST 出去,后者通常更短,而且总是更稳定。

第 4 步:当 Playwright 搬到 CI 上之后,识别工具跑在哪里

上面的示例用 127.0.0.1,因为在你的脚本和 CapSkip 共用一台机器时这是对的。识别工具是被你的 Python 代码调用的,不是被浏览器调用,也不是被页面调用,所以决定地址的是测试进程跑在哪里。Playwright 很容易让人忘记这一点,因为浏览器往往已经在别的地方了。

把那个进程搬进官方 Playwright Docker 镜像,或者搬到 CI 运行器上,回环地址现在指向的就是容器,而那里没有任何东西在监听,于是第一次识别就会抛出 NetworkException。把 CapSkip 切到服务器模式,它就改为监听你的网络地址或公网 IP,容器通过同一套 HTTP API 连接过来。当链路要跨公网时,建议使用静态公网 IP,并配一条只放行你预期地址的防火墙规则。服务器模式改变的只是识别工具监听的位置,别的什么都没变:它仍然是你自己的硬件,仍然不计量。

Python 进程运行在哪里用哪种连接模式
在 CapSkip 机器上,驱动本地浏览器Local 模式。127.0.0.1 在这里确实是对的
在同一内网的另一台机器上Server 模式,使用那台机器的内网地址
在 Playwright 容器、CI 运行器或 VPS 中Server mode,配一个固定公网 IP 加一条防火墙规则
在本地,但连接的是远程浏览器本地模式。浏览器从不与识别工具通信

从环境变量里读取主机和端口,这样同一个脚本在两种场合都能用。如果你不想手动传,客户端也会自己读取 CAPSKIP_HOST、CAPSKIP_PORT 和 CAPSKIP_API_KEY。

关于代理有一条 ALTCHA 专属的说明。这里支持代理,但它只用于抓取挑战。没有浏览器会话需要经由它转发,所以它对工作量证明本身没有影响,而当你把挑战文档内联传入时,它更是完全不起作用。

完整可运行示例

import os
from capskip import CapSkip, ApiException, NetworkException, TimeoutException
from playwright.sync_api import sync_playwright

PAGE_URL = "https://example.com/signup"

SET_ALTCHA_FIELD = """({name, token}) => {
    const button = document.querySelector('button[type=submit]');
    const form = button ? button.form : document.querySelector('form');
    let field = form.querySelector('[name=' + name + ']');
    if (!field) {
        field = document.createElement('input');
        field.type = 'hidden';
        field.name = name;
        form.appendChild(field);
    }
    field.value = token;
}"""

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

def is_challenge(r):
    return "altcha" in r.url and "json" in r.headers.get("content-type", "")

with sync_playwright() as p:
    page = p.chromium.launch(headless=True).new_page()

    # Catch, solve and submit with nothing slow in between.
    with page.expect_response(is_challenge) as caught:
        page.goto(PAGE_URL)

    try:
        result = solver.altcha(url=PAGE_URL, challenge_json=caught.value.json())
    except ApiException:
        raise SystemExit("refused: Argon2id, scrypt, or an expired challenge")
    except NetworkException:
        raise SystemExit("solver unreachable: check host and connection mode")
    except TimeoutException:
        raise SystemExit("no answer inside defaultTimeout")

    field_name = page.locator("altcha-widget").get_attribute("name") or "altcha"
    page.fill("input[name=email]", "someone@example.com")
    page.evaluate(SET_ALTCHA_FIELD, {"name": field_name, "token": result["token"]})
    page.click("button[type=submit]")

这四种异常都派生自 CapSkipError,所以改为捕获它,就能在一个代码块里处理 SDK 可能抛出的每一种失败。响应处理方式不同时,像上面那样分别捕获;不需要区分时就捕获 CapSkipError。

其他类型在同一个客户端上的用法是一样的。reCAPTCHA 和 Turnstile 需要 sitekey 和页面 URL,极验需要 gt 值、challenge 和页面 URL,图片识别需要文件路径、URL 或 base64。该包提供的全部方法见 Python 验证码识别页面,浏览器方面更完整的内容见 Playwright 验证码识别页面.

常见错误及其含义

你所看到的原因修复
两个小组件属性都返回 None小组件完全是用 JavaScript 配置的,所以这两个名字在标记里都不存在改为捕获网络响应,这种做法不依赖标记
get_attribute 卡住 30 秒然后抛出异常小组件元素从未出现,所以定位器一直等到默认超时对照渲染后的页面检查选择器,然后退回到捕获响应
响应等待超时小组件没有把 auto 属性设为 onload,所以什么都没被抓取,或者等待是在导航之后才布置的把触发抓取的动作包起来,并在它之前打开上下文管理器
捕获到的响应出现 JSON 解析错误等待是在小组件自己的脚本上返回的,它的 URL 里同样含有 altcha在过滤条件里加上 JSON 内容类型
刚刚抓到的挑战却报 ApiException内联的挑战已经过期,所以识别工具拒绝了它,而没有去做哈希一口气完成重新抓取和识别,或者传端点让识别工具自己重新抓取
日志显示 token 已经识别出来,验证却干脆失败挑战在识别和提交之间过期了抓取、识别和提交之间不要夹任何慢动作
大约三分之一秒后,在 ApiException 里收到 ERROR_CAPTCHA_UNSOLVABLE该挑战使用了 Argon2id 或 scrypt没什么可重试的。这两种算法是按设计直接拒绝的
第一次识别时抛出 NetworkExceptionCapSkip 没有运行,或者脚本在容器里却指向回环地址启动 CapSkip,然后在本地模式和服务器模式之间做选择
表单提交了,服务器却报告 altcha 值缺失隐藏字段被追加到了页面上另一个表单里去查询提交按钮所属的那个表单
提示 120 秒的 TimeoutException识别工具没有在默认轮询超时内给出结果检查识别工具是否在运行且没有饱和。调高上限只会让同样的结果来得更晚
提交按钮始终不会变为可用页面把它卡在自己的脚本看到小组件成功这件事上直接 POST 表单字段,或者满足页面所监听的那件事
调用时抛出 ValidationException两个挑战选项都没有给,或者传了 ALTCHA 不接受的选项传端点或者传文档,其他的都去掉

常见问题

识别 ALTCHA 到底需要浏览器吗?

不需要。ALTCHA 是一道哈希题,所以用 CPU 就能解出来,答案里不涉及任何浏览器。如果你打开 Playwright 的唯一理由就是验证码,那就把它关掉:用 HTTP 客户端抓取挑战,再把 token 提交回去,具体做法见 纯 Python 版指南 。当流程的其余部分确实需要一个真实页面时,Playwright 才有它的价值,比如会写入 cookie 的登录、多步表单,或者在脚本里渲染标记的站点。

Playwright 能从 Docker 或 GitHub Actions 里访问到识别工具吗?

可以,用服务器模式。在连接设置里把 CapSkip 从回环地址切到你的网络地址或公网 IP,然后把主机环境变量指向它。之后容器、运行器和识别工具说的就是它们在同一台机器上会说的那套 HTTP API。链路跨公网时请使用静态公网 IP,并用防火墙规则限制访问。无论哪种情况,识别工具都留在你自己的硬件上,所以授权和识别次数都不会有任何变化。

一个 ALTCHA token 能保持多久有效?

不久,而且由站点决定。有些窗口不到两分钟就关闭。过期时,站点会用一个干脆的验证失败来拒绝这个答案,看上去和答错一模一样,响应里没有任何东西能告诉你是哪一种。所以不要提前囤挑战,不要在浏览器又走了三个页面的时候把 token 停在变量里,更不要在有人填表的时候一直攥着一个 token。重新识别一次只要几毫秒,比搞清楚一个过期 token 为什么失败便宜得多。

Playwright 自己的超时会不会把识别过程掐断?

不会,因为识别不是一次 Playwright 调用。默认的 30 秒动作超时和导航超时管的是点击、等待和页面加载,而你的识别调用只是夹在两者之间的普通 Python 代码。真正生效的上限是客户端自己的 120 秒默认轮询超时,ALTCHA 用的是这个,而不是更长的 reCAPTCHA 那个,因为它是 CPU 计算而不是浏览器会话。要提防的反而是包在整个测试外面的限制,比如按测试计时的插件超时,或者 CI 作业时限。

简短版结论

抓住小组件请求的那个挑战,从属性上拿或者从响应里拿都行,把这份文档连同页面 URL 一起传给唯一的 ALTCHA 方法,然后把 token 写进隐藏字段,字段名由小组件自己的 name 属性决定,而且要写进真正会提交的那个表单。传递途中不要改动 token。把抓取、识别和提交放在一起,因为这个窗口可能不到两分钟就关闭,而过期的挑战和答错是无法区分的。脚本一旦不再和识别工具共用一台机器,就立刻切到服务器模式。

最后还有一点,它会改变你设计重试的方式。因为通往 本地验证码识别工具 的这条路径是在你自己已有的机器上计算工作量证明,丢掉一个挑战只花掉你自己 CPU 的几毫秒,除此之外没有任何代价,所以你完全可以重新加载页面、取一个新的挑战,而不必在一次长时间运行中勉强维持一个过期 token。