使用 DotNetBrowser 在 C# 中自动操作网页时,通常会通过它的 DOM API 来完成:找到输入框、设置其值,再点击按钮。但很多网页不会对这种方式设置的值作出响应。它们会等待用户实际输入或点击所产生的事件。例如,只有用户在输入框中输入内容后,按钮才会启用。

模拟键盘和鼠标输入可以解决这个问题。浏览器接收到的事件与真实键盘和鼠标产生的事件相同,因此网页无法区分两者。这种方式适用于自动化操作,例如在桌面应用中测试 Web 界面,或者填写无法修改的旧版 Web 应用中的表单。

本文将创建一个简单的 WPF 应用:在网页中输入一项待办任务,然后点击添加按钮。我们会分别使用 DotNetBrowser 和 CefSharp 实现这个应用,方便对照代码。最后,还会介绍如何使用 WebView2 完成同样的操作。

准备工作 

开始之前,请准备好:

  • Windows 10 或更高版本。
  • .NET 8 SDK 或更高版本。
  • DotNetBrowser 许可证密钥。试用密钥可免费使用 30 天。
  • Visual C++ Redistributable 2022(x64)。CefSharp 需要它,DotNetBrowser 则不需要。

创建网页 

首先,我们需要一个要自动操作的网页:一个包含文本框、Add 按钮和列表的愿望清单。创建一个文件夹存放两个项目,例如 BucketList,并在其中创建 shared 文件夹。将下面的页面保存到 shared 文件夹,文件名为 bucket-list.html,这样两个项目就能加载同一个文件。完整页面可在 GitHub 仓库中查看;这里省略了样式:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Bucket list</title>
</head>
<body>
  <h1>Bucket list</h1>
  <input id="task" type="text" placeholder="What do you want to do?" autofocus>
  <button id="add" disabled>Add</button>
  <ul id="list"></ul>
  <script>
    const input = document.getElementById('task');
    const addButton = document.getElementById('add');
    const list = document.getElementById('list');

    // 按钮由真实的输入事件启用,而不是通过设置 input.value 启用。
    input.addEventListener('input', () => {
      addButton.disabled = input.value.trim() === '';
    });

    addButton.addEventListener('click', () => {
      const item = document.createElement('li');
      item.textContent = input.value.trim();
      list.appendChild(item);
      input.value = '';
      addButton.disabled = true;
      input.focus();
    });
  </script>
</body>
</html>

页面加载时,Add 按钮处于禁用状态;只有触发 input 事件后,按钮才会启用。点击按钮会将任务添加到列表中、清空输入框,并将光标重新移回输入框。

列表为空时的愿望清单页面

输入任何内容之前的页面。

创建应用 

接下来,我们创建两个应用。它们都是只有一个窗口的 WPF 应用:上方显示浏览器,下方有两个按钮。Type task(输入任务) 用于在输入框中输入文字,Click Add(点击添加) 用于点击网页上的按钮。

DotNetBrowser 项目 

DotNetBrowser 是一款基于 Chromium 的 .NET 网页视图控件。首先,在 BucketList 文件夹中创建项目,并添加 DotNetBrowser.Wpf 程序包。该程序包会自动引入其他 DotNetBrowser 程序包:

dotnet new wpf -o DotNetBrowserApp
dotnet add DotNetBrowserApp package DotNetBrowser.Wpf

然后,在 DotNetBrowserApp.csproj 中添加以下内容,将网页复制到输出目录:

<ItemGroup>
  <None Include="..\shared\bucket-list.html" Link="bucket-list.html">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>

替换 MainWindow.xaml 中的内容:

<Window x:Class="DotNetBrowserApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:wpf="clr-namespace:DotNetBrowser.Wpf;assembly=DotNetBrowser.Wpf"
        Title="Bucket list — DotNetBrowser" Height="480" Width="640"
        Closed="Window_Closed">
    <DockPanel>
        <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
                    HorizontalAlignment="Center" Margin="8">
            <Button Content="Type task" Width="120" Margin="4"
                    Click="TypeTask_Click" />
            <Button Content="Click Add" Width="120" Margin="4"
                    Click="ClickAdd_Click" />
        </StackPanel>
        <wpf:BrowserView Name="browserView" />
    </DockPanel>
</Window>

替换 MainWindow.xaml.cs 中的内容。构造函数会创建 Engine 和 Browser,将 Browser 附加到视图,并加载网页。XAML 中引用了两个尚未实现的按钮事件处理程序,因此在接下来的部分添加这些处理程序之前,项目还无法编译:

using System;
using System.IO;
using System.Windows;
using DotNetBrowser.Browser;
using DotNetBrowser.Engine;

namespace DotNetBrowserApp
{
    public partial class MainWindow : Window
    {
        private readonly IEngine engine;
        private readonly IBrowser browser;

