Saltar al contenido
Empezar gratis
Volver al blog

Web scraping en R: guía práctica con rvest y httr2

Raluca PenciucÚltima actualización el 23 min read
Web scraping en R: guía práctica con rvest y httr2
En resumen: para hacer web scraping en R, usa rvest para analizar HTML con selectores CSS o XPath y httr2 para descargar páginas con reintentos y límites de velocidad. rvest por sí solo basta para una página; en cuanto rastrees un sitio, descarga con httr2 y analiza con rvest, y recurre a read_html_live() cuando JavaScript construya la página. Esta guía cubre tablas, paginación, datos en etiquetas script, API de scraping y pruebas sin conexión.

Los datos que necesitas están en una página web y quieres tenerlos en un tibble sin salir de R. El web scraping en R te lleva hasta ahí con dos paquetes y el tidyverse que ya conoces. ¿Es la primera vez que oyes hablar del tema? Empieza por qué es el web scraping.

Esta guía usa Goodreads como ejemplo de principio a fin: la lista de lectores "Best Books Ever" y la página de 1984 de George Orwell. Vas a analizar una página y una tabla con rvest, rastrear con cortesía una lista paginada con httr2, extraer datos que construye JavaScript, pasar las páginas bloqueadas a una API de scraping sin cambiar tu parser, probar tu código sin conexión y resumir el resultado.

Inicio rápido

  • Instala: install.packages(c("rvest", "httr2"))
  • Carga con read_html(), selecciona con html_elements(), extrae con html_text2() o html_attr()
  • Usa read_html_live() para páginas que construye JavaScript
  • Usa R 4.1 o posterior para el pipe nativo |>

¿Qué necesitas para hacer web scraping en R?

Necesitas R 4.1 o posterior y dos paquetes: rvest para analizar HTML y httr2 para descargar páginas con reintentos y límites de velocidad. Añade chromote cuando JavaScript construya las páginas, y una API de scraping cuando los sitios te bloqueen. Todo lo demás, de dplyr a readr, es el tidyverse que ya usas.

R encaja bien con el scraping porque las filas extraídas llegan directamente a un tibble. Así, la limpieza y el análisis ocurren en la misma sesión.

rvest analiza: selecciona elementos con selectores CSS o XPath y extrae texto, atributos y tablas. httr2 descarga: define cabeceras, espera entre solicitudes, reintenta los fallos temporales y ejecuta solicitudes en paralelo. rvest todavía puede descargar una URL por sí solo, y para una sola página es suficiente. En cuanto rastrees, descarga con httr2 y analiza con rvest.

Paquete

Función

Úsalo cuando

Estado en CRAN (oct. 2026)

rvest

Analizar HTML, seleccionar con CSS o XPath, tablas, read_html_live()

Siempre

1.0.5

xml2

Motor HTML/XML bajo rvest; url_absolute()

Resolver enlaces

1.6.0

httr2

Descargar con cabeceras, reintentos, control de velocidad, solicitudes en paralelo

Más de un puñado de páginas, o API

1.3.0

jsonlite

Analizar JSON de endpoints o etiquetas script

Los datos llegan en JSON

2.0.0

chromote

Chrome headless (lo usa read_html_live())

Páginas construidas con JavaScript

0.5.1

RSelenium

Selenium WebDriver desde R

Ya tienes Selenium configurado o necesitas Firefox

1.7.10 (requiere Java y un driver)

testthat

Pruebas del parser sin conexión

Cualquier scraper que vayas a volver a ejecutar

3.3.2

polite, Rcrawler

Utilidades antiguas de cortesía y rastreo

No empieces código nuevo con ellas

Archivados en CRAN en octubre de 2026

Rcrawler y polite se archivaron en CRAN (Rcrawler en abril de 2025 y polite en septiembre de 2026). Por eso, los tutoriales antiguos que dependen de ellos no se instalan correctamente. No los necesitas: req_throttle() de httr2 se encarga del ritmo de las solicitudes, y la comprobación de robots.txt que verás más adelante cubre el resto de lo que hacía polite.

¿Cómo se prepara R para hacer scraping?

