如何在 CefSharp OffScreen 控制台应用中识别 reCAPTCHA

要在 CefSharp 中识别 reCAPTCHA,先在 OffScreen 的 ChromiumWebBrowser 中加载页面,用一个通过 browser.GetMainFrame() 运行的脚本读取 sitekey,再把它传给 CapSkip 的 RecaptchaAsync。然后把 token 写入每一个 g-recaptcha-response 字段,并调用页面的回调函数或提交表单。大多数初次尝试都栽在 CefSharp 的两条规则上。导航刚结束就直接在 browser 对象本身上调用 EvaluateScriptAsync,几乎每次都会抛出异常,因为页面的 JavaScript 上下文还没有准备好。另外,Cef.Shutdown 必须在启动 CEF 的那个线程上运行,而 async Main 会悄无声息地破坏这一点。本指南会构建一个小型控制台应用,把这两点都处理好,同时还覆盖隐形小组件、显式渲染,以及同时处理多个页面的情况。
你需要什么
- 在 Windows 机器上运行的 CapSkip,并已开启 reCAPTCHA 识别功能。CefSharp 同样只支持 Windows,所以两者可以共用一台电脑。
- 已安装 .NET 10 SDK,或者已安装 .NET 8 SDK 并在第 1 步中改用 net8.0-windows。下面的代码已在 .NET 8 和 .NET 10 上运行过,使用 SDK 10.0.100 构建。
- NuGet 上的 CefSharp.OffScreen.NETCore 包和 CapSkip 包。本指南使用 CefSharp 152.0.100,它自带 Chromium 152。
- x64 版的 Visual C++ 2022 Redistributable。CefSharp 自己的 DLL 链接到它,而且 CefSharp 的 README 写明,从 138 版起必须安装 VC++ 2022。
- 识别工具的地址。在 Local 模式下,CapSkip 只在 127.0.0.1 上响应,仅供本机使用。在 Server 模式下,它监听你的网络地址或公网 IP,这样另一台机器上的应用就能通过 API 调用它。两种模式的设置位置都是 连接设置.
第 1 步:创建一个带 Windows 目标框架和运行时的项目
从控制台模板开始,但先不要添加包。这个模板的目标框架是普通的 net10.0(在 .NET 8 SDK 上则是 net8.0),而 CefSharp 的 .NET 包只支持 Windows 目标框架。
# create a console app, then edit CefApp.csproj as shown below before adding packages dotnet new console -n CefApp cd CefApp
打开 CefApp.csproj,用下面这个 PropertyGroup 替换原有的那个。
<PropertyGroup> <OutputType>Exe</OutputType> <!-- CefSharp's .NET packages only install into a Windows target (net8.0-windows works too) --> <TargetFramework>net10.0-windows</TargetFramework> <!-- without it, the Chromium binaries never reach bin and the app fails at startup --> <RuntimeIdentifier>win-x64</RuntimeIdentifier> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup>
然后添加这两个包。
# the first restore downloads about 250 MB, most of it Chromium for win-x64 dotnet add package CefSharp.OffScreen.NETCore dotnet add package CapSkip
这个 PropertyGroup 中的两行,各自都能防止一个真实的故障。没有 Windows 目标框架时,dotnet add package 会以 NU1202 停止,并提示这个包与 net10.0 不兼容,因为它支持的是 net6.0-windows7.0 及更高版本。没有 RuntimeIdentifier 时,构建会成功,没有任何警告,但应用随后会在启动时因 CefSharp.Core.Runtime.dll 抛出 FileNotFoundException。这条消息具有误导性:它点名的那个文件其实就在那里。真正缺少的是 libcef.dll,也就是 Chromium 引擎本身,只有当构建知道自己的运行时,它才会被复制到 bin。在我们的测试中,只把 PlatformTarget 设为 x64,会得到同样的错误。
构建产物会很大。第一次还原(restore)会下载大约 250 MB,输出文件夹里大约有 248 个文件、共 420 MB,其中 285 MB 是 libcef.dll。CefSharp.BrowserSubprocess.exe 位于你的 exe 旁边,因为 Chromium 会把渲染器作为独立进程运行。
第 2 步:CEF 只启动一次,并在同一个线程上停止它
CEF 在每个进程中只启动一次,而且必须在启动它的那个线程上关闭。CefSharp 的 General Usage 指南 提醒说,违反这条规则会让应用挂起。在 152 版中,它改为抛出异常。所以下面的顶层代码保持同步,只有 RunAsync 使用 await。
// Program.cs. Cef.Initialize and Cef.Shutdown must run on the same thread, so the
// top-level code stays synchronous and blocks while the async work runs
using System.Text.Json;
using CapSkip;
using CefSharp;
using CefSharp.OffScreen;
const string SiteUrl = "https://example.com/login";
var settings = new CefSettings
{
// CEF locks this folder: give each app, and each running copy, its own
RootCachePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CefApp"),
};
// InitializeAsync returns only when CEF is fully up
if (!Cef.InitializeAsync(settings).GetAwaiter().GetResult())
throw new InvalidOperationException("CEF did not start: " + Cef.GetExitCode());
try
{
RunAsync(SiteUrl).GetAwaiter().GetResult();
}
finally
{
Cef.Shutdown();
}
static async Task RunAsync(string url)
{
using var browser = new ChromiumWebBrowser(url);
// the wait has no timeout of its own: a malformed URL never finishes loading
var load = await browser.WaitForInitialLoadAsync().WaitAsync(TimeSpan.FromSeconds(30));
Console.WriteLine($"Loaded {url}: HTTP {load.HttpStatusCode}");
}原因如下。在控制台应用中,await 之后的代码通常会在线程池线程上恢复执行。在顶层使用 await 时,Cef.Shutdown 运行在线程池线程上,而不是运行 Cef.Initialize 的那个主线程上。应用随后因一个未处理的异常而崩溃,异常消息的开头是:Cef.Shutdown must be called on the same thread that Cef.Initialize was called。await Cef.InitializeAsync 也会以同样的方式失败,因为它的延续(continuation)运行在 CEF 自己的 UI 线程上。用 GetAwaiter().GetResult() 阻塞等待,能让 Cef.Shutdown 留在主线程上,而且当 RunAsync 抛出异常时,finally 块仍然会关闭 CEF。
使用 InitializeAsync,尽管代码是同步等待它的。它只有在 CEF 完全启动后才会完成,CefSharp 官方的 OffScreen 示例也是这样启动 CEF 的。
RootCachePath 必须是绝对路径。相对路径会立即抛出异常,消息里会直接说明这一点。如果不设置它,CEF 会使用本地应用数据目录下一个共享的默认文件夹,它自己的日志会对此发出警告。除非你同时把 CachePath 设为 RootCachePath 内的某个文件夹,否则 cookie 和本地存储只保存在内存中,所以每次重新运行时都不带任何会话。在我们的测试中,启动大约用了 0.3 秒,Shutdown 大约用了 0.2 秒。
第 3 步:通过主框架读取 sitekey
这是 CefSharp 特有的陷阱。在 .NET 10 上,紧接在 LoadUrlAsync 或 WaitForNavigationAsync 之后调用 browser.EvaluateScriptAsync,20 次运行中 20 次都抛出了异常。消息的开头是:Unable to execute javascript at this time, scripts can only be executed within a V8Context。页面的 JavaScript 上下文要在 8 到 18 毫秒之后才出现。在 WaitForInitialLoadAsync 之后调用,失败要少得多,但在 CEF 刚启动后并行启动的前三个浏览器中,仍有一两个会失败。同样的脚本通过 browser.GetMainFrame().EvaluateScriptAsync 发送,则从未失败,所以本指南中的每个脚本都经由主框架运行。
// data-sitekey covers most pages. A grecaptcha.render() call leaves no attributes,
// but Google's anchor iframe carries the key in k= and the widget size in size=
static async Task<(string Sitekey, bool Invisible, string? Callback)?> FindRecaptchaAsync(
ChromiumWebBrowser browser)
{
var r = await browser.GetMainFrame().EvaluateScriptAsync("""
(() => {
const el = document.querySelector('.g-recaptcha[data-sitekey]');
return el && [el.dataset.sitekey, el.dataset.size ?? '', el.dataset.callback ?? ''];
})()
""");
var attrs = r.Result as List<object>;
var anchor = browser.GetBrowser().GetAllFrames()
.Select(f => f.Url)
.FirstOrDefault(u => u.Contains("/recaptcha/") && u.Contains("/anchor?"));
var query = anchor is null
? null
: System.Web.HttpUtility.ParseQueryString(new Uri(anchor).Query);
var sitekey = attrs?[0] as string ?? query?["k"];
if (sitekey is null)
return null;
var invisible = (query?["size"] ?? attrs?[1] as string) == "invisible";
var callback = attrs?[2] as string;
return (sitekey, invisible, string.IsNullOrEmpty(callback) ? null : callback);
}CefSharp 返回的是真正的 .NET 值,而不是 JSON。JavaScript 字符串到手时就是不带引号的字符串,数组是元素为 object 的 List,整数是 Int32,null 或 undefined 则是 null。抛出异常的脚本不会在 C# 中引发异常:响应的 Success 为 false,JavaScript 错误则放在 Message 里。脚本返回的 Promise 会自动替你 await。
调用 grecaptcha.render() 的页面不会留下 data-sitekey 属性。但 Google 的 anchor iframe 仍然在它的 k 参数中带着 key,在 size 参数中带着小组件尺寸,而且 GetAllFrames() 会列出这个 iframe 及其地址,无论是否跨域,都不需要 JavaScript。框架的顺序在不同运行之间会变化,所以代码按地址而不是按位置查找。
第 4 步:用 CapSkip 识别验证码
把 sitekey 和页面地址发给 CapSkip。隐形小组件需要 invisible 选项,其他情况都用默认的复选框调用即可。
// CapSkip solves the widget for this sitekey and page and returns the token.
// CapSkip.TimeoutException is written in full: System has a TimeoutException too
static async Task<string?> SolveAsync(string sitekey, string pageUrl, bool invisible)
{
var solver = new CapSkipClient(host: "127.0.0.1", port: 8080);
var options = invisible ? new Dictionary<string, object?> { ["invisible"] = 1 } : null;
try
{
var result = await solver.RecaptchaAsync(sitekey, pageUrl, options);
return result.Code;
}
catch (NetworkException ex) { Console.WriteLine("Is CapSkip running? " + ex.Message); }
catch (ApiException ex) { Console.WriteLine("CapSkip could not solve it: " + ex.Message); }
catch (CapSkip.TimeoutException) { Console.WriteLine("No answer before the timeout"); }
catch (CapSkipError ex) { Console.WriteLine("CapSkip error: " + ex.Message); }
return null;
}有一个名称需要留意。在控制台项目的隐式 using 下,直接写 catch TimeoutException 会因 CS0104 编译失败,因为 System.TimeoutException 和 CapSkip.TimeoutException 都在作用域内。要写出完整的 CapSkip 名称。RecaptchaAsync 会在 SDK 的 recaptchaTimeout(默认 300 秒)到期后放弃,这个值在 CapSkipClient 构造函数中设置。慢识别通常会更早在 CapSkip 内部失败,这时到达你这里的是 ApiException。这里的 await 可能在任意线程上恢复执行,这没有问题:与 WebView2 不同,CefSharp 接受来自任意线程的脚本调用。
第 5 步:注入 token 并提交表单
把 token 放到 Google 的脚本本该放的位置,然后触发页面接下来要做的事,并等待表单提交到的那个页面。
// fill every response field, call the widget's callback or submit its form,
// then wait for the page the form posts to and read it
static async Task<string> SubmitTokenAsync(
ChromiumWebBrowser browser, string? callback, string token)
{
// start waiting before the submit, or the navigation can finish first
var navigation = browser.WaitForNavigationAsync(TimeSpan.FromSeconds(30));
var r = await browser.GetMainFrame().EvaluateScriptAsync($$"""
((token, cb) => {
const boxes = document.querySelectorAll('textarea[name="g-recaptcha-response"]');
boxes.forEach(b => { b.value = token; });
if (cb && typeof window[cb] === 'function') { window[cb](token); return 'called back'; }
const form = boxes[0]?.form;
if (!form) return 'no form found';
if (!form.checkValidity()) return 'form has invalid or empty fields';
form.requestSubmit();
return 'submitted';
})({{JsonSerializer.Serialize(token)}}, {{JsonSerializer.Serialize(callback)}})
""");
var how = r.Result as string ?? r.Message;
if (how is not ("submitted" or "called back"))
return how;
try
{
var page = await navigation;
var text = await browser.GetMainFrame().EvaluateScriptAsync("document.body.innerText");
return $"{how}, HTTP {page.HttpStatusCode}: {text.Result}";
}
catch (System.TimeoutException) { return how + ", but the page did not navigate"; }
}要填写每一个 g-recaptcha-response textarea,而不只是第一个。一个带两个小组件的表单,或者在 Google 的字段旁边还有自己字段的表单,会有两个同名字段,而 PHP 这类服务器只保留最后一个。如果小组件带有 data-callback 属性,脚本就用 token 调用那个函数,因为页面期望它被调用。否则脚本会调用 form.requestSubmit(),它会像点击按钮一样,运行页面自己的提交处理程序和 HTML5 校验。如果某个必填字段是空的,requestSubmit() 什么也不会提交,所以如果没有 checkValidity 那一行,应用会白白等满 30 秒,然后报告页面没有发生导航。有了这项检查,就能立即指出真正的原因。
要在提交脚本之前启动 WaitForNavigationAsync,而不是之后。在提交 50 毫秒后才启动,等待就会错过这次导航并超时。在提交之前启动,等待会在提交脚本返回后 10 到 30 毫秒内完成。WaitForNavigationAsync 默认只等待五秒,所以代码传入的是 30 秒。时间用完时,它会抛出 System.TimeoutException,这就是那个 catch 要写出完整 System 名称的原因。token 是以 JsonSerializer.Serialize 构建的 JSON 字符串字面量形式放进脚本的,所以其中的引号不会破坏脚本。不要改为把它作为第二个参数传给 EvaluateScriptAsync:那个重载会把第一个字符串当作要调用的函数名。
拿到 token 后要立即注入。Google 在它的 reCAPTCHA 验证指南 中说明,token 在签发两分钟后就会过期。在我们的测试中,表单在 CapSkip 返回后 16 到 21 毫秒就提交了。下面的完整示例会在 RunAsync 中调用这三个函数。
完整的控制台示例
用下面的代码替换 Program.cs,把 SiteUrl 改成你的页面,然后用 dotnet run 运行。
// Program.cs: load a page in a windowless CefSharp browser, solve its reCAPTCHA v2
// with CapSkip, submit the form and print the page that comes back
using System.Text.Json;
using CapSkip;
using CefSharp;
using CefSharp.OffScreen;
const string SiteUrl = "https://example.com/login";
var settings = new CefSettings
{
// CEF locks this folder: give each app, and each running copy, its own
RootCachePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CefApp"),
};
// InitializeAsync returns only when CEF is fully up
if (!Cef.InitializeAsync(settings).GetAwaiter().GetResult())
throw new InvalidOperationException("CEF did not start: " + Cef.GetExitCode());
try
{
RunAsync(SiteUrl).GetAwaiter().GetResult();
}
finally
{
Cef.Shutdown();
}
static async Task RunAsync(string url)
{
using var browser = new ChromiumWebBrowser(url);
// the wait has no timeout of its own: a malformed URL never finishes loading
var load = await browser.WaitForInitialLoadAsync().WaitAsync(TimeSpan.FromSeconds(30));
Console.WriteLine($"Loaded {url}: HTTP {load.HttpStatusCode}");
if (await FindRecaptchaAsync(browser) is not { } widget)
{
Console.WriteLine("No reCAPTCHA on this page");
return;
}
Console.WriteLine($"Sitekey {widget.Sitekey}, invisible: {widget.Invisible}");
var token = await SolveAsync(widget.Sitekey, browser.Address, widget.Invisible);
if (token is null)
return;
Console.WriteLine($"Token received, {token.Length} characters");
Console.WriteLine(await SubmitTokenAsync(browser, widget.Callback, token));
}
// data-sitekey covers most pages. A grecaptcha.render() call leaves no attributes,
// but Google's anchor iframe carries the key in k= and the widget size in size=
static async Task<(string Sitekey, bool Invisible, string? Callback)?> FindRecaptchaAsync(
ChromiumWebBrowser browser)
{
var r = await browser.GetMainFrame().EvaluateScriptAsync("""
(() => {
const el = document.querySelector('.g-recaptcha[data-sitekey]');
return el && [el.dataset.sitekey, el.dataset.size ?? '', el.dataset.callback ?? ''];
})()
""");
var attrs = r.Result as List<object>;
var anchor = browser.GetBrowser().GetAllFrames()
.Select(f => f.Url)
.FirstOrDefault(u => u.Contains("/recaptcha/") && u.Contains("/anchor?"));
var query = anchor is null
? null
: System.Web.HttpUtility.ParseQueryString(new Uri(anchor).Query);
var sitekey = attrs?[0] as string ?? query?["k"];
if (sitekey is null)
return null;
var invisible = (query?["size"] ?? attrs?[1] as string) == "invisible";
var callback = attrs?[2] as string;
return (sitekey, invisible, string.IsNullOrEmpty(callback) ? null : callback);
}
// CapSkip solves the widget for this sitekey and page and returns the token.
// CapSkip.TimeoutException is written in full: System has a TimeoutException too
static async Task<string?> SolveAsync(string sitekey, string pageUrl, bool invisible)
{
var solver = new CapSkipClient(host: "127.0.0.1", port: 8080);
var options = invisible ? new Dictionary<string, object?> { ["invisible"] = 1 } : null;
try
{
var result = await solver.RecaptchaAsync(sitekey, pageUrl, options);
return result.Code;
}
catch (NetworkException ex) { Console.WriteLine("Is CapSkip running? " + ex.Message); }
catch (ApiException ex) { Console.WriteLine("CapSkip could not solve it: " + ex.Message); }
catch (CapSkip.TimeoutException) { Console.WriteLine("No answer before the timeout"); }
catch (CapSkipError ex) { Console.WriteLine("CapSkip error: " + ex.Message); }
return null;
}
// fill every response field, call the widget's callback or submit its form,
// then wait for the page the form posts to and read it
static async Task<string> SubmitTokenAsync(
ChromiumWebBrowser browser, string? callback, string token)
{
// start waiting before the submit, or the navigation can finish first
var navigation = browser.WaitForNavigationAsync(TimeSpan.FromSeconds(30));
var r = await browser.GetMainFrame().EvaluateScriptAsync($$"""
((token, cb) => {
const boxes = document.querySelectorAll('textarea[name="g-recaptcha-response"]');
boxes.forEach(b => { b.value = token; });
if (cb && typeof window[cb] === 'function') { window[cb](token); return 'called back'; }
const form = boxes[0]?.form;
if (!form) return 'no form found';
if (!form.checkValidity()) return 'form has invalid or empty fields';
form.requestSubmit();
return 'submitted';
})({{JsonSerializer.Serialize(token)}}, {{JsonSerializer.Serialize(callback)}})
""");
var how = r.Result as string ?? r.Message;
if (how is not ("submitted" or "called back"))
return how;
try
{
var page = await navigation;
var text = await browser.GetMainFrame().EvaluateScriptAsync("document.body.innerText");
return $"{how}, HTTP {page.HttpStatusCode}: {text.Result}";
}
catch (System.TimeoutException) { return how + ", but the page did not navigate"; }
}应用会打印每个阶段:页面的 HTTP 状态、sitekey 以及小组件是否隐形、token 的长度,以及表单提交到的那个页面的文本。在没有小组件的页面上,它会说明这一点并停止。我们在 .NET 8 和 .NET 10 上用九个页面运行了它。其中有:自带 textarea 的复选框;同一个复选框,但使用 Google 真实的 api.js 及其公开测试 key;两个带回调的隐形小组件,一个用了 Google 的脚本,一个没用。其余几个是:带和不带 invisible 尺寸的显式渲染、两个响应字段、一个空着的必填字段,以及一个没有小组件的页面。如果识别工具没有运行,它也会在第一次尝试连接后大约两秒报告出来。
一次运行的大部分时间都在等待。页面上有 Google 的脚本时,识别请求在请求页面之后 0.3 到 1.3 秒到达 CapSkip,最长的是清除缓存后的第一次访问。这段时间大部分花在加载 api.js 及其 iframe 上。识别之后的所有步骤只用了几十毫秒。
有两种等待没有自己的超时。WaitForInitialLoadAsync 和 LoadUrlAsync 永远不会放弃,而格式错误的地址永远不会加载完成:一次没有加保护的测试,在我们两分半钟后停止它时仍在等待。这就是 RunAsync 要用 WaitAsync 包住第一次加载的原因。加载失败时仍然会完成,只是 Success 为 false。404 页面会报告它的状态码,关闭的端口则会在大约两秒后报告 ConnectionRefused。
同时处理多个页面
一次 Cef.Initialize 可以服务多个浏览器,每次识别也可以同时进行。不过,要给每个浏览器一个自己的 RequestContext。否则所有浏览器共用一个 cookie 存储,每个登录页面都会覆盖其他页面的会话 cookie。
// several pages at once: one Cef.Initialize, one browser per page. Each browser gets
// its own RequestContext, or they all share one cookie jar and one session
static Task RunManyAsync(string[] urls) =>
Task.WhenAll(urls.Select(async url =>
{
using var browser = new ChromiumWebBrowser(url, requestContext: new RequestContext());
await browser.WaitForInitialLoadAsync().WaitAsync(TimeSpan.FromSeconds(30));
if (await FindRecaptchaAsync(browser) is not { } widget)
return;
var token = await SolveAsync(widget.Sitekey, browser.Address, widget.Invisible);
if (token is not null)
Console.WriteLine(url + ": " + await SubmitTokenAsync(browser, widget.Callback, token));
}));三个共用上下文的浏览器并行识别,结果三次提交中有两次因 CSRF 不匹配被拒绝,因为 cookie 已经和表单对不上了。每个浏览器各用一个 RequestContext 后,三次提交全部被接受。在顶层用 RunManyAsync 代替 RunAsync 调用,并放在同样的 try 和 finally 中。这时编译器会警告 RunAsync 和 SiteUrl 未被使用,这无关紧要。CapSkip 的 reCAPTCHA 部分默认同时进行 10 个识别(由 Max. Threads 设置),其余的排队等待。
同一个应用的两个副本不能共用一个 RootCachePath。第二个副本在大约 0.1 秒内就启动失败,于是上面的代码抛出它的 InvalidOperationException,消息为 CEF did not start: NormalExitProcessNotified。更糟的是,已经在运行的那个副本稍后会在 Cef.Shutdown 时崩溃,八次运行中八次都是如此。给每个运行中的副本一个自己的文件夹。如果第二次启动仍有可能发生,就添加下面这个处理程序,它在每次运行中都阻止了崩溃。
// at the very end of Program.cs, then start CEF with
// Cef.InitializeAsync(settings, browserProcessHandler: new RelaunchHandler()).
// A second copy on the same RootCachePath still cannot start, but CEF no longer opens
// a window of its own in the running copy
class RelaunchHandler : CefSharp.Handler.BrowserProcessHandler
{
protected override bool OnAlreadyRunningAppRelaunch(
IReadOnlyDictionary<string, string> commandLine, string currentDirectory) => true;
}在 WinForms 或 WPF 中有什么不同
要在桌面应用里的 CefSharp 中识别 reCAPTCHA,使用 CefSharp.WinForms.NETCore 或 CefSharp.Wpf.NETCore 包,步骤不变。控件会自己启动和停止 CEF,所以你不需要调用 Cef.Shutdown。如果你想用自己的设置,就在创建第一个控件之前用这些设置调用 Cef.Initialize。脚本、框架和 CapSkip 调用的用法与上面完全相同。变化在于事件到达的位置。
// WinForms (CefSharp.WinForms.NETCore): CefSharp raises LoadingStateChanged, FrameLoadEnd
// and AddressChanged on its own thread, not the form's, so marshal control updates back
browser.LoadingStateChanged += (_, e) =>
{
if (e.IsLoading)
return;
statusLabel.BeginInvoke(() => statusLabel.Text = "Loaded " + browser.Address);
};CefSharp 在它自己的线程上触发 LoadingStateChanged、FrameLoadEnd 和 AddressChanged,而不是在拥有你窗体的那个线程上。脚本调用本身并不绑定到某个线程:EvaluateScriptAsync 在 UI 线程上、在 ConfigureAwait(false) 之后以及在 Task.Run 内部都能正常工作。这与 WebView2 正好相反,在 WebView2 中,任何来自其他线程的调用都会抛出异常。所以在 CefSharp 中,这条规则针对的是你自己的控件。在 WinForms 中,从事件里设置 Label,只有在附加了调试器时才会抛出 Cross-thread operation not valid,因为 WinForms 只在那时检查跨线程调用。没有调试器时,文本照样改变,也不会报告任何问题,所以 release 构建会把这个 bug 藏起来,而窗体之后仍可能出现异常行为。BeginInvoke 会在窗体的线程上执行更新。在 WPF 中,同样的更新总会抛出异常,消息为 The calling thread cannot access this object because a different thread owns it。在那里,要使用控件的 Dispatcher.InvokeAsync。
在另一台机器上运行 CapSkip
应用和识别工具不必在同一台电脑上。在连接设置里把 CapSkip 切换到 Server 模式,它就会监听你的网络地址或公网 IP。然后让客户端指向那里。
// CapSkip in Server mode on another Windows machine: replace the client line in SolveAsync 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 地址,每次建立新连接都要多花大约两秒。识别工具仍然是你自己的 Windows 机器,识别也仍然不计量。
在 CefSharp 中识别 reCAPTCHA 时的常见错误
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 添加包时出现 NU1202 | 项目的目标框架是不带 Windows 后缀的 net10.0 | 把目标框架设为 net10.0-windows 或 net8.0-windows |
| CefSharp.Core.Runtime.dll 报 FileNotFoundException | 没有 RuntimeIdentifier,所以 libcef.dll 从未被复制到 bin | 添加 RuntimeIdentifier win-x64 并重新构建 |
| Cef.Shutdown must be called on the same thread | 顶层的 await 把代码转移到了另一个线程上 | 保持顶层同步,并阻塞等待 RunAsync |
| Unable to execute javascript at this time | 新页面还没有 JavaScript 上下文时,脚本就在 browser 对象上运行了 | 通过 browser.GetMainFrame() 运行脚本 |
| 应用一直加载不完 | 地址格式错误,而加载等待没有超时 | 用带超时的 WaitAsync 包住这次等待 |
| 一个浏览器的提交被接受,其他浏览器的却被拒绝 | 并行的浏览器共用一个 cookie 存储 | 给每个浏览器一个自己的 RequestContext |
| 提交后页面又重新加载了登录表单 | 只填了一个响应字段、回调从未运行,或者 token 已过期 | 填写每一个字段,调用回调,并在识别后立即注入 |
| Is CapSkip running? No connection could be made | CapSkip 没有在运行,或者主机或端口不对 | 启动 CapSkip,并对照它的连接设置检查地址 |
常见问题
这适用于 reCAPTCHA v3 和 Enterprise 吗?
适用,只需改选项,不用换方法。Enterprise 页面加载的是 enterprise.js,而不是 api.js,这时 anchor 地址中会包含 /recaptcha/enterprise/,上面的框架查找已经能匹配到它。在 RecaptchaAsync 的选项里加上 enterprise 并设为 1。v3 页面的表单里既没有复选框,也没有 textarea:它的 sitekey 位于 api.js 的 render 参数中,而且页面是用自己的脚本发送 token。它的 anchor iframe 标着 size=invisible,所以 FindRecaptchaAsync 会把它报告为隐形小组件。先检查 api.js 地址中有没有 render=,然后传入设为 v3 的 version 和页面的 action,并去掉 invisible,SDK 在 v3 下会拒绝这个选项。关于 invisible 和 enterprise 选项的更多细节,请参阅这篇 用 C# 识别 reCAPTCHA v2 的指南.
在网站看来,OffScreen 浏览器像无头浏览器吗?
从明显的特征看不像。它的 user agent 是普通的 Chrome 152 字符串,其中没有 HeadlessChrome,navigator.webdriver 为 false,默认视图是 1366×768 像素。能看出的差异有两处。它发送的浏览器品牌标识里有 Chromium,却没有 Google Chrome;而且窗口的外部尺寸等于内部尺寸,因为页面周围没有浏览器边框。reCAPTCHA token 本身来自 CapSkip 的识别,而不是这个浏览器,而且我们的测试用的是 Google 的测试 key,所以我们没有测量过网站会给 CefSharp 浏览器打多少分。
可以不调用 Cef.Shutdown 吗?
在我们的测试中,省略它的应用会在 Main 返回后大约 0.1 秒正常退出。但 CefSharp 的文档仍然把它列为必需,而我们遇到的崩溃另有原因:在错误的线程上调用 Shutdown,或者同一个应用的第二个副本用了同一个缓存文件夹。把它保留在 finally 块中,在主线程上、在 InitializeAsync 完成之后调用。
应用能和 CapSkip 运行在不同的机器上吗?
可以。在连接设置里把 CapSkip 切换到 Server 模式,让它监听网络地址而不是回环地址,并把这个地址作为 CapSkipClient 的 host 传入。这样,应用就能从任何可以访问该端口的机器上,通过同一套 HTTP API 调用它。为不该访问它的地址添加一条 Windows Firewall 阻止规则。识别工具仍然运行在你自己的硬件上,没有任何一次识别会经过第三方服务。
简短版结论
要在 CefSharp 中识别 reCAPTCHA,把目标框架设为 net10.0-windows 或 net8.0-windows 并加上 RuntimeIdentifier,用 InitializeAsync 启动 CEF,并保持顶层同步,让 Cef.Shutdown 在同一个线程上运行。每个脚本都通过 GetMainFrame() 运行,从 data-sitekey 或 anchor iframe 读取 sitekey,再用 RecaptchaAsync 识别。填写每一个响应字段,调用回调函数或 form.requestSubmit(),并在提交之前就开始等待导航。
- 用 .NET 包能处理的所有验证码类型: C# 验证码识别页面.
- 复选框 reCAPTCHA 如何运作、识别工具覆盖哪些内容: reCAPTCHA v2 识别页面.
浏览器运行在你自己的 Windows 电脑上,识别工具也理应如此。CapSkip 这款 无限量验证码识别工具 就运行在同一台机器上,所以识别一整批页面的花费并不比识别一个页面更多。
