引言

最近项目有需求从一个老的站点抓取信息然后倒入到新的系统中。由于老的系统已经没有人维护,数据又比较分散,而要提取的数据在网页上表现的反而更统一,所以计划通过网络请求然后分析页面的方式来提取数据。而两年前的这个时候,我似乎做过相同的事情——缘分这件事情,真是有趣。

设想

在采集信息这件事情中,最麻烦的往往是不同的页面的分解、数据的提取——因为页面的设计和结构往往千差万别。同时,对于有些页面,通常不得不绕着弯子请求(ajax、iframe等),这导致数据提取成了最耗时也最痛苦的过程——因为你需要编写大量的逻辑代码将整个流程串联起来。我隐隐记得15年的7月,也就是两年前的这个时候,我就思考过这个问题。当时引入了一个类型CommonExtractor来解决这个问题。总体的定义是这样的:

    public class CommonExtractor
    {        public CommonExtractor(PageProcessConfig config)        {
            PageProcessConfig = config;
        }        protected PageProcessConfig PageProcessConfig;        public virtual void Extract(CrawledHtmlDocument document)        {            if (!PageProcessConfig.IncludedUrlPattern.Any(i => Regex.IsMatch(document.FromUrl.ToString(), i)))                return;            var node = new WebHtmlNode { Node = document.Contnet.DocumentNode, FromUrl = document.FromUrl };
            ExtractData(node, PageProcessConfig);
        }        protected Dictionary<string, ExtractionResult> ExtractData(WebHtmlNode node, PageProcessConfig blockConfig)
        {            var data = new Dictionary<string, ExtractionResult>();            foreach (var config in blockConfig.DataExtractionConfigs)
            {                if (node == null)                    continue;                /*使用'.'将当前节点作为上下文*/
                var selectedNodes = node.Node.SelectNodes("." + config.XPath);                var result = new ExtractionResult(config, node.FromUrl);                if (selectedNodes != null && selectedNodes.Any())
                {                    foreach (var sNode in selectedNodes)
                    {                        if (config.Attribute != null)
                            result.Fill(sNode.Attributes[config.Attribute].Value);                        else
                            result.Fill(sNode.InnerText);
                    }
                    data[config.Key] = result;
                }                else { data[config.Key] = null; }
            }            if (DataExtracted != null)
            {                var args = new DataExtractedEventArgs(data, node.FromUrl);
                DataExtracted(this, args);
            }            return data;
        }        public EventHandler<DataExtractedEventArgs> DataExtracted;
    }

代码有点乱(因为当时使用的是Abot进行爬网),但是意图还是挺明确的,希望从一个html文件中提取出有用的信息,然后通过一个配置来指定如何提取信息。这种处理方式存在的主要问题是:无法应对复杂结构,在应对特定的结构的时候必须引入新的配置,新的流程,同时这个新的流程不具备较高程度的可重用性。

设计

简单的开始

为了应对现实情况中的复杂性,最基本的处理必须设计的简单。从以前代码中捕捉到灵感,对于数据提取,其实我们想要的就是:

  • 给程序提供一个html文档

  • 程序给我们返回一个值

由此,给出了最基本的接口定义:

    public interface IContentProcessor
    {        /// <summary>
        /// 处理内容
        /// </summary>
        /// <param name="source"></param>
        /// <returns></returns>
        object Process(object source);
    }

可组合性

在上述的接口定义中,IContentProcessor接口的实现方法如果足够庞大,其实可以解决任何html页面的数据提取,但是,这意味着其可复用性会越来越低,同时维护将越来越困难。所以,我们更希望其方法实现足够小。但是,越小代表着其功能越少,那么,为了面对复杂的现实需求,必须让这些接口可以组合起来。所以,要为接口添加新的要素:子处理器。

    public interface IContentProcessor
    {        /// <summary>
        /// 处理内容
        /// </summary>
        /// <param name="source"></param>
        /// <returns></returns>
        object Process(object source);        /// <summary>
        /// 该处理器的顺序,越小越先执行
        /// </summary>
        int Order { get; }        /// <summary>
        /// 子处理器
        /// </summary>
        IList<IContentProcessor> SubProcessors { get; }
    }

这样一来,各个Processor就可以进行协作了。其嵌套关系和Order属性共同决定了其执行的顺序。同时,整个处理流程也具备了管道的特点:上一个Processor的处理结果可以作为下一个Processor的处理源。

结果的组合性

虽然解决了处理流程的可组合性,但是就目前而言,处理的结果还是不可组合的,因为无法应对复杂的结构。为了解决这个问题,引入了IContentCollector,这个接口继承自IContentProcessor,但是提出了额外的要求,如下:

    public interface IContentCollector : IContentProcessor
    {        /// <summary>
        /// 数据收集器收集的值对应的键
        /// </summary>
        string Key { get; }
    }

该接口要求提供一个Key来标识结果。这样,我们就可以用一个Dictionary<string,object>把复杂的结构管理起来了。因为字典的项对应的值也可以是Dictionary<string,object>,这个时候,如果使用json作为序列化手段的话,是非常容易将结果反序列化成复杂的类的。

至于为什么要将这个接口继承自IContentProcessor,这是为了保证节点类型的一致性,从而方便通过配置来构造整个处理流程。

配置

