User Tools

Site Tools

prog:csharp:250306-002:index

C#: 使用 AngleSharp 爬蟲工具來抓取網頁內容吧 (2025-03-06)

Local Backup

  • 前一次用到 AngleSharp 已經是去年抓網路小說的時候,想不到最近又用上了,乾脆就來筆記一下。
  • AngleSharp 是一款簡單方便的 C# 爬蟲套件,撈網頁時支援 QuerySelector 的語法來篩選網頁元素,並且撈回來的資料集合也都能用 Linq 操作,讓我們能對爬取的網頁內容快速進行篩選和處理,只需要短短的語法就可以開心抓想要的內容。
  • 說到要示範爬蟲,果然還是要用爬蟲界默認的經典範例 PTT 表特版 來操作(?),接著就讓我們來寫一個簡單的腳本來抓取文章吧!

安裝套件

  • 首先要先從 Nuget 安裝 AngleSharp
  • 確認安裝完畢後就可以開始撰寫囉~
  • 本篇接下來會使用 Linqpad 來進行簡單的範例,使用 VisualStudio 的朋友遇到 Dump() 之類的語法就請再自己調整一下呦

建立 Browser 抓取網頁內容

  • 首先我們需要先建立一個 Browser 來代替我們做事:
  • void Main()
    {
        // 建立 Browser 的配置
    	var config = AngleSharp.Configuration.Default.WithDefaultLoader();
    
        // 根據配置建立出我們的 Browser 
    	var browser = BrowsingContext.New(config);
    }
  • 建立之後就可以用這個 browser 的 OpenAsync() 來抓取網頁內容囉:
  • // 這邊為了方便處理也順便把 `Main` 改成非同步的版本
    async Task Main()
    {
    	var config = AngleSharp.Configuration.Default.WithDefaultLoader();
    	var browser = BrowsingContext.New(config);
    	
        // 這邊用的型別是 AngleSharp 提供的 AngleSharp.Dom.Url
    	var url = new Url("https://www.ptt.cc/bbs/Beauty/index.html");
    
        // 使用 OpenAsync 來打開網頁抓回內容
    	var document = await browser.OpenAsync(url);
    	document.Dump();
    }
  • 可以看到抓了一堆東西回來,包含網頁的 Uri、Body 等等:
  • 在前一個步驟我們雖然把網頁抓回來了,但讓我們看一下 context.Body.InnerHtml 的內容:
  • 看起來跟我們要的文章列表有點差距啊!這是因為 PTT 會先跳出一個視窗詢問是否滿十八歲,選擇「是」之後會記錄一筆 over18=1 到 Cookie 中,只有持有這個 Cookie 才能進入到文章列表。
  • 因此我們現在要先把答案準備到 Cookie 裡,這時候我們就需要調整一下前面的配置內容。
  • 首先先在配置裡加上預設的 Cookies:
  • // 加上 `WithDefaultCookies()` 來加上預設的 Cookie
    var config = AngleSharp.Configuration.Default
        .WithDefaultLoader()
        .WithDefaultCookies();
    
    var browser = BrowsingContext.New(config);
  • 接著在我們開啟網頁之前,使用 SetCookie 對目標指定要用的 Cookie:
  • var url = new Url("https://www.ptt.cc/bbs/Beauty/index.html");
    
    // 加上已滿十八歲的 Cookie 來通過年齡驗證頁面
    browser.SetCookie(url, "over18=1'"); 
    
    var document = await browser.OpenAsync(url);	
  • 現在會長得像這樣:
  • async Task Main()
    {
    	var config = AngleSharp.Configuration.Default
    		.WithDefaultLoader()
    		.WithDefaultCookies();
    		
    	var browser = BrowsingContext.New(config);
    	
    	var url = new Url("https://www.ptt.cc/bbs/Beauty/index.html");
    	browser.SetCookie(url, "over18=1'"); 
    	
    	var document = await browser.OpenAsync(url);	
    	document.Body.InnerHtml.Dump();
    }
  • 接著讓我們來確認爬回來的 HTML:
  • 可以看到我們成功進到看板囉!
  • 註:另一個很常在配置處理的是使用 LoaderOptions 加上 AngleSharp.Css 提供的 WithCss() 來抓取 CSS 處理後的結果,例如:
  • Configuration.Default
        .WithDefaultLoader(new LoaderOptions 
        { 
            IsResourceLoadingEnabled = true 
        })
        .WithCss();
  • 不過這些場景(需要掛 Cookie 啦、要先等 CSS 啦)通常都是遇到之後才去 Google 的,這邊就不再贅述。
  • 註:browser.OpenAsync 除了提供網址讓他直接抓回來以外,有時候我們也會遇到本機已經有 Html 檔案要處理,或是已經用 HttpClient 等方法把網頁內容拿回來了的狀況
  • 這種時候也可以用 OpenAsync 提供的委派方法來把 HTML 字串讀取成 AngleSharp 的物件。例如:OpenAsync(res ⇒ res.Content(html)),同時委派中的 VirtualResponse 也提供了對這物件設定 Cookie 等資訊的方法,有這個需求的朋友可以再動手試試。

