如何在 DrissionPage 中识别验证码并注入 token

drissionpage captcha - How to Solve CAPTCHA in DrissionPage and Inject the Token

DrissionPage 里的验证码环节就三步:从页面上读出 sitekey,发给识别工具,再用 JavaScript 把 token 写回表单。DrissionPage 通过 DevTools Protocol 直接驱动一个真实的 Chrome,不走 webdriver,所以浏览器这一侧比 Selenium 简单。元素模型可没这么简单,而且它有一个默认设置,会把「小组件没找到」变成识别工具报的错,而不是你的定位符报的错。大多数脚本失败得莫名其妙都是因为这个默认值,所以下面单开一节讲它。

你需要什么

  • Python 3.10 或更高版本,已安装 DrissionPage 4.1 和 CapSkip SDK。
  • 一个 DrissionPage 能启动或接管的 Chromium 浏览器。
  • 受保护表单的页面 URL。运行时读到的 sitekey。
  • 如果脚本和识别工具在同一台机器上,就让 CapSkip 以 Local 模式运行;如果脚本跑在另一台机器上,就用 Server 模式。两种模式都写在 连接设置.
# Both packages, one line.
pip install DrissionPage capskip

DrissionPage 为什么会改变这件事的做法

DrissionPage 直接通过 CDP 跟 Chrome 通信。中间没有 chromedriver 进程,所以没有驱动二进制要打补丁,也没有单独的服务需要跟着你的浏览器版本走。它还能接管一个已经在运行的浏览器,这一点在这里是真有用:你可以手动登录一次,让配置文件保持打开,然后让脚本接着用这个会话。

不变的是 token。识别成功的 reCAPTCHA 就是一个字符串,它最终必须落进那个隐藏的 g-recaptcha-response textarea 里,你才能提交。那个 textarea 是 display:none,所以在任何自动化工具里都没法直接往里输入。你要用 JavaScript 写进去,而 DrissionPage 的 run_js 方法就是干这个的。

有一点得说清楚:不用 webdriver 不等于不会被检测。通过 CDP 驱动 Chrome 只去掉了一个信号,你指纹里的其余部分原封不动。识别挑战和让自己看起来像一个真实浏览器是两件事,本文只讲前者。

第 1 步:从页面上读出 sitekey

sitekey 在宿主文档上,不在小组件的 iframe 里。Google 自己的标记把它放在一个容器元素上,属性名是 data-sitekey,而 DrissionPage 的属性定位符不用 CSS 选择器就能找到它。

# pip install DrissionPage
from DrissionPage import ChromiumPage

page = ChromiumPage()
page.get("https://example.com/page-with-recaptcha")

# @attr finds by attribute. The default search timeout is 10s,
# which is plenty for a widget that renders on load.
holder = page.ele("@data-sitekey")
sitekey = holder.attr("data-sitekey")

print(sitekey)   # 6Lxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

DrissionPage 的定位符前缀值得在这里学一下,因为它们能替掉你原本要写的大部分 XPath:@attr=value 是精确匹配,@attr:value 匹配子串,@attr^value 匹配开头,@attr$value 匹配结尾。在定位符前面加上标签名,比如 tag:iframe,还能把元素类型一起锁定。

有些站点根本不把 sitekey 放在宿主页面上,只在 iframe 的 URL 里传。那就改从那里读,用子串匹配定位到你要的那个 frame。

# Fallback: the k= query parameter on the widget iframe.
from urllib.parse import urlparse, parse_qs

frame = page.ele("tag:iframe@src:recaptcha/api2/anchor")
src = frame.attr("src")
sitekey = parse_qs(urlparse(src).query)["k"][0]

第 2 步:调用本地 API 识别

识别工具跑在你自己机器的 8080 端口上,说的是 2captcha 那套 API,所以 SDK 调用只有一行,背后也没有按次计费。页面 URL 要从 page.url 取,而不是自己重新敲一遍,因为 get() 和识别动作之间若发生跳转,你提交的就会是错的 URL。

