Zum Inhalt springen
Zurück zum Blog

Web-Scraping mit Java: Einen zuverlässigen Java-Scraper erstellen

Raluca PenciucZuletzt aktualisiert am 28 min read
Web-Scraping mit Java: Einen zuverlässigen Java-Scraper erstellen
TL;DR: Beginnen Sie mit HttpClient und Jsoup, wenn Daten im zurückgegebenen HTML vorhanden sind, rufe bei Bedarf eine exponierte JSON-Quelle auf und füge Rendering oder Selenium nur für browserabhängige Zustände hinzu. Dieses Java-Tutorial zum Web-Scraping erweitert eine Java-21-Codebasis um Paginierung, begrenzte Parallelität, Wiederholungsversuche, Sitzungen, Validierung und dauerhafte Ausgabe.

Web-Scraping ist die programmgesteuerte Extraktion von Informationen aus Websites, damit die resultierenden Daten gespeichert, validiert und analysiert werden können. Ein praktischer Java-Stack für das Web-Scraping beginnt in der Regel mit den in Java 21 integrierten HttpClient für Anfragen und Jsoup für das HTML-Parsing und fügt erst dann weitere Tools hinzu, wenn die Seite dies erfordert.

Die Schwierigkeit besteht selten darin, ein einzelnes Element von einer Seite auszuwählen. Zuverlässigkeitsprobleme treten auf, wenn Paginierungsschleifen auftreten, parallele Worker einen Dienst überlasten, vorübergehende Ausfälle fälschlicherweise als dauerhafte Fehler interpretiert werden, JavaScript die eigentliche Datenquelle verbirgt, authentifizierte Anfragen ihre Cookies verlieren oder ein Selektor stillschweigend null Datensätze zurückgibt.

Dieser Leitfaden führt Sie durch diese Phasen beim Aufbau eines kleinen Java-Webscrapers. Zunächst klassifizieren Sie die Seite als Server-HTML, JSON, gerenderten Inhalt oder Browser-Interaktion. Anschließend erstellen Sie ein typisiertes „Fetch-Parse-Crawl-Output“-Design, verfolgen die Paginierung ohne doppelte Downloads, vergleichen feste Executor-Instanzen mit virtuellen Threads, implementieren statusabhängige Wiederholungsversuche, erhalten autorisierte Sitzungen aufrecht und schreiben validierte Ergebnisse.

Die Beispiele verwenden eine öffentliche Übungswebsite und absichtlich festgelegte Sicherheitsgrenzen. Bevor Sie dieselben Muster an anderer Stelle anwenden, überprüfen Sie die Zugriffsanforderungen, die aktuellen Versionen der Abhängigkeiten, die Zielselektoren und die Richtlinien, die für Ihre spezifische Datenerfassung gelten.

Wählen Sie den einfachsten Java-Scraping-Ansatz

Der beste Stack ist derjenige mit der geringsten Komplexität, der die Daten zuverlässig zurückgibt. Beginnen Sie damit, die URL einmal abzufragen, den Antworttext zu überprüfen und ihn mit der Darstellung im Browser zu vergleichen. Wenn die Werte bereits in der Serverantwort vorhanden sind, reicht in der Regel Java HttpClient plus Jsoup in der Regel aus. Wenn die Seite eine JSON-Anfrage bereitstellt, rufen Sie stattdessen diesen Endpunkt auf. Fügen Sie Rendering oder Browser-Automatisierung erst dann hinzu, wenn die Daten tatsächlich von JavaScript oder Benutzerinteraktion abhängen.

Diese Entscheidungsregel sorgt dafür, dass ein Java-Web-Scraping-Projekt überschaubar, testbar und kostengünstig im Betrieb bleibt.

Klassifizieren Sie die Seite: HTML, JSON, gerendertes DOM oder Interaktion

Bevor Sie sich für eine Bibliothek entscheiden, klassifizieren Sie, wie die Zieldaten den Browser erreichen:

Seitentyp

Was Sie beobachten

Kleinster geeigneter Ansatz

Vom Server zurückgegebenes HTML

Der Titel, der Preis, die Zeilen oder die Links erscheinen in „Quelltext anzeigen“ oder in der rohen HTTP-Antwort

HttpClient ruft die Seite ab, Jsoup analysiert sie

Offenes JSON

DevTools zeigt eine Fetch/XHR-Antwort an, die die Datensätze enthält

Die dokumentierte oder zulässige HTTP-Anfrage nachstellen und JSON analysieren

Gerendertes DOM

Die ursprüngliche Antwort enthält die Daten noch nicht, aber JavaScript fügt sie nach dem Laden ein

Verwenden Sie einen verwalteten Renderer oder einen Browser und übergeben Sie anschließend den resultierenden HTML-Code an denselben Parser

Interaktion ausschließlich im Browser

Daten werden erst nach Klicks, Scrollen, dem Absenden von Formularen oder einem clientseitigen Zustandswechsel angezeigt

Verwenden Sie Selenium oder ein anderes Tool zur Browser-Automatisierung

Leiten Sie den Seitentyp nicht aus der visuellen Komplexität ab. Eine hochgradig interaktive Oberfläche kann von einem einfachen JSON-Endpunkt gelesen werden, während eine einfache Tabelle möglicherweise im Browser zusammengestellt wird.

Verwenden Sie folgende Prüfsequenz:

  1. Senden Sie eine normale GET-Anfrage und speichern Sie den Status, den Inhaltstyp und den ersten Teil des Hauptteils.
  2. Durchsuchen Sie den zurückgegebenen HTML-Code nach einem Wert, den Sie auf dem Bildschirm sehen können.
  3. Öffnen Sie die DevTools, laden Sie die Seite neu und filtern Sie das Netzwerk-Panel nach „Fetch/XHR“.
  4. Untersuchen Sie Antwortdaten, Anfragemethoden, Abfrageparameter, Header und Paginierungstoken.
  5. Entscheiden Sie, ob der erforderliche Zugriff eine direkte HTML-Anfrage, eine JSON-Anfrage, gerenderte Ausgabe oder eine Browser-Interaktion ist.

Ein JSON-Endpunkt ist nicht automatisch öffentlich oder uneingeschränkt zugänglich. Halten Sie die erforderliche Authentifizierung ein, beachten Sie die Nutzungsbedingungen der Website und vermeiden Sie es, kurzlebige Token blind zu kopieren. Ebenso ist die Browser-Automatisierung kein Allheilmittel für Zugriffskontrollen. Sie bietet Ihrem Java-Code lediglich eine echte Browser-Ausführungsumgebung.

Bei statischen Seiten funktioniert das Web-Scraping mit Jsoup gut, da Jsoup eine HTML-Zeichenkette in ein abfragbares Dokument umwandelt. Für eine tiefergehende Auseinandersetzung mit dem HTML-Parsing in Java mit Jsoup kann die Parser-Schicht unabhängig von der Netzwerk-Schicht untersucht werden. Diese Trennung wird später wichtig, wenn sich der Abrufmechanismus ändert.

Planen Sie ein kleines „Abrufen-Parsen-Crawlen-Ausgeben“-Design

