2023年以來一直很忙,臨近春節,各種瑣事更多,但鴿了太久沒寫文章總是不舒坦,忙中偷閒來記錄下最近用C#寫爬蟲的一些筆記。
爬蟲一般都是用Python來寫,生態豐富,動態語言開發速度快,偵錯也很方便
但是
我要說但是,動態語言也有其侷限性,筆者作為老爬蟲帶師,幾乎各種語言都搞過,現在這個任務並不複雜,用我最喜歡的C#做小菜一碟~
之前做 OneCat 專案的時候,最開始的資料採集模組,就是用 C# 做的,同時還整合了 Chloe 作為 ORM,用 Nancy 做 HTTP 介面,結合 C# 強大的並行功能,做出來的效果不錯。
這次是要爬一些桌布,很簡單的場景,於是沿用了之前 OneCat 專案的一些工具類,並且做了一些改進。
網路請求直接使用 .Net Core 標準庫的 HttpClient
,這個庫要求使用單例,在 AspNetCore 裡一般用依賴注入,不過這次簡單的爬蟲直接用 Console 程式就行。
把 HTML 爬下來後,還需要解析,在Python中一般用 BeautifulSoup,在C#裡可以用 AngleSharp ,也很好用~
為了使用方便,我又封裝了一個工具類,把 HttpClient 和 AngleSharp 整合在一起。
public static class HttpHelper {
public const string UserAgent =
"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Safari/537.36";
public static HttpClientHandler Handler { get; }
public static HttpClient Client { get; }
static HttpHelper() {
Handler = new HttpClientHandler();
Client = new HttpClient(Handler);
Client.DefaultRequestHeaders.Add("User-Agent", UserAgent);
}
public static async Task<IHtmlDocument> GetHtmlDocument(string url) {
var html = await Client.GetStringAsync(url);
// todo 這個用法有記憶體漏失問題,得優化一下
return new HtmlParser().ParseDocument(html);
}
public static async Task<IHtmlDocument> GetHtmlDocument(string url, string charset) {
var res = await Client.GetAsync(url);
var resBytes = await res.Content.ReadAsByteArrayAsync();
var resStr = Encoding.GetEncoding(charset).GetString(resBytes);
// todo 這個用法有記憶體漏失問題,得優化一下
return new HtmlParser().ParseDocument(resStr);
}
}
這段程式碼裡面有倆 todo
,這個記憶體漏失的問題在簡單的爬蟲中影響不大,所以後面有大規模的需求再來優化吧~
大部分爬蟲是從網頁上拿資料
如果網頁是後端渲染出來的話,沒有js動態載入資料,基本上用CSS選擇器+正規表示式就可以拿到任何想要的資料。
經過前面的封裝,請求網頁+解析HTML只需要一行程式碼
IHtmlDocument data = await HttpHelper.GetHtmlDocument(url);
拿到 IHtmlDocument
物件之後,用 QuerySelector
傳入css選擇器,就可以拿到各種元素了。
例如這樣,取出 <li>
元素下所有連結的地址
var data = await HttpHelper.GetHtmlDocument(url);
foreach (var item in data.QuerySelectorAll(".pagew li")) {
var link = item.QuerySelector("a");
var href = link?.GetAttribute("href");
if (href != null) await CrawlItem(href);
}
或者結合正規表示式
var data = await HttpHelper.GetHtmlDocument(url);
var page = data.QuerySelector(".pageinfo");
Console.WriteLine("拿到分頁資訊:{0}", page?.TextContent);
var match = Regex.Match(page?.TextContent ?? "", @"共\s(\d+)頁(\d+)條");
var pageCount = int.Parse(match.Groups[1].Value);
for (int i = 1; i <= pageCount; i++) {
await CrawlPage(i);
}
正規表示式非常好用,爬蟲必備~
這裡再推薦一個好用的東西,菜鳥工具的線上正規表示式測試,拿到一個字串之後,先在測試器裡面寫出一個能匹配的正則,再放到程式裡,效率更高~
地址: https://c.runoob.com/front-end/854/
老生常談的問題了
JSON 在 web 開發中很常見,無論是介面互動,還是本地儲存資料,這都是一種很好的格式
.Net Core 自帶的 System.Text.Json
還不錯,不需要手動安裝依賴,沒有特殊需求的話,直接用這個就好了
這裡的場景是要把採集的資料存到 JSON 裡,即序列化,用以下的設定程式碼一把梭即可,可以應付大多數場景
var jsonOption = new JsonSerializerOptions {
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
寫入檔案
await File.WriteAllTextAsync("path", JsonSerializer.Serialize(data, jsonOption));
最簡單就是直接用 HttpClient 獲取 Response,然後 CopyToAsync
寫到檔案流裡面
這個用法拿來下載幾個小檔案還可以,但多執行緒下載、斷點重連、失敗重試等方法就得自己實現了,比較繁瑣。
所以這次我直接用了第三方庫 Downloader,這個庫看起來很猛,功能很多,我就不翻譯了,詳情見專案主頁
專案地址: https://github.com/bezzad/Downloader
同樣的,我把下載的功能也封裝到 HttpHelper
中
增加這部分程式碼
public static IDownloadService Downloader { get; }
public static DownloadConfiguration DownloadConf => new DownloadConfiguration {
BufferBlockSize = 10240, // 通常,主機最大支援8000位元組,預設值為8000。
ChunkCount = 8, // 要下載的檔案分片數量,預設值為1
// MaximumBytesPerSecond = 1024 * 50, // 下載速度限制,預設值為零或無限制
MaxTryAgainOnFailover = 5, // 失敗的最大次數
ParallelDownload = true, // 下載檔案是否為並行的。預設值為false
Timeout = 1000, // 每個 stream reader 的超時(毫秒),預設值是1000
RequestConfiguration = {
Accept = "*/*",
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
CookieContainer = new CookieContainer(), // Add your cookies
Headers = new WebHeaderCollection(), // Add your custom headers
KeepAlive = true,
ProtocolVersion = HttpVersion.Version11, // Default value is HTTP 1.1
UseDefaultCredentials = false,
UserAgent = UserAgent
}
};
static HttpHelper() {
// ...
Downloader = new DownloadService(DownloadConf);
}
使用方法依然是一行程式碼
await HttpHelper.Downloader.DownloadFileTaskAsync(url, filepath);
不過這次沒有直接封裝一個下載的方法,而是把 IDownloadService
物件做成屬性,因為下載的時候往往要加一些「buff」
比如監聽下載進度,看下面的程式碼
HttpHelper.Downloader.DownloadStarted += DownloadStarted;
HttpHelper.Downloader.DownloadFileCompleted += DownloadFileCompleted;
HttpHelper.Downloader.DownloadProgressChanged += DownloadProgressChanged;
HttpHelper.Downloader.ChunkDownloadProgressChanged += ChunkDownloadProgressChanged;
這個庫提供了四個事件,分別是:
有了這些事件,就可以實現下載進度條展示了,接下來介紹的進度條,也是 Downloader 這個庫官方例子中使用的
專案地址: https://github.com/Mpdreamz/shellprogressbar
首先,把官網上的例子忘記吧,那幾個例子實際作用不大。
這個進度條有兩種模式,一種是它自己的 Tick
方法,先定義總任務數量,執行一次表示完成一個任務,比如這個:
using var bar = new ProgressBar(10, "正在下載所有圖片", BarOptions);
上面程式碼定義了10個任務,每執行一次 bar.Tick()
就表示完成一次任務,執行10次後就整個完成~
IProgress<T>
模式這個 IProgress<T>
是C#標準庫的型別,用來處理進度條的。
ProgressBar
物件可以使用 AsProgress<T>
方法轉換稱 IProgress<T>
物件,然後呼叫 IProgress<T>
的 Report
方法,報告進度。
這個就很適合下載進度這種非線性的任務,每次更新時,完成的進度都不一樣
Downloader的下載進度更新事件,用的是百分比,所以用這個 IProgress<T>
模式就很合適。
本爬蟲專案是要採集桌布,桌布的形式是按圖集組織的,一個圖集下可能有多個圖片
為了應對這種場景,可以用一個進度條顯示總進度,表示當前正在下載某個圖集
然後再巢狀子進度條,表示正在下載當前圖集的第n張圖片
然後的然後,再套娃一個孫子進度條,表示具體圖片的下載進度(百分比)
這裡用到的是 ProgressBar
的 Spawn
方法,會生成一個 ChildProgressBar
物件,此時更新子進度條物件的值就好了。
直接看程式碼吧
var list = // 載入圖集列表
using var bar = new ProgressBar(list.Count, "正在下載所有圖片", BarOptions);
foreach (var item in list) {
bar.Message = $"圖集:{item.Name}";
bar.Tick();
foreach (var imgUrl in item.Images) {
using (var childBar = bar.Spawn(item.ImageCount,$"圖片:{imgUrl}",ChildBarOptions)) {
childBar.Tick();
// 具體的下載程式碼
}
}
}
這樣就實現了主進度條顯示下載了第幾個圖集,子進度條顯示下載到第幾張圖片。
然後具體下載程式碼中,使用 Downloader 的事件監聽,再 Spawn 一個新的進度條顯示單張圖片的下載進度。
程式碼如下:
private async Task Download(IProgressBar bar, string url, string filepath) {
var percentageBar = bar.Spawn(100, $"正在下載:{Path.GetFileName(url)}", PercentageBarOptions);
HttpHelper.Downloader.DownloadStarted += DownloadStarted;
HttpHelper.Downloader.DownloadFileCompleted += DownloadFileCompleted;
HttpHelper.Downloader.DownloadProgressChanged += DownloadProgressChanged;
await HttpHelper.Downloader.DownloadFileTaskAsync(url, filepath);
void DownloadStarted(object? sender, DownloadStartedEventArgs e) {
Trace.WriteLine(
$"圖片, FileName:{Path.GetFileName(e.FileName)}, TotalBytesToReceive:{e.TotalBytesToReceive}");
}
void DownloadFileCompleted(object? sender, AsyncCompletedEventArgs e) {
Trace.WriteLine($"下載完成, filepath:{filepath}");
percentageBar.Dispose();
}
void DownloadProgressChanged(object? sender, DownloadProgressChangedEventArgs e) {
percentageBar.AsProgress<double>().Report(e.ProgressPercentage);
}
}
注意所有的 ProgressBar 物件都需要用完釋放,所以這裡在 DownloadFileCompleted
事件裡面 Dispose
了。
上面的是直接用 using
語句,自動釋放。
這個東西的自定義功能還不錯。
可以設定顏色、顯示字元、顯示位置啥的
var barOptions = new ProgressBarOptions {
ForegroundColor = ConsoleColor.Yellow,
BackgroundColor = ConsoleColor.DarkYellow,
ForegroundColorError = ConsoleColor.Red,
ForegroundColorDone = ConsoleColor.Green,
BackgroundCharacter = '\u2593',
ProgressBarOnBottom = true,
EnableTaskBarProgress = RuntimeInformation.IsOSPlatform(OSPlatform.Windows),
DisplayTimeInRealTime = false,
ShowEstimatedDuration = false
};
EnableTaskBarProgress
這個選項可以同時更新Windows任務狀態列上的進度
具體設定選項可以直接看原始碼,裡面註釋很詳細。
如果 Spawn 出來的子進度條沒設定選項,那就會繼承上一級的設定。
用 C# 來做爬蟲還是舒服的,至少比 Java 好很多
做控制檯應用,打包成exe也方便分發
除了本文提到的這些第三方庫,使用C#開發控制檯應用還有其他好用的玩法
比如下面這倆
做圖形介面的話,如果要跨平臺,Winform、WPF之類的就不考慮了
微軟的MAUI好像有點坑,且沒有官方Linux支援,也pass掉
比較成熟的可以選 avalonia
輕量級的可以試試: https://github.com/picoe/Eto
另外,推薦一個工具 RoslynPad,這個好像是模仿 LinqPad 的。
可以像Python寫指令碼一樣快速執行C#程式碼段,還支援引入nuget包,對於寫爬蟲或者簡單程式碼實驗,非常方便
最關鍵的是開源免費!LinqPad實在太貴了,RoslynPad現在越更新越好用,感覺慢慢可以趕上 LinqPad 了~
PS:新年的公眾號紅包封面還沒搞,爭取今晚搞定~