Descarga R desde CRAN si aún no lo tienes. La versión actual es R 4.6.2, y todos los fragmentos de esta guía funcionan en R 4.1 o posterior. Sirve cualquier editor: RStudio, Positron o VS Code. Después, instala en una línea los paquetes que usa esta guía:

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

read_html_live() también necesita Google Chrome en tu equipo. chromote lo encuentra automáticamente.

Crea una carpeta de proyecto con una subcarpeta fixtures/ para las páginas guardadas. Guarda las tres páginas de Goodreads que esta guía analiza sin conexión:

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

¿Cómo se hace scraping de una página web con rvest?

Carga la página con read_html(), selecciona los elementos que se repiten con html_elements() y un selector CSS y, después, extrae los campos de cada uno con html_element() más html_text2() o html_attr(). Construye un tibble con esos vectores. Como las funciones de rvest están vectorizadas, rara vez necesitas un bucle, y los campos que faltan devuelven NA en lugar de romper el código.

La lista "Best Books Ever" de Goodreads muestra 100 libros por página. Cada libro es una fila de una tabla con la clase tableList, y cada fila contiene una posición, un enlace al título, un enlace al autor y una línea como "4.36 avg rating — 10,338,532 ratings". Esta función convierte una página de la lista en un 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)
  )
}

Línea por línea:

  • html_elements(page, "table.tableList tr") devuelve todas las filas: un nodo por libro.
  • html_element(rows, "a.bookTitle"), en singular, devuelve exactamente un resultado por fila. Eso es lo que mantiene alineadas las columnas. Si a una fila le faltara el autor, obtendrías NA en esa posición, y no un vector más corto que desplazaría todos los valores siguientes.
  • html_text2() devuelve el texto tal como lo muestra un navegador, y une los saltos de línea y las secuencias de espacios. La antigua html_text() devuelve el texto en bruto, sangría incluida.
  • readr::parse_number() lee el primer número de una cadena, así que convierte la línea de valoración en 4.36. Si antes eliminas todo hasta "avg rating", queda "— 10,338,532 ratings", que se convierte en 10338532. Las dos columnas son números que puedes ordenar y promediar, no cadenas.
  • xml2::url_absolute() convierte cada href relativo, como /book/show/2767052-the-hunger-games, en una URL completa. Resuelve los enlaces respecto a la página de la que proceden, no respecto a una raíz del sitio escrita a mano. Un enlace puede ser relativo a la carpeta de la página, y una base equivocada genera URL que devuelven 404. La referencia de url_absolute() explica las reglas.

Ejecútala sobre la página guardada:

# 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")

Salida:

# 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

Una copia guardada te permite iterar sin enviar una solicitud en cada ejecución. read_html() también acepta una URL, así que read_html("https://www.goodreads.com/list/show/1.Best_Books_Ever") analiza la lista en vivo.

¿Cómo se encuentran los selectores CSS adecuados?

Abre la página en Chrome o Firefox, haz clic derecho sobre los datos que quieres y elige Inspeccionar. Busca una clase o etiqueta que se repita una vez por registro, pruébala en la consola de DevTools con document.querySelectorAll() y luego pega el mismo selector en html_elements(). Prefiere nombres de clase estables a rutas largas generadas y copiadas desde DevTools.

En la lista de Goodreads, al inspeccionar el título de un libro aparece un <a class="bookTitle"> dentro de un <tr> de table.tableList. Si escribes document.querySelectorAll("table.tableList tr").length en la consola, obtienes 100, uno por libro, lo que confirma que es el elemento repetido correcto.

En esta guía aparecen seis patrones de selectores, y cubren la mayor parte del trabajo de scraping:

  • Etiqueta.clase: a.bookTitle coincide con los elementos <a> que tienen la clase bookTitle.
  • Descendiente: table.tableList tr coincide con las filas que estén en cualquier lugar dentro de esa tabla.
  • Solo clase: .minirating coincide con cualquier elemento que tenga esa clase (aquí lo escribimos como span.minirating para ser explícitos).
  • ID: script#__NEXT_DATA__ coincide con el único elemento que tiene ese id.
  • Valor de atributo: script[type="application/ld+json"] coincide con los scripts de un tipo concreto.
  • Atributo de un control: button[aria-label="Book details and editions"] encuentra un botón por su nombre accesible, que suele sobrevivir a los cambios de estilo.

