Wprowadzenie
Czasami pojawia się pokusa pobierania pewnych informacji ze stron internetowych z poziomu kodu programu. Jest to zadanie trudne o tyle, że trzeba się
wgłębić w kod źródłowy strony, a przy tym niewdzięczne, bo
w każdej chwili kod strony może się zmienić na tyle dotkliwie, że pobieranie danych trzeba będzie robić od nowa. Ponadto robiąc coś takiego możemy
naruszyć prawa własności intelektualnej właściciela strony, której regulamin nie wyraża zgody na pobieranie i parsowanie jej kodu. Kolejnym problemem jest aktualizacja technologiczna strony - zwykle uważana za zjawisko pozytywne, w tym przypadku może oznaczać konieczność zmian w naszym programie albo dezaktualizację biblioteki, której używamy do parsowania stron.
API
Zanim zaczniemy myśleć jak parsować daną stronę, warto sprawdzić czy dostarcza ona
API - użycie go jest znacznie lepszym pomysłem niż parsowanie strony, gdyż jest to mechanizm oficjalnie wspierany przez utrzymujących serwis, który daje gwarancję, że z dnia na dzień nie zmieni się w stopniu wymagającym modyfikacji naszego programu. Poza tym użycie oficjalnego API zgodnie z instrukcją nie łamie żadnych praw do strony. Niestety część serwisów udostępnia API tylko odpłatnie albo z limitami funkcjonalności i zapytań.
Czy na pewno parsowanie stron w C++
Mimo iż uwielbiam język
C++, muszę powiedzieć, że w kwestii bibliotek do parsowania stron nie ma rewelacji - część bibliotek jest zaniedbana, część przestarzała, albo w czystym
C, dlatego warto zastanowić się, czy na pewno musimy robić coś takiego w
C++.
Może wystarczy wyrażenie regularne?
Zanim zaprzęgniemy do pracy bibliotekę do parsowania (a tym bardziej cały framework), warto zadać sobie pytanie, czy nie wystarczy zwykłe wyrażenie regularne, czyli w
C++ std::regex
. Jeśli szukamy jednego, prostego i dobrze określonego wzorca, a strona jest przewidywalna, często to w zupełności wystarcza i nie wymaga żadnych dodatkowych bibliotek.
Przykład: lista wersji językowych artykułu w Wikipedii (odnośniki w bocznym pasku). Każda z nich to odnośnik z atrybutem
hreflang
, a nazwa języka jest w znaczniku
span
. Program czyta stronę z pliku (jak ją pobrać, opisuję niżej, w sekcji o Wikipedii, na przykładzie strony
Lista polskich rodów książęcych):
Plik
00_regex_jezyki.cpp
:
#include <fstream>
#include <print>
#include <regex>
#include <sstream>
#include <string>
int main( int argc, char * * argv )
{
std::ifstream in( argc > 1 ? argv[ 1 ]: "RodyKsiazece.html", std::ios::binary );
std::ostringstream buffer;
buffer << in.rdbuf();
const std::string html = buffer.str();
const std::regex language { R"rx(<a href="([^"]+)"[^>]* hreflang="([^"]+)"[^>]*><span>([^<]+)</span>)rx" };
for( std::sregex_iterator it { html.begin(), html.end(), language }, end; it != end; ++it )
std::println( "{:<3} {}",( * it )[ 2 ].str(),( * it )[ 3 ].str() );
}
Program nie potrzebuje żadnych bibliotek, więc wystarczy go skompilować kompilatorem (używam
std::println
, więc potrzebny jest kompilator wspierający
C++23, np. na linuxie GCC 14):
g++-14 -std=c++23 -O2 -o 00_regex_jezyki 00_regex_jezyki.cpp
./00_regex_jezyki RodyKsiazece.html
Wynik (adresy odnośników są w grupie numer 1, ale są długie, więc ich nie wypisuję):
cs Čeština
en English
ru Русский
uk Українська
Trzeba jednak wiedzieć, kiedy wyrażenia regularne przestają wystarczać:
<b[^>]*><a [^>]*>([^<]+)</a></b>
Reguła praktyczna: wyrażenie regularne sprawdza się przy jednym prostym wzorcu na własnej lub bardzo stabilnej stronie. Gdy struktura jest zagnieżdżona, opcjonalna albo ma się zmieniać, potrzebny jest parser HTML-a.
BeautifulSoup w Pythonie
Do najlepszych i najpopularniejszych bibliotek do parsowania HTML-a należy
BeautifulSoup, niestety jest to biblioteka dla Pythona. Ale na łanach tegoż serwisu
opisałem jak używać Pythona z poziomu C++, więc można pokusić się jak jej użyć w kodzie własnej aplikacji bez. Przykłady użycia BeautifulSoup znajdziesz w
oficjalnej dokumentacji.
Zamiana HTML na XHTML
Gdy wszystko powyższe zawodzi, a musimy mimo wszystko parsować kod strony, jest jeszcze jeden sposób: narzędzie
libtidy potrafi przekonwertować kod dowolnej strony z HTML-a na
XHTML, a ten można parsować dowolnym
parserem XML-a, których w C++ jest wiele (np. RapidXML, opisany w
innym moim artykule, albo
pugixml obsługujący
język zapytań XPath).
Przegląd bibliotek do parsowania HTML-a
Zestawienie narzędzi do parsowania stron internetowych znajduje się w
Wikipedii. Nie da się jednoznacznie powiedzieć, które z nich jest najlepsze, bo każde ma jakieś wady, dlatego poniżej opisuję te, którym przyjrzałem się bliżej, a na końcu wybieram jedno narzędzie, przy użyciu którego pokażę przykłady. Przy takich zestawieniach dane szybko się starzeją, dlatego przy każdej bibliotece podaję daty (stan na 20.09.2026) - przed wyborem biblioteki do własnego projektu zajrzyj do jej repozytorium.
Najważniejsze cechy głównych kandydatów (kreska oznacza, że nie udało mi się ustalić danej daty):
Lexbor
Lexbor to obecnie najlepszy kandydat: projekt jest aktywnie rozwijany (wydanie 3.0.0 pojawiło się 31.03.2026, wersja 3.0.1 - 7.09.2026, a ostatnie zmiany w repozytorium są z 18.09.2026), a z jego parsera korzysta między innymi rozszerzenie DOM w PHP 8.4.
Gumbo
Gumbo powstał w Google, a oryginalne repozytorium jest zarchiwizowane (ostatnie wydanie 0.10.1 jest z 30.04.2015, ostatnia zmiana w kodzie z grudnia 2015). Nie oznacza to jednak końca projektu: społeczność utrzymuje
fork na Github (główne repozytorium jest na Codebergu), w którym 26.08.2026 ukazała się wersja 0.14.0. To ona jest dostępna w vcpkg.
libtidy + parser XML
HTML Tidy naprawia niedomknięte znaczniki i zwraca poprawny
XHTML, który parsujemy dowolną biblioteką do XML-a, na przykład
pugixml (wydanie 1.16 z 15.06.2026) z językiem zapytań
XPath: zamiast chodzić po drzewie opisujemy, czego szukamy. Wadą są dwa przebiegi (konwersja i parsowanie), dwie zależności zamiast jednej i stagnacja Tidy - ostatnie wydanie 5.8.0 jest z 16.07.2021, a ostatnia zmiana w repozytorium z 25.01.2022. Obie biblioteki są w vcpkg (pod nazwami
tidy-html5 i
pugixml).
htmlcxx
htmlcxx jest napisany w C++ (drzewo z iteratorami), a instalacja z repozytorium systemowego jest banalna. Ostatnia wersja to 0.87, którą do Debiana wprowadzono w grudniu 2020 roku, więc została wydana najpóźniej wtedy; nowszych wydań nie znalazłem, a w
vcpkg go nie ma. Dokumentacji praktycznie nie ma (na stronie głównej jest jeden przykład), a po dokumencie trzeba się poruszać iteratorami, sprawdzając w każdym węźle, czy to znacznik, którego szukamy.
libxml2
Moduł HTML w libxml2 wciąż ma wypisane w dokumentacji tylko funkcje. Od wersji 2.14 (27.03.2025) tokenizer jest zgodny z HTML5, ale budowanie drzewa nadal nie jest zgodne ze specyfikacją, a sami autorzy przestrzegają przed używaniem go do czegokolwiek poważnego (zob.
dyskusja o wsparciu HTML5). Do parsowania HTML-a są lepsze narzędzia.
Pozostałe biblioteki
Wybór biblioteki: Lexbor
Jak widać na ten moment idealnego kandydata nie ma, ale najbliżej ideału jest biblioteka
Lexbor, dlatego ją wybieram z następujących powodów:
Wybór nie wynika z tego, że pozostałe biblioteki nie działają. Dla sprawdzenia napisałem ten sam program (wypisanie listy artykułów ze strony, którą omówimy na końcu artykułu) w Lexborze, Gumbo, libtidy z pugixml i htmlcxx - wszystkie cztery wypisały identyczny wynik dla wszystkich wpisów.
Dalej pokazuję już tylko Lexbor.
Przygotowanie projektu: vcpkg i CMake
Biblioteki najłatwiej zainstalujemy
menedżerem pakietów vcpkg, który zaciągnie wszystkie zależności (w ostatnim przykładzie także
libcurl).
Jeśli nigdy nie używałeś
vcpkg, zajrzyj do
artykułu mojego autorstwa, w którym opisuję dokładniej ten multiplatformowy manager pakietów.
Instalacja na Linux
Budowanie na Linuksie (
vcpkg wymaga zainstalowanych narzędzi
curl,
zip,
unzip i
tar, które na Ubuntu zainstalujemy poleceniem
sudo apt install curl zip unzip tar
).
Mając już zainstalowane zależności możemy przystąpić do pozyskania biblioteki lexbor, które to wykonujemy przy pomocy poniższych poleceń (w katalogu z projektem):
git clone https://github.com/microsoft/vcpkg.git --depth=1
./vcpkg/bootstrap-vcpkg.sh -disableMetrics
./vcpkg/vcpkg install lexbor
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=g++-14 -DCMAKE_TOOLCHAIN_FILE=$PWD/vcpkg/scripts/buildsystems/vcpkg.cmake
cmake --build build --parallel $(nproc)
Opcja
--depth=1
pobiera tylko najnowszą wersję repozytorium, bez całej historii, więc klonowanie jest szybsze. Opcja
-disableMetrics
wyłącza wysyłanie danych telemetrycznych przez vcpkg, a
--parallel $(nproc)
każe użyć do kompilacji wszystkich rdzeni procesora (
nproc
zwraca ich liczbę). Przykłady używają
std::print
z C++23, który jest w GCC dopiero od wersji 14, a na
Ubuntu 24.04 domyślnym kompilatorem jest
g++ 13. Dlatego instalujemy
g++-14 poleceniem
sudo apt install g++-14
i wskazujemy go opcją
-DCMAKE_CXX_COMPILER=g++-14
.
Polecenie
vcpkg install
pobiera, buduje i instaluje biblioteki, co może potrwać kilka minut. Do wszystkich przykładów poza ostatnim wystarczy nam zainstalowanie samego
lexbor
. Pobieranie odbywa się do katalogu
vcpkg/installed
. CMake korzysta potem z gotowych bibliotek, a kolejne instalacje korzystają z pamięci podręcznej
vcpkg. Przykłady kompilowałem kompilatorem
g++-14
z włączonym
--std=c++23
i opcjami
-Wall -Wextra -pedantic
.
Amalgamacja: cały Lexbor w jednym pliku
Lexbor można też dołączyć do projektu jako tak zwaną amalgamację (single header): skrypt
single.pl (napisany w Perlu) skleja wybrane moduły razem z ich zależnościami w jeden plik nagłówkowy, który wystarczy dodać do projektu, bez instalowania biblioteki. Polecenia i opcje (między innymi wybór modułów) są opisane w
README repozytorium (sekcja Generate Amalgamation) i w
dokumentacji, więc nie odtwarzam ich tutaj. W projektach produkcyjnych zwykle korzysta się z menedżera pakietów, tak jak w tym artykule.
Instalacja Windows
Na Windowsie postępowanie jest analogiczne, jedynie skrypt instalujący vcpkg ma końcówkę
.bat
:
.\vcpkg\bootstrap-vcpkg.bat -disableMetrics
.
Plik CMakeLists.txt używany w przykładach
Poniższy plik obsługuje wszystkie poniższe przykłady, poza tymi, które dokonują pobrania strony:
cmake_minimum_required(VERSION 3.25)
project(lexbor_demo CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(lexbor CONFIG REQUIRED)
# Nazwa celu zależy od tripletu: biblioteki dynamiczne -> lexbor::lexbor, statyczne (np. x64-linux) -> lexbor::lexbor_static
if(TARGET lexbor::lexbor)
set(LEXBOR_TARGET lexbor::lexbor)
else()
set(LEXBOR_TARGET lexbor::lexbor_static)
endif()
# Plik z przykładem do zbudowania: domyślnie pierwszy, inny wskazujemy opcją -DEXAMPLE=03_dom.cpp
set(EXAMPLE 01_quick_start.cpp CACHE FILEPATH "Plik źródłowy przykładu")
get_filename_component(EXAMPLE_NAME ${EXAMPLE} NAME_WE) # program nazywa się tak jak plik: 03_dom
add_executable(${EXAMPLE_NAME} ${EXAMPLE})
target_link_libraries(${EXAMPLE_NAME} PRIVATE ${LEXBOR_TARGET})
Zwróć uwagę na nazwę celu w CMake-u: Lexbor eksportuje się jako
lexbor::lexbor_static
albo
lexbor::lexbor
, w zależności od tego, czy biblioteki w danym tripletcie są statyczne (na Linuksie domyślnie są). Dlatego w pliku jest warunek. Po instalacji vcpkg wypisuje fragmenty
find_package/target_link_libraries
dla każdej biblioteki, ale przy części z nich zaznacza, że są generowane heurystycznie i mogą być niepoprawne.
Każdy przykład jest w osobnym pliku o innej nazwie (na przykład
03_dom.cpp
), który zapisujemy w katalogu obok
CMakeLists.txt
(przykłady od drugiego korzystają jeszcze z pliku
lexbor_utils.hpp
, a późniejsze także z
lexbor_select.hpp
i
common.hpp
- każdy z nich pokażę, kiedy będzie potrzebny, i wszystkie leżą w tym samym katalogu). Plik
CMakeLists.txt
nie wymaga żadnych zmian: przykład, który chcemy zbudować, wskazujemy opcją
-DEXAMPLE=
, a program nazywa się tak samo jak jego plik. Domyślnie budowany jest pierwszy przykład, więc poleceniami z poprzedniej sekcji zbudujemy go i uruchomimy tak:
./build/01_quick_start
Kolejny przykład budujemy, wskazując jego plik (pozostałe opcje, jak kompilator i toolchain, CMake zapamiętuje w katalogu
build
, więc nie trzeba ich powtarzać):
cmake -S . -B build -DEXAMPLE=02_html_title.cpp
cmake --build build --parallel $(nproc)
./build/02_html_title
Jeśli chcesz wpleść Lexbor do własnego, większego projektu, z powyższego pliku potrzebujesz tylko trzech rzeczy:
find_package
, wyboru nazwy celu i
target_link_libraries
(oraz wskazania pliku toolchain vcpkg podczas konfiguracji, tak jak w poleceniu
cmake
powyżej):
find_package(lexbor CONFIG REQUIRED)
if(TARGET lexbor::lexbor)
set(LEXBOR_TARGET lexbor::lexbor)
else()
set(LEXBOR_TARGET lexbor::lexbor_static)
endif()
target_link_libraries(twoj_program PRIVATE ${LEXBOR_TARGET})
Lexbor - przegląd modułów na prostych przykładach
Zanim zajmiemy się prawdziwą stroną, poznajmy Lexbor na krótkich przykładach, w których HTML mamy zapisany w kodzie programu. Biblioteka składa się z
modułów, z których każdy zawiera oficjalny przykład, przykładowo:
Nazwy funkcji Lexbora układają się we wzór, który opisuje
dokumentacja (Function Naming): po nazwie od razu widać, do jakiego modułu i obiektu należy funkcja i co robi. Na przykład
lxb_html_document_parse
to moduł HTML, obiekt document i operacja parse. Wyjątkiem jest moduł Core, w którym wszystkie funkcje mają przedrostek
lexbor_
. Większość obiektów ma też ten sam cykl życia (tworzenie, inicjalizacja, użycie, czyszczenie i usunięcie), który będziemy widzieć w każdym przykładzie:
Quick Start: parsowanie napisu (moduł HTML)
Pierwszy przykład to przerobiony na C++
Quick Start z dokumentacji: zamiast jednego znacznika parsujemy krótki, celowo niechlujny fragment HTML-a (z niedomkniętymi akapitami) i sprawdzamy, co z niego powstało. Parser zachowuje się jak przeglądarka: sam dokłada brakujące znaczniki
html
,
head
i
body
oraz domyka to, co autor zostawił otwarte. Serializacja zwraca wynik porcjami, każdą z nich przekazujemy do naszej funkcji
printChunk()
. Lexbor oczekuje napisów jako pary (wskaźnik na bajty, długość), więc funkcja
bytes()
zamienia
std::string_view
na wskaźnik, a długość bierzemy z
.size()
.
Plik
01_quick_start.cpp
:
#include <lexbor/html/html.h>
#include <memory>
#include <print>
#include <string_view>
static const lxb_char_t * bytes( std::string_view text )
{
return reinterpret_cast < const lxb_char_t * >( text.data() );
}
static lxb_status_t printChunk( const lxb_char_t * data, size_t length, void * )
{
std::print( "{}", std::string_view { reinterpret_cast < const char * >( data ), length } );
return LXB_STATUS_OK;
}
int main()
{
constexpr std::string_view html = R"(
<h1>Raport z frontu kuchennego</h1>
<p>Kawa: <b>wypita</b>. Ciastka: <i>zniknęły w tajemniczych okolicznościach</i>.
<p>Podejrzany numer jeden: kot.)";
std::unique_ptr < lxb_html_document_t, decltype( & lxb_html_document_destroy ) > document { lxb_html_document_create(), & lxb_html_document_destroy };
if( !document )
return 1;
if( lxb_html_document_parse( document.get(), bytes( html ), html.size() ) != LXB_STATUS_OK )
return 1;
const lxb_char_t * name = lxb_dom_element_qualified_name( lxb_dom_interface_element( document->body ), nullptr );
std::println( "Element tag name: {}", reinterpret_cast < const char * >( name ) );
lxb_html_serialize_tree_cb( lxb_dom_interface_node( document.get() ), printChunk, nullptr );
std::println();
}
Wynik:
Element tag name: body
<html><head></head><body><h1>Raport z frontu kuchennego</h1>
<p>Kawa: <b>wypita</b>. Ciastka: <i>zniknęły w tajemniczych okolicznościach</i>.
</p><p>Podejrzany numer jeden: kot.</p></body></html>
Zachęcam do poeksperymentowania: podmień tekst w zmiennej
html
na własny i zobacz, co z niego zrobi parser. Gdyby w powyższym przykładzie zmienić tekst na:
<table><tr><td>Jeden<td>Dwa</table>
wydruk byłby taki (parser dołożył
tbody
, którego autor nie napisał):
<html><head></head><body><table><tbody><tr><td>Jeden</td><td>Dwa</td></tr></tbody></table></body></html>
A gdyby tekst wyglądał tak (źle zagnieżdżone znaczniki):
<b>pogrubione <i>i pochylone</b> jeszcze pochylone</i>
wydruk byłby taki (parser rozdzielił pochylenie na dwie części, tak jak zrobiłaby to przeglądarka):
<html><head></head><body><b>pogrubione <i>i pochylone</i></b><i> jeszcze pochylone</i></body></html>
Tytuł dokumentu (moduł HTML)
Tytuł strony można pobrać jedną funkcją
lxb_html_document_title
. Lexbor zwraca go w dwóch wersjach: z białymi znakami zredukowanymi (tak, jak pokazuje go przeglądarka) i surowej. Od tego przykładu funkcje pomocnicze (
bytes()
, odwrotna konwersja
view()
oraz
printChunk()
) są w pliku
lexbor_utils.hpp
, z którego korzystają wszystkie kolejne przykłady:
#pragma once
#include <lexbor/html/html.h>
#include <print>
#include <string_view>
inline const lxb_char_t * bytes( std::string_view text )
{
return reinterpret_cast < const lxb_char_t * >( text.data() );
}
inline std::string_view view( const lxb_char_t * data, size_t length )
{
return { reinterpret_cast < const char * >( data ), length };
}
inline lxb_status_t printChunk( const lxb_char_t * data, size_t length, void * )
{
std::print( "{}", view( data, length ) );
return LXB_STATUS_OK;
}
Plik
02_html_title.cpp
:
#include <lexbor/html/html.h>
#include <memory>
#include <print>
#include "lexbor_utils.hpp"
int main()
{
constexpr std::string_view html = "<head><title> Wielki tytuł, a w nim za dużo spacji </title></head>";
std::unique_ptr < lxb_html_document_t, decltype( & lxb_html_document_destroy ) > document { lxb_html_document_create(), & lxb_html_document_destroy };
if( !document || lxb_html_document_parse( document.get(), bytes( html ), html.size() ) != LXB_STATUS_OK )
return 1;
size_t length = 0; const lxb_char_t * title = lxb_html_document_title( document.get(), & length );
std::println( "Tytuł: \"{}\"", view( title, length ) );
title = lxb_html_document_title_raw( document.get(), & length );
std::println( "Tytuł surowy: \"{}\"", view( title, length ) );
constexpr std::string_view newTitle = "Znacznie krótszy tytuł";
lxb_html_document_title_set( document.get(), bytes( newTitle ), newTitle.size() );
title = lxb_html_document_title( document.get(), & length );
std::println( "Po zmianie: \"{}\"", view( title, length ) );
}
Wynik:
Tytuł: "Wielki tytuł, a w nim za dużo spacji"
Tytuł surowy: " Wielki tytuł, a w nim za dużo spacji "
Po zmianie: "Znacznie krótszy tytuł"
Wyszukiwanie, atrybuty i tworzenie węzłów (moduł DOM)
Moduł DOM daje dostęp do drzewa dokumentu. Elementy można wyszukiwać po nazwie znacznika, po klasie i po atrybucie (wyniki trafiają do kolekcji), odczytywać i zmieniać ich atrybuty oraz tworzyć nowe węzły i dołączać je do drzewa. Zwróć uwagę, że szukając w tym samym dokumencie po klasie i po atrybucie znajdujemy elementy o różnych znacznikach. W wywołaniach funkcji Lexbora nazwy parametrów, które nie są oczywiste (
nullptr
,
true
,
false
), opisuję w komentarzach.
Plik
03_dom.cpp
:
#include <lexbor/html/html.h>
#include <memory>
#include <print>
#include "lexbor_utils.hpp"
using namespace std::string_view_literals;
static void printTags( std::string_view label, lxb_dom_collection_t * collection )
{
std::print( "{:<18}", label );
for( size_t i = 0; i < lxb_dom_collection_length( collection ); ++i )
{
const lxb_char_t * tag = lxb_dom_element_qualified_name( lxb_dom_collection_element( collection, i ), nullptr );
std::print( " {}", reinterpret_cast < const char * >( tag ) );
}
std::println();
}
int main()
{
constexpr std::string_view html = R"(
<div id="kuchnia" class="pomieszczenie">
<p class="zwierze">Kot Mruczek wyskoczył z lodówki.</p>
<ul class="zakupy">
<li class="produkt" data-cena="5">mleko</li>
<li class="produkt" data-cena="0">powietrze</li>
<li class="zwierze" data-cena="5">karma dla kota</li>
</ul>
<span class="zwierze" data-cena="5">Ryba w akwarium nic nie widziała.</span>
</div>)";
std::unique_ptr < lxb_html_document_t, decltype( & lxb_html_document_destroy ) > document { lxb_html_document_create(), & lxb_html_document_destroy };
if( !document || lxb_html_document_parse( document.get(), bytes( html ), html.size() ) != LXB_STATUS_OK )
return 1;
lxb_dom_element_t * body = lxb_dom_interface_element( document->body );
lxb_dom_collection_t * found = lxb_dom_collection_make( & document->dom_document, 16 );
constexpr auto tag = "li"sv;
lxb_dom_elements_by_tag_name( body, found, bytes( tag ), tag.size() );
printTags( "znacznik li:", found );
constexpr auto cssClass = "zwierze"sv;
lxb_dom_collection_clean( found );
lxb_dom_elements_by_class_name( body, found, bytes( cssClass ), cssClass.size() );
printTags( "klasa zwierze:", found );
constexpr auto attribute = "data-cena"sv;
constexpr auto value = "5"sv;
lxb_dom_collection_clean( found );
lxb_dom_elements_by_attr( body, found, bytes( attribute ), attribute.size(), bytes( value ), value.size(), false );
printTags( "data-cena=\"5\":", found );
lxb_dom_element_t * kitchen = lxb_dom_interface_element( lxb_dom_interface_node( body )->first_child );
constexpr auto idName = "id"sv;
size_t length = 0;
const lxb_char_t * id = lxb_dom_element_get_attribute( kitchen, bytes( idName ), idName.size(), & length );
std::println( "id pierwszego elementu: {}", view( id, length ) );
constexpr auto titleName = "title"sv;
constexpr auto titleValue = "Uwaga: kot w środku"sv;
lxb_dom_element_set_attribute( kitchen, bytes( titleName ), titleName.size(), bytes( titleValue ), titleValue.size() );
constexpr auto newTag = "p"sv;
constexpr auto newText = "Akapit dopisany przez program"sv;
lxb_dom_element_t * paragraph = lxb_dom_document_create_element( & document->dom_document, bytes( newTag ), newTag.size(), nullptr );
lxb_dom_text_t * text = lxb_dom_document_create_text_node( & document->dom_document, bytes( newText ), newText.size() );
lxb_dom_node_insert_child( lxb_dom_interface_node( paragraph ), lxb_dom_interface_node( text ) );
lxb_dom_node_insert_child( lxb_dom_interface_node( kitchen ), lxb_dom_interface_node( paragraph ) );
lxb_html_serialize_tree_cb( lxb_dom_interface_node( kitchen ), printChunk, nullptr );
std::println();
lxb_dom_collection_destroy( found, true ); }
Wynik:
znacznik li: li li li
klasa zwierze: p li span
data-cena="5": li li span
id pierwszego elementu: kuchnia
<div id="kuchnia" class="pomieszczenie" title="Uwaga: kot w środku">
<p class="zwierze">Kot Mruczek wyskoczył z lodówki.</p>
<ul class="zakupy">
<li class="produkt" data-cena="5">mleko</li>
<li class="produkt" data-cena="0">powietrze</li>
<li class="zwierze" data-cena="5">karma dla kota</li>
</ul>
<span class="zwierze" data-cena="5">Ryba w akwarium nic nie widziała.</span>
<p>Akapit dopisany przez program</p></div>
Przechodzenie po drzewie i parametr context (moduł DOM)
Czasem zamiast szukać konkretnych elementów chcemy przejść po całym drzewie i coś z niego zebrać. Służy do tego funkcja
lxb_dom_node_simple_walk()
, która wywołuje naszą funkcję zwrotną dla każdego węzła. Funkcje zwrotne w Lexborze (jak w wielu bibliotekach C) mają ostatni parametr
context
typu
void*
: to miejsce na własne dane. Podajemy wskaźnik przy wywołaniu, a Lexbor przekazuje go z powrotem do każdego wywołania funkcji zwrotnej - dzięki temu funkcja, która nie ma dostępu do naszych zmiennych, może zapisywać wyniki w strukturze, którą jej wskażemy. Tak działał już
printChunk()
, któremu
context
nie był potrzebny, a z nazwanym
context
spotkamy się jeszcze w funkcjach dopisujących wynik do
std::string
. W C++ wygodniej jest traktować ten wskaźnik jako wskaźnik do lambdy, co pokazuje druga część przykładu (funkcja
forEachNode()
).
Plik
04_walk_context.cpp
:
#include <lexbor/html/html.h>
#include <map>
#include <memory>
#include <print>
#include <string>
#include <type_traits>
#include <vector>
#include "lexbor_utils.hpp"
struct Statistics
{
std::map < std::string, int > tags; std::vector < std::string > links; };
static lexbor_action_t collectStatistics( lxb_dom_node_t * node, void * context )
{
auto * statistics = static_cast < Statistics * >( context ); if( node->type == LXB_DOM_NODE_TYPE_ELEMENT )
{
lxb_dom_element_t * element = lxb_dom_interface_element( node );
size_t length = 0;
const lxb_char_t * name = lxb_dom_element_qualified_name( element, & length );
++statistics->tags[ std::string { view( name, length ) } ];
constexpr std::string_view href = "href";
if( const lxb_char_t * value = lxb_dom_element_get_attribute( element, bytes( href ), href.size(), & length ) )
statistics->links.emplace_back( view( value, length ) );
}
return LEXBOR_ACTION_OK; }
template < typename Callback >
static void forEachNode( lxb_dom_node_t * root, Callback && callback )
{
auto trampoline =[ ]( lxb_dom_node_t * node, void * context )->lexbor_action_t
{
( * static_cast < std::remove_reference_t < Callback > * >( context ) )( node );
return LEXBOR_ACTION_OK;
};
lxb_dom_node_simple_walk( root, trampoline, & callback );
}
int main()
{
constexpr std::string_view html = R"(
<h1>Nasze zakupy</h1>
<p>Kupiliśmy <a href="https://example.com/kot">kota w worku</a> oraz <a href="/promocje">promocję na nudę</a>.</p>
<ul>
<li>Lista jest <b>bardzo</b> długa</li>
<li><a href="mailto:kot@example.com">Napisz do kota</a></li>
</ul>)";
std::unique_ptr < lxb_html_document_t, decltype( & lxb_html_document_destroy ) > document { lxb_html_document_create(), & lxb_html_document_destroy };
if( !document || lxb_html_document_parse( document.get(), bytes( html ), html.size() ) != LXB_STATUS_OK )
return 1;
lxb_dom_node_t * root = lxb_dom_interface_node( document.get() );
Statistics statistics;
lxb_dom_node_simple_walk( root, collectStatistics, & statistics );
for( const auto &[ tag, count ]: statistics.tags )
std::println( "znacznik {:<4} występuje {}x", tag, count );
for( const std::string & link: statistics.links )
std::println( "odnośnik: {}", link );
size_t textNodes = 0;
forEachNode( root,[ & ]( lxb_dom_node_t * node )
{ textNodes += node->type == LXB_DOM_NODE_TYPE_TEXT; } );
std::println( "węzłów tekstowych: {}", textNodes );
}
Wynik:
znacznik a występuje 3x
znacznik b występuje 1x
znacznik body występuje 1x
znacznik h1 występuje 1x
znacznik head występuje 1x
znacznik html występuje 1x
znacznik li występuje 2x
znacznik p występuje 1x
znacznik ul występuje 1x
odnośnik: https://example.com/kot
odnośnik: /promocje
odnośnik: mailto:kot@example.com
węzłów tekstowych: 15
Selektory CSS (moduł Selectors)
Wyszukiwanie po nazwie, klasie i atrybucie jest wygodne, ale selektory CSS pozwalają opisać to, czego szukamy, dokładnie tak jak w przeglądarce (
querySelectorAll
). Opakowałem je w funkcję
select()
, która zwraca wektor znalezionych węzłów - użyjemy jej jeszcze w programach parsujących prawdziwe strony. Więcej o selektorach jest w
tutorialu z dokumentacji.
Funkcje pomocnicze (
select()
,
textContent()
i
attribute()
) zapisuję w pliku
lexbor_select.hpp
, bo użyjemy ich jeszcze w kolejnych przykładach:
#pragma once
#include <lexbor/css/css.h>
#include <lexbor/html/html.h>
#include <lexbor/selectors/selectors.h>
#include <print>
#include <string>
#include <vector>
#include "lexbor_utils.hpp"
using Nodes = std::vector < lxb_dom_node_t * >;
inline lxb_status_t collect( lxb_dom_node_t * node, lxb_css_selector_specificity_t , void * context )
{
static_cast < Nodes * >( context )->push_back( node );
return LXB_STATUS_OK;
}
inline Nodes select( lxb_dom_node_t * root, std::string_view selector )
{
Nodes result;
lxb_css_parser_t * parser = lxb_css_parser_create();
lxb_selectors_t * selectors = lxb_selectors_create();
if( lxb_css_parser_init( parser, nullptr ) == LXB_STATUS_OK && lxb_selectors_init( selectors ) == LXB_STATUS_OK )
{
lxb_css_selector_list_t * list = lxb_css_selectors_parse( parser, bytes( selector ), selector.size() );
if( list != nullptr )
{
lxb_selectors_find( selectors, root, list, collect, & result );
lxb_css_selector_list_destroy_memory( list );
}
else
std::println( stderr, "Niepoprawny selektor: {}", selector );
}
lxb_selectors_destroy( selectors, true );
lxb_css_parser_destroy( parser, true );
return result;
}
inline std::string textContent( lxb_dom_node_t * node )
{
size_t length = 0;
lxb_char_t * text = lxb_dom_node_text_content( node, & length );
if( !text )
return { };
std::string result { view( text, length ) };
lxb_dom_document_destroy_text( node->owner_document, text ); return result;
}
inline std::string attribute( lxb_dom_node_t * node, std::string_view name )
{
size_t length = 0;
const lxb_char_t * value = lxb_dom_element_get_attribute( lxb_dom_interface_element( node ), bytes( name ), name.size(), & length );
return value ? std::string { view( value, length ) }
: std::string
{ };
}
Plik
05_selectors.cpp
:
#include <lexbor/html/html.h>
#include <memory>
#include <print>
#include "lexbor_select.hpp"
#include "lexbor_utils.hpp"
int main()
{
constexpr std::string_view html = R"(
<ul id="menu">
<li class="danie">Zupa z niespodzianką</li>
<li class="napoj">Woda z bąbelkami</li>
<li class="danie">Kotlet <b>z zaskoczenia</b></li>
</ul>
<ol><li class="danie">Deser (tylko dla odważnych)</li></ol>)";
std::unique_ptr < lxb_html_document_t, decltype( & lxb_html_document_destroy ) > document { lxb_html_document_create(), & lxb_html_document_destroy };
if( !document || lxb_html_document_parse( document.get(), bytes( html ), html.size() ) != LXB_STATUS_OK )
return 1;
for( std::string_view selector: { "li.danie", "ul#menu > li.danie", "li:nth-child(2)", "b" } )
{
std::print( "{:<20} ->", selector );
for( lxb_dom_node_t * node: select( lxb_dom_interface_node( document.get() ), selector ) )
std::print( " [{}]", textContent( node ) );
std::println();
}
}
Wynik:
li.danie -> [Zupa z niespodzianką] [Kotlet z zaskoczenia] [Deser (tylko dla odważnych)]
ul#menu > li.danie -> [Zupa z niespodzianką] [Kotlet z zaskoczenia]
li:nth-child(2) -> [Woda z bąbelkami]
b -> [z zaskoczenia]
Wchodzenie do zagnieżdżonego elementu (moduł DOM)
Częsty scenariusz: na stronie jest kontener, w nim kolejne elementy (na przykład galeria memów), a w każdym z nich obrazek, którego adres chcemy wyciągnąć. Droga prowadzi tak: kontener, element galerii, odnośnik, obrazek, atrybut
src
. W tym przykładzie idziemy nią ręcznie, po wskaźnikach
first_child
i
next
każdego węzła. Uwaga na pułapkę: dziećmi elementu są też węzły tekstowe (białe znaki między znacznikami), dlatego funkcja
childElement()
pomija wszystko, co nie jest elementem, a pętla po galerii sprawdza klasę elementu, żeby pominąć reklamę. To samo zwróci jeden selektor
#galeria > .mem img
(z poprzedniego przykładu), ale warto wiedzieć, jak wygląda ręczna nawigacja po drzewie. Funkcja
attribute()
pochodzi z pliku
lexbor_select.hpp
z poprzedniego przykładu.
Plik
06_nested.cpp
:
#include <lexbor/html/html.h>
#include <memory>
#include <print>
#include "lexbor_select.hpp"
#include "lexbor_utils.hpp"
static lxb_dom_node_t * childElement( lxb_dom_node_t * parent, std::string_view tag )
{
for( lxb_dom_node_t * child = parent->first_child; child != nullptr; child = child->next )
{
if( child->type != LXB_DOM_NODE_TYPE_ELEMENT )
continue;
size_t length = 0;
const lxb_char_t * name = lxb_dom_element_qualified_name( lxb_dom_interface_element( child ), & length );
if( view( name, length ) == tag )
return child;
}
return nullptr;
}
int main()
{
constexpr std::string_view html = R"(
<div id="galeria">
<div class="mem">
<h3>Kot kontra klawiatura</h3>
<a href="/mem/1"><img src="/img/kot.jpg" alt="Kot śpi na klawiaturze"></a>
</div>
<div class="mem">
<h3>Kompilator o trzeciej w nocy</h3>
<a href="/mem/2"><img src="/img/blad.png" alt="400 linii błędów przez jeden średnik"></a>
</div>
<div class="reklama"><img src="/img/reklama.gif" alt="Kup teraz!"></div>
</div>)";
std::unique_ptr < lxb_html_document_t, decltype( & lxb_html_document_destroy ) > document { lxb_html_document_create(), & lxb_html_document_destroy };
if( !document || lxb_html_document_parse( document.get(), bytes( html ), html.size() ) != LXB_STATUS_OK )
return 1;
constexpr std::string_view containerId = "galeria";
lxb_dom_node_t * gallery = lxb_dom_node_by_id( lxb_dom_interface_node( document.get() ), bytes( containerId ), containerId.size() );
if( !gallery )
return 1;
for( lxb_dom_node_t * item = gallery->first_child; item != nullptr; item = item->next )
{
if( item->type != LXB_DOM_NODE_TYPE_ELEMENT || attribute( item, "class" ) != "mem" )
continue; lxb_dom_node_t * link = childElement( item, "a" );
lxb_dom_node_t * image = link ? childElement( link, "img" )
: nullptr;
if( image )
std::println( "{} -> {} ({})", attribute( link, "href" ), attribute( image, "src" ), attribute( image, "alt" ) );
}
}
Wynik:
/mem/1 -> /img/kot.jpg (Kot śpi na klawiaturze)
/mem/2 -> /img/blad.png (400 linii błędów przez jeden średnik)
Adresy URL (moduł URL)
Odnośniki na stronach są zwykle względne (na przykład
/artykuly/48
), a my potrzebujemy pełnych adresów. Moduł URL rozwiązuje je względem adresu bazowego według standardu WHATWG - dokładnie tak, jak robi to przeglądarka. Przykład jest przerobioną wersją
tutorialu z dokumentacji. Parser trzeba wyczyścić (
lxb_url_parser_clean()
) przed każdym kolejnym użyciem.
W
dokumentacji tego modułu zostało to fajnie zobrazowane:
https://user:password@example.com:8080/path/to/page?key=value#section
\___/ \__/ \______/ \_________/ \__/\___________/\________/\______/
| | | | | | | |
scheme user password host port path query fragment
A teraz przejdźmy do kodu, oto plik
07_url.cpp
:
#include <lexbor/url/url.h>
#include <print>
#include <string>
#include "lexbor_utils.hpp"
static lxb_status_t appendChunk( const lxb_char_t * data, size_t length, void * context )
{
static_cast < std::string * >( context )->append( view( data, length ) );
return LXB_STATUS_OK;
}
int main()
{
lxb_url_parser_t parser;
if( lxb_url_parser_init( & parser, nullptr ) != LXB_STATUS_OK ) return 1;
constexpr std::string_view base = "https://cpp0x.pl/artykuly/";
lxb_url_t * baseUrl = lxb_url_parse( & parser, nullptr, bytes( base ), base.size() ); for( std::string_view href: { "/artykuly/48", "49", "../kursy/", "?strona=2", "https://example.com/inny" } )
{
lxb_url_parser_clean( & parser ); lxb_url_t * url = lxb_url_parse( & parser, baseUrl, bytes( href ), href.size() );
if( !url )
continue;
std::string absolute;
lxb_url_serialize( url, appendChunk, & absolute, false ); std::println( "{:<26} -> {}", href, absolute );
}
lxb_url_parser_destroy( & parser, false ); lxb_url_memory_destroy( baseUrl );
}
Wynik:
/artykuly/48 -> https://cpp0x.pl/artykuly/48
49 -> https://cpp0x.pl/artykuly/49
../kursy/ -> https://cpp0x.pl/kursy/
?strona=2 -> https://cpp0x.pl/artykuly/?strona=2
https://example.com/inny -> https://example.com/inny
Kodowania znaków (moduł Encoding)
Parser HTML w Lexborze przyjmuje wyłącznie tekst w UTF-8 (tak jest napisane w nagłówku
lexbor/html/encoding.h
). Strona cpp0x.pl jest w UTF-8, więc w naszym programie nic nie trzeba konwertować, ale wiele starszych polskich stron jest w windows-1250 albo ISO-8859-2. Pokazuję więc, jak wykryć kodowanie na podstawie znacznika
meta
i jak zamienić tekst na UTF-8: najpierw dekodujemy go do punktów kodowych Unicode, a potem kodujemy do UTF-8.
Plik
08_encoding.cpp
:
#include <lexbor/encoding/encoding.h>
#include <lexbor/html/encoding.h>
#include <array>
#include <print>
#include <string>
#include "lexbor_utils.hpp"
static std::string toUtf8( std::string_view input, std::string_view encodingName )
{
const lxb_encoding_data_t * from = lxb_encoding_data_by_pre_name( bytes( encodingName ), encodingName.size() );
const lxb_encoding_data_t * to = lxb_encoding_data_by_pre_name( bytes( "utf-8" ), 5 );
if( !from || !to )
return { };
std::array < lxb_codepoint_t, 512 > codepoints;
std::array < lxb_char_t, 2048 > output;
lxb_encoding_decode_t decoder;
lxb_encoding_encode_t encoder;
lxb_encoding_decode_init( & decoder, from, codepoints.data(), codepoints.size() );
lxb_encoding_encode_init( & encoder, to, output.data(), output.size() );
const lxb_char_t * in = bytes( input );
from->decode( & decoder, & in, in + input.size() );
const lxb_codepoint_t * codepoint = codepoints.data();
to->encode( & encoder, & codepoint, codepoints.data() + lxb_encoding_decode_buf_used( & decoder ) );
return std::string { view( output.data(), lxb_encoding_encode_buf_used( & encoder ) ) };
}
int main()
{
constexpr std::string_view html = R"(<html><head><meta http-equiv="Content-Type" content="text/html; charset=windows-1250"></head>)";
lxb_html_encoding_t detector;
lxb_html_encoding_init( & detector );
lxb_html_encoding_determine( & detector, bytes( html ), bytes( html ) + html.size() );
if( lxb_html_encoding_entry_t * entry = lxb_html_encoding_meta_entry( & detector, 0 ) ) std::println( "Wykryte kodowanie: {}", view( entry->name, entry->end - entry->name ) );
lxb_html_encoding_destroy( & detector, false ); const std::string polish1250 = "Za" "\xbf\xf3\xb3\xe6" " g" "\xea\x9c" "l" "\xb9" " ja" "\x9f\xf1";
std::println( "Po konwersji: {}", toUtf8( polish1250, "windows-1250" ) );
}
Wynik:
Wykryte kodowanie: windows-1250
Po konwersji: Zażółć gęślą jaźń
Normalizacja tekstu i nazwy domen (moduł Unicode)
Polskie znaki można zapisać na dwa sposoby: "ą" to albo jeden punkt kodowy (U+0105), albo litera "a" z doklejonym ogonkiem (U+0061 U+0328). Dla człowieka to ten sam tekst, ale porównanie bajt po bajcie mówi, że są różne. Przed porównywaniem tekstów ze stron warto je znormalizować (tu do postaci NFC). Drugi przykład to zamiana polskiej nazwy domeny na zapis ASCII (
IDNA/
punycode). Zwróć uwagę na ostatni argument funkcji
lxb_unicode_idna_to_ascii()
: jest to enum, więc w C++ nie wystarczy wpisać zera, trzeba użyć
LXB_UNICODE_IDNA_FLAG_UNDEF
- w C by to przeszło, w C++ nie (typowy problem przy używaniu C-owych API).
Plik
09_unicode.cpp
:
#include <lexbor/encoding/encoding.h>
#include <lexbor/unicode/unicode.h>
#include <print>
#include <string>
#include "lexbor_utils.hpp"
static lxb_status_t appendChunk( const lxb_char_t * data, size_t length, void * context )
{
static_cast < std::string * >( context )->append( view( data, length ) );
return LXB_STATUS_OK;
}
static void dump( std::string_view label, const std::string & text )
{
std::print( "{}: {} ", label, text );
const lxb_char_t * p = bytes( text );
const lxb_char_t * end = p + text.size();
while( p < end )
std::print( "U+{:04X} ", lxb_encoding_decode_valid_utf_8_single( & p, end ) );
std::println();
}
int main()
{
const std::string composed = "\u0105";
const std::string decomposed = "a\u0328";
std::println( "Równe bajt po bajcie: {}", composed == decomposed ? "tak": "nie" );
lxb_unicode_normalizer_t * normalizer = lxb_unicode_normalizer_create();
lxb_unicode_normalizer_init( normalizer, LXB_UNICODE_NFC );
std::string normalized;
lxb_unicode_normalize( normalizer, bytes( decomposed ), decomposed.size(), appendChunk, & normalized, true ); dump( "wejście", decomposed );
dump( "NFC", normalized );
std::println( "Po normalizacji równe: {}", normalized == composed ? "tak": "nie" );
lxb_unicode_normalizer_destroy( normalizer, true );
lxb_unicode_idna_t idna;
lxb_unicode_idna_init( & idna );
constexpr std::string_view domain = "łódź.pl";
std::string ascii;
lxb_unicode_idna_to_ascii( & idna, bytes( domain ), domain.size(), appendChunk, & ascii, LXB_UNICODE_IDNA_FLAG_UNDEF ); std::println( "{} -> {}", domain, ascii );
lxb_unicode_idna_destroy( & idna, false ); }
Wynik:
Równe bajt po bajcie: nie
wejście: ą U+0061 U+0328
NFC: ą U+0105
Po normalizacji równe: tak
łódź.pl -> xn--d-uga0v4h.pl
Arkusze stylów (moduł CSS)
Moduł CSS potrafi sparsować cały arkusz stylów (a nie tylko pojedynczy selektor). Przy pracy ze stronami przydaje się rzadziej, ale pokazuje, że Lexbor jest czymś więcej niż parserem HTML-a: arkusz zostaje rozłożony na reguły, które można przeglądać i z powrotem zapisać.
Plik
10_css.cpp
:
#include <lexbor/css/css.h>
#include <print>
#include "lexbor_utils.hpp"
int main()
{
constexpr std::string_view css = "p.kot { color: orange; margin: 0 auto } h1 > b:first-child { font-weight: bold }";
lxb_css_parser_t * parser = lxb_css_parser_create();
if( lxb_css_parser_init( parser, nullptr ) != LXB_STATUS_OK ) return 1;
lxb_css_stylesheet_t * stylesheet = lxb_css_stylesheet_create( nullptr ); const lxb_status_t status = lxb_css_stylesheet_parse( stylesheet, parser, bytes( css ), css.size() );
lxb_css_parser_destroy( parser, true ); if( status != LXB_STATUS_OK )
return 1;
lxb_css_rule_serialize( stylesheet->root, printChunk, nullptr );
std::println();
lxb_css_stylesheet_destroy( stylesheet, true ); }
Wynik:
p.kot {color: orange; margin: 0 auto}
h1 > b:first-child {font-weight: bold}
Gdzie szukać więcej
Dodatkowe możliwości Lexbora
Przykłady z tego artykułu pokazują tylko niewielki wycinek biblioteki. Lexbor powstaje jako fundament silnika przeglądarki internetowej (hasło projektu w repozytorium brzmi "Crafting a Browser Engine with Simplicity and Flexibility"), a jego gotowe już moduły są używane produkcyjnie: rozszerzenie DOM w PHP korzysta z parsera HTML i selektorów CSS Lexbora od wersji PHP 8.4, a rozszerzenie URL od PHP 8.5. Na Lexborze opierają się też między innymi biblioteki
Selectolax (Python) i
Nokolexbor (Ruby), a wiązania do innych języków (Elixir, Erlang, Crystal, D, Julia) wymienia
README repozytorium.
Skoro biblioteka ma być fundamentem przeglądarki, musi być możliwie szybka. Dlatego zawiera wiele optymalizacji i własnych rozwiązań, nawet tam, gdzie zwykle używa się standardowej biblioteki C:
Biblioteka jest napisana w czystym C99, bez żadnych zależności zewnętrznych, a moduły można budować i używać osobno (w CMake opcja
LEXBOR_BUILD_SEPARATELY
tworzy osobną bibliotekę dla każdego modułu, na przykład
liblexbor-html
). Poniższy rysunek pokazuje podział na moduły (jest uproszczony: pokazuje grupy modułów, a nie wszystkie zależności między nimi):
To jednak jeszcze nie jest przeglądarka. Autor buduje na tych modułach silnik przeglądarki, ale moduły Layout (układ strony, według dokumentacji na etapie drzewa renderowania), Fonts (czcionki i wyświetlanie tekstu) oraz Engine (połączenie wszystkiego w jeden silnik) są w rozwoju lub dopiero planowane (zob.
Roadmap), a w dokumentacji nie ma mowy o silniku JavaScriptu. Do wyświetlenia strony brakuje więc jeszcze sporo, ale to, czego potrzebujemy przy pobieraniu danych, czyli rozpoznanie zawartości strony, jest już gotowe i - jak pokazuje wykorzystanie w PHP - dojrzałe. Dokumentacja niektórych modułów jest wciąż w przygotowaniu, dlatego poniżej zebrałem, co znajdziesz w poszczególnych modułach i gdzie tego szukać.
Core: podstawy biblioteki
Gdzie szukać:
dokumentacja Core (najobszerniejsza z dotychczasowych; katalog
source/lexbor/core w repozytorium)
HTML: parser i serializacja
Gdzie szukać:
dokumentacja HTML i
przykłady w repozytorium
DOM: drzewo dokumentu
Moduł implementuje standard
WHATWG DOM, czyli to samo, co w przeglądarce dostępne jest z JavaScriptu.
Gdzie szukać:
dokumentacja DOM i
przykłady w repozytorium
CSS: arkusze stylów
Gdzie szukać:
dokumentacja CSS (podstrony:
Parser,
Stylesheet,
Selectors i
Syntax) oraz
przykłady w repozytorium
Selectors: selektory CSS
Gdzie szukać:
dokumentacja Selectors,
tutorial z selektorami i
artykuł o selektorach
Style: HTML łączy się z CSS
To moduł pomocniczy, który łączy moduły HTML i CSS.
Gdzie szukać:
przykłady w repozytorium
Encoding: kodowania znaków
Parser HTML przyjmuje tylko UTF-8, więc strony w innych kodowaniach (na przykład starsze polskie strony w windows-1250) trzeba najpierw przekonwertować tym modułem.
Gdzie szukać:
dokumentacja Encoding i
przykłady w repozytorium
URL: adresy internetowe
Gdzie szukać:
dokumentacja URL i
tutorial z odnośnikami
Unicode i Punycode
Gdzie szukać:
dokumentacja Unicode i
przykłady w repozytorium
Moduły pomocnicze
Gdzie szukać:
przegląd modułów
Moduły w rozwoju
Według dokumentacji i README są to moduły w rozwoju lub planowane.
Gdzie szukać:
Roadmap
Konwencje nazewnicze
Dla ułatwienia warto rzucić okiem na oficjalne konwencje nazewnicze funkcji.
Dla modułu
core:

