Web Scraping

November 9, 2026

Jo Hardin

Agenda 11/09/26

  1. HTML / web scraping
  2. css selectors
  3. rvest

Important tool

Our approach to web scraping relies on the Chrome browser and an extension called SelectorGadget. Download them here:

Data Acquisition

Based on https://www.effectivedatastorytelling.com/post/a-deeper-dive-into-lego-bricks-and-data-stories, original source: https://www.linkedin.com/learning/instructors/bill-shander

Reading The Student Life

How often do you read The Student Life?
a. Every day
b. 3-5 times a week
c. Once a week
d. Rarely

Reading The Student Life

What do you think is the most common word in the titles of The Student Life opinion pieces?

Analyzing The Student Life

Reading The Student Life

How do you think the sentiments in opinion pieces in The Student Life compare across authors?
Roughly the same?
Wildly different?
Somewhere in between?

Analyzing The Student Life

All of this analysis is done in R!

(mostly) with tools you already know!

Common words in The Student Life titles

Code for the earlier plot:

data(stop_words) # from tidytext
tsl_opinion_titles |>
  tidytext::unnest_tokens(word, title) |>
  anti_join(stop_words) |>
  count(word, sort = TRUE) |>
  slice_head(n = 20) |>
  mutate(word = fct_reorder(word, n)) |>
  ggplot(aes(y = word, x = n, fill = log(n))) +
  geom_col(show.legend = FALSE) +
  theme_minimal(base_size = 16) +
  labs(
    x = "Number of mentions",
    y = "Word",
    title = "The Student Life - Opinion pieces",
    subtitle = "Common words in the 500 most recent opinion pieces",
    caption = "Source: Data scraped from The Student Life on November 8, 2026"
  ) +
  theme(
    plot.title.position = "plot",
    plot.caption = element_text(color = "gray30")
  )

Avg sentiment scores of first paragraph

Code for the earlier plot:

afinn_sentiments <- get_sentiments("afinn") # need tidytext and textdata
tsl_opinion_titles |>
  tidytext::unnest_tokens(word, first_p) |>
  anti_join(stop_words) |>
  left_join(afinn_sentiments) |>
  group_by(authors, title) |>
  summarize(total_sentiment = sum(value, na.rm = TRUE), .groups = "drop") |>
  group_by(authors) |>
  summarize(
    n_articles = n(),
    avg_sentiment = mean(total_sentiment, na.rm = TRUE),
  ) |>
  filter(n_articles > 1 & !is.na(authors)) |>
  arrange(desc(avg_sentiment)) |>
  slice(c(1:10, 69:78)) |>
  mutate(
    authors = fct_reorder(authors, avg_sentiment),
    neg_pos = if_else(avg_sentiment < 0, "neg", "pos"),
    label_position = if_else(neg_pos == "neg", 0.25, -0.25)
  ) |>
  ggplot(aes(y = authors, x = avg_sentiment)) +
  geom_col(aes(fill = neg_pos), show.legend = FALSE) +
  geom_text(
    aes(x = label_position, label = authors, color = neg_pos),
    hjust = c(rep(1, 10), rep(0, 10)),
    show.legend = FALSE,
    fontface = "bold"
  ) +
  geom_text(
    aes(label = round(avg_sentiment, 1)),
    hjust = c(rep(1.25, 10), rep(-0.25, 10)),
    color = "white",
    fontface = "bold"
  ) +
  scale_fill_manual(values = c("neg" = "#4d4009", "pos" = "#FF4B91")) +
  scale_color_manual(values = c("neg" = "#4d4009", "pos" = "#FF4B91")) +
  scale_x_continuous(breaks = -5:5, minor_breaks = NULL) +
  scale_y_discrete(breaks = NULL) +
  coord_cartesian(xlim = c(-5, 5)) +
  labs(
    x = "negative  ←     Average sentiment score (AFINN)     →  positive",
    y = NULL,
    title = "The Student Life - Opinion pieces\nAverage sentiment scores of first paragraph by author",
    subtitle = "Top 10 average positive and negative scores",
    caption = "Source: Data scraped from The Student Life on November 8, 2026"
  ) +
  theme_void(base_size = 16) +
  theme(
    plot.title = element_text(hjust = 0.5),
    plot.subtitle = element_text(
      hjust = 0.5,
      margin = unit(c(0.5, 0, 1, 0), "lines")
    ),
    axis.text.y = element_blank(),
    plot.caption = element_text(color = "gray30")
  )

