Scrape a saved job board without losing its source
source data loading
scraping
workforce research
Learn how to extract job titles and training details from structured HTML with R.
The fictional Riverton Workforce Lab begins with six job postings saved from a job board. A worker comparing the notices would need to keep each training offer, schedule, experience requirement, and named skill attached to the right job. Copying the lines into one block would make omissions and source mix-ups hard to audit.
Web scraping uses code to collect information from web pages. The team will scrape the saved page, keep the job and sentence identifiers, and check every record before it becomes research data. This first stage collects what the notices say; it does not classify the sentences or generalize from them.
The whole lesson works on static archived HTML: a file whose text is already in the markup when it is saved. That is the scope of what follows, and the last sections show two ordinary pages where the method stops working.
Note
The employers, postings, and Riverton Workforce Lab are fictional. The HTML file was written for this lesson and contains no personal information.
TipWhat you will learn
By the end of this lesson, you will be able to:
explain how HTML gives parts of a page names and structure;
select repeated job cards with rvest;
keep source identifiers beside extracted text;
compare scraped output with an expected fixture;
notice a renamed class and a page whose text is not in the markup; and
identify legal, ethical, and technical checks needed before live scraping.
Begin with an archived page
Live websites change. A saved source file lets another researcher inspect the same page later. The team records the file path, size, and fingerprint before extracting anything.
library(readr)library(dplyr)library(tibble)library(purrr)library(stringr)library(digest)library(rvest)job_page_path <-"data/workforce/job-board.html"job_page_lines <-read_lines(job_page_path)job_page_fingerprint <- digest::digest(paste(job_page_lines, collapse ="\n"),algo ="md5",serialize =FALSE)job_page_metadata <-read_csv("data/workforce/job-board-metadata.csv",col_types =cols(source_file =col_character(),created_for_lesson =col_character(),retrieval_status =col_character(),records =col_character(),md5 =col_character(),rights_note =col_character() ))# A size read from disk counts the line endings the local copy happens to# use, so it changes between systems. Measuring the newline-joined text, the# same text the fingerprint covers, describes the content instead.job_page_size <-nchar(paste(job_page_lines, collapse ="\n"),type ="bytes")tibble(source_file = job_page_path,bytes = job_page_size,fingerprint = job_page_fingerprint)
The fingerprint is compared with a stored value. It changes if the archived HTML changes and can reveal an accidental edit. It cannot prove that the postings are true or complete.
Read the page structure
HTML uses elements such as headings, paragraphs, and lists to organize a page. The example page places every posting inside an element whose class is job-card. A CSS selector is a short pattern that names the elements to collect.
The exact comparison succeeds: all 22 sentences match the fixture in the same order. This check would stop the build if a selector dropped, duplicated, or reordered text.
Both the HTML and expected table were written for this lesson, so the comparison tests parser behavior rather than independent truth. A live collection would need a separately captured audit sample.
When the page changes shape
The archived file is stable because it is a file. A live board is maintained by people who redesign it. The two pages below are written into the lesson as short HTML strings, and both would break the code above in ways worth telling apart.
The first is a redesign. The employer’s new template renames job-card to posting and moves the title into a heading.
The same selector against two versions of one site
Page
Selector
Cards found
Cards expected
archived board
.job-card
6
6
redesigned page
.job-card
0
1
Zero matches is the dangerous result, because zero matches is also what an empty page produces. Without the expected count, the scraper would write an empty table and the day’s collection would look like a day with no postings. An expected count turns an unreported redesign into a stop.
The second page is harder. Its visible container has no job text. The same file stores a JSON payload in a script element that application code could read and render later. This fixture does not execute that JavaScript; it separates visible markup from embedded data.
The container holds no characters. The job title exists only in the embedded script payload, which rvest can read as text but does not render into the container.
This example is deliberately gentle because the payload is already in the file. Many sites fetch data separately after the page loads. A saved copy of their initial HTML may contain nothing to extract, and an HTML parser cannot recover a response that was never saved.
Three routes exist from there, and they differ in cost and in what they promise. A documented API returns the same data with stated terms, and is the first thing to look for. The underlying data request the page itself makes can sometimes be called directly, which is faster but relies on an interface nobody promised to keep. A headless browser, driven from R through chromote or from Python through Playwright or Selenium, runs the page as a browser would; it is the general answer and by far the most expensive to run and maintain.
Whichever route is chosen, record it. Text recovered from a rendered page and text read from a saved file are not the same evidence, and a reader deserves to know which one produced a number.
A saved page is still incomplete evidence
The archived HTML shows what was captured, not every posting that existed. Before scraping a live site, the team must check:
the site’s terms and robots policy;
whether collection is lawful and expected in the relevant jurisdiction;
whether personal or sensitive information appears;
how often requests may be sent;
whether page content is loaded later by JavaScript;
which dates, filters, and geographic limits shaped the results; and
how the saved HTML and retrieval time will be preserved.
The Robots Exclusion Protocol communicates crawler preferences. It is not a grant of legal permission, and permission does not settle every ethical question. A public page can still contain information that should not be collected or republished.
What to remember
Scraping turns page structure into data; it does not establish that the data is true or representative.
Archive the source and record when it was collected.
Keep source IDs attached to extracted text.
Assert expected counts and content so selector failures cannot return zero rows without an error.
Zero matches and an empty page look identical without an expected count.
Some pages hold their text outside the markup, where an HTML parser cannot reach it.
Review terms, rate limits, privacy, and research ethics before live collection.
The team can now inspect 22 sentences without detaching them from the six notices. One source remains outside that table: a fictional training flyer saved as an image. That image shifts the investigation from HTML structure to OCR. Its source must still be preserved, and the extracted wording must be checked rather than treated as certain.