Scrapowanie danych w języku Perl

Opis projektu

Internet zwykle kojarzy się z przeglądaniem go w postaci renderowanej przez przeglądarkę
z pliku html. Jest to wygodne, jeśli zależy nam na ładnej oprawie i łatwej nawigacji.

Jeśli jednak chcemy przeglądać i analizować dane, wtedy forma strony html może okazać
się nie optymalna i prościej jest pobrać strony html na swój dysk, a następnie przetworzyć
je do bardziej przyjaznego dla dalszej obróbki formatu. Proces ten nazywa się scrapowaniem.

Napiszemy dzisiaj aplikację która pozwala pobierać dane z różych stron, po których można
iterować za pomocą parametru w adresie url i przetwarzać je do postaci plików json.

Wykorzystamy do tego język Perl. Aplikacja będzie składała się z części pobierającej dane
oraz przetwarzającej. Konfiguracja będzie wydzielona do osobnej klasy co pozwoli na łatwe
rozszerzanie zbioru obsługiwanych stron.

Instalacja

Pobieramy repo z gita i przechodzimy do utworzonego katalogu

git clone git@github.com:gustawdaniel/scraper.git && cd scraper

Ładownia konfiguracji

Proces scrapowania można podzielić na dwie fazy: pobranie danych i ich przetworzenie.
W niegkórych wypadkach - kiedy to co pobieramy decyduje o tym co będziemy pobierać

  • powinny one się zazębiać, u nas nie muszą. Za pobieranie danych będzie odpowiedzialny
    plik app.pl, a za analizę json.pl. Pliki z rozszerzeniem pm, to moduły, klasy, lub
    biblioteki, które sami piszemy, ale nie jest to kod wykonywalny aplikacji. Mamy tu Loader.pm
    moduł odpowiedzialny za rozpoznanie parametru przekazanego do app.pl i załadowanie jednej
    z trzech dostępnych konfiguracji z plików *Config.pm.

Ponieważ zarówno dla app.pl jak i json.pl pierwszą czynnością jest właśnie załadowanie
konfiguracji, zaczniemy od omówinia modułów. Aby kod był modułem wymagane jest zadeklarowanie
go zwrotem package:

Loader.pm

use strict;
use warnings FATAL => 'all';

package Loader;

Posiada on jedną metodę - load, która rozpoznaje, czy podano argument określający
rodzaj scrapowaej treści. Mamy do wyoboru rhf - rejestr hurotwni farmaceutycznych,
scpp - Scandinavian-Polish Chamber of Commerce oraz domyślne ra - rejestr aptek.

Nie zajmujmy się teraz pytaniem czym są te instytucje i dlaczego pobieramy ich dane.
Można je potraktować jako przykłady i samemu dopisać tutaj inne. Istotne jest, że
parametr $ARGV[0] jest ciągiem znakowym wpisanym za nazwią programu i w zależności
od niego dociągane są odpowiednie moduły z konfiguracją, na których wykonywana jest
metoda new. Jest to konstruktor obiektu zawierającego konfigurację. Następnie
obiekt otrzymuje swoją nazwę i jest zwracany.

sub load
{
    if(scalar @ARGV && $ARGV[0] eq "rhf") {
        use RhfConfig;
        my $config = RhfConfig->new();
        $config->{name} = "rhf";
        return $config;
    } elsif (scalar @ARGV && $ARGV[0] eq "spcc") {
        use SpccConfig;
        my $config = SpccConfig->new();
        $config->{name} = "spcc";
        return $config;
    } else {
        use RaConfig;
        my $config = RaConfig->new();
        $config->{name} = "ra";
        return $config;
    }
}

Na tym skończył by się kod w większości języków, ale Perl wymaga dopisanie jeszcze
jednej linii:

1;

Jedynka jest tutaj wymagana aby poinformować o sukcesie operacji ładowania modułu.
Ma to sens, jeśli przy inicjalizacji poszło by coś źle. Wtedy zwracając fałsz mogli
byśmy w bardziej czysty sposób zakończyć nasz program.

Jak wspomnieliśmy wcześniej mamy dostępnych kilka konfiguracji. Żeby nie powtarzać
kodu zamykamy je do obiektów, które konfigurowane są przez własności i metody. W
innych językach zastosowali byśmy interfejs. W perlu nie ma wbudowanego mechanizmu
interfejsów, ale można go napisać samemu. Pewnie zrobili byśmy to gdyby to był większy
projekt, ale dla tak prostego przypadku nie warto. Umawiamy się więc, że każda
konfiguracja musi mieć kilka metod i własności, ale może je implementować na swój
własny sposób. Zaczniemy do rejestru aptek:

RaConfig.pm

use strict;
use warnings FATAL => 'all';

package RaConfig;

Po określeniu nazwy paczki utworzymy jej konstruktor. Zastosujemy do tego funkcję
bless, której zadaniem jest zwrócić instancję obiektu tworzonej przez nas klasy.

Pierwszym argumentem konstuktora (którego nie będziemy podawać, a ustawiamy jest
automatycznie w tle) jest sam moduł, na którym wywoływana jest funkcja. Coś jak this,
albo self w innych języakch. Wkładamy to jako drugi argument do funkcji bless za pomocą
funkcji shift, która z tablicy z domyślnym kontekstem czyli właśnie argumentów
new wyciąga pierwszy element. Za pierwszy argument funkcji bless podajemy zbiór
własności obiektu. W tym przypadku limit równy maksymalnemu indeksowi strony, oraz
rows - selektor w którym znajduje się interesująca nas treść. Pozwala on przyśpieszyć
wyszukiwanie bo wszystkie późniejsze zapytania będą ograniczone tylko od obszaru wybranego
przez ten selektor.

sub new { return bless {limit=>100000,rows=>'.pharmacyDetailsControl_Div.controlContainer'}, shift; }

Dla pobierania danych najważniejszą informacją jest adres url z którego można je
uzyskać. Konstruowanie tego adresu na podstawie iterowanego indeksu strony wykonuje
metoda source

sub source { # arg index
    return "http://ra.rejestrymedyczne.csioz.gov.pl/_layouts/15/RA/PharmacyDetailsPublic.aspx?id=".$_[1]."&IsDlg=1";
}

Metoda invalid pozwala nam wyłapać strony, które z jakichś powodów należy ominąć. Podajemy do niej
html, bo respons może mieć kod 200, ale jeśli jest z nim coś nie tak, to ta metoda zapobiegnie dalszemu
przetwarzaniu tego htmla. W tym konkretnym przypadku zwróci ona true, jeśli html ma ciąg znaków zawarty
w wyrażeniu regularnym:

sub invalid { # arg html
    return $_[1] =~ /ctl00_PlaceHolderPageTitleInTitleArea_ErrorPageTitlePanel/;
}

Do przetwarzania kluczowa jest informacja jakie klucze i selektory odpowiadają instancji pobieranych danych.
Tutaj strona jest prostą tabelą, której klucze znajdują się w elementach h3, a wartości w span. Argumentem
metody jest obiekt służący do wyszukiwania w dokumencie html określonych wartości. Za pomocą swoich metod
query zwraca tablicę elementów pasujących do wzorca, a przez as_trimmed_text rzutuje je do ciągów znakowych
wewnątrz tych elementów. W metodzie select kolejno: tworzymy hash - czyli strukturę danych zawierającą
klucze i wartości bez patrzenia na kolejność. Następnie odnosimy się do niej jak do tablicy co pozwala
włożyć tablicę zwracaną przez pierwszy selektor jako klucze, a przez drugi jako wartości. Na koniec zwracamy hasha.

sub select { # arg query
    my %hash;
    @hash{$_[1]->query('.inputArea.full h3')->as_trimmed_text} = $_[1]->query('.inputArea.full span')->as_trimmed_text;
    return %hash;
}

Na koniec tak jak wcześnie zwracamy 1;

1;

Klasa dla rejestru hurtowni farmaceutycznych zostanie zaprezentowana już w całości, ponieważ jest bardzo podobna.

RhfConfig.pm

use strict;
use warnings FATAL => 'all';

package RhfConfig;

sub new { return bless {limit=>100000,rows=>'.pharmacyDetailsControl_Div.controlContainer'}, shift; }

sub source {
    return "http://rhf.rejestrymedyczne.csioz.gov.pl/_layouts/15/RHF/WarehouseDetailsPublic.aspx?id=".$_[1]."&IsDlg=1";
}

sub invalid {
    return $_[1] =~ /ctl00_PlaceHolderPageTitleInTitleArea_ErrorPageTitlePanel/;
}

sub select { # arg query
    my %hash;
    @hash{$_[1]->query('.inputArea.full h3')->as_trimmed_text} = $_[1]->query('.inputArea.full span')->as_trimmed_text;
    return %hash;
}

1;