Evita las rutas largas que genera "Copy selector" en DevTools, como #all_votes > table > tbody > tr:nth-child(1) > td:nth-child(3). Se rompen en cuanto cambia el diseño. Los nombres de clase generados con sufijos aleatorios son frágiles por la misma razón.

Si prefieres señalar y hacer clic, la extensión de navegador SelectorGadget sugiere un selector cuando haces clic en los elementos que quieres. Comprueba primero su sugerencia en la consola.

rvest también acepta XPath: html_elements(page, xpath = "//a[@class='bookTitle']") hace lo mismo que la versión CSS. Consulta cuándo XPath supera a CSS para ver los casos en los que es la mejor herramienta, y ten a mano la referencia completa de selectores mientras trabajas.

Consejo: si un selector funciona en DevTools pero devuelve cero elementos en R, puede que los datos no estén en el HTML que envía el servidor. DevTools muestra la página después de que se ejecute JavaScript, y read_html() la ve antes.

¿Cómo se hace scraping de una tabla HTML en R?

Selecciona la tabla con html_element() y pásala a html_table(), que devuelve un tibble con una columna por cada columna de la tabla. Indica header = FALSE cuando la tabla no tenga fila de encabezado y luego cambia el nombre de las columnas. Limpia después los números con readr::parse_number(), porque html_table() conserva los símbolos de moneda y el texto tal como se muestran.

La lista de Goodreads es una tabla HTML de verdad, así que html_table() la lee en una sola llamada:

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

Salida:

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

Obtienes 100 filas y cuatro columnas. La primera columna es la posición, ya tipada como entero. La tercera muestra el límite: Goodreads mete el título, el autor, la valoración y la puntuación de votos en una sola celda, así que html_table() te lo devuelve todo como una única cadena.

html_table() es la opción más rápida cuando cada celda contiene un solo valor, como en una lista de precios o una tabla de estadísticas. Cuando una celda agrupa varios campos, selecciona dentro de cada fila, como hace parse_books(), para obtener columnas limpias y tipadas.

Si llamas a html_table() sobre una página entera, obtienes una lista con todas las tablas. Por eso, selecciona primero la que quieres.

¿Cómo se hace scraping de varias páginas en R?

Extrae la primera página, lee el enlace a la página siguiente, resuélvelo con xml2::url_absolute() y repite hasta que el enlace desaparezca o llegues a un límite de páginas. Descarga con httr2: usa req_throttle() para limitar la velocidad de las solicitudes y req_retry() para reintentar las respuestas 429 y 503 respetando también la cabecera Retry-After.

read_html(url) sirve para una página. Para un rastreo, descarga con httr2, porque controlas el user agent, el tiempo de espera, la velocidad de solicitudes y los reintentos en un solo 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))

Salida:

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

Qué hace cada parte de fetch_html():

  • req_user_agent() identifica tu scraper y le da al sitio una forma de contactarte. Pon una dirección real.
  • req_throttle(capacity = 1, fill_time_s = 2) es un token bucket que admite una solicitud y se rellena cada dos segundos. Así, el rastreo envía como máximo una solicitud cada dos segundos aproximadamente a un mismo host, por rápido que vaya el bucle.
  • req_retry() reintenta los fallos transitorios. Por defecto, son HTTP 429 Too Many Requests y 503 Service Unavailable, y cuando el servidor envía una cabecera Retry-After, httr2 espera ese tiempo antes de volver a intentarlo.
  • req_timeout(30) evita que una solicitud colgada bloquee todo el rastreo.

scrape_list() sigue el enlace "Next" (a.next_page) en lugar de adivinar números de página, lo resuelve respecto a la URL actual y se detiene cuando el enlace desaparece o llega a max_pages. bind_rows(.id = "page") registra la página de origen de cada fila, y el rango de rank de 1 a 300 confirma que no se ha saltado ni repetido ninguna página.

Mantén max_pages bajo mientras desarrollas. Tres páginas te enseñan tanto como cien.

¿Cómo se hace scraping de páginas en paralelo?