Vermeiden Sie es, HTTP-Aufrufe, Selektoren, Paginierung, Wiederholungsversuche und das Schreiben von Dateien in einer einzigen main Methode. Ein kleines Design mit vier Zuständigkeiten reicht aus:

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

Stellen Sie die Abrufgrenze mit einer Schnittstelle dar:

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

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

Der Parser sollte nichts über Proxys, Cookies, Wiederholungsversuche oder Browser-Treiber wissen. Er empfängt Text und eine Basis-URI und gibt anschließend Domänen-Einträge zurück. Der Crawler ist für Seitenbegrenzungen, besuchte URLs und die Reihenfolge der Anfragen zuständig. Die Ausgabeschicht ist für die Normalisierung und Speicherung zuständig.

Halten Sie Transportmetadaten auch dann verfügbar, wenn der Parser sie nicht nutzt. Statuscodes, Antwort-Header, endgültige Umleitungsziele und die verstrichene Zeit sind nützlich für Entscheidungen über Wiederholungsversuche und zur Diagnose. Die Weitergabe dieser Details FetchResult verhindert, dass der Parser an einen bestimmten HTTP-Client gekoppelt wird.

Dies ist kein Framework. Es bietet gerade genug Struktur, um einen Java-Web-Scraper weiterentwickeln zu können, ohne Selektoren jedes Mal neu schreiben zu müssen, wenn sich die Anforderungsschicht ändert. Außerdem schafft es klare Testgrenzen: Gespeichertes HTML kann das Parsen testen, ein gefälschter PageFetcher kann die Crawling-Logik testen, und temporäre Dateien können die Ausgabe prüfen.

Erstellen Sie den Java-21-Scraper-Kern für ein Java-Web-Scraping-Projekt

Setzen Sie nun das Entscheidungsmodell in ein funktionierendes Projekt um. Die erste Version ruft eine Seite mit zulässigen Praktiken ab, parst Produktkarten, löst relative Links auf und gibt eingegebene Datensätze aus. In späteren Abschnitten werden derselbe Fetcher und derselbe Parser erweitert, anstatt sie zu ersetzen.

Erstellen Sie das Projekt und fügen Sie HttpClient und Jsoup hinzu

Verwenden Sie ein vollständiges Java 21 JDK, keine reine Laufzeitumgebung:

java -version
javac -version

HttpClient ist Teil des JDK, daher ist Jsoup die einzige für das statische Beispiel erforderliche Abhängigkeit. Das bereitgestellte Quellmaterial hat Jsoup festgepinnt 1.21.2festgelegt, doch diese Version ist zeitabhängig. Überprüfen Sie vor der Veröffentlichung die aktuelle Java-21-kompatible Version und führen Sie den Build erneut durch.

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

Das kompakte Gradle-Äquivalent lautet:

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

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

Wählen Sie ein Build-Tool und bleiben Sie dabei. Maven macht den Abhängigkeitsgraphen in XML explizit; Gradle ist kürzer, führt aber eine eigene Build-DSL ein. Keines der beiden verändert die Architektur des Scrapers.

Ein minimalistisches Quellcode-Layout sorgt für Übersichtlichkeit:

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

Das Tutorial verwendet die unten stehende einzelne Datei, damit du es schnell ausführen kannst. Sobald die erste Anfrage funktioniert, teile die verschachtelten Typen auf diese Dateien auf, ohne ihre öffentlichen Schnittstellen zu ändern. Dadurch lassen sich spätere Crawler-, Retry- und Ausgabeklassen leichter isoliert testen.

Modellieren Sie Datensätze und wählen Sie robuste CSS-Selektoren

Modellieren Sie jede extrahierte Zeile als Java-Record, anstatt parallele Listen von Strings zu übergeben:

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

Ein CSS-Selektor sollte eine stabile Struktur beschreiben, nicht eine zufällige Position. Bevorzugen Sie einen wiederholten Produkt-Container plus semantische Nachkommen, wie zum Beispiel article.product_pod, h3 a, und .price_color. Vermeide Selektoren, die aus generierten Klassennamen oder langen div:nth-child(...) Ketten, es sei denn, das Markup lässt Ihnen keine bessere Wahl. Ein Spickzettel für CSS-Selektoren ist nützlich, wenn Sie Attribut-, Kind-, Geschwister- oder zusammengesetzte Selektoren benötigen.

Behandeln Sie jede innere Übereinstimmung als optional. selectFirst() gibt null , wenn sich das Markup ändert; lesen Sie das Element daher erst aus, nachdem Sie es überprüft haben. Normalisieren Sie außerdem den Text an der Grenze und bewahren Sie die Quellseite zur Rückverfolgbarkeit auf.

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

Die an Jsoup.parse() , macht absUrl("href") nützlich macht. Sie löst Links wie catalogue/book.html anhand der Seite, auf der sie enthalten waren. Dies ist sicherer als das Verketten von Zeichenfolgen, insbesondere wenn der Crawler durch Paginierung in ein Unterverzeichnis gelangt.

Die Robustheit eines Selektors ist nicht dasselbe wie seine Unbestimmtheit. Ein Selektor wie a kann zwar eine Neugestaltung überstehen, aber nicht mehr relevante Navigationslinks zurückgeben. Wählen Sie den engsten stabilen Container aus, extrahieren Sie nur die Felder, die zu ihm gehören, und zählen Sie fehlende Pflichtfelder. Speichern Sie während der Entwicklung eine kleine zulässige HTML-Vorlage und testen Sie den Parser daran. So lassen sich Regressionen im Markup von Netzwerkproblemen unterscheiden.

Führen Sie einen vollständigen Scraper für statische Seiten aus

Das folgende Ein-Datei-Programm ist ein kompilierbarer Ausgangspunkt für das Web-Scraping mit Java HttpClient. Es trennt die Aufgaben des Abrufens und des Parsens, überprüft den Status und den Inhaltstyp und begrenzt sowohl die Verbindungsdauer als auch die Zeit pro Anfrage.

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

Kompilieren Sie es mit Maven oder Gradle, damit Jsoup im Klassenpfad enthalten ist. Die Ausgabe sollte die abgerufene URL, die Anzahl der extrahierten Elemente und bis zu drei Datensätze enthalten. Codieren Sie keine erwartete Seitenanzahl oder Beispielüberschriften fest ein, da sich der Inhalt und das Markup der Beispiel-Website ändern können.

Einige Details sind bewusst so gewählt. Eine HttpClient Instanz wird wiederverwendet, da Clients die Verbindungen und die Konfiguration verwalten. Die Anfrage hat eine begrenzte Frist, Weiterleitungen werden explizit behandelt, und die URI der endgültigen Antwort wird zur Basis-URI des Parsers. Das Programm prüft den 2xx-Bereich, anstatt nur 200, und überprüft anschließend, ob die Antwort HTML ähnelt, bevor Selektoren angewendet werden.

Das Beispiel löst bei einem Transport- oder Protokollfehler eine Ausnahme aus, da ein einseitiger Befehl deutlich sichtbar fehlschlagen sollte. Ein Crawler benötigt ein differenzierteres Verhalten: Er muss den Fehler klassifizieren, nur vorübergehende Fälle erneut versuchen, das Endergebnis protokollieren und fortfahren, wenn die Richtlinien dies zulassen. Wir fügen dieses Verhalten später hinzu, ohne parseBooks().

