使用AngleSharp提交表单






4.98/5 (17投票s)
登录、获取数据、登出。AngleSharp 拥有我们提交表单和传输所需数据以获取所需数据的全部功能。
目录
引言
偶尔我们会遇到以下问题:我们需要访问仅通过某个网页暴露的数据。不幸的是,该页面只有在提交登录表单后才能访问。我们可以做什么?大多数人会立即寻求像 PhantomJS 这样的解决方案,它相当笨重且仅限于强大的平台。例如,我们不能将使用 PhantomJS 的应用程序部署到普通智能手机上。
但是,我们很幸运。有许多 C# 项目试图解决这个问题。我们也可以直接使用标准的 HttpClient
并结合最先进的 HTML5 解析器。但正确地这样做很繁琐,而且 W3C 规范浩瀚。对于简单的问题,我们或许可以想出自己的、土法炼钢的解决方案,它能正常工作。但当页面(因此问题)发生变化时,我们可能需要重新考虑。
最后,我们可能会对一个能解决我们所有问题的最先进的解决方案感兴趣。本文将讨论 AngleSharp 库,它是完全用托管代码编写的(无头)浏览器的基础。
背景
我开始 AngleSharp 项目已经两年多了。最初计划只是一个小小的 HTML5 解析器组件,该项目很快就转变为成为 C# 中浏览器的基础。核心项目包含一个 HTML5 解析器、一个 CSS3 解析器、一个简单(但通常足够)的 HTTP 请求器以及许多其他实用程序。还有其他库(已发布或即将发布),它们负责脚本(例如连接 JavaScript 引擎)甚至渲染。
AngleSharp 的初始版本也在 CodeProject 上进行了记录。 第一篇文章 详细介绍了实现的部分。内部发生了一些变化,API 也更加成熟。目前我们即将迎来 AngleSharp v0.9 的发布。这距离真正的 AngleSharp v1 发布还有很短的时间。AngleSharp 使用 semver 版本控制(参见 http://semver.org),如果在任何地方发生破坏性更改,这将导致版本号急剧跳跃。因此,在 v1 发布之前,必须尽可能稳定和可扩展 API。
还讨论了连接 JavaScript 引擎。关于 AngleSharp 的 第二篇文章 概述了该库内部的一些进展和未来的路线图。这是一篇相当技术性的文档,描述了有哪些 JavaScript 引擎(尤其是在 .NET 中),以及为什么我们选择 Jint 而不是其他替代方案。此外,关于 JavaScript 引擎还有一些东西可以学习。
本文将更加以用户为中心。我们将讨论 AngleSharp 的(多或少)标准演示,并学习其 API。最有趣的是,我们将看到 AngleSharp 如何处理读取/操作网站的概念。
使用代码
提供的代码包含两个项目
- 一个非常简单的 ASP.NET MVC 网页。
- 一个基本的 WPF 桌面应用程序。
前者用于表示我们要访问的网站。我们感兴趣的特定数据仅对已认证的用户可用。后者是一个桌面应用程序,其中包含一个按钮,该按钮会触发一个登录、获取数据并登出网站的操作。
网站外观如下。以下截图显示了登陆页面。
包含秘密信息的页面只有一个小面板。渲染后如下。
WPF 客户端实际上只有一个按钮。应用程序的屏幕截图(在收到数据后)
尽管如此,WPF 客户端使用 MVVM 模式来提供代码的美观。让我们看看 VM
sealed class MainViewModel : BaseViewModel
{
State _state;
String _content;
RelayCommand _submit;
public MainViewModel()
{
_state = State.Idle;
_content = String.Empty;
_submit = new RelayCommand(async () =>
{
ChangeState(State.Loading);
// To be discussed in "Give Me the Code"
ChangeState(State.Finished);
});
}
public Boolean IsIdle
{
get { return _state == State.Idle; }
}
public Boolean IsLoading
{
get { return _state == State.Loading; }
}
public Boolean IsFinished
{
get { return _state == State.Finished; }
}
void ChangeState(State state)
{
_state = state;
TriggerChanged("IsIdle");
TriggerChanged("IsLoading");
TriggerChanged("IsFinished");
}
public RelayCommand Submit
{
get { return _submit; }
}
public String Content
{
get { return _content; }
set
{
_content = value;
TriggerChanged();
}
}
enum State
{
Idle,
Loading,
Finished
}
}
由于 UI 中发生的事情不多,因此谈论 XAML 并不是必需的。基本上我们只有一个按钮和一个文本框。一旦触发按钮操作,状态就会从空闲变为加载。最后,一旦我们收到所需数据,我们将状态更改为完成。
状态机制如下:在空闲状态下,只显示启用的按钮。加载状态显示一个内容为“Loading ...”的文本框,并禁用按钮。最后,在完成状态下,我们只看到文本框。文本框的内容是我们感兴趣的秘密页面的内容。
AngleSharp API
AngleSharp 向用户公开了一个功能齐全的 DOM。这需要许多组件的相互作用。该库本身不包含所有这些组件。相反,AngleSharp 具有扩展点,允许用户提供所需的功能。提供的(特定)功能集被聚合到一个 IConfiguration
接口的实例中。
核心库附带 IConfiguration
的标准实现,称为 Configuration
。标准实现提供了静态 Default
属性,该属性产生一个默认(通常为空)的配置。可以设置默认配置。如果未提供配置,它将在内部使用,因此非常有用。
在 AngleSharp 中有很多方法可以解析 HTML。但最好的方法是打开一个专用的 IBrowsingContext
。浏览上下文可以被看作是常用浏览器中的一个标签页。它是一个独立的单元,具有自己的安全设置、配置和历史记录。它还遵循加载文档的最佳实践,因此它知道如何与任何给定的 HTTP 请求器通信。它也对导航到页面或提交表单有用。后者对我们尤其感兴趣。
我们应该使用通过 BrowsingContext
类可访问的标准实现。创建新实例可以通过经典的 new
运算符,或通过静态 New
方法。后者在链式场景中看起来更好。所以让我们显式地使用默认配置创建一个新的上下文。
var context = BrowsingContext.New(Configuration.Default);
通过使用扩展方法向配置添加功能。任何 AngleSharp 的插件都将遵循相同的模式。这里最重要的概念是,IConfiguration
接口只定义了 getter。因此,它被认为是不可变的。由于没有插件可以期望使用特定的实现(例如 Configuration
),因此无法更改当前状态。因此,我们将始终收到一个新的 IConfiguration
实例,它将是前一个配置与新功能的聚合。
我们使用 With...()
扩展方法来创建一个新的、扩展的 IConfiguration
对象实例。在我们的特殊情况下,我们关心拥有一个 HTTP 请求器。尽管 AngleSharp 默认带有一个,但它不包含在 Configuration.Default
的默认设置中。我们需要包含它。
var configuration = Configuration.Default.WithDefaultRequester();
或者,我们也可以从一个全新的配置开始。从 Configuration.Default
获得的配置可能已经包含不需要的功能,具体取决于其他代码。这里我们写
var configuration = new Configuration().WithDefaultRequester();
除了确保 Configuration
不包含任何服务之外,实例化 Configuration
还有一个优点。我们可以设置区域设置信息。这不会影响数字等处理(它们都是不变的),但可能会影响规范中某些与文化相关的部分。例如,默认编码服务使用文化来确定默认编码。我们还可以集成自己的编码服务,例如,始终使用 UTF-8。但请记住,AngleSharp 的许多默认服务都是为了精确遵循标准而创建的。如果我们用自己的组件替换这些服务,我们可能会得到非标准的结果。
现在我们已经成功创建了一个包含即将进行的任务所需所有服务的 IConfiguration
的新 IBrowsingContext
,我们可以加载一个页面进行检查。IBrowsingContext
的方法再次以扩展方法的形式提供。这使得它们独立于具体实现。它们只需要实现 IBrowsingContext
定义的基本属性集。
所有方法都是 async
的。AngleSharp 尝试对所有内容都使用 TPL
。任何使用(可能是外部)流或需要以某种方式排队的内容都会被转换为 Task
。加载机制也适用于此。
如果我们想打开一个“空”页面,我们可以使用 OpenNewAsync
。我们也可以选择为这个空资源指定一个地址。这将是新文档的 baseURI
。基础 URL 只对导航、表单提交和其他事情有意义,但如果我们计划操作空文档,它可能会派上用场。
如果我们想打开一个“本地”页面,无论是使用现有的 Stream
(可能来自磁盘),还是使用现有的 string
实例,我们都可以使用以 Action
形式公开的虚拟响应接口,它是 OpenAsync
方法的重载。虚拟响应让我们能够决定我们希望从假设的服务器看到什么响应。
例如,如果我们有一个名为 sourceCode
的 string
变量,其中包含 Google 主页的(固定)源代码,我们可以使用以下指令
var document = await context.OpenAsync(res => res.
Address("http://www.google.com").
Status(200).
Header("Content-Language", "en").
Content(sourceCode));
链式调用使将大量语义传输到一行代码中变得非常容易。为了提高可读性,语句被分成多行。请注意,我们使用 await
来解包 Task<IDocument>
到 IDocument
,直到文档加载完成。
所有打开方法都会做同样的事情。它们将发送一个请求(如果需要),获取响应,并使用响应构造一个新文档。然后,文档由 HTML 解析器填充,该解析器异步地从响应的正文中构造 DOM。
当前文档也可以从上下文本身检索。上下文有一个名为 Active
的属性。该属性引用当前活动的 IDocument
。它是对“浏览选项卡,你当前显示什么文档?”这个问题的回答。
现在我们有了文档,下一个问题是——我们该用它做什么?我们可以使用 QuerySelectorAll
获取元素,或者只使用 QuerySelector
获取第一个元素。
var anchors = document.QuerySelectorAll("a");
var firstAnchor = anchors.FirstOrDefault();
// Alternatively
var firstAnchorDirect = document.QuerySelector("a");
使用 QuerySelector
和 QuerySelectorAll
以及 FirstOrDefault
LINQ 扩展方法的组合之间有一个细微但可能重要的区别:前者将在第一个匹配项处停止,而后者将明确迭代所有元素。原因很简单:QuerySelectorAll
已经返回了一个完全评估的集合。它不使用延迟评估。尽管如此,最大的信息是返回的类型实现了 IEnumerable<IElement>
,因此允许对结果使用 LINQ 语句。
单个元素由基接口 IElement
表示。但是 IElement
可能不会公开我们想要的锚定元素(IHtmlAnchorElement
)的属性或方法。我们可以使用 LINQ 进行类型转换。或者,我们可以包含 AngleSharp.Extensions
以方便一些方法。
var anchorsWithLinq = document.QuerySelectorAll("a").OfType<IHtmlAnchorElement>();
var anchorsConvinient = document.QuerySelectorAll<IHtmlAnchorElement>("a");
类型转换是使 DOM 操作比 JavaScript 更不愉快的事情之一。但它也使 DOM 操作更加可靠和稳定。
锚定元素还实现了 IUrlUtilities
。这会将 URL 与元素关联起来。当然,我们可能想导航到此 URL。但是我们不必获取 URL,联系浏览上下文并开始导航。相反,我们只需从扩展集中调用 Navigate
方法。
var anchor = document.QuerySelector<IHtmlAnchorElement>("a");
if (anchor != null)
document = await anchor.Navigate();
对 null
的检查可能是多余的,但使用它更好。QuerySelector
方法在找不到这样的元素时返回 null
。如预期的那样,导航方法是异步的。它返回导航目标的文档。理论上,我们可以使用 context.Active
,但为了方便,我们只需重新赋值 document
变量。
操作文档也很容易。我们可以使用官方 DOM 方法或方便的包装器。这些包装器有时熟悉 jQuery。通常它们在一组元素上工作,这些元素被提供为 IEnumerable<T>
,其中 T
必须实现 IElement
。
var anchors = document.QuerySelectorAll<IHtmlAnchorElement>("a");
anchors.AddClass("my-anchor-class").Attr(new { foo = "bar" });
document.QuerySelector("body").ClassList.Add("cs-body-element");
还有一些用于普通 DOM 操作的有用扩展。最重要的是 IDocument
的 CreateElement
方法为 C# 增加了一个很好的功能。通常,这个工厂方法只返回一个为请求的元素名称定制的 IElement
实例,例如:
var div = document.CreateElement("div");
但是使用这种方法可能需要额外的类型转换,如果我们想访问一些更专门的属性或方法。此外,可能只有一个类实现了我们想要的 DOM 接口。所以,例如,我们可以写以下代码来创建一个新的 HTML 锚定元素:
var newAnchor = document.CreateElement<IHtmlAnchorElement>();
这里不需要 string
。总的来说,这种方法应该在 C# 中受到青睐,但前提是只有一个实现类(通常是更专业接口的情况),因此名称与接口是一一对应的。在这种情况下,标签名 a 与 IHtmlAnchorElement
一一对应。
创建新元素只是故事的一部分。只要元素未附加到树上,它就不会集成到查询和任何类型的渲染中。AppendNode
方法可以链式调用,但只返回一个 INode
实例。幸运的是,我们有一个 AppendElement<T>
方法,它返回附加的元素及其对应的类型。
这允许如下代码工作。
document.Body.AppendElement(document.CreateElement<IHtmlAnchorElement>()).Href = "http://www.google.com";
可以在 AngleSharp 中构造、操作和使用表单元素。与浏览器一样,最重要的元素是 IHtmlFormElement
本身。然后我们混合了各种元素,如 IHtmlInputElement
、IHtmlButtonElement
和 IHtmLTextareaElement
,仅举几例。当然,IHtmlInputElement
可能是最常用的。它本身是许多状态的宿主,这些状态通过更改 Type
属性来设置。
var input = document.CreateElement<IHtmlInputElement>();
input.Type = "hidden";
默认情况下,Type
设置为 text。类型会影响一些(尤其是验证)方法的行为。AngleSharp 实现了一整套 HTML5 输入类型,包括约束验证模型。这允许像浏览器一样的表单验证。
有了这些基础,让我们看看我们示例的代码应该是什么样子。
给我代码
我们首先通过 NuGet 安装 AngleSharp。我们右键单击项目并选择“管理 NuGet 包...”。然后我们在网上搜索 AngleSharp 并点击安装。该包也可以在 NuGet 网站 上找到。
现在我们需要添加一些重要的命名空间。最重要的是我们需要 AngleSharp
命名空间,因为它包含 BrowsingContext
、Configuration
以及 IConfiguration
的扩展。我们还需要 AngleSharp.Extensions
,因为它包含处理 AngleSharp API 的有用助手。最后,我们还需要添加 AngleSharp.Dom
,甚至更专业的 AngleSharp.Dom.Html
。对于这个简单的示例,只需要后者。
using AngleSharp;
using AngleSharp.Dom.Html;
using AngleSharp.Extensions;
现在我们来决定 Web 服务器。这可以由应用程序的用户输入,或在某个配置文件中提供。我们将其硬编码为全局 static readonly
字段。
static readonly String WebsiteUrl = "https://:54361";
最后一步是填写 ViewModel 定义中的空白部分。我们想指定 Submit RelayCommand
的操作。
在剖析代码之前,我们应该先看一眼。代码不长,但功能很多。
_submit = new RelayCommand(async () =>
{
ChangeState(State.Loading);
var configuration = Configuration.Default.WithDefaultLoader().WithCookies();
var context = BrowsingContext.New(configuration);
await context.OpenAsync(WebsiteUrl);
await context.Active.QuerySelector<IHtmlAnchorElement>("a.log-in").Navigate();
await context.Active.QuerySelector<IHtmlFormElement>("form").Submit(new
{
User = "User",
Password = "secret"
});
await context.Active.QuerySelector<IHtmlAnchorElement>("a.secret-link").Navigate();
Content = context.Active.QuerySelector("p").Text();
ChangeState(State.Finished);
});
让我们回顾一下上面的代码做了什么。实际上有很多步骤,而且我们确实能够在很短的时间内完成所有这些步骤(本地连接不到一秒,例如在我机器上的调试版本中为 200 毫秒),这是非凡的。更令人惊叹的是开发速度。实现这个解决方案可能不到 5 分钟。
- 加载登陆页面
- 导航到具有 log-in 类名的锚定元素的 URL (
href
) - 等待登录页面加载
- 使用匿名对象中提供的键值对提交表单(页面的第一个/唯一表单)
- 等待表单提交并收到响应
- 导航到具有 secret-link 类名的锚定元素的 URL (
href
) - 等待内容页面加载
- 读取页面上第一个/唯一段落的内容(文本)
代码中最重要的部分是什么?正确的配置确实很重要。没有加载器,我们就没有 HTTP 请求器。我们就完蛋了。没有 cookie,我们就无法在页面之间传输身份验证。我们也无法验证验证令牌(稍后会详细介绍)。因此,cookie 对于登录表单或经过验证的表单是必须的。
var configuration = Configuration.Default.WithDefaultLoader().WithCookies();
虽然导航过程之前已经得到了相当多的解释,但我们并没有深入研究表单提交的细节。在 AngleSharp 中有多种方法可以进行表单提交。可能最流行的两种方法是
- 迭代包含的输入元素,如
IHtmlInputElement
或IHtmlTextareaElement
,并在Name
匹配时填写Value
。 - 使用一个辅助方法来提供一个键值对的
IEnumerable
,它携带相应的名称-值对。或者使用辅助方法提供一个匿名对象,该对象将被转换为这样的字典。
在我们的示例中,我们使用了后者。我们的输入名称非常适合使用匿名对象方法。如果它们很奇怪,我们可能无法使用有效的 C# 标识符。我们最终得到一行代码,它完成了从选择(希望是正确的!)表单到填写并提交它的所有工作。
context.Active.QuerySelector<IHtmlFormElement>("form").Submit(new
{
User = "User",
Password = "secret"
});
这些步骤完成了我们从主页收集数据所需的所有工作,而无需知道登录的确切 URL。我们只需要知道一些选择器来选择正确的元素并导航到它们的 URL。我们还需要知道输入字段(名称以及此处要求的名称)。所有这些都可以一步一步地研究。
就网站而言,没有什么特别之处。我们使用标准的表单身份验证模型。以下代码片段说明了 HomeController
类最重要的操作。由于这是一个简单的演示,我们不使用数据库或任何高级身份验证机制。我们只检查提供的凭据是否与预期的凭据匹配(只有一个)。
最重要的是,Secret
操作背后的信息是受保护的。我们使用标准的 AuthorizeAttribute
将身份验证检查的责任委托给框架。
[HttpGet]
public ViewResult LogIn()
{
return View();
}
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult LogIn(LogInModel model)
{
if (model.User == "User" && model.Password == "secret")
{
FormsAuthentication.SetAuthCookie(model.User, false);
return RedirectToAction("Index");
}
return View(model);
}
[HttpGet]
public RedirectToRouteResult LogOut()
{
FormsAuthentication.SignOut();
return RedirectToAction("LogIn");
}
[HttpGet]
[Authorize]
public ViewResult Secret()
{
return View();
}
另一件重要的是要认识到使用反伪造令牌来评估登录操作。显然,我们需要显式加载登录页面。如果我们直接将表单数据发送到服务器,我们将错过生成的反伪造令牌。因此,ValidateAntiForgeryTokenAttribute
在评估时将产生负面结果。结果,我们将无法登录。当然,这是不希望的。使用 AngleSharp 从一开始就使用有效的 BrowsingContext
的另一个原因。
兴趣点
我在一些会议和用户组会议上展示过这个演示的更完整的变体。第一次演讲是在 2015 年的 Developer Week。你可以在 GitHub 上找到原始示例。如果你对演示感兴趣,可以看看 我页面上的幻灯片。
观众的反应总是相当热情,尽管我意识到与 JavaScript 引擎连接的演示更受欢迎。我相信正确的表单提交和 HTTP 处理对于任何 HTML 工具都至关重要。最终,我们感兴趣的大部分 HTML 代码将来自服务器。与这些服务器通信应该无需安装其他库或提供自定义实现即可完成。
为什么 AngleSharp 核心库中的 HTTP 请求器功能有限?我个人很想增强这个功能,但由于 AngleSharp 被部署为 PCL(profile 259),我们无法访问平台特定的功能。幸运的是,使用的 PCL 配置文件带有一个 HTTP 请求器(WebRequest
和派生类 HttpWebRequest
)。这个特性使得核心库能够进行基本的 HTTP 请求。尽管如此,提供的请求器有一些平台相关的缺陷和一些平台无关的缺点。例如,我们无法接受某些 HTTPS 连接的证书。
未来将有一个库(名为 AngleSharp.Io),它将提供更好的解决方案。然而,这自然会比 PCL 有更强的依赖性。这些库都将是 AngleSharp GitHub 组织 的一部分。
历史
- v1.0.0 | 初始发布 | 2015.08.08
- v1.0.1 | 添加了一些链接 | 2015.08.11
- v1.0.2 | 修正了一些错别字 | 2015.08.12