Session 9: Scraping Interactive Web Pages (part 2)

Introduction to Web Scraping and Data Management for Social Scientists

Johannes B. Gruber

2026-07-30

Browser automation

What is Browser Automation?

  • Definition: the process of using software to control web browsers and interact with web elements programmatically
  • Tools Involved: Common tools include Selenium, Puppeteer, and Playwright
  • These tools allow scripts to perform actions like clicking, typing, and navigating through web pages automatically

Common Uses of Browser Automation

  • Testing: Widely used in software development for automated testing of web applications to ensure they perform as expected across different environments and browsers
  • Task Automation: Simplifies repetitive tasks such as form submissions, account setups, or any routine that can be standardized across web interfaces

Browser Automation in Web Scraping

  • Dynamic Content Handling: Essential for scraping websites that load content dynamically with JavaScript. Automation tools can interact with the webpage, wait for content to load, and then scrape the data.
  • Simulation of User Interaction: Can mimic human browsing patterns to interact with elements (like dropdowns, sliders, etc.) that need to be manipulated to access data
  • Avoiding Detection: More sophisticated than basic scraping scripts, browser automation can help mimic human-like interactions, reducing the risk of being detected and blocked by anti-scraping technologies

Example: Google Maps

Goal

  1. Check the communte time programatically
  2. Extract the distance and time it takes to make a journey

Can we use rvest?

static <- read_html(
  "https://www.google.de/maps/dir/Armadale+St,+Glasgow,+UK/Lilybank+House,+Glasgow,+UK/@55.8626667,-4.2712892,14z/data=!3m1!4b1!4m14!4m13!1m5!1m1!1s0x48884155c8eadf03:0x8f0f8905398fcf2!2m2!1d-4.2163615!2d55.8616765!1m5!1m1!1s0x488845cddf3cffdb:0x7648f9416130bcd5!2m2!1d-4.2904601!2d55.8740368!3e0?entry=ttu"
)
static |>
  html_elements(".Fk3sm") |>
  html_text2()
character(0)

google maps commute

Let’s use browser Automation

The new read_html_live from rvest solves this by emulating a browser:

# loads a real web browser
sess <- read_html_live(
  "https://www.google.de/maps/dir/Armadale+St,+Glasgow,+UK/Lilybank+House,+Glasgow,+UK/@55.8626667,-4.2712892,14z/data=!3m1!4b1!4m14!4m13!1m5!1m1!1s0x48884155c8eadf03:0x8f0f8905398fcf2!2m2!1d-4.2163615!2d55.8616765!1m5!1m1!1s0x488845cddf3cffdb:0x7648f9416130bcd5!2m2!1d-4.2904601!2d55.8740368!3e0?entry=ttu"
)

You can have a look at the browser with:

sess$view()

Unfortunately, we do not get content yet. We first have to click on “Accept all”

Let’s use browser Automation

After manipulating something about the session, you need to read it into R again:

sess <- read_html_live(
  "https://www.google.de/maps/dir/Armadale+St,+Glasgow,+UK/Lilybank+House,+Glasgow,+UK/@55.8626667,-4.2712892,14z/data=!3m1!4b1!4m14!4m13!1m5!1m1!1s0x48884155c8eadf03:0x8f0f8905398fcf2!2m2!1d-4.2163615!2d55.8616765!1m5!1m1!1s0x488845cddf3cffdb:0x7648f9416130bcd5!2m2!1d-4.2904601!2d55.8740368!3e0?entry=ttu"
)

Then we can extract information:

# the session behaves like a normal rvest html object
trip <- sess |>
  html_elements("div[data-trip-index]")

trip |>
  html_element("h1") |>
  html_text2()
[1] "via M8"                  "via Royston Rd and M8"  
[3] "via Alexandra Parade/A8"
trip |>
  html_element(".fontHeadlineSmall") |>
  html_text2()
[1] "17 min" "18 min" "21 min"
trip |>
  html_element(".fontBodyMedium") |>
  html_text2()
[1] "4.1 miles" "4.5 miles" "3.9 miles"

Store the cookies

  • Now that we have accepted the cookie banner, a small set of cookies was stored in the browser
  • These are destroyed when we close R, however
  • We can extract them and save the session for the next run, so no manual intervention is neccesary