Der User-Agent im Beispiel identifiziert die Anwendung, anstatt sich als bestimmter Browser auszugeben. Manche Websites erfordern möglicherweise zusätzliche Header, doch Header sollten die Anfrage widerspiegeln, die du tatsächlich stellst. Wenn ein späterer Fetch-Adapter ein Geheimnis benötigt, lies es mit System.getenv() und geben Sie deutlich an, dass ein Fehler vorliegt, wenn es fehlt.

Verwandeln Sie die Extraktion in einen kontrollierten Crawler

Ein Ein-Seiten-Extraktor wird zu einem Java-Web-Crawler, wenn er zusätzliche URLs auswählt und diesen folgt. Diese Änderung führt Zustand und Beendigungsregeln ein. Behalten Sie den bestehenden Abrufer und Parser bei und fügen Sie dann einen Crawl-Koordinator hinzu, der die aktuelle Seite, die Liste der besuchten Seiten, das Seitenlimit und den zwischengespeicherten Antworttext verwaltet.

Paginierung verfolgen und relative URLs auflösen

Java-Code für das Web-Scraping mit Paginierung sollte mehr als eine Stoppbedingung enthalten. Beende den Vorgang, wenn der nächste Link fehlt, die nächste URL ungültig ist, sich eine URL wiederholt, eine Antwort fehlschlägt oder eine konfigurierte Seitenobergrenze erreicht ist. Die Obergrenze schützt dich vor fehlerhaftem Markup und versehentlichem Durchlaufen der gesamten Website.

Fügen Sie einen Ergebnistyp und eine Crawling-Methode um die bestehenden Elemente herum hinzu PageFetcher und parseBooks() Funktionen:

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

Diese Methode analysiert jeden abgerufenen Body zweimal: einmal nach Datensätzen und einmal nach dem nächsten Link. Bei kleinen Seiten ist dies ressourcenschonend, doch können Sie sowohl Datensätze als auch die nächste URI aus einem einzigen Parser-Aufruf zurückgeben, falls die Profilierung einen nennenswerten Aufwand anzeigt. Die wichtige Optimierung besteht darin, eine zweite Netzwerkanfrage zu vermeiden, nicht darin, eine winzige DOM-Analyse vorzeitig zu verkürzen.

Die Selektorgruppe unterstützt einen standardkonformen rel=next Link und die Pager-Struktur der Übungswebsite. Betrachten Sie dies als Ausgangspunkt, nicht als universellen Selektor. Untersuchen Sie den tatsächlichen Pager und halten Sie Navigationsselektoren von Produktselektoren getrennt, damit sich Änderungen leicht isolieren lassen.

Rufen Sie den Crawler während der Entwicklung mit einer bewusst kleinen Kapazität auf:

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());

Die Zahl 3 ist eine Sicherheitsgrenze für diesen Durchlauf, keine angenommene Gesamtzahl. Erhöhen Sie sie erst, nachdem Sie sich vergewissert haben, dass der Selektor für den nächsten Link innerhalb des beabsichtigten Hosts und Pfads bleibt. Im Produktionscode sollten Sie eine URL außerhalb der Website ablehnen, bevor Sie sie abrufen.

Bei der cursorbasierten JSON-Paginierung ist die Schleifenform dieselbe. Ersetzen Sie next.absUrl("href") durch die Extraktion des nächsten Cursors oder Tokens und brechen Sie ab, wenn das Token fehlt oder wiederholt wird. Erraten Sie keine Seitenzahlen, wenn der Server einen expliziten Fortsetzungswert liefert.

Verhindern Sie Schleifen und doppelte Downloads mit besuchten URLs und zwischengespeichertem HTML

Die Menge der besuchten URLs beantwortet eine Frage: Haben wir diese normalisierte URL bereits abgefragt? Der HTML-Cache beantwortet eine weitere: Haben wir bereits Inhalte für diese endgültige URL heruntergeladen? Sie benötigen beides, da Weiterleitungen mehrere angeforderte URLs auf eine Seite verweisen können und fehlerhafte Paginierungen rückwärts verweisen können.

Die Wiederverwendung von HTML-Erkennungsdaten ist ein praktisches Unterscheidungsmerkmal für Java-Crawler beim Web-Scraping. Ein gängiges, ineffizientes Design durchläuft zunächst die Paginierung, um URLs zu sammeln, und ruft dann jede URL während der parallelen Extraktion erneut ab. Der obige Code speichert jede erfolgreiche Antwort, sobald er den nächsten Link entdeckt, sodass eine spätere Parsing-Phase direkt auf den Cache zugreifen kann:

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

In dieser Pipeline findet kein HTTP-Aufruf statt. Wenn Sie das Parsen später parallelisieren, parallelisieren Sie diese zwischengespeicherten Werte oder übergeben Sie sie an Ihren eigenen Executor. Greifen Sie nicht stillschweigend auf einen weiteren Download zurück.

Bei der URL-Normalisierung ist Zurückhaltung geboten. Das Entfernen von Fragmenten ist im Allgemeinen angebracht, da Fragmente nicht in HTTP-Anfragen gesendet werden. Das Entfernen von Abfrageparametern ist im Allgemeinen nicht sicher, da Parameter Seiten, Filter oder Cursor identifizieren können. Definieren Sie Kanonisierungsregeln für das Ziel, anstatt Daten global zu löschen.

Ein In-Memory-Cache ist lediglich durch maxPages und der Seitengröße. Für ein kleines Tutorial-Crawling ist das akzeptabel. Bei einem Langzeitlauf sollten Sie Antwort-Snapshots auf der Festplatte speichern oder sie in begrenzten Batches verarbeiten. Das Zwischenspeichern sollte die Anzahl der Anfragen reduzieren und keinen unbegrenzten Heap erzeugen.

Führen Sie Anfragen parallel aus, ohne die Kontrolle zu verlieren

Parallelität ist ein Mittel zur Steigerung des Durchsatzes, kein Versprechen, dass ein Crawl schneller abgeschlossen wird. Der Remote-Dienst, das Netzwerk, die CPU, der Arbeitsspeicher und die Antwortbeschränkungen können alle zum Engpass werden. Beginnen Sie sequenziell, messen Sie die Leistung und führen Sie dann eine kleine, explizite Obergrenze ein. Ein kontrolliertes paralleles Java-Design für das Web-Scraping sollte die maximale Anzahl der laufenden Anfragen deutlich machen.

Verwenden Sie einen festen ExecutorService und schreibfreundliche parallele Sammlungen

Ein fester Pool sorgt dafür, dass die Anfrageobergrenze der Anzahl der Worker entspricht. Senden Sie pro Aufgabe eine eigenständige URL, behalten Sie jede Futureund warten Sie auf den Abschluss, damit fehlgeschlagene Aufgaben nicht verloren gehen.

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 unterstützt häufige parallele Hinzufügungen, ohne die gesamte zugrunde liegende Struktur kopieren zu müssen. ConcurrentHashMap.newKeySet() bietet atomares „Seen-or-Not-Seen“-Verhalten durch add(). Ein gemeinsam genutzter ArrayList oder HashSet ist für parallele Schreibvorgänge unsicher, sofern der Zugriff nicht synchronisiert wird. CopyOnWriteArrayList ist in der Regel ein schlechter Ergebnis-Sink, da bei jedem Schreibvorgang das zugrunde liegende Array kopiert wird.

