Saltar para o conteúdo
Comece grátis
Voltar ao blogue

Web scraping em R: guia prático com rvest e httr2

Raluca PenciucÚltima atualização em 23 min read
Web scraping em R: guia prático com rvest e httr2
Resumo: para fazer web scraping em R, use o rvest para analisar HTML com seletores CSS ou XPath e o httr2 para buscar páginas com novas tentativas e limite de taxa. O rvest sozinho basta para uma página; quando você passar a rastrear um site, busque com o httr2 e analise com o rvest, e use read_html_live() quando o JavaScript montar a página. Este guia cobre tabelas, paginação, dados em tags script, APIs de scraping e testes offline.

Os dados de que você precisa estão em uma página web, e você quer colocá-los em um tibble sem sair do R. O web scraping em R leva você até lá com dois pacotes e o tidyverse que você já conhece. É novo no assunto? Comece entendendo o que é web scraping.

Este guia usa o Goodreads como exemplo do começo ao fim: a lista de leitores "Best Books Ever" e a página de 1984, de George Orwell. Você vai analisar uma página e uma tabela com o rvest, rastrear uma lista paginada com educação usando o httr2, extrair dados que o JavaScript monta, passar páginas bloqueadas para uma API de scraping sem mudar o seu parser, testar o código offline e resumir o resultado.

Início rápido

  • Instale: install.packages(c("rvest", "httr2"))
  • Carregue com read_html(), selecione com html_elements(), extraia com html_text2() ou html_attr()
  • Use read_html_live() para páginas montadas por JavaScript
  • Use o R 4.1 ou mais recente para ter o pipe nativo |>

Do que você precisa para fazer web scraping em R?

Você precisa do R 4.1 ou mais recente e de dois pacotes: o rvest para analisar HTML e o httr2 para buscar páginas com novas tentativas e limite de taxa. Acrescente o chromote quando as páginas forem montadas por JavaScript, e uma API de scraping quando os sites bloquearem você. Todo o resto, do dplyr ao readr, é o tidyverse que você já usa.

O R combina bem com scraping porque as linhas coletadas caem direto em um tibble. Assim, a limpeza e a análise acontecem na mesma sessão.

O rvest analisa: seleciona elementos com seletores CSS ou XPath e extrai textos, atributos e tabelas. O httr2 busca: define cabeçalhos, espera entre as requisições, repete falhas temporárias e executa requisições em paralelo. O rvest ainda consegue buscar uma URL sozinho, o que serve bem para uma página. Quando começar a rastrear, busque com o httr2 e analise com o rvest.

Pacote

Função

Use quando

Status no CRAN (out. 2026)

rvest

Analisar HTML, selecionar com CSS ou XPath, tabelas, read_html_live()

Sempre

1.0.5

xml2

Motor HTML/XML por baixo do rvest; url_absolute()

Resolver links

1.6.0

httr2

Buscar com cabeçalhos, novas tentativas, controle de taxa, requisições paralelas

Mais do que algumas páginas, ou APIs

1.3.0

jsonlite

Analisar JSON de endpoints ou tags script

Os dados vêm em JSON

2.0.0

chromote

Chrome headless (usado por read_html_live())

Páginas montadas por JavaScript

0.5.1

RSelenium

Selenium WebDriver a partir do R

Já existe um ambiente Selenium ou é preciso usar o Firefox

1.7.10 (exige Java e um driver)

testthat

Testes offline do parser

Qualquer scraper que você vá rodar de novo

3.3.2

polite, Rcrawler

Utilitários antigos de boas maneiras e rastreamento

Não comece código novo com eles

Arquivados no CRAN em outubro de 2026

O Rcrawler e o polite foram arquivados no CRAN (o Rcrawler em abril de 2025, o polite em setembro de 2026). Por isso, tutoriais antigos que dependem deles não instalam direito. Você não precisa deles: o req_throttle() do httr2 cuida do ritmo das requisições, e a verificação do robots.txt mais adiante neste guia cobre o restante do que o polite fazia.

Como configurar o R para scraping?

Baixe o R no CRAN se ainda não tiver. A versão atual é o R 4.6.2, e todos os trechos deste guia rodam no R 4.1 ou mais recente. Qualquer editor serve: RStudio, Positron ou VS Code. Depois, instale em uma linha os pacotes que este guia usa:

install.packages(c("rvest", "httr2", "jsonlite", "dplyr", "readr", "chromote", "testthat"))

O read_html_live() também precisa do Google Chrome instalado na sua máquina. O chromote o encontra automaticamente.

Crie uma pasta de projeto com uma subpasta fixtures/ para as páginas salvas. Salve as três páginas do Goodreads que este guia analisa offline:

download.file("https://www.goodreads.com/list/show/1.Best_Books_Ever", "fixtures/list-page1.html")
download.file("https://www.goodreads.com/list/show/1.Best_Books_Ever?page=2", "fixtures/list-page2.html")
download.file("https://www.goodreads.com/book/show/5470.1984", "fixtures/book-1984.html")

Como fazer scraping de uma página web com o rvest?

Carregue a página com read_html(), selecione os elementos que se repetem com html_elements() e um seletor CSS e, depois, extraia os campos de cada um com html_element() mais html_text2() ou html_attr(). Monte um tibble com esses vetores. Como as funções do rvest são vetorizadas, você raramente precisa de um loop, e campos ausentes voltam como NA em vez de quebrar o código.

A lista "Best Books Ever" do Goodreads mostra 100 livros por página. Cada livro é uma linha de uma tabela com a classe tableList, e cada linha traz uma posição, um link para o título, um link para o autor e uma linha como "4.36 avg rating — 10,338,532 ratings". Esta função transforma uma página da lista em um tibble:

# parse_books.R: turn one Goodreads list page into a tibble
library(rvest)
library(tibble)

parse_books <- function(page, page_url) {
  rows  <- html_elements(page, "table.tableList tr")
  links <- html_element(rows, "a.bookTitle")
  stats <- html_text2(html_element(rows, "span.minirating"))   # "4.36 avg rating — 10,338,532 ratings"
  tibble(
    rank       = as.integer(html_text2(html_element(rows, "td.number"))),
    title      = html_text2(links),
    author     = html_text2(html_element(rows, "a.authorName")),
    avg_rating = readr::parse_number(stats),
    ratings    = readr::parse_number(sub(".*avg rating", "", stats)),
    url        = xml2::url_absolute(html_attr(links, "href"), page_url)
  )
}

Linha por linha:

  • html_elements(page, "table.tableList tr") retorna todas as linhas: um nó por livro.
  • html_element(rows, "a.bookTitle"), no singular, retorna exatamente um resultado por linha. É isso que mantém as colunas alinhadas. Se faltasse o autor em uma linha, você receberia NA naquela posição, e não um vetor mais curto que desloca todos os valores abaixo.
  • html_text2() retorna o texto do jeito que o navegador o exibe, juntando quebras de linha e sequências de espaços. A antiga html_text() retorna o texto bruto, com indentação e tudo.
  • readr::parse_number() lê o primeiro número de uma string, então transforma a linha de avaliação em 4.36. Removendo antes tudo até "avg rating", sobra "— 10,338,532 ratings", que vira 10338532. As duas colunas são números, não strings, e você pode ordená-las e calcular médias.
  • xml2::url_absolute() transforma cada href relativo, como /book/show/2767052-the-hunger-games, em uma URL completa. Resolva os links em relação à página de onde vieram, não a uma raiz de site digitada à mão. Um link pode ser relativo à pasta da página, e a base errada gera URLs que dão 404. A referência de url_absolute() explica as regras.

Rode a função na página salva:

# 01_static.R: parse a saved page (offline); the same code works on a live URL
source("parse_books.R")
page  <- read_html("fixtures/list-page1.html")
books <- parse_books(page, "https://www.goodreads.com/list/show/1.Best_Books_Ever")
print(books, n = 5, width = 100)
cat("rows:", nrow(books), "\n")

Saída:

# A tibble: 100 × 6
   rank title                                                       
  <int> <chr>                                                       
