C# 网络爬虫:实用指南
Expert Network Defense Engineer
TL;DR:
HttpClient加上 AngleSharp 是现代 C# 的组合。 AngleSharp 使用 CSS 选择器,因此doc.QuerySelectorAll("div.quote")的行为与同一选择器在浏览器控制台中的行为一致。- Scrapeless Universal Scraping API 以 JSON 信封的形式响应。 标记内容在
data中到达,因此您首先读取 JSON,然后解析 HTML。 - PuppeteerSharp 通常的第一步是下载一个浏览器。连接到远程浏览器则完全跳过这一步。 每个快速入门都从
BrowserFetcher().DownloadAsync()开始;Puppeteer.ConnectAsync从不调用它。测量:验证项目完成时,磁盘上没有 Chromium,也没有 PuppeteerSharp 浏览器缓存,大小为 7.6 MB。 - 远程 CDP 路径毫不费力地工作。
ConnectAsync接受一个携带查询字符串凭据的wss://端点,并返回title=Quotes to Scrape,quotes on page=10。 - 免费开始: Scrapeless 仪表盘 发放的密钥可以与下面的每个示例一起使用。
你需要什么
以下所有内容均在 .NET SDK 8.0.129 上运行,目标网站为 quotes.toscrape.com,这是一个用于爬取练习的网站。
bash
dotnet new console -o scraper
cd scraper
dotnet add package AngleSharp # 1.7.1
dotnet add package PuppeteerSharp # 25.5.0
HttpClient 和 System.Text.Json 在基础类库中,因此唯一的依赖是解析器和后面的浏览器驱动。
关于解析器的选择:HtmlAgilityPack 是大多数 C# 爬虫材料选择的名称,它有效。 AngleSharp 在新代码中值得优先选择,因为它实现了 W3C DOM,并直接使用 CSS 选择器,这意味着您从浏览器开发工具中复制的选择器可以不变地工作,而不需要转换为 XPath。
获取和解析
csharp
using AngleSharp.Html.Parser;
using System.Text;
var http = new HttpClient();
http.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (compatible; csharp-guide/1.0)");
var res = await http.GetAsync("https://quotes.toscrape.com/");
var html = await res.Content.ReadAsStringAsync();
Console.WriteLine($"http={(int)res.StatusCode} bytes={Encoding.UTF8.GetByteCount(html)}");
var parser = new HtmlParser();
var doc = await parser.ParseDocumentAsync(html);
var quotes = doc.QuerySelectorAll("div.quote");
Console.WriteLine($"quotes={quotes.Length}");
Console.WriteLine($"first_author={quotes[0].QuerySelector("small.author")!.TextContent}");
Console.WriteLine($"first_text={quotes[0].QuerySelector("span.text")!.TextContent[..40]}");
text
http=200 bytes=11064
quotes=10
first_author=Albert Einstein
first_text=“The world as we have created it is a pr
值得注意的三个细节。
设置一个用户代理。 HttpClient 默认不发送用户代理,且没有用户代理的请求是网站区分对待的最便宜的方式之一。
Encoding.UTF8.GetByteCount(html) 不是 html.Length。 .NET 字符串是 UTF-16,因此 Length 计算的是 UTF-16 代码单元,而不是网络上的字节。在此页面上,两者有所不同,因为引号使用了卷曲引号。报告您指的内容,但在将直接获取与 API 响应进行比较时,不要将其混淆。
将子查询的范围限定于行。 quotes[0].QuerySelector(...) 在该元素内搜索;在循环中调用 doc.QuerySelector(...) 每次迭代返回第一个引号的值,产生看似完整但 uniformly 错误的数据集。
普通 C# 的止步之处
该脚本之所以有效,是因为目标在服务器端渲染,并且不屏蔽调用者。有两件事终止这一点:JavaScript 执行后才存在的内容,以及拒绝为裸 HTTP 客户端提供服务的网站。这两者都不是 .NET 的限制——任何语言中的 HTTP 客户端都无法处理。
从这里开始,升级使用的是不同的工具,而不是更好的工具,简单路径在有效的地方保持最便宜。关于浏览器添加的内容的大致形状,请参见 浏览器自动化解释。
升级一:Universal Scraping API
这使您的代码成为普通的 HttpClient 调用者,并将难度转移到服务器端。
csharp
using System.Text;
using System.Text.Json;
using AngleSharp.Html.Parser;
var key = Environment.GetEnvironmentVariable("SCRAPELESS_API_KEY")!;
var payload = JsonSerializer.Serialize(new
{
actor = "unlocker.webunlocker",
input = new { url = "https://quotes.toscrape.com/", js_render = false }
});
using var req = new HttpRequestMessage(
HttpMethod.Post, "https://api.scrapeless.com/api/v2/unlocker/request")
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
};
req.Headers.Add("x-api-token", key);
var apiRes = await http.SendAsync(req);
using var json = JsonDocument.Parse(await apiRes.Content.ReadAsStringAsync());
var keys = string.Join(",", json.RootElement.EnumerateObject().Select(p => p.Name));
Console.WriteLine($"http={(int)apiRes.StatusCode} envelope_keys={keys}");
var apiHtml = json.RootElement.GetProperty("data").GetString()!;
var apiDoc = await new HtmlParser().ParseDocumentAsync(apiHtml);
Console.WriteLine($"quotes={apiDoc.QuerySelectorAll("div.quote").Length}");
text
http=200 envelope_keys=code,data
quotes=10
信封是需要内化的部分。响应主体是 JSON,标记内容位于 data。将原始主体传递给 ParseDocumentAsync 会产生一个没有匹配元素且不抛出任何异常的文档——选择器安静地返回零结果。读取 JSON,获取 data,然后解析,并保持这个 unwrap 在一个方法中。
请注意使用 req.Headers.Add("x-api-token", key) 而不是 Authorization 头。它是一个自定义头,因此它直接附加在 HttpRequestMessage.Headers 上;试图将其表达为类型化的身份验证头是一条不适用的绕行路。
升级二:一个未下载的远程浏览器
在这里,C# 的表现明显优于通常的说明。每个 PuppeteerSharp 快速入门都是这样开始的:
csharp
// The standard first step — downloads a Chromium build before anything runs.
await new BrowserFetcher().DownloadAsync();
await using var browser = await Puppeteer.LaunchAsync(new LaunchOptions { Headless = true });
Puppeteer.ConnectAsync 不需要其中的任何内容。您正在连接到已经存在于其他地方的浏览器,因此 BrowserFetcher 从未涉及,也没有任何东西落在磁盘上:
csharp
using PuppeteerSharp;
var key = Environment.GetEnvironmentVariable("SCRAPELESS_API_KEY")!;
await using var browser = await Puppeteer.ConnectAsync(new ConnectOptions
{
BrowserWSEndpoint = $"wss://browser.scrapeless.com/api/v2/browser?token={key}"
});
var page = await browser.NewPageAsync();
await page.GoToAsync("https://quotes.toscrape.com/");
Console.WriteLine($"title={await page.GetTitleAsync()}");
var n = await page.EvaluateExpressionAsync<int>("document.querySelectorAll('.quote').length");
Console.WriteLine($"quotes on page={n}");
text
title=Quotes to Scrape
quotes on page=10
除了便利之外,这样做还为您带来了两件事。
项目保持小巧。 在运行本文中的每个示例后,验证项目在磁盘上的大小测量为 7.6 MB,不包含 Chromium,并且不留下 PuppeteerSharp 浏览器缓存。一个 LaunchAsync 工作流将浏览器构建添加到运行代码的每台机器和每个容器镜像中。
EvaluateExpressionAsync<T> 为您反序列化。 请求 <int> 会返回一个 int,因此没有 JsonElement 需要拆解,并且浏览器与您的变量之间没有手动解析。
值得了解的是:ConnectAsync 接受了一个 wss:// 端点,其中凭证在查询字符串中携带,而无需任何额外配置。这在各语言的 CDP 客户端之间并不普遍——有些会丢弃查询字符串或者省略 TLS 服务器名称协商——因此如果您是从另一个堆栈迁移,本部分很可能比您离开的部分更容易。
选择三者之间的差异
| 方法 | 使用时机 | 成本 |
|---|---|---|
HttpClient + AngleSharp |
服务器渲染的 HTML,目标宽松 | 最低;一个包 |
| 通用抓取 API | 被阻止或挑战,无需 JS | 一次 HTTP 调用,需拆解的信封 |
| PuppeteerSharp + 远程浏览器 | 内容需要 JavaScript 执行 | 每个任务一个会话,但磁盘上没有浏览器 |
从下往上查看列表。一页看似需要浏览器的页面通常在 <script> 标签中嵌入数据,而一个 QuerySelector 加上 JsonDocument.Parse 在各个维度上都胜过浏览器会话。
结论
C# 是一个舒适的抓取语言,标准库覆盖的内容比生态系统的教程暗示的要多。HttpClient 和 AngleSharp 处理服务器渲染的页面,您可以从开发工具中粘贴 CSS 选择器,而值得记住的两个陷阱是计算UTF-16单元时,您本是想计算字节,并在查询文档时,您本是想查询行。
浏览器故事比文档所暗示的更好。每个快速入门中的下载步骤是启动本地浏览器的一部分,而不是 PuppeteerSharp 的一部分,ConnectAsync 移除了它及其磁盘占用——一个 7.6 MB 的项目仍然驱动一个真实的 Chrome。
准备好用 C# 进行抓取吗?
在 Scrapeless 仪表板 创建一个密钥,并对您已收集的页面运行 HttpClient 示例。如果它返回您期望的结果,您就完成了——一个包,无需浏览器。 通用抓取 API 涵盖了其他情况下的页面,当前费用见 定价页面。
常见问题解答
问:AngleSharp 还是 HtmlAgilityPack?
两者对现实世界的 HTML 解析得很好。AngleSharp 实现了 W3C DOM,并接受 CSS 选择器,因此开发工具选择器无缝迁移;HtmlAgilityPack 以 XPath 优先,历史更为悠久。对于新代码,AngleSharp 通常意味着更少的翻译工作。
问:我是否必须下载 Chromium 才能使用 PuppeteerSharp?
仅当您启动本地浏览器时。 BrowserFetcher().DownloadAsync() 属于 LaunchAsync 路径;ConnectAsync 附加到已存在的浏览器,并且从不接触它。此处的验证项目保持在 7.6 MB,没有磁盘上的浏览器。
问:当 API 调用成功时,为什么我的解析返回为空?
您正在解析信封。响应是包含在 data 里的 JSON;因此 AngleSharp 接收到一个 JSON 字符串,不匹配任何内容,也不抛出任何内容。请先从 JSON 中读取 data。
问:为什么字节数与字符串长度不同?
.NET 字符串是 UTF-16,因此 string.Length 计算代码单位,而 Encoding.UTF8.GetByteCount 计算传输的字节。任何包含非 ASCII 字符(包括排版引号)的页面都会显示差异。
问:我应该重用 HttpClient 吗?
是的。创建一个并分享它,或者在托管应用中使用 IHttpClientFactory。每次请求构造一个新的 HttpClient 在负载下耗尽套接字——微软自己的 HttpClient 指南 清楚说明了原因——这对抓取器的适用性与其他 .NET HTTP 工作负载完全相同。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



