65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Dotnet Core、 Entity Framework Core 和 C# 创建自定义 Web 爬虫

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (6投票s)

2019年2月24日

CPOL

9分钟阅读

viewsIcon

26979

目前有大多数基于不同基础架构的网络爬取和网络爬虫框架。但在 dotnet 环境中,您找不到能够满足您自定义需求的工具。

引言

在本文中,我们将实现一个自定义网络爬虫,并将其用于 **eBay 电子商务网站**,抓取 eBay iPhone 页面,并使用 Entity Framework Core 将这些记录插入到我们的 SQL Server 数据库中。一个示例数据库模式将是 **Microsoft eShopWeb 应用程序**,我们将把 eBay 记录插入到 `Catalog` 表中。

背景

大多数网络爬取和网络爬虫框架存在于不同的基础架构上。但在 dotnet 环境中,您找不到能够满足您自定义需求的工具。

在开始开发新爬虫之前,我搜索了以下用 C# 编写的工具

  • Abot 是一个不错的爬虫,但如果您需要实现一些自定义功能,它没有免费支持,也没有足够的文档。
  • DotnetSpider 拥有非常好的设计,其架构与 Scrapy 和 WebMagic 等大多数爬虫使用的架构相同。但文档是中文的,即使我用 Google 翻译成英文,也很难学习如何实现自定义场景。此外,我想将爬虫输出插入到 SQL Server 数据库中,但它运行不正常,我已经在 GitHub 上提出了一个问题,但目前还没有人回应。

在有限的时间内,我没有其他选择来解决我的问题。而且我不想花更多时间去研究更多的爬虫基础设施。我决定编写自己的工具。

爬虫基础

搜索了大量的仓库后,我萌生了创建新爬虫的想法。因此,爬虫架构的主要模块几乎相同,它们都指向爬虫生命周期的宏大图景,您可以在下面看到一个示例

爬虫的主要模块

此图显示了一个通用爬虫项目应包含的主要模块。因此,我将这些模块作为独立的 Visual Studio 解决方案项目添加。

这些模块的基本解释如下:

  • `Downloader`;负责将给定 URL 下载到本地文件夹或临时文件夹,并将其返回给处理器,作为 `htmlnode` 对象。
  • `Processors`;负责处理给定的 HTML 节点,提取并找到预期的特定节点,用这些处理过的数据加载实体。并将此处理器返回给管道。
  • `Pipelines`;负责将实体导出到应用程序使用的不同数据库。
  • `Scheduler`;负责调度爬虫命令,以提供礼貌的爬取操作。

逐步开发 DotnetCrawler

根据上述模块,我们如何使用 `Crawler` 类?让我们试着想象一下,然后一起实现。