        public MainWindow()
        {
            engine = EngineFactory.Create(new EngineOptions.Builder
            {
                RenderingMode = RenderingMode.HardwareAccelerated,
                LicenseKey = "<your_license_key>"
            }.Build());
            browser = engine.CreateBrowser();

            InitializeComponent();
            browserView.InitializeFrom(browser);

            string page = Path.Combine(AppContext.BaseDirectory,
                                       "bucket-list.html");
            browser.Navigation.LoadUrl(page);
        }

        private void Window_Closed(object sender, EventArgs e)
        {
            browser.Dispose();
            engine.Dispose();
        }
    }
}

这里将渲染模式设为硬件加速,这样 Chromium 就会直接在窗口中绘制页面。Engine 指南还介绍了另一种模式:离屏渲染。

将 <your_license_key> 替换为你的许可证密钥。关于如何通过许可证文件等其他方式设置密钥,请参阅许可证指南。

CefSharp 项目 

接下来,我们用 CefSharp 实现相同的应用。CefSharp 是 Chromium Embedded Framework 的开源 .NET 封装库。在 BucketList 文件夹中创建第二个项目:

dotnet new wpf -o CefSharpApp
dotnet add CefSharpApp package CefSharp.Wpf.NETCore

CefSharp 会为不同架构提供不同的原生二进制文件,因此项目需要指定目标架构。在 CefSharpApp.csproj 中添加平台设置和网页文件:

<PropertyGroup>
  <PlatformTarget>x64</PlatformTarget>
  <RuntimeIdentifier>win-x64</RuntimeIdentifier>
  <SelfContained>false</SelfContained>
</PropertyGroup>
<ItemGroup>
  <None Include="..\shared\bucket-list.html" Link="bucket-list.html">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </None>
</ItemGroup>

除了标出的几行之外,窗口与前面的相同。主要区别是使用 ChromiumWebBrowser 而不是 BrowserView,并且不需要 Closed 事件处理程序。替换 MainWindow.xaml 中的内容:

<Window x:Class="CefSharpApp.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:cef="clr-namespace:CefSharp.Wpf;assembly=CefSharp.Wpf"
        Title="Bucket list — CefSharp" Height="480" Width="640">
    <DockPanel>
        <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
                    HorizontalAlignment="Center" Margin="8">
            <Button Content="Type task" Width="120" Margin="4"
                    Click="TypeTask_Click" />
            <Button Content="Click Add" Width="120" Margin="4"
                    Click="ClickAdd_Click" />
        </StackPanel>
        <cef:ChromiumWebBrowser Name="browser" />
    </DockPanel>
</Window>

CefSharp 会在创建第一个浏览器控件时启动 Chromium,并在应用退出时将其关闭,因此构造函数只需加载网页。替换 MainWindow.xaml.cs 中的内容:

using System;
using System.Collections.Generic;
using System.IO;
using System.Windows;
using CefSharp;

namespace CefSharpApp
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            string page = Path.Combine(AppContext.BaseDirectory,
                                       "bucket-list.html");
            browser.Address = new Uri(page).AbsoluteUri;
        }
    }
}

模拟键盘输入 

窗口准备好后,我们先让 Type task 按钮发挥作用。键盘每输入一个字符,通常会产生三个事件:按键按下、字符输入和按键释放。网页会分别收到 keydown、keypress 和 keyup 事件,字符插入后还会触发 input 事件。要模拟键盘输入,我们也需要触发这三个事件。与通过 JavaScript 分发、isTrusted 属性为 false 的事件不同,这些事件是可信事件,因此网页会像处理真实输入一样处理它们。

在 DotNetBrowser 中,IBrowser.Keyboard 提供 KeyPressed、KeyTyped 和 KeyReleased 事件,每个事件都有一个 Raise() 方法。在 DotNetBrowser 项目的 MainWindow.xaml.cs 中添加以下命名空间:

using DotNetBrowser.Input.Keyboard;
using DotNetBrowser.Input.Keyboard.Events;

然后在类中添加事件处理程序和辅助方法:

private void TypeTask_Click(object sender, RoutedEventArgs e)
{
    browser.Focus();
    browser.MainFrame.Document.GetElementById("task").Focus();
    TypeText(browser.Keyboard, "Learn to surf");
}

private static void TypeText(IKeyboard keyboard, string text)
{
    foreach (char c in text)
    {
        // 字母、数字和空格的按键代码与其大写字符代码相同。
        KeyCode key = (KeyCode)char.ToUpperInvariant(c);
        string keyChar = c.ToString();
        KeyModifiers modifiers = new KeyModifiers
        {
            ShiftDown = char.IsUpper(c)
        };

        keyboard.KeyPressed.Raise(new KeyPressedEventArgs
        {
            VirtualKey = key,
            KeyChar = keyChar,
            Modifiers = modifiers
        });
        keyboard.KeyTyped.Raise(new KeyTypedEventArgs
        {
            VirtualKey = key,
            KeyChar = keyChar,
            Modifiers = modifiers
        });
        keyboard.KeyReleased.Raise(new KeyReleasedEventArgs
        {
            VirtualKey = key,
            Modifiers = modifiers
        });
    }
}

