跳至内容
返回博客

Java 网页抓取:构建一个可靠的 Java 抓取工具

Raluca Penciuc最后更新于 4 min read
Java 网页抓取:构建一个可靠的 Java 抓取工具

开始提取

免费试用 WebScrapingAPI

领取 1,000 个免费 API 积分。无需信用卡。

开始使用
简而言之:首先使用 HttpClient 和 Jsoup 开始;当返回的 HTML 中包含数据时,在适当情况下调用公开的 JSON 数据源;仅针对与浏览器相关的状态才添加渲染或 Selenium 操作。本 Java 网页抓取教程将通过分页、并发限制、重试、会话管理、数据验证和持久化输出,逐步扩展一个 Java 21 代码库。

网络爬虫是指通过编程方式从网站中提取信息,以便对所得数据进行存储、验证和分析。一个实用的 Java 网络爬虫技术栈通常从 Java 21 内置的 HttpClient 处理请求,并使用 Jsoup 进行 HTML 解析,仅在页面有特殊需求时才添加其他工具。

难点很少在于从单个页面中选取一个元素。可靠性问题通常出现在分页循环时、并发工作者导致服务过载时、瞬时故障被误认为永久性错误时、JavaScript 隐藏了真实数据源时、经过身份验证的请求丢失 Cookie 时,或者选择器悄无声息地返回零条记录时。

本指南将通过这些阶段构建一个小型 Java 网页抓取工具。您将首先将页面分类为服务器端 HTML、JSON、渲染内容或浏览器交互。 随后,你将构建一个类型化的“获取-解析-爬取-输出”设计,在分页过程中避免重复下载,比较固定执行器与虚拟线程,实现状态感知重试,保持授权会话,并输出经过验证的结果。

示例使用了一个公开的练习网站,并设置了刻意设定的安全限制。在其他场景中应用相同模式之前,请确认访问要求、当前依赖项版本、目标选择器以及适用于您特定数据采集的政策。

选择最简单的 Java 爬取方案

最佳技术栈是能够可靠返回数据且复杂度最低的那种。首先向 URL 发送一次请求,检查响应正文,并将其与浏览器显示的内容进行对比。如果服务器响应中已包含这些值,Java 的 HttpClient 配合 Jsoup 通常就足够了。如果页面提供了 JSON 请求接口,请直接调用该端点。仅当数据确实依赖于 JavaScript 或用户交互时,才添加渲染或浏览器自动化功能。

这一决策规则可确保 Java 网页抓取项目保持精简、易于测试且运营成本低廉。

对页面进行分类:HTML、JSON、渲染后的 DOM 或交互

在选择库之前,请先分类目标数据如何到达浏览器:

页面类型

观察结果

最简便的合适方案

服务器返回的 HTML

标题、价格、行或链接会出现在“查看源代码”或原始 HTTP 响应中

HttpClient 加载页面后,Jsoup 会对其进行解析

暴露的 JSON

开发者工具显示的 Fetch/XHR 响应中包含相关记录

重现文档中记载或允许的 HTTP 请求并解析 JSON

渲染后的 DOM

初始响应中缺少数据,但 JavaScript 会在加载完成后将其插入

使用托管渲染器或浏览器,然后将生成的 HTML 传递给相同的解析器

仅限浏览器交互

数据仅在点击、滚动、表单提交或客户端状态转换后才会显示

使用 Selenium 或其他浏览器自动化工具

切勿根据页面视觉复杂度推断其类型。高度交互的界面可能仅从一个简单的 JSON 接口读取数据,而一个普通的表格则可能在浏览器中动态生成。

请遵循以下检查步骤:

  1. 发送一个普通的 GET 请求,并记录状态码、内容类型以及请求主体的第一部分。
  2. 在返回的 HTML 中搜索一个您能在屏幕上看到的值。
  3. 打开开发者工具,重新加载页面,并将“网络”面板过滤为“Fetch/XHR”。
  4. 检查响应负载、请求方法、查询参数、请求头和分页令牌。
  5. 确定所需的访问是直接的 HTML 请求、JSON 请求、渲染后的输出,还是浏览器交互。

JSON 端点并非自动公开或无限制访问。请保留必要的身份验证,遵守网站条款,并避免盲目复制短效令牌。同样,浏览器自动化并非解决访问控制问题的万能方案,它只是为您的 Java 代码提供了一个真实的浏览器执行环境。

对于静态页面,Jsoup 网络爬取效果良好,因为 Jsoup 能将 HTML 字符串转换为可查询的文档。若要更深入地了解在 Java 中使用 Jsoup 进行 HTML 解析,可以将解析层与网络层分开研究。这种分离在后续获取机制发生变化时会变得尤为重要。

规划一个小型“获取-解析-爬取-输出”设计

避免将 HTTP 请求、选择器、分页、重试和文件写入功能合并到一个 main 方法中。一个包含四个职责的小型设计就足够了:

fetch:  URI -> status, headers, HTML
parse:  HTML + base URI -> typed records and next link
crawl:  decide which URI to visit next and enforce limits
output: validate, deduplicate, and persist records

使用接口来表示获取边界:

public interface PageFetcher {
    FetchResult fetch(URI uri) throws IOException, InterruptedException;
}

public record FetchResult(
        URI uri,
        int statusCode,
        HttpHeaders headers,
        String body) {}

解析器不应涉及代理、Cookie、重试或浏览器驱动程序。它接收文本和基础 URI,然后返回域名记录。爬虫负责页面限制、已访问 URL 以及请求排序。输出层负责规范化和存储。

即使解析器不使用传输元数据,也应确保其可用。状态码、响应头、最终重定向地址和耗时信息对于重试决策和故障诊断非常有用。将这些细节传递通过 FetchResult 可防止解析器与特定的 HTTP 客户端产生耦合。

这并非一个框架。它仅提供恰到好处的结构,使 Java Web 爬虫能够随着请求层的变更而演进,而无需每次都重写选择器。它还创建了明显的测试切入点:保存的 HTML 可用于测试解析,一个模拟的 PageFetcher 可用于测试爬取逻辑,临时文件则可用于测试输出。

为 Java 网络爬虫项目构建 Java 21 爬虫核心

现在将决策模型转化为一个可运行的项目。第一个版本将获取一个允许访问的练习页面,解析产品卡片,解析相对链接,并打印已输入的记录。后续章节将扩展相同的抓取器和解析器,而不是替换它们。

创建项目并添加 HttpClient 和 Jsoup

请使用完整的 Java 21 JDK,而非仅包含运行时的安装包:

java -version
javac -version

HttpClient 是 JDK 的一部分,因此 Jsoup 是静态示例所需的唯一依赖项。提供的源材料固定了 Jsoup 1.21.2,但该版本具有时效性。发布前请确认当前兼容 Java 21 的版本,并重新运行构建。

<!-- pom.xml -->
<properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <jsoup.version>1.21.2</jsoup.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.jsoup</groupId>
        <artifactId>jsoup</artifactId>
        <version>${jsoup.version}</version>
    </dependency>
</dependencies>

相应的精简版 Gradle 代码如下:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation 'org.jsoup:jsoup:1.21.2'
}

