跳至内容
免费开始
返回博客

使用 libcurl 和 lexbor 进行 C 和 C++ 网页抓取

Raluca Penciuc最后更新于 7 min read
使用 libcurl 和 lexbor 进行 C 和 C++ 网页抓取
简而言之:在 C 和 C++ 中进行网页抓取,请用 libcurl 下载网页,用 lexbor 通过 CSS 选择器解析 HTML5;两者都是纯 C 库,因此一套技术栈即可同时服务两种语言。当爬虫位于原生代码库中、运行在资源受限的硬件上,或需要解析大量 HTML 时,请选择 C++。对于快速的一次性任务,用 Python 编写更快。

本指南将向您展示如何使用两种可移植的 C 语言库(而非 C#)用 C 和 C++ 抓取网站:libcurl 用于下载网页,lexbor 用于通过 CSS 选择器解析 HTML5。这两者都是纯 C 语言实现的,因此一套运行环境即可同时支持这两种语言。它们在 Windows、macOS 和 Linux 上的安装方式也完全相同。

您将构建一个用于公共练习书店“Books to Scrape”的 C++20 爬虫。该爬虫支持分页,通过小型线程池访问每个产品页面,并保存经过清理的 CSV 和 JSON 文件。 随后,你将通过三种方式处理 Quotes to Scrape 网站上的 JavaScript 页面:直接从 script 标签中读取数据、使用无头 Chrome 渲染页面,以及交由爬取 API 处理。 最后,你将再次看到作为纯 C 程序的核心爬虫。CMake 和 vcpkg 将根据单一清单文件完成所有构建工作。

你将构建的内容:

  1. 一个能够礼貌地沿列表页面中的“下一页”链接进行爬行的爬虫。
  2. 一个产品页面处理步骤,利用四个工作线程添加 UPC 码和库存数量。
  3. 支持 RFC 4180 标准的 CSV 及有序 JSON 导出,价格以整数形式呈现。
  4. 从 JavaScript 页面获取数据的 3 种方法。
  5. 基于同一组两个库构建的纯 C17 爬虫。

想改用 C# 吗?我们的 C# 网页抓取指南涵盖了 HttpClient、AngleSharp 以及 .NET 版的 Playwright。

C++ 适合网页抓取吗?

是的,当爬虫必须集成到现有的 C 或 C++ 代码库中、在资源受限的硬件上运行,或者在内存有限的情况下处理大量 HTML 时,C++ 非常适合。对于一次性任务,Python 的编写速度更快。C++ 没有官方的 Playwright 绑定,因此对于 JavaScript 内容较多的网站,需要使用无头 Chrome 或爬虫 API。

C++ 特别适合以下场景:

  • 嵌入式和边缘设备,在此类场景下,一个小巧的原生二进制文件比分发 Python 运行时更优。
  • 现有原生服务,例如交易系统、搜索索引器和监控代理,在此类场景中,爬虫只是一个额外的模块,而非用其他语言编写的独立进程。
  • 海量解析场景,此时每页更低的 CPU 和内存占用能为服务器带来切实的资源节省。
  • 希望在 C 和 C++ 中使用相同解析器的团队,因为 libcurl 和 lexbor 都是 C 库,其上层提供了 C++ 封装。

C++ 不太适合以下场景:

  • 快速的一次性脚本,这种情况下使用 Python 笔记本能更快地生成 CSV 文件。
  • 涉及登录、点击和等待等复杂操作的浏览器自动化。Playwright 支持 JavaScript、Python、Java 和 .NET,但不支持 C++。
  • 团队中无人定期审查 C++ 代码的情况。

手动内存管理是原生爬虫的主要风险,因为每个 C 库都会提供需要手动释放的原始指针。本指南将每个 C 句柄封装在一个小型 RAII 类中,并演示如何使用内存 sanitizer 检查结果,从而确保遗漏的清理操作会在测试运行中暴露出来,而非在生产环境中导致内存泄漏。

在 C 和 C++ 中进行网页抓取需要什么?

你需要一个 C++20 编译器、CMake 以及三个库:用于下载页面的 libcurl、用于解析 HTML5 并运行 CSS 选择器的 lexbor,以及用于生成 JSON 的 nlohmann/json。 libcurl 和 lexbor 是纯 C 库,因此相同的软件栈在 C 语言中同样适用。vcpkg 可在 Windows、macOS 和 Linux 上安装这三个库。

一些较旧的教程仍推荐已存档或无法处理实际 HTML 的库。

C 和 C++ 爬虫库(vcpkg 版本,2026 年 11 月)

库

语言

适用场景

版本(vcpkg)

状态

libcurl

C

HTTP(S) 请求、代理、Cookie

8.23.0

处于活动状态;随大多数操作系统一同发布

cpr

C++

基于 libcurl 的 requests 风格封装库

1.15.1

活跃

cpp-httplib

C++(仅头文件)

简单的 HTTP 客户端和服务器

0.63.0

Active

Boost.Beast

C++

低级异步 HTTP 和 WebSocket

1.92.0

处于活跃状态;学习曲线陡峭

lexbor

C

HTML5 解析 + CSS 选择器

3.0.1

处于活动状态;无外部依赖

libxml2

C

HTML/XML 解析 + XPath

2.15.4

处于活动状态;HTML 解析器并非完整的 HTML5 树构建器

Gumbo

C

HTML5 解析(无选择器)

0.14.1(社区分支)

Google 代码库已存档;在 Codeberg 上分叉

pugixml

C++

仅支持 XML(格式正确的输入)

1.16

处于活跃状态;不适用于实际的HTML

nlohmann/json

C++(仅头文件)

JSON 输出和脚本 JSON 解析

3.12.0

活跃

本指南使用 libcurl、lexbor 和 nlohmann/json。这两个核心库均用 C 语言编写,正在积极维护中,并已打包在 vcpkg 和 Conan 中。lexbor 完全没有外部依赖,而 libcurl 几乎已预装在所有操作系统中。 您无需安装任何特殊软件,即可获得浏览器级别的解析器和经过实战检验的 HTTP 客户端。您为 C++ 编写的代码几乎可以逐行移植到纯 C 语言中。

使用 CMake 和 vcpkg 设置项目

该项目使用 vcpkg 清单文件,这是一个列出库名的小型 JSON 文件。当 CMake 使用 vcpkg 的工具链文件配置项目时,vcpkg 会读取该清单并为您的平台构建所有依赖项。详情请参阅 vcpkg 清单模式。最终的文件结构如下:

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)