req_perform_parallel() de httr2 recibe una lista de solicitudes y ejecuta varias a la vez, y max_active limita cuántas están en curso (el valor por defecto es 10). Construye la lista con lapply() sobre tus URL y añade req_throttle() a cada solicitud para que la velocidad combinada hacia un mismo host siga siendo cortés. No lances un worker por núcleo de CPU contra el mismo sitio. Eso multiplica tu tráfico y hace que te bloqueen enseguida. Con miles de URL, el cuello de botella es la red, no R: analizar una página lleva milisegundos. Reparte el trabajo en paralelo entre hosts distintos, no sobre uno solo.

¿Cómo saber si una página necesita JavaScript?

Descarga el HTML en bruto con read_html() y comprueba si tu selector encuentra algo. Si devuelve cero elementos pero el navegador muestra los datos, es JavaScript quien construye ese contenido. Antes de recurrir a un navegador, mira dentro de las etiquetas script: muchos sitios envían los registros como JSON, que jsonlite puede analizar directamente.

La página de 1984 en Goodreads muestra un botón "Book details and editions". Si haces clic en él en un navegador, se abre un panel con el formato, la editorial y el idioma de la edición. En el HTML en bruto, ese panel todavía no existe. Pero los datos que hay detrás sí, dentro de dos etiquetas 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")

Salida:

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 

El selector no encuentra nada, así que el panel lo construye JavaScript. Aun así, la página contiene los datos.

JSON-LD (script[type="application/ld+json"]) es un resumen estructurado que muchos sitios publican para los buscadores. Es pequeño, está documentado por schema.org y cambia con menos frecuencia que el diseño de una página, así que búscalo primero en páginas de productos, libros, recetas y eventos.

__NEXT_DATA__ es el estado a partir del cual renderiza el framework JavaScript de la página. Goodreads guarda los registros en una lista plana indexada por tipo, así que el código se queda con la entrada Book cuyo ID coincide con este libro. Contiene campos que el HTML visible no tiene, como la editorial, pero su estructura es interna del sitio y puede cambiar sin previo aviso. Por eso conviene cubrirla con pruebas.

Una tercera fuente son los propios endpoints JSON del sitio: en DevTools, abre Network, filtra por Fetch/XHR y recarga. httr2 puede llamar a un endpoint que encuentres ahí, y resp_body_json() analiza la respuesta, siempre que robots.txt permita su ruta.

Las tres suelen ser más rápidas y estables que renderizar la página en un navegador.

¿Cómo se hace scraping de páginas renderizadas con JavaScript en R?

Usa read_html_live() de rvest, que carga la página en un Chrome headless a través de chromote y te permite aplicar las funciones habituales de rvest al DOM renderizado. También puede hacer clic, escribir y desplazarse. Instala antes Google Chrome. Para trabajos más grandes o sitios que bloquean, renderiza en remoto con una API de scraping en lugar de ejecutar Chrome tú mismo.

read_html_live() abre la página de 1984, hace clic en el botón de detalles y lee el panel 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())

Salida:

# 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                             

read_html_live() llegó en rvest 1.0.4 y devuelve un objeto de página en vivo. Su método $html_elements() funciona como la función que ya conoces, y el resultado pasa directamente a html_element() y html_text2(). El mismo objeto puede hacer $click() sobre un selector, $type() en un campo y $scroll_to() hasta una posición, lo que cubre los botones de "cargar más" y el scroll infinito. La referencia de read_html_live() enumera todos los métodos.

Sys.sleep() es la espera más sencilla posible. En páginas lentas, usa un bucle corto que compruebe length(page$html_elements("div.EditionDetails")) cada medio segundo hasta que sea mayor que cero o se agote un tiempo límite. options(chromote.timeout = 30) da más tiempo de carga a las páginas pesadas. Cierra la sesión cuando termines, porque cada una mantiene un proceso de Chrome en marcha.

¿Nunca has usado un navegador sin ventana? Lee cómo funcionan los navegadores headless.

RSelenium es la vía más antigua. Sigue en CRAN y es útil si tu equipo ya usa un Selenium Grid o necesita Firefox. El precio es la configuración: Java, un servidor o driver de Selenium y versiones de navegador y driver que tienen que coincidir.

Un Chrome local funciona bien para un puñado de páginas. Cuando necesites sesiones de navegador gestionadas en la nube, con esperas y acciones, usa un navegador automatizado alojado.