选择一种构建工具并保持一致。Maven 通过 XML 显式定义依赖关系图;Gradle 代码更简洁,但引入了其专有的构建 DSL。这两种方式均不会改变爬虫的架构。

简化的源代码布局使职责关系一目了然:

src/main/java/example/
  PageFetcher.java
  FetchResult.java
  HttpPageFetcher.java
  Book.java
  BookParser.java
  StaticBookScraper.java

本教程使用下方的单个文件,以便您快速运行。一旦首次请求成功,请将嵌套类型拆分到相应文件中,同时保持其公共接口不变。这将使后续的爬虫、重试和输出类更容易进行隔离测试。

将数据建模为记录,并选择健壮的 CSS 选择器

将每行提取的数据建模为 Java 记录,而非传递并行的字符串列表:

public record Book(
        String title,
        String price,
        URI detailUrl,
        URI sourcePage) {}

CSS 选择器应描述稳定的结构,而非偶然的位置。建议优先使用重复的乘积容器加上语义后代,例如 article.product_pod, h3 a,以及 .price_color。除非标记无法提供更好的选择方案,否则应避免使用由生成的类名或冗长的 div:nth-child(...) 链构成的选择器,除非标记结构不提供更好的选择。当需要属性、子节点、同级节点或复合选择器时,CSS 选择器速查表会很有帮助。

将每个内部匹配视为可选。 selectFirst() 返回 null ,因此请在检查元素后再读取其内容。此外,请对边界处的文本进行标准化处理,并保留源页面以供追溯。

static List<Book> parseBooks(String html, URI pageUri) {
    Document document = Jsoup.parse(html, pageUri.toString());
    List<Book> books = new ArrayList<>();

    for (Element card : document.select("article.product_pod")) {
        Element link = card.selectFirst("h3 a");
        Element price = card.selectFirst(".price_color");

        if (link == null || price == null) {
            continue;
        }

        String title = link.attr("title").trim();
        if (title.isBlank()) {
            title = link.text().trim();
        }

        String absoluteUrl = link.absUrl("href");
        String cleanPrice = price.text().trim();

        if (title.isBlank() || absoluteUrl.isBlank() || cleanPrice.isBlank()) {
            continue;
        }

        books.add(new Book(
                title,
                cleanPrice,
                URI.create(absoluteUrl),
                pageUri));
    }

    return List.copyOf(books);
}

传递给 Jsoup.parse() 正是使其 absUrl("href") 该功能变得实用。它能解析诸如 catalogue/book.html ,并将其解析为包含这些链接的页面。这比字符串拼接更安全,尤其是在分页操作将爬虫引导至子目录之后。

选择器的鲁棒性不等同于选择器的模糊性。例如, a 可能在页面重新设计后依然有效,但返回的导航链接可能与当前内容无关。应选择范围最窄且稳定的容器,仅提取属于该容器的字段,并统计缺失的必填字段。在开发过程中,保存一个小型且符合规范的HTML测试数据集,并以此测试解析器。这样可以区分标记回归问题与网络问题。

运行一个完整的静态页面抓取程序

以下单文件程序是 Java HttpClient 网络爬虫的可编译起点。它将获取和解析职责分离,检查状态码和内容类型,并限制连接时间和每次请求的时间。

package example;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;

public final class StaticBookScraper {
    record FetchResult(
            URI uri,
            int statusCode,
            HttpHeaders headers,
            String body) {}

    record Book(
            String title,
            String price,
            URI detailUrl,
            URI sourcePage) {}

    interface PageFetcher {
        FetchResult fetch(URI uri)
                throws IOException, InterruptedException;
    }

    static final class HttpPageFetcher implements PageFetcher {
        private final HttpClient client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(20))
                .followRedirects(HttpClient.Redirect.NORMAL)
                .build();

        @Override
        public FetchResult fetch(URI uri)
                throws IOException, InterruptedException {
            HttpRequest request = HttpRequest.newBuilder(uri)
                    .timeout(Duration.ofSeconds(60))
                    .header(
                            "User-Agent",
                            "Java21TutorialScraper/1.0 (+contact@example.com)")
                    .GET()
                    .build();

            HttpResponse<String> response = client.send(
                    request,
                    HttpResponse.BodyHandlers.ofString());

            return new FetchResult(
                    response.uri(),
                    response.statusCode(),
                    response.headers(),
                    response.body());
        }
    }

    static List<Book> parseBooks(String html, URI pageUri) {
        Document document = Jsoup.parse(html, pageUri.toString());
        List<Book> books = new ArrayList<>();

        for (Element card : document.select("article.product_pod")) {
            Element link = card.selectFirst("h3 a");
            Element price = card.selectFirst(".price_color");

            if (link == null || price == null) {
                continue;
            }

            String title = link.attr("title").trim();
            if (title.isBlank()) {
                title = link.text().trim();
            }

            String detailUrl = link.absUrl("href");
            String cleanPrice = price.text().trim();

            if (!title.isBlank()
                    && !detailUrl.isBlank()
                    && !cleanPrice.isBlank()) {
                books.add(new Book(
                        title,
                        cleanPrice,
                        URI.create(detailUrl),
                        pageUri));
            }
        }

        return List.copyOf(books);
    }

    public static void main(String[] args) throws Exception {
        URI start = URI.create("https://books.toscrape.com/");
        PageFetcher fetcher = new HttpPageFetcher();

        FetchResult response = fetcher.fetch(start);
        if (response.statusCode() < 200
                || response.statusCode() >= 300) {
            throw new IOException(
                    "Unexpected HTTP status "
                            + response.statusCode()
                            + " for "
                            + response.uri());
        }

        String contentType = response.headers()
                .firstValue("Content-Type")
                .orElse("");
        if (!contentType.toLowerCase().contains("text/html")) {
            throw new IOException(
                    "Expected HTML but received " + contentType);
        }

        List<Book> books = parseBooks(
                response.body(),
                response.uri());

        System.out.printf(
                "Fetched %s, extracted %d records%n",
                response.uri(),
                books.size());

        books.stream()
                .limit(3)
                .forEach(book -> System.out.printf(
                        "%s | %s | %s%n",
                        book.title(),
                        book.price(),
                        book.detailUrl()));
    }
}

通过 Maven 或 Gradle 编译该程序,确保 Jsoup 位于类路径中。输出应包含已获取的 URL、提取的记录数以及最多三条记录。请勿硬编码预期的页面总数或示例标题,因为实际网站的内容和标记可能会发生变化。

其中一些细节是刻意设计的。一个 HttpClient 示例被重复使用,因为客户端负责管理连接和配置。请求设有有限的超时时间,重定向会被显式处理,且最终响应的 URI 将成为解析器的基准 URI。程序会检查 2xx 状态码范围,而非仅 200,随后在应用选择器之前,会验证响应是否类似于 HTML。

该示例在传输或协议失败时会抛出异常,因为单页命令应明确报错。爬虫需要更精细的行为:分类失败原因、仅对临时性情况重试、记录最终结果,并在策略允许时继续执行。我们稍后将添加该行为,且无需更改 parseBooks().

