All in One View

Content from Introduction


Last updated on 2026-08-25 | Edit this page

Estimated time: 12 minutes

Overview

Questions

  • How do you write a lesson using R Markdown and sandpaper?

Objectives

  • Explain how to use markdown with the new lesson template
  • Demonstrate how to include pieces of code, figures, and nested challenge blocks

Introduction


This is a course in how to use the programming language R to do web scraping. Web scraping is a set of methods to systematically download parts of web pages by writing code in a script instead of copy-pasting from the web page.

What makes up a web page?


A web pages is made up of elements in the HTML language.

Why learn to scrape instead of copy-pasting?


The advantage with using web scraping is that we can in our script specify which specific parts of the web page that we want to download by referring to those elements’ HTML code. This allows for precision in what we download.

Furthermore, the scraped content is often in a format that can more easily be handled. By scraping we do not run the risk of having the content in a weird format when it is copy-pasted into a text file or an Excel sheet that makes it unsuited for analysis

Another advantage of web scraping, which we will do in this course, is that we can scrape the same element on multiple pages. i.e. instead of opening each page separately and for each page marking the parts of the page that we want to download and copy-pasting it, we can write a script that will scrape the same HTML element on multiple pages. This allows for speed and consistency

Am I allowed to scrape?


Not all web pages allow for scraping. Just because you can scrape doesn’t mean that you’re allowed to. It is necessary to read the terms and conditions for scraping.

Sometimes scraping rules differ depending on who does the scraping. E.g. many web pages allow search engines to scrape them in order to index them in the search engines index, but do not allow others to scrape their pages. Sometimes

Some organizations allow scraping of some of their web pages, but not all of them. E.g. they may allow scraping of their front page, but not of sub-pages. Or they allow scraping of a few of their sub-pages but not all their other sub-pages on their front page.

Fortunately for us, we don’t have to read through this ourselves. The package polite has a function called bow(). This function ensures that we only scrape web pages that allow scraping. When we run it on a URL, if it gives the message The path is scrapable for this user-agent it means that we are allowed to scrape. If it gives the message The path is not scrapable for this user-agent it means that scraping is not allowed. We can integrate polite and bow() into our workflow so that if we run the function before we scrape the web page, it will prevent us from scraping and thus ensure that we don’t do something that we’re not allowed to.

Content from Scraping tables on a page


Last updated on 2026-08-25 | Edit this page

Scraping tables on a single page


Introduction


Lets us start by installing the libraries that will give us the necessary functionality to do web scraping

R

install.packages("polite")
install.packages("rvest")
install.packages("tidyverse")
install.packages("purrr")
install.packages("htmlTable")
install.packages("htmltools")
install.packages("scales")

Lets us now activate our installed libraries to activate their functionality

R

library(polite)
library(rvest)
library(tidyverse)
library(purrr)
library(htmlTable)
library(htmltools)
library(scales)

Scraping multiple tables on one page

One of the formats that data on a website often come in is a table

Let’s look at statics about students at The University of Copenhagen (UCPH) at this page: https://om.ku.dk/tal-og-fakta/studerende/ The html element for a table is simply called <table> We can use this HTML tag to scrape a table using html_table

We see on the website that there are multiple elements that can be clicked to reveal some of the stats for UCPH students.

We want to see what HTML tags that the stats from the website have. We can use our internet browser’s ability to inspect the HTML of any website that we visit. This is the HTML that generates the website that we see when we type a URL and go to the website. Most internet browsers are able to inspect a website’s HTML. If the browser that you use cannot do this, then try to do it with a different browser

When we inspect the HTML, we see that there are many tables in HTML, each corresponding to one drop-down part on the webpage

Fortunately for us, the function html_table will scrape all the tables on the page

The first thing we need to do is to scrape the webpage. One of the challenges of webscraping is that sometimes, certain webpages won’t allow scraping. This prohibition can be for the entire webpage with all its subpages, or it can be for certain subpages. It can be quite laborious read and check this yourself, but fortunately for us, the polite package allows os to automatize this. By using its function bow before we scrape, it will check if scraping is allowed. If scraping is allowed, the scraping will go ahead. If scraping is not allowed, no scraping will take place. So let us start by writing the name of our object. Then we use bow and write the link of the page that we want to scrape, in order to check if scraping is allowed. Then we scrape the webpage

