Przykład wykorzystania PHP do parsowania kodu HTML

Lubię wykorzystywać napisane przeze mnie narzędzia do przeprowadzania różnych analiz. Przeważnie dane dla takich narzędzi udaje się pozyskać w formie np. plików CSV ale niestety czasem zdarzy się, iż dostępna będzie wyłącznie strona gdzie interesujące nas dane zostaną przedstawione w formie kodu HTML (o ile dopisze nam szczęście 😉 ). W tym wpisie zaprezentuję sposób na poradzenie sobie z takim „problemem” przy pomocy języka PHP.

Wiem, że najbardziej interesujące byłoby pokazanie rozwiązania na jakimś prawdziwym portalu ale dla celów edukacyjnych przygotujemy sobie własny kod (wklejanie tutaj sporej ilości kodu nie ma sensu podobnie jak odwoływanie się do jakiejkolwiek strony, która za godzinę może już wyglądać inaczej). Nasz przykładowy kod będzie dotyczył przykładowej listy ofert wraz ze statystyką cen za jakis okres czasu. Mówiąc inaczej chciałem aby w przykładzie znalazł się kod HTML z dwiema różnymi tabelami (różnymi w sensie zarówno struktury jak i samych danych).

<?php

$html = '
<!doctype html>
<html>
    <head>
        <title>Testowy kod HTML</title>
    </head>
    <body>
        <h1>Aktualny lista ofert</h1>
        <table>
            <tr>
                <th>#</th>
                <th>Nazwa</th>
                <th>Cena</th>
            </tr>
            <tr>
                <td>1</td>
                <td>AAAA</td>
                <td>0.45</td>
            </tr>
            <tr>
                <td>2</td>
                <td>BBBB</td>
                <td>0.73</td>
            </tr>
            <tr>
                <td>3</td>
                <td>CCCC</td>
                <td>1.02</td>
            </tr>
        </table>
        <hr>
        <h1>Statystyka cen za ostatni rok</h1>
        <table>
            <tr>
                <th>#</th>
                <th>Nazwa</th>
                <th>Cena Min</th>
                <th>Cena Max</th>
            </tr>
            <tr>
                <td>1</td>
                <td>AAAA</td>
                <td>0.22</td>
                <td>0.63</td>
            </tr>
            <tr>
                <td>2</td>
                <td>BBBB</td>
                <td>0.35</td>
                <td>1.34</td>
            </tr>
            <tr>
                <td>3</td>
                <td>CCCC</td>
                <td>1.00</td>
                <td>2.37</td>
            </tr>
        </table>
    </body>
</html>';

Kod HTML interesującej nas strony możemy pozyskać na kilka sposobów (np. z wykorzystaniem funkcji file_get_contents) ale tym nie będziemy się tutaj zajmować – ważne jest tylko aby docelowo mieć ten kod HTML zapisany w jakiejś zmiennej. Przyjmijmy teraz, iż naszym zadaniem będzie przygotowanie kolekcji obiektów zawierających komplet aktualnych informacji o produktach (czyli nazwę, aktualną cenę oraz ceny minamlną i maksymalną).

Kiedy zabierałem się do rozwiązywania podobnego problemu poraz pierwszy odruchowo sięgnąłem po wyrażenia regularne. Nie powiem – napisany kod działał poprawnie chociaż był dosyć wrażliwy na zmiany w strukturze kodu co czasem wymagało pisania aktualizacji. Mówiąc inaczej – rozwiązanie operte o wyrażenia regularne było na tyle toporne, iż porzuciłem je od razu po znalezieniu czegoś lepszego. Mam nadzieję, że wybaczycie mi ale nawet nie zamierzam przywoływać „regularnych demonów” w tym wpisie choćby tylko dla pokazania, że „się da”. Tylko nie zrozumcie mnie proszę źle – nie mam nic do wyrażeń regularnych jako takich. Są bardzo przydatne i wcale od nich nie stronię. Po prostu uważam, iż nie nadają się do efektywnego parsowania kodu HTML. W zamian proponuję wykorzystanie klasy DOMDocument dostępnej w języku PHP w wersjach zarówno 5 jak i 7.

Zacznijmy od załadowania naszego kodu HTML do nowoutworzonego obiektu klasy DOMDocument.

$dom = new \DOMDocument();
$dom->loadHtml($html);

