Zum Inhalt springen
Zurück zum Blog

Web-Scraping mit C und C++ unter Verwendung von libcurl und lexbor

Raluca PenciucZuletzt aktualisiert am 25 min read
Web-Scraping mit C und C++ unter Verwendung von libcurl und lexbor
Kurzfassung: Für Web-Scraping in C und C++ verwenden Sie libcurl zum Herunterladen von Seiten und lexbor zum Parsen von HTML5 mit CSS-Selektoren; beide sind reines C, sodass ein Stack beide Sprachen abdeckt. Wählen Sie C++, wenn der Scraper Teil einer nativen Codebasis ist, auf eingeschränkter Hardware läuft oder große HTML-Mengen parst. Für schnelle einmalige Aufgaben ist Python schneller geschrieben.

Dieser Leitfaden zeigt Ihnen, wie Sie Websites in C und C++ (nicht C#) mit zwei portablen C-Bibliotheken auswerten können: libcurl zum Herunterladen von Seiten und lexbor zum Parsen von HTML5 mit CSS-Selektoren. Beide sind in reinem C geschrieben, sodass ein einziger Stack für beide Sprachen ausreicht. Außerdem lassen sie sich unter Windows, macOS und Linux auf dieselbe Weise installieren.

Sie erstellen einen C++20-Scraper für eine öffentliche Übungsbuchhandlung namens „Books to Scrape“. Dieser berücksichtigt die Paginierung, ruft jede Produktseite mit einem kleinen Thread-Pool auf und speichert saubere CSV- und JSON-Dateien. Anschließend bearbeiten Sie eine JavaScript-Seite auf „Quotes to Scrape“ auf drei verschiedene Arten: indem Sie die Daten direkt aus einem Script-Tag auslesen, sie mit Headless Chrome rendern und sie an eine Scraping-API übergeben. Abschließend sehen Sie den Kern-Scraper noch einmal als reines C-Programm. CMake und vcpkg erstellen alles aus einem einzigen Manifest.

Was du erstellen wirst:

  1. Einen Crawler, der „Weiter“-Links auf Produktlistenseiten höflich folgt.
  2. Einen Schritt für Produktseiten, der UPCs und Lagerbestände mit vier Worker-Threads hinzufügt.
  3. RFC 4180 CSV- und geordneter JSON-Export mit ganzzahligen Preisen.
  4. Drei Möglichkeiten, Daten von einer JavaScript-Seite abzurufen.
  5. Ein einfacher C17-Scraper, der auf denselben beiden Bibliotheken basiert.

Schreiben Sie stattdessen lieber C#? Unser C#-Leitfaden zum Web-Scraping behandelt HttpClient, AngleSharp und Playwright für .NET.

Eignet sich C++ für das Web-Scraping?

Ja, wenn der Scraper in eine bestehende C- oder C++-Codebasis integriert werden muss, auf Hardware mit begrenzten Ressourcen läuft oder große Mengen an HTML-Daten mit wenig Arbeitsspeicher verarbeiten muss. Für einmalige Aufgaben lässt sich Python schneller programmieren. Da es für C++ keine offizielle Playwright-Bindung gibt, benötigen JavaScript-lastige Websites Headless Chrome oder eine Scraping-API.

C++ eignet sich besonders für:

  • Embedded- und Edge-Geräte, bei denen eine kleine native Binärdatei die Bereitstellung einer Python-Laufzeitumgebung übertrifft.
  • Bestehende native Dienste wie Handelssysteme, Suchindexierer und Überwachungsagenten, bei denen der Scraper ein weiteres Modul ist und kein separater Prozess in einer anderen Sprache.
  • Parsing mit hohem Datenaufkommen, bei dem sich eine geringere CPU- und Speicherauslastung pro Seite in echten Einsparungen auf den Servern niederschlägt.
  • Teams, die denselben Parser in C und C++ nutzen möchten, da libcurl und lexbor C-Bibliotheken sind, die mit C++-Wrappern versehen sind.

C++ eignet sich weniger für:

  • Schnelle Einmal-Skripte, bei denen man mit einem Python-Notebook schneller zu einer CSV-Datei gelangt.
  • Aufwändige Browser-Automatisierung mit Anmeldungen, Klicks und Wartezeiten. Playwright unterstützt JavaScript, Python, Java und .NET, jedoch nicht C++.
  • Teams, in denen niemand regelmäßig C++-Code überprüft.

Die manuelle Speicherverwaltung ist das Hauptrisiko bei nativen Scrapern, da jede C-Bibliothek Ihnen rohe Zeiger zur Freigabe übergibt. Dieser Leitfaden verpackt jeden C-Handle in eine kleine RAII-Klasse und zeigt, wie man das Ergebnis mit Sanitizern überprüft, sodass eine versäumte Bereinigung bereits im Testlauf auffällt und nicht erst als Speicherleck in der Produktion auftritt.

Was benötigen Sie für das Web-Scraping in C und C++?

Sie benötigen einen C++20-Compiler, CMake und drei Bibliotheken: libcurl zum Herunterladen von Seiten, lexbor zum Parsen von HTML5 und Ausführen von CSS-Selektoren sowie nlohmann/json zum Schreiben von JSON. libcurl und lexbor sind reine C-Bibliotheken, daher funktioniert derselbe Stack auch in C. vcpkg installiert alle drei unter Windows, macOS und Linux.

Ältere Tutorials empfehlen immer noch Bibliotheken, die bereits archiviert wurden oder mit realem HTML nicht zurechtkommen.

C- und C++-Scraping-Bibliotheken (vcpkg-Versionen, November 2026)

Bibliothek

Sprache

Verwendung für

Version (vcpkg)

Status

libcurl

C

HTTP(S)-Anfragen, Proxys, Cookies

8.23.0

Aktiv; im Lieferumfang der meisten Betriebssysteme enthalten

cpr

C++

Wrapper im „requests“-Stil um libcurl

1.15.1

Aktiv

cpp-httplib

C++ (nur Header)

Einfacher HTTP-Client und -Server

0.63.0

Aktiv

Boost.Beast

C++

Low-Level-Asynchron-HTTP und WebSocket

1.92.0

Aktiv; steile Lernkurve

lexbor

C

HTML5-Parsing + CSS-Selektoren

3.0.1

Aktiv; keine externen Abhängigkeiten

libxml2

C

HTML/XML-Parsing + XPath

2.15.4

Aktiv; der HTML-Parser ist kein vollständiger HTML5-Baumgenerator

Gumbo

C

HTML5-Parsing (keine Selektoren)

0.14.1 (Community-Fork)

Google-Repo archiviert; Fork auf Codeberg

pugixml

C++

Nur XML (wohlgeformte Eingabe)

1.16

Aktiv; nicht für echtes HTML geeignet

nlohmann/json

C++ (nur Header)

JSON-Ausgabe und Parsen von Script-JSON

3.12.0

Aktiv

In dieser Anleitung werden libcurl, lexbor und nlohmann/json verwendet. Beide Kernbibliotheken sind in C geschrieben, werden aktiv gepflegt und sind in vcpkg und Conan als Pakete verfügbar. lexbor hat keinerlei externe Abhängigkeiten, und libcurl ist bereits in fast jedem Betriebssystem enthalten. Sie erhalten einen Parser auf Browser-Niveau und einen praxiserprobten HTTP-Client, ohne dass Sie etwas Exotisches installieren müssen. Der Code, den Sie für C++ schreiben, lässt sich zudem fast Zeile für Zeile in reines C übertragen.

Richten Sie das Projekt mit CMake und vcpkg ein

Das Projekt verwendet ein vcpkg-Manifest, eine kleine JSON-Datei, in der die Bibliotheken aufgelistet sind. Wenn CMake das Projekt mit der Toolchain-Datei von vcpkg konfiguriert, liest vcpkg das Manifest und baut alle Abhängigkeiten für Ihre Plattform. Details finden Sie unter „vcpkg-Manifest-Modus“. Hier ist die Struktur, die Sie am Ende erhalten:

bookscraper/
├── CMakeLists.txt
├── vcpkg.json
├── src/
│   ├── http.hpp      # libcurl client: timeouts, retries, Retry-After
│   ├── html.hpp      # lexbor RAII wrapper: parse + CSS selectors
│   ├── books.hpp     # Book struct, listing and detail parsers, URL resolution
│   ├── csv.hpp       # RFC 4180 CSV writer
│   ├── robots.hpp    # robots.txt check
│   └── main.cpp      # crawl, JavaScript, and Scraper API commands
├── tests/parser_test.cpp
└── fixtures/         # saved pages for offline tests

vcpkg.json

{
  "name": "bookscraper",
  "version": "1.0.0",
  "dependencies": ["curl", "lexbor", "nlohmann-json"]
}

CMakeLists.txt

cmake_minimum_required(VERSION 3.21)
project(bookscraper CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(CURL REQUIRED)
find_package(lexbor CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)
# lexbor exports lexbor::lexbor for shared builds and lexbor::lexbor_static for static ones
if(TARGET lexbor::lexbor)
  set(LEXBOR_TARGET lexbor::lexbor)
else()
  set(LEXBOR_TARGET lexbor::lexbor_static)
endif()

add_executable(bookscraper src/main.cpp)
target_link_libraries(bookscraper PRIVATE CURL::libcurl ${LEXBOR_TARGET} nlohmann_json::nlohmann_json)

enable_testing()
add_executable(parser_test tests/parser_test.cpp)
target_include_directories(parser_test PRIVATE src)
target_compile_definitions(parser_test PRIVATE FIXTURES="${CMAKE_CURRENT_SOURCE_DIR}/fixtures")
target_link_libraries(parser_test PRIVATE CURL::libcurl ${LEXBOR_TARGET})
add_test(NAME parser COMMAND parser_test)

Der if(TARGET ...) Block ist wichtig. vcpkg erstellt „lexbor“ unter Linux standardmäßig als statische Bibliothek, und der statische Build exportiert einen anderen CMake-Zielnamen als der Shared-Build. Wenn Sie beide Zeilen beibehalten, vermeiden Sie einen verwirrenden Link-Fehler.

Klonen und initialisieren Sie vcpkg einmal, dann konfigurieren, erstellen und testen Sie. Unter Windows führen Sie bootstrap-vcpkg.bat und verwenden Sie Backslashes:

git clone https://github.com/microsoft/vcpkg.git
./vcpkg/bootstrap-vcpkg.sh
cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=./vcpkg/scripts/buildsystems/vcpkg.cmake -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build

Ausgabe (Auszug)

-- Found nlohmann_json: …/vcpkg_installed/x64-linux/share/nlohmann_json/nlohmann_jsonConfig.cmake (found version "3.12.0")
-- Configuring done (67.9s)
[100%] Built target bookscraper
1/1 Test #1: parser ...........................   Passed    0.00 sec
100% tests passed, 0 tests failed out of 1

Der erste `configure`-Lauf erstellt die Bibliotheken aus dem Quellcode, was eine Minute dauert. Spätere `configure`-Läufe verwenden diese bereits.

Wenn du lieber Systempakete verwenden möchtest, gibt es für Debian und Ubuntu sudo apt install libcurl4-openssl-dev nlohmann-json3-dev. lexbor ist in Debian Stable nicht enthalten, installieren Sie es daher mit vcpkg oder kompilieren Sie es aus dem Quellcode. Unter macOS brew install curl lexbor nlohmann-json werden alle drei installiert.

Visual Studio: Öffne den Projektordner in Visual Studio 2022 oder höher, das CMakeLists.txt automatisch erkennt. In CMakePresets.json, stellen Sie CMAKE_TOOLCHAIN_FILE auf den vcpkg-Pfad scripts/buildsystems/vcpkg.cmake, und das Manifest installiert libcurl, lexbor und nlohmann/json beim ersten „configure“. Sie müssen keine Include-Pfade, Bibliothekspfade oder Umgebungsvariablen manuell festlegen.

Wie kann man eine Webseite in C++ auslesen?

Laden Sie die Seite mit libcurl in einen std::string herunter, analysieren Sie sie mit lexbor und wählen Sie Elemente mit CSS-Selektoren wie „article.product_pod“ aus. Lies den Text oder die Attribute jedes Elements aus und wandle dann Preise und relative Links in saubere Werte um. Verpacke die C-Handles in kleine RAII-Klassen, damit jeder Parser und jedes Handle automatisch freigegeben wird.

Beginnen Sie mit dem Parser, da dies der Teil ist, mit dem Sie am häufigsten zu tun haben werden. Die API von lexbor ist reines C: Man erstellt Objekte, initialisiert sie, nutzt sie und löscht sie. Die Html unten stehende Klasse verbirgt diesen Lebenszyklus. Ihr Destruktor gibt alles frei, selbst wenn mitten auf einer Seite eine Ausnahme ausgelöst wird.

// html.hpp: a small RAII layer over lexbor's HTML5 parser and CSS selector engine
#pragma once
#include <lexbor/html/html.h>
#include <lexbor/css/css.h>
#include <lexbor/selectors/selectors.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>

using Node = lxb_dom_node_t*;

class Html {
public:
    explicit Html(std::string_view html)
        : doc_(lxb_html_document_create()), css_(lxb_css_parser_create()), mem_(lxb_css_memory_create()),
          css_sel_(lxb_css_selectors_create()), sel_(lxb_selectors_create()) {
        if (!doc_ || !css_ || !mem_ || !css_sel_ || !sel_
            || lxb_html_document_parse(doc_, reinterpret_cast<const lxb_char_t*>(html.data()), html.size()) != LXB_STATUS_OK
            || lxb_css_parser_init(css_, nullptr) != LXB_STATUS_OK
            || lxb_css_memory_init(mem_, 128) != LXB_STATUS_OK
            || lxb_css_selectors_init(css_sel_) != LXB_STATUS_OK
            || lxb_selectors_init(sel_) != LXB_STATUS_OK) {
            release();
            throw std::runtime_error("lexbor: could not parse the document");
        }
        // Every compiled selector lives in one memory pool that is freed with the document.
        lxb_css_parser_memory_set(css_, mem_);
        lxb_css_parser_selectors_set(css_, css_sel_);
    }
    ~Html() { release(); }
    Html(const Html&) = delete;
    Html& operator=(const Html&) = delete;

    // All elements under `scope` (the whole document by default) that match a CSS selector.
    std::vector<Node> select(std::string_view css, Node scope = nullptr) const {
        std::vector<Node> found;
        lxb_css_selector_list_t* list = compile(css);
        lxb_selectors_find(sel_, scope ? scope : lxb_dom_interface_node(doc_), list,
            [](Node node, lxb_css_selector_specificity_t, void* out) -> lxb_status_t {
                static_cast<std::vector<Node>*>(out)->push_back(node);
                return LXB_STATUS_OK;
            }, &found);
        return found;
    }

    Node first(std::string_view css, Node scope = nullptr) const {
        auto nodes = select(css, scope);
        return nodes.empty() ? nullptr : nodes.front();
    }

    std::string text(Node node) const {
        if (!node) return {};
        size_t len = 0;
        lxb_char_t* raw = lxb_dom_node_text_content(node, &len);   // allocated by lexbor
        std::string out(reinterpret_cast<char*>(raw), len);
        lxb_dom_document_destroy_text(lxb_dom_interface_document(doc_), raw);
        return out;
    }

    static std::string attr(Node node, std::string_view name) {
        if (!node) return {};
        size_t len = 0;
        const lxb_char_t* value = lxb_dom_element_get_attribute(lxb_dom_interface_element(node),
            reinterpret_cast<const lxb_char_t*>(name.data()), name.size(), &len);
        return value ? std::string(reinterpret_cast<const char*>(value), len) : std::string{};
    }

private:
    // Compile each selector once per document; "h3 a" inside 20 cards is parsed a single time.
    lxb_css_selector_list_t* compile(std::string_view css) const {
        auto it = cache_.find(std::string(css));
        if (it != cache_.end()) return it->second;
        auto* list = lxb_css_selectors_parse(css_, reinterpret_cast<const lxb_char_t*>(css.data()), css.size());
        if (!list || css_->status != LXB_STATUS_OK) throw std::invalid_argument("bad CSS selector: " + std::string(css));
        return cache_.emplace(std::string(css), list).first->second;
    }
    void release() {
        if (sel_) lxb_selectors_destroy(sel_, true);
        if (css_sel_) lxb_css_selectors_destroy(css_sel_, true);
        if (css_) lxb_css_parser_destroy(css_, true);
        if (mem_) lxb_css_memory_destroy(mem_, true);     // frees every compiled selector list
        if (doc_) lxb_html_document_destroy(doc_);
        sel_ = nullptr; css_sel_ = nullptr; css_ = nullptr; mem_ = nullptr; doc_ = nullptr;
    }
    lxb_html_document_t* doc_;
    lxb_css_parser_t* css_;
    lxb_css_memory_t* mem_;
    lxb_css_selectors_t* css_sel_;
    lxb_selectors_t* sel_;
    mutable std::unordered_map<std::string, lxb_css_selector_list_t*> cache_;
};

Drei Details zu lexbor solltest du kennen, bevor du diese Klasse änderst:

  1. Das Dokument, der CSS-Parser und die Selektor-Engine sind separate Objekte. Du benötigst alle drei, um eine Abfrage auszuführen, und die Klasse erstellt und löscht sie an einer Stelle, sodass keines der Objekte die anderen überdauern kann.
  2. Kompilierte Selektoren teilen sich einen gemeinsamen Speicherpool. Die Klasse kompiliert jede Selektorzeichenfolge einmal und speichert sie für die gesamte Lebensdauer des Dokuments im Cache, sodass h3 a der Inhalt von 20 Produktkarten nur ein einziges Mal geparst wird. Der Pool erklärt auch einen klassischen lexbor-Absturz: Wenn Sie den Speicher eines Selektors freigeben und anschließend einen anderen Selektor mit demselben Parser parsen, liest lexbor auf den freigegebenen Speicher zu. Ein gemeinsamer Pool, der im Destruktor freigegeben wird, verhindert dies.
  3. Textinhalte werden automatisch für Sie zugewiesen. lxb_dom_node_text_content gibt einen neuen Puffer zurück, und Sie müssen ihn mit lxb_dom_document_destroy_text. Der text() Hilfsfunktion kopiert ihn in einen std::string und gibt ihn sofort frei.

Die Dokumentation zum Modul „lexbor selectors“ listet alle unterstützten Selektoren auf, darunter :has() und :nth-child().

Als Nächstes wandeln wir den HTML-Code in typisierte Datensätze um. Jede Buchkarte auf „Books to Scrape“ enthält einen Titel, einen Preis, eine Sternebewertung, eine Zeile zur Verfügbarkeit und einen Link zur entsprechenden Produktseite.

// books.hpp: turn Books to Scrape HTML into typed records
#pragma once
#include "html.hpp"
#include <curl/curl.h>
#include <charconv>
#include <memory>
#include <optional>
#include <string>
#include <vector>

struct Book {
    std::string title;
    long price_pence = 0;     // integer money: 51.77 GBP is stored as 5177
    int rating = 0;           // 1 to 5 stars
    bool in_stock = false;
    std::string url;          // absolute product URL
    std::string upc;          // filled from the product page
    int stock_count = 0;      // filled from the product page
};

// Resolve an href against the URL of the page it came from, using libcurl's URL API.
inline std::string resolve_url(const std::string& page_url, const std::string& href) {
    std::unique_ptr<CURLU, decltype(&curl_url_cleanup)> u(curl_url(), &curl_url_cleanup);
    if (!u || curl_url_set(u.get(), CURLUPART_URL, page_url.c_str(), 0) != CURLUE_OK
           || curl_url_set(u.get(), CURLUPART_URL, href.c_str(), 0) != CURLUE_OK)  // relative to the page
        return {};
    char* full = nullptr;
    if (curl_url_get(u.get(), CURLUPART_URL, &full, 0) != CURLUE_OK) return {};
    std::string out(full);
    curl_free(full);
    return out;
}

// "£51.77" -> 5177. Locale-independent: std::from_chars ignores the user's decimal separator.
inline std::optional<long> parse_pence(std::string_view text) {
    const auto start = text.find_first_of("0123456789");
    if (start == std::string_view::npos) return std::nullopt;
    text.remove_prefix(start);
    long whole = 0, frac = 0;
    auto [p, ec] = std::from_chars(text.data(), text.data() + text.size(), whole);
    if (ec != std::errc{}) return std::nullopt;
    if (p != text.data() + text.size() && *p == '.') {
        const char* f = p + 1;
        auto [q, ec2] = std::from_chars(f, text.data() + text.size(), frac);
        if (ec2 != std::errc{} || q - f != 2) return std::nullopt;
    }
    return whole * 100 + frac;
}

// <p class="star-rating Three"> -> 3
inline int parse_rating(const std::string& css_class) {
    static constexpr const char* words[] = {"One", "Two", "Three", "Four", "Five"};
    for (int i = 0; i < 5; ++i)
        if (css_class.find(words[i]) != std::string::npos) return i + 1;
    return 0;
}

struct ListingPage {
    std::vector<Book> books;
    std::string next_url;     // empty on the last page
};

inline ListingPage parse_listing(std::string_view html, const std::string& page_url) {
    Html doc(html);
    ListingPage page;
    for (Node card : doc.select("article.product_pod")) {
        Node link = doc.first("h3 a", card);
        Book b;
        b.title = Html::attr(link, "title");                   // the visible text is truncated with "..."
        b.url = resolve_url(page_url, Html::attr(link, "href"));
        b.price_pence = parse_pence(doc.text(doc.first(".price_color", card))).value_or(-1);
        b.rating = parse_rating(Html::attr(doc.first("p.star-rating", card), "class"));
        b.in_stock = doc.text(doc.first(".availability", card)).find("In stock") != std::string::npos;
        page.books.push_back(std::move(b));
    }
    if (Node next = doc.first("li.next a")) page.next_url = resolve_url(page_url, Html::attr(next, "href"));
    return page;
}

// Product page: UPC and "In stock (22 available)" from the information table.
inline void parse_detail(std::string_view html, Book& b) {
    Html doc(html);
    for (Node row : doc.select("table.table-striped tr")) {
        const std::string key = doc.text(doc.first("th", row));
        const std::string value = doc.text(doc.first("td", row));
        if (key == "UPC") b.upc = value;
        if (key == "Availability") {
            const auto open = value.find('(');
            if (open != std::string::npos) std::from_chars(value.data() + open + 1, value.data() + value.size(), b.stock_count);
        }
    }
}

Preise bleiben bis zum Zeitpunkt der Ausgabe als ganzzahlige Pence-Beträge erhalten. Binäre Gleitkommazahlen können 51,77 nicht exakt darstellen, daher schwanken die Summen oder Vergleiche double Preise weichen um Bruchteile eines Pennys ab. long Bei Ganzzahlwerten ist dies nicht der Fall. Der Titel stammt aus dem title und nicht aus dessen Text, da der sichtbare Text bei langen Titeln mit „…“ abgeschnitten wird. parse_detail liest später die Informationstabelle der Produktseite, wenn der Crawler jedes Buch besucht.

main.cpp verknüpft die Header miteinander. Es beginnt mit den Includes, einem ehrlichen User-Agent und einem kleinen Datei-Helper, dann erfolgt die Weiterleitung anhand eines Befehlsnamens:

// main.cpp: Books to Scrape crawler (libcurl + lexbor), plus the JavaScript and Scraper API paths
#include "books.hpp"
#include "csv.hpp"
#include "http.hpp"
#include "robots.hpp"
#include <nlohmann/json.hpp>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <fstream>
#include <iostream>
#include <sstream>
#include <thread>

static const std::string kUserAgent = "bookscraper/1.0 (+https://example.com/contact)";

static std::string read_file(const std::string& path) {
    std::ifstream in(path, std::ios::binary);
    if (!in) throw std::runtime_error("cannot open " + path);
    std::ostringstream ss; ss << in.rdbuf(); return ss.str();
}
int main(int argc, char** argv) try {
    CurlGlobal curl;
    const std::string cmd = argc > 1 ? argv[1] : "crawl";
    // if (cmd == "fixture") { … } else if (cmd == "crawl") { … } … : the commands in this guide
    return 0;
} catch (const std::exception& e) {
    std::cerr << "error: " << e.what() << '\n';
    return 1;
}

Führe den Parser zunächst auf einer gespeicherten Kopie der Seite aus. Wenn hier etwas schiefgeht, weißt du, dass es sich um ein Parsing-Problem und nicht um ein Netzwerkproblem handelt. Dies ist der fixture Befehl:

    if (cmd == "fixture") {                       // offline: parse saved HTML
        auto page = parse_listing(read_file(argv[2]), "https://books.toscrape.com/catalogue/page-1.html");
        for (size_t i = 0; i < 3 && i < page.books.size(); ++i) {
            const Book& b = page.books[i];
            std::cout << b.title << " | " << format_price(b.price_pence) << " | " << b.rating << " stars | " << b.url << '\n';
        }
        std::cout << page.books.size() << " books, next: " << page.next_url << '\n';
    }
curl -s https://books.toscrape.com/catalogue/page-1.html -o page1.html
./build/bookscraper fixture page1.html

Ausgabe

A Light in the Attic | 51.77 | 3 stars | https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Tipping the Velvet | 53.74 | 1 stars | https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
Soumission | 50.10 | 1 stars | https://books.toscrape.com/catalogue/soumission_998/index.html
20 books, next: https://books.toscrape.com/catalogue/page-2.html

Warum lexbor statt libxml2 oder Gumbo?

lexbor folgt den WHATWG-Regeln für das HTML-Parsing, die auch von Browsern verwendet werden, verfügt über eine integrierte CSS-Selektor-Engine und wird aktiv weiterentwickelt. Der HTML-Parser von libxml2 stammt aus der Zeit vor HTML5, sodass sich sein Baum von dem unterscheiden kann, was DevTools anzeigt. Google hat sein Gumbo-Repository archiviert. Ein Community-Fork wird zwar weitergeführt, aber keine der beiden Versionen verfügt über eine Selektor-Engine.

Der Unterschied zeigt sich bei unordentlichem Markup, wie es auf echten Websites ständig vorkommt. Hier ist ein Beispiel für eine falsch verschachtelte `input`-Anweisung, <table><b>Sale</b><tr><td>9.99</td></tr></table>, geparst von lexbor, von libxml2 2.9 (der Version, die in den meisten Linux-Distributionen enthalten ist) und von Chrome:

Ausgabe

lexbor : <body><b>Sale</b><table><tbody><tr><td>9.99</td></tr></tbody></table></body>
libxml2: <body><table>
<b>Sale</b><tr><td>9.99</td></tr>
</table></body>
chrome  : <html><head></head><body><b>Sale</b><table><tbody><tr><td>9.99</td></tr></tbody></table></body></html>

lexbor und Chrome bauen denselben Baum auf: Das <b> Element wird vor die Tabelle verschoben, und ein <tbody> wird um die Zeile herum eingefügt. libxml2 belässt <b> innerhalb der Tabelle und fügt kein <tbody>. Ein Selektor, den man aus den DevTools kopiert, wie beispielsweise table > tbody > tr, passt bei lexbor, gibt bei diesem libxml2-Baum jedoch stillschweigend nichts zurück. Die Regeln, denen lexbor folgt, sind in der WHATWG-Spezifikation zur HTML-Analyse festgelegt. Neuere libxml2-Versionen tokenisieren eher wie HTML5, bauen aber immer noch nicht den vollständigen HTML5-Baum auf.

Stattdessen libxml2 und XPath verwenden

Wenn Ihr Projekt bereits libxml2 einbindet oder Sie in XPath denken, können Sie dieselbe Listenseite damit parsen. Umschließen Sie jedes libxml2-Objekt mit einem std::unique_ptr mit der entsprechenden „free“-Funktion ein, übergeben Sie HTML_PARSE_NONET , damit der Parser niemals Daten aus dem Netzwerk abruft, und geben Sie jede von libxml2 gelieferte Zeichenkette mit xmlFree, nicht free(). Wenn Sie sich zwischen den beiden Abfragesprachen entscheiden müssen, finden Sie unter „XPath vs. CSS-Selektoren“ einen direkten Vergleich.

// libxml2 alternative: the same listing page through libxml2's HTML parser and XPath.
#include <libxml/HTMLparser.h>
#include <libxml/xpath.h>
#include <fstream>
#include <iostream>
#include <memory>
#include <sstream>

int main(int argc, char** argv) {
    std::ifstream in(argc > 1 ? argv[1] : "fixtures/books-page1.html", std::ios::binary);
    std::ostringstream ss; ss << in.rdbuf(); const std::string html = ss.str();

    std::unique_ptr<xmlDoc, decltype(&xmlFreeDoc)> doc(
        htmlReadMemory(html.data(), static_cast<int>(html.size()), "https://books.toscrape.com/catalogue/page-1.html",
                       "UTF-8", HTML_PARSE_NOERROR | HTML_PARSE_NOWARNING | HTML_PARSE_NONET),
        &xmlFreeDoc);
    if (!doc) { std::cerr << "parse failed\n"; return 1; }
    std::unique_ptr<xmlXPathContext, decltype(&xmlXPathFreeContext)> ctx(xmlXPathNewContext(doc.get()), &xmlXPathFreeContext);
    std::unique_ptr<xmlXPathObject, decltype(&xmlXPathFreeObject)> res(
        xmlXPathEvalExpression(BAD_CAST "//article[contains(@class,'product_pod')]//h3/a/@title", ctx.get()),
        &xmlXPathFreeObject);
    if (!res || !res->nodesetval) return 1;
    for (int i = 0; i < res->nodesetval->nodeNr && i < 3; ++i) {
        xmlChar* title = xmlNodeGetContent(res->nodesetval->nodeTab[i]);
        std::cout << reinterpret_cast<char*>(title) << '\n';
        xmlFree(title);                         // libxml2 memory goes back through xmlFree, not free()
    }
    std::cout << res->nodesetval->nodeNr << " titles\n";
}
g++ -std=c++20 libxml2_xpath.cpp $(pkg-config --cflags --libs libxml-2.0) -o libxml2_xpath
./libxml2_xpath page1.html

Ausgabe

A Light in the Attic
Tipping the Velvet
Soumission
20 titles

Wie findet man die richtigen CSS-Selektoren?

Öffne die Seite in deinem Browser, klicke mit der rechten Maustaste auf die Daten und wähle „Inspect“ aus. Wählen Sie kurze Selektoren, die auf stabilen Klassen oder Attributen basieren, wie z. B. „article.product_pod h3 a“, und testen Sie diese in der DevTools-Konsole mit „document.querySelectorAll“. Überprüfen Sie anschließend auch den Roh-HTML-Code, da DevTools das geparste DOM anzeigt, einschließlich der von Browsern eingefügten „tbody“-Elemente.

Auf „Books to Scrape“ befindet sich jedes Buch in einem <article class="product_pod">. Darin sind die benötigten Selektoren kurz und an Klassennamen gebunden, die den Inhalt beschreiben, wodurch sie weniger anfällig für Fehler sind als lange Pfade, die mit „Selektor kopieren“ kopiert wurden:

Daten

Selektor (innerhalb der Karte)

Lesetipps

Titel

h3 a

Das title Attribut (der Linktext wird mit „...“ abgeschnitten)

Preis

.price_color

Der Text, z. B. £51.77

Bewertung

p.star-rating

Das zweite Wort des class Attributs, z. B. Three

Nächste Seite

li.next a (ganze Seite)

Das href Attribut

Testen Sie jeden Selektor in der Konsole, bevor Sie C++ schreiben: document.querySelectorAll("article.product_pod").length sollte den Wert 20 zurückgeben. Drücken Sie anschließend Strg+U, um den Quellcode anzuzeigen und zu überprüfen, ob dieselben Elemente auch im HTML-Code vorhanden sind, den der Server sendet – und nicht nur im DOM nach Ausführung der Skripte. Diese eine Überprüfung deckt JavaScript-basierte Seiten frühzeitig auf. Unser Spickzettel zu CSS-Selektoren behandelt Attribut-, Pseudoklassen- und Kombinator-Selektoren, wenn die einfachen nicht ausreichen.

Wie sollten Sie libcurl für das Web-Scraping konfigurieren?

Erstellen Sie pro Thread einen einfachen Handle und verwenden Sie diesen wieder, damit die Verbindungen offen bleiben. Legen Sie einen aussagekräftigen User-Agent fest, stellen Sie Verbindungs- und Gesamt-Timeouts sowie ein Limit für Weiterleitungen ein und setzen Sie CURLOPT_ACCEPT_ENCODING auf eine leere Zeichenkette, um die Komprimierung zu aktivieren. Überprüfen Sie den HTTP-Status selbst, wiederholen Sie nur bei 408, 429, 5xx und Netzwerkfehlern den Versuch und beachten Sie den „Retry-After“-Header.

Die HttpClient Klasse verpackt ein „easy handle“ von libcurl mit diesen Standardeinstellungen. CurlGlobal wird curl_global_init einmal, und zwar main, bevor irgendein Thread gestartet wird, da dieser Aufruf nicht threadsicher ist.

// http.hpp: one reusable libcurl handle with timeouts, an honest User-Agent, and polite retries
#pragma once
#include <curl/curl.h>
#include <algorithm>
#include <chrono>
#include <stdexcept>
#include <string>
#include <thread>

// Call curl_global_init() once, in main(), before any thread starts.
struct CurlGlobal {
    CurlGlobal() {
        if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK)
            throw std::runtime_error("curl_global_init failed");
    }
    ~CurlGlobal() { curl_global_cleanup(); }
    CurlGlobal(const CurlGlobal&) = delete;
    CurlGlobal& operator=(const CurlGlobal&) = delete;
};

struct Response {
    long status = 0;
    std::string body;
};

class HttpClient {
public:
    explicit HttpClient(const std::string& user_agent, long timeout_seconds = 30)
        : curl_(curl_easy_init()) {
        if (!curl_) throw std::runtime_error("curl_easy_init failed");
        curl_easy_setopt(curl_, CURLOPT_USERAGENT, user_agent.c_str());  // libcurl copies the string
        curl_easy_setopt(curl_, CURLOPT_FOLLOWLOCATION, 1L);
        curl_easy_setopt(curl_, CURLOPT_MAXREDIRS, 5L);
        curl_easy_setopt(curl_, CURLOPT_CONNECTTIMEOUT, 10L);
        curl_easy_setopt(curl_, CURLOPT_TIMEOUT, timeout_seconds);
        curl_easy_setopt(curl_, CURLOPT_ACCEPT_ENCODING, "");  // accept every compression this libcurl supports
        curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, &HttpClient::append);
    }
    ~HttpClient() { curl_easy_cleanup(curl_); }
    HttpClient(const HttpClient&) = delete;             // one handle per thread; never share it
    HttpClient& operator=(const HttpClient&) = delete;

    // GET a URL. Retries network errors, 408, 429, and 5xx; waits for Retry-After when the server sends it.
    Response get(const std::string& url, int max_retries = 3) {
        for (int attempt = 0;; ++attempt) {
            Response r;
            curl_easy_setopt(curl_, CURLOPT_URL, url.c_str());
            curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &r.body);
            const CURLcode rc = curl_easy_perform(curl_);
            if (rc == CURLE_OK) curl_easy_getinfo(curl_, CURLINFO_RESPONSE_CODE, &r.status);

            const bool retryable = rc != CURLE_OK || r.status == 408 || r.status == 429 || r.status >= 500;
            if (!retryable || attempt == max_retries) {
                if (rc != CURLE_OK)
                    throw std::runtime_error(std::string("GET ") + url + " failed: " + curl_easy_strerror(rc));
                return r;
            }

            curl_off_t retry_after = 0;                   // seconds, parsed by libcurl; 0 when absent
            curl_easy_getinfo(curl_, CURLINFO_RETRY_AFTER, &retry_after);
            const auto backoff = std::chrono::seconds(1LL << attempt);   // 1 s, 2 s, 4 s
            const auto wait = retry_after > 0 ? std::chrono::seconds(std::min<curl_off_t>(retry_after, 120)) : backoff;
            std::this_thread::sleep_for(wait);
        }
    }

