Saltar para o conteúdo
Voltar ao blogue

Web Scraping em Java: Criar um scraper fiável em Java

Raluca PenciucÚltima atualização em 31 min read
Web Scraping em Java: Criar um scraper fiável em Java
Resumo: Comece com HttpClient e o Jsoup quando os dados estiverem presentes no HTML devolvido, recorra a uma fonte JSON exposta quando for adequado e adicione renderização ou Selenium apenas para estados dependentes do navegador. Este tutorial de web scraping em Java desenvolve uma base de código Java 21 através de paginação, concorrência limitada, novas tentativas, sessões, validação e saída duradoura.

O web scraping consiste na extração programática de informação de sites para que os dados resultantes possam ser armazenados, validados e analisados. Uma pilha prática de web scraping em Java começa normalmente com o HttpClient para os pedidos e o Jsoup para a análise de HTML, acrescentando depois outras ferramentas apenas quando a página assim o exigir.

A parte difícil raramente é selecionar um único elemento de uma página. Surgem problemas de fiabilidade quando a paginação entra em loop, os trabalhadores simultâneos sobrecarregam um serviço, falhas transitórias são confundidas com erros permanentes, o JavaScript oculta a verdadeira fonte de dados, os pedidos autenticados perdem os seus cookies ou um seletor devolve silenciosamente zero registos.

Este guia constrói um pequeno scraper web em Java ao longo dessas etapas. Primeiro, irá classificar a página como HTML do servidor, JSON, conteúdo renderizado ou interação com o navegador. Em seguida, irá criar um design tipado de «fetch-parse-crawl-output», acompanhar a paginação sem downloads duplicados, comparar executores fixos com threads virtuais, implementar novas tentativas sensíveis ao estado, preservar sessões autorizadas e registar resultados validados.

Os exemplos utilizam um site público de treino e limites de segurança deliberados. Antes de utilizar os mesmos padrões noutro contexto, confirme os requisitos de acesso, as versões atuais das dependências, os seletores de destino e as políticas que se aplicam à sua recolha específica.

Escolha a abordagem de scraping em Java mais simples

A melhor pilha é aquela menos complexa que devolve os dados de forma fiável. Comece por solicitar o URL uma vez, inspecione o corpo da resposta e compare-o com o que o navegador apresenta. Se os valores já existirem na resposta do servidor, o HttpClient e o Jsoup são normalmente suficientes. Se a página expor um pedido JSON, recorra a esse ponto final. Adicione renderização ou automação do navegador apenas quando os dados dependerem verdadeiramente de JavaScript ou da interação do utilizador.

Essa regra de decisão mantém um projeto de web scraping em Java pequeno, testável e económico de operar.

Classifique a página: HTML, JSON, DOM renderizado ou interação

Antes de escolher uma biblioteca, classifique a forma como os dados-alvo chegam ao navegador:

Tipo de página

O que se observa

Abordagem mais simples adequada

HTML devolvido pelo servidor

O título, o preço, as linhas ou os links aparecem em «Ver Código-fonte» ou na resposta HTTP bruta

HttpClient a página é carregada e o Jsoup analisa-a

JSON exposto

O DevTools mostra uma resposta Fetch/XHR contendo os registos

Reproduza o pedido HTTP documentado ou permitido e analise o JSON

DOM renderizado

A resposta inicial não contém os dados, mas o JavaScript insere-os após o carregamento

Utilize um renderizador gerido ou um navegador e, em seguida, passe o HTML resultante para o mesmo analisador

Interação exclusiva do navegador

Os dados só aparecem após cliques, deslocamento, envio de formulários ou uma transição de estado do lado do cliente

Utilize o Selenium ou outra ferramenta de automação de navegadores

Não deduza o tipo de página com base na sua complexidade visual. Uma interface altamente interativa pode ser lida a partir de um único ponto de extremidade JSON simples, enquanto uma tabela simples pode ser montada no navegador.

Utilize esta sequência de verificação:

  1. Envie um pedido GET normal e guarde o estado, o tipo de conteúdo e a primeira parte do corpo.
  2. Procure no HTML devolvido um valor que consiga ver no ecrã.
  3. Abra o DevTools, atualize a página e filtre o painel «Rede» para «Fetch/XHR».
  4. Inspecione as cargas de resposta, os métodos de pedido, os parâmetros de consulta, os cabeçalhos e os tokens de paginação.
  5. Determine se o acesso necessário é uma solicitação HTML direta, uma solicitação JSON, uma saída renderizada ou uma interação com o navegador.

Um ponto de extremidade JSON não é automaticamente público nem irrestrito. Respeite a autenticação necessária, cumpra os termos do site e evite copiar cegamente tokens de curta duração. Da mesma forma, a automação do navegador não é uma solução universal para controlos de acesso. Apenas proporciona ao seu código Java um ambiente de execução real de navegador.

Para páginas estáticas, a extração de dados da Web com o Jsoup funciona bem porque o Jsoup transforma uma cadeia de caracteres HTML num documento pesquisável. Para uma abordagem mais aprofundada da análise de HTML em Java com o Jsoup, a camada de análise pode ser estudada independentemente da camada de rede. Essa separação torna-se importante mais tarde, quando o mecanismo de obtenção de dados muda.

Planeie um pequeno projeto de «busca-análise-rastreio-saída»

Evite colocar chamadas HTTP, seletores, paginação, novas tentativas e gravação de ficheiros num único main método. Um projeto pequeno com quatro responsabilidades é suficiente:

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

Representar o limite de obtenção com uma interface:

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

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