Trochę inaczej natomiast skonfigurowano Skandynawsko-Polską Izbę Gospodarczą.

SpccConfig.pm

use strict;
use warnings FATAL => 'all';

package SpccConfig;

sub new { return bless {limit=>12,rows=>'td.col-1'}, shift; }

sub source {
    my $link = "https://www.spcc.pl/members/search/all/all/all";
    if($_[1]) { $link .= "?page=".$_[1]; }
    return $link;
}

sub invalid { return 0; }

sub select {
    my $q = $_[1];
    return (
        'name'       => $q->query('.members_search_title')->as_trimmed_text,
        'phone'      => $q->query('.views-field-field-telefon-value')->as_trimmed_text,
        'person'      => $q->query('.views-field-field-kontakt-osoba-value')->as_trimmed_text,
        'email'      => $q->query('.views-field-field-email-email')->as_trimmed_text,
        'www'      => $q->query('.views-field-field-www-url')->as_trimmed_text,
        'branches'     => $q->query('.views-field-phpcode-2')->as_trimmed_text
    )
}

1;

W kodzie widzimy, że za pobieranie danych służą już inne selektory. Nie ma szans, żeby
kod 200 oznaczał błędą stronę oraz źródło pierwszej strony nie zawiera żadnych parametrów
przekazywanych do adresu URL. Poza tym jednak wszystkie funkcji spełniają to samo
zadanie. Dzięki temu w kolejnej części będziemy mogli wykorzystać jeden kod do pobierania
danych z każdego z tych źródeł.

Pobieranie treści

Za samo pobieranie danych odpowiada app.pl. Zaczynamy standordowo od załadowania wymaganych
modułów:

app.pl

#!/usr/bin/env perl
use strict;
use warnings FATAL => 'all';
use LWP::Simple;
use open ':std', ':encoding(UTF-8)';
use Loader;

LWP służy do wysyłania żądań get, Loader to nasz moduł omówiony w poprzednim rozdziale.
Ładujemy konfigurację określoną parametrem za nazwą programu za pomocą linii:

my $config = Loader->load();

Nastawiamy na 0 liczniki sukcesów pobierania s i błędów e.

my $e = 0;
my $s = 0;

Tworzymy katalog raw na pobrane dane i wewnątrz podkatalog odpowiadający skróconej nazwie
naszego źródła danych.

mkdir 'raw', 0755;
mkdir 'raw/'.$config->{name}, 0755;

Ponieważ jest to liniowy bardzo prosty scraper, indeks podawany do metody source
obiektu config obliczamy poprzez iterowanie go o jeden od zera do limitu podanego
w konfiguracji.

for(my $i = 7480; $i<=$config->{limit}; $i++) {

Url wyciągamy za pomocą metody source podając jej właśnie ten index. Funkcja get
z modułu LWP:Simple wysyła request do zadany adres i zwraca ciało odpowiedzi.

    my $html = get $config->source($i);

Jeśli w zwróconej odpowiedzi - czyli kodzie html znajdują się informacje o błędzie
to metoda invalid określona w konfiguracji powinna zwrócić true. Wówczas wyświetli
się czerwony napisa ERROR, a licznik błędów podniesie się. Spowoduje to również
automatyczne przejście do kolejnego indeksu pętli.

    if ($config->invalid($html))
    {
        print "ID:\t" . $i . " - \e[31mERROR\e[0m - [e: ".++$e.", s: $s]\n";
        next;
    }

Jeśli wszystko poszło dobrze, to do pliku którego nazwa to po prostu index pętli
zapisywany jest kod html strony.

    open(my $fh, '>', "raw/".$config->{name}."/".$i.".html") or die "Could not open file: $!";
    print $fh ($html);
    close $fh;

Index numerujący sukcesy podnosi się i zielony komunikat SUCCESS pojawia się an ekranie.

    print "ID:\t" . $i . " - \e[32mSUCCESS\e[0m - [e: $e, s: ".++$s."]\n";
}

Czas wykonywania pobierania zależy od szybkości łącza. U mnie time wykonany na tym programie
dla spcc dał wynik:

real	0m35.027s
user	0m0.456s
sys	0m0.080s

Co pokazuje, ogromny potencjał tkwiący w zrównolegleniu operacji pobierania danych.

Przykład screenu z pobierania danch:

Analiza danych