使用篩選器來抓指定的內容

  • 現在我們已經成功把網頁內容抓回來了,接著就是要使用選擇器來抓出我們想要的內容囉。
  • 如果有用過 JQuery 或是整天寫 CSS 的朋友應該不會陌生,大致上是這樣的:
    • div: 就是抓 <div>
    • #wow: 抓 id 是 wow 的元素
    • .hello: 抓 class 是 hello 的元素
  • 當然也可以加以組合,例如 div#wow, div > p.hello 等,有興趣的朋友可以再看一眼 菜鳥教程的 CSS 選擇器說明,其他狀況就等需要的時候再查表即可。
  • 首先讓我們好好觀察 HTML 結構,可以發現標題框是放在 class="r-ent" 的 div 裡,那我們就可以這樣下 Selector:div.r-ent
  • 接著我們就能用 QuerySelectorAll() 這個方法來用 Selector 抓取我們想要的元素:
  • document
        .QuerySelectorAll("div.r-ent") // 指定 class 為 r-ent 的 div
        .Select(node => node.InnerHtml) // 直接抓內容出來看看
        .Dump();
  • 可以看到我們成功抓了一排標題資訊回來囉!
  • 當然 AngleSharp 也提供了 QuerySelector 來抓取單個元素,這兩個方法用起來和 JavaScript 的體驗應該是差不多啦。
  • 確定我們有把要的內容抓回來,也不會再用到網頁內容的話,就可以補一行 document.Close(); 把開啟的網頁內容順手清掉囉。
  • 註:現在都2022年了,瀏覽器當然也有提供直接抓 Selector 的功能了
  • 這邊以 Edge 為例,讓我們到目標網頁按下F12打開開發人員工具,直接選取目標:
  • 選取目標後就會告訴我們這是哪個元素,我們只需要對該元素右鍵,然後複製它的 Selector 就行啦
  • 不過這樣抓到的 Selector 語法通常會比較囉嗦一點,例如這個頁面就是 #main-container > div.r-list-container.action-bar-margin.bbs-screen,這部份就再自己調整一下囉

