引言

最近項目有需求從一個老的站點抓取信息然后倒入到新的系統(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路徑來獲取指定的信息??梢酝ㄟ^指定ValueProviderValueProviderKey來指定如何從一個節(jié)點中獲取數(shù)據(jù),實現(xiàn)如下:

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