private:
    static size_t append(char* data, size_t size, size_t count, void* out) {
        static_cast<std::string*>(out)->append(data, size * count);
        return size * count;
    }
    CURL* curl_;
};

Vier Einstellungen in dieser Klasse sind von Bedeutung:

  • „No“ CURLOPT_FAILONERROR. Diese Option wandelt HTTP-Fehler in Übertragungsfehler um und verwirft den Body. Der Scraper benötigt sowohl den Statuscode als auch den Body, daher überprüft er den Status selbst.
  • Wiederholungsversuche nur dort, wo sie helfen können. Ein 404-Fehler behebt sich nicht von selbst, daher versucht der Client bei Netzwerkfehlern sowie bei 408-, 429- und 5xx-Antworten bis zu dreimal einen erneuten Versuch.
  • CURLINFO_RETRY_AFTER übernimmt das Parsen. libcurl liest den „Retry-After“-Header in beiden Formaten (Sekunden oder ein HTTP-Datum) und gibt dir die Sekunden zurück. Der Client begrenzt die Wartezeit auf 120 Sekunden, sodass ein fehlerhafter oder beschädigter Header den Crawl nicht stundenlang blockieren kann. Fehlt der Header, wartet er zunächst 1, dann 2 und schließlich 4 Sekunden.
  • Das Handle wird wiederverwendet. Jeder get Aufruf hält dieselbe Verbindung offen, sofern der Server dies zulässt, was für Sie schneller und für die Website ressourcenschonender ist.