Die lokale futures Liste bleibt eine normale ArrayList , da nur der Koordinator-Thread in sie schreibt. Thread-Sicherheit sollte dort angewendet werden, wo tatsächlich eine gemeinsame Nutzung stattfindet, und nicht reflexartig auf jede Sammlung.

Der Aufruf von get() ist wichtig. Wenn eine Aufgabe eine Ausnahme auslöst, erhält der Koordinator ein ExecutionException anstatt eine optimistische Erfolgsmeldung auszugeben, während Seiten unbemerkt fehlgeschlagen sind. In einem lang laufenden Crawler sollten Sie diese Ursache aufschlüsseln, die URL protokollieren und entscheiden, ob andere Aufgaben fortfahren dürfen. Wenn der Koordinator unterbrochen wird, sollten Sie den Unterbrechungsstatus nach der Bereinigung beibehalten, anstatt ihn zu ignorieren.

Verwenden Sie parallelStream() nur, um ein paar Zeilen Code zu sparen. Es nutzt standardmäßig den üblichen Fork-Join-Pool, der das Anforderungslimit verbirgt und möglicherweise mit nicht damit zusammenhängenden Aufgaben im selben Prozess um Ressourcen konkurriert. Ein expliziter Executor gibt dem Scraper eine beobachtbare Warteschlange, einen Lebenszyklus und eine Kapazität.

Ein fester Pool ist leicht nachzuvollziehen und bleibt eine gute Standardeinstellung für einen überschaubaren Java-Web-Scraper. Beginne mit einem niedrigen, gemessenen Limit, beobachte Latenz und Statuscodes und erhöhe den Wert langsam. Das bereitgestellte Quellenmaterial erwähnt kleine Demonstrationsbereiche, aber es gibt keine allgemein sichere Thread-Anzahl. Die vom Ziel veröffentlichten Limits und dein eigenes Anfragebudget sind wichtiger.

Verwenden Sie virtuelle Threads mit einem Semaphor, wenn die Arbeitslast dies rechtfertigt

Virtuelle Threads in Java 21 erleichtern die Skalierung von blockierendem E/A-Code, ohne dass ein großer plattformseitiger Thread-Pool gepflegt werden muss. JEP 444 von OpenJDK beschreibt sie als leichtgewichtige, von der JVM verwaltete Threads, die zur Unterstützung von parallelen Anwendungen mit hohem Durchsatz gedacht sind. Sie machen es jedoch nicht überflüssig, die Belastung eines Remote-Dienstes zu begrenzen.

Verwenden Sie einen virtuellen Thread pro Aufgabe und ein Semaphor für die tatsächliche HTTP-Obergrenze:

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

Die Anzahl der virtuellen Threads mag zwar groß sein, aber nur maxInFlight Aufgaben können in den Abrufblock eintreten. Erwerben Sie die Berechtigung unmittelbar vor der eingeschränkten Operation und geben Sie sie finally. Halten Sie sie nicht, während Datensätze in eine Datenbank geschrieben werden oder während nicht damit zusammenhängende CPU-Aufgaben laufen, es sei denn, diese Vorgänge unterliegen derselben Begrenzung.

Wenn die Paginierungserkennung bereits HTML zwischengespeichert hat, übermitteln Sie die Parsing-Aufgaben über den Cache, anstatt fetcher erneut aufzurufen. Das Parsen ist in der Regel eine CPU-Aufgabe; führen Sie daher einen Benchmark durch, bevor Sie ihr Tausende virtueller Threads zuweisen. Virtuelle Threads sind in erster Linie dafür ausgelegt, blockierende Operationen effizienter zu gestalten, nicht um jede Berechnung zu beschleunigen.

Auswahl

Bevorzugen Sie dies, wenn

Hauptsteuerung

Fester Pool

Die Anzahl der Aufgaben ist moderat und es gibt klar definierte, einfach begrenzte Worker

Poolgröße

Virtuelle Threads

Sie haben viele blockierende E/A-Aufgaben und möchten einen unkomplizierten Code mit einem Thread pro Aufgabe

Semaphor oder ein anderer expliziter Begrenzer

Beim Web-Scraping mit virtuellen Threads in Java ist Einfachheit der Hauptvorteil. Wenn ein fester Pool die Arbeitslast bereits bewältigt, sind virtuelle Threads kein zwingendes Upgrade. Fügen Sie in beiden Versionen Wiederholungsversuche und Host-spezifische Begrenzungen hinzu, bevor Sie die Parallelität erhöhen.

Machen Sie vorübergehende Fehler behebbar

Ein zuverlässiger Scraper behandelt nicht jede Antwort, die nicht im 2xx-Bereich liegt, gleich. Versuchen Sie es erneut bei Fehlern, die plausiblerweise vorübergehend sind, wie z. B. Ratenbeschränkungen, bestimmte Serverfehler, Timeouts und Verbindungsabbrüche. Versuchen Sie nicht blindlings, Authentifizierungsfehler, unzulässige Anfragen, fehlende Seiten oder fehlerhaft formatierte Anfragen erneut auszuführen. Diese erfordern in der Regel eine Änderung der Anmeldedaten, Berechtigungen, der URL oder des Aufbaus der Anfrage.

Implementieren Sie statusabhängige Wiederholungsversuche mit „Retry-After“ und Jitter

Die folgende Wiederholungsrichtlinie umschließt denselben PageFetcher Vertrag. Sie begrenzt die Anzahl der Versuche, berücksichtigt einen gültigen Retry-After Wert, bevor der lokale Backoff berechnet wird, fügt Jitter hinzu und protokolliert endgültige Fehler. Die relevante HTTP-Semantik ist im Abschnitt „Retry-After“ des RFC 9110 definiert.

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

Erstellen Sie sie mit expliziten Grenzwerten anstelle von verstreuten Konstanten:

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

Der lokale Zeitplan steigt annähernd von 500 Millisekunden auf 1, 2 und 4 Sekunden an, wobei innerhalb jeder Grenze Zufälligkeit zum Tragen kommt. Ein gültiger Retry-After Wert hat Vorrang, doch diese Implementierung wendet die konfigurierte maximale Verzögerung an. Sollte eine Wartezeit, die kürzer ist als vom Server gefordert, gegen Ihre Betriebsrichtlinien verstoßen, brechen Sie die Aufgabe ab oder planen Sie sie neu, anstatt die Verzögerung zu begrenzen und sofort einen erneuten Versuch zu starten.

Fügen Sie 403 standardmäßig zum Wiederholungssatz hinzu. Dies kann bedeuten, dass die Anfrage verboten oder blockiert ist, und eine Wiederholung ohne Behebung der Ursache erzeugt nur mehr Datenverkehr. Ebenso 400, 401sowie 404 sind in der Regel Probleme mit der Konfiguration, der Authentifizierung oder der URL. Eine Java-Wiederholungsschicht für das Web-Scraping sollte konservativ vorgehen, da jeder Wiederholungsversuch das Anfragebudget belastet.