整理抓取到的內容

  • 現在讓我們建立一個類別用來處理標題資訊吧,這邊我只需要名稱、推噓數和文章連結,其他像是作者什麼的不太需要:
  • public class Post
    {
    	public string Title { get; set; }
    	public int Push { get; set; }
    	public string Link { get; set; }
    }
  • 接著就來把我們剛剛抓到的每一則文章標題轉換成我們要的物件吧!
  • 觀察上面抓到的標題資訊,可以發現標題的文字和文章連結都放在 <div class="title"> 裡的 <a>,這邊我們就可以利用 QuerySelector 來抓到這個元素
  • 而為了拿到連結,這邊會需要使用 GetAttribute 來抓取元素的 href 屬性。這樣標題和連結就搞定了。
  • 另外要注意的是:如果文章被刪掉了,可是抓不到這些東西的!所以可以在 QuerySelector 之後用 ?. 的方式來做個 Null 時的防呆,最後也可以再用 Where 來過濾掉無效的文章。
  • var titleElement = post.QuerySelector("div.title > a");
    var title = titleElement?.InnerHtml; // 標題文字
    var link = titleElement?.GetAttribute("href"); // 文章連結
  • 剩下的推噓數可以看到是放在 <div class=“nrec”> 裡面,這邊我希望能轉換成數字,方便我們後續如果要用推噓數做篩選。所以讓我們額外處理一下:
    • 只顯示「爆」,這時候我們就視作 100
    • 如果有明確的數字,轉換為 Int
    • 沒有數字的話就當成 0。
  • var pushString = post.QuerySelector("div.nrec > span")?.InnerHtml;
    
    var pushCount = 
        pushString == "爆" ? 100 : 
        Int16.TryParse(pushString, out var push) ? push : 0;
  • 最後就把這些資訊拿來組裝我們的物件,那麼現在的程式碼就會像這樣:
  • var posts = postSource.Select(post => 
    {
        var titleElement = post.QuerySelector("div.title > a");
        var title = titleElement?.InnerHtml;
        var link = titleElement?.GetAttribute("href");
        
        var pushString = post.QuerySelector("div.nrec > span")?.InnerHtml;
        var pushCount = 
            pushString == "爆" ? 100 : 
            Int16.TryParse(pushString, out var push) ? push : 0;
            
        return new Post
        {
            Title = title,
            Link = link,
            Push = pushCount
        };
    })
    .Where(post => post.Title != null)
    .Dump();
  • 到這邊我們就成功把網頁的內容抓下來、篩選出我們要的內容囉!
  • 註:除了我們前面使用的 QuerySelector、GetAttribute 以外,AngleSharp 還提供了 GetElementsByTagName、Children 等屬性和方法讓我們能方便地在 DOM 中到處抓取元素。
  • 有興趣的朋友再自己摸索一下吧,我自己是比較習慣無腦 QuerySelector 了啦哈哈。

