如何在 Python 中从 script 标签入手识别 Friendly Captcha

要在 Python 中识别 Friendly Captcha,先从 frc-captcha 元素上读取 data-sitekey,找到加载小组件的 script 标签,根据这个脚本区分 v1 和 v2,然后把 sitekey、页面 URL、版本以及脚本的完整地址传给 CapSkip 的 friendly_captcha 方法。你会拿回一个 token:v1 提交到 frc-captcha-solution,v2 提交到 frc-captcha-response。关键就在 script 标签上。Friendly Captcha 在同一个名字、同一种 sitekey 格式下发布了两套毫不相干的协议,识别错了版本,返回的 token 看起来有效,却会被悄无声息地拒绝。CapSkip 从 1.4.0 版开始支持 Friendly Captcha。本指南讲四件事:读取页面、调用、提交,以及用 AsyncCapSkip 同时运行大量识别。
你需要什么
- 在 Windows 机器上运行的 CapSkip 1.4.0 或更高版本。Friendly Captcha 支持就是在该版本中加入的,同时加入的还有 CaptchaFox 和 Capy Puzzle。
- Python 3.10 或更高版本,以及 capskip 包 1.3.0 或更高版本,这是第一个带有 friendly_captcha 的版本。示例还用 requests 来获取页面和提交表单。
- 显示小组件的那个页面的 URL。sitekey、脚本地址和字段名都来自该页面的 HTML,第 1 步会告诉你它们在哪里。
- 识别工具的地址。Local 模式只在 127.0.0.1 上响应,仅供本机使用;Server 模式监听你的网络地址或公网 IP,这样另一台机器上的脚本就能通过 API 调用它。两者都位于 连接设置中,第 4 步会说明何时该切换。
# pip install capskip requests pip install "capskip>=1.3.0" requests
引号不能省。在 cmd 里,不加引号的大于号是重定向:pip 会安装它找到的任何 capskip,不做版本检查,还会把输出写进一个名为 1.3.0 的文件。PowerShell 会把这个参数原样传过去,但加上引号在任何 shell 里都管用。
第 1 步:读取小组件及其 script 标签
两个版本渲染出的元素完全相同:一个带有 frc-captcha 类和 data-sitekey 属性的 div。所以这个元素只能给你 sitekey,却说明不了用的是哪套协议。版本信息在加载小组件的 script 标签里,因为 v1 和 v2 是两个不同的包,文件名也不同:
<!-- v2: the @friendlycaptcha/sdk package --> <div class="frc-captcha" data-sitekey="YOUR_SITEKEY"></div> <script type="module" src="https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk/site.min.js" async defer></script> <script nomodule src="https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk/site.compat.min.js" async defer></script> <!-- v1: the friendly-challenge package --> <div class="frc-captcha" data-sitekey="YOUR_SITEKEY"></div> <script type="module" src="https://cdn.jsdelivr.net/npm/friendly-challenge/widget.module.min.js" async defer></script> <script nomodule src="https://cdn.jsdelivr.net/npm/friendly-challenge/widget.min.js" async defer></script>
大多数站点会在这个路径里固定一个版本号,这对这里没有任何影响。用 Python 标准库就足以读出全部信息。下面这个解析器会保存小组件元素的属性,以及每个非 nomodule 后备脚本的地址,并以页面 URL 为基准解析成完整地址:
# pip install capskip requests
from html.parser import HTMLParser
from urllib.parse import urljoin
import requests
class FriendlyPage(HTMLParser):
"""Collects the frc-captcha element and the page's script tags."""
def __init__(self, page_url):
super().__init__()
self.page_url = page_url
self.widget = {}
self.scripts = []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if "frc-captcha" in (a.get("class") or "").split():
self.widget = a
# Skip nomodule fallbacks; keep full addresses, never relative.
if tag == "script" and a.get("src") and "nomodule" not in a:
self.scripts.append(urljoin(self.page_url, a["src"]))
session = requests.Session()
page = FriendlyPage("https://example.com/signup")
page.feed(session.get(page.page_url, timeout=30).text)urljoin 这一调用必不可少。自行托管的小组件可能使用相对的 src,比如 /js/site.min.js,而 CapSkip 需要一个它能获取到的地址:在 v2 上,它会在自己的浏览器里加载同一个脚本来完成识别。只传一个裸路径,小组件就永远加载不出来。
第 2 步:选定版本并调用 friendly_captcha
CapSkip 按固定顺序判定版本,拿到第一个答案就停下:先看你传入的版本,再看你作为 module_script 传入的脚本地址,最后默认为 v1。陷阱就在最后这一步。如果 CapSkip 无法从脚本中读出版本,比如打包后的 /assets/app.4f2a.js,它会一声不吭地按 v1 识别,而 v2 站点随后就会拒绝每一个 token。
所以要在你自己的代码里判定版本,在那里你可以拒绝去猜。CDN 地址中的包名是明确无误的。自行托管的构建常常会去掉包名,但保留文件名,官方 WordPress 插件就是这样:site.min.js 是 v2,widget.module.min.js 或 widget.min.js 是 v1。小组件自身的属性是最后的手段,因为两个版本给选项起的名字不一样:
from urllib.parse import urlparse
V2_FILES = {"site.min.js", "site.compat.min.js"}
V1_FILES = {"widget.module.min.js", "widget.min.js", "widget.polyfilled.min.js"}
def friendly_version(page):
# Package names are unambiguous, so check them first.
for src in page.scripts:
if "@friendlycaptcha/sdk" in src:
return "v2", src
if "friendly-challenge" in src:
return "v1", src
# Self-hosted builds usually keep the file name. Themes ship their own
# site.min.js too, so only trust a path that says friendly.
for src in [s for s in page.scripts if "friendly" in s.lower()]:
name = urlparse(src).path.rsplit("/", 1)[-1]
if name in V2_FILES:
return "v2", src
if name in V1_FILES:
return "v1", src
# Last resort: v2 and v1 name their widget options differently.
if {"data-api-endpoint", "data-form-field-name"} & page.widget.keys():
return "v2", None
if {"data-puzzle-endpoint", "data-solution-field-name"} & page.widget.keys():
return "v1", None
raise RuntimeError("v1 or v2? Read the page and set it by hand.")按文件名判断时,只信任路径里提到 friendly 的那些,因为主题可能会加载一个它自己的 site.min.js,跟小组件毫无关系。
然后发起调用。传入你找到的版本,有脚本地址的话也一并传入。显式指定的版本优先;在 v2 上,这个地址会让 CapSkip 用站点实际加载的那个构建来识别。值为 None 的参数会直接从请求中省略。
from capskip import CapSkip
solver = CapSkip(host="127.0.0.1", port=8080)
version, script = friendly_version(page)
result = solver.friendly_captcha(
page.widget["data-sitekey"],
"https://example.com/signup",
version=version,
module_script=script,
# data-api-endpoint="eu" (v2) or data-puzzle-endpoint (v1).
api_server=page.widget.get("data-api-endpoint")
or page.widget.get("data-puzzle-endpoint"),
)
print(result["token"][:40]) # v2 tokens start with AQQA.api_server 这一行用来处理使用 Friendly Captcha EU 端点的站点。EU 站点的 sitekey 在全球端点上照样能拿到 token,所以在那里识别,只会在站点自己的验证环节失败,和版本出错一样,是无声无息的失败。两个属性都不存在时,这个值为 None,CapSkip 会使用全球端点。
结果是一个普通的 dict。result["token"] 是要提交的字符串,result["code"] 存的是同一个字符串,方便从其他识别工具移植过来的脚本使用,result["captchaId"] 则是 CapSkip 对这次任务的引用编号。版本不是 v1、v2、1 或 2,sitekey 为空,或者传了该方法不接受的选项,都会在发送任何内容之前抛出 ValidationException。
第 3 步:把 token 提交到小组件使用的字段
字段名是两个版本之间的第二处差异,而且站点可以在小组件元素上给它改名:
import requests
# A renamed field wins; otherwise the default for the version.
field = (
page.widget.get("data-form-field-name") # v2 rename
or page.widget.get("data-solution-field-name") # v1 rename
or ("frc-captcha-response" if version == "v2" else "frc-captcha-solution")
)
# session is the requests.Session that fetched the page.
resp = session.post(
"https://example.com/signup",
data={"email": "YOUR_EMAIL", field: result["token"]},
# Browsers send the page as Referer; some servers refuse a post without it.
headers={"Referer": page.page_url},
timeout=30,
)
print(resp.status_code)v1 有一个特殊情况:如果 data-solution-field-name 被设为单个连字符,就表示小组件根本不写入隐藏字段,站点自己的脚本会用别的方式发送 token。照原样复现那个请求,具体做法见下文。
表单的 action 属性指向哪里,就提交到哪里,并带上表单的其他字段,包括 CSRF token 这类隐藏字段。通过同一个 requests.Session 获取页面并提交表单,可以保留站点的 cookie,而那个 CSRF token 通常就依赖这些 cookie。还要把页面 URL 作为 Referer 发送:requests 不会发送 Referer 或 Origin 头,而有些框架(Django 就是其中之一)会拒绝两者都没有的 HTTPS 表单提交,哪怕 cookie 和 token 都完全正确。
用 data= 把 token 放在请求体里发送,绝不要用 params= 放进 URL。v2 的 token 大约 6 KB,足以触发服务器对 URL 长度的限制;v1 的 token 则是由点号分隔的四段,长几百个字符。原样传递,不要做任何裁剪或重新编码。一个 token 只够提交一次:Friendly Captcha 的验证会拒绝已被使用过或已过期的响应,所以每个表单都要重新识别。
有些站点用 JavaScript 以 JSON 请求体发送表单,而不是普通的表单提交。如果字段名正确,提交却仍然失败,就打开 DevTools,手动提交一次,然后原样照搬页面发送的内容。
第 4 步:同时处理多个表单,以及识别工具运行在哪里
Python 中的 AsyncCapSkip 是基于 httpx 构建的真正异步客户端,而不是阻塞版客户端的别名,所以一个事件循环就能让大量识别同时进行。用信号量给它们设个上限,让同时运行的数量永远不超过 CapSkip 能同时处理的数量。在 CapSkip 的 Friendly Captcha 设置区中,Max. Threads 默认为 10。
import asyncio
from capskip import AsyncCapSkip
solver = AsyncCapSkip(host="127.0.0.1", port=8080)
limit = asyncio.Semaphore(10) # match Friendly Captcha Max. Threads
async def solve(sitekey, url, version, script):
async with limit:
r = await solver.friendly_captcha(
sitekey, url, version=version, module_script=script)
return r["token"]
async def main(jobs):
# jobs: (sitekey, url, version, script) tuples from Step 2
return await asyncio.gather(*(solve(*j) for j in jobs))识别耗时会有波动,要有心理准备。Friendly Captcha 按每个请求设定工作量,并对已经见过很多次的地址调高这个量,而且 CapSkip 会在真实浏览器里识别每一个 v2 小组件。这就是为什么这个方法按 recaptchaTimeout(默认 300 秒)轮询,而不是按 120 秒的 defaultTimeout。CapSkip 自己也有限制。在它的 Friendly Captcha 设置中,一个任务最多可以等 250 秒来获得空闲线程,然后再花 120 秒识别,超出就会被 CapSkip 判为失败。所以慢识别需要更高的 Row Timeout,而 SDK 自身的超时(默认 300 秒,或单次调用时传入的 timeout=)必须足以覆盖这个时长,再加上任务等待线程所花的时间。上面的信号量能让这段等待接近于零。如果在长时间运行中识别变慢,通常是某个地址上的难度在上升。可以把单个请求的代理以带 type 和 uri 键的 dict 形式传入,或者在 CapSkip 里配置代理池,免得每次识别都来自同一个地址。
示例使用 127.0.0.1,因为当你的脚本和识别工具在同一台机器上时,这样写是对的。一旦脚本跑在别处,比如 VPS、容器或 CI runner,回环地址就指向了错误的机器,第一次调用就会抛出 NetworkException。把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP,上面这些环境都能通过同一套 API 访问到它。如果链路要经过公网,请使用静态公网 IP,并为你预期的地址配置防火墙规则。硬件仍然是你自己的,用量也仍然不计量。客户端不会自己读取环境变量,所以要在你的代码里读取 CAPSKIP_HOST 并传进去,就像下面的完整示例那样。
完整可运行示例
# pip install capskip requests
import os
from html.parser import HTMLParser
from urllib.parse import urljoin, urlparse
import requests
from capskip import CapSkip
from capskip.exceptions import CapSkipError, ValidationException
PAGE_URL = "https://example.com/signup"
V2_FILES = {"site.min.js", "site.compat.min.js"}
V1_FILES = {"widget.module.min.js", "widget.min.js", "widget.polyfilled.min.js"}
class FriendlyPage(HTMLParser):
def __init__(self, page_url):
super().__init__()
self.page_url, self.widget, self.scripts = page_url, {}, []
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if "frc-captcha" in (a.get("class") or "").split():
self.widget = a
if tag == "script" and a.get("src") and "nomodule" not in a:
self.scripts.append(urljoin(self.page_url, a["src"]))
def friendly_version(page):
for src in page.scripts:
if "@friendlycaptcha/sdk" in src:
return "v2", src
if "friendly-challenge" in src:
return "v1", src
for src in [s for s in page.scripts if "friendly" in s.lower()]:
name = urlparse(src).path.rsplit("/", 1)[-1]
if name in V2_FILES or name in V1_FILES:
return ("v2" if name in V2_FILES else "v1"), src
keys = page.widget.keys()
if {"data-api-endpoint", "data-form-field-name"} & keys:
return "v2", None
if {"data-puzzle-endpoint", "data-solution-field-name"} & keys:
return "v1", None
raise SystemExit("v1 or v2? Read the page and set it by hand.")
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()
page = FriendlyPage(PAGE_URL)
page.feed(session.get(PAGE_URL, timeout=30).text)
if not page.widget.get("data-sitekey"):
raise SystemExit("No frc-captcha widget in the HTML; it may be built by JS.")
version, script = friendly_version(page)
try:
result = solver.friendly_captcha(
page.widget["data-sitekey"], PAGE_URL,
version=version, module_script=script,
api_server=page.widget.get("data-api-endpoint")
or page.widget.get("data-puzzle-endpoint"),
)
except ValidationException as exc:
raise SystemExit(f"not sent: {exc}")
except CapSkipError as exc:
raise SystemExit(f"solve failed: {exc!r}")
field = (page.widget.get("data-form-field-name")
or page.widget.get("data-solution-field-name")
or ("frc-captcha-response" if version == "v2" else "frc-captcha-solution"))
# Post wherever the form's action points, with its other fields.
# Browsers send the page as Referer; some servers refuse a post without it.
resp = session.post(PAGE_URL, data={"email": "YOUR_EMAIL", field: result["token"]},
headers={"Referer": PAGE_URL}, timeout=30)
print(resp.status_code, version, field)版本只判定一次,却用在两处:识别和字段名,所以两者永远不会对不上。如果解析器找不到小组件,通常是因为页面用 JavaScript 动态生成它,这时你需要从渲染后的页面或者创建它的脚本里读取 sitekey。原始接口接受的全部参数,都记录在 Friendly Captcha API 参考文档.
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| CapSkip 正常返回的 token 被站点拒绝 | 识别了错误的版本,常见原因是默认的 v1 被悄悄套用 | 像第 2 步那样在代码里判定版本并传入 |
| 在小组件带有 data-api-endpoint(v2)或 data-puzzle-endpoint(v1)的站点上被拒绝 | token 来自全球端点,而站点用的是 EU 端点 | 把该属性的值作为 api_server 传入 |
| token 没问题,提交却仍然失败 | token 填进了错误的字段,提交时没有带 Referer,或者站点提交的是 JSON | 检查改名属性,并把页面 URL 作为 Referer 发送,再在 DevTools 里照原样复现请求 |
| 在自行托管小组件的站点上,v2 识别失败 | module_script 传的是相对路径,所以小组件根本没有加载 | 传入之前先用 urljoin 把 src 解析成完整地址 |
| 每次都在几秒内抛出 ApiException,提示 ERROR_CAPTCHA_UNSOLVABLE | Friendly Captcha 拒绝了该 sitekey 或页面的来源,或者该 key 所属的账户没有启用 v2 或 EU 端点 | 检查 sitekey、页面 URL 和 api_server;重试无济于事 |
| 还没发送任何内容就抛出 ValidationException | sitekey 为空,版本不是 v1、v2、1 或 2,或者用了未知的选项 | 确认解析器找到了小组件,并去掉错误信息里点名的那个选项 |
| 长时间运行时,识别越来越慢 | 对于频繁访问的地址,Friendly Captcha 会提高工作量 | 为每个请求加代理,或者在 CapSkip 里配置代理池 |
| 两分钟或更久之后抛出 ApiException,提示 ERROR_CAPTCHA_UNSOLVABLE | 某次识别超过了 CapSkip 120 秒的 Row Timeout,或者等待空闲线程的时间超过了 250 秒的 Wait Timeout | 让信号量与 Max. Threads 保持一致,添加代理,或者在 Friendly Captcha 设置中调高 Row Timeout |
| 300 秒后抛出 TimeoutException | 某个任务先等待空闲线程,再进行识别,耗时超过了 SDK 的轮询时长 | 让信号量与 Max. Threads 保持一致,或者传入更长的 timeout= |
| 第一次调用就抛出 NetworkException | CapSkip 没有在运行,或者主机和端口不对 | 启动 CapSkip,然后确认该用 Local 模式还是 Server 模式 |
常见问题
为什么不只传 module_script,让 CapSkip 自己判断?
可以这样做,对于从 CDN 加载的脚本也行得通,因为包名就在地址里。风险在于回退。当地址里没有 CapSkip 认得的构建时,它会按 v1 识别而不是报错,结果里也没有任何东西会提醒你。在你自己的代码里做判断,就能把这个无声的默认值变成一个你看得见的异常。
识别需要我这边有浏览器吗,比如 Selenium 或 Playwright?
不需要。你的脚本只需要 HTML,而 HTML 由 requests 获取。所有浏览器相关的工作都在 CapSkip 内部完成:它在自己的浏览器里识别 v2 小组件,v1 谜题则直接识别。如果你本来就因为别的原因在驱动浏览器,同样的调用照样可用;改为从实时页面读取 sitekey 和脚本地址即可。
VPS 或云端的脚本能使用我 Windows 电脑上的识别工具吗?
可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址,在脚本运行的地方设置 CAPSKIP_HOST,然后像完整示例那样把它传给客户端。VPS、容器和托管 runner 都通过同一套 HTTP API 连接。如果链路要经过公网,请使用静态公网 IP 并配上防火墙规则。识别工具始终运行在你自己的硬件上,所以识别次数再多,你付的费用也不会变。
这和 C# 指南有什么不同?
原始接口、选项和结果字段在每个 CapSkip SDK 里都一样,变的只是写法。真正的区别在于它们周围的代码:标准库解析器、携带站点 cookie 的 requests 会话,以及 AsyncCapSkip。在 Python 里,AsyncCapSkip 是一个独立的异步客户端;而在 .NET 里,它只是 CapSkipClient 的另一个名字,因为 CapSkipClient 本身就是异步的。这套流程的 .NET 版本见 C# Friendly Captcha 指南,在 Python 中并发运行识别的更多内容,请参阅 并行识别指南.
简短版结论
要在 Python 中识别 Friendly Captcha,先解析页面,找出 frc-captcha 元素及其 script 标签,并把每个 src 解析成完整地址。根据包名或小组件自身的属性判断是 v1 还是 v2,两者都看不出来时就抛出异常。用 sitekey、页面 URL、这个版本和脚本地址调用 friendly_captcha,EU 站点再加上 api_server。token 只提交一次,放在请求体里,填进版本和小组件所指定的字段。
- 这个类型如何运作、识别工具覆盖哪些内容: Friendly Captcha 识别页面.
- Python 包能识别的其他所有验证码类型: Python 验证码识别页面.
关于用量,还有一点。Friendly Captcha 的难度以 CPU 时间计价,而不是以金钱计价,所以忙碌一天的代价,只是你自己机器上的识别时间。把一款 无限量验证码识别工具 跑在本地,唯一的限制就是那台机器和你分配给它的线程,而不是计费表。
