C#爬蟲開發小結

2023-01-19 18:00:51

前言

2023年以來一直很忙,臨近春節,各種瑣事更多,但鴿了太久沒寫文章總是不舒坦,忙中偷閒來記錄下最近用C#寫爬蟲的一些筆記。

爬蟲一般都是用Python來寫,生態豐富,動態語言開發速度快,偵錯也很方便

但是

我要說但是,動態語言也有其侷限性,筆者作為老爬蟲帶師,幾乎各種語言都搞過,現在這個任務並不複雜,用我最喜歡的C#做小菜一碟~

開始

之前做 OneCat 專案的時候,最開始的資料採集模組,就是用 C# 做的,同時還整合了 Chloe 作為 ORM,用 Nancy 做 HTTP 介面,結合 C# 強大的並行功能,做出來的效果不錯。

這次是要爬一些桌布,很簡單的場景,於是沿用了之前 OneCat 專案的一些工具類,並且做了一些改進。

HttpHelper

網路請求直接使用 .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 ,這個記憶體漏失的問題在簡單的爬蟲中影響不大,所以後面有大規模的需求再來優化吧~

搞HTML

大部分爬蟲是從網頁上拿資料

如果網頁是後端渲染出來的話,沒有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 處理

老生常談的問題了

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模式

這個進度條有兩種模式,一種是它自己的 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張圖片

然後的然後,再套娃一個孫子進度條,表示具體圖片的下載進度(百分比)

這裡用到的是 ProgressBarSpawn 方法,會生成一個 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:新年的公眾號紅包封面還沒搞,爭取今晚搞定~