Um die Funktionsweise der Wiederholungslogik zu sehen, richte den Client auf einen kleinen lokalen Server, der zweimal mit 429 antwortet Retry-After: 2 zweimal, dann mit 200 antwortet:

# Local test server: the first two requests get 429 + Retry-After: 2, then 200.
import http.server, sys
hits = 0
class H(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        global hits; hits += 1
        if hits <= 2:
            self.send_response(429); self.send_header("Retry-After", "2"); self.end_headers(); self.wfile.write(b"slow down")
        else:
            self.send_response(200); self.end_headers(); self.wfile.write(b"ok after %d tries" % hits)
    def log_message(self, fmt, *a): sys.stderr.write("server: " + fmt % a + "\n")
http.server.HTTPServer(("127.0.0.1", 8429), H).serve_forever()
#include "../src/http.hpp"
#include <iostream>
int main() {
    CurlGlobal g;
    HttpClient http("bookscraper/1.0");
    auto t0 = std::chrono::steady_clock::now();
    Response r = http.get("http://127.0.0.1:8429/");
    auto s = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
    std::cout << "status " << r.status << ", body \"" << r.body << "\", waited " << s << " s\n";
}

Ausgabe (Zeilen aus dem Serverprotokoll, dann das Programm)

server: "GET / HTTP/1.1" 429 -
server: "GET / HTTP/1.1" 429 -
server: "GET / HTTP/1.1" 200 -
status 200, body "ok after 3 tries", waited 4.00422 s

Der Client hat zweimal zwei Sekunden gewartet, genau wie vom Server gefordert, und war beim dritten Versuch erfolgreich.

User-Agent: Identifizieren Sie Ihren Scraper und geben Sie Website-Betreibern eine Möglichkeit, Sie zu erreichen, zum Beispiel bookscraper/1.0 (+https://example.com/contact). Wenn Sie vorgeben, ein Desktop-Browser zu sein, lässt sich Ihr Datenverkehr schwerer von missbräuchlichem Traffic unterscheiden und wird dadurch nicht willkommener. Unser Leitfaden zu HTTP-Headern für das Web-Scraping behandelt die anderen Header, deren Einstellung sinnvoll ist.

Proxys: Fügen Sie CURLOPT_PROXY (zum Beispiel socks5h://host:1080) hinzu und CURLOPT_PROXYUSERPWD lesen Sie aus Umgebungsvariablen aus, um den Datenverkehr über einen Proxy zu leiten. Die Option entspricht dem -x Flag von curl, sodass die gleichen Einstellungen wie bei der Verwendung von curl mit einem Proxy übernommen werden.

cpr: Wenn du eine C++-API im „requests“-Stil bevorzugst, umschließt cpr die libcurl, und der Rest dieser Anleitung funktioniert genauso.

Wie kann man in C++ mehrere Seiten auslesen?

Analysiere den „Nächste Seite“-Link auf jeder Listenseite, löse ihn relativ zur aktuellen URL auf und wiederhole den Vorgang so lange, bis kein Link mehr vorhanden ist oder ein Seitenlimit erreicht ist. Die URL-API von libcurl löst relative Links korrekt auf. Halte zwischen den Anfragen eine Pause ein und rufe Detailseiten mit einem kleinen festen Pool von Worker-Threads ab, von denen jeder über ein eigenes libcurl-Handle verfügt.

Die Crawling-Schleife ist kurz, da der Parser bereits die nächste URL zurückgibt:

// 1. Follow "next" links until the last page or a page limit, pausing between requests.
static std::vector<Book> crawl(HttpClient& http, std::string url, int max_pages) {
    std::vector<Book> all;
    for (int page = 1; !url.empty() && page <= max_pages; ++page) {
        Response r = http.get(url);
        if (r.status != 200) { std::cerr << "HTTP " << r.status << " for " << url << '\n'; break; }
        ListingPage lp = parse_listing(r.body, url);
        std::cerr << "page " << page << ": " << lp.books.size() << " books\n";
        all.insert(all.end(), lp.books.begin(), lp.books.end());
        url = lp.next_url;
        std::this_thread::sleep_for(std::chrono::milliseconds(500));
    }
    return all;
}

Das Seitenlimit schützt Sie vor Endlosschleifen und davor, versehentlich einen Katalog mit 1.000 Seiten zu crawlen, wenn Sie eigentlich nur zwei Seiten testen wollten. Die Pause von einer halben Sekunde sorgt für eine moderate Anfragerate.

Der knifflige Teil ist resolve_url in books.hpp. Die Links bei „Books to Scrape“ sind relativ: Eine Karte auf /catalogue/page-1.html verweist auf a-light-in-the-attic_1000/index.html, und derselbe href auf der Startseite bedeutet etwas anderes, da sich die Startseite ein Verzeichnis höher befindet. Das Aneinanderfügen von Zeichenketten schlägt in solchen Fällen und bei ../ Pfade. Die URL-API von libcurl wendet stattdessen die Standardregeln zur Auflösung an: Sie legt die Seiten-URL als Basis fest, fügt href darauf und liest die absolute URL zurück. Die Offline-Tests weiter hinten in diesem Leitfaden prüfen sowohl einen einfachen relativen Pfad als auch einen ../ Link.

Der crawl Befehl überprüft die robots.txt-Datei (erläutert im Abschnitt zum verantwortungsvollen Scraping), durchsucht die Übersichtsseiten, ruft jede Produktseite auf und schreibt beide Exportdateien:

    if (cmd == "crawl") {                  // live: listing pages, product pages, CSV + JSON
        const int max_pages = argc > 2 ? std::atoi(argv[2]) : 2;
        HttpClient http(kUserAgent);
        Response robots_txt = http.get("https://books.toscrape.com/robots.txt");
        Robots robots(robots_txt.status == 200 ? robots_txt.body : "", kUserAgent);
        std::cerr << "robots.txt: HTTP " << robots_txt.status << ", /catalogue/ allowed: "
                  << std::boolalpha << robots.allowed("/catalogue/page-1.html") << '\n';
        if (!robots.allowed("/catalogue/page-1.html")) return 1;
        auto books = crawl(http, "https://books.toscrape.com/catalogue/page-1.html", max_pages);
        enrich(books, 4);
        write_csv("books.csv", books);
        write_json("books.json", books);
        std::cout << "saved " << books.size() << " books to books.csv and books.json\n";
    }
cd build && ./bookscraper crawl 2

Ausgabe (ca. 7 Sekunden für 2 Übersichtsseiten, 40 Produktseiten und die Pausen)

robots.txt: HTTP 404, /catalogue/ allowed: true
page 1: 20 books
page 2: 20 books
saved 40 books to books.csv and books.json

Wenn das Crawlen selbst zum Projekt wird – mit Tausenden von Seiten, Link-Erkennung und Zeitplanung –, sammelt die Crawl-API von WebScrapingAPI verwandte Seiten in einem einzigen begrenzten Crawl.

Wie scrapt man Seiten gleichzeitig?

Starten Sie eine feste Anzahl von std::jthread-Workern, etwa vier, und lassen Sie jeden den nächsten Index aus einem std::atomic-Zähler beanspruchen. Weisen Sie jedem Worker einen eigenen libcurl-Easy-Handle zu, da ein Handle niemals von zwei Threads gleichzeitig verwendet werden darf. Schreiben Sie jedes Ergebnis in einen eigenen Slot, sodass kein Mutex erforderlich ist.

Produktseiten sind voneinander unabhängig und eignen sich daher ideal für einen kleinen Pool:

// 2. Visit product pages with a small, fixed number of workers. Each worker owns its own
//    libcurl handle, and each result goes into its own slot, so no mutex is needed.
static void enrich(std::vector<Book>& books, unsigned workers) {
    std::atomic<size_t> next{0};
    std::vector<std::jthread> pool;
    for (unsigned w = 0; w < workers; ++w)
        pool.emplace_back([&] {
            HttpClient http(kUserAgent);
            for (size_t i = next++; i < books.size(); i = next++) {
                try {
                    Response r = http.get(books[i].url);
                    if (r.status == 200) parse_detail(r.body, books[i]);
                } catch (const std::exception& e) { std::cerr << e.what() << '\n'; }
            }
        });
}   // jthread joins on destruction

Jeder Worker erstellt seinen eigenen HttpClient innerhalb des Threads und durchläuft dann eine Schleife: den nächsten Index abrufen, die Seite des jeweiligen Buches abrufen und upc und stock_count diesen Book. Da niemals zwei Worker dasselbe Element bearbeiten und sich die Größe des Vektors während der Schleife nicht ändert, muss nichts gesperrt werden. std::jthread „joins“ wird automatisch aufgerufen, wenn der Worker zerstört wird, sodass die Funktion nicht zurückkehren kann, solange ein Worker noch schreibt. Es gibt keine join Aufrufe, die vergessen werden könnten.

Vier Worker sind ein vernünftiger Standardwert für eine einzelne Website. Mehr Threads belasten meist das Ziel. Wenn du jemals Hunderte von Übertragungen parallel benötigst, schau dir die „multi“-Schnittstelle von libcurl an, die viele Übertragungen aus einem Thread heraus steuert, anstatt weitere Threads hinzuzufügen.

Wie speichert man gescrapte Daten als CSV oder JSON?

Speichere Preise als ganzzahlige Cent- oder Pence-Werte und erstelle dann eine CSV-Datei gemäß RFC 4180: Setze Felder, die Kommas, Anführungszeichen oder Zeilenumbrüche enthalten, in Anführungszeichen, verdoppele alle inneren Anführungszeichen und beende Zeilen mit CRLF. Für JSON behält „ordered_json“ von nlohmann/json die Reihenfolge der Felder bei, in der du sie hinzugefügt hast. Beide Formate lassen sich problemlos in Tabellenkalkulationsprogrammen und Datentools öffnen.

Buchtitel führen bei einfachen CSV-Dateien schnell zu Fehlern. „Books to Scrape“ enthält Titel mit Kommas und Klammern auf den ersten Seiten, und das Trennen von Feldern durch einfache Kommas würde jede nachfolgende Spalte verschieben. Der unten stehende Skriptbefehl folgt RFC 4180:

// csv.hpp: RFC 4180 output, so titles with commas or quotes stay in one column
#pragma once
#include "books.hpp"
#include <cstdio>
#include <fstream>
#include <string>
#include <vector>

inline std::string csv_field(const std::string& s) {
    if (s.find_first_of(",\"\r\n") == std::string::npos) return s;
    std::string out = "\"";
    for (char c : s) {
        if (c == '"') out += '"';   // a quote inside a field is written twice
        out += c;
    }
    return out + '"';
}

inline std::string format_price(long pence) {
    char buf[32];
    std::snprintf(buf, sizeof buf, "%ld.%02ld", pence / 100, pence % 100);
    return buf;
}

inline void write_csv(const std::string& path, const std::vector<Book>& books) {
    std::ofstream out(path, std::ios::binary);       // binary: "\r\n" stays exactly as written on Windows
    out << "title,price_gbp,rating,in_stock,stock_count,upc,url\r\n";
    for (const Book& b : books)
        out << csv_field(b.title) << ',' << format_price(b.price_pence) << ',' << b.rating << ','
            << (b.in_stock ? "true" : "false") << ',' << b.stock_count << ',' << csv_field(b.upc) << ','
            << csv_field(b.url) << "\r\n";
    if (!out) throw std::runtime_error("could not write " + path);
}

Die Datei wird absichtlich im Binärmodus geöffnet. Im Textmodus wandelt Windows jedes \n in \r\num, sodass die \r\n Zeilenenden würden als \r\r\n. Der Binärmodus schreibt auf jeder Plattform genau die angeforderten Bytes.

Der JSON-Writer verwendet ordered_json, sodass die Felder in derselben Reihenfolge wie die CSV-Spalten und nicht alphabetisch ausgegeben werden:

static void write_json(const std::string& path, const std::vector<Book>& books) {
    nlohmann::ordered_json arr = nlohmann::ordered_json::array();   // keeps field order
    for (const Book& b : books)
        arr.push_back({{"title", b.title}, {"price_gbp", b.price_pence / 100.0}, {"rating", b.rating},
                       {"in_stock", b.in_stock}, {"stock_count", b.stock_count}, {"upc", b.upc}, {"url", b.url}});
    std::ofstream(path) << arr.dump(2) << '\n';
}

Ausgabe: books.csv (erste Zeilen sowie die Zeile, die in Anführungszeichen gesetzt werden muss)

title,price_gbp,rating,in_stock,stock_count,upc,url
A Light in the Attic,51.77,3,true,22,a897fe39b1053632,https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html
Tipping the Velvet,53.74,1,true,20,90fa61229261140a,https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html
…
"Starving Hearts (Triangular Trade Trilogy, #1)",13.99,2,true,19,0312262ecafa5a40,https://books.toscrape.com/catalogue/starving-hearts-triangular-trade-trilogy-1_990/index.html

Ausgabe: books.json (erster Datensatz)

{
  "title": "A Light in the Attic",
  "price_gbp": 51.77,
  "rating": 3,
  "in_stock": true,
  "stock_count": 22,
  "upc": "a897fe39b1053632",
  "url": "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html"
}

Der Titel „Starving Hearts“ bleibt in einer Spalte, da das Komma innerhalb von Anführungszeichen steht. Die Preise wurden mit std::from_chars, das die Ländereinstellung des Benutzers ignoriert; daher kann ein System, das Kommas als Dezimaltrennzeichen verwendet, das Ergebnis nicht verändern. Der Preis wird als zwei Ganzzahlen – Pfund und Pence – geparst, da die Unterstützung für Gleitkommazahlen from_chars unterscheidet sich nach wie vor zwischen den verschiedenen Standardbibliotheken.

Wie scrapt man eine Website in reinem C?

Verwende dieselben beiden Bibliotheken ohne die C++-Wrapper: libcurl schreibt die Antwort über einen Schreib-Callback in einen erweiterbaren Puffer, und lexbor analysiert den Puffer und führt CSS-Selektoren aus. Gib jedes Handle auf jedem Pfad frei. Ein einziges Bereinigungslabel mit „goto“ sorgt dafür, dass das überschaubar bleibt. Das komplette C-Programm umfasst weniger als 80 Zeilen.

Alles oben Genannte funktioniert auch in C17. Das folgende Programm ruft die erste Seite von „Books to Scrape“ ab und gibt jeden Titel aus:

/* scrape.c: plain C17. Fetch one Books to Scrape page with libcurl, print titles and prices with lexbor. */
#include <curl/curl.h>
#include <lexbor/html/html.h>
#include <lexbor/css/css.h>
#include <lexbor/selectors/selectors.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct { char *data; size_t len; } buffer;

static size_t on_data(char *ptr, size_t size, size_t n, void *userdata) {
    buffer *buf = userdata;
    size_t add = size * n;
    char *grown = realloc(buf->data, buf->len + add + 1);
    if (!grown) return 0;                       /* returning less than add makes libcurl abort the transfer */
    buf->data = grown;
    memcpy(buf->data + buf->len, ptr, add);
    buf->len += add;
    buf->data[buf->len] = '\0';
    return add;
}

static lxb_status_t on_match(lxb_dom_node_t *node, lxb_css_selector_specificity_t spec, void *ctx) {
    (void) spec; (void) ctx;
    size_t len = 0;
    const lxb_char_t *title = lxb_dom_element_get_attribute(lxb_dom_interface_element(node),
                                                            (const lxb_char_t *) "title", 5, &len);
    if (title) printf("%.*s\n", (int) len, (const char *) title);
    return LXB_STATUS_OK;
}

int main(void) {
    const char *url = "https://books.toscrape.com/catalogue/page-1.html";
    static const char selector[] = "article.product_pod h3 a";
    buffer page = {0};
    long status = 0;
    int rc = EXIT_FAILURE;

    if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) return EXIT_FAILURE;
    CURL *curl = curl_easy_init();
    if (!curl) goto cleanup_global;
    curl_easy_setopt(curl, CURLOPT_URL, url);
    curl_easy_setopt(curl, CURLOPT_USERAGENT, "bookscraper-c/1.0 (+https://example.com/contact)");
    curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, on_data);
    curl_easy_setopt(curl, CURLOPT_WRITEDATA, &page);

    CURLcode res = curl_easy_perform(curl);
    if (res != CURLE_OK) { fprintf(stderr, "request failed: %s\n", curl_easy_strerror(res)); goto cleanup_curl; }
    curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
    if (status != 200) { fprintf(stderr, "HTTP %ld\n", status); goto cleanup_curl; }

    lxb_html_document_t *doc = lxb_html_document_create();
    lxb_css_parser_t *parser = lxb_css_parser_create();
    lxb_selectors_t *sel = lxb_selectors_create();
    lxb_css_selector_list_t *list = NULL;
    if (doc && parser && sel
        && lxb_html_document_parse(doc, (const lxb_char_t *) page.data, page.len) == LXB_STATUS_OK
        && lxb_css_parser_init(parser, NULL) == LXB_STATUS_OK
        && lxb_selectors_init(sel) == LXB_STATUS_OK
        && (list = lxb_css_selectors_parse(parser, (const lxb_char_t *) selector, sizeof selector - 1)) != NULL) {
        lxb_selectors_find(sel, lxb_dom_interface_node(doc), list, on_match, NULL);
        rc = EXIT_SUCCESS;
    }
    if (list) lxb_css_selector_list_destroy_memory(list);
    if (sel) lxb_selectors_destroy(sel, true);
    if (parser) lxb_css_parser_destroy(parser, true);
    if (doc) lxb_html_document_destroy(doc);
cleanup_curl:
    curl_easy_cleanup(curl);
cleanup_global:
    curl_global_cleanup();
    free(page.data);
    return rc;
}
gcc -std=c17 -Wall -Wextra scrape.c $(pkg-config --cflags --libs libcurl lexbor) -o scrape
./scrape

Ausgabe (die ersten 3 von 20 Zeilen)

A Light in the Attic
Tipping the Velvet
Soumission
…

Wenn Sie die Bibliotheken mit vcpkg installiert haben, verweisen Sie PKG_CONFIG_PATH zunächst vcpkg_installed/<triplet>/lib/pkgconfig , da vcpkg libcurl.pc und lexbor.pc Dateien dort bereitstellt.

Zwei Fallstricke stellen den meisten C-Scrapern eine Falle:

  • realloc kann fehlschlagen. Weisen Sie das Ergebnis einem temporären Zeiger zu und überprüfen Sie diesen, bevor Sie buf->data. Das Schreiben buf->data = realloc(buf->data, …) führt zu einem Speicherleck des alten Puffers, wenn der Speicher knapp wird.
  • Der Schreib-Callback muss die Anzahl der verarbeiteten Bytes zurückgeben. Jeder andere Wert signalisiert libcurl, dass etwas schiefgelaufen ist, und die Übertragung wird abgebrochen. Auf diese Weise meldet der Callback auch einen Zuweisungsfehler: Er gibt 0 zurück.

Die goto Labels bieten der Funktion einen Ausstiegspfad pro Ressource, sodass libcurl- und lexbor-Objekte in umgekehrter Reihenfolge freigegeben werden, unabhängig davon, wo ein Fehler auftritt.

Woran erkennt man, ob eine Seite JavaScript benötigt?

Rufe die Seite mit libcurl ab und zähle die erwarteten Elemente. Wenn die Anzahl null ist, dein Browser aber Daten anzeigt, baut die Seite ihren Inhalt mit JavaScript auf. Bevor du zum Browser greifst, durchsuche die Script-Tags im Roh-HTML und den Netzwerk-Tab. Die Daten liegen oft bereits als JSON vor, das du direkt auswerten kannst.

Die /js/ Version von „Quotes to Scrape“ sieht im Browser identisch aus wie die normale Version. Ruft man sie jedoch mit libcurl ab, fehlen die Zitat-Elemente. Die Zitate befinden sich in einem JavaScript-Array innerhalb eines <script> Tag, und das seiteninterne Skript baut nach dem Laden den HTML-Code daraus auf.

Das bedeutet, dass man den Browser oft komplett überspringen kann. Diese Funktion findet das Skript, das das Array definiert, und übergibt das Array an nlohmann/json:

// 3. JavaScript pages, option A: the data is often already in the HTML, inside a <script>.
static nlohmann::json quotes_from_script(const std::string& html) {
    Html doc(html);
    for (Node script : doc.select("script")) {
        const std::string js = doc.text(script);
        const auto start = js.find("var data = [");
        if (start == std::string::npos) continue;
        const auto open = js.find('[', start);
        const auto close = js.find("];", open);   // first "];" after the array start ends the literal
        return nlohmann::json::parse(js.substr(open, close - open + 1));
    }
    return nlohmann::json::array();
}
    if (cmd == "js") {                     // live: plain fetch of a JavaScript page
        HttpClient http(kUserAgent);
        const std::string html = http.get("https://quotes.toscrape.com/js/").body;
        Html doc(html);
        std::cout << "div.quote elements in raw HTML: " << doc.select("div.quote").size() << '\n';
        auto quotes = quotes_from_script(html);
        std::cout << "quotes found in <script> JSON: " << quotes.size() << '\n'
                  << "first: " << quotes[0]["text"].get<std::string>() << " - "
                  << quotes[0]["author"]["name"].get<std::string>() << '\n';
    }

Ausgabe

div.quote elements in raw HTML: 0
quotes found in <script> JSON: 10
first: “The world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.” - Albert Einstein

Null Elemente im HTML, zehn Zitate im Skript. Die Extraktion sucht nach var data = [und nimmt anschließend alles bis zum ersten ]; danach. Das ist ein Muster für diese Seite, kein allgemeiner JavaScript-Parser. Suchen Sie auf anderen Seiten nach application/json oder application/ld+json „script“-Tags, oder öffne den Netzwerk-Tab und rufe den JSON-Endpunkt auf, den die Seite selbst verwendet.

Wie scrapt man JavaScript-gerenderte Seiten in C++?

Führe Chrome im Headless-Modus mit der Option `--dump-dom` aus, um die Skripte der Seite auszuführen und den endgültigen HTML-Code auszugeben, und parse diese Ausgabe anschließend mit lexbor. Für Klicks, Anmeldungen oder Wartezeiten steuere Chrome über WebDriver oder das DevTools-Protokoll über HTTP an. Playwright verfügt über keine offizielle C++-Anbindung. Im großen Maßstab macht eine Rendering-API den Einsatz einer Browserflotte vollständig überflüssig.

Wenn sich die Daten nicht in einem Script-Tag befinden, lassen Sie die Seite von einem echten Browser aufbauen. Ein headless Browser läuft ohne Fenster, und das --dump-dom Flag gibt das gerenderte DOM an die Standardausgabe aus, die C++ über eine Pipe lesen kann:

// 3. JavaScript pages, option B: let headless Chrome run the scripts, then parse the DOM it prints.
//    POSIX popen; the URL is a constant here. Never paste untrusted input into a shell command.
static std::string render_with_chrome(const std::string& url) {
    const char* chrome = std::getenv("CHROME");
    const std::string cmd = std::string(chrome ? chrome : "google-chrome") +
        " --headless --disable-gpu --dump-dom '" + url + "' 2>/dev/null";
    std::unique_ptr<FILE, int (*)(FILE*)> pipe(popen(cmd.c_str(), "r"), pclose);
    if (!pipe) throw std::runtime_error("could not start Chrome");
    std::string dom; char buf[8192];
    for (size_t n; (n = std::fread(buf, 1, sizeof buf, pipe.get())) > 0;) dom.append(buf, n);
    return dom;
}
    if (cmd == "render") {                 // live: headless Chrome, then lexbor
        Html doc(render_with_chrome("https://quotes.toscrape.com/js/"));
        auto quotes = doc.select("div.quote");
        std::cout << "div.quote elements after rendering: " << quotes.size() << '\n';
        if (!quotes.empty())
            std::cout << "first author: " << doc.text(doc.first("small.author", quotes[0])) << '\n';
    }

Ausgabe (einige Sekunden, einschließlich Chrome-Start)

div.quote elements after rendering: 10
first author: Albert Einstein

Die gleiche Html Klasse und dieselben CSS-Selektoren funktionieren bei der Chrome-Ausgabe, da lexbor und Chrome HTML auf dieselbe Weise analysieren. Die Dokumentation zum Headless-Modus von Chrome behandelt die anderen Flags.

Beachten Sie drei Einschränkungen:

  • Erstellen Sie den Befehl niemals aus nicht vertrauenswürdigen Eingaben. Die URL hier ist eine Konstante. Eine URL, die von einer Seite oder einem Benutzer stammt, könnte Shell-Syntax enthalten, und popen würde diese ausführen. Verwenden Sie unter Windows _popen und _pclose mit doppelten Anführungszeichen um die URL.
  • --dump-dom gibt das DOM nach dem „load“-Ereignis aus. Es wartet nicht auf Daten, die später über zusätzliche Netzwerkaufrufe eintreffen. Wenn eine Seite danach Inhalte lädt oder du Klicks, Anmeldungen oder Scrollvorgänge benötigst, steuere Chrome über WebDriver oder das Chrome DevTools-Protokoll an – beide nutzen HTTP oder WebSocket, auf die libcurl zugreifen kann.
  • Setzen Sie die CHROME Umgebungsvariable unter macOS und Windows auf den Pfad des Browsers ein, wobei google-chrome nicht auf der PATH.

Bei vielen Browsersitzungen mit Anmeldungen und Interaktionen führt die Browser-API von WebScrapingAPI die Browser für Sie aus.

Wie nutzt man eine Web-Scraping-API aus C++?

Senden Sie mit libcurl eine GET-Anfrage an den Scraper-API-Endpunkt von WebScrapingAPI und übergeben Sie dabei Ihren API-Schlüssel, die URL-kodierte Ziel-URL sowie Optionen wie „render_js=1“ und „country=us“. Der Antworttext enthält den HTML-Code der Seite, sodass Ihr bestehender Lexbor-Parser unverändert weiterverwendet werden kann. Lesen Sie den Schlüssel aus der Umgebungsvariablen `WSA_API_KEY` aus.

Chrome selbst auszuführen ist für ein paar Seiten in Ordnung. Wenn du JavaScript-Rendering, ein bestimmtes Land oder Zugriff auf Websites benötigst, die Scraper blockieren – und das über Tausende von Anfragen hinweg –, übernimmt eine Scraping-API die Browser und Proxys. Da sie HTML zurückgibt, besteht die Änderung auf deiner Seite lediglich aus einer URL:

// 4. Hand-off: the same libcurl client, pointed at WebScrapingAPI's Scraper API.
static std::string scraper_api_url(const std::string& api_key, const std::string& target,
                                   bool render_js, const std::string& country) {
    std::unique_ptr<CURL, decltype(&curl_easy_cleanup)> c(curl_easy_init(), &curl_easy_cleanup);
    auto escape = [&](const std::string& s) {
        char* e = curl_easy_escape(c.get(), s.c_str(), static_cast<int>(s.size()));
        std::string out(e); curl_free(e); return out;
    };
    std::string url = "https://api.webscrapingapi.com/v2?api_key=" + escape(api_key) + "&url=" + escape(target);
    if (render_js) url += "&render_js=1";
    if (!country.empty()) url += "&country=" + escape(country);
    return url;
}
    if (cmd == "api") {                    // Scraper API: needs WSA_API_KEY; prints a dry run without it
        const char* key = std::getenv("WSA_API_KEY");
        const std::string url = scraper_api_url(key ? key : "YOUR_API_KEY",
                                                "https://quotes.toscrape.com/js/", true, "us");
        if (!key) { std::cout << "dry run (set WSA_API_KEY to send it):\n" << url << '\n'; return 0; }
        HttpClient http(kUserAgent, 90);          // rendered requests take longer
        Response r = http.get(url, 1);
        if (r.status != 200) { std::cerr << "Scraper API returned HTTP " << r.status << ": " << r.body << '\n'; return 1; }
        Html doc(r.body);
        std::cout << "div.quote elements via Scraper API: " << doc.select("div.quote").size() << '\n';
    }

curl_easy_escape kodiert die Ziel-URL, sodass ihre eigene ? und & Zeichen nicht mit den Parametern der API verwechselt werden können. Der Client erhält ein Timeout von 90 Sekunden, da das Rendern einer Seite länger dauert als das Abrufen, und nur einen Wiederholungsversuch, sodass ein falscher Schlüssel schnell erkannt wird. Halten Sie den Schlüssel aus dem Quellcode und den Shell-Verlaufsdateien fern, die Sie teilen:

export WSA_API_KEY="your-key-from-the-dashboard"   # PowerShell: $env:WSA_API_KEY = "..."
./bookscraper api

Ohne die Variable gibt der Befehl die Anfrage aus, die er senden würde:

Ausgabe ohne WSA_API_KEY

dry run (set WSA_API_KEY to send it):
https://api.webscrapingapi.com/v2?api_key=YOUR_API_KEY&url=https%3A%2F%2Fquotes.toscrape.com%2Fjs%2F&render_js=1&country=us

Bei einem falschen Schlüssel antwortet die API mit einem 401-Fehler in JSON, und das Programm gibt diesen aus, anstatt zu versuchen, ihn zu parsen:

Ausgabe mit einem ungültigen Schlüssel

Scraper API returned HTTP 401: {"error":"Invalid API key"}

Beispielausgabe mit einem gültigen Schlüssel (zur Veranschaulichung simuliert)

div.quote elements via Scraper API: 10

Die gerenderte Seite sollte dieselben zehn Zitate enthalten, die Chrome generiert hat, und derselbe Selektor sollte sie finden. WebScrapingAPI meldet außerdem die bei jeder Anfrage verbrauchten Credits in den Antwort-Headern mit dem Präfix WSA, die Sie mit CURLOPT_HEADERFUNCTION auslesen können, wenn Sie die Nutzung aus C++ heraus nachverfolgen möchten.

Welchen C++-Scraping-Ansatz sollten Sie verwenden?

Verwenden Sie libcurl und lexbor für statische Seiten und überprüfen Sie die Script-Tags, bevor Sie einen Browser hinzufügen. Verwenden Sie Headless Chrome, wenn die Seite JavaScript benötigt und das Volumen gering ist. Wechseln Sie zu einer Scraping-API, wenn Sie JavaScript-Rendering, Geotargeting oder Proxy-Rotation in großem Maßstab benötigen, ohne Browser selbst ausführen zu müssen.

Seite oder Anforderung

Verwenden Sie

Warum

Kosten

Statisches HTML (zu scrapende Bücher)

libcurl + lexbor

Am schnellsten, wenigste bewegliche Teile

Nur Ihre Bandbreite

Daten in einem <script> (Zitate /js/)

libcurl + nlohmann/json

Kein Browser erforderlich

Nur Ihre Bandbreite

Inhalt wird nach dem Laden generiert, wenige Seiten

Headless Chrome --dump-dom + lexbor

Echte Browser-Darstellung

Eine Chrome-Installation; wenige Sekunden pro Seite

Klicks, Anmeldungen, Wartezeiten

WebDriver oder DevTools-Protokoll über HTTP

Volle Browserkontrolle

Browserflotte zum Ausführen und Aktualisieren

JavaScript, Geotargeting oder Blockierungen in großem Maßstab

Scraper-API (render_js, country)

Rendering und Proxys werden für Sie ausgeführt

Credits pro Anfrage

Wie testet und debuggt man einen C++-Scraper?

Speichern Sie echte Seiten als Fixtures und testen Sie Ihren Parser offline mit CTest daran, damit Fehler auf das Parsing und nicht auf das Netzwerk hinweisen. Erstellen Sie Testbinärdateien mit AddressSanitizer und UndefinedBehaviorSanitizer, um Speicherlecks und Speicherfehler im Zusammenhang mit C-Bibliotheksaufrufen zu erkennen, und führen Sie ThreadSanitizer auf dem parallel laufenden Crawler aus. Aktualisieren Sie die Fixtures, wenn sich die Website ändert.

Speichern Sie die Seiten, auf die Ihr Parser angewiesen ist, mit curl -o in fixtures/: Eine Übersichtsseite und eine Produktseite reichen hier aus. Der folgende Test überprüft den Parser, die URL-Auflösung, die Preisauswertung, die CSV-Einschließung in Anführungszeichen und die „robots.txt“-Regeln, ohne das Netzwerk zu beanspruchen:

// parser_test.cpp: offline checks against saved pages, run with ctest
#include "books.hpp"
#include "csv.hpp"
#include "robots.hpp"
#include <fstream>
#include <iostream>
#include <sstream>

static int failures = 0;
#define CHECK(cond) do { if (!(cond)) { std::cerr << "FAIL line " << __LINE__ << ": " #cond "\n"; ++failures; } } while (0)

static std::string slurp(const std::string& p) { std::ifstream f(p, std::ios::binary); std::ostringstream s; s << f.rdbuf(); return s.str(); }

int main() {
    curl_global_init(CURL_GLOBAL_DEFAULT);
    const std::string base = "https://books.toscrape.com/catalogue/page-1.html";
    auto page = parse_listing(slurp(FIXTURES "/books-page1.html"), base);
    CHECK(page.books.size() == 20);
    CHECK(page.books[0].title == "A Light in the Attic");
    CHECK(page.books[0].price_pence == 5177);
    CHECK(page.books[0].rating == 3);
    CHECK(page.books[0].in_stock);
    CHECK(page.books[0].url == "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html");
    CHECK(page.next_url == "https://books.toscrape.com/catalogue/page-2.html");

    // The same href means a different URL on the home page: resolve against the page you fetched.
    CHECK(resolve_url("https://books.toscrape.com/index.html", "catalogue/page-2.html")
          == "https://books.toscrape.com/catalogue/page-2.html");
    CHECK(resolve_url("https://books.toscrape.com/catalogue/page-2.html", "../index.html")
          == "https://books.toscrape.com/index.html");

    Book b; parse_detail(slurp(FIXTURES "/book-detail.html"), b);
    CHECK(b.upc == "a897fe39b1053632");
    CHECK(b.stock_count == 22);

    CHECK(parse_pence("£0.99") == 99);
    CHECK(!parse_pence("free").has_value());
    CHECK(csv_field("Starving Hearts (Triangular Trade Trilogy, #1)") == "\"Starving Hearts (Triangular Trade Trilogy, #1)\"");
    CHECK(csv_field("6\" ruler") == "\"6\"\" ruler\"");

    // robots.txt: a 404 means no rules; otherwise the most specific matching rule wins.
    CHECK(Robots("", "bookscraper").allowed("/catalogue/page-1.html"));
    const std::string txt = "User-agent: *\nDisallow: /private/\nAllow: /private/public-page\n\n"
                            "User-agent: bookscraper\nDisallow: /search\n";
    CHECK(Robots(txt, "bookscraper/1.0").allowed("/private/x"));          // our own group replaces "*"
    CHECK(!Robots(txt, "bookscraper/1.0").allowed("/search?q=c"));
    CHECK(!Robots(txt, "otherbot").allowed("/private/x"));
    CHECK(Robots(txt, "otherbot").allowed("/private/public-page"));

    curl_global_cleanup();
    std::cout << (failures ? "FAILED" : "all parser checks passed") << '\n';
    return failures ? 1 : 0;
}

Führen Sie ihn über CTest aus und erstellen Sie anschließend denselben Test mit Sanitizern. ThreadSanitizer benötigt einen eigenen Build, da er nicht mit AddressSanitizer kombiniert werden kann:

ctest --test-dir build --output-on-failure

# Memory and undefined-behavior checks (GCC or Clang)
g++ -std=c++20 -g -fsanitize=address,undefined -Isrc -DFIXTURES='"fixtures"' tests/parser_test.cpp $(pkg-config --cflags --libs libcurl lexbor) -o parser_test_asan && ./parser_test_asan

# Data races in the crawler (separate build; TSan can't be combined with ASan)
g++ -std=c++20 -g -fsanitize=thread src/main.cpp $(pkg-config --cflags --libs libcurl lexbor) -o bookscraper_tsan && ./bookscraper_tsan crawl 1

Ausgabe

all parser checks passed

100% tests passed, 0 tests failed out of 1

Ein sauberer Sanitizer-Lauf gibt nur die programminterne Ausgabe aus. Bei jedem Speicherleck, jedem Lesezugriff außerhalb des zulässigen Bereichs oder jedem Datenkonflikt wird ein Bericht mit der genauen Zeile ausgegeben.

Zwei Debugging-Gewohnheiten sparen am meisten Zeit. Erstens: Setzen Sie CURLOPT_VERBOSE auf 1 auf das Handle, um alle Request- und Response-Header zu sehen – so lassen sich Umleitungen, Komprimierung und unerwartete Statuscodes schnell erkennen. Zweitens: Wenn ein Selektor nichts zurückgibt, schreibe r.body in eine Datei und öffne diese. Der empfangene HTML-Code ist oft eine Einwilligungsseite, eine Fehlerseite oder eine JavaScript-Shell – nicht die Seite, die du im Browser gesehen hast.

Wie führt man verantwortungsbewusstes Scraping mit C oder C++ durch?

Lies vor dem Crawlen die robots.txt und überspringe unzulässige Pfade, identifiziere deinen Scraper mit einem ehrlichen User-Agent, der eine Kontakt-URL enthält, und streue die Anfragen zeitlich. Sammle nur die öffentlichen Daten, die du benötigst, vermeide personenbezogene Daten, sofern du keine rechtmäßige Grundlage hast, und halte dich an die Nutzungsbedingungen der Website. Zieh dich sofort zurück, wenn der Server den Status 429 zurückgibt.

Der Crawler erledigt das meiste davon bereits: Er sendet einen identifizierenden User-Agent, macht Pausen zwischen den Seiten, beschränkt sich auf vier Worker und wartet so lange, wie es „Retry-After“ vorschreibt. Der letzte Schritt ist „robots.txt“. Dieser kleine Checker folgt RFC 9309:

// robots.hpp: a small RFC 9309 check for one user agent (longest matching rule wins; Allow wins ties)
#pragma once
#include <algorithm>
#include <cctype>
#include <sstream>
#include <string>
#include <vector>

class Robots {
public:
    // body = robots.txt contents; pass an empty string when the file returns 404 (everything is allowed)
    Robots(const std::string& body, std::string agent) {
        std::transform(agent.begin(), agent.end(), agent.begin(), tolower_ascii);
        std::istringstream in(body);
        std::string line;
        bool in_group = false, group_has_rules = false, matched_specific = false;
        std::vector<Rule> star, mine;
        std::vector<Rule>* target = nullptr;
        while (std::getline(in, line)) {
            line = trim(line.substr(0, line.find('#')));
            const auto colon = line.find(':');
            if (colon == std::string::npos) continue;
            std::string key = trim(line.substr(0, colon)), value = trim(line.substr(colon + 1));
            std::transform(key.begin(), key.end(), key.begin(), tolower_ascii);
            if (key == "user-agent") {
                if (!in_group || group_has_rules) { target = nullptr; group_has_rules = false; }
                in_group = true;
                std::transform(value.begin(), value.end(), value.begin(), tolower_ascii);
                if (value == "*") { if (!target) target = &star; }
                else if (agent.find(value) != std::string::npos) { target = &mine; matched_specific = true; }
            } else if (key == "allow" || key == "disallow") {
                group_has_rules = true;
                if (target && !value.empty()) target->push_back({value, key == "allow"});
            }
        }
        rules_ = matched_specific ? mine : star;
    }

    bool allowed(const std::string& path) const {
        size_t best = 0; bool allow = true;
        for (const Rule& r : rules_)
            if (path.compare(0, r.prefix.size(), r.prefix) == 0 &&
                (r.prefix.size() > best || (r.prefix.size() == best && r.allow))) {
                best = r.prefix.size(); allow = r.allow;
            }
        return allow;
    }

private:
    struct Rule { std::string prefix; bool allow; };
    static char tolower_ascii(char c) { return (c >= 'A' && c <= 'Z') ? char(c - 'A' + 'a') : c; }
    static std::string trim(const std::string& s) {
        const auto b = s.find_first_not_of(" \t\r"), e = s.find_last_not_of(" \t\r");
        return b == std::string::npos ? "" : s.substr(b, e - b + 1);
    }
    std::vector<Rule> rules_;
};

Er wählt die Gruppe für Ihren User-Agent aus oder greift auf die * Gruppe, wenn keine vorhanden ist, und die längste übereinstimmende Regel gewinnt bei Allow bei Gleichstand. Eine robots.txt, die einen 404-Status zurückgibt, bedeutet, dass keine Regeln vorhanden sind, was bei „Books to Scrape“ der Fall ist:

robots.txt: HTTP 404, /catalogue/ allowed: true

Dieser Checker ist bewusst klein gehalten. Er berücksichtigt nicht die * und $ Platzhalter, die RFC 9309 innerhalb von Pfaden zulässt; verwende daher einen vollständigen Parser für Produktions-Crawls von Websites, die du nicht kontrollierst.

„robots.txt“ ist eine Crawling-Konvention, keine Lizenz. Was Sie sammeln dürfen, hängt auch von den Daten, den Nutzungsbedingungen der Website und den gesetzlichen Bestimmungen Ihres Tätigkeitsgebiets ab. Lesen Sie „Ist Web-Scraping legal?“ , bevor Sie Daten für ein kommerzielles Projekt scrapen.

Häufig gestellte Fragen

Ist C++ beim Web-Scraping schneller als Python?

Beim Parsen und Verarbeiten in der Regel ja: Kompiliertes C++ mit Lexbor benötigt pro Seite weniger CPU-Leistung und Arbeitsspeicher als die meisten Python-Stacks. Bei den meisten Scrapern dominieren jedoch Netzwerklatenz und „polite delays“ die Gesamtzeit, sodass der tatsächliche Vorteil geringer ist, als Benchmarks vermuten lassen. Entscheiden Sie sich für C++, wenn das Parsing-Volumen, Speicherbeschränkungen oder eine bestehende Codebasis dies rechtfertigen.

Was ist der Unterschied zwischen C, C++ und C# beim Web-Scraping?

C und C++ werden zu nativem Code kompiliert und nutzen C-Bibliotheken wie libcurl und lexbor, sodass ein einziger Stack für beide Sprachen ausreicht. C# läuft auf .NET und verfügt über eigene Tools, darunter HttpClient, AngleSharp und Playwright für .NET. Die Namen ähneln sich, doch die Bibliotheken überschneiden sich nicht. C#-Entwickler sollten unseren separaten C#-Leitfaden befolgen.

Was ist der beste HTML-Parser für C++?

lexbor ist im Jahr 2026 die beste Standardwahl: Es implementiert den WHATWG-HTML5-Parsing-Algorithmus, enthält eine CSS-Selektor-Engine, hat keine externen Abhängigkeiten und lässt sich über vcpkg unter Windows, macOS und Linux installieren. Verwenden Sie libxml2, wenn Sie XPath benötigen oder bereits davon abhängig sind, und pugixml nur für wohlgeformtes XML, nicht für HTML aus der Praxis.

Sollte ich cpr oder libcurl für das Web-Scraping verwenden?

Beides funktioniert, da cpr ein C++-Wrapper um libcurl mit einer benutzerfreundlicheren API im „Requests“-Stil ist. Die reine libcurl bietet Ihnen alle Optionen, wie beispielsweise das integrierte „Retry-After“-Parsing und die URL-API, mit einer Abhängigkeit weniger, und die gleichen Aufrufe funktionieren auch in reinem C. Entscheiden Sie sich für cpr, wenn Sie kürzeren Code bevorzugen und ausschließlich C++ schreiben.

Funktioniert lexbor unter Windows und in Visual Studio?

Ja. Die vcpkg-Portierung von lexbor lässt sich auf Windows-Desktops, macOS und Linux kompilieren. Ausgenommen sind lediglich Universal Windows Platform-Apps. Öffnen Sie in Visual Studio den Projektordner, verweisen Sie CMAKE_TOOLCHAIN_FILE auf die Toolchain-Datei von vcpkg, und das Manifest installiert libcurl, lexbor und nlohmann/json beim ersten Konfigurationsdurchlauf. Es sind keine manuellen Bibliothekspfade erforderlich.

Kann C++ JavaScript-Websites auslesen?

Ja, aber nicht allein mit libcurl, da libcurl kein JavaScript ausführt. Prüfen Sie zunächst, ob die Daten bereits als JSON in einem Script-Tag vorliegen. Andernfalls führen Sie Headless Chrome mit --dump-dom aus, steuern Sie einen Browser über WebDriver oder das DevTools-Protokoll an oder rufen Sie eine Scraping-API mit aktiviertem JavaScript-Rendering auf und analysieren Sie den zurückgegebenen HTML-Code.

Wie vermeide ich es, beim Scraping mit C++ blockiert zu werden?

Sende weniger Anfragen, langsamer, mit einem ehrlichen User-Agent und beachte „Retry-After“, wenn ein Server mit 429 antwortet. Verwende Verbindungen wieder, anstatt neue zu öffnen, und lass nicht mehr Worker laufen, als die Website bewältigen kann. Bei geschützten Websites sollten Sie Anfragen über rotierende Proxys mit `CURLOPT_PROXY` leiten oder eine Scraping-API verwenden, die Proxys und Browser verwaltet.

Das Scraping öffentlicher Daten ist in vielen Rechtsordnungen legal, aber die Antwort hängt davon ab, was Sie erfassen, wie Sie es erfassen und welche Nutzungsbedingungen die Website vorsieht. Vermeiden Sie die Erfassung personenbezogener Daten ohne rechtmäßige Grundlage, umgehen Sie niemals Anmeldungen oder Paywalls, halten Sie sich an die „robots.txt“-Datei und holen Sie bei kommerziellen Projekten rechtlichen Rat ein. Die von Ihnen verwendete Programmiersprache ändert nichts an den Regeln.

Wie verwende ich einen Proxy mit libcurl?

Setzen Sie CURLOPT_PROXY auf die Proxy-URL, z. B. http://host:port oder socks5h://host:port, und CURLOPT_PROXYUSERPWD auf die Anmeldedaten, die aus Umgebungsvariablen statt aus dem Quellcode gelesen werden. libcurl unterstützt HTTP-, HTTPS- und SOCKS-Proxys. Um IP-Adressen zu rotieren, verweise die Option auf das rotierende Gateway eines Anbieters und belasse den Rest deines Scrapers unverändert.

Wie viele Threads sollte ein C++-Scraper verwenden?

Beginnen Sie mit etwa vier gleichzeitigen Anfragen pro Website und messen Sie die Leistung. Netzwerkverzögerungen – nicht die CPU – schränken die meisten Scraper ein, sodass zusätzliche Threads vor allem die Zielserver belasten und die Blockierungsraten erhöhen. Weisen Sie jedem Thread ein eigenes libcurl-Handle zu. Bei Tausenden von parallelen Übertragungen sollten Sie die „multi“-Schnittstelle von libcurl für einige wenige Threads nutzen, anstatt einen Thread pro Anfrage zu verwenden.

Scrapen Sie JavaScript-lastige und geschützte Websites mit C++

Behalten Sie Ihren libcurl- und lexbor-Code bei und überlassen Sie WebScrapingAPI das Rendering und das Geotargeting. Fügen Sie render_js=1 und country zu einer GET-Anfrage hinzu und analysieren Sie den HTML-Code, den Sie bereits kennen. Das Rendern von JavaScript und das Geotargeting verursachen keine zusätzlichen Kosten. Fehlgeschlagene Anfragen werden nicht in Rechnung gestellt, und die kostenlose Testversion umfasst 5.000 Anfragen. Probieren Sie die Scraper-API aus

Über den Autor

Raluca Penciuc, Full-Stack-Entwickler @ WebScrapingAPI

Raluca Penciuc

Full-Stack-Entwickler

Raluca Penciuc ist Full-Stack-Entwicklerin bei WebScrapingAPI. Sie entwickelt Scraper, verbessert Umgehungsstrategien und findet zuverlässige Wege, um die Erkennung auf Zielwebsites zu verringern.

Web-Scraping mit AWS Lambda: Leitfaden für Python und Java 2026
Anleitungen

Web-Scraping mit AWS Lambda: Leitfaden für Python und Java 2026

TL;DR: Kurz gesagt: Web-Scraping mit AWS Lambda funktioniert am besten, wenn jeder Aufruf kurz, begrenzt und unabhängig wiederholbar ist. Beginnen Sie mit direktem HTTP, AWS SAM und S3 und fügen Sie erst dann SQS, Container, Browser-Rendering, Proxys oder eine verwaltete Abrufebene hinzu, wenn sich im Rahmen der Arbeitslast herausstellt, dass diese erforderlich sind.

Suciu Dan26 min read
Artikel lesen
So verwenden Sie GoSpider: Crawlen, URLs bereinigen und Daten extrahieren
Anleitungen

So verwenden Sie GoSpider: Crawlen, URLs bereinigen und Daten extrahieren

TL;DR: GoSpider ist ein Befehlszeilen-Crawler zum Auffinden von URLs, kein vollständiger Scraper für strukturierte Daten. Diese Anleitung zur Verwendung von GoSpider zeigt einen begrenzten Crawl, die saubere Verarbeitung der Ausgabe, die Übergabe von Colly an CSV sowie einen Diagnosepfad für 403-Antworten oder Seiten, die eine JavaScript-Darstellung erfordern.

Suciu Dan20 min read
Artikel lesen
Wie man Redfin scrappt: Python-Leitfaden für Immobiliendaten
Anleitungen

Wie man Redfin scrappt: Python-Leitfaden für Immobiliendaten

TL;DR: Redfin stellt versteckte API-Endpunkte zur Verfügung, die strukturiertes JSON für Immobilienangebote zurückgeben, wodurch das fragile HTML-Parsing vollständig übersprungen werden kann. Diese Anleitung führt Sie durch den Aufbau eines Python-Scrapers, der Miet- und Verkaufsdaten extrahiert, nach Standort sucht, neue Angebote über XML-Sitemaps überwacht und saubere Ergebnisse in CSV oder JSON exportiert.

Suciu Dan11 min read
Artikel lesen

Los geht’s

Sind Sie bereit, Ihre Datenerfassung zu erweitern?

Schließen Sie sich den über 2.000 Unternehmen an, die WebScrapingAPI nutzen, um Webdaten im Unternehmensmaßstab ohne zusätzlichen Infrastrukturaufwand zu extrahieren.