O analisador não deve saber nada sobre proxies, cookies, novas tentativas ou controladores de navegador. Recebe texto e um URI base e, em seguida, devolve registos de domínio. O rastreador é responsável pelos limites de páginas, URLs visitadas e ordenação de pedidos. A camada de saída é responsável pela normalização e armazenamento.

Mantenha os metadados de transporte disponíveis mesmo quando o analisador não os utilize. Códigos de estado, cabeçalhos de resposta, locais de redirecionamento finais e tempo decorrido são úteis para decisões de repetição de tentativas e diagnósticos. Passar esses detalhes através FetchResult impede que o analisador fique acoplado a um cliente HTTP específico.

Isto não é um framework. É apenas a estrutura necessária para permitir que um scraper web em Java evolua sem ter de reescrever os seletores sempre que a camada de pedidos muda. Também cria pontos de teste óbvios: o HTML guardado permite testar a análise, um PageFetcher pode testar a lógica de rastreamento e os ficheiros temporários podem testar a saída.

Construir o núcleo do scraper Java 21 para um projeto de web scraping em Java

Agora, transforme o modelo de decisão num projeto funcional. A primeira versão irá buscar uma página de exercícios autorizados, analisar fichas de produtos, resolver links relativos e imprimir registos digitados. As secções seguintes irão ampliar o mesmo fetcher e analisador, em vez de os substituir.

Crie o projeto e adicione o HttpClient e o Jsoup

Utilize um JDK Java 21 completo, e não uma instalação apenas de tempo de execução:

java -version
javac -version

HttpClient faz parte do JDK, pelo que o Jsoup é a única dependência necessária para o exemplo estático. O material de código-fonte fornecido fixou o Jsoup 1.21.2, mas essa versão é sensível ao tempo. Verifique a versão atual compatível com o Java 21 antes da publicação e execute novamente a compilação.

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

O equivalente compacto em Gradle é:

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

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

Escolha uma ferramenta de compilação e mantenha-a consistente. O Maven torna o gráfico de dependências explícito em XML; o Gradle é mais conciso, mas introduz a sua própria DSL de compilação. Nenhuma das duas altera a arquitetura do scraper.

Um layout mínimo do código-fonte mantém as responsabilidades visíveis:

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

O tutorial utiliza um único ficheiro, como se pode ver abaixo, para que o possa executar rapidamente. Assim que a primeira solicitação funcionar, divida os tipos aninhados nesses ficheiros sem alterar os seus contratos públicos. Isso facilita o teste isolado das futuras classes de rastreador, repetição e saída.

Modele registos e opte por seletores CSS resilientes

Modele cada linha extraída como um registo Java, em vez de passar listas paralelas de cadeias de caracteres:

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

Um seletor CSS deve descrever uma estrutura estável, não uma posição incidental. Dá preferência a um contêiner de produto repetido mais descendentes semânticos, como article.product_pod, h3 a, e .price_color. Evite seletores construídos a partir de nomes de classes gerados ou longas div:nth-child(...) , a menos que a marcação não lhe ofereça nenhuma opção melhor. Uma ficha de referência de seletores CSS é útil quando precisa de seletores de atributos, filhos, irmãos ou compostos.

Trate cada correspondência interna como opcional. selectFirst() retorna null quando a marcação muda, por isso leia o elemento só depois de o verificar. Normalize também o texto no limite e guarde a página de origem para fins de rastreabilidade.

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

A URI base passada para Jsoup.parse() é o que torna absUrl("href") útil. Resolve ligações como catalogue/book.html em relação à página que os continha. Isto é mais seguro do que concatenar cadeias de caracteres, especialmente depois de a paginação deslocar o rastreador para um subdiretório.

A resiliência de um seletor não é o mesmo que a imprecisão de um seletor. Um seletor como a pode sobreviver a uma reformulação, mas devolver links de navegação não relacionados. Selecione o contentor estável mais restrito, extraia apenas os campos que lhe pertencem e conte os campos obrigatórios em falta. Durante o desenvolvimento, guarde um pequeno modelo HTML permitido e teste o analisador em relação a esse modelo. Isso permite distinguir uma regressão na marcação de um problema de rede.

Execute um scraper completo de páginas estáticas

O programa de ficheiro único que se segue é um ponto de partida compilável para a extração de dados da Web com o HttpClient em Java. Mantém separadas as responsabilidades de obtenção e análise, verifica o estado e o tipo de conteúdo e limita tanto o tempo de ligação como o tempo por pedido.

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

Compile-o através do Maven ou do Gradle para que o Jsoup esteja no classpath. A saída deve indicar o URL obtido, uma contagem dos dados extraídos e até três registos. Não codifique de forma rígida uma contagem esperada de páginas ou títulos de amostra, pois o conteúdo e a marcação do site de treino podem mudar.

Vários detalhes são deliberados. Uma HttpClient instância é reutilizada porque os clientes gerem as ligações e a configuração. A solicitação tem um prazo finito, os redirecionamentos são tratados explicitamente e a URI da resposta final torna-se a URI base do analisador. O programa verifica um intervalo 2xx em vez de apenas 200, e depois verifica se a resposta se assemelha a HTML antes de aplicar seletores.

O exemplo lança uma exceção em caso de falha de transporte ou de protocolo, porque um comando de uma página deve falhar de forma evidente. Um rastreador necessita de um comportamento mais refinado: classificar a falha, repetir a tentativa apenas em casos transitórios, registar o resultado final e continuar quando a política o permitir. Acrescentamos esse comportamento mais tarde, sem alterar parseBooks().

