如何在 MechanicalSoup 中识别 reCAPTCHA 并提交表单

mechanicalsoup captcha - How to Solve reCAPTCHA in MechanicalSoup and Submit the Form

MechanicalSoup 从不运行验证码小组件,所以小组件本该填写的那个字段通常并不在表单里,给它赋值会抛出 LinkNotFoundError。解决 MechanicalSoup 验证码问题需要三步。从 MechanicalSoup 已经解析好的页面中读取 sitekey,然后用运行在你自己 Windows 机器上的 CapSkip 识别它。接着在调用 submit_selected 之前,用 new_control 亲手加上 g-recaptcha-response 字段。剩下的交给 MechanicalSoup:会话 cookie、隐藏的 CSRF 字段,以及严格的服务器会检查的 Referer 请求头。本指南涵盖 reCAPTCHA 复选框、隐形按钮、v3、Enterprise、CapSkip 能识别的其他小组件、同时进行多个登录,以及把识别工具放在另一台机器上运行。

你需要什么

  • 在 Windows 机器上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。
  • Python 3.10 或更高版本、capskip 包和 MechanicalSoup。示例是用 capskip 1.3.0 和 MechanicalSoup 1.4.0 运行的,安装它们时会一并装上 requests、Beautiful Soup 和 lxml。
  • 带有表单的那个页面的 URL。sitekey、表单字段和小组件类型,全都从这个页面的 HTML 中获取。
  • 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样 VPS 或另一台电脑上的脚本就能通过 API 调用它。两种模式的设置位置都是 连接设置.
# Python 3.10 or later; MechanicalSoup brings requests, bs4 and lxml
pip install capskip mechanicalsoup

第 1 步:打开页面并找到 sitekey

StatefulBrowser 就是一个附带了已解析页面的 requests 会话。它会保留站点设置的每个 cookie,跟随重定向,并记住它最终停留的地址。打开登录页面,查找 reCAPTCHA 元素:

# pip install capskip mechanicalsoup
import mechanicalsoup

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

# One StatefulBrowser per session: it keeps cookies, follows redirects
# and sends the current page as Referer when it submits a form.
browser = mechanicalsoup.StatefulBrowser(raise_on_404=True)
browser.open(PAGE_URL)

# The checkbox div, or the button on invisible reCAPTCHA, holds the key.
widget = browser.page.select_one(".g-recaptcha[data-sitekey]")
if widget is None:
    raise RuntimeError("No reCAPTCHA widget in the HTML; see the v3 section.")
sitekey = widget["data-sitekey"]

# The form around the widget; a page often has a search form first.
browser.select_form(widget.find_parent("form") or 'form[method="post" i]')
print(sitekey, browser.url)

复选框小组件是一个带有 g-recaptcha 类和 data-sitekey 属性的 div。隐形 reCAPTCHA 通常会把同样的类和属性改放在提交按钮上,所以一个选择器就能同时找到两者。如果页面用 render=explicit 加载 api.js,它就会用自己的脚本构建小组件,并把 key 传给 grecaptcha.render,这时要在 DevTools 里从那个调用中读取 key。要选中包住小组件的那个表单,而不是页面上的第一个表单。很多站点会在页眉放一个搜索表单,而 select_form 默认选中的就是它。如果小组件位于它所属的表单之外,兜底逻辑会取第一个以 POST 方式提交的表单,所以如果页面上有两个这样的表单,就改为传入登录表单的 id。

browser.url 是经过所有重定向之后页面最终来自的地址。这才是该发给识别工具的 URL,因为 sitekey 是为它所属站点的域名登记的,针对错误页面的识别只是白费功夫。raise_on_404 会让不存在的页面在 open 时就明确报错,而不是到后面才出问题。

第 2 步:用 CapSkip 识别 reCAPTCHA

对 capskip 包的一次调用就能完成识别。CapSkip 在你的 Windows 机器上识别验证码,并返回小组件本该放进表单的那个 token:

from capskip import CapSkip

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

# enterprise.js in place of api.js means reCAPTCHA Enterprise.
enterprise = int("recaptcha/enterprise.js" in str(browser.page))
if widget.name == "button" and widget.get("data-action"):
    # Google's v3 button binding: a g-recaptcha button with an action is v3.
    result = solver.recaptcha(sitekey=sitekey, url=browser.url, version="v3",
                              action=widget["data-action"], enterprise=enterprise)
else:
    # A g-recaptcha button, or data-size="invisible", is the invisible widget.
    invisible = widget.name == "button" or widget.get("data-size") == "invisible"
    result = solver.recaptcha(sitekey=sitekey, url=browser.url,
                              invisible=int(invisible), enterprise=enterprise)

token = result["code"]
print(token[:40])   # what the widget would have written into the form

复选框是最基本的调用,隐形 reCAPTCHA 是同一个调用,只是把 invisible 设为 1。如果一个 g-recaptcha 按钮还带有 data-action,那就是 Google 的 v3 按钮绑定方式,所以脚本会按 v3 并使用这个 action 来识别它。加载 enterprise.js 而不是 api.js 的页面运行的是 reCAPTCHA Enterprise,用 enterprise=1 告诉 CapSkip 即可。token 位于 result["code"] 中。它的有效期大约两分钟,而且只能验证一次,所以要在临提交前才识别,每次登录都要重新识别。

recaptcha 最多按 recaptchaTimeout 轮询,默认 300 秒。要改用别的值,就在构造函数上设置,因为按单次调用传入 timeout 会抛出 TypeError:这个方法本身已经传了一个 timeout。CapSkip 自己也有一套计时。一个任务最多等待 250 秒(Wait Timeout)来获得空闲的 reCAPTCHA 线程,之后识别本身还有一个独立的时限,即 Row Timeout,默认 250 秒。其中任何一个用完,CapSkip 都会判定任务失败,SDK 则抛出带 ERROR_CAPTCHA_UNSOLVABLE 的 ApiException。这里用的 CapSkip 客户端没有给自己的 HTTP 调用设置超时,所以如果 CapSkip 接受了连接却始终不回应,调用就会一直挂着。

第 3 步:加上小组件本该写入的字段,然后提交

每个 MechanicalSoup 验证码脚本都会栽在这一步。在真实浏览器中,Google 的脚本会创建一个名为 g-recaptcha-response 的隐藏 textarea,并在验证码完成后填好它。MechanicalSoup 只解析服务器发来的 HTML,不运行任何 JavaScript,所以在大多数页面上,这个字段并不存在。这时用常规方式给它赋值,就会抛出 LinkNotFoundError,消息为 No valid element named g-recaptcha-response。new_control 会把这个字段加到选中的表单里:

# This line raises LinkNotFoundError on most pages: the widget script
# creates the field in a real browser, so the parsed HTML lacks it.
# browser["g-recaptcha-response"] = token

# Add the field the widget would have written, replacing any copy.
browser.new_control("hidden", "g-recaptcha-response", token)
browser["email"] = "YOUR_EMAIL"
browser["password"] = "YOUR_PASSWORD"

response = browser.submit_selected()
print(response.status_code, browser.url)

new_control 在加入自己的字段之前,会先移除所有同名字段,这一点对保留了 Google noscript 兜底标记的页面很重要。这种兜底标记里包含一个同名的 textarea,MechanicalSoup 的解析器会把它当作表单的一部分读进来。在这类页面上,普通的赋值也能用,但 new_control 只用一行就能同时应对两种页面。