Gdyby ktoś zastanawiał się dlaczego zapisuję nazwę klasy z backslashem to wyjaśniam, iż bardzo żadko zdarza mi się pisać kod w PHP gdzie nie ma określonych przestrzeni nazw. Wyrobiłem więc u siebie nawyk wskazywania klas wraz ze wskazaniem ich przestrzeni nazw (oczywiście uwzględniając klauzule USE).

Wróćmy teraz do naszego kodu. Po załadowaniu treści HTML do obiektu, gdyby ktoś wyświetlił sobie rezultat, wszystko wydaje się być ok i tak faktycznie jest w naszym przypadku. Nie oznacza to jednak, iż tak będzie zawsze. Czasem zdarzy się, iż pobrany przez nas kod HTML nie jest do końca poprawny i wtedy metoda ładująca treść HTML będzie sygnalizować problemy. Aby sobie z tym poradzić możemy wykorzystać funkcję libxml_use_internal_errors w następujący sposób.

$dom = new \DOMDocument();
//Ustawiamy wartość i zapamiętujemy poprzednią
$internalErrors = libxml_use_internal_errors(true);
$dom->loadHtml($html);
//Odtwarzamy poprzednią wartość
libxml_use_internal_errors($internalErrors);

Kiedy już mamy załadowany kod HTML do obiektu możemy zacząć na nim działać. Ponieważ jednak chcemy zapisywać nasze wyniki jako kolekcję obiektów to zdefiniujmy sobie najpierw klasę, do przechowywania naszych danych. Patrząc na nasz kod HTML możemy zauważyć, że nasza klasa powinna mieć co najmniej cztery pola do przechowywania informacji o nazwie, cenie, cenie minimalnej oraz cenie maksymalnej. Nasza klasa może wyglądać np. tak

class walor
{
    private $nazwa;
    private $cena;
    private $cenaMin;
    private $cenaMax;
    
    public function getNazwa()
    {
        return $this->nazwa;
    }
    
    public function getCena()
    {
        return $this->cena;
    }
    
    public function getCenaMin()
    {
        return $this->cenaMin;
    }
    
    public function getCenaMax()
    {
        return $this->cenaMax;
    }
    
    public function setNazwa($nazwa)
    {
        $this->nazwa = $nazwa;
    }
    
    public function setCena($cena)
    {
        $this->cena = $cena;
    }
    
    public function setCenaMin($cenaMin)
    {
        $this->cenaMin = $cenaMin;
    }
    
    public function setCenaMax($cenaMax)
    {
        $this->cenaMax = $cenaMax;
    }
}

Ktoś mógłby zapytać czemu nie napisałem w tej klasie konstruktora. Przecież encję łatwiej jest utworzyć od razu z zainicjowanymi wartościami. Pragnę jednak zwrócić uwagę na fakt, iż z kodu HTML nie wynika wprost, że „da się” utworzyć obiekt od razu ze wszystkimi wartościami. Interesujące nas dane są zawarte w dwóch różnych miejscach a jeszcze nie wiemy w jaki sposób będziemy się do nich odwoływać. Przy okazji warto również zwrócić uwagę na fakt, iż jeśli będziemy się posługiwać kolekcją a nie zamierzamy tworzyć obiektów od razu ze wszystkimi wartościami, to warto pomyśleć o takim sposobie zapisu tej kolekcji aby w miarę szybko odnajdywać interesujący nas obiekt w celu uzupełnienia przechowywanych w nim danych. Najbardziej naturalnym wyborem będzie tutaj pole nazwy i dlatego też elementy naszej kolekcji będą indeksowane właśnie wartością tego pola.

Jedną z podstawowych metod udostępnianych przez klasę DOMDocument, z której będziemy tutaj korzystać jest getElementsByTagName, która zwraca nam kolekcję tagów o podanej nazwie wraz z ich strukturą (mówiąc inaczej – pozwala nam zwrócić wybrane fragmenty struktury HTML). W naszym kodzie znajdują się dwie tabele, które nas interesują, aby je „pobrać” możemy posłużyć się następującym kodem

$tabele = $dom->getElementsByTagName('table');
$tabOferty = $tabele->item(0);
$tabStats = $tabele->item(1);