Protokollieren Sie den Anfragekontext und fassen Sie die Crawling-Ergebnisse zusammen

Wiederholungsprotokolle dienen der Diagnose, nicht der Datenausgabe. Zeichne die URL, den Status oder Ausnahmetyp, die Versuchsnummer, die Wartezeit und eine kurze einzeilige Vorschau des Inhalts auf. Protokolliere niemals API-Schlüssel, Autorisierungs-Header, Sitzungs-Cookies, vollständige Anmeldeantworten oder vollständige Payloads mit personenbezogenen Daten.

Ergebnisse auf Crawl-Ebene getrennt von den Anforderungsversuchen verfolgen:

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

Erhöhen Sie attempted einmal, wenn der Crawler eine Seite akzeptiert, nicht bei jedem Wiederholungsversuch. Erhöhen Sie genau ein Endergebnis, nachdem der Abruf entweder erfolgreich war oder die Richtlinie ausgeschöpft ist. Geben Sie beim Herunterfahren eine kompakte Fehlerliste aus RetryingHttpFetcher.failures() und die aggregierten Gesamtwerte aus. Dadurch erhalten geplante Läufe einen nützlichen Abschlussbericht, ohne dass die Protokolle mit jeder erfolgreichen Antwort überflutet werden.

Wenn Parallelität aktiviert ist, fügen Sie eine Lauf-ID hinzu und halten Sie jedes Protokollereignis auf einer Zeile. Dadurch wird die verschachtelte Ausgabe durchsuchbar. Verwenden Sie in einem Produktionslogger strukturierte Felder anstelle von Textanalyse.

Umgang mit dynamischen Seiten und authentifizierten Sitzungen

Wenn der statische Abrufer gültiges HTML zurückgibt, aber nicht die Daten, die Sie in einem Browser sehen können, ersetzen Sie nicht sofort den gesamten Scraper. Stellen Sie zunächst fest, woher der Browser den fehlenden Zustand bezogen hat. Halten Sie parseBooks() oder den entsprechenden Parser unabhängig und tauschen Sie nur den Abruf-Anbieter aus, wenn das Rendering oder der Sitzungsstatus tatsächlich erforderlich ist.

Überprüfen Sie eingebettete Daten und Fetch-/XHR-Aufrufe vor dem Rendern

Dynamischer Inhalt ist Inhalt, der nach der ersten Antwort durch JavaScript eingefügt oder geändert wird. Er kann dennoch aus einer zugänglichen Datenquelle stammen, die einfacher zu handhaben ist als die Browser-Automatisierung.

Überprüfen Sie diese Stellen der Reihe nach:

  1. Durchsuchen Sie den Roh-HTML-Code nach dem sichtbaren Wert.
  2. Überprüfen Sie <script type="application/ld+json"> Blöcke und andere im Dokument eingebettete serialisierte Zustände.
  3. Laden Sie die Seite bei geöffneten DevTools neu und filtern Sie die Netzwerkanfragen nach „Fetch/XHR“.
  4. Wählen Sie die Anfrage aus, die die Datensätze zurückgibt, und überprüfen Sie deren Methode, URL, Nutzdaten, Antwort und Paginierungsfelder.
  5. Vergewissern Sie sich, dass die Reproduktion zulässig ist und dass die erforderlichen Anmeldedaten oder Tokens auf rechtmäßige Weise beschafft werden können.

Bei eingebettetem JSON-LD kann Jsoup das Skript lokalisieren, während eine JSON-Bibliothek dessen Text parst:

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

Rufen Sie einen Fetch/XHR-Endpunkt HttpClient genau wie jede andere Anfrage auf:

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

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

Anschließend parsen response.body() mit Jackson, org.jsonoder einer anderen Bibliothek Ihrer Wahl. Scrapen Sie das visuelle DOM nicht, wenn die zugrunde liegende zulässige Antwort bereits stabile, strukturierte Felder enthält.

Die Netzwerküberprüfung zeigt zudem, ob die Paginierung Seitenzahlen, Cursor, POST-Inhalte oder Request-Header verwendet. Eine in DevTools erfasste Anfrage kann jedoch ablaufende Signaturen, benutzerspezifische Autorisierungen oder Anti-CSRF-Werte enthalten. Das Wiederholen dieser Anfrage außerhalb der autorisierten Sitzung kann fehlschlagen oder gegen Richtlinien verstoßen. Reproduzieren Sie nur die minimal erforderliche, stabile Anfrage, die Ihr Anwendungsfall stellen darf.

Dieser Workflow mit minimaler Komplexität ist der Kern des dynamischen Web-Scrapings in Java: Überprüfen Sie zunächst den Datenpfad und fügen Sie die Ausführung erst dann hinzu, wenn der Datenzugriff von der Ausführung abhängt.

Wählen Sie verwaltetes JavaScript-Rendering oder Selenium für rein browserbasiertes Verhalten

Wenn JavaScript ausgeführt werden muss, wählen Sie je nach erforderlicher Interaktion zwischen dem Abruf gerenderter Seiten und der vollständigen Browsersteuerung:

Anforderung

Kategorie des verwalteten Renderings

Selenium

HTML zurückgeben, nachdem Skripte abgeschlossen sind

Gut geeignet, wenn der Dienst die erforderliche Wartebedingung unterstützt

Funktioniert, aber Sie müssen den Browser-Lebenszyklus verwalten

Klicken, Scrollen, Eingeben, Hochladen oder mehrere Schritte koordinieren

Hängt vom vom Anbieter unterstützten Befehlsmodell ab

Sehr gut geeignet

Vorhandene Jsoup-Selektoren wiederverwenden

Das zurückgegebene HTML mit Jsoup analysieren

An getPageSource() an Jsoup

Operativer Aufwand

Die Browser-Infrastruktur wird gehostet

Sie kümmern sich um Browser, Treiber, Speicher, Abstürze und Skalierung

Debuggen des visuellen Verhaltens lokal

Eingeschränkt durch die Tools des Anbieters

Passt gut zum Headed-Modus und zu Screenshots

Ein verwalteter Renderer sollte dieselbe PageFetcher Grenze und denselben Rückgabewert FetchResult. Dadurch bleiben Parser und Crawler erhalten. Ein Provider-Adapter sollte nicht fest programmiert werden, bevor dessen aktueller Endpunkt, die Authentifizierung, die Rendering-Flags, die weitergeleiteten Header und Cookies, die Beschränkungen sowie die Fehlersemantik überprüft wurden.

Verwenden Sie Selenium, wenn der erforderliche Zustand erst nach einem Browser-Vorgang vorliegt, wie z. B. dem Klicken auf eine Registerkarte, dem Absenden eines Formulars, dem Scrollen durch eine unendliche Liste oder dem Warten auf eine clientseitige Weiterleitung. Überprüfen Sie zum Zeitpunkt der Veröffentlichung die aktuelle selenium-java Version und die Konfiguration des Browser-Treibers. Die festgehaltenen Versionen des Quellmaterials sind zeitabhängig.