if(TARGET ...) 代码块至关重要。vcpkg 默认在 Linux 上将 lexbor 构建为静态库,且静态构建导出的 CMake 目标名称与共享库构建的不同。保留这两行代码可避免出现令人困惑的链接错误。

克隆并初始化 vcpkg 一次,然后进行配置、构建和测试。在 Windows 上,请运行 bootstrap-vcpkg.bat 并使用反斜杠:

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

输出(尾部)

-- 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

首次 `configure` 会从源代码构建库,因此需要一分钟左右。后续的 `configure` 会复用这些库。

如果您更倾向于使用系统软件包,Debian 和 Ubuntu 提供 sudo apt install libcurl4-openssl-dev nlohmann-json3-dev。lexbor 未包含在 Debian 稳定版中,因此请使用 vcpkg 安装或从源代码编译。在 macOS 上, brew install curl lexbor nlohmann-json 会安装这三者。

Visual Studio:在 Visual Studio 2022 或更高版本中打开项目文件夹,系统会 CMakeLists.txt 。在 CMakePresets.json中,将 CMAKE_TOOLCHAIN_FILE 为 vcpkg 的 scripts/buildsystems/vcpkg.cmake,并在首次配置时,该清单会自动安装 libcurl、lexbor 和 nlohmann/json。您无需手动设置包含路径、库路径或环境变量。

如何用 C++ 抓取网页?

使用 libcurl 将页面下载到 std::string 中,用 lexbor 进行解析,并使用 CSS 选择器(如 article.product_pod)选择元素。 读取每个元素的文本或属性,然后将价格和相对链接转换为干净的值。将 C 句柄封装在小型 RAII 类中,以便每个解析器和句柄都能自动释放。

从解析器开始,因为这是你最常接触的部分。lexbor 的 API 是纯 C 风格:你创建对象、初始化它们、使用它们,然后销毁它们。下面的 Html 下面的类隐藏了这一生命周期。其析构函数会释放所有资源,即使在页面处理中途抛出异常也是如此。

// 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_;
};

在修改此类之前,有三个关于 lexbor 的细节值得了解:

  1. 文档、CSS 解析器和选择器引擎是独立的对象。执行查询需要这三者,而该类在同一位置创建并销毁它们,因此不会出现某个对象比其他对象存活更久的情况。
  2. 编译后的选择器共享一个内存池。该类会将每个选择器字符串编译一次,并在文档生命周期内将其缓存,因此 h3 a 20张产品卡片中的选择器仅需解析一次。该内存池也解释了lexbor的一个经典崩溃原因:若先释放某个选择器的内存,随后使用同一解析器解析另一个选择器,lexbor将读取已被释放的内存。通过在析构函数中释放的共享内存池,可避免此问题。
  3. 文本内容会自动为你分配内存。 lxb_dom_node_text_content 会返回一个新的缓冲区,你必须通过 lxb_dom_document_destroy_text。该 text() 辅助函数会将其复制到一个 std::string 中,并立即释放它。