Where is the data coming from?

Where is the data coming from?

tsl_opinion_titles
# A tibble: 500 × 4
  title                            authors date                first_p
  <chr>                            <chr>   <dttm>              <chr>  
1 Don’t let the midterms fool you… Olivia… 2026-11-06 11:07:00 olivia…
2 The blue tsunami is forcing Dem… Jun Kw… 2026-11-06 11:05:00 jun kw…
3 Money talks, and it sounds like… Elena … 2026-11-06 09:54:00 in som…
4 Write right — buy a legal pad    Luca D… 2026-11-06 08:58:00 luca d…
5 It’s time to banish laptops fro… Phil Z… 2026-11-06 07:38:00 phil z…
6 The bureaucratic veil over Pomo… Zara S… 2026-10-29 09:54:00 zara s…
# ℹ 494 more rows

HTML / Web scraping

Scraping the web: what? why?

  • Increasing amount of data is available on the web

  • Data are provided in an unstructured format: you can always copy & paste, but it’s time-consuming and prone to errors

  • Web scraping is the process of extracting information automatically and transforming it into a structured dataset

  • Two different scenarios:

    • Screen scraping: extract data from source code of website, with html parser (easy) or regular expression matching (less easy).

    • Web APIs (application programming interface): website offers a set of structured http requests that return JSON or XML files.

Hypertext Markup Language

Much of the data on the web is available as HTML - while it is structured (hierarchical), often it is not immediately available in a form useful for analysis (flat / tidy).

<html>
  <head>
    <title>Here is a title</title>
  </head>
  <body>
    <p align="center">Hello world!</p>
    <br>
    <div class="name" id="first">John</div>
    <div class="name" id="last">Doe</div>
    <div class="contact">
      <div class="home">555-555-1234</div>
      <div class="home">555-555-2345</div>
      <div class="work">555-555-9999</div>
      <div class="fax">555-555-8888</div>
    </div>
  </body>
</html>

Some HTML elements

  • <html>: start of the HTML page
  • <head>: header information (metadata about the page)
  • <body>: everything that is on the page
  • <p>: paragraph
  • <b>: bold
  • <table>: table
  • <div>: a container to group content together (“division”) (used to group other HTML elements together)
  • <a>: the “anchor” element that creates a hyperlink

HTML attribute

An attribute in HTML is a name–value pair that gives extra information about an element. It sits inside the opening tag and modifies the element’s behavior, appearance, identity, or data.

Think of the attribute as the argument to the element (which would be the function in the analogy).

rvest

  • The rvest package makes basic processing and manipulation of HTML data straight forward
  • It is designed to work with pipelines built with |>
  • rvest.tidyverse.org
library(rvest)

rvest hex logo

rvest

Core functions:

  • read_html() - read HTML data from a url or character string.

  • html_elements() - select specified elements from the HTML document using CSS selectors.

  • html_element() - select a single element from the HTML document using CSS selectors.

  • html_table() - parse an HTML table into a data frame.

  • html_text() / html_text2() - extract element’s text content.

  • html_name() - extract a element’s name(s).

  • html_attrs() - extract all attributes.

  • html_attr() - extract attribute value(s) by name.

html & rvest

html <-
  '<html>
  <head>
    <title>This is a title</title>
  </head>
  <body>
    <p align="center">Hello world!</p>
    <br>
    <div class="name" id="first">John</div>
    <div class="name" id="last">Doe</div>
    <div class="contact">
      <div class="home">555-555-1234</div>
      <div class="home">555-555-2345</div>
      <div class="work">555-555-9999</div>
      <div class="fax">555-555-8888</div>
    </div>
  </body>