示例中的 User-Agent 用于标识应用程序,而非伪装成特定浏览器。某些网站可能需要额外标头,但标头应真实反映您实际发出的请求。如果后续的抓取适配器需要密钥,请通过 System.getenv() 读取,若缺失则明确报错。

将数据提取转化为受控的爬虫

当单页提取器开始选择并访问其他 URL 时,它便转变为一个 Java Web 爬虫。这一变化引入了状态和终止规则。保留现有的抓取器和解析器,然后添加一个爬取协调器,由其管理当前页面、已访问页面集、页面限制以及缓存的响应正文。

处理分页并解析相对 URL

分页式网络爬取的 Java 代码应包含多个停止条件。当下一条链接消失、下一条 URL 无效、URL 重复、响应失败或达到配置的页面上限时,应停止爬取。该上限可防止因标记损坏或意外遍历整个网站而导致的错误。

围绕现有的 PageFetcherparseBooks() 函数:

record CrawlResult(
        List<Book> books,
        Map<URI, String> htmlByPage) {}

static CrawlResult crawlPages(
        URI start,
        int maxPages,
        PageFetcher fetcher)
        throws IOException, InterruptedException {

    if (maxPages < 1) {
        throw new IllegalArgumentException(
                "maxPages must be positive");
    }

    Set<URI> visited = new LinkedHashSet<>();
    Map<URI, String> cache = new LinkedHashMap<>();
    List<Book> books = new ArrayList<>();

    URI current = normalize(start);

    while (current != null && cache.size() < maxPages) {
        URI requested = normalize(current);
        if (!visited.add(requested)) {
            System.err.println(
                    "Stopping at repeated URL: " + requested);
            break;
        }

        FetchResult response = fetcher.fetch(requested);
        if (response.statusCode() < 200
                || response.statusCode() >= 300) {
            System.err.printf(
                    "Stopping after status %d for %s%n",
                    response.statusCode(),
                    requested);
            break;
        }

        URI finalUri = normalize(response.uri());
        if (cache.putIfAbsent(finalUri, response.body()) != null) {
            System.err.println(
                    "Stopping after redirect to cached URL: "
                            + finalUri);
            break;
        }
        visited.add(finalUri);

        books.addAll(parseBooks(
                response.body(),
                finalUri));

        Document document = Jsoup.parse(
                response.body(),
                finalUri.toString());

        Element next = document.selectFirst(
                "a[rel=next], li.next a");

        if (next == null) {
            current = null;
            continue;
        }

        String absoluteNext = next.absUrl("href");
        current = absoluteNext.isBlank()
                ? null
                : URI.create(absoluteNext);
    }

    return new CrawlResult(
            List.copyOf(books),
            Map.copyOf(cache));
}

static URI normalize(URI uri) {
    try {
        return new URI(
                uri.getScheme(),
                uri.getAuthority(),
                uri.getPath(),
                uri.getQuery(),
                null).normalize();
    } catch (URISyntaxException e) {
        throw new IllegalArgumentException(
                "Cannot normalize URI: " + uri,
                e);
    }
}

该方法会将每次获取的正文解析两次:一次用于提取记录,一次用于获取下一个链接。对于页面较小的情况,这种操作开销较低;但如果性能分析显示存在显著开销,则可通过单次解析调用同时返回记录和下一个 URI。关键的优化在于避免第二次网络请求,而非过早地节省微小的 DOM 解析开销。

该选择器组支持类似标准的 rel=next 链接以及练习网站的分页器结构。将其视为起点,而非通用选择器。请检查实际的分页器,并将导航选择器与产品选择器分开,以便在发生变更时易于定位问题。

在开发过程中,请刻意使用较小的爬虫容量:

CrawlResult result = crawlPages(
        URI.create("https://books.toscrape.com/"),
        3,
        new HttpPageFetcher());

System.out.printf(
        "Pages: %d, records: %d%n",
        result.htmlByPage().size(),
        result.books().size());

该数字 3 是本次运行的安全边界,而非预设总量。仅在确认“下一个链接”选择器仍位于预期主机和路径范围内后,才可增加该数值。在生产代码中,应在获取站外“下一个”URL之前将其拒收。

对于基于光标的 JSON 分页,循环结构相同。将 next.absUrl("href") 为提取下一个光标或令牌,并在令牌缺失或重复时停止。当服务器提供显式的续页值时,请勿猜测页码。

利用已访问 URL 和缓存的 HTML 防止循环和重复下载

已访问集合回答了一个问题:我们是否已经尝试过这个规范化 URL?HTML 缓存回答了另一个问题:我们是否已经下载了该最终 URL 的内容?你需要两者,因为重定向可能会将多个请求的 URL 映射到一个页面,而格式错误的分页器可能会指向后面的页面。

复用发现阶段的 HTML 是 Web 爬取 Java 爬虫的一大实用优势。一种常见且低效的设计是:先遍历分页链接以收集 URL,然后在并行提取过程中再次获取每个 URL。上面的代码在发现下一个链接时会将每个成功的响应存储起来,因此后续的解析阶段可以直接使用缓存:

List<Book> reparsed = cache.entrySet().stream()
        .flatMap(entry -> parseBooks(
                entry.getValue(),
                entry.getKey()).stream())
        .toList();

该处理流程中不会发生任何 HTTP 请求。若后续要并行化解析,可对这些缓存值进行并行处理,或将其提交给自定义执行器。切勿在未提示的情况下默认回退到再次下载。

URL 规范化应有所节制。移除片段通常是合适的,因为片段不会包含在 HTTP 请求中。而移除查询参数通常并不安全,因为参数可能用于标识页面、过滤器或光标。应针对目标定义规范化规则,而非全局删除数据。

内存缓存仅受 maxPages 和页面大小所限制。对于小型教程爬取任务,这尚可接受。但对于长期运行的任务,应将响应快照存储在磁盘上,或以有限批次进行处理。缓存的目的是减少请求,而非生成无限大的堆。

在保持控制的前提下并发执行请求

并发是一种吞吐量工具,而非爬取任务会更快完成的保证。远程服务、网络、CPU、内存和响应限制都可能成为瓶颈。应先顺序执行,进行测量,然后引入一个较小的显式上限。受控的并行 Web 抓取 Java 设计应使其最大待处理请求数一目了然。

使用固定的 ExecutorService 并采用易于写入的并发集合

固定的请求池可使请求上限等于工作线程数。每个任务提交一个独立的 URL,保留每个 Future,并等待其完成,以确保任务失败时数据不会丢失。

