跳至内容
免费开始
返回博客

使用 HttpClient、AngleSharp 和 Playwright 进行 C# 网页抓取

Sorin-Gabriel Marica最后更新于 7 min read
使用 HttpClient、AngleSharp 和 Playwright 进行 C# 网页抓取
简而言之:在 C# 中进行网页抓取,请从 .NET 10 入手:用内置的 HttpClient 下载网页,用 AngleSharp 通过 CSS 选择器解析 HTML。如果您更喜欢 XPath 或需要维护旧代码,请选择 HtmlAgilityPack;当页面由 JavaScript 构建时,再加入 Playwright for .NET。本指南构建一个爬虫,涵盖礼貌重试、有界爬取、CSV 和 JSON 导出以及离线测试。

C# 中的网页抓取始于一个常见的问题:你需要的数据位于某个网页上,而你希望在不离开 .NET 环境的情况下,将其转换为类型化的 C# 对象。在 .NET 10 上,该平台已经提供了你所需的大部分功能,而少数缺失的部分则可以通过成熟的 NuGet 包来补充。

本指南将逐步构建一个小型爬虫。你将使用 AngleSharp(或 HtmlAgilityPack,如果你更喜欢 XPath)解析页面,通过一个 HttpClient 以正确方式重试 429 响应,在受限并发条件下爬取分页列表,并导出 CSV 和 JSON 格式。随后,你将提取由 JavaScript 生成的数据,使用 Playwright for .NET 渲染页面,将受阻页面交由爬虫 API 处理(无需修改解析器),并离线测试所有功能。 示例中使用了两个专为数据抓取而建的公开练习站点:Books to Scrape 以及 /js/ Quotes to Scrape 的某个版本。

快速入门——安装 .NET 10 SDK,并使用 dotnet new console. - 添加解析器: dotnet add package AngleSharp. - 使用 HttpClient.GetStringAsync(),使用 QuerySelectorAll(),并使用 TextContent 和 GetAttribute()。 - 当 JavaScript 构建页面时,使用 Playwright for .NET。

在 C# 中进行网页抓取需要什么?

你需要 .NET 10 SDK、用于下载页面的 HttpClient(内置)以及 AngleSharp 或 HtmlAgilityPack 等 HTML 解析器来提取数据。 若需重试功能,请添加 Microsoft.Extensions.Http.Resilience;若需处理 CSV 文件,请添加 CsvHelper;当页面由 JavaScript 构建时,请使用 Playwright for .NET。其余所有组件均随 .NET 一起提供。

一个 C# 网页抓取程序会下载页面,查找您关注的部分,并将其保存下来。HttpClient 和 System.Text.Json 随 .NET 一起提供,而以下包则填补了其余功能缺口。

请从 .NET 10 开始。这是当前的长期支持版本,支持周期将持续至 2028 年末。根据 .NET 支持政策,.NET 8 和 .NET 9 均将于 2026 年 11 月 10 日结束支持,因此基于这些版本的教程即将过时。

C# 网页抓取库(截至 2026 年 10 月的 NuGet 版本)

库

用途

适用场景

NuGet(2026年10月)

HttpClient

下载页面和 API 响应

始终可用(内置于 .NET 中)

随 .NET 10 一起发布

AngleSharp

基于标准的 HTML 解析器,支持 CSS 选择器

从开发者工具中复制选择器

1.8.6

HtmlAgilityPack

支持 XPath 和 LINQ 的宽容型 HTML 解析器

您更倾向于使用XPath或需要维护旧版代码

1.13.1

Microsoft.Extensions.Http.Resilience

HttpClient 的重试、超时和 Retry-After 功能

远不止寥寥数个请求

10.11.0

CsvHelper

读写 CSV

导出行

34.0.0(新 API;请参阅 CSV 部分)

.NET 版 Playwright

无头 Chromium、Firefox、WebKit

基于 JavaScript 构建或交互式页面

1.64.1

PuppeteerSharp

采用 Puppeteer 风格 API 的无头 Chromium

移植 Node Puppeteer 脚本

25.15.0

Selenium.WebDriver

通过 WebDriver 实现浏览器自动化

现有的 Selenium 测试或 Grid

4.52.0

ScrapySharp、HtmlAgilityPack.CssSelectors.NetCore

旧版辅助工具

请勿在新代码中使用它们

最后发布于 2018 年和 2019 年

最后一行的两个名称在许多 C# 爬虫教程中仍然可见。ScrapySharp 的最后一次发布是在 2018 年,而 HtmlAgilityPack.CssSelectors.NetCore(该插件为 HtmlAgilityPack 提供了 CSS 选择器支持)的最后一次发布是在 2019 年。 如今这两者均已不再必要:AngleSharp 原生支持 CSS 选择器且正在积极维护,而 HtmlAgilityPack 本身也是最新的。前三行加上 Playwright 几乎可以满足所有项目的需求。

如何搭建一个 C# 爬虫项目?

创建一个控制台应用程序,并添加本指南静态部分所使用的四个包:

dotnet new console -n BookScraper
cd BookScraper
dotnet add package AngleSharp
dotnet add package HtmlAgilityPack
dotnet add package Microsoft.Extensions.Http.Resilience
dotnet add package CsvHelper

任何编辑器均可。在 Visual Studio 中,选择“控制台应用程序”模板,并通过“管理 NuGet 包”添加这些包。在 VS Code 中,安装 C# 开发工具包并在终端中运行相关命令。Rider 同样适用。

接下来,将每个练习页面的副本保存到 fixtures 文件夹中,以便离线开发解析器。这些文件稍后将作为你的测试数据:

mkdir fixtures
curl -o fixtures/books-page1.html https://books.toscrape.com/index.html
curl -o fixtures/books-page2.html https://books.toscrape.com/catalogue/page-2.html
curl -o fixtures/quotes-js.html https://quotes.toscrape.com/js/

在 Windows PowerShell 中,使用 Invoke-WebRequest -OutFile fixtures/books-page1.html https://books.toscrape.com/index.html 命令处理每个页面。从项目文件夹运行应用程序,以便 File.ReadAllText("fixtures/...") 能找到这些文件。

还有一项 .NET 10 功能: dotnet run app.cs 可运行单个 .cs 文件,无需项目文件,Playwright 示例中就采用了这种方式。

如何用 C# 抓取网页?

使用 HttpClient 下载 HTML,将其加载到 AngleSharp 的 HtmlParser 中,通过 QuerySelectorAll() 和 CSS 选择器选中重复出现的元素,然后使用 TextContent 读取文本,并使用 GetAttribute() 获取链接。 将每张卡片转换为类型化记录,使用不变文化解析价格,并根据来源页面的 URL 解析每个链接。

“Books to Scrape”是一个用于练习的书店,包含 1,000 本书籍,分布在 50 个目录页面中。每本书都位于一个 <article class="product_pod"> 卡片中,包含书名链接、星级评分、价格和库存信息。首先明确数据结构。一个 sealed record 提供了一个具有值相等性的不可变类型,因此测试可以比较整行数据,而一次 SequenceEqual 调用即可验证两个解析器的结果一致。

// Book.cs: one row of scraped data. [CsvRecord] lets CsvHelper 34 generate the CSV mapping at compile time.
using CsvHelper;