¿Cómo se hace scraping de páginas bloqueadas desde R con una API de scraping?

Envía la URL de destino a una API de scraping desde httr2 y analiza la respuesta con rvest como siempre. Con la Scraper API de WebScrapingAPI, llamas a un único endpoint con tu api_key y la URL. Añade render_js=1 para páginas con JavaScript. Guarda la clave en .Renviron, nunca en el código. Tus funciones de análisis no cambian.

Los reintentos y el control de velocidad resuelven la mayoría de los problemas en sitios amigables. Estos síntomas indican que la descarga local es el cuello de botella:

  • respuestas 403 o 429 que siguen apareciendo después de reintentos corteses
  • páginas de verificación o de consentimiento donde debería estar el contenido
  • contenido que cambia según el país, cuando necesitas la versión de un lugar concreto
  • más páginas con JavaScript por renderizar de las que puede manejar tu Chrome local

Antes de cambiar la capa de descarga, prueba lo básico. Hay otras formas de reducir los bloqueos, y cuatro son las que más importan:

  • Ve más despacio: sube fill_time_s en req_throttle().
  • Envía un user agent honesto con datos de contacto.
  • Guarda en caché las páginas que ya has descargado en lugar de volver a pedirlas.
  • No extraigas lo que no necesitas, como imágenes o páginas ajenas a tu pregunta.

Si los síntomas persisten, cambia la capa de descarga. La Scraper API de WebScrapingAPI recibe una URL y devuelve la página. El propio servicio se encarga de los proxies, los reintentos y, de forma opcional, el renderizado de JavaScript. No hay SDK para R y no lo necesitas: una sola función de httr2 es toda la integración.

# 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

Salida:

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

La URL impresa muestra por qué importa req_url_query(). La URL de destino tiene su propio ?page=2. Si la pegaras sin más dentro de otra URL, ese ? y cualquier & se leerían como parámetros de la API, y la API descargaría la página equivocada. httr2 los codifica como %3F y %26, así que el destino llega intacto. Los argumentos NULL se descartan, de modo que render_js y country solo aparecen cuando los defines.

Mantén la clave fuera de tu código. Añade esta línea al archivo .Renviron de tu directorio personal y reinicia R:

WSA_API_KEY=your_api_key_here

Sys.getenv("WSA_API_KEY") la lee, y el manual de R explica cómo lee R el archivo .Renviron al arrancar. Si la variable no está definida, wsa_get() se detiene con Error: Set WSA_API_KEY in your environment (for example in ~/.Renviron). Una clave no válida devuelve un HTTP 401 con el cuerpo {"error":"Invalid API key"}, y httr2 lo convierte en un error de R. Así, una clave incorrecta nunca pasa desapercibida como una página vacía.

render_js = TRUE ejecuta el JavaScript de la página antes de devolver el HTML. country = "us" descarga la página tal como la vería un visitante de ese país. El renderizado de JavaScript y la geolocalización no cuestan créditos adicionales.

La respuesta pasa a la misma parse_books() que escribiste al principio:

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"]'))

Salida:

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

El parser no ha cambiado, solo la línea de descarga. Por eso, cada prueba que escribas para parse_books() también cubre este camino.

¿Qué método de scraping en R deberías usar?

Usa rvest solo para páginas estáticas y sitios de práctica. Añade httr2 cuando rastrees muchas páginas y necesites límites de velocidad y reintentos. Analiza el JSON de las etiquetas script o el endpoint JSON del propio sitio cuando JavaScript cargue los datos. Usa read_html_live() para unas pocas páginas interactivas, y una API de scraping cuando los bloqueos o el volumen hagan que la descarga local sea poco fiable.

Situación

Usa

Por qué

HTML estático, pocas páginas

read_html() + rvest

Menos piezas móviles

Muchas páginas o un rastreo

Descarga con httr2 (req_throttle(), req_retry()) + rvest

Límites de velocidad, reintentos, tiempos de espera

Datos en JSON dentro de <script> o en un endpoint JSON

httr2 + jsonlite

Más rápido y estable que renderizar

Página construida con JavaScript, poco volumen

read_html_live()

Chrome real, las mismas funciones de rvest

Clics, inicios de sesión y flujos de varios pasos que tienes permiso para automatizar