submit_selected 会在加载页面的同一个会话上,发送表单携带的每个字段,包括隐藏的 CSRF token。它还会把页面地址作为 Referer 请求头发送。普通的 requests 不会发送 Referer,MechanicalSoup 更底层的 Browser.submit 方法也不会。有些框架(Django 就是其中之一)会拒绝没有 Referer 或 Origin 的 HTTPS 表单提交,哪怕 cookie 和 token 都完全正确。所以要坚持在打开页面的那个 browser 对象上使用 submit_selected。如果表单有多个提交按钮,就把你要用的那个按钮的 name 作为 btnName 传入。另外,返回 200 并不能证明登录成功。很多站点在验证码被拒时,会返回同一个表单和 200,所以要在 browser.page 中查找只有登录后的页面才会显示的内容。

reCAPTCHA v3 没有可找的小组件

v3 不会向用户提任何问题。站点加载 Google 的脚本时,会把 sitekey 放在地址里 render= 之后;站点自己的脚本再用一个 action 名称(比如 login)请求 token。大多数 v3 页面随后会自己把 token 写进一个隐藏的 input。从 HTML 中读出这两个值,留意脚本是 api.js 还是 enterprise.js,然后把它们连同设为 v3 的 version 一起传过去。如果页面改为把 v3 绑定到按钮上,第 2 步已经处理了这种情况。

import re

# A v3 page has no widget element; the key sits in the script tags.
html = str(browser.page)
key = re.search(r"recaptcha/(api|enterprise)\.js\?[^\"'<>\s]*?\brender=(?!explicit\b)([\w-]+)", html)
action = re.search(r"execute\(\s*[^,()]+,\s*\{\s*[\"']?action[\"']?\s*:\s*[\"']([\w/]+)", html)
if key is None:
    raise RuntimeError("No v3 render key in the HTML; check DevTools.")

v3 = solver.recaptcha(sitekey=key.group(2), url=browser.url, version="v3",
                      enterprise=int(key.group(1) == "enterprise"),
                      **({"action": action.group(1)} if action else {}))

browser.select_form('form[method="post" i]')
# The field the page's own script fills in; read its name in DevTools.
browser.new_control("hidden", "g-recaptcha-response", v3["code"])

在 v3 页面上,第 1 步只运行到 browser.open 为止,接着运行第 2 步中创建识别工具实例的那几行,然后是这段代码,最后是第 3 步的登录凭据和 submit_selected。action 之所以重要,是因为站点后端通常会检查 token 是否是为它预期的 action 签发的。如果匹配模式没找到 action,调用就不带 action 发出,CapSkip 会使用 verify,而期望 login 的站点会拒绝它。如果 execute 调用位于外部脚本中,就在 DevTools 里读出 action,自己传进去。字段名就是页面脚本填写的那个,常常是 g-recaptcha-response,但并非总是如此,所以要在 DevTools 里手动提交一次来确认。然后像上面那样填好登录凭据并调用 submit_selected。Google 的 reCAPTCHA v3 文档 展示了这些匹配模式所对应的 render 和 execute 调用。 在 Python 中识别 reCAPTCHA v3 的指南 更深入地讲解了这个调用本身。

其他小组件的做法也一样

在 MechanicalSoup 中,CapSkip 能识别的每种小组件都遵循同一个模式:读取 key、识别、加上小组件本该创建的字段、提交。变的只有选择器、字段名和方法:

小组件key 的位置需添加的字段capskip 方法
reCAPTCHA v2 复选框div.g-recaptcha, data-sitekeyg-recaptcha-responsesolver.recaptcha()
reCAPTCHA v2 隐形版带 g-recaptcha 类的按钮,或带 data-size=invisible 的 divg-recaptcha-responsesolver.recaptcha() 加上 invisible=1
reCAPTCHA v3脚本地址中的 api.js?render= 或 enterprise.js?render=,或者带 data-action 的 g-recaptcha 按钮页面脚本填写的那个 inputsolver.recaptcha() 加上 version="v3" 和 action
reCAPTCHA Enterprise用 enterprise.js 代替 api.js与 v2 或 v3 相同solver.recaptcha() 加上 enterprise=1
Cloudflare Turnstile 小组件div.cf-turnstile, data-sitekeycf-turnstile-responsesolver.turnstile()
Friendly Captchadiv.frc-captcha, data-sitekeyv2 用 frc-captcha-response,v1 用 frc-captcha-solutionsolver.friendly_captcha() 加上 version="v1" 或 "v2"
CaptchaFoxdiv.captchafox, data-sitekeycf-captcha-responsesolver.captchafox()

