如何在 Selenium 中识别 ALTCHA,不用拦截网络请求

在 Selenium 中识别 ALTCHA,永远不需要等小组件自己跑完。ALTCHA 属于工作量证明,而不是图像识别:站点下发一道哈希题,客户端必须找出满足它的那个数字才能被放行。没有任何东西需要去看,也没有任何东西需要去点,所以浏览器是为流程的其余部分准备的,不是为验证码准备的。Selenium 没有 Playwright 那种一次调用就能等响应的等价写法,而这正是少数几个完全不吃亏的场景之一:小组件会在一个属性里写明自己的挑战接口地址,而识别工具需要的就只有这个。
你需要什么
- CapSkip 1.2.6 或更高版本,运行在一台 Windows 机器上。ALTCHA 支持是在该版本中加入的,更早的版本没有对应的方法可以调用。
- Python 3.10 或更高版本,已安装 Selenium 和 CapSkip 包,以及一个版本匹配的浏览器。
- 承载小组件的页面 URL。这里没有 sitekey,因为 ALTCHA 根本就没有这个东西。
- 识别工具的地址。本地模式在 127.0.0.1 上应答,只服务于这一台设备;服务器模式监听你的网络地址或公网 IP,让 Grid 节点、CI runner 或另一台机器都能通过同一套 API 访问到它。第 5 步会讲哪一种适用于你,两者都设置在 连接设置.
# pip install selenium capskip pip install selenium capskip
第 1 步:从小组件上读出挑战接口地址
小组件元素会写明它将向哪个接口索取挑战。具体由哪个属性承载,取决于小组件的版本,所以请直接看页面源码,不要凭猜。
| 小组件版本 | 指定挑战的属性 |
|---|---|
| v1 和 v2 | 接口地址用 challengeurl,挑战为内联时另有一个 challengejson 属性 |
| v3 及以后 | challenge,这一个属性既接受 URL,也接受挑战数据本身 |
这里有两个 Selenium 的习性很关键,而且都不明显。Selenium 不会自动等待元素,所以在页面把小组件放进 DOM 之前就去查询它,你得到的是 NoSuchElementException,而不是一次重试。另外,普通的属性调用是一种混合行为:它会先尝试同名的 JavaScript property,找不到时才回退到标记。而 Web Component 会为自己的属性定义同名 property,所以拿回来的东西取决于小组件的构建方式,而不是取决于你能读到的 HTML。DOM 属性调用没有这层回退,返回的就是标记里写的内容,这才是你想要的。Python API 参考文档对这个差别的说明,见 WebElement 页面.
# pip install selenium capskip
from urllib.parse import urljoin
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
PAGE_URL = "https://example.com/signup"
driver = webdriver.Chrome()
driver.get(PAGE_URL)
# Nothing auto-waits in Selenium, so wait for the element.
widget = WebDriverWait(driver, 15).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "altcha-widget"))
)
# v1 and v2 use challengeurl or challengejson; v3 uses challenge.
value = (widget.get_dom_attribute("challengeurl")
or widget.get_dom_attribute("challengejson")
or widget.get_dom_attribute("challenge"))
if value is None:
raise SystemExit("no challenge attribute on the widget")那三个属性里有两个给你的是挑战文档本身,而不是指向它的指针,所以在把拿到的值当成 URL 之前,先判断一下它究竟是什么。而 URL 还要多走一步:标记里通常写的是同源路径,比如一个斜杠开头再加一个目录名,HTTP 客户端没法直接请求它。请先用页面 URL 把它解析成绝对地址。
import json
challenge, endpoint = None, None
if value.lstrip().startswith("{"):
# challengejson, or a v3 challenge holding the data inline.
challenge = json.loads(value)
else:
# A path in the markup becomes an absolute URL here.
endpoint = urljoin(PAGE_URL, value)小组件 type 属性上的三种交互形态,native、checkbox 和 switch,纯粹是视觉差异,从不会传到识别工具;另外那个独立的 display 属性同样只影响外观。ALTCHA 对这些的完整说明见 它自己的小组件指南.
第 2 步:用浏览器的会话去取挑战
只要接口是公开的,这一步就可以跳过,而且很多接口确实是公开的。把 URL 交给 CapSkip,它会自己去取挑战。这一步之所以存在,是因为有些站点会把挑战绑定到发起请求的那个会话上,而识别工具是从你的机器上、不带任何 cookie 去取的,所以它拿回来的挑战不属于任何人。于是答案是对的,站点却照样拒绝。
修法是在浏览器的会话里去取挑战,然后传挑战文档而不是指针。把 Selenium 已经持有的 cookie 复制进一个 HTTP 客户端,带上同一个 user agent,自己去请求。
import requests
if endpoint:
session = requests.Session()
for cookie in driver.get_cookies():
session.cookies.set(cookie["name"], cookie["value"],
domain=cookie["domain"])
session.headers["User-Agent"] = driver.execute_script(
"return navigator.userAgent")
# Referer matters on sites that check where the ask came from.
challenge = session.get(endpoint,
headers={"Referer": PAGE_URL}).json()那一行上的 JSON 解码错误是一种有用的失败。它说明接口返回的是 HTML,通常是登录页或者同意墙,这就告诉你请求是以陌生人的身份发出去的,也说明这一步做对了。取完挑战就立刻开始识别。窗口很短,而且计时是从站点下发挑战那一刻开始的,不是从你想起来要用它的时候开始的。
第 3 步:把挑战交给那个唯一的 ALTCHA 方法
只有一个方法,完整说明见 ALTCHA 验证码识别页面,而且它接受两种形式的挑战。传挑战文档,就一个请求都不会发出去。传接口地址,识别工具会自己去取,包括在任务排队太久、第一份挑战已经过期时再取一份新的。
from capskip import CapSkip solver = CapSkip(host="127.0.0.1", port=8080) # The document from step 2, 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
结果里有两个键只属于 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 返回,并且永不重试,因为内存密集型函数不是重试能解决的问题。在这个类型上,这个结果指向的是算法,而不是一张看不清的图片,而这个错误码另有 一篇专门的指南.
第 4 步:把 token 写进隐藏字段
小组件把载荷提交在一个隐藏 input 里,这个字段的名字来自它自己的 name 属性,默认值是 altcha。请像读挑战属性那样把它读出来,不要凭猜。这一次运行中没有任何东西验证过小组件,所以也没有任何东西填过那个字段,得由你自己来填。
Selenium 可以把 WebElement 直接传进脚本,所以用按钮本身来定位正确的表单是最干净的做法。把提交按钮传过去,读它的 form 属性:注册页往往带着好几个表单,如果按钮属于其中一个,你却把字段追加到了页面上的第一个表单里,服务端就永远看不到这个值。
SET_FIELD = """
const form = arguments[0].form || document.querySelector('form');
let field = form.querySelector('[name="' + arguments[1] + '"]');
if (!field) {
field = document.createElement('input');
field.type = 'hidden';
field.name = arguments[1];
form.appendChild(field);
}
field.value = arguments[2];
"""
button = driver.find_element(By.CSS_SELECTOR, "button[type=submit]")
field_name = widget.get_dom_attribute("name") or "altcha"
driver.execute_script(SET_FIELD, button, field_name, result["token"])
button.click()把字符串原样传过去。token 是一份 JSON 文档的 base64,其中的字段都在服务器的 HMAC 签名覆盖范围内,所以任何看起来像是在收拾整理的动作都会破坏它:去掉空白、解码后重新编码,或者按另一种键顺序重建 JSON。有些集成方式是从 JSON body 的字段里读取载荷,而不是表单字段,小组件也可以配置成用 cookie 传递,所以要看清页面自己的提交发的是什么,然后照做。
如果提交按钮始终没法点击,说明页面卡住它的条件是自己的脚本听到小组件成功,而不是那个字段里有没有值。这里有两个老实的答案。要么找出页面在监听什么并满足它,要么干脆跳过按钮,带着浏览器已有的 cookie 直接把表单的字段 POST 出去,后者通常更短,而且总是更稳。
第 5 步:脚本换地方跑之后,识别工具在哪里
示例里用的是 127.0.0.1,因为只要你的脚本和 CapSkip 在同一台机器上,这就是对的。识别工具是被你的 Python 代码调用的,不是被浏览器调用,也不是被页面调用,所以地址取决于测试进程跑在哪里,而不是 Chrome 跑在哪里。这一点在 Selenium 里特别容易搞反,因为浏览器本来就经常已经在别的地方了。
| Python 进程运行在哪里 | 用哪种连接模式 |
|---|---|
| 在 CapSkip 机器上,驱动本地浏览器 | Local 模式。127.0.0.1 在这里确实是对的 |
| 在你自己的机器上,驱动一个远程 WebDriver 或 Grid 节点 | 本地模式。浏览器从不与识别工具通信 |
| 在同一内网的另一台机器上 | 服务器模式,用识别工具所在机器的内网地址 |
| 在容器里、在 CI runner 上或者在 VPS 上 | Server mode,配一个固定公网 IP 加一条防火墙规则 |
把 CapSkip 切换到服务器模式,它就会监听你的网络地址或公网 IP,而不是回环地址,runner 通过它在本机时会用的那套 HTTP API 连过来。如果链路要跨公网,建议使用静态公网 IP,并配一条只放行你预期地址的防火墙规则。服务器模式只改变识别工具监听在哪里,别的什么都不变:它仍然是你自己的硬件,仍然不计用量。把测试本身跑在 Grid 上还有另一套坑,单独讲在 Selenium Grid 指南.
从环境变量里读取主机和端口,这样同一个脚本在两种场合都能用。客户端不会自己读取 CAPSKIP_HOST 或 CAPSKIP_PORT,所以要把它们传给构造函数,就像下面的完整示例那样。
关于代理有一条 ALTCHA 专属的说明。这里支持代理,但它只用于抓取挑战。没有浏览器会话需要经由它转发,所以它对工作量证明本身没有影响,而当你把挑战文档内联传入时,它更是完全不起作用。
完整可运行示例
import json, os, requests
from urllib.parse import urljoin
from capskip import CapSkip, ApiException, NetworkException, TimeoutException
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
PAGE_URL = "https://example.com/signup"
SET_FIELD = """
const form = arguments[0].form || document.querySelector('form');
let field = form.querySelector('[name="' + arguments[1] + '"]');
if (!field) {
field = document.createElement('input');
field.type = 'hidden';
field.name = arguments[1];
form.appendChild(field);
}
field.value = arguments[2];
"""
solver = CapSkip(
host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
port=int(os.environ.get("CAPSKIP_PORT", 8080)),
)
driver = webdriver.Chrome()
try:
driver.get(PAGE_URL)
widget = WebDriverWait(driver, 15).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "altcha-widget"))
)
value = (widget.get_dom_attribute("challengeurl")
or widget.get_dom_attribute("challengejson")
or widget.get_dom_attribute("challenge"))
if value is None:
raise SystemExit("no challenge attribute on the widget")
if value.lstrip().startswith("{"):
challenge = json.loads(value)
else:
endpoint = urljoin(PAGE_URL, value)
session = requests.Session()
for cookie in driver.get_cookies():
session.cookies.set(cookie["name"], cookie["value"],
domain=cookie["domain"])
session.headers["User-Agent"] = driver.execute_script(
"return navigator.userAgent")
challenge = session.get(endpoint,
headers={"Referer": PAGE_URL}).json()
try:
result = solver.altcha(url=PAGE_URL, challenge_json=challenge)
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")
button = driver.find_element(By.CSS_SELECTOR, "button[type=submit]")
field_name = widget.get_dom_attribute("name") or "altcha"
driver.find_element(By.NAME, "email").send_keys("[email protected]")
driver.execute_script(SET_FIELD, button, field_name, result["token"])
button.click()
finally:
driver.quit()四个异常都派生自 CapSkipError,所以改成捕获这一个,就能在一个代码块里处理 SDK 可能抛出的全部失败。当不同失败需要不同响应时,就像上面那样捕获具体的那几个;不需要区分时就捕获 CapSkipError。注意你捕获的是哪一个 TimeoutException:CapSkip 的和 Selenium 自己的重名,所以如果两者都在作用域里,请给其中一个起别名导入。
其他类型在同一个客户端上的用法是一样的。reCAPTCHA 和 Turnstile 需要 sitekey 和页面 URL,极验需要 gt 值、challenge 和页面 URL,图片识别需要文件路径、URL 或 base64。该包提供的全部方法见 Python 验证码识别页面,浏览器方面更完整的内容见 Selenium 验证码识别页面.
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 在小组件上抛出 NoSuchElementException | 元素还没有被插入 DOM,脚本生成标记时就会这样,而 Selenium 自己不会等 | 把查找包在一个显式等待里,等元素出现 |
| 三个属性读出来都是 None | 小组件完全是用 JavaScript 配置的,所以这三个名字在标记里都不存在 | 从页面自己的脚本里读出配置,或者退一步,用网络面板里能看到的那个接口地址 |
| 页面源码里明明有值,属性却读出空字符串 | 普通属性调用返回的是 Web Component 定义的那个 property,而它是空的 | 改用 DOM 属性调用,它读的是标记,没有 property 回退 |
| HTTP 客户端抛出 MissingSchema,并指出一个以斜杠开头的值 | 标记里存的是同源路径,DOM 属性调用会原样把它返回 | 在请求之前、以及在把它传给识别工具之前,先用页面 URL 解析成绝对地址 |
| 属性值以花括号开头 | 这是 challengejson 属性,或者是把挑战文档内联携带、而不是给出 URL 的 v3 小组件 | 把它按 JSON 解析,作为内联挑战传进去 |
| 请求接口地址时出现 JSON 解码错误 | 接口返回的是 HTML,说明请求到达时没有带上浏览器的会话 | 请求之前先把 cookie 和 user agent 复制过去 |
| 刚刚抓到的挑战却报 ApiException | 内联的挑战已经过期,所以识别工具拒绝了它,而没有去做哈希 | 一口气完成获取和识别,或者直接传接口地址让识别工具自己重取,前提是那个接口没有绑定会话 |
| 大约三分之一秒后,在 ApiException 里收到 ERROR_CAPTCHA_UNSOLVABLE | 该挑战使用了 Argon2id 或 scrypt | 没什么可重试的。这两种算法是按设计直接拒绝的 |
| 日志显示 token 已经识别出来,验证却干脆失败 | 挑战在识别和提交之间过期了 | 抓取、识别和提交之间不要夹任何慢动作 |
| 第一次识别时抛出 NetworkException | CapSkip 没有运行,或者脚本在容器里却指向回环地址 | 启动 CapSkip,然后在本地模式和服务器模式之间做选择 |
| 提示 120 秒的 TimeoutException | 识别工具没有在默认轮询超时内给出答案,ALTCHA 用的是这个默认值,而不是更长的 reCAPTCHA 超时 | 检查识别工具是否在运行且没有饱和。调高上限只会让同样的结果来得更晚 |
| 表单提交了,服务器却报告 altcha 值缺失 | 隐藏字段被追加到了页面上另一个表单里 | 像示例代码那样,从提交按钮上读 form 属性 |
| 提交按钮一直是禁用状态 | 页面把它卡在自己的脚本看到小组件成功这件事上 | 直接 POST 表单字段,或者满足页面所监听的那件事 |
| 调用时抛出 ValidationException | 两个挑战选项都没有给,或者传了 ALTCHA 不接受的选项 | 传端点或者传文档,其他的都去掉 |
常见问题
识别 ALTCHA 到底需要浏览器吗?
不需要。ALTCHA 是一道哈希题,答案是用 CPU 算出来的,整个求解过程不涉及浏览器。如果你打开 Selenium 的唯一理由就是验证码,那就把它关掉:用 HTTP 客户端取回挑战,再把 token 提交回去,这一整套做法都在 纯 Python 版指南 里走了一遍。Selenium 真正有价值的地方,是流程的其余部分确实需要一个真实页面的时候,比如一次会设置 cookie 的登录、一个多步表单,或者只有脚本跑完才存在的标记。
为什么小组件的属性读出来是空的?
因为普通属性调用是一种混合行为。元素定义了同名 JavaScript property 时它就返回那个 property,只有在没有时才回退到标记;而 Web Component 会为自己的属性定义 property,于是一个空的 property 就压过了你在页面源码里能看到的值。DOM 属性调用没有这层回退,返回的就是标记里写的内容。另一个原因是你读的名字对不上眼前这一代小组件,所以在断定属性不存在之前,三个名字都试一遍。
CI runner 或者容器能访问到识别工具吗?
可以,用服务器模式。在连接设置里把 CapSkip 从回环地址切到你的网络地址或公网 IP,然后把 host 环境变量指向它。之后 runner 和识别工具之间说的,就是它们在同一台机器上时说的那套 HTTP API。如果链路要跨公网,请使用静态公网 IP,并用一条防火墙规则限制来源。远程 Grid 节点是另一个问题,通常什么都不用做,因为节点跑的是浏览器,而调用识别工具的是你的脚本,它还留在你启动它的地方。
Selenium 自己的超时会把识别掐断吗?
不会,因为识别不是一条 WebDriver 命令。隐式等待、页面加载超时和脚本超时管的都是 Selenium 发给浏览器的调用,而你那次识别调用只是夹在其中两条命令之间的普通 Python 代码。真正适用的上限是客户端默认的 120 秒轮询超时,ALTCHA 用的是它,而不是更长的 reCAPTCHA 超时,因为这是 CPU 计算,不是一次浏览器会话。真正要当心的是套在整个测试外面的限制,比如单条测试的插件超时,或者 CI 任务的时限。
简短版结论
先等小组件出现,用 DOM 属性调用读出它的挑战属性,把路径用页面 URL 解析成绝对地址;如果站点把挑战绑定到了会话,就在浏览器的会话里去取那个接口。把挑战文档或者 URL 连同页面 URL 一起传给那个唯一的 ALTCHA 方法,然后把 token 写进隐藏字段,字段名由小组件自己的 name 属性给出,位置是提交按钮所属的那个表单。中途不要对 token 做任何改动。把获取、识别和提交放在一起,因为这个窗口可能两分钟内就关闭,而过期的挑战看起来和答错一模一样。
- 这个类型是什么、它是怎么工作的: ALTCHA 验证码识别页面.
- 同一件事,完全不用浏览器: 在纯 Python 中识别 ALTCHA.
还有一件事会改变你设计重试的方式。因为走这条路用上的 无限量验证码识别工具 是在你本来就拥有的硬件上计算工作量证明,所以丢掉一个挑战只花掉你自己 CPU 的几毫秒,除此之外没有别的代价,因此重新加载页面换一个新挑战,永远比在一场长测试里勉强撑着一个过期 token 更划算。
