如何用 C# 在 WebView2 中识别 reCAPTCHA(WinForms、WPF)

要在 WebView2 中识别 reCAPTCHA,先等待 NavigationCompleted,用 ExecuteScriptAsync 读取 sitekey,再用普通的 await 把它发给 CapSkip,这样你的代码会回到 UI 线程上继续执行。然后把 token 写入每一个 g-recaptcha-response 字段,并调用页面的回调函数或提交表单。大多数失败都源自两个细节。ExecuteScriptAsync 返回的是 JSON,而不是字符串,所以 sitekey 到手时是带着引号的。另外,只要在识别调用上加一个 ConfigureAwait(false),下一次 WebView2 调用就会抛出 InvalidOperationException,因为 CoreWebView2 只在创建它的线程上响应。本指南会构建一个小型 WinForms 应用,把这两点都处理好,同时还覆盖隐形小组件、显式渲染,以及在识别中途关闭窗口的情况。
你需要什么
- 在 Windows 机器上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。
- 已安装 .NET 8 SDK 或更高版本。下面的代码已在 .NET 8 和 .NET 10 上运行过,使用 SDK 10.0.100 构建。
- WebView2 Runtime。Windows 11 自带它,而且它会自动更新,与 Edge 浏览器的更新相互独立。
- NuGet 上的 Microsoft.Web.WebView2 包和 CapSkip 包。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的应用就能通过 API 调用它。两种模式的设置位置都是 连接设置.
dotnet new winforms -n WvApp cd WvApp dotnet add package Microsoft.Web.WebView2 dotnet add package CapSkip
两个包在 net8.0-windows 和 net10.0-windows 下都能顺利还原。WinForms 构建会打印一条无害的 MSB3277 警告,内容是 WindowsBase 的版本冲突,因为 WebView2 包总是同时引用它的 WPF 程序集。这条警告不影响应用,WPF 项目也从来不会出现它。把 UseWPF 设为 true 也能让它消失,但这样一来,System.IO 就会被移出隐式 using,Path.Combine 也就无法编译了。
第 1 步:给 WebView2 一个可写的数据文件夹
WebView2 把 cookie、缓存和本地存储保存在用户数据文件夹里。如果你不指定,它会在你的 exe 旁边创建一个,名为 WvApp.exe.WebView2。从 bin\Debug 运行应用时,这没有问题。可一旦应用被安装到普通用户没有写权限的位置,比如 Program Files,初始化就会立即失败,抛出 UnauthorizedAccessException,提示 Access is denied。所以要改为在用户的本地应用数据目录下指定一个文件夹。
async Task StartAsync()
{
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WvApp");
var env = await CoreWebView2Environment.CreateAsync(null, dataDir);
await webView.EnsureCoreWebView2Async(env);
webView.CoreWebView2.NavigationCompleted += OnNavigationCompleted;
webView.CoreWebView2.Navigate(LoginUrl);
}先创建环境,再让其他任何代码碰这个控件。如果先设置 Source 属性,就会用默认环境开始初始化,之后再用你自己的环境调用 EnsureCoreWebView2Async,就会抛出 ArgumentException,提示 WebView2 已经用另一个 CoreWebView2Environment 初始化过了。在初始化之前对控件调用 ExecuteScriptAsync 同样会失败,抛出的 InvalidOperationException 会提示你去调用 EnsureCoreWebView2Async。如果像本指南的代码那样通过 webView.CoreWebView2 调用,同样的错误就会变成 NullReferenceException,因为 CoreWebView2 此时还是 null。Microsoft 关于 WebView2 用户数据文件夹 的页面介绍了这个文件夹可以放在哪些位置。
第 2 步:读取 sitekey,并解码 ExecuteScriptAsync 的返回值
sitekey 就在页面里,所以用 JavaScript 读取它。复选框小组件或隐形小组件会把它放在 data-sitekey 属性里。如果页面是自己用 grecaptcha.render 渲染小组件的,就没有这个属性,但 Google 的 anchor iframe 会在它的 k 查询参数里带着同一个 key,所以脚本会退而读取那里。
// data-sitekey first, then the anchor iframe that an explicit render leaves
// behind (api2 for reCAPTCHA, enterprise for reCAPTCHA Enterprise)
const string FindSitekeyJs = """
(() => {
const el = document.querySelector('.g-recaptcha[data-sitekey]');
if (el) return el.getAttribute('data-sitekey');
const f = document.querySelector('iframe[src*="/recaptcha/"][src*="/anchor?"]');
return f ? new URL(f.src).searchParams.get('k') : null;
})()
""";
async Task<string?> EvalStringAsync(string js) =>
JsonSerializer.Deserialize<string>(await webView.CoreWebView2.ExecuteScriptAsync(js));ExecuteScriptAsync 返回的是序列化为 JSON 的脚本结果。sitekey 到手时带着引号,而不存在的元素会以 null 这四个字母返回。抛出异常的脚本(比如对一个不存在的元素引发 TypeError)同样返回 null,C# 这一侧不会有任何异常。用 System.Text.Json 反序列化,你得到的就是真正的字符串或真正的 null。手动去掉引号看起来效果一样,直到某个值里含有转义字符,那时它就会出错。
要在 NavigationCompleted 之后运行这段脚本,而不是在 DOMContentLoaded 时。Google 的 api.js 是异步加载的,所以在 DOMContentLoaded 时,小组件的 div 已经存在,但响应 textarea、grecaptcha 对象和 anchor iframe 可能都还没有。在我们的测试中,首次访问时它们在 0.3 到 1.4 秒之后才出现,但总是早于 NavigationCompleted。脚本被缓存之后,它们在 DOMContentLoaded 时就已经在了。所以读得太早的代码,可能在你的机器上能用,到了新用户那里却会失败。
第 3 步:识别验证码,但不离开 UI 线程
在 WebView2 中识别 reCAPTCHA 的尝试,大多就栽在这条规则上。CoreWebView2 属于创建它的那个 UI 线程,从其他任何线程访问它都会抛出异常。面向库代码的指南会让你给每个 await 都加上 ConfigureAwait(false)。但在 WebView2 的事件处理程序里,这条建议是错的。
// In OnNavigationCompleted, where core is webView.CoreWebView2.
// Throws: ConfigureAwait(false) resumes on a thread-pool thread
var result = await solver.RecaptchaAsync(sitekey, core.Source).ConfigureAwait(false);
await webView.CoreWebView2.ExecuteScriptAsync("document.title");
// System.InvalidOperationException: CoreWebView2 can only be accessed from the UI thread.ConfigureAwait(false) 会让识别之后的代码在线程池线程上恢复执行。接下来对 CoreWebView2 的调用就会因上面那个 InvalidOperationException 而失败。如果把脚本调用包进 Task.Run,也会出现同样的情况。在 WPF 中报错信息不同,是 The calling thread cannot access this object because a different thread owns it;如果这个异常从 async void 处理程序中逃逸出去,应用就会崩溃。完整的规则请阅读 Microsoft 关于 WebView2 线程模型 的说明。
// Works: a plain await inside a WinForms event handler comes back on the UI
// thread
var result = await solver.RecaptchaAsync(sitekey, core.Source);
await webView.CoreWebView2.ExecuteScriptAsync("document.title");在 WinForms 或 WPF 事件处理程序中,普通的 await 会捕获 UI 的同步上下文,所以它之后的代码会再次在 UI 线程上运行。CapSkip 工作期间,UI 始终保持响应:在我们的测试中,一次 7.88 秒的识别期间,一个 100 毫秒的计时器触发了 71 次。用 GetAwaiter().GetResult() 阻塞等待不会死锁,因为 CapSkip SDK 内部使用了 ConfigureAwait(false),但在整个识别期间窗口都会卡住。
第 4 步:注入 token 并提交表单
CapSkip 返回之后,把 token 放到 Google 的脚本本该放的位置,然后触发页面接下来要做的事。
// fill every response textarea, then call data-callback or submit the form
const string InjectJs = """
(token => {
const boxes = document.querySelectorAll('textarea[name="g-recaptcha-response"]');
boxes.forEach(b => { b.value = token; });
const cb = document.querySelector('.g-recaptcha[data-callback]')?.dataset.callback;
if (cb && typeof window[cb] === 'function') { window[cb](token); return 'called ' + cb; }
const form = boxes[0]?.form;
if (!form) return 'no form found';
if (!form.checkValidity()) return 'form has empty required fields';
form.requestSubmit();
return 'submitted the form';
})
""";要填写每一个 g-recaptcha-response textarea,而不只是第一个。一个带两个小组件的表单,或者在 Google 的 textarea 旁边还有自己的 textarea 的表单,会有两个同名字段,而 PHP 这类服务器只保留最后一个。在我们那个带两个字段的测试页面上,只设置第一个会被拒绝,两个都设置则会被接受。
带有 data-callback 属性的小组件,会期望页面的回调函数拿着 token 运行,所以脚本会调用它。在我们的回调测试页面上,不调用回调直接提交表单会被拒绝。用 grecaptcha.render 渲染小组件的页面,则是在 JavaScript 里传入回调,脚本看不到它。这时要在页面自己的脚本里找到这个函数名,同样用 token 调用它。
否则,脚本会用 form.requestSubmit() 提交表单,它会像点击表单按钮一样,运行页面自己的提交处理程序和 HTML5 校验。只剩一个区别:只有像 form.requestSubmit(button) 这样传入按钮时,按钮自身的 name 和 value 才会被发送。较老的 form.submit() 方法会跳过处理程序和校验,所以一个空着的必填字段照样会被发往服务器。checkValidity 那一行会报告这种情况,而不是提交一个只填了一半的表单。
token 是作为 JSON 字符串字面量传入的,由 JsonSerializer.Serialize 构建,所以其中的引号或反斜杠永远不会破坏脚本。拿到之后立即注入。Google 的文档说明,reCAPTCHA token 在签发两分钟后过期。
完整的 WinForms 示例
创建项目时命名为 WvApp,这样这个文件就和模板生成的 Form1.Designer.cs 共用一个命名空间。用下面的代码替换 Form1.cs,把 LoginUrl 改成你的页面,然后用 dotnet run 运行。Program.cs 保持模板生成的样子不变。
// Form1.cs, in a project made with: dotnet new winforms -n WvApp
using System.Text.Json;
using CapSkip;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace WvApp;
public partial class Form1 : Form
{
const string LoginUrl = "https://example.com/login";
// data-sitekey first, then the anchor iframe that an explicit render leaves
// behind (api2 for reCAPTCHA, enterprise for reCAPTCHA Enterprise)
const string FindSitekeyJs = """
(() => {
const el = document.querySelector('.g-recaptcha[data-sitekey]');
if (el) return el.getAttribute('data-sitekey');
const f = document.querySelector('iframe[src*="/recaptcha/"][src*="/anchor?"]');
return f ? new URL(f.src).searchParams.get('k') : null;
})()
""";
const string IsInvisibleJs = """
(() => {
const f = document.querySelector('iframe[src*="/recaptcha/"][src*="/anchor?"]');
if (f) return new URL(f.src).searchParams.get('size') === 'invisible';
const el = document.querySelector('.g-recaptcha[data-sitekey]');
if (!el) return false;
if (el.getAttribute('data-size') === 'invisible') return true;
return el.tagName === 'BUTTON' && !el.hasAttribute('data-action');
})()
""";
// fill every response textarea, then call data-callback or submit the form
const string InjectJs = """
(token => {
const boxes = document.querySelectorAll('textarea[name="g-recaptcha-response"]');
boxes.forEach(b => { b.value = token; });
const cb = document.querySelector('.g-recaptcha[data-callback]')?.dataset.callback;
if (cb && typeof window[cb] === 'function') { window[cb](token); return 'called ' + cb; }
const form = boxes[0]?.form;
if (!form) return 'no form found';
if (!form.checkValidity()) return 'form has empty required fields';
form.requestSubmit();
return 'submitted the form';
})
""";
readonly WebView2 webView = new() { Dock = DockStyle.Fill };
readonly CapSkipClient solver = new(host: "127.0.0.1", port: 8080);
readonly CancellationTokenSource cts = new();
public Form1()
{
InitializeComponent();
Controls.Add(webView);
Load += async (_, _) => await StartAsync();
FormClosing += (_, _) => cts.Cancel();
}
async Task StartAsync()
{
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WvApp");
var env = await CoreWebView2Environment.CreateAsync(null, dataDir);
await webView.EnsureCoreWebView2Async(env);
webView.CoreWebView2.NavigationCompleted += OnNavigationCompleted;
webView.CoreWebView2.Navigate(LoginUrl);
}
async Task<string?> EvalStringAsync(string js) =>
JsonSerializer.Deserialize<string>(await webView.CoreWebView2.ExecuteScriptAsync(js));
async void OnNavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
{
var core = webView.CoreWebView2;
var sitekey = await EvalStringAsync(FindSitekeyJs);
if (sitekey is null) return; // no widget on this page
core.NavigationCompleted -= OnNavigationCompleted; // solve once, not on the result page
var invisible = await core.ExecuteScriptAsync(IsInvisibleJs) == "true";
var options = invisible ? new Dictionary<string, object?> { ["invisible"] = 1 } : null;
Text = "Solving reCAPTCHA...";
try
{
var result = await solver.RecaptchaAsync(sitekey, core.Source, options, cts.Token);
var token = JsonSerializer.Serialize(result.Code);
Text = "reCAPTCHA solved, " + await EvalStringAsync($"({InjectJs})({token})");
}
catch (Exception) when (cts.IsCancellationRequested) { } // window closed mid-solve
catch (CapSkipError ex) { Text = "CapSkip error: " + ex.Message; }
}
}处理程序一找到 sitekey 就把自己移除。表单提交到的那个页面也会再次触发 NavigationCompleted,如果没有那一行,应用还会试图去识别结果页面。在没有小组件的页面上,处理程序会提前返回并保持挂接,所以下一次导航之后它会再检查一遍。如果小组件是在同一页面上稍后才出现的,就需要后面展示的轮询循环。隐形小组件是通过 Google 的 anchor iframe 的 size 参数检测出来的。在那个 iframe 出现之前,靠 data-size 属性或绑定在按钮上的小组件来判断它是隐形的。随后它会带着 invisible 选项发给 CapSkip。窗口标题会显示状态。对一个工具来说这就够了,也可以改用 Label 来显示。
窗口开始关闭时,CancellationTokenSource 会被取消。在识别中途关闭应用,SDK 会立刻停止轮询,并抛出 TaskCanceledException,第一个 catch 会把它吞掉。如果没有这个令牌,副窗体上的识别在窗体关闭后还会继续。等它返回时,CoreWebView2 已经是 null,下一次脚本调用就会抛出 NullReferenceException,WinForms 会在它的未处理异常对话框中显示出来。
在 WPF 中使用同样的代码
要在 WPF 下的 WebView2 中识别 reCAPTCHA,上面的步骤保持不变。WPF 控件底层是同一个 CoreWebView2,所以 WebView2 和 CapSkip 的调用都不用改。在 MainWindow.xaml 中用 Microsoft.Web.WebView2.Wpf 命名空间声明这个控件,并把它命名为 webView,然后删掉 webView 字段和 Controls.Add 那一行。把其余代码移到 MainWindow.xaml.cs 中,从 Loaded 事件启动,并在 Closing 事件处理程序中取消令牌。WPF 窗口没有 Text 属性,所以改为把状态写到 Title。WPF 的隐式 using 不包含 System.IO,所以 Path.Combine 需要单独的一行 using。把模板在该文件里生成的 System.Windows.Shapes 导入删掉,因为它有自己的 Path 类,会让这个名称产生歧义。
iframe 中的小组件,以及加载较晚的小组件
ExecuteScriptAsync 只在顶层文档中运行。如果登录表单本身位于 iframe 中,sitekey 脚本在那里会返回 null。用 FrameCreated 事件收集页面的各个框架(这个事件必须在导航之前挂接),并在每个框架销毁时把它移除。对一个已经消失的框架发起调用,会抛出 InvalidOperationException。
// a field: the page's iframes, filled before Navigate() and pruned as they go
readonly List<CoreWebView2Frame> frames = new();
// in StartAsync, before Navigate():
webView.CoreWebView2.FrameCreated += (_, e) =>
{
var frame = e.Frame; // read it once: each read returns a new wrapper
frames.Add(frame);
frame.Destroyed += (_, _) => frames.Remove(frame);
};每个 CoreWebView2Frame 都有自己的 ExecuteScriptAsync,所以同样的脚本可以在框架内部运行。把框架自己的地址作为页面 URL 发给 CapSkip,因为小组件所在的正是那个页面。当顶层文档返回不了 sitekey 时,就在 OnNavigationCompleted 中调用这个方法,并把它放在与主识别相同的 try 和 catch 块中。FrameCreated 也会为 Google 自己的 anchor iframe 和挑战 iframe 触发,循环会跳过它们,因为脚本在里面返回 null。
// widget inside a cross-origin iframe: run the same scripts in that frame
async Task SolveInFramesAsync()
{
foreach (var frame in frames.ToList())
{
var key = JsonSerializer.Deserialize<string>(await frame.ExecuteScriptAsync(FindSitekeyJs));
if (key is null) continue;
var href = JsonSerializer.Deserialize<string>(await frame.ExecuteScriptAsync("location.href"))!;
var result = await solver.RecaptchaAsync(key, href, null, cts.Token);
var token = JsonSerializer.Serialize(result.Code);
await frame.ExecuteScriptAsync($"({InjectJs})({token})");
return;
}
}有些页面会在 load 事件之后才添加小组件,比如在 cookie 横幅之后或首次点击之后。这时 NavigationCompleted 什么也找不到。一个简短的轮询循环就能解决:在一个加载两秒后才添加小组件的页面上,它在 2.11 秒后找到了 key。
// for pages that add the widget after the load event
async Task<string?> WaitForSitekeyAsync(TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var key = await EvalStringAsync(FindSitekeyJs);
if (key is not null) return key;
await Task.Delay(250);
}
return null;
}在另一台机器上运行 CapSkip
应用和识别工具不必在同一台电脑上。在连接设置里把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP。然后让客户端指向那里。
// CapSkip in Server mode on another Windows machine on your network var solver = new CapSkipClient(apiKey: "YOUR_API_KEY", host: "192.168.1.50", port: 8080);
如果链路要经过公网,请使用静态公网 IP,并打开 API 密钥校验,在客户端中使用同一个密钥。Server 模式启动时,CapSkip 会向所有地址开放端口,所以要想只让你预期的机器访问,就为其他地址添加一条 Windows Firewall 阻止规则。在同一台机器上,要写 127.0.0.1 而不是 localhost。在我们的测试中,localhost 每次建立新连接时都会先尝试 IPv6 地址,要等大约两秒它失败后才继续。一个没在运行的识别工具,要花四秒而不是两秒才会被报告出来。
在应用中处理 CapSkip 错误
SDK 会抛出四种异常,都派生自 CapSkipError。要为每种失败显示不同的消息,就用下面这个 try 块替换 OnNavigationCompleted 中的那个。把取消过滤器放在最前面:在任务发送过程中关闭窗口,会引发 NetworkException,如果过滤器排在后面,它就会被当成错误显示出来。
try
{
var result = await solver.RecaptchaAsync(sitekey, core.Source, options, cts.Token);
var token = JsonSerializer.Serialize(result.Code);
Text = "reCAPTCHA solved, " + await EvalStringAsync($"({InjectJs})({token})");
}
catch (Exception) when (cts.IsCancellationRequested) { } // window closed mid-solve
catch (NetworkException ex) { Text = "Is CapSkip running? " + ex.Message; }
catch (ApiException ex) { Text = "CapSkip could not solve it: " + ex.Message; }
catch (CapSkip.TimeoutException) { Text = "No answer before the timeout"; }
catch (CapSkipError ex) { Text = "CapSkip error: " + ex.Message; }有一个名称需要留意。在 WinForms 项目的隐式 using 下,直接写 catch TimeoutException 会因 CS0104 编译失败,因为 System.TimeoutException 和 CapSkip.TimeoutException 都在作用域内。像上面那样写出完整的 CapSkip 名称。RecaptchaAsync 会在 SDK 的 recaptchaTimeout(默认 300 秒)到期后放弃,这个值在 CapSkipClient 构造函数中设置。慢识别通常会更早在 CapSkip 内部失败,这时到达你这里的是 ApiException。
在 WebView2 中识别 reCAPTCHA 时的常见错误
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| InvalidOperationException: CoreWebView2 can only be accessed from the UI thread | 识别之后的代码通过 ConfigureAwait(false) 或 Task.Run 在线程池线程上恢复执行 | 在事件处理程序中使用普通的 await |
| sitekey 两边带着引号,每次识别都失败 | ExecuteScriptAsync 返回的原始 JSON 被直接发给了 CapSkip | 用 System.Text.Json 反序列化结果 |
| 页面上明明有可见的小组件,sitekey 却是 null | 页面自己渲染小组件、稍后才添加它,或者它位于 iframe 中 | 读取 anchor iframe、轮询等待 key,或者在框架内运行脚本 |
| 应用一启动就报 Access is denied | WebView2 试图在用户无写权限的 exe 旁边创建数据文件夹 | 用本地应用数据目录下的文件夹创建环境 |
| ArgumentException:已经用另一个环境初始化过 | 在创建你的环境之前就设置了 Source | 先用你的环境初始化,再导航 |
| 提交后页面又重新加载了登录表单 | 只填了一个响应字段、回调从未运行,或者 token 已过期 | 填写每一个字段,调用回调,并在识别后立即注入 |
| 应用试图识别登录后跳转到的页面 | 每次导航都会触发 NavigationCompleted | 找到 sitekey 后就移除处理程序 |
| NetworkException,提示目标计算机积极拒绝了连接 | CapSkip 没有在运行,或者主机或端口不对 | 启动 CapSkip,并对照它的连接设置检查地址 |
| 关闭窗口后弹出 NullReferenceException 对话框 | 窗体关闭之后,一次识别才完成 | 传入 CancellationToken,并在窗体关闭时取消它 |
常见问题
这适用于 reCAPTCHA v3 和 Enterprise 吗?
适用,只需改选项,不用换方法。Enterprise 页面加载的是 enterprise.js,而不是 api.js。在 RecaptchaAsync 的选项里加上 enterprise 并设为 1,本指南的其余部分保持不变。v3 页面则不同:它的 sitekey 位于 api.js 的 render 参数中,而且页面是用自己的脚本发送 token,而不是通过 textarea。传入设为 v3 的 version 和页面的 action,并去掉 invisible,SDK 在 v3 下会拒绝这个选项。另一篇 用 C# 识别 reCAPTCHA v2 的指南 对这些选项有更详细的介绍。
怎样区分脚本错误和真正的 null?
使用 ExecuteScriptWithResultAsync,当前的 WebView2 包已经包含它。它会报告脚本是否成功,失败时还会给出异常消息;而 ExecuteScriptAsync 在这两种情况下都返回 null。像上面那些简单的读取,继续用 ExecuteScriptAsync 就行,因为在那里,找不到小组件和选择器写错意味着同一件事:没有需要识别的东西。
这和在移动端 WebView 中识别验证码是一回事吗?
不是。WebView2 是 Windows 桌面应用里的 Edge 引擎,你直接用 C# 控制它。Android 或 iOS 应用里的 WebView 则是从外部驱动的,通常借助 Appium,它有自己的会话超时。这种情况详见 用 Appium 识别验证码的指南.
桌面应用能和 CapSkip 运行在不同的机器上吗?
可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址,并把这个地址作为 CapSkipClient 的 host 传入。这样,应用就能从任何可以访问该端口的机器上,通过同一套 HTTP API 调用它。为不该访问它的地址添加一条 Windows Firewall 阻止规则。识别工具仍然运行在你自己的硬件上,没有任何一次识别会经过第三方服务。
简短版结论
要在 WebView2 中识别 reCAPTCHA,就在 NavigationCompleted 之后读取 sitekey,并把 ExecuteScriptAsync 返回的 JSON 反序列化。await RecaptchaAsync 时不要加 ConfigureAwait(false),这样下一次 CoreWebView2 调用才会发生在 UI 线程上。填写每一个响应字段,然后调用页面的回调函数,或者使用 form.requestSubmit()。给 WebView2 一个可写的数据文件夹,在窗口关闭时取消识别,当应用搬到另一台机器上时,把 CapSkip 切换到 Server 模式。
- 用 .NET 包能处理的所有验证码类型: C# 验证码识别页面.
- 复选框 reCAPTCHA 如何运作、识别工具覆盖哪些内容: reCAPTCHA v2 识别页面.
桌面应用和它的识别工具放在同一台 Windows 电脑上,是最简单的配置。这正是 本地验证码识别工具 的用途:token 来自你自己的机器,任何一次识别都不会离开它。