static List<Book> scrapeConcurrently(
        Collection<URI> urls,
        int maxInFlight,
        PageFetcher fetcher)
        throws InterruptedException, ExecutionException {

    ExecutorService executor =
            Executors.newFixedThreadPool(maxInFlight);

    Queue<Book> results = new ConcurrentLinkedQueue<>();
    Set<URI> claimed = ConcurrentHashMap.newKeySet();
    List<Future<?>> futures = new ArrayList<>();

    try {
        for (URI rawUrl : urls) {
            URI url = normalize(rawUrl);
            if (!claimed.add(url)) {
                continue;
            }

            futures.add(executor.submit(() -> {
                FetchResult response = fetcher.fetch(url);

                if (response.statusCode() >= 200
                        && response.statusCode() < 300) {
                    results.addAll(parseBooks(
                            response.body(),
                            response.uri()));
                } else {
                    System.err.printf(
                            "status=%d url=%s%n",
                            response.statusCode(),
                            url);
                }
                return null;
            }));
        }

        for (Future<?> future : futures) {
            future.get();
        }
    } finally {
        executor.shutdown();
        if (!executor.awaitTermination(
                30,
                TimeUnit.SECONDS)) {
            executor.shutdownNow();
        }
    }

    return List.copyOf(results);
}

ConcurrentLinkedQueue 支持频繁的并发添加,而无需复制整个底层结构。 ConcurrentHashMap.newKeySet() 通过 add()。共享的 ArrayListHashSet 在未进行访问同步的情况下,对于并发写入是不安全的。 CopyOnWriteArrayList 通常是一个较差的结果接收器,因为每次写入都会复制其底层数组。

本地 futures 列表仍保持为普通 ArrayList ,因为只有协调线程会对其进行写入。线程安全应应用于实际发生共享的地方,而非机械地应用于每个集合。

调用 get() 非常重要。如果某个任务抛出异常,协调器会收到一个 ExecutionException 异常,而不是在页面已悄然失败时仍打印乐观的成功消息。在长期运行的爬虫中,应解析该异常原因,记录 URL,并决定其他任务是否可以继续。如果协调器被中断,应在清理后保留中断状态,而不是直接忽略它。

请勿使用 parallelStream() 仅仅是为了节省几行代码。它默认使用通用的 fork-join 池,这会隐藏请求限制,并且可能与同一进程中的无关任务发生竞争。显式的执行器能为爬虫提供可观察的队列、生命周期和容量。

固定线程池易于理解,对于规模适中的 Java 网页抓取程序而言,它仍然是一个不错的默认选择。建议从较低的实测限制开始,观察延迟和状态码,并逐步增加。提供的参考资料中提到了小型演示范围,但并不存在放之四海皆准的安全线程数。目标站点的公开限制以及您自身的请求预算更为重要。

当工作负载需要时,请配合信号量使用虚拟线程

Java 21 的虚拟线程使阻塞式 I/O 代码更容易扩展,而无需维护庞大的平台线程池。OpenJDK 的 JEP 444 将其描述为轻量级的 JVM 管理线程,旨在支持高吞吐量的并发应用程序。但这并不意味着可以忽略限制对远程服务压力的必要性。

建议为每个任务分配一个虚拟线程,并使用信号量来控制实际的 HTTP 请求上限:

static List<Book> scrapeWithVirtualThreads(
        Collection<URI> urls,
        int maxInFlight,
        PageFetcher fetcher)
        throws InterruptedException, ExecutionException {

    Semaphore permits = new Semaphore(maxInFlight);
    Queue<Book> results = new ConcurrentLinkedQueue<>();
    Set<URI> claimed = ConcurrentHashMap.newKeySet();
    List<Future<Void>> futures = new ArrayList<>();

    try (ExecutorService executor =
                 Executors.newVirtualThreadPerTaskExecutor()) {

        for (URI rawUrl : urls) {
            URI url = normalize(rawUrl);
            if (!claimed.add(url)) {
                continue;
            }

            futures.add(executor.submit(() -> {
                permits.acquire();
                try {
                    FetchResult response = fetcher.fetch(url);
                    if (response.statusCode() >= 200
                            && response.statusCode() < 300) {
                        results.addAll(parseBooks(
                                response.body(),
                                response.uri()));
                    }
                } finally {
                    permits.release();
                }
                return null;
            }));
        }

        for (Future<Void> future : futures) {
            future.get();
        }
    }

    return List.copyOf(results);
}

虚拟线程的数量可能很大,但只有 maxInFlight 个任务能进入获取块。应在受限操作开始前立即获取许可,并在 finally中释放。在将记录写入数据库或执行无关的 CPU 工作时,请勿持有该许可,除非这些操作共享相同的限制。

如果分页发现功能已缓存了 HTML,请通过缓存提交解析任务,而不是再次调用 fetcher 。解析通常属于CPU工作,因此在为其分配数千个虚拟线程之前请先进行基准测试。虚拟线程的主要设计目的是提高阻塞操作的效率,而非加速每项计算。

选择

在以下情况下优先使用

主控制

固定池

任务数量适中,且简单有界的工作线程明确

线程池大小

虚拟线程

您有大量阻塞式I/O任务,并希望采用“每个任务一个线程”的简单实现方式

信号量或其他显式限制器

对于使用 Java 虚拟线程进行网页抓取,其主要优势在于简单易用。如果固定大小的线程池已能满足工作负载需求,则虚拟线程并非必须的升级方案。无论采用哪种方案,在提高并发度之前,都应先添加重试机制和按主机设置的限制。

使临时故障可恢复

可靠的爬虫不会将所有非 2xx 响应一视同仁。对于可能只是暂时的失败(如速率限制、特定服务器错误、超时和连接重置),应进行重试。 不要盲目重试身份验证失败、被禁止的请求、页面不存在或请求格式错误的情况。这些通常需要更改凭据、权限、URL 或请求结构。

利用 Retry-After 和抖动机制实现状态感知重试

下面的重试策略封装了相同的 PageFetcher 合同。它会限制重试次数,在计算本地退避策略前会遵循有效的 Retry-After 值,并在此基础上计算本地退避,添加抖动,并记录最终失败。相关的 HTTP 语义在 RFC 9110 的 Retry-After 章节中定义。

final class RetryingHttpFetcher implements PageFetcher {
    record Failure(
            URI uri,
            int attempts,
            Integer statusCode,
            String message,
            String bodyPreview) {}

    private final HttpClient client;
    private final int maxAttempts;
    private final Duration baseDelay;
    private final Duration maxDelay;
    private final Queue<Failure> failures =
            new ConcurrentLinkedQueue<>();

    RetryingHttpFetcher(
            HttpClient client,
            int maxAttempts,
            Duration baseDelay,
            Duration maxDelay) {

        if (maxAttempts < 1) {
            throw new IllegalArgumentException(
                    "maxAttempts must be positive");
        }

        this.client = client;
        this.maxAttempts = maxAttempts;
        this.baseDelay = baseDelay;
        this.maxDelay = maxDelay;
    }