cookies <- sess$session$Network$getCookies()
saveRDS(cookies, "data/chromote_cookies.rds")

In the next run, you can load the cookies with:

cookies <- readRDS("data/chromote_cookies.rds")
sess <- sess$session$Network$setCookies(cookies = cookies$cookies)

Example: Reddit

Goal

  1. Get Reddit posts
  2. Get the timestamp, up/downvot score, post and comment text

Can we use rvest to get post URLs?

html_subreddit <- read_html_live("https://www.reddit.com/r/wallstreetbets/")

posts <- html_subreddit |>
  html_elements("a") |>
  html_attr("href") |>
  str_subset("/comments/") |>
  str_replace_all("^/", "https://www.reddit.com/") |>
  unique()
posts
 [1] "https://www.reddit.com/r/wallstreetbets/comments/1v5jo48/weekly_earnings_thread_727_731/"                 
 [2] "https://www.reddit.com/r/wallstreetbets/comments/1vap9dz/daily_discussion_thread_for_july_30_2026/"       
 [3] "https://www.reddit.com/r/wallstreetbets/comments/1va8gr3/anyeong_haseyoh/"                                
 [4] "https://www.reddit.com/r/wallstreetbets/comments/1v8sd52/take_a_break_get_out_and_touch_grass/"           
 [5] "https://www.reddit.com/r/wallstreetbets/comments/1vajtay/in_june_2000_just_one_month_before_the_dotcom/"  
 [6] "https://www.reddit.com/r/wallstreetbets/comments/1vaba7w/wow/"                                            
 [7] "https://www.reddit.com/r/wallstreetbets/comments/1v4dcer/elon_has_ruined_me/"                             
 [8] "https://www.reddit.com/r/wallstreetbets/comments/1vaquxd/fuck_you_zuck/"                                  
 [9] "https://www.reddit.com/r/wallstreetbets/comments/1va7m08/all_gone_but_at_least_i_got_a_screenshot/"       
[10] "https://www.reddit.com/r/wallstreetbets/comments/1v9l6ib/kospi_circuit_breaker/"                          
[11] "https://www.reddit.com/r/wallstreetbets/comments/1vak683/turns_out_leopold_is_just_one_of_us/"            
[12] "https://www.reddit.com/r/wallstreetbets/comments/1va9iij/kospi_waiting_to_open_today/"                    
[13] "https://www.reddit.com/r/wallstreetbets/comments/1v6a4yw/debt_mgmt_to_wealth_mgmt_15k_to_15m_in_2_months/"
[14] "https://www.reddit.com/r/wallstreetbets/comments/1vapgau/you_all_called_me_regarded/"                     
[15] "https://www.reddit.com/r/wallstreetbets/comments/1va7c7o/god_help_us_all_from_korea_today/"               
[16] "https://www.reddit.com/r/wallstreetbets/comments/1vaa4a5/bill_kospi_coming_for_us_all_tonight/"           
[17] "https://www.reddit.com/r/wallstreetbets/comments/1va78k6/what_a_nasty_rug_pull/"                          
[18] "https://www.reddit.com/r/wallstreetbets/comments/1vaa6xu/spacex_gets_16_billion_us_space_force_order_for/"
[19] "https://www.reddit.com/r/wallstreetbets/comments/1va3vlr/fed_holds_rates_steady/"                         
[20] "https://www.reddit.com/r/wallstreetbets/comments/1vaja9t/my_lifes_screwed_korean_investors_stress_out/"   
[21] "https://www.reddit.com/r/wallstreetbets/comments/1va7ch0/is_it_insider_trading_if/"                       
[22] "https://www.reddit.com/r/wallstreetbets/comments/1v8scj0/market_only_goes_up/"                            
[23] "https://www.reddit.com/r/wallstreetbets/comments/1v8es1q/michael_burry_has_been_shorting_nvda_since/"     
[24] "https://www.reddit.com/r/wallstreetbets/comments/1va7a51/what_are_your_moves_tomorrow_july_30_2026/"      
[25] "https://www.reddit.com/r/wallstreetbets/comments/1vajqvs/what_the_hell_is_still_keeping_the_market_up/"   
[26] "https://www.reddit.com/r/wallstreetbets/comments/1v62du4/qualcomms_ai_robot_presentation/"                
[27] "https://www.reddit.com/r/wallstreetbets/comments/1v9oo7g/623_of_margin_debt_is_held_by_investors_aged_50/"
[28] "https://www.reddit.com/r/wallstreetbets/comments/1va7m6z/vix_gain_today/"                                 

