引言
最近项目有需求从一个老的站点抓取信息然后倒入到新的系统中。由于老的系统已经没有人维护,数据又比较分散,而要提取的数据在网页上表现的反而更统一,所以计划通过网络请求然后分析页面的方式来提取数据。而两年前的这个时候,我似乎做过相同的事情——缘分这件事情,真是有趣。
设想
在采集信息这件事情中,最麻烦的往往是不同的页面的分解、数据的提取——因为页面的设计和结构往往千差万别。同时,对于有些页面,通常不得不绕着弯子请求(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路径来获取指定的信息。可以通过指定ValueProvider
和ValueProviderKey
来指定如何从一个节点中获取数据,实现如下:
public class XpathContentProcessor : BaseContentProcessor http://www.cnblogs.com/lightluomeng/p/7212577.html