O User-Agent no exemplo identifica a aplicação, em vez de fingir ser um navegador específico. Alguns sites podem exigir cabeçalhos adicionais, mas os cabeçalhos devem refletir o pedido que está efetivamente a fazer. Se um adaptador de recuperação posterior precisar de um segredo, leia-o com System.getenv() e falhe de forma clara quando este estiver ausente.

Transforme a extração num rastreador controlado

Um extrator de uma única página torna-se um rastilhador web em Java quando escolhe e segue URLs adicionais. Essa alteração introduz regras de estado e de terminação. Mantenha o fetcher e o analisador existentes e, em seguida, adicione um coordenador de rastreamento que seja responsável pela página atual, pelo conjunto de páginas visitadas, pelo limite de páginas e pelo corpo da resposta em cache.

Siga a paginação e resolva URLs relativas

O código Java de web scraping com paginação deve ter mais do que uma condição de paragem. Pare quando o link seguinte desaparecer, quando o URL seguinte for inválido, quando um URL se repetir, quando uma resposta falhar ou quando for atingido um limite de páginas configurado. O limite protege-o de marcação corrompida e de percurso acidental por todo o site.

Adicione um tipo de resultado e um método de rastreamento em torno do PageFetcher e parseBooks() funções existentes:

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

Este método analisa cada corpo recuperado duas vezes: uma para os registos e outra para o link seguinte. Isso é pouco dispendioso para páginas pequenas, mas pode devolver tanto os registos como o URI seguinte a partir de uma única chamada ao analisador, caso a análise de desempenho revele um custo significativo. A otimização importante consiste em evitar um segundo pedido de rede, e não em eliminar prematuramente uma análise minúscula do DOM.

O grupo de seletores suporta um rel=next e a forma do paginador do site de treino. Trate-o como um ponto de partida, não como um seletor universal. Analise o paginador real e mantenha os seletores de navegação separados dos seletores de produtos, para que seja fácil isolar uma alteração.

Chame o rastreador com um limite deliberadamente baixo durante o desenvolvimento:

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

O número 3 é um limite de segurança para esta execução, não um total estimado. Aumente-o apenas após confirmar que o seletor «next-link» permanece dentro do host e do caminho pretendidos. No código de produção, rejeite um URL seguinte fora do site antes de o recuperar.

Para a paginação JSON baseada em cursor, a estrutura do ciclo é a mesma. Substitua next.absUrl("href") pela extração do cursor ou token seguinte e pare quando o token estiver ausente ou for repetido. Não adivinhe números de página quando o servidor fornecer um valor de continuação explícito.

Evite loops e downloads duplicados com URLs visitadas e HTML em cache

O conjunto de URLs visitadas responde a uma pergunta: já tentámos esta URL normalizada? O cache de HTML responde a outra: já descarregámos o conteúdo para esta URL final? É necessário recorrer a ambos, porque os redirecionamentos podem mapear várias URLs solicitadas para uma única página e os paginadores malformados podem apontar para trás.

A reutilização do HTML de descoberta é um diferencial prático para os rastreadores Java de web scraping. Um design ineficiente comum percorre primeiro a paginação para recolher URLs e, em seguida, recupera cada URL novamente durante a extração paralela. O código acima armazena cada resposta bem-sucedida à medida que descobre o próximo link, para que uma fase de análise posterior possa consumir o cache diretamente:

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

Nenhuma chamada HTTP ocorre nesse fluxo de trabalho. Se, posteriormente, paralelizar a análise, utilize esses valores em cache ou envie-os para o seu próprio executor. Não recorra silenciosamente a outro download.

A normalização de URLs deve ser feita com moderação. A remoção de fragmentos é geralmente adequada, uma vez que os fragmentos não são enviados em pedidos HTTP. A remoção de parâmetros de consulta não é, em geral, segura, pois os parâmetros podem identificar páginas, filtros ou cursores. Defina regras de canonização para o destino, em vez de eliminar dados globalmente.

Um cache na memória é limitado apenas pelo maxPages e pelo tamanho da página. Para um pequeno rastreio tutorial, isso é aceitável. Para uma execução prolongada, armazene instantâneos das respostas no disco ou processe-os em lotes limitados. O armazenamento em cache deve reduzir as solicitações, não criar uma pilha ilimitada.

Execute pedidos em simultâneo sem perder o controlo

A concorrência é uma ferramenta de rendimento, não uma garantia de que um rastreio terminará mais rapidamente. O serviço remoto, a rede, a CPU, a memória e os limites de resposta podem todos tornar-se pontos de estrangulamento. Comece sequencialmente, avalie e, em seguida, introduza um pequeno limite explícito. Um design Java controlado para a extração paralela de dados da Web deve tornar evidentes as suas solicitações máximas em andamento.

Utilize um ExecutorService fixo e coleções concorrentes fáceis de programar

Um conjunto fixo faz com que o limite de pedidos seja igual ao número de trabalhadores. Envie um URL independente por tarefa, guarde cada um Futuree aguarde a conclusão, para que as falhas nas tarefas não se percam.

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 suporta adições simultâneas frequentes sem copiar toda a estrutura subjacente. ConcurrentHashMap.newKeySet() proporciona um comportamento atómico de «visto ou não visto» através de add(). Um ArrayList ou HashSet não é seguro para gravações simultâneas, a menos que o acesso seja sincronizado. CopyOnWriteArrayList é normalmente um mau destino de resultados, porque cada gravação copia a sua matriz subjacente.

A lista futures permanece uma ArrayList , uma vez que apenas o thread coordenador a grava. A segurança de threads deve ser aplicada onde a partilha ocorre efetivamente, e não a todas as coleções de forma reflexiva.