点击 WPF 按钮会使 Browser 失去焦点,因此事件处理程序会先将焦点还给 Browser。然后,它通过 DOM API 将焦点移到文本框,以防用户之前点击了网页上的其他位置。

按下和输入字符事件会分别通过 VirtualKey 和 KeyChar 传递按键和字符;释放事件只传递按键。对于字母、数字和空格,按键代码与对应大写字符的代码相同,因此直接转换类型即可。大写字母还需要设置 ShiftDown,这与真实键盘的输入方式一致。对于 @ 这样的符号,需要传入用户键盘布局中用于输入该符号的按键。例如,在美式键盘上,可以使用 KeyCode.Vk2 并设置 ShiftDown。

在 CefSharp 中,键盘事件通过 IBrowserHost 发送,事件类型分别是 RawKeyDown、Char 和 KeyUp。对于 RawKeyDown 和 KeyUp,WindowsKeyCode 表示按键代码;对于 Char,它表示字符代码。在 CefSharp 项目的 MainWindow.xaml.cs 中添加事件处理程序和辅助方法:

private async void TypeTask_Click(object sender, RoutedEventArgs e)
{
    IBrowserHost host = browser.GetBrowserHost();
    host.SendFocusEvent(true);
    await browser.EvaluateScriptAsync(
        "document.getElementById('task').focus()");
    TypeText(host, "Learn to surf");
}

private static void TypeText(IBrowserHost host, string text)
{
    foreach (char c in text)
    {
        // 字母、数字和空格的按键代码与其大写字符代码相同。
        int keyCode = char.ToUpperInvariant(c);
        CefEventFlags modifiers = char.IsUpper(c)
            ? CefEventFlags.ShiftDown
            : CefEventFlags.None;

        host.SendKeyEvent(new KeyEvent
        {
            Type = KeyEventType.RawKeyDown,
            WindowsKeyCode = keyCode,
            Modifiers = modifiers
        });
        host.SendKeyEvent(new KeyEvent
        {
            Type = KeyEventType.Char,
            WindowsKeyCode = c,
            Modifiers = modifiers
        });
        host.SendKeyEvent(new KeyEvent
        {
            Type = KeyEventType.KeyUp,
            WindowsKeyCode = keyCode,
            Modifiers = modifiers
        });
    }
}

两种实现中的辅助方法很相似。DotNetBrowser 将按键和字符分别存放在不同字段中,而 CefSharp 只有一个整数型字段,其含义取决于事件类型。两者的事件处理程序差异更大:CefSharp 没有 DOM API,因此处理程序需要通过 SendFocusEvent 将焦点还给 Browser,再用 JavaScript 将焦点移到文本框。如需了解两个库的更多区别,请参阅 选择 DotNetBrowser 还是 CefSharp 以及迁移指南。

模拟鼠标点击 

接下来,让 Click Add 按钮发挥作用。一次鼠标点击包括移动鼠标、按下鼠标按钮和释放鼠标按钮,三者都发生在同一个位置。难点在于找到这个位置:也就是按钮在浏览器坐标系中的位置。

在 DotNetBrowser 中,可以通过 DOM API 找到元素,再使用 SimulateClick 扩展方法点击它。在 DotNetBrowser 项目的 MainWindow.xaml.cs 中添加以下命名空间:

using DotNetBrowser.Dom;
using DotNetBrowser.Input.Mouse;
using DotNetBrowser.Input.Mouse.Events;

然后添加事件处理程序:

private void ClickAdd_Click(object sender, RoutedEventArgs e)
{
    IElement addButton = browser.MainFrame.Document.GetElementById("add");
    browser.Mouse.SimulateClick(MouseButton.Left, addButton);
}

SimulateClick 会在元素中心触发 IMouse.Moved、IMouse.Pressed 和 IMouse.Released 事件,并在事件之间稍作停顿。如果已经知道坐标,可以使用接收 Point 的重载方法。如果要实现双击或拖动,就像模拟键盘输入那样,手动触发鼠标事件即可。Raise() 方法同样适用于 IBrowser.Mouse、IBrowser.Keyboard 和 IBrowser.Touch 上的所有事件,包括鼠标滚轮和触摸手势。更多示例请参阅 Browser 指南中的模拟用户输入。

在 CefSharp 中,我们先用脚本找到按钮中心的位置,再由 Browser 宿主在该位置发送鼠标事件。在 CefSharp 项目的 MainWindow.xaml.cs 中添加事件处理程序:

private async void ClickAdd_Click(object sender, RoutedEventArgs e)
{
    // 没有 DOM API,因此需要通过 JavaScript 获取按钮的位置。
    JavascriptResponse response = await browser.EvaluateScriptAsync(@"
        (() => {
            const rect = document.getElementById('add').getBoundingClientRect();
            return [rect.x + rect.width / 2, rect.y + rect.height / 2];
        })()");
    List<object> center = (List<object>)response.Result;
    int x = Convert.ToInt32(center[0]);
    int y = Convert.ToInt32(center[1]);

    IBrowserHost host = browser.GetBrowserHost();
    MouseEvent mouseEvent = new MouseEvent(x, y, CefEventFlags.None);
    host.SendMouseMoveEvent(mouseEvent, false);
    host.SendMouseClickEvent(mouseEvent, MouseButtonType.Left, false, 1);
    host.SendMouseClickEvent(mouseEvent, MouseButtonType.Left, true, 1);
}

EvaluateScriptAsync 会将 JavaScript 数组作为由装箱数值组成的 List<object> 返回。CefSharp 使用相对于视图左上角的坐标;对于主框架,getBoundingClientRect() 返回的正是这种坐标。每次调用 SendMouseClickEvent 都会发送一个事件:按下时,mouseUp 为 false;释放时,mouseUp 为 true。

运行应用 

两个项目现在都已完成。在 BucketList 文件夹中运行其中一个项目:

dotnet run --project DotNetBrowserApp
dotnet run --project CefSharpApp

网页加载完成后的 DotNetBrowser 和 CefSharp 窗口

两个应用启动后的窗口。

点击 Type task:文本会出现在输入框中,Add 按钮也会启用,因为网页收到了 input 事件。然后点击 Click Add:任务会出现在列表中,输入框被清空,按钮再次变为禁用状态。

按下两个按钮后的窗口

无需手动操作网页,任务也能添加到列表中。

包含两个项目的完整示例可在 GitHub 仓库中查看。

WebView2 怎么样? 

WebView2 是微软基于 Edge 的网页视图控件,但它没有提供模拟键盘输入的 API。对于鼠标输入,它提供了 CoreWebView2CompositionController.SendMouseInput,但该方法只能在通过组合 API 承载 WebView2 时使用,而且 WPF 和 Windows Forms 控件并未公开这个方法。

因此,可以改用 Chrome DevTools Protocol(CDP)。这是调试工具用来控制 Chromium 的 JSON 接口。通过 CallDevToolsProtocolMethodAsync,可以调用任意协议方法,包括 Input.dispatchKeyEvent 和 Input.dispatchMouseEvent。调用时需要手动编写 JSON 参数。例如,模拟一次按键按下:

await webView.CoreWebView2.CallDevToolsProtocolMethodAsync(
    "Input.dispatchKeyEvent",
    @"{ ""type"": ""keyDown"", ""key"": ""L"", ""text"": ""L"" }");

这种方式可以实现目标,但每个事件都需要手动编写 JSON 字符串,而不是使用类型化对象。此外,您还需要像 CefSharp 示例那样,通过 ExecuteScriptAsync 和 getBoundingClientRect() 自行计算坐标。详情请参阅方法参考和 Input 域。

相比之下,DotNetBrowser 提供了类型化的键盘和鼠标事件,以及用于查找目标元素的 DOM API,具体用法如前面的代码所示。下面对比了这三种网页视图控件:

CefSharpWebView2DotNetBrowser
输入使用原始按键代码调用 SendKeyEvent()DevTools Protocol通过 Keyboard 事件调用 Raise()
点击在指定坐标调用 SendMouseClickEvent()DevTools Protocol在 DOM 元素上调用 Mouse.SimulateClick()
查找目标元素JavaScriptJavaScriptDOM API

更多区别请参阅 选择 DotNetBrowser 还是 WebView2 以及 WebView2 迁移指南。

总结 

本文分别使用 DotNetBrowser 和 CefSharp 创建了相同的 WPF 应用,并通过模拟输入在网页中输入文字、点击按钮。如果网页会对值的变化作出响应,可以使用 DOM API;如果网页依赖输入事件,则可以模拟用户输入。完整示例见 GitHub 仓库。

借助模拟输入,应用可以像用户一样操作网页。无论是测试自己的 Web 界面,还是自动操作无法修改的 Web 应用,都可以采用这种方式。

Spinner

发送中。。。

抱歉,发送中断

请再次尝试,如果问题仍然存在,请联系我们 info@teamdev.com.

阅读并同意条款以继续。

您的个人 DotNetBrowser 试用密钥和快速入门指南将在几分钟内发送至您的电子邮箱。