Métodos de read_html_live(), o RSelenium

Soporte de interacción

403/429 que los reintentos no resuelven, contenido específico de un país o renderizado a gran escala

Scraper API desde httr2

Proxies, reintentos y renderizado gestionados; parser sin cambios

Si no lo tienes claro, recorre esta lista de arriba abajo y detente en el primer sí:

  1. ¿Están los datos en el HTML en bruto? Usa rvest.
  2. ¿Necesitas más que unas pocas páginas? Descarga con httr2 y analiza con rvest.
  3. ¿Están los datos en una etiqueta script o en un endpoint JSON? Analízalos con jsonlite.
  4. ¿La página necesita un navegador y solo tienes unas pocas páginas? Usa read_html_live().
  5. ¿Te bloquean, necesitas un país concreto o renderizas a gran escala? Usa una API de scraping.

¿Cómo se prueba un scraper y se convierten los resultados en un conjunto de datos?

Guarda una copia de cada tipo de página como archivo HTML y escribe pruebas de testthat que la analicen sin conexión, para que un rediseño del sitio haga fallar una prueba en lugar de devolver NA en silencio. Después, trata el tibble extraído como cualquier otro dato: resúmelo con dplyr, guárdalo con readr::write_csv() y anota cuándo lo recopilaste.

Los scrapers rara vez fallan con un error cuando un sitio cambia. Devuelven columnas vacías. Las pruebas contra páginas guardadas lo detectan. Ya guardaste las fixtures con download.file(). Si descargas con httr2, writeLines(resp_body_string(resp), "fixtures/page.html") hace lo mismo. Súbelas al repositorio del proyecto para que las pruebas se ejecuten igual en cualquier equipo.

Este archivo de testthat comprueba lo que importa del parser de la 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/")
})

Ejecútalo desde la carpeta tests/ con testthat::test_file("test-parse.R"):

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

Cuando el sitio cambie, guarda una copia nueva de la página y vuelve a ejecutar la prueba. Un fallo te dice qué campo se ha roto antes de que los datos erróneos lleguen a tu análisis. Aplica la misma regla de "fallar de forma visible" en producción: si un selector devuelve cero filas, detén la ejecución con un error en lugar de escribir un archivo vacío.

¿Los libros con más valoraciones también tienen mejor nota? Dos páginas guardadas te dan 200 libros para analizar:

# 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")

Salida:

# 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

En esta muestra, los 37 libros con más de tres millones de valoraciones tienen una nota mediana de 4,3, frente a 4,1 de los libros con menos de un millón. Es un patrón en una sola lista votada por lectores, no una ley del mundo editorial. A partir de aquí, son datos de R corrientes: un gráfico de dispersión de ggplot2 de ratings frente a avg_rating en escala logarítmica es el siguiente gráfico evidente.

Como la lista cambia a diario, añade una columna scraped_at = Sys.time() antes de guardar. Cuando alguien vuelva a ejecutar el análisis el mes que viene y obtenga cifras distintas, la marca de tiempo explicará por qué.

¿Qué es mejor para el web scraping, R o Python?

Python tiene más bibliotecas de scraping y herramientas de navegador, así que encaja con rastreadores grandes y servicios en producción. R es la mejor opción cuando los datos extraídos alimentan análisis, informes o modelos en R, porque rvest devuelve tibbles y httr2 cubre la descarga cortés. Para la mayoría de los analistas, quedarse en un solo lenguaje compensa más que cambiar por un scraping algo más rápido. Si los datos terminan en un análisis en R, un informe de Quarto o un modelo, hacer scraping en R elimina un traspaso: sin exportaciones y sin un segundo entorno que mantener sincronizado.

La capa de descarga es independiente del lenguaje, lo que facilita combinarlos. Un cliente HTTP o una API de scraping devuelve el mismo HTML a R y a Python, así que un equipo puede rastrear en un lenguaje y analizar en el otro. Para la parte de Python, consulta nuestra guía de web scraping con Python.

¿Cómo se hace scraping de forma responsable en R?

Lee los términos y el robots.txt del sitio antes de rastrear, mantén una velocidad de solicitudes baja, identifica tu scraper con un user agent y recopila solo los campos que necesitas. Evita los datos personales salvo que tengas una base legal, y nunca hagas scraping tras un inicio de sesión sin permiso. Ante la duda, pregunta al propietario del sitio o usa su API.