R

dat <-
  bow("https://om.ku.dk/tal-og-fakta/studerende/") %>%
  scrape()

Handling the Danish decimal separators format

But before we go further with our data, we need to make sure that R will handle decimal separators correctly. The default standard in R for handling decimal separators is to use the American version, where dot is the decimal separator, and comma groups larger numbers together, making it easier for the human eye to read numbers that are from one thousand and above. In the tables that we want to scrape here, the Danish format is used, where comma is the decimal separator, and dot groups larger numbers together. We there need to start by telling R that it should use the Danish format

R

dansk_locale <- locale(decimal_mark = ",", 
  grouping_mark = ".", 
  date_names = "da", 
  date_format = "%d-%m-%Y", 
  time_format = "%H:%M:%S", 
  tz = "Europe/Copenhagen")

The scrape function scrapes everything on the webpage. So now we need to tell R which part of the HTML that we want to work with. To specify that we want to work with all the tables from the webpage, we can use the function html_table. This function scrapes all the tables on the page. So let us first write the name of our new object. Then we tell R to work the the scraped data, which we called dat. Lastly, we tell R that it should take the tables form the scraped webpage.

R

tabeller <- 
  dat %>%  
  html_table(fill = TRUE, convert = FALSE)

Now we have an object with the tables, which is a list. Each table is a separate element in the list. However, we want all the tables to be merged into one dataframe. To do this we use bind_rows

R

tabeller <- tabeller %>% bind_rows()

Now we have a dataframe with 2 columns. The first column, called X1 contains the the statistic that was calculated. The second column, called X2, contains the number of the calculated statistic.

We see that some statistics are percentages, and other are absolute numbers. We need to handle this in order to do analysis. To do this we need to do a couple of steps. First, we create a new column that tells if the measured statistic is an absolute number of a percentage. we call the new column X3. Then we need to remove the percentage sign from column X2. Lastly, to make sure that the X2 column can be used for calculations, we trim any unintended whitespace with str_trim.

R

tabeller <- tabeller %>% 
  mutate(X3 = str_detect(X2, "%")) %>% 
  mutate(X2 = str_remove(X2, "%"), 
         X2 = str_trim(X2))

Now that we have scraped and cleaned our data, we need to tell R that it should use the Danish format for numbers, date format, and use the time zone for Copenhagen

R

tabeller <- type_convert(tabeller, locale = dansk_locale)

OUTPUT


── Column specification ────────────────────────────────────────────────────────
cols(
  X1 = col_character(),
  X2 = col_number()
)

Content from Scraping tables on multiple pages with predictable URLs


Last updated on 2026-08-25 | Edit this page

Lets us start by installing the libraries that will give us the necessary functionality to do web scraping

R

install.packages("polite")
install.packages("rvest")
install.packages("tidyverse")
install.packages("purrr")
install.packages("htmlTable")
install.packages("htmltools")
install.packages("scales")

Lets us now activate our installed libraries to activate their functionality

R

library(polite)
library(rvest)
library(tidyverse)
library(purrr)
library(htmlTable)
library(htmltools)
library(scales)

Scraping tables on multiple pages


Scraping predictable URLs

But what if the table is spraed across multiple pages? There’s a way to handle that, but it does required a bit more work than the previous example where all tables were on one page. To learning how to scrape webpages where the table is spread across multiple pages, we’ll use this example: http://www.scrapethissite.com/pages/forms/?page_num=1. This link contains 24 pages with team statistics for all teams in the North American professional National Hockey League from year 1990 to year 2011.

To begin we must inspect the URL. Fortunately for us, each page has a base URL, and then it ends with a number that is the the page number. So e.g. the URL for the first page is http://www.scrapethissite.com/pages/forms/?page_num=1. The URL for the second page is http://www.scrapethissite.com/pages/forms/?page_num=2, and so on until page 24. We can isolate the base URL, which is http://www.scrapethissite.com/pages/forms/?page_num=, and then create 24 URLs, each with a number going from 1 to 24

R

paste0("http://scrapethissite.com/pages/forms/?page_num=", 1:24)