# pip install capskip
from capskip import CapSkip

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

# Same call shape for v3 (version="v3") and Enterprise (enterprise=1).
result = solver.recaptcha(sitekey=sitekey, url=page.url)

token = result["code"]   # the g-recaptcha-response value

Turnstile 和极验(GeeTest)各有自己的方法,solver.turnstile() 和 solver.geetest(),两者的调用形态和上面那次一样。各自的完整参数列表见 CapSkip API 文档.

第 3 步:注入 token 并提交

DrissionPage 向 run_js 传递额外参数时是按位置传的,脚本里读取的形式是 arguments[0] 等等。把 token 作为参数传进去,而不是用 f-string 拼出 JavaScript:token 很长、不透明,偶尔还塞满了你不想操心怎么转义的字符。

# Extra args arrive as arguments[0], arguments[1], ...
page.run_js(
    "document.getElementById('g-recaptcha-response').value = arguments[0];",
    token,
)

# Then submit the form the way the page expects.
page.ele("tag:button@type=submit").click()

如果站点不是在提交时读 textarea,而是定义了一个回调,那就在设置完值之后调用它。回调是站点自定义的函数名,所以要从页面自身的标记里读出来,不要靠猜;另外注意这仍然是普通的 reCAPTCHA v2:回调改变的是你怎么把 token 交出去,不是它怎么被识别的。 reCAPTCHA v2 识别工具页面 把两种提交方式都讲了。

最耗时间的那个静默失败

DrissionPage 出厂时 Settings.raise_when_ele_not_found 是 False。定位符什么都没匹配到,也不会抛异常。它返回一个 NoneElement,这个对象是假值,和 Python 的 None 比较相等,于是你的脚本继续往下跑。

这是有意为之的设计,在你探测可选元素时很好用。在这里就不好用了,因为你下一步就要读属性,而报出来的错点的是定位符,不是小组件。更糟的情况是元素确实存在,却压根没有 data-sitekey 属性。这时 attr() 交还一个 None,这个 None 一路以空 key 的形式传到识别工具,失败最后以 ERROR_GOOGLEKEY 的形式,从一个你根本没起疑心的 API 调用里冒出来。

两行代码就能解决。把抛异常打开,并且在花掉一次识别之前先检查 sitekey。

# Make a missing element fail where it happened.
from DrissionPage.common import Settings

Settings.set_raise_when_ele_not_found(True)

# And still check the value, because a found element can hold nothing.
if not sitekey:
    raise RuntimeError("No sitekey on the page. Check the widget rendered.")

完整可运行示例

上面所有内容,合成一个脚本。这里的 finally 块比平时更重要,因为 DrissionPage 可以接管一个不是你启动的浏览器,而把接手过来的浏览器关掉,通常并不是你想要的。

# pip install DrissionPage capskip
from DrissionPage import ChromiumPage
from DrissionPage.common import Settings
from capskip import CapSkip

Settings.set_raise_when_ele_not_found(True)

PAGE_URL = "https://example.com/page-with-recaptcha"
page = ChromiumPage()
solver = CapSkip(host="127.0.0.1", port=8080)

try:
    page.get(PAGE_URL)
    sitekey = page.ele("@data-sitekey").attr("data-sitekey")
    if not sitekey:
        raise RuntimeError("Widget found but data-sitekey was empty.")

    result = solver.recaptcha(sitekey=sitekey, url=page.url)
    page.run_js(
        "document.getElementById('g-recaptcha-response').value = arguments[0];",
        result["code"],
    )
    page.ele("tag:button@type=submit").click()
    page.wait.doc_loaded()

    print(page.title)   # the page you land on after submitting
finally:
    page.quit()

把识别工具跑在另一台机器上

