[DajSięPoznać#5] F# WebCrawler + Privoxy

Wstęp

Poprzednio pisałem o wizualizacji danych, tym razem o tym, jak te dane pozyskać. Po lupę zostały wzięty trzy duże serwisy z ogłoszeniami o nieruchomościach: gumtree, morizon i olx. Celem będzie pobranie na dysk dużej ilości ogłoszeń.

Analiza stron

Żeby napisać crawlera, najpierw trzeba trochę poklikać, pooglądać requesty w Fiddlerze i zbadać, jak dane są prezentowane na stronach gdy wejdziemy tam z przeglądarki.

Na każdym z wymienionych portali lista ogłoszeń po wybraniu kryteriów (Kraków, sprzedaż) jest paginowana, stąd w kodzie utworzone zostały sekwencje (odpowiednik IEnumerable<T> z C#) dla każdej strony. Przykładowo dla gumtree generowanie linków do kolejnych stron z listami ogłoszeń wygląda tak:

let gumtreeAdverts = 
    seq {
        let urlBase = "http://www.gumtree.pl/s-mieszkania-i-domy-sprzedam-i-kupie/krakow/mieszkanie/"
        let checksum = "v1c9073l3200208a1dwp"
        yield urlBase + checksum + "1"

        let createUrl = fun i -> String.Format("{0}page-{1}/{2}{1}", urlBase, i, checksum)

        for page in 2 .. 200 do
            yield createUrl(page)
    }

 

Dla każdego takiego adresu trzeba będzie pobrać HTML i sparsować go, wyciągając z niego listę linków do konkretnych ogłoszeń. Do tego celu służą parsery, przykładowo dla olx wygląda tak:

let parseOlx(streamAsync:Async<Stream>) = 
    async{
        let! stream = streamAsync
        let html = HtmlDocument.Load(stream)
        return html.Descendants["table"]
            |> Seq.filter(fun t -> t.HasAttribute("id", "offers_table"))
            |> Seq.head
            |> fun table -> table.Descendants["a"]
            |> Seq.map(fun a -> a.AttributeValue("href"))
            |> Seq.map(fun link -> (link.Split[|'#'|]).[0])
            |> Seq.filter(fun link -> link.StartsWith("http://olx.pl/oferta/"))
            |> Seq.distinct
    }

Do chodzenia po drzewie DOM używam FSharp.Data, megapotężnej biblioteki związanej z type providers, ale o nich innym razem. Parser wycina atrybuty href, wybiera z nich URL do znaku # (hashmarka).

Funkcja ta jest przykładem użycia Asynchronous Workflow i pokazuje, jak przyjemnie pisze się asynchroniczny kod w F#. Operator let! (let bang) mówiąc językiem C# “awaituje” asynchroniczną operację, tj zawiesza wątek do jej wykonania. Dodatkowo całość wstawiamy do bloku async.

Funkcja taka jest w prosty sposób testowalna:

    [<Fact>]
    member x.Check_Olx_Adverts_Page_Parser() =
        //Arrange
        let url = Seq.head(olxAdverts)
        let expPrefix = "http://olx.pl/oferta/"
        let expSuffix = ".html"

        //Act
        let stream = downloadHtmlAsync(url)
        let links = Async.RunSynchronously(parseOlx(stream))

        //Assert
        let areLinksCorrect = links 
                              |> Seq.forall(fun d -> d.StartsWith(expPrefix) && d.EndsWith(expSuffix))       
        Assert.True(areLinksCorrect)

 

Typ Async udostępnia wiele pomocnych metod w pracy z asynchronicznym kodem, przykładowo użyte tu Async.RunSynchronously przywróci synchroniczny flow wykonywania kodu.

Algorytm Web Crawlera

Algorytm działania crawlera będzie następujący:

  1. Dla każdego portalu wygeneruj sekwencję linków do stron z listą ogłoszeń
  2. Pobierz równolegle HTML-e z każdej strony z sekwencji
  3. Parsuj każdy HTML wyciągając z niego listę adresów URL do poszczególnych ogłoszeń
  4. Wylicz MD5 z każdegu URL i posortuj je według tych skrótów po to, by w miarę równolegle rozłożyć obciążenie portali requestami
  5. Pobierz równolegle HTML każdego ogłoszenia i zapisz go na dysku

Czyli inaczej mówiąc (językiem funkcyjnym):

let crawl pages = 
    async{
        let sites = [(olxAdverts |> Seq.take(pages), parseOlx); 
                    (morizonAdverts |> Seq.take(pages), parseMorizon); 
                    (gumtreeAdverts |> Seq.take(pages), parseGumtree)]
        
        sites 
            |> List.map(fun (links, parser) -> 
                (links |> Seq.map(fun link-> downloadHtmlAsync(link) |> parser) |> Async.Parallel)
                |> Async.RunSynchronously)
            |> Seq.collect(fun d-> (d |> Seq.concat))
            |> Seq.map(fun link ->(link, md5(link)))
            |> Seq.sortBy(fun tuple -> snd(tuple))
            |> Seq.map(fun tuple -> (downloadHtmlAsync(fst(tuple)), snd(tuple)))
            |> Seq.map(fun (stream, md5) -> saveFileToDisk(stream, md5))
            |> Async.Parallel
            |> Async.RunSynchronously
            |> ignore
    }

 

Instrukcja Seq.collect(fun d->(d |> Seq.concat)) transformuje zagnieżdżone sekewncje (seq<seq<string>>) do pojedynczej sekwencji stringów (przez konkatenację).

TOR i Privoxy

Podczas pisania kodu pojawił się pomysł by requesty przepuszczać przez jakieś proxy anonimizujące ruch. Anonimowość ? Nie zaszkodzi, ale przede wszystkim chodzi też o to, by pobierać dane w sposób “humanitarny” i dodawać losowe opóźnienia do żądań HTTP. Dobrym narzędziem będzie TOR. Wykorzystuje on tzw. trasowanie cebulowe, czyli wielokrotne szyfrowanie wiadomości pomiędzy kolejnymi routerami sieci (stąd analogia do cebuli i jej budowy – kolejne warstwy to kolejne procesy szyfrowania wiadomości).

Samo pobranie TOR-a daje nam przeglądarkę internetową, ale nie tworzy jeszcze Proxy HTTP na localhoscie, ponieważ używa on protokołu SOCKS5, który działa na warstwie TCP/IP.  Dlatego potrzebne będzie drugie narzędzie – Privoxy. Jego zadaniem będzie stworzenie Proxy HTTP na localhoscie i forwardowanie requestów przez TOR-a. W pliku konfiguracyjnym Privoxy należy dodać linijkę

forward-socks5t   /               127.0.0.1:9150 .

Port 9150 to ten, na którym działa TOR (można sprawdzić przez netstat -a -b)

W kodzie metoda pobierająca HTML-e wygląda następująco:

let downloadHtmlAsync(url:string) =
    async {
            let req = WebRequest.Create(url)
            if useTor then
                req.Proxy <- new WebProxy("127.0.0.1:8118"); 
            req.Timeout <- 2000
            let! rsp = req.AsyncGetResponse()          
            return rsp.GetResponseStream()
          }

W praktyce to działa, ale jest jeden spory mankament – bez używania TOR-a i Privoxy crawling 28 ogłoszeń (razem z requestem po listę i zapisywaniem ich na dysk) trwał 7,7 sekundy, natomiast z nim ponad 75 sekund, stąd konieczność zwiększenie Timeout-u.

Advertisements

One thought on “[DajSięPoznać#5] F# WebCrawler + Privoxy

  1. Pingback: [DajSięPoznać] Podsumowanie | When the smoke is going down

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s