OUTPUT

 [1] "http://scrapethissite.com/pages/forms/?page_num=1"
 [2] "http://scrapethissite.com/pages/forms/?page_num=2"
 [3] "http://scrapethissite.com/pages/forms/?page_num=3"
 [4] "http://scrapethissite.com/pages/forms/?page_num=4"
 [5] "http://scrapethissite.com/pages/forms/?page_num=5"
 [6] "http://scrapethissite.com/pages/forms/?page_num=6"
 [7] "http://scrapethissite.com/pages/forms/?page_num=7"
 [8] "http://scrapethissite.com/pages/forms/?page_num=8"
 [9] "http://scrapethissite.com/pages/forms/?page_num=9"
[10] "http://scrapethissite.com/pages/forms/?page_num=10"
[11] "http://scrapethissite.com/pages/forms/?page_num=11"
[12] "http://scrapethissite.com/pages/forms/?page_num=12"
[13] "http://scrapethissite.com/pages/forms/?page_num=13"
[14] "http://scrapethissite.com/pages/forms/?page_num=14"
[15] "http://scrapethissite.com/pages/forms/?page_num=15"
[16] "http://scrapethissite.com/pages/forms/?page_num=16"
[17] "http://scrapethissite.com/pages/forms/?page_num=17"
[18] "http://scrapethissite.com/pages/forms/?page_num=18"
[19] "http://scrapethissite.com/pages/forms/?page_num=19"
[20] "http://scrapethissite.com/pages/forms/?page_num=20"
[21] "http://scrapethissite.com/pages/forms/?page_num=21"
[22] "http://scrapethissite.com/pages/forms/?page_num=22"
[23] "http://scrapethissite.com/pages/forms/?page_num=23"
[24] "http://scrapethissite.com/pages/forms/?page_num=24"

But what if the URLs were predictable, but did not increase one integer after another, starting from 1? we can handle this by creating a list of URLs using some functions in R, which will concatenate each element of URL, until we for each page have the full URL necessary

In this example we will create URLs which will allow us to download results from political elections in various states in The United States in 2012 and 2016. In this case, instead of number starting with 1 and then increasing one integer at a time, we need to specify in the URL which year the election took place and in which state it took place. To do this we first write the name of our new object. Now we need to concatenate a series of text strings. To indicate that we want to start concatenation we use the crossing function. The first part of our URL will be the base URL, which is https://www.example.com/. Then we add state to the URL. Then we draw a list of states, starting in alphabetical oorder with the first state, Alaska, and going onwards in alphabetical to the 5th state, which is California. Then we need to tell R that for each state, it should create 2 URLs. One with the year number 2012, and one with the year number 2016. So we put them as a vector. Then we need to tell R that it should unite all these elements into one URL, and that there should be no space between the elements that constitute the URL. When then call the URLs with the function pull

R

urls <-
  crossing("https://www.example.com/", 
           "?state=", 
           state.abb[1:5], 
           "?year", 
           c(2012, 2016)) %>% 
  unite("urls", sep = "") %>% 
  pull(urls)

Let us return to the data with hockey results. We know that there are 24 pages with tables containing the statistics, and we have a predictable URL, so we can easily create each URLs for each page. But let us imagine that we have a predictable URL, but we don’t know how many pages there. We need to write a script that will automatically find out how many pages there are. This is really useful if there are 100s of pages, and it would take too long to click to the last page in order to find out how many there are Let us start by scraping the website

R

dat <- 
  bow("http://www.scrapethissite.com/pages/forms/") %>% 
  scrape()

We know need to go to the webpage in our internet browser and inspect the HTML We find that there is an HTML element which shows how many pages there are in total. This element is called “.pagination>li”. We can specify that the specific HTML element that we want to work with the function html_elements. Now we need to draw out the actual content of the selected HTML-element by using the function html_text2. The content that we draw out is a series of numbers, but they are in the format of character. We need to convert them to numbers by using as.numeric we now have a series of all page numbers, from 1 to 24. However, we are only interested in the last page number, which in our case has the highest numerical value. We can therefore isolate this number by using the function max

R

n_pages <- 
  dat %>% 
  html_elements(".pagination>li") %>% 
  html_text2() %>% 
  as.numeric() %>% 
  max(na.rm = TRUE)

WARNING

Warning in dat %>% html_elements(".pagination>li") %>% html_text2() %>% : NAs
introduced by coercion

Now we can create all the URLs by pasting our basic URL with the page numbers, going from 1 until the last page number. Our URLs become a vector with 24 elements, each element being the base URL with a number at the end