lexbor 选择器模块的文档列出了所有受支持的选择器,包括 :has() 和 :nth-child().

接下来,将 HTML 转换为类型化记录。Books to Scrape 上的每张图书卡片都包含书名、价格、星级评分、库存状态以及指向其商品页面的链接。

// 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);
        }
    }
}

价格在打印出来之前始终以整数便士的形式保存。二进制浮点数无法精确表示 51.77,因此对 double 价格时,结果会因几分之一便士的误差而产生偏差。 long 而整数值则不会。书名取自链接的 title 属性,而非其文本内容,因为较长的书名在显示时会被截断并以“...”表示。 parse_detail 在爬虫访问每本书时,会随后读取产品页面的信息表。

main.cpp 将各表头关联起来。它首先包含几个包含文件、一个真实的 User-Agent 以及一个小型文件辅助程序,然后根据命令名称进行分派:

// 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;
}

首先对页面的保存副本运行解析器。如果在此处出现问题,则说明是解析问题,而非网络问题。这就是 fixture 命令:

    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

输出

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

为什么选择 lexbor 而不是 libxml2 或 Gumbo?

lexbor 遵循浏览器所采用的 WHATWG HTML 解析规则,内置 CSS 选择器引擎,并且正在积极维护中。libxml2 的 HTML 解析器诞生于 HTML5 之前,因此其树结构可能与开发者工具显示的结果有所不同。 Google 已将其 Gumbo 代码库归档。虽然社区分支仍在继续维护,但这两个版本均未包含选择器引擎。

这种差异在杂乱的标记中尤为明显,而真实网站中经常会出现此类情况。以下是一个嵌套错误的 input 标签: <table><b>Sale</b><tr><td>9.99</td></tr></table>,分别由 lexbor、libxml2 2.9(大多数 Linux 发行版默认搭载的版本)以及 Chrome 解析的结果如下:

输出

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 和 Chrome 生成的树结构相同: <b> 元素移到了表格前面,并且在该行周围出现了一个 <tbody> 被包裹在行元素之外。而 libxml2 则将 <b> 保留在表格内部,且未添加 <tbody>。因此,从开发者工具中复制的选择器(例如 table > tbody > tr,在 lexbor 上能匹配成功,但在 libxml2 的树结构中则会无声地返回空结果。lexbor 遵循的规则来自 WHATWG HTML 解析规范。较新的 libxml2 版本在词法分析方面更接近 HTML5,但它们仍然无法构建完整的 HTML5 树结构。

改用 libxml2 和 XPath

如果您的项目已链接 libxml2,或者您习惯使用 XPath 进行思考,则可以使用它来解析同一列表页面。将每个 libxml2 对象包裹在 std::unique_ptr ,并使用其对应的 free 函数,传入 HTML_PARSE_NONET ,确保解析器不会从网络获取任何内容,并将 libxml2 返回的每个字符串通过 xmlFree,而非 free()。若您正在这两种查询语言之间抉择,可参阅《XPath 与 CSS 选择器的对比》一文进行详细比较。

// 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

输出

A Light in the Attic
Tipping the Velvet
Soumission
20 titles

如何找到合适的 CSS 选择器?

在浏览器中打开页面,右键单击目标内容,然后选择“检查”。 选择基于稳定类或属性的简短选择器,例如 article.product_pod h3 a,并在开发者工具控制台中使用 document.querySelectorAll 进行测试。同时也要检查原始 HTML 代码,因为开发者工具显示的是已解析的 DOM,其中包含浏览器插入的 tbody 元素。

在“Books to Scrape”网站上,每本书都位于一个 <article class="product_pod">标签内。其中,你需要的选择器都很简短,且与描述内容的类名相关联,这使得它们比使用“复制选择器”功能复制而来的长路径更不容易失效:

数据

选择器(位于卡片内)

阅读推荐

标题

h3 a

The title 属性(链接文本以“...”截断)

价格

.price_color

文本,例如 £51.77

评分

p.star-rating

该 class ,例如 Three

下一页

li.next a (整页)

该 href 属性

在编写 C++ 代码之前,请先在控制台中测试每个选择器: document.querySelectorAll("article.product_pod").length 应返回 20。然后按 Ctrl+U 查看源代码,确认服务器发送的 HTML 中确实存在这些元素,而不仅仅是在脚本执行后的 DOM 中存在。这一项检查能及早发现由 JavaScript 构建的页面。当简单选择器不够用时,我们的 CSS 选择器速查表涵盖了属性选择器、伪类选择器和组合选择器。

如何配置 libcurl 进行网页抓取?

为每个线程创建一个简便的句柄并重复使用,以保持连接处于打开状态。设置一个描述性 User-Agent、连接和总超时时间、重定向限制,并将 CURLOPT_ACCEPT_ENCODING 设为空字符串以启用压缩。 请自行检查 HTTP 状态码,仅对 408、429、5xx 状态码及网络错误进行重试,并遵守 Retry-After 标头。

该 HttpClient 类封装了一个带有上述默认设置的 libcurl easy 句柄。 CurlGlobal 仅 curl_global_init 一次,在 main中,在任何线程启动之前,因为该调用不具备线程安全性。

// 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_;
};