Powyższy fragment kodu możemy oczywiście rozbudować o sprawdzanie czy np. w strukturze znajdują się co najmniej dwie tabele. Możemy w tym celu wykorzystać zarówno fakt, iż metoda item zwróci wartość NULL dla niepoprawnego indeksu, jak i właściwość (nie metodę) length klasy DOMNodeList, której obiekt jest zwracany przez metodę getElementsByTagName. Na potrzeby naszego przykładu takiego sprawdzania wykonywać jednak nie będziemy.

Zgodnie z moim doświadczeniem oraz wpisami znalezionymi w sieci (np. tutaj) mogę powiedzieć, iż kolejność elementów w tabeli jest związana z kolejnością pojawiania się elementów w kodzie HTML (gdyby ktoś znał przypadek temu przeczący to proszę o informację w komentarzu). Stąd też wynika przypisanie poszczególnych tabel do odpowiednich zmiennych.

Spróbujmy teraz przetworzyć pierwszą z tabel. Podstawowym faktem jest to, iż zawiera ona „ileś” (udajmy, że nie pamiętamy) wierszy z czego pierwszy zawiera nagłówki. Każdy z tych wierszy (poza pierwszym) składa się z kolekcji elementów TD (ułożonych w kolejności zgodnej z nagłówkami). Spróbujmy zatem napisać kod przechodzący po strukturze tabeli.

//Najpierw tworzymy naszą wynikową kolekcję.
$kolekcja = [];

//Odczytujemy kolekcję elementów TR z tabeli ofert
$trsOfert = $tabOferty->getElementsByTagName('tr');
//dzięki rozpoczęciu iteracji od 1 ignorujemy wiersz z nagłówkami
for ($i = 1; $i < $trsOfert->length; $i++) {
    $tdsOferty = $trsOfert->item($i)->getElementsByTagName('td');
    
    //W tym miejscu mamy już bieżący wiersz oraz kolekcję pól.
    //Możemy zabrać się za tworzenie obiektów kolekcji.
    $element = new \Walor();
    $element->setNazwa($tdsOferty->item(1)->textContent);
    $element->setCena($tdsOferty->item(2)->textContent);
    
    //Zapamiętujemy nasz obiekt w kolekcji
    $kolekcja[$element->getNazwa()] = $element;
}

Gdybyśmy nie znali kolejności kolumn ale znalibyśmy ich nazwy wtedy na podstawie pierwszego wiersza (zawierającego nagłówki), moglibyśmy określić sobie automatycznie kolejność pól aby wiedzieć w jaki sposób „zmapować” dane z tabeli do obiektu. Nasz kod w takiej sytuacji mógłby wyglądać następująco.

//Najpierw tworzymy naszą wynikową kolekcję.
$kolekcja = [];

//Odczytujemy kolekcję elementów TR z tabeli ofert
$trsOfert = $tabOferty->getElementsByTagName('tr');

//Odczytujemy pierwszy wiersz z nagłówkami kolumn
$thOfert = $trsOfert->item(0)->getElementsByTagName('th');

//Tworzymy tabelę mapowania
$mapowanie = [];

for ($i = 0; $i < $thOfert->length; $i++) {
    switch ($thOfert->item($i)->textContent) {
        case 'Nazwa':
            $setter = 'setNazwa';
            break;
            
        case 'Cena':
            $setter = 'setCena';
            break;
            
        default:
            $setter = null;
    }
    
    //W tabeli mapowania zapisujemy tylko te kolumny, które chcemy mapować
    if (!empty($setter)) {
        //W tabeli mapowania zapisujemy nazwę metody w obiekcie po indeksem kolumny
        $mapowanie[$i] = $setter;
    }
}

//Przetwarzamy wiersze z danymi
for ($i = 1; $i < $trsOfert->length; $i++) {
    $tdsOferty = $trsOfert->item($i)->getElementsByTagName('td');
    
    //W tym miejscu mamy już bieżący wiersz oraz kolekcję pól.
    //Możemy zabrać się za tworzenie obiektów kolekcji.
    $element = new \Walor();
    
    //Ignorujemy pierwszą kolumnę ponieważ numer wiersza nas nie interesuje
    for ($t = 1; $t < $tdsOferty->length; $t++) {
        if (array_key_exists($t, $mapowanie)) {
            $setter = $mapowanie[$t];
            
            //Sprawdzamy czy nasz obiekt posiada metodę setter
            if (method_exists($element, $setter)) {
                $element->$setter($tdsOferty->item($t)->textContent);
            }
        }
    }
    
    //Zapamiętujemy nasz obiekt w kolekcji
    $kolekcja[$element->getNazwa()] = $element;
}  