R

urls <- paste0("https://scrapethissite.com/pages/forms/?page_num=", 1:n_pages)

Now it is time to scrape data from all the URLs that we have created. First we write the name of our new object. Then we use the map function to tell R that it must do the scrape for each URL in our urls vector. The we use bow for each URL to ensure that scraping is allowed. Lastly, we scrape the pages with the scrape function

R

# downloading multiple pages
dat_all <- 
  map(urls, 
      ~ bow(.x) %>% 
        scrape())

The map function returns a list, where each element is a page.

Now we need to tell R that we want each element in the list to be combined with the other elements, so that our list can be turned into a dataframe. First we write the name of our new dataframe. Then we tell R that it should turn the list elements into a dataframe by using the map_dfr function. We tell R what the name of our list is, and that we want to draw the table from each element in the list, and then we draw the table with html_table

R

# formatting downloaded list into a dataframe
dat_tables <- 
  map_dfr(dat_all, 
          ~ html_elements(.x, "table") %>% 
            html_table())

Content from Scraping tables on pages with unpredictable URLs


Last updated on 2026-08-25 | Edit this page

Lets us start by installing the libraries that will give us the necessary functionality to do web scraping

R

install.packages("polite")
install.packages("rvest")
install.packages("tidyverse")
install.packages("purrr")
install.packages("htmlTable")
install.packages("htmltools")
install.packages("scales")

Lets us now activate our installed libraries to activate their functionality

R

library(polite)
library(rvest)
library(tidyverse)
library(purrr)
library(htmlTable)
library(htmltools)
library(scales)

Scraping pages with unpredictable URLs


What if there are tables on multiple pages that we want to scrape, but the pagination does not give us a predictable URL? i.e. instead of a sequence of integers increasing by one, or letters change in alphabetical order or reverse alphabetic order, there is a custom sequence of letters and digits? There is a way to handle this so that we can scrape all pages in one action, but it requires some inspection of the data and writing some functions

We cannot anticipate what the all the various URLs will be, so we to find a method to automate the finding of all URLs, so that we can scrape all of them in one action.

Let us start by scraping the base URL

R

page1 <- 
  bow("http://www.scrapethissite.com/pages/forms/") %>% 
  scrape()

In order to automate the finding of all URLs, we need to identify in the HTML where there is the button that allows us to go to the next page. We inspect the HTML of the webpage http://www.scrapethissite.com/pages/forms/ and see that there is an HTML element which describes the button that moves us to the next page. This button is a link. A link in HTML has is href. We see that HTML for the next button is aria-label=‘Next’. We need to extract the link from the button that goes to the next page. To do this we write the name of our new object. Then we write the name of object with the scraped webpage. Then we use html_elements o tell R that we want to work with the HTML that constitutes the Next-button. The last thing we need to do is to extract the link itself. To do this we use the function html_attr and write the HTML element for the link, which is <href>

R

# find the next button, write a CSS selector for it, and pull the value of href
next_page <- 
  page1 %>% 
  html_elements("[aria-label='Next']") %>% 
  html_attr("href")

creating the set of URLs

Now that we have the pagination number for the first page, we need to concatenate it with the base URL, with the pagination number coming last

R

# verify that this is a full URL (starting with "http") that we can scrape, and it if not. 
# This is a root-relative path, so prefix it with the website's root URL
next_page <- 
  paste0("http://www.scrapethissite.com", 
         next_page)

Finding the last page

We need to tell R that it should keep scraping pages until it comes to a page where there is no Next-button. So we scrape the last page and draw out the link

R

next_page_final <- 
  bow("http://www.scrapethissite.com/pages/forms/?page_num=24") %>% 
  scrape() %>% 
  html_elements("[aria-label='Next']") %>% 
  html_attr("href")

We need to test if the last page i.e. our objects are indeed our the last page. We use the function is_empty. If the object is the last page, it will give the result TRUE. If the object is not the last page it will give us the value FALSE

R

# testing if condition for both are correct. next_page_final should be TRUE, next_page should be FALSE
is_empty(next_page_final)

OUTPUT

[1] TRUE

R

is_empty(next_page)

OUTPUT

[1] FALSE

Assembling the URLs

Now we need to tell R that it should scrape a page, find the URL for the next page and then scrape that next page until it finds the last page.