CaptchaFox 是唯一多一条规则的小组件。它的 token 属于签发它的那个浏览器。所以,当结果里带有 userAgent 时,要在提交之前用 set_user_agent 把它设置到 browser 对象上,否则 MechanicalSoup 会发送 python-requests 和它自己的名字。 在 Python 中识别 CaptchaFox 的指南 讲解了小组件构建版本和重试。

完整可运行示例

下面是放在一个文件里的完整 MechanicalSoup 验证码登录流程。login 会在一个新的 browser 对象中打开页面,找到复选框、隐形或 v3 reCAPTCHA,识别它,加上字段并提交:

# pip install capskip mechanicalsoup
# login.py: run with python login.py
import os
import re

import mechanicalsoup
from capskip import CapSkip, CapSkipError, TimeoutException

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

# render= carries a v3 key on api.js or enterprise.js; render=explicit is a v2 page.
V3_KEY = re.compile(r"recaptcha/(api|enterprise)\.js\?[^\"'<>\s]*?\brender=(?!explicit\b)([\w-]+)")
# The action passed to execute, whether the key is a string or a variable.
V3_ACTION = re.compile(r"execute\(\s*[^,()]+,\s*\{\s*[\"']?action[\"']?\s*:\s*[\"']([\w/]+)")

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")),
)


def solve_page(browser):
    """Solve the page's reCAPTCHA and select its form. Returns the token."""
    html = str(browser.page)
    enterprise = int("recaptcha/enterprise.js" in html)
    widget = browser.page.select_one(".g-recaptcha[data-sitekey]")
    if widget is not None:
        browser.select_form(widget.find_parent("form") or 'form[method="post" i]')
        if widget.name == "button" and widget.get("data-action"):
            # Google's v3 button binding: plain api.js, the action on the button.
            return solver.recaptcha(sitekey=widget["data-sitekey"], url=browser.url, version="v3",
                                    action=widget["data-action"], enterprise=enterprise)["code"]
        invisible = widget.name == "button" or widget.get("data-size") == "invisible"
        return solver.recaptcha(sitekey=widget["data-sitekey"], url=browser.url,
                                invisible=int(invisible), enterprise=enterprise)["code"]
    key = V3_KEY.search(html)
    if key is None:
        raise RuntimeError(f"No reCAPTCHA found on {browser.url}")
    action = V3_ACTION.search(html)
    browser.select_form('form[method="post" i]')
    return solver.recaptcha(sitekey=key.group(2), url=browser.url, version="v3",
                            enterprise=int(key.group(1) == "enterprise"),
                            **({"action": action.group(1)} if action else {}))["code"]


def login(email, password, page_url=PAGE_URL):
    # A new browser per login, so sessions never share cookies.
    with mechanicalsoup.StatefulBrowser(raise_on_404=True) as browser:
        browser.open(page_url)
        token = solve_page(browser)
        browser.new_control("hidden", "g-recaptcha-response", token)
        browser["email"] = email
        browser["password"] = password
        response = browser.submit_selected()
        return response.status_code, browser.url


if __name__ == "__main__":
    try:
        print(login("YOUR_EMAIL", "YOUR_PASSWORD"))
    except TimeoutException:
        raise SystemExit("gave up after recaptchaTimeout")
    except CapSkipError as exc:
        raise SystemExit(f"solve failed: {type(exc).__name__}: {exc}")
    except mechanicalsoup.LinkNotFoundError as exc:
        # A 404, or a form or field the page does not have.
        raise SystemExit(f"not found: {str(exc) or PAGE_URL}")
    except RuntimeError as exc:
        raise SystemExit(str(exc))