Dla pozostałych modułów:

robots.txt - zanim pobierzesz stronę
Zanim napiszemy program pobierający stronę, sprawdźmy, czego jej właściciel sobie życzy. Serwisy mogą to zapisać w pliku
robots.txt
umieszczonym w głównym katalogu serwisu (dla cpp0x.pl jest to
https://cpp0x.pl/robots.txt). Format opisuje standard
RFC 9309 (Robots Exclusion Protocol) z września 2022 roku. Plik składa się z grup: każda zaczyna się od linii
User-agent
(nazwa robota, gwiazdka oznacza wszystkich), a dalej są reguły
Allow
i
Disallow
ze ścieżkami. Przykład (wymyślony):
User-agent: *
Disallow: /admin/
Allow: /artykuly/
User-agent: Googlebot
Disallow: /prywatne/
User-agent: cpp0x-html-parser-demo
Disallow: /
Robot szuka grupy pasującej do nazwy z nagłówka
User-Agent
(wielkość liter nie ma znaczenia), a jeśli takiej nie ma, stosuje grupę z gwiazdką. Jeśli pasuje kilka reguł, obowiązuje ta z najdłuższą pasującą ścieżką, a jeśli żadna nie pasuje - dostęp jest dozwolony. W powyższym przykładzie ogólny robot może pobierać artykuły, ale nie może zaglądać do
/admin/
. Robot podpisujący się jako
cpp0x-html-parser-demo
nie może pobierać niczego.
Trzecia grupa dotyczy robota wyszukiwarki Google, który nazywa się
Googlebot
(tak brzmi jego nazwa w linii
User-agent
; pełną listę robotów Google, także tych mniej znanych, znajdziesz w
dokumentacji Google). W przykładzie zabroniliśmy mu wstępu do katalogu
/prywatne/
. Uwaga na częsty błąd: skoro Googlebot ma własną grupę, to grupa z gwiazdką w ogóle go nie dotyczy, więc w powyższym pliku
może wchodzić do
/admin/
(zakaz z grupy ogólnej go nie obejmuje). Jeśli chcesz zabronić czegoś wszystkim robotom, ta reguła musi być powtórzona w każdej grupie.
Kilka uwag:
Co wolno? Licencje, regulaminy i robots.txt w praktyce
Poniższe uwagi są informacją, a nie poradą prawną. Zasady zależą od kraju, od serwisu i od tego, co robimy z pobranymi danymi (analiza na własny użytek to co innego niż publikowanie cudzych treści), a regulaminy i licencje się zmieniają - przed pobieraniem sprawdź aktualne.
Wikipedia i inne projekty Wikimedia
Treści Wikipedii są udostępniane na licencji
Creative Commons Attribution-ShareAlike (CC BY-SA), która pozwala je kopiować i przetwarzać pod warunkiem podania autorstwa i udostępniania przeróbek na tej samej licencji. Licencja dotyczy jednak treści, a nie sposobu dostępu do serwerów - ten reguluje osobna
polityka dla robotów (Robot policy) oraz
polityka User-Agent. Najważniejsze zasady: przestrzegaj pliku
robots.txt, przedstawiaj się w nagłówku
User-Agent
wraz z danymi kontaktowymi (adres e-mail lub strona internetowa) i nie przeciążaj serwerów - dla interfejsu zwracającego HTML dokumentacja podaje dla klientów nieuwierzytelnionych najwyżej 3 równoczesne żądania i mniej niż 5 żądań na sekundę. Przy większych ilościach danych lepsze są oficjalne API i archiwa (dumpy) niż pobieranie stron po jednej. Nie zapominaj też, że obrazki pochodzą z Wikimedia Commons i każdy plik ma własną licencję, więc to, że wolno pobrać stronę, nie znaczy, że wolno wykorzystać każdy obrazek. Przykład parsowania takiej strony jest w tym artykule niżej.
cppreference.com
Treść
cppreference.com jest udostępniona na licencjach CC BY-SA 3.0 i GFDL, a FAQ serwisu pisze wprost, że można ją używać niemal w dowolny sposób, także kopiować, tłumaczyć i publikować jej kopie (zmienioną treść trzeba wydać na równoważnej licencji), a autorzy proszą tylko o podanie linku do cppreference.com. Serwis oferuje też
archiwa do pobrania offline: oficjalna "książka HTML" i "surowe archiwum" (zrobione zresztą poleceniem
wget) pochodzą z 7 czerwca 2019 roku, a
nieoficjalny fork ma nowsze wydanie z 9 lutego 2025 roku. Są też paczki
cppreference-doc-en-html w Debianie i Ubuntu. Na stronach FAQ i archiwów nie ma osobnych zasad dla robotów, a pliku
robots.txt serwis w ogóle nie ma:
wget https://en.cppreference.com/robots.txt
kończy się błędem
404 Not Found, a w przeglądarce ten adres pokazuje zwykłą, pustą stronę wiki ("There is currently no text in this page"). Strony mają w kodzie tylko znacznik
meta robots o wartości
max-image-preview:standard, który nie zabrania ani indeksowania, ani śledzenia odnośników. Zgodnie z RFC 9309 (zob. niżej) brak pliku oznacza, że roboty mogą pobierać dowolne zasoby, co nie zwalnia jednak z umiaru: to serwis prowadzony przez wolontariuszy, więc przy większych ilościach danych lepsze są archiwa. Wniosek: treść wolno kopiować na warunkach licencji, a do większych ilości najlepiej użyć archiwum, zamiast pobierać stronę po stronie.
Gdy nie ma ani regulaminu, ani robots.txt
Brak zakazu nie jest zezwoleniem. Standard
RFC 9309 mówi, że jeśli serwer odpowiada na żądanie pliku
robots.txt błędem z zakresu 4xx (na przykład 404, czyli pliku nie ma), robot może pobierać dowolne zasoby serwisu. (Tak jest na przykład na cppreference.com, które nie ma pliku
robots.txt.) Jeśli jednak plik jest niedostępny z powodu błędu serwera lub sieci (5xx), robot ma założyć, że wszystko jest zabronione. Brak zapisów o robotach niczego nie mówi za to o prawach do samej treści: co do zasady jest ona chroniona prawem autorskim (jeśli właściciel nie wskazał licencji, to wszystkie prawa są zastrzeżone), a w niektórych krajach chronione jest też samo wyszukiwanie w bazach danych. W Unii Europejskiej
dyrektywa 2019/790 przewiduje wyjątki dla eksploracji tekstów i danych: art. 3 dla organizacji badawczych i instytucji dziedzictwa kulturowego (w celach naukowych), a art. 4 dla każdego i w dowolnym celu, ale tylko o ile właściciel praw nie zastrzegł tego w odpowiedni sposób, na przykład w formie czytelnej dla maszyn. Szczegóły wdrożenia różnią się między krajami. W praktyce: jeśli nie masz pewności, zapytaj właściciela serwisu (zwykle wystarczy krótki e-mail), poszukaj API albo archiwum do pobrania, pobieraj tylko to, co potrzebujesz, i nie publikuj cudzych treści.
Ciekawostka: yt-dlp
Ciekawym przykładem jest
yt-dlp, następca youtube-dl: program pobierający filmy i dźwięk z YouTube i z bardzo wielu innych serwisów (na
liście obsługiwanych stron jest ponad 1700 pozycji). Jest bardzo popularny - repozytorium ma ponad 180 tysięcy gwiazdek na GitHubie. Tymczasem
regulamin YouTube zabrania pobierania treści, o ile nie umożliwia tego sam serwis, oraz korzystania z serwisu za pomocą automatów (robotów, scraperów), z wyjątkiem m.in. publicznych wyszukiwarek działających zgodnie z
robots.txt. Użycie takiego narzędzia względem YouTube jest więc sprzeczne z regulaminem, a czy jest też sprzeczne z prawem, zależy od kraju i jest kwestią sporną: w 2020 roku, po zawiadomieniu organizacji RIAA, GitHub usunął repozytorium youtube-dl, po czym
przywrócił je po interwencji EFF. Popularność narzędzia nie jest jednak dowodem, że to, co robi, jest dozwolone.
Pobranie strony
Jako przykład pobierzemy listę artykułów z serwisu
cpp0x.pl, czyli tego, na którym czytasz ten tekst:
cpp0x.pl/artykuly. Zrobimy to raz, poleceniem
wget
(na Ubuntu:
sudo apt install wget
), i zapiszemy stronę w pliku. Dalej pracujemy już na kopii z dysku:
wget -U "cpp0x-html-parser-demo/1.0 (przyklad z artykulu na cpp0x.pl; kontakt: adres@example.com)" -O Cpp0xArtykuly.html https://cpp0x.pl/artykuly/
Opcja
-U
ustawia nagłówek
User-Agent
: przedstawiamy się nazwą programu i danymi kontaktowymi (wpisz tu własny adres zamiast
adres@example.com
), a opcja
-O
podaje nazwę pliku, w którym zostanie zapisana strona.
Serwisy internetowe nie są darmowym źródłem danych, dlatego przy pobieraniu stron trzymaj się kilku zasad: pobieraj stronę raz i pracuj na kopii z dysku (testowanie parsera na żywej stronie w pętli może zostać uznane za atak
DDoS), przedstaw się w nagłówku
User-Agent
, nie pobieraj więcej niż potrzebujesz oraz przestrzegaj pliku
robots.txt i regulaminu serwisu.
Czego szukamy w kodzie strony
Przykład wypisze listę artykułów wraz z odnośnikami i opisami. Aby to zrobić, zajrzyjmy do kodu strony (dla czytelności dodałem wcięcia, oryginał jest w jednej linii):
<h2 class="Static"><a href="/artykuly/12">Konfiguracja</a></h2>
<div class="FlatList">
<div class="Lesson">
<div class="Title"><a href="/artykuly/48">[C++] Instalacja bibliotek w Code::Blocks</a> <span class="RelCategory">(artykuł)</span></div>
<div class="Description">Artykuł opisuje w jaki sposób przeprowadza się konfigurację nowych bibliotek w środowisku Code::Blocks.</div>
</div>
<div class="Lesson">
<div class="Title"><a href="/artykuly/102">Narzędzia dla programistów C++</a> <span class="RelCategory">(kategoria)</span></div>
<div class="Description"></div>
</div>
</div>
Każdy wpis to blok
div
z klasą
Lesson
, a w nim tytuł z odnośnikiem i opis. Kilka rzeczy, na które trzeba uważać:
Program korzysta z prostego pliku pomocniczego
common.hpp
: wczytuje plik do napisu i normalizuje białe znaki (kod strony ma znaki nowej linii w środku opisów).
#pragma once
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <print>
#include <sstream>
#include <string>
inline std::string readFile( const std::string & path )
{
std::ifstream in( path, std::ios::binary );
if( !in )
{
std::println( std::cerr, "Nie można otworzyć pliku: {}", path );
std::exit( 1 );
}
std::ostringstream ss;
ss << in.rdbuf();
return ss.str();
}
inline std::string squeezeWhitespace( const std::string & s )
{
std::string out;
bool inSpace = true; for( unsigned char c: s )
{
if( c == ' ' || c == '\t' || c == '\n' || c == '\r' )
{
inSpace = true;
}
else
{
if( inSpace && !out.empty() )
out += ' ';
out += static_cast < char >( c );
inSpace = false;
}
}
return out;
}
Program parsujący stronę
Mając poznane klocki, składamy program: funkcje
select()
,
textContent()
i
attribute()
pochodzą z pliku
lexbor_select.hpp
, a odnośniki zamieniamy na pełne adresy modułem URL względem adresu, z którego pobraliśmy stronę. Cała logika sprowadza się do trzech selektorów:
div.Lesson
(bloki wpisów) oraz
.Title > a
i
.Description
wewnątrz każdego z nich.
Plik
11_cpp0x_articles.cpp
:
#include <lexbor/html/html.h>
#include <lexbor/url/url.h>
#include <memory>
#include <print>
#include <string>
#include <vector>
#include "common.hpp"
#include "lexbor_select.hpp"
#include "lexbor_utils.hpp"
static lxb_status_t appendChunk( const lxb_char_t * data, size_t length, void * context )
{
static_cast < std::string * >( context )->append( view( data, length ) );
return LXB_STATUS_OK;
}
static std::string resolveUrl( lxb_url_parser_t * parser, const lxb_url_t * base, const std::string & href )
{
lxb_url_parser_clean( parser );
lxb_url_t * url = lxb_url_parse( parser, base, bytes( href ), href.size() );
if( !url )
return href;
std::string absolute;
lxb_url_serialize( url, appendChunk, & absolute, false );
return absolute;
}
int main( int argc, char * * argv )
{
const std::string html = readFile( argc > 1 ? argv[ 1 ]: "Cpp0xArtykuly.html" );
std::unique_ptr < lxb_html_document_t, decltype( & lxb_html_document_destroy ) > document { lxb_html_document_create(), & lxb_html_document_destroy };
if( !document || lxb_html_document_parse( document.get(), bytes( html ), html.size() ) != LXB_STATUS_OK )
{
std::println( stderr, "Nie udało się sparsować dokumentu" );
return 1;
}
lxb_url_parser_t urlParser;
lxb_url_parser_init( & urlParser, nullptr );
constexpr std::string_view baseAddress = "https://cpp0x.pl/artykuly/";
lxb_url_t * baseUrl = lxb_url_parse( & urlParser, nullptr, bytes( baseAddress ), baseAddress.size() );
int number = 1;
for( lxb_dom_node_t * lesson: select( lxb_dom_interface_node( document.get() ), "div.Lesson" ) )
{
std::string title, url, description;
if( Nodes links = select( lesson, ".Title > a" ); !links.empty() )
{
title = squeezeWhitespace( textContent( links.front() ) );
url = resolveUrl( & urlParser, baseUrl, attribute( links.front(), "href" ) );
}
if( Nodes descriptions = select( lesson, ".Description" ); !descriptions.empty() )
description = squeezeWhitespace( textContent( descriptions.front() ) );
std::println( "{}. {}\n {}\n {}", number++, title, url, description );
}
lxb_url_parser_destroy( & urlParser, false ); lxb_url_memory_destroy( baseUrl );
}
Uruchomienie (pierwszy argument to nazwa pliku z kopią strony, domyślnie
Cpp0xArtykuly.html
):
cmake -S . -B build -DEXAMPLE=11_cpp0x_articles.cpp
cmake --build build --parallel $(nproc)
./build/11_cpp0x_articles Cpp0xArtykuly.html
Wynik wygląda tak:
1. [C++] Instalacja bibliotek w Code::Blocks
https://cpp0x.pl/artykuly/48
Artykuł opisuje w jaki sposób przeprowadza się konfigurację nowych bibliotek w środowisku Code::Blocks.
2. [C++] Instalacja bibliotek w Dev-C++
https://cpp0x.pl/artykuly/49
Artykuł opisuje w jaki sposób przeprowadza się konfigurację nowych bibliotek w środowisku Dev-C++. Proces instalacji nowej biblioteki został omówiony na przykładzie biblioteki freeglut.
3. Instalacja i uruchomienie Code::Blocks
https://cpp0x.pl/artykuly/6
Artykuł opisujący proces instalacji i podstawowej konfiguracji środowiska Code::Blocks.
(...)
W tym kodzie jest kilka miejsc, w których łatwo o błąd (część z nich znalazłem uruchamiając programy pod AddressSanitizerem):
Wikipedia: wejście do zagnieżdżonych elementów
Na koniec przykład na prawdziwej, dużej stronie: polska Wikipedia i
lista polskich rodów książęcych z ich herbami. Wikipedia dopuszcza korzystanie z robotów pod warunkami opisanymi wyżej (robots.txt,
User-Agent
z danymi kontaktowymi, umiar), a treść artykułów jest na licencji CC BY-SA. Stronę pobieramy raz, tak jak poprzednio:
wget -U "cpp0x-html-parser-demo/1.0 (przyklad z artykulu na cpp0x.pl; kontakt: adres@example.com)" -O RodyKsiazece.html https://pl.wikipedia.org/wiki/Polskie_rody_ksi%C4%85%C5%BC%C4%99ce
Adres jest przekierowaniem do artykułu "Lista polskich rodów książęcych" (Wikipedia zwraca wtedy treść docelowej strony pod adresem przekierowania, więc
wget
nie widzi żadnego przekierowania HTTP). Plik ma ponad 1 MB, a znajdują się w nim 23 tabele (po jednej na sekcję) z łącznie 188 rodami. Każdy ród to wiersz tabeli, a w nim herb (obrazek) i nazwa rodu. Tak wygląda jeden wiersz (dla czytelności usunąłem część atrybutów):
<table class="wikitable">
<tr><th>Herb</th><th>Ród</th><th>Uznanie na podstawie</th><th>Status rodziny</th></tr>
<tr>
<td><span typeof="mw:File"><a href="https://pl.wikipedia.org/wiki/Plik:POL_Przemysł_II_1295_COA.svg" class="mw-file-description"><img src="//thumb.wikimedia.org/wikipedia/commons/thumb/b/b2/POL_Przemys%C5%82_II_1295_COA.svg/120px-POL_Przemys%C5%82_II_1295_COA.svg.png?utm_source=pl.wikipedia.org&utm_campaign=parser&utm_content=thumbnail" ...></a></span></td>
<td><b><a href="https://pl.wikipedia.org/wiki/Piastowie" title="Piastowie">Piastowie</a></b></td>
<td>Władcy Polski</td>
<td>wymarli w 1675<sup>...</sup></td>
</tr>
...
</table>
Droga do herbu prowadzi więc przez kilka poziomów: tabela, wiersz, pierwsza komórka, obrazek i jego atrybut
src
. Przy tej stronie łatwo się potknąć, bo (sprawdziłem to na całym pliku):
Plik
12_wikipedia_herby.cpp
:
#include <lexbor/html/html.h>
#include <memory>
#include <print>
#include <string>
#include "common.hpp"
#include "lexbor_select.hpp"
#include "lexbor_utils.hpp"
int main( int argc, char * * argv )
{
const std::string html = readFile( argc > 1 ? argv[ 1 ]: "RodyKsiazece.html" );
std::unique_ptr < lxb_html_document_t, decltype( & lxb_html_document_destroy ) > document { lxb_html_document_create(), & lxb_html_document_destroy };
if( !document || lxb_html_document_parse( document.get(), bytes( html ), html.size() ) != LXB_STATUS_OK )
{
std::println( stderr, "Nie udało się sparsować dokumentu" );
return 1;
}
int number = 0;
for( lxb_dom_node_t * table: select( lxb_dom_interface_node( document.get() ), "table.wikitable" ) )
{
for( lxb_dom_node_t * row: select( table, "tr" ) )
{
Nodes images = select( row, "td:nth-child(1) img" ); Nodes names = select( row, "td:nth-child(2) > b" ); if( images.empty() || names.empty() )
continue; std::string source = attribute( images.front(), "src" );
source = source.substr( 0, source.find( '?' ) ); if( source.starts_with( "//" ) ) source.insert( 0, "https:" ); std::println( "{:3}. {:<28} {}", ++number, squeezeWhitespace( textContent( names.front() ) ), source );
}
}
std::println( "Razem: {} rodów", number );
}
Uruchomienie:
cmake -S . -B build -DEXAMPLE=12_wikipedia_herby.cpp
cmake --build build --parallel $(nproc)
./build/12_wikipedia_herby RodyKsiazece.html
Wynik (skróciłem, adresy są długie):
1. Piastowie https://thumb.wikimedia.org/wikipedia/commons/thumb/b/b2/POL_Przemys%C5%82_II_1295_COA.svg/120px-POL_Przemys%C5%82_II_1295_COA.svg.png
2. Piastowie mazowieccy https://thumb.wikimedia.org/wikipedia/commons/thumb/d/d6/Herb_Ksi%C4%99stwa_Czerskiego.PNG/120px-Herb_Ksi%C4%99stwa_Czerskiego.PNG
3. Piastowie śląscy https://thumb.wikimedia.org/wikipedia/commons/thumb/7/7a/POL_wojew%C3%B3dztwo_dolno%C5%9Bl%C4%85skie_COA.svg/120px-POL_wojew%C3%B3dztwo_dolno%C5%9Bl%C4%85skie_COA.svg.png
(...)
188. Wettynowie https://thumb.wikimedia.org/wikipedia/commons/thumb/8/86/Wappen_Freistaat_Sachsen_von_Wettinern_%26_Askanier_ohne_Kontur.svg/120px-Wappen_Freistaat_Sachsen_von_Wettinern_%26_Askanier_ohne_Kontur.svg.png
Razem: 188 rodów
Program wykonuje sporo wywołań
select()
(dla każdego wiersza dwa), ale nawet dla tak dużej strony całość trwa u mnie około 20 milisekund. Tak samo jak poprzednio, wynik porównałem z niezależnym parserem (Python z BeautifulSoup) - wszystkie 188 nazw rodów i adresów herbów zgadza się.
Gdy samo pobranie HTML-a nie wystarcza
Wszystko, co pokazałem, zakłada, że dane, których szukamy, są w kodzie HTML, który zwraca serwer. Nie zawsze tak jest.
Strony dynamiczne
Wiele współczesnych stron zwraca prawie pusty HTML, a właściwą treść dociąga dopiero JavaScript uruchomiony w przeglądarce (dodatkowymi zapytaniami do serwera). Ani
wget
, ani libcurl, ani Lexbor nie uruchamiają JavaScriptu, więc zobaczą tylko ten pusty szkielet. Co wtedy?
Interakcja ze stroną: logowanie i formularze
Czasem dane są za formularzem: trzeba się zalogować, wypełnić wyszukiwarkę albo pobrać coś, uzupełnić i wysłać z powrotem. Protokół HTTP nie pamięta użytkownika, więc serwis rozpoznaje nas po
ciasteczkach (cookies), które dostajemy po zalogowaniu i musimy odsyłać w kolejnych zapytaniach. Formularz wysyła się zapytaniem POST z polami z formularza, a często także z ukrytym polem z tokenem zabezpieczającym (CSRF), który trzeba wcześniej odczytać ze strony - tu przydaje się parser HTML-a. libcurl potrafi obsługiwać zarówno ciasteczka, jak i wysyłanie formularzy, a przy skomplikowanych stronach (skrypty, wieloetapowe logowanie) łatwiej użyć przeglądarki sterowanej Selenium. Pamiętaj jednak o tym, że logując się, zwykle akceptujemy regulamin serwisu, a automatyczne logowanie bywa w nim zabronione. Jeśli serwis udostępnia API z uwierzytelnianiem, użyj go zamiast udawać przeglądarkę, a swoich haseł nigdy nie zapisuj w kodzie programu.
Pobieranie strony w programie: libcurl
Dotąd stronę pobieraliśmy poleceniem
wget
. Jeśli pobieranie ma być częścią programu, potrzebujemy biblioteki HTTP. Wybrałem
libcurl, bo tak jak Lexbor jest napisana w C (cały artykuł jest o używaniu bibliotek C z C++), jest dostępna praktycznie wszędzie i ma wszystko, czego potrzeba: HTTPS, przekierowania, ciasteczka, wysyłanie formularzy. Ma też wadę: API w C, w którym wszystko ustawia się wieloma wywołaniami
curl_easy_setopt()
, jest dość niewygodne. Jeśli wolisz bardziej C++-owy interfejs, w vcpkg są między innymi
biblioteka cpr (nakładka na libcurl),
cpp-httplib i
Boost.Beast.
Doinstalowujemy libcurl (jest zależna od OpenSSL-a, więc ta instalacja trwa dłużej - u mnie kilka minut na jednym rdzeniu):
./vcpkg/vcpkg install curl
Do tego przykładu potrzebujemy CMake'a, który linkuje też libcurl. Zapisujemy go jako
CMakeLists.txt
(w miejsce poprzedniego); poza libcurl różni się tylko tym, że domyślnie buduje właśnie ten przykład:
cmake_minimum_required(VERSION 3.25)
project(lexbor_demo CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(lexbor CONFIG REQUIRED)
find_package(CURL REQUIRED)
# Nazwa celu zależy od tripletu: biblioteki dynamiczne -> lexbor::lexbor, statyczne (np. x64-linux) -> lexbor::lexbor_static
if(TARGET lexbor::lexbor)
set(LEXBOR_TARGET lexbor::lexbor)
else()
set(LEXBOR_TARGET lexbor::lexbor_static)
endif()
# Plik z przykładem do zbudowania: domyślnie przykład z pobieraniem, inny wskazujemy opcją -DEXAMPLE=03_dom.cpp
set(EXAMPLE 13_download.cpp CACHE FILEPATH "Plik źródłowy przykładu")
get_filename_component(EXAMPLE_NAME ${EXAMPLE} NAME_WE) # program nazywa się tak jak plik: 13_download
add_executable(${EXAMPLE_NAME} ${EXAMPLE})
target_link_libraries(${EXAMPLE_NAME} PRIVATE ${LEXBOR_TARGET} CURL::libcurl)
Poniższy program używa libcurl i zapisuje stronę w pliku - dopóki plik jest młodszy niż 24 godziny, kolejne uruchomienia korzystają z kopii z dysku i nie odpytują serwera. Obowiązują te same zasady co przy
wget
: pobieraj stronę raz, przedstaw się w
User-Agent
(wpisz własne dane kontaktowe zamiast
adres@example.com
) i sprawdź
robots.txt.
Plik
13_download.cpp
:
#include <curl/curl.h>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <print>
#include <stdexcept>
#include <string>
namespace fs = std::filesystem;
static size_t writeToString( char * data, size_t size, size_t count, void * userdata )
{
static_cast < std::string * >( userdata )->append( data, size * count );
return size * count;
}
std::string download( const std::string & url )
{
CURL * curl = curl_easy_init();
if( !curl )
throw std::runtime_error( "curl_easy_init() failed" );
std::string body;
char error[ CURL_ERROR_SIZE ] = { };
curl_easy_setopt( curl, CURLOPT_URL, url.c_str() );
curl_easy_setopt( curl, CURLOPT_FOLLOWLOCATION, 1L ); curl_easy_setopt( curl, CURLOPT_FAILONERROR, 1L ); curl_easy_setopt( curl, CURLOPT_TIMEOUT, 20L ); curl_easy_setopt( curl, CURLOPT_ERRORBUFFER, error );
curl_easy_setopt( curl, CURLOPT_USERAGENT, "cpp0x-html-parser-demo/1.0 (przyklad z artykulu na cpp0x.pl; kontakt: adres@example.com)" );
curl_easy_setopt( curl, CURLOPT_WRITEFUNCTION, writeToString );
curl_easy_setopt( curl, CURLOPT_WRITEDATA, & body );
const CURLcode result = curl_easy_perform( curl );
curl_easy_cleanup( curl );
if( result != CURLE_OK )
throw std::runtime_error( std::string( "Pobieranie nie powiodło się: " ) +( error[ 0 ] ? error
: curl_easy_strerror( result ) ) );
return body;
}
std::string downloadCached( const std::string & url, const fs::path & cacheFile, std::chrono::hours maxAge )
{
if( fs::exists( cacheFile ) )
{
const auto age = fs::file_time_type::clock::now() - fs::last_write_time( cacheFile );
if( age < maxAge )
{
std::println( std::cerr, "Używam kopii z dysku: {}", cacheFile.string() );
std::ifstream in( cacheFile, std::ios::binary );
return { std::istreambuf_iterator < char >( in ), { } };
}
}
std::println( std::cerr, "Pobieram: {}", url );
std::string body = download( url );
std::ofstream( cacheFile, std::ios::binary ) << body;
return body;
}
int main( int argc, char * * argv )
{
const std::string url = argc > 1 ? argv[ 1 ]: "https://cpp0x.pl/artykuly/";
const fs::path cache = argc > 2 ? argv[ 2 ]: "Cpp0xArtykuly.html";
try
{
curl_global_init( CURL_GLOBAL_DEFAULT );
const std::string html = downloadCached( url, cache, std::chrono::hours( 24 ) );
std::println( std::cerr, "Mamy {} bajtów HTML-a", html.size() );
curl_global_cleanup();
}
catch( const std::exception & e )
{
std::println( std::cerr, "Błąd: {}", e.what() );
return 1;
}
}
Uruchomienie (bez argumentów pobierze stronę z listą artykułów do pliku
Cpp0xArtykuly.html
):
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=g++-14 -DCMAKE_TOOLCHAIN_FILE=$PWD/vcpkg/scripts/buildsystems/vcpkg.cmake
cmake --build build --parallel $(nproc)
./build/13_download
./build/13_download https://cpp0x.pl/artykuly/ Cpp0xArtykuly.html
Ostatnie polecenie to to samo co przedostatnie, tylko z jawnie podanymi argumentami: adresem strony i nazwą pliku z kopią.
Podsumowanie
Lexbor pozwala wyciągnąć dane ze strony krótkim kodem, a jego moduły pokrywają też inne potrzeby przy pracy z HTML-em: od adresów URL i kodowań znaków po normalizację tekstu.
Lexbor, podobnie jak inne parsery HTML-a, nie wykonuje JavaScriptu. Jeśli interesujące nas dane pojawiają się na stronie dopiero po uruchomieniu skryptów, samo pobranie i sparsowanie HTML-a nie wystarczy - trzeba poszukać API, z którego korzysta strona, albo użyć prawdziwej przeglądarki sterowanej z programu, na przykład Selenium (zob. sekcja o stronach dynamicznych).
Bez względu na wybraną bibliotekę pamiętaj, że kod strony może się zmienić bez uprzedzenia: dobrze jest napisać kilka prostych testów sprawdzających, czy parser znalazł oczekiwaną liczbę elementów, żeby zmianę zauważyć od razu, a nie po tygodniu i uniknąć crasha naszej aplikacji. Również licencja względem strony może się zmienić - prawdopodobnym jest, że ktoś nie zabronił kiedyś, my zadowoleni napisaliśmy scrapper i potem pojawiła się zmiana licencyjna.
Bibliografia
Autor artykułu
Artykuł powstał we IX 2026.