static async Task MainAsync(string[] args)
{
    var crawler = new DotnetCrawler<Catalog>()
                         .AddRequest(new DotnetCrawlerRequest 
                         { Url = "https://www.ebay.com/b/Apple-iPhone/9355/bn_319682", 
                           Regex = @".*itm/.+", TimeOut = 5000 })
                         .AddDownloader(new DotnetCrawlerDownloader 
                         { DownloderType = DotnetCrawlerDownloaderType.FromMemory, 
                           DownloadPath = @"C:\DotnetCrawlercrawler\" })
                         .AddProcessor(new DotnetCrawlerProcessor<Catalog> { })
                         .AddPipeline(new DotnetCrawlerPipeline<Catalog> { });

    await crawler.Crawle();
}

如您所见,`DotnetCrawler` 类具有一个泛型实体类型,该类型将用作 DTO 对象并保存到数据库中。Catalog 是 `DotnetCrawler` 的泛型类型,也是由 `DotnetCrawler.Data` 项目中的 EF.Core 脚手架命令生成的。我们稍后会看到这一点。

这个 `DotnetCrawler` 对象使用建造者设计模式进行配置,以便加载其配置。这种技术也被命名为流式设计。

使用以下方法配置 `DotnetCrawler`

  • `AddRequest`;这包括爬虫目标的`主要 URL`。此外,我们可以为目标 URL 定义过滤器,旨在聚焦预期部分。
  • `AddDownloader`;这包括下载器类型,如果下载类型是“`FromFile`”,这意味着下载到本地文件夹,那么还需要下载文件夹的路径。其他选项是“`FromMemory`”和“`FromWeb`”,它们都下载目标 URL 但不保存。
  • `AddProcessor`;此方法加载一个新的默认处理器,该处理器主要提供提取 HTML 页面并定位某些 HTML 标签的功能。由于其可扩展设计,您可以创建自己的处理器。
  • `AddPipeline`;此方法加载一个新的默认管道,该管道主要提供将实体保存到数据库的功能。当前管道提供使用 Entity Framework Core 连接 SqlServer 的功能。由于其可扩展设计,您可以创建自己的管道。

所有这些配置都应存储在主类中;*DotnetCrawler.cs*。

public class DotnetCrawler<TEntity> : IDotnetCrawler where TEntity : class, IEntity
    {
        public IDotnetCrawlerRequest Request { get; private set; }
        public IDotnetCrawlerDownloader Downloader { get; private set; }
        public IDotnetCrawlerProcessor<TEntity> Processor { get; private set; }
        public IDotnetCrawlerScheduler Scheduler { get; private set; }
        public IDotnetCrawlerPipeline<TEntity> Pipeline { get; private set; }

        public DotnetCrawler()
        {
        }

        public DotnetCrawler<TEntity> AddRequest(IDotnetCrawlerRequest request)
        {
            Request = request;
            return this;
        }

        public DotnetCrawler<TEntity> AddDownloader(IDotnetCrawlerDownloader downloader)
        {
            Downloader = downloader;
            return this;
        }

        public DotnetCrawler<TEntity> AddProcessor(IDotnetCrawlerProcessor<TEntity> processor)
        {
            Processor = processor;
            return this;
        }

        public DotnetCrawler<TEntity> AddScheduler(IDotnetCrawlerScheduler scheduler)
        {
            Scheduler = scheduler;
            return this;
        }

        public DotnetCrawler<TEntity> AddPipeline(IDotnetCrawlerPipeline<TEntity> pipeline)
        {
            Pipeline = pipeline;
            return this;
        }        
    }

根据此,完成必要的配置后,`crawler.Crawle()` 方法将异步触发。此方法通过依次导航到下一个模块来完成其操作。

eShopOnWeb 微软项目使用示例

此库还包含一个名为 `DotnetCrawler.Sample` 的示例项目。基本上,在此示例项目中,实现了 Microsoft **eShopOnWeb 仓库**。您可以在**此处**找到此仓库。在此示例仓库中实现了电子商务项目,当您使用 `EF.Core` **代码优先方法**生成时,它具有“`Catalog`”表。因此,在使用爬虫之前,您应该下载并使用真实数据库运行此项目。要执行此操作,请参阅此信息。(如果您已经有现有数据库,则可以使用您的数据库继续。)

我们正在 `DotnetCrawler` 类中将“`Catalog`”表作为泛型类型传递。

var crawler = new DotnetCrawler<Catalog>()

`Catalog` 是 `DotnetCrawler` 的泛型类型,也由 `DotnetCrawler.Data` 项目中的 `EF.Core` **scaffolding** 命令生成。`DotnetCrawler.Data` 项目安装了 `EF.Core` nuget 包。在运行此命令之前,`.Data` 项目应下载以下 nuget 包。

运行 EF 命令所需的包

现在,我们的包已准备就绪,可以通过在包管理器控制台中选择 `DotnetCrawler.Data` 项目来运行 EF 命令。

Scaffold-DbContext "Server=(localdb)\mssqllocaldb;Database=Microsoft.eShopOnWeb.CatalogDb;
Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer -OutputDir Models

通过此命令,`DotnetCrawler.Data` 项目创建了 *Model* 文件夹。此文件夹包含从 **eShopOnWeb** Microsoft 示例生成的所有实体和上下文对象。

eShopOnWeb 实体

之后,您需要使用自定义爬虫属性配置您的实体类,以便爬虫蜘蛛能够理解并从 eBay iPhone 的网页加载实体字段;

[DotnetCrawlerEntity(XPath = "//*[@id='LeftSummaryPanel']/div[1]")]
public partial class Catalog : IEntity
{
    public int Id { get; set; }
    [DotnetCrawlerField(Expression = "1", SelectorType = SelectorType.FixedValue)]
    public int CatalogBrandId { get; set; }
    [DotnetCrawlerField(Expression = "1", SelectorType = SelectorType.FixedValue)]
    public int CatalogTypeId { get; set; }        
    public string Description { get; set; }
    [DotnetCrawlerField(Expression = "//*[@id='itemTitle']/text()", SelectorType = SelectorType.XPath)]
    public string Name { get; set; }
    public string PictureUri { get; set; }
    public decimal Price { get; set; }

    public virtual CatalogBrand CatalogBrand { get; set; }
    public virtual CatalogType CatalogType { get; set; }
}

使用此代码,爬虫基本上请求给定的 URL,并尝试查找为目标网页 URL 定义了 xpath 地址的给定属性。

完成这些定义后,我们终于可以异步运行 `crawler.Crawle()` 方法了。在此方法中,它依次执行以下操作。

  • 它访问给定请求对象中的 URL 并查找其中的链接。如果 Regex 的属性值已满,它会相应地应用过滤。
  • 它在互联网上找到这些 URL,并使用不同的方法下载。
  • 下载的网页经过处理以生成所需的数据。
  • 最后,这些数据使用 `EF.Core` 保存到数据库中。
public async Task Crawle()
{
var linkReader = new DotnetCrawlerPageLinkReader(Request);
var links = await linkReader.GetLinks(Request.Url, 0);
foreach (var url in links)
{
var document = await Downloader.Download(url);
var entity = await Processor.Process(document);
await Pipeline.Run(entity);
}
}

Visual Studio 解决方案的项目结构

因此,您可以从在 Visual Studio 中创建 Blank Solution 的新项目开始。之后,您可以按照下图添加 .NET Core 类库项目

只有一个示例项目将是 .NET Core 控制台应用程序。我将逐一解释此解决方案中的所有项目。

DotnetCrawler.Core

该项目包含爬虫的主要类。它只有一个包含 `Crawle` 方法的 `接口` 和此 `接口` 的实现。因此,您可以在此项目中创建自定义爬虫。

DotnetCrawler.Data

该项目包括 *Attributes*、*Models* 和 *Repository* 文件夹。我们应该深入研究这些文件夹。

  • *Model* 文件夹;应包含由 Entity Framework Core 生成的 `Entity` 类。因此,您应该将您的数据库表实体放在此文件夹中,并且此文件夹中也应包含 Entity Framework Core 的 `Context` 对象。现在此文件夹包含 **eShopOnWeb** 微软的数据库示例。
  • *Attributes* 文件夹;包含爬虫属性,提供爬取网页的 xpath 信息。它包含 2 个类,用于实体属性的 *DotnetCrawlerEntityAttribute.cs* 和用于属性属性的 *DotnetCrawlerFieldAttribute.cs*。这些属性应应用于 `EF.Core` 实体类。您可以在下面的代码块中看到属性的用法示例
    [DotnetCrawlerEntity(XPath = "//*[@id='LeftSummaryPanel']/div[1]")]
    public partial class Catalog : IEntity
    {
    public int Id { get; set; }
    [DotnetCrawlerField(Expression = "//*[@id='itemTitle']/text()", SelectorType = SelectorType.XPath)]
    public string Name { get; set; }
    }

    第一个 xpath 用于在开始爬取时定位 HTML 节点。第二个 xpath 用于获取特定 HTML 节点中的真实数据信息。在此示例中,此路径从 eBay 检索 iPhone 名称。

  • *Repository* 文件夹;包含基于 `EF.Core` 实体和数据库上下文的存储库设计模式实现。我使用此资源来实现存储库模式。为了使用存储库模式,我们必须为所有 `EF.Core` 实体应用 `IEntity 接口`。您可以在上面的代码中看到 `Catalog` 类实现了 `IEntity` `接口`。因此,爬虫的泛型类型应实现自 `IEntity`。

DotnetCrawler.Downloader

该项目在爬虫主类中包含了下载算法。根据下载器的 `DownloadType`,可以应用不同类型的下载方法。此外,您可以在此处开发自己的自定义下载器,以满足您的需求。为了提供这些下载功能,该项目应加载 `HtmlAgilityPack` 和 `HtmlAgilityPack.CssSelector.NetCore` 包;

下载方法的主要功能在 *DotnetCrawlerDownloader.cs* — `DownloadInternal()` 方法中

private async Task<HtmlDocument> DownloadInternal(string crawlUrl)
{
    switch (DownloderType)
    {
        case DotnetCrawlerDownloaderType.FromFile:
            using (WebClient client = new WebClient())
            {
                await client.DownloadFileTaskAsync(crawlUrl, _localFilePath);
            }
            return GetExistingFile(_localFilePath);

        case DotnetCrawlerDownloaderType.FromMemory:
            var htmlDocument = new HtmlDocument();
            using (WebClient client = new WebClient())
            {
                string htmlCode = await client.DownloadStringTaskAsync(crawlUrl);
                htmlDocument.LoadHtml(htmlCode);
            }
            return htmlDocument;

        case DotnetCrawlerDownloaderType.FromWeb:
            HtmlWeb web = new HtmlWeb();
            return await web.LoadFromWebAsync(crawlUrl);
    }

    throw new InvalidOperationException("Can not load html file from given source.");
}

此方法根据下载器类型下载目标 URL;下载本地文件,下载临时文件或不下载直接从网络读取。

此外,爬虫的主要功能之一是页面访问算法。因此,在这个项目中,*DotnetCrawlerPageLinkReader.cs* 类使用递归方法实现页面访问算法。您可以通过给定 `depth` 参数来使用此页面访问算法。我正在使用此资源来解决此问题。

public class DotnetCrawlerPageLinkReader
{
    private readonly IDotnetCrawlerRequest _request;
    private readonly Regex _regex;

    public DotnetCrawlerPageLinkReader(IDotnetCrawlerRequest request)
    {
        _request = request;
        if (!string.IsNullOrWhiteSpace(request.Regex))
        {
            _regex = new Regex(request.Regex);
        }
    }

    public async Task<IEnumerable<string>> GetLinks(string url, int level = 0)
    {
        if (level < 0)
            throw new ArgumentOutOfRangeException(nameof(level));

        var rootUrls = await GetPageLinks(url, false);

        if (level == 0)
            return rootUrls;

        var links = await GetAllPagesLinks(rootUrls);

        --level;
        var tasks = await Task.WhenAll(links.Select(link => GetLinks(link, level)));
        return tasks.SelectMany(l => l);
    }

    private async Task<IEnumerable<string>> GetPageLinks(string url, bool needMatch = true)
    {
        try
        {
            HtmlWeb web = new HtmlWeb();
            var htmlDocument = await web.LoadFromWebAsync(url);

            var linkList = htmlDocument.DocumentNode
                               .Descendants("a")
                               .Select(a => a.GetAttributeValue("href", null))
                               .Where(u => !string.IsNullOrEmpty(u))
                               .Distinct();

            if (_regex != null)
                linkList = linkList.Where(x => _regex.IsMatch(x));

            return linkList;
        }
        catch (Exception exception)
        {
            return Enumerable.Empty<string>();
        }
    }

    private async Task<IEnumerable<string>> GetAllPagesLinks(IEnumerable<string> rootUrls)
    {
        var result = await Task.WhenAll(rootUrls.Select(url => GetPageLinks(url)));

        return result.SelectMany(x => x).Distinct();
    }
}

DotnetCrawler.Processor

该项目提供了将下载的网页数据转换为 EF.Core 实体。此需求通过使用反射来解决泛型类型的 `get` 或 `set` 成员。在 *DotnetCrawlerProcessor.cs* 类中,实现了爬虫的当前处理器。此外,您可以在此处开发自己的自定义处理器以实现您的需求。

public class DotnetCrawlerProcessor<TEntity> : IDotnetCrawlerProcessor<TEntity> 
                where TEntity : class, IEntity
{
    public async Task<IEnumerable<TEntity>> Process(HtmlDocument document)
    {
        var nameValueDictionary = GetColumnNameValuePairsFromHtml(document);

        var processorEntity = ReflectionHelper.CreateNewEntity<TEntity>();
        foreach (var pair in nameValueDictionary)
        {
            ReflectionHelper.TrySetProperty(processorEntity, pair.Key, pair.Value);
        }

        return new List<TEntity>
        {
            processorEntity as TEntity
        };
    }

    private static Dictionary<string, object> GetColumnNameValuePairsFromHtml(HtmlDocument document)
    {
        var columnNameValueDictionary = new Dictionary<string, object>();

        var entityExpression = ReflectionHelper.GetEntityExpression<TEntity>();
        var propertyExpressions = ReflectionHelper.GetPropertyAttributes<TEntity>();

        var entityNode = document.DocumentNode.SelectSingleNode(entityExpression);

        foreach (var expression in propertyExpressions)
        {
            var columnName = expression.Key;
            object columnValue = null;
            var fieldExpression = expression.Value.Item2;

            switch (expression.Value.Item1)
            {
                case SelectorType.XPath:
                    var node = entityNode.SelectSingleNode(fieldExpression);
                    if (node != null)
                        columnValue = node.InnerText;
                    break;
                case SelectorType.CssSelector:
                    var nodeCss = entityNode.QuerySelector(fieldExpression);
                    if (nodeCss != null)
                        columnValue = nodeCss.InnerText;
                    break;
                case SelectorType.FixedValue:
                    if (Int32.TryParse(fieldExpression, out var result))
                    {
                        columnValue = result;
                    }
                    break;
                default:
                    break;
            }
            columnNameValueDictionary.Add(columnName, columnValue);
        }

        return columnNameValueDictionary;
    }
}

DotnetCrawler.Pipeline

此项目提供从处理器模块将给定实体对象 `插入` 数据库的功能。为了使用 `EF.Core` 作为对象关系映射框架将数据插入数据库。在 *DotnetCrawlerPipeline.cs* 类中,实现了爬虫的当前管道。您也可以在此处开发自己的自定义管道,以满足您的需求(不同数据库类型的持久化)。

public class DotnetCrawlerPipeline<TEntity> : IDotnetCrawlerPipeline<TEntity> 
               where TEntity : class, IEntity
{
    private readonly IGenericRepository<TEntity> _repository;

    public DotnetCrawlerPipeline()
    {
        _repository = new GenericRepository<TEntity>();
    }

    public async Task Run(IEnumerable<TEntity> entityList)
    {
        foreach (var entity in entityList)
        {
            await _repository.CreateAsync(entity);
        }
    }
}

DotnetCrawler.Scheduler

该项目为爬虫的爬取操作提供调度任务。此需求不像其他解决方案那样实现了默认解决方案,因此您可以在此处开发自己的自定义处理器以实现您的需求。您可以将 QuartzHangfire 用于后台作业。

DotnetCrawler.Sample

此项目证明了使用 DotnetCrawler 将新 iPhone 从 eBay 电子商务网站插入到 `Catalog` 表中。因此,您可以将 `DotnetCrawler.Sample` 设置为启动项目,并调试我们在文章上述部分解释的模块。

Catalog 表列表,爬虫插入的最后 10 条记录

结论

该库的设计类似于其他强大的爬虫库,如 WebMagicScrapy,但其架构侧重于通过应用**领域驱动设计**和**面向对象原则**等最佳实践来实现**易于扩展**和**可伸缩性**。因此,您可以轻松实现您的自定义需求,并使用这个**直接**、**轻量级网络爬取/抓取**库的默认功能,该库基于 **dotnet core** 输出 **Entity Framework Core**。

如果您喜欢这篇文章,请为我点赞和投票。

历史

  • 2019年2月24日 - 初次发布
© . All rights reserved.