每次调用 login 都会建立自己的 StatefulBrowser,用完就关闭,所以两次登录永远不会共用 cookie。即使某一步失败,with 代码块也会关闭会话。大多数失败都会告诉你该去哪里找原因。LinkNotFoundError 意味着 404、表单缺失,或者字段名与页面对不上。RuntimeError 意味着页面上没有脚本能认出的 reCAPTCHA,这时要在真实浏览器里打开它,看看加载了什么。如果在那里加载正常,就打印 browser.open(page_url).status_code:403 表示站点拒绝了默认的 user agent。CapSkipError 来自识别本身,请参考下面的错误表。

同时进行多个登录,以及把识别工具放在另一台机器上

MechanicalSoup 是同步的,这里用的 CapSkip 客户端也是。所以,当一个 MechanicalSoup 验证码任务要处理大量登录时,线程是让它们并行运行的最简单方式。每个线程使用一个 StatefulBrowser(login 本身已经这样做了),并把线程池的上限设为十,也就是 CapSkip 中 reCAPTCHA 的 Max. Threads 默认值。超出上限而排队的任务会等待空闲线程,而长时间排队会吃掉 SDK 那 300 秒的轮询时间。先在 CapSkip 的 reCAPTCHA 设置中调高 Max. Threads,再调高 max_workers。把完整示例保存为 login.py,然后在它旁边的第二个文件里运行下面这段代码:

# pool.py: save next to login.py, the full example, and run this file
from concurrent.futures import ThreadPoolExecutor

from login import login

accounts = [("YOUR_EMAIL_1", "YOUR_PASSWORD_1"), ("YOUR_EMAIL_2", "YOUR_PASSWORD_2")]


def attempt(account):
    # One failed login must not stop the others.
    try:
        return login(*account)
    except Exception as exc:
        return f"{type(exc).__name__}: {exc}"


# Ten at a time, to match CapSkip's default Max. Threads for reCAPTCHA.
with ThreadPoolExecutor(max_workers=10) as pool:
    for account, outcome in zip(accounts, pool.map(attempt, accounts)):
        print(account[0], outcome)

pool.map 会按传入账号的顺序返回结果,而 attempt 会把失败转换成一个值,所以一次失败的登录不会让其余登录停下来。在针对模拟识别工具的测试中,通过这个线程池跑了二十五次登录,同时进行的识别从未超过十个。

只要脚本和 CapSkip 在同一台 Windows 电脑上,127.0.0.1 就是对的。一旦把脚本挪到 VPS、另一台机器上的定时任务或容器里,回环地址就指向了错误的机器,第一次识别就会抛出 NetworkException。把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP,上面这些环境都能通过同一套 API 调用它。如果链路要经过公网,请使用静态公网 IP。Server 模式启动时,CapSkip 会向所有地址开放它的端口,所以要想只让你预期的机器访问,就为其他地址添加一条 Windows Firewall 阻止规则。它仍然是你自己的机器,识别也仍然不计量。客户端不会自己读取环境变量,所以完整示例才会读取 CAPSKIP_HOST 并把它传进去。

常见错误及其含义