Chamar get() é importante. Se uma tarefa lançar uma exceção, o coordenador recebe um ExecutionException em vez de apresentar uma mensagem otimista de sucesso enquanto as páginas falham silenciosamente. Num rastreador de longa duração, analise essa causa, registe o URL e decida se as outras tarefas podem continuar. Se o coordenador for interrompido, preserve o estado de interrupção após a limpeza, em vez de o ignorar.

Não utilize parallelStream() apenas para poupar algumas linhas de código. Por predefinição, utiliza o pool comum «fork-join», que oculta o limite de pedidos e pode entrar em conflito com tarefas não relacionadas no mesmo processo. Um executor explícito proporciona ao scraper uma fila, um ciclo de vida e uma capacidade observáveis.

Um pool fixo é fácil de compreender e continua a ser um bom valor por predefinição para um scraper web Java de dimensão modesta. Comece com um limite baixo e medido, observe a latência e os códigos de estado e aumente gradualmente. O material de referência fornecido menciona pequenos intervalos de demonstração, mas não existe um número universal seguro de threads. Os limites publicados pelo alvo e o seu próprio orçamento de pedidos são mais importantes.

Utilize threads virtuais com um semáforo quando a carga de trabalho o justificar

As threads virtuais do Java 21 facilitam a escalabilidade do código de E/S bloqueante sem a necessidade de manter um grande pool de threads da plataforma. A JEP 444 do OpenJDK descreve-as como threads leves geridas pela JVM, destinadas a suportar aplicações concorrentes de alto rendimento. Elas não eliminam a necessidade de limitar a pressão sobre um serviço remoto.

Utilize uma thread virtual por tarefa e um semáforo para o limite HTTP efetivo:

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

O número de threads virtuais pode ser elevado, mas apenas maxInFlight tarefas podem entrar no bloco de recuperação. Obtenha a autorização imediatamente antes da operação restrita e liberte-a em finally. Não a mantenha enquanto os registos estiverem a ser gravados numa base de dados ou enquanto estiverem a ser executadas tarefas de CPU não relacionadas, a menos que essas operações partilhem o mesmo limite.

Se a descoberta de paginação já tiver HTML em cache, envie tarefas de análise através do cache em vez de chamar fetcher novamente. A análise é normalmente trabalho da CPU, por isso faça testes de desempenho antes de atribuir milhares de threads virtuais a essa tarefa. As threads virtuais foram concebidas principalmente para tornar as operações de bloqueio mais eficientes, não para acelerar todos os cálculos.

Escolha

Prefira-a quando

Controlo principal

Pool fixo

O número de tarefas for moderado e os trabalhadores com limites simples forem bem definidos

Tamanho do conjunto

Threads virtuais

Tem muitas tarefas de E/S bloqueantes e pretende um código simples do tipo «uma thread por tarefa»

Semáforo ou outro limitador explícito

Para a extração de dados da Web com threads virtuais em Java, a simplicidade é a principal vantagem. Se um pool fixo já for suficiente para a carga de trabalho, as threads virtuais não são uma atualização obrigatória. Em ambas as versões, adicione tentativas de repetição e limites por anfitrião antes de aumentar a concorrência.

Torne as falhas transitórias recuperáveis

Um scraper fiável não trata todas as respostas que não sejam 2xx da mesma forma. Tente novamente as falhas que sejam plausivelmente temporárias, tais como limites de taxa, erros de servidores específicos, tempos de espera esgotados e reinicializações de ligação. Não repita cegamente falhas de autenticação, pedidos proibidos, páginas em falta ou pedidos malformados. Estes requerem normalmente uma alteração nas credenciais, permissões, URL ou na construção do pedido.

Implemente tentativas de repetição sensíveis ao estado com Retry-After e jitter

A política de repetição abaixo envolve o mesmo PageFetcher contrato. Limita as tentativas, respeita um valor válido Retry-After antes de calcular o backoff local, adiciona jitter e regista falhas finais. A semântica HTTP relevante está definida na secção Retry-After da RFC 9110.

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

Construa-a com limites explícitos em vez de constantes dispersas:

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

O intervalo local aumenta aproximadamente de 500 milissegundos para 1, 2 e 4 segundos, com aleatoriedade dentro de cada intervalo. Um valor válido Retry-After tem prioridade, mas esta implementação aplica o atraso máximo configurado. Se esperar menos do que o solicitado pelo servidor violar a sua política operacional, interrompa a tarefa ou reprograme-a, em vez de limitar e tentar novamente de imediato.

Não adicione 403 ao conjunto de tentativas por predefinição. Isso pode significar que o pedido está proibido ou bloqueado, e a repetição sem alterar a causa apenas cria mais tráfego. Da mesma forma, 400, 401e 404 são normalmente problemas de configuração, autenticação ou URL. Uma camada de repetição em Java para web scraping deve ser conservadora, pois cada repetição consome o orçamento de pedidos.

Registe o contexto da solicitação e resuma os resultados do rastreamento

Os registos de novas tentativas destinam-se ao diagnóstico, não ao despejo de dados. Registe o URL, o estado ou o tipo de exceção, o número da tentativa, o tempo de espera e uma breve pré-visualização do corpo do pedido numa única linha. Nunca registe chaves de API, cabeçalhos de autorização, cookies de sessão, respostas completas de início de sessão ou cargas completas de dados pessoais.

Acompanhe os resultados ao nível do rastreio separadamente das tentativas de pedido:

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