This does not look too bad!

Can we use rvest to get posts?

html_post <- read_html_live(
  "https://www.reddit.com/r/wallstreetbets/comments/1ehcsoq/daily_discussion_thread_for_august_01_2024/"
)
post_data <- html_post |>
  html_elements("shreddit-post")

post_data |>
  html_attr("created-timestamp") |>
  lubridate::as_datetime()
[1] "2024-08-01 09:57:12 UTC"
post_data |>
  html_attr("id")
[1] "t3_1ehcsoq"
post_data |>
  html_attr("subreddit-id")
[1] "t5_2th52"
post_data |>
  html_attr("score")
[1] "205"
post_data |>
  html_attr("comment-count")
[1] "15645"

We actually can!

Can we use rvest to get comments?

comments <- html_post |>
  html_elements("shreddit-comment")
comments
{xml_nodeset (20)}
 [1] <shreddit-comment created="2024-08-02T03:18:13.777000+0000" author="Yeez ...
 [2] <shreddit-comment created="2024-08-02T02:22:35.524000+0000" author="Sigh ...
 [3] <shreddit-comment created="2024-08-02T02:41:13.260000+0000" author="[del ...
 [4] <shreddit-comment created="2024-08-02T02:39:38.729000+0000" author="[del ...
 [5] <shreddit-comment created="2024-08-02T00:02:45.266000+0000" author="Dead ...
 [6] <shreddit-comment created="2024-08-01T23:06:17.381000+0000" author="Dear ...
 [7] <shreddit-comment created="2024-08-01T23:03:47.571000+0000" author="etka ...
 [8] <shreddit-comment created="2024-08-01T22:43:30.763000+0000" author="Doct ...
 [9] <shreddit-comment created="2024-08-02T10:14:51.767000+0000" author="[del ...
[10] <shreddit-comment created="2024-08-02T00:02:01.404000+0000" author="Dead ...
[11] <shreddit-comment created="2024-08-01T22:39:14.519000+0000" author="[del ...
[12] <shreddit-comment created="2024-08-02T04:54:17.492000+0000" author="Visu ...
[13] <shreddit-comment created="2024-08-01T22:04:59.062000+0000" author="Lost ...
[14] <shreddit-comment created="2024-08-01T22:04:51.738000+0000" author="Gris ...
[15] <shreddit-comment created="2024-08-01T22:04:12.651000+0000" author="[del ...
[16] <shreddit-comment created="2024-08-01T23:06:04.582000+0000" author="Tay_ ...
[17] <shreddit-comment created="2024-08-01T23:04:59.618000+0000" author="etka ...
[18] <shreddit-comment created="2024-08-01T21:48:22.196000+0000" author="Squi ...
[19] <shreddit-comment created="2024-08-01T21:24:04.582000+0000" author="Cree ...
[20] <shreddit-comment created="2024-08-01T21:23:52.073000+0000" author="Dema ...

😑

Interacting with the session

html_post$view()

Scrolling down as far as possible:

html_post$scroll_to(top = 10^5)

Then clicking the “load_more_comments”-button

html_post$click("[noun=\"load_more_comments\"]")

This triggers new content to be loaded:

comments <- html_post |>
  html_elements("shreddit-comment")
comments
{xml_nodeset (29)}
 [1] <shreddit-comment created="2024-08-02T03:18:13.777000+0000" author="Yeez ...
 [2] <shreddit-comment created="2024-08-02T02:22:35.524000+0000" author="Sigh ...
 [3] <shreddit-comment created="2024-08-02T02:41:13.260000+0000" author="[del ...
 [4] <shreddit-comment created="2024-08-02T02:39:38.729000+0000" author="[del ...
 [5] <shreddit-comment created="2024-08-02T00:02:45.266000+0000" author="Dead ...
 [6] <shreddit-comment created="2024-08-01T23:06:17.381000+0000" author="Dear ...
 [7] <shreddit-comment created="2024-08-01T23:03:47.571000+0000" author="etka ...
 [8] <shreddit-comment created="2024-08-01T22:43:30.763000+0000" author="Doct ...
 [9] <shreddit-comment created="2024-08-02T10:14:51.767000+0000" author="[del ...