爬虫迟早会被搬到服务器上,DrissionPage 跟着搬没问题:它需要一个 Chromium 二进制和一个显示缓冲区,其他也没什么了。识别工具倒不必跟着搬到同一台机器上。

CapSkip 有两种连接模式。 本地 模式绑定到 127.0.0.1,只有该设备本身能访问,你在写脚本的阶段用它正合适。 服务器 模式绑定到你的内网或公网 IP,这样一台采集用的虚拟机、一台容器宿主机或者第二台工作站,都能通过 API 调用同一个识别工具。固定公网 IP 能让这个地址保持稳定。代码里除了你传进去的主机地址之外什么都不用改,价格也不变,因为硬件还是你自己的。

# Same SDK, same call. Only the host moves.
solver = CapSkip(host="10.0.0.12", port=8080, apiKey="YOUR_API_KEY")

识别工具一旦监听在网络地址上,就把密钥校验打开,并且给每台机器发各自的密钥,这样吊销其中一个不会动到其他机器。 设置指南 把两种模式都走了一遍。

常见错误及其含义

你所看到的原因修复
attr() 抛出 ElementNotFoundError定位符什么都没匹配到,返回了 NoneElement放宽定位符,或者等小组件渲染出来
元素不存在,脚本却继续往下跑raise_when_ele_not_found 默认为 FalseSettings.set_raise_when_ele_not_found(True)
ERROR_GOOGLEKEYattr() 返回了 None,提交上去的是一个空 key检查这个值,或者从 iframe 上读 k 参数
NetworkExceptionCapSkip 没在运行,或者 host 填错了启动应用,或者把 host 指向服务器地址
TimeoutException识别耗时超过了 recaptchaTimeout把它调到默认的 300 秒以上
表单拒绝了一个识别本身没问题的 tokentoken 在提交之前就过期了在提交前一刻才识别,不要在页面加载时就识别

常见问题

我该用 ChromiumPage 还是更新的 Chromium 类?

都行。4.1 版从顶层包导出 Chromium、ChromiumPage、SessionPage 和 WebPage,而 ChromiumPage 仍然是拿到单个标签页最短的路径。验证码这部分的活儿两者完全一样,因为读 sitekey、识别和注入 token 都是通过标签页对象完成的。

我需要切进 reCAPTCHA 的 iframe 吗?

不需要,这恰恰是大家过度设计的地方。复选框确实在 iframe 里,但 sitekey 属性和那个隐藏的响应 textarea 都属于宿主文档。只有当站点不把 sitekey 放到页面上、你必须从 frame 自己的 URL 里读时,才需要去碰 frame。

为什么我的脚本传了一个 None 的 sitekey 却一声不吭?

因为 DrissionPage 的默认行为是返回一个假值占位对象而不是抛异常,也因为一个存在的元素照样可能没有你要的那个属性。两条路径最后都会让一个你以为是字符串的变量装着 None。在文件顶部把抛异常打开,再对这个值加一次显式检查,两者合起来就把两种情况都盖住了。

我的爬虫跑在 VPS 上,识别工具该放哪?

放哪都行,只要两边能互相访问。Server 模式让识别工具监听在一个网络地址上,而不是回环地址,于是 VPS 就像调用任何内部服务那样通过 API 调它。把 host 参数指向那个地址,打开密钥校验,再给这台 VPS 发一个自己的密钥。

简短版结论

用属性定位符读出 sitekey,在 127.0.0.1:8080上完成识别,用 run_js 和位置参数把 token 注入进去,然后提交。做这些之前,先把 raise_when_ele_not_found 打开,因为默认值恰恰藏起了你最先会撞上的那个失败。想了解 Python 这边更完整的图景,包括 Selenium 和 Playwright,见 Python 验证码识别页面。而且,因为这个识别工具是 本地验证码识别工具 而不是按量计费的服务,所以重试一千个页面的抓取,和只重试十个页面的抓取,花的钱一样。