Web Scraping with R and rvest
Web scraping with R done right: fetch through proxies with httr2, parse HTML using rvest html_elements and html_table, and bind clean, rectangular data frames.

Web scraping with R has one advantage no other language matches: the data you pull lands one function call away from a tibble, a model, or a ggplot. The catch is that rvest's read_html() gives you nowhere to attach a proxy, custom headers, or a retry, so the moment a target rate-limits you or hides behind JavaScript, the naive tutorial code stops working. This guide wires rvest to httr2 for proxy support, shows the html_element() trick that keeps your data frames rectangular, and hands JavaScript-heavy pages to a scraping API so the R you write stays parse-and-analyze.
Why R for Web Scraping?
Python owns the scraping conversation, so why write a scraper in R at all? The answer is what happens after the fetch. In R, scraped HTML becomes a tibble with one call, and that tibble is already sitting in the same environment as dplyr, ggplot2, and every statistical model you'd want to run on it. There's no export step, no handoff to a separate analysis stack. If the whole point of the scrape is a chart, a regression, or a cleaned dataset, R keeps the loop tight.
The tooling is mature. rvest wraps xml2 and gives you jQuery-style CSS selectors; html_table() reads an HTML table directly into a data frame; the tidyverse pipe (|>) chains selection and cleaning into one readable expression. If you're new to the practice itself, start with what web scraping is and come back.
The tradeoffs are honest. R is single-threaded by default, so high-concurrency crawls need extra machinery that Python and Rust ship with. rvest reads only the initial HTML, so JavaScript-rendered content needs help (covered below). And read_html() has no proxy argument, which is the wall almost everyone hits first.
Here are the packages that do the work:
| Package | Role | Notes |
|---|---|---|
| `rvest` | HTML parsing + CSS/XPath selectors | Wraps xml2; `html_table()`, `html_text2()`, sessions, forms |
| `xml2` | Underlying parser | rvest is built on it; you rarely call it directly |
| `httr2` | HTTP client | Modern, pipeable, supports `req_proxy()` and retries |
| `httr` | HTTP client (older) | `use_proxy()`; still what rvest sessions use internally |
| `polite` | robots.txt + rate limiting | `bow()` and `scrape()`; the courteous default |
| `ratelimitr` | Rate limiting | Wrap any function with a calls-per-period cap |
| `dplyr` / `tibble` | Data frames | Where your scraped rows land |
Install the Toolkit
Install the core packages once. These versions are current as of August 2026:
install.packages(c("rvest", "httr2", "polite", "dplyr"))
# rvest 1.0.4, httr2 1.0.x, polite 0.1.3, dplyr 1.1.x
The smallest possible scrape confirms the install works. Read a page, select the headings, pull clean text:
library(rvest)
page <- read_html("https://www.sparkproxy.io/")
page |>
html_elements("h2") |>
html_text2()
read_html() fetches and parses in one call, which is convenient for a hello-world and a trap for anything real, because there is no place in that call to set a proxy, a header, or a timeout. Hold that thought. The proxy section is where it matters.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Read and Parse HTML with rvest
Four functions cover almost every parse. Learn the difference between the singular and plural selectors early, because it decides whether your data frames come out rectangular.
html_elements(css)returns every node that matches, flattened into one vector.html_element(css)returns one node per input node, insertingNAwhere a match is missing. This is what keeps columns aligned.html_text2()extracts text the way a browser renders it: collapsed whitespace,treated as a line break.html_attr(name)reads an attribute such ashreforsrc.
library(rvest)
page <- read_html("https://www.sparkproxy.io/blog")
# All article titles on the page
titles <- page |>
html_elements("article h2 a") |>
html_text2()
# The href of each of those same links
links <- page |>
html_elements("article h2 a") |>
html_attr("href")
Reach for html_text2() over the older html_text() by default. html_text() returns the raw text nodes exactly as they sit in the source, newlines and indentation included, so you get strings like "\n Pricing\n ". html_text2() normalizes that to "Pricing". The only time to prefer html_text() is a huge page where you'll clean the strings yourself anyway and want the speed, since html_text2() does more work per node.
For attributes that may be absent, html_attr() returns NA rather than erroring, which is exactly what you want when you later assemble a data frame.
Extract Tables Straight to a Data Frame
This is R's party trick. If the data already lives in a If the numeric columns still come back as character (common when a currency symbol or thousands separator rides along), clean them after the fact: Most pages don't hand you a clean Why the singular form matters: say one card is missing its To crawl many pages, wrap the per-page logic in a function and combine the results. That loop hits five URLs back to back with no throttle and no proxy. Both problems get fixed next. Here's the gotcha the intro promised. Verify the proxy actually took effect before you trust a whole crawl to it. Route an IP echo endpoint through the same proxy and confirm the address is the proxy's, not your server's: If that prints your real server IP, the proxy silently failed and every request is exposing your origin. Fix it before you scale. Prefer the older httr stack? Use the Some data sits behind a login or a search form. rvest's After the submit, the session carries the auth cookie. Now navigate authenticated pages with A form with more than one submit button needs a hint. Pass Hammering a site is how you get blocked and how you get your proxy pool burned. Two packages make good behavior the default. polite bundles the whole etiquette layer: it reads ratelimitr is lower level and composes with anything. It wraps a function so it can't be called more than N times per period, sleeping if you go over: Reach for polite when you want the full courteous default on a single host. Reach for ratelimitr when you need a hard calls-per-second ceiling across a custom fetch function, for example one that already routes through httr2 and a proxy. If blocks persist even with sane delays and rotation, the cause is usually headers or fingerprint, not rate; how to avoid getting your proxy blocked walks through the fixes. rvest reads the HTML the server sends. If a page builds its content with JavaScript after load, that content is not in the HTML rvest sees, and your selectors come back empty. You can drive a headless browser from R, but it's heavy. The lighter path is to hand the URL to a scraping API that renders the page server-side, solves the proxy and fingerprint problem, and returns finished HTML that drops straight into rvest. The SparkProxy Scraping API is an ordinary httr2 request. The base endpoint is The parameters map to the controls you'd otherwise build yourself: Set The API replaces the proxy config, the retry loop, and the fingerprint workarounds, so the R that's left is fetch and parse. If you're weighing building that machinery yourself against renting it, web scraping API vs self-managed proxies lays out the cost tradeoffs. Yes, especially when analysis is the goal. rvest reads HTML into tibbles that dplyr, ggplot2, and your models can use immediately, with no export step. R is single-threaded by default, so very high-concurrency crawls need extra tooling that Python or Rust ship with, but for research, reporting, and data collection that ends in a chart or a model, R is a strong fit. rvest's Because Select the table and call rvest only sees the HTML the server sends, so JavaScript-built content comes back empty. Either drive a headless browser from R, or send the URL to a scraping API that renders the page server-side and returns finished HTML. With the SparkProxy Scraping API you call Get 50% off your first purchase Premium datacentre proxies with unlimited bandwidth. Use the code at checkout. Offer ends soon — claim it before it's gone Learn how to scrape Stack Overflow data the right way: the official Stack Exchange API, filters, backoff, the CC BY-SA data dump, and proxy-safe code. Learn how to scrape Redfin data: pull listings, prices, and property details from Redfin's Stingray JSON API and CSV export, and handle its rate limits. Learn how to scrape IMDb data: titles, ratings, cast, and reviews. Pull IMDb's JSON-LD and hidden JSON, then use the official datasets for bulk facts.,
html_table() reads it into a tibble in one line, with the header row promoted to column names and columns type-converted:
library(rvest)
tables <- read_html("https://www.sparkproxy.io/pricing") |>
html_elements("table")
# Pick the table you want, parse it
pricing <- tables[[1]] |>
html_table()
pricing
#> # A tibble: 4 x 3
#> plan price_month bandwidth
#> <chr> <chr> <chr>
#> ...
html_table() takes a few arguments worth knowing. header = NA auto-detects the header row; set it TRUE or FALSE to force the behavior. convert = TRUE (the default) coerces numeric-looking columns to numbers. na.strings controls which cell values map to NA. When a page has several tables and you only want one, select first with a specific CSS path (html_element("#pricing table")) instead of grabbing them all and indexing.library(dplyr)
library(readr)
pricing <- pricing |>
mutate(price_month = parse_number(price_month))
Build a Rectangular Data Frame from Elements
. You get a grid of cards, list items, or divs, and you have to assemble the data frame yourself. Here's the single most important rule in R scraping, and the one tutorials skip: select the repeating record first with
html_elements(), then pull each field with html_element() (singular) so missing fields become NA and every column stays the same length.
library(rvest)
library(dplyr)
page <- read_html("https://www.sparkproxy.io/products")
# 1. One node per record
cards <- page |> html_elements(".product-card")
# 2. One field per record: html_element() (singular) fills NA on a miss
products <- tibble(
name = cards |> html_element(".title") |> html_text2(),
price = cards |> html_element(".price") |> html_text2(),
url = cards |> html_element("a") |> html_attr("href")
)
products
.price. If you wrote cards |> html_elements(".price"), you'd get back only the prices that exist, one short, and tibble() would throw arguments imply differing number of rows. html_element() returns exactly one result per card, NA included, so all three vectors have the same length and the rows line up with their names. That NA is a feature. It marks the gap instead of silently shifting every price up a row.purrr::list_rbind() stacks the tibbles into one:library(purrr)
scrape_page <- function(n) {
url <- paste0("https://www.sparkproxy.io/products?page=", n)
cards <- read_html(url) |> html_elements(".product-card")
tibble(
page = n,
name = cards |> html_element(".title") |> html_text2(),
price = cards |> html_element(".price") |> html_text2()
)
}
all_products <- map(1:5, scrape_page) |> list_rbind()
Route rvest Through a Proxy
read_html("https://...") has no proxy parameter, no header parameter, and no timeout. There is nowhere to hang them. The fix is to split the job: fetch with httr2 (which supports proxies), then parse the response body with rvest. resp_body_html() returns the same xml2 document that read_html() would, so every rvest selector works on it unchanged.library(httr2)
library(rvest)
resp <- request("https://www.sparkproxy.io/") |>
req_proxy(
url = "http://gate.sparkproxy.io",
port = 10000,
username = "your-username",
password = "your-password"
) |>
req_user_agent("research-bot/1.0 (you@sparkproxy.io)") |>
req_perform()
# Hand the fetched body to rvest
page <- resp |> resp_body_html()
titles <- page |> html_elements("h2") |> html_text2()
ip <- request("https://api.ipify.org") |>
req_proxy("http://gate.sparkproxy.io", port = 10000,
username = "your-username", password = "your-password") |>
req_perform() |>
resp_body_string()
cat("egress IP:", ip, "\n")
use_proxy() does the same job, and rvest can parse an httr response directly:library(httr)
library(rvest)
resp <- GET(
"https://www.sparkproxy.io/",
use_proxy("gate.sparkproxy.io", port = 10000,
username = "your-username", password = "your-password"),
user_agent("research-bot/1.0")
)
page <- read_html(content(resp, as = "text"))
http:// scheme for the proxy URL even when the target is HTTPS. The proxy opens a CONNECT tunnel and the tunnel carries the encrypted traffic. Rotating IPs, pool sizing, and per-IP request limits are their own topic; the guide on using datacenter proxies for web scraping covers the math. Sessions and Forms: Log In and Paginate
session() holds a cookie jar across requests, so you authenticate once and reuse the session. The useful detail almost no tutorial mentions: session() passes its extra arguments straight to httr::GET(), which means you can inject a proxy and a user agent into the session the same way you would a plain request.library(rvest)
# The proxy config rides along into every request this session makes
s <- session(
"https://www.sparkproxy.io/login",
httr::use_proxy("gate.sparkproxy.io", 10000,
username = "your-username", password = "your-password"),
httr::user_agent("research-bot/1.0")
)
# Grab the login form, fill it, submit it
form <- html_form(s)[[1]]
filled <- html_form_set(form,
email = "you@sparkproxy.io",
password = "your-password"
)
s <- session_submit(s, filled)
session_jump_to() and parse them with the same selectors:s <- session_jump_to(s, "https://www.sparkproxy.io/dashboard/orders")
orders <- s |>
html_element("table") |>
html_table()
submit = to session_submit() with the name of the control you want, otherwise rvest uses the first one and you may trigger the wrong action. For plain pagination that uses URL query parameters (?page=2), skip the form machinery entirely and loop over the URLs as shown in the data frame section. Sessions are for state you can't express in a URL. Throttle Politely with polite and ratelimitr
robots.txt, declares a user agent, enforces a delay between requests, and caches responses so a re-run doesn't re-fetch. bow() introduces you to the host, scrape() fetches within the rules:library(polite)
host <- bow(
"https://www.sparkproxy.io/",
user_agent = "research-bot (you@sparkproxy.io)",
delay = 5 # seconds between requests
)
result <- scrape(host) # obeys robots.txt and the delay
bow() returns a session object; use nod() to move to another path on the same host without re-reading robots.txt every time. If robots.txt disallows the path, scrape() warns and returns NULL instead of fetching, which is the behavior you want by default.library(ratelimitr)
library(rvest)
# Cap our fetcher at 20 requests per minute
limited_fetch <- limit_rate(
function(url) read_html(url),
rate(n = 20, period = 60)
)
pages <- lapply(urls, limited_fetch)
JavaScript Pages: the SparkProxy Scraping API from R
https://scrape.sparkproxy.io/api/v1, you authenticate with the X-API-Key header, and you pass the target URL and options as query parameters. resp_body_html() parses the rendered result for rvest:library(httr2)
library(rvest)
scrape_via_api <- function(target,
api_key = Sys.getenv("SPARKPROXY_API_KEY")) {
request("https://scrape.sparkproxy.io/api/v1") |>
req_headers(`X-API-Key` = api_key) |>
req_url_query(
url = target,
render_js = "true", # headless Chromium renders the page
premium_proxy = "true", # route through residential IPs
country_code = "us" # geo-target the request
) |>
req_perform() |>
resp_body_html()
}
page <- scrape_via_api("https://www.sparkproxy.io/")
prices <- page |> html_elements(".price") |> html_text2()
Parameter Type What it does `url` string The target URL to fetch (required) `render_js` boolean Runs headless Chromium so JavaScript-built pages return full HTML `premium_proxy` boolean Routes through residential IPs for hard targets `country_code` string ISO 3166-1 alpha-2 code for geo-targeting, for example `us` or `de` `json_response` boolean Wraps the result in a JSON envelope with status, timing, and credits json_response=true when you want metadata alongside the HTML. The envelope's body field holds the page HTML, which you parse with read_html():library(httr2)
library(rvest)
env <- request("https://scrape.sparkproxy.io/api/v1") |>
req_headers(`X-API-Key` = Sys.getenv("SPARKPROXY_API_KEY")) |>
req_url_query(
url = "https://www.sparkproxy.io/",
render_js = "true",
json_response = "true"
) |>
req_perform() |>
resp_body_json()
page <- read_html(env$body)
status <- env$status_code
credits <- env$credits_used
cat("status", status, "-", credits, "credits\n")
Common Errors and Fixes
Error or symptom Cause Fix Requests show your real IP, not the proxy `read_html(url)` has no proxy argument Fetch with `httr2::req_proxy()` (or `httr::use_proxy()`), then parse `resp_body_html()` `arguments imply differing number of rows` Built a tibble with `html_elements()` (plural) per field Select records first, pull each field with `html_element()` (singular) so misses become `NA` Text full of `\n` and stray spaces Used `html_text()` on messy HTML Use `html_text2()`, which renders whitespace like a browser Selectors return empty on a live page Content is rendered by JavaScript Use `render_js=true` via the Scraping API, or drive a headless browser `403 Forbidden` on the first request Default R user agent or TLS fingerprint flagged Set `req_user_agent()`, rotate proxies, or use the Scraping API `html_table()` columns are all character Currency symbols or separators block conversion Clean with `readr::parse_number()` after parsing `Couldn't connect to server` through a proxy Wrong scheme or bad host/port/credentials Use `http://` for the proxy even on HTTPS targets; recheck host, port, and auth `session_submit()` triggers the wrong action Form has multiple submit buttons Pass `submit =` with the button's `name` to pick the right one Frequently asked questions
FAQ
read_html() has no proxy argument, so you fetch through a proxy with a separate HTTP client and parse the result. Use httr2::request(url) |> req_proxy(url, port, username, password) |> req_perform() |> resp_body_html(), then run rvest selectors on that document. With the older httr stack, pass use_proxy() to GET() and hand the response to read_html().read_html() does not accept proxy, header, or timeout settings at all. It fetches with a plain internal request and only exposes the URL. To route through a proxy you must fetch with httr2 or httr, which do support proxies, and then parse the returned body with rvest. Splitting the fetch from the parse is the standard pattern for any non-trivial scrape in R.html_table(): read_html(url) |> html_element("table") |> html_table() returns a tibble with the header row as column names. It auto-converts numeric columns by default. If a page has several tables, target the one you want with a specific CSS selector first, and clean stubborn character columns with readr::parse_number().html_elements() (plural) returns every matching node flattened into one vector. html_element() (singular) returns exactly one node per input node and inserts NA where a match is missing. Use the singular form when building a data frame: it keeps every column the same length so rows stay aligned, even when some records are missing a field.https://scrape.sparkproxy.io/api/v1 with render_js=true and parse the response with rvest, so no browser runs on your machine.
Related articles

How to Scrape Stack Overflow Data (Questions, Answers)
How to Scrape Redfin Data: Listings, Prices, Market

How to Scrape IMDb Data: Ratings, Cast, Reviews