First we create an empty list in which our scraped data will be placed into. Then we tell R that the first page to download is the URL for the first page. Then we write our own function. We tell R that it should run this function if the page it scrapes has a page coming after that. Then it should create a link by pasting the base URL with the number of the next pages. Then it should the link that it has created by the paste0 function. The downloaded page should be stored in a list. Then from the scraped webpage it should draw out the link for the next-page button.

R

all_dat <- list()
next_page <- "/pages/forms/?page_num=1"
i <- 0
# proceed if there is a next page from the last scrape
while (!is_empty(next_page)) {
  # increase iteration count
  i <- i + 1
  
  # assemble URL
  link <- 
    paste0("http://www.scrapethissite.com", 
           next_page)
  
  # download current page
  dat <- 
    bow(link) %>% 
    scrape()
  
  # store downloaded page in list
  all_dat[[i]] <- dat
  
  # get link for next page
  next_page <- 
    dat %>% 
    html_elements("[aria-label='Next']") %>% 
    html_attr("href")
  
  # delay for five seconds before next download
  Sys.sleep(5)
}

Now we need to paste the 24 pages into a vector, each element in the vector being a page

R

# downloading multiple pages
urls <- paste0("http://scrapethissite.com/pages/forms/?page_num=", 1:24)

scraping the URLs

Now we scrape each of the 24 URLs that we have created. We have 24 elements in our URL vector, so we use the map function to tell R that it should scrape each URL in our URL vector.

R

dat_all <-
  map(urls,
      ~ bow(.x) %>%
        scrape())

Now we need to tell R that for each page in our list element in our scraped data, it should draw out the table, and combine them together into one dataframe with the map_dfr function

R

# go through each element of the page list and pull out a dataframe
# and then row-bind all of them together
dat_tables <- 
  map_dfr(all_dat, 
          ~ html_elements(.x, "table") %>% 
            html_table())

Content from Scraping text and headers


Last updated on 2026-08-25 | Edit this page

Introduction


R

install.packages("polite")
install.packages("rvest")
install.packages("tidyverse")
install.packages("purrr")
install.packages("htmlTable")
install.packages("htmltools")
install.packages("scales")

R

library(polite)
library(rvest)
library(tidyverse)
library(purrr)
library(htmlTable)
library(htmltools)
library(scales)

In this part of the course, we will now look at how to scrape a few different HTML elements. Specifically, we will look at how to scrape paragraphs and headers. A paragraph is an HTML element that often contains a bulk of text that we can be interested in when we scrape a webpage. We will also scrape headers, which is an HTML element that often describes the content of the webpage or the content of other HTML elements on webpage.

We have found a Wikipedia article in English about plans from United States presidents to acquire Greenland. Wikipedia articles are usually licensed with a Creative Commons license. We therefore expect that we are allowed to scrape the page.

Let us start by using bow to check if the page allows scraping

R

bow("https://en.wikipedia.org/wiki/Proposed_United_States_acquisition_of_Greenland")

OUTPUT

<polite session> https://en.wikipedia.org/wiki/Proposed_United_States_acquisition_of_Greenland
    User-agent: polite R package
    robots.txt: 464 rules are defined for 34 bots
   Crawl delay: 5 sec
  The path is scrapable for this user-agent

scraping the URL

We see that scraping is allowed, so let us scrape the main page

R

dat <- bow("https://en.wikipedia.org/wiki/Proposed_United_States_acquisition_of_Greenland") %>% 
  scrape()

specifying HTML elements to be extracted from the scraped webpage

We have successfully scraped the page. Now we need to extract the HTML elements that we are interested in. In HTML the headers elements start with <h>. The headers exist in a hierarchical fashion. The most overall header is called <h1>. The second most overall header is called <h2> and so on. On the Wikipedia page the headers are <h1>, <h2>, <h3>, and <h4>. So we need to specify each of them when we extract the HTML elements from the our scrape. The headers are useful for designating which sections the article’s text is divided into. But they do not contain the article’s text. The article’s text is in the HTML element <p>. But all the text is not found in one <p> element. It is divided into multiple paragraphs. This is because the text is then displayed in separate paragraphs on the page, which makes the article more readable to the human eye.

We can write p to extract all paragraphs

R

greenland_us_wiki <- html_elements(dat, "h1, h2, h3, h4, p")