[CsvRecord]
public sealed partial record Book(string Title, decimal Price, int Rating, bool InStock, string Url);

该 [CsvRecord] 属性和 partial 关键字专用于 CsvHelper 34(参见导出部分)。接下来是解析器。AngleSharp 像浏览器一样解析 HTML,遵循 HTML 标准,并且支持与你在开发者工具中输入的完全相同的 CSS 选择器。

// BookParser.cs: turn one Books to Scrape listing page into Book records (AngleSharp)
using System.Globalization;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;

public static class BookParser
{
    static readonly HtmlParser Parser = new();

    static readonly Dictionary<string, int> RatingWords = new()
    {
        ["One"] = 1, ["Two"] = 2, ["Three"] = 3, ["Four"] = 4, ["Five"] = 5,
    };

    public static IDocument Load(string html) => Parser.ParseDocument(html);

    public static List<Book> ParseListing(IDocument page, Uri pageUrl)
    {
        var books = new List<Book>();
        foreach (var card in page.QuerySelectorAll("article.product_pod"))
        {
            var link = card.QuerySelector("h3 a");
            if (link is null) continue;                      // skip broken cards instead of crashing

            var priceText = card.QuerySelector("p.price_color")?.TextContent ?? "";
            var price = decimal.Parse(priceText.Trim().TrimStart('£'), CultureInfo.InvariantCulture);

            var ratingClass = card.QuerySelector("p.star-rating")?.ClassList
                .FirstOrDefault(c => RatingWords.ContainsKey(c));
            var stock = card.QuerySelector("p.availability")?.TextContent ?? "";

            books.Add(new Book(
                Title:   link.GetAttribute("title") ?? link.TextContent.Trim(),  // visible text is truncated
                Price:   price,
                Rating:  ratingClass is null ? 0 : RatingWords[ratingClass],
                InStock: stock.Contains("In stock", StringComparison.OrdinalIgnoreCase),
                Url:     new Uri(pageUrl, link.GetAttribute("href")).ToString()  // resolve against the page, not the site root
            ));
        }
        return books;
    }
}

每个字段都隐藏着一个小陷阱:

  • 卡片选择器。 article.product_pod 仅匹配每张图书卡片,且不匹配其他任何内容。
  • 书名。可见的链接文本会被截断(“A Light in the ...”),因此请从 title 属性中读取完整书名。
  • 价格。页面显示 £51.77。去除货币符号,并将其解析为 decimal ,并使用 CultureInfo.InvariantCulture。如果没有不变的文化设置, decimal.Parse 将使用计算机的区域设置,而在德国或罗马尼亚的计算机上,“51.77”要么解析失败,要么变成 5,177。
  • 评分。星星是通过 CSS 绘制的,HTML 中唯一的数据是一个类名,例如 star-rating Three。AngleSharp的 ClassList 会暴露这些类,并有一个小型字典将单词映射为1到5之间的数字。任何意外情况都会被设为0,而不是错误的猜测。
  • 库存。库存状态行变为 bool.
  • URL。本网站上的链接均为相对路径,且以页面所在文件夹为基准,而非网站根目录。在首页上,href 链接以 catalogue/,而在第2页则不以开头。 new Uri(pageUrl, href) 会根据链接来源页面解析每个链接,这与浏览器的处理方式一致。若将 href 链接与网站根目录关联,则会在首页正常显示,但在第 2 页及后续页面会返回 404 错误。

该 if (link is null) continue; 这一行代码可防止某张特殊卡片(例如广告位)导致运行失败。后续测试会检查行数。

请先在保存的副本上运行解析器,这样在迭代过程中就不会有操作触及网络:

// Program.cs (parse a saved page)
var pageUrl = new Uri("https://books.toscrape.com/index.html");
var page = BookParser.Load(File.ReadAllText("fixtures/books-page1.html"));
var books = BookParser.ParseListing(page, pageUrl);
foreach (var b in books.Take(3)) Console.WriteLine(b);
Console.WriteLine($"rows: {books.Count}");
Book { Title = A Light in the Attic, Price = 51.77, Rating = 3, InStock = True, Url = https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html }
Book { Title = Tipping the Velvet, Price = 53.74, Rating = 1, InStock = True, Url = https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html }
Book { Title = Soumission, Price = 50.10, Rating = 1, InStock = True, Url = https://books.toscrape.com/catalogue/soumission_998/index.html }
rows: 20

一旦输出结果看起来正确,就让同一个解析器处理实时页面。 PoliteHttp.Create() 辅助程序是下方 HttpClient 部分中配置的客户端。初次尝试时,一个简单的 new HttpClient() 同样可行。

// Program.cs (same parser, live page)
using var http = PoliteHttp.Create();
var pageUrl = new Uri("https://books.toscrape.com/index.html");
var books = BookParser.ParseListing(BookParser.Load(await http.GetStringAsync(pageUrl)), pageUrl);
Console.WriteLine($"live rows: {books.Count}; first: {books[0].Title} ({books[0].Price})");
live rows: 20; first: A Light in the Attic (51.77)

如何改用 HtmlAgilityPack 和 XPath 进行抓取?

HtmlAgilityPack 是历史悠久的 .NET HTML 解析器,大多数较早的 C# 抓取教程都使用它。它至今仍在维护中,对错误的标记具有很强的容错性,并且基于 XPath 查询构建。如果您的团队已经在使用它,或者您习惯使用 XPath 进行编程,以下是相同的提取代码:

// BookParserXPath.cs: the same extraction with HtmlAgilityPack and XPath
using System.Globalization;
using HtmlAgilityPack;

public static class BookParserXPath
{
    public static List<Book> ParseListing(string html, Uri pageUrl)
    {
        var doc = new HtmlDocument();
        doc.LoadHtml(html);

        var cards = doc.DocumentNode.SelectNodes("//article[contains(@class,'product_pod')]");
        if (cards is null) return [];                       // SelectNodes returns null, not an empty list

        return cards.Select(card =>
        {
            var link = card.SelectSingleNode(".//h3/a")!;
            var price = card.SelectSingleNode(".//p[@class='price_color']")!.InnerText.Trim().TrimStart('£');
            var rating = card.SelectSingleNode(".//p[contains(@class,'star-rating')]")!
                .GetClasses().Last();                         // "star-rating Three" -> "Three"
            return new Book(
                Title:   HtmlEntity.DeEntitize(link.GetAttributeValue("title", "")),
                Price:   decimal.Parse(price, CultureInfo.InvariantCulture),
                Rating:  Array.IndexOf(["One", "Two", "Three", "Four", "Five"], rating) + 1,
                InStock: card.SelectSingleNode(".//p[contains(@class,'availability')]")!.InnerText.Contains("In stock"),
                Url:     new Uri(pageUrl, link.GetAttributeValue("href", "")).ToString());
        }).ToList();
    }
}

由于 Book 是一个记录,通过一次比较即可证明这两个解析器结果一致:

// Program.cs (compare with AngleSharp)
var books = BookParserXPath.ParseListing(File.ReadAllText("fixtures/books-page1.html"),
                                         new Uri("https://books.toscrape.com/index.html"));
Console.WriteLine($"rows: {books.Count}; first: {books[0]}");
var same = books.SequenceEqual(BookParser.ParseListing(
    BookParser.Load(File.ReadAllText("fixtures/books-page1.html")),
    new Uri("https://books.toscrape.com/index.html")));
Console.WriteLine($"identical to AngleSharp result: {same}");
rows: 20; first: Book { Title = A Light in the Attic, Price = 51.77, Rating = 3, InStock = True, Url = https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html }
identical to AngleSharp result: True

XPath 版本使用了四个模式: //tag[contains(@class,'x')] 通过类名查找任意位置的元素, .// 用于在当前节点内部搜索, [@class='x'] 用于精确匹配属性,以及 / 查找直接子节点。若遇到超出上述范围的情况,请随时查阅 XPath 语法参考。

请注意一个陷阱: SelectNodes() 当没有匹配项时, null,而非空集合。如果在未进行空值检查的情况下对结果进行循环处理, NullReferenceException 。 HtmlEntity.DeEntitize 之所以存在,是因为 HtmlAgilityPack 会保留诸如 &amp; 等实体保留在属性值中。

如果你希望完全跳过 XPath,HtmlAgilityPack 也支持 LINQ。 doc.DocumentNode.Descendants("article").Where(n => n.HasClass("product_pod")) 可以找到相同的卡片,并且你可以从那里开始 Select() 。

如果你从浏览器开发者工具中复制选择器,并且希望进行基于标准的解析,请使用 AngleSharp。如果你更喜欢 XPath 或 LINQ,或者正在维护已经使用它们的代码,请使用 HtmlAgilityPack。

如何找到合适的 CSS 选择器?

在 Chrome 或 Firefox 中打开页面,右键单击所需的数据,然后选择“检查”。 找到每个记录中重复出现一次的类名或标签,在开发者工具控制台中使用 document.querySelectorAll() 进行测试,然后将该选择器粘贴到 AngleSharp 的 QuerySelectorAll() 中。建议优先使用简短且稳定的类名,而非从开发者工具中复制的冗长生成路径。

在“Books to Scrape”网站上,右键单击书名并选择“检查”后,会跳转到 <a> 位于 <h3>,位于 <article class="product_pod">。该 `article` 元素即为记录边界:每本书一个,每页二十个。在控制台中, document.querySelectorAll("article.product_pod").length 返回 20。

本指南中的每个选择器都使用以下六种模式之一:

模式

示例

匹配条件

标签

article

每个 <article> 元素

类

.product_pod

具有该类的任何元素

标签和类

p.price_color

<p> 具有该类的元素

后代

h3 a

一个 <a> 位于 <h3>

属性

a[href]

具有 href

组合

li.next a

“next”列表项内的链接

开发者工具的“复制选择器”会生成诸如 #default > div > div > ... > li:nth-child(1) > article ,当布局中添加了包裹元素时,这些路径就会失效 div时会失效。而基于有意义类名的简短选择器则能经受住重新设计。

XPath 是另一种选择,它能实现 CSS 无法做到的事情,例如根据元素的文本进行选择,或向上遍历至父元素。如需并排对比,请参阅《何时 XPath 胜过 CSS》。

提示:如果某个选择器在 DevTools 中能找到元素,但在 C# 中却返回零,那么该数据很可能并不存在于原始 HTML 中。DevTools 显示的是 JavaScript 执行后的页面,而 HttpClient 只能看到服务器发送的内容。请按 Ctrl+U(查看源代码)并搜索该文本。 如果找不到,请跳转到下文的 JavaScript 部分。

如何配置 HttpClient 进行网页抓取?

创建一个 HttpClient 并将其复用以处理所有请求,设置 PooledConnectionLifetime 以确保 DNS 变更能被及时检测到,发送真实的 User-Agent,并添加一个重试管道——该管道在遇到 408、429 和 5xx 响应时会进行退避,并遵守 Retry-After 指令。 Microsoft.Extensions.Http.Resilience 仅需约十五行代码即可实现这种重试行为。

有两个典型的错误。第一个是每次请求都创建一个新的 HttpClient :每个实例都会打开独立的连接,处理几千个页面时,这可能会耗尽机器的套接字资源。第二个错误是使用静态客户端且从不回收连接,因此当网站的 DNS 发生变更后,它仍会继续向旧的 IP 地址发送请求。

微软的 HttpClient 指南解决了这两个问题:使用一个长生命周期的客户端,并在 PooledConnectionLifetime 其处理程序,确保每隔几分钟回收一次连接。同一指南还展示了如何为静态客户端添加容错机制,即下文所示的模式。

// PoliteHttp.cs: one shared HttpClient with an honest user agent, timeouts, and retries
using System.Net;
using Microsoft.Extensions.Http.Resilience;
using Polly;

public static class PoliteHttp
{
    public static HttpClient Create(TimeSpan? attemptTimeout = null)
    {
        var pipeline = new ResiliencePipelineBuilder<HttpResponseMessage>()
            .AddRetry(new HttpRetryStrategyOptions
            {
                MaxRetryAttempts = 3,                        // retries 408, 429, 5xx and network errors
                BackoffType = DelayBackoffType.Exponential,  // waits longer each time, honors Retry-After
                UseJitter = true,
            })
            .AddTimeout(attemptTimeout ?? TimeSpan.FromSeconds(30))
            .Build();

        var handler = new ResilienceHandler(pipeline)
        {
            InnerHandler = new SocketsHttpHandler
            {
                PooledConnectionLifetime = TimeSpan.FromMinutes(5),
                AutomaticDecompression = DecompressionMethods.All,
            },
        };

        var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; // the pipeline owns timeouts
        http.DefaultRequestHeaders.UserAgent.ParseAdd("csharp-scraping-tutorial/1.0 (+mailto:you@example.com)");
        return http;
    }
}

各部分的功能如下:

  • SocketsHttpHandler 是 HttpClient. PooledConnectionLifetime = 5 minutes 负责回收连接,从而使 DNS 变更生效,而 AutomaticDecompression 并处理 gzip、Brotli 和 deflate 压缩格式。
  • 该重试策略源自微软的 HTTP 弹性库,该库基于 Polly 构建。 HttpRetryStrategyOptions 它已预先识别出哪些错误值得重试:408 请求超时、HTTP 429 请求过多、5xx 服务器错误、网络异常以及超时。该策略采用指数退避机制,并加入抖动以避免并行工作者同步重试,同时在服务器发送 Retry-After 标头(即服务器告知应暂停多长时间)。
  • 该超时策略默认将每次尝试的时长设为 30 秒。 HttpClient.Timeout 被设置为无限,因为 HttpClient,否则该参数自身的100秒默认值会干扰重试流程。重试超时由管道自行管理。可选的 attemptTimeout 参数允许较慢的调用(例如渲染后的 API 请求)使用更长的超时限制。
  • User-Agent 用于标识您的爬虫,并提供一种联系您的方式。网站可以联系您,而不是直接封禁您。请将电子邮件地址替换为您自己的。关于其余请求头及其适用场景,请参阅“哪些请求头对爬取重要”。

重试三次就足够了。重试更多次很少能将拒绝请求转为成功,因此请记录失败并继续执行。

在 ASP.NET Core 或工作线程服务中,请跳过静态辅助方法,转而让依赖注入来管理客户端。使用 builder.Services.AddHttpClient<BookFetcher>() ,并链式调用 .AddStandardResilienceHandler() 以获取重试、超时以及具有合理默认值的断路器功能。 IHttpClientFactory 随后会为你处理连接生命周期。本指南中的解析器类完全不会改变,仅需调整 HttpClient 的来源发生了变化。

如何用 C# 抓取多个页面?

解析第一页,读取下一页链接,使用 new Uri(pageUrl, href) 根据当前页面 URL 解析该链接,并重复此过程,直到链接消失或达到页面限制。 复用同一个 HttpClient,请求间隔约一秒,并让重试管道处理 429 和 503 响应。

Books to Scrape 将其目录分成了 50 页,每页末尾都有一个分页器,其“下一页”项为 <li class="next"><a href="page-2.html">next</a></li>。沿着该链接一直追踪直至其消失,意味着你无需猜测页面总数。

// Crawler.cs: follow "next" links politely, then visit detail pages with bounded concurrency
using System.Collections.Concurrent;
using AngleSharp.Dom;

public static class Crawler
{
    public static async Task<List<Book>> CrawlCatalogAsync(HttpClient http, Uri start, int maxPages = 3)
    {
        var books = new List<Book>();
        Uri? url = start;
        for (var page = 1; url is not null && page <= maxPages; page++)
        {
            var html = await http.GetStringAsync(url);
            var doc = BookParser.Load(html);
            books.AddRange(BookParser.ParseListing(doc, url));

            var next = doc.QuerySelector("li.next a")?.GetAttribute("href");
            url = next is null ? null : new Uri(url, next);   // relative to the current page
            await Task.Delay(TimeSpan.FromSeconds(1));         // about one request per second
        }
        return books;
    }

    public sealed record BookDetail(string Url, string Upc, int Available);

    public static async Task<List<BookDetail>> FetchDetailsAsync(HttpClient http, IEnumerable<string> urls)
    {
        var results = new ConcurrentBag<BookDetail>();
        var options = new ParallelOptions { MaxDegreeOfParallelism = 3 };   // a small, fixed cap per site

        await Parallel.ForEachAsync(urls, options, async (url, ct) =>
        {
            var doc = BookParser.Load(await http.GetStringAsync(url, ct));
            var table = doc.QuerySelectorAll("table.table-striped tr")
                .ToDictionary(r => r.QuerySelector("th")!.TextContent.Trim(),
                              r => r.QuerySelector("td")!.TextContent.Trim());
            var available = new string(table["Availability"].Where(char.IsDigit).ToArray());
            results.Add(new BookDetail(url, table["UPC"], int.Parse(available)));
            await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
        });

        return results.OrderBy(d => d.Url).ToList();
    }
}

CrawlCatalogAsync 在每次循环中执行四项操作:获取页面、使用相同的 BookParser解析书目,查找,并等待一秒。 li.next a,并等待一秒。这里的 new Uri(url, next) 行在此处也至关重要。第1页上的下一个链接是 page-2.html,相对于 /catalogue/,通过当前页面解析它每次都能得到正确的绝对URL。每个页面都会被传递给 ParseListing ,因此每页上的每条书籍链接也能正确解析。

该 maxPages 参数起着安全阀的作用。开发期间请将其值设得较小,这样即使出现选择器错误或循环漏洞,也只会产生三个请求,而非三千个。待输出结果正确后,再将其值调高。

从 catalogue/page-1.html,而非首页,这样每个页面的 URL 结构都保持一致。

// Program.cs (crawl three pages)
using var http = PoliteHttp.Create();
var books = await Crawler.CrawlCatalogAsync(http, new Uri("https://books.toscrape.com/catalogue/page-1.html"), maxPages: 3);
Console.WriteLine($"pages: 3, rows: {books.Count}");
Console.WriteLine(books[20].Url);
using var check = await http.GetAsync(books[20].Url);
Console.WriteLine($"status of a parsed detail URL: {(int)check.StatusCode}");
pages: 3, rows: 60
https://books.toscrape.com/catalogue/in-her-wake_980/index.html
status of a parsed detail URL: 200

第 21 行是第 2 页上的第一本书,其 URL 返回 200 状态码,这证实了基于页面的相对路径解析在第一页之后依然有效。而基于根目录的相对路径连接通常正是在此处出现问题。

这个“下一个链接”循环是一个小型爬虫,它无法访问同一页面两次,因为每个页面只指向下一个页面。一旦你跟随可能形成回环的链接(例如分类页面、筛选器或“相关项目”),请维护一个 HashSet<string> 已访问的URL列表,并跳过其中已有的内容。如果这两项任务的区别不太明确,以下是抓取与爬虫的区别。

一秒的延迟是自行设定的。在真实网站上,应从低速开始,留意 429 错误,并将 robots.txt 和搜索条件视为上限。

如何在 C# 中并行抓取页面?

详情页包含 UPC 和精确库存数量等额外信息。逐一获取这些信息速度较慢,而一次性全部获取又会造成系统负担。 Parallel.ForEachAsync 介于两者之间:它为每个项目运行一个异步主体,最多 MaxDegreeOfParallelism 个任务同时运行。

FetchDetailsAsync 在上述爬虫中,该上限被设为三个。建议为每个网站选择一个较小的固定数值,而不是 Environment.ProcessorCount——这是许多示例中常用的数值。您的CPU核心数并不能说明网站能处理多少请求,而且抓取过程几乎完全取决于网络响应时间。

结果会被存入一个 ConcurrentBag中,该数组支持多个任务同时安全地向其追加数据,且由于并行任务的完成顺序不固定,因此会在最后进行排序。

// Program.cs (detail pages, three at a time)
using var http = PoliteHttp.Create();
var page1 = new Uri("https://books.toscrape.com/catalogue/page-1.html");
var books = BookParser.ParseListing(BookParser.Load(await http.GetStringAsync(page1)), page1);
var details = await Crawler.FetchDetailsAsync(http, books.Take(6).Select(b => b.Url));
foreach (var d in details) Console.WriteLine($"{d.Upc}  {d.Available,3} available  {d.Url}");
a897fe39b1053632   22 available  https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
4165285e1663650f   20 available  https://books.toscrape.com/catalogue/sapiens-a-brief-history-of-humankind_996/index.html
e00eb4fd7b871a48   20 available  https://books.toscrape.com/catalogue/sharp-objects_997/index.html
6957f44c3847a760   20 available  https://books.toscrape.com/catalogue/soumission_998/index.html
f77dbf2323deb740   19 available  https://books.toscrape.com/catalogue/the-requiem-red_995/index.html
90fa61229261140a   20 available  https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html

每个任务共享同一个 HttpClient,这既安全也是设计初衷: HttpClient 该对象在发送请求时是线程安全的,共享它意味着重试管道和连接池可同时应用于所有工作线程。

如何在 C# 中将抓取的数据保存为 CSV 或 JSON 格式?

对于 JSON,请使用 .NET 自带的 System.Text.Json 对记录列表进行序列化。 对于 CSV,请使用 CsvHelper:用 [CsvRecord] 标记记录,通过 CsvWriter.ToFile() 打开写入器,并调用 WriteRecords()。CsvHelper 34 默认使用不变文化格式写入数字,因此价格在任何机器上都会保留小数点。

这两种格式只需几行代码:

// Export.cs: write results to CSV (CsvHelper 34) and JSON (System.Text.Json)
using System.Text.Json;
using CsvHelper;

public static class Export
{
    public static void ToCsv(IEnumerable<Book> books, string path)
    {
        using var csv = CsvWriter.ToFile(path);   // UTF-8, header row first, invariant culture by default
        csv.WriteRecords(books);
    }

    public static void ToJson(IEnumerable<Book> books, string path) =>
        File.WriteAllText(path, JsonSerializer.Serialize(books, new JsonSerializerOptions { WriteIndented = true }));
}
// Program.cs (save results)
var pageUrl = new Uri("https://books.toscrape.com/index.html");
var books = BookParser.ParseListing(BookParser.Load(File.ReadAllText("fixtures/books-page1.html")), pageUrl);
Export.ToCsv(books, "books.csv");
Export.ToJson(books, "books.json");
Console.WriteLine(string.Join(Environment.NewLine, File.ReadLines("books.csv").Take(3)));
Console.WriteLine($"wrote books.csv and books.json with {books.Count} rows");
Title,Price,Rating,InStock,Url
A Light in the Attic,51.77,3,True,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Tipping the Velvet,53.74,1,True,https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
wrote books.csv and books.json with 20 rows

标题行来自记录的属性名称,列顺序遵循记录的参数顺序。JSON 保留相同的名称和类型,因此价格始终为数字,而 InStock 布尔值。CSV 适用于电子表格和数据导入,JSON 适用于程序和嵌套数据。

CsvHelper 34 更改了 API。2026 年 10 月 28 日发布的 CsvHelper 34.0.0 是一个重写版本。使用 new CsvWriter(writer, CultureInfo.InvariantCulture)(这是大多数 C# 爬虫教程中展示的写法),已无法针对当前包进行编译。请使用本文中展示的 v34 API,或通过 dotnet add package CsvHelper --version 33.1.0 为旧代码锁定旧版本。CsvHelper v34 迁移指南列出了其余变更。

这也是为什么 Book 被声明为 partial 使用 [CsvRecord] 属性进行声明。CsvHelper 34 会在编译时生成记录与其列之间的映射关系,而源代码生成器需要一个 partial 类型来添加该代码。

在旧版本中,您需要向每个写入器传递一个文化设置,如果忘记了这一点,就会导致一台德语机器将数据 51,77 写入逗号分隔的文件。第 34 版默认使用不变文化。

如何判断页面是否需要 JavaScript?

使用 HttpClient 获取原始 HTML,并对其运行选择器。如果选择器未找到任何内容,但浏览器显示了数据,则说明该内容由 JavaScript 生成的。在启动浏览器之前,请搜索页面源代码并查看开发者工具的“网络”选项卡:许多网站以 JSON 格式传输记录,System.Text.Json 可以直接读取这些数据。

该 /js/ “Quotes to Scrape”的此版本在浏览器中看起来与常规网站完全相同:十条引语,每条都包含 div.quote 中,包含文本、作者和标签。但原始 HTML 却揭示了不同的情况。服务器发送了一个空容器,外加一个 <script> 块,其中将每条名言作为 JavaScript 数组存储,而浏览器会在页面加载后从该数组中构建 div.quote 元素。

因此,在打开浏览器之前,请先检查响应内容。如果记录位于脚本标签中, HttpClient 和 System.Text.Json 就足够了。

// ScriptData.cs: is the data in the HTML, or built by JavaScript? Read it from the <script> tag.
using System.Text.Json;
using System.Text.RegularExpressions;

public sealed record Author(string Name);
public sealed record Quote(string Text, Author Author, string[] Tags);

public static partial class ScriptData
{
    [GeneratedRegex(@"var data = (\[.*?\]);", RegexOptions.Singleline)]
    private static partial Regex DataArray();

    public static List<Quote> ReadQuotes(string html)
    {
        var doc = BookParser.Load(html);
        var script = doc.QuerySelectorAll("script")
            .Select(s => s.TextContent)
            .First(t => t.Contains("var data"));
        var json = DataArray().Match(script).Groups[1].Value;
        return JsonSerializer.Deserialize<List<Quote>>(json,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
    }
}
// Program.cs (check, then read the script tag)
var html = File.ReadAllText("fixtures/quotes-js.html");
Console.WriteLine($"div.quote elements in raw HTML: {BookParser.Load(html).QuerySelectorAll("div.quote").Length}");
var quotes = ScriptData.ReadQuotes(html);
foreach (var q in quotes.Take(2)) Console.WriteLine($"{q.Author.Name}: {q.Text[..60]}… [{string.Join(", ", q.Tags)}]");
Console.WriteLine($"quotes parsed from the script tag: {quotes.Count}");
div.quote elements in raw HTML: 0
Albert Einstein: “The world as we have created it is a process of our thinkin… [change, deep-thoughts, thinking, world]
J.K. Rowling: “It is our choices, Harry, that show what we truly are, far … [abilities, choices]
quotes parsed from the script tag: 10

第一行是验证:原始 HTML 中只有零个 div.quote 元素,尽管浏览器显示的是十个。随后 AngleSharp 会找到包含 var data,一个源代码生成的正则表达式将数组提取出来,最后由 System.Text.Json 将其转换为 Quote 记录。 [GeneratedRegex] 在构建时编译该模式,并 PropertyNameCaseInsensitive 将小写键映射到记录属性上。

同样的思路还适用于另外两种常见情况。许多产品和文章页面将结构化数据嵌入 <script type="application/ld+json">,你可以使用 AngleSharp 将其选中并直接反序列化,无需正则表达式。此外,许多以 JavaScript 为主的网站会从其自身的 JSON 端点加载记录。打开开发者工具,进入“网络”选项卡,按 Fetch/XHR 过滤,然后刷新页面。 如果某个请求返回了屏幕上显示的数据,请使用 HttpClient.GetFromJsonAsync<T>() ,完全跳过 HTML 处理。

读取 JSON 比渲染更快,且出错率更低,因为数据格式的变更频率远低于布局的变更。

如何用 C# 抓取由 JavaScript 渲染的页面?

使用 Playwright for .NET——这是微软的浏览器自动化库。它会下载 Chromium 内核,以无头模式加载页面,等待你指定选择器,并允许你读取元素或渲染后的 HTML。PuppeteerSharp 和 Selenium WebDriver 同样适用。 对于大量页面或被屏蔽的网站,建议使用爬虫 API 进行远程渲染,而非自行运行浏览器。

当没有 JSON 可读取,或者必须先点击或输入时,请使用真实浏览器。Playwright 可通过 C# 驱动 Chromium、Firefox 或 WebKit,默认以无头模式运行,且其定位器内置了等待功能。如果“无窗口浏览器”这个概念对你来说很陌生,这里介绍了无头浏览器的工作原理。

为了将浏览器与主项目分离,这是一个基于文件的 .NET 10 应用程序,它声明了自己的包:

#:package Microsoft.Playwright@1.64.1
#:property PublishAot=false
// render.cs: a .NET 10 file-based app. First run: dotnet run render.cs -- install
using Microsoft.Playwright;

if (args is ["install"])
    return Microsoft.Playwright.Program.Main(["install", "chromium"]);

using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new() { Headless = true });
var page = await browser.NewPageAsync(new() { UserAgent = "csharp-scraping-tutorial/1.0 (+mailto:you@example.com)" });

await page.GotoAsync("https://quotes.toscrape.com/js/");
var quotes = page.Locator("div.quote");
await quotes.First.WaitForAsync();                       // wait for JavaScript to build the list

foreach (var quote in (await quotes.AllAsync()).Take(3))
{
    var text = await quote.Locator("span.text").InnerTextAsync();
    var author = await quote.Locator("small.author").InnerTextAsync();
    Console.WriteLine($"{author}: {text[..50]}…");
}
Console.WriteLine($"quotes rendered: {await quotes.CountAsync()}");

// Hand the rendered HTML to the same AngleSharp code you already wrote, if you prefer
var html = await page.ContentAsync();
Console.WriteLine($"rendered HTML length: {html.Length}");
return 0;
dotnet run render.cs -- install   # once: downloads Chromium
dotnet run render.cs
Albert Einstein: “The world as we have created it is a process of o…
J.K. Rowling: “It is our choices, Harry, that show what we truly…
Albert Einstein: “There are only two ways to live your life. One is…
quotes rendered: 10
rendered HTML length: 8940

开头的两行 #: 行代码代替了项目文件。 #:package 从 NuGet 引入 Playwright。 #:property PublishAot=false 这一步骤是必要的,因为基于文件的应用程序默认采用原生 AOT 设置,而 Playwright 依赖于基于反射的 JSON 序列化,而 AOT 会将其剔除。首次运行时, install 会下载相应的 Chromium 构建版本。在 Linux 系统上,Chromium 可能还需要系统库,因此请查阅适用于您操作系统的 Playwright for .NET 文档。

关键代码行是 await quotes.First.WaitForAsync()。它会等待直到至少有一个 div.quote 存在,这意味着 JavaScript 已完成列表的构建。固定的 Task.Delay(3000) 在加载速度快的页面上会过长,而在加载速度慢的页面上又会过短。

如果您希望将所有解析操作集中在一个地方, page.ContentAsync() 会将渲染后的 HTML 作为字符串返回。将其传递给 BookParser.Load() 并使用 AngleSharp 进行查询,操作方式与静态页面完全相同。

PuppeteerSharp 是 Node.js 中 Puppeteer 的 C# 移植版。它专注于 Chromium,其 API 与 Puppeteer 的几乎逐行对应,这使其非常适合将现有的 Node.js 脚本移植到 .NET 平台。

Selenium WebDriver 是一种历史悠久的选项,适合已经运行 Selenium 测试或 Selenium Grid 的团队。在数据抓取方面,与 Playwright 相比,你需要编写更多显式的等待代码,因为大多数元素都需要一个 WebDriverWait 才能读取。

本地浏览器会消耗内存和 CPU 资源。当您需要大量会话,或者在难以自动化的网站上执行点击和表单填写等交互步骤时,托管式自动化浏览器可以将这些工作转移到您的机器之外。

如何在 C# 中使用网页抓取 API?

使用 HttpClient 将目标 URL 发送至抓取 API,并用您现有的代码解析其返回的 HTML。使用 WebScrapingAPI 的抓取 API 时,请使用您的 api_key 和 URL 编码的目标地址调用一个端点,对于 JavaScript 页面请添加 render_js=1,并指定 country 参数以设置地理位置。 请从环境变量中读取密钥,切勿在源代码中硬编码。

默认应从您自己的机器上获取数据。以下症状表明这已成为瓶颈:

  • 即使以礼貌的请求速率进行重试,仍不断返回 403 或 429 响应。
  • 显示验证或同意页面,而非浏览器中可见的内容。
  • 当您需要特定位置时,内容会因国家/地区而异,例如价格、库存或搜索结果。
  • JavaScript 渲染量超过本地 Chromium 实例的处理能力。

首先排除基础问题。以下四种习惯可解决许多阻塞问题,此外还有其他方法可以进一步减少阻塞:

  • 放慢速度:减少并发请求数量,并在请求之间增加延迟。
  • 发送真实且一致的 User-Agent 头。
  • 缓存已获取的页面,避免重复下载。
  • 仅获取所需的页面和字段。

如果你运行自己的代理池,还可以将 HttpClient 通过代理进行路由,其余部分保持不变。

当上述方法行不通时,可让爬虫 API 接管抓取步骤。WebScrapingAPI 的爬虫 API 接受 URL 并返回页面,同时处理代理、重试以及可选的 JavaScript 渲染。在 C# 中,只需一个 GET 请求,一个小类即可完成全部集成:

// WsaClient.cs: fetch pages through WebScrapingAPI's Scraper API; parsing stays the same
public sealed class WsaClient(HttpClient http)
{
    const string Endpoint = "https://api.webscrapingapi.com/v2";

    public static Uri BuildUri(string apiKey, string targetUrl, bool renderJs = false, string? country = null)
    {
        var query = new List<string>
        {
            "api_key=" + Uri.EscapeDataString(apiKey),
            "url=" + Uri.EscapeDataString(targetUrl),     // encode the target so its ? and & survive
        };
        if (renderJs) query.Add("render_js=1");
        if (country is not null) query.Add("country=" + Uri.EscapeDataString(country));
        return new Uri($"{Endpoint}?{string.Join("&", query)}");
    }

    public async Task<string> GetHtmlAsync(string targetUrl, bool renderJs = false, string? country = null,
                                           CancellationToken ct = default)
    {
        var apiKey = Environment.GetEnvironmentVariable("WSA_API_KEY");
        if (string.IsNullOrWhiteSpace(apiKey))
            throw new InvalidOperationException("Set the WSA_API_KEY environment variable first.");

        using var response = await http.GetAsync(BuildUri(apiKey, targetUrl, renderJs, country), ct);
        var body = await response.Content.ReadAsStringAsync(ct);
        if (!response.IsSuccessStatusCode)
            throw new HttpRequestException($"Scraper API returned {(int)response.StatusCode}: {body}");
        return body;
    }
}

BuildUri 该接口为公开的,因此您无需实际发送任何数据即可准确查看将要发送的内容:

// Program.cs (see what you'll send, without sending it)
var uri = WsaClient.BuildUri("YOUR_KEY", "https://quotes.toscrape.com/js/?page=1&tag=life", renderJs: true, country: "us");
Console.WriteLine(uri.AbsoluteUri);
try { await new WsaClient(new HttpClient()).GetHtmlAsync("https://books.toscrape.com/"); }
catch (InvalidOperationException ex) { Console.WriteLine($"without a key: {ex.Message}"); }
https://api.webscrapingapi.com/v2?api_key=YOUR_KEY&url=https%3A%2F%2Fquotes.toscrape.com%2Fjs%2F%3Fpage%3D1%26tag%3Dlife&render_js=1&country=us
without a key: Set the WSA_API_KEY environment variable first.

请查看 url= 值。目标页面自身的 ? 和 & 变成了 %3F 和 %26。如果没有 Uri.EscapeDataString(),API 会将 tag=life 作为其自身的参数之一,从而获取错误的页面。

该密钥在代码中绝不会出现。请在每个 Shell 或每台机器上设置一次:

export WSA_API_KEY=your-key-here        # macOS and Linux
setx WSA_API_KEY "your-key-here"        # Windows (open a new terminal afterward)

在 ASP.NET Core 应用中, dotnet user-secrets 开发阶段应将密钥从代码库中移除,生产环境中则由主机的密钥存储负责此项工作。如果该变量缺失, WsaClient 会在发送请求前显示明确的错误信息并停止执行,如第二行输出所示。如果密钥错误,API 将返回 HTTP 401 状态码,并附带 {"error":"Invalid API key"},并 WsaClient 将该正文显示在 HttpRequestException中,因此您会看到具体原因,而非空结果。

两个参数即可满足大多数需求。 renderJs: true 添加 render_js=1,该方法会在返回页面之前先在浏览器中渲染该页面。 country: "us" 添加 country=us,该方法会从指定位置获取页面。JavaScript 渲染和地理定位不会消耗额外积分。渲染请求比普通请求耗时更长,因此请通过 PoliteHttp.Create(TimeSpan.FromSeconds(120)).

响应内容为 HTML,因此会原样放入 BookParser 你开头编写的代码中,内容保持不变:

// Program.cs (fetch through the Scraper API)
using var http = PoliteHttp.Create(attemptTimeout: TimeSpan.FromSeconds(120));
var wsa = new WsaClient(http);
var target = new Uri("https://books.toscrape.com/catalogue/page-2.html");
var books = BookParser.ParseListing(BookParser.Load(await wsa.GetHtmlAsync(target.ToString(), country: "us")), target);
Console.WriteLine($"rows via Scraper API: {books.Count}; first: {books[0].Title}");
var rendered = BookParser.Load(await wsa.GetHtmlAsync("https://quotes.toscrape.com/js/", renderJs: true));
Console.WriteLine($"div.quote after render_js: {rendered.QuerySelectorAll("div.quote").Length}");

示例输出:

rows via Scraper API: 20; first: In Her Wake
div.quote after render_js: 10

ParseListing 仍会接收目标 URL,而非 API 端点。链接会根据 HTML 来源页面进行解析,因此无论页面如何获取,每本书的 URL 都保持正确。渲染后的“名言”页面包含与 div.quote 元素,与 Playwright 所见完全一致。

你应该采用哪种 C# 爬取方法?

对于静态页面,请使用 HttpClient 配合 AngleSharp 或 HtmlAgilityPack。当爬取大量页面时,请添加容错管道并设置并发上限。当 JavaScript 加载数据时,请读取 script 标签中的 JSON 或网站的 JSON 端点。 对于少量交互式页面,请使用 Playwright;当存在阻塞或数据量过大导致本地抓取不可靠时,请使用爬虫 API。

场景

使用

原因

静态 HTML,少量页面

HttpClient + AngleSharp 或 HtmlAgilityPack

组件最少

页面较多或需要爬取

与Resilience管道共享HttpClient,延迟, Parallel.ForEachAsync 上限

重试、重试间隔、礼貌速率

数据格式为 <script> JSON 格式或 JSON 端点

HttpClient + System.Text.Json

比渲染更快、更稳定

基于 JavaScript 构建的页面,数据量较小

适用于 .NET 的 Playwright

真实浏览器,内置等待功能

允许您自动化的点击、登录或多步骤流程

Playwright(如果您的团队已经在使用 Selenium,则可选用 Selenium)

交互支持

403/429 错误(重试无法解决)、特定国家/地区的内容,或大规模渲染

通过 HttpClient 调用爬虫 API

托管代理、重试和渲染;解析器保持不变

如果您不确定某页面应归入哪类,请按顺序逐一自问以下问题,并在第一个回答“是”时停止:

  1. 数据是否在“查看源代码”中?使用 HttpClient 获取数据,并通过 AngleSharp 或 HtmlAgilityPack 进行解析。
  2. 是否需要处理大量页面?请添加容错管道、延迟以及较小的并发上限。
  3. 数据是否位于 script 标签中,或“网络”选项卡中的 JSON 请求中?请使用 System.Text.Json 读取。
  4. 该页面是否需要浏览器,且仅需执行几次?请使用 Playwright for .NET。
  5. 是否遇到访问限制、需要特定国家/地区,或者需要大规模渲染?通过 Scraper API 发送请求,并保留您的解析器。

如何测试 C# Web 爬虫?

将每种页面类型保存为 HTML 文件,创建一个 xUnit 测试项目以离线解析这些文件,并验证行数、类型化值和绝对 URL。当网站更改标记时,测试会在几秒内失败,而不是让您的爬虫在生产环境中默默返回空结果。

爬虫往往会“静默失败”:当网站重命名某个类时,任务会生成一个看似成功的空文件。你在设置过程中保存的页面本身就是现成的测试数据。

在爬虫项目旁创建一个测试项目并引用它:

dotnet new xunit -n BookScraper.Tests
cd BookScraper.Tests
dotnet add reference ../BookScraper/BookScraper.csproj

在 BookScraper.Tests.csproj 中添加配置,以便将保存的页面复制到测试二进制文件旁边:

<ItemGroup>
  <None Include="../fixtures/*.html" Link="fixtures/%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

然后测试你所依赖的行为,而非具体实现:

// BookParserTests.cs: run the parser against a saved page, so tests never touch the network
public class BookParserTests
{
    static readonly Uri Page2 = new("https://books.toscrape.com/catalogue/page-2.html");

    static List<Book> ParseFixture() =>
        BookParser.ParseListing(BookParser.Load(File.ReadAllText("fixtures/books-page2.html")), Page2);

    [Fact]
    public void Finds_every_book_on_the_page() => Assert.Equal(20, ParseFixture().Count);

    [Fact]
    public void Resolves_links_against_the_page_not_the_site_root()
    {
        var first = ParseFixture()[0];
        Assert.Equal("https://books.toscrape.com/catalogue/in-her-wake_980/index.html", first.Url);
    }

    [Fact]
    public void Reads_price_and_rating_as_numbers()
    {
        var first = ParseFixture()[0];
        Assert.Equal(12.84m, first.Price);
        Assert.InRange(first.Rating, 1, 5);
    }
}
Passed!  - Failed:     0, Passed:     3, Skipped:     0, Total:     3, Duration: 147 ms - BookScraper.Tests.dll (net10.0)

这三个测试分别守护了最容易出错的三个方面:行数(卡片选择器仍能匹配)、第 2 页的 URL(链接仍能解析到该页面)以及类型化值(价格和评分仍能解析)。 第 2 页是特意设置的测试环境,因为这是根路径相对 URL 错误显现的地方。

在生产环境中应用相同思路时,遵循“高调报错”原则:如果列表页面返回零张卡片,则抛出异常而非写入空文件。当测试失败时,保存该页面的最新副本,修复选择器,然后重新运行。同时考虑在记录中添加一个 ScrapedAt 时间戳字段,这样每行记录都能显示采集时间,便于后续比较不同运行结果。

如何使用 C# 进行负责任的网页抓取?

在爬取前请阅读网站的条款和 robots.txt 文件,保持较低的请求频率,使用包含联系信息的用户代理标识您的爬虫,并仅收集您需要的字段。除非有合法依据,否则请避免收集个人数据,且未经许可不要爬取登录后的内容。如果有官方 API,请优先使用。

robots.txt 是位于网站根目录下的纯文本文件,用于告知爬虫应跳过哪些路径,其格式由 robots.txt 标准(RFC 9309)定义。使用您现有的客户端,只需几行代码即可读取该文件:

// Robots.cs: read a site's robots.txt before you crawl (RFC 9309)
using System.Net;

public static class Robots
{
    public static async Task<List<string>> ReadRulesAsync(HttpClient http, Uri site)
    {
        using var response = await http.GetAsync(new Uri(site, "/robots.txt"));
        if (response.StatusCode == HttpStatusCode.NotFound)
            return ["No robots.txt: no crawl rules published"];
        response.EnsureSuccessStatusCode();

        var text = await response.Content.ReadAsStringAsync();
        return text.Split('\n')
            .Select(line => line.Trim())
            .Where(line => line.StartsWith("User-agent:", StringComparison.OrdinalIgnoreCase)
                        || line.StartsWith("Disallow:", StringComparison.OrdinalIgnoreCase)
                        || line.StartsWith("Allow:", StringComparison.OrdinalIgnoreCase)
                        || line.StartsWith("Crawl-delay:", StringComparison.OrdinalIgnoreCase))
            .ToList();
    }
}
// Program.cs (read robots.txt first)
using var http = PoliteHttp.Create();
foreach (var line in await Robots.ReadRulesAsync(http, new Uri("https://books.toscrape.com"))) Console.WriteLine(line);
No robots.txt: no crawl rules published

Books to Scrape 未发布 robots.txt 文件,根据该标准,文件缺失意味着不适用任何爬取规则。在真实网站上,请遵守 Disallow 针对您用户代理的规则以及任何 Crawl-delay.

请注意,robots.txt 仅是爬虫的惯例,并非法律许可。网站的服务条款、数据类型以及您与该网站的运营所在地均会产生影响,因此在抓取任何商业数据前,请务必查阅相关法规以确认网络抓取是否合法。 仅收集您所需的数据,安全存储,除非有明确的合法依据,否则请远离个人数据。关于我们恪守的标准,请参阅我们的《负责任的网络数据原则》。

常见问题

哪种 C# 库最适合网络爬取?

并没有一种通用的库能适用于所有任务。 建议使用 HttpClient 下载页面,AngleSharp 或 HtmlAgilityPack 解析 HTML,当内容由 JavaScript 生成时则使用 Playwright for .NET。对于大型或受阻的任务,请保留解析器,并将下载步骤替换为爬虫 API。大多数生产环境中的爬虫都会结合使用其中两到三种方案。

我应该使用 HtmlAgilityPack 还是 AngleSharp?

两者均持续维护,且能很好地解析实际场景中的 HTML。AngleSharp 遵循 HTML 标准并原生支持 CSS 选择器,因此从浏览器开发者工具中复制的选择器可直接使用。 HtmlAgilityPack 历史更悠久,对错误标记的容错性极高,且基于 XPath 和 LINQ 构建。请选择团队偏好的查询风格。日后若需切换,仅需更换解析器即可。

C# 能否抓取由 JavaScript 渲染的网站?

仅靠 HttpClient 无法实现,因为它只能接收服务器发送的 HTML 内容。Playwright for .NET、PuppeteerSharp 或 Selenium 可以在无头浏览器中运行页面,并提供渲染后的内容。建议先检查页面源代码和网络请求中是否包含 JSON 数据,因为这通常比渲染更快、更可靠。

在 C# 中进行抓取,Playwright 和 Selenium 哪个更好?

对于初次接触数据抓取的开发者,Playwright for .NET 通常更简单:它会自动安装浏览器,自动等待元素加载,并且通过一个 API 即可支持 Chromium、Firefox 和 WebKit。 如果您的团队已经在运行 Selenium 测试或 Selenium Grid,那么 Selenium WebDriver 仍然是合理的选择。这两者都比普通的 HTTP 请求更慢、更耗资源,因此请仅在必要时使用。

C# 中是否有与 Scrapy 相当的工具?

没有哪个 .NET 框架能像 Scrapy 在 Python 中的那样被广泛使用。 大多数 C# 团队都会自行组合相同的组件:使用带容错管道的 HttpClient 进行数据获取,使用 AngleSharp 或 HtmlAgilityPack 进行解析,使用 Parallel.ForEachAsync 或通道实现并发,并使用队列或数据库管理 URL。这一技术栈足以满足大多数爬取项目的需求。

如何在 C# 中处理 HTTP 429 请求过多错误?

放慢请求速度并稍后重试。使用 Microsoft.Extensions.Http.Resilience 添加重试管道,该管道会对 429 和 5xx 响应采用指数退避策略进行重试,并在服务器发送 Retry-After 标头时等待其响应。降低并发数,并在请求之间增加延迟。 如果 429 错误仍以“礼貌”的频率持续出现,说明网站要求您停止操作。

如何在使用 C# 进行网页抓取时避免被封禁?

复用一个 HttpClient 实例,发送真实的 User-Agent 头,保持低并发,缓存已获取的页面,并在经过服务器要求的延迟时间后才重试 429 响应。 如果网站仍然返回 403 错误或验证页面,请通过一个负责管理代理和渲染的爬虫 API 转发请求,并保持您的解析器不变。

在 .NET 应用程序中应如何存储 API 密钥?

切勿将其写入源代码。 通过 Environment.GetEnvironmentVariable 从 WSA_API_KEY 等环境变量中读取密钥;在本地 ASP.NET Core 开发中使用 dotnet user-secrets;在生产环境中使用主机提供的密钥存储。切勿将密钥粘贴到共享的 URL 中、提交到 Git,或记录完整的请求 URL。

如何在 C# 中并行抓取多个页面?

使用 Parallel.ForEachAsync 时,将 MaxDegreeOfParallelism 设置为较小的数值(例如每个网站 3 到 5 个任务),并让所有任务共享同一个 HttpClient。将结果收集到线程安全的集合中,例如 ConcurrentBag。该限制应根据网站的承载能力来设定,而非取决于机器拥有多少个 CPU 核心。

使用 C# 进行网页抓取是否合法?

抓取公开数据通常是合法的,但这取决于网站的条款、数据类型以及当地法律,特别是涉及个人数据和受版权保护的内容时。编程语言本身不会改变这一点。请遵守 robots.txt 规则,避免在没有合法依据的情况下获取个人数据,并在为商业项目进行抓取前寻求法律建议。

保留您的 C# 解析器,将棘手的页面交由其他工具处理

当网站开始屏蔽您或需要 JavaScript 时,您的 AngleSharp 或 HtmlAgilityPack 代码无需更改。只需将 WsaClient 指向 WebScrapingAPI 的 Scraper API,并继续在 .NET 环境中进行解析。JavaScript 渲染和地理定位不会消耗额外积分,失败的请求不计费,免费试用版包含 5,000 次请求。

关于作者

Sorin-Gabriel Marica, 全栈开发工程师 @ WebScrapingAPI

Sorin-Gabriel Marica

全栈开发工程师

索林·马里卡(Sorin Marica)是 WebScrapingAPI 的全栈及 DevOps 工程师,负责开发产品功能并维护确保平台平稳运行的基础设施。

开始构建

准备好扩展您的数据收集规模了吗?

加入2,000多家企业,使用WebScrapingAPI在无需任何基础设施开销的情况下,以企业级规模提取网络数据。