搭配遞迴來多撈幾頁

  • 其實我們前面已經把 AngleSharp 的基本操作跑完一輪了,基本上不外乎是「打開目標網頁 → 找到目標元素 → 用篩選器抓出來」這樣的 Loop,這節只是單純讓這個腳本完善一點而已。
  • 因此不感興趣的朋友也可以直接跳過這一段,直接前往小結,準備出發去動手抓自己想要的網頁囉。
  • 現在讓我們回到文章列表來,只抓第一頁實在沒什麼搞頭。如果我們想要換頁,那麼首先就要先抓出這個換頁按鈕:
  • 利用前面提到的F12大法,我們可以拿到這個按鈕的 Selector 語法,讓我們直接丟到 QuerySelector 裡,並且取得它的 href 屬性:
  • var nextPageLink = document
    	.QuerySelector("div.btn-group.btn-group-paging > a:nth-child(2)")
    	.GetAttribute("href")
    	.Dump(); // = /bbs/Beauty/index3980.html
  • 現在我們有了往前一頁的連結了,後續只需要把站台的 Url 和下一頁的相對 Url 就可以拼湊出換頁的連結了。
  • 如此一來就可以做出「抓文章 → 下一頁 → 抓文章…」的循環。像這種場合直接使用遞迴,寫起來會快一點。
  • 首先讓我們把抓取文章的處理過程抽出去當作方法,當然會重複用到我們的 Browser,還有站台 Url 以及每一頁的 Url,最後再丟個數字控制要抓幾頁,大概像這樣:
    private async Task<IEnumerable<Post>> GetPosts(
    	IBrowsingContext browser,
    	string baseUrl, 
    	string pageUrl,
    	int remainingPages)
    {
    }
  • 現在讓我們把上面的處理步驟逐一搬移到方法中,預期會需要:
    • 先組裝 Url、設定 Cookie 然後 Open 抓回網頁內容
    • 抓取這一頁的所有文章標題
    • 取得下一頁的連結
    • 遞迴取得下一頁及往後頁數的文章列表
    • 把這一頁和下一頁往後的文章列表組裝起來回傳
  • private async Task<IEnumerable<Post>> GetPosts(
    	IBrowsingContext browser, 
    	string baseUrl, 
    	string pageUrl,
    	int remainingPages)
    {
    	// 組裝 Url 並設定 Cookie
    	var url = new Url(baseUrl + pageUrl);
    	browser.SetCookie(url, "over18=1'");
    	var document = await browser.OpenAsync(url);
    
    	// 取出所有文章標題
    	var postSource = document.QuerySelectorAll("div.r-ent");
    
    	var posts = postSource.Select(post =>
    	{
    		var titleElement = post.QuerySelector("div.title > a");
    		var title = titleElement?.InnerHtml;
    		var link = titleElement?.GetAttribute("href");
    
    		var pushString = post.QuerySelector("div.nrec > span")?.InnerHtml;
    		var pushCount =
    			pushString == "爆" ? 100 :
    			Int16.TryParse(pushString, out var push) ? push : 0;
    
    		return new Post
    		{
    			Title = title,
    			Link = link,
    			Push = pushCount
    		};
    	})
    	.Where(post => post.Title != null);
    
    	// 取得下一頁的連結
    	var nextPageUrl = document
    		.QuerySelector("div.btn-group.btn-group-paging > a:nth-child(2)")
    		.GetAttribute("href");
    		
    	document.Close();
    	
    	// 檢查剩餘頁數
    	remainingPages--;
    	if (remainingPages == 0)
    	{
    		return posts;
    	}
    
        // 組裝遞迴取得的文章列表
    	var nextPagePosts = await GetPosts(browser, baseUrl, nextPageUrl, remainingPages);
    	return posts.Concat(nextPagePosts);
    }
  • 雖然也可以把詳細的步驟再拆得更細,像是把組裝 Post 的部分拆出去私有方法,或是多加一層迴圈進到文章內抓取照片等等,不過現在我們只需要穩定拿到文章資訊就行
  • 接著再稍微修改一下,外面呼叫方法的部份只需要負責建立 Broswer 和定下第一頁的 Url 就好了
  • async Task Main()
    {
    	var config = AngleSharp.Configuration.Default
    		.WithDefaultLoader()
    		.WithDefaultCookies();
    		
    	var browser = BrowsingContext.New(config);
    
    	var baseUrl = "https://www.ptt.cc";
    	var indexUrl = "/bbs/Beauty/index.html";
    
    	var pages = 10;
    	var posts = await GetPosts(browser, baseUrl, indexUrl, pages);
    }
  • 這樣就大功告成,一次抓它個十頁都沒有問題囉!
  • 最後就可以按照我們的要求來處理 posts 的文章啦,例如:
  • posts.Where(post => post.Push > 90).Dump();
  • 馬上就可以抓出十頁內 90 推以上的文,所以說 Linq 就是方便哪。
  • 到這邊我們已經可以很靈活地去運用這個列表了,像是把撈出來的文章連結搭配
    LineNotify
    做個推播通知啦,還是乾脆掛到排程服務去定時爬資料啦,都是很彈性很自由的了。

小結

  • 最後再一次整理本篇的操作流程:
    • 從 AngleSharp.Configuration.Default 建立組態
    • 使用 BrowsingContext.New(config) 來建立 Browser
    • 根據需求調整組態和 Browser,例如 SetCookie
    • 使用 browser.OpenAsync(url) 把網頁內容抓回來
    • 使用 篩選器 搭配 QuerySelector 等方法,從網頁內容中抓出我們要的資訊
    • 網頁爬完之後可以順手 Close()
    • 自由發揮
  • 步驟其實非常簡單,各位朋友可以出發去動手抓自己想要的網頁囉!
  • 例如說抓一下股票資訊啦、稽查朋友在 PTT 的留言啦,都蠻有趣(?)的呢
  • 總之,當你需要在 C# 能簡單使用的小爬蟲,就是 AngleSharp 出場的時候啦!

參考資料

其他文章

  • 3 person(s) visited this page until now.

Permalink prog/csharp/250306-002/index.txt · Last modified: 2025/03/06 10:24 by jethro

oeffentlich