Aumente attempted uma vez quando o rastreador aceitar uma página, e não uma vez por nova tentativa. Incrementa exatamente um resultado final após a obtenção ser bem-sucedida ou esgotar a política. Ao encerrar, imprime uma lista compacta de falhas a partir RetryingHttpFetcher.failures() e dos totais agregados. Isso proporciona às execuções agendadas um relatório de encerramento útil, sem sobrecarregar os registos com todas as respostas bem-sucedidas.

Quando a concorrência estiver ativada, inclua um identificador de execução e mantenha cada evento de registo numa única linha. Isso torna a saída intercalada pesquisável. Num registador de produção, utilize campos estruturados em vez de analisar texto livre.

Lidar com páginas dinâmicas e sessões autenticadas

Quando o fetcher estático devolver HTML válido, mas não os dados que se podem ver num navegador, não substitua imediatamente todo o scraper. Determine primeiro onde é que o navegador obteve o estado em falta. Mantenha parseBooks() ou o analisador equivalente independente e, em seguida, troque apenas o fornecedor de recuperação quando a renderização ou o estado da sessão forem realmente necessários.

Inspecione os dados incorporados e as chamadas Fetch/XHR antes da renderização

O conteúdo dinâmico é aquele inserido ou alterado pelo JavaScript após a resposta inicial. Pode ainda assim ter origem numa fonte de dados acessível que seja mais simples do que a automação do navegador.

Verifique estes locais por ordem:

  1. Procure o valor visível no HTML bruto.
  2. Inspecione <script type="application/ld+json"> os blocos e outros estados serializados incorporados no documento.
  3. Atualize a página com as DevTools abertas e filtre os pedidos de rede para Fetch/XHR.
  4. Selecione a solicitação que devolve os registos e inspecione os seus campos de método, URL, carga útil, resposta e paginação.
  5. Confirme se é permitido reproduzir a operação e se as credenciais ou tokens necessários podem ser obtidos de forma legítima.

Para JSON-LD incorporado, o Jsoup consegue localizar o script, enquanto uma biblioteca JSON analisa o seu texto:

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

Para um ponto final Fetch/XHR, chame-o com HttpClient tal como qualquer outra solicitação:

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

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

Em seguida, analise response.body() com o Jackson, org.jsonou outra biblioteca à sua escolha. Não extraia o DOM visual quando a resposta subjacente permitida já contiver campos estruturados estáveis.

A inspeção de rede também revela se a paginação utiliza números de página, cursores, corpos de POST ou cabeçalhos de pedido. No entanto, um pedido capturado no DevTools pode incluir assinaturas com prazo de validade, autorização específica do utilizador ou valores anti-CSRF. Reproduzi-lo fora da sessão autorizada pode falhar ou violar a política. Reproduza apenas a solicitação mínima estável que o seu caso de uso está autorizado a efetuar.

Este fluxo de trabalho de menor complexidade é o cerne do scraping dinâmico na Web em Java: inspecione primeiro o caminho dos dados e, só depois, adicione a execução quando o acesso aos dados depender da execução.

Opte pela renderização JavaScript gerida ou pelo Selenium para comportamentos exclusivos do navegador

Se for necessário executar JavaScript, opte entre a recuperação da página renderizada e o controlo total do navegador, consoante a interação necessária:

Requisito

Categoria de renderização gerida

Selenium

Devolver HTML após a execução dos scripts

É adequado quando o serviço suporta a condição de espera necessária

Funciona, mas o utilizador tem de gerir o ciclo de vida do navegador

Clicar, percorrer, digitar, carregar ficheiros ou coordenar várias etapas

Depende do modelo de instruções suportado pelo fornecedor

Adequação elevada

Reutilize os seletores Jsoup existentes

Analise o HTML devolvido com o Jsoup

Passar getPageSource() para o Jsoup

Carga operacional

A infraestrutura do navegador é hospedada

É o utilizador que gere os navegadores, os controladores, a memória, as falhas e o dimensionamento

Depure o comportamento visual localmente

Limitado pelas ferramentas do fornecedor

Ótima compatibilidade com o modo «headed» e capturas de ecrã

Um renderizador gerido deve implementar o mesmo PageFetcher limite e devolução FetchResult. Isso preserva o analisador e o rastreador. Não codifique de forma rígida um adaptador de fornecedor até que o seu ponto final atual, autenticação, sinalizadores de renderização, cabeçalhos e cookies reencaminhados, limites e semântica de erros tenham sido verificados.

Utilize o Selenium quando o estado necessário só existir após um comportamento do navegador, como clicar num separador, enviar um formulário, percorrer uma lista infinita ou aguardar uma rota do lado do cliente. No momento da publicação, verifique a selenium-java e a configuração do driver do navegador. As versões fixadas do material de referência são sensíveis ao tempo.

Um caminho mínimo em Java para web scraping com o Selenium tem o seguinte aspeto:

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

Utilize uma espera explícita por um estado que comprove que os dados estão prontos. Os tempos de espera fixos ou desperdiçam tempo ou falham quando uma página é mais lenta do que o esperado. Se um clique ativar a paginação, aguarde por uma alteração significativa, como o antigo contentor de resultados ficar desatualizado ou um marcador de página ser atualizado, antes de ler novamente.

Coloque sempre quit() em finally. Fechar apenas a janela atual pode deixar processos do driver em execução. O modo «headless» remove a interface visível, mas não garante um melhor desempenho para todas as páginas. Se precisar de contextualização sobre o que é um navegador «headless» e como a sua arquitetura difere de um cliente HTTP, considere isso como um tópico operacional separado da análise de HTML.

Nem a renderização gerida nem o Selenium concedem permissão para aceder a conteúdo restrito. São opções de execução, não soluções alternativas às políticas.

