之前的章節,我們陸續的介紹了使用C#制作爬蟲的基礎知識,而且現在也應該比較了解如何制作一只簡單的Web爬蟲了。
本節,我們來做一個完整的爬蟲系統,將之前的零散的東西串聯起來,可以作為一個爬蟲項目運作流程的初探,但實際項目中,還需要解決其他一些問題,我們后續章節也將繼續深耕:)
先來看一下解決方案的整體結構:
我們也希望我們的爬蟲框架能夠被應用到跨平台的項目中,所以,本項目采用了.Net Core Framework作為基礎。
根據上圖所示,項目結構還是很簡單的。爬蟲框架部分,與之前章節的內容並沒有太大變動。本節主要是看一下在應用中,如何將一只小螞蟻擴展到一群小螞蟻。
本示例項目以采集某在線小說網站為例,特此對該小說網站說一聲:如有得罪,敬請諒解、如有引流,敬請打賞:P
好了,步入正體,現來看看應用程序的入口(MikeWare.Crawler.EBooks)項目,是如何做的吧。

1 namespace MikeWare.Crawlers.EBooks 2 { 3 using MikeWare.Crawlers.EBooks.Bizs; 4 using MikeWare.Crawlers.EBooks.Entities; 5 using System; 6 7 class Program 8 { 9 static void Main(string[] args) 10 { 11 var lastUpdateTime = DateTime.MinValue; 12 13 BooksList.Start(1, lastUpdateTime); 14 15 Console.Read(); 16 } 17 } 18 }
這個項目很簡單,就是用了項目初始的Program類,在Main方法中,構造了一個DateTime lastUpdateTime變量,然后就開始采集任務了。
關於lastUpdateTime變量,我們可以這么理解,就是在采集過程中,我們可能需要一遍又一遍的對數據源進行采集,以獲取更新數據。在實際情境中,可能數據源的更新,並不是所有數據都在發生變化的,比如我們本例中的小說,小說的作者昨天寫了一些章節,那么這些章節,在今天甚至這輩子都不會再發生變化了,所以,我們也沒有必要每一次采集都將所有小說的章節都采集一遍,也就是我們只對有更新的小說感興趣,那么如何區分新的數據與老的數據,這個要看數據源為我們提供了什么樣的特征,從中尋找到一個或多個合適的特征來作為我們的標志位,本例呢,就是采用了小說的更新時間,這就是lastUpdateTime的由來,可以根據具體的情況,來自定義符合實際情況的標記位來達到采集增量的目的。
那么對於首次采集來講,我們可能希望是將整個站點的所有小說都采集一遍,那么,這個時候,lastUpdateTime的初始值,就可以設定DateTime.MinValue,這樣,即使再古老的小說,它的更新時間也不會早於這個標記位了,也就達到了采集全部小說的目的;那么對於再次采集,我們可以先統計上一次采集結果中,最近的更新時間,作為本次采集的lastUpdateTime。所以對於無論是首次采集還是再次采集來講,邏輯可以合並為“獲取上一次采集的最近更新時間”,而這個邏輯內部去判斷,如果之前有采集記錄,就返回最近的更新時間,如果沒有,就返回DateTime.MinValue,這樣就都統一起來了。
同時,本項目其實只是提供了一個采集任務的啟動的觸發點。我盡量將它作得很輕,這樣可以方便移植,或許一個WinForm項目中的Button_Click事件或者一個WebApplication項目的Page_Load事件才是它的入口點,Anyway,Main方法中的內容,拷貝過去就好:)View部分暫不多說了。
接下來,我們簡單介紹一下(MikeWare.Crawlers.EBooks.Entities)項目

1 namespace MikeWare.Crawlers.EBooks.Entities 2 { 3 using System; 4 using System.Collections.Generic; 5 6 public class Book 7 { 8 public int Id { get; set; } 9 public string Name { get; set; } 10 public string PhotoUrl { get; set; } 11 public Dictionary<int, string> Sections { get; set; } 12 public Dictionary<int, string> SectionContents { get; set; } 13 14 public string Author { get; set; } 15 public DateTime LastUpdateTime { get; set; } 16 } 17 }
這個項目也很簡單,只提供了一個類(Book),這個類中,定義了一本書的ID、名字、封面圖片的URL、作者、最近更新時間、章節內容等屬性。用來描述一本書的基本特征。不過,我並沒有采集一本書的評論及評分內容,一、數據源沒有提供評論數據;二、我更希望實現我自己的評分評價系統,而不依賴於數據源的評分;這里只是想說明,實體的定義,是為了業務服務的,可以根據需要,去自定義;當然,如果希望數據完整,我們應該把評分等數據都采集過來做持久化,萬一以后哪天又突然想用這部分數據了呢,再去重新采集一遍?呵呵……拍腦袋的事情總是防不勝防。
好了,接下來,開始介紹(MikeWare.Crawlers.EBooks.Bizs)項目