    @Override
    public FetchResult fetch(URI uri)
            throws IOException, InterruptedException {

        for (int attempt = 1;
             attempt <= maxAttempts;
             attempt++) {

            HttpRequest request = HttpRequest.newBuilder(uri)
                    .timeout(Duration.ofSeconds(60))
                    .header(
                            "User-Agent",
                            "Java21TutorialScraper/1.0 "
                                    + "(+contact@example.com)")
                    .GET()
                    .build();

            HttpResponse<String> response;
            try {
                response = client.send(
                        request,
                        HttpResponse.BodyHandlers.ofString());
            } catch (IOException error) {
                if (!isTransient(error)
                        || attempt == maxAttempts) {
                    recordFailure(
                            uri,
                            attempt,
                            null,
                            error.toString(),
                            "");
                    throw error;
                }

                Duration delay = backoff(attempt);
                logRetry(
                        uri,
                        null,
                        error.getClass().getSimpleName(),
                        attempt,
                        delay,
                        "");
                Thread.sleep(delay);
                continue;
            }

            int status = response.statusCode();
            if (status >= 200 && status < 300) {
                return new FetchResult(
                        response.uri(),
                        status,
                        response.headers(),
                        response.body());
            }

            String preview = preview(response.body(), 300);
            boolean retryable = isRetryableStatus(status);

            if (!retryable || attempt == maxAttempts) {
                String message = retryable
                        ? "retry limit reached"
                        : "non-retryable HTTP status";

                recordFailure(
                        response.uri(),
                        attempt,
                        status,
                        message,
                        preview);

                throw new IOException(
                        message + ": " + status
                                + " for " + response.uri());
            }

            Duration delay = retryAfter(
                    response.headers()).orElseGet(
                            () -> backoff(attempt));

            logRetry(
                    response.uri(),
                    status,
                    "HTTP",
                    attempt,
                    delay,
                    preview);

            Thread.sleep(delay);
        }

        throw new IllegalStateException(
                "Retry loop exited unexpectedly");
    }

    List<Failure> failures() {
        return List.copyOf(failures);
    }

    private boolean isRetryableStatus(int status) {
        return status == 408
                || status == 429
                || status == 500
                || status == 502
                || status == 503
                || status == 504;
    }

    private boolean isTransient(IOException error) {
        for (Throwable cause = error;
             cause != null;
             cause = cause.getCause()) {
            if (cause instanceof HttpTimeoutException
                    || cause instanceof ConnectException
                    || cause instanceof SocketException) {
                return true;
            }
        }
        return false;
    }

    private Optional<Duration> retryAfter(
            HttpHeaders headers) {

        return headers.firstValue("Retry-After")
                .flatMap(this::parseRetryAfter)
                .map(this::capDelay);
    }

    private Optional<Duration> parseRetryAfter(
            String rawValue) {

        String value = rawValue.trim();

        try {
            long seconds = Long.parseLong(value);
            return Optional.of(Duration.ofSeconds(
                    Math.max(0, seconds)));
        } catch (NumberFormatException ignored) {
            // Try the HTTP-date form next.
        }

        try {
            Instant retryAt = ZonedDateTime.parse(
                    value,
                    DateTimeFormatter.RFC_1123_DATE_TIME)
                    .toInstant();

            Duration delay = Duration.between(
                    Instant.now(),
                    retryAt);

            return Optional.of(
                    delay.isNegative()
                            ? Duration.ZERO
                            : delay);
        } catch (DateTimeParseException ignored) {
            return Optional.empty();
        }
    }

    private Duration backoff(int attempt) {
        long multiplier =
                1L << Math.min(attempt - 1, 10);

        long ceiling = Math.min(
                maxDelay.toMillis(),
                baseDelay.toMillis() * multiplier);

        long floor = Math.min(
                baseDelay.toMillis(),
                ceiling);

        long millis = ceiling <= floor
                ? ceiling
                : ThreadLocalRandom.current()
                        .nextLong(floor, ceiling + 1);

        return Duration.ofMillis(millis);
    }

    private Duration capDelay(Duration delay) {
        return delay.compareTo(maxDelay) > 0
                ? maxDelay
                : delay;
    }

    private void recordFailure(
            URI uri,
            int attempts,
            Integer status,
            String message,
            String bodyPreview) {

        failures.add(new Failure(
                uri,
                attempts,
                status,
                message,
                bodyPreview));
    }

    private void logRetry(
            URI uri,
            Integer status,
            String kind,
            int attempt,
            Duration delay,
            String bodyPreview) {

        System.err.printf(
                "retry url=%s status=%s kind=%s "
                        + "attempt=%d/%d waitMs=%d body=%s%n",
                uri,
                status == null ? "-" : status,
                kind,
                attempt,
                maxAttempts,
                delay.toMillis(),
                bodyPreview);
    }

    private static String preview(
            String body,
            int maxChars) {

        if (body == null) {
            return "";
        }

        String oneLine = body.replaceAll("\\s+", " ").trim();
        return oneLine.substring(
                0,
                Math.min(maxChars, oneLine.length()));
    }
}

请使用显式限制而非零散的常量来构建它:

HttpClient client = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(20))
        .followRedirects(HttpClient.Redirect.NORMAL)
        .build();

PageFetcher fetcher = new RetryingHttpFetcher(
        client,
        4,
        Duration.ofMillis(500),
        Duration.ofSeconds(30));

本地调度时间大致从 500 毫秒逐渐增加至 1、2 和 4 秒,每个区间内均包含随机波动。虽然有效 Retry-After 值具有优先级,但此实现会应用配置的最大延迟。如果等待时间少于服务器要求会违反您的操作策略,请停止任务或重新调度,而不是设置上限并立即重试。

默认情况下,请勿将 403 。这可能意味着请求被禁止或被阻塞,若未解决根本原因就重复尝试,只会增加网络流量。同样, 400, 401404 通常表示配置、身份验证或 URL 问题。Web 爬虫的 Java 重试层应采取保守策略,因为每次重试都会消耗请求配额。

记录请求上下文并汇总爬取结果

重试日志用于诊断,而非数据倾倒。应记录 URL、状态或异常类型、尝试次数、等待时间,以及一行简短的正文预览。切勿记录 API 密钥、授权头、会话 Cookie、完整的登录响应或完整的个人数据负载。

将爬取层级的结果与请求尝试分开跟踪:

final class CrawlMetrics {
    private final LongAdder pagesAttempted = new LongAdder();
    private final LongAdder pagesSucceeded = new LongAdder();
    private final LongAdder pagesFailed = new LongAdder();

    void attempted() {
        pagesAttempted.increment();
    }

    void succeeded() {
        pagesSucceeded.increment();
    }

    void failed() {
        pagesFailed.increment();
    }

    void printSummary() {
        System.out.printf(
                "crawl attempted=%d succeeded=%d failed=%d%n",
                pagesAttempted.sum(),
                pagesSucceeded.sum(),
                pagesFailed.sum());
    }
}

仅在 attempted :当爬虫成功获取页面时增加一次,而非每次重试都增加一次。在获取成功或耗尽策略后,仅对最终结果增加一次计数。关闭时,从 RetryingHttpFetcher.failures() 和汇总总数生成一份简明失败列表。这为定时任务提供了有用的退出报告,同时避免因记录每个成功响应而淹没日志。

当启用并发时,请包含运行标识符,并将每个日志事件保持在一行内。这使得交错的输出内容可搜索。在生产环境日志器中,请使用结构化字段,而非解析自然语言文本。

处理动态页面和经过身份验证的会话

当静态抓取器返回有效的 HTML 但并非您在浏览器中看到的数据时,请勿立即替换整个抓取器。首先确定浏览器是从何处获取了缺失的状态。保持 parseBooks() 或等效解析器保持独立,仅在渲染或会话状态确有必要时,才更换抓取提供程序。