[10] <shreddit-comment created="2024-08-02T00:02:01.404000+0000" author="Dead ...
[11] <shreddit-comment created="2024-08-01T22:39:14.519000+0000" author="[del ...
[12] <shreddit-comment created="2024-08-02T04:54:17.492000+0000" author="Visu ...
[13] <shreddit-comment created="2024-08-01T22:04:59.062000+0000" author="Lost ...
[14] <shreddit-comment created="2024-08-01T22:04:51.738000+0000" author="Gris ...
[15] <shreddit-comment created="2024-08-01T22:04:12.651000+0000" author="[del ...
[16] <shreddit-comment created="2024-08-01T23:06:04.582000+0000" author="Tay_ ...
[17] <shreddit-comment created="2024-08-01T23:04:59.618000+0000" author="etka ...
[18] <shreddit-comment created="2024-08-01T21:48:22.196000+0000" author="Squi ...
[19] <shreddit-comment created="2024-08-01T21:24:04.582000+0000" author="Cree ...
[20] <shreddit-comment created="2024-08-01T21:23:52.073000+0000" author="Dema ...
...

Automate the scrolling

last_y <- -1
#scroll as far as possible
while (html_post$get_scroll_position()$y > last_y) {
  last_y <- html_post$get_scroll_position()$y
  html_post$scroll_to(top = 10^5)
  load_more <- html_post |>
    html_element("[noun=\"load_more_comments\"]") |>
    length()
  if (load_more) {
    html_post$click("[noun=\"load_more_comments\"]")
    html_post$scroll_to(top = 10^5)
  }
  # wati random number of seconds
  sec_random <- runif(1, 1, 3) * 5
  message("Scrolled down, waiting: ", round(sec_random, 1), " s")
  Sys.sleep(sec_random)
}

Example: news with Cloudflare protection

Goal

  1. Collect news media data from Manila Bulletin

Initial attempt with rvest

mb_link <- "https://mb.com.ph/2021/04/14/drug-suspect-killed-in-buy-bust-operation-in-marilao/"
read_html(mb_link)
Error in `open.connection()`:
! cannot open the connection

😑

Maybe attempt with rvest

sess <- read_html_live(mb_link)
sess$view()

😑

Introducing Scrapling

  • “An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl”
  • Uses different special versions of a web browsers that can be controlled through code
  • Essentially an opinionated collection of other packages for automation tasks:
    • Can adapt it’s own code to look for elements (if you scrape continuously)
    • Simplifies continuous crawling
    • Can impersonate other browsers
    • Can request and collect raw data stealthyly
  • kept up to date with the newest tricks in the “data war”

But wait, that’s a Python package!

Introducing reticulate

  • reticulate provides an easy interface to use Python functions and packages
  • converts almost all objects with ease from Python to R (and the other way around)
  • you get the best of both worlds:
    • bleeding edge software from the software engineering community
    • familiar and intuitive world class packages for statistics, data science, visualization etc.
  • No need to choose between Python and R anmore, you can just use both in the same session

Install and import

library(reticulate)
py_require(c(
  "scrapling",
  "curl_cffi",
  "playwright",
  "patchright",
  "msgspec",
  "browserforge"
))
scrapling <- import("scrapling")

Try the page

page <- scrapling$fetchers$StealthyFetcher$fetch(
  mb_link,
  headless = FALSE,
  network_idle = TRUE
)
# echo: false
page <- scrapling$fetchers$StealthyFetcher$fetch(
  mb_link,
  headless = TRUE,
  network_idle = TRUE
)

Looks good 😌 🪄!

Getting the content

html <- page$body |>
  as.character() |>
  read_html()

html |>
  html_text() |>
  stringr::str_detect("Drug suspect")
[1] TRUE
headline <- html |>
  html_elements(".article-title") |>
  html_text2()

author <- html |>
  html_elements(".author-section a") |>
  html_text2()

date <- html |>
  html_elements(".issue_date") |>
  html_text2() |>
  stringr::str_extract("\\w{3} \\d{1,2}, \\d{4}") |>
  lubridate::mdy()


text <- html |>
  html_elements(".article-full-body p") |>
  html_text2() |>
  paste0(collapse = "\n\n")