robots.txt es la forma en que un sitio indica a los rastreadores qué rutas no quiere que visiten. El estándar robots.txt (RFC 9309) define las reglas: busca el grupo de tu user agent, y gana la regla coincidente más específica. Esta función las aplica a una lista de rutas:

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

Salida:

                          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

Las páginas de lista y de libro que usa esta guía están permitidas. Las listas de reseñas y la búsqueda no, y por eso esta guía trabaja con valoraciones a nivel de libro y no recopila reseñas individuales. La última fila muestra la regla de la coincidencia más larga en acción: Goodreads prohíbe /work, pero permite la ruta más específica /work/quotes.

robots.txt es una convención para rastreadores, no un permiso legal, y no es el único documento que cuenta. Los términos de uso de un sitio pueden restringir la recopilación automatizada incluso en rutas que robots.txt permite. Los términos de Goodreads, por ejemplo, excluyen la minería de datos y las herramientas de extracción automatizada de la licencia que conceden. Léelos antes de ejecutar estos ejemplos y pide permiso a Goodreads antes de recopilar sus datos para cualquier fin que vaya más allá del aprendizaje.

Las reseñas también muestran por qué los datos personales requieren cuidado: cada una lleva el nombre y las opiniones de un lector. Para más buenos hábitos, consulta estas buenas prácticas de scraping y nuestros principios de datos web responsables.

FAQ

¿Se sigue manteniendo rvest?

Sí. rvest forma parte del tidyverse, y la versión 1.0.5 está en CRAN a fecha de octubre de 2026. La versión 1.0.4 añadió read_html_live() para páginas con JavaScript. Para la descarga, el mismo equipo mantiene ahora httr2, que gestiona reintentos, límites de velocidad y solicitudes en paralelo, por lo que la mayoría de los scrapers modernos en R combinan ambos paquetes.

¿Cuál es la diferencia entre rvest y httr2?

rvest analiza HTML: selecciona elementos con CSS o XPath y extrae texto, atributos y tablas. httr2 hace solicitudes HTTP: define cabeceras, reintenta fallos, controla tu velocidad y gestiona API JSON. Puedes extraer páginas sencillas solo con rvest, pero en los rastreos descargas con httr2 y analizas con rvest.

¿Puede rvest hacer scraping de sitios con JavaScript?

read_html() por sí sola no puede, porque solo ve el HTML que envía el servidor. read_html_live() sí: ejecuta la página en un Chrome headless a través de chromote y te da el DOM renderizado para usarlo con las funciones normales de rvest. Comprueba primero si hay JSON en las etiquetas script o un endpoint JSON, que suele ser más rápido.

¿Merece la pena seguir usando RSelenium?

RSelenium sigue en CRAN, pero necesita Java, un servidor o driver de Selenium y versiones de navegador que coincidan, lo que hace frágil la configuración. Úsalo cuando tu equipo ya trabaje con Selenium o necesite Firefox. Para la mayoría de los usuarios de R, read_html_live() de rvest cubre el renderizado, los clics y la escritura con mucha menos configuración.

¿Cómo extraigo una tabla de un sitio web en R?

Carga la página con read_html(), selecciona la tabla con html_element() y un selector CSS como "table.data", y luego llama a html_table(). Obtienes un tibble con una columna por cada columna de la tabla. Usa header = FALSE para tablas sin encabezado y limpia después los números con readr::parse_number().

¿Cómo evito que me bloqueen al hacer scraping con R?

Reduce la velocidad con req_throttle() de httr2, reintenta las respuestas 429 y 503 con req_retry(), define un user agent honesto y guarda las páginas en caché para no volver a descargarlas. Si un sitio sigue devolviendo errores 403 o páginas de verificación, envía las solicitudes a través de una API de scraping que gestione proxies y renderizado, y deja tu parser de rvest como está.

¿Cómo debo guardar una clave de API en R?

Pon la clave en tu archivo .Renviron de usuario con una línea como WSA_API_KEY=your_key, reinicia R y léela con Sys.getenv("WSA_API_KEY"). Nunca pegues claves en scripts, notebooks ni URL que compartas, y añade cualquier archivo .Renviron del proyecto a .gitignore para que la clave nunca llegue al control de versiones.