Preserve os cookies com o HttpClient CookieManager

A gestão de sessões significa transportar o estado do utilizador entre pedidos, normalmente através de cookies. Associe um CookieManager a um HttpClient, envie o fluxo de início de sessão autorizado e utilize esse mesmo cliente para páginas posteriores.

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

Este exemplo abrange apenas um formulário simples que define cookies. Um fluxo protegido contra CSRF requer normalmente um pedido GET prévio, a extração de um token oculto ou outro valor fornecido pelo servidor e o envio desse valor juntamente com o formulário. Algumas aplicações utilizam OAuth, autenticação multifator, tokens de portador, verificações de dispositivo ou estado associado ao navegador. Não existe uma receita genérica segura de início de sessão para esses sistemas.

Verifique o sucesso utilizando um marcador de página autenticada ou o redirecionamento esperado, e não apenas uma 200 resposta de início de sessão. Limite a aceitação de cookies se a exploração abranger domínios não relacionados e nunca registe o armazenamento de cookies. Um manual básico sobre cookies HTTP pode ajudar na depuração de domínios, percursos, validade Securee SameSite comportamento.

Valide e armazene os resultados extraídos

Os campos apresentados comprovam que os seletores corresponderam uma vez. Não comprovam, porém, que a saída seja utilizável. Mude o critério de sucesso para registos normalizados e rastreáveis que passem nas verificações de campos obrigatórios e cheguem a um armazenamento duradouro. É aqui que um scraper de tutoriais se torna um pipeline de dados fiável.

Normalize os campos e rejeite registos incompletos

Normalize imediatamente após a extração para que todos os componentes a jusante vejam o mesmo formato. Elimine espaços em branco repetidos, converta espaços não separáveis quando apropriado, rejeite campos obrigatórios em branco e mantenha a página de origem que produziu cada registo.

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

Defina os campos obrigatórios com base na utilização pretendida, e não com base no que por acaso for fácil de selecionar. Um URL de detalhes pode ser essencial para a rastreabilidade, enquanto um URL de imagem pode ser opcional. Se o preço for calculado ou ordenado, analise o montante e a moeda em campos tipados específicos do destino. Não remova símbolos nem presuma que todos os sites utilizam os mesmos separadores decimais e de milhares.

Mantenha os registos inválidos visíveis através de contadores ou de uma amostra limitada de motivos de rejeição. Ignorar silenciosamente todas as discrepâncias pode transformar uma regressão do seletor numa execução aparentemente bem-sucedida com poucos dados. Ao mesmo tempo, não retenha páginas sensíveis na íntegra apenas para explicar uma linha incorreta.

O URL de origem deve constar no registo, mesmo quando não faz parte do esquema empresarial final. Permite-lhe reproduzir uma extração, investigar conflitos e identificar qual o modelo de página que falhou.

Desduplique e grave a saída em JSON, CSV ou numa base de dados

Escolha uma chave de deduplicação com significado no domínio. Uma URL de detalhe canónica é frequentemente melhor do que um título, porque os títulos podem repetir-se ou alterar-se. Para uma pequena execução na memória, preserve a ordem em que foram vistos pela primeira vez com um LinkedHashMap:

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

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

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

Se as páginas seguintes contiverem valores mais recentes, utilize uma regra de fusão explícita em vez de putIfAbsent. Registe a contagem de duplicados em qualquer dos casos.

Saída

Melhor ajuste

Atenção principal

CSV

Registos simples utilizados por folhas de cálculo ou tarefas em lote simples

Escapar vírgulas, aspas e quebras de linha

JSON

Registos aninhados, APIs, arquivos ou evolução de esquemas

Utilize um serializador JSON em vez de cadeias de caracteres criadas manualmente

Base de dados

Carregamentos incrementais, consultas, restrições e histórico de várias execuções

Gravações em lote, utilização de transações e definição de chaves únicas

Um gravador CSV sem dependências para o registo atual Book pode ter o seguinte aspeto:

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

Para JSON, uma biblioteca como a Jackson pode serializar registos Java depois de escolher e verificar a versão da sua dependência. Para uma base de dados, utilize instruções preparadas JDBC, agrupe um número limitado de registos, efetue o commit deliberadamente e deixe que uma restrição de unicidade imponha a regra de identidade.

Para saída em ficheiro, escreva para um caminho temporário e transfira-o para o local definitivo apenas depois de o gravador ter sido fechado com sucesso. Isso evita que uma execução falhada apresente um ficheiro CSV ou JSON parcialmente escrito como um conjunto de dados completo.

Não retenha um conjunto de resultados ilimitado apenas para o escrever no final. Transmita os registos aceites para um gravador, coloque-os na fila de um único consumidor de escrita na base de dados ou descarregue lotes limitados. Mantenha o limite de simultaneidade da rede separado da capacidade da fila de saída, para que o armazenamento lento crie contrapressão em vez de um crescimento ilimitado da memória.

Monitorize o estado da extração com contagens e avisos de resultados vazios

Um estado HTTP bem-sucedido pode, ainda assim, produzir zero registos porque um seletor mudou, surgiu uma página de consentimento ou o pedido recebeu um modelo diferente. Acompanhe as métricas de páginas e registos no limite do analisador:

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

Passe o número de contentores selecionados separadamente dos registos aceites. Zero contentores sugere que a estrutura da página ou a resposta está errada. Contentores com zero registos aceites sugerem que a validação de campos obrigatórios está a falhar. Trata-se de incidentes diferentes.