tibble::tibble(
  url = mb_link,
  date,
  author,
  headline,
  text
)
# A tibble: 1 × 5
  url                                           date       author headline text 
  <chr>                                         <date>     <chr>  <chr>    <chr>
1 https://mb.com.ph/2021/04/14/drug-suspect-ki… 2021-04-14 Fredd… Drug su… "A d…

Mission: success!

Summary: Browser Automation

What is it

  • remote control a browser to perform pre-defined steps
  • several available tools:
    • native R: chromote, used in read_html_live
    • native Python: Playwright, can be used from R with playwrightr (experimental)
    • native Java: Selenium, can be used from R with RSelenium (buggy and outdated)
    • native JavaScript: Puppeteer not R bindings
  • Opinionated tool collections:
    • scrapling: combines different tools for “stealthy” scraping

What are they good for?

  • Get content from pages which you can’t otherwise access
  • Load more content through automated scrolling on dynamic pages
  • Automate tasks like downloading files

Issues

  • Companies have mechanisms to counter scraping:
    • rate limiting requests per second/minute/day and user/IP(Twitter)
    • captchas (can be solved but quite complex)
  • Won’t get you around very obscure HTML code (Facebook)
  • Quite heavy and very slow compared to requests

Wrap Up

Save some information about the session for reproducibility.

sessionInfo()
R version 4.6.1 (2026-06-24)
Platform: x86_64-pc-linux-gnu
Running under: EndeavourOS

Matrix products: default
BLAS:   /usr/lib/libblas.so.3.12.0 
LAPACK: /usr/lib/liblapack.so.3.12.0  LAPACK version 3.12.0

locale:
 [1] LC_CTYPE=en_GB.UTF-8       LC_NUMERIC=C              
 [3] LC_TIME=en_GB.UTF-8        LC_COLLATE=en_GB.UTF-8    
 [5] LC_MONETARY=en_GB.UTF-8    LC_MESSAGES=en_GB.UTF-8   
 [7] LC_PAPER=en_GB.UTF-8       LC_NAME=C                 
 [9] LC_ADDRESS=C               LC_TELEPHONE=C            
[11] LC_MEASUREMENT=en_GB.UTF-8 LC_IDENTIFICATION=C       

time zone: Europe/Berlin
tzcode source: system (glibc)

attached base packages:
[1] stats     graphics  grDevices utils     datasets  methods   base     

other attached packages:
 [1] reticulate_1.46.0 rvest_1.0.4       httr2_1.2.3       lubridate_1.9.5  
 [5] forcats_1.0.1     stringr_1.6.0     dplyr_1.2.1       purrr_1.2.2      
 [9] readr_2.2.0       tidyr_1.3.2       tibble_3.3.1      ggplot2_4.0.3    
[13] tidyverse_2.0.0  

loaded via a namespace (and not attached):
 [1] utf8_1.2.6         rappdirs_0.3.4     generics_0.1.4     xml2_1.6.0        
 [5] lattice_0.22-9     stringi_1.8.7      hms_1.1.4          digest_0.6.39     
 [9] magrittr_2.0.5     evaluate_1.0.5     grid_4.6.1         timechange_0.4.0  
[13] RColorBrewer_1.1-3 fastmap_1.2.0      rprojroot_2.1.1    Matrix_1.7-5      
[17] jsonlite_2.0.0     processx_3.9.0     chromote_0.5.1     ps_1.9.3          
[21] promises_1.5.0     httr_1.4.8         selectr_0.4-2      scales_1.4.0      
[25] cli_3.6.6          rlang_1.2.0        withr_3.0.3        yaml_2.3.12       
[29] otel_0.2.0         tools_4.6.1        tzdb_0.5.0         here_1.0.2        
[33] curl_7.1.0         png_0.1-9          vctrs_0.7.3        R6_2.6.1          
[37] lifecycle_1.0.5    pkgconfig_2.0.3    pillar_1.11.1      later_1.4.8       
[41] gtable_0.3.6       glue_1.8.1         Rcpp_1.1.2         xfun_0.59         
[45] tidyselect_1.2.1   knitr_1.51         farver_2.1.2       websocket_1.4.4   
[49] htmltools_0.5.9    rmarkdown_2.31     compiler_4.6.1     S7_0.2.2