</html>'
read_html(html)
{html_document}
<html>
[1] <head>\n<meta http-equiv="Content-Type" content="text/html; charset=UTF-8 ...
[2] <body>\n    <p align="center">Hello world!</p>\n    <br><div class="name" ...

Selecting elements

read_html(html) |> html_elements("p")
{xml_nodeset (1)}
[1] <p align="center">Hello world!</p>
read_html(html) |> html_elements("p") |> html_text()
[1] "Hello world!"
read_html(html) |> html_elements("p") |> html_name()
[1] "p"
read_html(html) |> html_elements("p") |> html_attrs()
[[1]]
   align 
"center" 
read_html(html) |> html_elements("p") |> html_attr("align")
[1] "center"

More selecting elements

read_html(html) |> html_elements("div")
{xml_nodeset (7)}
[1] <div class="name" id="first">John</div>
[2] <div class="name" id="last">Doe</div>
[3] <div class="contact">\n      <div class="home">555-555-1234</div>\n       ...
[4] <div class="home">555-555-1234</div>
[5] <div class="home">555-555-2345</div>
[6] <div class="work">555-555-9999</div>
[7] <div class="fax">555-555-8888</div>
read_html(html) |> html_elements("div") |> html_text()
[1] "John"                                                                                  
[2] "Doe"                                                                                   
[3] "\n      555-555-1234\n      555-555-2345\n      555-555-9999\n      555-555-8888\n    "
[4] "555-555-1234"                                                                          
[5] "555-555-2345"                                                                          
[6] "555-555-9999"                                                                          
[7] "555-555-8888"                                                                          

CSS selectors

  • We will use a tool called SelectorGadget to help us identify the HTML elements of interest by constructing a CSS selector which can be used to subset the HTML document.
  • Some examples of basic selector syntax is below,
Selector Example Description
.class .title Select all elements with class=“title”
#id #name Select all elements with id=“name”
element p Select all <p> elements
element element div p Select all <p> elements inside a <div> element
element>element div > p Select all <p> elements with <div> as a direct parent
[attribute] [class] Select all elements with a class attribute
[attribute=value] [class=title] Select all elements with class=“title”

