Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

Html Agility Pack - masowe wyciąganie danych ze stron WWW

Niedawno potrzebowałem pozyskać zawartość pewnej bazy danych. Niestety była ona upubliczniona jedynie w formie serwisu WWW prezentującego 50 rekordów na pojedynczej stronie. Cała baza miała ponad 150 tysięcy rekordów. Co zrobić w takiej sytuacji? Przeklikiwać się przez 3000 stron, ręcznie gromadząc dane w pliku tekstowym? Tydzień i po sprawie! ;) Lepiej jednak napisać automat (tzw. scraper), który zrobi to za Ciebie. Automat musi zrobić trzy rzeczy:

  • wygenerować listę adresów stron, z których pobierane będą dane;
  • odwiedzać kolejno strony i wyciągać odpowiednie informacje z ich kodu HTML;
  • zrzucać dane do lokalnej bazy i logować postęp prac.

Generowanie adresów powinno być proste. W większości serwisów stronicowanie jest zrobione za pomocą zwykłych lików, w których numer strony jest łatwo widoczny w głównej części URL (http://example.com/somedb/page/1) lub w query stringu (http://example.com/somedb?page=1). Jeśli paginacja jest ajaxowa sprawa nieco się kompiluje, nie martwmy się tym jednak w tym poście... Gdy poznasz wzorzec przekazywania parametru z numerem strony, do stworzenia listy adresów wystarczy zwykła pętla z czymś w stylu:

string url = string.Format("http://example.com/somedb?page={0}", pageNumber)

Teraz pora na coś ciekawszego. W jaki sposób wyciągnąć dane ze strony WWW? Można użyć klas WebRequest/WebResponse lub WebClient z przestrzeni nazw System.Net do pobrania zawartości strony, po czym pozyskiwać informacje za pomocą wyrażeń regularnych. Można też spróbować potraktować pobraną treść jako XML i badać ją za pomocą XPath lub LINQ to XML. Nie są to jednak dobre podejścia. Przy skomplikowanej strukturze strony napisanie prawidłowego wyrażenia może być trudne, trzeba też pamiętać, że w większości przypadków strony internetowe nie są prawidłowymi dokumentami XML. Na szczęście powstała biblioteka Html Agility Pack, która umożliwia łatwe parsowanie stron HTML. Nawet tych, których kod nie przeszedłby walidacji bo np. nie zawiera poprawnych znaczników zamykających. HAP procesuje treść strony i buduje obiektowy model dokumentu, który można przetwarzać za pomocą LINQ to Objects lub XPath.

Aby zacząć pracę z HAP użyj NuGet by zainstalować pakiet HtmlAgilityPack (ja używałem wersji 1.4.6) po czym zaimportuj namespace o tej samej nazwie. Jeśli nie chcesz korzystać z NuGet (dlaczego?) pobierz plik zip ze strony projektu i dodaj referencje do pliku HtmlAgilityPack.dll stosownego dla Twojej platformy (zip zawiera np. wersje dla .NET 4.5 i Silverlight 5). Pomocna będzie też dokumentacja w pliku .chm. Uwaga! Gdy otworzyłem pobrany plik (w Windows 7), dokumentacja wyglądała na pustą. Pomogło użycie opcji „Odblokuj” z menu właściwości pliku.

Pobranie treści strony za pomocą HAP jest bardzo proste. Należy utworzyć obiekt klasy HtmlWeb po czym użyć metody Load podając adres strony:

HtmlWeb htmlWeb = new HtmlWeb();
HtmlDocument htmlDocument = htmlWeb.Load("http://en.wikipedia.org/wiki/Paintball");

W odpowiedzi otrzymamy obiekt klasy HtmlDocument, która stanowi centrum biblioteki HAP.

HtmlWeb zawiera właściwości, które mają wpływ na sposób pobierania dokumentu z sieci. Można np. wskazać czy ma być użyty mechanizm ciasteczek (UseCookies) oraz ustalić nagłówek User Agent dołączany do żądania HTTP (UserAgent). Mnie szczególnie przydały się właściwości AutoDetectEncoding i OverrideEncoding, dzięki którym mogłem poprawnie odczytać dokument z polskimi znakami:

HtmlWeb htmlWeb = new HtmlWeb() { AutoDetectEncoding = false, OverrideEncoding = Encoding.GetEncoding("iso-8859-2") };

Inna bardzo przydatną właściwością HttpWeb jest StatusCode (typu System.Net.HttpStatusCode). Informuje ona o rezultacie obsługi ostatniego żądania.

Mając obiekt HtmlDocument możemy przystąpić do wyciągania danych. Oto przykład jak wyciągnąć adresy i teksty linków z pobranej wcześniej strony (dodaj using System.Linq):

IEnumerable<HtmlNode> links = htmlDocument.DocumentNode.Descendants("a").Where(x => x.Attributes.Contains("href");
foreach (var link in links)
{
    Console.WriteLine(string.Format("Link href={0}, link text={1}", link.Attributes["href"].Value, link.InnerText));       
}

Właściwość DocumentNode typu HtmlNode wskazuje na root strony. Metodą Descendants pobieramy wszystkie linki (tag a) zawierające atrybut href, po czym wypisujemy adres linku i jego tekst na konsole. Prawda, że proste? Kilka innych przykładów:

Pobranie całego kodu HTML strony:

string html = htmlDocument.DocumentNode.OuterHtml;

Pobranie elementu o id footer”:

HtmlNode footer = htmlDocument.DocumentNode.Descendants().SingleOrDefault(x => x.Id == "footer");

Pobranie dzieci div o id „toc” i wyświetlenie nazw tych, których typ jest różny od Text:

IEnumerable<HtmlNode> tocChildren = htmlDocument.DocumentNode.Descendants().Single(x => x.Id == "toc").ChildNodes;
foreach (HtmlNode child in tocChildren)
{
    if (child.NodeType != HtmlNodeType.Text)
    {
        Console.WriteLine(child.Name);
    }
}

Pobranie elementów listy (tag li) mających klasę toclevel-1:

IEnumerable<HtmlNode> tocLiLevel1 = htmlDocument.DocumentNode.Descendants()
    .Where(x => x.Name == "li" && x.Attributes.Contains("class")
    && x.Attributes["class"].Value.Split().Contains("toclevel-1"));

Zwróć uwagę, że filtr Where jest dość złożony. Prosty warunek:

Where(x => x.Name == "li" && x.Attributes["class"].Value == "toclevel-1")

nie jest poprawny! Po pierwsze nie ma gwarancji, że każdy tag li ma atrybut class, więc mógłby się pojawić NullReferenceException. Po drugie sprawdzenie istnienia klasy toclevel-1 jest wadliwe. Element HTML może mieć wiele klas, dlatego zamiast == warto użyć Contains(). Samo Value.Contains to jednak za mało. Co jeśli szukamy klasy o nazwie „sec”, a element ma klasę „secret”? Taki element też zostanie dopasowany! Zamiast Value.Contains należy użyć Value.Split().Contains. Wówczas przeszukujemy nie string, a tablicę stringów (przy użyciu operatora równości).

Pobranie tekstów wszystkich elementów li, które zagnieżdżone są w co najmniej jedynym elemencie li:

var h1Texts = from node in htmlDocument.DocumentNode.Descendants()
              where node.Name == "li" && node.Ancestors("li").Count() > 0
              select node.InnerText;

Oprócz LINQ to Objects do wyciągania informacji można użyć również zapytań XPath. Przykładowo:

Pobranie tagów a, których wartość atrybutu href zaczyna się od # i jest dłuższa niż 15 znaków:

IEnumerable<HtmlNode> links = htmlDocument.DocumentNode.SelectNodes("//a[starts-with(@href, '#') and string-length(@href) > 15]"));

Znalezienie elementów li w div o idtoc”, które są trzecim dzieckiem w swoim elemencie zawierającym:

IEnumerable<HtmlNode> listItems = htmlDocument.DocumentNode.SelectNodes("//div[@id='toc']//li[3]");

XPath to skomplikowane narzędzie o ogromnych możliwościach, ja poprzestanę na tych dwóch przykładach...

HAP pozwala nie tylko na badanie struktury i treści strony, umożliwia też jej modyfikację i zapis. Istnieją metody pomocnicze m. in. do wykrycia kodowania (DetectEncoding) lub usunięcia encji HTML (DeEntitize). Można również pozyskać informacje o tym czy oryginalny dokument posiadał odpowiednio zamknięte tagi itp. Te tematy wykraczają jednak poza zakres postu.

W miarę pobierania informacji z kolejnych stron zrzucaj je do bazy, z której będziesz mógł wygodnie skorzystać (może wystarczy Ci plik .csv, może potrzebna będzie baza SQL - mnie wystarczył zwykły plik tekstowy).

Ostatnią rzeczą, która należy zrobić jest zadbanie o to by scraper logował informacje o postępie prac (chciałbyś przecież wiedzieć jak daleko zaszedł Twój automat i czy coś nie poszło źle). Do logowania najlepiej jest użyć wyspecjalizowanej biblioteki takiej jak np. log4net. W necie jest mnóstwo instrukcji o tym jak z niej korzystać, nie będę wiec o tym pisał. Dodam za to przykładową konfiguracje, której możesz użyć w aplikacji konsolowej:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <configSections>
        <section name="log4net" type="log4net.Config.Log4NetConfigurationSectionHandler, log4net"/>          
    </configSections>
    <log4net>        
        <root>
            <level value="DEBUG"/>            
            <appender-ref ref="ConsoleAppender" />
            <appender-ref ref="RollingFileAppender"/>
        </root>
        <appender name="ConsoleAppender" type="log4net.Appender.ColoredConsoleAppender">
            <layout type="log4net.Layout.PatternLayout">
                <conversionPattern value="%date{ISO8601} %level [%thread] %logger - %message%newline" />
            </layout>
            <mapping>
                <level value="ERROR" />
                <foreColor value="White" />
                <backColor value="Red" />
            </mapping>
            <filter type="log4net.Filter.LevelRangeFilter">
                <levelMin value="INFO" />                
            </filter>
        </appender>         
        <appender name="RollingFileAppender" type="log4net.Appender.RollingFileAppender">
            <file value="Log.txt" />
            <appendToFile value="true" />
            <rollingStyle value="Size" />
            <maxSizeRollBackups value="10" />
            <maximumFileSize value="50MB" />
            <staticLogFileName value="true" />
            <layout type="log4net.Layout.PatternLayout">
                <conversionPattern value="%date{ISO8601} %level [%thread] %logger - %message%newline%exception" />
            </layout>
        </appender>
    </log4net>    
</configuration>

Posiada ona dwa appendery: ConsoleAppender i RollingFileAppender. Pierwszy z nich loguje tekst do okna konsoli, dbając o to by błędy wyraźnie wyróżniały się kolorem. By ograniczyć ilość informacji ustawiony jest LevelRangeFilter dzięki czemu prezentowane są tylko wpisy o poziomie INFO lub wyższym.

Drugi appender loguje do pliku tekstowego (trafiają tam nawet wpisy o poziomie DEBUG). Maksymalny rozmiar pojedynczego pliku ustalony jest na 50MB, a poduszczana ilość plików archiwalnych to 10. Bieżący log znajduje się zawsze w pliku Log.txt...

I to wszystko, scraper jest gotowy! Odpal go i niech tyra za Ciebie. Wielogodzinną, monotonną robotę zostaw tym, którzy nie potrafią programować! :)

Możesz jeszcze wykonać małe ćwiczenie: zamiast tworzyć listę wszystkich stron do odwiedzenie, ustal jedynie pierwszą stronę po czym spróbuj znaleźć link do następnej strony w kodzie bieżącej.

P.S. Pamiętaj o tym, że HAP działa na kodzie HTML, który został nadesłany przez serwer i to na bazie tego kodu buduje model dokumentu. DOM, który obserwujesz w narzędziach deweloperskich swojej przeglądarki często jest wynikiem działania skryptów i może znacznie różnić się od tego, który wynikałby z odpowiedzi HTTP.

Aktualizacja 08.12.2013: Zgodnie z prośbą stworzyłem proste demo (w Visual Studio 2010) użycia Html Agility Pack i log4net. Aplikacja wyciąga linki ze strony wiki i zapisuje je w pliku tekstowym. Strona wiki zrzucona jest do pliku htm by uniknąć zależności od strony w sieci, która może się zmienić. Pobierz.