Do przetworzenia pobranych plkiów html do postaci pliku json służy program json.pl.
Zastanawiałem się nad tym, czy nie włączyć tu sqlite3 albo mongodb, ale zależało mi
na lekkiej, możliwie prostej bazie NoSQL. Niestety sqlite3 nie jest NoSQL, a mondodb
nie jest tak łatwa w instalacji i konfiguracji. Ostatecznie zostałem przy zwykłym pliku
json, lecz należy pamiętać, że to rozwiązanie nie sprawdzi się przy nprawdę dużych
zbiorach danych, gdzie musimy liczyć się ze skończoną ilością pamięci RAM.

Program zaczyna się od ładowani modułów.

json.pl

#!/usr/bin/env perl
use strict;
use warnings FATAL => 'all';

use HTML::Query 'Query';
use JSON;
use Loader;

Pierwszym jest HTML::Query - silnik do parsowania html i wykonywani na nim selektorów.
Moduł JSON pozwala konwertować hashe i tablice do foramtu json. Loader poznaliśmy
już i widzieliśmy go w akcji. Odpowiada on za ładownie konfiguracji. Obok konfiguracji
drugą zmienną globalną w tym programie jest tablica instancji obieków reprezentujących
pobrane dane - firmy, apteki lub hurtownie.

my $config = Loader->load();
my @instances = ();

Ponownie robimy przebieg po wszystkich indeksach.

for(my $i = 0; $i<=$config->{limit}; $i++) {

Tym razem za opuszczenie pętli odpowiada sprawdzenie czy plik istnieje, jeśli nie,
to przechodzimy do kolejnego przebiegu

    if (! -f 'raw/'.$config->{name}."/".$i.".html") { next; }

Jeśli plik istnieje, to ładujemy go do obiektu Query, na którym będziemy wykonywać
selektory.

    my $q = Query( file => 'raw/'.$config->{name}."/".$i.".html" );

Okazja do ich użycia nadarza się dość szybko. Pierwszy raz korzystamy z selektora
określonego w konstruktorze obiektu config we własności rows. Nim wycinamy
obszar w którym są interesujące dane. Może się okazać, że jest więcej takich obszarów.

Na przykład apteki mają układ z jedną apteką na stronie, a spcc ma wiele firm na
jednym widoku. Niezależnie od tego wszystkie obszary odpowiadają pojedyńczym instancjom
wyszukiwanego obiektu.

    my @rows = $q->query($config->{rows})->get_elements(); #

Nie ważne, czy instancja jest jedna, czy jest ich kilka iterujemy po nich:

    foreach my $row (@rows)
    {

Wewnąrz pęli przesłaniamy nasze query przez query obcięte tylko do obszaru
danej instancji.

        $q = Query( tree => $row );

Tak ustawiony selektor przekazujemy do metody select obiektu config.

        my %object = $config->select($q);

W metodzie select leżą szczegóły dotyczące tego jak parsować daną instancję obiektu.
Tu nie musimy się tym przejmować. Istostne jest, że to co otrzymamy będzie obiektem
typu hash, który następnie dołączamy do tablicy instances.

        push @instances, \%object;
    }
}

Kiedy pętla zakończy się. Tablica instances zostaje przekazana do obiektu kodującego
ją do formatu json. Z powodu polskich znaków obiekt dostaje po drodze konfigurację
nakazującą mu korzystać z utf-8.

print JSON->new->utf8(0)->encode(
    {
        'instances'=> \@instances
    }
);

Przetworzenie danych dla spcc zajmuje nie całe trzy sekundy, tym razem przy pełnym
obciążenieu procesora.

real	0m2.772s
user	0m2.768s
sys	0m0.000s

Screen z widokiem przetworzonych danych

Podsumowanie

Program był pisany około pół roku temu. Teraz przed publikacją standardowo odrobinę go
dopracowałem. Zastosowano w nim staroszkolną metodę oobsługi obiektów w Perlu. Warto
wspomnieć, że istnieją w nim również biblioteki jak Moose, albo Moo które wprowadzają obiekty dodając do
nich trochę tzw. "cukru składniowego". Jednak znacznie ciekawsze jest to, że dokładnie
dwa tygodnie temu - 24 lipca wyszła stabilna wersja interpretera szóstej wersji języka
Perl. Wprowadza ona obiektowość jako część natywnej składni języka. Zapewnia przy tym
lepsze typowanie, czyli łata chyba ten główny brak Perla 5, przez który trudno
było pisać w nim bezpiecznie. Być może oznacza to, że perl 6 powróci do wyższych poziomów
popularności.