CSS classes and ids

  • class and id are used to style elements (e.g., change their color!). They are special types of attributes.

  • class can be applied to multiple different elements (class is identified with ., for example .name)

  • id is unique to each element (id is identified with #, for example, #first)

read_html(html) |> html_elements(".name")
{xml_nodeset (2)}
[1] <div class="name" id="first">John</div>
[2] <div class="name" id="last">Doe</div>
read_html(html) |> html_elements("div.name")
{xml_nodeset (2)}
[1] <div class="name" id="first">John</div>
[2] <div class="name" id="last">Doe</div>
read_html(html) |> html_elements("#first")
{xml_nodeset (1)}
[1] <div class="name" id="first">John</div>

CSS classes and ids (cont.)

One id but many classes

<div id="results">
    <p class="price">$10</p>
    <p class="price">$25</p>
    <p class="price sale">$5</p>
</div>
page |> html_element("#results") # returns one element
page |> html_elements(".price") # returns all matching elements

Text with html_text() vs. html_text2()

  • The two functions handle whitespace differently:
html <- read_html("<p>  Hello,\n   world! </p>")

html |> html_element("p") |> html_text()
[1] "  Hello,\n   world! "
html |> html_element("p") |> html_text2()
[1] "Hello, world!"

Text with html_text() vs. html_text2()

html <- read_html(
  "<p>  
    First sentence in the paragraph.
    Second sentence that follows a literal line break. <br>Third sentence will start on a new line in the rendered html, but doesn't have a line break as part of the code.
  </p>"
)
html |> html_text()
[1] "  \n    First sentence in the paragraph.\n    Second sentence that follows a literal line break. Third sentence will start on a new line in the rendered html, but doesn't have a line break as part of the code.\n  "
html |> html_text2()
[1] "First sentence in the paragraph. Second sentence that follows a literal line break.\nThird sentence will start on a new line in the rendered html, but doesn't have a line break as part of the code."

html_text2() collapses any white space (including \n) into a single space, and turns <br> into \n.

HTML tables with html_table()

html_table <-
  '<html>
  <head>
    <title>This is a title</title>
  </head>
  <body>
    <table>
      <tr> <th>a</th> <th>b</th> <th>c</th> </tr>
      <tr> <td>1</td> <td>2</td> <td>3</td> </tr>
      <tr> <td>2</td> <td>3</td> <td>4</td> </tr>
      <tr> <td>3</td> <td>4</td> <td>5</td> </tr>
    </table>
  </body>
</html>'
read_html(html_table) |>
  html_elements("table") |>
  html_table()
[[1]]
# A tibble: 3 × 3
      a     b     c
  <int> <int> <int>
1     1     2     3
2     2     3     4
3     3     4     5

html_attr()

extracts data from attributes:

(n.b., the <a> tag refers to “anchor” and is used to create hyperlinks)

html <- minimal_html(
  "
  <p><a href='https://en.wikipedia.org/wiki/Cat'>cats</a></p>
  <p><a href='https://en.wikipedia.org/wiki/Dog'>dogs</a></p>
"
)
html
{html_document}
<html>
[1] <head>\n<meta http-equiv="Content-Type" content="text/html; charset=UTF-8 ...
[2] <body>\n<p><a href="https://en.wikipedia.org/wiki/Cat">cats</a></p>\n  <p ...
html |>
  html_attr("href")
[1] NA
html |>
  html_elements("[href]") |>
  html_attr("href")
[1] "https://en.wikipedia.org/wiki/Cat" "https://en.wikipedia.org/wiki/Dog"
html |>
  html_elements("p")
{xml_nodeset (2)}
[1] <p><a href="https://en.wikipedia.org/wiki/Cat">cats</a></p>
[2] <p><a href="https://en.wikipedia.org/wiki/Dog">dogs</a></p>
html |>
  html_elements("p") |>
  html_element("a")
{xml_nodeset (2)}
[1] <a href="https://en.wikipedia.org/wiki/Cat">cats</a>
[2] <a href="https://en.wikipedia.org/wiki/Dog">dogs</a>
html |>
  html_elements("p") |>
  html_element("a") |>
  html_attr("href")
[1] "https://en.wikipedia.org/wiki/Cat" "https://en.wikipedia.org/wiki/Dog"
html |>
  html_elements("p a") |>
  html_attr("href")
[1] "https://en.wikipedia.org/wiki/Cat" "https://en.wikipedia.org/wiki/Dog"
html |>
  html_element("a") |>
  html_attr("href")
[1] "https://en.wikipedia.org/wiki/Cat"
html |>
  html_elements("a") |>
  html_attr("href")
[1] "https://en.wikipedia.org/wiki/Cat" "https://en.wikipedia.org/wiki/Dog"
  • html_attr() always returns a string, so if you’re extracting numbers or dates, you’ll need to do some post-processing.

div p vs div > p

  • div p selects all <p> elements within <div>, regardless of depth.
  • div > p selects only direct child <p> elements of <div>.
<div>
  <p>This will be selected by both `div p` and `div > p`.</p> 
  <section>
   <p>This will be selected only by `div p`, not by `div > p`. Because it is inside the section tag.</p>
  </section>
</div>

SelectorGadget

SelectorGadget (selectorgadget.com) is a javascript based tool that helps you interactively build an appropriate CSS selector for the content you are interested in.

Recap

  • Use the SelectorGadget identify elements you want to grab
  • Use the rvest R package to first read in the entire page (into R) and then parse the object you’ve read in to the elements you’re interested in
  • Put the components together in a data frame (a tibble) and analyze it like you analyze any other data

Plan

  1. Read in the entire page
  2. Scrape opinion title and save as title
  3. Scrape author and save as author
  4. Scrape date and save as date
  5. Create a new data frame called tsl_opinion with variables title, author, and date

Read in the entire page

tsl_page <- read_html("https://tsl.news/category/opinions/")
tsl_page
{html_document}
<html lang="en-US">
[1] <head>\n<meta http-equiv="Content-Type" content="text/html; charset=UTF-8 ...
[2] <body class="archive category category-opinions category-2244 custom-back ...
typeof(tsl_page)
[1] "list"
class(tsl_page)
[1] "xml_document" "xml_node"    
  • we need to convert into something more familiar, like a data frame

Scrape title and save as title

tsl_page |>
  html_elements(".entry-title a")
{xml_nodeset (10)}
 [1] <a href="https://tsl.news/opinion-dont-let-the-midterms-fool-you-elites- ...
 [2] <a href="https://tsl.news/opinion-blue-tsunami-is-forcing-democrats-to-d ...
 [3] <a href="https://tsl.news/opinion-money-talks-and-it-sounds-like-bigotry ...
 [4] <a href="https://tsl.news/opinion-write-right-buy-a-legal-pad/" title="O ...
 [5] <a href="https://tsl.news/opinion-its-time-to-banish-laptops-from-our-cl ...
 [6] <a href="https://tsl.news/opinion-the-bureaucratic-veil-over-pomonas-cen ...
 [7] <a href="https://tsl.news/opinion-stop-being-morally-outraged-at-the-dem ...
 [8] <a href="https://tsl.news/opinion-lets-share-our-ghost-stories/" title=" ...
 [9] <a href="https://tsl.news/opinion-too-elite-for-the-label-why-nara-smith ...
[10] <a href="https://tsl.news/opinion-we-created-elizabeth-holmes-why-cant-w ...
title <- tsl_page |>
  html_elements(".entry-title a") |>
  html_text()

title
 [1] "OPINION: Don’t let the midterms fool you — Oligarchs and their money-backed media campaigns are still driving our backsliding democracy"
 [2] "OPINION: The blue tsunami is forcing Democrats to do their least favorite thing — actually govern"                                      
 [3] "OPINION: Money talks, and it sounds like bigotry in our classrooms"                                                                     
 [4] "OPINION: Write right — buy a legal pad"                                                                                                 
 [5] "OPINION: It’s time to banish laptops from our classrooms"                                                                               
 [6] "OPINION: The bureaucratic veil over Pomona’s Center for Global Engagement"                                                              
 [7] "OPINION: Stop being morally outraged at the Democratic Party"                                                                           
 [8] "OPINION: Let’s share our ghost stories"                                                                                                 
 [9] "OPINION: Too elite for the label: Why Nara Smith rejects the ‘tradwife’ brand"                                                          
[10] "OPINION: We created Elizabeth Holmes. Why can’t we let her go?"                                                                         
title <- title |>
  str_remove("OPINION: ")

title
 [1] "Don’t let the midterms fool you — Oligarchs and their money-backed media campaigns are still driving our backsliding democracy"
 [2] "The blue tsunami is forcing Democrats to do their least favorite thing — actually govern"                                      
 [3] "Money talks, and it sounds like bigotry in our classrooms"                                                                     
 [4] "Write right — buy a legal pad"                                                                                                 
 [5] "It’s time to banish laptops from our classrooms"                                                                               
 [6] "The bureaucratic veil over Pomona’s Center for Global Engagement"                                                              
 [7] "Stop being morally outraged at the Democratic Party"                                                                           
 [8] "Let’s share our ghost stories"                                                                                                 
 [9] "Too elite for the label: Why Nara Smith rejects the ‘tradwife’ brand"                                                          
[10] "We created Elizabeth Holmes. Why can’t we let her go?"                                                                         

Scrape author and save as author

author <- tsl_page |>
  html_elements("span.author") |>
  html_text()
author
 [1] "By Olivia Brinkman Schooler" "By Jun Kwon"                
 [3] "By Elena Gutierrez"          "By Luca Davis"              
 [5] "By Phil Zuckerman"           "By Zara Seldon"             
 [7] "By Charlotte Renner"         "By Leilani Fernandez"       
 [9] "By Makayla Tracy"            "By Tahi Wilton Geary"       
author <- author |>
  str_replace("By ", "")

author
 [1] "Olivia Brinkman Schooler" "Jun Kwon"                
 [3] "Elena Gutierrez"          "Luca Davis"              
 [5] "Phil Zuckerman"           "Zara Seldon"             
 [7] "Charlotte Renner"         "Leilani Fernandez"       
 [9] "Makayla Tracy"            "Tahi Wilton Geary"       

Scrape date and save as date

date <- tsl_page |>
  html_elements(".published") |>
  html_text()
date
 [1] "November 6, 2026 3:07 am"  "November 6, 2026 3:05 am" 
 [3] "November 6, 2026 1:54 am"  "November 6, 2026 12:58 am"
 [5] "November 5, 2026 11:38 pm" "October 29, 2026 2:54 am" 
 [7] "October 29, 2026 2:32 am"  "October 29, 2026 2:02 am" 
 [9] "October 29, 2026 1:31 am"  "October 29, 2026 1:04 am" 
date <- date |>
  lubridate::mdy_hm(tz = "America/Los_Angeles")
date
 [1] "2026-11-06 03:07:00 PST" "2026-11-06 03:05:00 PST"
 [3] "2026-11-06 01:54:00 PST" "2026-11-06 00:58:00 PST"
 [5] "2026-11-05 23:38:00 PST" "2026-10-29 02:54:00 PDT"
 [7] "2026-10-29 02:32:00 PDT" "2026-10-29 02:02:00 PDT"
 [9] "2026-10-29 01:31:00 PDT" "2026-10-29 01:04:00 PDT"

Create a new data frame

tsl_opinion <- tibble(
  title,
  author,
  date
)

tsl_opinion
# A tibble: 10 × 3
  title                                     author date               
  <chr>                                     <chr>  <dttm>             
1 Don’t let the midterms fool you — Oligar… Olivi… 2026-11-06 03:07:00
2 The blue tsunami is forcing Democrats to… Jun K… 2026-11-06 03:05:00
3 Money talks, and it sounds like bigotry … Elena… 2026-11-06 01:54:00
4 Write right — buy a legal pad             Luca … 2026-11-06 00:58:00
5 It’s time to banish laptops from our cla… Phil … 2026-11-05 23:38:00
6 The bureaucratic veil over Pomona’s Cent… Zara … 2026-10-29 02:54:00
# ℹ 4 more rows

Opinion titles

tsl_opinions <- function(i) {
  tsl_page <- rvest::read_html(paste0(
    "https://tsl.news/category/opinions/page/",
    i
  ))

  title <- tsl_page |>
    html_elements(".entry-title a") |>
    html_text() |>
    str_remove("OPINION: ")

  author <- tsl_page |>
    html_elements("span.author") |>
    html_text() |>
    tibble() |>
    set_names(nm = "authors") |>
    mutate(authors = str_replace(authors, "By ", ""))

  date <- tsl_page |>
    html_elements(".published") |>
    html_text() |>
    lubridate::mdy_hm(tz = "America/Los_Angeles")

  first_p <- tsl_page |>
    html_elements(".entry-content p:nth-child(1)") |>
    html_text() |>
    tolower()

  tibble(
    title,
    author,
    date,
    first_p
  )
}

tsl_opinion_titles <- c(1:50) |> purrr::map(tsl_opinions) |> list_rbind()

Web scraping considerations

Check if you are allowed!

library(robotstxt)
paths_allowed("https://tsl.news/category/opinions/")
[1] TRUE
paths_allowed("http://www.facebook.com")
[1] FALSE

Ethics: “Can you?” vs “Should you?”

“Can you?” vs “Should you?”

Challenges: Unreliable formatting

Challenges: Data broken into many pages