该类中有四项关键选项:

  • No CURLOPT_FAILONERROR。该选项会将 HTTP 错误转换为传输错误,并丢弃请求体。由于爬虫需要同时获取状态码和请求体,因此它会自行检查状态码。
  • 仅在有效的情况下重试。404 错误不会自行修复,因此客户端会对网络错误、408、429 以及 5xx 响应进行重试,最多三次。
  • CURLINFO_RETRY_AFTER 负责解析。libcurl 会读取 Retry-After 标头(无论是以秒还是 HTTP 日期格式),并返回秒数。 客户端将等待时间限制在 120 秒内,因此恶意或损坏的标头无法使爬取过程停滞数小时。如果没有该标头,它会依次延迟 1、2 然后 4 秒。
  • 连接句柄会被复用。每次 get 调用在服务器允许的情况下都会保持同一连接打开,这既能提高您的访问速度,又能减轻网站负担。

要观察重试逻辑的运行效果,请将客户端指向一个微型本地服务器,该服务器会先返回 429 状态码并附带 Retry-After: 2 ,随后返回200:

# 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";
}

输出(服务器日志行,随后是程序)

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

客户端按照服务器的要求,两次等待了两秒钟,并在第三次尝试时成功。

User-Agent:标识您的爬虫,并为网站所有者提供联系您的方式,例如 bookscraper/1.0 (+https://example.com/contact)。伪装成桌面浏览器会使您的流量更难与滥用行为区分开来,也不会让您的访问更受欢迎。我们关于网络爬虫HTTP头部的指南涵盖了其他值得设置的头部。

代理:添加 CURLOPT_PROXY (例如 socks5h://host:1080) 并从环境变量中读取 CURLOPT_PROXYUSERPWD 从环境变量中读取设置,以通过代理进行路由。该选项与 curl 的 -x 标志,因此使用 curl 配合代理时的相同设置将直接沿用。

cpr:如果您更喜欢 requests 风格的 C++ API,cpr 封装了 libcurl,本指南的其余部分同样适用。

如何用 C++ 抓取多个页面?

解析每个列表页面的下一页链接,将其解析为当前 URL,并循环执行直至无链接剩余或达到页面限制。libcurl 的 URL API 能正确解析相对链接。在请求之间进行暂停,并使用一个较小的固定工作线程池来获取详情页面,每个线程拥有自己的 libcurl 句柄。

由于解析器会立即返回下一个 URL,因此爬取循环非常短:

// 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;
}

页面限制既能防止陷入无限循环,也能避免你在本意测试两页时,却意外爬取了 1,000 页的目录。半秒的暂停时间则能确保请求速率保持在合理范围内。

棘手之处在于 resolve_url 在 books.hpp。Books to Scrape 中的链接是相对的:位于 /catalogue/page-1.html 链接到 a-light-in-the-attic_1000/index.html,而主页上的同一 href 在主页上则指向其他内容,因为主页位于上一级目录中。在这种情况下,以及在 ../ 路径上也会失效。而 libcurl 的 URL API 则采用标准的解析规则:将页面 URL 设为基准,在 href ,然后读取回绝对 URL。本指南后面的离线测试将同时检查纯相对路径和 ../ 链接。

该 crawl 该命令会检查 robots.txt(详见“负责任的爬取”章节),爬取商品列表页面,访问每个商品详情页,并生成两个导出文件:

    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

输出(处理 2 个列表页面、40 个产品页面及暂停时间约需 7 秒)

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

当爬取本身成为一个项目,涉及数千个页面、链接发现和任务调度时,WebScrapingAPI 的 Crawl API 可通过一次受限爬取收集相关页面。

如何并行抓取页面?

启动固定数量的 std::jthread 工作线程(约四个),并让每个线程从 std::atomic 计数器中获取下一个索引。为每个工作线程分配独立的 libcurl easy 句柄,因为一个句柄绝不能被两个线程同时使用。将每个结果写入其专属槽位,因此无需使用互斥锁。

产品页面彼此独立,因此非常适合使用小型线程池:

// 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

每个工作线程在内部创建自己的 HttpClient ,然后进入循环:获取下一个索引,抓取该书的页面,并填充 upc 并 stock_count 该页 Book。由于没有任何两个工作者会同时访问同一个元素,且向量在循环期间大小始终不变,因此无需进行任何锁定。 std::jthread 在被销毁时会自动进行合并,因此当某个工作者仍在写入时,该函数无法返回。没有 join 需要处理的释放操作。

对于单个站点而言,四个工作线程是一个合理的默认值。增加线程数量通常会给目标服务器增加负载。如果你需要并行处理数百次传输,请参考 libcurl 的 multi 接口,它通过单个线程驱动多次传输,而非增加线程数量。

如何将抓取的数据保存为 CSV 或 JSON 格式?

将价格存储为整数形式的美分或便士,然后生成符合 RFC 4180 标准的 CSV 文件:对包含逗号、引号或换行符的字段进行转义,将内部引号双写,并在行尾使用 CRLF 结尾。对于 JSON,nlohmann/json 的 ordered_json 函数会按你添加字段的顺序保留字段顺序。 这两种格式在电子表格和数据工具中都能正常打开。

书籍标题会很快导致简单的 CSV 格式出错。Books to Scrape 中的书籍标题在第一页就包含逗号和圆括号,如果用普通逗号分隔字段,会导致其后所有列的位置发生偏移。下面的代码遵循 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);
}