1     1 The Hunger Games (The Hunger Games, #1)                     
2     2 Pride and Prejudice                                         
3     3 To Kill a Mockingbird                                       
4     4 Harry Potter and the Order of the Phoenix (Harry Potter, #5)
5     5 The Book Thief                                              
  author          avg_rating  ratings url                                       
  <chr>                <dbl>    <dbl> <chr>                                     
1 Suzanne Collins       4.36 10338532 https://www.goodreads.com/book/show/27670…
2 Jane Austen           4.3   5042082 https://www.goodreads.com/book/show/1885.…
3 Harper Lee            4.26  7157438 https://www.goodreads.com/book/show/2657.…
4 J.K. Rowling          4.5   3954056 https://www.goodreads.com/book/show/2.Har…
5 Markus Zusak          4.39  2994493 https://www.goodreads.com/book/show/19063…
# ℹ 95 more rows
rows: 100

Uma cópia salva permite iterar sem enviar uma requisição a cada execução. O read_html() também aceita uma URL, então read_html("https://www.goodreads.com/list/show/1.Best_Books_Ever") analisa a lista ao vivo.

Como encontrar os seletores CSS certos?

Abra a página no Chrome ou no Firefox, clique com o botão direito nos dados que você quer e escolha Inspecionar. Procure uma classe ou tag que se repita uma vez por registro, teste-a no console do DevTools com document.querySelectorAll() e depois cole o mesmo seletor em html_elements(). Prefira nomes de classe estáveis a caminhos longos gerados e copiados do DevTools.

Na lista do Goodreads, inspecionar o título de um livro mostra um <a class="bookTitle"> dentro de um <tr> de table.tableList. Digitar document.querySelectorAll("table.tableList tr").length no console retorna 100, um por livro, o que confirma que esse é o elemento repetido certo.

Seis padrões de seletor aparecem neste guia, e eles cobrem a maior parte do trabalho de scraping:

  • Tag.classe: a.bookTitle corresponde a elementos <a> com a classe bookTitle.
  • Descendente: table.tableList tr corresponde a linhas em qualquer lugar dentro dessa tabela.
  • Só a classe: .minirating corresponde a qualquer elemento com essa classe (aqui escrevemos span.minirating para deixar explícito).
  • ID: script#__NEXT_DATA__ corresponde ao único elemento com esse id.
  • Valor de atributo: script[type="application/ld+json"] corresponde a scripts de um tipo específico.
  • Atributo de um controle: button[aria-label="Book details and editions"] encontra um botão pelo nome acessível, que costuma sobreviver a mudanças de estilo.

Evite os caminhos longos que o "Copy selector" do DevTools gera, como #all_votes > table > tbody > tr:nth-child(1) > td:nth-child(3). Eles quebram assim que o layout muda. Nomes de classe gerados com sufixos aleatórios são frágeis pelo mesmo motivo.

Se você prefere apontar e clicar, a extensão de navegador SelectorGadget sugere um seletor quando você clica nos elementos desejados. Confira a sugestão no console antes.

O rvest também aceita XPath: html_elements(page, xpath = "//a[@class='bookTitle']") faz o mesmo trabalho que a versão em CSS. Leia quando o XPath supera o CSS para saber em que casos ele é a melhor ferramenta, e deixe a referência completa de seletores aberta enquanto trabalha.

Dica: se um seletor funciona no DevTools mas retorna zero elementos no R, talvez os dados não estejam no HTML que o servidor envia. O DevTools mostra a página depois que o JavaScript roda, e o read_html() a vê antes.

Como fazer scraping de uma tabela HTML em R?

Selecione a tabela com html_element() e passe-a para html_table(), que retorna um tibble com uma coluna para cada coluna da tabela. Use header = FALSE quando a tabela não tiver linha de cabeçalho e, depois, renomeie as colunas. Limpe os números em seguida com readr::parse_number(), porque o html_table() mantém símbolos de moeda e textos exatamente como aparecem.

A lista do Goodreads é uma tabela HTML de verdade, então o html_table() a lê em uma única chamada:

# 03_table.R: read the same list with html_table()
library(rvest)
page <- read_html("fixtures/list-page1.html")
raw  <- page |> html_element("table.tableList") |> html_table(header = FALSE)
dim(raw)
raw$X3 <- trimws(gsub("\\s+", " ", raw$X3))   # collapse the cell's line breaks
print(raw[, c("X1", "X3")], n = 3, width = 100)

Saída:

[1] 100   4
# A tibble: 100 × 2
     X1
  <int>
1     1
2     2
3     3
  X3                                                                            
  <chr>                                                                         
1 The Hunger Games (The Hunger Games, #1) by Suzanne Collins 4.36 avg rating — …
2 Pride and Prejudice by Jane Austen 4.30 avg rating — 5,042,082 ratings score:…
3 To Kill a Mockingbird by Harper Lee 4.26 avg rating — 7,157,438 ratings score…

Você recebe 100 linhas e quatro colunas. A primeira coluna é a posição, já tipada como inteiro. A terceira mostra o limite: o Goodreads junta título, autor, avaliação e pontuação de votos em uma única célula, então o html_table() entrega tudo isso como uma só string.

O html_table() é a opção mais rápida quando cada célula tem um único valor, como em uma tabela de preços ou de estatísticas. Quando uma célula junta vários campos, selecione dentro de cada linha, como faz o parse_books(), para obter colunas limpas e tipadas.

Chamar html_table() na página inteira retorna uma lista com todas as tabelas. Por isso, selecione primeiro a que você quer.

Como fazer scraping de várias páginas em R?

Faça o scraping da primeira página, leia o link para a próxima, resolva-o com xml2::url_absolute() e repita até o link sumir ou você atingir um limite de páginas. Busque pelo httr2 com req_throttle() para limitar a taxa de requisições e req_retry() para repetir respostas 429 e 503, respeitando também o cabeçalho Retry-After.

O read_html(url) serve para uma página. Para um rastreamento, busque com o httr2, porque assim você controla o user agent, o tempo limite, a taxa de requisições e as novas tentativas em um único pipeline:

# 02_paginate.R: polite live crawl with httr2 retries, throttling, and next-page links
library(httr2)
source("parse_books.R")

fetch_html <- function(url) {
  request(url) |>
    req_user_agent("r-scraping-tutorial/1.0 (you@example.com)") |>
    req_throttle(capacity = 1, fill_time_s = 2) |>   # at most ~1 request every 2 seconds
    req_retry(max_tries = 3) |>                      # retries 429 and 503, honors Retry-After
    req_timeout(30) |>
    req_perform() |>
    resp_body_html()
}

scrape_list <- function(start_url, max_pages = 3) {
  url <- start_url
  out <- list()
  for (i in seq_len(max_pages)) {
    page     <- fetch_html(url)
    out[[i]] <- parse_books(page, url)
    next_href <- html_attr(html_element(page, "a.next_page"), "href")
    if (is.na(next_href)) break
    url <- xml2::url_absolute(next_href, url)
  }
  dplyr::bind_rows(out, .id = "page")
}

books <- scrape_list("https://www.goodreads.com/list/show/1.Best_Books_Ever", max_pages = 3)
print(dplyr::count(books, page))
print(range(books$rank))

Saída:

# A tibble: 3 × 2
  page      n
  <chr> <int>
1 1       100
2 2       100
3 3       100
[1]   1 300

O que cada parte de fetch_html() faz:

  • req_user_agent() identifica o seu scraper e dá ao site uma forma de entrar em contato com você. Coloque um endereço real ali.
  • req_throttle(capacity = 1, fill_time_s = 2) é um token bucket que comporta uma requisição e se reabastece a cada dois segundos. Assim, o rastreamento envia no máximo cerca de uma requisição a cada dois segundos para um mesmo host, não importa a velocidade do loop.
  • req_retry() repete falhas temporárias. Por padrão, são HTTP 429 Too Many Requests e 503 Service Unavailable, e, quando o servidor envia um cabeçalho Retry-After, o httr2 espera esse tempo antes de tentar de novo.
  • req_timeout(30) impede que uma requisição travada paralise o rastreamento inteiro.

O scrape_list() segue o link "Next" (a.next_page) em vez de adivinhar números de página, resolve-o em relação à URL atual e para quando o link some ou quando chega a max_pages. O bind_rows(.id = "page") registra a página de origem de cada linha, e o intervalo de rank de 1 a 300 confirma que nenhuma página foi pulada ou repetida.

Mantenha max_pages baixo durante o desenvolvimento. Três páginas ensinam tanto quanto cem.

Como fazer scraping de páginas em paralelo?

O req_perform_parallel() do httr2 recebe uma lista de requisições e executa várias ao mesmo tempo, com max_active limitando quantas ficam em andamento (o padrão é 10). Monte a lista com lapply() sobre as suas URLs e acrescente req_throttle() a cada requisição para que a taxa combinada para um único host continue moderada. Não inicie um worker por núcleo de CPU contra o mesmo site. Isso multiplica o seu tráfego e faz você ser bloqueado rápido. Com milhares de URLs, o gargalo é a rede, não o R: analisar uma página leva milissegundos. Distribua o trabalho paralelo entre hosts diferentes, não sobre um só.

Como saber se uma página precisa de JavaScript?

Busque o HTML bruto com read_html() e verifique se o seu seletor encontra alguma coisa. Se ele retornar zero elementos, mas o navegador mostrar os dados, é o JavaScript que monta esse conteúdo. Antes de partir para um navegador, olhe dentro das tags script: muitos sites enviam os registros como JSON, que o jsonlite consegue analisar diretamente.

A página de 1984 no Goodreads mostra um botão "Book details and editions". Ao clicar nele no navegador, um painel se abre com o formato, a editora e o idioma da edição. No HTML bruto, esse painel ainda não existe. Mas os dados por trás dele existem, dentro de duas tags script:

# 04_js_check.R: is the data in the HTML, or built by JavaScript?
library(rvest)
page <- read_html("fixtures/book-1984.html")
cat("edition details in raw HTML:", length(html_elements(page, "div.EditionDetails")), "\n")

# 1. JSON-LD: the structured summary many sites publish for search engines
ld <- page |> html_element('script[type="application/ld+json"]') |> html_text2() |> jsonlite::fromJSON()
book <- tibble::tibble(
  title      = ld$name,
  author     = ld$author$name[1],
  pages      = ld$numberOfPages,
  avg_rating = ld$aggregateRating$ratingValue,
  ratings    = ld$aggregateRating$ratingCount
)
print(book)

# 2. __NEXT_DATA__: the app state the page's JavaScript renders from
next_data <- page |> html_element("script#__NEXT_DATA__") |> html_text2() |>
  jsonlite::fromJSON(simplifyVector = FALSE)
state     <- next_data$props$pageProps$apolloState
this_book <- Filter(\(x) identical(x$`__typename`, "Book") && identical(x$legacyId, 5470L), state)[[1]]
cat("publisher:", this_book$details$publisher, "| language:", this_book$details$language$name, "\n")

Saída:

edition details in raw HTML: 0 
# A tibble: 1 × 5
  title author        pages avg_rating ratings
  <chr> <chr>         <int>      <dbl>   <int>
1 1984  George Orwell   328       4.21 5812589
publisher: New American Library | language: English 

O seletor não encontra nada, então é o JavaScript que monta o painel. Mesmo assim, a página carrega os dados.

O JSON-LD (script[type="application/ld+json"]) é um resumo estruturado que muitos sites publicam para os mecanismos de busca. Ele é pequeno, documentado pelo schema.org e muda com menos frequência que o layout da página, então procure por ele primeiro em páginas de produtos, livros, receitas e eventos.

O __NEXT_DATA__ é o estado a partir do qual o framework JavaScript da página renderiza. O Goodreads guarda os registros em uma lista plana indexada por tipo, então o código fica com a entrada Book cujo ID corresponde a este livro. Ela tem campos que o HTML visível não tem, como a editora, mas a estrutura é interna do site e pode mudar sem aviso. Por isso, cubra-a com testes.

Uma terceira fonte são os próprios endpoints JSON do site: no DevTools, abra a aba Network, filtre por Fetch/XHR e recarregue a página. O httr2 pode chamar um endpoint encontrado ali, e o resp_body_json() analisa a resposta, desde que o robots.txt permita aquele caminho.

As três opções costumam ser mais rápidas e estáveis do que renderizar a página em um navegador.

Como fazer scraping de páginas renderizadas com JavaScript em R?

Use o read_html_live() do rvest, que carrega a página em um Chrome headless por meio do chromote e permite aplicar as funções habituais do rvest ao DOM renderizado. Ele também sabe clicar, digitar e rolar a página. Instale o Google Chrome antes. Para trabalhos maiores ou sites que bloqueiam, renderize remotamente com uma API de scraping em vez de rodar o Chrome por conta própria.

O read_html_live() abre a página de 1984, clica no botão de detalhes e lê o painel que aparece:

# 05_live.R: render the page in headless Chrome and click a button (rvest >= 1.0.4, chromote + Chrome)
library(rvest)
options(chromote.timeout = 30)   # heavy pages can take longer than the 10-second default
page <- read_html_live("https://www.goodreads.com/book/show/5470.1984")
Sys.sleep(2)   # simple wait; see the note on waiting for a selector
page$click('button[aria-label="Book details and editions"]')
Sys.sleep(1)
items <- page$html_elements("div.EditionDetails div.DescListItem")
details <- tibble::tibble(
  field = items |> html_element("dt") |> html_text2(),
  value = items |> html_element("dd") |> html_text2()
)
print(details)
invisible(page$session$close())

Saída:

# A tibble: 3 × 2
  field     value                               
  <chr>     <chr>                               
1 Format    328 pages, Mass Market Paperback    
2 Published July 1, 1950 by New American Library
3 Language  English                             

O read_html_live() chegou no rvest 1.0.4 e retorna um objeto de página ao vivo. O método $html_elements() funciona como a função que você já conhece, e o resultado segue direto para html_element() e html_text2(). O mesmo objeto pode usar $click() em um seletor, $type() em um campo e $scroll_to() até uma posição, o que cobre botões de "carregar mais" e rolagem infinita. A referência de read_html_live() lista todos os métodos.

O Sys.sleep() é a espera mais simples possível. Em páginas lentas, use um loop curto que verifica length(page$html_elements("div.EditionDetails")) a cada meio segundo até o valor passar de zero ou um tempo limite se esgotar. options(chromote.timeout = 30) dá mais tempo para páginas pesadas carregarem. Feche a sessão quando terminar, porque cada uma mantém um processo do Chrome rodando.

Nunca usou um navegador sem janela? Leia como funcionam os navegadores headless.

O RSelenium é o caminho mais antigo. Ele continua no CRAN e é útil se a sua equipe já roda um Selenium Grid ou precisa do Firefox. O custo é a configuração: Java, um servidor ou driver do Selenium e versões de navegador e driver que precisam bater.

O Chrome local funciona bem para algumas páginas. Quando você precisar de sessões de navegador gerenciadas na nuvem, com esperas e ações, use um navegador automatizado hospedado.

Como fazer scraping de páginas bloqueadas a partir do R com uma API de scraping?

Envie a URL de destino para uma API de scraping a partir do httr2 e analise a resposta com o rvest como sempre. Com a Scraper API da WebScrapingAPI, você chama um único endpoint com a sua api_key e a URL. Acrescente render_js=1 para páginas com JavaScript. Guarde a chave no .Renviron, nunca no código. Suas funções de análise não mudam.

Novas tentativas e controle de taxa resolvem a maioria dos problemas em sites amigáveis. Estes sintomas indicam que a busca local virou o gargalo:

  • respostas 403 ou 429 que continuam aparecendo mesmo depois de novas tentativas educadas
  • páginas de verificação ou de consentimento no lugar do conteúdo
  • conteúdo que muda conforme o país, quando você precisa da versão de um local específico
  • mais páginas com JavaScript para renderizar do que o Chrome local dá conta

Antes de trocar a camada de busca, tente o básico. Há outras formas de reduzir bloqueios, e quatro são as mais importantes:

  • Vá mais devagar: aumente fill_time_s em req_throttle().
  • Envie um user agent honesto, com dados de contato.
  • Guarde em cache as páginas que você já buscou em vez de pedi-las de novo.
  • Não colete o que você não precisa, como imagens ou páginas fora da sua pergunta.

Se os sintomas continuarem, troque a camada de busca. A Scraper API da WebScrapingAPI recebe uma URL e devolve a página. Proxies, novas tentativas e renderização opcional de JavaScript ficam por conta do serviço. Não existe SDK para R, e você não precisa de um: uma única função do httr2 é toda a integração.

# 06_scraper_api.R: call WebScrapingAPI's Scraper API from R with httr2
library(httr2)
library(rvest)

wsa_get <- function(url, render_js = FALSE, country = NULL) {
  key <- Sys.getenv("WSA_API_KEY")
  if (key == "") stop("Set WSA_API_KEY in your environment (for example in ~/.Renviron).")
  request("https://api.webscrapingapi.com/v2") |>
    req_url_query(
      api_key   = key,
      url       = url,                       # httr2 URL-encodes query values
      render_js = if (render_js) 1 else NULL,
      country   = country
    ) |>
    req_retry(max_tries = 3) |>
    req_timeout(120) |>
    req_perform()
}

# See exactly what you'll send, without sending it
req <- request("https://api.webscrapingapi.com/v2") |>
  req_url_query(api_key = "YOUR_KEY",
                url = "https://www.goodreads.com/list/show/1.Best_Books_Ever?page=2",
                country = "us")
req$url

Saída:

[1] "https://api.webscrapingapi.com/v2?api_key=YOUR_KEY&url=https%3A%2F%2Fwww.goodreads.com%2Flist%2Fshow%2F1.Best_Books_Ever%3Fpage%3D2&country=us"

A URL exibida mostra por que o req_url_query() importa. A URL de destino tem o seu próprio ?page=2. Colados sem codificação em outra URL, esse ? e qualquer & seriam lidos como parâmetros da API, e ela buscaria a página errada. O httr2 os codifica como %3F e %26, então o destino chega intacto. Argumentos NULL são descartados, então render_js e country só aparecem quando você os define.

Mantenha a chave fora do código. Adicione esta linha ao arquivo .Renviron do seu diretório pessoal e reinicie o R:

WSA_API_KEY=your_api_key_here

Sys.getenv("WSA_API_KEY") lê a chave, e o manual do R explica como o R lê o .Renviron na inicialização. Se a variável não estiver definida, wsa_get() para com Error: Set WSA_API_KEY in your environment (for example in ~/.Renviron). Uma chave inválida volta como HTTP 401 com o corpo {"error":"Invalid API key"}, e o httr2 transforma isso em um erro do R. Assim, uma chave errada nunca passa despercebida como uma página vazia.

render_js = TRUE executa o JavaScript da página antes de o HTML voltar. country = "us" busca a página como um visitante daquele país a veria. Renderização de JavaScript e geolocalização não custam créditos extras.

A resposta vai para o mesmo parse_books() que você escreveu no começo:

source("parse_books.R")
source("wsa_get.R")   # the wsa_get() function from the previous snippet

target <- "https://www.goodreads.com/list/show/1.Best_Books_Ever?page=2"
page   <- wsa_get(target, country = "us") |> httr2::resp_body_html()
books  <- parse_books(page, target)
print(books, n = 5, width = 100)
cat("rows:", nrow(books), "\n")

# Ask the API to run the page's JavaScript before it returns the HTML
book_page <- wsa_get("https://www.goodreads.com/book/show/5470.1984", render_js = TRUE) |>
  httr2::resp_body_html()
html_text2(html_element(book_page, 'h1[data-testid="bookTitle"]'))

Saída:

# A tibble: 100 × 6
   rank title                                                       
  <int> <chr>                                                       
1   101 The Alchemist                                               
2   102 Divergent (Divergent, #1)                                   
3   103 Fahrenheit 451                                              
4   104 The Catcher in the Rye                                      
5   105 The Handmaid's Tale                                         
  author           avg_rating ratings url                                       
  <chr>                 <dbl>   <dbl> <chr>                                     
1 Paulo Coelho           3.91 3382145 https://www.goodreads.com/book/show/18144…
2 Veronica Roth          4.13 3905122 https://www.goodreads.com/book/show/13335…
3 Ray Bradbury           3.97 2488011 https://www.goodreads.com/book/show/13079…
4 J.D. Salinger          3.8  3600144 https://www.goodreads.com/book/show/5107.…
5 Margaret Atwood        4.13 2145887 https://www.goodreads.com/book/show/38447…
# ℹ 95 more rows
rows: 100
[1] "1984"

O parser não mudou, só a linha de busca. Por isso, todo teste que você escrever para parse_books() também cobre esse caminho.

Qual método de scraping em R você deve usar?

Use o rvest sozinho para páginas estáticas e sites de treino. Acrescente o httr2 quando for rastrear muitas páginas e precisar de limite de taxa e novas tentativas. Analise o JSON das tags script ou o endpoint JSON do próprio site quando o JavaScript carregar os dados. Use o read_html_live() para algumas páginas interativas e uma API de scraping quando bloqueios ou volume tornarem a busca local pouco confiável.

Situação

Use

Por quê

HTML estático, poucas páginas

read_html() + rvest

Menos peças móveis

Muitas páginas ou um rastreamento

Busca com httr2 (req_throttle(), req_retry()) + rvest

Limite de taxa, novas tentativas, tempos limite

Dados em JSON dentro de <script> ou em um endpoint JSON

httr2 + jsonlite

Mais rápido e estável do que renderizar

Página montada por JavaScript, pouco volume

read_html_live()

Chrome de verdade, mesmas funções do rvest

Cliques, logins e fluxos de várias etapas que você tem permissão para automatizar

Métodos de read_html_live() ou RSelenium

Suporte a interação

403/429 que novas tentativas não resolvem, conteúdo por país ou renderização em volume

Scraper API a partir do httr2

Proxies, novas tentativas e renderização gerenciados; parser inalterado

Na dúvida, percorra esta lista de cima para baixo e pare no primeiro sim:

  1. Os dados estão no HTML bruto? Use o rvest.
  2. Você precisa de mais do que algumas páginas? Busque com o httr2 e analise com o rvest.
  3. Os dados estão em uma tag script ou em um endpoint JSON? Analise-os com o jsonlite.
  4. A página precisa de um navegador, e você tem só algumas páginas? Use read_html_live().
  5. Você está sendo bloqueado, precisa de um país específico ou está renderizando em volume? Use uma API de scraping.

Como testar um scraper e transformar os resultados em um conjunto de dados?

Salve uma cópia de cada tipo de página como arquivo HTML e escreva testes com testthat que a analisem offline, para que uma reformulação do site faça um teste falhar em vez de retornar NA em silêncio. Depois, trate o tibble coletado como qualquer outro dado: resuma-o com dplyr, grave-o com readr::write_csv() e registre quando você o coletou.

Scrapers raramente travam quando um site muda. Eles retornam colunas vazias. Testes contra páginas salvas pegam isso. Você já salvou as fixtures com download.file(). Se buscar com o httr2, writeLines(resp_body_string(resp), "fixtures/page.html") faz o mesmo. Faça commit delas no projeto para que os testes rodem do mesmo jeito em qualquer máquina.

Este arquivo testthat verifica o que importa no parser da lista:

# tests/test-parse.R: offline tests against saved HTML, run with testthat::test_file()
library(testthat)
source("../parse_books.R")

test_that("list page parses into 100 clean rows", {
  books <- parse_books(read_html("../fixtures/list-page1.html"),
                       "https://www.goodreads.com/list/show/1.Best_Books_Ever")
  expect_equal(nrow(books), 100)
  expect_false(anyNA(books$avg_rating))
  expect_true(all(books$avg_rating >= 1 & books$avg_rating <= 5))
  expect_true(all(books$ratings > 0))
  expect_match(books$url, "^https://www\\.goodreads\\.com/book/show/")
})

Rode-o a partir da pasta tests/ com testthat::test_file("test-parse.R"):

[ FAIL 0 | WARN 0 | SKIP 0 | PASS 5 ]

Quando o site mudar, salve uma nova cópia da página e rode o teste de novo. Uma falha mostra qual campo quebrou antes que dados ruins cheguem à sua análise. Aplique a mesma regra de "falhar ruidosamente" em produção: se um seletor retornar zero linhas, interrompa a execução com um erro em vez de gravar um arquivo vazio.

Livros com mais avaliações também recebem notas maiores? Duas páginas salvas dão 200 livros para examinar:

# 07_analysis.R: from scraped rows to an answer
library(dplyr, warn.conflicts = FALSE)
source("parse_books.R")
list_url <- "https://www.goodreads.com/list/show/1.Best_Books_Ever"
books <- bind_rows(
  parse_books(read_html("fixtures/list-page1.html"), list_url),
  parse_books(read_html("fixtures/list-page2.html"), paste0(list_url, "?page=2"))
)
books |>
  mutate(popularity = cut(ratings, c(0, 1e6, 3e6, Inf),
                          labels = c("under 1M", "1M to 3M", "over 3M"))) |>
  group_by(popularity) |>
  summarize(books = n(), median_rating = median(avg_rating), .groups = "drop") |>
  print()
books |> count(author, sort = TRUE) |> head(3) |> print()
readr::write_csv(books, "books.csv")
cat("wrote books.csv with", nrow(books), "rows\n")

Saída:

# A tibble: 3 × 3
  popularity books median_rating
  <fct>      <int>         <dbl>
1 under 1M      84          4.1 
2 1M to 3M      79          4.14
3 over 3M       37          4.3 
# A tibble: 3 × 2
  author           n
  <chr>        <int>
1 J.K. Rowling    10
2 Rick Riordan     5
3 Riley Sager      4
wrote books.csv with 200 rows

Nesta amostra, os 37 livros com mais de três milhões de avaliações têm nota mediana de 4,3, contra 4,1 dos livros com menos de um milhão. É um padrão em uma única lista votada por leitores, não uma lei do mercado editorial. Daqui em diante, são dados comuns do R: um gráfico de dispersão do ggplot2 com ratings contra avg_rating em escala logarítmica é o próximo gráfico óbvio.

Como a lista muda todos os dias, acrescente uma coluna scraped_at = Sys.time() antes de salvar. Quando alguém rodar a análise de novo no mês que vem e obtiver números diferentes, o carimbo de data e hora explica o porquê.

R ou Python: qual é melhor para web scraping?

O Python tem mais bibliotecas de scraping e ferramentas de navegador, por isso se dá bem com rastreadores grandes e serviços em produção. O R é a melhor escolha quando os dados coletados alimentam análises, relatórios ou modelos em R, porque o rvest retorna tibbles e o httr2 cobre a busca educada. Para a maioria dos analistas, ficar em uma só linguagem vale mais do que trocar por um scraping um pouco mais rápido. Se os dados vão parar em uma análise em R, em um relatório Quarto ou em um modelo, fazer o scraping em R elimina uma etapa de repasse: sem exportação e sem um segundo ambiente para manter sincronizado.

A camada de busca é independente da linguagem, o que facilita misturar as duas. Um cliente HTTP ou uma API de scraping devolve o mesmo HTML para o R e para o Python, então uma equipe pode rastrear em uma linguagem e analisar na outra. Para o lado do Python, veja o nosso guia de web scraping com Python.

Como fazer scraping de forma responsável em R?

Leia os termos de uso e o robots.txt do site antes de rastrear, mantenha a taxa de requisições baixa, identifique o seu scraper com um user agent e colete apenas os campos de que precisa. Evite dados pessoais a menos que tenha uma base legal, e nunca faça scraping atrás de um login sem permissão. Na dúvida, pergunte ao dono do site ou use a API dele.

O robots.txt é a forma como um site informa aos rastreadores quais caminhos ele não quer que sejam visitados. O padrão robots.txt (RFC 9309) define as regras: encontre o grupo do seu user agent, e a regra correspondente mais específica vence. Esta função aplica essas regras a uma lista de caminhos:

# 08_robots.R: check paths against a site's robots.txt before you crawl (RFC 9309)
library(httr2)

robots_allows <- function(site, paths, agent = "*") {
  resp <- request(site) |> req_url_path("/robots.txt") |>
    req_error(is_error = \(resp) FALSE) |> req_perform()
  if (resp_status(resp) == 404) return(rep(TRUE, length(paths)))   # no robots.txt, no rules
  lines <- trimws(sub("#.*", "", strsplit(resp_body_string(resp), "\n")[[1]]))
  lines <- lines[lines != ""]
  field <- tolower(trimws(sub(":.*", "", lines)))
  value <- trimws(sub("^[^:]*:", "", lines))

  # Keep the Allow/Disallow rules from groups whose User-agent line matches `agent`
  keep <- logical(length(lines)); in_group <- FALSE
  for (i in seq_along(lines)) {
    if (field[i] == "user-agent") {
      if (i == 1 || field[i - 1] != "user-agent") in_group <- FALSE
      in_group <- in_group || value[i] == agent
    } else {
      keep[i] <- in_group && field[i] %in% c("allow", "disallow") && value[i] != ""
    }
  }
  type <- field[keep]; rule <- value[keep]
  pattern <- paste0("^", gsub("*", ".*", gsub("([.?+^$()|{}\\[\\]\\\\])", "\\\\\\1", rule, perl = TRUE), fixed = TRUE))

  vapply(paths, function(path) {
    hits <- which(vapply(pattern, grepl, logical(1), x = path))
    if (length(hits) == 0) return(TRUE)
    best <- hits[nchar(rule[hits]) == max(nchar(rule[hits]))]   # longest match wins
    any(type[best] == "allow")                                  # a tie goes to Allow
  }, logical(1))
}

paths <- c("/list/show/1.Best_Books_Ever", "/book/show/5470.1984", "/book/reviews/5470",
           "/review/list/1", "/search?q=orwell", "/work/quotes/153313")
data.frame(path = paths, allowed = unname(robots_allows("https://www.goodreads.com", paths)))

Saída:

                          path allowed
1 /list/show/1.Best_Books_Ever    TRUE
2         /book/show/5470.1984    TRUE
3           /book/reviews/5470   FALSE
4               /review/list/1   FALSE
5             /search?q=orwell   FALSE
6          /work/quotes/153313    TRUE

As páginas de lista e de livro que este guia usa são permitidas. Listas de resenhas e a busca não são, e é por isso que este guia trabalha com avaliações no nível do livro e não coleta resenhas individuais. A última linha mostra a regra da correspondência mais longa em ação: o Goodreads bloqueia /work, mas permite o caminho mais específico /work/quotes.

O robots.txt é uma convenção para rastreadores, não uma permissão legal, e não é o único documento que conta. Os termos de uso de um site podem restringir a coleta automatizada mesmo em caminhos que o robots.txt permite. Os termos do Goodreads, por exemplo, excluem mineração de dados e ferramentas de extração automatizada da licença que concedem. Leia-os antes de rodar estes exemplos e peça permissão ao Goodreads antes de coletar os dados dele para qualquer coisa além de aprendizado.

As resenhas também mostram por que dados pessoais exigem cuidado: cada uma traz o nome e as opiniões de um leitor. Para mais bons hábitos, veja estas boas práticas de scraping e os nossos princípios de dados web responsáveis.

FAQ

O rvest ainda recebe manutenção?

Sim. O rvest faz parte do tidyverse, e a versão 1.0.5 está no CRAN em outubro de 2026. A versão 1.0.4 adicionou o read_html_live() para páginas com JavaScript. Para a busca, a mesma equipe agora mantém o httr2, que cuida de novas tentativas, limites de taxa e requisições paralelas, por isso a maioria dos scrapers modernos em R combina os dois pacotes.

Qual é a diferença entre rvest e httr2?

O rvest analisa HTML: seleciona elementos com CSS ou XPath e extrai textos, atributos e tabelas. O httr2 faz requisições HTTP: define cabeçalhos, repete falhas, controla a sua taxa e lida com APIs JSON. Dá para fazer scraping de páginas simples só com o rvest, mas em rastreamentos você busca com o httr2 e analisa com o rvest.

O rvest consegue fazer scraping de sites com JavaScript?

O read_html() simples não consegue, porque só enxerga o HTML que o servidor envia. O read_html_live() consegue: ele roda a página em um Chrome headless por meio do chromote e entrega o DOM renderizado para você usar com as funções normais do rvest. Antes, procure JSON dentro de tags script ou um endpoint JSON, o que costuma ser mais rápido.

Ainda vale a pena usar o RSelenium?

O RSelenium continua no CRAN, mas exige Java, um servidor ou driver do Selenium e versões de navegador compatíveis, o que torna a configuração frágil. Use-o quando a sua equipe já roda Selenium ou precisa do Firefox. Para a maioria dos usuários de R, o read_html_live() do rvest cobre renderização, cliques e digitação com muito menos configuração.

Como extrair uma tabela de um site em R?

Carregue a página com read_html(), selecione a tabela com html_element() e um seletor CSS como "table.data" e depois chame html_table(). Você recebe um tibble com uma coluna para cada coluna da tabela. Use header = FALSE para tabelas sem cabeçalho e limpe os números depois com readr::parse_number().

Como evitar bloqueios ao fazer scraping com R?

Diminua o ritmo com o req_throttle() do httr2, repita respostas 429 e 503 com req_retry(), defina um user agent honesto e guarde as páginas em cache para não buscá-las de novo. Se um site ainda retornar erros 403 ou páginas de verificação, envie as requisições por uma API de scraping que gerencie proxies e renderização, e mantenha o seu parser rvest como está.

Como guardar uma chave de API em R?

Coloque a chave no seu arquivo .Renviron de usuário em uma linha como WSA_API_KEY=your_key, reinicie o R e leia-a com Sys.getenv("WSA_API_KEY"). Nunca cole chaves em scripts, notebooks ou URLs que você compartilha, e adicione qualquer arquivo .Renviron do projeto ao .gitignore para que a chave nunca chegue ao controle de versão.

Dá para fazer scraping de páginas que exigem login com R?

Tecnicamente sim: o session() do rvest consegue enviar formulários e manter cookies, e o httr2 pode enviar requisições autenticadas. Só faça isso com permissão do dono do site e dentro dos termos dele, porque áreas logadas costumam ter dados pessoais ou contratados. Se o site oferecer uma API oficial, use-a em vez de fazer scraping das páginas logadas.

Como fazer scraping de muitas páginas em paralelo em R?

Monte uma lista de requisições do httr2 e envie-as com req_perform_parallel(), usando max_active para limitar quantas rodam ao mesmo tempo. Acrescente req_throttle() para que nenhum host receba mais tráfego do que aguenta. Mantenha a concorrência moderada para cada site, porque velocidade raramente importa tanto quanto não ser bloqueado.

Fazer scraping de dados publicamente disponíveis costuma ser lícito, mas depende dos termos do site, do tipo de dado e da legislação local, principalmente no caso de dados pessoais e conteúdo protegido por direitos autorais. A linguagem não muda isso. Respeite o robots.txt, evite dados pessoais sem base legal e busque orientação jurídica em projetos comerciais.

Mantenha seu código em R e repasse as páginas difíceis

Seus parsers do rvest não precisam mudar quando um site começa a bloquear você ou passa a exigir JavaScript. Aponte a busca para a Scraper API da WebScrapingAPI e continue analisando em R. Renderização de JavaScript e geolocalização não custam créditos extras, e requisições com falha não são cobradas. Compare os planos da Scraper API, começando por um teste gratuito com 5.000 requisições.

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

TL;DR: 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

TL;DR: 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.