引言
最近項目有需求從一個老的站點抓取信息然后倒入到新的系統(tǒng)中。由于老的系統(tǒng)已經(jīng)沒有人維護(hù),數(shù)據(jù)又比較分散,而要提取的數(shù)據(jù)在網(wǎng)頁上表現(xiàn)的反而更統(tǒng)一,所以計劃通過網(wǎng)絡(luò)請求然后分析頁面的方式來提取數(shù)據(jù)。而兩年前的這個時候,我似乎做過相同的事情——緣分這件事情,真是有趣。
設(shè)想
在采集信息這件事情中,最麻煩的往往是不同的頁面的分解、數(shù)據(jù)的提取——因為頁面的設(shè)計和結(jié)構(gòu)往往千差萬別。同時,對于有些頁面,通常不得不繞著彎子請求(ajax、iframe等),這導(dǎo)致數(shù)據(jù)提取成了最耗時也最痛苦的過程——因為你需要編寫大量的邏輯代碼將整個流程串聯(lián)起來。我隱隱記得15年的7月,也就是兩年前的這個時候,我就思考過這個問題。當(dāng)時引入了一個類型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; /*使用'.'將當(dāng)前節(jié)點作為上下文*/ 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; }
代碼有點亂(因為當(dāng)時使用的是Abot進(jìn)行爬網(wǎng)),但是意圖還是挺明確的,希望從一個html文件中提取出有用的信息,然后通過一個配置來指定如何提取信息。這種處理方式存在的主要問題是:無法應(yīng)對復(fù)雜結(jié)構(gòu),在應(yīng)對特定的結(jié)構(gòu)的時候必須引入新的配置,新的流程,同時這個新的流程不具備較高程度的可重用性。
設(shè)計
簡單的開始
為了應(yīng)對現(xiàn)實情況中的復(fù)雜性,最基本的處理必須設(shè)計的簡單。從以前代碼中捕捉到靈感,對于數(shù)據(jù)提取,其實我們想要的就是:
給程序提供一個html文檔
程序給我們返回一個值
由此,給出了最基本的接口定義:
public interface IContentProcessor { /// <summary> /// 處理內(nèi)容 /// </summary> /// <param name="source"></param> /// <returns></returns> object Process(object source); }
可組合性
在上述的接口定義中,IContentProcessor
接口的實現(xiàn)方法如果足夠龐大,其實可以解決任何html頁面的數(shù)據(jù)提取,但是,這意味著其可復(fù)用性會越來越低,同時維護(hù)將越來越困難。所以,我們更希望其方法實現(xiàn)足夠小。但是,越小代表著其功能越少,那么,為了面對復(fù)雜的現(xiàn)實需求,必須讓這些接口可以組合起來。所以,要為接口添加新的要素:子處理器。
public interface IContentProcessor { /// <summary> /// 處理內(nèi)容 /// </summary> /// <param name="source"></param> /// <returns></returns> object Process(object source); /// <summary> /// 該處理器的順序,越小越先執(zhí)行 /// </summary> int Order { get; } /// <summary> /// 子處理器 /// </summary> IList<IContentProcessor> SubProcessors { get; } }
這樣一來,各個Processor
就可以進(jìn)行協(xié)作了。其嵌套關(guān)系和Order
屬性共同決定了其執(zhí)行的順序。同時,整個處理流程也具備了管道的特點:上一個Processor
的處理結(jié)果可以作為下一個Processor
的處理源。
結(jié)果的組合性
雖然解決了處理流程的可組合性,但是就目前而言,處理的結(jié)果還是不可組合的,因為無法應(yīng)對復(fù)雜的結(jié)構(gòu)。為了解決這個問題,引入了IContentCollector,這個接口繼承自IContentProcessor,但是提出了額外的要求,如下:
public interface IContentCollector : IContentProcessor { /// <summary> /// 數(shù)據(jù)收集器收集的值對應(yīng)的鍵 /// </summary> string Key { get; } }
該接口要求提供一個Key來標(biāo)識結(jié)果。這樣,我們就可以用一個Dictionary<string,object>
把復(fù)雜的結(jié)構(gòu)管理起來了。因為字典的項對應(yīng)的值也可以是Dictionary<string,object>
,這個時候,如果使用json作為序列化手段的話,是非常容易將結(jié)果反序列化成復(fù)雜的類的。
至于為什么要將這個接口繼承自IContentProcessor
,這是為了保證節(jié)點類型的一致性,從而方便通過配置來構(gòu)造整個處理流程。
配置
從上面的設(shè)計中可以看到,整個處理流程其實是一棵樹,結(jié)構(gòu)非常規(guī)范。這就為配置提供了可行性,這里使用一個Content-Processor-Options
類型來表示每個Processor
節(jié)點的類型和必要的初始化信息。定義如下所示:
public class ContentProcessorOptions { /// <summary> /// 構(gòu)造Processor的參數(shù)列表 /// </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
屬性來快速初始化只有一個子處理節(jié)點的ContentCollector
,這樣就可以減少配置內(nèi)容的層級,從而使得配置文件更加清晰。而以下方法則表示了如何通過一個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標(biāo)簽,返回了一個string []
,同時將這個作為源傳遞給下一個處理節(jié)點。下一個處理節(jié)點會正確的處理每個string
,但是如果此節(jié)點也是針對一個string
返回一個string[]
的話,這個string []
應(yīng)該被一個Connector
拼接起來。否則的話,結(jié)果就變成了2維
、3維度
乃至是更多維度的數(shù)組。這樣的話,每個節(jié)點的邏輯就變復(fù)雜同時不可控了。所以集合需要收斂到一個維度。
配置文件中的Properties不支持復(fù)雜結(jié)構(gòu)
由于當(dāng)前使用的.NET CORE的配置文件系統(tǒng),無法在一個Dictionary<string,object>
中將其子項設(shè)置為集合。
若干實現(xiàn)
Processor的實現(xiàn)和測試
HttpRequestContentProcessor
該處理器用于從網(wǎng)絡(luò)上下載一段html文本,將文本內(nèi)容作為源傳遞給下一個處理器;可以同時指定請求url或者將上一個請求節(jié)點傳遞過來的源作為url進(jìn)行請求。實現(xiàn)如下:
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
來指定如何從一個節(jié)點中獲取數(shù)據(jù),實現(xiàn)如下:
public class XpathContentProcessor : BaseContentProcessor http://www.cnblogs.com/lightluomeng/p/7212577.html