在渲染前检查嵌入式数据和 Fetch/XHR 调用

动态内容是指在初始响应后由 JavaScript 插入或更改的内容。它可能仍源自可访问的数据源,且该数据源比浏览器自动化更简单。

请按以下顺序检查这些位置:

  1. 在原始 HTML 中搜索可见值。
  2. 检查 <script type="application/ld+json"> 文档中嵌入的块及其他序列化状态。
  3. 在开发工具打开的状态下重新加载页面,并将“网络”请求过滤为 Fetch/XHR。
  4. 选择返回记录的请求,并检查其方法、URL、有效载荷、响应以及分页字段。
  5. 确认允许重现该操作,并且能够通过合法途径获取所需的凭据或令牌。

对于嵌入的 JSON-LD,Jsoup 可以定位脚本,而 JSON 库则可解析其文本:

Document document = Jsoup.parse(html, pageUri.toString());

for (Element script : document.select(
        "script[type=application/ld+json]")) {
    String json = script.data().trim();
    if (!json.isBlank()) {
        // Parse with the JSON library selected for the project.
    }
}

对于 Fetch/XHR 端点,请像调用其他请求一样使用 HttpClient 就像调用任何其他请求一样:

HttpRequest request = HttpRequest.newBuilder(apiUri)
        .timeout(Duration.ofSeconds(30))
        .header("Accept", "application/json")
        .GET()
        .build();

HttpResponse<String> response = client.send(
        request,
        HttpResponse.BodyHandlers.ofString());

然后使用 response.body()org.json或其他选定的库进行解析。当底层允许的响应中已包含稳定的结构化字段时,请勿抓取可视化 DOM。

网络检查还能揭示分页是使用页码、游标、POST 请求体还是请求头。不过,在开发者工具中捕获的请求可能包含过期签名、用户专属授权或反 CSRF 值。若在未授权的会话外重放该请求,可能会失败或违反政策。 仅重现您的用例被允许发出的最小稳定请求。

这种复杂度最低的工作流是动态 Web 抓取(Java)的核心:先检查数据路径,仅当数据访问依赖于执行时才添加执行逻辑。

对于仅涉及浏览器的行为,请选择托管式 JavaScript 渲染或 Selenium

如果必须运行 JavaScript,请根据所需的交互方式在“渲染页面检索”和“完全浏览器控制”之间进行选择:

需求

受管渲染类别

Selenium

在脚本执行完成后返回 HTML

当服务支持所需的等待条件时,此方案非常适合

虽然可行,但需自行管理浏览器生命周期

点击、滚动、输入、上传或协调多个步骤

取决于服务商支持的指令模型

非常适合

复用现有的 Jsoup 选择器

使用 Jsoup 解析返回的 HTML

传递 getPageSource() 给 Jsoup

运维负担

浏览器基础设施由第三方托管

您需要管理浏览器、驱动程序、内存、崩溃及扩展性

在本地调试视觉行为

受供应商工具的限制

与头部模式和截图高度契合

受管渲染器应实现相同的 PageFetcher 边界并返回 FetchResult。这样可以保护解析器和爬虫。在确认提供商的当前端点、身份验证、渲染标志、转发头和Cookie、限制以及错误语义之前,请勿硬编码提供商适配器。

当所需状态仅在执行某些浏览器操作后才存在时(例如点击标签页、提交表单、滚动无限列表或等待客户端路由),请使用 Selenium。发布时,请验证当前 selenium-java 版本及浏览器驱动程序配置。源材料中固定的版本信息具有时效性。

一个最简的 Selenium Web 爬取 Java 实现路径如下:

ChromeOptions options = new ChromeOptions();
options.addArguments("--headless");

WebDriver driver = new ChromeDriver(options);

try {
    driver.get(targetUrl);

    WebDriverWait wait = new WebDriverWait(
            driver,
            Duration.ofSeconds(10));

    wait.until(
            ExpectedConditions.presenceOfElementLocated(
                    By.cssSelector(".result-card")));

    String renderedHtml = driver.getPageSource();
    URI finalUri = URI.create(driver.getCurrentUrl());

    List<Book> books = parseBooks(
            renderedHtml,
            finalUri);

    System.out.println(
            "Rendered records: " + books.size());
} finally {
    driver.quit();
}

对于证明数据已准备就绪的状态,请使用显式等待。当页面加载速度慢于预期时,固定的睡眠时间要么浪费时间,要么导致失败。如果点击触发了分页,请等待出现有意义的变化(例如旧的结果容器过期或页码标记更新),然后再进行读取。

始终将 quit() in finally中。仅关闭当前窗口可能会留下驱动程序进程。无头模式虽然去除了可见界面,但并不能保证对每个页面都能提升性能。若需了解无头浏览器的背景及其架构与 HTTP 客户端的差异,请将其视为与 HTML 解析相独立的操作性话题。

无论是托管渲染还是 Selenium,均不授予访问受限内容的权限。它们只是执行层面的选择,而非绕过安全策略的手段。

会话管理是指在不同请求之间传递用户状态,通常通过 Cookie 实现。将一个 CookieManager 可重复使用的 HttpClient,完成授权登录流程,并在后续页面中继续使用该客户端。

static HttpResponse<String> loginAndFetch(
        URI loginUri,
        URI dataUri)
        throws IOException, InterruptedException {

    CookieManager cookieManager = new CookieManager();
    cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);

    HttpClient sessionClient = HttpClient.newBuilder()
            .cookieHandler(cookieManager)
            .followRedirects(HttpClient.Redirect.NORMAL)
            .connectTimeout(Duration.ofSeconds(20))
            .build();

    String username = requireEnv("SCRAPER_USERNAME");
    String password = requireEnv("SCRAPER_PASSWORD");

    String form = "username=" + encode(username)
            + "&password=" + encode(password);

    HttpRequest login = HttpRequest.newBuilder(loginUri)
            .timeout(Duration.ofSeconds(30))
            .header(
                    "Content-Type",
                    "application/x-www-form-urlencoded")
            .POST(HttpRequest.BodyPublishers.ofString(form))
            .build();

    HttpResponse<String> loginResponse = sessionClient.send(
            login,
            HttpResponse.BodyHandlers.ofString());

    if (loginResponse.statusCode() < 200
            || loginResponse.statusCode() >= 400) {
        throw new IOException(
                "Login failed with status "
                        + loginResponse.statusCode());
    }

    HttpRequest protectedPage =
            HttpRequest.newBuilder(dataUri)
                    .timeout(Duration.ofSeconds(30))
                    .GET()
                    .build();

    return sessionClient.send(
            protectedPage,
            HttpResponse.BodyHandlers.ofString());
}

static String requireEnv(String name) {
    String value = System.getenv(name);
    if (value == null || value.isBlank()) {
        throw new IllegalStateException(
                "Missing environment variable " + name);
    }
    return value;
}

static String encode(String value) {
    return URLEncoder.encode(
            value,
            StandardCharsets.UTF_8);
}