Ein minimaler Java-Pfad für Selenium-Web-Scraping sieht wie folgt aus:

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

Verwenden Sie eine explizite Wartezeit für einen Zustand, der belegt, dass die Daten bereit sind. Feste Wartezeiten verschwenden entweder Zeit oder schlagen fehl, wenn eine Seite langsamer ist als erwartet. Wenn ein Klick eine Paginierung auslöst, warten Sie auf eine aussagekräftige Änderung – beispielsweise, dass der alte Ergebniscontainer veraltet ist oder sich ein Seitenmarker aktualisiert –, bevor Sie erneut lesen.

Platzieren Sie quit() in finally. Das Schließen nur des aktuellen Fensters kann dazu führen, dass Treiberprozesse zurückbleiben. Der Headless-Modus entfernt die sichtbare Benutzeroberfläche, garantiert jedoch nicht für jede Seite eine bessere Leistung. Wenn Sie Hintergrundinformationen darüber benötigen, was ein Headless-Browser ist und wie sich dessen Architektur von einem HTTP-Client unterscheidet, betrachten Sie dies als ein operatives Thema, das vom HTML-Parsing getrennt ist.

Weder das verwaltete Rendering noch Selenium gewähren Zugriff auf eingeschränkte Inhalte. Es handelt sich um Ausführungsoptionen, nicht um Umgehungslösungen für Richtlinien.

Cookies mit dem HttpClient CookieManager beibehalten

Sitzungsverwaltung bedeutet, den Benutzerstatus über mehrere Anfragen hinweg zu übertragen, üblicherweise mithilfe von Cookies. Fügen Sie eines CookieManager an einen wiederverwendbaren HttpClient, führen Sie den autorisierten Anmeldeablauf durch und verwenden Sie denselben Client für nachfolgende Seiten.

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

Dieses Beispiel behandelt nur ein einfaches Formular, das Cookies setzt. Ein CSRF-geschützter Ablauf erfordert in der Regel einen vorherigen GET-Aufruf, die Extraktion eines versteckten Tokens oder eines anderen vom Server bereitgestellten Werts sowie die Übermittlung dieses Werts zusammen mit dem Formular. Einige Anwendungen nutzen OAuth, Multi-Faktor-Authentifizierung, Bearer-Token, Geräteprüfungen oder browsergebundenen Status. Für diese Systeme gibt es kein sicheres, allgemeingültiges Anmeldeverfahren.

Überprüfen Sie den Erfolg anhand eines Markers für eine authentifizierte Seite oder einer erwarteten Weiterleitung, nicht lediglich anhand einer 200 Anmeldeantwort. Schränken Sie die Cookie-Akzeptanz ein, wenn der Crawl nicht zusammenhängende Domains umfasst, und protokollieren Sie niemals den Cookie-Speicher. Eine Einführung in HTTP-Cookies kann beim Debuggen von Domain, Pfad, Ablaufdatum Secureund SameSite Verhaltens.

Validieren und speichern Sie die gescrapten Ergebnisse

Ausgegebene Felder beweisen lediglich, dass Selektoren einmal übereinstimmten. Sie beweisen nicht, dass die Ausgabe verwertbar ist. Verlagern Sie das Erfolgskriterium auf normalisierte, nachvollziehbare Datensätze, die die Prüfungen auf Pflichtfelder bestehen und in einen dauerhaften Speicher gelangen. An dieser Stelle wird aus einem Tutorial-Scraper eine zuverlässige Datenpipeline.

Felder normalisieren und unvollständige Datensätze verwerfen

Normalisieren Sie unmittelbar nach der Extraktion, damit jede nachgelagerte Komponente die gleiche Struktur vorfindet. Reduzieren Sie wiederholte Leerzeichen, wandeln Sie nicht trennbare Leerzeichen gegebenenfalls um, lehnen Sie leere Pflichtfelder ab und bewahren Sie die Quellseite auf, von der jeder Datensatz stammt.

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

Definieren Sie Pflichtfelder anhand des Verwendungszwecks und nicht danach, was sich gerade leicht auswählen lässt. Eine Detail-URL kann für die Rückverfolgbarkeit unerlässlich sein, während eine Bild-URL optional sein kann. Wenn Preise berechnet oder sortiert werden sollen, zerlegen Sie den Betrag und die Währung in zielspezifische, typisierte Felder. Entfernen Sie keine Symbole und gehen Sie nicht davon aus, dass jede Website dieselben Dezimal- und Tausendertrennzeichen verwendet.

Behalten Sie ungültige Datensätze durch Zähler oder eine begrenzte Auswahl von Ablehnungsgründen im Blick. Das stille Überspringen jeder Nichtübereinstimmung kann eine Regression des Selektors in einen scheinbar erfolgreichen Durchlauf mit wenigen Daten verwandeln. Behalten Sie gleichzeitig keine ganzen sensiblen Seiten bei, nur um eine fehlerhafte Zeile zu erklären.

Die Quell-URL gehört in den Datensatz, auch wenn sie nicht Teil des endgültigen Geschäftsschemas ist. Sie ermöglicht es Ihnen, eine Extraktion zu reproduzieren, Konflikte zu untersuchen und festzustellen, welche Seitenvorlage fehlgeschlagen ist.

Duplikatbereinigung und Ausgabe als JSON, CSV oder in eine Datenbank

Wählen Sie einen Deduplizierungsschlüssel mit domänenbezogener Bedeutung. Eine kanonische Detail-URL ist oft besser als ein Titel, da sich Titel wiederholen oder ändern können. Behalten Sie bei einem kleinen In-Memory-Lauf die Reihenfolge des ersten Auftretens mit einem LinkedHashMap:

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

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

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

Wenn spätere Seiten aktuellere Werte enthalten, verwenden Sie stattdessen eine explizite Zusammenführungsregel putIfAbsent. Protokollieren Sie in beiden Fällen die Anzahl der Duplikate.

Ausgabe

Beste Übereinstimmung

Wichtigster Hinweis

CSV

Flache Datensätze, die von Tabellenkalkulationsprogrammen oder einfachen Batch-Jobs verarbeitet werden

Kommas, Anführungszeichen und Zeilenumbrüche müssen mit Escape-Zeichen versehen werden

JSON

Verschachtelte Datensätze, APIs, Archive oder Schemaentwicklung

Verwenden Sie einen JSON-Serializer anstelle von manuell erstellten Zeichenfolgen

Datenbank

Inkrementelle Ladevorgänge, Abfragen, Einschränkungen und Verlaufsdaten für mehrere Durchläufe

Batch-Schreibvorgänge, Transaktionen nutzen und eindeutige Schlüssel definieren

Ein abhängigkeitsfreier CSV-Writer für den aktuellen Book Datensatz könnte wie folgt aussehen:

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 + "\"";
}

Für JSON kann eine Bibliothek wie Jackson Java-Datensätze serialisieren, nachdem Sie die entsprechende Abhängigkeitsversion ausgewählt und überprüft haben. Für eine Datenbank verwenden Sie vorbereitete JDBC-Anweisungen, fassen Sie eine begrenzte Anzahl von Datensätzen zu einem Batch zusammen, führen Sie das Commit gezielt durch und lassen Sie eine Eindeutigkeitsbeschränkung die Identitätsregel durchsetzen.