1 namespace MikeWare.Crawlers.EBooks.Bizs 2 { 3 using MikeWare.Core.Components.CrawlerFramework; 4 using System; 5 using System.Net; 6 using System.Text; 7 using System.Text.RegularExpressions; 8 using System.Threading; 9 using System.Threading.Tasks; 10 11 public class BooksList 12 { 13 private static Encoding encoding = new UTF8Encoding(false); 14 private static int total_page = -1; 15 private static Regex regex_list = new Regex(@"<li>[^<]+<div.*?更新:(?<updateTime>\d+?-\d+?-\d+?)[^\d].+?<a[^/]+?/Shtml(?<id>\d+?)\.html.+?</li>", RegexOptions.Singleline); 16 private static Regex regex_page = new Regex(@"<div class=""tspage"">.+?<a href='/s/new/index_(?<totalPage>\d+?).html'>尾頁</a>.+?</div>", RegexOptions.Singleline); 17 18 public static void Start(int pageIndex, DateTime lastUpdateTime) 19 { 20 new WorkerAnt() 21 { 22 AntId = (uint)Math.Abs(DateTime.Now.ToString("yyyyMMddHHmmssfff").GetHashCode()), 23 OnJobStatusChanged = (sender, args) => 24 { 25 Console.WriteLine($"{args.EventAnt.AntId} said: {args.Context.JobName} entered status '{args.Context.JobStatus}'."); 26 switch (args.Context.JobStatus) 27 { 28 case TaskStatus.Created: 29 if (string.IsNullOrEmpty(args.Context.JobName)) 30 { 31 Console.WriteLine($"Can not execute a job with no name."); 32 args.Cancel = true; 33 } 34 else 35 Console.WriteLine($"{args.EventAnt.AntId} said: job {args.Context.JobName} created."); 36 break; 37 case TaskStatus.Running: 38 if (null != args.Context.Memory) 39 Console.WriteLine($"{args.EventAnt.AntId} said: {args.Context.JobName} already downloaded {args.Context.Memory.Length} bytes."); 40 break; 41 case TaskStatus.RanToCompletion: 42 if (null != args.Context.Buffer && 0 < args.Context.Buffer.Length) 43 Analize(args.Context.Buffer, pageIndex, lastUpdateTime); 44 if (null != args.Context.Watch) 45 Console.WriteLine("/* ********************** using {0}ms / request ******************** */" 46 + Environment.NewLine + Environment.NewLine, (args.Context.Watch.Elapsed.TotalMilliseconds / 100).ToString("000.00")); 47 break; 48 case TaskStatus.Faulted: 49 Console.WriteLine($"{args.EventAnt.AntId} said: job {args.Context.JobName} faulted because {args.Message}."); 50 break; 51 case TaskStatus.WaitingToRun: 52 case TaskStatus.WaitingForChildrenToComplete: 53 case TaskStatus.Canceled: 54 case TaskStatus.WaitingForActivation: 55 default:/* Do nothing on this even. */ 56 break; 57 } 58 }, 59 }.Work(new JobContext 60 { 61 JobName = "奇書網-最新電子書-列表", 62 Uri = $"http://www.xqishuta.com/s/new/index_{pageIndex}.html", 63 Method = WebRequestMethods.Http.Get, 64 }); 65 } 66 67 private static void Analize(byte[] data, int pageIndex, DateTime lastUpdateTime) 68 { 69 if (null == data || 0 == data.Length) 70 return; 71 72 var context = encoding.GetString(data); 73 var matches = regex_list.Matches(context); 74 if (null != matches && 0 < matches.Count) 75 { 76 var update_time = DateTime.MinValue; 77 var id = 0; 78 foreach (Match match in matches) 79 { 80 if (!DateTime.TryParse(match.Groups["updateTime"].Value, out update_time) 81 || !int.TryParse(match.Groups["id"].Value, out id)) continue; 82 83 if (update_time > lastUpdateTime) 84 { 85 Thread.Sleep(5); 86 BookSectionsList.Start(id); 87 } 88 else 89 return; 90 } 91 } 92 93 if (-1 == total_page) 94 { 95 var match = regex_page.Match(context); 96 if (null != match && match.Success && int.TryParse(match.Groups["totalPage"].Value, out total_page)) ; 97 98 } 99 100 if (pageIndex < total_page) 101 { 102 Thread.Sleep(5); 103 pageIndex++; 104 Start(pageIndex, lastUpdateTime); 105 } 106 } 107 } 108 }
這個邏輯處理類,實際上是整個采集任務的入口點,提供了幾個私有變量和兩個方法,我們挨個介紹一下:
// 提供了頁面解析的編碼設定; private static Encoding encoding = new UTF8Encoding(false); // 這個頁面是一個可以翻頁的列表頁面,所以,我們有必要知道這個列表,一共有多少頁; private static int total_page = -1; // 一個正則表達式,獲取列表的每一項的數據; private static Regex regex_list = new Regex(@"……"); // 一個正則表達式,獲取翻頁中最后一頁的頁碼的數據; private static Regex regex_page = new Regex(@"……");
這些變量被定義為私有靜態變量,首先的考慮是當這個處理類被重復調用時,盡量避免不必要的內存分配。當然,像encoding這樣的變量,可能整個站點都是統一的,沒有必要在每個處理類中都單獨聲明一個,這里只是為了能夠使每一個類盡量完整,免得大家在閱讀的時候還要跳轉到別的類去查看encoding的定義;
關於正則表達式,這里不做過多說明,不是本書的重點;
接下來,是本類中的Start方法,這個方法需要兩個參數,一個是前面說過的lastUpdateTime,另一個就是Url中需要的頁碼:pageIndex;這個方法在View層被觸發,開始啟動了整個采集任務,View層傳遞過來的pageIndex為1;
case TaskStatus.RanToCompletion: if (null != args.Context.Buffer && 0 < args.Context.Buffer.Length) Analize(args.Context.Buffer, pageIndex, lastUpdateTime); if (null != args.Context.Watch) Console.WriteLine("/* ********************** using {0}ms / request ******************** */" + Environment.NewLine + Environment.NewLine, (args.Context.Watch.Elapsed.TotalMilliseconds / 100).ToString("000.00")); break;
上面代碼段指示了,當任務完成時,調用了Analize(xxx,yyy,zzz)方法。
接下來,本類的另一個方法(Analize),它的聲明如下:
private static void Analize(byte[] data, int pageIndex, DateTime lastUpdateTime)
這里需要說明的是第一個參數data,它是一個字節數組,我們為什么沒有在采集完成時,直接將數據轉化為文本然后傳遞給Analize方法?這里,我們需要分開兩部分來看待,Start方法,我們可以看作是一個采集器,而Analize方法可以看作是一個分析器,采集器呢,本身不知道也不需要知道它采集的是個什么東西,它只管采集,數據盡管交給分析器去處理,這樣任務單一;分析器呢,是針對某一個任務而定制的,比如這個列表頁,我們的分析工作,就是針對列表頁的特性進行分析,數據怎么抽取,流程怎么流轉,這,可以說都是預知的,隱含的條件,就是它要分析的內容是文本,也是提前就已知的;那么,我們切換到另一個場景,如果我們采集的是一個圖片或者壓縮包文件,采集器硬要把它轉換為文本,這種做法是錯誤的,而分析器,它是已經知道了它的目的,在分析處理的過程中,它的邏輯就是如何處理這樣的文件,所以接收一個字節數組來做后續的處理,也是沒有問題的。
這也是為什么要拆分為Start和Analize兩個方法,兩個方法合並到一起,都寫在Start里行不行呢,肯定行啊,可是行是行,但是不好。因為我在這里,只是提出了采集器和分析器的概念,當面向更為復雜的業務時,還會有諸如存儲器、調度器等等實際需要的組件。那么,都寫在Start里?顯然,不是一個很好的決策。
好了,我們繼續來分析Analize方法的內部實現,邏輯也不算復雜,主要有兩個分支:
- 當我們獲取到小說列表了以后,就遍歷列表,如果這部小說的最近更新時間符合我們的lastUpdateTime限制,則得到這部小說的Id,並調用BookSectionsList.Start(id),繼而進行下一步的采集,否則,就返回了,不再繼續采集后續小說及列表頁面;
- 當列表頁的頁碼仍小於總頁碼的時候,遞增pageIndex,調用Start方法,進行翻頁采集;
這就是BookList類的工作了,基本完成。
另外的兩個類,工作原理與BookList的一致,由采集器與分析器組成:
- BookSectionsList:采集一部小說的章節列表;
- BookSection:采集具體到某一章節的內容;
OK,這樣,我們就完成了一個簡單的爬蟲應用,預覽一下效果總是迫不及待的。
聲明:本示例僅做為示例項目發布,並不贊成直接使用。
另:本示例還是有許多不足之處,比如,運行1分鍾之后,會出現很多錯誤,比如,超時、目標主機積極拒絕、沒有做異常處理等;這里就涉及到了爬蟲框架的后續內容:反爬策略及應對,敬請期待后續章節;
喜歡本系列叢書的朋友,可以點擊鏈接加入QQ交流群(994761602)【C# 破境之道】
方便各位在有疑問的時候可以及時給我個反饋。同時,也算是給各位志同道合的朋友提供一個交流的平台。
需要源碼的童鞋,也可以在群文件中獲取最新源代碼。