本示例仅涵盖一个用于建立 Cookie 的简单表单。受 CSRF 保护的登录流程通常需要先执行一次 GET 请求,提取隐藏令牌或其他服务器提供的值,并随表单一起提交该值。某些应用程序使用 OAuth、多因素认证、承载令牌、设备验证或与浏览器绑定的状态。 对于这些系统,不存在通用的安全登录方案。

请通过经过身份验证的页面标记或预期的重定向来验证成功,而不仅仅是 200 登录响应。若爬取涉及无关域名,请限制Cookie的接受范围,且切勿记录Cookie存储内容。在调试域名、路径、过期时间、 SecureSameSite 行为问题时提供帮助。

验证并持久化抓取结果

打印出的字段仅能证明选择器曾匹配过一次,但无法证明输出结果可用。应将成功标准调整为:通过必填字段检查并存入持久化存储的、经过规范化处理且可追溯的记录。这正是教程级抓取工具蜕变为可靠数据管道的关键所在。

规范化字段并过滤不完整的记录

在提取后立即进行标准化处理,确保每个下游组件看到的结构一致。压缩重复的空白符,在适当情况下将普通空格转换为不换行空格,拒绝留空的必填字段,并保留生成每条记录的源页面。

record RawBook(
        String title,
        String priceText,
        String detailUrl,
        URI sourcePage) {}

record Book(
        String title,
        String priceText,
        URI detailUrl,
        URI sourcePage) {}

static Optional<Book> normalize(RawBook raw) {
    String title = cleanText(raw.title());
    String price = cleanText(raw.priceText());
    String detail = cleanText(raw.detailUrl());

    if (title.isBlank()
            || price.isBlank()
            || detail.isBlank()
            || raw.sourcePage() == null) {
        return Optional.empty();
    }

    URI detailUri;
    try {
        detailUri = URI.create(detail);
    } catch (IllegalArgumentException error) {
        return Optional.empty();
    }

    if (!detailUri.isAbsolute()) {
        return Optional.empty();
    }

    return Optional.of(new Book(
            title,
            price,
            detailUri,
            raw.sourcePage()));
}

static String cleanText(String value) {
    if (value == null) {
        return "";
    }

    return value
            .replace('\u00A0', ' ')
            .replaceAll("\\s+", " ")
            .trim();
}

根据预期用途定义必填字段,而非仅基于“易于选取”的标准。详细信息 URL 可能对可追溯性至关重要,而图片 URL 则可能为可选字段。如果需要计算或排序价格,请将金额和货币解析为目标系统特有的类型化字段。 不要移除符号,也不要假设每个网站都使用相同的小数点和千位分隔符。

通过计数器或有限范围的拒绝原因样本,让无效记录保持可见。若对所有不匹配情况均默认跳过,可能会导致选择器回归测试在数据量极少的情况下看似成功。同时,不要仅仅为了解释一行错误数据而保留整个敏感页面。

即使源 URL 不属于最终业务模式的一部分,也应将其保留在记录中。这使您能够重现数据提取过程、调查冲突,并识别出哪个页面模板出现故障。

去重并输出 JSON、CSV 或数据库结果

选择一个具有领域含义的去重键。规范化的详细信息 URL 通常优于标题,因为标题可能会重复或发生变化。对于小型内存内运行,请使用 LinkedHashMap:

Map<URI, Book> uniqueByUrl = new LinkedHashMap<>();

for (Book book : normalizedBooks) {
    uniqueByUrl.putIfAbsent(
            normalize(book.detailUrl()),
            book);
}

List<Book> uniqueBooks =
        List.copyOf(uniqueByUrl.values());

如果后续页面包含更新鲜的值,请使用显式的合并规则,而不是 putIfAbsent。无论采用哪种方式,均需记录重复项计数。

输出

最佳匹配

主要注意事项

CSV

用于电子表格或简单批处理任务的平面记录

转义逗号、引号和换行符

JSON

嵌套记录、API、归档文件或模式演变

使用 JSON 序列化器,而非手动构建字符串

数据库

增量加载、查询、约束和多运行历史记录

批量写入、使用事务以及定义唯一键

一个无依赖的 CSV 写入器,用于当前 Book 记录的无依赖 CSV 写入器可以如下所示:

static void writeCsv(
        Path path,
        Iterable<Book> books)
        throws IOException {

    try (BufferedWriter writer = Files.newBufferedWriter(
            path,
            StandardCharsets.UTF_8)) {

        writer.write(
                "title,price,detail_url,source_page");
        writer.newLine();

        for (Book book : books) {
            writer.write(String.join(",",
                    csv(book.title()),
                    csv(book.priceText()),
                    csv(book.detailUrl().toString()),
                    csv(book.sourcePage().toString())));
            writer.newLine();
        }
    }
}

static String csv(String value) {
    String escaped = value.replace("\"", "\"\"");
    return "\"" + escaped + "\"";
}

对于 JSON,在选择并验证其依赖项版本后,可以使用 Jackson 等库对 Java 记录进行序列化。对于数据库,请使用 JDBC 预编译语句,将记录分批处理(每批数量有限),有意识地提交事务,并让唯一约束来强制执行标识规则。

对于文件输出,应先写入临时路径,并在写入器成功关闭后才将其移动到目标位置。这样可以防止因运行失败而导致半写入的 CSV 或 JSON 文件被误认为完整数据集。

切勿仅为在最后写入而保留无限大的结果集。应将接收到的记录流式传输至写入器,将其入队至单个数据库写入消费者,或分批刷新有限数量的记录。将网络并发限制与输出队列容量分开,这样当存储速度较慢时,会产生回压,而非导致内存无限增长。

通过计数和空结果警告监控提取健康状况

即使 HTTP 状态码成功,也可能返回零条记录,原因可能是选择器发生变化、出现同意页面,或者请求收到了不同的模板。请在解析器边界处跟踪页面和记录指标:

final class ExtractionMetrics {
    private final LongAdder pagesParsed = new LongAdder();
    private final LongAdder emptyPages = new LongAdder();
    private final LongAdder accepted = new LongAdder();
    private final LongAdder rejected = new LongAdder();
    private final LongAdder duplicates = new LongAdder();

    void recordPage(
            URI uri,
            int containers,
            int acceptedOnPage,
            int rejectedOnPage) {

        pagesParsed.increment();
        accepted.add(acceptedOnPage);
        rejected.add(rejectedOnPage);

        if (containers == 0 || acceptedOnPage == 0) {
            emptyPages.increment();
            System.err.printf(
                    "empty-extraction url=%s "
                            + "containers=%d accepted=%d "
                            + "rejected=%d%n",
                    uri,
                    containers,
                    acceptedOnPage,
                    rejectedOnPage);
        }
    }

    void duplicate() {
        duplicates.increment();
    }

    void printSummary() {
        System.out.printf(
                "extraction pages=%d empty=%d "
                        + "accepted=%d rejected=%d "
                        + "duplicates=%d%n",
                pagesParsed.sum(),
                emptyPages.sum(),
                accepted.sum(),
                rejected.sum(),
                duplicates.sum());
    }
}

将选中的容器数量与接收到的记录数量分开传递。容器数量为零表明页面结构或响应有误;而接收记录为零的容器则表明必填字段验证失败。这些属于不同的故障类型。