从上面的设计中可以看到,整个处理流程其实是一棵树,结构非常规范。这就为配置提供了可行性,这里使用一个Content-Processor-Options类型来表示每个Processor节点的类型和必要的初始化信息。定义如下所示:

    public class ContentProcessorOptions
    {        /// <summary>
        /// 构造Processor的参数列表
        /// </summary>
        public Dictionary<string, object> Properties { get; set; } = new Dictionary<string, object>();        /// <summary>
        /// Processor的类型信息
        /// </summary>
        public string ProcessorType { get; set; }        /// <summary>
        /// 指定一个子Processor,用于快速初始化Children,从而减少嵌套。
        /// </summary>
        public string SubProcessorType { get; set; }        /// <summary>
        /// 子项配置
        /// </summary>
        public List<ContentProcessorOptions> Children { get; set; } = new List<ContentProcessorOptions>();
    }

在Options中引入了SubProcessorType属性来快速初始化只有一个子处理节点的ContentCollector,这样就可以减少配置内容的层级,从而使得配置文件更加清晰。而以下方法则表示了如何通过一个Content-Processor-Options初始化Processor。这里使用了反射,但是由于不会频繁初始化,所以不会有太大的问题。

        public static IContentProcessor BuildContentProcessor(ContentProcessorOptions contentProcessorOptions)        {
            Type instanceType = null;            try
            {
                instanceType = Type.GetType(contentProcessorOptions.ProcessorType, true);
            }            catch
            {                foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
                {                    if (assembly.IsDynamic) continue;
                    instanceType = assembly.GetExportedTypes()
                        .FirstOrDefault(i => i.FullName == contentProcessorOptions.ProcessorType);                    if (instanceType != null) break;
                }
            }            if (instanceType == null) return null;            var instance = Activator.CreateInstance(instanceType);            foreach (var property in contentProcessorOptions.Properties)
            {                var instanceProperty = instance.GetType().GetProperty(property.Key);                if (instanceProperty == null) continue;                var propertyType = instanceProperty.PropertyType;                var sourceValue = property.Value.ToString();                var dValue = sourceValue.Convert(propertyType);
                instanceProperty.SetValue(instance, dValue);
            }            var processorInstance = (IContentProcessor) instance;            if (!contentProcessorOptions.SubProcessorType.IsNullOrWhiteSpace())
            {                var quickOptions = new ContentProcessorOptions
                {
                    ProcessorType = contentProcessorOptions.SubProcessorType,
                    Properties = contentProcessorOptions.Properties
                };                var quickProcessor = BuildContentProcessor(quickOptions);
                processorInstance.SubProcessors.Add(quickProcessor);
            }            foreach (var processorOption in contentProcessorOptions.Children)
            {                var processor = BuildContentProcessor(processorOption);
                processorInstance.SubProcessors.Add(processor);
            }            return processorInstance;
        }

几个约束

需要收敛集合

通过一个例子来说明问题:比如,一个html文档中提取了n个p标签,返回了一个string [],同时将这个作为源传递给下一个处理节点。下一个处理节点会正确的处理每个string,但是如果此节点也是针对一个string返回一个string[]的话,这个string []应该被一个Connector拼接起来。否则的话,结果就变成了2维3维度乃至是更多维度的数组。这样的话,每个节点的逻辑就变复杂同时不可控了。所以集合需要收敛到一个维度。

配置文件中的Properties不支持复杂结构

由于当前使用的.NET CORE的配置文件系统,无法在一个Dictionary<string,object>中将其子项设置为集合。

若干实现

Processor的实现和测试

HttpRequestContentProcessor

该处理器用于从网络上下载一段html文本,将文本内容作为源传递给下一个处理器;可以同时指定请求url或者将上一个请求节点传递过来的源作为url进行请求。实现如下:

  public class HttpRequestContentProcessor : BaseContentProcessor
    {        public bool UseUrlWhenSourceIsNull { get; set; } = true;        public string Url { get; set; }        public bool IgnoreBadUri { get; set; }        protected override object ProcessElement(object element)        {            if (element == null) return null;            if (Uri.IsWellFormedUriString(element.ToString(), UriKind.Absolute))
            {                if (IgnoreBadUri) return null;                throw new FormatException($"需要请求的地址{Url}格式不正确");
            }            return DownloadHtml(element.ToString());
        }        public override object Process(object source)        {            if (source == null && UseUrlWhenSourceIsNull && !Url.IsNullOrWhiteSpace())                return DownloadHtml(Url);            return base.Process(source);
        }        private static async Task<string> DownloadHtmlAsync(string url)        {            using (var client = new HttpClient())
            {                var result = await client.GetAsync(url);                var html = await result.Content.ReadAsStringAsync();                return html;
            }
        }        private string DownloadHtml(string url)        {            return AsyncHelper.Synchronize(() => DownloadHtmlAsync(url));
        }
    }

测试如下:

        [TestMethod]        public void HttpRequestContentProcessorTest()        {            var processor = new HttpRequestContentProcessor {Url = "https://www.baidu.com"};            var result = processor.Process(null);
            Assert.IsTrue(result.ToString().Contains("baidu"));
        }

XpathContentProcessor

该处理器通过接受一个XPath路径来获取指定的信息。可以通过指定ValueProviderValueProviderKey来指定如何从一个节点中获取数据,实现如下:

    public class XpathContentProcessor : BaseContentProcessor
    http://www.cnblogs.com/lightluomeng/p/7212577.html

网友评论