¿Puedo hacer scraping de páginas que requieren inicio de sesión con R?

Técnicamente sí: session() de rvest puede enviar formularios y conservar cookies, y httr2 puede enviar solicitudes autenticadas. Hazlo solo con permiso del propietario del sitio y dentro de sus términos, porque las zonas con inicio de sesión suelen contener datos personales o contratados. Si el sitio ofrece una API oficial, úsala en lugar de extraer las páginas con sesión iniciada.

¿Cómo hago scraping de muchas páginas en paralelo en R?

Construye una lista de solicitudes de httr2 y envíalas con req_perform_parallel(), usando max_active para limitar cuántas se ejecutan a la vez. Añade req_throttle() para que ningún host reciba más tráfico del que puede soportar. Mantén una concurrencia moderada con cada sitio, porque la velocidad rara vez importa tanto como evitar que te bloqueen.

Hacer scraping de datos públicos suele ser legal, pero depende de los términos del sitio, del tipo de datos y de la legislación local, sobre todo cuando hay datos personales o contenido protegido por derechos de autor. El lenguaje no cambia eso. Respeta robots.txt, evita los datos personales sin base legal y busca asesoramiento jurídico para proyectos comerciales.

Conserva tu código en R y delega las páginas difíciles

Tus parsers de rvest no tienen que cambiar cuando un sitio empieza a bloquearte o necesita JavaScript. Apunta la descarga a la Scraper API de WebScrapingAPI y sigue analizando en R. El renderizado de JavaScript y la geolocalización no cuestan créditos adicionales, y las solicitudes fallidas no se cobran. Compara los planes de Scraper API, empezando por una prueba gratuita de 5.000 solicitudes.

Acerca del autor

Raluca Penciuc, Desarrollador full-stack @ WebScrapingAPI

Raluca Penciuc

Desarrollador full-stack

Raluca Penciuc es desarrolladora full stack en WebScrapingAPI, donde se dedica a crear rastreadores, mejorar las técnicas de evasión y buscar formas fiables de reducir la detección en los sitios web de destino.

Extracción de datos web con AWS Lambda: guía para Python y Java 2026
Guías

Extracción de datos web con AWS Lambda: guía para Python y Java 2026

TL;DR: En resumen: el web scraping con AWS Lambda funciona mejor cuando cada invocación es breve, está delimitada y se puede reintentar de forma independiente. Empieza con HTTP directo, AWS SAM y S3, y añade SQS, contenedores, renderización en navegador, proxies o una capa de recuperación gestionada solo cuando la carga de trabajo demuestre que los necesita.

Suciu Dan33 min read
Leer artículo
Cómo utilizar GoSpider: rastrear, depurar URL y extraer datos
Guías

Cómo utilizar GoSpider: rastrear, depurar URL y extraer datos

TL;DR: En resumen: GoSpider es un rastreador de línea de comandos diseñado para descubrir URL, no un extractor completo de datos estructurados. Esta guía sobre cómo utilizar GoSpider muestra cómo realizar un rastreo limitado, gestionar correctamente los resultados, pasar los datos de Colly a CSV y seguir una ruta de diagnóstico para respuestas 403 o páginas que requieren renderización con JavaScript.

Suciu Dan24 min read
Leer artículo
Cómo raspar Redfin: Guía Python de Datos Inmobiliarios
Guías

Cómo raspar Redfin: Guía Python de Datos Inmobiliarios

TL;DR: Redfin expone puntos finales de API ocultos que devuelven JSON estructurado para los listados de propiedades, lo que permite omitir por completo el frágil análisis HTML. Esta guía te guía a través de la construcción de un raspador de Python que extrae datos de alquiler y venta, busca por ubicación, supervisa los nuevos listados a través de mapas de sitio XML y exporta resultados limpios a CSV o JSON.

Suciu Dan14 min read
Leer artículo

Empieza a crear

¿Estás listo para ampliar tu recopilación de datos?

Únete a más de 2000 empresas que utilizan WebScrapingAPI para extraer datos de la web a escala empresarial sin ningún gasto de infraestructura.