Para execuções programadas de web scraping em Java, compare as contagens com uma expectativa configurada derivada do seu próprio histórico, e não de um benchmark universal. Emita um aviso em caso de zeros inesperados, alterações bruscas ou uma elevada taxa de rejeição e, em seguida, guarde uma pequena amostra para diagnóstico. Mantenha os registos normais concisos: uma linha para falhas de pedido, uma linha para avisos de extração quando necessário e um resumo da execução.

Prepare o scraper para uma utilização responsável em produção

A preparação para produção tem principalmente a ver com limites. Defina o que o rastreador pode solicitar, quanto trabalho pode realizar, que dados pode reter e como deve parar. Estes controlos são mais importantes do que adicionar mais uma biblioteca a uma pilha Java de web scraping.

Defina limites de rastreamento, orçamentos de pedidos, dados confidenciais e verificações de políticas

Configure uma lista de permissões de esquemas, anfitriões e prefixos de caminho. Rejeite redirecionamentos para fora do site e links seguintes antes de os agendar. Defina o número máximo de páginas, o tempo máximo decorrido, o tamanho máximo da resposta, a simultaneidade por anfitrião, o total de tentativas de pedido e um limite de saída. Um orçamento de pedidos impede que um erro no seletor ou um calendário cíclico se transforme num rastreio sem fim.

Limite a velocidade com um limite de simultaneidade e, quando apropriado, um intervalo mínimo entre pedidos. Observe as respostas de limitação de taxa e reduza a pressão, em vez de tratar as tentativas de repetição como rendimento adicional. Obtenha apenas os recursos necessários para o conjunto de dados, armazene em cache as respostas de descoberta e evite descarregar imagens, scripts ou folhas de estilo quando o HTML direto for suficiente.

Mantenha os segredos fora do controlo de código-fonte. Leia as credenciais e as chaves de API a partir de variáveis de ambiente ou de um gestor de segredos, valide-as no arranque e oculte-as dos registos. Analise os cabeçalhos HTTP para a extração de dados da Web como entradas de protocolo, e não como elementos decorativos. Envie apenas os cabeçalhos de que a sua solicitação necessita e utilize um identificador de aplicação genuíno quando o destino o exigir.

Antes da recolha, analise quatro áreas distintas:

  • Legislação aplicável: os requisitos variam consoante a jurisdição, o tipo de dados, o método de acesso e a utilização.
  • Termos do site: as restrições contratuais e as utilizações permitidas podem diferir da acessibilidade técnica.
  • Privacidade: minimize os dados pessoais, defina o período de retenção, proteja os resultados e documente a finalidade.
  • robots.txt: trate as diretivas do rastreador como um sinal operacional e analise o Protocolo de Exclusão de Robôs padronizado.

Nenhuma destas verificações substitui as outras, e este tutorial não constitui aconselhamento jurídico. Um quadro de conformidade jurídica dedicado à extração de dados da Web pode apoiar a análise, mas a redação final e o caso de utilização concreto devem passar pelo processo normal de conformidade ou editorial do site.

Utilize uma lista de verificação concisa para a entrada em funcionamento

Antes de agendar o rastreador, verifique se:

Execute primeiro um «canário» deliberadamente pequeno. Inspecione a distribuição do seu estado, o número de registos, os motivos de rejeição, o ficheiro de saída e o comportamento de limpeza antes de aumentar o orçamento da página.

Pontos-chave

  • Comece com a resposta em bruto. Utilize HttpClient e o Jsoup para o HTML do servidor, dê preferência a uma fonte JSON autorizada quando esta expor os registos e adicione a execução no navegador apenas quando necessário.
  • Mantenha a obtenção, a análise, o rastreio e a saída separados, para que os mesmos seletores permaneçam inalterados face a alterações nas tentativas, sessões, renderização ou no fornecedor de pedidos.
  • Estabeleça limites rígidos em relação à paginação, simultaneidade, tentativas, tempo decorrido e tamanho da saída. As threads virtuais continuam a necessitar de um semáforo ou de um limite equivalente em tempo real.
  • Refaça apenas falhas transitórias plausíveis, respeite Retry-After, utilize um backoff limitado com jitter e preserve as falhas terminais com contexto suficiente para as diagnosticar.
  • Trate a validação de registos, a deduplicação, os avisos de resultados vazios e a persistência duradoura como parte da correção do Java no web scraping, e não como uma limpeza após a extração.

Perguntas Frequentes

Estas respostas abordam os limites da ferramenta que frequentemente levam a um excesso de engenharia. Escolha com base na localização dos dados, nas transições de estado necessárias, na segurança de repetir um pedido e nos limites impostos pelo alvo e pelo seu próprio tempo de execução. Verifique novamente essas premissas sempre que a página ou o modelo de acesso se alterarem.

O Jsoup consegue executar JavaScript?

Não, o Jsoup não executa JavaScript. Ele analisa o HTML ou XML que fornecer e disponibiliza APIs de percurso do DOM e de seletores CSS. Não clica em controlos, não aguarda atualizações assíncronas, não mantém um DOM do navegador em tempo real nem expõe variáveis JavaScript, a menos que estas tenham sido serializadas no HTML. Ainda pode utilizar o Jsoup depois de outro componente ter renderizado uma página, passando-lhe a marcação final. Consegue ler o texto de um elemento script, mas não consegue avaliar esse script.

Quando é que um scraper em Java deve utilizar o Selenium em vez do HttpClient e do Jsoup?