该文件特意以二进制模式打开。若以文本模式打开,Windows 会将每个 \n 转换为 \r\n,因此 \r\n 行结束符将显示为 \r\r\n。二进制模式则会在所有平台上精确写入您要求的字节。

JSON 写入器使用 ordered_json,因此字段的输出顺序与 CSV 列的顺序一致,而非按字母顺序排列:

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';
}

输出:books.csv(前几行,以及需要加引号的那一行)

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

输出:books.json(第一条记录)

{
  "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"
}

《Starving Hearts》的书名保持在同一列中,因为逗号位于引号内部。价格使用 std::from_chars进行解析,该方法会忽略用户的区域设置,因此即使系统设置为使用逗号作为小数分隔符,也不会改变结果。价格被解析为两个整数(即英镑和便士),因为各标准库对浮点数 from_chars 支持在各标准库中仍存在差异。

如何用纯 C 语言抓取网站数据?

使用相同的两个库,但去除 C++ 封装层:libcurl 通过写入回调将响应写入可扩展缓冲区,lexbor 解析缓冲区并运行 CSS 选择器。释放每个路径上的所有句柄。使用一个带有 goto 语句的清理标签可确保代码易于管理。完整的 C 程序不到 80 行。

以上内容在 C17 中同样适用。下面的程序获取第一个“Books to Scrape”页面,并打印所有书名:

/* 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

输出(20 行中的前 3 行)

A Light in the Attic
Tipping the Velvet
Soumission
…

如果您使用 vcpkg 安装了库,请将路径指向 PKG_CONFIG_PATH 指向 vcpkg_installed/<triplet>/lib/pkgconfig ,因为 vcpkg 默认包含 libcurl.pc 并 lexbor.pc 文件。

有两个陷阱会让大多数 C 语言抓取工具出错:

  • realloc 可能会失败。将其结果赋值给一个临时指针,并在覆盖前进行检查 buf->data。写入 buf->data = realloc(buf->data, …) 会导致内存耗尽时旧缓冲区被泄漏。
  • 写回调必须返回其处理的字节数。任何其他值都会告知 libcurl 出了问题,并导致传输中止。这也是回调报告分配失败的方式:返回 0。

这些 goto 标签为该函数提供了每个资源一条退出路径,因此无论错误发生在何处,libcurl 和 lexbor 对象都会按相反顺序被释放。

如何判断一个页面是否需要 JavaScript?

使用 libcurl 获取页面,并统计预期元素的数量。如果计数为零,但浏览器显示了数据,则说明该页面通过 JavaScript 构建内容。在查看浏览器之前,请先搜索原始 HTML 中的 script 标签和“网络”选项卡。数据通常已以 JSON 格式存在,您可以直接解析。

《 /js/ Quotes to Scrape 的此版本在浏览器中看起来与普通版本完全相同。但若使用 libcurl 抓取该页面,报价元素便不复存在。这些报价实际上存储在 <script> 标签内的 JavaScript 数组中,页面自身的脚本会在加载完成后根据该数组生成 HTML。

这意味着你通常可以完全跳过浏览器。以下函数会查找定义该数组的脚本,并将数组传递给 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';
    }

输出

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

HTML 中没有元素,脚本中有十条引语。提取过程会查找 var data = [,然后截取所有内容直至第一个 ]; 之前的所有内容。这只是针对本页面的特定模式,并非通用的 JavaScript 解析器。在其他网站上,请查找 application/json 或 application/ld+json script 标签,或者打开网络标签页,调用该页面本身使用的 JSON 接口。

如何用 C++ 抓取由 JavaScript 渲染的页面?

运行无头 Chrome 并使用 --dump-dom 参数执行页面脚本并输出最终的 HTML,然后使用 lexbor 解析该输出。若需执行点击、登录或等待操作,可通过 WebDriver 或基于 HTTP 的 DevTools 协议控制 Chrome。Playwright 没有官方的 C++ 绑定。 在大规模场景下,渲染 API 可以完全省去浏览器集群。

当数据未位于 script 标签中时,可让真实浏览器构建页面。无头浏览器无需窗口即可运行,Chrome 的 --dump-dom 标志会将渲染后的 DOM 输出到标准输出,C++ 可通过管道读取:

// 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';
    }

输出(包括 Chrome 启动时间在内,耗时几秒)

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

相同的 Html 类和相同的 CSS 选择器在 Chrome 的输出中同样有效,因为 lexbor 和 Chrome 以相同的方式解析 HTML。Chrome 无头模式的文档中介绍了其他标志。

请牢记以下三点限制:

  • 切勿根据不可信的输入构建命令。此处的 URL 是一个常量。从网页或用户处获取的 URL 可能包含 shell 语法,而 popen 会执行该内容。在 Windows 系统上,请使用 _popen 并 _pclose ,并用双引号将 URL 括起来。
  • --dump-dom 会在 load 事件后输出 DOM。它不会等待后续通过额外网络请求传入的数据。当页面在此之后加载内容,或者您需要点击、登录或滚动操作时,请通过 WebDriver 或 Chrome DevTools 协议控制 Chrome,这两者均使用 libcurl 可访问的 HTTP 或 WebSocket 协议。
  • 在 macOS 和 Windows 上,将 CHROME 环境变量设置为浏览器在 macOS 和 Windows 上的路径,其中 google-chrome 该路径不在 PATH.

对于涉及登录和交互的多个浏览器会话,WebScrapingAPI 的 Browser API 会为您运行浏览器。

如何在 C++ 中使用网络爬虫 API?

使用 libcurl 向 WebScrapingAPI 的 Scraper API 端点发送一个 GET 请求,传入您的 API 密钥、URL 编码的目标 URL 以及诸如 render_js=1 和 country=us 之类的选项。响应正文即为页面的 HTML 内容,因此您现有的 lexbor 解析器无需修改即可直接使用。 从 WSA_API_KEY 环境变量中读取 API 密钥。

对于少量页面,自行运行 Chrome 完全没问题。但当您需要 JavaScript 渲染、指定国家/地区,或访问会阻止数千次请求的爬虫网站时,爬取 API 便会接管浏览器和代理。由于它返回的是 HTML,您这边只需修改一个 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 对目标 URL 进行编码,因此其自身的 ? 和 & 字符不会被误认为 API 的参数。客户端设有 90 秒超时限制(因为渲染页面比获取页面耗时更长),且仅允许一次重试,因此密钥错误时会迅速失败。请勿将密钥保存在您共享的源代码和 shell 历史记录文件中:

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

如果不使用该变量,命令会打印出它本应发送的请求:

未包含 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

若密钥错误,API 将返回 401 状态码并附带 JSON 错误信息,程序会直接打印该错误信息而非尝试解析:

使用无效密钥时的输出

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

有效密钥的示例输出(为说明而模拟)

div.quote elements via Scraper API: 10

渲染后的页面应包含与 Chrome 生成的相同的十条名言,且使用相同的选择器应能找到它们。WebScrapingAPI 还会在响应头中报告每次请求消耗的积分,这些积分以 WSA,若需从 C++ 端追踪使用情况,可通过 CURLOPT_HEADERFUNCTION 。

您应该采用哪种 C++ 抓取方法?

对于静态页面,请使用 libcurl 和 lexbor,并在添加浏览器之前检查 script 标签。当页面需要 JavaScript 且数据量较小时,请使用无头 Chrome。当您需要大规模的 JavaScript 渲染、地理定位或代理轮换,且不想自行运行浏览器时,请切换到爬取 API。

页面或需求

使用

原因

成本

静态 HTML(待抓取的书籍)

libcurl + lexbor

速度最快,组件最少

仅消耗您的带宽

数据存储在 <script> (报价 /js/)

libcurl + nlohmann/json

无需浏览器

仅消耗您的带宽

加载后生成内容,页面数量极少

无界面版Chrome --dump-dom + lexbor

真实浏览器渲染

仅需安装一次Chrome;每页仅需几秒

点击、登录、等待

通过HTTP调用WebDriver或开发者工具协议

完全控制浏览器

可运行和更新的浏览器集群

JavaScript、地理定位或大规模屏蔽

抓取 API(render_js, country)

自动渲染和代理服务

按请求计费

如何测试和调试 C++ 爬虫?

将真实页面保存为测试数据,并使用 CTest 离线测试您的解析器,这样失败原因就能归因于解析问题,而非网络问题。使用 AddressSanitizer 和 UndefinedBehaviorSanitizer 构建测试二进制文件,以捕获 C 库调用周围的内存泄漏和内存错误,并在并发爬虫上运行 ThreadSanitizer。 当网站内容发生变化时,请刷新测试数据。

使用 curl -o 保存为 fixtures/:在此处,一个商品列表页和一个商品详情页就足够了。下面的测试在不触及网络的情况下,检查了解析器、URL解析、价格解析、CSV引号处理以及robots.txt规则:

// 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;
}

通过 CTest 运行该测试,然后使用 sanitizer 重新构建相同的测试。ThreadSanitizer 需要单独构建,因为它无法与 AddressSanitizer 结合使用:

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

输出

all parser checks passed

100% tests passed, 0 tests failed out of 1

一次干净的 sanitizer 运行仅会打印程序自身的输出。任何内存泄漏、越界读取或数据竞争都会生成一份包含确切行号的报告。

有两个调试习惯能节省最多时间。首先,设置 CURLOPT_VERBOSE 为 1 ,以查看每个请求和响应头,这能快速发现重定向、压缩以及你未预期的状态码。其次,当选择器返回空值时,将 r.body 写入文件并打开它。你收到的 HTML 通常是同意页面、错误页面或 JavaScript 控制台,而不是你在浏览器中看到的页面。

如何使用 C 或 C++ 进行负责任的数据抓取?

在爬取前阅读 robots.txt 并跳过被禁止的路径;使用包含联系 URL 的真实 User-Agent 标识你的爬虫;并合理间隔请求。仅收集你所需的公开数据,除非有合法依据,否则避免收集个人数据,并遵守网站的条款。当服务器返回 429 状态码时,请立即停止爬取。

该爬虫已实现上述大部分要求:它发送包含标识信息的 User-Agent,在列出页面之间进行暂停,将工作线程限制为四个,并根据 Retry-After 指定的时间等待。最后一步是 robots.txt 检查。这个小型检查器遵循 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_;
};

它会根据您的用户代理选择对应组别,若不存在则回退到 * 组,并采用最长的匹配规则, Allow 平局时以最长规则为准。返回 404 状态码的 robots.txt 表示不存在规则,Books to Scrape 即属于这种情况:

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

该检查器刻意设计得非常精简。它不处理 * 和 $ RFC 9309 允许在路径中使用的通配符,因此对于您无法控制的网站,在正式爬取时请使用完整的解析器。

Robots.txt 是一种爬取约定,而非许可。您可以收集的内容还取决于数据本身、网站的使用条款以及您运营所在地的法律。在为商业项目进行任何数据抓取之前,请阅读《网络抓取合法吗?》。

常见问题

在网页抓取方面,C++ 比 Python 更快吗?

就解析和处理而言,通常是的:使用 lexbor 编译的 C++ 在每页消耗的 CPU 和内存方面都少于大多数 Python 技术栈。不过,对于大多数爬虫来说,网络延迟和礼貌等待时间占用了大部分总时间,因此实际性能提升比基准测试显示的要小。 当数据量较大、受内存限制或需基于现有代码库时,建议选择 C++。

在网页抓取方面,C、C++ 和 C# 有什么区别?

C 和 C++ 均可编译为原生代码,并使用 libcurl 和 lexbor 等 C 库,因此一套技术栈即可同时满足这两种语言的需求。 C# 运行在 .NET 平台上,并拥有自己的工具,包括 HttpClient、AngleSharp 和 Playwright for .NET。虽然名称相似,但这些库的功能并不重叠。C# 开发者应参考我们单独的 C# 指南。

C++ 最佳的 HTML 解析器是什么?

在 2026 年,lexbor 是最强大的默认选择:它实现了 WHATWG HTML5 解析算法,包含 CSS 选择器引擎,没有外部依赖,并且可通过 vcpkg 在 Windows、macOS 和 Linux 上安装。 当您需要 XPath 或已经依赖它时,请使用 libxml2;而 pugixml 仅适用于结构良好的 XML,不适用于实际的 HTML。

进行网页抓取时,应该使用 cpr 还是 libcurl?

两者皆可,因为 cpr 是 libcurl 的 C++ 封装库,提供了更友好的、类似 requests 风格的 API。 直接使用 libcurl 能提供所有选项,例如内置的 Retry-After 解析和 URL API,且依赖项少一个,相同的调用在纯 C 语言中同样有效。若您更倾向于编写更简洁的代码且仅使用 C++,请选择 cpr。

lexbor 能在 Windows 和 Visual Studio 上运行吗?

可以。lexbor 的 vcpkg 移植版可在 Windows 桌面、macOS 和 Linux 上构建。仅通用 Windows 平台 (UWP) 应用除外。 在 Visual Studio 中,打开项目文件夹,将 CMAKE_TOOLCHAIN_FILE 指向 vcpkg 的工具链文件,首次配置时,清单文件会自动安装 libcurl、lexbor 和 nlohmann/json。无需手动设置库路径。

C++ 能抓取 JavaScript 网站吗?

可以,但仅靠 libcurl 无法实现,因为 libcurl 本身不执行 JavaScript。首先检查数据是否已作为 JSON 格式存在于 script 标签中。 否则,请运行带 --dump-dom 参数的无头 Chrome,通过 WebDriver 或 DevTools 协议控制浏览器,或者调用启用了 JavaScript 渲染的爬取 API,并解析返回的 HTML。

如何在使用 C++ 进行抓取时避免被封禁?

减少请求次数,放慢请求速度,使用真实的 User-Agent,并在服务器返回 429 状态码时遵守 Retry-After 规则。重复利用现有连接而非建立新连接,且不要运行超过网站处理能力的 worker 数量。 对于受保护的网站,请通过 CURLOPT_PROXY 设置轮换代理来转发请求,或者使用能够管理代理和浏览器的爬取 API。

使用 C++ 进行网页抓取是否合法?

在许多司法管辖区,抓取公开数据是合法的,但具体答案取决于您收集的内容、收集方式以及网站的条款。请勿在缺乏合法依据的情况下收集个人数据,切勿绕过登录或付费墙,遵守 robots.txt 规则,并在进行商业项目时寻求法律建议。您使用的编程语言不会改变这些规则。

如何在 libcurl 中使用代理?

将 CURLOPT_PROXY 设置为代理 URL(例如 http://host:port 或 socks5h://host:port),并将 CURLOPT_PROXYUSERPWD 设置为凭据(建议从环境变量中读取,而非在源代码中硬编码)。libcurl 支持 HTTP、HTTPS 和 SOCKS 代理。 若要轮换 IP 地址,只需将该选项指向提供商的轮换网关,并保持爬虫代码的其他部分不变。

C++ 爬虫应该使用多少个线程?

建议从每个网站约四个并发请求开始,并进行测试。大多数爬虫的瓶颈在于网络等待而非 CPU,因此额外的线程主要会增加目标服务器的负载并提高封禁率。为每个线程分配独立的 libcurl 句柄。对于数千个并行传输,应使用 libcurl 的 multi 接口在少数几个线程上处理,而非为每个请求分配一个线程。

使用 C++ 抓取 JavaScript 内容繁多且受保护的网站

保留您的 libcurl 和 lexbor 代码,让 WebScrapingAPI 负责渲染和地理定位。添加 render_js=1 和 country 到一个 GET 请求中,并解析您已知的 HTML。JavaScript 渲染和地理定位不消耗额外积分。失败的请求不计费,免费试用包含 5,000 次请求。试用 Scraper API

关于作者

Raluca Penciuc, 全栈开发工程师 @ WebScrapingAPI

Raluca Penciuc

全栈开发工程师

Raluca Penciuc 是 WebScrapingAPI 的全栈开发工程师,主要负责开发爬虫、优化规避机制,并探索可靠的方法以降低在目标网站上的被检测概率。

开始构建

准备好扩展您的数据收集规模了吗?

加入2,000多家企业,使用WebScrapingAPI在无需任何基础设施开销的情况下,以企业级规模提取网络数据。