Ponieważ jednak znamy kolejność kolumn dlatego w naszym przypadku nie będzie specjalnie rozbudowywać kodu rozwiązania. Podobnie możemy postąpić z tabelą statystyk.

//Odczytujemy kolekcję elementów TR z tabeli statystyk
$trsStats = $tabStats->getElementsByTagName('tr');
//dzięki rozpoczęciu iteracji od 1 ignorujemy wiersz z nagłówkami
for ($i = 1; $i < $trsStats->length; $i++) {
    $tdsStats = $trsStats->item($i)->getElementsByTagName('td');
    
    $nazwa = $tdsStats->item(1)->textContent;
    if (array_key_exists($nazwa, $kolekcja)) {
        $kolekcja[$nazwa]->setCenaMin($tdsStats->item(2)->textContent);
        $kolekcja[$nazwa]->setCenaMax($tdsStats->item(3)->textContent);
    }
}

Gdyby pojawił się przypadek, iż w tabeli statystyk jest opisany walor, którego nie ma w tabeli ofert to możemy postąpić dwojako. Albo tak jak w kodzie powyżej możemy zignorować taki wpis (gdybyśmy chcieli mieć w kolekcji tylko walory obecne w tabeli ofert) albo dodać mimo wszystko brakujące walory – w takiej sytuacji należałoby posłużyć się poniższym kodem.

//Odczytujemy kolekcję elementów TR z tabeli statystyk
$trsStats = $tabStats->getElementsByTagName('tr');
//dzięki rozpoczęciu iteracji od 1 ignorujemy wiersz z nagłówkami
for ($i = 1; $i < $trsStats->length; $i++) {
    $tdsStats = $trsStats->item($i)->getElementsByTagName('td');
    
    $nazwa = $tdsStats->item(1)->textContent;
    if (array_key_exists($nazwa, $kolekcja)) {
        $kolekcja[$nazwa]->setCenaMin($tdsStats->item(2)->textContent);
        $kolekcja[$nazwa]->setCenaMax($tdsStats->item(3)->textContent);
    } else {
        //Tutaj mamy fragment kodu na wypadek gdyby w statystykach 
        //pojawił się walor, którego nie w tabeli ofert.
        $element = new \Walor();
        $element->setNazwa($nazwa);
        $element->setCena($tdsOferty->item(2)->textContent);
        
        //Zapamiętujemy nasz obiekt w kolekcji
        $kolekcja[$element->getNazwa()] = $element;
    }
}

Poniżej zapiszę jeszcze cały kod (w wariancie gdzie ignorujemy wpisy występujące wyłącznie w tabeli statystyk).

<?php

$html = '
<!doctype html>
<html>
    <head>
        <title>Testowy kod HTML</title>
    </head>
    <body>
        <h1>Aktualny lista ofert</h1>
        <table>
            <tr>
                <th>#</th>
                <th>Nazwa</th>
                <th>Cena</th>
            </tr>
            <tr>
                <td>1</td>
                <td>AAAA</td>
                <td>0.45</td>
            </tr>
            <tr>
                <td>2</td>
                <td>BBBB</td>
                <td>0.73</td>
            </tr>
            <tr>
                <td>3</td>
                <td>CCCC</td>
                <td>1.02</td>
            </tr>
        </table>
        <hr>
        <h1>Statystyka cen za ostatni rok</h1>
        <table>
            <tr>
                <th>#</th>
                <th>Nazwa</th>
                <th>Cena Min</th>
                <th>Cena Max</th>
            </tr>
            <tr>
                <td>1</td>
                <td>AAAA</td>
                <td>0.22</td>
                <td>0.63</td>
            </tr>
            <tr>
                <td>2</td>
                <td>BBBB</td>
                <td>0.35</td>
                <td>1.34</td>
            </tr>
            <tr>
                <td>3</td>
                <td>CCCC</td>
                <td>1.00</td>
                <td>2.37</td>
            </tr>
        </table>
    </body>
</html>';

$dom = new \DOMDocument();
//Ustawiamy wartość i zapamiętujemy poprzednią
$internalErrors = libxml_use_internal_errors(true);
$dom->loadHtml($html);
//Odtwarzamy poprzednią wartość
libxml_use_internal_errors($internalErrors);