你所看到的原因修复
LinkNotFoundError: No valid element named g-recaptcha-response小组件的脚本从未运行,所以它创建的字段不在解析出的表单里提交之前用 new_control 加上它
token 随搜索表单一起提交了,或者 select_form 抛出 LinkNotFoundError页面上排在前面的是另一个表单,或者小组件位于它所属的表单之外选中包住小组件的表单,或者按 id 选中登录表单
token 是新的,却返回 403 或 CSRF 错误提交是通过新会话、requests.post 或 Browser.submit 发出的,因此缺少 cookie 或 Referer在打开页面的那个 browser 对象上填写表单,并用 submit_selected 提交
CapSkip 返回了 token,站点却说验证码没有通过隐形小组件被当作复选框识别,v3 的 action 与页面的不一致,Enterprise 页面识别时没带 enterprise=1,或者 token 已经超过两分钟传入 invisible=1、enterprise=1 或页面的 action,并在临提交前才识别
TypeError: capskip.solver.CapSkip.solve() got multiple values for keyword argument timeout按单次调用给 recaptcha 传了 timeout改为在 CapSkip 构造函数上设置 recaptchaTimeout
带 ERROR_CAPTCHA_UNSOLVABLE 的 ApiExceptionCapSkip 按自己的计时判定任务失败,或者 sitekey 和页面 URL 不匹配发送 browser.url 和该页面自己的 sitekey,同时进行的识别最多保持十个
300 秒后抛出 TimeoutException等待空闲线程的时间加上识别本身,超过了 SDK 的时限把线程池的上限设为 Max. Threads,或者在构造函数上调高 recaptchaTimeout
页面在真实浏览器里加载正常,却找不到 reCAPTCHA站点返回了 403,因为它拒绝默认的 user agent,而这个 user agent 里写着 python-requests 和 MechanicalSoup创建 StatefulBrowser 时,把一个浏览器的 user agent 作为 user_agent 传入
第一次识别时出现 NetworkExceptionCapSkip 没有在运行,主机或端口不对,或者脚本运行在另一台机器上启动 CapSkip,然后确认它应该处于 Local 模式还是 Server 模式

常见问题

为什么用 MechanicalSoup,而不是直接用 requests?

它省去了你重建表单的工夫。用 requests 时,你要自己收集隐藏字段、保持会话、解析表单的 action 并设置 Referer,漏掉其中任何一项,一个好好的 token 都会被拒绝。MechanicalSoup 会根据解析好的页面把这四件事全部做好,所以为了验证码,你只需要加上那一个字段。对于用 JavaScript 构建表单的页面,两者都不够用,浏览器自动化工具更合适。

通过验证码后,会话会保持登录状态吗?

会。StatefulBrowser 会保留登录后站点设置的 cookie,所以你接下来在同一个 browser 对象上打开的页面都处于登录状态。完整示例会在 login 返回时关闭它的 browser 对象,所以要把后续请求移到那个 with 代码块里面。或者不用 with 创建 browser 对象,把它返回出来,用完后再调用 browser.close()。

VPS 上的 MechanicalSoup 脚本能使用我 Windows 电脑上的 CapSkip 吗?

可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址。然后在脚本运行的地方设置 CAPSKIP_HOST,完整示例会读取它。VPS、cron 任务和容器都通过同一套 HTTP API 连接。如果链路要经过公网,请使用静态公网 IP,并为不应访问该端口的地址添加一条 Windows Firewall 阻止规则。识别工具始终运行在你自己的硬件上,所以登录次数再多,你付的费用也不会变。

简短版结论

解决 MechanicalSoup 验证码问题,归根结底就是补上一个缺失的字段,因为 MechanicalSoup 无法运行写入这个字段的小组件。用 StatefulBrowser 打开页面,选中包住小组件的表单。从 g-recaptcha 元素中读取 sitekey,或者从 v3 页面的脚本中读取 render 用的 key 和 action,然后用 browser.url 交给 CapSkip 识别。用 new_control 加上 g-recaptcha-response 字段,填好其余字段,再在同一个 browser 对象上调用 submit_selected,它会把 cookie、CSRF 字段和 Referer 一并带上。在临提交前才识别,同时进行的登录最多保持十个,脚本在别处运行时,就把 CapSkip 切换到 Server 模式。

MechanicalSoup 从不启动浏览器,所以能让爬虫保持轻量。把它和一款 验证码识别工具 搭配使用,并让后者运行在你自己的 Windows 机器上,繁重的工作就都留在你掌控的硬件上,也不会产生按次计费的账单。