对于计划执行的 Java Web 抓取任务,应将计数与基于自身历史数据配置的预期值进行比较,而非采用通用基准。当出现意外的零值、剧烈变化或高拒绝率时发出警告,并保留一小部分诊断样本。保持常规日志简洁:一条请求失败记录、一条必要的提取警告记录,以及一条运行摘要。

为爬虫做好负责任的生产环境部署准备

生产就绪主要关乎边界设定。明确爬虫可请求的内容、可执行的工作量、可保留的数据范围以及停止条件。这些控制措施比向 Java 网页抓取技术栈中添加另一个库更为重要。

设置爬取边界、请求配额、密钥及策略检查

配置包含协议、主机和路径前缀的白名单。在安排任务前,拒绝站外重定向和“下一页”链接。 设置最大页面数、最大耗时、最大响应大小、单主机并发数、总请求尝试次数以及输出限制。请求配额可防止选择器错误或循环日历导致爬取无休止地进行。

通过并发上限进行限流,并在适当情况下设置请求间隔的最小值。注意速率限制响应,并降低请求压力,而非将重试视为额外的吞吐量。仅获取数据集所需的资源,缓存发现响应,并在直接使用 HTML 即可满足需求时避免下载图片、脚本或样式表。

将密钥置于源代码控制之外。从环境变量或密钥管理器中读取凭据和 API 密钥,在启动时进行验证,并从日志中屏蔽这些信息。审查 HTTP 头信息,将其视为协议输入而非装饰。仅发送请求所需的头信息,并在目标要求时使用真实的应用程序标识符。

在收集数据之前,请审查以下四个独立方面:

  • 适用法律:要求因司法管辖区、数据类型、访问方式和用途而异。
  • 网站条款:合同限制和允许用途可能与技术可访问性存在差异。
  • 隐私:尽量减少个人数据,明确保留期限,确保输出安全,并记录数据收集目的。
  • robots.txt:将爬虫指令视为操作信号,并审查标准化的机器人排除协议

上述任何一项检查均不能替代其他检查,且本教程不构成法律建议。专门的网络爬虫法律合规框架可辅助审查工作,但最终措辞及实际应用场景仍应通过该网站的常规合规或编辑流程审核。

使用简明扼要的上线检查清单

在安排爬虫运行前,请确认:

首先运行一个刻意设置为较小的“金丝雀”测试。在增加页面配额之前,检查其状态分布、记录数量、拒绝原因、输出文件以及清理行为。

关键要点

  • 从原始响应开始。使用 HttpClient 和 Jsoup 处理服务器生成的 HTML,若数据以 JSON 格式提供且符合规范,则优先选用该源,仅在必要时才添加浏览器执行环节。
  • 将获取、解析、爬取和输出保持分离,以便相同的选择器在重试、会话、渲染或请求提供程序发生变化时仍能正常工作。
  • 对分页、并发、尝试次数、耗时和输出大小设置硬性限制。虚拟线程仍需使用信号量或等效的处理中上限机制。
  • 仅对合理的瞬时故障进行重试,遵守 Retry-After,采用带抖动的有限退避策略,并保留终端故障信息,附带足够上下文以便诊断。
  • 将记录验证、去重、空结果警告和持久化存储视为 Web 抓取 Java 正确性的一部分,而不是提取后的清理工作。

常见问题

这些答案涉及常导致过度设计的工具边界问题。应根据数据所在位置、所需的状态转换、请求是否可安全重复,以及目标系统和自身运行时施加的限制来做出选择。每当页面或访问模型发生变化时,请重新核对这些假设。

Jsoup 能执行 JavaScript 吗?

不,Jsoup 不执行 JavaScript。它解析您提供的 HTML 或 XML,并提供 DOM 遍历和 CSS 选择器 API。它不会点击控件、等待异步更新、维护实时浏览器 DOM,也不会暴露 JavaScript 变量(除非这些变量已被序列化到 HTML 中)。 当其他组件渲染完页面后,您仍可通过向 Jsoup 传递最终的标记来使用它。它可以读取脚本元素中的文本,但无法执行该脚本。

Java 爬虫在何种情况下应使用 Selenium 而不是 HttpClient 和 Jsoup?

当所需数据或状态仅在真实浏览器行为发生后才存在时(例如点击、滚动、填写表单、处理客户端路由或等待 JavaScript 驱动的更新),应使用 Selenium。它对于调试页面如何达到该状态也很有用。如果初始 HTML 或经过授权的 JSON 请求中已包含数据, HttpClient 则 HttpClient 和 Jsoup 更为简单、确定性更强且更易于操作。真实浏览器会增加启动时间、内存占用、驱动程序生命周期管理,并带来更多故障模式。

在 Java 爬虫中应如何限制并发请求?

设置明确的“进行中”请求上限,并从较低的起始值开始逐步调整。使用固定执行器时,任务池大小即为上限;使用虚拟线程时,应采用信号量或速率限制器,因为执行器可能创建大量任务。 针对每个主机设置限制,限制队列中的任务和输出缓冲区,监控延迟和状态分布,并在限流或故障率上升时降低并发度。如果一个进程访问多个主机,请设置一个独立的全局上限。

Java 爬虫应针对哪些 HTTP 错误进行重试?

重试 429,选定 5xx 响应,例如 500, 502, 503,以及 504,请求超时,以及在尝试次数受限时发生的瞬时连接故障。遵守 Retry-After ,否则请采用带抖动的指数退避策略。在查明原因并予以纠正之前,请勿反复重试大多数 4xx 响应,直到查明并排除根本原因。对于非幂等 POST 请求需格外谨慎,因为重复发送此类请求可能会导致服务器端操作重复,即使在超时后也是如此。在重试配额耗尽后,记录最终状态或异常。

结论

一个可靠的 Java 爬虫首先要做出正确的数据访问决策。在选择工具之前,请先检查返回的 HTML 和浏览器网络流量。对于静态页面,复用一个 HttpClient,使用 Jsoup 进行解析,构建类型化记录,并确保解析器与传输细节解耦。随后在同一代码库基础上扩展,实现有限分页、已访问 URL、HTML 缓存、显式并发限制、选择性重试、基于 Cookie 的会话、数据验证以及持久化输出。

关键控制措施清晰且有限:页面上限、请求时限、待处理请求许可、重试次数、响应诊断、必填字段、输出边界以及干净的关闭流程。这些控制措施使故障原因可追溯,同时让你能够修改其中一层,而不会破坏Web爬取Java管道的其余部分。

仅当需求中包含浏览器交互时才使用 Selenium,而非仅仅因为页面碰巧使用了 JavaScript。如果该工作流确实需要点击、滚动、表单或渲染状态,且您无法自行管理浏览器基础设施,那么 WebScrapingAPI 浏览器 API 是一个值得评估的合理托管选项。

从一个获准访问的页面开始,保存一个解析器测试案例,并运行一个小型先导测试。一旦计数、失败情况和输出结果看起来正确,就在继续遵守目标网站的限制条件和您自身的合规审查要求的同时,逐步提高爬取预算。

关于作者

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

Raluca Penciuc

全栈开发工程师

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

开始构建

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

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