Web Scraping in R With rvest
To scrape a website in R and get a clean data frame, install rvest, read the page with read_html(), select one node per record with html_elements(), pull each field out of that record with html_element() plus html_text2() or html_attr(), put the fields in a tibble(), then clean them with str_squish(), parse_number() and distinct(). A real HTML <table> is one call: html_table() returns a tibble. Loop over the pages with a 1-second pause and write the result to CSV; JavaScript pages load in a live Chrome with read_html_live() (rvest 1.0.4 and newer). When you scrape at volume, httr2 sends the requests through ProxyField residential proxies from $1.95/GB; start with the Easy Trial: 1 GB for $2.95.
- residential IPs
- 412M+
- from, traffic never expires
- $1.95/GB
- Easy Trial
- 1 GB for $2.95
On this page 13 sections
- Why use R for web scraping?
- The R packages for web scraping
- Step 1. Install the packages
- Step 2. Read a page
- Step 3. Select elements with CSS selectors
- Step 4. Build a clean data frame
- Step 5. Scrape HTML tables with html_table()
- Step 6. Loop over pages, politely
- Step 7. JavaScript pages: read_html_live()
- Step 8. Requests with headers, retries and a proxy (httr2)
- rvest sessions and forms
- When to add proxies to an R scraper
- Web Scraping in R With rvest FAQ
Why use R for web scraping?
R is a good choice when the scraped data goes straight into analysis: the rows land in a data frame, and dplyr, ggplot2 or a model take it from there in the same script. rvest, written by Hadley Wickham and part of the tidyverse, keeps scraping short and pipe-friendly. Python has more scraping libraries and frameworks for large crawls (Python web scraping), but for research, reporting and a few thousand pages, R does the job without switching languages. Language trade-offs in general: best language for web scraping.
The R packages for web scraping
Versions from CRAN.
| Package | Version | What it does |
|---|---|---|
| rvest | 1.0.5 (2025-08-29), MIT | Reads HTML, selects elements, extracts text, attributes and tables; sessions and forms; read_html_live() for JavaScript pages |
| xml2 | (installed with rvest) | The HTML/XML parser underneath rvest |
| httr2 | 1.3.0 (2026-07-13) | HTTP requests with headers, retries, rate limits and proxies |
| chromote | 0.5.1 (2025-04-24) | Controls Chrome over the DevTools Protocol; used by read_html_live() |
| robotstxt | 0.7.15 (2024-08-29) | Reads a site's robots.txt and checks whether a path is allowed |
| RSelenium | 1.7.10 (2026-02-19) | Drives a browser through Selenium from R |
The polite package, which many older tutorials use, was removed from CRAN on 2026-09-12 (CRAN); the steps below use robotstxt and a pause instead.
Step 1. Install the packages
install.packages(c("rvest", "dplyr", "stringr", "readr", "janitor", "httr2", "robotstxt"))
library(rvest)
library(dplyr)
library(stringr)
library(readr)rvest pulls in xml2; dplyr, stringr and readr do the cleaning in step 4, and janitor tidies table headers in step 5. Install chromote too if you need JavaScript pages (step 7).
Step 2. Read a page
The examples use books.toscrape.com, a practice site made for learning to scrape.
page <- read_html("https://books.toscrape.com/")
page |> html_element("title") |> html_text2()read_html() downloads the page and parses it into a document you can query. Before you scrape a real site, look at the page source in your browser (Ctrl+U): if the data is not there, the page renders it with JavaScript (step 7).
Step 3. Select elements with CSS selectors
html_elements() returns every match, html_element() the first match per input. Find the selectors with the browser's Inspect panel or with the SelectorGadget bookmarklet that rvest's documentation recommends.
books <- page |> html_elements("article.product_pod")
length(books) # 20 books on the page
titles <- books |> html_element("h3 a") |> html_attr("title")
prices <- books |> html_element("p.price_color") |> html_text2()
rating <- books |> html_element("p.star-rating") |> html_attr("class")
links <- books |> html_element("h3 a") |> html_attr("href") |> url_absolute("https://books.toscrape.com/")Use html_text2() rather than html_text(): it returns the text as a browser would show it, turning <br> and block elements into line breaks and collapsing extra spaces, while html_text() returns the raw text, including the HTML's indentation and newlines. url_absolute() turns relative links into full URLs. Writing CSS and XPath selectors in depth: HTML scraping.
Step 4. Build a clean data frame
Put the raw fields in a tibble, then clean every column in one mutate():
raw <- tibble(title = titles, price = prices, rating = rating, url = links)
books_df <- raw |>
mutate(
title = str_squish(title), # trim and collapse spaces
price = parse_number(price), # "£51.77" -> 51.77
rating = match(str_remove(rating, "star-rating "), # "star-rating Three" -> 3
c("One", "Two", "Three", "Four", "Five"))
) |>
filter(!is.na(title), title != "") |> # drop empty records
distinct(url, .keep_all = TRUE) # one row per book
glimpse(books_df)
#> Rows: 20
#> Columns: 4
#> $ title <chr> "A Light in the Attic", "Tipping the Velvet", "Soumission", "Sharp Objects", …
#> $ price <dbl> 51.77, 53.74, 50.10, 47.82, …
#> $ rating <int> 3, 1, 1, 4, …
#> $ url <chr> "https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html", …| Clean-up step | Function | What it fixes |
|---|---|---|
| Whitespace | stringr::str_squish() | leading, trailing and repeated spaces, line breaks |
| Numbers | readr::parse_number() | currency signs, thousands separators, text around the number |
| Links | rvest::url_absolute() (step 3) | relative href values such as catalogue/page-2.html |
| Empty rows | dplyr::filter() | records without the key field |
| Duplicates | dplyr::distinct() | the same item scraped twice, for example on two pages |
| Column names | janitor::clean_names() | headers like Price (excl. tax) become price_excl_tax |
Select fields from each book card (books |> html_element(...)), not from the whole page (page |> html_elements(...)). html_element() returns exactly one result per card, so a book without a price gets NA and every column has 20 values. Selecting each field from the whole page returns vectors of different lengths when one card lacks a field, and tibble() stops with an error such as Tibble columns must have compatible sizes.
Step 5. Scrape HTML tables with html_table()
Tables are where R shines. html_table() turns a <table> into a tibble in one call. On one element (html_element()) it returns one tibble; on several elements or the whole document it returns a list of tibbles, one per table.
detail <- read_html("https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html")
info <- detail |> html_element("table.table-striped") |> html_table(header = FALSE)
info
#> # A tibble: 7 × 2
#> X1 X2
#> <chr> <chr>
#> 1 UPC a897fe39b1053632
#> 2 Product Type Books
#> 3 Price (excl. tax) £51.77
#> …
# one row per book, with clean column names
info |>
tidyr::pivot_wider(names_from = X1, values_from = X2) |>
janitor::clean_names() # upc, product_type, price_excl_tax, …
all_tables <- detail |> html_elements("table") |> html_table() # a list of tibblesThe arguments, as the rvest reference documents them (html_table(x, header = NA, trim = TRUE, dec = ".", na.strings = "NA", convert = TRUE)):
| Argument | Default | What it does |
|---|---|---|
header | NA | NA uses the first row as column names only if it is made of <th> cells; TRUE always uses it, FALSE never (columns become X1, X2, …) |
trim | TRUE | removes leading and trailing whitespace in each cell |
dec | "." | the decimal mark; set "," for tables written as 1.234,56 |
na.strings | "NA" | values turned into NA when convert = TRUE, for example c("", "-", "n/a") |
convert | TRUE | runs type.convert(), so number columns come back as integer or double |
Merged cells (colspan, rowspan) are handled as a browser shows them: the value is repeated in every cell the merged cell covers. Rows with too few cells are filled with NA; the old fill argument is deprecated for that reason. With header = TRUE, names stay exactly as in the page, so run janitor::clean_names() before you refer to them in code.
Step 6. Loop over pages, politely
library(robotstxt)
paths_allowed("https://books.toscrape.com/catalogue/page-2.html") # TRUE if robots.txt allows it
scrape_page <- function(url) {
p <- read_html(url)
b <- p |> html_elements("article.product_pod")
tibble(
title = b |> html_element("h3 a") |> html_attr("title"),
price = b |> html_element("p.price_color") |> html_text2()
)
}
urls <- sprintf("https://books.toscrape.com/catalogue/page-%d.html", 1:50)
all_books <- purrr::map(urls, \(u) { Sys.sleep(1); scrape_page(u) }) |> purrr::list_rbind()
write.csv(all_books, "books.csv", row.names = FALSE)
nrow(all_books) # 1,000Sys.sleep(1) keeps the pace at one page per second. robots.txt lists the paths a site asks crawlers to avoid; it is a request, not access control, and the site's terms apply too (RFC 9309). How to check both: how to check if scraping is allowed.
Step 7. JavaScript pages: read_html_live()
rvest 1.0.4 (February 2024) added read_html_live(), which loads the page in a real Chrome through chromote, so you can scrape HTML that JavaScript generates (rvest changelog). It returns a live object you can also scroll, click and type into. The function is marked experimental.
install.packages("chromote") # needs Chrome or Chromium installed
live <- read_html_live("https://quotes.toscrape.com/js/")
live |> html_elements("div.quote span.text") |> html_text2() |> head(3)
live$click("li.next a") # interact, then select againBefore you render, open the browser's Network tab: many JavaScript pages load their data from a JSON request, which httr2 can call directly and httr2::resp_body_json() parses into a list. RSelenium is the older way to drive a browser from R; for most new code, read_html_live() is simpler. Dynamic pages in general: how to scrape dynamic web pages.
Step 8. Requests with headers, retries and a proxy (httr2)
read_html() is fine for small jobs. For larger ones, fetch with httr2, which adds a User-Agent, retries on temporary errors, a rate limit and a proxy, then parse the body with rvest.
library(httr2)
fetch <- function(url) {
request(url) |>
req_user_agent("books-tutorial/1.0 ([email protected])") |>
req_retry(max_tries = 4) |>
req_throttle(capacity = 30, fill_time_s = 60) |> # about 30 requests a minute
req_proxy("http://gate.proxyfield.io", port = 7000,
username = "<login>-country-us-city-new_york", password = "<password>") |>
req_perform() |>
resp_body_html()
}
page <- fetch("https://books.toscrape.com/")
page |> html_elements("h3 a") |> html_attr("title") |> head()req_proxy(req, url, port, username, password) is httr2's documented proxy function (httr2 reference). Remove the req_proxy line to send requests from your own IP. In the ProxyField username, <login>-country-us-city-new_york asks for exit IPs in New York. Region, session and session time go in the same username: -region-<region>, -session-<id> and -ttl-<seconds> (30 to 129,600 seconds, 20 minutes by default), as in <login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional.
rvest sessions and forms
session() keeps cookies between requests and lets you follow links (session_follow_link()) and submit forms (html_form(), html_form_set(), session_submit()), which covers simple searches and logins to your own accounts. Do not scrape data behind logins you are not entitled to, and read is web scraping legal before you collect personal data.
When to add proxies to an R scraper
Sites limit how many requests one IP may send. If your R scraper starts getting 403 or 429 responses at a polite pace, or you need pages as they look from another country, route the httr2 requests through rotating proxies as in step 8: each request then leaves from a new IP. ProxyField residential proxies give you a new IP per request or a sticky session from 30 s to 36 h, with country, region and city targeting over HTTP, HTTPS or SOCKS5. They cost $2.95/GB for 1 GB and fall to $1.95/GB from 1,000 GB, with no subscription; traffic never expires, and all purchases are final. Plain HTML pages are small, so 1 GB covers thousands of pages like these. Start with residential proxies and the Easy Trial: 1 GB for $2.95.
Web Scraping in R With rvest FAQ
How do I scrape a website in R with rvest and turn the results into a clean data frame?
ProxyField's tutorial above does it in 5 steps: read the page with read_html(), select one node per record with html_elements(), extract each field inside that record with html_element() and html_text2() or html_attr(), and put the fields in a tibble(). Then clean the columns with str_squish(), parse_number(), filter() and distinct(). If the data sits in an HTML <table>, html_table() returns a tibble in one call.
How do I scrape a website with R?
Install rvest, read the page with read_html(), select elements with CSS selectors, extract text, attributes and tables, and build a data frame. Loop over pages with Sys.sleep() between requests, render JavaScript pages with read_html_live() and fetch at volume with httr2, then write the result to CSV or analyze it in the same script.
Why use R for web scraping?
Because the scraped rows go straight into a data frame and on to dplyr, ggplot2 or a model in the same script. rvest's pipe-friendly functions keep a scraper short; Python is the stronger choice for very large crawls.
What is the best R package for web scraping?
ProxyField's R tutorial uses rvest, the package that fits most jobs: it reads HTML, selects elements with CSS or XPath, extracts text, attributes and tables, and handles sessions and forms. Pair it with httr2 for headers, retries and proxies, and with chromote when pages need JavaScript.
What is rvest?
An R package by Hadley Wickham, part of the tidyverse, for scraping web pages: read_html(), html_elements(), html_text2(), html_attr() and html_table() cover most needs. It is published on CRAN under the MIT license.
How do I scrape a table from a website in R?
Read the page with read_html(), select the table with html_element("table") or a more specific selector, and call html_table(), which returns a tibble with number columns already converted (convert = TRUE). Use html_elements("table") and html_table() to get every table on the page as a list of tibbles; merged cells are repeated in every cell they cover.
How do I use rvest to format and clean the scraped data?
Extract text with html_text2(), which already normalizes whitespace, then clean the columns in one mutate(): str_squish() for text, parse_number() for prices such as "£51.77", and url_absolute() for links. Drop empty rows with filter() and repeats with distinct(), and select fields within each item card so missing values become NA instead of misaligning rows.
What is the difference between html_text() and html_text2()?
html_text() returns the raw text of a node, with the source's indentation and line breaks. html_text2() returns it as a browser shows it: <br> and paragraphs become line breaks and repeated spaces collapse. Use html_text2() for scraped values unless you need the raw source text.
How do I scrape JavaScript-rendered pages in R?
Use read_html_live() from rvest 1.0.4 or newer, which loads the page in Chrome through chromote and lets you click and scroll before selecting elements. Check the Network tab first: calling the page's JSON request with httr2 is faster.
Can I scrape websites in RStudio?
Yes. RStudio is an editor for R, so any rvest or httr2 code runs in it; install the packages from the Packages pane or with install.packages(). The Viewer pane can also show a live page from read_html_live() with $view().
Show 2 more questionsShow fewer questions
How do I use Selenium with R?
With the RSelenium package from CRAN, which drives a browser through a Selenium server. For most new scrapers, rvest's read_html_live() is simpler, because it needs only Chrome and no Selenium server.
How do I use a proxy for web scraping in R?
Fetch with httr2 and add req_proxy(url, port, username, password) to the request, then parse the response body with rvest. With a rotating gateway, each request leaves from a new IP.
Related pages
Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.