Bei der Dateiausgabe schreiben Sie in einen temporären Pfad und verschieben die Datei erst an ihren endgültigen Speicherort, nachdem der Writer erfolgreich geschlossen wurde. Dadurch wird verhindert, dass bei einem fehlgeschlagenen Durchlauf eine nur halb geschriebene CSV- oder JSON-Datei als vollständiger Datensatz präsentiert wird.

Halten Sie keine unbegrenzte Ergebnismenge zurück, nur um sie am Ende zu schreiben. Leiten Sie akzeptierte Datensätze per Stream an einen Writer weiter, stellen Sie sie in die Warteschlange eines einzelnen Consumers für den Datenbank-Write-Vorgang ein oder leeren Sie begrenzte Batches. Halten Sie das Limit für die Netzwerk-Parallelität getrennt von der Kapazität der Ausgabewarteschlange, damit langsamer Speicher Rückstau verursacht, anstatt zu einem unbegrenzten Speicherwachstum zu führen.

Überwachen Sie den Zustand der Extraktion mithilfe von Zählern und Warnungen bei leeren Ergebnissen

Ein erfolgreicher HTTP-Status kann dennoch null Datensätze ergeben, weil sich ein Selektor geändert hat, eine Einwilligungsseite angezeigt wurde oder die Anfrage eine andere Vorlage erhalten hat. Verfolgen Sie Seiten- und Datensatzmetriken an der Parser-Grenze:

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

Geben Sie die Anzahl der ausgewählten Container getrennt von den akzeptierten Datensätzen weiter. Null Container deuten darauf hin, dass die Seitenstruktur oder die Antwort falsch ist. Container mit null akzeptierten Datensätzen deuten darauf hin, dass die Validierung von Pflichtfeldern fehlschlägt. Das sind unterschiedliche Vorfälle.

Bei geplanten Java-Läufen zum Web-Scraping vergleichen Sie die Zählwerte mit einem konfigurierten Erwartungswert, der aus Ihrem eigenen Verlauf abgeleitet wurde, und nicht mit einem universellen Benchmark. Warnen Sie bei unerwarteten Nullen, abrupten Änderungen oder einer hohen Ablehnungsquote und bewahren Sie anschließend eine kleine diagnostische Stichprobe auf. Halten Sie normale Protokolle prägnant: eine Zeile für jeden fehlgeschlagenen Aufruf, bei Bedarf eine Zeile für eine Extraktionswarnung und eine Zusammenfassung des Laufs.

Bereiten Sie den Scraper für den verantwortungsvollen Einsatz in der Produktion vor

Bei der Produktionsreife geht es vor allem um Grenzen. Definieren Sie, was der Crawler anfordern darf, wie viel Arbeit er verrichten darf, welche Daten er speichern darf und wie er gestoppt wird. Diese Kontrollen sind wichtiger als das Hinzufügen einer weiteren Bibliothek zu einem Java-Web-Scraping-Stack.

Legen Sie Crawling-Grenzen, Anfragekontingente, Geheimnisse und Richtlinienprüfungen fest

Konfigurieren Sie eine Zulassungsliste mit Schemata, Hosts und Pfadpräfixen. Weisen Sie Weiterleitungen außerhalb der Website und „Weiter“-Links zurück, bevor Sie diese einplanen. Legen Sie die maximale Seitenanzahl, die maximale Laufzeit, die maximale Antwortgröße, die Parallelität pro Host, die Gesamtzahl der Anfrageversuche und ein Ausgabelimit fest. Ein Anfragebudget verhindert, dass ein Selektorfehler oder ein zyklischer Kalender zu einem Crawl ohne Ende führt.

Drosseln Sie die Abfrage mit einer Obergrenze für die Parallelität und, falls angemessen, einem Mindestintervall zwischen den Anfragen. Beachten Sie Rate-Limit-Antworten und reduzieren Sie die Last, anstatt Wiederholungsversuche als zusätzlichen Durchsatz zu betrachten. Rufen Sie nur die für den Datensatz erforderlichen Ressourcen ab, speichern Sie Discovery-Antworten im Cache und vermeiden Sie das Herunterladen von Bildern, Skripten oder Stylesheets, wenn reines HTML ausreicht.

Bewahren Sie Geheimnisse außerhalb der Quellcodeverwaltung auf. Lesen Sie Anmeldedaten und API-Schlüssel aus Umgebungsvariablen oder einem Secret-Manager aus, validieren Sie diese beim Start und schwärzen Sie sie in den Protokollen. Überprüfen Sie HTTP-Header im Hinblick auf Web-Scraping als Protokolleingaben und nicht als bloße Verzierungen. Senden Sie nur die Header, die Ihre Anfrage benötigt, und verwenden Sie eine ehrliche Anwendungs-ID, wenn das Ziel dies erwartet.

Überprüfen Sie vor der Datenerfassung vier separate Bereiche:

  • Geltendes Recht: Die Anforderungen variieren je nach Rechtsordnung, Datentyp, Zugriffsmethode und Verwendung.
  • Nutzungsbedingungen der Website: Vertragliche Einschränkungen und zulässige Verwendungszwecke können von der technischen Zugänglichkeit abweichen.
  • Datenschutz: Minimieren Sie personenbezogene Daten, legen Sie die Aufbewahrungsfristen fest, sichern Sie die Ausgabe und dokumentieren Sie den Zweck.
  • robots.txt: Behandeln Sie Crawler-Anweisungen als betriebliches Signal und prüfen Sie das standardisierte Robots Exclusion Protocol.

Keine dieser Überprüfungen ersetzt die anderen, und dieses Tutorial stellt keine Rechtsberatung dar. Ein spezielles Rahmenwerk zur Einhaltung rechtlicher Vorschriften beim Web-Scraping kann die Überprüfung unterstützen, doch der endgültige Wortlaut und der konkrete Anwendungsfall sollten den üblichen Compliance- oder redaktionellen Prozess der Website durchlaufen.

Verwenden Sie eine prägnante Checkliste für die Inbetriebnahme

Bevor Sie den Crawler einplanen, überprüfen Sie:

Führen Sie zunächst einen bewusst kleinen Canary-Test durch. Überprüfen Sie dessen Statusverteilung, Datensatzzahlen, Ablehnungsgründe, Ausgabedatei und das Bereinigungsverhalten, bevor Sie das Seitenbudget erhöhen.