Utilize o Selenium quando os dados ou o estado necessários só existirem após um comportamento real do navegador, como clicar, percorrer a página, preencher um formulário, gerir uma rota do lado do cliente ou aguardar atualizações impulsionadas por JavaScript. Também é útil para depurar a forma como uma página atinge esse estado. Se o HTML inicial ou um pedido JSON autorizado contiver os dados, HttpClient e o Jsoup são mais simples, mais determinísticos e mais fáceis de utilizar. Um navegador real acrescenta tempo de arranque, consumo de memória, ciclo de vida do controlador e mais modos de falha.

Como devo limitar as solicitações simultâneas num scraper Java?

Defina um limite explícito de solicitações em andamento e ajuste-o gradualmente a partir de um ponto de partida baixo. Com um executor fixo, o tamanho do pool é o limite máximo. Com threads virtuais, utilize um semáforo ou um limitador de taxa, uma vez que o executor pode criar muitas tarefas. Aplique limites por anfitrião, limite o trabalho em fila e os buffers de saída, monitorize a latência e as distribuições de estado e reduza a simultaneidade quando houver limitação de largura de banda ou aumento de falhas. Mantenha um limite global separado se um processo visitar vários anfitriões.

Que erros HTTP deve um scraper Java tentar resolver novamente?

Reintentar 429, respostas 5xx respostas como 500, 502, 503, e 504, tempos de espera de pedidos e falhas de ligação transitórias quando o número de tentativas estiver limitado. Respeite Retry-After e, caso contrário, utilize o backoff exponencial com jitter. Não repita repetidamente a maioria das 4xx respostas até corrigir a causa. Tenha especial cuidado com pedidos POST não idempotentes, pois repeti-los pode criar ações duplicadas do lado do servidor, mesmo após um tempo limite. Registe o estado final ou a exceção após o limite de tentativas ter sido esgotado.

Conclusão

Um scraper Java fiável começa com uma decisão correta de acesso aos dados. Analise o HTML devolvido e o tráfego de rede do navegador antes de escolher as ferramentas. Para páginas estáticas, reutilize um HttpClient, analise-o com o Jsoup, modele registos tipados e mantenha o analisador independente dos detalhes de transporte. Em seguida, amplie a mesma base de código com paginação limitada, URLs visitadas, HTML em cache, limites explícitos de concorrência, novas tentativas seletivas, sessões baseadas em cookies, validação e saída duradoura.

Os controlos importantes são visíveis e finitos: limites de páginas, prazos de pedidos, autorizações em curso, tentativas de repetição, diagnóstico de respostas, campos obrigatórios, limites de saída e encerramento seguro. Esses controlos tornam as falhas explicáveis. Também permitem alterar uma camada sem desestabilizar o resto do pipeline de scraping da Web em Java.

Utilize o Selenium apenas quando a interação com o navegador fizer parte dos requisitos, e não simplesmente porque uma página utiliza JavaScript. Se esse fluxo de trabalho exigir realmente cliques, deslocamento, formulários ou estado renderizado sem que tenha de gerir a infraestrutura do navegador, a API do navegador da WebScrapingAPI é uma opção alojada razoável a considerar.

Comece com uma página autorizada, guarde uma configuração do analisador e execute um pequeno teste piloto. Assim que as contagens, as falhas e a saída parecerem corretas, aumente gradualmente o orçamento de rastreamento, continuando a respeitar as restrições do alvo e a sua própria análise de conformidade.

Sobre o autor

Raluca Penciuc, Desenvolvedor Full-Stack @ WebScrapingAPI

Raluca Penciuc

Desenvolvedor Full-Stack

Raluca Penciuc é programadora Full Stack na WebScrapingAPI, onde desenvolve scrapers, aperfeiçoa estratégias de evasão e procura formas fiáveis de reduzir a deteção nos sites-alvo.

Web Scraping com AWS Lambda: Guia para Python e Java 2026
Guias

Web Scraping com AWS Lambda: Guia para Python e Java 2026

Resumo: A extração de dados da Web com o AWS Lambda funciona melhor quando cada invocação é curta, delimitada e pode ser repetida de forma independente. Comece com HTTP direto, AWS SAM e S3 e, só depois, adicione SQS, contentores, renderização no navegador, proxies ou uma camada de recuperação gerida, apenas quando a carga de trabalho demonstrar que precisa deles.

Suciu Dan32 min read
Ler artigo
Como utilizar o GoSpider: rastrear, limpar URLs e extrair dados
Guias

Como utilizar o GoSpider: rastrear, limpar URLs e extrair dados

Resumo: O GoSpider é um rastreador de linha de comandos destinado a descobrir URLs, não um extrator completo de dados estruturados. Este guia sobre como utilizar o GoSpider mostra como realizar um rastreio limitado, gerir resultados de forma organizada, fazer a transferência de dados do Colly para CSV e seguir um percurso de diagnóstico para respostas 403 ou páginas que exijam renderização em JavaScript.

Suciu Dan23 min read
Ler artigo
Como fazer o Scrape Redfin: Guia Python para Dados de Propriedade
Guias

Como fazer o Scrape Redfin: Guia Python para Dados de Propriedade

TL;DR: A Redfin expõe pontos de extremidade de API ocultos que retornam JSON estruturado para listagens de propriedades, tornando possível ignorar totalmente a análise HTML frágil. Este guia orienta-o na construção de um scraper Python que extrai dados de aluguer e venda, pesquisa por localização, monitoriza novas listagens através de sitemaps XML e exporta resultados limpos para CSV ou JSON.

Suciu Dan14 min read
Ler artigo

Comece a construir

Pronto para expandir a sua recolha de dados?

Junte-se a mais de 2.000 empresas que utilizam a WebScrapingAPI para extrair dados da Web à escala empresarial, sem quaisquer custos de infraestrutura.