We now have the headers and the paragraphs extracted. Now we need to convert their content into a readable format so that we can work with the text. To do this we use the function html_text. This extracts the content of the HTML elements that specified before, i.e. headers and paragraphs. But only having the text content will make us able to discern which texts are headers and which are paragraphs. We will therefore need to use html_name to give us each text’s HTML element.

The best way to format this data is to make it a tibble, which is type of data frame. We therefore create a tibble where the HTML tag is in one column, and the text of that HTML element is in another columns.

R

df_greenland_us <- tibble(
  tag = html_name(greenland_us_wiki),  # Extracts tag names (h1, h2, h3, h4, p)
  text = html_text(greenland_us_wiki, trim = TRUE)  # Extracts clean text
)

Now we have the right alignment of rows and columns where each row is an HTML element with its corresponding text content.

But we would like to have a better understanding and overview of which paragraphs are found under which headings. To do this let us first create a new set of columns. One column for each header, using mutate

Now we need to make sure that each of these header columns has all its cells filled out. This will allow us to for each paragraph see under which header 1 it is and under which header 2 it is under and so on. To do this we use the fill function. Fill allows us to for each of the header columns extend the text value in a cell through all the succeeding empty cells until it reaches a cell that already has text in it. To specify this direction we write that the .direction should be downwards.

R

df_greenland_us <- df_greenland_us %>%
  mutate(
    h1 = ifelse(tag == "h1", text, NA),
    h2 = ifelse(tag == "h2", text, NA),
    h3 = ifelse(tag == "h3", text, NA),
    h4 = ifelse(tag == "h4", text, NA)
  ) %>%
  fill(h1, .direction = "down") %>%  # Fill down h1 for all content
  fill(h2, .direction = "down") %>%  # Fill down h2 until new h2 appears
  fill(h3, .direction = "down") %>%  # Fill down h3 until new h3 appears
  fill(h4, .direction = "down")      # Fill down h4 until new h4 appears

We see that for some unknown reason the first header in the data frame is <h2> and not <h1>. By looking at the article page we see that <h1> is the proper header of the article that encompasses all its content. So we need to remove the rows that come before <h1>

R

# Step 1: Remove everything before the first h1
df_greenland_us <- df_greenland_us %>%
  filter(cumsum(tag == "h1") > 0)  # Keep rows after the first h1 appears

filtering away unwanted columns

In the bottom of our dataframe, we see that there are some rows that are paragraphs and headers but they do not form part of the article text itself. We need to remove them. Because the rows come at then end of the dataframe, we can simply find the first of these superfluous rows and delete it and everything after that.

When this is the case, there is in a header, instead of a usual header text there is the text “See also”. To find the row where “See also” is the text, we use the which function to search the text cells in rows where the tag is a header (except <h1>) and the text starts with “see also”. We specify with “^” that the text must begin with “see also”. We convert the text to lowercase to increase the probability of a match. R is very literal, so it treats “See also” and “see also” as 2 entirely different strings.

R

# Step 2: Find where "See also" appears in an h2, h3, or h4 and remove everything after
see_also_row <- which(df_greenland_us$tag %in% c("h2", "h3", "h4") & 
                        str_detect(str_to_lower(df_greenland_us$text), "^see also"))

We now see the number of rows there are until we reach “see also”. We can now use this knowledge to do a slice so that we only retain the article’s proper text. We start by specifying with if that this action should only be conducted if there is actually a “see also” found in the scrape. If there is not, then nothing should be done.

We use slice to tell R that it should keep the rows beginning from row 1 all the way to the lowest number in see_also_row. The lowest number in see_also_row is the row that contains “see also”. If we were to do this, we would retain the “see also” row in our dataframe but remove everything after it. But the row with “see also” should also be removed. So we use -1 to tell R that it should not include the last row in the slice, i.e. the row containing “see also”.

R

# Only proceed if "See also" is found
if (length(see_also_row) > 0) {
  df_greenland_us <- df_greenland_us %>% slice(1:(min(see_also_row) - 1))  # Keep only rows before "See also"
}

Now we have our data frame in the final format. We can analyze the headers to see which topics are described in the text. We can use text mining methods to analyze how the various topics are described, whether the words used in the various sections are positive or negative, or other sentiments expressed in the text. We can also count the number of words under each <h2> or <h3>, to see which topics are discussed the most.