Wichtige Erkenntnisse

  • Beginnen Sie mit der Rohantwort. Verwenden Sie HttpClient und Jsoup für Server-HTML, bevorzugen Sie eine zulässige JSON-Quelle, wenn diese die Datensätze bereitstellt, und fügen Sie die Browserausführung nur bei Bedarf hinzu.
  • Halten Sie Abruf, Parsing, Crawling und Ausgabe voneinander getrennt, damit dieselben Selektoren Änderungen bei Wiederholungsversuchen, Sitzungen, der Darstellung oder dem Anforderungsanbieter überstehen.
  • Legen Sie feste Obergrenzen für Paginierung, Parallelität, Versuche, verstrichene Zeit und Ausgabegröße fest. Virtuelle Threads benötigen weiterhin einen Semaphor oder eine gleichwertige Obergrenze für laufende Vorgänge.
  • Wiederhole nur plausible vorübergehende Fehler, beachte Retry-After, wenden Sie ein begrenztes Backoff mit Jitter an und bewahren Sie endgültige Fehler mit ausreichend Kontext auf, um sie diagnostizieren zu können.
  • Behandeln Sie die Validierung von Datensätzen, die Deduplizierung, Warnungen bei leeren Ergebnissen und dauerhafte Persistenz als Teil der Korrektheit beim Web-Scraping in Java und nicht als Nachbearbeitung nach der Extraktion.

FAQ

Diese Antworten befassen sich mit den Grenzen von Tools, die oft zu Überdimensionierung führen. Treffen Sie Ihre Wahl danach, wo sich die Daten befinden, welche Zustandsübergänge erforderlich sind, ob eine Anfrage sicher wiederholt werden kann und welche Einschränkungen das Ziel sowie Ihre eigene Laufzeitumgebung auferlegen. Überprüfen Sie diese Annahmen immer dann erneut, wenn sich die Seite oder das Zugriffsmodell ändert.

Kann Jsoup JavaScript ausführen?

Nein, Jsoup führt kein JavaScript aus. Es analysiert das von Ihnen bereitgestellte HTML oder XML und stellt APIs für die DOM-Durchquerung und CSS-Selektoren bereit. Es klickt keine Steuerelemente an, wartet nicht auf asynchrone Aktualisierungen, verwaltet kein Live-Browser-DOM und stellt keine JavaScript-Variablen bereit, es sei denn, diese wurden in das HTML serialisiert. Sie können Jsoup dennoch verwenden, nachdem eine andere Komponente eine Seite gerendert hat, indem Sie ihr das endgültige Markup übergeben. Es kann den Text eines Script-Elements lesen, dieses Skript jedoch nicht ausführen.

Wann sollte ein Java-Scraper Selenium anstelle von HttpClient und Jsoup verwenden?

Verwenden Sie Selenium, wenn die benötigten Daten oder der benötigte Zustand erst nach echtem Browserverhalten vorliegen, wie z. B. Klicken, Scrollen, Ausfüllen eines Formulars, die Verarbeitung einer clientseitigen Route oder das Warten auf JavaScript-gesteuerte Aktualisierungen. Es ist auch nützlich, um zu debuggen, wie eine Seite diesen Zustand erreicht. Wenn das ursprüngliche HTML oder eine autorisierte JSON-Anfrage die Daten enthält, HttpClient sind Jsoup und einfacher, deterministischer und leichter zu handhaben. Ein echter Browser bringt Startzeit, Speicherverbrauch, den Lebenszyklus des Treibers und weitere Fehlerquellen mit sich.

Wie sollte ich die Anzahl gleichzeitiger Anfragen in einem Java-Scraper begrenzen?

Legen Sie ein explizites Limit für laufende Anfragen fest und passen Sie es schrittweise von einem niedrigen Ausgangspunkt aus an. Bei einem festen Executor ist die Poolgröße die Obergrenze. Bei virtuellen Threads sollten Sie ein Semaphor oder einen Ratenbegrenzer verwenden, da der Executor viele Aufgaben erstellen kann. Wenden Sie Begrenzungen pro Host an, begrenzen Sie die Arbeitswarteschlange und die Ausgabepuffer, überwachen Sie Latenz und Statusverteilungen und reduzieren Sie die Parallelität, wenn Drosselungen auftreten oder die Fehlerrate steigt. Legen Sie eine separate globale Obergrenze fest, wenn ein Prozess mehrere Hosts abfragt.

Bei welchen HTTP-Fehlern sollte ein Java-Scraper einen erneuten Versuch unternehmen?

Wiederholen 429, ausgewählte 5xx Antworten wie 500, 502, 503, sowie 504, Zeitüberschreitungen bei Anfragen sowie vorübergehende Verbindungsausfälle, wenn die Anzahl der Versuche begrenzt ist. Beachte Retry-After und wende ansonsten exponentielles Backoff mit Jitter an. Wiederhole die meisten 4xx Antworten nicht wiederholt, bis Sie die Ursache behoben haben. Seien Sie besonders vorsichtig bei nicht-idempotenten POST-Anfragen, da deren Wiederholung zu doppelten serverseitigen Aktionen führen kann, selbst nach Ablauf eines Timeouts. Zeichnen Sie den endgültigen Status oder die Ausnahme auf, nachdem das Wiederholungsbudget aufgebraucht ist.

Fazit

Ein zuverlässiger Java-Scraper beginnt mit einer richtigen Entscheidung zum Datenzugriff. Überprüfen Sie das zurückgegebene HTML und den Netzwerkverkehr des Browsers, bevor Sie Tools auswählen. Verwenden Sie für statische Seiten ein HttpClient, analysieren Sie es mit Jsoup, modellieren Sie typisierte Datensätze und halten Sie den Parser unabhängig von Transportdetails. Erweitern Sie dann dieselbe Codebasis um begrenzte Paginierung, besuchte URLs, zwischengespeichertes HTML, explizite Parallelitätsgrenzen, selektive Wiederholungsversuche, Cookie-gestützte Sitzungen, Validierung und dauerhafte Ausgabe.

Die wichtigen Kontrollmechanismen sind sichtbar und endlich: Seitenbegrenzungen, Anforderungsfristen, Genehmigungen für laufende Vorgänge, Wiederholungsversuche, Antwortdiagnosen, Pflichtfelder, Ausgabegrenzen und sauberes Herunterfahren. Diese Kontrollmechanismen machen Fehler nachvollziehbar. Sie ermöglichen es Ihnen zudem, eine Ebene zu ändern, ohne den Rest der Java-Web-Scraping-Pipeline zu destabilisieren.

Verwenden Sie Selenium nur dann, wenn die Interaktion mit dem Browser Teil der Anforderung ist, und nicht, weil eine Seite zufällig JavaScript verwendet. Wenn dieser Workflow tatsächlich Klicks, Scrollen, Formulare oder den gerenderten Zustand erfordert, ohne dass Sie die Browser-Infrastruktur selbst betreiben, ist die Browser-API von WebScrapingAPI eine sinnvolle gehostete Option, die Sie in Betracht ziehen sollten.

Beginnen Sie mit einer zugelassenen Seite, speichern Sie eine Parser-Konfiguration und führen Sie einen kleinen Testlauf durch. Sobald Anzahl, Fehler und Ausgabe korrekt aussehen, erhöhen Sie das Crawling-Budget schrittweise, während Sie weiterhin die Einschränkungen des Ziels und Ihre eigene Compliance-Prüfung beachten.

Über den Autor

Raluca Penciuc, Full-Stack-Entwickler @ WebScrapingAPI

Raluca Penciuc

Full-Stack-Entwickler

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

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

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

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

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

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

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

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

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

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

Suciu Dan11 min read
Artikel lesen

Los geht’s

Sind Sie bereit, Ihre Datenerfassung zu erweitern?

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