//Klasa do przechowywania informacji o pojedynczym walorze
class Walor
{
    private $nazwa;
    private $cena;
    private $cenaMin;
    private $cenaMax;
    
    public function getNazwa()
    {
        return $this->nazwa;
    }
    
    public function getCena()
    {
        return $this->cena;
    }
    
    public function getCenaMin()
    {
        return $this->cenaMin;
    }
    
    public function getCenaMax()
    {
        return $this->cenaMax;
    }
    
    public function setNazwa($nazwa)
    {
        $this->nazwa = $nazwa;
    }
    
    public function setCena($cena)
    {
        $this->cena = $cena;
    }
    
    public function setCenaMin($cenaMin)
    {
        $this->cenaMin = $cenaMin;
    }
    
    public function setCenaMax($cenaMax)
    {
        $this->cenaMax = $cenaMax;
    }
}

$tabele = $dom->getElementsByTagName('table');
$tabOferty = $tabele->item(0);
$tabStats = $tabele->item(1);

//Tworzymy naszą wynikową kolekcję.
$kolekcja = [];

//Odczytujemy kolekcję elementów TR z tabeli ofert
$trsOfert = $tabOferty->getElementsByTagName('tr');
//dzięki rozpoczęciu iteracji od 1 ignorujemy wiersz z nagłówkami
for ($i = 1; $i < $trsOfert->length; $i++) {
    $tdsOferty = $trsOfert->item($i)->getElementsByTagName('td');
    
    //W tym miejscu mamy już bieżący wiersz oraz kolekcję pól.
    //Możemy zabrać się za tworzenie obiektów kolekcji.
    $element = new \Walor();
    $element->setNazwa($tdsOferty->item(1)->textContent);
    $element->setCena($tdsOferty->item(2)->textContent);
    
    //Zapamiętujemy nasz obiekt w kolekcji
    $kolekcja[$element->getNazwa()] = $element;
}

//Odczytujemy kolekcję elementów TR z tabeli statystyk
$trsStats = $tabStats->getElementsByTagName('tr');
//dzięki rozpoczęciu iteracji od 1 ignorujemy wiersz z nagłówkami
for ($i = 1; $i < $trsStats->length; $i++) {
    $tdsStats = $trsStats->item($i)->getElementsByTagName('td');
    
    $nazwa = $tdsStats->item(1)->textContent;
    if (array_key_exists($nazwa, $kolekcja)) {
        $kolekcja[$nazwa]->setCenaMin($tdsStats->item(2)->textContent);
        $kolekcja[$nazwa]->setCenaMax($tdsStats->item(3)->textContent);
    }
}

Wykonanie powyższego kodu da nam na wyjściu następującą kolekcję, z którą możemy dalej pracować.

array(3) {
  ["AAAA"]=>
  object(Walor)#8 (4) {
    ["nazwa":"Walor":private]=>
    string(4) "AAAA"
    ["cena":"Walor":private]=>
    string(4) "0.45"
    ["cenaMin":"Walor":private]=>
    string(4) "0.22"
    ["cenaMax":"Walor":private]=>
    string(4) "0.63"
  }
  ["BBBB"]=>
  object(Walor)#7 (4) {
    ["nazwa":"Walor":private]=>
    string(4) "BBBB"
    ["cena":"Walor":private]=>
    string(4) "0.73"
    ["cenaMin":"Walor":private]=>
    string(4) "0.35"
    ["cenaMax":"Walor":private]=>
    string(4) "1.34"
  }
  ["CCCC"]=>
  object(Walor)#10 (4) {
    ["nazwa":"Walor":private]=>
    string(4) "CCCC"
    ["cena":"Walor":private]=>
    string(4) "1.02"
    ["cenaMin":"Walor":private]=>
    string(4) "1.00"
    ["cenaMax":"Walor":private]=>
    string(4) "2.37"
  }
}

Prawda, że przetwarzanie kodu HTML poprzez klasę DOMDocument jest bardzo wygodne i zdecydowanie lepsze niż z użyciem wyrażeń regularnych? Zapewne moja propozycja nie jest ani jedyną ani być może najlepszą dlatego też jestem ciekawy jak Wy radzicie sobie z tym problemem. Zapraszam do komentowania 🙂

Mogą zainteresować Ciebie również poniższe wpisy

Comments are closed.